diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6995b7b --- /dev/null +++ b/.dockerignore @@ -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/** diff --git a/.env.example b/.env.example index 08a0e4e..5912766 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,25 @@ GEMINI_API_KEY= # core-backend privileged endpoints (/invites, /admin/metrics, /admin/dashboard, /admin/push-test) 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 -# Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test). -# Without this, notifyUser soft-skips and records push_skipped metrics. +# FCM push (escalation notify + /admin/push-test). Prefer HTTP v1 service account: +# 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_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 diff --git a/.gitignore b/.gitignore index 0f4b314..a645677 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,11 @@ poc/tone-corpus/data/ .env.* !.env.example +# Firebase / FCM service account JSON (never commit) +secrets/** +!secrets/.gitkeep +!secrets/README.md + __pycache__/ *.pyc @@ -17,3 +22,8 @@ __pycache__/ # Go build output (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 diff --git a/AGENTS.md b/AGENTS.md index 943885d..31f938b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,6 +90,9 @@ changes it. - 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. +- 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 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 @@ -113,6 +116,24 @@ Do not invent frameworks, folder layouts, or CI conventions beyond what in sync. - 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 - Read the relevant docs before proposing product/tech changes. diff --git a/CLAUDE.md b/CLAUDE.md index 87b74ca..24010e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,13 +4,18 @@ Follow the project instructions in [@AGENTS.md](./AGENTS.md). Quick context: -- Phase 1 A~C are in place (`core-backend/`, `ai-service/`, `mobile/`). Next is - **D — human PoC** (`docs/roadmap.md`). Do not start PoC early or invent §3 defaults. +- Phase 1 A~C are in place (`core-backend/`, `ai-service/`, `mobile/`). + **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: **와카뷰** (가칭 확정). - Source of decisions: `docs/decision-log.md` (Q1~Q7 **확정**; PoC sub-questions open). - v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first. - Hard bans for v1: L3/L4, OS-layer over third-party messengers, B2B. - 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 relevant files under `docs/`. diff --git a/README.md b/README.md index 6e86333..c3f54cd 100644 --- a/README.md +++ b/README.md @@ -18,17 +18,22 @@ - 기획: [`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) - 로드맵 (작업 체크리스트): [`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) - PoC 계획/준비물: [`docs/poc-plan.md`](./docs/poc-plan.md) · [`docs/poc-materials.md`](./docs/poc-materials.md) ## AI 에이전트 규칙 - [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md) +- **브랜치:** 작업·머지는 항상 `main` +- **리모트:** GitHub `origin` + Gitea `gitea`(iykyka) — `main` 갱신 후 `./scripts/push-both.sh` ## 현재 단계 -- Phase 1 **A~C**까지 반영됨 (서버·Flutter·베타 직전 문서/배포 경로) -- 다음: **D — 사람 PoC #1/#3·Q3 인터뷰** (`docs/roadmap.md` §3, 맨 마지막) +- Phase 1 **A~C** + **N2 컷오버 + N3 안정화 완료** — 라이브: [`https://msn.iykyka.com`](https://msn.iykyka.com) +- 테스터 안내: [`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가 기입 ## 로컬 실행 (요약) @@ -50,3 +55,18 @@ go run . migrate && go run . # 터미널 3 — Flutter (Android) 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`)만 공개. diff --git a/ai-service/.dockerignore b/ai-service/.dockerignore new file mode 100644 index 0000000..dfcd4c1 --- /dev/null +++ b/ai-service/.dockerignore @@ -0,0 +1,7 @@ +**/__pycache__ +**/.pytest_cache +tests +.git +*.md +Dockerfile +.env diff --git a/ai-service/Dockerfile b/ai-service/Dockerfile new file mode 100644 index 0000000..4be4aaa --- /dev/null +++ b/ai-service/Dockerfile @@ -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"] diff --git a/core-backend/.dockerignore b/core-backend/.dockerignore new file mode 100644 index 0000000..70aba44 --- /dev/null +++ b/core-backend/.dockerignore @@ -0,0 +1,5 @@ +**/*.db +**/__pycache__ +.git +*.md +Dockerfile diff --git a/core-backend/Dockerfile b/core-backend/Dockerfile new file mode 100644 index 0000000..18a90c1 --- /dev/null +++ b/core-backend/Dockerfile @@ -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"] diff --git a/core-backend/a1_a2_routes.go b/core-backend/a1_a2_routes.go index e8a7d1c..6d80c0b 100644 --- a/core-backend/a1_a2_routes.go +++ b/core-backend/a1_a2_routes.go @@ -20,6 +20,21 @@ type createContactRequest struct { 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 { 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()}) return } - c.JSON(http.StatusOK, gin.H{ - "id": contact.ID, - "display_name": contact.DisplayName, - "contact_user_id": contact.ContactUserID, - "relationship_note": contact.RelationshipNote, - }) + c.JSON(http.StatusOK, contactJSON(contact)) }) 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) out := make([]gin.H, 0, len(contacts)) for _, ct := range contacts { - out = append(out, gin.H{ - "id": ct.ID, - "display_name": ct.DisplayName, - "contact_user_id": ct.ContactUserID, - "relationship_note": ct.RelationshipNote, - }) + out = append(out, contactJSON(ct)) } 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) { userID, ok := parseUintParam(c, "id") if !ok { diff --git a/core-backend/a1_a2_test.go b/core-backend/a1_a2_test.go index 1613699..276d8b9 100644 --- a/core-backend/a1_a2_test.go +++ b/core-backend/a1_a2_test.go @@ -7,6 +7,46 @@ import ( "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) { server, _ := setupTestServer(t) resp := postJSON(t, server.URL+"/invites", nil) diff --git a/core-backend/b_test.go b/core-backend/b_test.go index 52b92b4..9e9749f 100644 --- a/core-backend/b_test.go +++ b/core-backend/b_test.go @@ -95,7 +95,7 @@ func TestRevokeSessionAndPushTestWithoutFCM(t *testing.T) { 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{}{ "user_id": userID, "title": "t", diff --git a/core-backend/demo.go b/core-backend/demo.go index 7ce0a22..e6278c3 100644 --- a/core-backend/demo.go +++ b/core-backend/demo.go @@ -63,6 +63,17 @@ func registerDemoRoutes(r *gin.Engine) { "demo_invite_code": demoInviteCode, "demo_display_name": "테스터", "hint": "회원가입 화면에 표시된 테스트 코드를 그대로 쓰면 됩니다.", + "pairing_steps": []string{ + "두 명이 같은 DEMO-YKAVU 코드로 각각 가입한다 (시크릿/다른 브라우저).", + "각자 대화 목록의 내 사용자 ID를 복사해 상대에게 알려 준다.", + "연락처에 상대 표시 이름 + 숫자 ID를 넣고 추가한 뒤 「대화」를 누른다.", + "메시지를 보내고, 자율성 L1에서 와카뷰 초안을 한 번 승인·전송해 본다.", + "L0(비서)에서는 초안을 「입력창으로 옮기기」만 되며 — 직접 보낸다.", + }, + "notes": []string{ + "대화는 표시 이름이 아니라 숫자 사용자 ID로 연결됩니다.", + "ID 없는 옛 연락처는 연락처 화면에서 「ID 입력」으로 고치면 됩니다.", + }, }) }) } diff --git a/core-backend/demo_test.go b/core-backend/demo_test.go index b0db64c..6988fa6 100644 --- a/core-backend/demo_test.go +++ b/core-backend/demo_test.go @@ -24,6 +24,10 @@ func TestDemoInviteReusableForMultipleSignups(t *testing.T) { if demo["demo_invite_code"] != demoInviteCode { 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{ InviteCode: demoInviteCode, diff --git a/core-backend/go.mod b/core-backend/go.mod index f4613b5..a2ddee6 100644 --- a/core-backend/go.mod +++ b/core-backend/go.mod @@ -3,19 +3,27 @@ module hikikomori/core-backend go 1.25.0 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/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // 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/universal-translator v0.18.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-yaml v1.19.2 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/text v0.34.0 // 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 ) diff --git a/core-backend/go.sum b/core-backend/go.sum index 182699d..37e0bda 100644 --- a/core-backend/go.sum +++ b/core-backend/go.sum @@ -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/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= 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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/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-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= 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/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 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-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= 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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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.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.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/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/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/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/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= 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/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/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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= 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 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/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= diff --git a/core-backend/main_test.go b/core-backend/main_test.go index 4c49d86..1f2bd1d 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -105,6 +105,21 @@ func postJSONAuth(t *testing.T, url, token string, body interface{}) *http.Respo 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 { t.Helper() req, _ := http.NewRequest(http.MethodDelete, url, nil) diff --git a/core-backend/push.go b/core-backend/push.go index d187004..a2aab5d 100644 --- a/core-backend/push.go +++ b/core-backend/push.go @@ -2,28 +2,111 @@ package main import ( "bytes" + "context" "encoding/json" "fmt" + "io" "net/http" "os" "strings" + "sync" "time" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" "gorm.io/gorm" ) -// FCM legacy HTTP API (server key). When FCM_SERVER_KEY is unset, notifyUser -// records a metric and returns without error so product flows stay usable. +const fcmMessagingScope = "https://www.googleapis.com/auth/firebase.messaging" + +// 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 { 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 { - To string `json:"to,omitempty"` - Registration []string `json:"registration_ids,omitempty"` - Priority string `json:"priority"` - Notification map[string]string `json:"notification"` - Data map[string]string `json:"data,omitempty"` + To string `json:"to,omitempty"` + Registration []string `json:"registration_ids,omitempty"` + Priority string `json:"priority"` + Notification map[string]string `json:"notification"` + Data map[string]string `json:"data,omitempty"` +} + +type fcmV1MessageRequest struct { + Message fcmV1Message `json:"message"` +} + +type fcmV1Message struct { + Token string `json:"token"` + Notification map[string]string `json:"notification,omitempty"` + Data map[string]string `json:"data,omitempty"` +} + +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)) + for _, t := range tokens { + if strings.HasPrefix(t.Token, "install:") { + continue + } + 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) { @@ -33,25 +116,89 @@ func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]st return 0, "no_device_tokens", nil } - key := fcmServerKey() - if key == "" { - runtimeMetrics.recordPush(0, true) - return 0, "fcm_not_configured", nil - } - - // Skip placeholder install:* tokens — they are not real FCM registration IDs. - regIDs := make([]string, 0, len(tokens)) - for _, t := range tokens { - if strings.HasPrefix(t.Token, "install:") { - continue - } - regIDs = append(regIDs, t.Token) - } + regIDs := collectFCMRegistrationIDs(tokens) if len(regIDs) == 0 { runtimeMetrics.recordPush(0, true) 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{ Registration: regIDs, Priority: "high", @@ -61,7 +208,7 @@ func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]st raw, _ := json.Marshal(payload) req, err := http.NewRequest(http.MethodPost, "https://fcm.googleapis.com/fcm/send", bytes.NewReader(raw)) if err != nil { - return 0, "", err + return 0, err } req.Header.Set("Content-Type", "application/json") 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} resp, err := client.Do(req) if err != nil { - runtimeMetrics.recordPush(0, true) - return 0, "", err + return 0, err } defer resp.Body.Close() 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 } diff --git a/deploy/nginx-web.conf b/deploy/nginx-web.conf new file mode 100644 index 0000000..9c36acb --- /dev/null +++ b/deploy/nginx-web.conf @@ -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; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6f31d80 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/PLANNING.md b/docs/PLANNING.md index f368ba0..0e95bbf 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -29,18 +29,18 @@ ## 2. 먼저 확정해야 할 결정 (회의 Q1~Q7) 기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다. -현재 작업용 답은 [`decision-log.md`](./decision-log.md)에 있으며, 상태는 모두 **제안(잠정)** 이다. -회의에서 정식 확정되기 전까지는 decision-log를 단일 기준으로 따른다. +현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30). +PoC 의존 하위 질문(자율성 기본값 등)만 열려 있다 — decision-log를 단일 기준으로 따른다. -| # | 질문 | 잠정 결정 | 상태 | +| # | 질문 | 결정 | 상태 | |---|------|------|------| -| Q1 | AI 와카뷰로 확정? | 예 | 제안 — 근거는 `decision-log.md` | -| Q2 | 타깃: 대중 vs 회사? | 대중 우선 (B2B는 이후) | 제안 | -| Q3 | 자율성 몇 단계까지 출시? | L0~L2만 | 제안 | -| Q4 | 사칭 우려 대응 충분한가? | 1차 설계는 충분, 실사용 검증 필요 | 제안 | -| Q5 | MVP 데모 시나리오 1개는? | 읽씹 종결 + 단톡 따라잡기 (묶음) | 제안 | -| Q6 | 서비스 이름 | "와카뷰" (가칭) | 제안 (가칭) | -| Q7 | 자체 앱 vs OS 레이어 시작점 | 자체 앱 클로즈드 베타 먼저 → OS 레이어는 이후 | 제안 | +| Q1 | AI 와카뷰로 확정? | 예 | **확정** — `decision-log.md` | +| Q2 | 타깃: 대중 vs 회사? | 대중 우선 (B2B는 이후) | **확정** | +| Q3 | 자율성 몇 단계까지 출시? | L0~L2만 (시작 기본값은 PoC 후) | **확정** (하위 기본값은 열림) | +| Q4 | 사칭 우려 대응 충분한가? | 1차 설계는 충분, 실사용 검증 필요 | **확정** (실사용은 PoC) | +| Q5 | MVP 데모 시나리오 1개는? | 읽씹 종결 + 단톡 따라잡기 (묶음) | **확정** | +| Q6 | 서비스 이름 | "와카뷰 (Ykavu)" | **확정** (2026-07-31 명칭 변경) | +| Q7 | 자체 앱 vs OS 레이어 시작점 | 자체 앱 클로즈드 베타 먼저 → OS 레이어는 이후 | **확정** | ## 3. MVP 시나리오 좁히기 @@ -147,10 +147,13 @@ PoC #3 역할극 자극재로 사용 - [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md` (질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장) -- [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영) +- [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영) — **맨 마지막(N5/D)** - [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 결과 무관 기반 작업(백엔드/클라이언트 뼈대)과 PoC 결과 필요 항목을 구분해둠 -- [ ] 기술 스택 결정 (클라이언트/백엔드/DB/메시지 릴레이/온디바이스 저장소) — `roadmap.md` - Phase 1 §1, 회의 필요 +- [x] 기술 스택 결정 — `tech-design.md` §8 / `roadmap.md` 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) diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md new file mode 100644 index 0000000..511c733 --- /dev/null +++ b/docs/deploy-checklist.md @@ -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의 대응 `[~]`/`[ ]`도 같이 갱신한다. diff --git a/docs/deploy-docker.md b/docs/deploy-docker.md new file mode 100644 index 0000000..25169c0 --- /dev/null +++ b/docs/deploy-docker.md @@ -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`. diff --git a/docs/deploy-portainer.md b/docs/deploy-portainer.md new file mode 100644 index 0000000..8dd420a --- /dev/null +++ b/docs/deploy-portainer.md @@ -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를 직접 수행한 뒤 결과만 공유 diff --git a/docs/fcm-setup.md b/docs/fcm-setup.md new file mode 100644 index 0000000..9c838a4 --- /dev/null +++ b/docs/fcm-setup.md @@ -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": , "title":"와카뷰","body":"push smoke"}' +``` + +기대: `sent >= 1`. +`only_placeholder_tokens` → Android에 `google-services.json` 넣고 재설치. +`fcm_not_configured` → 서버에 서비스 계정 파일 경로 확인. + +## Web + +Web 푸시는 별도 VAPID 설정이 필요하며 이번 N4는 **Android 실푸시** 우선. diff --git a/docs/ops-backup.md b/docs/ops-backup.md new file mode 100644 index 0000000..f3a13c6 --- /dev/null +++ b/docs/ops-backup.md @@ -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`)에 복구해 검증한 뒤 컷오버. diff --git a/docs/roadmap.md b/docs/roadmap.md index 8d168ee..304ea72 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -212,10 +212,19 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase ##### D. 맨 마지막 — 사람 PoC (지금 안 함) - §3 항목과 동일. A~C 완료 후에만 착수. + **단, 프로덕션 Docker 배포·스모크(N1~N3)는 D보다 앞** — [`deploy-checklist.md`](./deploy-checklist.md). ##### E. 베타 이후 (지금은 설계만, 구현 금지) - 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 확장 + 베타 확대 diff --git a/docs/tester-guide.md b/docs/tester-guide.md new file mode 100644 index 0000000..81f0e62 --- /dev/null +++ b/docs/tester-guide.md @@ -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` (호스트에서) diff --git a/mobile/Dockerfile b/mobile/Dockerfile new file mode 100644 index 0000000..5cf6f56 --- /dev/null +++ b/mobile/Dockerfile @@ -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 diff --git a/mobile/Dockerfile.prebuilt b/mobile/Dockerfile.prebuilt new file mode 100644 index 0000000..ce03106 --- /dev/null +++ b/mobile/Dockerfile.prebuilt @@ -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 diff --git a/mobile/README.md b/mobile/README.md index 899c8e1..8e73b14 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -63,9 +63,14 @@ HTTP로 검증한다. 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 결과는 맨 마지막에 반영) - iOS 빌드 (v1 범위 밖) diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore index a291f3c..b262afb 100644 --- a/mobile/android/.gitignore +++ b/mobile/android/.gitignore @@ -14,3 +14,6 @@ key.properties **/*.jks *.jks *.keystore + +# Firebase — never commit real project credentials (see docs/fcm-setup.md). +app/google-services.json diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 48a5c6f..bd3b313 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -8,6 +8,11 @@ plugins { 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 keystorePropertiesFile = rootProject.file("key.properties") val hasReleaseKeystore = keystorePropertiesFile.exists() diff --git a/mobile/android/app/google-services.json.example b/mobile/android/app/google-services.json.example new file mode 100644 index 0000000..d535fee --- /dev/null +++ b/mobile/android/app/google-services.json.example @@ -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" +} diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index c2a24b9..cf477bb 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + { if (draft == null || draft.isEscalate || session.user == null) return; final text = _draftEdit.text.trim(); 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); try { final msg = await session.api.sendMessage( @@ -278,6 +290,14 @@ class _ChatScreenState extends State { ); } + final level = context.watch().autonomyLevel; + final isL0 = level == AutonomyLevel.L0; + final title = isL0 + ? '초안 (L0) — 직접 보내기' + : level == AutonomyLevel.L1 + ? 'L1 승인 — 수정 후 보내기' + : '초안 (L2) — 승인 후 보내기'; + return Container( margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), padding: const EdgeInsets.all(16), @@ -293,12 +313,21 @@ class _ChatScreenState extends State { children: [ Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.onSecondaryContainer), const SizedBox(width: 6), - Text( - 'L1 승인 — 수정 후 보내기', - style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onSecondaryContainer), + Expanded( + child: Text( + title, + 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), TextField( controller: _draftEdit, @@ -319,7 +348,7 @@ class _ChatScreenState extends State { const SizedBox(width: 8), FilledButton( onPressed: _busy ? null : _sendTwinApproved, - child: const Text('승인하고 보내기'), + child: Text(isL0 ? '입력창으로 옮기기' : '승인하고 보내기'), ), ], ), diff --git a/mobile/lib/screens/contacts_screen.dart b/mobile/lib/screens/contacts_screen.dart index 7bc5ffc..afd8dc9 100644 --- a/mobile/lib/screens/contacts_screen.dart +++ b/mobile/lib/screens/contacts_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../models/models.dart'; import '../services/api_client.dart'; import '../state/session_state.dart'; +import '../widgets/my_user_id_chip.dart'; import 'chat_screen.dart'; class ContactsScreen extends StatefulWidget { @@ -45,32 +46,44 @@ class _ContactsScreenState extends State { final nameCtrl = TextEditingController(); final peerCtrl = TextEditingController(); final noteCtrl = TextEditingController(); + final session = context.read(); + final myId = session.user?.id; final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('연락처 추가'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: nameCtrl, - decoration: const InputDecoration(labelText: '표시 이름'), - ), - const SizedBox(height: 12), - TextField( - controller: peerCtrl, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: '상대 사용자 ID (선택)', - helperText: '대화 시작에 필요', + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (myId != null) ...[ + MyUserIdChip(userId: myId), + const SizedBox(height: 12), + ], + TextField( + controller: nameCtrl, + decoration: const InputDecoration( + labelText: '표시 이름', + helperText: '목록에 보일 이름 (예: 친구 닉네임)', + ), ), - ), - const SizedBox(height: 12), - TextField( - controller: noteCtrl, - decoration: const InputDecoration(labelText: '관계 메모 (선택)'), - ), - ], + const SizedBox(height: 12), + TextField( + controller: peerCtrl, + keyboardType: TextInputType.number, + 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('취소')), @@ -79,20 +92,30 @@ class _ContactsScreenState extends State { ), ); if (ok != true || !mounted) return; - final session = context.read(); final name = nameCtrl.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 peer = int.tryParse(peerCtrl.text.trim()); final created = await session.api.createContact( userId: session.user!.id, displayName: name, contactUserId: peer, relationshipNote: noteCtrl.text.trim(), ); - setState(() => _contacts = [..._contacts, created]); + setState(() { + _contacts = [..._contacts, created]; + _error = null; + }); } on ApiException catch (e) { - setState(() => _error = '추가 실패 (${e.statusCode})'); + setState(() => _error = '추가 실패 (${e.statusCode}): ${e.body}'); } } @@ -100,7 +123,7 @@ class _ContactsScreenState extends State { final session = context.read(); final me = session.user; if (me == null || contact.contactUserId == null) { - setState(() => _error = '상대 사용자 ID가 있는 연락처만 대화를 시작할 수 있습니다.'); + setState(() => _error = '이 연락처에는 상대 사용자 ID가 없습니다. 「ID 입력」으로 숫자 ID를 넣으세요.'); return; } try { @@ -110,13 +133,95 @@ class _ContactsScreenState extends State { ); if (!mounted) return; 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) { setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}'); } } + Future _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(); + final ok = await showDialog( + 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 _delete(Contact c) async { final session = context.read(); if (session.user == null) return; @@ -140,8 +245,15 @@ class _ContactsScreenState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final me = context.watch().user?.id; + final missingIdCount = _contacts.where((c) => c.contactUserId == null).length; return Scaffold( - appBar: AppBar(title: const Text('연락처')), + appBar: AppBar( + title: const Text('연락처'), + actions: [ + if (me != null) MyUserIdChip(userId: me, compact: true), + ], + ), floatingActionButton: FloatingActionButton( onPressed: _showAddDialog, tooltip: '연락처 추가', @@ -154,6 +266,29 @@ class _ContactsScreenState extends State { : ListView( padding: const EdgeInsets.symmetric(vertical: 4), 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) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -161,17 +296,21 @@ class _ContactsScreenState extends State { ), if (_contacts.isEmpty) Padding( - padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32), + padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 32), child: Column( children: [ Icon(Icons.person_add_outlined, size: 40, color: theme.colorScheme.outline), const SizedBox(height: 12), Text('연락처가 없습니다', style: theme.textTheme.titleMedium), - const SizedBox(height: 4), + const SizedBox(height: 8), Text( - '오른쪽 아래 버튼으로 첫 연락처를 추가해 보세요.', + '상대에게 내 ID를 알려 주고, 상대의 숫자 ID를 받아 추가하세요.\n' + '표시 이름만 넣고 ID를 비우면 대화를 시작할 수 없습니다.', 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 { ), title: Text(c.displayName, style: theme.textTheme.titleSmall), subtitle: Text( - [ - if (c.contactUserId != null) '사용자 #${c.contactUserId}', - if (c.relationshipNote.isNotEmpty) c.relationshipNote, - ].join(' · '), - style: theme.textTheme.bodySmall, + c.contactUserId == null + ? '사용자 ID 없음 — 대화 불가 (다시 추가 필요)' + : [ + '사용자 #${c.contactUserId}', + if (c.relationshipNote.isNotEmpty) c.relationshipNote, + ].join(' · '), + style: theme.textTheme.bodySmall?.copyWith( + color: c.contactUserId == null + ? theme.colorScheme.error + : theme.colorScheme.onSurfaceVariant, + ), ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - if (c.contactUserId != null) - TextButton(onPressed: () => _startChat(c), child: const Text('대화')), + if (c.contactUserId != null) ...[ + 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( tooltip: '삭제', icon: const Icon(Icons.delete_outline, size: 20), @@ -210,6 +368,13 @@ class _ContactsScreenState extends State { ), ], ), + onTap: () { + if (c.contactUserId == null) { + _editContact(c); + } else { + _startChat(c); + } + }, ), ), const SizedBox(height: 72), diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart index 364cf99..ba3f3e1 100644 --- a/mobile/lib/screens/conversation_list_screen.dart +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -6,6 +6,7 @@ import '../services/api_client.dart'; import '../state/session_state.dart'; import '../theme/app_theme.dart'; import '../widgets/gradient_text.dart'; +import '../widgets/my_user_id_chip.dart'; import 'autonomy_settings_screen.dart'; import 'chat_screen.dart'; import 'contacts_screen.dart'; @@ -21,6 +22,7 @@ class ConversationListScreen extends StatefulWidget { class _ConversationListScreenState extends State { List _rooms = []; + Map _peerNames = {}; bool _loading = true; String? _error; @@ -39,7 +41,23 @@ class _ConversationListScreenState extends State { try { final list = await session.api.listConversations(); list.sort((a, b) => b.id.compareTo(a.id)); - setState(() => _rooms = list); + final names = {}; + 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) { setState(() => _error = '대화 목록 실패 (${e.statusCode}): ${e.body}'); } finally { @@ -47,20 +65,55 @@ class _ConversationListScreenState extends State { } } + 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 [] : room.userIds.where((id) => id != me).toList(); + final peerPart = peers.isEmpty ? '참가자 없음' : '상대 ID ${peers.first}'; + return '$peerPart · 방 #${room.id}'; + } + Future _createConversation() async { final peerCtrl = TextEditingController(); + final session = context.read(); + final myId = session.user?.id; final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('새 대화'), - content: TextField( - controller: peerCtrl, - keyboardType: TextInputType.number, - autofocus: true, - decoration: const InputDecoration( - labelText: '상대 사용자 ID', - helperText: '연락처에 등록된 상대면 연락처 화면에서 시작하는 편이 낫습니다.', - ), + 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, + keyboardType: TextInputType.number, + autofocus: true, + decoration: const InputDecoration( + labelText: '상대 사용자 ID (숫자)', + helperText: '이름/닉네임이 아니라 숫자 ID입니다. 연락처에 등록돼 있으면 연락처에서 시작하세요.', + ), + ), + ], ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), @@ -69,15 +122,31 @@ class _ConversationListScreenState extends State { ), ); if (ok != true || !mounted) return; - final session = context.read(); final me = session.user; 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 { final conv = await session.api.createConversation(userIds: [me.id, peer]); if (!mounted) return; await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => ChatScreen(conversationId: conv.id)), + MaterialPageRoute( + builder: (_) => ChatScreen( + conversationId: conv.id, + title: _peerNames[peer] ?? '상대 #$peer', + ), + ), ); await _load(); } on ApiException catch (e) { @@ -107,6 +176,7 @@ class _ConversationListScreenState extends State { appBar: AppBar( title: GradientText('와카뷰', style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)), actions: [ + if (me != null) MyUserIdChip(userId: me, compact: true), IconButton( tooltip: '사후 알림', onPressed: () { @@ -163,12 +233,17 @@ class _ConversationListScreenState extends State { padding: const EdgeInsets.symmetric(vertical: 4), children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), child: Text( session.user == null ? '' : '안녕하세요, ${session.user!.displayName}님', 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) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -196,15 +271,28 @@ class _ConversationListScreenState extends State { child: const Icon(Icons.chat_bubble_outline, size: 30, color: Colors.white), ), const SizedBox(height: 16), + Text('대화방이 없습니다', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), Text( - '대화방이 없습니다', - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text( - '연락처나 "새 대화" 버튼으로 첫 대화를 시작해 보세요.', + '1) 내 ID를 상대에게 알려 주세요\n' + '2) 연락처에 상대의 숫자 ID를 넣고 추가\n' + '3) 연락처에서 「대화」또는 행을 탭하세요', 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 { ), ), title: Text( - me == null ? '대화방 #${room.id}' : room.titleFor(me), + _titleFor(room, me), style: theme.textTheme.titleSmall, ), - subtitle: room.twinDisabledByPeer - ? Row( - children: [ - Icon(Icons.block, size: 13, color: theme.colorScheme.error), - const SizedBox(width: 4), - Text( - '상대가 와카뷰를 거부함', - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error), - ), - ], - ) - : Text('대화방 ID ${room.id}', style: theme.textTheme.bodySmall), + subtitle: Row( + children: [ + if (room.twinDisabledByPeer) ...[ + Icon(Icons.block, size: 13, color: theme.colorScheme.error), + const SizedBox(width: 4), + ], + Expanded( + child: Text( + _subtitleFor(room, me), + style: theme.textTheme.bodySmall?.copyWith( + color: room.twinDisabledByPeer + ? theme.colorScheme.error + : theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), trailing: const Icon(Icons.chevron_right, size: 20), onTap: () async { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ChatScreen( conversationId: room.id, - title: me == null ? null : room.titleFor(me), + title: _titleFor(room, me), ), ), ); diff --git a/mobile/lib/screens/data_flow_screen.dart b/mobile/lib/screens/data_flow_screen.dart index 3baeaaa..7692fab 100644 --- a/mobile/lib/screens/data_flow_screen.dart +++ b/mobile/lib/screens/data_flow_screen.dart @@ -96,7 +96,7 @@ class DataFlowScreen extends StatelessWidget { body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n' '채팅 릴레이: 보낸 메시지 본문\n' '계정: 표시 이름·초대 코드·세션 토큰\n' - '푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후', + '푸시: Firebase 연결 시 실 FCM 토큰 등록 · 미연결 시 install 플레이스홀더 (docs/fcm-setup.md)', ), _section( context, diff --git a/mobile/lib/screens/signup_screen.dart b/mobile/lib/screens/signup_screen.dart index a28c709..879c289 100644 --- a/mobile/lib/screens/signup_screen.dart +++ b/mobile/lib/screens/signup_screen.dart @@ -181,6 +181,14 @@ class _DemoTestPanel extends StatelessWidget { '탭하면 입력란에 채워집니다 · 여러 명이 같은 코드로 가입 가능', 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, + ), + ), ], ), ), diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart index 0e27a35..6e3f264 100644 --- a/mobile/lib/services/api_client.dart +++ b/mobile/lib/services/api_client.dart @@ -127,6 +127,21 @@ class ApiClient { return Contact.fromJson(json); } + Future 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 deleteContact(int userId, int contactId) async { await _json('DELETE', '/users/$userId/contacts/$contactId'); } diff --git a/mobile/lib/services/push_token_service.dart b/mobile/lib/services/push_token_service.dart new file mode 100644 index 0000000..fa9826e --- /dev/null +++ b/mobile/lib/services/push_token_service.dart @@ -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 Function(String installId)? storeInstallId; + + /// Load a previously stored install id. + final Future 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 _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 _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'; + } +} diff --git a/mobile/lib/state/session_state.dart b/mobile/lib/state/session_state.dart index ca94f8f..1f6da82 100644 --- a/mobile/lib/state/session_state.dart +++ b/mobile/lib/state/session_state.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:math'; import 'package:flutter/foundation.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 '../models/models.dart'; import '../services/api_client.dart'; +import '../services/push_token_service.dart'; class SessionState extends ChangeNotifier { 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 - /// is wired with a real FCM registration token (roadmap B). + /// Registers a real FCM token when Firebase is configured; otherwise a stable + /// `install:` placeholder (server skips placeholders for delivery). Future _registerDeviceTokenBestEffort() async { if (user == null) return; try { _db ??= await _openDb(); - var installId = await _db!.getKv(_kDeviceInstallId); - if (installId == null || installId.isEmpty) { - final rand = Random.secure(); - installId = List.generate(16, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')).join(); - await _db!.setKv(_kDeviceInstallId, installId); - } + final resolved = await PushTokenService( + readInstallId: () => _db!.getKv(_kDeviceInstallId), + storeInstallId: (id) => _db!.setKv(_kDeviceInstallId, id), + ).resolve(); await _api.registerDeviceToken( userId: user!.id, - token: 'install:$installId', - platform: defaultTargetPlatform == TargetPlatform.android ? 'android' : 'other', + token: resolved.token, + platform: resolved.platform, + ); + debugPrint( + resolved.isFcm + ? 'device token registered (FCM)' + : 'device token registered (install placeholder)', ); } catch (e) { debugPrint('device token register skipped: $e'); diff --git a/mobile/lib/widgets/my_user_id_chip.dart b/mobile/lib/widgets/my_user_id_chip.dart new file mode 100644 index 0000000..aa602d6 --- /dev/null +++ b/mobile/lib/widgets/my_user_id_chip.dart @@ -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 _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), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 25af7a5..939d362 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -5,18 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc" + sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a url: "https://pub.dev" 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: dependency: transitive description: name: analyzer - sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0" + sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f" url: "https://pub.dev" source: hosted - version: "10.2.0" + version: "8.1.1" args: dependency: transitive description: @@ -29,10 +37,10 @@ packages: dependency: transitive description: name: async - sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.13.1" + version: "2.13.0" boolean_selector: dependency: transitive description: @@ -61,10 +69,10 @@ packages: dependency: transitive description: name: build_daemon - sha256: "8c0535c3b2f625619f4dd1036ef1127f2e77bfedf89ed2eb2676ef076e0b6712" + sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78 url: "https://pub.dev" source: hosted - version: "4.1.3" + version: "4.1.2" build_runner: dependency: "direct dev" description: @@ -129,14 +137,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -165,18 +165,18 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 url: "https://pub.dev" source: hosted - version: "1.0.9" + version: "1.0.8" dart_style: dependency: transitive description: name: dart_style - sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" + sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697 url: "https://pub.dev" source: hosted - version: "3.1.7" + version: "3.1.2" drift: dependency: "direct main" description: @@ -209,14 +209,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -225,6 +217,54 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -290,10 +330,10 @@ packages: dependency: transitive description: name: flutter_secure_storage_windows - sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "4.2.2" + version: "4.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -316,10 +356,10 @@ packages: dependency: "direct main" description: name: google_fonts - sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c" url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.3.2" graphs: dependency: transitive description: @@ -328,14 +368,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" - hooks: - dependency: transitive - description: - name: hooks - sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" - url: "https://pub.dev" - source: hosted - version: "2.0.2" http: dependency: "direct main" description: @@ -368,38 +400,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: name: json_annotation - sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted - version: "4.12.0" + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -480,14 +488,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -508,42 +508,42 @@ packages: dependency: "direct main" description: name: path_provider - sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" url: "https://pub.dev" source: hosted - version: "2.1.6" + version: "2.1.5" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.2.19" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.4.2" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.1" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.2" path_provider_windows: dependency: transitive description: @@ -608,38 +608,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: name: shared_preferences - sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" url: "https://pub.dev" source: hosted - version: "2.5.5" + version: "2.5.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e url: "https://pub.dev" source: hosted - version: "2.4.27" + version: "2.4.13" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" url: "https://pub.dev" source: hosted - version: "2.5.6" + version: "2.5.4" shared_preferences_linux: dependency: transitive description: @@ -652,10 +644,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.1" shared_preferences_web: dependency: transitive description: @@ -697,18 +689,18 @@ packages: dependency: transitive description: name: source_gen - sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 + sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" url: "https://pub.dev" source: hosted - version: "4.2.4" + version: "4.2.0" source_span: dependency: transitive description: name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.2" + version: "1.10.1" sqlcipher_flutter_libs: dependency: "direct main" description: @@ -801,10 +793,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "15.2.0" + version: "15.0.0" watcher: dependency: transitive description: @@ -841,10 +833,10 @@ packages: dependency: transitive description: name: win32 - sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "5.15.0" xdg_directories: dependency: transitive description: @@ -862,5 +854,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.12.0 <4.0.0" - flutter: ">=3.44.0" + dart: ">=3.10.0-0 <4.0.0" + flutter: ">=3.29.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a47508f..7ecdae3 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -45,6 +45,8 @@ dependencies: flutter_secure_storage: ^10.3.1 sqlcipher_flutter_libs: ^0.6.8 google_fonts: ^6.3.2 + firebase_core: ^4.12.1 + firebase_messaging: ^16.4.3 dev_dependencies: flutter_test: diff --git a/scripts/backup-postgres.sh b/scripts/backup-postgres.sh new file mode 100755 index 0000000..c8cd211 --- /dev/null +++ b/scripts/backup-postgres.sh @@ -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" diff --git a/scripts/push-both.sh b/scripts/push-both.sh index ef28999..7fb4fa7 100755 --- a/scripts/push-both.sh +++ b/scripts/push-both.sh @@ -1,5 +1,7 @@ #!/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 BRANCH="${1:-main}" cd "$(dirname "$0")/.." diff --git a/scripts/server-up.sh b/scripts/server-up.sh new file mode 100755 index 0000000..a856a1e --- /dev/null +++ b/scripts/server-up.sh @@ -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 diff --git a/secrets/.gitkeep b/secrets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/secrets/README.md b/secrets/README.md new file mode 100644 index 0000000..38fe44d --- /dev/null +++ b/secrets/README.md @@ -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).