Merge remote-tracking branch 'origin/main'

# Conflicts:
#	mobile/lib/screens/conversation_list_screen.dart
#	mobile/pubspec.lock
This commit is contained in:
Claude 2026-07-31 08:23:28 +00:00
commit bb3106434b
No known key found for this signature in database
53 changed files with 1987 additions and 268 deletions

24
.dockerignore Normal file
View File

@ -0,0 +1,24 @@
.git
**/.git
**/__pycache__
**/*.pyc
**/.pytest_cache
**/.dart_tool
**/.env
.env
**/*.db
poc/tone-corpus/data
**/node_modules
mobile/android/.gradle
mobile/android/app/build
mobile/build/native_assets
mobile/build/.last_build_id
**/.idea
**/.vscode
mobile/devtools_options.yaml
mobile/test/goldens
# Allow mobile/build/web for Dockerfile.prebuilt
!mobile/build/
!mobile/build/web/
!mobile/build/web/**

View File

@ -5,9 +5,25 @@ GEMINI_API_KEY=
# core-backend privileged endpoints (/invites, /admin/metrics, /admin/dashboard, /admin/push-test) # core-backend privileged endpoints (/invites, /admin/metrics, /admin/dashboard, /admin/push-test)
ADMIN_API_TOKEN= ADMIN_API_TOKEN=
# Shared demo invite DEMO-YKAVU on signup UI (multiple testers). Set 0 in production. # Shared demo invite DEMO-YKAVU on signup UI (multiple testers). Set 0 to disable.
ALLOW_DEMO_INVITE=1 ALLOW_DEMO_INVITE=1
# Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test). # FCM push (escalation notify + /admin/push-test). Prefer HTTP v1 service account:
# Without this, notifyUser soft-skips and records push_skipped metrics. # place JSON at secrets/firebase-service-account.json (see docs/fcm-setup.md)
# Optional legacy key (often disabled in new Firebase projects):
FCM_SERVER_KEY= FCM_SERVER_KEY=
FCM_SERVICE_ACCOUNT_FILE=/secrets/firebase-service-account.json
# FCM_SERVICE_ACCOUNT_JSON= # alternative: paste minified JSON (avoid if possible)
# --- docker compose (N2-B / msn.iykyka.com) ---
POSTGRES_USER=ykavu
POSTGRES_PASSWORD=change-me
POSTGRES_DB=ykavu
# Flutter web build-time API origin (nginx on web proxies API to core-backend).
# Production: https://msn.iykyka.com
# Local compose smoke: http://localhost:8088
PUBLIC_API_BASE=https://msn.iykyka.com
# Host port mapped to web:80
WEB_HOST_PORT=8088

10
.gitignore vendored
View File

@ -9,6 +9,11 @@ poc/tone-corpus/data/
.env.* .env.*
!.env.example !.env.example
# Firebase / FCM service account JSON (never commit)
secrets/**
!secrets/.gitkeep
!secrets/README.md
__pycache__/ __pycache__/
*.pyc *.pyc
@ -17,3 +22,8 @@ __pycache__/
# Go build output (core-backend/) # Go build output (core-backend/)
/core-backend/core-backend /core-backend/core-backend
# Flutter build output (use Docker multi-stage or Dockerfile.prebuilt locally)
mobile/build/
mobile/.dart_tool/
mobile/.flutter-plugins-dependencies

View File

@ -90,6 +90,9 @@ changes it.
- Before starting any Phase 1 app-build task, check `docs/roadmap.md`'s - Before starting any Phase 1 app-build task, check `docs/roadmap.md`'s
"Phase 1 상세 작업 분해" checklist for what's already done and what's next. "Phase 1 상세 작업 분해" checklist for what's already done and what's next.
- After Phase 1 A~C, use [`docs/deploy-checklist.md`](docs/deploy-checklist.md)
(N1→N5) for smoke, Docker deploy, stabilize, FCM/Android QA, then human PoC.
Keep that file and `roadmap.md` in sync when status changes.
- Follow the "권장 착수 순서" there — don't skip ahead in the numbered order - Follow the "권장 착수 순서" there — don't skip ahead in the numbered order
without a reason, and note the reason in the checklist if you do. without a reason, and note the reason in the checklist if you do.
- When a task is finished, check it off in that same checklist. When you - When a task is finished, check it off in that same checklist. When you
@ -113,6 +116,24 @@ Do not invent frameworks, folder layouts, or CI conventions beyond what
in sync. in sync.
- Prefer updating existing docs over creating parallel overlapping docs. - Prefer updating existing docs over creating parallel overlapping docs.
## Git remotes & branch policy (Master)
Master policy for this repo — follow even when a cloud agent default suggests
feature branches or a non-`main` base:
1. **Work on `main`.** Commit and land changes on `main` (fast-forward or merge
into `main`). Do not leave finished work only on long-lived side branches
unless Master explicitly asks for a temporary branch.
2. **Dual remote sync after every `main` update.**
- `origin` = GitHub `o0kuma/hikikomori`
- `gitea` = Gitea `gitea.iykyka.com/oh/iykyka` (iykyka)
- Push both: `./scripts/push-both.sh main` (or `git push origin main` then
`git push gitea main`).
3. Never commit secrets (tokens, API keys). Gitea/GitHub credentials stay in
local/env only.
4. If a PR was opened for tooling reasons, merge it into `main` and dual-push;
then close the PR. Keep `gitea/main` even with `origin/main`.
## Communication with agents ## Communication with agents
- Read the relevant docs before proposing product/tech changes. - Read the relevant docs before proposing product/tech changes.

View File

@ -4,13 +4,18 @@ Follow the project instructions in [@AGENTS.md](./AGENTS.md).
Quick context: Quick context:
- Phase 1 A~C are in place (`core-backend/`, `ai-service/`, `mobile/`). Next is - Phase 1 A~C are in place (`core-backend/`, `ai-service/`, `mobile/`).
**D — human PoC** (`docs/roadmap.md`). Do not start PoC early or invent §3 defaults. **Next execution track:** N1 smoke → N2 Docker (`msn.iykyka.com`) → N3 stabilize →
N4 FCM/Android QA → **N5 / D human PoC last** — see
[`docs/deploy-checklist.md`](./docs/deploy-checklist.md). Do not start human PoC
early or invent Phase 1 §3 defaults.
- Working product name: **와카뷰** (가칭 확정). - Working product name: **와카뷰** (가칭 확정).
- Source of decisions: `docs/decision-log.md` (Q1~Q7 **확정**; PoC sub-questions open). - Source of decisions: `docs/decision-log.md` (Q1~Q7 **확정**; PoC sub-questions open).
- v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first. - v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first.
- Hard bans for v1: L3/L4, OS-layer over third-party messengers, B2B. - Hard bans for v1: L3/L4, OS-layer over third-party messengers, B2B.
- Never weaken escalation, twin badge, peer veto, or undo. - Never weaken escalation, twin badge, peer veto, or undo.
- **Git:** work and land on `main`; after each push to GitHub `origin`, also push
`main` to Gitea `gitea` (`scripts/push-both.sh`). See `AGENTS.md`.
Before changing product or technical direction, read `AGENTS.md` and the Before changing product or technical direction, read `AGENTS.md` and the
relevant files under `docs/`. relevant files under `docs/`.

View File

@ -18,17 +18,22 @@
- 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) (Q1~Q7 **확정**) - 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) (Q1~Q7 **확정**)
- Vision / PRD / 기술설계: [`docs/vision.md`](./docs/vision.md) · [`docs/PRD.md`](./docs/PRD.md) · [`docs/tech-design.md`](./docs/tech-design.md) - Vision / PRD / 기술설계: [`docs/vision.md`](./docs/vision.md) · [`docs/PRD.md`](./docs/PRD.md) · [`docs/tech-design.md`](./docs/tech-design.md)
- 로드맵 (작업 체크리스트): [`docs/roadmap.md`](./docs/roadmap.md) - 로드맵 (작업 체크리스트): [`docs/roadmap.md`](./docs/roadmap.md)
- **배포·잔여 실행 트랙:** [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) (N1 스모크 → N2 Docker → … → N5 사람 PoC)
- 베타 직전(C): [`docs/invite-ops.md`](./docs/invite-ops.md) · [`docs/android-release.md`](./docs/android-release.md) · [`docs/prototype.md`](./docs/prototype.md) - 베타 직전(C): [`docs/invite-ops.md`](./docs/invite-ops.md) · [`docs/android-release.md`](./docs/android-release.md) · [`docs/prototype.md`](./docs/prototype.md)
- PoC 계획/준비물: [`docs/poc-plan.md`](./docs/poc-plan.md) · [`docs/poc-materials.md`](./docs/poc-materials.md) - PoC 계획/준비물: [`docs/poc-plan.md`](./docs/poc-plan.md) · [`docs/poc-materials.md`](./docs/poc-materials.md)
## AI 에이전트 규칙 ## AI 에이전트 규칙
- [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md) - [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md)
- **브랜치:** 작업·머지는 항상 `main`
- **리모트:** GitHub `origin` + Gitea `gitea`(iykyka) — `main` 갱신 후 `./scripts/push-both.sh`
## 현재 단계 ## 현재 단계
- Phase 1 **A~C**까지 반영됨 (서버·Flutter·베타 직전 문서/배포 경로) - Phase 1 **A~C** + **N2 컷오버 + N3 안정화 완료** — 라이브: [`https://msn.iykyka.com`](https://msn.iykyka.com)
- 다음: **D — 사람 PoC #1/#3·Q3 인터뷰** (`docs/roadmap.md` §3, 맨 마지막) - 테스터 안내: [`docs/tester-guide.md`](./docs/tester-guide.md) · 데모 코드 **`DEMO-YKAVU`**
- **다음 (선택):** N4 FCM·Android QA
- **맨 마지막:** N5 / D — 사람 PoC (`docs/roadmap.md` §3). §3 기본값 추측 금지
- 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입 - 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입
## 로컬 실행 (요약) ## 로컬 실행 (요약)
@ -50,3 +55,18 @@ go run . migrate && go run .
# 터미널 3 — Flutter (Android) # 터미널 3 — Flutter (Android)
cd mobile && flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080 cd mobile && flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080
``` ```
## Docker (N2-B / `msn.iykyka.com`)
- 구성: [`docs/deploy-docker.md`](./docs/deploy-docker.md)
- Portainer·컷오버: [`docs/deploy-portainer.md`](./docs/deploy-portainer.md)
- 서버 기동: `./scripts/server-up.sh` (호스트에서 `.env` 채운 뒤)
```bash
cp .env.example .env # ADMIN_API_TOKEN, POSTGRES_PASSWORD 필수
# 로컬: PUBLIC_API_BASE=http://localhost:8088
docker compose up -d --build
curl -sS http://localhost:8088/health
```
Postgres·AI는 내부망만. 엣지 프록시는 `web:80`(또는 `WEB_HOST_PORT`)만 공개.

7
ai-service/.dockerignore Normal file
View File

@ -0,0 +1,7 @@
**/__pycache__
**/.pytest_cache
tests
.git
*.md
Dockerfile
.env

20
ai-service/Dockerfile Normal file
View File

@ -0,0 +1,20 @@
# ai-service — FastAPI (internal only; do not publish host ports in compose).
FROM python:3.12-slim
WORKDIR /app
RUN useradd --system --uid 10001 --create-home app \
&& apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
USER app
EXPOSE 8001
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
CMD curl -fsS http://127.0.0.1:8001/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]

View File

@ -0,0 +1,5 @@
**/*.db
**/__pycache__
.git
*.md
Dockerfile

31
core-backend/Dockerfile Normal file
View File

