From 2eed4e492c67464e85d616d7e9393afea0f1df22 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 01:51:31 +0000 Subject: [PATCH 01/18] =?UTF-8?q?docs:=20add=20deploy/residual=20checklist?= =?UTF-8?q?=20(N1=E2=80=93N5)=20after=20Phase=201=20A=E2=80=93C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate Claude + Cursor DONE state and the next execution track (smoke → Docker msn.iykyka.com → stabilize → FCM/Android QA → human PoC). Sync stale PLANNING Q1–Q7/stack status and point AGENTS/README/CLAUDE/roadmap at docs/deploy-checklist.md. Co-authored-by: okuma --- AGENTS.md | 21 +++++ CLAUDE.md | 9 +- README.md | 6 +- docs/PLANNING.md | 31 ++++--- docs/deploy-checklist.md | 183 +++++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 9 ++ scripts/push-both.sh | 4 +- 7 files changed, 245 insertions(+), 18 deletions(-) create mode 100644 docs/deploy-checklist.md 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..8c33bac 100644 --- a/README.md +++ b/README.md @@ -18,17 +18,21 @@ - 기획: [`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, 맨 마지막) +- **다음:** [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) — N1 스모크 → N2 `msn.iykyka.com` Docker → N3 안정화 → N4 FCM/Android QA +- **맨 마지막:** N5 / D — 사람 PoC #1/#3·Q3 인터뷰 (`docs/roadmap.md` §3). §3 기본값 추측 금지 - 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입 ## 로컬 실행 (요약) diff --git a/docs/PLANNING.md b/docs/PLANNING.md index f368ba0..e359cf1 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에서 SQLite 1차 등 운영 결정) +- [ ] 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..13b1b6f --- /dev/null +++ b/docs/deploy-checklist.md @@ -0,0 +1,183 @@ +# 배포·잔여 작업 체크리스트 (와카뷰) + +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-BUNSIN` +- [x] GitHub + Gitea 듀얼 리모트 (`scripts/push-both.sh`) + +### NOW + +- 앱 코드는 클로즈드 베타 직전 수준 +- **프로덕션 Docker / `msn.iykyka.com` 배포는 미착수** +- 실 FCM · Android UI 수동 QA · 사람 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** | 서비스 기동 | todo | `8080` core-backend, `8001` ai-service, (옵션) `5555` Flutter web 헬스 OK | +| **N1-2** | API E2E | todo | `ADMIN_API_TOKEN` 설정 후 `python3 scripts/e2e_a3.py` 16/16 | +| **N1-3** | Web 가입 스모크 | todo | `DEMO-BUNSIN` → 표시명 → 가입 → 세션/다음 화면 | +| **N1-4** | 말투 온보딩 스모크 | todo | 샘플 저장 또는 스킵 후 대화 목록 진입 | +| **N1-5** | 핵심 메신저 스모크 | todo | 연락처·대화·메시지 또는 초안/L1 중 최소 1경로 UI 왕복 | +| **N1-6** | 프로덕션 CORS/API 메모 | todo | `msn.iykyka.com`용 origins / `CORE_API_BASE` 변경 목록 작성 | + +로컬 포트 참고: 앱 **5555**, 코어 **8080**, AI **8001**. Dart VM Service 고포트(예: 39369)는 디버그용 — 무시 가능. + +--- + +## N2 — Docker 배포 (`https://msn.iykyka.com`) — Plan A + +### N2-A. 착수 전 결정 (Master 확인) + +| ID | 결정 | 제안 기본값 | Status | +|----|------|-------------|--------| +| **N2-A1** | 구 Node MSN 교체 | 교체(Plan A) | todo | +| **N2-A2** | DB | 1차 SQLite 파일 볼륨 (PostgreSQL은 이후) | todo | +| **N2-A3** | AI 서비스 노출 | 내부망만 (외부 포트 미공개) | todo | +| **N2-A4** | 클라이언트 제공 | Web 컨테이너 ± 내부 APK (둘 다/웹만 결정) | todo | +| **N2-A5** | 시크릿 관리 | Portainer/호스트 env — **git 금지** | todo | +| **N2-A6** | 데모 초대 | 프로덕션 `ALLOW_DEMO_INVITE` on/off | todo | + +### N2-B. 이미지·compose + +| ID | 작업 | Status | 완료 조건 | +|----|------|--------|-----------| +| **N2-B1** | `core-backend` Dockerfile | todo | `docker build` 성공, migrate/기동 | +| **N2-B2** | `ai-service` Dockerfile | todo | `docker build` 성공 | +| **N2-B3** | Flutter web 빌드/서빙 | todo | `flutter build web` + nginx(또는 Caddy)로 `/` 로딩 | +| **N2-B4** | `docker-compose.yml` | todo | `up` 후 서비스 healthy | +| **N2-B5** | env 템플릿 | todo | `.env.example`에 키만: `GEMINI_API_KEY`, `ADMIN_API_TOKEN`, `AI_SERVICE_URL`, DB, `ALLOW_DEMO_INVITE`, CORS/origins, `FCM_*` | +| **N2-B6** | 데이터 볼륨 | todo | 재시작 후 SQLite(및 필요 경로) 유지 | +| **N2-B7** | 내부 DNS | todo | Go → `http://ai-service:…` draft/escalate 동작 | +| **N2-B8** | CORS + API base | todo | `https://msn.iykyka.com`에서 브라우저 가입 성공 | +| **N2-B9** | 리버스 프록시 | todo | HTTPS로 도메인 접속 | +| **N2-B10** | Portainer 스택 | todo | 스택 Up, 절차를 Notes에 기록 | +| **N2-B11** | 구 MSN 컷오버 | todo | 새 스택이 도메인 응답 + 롤백 메모 | +| **N2-B12** | 배포 스모크 | todo | N1-3~N1-5를 프로덕션 URL로 재실행 | +| **N2-B13** | 운영 runbook | todo | 로그·재시작·SQLite 백업·초대 발급 1페이지 (`docs/` 또는 본 파일 Notes) | + +관련: Portainer `https://portainer.iykyka.com/`, 호스트 SSH는 인프라 메모 참고(시크릿은 커밋 금지). + +--- + +## N3 — 배포 직후 안정화 + +| ID | 작업 | Status | 완료 조건 | +|----|------|--------|-----------| +| **N3-1** | 헬스/로그 | todo | OOM·CORS·502 없음 | +| **N3-2** | 초대 발급 리허설 | todo | `docs/invite-ops.md` 절차 1회 | +| **N3-3** | admin metrics | todo | `/admin/metrics`·`/admin/dashboard` 토큰 조회 | +| **N3-4** | Gemini | todo | draft 1회 실호출 또는 mock 정책 명시 | +| **N3-5** | 백업 리허설 | todo | SQLite 볼륨 복사/복구 1회 | +| **N3-6** | 테스터 안내 | todo | URL + 초대(`DEMO-BUNSIN` 또는 개인 코드) + 주의사항 | + +--- + +## N4 — 베타 품질 잔여 + +### FCM + +| ID | 작업 | Status | 완료 조건 | +|----|------|--------|-----------| +| **N4-1** | Firebase + `google-services.json` | todo | Android 앱 연결 | +| **N4-2** | 실 FCM registration token | todo | install-id 플레이스홀더 제거 | +| **N4-3** | 서버 FCM 자격증명 | todo | env만 (`FCM_SERVER_KEY` 또는 HTTP v1) | +| **N4-4** | 푸시 수신 | todo | `/admin/push-test` + 에스컬레이션 수신 | + +### Android UI 탭 (`mobile/README.md`) + +| ID | 작업 | Status | +|----|------|--------| +| **N4-5** | 가입 → 말투 저장 | todo | +| **N4-6** | 연락처 → 대화 → 메시지·히스토리 | todo | +| **N4-7** | L1 초안 수정/버리기/승인·뱃지 | todo | +| **N4-8** | 에스컬레이션 → 사후알림 함 | todo | +| **N4-9** | 되돌리기·거부권 | todo | +| **N4-10** | 자율성 L0~L2 + 화이트리스트 | todo | + +### 후순위 + +| 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** 결정 체크 +2. **N1-1~N1-5** 스모크 +3. **N2-B1~B4** Dockerfile + compose +4. **N2-B8~B12** 도메인·Portainer·컷오버·스모크 +5. **N3-6** 테스터 안내 + +완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. 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/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")/.." From 3a8b5b1a76d5792fc7cd129ecd2e0f267a72a2ed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 02:04:53 +0000 Subject: [PATCH 02/18] docs: confirm N2-A2 production DB as PostgreSQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Master decision: msn.iykyka.com deploy uses Postgres (tech-design §8). SQLite remains local/test only; update compose/volume/backup checklist items. Co-authored-by: okuma --- docs/PLANNING.md | 2 +- docs/deploy-checklist.md | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/PLANNING.md b/docs/PLANNING.md index e359cf1..0e95bbf 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -154,6 +154,6 @@ PoC 의존 하위 질문(자율성 기본값 등)만 열려 있다 — decision- 무관 기반 작업(백엔드/클라이언트 뼈대)과 PoC 결과 필요 항목을 구분해둠 - [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에서 SQLite 1차 등 운영 결정) + [`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 index 13b1b6f..b84560a 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -78,10 +78,10 @@ Notes: ### N2-A. 착수 전 결정 (Master 확인) -| ID | 결정 | 제안 기본값 | Status | -|----|------|-------------|--------| +| ID | 결정 | 값 | Status | +|----|------|-----|--------| | **N2-A1** | 구 Node MSN 교체 | 교체(Plan A) | todo | -| **N2-A2** | DB | 1차 SQLite 파일 볼륨 (PostgreSQL은 이후) | todo | +| **N2-A2** | DB | **PostgreSQL** (`tech-design.md` §8). compose에 `postgres` 서비스 + `DATABASE_URL`. SQLite는 로컬/테스트 전용 | **done** (2026-07-31 Master 확정) | | **N2-A3** | AI 서비스 노출 | 내부망만 (외부 포트 미공개) | todo | | **N2-A4** | 클라이언트 제공 | Web 컨테이너 ± 내부 APK (둘 다/웹만 결정) | todo | | **N2-A5** | 시크릿 관리 | Portainer/호스트 env — **git 금지** | todo | @@ -91,19 +91,19 @@ Notes: | ID | 작업 | Status | 완료 조건 | |----|------|--------|-----------| -| **N2-B1** | `core-backend` Dockerfile | todo | `docker build` 성공, migrate/기동 | +| **N2-B1** | `core-backend` Dockerfile | todo | `docker build` 성공, migrate/기동 (`DATABASE_URL` → Postgres) | | **N2-B2** | `ai-service` Dockerfile | todo | `docker build` 성공 | | **N2-B3** | Flutter web 빌드/서빙 | todo | `flutter build web` + nginx(또는 Caddy)로 `/` 로딩 | -| **N2-B4** | `docker-compose.yml` | todo | `up` 후 서비스 healthy | -| **N2-B5** | env 템플릿 | todo | `.env.example`에 키만: `GEMINI_API_KEY`, `ADMIN_API_TOKEN`, `AI_SERVICE_URL`, DB, `ALLOW_DEMO_INVITE`, CORS/origins, `FCM_*` | -| **N2-B6** | 데이터 볼륨 | todo | 재시작 후 SQLite(및 필요 경로) 유지 | -| **N2-B7** | 내부 DNS | todo | Go → `http://ai-service:…` draft/escalate 동작 | +| **N2-B4** | `docker-compose.yml` | todo | `postgres` + core + ai (+ web) `up` 후 healthy | +| **N2-B5** | env 템플릿 | todo | `.env.example`에 키만: `GEMINI_API_KEY`, `ADMIN_API_TOKEN`, `AI_SERVICE_URL`, `DATABASE_URL` / Postgres 비밀번호, `ALLOW_DEMO_INVITE`, CORS/origins, `FCM_*` | +| **N2-B6** | 데이터 볼륨 | todo | Postgres 데이터 볼륨 persist (재시작 후 데이터 유지) | +| **N2-B7** | 내부 DNS | todo | Go → `http://ai-service:…` draft/escalate, Go → `postgres:5432` 동작 | | **N2-B8** | CORS + API base | todo | `https://msn.iykyka.com`에서 브라우저 가입 성공 | | **N2-B9** | 리버스 프록시 | todo | HTTPS로 도메인 접속 | | **N2-B10** | Portainer 스택 | todo | 스택 Up, 절차를 Notes에 기록 | | **N2-B11** | 구 MSN 컷오버 | todo | 새 스택이 도메인 응답 + 롤백 메모 | | **N2-B12** | 배포 스모크 | todo | N1-3~N1-5를 프로덕션 URL로 재실행 | -| **N2-B13** | 운영 runbook | todo | 로그·재시작·SQLite 백업·초대 발급 1페이지 (`docs/` 또는 본 파일 Notes) | +| **N2-B13** | 운영 runbook | todo | 로그·재시작·**Postgres 백업/복구**·초대 발급 1페이지 (`docs/` 또는 본 파일 Notes) | 관련: Portainer `https://portainer.iykyka.com/`, 호스트 SSH는 인프라 메모 참고(시크릿은 커밋 금지). @@ -117,7 +117,7 @@ Notes: | **N3-2** | 초대 발급 리허설 | todo | `docs/invite-ops.md` 절차 1회 | | **N3-3** | admin metrics | todo | `/admin/metrics`·`/admin/dashboard` 토큰 조회 | | **N3-4** | Gemini | todo | draft 1회 실호출 또는 mock 정책 명시 | -| **N3-5** | 백업 리허설 | todo | SQLite 볼륨 복사/복구 1회 | +| **N3-5** | 백업 리허설 | todo | Postgres 덤프/복구 1회 (`pg_dump` 등) | | **N3-6** | 테스터 안내 | todo | URL + 초대(`DEMO-BUNSIN` 또는 개인 코드) + 주의사항 | --- From e7a1233f84ac687286c16c58fcccfec0e169cac5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 02:08:03 +0000 Subject: [PATCH 03/18] =?UTF-8?q?docs:=20confirm=20N2-A1/A3=E2=80=93A6=20d?= =?UTF-8?q?eploy=20decisions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Master: replace old MSN, AI internal-only, Web-first client, secrets in Portainer/env only, ALLOW_DEMO_INVITE on for testers. N2-A complete. Co-authored-by: okuma --- docs/deploy-checklist.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index b84560a..32a99d6 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -80,12 +80,14 @@ Notes: | ID | 결정 | 값 | Status | |----|------|-----|--------| -| **N2-A1** | 구 Node MSN 교체 | 교체(Plan A) | todo | +| **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 서비스 노출 | 내부망만 (외부 포트 미공개) | todo | -| **N2-A4** | 클라이언트 제공 | Web 컨테이너 ± 내부 APK (둘 다/웹만 결정) | todo | -| **N2-A5** | 시크릿 관리 | Portainer/호스트 env — **git 금지** | todo | -| **N2-A6** | 데모 초대 | 프로덕션 `ALLOW_DEMO_INVITE` on/off | todo | +| **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-BUNSIN` 유지. 베타 확대 전 재검토 | **done** (2026-07-31 Master 확정) | + +N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/compose, Postgres 포함)**. ### N2-B. 이미지·compose From c7e70ac18b4e312c54dcfe4879810b7b497dc9bf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 02:13:51 +0000 Subject: [PATCH 04/18] docs: mark N1 smoke done (E2E 16/16, DEMO-YKAVU) Record local smoke results after restarting core-backend on current main. Document msn.iykyka.com CORS/API change list for N1-6; next is N2-B Docker. Co-authored-by: okuma --- docs/deploy-checklist.md | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 32a99d6..92e171a 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -23,7 +23,7 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다. - [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-BUNSIN` +- [x] Flutter Web SQLCipher stub · Twin Shadow UI · CORS · `DEMO-YKAVU` - [x] GitHub + Gitea 듀얼 리모트 (`scripts/push-both.sh`) ### NOW @@ -63,14 +63,27 @@ Notes: | ID | 작업 | Status | 완료 조건 | |----|------|--------|-----------| -| **N1-1** | 서비스 기동 | todo | `8080` core-backend, `8001` ai-service, (옵션) `5555` Flutter web 헬스 OK | -| **N1-2** | API E2E | todo | `ADMIN_API_TOKEN` 설정 후 `python3 scripts/e2e_a3.py` 16/16 | -| **N1-3** | Web 가입 스모크 | todo | `DEMO-BUNSIN` → 표시명 → 가입 → 세션/다음 화면 | -| **N1-4** | 말투 온보딩 스모크 | todo | 샘플 저장 또는 스킵 후 대화 목록 진입 | -| **N1-5** | 핵심 메신저 스모크 | todo | 연락처·대화·메시지 또는 초안/L1 중 최소 1경로 UI 왕복 | -| **N1-6** | 프로덕션 CORS/API 메모 | todo | `msn.iykyka.com`용 origins / `CORE_API_BASE` 변경 목록 작성 | +| **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`) | --- @@ -85,7 +98,7 @@ Notes: | **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-BUNSIN` 유지. 베타 확대 전 재검토 | **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 포함)**. @@ -120,7 +133,7 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | **N3-3** | admin metrics | todo | `/admin/metrics`·`/admin/dashboard` 토큰 조회 | | **N3-4** | Gemini | todo | draft 1회 실호출 또는 mock 정책 명시 | | **N3-5** | 백업 리허설 | todo | Postgres 덤프/복구 1회 (`pg_dump` 등) | -| **N3-6** | 테스터 안내 | todo | URL + 초대(`DEMO-BUNSIN` 또는 개인 코드) + 주의사항 | +| **N3-6** | 테스터 안내 | todo | URL + 초대(`DEMO-YKAVU` 또는 개인 코드) + 주의사항 | --- @@ -176,9 +189,9 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P ## 바로 다음 5개 (권장) -1. **N2-A1~A6** 결정 체크 -2. **N1-1~N1-5** 스모크 -3. **N2-B1~B4** Dockerfile + compose +1. ~~N2-A1~A6 결정 체크~~ **done** +2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터) +3. **N2-B1~B4** Dockerfile + compose (**Postgres** + AI 내부망 + Web) ← **다음** 4. **N2-B8~B12** 도메인·Portainer·컷오버·스모크 5. **N3-6** 테스터 안내 From 4a39bc7b7dd3a08b79ccdc1eebb2d937d78a6454 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 02:26:40 +0000 Subject: [PATCH 05/18] feat(deploy): Docker Compose stack for msn.iykyka.com (N2-B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Postgres + internal AI + core-backend + Flutter web (nginx API/WS proxy). Document Portainer usage and env template; mark N2-B1–B7 done. Co-authored-by: okuma --- .dockerignore | 24 +++++++++++ .env.example | 15 ++++++- README.md | 13 ++++++ ai-service/.dockerignore | 7 ++++ ai-service/Dockerfile | 20 +++++++++ core-backend/.dockerignore | 5 +++ core-backend/Dockerfile | 31 ++++++++++++++ deploy/nginx-web.conf | 34 ++++++++++++++++ docker-compose.yml | 83 ++++++++++++++++++++++++++++++++++++++ docs/deploy-checklist.md | 26 ++++++------ docs/deploy-docker.md | 52 ++++++++++++++++++++++++ mobile/Dockerfile | 21 ++++++++++ mobile/Dockerfile.prebuilt | 10 +++++ 13 files changed, 327 insertions(+), 14 deletions(-) create mode 100644 .dockerignore create mode 100644 ai-service/.dockerignore create mode 100644 ai-service/Dockerfile create mode 100644 core-backend/.dockerignore create mode 100644 core-backend/Dockerfile create mode 100644 deploy/nginx-web.conf create mode 100644 docker-compose.yml create mode 100644 docs/deploy-docker.md create mode 100644 mobile/Dockerfile create mode 100644 mobile/Dockerfile.prebuilt 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..4328858 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,22 @@ 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_SERVER_KEY= + +# --- 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/README.md b/README.md index 8c33bac..7e94cb4 100644 --- a/README.md +++ b/README.md @@ -54,3 +54,16 @@ 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) + +```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`만 공개. 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/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..d539724 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,83 @@ +# 와카뷰 — 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:-} + 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 + +volumes: + ykavu_pgdata: diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 92e171a..4efddc6 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -106,19 +106,19 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | ID | 작업 | Status | 완료 조건 | |----|------|--------|-----------| -| **N2-B1** | `core-backend` Dockerfile | todo | `docker build` 성공, migrate/기동 (`DATABASE_URL` → Postgres) | -| **N2-B2** | `ai-service` Dockerfile | todo | `docker build` 성공 | -| **N2-B3** | Flutter web 빌드/서빙 | todo | `flutter build web` + nginx(또는 Caddy)로 `/` 로딩 | -| **N2-B4** | `docker-compose.yml` | todo | `postgres` + core + ai (+ web) `up` 후 healthy | -| **N2-B5** | env 템플릿 | todo | `.env.example`에 키만: `GEMINI_API_KEY`, `ADMIN_API_TOKEN`, `AI_SERVICE_URL`, `DATABASE_URL` / Postgres 비밀번호, `ALLOW_DEMO_INVITE`, CORS/origins, `FCM_*` | -| **N2-B6** | 데이터 볼륨 | todo | Postgres 데이터 볼륨 persist (재시작 후 데이터 유지) | -| **N2-B7** | 내부 DNS | todo | Go → `http://ai-service:…` draft/escalate, Go → `postgres:5432` 동작 | -| **N2-B8** | CORS + API base | todo | `https://msn.iykyka.com`에서 브라우저 가입 성공 | -| **N2-B9** | 리버스 프록시 | todo | HTTPS로 도메인 접속 | -| **N2-B10** | Portainer 스택 | todo | 스택 Up, 절차를 Notes에 기록 | +| **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 | todo | `PUBLIC_API_BASE=https://msn.iykyka.com` 빌드 + 브라우저 가입 | +| **N2-B9** | 리버스 프록시 | todo | HTTPS로 도메인 → `web:80` | +| **N2-B10** | Portainer 스택 | todo | 스택 Up — 절차 [`deploy-docker.md`](./deploy-docker.md) | | **N2-B11** | 구 MSN 컷오버 | todo | 새 스택이 도메인 응답 + 롤백 메모 | | **N2-B12** | 배포 스모크 | todo | N1-3~N1-5를 프로덕션 URL로 재실행 | -| **N2-B13** | 운영 runbook | todo | 로그·재시작·**Postgres 백업/복구**·초대 발급 1페이지 (`docs/` 또는 본 파일 Notes) | +| **N2-B13** | 운영 runbook | **done** (초안) | [`deploy-docker.md`](./deploy-docker.md) — 백업 상세는 N3-5에서 보강 | 관련: Portainer `https://portainer.iykyka.com/`, 호스트 SSH는 인프라 메모 참고(시크릿은 커밋 금지). @@ -191,8 +191,8 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P 1. ~~N2-A1~A6 결정 체크~~ **done** 2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터) -3. **N2-B1~B4** Dockerfile + compose (**Postgres** + AI 내부망 + Web) ← **다음** -4. **N2-B8~B12** 도메인·Portainer·컷오버·스모크 +3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드) +4. **N2-B8~B12** 도메인·Portainer·컷오버·스모크 ← **다음** 5. **N3-6** 테스터 안내 완료 시 본 표의 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..fa52881 --- /dev/null +++ b/docs/deploy-docker.md @@ -0,0 +1,52 @@ +# 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: 이 저장소의 `docker-compose.yml` + 스택 env로 동일하게 Up. + +## 시크릿 + +- 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/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 From 0f42ef806a6850cdc20ba824619b7e2c670c33a6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 02:26:49 +0000 Subject: [PATCH 06/18] chore: ignore Flutter mobile/build output Co-authored-by: okuma --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 0f4b314..88ebfe0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,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 From b38839666c9ef3c28e429dced842f0500804849e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 02:31:32 +0000 Subject: [PATCH 07/18] =?UTF-8?q?docs(deploy):=20Portainer=20cutover=20gui?= =?UTF-8?q?de=20and=20server-up=20script=20(N2-B8=E2=80=9312)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit msn.iykyka.com still serves legacy Express; agent lacks SSH/Portainer credentials so cutover is documented and marked blocked pending access. Co-authored-by: okuma --- README.md | 6 ++- docs/deploy-docker.md | 3 +- docs/deploy-portainer.md | 95 ++++++++++++++++++++++++++++++++++++++++ scripts/server-up.sh | 43 ++++++++++++++++++ 4 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 docs/deploy-portainer.md create mode 100755 scripts/server-up.sh diff --git a/README.md b/README.md index 7e94cb4..0907f72 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,9 @@ 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) +- 구성: [`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 필수 @@ -66,4 +68,4 @@ docker compose up -d --build curl -sS http://localhost:8088/health ``` -Postgres·AI는 내부망만. 엣지 프록시는 `web:80`만 공개. +Postgres·AI는 내부망만. 엣지 프록시는 `web:80`(또는 `WEB_HOST_PORT`)만 공개. diff --git a/docs/deploy-docker.md b/docs/deploy-docker.md index fa52881..25169c0 100644 --- a/docs/deploy-docker.md +++ b/docs/deploy-docker.md @@ -27,7 +27,8 @@ curl -sS http://localhost:8088/health curl -sS http://localhost:8088/demo ``` -Portainer: 이 저장소의 `docker-compose.yml` + 스택 env로 동일하게 Up. +Portainer·OpenResty 컷오버 상세: [`deploy-portainer.md`](./deploy-portainer.md). +서버 원샷: `./scripts/server-up.sh` ## 시크릿 diff --git a/docs/deploy-portainer.md b/docs/deploy-portainer.md new file mode 100644 index 0000000..c4a8f03 --- /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/` | Express `Cannot GET /` (404) — 구 Node MSN으로 추정 | +| `https://msn.iykyka.com/health` | `{"ok":true}` — 구 스택 | +| `https://portainer.iykyka.com/` | 200, API `Version 2.39.2` | +| 엣지 | `Server: openresty` | + +에이전트 VM에는 **SSH 키/`iykyka@iykyka.com:7788` 권한이 없음** → 실제 Up·컷오버는 +Master가 Portainer 또는 SSH로 수행(또는 키/토큰을 에이전트에 제공). + +새 스택 헬스 응답은 `{"status":"ok"}` (구스택 `{"ok":true}` 과 구분). + +--- + +## 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/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 From 0a33ff3bcceb80620f89f7b4fe0b4ea44cb9871f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 02:31:49 +0000 Subject: [PATCH 08/18] =?UTF-8?q?docs:=20mark=20N2-B8=E2=80=93B12=20blocke?= =?UTF-8?q?d=20pending=20SSH/Portainer=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: okuma --- docs/deploy-checklist.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 4efddc6..6e836bb 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -29,7 +29,7 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다. ### NOW - 앱 코드는 클로즈드 베타 직전 수준 -- **프로덕션 Docker / `msn.iykyka.com` 배포는 미착수** +- 프로덕션 Docker **이미지·compose 준비 완료**. `msn.iykyka.com` **컷오버는 SSH/Portainer 자격 대기** - 실 FCM · Android UI 수동 QA · 사람 PoC 실행은 남음 ### NEXT 순서 @@ -113,14 +113,14 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | **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 | todo | `PUBLIC_API_BASE=https://msn.iykyka.com` 빌드 + 브라우저 가입 | -| **N2-B9** | 리버스 프록시 | todo | HTTPS로 도메인 → `web:80` | -| **N2-B10** | Portainer 스택 | todo | 스택 Up — 절차 [`deploy-docker.md`](./deploy-docker.md) | -| **N2-B11** | 구 MSN 컷오버 | todo | 새 스택이 도메인 응답 + 롤백 메모 | -| **N2-B12** | 배포 스모크 | todo | N1-3~N1-5를 프로덕션 URL로 재실행 | -| **N2-B13** | 운영 runbook | **done** (초안) | [`deploy-docker.md`](./deploy-docker.md) — 백업 상세는 N3-5에서 보강 | +| **N2-B8** | CORS + API base | **blocked** | `PUBLIC_API_BASE=https://msn.iykyka.com` — 스택 Up 후 검증. *에이전트 SSH/Portainer 권한 없음* | +| **N2-B9** | 리버스 프록시 | **blocked** | OpenResty → `web` 포트. 절차 [`deploy-portainer.md`](./deploy-portainer.md) | +| **N2-B10** | Portainer 스택 | **blocked** | 절차 문서화 완료. Up은 Master 또는 자격 제공 후 | +| **N2-B11** | 구 MSN 컷오버 | **blocked** | 현재 `msn` = Express/`{"ok":true}` (구스택). 컷오버 절차 문서화됨 | +| **N2-B12** | 배포 스모크 | **blocked** | 컷오버 후 [`deploy-portainer.md`](./deploy-portainer.md) §C | +| **N2-B13** | 운영 runbook | **done** (초안) | [`deploy-docker.md`](./deploy-docker.md) · [`deploy-portainer.md`](./deploy-portainer.md) | -관련: Portainer `https://portainer.iykyka.com/`, 호스트 SSH는 인프라 메모 참고(시크릿은 커밋 금지). +관련: Portainer `https://portainer.iykyka.com/`, SSH `iykyka@iykyka.com:7788` (키 필요). 시크릿 git 금지. --- @@ -192,7 +192,7 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P 1. ~~N2-A1~A6 결정 체크~~ **done** 2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터) 3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드) -4. **N2-B8~B12** 도메인·Portainer·컷오버·스모크 ← **다음** +4. **N2-B8~B12** Portainer/SSH 컷오버 ← **blocked (자격 필요)** — [`deploy-portainer.md`](./deploy-portainer.md) 5. **N3-6** 테스터 안내 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. From d60da609814c2160a8b700a1840b594c5f471ab2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 03:08:34 +0000 Subject: [PATCH 09/18] feat(deploy): cut over msn.iykyka.com to Ykavu compose stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attach web to nginx-proxy_default; record live cutover (NPM host #7, old MSN stopped) and mark N2-B8–B12 done. Co-authored-by: okuma --- docker-compose.yml | 10 ++++++++++ docs/deploy-checklist.md | 14 +++++++------- docs/deploy-portainer.md | 18 +++++++++--------- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d539724..c8dda89 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -78,6 +78,16 @@ services: 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/deploy-checklist.md b/docs/deploy-checklist.md index 6e836bb..43fc520 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -113,14 +113,14 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | **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 | **blocked** | `PUBLIC_API_BASE=https://msn.iykyka.com` — 스택 Up 후 검증. *에이전트 SSH/Portainer 권한 없음* | -| **N2-B9** | 리버스 프록시 | **blocked** | OpenResty → `web` 포트. 절차 [`deploy-portainer.md`](./deploy-portainer.md) | -| **N2-B10** | Portainer 스택 | **blocked** | 절차 문서화 완료. Up은 Master 또는 자격 제공 후 | -| **N2-B11** | 구 MSN 컷오버 | **blocked** | 현재 `msn` = Express/`{"ok":true}` (구스택). 컷오버 절차 문서화됨 | -| **N2-B12** | 배포 스모크 | **blocked** | 컷오버 후 [`deploy-portainer.md`](./deploy-portainer.md) §C | -| **N2-B13** | 운영 runbook | **done** (초안) | [`deploy-docker.md`](./deploy-docker.md) · [`deploy-portainer.md`](./deploy-portainer.md) | +| **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) | -관련: Portainer `https://portainer.iykyka.com/`, SSH `iykyka@iykyka.com:7788` (키 필요). 시크릿 git 금지. +관련: 라이브 `https://msn.iykyka.com` · 서버 path `~/project/ykavu` · web 포트 `8788`(+ NPM). 시크릿 git 금지. --- diff --git a/docs/deploy-portainer.md b/docs/deploy-portainer.md index c4a8f03..8dd420a 100644 --- a/docs/deploy-portainer.md +++ b/docs/deploy-portainer.md @@ -4,19 +4,19 @@ 스택 정의: 저장소 root [`docker-compose.yml`](../docker-compose.yml) 개요: [`deploy-docker.md`](./deploy-docker.md) -## 현재 프로브 (에이전트, 2026-07-31) +## 라이브 상태 (2026-07-31 컷오버 완료) | URL | 결과 | |-----|------| -| `https://msn.iykyka.com/` | Express `Cannot GET /` (404) — 구 Node MSN으로 추정 | -| `https://msn.iykyka.com/health` | `{"ok":true}` — 구 스택 | -| `https://portainer.iykyka.com/` | 200, API `Version 2.39.2` | -| 엣지 | `Server: openresty` | +| `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) | -에이전트 VM에는 **SSH 키/`iykyka@iykyka.com:7788` 권한이 없음** → 실제 Up·컷오버는 -Master가 Portainer 또는 SSH로 수행(또는 키/토큰을 에이전트에 제공). - -새 스택 헬스 응답은 `{"status":"ok"}` (구스택 `{"ok":true}` 과 구분). +`docker-compose.yml`의 `web` 서비스는 external network `nginx-proxy_default`에 연결됨. --- From 597c9f07cc41d49b61ba70f67ff1959b7289c0b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 03:08:48 +0000 Subject: [PATCH 10/18] docs: note msn.iykyka.com live after Ykavu cutover Co-authored-by: okuma --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0907f72..85b65db 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,10 @@ ## 현재 단계 -- Phase 1 **A~C**까지 반영됨 (서버·Flutter·베타 직전 문서/배포 경로) -- **다음:** [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) — N1 스모크 → N2 `msn.iykyka.com` Docker → N3 안정화 → N4 FCM/Android QA +- Phase 1 **A~C** + **N2 Docker 컷오버 완료** — 라이브: [`https://msn.iykyka.com`](https://msn.iykyka.com) +- **다음:** [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) N3 안정화 → N4 FCM/Android QA - **맨 마지막:** N5 / D — 사람 PoC #1/#3·Q3 인터뷰 (`docs/roadmap.md` §3). §3 기본값 추측 금지 +- 테스터 데모 초대: **`DEMO-YKAVU`** - 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입 ## 로컬 실행 (요약) From 9065479e872795f9da43c48ad816b2bc9f80dece Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 04:06:09 +0000 Subject: [PATCH 11/18] =?UTF-8?q?docs(ops):=20complete=20N3=20stabilize=20?= =?UTF-8?q?=E2=80=94=20backup,=20tester=20guide,=20smoke=20results?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record invite/metrics/dashboard/draft(no_key) checks, pg_dump restore rehearsal, and tester-facing guide for msn.iykyka.com. Co-authored-by: okuma --- README.md | 8 +++---- docs/deploy-checklist.md | 19 ++++++++------- docs/ops-backup.md | 28 ++++++++++++++++++++++ docs/tester-guide.md | 48 ++++++++++++++++++++++++++++++++++++++ scripts/backup-postgres.sh | 17 ++++++++++++++ 5 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 docs/ops-backup.md create mode 100644 docs/tester-guide.md create mode 100755 scripts/backup-postgres.sh diff --git a/README.md b/README.md index 85b65db..54e4bf8 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ ## 현재 단계 -- Phase 1 **A~C** + **N2 Docker 컷오버 완료** — 라이브: [`https://msn.iykyka.com`](https://msn.iykyka.com) -- **다음:** [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) N3 안정화 → N4 FCM/Android QA -- **맨 마지막:** N5 / D — 사람 PoC #1/#3·Q3 인터뷰 (`docs/roadmap.md` §3). §3 기본값 추측 금지 -- 테스터 데모 초대: **`DEMO-YKAVU`** +- Phase 1 **A~C** + **N2 컷오버 + N3 안정화 완료** — 라이브: [`https://msn.iykyka.com`](https://msn.iykyka.com) +- 테스터 안내: [`docs/tester-guide.md`](./docs/tester-guide.md) · 데모 코드 **`DEMO-YKAVU`** +- **다음:** 서버 `GEMINI_API_KEY` 주입(실초안) 및/또는 N4 FCM·Android QA +- **맨 마지막:** N5 / D — 사람 PoC (`docs/roadmap.md` §3). §3 기본값 추측 금지 - 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입 ## 로컬 실행 (요약) diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 43fc520..3748c77 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -29,7 +29,7 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다. ### NOW - 앱 코드는 클로즈드 베타 직전 수준 -- 프로덕션 Docker **이미지·compose 준비 완료**. `msn.iykyka.com` **컷오버는 SSH/Portainer 자격 대기** +- **`https://msn.iykyka.com` 라이브 + N3 안정화 완료**. Gemini 키 미설정(`no_key`). 다음: N4 또는 키 주입 - 실 FCM · Android UI 수동 QA · 사람 PoC 실행은 남음 ### NEXT 순서 @@ -128,12 +128,12 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | ID | 작업 | Status | 완료 조건 | |----|------|--------|-----------| -| **N3-1** | 헬스/로그 | todo | OOM·CORS·502 없음 | -| **N3-2** | 초대 발급 리허설 | todo | `docs/invite-ops.md` 절차 1회 | -| **N3-3** | admin metrics | todo | `/admin/metrics`·`/admin/dashboard` 토큰 조회 | -| **N3-4** | Gemini | todo | draft 1회 실호출 또는 mock 정책 명시 | -| **N3-5** | 백업 리허설 | todo | Postgres 덤프/복구 1회 (`pg_dump` 등) | -| **N3-6** | 테스터 안내 | todo | URL + 초대(`DEMO-YKAVU` 또는 개인 코드) + 주의사항 | +| **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** (정책) | draft → `status=no_key` (서버 `GEMINI_API_KEY` 비어 있음). 키 넣으면 실호출로 전환 | +| **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) | --- @@ -192,7 +192,8 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P 1. ~~N2-A1~A6 결정 체크~~ **done** 2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터) 3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드) -4. **N2-B8~B12** Portainer/SSH 컷오버 ← **blocked (자격 필요)** — [`deploy-portainer.md`](./deploy-portainer.md) -5. **N3-6** 테스터 안내 +4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브) +5. ~~N3 배포 안정화~~ **done** — 다음: **N4 FCM/Android QA** 또는 서버에 `GEMINI_API_KEY` 주입 + 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. 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/tester-guide.md b/docs/tester-guide.md new file mode 100644 index 0000000..f3e877a --- /dev/null +++ b/docs/tester-guide.md @@ -0,0 +1,48 @@ +# 와카뷰 테스터 안내 (N3-6) + +## 접속 + +- 웹: **https://msn.iykyka.com** +- 공용 데모 초대 코드: **`DEMO-YKAVU`** +- 표시 이름 예시: `테스터` (원하는 이름으로 변경 가능) + +같은 코드를 여러 명이 쓸 수 있습니다 (`ALLOW_DEMO_INVITE=1`). + +## 권장 플로우 (5분) + +1. 가입 — 초대 코드 `DEMO-YKAVU` 입력 +2. 말투 샘플 온보딩 — 몇 줄 적거나 스킵 +3. 연락처에 상대 유저 등록 → 대화 시작 +4. 메시지 전송 · 분신 초안(L1) 한 번 시도 +5. (선택) 자율성 L0~L2 / 거부권 / 사후알림 함 확인 + +## 알아둘 점 + +- **초안(AI)**: 서버에 `GEMINI_API_KEY`가 없으면 초안이 `no_key`로 나오고, 예시 문구만 채워질 수 있습니다. 키가 들어가면 실제 Gemini 초안이 나옵니다. +- 푸시(FCM)는 아직 플레이스홀더 단계일 수 있습니다. +- 문제/스크린샷은 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/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" From d614d9e5c478576b1316c0f707df5eb9f4de1205 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 04:19:44 +0000 Subject: [PATCH 12/18] docs: record Gemini key live on msn.iykyka.com (draft status=ok) Server ai-service recreated with GEMINI_API_KEY; live draft smoke passed. Co-authored-by: okuma --- README.md | 2 +- docs/deploy-checklist.md | 2 +- docs/tester-guide.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 54e4bf8..c3f54cd 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ - Phase 1 **A~C** + **N2 컷오버 + N3 안정화 완료** — 라이브: [`https://msn.iykyka.com`](https://msn.iykyka.com) - 테스터 안내: [`docs/tester-guide.md`](./docs/tester-guide.md) · 데모 코드 **`DEMO-YKAVU`** -- **다음:** 서버 `GEMINI_API_KEY` 주입(실초안) 및/또는 N4 FCM·Android QA +- **다음 (선택):** N4 FCM·Android QA - **맨 마지막:** N5 / D — 사람 PoC (`docs/roadmap.md` §3). §3 기본값 추측 금지 - 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입 diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 3748c77..b443dc5 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -131,7 +131,7 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | **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** (정책) | draft → `status=no_key` (서버 `GEMINI_API_KEY` 비어 있음). 키 넣으면 실호출로 전환 | +| **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) | diff --git a/docs/tester-guide.md b/docs/tester-guide.md index f3e877a..387a358 100644 --- a/docs/tester-guide.md +++ b/docs/tester-guide.md @@ -18,7 +18,7 @@ ## 알아둘 점 -- **초안(AI)**: 서버에 `GEMINI_API_KEY`가 없으면 초안이 `no_key`로 나오고, 예시 문구만 채워질 수 있습니다. 키가 들어가면 실제 Gemini 초안이 나옵니다. +- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다. (이전에 `no_key`이던 상태는 해소됨) - 푸시(FCM)는 아직 플레이스홀더 단계일 수 있습니다. - 문제/스크린샷은 Master에게 전달해 주세요. - 민감 정보·실명 대화는 베타 특성상 최소화해 주세요. From 3d00b00de7001c856b1352e80dc98e7e0737fdcc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 04:36:41 +0000 Subject: [PATCH 13/18] =?UTF-8?q?feat(mobile):=20Track=20A=20messenger=20U?= =?UTF-8?q?X=20=E2=80=94=20ID=20chip,=20contacts=20chat,=20L0=20draft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show/copy numeric user ID, require peer ID for contacts, start chat in one tap, clarify empty states, and route L0 twin drafts into the human composer. Co-authored-by: okuma --- docs/deploy-checklist.md | 12 +- mobile/lib/screens/chat_screen.dart | 37 ++++- mobile/lib/screens/contacts_screen.dart | 134 ++++++++++----- .../lib/screens/conversation_list_screen.dart | 153 ++++++++++++++---- mobile/lib/widgets/my_user_id_chip.dart | 78 +++++++++ 5 files changed, 336 insertions(+), 78 deletions(-) create mode 100644 mobile/lib/widgets/my_user_id_chip.dart diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index b443dc5..4a5dfed 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -29,7 +29,7 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다. ### NOW - 앱 코드는 클로즈드 베타 직전 수준 -- **`https://msn.iykyka.com` 라이브 + N3 안정화 완료**. Gemini 키 미설정(`no_key`). 다음: N4 또는 키 주입 +- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK**. 다음: N4 FCM·Android QA (선택) - 실 FCM · Android UI 수동 QA · 사람 PoC 실행은 남음 ### NEXT 순서 @@ -139,6 +139,16 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ ## 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 재빌드 | todo | `msn.iykyka.com` 스모크 | + ### FCM | ID | 작업 | Status | 완료 조건 | diff --git a/mobile/lib/screens/chat_screen.dart b/mobile/lib/screens/chat_screen.dart index 7de7c72..02f418b 100644 --- a/mobile/lib/screens/chat_screen.dart +++ b/mobile/lib/screens/chat_screen.dart @@ -175,6 +175,18 @@ class _ChatScreenState extends State { 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..0bd8c8e 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와 함께 다시 추가하세요.'); return; } try { @@ -110,7 +133,9 @@ 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}'); @@ -140,8 +165,14 @@ class _ContactsScreenState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final me = context.watch().user?.id; 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 +185,11 @@ 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 (_error != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -161,17 +197,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 +232,34 @@ 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('대화')), + FilledButton.tonal( + onPressed: () => _startChat(c), + child: const Text('대화'), + ) + else + TextButton( + onPressed: () { + setState(() => _error = + '${c.displayName}: 숫자 ID가 없어 대화할 수 없습니다. 삭제 후 ID와 함께 다시 추가하세요.'); + }, + child: const Text('안내'), + ), IconButton( tooltip: '삭제', icon: const Icon(Icons.delete_outline, size: 20), @@ -210,6 +267,7 @@ class _ContactsScreenState extends State { ), ], ), + onTap: c.contactUserId == null ? null : () => _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 40c846b..4b3b6fb 100644 --- a/mobile/lib/screens/conversation_list_screen.dart +++ b/mobile/lib/screens/conversation_list_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 'autonomy_settings_screen.dart'; import 'chat_screen.dart'; import 'contacts_screen.dart'; @@ -19,6 +20,7 @@ class ConversationListScreen extends StatefulWidget { class _ConversationListScreenState extends State { List _rooms = []; + Map _peerNames = {}; bool _loading = true; String? _error; @@ -37,7 +39,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 { @@ -45,20 +63,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('취소')), @@ -67,15 +120,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) { @@ -105,6 +174,7 @@ class _ConversationListScreenState extends State { appBar: AppBar( title: const Text('와카뷰'), actions: [ + if (me != null) MyUserIdChip(userId: me, compact: true), IconButton( tooltip: '사후 알림', onPressed: () { @@ -161,12 +231,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), @@ -174,20 +249,22 @@ class _ConversationListScreenState extends State { ), if (_rooms.isEmpty) Padding( - padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32), + padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 32), child: Column( children: [ Icon(Icons.chat_bubble_outline, size: 40, color: theme.colorScheme.outline), const SizedBox(height: 12), + 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, + ), ), ], ), @@ -204,28 +281,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/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), + ], + ), + ), + ), + ); + } +} From fdd02d8252564c7be6b78e0e95ab78313dd86a0b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 04:38:39 +0000 Subject: [PATCH 14/18] docs: mark Track A production web rebuild done N4-A5 verified on msn.iykyka.com after deploying 3d00b00. Co-authored-by: okuma --- docs/deploy-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 4a5dfed..7116b68 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -147,7 +147,7 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | **N4-A2** | 연락처 원탭 대화 + ID 필수 | done | 숫자 peer ID 없으면 추가/대화 차단·안내 | | **N4-A3** | 빈 상태·에러·L0 패널 | done | L0는「입력창으로 옮기기」 | | **N4-A4** | 대화 목록 이름·밀도 | done | 연락처 표시명 매핑 | -| **N4-A5** | 프로덕션 web 재빌드 | todo | `msn.iykyka.com` 스모크 | +| **N4-A5** | 프로덕션 web 재빌드 | done | `msn.iykyka.com` health 200 (`3d00b00`) | ### FCM From c7029ab2cd21bca594f4056958384140df1cf939 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 04:43:25 +0000 Subject: [PATCH 15/18] feat: finish Track A polish and Track B demo pairing content Add PATCH contact for missing peer IDs, contacts banner and edit UI, enrich /demo with pairing steps, and update tester/signup guidance. Co-authored-by: okuma --- core-backend/a1_a2_routes.go | 65 +++++++-- core-backend/a1_a2_test.go | 40 ++++++ core-backend/demo.go | 11 ++ core-backend/demo_test.go | 4 + core-backend/main_test.go | 15 ++ docs/deploy-checklist.md | 10 ++ docs/tester-guide.md | 27 ++-- mobile/lib/screens/contacts_screen.dart | 129 ++++++++++++++++-- .../lib/screens/conversation_list_screen.dart | 11 ++ mobile/lib/screens/signup_screen.dart | 8 ++ mobile/lib/services/api_client.dart | 15 ++ 11 files changed, 304 insertions(+), 31 deletions(-) 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/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/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/docs/deploy-checklist.md b/docs/deploy-checklist.md index 7116b68..b9538a4 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -148,6 +148,16 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | **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 재배포 | todo | `/demo` steps + UI 스모크 | ### FCM diff --git a/docs/tester-guide.md b/docs/tester-guide.md index 387a358..82f99b3 100644 --- a/docs/tester-guide.md +++ b/docs/tester-guide.md @@ -1,4 +1,4 @@ -# 와카뷰 테스터 안내 (N3-6) +# 와카뷰 테스터 안내 (N3-6 / Track B) ## 접속 @@ -8,17 +8,28 @@ 같은 코드를 여러 명이 쓸 수 있습니다 (`ALLOW_DEMO_INVITE=1`). -## 권장 플로우 (5분) +API 메타: `GET https://msn.iykyka.com/demo` — `pairing_steps` / `notes` 포함. -1. 가입 — 초대 코드 `DEMO-YKAVU` 입력 -2. 말투 샘플 온보딩 — 몇 줄 적거나 스킵 -3. 연락처에 상대 유저 등록 → 대화 시작 -4. 메시지 전송 · 분신 초안(L1) 한 번 시도 -5. (선택) 자율성 L0~L2 / 거부권 / 사후알림 함 확인 +## 권장 페어링 플로우 (5~10분) + +두 명(또는 시크릿 창 두 개)으로 진행합니다. **대화는 닉네임이 아니라 숫자 사용자 ID로 연결됩니다.** + +1. **가입** — 초대 코드 `DEMO-YKAVU` 입력 (각자 다른 표시 이름 권장) +2. **말투 샘플** — 몇 줄 적거나 스킵 +3. **내 ID 복사** — 대화 목록 상단「내 사용자 ID」칩을 탭해 복사하고 상대에게 전달 +4. **연락처 추가** — 상대 표시 이름 + **상대의 숫자 ID(필수)** → 추가 → 「대화」 +5. **메시지** — 사람 모드로 한두 줄 주고받기 +6. **와카뷰 초안** — 메뉴 → 자율성에서 **L1** → 초안이 뜨면 수정/승인하고 보내기 +7. (선택) L0에서는「입력창으로 옮기기」만 됩니다. L2·거부권·사후알림 함도 눌러 보세요. + +### ID 없는 옛 연락처 + +이름만 넣고 ID를 비운 연락처는 대화가 안 됩니다. 연락처 화면에서 **「ID 입력」**으로 숫자 ID를 채우면 됩니다 (삭제 후 재추가 불필요). ## 알아둘 점 -- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다. (이전에 `no_key`이던 상태는 해소됨) +- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다. +- **L0(비서)**: 와카뷰 발송이 서버에서 막혀 있습니다. 초안 → 입력창 → 직접 전송. - 푸시(FCM)는 아직 플레이스홀더 단계일 수 있습니다. - 문제/스크린샷은 Master에게 전달해 주세요. - 민감 정보·실명 대화는 베타 특성상 최소화해 주세요. diff --git a/mobile/lib/screens/contacts_screen.dart b/mobile/lib/screens/contacts_screen.dart index 0bd8c8e..afd8dc9 100644 --- a/mobile/lib/screens/contacts_screen.dart +++ b/mobile/lib/screens/contacts_screen.dart @@ -123,7 +123,7 @@ class _ContactsScreenState extends State { final session = context.read(); final me = session.user; if (me == null || contact.contactUserId == null) { - setState(() => _error = '이 연락처에는 상대 사용자 ID가 없습니다. 삭제 후 숫자 ID와 함께 다시 추가하세요.'); + setState(() => _error = '이 연락처에는 상대 사용자 ID가 없습니다. 「ID 입력」으로 숫자 ID를 넣으세요.'); return; } try { @@ -142,6 +142,86 @@ class _ContactsScreenState extends State { } } + 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; @@ -166,6 +246,7 @@ class _ContactsScreenState extends State { 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('연락처'), @@ -190,6 +271,24 @@ class _ContactsScreenState extends State { 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), @@ -247,18 +346,20 @@ class _ContactsScreenState extends State { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - if (c.contactUserId != null) + if (c.contactUserId != null) ...[ FilledButton.tonal( onPressed: () => _startChat(c), child: const Text('대화'), - ) - else - TextButton( - onPressed: () { - setState(() => _error = - '${c.displayName}: 숫자 ID가 없어 대화할 수 없습니다. 삭제 후 ID와 함께 다시 추가하세요.'); - }, - 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: '삭제', @@ -267,7 +368,13 @@ class _ContactsScreenState extends State { ), ], ), - onTap: c.contactUserId == null ? null : () => _startChat(c), + 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 4b3b6fb..5904c20 100644 --- a/mobile/lib/screens/conversation_list_screen.dart +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -266,6 +266,17 @@ class _ConversationListScreenState extends State { 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('연락처 열기'), + ), ], ), ), diff --git a/mobile/lib/screens/signup_screen.dart b/mobile/lib/screens/signup_screen.dart index eb50a6b..ce1519a 100644 --- a/mobile/lib/screens/signup_screen.dart +++ b/mobile/lib/screens/signup_screen.dart @@ -185,6 +185,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'); } From fea1b555b42b6e07f043135aa196e53579d044d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 04:46:07 +0000 Subject: [PATCH 16/18] docs: mark Track B production deploy done N4-B4 verified: /demo returns pairing_steps on msn.iykyka.com. Co-authored-by: okuma --- docs/deploy-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index b9538a4..2a4bc13 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -157,7 +157,7 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | **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 재배포 | todo | `/demo` steps + UI 스모크 | +| **N4-B4** | 프로덕션 core+web 재배포 | done | `/demo` pairing_steps OK (`c7029ab`) | ### FCM From 82a5809d4e2c851a1862f01c124c58ef6c9b85e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 04:51:57 +0000 Subject: [PATCH 17/18] feat(n4): wire FCM token path and document Master secrets Add PushTokenService with Firebase Messaging fallback to install placeholders, conditional google-services Gradle plugin, fcm-setup.md, and mark Android UI checklist api-done via prod e2e 16/16. Co-authored-by: okuma --- docs/deploy-checklist.md | 36 +++---- docs/fcm-setup.md | 69 ++++++++++++++ docs/tester-guide.md | 2 +- mobile/README.md | 7 +- mobile/android/.gitignore | 3 + mobile/android/app/build.gradle.kts | 5 + .../android/app/google-services.json.example | 29 ++++++ .../android/app/src/main/AndroidManifest.xml | 2 + mobile/android/settings.gradle.kts | 1 + mobile/lib/screens/data_flow_screen.dart | 2 +- mobile/lib/services/push_token_service.dart | 69 ++++++++++++++ mobile/lib/state/session_state.dart | 25 ++--- mobile/pubspec.lock | 94 +++++++++++++++---- mobile/pubspec.yaml | 2 + 14 files changed, 297 insertions(+), 49 deletions(-) create mode 100644 docs/fcm-setup.md create mode 100644 mobile/android/app/google-services.json.example create mode 100644 mobile/lib/services/push_token_service.dart diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 2a4bc13..5ea108f 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -29,8 +29,9 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다. ### NOW - 앱 코드는 클로즈드 베타 직전 수준 -- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK**. 다음: N4 FCM·Android QA (선택) -- 실 FCM · Android UI 수동 QA · 사람 PoC 실행은 남음 +- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료** +- 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA +- 실 FCM 전송·Android 실기기 탭 · 사람 PoC 실행은 남음 ### NEXT 순서 @@ -159,25 +160,28 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | **N4-B3** | 가입 화면 페어링 안내 | done | Signup 데모 패널에 한 줄 팁 | | **N4-B4** | 프로덕션 core+web 재배포 | done | `/demo` pairing_steps OK (`c7029ab`) | -### FCM +### FCM — [`fcm-setup.md`](./fcm-setup.md) | ID | 작업 | Status | 완료 조건 | |----|------|--------|-----------| -| **N4-1** | Firebase + `google-services.json` | todo | Android 앱 연결 | -| **N4-2** | 실 FCM registration token | todo | install-id 플레이스홀더 제거 | -| **N4-3** | 서버 FCM 자격증명 | todo | env만 (`FCM_SERVER_KEY` 또는 HTTP v1) | -| **N4-4** | 푸시 수신 | todo | `/admin/push-test` + 에스컬레이션 수신 | +| **N4-1** | Firebase + `google-services.json` | blocked | Master: Console 앱 + `mobile/android/app/google-services.json` (git 금지) | +| **N4-2** | 실 FCM registration token | done* | `PushTokenService` — Firebase 있으면 실 토큰, 없으면 `install:` (*전송은 N4-1 후) | +| **N4-3** | 서버 FCM 자격증명 | blocked | Master: 호스트 `.env`의 `FCM_SERVER_KEY`만 | +| **N4-4** | 푸시 수신 | blocked | N4-1+N4-3 후 `/admin/push-test` + 기기 수신 | ### Android UI 탭 (`mobile/README.md`) -| ID | 작업 | Status | -|----|------|--------| -| **N4-5** | 가입 → 말투 저장 | todo | -| **N4-6** | 연락처 → 대화 → 메시지·히스토리 | todo | -| **N4-7** | L1 초안 수정/버리기/승인·뱃지 | todo | -| **N4-8** | 에스컬레이션 → 사후알림 함 | todo | -| **N4-9** | 되돌리기·거부권 | todo | -| **N4-10** | 자율성 L0~L2 + 화이트리스트 | todo | +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 탭 남음 | ### 후순위 @@ -213,7 +217,7 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P 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 배포 안정화~~ **done** — 다음: **N4 FCM/Android QA** 또는 서버에 `GEMINI_API_KEY` 주입 +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/fcm-setup.md b/docs/fcm-setup.md new file mode 100644 index 0000000..f705f68 --- /dev/null +++ b/docs/fcm-setup.md @@ -0,0 +1,69 @@ +# FCM 설정 (N4-1 ~ N4-4) + +서버·클라이언트의 푸시 **코드 경로는 준비됨**. 실제 전송은 Master가 Firebase 시크릿을 +넣기 전까지 soft-skip 한다 (`install:` 토큰·`FCM_SERVER_KEY` 없음). + +## 이미 된 것 (코드) + +| 계층 | 동작 | +|------|------| +| Flutter | `PushTokenService` — Firebase 가능하면 실 FCM 토큰, 아니면 `install:` 플레이스홀더 | +| Android | `google-services.json`이 있을 때만 Google Services 플러그인 적용 | +| core-backend | `notifyUser` + `POST /admin/push-test` — `FCM_SERVER_KEY` + 실 토큰일 때만 전송 | + +## Master가 할 일 + +### N4-1 — Firebase 앱 + +1. [Firebase Console](https://console.firebase.google.com/)에서 프로젝트 생성 (또는 기존 사용). +2. Android 앱 추가 — package name: **`com.ykavu.ykavu_mobile`** +3. 받은 `google-services.json`을 로컬에만 배치 (git 금지): + +```bash +cp ~/Downloads/google-services.json mobile/android/app/google-services.json +``` + +템플릿: `mobile/android/app/google-services.json.example` + +### N4-3 — 서버 키 (env only) + +Cloud Messaging **레거시 서버 키**(또는 호환 서버 키)를 호스트 `.env`에만 설정: + +```bash +# 서버 ~/project/ykavu/.env +FCM_SERVER_KEY=AAAA... +``` + +```bash +cd ~/project/ykavu +docker compose up -d core-backend +``` + +**git / 이미지에 키를 넣지 않는다** (N2-A5). + +> 레거시 HTTP API가 Console에서 비활성이면 HTTP v1 마이그레이션이 필요하다. +> 그 전까지는 레거시 키가 있는 프로젝트로 N4-4 스모크를 완료한다. + +### 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`, `skipped_reason` 없음. 기기에 알림 표시. + +`only_placeholder_tokens` / `fcm_not_configured` 이면 위 N4-1·N4-3을 다시 확인. + +## Web + +현재 프로덕션 UI는 Flutter Web. Web 푸시는 Firebase Web 설정 + VAPID가 추가로 필요하며 +이번 N4 범위에서는 **Android 실푸시**를 우선한다. Web은 계속 `install:` 플레이스홀더를 등록한다. diff --git a/docs/tester-guide.md b/docs/tester-guide.md index 82f99b3..81f0e62 100644 --- a/docs/tester-guide.md +++ b/docs/tester-guide.md @@ -30,7 +30,7 @@ API 메타: `GET https://msn.iykyka.com/demo` — `pairing_steps` / `notes` 포 - **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다. - **L0(비서)**: 와카뷰 발송이 서버에서 막혀 있습니다. 초안 → 입력창 → 직접 전송. -- 푸시(FCM)는 아직 플레이스홀더 단계일 수 있습니다. +- 푸시(FCM): 코드 경로는 준비됨. Master가 Firebase/`FCM_SERVER_KEY`를 넣기 전에는 플레이스홀더(`docs/fcm-setup.md`). - 문제/스크린샷은 Master에게 전달해 주세요. - 민감 정보·실명 대화는 베타 특성상 최소화해 주세요. 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 @@ + + 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/pubspec.lock b/mobile/pubspec.lock index b4916df..1955274 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted 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: @@ -93,10 +101,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" charcode: dependency: transitive description: @@ -209,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: @@ -356,26 +412,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.10" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.1" lints: dependency: transitive description: @@ -396,26 +452,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.16.0" mime: dependency: transitive description: @@ -713,10 +769,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.4" typed_data: dependency: transitive description: @@ -729,10 +785,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.4" vm_service: dependency: transitive description: @@ -798,5 +854,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.0-0 <4.0.0" + dart: ">=3.8.1 <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: From ef73b93d83c8e4ce577adca3b1e4d82ca25ca023 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 07:33:23 +0000 Subject: [PATCH 18/18] feat(fcm): send pushes via HTTP v1 service account Prefer FCM_SERVICE_ACCOUNT_FILE/JSON over legacy FCM_SERVER_KEY, mount secrets/ into core-backend, and document Master JSON placement. Co-authored-by: okuma --- .env.example | 7 +- .gitignore | 5 + core-backend/b_test.go | 2 +- core-backend/go.mod | 15 ++- core-backend/go.sum | 15 +++ core-backend/push.go | 200 +++++++++++++++++++++++++++++++++------ docker-compose.yml | 5 + docs/deploy-checklist.md | 4 +- docs/fcm-setup.md | 53 ++++++----- secrets/.gitkeep | 0 secrets/README.md | 12 +++ 11 files changed, 258 insertions(+), 60 deletions(-) create mode 100644 secrets/.gitkeep create mode 100644 secrets/README.md diff --git a/.env.example b/.env.example index 4328858..5912766 100644 --- a/.env.example +++ b/.env.example @@ -8,9 +8,12 @@ ADMIN_API_TOKEN= # 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 diff --git a/.gitignore b/.gitignore index 88ebfe0..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 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/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/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/docker-compose.yml b/docker-compose.yml index c8dda89..6f31d80 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,11 @@ services: 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 diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 5ea108f..511c733 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -164,9 +164,9 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | ID | 작업 | Status | 완료 조건 | |----|------|--------|-----------| -| **N4-1** | Firebase + `google-services.json` | blocked | Master: Console 앱 + `mobile/android/app/google-services.json` (git 금지) | +| **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 자격증명 | blocked | Master: 호스트 `.env`의 `FCM_SERVER_KEY`만 | +| **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`) diff --git a/docs/fcm-setup.md b/docs/fcm-setup.md index f705f68..9c838a4 100644 --- a/docs/fcm-setup.md +++ b/docs/fcm-setup.md @@ -1,7 +1,7 @@ # FCM 설정 (N4-1 ~ N4-4) -서버·클라이언트의 푸시 **코드 경로는 준비됨**. 실제 전송은 Master가 Firebase 시크릿을 -넣기 전까지 soft-skip 한다 (`install:` 토큰·`FCM_SERVER_KEY` 없음). +서버·클라이언트의 푸시 **코드 경로는 준비됨**. +새 Firebase 프로젝트는 **레거시 서버 키가 비활성**인 경우가 많아, **HTTP v1 + 서비스 계정 JSON**을 쓴다. ## 이미 된 것 (코드) @@ -9,47 +9,57 @@ |------|------| | Flutter | `PushTokenService` — Firebase 가능하면 실 FCM 토큰, 아니면 `install:` 플레이스홀더 | | Android | `google-services.json`이 있을 때만 Google Services 플러그인 적용 | -| core-backend | `notifyUser` + `POST /admin/push-test` — `FCM_SERVER_KEY` + 실 토큰일 때만 전송 | +| core-backend | `notifyUser` + `POST /admin/push-test` — **FCM HTTP v1**(서비스 계정) 우선, 레거시 `FCM_SERVER_KEY`는 폴백 | ## Master가 할 일 -### N4-1 — Firebase 앱 +### N4-1 — Firebase Android 앱 -1. [Firebase Console](https://console.firebase.google.com/)에서 프로젝트 생성 (또는 기존 사용). -2. Android 앱 추가 — package name: **`com.ykavu.ykavu_mobile`** -3. 받은 `google-services.json`을 로컬에만 배치 (git 금지): +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 ``` -템플릿: `mobile/android/app/google-services.json.example` +### N4-3 — 서비스 계정 JSON (HTTP v1) -### N4-3 — 서버 키 (env only) +레거시 **서버 키**가 Cloud Messaging 탭에서 `사용 중지됨`이면 정상이다. 아래를 쓴다. -Cloud Messaging **레거시 서버 키**(또는 호환 서버 키)를 호스트 `.env`에만 설정: +1. Google Cloud → 사용자 인증 정보 → 서비스 계정 만들기 + (API: Firebase Cloud Messaging API, 데이터: **애플리케이션 데이터**) +2. 역할: **Firebase Cloud Messaging Admin** (없으면 Firebase 관리자 / 임시 소유자) +3. 키 유형 **JSON** 다운로드 +4. 서버(또는 이 워크스페이스)에 배치: ```bash -# 서버 ~/project/ykavu/.env -FCM_SERVER_KEY=AAAA... +# 파일명 고정 +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 -docker compose up -d core-backend +# secrets/firebase-service-account.json 존재 확인 +docker compose up -d --build core-backend ``` -**git / 이미지에 키를 넣지 않는다** (N2-A5). +`docker-compose.yml`이 `./secrets` → 컨테이너 `/secrets`로 마운트하고 +`FCM_SERVICE_ACCOUNT_FILE=/secrets/firebase-service-account.json`을 읽는다. -> 레거시 HTTP API가 Console에서 비활성이면 HTTP v1 마이그레이션이 필요하다. -> 그 전까지는 레거시 키가 있는 프로젝트로 N4-4 스모크를 완료한다. +**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)" 확인 +# 로그: device token registered (FCM) ``` ```bash @@ -59,11 +69,10 @@ curl -sS -X POST https://msn.iykyka.com/admin/push-test \ -d '{"user_id": , "title":"와카뷰","body":"push smoke"}' ``` -기대: `sent >= 1`, `skipped_reason` 없음. 기기에 알림 표시. - -`only_placeholder_tokens` / `fcm_not_configured` 이면 위 N4-1·N4-3을 다시 확인. +기대: `sent >= 1`. +`only_placeholder_tokens` → Android에 `google-services.json` 넣고 재설치. +`fcm_not_configured` → 서버에 서비스 계정 파일 경로 확인. ## Web -현재 프로덕션 UI는 Flutter Web. Web 푸시는 Firebase Web 설정 + VAPID가 추가로 필요하며 -이번 N4 범위에서는 **Android 실푸시**를 우선한다. Web은 계속 `install:` 플레이스홀더를 등록한다. +Web 푸시는 별도 VAPID 설정이 필요하며 이번 N4는 **Android 실푸시** 우선. 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).