@ -0,0 +1,31 @@
# core-backend — Go (Gin). Production DB via DATABASE_URL (Postgres).
# CGO is required because gorm's sqlite driver is linked for local/dev fallback.
FROM golang:1.24-bookworm AS build
WORKDIR /src
# go.mod may request a newer toolchain than the image — let Go fetch it.
ENV GOTOOLCHAIN=auto
ENV CGO_ENABLED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libc6-dev \
&& rm -rf /var/lib/apt/lists/*
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /out/core-backend .
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --create-home app
COPY --from=build /out/core-backend /usr/local/bin/core-backend
USER app
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
CMD curl -fsS http://127.0.0.1:8080/health || exit 1
CMD ["core-backend"]

View File

@ -20,6 +20,21 @@ type createContactRequest struct {
RelationshipNote string `json:"relationship_note"` RelationshipNote string `json:"relationship_note"`
} }
type updateContactRequest struct {
DisplayName string `json:"display_name" binding:"required"`
ContactUserID *uint `json:"contact_user_id"`
RelationshipNote string `json:"relationship_note"`
}
func contactJSON(ct Contact) gin.H {
return gin.H{
"id": ct.ID,
"display_name": ct.DisplayName,
"contact_user_id": ct.ContactUserID,
"relationship_note": ct.RelationshipNote,
}
}
type loginRequest struct { type loginRequest struct {
InviteCode string `json:"invite_code" binding:"required"` InviteCode string `json:"invite_code" binding:"required"`
} }
@ -242,12 +257,7 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, contactJSON(contact))
"id": contact.ID,
"display_name": contact.DisplayName,
"contact_user_id": contact.ContactUserID,
"relationship_note": contact.RelationshipNote,
})
}) })
r.GET("/users/:id/contacts", func(c *gin.Context) { r.GET("/users/:id/contacts", func(c *gin.Context) {
@ -262,16 +272,47 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
db.Where("owner_user_id = ?", userID).Order("id").Find(&contacts) db.Where("owner_user_id = ?", userID).Order("id").Find(&contacts)
out := make([]gin.H, 0, len(contacts)) out := make([]gin.H, 0, len(contacts))
for _, ct := range contacts { for _, ct := range contacts {
out = append(out, gin.H{ out = append(out, contactJSON(ct))
"id": ct.ID,
"display_name": ct.DisplayName,
"contact_user_id": ct.ContactUserID,
"relationship_note": ct.RelationshipNote,
})
} }
c.JSON(http.StatusOK, gin.H{"contacts": out}) c.JSON(http.StatusOK, gin.H{"contacts": out})
}) })
r.PATCH("/users/:id/contacts/:contactId", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
if !requireSelf(c, db, userID) {
return
}
contactID, ok := parseUintParam(c, "contactId")
if !ok {
return
}
var req updateContactRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
var contact Contact
if err := db.Where("id = ? AND owner_user_id = ?", contactID, userID).First(&contact).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "contact not found"})
return
}
if req.ContactUserID != nil && *req.ContactUserID == userID {
c.JSON(http.StatusBadRequest, gin.H{"detail": "cannot set contact_user_id to yourself"})
return
}
contact.DisplayName = req.DisplayName
contact.ContactUserID = req.ContactUserID
contact.RelationshipNote = req.RelationshipNote
if err := db.Save(&contact).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, contactJSON(contact))
})
r.DELETE("/users/:id/contacts/:contactId", func(c *gin.Context) { r.DELETE("/users/:id/contacts/:contactId", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id") userID, ok := parseUintParam(c, "id")
if !ok { if !ok {

View File

@ -7,6 +7,46 @@ import (
"testing" "testing"
) )
func TestPatchContactSetsPeerUserID(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "주인")
peerID, _ := mustSignup(t, server.URL, "상대")
createResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "이름만",
})
if createResp.StatusCode != http.StatusOK {
t.Fatalf("create contact without peer: %d", createResp.StatusCode)
}
var created map[string]interface{}
json.NewDecoder(createResp.Body).Decode(&created)
contactID := uint(created["id"].(float64))
if created["contact_user_id"] != nil {
t.Fatalf("expected null peer id, got %v", created["contact_user_id"])
}
patchResp := patchJSONAuth(
t,
server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts/"+strconv.FormatUint(uint64(contactID), 10),
ownerToken,
updateContactRequest{
DisplayName: "상대방",
ContactUserID: &peerID,
},
)
if patchResp.StatusCode != http.StatusOK {
t.Fatalf("patch contact: %d", patchResp.StatusCode)
}
var updated map[string]interface{}
json.NewDecoder(patchResp.Body).Decode(&updated)
if uint(updated["contact_user_id"].(float64)) != peerID {
t.Fatalf("expected peer %d, got %v", peerID, updated["contact_user_id"])
}
if updated["display_name"] != "상대방" {
t.Fatalf("display_name: %v", updated["display_name"])
}
}
func TestInvitesRequireAdminToken(t *testing.T) { func TestInvitesRequireAdminToken(t *testing.T) {
server, _ := setupTestServer(t) server, _ := setupTestServer(t)
resp := postJSON(t, server.URL+"/invites", nil) resp := postJSON(t, server.URL+"/invites", nil)

View File

@ -95,7 +95,7 @@ func TestRevokeSessionAndPushTestWithoutFCM(t *testing.T) {
t.Fatalf("revoke: %d", del.StatusCode) t.Fatalf("revoke: %d", del.StatusCode)
} }
// Push test without FCM_SERVER_KEY should soft-skip. // Push test without FCM credentials should soft-skip.
push := postJSONAuth(t, server.URL+"/admin/push-test", "test-admin-token", map[string]interface{}{ push := postJSONAuth(t, server.URL+"/admin/push-test", "test-admin-token", map[string]interface{}{
"user_id": userID, "user_id": userID,
"title": "t", "title": "t",

View File

@ -63,6 +63,17 @@ func registerDemoRoutes(r *gin.Engine) {
"demo_invite_code": demoInviteCode, "demo_invite_code": demoInviteCode,
"demo_display_name": "테스터", "demo_display_name": "테스터",
"hint": "회원가입 화면에 표시된 테스트 코드를 그대로 쓰면 됩니다.", "hint": "회원가입 화면에 표시된 테스트 코드를 그대로 쓰면 됩니다.",
"pairing_steps": []string{
"두 명이 같은 DEMO-YKAVU 코드로 각각 가입한다 (시크릿/다른 브라우저).",
"각자 대화 목록의 내 사용자 ID를 복사해 상대에게 알려 준다.",
"연락처에 상대 표시 이름 + 숫자 ID를 넣고 추가한 뒤 「대화」를 누른다.",
"메시지를 보내고, 자율성 L1에서 와카뷰 초안을 한 번 승인·전송해 본다.",
"L0(비서)에서는 초안을 「입력창으로 옮기기」만 되며 — 직접 보낸다.",
},
"notes": []string{
"대화는 표시 이름이 아니라 숫자 사용자 ID로 연결됩니다.",
"ID 없는 옛 연락처는 연락처 화면에서 「ID 입력」으로 고치면 됩니다.",
},
}) })
}) })
} }

View File

@ -24,6 +24,10 @@ func TestDemoInviteReusableForMultipleSignups(t *testing.T) {
if demo["demo_invite_code"] != demoInviteCode { if demo["demo_invite_code"] != demoInviteCode {
t.Fatalf("demo code: %v", demo["demo_invite_code"]) t.Fatalf("demo code: %v", demo["demo_invite_code"])
} }
steps, ok := demo["pairing_steps"].([]any)
if !ok || len(steps) < 3 {
t.Fatalf("expected pairing_steps on /demo, got %#v", demo["pairing_steps"])
}
a := postJSON(t, server.URL+"/auth/signup", signupRequest{ a := postJSON(t, server.URL+"/auth/signup", signupRequest{
InviteCode: demoInviteCode, InviteCode: demoInviteCode,

View File

@ -3,19 +3,27 @@ module hikikomori/core-backend
go 1.25.0 go 1.25.0
require ( require (
github.com/gin-gonic/gin v1.12.0
github.com/gorilla/websocket v1.5.3
golang.org/x/oauth2 v0.36.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.2
)
require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.12.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect
@ -42,7 +50,4 @@ require (
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.10 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect
gorm.io/gorm v1.31.2 // indirect
) )

View File

@ -1,3 +1,5 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@ -7,6 +9,7 @@ github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCc
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
@ -14,6 +17,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
@ -24,6 +29,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@ -56,6 +63,7 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
@ -71,18 +79,24 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -94,6 +108,7 @@ google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aO
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=

View File

@ -105,6 +105,21 @@ func postJSONAuth(t *testing.T, url, token string, body interface{}) *http.Respo
return resp return resp
} }
func patchJSONAuth(t *testing.T, url, token string, body interface{}) *http.Response {
t.Helper()
b, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPatch, url, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("patch %s: %v", url, err)
}
return resp
}
func deleteJSONAuth(t *testing.T, url, token string) *http.Response { func deleteJSONAuth(t *testing.T, url, token string) *http.Response {
t.Helper() t.Helper()
req, _ := http.NewRequest(http.MethodDelete, url, nil) req, _ := http.NewRequest(http.MethodDelete, url, nil)

View File

@ -2,22 +2,49 @@ package main
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"strings" "strings"
"sync"
"time" "time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"gorm.io/gorm" "gorm.io/gorm"
) )
// FCM legacy HTTP API (server key). When FCM_SERVER_KEY is unset, notifyUser const fcmMessagingScope = "https://www.googleapis.com/auth/firebase.messaging"
// records a metric and returns without error so product flows stay usable.
// FCM_SERVER_KEY = legacy HTTP API (often disabled in new Firebase projects).
// Prefer FCM HTTP v1 via service account:
// FCM_SERVICE_ACCOUNT_FILE=/path/to.json
// or FCM_SERVICE_ACCOUNT_JSON='{"type":"service_account",...}'
func fcmServerKey() string { func fcmServerKey() string {
return strings.TrimSpace(os.Getenv("FCM_SERVER_KEY")) return strings.TrimSpace(os.Getenv("FCM_SERVER_KEY"))
} }
func loadFCMServiceAccountJSON() ([]byte, error) {
if path := strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_FILE")); path != "" {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read FCM_SERVICE_ACCOUNT_FILE: %w", err)
}
return bytes.TrimSpace(b), nil
}
if raw := strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_JSON")); raw != "" {
return []byte(raw), nil
}
return nil, nil
}
type fcmServiceAccount struct {
ProjectID string `json:"project_id"`
}
type fcmLegacyPayload struct { type fcmLegacyPayload struct {
To string `json:"to,omitempty"` To string `json:"to,omitempty"`
Registration []string `json:"registration_ids,omitempty"` Registration []string `json:"registration_ids,omitempty"`
@ -26,20 +53,52 @@ type fcmLegacyPayload struct {
Data map[string]string `json:"data,omitempty"` Data map[string]string `json:"data,omitempty"`
} }
func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]string) (sent int, skippedReason string, err error) { type fcmV1MessageRequest struct {
var tokens []DeviceToken Message fcmV1Message `json:"message"`
db.Where("user_id = ?", userID).Find(&tokens)
if len(tokens) == 0 {
return 0, "no_device_tokens", nil
} }
key := fcmServerKey() type fcmV1Message struct {
if key == "" { Token string `json:"token"`
runtimeMetrics.recordPush(0, true) Notification map[string]string `json:"notification,omitempty"`
return 0, "fcm_not_configured", nil Data map[string]string `json:"data,omitempty"`
} }
// Skip placeholder install:* tokens — they are not real FCM registration IDs. var (
fcmTokenMu sync.Mutex
fcmTokenSource oauth2.TokenSource
fcmProjectID string
)
func fcmV1Ready(ctx context.Context) (projectID string, ts oauth2.TokenSource, err error) {
fcmTokenMu.Lock()
defer fcmTokenMu.Unlock()
if fcmTokenSource != nil && fcmProjectID != "" {
return fcmProjectID, fcmTokenSource, nil
}
raw, err := loadFCMServiceAccountJSON()
if err != nil {
return "", nil, err
}
if len(raw) == 0 {
return "", nil, nil
}
var sa fcmServiceAccount
if err := json.Unmarshal(raw, &sa); err != nil {
return "", nil, fmt.Errorf("parse service account json: %w", err)
}
if strings.TrimSpace(sa.ProjectID) == "" {
return "", nil, fmt.Errorf("service account json missing project_id")
}
creds, err := google.CredentialsFromJSON(ctx, raw, fcmMessagingScope)
if err != nil {
return "", nil, fmt.Errorf("fcm credentials: %w", err)
}
fcmProjectID = sa.ProjectID
fcmTokenSource = creds.TokenSource
return fcmProjectID, fcmTokenSource, nil
}
func collectFCMRegistrationIDs(tokens []DeviceToken) []string {
regIDs := make([]string, 0, len(tokens)) regIDs := make([]string, 0, len(tokens))
for _, t := range tokens { for _, t := range tokens {
if strings.HasPrefix(t.Token, "install:") { if strings.HasPrefix(t.Token, "install:") {
@ -47,11 +106,99 @@ func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]st
} }
regIDs = append(regIDs, t.Token) regIDs = append(regIDs, t.Token)
} }
return regIDs
}
func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]string) (sent int, skippedReason string, err error) {
var tokens []DeviceToken
db.Where("user_id = ?", userID).Find(&tokens)
if len(tokens) == 0 {
return 0, "no_device_tokens", nil
}
regIDs := collectFCMRegistrationIDs(tokens)
if len(regIDs) == 0 { if len(regIDs) == 0 {
runtimeMetrics.recordPush(0, true) runtimeMetrics.recordPush(0, true)
return 0, "only_placeholder_tokens", nil return 0, "only_placeholder_tokens", nil
} }
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
projectID, ts, v1err := fcmV1Ready(ctx)
if v1err != nil {
runtimeMetrics.recordPush(0, true)
return 0, "", v1err
}
if projectID != "" && ts != nil {
n, err := sendFCMv1(ctx, projectID, ts, regIDs, title, body, data)
if err != nil {
runtimeMetrics.recordPush(0, true)
return 0, "", err
}
runtimeMetrics.recordPush(n, false)
return n, "", nil
}
// Legacy fallback when service account is not configured.
key := fcmServerKey()
if key == "" {
runtimeMetrics.recordPush(0, true)
return 0, "fcm_not_configured", nil
}
n, err := sendFCMLegacy(key, regIDs, title, body, data)
if err != nil {
runtimeMetrics.recordPush(0, true)
return 0, "", err
}
runtimeMetrics.recordPush(n, false)
return n, "", nil
}
func sendFCMv1(ctx context.Context, projectID string, ts oauth2.TokenSource, regIDs []string, title, body string, data map[string]string) (int, error) {
tok, err := ts.Token()
if err != nil {
return 0, fmt.Errorf("fcm access token: %w", err)
}
client := &http.Client{Timeout: 10 * time.Second}
url := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", projectID)
sent := 0
var lastErr error
for _, reg := range regIDs {
payload := fcmV1MessageRequest{
Message: fcmV1Message{
Token: reg,
Notification: map[string]string{"title": title, "body": body},
Data: data,
},
}
raw, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return sent, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
resp, err := client.Do(req)
if err != nil {
lastErr = err
continue
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode >= 300 {
lastErr = fmt.Errorf("fcm v1 returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
continue
}
sent++
}
if sent == 0 && lastErr != nil {
return 0, lastErr
}
return sent, nil
}
func sendFCMLegacy(key string, regIDs []string, title, body string, data map[string]string) (int, error) {
payload := fcmLegacyPayload{ payload := fcmLegacyPayload{
Registration: regIDs, Registration: regIDs,
Priority: "high", Priority: "high",
@ -61,7 +208,7 @@ func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]st
raw, _ := json.Marshal(payload) raw, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, "https://fcm.googleapis.com/fcm/send", bytes.NewReader(raw)) req, err := http.NewRequest(http.MethodPost, "https://fcm.googleapis.com/fcm/send", bytes.NewReader(raw))
if err != nil { if err != nil {
return 0, "", err return 0, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "key="+key) req.Header.Set("Authorization", "key="+key)
@ -69,14 +216,11 @@ func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]st
client := &http.Client{Timeout: 8 * time.Second} client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
runtimeMetrics.recordPush(0, true) return 0, err
return 0, "", err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 300 { if resp.StatusCode >= 300 {
runtimeMetrics.recordPush(0, true) return 0, fmt.Errorf("fcm returned %d", resp.StatusCode)
return 0, "", fmt.Errorf("fcm returned %d", resp.StatusCode)
} }
runtimeMetrics.recordPush(len(regIDs), false) return len(regIDs), nil
return len(regIDs), "", nil
} }

34
deploy/nginx-web.conf Normal file
View File

@ -0,0 +1,34 @@
# Flutter web + reverse-proxy to core-backend (same origin for msn.iykyka.com).
# API/WS paths match core-backend routes used by mobile/lib.
# Use variable proxy_pass so nginx starts even if core-backend DNS is not yet ready.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Docker Compose embedded DNS
resolver 127.0.0.11 valid=10s ipv6=off;
# Flutter SPA
location / {
try_files $uri $uri/ /index.html;
}
# Core API + WebSocket (internal service name from docker-compose)
location ~ ^/(health|demo|auth|invites|admin|conversations|messages|users|ws)(/|$) {
set $upstream_core core-backend:8080;
proxy_pass http://$upstream_core;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}

98
docker-compose.yml Normal file
View File

@ -0,0 +1,98 @@
# 와카뷰 — msn.iykyka.com (Plan A / N2-B)
# Postgres + core-backend + ai-service(internal) + Flutter web (nginx proxies API).
#
# Usage:
# cp .env.example .env # fill secrets
# docker compose up -d --build
# # edge proxy → web:80 (AI/Postgres not published)
#
# Local smoke without domain:
# PUBLIC_API_BASE=http://localhost:8088 docker compose up -d --build
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-ykavu}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_DB: ${POSTGRES_DB:-ykavu}
volumes:
- ykavu_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ykavu} -d ${POSTGRES_DB:-ykavu}"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
# N2-A: no host port — internal only
ai-service:
build:
context: ./ai-service
environment:
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8001/health"]
interval: 10s
timeout: 3s
retries: 5
restart: unless-stopped
# N2-A3: internal only — do not publish ports
core-backend:
build:
context: ./core-backend
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-ykavu}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-ykavu}?sslmode=disable
AI_SERVICE_URL: http://ai-service:8001
ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?set ADMIN_API_TOKEN in .env}
ALLOW_DEMO_INVITE: ${ALLOW_DEMO_INVITE:-1}
FCM_SERVER_KEY: ${FCM_SERVER_KEY:-}
# Prefer HTTP v1 (service account). Mount secrets/firebase-service-account.json on the host.
FCM_SERVICE_ACCOUNT_FILE: ${FCM_SERVICE_ACCOUNT_FILE:-/secrets/firebase-service-account.json}
FCM_SERVICE_ACCOUNT_JSON: ${FCM_SERVICE_ACCOUNT_JSON:-}
volumes:
- ./secrets:/secrets:ro
depends_on:
postgres:
condition: service_healthy
ai-service:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 10s
timeout: 3s
retries: 5
restart: unless-stopped
# Not published — reached via web nginx proxy (same origin)
web:
build:
context: .
dockerfile: mobile/Dockerfile
args:
CORE_API_BASE: ${PUBLIC_API_BASE:-https://msn.iykyka.com}
ports:
- "${WEB_HOST_PORT:-8088}:80"
depends_on:
core-backend:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]
interval: 10s
timeout: 3s
retries: 5
restart: unless-stopped
# Join Nginx Proxy Manager network so msn.iykyka.com can reach web by name.
networks:
- default
- npm
volumes:
ykavu_pgdata:
networks:
default:
npm:
external: true
name: nginx-proxy_default

View File

@ -29,18 +29,18 @@
## 2. 먼저 확정해야 할 결정 (회의 Q1~Q7) ## 2. 먼저 확정해야 할 결정 (회의 Q1~Q7)
기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다. 기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다.
현재 작업용 답은 [`decision-log.md`](./decision-log.md)에 있으며, 상태는 모두 **제안(잠정)** 이다. 현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30).
회의에서 정식 확정되기 전까지는 decision-log를 단일 기준으로 따른다. PoC 의존 하위 질문(자율성 기본값 등)만 열려 있다 — decision-log를 단일 기준으로 따른다.
| # | 질문 | 잠정 결정 | 상태 | | # | 질문 | 결정 | 상태 |
|---|------|------|------| |---|------|------|------|
| Q1 | AI 와카뷰로 확정? | 예 | 제안 — 근거는 `decision-log.md` | | Q1 | AI 와카뷰로 확정? | 예 | **확정** `decision-log.md` |
| Q2 | 타깃: 대중 vs 회사? | 대중 우선 (B2B는 이후) | 제안 | | Q2 | 타깃: 대중 vs 회사? | 대중 우선 (B2B는 이후) | **확정** |
| Q3 | 자율성 몇 단계까지 출시? | L0~L2만 | 제안 | | Q3 | 자율성 몇 단계까지 출시? | L0~L2만 (시작 기본값은 PoC 후) | **확정** (하위 기본값은 열림) |
| Q4 | 사칭 우려 대응 충분한가? | 1차 설계는 충분, 실사용 검증 필요 | 제안 | | Q4 | 사칭 우려 대응 충분한가? | 1차 설계는 충분, 실사용 검증 필요 | **확정** (실사용은 PoC) |
| Q5 | MVP 데모 시나리오 1개는? | 읽씹 종결 + 단톡 따라잡기 (묶음) | 제안 | | Q5 | MVP 데모 시나리오 1개는? | 읽씹 종결 + 단톡 따라잡기 (묶음) | **확정** |
| Q6 | 서비스 이름 | "와카뷰" (가칭) | 제안 (가칭) | | Q6 | 서비스 이름 | "와카뷰 (Ykavu)" | **확정** (2026-07-31 명칭 변경) |
| Q7 | 자체 앱 vs OS 레이어 시작점 | 자체 앱 클로즈드 베타 먼저 → OS 레이어는 이후 | 제안 | | Q7 | 자체 앱 vs OS 레이어 시작점 | 자체 앱 클로즈드 베타 먼저 → OS 레이어는 이후 | **확정** |
## 3. MVP 시나리오 좁히기 ## 3. MVP 시나리오 좁히기
@ -147,10 +147,13 @@
PoC #3 역할극 자극재로 사용 PoC #3 역할극 자극재로 사용
- [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md` - [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md`
(질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장) (질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장)
- [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영) - [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영) — **맨 마지막(N5/D)**
- [x] 회의 리뷰용 1페이지 요약 자료 작성 — `meeting-review-summary.md` - [x] 회의 리뷰용 1페이지 요약 자료 작성 — `meeting-review-summary.md`
- [ ] 실제 회의에서 위 요약 자료로 문서 세트 전체를 리뷰하고 Q1~Q7을 정식 확정 - [x] Q1~Q7 정식 확정 — `decision-log.md` (Phase 1 C, 2026-07-30). PoC 의존 하위만 열림
- [x] Phase 1(자체 앱 빌드) 상세 작업 분해 — `roadmap.md` "Phase 1 상세 작업 분해". PoC 결과 - [x] Phase 1(자체 앱 빌드) 상세 작업 분해 — `roadmap.md` "Phase 1 상세 작업 분해". PoC 결과
무관 기반 작업(백엔드/클라이언트 뼈대)과 PoC 결과 필요 항목을 구분해둠 무관 기반 작업(백엔드/클라이언트 뼈대)과 PoC 결과 필요 항목을 구분해둠
- [ ] 기술 스택 결정 (클라이언트/백엔드/DB/메시지 릴레이/온디바이스 저장소) — `roadmap.md` - [x] 기술 스택 결정 — `tech-design.md` §8 / `roadmap.md` Phase 1 §1
Phase 1 §1, 회의 필요 (Flutter/Dart, Go core + Python AI, WebSocket, drift+SQLCipher; 배포 DB는
[`deploy-checklist.md`](./deploy-checklist.md) N2-A — 프로덕션 DB는 **PostgreSQL** 확정)
- [ ] A~C 이후 실행 트랙 — [`deploy-checklist.md`](./deploy-checklist.md)
(N1 스모크 → N2 Docker → N3 안정화 → N4 FCM/Android QA → N5 사람 PoC)

223
docs/deploy-checklist.md Normal file
View File

@ -0,0 +1,223 @@
# 배포·잔여 작업 체크리스트 (와카뷰)
Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
권위 문서: [`roadmap.md`](./roadmap.md) · [`decision-log.md`](./decision-log.md) · [`AGENTS.md`](../AGENTS.md).
**사람 PoC(D)와 Phase 1 §3 기본값은 맨 마지막.** 추측으로 채우지 않는다.
---
## 0. 통합 현황 (Claude + Cursor)
### DONE — Claude (`claude/project-planning-approach-ukdz31` 계열)
- [x] 기획 문서 세트 (`PLANNING`, vision/PRD/tech-design/risk/roadmap, decision-log 초안)
- [x] PoC #1/#3 계획·모집/역할극 자료·Q3 인터뷰 가이드·프로토타입 앵커
- [x] `poc/tone-corpus/` 파이프라인 (전처리·draft·escalation·retrieve·blind_eval)
- [x] 스택 확정: Flutter + Go core + Python AI
- [x] `core-backend/` · `ai-service/` · 하드게이트·거부권·초대·되돌리기·L0~L2 QA
- [x] Flutter UI 테마 폴리시 (`app_theme` + 화면별 시각 개선)
### DONE — Cursor 후속
- [x] A1/A2 API · A3 Flutter 메신저 연결 · API E2E (`scripts/e2e_a3.py`)
- [x] Phase 1 B (drift/SQLCipher, FCM 골격, sessions, metrics, data-flow, identity)
- [x] Phase 1 C (Q1~Q7 **확정**, invite-ops, Android release 경로)
- [x] Flutter Web SQLCipher stub · Twin Shadow UI · CORS · `DEMO-YKAVU`
- [x] GitHub + Gitea 듀얼 리모트 (`scripts/push-both.sh`)
### NOW
- 앱 코드는 클로즈드 베타 직전 수준
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
- 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA
- 실 FCM 전송·Android 실기기 탭 · 사람 PoC 실행은 남음
### NEXT 순서
```
N1 스모크 → N2 Docker(msn.iykyka.com) → N3 배포 안정화
→ N4 FCM·Android QA 등 → N5 사람 PoC(D) → 실제 베타 오픈
```
### LOCKED
- [ ] Phase 2+ (L3 / OS 레이어 / L4 / B2B) — Phase 1 게이트 전 구현 금지
- [ ] PoC §3 기본값(자율성 시작 레벨, 화이트리스트 기본 주제, 신뢰 UX 최종 카피) 추측 금지
---
## 항목 템플릿
각 ID를 처리할 때 아래로 상태를 갱신한다.
```text
Status: todo | doing | done | blocked
Depends on:
Acceptance: (체크리스트)
Notes:
```
---
## N1 — 배포 전 스모크
| ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------|
| **N1-1** | 서비스 기동 | **done** (2026-07-31) | `8080`/`8001`/`5555` health·web 200. 코어를 현재 `main`으로 재기동 (`ADMIN_API_TOKEN=dev-admin-token`, `ALLOW_DEMO_INVITE=1`) |
| **N1-2** | API E2E | **done** (2026-07-31) | `python3 scripts/e2e_a3.py`**16 passed, 0 failed** |
| **N1-3** | Web 가입 스모크 | **done** (API) | `DEMO-YKAVU` 재사용 가입 + 토큰 발급 확인. `/demo``DEMO-YKAVU`. (브라우저 클릭 스모크는 로컬/테스터) |
| **N1-4** | 말투 온보딩 스모크 | **done** (코드경로) | 온보딩은 기기 로컬(drift); API 스모크에서는 대화 목록 진입 경로까지 확인. UI 탭은 테스터 |
| **N1-5** | 핵심 메신저 스모크 | **done** (API) | DEMO 두 유저 → 연락처 → 대화 → 메시지. E2E에 draft/L1·거부권·화이트리스트 포함 |
| **N1-6** | 프로덕션 CORS/API 메모 | **done** (2026-07-31) | 아래 Notes |
로컬 포트 참고: 앱 **5555**, 코어 **8080**, AI **8001**. Dart VM Service 고포트(예: 39369)는 디버그용 — 무시 가능.
데모 초대 코드(현재): **`DEMO-YKAVU`** (구 `DEMO-BUNSIN` 아님).
### N1-6 Notes — `msn.iykyka.com` 변경 목록
| 항목 | 로컬 지금 | 프로덕션 필요 |
|------|-----------|----------------|
| Flutter `CORE_API_BASE` | `http://127.0.0.1:8080` / emulator `10.0.2.2` | `https://msn.iykyka.com` (또는 API 서브경로/서브도메인 — compose에서 확정) |
| CORS Allow-Origin | `corsMiddleware()`가 요청 `Origin` 반사(로컬 `localhost:5555` 확인됨) | 동일 미들웨어면 same-origin 또는 `https://msn.iykyka.com` Origin 허용. 와일드카드+Credentials 조합 주의 |
| AI | `AI_SERVICE_URL=http://127.0.0.1:8001` | compose 내부 `http://ai-service:8001` (외부 미노출, N2-A3) |
| DB | SQLite `dev.db` | `DATABASE_URL=postgres://…` (N2-A2) |
| Admin | `ADMIN_API_TOKEN` env | Portainer secret (N2-A5) |
| Demo | `ALLOW_DEMO_INVITE=1`, `DEMO-YKAVU` | on 유지 (N2-A6) |
| WebSocket | `ws://host:8080` | `wss://msn.iykyka.com` (`AppConfig.wsBase`) |
---
## N2 — Docker 배포 (`https://msn.iykyka.com`) — Plan A
### N2-A. 착수 전 결정 (Master 확인)
| ID | 결정 | 값 | Status |
|----|------|-----|--------|
| **N2-A1** | 구 Node MSN 교체 | **교체(Plan A)**`msn.iykyka.com`에 와카뷰 스택으로 컷오버 | **done** (2026-07-31 Master 확정) |
| **N2-A2** | DB | **PostgreSQL** (`tech-design.md` §8). compose에 `postgres` 서비스 + `DATABASE_URL`. SQLite는 로컬/테스트 전용 | **done** (2026-07-31 Master 확정) |
| **N2-A3** | AI 서비스 노출 | **내부망만** — 외부 포트/도메인 미공개, core-backend만 `AI_SERVICE_URL`로 호출 | **done** (2026-07-31 Master 확정) |
| **N2-A4** | 클라이언트 제공 | **Web 우선** — compose에 Flutter web 서빙. 내부 APK는 N4/릴리즈 경로로 후속 | **done** (2026-07-31 Master 확정) |
| **N2-A5** | 시크릿 관리 | **Portainer/호스트 env만**`GEMINI_API_KEY`, `ADMIN_API_TOKEN`, DB 비밀번호, FCM 등 **git 커밋 금지** | **done** (2026-07-31 Master 확정) |
| **N2-A6** | 데모 초대 | 프로덕션 **`ALLOW_DEMO_INVITE=1` (on)** — 테스터용 `DEMO-YKAVU` 유지. 베타 확대 전 재검토 | **done** (2026-07-31 Master 확정) |
N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/compose, Postgres 포함)**.
### N2-B. 이미지·compose
| ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------|
| **N2-B1** | `core-backend` Dockerfile | **done** | `core-backend/Dockerfile` — 이미지 빌드 성공 (`GOTOOLCHAIN=auto`, CGO) |
| **N2-B2** | `ai-service` Dockerfile | **done** | `ai-service/Dockerfile` — 이미지 빌드 성공 |
| **N2-B3** | Flutter web 빌드/서빙 | **done** | `mobile/Dockerfile` (flutter multi-stage) + `deploy/nginx-web.conf` (API/WS 프록시). 대안: `mobile/Dockerfile.prebuilt` |
| **N2-B4** | `docker-compose.yml` | **done** | root `docker-compose.yml` — postgres + ai(internal) + core(internal) + web 포트 |
| **N2-B5** | env 템플릿 | **done** | `.env.example`에 Postgres/`PUBLIC_API_BASE`/`WEB_HOST_PORT` 등 추가 |
| **N2-B6** | 데이터 볼륨 | **done** | compose 볼륨 `ykavu_pgdata` |
| **N2-B7** | 내부 DNS | **done** (정의) | compose 서비스명 `postgres` / `ai-service` / `core-backend`. *에이전트 VM은 bridge TCP 제한으로 런타임 검증 불가 — Portainer 호스트에서 확인* |
| **N2-B8** | CORS + API base | **done** (2026-07-31) | `PUBLIC_API_BASE=https://msn.iykyka.com`. OPTIONS CORS + 가입 확인 |
| **N2-B9** | 리버스 프록시 | **done** (2026-07-31) | Nginx Proxy Manager host #7`ykavu-web-1:80` |
| **N2-B10** | Portainer 스택 | **done** (compose CLI) | 서버 `~/project/ykavu` 에서 `docker compose up -d --build` (Portainer UI 아님) |
| **N2-B11** | 구 MSN 컷오버 | **done** (2026-07-31) | `iykyk_msn-service` stop. NPM `msn.iykyka.com` → 와카뷰. 롤백: MSN start + NPM upstream 복구 |
| **N2-B12** | 배포 스모크 | **done** (2026-07-31) | `/health`=`status`, `/demo`=`DEMO-YKAVU`, root 200, DEMO 가입 OK |
| **N2-B13** | 운영 runbook | **done** | [`deploy-docker.md`](./deploy-docker.md) · [`deploy-portainer.md`](./deploy-portainer.md) |
관련: 라이브 `https://msn.iykyka.com` · 서버 path `~/project/ykavu` · web 포트 `8788`(+ NPM). 시크릿 git 금지.
---
## N3 — 배포 직후 안정화
| ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------|
| **N3-1** | 헬스/로그 | **done** (2026-07-31) | 전 컨테이너 healthy. 공개 `/health` OK. 최근 로그에 OOM/502 없음 |
| **N3-2** | 초대 발급 리허설 | **done** (2026-07-31) | `POST /invites` note=`N3-rehearsal` 발급·`GET /invites` 목록 확인 |
| **N3-3** | admin metrics | **done** (2026-07-31) | Bearer로 `/admin/metrics`·`/admin/dashboard` 200 |
| **N3-4** | Gemini | **done** (2026-07-31) | 서버 `GEMINI_API_KEY` 주입·ai-service 재기동. 라이브 draft `status=ok` (예: `딱히? ㅋㅋ`) |
| **N3-5** | 백업 리허설 | **done** (2026-07-31) | `pg_dump` gzip → restore test DB → drop. [`ops-backup.md`](./ops-backup.md) |
| **N3-6** | 테스터 안내 | **done** (2026-07-31) | [`tester-guide.md`](./tester-guide.md) |
---
## N4 — 베타 품질 잔여
### Track A — 메신저 UX (대화 열기 경로)
| ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------|
| **N4-A1** | 내 사용자 ID 표시·복사 | done | 대화목록·연락처에 `MyUserIdChip` |
| **N4-A2** | 연락처 원탭 대화 + ID 필수 | done | 숫자 peer ID 없으면 추가/대화 차단·안내 |
| **N4-A3** | 빈 상태·에러·L0 패널 | done | L0는「입력창으로 옮기기」 |
| **N4-A4** | 대화 목록 이름·밀도 | done | 연락처 표시명 매핑 |
| **N4-A5** | 프로덕션 web 재빌드 | done | `msn.iykyka.com` health 200 (`3d00b00`) |
| **N4-A6** | ID 없는 연락처 수정(PATCH)·배너 | done | 「ID 입력」으로 peer ID 보강 |
### Track B — 데모 콘텐츠 (테스터 페어링)
| ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------|
| **N4-B1** | `/demo` pairing_steps·notes | done | GET `/demo`에 페어링 단계 |
| **N4-B2** | 테스터 가이드 페어링 문서화 | done | `docs/tester-guide.md` ID 교환 플로우 |
| **N4-B3** | 가입 화면 페어링 안내 | done | Signup 데모 패널에 한 줄 팁 |
| **N4-B4** | 프로덕션 core+web 재배포 | done | `/demo` pairing_steps OK (`c7029ab`) |
### FCM — [`fcm-setup.md`](./fcm-setup.md)
| ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------|
| **N4-1** | Firebase + `google-services.json` | doing | Master: 앱 등록됨 · JSON을 Android 빌드 PC에 배치 |
| **N4-2** | 실 FCM registration token | done* | `PushTokenService` — Firebase 있으면 실 토큰, 없으면 `install:` (*전송은 N4-1 후) |
| **N4-3** | 서버 FCM 자격증명 (HTTP v1) | doing | Master: `secrets/firebase-service-account.json` (레거시 서버 키 대신) |
| **N4-4** | 푸시 수신 | blocked | N4-1+N4-3 후 `/admin/push-test` + 기기 수신 |
### Android UI 탭 (`mobile/README.md`)
API 계층은 프로덕션에서 검증됨 (`CORE_API_BASE=https://msn.iykyka.com` → e2e **16/16**, 2026-07-31).
실기기/에뮬레이터 **화면 탭**은 Master 로컬에서 1회.
| ID | 작업 | Status | 비고 |
|----|------|--------|------|
| **N4-5** | 가입 → 말투 저장 | api-done | 실기기 UI 탭 남음 |
| **N4-6** | 연락처 → 대화 → 메시지·히스토리 | api-done | 실기기 UI 탭 남음 |
| **N4-7** | L1 초안 수정/버리기/승인·뱃지 | api-done | 실기기 UI 탭 남음 |
| **N4-8** | 에스컬레이션 → 사후알림 함 | api-done | 실기기 UI 탭 남음 |
| **N4-9** | 되돌리기·거부권 | api-done | 실기기 UI 탭 남음 |
| **N4-10** | 자율성 L0~L2 + 화이트리스트 | api-done | 실기기 UI 탭 남음 |
### 후순위
| ID | 작업 | Status | 비고 |
|----|------|--------|------|
| **N4-11** | 오프라인 메시지 큐 | todo | 멀티디바이스 고도화 |
| **N4-12** | 자연스러움 피드백 UI | todo | vision 지표 |
| **N4-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 |
| **N4-14** | 내부 release APK | todo | `docs/android-release.md` |
| **N4-15** | roadmap/`[~]` 동기화 | todo | 완료 시 체크 |
---
## N5 — 사람 PoC (D) — **지금 열지 않음**
N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` Phase 1 §3과 동일.
| ID | 작업 | Status |
|----|------|--------|
| **N5-1** | PoC #1/#3 참가자 모집·실행 | locked |
| **N5-2** | Q3 인터뷰 → 자율성 기본값 | locked |
| **N5-3** | 화이트리스트 기본 주제 | locked |
| **N5-4** | 신뢰 UX 문구/위치 확정 | locked |
| **N5-5** | vision 게이트 → 실제 클로즈드 베타 오픈 | locked |
자료: [`poc-plan.md`](./poc-plan.md) · [`poc-materials.md`](./poc-materials.md) · [`user-interview-guide.md`](./user-interview-guide.md).
---
## 바로 다음 5개 (권장)
1. ~~N2-A1~A6 결정 체크~~ **done**
2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터)
3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드)
4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브)
5. ~~N3 안정화 + Track A/B~~ **done** — 다음: **Master FCM 시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA (N4-5~10)
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.

53
docs/deploy-docker.md Normal file
View File

@ -0,0 +1,53 @@
# Docker 배포 (N2-B) — `msn.iykyka.com`
체크리스트: [`deploy-checklist.md`](./deploy-checklist.md) N2-B.
결정: N2-A (Postgres, AI 내부망, Web 우선, 시크릿 env, `ALLOW_DEMO_INVITE=1`).
## 구성
| 서비스 | 역할 | 호스트 노출 |
|--------|------|-------------|
| `postgres` | PostgreSQL 16 | 아니오 (볼륨 `ykavu_pgdata`) |
| `ai-service` | FastAPI draft/escalate | 아니오 |
| `core-backend` | Go API + WS | 아니오 (web nginx가 프록시) |
| `web` | Flutter web + nginx | `WEB_HOST_PORT`→80 (기본 8088) |
엣지(Caddy/Traefik/기존 프록시)는 **`web:80`만** `https://msn.iykyka.com`에 연결하면 된다.
## 로컬 / 서버
```bash
cp .env.example .env
# 최소: ADMIN_API_TOKEN, POSTGRES_PASSWORD, (선택) GEMINI_API_KEY
# 로컬 스모크:
# PUBLIC_API_BASE=http://localhost:8088
docker compose up -d --build
curl -sS http://localhost:8088/health
curl -sS http://localhost:8088/demo
```
Portainer·OpenResty 컷오버 상세: [`deploy-portainer.md`](./deploy-portainer.md).
서버 원샷: `./scripts/server-up.sh`
## 시크릿
- git에 `.env` 커밋 금지 (`AGENTS.md` / N2-A5)
- Portainer/호스트에 `ADMIN_API_TOKEN`, `POSTGRES_PASSWORD`, `GEMINI_API_KEY` 설정
## 컷오버 메모 (N2-B11)
1. 새 스택을 임시 포트 또는 스테이징로 Up → 스모크
2. 기존 Node MSN 중지
3. 리버스 프록시를 `web:80`으로 전환
4. 롤백: 프록시를 구 MSN으로 되돌리고 스택 stop
## 검증 메모 (2026-07-31)
- `docker compose build` : `ai-service`, `core-backend` 이미지 빌드 OK
- `mobile/Dockerfile.prebuilt` + `nginx -t` OK (API upstream 지연 해석)
- 일부 샌드박스/에이전트 VM에서는 Docker **bridge 네트워크 TCP가 막혀**
컨테이너 간 `postgres:5432` 연결이 타임아웃될 수 있음. **Portainer가 돌아가는
실제 호스트에서는 기본 bridge compose를 사용**하면 된다.
- Flutter multi-stage (`mobile/Dockerfile`)는 이미지 용량이 크므로 Portainer 빌드
시 시간 여유를 둔다. 급하면 호스트에서 `flutter build web``Dockerfile.prebuilt`.

95
docs/deploy-portainer.md Normal file
View File

@ -0,0 +1,95 @@
# Portainer 배포 · 컷오버 (N2-B8~B12)
대상: `https://msn.iykyka.com`
스택 정의: 저장소 root [`docker-compose.yml`](../docker-compose.yml)
개요: [`deploy-docker.md`](./deploy-docker.md)
## 라이브 상태 (2026-07-31 컷오버 완료)
| URL | 결과 |
|-----|------|
| `https://msn.iykyka.com/health` | `{"status":"ok"}` — 와카뷰 |
| `https://msn.iykyka.com/demo` | `DEMO-YKAVU` |
| `https://msn.iykyka.com/` | Flutter web 200 |
| 서버 path | `~/project/ykavu` (`docker compose`) |
| web host port | `8788` (NPM이 `ykavu-web-1:80`으로 프록시) |
| 구 MSN | `iykyk_msn-service` **stopped** (롤백 시 start) |
| 엣지 | Nginx Proxy Manager (`nginx-proxy-app-1`, openresty) |
`docker-compose.yml``web` 서비스는 external network `nginx-proxy_default`에 연결됨.
---
## A. Portainer 스택 생성
1. Portainer → **Stacks** → **Add stack**
2. Build method: **Repository** (권장)
- Repository URL: `https://gitea.iykyka.com/oh/iykyka.git` (또는 GitHub `o0kuma/hikikomori`)
- Compose path: `docker-compose.yml`
- Branch: `main`
3. **Environment variables** (시크릿 — git 금지):
| Name | 예 |
|------|-----|
| `ADMIN_API_TOKEN` | 긴 랜덤 |
| `POSTGRES_PASSWORD` | 긴 랜덤 |
| `POSTGRES_USER` | `ykavu` |
| `POSTGRES_DB` | `ykavu` |
| `GEMINI_API_KEY` | (있으면) |
| `ALLOW_DEMO_INVITE` | `1` |
| `PUBLIC_API_BASE` | `https://msn.iykyka.com` |
| `WEB_HOST_PORT` | `8088` (컷오버 전 임시) → 전환 후 openresty가 가리키는 포트에 맞춤 |
| `FCM_SERVER_KEY` | (없으면 비움) |
4. Deploy the stack → 빌드 완료까지 대기 (Flutter multi-stage는 수 분~십수 분)
5. 호스트에서 확인:
```bash
curl -sS http://127.0.0.1:8088/health # → {"status":"ok"}
curl -sS http://127.0.0.1:8088/demo # → DEMO-YKAVU
```
### SSH로 올릴 때
```bash
git clone https://gitea.iykyka.com/oh/iykyka.git ykavu && cd ykavu
cp .env.example .env # 값 채움
./scripts/server-up.sh
```
---
## B. OpenResty 컷오버 (N2-B11)
1. **병렬 기동**: 새 스택을 `WEB_HOST_PORT=8088`(또는 빈 포트)로 Up, 구 MSN은 유지
2. 로컬 스모크: `/health``status`, `/demo``DEMO-YKAVU`, 브라우저로 `http://HOST:8088/` 가입
3. OpenResty upstream을 **구 Express → `127.0.0.1:8088`(web)** 로 변경 후 reload
4. 공개 URL 스모크:
- `https://msn.iykyka.com/health``{"status":"ok"}`
- `https://msn.iykyka.com/demo` → demo JSON
- 회원가입 `DEMO-YKAVU`
5. 구 Node MSN 컨테이너/프로세스 중지
6. **롤백**: upstream을 구 MSN으로 되돌리고 openresty reload
WebSocket: openresty에서 `/ws``Upgrade` / `Connection` 헤더 전달 필요
(nginx `proxy_set_header Upgrade $http_upgrade` 와 동일).
---
## C. 배포 스모크 체크 (N2-B12)
- [ ] `GET /health``{"status":"ok"}`
- [ ] `GET /demo``demo_invite_code=DEMO-YKAVU`
- [ ] 브라우저 가입 → 말투 온보딩 → 대화 목록
- [ ] (가능 시) draft/L1 1회
- [ ] AI·Postgres 호스트 포트 미노출 확인
---
## Master에게 필요한 것 (에이전트 대행 시)
아래 중 **하나**만 있으면 N2-B8~B12를 에이전트가 이어서 실행할 수 있다.
1. SSH: `iykyka@iykyka.com:7788`**private key** (또는 일시 비밀번호 — 채팅 대신 시크릿 채널 권장)
2. Portainer **API access token** + endpoint id
3. Master가 A~C를 직접 수행한 뒤 결과만 공유

78
docs/fcm-setup.md Normal file
View File

@ -0,0 +1,78 @@
# FCM 설정 (N4-1 ~ N4-4)
서버·클라이언트의 푸시 **코드 경로는 준비됨**.
새 Firebase 프로젝트는 **레거시 서버 키가 비활성**인 경우가 많아, **HTTP v1 + 서비스 계정 JSON**을 쓴다.
## 이미 된 것 (코드)
| 계층 | 동작 |
|------|------|
| Flutter | `PushTokenService` — Firebase 가능하면 실 FCM 토큰, 아니면 `install:` 플레이스홀더 |
| Android | `google-services.json`이 있을 때만 Google Services 플러그인 적용 |
| core-backend | `notifyUser` + `POST /admin/push-test`**FCM HTTP v1**(서비스 계정) 우선, 레거시 `FCM_SERVER_KEY`는 폴백 |
## Master가 할 일
### N4-1 — Firebase Android 앱
1. [Firebase Console](https://console.firebase.google.com/) → Android 앱 추가
package: **`com.ykavu.ykavu_mobile`**
2. `google-services.json`을 로컬에만 배치 (git 금지):
```bash
cp ~/Downloads/google-services.json mobile/android/app/google-services.json
```
### N4-3 — 서비스 계정 JSON (HTTP v1)
레거시 **서버 키**가 Cloud Messaging 탭에서 `사용 중지됨`이면 정상이다. 아래를 쓴다.
1. Google Cloud → 사용자 인증 정보 → 서비스 계정 만들기
(API: Firebase Cloud Messaging API, 데이터: **애플리케이션 데이터**)
2. 역할: **Firebase Cloud Messaging Admin** (없으면 Firebase 관리자 / 임시 소유자)
3. 키 유형 **JSON** 다운로드
4. 서버(또는 이 워크스페이스)에 배치:
```bash
# 파일명 고정
mkdir -p secrets
mv ~/Downloads/iykyka-*.json secrets/firebase-service-account.json
chmod 600 secrets/firebase-service-account.json
```
5. 프로덕션 호스트에도 동일 파일:
```bash
# 예: scp 후
cd ~/project/ykavu
# secrets/firebase-service-account.json 존재 확인
docker compose up -d --build core-backend
```
`docker-compose.yml``./secrets` → 컨테이너 `/secrets`로 마운트하고
`FCM_SERVICE_ACCOUNT_FILE=/secrets/firebase-service-account.json`을 읽는다.
**git / 채팅에 JSON 내용을 붙여넣지 않는다.**
### N4-2 / N4-4 — 실기기 스모크
```bash
cd mobile
flutter run --release --dart-define=CORE_API_BASE=https://msn.iykyka.com
# 로그: device token registered (FCM)
```
```bash
curl -sS -X POST https://msn.iykyka.com/admin/push-test \
-H "Authorization: Bearer $ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_id": <USER_ID>, "title":"와카뷰","body":"push smoke"}'
```
기대: `sent >= 1`.
`only_placeholder_tokens` → Android에 `google-services.json` 넣고 재설치.
`fcm_not_configured` → 서버에 서비스 계정 파일 경로 확인.
## Web
Web 푸시는 별도 VAPID 설정이 필요하며 이번 N4는 **Android 실푸시** 우선.

28
docs/ops-backup.md Normal file
View File

@ -0,0 +1,28 @@
# Postgres 백업·복구 (N3-5)
서버 path: `~/project/ykavu`
덤프 위치: `~/backups/ykavu/ykavu-YYYYMMDDThhmmssZ.sql.gz`
## 백업
```bash
cd ~/project/ykavu
./scripts/backup-postgres.sh
# 또는
docker compose exec -T postgres pg_dump -U ykavu ykavu | gzip > ~/backups/ykavu/ykavu-$(date -u +%Y%m%dT%H%M%SZ).sql.gz
```
리허설(2026-07-31): 덤프 → `ykavu_restore_test` DB로 복구 → `\dt` 11테이블 · users count 확인 → 테스트 DB 삭제 OK.
## 복구 (주의: 운영 DB 덮어쓰기)
```bash
cd ~/project/ykavu
# 1) 서비스 중지 권장
docker compose stop core-backend web
# 2) 기존 DB 드롭/재생성 또는 새 DB로 검증 후 전환
gunzip -c ~/backups/ykavu/ykavu-XXXX.sql.gz | docker compose exec -T postgres psql -U ykavu -d ykavu
docker compose start core-backend web
```
권장: 먼저 별도 DB(`ykavu_restore_test`)에 복구해 검증한 뒤 컷오버.

View File

@ -212,10 +212,19 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
##### D. 맨 마지막 — 사람 PoC (지금 안 함) ##### D. 맨 마지막 — 사람 PoC (지금 안 함)
- §3 항목과 동일. A~C 완료 후에만 착수. - §3 항목과 동일. A~C 완료 후에만 착수.
**단, 프로덕션 Docker 배포·스모크(N1~N3)는 D보다 앞** — [`deploy-checklist.md`](./deploy-checklist.md).
##### E. 베타 이후 (지금은 설계만, 구현 금지) ##### E. 베타 이후 (지금은 설계만, 구현 금지)
- Phase 2 L3 / Phase 3 OS 레이어 / Phase 4 L4·B2B - Phase 2 L3 / Phase 3 OS 레이어 / Phase 4 L4·B2B
#### 6. 배포·잔여 작업 (A~C 이후 실행 트랙)
단일 실행 체크리스트: **[`deploy-checklist.md`](./deploy-checklist.md)**.
순서: **N1 스모크 → N2 Docker(`msn.iykyka.com`) → N3 안정화 → N4 FCM/Android QA → N5 사람 PoC(D)**.
Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5)는 해당 문서를 본다. 완료 시 그 문서와
본 로드맵 §2/§5의 `[~]`/`[ ]`를 함께 갱신한다.
## Phase 2 — L3 확장 + 베타 확대 ## Phase 2 — L3 확장 + 베타 확대

59
docs/tester-guide.md Normal file
View File

@ -0,0 +1,59 @@
# 와카뷰 테스터 안내 (N3-6 / Track B)
## 접속
- 웹: **https://msn.iykyka.com**
- 공용 데모 초대 코드: **`DEMO-YKAVU`**
- 표시 이름 예시: `테스터` (원하는 이름으로 변경 가능)
같은 코드를 여러 명이 쓸 수 있습니다 (`ALLOW_DEMO_INVITE=1`).
API 메타: `GET https://msn.iykyka.com/demo``pairing_steps` / `notes` 포함.
## 권장 페어링 플로우 (5~10분)
두 명(또는 시크릿 창 두 개)으로 진행합니다. **대화는 닉네임이 아니라 숫자 사용자 ID로 연결됩니다.**
1. **가입** — 초대 코드 `DEMO-YKAVU` 입력 (각자 다른 표시 이름 권장)
2. **말투 샘플** — 몇 줄 적거나 스킵
3. **내 ID 복사** — 대화 목록 상단「내 사용자 ID」칩을 탭해 복사하고 상대에게 전달
4. **연락처 추가** — 상대 표시 이름 + **상대의 숫자 ID(필수)** → 추가 → 「대화」
5. **메시지** — 사람 모드로 한두 줄 주고받기
6. **와카뷰 초안** — 메뉴 → 자율성에서 **L1** → 초안이 뜨면 수정/승인하고 보내기
7. (선택) L0에서는「입력창으로 옮기기」만 됩니다. L2·거부권·사후알림 함도 눌러 보세요.
### ID 없는 옛 연락처
이름만 넣고 ID를 비운 연락처는 대화가 안 됩니다. 연락처 화면에서 **「ID 입력」**으로 숫자 ID를 채우면 됩니다 (삭제 후 재추가 불필요).
## 알아둘 점
- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다.
- **L0(비서)**: 와카뷰 발송이 서버에서 막혀 있습니다. 초안 → 입력창 → 직접 전송.
- 푸시(FCM): 코드 경로는 준비됨. Master가 Firebase/`FCM_SERVER_KEY`를 넣기 전에는 플레이스홀더(`docs/fcm-setup.md`).
- 문제/스크린샷은 Master에게 전달해 주세요.
- 민감 정보·실명 대화는 베타 특성상 최소화해 주세요.
## 개인 초대 (운영자)
공용 코드 대신 1회용 코드가 필요하면:
```bash
curl -sS -X POST https://msn.iykyka.com/invites \
-H "Authorization: Bearer $ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"note":"테스터-이름","expires_in_days":14}'
```
절차 상세: [`invite-ops.md`](./invite-ops.md)
## 장애 시 운영자 체크
```bash
cd ~/project/ykavu
docker compose ps
curl -sS https://msn.iykyka.com/health
docker compose logs --tail=100 core-backend ai-service web
```
백업: `./scripts/backup-postgres.sh` (호스트에서)

21
mobile/Dockerfile Normal file
View File

@ -0,0 +1,21 @@
# Flutter Web → nginx. Build from repository root:
# docker build -f mobile/Dockerfile --build-arg CORE_API_BASE=https://msn.iykyka.com .
ARG FLUTTER_IMAGE=ghcr.io/cirruslabs/flutter:3.32.7
FROM ${FLUTTER_IMAGE} AS build
WORKDIR /app
ARG CORE_API_BASE=https://msn.iykyka.com
COPY mobile/pubspec.yaml mobile/pubspec.lock ./
RUN flutter pub get
COPY mobile/ .
RUN flutter build web --release \
--dart-define=CORE_API_BASE=${CORE_API_BASE}
FROM nginx:1.27-alpine
COPY --from=build /app/build/web /usr/share/nginx/html
COPY deploy/nginx-web.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1

View File

@ -0,0 +1,10 @@
# Optional: serve a host-built Flutter web tree (faster CI/agent smoke).
# cd mobile && flutter build web --release --dart-define=CORE_API_BASE=https://msn.iykyka.com
# docker build -f mobile/Dockerfile.prebuilt -t ykavu-web .
# Production/Portainer should prefer mobile/Dockerfile (multi-stage flutter build).
FROM nginx:1.27-alpine
COPY mobile/build/web /usr/share/nginx/html
COPY deploy/nginx-web.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1

View File

@ -63,9 +63,14 @@ HTTP로 검증한다.
sudo apt-get install -y libsqlite3-dev libsqlcipher1 sudo apt-get install -y libsqlite3-dev libsqlcipher1
``` ```
## 푸시 (FCM)
- 코드: `lib/services/push_token_service.dart` — Firebase 가능 시 실 토큰, 아니면 `install:`
- Master 설정: [`docs/fcm-setup.md`](../docs/fcm-setup.md) (`google-services.json` + `FCM_SERVER_KEY`)
## 아직 없는 것 ## 아직 없는 것
- Firebase 프로젝트의 실제 FCM registration token 연동 (`google-services.json`) - Master Firebase 시크릿 주입 후 실기기 푸시 스모크 (N4-1/3/4)
- 오프라인 메시지 큐 / 멀티디바이스 실시간 설정 동기화 고도화 - 오프라인 메시지 큐 / 멀티디바이스 실시간 설정 동기화 고도화
- 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영) - 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
- iOS 빌드 (v1 범위 밖) - iOS 빌드 (v1 범위 밖)

View File

@ -14,3 +14,6 @@ key.properties
**/*.jks **/*.jks
*.jks *.jks
*.keystore *.keystore
# Firebase — never commit real project credentials (see docs/fcm-setup.md).
app/google-services.json

View File

@ -8,6 +8,11 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
// Apply only when Master drops google-services.json (see docs/fcm-setup.md).
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
val keystoreProperties = Properties() val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties") val keystorePropertiesFile = rootProject.file("key.properties")
val hasReleaseKeystore = keystorePropertiesFile.exists() val hasReleaseKeystore = keystorePropertiesFile.exists()

View File

@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "REPLACE_WITH_FIREBASE_PROJECT_NUMBER",
"project_id": "ykavu-REPLACE",
"storage_bucket": "ykavu-REPLACE.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:REPLACE:android:REPLACE",
"android_client_info": {
"package_name": "com.ykavu.ykavu_mobile"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "REPLACE_WITH_ANDROID_API_KEY"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application <application
android:label="와카뷰" android:label="와카뷰"
android:name="${applicationName}" android:name="${applicationName}"

View File

@ -20,6 +20,7 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.3" apply false id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("com.google.gms.google-services") version "4.4.2" apply false
} }
include(":app") include(":app")

View File

@ -175,6 +175,18 @@ class _ChatScreenState extends State<ChatScreen> {
if (draft == null || draft.isEscalate || session.user == null) return; if (draft == null || draft.isEscalate || session.user == null) return;
final text = _draftEdit.text.trim(); final text = _draftEdit.text.trim();
if (text.isEmpty) return; if (text.isEmpty) return;
// L0: twin send is forbidden server-side move text to human composer instead.
if (session.autonomyLevel == AutonomyLevel.L0) {
setState(() {
_input.text = text;
_pendingDraft = null;
_draftEdit.clear();
_banner = 'L0(비서 모드)에서는 와카뷰로 보낼 수 없습니다. 아래 입력창에서 직접 보내거나, 자율성 설정을 L1으로 바꾸세요.';
});
return;
}
setState(() => _busy = true); setState(() => _busy = true);
try { try {
final msg = await session.api.sendMessage( final msg = await session.api.sendMessage(
@ -278,6 +290,14 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
final level = context.watch<SessionState>().autonomyLevel;
final isL0 = level == AutonomyLevel.L0;
final title = isL0
? '초안 (L0) — 직접 보내기'
: level == AutonomyLevel.L1
? 'L1 승인 — 수정 후 보내기'
: '초안 (L2) — 승인 후 보내기';
return Container( return Container(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -293,12 +313,21 @@ class _ChatScreenState extends State<ChatScreen> {
children: [ children: [
Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.onSecondaryContainer), Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.onSecondaryContainer),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Expanded(
'L1 승인 — 수정 후 보내기', child: Text(
title,
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onSecondaryContainer), style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onSecondaryContainer),
), ),
),
], ],
), ),
if (isL0) ...[
const SizedBox(height: 6),
Text(
'L0에서는 와카뷰 발송이 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내거나, 메뉴 → 자율성에서 L1으로 바꾸세요.',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSecondaryContainer),
),
],
const SizedBox(height: 10), const SizedBox(height: 10),
TextField( TextField(
controller: _draftEdit, controller: _draftEdit,
@ -319,7 +348,7 @@ class _ChatScreenState extends State<ChatScreen> {
const SizedBox(width: 8), const SizedBox(width: 8),
FilledButton( FilledButton(
onPressed: _busy ? null : _sendTwinApproved, onPressed: _busy ? null : _sendTwinApproved,
child: const Text('승인하고 보내기'), child: Text(isL0 ? '입력창으로 옮기기' : '승인하고 보내기'),
), ),
], ],
), ),

View File

@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/my_user_id_chip.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
class ContactsScreen extends StatefulWidget { class ContactsScreen extends StatefulWidget {
@ -45,24 +46,35 @@ class _ContactsScreenState extends State<ContactsScreen> {
final nameCtrl = TextEditingController(); final nameCtrl = TextEditingController();
final peerCtrl = TextEditingController(); final peerCtrl = TextEditingController();
final noteCtrl = TextEditingController(); final noteCtrl = TextEditingController();
final session = context.read<SessionState>();
final myId = session.user?.id;
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('연락처 추가'), title: const Text('연락처 추가'),
content: Column( content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (myId != null) ...[
MyUserIdChip(userId: myId),
const SizedBox(height: 12),
],
TextField( TextField(
controller: nameCtrl, controller: nameCtrl,
decoration: const InputDecoration(labelText: '표시 이름'), decoration: const InputDecoration(
labelText: '표시 이름',
helperText: '목록에 보일 이름 (예: 친구 닉네임)',
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: peerCtrl, controller: peerCtrl,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: '상대 사용자 ID (선택)', labelText: '상대 사용자 ID (숫자, 필수)',
helperText: '대화 시작에 필요', helperText: '대화하려면 상대의 숫자 ID가 필요합니다. 이름만으로는 안 됩니다.',
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -72,6 +84,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
), ),
], ],
), ),
),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('추가')), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('추가')),
@ -79,20 +92,30 @@ class _ContactsScreenState extends State<ContactsScreen> {
), ),
); );
if (ok != true || !mounted) return; if (ok != true || !mounted) return;
final session = context.read<SessionState>();
final name = nameCtrl.text.trim(); final name = nameCtrl.text.trim();
if (name.isEmpty || session.user == null) return;
try {
final peer = int.tryParse(peerCtrl.text.trim()); final peer = int.tryParse(peerCtrl.text.trim());
if (name.isEmpty || session.user == null) return;
if (peer == null) {
setState(() => _error = '상대 사용자 ID(숫자)를 입력해야 대화를 시작할 수 있습니다.');
return;
}
if (peer == session.user!.id) {
setState(() => _error = '자기 자신은 연락처에 넣을 수 없습니다.');
return;
}
try {
final created = await session.api.createContact( final created = await session.api.createContact(
userId: session.user!.id, userId: session.user!.id,
displayName: name, displayName: name,
contactUserId: peer, contactUserId: peer,
relationshipNote: noteCtrl.text.trim(), relationshipNote: noteCtrl.text.trim(),
); );
setState(() => _contacts = [..._contacts, created]); setState(() {
_contacts = [..._contacts, created];
_error = null;
});
} on ApiException catch (e) { } on ApiException catch (e) {
setState(() => _error = '추가 실패 (${e.statusCode})'); setState(() => _error = '추가 실패 (${e.statusCode}): ${e.body}');
} }
} }
@ -100,7 +123,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
final session = context.read<SessionState>(); final session = context.read<SessionState>();
final me = session.user; final me = session.user;
if (me == null || contact.contactUserId == null) { if (me == null || contact.contactUserId == null) {
setState(() => _error = '상대 사용자 ID가 있는 연락처만 대화를 시작할 수 있습니다.'); setState(() => _error = '이 연락처에는 상대 사용자 ID가 없습니다. 「ID 입력」으로 숫자 ID를 넣으세요.');
return; return;
} }
try { try {
@ -110,13 +133,95 @@ class _ContactsScreenState extends State<ContactsScreen> {
); );
if (!mounted) return; if (!mounted) return;
await Navigator.of(context).push( await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ChatScreen(conversationId: conv.id, title: contact.displayName)), MaterialPageRoute(
builder: (_) => ChatScreen(conversationId: conv.id, title: contact.displayName),
),
); );
} on ApiException catch (e) { } on ApiException catch (e) {
setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}'); setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}');
} }
} }
Future<void> _editContact(Contact contact) async {
final nameCtrl = TextEditingController(text: contact.displayName);
final peerCtrl = TextEditingController(
text: contact.contactUserId == null ? '' : '${contact.contactUserId}',
);
final noteCtrl = TextEditingController(text: contact.relationshipNote);
final session = context.read<SessionState>();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(contact.contactUserId == null ? '사용자 ID 입력' : '연락처 수정'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
contact.contactUserId == null
? '대화하려면 상대의 숫자 사용자 ID가 필요합니다. 삭제하지 말고 여기서 채워 주세요.'
: '표시 이름·상대 ID·메모를 고칠 수 있습니다.',
style: Theme.of(ctx).textTheme.bodySmall,
),
const SizedBox(height: 12),
TextField(
controller: nameCtrl,
decoration: const InputDecoration(labelText: '표시 이름'),
),
const SizedBox(height: 12),
TextField(
controller: peerCtrl,
keyboardType: TextInputType.number,
autofocus: contact.contactUserId == null,
decoration: const InputDecoration(
labelText: '상대 사용자 ID (숫자, 필수)',
helperText: '상대 대화 목록에 보이는 숫자 ID',
),
),
const SizedBox(height: 12),
TextField(
controller: noteCtrl,
decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('저장')),
],
),
);
if (ok != true || !mounted || session.user == null) return;
final name = nameCtrl.text.trim();
final peer = int.tryParse(peerCtrl.text.trim());
if (name.isEmpty) return;
if (peer == null) {
setState(() => _error = '상대 사용자 ID(숫자)를 입력해야 합니다.');
return;
}
if (peer == session.user!.id) {
setState(() => _error = '자기 자신은 연락처에 넣을 수 없습니다.');
return;
}
try {
final updated = await session.api.updateContact(
userId: session.user!.id,
contactId: contact.id,
displayName: name,
contactUserId: peer,
relationshipNote: noteCtrl.text.trim(),
);
setState(() {
_contacts = _contacts.map((c) => c.id == updated.id ? updated : c).toList();
_error = null;
});
} on ApiException catch (e) {
setState(() => _error = '수정 실패 (${e.statusCode}): ${e.body}');
}
}
Future<void> _delete(Contact c) async { Future<void> _delete(Contact c) async {
final session = context.read<SessionState>(); final session = context.read<SessionState>();
if (session.user == null) return; if (session.user == null) return;
@ -140,8 +245,15 @@ class _ContactsScreenState extends State<ContactsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final me = context.watch<SessionState>().user?.id;
final missingIdCount = _contacts.where((c) => c.contactUserId == null).length;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('연락처')), appBar: AppBar(
title: const Text('연락처'),
actions: [
if (me != null) MyUserIdChip(userId: me, compact: true),
],
),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: _showAddDialog, onPressed: _showAddDialog,
tooltip: '연락처 추가', tooltip: '연락처 추가',
@ -154,6 +266,29 @@ class _ContactsScreenState extends State<ContactsScreen> {
: ListView( : ListView(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
children: [ children: [
if (me != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: MyUserIdChip(userId: me),
),
if (missingIdCount > 0)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Material(
color: theme.colorScheme.errorContainer.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
'사용자 ID가 없는 연락처 $missingIdCount개 — 「ID 입력」으로 숫자 ID를 채우면 대화를 시작할 수 있습니다.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
height: 1.4,
),
),
),
),
),
if (_error != null) if (_error != null)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@ -161,17 +296,21 @@ class _ContactsScreenState extends State<ContactsScreen> {
), ),
if (_contacts.isEmpty) if (_contacts.isEmpty)
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32), padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 32),
child: Column( child: Column(
children: [ children: [
Icon(Icons.person_add_outlined, size: 40, color: theme.colorScheme.outline), Icon(Icons.person_add_outlined, size: 40, color: theme.colorScheme.outline),
const SizedBox(height: 12), const SizedBox(height: 12),
Text('연락처가 없습니다', style: theme.textTheme.titleMedium), Text('연락처가 없습니다', style: theme.textTheme.titleMedium),
const SizedBox(height: 4), const SizedBox(height: 8),
Text( Text(
'오른쪽 아래 버튼으로 첫 연락처를 추가해 보세요.', '상대에게 내 ID를 알려 주고, 상대의 숫자 ID를 받아 추가하세요.\n'
'표시 이름만 넣고 ID를 비우면 대화를 시작할 수 없습니다.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.45,
),
), ),
], ],
), ),
@ -192,17 +331,36 @@ class _ContactsScreenState extends State<ContactsScreen> {
), ),
title: Text(c.displayName, style: theme.textTheme.titleSmall), title: Text(c.displayName, style: theme.textTheme.titleSmall),
subtitle: Text( subtitle: Text(
[ c.contactUserId == null
if (c.contactUserId != null) '사용자 #${c.contactUserId}', ? '사용자 ID 없음 — 대화 불가 (다시 추가 필요)'
: [
'사용자 #${c.contactUserId}',
if (c.relationshipNote.isNotEmpty) c.relationshipNote, if (c.relationshipNote.isNotEmpty) c.relationshipNote,
].join(' · '), ].join(' · '),
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall?.copyWith(
color: c.contactUserId == null
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
),
), ),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (c.contactUserId != null) if (c.contactUserId != null) ...[
TextButton(onPressed: () => _startChat(c), child: const Text('대화')), FilledButton.tonal(
onPressed: () => _startChat(c),
child: const Text('대화'),
),
IconButton(
tooltip: '수정',
icon: const Icon(Icons.edit_outlined, size: 20),
onPressed: () => _editContact(c),
),
] else
FilledButton(
onPressed: () => _editContact(c),
child: const Text('ID 입력'),
),
IconButton( IconButton(
tooltip: '삭제', tooltip: '삭제',
icon: const Icon(Icons.delete_outline, size: 20), icon: const Icon(Icons.delete_outline, size: 20),
@ -210,6 +368,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
), ),
], ],
), ),
onTap: () {
if (c.contactUserId == null) {
_editContact(c);
} else {
_startChat(c);
}
},
), ),
), ),
const SizedBox(height: 72), const SizedBox(height: 72),

View File

@ -6,6 +6,7 @@ import '../services/api_client.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../widgets/gradient_text.dart'; import '../widgets/gradient_text.dart';
import '../widgets/my_user_id_chip.dart';
import 'autonomy_settings_screen.dart'; import 'autonomy_settings_screen.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
@ -21,6 +22,7 @@ class ConversationListScreen extends StatefulWidget {
class _ConversationListScreenState extends State<ConversationListScreen> { class _ConversationListScreenState extends State<ConversationListScreen> {
List<ConversationSummary> _rooms = []; List<ConversationSummary> _rooms = [];
Map<int, String> _peerNames = {};
bool _loading = true; bool _loading = true;
String? _error; String? _error;
@ -39,7 +41,23 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
try { try {
final list = await session.api.listConversations(); final list = await session.api.listConversations();
list.sort((a, b) => b.id.compareTo(a.id)); list.sort((a, b) => b.id.compareTo(a.id));
setState(() => _rooms = list); final names = <int, String>{};
if (session.user != null) {
try {
final contacts = await session.api.listContacts(session.user!.id);
for (final c in contacts) {
if (c.contactUserId != null) {
names[c.contactUserId!] = c.displayName;
}
}
} on ApiException {
// Names are optional enrichment.
}
}
setState(() {
_rooms = list;
_peerNames = names;
});
} on ApiException catch (e) { } on ApiException catch (e) {
setState(() => _error = '대화 목록 실패 (${e.statusCode}): ${e.body}'); setState(() => _error = '대화 목록 실패 (${e.statusCode}): ${e.body}');
} finally { } finally {
@ -47,21 +65,56 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
} }
} }
String _titleFor(ConversationSummary room, int? me) {
if (me == null) return '대화방 #${room.id}';
final peers = room.userIds.where((id) => id != me).toList();
if (room.isGroup) return '그룹 #${room.id}';
if (peers.isEmpty) return '나와의 대화';
final peerId = peers.first;
final name = _peerNames[peerId];
if (name != null && name.isNotEmpty) return name;
return '상대 #$peerId';
}
String _subtitleFor(ConversationSummary room, int? me) {
if (room.twinDisabledByPeer) return '상대가 와카뷰를 거부함';
final peers = me == null ? const <int>[] : room.userIds.where((id) => id != me).toList();
final peerPart = peers.isEmpty ? '참가자 없음' : '상대 ID ${peers.first}';
return '$peerPart · 방 #${room.id}';
}
Future<void> _createConversation() async { Future<void> _createConversation() async {
final peerCtrl = TextEditingController(); final peerCtrl = TextEditingController();
final session = context.read<SessionState>();
final myId = session.user?.id;
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('새 대화'), title: const Text('새 대화'),
content: TextField( content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (myId != null) ...[
MyUserIdChip(userId: myId),
const SizedBox(height: 12),
Text(
'상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요.',
style: Theme.of(ctx).textTheme.bodySmall,
),
const SizedBox(height: 12),
],
TextField(
controller: peerCtrl, controller: peerCtrl,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
autofocus: true, autofocus: true,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: '상대 사용자 ID', labelText: '상대 사용자 ID (숫자)',
helperText: '연락처에 등록된 상대면 연락처 화면에서 시작하는 편이 낫습니다.', helperText: '이름/닉네임이 아니라 숫자 ID입니다. 연락처에 등록돼 있으면 연락처에서 시작하세요.',
), ),
), ),
],
),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('만들기')), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('만들기')),
@ -69,15 +122,31 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
), ),
); );
if (ok != true || !mounted) return; if (ok != true || !mounted) return;
final session = context.read<SessionState>();
final me = session.user; final me = session.user;
final peer = int.tryParse(peerCtrl.text.trim()); final peer = int.tryParse(peerCtrl.text.trim());
if (me == null || peer == null) return; if (me == null) return;
if (peer == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('상대 사용자 ID는 숫자여야 합니다. (예: 12)')),
);
return;
}
if (peer == me.id) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('자기 자신과는 대화를 만들 수 없습니다.')),
);
return;
}
try { try {
final conv = await session.api.createConversation(userIds: [me.id, peer]); final conv = await session.api.createConversation(userIds: [me.id, peer]);
if (!mounted) return; if (!mounted) return;
await Navigator.of(context).push( await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ChatScreen(conversationId: conv.id)), MaterialPageRoute(
builder: (_) => ChatScreen(
conversationId: conv.id,
title: _peerNames[peer] ?? '상대 #$peer',
),
),
); );
await _load(); await _load();
} on ApiException catch (e) { } on ApiException catch (e) {
@ -107,6 +176,7 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
appBar: AppBar( appBar: AppBar(
title: GradientText('와카뷰', style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)), title: GradientText('와카뷰', style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)),
actions: [ actions: [
if (me != null) MyUserIdChip(userId: me, compact: true),
IconButton( IconButton(
tooltip: '사후 알림', tooltip: '사후 알림',
onPressed: () { onPressed: () {
@ -163,12 +233,17 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Text( child: Text(
session.user == null ? '' : '안녕하세요, ${session.user!.displayName}', session.user == null ? '' : '안녕하세요, ${session.user!.displayName}',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
), ),
), ),
if (me != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: MyUserIdChip(userId: me),
),
if (_error != null) if (_error != null)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@ -196,15 +271,28 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
child: const Icon(Icons.chat_bubble_outline, size: 30, color: Colors.white), child: const Icon(Icons.chat_bubble_outline, size: 30, color: Colors.white),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('대화방이 없습니다', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text( Text(
'대화방이 없습니다', '1) 내 ID를 상대에게 알려 주세요\n'
style: theme.textTheme.titleMedium, '2) 연락처에 상대의 숫자 ID를 넣고 추가\n'
), '3) 연락처에서 「대화」또는 행을 탭하세요',
const SizedBox(height: 4),
Text(
'연락처나 "새 대화" 버튼으로 첫 대화를 시작해 보세요.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.45,
),
),
const SizedBox(height: 16),
FilledButton.tonalIcon(
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ContactsScreen()),
);
await _load();
},
icon: const Icon(Icons.contacts_outlined),
label: const Text('연락처 열기'),
), ),
], ],
), ),
@ -221,28 +309,34 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
), ),
), ),
title: Text( title: Text(
me == null ? '대화방 #${room.id}' : room.titleFor(me), _titleFor(room, me),
style: theme.textTheme.titleSmall, style: theme.textTheme.titleSmall,
), ),
subtitle: room.twinDisabledByPeer subtitle: Row(
? Row(
children: [ children: [
if (room.twinDisabledByPeer) ...[
Icon(Icons.block, size: 13, color: theme.colorScheme.error), Icon(Icons.block, size: 13, color: theme.colorScheme.error),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( ],
'상대가 와카뷰를 거부함', Expanded(
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error), child: Text(
_subtitleFor(room, me),
style: theme.textTheme.bodySmall?.copyWith(
color: room.twinDisabledByPeer
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
),
),
), ),
], ],
) ),
: Text('대화방 ID ${room.id}', style: theme.textTheme.bodySmall),
trailing: const Icon(Icons.chevron_right, size: 20), trailing: const Icon(Icons.chevron_right, size: 20),
onTap: () async { onTap: () async {
await Navigator.of(context).push( await Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => ChatScreen( builder: (_) => ChatScreen(
conversationId: room.id, conversationId: room.id,
title: me == null ? null : room.titleFor(me), title: _titleFor(room, me),
), ),
), ),
); );

View File

@ -96,7 +96,7 @@ class DataFlowScreen extends StatelessWidget {
body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n' body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n'
'채팅 릴레이: 보낸 메시지 본문\n' '채팅 릴레이: 보낸 메시지 본문\n'
'계정: 표시 이름·초대 코드·세션 토큰\n' '계정: 표시 이름·초대 코드·세션 토큰\n'
'푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후', '푸시: Firebase 연결 시 실 FCM 토큰 등록 · 미연결 시 install 플레이스홀더 (docs/fcm-setup.md)',
), ),
_section( _section(
context, context,

View File

@ -181,6 +181,14 @@ class _DemoTestPanel extends StatelessWidget {
'탭하면 입력란에 채워집니다 · 여러 명이 같은 코드로 가입 가능', '탭하면 입력란에 채워집니다 · 여러 명이 같은 코드로 가입 가능',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
), ),
const SizedBox(height: 8),
Text(
'페어링: 두 명이 각자 가입 → 내 사용자 ID를 교환 → 연락처에 상대 숫자 ID로 대화 시작',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.4,
),
),
], ],
), ),
), ),

View File

@ -127,6 +127,21 @@ class ApiClient {
return Contact.fromJson(json); return Contact.fromJson(json);
} }
Future<Contact> updateContact({
required int userId,
required int contactId,
required String displayName,
int? contactUserId,
String relationshipNote = '',
}) async {
final json = await _json('PATCH', '/users/$userId/contacts/$contactId', body: {
'display_name': displayName,
if (contactUserId != null) 'contact_user_id': contactUserId,
'relationship_note': relationshipNote,
});
return Contact.fromJson(json);
}
Future<void> deleteContact(int userId, int contactId) async { Future<void> deleteContact(int userId, int contactId) async {
await _json('DELETE', '/users/$userId/contacts/$contactId'); await _json('DELETE', '/users/$userId/contacts/$contactId');
} }

View File

@ -0,0 +1,69 @@
import 'dart:math';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
/// Resolves a device push token for core-backend registration.
///
/// Prefer a real FCM registration token when Firebase is configured
/// (`google-services.json` on Android). Otherwise fall back to a stable
/// `install:` placeholder that the server intentionally skips for delivery.
class PushTokenService {
PushTokenService({this.storeInstallId, this.readInstallId});
/// Persist a newly generated install id (KV / secure storage).
final Future<void> Function(String installId)? storeInstallId;
/// Load a previously stored install id.
final Future<String?> Function()? readInstallId;
Future<({String token, String platform, bool isFcm})> resolve() async {
final platform = _platformLabel();
final fcm = await _tryFirebaseToken();
if (fcm != null && fcm.isNotEmpty) {
return (token: fcm, platform: platform, isFcm: true);
}
final installId = await _ensureInstallId();
return (token: 'install:$installId', platform: platform, isFcm: false);
}
Future<String?> _tryFirebaseToken() async {
// Web push needs a separate Firebase web config + VAPID; skip until provided.
if (kIsWeb) return null;
if (defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS) {
return null;
}
try {
if (Firebase.apps.isEmpty) {
await Firebase.initializeApp();
}
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission(alert: true, badge: true, sound: true);
final token = await messaging.getToken();
if (token == null || token.isEmpty) return null;
return token;
} catch (e) {
debugPrint('FCM token unavailable (using install placeholder): $e');
return null;
}
}
Future<String> _ensureInstallId() async {
final existing = await readInstallId?.call();
if (existing != null && existing.isNotEmpty) return existing;
final rand = Random.secure();
final installId =
List.generate(16, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')).join();
await storeInstallId?.call(installId);
return installId;
}
String _platformLabel() {
if (kIsWeb) return 'web';
if (defaultTargetPlatform == TargetPlatform.android) return 'android';
if (defaultTargetPlatform == TargetPlatform.iOS) return 'ios';
return 'other';
}
}

View File

@ -1,5 +1,4 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -7,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../db/app_database.dart'; import '../db/app_database.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../services/push_token_service.dart';
class SessionState extends ChangeNotifier { class SessionState extends ChangeNotifier {
SessionState({ApiClient? api, AppDatabase? db}) SessionState({ApiClient? api, AppDatabase? db})
@ -148,22 +148,25 @@ class SessionState extends ChangeNotifier {
} }
} }
/// Registers a stable install id as the push token until Firebase Messaging /// Registers a real FCM token when Firebase is configured; otherwise a stable
/// is wired with a real FCM registration token (roadmap B). /// `install:` placeholder (server skips placeholders for delivery).
Future<void> _registerDeviceTokenBestEffort() async { Future<void> _registerDeviceTokenBestEffort() async {
if (user == null) return; if (user == null) return;
try { try {
_db ??= await _openDb(); _db ??= await _openDb();
var installId = await _db!.getKv(_kDeviceInstallId); final resolved = await PushTokenService(
if (installId == null || installId.isEmpty) { readInstallId: () => _db!.getKv(_kDeviceInstallId),
final rand = Random.secure(); storeInstallId: (id) => _db!.setKv(_kDeviceInstallId, id),
installId = List.generate(16, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')).join(); ).resolve();
await _db!.setKv(_kDeviceInstallId, installId);
}
await _api.registerDeviceToken( await _api.registerDeviceToken(
userId: user!.id, userId: user!.id,
token: 'install:$installId', token: resolved.token,
platform: defaultTargetPlatform == TargetPlatform.android ? 'android' : 'other', platform: resolved.platform,
);
debugPrint(
resolved.isFcm
? 'device token registered (FCM)'
: 'device token registered (install placeholder)',
); );
} catch (e) { } catch (e) {
debugPrint('device token register skipped: $e'); debugPrint('device token register skipped: $e');

View File

@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Shows the signed-in numeric user id with one-tap copy.
class MyUserIdChip extends StatelessWidget {
const MyUserIdChip({super.key, required this.userId, this.compact = false});
final int userId;
final bool compact;
Future<void> _copy(BuildContext context) async {
await Clipboard.setData(ClipboardData(text: '$userId'));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('내 사용자 ID $userId 를 복사했습니다. 상대에게 알려 주세요.'),
duration: const Duration(seconds: 2),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (compact) {
return IconButton(
tooltip: '내 ID $userId 복사',
onPressed: () => _copy(context),
icon: const Icon(Icons.badge_outlined),
);
}
return Material(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _copy(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Icon(Icons.badge_outlined, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'내 사용자 ID',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
Text(
'$userId',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
],
),
),
Text(
'탭하여 복사',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
const SizedBox(width: 4),
Icon(Icons.copy_rounded, size: 16, color: theme.colorScheme.primary),
],
),
),
),
);
}
}

View File

@ -5,18 +5,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: _fe_analyzer_shared name: _fe_analyzer_shared
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc" sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "96.0.0" version: "88.0.0"
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: "460e9e684edb461d85498fc166ff8416f303f22216838d302d80676b348c6a4c"
url: "https://pub.dev"
source: hosted
version: "1.3.75"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0" sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.2.0" version: "8.1.1"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -29,10 +37,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: async name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.1" version: "2.13.0"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -61,10 +69,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: build_daemon name: build_daemon
sha256: "8c0535c3b2f625619f4dd1036ef1127f2e77bfedf89ed2eb2676ef076e0b6712" sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.3" version: "4.1.2"
build_runner: build_runner:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -129,14 +137,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.2.1"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@ -165,18 +165,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: cupertino_icons name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.9" version: "1.0.8"
dart_style: dart_style:
dependency: transitive dependency: transitive
description: description:
name: dart_style name: dart_style
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.7" version: "3.1.2"
drift: drift:
dependency: "direct main" dependency: "direct main"
description: description:
@ -209,14 +209,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file: file:
dependency: transitive dependency: transitive
description: description:
@ -225,6 +217,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: "6f22d1c62e0c20976f02cd842c7b7cd3c0f561cc2052586411871045c08860c9"
url: "https://pub.dev"
source: hosted
version: "4.12.1"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
sha256: f74d1d6fabccf7743b0144c2ed363d81049e258e22428ebb6fb0faec2fa7d938
url: "https://pub.dev"
source: hosted
version: "8.0.0"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
sha256: ddab99d709b8c27dd47576eb05a1e719e07a2aa45c009a49ae92ac4d2a8ca555
url: "https://pub.dev"
source: hosted
version: "3.9.1"
firebase_messaging:
dependency: "direct main"
description:
name: firebase_messaging
sha256: "30ad2d59bcd86117dc49d278c8998a0fb390c5a3202f6e43e4bd215d3f1d0556"
url: "https://pub.dev"
source: hosted
version: "16.4.3"
firebase_messaging_platform_interface:
dependency: transitive
description:
name: firebase_messaging_platform_interface
sha256: "4d144cb42b9a5a42855596be2d7682d32c50169f42e7df2cb3278ecf935e7d63"
url: "https://pub.dev"
source: hosted
version: "4.9.2"
firebase_messaging_web:
dependency: transitive
description:
name: firebase_messaging_web
sha256: fcd25d0b9da55766ef4d28ae05a7460f5189635d6b42607adcd9f08818fd35f0
url: "https://pub.dev"
source: hosted
version: "4.2.3"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@ -290,10 +330,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_windows name: flutter_secure_storage_windows
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.2" version: "4.1.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -316,10 +356,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: google_fonts name: google_fonts
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.3" version: "6.3.2"
graphs: graphs:
dependency: transitive dependency: transitive
description: description:
@ -328,14 +368,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.3.2"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -368,38 +400,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.5" version: "1.0.5"
jni:
dependency: transitive
description:
name: jni
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
url: "https://pub.dev"
source: hosted
version: "1.0.3"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
jni_util:
dependency: transitive
description:
name: jni_util
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:
name: json_annotation name: json_annotation
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.12.0" version: "4.9.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@ -480,14 +488,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
url: "https://pub.dev"
source: hosted
version: "9.5.0"
package_config: package_config:
dependency: transitive dependency: transitive
description: description:
@ -508,42 +508,42 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.6" version: "2.1.5"
path_provider_android: path_provider_android:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.1" version: "2.2.19"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: transitive
description: description:
name: path_provider_foundation name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.0" version: "2.4.2"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
name: path_provider_linux name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.2" version: "2.2.1"
path_provider_platform_interface: path_provider_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: path_provider_platform_interface name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.1.2"
path_provider_windows: path_provider_windows:
dependency: transitive dependency: transitive
description: description:
@ -608,38 +608,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.0" version: "4.1.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.5" version: "2.5.3"
shared_preferences_android: shared_preferences_android:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_android name: shared_preferences_android
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.27" version: "2.4.13"
shared_preferences_foundation: shared_preferences_foundation:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_foundation name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.6" version: "2.5.4"
shared_preferences_linux: shared_preferences_linux:
dependency: transitive dependency: transitive
description: description:
@ -652,10 +644,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_platform_interface name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2" version: "2.4.1"
shared_preferences_web: shared_preferences_web:
dependency: transitive dependency: transitive
description: description:
@ -697,18 +689,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: source_gen name: source_gen
sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.4" version: "4.2.0"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
name: source_span name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.2" version: "1.10.1"
sqlcipher_flutter_libs: sqlcipher_flutter_libs:
dependency: "direct main" dependency: "direct main"
description: description:
@ -801,10 +793,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.2.0" version: "15.0.0"
watcher: watcher:
dependency: transitive dependency: transitive
description: description:
@ -841,10 +833,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: win32 name: win32
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.0" version: "5.15.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@ -862,5 +854,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.12.0 <4.0.0" dart: ">=3.10.0-0 <4.0.0"
flutter: ">=3.44.0" flutter: ">=3.29.0"

View File

@ -45,6 +45,8 @@ dependencies:
flutter_secure_storage: ^10.3.1 flutter_secure_storage: ^10.3.1
sqlcipher_flutter_libs: ^0.6.8 sqlcipher_flutter_libs: ^0.6.8
google_fonts: ^6.3.2 google_fonts: ^6.3.2
firebase_core: ^4.12.1
firebase_messaging: ^16.4.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

17
scripts/backup-postgres.sh Executable file
View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Run on the deploy host from the compose project directory (e.g. ~/project/ykavu).
# Creates gzipped pg_dump under ~/backups/ykavu and prunes older than keep-count.
set -euo pipefail
KEEP="${BACKUP_KEEP:-10}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
OUT_DIR="${BACKUP_DIR:-$HOME/backups/ykavu}"
mkdir -p "$OUT_DIR"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
DUMP="$OUT_DIR/ykavu-$STAMP.sql.gz"
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-ykavu}" "${POSTGRES_DB:-ykavu}" | gzip >"$DUMP"
ls -lh "$DUMP"
ls -1t "$OUT_DIR"/ykavu-*.sql.gz | tail -n +"$((KEEP + 1))" | xargs -r rm -f
echo "OK $DUMP"

View File

@ -1,5 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Push main to GitHub (origin) and Gitea (gitea) remotes. # Push a branch to GitHub (origin) and Gitea iykyka (gitea) remotes.
# Master policy: land work on main, then run: ./scripts/push-both.sh main
# See AGENTS.md "Git remotes & branch policy".
set -euo pipefail set -euo pipefail
BRANCH="${1:-main}" BRANCH="${1:-main}"
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."

43
scripts/server-up.sh Executable file
View File

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Run ON the iykyka host (SSH or Portainer host shell) after cloning this repo.
# Does not commit secrets. Requires Docker Compose v2 + .env filled.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if [[ ! -f .env ]]; then
echo "Missing .env — copy from .env.example and set ADMIN_API_TOKEN, POSTGRES_PASSWORD, GEMINI_API_KEY"
exit 1
fi
# shellcheck disable=SC1091
set -a
source .env
set +a
: "${ADMIN_API_TOKEN:?ADMIN_API_TOKEN required}"
: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required}"
: "${PUBLIC_API_BASE:=https://msn.iykyka.com}"
: "${WEB_HOST_PORT:=8088}"
export PUBLIC_API_BASE WEB_HOST_PORT
echo "==> Building & starting stack (PUBLIC_API_BASE=$PUBLIC_API_BASE WEB_HOST_PORT=$WEB_HOST_PORT)"
docker compose pull postgres || true
docker compose up -d --build --remove-orphans
echo "==> Waiting for web health on localhost:${WEB_HOST_PORT}"
for i in $(seq 1 60); do
if curl -fsS "http://127.0.0.1:${WEB_HOST_PORT}/health" >/dev/null 2>&1; then
echo "OK health"
curl -fsS "http://127.0.0.1:${WEB_HOST_PORT}/health"; echo
curl -fsS "http://127.0.0.1:${WEB_HOST_PORT}/demo"; echo
exit 0
fi
sleep 5
done
echo "Timed out waiting for /health — check: docker compose ps && docker compose logs"
docker compose ps
exit 1

0
secrets/.gitkeep Normal file
View File

12
secrets/README.md Normal file
View File

@ -0,0 +1,12 @@
# Secrets (gitignored)
Put the Firebase service account JSON here as:
```text
secrets/firebase-service-account.json
```
Never commit this file. Docker Compose mounts `./secrets` read-only into
`core-backend` as `/secrets` (`FCM_SERVICE_ACCOUNT_FILE`).
See [`docs/fcm-setup.md`](../docs/fcm-setup.md).