Compare commits

..

No commits in common. "main" and "cursor/n4-fcm-android-qa-382d" have entirely different histories.

105 changed files with 1187 additions and 8357 deletions

View File

@ -8,12 +8,9 @@ ADMIN_API_TOKEN=
# Shared demo invite DEMO-YKAVU on signup UI (multiple testers). Set 0 to disable. # Shared demo invite DEMO-YKAVU on signup UI (multiple testers). Set 0 to disable.
ALLOW_DEMO_INVITE=1 ALLOW_DEMO_INVITE=1
# FCM push (escalation notify + /admin/push-test). Prefer HTTP v1 service account: # Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test).
# place JSON at secrets/firebase-service-account.json (see docs/fcm-setup.md) # Without this, notifyUser soft-skips and records push_skipped metrics.
# Optional legacy key (often disabled in new Firebase projects):
FCM_SERVER_KEY= FCM_SERVER_KEY=
FCM_SERVICE_ACCOUNT_FILE=/secrets/firebase-service-account.json
# FCM_SERVICE_ACCOUNT_JSON= # alternative: paste minified JSON (avoid if possible)
# --- docker compose (N2-B / msn.iykyka.com) --- # --- docker compose (N2-B / msn.iykyka.com) ---
POSTGRES_USER=ykavu POSTGRES_USER=ykavu
@ -27,15 +24,3 @@ PUBLIC_API_BASE=https://msn.iykyka.com
# Host port mapped to web:80 # Host port mapped to web:80
WEB_HOST_PORT=8088 WEB_HOST_PORT=8088
# --- N4-W4 Web Push (Flutter web build-args / dart-define; never commit real values) ---
# Firebase Console → Project settings → Your apps → Web app (msn.iykyka.com)
# + Cloud Messaging → Web Push certificates (VAPID key pair).
# Leave empty to keep install: placeholder tokens until secrets are ready.
FIREBASE_API_KEY=
FIREBASE_AUTH_DOMAIN=
FIREBASE_PROJECT_ID=
FIREBASE_STORAGE_BUCKET=
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_APP_ID=
FIREBASE_VAPID_KEY=

5
.gitignore vendored
View File

@ -9,11 +9,6 @@ poc/tone-corpus/data/
.env.* .env.*
!.env.example !.env.example
# Firebase / FCM service account JSON (never commit)
secrets/**
!secrets/.gitkeep
!secrets/README.md
__pycache__/ __pycache__/
*.pyc *.pyc

View File

@ -10,8 +10,7 @@ copying content into prompts or new files.
When documents disagree, follow this order: When documents disagree, follow this order:
1. [`docs/decision-log.md`](docs/decision-log.md) — working assumptions for Q1~Q8 (확정) 1. [`docs/decision-log.md`](docs/decision-log.md) — working assumptions for Q1~Q7
and Q9 (L2 의미 — 제안 until Master confirms)
2. [`docs/vision.md`](docs/vision.md) / [`docs/PRD.md`](docs/PRD.md) / [`docs/tech-design.md`](docs/tech-design.md) 2. [`docs/vision.md`](docs/vision.md) / [`docs/PRD.md`](docs/PRD.md) / [`docs/tech-design.md`](docs/tech-design.md)
3. [`docs/roadmap.md`](docs/roadmap.md) / [`docs/risk-log.md`](docs/risk-log.md) 3. [`docs/roadmap.md`](docs/roadmap.md) / [`docs/risk-log.md`](docs/risk-log.md)
4. [`docs/PLANNING.md`](docs/PLANNING.md) — process guide 4. [`docs/PLANNING.md`](docs/PLANNING.md) — process guide
@ -19,12 +18,10 @@ When documents disagree, follow this order:
Notes: Notes:
- Q1~Q8 in `decision-log.md` are **확정** (Q1~Q7: Phase 1 C, 2026-07-30; - Q1~Q7 in `decision-log.md` are **확정** (Phase 1 C, 2026-07-30). PoC-dependent
Q8 account/settings IA: 2026-08-03). PoC-dependent sub-questions (default sub-questions (default autonomy level, whitelist defaults, final branding)
autonomy level, whitelist defaults, final branding) stay open — do not invent stay open — do not invent those. To reverse a Q, update `decision-log.md` and
those. Q9 (L2 receive-triggered auto-reply) remains **제안** — implement only derived docs in the same change.
after Master marks it 확정; see `docs/account-settings-ia.md`. To reverse a Q,
update `decision-log.md` and derived docs in the same change.
- Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for - Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for
current working answers. current working answers.
- Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later** - Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later**

View File

@ -1,4 +1,4 @@
# hikikomori / 와카뷰 (Ykavu) # hikikomori / 와카뷰 (가칭)
카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 와카뷰". 카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 와카뷰".
@ -6,80 +6,35 @@
| 경로 | 역할 | | 경로 | 역할 |
|---|---| |---|---|
| `docs/` | 기획·PRD·기술설계·로드맵·배포 체크리스트·PoC | | `docs/` | 기획·PRD·기술설계·로드맵·PoC 계획 |
| `core-backend/` | Go 코어 (가입·메시지·WebSocket·자율성·안전장치) | | `core-backend/` | Go 코어 (가입·메시지·WebSocket·자율성·안전장치) |
| `ai-service/` | Python AI 내부 API (`/draft`, `/escalate/check`) | | `ai-service/` | Python AI 내부 API (`/draft`, `/escalate/check`) |
| `backend/` | 초기 Python 프로토타입 (참고용) | | `backend/` | 초기 Python 프로토타입 (참고용) |
| `poc/tone-corpus/` | PoC 실험 스크립트 | | `poc/tone-corpus/` | PoC 실험 스크립트 |
| `mobile/` | Flutter 클라이언트 (Android 우선 · Web 배포) | | `mobile/` | Flutter 클라이언트 (Android 우선) |
| `secrets/` | FCM 등 시크릿 마운트용 (git에 실파일 커밋 금지) |
## 문서 지도 ## 문서
권위 순서·에이전트 규칙: [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md). - 기획: [`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)
| [`docs/PLANNING.md`](./docs/PLANNING.md) | 프로세스·산출물 가이드 |
| [`docs/decision-log.md`](./docs/decision-log.md) | Q1~**Q8 확정**, **Q9 제안** (단일 결정 기준) |
| [`docs/vision.md`](./docs/vision.md) | 문제·가치제안·성공 지표 |
| [`docs/PRD.md`](./docs/PRD.md) | P0 기능·유저 플로우 |
| [`docs/tech-design.md`](./docs/tech-design.md) | 온디바이스/서버 경계·자율성 엔진 |
| [`docs/risk-log.md`](./docs/risk-log.md) | 리스크·완화 추적 |
| [`docs/account-settings-ia.md`](./docs/account-settings-ia.md) | 계정·로그인·설정 IA (Q8) · L2 의미 갭 (Q9) |
### 실행 · 잔여 작업 (여기가 본체)
| 문서 | 용도 |
|------|------|
| [`docs/roadmap.md`](./docs/roadmap.md) | Phase 체크리스트 (`[x]` / `[~]` / `[ ]`) |
| [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) | **배포·잔여 실행 트랙** (DONE/NOW/NEXT · Track A~C · N1~N5 · N4-W) |
| [`docs/web-upgrade.md`](./docs/web-upgrade.md) | **웹 고도화** (데모/프리뷰 · W0~W7 · Web Push · PWA). Q9 비범위 |
남은 일: 웹은 `web-upgrade.md` / `deploy-checklist` §N4-W. Android/FCM 실기기·APK·SHARE_URL·PoC는 §N4·§N5.
### 배포 · 운영
| 문서 | 용도 |
|------|------|
| [`docs/deploy-docker.md`](./docs/deploy-docker.md) | Compose·이미지 |
| [`docs/deploy-portainer.md`](./docs/deploy-portainer.md) | Portainer·컷오버 |
| [`docs/ops-backup.md`](./docs/ops-backup.md) | 백업 |
| [`docs/invite-ops.md`](./docs/invite-ops.md) | 초대 코드 운영 |
| [`docs/fcm-setup.md`](./docs/fcm-setup.md) | FCM Android N4-1~4 · Web Push(W4) dart-define 주입 |
| [`docs/android-release.md`](./docs/android-release.md) | 내부 release APK 서명 |
| [`docs/tester-guide.md`](./docs/tester-guide.md) | 테스터 안내 · `DEMO-YKAVU` |
### PoC · 회의
| 문서 | 용도 |
|------|------|
| [`docs/poc-plan.md`](./docs/poc-plan.md) | PoC #1/#3 실행 계획 |
| [`docs/poc-materials.md`](./docs/poc-materials.md) | 모집·역할극 자료 |
| [`docs/user-interview-guide.md`](./docs/user-interview-guide.md) | Q3 자율성 인터뷰 |
| [`docs/prototype.md`](./docs/prototype.md) | 클릭 프로토타입 앵커 · `SHARE_URL` (Master 기입) |
| [`docs/meeting-review-summary.md`](./docs/meeting-review-summary.md) | 회의 1페이지 요약 |
## AI 에이전트 규칙 ## AI 에이전트 규칙
- [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md) - [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md)
- **브랜치:** 작업·머지는 항상 `main` - **브랜치:** 작업·머지는 항상 `main`
- **리모트:** GitHub `origin` + Gitea `gitea`(iykyka) — `main` 갱신 후 `./scripts/push-both.sh` - **리모트:** GitHub `origin` + Gitea `gitea`(iykyka) — `main` 갱신 후 `./scripts/push-both.sh`
- **Q9** (L2 수신 트리거 자동응대)는 Master 확정 전 구현 금지
## 현재 단계 ## 현재 단계
- Phase 1 **A~C** + Track A/B/C + **Q8**(로그인·설정·로그아웃) 코드 완료 - Phase 1 **A~C** + **N2 컷오버 + N3 안정화 완료** — 라이브: [`https://msn.iykyka.com`](https://msn.iykyka.com)
- 라이브: [`https://msn.iykyka.com`](https://msn.iykyka.com) · 데모 코드 **`DEMO-YKAVU`** - 테스터 안내: [`docs/tester-guide.md`](./docs/tester-guide.md) · 데모 코드 **`DEMO-YKAVU`**
- **웹 고도화 (W0~W7):** 구현·배포 완료 — [`docs/web-upgrade.md`](./docs/web-upgrade.md). - **다음 (선택):** N4 FCM·Android QA
**남은 것:** W4 **accept** (하드 리프레시·알림 허용·로그인 후 `/admin/push-test``sent >= 1`) - **맨 마지막:** N5 / D — 사람 PoC (`docs/roadmap.md` §3). §3 기본값 추측 금지
- **후순위 (Master):** Android N4 — `google-services.json` PC 배치 · 실기기 FCM · UI 탭 · APK · `SHARE_URL` - 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입
- **결정 대기:** Q9 — L2 의미 (카피 정렬 ± 수신 자동응대) — 웹 트랙에도 **비범위**
- **맨 마지막:** N5 / D — 사람 PoC. §3 기본값 추측 금지
상세 현황: [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) §0.
## 로컬 실행 (요약) ## 로컬 실행 (요약)
@ -105,7 +60,6 @@ cd mobile && flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080
- 구성: [`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) - Portainer·컷오버: [`docs/deploy-portainer.md`](./docs/deploy-portainer.md)
- FCM: [`docs/fcm-setup.md`](./docs/fcm-setup.md)
- 서버 기동: `./scripts/server-up.sh` (호스트에서 `.env` 채운 뒤) - 서버 기동: `./scripts/server-up.sh` (호스트에서 `.env` 채운 뒤)
```bash ```bash

View File

@ -47,29 +47,6 @@ SYSTEM_PROMPT = """너는 어떤 사람의 '와카뷰'다. 아래 예시 발화
내용은 여기까지 오지 않는다. 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)""" 내용은 여기까지 오지 않는다. 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)"""
# 관계별 페르소나 (roadmap.md §2.7-B, PRD.md §2.1-②/§3.1) -- 최소 2종. 말투
# "예시"가 이미 있는 격식 수준을 담고 있으므로, 이 지침은 그 예시를 뒤집지
# 않는 선에서 미묘하게 톤을 조정하는 보조 신호일 뿐이다.
RELATIONSHIP_TIER_INSTRUCTIONS = {
"close": "\n\n[관계] 상대는 나와 가까운 사이다. 말투 예시가 허용하는 범위에서 편하고 친근하게 써라.",
"formal": "\n\n[관계] 상대는 나와 공식적인 사이다. 말투 예시의 격식 수준을 지키면서, "
"이모티콘/줄임말은 예시에 없다면 새로 만들지 말고 평소보다 한 톤 더 예의를 갖춰라.",
}
def system_prompt_for_tier(relationship_tier=None, relationship_note=None):
"""relationship_note (roadmap.md §2.7-E): a free-text per-contact note
like "호칭: 자기야, 절대 언급 금지: 전 여친" (Contact.RelationshipNote). Purely
a tone/content hint about *this specific person* -- distinct from the
close/formal tier instruction above, and never a bypass of the
escalation/identity gates in draft_reply(). Empty/None leaves the prompt
unchanged."""
extra = RELATIONSHIP_TIER_INSTRUCTIONS.get(relationship_tier, "")
if relationship_note:
extra += f"\n\n[관계 메모] {relationship_note} -- 위 내용을 참고해 호칭/금기어 등을 지켜라."
return SYSTEM_PROMPT + extra
def build_user_prompt(style_examples, context_lines): def build_user_prompt(style_examples, context_lines):
examples = "\n".join(f"- {s}" for s in style_examples) examples = "\n".join(f"- {s}" for s in style_examples)
context = "\n".join(context_lines) context = "\n".join(context_lines)
@ -84,26 +61,12 @@ def last_incoming_text(context_lines):
return last.split(": ", 1)[1] if ": " in last else last return last.split(": ", 1)[1] if ": " in last else last
def draft_reply( def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key=None):
style_examples,
context_lines,
model="gemini-2.5-flash",
api_key=None,
relationship_tier=None,
relationship_note=None,
):
"""Returns (status, text). status is one of "escalate" | "no_key" | "ok". """Returns (status, text). status is one of "escalate" | "no_key" | "ok".
"escalate": text is the escalation reason (금전/약속 확정/감정적으로 무거운 주제). "escalate": text is the escalation reason (금전/약속 확정/감정적으로 무거운 주제).
"no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing). "no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing).
"ok": text is the generated draft. "ok": text is the generated draft.
relationship_tier: "close" | "formal" | None (roadmap.md §2.7-B). Only
nudges tone -- escalation/identity gating above is unaffected by it.
relationship_note: free-text per-contact note (roadmap.md §2.7-E), e.g.
"호칭: 자기야, 절대 언급 금지: 전 여친". Also only a tone/content hint --
escalation/identity gating above is unaffected by it, same as tier.
""" """
incoming = last_incoming_text(context_lines) incoming = last_incoming_text(context_lines)
gate = check_escalation(incoming) gate = check_escalation(incoming)
@ -126,9 +89,6 @@ def draft_reply(
resp = client.models.generate_content( resp = client.models.generate_content(
model=model, model=model,
contents=build_user_prompt(style_examples, context_lines), contents=build_user_prompt(style_examples, context_lines),
config=types.GenerateContentConfig( config=types.GenerateContentConfig(system_instruction=SYSTEM_PROMPT, max_output_tokens=300),
system_instruction=system_prompt_for_tier(relationship_tier, relationship_note),
max_output_tokens=300,
),
) )
return "ok", resp.text.strip() return "ok", resp.text.strip()

View File

@ -7,7 +7,6 @@ from pydantic import BaseModel, model_validator
from .escalation_filter import check as check_escalation from .escalation_filter import check as check_escalation
from .generation import draft_reply, last_incoming_text, load_dotenv_if_present from .generation import draft_reply, last_incoming_text, load_dotenv_if_present
from .retrieve_style import retrieve as retrieve_style_examples from .retrieve_style import retrieve as retrieve_style_examples
from .summarize import summarize_messages
@asynccontextmanager @asynccontextmanager
@ -30,8 +29,6 @@ class DraftRequest(BaseModel):
history: Optional[List[str]] = None history: Optional[List[str]] = None
k: int = 6 k: int = 6
model: str = "gemini-2.5-flash" model: str = "gemini-2.5-flash"
relationship_tier: Optional[str] = None
relationship_note: Optional[str] = None
@model_validator(mode="after") @model_validator(mode="after")
def check_exactly_one_style_source(self): def check_exactly_one_style_source(self):
@ -73,30 +70,5 @@ def draft(req: DraftRequest):
req.history, last_incoming_text(req.context_lines), k=req.k req.history, last_incoming_text(req.context_lines), k=req.k
) )
status, text = draft_reply( status, text = draft_reply(style_examples, req.context_lines, model=req.model)
style_examples,
req.context_lines,
model=req.model,
relationship_tier=req.relationship_tier,
relationship_note=req.relationship_note,
)
return DraftResponse(status=status, text=text) return DraftResponse(status=status, text=text)
class SummarizeRequest(BaseModel):
my_display_name: str
context_lines: List[str]
model: str = "gemini-2.5-flash"
class SummarizeResponse(BaseModel):
status: str
summary: str
@app.post("/summarize", response_model=SummarizeResponse)
def summarize(req: SummarizeRequest):
"""단톡 따라잡기 (roadmap.md §2.7-A) -- read-only catch-up summary, no
escalation/identity gating since nothing generated here is ever sent."""
status, text = summarize_messages(req.my_display_name, req.context_lines, model=req.model)
return SummarizeResponse(status=status, summary=text)

View File

@ -1,46 +0,0 @@
"""Group catch-up summary -- roadmap.md §2.7-A "단톡 따라잡기". Separate from
generation.py's draft_reply: this never produces something meant to be sent,
so it carries none of the escalation/identity gating a draft needs, and it
summarizes many messages at once instead of drafting one reply.
"""
from .generation import load_dotenv_if_present # re-exported for main.py convenience
SUMMARY_SYSTEM_PROMPT = """너는 사용자가 오랫동안 못 본 단체 대화방을 대신 읽고 요약해 주는 비서다. \
아래 대화 로그를 보고, 사용자('')에게 필요한 내용만 3~5줄로 한국어로 요약해라.
지켜야 :
- 나에게 멘션/질문된 , 결정된 사항(약속·시간·장소 ) 우선으로 다룬다.
- 나와 무관한 잡담은 요약에서 뺀다.
- 답장이 필요해 보이는 항목이 있으면 마지막 줄에 "-> 답장 필요: ..." 형식으로 짧게 짚어준다.
- 요약문만 출력한다. 인사말, 설명, 따옴표를 덧붙이지 않는다.
- 요약은 자체로 전송되지 않는다 -- 답장은 항상 사용자가 직접 검토해서 보낸다."""
def build_summary_prompt(my_display_name, context_lines):
context = "\n".join(context_lines)
return f"""[내 이름] {my_display_name}\n\n[안 본 동안의 대화]\n{context}\n\n위 대화 요약:"""
def summarize_messages(my_display_name, context_lines, model="gemini-2.5-flash", api_key=None):
"""Returns (status, text). status is one of "no_key" | "ok".
"no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing).
"ok": text is the generated summary.
"""
import os
api_key = api_key or os.environ.get("GEMINI_API_KEY")
prompt = build_summary_prompt(my_display_name, context_lines)
if not api_key:
return "no_key", prompt
from google import genai # pip install google-genai
from google.genai import types
client = genai.Client(api_key=api_key)
resp = client.models.generate_content(
model=model,
contents=prompt,
config=types.GenerateContentConfig(system_instruction=SUMMARY_SYSTEM_PROMPT, max_output_tokens=300),
)
return "ok", resp.text.strip()

View File

@ -6,14 +6,7 @@ import sys
import types import types
from unittest.mock import MagicMock from unittest.mock import MagicMock
from app.generation import ( from app.generation import build_user_prompt, draft_reply, last_incoming_text
RELATIONSHIP_TIER_INSTRUCTIONS,
SYSTEM_PROMPT,
build_user_prompt,
draft_reply,
last_incoming_text,
system_prompt_for_tier,
)
def test_last_incoming_text_strips_speaker_prefix(): def test_last_incoming_text_strips_speaker_prefix():
@ -80,97 +73,3 @@ def test_draft_reply_ok_calls_gemini_with_expected_args(monkeypatch):
_, kwargs = fake_client.models.generate_content.call_args _, kwargs = fake_client.models.generate_content.call_args
assert kwargs["model"] == "gemini-2.5-flash" assert kwargs["model"] == "gemini-2.5-flash"
assert "오늘 저녁 뭐 먹을래" in kwargs["contents"] assert "오늘 저녁 뭐 먹을래" in kwargs["contents"]
def test_system_prompt_for_tier_none_returns_base():
assert system_prompt_for_tier(None) == SYSTEM_PROMPT
def test_system_prompt_for_tier_close_adds_instruction():
prompt = system_prompt_for_tier("close")
assert prompt.startswith(SYSTEM_PROMPT)
assert RELATIONSHIP_TIER_INSTRUCTIONS["close"] in prompt
def test_system_prompt_for_tier_formal_adds_instruction():
prompt = system_prompt_for_tier("formal")
assert prompt.startswith(SYSTEM_PROMPT)
assert RELATIONSHIP_TIER_INSTRUCTIONS["formal"] in prompt
def test_system_prompt_for_tier_includes_relationship_note_when_present():
note = "호칭: 자기야, 절대 언급 금지: 전 여친"
prompt = system_prompt_for_tier("close", note)
assert prompt.startswith(SYSTEM_PROMPT)
assert RELATIONSHIP_TIER_INSTRUCTIONS["close"] in prompt
assert "[관계 메모]" in prompt
assert note in prompt
def test_system_prompt_without_note_unaffected():
# No note passed at all (default None) must produce the exact same
# prompt as before this field existed.
assert system_prompt_for_tier("close", None) == system_prompt_for_tier("close")
assert "[관계 메모]" not in system_prompt_for_tier("close")
# Explicit empty string must behave the same as None (falsy check).
assert system_prompt_for_tier("formal", "") == system_prompt_for_tier("formal")
def test_draft_reply_passes_relationship_tier_into_system_instruction(monkeypatch):
fake_response = MagicMock()
fake_response.text = "네 알겠습니다"
fake_client = MagicMock()
fake_client.models.generate_content.return_value = fake_response
fake_genai = types.ModuleType("google.genai")
fake_genai.Client = MagicMock(return_value=fake_client)
fake_types = types.ModuleType("google.genai.types")
fake_types.GenerateContentConfig = MagicMock(side_effect=lambda **kw: kw)
fake_google = types.ModuleType("google")
fake_google.genai = fake_genai
monkeypatch.setitem(sys.modules, "google", fake_google)
monkeypatch.setitem(sys.modules, "google.genai", fake_genai)
monkeypatch.setitem(sys.modules, "google.genai.types", fake_types)
status, text = draft_reply(
["알겠습니다"],
["상대: 내일 회의 시간 괜찮으세요?"],
api_key="fake-key",
relationship_tier="formal",
)
assert status == "ok"
_, kwargs = fake_client.models.generate_content.call_args
assert RELATIONSHIP_TIER_INSTRUCTIONS["formal"] in kwargs["config"]["system_instruction"]
def test_draft_reply_passes_relationship_note_into_system_instruction(monkeypatch):
fake_response = MagicMock()
fake_response.text = "네 알겠습니다"
fake_client = MagicMock()
fake_client.models.generate_content.return_value = fake_response
fake_genai = types.ModuleType("google.genai")
fake_genai.Client = MagicMock(return_value=fake_client)
fake_types = types.ModuleType("google.genai.types")
fake_types.GenerateContentConfig = MagicMock(side_effect=lambda **kw: kw)
fake_google = types.ModuleType("google")
fake_google.genai = fake_genai
monkeypatch.setitem(sys.modules, "google", fake_google)
monkeypatch.setitem(sys.modules, "google.genai", fake_genai)
monkeypatch.setitem(sys.modules, "google.genai.types", fake_types)
note = "호칭: 자기야, 절대 언급 금지: 전 여친"
status, text = draft_reply(
["알겠습니다"],
["상대: 내일 회의 시간 괜찮으세요?"],
api_key="fake-key",
relationship_tier="formal",
relationship_note=note,
)
assert status == "ok"
_, kwargs = fake_client.models.generate_content.call_args
assert note in kwargs["config"]["system_instruction"]

View File

@ -2,7 +2,6 @@
/escalate/check into pytest -- roadmap.md Phase 1 §2.5.""" /escalate/check into pytest -- roadmap.md Phase 1 §2.5."""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import app.main as main_module
from app.main import app from app.main import app
client = TestClient(app) client = TestClient(app)
@ -91,98 +90,3 @@ def test_draft_rejects_both_style_sources():
def test_draft_rejects_neither_style_source(): def test_draft_rejects_neither_style_source():
resp = client.post("/draft", json={"context_lines": ["상대: 안녕"]}) resp = client.post("/draft", json={"context_lines": ["상대: 안녕"]})
assert resp.status_code == 422 assert resp.status_code == 422
def test_summarize_no_key(monkeypatch):
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
resp = client.post(
"/summarize",
json={
"my_display_name": "민수",
"context_lines": ["철수: 토요일 모임 3시로 하자", "영희: ㅇㅋ"],
},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "no_key"
assert "토요일 모임 3시로 하자" in body["summary"]
def test_draft_passes_relationship_tier_through_to_draft_reply(monkeypatch):
captured = {}
def fake_draft_reply(
style_examples, context_lines, model="gemini-2.5-flash", relationship_tier=None, relationship_note=None
):
captured["relationship_tier"] = relationship_tier
return "ok", "네 알겠습니다"
monkeypatch.setattr(main_module, "draft_reply", fake_draft_reply)
resp = client.post(
"/draft",
json={
"context_lines": ["상대: 내일 회의 시간 괜찮으세요?"],
"style_examples": ["알겠습니다"],
"relationship_tier": "formal",
},
)
assert resp.status_code == 200
assert captured["relationship_tier"] == "formal"
def test_draft_relationship_tier_defaults_to_none(monkeypatch):
captured = {}
def fake_draft_reply(
style_examples, context_lines, model="gemini-2.5-flash", relationship_tier=None, relationship_note=None
):
captured["relationship_tier"] = relationship_tier
return "ok", "ㅇㅋ"
monkeypatch.setattr(main_module, "draft_reply", fake_draft_reply)
resp = client.post(
"/draft",
json={"context_lines": ["상대: 오늘 뭐해?"], "style_examples": ["ㅇㅋ"]},
)
assert resp.status_code == 200
assert captured["relationship_tier"] is None
def test_draft_passes_relationship_note_through_to_draft_reply(monkeypatch):
captured = {}
def fake_draft_reply(
style_examples, context_lines, model="gemini-2.5-flash", relationship_tier=None, relationship_note=None
):
captured["relationship_note"] = relationship_note
return "ok", "네 알겠습니다"
monkeypatch.setattr(main_module, "draft_reply", fake_draft_reply)
resp = client.post(
"/draft",
json={
"context_lines": ["상대: 자기야 오늘 뭐해?"],
"style_examples": ["응 집이야"],
"relationship_note": "호칭: 자기야, 절대 언급 금지: 전 여친",
},
)
assert resp.status_code == 200
assert captured["relationship_note"] == "호칭: 자기야, 절대 언급 금지: 전 여친"
def test_draft_relationship_note_defaults_to_none(monkeypatch):
captured = {}
def fake_draft_reply(
style_examples, context_lines, model="gemini-2.5-flash", relationship_tier=None, relationship_note=None
):
captured["relationship_note"] = relationship_note
return "ok", "ㅇㅋ"
monkeypatch.setattr(main_module, "draft_reply", fake_draft_reply)
resp = client.post(
"/draft",
json={"context_lines": ["상대: 오늘 뭐해?"], "style_examples": ["ㅇㅋ"]},
)
assert resp.status_code == 200
assert captured["relationship_note"] is None

View File

@ -1,55 +0,0 @@
"""단톡 따라잡기 (roadmap.md §2.7-A) -- the Gemini call is mocked, same pattern
as test_generation.py. Asserts the no_key fallback and the exact request
shape sent to the model; not real summary quality."""
import sys
import types
from unittest.mock import MagicMock
from app.summarize import build_summary_prompt, summarize_messages
def test_build_summary_prompt_includes_name_and_context():
prompt = build_summary_prompt("민수", ["철수: 이번 주 토요일 모임 3시로 확정", "영희: 나 못 갈 것 같아"])
assert "민수" in prompt
assert "토요일 모임 3시로 확정" in prompt
assert "나 못 갈 것 같아" in prompt
def test_summarize_messages_no_key_returns_prompt_without_calling_model(monkeypatch):
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
status, text = summarize_messages("민수", ["철수: 밥 언제 먹지"], api_key=None)
assert status == "no_key"
assert "밥 언제 먹지" in text
def test_summarize_messages_ok_calls_gemini_with_expected_args(monkeypatch):
# summarize.py does `from google import genai` / `from google.genai import
# types` *inside* summarize_messages, same stubbing approach as
# test_generation.py to avoid the real google-genai dependency chain.
fake_response = MagicMock()
fake_response.text = " 토요일 3시 모임 확정. -> 답장 필요: 참석 여부 답하기 "
fake_client = MagicMock()
fake_client.models.generate_content.return_value = fake_response
fake_genai = types.ModuleType("google.genai")
fake_genai.Client = MagicMock(return_value=fake_client)
fake_types = types.ModuleType("google.genai.types")
fake_types.GenerateContentConfig = MagicMock(side_effect=lambda **kw: kw)
fake_google = types.ModuleType("google")
fake_google.genai = fake_genai
monkeypatch.setitem(sys.modules, "google", fake_google)
monkeypatch.setitem(sys.modules, "google.genai", fake_genai)
monkeypatch.setitem(sys.modules, "google.genai.types", fake_types)
status, text = summarize_messages(
"민수", ["철수: 토요일 3시에 모이자", "영희: ㅇㅋ"], model="gemini-2.5-flash", api_key="fake-key"
)
assert status == "ok"
assert text == "토요일 3시 모임 확정. -> 답장 필요: 참석 여부 답하기"
fake_genai.Client.assert_called_once_with(api_key="fake-key")
fake_client.models.generate_content.assert_called_once()
_, kwargs = fake_client.models.generate_content.call_args
assert kwargs["model"] == "gemini-2.5-flash"
assert "토요일 3시에 모이자" in kwargs["contents"]

View File

@ -3,7 +3,6 @@ package main
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
@ -19,20 +18,12 @@ type createContactRequest struct {
DisplayName string `json:"display_name" binding:"required"` DisplayName string `json:"display_name" binding:"required"`
ContactUserID *uint `json:"contact_user_id"` ContactUserID *uint `json:"contact_user_id"`
RelationshipNote string `json:"relationship_note"` RelationshipNote string `json:"relationship_note"`
// RelationshipTier overrides the owner's global default for this
// contact (roadmap.md §2.7-B). Nil = use the global default.
RelationshipTier *RelationshipTier `json:"relationship_tier"`
// AutonomyLevel overrides the owner's global default autonomy level for
// this contact (roadmap.md §2.7-D). Nil = use the global default.
AutonomyLevel *AutonomyLevel `json:"autonomy_level"`
} }
type updateContactRequest struct { type updateContactRequest struct {
DisplayName string `json:"display_name" binding:"required"` DisplayName string `json:"display_name" binding:"required"`
ContactUserID *uint `json:"contact_user_id"` ContactUserID *uint `json:"contact_user_id"`
RelationshipNote string `json:"relationship_note"` RelationshipNote string `json:"relationship_note"`
RelationshipTier *RelationshipTier `json:"relationship_tier"`
AutonomyLevel *AutonomyLevel `json:"autonomy_level"`
} }
func contactJSON(ct Contact) gin.H { func contactJSON(ct Contact) gin.H {
@ -41,55 +32,33 @@ func contactJSON(ct Contact) gin.H {
"display_name": ct.DisplayName, "display_name": ct.DisplayName,
"contact_user_id": ct.ContactUserID, "contact_user_id": ct.ContactUserID,
"relationship_note": ct.RelationshipNote, "relationship_note": ct.RelationshipNote,
"relationship_tier": ct.RelationshipTier,
"autonomy_level": ct.AutonomyLevel,
} }
} }
type loginRequest struct { type loginRequest struct {
InviteCode string `json:"invite_code" binding:"required"` InviteCode string `json:"invite_code" binding:"required"`
DisplayName string `json:"display_name"`
} }
func registerA1A2Routes(r *gin.Engine, db *gorm.DB) { func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
// Q8b: re-enter closed beta with invite code (+ display_name for shared DEMO).
r.POST("/auth/login", func(c *gin.Context) { r.POST("/auth/login", func(c *gin.Context) {
var req loginRequest var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return return
} }
var invite InviteCode
if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
return
}
if invite.UsedAt == nil || invite.UsedByUserID == nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invite code not yet used for signup"})
return
}
var user User var user User
if demoInviteEnabled() && isDemoInviteCode(req.InviteCode) { if err := db.First(&user, *invite.UsedByUserID).Error; err != nil {
name := strings.TrimSpace(req.DisplayName) c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
if name == "" { return
c.JSON(http.StatusBadRequest, gin.H{"detail": "display_name is required for DEMO invite login"})
return
}
// Demo signups store a unique per-user invite_code, not DEMO-YKAVU.
// Match the most recent user with this display_name (testers reuse names rarely).
if err := db.Where("display_name = ?", name).Order("id desc").First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "no user with that display_name under DEMO signup"})
return
}
} else {
var invite InviteCode
if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
return
}
if invite.UsedAt == nil || invite.UsedByUserID == nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invite code not yet used for signup"})
return
}
if err := db.First(&user, *invite.UsedByUserID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
return
}
if name := strings.TrimSpace(req.DisplayName); name != "" && user.DisplayName != name {
c.JSON(http.StatusForbidden, gin.H{"detail": "display_name does not match this invite"})
return
}
} }
session, err := createSession(db, user.ID) session, err := createSession(db, user.ID)
if err != nil { if err != nil {
@ -224,26 +193,12 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
for _, pp := range pps { for _, pp := range pps {
participantIDs = append(participantIDs, pp.UserID) participantIDs = append(participantIDs, pp.UserID)
} }
// Unread count (roadmap.md §2.7-A "안 본 동안" badge) -- messages
// after this participant's read marker, excluding their own sends
// (sending a message means you've obviously seen it).
unreadQuery := db.Model(&Message{}).
Where("conversation_id = ? AND retracted = ? AND sender_id != ?", conv.ID, false, userID)
if p.LastReadMessageID != nil {
unreadQuery = unreadQuery.Where("id > ?", *p.LastReadMessageID)
}
var unreadCount int64
unreadQuery.Count(&unreadCount)
out = append(out, gin.H{ out = append(out, gin.H{
"id": conv.ID, "id": conv.ID,
"is_group": conv.IsGroup, "is_group": conv.IsGroup,
"twin_disabled_by_peer": conv.TwinDisabledByPeer, "twin_disabled_by_peer": conv.TwinDisabledByPeer,
"twin_disabled_by_flood": conv.TwinDisabledByFlood, "user_ids": participantIDs,
"user_ids": participantIDs, "created_at": conv.CreatedAt,
"created_at": conv.CreatedAt,
"unread_count": unreadCount,
}) })
} }
c.JSON(http.StatusOK, gin.H{"conversations": out}) c.JSON(http.StatusOK, gin.H{"conversations": out})
@ -262,23 +217,19 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
c.JSON(http.StatusForbidden, gin.H{"detail": "not a participant of this conversation"}) c.JSON(http.StatusForbidden, gin.H{"detail": "not a participant of this conversation"})
return return
} }
query := db.Where("conversation_id = ?", convID)
// 오프라인 큐 캐치업(roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11):
// 재연결·앱 복귀 시 클라이언트가 이미 가진 마지막 메시지 id 이후만
// 다시 받아 gap을 메운다. 파라미터가 없으면 기존 동작(전체 히스토리) 그대로.
if q := c.Query("since_id"); q != "" {
sinceID, err := strconv.ParseUint(q, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "since_id must be a positive integer"})
return
}
query = query.Where("id > ?", sinceID)
}
var messages []Message var messages []Message
query.Order("id asc").Find(&messages) db.Where("conversation_id = ?", convID).Order("id asc").Find(&messages)
out := make([]gin.H, 0, len(messages)) out := make([]gin.H, 0, len(messages))
for _, m := range messages { for _, m := range messages {
out = append(out, messageJSON(m)) out = append(out, gin.H{
"id": m.ID,
"conversation_id": m.ConversationID,
"sender_id": m.SenderID,
"sender_mode": m.SenderMode,
"text": m.Text,
"retracted": m.Retracted,
"created_at": m.CreatedAt,
})
} }
c.JSON(http.StatusOK, gin.H{"messages": out}) c.JSON(http.StatusOK, gin.H{"messages": out})
}) })
@ -296,21 +247,11 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return return
} }
if req.RelationshipTier != nil && !validRelationshipTier(*req.RelationshipTier) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "relationship_tier must be one of close, formal"})
return
}
if req.AutonomyLevel != nil && !validAutonomyLevel(*req.AutonomyLevel) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "autonomy_level must be one of L0, L1, L2"})
return
}
contact := Contact{ contact := Contact{
OwnerUserID: userID, OwnerUserID: userID,
ContactUserID: req.ContactUserID, ContactUserID: req.ContactUserID,
DisplayName: req.DisplayName, DisplayName: req.DisplayName,
RelationshipNote: req.RelationshipNote, RelationshipNote: req.RelationshipNote,
RelationshipTier: req.RelationshipTier,
AutonomyLevel: req.AutonomyLevel,
} }
if err := db.Create(&contact).Error; err != nil { if err := db.Create(&contact).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
@ -353,14 +294,6 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return return
} }
if req.RelationshipTier != nil && !validRelationshipTier(*req.RelationshipTier) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "relationship_tier must be one of close, formal"})
return
}
if req.AutonomyLevel != nil && !validAutonomyLevel(*req.AutonomyLevel) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "autonomy_level must be one of L0, L1, L2"})
return
}
var contact Contact var contact Contact
if err := db.Where("id = ? AND owner_user_id = ?", contactID, userID).First(&contact).Error; err != nil { 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"}) c.JSON(http.StatusNotFound, gin.H{"detail": "contact not found"})
@ -373,8 +306,6 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
contact.DisplayName = req.DisplayName contact.DisplayName = req.DisplayName
contact.ContactUserID = req.ContactUserID contact.ContactUserID = req.ContactUserID
contact.RelationshipNote = req.RelationshipNote contact.RelationshipNote = req.RelationshipNote
contact.RelationshipTier = req.RelationshipTier
contact.AutonomyLevel = req.AutonomyLevel
if err := db.Save(&contact).Error; err != nil { if err := db.Save(&contact).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return return

View File

@ -63,7 +63,7 @@ func TestLoginReturnsSessionToken(t *testing.T) {
t.Fatalf("signup: %d", signup.StatusCode) t.Fatalf("signup: %d", signup.StatusCode)
} }
login := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: code, DisplayName: "로그인"}) login := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: code})
if login.StatusCode != http.StatusOK { if login.StatusCode != http.StatusOK {
t.Fatalf("expected 200 login, got %d", login.StatusCode) t.Fatalf("expected 200 login, got %d", login.StatusCode)
} }
@ -74,23 +74,6 @@ func TestLoginReturnsSessionToken(t *testing.T) {
} }
} }
func TestDemoLoginRequiresDisplayName(t *testing.T) {
t.Setenv("ALLOW_DEMO_INVITE", "1")
server, _ := setupTestServer(t)
signup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: demoInviteCode, DisplayName: "데모유저"})
if signup.StatusCode != http.StatusOK {
t.Fatalf("demo signup: %d", signup.StatusCode)
}
missing := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: demoInviteCode})
if missing.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 without display_name, got %d", missing.StatusCode)
}
ok := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: demoInviteCode, DisplayName: "데모유저"})
if ok.StatusCode != http.StatusOK {
t.Fatalf("expected 200 demo login, got %d", ok.StatusCode)
}
}
func TestConversationContactMessageHistoryAndEscalationLogs(t *testing.T) { func TestConversationContactMessageHistoryAndEscalationLogs(t *testing.T) {
server, _ := setupTestServer(t) server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "주인") ownerID, ownerToken := mustSignup(t, server.URL, "주인")
@ -177,90 +160,6 @@ func TestConversationContactMessageHistoryAndEscalationLogs(t *testing.T) {
} }
} }
// 오프라인 큐 캐치업(roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11):
// 재연결·앱 복귀 시 since_id로 놓친 메시지만 다시 받아올 수 있어야 한다.
func TestListMessagesSinceID(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "캐치업주인")
peerID, _ := mustSignup(t, server.URL, "캐치업상대")
convResp := postJSONAuth(t, server.URL+"/conversations", ownerToken, createConversationRequest{
UserIDs: []uint{ownerID, peerID},
})
if convResp.StatusCode != http.StatusOK {
t.Fatalf("create conversation: %d", convResp.StatusCode)
}
var conv map[string]interface{}
json.NewDecoder(convResp.Body).Decode(&conv)
convID := uint(conv["id"].(float64))
convPath := server.URL + "/conversations/" + strconv.FormatUint(uint64(convID), 10) + "/messages"
var sentIDs []uint
for _, text := range []string{"하나", "둘", "셋"} {
resp := postJSON(t, convPath, sendMessageRequest{SenderID: ownerID, Text: text})
if resp.StatusCode != http.StatusOK {
t.Fatalf("send %q: %d", text, resp.StatusCode)
}
var sent map[string]interface{}
json.NewDecoder(resp.Body).Decode(&sent)
sentIDs = append(sentIDs, uint(sent["id"].(float64)))
}
getMessages := func(query string) *http.Response {
req, _ := http.NewRequest(http.MethodGet, convPath+query, nil)
req.Header.Set("Authorization", "Bearer "+ownerToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
// No since_id -- existing behavior unchanged: full history.
full := getMessages("")
if full.StatusCode != http.StatusOK {
t.Fatalf("full history: %d", full.StatusCode)
}
var fullOut map[string]interface{}
json.NewDecoder(full.Body).Decode(&fullOut)
if len(fullOut["messages"].([]interface{})) != 3 {
t.Fatalf("expected 3 messages without since_id, got %v", fullOut)
}
// since_id=<first message> -- only the later two.
since := getMessages("?since_id=" + strconv.FormatUint(uint64(sentIDs[0]), 10))
if since.StatusCode != http.StatusOK {
t.Fatalf("since_id history: %d", since.StatusCode)
}
var sinceOut map[string]interface{}
json.NewDecoder(since.Body).Decode(&sinceOut)
sinceMsgs := sinceOut["messages"].([]interface{})
if len(sinceMsgs) != 2 {
t.Fatalf("expected 2 messages after since_id, got %v", sinceOut)
}
firstReturned := sinceMsgs[0].(map[string]interface{})
if uint(firstReturned["id"].(float64)) != sentIDs[1] {
t.Fatalf("expected first returned message to be %d, got %v", sentIDs[1], firstReturned["id"])
}
// since_id beyond the highest message id -- empty array, not an error.
beyond := getMessages("?since_id=" + strconv.FormatUint(uint64(sentIDs[2])+1000, 10))
if beyond.StatusCode != http.StatusOK {
t.Fatalf("beyond history: %d", beyond.StatusCode)
}
var beyondOut map[string]interface{}
json.NewDecoder(beyond.Body).Decode(&beyondOut)
if len(beyondOut["messages"].([]interface{})) != 0 {
t.Fatalf("expected 0 messages beyond highest id, got %v", beyondOut)
}
// Non-numeric since_id -- 400.
bad := getMessages("?since_id=abc")
if bad.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for non-numeric since_id, got %d", bad.StatusCode)
}
}
func TestContactScopedWhitelistMatch(t *testing.T) { func TestContactScopedWhitelistMatch(t *testing.T) {
server, db := setupTestServer(t) server, db := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "화이트주인") ownerID, ownerToken := mustSignup(t, server.URL, "화이트주인")

View File

@ -19,19 +19,10 @@ func newAIServiceClient() *AIServiceClient {
} }
type draftRequest struct { type draftRequest struct {
ContextLines []string `json:"context_lines"` ContextLines []string `json:"context_lines"`
// RelationshipTier (roadmap.md §2.7-B): "close" | "formal". Empty is StyleExamples []string `json:"style_examples,omitempty"`
// treated by ai-service as "no tier info" and falls back to its own History []string `json:"history,omitempty"`
// default tone, same as before this field existed. K int `json:"k,omitempty"`
RelationshipTier string `json:"relationship_tier,omitempty"`
// RelationshipNote (roadmap.md §2.7-E): the contact's free-text note
// (e.g. "호칭: 자기야, 절대 언급 금지: 전 여친"). Empty means "no note" and
// ai-service's prompt gets no extra injection, same as before this field
// existed.
RelationshipNote string `json:"relationship_note,omitempty"`
StyleExamples []string `json:"style_examples,omitempty"`
History []string `json:"history,omitempty"`
K int `json:"k,omitempty"`
} }
type draftResponse struct { type draftResponse struct {
@ -62,42 +53,6 @@ func (c *AIServiceClient) requestDraft(req draftRequest) (*draftResponse, error)
return &out, nil return &out, nil
} }
type summaryRequest struct {
MyDisplayName string `json:"my_display_name"`
ContextLines []string `json:"context_lines"`
}
type summaryResponse struct {
Status string `json:"status"`
Summary string `json:"summary"`
}
// requestSummary calls ai-service's POST /summarize -- the "단톡 따라잡기"
// catch-up summary (roadmap.md §2.7-A), separate from /draft since it never
// produces something meant to be sent, only read.
func (c *AIServiceClient) requestSummary(req summaryRequest) (*summaryResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
resp, err := c.HTTP.Post(c.BaseURL+"/summarize", "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("ai service unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ai service returned %d", resp.StatusCode)
}
var out summaryResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}
type escalationCheckRequest struct { type escalationCheckRequest struct {
Text string `json:"text"` Text string `json:"text"`
} }

View File

@ -1,32 +0,0 @@
package main
import "gorm.io/gorm"
// resolveAutonomyLevel implements roadmap.md §2.7-D: a user may want L2
// (auto-send) with a close friend but L0 (drafts only) with someone else --
// PRD.md §3.1 "자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정" and §4
// "사용자가 여러 상대에게 다른 자율성 레벨을 원함". Mirrors
// resolveRelationshipTier's structure exactly (both share persona.go's
// findCounterpartContact lookup). Resolution order: contact-specific
// override (1:1 only) -> the sender's global TwinSettings default ->
// AutonomyL0 as the fail-safe fallback if nothing is set (L0 is the
// documented safe default everywhere else in this codebase -- never fail
// open to L1/L2). Group conversations always use the global default, same
// as relationship tier, since a group has more than one counterpart to pick
// a level for -- note this resolver only decides the L0/L1/L2 branch; the
// unconditional "group conversations never auto-send" hard block in
// main.go's POST /conversations/:id/messages runs earlier and independent
// of this function's result.
func resolveAutonomyLevel(db *gorm.DB, actorID, conversationID uint) AutonomyLevel {
if contact, ok := findCounterpartContact(db, actorID, conversationID); ok {
if contact.AutonomyLevel != nil && validAutonomyLevel(*contact.AutonomyLevel) {
return *contact.AutonomyLevel
}
}
var settings TwinSettings
if err := db.Where("user_id = ?", actorID).First(&settings).Error; err == nil && validAutonomyLevel(settings.AutonomyLevel) {
return settings.AutonomyLevel
}
return AutonomyL0
}

View File

@ -1,229 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"strconv"
"testing"
)
// TestContactAutonomyLevelOverridesGlobalDefaultInSendGate mirrors
// TestContactRelationshipTierOverridesGlobalDefaultInDraft: global default
// stays at the signup default (L0, which blocks all twin auto-send), but a
// per-contact override raises it to L2 for this specific counterpart, so a
// whitelisted twin message goes through without requiring approval.
func TestContactAutonomyLevelOverridesGlobalDefaultInSendGate(t *testing.T) {
server, db := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
l2 := AutonomyL2
contactResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
AutonomyLevel: &l2,
})
if contactResp.StatusCode != http.StatusOK {
t.Fatalf("create contact: %d", contactResp.StatusCode)
}
db.Create(&WhitelistRule{UserID: ownerID, TopicKeyword: "ㅇㅇ"})
convResp := postJSONAuth(t, server.URL+"/conversations", ownerToken, createConversationRequest{
UserIDs: []uint{ownerID, peerID},
})
var conv map[string]interface{}
json.NewDecoder(convResp.Body).Decode(&conv)
convID := uint(conv["id"].(float64))
// Global default is still L0 (never changed) -- without the contact
// override this would 403. Approved is false and Text matches the
// whitelist keyword, which only clears the gate at L2.
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{
SenderID: ownerID, Text: "ㅇㅇ 알겠어", SenderMode: SenderTwin,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 (contact override L2 + whitelist match), got %d", resp.StatusCode)
}
}
// TestSendGateFallsBackToGlobalAutonomyWithoutContactOverride mirrors
// TestDraftFallsBackToGlobalTierWithoutContactOverride: no contact record at
// all -- resolveAutonomyLevel must fall back to the sender's global
// TwinSettings.AutonomyLevel rather than erroring or defaulting to L0 when a
// non-L0 global level was explicitly set.
func TestSendGateFallsBackToGlobalAutonomyWithoutContactOverride(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
setAutonomyLevel(t, server.URL, ownerID, ownerToken, AutonomyL1)
convResp := postJSONAuth(t, server.URL+"/conversations", ownerToken, createConversationRequest{
UserIDs: []uint{ownerID, peerID},
})
var conv map[string]interface{}
json.NewDecoder(convResp.Body).Decode(&conv)
convID := uint(conv["id"].(float64))
// L1 without approval must still block.
blocked := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{
SenderID: ownerID, Text: "안녕하세요", SenderMode: SenderTwin,
})
if blocked.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 for unapproved L1 send, got %d", blocked.StatusCode)
}
// L1 with approval must go through -- confirms the global default (not
// some stray L0 fallback) is really what got resolved.
approved := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{
SenderID: ownerID, Text: "안녕하세요", SenderMode: SenderTwin, Approved: true,
})
if approved.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for approved L1 send using global default, got %d", approved.StatusCode)
}
}
// TestGroupConversationAutonomyAlwaysUsesGlobalDefaultNotContactOverride
// verifies resolveAutonomyLevel's own group-conversation branch directly
// (the unconditional "no twin auto-send in groups" hard block in main.go
// already stops group sends earlier and independently -- this test is about
// resolveAutonomyLevel's resolution order, mirroring
// TestGroupConversationDraftUsesGlobalTierNotContactOverride).
func TestGroupConversationAutonomyAlwaysUsesGlobalDefaultNotContactOverride(t *testing.T) {
server, db := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
l2 := AutonomyL2
postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
AutonomyLevel: &l2,
})
convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID})
// Global default is still the signup default (L0). Even though the
// contact has an L2 override, a group conversation must never pick it up.
got := resolveAutonomyLevel(db, ownerID, convID)
if got != AutonomyL0 {
t.Fatalf("expected group conversation to resolve global default L0 ignoring contact override, got %v", got)
}
}
// TestDraftGroupConversationHardBlockStillWinsOverAutonomyLevel is a smoke
// check that the pre-existing "no twin auto-send in groups" hard block
// (roadmap.md §2.7-A) still runs before -- and independent of -- the
// autonomy gate, even when a contact override would otherwise allow L2.
func TestGroupConversationSendStillBlockedRegardlessOfContactAutonomyOverride(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
l2 := AutonomyL2
postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
AutonomyLevel: &l2,
})
convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID})
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{
SenderID: ownerID, Text: "그룹에서 자동발송 시도", SenderMode: SenderTwin, Approved: true,
})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 for group twin auto-send regardless of contact autonomy override, got %d", resp.StatusCode)
}
}
func TestResolveAutonomyLevelFallsBackToL0WithNoSettingsAtAll(t *testing.T) {
server, db := setupTestServer(t)
// A conversation with a sender that never signed up / has no
// TwinSettings row at all must fail closed to L0, not error out.
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
_ = server
got := resolveAutonomyLevel(db, 999999, conv.ID)
if got != AutonomyL0 {
t.Fatalf("expected fail-safe default L0 for unknown user, got %v", got)
}
}
func TestCreateContactRejectsInvalidAutonomyLevel(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
bad := AutonomyLevel("L9")
resp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "이상함",
AutonomyLevel: &bad,
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 creating contact with invalid autonomy_level, got %d", resp.StatusCode)
}
}
func TestUpdateContactRejectsInvalidAutonomyLevel(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
createResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "친구",
})
var created map[string]interface{}
json.NewDecoder(createResp.Body).Decode(&created)
contactID := uint(created["id"].(float64))
bad := AutonomyLevel("nope")
resp := patchJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts/"+strconv.FormatUint(uint64(contactID), 10), ownerToken, updateContactRequest{
DisplayName: "친구",
AutonomyLevel: &bad,
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 updating contact with invalid autonomy_level, got %d", resp.StatusCode)
}
}
// TestUpdateContactFullReplaceResetsAutonomyLevelOverride confirms Contact
// PATCH is full-replace (like every other Contact field, unlike TwinSettings
// PATCH which is conditional/partial) -- omitting autonomy_level on a later
// PATCH resets the override back to nil (use the global default), it does
// not leave the previous override in place.
func TestUpdateContactFullReplaceResetsAutonomyLevelOverride(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
l2 := AutonomyL2
createResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
AutonomyLevel: &l2,
})
var created map[string]interface{}
json.NewDecoder(createResp.Body).Decode(&created)
contactID := uint(created["id"].(float64))
if created["autonomy_level"] != "L2" {
t.Fatalf("expected autonomy_level L2 right after create, got %v", created["autonomy_level"])
}
// Update without autonomy_level in the request body -- full-replace
// semantics mean this must reset it to nil, exactly like
// relationship_tier does today.
updateResp := patchJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts/"+strconv.FormatUint(uint64(contactID), 10), ownerToken, updateContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
})
if updateResp.StatusCode != http.StatusOK {
t.Fatalf("update contact: %d", updateResp.StatusCode)
}
var updated map[string]interface{}
json.NewDecoder(updateResp.Body).Decode(&updated)
if updated["autonomy_level"] != nil {
t.Fatalf("expected autonomy_level to reset to nil after omitting it on PATCH, got %v", updated["autonomy_level"])
}
}

View File

@ -15,15 +15,13 @@ type registerDeviceRequest struct {
func messageJSON(m Message) gin.H { func messageJSON(m Message) gin.H {
return gin.H{ return gin.H{
"id": m.ID, "id": m.ID,
"conversation_id": m.ConversationID, "conversation_id": m.ConversationID,
"sender_id": m.SenderID, "sender_id": m.SenderID,
"sender_mode": m.SenderMode, "sender_mode": m.SenderMode,
"text": m.Text, "text": m.Text,
"retracted": m.Retracted, "retracted": m.Retracted,
"draft_edited": m.DraftEdited, "created_at": m.CreatedAt,
"naturalness_rating": m.NaturalnessRating,
"created_at": m.CreatedAt,
} }
} }
@ -203,12 +201,6 @@ const adminDashboardHTML = `<!doctype html>
.v { font-size: 1.4rem; font-weight: 700; margin-top: 4px; } .v { font-size: 1.4rem; font-weight: 700; margin-top: 4px; }
pre { background: #fff; border: 1px solid #d5e2db; border-radius: 10px; padding: 14px; overflow: auto; } pre { background: #fff; border: 1px solid #d5e2db; border-radius: 10px; padding: 14px; overflow: auto; }
button { margin: 12px 0; padding: 8px 14px; border-radius: 8px; border: 0; background: #1f6f5b; color: #fff; cursor: pointer; } button { margin: 12px 0; padding: 8px 14px; border-radius: 8px; border: 0; background: #1f6f5b; color: #fff; cursor: pointer; }
.breakdowns { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); margin-top: 20px; }
.breakdown table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #d5e2db; border-radius: 10px; overflow: hidden; }
.breakdown th, .breakdown td { text-align: left; padding: 8px 12px; font-size: .85rem; }
.breakdown th { background: #eaf1ed; color: #40554c; }
.breakdown tr + tr td { border-top: 1px solid #e4ede8; }
.breakdown td.n { text-align: right; font-weight: 600; }
</style> </style>
</head> </head>
<body> <body>
@ -216,25 +208,9 @@ const adminDashboardHTML = `<!doctype html>
<p>Bearer ADMIN_API_TOKEN 으로 /admin/metrics 불러옵니다. 생성 지연·오류율은 프로세스 메모리 샘플입니다.</p> <p>Bearer ADMIN_API_TOKEN 으로 /admin/metrics 불러옵니다. 생성 지연·오류율은 프로세스 메모리 샘플입니다.</p>
<button onclick="load()">새로고침</button> <button onclick="load()">새로고침</button>
<div class="grid" id="cards"></div> <div class="grid" id="cards"></div>
<div class="breakdowns">
<div class="breakdown">
<h2 style="margin:0 0 8px;font-size:1rem">에스컬레이션 사유별</h2>
<table><thead><tr><th>reason</th><th style="text-align:right">count</th></tr></thead><tbody id="escByReason"></tbody></table>
</div>
<div class="breakdown">
<h2 style="margin:0 0 8px;font-size:1rem">와카뷰 발송 차단 사유별</h2>
<table><thead><tr><th>reason</th><th style="text-align:right">count</th></tr></thead><tbody id="blockedByReason"></tbody></table>
</div>
</div>
<h2 style="margin-top:28px;font-size:1rem">raw JSON</h2> <h2 style="margin-top:28px;font-size:1rem">raw JSON</h2>
<pre id="raw">loading</pre> <pre id="raw">loading</pre>
<script> <script>
function renderBreakdown(elId, obj) {
const rows = Object.entries(obj || {}).sort((a, b) => b[1] - a[1]);
document.getElementById(elId).innerHTML = rows.length
? rows.map(([k, v]) => '<tr><td>'+k+'</td><td class="n">'+v+'</td></tr>').join('')
: '<tr><td colspan="2">없음</td></tr>';
}
async function load() { async function load() {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
let token = params.get('token') || localStorage.ADMIN_API_TOKEN || ''; let token = params.get('token') || localStorage.ADMIN_API_TOKEN || '';
@ -253,8 +229,6 @@ const adminDashboardHTML = `<!doctype html>
['twin msgs', data.messages_twin_total], ['twin msgs', data.messages_twin_total],
['escalations', data.escalations_total], ['escalations', data.escalations_total],
['peer veto rate', (data.peer_veto_rate || 0).toFixed(3)], ['peer veto rate', (data.peer_veto_rate || 0).toFixed(3)],
['초안 무수정 발송률', (data.draft_unedited_rate || 0).toFixed(3) + ' (n=' + (data.draft_edited_tracked_total || 0) + ')'],
['자연스러움 긍정률', (data.naturalness_positive_rate || 0).toFixed(3) + ' (n=' + (data.naturalness_ratings_total || 0) + ')'],
['draft req', data.draft_requests], ['draft req', data.draft_requests],
['draft err rate', (data.draft_error_rate || 0).toFixed(3)], ['draft err rate', (data.draft_error_rate || 0).toFixed(3)],
['draft avg ms', (data.draft_latency_avg_ms || 0).toFixed(1)], ['draft avg ms', (data.draft_latency_avg_ms || 0).toFixed(1)],
@ -263,8 +237,6 @@ const adminDashboardHTML = `<!doctype html>
]; ];
document.getElementById('cards').innerHTML = cards.map(([k,v]) => document.getElementById('cards').innerHTML = cards.map(([k,v]) =>
'<div class="card"><div class="k">'+k+'</div><div class="v">'+v+'</div></div>').join(''); '<div class="card"><div class="k">'+k+'</div><div class="v">'+v+'</div></div>').join('');
renderBreakdown('escByReason', data.escalations_by_reason);
renderBreakdown('blockedByReason', data.twin_sends_blocked_by_reason);
} }
load(); load();
</script> </script>

View File

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

View File

@ -1,57 +0,0 @@
package main
import (
"fmt"
"time"
"gorm.io/gorm"
)
// Flood/spam detection minimum version (roadmap.md §2.7-C, PRD.md §4
// 엣지케이스: "상대가 짧은 시간에 메시지 도배 -> 스팸 감지 임계치 초과 시 응대
// 중단, 사용자에게 보고 (P1이지만 안전 관련이라 v1 최소 버전 필요)").
//
// These two constants are a conservative, clearly-labeled v1-minimum
// placeholder, NOT a PoC-validated number -- unlike the autonomy-default/
// whitelist-default questions in roadmap.md §3 ("PoC 결과가 있어야 정할 수
// 있는 것"), this is a technical safety minimum that has to exist for P0
// coverage, so it's fine to ship a placeholder and revisit with real usage
// data later rather than leaving the gate unbuilt.
const (
// floodMessageThreshold is how many incoming messages from the
// counterpart within floodWindow count as "도배".
floodMessageThreshold = 5
// floodWindow is the trailing window floodMessageThreshold is counted over.
floodWindow = 2 * time.Minute
)
// floodIncomingCount counts messages in conversationID sent by anyone other
// than ownerUserID (i.e. the counterpart's own words, not the twin's
// auto-sends nor the owner's own human-typed messages, both of which use
// SenderID == ownerUserID -- see sendMessageRequest.SenderID) within the
// trailing floodWindow. Retracted messages still count: retraction undoes an
// auto-send the twin/owner made, it says nothing about whether the peer was
// flooding.
func floodIncomingCount(db *gorm.DB, conversationID, ownerUserID uint) int64 {
var count int64
db.Model(&Message{}).
Where("conversation_id = ? AND sender_id != ? AND created_at >= ?",
conversationID, ownerUserID, time.Now().Add(-floodWindow)).
Count(&count)
return count
}
// floodDetected reports whether the counterpart has crossed
// floodMessageThreshold within floodWindow for this conversation, plus the
// count for the resulting EscalationLog reason string.
func floodDetected(db *gorm.DB, conversationID, ownerUserID uint) (bool, int64) {
count := floodIncomingCount(db, conversationID, ownerUserID)
return count > floodMessageThreshold, count
}
// floodReason formats the Korean EscalationLog copy for a flood auto-pause,
// matching the tone of the existing escalation_filter reasons.
func floodReason(count int64) string {
return fmt.Sprintf("도배 감지: 최근 %d분 동안 상대로부터 메시지 %d건 수신 (임계치 %d건) -- 자동응대 일시중단",
int(floodWindow/time.Minute), count, floodMessageThreshold)
}

View File

@ -1,204 +0,0 @@
package main
import (
"net/http"
"strconv"
"testing"
"time"
"gorm.io/gorm"
)
// seedIncomingMessages inserts n messages from a counterpart (any sender_id
// != ownerID) into conv, timestamped now, simulating a peer flooding the
// conversation. Uses db.Create directly (like the peer-veto tests above)
// rather than going through POST /messages, since that endpoint's gating
// only applies to sender_mode=twin and these are meant to be plain incoming
// human messages from the other party.
func seedIncomingMessages(t *testing.T, db *gorm.DB, conv Conversation, ownerID uint, n int) {
t.Helper()
peerID := ownerID + 1000 // any id distinct from the owner
for i := 0; i < n; i++ {
db.Create(&Message{
ConversationID: conv.ID,
SenderID: peerID,
SenderMode: SenderHuman,
Text: "spam " + strconv.Itoa(i),
})
}
}
func TestFloodDetectionBlocksTwinAutoSendAfterThresholdExceeded(t *testing.T) {
server, db := setupTestServer(t)
senderID, token := mustSignup(t, server.URL, "도배테스트")
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "ㅇㅇ"})
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
// Below threshold: the counterpart sending floodMessageThreshold messages
// exactly must NOT trip the gate yet (strictly greater-than semantics).
seedIncomingMessages(t, db, conv, senderID, floodMessageThreshold)
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "ㅇㅇ 알겠어", SenderMode: SenderTwin, Approved: true,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 at exactly the threshold (not yet exceeded), got %d", resp.StatusCode)
}
// One more incoming message tips it over the threshold.
seedIncomingMessages(t, db, conv, senderID, 1)
blocked := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "ㅇㅇ 또 왔어", SenderMode: SenderTwin, Approved: true,
})
if blocked.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 once flood threshold is exceeded, got %d", blocked.StatusCode)
}
var conv2 Conversation
if err := db.First(&conv2, conv.ID).Error; err != nil {
t.Fatalf("reload conversation: %v", err)
}
if !conv2.TwinDisabledByFlood {
t.Fatal("expected twin_disabled_by_flood to be set after flood detection")
}
var logs []EscalationLog
db.Where("conversation_id = ?", conv.ID).Find(&logs)
if len(logs) != 1 {
t.Fatalf("expected exactly one EscalationLog row for the flood block, got %d", len(logs))
}
// The gate must stay closed on a subsequent attempt without re-counting
// (also prevents duplicate EscalationLog rows piling up per attempt).
again := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "ㅇㅇ 세번째", SenderMode: SenderTwin, Approved: true,
})
if again.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 to persist once flood-blocked, got %d", again.StatusCode)
}
db.Where("conversation_id = ?", conv.ID).Find(&logs)
if len(logs) != 1 {
t.Fatalf("expected the flood EscalationLog to stay at 1 row, got %d", len(logs))
}
}
func TestFloodDetectionDoesNotBlockHumanMessages(t *testing.T) {
server, db := setupTestServer(t)
senderID, _ := mustSignup(t, server.URL, "도배사람")
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
seedIncomingMessages(t, db, conv, senderID, floodMessageThreshold+5)
// The owner's own human-authored message is never gated, flood or
// otherwise -- it's their own words.
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "괜찮아?", SenderMode: SenderHuman,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("flood detection must not block human-authored messages, got %d", resp.StatusCode)
}
}
func TestFloodDetectionOnlyCountsMessagesWithinWindow(t *testing.T) {
server, db := setupTestServer(t)
senderID, token := mustSignup(t, server.URL, "옛도배")
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
// Stale messages from outside floodWindow must not count toward the
// threshold, even though there are plenty of them.
peerID := senderID + 1000
stale := time.Now().Add(-floodWindow * 3)
for i := 0; i < floodMessageThreshold+10; i++ {
db.Create(&Message{
ConversationID: conv.ID,
SenderID: peerID,
SenderMode: SenderHuman,
Text: "old spam",
CreatedAt: stale,
})
}
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "ㅇㅇ 알겠어", SenderMode: SenderTwin, Approved: true,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("stale messages outside floodWindow must not trigger the gate, got %d", resp.StatusCode)
}
}
func TestFloodResetReenablesTwinAutoSend(t *testing.T) {
server, db := setupTestServer(t)
senderID, token := mustSignup(t, server.URL, "재개테스트")
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
seedIncomingMessages(t, db, conv, senderID, floodMessageThreshold+1)
blocked := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "ㅇㅇ", SenderMode: SenderTwin, Approved: true,
})
if blocked.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 once flooded, got %d", blocked.StatusCode)
}
// One-tap undo (AGENTS.md "every automatic action needs post-hoc
// notification + one-tap undo") -- unlike peer veto, this auto-pause
// must be reversible.
resetResp := postJSON(t, convBase+"/flood-reset", nil)
if resetResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 resetting flood block, got %d", resetResp.StatusCode)
}
var conv2 Conversation
if err := db.First(&conv2, conv.ID).Error; err != nil {
t.Fatalf("reload conversation: %v", err)
}
if conv2.TwinDisabledByFlood {
t.Fatal("expected twin_disabled_by_flood to be cleared after flood-reset")
}
// The flood messages that tripped the gate are still inside floodWindow
// right after reset, so without clearing them a resend would immediately
// re-trip the same detection (correct fail-safe behavior, but not what
// this test is checking) -- simulate the message rate having actually
// dropped, which is the case flood-reset is meant for.
db.Where("conversation_id = ?", conv.ID).Delete(&Message{})
resent := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "ㅇㅇ 다시", SenderMode: SenderTwin, Approved: true,
})
if resent.StatusCode != http.StatusOK {
t.Fatalf("expected 200 after flood-reset re-enabled auto-send, got %d", resent.StatusCode)
}
}
func TestFloodResetMissingConversation(t *testing.T) {
server, _ := setupTestServer(t)
resp := postJSON(t, server.URL+"/conversations/9999/flood-reset", nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
}

View File

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

View File

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

View File

@ -1,135 +0,0 @@
package main
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// registerGroupSummaryRoutes implements roadmap.md §2.7-A "단톡 따라잡기":
// a read marker per participant + an AI-generated catch-up summary of
// whatever arrived since that marker. Kept separate from a1_a2_routes.go
// since it's new (2026-07-31) scope, not part of the original A1/A2 surface.
func registerGroupSummaryRoutes(r *gin.Engine, db *gorm.DB, ai *AIServiceClient) {
// POST /conversations/:id/read advances the caller's read marker. Body:
// {"message_id": <highest message id the client has seen>}. Never moves
// the marker backward, so out-of-order client calls can't lose history.
r.POST("/conversations/:id/read", func(c *gin.Context) {
actor, ok := currentUser(c, db, true)
if !ok {
return
}
convID, ok := parseUintParam(c, "id")
if !ok {
return
}
if !isParticipant(db, convID, actor.ID) {
c.JSON(http.StatusForbidden, gin.H{"detail": "not a participant of this conversation"})
return
}
var req struct {
MessageID uint `json:"message_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
var participant ConversationParticipant
if err := db.Where("conversation_id = ? AND user_id = ?", convID, actor.ID).
First(&participant).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "participant record not found"})
return
}
if participant.LastReadMessageID == nil || *participant.LastReadMessageID < req.MessageID {
participant.LastReadMessageID = &req.MessageID
if err := db.Save(&participant).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"last_read_message_id": participant.LastReadMessageID})
})
// GET /conversations/:id/summary returns an AI-generated 3~5 line
// catch-up of messages since the caller's read marker, focused on
// mentions of the caller and decisions made (PRD.md §2.3-②). Read-only
// -- it never sends anything, so it carries none of the escalation/
// autonomy-level machinery that POST /messages does.
r.GET("/conversations/:id/summary", func(c *gin.Context) {
actor, ok := currentUser(c, db, true)
if !ok {
return
}
convID, ok := parseUintParam(c, "id")
if !ok {
return
}
if !isParticipant(db, convID, actor.ID) {
c.JSON(http.StatusForbidden, gin.H{"detail": "not a participant of this conversation"})
return
}
var participant ConversationParticipant
if err := db.Where("conversation_id = ? AND user_id = ?", convID, actor.ID).
First(&participant).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "participant record not found"})
return
}
q := db.Where("conversation_id = ? AND retracted = ?", convID, false)
if participant.LastReadMessageID != nil {
q = q.Where("id > ?", *participant.LastReadMessageID)
}
var unread []Message
q.Order("id asc").Find(&unread)
if len(unread) == 0 {
c.JSON(http.StatusOK, gin.H{"status": "empty", "summary": "", "unread_count": 0})
return
}
names := map[uint]string{}
contextLines := make([]string, 0, len(unread))
needsReply := false
for _, m := range unread {
var label string
if m.SenderID == actor.ID {
label = "나"
} else {
name, cached := names[m.SenderID]
if !cached {
var sender User
if err := db.First(&sender, m.SenderID).Error; err == nil {
name = sender.DisplayName
}
if name == "" {
name = "상대"
}
names[m.SenderID] = name
}
label = name
needsReply = true
}
contextLines = append(contextLines, label+": "+m.Text)
}
result, err := ai.requestSummary(summaryRequest{
MyDisplayName: actor.DisplayName,
ContextLines: contextLines,
})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": result.Status,
"summary": strings.TrimSpace(result.Summary),
"unread_count": len(unread),
"needs_reply": needsReply,
})
})
}

View File

@ -1,163 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
)
func createGroup(t *testing.T, serverURL, token string, userIDs []uint) uint {
t.Helper()
resp := postJSONAuth(t, serverURL+"/conversations", token, createConversationRequest{
UserIDs: userIDs,
IsGroup: true,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("create group: %d", resp.StatusCode)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
return uint(out["id"].(float64))
}
func TestGroupConversationBlocksTwinAutoSendRegardlessOfAutonomyLevel(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "주인")
peerID, _ := mustSignup(t, server.URL, "친구")
convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID})
// L2 with a matching whitelist rule would normally auto-send in a 1:1
// conversation (main_test.go's autonomy flow tests cover that) -- group
// conversations must block twin sends outright regardless (PRD.md
// §2.3-③, roadmap.md §2.7-A: "이 시나리오는 L0 고정, 자동 발송 없음").
setAutonomyLevel(t, server.URL, ownerID, ownerToken, AutonomyL2)
resp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", ownerToken, sendMessageRequest{
SenderID: ownerID,
Text: "ㅇㅋ 알겠음",
SenderMode: SenderTwin,
Approved: true,
})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 blocking twin send in group, got %d", resp.StatusCode)
}
// A human-authored send in the same group is unaffected.
humanResp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", ownerToken, sendMessageRequest{
SenderID: ownerID,
Text: "내가 직접 씀",
})
if humanResp.StatusCode != http.StatusOK {
t.Fatalf("expected human send to succeed, got %d", humanResp.StatusCode)
}
}
func TestGroupSummaryReflectsUnreadSinceReadMarker(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID})
convIDStr := strconv.FormatUint(uint64(convID), 10)
// Nothing read yet -- summary should be empty (no messages at all).
emptyResp, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+convIDStr+"/summary", nil)
emptyResp.Header.Set("Authorization", "Bearer "+ownerToken)
emptyOut, err := http.DefaultClient.Do(emptyResp)
if err != nil {
t.Fatalf("get summary: %v", err)
}
var emptyBody map[string]interface{}
json.NewDecoder(emptyOut.Body).Decode(&emptyBody)
if emptyBody["status"] != "empty" {
t.Fatalf("expected empty status with no messages, got %v", emptyBody)
}
// Peer sends two messages the owner hasn't read.
postJSONAuth(t, server.URL+"/conversations/"+convIDStr+"/messages", ownerToken, sendMessageRequest{
SenderID: peerID, Text: "토요일 모임 3시 어때?",
})
postJSONAuth(t, server.URL+"/conversations/"+convIDStr+"/messages", ownerToken, sendMessageRequest{
SenderID: peerID, Text: "민수야 답장 좀",
})
sumReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+convIDStr+"/summary", nil)
sumReq.Header.Set("Authorization", "Bearer "+ownerToken)
sumResp, err := http.DefaultClient.Do(sumReq)
if err != nil {
t.Fatalf("get summary: %v", err)
}
var sumBody map[string]interface{}
json.NewDecoder(sumResp.Body).Decode(&sumBody)
if sumBody["status"] != "ok" {
t.Fatalf("expected ok status, got %v", sumBody)
}
if int(sumBody["unread_count"].(float64)) != 2 {
t.Fatalf("expected 2 unread, got %v", sumBody["unread_count"])
}
if sumBody["needs_reply"] != true {
t.Fatalf("expected needs_reply true, got %v", sumBody["needs_reply"])
}
summaryText := sumBody["summary"].(string)
if !containsAll(summaryText, "토요일 모임 3시 어때", "민수야 답장 좀") {
t.Fatalf("summary missing unread content: %v", summaryText)
}
// List conversations should report the same unread count.
listReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations", nil)
listReq.Header.Set("Authorization", "Bearer "+ownerToken)
listResp, err := http.DefaultClient.Do(listReq)
if err != nil {
t.Fatalf("list conversations: %v", err)
}
var listBody map[string]interface{}
json.NewDecoder(listResp.Body).Decode(&listBody)
convs := listBody["conversations"].([]interface{})
found := false
for _, raw := range convs {
c := raw.(map[string]interface{})
if uint(c["id"].(float64)) == convID {
found = true
if int(c["unread_count"].(float64)) != 2 {
t.Fatalf("expected list unread_count 2, got %v", c["unread_count"])
}
}
}
if !found {
t.Fatalf("conversation %d not found in list", convID)
}
// Mark read up to the latest message, then re-fetch: should be empty
// again and the list's unread_count should drop to 0.
var msgs map[string]interface{}
msgsReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+convIDStr+"/messages", nil)
msgsReq.Header.Set("Authorization", "Bearer "+ownerToken)
msgsResp, _ := http.DefaultClient.Do(msgsReq)
json.NewDecoder(msgsResp.Body).Decode(&msgs)
msgList := msgs["messages"].([]interface{})
lastID := uint(msgList[len(msgList)-1].(map[string]interface{})["id"].(float64))
readResp := postJSONAuth(t, server.URL+"/conversations/"+convIDStr+"/read", ownerToken, map[string]interface{}{"message_id": lastID})
if readResp.StatusCode != http.StatusOK {
t.Fatalf("mark read: %d", readResp.StatusCode)
}
afterReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+convIDStr+"/summary", nil)
afterReq.Header.Set("Authorization", "Bearer "+ownerToken)
afterResp, _ := http.DefaultClient.Do(afterReq)
var afterBody map[string]interface{}
json.NewDecoder(afterResp.Body).Decode(&afterBody)
if afterBody["status"] != "empty" {
t.Fatalf("expected empty summary after marking read, got %v", afterBody)
}
}
func containsAll(haystack string, needles ...string) bool {
for _, n := range needles {
if !strings.Contains(haystack, n) {
return false
}
}
return true
}

View File

@ -31,27 +31,10 @@ type sendMessageRequest struct {
// L2 draft outside the whitelist (PRD.md §2.2). Ignored for // L2 draft outside the whitelist (PRD.md §2.2). Ignored for
// human-authored messages. // human-authored messages.
Approved bool `json:"approved"` Approved bool `json:"approved"`
// OriginalDraftText is the AI draft's text before any edits, sent by the
// client alongside the (possibly edited) final Text so the server can
// diff them into Message.DraftEdited (PRD.md §5 L1 approval-rate proxy,
// deploy-checklist.md N4-12). Optional -- omitted/empty means "not
// derived from a draft, or an older client", and DraftEdited stays nil
// rather than being guessed. Only consulted for SenderMode == twin.
OriginalDraftText string `json:"original_draft_text"`
}
// messageFeedbackRequest is the explicit naturalness signal (vision.md
// metric, PRD.md §5, deploy-checklist.md N4-12) — "이 답장 나답아요?" one-tap
// thumbs up/down on a twin-authored message.
type messageFeedbackRequest struct {
Natural bool `json:"natural"`
} }
type updateTwinSettingsRequest struct { type updateTwinSettingsRequest struct {
AutonomyLevel AutonomyLevel `json:"autonomy_level" binding:"required"` AutonomyLevel AutonomyLevel `json:"autonomy_level" binding:"required"`
// RelationshipTier is optional so existing autonomy-only PATCH calls
// keep working unchanged (roadmap.md §2.7-B). Nil = leave it as-is.
RelationshipTier *RelationshipTier `json:"relationship_tier"`
} }
type createWhitelistRuleRequest struct { type createWhitelistRuleRequest struct {
@ -101,17 +84,15 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
registerBRoutes(r, db) registerBRoutes(r, db)
registerInviteOpsRoutes(r, db) registerInviteOpsRoutes(r, db)
registerDemoRoutes(r) registerDemoRoutes(r)
registerGroupSummaryRoutes(r, db, ai)
r.GET("/admin/metrics", func(c *gin.Context) { r.GET("/admin/metrics", func(c *gin.Context) {
if !requireAdmin(c) { if !requireAdmin(c) {
return return
} }
// v1-minimal (roadmap.md §2.6): counts honestly derivable from the // v1-minimal (roadmap.md §2.6): only counts honestly derivable from
// current schema, plus process-local draft/escalation timings kept // the current schema. Draft-generation latency and AI-service error
// in runtimeMetrics (see metrics.go) -- the latter reset on restart // rate need a request-timing/logging layer that doesn't exist yet --
// and are not a substitute for a real time-series DB, but are real // not fabricated here, left for that future work.
// numbers, not placeholders.
var usersTotal, humanMessages, twinMessages, escalationsTotal int64 var usersTotal, humanMessages, twinMessages, escalationsTotal int64
var conversationsTotal, conversationsVetoed, invitesMinted, invitesUsed int64 var conversationsTotal, conversationsVetoed, invitesMinted, invitesUsed int64
db.Model(&User{}).Count(&usersTotal) db.Model(&User{}).Count(&usersTotal)
@ -139,34 +120,6 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
peerVetoRate = float64(conversationsVetoed) / float64(conversationsTotal) peerVetoRate = float64(conversationsVetoed) / float64(conversationsTotal)
} }
// L1 approval-rate proxy (PRD.md §5 "초안을 수정 없이 그대로 발송한
// 비율", vision.md 자연스러움 지표, deploy-checklist.md N4-12):
// draft_edited is only set (non-nil) for twin-mode messages sent
// through the draft-approval flow with an original draft to diff
// against -- messages where it's nil (human-authored, or no draft
// text sent) are excluded from both counts so they can't dilute
// the rate either way. Zero-tracked-messages guarded the same way
// peer_veto_rate guards zero conversations above.
var draftTrackedTotal, draftEditedTrue int64
db.Model(&Message{}).Where("sender_mode = ? AND draft_edited IS NOT NULL", SenderTwin).Count(&draftTrackedTotal)
db.Model(&Message{}).Where("sender_mode = ? AND draft_edited = ?", SenderTwin, true).Count(&draftEditedTrue)
var draftUneditedRate float64
if draftTrackedTotal > 0 {
draftUneditedRate = float64(draftTrackedTotal-draftEditedTrue) / float64(draftTrackedTotal)
}
// Explicit naturalness signal ("이 답장 나답아요?" 원탭, vision.md 지표,
// deploy-checklist.md N4-12): same nil-excluded-from-denominator
// shape as draft_unedited_rate above -- unrated messages don't
// count as either positive or negative.
var naturalnessTrackedTotal, naturalnessPositiveTrue int64
db.Model(&Message{}).Where("sender_mode = ? AND naturalness_rating IS NOT NULL", SenderTwin).Count(&naturalnessTrackedTotal)
db.Model(&Message{}).Where("sender_mode = ? AND naturalness_rating = ?", SenderTwin, true).Count(&naturalnessPositiveTrue)
var naturalnessPositiveRate float64
if naturalnessTrackedTotal > 0 {
naturalnessPositiveRate = float64(naturalnessPositiveTrue) / float64(naturalnessTrackedTotal)
}
rt := runtimeMetrics.snapshot() rt := runtimeMetrics.snapshot()
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"users_total": usersTotal, "users_total": usersTotal,
@ -181,31 +134,22 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
// vision.md doesn't pin down the exact denominator, so treat // vision.md doesn't pin down the exact denominator, so treat
// this as a first approximation, not the final definition. // this as a first approximation, not the final definition.
"peer_veto_rate": peerVetoRate, "peer_veto_rate": peerVetoRate,
// L1 approval-rate proxy / naturalness instrumentation (PRD.md §5,
// vision.md, deploy-checklist.md N4-12). This is capture
// infrastructure, not a PoC conclusion -- see docs/roadmap.md and
// docs/deploy-checklist.md N4-12 for that distinction.
"draft_edited_tracked_total": draftTrackedTotal,
"draft_unedited_rate": draftUneditedRate,
"naturalness_ratings_total": naturalnessTrackedTotal,
"naturalness_positive_rate": naturalnessPositiveRate,
// Process-local draft/AI timings (roadmap B). Reset on restart. // Process-local draft/AI timings (roadmap B). Reset on restart.
"draft_requests": rt.DraftRequests, "draft_requests": rt.DraftRequests,
"draft_errors": rt.DraftErrors, "draft_errors": rt.DraftErrors,
"draft_error_rate": rt.DraftErrorRate, "draft_error_rate": rt.DraftErrorRate,
"draft_latency_avg_ms": rt.DraftLatencyAvgMs, "draft_latency_avg_ms": rt.DraftLatencyAvgMs,
"draft_latency_max_ms": rt.DraftLatencyMaxMs, "draft_latency_max_ms": rt.DraftLatencyMaxMs,
"draft_latency_samples": rt.DraftLatencySamples, "draft_latency_samples": rt.DraftLatencySamples,
"escalate_checks": rt.EscalateChecks, "escalate_checks": rt.EscalateChecks,
"escalate_errors": rt.EscalateErrors, "escalate_errors": rt.EscalateErrors,
"escalate_error_rate": rt.EscalateErrorRate, "escalate_error_rate": rt.EscalateErrorRate,
"twin_sends_blocked": rt.TwinSendsBlocked, "twin_sends_blocked": rt.TwinSendsBlocked,
"twin_sends_blocked_by_reason": rt.TwinSendsBlockedByReason, "push_attempts": rt.PushAttempts,
"push_attempts": rt.PushAttempts, "push_skipped": rt.PushSkipped,
"push_skipped": rt.PushSkipped, "push_delivered": rt.PushDelivered,
"push_delivered": rt.PushDelivered, "invites_minted": invitesMinted,
"invites_minted": invitesMinted, "invites_used": invitesUsed,
"invites_used": invitesUsed,
}) })
}) })
@ -257,7 +201,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return return
} }
db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0, RelationshipTier: RelationshipFormal}) db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0})
if !demo { if !demo {
now := time.Now() now := time.Now()
@ -309,70 +253,24 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
// words and are never gated. On any doubt (AI service unreachable // words and are never gated. On any doubt (AI service unreachable
// or erroring) we fail closed and block the send. Peer veto is // or erroring) we fail closed and block the send. Peer veto is
// checked first (it's a total kill switch for this conversation, // checked first (it's a total kill switch for this conversation,
// independent of content), then the group-chat block, then flood // independent of content), then escalation, then autonomy level --
// detection, then escalation, then autonomy level -- none of the // none of the later checks can override an earlier block.
// later checks can override an earlier block.
if req.SenderMode == SenderTwin { if req.SenderMode == SenderTwin {
if conversation.TwinDisabledByPeer { if conversation.TwinDisabledByPeer {
runtimeMetrics.recordTwinBlocked("peer_veto") runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 와카뷰를 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 와카뷰를 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"})
return return
} }
// 단톡 따라잡기(PRD.md §2.3-③, roadmap.md §2.7-A): 이 시나리오는
// L0 고정, 자동 발송 없음 -- 사용자의 전역 자율성 레벨(L1/L2)과
// 무관하게 그룹 대화에서는 와카뷰 발송 자체를 막는다. 초안은
// 항상 사람이 검토해서 직접 보낸다.
if conversation.IsGroup {
runtimeMetrics.recordTwinBlocked("group_conversation")
c.JSON(http.StatusForbidden, gin.H{"detail": "그룹 대화에서는 와카뷰 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"})
return
}
// 스팸/도배 감지 최소 버전(PRD.md §4, roadmap.md §2.7-C): 이미
// 도배로 중단된 대화방이면 재검사 없이 바로 막는다 (중복
// EscalationLog 방지 + 매 시도마다 카운트 쿼리를 다시 돌리지
// 않기 위함). 아직 중단되지 않았다면 이번 전송을 계기로 트리거를
// 검사한다 -- 이 게이트는 에스컬레이션 하드게이트와 같은 우회
// 불가 지점에 있어, 어떤 자율성 레벨/화이트리스트로도 건너뛸 수
// 없다.
if conversation.TwinDisabledByFlood {
runtimeMetrics.recordTwinBlocked("flood_blocked")
c.JSON(http.StatusForbidden, gin.H{"detail": "도배 감지로 이 대화방의 와카뷰 자동 발송이 일시중단되어 있습니다 -- 사후 알림에서 확인 후 재개할 수 있습니다"})
return
}
if exceeded, count := floodDetected(db, convID, req.SenderID); exceeded {
conversation.TwinDisabledByFlood = true
db.Save(&conversation)
runtimeMetrics.recordTwinBlocked("flood_detected")
reason := floodReason(count)
db.Create(&EscalationLog{
UserID: req.SenderID,
ConversationID: convID,
Reason: reason,
MessageSnippet: req.Text,
})
// Automatic action -> post-hoc notification (AGENTS.md
// absolute safety invariants), same as the escalation gate
// below. Best-effort push (no-op without FCM_SERVER_KEY /
// real tokens).
_, _, _ = notifyUser(db, req.SenderID, "와카뷰 자동응대 일시중단", reason, map[string]string{
"type": "flood",
"conversation_id": strconv.FormatUint(uint64(convID), 10),
})
c.JSON(http.StatusForbidden, gin.H{"detail": "도배 감지로 자동 발송이 일시중단되었습니다", "reason": reason})
return
}
result, err := ai.checkEscalation(req.Text) result, err := ai.checkEscalation(req.Text)
runtimeMetrics.recordEscalate(err) runtimeMetrics.recordEscalate(err)
if err != nil { if err != nil {
runtimeMetrics.recordTwinBlocked("escalate_check_error") runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusBadGateway, gin.H{"detail": "escalation gate unavailable, twin send blocked: " + err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"detail": "escalation gate unavailable, twin send blocked: " + err.Error()})
return return
} }
if result.Escalate { if result.Escalate {
runtimeMetrics.recordTwinBlocked("escalated") runtimeMetrics.recordTwinBlocked()
db.Create(&EscalationLog{ db.Create(&EscalationLog{
UserID: req.SenderID, UserID: req.SenderID,
ConversationID: convID, ConversationID: convID,
@ -390,54 +288,42 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
} }
// Autonomy gate (PRD.md §2.1/§2.2, tech-design.md §3): missing // Autonomy gate (PRD.md §2.1/§2.2, tech-design.md §3): missing
// settings fail closed to L0, the documented default. roadmap.md // settings fail closed to L0, the documented default.
// §2.7-D: the level itself can be overridden per-contact (1:1 level := AutonomyL0
// only -- group conversations already returned above and never var settings TwinSettings
// reach this point), so resolve it the same way relationship if err := db.Where("user_id = ?", req.SenderID).First(&settings).Error; err == nil {
// tier is resolved instead of reading only the global default. level = settings.AutonomyLevel
level := resolveAutonomyLevel(db, req.SenderID, convID) }
switch level { switch level {
case AutonomyL0: case AutonomyL0:
runtimeMetrics.recordTwinBlocked("autonomy_l0") runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 와카뷰 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"}) c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 와카뷰 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"})
return return
case AutonomyL1: case AutonomyL1:
if !req.Approved { if !req.Approved {
runtimeMetrics.recordTwinBlocked("autonomy_l1_unapproved") runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "L1은 발송 전 사용자 승인이 필요합니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "L1은 발송 전 사용자 승인이 필요합니다"})
return return
} }
case AutonomyL2: case AutonomyL2:
if !req.Approved && !whitelistMatches(db, req.SenderID, convID, req.Text) { if !req.Approved && !whitelistMatches(db, req.SenderID, convID, req.Text) {
runtimeMetrics.recordTwinBlocked("autonomy_l2_no_whitelist_match") runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "화이트리스트에 없는 주제는 L1과 동일하게 사용자 승인이 필요합니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "화이트리스트에 없는 주제는 L1과 동일하게 사용자 승인이 필요합니다"})
return return
} }
default: default:
runtimeMetrics.recordTwinBlocked("autonomy_unknown_level") runtimeMetrics.recordTwinBlocked()
c.JSON(http.StatusForbidden, gin.H{"detail": "알 수 없는 자율성 레벨이라 발송을 차단합니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "알 수 없는 자율성 레벨이라 발송을 차단합니다"})
return return
} }
} }
// Implicit L1-approval-rate signal (PRD.md §5, deploy-checklist.md
// N4-12): only meaningful for twin-mode sends where the client told
// us what the original draft said. Absent/empty original text
// (human messages, or a client that isn't draft-derived) leaves
// DraftEdited nil rather than guessing true/false.
var draftEdited *bool
if req.SenderMode == SenderTwin && req.OriginalDraftText != "" {
edited := req.Text != req.OriginalDraftText
draftEdited = &edited
}
message := Message{ message := Message{
ConversationID: convID, ConversationID: convID,
SenderID: req.SenderID, SenderID: req.SenderID,
SenderMode: req.SenderMode, SenderMode: req.SenderMode,
Text: req.Text, Text: req.Text,
DraftEdited: draftEdited,
} }
if err := db.Create(&message).Error; err != nil { if err := db.Create(&message).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
@ -494,46 +380,6 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
c.JSON(http.StatusOK, gin.H{"id": message.ID, "retracted": true}) c.JSON(http.StatusOK, gin.H{"id": message.ID, "retracted": true})
}) })
// "이 답장 나답아요?" 자연스러움 피드백(vision.md 지표, PRD.md §5,
// deploy-checklist.md N4-12): 트윈이 실제로 보낸 메시지 위에 붙는 가벼운
// 원탭 피드백 -- 사람이 직접 쓴 메시지는 이 지표의 대상이 아니라 400.
// 재제출은 덮어쓰기로 처리한다(409로 막지 않음): 클라이언트가 이미 한
// 번 탭한 메시지는 다시 UI를 보여주지 않아 재제출 자체가 드물고, 네트워크
// 재시도 등으로 같은 요청이 두 번 가더라도 "평가를 덮어쓴다"는 동작이
// 사용자에게 해롭지 않기 때문 -- retract의 409(한 번 되돌린 걸 다시
// 되돌릴 수 없음, 되돌리기는 상태를 한 방향으로만 바꿈)와는 성격이 다름.
r.POST("/messages/:id/feedback", func(c *gin.Context) {
msgID, ok := parseUintParam(c, "id")
if !ok {
return
}
var message Message
if err := db.First(&message, msgID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "message not found"})
return
}
if message.SenderMode != SenderTwin {
c.JSON(http.StatusBadRequest, gin.H{"detail": "only twin-authored messages can be rated for naturalness"})
return
}
var req messageFeedbackRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
natural := req.Natural
message.NaturalnessRating = &natural
if err := db.Save(&message).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, messageJSON(message))
})
r.POST("/conversations/:id/draft", func(c *gin.Context) { r.POST("/conversations/:id/draft", func(c *gin.Context) {
convID, ok := parseUintParam(c, "id") convID, ok := parseUintParam(c, "id")
if !ok { if !ok {
@ -556,28 +402,12 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
return return
} }
// 관계별 페르소나(roadmap.md §2.7-B): optional auth -- this endpoint
// has never required a session, so callers without one (existing
// tests, or a future anonymous path) still work exactly as before,
// just without tier resolution (falls back to "formal").
tier := RelationshipFormal
note := ""
if actor, ok := currentUser(c, db, false); ok {
tier = resolveRelationshipTier(db, actor.ID, convID)
// 관계 메모 실제 반영(roadmap.md §2.7-E): unlike tier/autonomy
// there is no global default note, so group conversations (or no
// matching Contact) just resolve to "" here.
note = resolveRelationshipNote(db, actor.ID, convID)
}
started := time.Now() started := time.Now()
result, err := ai.requestDraft(draftRequest{ result, err := ai.requestDraft(draftRequest{
ContextLines: req.ContextLines, ContextLines: req.ContextLines,
RelationshipTier: string(tier), StyleExamples: req.StyleExamples,
RelationshipNote: note, History: req.History,
StyleExamples: req.StyleExamples, K: req.K,
History: req.History,
K: req.K,
}) })
runtimeMetrics.recordDraft(time.Since(started), err) runtimeMetrics.recordDraft(time.Since(started), err)
if err != nil { if err != nil {
@ -613,32 +443,6 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
c.JSON(http.StatusOK, gin.H{"conversation_id": convID, "twin_disabled_by_peer": true}) c.JSON(http.StatusOK, gin.H{"conversation_id": convID, "twin_disabled_by_peer": true})
}) })
// 도배 감지로 자동 발송이 일시중단된 대화방을 다시 켠다 (roadmap.md
// §2.7-C, AGENTS.md "every automatic action needs post-hoc notification +
// one-tap undo"). 거부권(veto)과 달리 이 중단은 사람의 결정이 아니라
// 시스템이 자동으로 취한 조치라서, 되돌리기 경로가 반드시 있어야 한다 --
// 거부권처럼 영구적으로 막아두지 않는다.
r.POST("/conversations/:id/flood-reset", func(c *gin.Context) {
convID, ok := parseUintParam(c, "id")
if !ok {
return
}
var conversation Conversation
if err := db.First(&conversation, convID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "conversation not found"})
return
}
conversation.TwinDisabledByFlood = false
if err := db.Save(&conversation).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"conversation_id": convID, "twin_disabled_by_flood": false})
})
r.PATCH("/users/:id/twin-settings", func(c *gin.Context) { r.PATCH("/users/:id/twin-settings", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id") userID, ok := parseUintParam(c, "id")
if !ok { if !ok {
@ -653,14 +457,10 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return return
} }
if !validAutonomyLevel(req.AutonomyLevel) { if req.AutonomyLevel != AutonomyL0 && req.AutonomyLevel != AutonomyL1 && req.AutonomyLevel != AutonomyL2 {
c.JSON(http.StatusBadRequest, gin.H{"detail": "autonomy_level must be one of L0, L1, L2"}) c.JSON(http.StatusBadRequest, gin.H{"detail": "autonomy_level must be one of L0, L1, L2"})
return return
} }
if req.RelationshipTier != nil && !validRelationshipTier(*req.RelationshipTier) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "relationship_tier must be one of close, formal"})
return
}
var settings TwinSettings var settings TwinSettings
if err := db.Where("user_id = ?", userID).First(&settings).Error; err != nil { if err := db.Where("user_id = ?", userID).First(&settings).Error; err != nil {
@ -668,19 +468,12 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
return return
} }
settings.AutonomyLevel = req.AutonomyLevel settings.AutonomyLevel = req.AutonomyLevel
if req.RelationshipTier != nil {
settings.RelationshipTier = *req.RelationshipTier
}
if err := db.Save(&settings).Error; err != nil { if err := db.Save(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{"user_id": userID, "autonomy_level": settings.AutonomyLevel})
"user_id": userID,
"autonomy_level": settings.AutonomyLevel,
"relationship_tier": settings.RelationshipTier,
})
}) })
r.POST("/users/:id/whitelist-rules", func(c *gin.Context) { r.POST("/users/:id/whitelist-rules", func(c *gin.Context) {

View File

@ -31,14 +31,7 @@ func mockAIService(t *testing.T) *httptest.Server {
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
// Echo relationship_tier and relationship_note into the response json.NewEncoder(w).Encode(draftResponse{Status: "ok", Text: "mock draft for: " + strings.Join(req.ContextLines, " | ")})
// text (roadmap.md §2.7-B, §2.7-E) so tests can assert what
// core-backend resolved and forwarded, without needing a real
// ai-service.
json.NewEncoder(w).Encode(draftResponse{
Status: "ok",
Text: "mock draft [tier=" + req.RelationshipTier + "] [note=" + req.RelationshipNote + "] for: " + strings.Join(req.ContextLines, " | "),
})
case "/escalate/check": case "/escalate/check":
// Mirrors escalation_filter.py's rule set closely enough to // Mirrors escalation_filter.py's rule set closely enough to
// exercise core-backend's gating logic; the rules themselves // exercise core-backend's gating logic; the rules themselves
@ -57,17 +50,6 @@ func mockAIService(t *testing.T) *httptest.Server {
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out) json.NewEncoder(w).Encode(out)
case "/summarize":
var req summaryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(summaryResponse{
Status: "ok",
Summary: "mock summary for " + req.MyDisplayName + ": " + strings.Join(req.ContextLines, " | "),
})
default: default:
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
} }
@ -658,11 +640,6 @@ func TestVetoMissingConversation(t *testing.T) {
} }
func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) { func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) {
// runtimeMetrics is a package-level in-process counter shared across the
// whole test binary (not reset per-test like the per-test sqlite db), so
// tests that assert on its exact totals must reset it first to avoid
// picking up counts left behind by earlier tests in this file.
runtimeMetrics = &RuntimeMetrics{}
server, db := setupTestServer(t) server, db := setupTestServer(t)
senderID, token := mustSignup(t, server.URL, "메트릭") senderID, token := mustSignup(t, server.URL, "메트릭")
@ -716,67 +693,6 @@ func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) {
if out["invites_minted"].(float64) < 1 || out["invites_used"].(float64) < 1 { if out["invites_minted"].(float64) < 1 || out["invites_used"].(float64) < 1 {
t.Fatalf("expected at least 1 minted/used invite, got %v", out) t.Fatalf("expected at least 1 minted/used invite, got %v", out)
} }
// The "계좌번호 알려줄게" twin send above was blocked by the escalation
// gate, not by veto/flood/autonomy -- confirm it landed under the right
// by-reason key (roadmap.md monitoring gap: distinguish *why* a
// twin-authored send was blocked, not just a raw total).
blockedByReason := out["twin_sends_blocked_by_reason"].(map[string]interface{})
if blockedByReason["escalated"].(float64) != 1 {
t.Fatalf("expected 1 escalated block, got %v", blockedByReason)
}
if out["twin_sends_blocked"].(float64) != 1 {
t.Fatalf("expected top-line twin_sends_blocked to match the sum of by-reason counts, got %v", out["twin_sends_blocked"])
}
}
// TestAdminMetricsBlockedByReasonAttributesDistinctCauses exercises two
// different twin-send block paths (peer veto, autonomy L0) through the real
// HTTP handler and checks /admin/metrics attributes each to its own reason
// key instead of collapsing them into one undifferentiated total.
func TestAdminMetricsBlockedByReasonAttributesDistinctCauses(t *testing.T) {
runtimeMetrics = &RuntimeMetrics{} // see comment in TestAdminMetricsCountsMessagesEscalationsAndVeto
server, db := setupTestServer(t)
senderID, _ := mustSignup(t, server.URL, "차단사유") // defaults to AutonomyL0
vetoedConv := Conversation{IsGroup: false}
if err := db.Create(&vetoedConv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
vetoedBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(vetoedConv.ID), 10)
postJSON(t, vetoedBase+"/veto", nil)
if resp := postJSON(t, vetoedBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "안녕", SenderMode: SenderTwin}); resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected peer-veto block, got %d", resp.StatusCode)
}
l0Conv := Conversation{IsGroup: false}
if err := db.Create(&l0Conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
l0Base := server.URL + "/conversations/" + strconv.FormatUint(uint64(l0Conv.ID), 10)
if resp := postJSON(t, l0Base+"/messages", sendMessageRequest{SenderID: senderID, Text: "안녕", SenderMode: SenderTwin}); resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected L0 autonomy block, got %d", resp.StatusCode)
}
mreq, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil)
mreq.Header.Set("Authorization", "Bearer test-admin-token")
resp, err := http.DefaultClient.Do(mreq)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
blockedByReason := out["twin_sends_blocked_by_reason"].(map[string]interface{})
if blockedByReason["peer_veto"].(float64) != 1 {
t.Fatalf(`expected twin_sends_blocked_by_reason["peer_veto"] == 1, got %v`, blockedByReason)
}
if blockedByReason["autonomy_l0"].(float64) != 1 {
t.Fatalf(`expected twin_sends_blocked_by_reason["autonomy_l0"] == 1, got %v`, blockedByReason)
}
if out["twin_sends_blocked"].(float64) != 2 {
t.Fatalf("expected top-line twin_sends_blocked == 2, got %v", out["twin_sends_blocked"])
}
} }
func TestWhitelistRuleCRUD(t *testing.T) { func TestWhitelistRuleCRUD(t *testing.T) {
@ -937,304 +853,6 @@ func TestRetractMissingMessage(t *testing.T) {
} }
} }
// --- Draft-edited implicit signal (PRD.md §5, deploy-checklist.md N4-12) ---
func TestDraftEditedTrueWhenSentTextDiffersFromOriginalDraft(t *testing.T) {
server, db := setupTestServer(t)
senderID, token := mustSignup(t, server.URL, "수정함")
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "ㅇㅇ 좋아 이따 보자", SenderMode: SenderTwin, Approved: true,
OriginalDraftText: "ㅇㅇ 좋아",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
msgID := uint(out["id"].(float64))
var message Message
db.First(&message, msgID)
if message.DraftEdited == nil || *message.DraftEdited != true {
t.Fatalf("expected DraftEdited=true, got %v", message.DraftEdited)
}
}
func TestDraftEditedFalseWhenSentTextMatchesOriginalDraft(t *testing.T) {
server, db := setupTestServer(t)
senderID, token := mustSignup(t, server.URL, "그대로")
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "ㅇㅇ 좋아", SenderMode: SenderTwin, Approved: true,
OriginalDraftText: "ㅇㅇ 좋아",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
msgID := uint(out["id"].(float64))
var message Message
db.First(&message, msgID)
if message.DraftEdited == nil || *message.DraftEdited != false {
t.Fatalf("expected DraftEdited=false, got %v", message.DraftEdited)
}
}
func TestDraftEditedNilWhenNoOriginalDraftTextSent(t *testing.T) {
server, db := setupTestServer(t)
senderID, token := mustSignup(t, server.URL, "구버전클라")
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
// No OriginalDraftText field at all (e.g. an older client) -- must not
// be guessed as edited or unedited, stays nil.
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "ㅇㅇ 알겠음", SenderMode: SenderTwin, Approved: true,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
msgID := uint(out["id"].(float64))
if _, present := out["draft_edited"]; !present || out["draft_edited"] != nil {
t.Fatalf("expected draft_edited to be null in response, got %v", out["draft_edited"])
}
var message Message
db.First(&message, msgID)
if message.DraftEdited != nil {
t.Fatalf("expected DraftEdited nil, got %v", *message.DraftEdited)
}
}
func TestDraftEditedNotSetForHumanMessages(t *testing.T) {
server, db := setupTestServer(t)
senderID, _ := mustSignup(t, server.URL, "사람메시지")
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
// Even if a caller weirdly sent original_draft_text on a human message,
// it must be ignored -- DraftEdited is a twin-only signal.
resp := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "안녕", SenderMode: SenderHuman,
OriginalDraftText: "다른 텍스트",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
msgID := uint(out["id"].(float64))
var message Message
db.First(&message, msgID)
if message.DraftEdited != nil {
t.Fatalf("expected DraftEdited nil for a human message, got %v", *message.DraftEdited)
}
}
// --- Explicit naturalness feedback ("이 답장 나답아요?", vision.md, N4-12) ---
func TestMessageFeedbackOnTwinMessageReflectedOnRefetch(t *testing.T) {
server, db := setupTestServer(t)
senderID, token := mustSignup(t, server.URL, "피드백")
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
if err := db.Create(&ConversationParticipant{ConversationID: conv.ID, UserID: senderID}).Error; err != nil {
t.Fatalf("create participant: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
sendResp := postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "ㅇㅇ 좋아", SenderMode: SenderTwin, Approved: true,
})
var sent map[string]interface{}
json.NewDecoder(sendResp.Body).Decode(&sent)
msgID := uint(sent["id"].(float64))
feedbackResp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/feedback", messageFeedbackRequest{Natural: true})
if feedbackResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 submitting feedback, got %d", feedbackResp.StatusCode)
}
// Re-fetch via the conversation history endpoint (authenticated) and
// confirm the rating stuck.
req, _ := http.NewRequest(http.MethodGet, convBase+"/messages", nil)
req.Header.Set("Authorization", "Bearer "+token)
listResp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("list messages: %v", err)
}
var listOut map[string]interface{}
json.NewDecoder(listResp.Body).Decode(&listOut)
messages := listOut["messages"].([]interface{})
var found map[string]interface{}
for _, m := range messages {
mm := m.(map[string]interface{})
if uint(mm["id"].(float64)) == msgID {
found = mm
}
}
if found == nil {
t.Fatalf("sent message not found in refetch: %v", messages)
}
if found["naturalness_rating"] != true {
t.Fatalf("expected naturalness_rating=true on refetch, got %v", found["naturalness_rating"])
}
// Resubmission overwrites rather than 409ing (documented choice: a
// bounded rating change isn't unsafe the way re-retracting would be).
overwrite := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/feedback", messageFeedbackRequest{Natural: false})
if overwrite.StatusCode != http.StatusOK {
t.Fatalf("expected 200 overwriting feedback, got %d", overwrite.StatusCode)
}
var overwriteOut map[string]interface{}
json.NewDecoder(overwrite.Body).Decode(&overwriteOut)
if overwriteOut["naturalness_rating"] != false {
t.Fatalf("expected naturalness_rating=false after overwrite, got %v", overwriteOut["naturalness_rating"])
}
}
func TestMessageFeedbackRejectsHumanMessage(t *testing.T) {
server, db := setupTestServer(t)
senderID, _ := mustSignup(t, server.URL, "사람피드백")
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
sendResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
SenderID: senderID, Text: "안녕", SenderMode: SenderHuman,
})
var sent map[string]interface{}
json.NewDecoder(sendResp.Body).Decode(&sent)
msgID := uint(sent["id"].(float64))
resp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/feedback", messageFeedbackRequest{Natural: true})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 rating a human message, got %d", resp.StatusCode)
}
}
func TestMessageFeedbackMissingMessage(t *testing.T) {
server, _ := setupTestServer(t)
resp := postJSON(t, server.URL+"/messages/9999/feedback", messageFeedbackRequest{Natural: true})
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
}
// --- /admin/metrics rate calculations for the two signals above ---
func TestAdminMetricsDraftUneditedAndNaturalnessRates(t *testing.T) {
runtimeMetrics = &RuntimeMetrics{} // see comment in TestAdminMetricsCountsMessagesEscalationsAndVeto
server, db := setupTestServer(t)
senderID, token := mustSignup(t, server.URL, "지표계산")
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10)
// 2 edited + 3 unedited twin sends -> draft_unedited_rate should be
// 3/5 = 0.6. A plain human message and a twin send with no
// original_draft_text must not affect the denominator either way.
postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "인간이 직접", SenderMode: SenderHuman})
postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "초안없음", SenderMode: SenderTwin, Approved: true})
for i := 0; i < 2; i++ {
postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "수정된 문구", SenderMode: SenderTwin, Approved: true,
OriginalDraftText: "원래 초안",
})
}
for i := 0; i < 3; i++ {
postJSON(t, convBase+"/messages", sendMessageRequest{
SenderID: senderID, Text: "그대로", SenderMode: SenderTwin, Approved: true,
OriginalDraftText: "그대로",
})
}
mreq, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil)
mreq.Header.Set("Authorization", "Bearer test-admin-token")
resp, err := http.DefaultClient.Do(mreq)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
if out["draft_edited_tracked_total"].(float64) != 5 {
t.Fatalf("expected 5 draft-tracked messages, got %v", out["draft_edited_tracked_total"])
}
if got := out["draft_unedited_rate"].(float64); got != 0.6 {
t.Fatalf("expected draft_unedited_rate 0.6, got %v", got)
}
// No naturalness ratings submitted yet -- must not divide by zero.
if out["naturalness_ratings_total"].(float64) != 0 {
t.Fatalf("expected 0 naturalness ratings, got %v", out["naturalness_ratings_total"])
}
if out["naturalness_positive_rate"].(float64) != 0 {
t.Fatalf("expected naturalness_positive_rate 0 (zero-guarded), got %v", out["naturalness_positive_rate"])
}
// Rate a couple of the twin messages we just sent and check the rate
// updates too (1 positive out of 2 rated -> 0.5).
var twinMessages []Message
db.Where("sender_mode = ?", SenderTwin).Order("id asc").Find(&twinMessages)
if len(twinMessages) < 2 {
t.Fatalf("expected at least 2 twin messages, got %d", len(twinMessages))
}
postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(twinMessages[0].ID), 10)+"/feedback", messageFeedbackRequest{Natural: true})
postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(twinMessages[1].ID), 10)+"/feedback", messageFeedbackRequest{Natural: false})
resp2, err := http.DefaultClient.Do(mreq)
if err != nil {
t.Fatalf("get metrics again: %v", err)
}
var out2 map[string]interface{}
json.NewDecoder(resp2.Body).Decode(&out2)
if out2["naturalness_ratings_total"].(float64) != 2 {
t.Fatalf("expected 2 naturalness ratings, got %v", out2["naturalness_ratings_total"])
}
if got := out2["naturalness_positive_rate"].(float64); got != 0.5 {
t.Fatalf("expected naturalness_positive_rate 0.5, got %v", got)
}
}
func TestDraftMissingConversation(t *testing.T) { func TestDraftMissingConversation(t *testing.T) {
server, _ := setupTestServer(t) server, _ := setupTestServer(t)
resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{ resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{

View File

@ -17,16 +17,9 @@ type RuntimeMetrics struct {
EscalateChecks int64 EscalateChecks int64
EscalateErrors int64 EscalateErrors int64
TwinSendsBlocked int64 TwinSendsBlocked int64
// TwinSendsBlockedByReason breaks the total above down by *why* a PushAttempts int64
// twin-authored send was blocked (peer_veto, flood_blocked, PushSkipped int64
// autonomy_l0, ...) -- see the call sites in main.go's PushDelivered int64
// POST /conversations/:id/messages handler for the full set of keys.
// This lets an operator tell a healthy signal (escalations catching
// sensitive content) apart from a bad one (lots of peer_veto/flood).
TwinSendsBlockedByReason map[string]int64
PushAttempts int64
PushSkipped int64
PushDelivered int64
} }
const maxLatencySamples = 200 const maxLatencySamples = 200
@ -55,14 +48,10 @@ func (m *RuntimeMetrics) recordEscalate(err error) {
} }
} }
func (m *RuntimeMetrics) recordTwinBlocked(reason string) { func (m *RuntimeMetrics) recordTwinBlocked() {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
m.TwinSendsBlocked++ m.TwinSendsBlocked++
if m.TwinSendsBlockedByReason == nil {
m.TwinSendsBlockedByReason = map[string]int64{}
}
m.TwinSendsBlockedByReason[reason]++
} }
func (m *RuntimeMetrics) recordPush(delivered int, skipped bool) { func (m *RuntimeMetrics) recordPush(delivered int, skipped bool) {
@ -99,41 +88,35 @@ func (m *RuntimeMetrics) snapshot() ginHMetrics {
if m.EscalateChecks > 0 { if m.EscalateChecks > 0 {
escErrRate = float64(m.EscalateErrors) / float64(m.EscalateChecks) escErrRate = float64(m.EscalateErrors) / float64(m.EscalateChecks)
} }
byReason := make(map[string]int64, len(m.TwinSendsBlockedByReason))
for k, v := range m.TwinSendsBlockedByReason {
byReason[k] = v
}
return ginHMetrics{ return ginHMetrics{
DraftRequests: m.DraftRequests, DraftRequests: m.DraftRequests,
DraftErrors: m.DraftErrors, DraftErrors: m.DraftErrors,
DraftErrorRate: errRate, DraftErrorRate: errRate,
DraftLatencyAvgMs: avgMs, DraftLatencyAvgMs: avgMs,
DraftLatencyMaxMs: float64(max.Milliseconds()), DraftLatencyMaxMs: float64(max.Milliseconds()),
DraftLatencySamples: len(m.DraftLatencies), DraftLatencySamples: len(m.DraftLatencies),
EscalateChecks: m.EscalateChecks, EscalateChecks: m.EscalateChecks,
EscalateErrors: m.EscalateErrors, EscalateErrors: m.EscalateErrors,
EscalateErrorRate: escErrRate, EscalateErrorRate: escErrRate,
TwinSendsBlocked: m.TwinSendsBlocked, TwinSendsBlocked: m.TwinSendsBlocked,
TwinSendsBlockedByReason: byReason, PushAttempts: m.PushAttempts,
PushAttempts: m.PushAttempts, PushSkipped: m.PushSkipped,
PushSkipped: m.PushSkipped, PushDelivered: m.PushDelivered,
PushDelivered: m.PushDelivered,
} }
} }
type ginHMetrics struct { type ginHMetrics struct {
DraftRequests int64 DraftRequests int64
DraftErrors int64 DraftErrors int64
DraftErrorRate float64 DraftErrorRate float64
DraftLatencyAvgMs float64 DraftLatencyAvgMs float64
DraftLatencyMaxMs float64 DraftLatencyMaxMs float64
DraftLatencySamples int DraftLatencySamples int
EscalateChecks int64 EscalateChecks int64
EscalateErrors int64 EscalateErrors int64
EscalateErrorRate float64 EscalateErrorRate float64
TwinSendsBlocked int64 TwinSendsBlocked int64
TwinSendsBlockedByReason map[string]int64 PushAttempts int64
PushAttempts int64 PushSkipped int64
PushSkipped int64 PushDelivered int64
PushDelivered int64
} }

View File

@ -1,45 +0,0 @@
package main
import "testing"
// TestRecordTwinBlockedByReason confirms recordTwinBlocked attributes counts
// to the right reason key and still keeps a top-line total in sync, so
// existing dashboards/scripts reading twin_sends_blocked keep working
// alongside the new by-reason breakdown.
func TestRecordTwinBlockedByReason(t *testing.T) {
m := &RuntimeMetrics{}
m.recordTwinBlocked("x")
m.recordTwinBlocked("x")
m.recordTwinBlocked("y")
snap := m.snapshot()
if snap.TwinSendsBlocked != 3 {
t.Fatalf("expected top-line total 3, got %d", snap.TwinSendsBlocked)
}
if got := snap.TwinSendsBlockedByReason["x"]; got != 2 {
t.Fatalf(`expected TwinSendsBlockedByReason["x"] == 2, got %d`, got)
}
if got := snap.TwinSendsBlockedByReason["y"]; got != 1 {
t.Fatalf(`expected TwinSendsBlockedByReason["y"] == 1, got %d`, got)
}
if len(snap.TwinSendsBlockedByReason) != 2 {
t.Fatalf("expected exactly 2 distinct reasons, got %v", snap.TwinSendsBlockedByReason)
}
}
// TestRecordTwinBlockedSnapshotIsolation confirms the snapshot's map is a
// copy, not an alias into the live struct -- so a caller mutating the
// returned map (e.g. JSON-encoding it, or a test asserting on it) can't
// corrupt runtimeMetrics's internal state.
func TestRecordTwinBlockedSnapshotIsolation(t *testing.T) {
m := &RuntimeMetrics{}
m.recordTwinBlocked("peer_veto")
snap := m.snapshot()
snap.TwinSendsBlockedByReason["peer_veto"] = 999
again := m.snapshot()
if again.TwinSendsBlockedByReason["peer_veto"] != 1 {
t.Fatalf("snapshot map leaked a live reference into RuntimeMetrics, got %v", again.TwinSendsBlockedByReason)
}
}

View File

@ -17,27 +17,6 @@ const (
AutonomyL2 AutonomyLevel = "L2" AutonomyL2 AutonomyLevel = "L2"
) )
func validAutonomyLevel(l AutonomyLevel) bool {
return l == AutonomyL0 || l == AutonomyL1 || l == AutonomyL2
}
// RelationshipTier is the minimum-2-tier persona split roadmap.md §2.7-B /
// PRD.md §2.1-②/§3.1 requires: draft tone should read differently for a
// close friend than for someone you'd stay formal with. Defaults to the
// more conservative "formal" tier for anyone who hasn't set this explicitly
// (same fail-safe-leaning default philosophy as AutonomyLevel defaulting
// to L0 when missing).
type RelationshipTier string
const (
RelationshipClose RelationshipTier = "close"
RelationshipFormal RelationshipTier = "formal"
)
func validRelationshipTier(t RelationshipTier) bool {
return t == RelationshipClose || t == RelationshipFormal
}
type User struct { type User struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
InviteCode string `gorm:"uniqueIndex;not null"` InviteCode string `gorm:"uniqueIndex;not null"`
@ -76,17 +55,7 @@ type Contact struct {
ContactUserID *uint ContactUserID *uint
DisplayName string `gorm:"not null"` DisplayName string `gorm:"not null"`
RelationshipNote string RelationshipNote string
// RelationshipTier overrides the owner's global TwinSettings.RelationshipTier CreatedAt time.Time
// for drafts sent to this specific contact (roadmap.md §2.7-B). Nil means
// "use the global default".
RelationshipTier *RelationshipTier
// AutonomyLevel overrides the owner's global TwinSettings.AutonomyLevel
// for auto-send gating in 1:1 conversations with this specific contact
// (roadmap.md §2.7-D, PRD.md §3.1 "전역 기본값 + 상대별 예외 설정"). Nil
// means "use the global default". Same nullable-override shape as
// RelationshipTier above.
AutonomyLevel *AutonomyLevel
CreatedAt time.Time
} }
// TwinDisabledByPeer is the veto flag (tech-design.md §4: "대화방 단위 // TwinDisabledByPeer is the veto flag (tech-design.md §4: "대화방 단위
@ -94,28 +63,17 @@ type Contact struct {
// it lives here rather than on Contact. Set by POST /conversations/:id/veto // it lives here rather than on Contact. Set by POST /conversations/:id/veto
// when the counterpart asks to talk to the human only; checked before any // when the counterpart asks to talk to the human only; checked before any
// twin auto-send in that conversation (main.go). // twin auto-send in that conversation (main.go).
// TwinDisabledByFlood is the flood/spam auto-pause flag (roadmap.md §2.7-C,
// PRD.md §4 "스팸 감지 임계치 초과 시 응대 중단"). Unlike TwinDisabledByPeer
// (a deliberate one-way human/peer choice, no un-veto endpoint by design),
// this is a fully automatic system action, so AGENTS.md's "every automatic
// action needs post-hoc notification + one-tap undo" applies -- it's
// reversible via POST /conversations/:id/flood-reset, unlike peer veto.
type Conversation struct { type Conversation struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
IsGroup bool `gorm:"not null;default:false"` IsGroup bool `gorm:"not null;default:false"`
TwinDisabledByPeer bool `gorm:"not null;default:false"` TwinDisabledByPeer bool `gorm:"not null;default:false"`
TwinDisabledByFlood bool `gorm:"not null;default:false"` CreatedAt time.Time
CreatedAt time.Time
} }
type ConversationParticipant struct { type ConversationParticipant struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
ConversationID uint `gorm:"not null;index"` ConversationID uint `gorm:"not null;index"`
UserID uint `gorm:"not null;index"` UserID uint `gorm:"not null;index"`
// LastReadMessageID is this participant's read marker (roadmap.md
// §2.7-A "단톡 따라잡기") -- nil means nothing read yet. Drives the
// unread badge and GET /conversations/:id/summary's "안 본 동안" window.
LastReadMessageID *uint
} }
type Message struct { type Message struct {
@ -129,31 +87,15 @@ type Message struct {
// one-tap undo") -- set via POST /messages/:id/retract, twin-authored // one-tap undo") -- set via POST /messages/:id/retract, twin-authored
// messages only. // messages only.
Retracted bool `gorm:"not null;default:false"` Retracted bool `gorm:"not null;default:false"`
// DraftEdited is the implicit L1-approval-rate signal (PRD.md §5's CreatedAt time.Time
// "초안을 수정 없이 그대로 발송한 비율", vision.md's naturalness metric,
// deploy-checklist.md N4-12). Nil for human-authored messages and for
// any message not sent through the draft-approval flow (old rows, or a
// client that didn't send original_draft_text) -- only set to a
// concrete true/false for twin-mode messages where the client told us
// what the original AI draft text was, so we could diff it against
// what actually got sent. Never guessed.
DraftEdited *bool
// NaturalnessRating is the explicit "이 답장 나답아요?" signal (vision.md
// naturalness metric, PRD.md §5, deploy-checklist.md N4-12): true = 👍,
// false = 👎, nil = not rated yet. Set via POST /messages/:id/feedback,
// twin-authored messages only (rating a human's own words doesn't make
// sense for this metric).
NaturalnessRating *bool
CreatedAt time.Time
} }
type TwinSettings struct { type TwinSettings struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
UserID uint `gorm:"uniqueIndex;not null"` UserID uint `gorm:"uniqueIndex;not null"`
AutonomyLevel AutonomyLevel `gorm:"not null;default:L0"` AutonomyLevel AutonomyLevel `gorm:"not null;default:L0"`
RelationshipTier RelationshipTier `gorm:"not null;default:formal"` CreatedAt time.Time
CreatedAt time.Time UpdatedAt time.Time
UpdatedAt time.Time
} }
// WhitelistRule is the L2 auto-send whitelist -- a (contact, topic) pair the // WhitelistRule is the L2 auto-send whitelist -- a (contact, topic) pair the

View File

@ -1,66 +0,0 @@
package main
import "gorm.io/gorm"
// findCounterpartContact locates the Contact row (if any) representing the
// other person in a 1:1 conversation, from actorID's point of view. Shared
// by resolveRelationshipTier (§2.7-B), resolveAutonomyLevel (§2.7-D), and
// resolveRelationshipNote (§2.7-E) -- all three need the exact same
// "which Contact row applies to this actor+conversation" lookup and only
// differ in which field of the result they read. Returns ok=false for group
// conversations (more than one counterpart, so no single contact applies)
// or when no Contact row exists between actorID and the other participant.
func findCounterpartContact(db *gorm.DB, actorID, conversationID uint) (Contact, bool) {
var conv Conversation
if err := db.First(&conv, conversationID).Error; err != nil || conv.IsGroup {
return Contact{}, false
}
var parts []ConversationParticipant
db.Where("conversation_id = ?", conversationID).Find(&parts)
for _, p := range parts {
if p.UserID == actorID {
continue
}
var contact Contact
if err := db.Where("owner_user_id = ? AND contact_user_id = ?", actorID, p.UserID).First(&contact).Error; err == nil {
return contact, true
}
break
}
return Contact{}, false
}
// resolveRelationshipTier implements roadmap.md §2.7-B: draft tone should
// read differently for a close friend than for someone you'd stay formal
// with. Resolution order: contact-specific override (1:1 only) -> the
// sender's global TwinSettings default -> "formal" as the fail-safe
// fallback if nothing is set. Group conversations always use the global
// default since a group has more than one counterpart to pick a tier for.
func resolveRelationshipTier(db *gorm.DB, actorID, conversationID uint) RelationshipTier {
if contact, ok := findCounterpartContact(db, actorID, conversationID); ok {
if contact.RelationshipTier != nil && validRelationshipTier(*contact.RelationshipTier) {
return *contact.RelationshipTier
}
}
var settings TwinSettings
if err := db.Where("user_id = ?", actorID).First(&settings).Error; err == nil && validRelationshipTier(settings.RelationshipTier) {
return settings.RelationshipTier
}
return RelationshipFormal
}
// resolveRelationshipNote implements roadmap.md §2.7-E: Contact.RelationshipNote
// (a free-text note like "호칭: 자기야, 절대 언급 금지: 전 여친") already has full
// CRUD but never reached the draft prompt -- this makes it actually flow into
// ai-service. Unlike relationship tier / autonomy level, a note is inherently
// per-person: there is no "global default note" to fall back to. So this
// simply returns "" for group conversations, for a 1:1 with no matching
// Contact row, or for a Contact whose note is unset -- ai-service treats an
// empty note as "no extra instruction", identical to today's behavior.
func resolveRelationshipNote(db *gorm.DB, actorID, conversationID uint) string {
if contact, ok := findCounterpartContact(db, actorID, conversationID); ok {
return contact.RelationshipNote
}
return ""
}

View File

@ -1,274 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
)
func TestPatchTwinSettingsUpdatesRelationshipTier(t *testing.T) {
server, _ := setupTestServer(t)
userID, token := mustSignup(t, server.URL, "민수")
tier := RelationshipClose
resp := patchJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/twin-settings", token, updateTwinSettingsRequest{
AutonomyLevel: AutonomyL1,
RelationshipTier: &tier,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
if out["relationship_tier"] != "close" {
t.Fatalf("expected relationship_tier close, got %v", out)
}
if out["autonomy_level"] != "L1" {
t.Fatalf("expected autonomy_level L1, got %v", out)
}
}
func TestPatchTwinSettingsRejectsInvalidRelationshipTier(t *testing.T) {
server, _ := setupTestServer(t)
userID, token := mustSignup(t, server.URL, "민수")
bad := RelationshipTier("aloof")
resp := patchJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/twin-settings", token, updateTwinSettingsRequest{
AutonomyLevel: AutonomyL0,
RelationshipTier: &bad,
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
}
func TestPatchTwinSettingsOmittingTierLeavesItUnchanged(t *testing.T) {
server, _ := setupTestServer(t)
userID, token := mustSignup(t, server.URL, "민수")
userIDStr := strconv.FormatUint(uint64(userID), 10)
tier := RelationshipClose
patchJSONAuth(t, server.URL+"/users/"+userIDStr+"/twin-settings", token, updateTwinSettingsRequest{
AutonomyLevel: AutonomyL1,
RelationshipTier: &tier,
})
// Existing autonomy-only callers must keep working exactly as before --
// omitting relationship_tier must not reset it back to the DB default.
resp := patchJSONAuth(t, server.URL+"/users/"+userIDStr+"/twin-settings", token, updateTwinSettingsRequest{
AutonomyLevel: AutonomyL2,
})
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
if out["relationship_tier"] != "close" {
t.Fatalf("expected relationship_tier to stay close, got %v", out)
}
}
func TestContactRelationshipTierOverridesGlobalDefaultInDraft(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
// Global default starts as "formal" (signup default). Override just
// this contact to "close".
closeTier := RelationshipClose
contactResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
RelationshipTier: &closeTier,
})
if contactResp.StatusCode != http.StatusOK {
t.Fatalf("create contact: %d", contactResp.StatusCode)
}
convResp := postJSONAuth(t, server.URL+"/conversations", ownerToken, createConversationRequest{
UserIDs: []uint{ownerID, peerID},
})
var conv map[string]interface{}
json.NewDecoder(convResp.Body).Decode(&conv)
convID := uint(conv["id"].(float64))
draftResp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/draft", ownerToken, draftMessageRequest{
ContextLines: []string{"상대: 오늘 저녁에 뭐 먹을래?"},
StyleExamples: []string{"ㅇㅇ 좋지"},
})
if draftResp.StatusCode != http.StatusOK {
t.Fatalf("draft: %d", draftResp.StatusCode)
}
var draftOut draftResponse
json.NewDecoder(draftResp.Body).Decode(&draftOut)
if !strings.Contains(draftOut.Text, "tier=close") {
t.Fatalf("expected contact override tier=close in mock draft, got %v", draftOut.Text)
}
}
func TestDraftFallsBackToGlobalTierWithoutContactOverride(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
// No contact record at all -- resolveRelationshipTier must fall back to
// the signup default ("formal") rather than erroring.
convResp := postJSONAuth(t, server.URL+"/conversations", ownerToken, createConversationRequest{
UserIDs: []uint{ownerID, peerID},
})
var conv map[string]interface{}
json.NewDecoder(convResp.Body).Decode(&conv)
convID := uint(conv["id"].(float64))
draftResp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/draft", ownerToken, draftMessageRequest{
ContextLines: []string{"상대: 내일 시간 되세요?"},
StyleExamples: []string{"네 됩니다"},
})
var draftOut draftResponse
json.NewDecoder(draftResp.Body).Decode(&draftOut)
if !strings.Contains(draftOut.Text, "tier=formal") {
t.Fatalf("expected global default tier=formal in mock draft, got %v", draftOut.Text)
}
}
func TestDraftWithoutAuthDefaultsToFormalTier(t *testing.T) {
server, db := setupTestServer(t)
conv := Conversation{IsGroup: false}
if err := db.Create(&conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
// No Authorization header at all -- this endpoint has never required
// one, so it must keep working (just without tier personalization).
resp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/draft", draftMessageRequest{
ContextLines: []string{"상대: 오늘 저녁에 뭐 먹을래?"},
StyleExamples: []string{"ㅇㅇ 좋지"},
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var out draftResponse
json.NewDecoder(resp.Body).Decode(&out)
if !strings.Contains(out.Text, "tier=formal") {
t.Fatalf("expected default tier=formal for unauthenticated call, got %v", out.Text)
}
}
func TestContactRelationshipNoteReachesDraftRequest(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
contactResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
RelationshipNote: "호칭: 자기야, 절대 언급 금지: 전 여친",
})
if contactResp.StatusCode != http.StatusOK {
t.Fatalf("create contact: %d", contactResp.StatusCode)
}
convResp := postJSONAuth(t, server.URL+"/conversations", ownerToken, createConversationRequest{
UserIDs: []uint{ownerID, peerID},
})
var conv map[string]interface{}
json.NewDecoder(convResp.Body).Decode(&conv)
convID := uint(conv["id"].(float64))
draftResp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/draft", ownerToken, draftMessageRequest{
ContextLines: []string{"상대: 자기야 오늘 뭐해?"},
StyleExamples: []string{"응 그냥 집이야"},
})
if draftResp.StatusCode != http.StatusOK {
t.Fatalf("draft: %d", draftResp.StatusCode)
}
var draftOut draftResponse
json.NewDecoder(draftResp.Body).Decode(&draftOut)
if !strings.Contains(draftOut.Text, "[note=호칭: 자기야, 절대 언급 금지: 전 여친]") {
t.Fatalf("expected contact relationship note forwarded in mock draft, got %v", draftOut.Text)
}
}
func TestDraftWithoutRelationshipNoteHasNoNoteText(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
// Contact exists but with no RelationshipNote set (empty string, the
// zero value) -- must resolve to "" rather than injecting anything.
postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
})
convResp := postJSONAuth(t, server.URL+"/conversations", ownerToken, createConversationRequest{
UserIDs: []uint{ownerID, peerID},
})
var conv map[string]interface{}
json.NewDecoder(convResp.Body).Decode(&conv)
convID := uint(conv["id"].(float64))
draftResp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/draft", ownerToken, draftMessageRequest{
ContextLines: []string{"상대: 내일 시간 되세요?"},
StyleExamples: []string{"네 됩니다"},
})
var draftOut draftResponse
json.NewDecoder(draftResp.Body).Decode(&draftOut)
if !strings.Contains(draftOut.Text, "[note=]") {
t.Fatalf("expected empty note to produce no note text, got %v", draftOut.Text)
}
}
func TestGroupConversationDraftAlwaysGetsEmptyNoteRegardlessOfContactNote(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
// A 1:1 relationship note exists for this same pair of users elsewhere,
// but a group conversation has more than one counterpart -- there is no
// single "the note" to pick, so it must always resolve to "".
postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
RelationshipNote: "호칭: 자기야",
})
convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID})
draftResp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/draft", ownerToken, draftMessageRequest{
ContextLines: []string{"철수: 이번 주 토요일 모임 어때?"},
StyleExamples: []string{"ㅇㅇ 좋지"},
})
var draftOut draftResponse
json.NewDecoder(draftResp.Body).Decode(&draftOut)
if !strings.Contains(draftOut.Text, "[note=]") {
t.Fatalf("expected group draft to always get empty note despite existing 1:1 contact note, got %v", draftOut.Text)
}
}
func TestGroupConversationDraftUsesGlobalTierNotContactOverride(t *testing.T) {
server, _ := setupTestServer(t)
ownerID, ownerToken := mustSignup(t, server.URL, "민수")
peerID, _ := mustSignup(t, server.URL, "철수")
closeTier := RelationshipClose
postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
DisplayName: "철수",
ContactUserID: &peerID,
RelationshipTier: &closeTier,
})
convID := createGroup(t, server.URL, ownerToken, []uint{ownerID, peerID})
draftResp := postJSONAuth(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/draft", ownerToken, draftMessageRequest{
ContextLines: []string{"철수: 이번 주 토요일 모임 어때?"},
StyleExamples: []string{"ㅇㅇ 좋지"},
})
var draftOut draftResponse
json.NewDecoder(draftResp.Body).Decode(&draftOut)
// Group conversations ignore the per-contact override (roadmap.md
// §2.7-B: a group has more than one counterpart) and use the global
// default ("formal") instead.
if !strings.Contains(draftOut.Text, "tier=formal") {
t.Fatalf("expected group draft to use global tier=formal, got %v", draftOut.Text)
}
}

View File

@ -2,111 +2,28 @@ package main
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"strings" "strings"
"sync"
"time" "time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"gorm.io/gorm" "gorm.io/gorm"
) )
const fcmMessagingScope = "https://www.googleapis.com/auth/firebase.messaging" // 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.
// FCM_SERVER_KEY = legacy HTTP API (often disabled in new Firebase projects).
// Prefer FCM HTTP v1 via service account:
// FCM_SERVICE_ACCOUNT_FILE=/path/to.json
// or FCM_SERVICE_ACCOUNT_JSON='{"type":"service_account",...}'
func fcmServerKey() string { func fcmServerKey() string {
return strings.TrimSpace(os.Getenv("FCM_SERVER_KEY")) return strings.TrimSpace(os.Getenv("FCM_SERVER_KEY"))
} }
func loadFCMServiceAccountJSON() ([]byte, error) {
if path := strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_FILE")); path != "" {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read FCM_SERVICE_ACCOUNT_FILE: %w", err)
}
return bytes.TrimSpace(b), nil
}
if raw := strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_JSON")); raw != "" {
return []byte(raw), nil
}
return nil, nil
}
type fcmServiceAccount struct {
ProjectID string `json:"project_id"`
}
type fcmLegacyPayload struct { type fcmLegacyPayload struct {
To string `json:"to,omitempty"` To string `json:"to,omitempty"`
Registration []string `json:"registration_ids,omitempty"` Registration []string `json:"registration_ids,omitempty"`
Priority string `json:"priority"` Priority string `json:"priority"`
Notification map[string]string `json:"notification"` Notification map[string]string `json:"notification"`
Data map[string]string `json:"data,omitempty"` 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) { func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]string) (sent int, skippedReason string, err error) {
@ -116,89 +33,25 @@ func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]st
return 0, "no_device_tokens", nil return 0, "no_device_tokens", nil
} }
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() key := fcmServerKey()
if key == "" { if key == "" {
runtimeMetrics.recordPush(0, true) runtimeMetrics.recordPush(0, true)
return 0, "fcm_not_configured", nil return 0, "fcm_not_configured", nil
} }
n, err := sendFCMLegacy(key, regIDs, title, body, data)
if err != 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)
}
if len(regIDs) == 0 {
runtimeMetrics.recordPush(0, true) runtimeMetrics.recordPush(0, true)
return 0, "", err return 0, "only_placeholder_tokens", nil
} }
runtimeMetrics.recordPush(n, false)
return n, "", nil
}
func sendFCMv1(ctx context.Context, projectID string, ts oauth2.TokenSource, regIDs []string, title, body string, data map[string]string) (int, error) {
tok, err := ts.Token()
if err != nil {
return 0, fmt.Errorf("fcm access token: %w", err)
}
client := &http.Client{Timeout: 10 * time.Second}
url := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", projectID)
sent := 0
var lastErr error
for _, reg := range regIDs {
payload := fcmV1MessageRequest{
Message: fcmV1Message{
Token: reg,
Notification: map[string]string{"title": title, "body": body},
Data: data,
},
}
raw, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return sent, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
resp, err := client.Do(req)
if err != nil {
lastErr = err
continue
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode >= 300 {
lastErr = fmt.Errorf("fcm v1 returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
continue
}
sent++
}
if sent == 0 && lastErr != nil {
return 0, lastErr
}
return sent, nil
}
func sendFCMLegacy(key string, regIDs []string, title, body string, data map[string]string) (int, error) {
payload := fcmLegacyPayload{ payload := fcmLegacyPayload{
Registration: regIDs, Registration: regIDs,
Priority: "high", Priority: "high",
@ -208,7 +61,7 @@ func sendFCMLegacy(key string, regIDs []string, title, body string, data map[str
raw, _ := json.Marshal(payload) raw, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, "https://fcm.googleapis.com/fcm/send", bytes.NewReader(raw)) req, err := http.NewRequest(http.MethodPost, "https://fcm.googleapis.com/fcm/send", bytes.NewReader(raw))
if err != nil { if err != nil {
return 0, err return 0, "", err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "key="+key) req.Header.Set("Authorization", "key="+key)
@ -216,11 +69,14 @@ func sendFCMLegacy(key string, regIDs []string, title, body string, data map[str
client := &http.Client{Timeout: 8 * time.Second} client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return 0, err runtimeMetrics.recordPush(0, true)
return 0, "", err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 300 { if resp.StatusCode >= 300 {
return 0, fmt.Errorf("fcm returned %d", resp.StatusCode) runtimeMetrics.recordPush(0, true)
return 0, "", fmt.Errorf("fcm returned %d", resp.StatusCode)
} }
return len(regIDs), nil runtimeMetrics.recordPush(len(regIDs), false)
return len(regIDs), "", nil
} }

View File

@ -12,25 +12,7 @@ server {
# Docker Compose embedded DNS # Docker Compose embedded DNS
resolver 127.0.0.11 valid=10s ipv6=off; resolver 127.0.0.11 valid=10s ipv6=off;
# Entrypoint + service worker: always revalidate (PWA / docs/web-upgrade N4-W5). # Flutter SPA
location = /index.html {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location = /flutter_service_worker.js {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location = /manifest.json {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location = /firebase-messaging-sw.js {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
# Flutter SPA shell — offline shell cache is SW-managed; HTML fallback for deep links.
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }

View File

@ -48,11 +48,6 @@ services:
ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?set ADMIN_API_TOKEN in .env} ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?set ADMIN_API_TOKEN in .env}
ALLOW_DEMO_INVITE: ${ALLOW_DEMO_INVITE:-1} ALLOW_DEMO_INVITE: ${ALLOW_DEMO_INVITE:-1}
FCM_SERVER_KEY: ${FCM_SERVER_KEY:-} 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: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@ -72,14 +67,6 @@ services:
dockerfile: mobile/Dockerfile dockerfile: mobile/Dockerfile
args: args:
CORE_API_BASE: ${PUBLIC_API_BASE:-https://msn.iykyka.com} CORE_API_BASE: ${PUBLIC_API_BASE:-https://msn.iykyka.com}
# N4-W4 Web Push — set in host .env (never commit). Empty → install: fallback.
FIREBASE_API_KEY: ${FIREBASE_API_KEY:-}
FIREBASE_AUTH_DOMAIN: ${FIREBASE_AUTH_DOMAIN:-}
FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:-}
FIREBASE_STORAGE_BUCKET: ${FIREBASE_STORAGE_BUCKET:-}
FIREBASE_MESSAGING_SENDER_ID: ${FIREBASE_MESSAGING_SENDER_ID:-}
FIREBASE_APP_ID: ${FIREBASE_APP_ID:-}
FIREBASE_VAPID_KEY: ${FIREBASE_VAPID_KEY:-}
ports: ports:
- "${WEB_HOST_PORT:-8088}:80" - "${WEB_HOST_PORT:-8088}:80"
depends_on: depends_on:

View File

@ -30,9 +30,7 @@
기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다. 기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다.
현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30). 현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30).
PoC 의존 하위 질문(자율성 기본값 등)과 **Q9(L2 의미, 제안)** 가 열려 있다. PoC 의존 하위 질문(자율성 기본값 등)만 열려 있다 — decision-log를 단일 기준으로 따른다.
**Q8(계정·설정 IA)은 2026-08-03 확정·구현** — decision-log를 단일 기준으로 따른다.
계정 UX 상세는 [`account-settings-ia.md`](./account-settings-ia.md).
| # | 질문 | 결정 | 상태 | | # | 질문 | 결정 | 상태 |
|---|------|------|------| |---|------|------|------|
@ -107,8 +105,7 @@ PoC 의존 하위 질문(자율성 기본값 등)과 **Q9(L2 의미, 제안)**
3. [`tech-design.md`](./tech-design.md) — 온디바이스/서버 경계, 데이터 흐름, 에스컬레이션 로직 3. [`tech-design.md`](./tech-design.md) — 온디바이스/서버 경계, 데이터 흐름, 에스컬레이션 로직
4. [`risk-log.md`](./risk-log.md) — 회의 자료 §2-6, §oslayer §4의 리스크를 완화 상태와 함께 추적 4. [`risk-log.md`](./risk-log.md) — 회의 자료 §2-6, §oslayer §4의 리스크를 완화 상태와 함께 추적
5. [`roadmap.md`](./roadmap.md) — L0~L4 단계, 자체 앱→OS 레이어 확장 시점 반영 5. [`roadmap.md`](./roadmap.md) — L0~L4 단계, 자체 앱→OS 레이어 확장 시점 반영
6. [`decision-log.md`](./decision-log.md) — Q1~Q8 확정 + Q9 제안, 계속 누적 기록 6. [`decision-log.md`](./decision-log.md) — Q1~Q7 결정과 근거, 계속 누적 기록
6a. [`account-settings-ia.md`](./account-settings-ia.md) — 초대 가입·로그아웃·설정 IA·L2 갭 명세
7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준 7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준
8. [`poc-materials.md`](./poc-materials.md) — 모집 문구, 동의 안내, 역할극 스크립트, 인터뷰 질문지 초안 8. [`poc-materials.md`](./poc-materials.md) — 모집 문구, 동의 안내, 역할극 스크립트, 인터뷰 질문지 초안
9. [`user-interview-guide.md`](./user-interview-guide.md) — Q3 자율성 수용성 인터뷰 (와카뷰 사용자 관점) 9. [`user-interview-guide.md`](./user-interview-guide.md) — Q3 자율성 수용성 인터뷰 (와카뷰 사용자 관점)

View File

@ -12,17 +12,6 @@
## 2. 유저 플로우 ## 2. 유저 플로우
### 2.0 계정 · 입장 (클로즈드 베타)
Phase 1은 **이메일/비밀번호 계정이 아니라 초대 코드 입장**이다
(`decision-log.md` Q8 확정, `invite-ops.md`, `account-settings-ia.md`).
1. **새로 가입** — 발급된 초대 코드 + 표시 이름 → 세션 토큰 발급 → 말투 온보딩
2. **이미 가입** — 초대 코드 + 표시 이름으로 로그인(토큰 재발급) → 메인
(`DEMO-YKAVU`는 표시 이름으로 사용자 구분)
3. **로그아웃** — 설정에서 명시적 로그아웃 → 서버 세션 종료 + 기기 토큰 삭제 → 입장 화면
4. **탈퇴** — Phase 1 앱 UI 비범위 (운영 수동)
### 2.1 온보딩 (말투 학습) ### 2.1 온보딩 (말투 학습)
1. 가입 시 기존 대화 일부 임포트 또는 짧은 질문 응답으로 말투 초기 세팅 (목표: 5분 이내) 1. 가입 시 기존 대화 일부 임포트 또는 짧은 질문 응답으로 말투 초기 세팅 (목표: 5분 이내)
2. 관계별 페르소나 초기값 설정 — 최소 "가까운 사이 / 공식적인 사이" 2종만 v1에서 지원 2. 관계별 페르소나 초기값 설정 — 최소 "가까운 사이 / 공식적인 사이" 2종만 v1에서 지원
@ -31,18 +20,12 @@ Phase 1은 **이메일/비밀번호 계정이 아니라 초대 코드 입장**
### 2.2 읽씹 종결 시나리오 (핵심) ### 2.2 읽씹 종결 시나리오 (핵심)
1. 사용자가 "방해금지"(수면 중 등) 상태를 켜거나, 앱이 비활성 상태 감지 시 자동 제안 1. 사용자가 "방해금지"(수면 중 등) 상태를 켜거나, 앱이 비활성 상태 감지 시 자동 제안
2. 상대가 메시지를 보냄 → 와카뷰가 맥락(상태, 최근 활동 패턴)을 보고 응답 초안 생성 2. 상대가 메시지를 보냄 → 와카뷰가 맥락(상태, 최근 활동 패턴)을 보고 응답 초안 생성
3. **L1**: 사용자에게 "지금 자동 응답 보낼까요?" 알림 → 승인 시 발송 3. **L1**: 사용자에게 "지금 자동 응답 보낼까요?" 알림 → 승인 시 발송 (기본값)
4. **L2 (목표)**: 사용자가 화이트리스트에 등록한 상대·주제(예: "가벼운 안부, 시간 문의")에 한해 4. **L2**: 사용자가 화이트리스트에 등록한 상대·주제(예: "가벼운 안부, 시간 문의")에 한해
즉시 자동 발송, 사후 알림 즉시 자동 발송, 사후 알림
5. 상대에게는 와카뷰 뱃지가 붙은 말풍선으로 표시됨 (3.1 참고) 5. 상대에게는 와카뷰 뱃지가 붙은 말풍선으로 표시됨 (3.1 참고)
6. 사용자가 복귀하면 미응답/보류 항목 요약 제공, 필요 시 후속 메시지 작성 6. 사용자가 복귀하면 미응답/보류 항목 요약 제공, 필요 시 후속 메시지 작성
> **구현 갭 (Q9 제안):** 2026-08 현재 클라이언트는 L2를 “수신 즉시 자동응대”가 아니라
> **와카뷰 발송 시 승인 생략 게이트트**로만 구현한다. 수신 트리거 자동응대는
> `account-settings-ia.md` §3.4 / `roadmap.md` 후속 항목으로 분리한다. 그전까지 제품 카피는
> 게이트 현실에 맞게 쓴다.
### 2.3 단톡 따라잡기 시나리오 ### 2.3 단톡 따라잡기 시나리오
1. 사용자가 오랜만에 단톡방 진입 또는 "안 본 동안 요약" 요청 1. 사용자가 오랜만에 단톡방 진입 또는 "안 본 동안 요약" 요청
2. 와카뷰가 **나에게 멘션된 것 / 결정된 사항** 위주로 3~5줄 요약 2. 와카뷰가 **나에게 멘션된 것 / 결정된 사항** 위주로 3~5줄 요약
@ -55,9 +38,6 @@ Phase 1은 **이메일/비밀번호 계정이 아니라 초대 코드 입장**
| 기능 | 설명 | | 기능 | 설명 |
|---|---| |---|---|
| 초대 코드 가입·재로그인 | 클로즈드 베타 입장. 이메일/비번 없음 (`account-settings-ia.md`) |
| 로그아웃 | 설정에서 세션 종료 + 로컬 토큰 클리어 |
| 설정 IA | 말투·자율성·세션·데이터흐름·로그아웃을 한 진입점에서 발견 가능 |
| 와카뷰 뱃지 | 와카뷰가 작성한 말풍선은 사람 말풍선과 시각적으로 구분(점선 테두리 + 뱃지 라벨) | | 와카뷰 뱃지 | 와카뷰가 작성한 말풍선은 사람 말풍선과 시각적으로 구분(점선 테두리 + 뱃지 라벨) |
| 자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정 | | 자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정 |
| 응답 승인 UI | L1 초안을 한 탭으로 검토·수정·발송 | | 응답 승인 UI | L1 초안을 한 탭으로 검토·수정·발송 |

View File

@ -1,112 +0,0 @@
# 계정 · 인증 · 설정 IA (Phase 1 갭 명세)
권위: [`decision-log.md`](./decision-log.md) Q8(확정)·Q9(제안) · [`PRD.md`](./PRD.md) · [`invite-ops.md`](./invite-ops.md).
Q8은 2026-08-03 확정·구현. Q9(수신 트리거 L2)는 제안 상태로 코드 범위를 넓히지 않는다.
## 1. 현재 상태 (as-is)
| 영역 | 있음 | 없음 / 불완전 |
|------|------|----------------|
| 가입 | 초대 코드 + 표시 이름 (`SignupScreen`, `POST /auth/signup`) | 이메일·비밀번호·소셜 |
| 로그인 | 서버 `POST /auth/login` (초대 코드) | 앱 UI에 「이미 가입」경로 |
| 세션 | Bearer 토큰 로컬 저장, `SessionsScreen` 목록/종료 | 종료 후 로컬 토큰 클리어·가입 화면 복귀 |
| 로그아웃 | — | 메인 메뉴 로그아웃 **없음** |
| 설정 | ⋮ → 말투 / 자율성(안에 세션·데이터흐름) | 단일 「설정」진입점·로그아웃 묶음 |
| L2 | 서버 발송 게이트 + 화이트리스트 키워드 | PRD §2.2식 **수신 트리거 자동응대** |
## 2. Phase 1 목표 모델 (to-be, Q8 확정)
### 2.1 계정
- **초대 코드 클로즈드 베타만.** 이메일/비번/OAuth는 Phase 1 비범위 (`decision-log` Q7·Q8a).
- 한 초대 코드 = 한 가입 (`invite-ops.md`). 데모 코드 `DEMO-YKAVU`는 운영 예외(재사용 허용).
- 사용자는 **숫자 사용자 ID**로 페어링한다 (표시 이름만으로는 대화 불가 — 기존 Track A).
### 2.2 가입 / 로그인 UX
```
[스플래시]
→ 토큰 유효? → 메인(대화 목록)
→ 없음 → [입장 화면]
├─ 새로 가입 (초대 코드 + 표시 이름) → 말투 온보딩 → 메인
└─ 이미 가입 (초대 코드 + 표시 이름) → 메인
```
- 「이미 가입」은 `POST /auth/login`으로 새 세션 토큰을 받는다.
- 일반 초대: 사용된 초대 코드 → 사용자. 표시 이름이 있으면 일치해야 함.
- `DEMO-YKAVU`: 표시 이름 **필수** (공유 코드라 이름으로 사용자 구분).
- 비밀번호 없음. 초대 코드 유출 = 계정 탈취 가능 → 베타 한정 리스크로 `invite-ops`에 명시.
### 2.3 로그아웃 (P0 갭)
1. 설정(또는 ⋮) → **로그아웃** 확인
2. `DELETE /users/:id/sessions/:current` (또는 동등 revoke)
3. 로컬: `session_token` / 유저 캐시 삭제, API 클라이언트 토큰 클리어
4. 네비게이션: 입장(가입/로그인) 화면으로 스택 리셋
5. 말투 샘플 등 온디바이스 DB는 **기기 잔존**(같은 사용자가 다시 로그인하면 재사용) —
“기기에서 전부 삭제”는 별 옵션(후속)
### 2.4 설정 IA
대화 목록에서 발견 가능한 한 진입점(권장: ⋮ 유지 + 라벨 「설정」, 또는 톱니 아이콘).
| 항목 | 화면 | 비고 |
|------|------|------|
| 말투 · 페르소나 | `OnboardingToneScreen` | 설정 허브에서 진입 |
| 자율성 · 화이트리스트 · 관계 기본 | `AutonomySettingsScreen` | 기존 |
| 로그인 세션 | `SessionsScreen` | 기존, 로그아웃과 구분 |
| 데이터 흐름 | `DataFlowScreen` | 기존 |
| **로그아웃** | 확인 다이얼로그만 | **신규** |
채팅방 앱바에는 설정 전체를 넣지 않는다 (거부권·스누즈만 유지).
### 2.5 계정 삭제
- Phase 1 앱 UI **비범위** (Q8e 제안).
- 운영 필요 시 서버/DB 수동. 정식 탈퇴 API는 베타 이후 Q.
## 3. L2 의미 (Q9 제안) — 카피·구현 정렬
### 3.1 PRD가 말하는 것 (`PRD.md` §2.2)
상대가 메시지를 보냄 → (화이트리스트면) 와카뷰가 **즉시 자동 발송** + 사후 알림.
### 3.2 지금 코드가 하는 것
- 사용자가 ✨으로 초안을 받고, 클라이언트가 `sender_mode=twin`으로 발송할 때
- 서버가 L2 + 화이트리스트 매칭이면 `approved` 없이도 저장·릴레이
- **상대 메시지 수신을 감지해 스스로 초안·발송하는 루프는 없음**
### 3.3 Phase 1에서의 제품 카피 (제안)
- 자율성 화면 L2 설명:
「화이트리스트 주제의 **와카뷰 답장**은 승인 없이 보낼 수 있습니다.
상대 메시지를 받으면 ✨으로 초안을 만든 뒤 보내세요.」
- “자리를 비우면 알아서 답함”은 **수신 트리거 자동응대** 구현 전까지 쓰지 않음.
### 3.4 후속 구현 (별 로드맵, Q9 확정 후)
1. 1:1에서 상대 메시지 WS/수신 이벤트
2. 에스컬레이션·거부권·도배·그룹 하드게이트 통과 시만
3. 초안 생성 → L2+화이트리스트면 twin 발송, 아니면 L1 알림
4. 사후 알림 + 되돌리기 (기존 불변식)
## 4. 수락 기준 (구현 착수 시)
**계정/설정 (Q8 확정)**
- [x] 입장 화면에 가입 / 이미 가입(로그인) 구분
- [x] 로그아웃 시 서버 세션 무효 + 로컬 토큰 삭제 + 입장 화면 복귀
- [x] 설정 진입에서 로그아웃이 한 번의 탭 경로로 발견 가능
- [x] 이메일/비번 UI를 추가하지 않음
**L2 정렬 (Q9 확정 후)**
- [ ] L2 설명이 발송 게이트 현실과 모순되지 않음
- [ ] 수신 자동응대를 넣을 경우 별도 roadmap 체크리스트 + 안전 불변식 테스트
## 5. 비범위 (이 문서에서 다루지 않음)
- 이메일 인증, OAuth, 비밀번호 재설정
- OS 레이어 계정 연동
- L3/L4

View File

@ -34,31 +34,9 @@
**순서:** ① 자체 앱 클로즈드 베타로 핵심 가설 검증 → ② 검증되면 OS 레이어 읽기 전용 허브 → **순서:** ① 자체 앱 클로즈드 베타로 핵심 가설 검증 → ② 검증되면 OS 레이어 읽기 전용 허브 →
③ 자체 앱은 L3·L4 등 완전한 기능의 최종 목적지로 유지. ③ 자체 앱은 L3·L4 등 완전한 기능의 최종 목적지로 유지.
## Q8 — 계정·인증·설정 IA (클로즈드 베타 갭) — 확정
2026-08-03 Master 피드백 후 문서화 → **2026-08-03 Master 「1번부터 진행」으로 Q8 확정·구현**.
| # | 질문 | 결정 | 근거 | 상태 |
|---|------|------|------|------|
| Q8a | Phase 1 계정 모델? | **초대 코드 클로즈드 베타 유지** (이메일/비번·소셜 로그인 **비범위**) | Q7 + `invite-ops.md` | 확정 |
| Q8b | 재접속(로그인) UX? | **초대 코드 + 표시 이름**으로 토큰 재발급. 입장 화면에 「이미 가입」탭. (`DEMO-YKAVU`는 표시 이름으로 사용자 구분) | 서버 login 확장 + 앱 UI | 확정 |
| Q8c | 로그아웃? | **P0**. 설정에서 로그아웃 → 현재 세션 revoke + 로컬 토큰/유저 클리어 → 입장 화면 | `SessionsScreen`만으로는 불완전했음 | 확정 |
| Q8d | 설정 IA? | 대화 목록 **설정** 화면: 말투 · 자율성 · 로그인 세션 · 데이터 흐름 · **로그아웃** | 발견성 | 확정 |
| Q8e | 계정 삭제/탈퇴? | Phase 1 앱 UI **비범위** (운영 수동) | 베타 범위 | 확정 |
상세 명세: [`account-settings-ia.md`](./account-settings-ia.md).
## Q9 — L2 “자동 응답”의 제품 의미 — 제안
| # | 질문 | 제안 결정 | 근거 | 상태 |
|---|------|----------|------|------|
| Q9 | L2는 무엇인가? | **목표(PRD §2.2)**: 상대 메시지 수신 → 화이트리스트면 초안·발송까지 무인. **현재 구현**: 클라이언트가 와카뷰 발송을 시도할 때 서버가 화이트리스트면 `approved` 없이 통과시키는 **발송 게이트**. Phase 1 베타 직전 최소는 게이트를 유지하되, UI/카피로 “상대가 오면 알아서 답한다”고 오해되지 않게 하고, **수신 트리거 자동응대는 별도 로드맵 항목**으로 분리 | 2026-08-03 실사용에서 L2+주제 등록 후에도 ✨ 없이는 응답이 없어 혼란 | 제안 |
## 아직 열려 있는 하위 질문 (PoC/실사용 의존 — D 이후) ## 아직 열려 있는 하위 질문 (PoC/실사용 의존 — D 이후)
- 와카뷰 응답 임계점·화이트리스트 기본 주제 목록 (실사용 데이터) - 와카뷰 응답 임계점·화이트리스트 기본 주제 목록 (실사용 데이터)
- 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰) - 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰)
- 상표 정식 등록 여부 (Q6은 "와카뷰"로 명칭 확정, 등록 절차는 베타 반응 이후) - 상표 정식 등록 여부 (Q6은 "와카뷰"로 명칭 확정, 등록 절차는 베타 반응 이후)
- 베타 참가자 모집 규모와 방식 - 베타 참가자 모집 규모와 방식
- Q9 제안 → Master 확정 (Q8은 2026-08-03 확정)

View File

@ -17,13 +17,6 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
- [x] 스택 확정: Flutter + Go core + Python AI - [x] 스택 확정: Flutter + Go core + Python AI
- [x] `core-backend/` · `ai-service/` · 하드게이트·거부권·초대·되돌리기·L0~L2 QA - [x] `core-backend/` · `ai-service/` · 하드게이트·거부권·초대·되돌리기·L0~L2 QA
- [x] Flutter UI 테마 폴리시 (`app_theme` + 화면별 시각 개선) - [x] Flutter UI 테마 폴리시 (`app_theme` + 화면별 시각 개선)
- [x] UI 2차 리디자인 — "아우로라 글래스"로 강화: 채도 높인 그라디언트+블러 글로우 orb, 고대비
글래스 카드/그림자, `BrandMark`/`GradientText`/`PrimaryGradientButton` 신규 위젯으로 스플래시·
가입·온보딩·대화목록 재적용. 실제 Flutter SDK 설치해 로컬 빌드+스크린샷(가입→온보딩→대화목록→
채팅)으로 검증, 부수적으로 onboarding 헤더 뱃지가 `ListView` 직계 자식이라 전체폭으로 늘어나던
기존 레이아웃 버그도 발견해 수정 (`Align`로 감쌈). **주의: GitHub `main`에는 반영됐지만
`msn.iykyka.com` 프로덕션 재배포는 안 됨** — 이 작업 세션은 iykyka 호스트 접근 권한이 없어
`scripts/server-up.sh` 재기동은 Master가 해당 호스트에서 직접 해야 함
### DONE — Cursor 후속 ### DONE — Cursor 후속
@ -37,22 +30,14 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
- 앱 코드는 클로즈드 베타 직전 수준 - 앱 코드는 클로즈드 베타 직전 수준
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료** - **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
- **웹 고도화:** [`web-upgrade.md`](./web-upgrade.md) — W0~W7 구현·배포 **done** · **W4 accept만** (하드 리프레시·알림·push-test) - 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA
- 후순위: **N4 Android FCM/UI**`google-services.json` PC 배치·실기기 (서버 SA는 배치됨) - 실 FCM 전송·Android 실기기 탭 · 사람 PoC 실행은 남음
- UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영
- **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대)
- **Q8 계정·설정 IA (2026-08-03 확정·구현):** 로그인 탭 · 설정 허브 · 로그아웃
- **문서 갭 잔여:** L2 의미 [`decision-log.md`](./decision-log.md) Q9 **제안**
[`account-settings-ia.md`](./account-settings-ia.md) §3. Master 확정 전 Q9 구현 금지
(**웹 트랙에도 Q9 비범위**)
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음 (Android 트랙)
### NEXT 순서 ### NEXT 순서
``` ```
N1 스모크 → N2 Docker(msn.iykyka.com) → N3 배포 안정화 N1 스모크 → N2 Docker(msn.iykyka.com) → N3 배포 안정화
→ N4-W 웹 고도화(W1→…→W7) 병행 가능 → N4 FCM·Android QA 등 → N5 사람 PoC(D) → 실제 베타 오픈
→ N4 Android FCM·UI QA 등 → N5 사람 PoC(D) → 실제 베타 오픈
``` ```
### LOCKED ### LOCKED
@ -175,61 +160,14 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
| **N4-B3** | 가입 화면 페어링 안내 | done | Signup 데모 패널에 한 줄 팁 | | **N4-B3** | 가입 화면 페어링 안내 | done | Signup 데모 패널에 한 줄 팁 |
| **N4-B4** | 프로덕션 core+web 재배포 | done | `/demo` pairing_steps OK (`c7029ab`) | | **N4-B4** | 프로덕션 core+web 재배포 | done | `/demo` pairing_steps OK (`c7029ab`) |
### Track C — 콘텐츠 갭 (PRD P0 대비 미구현, 2026-07-31 발견) ### FCM — [`fcm-setup.md`](./fcm-setup.md)
`roadmap.md` §2.7과 동일 항목. `PRD.md` §3.1 P0 표 대조 결과 발견(2026-07-31), 2026-08-03
2차 재분석으로 D/E/F 추가. **우선순위: C1(단톡 따라잡기) → C2(관계별 페르소나) → C3(스팸 감지)
→ C4(자율성 상대별 예외) → C5(관계 메모 반영) → C6(답장 마감 알림)** — 단톡 따라잡기는 v1 MVP
시나리오 2개 중 하나인데 현재 0% 구현이라 완성도 공백이 가장 큼; C4는 P0 명시 항목이지만
오버라이드 메커니즘 자체가 없어 C1~C3 다음; C5는 필드/UI가 이미 있고 프롬프트 주입만 빠져
비용이 가장 작음; C6은 P1이자 신규 구현 분량이 가장 커서 맨 마지막.
| ID | 작업 | Status | 완료 조건 | | ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------| |----|------|--------|-----------|
| **N4-C1a** | 그룹 대화 생성 UI(복수 상대) | **done** (2026-08-03) | "새 대화" 다이얼로그가 상대 여러 명 입력 받아 `is_group:true`로 생성 | | **N4-1** | Firebase + `google-services.json` | blocked | Master: Console 앱 + `mobile/android/app/google-services.json` (git 금지) |
| **N4-C1b** | `GET /conversations/:id/summary` | **done** (2026-08-03) | `core-backend/group_summary.go` + 읽음 마커(`POST /conversations/:id/read`) |
| **N4-C1c** | 단톡 요약 생성(멘션/결정사항 3~5줄) | **done** (2026-08-03) | `ai-service/app/summarize.py` + `POST /summarize` |
| **N4-C1d** | 요약→초안 버튼 연결 (L0 고정) | **done** (2026-08-03) | `chat_screen.dart`. 서버가 그룹 대화 트윈 발송을 전역 레벨과 무관하게 항상 차단 |
| **N4-C1e** | "안 본 동안" 배지 | **done** (2026-08-03) | 대화 목록에 서버 계산 `unread_count` 표시 |
| **N4-C2a** | `relationship_tier` 필드(가까운/공식적) | **done** (2026-08-03) | `core-backend/models.go` `TwinSettings`(전역 기본값)·`Contact`(상대별 오버라이드) 확장 |
| **N4-C2b** | 온보딩 관계 티어 선택 스텝 | **done** (2026-08-03) | `onboarding_tone_screen.dart`, 말투 샘플 다음에 전역 기본값 선택 |
| **N4-C2c** | 연락처별 관계 티어 오버라이드 | **done** (2026-08-03) | `contacts_screen.dart` `_RelationshipTierPicker`, 자율성 설정 화면에 전역 기본값 변경 UI |
| **N4-C2d** | 초안 생성 시 티어별 톤 프롬프트 분기 | **done** (2026-08-03) | `ai-service/app/generation.py` `RELATIONSHIP_TIER_INSTRUCTIONS` + `core-backend/persona.go` `resolveRelationshipTier()`(연락처 오버라이드 → 전역 기본값 → `formal`) |
| **N4-C3a** | 짧은 시간 내 동일 상대 도배 감지 → 응대 일시중단 | **done** (2026-08-03) | `core-backend/flood_detect.go`(`floodMessageThreshold=5`건/`floodWindow=2분`, 안전 최소값 placeholder) + `main.go` `POST /conversations/:id/messages`의 peer-veto·그룹차단 다음, 에스컬레이션 이전 지점(우회 불가). `Conversation.TwinDisabledByFlood`로 대화방 단위 차단, `POST /conversations/:id/flood-reset`로 재개(거부권과 달리 되돌리기 가능) |
| **N4-C3b** | 도배 중단 시 사후 알림 | **done** (2026-08-03) | 기존 `EscalationLog`/`InboxScreen` 그대로 재사용(신규 알림 경로 없음). 대화 목록·채팅방 배너에 상태 표시 + "자동응대 재개" one-tap undo 버튼 추가 |
| **N4-C4a** | `Contact.AutonomyLevel` 오버라이드 필드 | **done** (2026-08-03) | `core-backend/models.go`, `RelationshipTier`와 동일 패턴(nil = 전역 기본값) |
| **N4-C4b** | 자율성 레벨 해석 함수 + 발송 게이트 교체 | **done** (2026-08-03) | `core-backend/autonomy_resolve.go` `resolveAutonomyLevel()`(연락처 오버라이드 → 전역 기본값 → `L0`, `resolveRelationshipTier`와 동일 구조). `main.go` `POST /conversations/:id/messages`가 전역값만 읽던 부분을 이 함수 호출로 교체 — peer-veto·그룹차단·도배 감지·에스컬레이션 순서는 그대로, "level" 계산만 교체. 그룹 대화는 이 함수 자체도 전역 기본값만 쓰고, 그 전에 걸리는 무조건 차단과 이중으로 안전 |
| **N4-C4c** | 연락처별 자율성 오버라이드 UI | **done** (2026-08-03) | `contacts_screen.dart` `_AutonomyLevelPicker`(`_RelationshipTierPicker` 옆, 기본값 사용/L0/L1/L2 4-way 칩), 연락처 목록 서브타이틀에도 표시 |
| **N4-C5a** | `draftRequest``RelationshipNote` 필드 추가 | **done** (2026-08-03) | `core-backend/aiservice.go`, `relationship_tier``omitempty`, 빈 문자열 = 무영향 |
| **N4-C5b** | draft 핸들러가 연락처 메모 조회해 전달 | **done** (2026-08-03) | `main.go` `POST /conversations/:id/draft` + `core-backend/persona.go` `resolveRelationshipNote()`. 그룹은 항상 빈 문자열(전역 기본 메모 개념 자체가 없음, 티어/자율성과 다른 지점). `resolveRelationshipTier`/`resolveAutonomyLevel`과 공유하는 `findCounterpartContact()` 헬퍼로 3중 복붙 제거 |
| **N4-C5c** | 메모를 톤 프롬프트에 주입 | **done** (2026-08-03) | `ai-service/app/generation.py` `system_prompt_for_tier(relationship_tier, relationship_note)``"[관계 메모] {note} -- ..."` 문단 추가(빈 값/`None`이면 무영향, 관계 티어 지침과 별도 문단이라 안 섞임). 에스컬레이션/정체성 게이팅에는 영향 없음 |
| **N4-C6a** | "이따 답장" 스누즈 저장 | **done** (2026-08-03) | 순수 온디바이스 — `mobile/lib/db/tables.dart` `ConversationSnoozes` drift 테이블(서버 전송 없음), `chat_screen.dart` 앱바 "이따 답장" → 빠른 선택(1시간 후/저녁에/내일) + 해제, 실제 답장 전송 시 자동 해제 |
| **N4-C6b** | 리마인드 로컬 알림(본인에게만) | **done (부분 검증)** (2026-08-03) | `flutter_local_notifications`+`timezone` 실제 연동(`mobile/lib/services/snooze_notification_service_native.dart` `zonedSchedule`/`cancel`) — **단, 이 샌드박스에는 실기기/에뮬레이터가 없어 알림이 실제로 울리는지/탭 동작은 검증 불가**. 검증한 건 스케줄러를 목(mock)으로 바꿔 id·시각·페이로드가 올바른지뿐(`test/snooze_controller_test.dart`). 웹 빌드는 플러그인이 웹 미지원이라 `snooze_notification_service_web.dart` no-op 스텁. 실기기 검증 전까지는 대화 목록/채팅방의 인앱 배지·배너(C6c)가 실질적인 리마인드 경로 |
| **N4-C6c** | 스누즈 표시/취소 UI | **done** (2026-08-03) | `chat_screen.dart`(앱바 아이콘 상태 + 마감 지난 스누즈 배너 + 해제 버튼), `conversation_list_screen.dart`(서브타이틀 아래 "답장 마감" 배지, 탭하여 바로 해제). 마감 판정은 순수 함수 `isSnoozePastDue()`로 분리해 고정 시각 단위 테스트(`test/snooze_service_test.dart`) |
### N4-W — 웹 고도화 — [`web-upgrade.md`](./web-upgrade.md)
데모/프리뷰 표면 유지. **Q9 비범위.** Wi 단위 승인 후 구현. 서버 FCM SA는 Android와 공유.
| ID | 작업 | Status | 비고 |
|----|------|--------|------|
| **N4-W0** | 제품 경계 · 문서 랜딩 | **done** (2026-08-04) | 성공 정의·비범위·규율 고정 |
| **N4-W1** | 웹 로컬 지속성 (prefs/idb) | **done** (2026-08-04) | `app_database_web.dart` SharedPreferences · Drift WASM 비채택 · 라이브 하드 리프레시 스모크는 배포 후 |
| **N4-W2** | 데모 메신저 UX | **done** (2026-08-04) | ≥900px 분할·WS 상태·ID 복사·웹 안내 배너 |
| **N4-W3** | 인앱·Notification API | **done** (2026-08-04) | 웹 Timer+Notification · 포커스 past-due · 설정 권한 |
| **N4-W4** | Web Push (FCM + VAPID) | **web-live** (2026-08-04) | 시크릿·`:web:`·SW 배포 done · **accept만:** 하드 리프레시·알림·실 토큰·push-test |
| **N4-W5** | PWA | **done** (2026-08-04) | manifest 테마 · nginx no-cache · OfflineBanner |
| **N4-W6** | 테스터 가이드 | **done** (2026-08-04) | `tester-guide.md` 웹 절 |
| **N4-W7** | 품질 | **done** (2026-08-04) | `scripts/e2e_web_smoke.sh` + 부록 QA (Playwright는 후속) |
### FCM (Android) — [`fcm-setup.md`](./fcm-setup.md)
| ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------|
| **N4-1** | Firebase + `google-services.json` | doing | Master: 앱 등록됨 · JSON을 Android 빌드 PC에 배치 |
| **N4-2** | 실 FCM registration token | done* | `PushTokenService` — Firebase 있으면 실 토큰, 없으면 `install:` (*전송은 N4-1 후) | | **N4-2** | 실 FCM registration token | done* | `PushTokenService` — Firebase 있으면 실 토큰, 없으면 `install:` (*전송은 N4-1 후) |
| **N4-3** | 서버 FCM 자격증명 (HTTP v1) | **done** (2026-08) | 프로덕션 `secrets/firebase-service-account.json` 마운트됨. 웹 W4와 공유 | | **N4-3** | 서버 FCM 자격증명 | blocked | Master: 호스트 `.env``FCM_SERVER_KEY`만 |
| **N4-4** | 푸시 수신 (Android) | blocked | N4-1+N4-3 후 `/admin/push-test` + 기기 수신 | | **N4-4** | 푸시 수신 | blocked | N4-1+N4-3 후 `/admin/push-test` + 기기 수신 |
### Android UI 탭 (`mobile/README.md`) ### Android UI 탭 (`mobile/README.md`)
@ -249,11 +187,11 @@ API 계층은 프로덕션에서 검증됨 (`CORE_API_BASE=https://msn.iykyka.co
| ID | 작업 | Status | 비고 | | ID | 작업 | Status | 비고 |
|----|------|--------|------| |----|------|--------|------|
| **N4-11** | 오프라인 메시지 큐 | **done (부분 검증)** (2026-08-03) | 서버는 이미 모든 메시지를 DB에 durable하게 저장 — 별도 큐를 새로 만들지 않고 `GET /conversations/:id/messages?since_id=`(신규, 파라미터 없으면 기존 전체 히스토리 그대로) + 클라이언트 WS 재연결(backoff 1s→2s→...→30s 캡, 성공 시 리셋, `mobile/lib/services/ws_client.dart`) + 재연결/앱 포그라운드 복귀 시 since_id 캐치업(`mobile/lib/screens/chat_screen.dart`)으로 gap을 메움. `core-backend/a1_a2_test.go`(`TestListMessagesSinceID`)와 `mobile/test/message_sync_test.dart`(backoff 계산 + 중복 없는 병합을 순수 함수로 뽑아 검증)로 확인한 부분과, **실제 소켓 재연결 타이밍·앱 백그라운드/포그라운드 전환에서 OS가 소켓을 어떻게 처리하는지는 단위 테스트로 증명 불가 — 실기기 Android QA 남음**(N4-C6b와 같은 프레이밍) | | **N4-11** | 오프라인 메시지 큐 | todo | 멀티디바이스 고도화 |
| **N4-12** | 자연스러움 피드백 UI | **done (계측만)** (2026-08-03) | vision 지표. **PoC 실행/결론이 아니라 캡처 장치** — 실제 %는 N5 이후 실 사용 데이터로. 암묵 신호: `core-backend/models.go` `Message.DraftEdited`(nullable — 사람 메시지/구버전 클라는 nil, 트윈 승인-발송 경로에서 클라이언트가 `original_draft_text`를 같이 보내면 서버가 diff해 true/false) + `mobile/lib/screens/chat_screen.dart` `_sendTwinApproved``_pendingDraft.text`(편집 전 원본)를 함께 전송. 명시 신호: `Message.NaturalnessRating`(nullable bool) + `POST /messages/:id/feedback`(트윈 메시지만, 400 아니면 재제출은 덮어씀 — 409 아님) + `chat_screen.dart`/`message_bubble.dart`의 "이 답장 나답아요?" 원탭 👍/👎(한 번 탭하면 그 세션에서 다시 안 보여줌, `_ratedMessageIds` + `message_sync.dart``ratedMessageIdsFrom()`). `/admin/metrics``draft_unedited_rate`/`naturalness_positive_rate`(둘 다 분모 0일 때 0으로 zero-guard, `peer_veto_rate`와 동일 패턴) 추가, `adminDashboardHTML`에 카드 2개 추가. 백엔드 테스트 8개(`core-backend/main_test.go`) + 모바일 테스트 5개(`mobile/test/`) 추가, 전부 통과 | | **N4-12** | 자연스러움 피드백 UI | todo | vision 지표 |
| **N4-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 | | **N4-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 |
| **N4-14** | 내부 release APK | todo | `docs/android-release.md` | | **N4-14** | 내부 release APK | todo | `docs/android-release.md` |
| **N4-15** | roadmap/`[~]` 동기화 | **done** (2026-08-03) | `roadmap.md` §2.1 "푸시 알림 서비스 연동"(낡은 중복 표기 → §B/N4-1~4로 교차참조), §4-4번 "남은 것"(완료된 "C 배포 준비" 제거), §2.3 온보딩 뼈대(관계 티어 스텝 추가 반영), §2.5 수동 QA 항목(N4-5~10/N4-C6b/N4-11 교차참조)을 실제 코드 상태와 맞춤. 나머지 `[~]`(FCM 푸시, 멀티디바이스 동기화, 데이터 프라이버시, L2 카피/수신트리거)는 실제로 아직 Master 액션·Q9 확정 대기 중이라 그대로 유지 — 허위로 채우지 않음 | | **N4-15** | roadmap/`[~]` 동기화 | todo | 완료 시 체크 |
--- ---
@ -279,19 +217,7 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P
2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터) 2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터)
3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드) 3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드)
4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브) 4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브)
5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03), 5. ~~N3 안정화 + Track A/B~~ **done** — 다음: **Master FCM 시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA (N4-5~10)
~~Track C2 관계별 페르소나~~ **done** (2026-08-03), ~~Track C3 스팸/도배 감지~~ **done**
(2026-08-03) — **Track C 콘텐츠 갭 A/B/C 전체 완료.** 2026-08-03 2차 재분석으로 D/E/F 추가
발견, ~~Track C4 자율성 상대별 예외~~ **done** (2026-08-03), ~~Track C5 관계 메모 반영~~
**done** (2026-08-03), ~~Track C6 답장 마감 알림~~ **done(부분 검증)** (2026-08-03) —
**2026-08-03 2차 갭 분석 배치(C4/C5/C6) 전체 구현 완료.** C6은 온디바이스 스누즈
저장·UI는 완전히 검증(단위 테스트)됐지만, 실제 OS 로컬 알림이 실기기에서 울리는지는
이 샌드박스(실기기/에뮬레이터 없음)에서 검증 불가 — Android UI QA(N4-5~10)에서 처음
확인 필요, 그때까지는 인앱 배지·배너가 실질적 대체 경로. Track C A~F 프로덕션
재배포는 **완료** (`95422bb` / docs `f363e8e`, 2026-08-03).
**바로 다음:** 웹 **W4 accept**`msn.iykyka.com` 하드 리프레시·알림 허용·로그인 후 `/admin/push-test` · 또는 Android N4-1/4.
웹 W0~W7 코드·`:web:` 시크릿 배포·서버 SA **done** (브라우저 실토큰·push-test만 남음).
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.

View File

@ -1,58 +1,55 @@
# FCM 설정 (N4-1 ~ N4-4 · N4-W4) # FCM 설정 (N4-1 ~ N4-4)
서버·클라이언트의 푸시 **코드 경로는 준비됨**. 서버·클라이언트의 푸시 **코드 경로는 준비됨**. 실제 전송은 Master가 Firebase 시크릿을
새 Firebase 프로젝트는 **레거시 서버 키가 비활성**인 경우가 많아, **HTTP v1 + 서비스 계정 JSON**을 쓴다. 넣기 전까지 soft-skip 한다 (`install:` 토큰·`FCM_SERVER_KEY` 없음).
## 이미 된 것 (코드 · 프로덕션) ## 이미 된 것 (코드)
| 계층 | 동작 | | 계층 | 동작 |
|------|------| |------|------|
| Flutter | `PushTokenService` — Firebase 가능하면 실 FCM 토큰, 아니면 `install:` 플레이스홀더 | | Flutter | `PushTokenService` — Firebase 가능하면 실 FCM 토큰, 아니면 `install:` 플레이스홀더 |
| Android | `google-services.json`이 있을 때만 Google Services 플러그인 적용 | | Android | `google-services.json`이 있을 때만 Google Services 플러그인 적용 |
| Web (N4-W4) | `FirebaseWebConfig` + 프로덕션 `.env` `FIREBASE_*`/`VAPID` 주입 · SW populated (`:web:` APP_ID) | | core-backend | `notifyUser` + `POST /admin/push-test``FCM_SERVER_KEY` + 실 토큰일 때만 전송 |
| core-backend | `notifyUser` + `POST /admin/push-test`**FCM HTTP v1**(서비스 계정) 우선 |
| 서버 SA (N4-3) | 프로덕션 `secrets/firebase-service-account.json` 마운트 **done** |
## Master가 할 일 ## Master가 할 일
### N4-1 — Firebase Android ### N4-1 — Firebase 앱
1. [Firebase Console](https://console.firebase.google.com/) → Android 앱 추가 1. [Firebase Console](https://console.firebase.google.com/)에서 프로젝트 생성 (또는 기존 사용).
package: **`com.ykavu.ykavu_mobile`** 2. Android 앱 추가 — package name: **`com.ykavu.ykavu_mobile`**
2. `google-services.json`을 **Android 빌드 PC**에만 배치 (git 금지): 3. 받은 `google-services.json`을 로컬에만 배치 (git 금지):
```bash ```bash
cp ~/Downloads/google-services.json mobile/android/app/google-services.json cp ~/Downloads/google-services.json mobile/android/app/google-services.json
``` ```
### N4-3 — 서비스 계정 JSON (HTTP v1) — **프로덕션 done** 템플릿: `mobile/android/app/google-services.json.example`
레거시 **서버 키**가 Cloud Messaging 탭에서 `사용 중지됨`이면 정상이다. ### N4-3 — 서버 키 (env only)
프로덕션 호스트에 이미 배치됨: Cloud Messaging **레거시 서버 키**(또는 호환 서버 키)를 호스트 `.env`에만 설정:
```bash ```bash
# ~/project/ykavu/secrets/firebase-service-account.json # 서버 ~/project/ykavu/.env
# compose: ./secrets → /secrets, FCM_SERVICE_ACCOUNT_FILE=/secrets/firebase-service-account.json FCM_SERVER_KEY=AAAA...
``` ```
새 환경에 다시 놓을 때:
```bash ```bash
mkdir -p secrets cd ~/project/ykavu
mv ~/Downloads/iykyka-*.json secrets/firebase-service-account.json docker compose up -d core-backend
chmod 600 secrets/firebase-service-account.json
docker compose up -d --build core-backend
``` ```
**git / 채팅에 JSON 내용을 붙여넣지 않는다.** **git / 이미지에 키를 넣지 않는다** (N2-A5).
### N4-2 / N4-4 — Android 실기기 스모크 > 레거시 HTTP API가 Console에서 비활성이면 HTTP v1 마이그레이션이 필요하다.
> 그 전까지는 레거시 키가 있는 프로젝트로 N4-4 스모크를 완료한다.
### N4-2 / N4-4 — 실기기 스모크
```bash ```bash
cd mobile cd mobile
flutter run --release --dart-define=CORE_API_BASE=https://msn.iykyka.com flutter run --release --dart-define=CORE_API_BASE=https://msn.iykyka.com
# 로그: device token registered (FCM) # 가입 → 로그에 "device token registered (FCM)" 확인
``` ```
```bash ```bash
@ -62,60 +59,11 @@ curl -sS -X POST https://msn.iykyka.com/admin/push-test \
-d '{"user_id": <USER_ID>, "title":"와카뷰","body":"push smoke"}' -d '{"user_id": <USER_ID>, "title":"와카뷰","body":"push smoke"}'
``` ```
기대: `sent >= 1`. 기대: `sent >= 1`, `skipped_reason` 없음. 기기에 알림 표시.
`only_placeholder_tokens` → Android에 `google-services.json` 넣고 재설치.
`fcm_not_configured` → 서비스 계정 경로 확인.
## Web (N4-W4) `only_placeholder_tokens` / `fcm_not_configured` 이면 위 N4-1·N4-3을 다시 확인.
설계·수락: [`web-upgrade.md`](./web-upgrade.md) §N4-W4 · 실행 표: [`deploy-checklist.md`](./deploy-checklist.md) §N4-W. ## Web
**프로덕션:** Firebase Web config + VAPID + `:web:` APP_ID가 서버 `.env`에 있고 `web` 이미지에 빌드 주입됨. 현재 프로덕션 UI는 Flutter Web. Web 푸시는 Firebase Web 설정 + VAPID가 추가로 필요하며
SW(`firebase-messaging-sw.js`) populated. 서버 SA는 Android와 공유. 이번 N4 범위에서는 **Android 실푸시**를 우선한다. Web은 계속 `install:` 플레이스홀더를 등록한다.
### 남은 accept (브라우저)
1. `https://msn.iykyka.com` **하드 리프레시** (또는 사이트 데이터 삭제)
2. 알림 **허용** → 로그인
3. device_tokens에 실 토큰(`platform=web`, `install:` 아님)
4. `/admin/push-test``sent >= 1` (탭 백그라운드에서도 알림)
로컬/스테이징에서 define이 비어 있으면 웹은 `install:`만 등록 → `only_placeholder_tokens`가 정상.
### 새 환경에 Web 시크릿을 넣을 때
배포 호스트 **`~/project/ykavu/.env`**(클라우드 에이전트 `/workspace/.env`와 별개):
```bash
FIREBASE_API_KEY=
FIREBASE_AUTH_DOMAIN= # 비우면 {projectId}.firebaseapp.com
FIREBASE_PROJECT_ID=
FIREBASE_STORAGE_BUCKET= # 비우면 {projectId}.appspot.com
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_APP_ID= # must be 1:…:web:… (not :android:)
FIREBASE_VAPID_KEY=
```
```bash
docker compose up -d --build web
```
### 로컬 Flutter web
```bash
cd mobile
flutter run -d chrome \
--dart-define=CORE_API_BASE=http://localhost:8080 \
--dart-define=FIREBASE_API_KEY=... \
--dart-define=FIREBASE_APP_ID=... \
--dart-define=FIREBASE_MESSAGING_SENDER_ID=... \
--dart-define=FIREBASE_PROJECT_ID=... \
--dart-define=FIREBASE_VAPID_KEY=...
```
### 구현 위치
- `mobile/lib/config/firebase_web_config.dart`
- `mobile/lib/services/push_token_service.dart` (`_tryFirebaseWebToken`)
- `mobile/web/firebase-messaging-sw.js` (+ `.template`, `scripts/docker_build_web.sh`)
- `mobile/Dockerfile` / `docker-compose.yml` build-args

View File

@ -35,17 +35,11 @@
**2.1 코어 백엔드** (Go, PoC 결과 무관 — 지금 착수 가능) **2.1 코어 백엔드** (Go, PoC 결과 무관 — 지금 착수 가능)
- [x] 계정/인증 (초대 코드 기반 가입) — `core-backend/` (Go, Gin), 중복 코드 409 실제 테스트로 확인함 - [x] 계정/인증 (초대 코드 기반 가입) — `core-backend/` (Go, Gin), 중복 코드 409 실제 테스트로 확인함
- [x] 메시지 릴레이 서버 (송수신) — `core-backend/` WebSocket + REST, 실제 테스트로 브로드캐스트 확인함. - [x] 메시지 릴레이 서버 (송수신) — `core-backend/` WebSocket + REST, 실제 테스트로 브로드캐스트 확인함.
멀티 디바이스 동기화(같은 유저 여러 기기)는 아직 — 지금은 대화방 단위 인메모리 커넥션 매니저뿐. 멀티 디바이스 동기화(같은 유저 여러 기기)는 아직 — 지금은 대화방 단위 인메모리 커넥션 매니저뿐
오프라인 큐(deploy-checklist N4-11)는 DB가 이미 모든 메시지를 durable하게 들고 있으므로
서버에 별도 큐를 새로 만들지 않고, `GET /conversations/:id/messages?since_id=`로 재연결 시
gap을 메우는 방식으로 구현함(Go 테스트로 확인) — 자세한 내용은 §4 N4-11 항목 참고
- [x] DB 스키마: users, invite_codes, contacts, conversations, messages, twin_settings, - [x] DB 스키마: users, invite_codes, contacts, conversations, messages, twin_settings,
escalation_logs, whitelist_rules — `core-backend/models.go` (GORM), `backend/app/models.py` escalation_logs, whitelist_rules — `core-backend/models.go` (GORM), `backend/app/models.py`
(Python 프로토타입)와 동일 스키마(+ invite_codes는 여기서 새로 추가) (Python 프로토타입)와 동일 스키마(+ invite_codes는 여기서 새로 추가)
- [~] 푸시 알림 서비스 연동 — **2026-08-03 동기화**: 이 항목은 오래 전에 적힌 뒤 갱신 안 된 - [ ] 푸시 알림 서비스 연동
중복 표기였음. 실제 진행 상황은 §B "FCM 푸시 연동"과 `deploy-checklist.md` N4-1~4에서 추적
중 — 서버 쪽(`notifyUser`, `/admin/push-test`, 토큰 등록)은 구현·테스트 완료, 남은 건 Master의
Firebase 시크릿 배치(N4-1/3)와 그 이후 실기기 수신 확인(N4-4)뿐
`backend/`(Python 프로토타입)는 그대로 참고용으로 남겨둔다 — `core-backend/`(Go)가 실제로 쓰는 것. `backend/`(Python 프로토타입)는 그대로 참고용으로 남겨둔다 — `core-backend/`(Go)가 실제로 쓰는 것.
@ -78,8 +72,7 @@
- [x] 기본 채팅 UI (대화 목록, 대화방) — `GET`/`POST /conversations`, 히스토리 로드, - [x] 기본 채팅 UI (대화 목록, 대화방) — `GET`/`POST /conversations`, 히스토리 로드,
WebSocket 수신·인간 메시지 전송. 연락처 화면에서 DM 시작 WebSocket 수신·인간 메시지 전송. 연락처 화면에서 DM 시작
- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 가입 + 말투 샘플 입력 UI(`OnboardingToneScreen`, - [~] 온보딩 플로우(5분 온보딩) 뼈대 — 가입 + 말투 샘플 입력 UI(`OnboardingToneScreen`,
기기 로컬) + 관계 티어 기본값 선택(2.7-B, 2026-08-03 추가). 말투 학습 UX 디테일·카피는 기기 로컬). 말투 학습 UX 디테일·카피는 **사람 PoC #1 결과를 맨 마지막에 반영**
**사람 PoC #1 결과를 맨 마지막에 반영** — 이 부분만 여전히 열려 있음
- [x] 와카뷰 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는 - [x] 와카뷰 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는
서버/AI 프롬프트 측과 이어서 다듬을 것 서버/AI 프롬프트 측과 이어서 다듬을 것
- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외는 - [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외는
@ -116,10 +109,7 @@
구현과 함께: L0 차단, L1 미승인 차단/승인 시 발송, L2 화이트리스트 매칭 자동발송/비매칭 시 승인 구현과 함께: L0 차단, L1 미승인 차단/승인 시 발송, L2 화이트리스트 매칭 자동발송/비매칭 시 승인
필요, 에스컬레이션이 레벨·화이트리스트·승인 여부와 무관하게 항상 우선한다는 것까지 6개 케이스 필요, 에스컬레이션이 레벨·화이트리스트·승인 여부와 무관하게 항상 우선한다는 것까지 6개 케이스
전부 실제 테스트로 확인함 전부 실제 테스트로 확인함
- [ ] 온보딩·채팅·설정 수동 QA — `mobile/` 골격 위에 실기기/에뮬레이터로 진행 (후속). API 계층은 - [ ] 온보딩·채팅·설정 수동 QA — `mobile/` 골격 위에 실기기/에뮬레이터로 진행 (후속)
이미 검증됨(`deploy-checklist.md` N4-5~10 "api-done") — 남은 건 화면 탭뿐. 그중 N4-C6b(스누즈
로컬 알림)·N4-11(오프라인 재연결)은 이 세션 도구엔 실기기/에뮬레이터가 없어 순수 로직만
단위테스트로 확인했고 실제 동작 확인은 이 수동 QA 라운드에서 처음 이뤄짐
**2.6 베타 배포 준비** **2.6 베타 배포 준비**
- [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라 - [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라
@ -128,164 +118,14 @@
409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. 발급자 인증은 409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. 발급자 인증은
`ADMIN_API_TOKEN`(A2). 운영 절차·만료/회수는 C (`docs/invite-ops.md`) `ADMIN_API_TOKEN`(A2). 운영 절차·만료/회수는 C (`docs/invite-ops.md`)
- [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은 - [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은
`/admin/metrics``peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). **2026-08-03 `/admin/metrics``peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움
자연스러움 계측 인프라 추가(deploy-checklist.md N4-12, 아래 참고)**: 암묵 신호(초안 무수정 피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기
발송률)와 명시 신호(원탭 "이 답장 나답아요?" 피드백) 둘 다 캡처되어 `/admin/metrics` 보다 지금까지의 하드게이트 테스트들이 이미 보증하는 것 — 별도 계측 불필요
집계된다. **주의 — 이건 PoC 실행이나 실제 수치 결론이 아니라 계측/캡처 장치일 뿐이다.** - [~] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) — `GET /admin/metrics`
실제 자연스러움 %는 실 베타 사용 데이터가 쌓여야 나온다(N5 이후). 안전선 위반 0건은 런타임에 카운트 기반 데이터(메시지 수, 에스컬레이션 사유별 집계, 거부권 발동률, 초대 코드 발급/사용 수)는
"수집"하는 지표라기보다 지금까지의 하드게이트 테스트들이 이미 보증하는 것 — 별도 계측 불필요 노출함. **대시보드 UI 자체와 생성 지연시간·오류율**은 아직 없음 — UI는 Flutter/관리자 웹 쪽이고,
- [x] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) — **2026-08-03 재확인**: 지연시간·오류율은 요청 타이밍/로깅 계측 계층이 따로 필요해서 이번엔 만들지 않음(허위로 채우지
이 항목의 `[~]` 상태와 "대시보드 UI 자체와 생성 지연시간·오류율은 아직 없음" 설명은 실제로는 않고 명시적으로 비워둠)
낡은 기록이었음(§B의 "생성 지연시간·오류율 계측"/"모니터링 대시보드(최소)" `[x]` 항목과 서로
모순되고 있었던 걸 오늘 발견). 실제로는 이전 Phase 1 B 커밋에서 이미 `GET /admin/dashboard`
(HTML, `core-backend/b_routes.go``adminDashboardHTML`)와 그 위에서 읽는
`GET /admin/metrics`(`core-backend/main.go`, 카드: 사용자·메시지·에스컬레이션·거부권 발동률·
생성 지연시간 평균/최대·에스컬레이션 오류율 등)가 구현되어 있었음. 오늘 실제로 추가한 것은
두 가지뿐: (1) 트윈 발송 차단을 사유별로 분해하는 `twin_sends_blocked_by_reason`
(peer_veto/group_conversation/flood_blocked/flood_detected/escalate_check_error/escalated/
autonomy_l0/autonomy_l1_unapproved/autonomy_l2_no_whitelist_match/autonomy_unknown_level —
이전엔 전부 `twin_sends_blocked` 한 총합으로만 뭉쳐 있었음), (2) 이미 JSON에는 있었지만
대시보드 화면에는 안 보이던 `escalations_by_reason`과 새 `twin_sends_blocked_by_reason`
대시보드에 사유별 표로 렌더링. 정직하게 남겨둘 한계: 이 계측은 여전히 프로세스 메모리
전용(`RuntimeMetrics`)이라 재시작하면 리셋되는 근사치이고, 진짜 타임시리즈 DB가 아님 —
트렌드 분석이 필요해지면 별도 작업으로 남음
**2.7 콘텐츠 갭 — PRD 3.1 P0 대비 미구현 기능** (2026-07-31 발견, 2026-08-03 2차 재분석으로
2.7-D~F 추가)
`PRD.md` §3.1 P0 표와 실제 코드를 대조해 발견. **우선순위: 2.7-A → 2.7-B → 2.7-C → 2.7-D →
2.7-E → 2.7-F** (이유: 단톡 따라잡기는 v1의 2개 MVP 시나리오 중 하나인데 현재 0% 구현이라
완성도 공백이 가장 큼; 페르소나는 1:1·단톡 양쪽 초안 품질에 영향; 스팸 감지는 P0지만 "최소
버전"이라 상대적으로 작음; 자율성 상대별 예외는 P0에 명시된 항목인데 관계별 페르소나와 달리
오버라이드 메커니즘 자체가 없어서 우선순위 4; 관계 메모는 필드/UI는 이미 있고 프롬프트 주입
한 줄만 빠진 상태라 비용이 가장 작음; 답장 마감 알림은 P1이자 셋 중 가장 새로 만들어야 할
분량이 커서 맨 마지막).
**2.7-A 단톡 따라잡기** (`PRD.md` §2.3 — **완료** 2026-08-03)
- [x] 그룹 대화 생성 UI(복수 상대 추가) — `conversation_list_screen.dart`의 "새 대화" 다이얼로그가
동적으로 상대 필드를 추가/삭제할 수 있게 됨. 2명 이상이면 자동으로 `is_group:true`로 생성
- [x] `GET /conversations/:id/summary``core-backend/group_summary.go`. 참가자별 읽음 마커
(`ConversationParticipant.LastReadMessageID`)를 두고, 그 이후 메시지만 모아 AI 서비스로 전달.
`POST /conversations/:id/read`로 마커 갱신. `GET /conversations`에도 `unread_count` 추가
- [x] 요약 생성 — `ai-service/app/summarize.py`(신규), "나에게 멘션된 것/결정된 사항" 위주 3~5줄
+ 답장 필요 항목 표시(`PRD.md` §2.3-②). `POST /summarize` 엔드포인트. 읽기 전용이라 escalation/
identity 게이트 없음(아무것도 발송되지 않으므로)
- [x] 요약 내 "답장 필요" 항목 → 초안 버튼 연결 — `chat_screen.dart`의 안 본 동안 요약 다이얼로그에서
바로 `_requestDraft()`로 이어감. **그룹 대화는 전역 자율성 레벨과 무관하게 서버(`main.go`)가
트윈 발송 자체를 항상 차단** — 클라이언트도 L0 취급으로 렌더링(PRD §2.3-③, 안전 불변식 유지)
- [x] "안 본 동안" 배지(마지막 읽음 이후 새 메시지 수) — `conversation_list_screen.dart`, 서버가
계산한 `unread_count`를 그대로 표시
- **주의**: 읽음 마커는 채팅방에 "들어올 때"가 아니라 "나갈 때"(`dispose()`) 찍는다 — 들어오는
순간 찍으면 그 방을 여는 즉시 안 본 게 0이 되어 "안 본 동안 요약"이 항상 빈 결과만 보여주는
버그가 생김(실제 Playwright로 스크린샷 찍어보다가 발견). 실시간 소켓 메시지는 화면이 열려
있는 동안은 계속 읽음으로 따라가도 됨
**2.7-B 관계별 페르소나** (`PRD.md` §2.1-②·§3.1, 최소 2종 — **완료** 2026-08-03)
- [x] `relationship_tier` 필드 추가(가까운 사이/공식적인 사이, 전역 기본값) — `TwinSettings`
추가, 신규 유저 기본값은 안전 우선으로 `formal`. `Contact`에도 상대별 오버라이드 필드 추가
- [x] 온보딩에 관계 티어 선택 스텝 추가 — `onboarding_tone_screen.dart`, 말투 샘플 다음에 전역
기본값 선택(`SegmentedButton`)
- [x] 연락처별 관계 티어 오버라이드 — `contacts_screen.dart` 연락처 추가/수정 다이얼로그에
`_RelationshipTierPicker`(기본값 사용/가까운 사이/공식적인 사이 3-way 칩), 자율성 화면에도
전역 기본값 변경 UI 추가
- [x] 초안 생성 시 상대 티어별 톤 프롬프트 분기 — `ai-service/app/generation.py`
`RELATIONSHIP_TIER_INSTRUCTIONS` + `system_prompt_for_tier()`. `core-backend`
`POST /conversations/:id/draft` 호출마다 `resolveRelationshipTier()`로 해석(연락처 오버라이드
→ 전역 기본값 → `formal`) 해서 `ai-service`로 전달. 그룹 대화는 상대가 여럿이라 항상 전역
기본값만 사용. 이 엔드포인트가 원래 인증을 요구하지 않던 걸 깨지 않도록 `currentUser(..., false)`
선택적 인증 처리 — 토큰 없는 기존 호출도 그대로 동작(티어 해석만 스킵)
**2.7-C 스팸/도배 감지 최소 버전** (`PRD.md` §4 엣지케이스: "안전 관련이라 v1 최소 버전 필요"로
명시 — **완료** 2026-08-03)
- [x] 짧은 시간 내 동일 상대 메시지 N건 초과 시 자동응대 일시중단 — `core-backend/flood_detect.go`
(`floodMessageThreshold = 5`건 / `floodWindow = 2분`, **PoC 검증값이 아니라 안전 최소값으로
명시한 v1 placeholder** — §3 "PoC 결과가 있어야 정할 수 있는 것"과는 성격이 다른, 반드시
있어야 하는 기술적 안전장치라 임시값으로 우선 구현). `main.go`의 `POST
/conversations/:id/messages`에서 peer-veto → 그룹 대화 차단 다음, 에스컬레이션 하드게이트
이전 지점에 추가 — 트윈 발송 시도 전체가 지나가는 동일 우회 불가 지점. 카운트는 대화방 내
`sender_id != 소유자`인 메시지(=상대가 보낸 것, 트윈 자동발송·소유자 본인 발송 모두 제외)만
집계. `Conversation.TwinDisabledByFlood`로 대화방 단위 영구 차단(거부권과 동일 저장 패턴) —
단, 거부권과 달리 **사람의 선택이 아닌 시스템의 자동 조치**라 되돌릴 수 있어야 해서(AGENTS.md
"every automatic action needs post-hoc notification + one-tap undo") `POST
/conversations/:id/flood-reset`로 재개 가능(거부권은 v1에서 되돌리기 API 없음, 의도적으로 다름)
- [x] 중단 시 사후 알림 — 기존 `EscalationLog`/`InboxScreen` 스키마·UI 그대로 재사용(신규 알림
경로 없음). 대화 목록(`conversation_list_screen.dart`)에도 `twin_disabled_by_flood` 배지 추가
(거부권 배지와 같은 자리, 다른 아이콘). 재개(one-tap undo)는 `chat_screen.dart`의 배너에
"자동응대 재개" 버튼으로 노출 — 채팅방을 열면(목록에서 넘어온 초기 상태) 또는 발송 시도가
다시 차단되면 즉시 뜬다
**2.7-D 자율성 상대별 예외** (`PRD.md` §3.1 "자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외
설정", §4 엣지케이스 "사용자가 여러 상대에게 다른 자율성 레벨을 원함" — P0인데 현재 전역
`TwinSettings.AutonomyLevel` 하나뿐, `Contact`에 오버라이드 필드 자체가 없음, 2026-08-03 발견 —
**완료** 2026-08-03)
- [x] `Contact``AutonomyLevel *AutonomyLevel` 오버라이드 필드 추가(`RelationshipTier`와
동일 패턴, nil = 전역 기본값 사용) — `core-backend/models.go`
- [x] 자율성 레벨 해석 함수 추가(연락처 오버라이드 → 전역 기본값 → `L0`, `resolveRelationshipTier`
와 동일 구조) — `core-backend/autonomy_resolve.go``resolveAutonomyLevel()`. 메시지
발송 게이트(`main.go` `POST /conversations/:id/messages`)가 `db.Where("user_id =
?", req.SenderID).First(&settings)`로 전역값만 읽던 걸 이 해석 함수 호출 한 줄로 교체 —
peer-veto·그룹 차단·도배 감지·에스컬레이션 하드게이트는 순서 그대로 유지, "level" 계산
방식만 바뀜. 실패 시 폴백은 `L1`/`L2`가 아니라 `L0`(이 코드베이스 전반의 안전 우선 기본값과
동일한 이유 — 알 수 없으면 항상 초안만 만들고 사람이 직접 보냄)
- [x] 연락처 추가/수정 다이얼로그에 자율성 레벨 오버라이드 UI(`contacts_screen.dart`,
`_RelationshipTierPicker`와 나란히 `_AutonomyLevelPicker`(기본값 사용/L0/L1/L2 4-way 칩) 추가,
연락처 목록 서브타이틀에도 오버라이드 표시)
- [x] 그룹 대화는 기존과 동일하게 항상 전역 L0 취급 유지(단톡 따라잡기 안전 불변식 변경 없음) —
`resolveAutonomyLevel()``RelationshipTier`와 동일하게 그룹 대화면 연락처 오버라이드를
건너뛰고 전역 기본값만 사용하도록 구현, 그룹 대화는 애초에 `main.go`의 무조건 차단이 이
해석 함수 호출보다 먼저 걸려서 두 안전장치가 이중으로 겹침
**2.7-E 관계 메모 실제 반영** (`PRD.md` §3.2 P1, `Contact.RelationshipNote` 필드·CRUD는 이미
있으나 `draftRequest`에 필드 자체가 없어 ai-service 프롬프트에 전혀 전달되지 않음 — 저장만 되는
스텁, 2026-08-03 발견 — **완료** 2026-08-03)
- [x] `core-backend/aiservice.go``draftRequest``RelationshipNote string` 필드 추가
(`relationship_tier` 옆, 빈 문자열 = 메모 없음 = ai-service 프롬프트 무영향, `omitempty`)
- [x] `POST /conversations/:id/draft` 핸들러가 연락처의 `RelationshipNote`를 조회해 요청에 포함 —
`core-backend/persona.go``resolveRelationshipNote()`. 티어/자율성과 달리 메모는 순전히
개인별이라 "전역 기본 메모" 개념 자체가 없음 — 그룹 대화(상대가 여럿)나 매칭되는 `Contact`
없는 경우 그냥 빈 문자열로 귀결(관계 티어의 "전역 기본값 폴백"과 다른 지점). 이 참에
`resolveRelationshipTier`/`resolveAutonomyLevel`/`resolveRelationshipNote` 셋이 거의 동일한
"1:1 대화에서 상대방의 Contact 행 찾기" 루프를 각자 복붙하고 있던 걸 `persona.go`
`findCounterpartContact(db, actorID, conversationID) (Contact, bool)` 공용 헬퍼로 추출해서
셋 다 이걸 호출하도록 정리(중복 제거, 그룹 판정 로직도 한 곳에만 존재)
- [x] `ai-service/app/generation.py`가 메모가 있으면 시스템 프롬프트에 "호칭/금기어" 지침으로
주입(빈 문자열/`None`이면 기존과 동일하게 무영향) — `system_prompt_for_tier(relationship_tier,
relationship_note)`에 `"\n\n[관계 메모] {note} -- 위 내용을 참고해 호칭/금기어 등을 지켜라."`
형태로 추가. 관계 티어(가까운/공식적) 지침과 분리된 별도 문단이라 서로 안 섞임. `draft_reply()`
`relationship_note` 파라미터를 받아 그대로 전달 — 에스컬레이션/정체성 게이팅 로직 이전 단계라
이 둘에는 전혀 영향 없음(순수 톤/금기어 힌트, 안전 게이트 우회 아님)
**2.7-F 답장 마감 알림** (`PRD.md` §3.2 P1: "내가 '이따 답장' 누르면 나에게만 리마인드" — 코드
전무, 아이디어 회의 문서에만 존재, 2026-08-03 발견 — **완료(부분 검증)** 2026-08-03, 아래 참고)
- [x] 메시지/대화에 "이따 답장" 스누즈 액션 + 리마인드 시각 저장 — 순수 온디바이스
(`mobile/lib/db/tables.dart`의 `ConversationSnoozes` drift 테이블, 서버에는 전혀
전달되지 않음). `chat_screen.dart` 앱바에 "이따 답장" 아이콘 → `SimpleDialog`
빠른 선택(`1시간 후`/`저녁에`/`내일`, `mobile/lib/services/snooze_service.dart`
`SnoozeQuickPick`) + 스누즈 해제. 기본값은 빠른 선택 없이도 쓸 수 있게 상수
`kDefaultSnoozeDuration`(2시간)로 노출은 해 두었지만 UI 자체는 빠른 선택 3개만
제공(추가 커스텀 시각 입력은 P1 범위상 생략). 사람이 실제로 답장을 보내면(휴먼
전송이든 트윈 승인 전송이든) 자동으로 스누즈 해제(`_clearSnoozeQuiet()`)
- [x] 리마인드 도달 시 로컬 알림 — **`flutter_local_notifications`(+`timezone`) 실제
연동, 단 실기기/에뮬레이터로 발사·탭 동작까지 검증하지는 못함.** 이 샌드박스는
Flutter 웹 빌드와 Playwright 브라우저 자동화만 가능하고 실제 Android/iOS
기기·에뮬레이터가 없어 `zonedSchedule()`이 만든 예약이 실제로 울리는지는 확인할
수 없다 — 검증한 것은 `mobile/lib/services/snooze_controller.dart`가 스케줄러
인터페이스(`SnoozeNotificationScheduler`)를 목(mock)으로 바꿔 넣었을 때 올바른
id·시각·페이로드로 호출되는가뿐(`test/snooze_controller_test.dart`). 대신 진짜로
검증 가능한 **인앱 대체 노출**을 항상 함께 켜 둔다 — 앱을 열 때(대화 목록 로드,
채팅방 진입) 마감이 지난 스누즈는 배지/배너로 반드시 보인다(아래 항목). 웹 빌드는
플러그인 자체가 웹을 지원하지 않아 `snooze_notification_service_web.dart`
no-op 스텁으로 대체(웹은 프리뷰 서피스일 뿐 릴리스 타깃이 아님)
- [x] `chat_screen.dart`/`conversation_list_screen.dart`에 스누즈 표시·취소 UI —
채팅방은 앱바 아이콘 상태(`Icons.alarm_on`) + 마감 지난 스누즈 배너("스누즈
해제" 버튼), 대화 목록은 서브타이틀 아래 "답장 마감 (탭하여 해제)" 배지(탭하면
채팅방을 열지 않고도 바로 해제). 마감 판정은 순수 함수 `isSnoozePastDue(now,
snoozedUntil)`로 분리해 고정 시각으로 단위 테스트(`test/snooze_service_test.dart`)
**스코프 밖 (제안 아님, 참고용)**: 이미지/파일 전송·읽음표시·타이핑 인디케이터 등 "일반 메신저"
테이블스테이크 기능은 `PRD.md`에 명시되지 않음 — 콘텐츠 공백의 또 다른 후보일 수 있으나 이건
PRD 범위 확장이라 Master 결정이 먼저 필요. L3/L4/OS레이어/B2B는 `decision-log.md`/`AGENTS.md`
하드 밴 그대로 유지.
#### 3. PoC 결과가 있어야 정할 수 있는 것 — **전체 빌드가 끝난 뒤 맨 마지막** #### 3. PoC 결과가 있어야 정할 수 있는 것 — **전체 빌드가 끝난 뒤 맨 마지막**
@ -309,19 +149,13 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다.
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션· 3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션·
되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서 되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서
4. [~] 2.3 Flutter 클라이언트 — A3 + B(drift/SQLCipher·데이터흐름·세션) 완료 + API E2E. 4. [~] 2.3 Flutter 클라이언트 — A3 + B(drift/SQLCipher·데이터흐름·세션) 완료 + API E2E.
**2026-08-03 동기화**: "C 배포 준비"는 6번에서 이미 완료됐으므로 여기 "남은 것"에서 제거. 남은 것: 실제 Firebase FCM 토큰, Android UI 탭(`mobile/README.md`), C 배포 준비
실제로 남은 건 Master의 Firebase FCM 시크릿 배치(N4-1/3)와 Android UI 탭(N4-5~10,
`mobile/README.md`)뿐
5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료. 5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료.
클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께 클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께
- 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은 - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은
§3(사람 PoC) 이후** §3(사람 PoC) 이후**
- 5-2. [x] 화이트리스트 규칙 CRUD API - 5-2. [x] 화이트리스트 규칙 CRUD API
6. [x] Phase 1 C 베타 직전 — Q1~Q7 확정, 초대 운영, 프로토타입 앵커, Android 릴리즈 경로 6. [x] Phase 1 C 베타 직전 — Q1~Q7 확정, 초대 운영, 프로토타입 앵커, Android 릴리즈 경로
- 6-1. [x] 2.7 콘텐츠 갭 — PRD P0 대비 미구현 기능 (**우선순위: A 단톡 따라잡기 → B 관계별
페르소나 → C 스팸 감지**, 2026-07-31 발견 — A/B/C 모두 완료 2026-08-03). §3 사람 PoC보다
먼저 끝내야 함 — PRD가 요구하는
v1 P0 범위이므로 §4 순서상 D(사람 PoC) 앞
7. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 실제 베타 오픈 (**맨 마지막 / D**) 7. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 실제 베타 오픈 (**맨 마지막 / D**)
@ -342,11 +176,6 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
- [x] 로그인 세션/토큰 (`Session`, signup/login 시 Bearer 발급) - [x] 로그인 세션/토큰 (`Session`, signup/login 시 Bearer 발급)
- [x] `/invites`, `/admin/metrics` 접근 제어 (`ADMIN_API_TOKEN`) - [x] `/invites`, `/admin/metrics` 접근 제어 (`ADMIN_API_TOKEN`)
- [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`) - [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`)
- [x] **계정·설정 IA** (`decision-log` Q8 확정, [`account-settings-ia.md`](./account-settings-ia.md))
— 이메일/비번 도입 금지(Phase 1)
- [x] 입장 화면: 새로 가입 / 이미 가입(`POST /auth/login`, DEMO는 display_name 필수)
- [x] 로그아웃: 세션 revoke + 로컬 토큰 클리어 + 입장 화면 복귀
- [x] 설정 진입점에 로그아웃·세션·자율성·말투·데이터흐름 묶기
**A3. Flutter — 메신저답게 다듬기** (A1/A2 이후) **A3. Flutter — 메신저답게 다듬기** (A1/A2 이후)
- [x] 대화 목록/연락처 UI를 서버 API에 연결 - [x] 대화 목록/연락처 UI를 서버 API에 연결
@ -356,27 +185,13 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
- [x] E2E QA — `scripts/e2e_a3.py`로 A3 HTTP 플로우 16/16 통과(가입·연락처·대화·히스토리· - [x] E2E QA — `scripts/e2e_a3.py`로 A3 HTTP 플로우 16/16 통과(가입·연락처·대화·히스토리·
draft/L1·에스컬레이션·알림 로그·되돌리기·거부권·화이트리스트). `go test`/`pytest`/`flutter test` draft/L1·에스컬레이션·알림 로그·되돌리기·거부권·화이트리스트). `go test`/`pytest`/`flutter test`
동시 통과. Android 에뮬레이터 UI 탭은 이 환경에 SDK가 없어 체크리스트는 `mobile/README.md`에 유지 동시 통과. Android 에뮬레이터 UI 탭은 이 환경에 SDK가 없어 체크리스트는 `mobile/README.md`에 유지
- [ ] **L2 카피·의미 정렬** (`decision-log` Q9 제안) — 현재는 발송 게이트; UI가
“수신 자동응대”로 오해되지 않게 설명 수정. 수신 트리거 자동응대는 별 항목
- [ ] **L2 수신 트리거 자동응대** (PRD §2.2 목표) — Q9 확정·안전 게이트 테스트 후.
상대 메시지 수신 → 초안 → 화이트리스트면 twin 발송 + 사후 알림/되돌리기
##### B. 그다음 — 베타 품질 ##### B. 그다음 — 베타 품질
- [~] FCM 푸시 연동 — 토큰 등록 + 에스컬레이션 시 `notifyUser` + `POST /admin/push-test`. - [~] FCM 푸시 연동 — 토큰 등록 + 에스컬레이션 시 `notifyUser` + `POST /admin/push-test`.
`FCM_SERVER_KEY`와 실제 FCM registration token이 있으면 전송, 없으면 soft-skip. `FCM_SERVER_KEY`와 실제 FCM registration token이 있으면 전송, 없으면 soft-skip.
Flutter는 install-id 플레이스홀더 등록(Firebase Messaging 앱 키는 배포 환경에서 교체) Flutter는 install-id 플레이스홀더 등록(Firebase Messaging 앱 키는 배포 환경에서 교체)
- [~] 멀티 디바이스 동기화 — 세션 목록 + 세션 종료(`DELETE /users/:id/sessions/:id`). - [~] 멀티 디바이스 동기화 — 세션 목록 + 세션 종료(`DELETE /users/:id/sessions/:id`).
메시지 히스토리 서버 동기화는 이미 REST/WS. 오프라인 메시지 큐(deploy-checklist N4-11)는 메시지 히스토리 서버 동기화는 이미 REST/WS; 오프라인 큐는 후속
구현 완료(부분 검증) — 서버는 이미 모든 메시지를 DB에 durable하게 저장하므로 별도
큐를 새로 만들지 않고, `GET /conversations/:id/messages?since_id=`(신규, 없으면 기존
전체 히스토리 동작 그대로) + 클라이언트 WS 재연결(backoff 1s→2s→4s→8s→...→30s 캡, 성공
시 리셋) + 재연결/앱 포그라운드 복귀 시 그 since_id 캐치업으로 gap을 메움.
`core-backend/a1_a2_test.go`(`TestListMessagesSinceID`)와
`mobile/test/message_sync_test.dart`(backoff 계산 + 중복 없는 병합 순수 함수)로
결정적으로 검증한 부분과, 실제 소켓 재연결 타이밍·앱 백그라운드/포그라운드 전환에서
OS가 소켓을 실제로 어떻게 처리하는지는 단위 테스트로 증명할 수 없어 실기기 Android
QA가 남아 있음(N4-C6b 스누즈 알림과 같은 "부분 검증" 프레이밍)
- [x] drift + SQLCipher 로컬 저장 — `mobile/lib/db/` (말투 샘플·KV). 키는 - [x] drift + SQLCipher 로컬 저장 — `mobile/lib/db/` (말투 샘플·KV). 키는
`flutter_secure_storage`. Linux CI는 SQLCipher SO 없으면 메모리 폴백 `flutter_secure_storage`. Linux CI는 SQLCipher SO 없으면 메모리 폴백
- [x] 말투 이력 기기 내 저장 + 서버 최소 전송 — drift 암호화 저장, draft에 샘플만 전달 - [x] 말투 이력 기기 내 저장 + 서버 최소 전송 — drift 암호화 저장, draft에 샘플만 전달
@ -406,23 +221,9 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
단일 실행 체크리스트: **[`deploy-checklist.md`](./deploy-checklist.md)**. 단일 실행 체크리스트: **[`deploy-checklist.md`](./deploy-checklist.md)**.
순서: **N1 스모크 → N2 Docker(`msn.iykyka.com`) → N3 안정화 → N4-W 웹 고도화(병행) / 순서: **N1 스모크 → N2 Docker(`msn.iykyka.com`) → N3 안정화 → N4 FCM/Android QA → N5 사람 PoC(D)**.
N4 Android FCM·UI QA → N5 사람 PoC(D)**. Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5)는 해당 문서를 본다. 완료 시 그 문서와
Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5, **N4-W0…W7**)는 해당 문서를 본다. 본 로드맵 §2/§5의 `[~]`/`[ ]`를 함께 갱신한다.
완료 시 그 문서와 본 로드맵 §2/§5의 `[~]`/`[ ]`를 함께 갱신한다.
##### 웹 고도화 (데모/프리뷰) — [`web-upgrade.md`](./web-upgrade.md)
- [x] **W0** 제품 경계 문서화 (2026-08-04) — 데모/프리뷰 · Q9 비범위 · Wi 규율
- [x] **W1** 웹 로컬 지속성 (prefs/idb · Drift WASM 비채택) — 2026-08-04
- [x] **W2** 데모 UX (분할·WS 상태·ID 복사) — 2026-08-04
- [x] **W3** 인앱 + Notification API — 2026-08-04
- [x] **W4** Web Push (FCM + VAPID · `:web:` APP_ID 프로덕션 주입) — **accept 대기:** 브라우저 하드 리프레시·알림·push-test
- [x] **W5** PWA (manifest 테마 · 셸 캐시) — 2026-08-04 (W4와 독립 선행)
- [x] **W6** 테스터 가이드 — 2026-08-04
- [x] **W7** edge 스모크 (`e2e_web_smoke.sh`) — 2026-08-04; Playwright UI는 후속
W0~W7 코드·배포 완료. W4만 브라우저 실토큰·`/admin/push-test` 확인 남음. Android N4와 서버 SA 공유.
## Phase 2 — L3 확장 + 베타 확대 ## Phase 2 — L3 확장 + 베타 확대

View File

@ -55,20 +55,6 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색
- **v2 이후 온디바이스 증류 대비**: 배터리/지연/프라이버시 압박으로 자체 경량 모델이 필요해지면, - **v2 이후 온디바이스 증류 대비**: 배터리/지연/프라이버시 압박으로 자체 경량 모델이 필요해지면,
이 코퍼스가 그 모델의 기반 학습 데이터가 된다. v1 시점에는 착수하지 않는다 이 코퍼스가 그 모델의 기반 학습 데이터가 된다. v1 시점에는 착수하지 않는다
## 2-2. 인증 · 세션 (클로즈드 베타)
상세 UX: [`account-settings-ia.md`](./account-settings-ia.md) · 운영: [`invite-ops.md`](./invite-ops.md).
| 경로 | 역할 |
|------|------|
| `POST /auth/signup` | 미사용 초대 코드 + `display_name` → User + Session(Bearer) |
| `POST /auth/login` | 사용된 초대 코드(+ 선택적 display_name) → 새 Session. DEMO는 display_name 필수 |
| `GET/DELETE /users/:id/sessions...` | 멀티 디바이스 목록·종료 |
| 클라이언트 | `shared_preferences`에 토큰 저장; 로그아웃 시 revoke + 로컬 삭제 → 입장 화면 |
- Phase 1은 **비밀번호·OAuth 없음.** 초대 코드가 비밀에 해당한다.
- 로그아웃은 서버 세션 무효화만으로는 부족하고, 클라이언트가 토큰을 지워야 입장 화면으로 돌아간다.
## 3. 자율성 엔진 (L0~L2) ## 3. 자율성 엔진 (L0~L2)
1. 수신 메시지 → 에스컬레이션 판정기 먼저 통과 (금전/약속 확정/민감 키워드+의도 분류) 1. 수신 메시지 → 에스컬레이션 판정기 먼저 통과 (금전/약속 확정/민감 키워드+의도 분류)
@ -76,18 +62,9 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색
3. 아니면 자율성 레벨 확인: 3. 아니면 자율성 레벨 확인:
- L0: 초안만 생성해 사용자에게 보여줌, 발송 없음 - L0: 초안만 생성해 사용자에게 보여줌, 발송 없음
- L1: 초안 생성 + 발송 승인 요청 알림 - L1: 초안 생성 + 발송 승인 요청 알림
- L2 **(목표)**: 상대·주제가 화이트리스트에 있으면 즉시 발송, 아니면 L1과 동일하게 강등 - L2: 상대·주제가 화이트리스트에 있으면 즉시 발송, 아니면 L1과 동일하게 강등
4. 발송된 모든 자동 응답은 로컬 이벤트 로그에 기록 (사후 알림 + 되돌리기 버튼 노출) 4. 발송된 모든 자동 응답은 로컬 이벤트 로그에 기록 (사후 알림 + 되돌리기 버튼 노출)
### 3-1. 현재 서버 게이트 vs 목표 오케스트레이션
- **현재 (`core-backend` 메시지 POST):** `sender_mode=twin`일 때만 레벨 검사.
L2 + `whitelistMatches(text)``approved` 없이 통과. 수신 이벤트가 이 경로를
자동 호출하지는 않는다.
- **목표 (PRD §2.2 / decision-log Q9):** 상대 메시지 수신 → (게이트 통과 시) 초안 →
L2면 twin 발송. 구현은 별도 로드맵 항목으로 분리한다.
에스컬레이션 판정기는 v1에서는 규칙 기반(키워드 + 간단한 의도 분류) + 온디바이스 모델의 결합으로 에스컬레이션 판정기는 v1에서는 규칙 기반(키워드 + 간단한 의도 분류) + 온디바이스 모델의 결합으로
시작하고, 오탐/누락 사례를 베타 로그로 계속 튜닝한다. 100% 정확도를 목표하지 않는다 — 애매하면 시작하고, 오탐/누락 사례를 베타 로그로 계속 튜닝한다. 100% 정확도를 목표하지 않는다 — 애매하면
항상 에스컬레이션 쪽으로 fail-safe. 항상 에스컬레이션 쪽으로 fail-safe.

View File

@ -2,38 +2,25 @@
## 접속 ## 접속
- 웹: **https://msn.iykyka.com** (데모/프리뷰 1차 표면) - 웹: **https://msn.iykyka.com**
- 권장 브라우저: **Desktop Chrome**
- 공용 데모 초대 코드: **`DEMO-YKAVU`** - 공용 데모 초대 코드: **`DEMO-YKAVU`**
- 표시 이름 예시: `테스터` (원하는 이름으로 변경 가능**이미 가입** 탭 재로그인 시 같은 이름 필요) - 표시 이름 예시: `테스터` (원하는 이름으로 변경 가능)
같은 코드를 여러 명이 쓸 수 있습니다 (`ALLOW_DEMO_INVITE=1`). 같은 코드를 여러 명이 쓸 수 있습니다 (`ALLOW_DEMO_INVITE=1`).
API 메타: `GET https://msn.iykyka.com/demo``pairing_steps` / `notes` 포함. API 메타: `GET https://msn.iykyka.com/demo``pairing_steps` / `notes` 포함.
### 웹 프리뷰 팁 (N4-W)
1. **넓은 화면(≥900px)** — 왼쪽 대화 목록 · 오른쪽 채팅이 나란히 보입니다.
2. **내 ID 복사** — 목록 상단 칩 또는 설정 → 계정 행을 탭합니다.
3. **브라우저 알림** — 설정 → 「브라우저 알림」에서 허용하면 답장 마감 리마인드·(허용 시) Web Push를 받을 수 있습니다(거부해도 인앱 배지는 동작).
4. **로그인 세션 만료 시각** — 설정 → 로그인 세션의 「만료」는 **기기 로컬 시간**(`yyyy-MM-dd HH:mm`)으로 표시됩니다.
5. **홈 화면 추가(PWA)** — Chrome 주소창 옆 설치 아이콘 또는 메뉴 → 「앱 설치」.
6. **로그아웃** — 설정 → 로그아웃 → 입장 화면 「이미 가입」으로 복귀.
7. **캐시가 옛 버전일 때** — 강력 새로고침, 또는 DevTools → Application → Service Workers → Unregister 후 새로고침.
상세 설계: [`web-upgrade.md`](./web-upgrade.md).
## 권장 페어링 플로우 (5~10분) ## 권장 페어링 플로우 (5~10분)
두 명(또는 시크릿 창 두 개)으로 진행합니다. **대화는 닉네임이 아니라 숫자 사용자 ID로 연결됩니다.** 두 명(또는 시크릿 창 두 개)으로 진행합니다. **대화는 닉네임이 아니라 숫자 사용자 ID로 연결됩니다.**
1. **가입**「새로 가입」탭 · 초대 코드 `DEMO-YKAVU` (각자 다른 표시 이름 권장). 시크릿 창 두 개로 두 사용자 가능. 1. **가입** — 초대 코드 `DEMO-YKAVU` 입력 (각자 다른 표시 이름 권장)
2. **말투 샘플** — 몇 줄 적거나 스킵 (하드 리프레시 후에도 유지됨) 2. **말투 샘플** — 몇 줄 적거나 스킵
3. **내 ID 복사** — 대화 목록 상단「내 사용자 ID」칩을 탭해 복사하고 상대에게 전달 3. **내 ID 복사** — 대화 목록 상단「내 사용자 ID」칩을 탭해 복사하고 상대에게 전달
4. **연락처 추가** — 상대 표시 이름 + **상대의 숫자 ID(필수)** → 추가 → 「대화」 4. **연락처 추가** — 상대 표시 이름 + **상대의 숫자 ID(필수)** → 추가 → 「대화」
5. **메시지** — 사람 모드로 한두 줄 주고받기 · 채팅 앱바의 「실시간」상태 확인 5. **메시지** — 사람 모드로 한두 줄 주고받기
6. **와카뷰 초안**설정 → 자율성에서 **L1** → 채팅에서 ✨ 초안 → 수정/승인하고 보내기 6. **와카뷰 초안** — 메뉴 → 자율성에서 **L1** → 초안이 뜨면 수정/승인하고 보내기
7. (선택) L0에서는「입력창으로 옮기기」만 됩니다. L2·거부권·사후알림 함·이따 답장도 눌러 보세요. 7. (선택) L0에서는「입력창으로 옮기기」만 됩니다. L2·거부권·사후알림 함도 눌러 보세요.
### ID 없는 옛 연락처 ### ID 없는 옛 연락처
@ -43,9 +30,7 @@ API 메타: `GET https://msn.iykyka.com/demo` — `pairing_steps` / `notes` 포
- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다. - **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다.
- **L0(비서)**: 와카뷰 발송이 서버에서 막혀 있습니다. 초안 → 입력창 → 직접 전송. - **L0(비서)**: 와카뷰 발송이 서버에서 막혀 있습니다. 초안 → 입력창 → 직접 전송.
- 푸시: 웹은 프로덕션에 Firebase Web(`:web:`)·VAPID가 이미 주입되어 있습니다. - 푸시(FCM): 코드 경로는 준비됨. Master가 Firebase/`FCM_SERVER_KEY`를 넣기 전에는 플레이스홀더(`docs/fcm-setup.md`).
**하드 리프레시**(또는 사이트 데이터 삭제) → 알림 **허용** → 로그인하면 실 FCM 토큰이 등록됩니다.
Android 실기기 푸시는 `google-services.json` 배치 후 (`docs/fcm-setup.md`).
- 문제/스크린샷은 Master에게 전달해 주세요. - 문제/스크린샷은 Master에게 전달해 주세요.
- 민감 정보·실명 대화는 베타 특성상 최소화해 주세요. - 민감 정보·실명 대화는 베타 특성상 최소화해 주세요.

View File

@ -1,488 +0,0 @@
# 웹 고도화 (데모/프리뷰 + Web Push + PWA)
권위: [`decision-log.md`](./decision-log.md) · [`deploy-checklist.md`](./deploy-checklist.md) · [`AGENTS.md`](../AGENTS.md).
실행 추적 ID: **N4-W0 … N4-W7** (`deploy-checklist.md` §N4-W).
## 0. 고정 결정 (2026-08-04 Master)
| # | 결정 | 상태 |
|---|------|------|
| 1 | 웹 = **데모/프리뷰 1차 표면** (`https://msn.iykyka.com`). Android 동등 교체 아님 | 확정 |
| 2 | **Web Push**(FCM Web + VAPID) · **PWA**(설치·standalone·셸 SW) 포함 | 확정 |
| 3 | 본 문서로 Wi를 **완전 상세**히 짜고, **Wi 단위**로 차근차근 구현 | 확정 |
| 4 | **Q9**(L2 수신 트리거 자동응대) · PoC §3 기본값 추측 · L3/OS/B2B **비범위** | 확정 |
### 성공 정의 (데모/프리뷰)
두 테스터가 **브라우저만**으로: 가입(또는 로그인) → 사용자 ID 교환·연락처 → 대화 → 초안(✨) → L1 승인·와카뷰 뱃지 → 설정/로그아웃까지 **안정 시연**.
### 비범위 (전체 공통)
- Q9 수신 트리거 자동응대 및 “자리를 비우면 알아서 답함” 카피
- 사람 PoC #1/#3 실행 · 자율성/화이트리스트 **기본값** 확정
- Android N4 실기기 트랙(병행 가능, 본 문서 작업 분해에 넣지 않음)
- Drift WASM / SQLCipher-on-web / Play·App Store 배포
- 계정 삭제 UI (Q8e), 이미지·읽음·타이핑 등 일반 메신저 확장
### 구현 규율
1. **한 번에 W 전체 구현 금지.** `Wi`마다 Master 승인 → 코드 → 본 문서·`deploy-checklist` Status 동시 갱신.
2. Wi 완료 전 다음 Wi 착수하지 않는다 (의존 화살표 준수). 예외는 Master 명시.
3. 시크릿(Firebase Web config, VAPID, 서비스 계정) **git 커밋 금지**.
4. 서버 FCM HTTP v1 SA는 Android N4와 **공유**. 클라이언트 시크릿 경로만 플랫폼별 분리.
```mermaid
flowchart LR
W0[W0_Boundary] --> W1[W1_Persistence]
W1 --> W2[W2_DemoUX]
W2 --> W3[W3_InAppNotify]
W3 --> W4[W4_WebPush]
W4 --> W5[W5_PWA]
W5 --> W6[W6_TesterOps]
W6 --> W7[W7_Quality]
```
---
## 1. As-is (코드 박제, 2026-08)
| 영역 | 현황 | 근거 |
|------|------|------|
| 서빙 | Flutter web + nginx, API/WS 프록시 | `mobile/Dockerfile`, `deploy/nginx-web.conf` |
| 로컬 DB | 웹 = **메모리 스텁**, 새로고침 시 말투·온보딩·스누즈 유실 | `mobile/lib/db/app_database_web.dart` |
| 세션 | `shared_preferences`에 user/token (웹에서도 대체로 유지) | `mobile/lib/state/session_state.dart` |
| 푸시 | 웹에서 FCM 즉시 skip → `install:` 플레이스홀더 | `mobile/lib/services/push_token_service.dart` |
| 스누즈 OS 알림 | 웹 no-op | `mobile/lib/services/snooze_notification_service_web.dart` |
| PWA 골격 | `manifest.json` + icons 있음. theme `#7C6FF0`(낡은 아우로라). SW 전략 미문서화 | `mobile/web/` |
| 서버 푸시 | HTTP v1 SA 마운트됨. 플레이스홀더 토큰은 skip | `core-backend/push.go`, `/admin/push-test` |
| 테마 | iMessage-inspired light + soft charcoal dark | `mobile/lib/theme/app_theme.dart` (`#007AFF` / canvas `#F2F2F7`·`#141418`) |
---
## N4-W0 — 제품 경계
| | |
|--|--|
| **Status** | **done** (본 문서 랜딩으로 경계 고정, 2026-08-04) |
| **목표** | 웹 고도화의 제품·기술 경계를 문서에 고정해 구현 범위 논쟁을 없앤다 |
| **비범위** | 코드 변경 없음 |
### 작업 분해
- [x] 데모/프리뷰 성공 정의 기록
- [x] Q9 / PoC §3 / Android N4 / Drift WASM 비범위 명시
- [x] Wi 순서·승인 규율 명시
- [x] README · roadmap · deploy-checklist · fcm-setup · mobile README 교차 링크
### 수락 기준
- [x] 본 파일 §0이 Master 결정 4항과 일치
- [x] 실행 ID N4-W*가 `deploy-checklist.md`에 존재
### Master 액션
- 없음 (문서 승인 = W0 완료)
### 위험
- “웹도 Android만큼”으로 범위가 커지면 W1에서 Drift WASM 등으로 비대화 → §0을 인용해 거절
### 터치 파일 (문서)
- `docs/web-upgrade.md` (본 파일)
- `README.md`, `docs/roadmap.md`, `docs/deploy-checklist.md`, `docs/fcm-setup.md`, `mobile/README.md`
---
## N4-W1 — 웹 로컬 지속성
| | |
|--|--|
| **Status** | **done** (2026-08-04) |
| **목표** | 하드 리프레시 후에도 말투·온보딩·스누즈·install id가 유지되는 프리뷰 UX |
| **비범위** | SQLCipher/암호화 DB, Drift WASM, 서버 측 말투 동기화 |
### As-is
- `AppDatabase` 웹 구현이 in-memory `Map` (`app_database_web.dart`)
- 인증만 `SharedPreferences`에 남아 새로고침 후 “가입된 것처럼” 보이지만 말투 온보딩이 다시 뜰 수 있음
### To-be
- 웹 `AppDatabase`를 **durable KV**로 승격
- **기술 선택 (고정):** `shared_preferences`(또는 동일 idb 백엔드)로 KV·말투 JSON·스누즈 맵 직렬화. **Drift WASM 비채택**
- `encrypted` 플래그는 웹에서 계속 `false`; `DataFlowScreen` 카피에 “웹 프리뷰는 브라우저 저장소(비암호화)” 명시
### 작업 분해
- [x] `app_database_web.dart`: open 시 SharedPreferences 로드, mutate 시 persist
- [x] 키 네임스페이스: `ykavu_web_db_kv_*`, `ykavu_web_db_tone_samples`, `ykavu_web_db_snoozes`
- [x] `AppDatabase.memory()`는 비지속(테스트용) 유지 — `open()`만 durable
- [x] 단위 테스트: `mobile/test/web_db_persistence_test.dart` (mock prefs round-trip)
- [x] `DataFlowScreen` 웹 카피 갱신
### 수락 기준
- [x] 단위 테스트: open → 저장 → 재 open 시 말투·KV·스누즈 복원
- [ ] Chrome 라이브 하드 리프레시 스모크 (배포 후 Master/에이전트 1회)
- [x] 시크릿 창(다른 프로필)과는 저장소 격리 — 브라우저 프로필 단위 SharedPreferences (기대 동작)
### Master 액션
- 승인 후 구현 착수; 별도 시크릿 없음
### 위험
- prefs 용량·JSON 파싱 오류 시 빈 DB로 폴백 (corrupt 시 wipe + 로그)
- 여러 탭 동시 기록 → last-write-wins 허용 (프리뷰)
### 예상 터치 파일
- `mobile/lib/db/app_database_web.dart`
- `mobile/lib/screens/data_flow_screen.dart` (카피)
- `mobile/test/` (웹 persistence 테스트, 선택)
- `docs/web-upgrade.md` Status → done
---
## N4-W2 — 데모 메신저 UX
| | |
|--|--|
| **Status** | **done** (2026-08-04) |
| **목표** | 데스크톱·모바일 브라우저에서 시연 마찰을 줄인 프리뷰 UX |
| **비범위** | 디자인 시스템 전면 교체, 카드/히어로 과설계, Q9 카피 |
### As-is
- 모바일 우선 단일 컬럼 스택; 대화 목록 ↔ 채팅 push
- `MyUserIdChip` 등 ID 노출은 있으나 페어링 CTA 발견성 편차
- WS 재연결 backoff는 구현됨, UI 인디케이터는 약함
### To-be
- **넓은 뷰포트(예: ≥900px):** 목록 | 채팅 **분할**(또는 동등 반응형). 좁은 폭은 기존 스택 유지
- WS 연결 상태: connecting / live / reconnecting(backoff) 한 줄 표시
- 내 사용자 ID **원탭 복사** CTA 강화 (대화 목록·설정)
- 웹 기대치 배너(첫 진입 또는 설정): 새로고침 후 데이터(W1 이후), 알림(W3/W4), 홈 화면 추가(W5)
- 시각: 기존 iMessage-inspired 테마 유지. 신규 카드 남발·히어로 오버레이 금지
### 작업 분해
- [x] 반응형 셸: `ConversationListScreen` ≥900px 목록|채팅 분할 · 좁은 폭 Navigator push
- [x] `ConversationSocket.linkStates` + 채팅 앱바/재연결 바
- [x] ID 복사: 목록 칩 + 설정 계정 행
- [x] 웹 전용 1회 dismissible 안내 (`WebPreviewBanner`)
- [ ] 수동: 1280px·390px 폭 시연 (배포 후)
### 수락 기준
- [x] 분할·스택 경로 코드 연결 (라이브 폭 스모크는 배포 후)
- [x] 재연결 중 「다시 연결 중…」 표시
- [x] 기존 `AppTheme` 유지
### Master 액션
- UX 톤 승인 (분할 vs 스택-only 미세 조정은 구현 PR에서 스크린샷)
### 위험
- Navigator 스택과 분할 레이아웃 충돌 → 웹만 `MasterDetail` 패턴으로 격리
### 예상 터치 파일
- `mobile/lib/main.dart`
- `mobile/lib/screens/conversation_list_screen.dart`
- `mobile/lib/screens/chat_screen.dart`
- `mobile/lib/services/ws_client.dart` (상태 노출 API)
- `mobile/lib/widgets/my_user_id_chip.dart` (또는 설정)
---
## N4-W3 — 인앱·브라우저 알림 대체 (Push 전)
| | |
|--|--|
| **Status** | **done** (2026-08-04) |
| **목표** | 웹에서 스누즈/에스컬레이션을 OS 로컬알림 없이 **인지 가능**하게 |
| **비범위** | FCM Web Push 본구현(W4), Android `flutter_local_notifications` 변경 |
### As-is
- `snooze_notification_service_web.dart` = no-op
- 인앱 배지/배너(C6c)는 동작 (포그라운드·리프레시 후 W1 전제)
### To-be (고정 우선순위)
1. **항상:** 인앱 배지·배너·Inbox (기존)
2. **권한 허용 시:** 브라우저 **Notification API**로 스누즈 마감·(선택) 포그라운드 에스컬레이션 토스트성 알림
3. 권한 거부/미지원: 인앱만 — 실패로 치지 않음
스케줄: 웹은 Service Worker 알람이 약하므로 **포그라운드 타이머 + 다음 포커스 시 past-due 검사**를 1차 경로로 한다. (백그라운드 확정 전달은 W4)
### 작업 분해
- [x] `snooze_notification_service_web.dart`: Notification API + in-tab Timer
- [x] 포그라운드 복귀 시 `notifyPastDueOnFocus` → Notification (허용 시)
- [x] 설정 → 「브라우저 알림」권한 요청
- [x] 거부 시 인앱 배지/배너 유지 (기존 C6c)
- [ ] 에스컬레이션 전용 포그라운드 SnackBar — Inbox/기존 배너로 충분, W4 푸시와 연계
### 수락 기준
- [x] 단위: past-due만 `showImmediate` 호출
- [x] 권한 거부 경로 문서화 (인앱 배지)
- [ ] 라이브 Chrome에서 알림 허용 후 스누즈 마감 스모크 (배포 후)
### Master 액션
- 브라우저에서 알림 허용 클릭 (시연 시)
### 위험
- Safari/iOS 웹 알림 제약 → Chrome/Desktop을 데모 기준 브라우저로 명시 (W6)
### 예상 터치 파일
- `mobile/lib/services/snooze_notification_service_web.dart`
- `mobile/lib/services/snooze_controller.dart`
- `mobile/lib/screens/chat_screen.dart` / `conversation_list_screen.dart`
- `mobile/lib/screens/settings_screen.dart` (선택)
---
## N4-W4 — Web Push (FCM)
| | |
|--|--|
| **Status** | **web-live** (2026-08-04) — `:web:` APP_ID·VAPID·SW 프로덕션 주입 완료 · **accept 대기:** 하드 리프레시→알림→로그인→실 토큰·`/admin/push-test` |
| **목표** | 웹 탭이 백그라운드여도 에스컬레이션 등 서버 `notifyUser`가 **실 FCM 웹 토큰**으로 전달 |
| **비범위** | 레거시 `FCM_SERVER_KEY` 신규 의존, 커스텀 푸시 프로토콜 |
### Shipped (프로덕션)
- `FirebaseWebConfig` + compose/`Dockerfile` build-args · SW template 치환
- `PushTokenService` 웹 분기 · `msn.iykyka.com``:web:` APP_ID·VAPID 주입·재배포됨
- 서버 SA(`secrets/firebase-service-account.json`) 공유 · 시크릿 git 미커밋
### Master 액션 (accept만)
1. Chrome에서 `https://msn.iykyka.com` **하드 리프레시** (또는 사이트 데이터 삭제)
2. 알림 **허용** → DEMO/계정 로그인
3. device_tokens에 `platform=web` 실 토큰 확인 후 `POST /admin/push-test``sent >= 1`
### 작업 분해
- [x] 빌드 주입: `--dart-define=FIREBASE_…` + SW template 치환 (gitignore/시크릿 미커밋)
- [x] `PushTokenService` 웹 분기 + permission
- [x] Docker web 빌드에 define 전달 (`mobile/Dockerfile` / compose)
- [x] 프로덕션 `.env``FIREBASE_*` / VAPID · `:web:` APP_ID · `web` 재빌드
- [ ] `/admin/push-test`로 user 지정 → Chrome 백그라운드 수신 (**브라우저 accept**)
- [x] `docs/fcm-setup.md` Web 절 동기화
- [x] `DataFlowScreen` 푸시 문구 갱신
### 수락 기준
- [ ] device_tokens에 `install:`이 아닌 토큰 + `platform=web` (**브라우저 로그인 후**)
- [ ] `POST /admin/push-test``sent >= 1`, 브라우저 알림 표시
- [x] 시크릿이 git history에 없음 (define/env만)
- [x] 프로덕션 SW populated (`firebase.initializeApp`, appId `:web:`)
### 위험
- SW 등록 순서/캐시로 토큰 null → 배포 후 hard refresh 절차를 W6/tester-guide에 적음
- Android `google-services.json`과 Web config 혼동 → 문서에서 경로 분리
### 터치 파일
- `mobile/lib/config/firebase_web_config.dart`
- `mobile/lib/services/push_token_service.dart`
- `mobile/web/` (messaging SW, index 훅)
- `mobile/Dockerfile` / `docker-compose.yml` / `.env.example`
- `docs/fcm-setup.md`, `docs/web-upgrade.md`
### 서버
- **코어 코드 변경 없음.** 기존 `notifyUser` / HTTP v1. 웹 토큰도 동일 messages:send
- 클라이언트 시크릿만 host `.env` → compose build-args
---
## N4-W5 — PWA
| | |
|--|--|
| **Status** | **done** (2026-08-04) — W4와 독립 선행 |
| **목표** | 홈 화면 추가·standalone으로 데모 가능; 오프라인은 **셸만** |
| **비범위** | 오프라인 메시지 작성/전송, 풀 오프라인 메신저 |
### As-is
- `mobile/web/manifest.json`: name/icons/`display:standalone` 있음
- `theme_color` / `background_color` = `#7C6FF0` (현 테마와 불일치)
- `index.html` description = 플레이스홀더 (“A new Flutter project.”)
- Flutter 빌드가 `flutter_service_worker.js` 생성 가능 — 운영 캐시 버스트 미문서화
### To-be
| 항목 | 값 |
|------|-----|
| theme / background | light canvas `#F2F2F7` 또는 accent `#007AFF` (택1, 구현 시 `AppTheme`와 맞춤) |
| description | 제품 한 줄: “나를 대신해 답하는, 나만의 와카뷰” |
| 오프라인 | 앱 셸 캐시만. API/WS는 온라인 필수 — 오프라인 시 명확한 배너(W2와 공유) |
| install | Chrome installability (manifest + SW + HTTPS + icons) |
### 작업 분해
- [x] `manifest.json` theme `#007AFF` / background `#F2F2F7` · orientation `any`
- [x] `index.html` description / theme-color / apple meta
- [x] nginx: `index.html` · `flutter_service_worker.js` · `manifest.json``Cache-Control: no-cache`
- [x] `OfflineBanner` (navigator.onLine)
- [ ] Chrome Application 패널 수동 확인 (배포 후)
### 수락 기준
- [x] manifest·index·캐시 헤더 코드 반영
- [x] 오프라인 시 배너 문구
- [ ] standalone 설치 시연 (배포 후 Master/테스터)
### Master 액션
- 배포 파이프라인에 Web Firebase define이 있으면 W4와 동일 시크릿 유지
- 컷오버 후 한 번 hard refresh / “Unregister SW” 런북 숙지
### 위험
- SW가 오래된 main.dart.js 제공 → 배포 체크리스트에 “웹 캐시 확인” 항목
### 예상 터치 파일
- `mobile/web/manifest.json`
- `mobile/web/index.html`
- `deploy/nginx-web.conf` (캐시 헤더, 필요 시)
- `docs/deploy-checklist.md` / `docs/tester-guide.md`
---
## N4-W6 — 테스터 운영 폴리시
| | |
|--|--|
| **Status** | **done** (2026-08-04) |
| **목표** | 웹 데모 스크립트가 설치·알림·페어링·로그아웃까지 재현 가능 |
| **비범위** | PoC 인터뷰 본실행, SHARE_URL(별도 N4-13) |
### 작업 분해
- [x] [`tester-guide.md`](./tester-guide.md) 웹 프리뷰 팁 · 페어링·캐시 런북
- [x] SW unregister / 강력 새로고침 안내
### 수락 기준
- [x] 가이드에 Chrome·PWA·알림·로그아웃 경로 명시
- [ ] Master walkthrough 1회 (운영)
### Master 액션
- 가이드 문구 톤 확인
### 예상 터치 파일
- `docs/tester-guide.md`
- `docs/web-upgrade.md`
---
## N4-W7 — 품질
| | |
|--|--|
| **Status** | **done** (2026-08-04, edge smoke) — Playwright UI는 후속 선택 |
| **목표** | 웹 회귀를 자동화·수동 체크리스트로 고정 |
| **비범위** | Android 에뮬레이터 UI 탭(N4-5~10), Q9 테스트 |
### 작업 분해
- [x] [`scripts/e2e_web_smoke.sh`](../scripts/e2e_web_smoke.sh) — health/demo/manifest/login-400
- [x] 부록 A 수동 QA 표 유지
- [ ] Playwright 브라우저 UI 스모크 — 선택 후속 (에이전트 환경 제약)
### 수락 기준
- [x] `./scripts/e2e_web_smoke.sh` 한 줄 실행
- [x] 부록 A가 W1~W5와 연결됨
### Master 액션
- 라이브 대상 스모크 시 DEMO 계정 충돌 주의 (display_name)
### 예상 터치 파일
- `scripts/` 또는 `mobile/integration_test/`
- `docs/web-upgrade.md` 부록
---
## 2. Android N4와의 관계
| 항목 | Android N4 | Web N4-W |
|------|------------|----------|
| 서버 SA | 공유 (이미 배치된 경우 유지) | 동일 |
| 클라이언트 설정 | `google-services.json` | Firebase Web config + VAPID |
| 푸시 스모크 | 실기기 | Chrome + `/admin/push-test` |
| 로컬 DB | SQLCipher | prefs/idb (비암호화 프리뷰) |
| 일정 | Master 실기기 가능 시 | **문서 승인 후 웹 Wi 우선 가능** |
병행 시 서버·compose 충돌 최소화: web 이미지 빌드 args만 추가.
---
## 3. 상태 갱신 규칙
Wi를 끝낼 때마다:
1. 본 문서 해당 Wi **Status**`done` (또는 `done (부분)`) + 날짜
2. [`deploy-checklist.md`](./deploy-checklist.md) N4-W 표 Status 동기화
3. [`roadmap.md`](./roadmap.md) Web `[~]` 체크 갱신
4. 구현 커밋과 **같은 변경**에 문서 체크를 넣음 (허위 done 금지)
---
## 부록 A — 웹 수동 QA 체크리스트 (구현 후)
**환경:** Desktop Chrome · `https://msn.iykyka.com` · 필요 시 시크릿 창 2개
| # | 단계 | W 의존 | OK |
|---|------|--------|-----|
| A1 | 새로 가입 (DEMO) + 말투 저장 | W1 | [ ] |
| A2 | 하드 리프레시 후 세션·말투 유지 | W1 | [ ] |
| A3 | 두 프로필 페어링 · 메시지 · 초안 · 뱃지 | W2 | [ ] |
| A4 | WS 끊김/재연결 상태 인지 | W2 | [ ] |
| A5 | 스누즈 → 인앱 배지 (+ 알림 허용 시 Notification) | W3 | [ ] |
| A6 | 설정 → 로그아웃 → 이미 가입 로그인 | Q8 | [ ] |
| A7 | 실 FCM 웹 토큰 · push-test 수신 | W4 | [ ] |
| A8 | PWA 설치 · standalone 시연 | W5 | [ ] |
| A9 | 오프라인 시 연결 필요 안내 | W5 | [ ] |
---
## 부록 B — Master 시크릿·accept 체크리스트 (W4)
- [x] Firebase Web app 등록 (도메인 `msn.iykyka.com`, APP_ID `:web:`)
- [x] VAPID key
- [x] 서버 `~/project/ykavu/.env``FIREBASE_*` / `FIREBASE_VAPID_KEY` (git 미포함) → `web` 재빌드
- [x] 서버 `secrets/firebase-service-account.json` 유지 (Android와 공유)
- [ ] 하드 리프레시·알림 허용·로그인 후 push-test로 `sent >= 1` 확인
---
## 부록 C — 관련 문서
| 문서 | 관계 |
|------|------|
| [`deploy-checklist.md`](./deploy-checklist.md) | N4-W 실행 표 |
| [`fcm-setup.md`](./fcm-setup.md) | Android + Web 시크릿 절차 |
| [`tester-guide.md`](./tester-guide.md) | 테스터 스크립트 (W6) |
| [`account-settings-ia.md`](./account-settings-ia.md) | Q8 완료 · Q9 비범위 |
| [`roadmap.md`](./roadmap.md) | Phase 체크 |
| [`android-release.md`](./android-release.md) | 별트랙 (본 문서 비범위) |

View File

@ -1,37 +1,17 @@
# Flutter Web → nginx. Build from repository root: # Flutter Web → nginx. Build from repository root:
# docker build -f mobile/Dockerfile --build-arg CORE_API_BASE=https://msn.iykyka.com . # docker build -f mobile/Dockerfile --build-arg CORE_API_BASE=https://msn.iykyka.com .
# Optional Web Push (N4-W4) — pass FIREBASE_* / FIREBASE_VAPID_KEY as build-args
# (never commit real values; see .env.example and docs/fcm-setup.md).
#
# Secrets are copied into ENV for the build stage only (not the final nginx image).
# The RUN line calls a script so values are not expanded into Docker build logs.
ARG FLUTTER_IMAGE=ghcr.io/cirruslabs/flutter:3.32.7 ARG FLUTTER_IMAGE=ghcr.io/cirruslabs/flutter:3.32.7
FROM ${FLUTTER_IMAGE} AS build FROM ${FLUTTER_IMAGE} AS build
WORKDIR /app WORKDIR /app
ARG CORE_API_BASE=https://msn.iykyka.com ARG CORE_API_BASE=https://msn.iykyka.com
ARG FIREBASE_API_KEY=
ARG FIREBASE_AUTH_DOMAIN=
ARG FIREBASE_PROJECT_ID=
ARG FIREBASE_STORAGE_BUCKET=
ARG FIREBASE_MESSAGING_SENDER_ID=
ARG FIREBASE_APP_ID=
ARG FIREBASE_VAPID_KEY=
ENV CORE_API_BASE=${CORE_API_BASE} \
FIREBASE_API_KEY=${FIREBASE_API_KEY} \
FIREBASE_AUTH_DOMAIN=${FIREBASE_AUTH_DOMAIN} \
FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID} \
FIREBASE_STORAGE_BUCKET=${FIREBASE_STORAGE_BUCKET} \
FIREBASE_MESSAGING_SENDER_ID=${FIREBASE_MESSAGING_SENDER_ID} \
FIREBASE_APP_ID=${FIREBASE_APP_ID} \
FIREBASE_VAPID_KEY=${FIREBASE_VAPID_KEY}
COPY mobile/pubspec.yaml mobile/pubspec.lock ./ COPY mobile/pubspec.yaml mobile/pubspec.lock ./
RUN flutter pub get RUN flutter pub get
COPY mobile/ . COPY mobile/ .
RUN chmod +x scripts/docker_build_web.sh && ./scripts/docker_build_web.sh RUN flutter build web --release \
--dart-define=CORE_API_BASE=${CORE_API_BASE}
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
COPY --from=build /app/build/web /usr/share/nginx/html COPY --from=build /app/build/web /usr/share/nginx/html

View File

@ -1,6 +1,5 @@
# Optional: serve a host-built Flutter web tree (faster CI/agent smoke). # 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 \ # cd mobile && flutter build web --release --dart-define=CORE_API_BASE=https://msn.iykyka.com
# # optional W4: --dart-define=FIREBASE_API_KEY=... --dart-define=FIREBASE_VAPID_KEY=...
# docker build -f mobile/Dockerfile.prebuilt -t ykavu-web . # docker build -f mobile/Dockerfile.prebuilt -t ykavu-web .
# Production/Portainer should prefer mobile/Dockerfile (multi-stage flutter build). # Production/Portainer should prefer mobile/Dockerfile (multi-stage flutter build).
FROM nginx:1.27-alpine FROM nginx:1.27-alpine

View File

@ -1,9 +1,7 @@
# 와카뷰 mobile (Flutter) # 와카뷰 mobile (Flutter)
Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3). Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3). **v1은 Android 빌드만** 대상으로 한다
**릴리즈 타깃(암호화 DB·OS 알림):** Android. (`docs/tech-design.md` §8).
**데모/프리뷰 1차 표면:** Flutter **Web** (`https://msn.iykyka.com`) —
고도화 설계·단계: [`docs/web-upgrade.md`](../docs/web-upgrade.md) (W0~W7).
## 지금 있는 것 ## 지금 있는 것
@ -65,29 +63,14 @@ HTTP로 검증한다.
sudo apt-get install -y libsqlite3-dev libsqlcipher1 sudo apt-get install -y libsqlite3-dev libsqlcipher1
``` ```
## 웹 (데모/프리뷰)
- 로컬 DB: `lib/db/app_database_web.dart`**W1 done**: SharedPreferences 지속성(비암호화 프리뷰)
- 스누즈 OS 알림: 웹 no-op → **W3** Notification API / 인앱
- 푸시: 프로덕션은 `FIREBASE_*` + VAPID 주입됨 · 로컬은 dart-define 없으면 `install:` ([`docs/fcm-setup.md`](../docs/fcm-setup.md) Web 절)
- PWA: **W5 done** — manifest 테마·설치·셸 캐시
- **Q9**(수신 자동응대)는 웹 트랙 비범위
```bash
# 웹 로컬 (core-backend 기동 후)
cd mobile && flutter run -d chrome --dart-define=CORE_API_BASE=http://localhost:8080
```
## 푸시 (FCM) ## 푸시 (FCM)
- 코드: `lib/services/push_token_service.dart` — Firebase 가능 시 실 토큰, 아니면 `install:` - 코드: `lib/services/push_token_service.dart` — Firebase 가능 시 실 토큰, 아니면 `install:`
- Android: [`docs/fcm-setup.md`](../docs/fcm-setup.md) (`google-services.json`) - Master 설정: [`docs/fcm-setup.md`](../docs/fcm-setup.md) (`google-services.json` + `FCM_SERVER_KEY`)
- Web: [`docs/web-upgrade.md`](../docs/web-upgrade.md) W4 + 동일 `fcm-setup` Web 절
## 아직 없는 것 ## 아직 없는 것
- 웹 W4 **accept** — 브라우저 실 FCM 토큰·`/admin/push-test` (코드·`:web:` 배포는 완료, [`web-upgrade.md`](../docs/web-upgrade.md)) - Master Firebase 시크릿 주입 후 실기기 푸시 스모크 (N4-1/3/4)
- Android: `google-services.json` PC 배치 후 실기기 푸시 스모크 (N4-1/4; 서버 SA는 N4-3 done) - 오프라인 메시지 큐 / 멀티디바이스 실시간 설정 동기화 고도화
- 멀티디바이스 실시간 설정 동기화 고도화
- 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영) - 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
- iOS 빌드 (v1 범위 밖) - iOS 빌드 (v1 범위 밖)

View File

@ -1,64 +0,0 @@
import 'package:firebase_core/firebase_core.dart';
/// Firebase Web + VAPID settings injected at build time via `--dart-define`.
///
/// Never commit real values. Pass them through Docker build-args / `.env`
/// (see `.env.example` and `docs/fcm-setup.md` Web section).
class FirebaseWebConfig {
const FirebaseWebConfig({
required this.apiKey,
required this.appId,
required this.messagingSenderId,
required this.projectId,
required this.vapidKey,
this.authDomain = '',
this.storageBucket = '',
});
/// Reads compile-time defines (`FIREBASE_*`).
factory FirebaseWebConfig.fromEnvironment() {
return const FirebaseWebConfig(
apiKey: String.fromEnvironment('FIREBASE_API_KEY'),
appId: String.fromEnvironment('FIREBASE_APP_ID'),
messagingSenderId: String.fromEnvironment('FIREBASE_MESSAGING_SENDER_ID'),
projectId: String.fromEnvironment('FIREBASE_PROJECT_ID'),
vapidKey: String.fromEnvironment('FIREBASE_VAPID_KEY'),
authDomain: String.fromEnvironment('FIREBASE_AUTH_DOMAIN'),
storageBucket: String.fromEnvironment('FIREBASE_STORAGE_BUCKET'),
);
}
final String apiKey;
final String appId;
final String messagingSenderId;
final String projectId;
final String vapidKey;
final String authDomain;
final String storageBucket;
/// True when the minimum set for FCM Web token registration is present.
bool get isReady =>
apiKey.isNotEmpty &&
appId.isNotEmpty &&
messagingSenderId.isNotEmpty &&
projectId.isNotEmpty &&
vapidKey.isNotEmpty;
/// [FirebaseOptions] for [Firebase.initializeApp], or null when not ready.
FirebaseOptions? get options {
if (!isReady) return null;
final domain =
authDomain.isNotEmpty ? authDomain : '$projectId.firebaseapp.com';
final bucket = storageBucket.isNotEmpty
? storageBucket
: '$projectId.appspot.com';
return FirebaseOptions(
apiKey: apiKey,
appId: appId,
messagingSenderId: messagingSenderId,
projectId: projectId,
authDomain: domain,
storageBucket: bucket,
);
}
}

View File

@ -532,256 +532,16 @@ class LocalKvCompanion extends UpdateCompanion<LocalKvData> {
} }
} }
class $ConversationSnoozesTable extends ConversationSnoozes
with TableInfo<$ConversationSnoozesTable, ConversationSnooze> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$ConversationSnoozesTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _conversationIdMeta = const VerificationMeta(
'conversationId',
);
@override
late final GeneratedColumn<String> conversationId = GeneratedColumn<String>(
'conversation_id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _snoozedUntilMsMeta = const VerificationMeta(
'snoozedUntilMs',
);
@override
late final GeneratedColumn<int> snoozedUntilMs = GeneratedColumn<int>(
'snoozed_until_ms',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [conversationId, snoozedUntilMs];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'conversation_snoozes';
@override
VerificationContext validateIntegrity(
Insertable<ConversationSnooze> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('conversation_id')) {
context.handle(
_conversationIdMeta,
conversationId.isAcceptableOrUnknown(
data['conversation_id']!,
_conversationIdMeta,
),
);
} else if (isInserting) {
context.missing(_conversationIdMeta);
}
if (data.containsKey('snoozed_until_ms')) {
context.handle(
_snoozedUntilMsMeta,
snoozedUntilMs.isAcceptableOrUnknown(
data['snoozed_until_ms']!,
_snoozedUntilMsMeta,
),
);
} else if (isInserting) {
context.missing(_snoozedUntilMsMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {conversationId};
@override
ConversationSnooze map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return ConversationSnooze(
conversationId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}conversation_id'],
)!,
snoozedUntilMs: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}snoozed_until_ms'],
)!,
);
}
@override
$ConversationSnoozesTable createAlias(String alias) {
return $ConversationSnoozesTable(attachedDatabase, alias);
}
}
class ConversationSnooze extends DataClass
implements Insertable<ConversationSnooze> {
/// `Conversation.id` (drift
/// text ).
final String conversationId;
final int snoozedUntilMs;
const ConversationSnooze({
required this.conversationId,
required this.snoozedUntilMs,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['conversation_id'] = Variable<String>(conversationId);
map['snoozed_until_ms'] = Variable<int>(snoozedUntilMs);
return map;
}
ConversationSnoozesCompanion toCompanion(bool nullToAbsent) {
return ConversationSnoozesCompanion(
conversationId: Value(conversationId),
snoozedUntilMs: Value(snoozedUntilMs),
);
}
factory ConversationSnooze.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return ConversationSnooze(
conversationId: serializer.fromJson<String>(json['conversationId']),
snoozedUntilMs: serializer.fromJson<int>(json['snoozedUntilMs']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'conversationId': serializer.toJson<String>(conversationId),
'snoozedUntilMs': serializer.toJson<int>(snoozedUntilMs),
};
}
ConversationSnooze copyWith({String? conversationId, int? snoozedUntilMs}) =>
ConversationSnooze(
conversationId: conversationId ?? this.conversationId,
snoozedUntilMs: snoozedUntilMs ?? this.snoozedUntilMs,
);
ConversationSnooze copyWithCompanion(ConversationSnoozesCompanion data) {
return ConversationSnooze(
conversationId: data.conversationId.present
? data.conversationId.value
: this.conversationId,
snoozedUntilMs: data.snoozedUntilMs.present
? data.snoozedUntilMs.value
: this.snoozedUntilMs,
);
}
@override
String toString() {
return (StringBuffer('ConversationSnooze(')
..write('conversationId: $conversationId, ')
..write('snoozedUntilMs: $snoozedUntilMs')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(conversationId, snoozedUntilMs);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ConversationSnooze &&
other.conversationId == this.conversationId &&
other.snoozedUntilMs == this.snoozedUntilMs);
}
class ConversationSnoozesCompanion extends UpdateCompanion<ConversationSnooze> {
final Value<String> conversationId;
final Value<int> snoozedUntilMs;
final Value<int> rowid;
const ConversationSnoozesCompanion({
this.conversationId = const Value.absent(),
this.snoozedUntilMs = const Value.absent(),
this.rowid = const Value.absent(),
});
ConversationSnoozesCompanion.insert({
required String conversationId,
required int snoozedUntilMs,
this.rowid = const Value.absent(),
}) : conversationId = Value(conversationId),
snoozedUntilMs = Value(snoozedUntilMs);
static Insertable<ConversationSnooze> custom({
Expression<String>? conversationId,
Expression<int>? snoozedUntilMs,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (conversationId != null) 'conversation_id': conversationId,
if (snoozedUntilMs != null) 'snoozed_until_ms': snoozedUntilMs,
if (rowid != null) 'rowid': rowid,
});
}
ConversationSnoozesCompanion copyWith({
Value<String>? conversationId,
Value<int>? snoozedUntilMs,
Value<int>? rowid,
}) {
return ConversationSnoozesCompanion(
conversationId: conversationId ?? this.conversationId,
snoozedUntilMs: snoozedUntilMs ?? this.snoozedUntilMs,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (conversationId.present) {
map['conversation_id'] = Variable<String>(conversationId.value);
}
if (snoozedUntilMs.present) {
map['snoozed_until_ms'] = Variable<int>(snoozedUntilMs.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('ConversationSnoozesCompanion(')
..write('conversationId: $conversationId, ')
..write('snoozedUntilMs: $snoozedUntilMs, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase { abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e); _$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this); $AppDatabaseManager get managers => $AppDatabaseManager(this);
late final $ToneSamplesTable toneSamples = $ToneSamplesTable(this); late final $ToneSamplesTable toneSamples = $ToneSamplesTable(this);
late final $LocalKvTable localKv = $LocalKvTable(this); late final $LocalKvTable localKv = $LocalKvTable(this);
late final $ConversationSnoozesTable conversationSnoozes =
$ConversationSnoozesTable(this);
@override @override
Iterable<TableInfo<Table, Object?>> get allTables => Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>(); allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override @override
List<DatabaseSchemaEntity> get allSchemaEntities => [ List<DatabaseSchemaEntity> get allSchemaEntities => [toneSamples, localKv];
toneSamples,
localKv,
conversationSnoozes,
];
} }
typedef $$ToneSamplesTableCreateCompanionBuilder = typedef $$ToneSamplesTableCreateCompanionBuilder =
@ -1102,169 +862,6 @@ typedef $$LocalKvTableProcessedTableManager =
LocalKvData, LocalKvData,
PrefetchHooks Function() PrefetchHooks Function()
>; >;
typedef $$ConversationSnoozesTableCreateCompanionBuilder =
ConversationSnoozesCompanion Function({
required String conversationId,
required int snoozedUntilMs,
Value<int> rowid,
});
typedef $$ConversationSnoozesTableUpdateCompanionBuilder =
ConversationSnoozesCompanion Function({
Value<String> conversationId,
Value<int> snoozedUntilMs,
Value<int> rowid,
});
class $$ConversationSnoozesTableFilterComposer
extends Composer<_$AppDatabase, $ConversationSnoozesTable> {
$$ConversationSnoozesTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get conversationId => $composableBuilder(
column: $table.conversationId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get snoozedUntilMs => $composableBuilder(
column: $table.snoozedUntilMs,
builder: (column) => ColumnFilters(column),
);
}
class $$ConversationSnoozesTableOrderingComposer
extends Composer<_$AppDatabase, $ConversationSnoozesTable> {
$$ConversationSnoozesTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get conversationId => $composableBuilder(
column: $table.conversationId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get snoozedUntilMs => $composableBuilder(
column: $table.snoozedUntilMs,
builder: (column) => ColumnOrderings(column),
);
}
class $$ConversationSnoozesTableAnnotationComposer
extends Composer<_$AppDatabase, $ConversationSnoozesTable> {
$$ConversationSnoozesTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get conversationId => $composableBuilder(
column: $table.conversationId,
builder: (column) => column,
);
GeneratedColumn<int> get snoozedUntilMs => $composableBuilder(
column: $table.snoozedUntilMs,
builder: (column) => column,
);
}
class $$ConversationSnoozesTableTableManager
extends
RootTableManager<
_$AppDatabase,
$ConversationSnoozesTable,
ConversationSnooze,
$$ConversationSnoozesTableFilterComposer,
$$ConversationSnoozesTableOrderingComposer,
$$ConversationSnoozesTableAnnotationComposer,
$$ConversationSnoozesTableCreateCompanionBuilder,
$$ConversationSnoozesTableUpdateCompanionBuilder,
(
ConversationSnooze,
BaseReferences<
_$AppDatabase,
$ConversationSnoozesTable,
ConversationSnooze
>,
),
ConversationSnooze,
PrefetchHooks Function()
> {
$$ConversationSnoozesTableTableManager(
_$AppDatabase db,
$ConversationSnoozesTable table,
) : super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$ConversationSnoozesTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$ConversationSnoozesTableOrderingComposer(
$db: db,
$table: table,
),
createComputedFieldComposer: () =>
$$ConversationSnoozesTableAnnotationComposer(
$db: db,
$table: table,
),
updateCompanionCallback:
({
Value<String> conversationId = const Value.absent(),
Value<int> snoozedUntilMs = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => ConversationSnoozesCompanion(
conversationId: conversationId,
snoozedUntilMs: snoozedUntilMs,
rowid: rowid,
),
createCompanionCallback:
({
required String conversationId,
required int snoozedUntilMs,
Value<int> rowid = const Value.absent(),
}) => ConversationSnoozesCompanion.insert(
conversationId: conversationId,
snoozedUntilMs: snoozedUntilMs,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$ConversationSnoozesTableProcessedTableManager =
ProcessedTableManager<
_$AppDatabase,
$ConversationSnoozesTable,
ConversationSnooze,
$$ConversationSnoozesTableFilterComposer,
$$ConversationSnoozesTableOrderingComposer,
$$ConversationSnoozesTableAnnotationComposer,
$$ConversationSnoozesTableCreateCompanionBuilder,
$$ConversationSnoozesTableUpdateCompanionBuilder,
(
ConversationSnooze,
BaseReferences<
_$AppDatabase,
$ConversationSnoozesTable,
ConversationSnooze
>,
),
ConversationSnooze,
PrefetchHooks Function()
>;
class $AppDatabaseManager { class $AppDatabaseManager {
final _$AppDatabase _db; final _$AppDatabase _db;
@ -1273,6 +870,4 @@ class $AppDatabaseManager {
$$ToneSamplesTableTableManager(_db, _db.toneSamples); $$ToneSamplesTableTableManager(_db, _db.toneSamples);
$$LocalKvTableTableManager get localKv => $$LocalKvTableTableManager get localKv =>
$$LocalKvTableTableManager(_db, _db.localKv); $$LocalKvTableTableManager(_db, _db.localKv);
$$ConversationSnoozesTableTableManager get conversationSnoozes =>
$$ConversationSnoozesTableTableManager(_db, _db.conversationSnoozes);
} }

View File

@ -13,9 +13,9 @@ import 'package:sqlite3/open.dart';
import 'tables.dart'; import 'tables.dart';
part 'app_database_native.g.dart'; part 'app_database.g.dart';
@DriftDatabase(tables: [ToneSamples, LocalKv, ConversationSnoozes]) @DriftDatabase(tables: [ToneSamples, LocalKv])
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
AppDatabase(super.e, {this.encrypted = false}); AppDatabase(super.e, {this.encrypted = false});
@ -23,18 +23,7 @@ class AppDatabase extends _$AppDatabase {
final bool encrypted; final bool encrypted;
@override @override
int get schemaVersion => 2; int get schemaVersion => 1;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
onUpgrade: (m, from, to) async {
// v1 -> v2: roadmap.md §2.7-F .
if (from < 2) {
await m.createTable(conversationSnoozes);
}
},
);
/// In-memory DB for unit tests (no SQLCipher / filesystem). /// In-memory DB for unit tests (no SQLCipher / filesystem).
factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory(), encrypted: false); factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory(), encrypted: false);
@ -87,42 +76,6 @@ class AppDatabase extends _$AppDatabase {
Future<void> setBoolKv(String key, bool value) async { Future<void> setBoolKv(String key, bool value) async {
await setKv(key, value ? '1' : '0'); await setKv(key, value ? '1' : '0');
} }
/// (roadmap.md §2.7-F) "이따 답장" [conversationId]
/// . .
Future<void> setSnoozedUntil(int conversationId, DateTime until) async {
await into(conversationSnoozes).insertOnConflictUpdate(
ConversationSnoozesCompanion.insert(
conversationId: conversationId.toString(),
snoozedUntilMs: until.millisecondsSinceEpoch,
),
);
}
Future<DateTime?> getSnoozedUntil(int conversationId) async {
final row = await (select(conversationSnoozes)
..where((t) => t.conversationId.equals(conversationId.toString())))
.getSingleOrNull();
if (row == null) return null;
// isUtc: true millisecondsSinceEpoch는
// , UTC로 DateTime.== ( ) .
return DateTime.fromMillisecondsSinceEpoch(row.snoozedUntilMs, isUtc: true);
}
Future<void> clearSnooze(int conversationId) async {
await (delete(conversationSnoozes)
..where((t) => t.conversationId.equals(conversationId.toString())))
.go();
}
/// (`conversation_list_screen.dart`) .
Future<Map<int, DateTime>> loadAllSnoozes() async {
final rows = await select(conversationSnoozes).get();
return {
for (final r in rows)
int.parse(r.conversationId): DateTime.fromMillisecondsSinceEpoch(r.snoozedUntilMs, isUtc: true),
};
}
} }
const _kDbPassphrase = 'db_passphrase_v1'; const _kDbPassphrase = 'db_passphrase_v1';

View File

@ -1,125 +1,31 @@
import 'dart:convert'; /// Web/Chrome stub for [AppDatabase].
import 'package:shared_preferences/shared_preferences.dart';
/// Web/Chrome [AppDatabase] backed by [SharedPreferences] (IndexedDB under the hood).
/// ///
/// SQLCipher / dart:ffi cannot compile for Flutter Web. Persistence is /// SQLCipher / dart:ffi cannot compile for Flutter Web. Chrome runs use an
/// **not encrypted** acceptable for the demo/preview surface /// in-tab memory map so UI + API flows still work. Persistence is not encrypted
/// (`docs/web-upgrade.md` N4-W1). Drift WASM is intentionally not used. /// and is lost on refresh (web is a preview surface, not the release target).
///
/// [AppDatabase.memory] stays ephemeral (unit tests / fallbacks).
class AppDatabase { class AppDatabase {
AppDatabase({ AppDatabase({this.encrypted = false});
this.encrypted = false,
SharedPreferences? prefs,
bool persist = false,
}) : _prefs = prefs,
_persist = persist && prefs != null;
/// Always false on web there is no SQLCipher path. /// Always false on web there is no SQLCipher path.
final bool encrypted; final bool encrypted;
final SharedPreferences? _prefs;
final bool _persist;
final Map<String, String> _kv = {}; final Map<String, String> _kv = {};
List<String> _toneSamples = []; List<String> _toneSamples = [];
final Map<int, DateTime> _snoozes = {};
static const _kTone = 'ykavu_web_db_tone_samples'; factory AppDatabase.memory() => AppDatabase(encrypted: false);
static const _kSnoozes = 'ykavu_web_db_snoozes';
static const _kvPrefix = 'ykavu_web_db_kv_';
factory AppDatabase.memory() => AppDatabase(encrypted: false, persist: false); static Future<AppDatabase> open() async => AppDatabase(encrypted: false);
/// Opens a durable preview DB (survives hard refresh in the same browser profile).
static Future<AppDatabase> open() async {
final prefs = await SharedPreferences.getInstance();
final db = AppDatabase(encrypted: false, prefs: prefs, persist: true);
await db._hydrate();
return db;
}
Future<void> _hydrate() async {
final prefs = _prefs;
if (!_persist || prefs == null) return;
try {
final toneRaw = prefs.getString(_kTone);
if (toneRaw != null && toneRaw.isNotEmpty) {
final decoded = jsonDecode(toneRaw);
if (decoded is List) {
_toneSamples = decoded.map((e) => e.toString()).toList();
}
}
} catch (_) {
_toneSamples = [];
await prefs.remove(_kTone);
}
try {
final snoozeRaw = prefs.getString(_kSnoozes);
if (snoozeRaw != null && snoozeRaw.isNotEmpty) {
final decoded = jsonDecode(snoozeRaw);
if (decoded is Map) {
_snoozes
..clear()
..addEntries(
decoded.entries.map((e) {
final id = int.tryParse(e.key.toString());
final ms = e.value is int
? e.value as int
: int.tryParse(e.value.toString());
if (id == null || ms == null) return null;
return MapEntry(id, DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true));
}).whereType<MapEntry<int, DateTime>>(),
);
}
}
} catch (_) {
_snoozes.clear();
await prefs.remove(_kSnoozes);
}
for (final key in prefs.getKeys()) {
if (!key.startsWith(_kvPrefix)) continue;
final logical = key.substring(_kvPrefix.length);
final value = prefs.getString(key);
if (value != null) _kv[logical] = value;
}
}
Future<void> _persistTone() async {
if (!_persist || _prefs == null) return;
await _prefs.setString(_kTone, jsonEncode(_toneSamples));
}
Future<void> _persistSnoozes() async {
if (!_persist || _prefs == null) return;
final map = <String, int>{
for (final e in _snoozes.entries) e.key.toString(): e.value.toUtc().millisecondsSinceEpoch,
};
await _prefs.setString(_kSnoozes, jsonEncode(map));
}
Future<void> _persistKv(String key, String value) async {
if (!_persist || _prefs == null) return;
await _prefs.setString('$_kvPrefix$key', value);
}
Future<List<String>> loadToneSamples() async => List<String>.from(_toneSamples); Future<List<String>> loadToneSamples() async => List<String>.from(_toneSamples);
Future<void> replaceToneSamples(List<String> samples) async { Future<void> replaceToneSamples(List<String> samples) async {
_toneSamples = List<String>.from(samples); _toneSamples = List<String>.from(samples);
await _persistTone();
} }
Future<String?> getKv(String key) async => _kv[key]; Future<String?> getKv(String key) async => _kv[key];
Future<void> setKv(String key, String value) async { Future<void> setKv(String key, String value) async {
_kv[key] = value; _kv[key] = value;
await _persistKv(key, value);
} }
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async { Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
@ -132,20 +38,5 @@ class AppDatabase {
await setKv(key, value ? '1' : '0'); await setKv(key, value ? '1' : '0');
} }
/// roadmap.md §2.7-F on-device snooze; web preview persists via SharedPreferences.
Future<void> setSnoozedUntil(int conversationId, DateTime until) async {
_snoozes[conversationId] = until.toUtc();
await _persistSnoozes();
}
Future<DateTime?> getSnoozedUntil(int conversationId) async => _snoozes[conversationId];
Future<void> clearSnooze(int conversationId) async {
_snoozes.remove(conversationId);
await _persistSnoozes();
}
Future<Map<int, DateTime>> loadAllSnoozes() async => Map<int, DateTime>.from(_snoozes);
Future<void> close() async {} Future<void> close() async {}
} }

View File

@ -19,16 +19,3 @@ class LocalKv extends Table {
@override @override
Set<Column<Object>> get primaryKey => {key}; Set<Column<Object>> get primaryKey => {key};
} }
/// (roadmap.md §2.7-F) "이따 답장" .
/// ( ,
/// AGENTS.md "Tone/style learning: on-device first" ).
class ConversationSnoozes extends Table {
/// `Conversation.id` (drift
/// text ).
TextColumn get conversationId => text()();
IntColumn get snoozedUntilMs => integer()();
@override
Set<Column<Object>> get primaryKey => {conversationId};
}

View File

@ -89,8 +89,7 @@ class _BootstrapState extends State<_Bootstrap> {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.light(), theme: AppTheme.light(),
darkTheme: AppTheme.dark(), darkTheme: AppTheme.dark(),
// Default to light messenger UI; OS dark still maps to soft charcoal. themeMode: ThemeMode.system,
themeMode: ThemeMode.light,
builder: (context, child) => GradientBackdrop(child: child), builder: (context, child) => GradientBackdrop(child: child),
home: const SplashScreen(), home: const SplashScreen(),
); );
@ -113,8 +112,7 @@ class YkavuApp extends StatelessWidget {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.light(), theme: AppTheme.light(),
darkTheme: AppTheme.dark(), darkTheme: AppTheme.dark(),
// Default to light messenger UI; OS dark still maps to soft charcoal. themeMode: ThemeMode.system,
themeMode: ThemeMode.light,
builder: (context, child) => GradientBackdrop(child: child), builder: (context, child) => GradientBackdrop(child: child),
home: Consumer<SessionState>( home: Consumer<SessionState>(
builder: (context, s, _) { builder: (context, s, _) {

View File

@ -4,26 +4,6 @@ enum SenderMode { human, twin }
// ignore: constant_identifier_names // ignore: constant_identifier_names
enum AutonomyLevel { L0, L1, L2 } enum AutonomyLevel { L0, L1, L2 }
extension AutonomyLevelLabel on AutonomyLevel {
/// Short label reused verbatim from autonomy_settings_screen.dart's
/// segmented-button labels ("L0"/"L1"/"L2") so per-contact chips
/// (roadmap.md §2.7-D) don't invent new copy.
String get label => name;
}
/// (roadmap.md §2.7-B, PRD.md §2.1-/§3.1) minimum 2 tiers.
enum RelationshipTier {
close,
formal;
static RelationshipTier fromJson(String? raw) => RelationshipTier.values.firstWhere(
(e) => e.name == raw,
orElse: () => RelationshipTier.formal,
);
String get label => this == RelationshipTier.close ? '가까운 사이' : '공식적인 사이';
}
class User { class User {
User({required this.id, required this.displayName, required this.inviteCode}); User({required this.id, required this.displayName, required this.inviteCode});
@ -39,10 +19,9 @@ class User {
} }
class TwinSettings { class TwinSettings {
TwinSettings({required this.autonomyLevel, required this.relationshipTier}); TwinSettings({required this.autonomyLevel});
final AutonomyLevel autonomyLevel; final AutonomyLevel autonomyLevel;
final RelationshipTier relationshipTier;
factory TwinSettings.fromJson(Map<String, dynamic> json) { factory TwinSettings.fromJson(Map<String, dynamic> json) {
final raw = (json['autonomy_level'] as String? ?? 'L0').toUpperCase(); final raw = (json['autonomy_level'] as String? ?? 'L0').toUpperCase();
@ -51,7 +30,6 @@ class TwinSettings {
(e) => e.name == raw, (e) => e.name == raw,
orElse: () => AutonomyLevel.L0, orElse: () => AutonomyLevel.L0,
), ),
relationshipTier: RelationshipTier.fromJson(json['relationship_tier'] as String?),
); );
} }
} }
@ -62,8 +40,6 @@ class ConversationSummary {
required this.isGroup, required this.isGroup,
required this.userIds, required this.userIds,
required this.twinDisabledByPeer, required this.twinDisabledByPeer,
this.twinDisabledByFlood = false,
this.unreadCount = 0,
this.createdAt, this.createdAt,
}); });
@ -71,10 +47,6 @@ class ConversationSummary {
final bool isGroup; final bool isGroup;
final List<int> userIds; final List<int> userIds;
final bool twinDisabledByPeer; final bool twinDisabledByPeer;
// roadmap.md §2.7-C / : ( )
// twinDisabledByPeer와 (POST /conversations/:id/flood-reset).
final bool twinDisabledByFlood;
final int unreadCount;
final DateTime? createdAt; final DateTime? createdAt;
factory ConversationSummary.fromJson(Map<String, dynamic> json) { factory ConversationSummary.fromJson(Map<String, dynamic> json) {
@ -84,8 +56,6 @@ class ConversationSummary {
isGroup: json['is_group'] as bool? ?? false, isGroup: json['is_group'] as bool? ?? false,
userIds: rawIds.map((e) => e as int).toList(), userIds: rawIds.map((e) => e as int).toList(),
twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false, twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false,
twinDisabledByFlood: json['twin_disabled_by_flood'] as bool? ?? false,
unreadCount: json['unread_count'] as int? ?? 0,
createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''), createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''),
); );
} }
@ -105,36 +75,18 @@ class Contact {
required this.displayName, required this.displayName,
this.contactUserId, this.contactUserId,
this.relationshipNote = '', this.relationshipNote = '',
this.relationshipTier,
this.autonomyLevel,
}); });
final int id; final int id;
final String displayName; final String displayName;
final int? contactUserId; final int? contactUserId;
final String relationshipNote; final String relationshipNote;
/// Per-contact override of the global relationship tier (roadmap.md
/// §2.7-B). Null means "use the global default".
final RelationshipTier? relationshipTier;
/// Per-contact override of the global autonomy level (roadmap.md §2.7-D,
/// PRD.md §3.1 "전역 기본값 + 상대별 예외 설정"). Null means "use the
/// global default".
final AutonomyLevel? autonomyLevel;
factory Contact.fromJson(Map<String, dynamic> json) => Contact( factory Contact.fromJson(Map<String, dynamic> json) => Contact(
id: json['id'] as int, id: json['id'] as int,
displayName: json['display_name'] as String? ?? '', displayName: json['display_name'] as String? ?? '',
contactUserId: json['contact_user_id'] as int?, contactUserId: json['contact_user_id'] as int?,
relationshipNote: json['relationship_note'] as String? ?? '', relationshipNote: json['relationship_note'] as String? ?? '',
relationshipTier: json['relationship_tier'] == null
? null
: RelationshipTier.fromJson(json['relationship_tier'] as String?),
autonomyLevel: json['autonomy_level'] == null
? null
: AutonomyLevel.values.firstWhere(
(e) => e.name == json['autonomy_level'] as String?,
orElse: () => AutonomyLevel.L0,
),
); );
} }
@ -174,8 +126,6 @@ class ChatMessage {
required this.text, required this.text,
required this.retracted, required this.retracted,
required this.createdAt, required this.createdAt,
this.draftEdited,
this.naturalnessRating,
}); });
final int id; final int id;
@ -185,15 +135,6 @@ class ChatMessage {
final String text; final String text;
final bool retracted; final bool retracted;
final DateTime createdAt; final DateTime createdAt;
/// L1 approval-rate proxy (PRD.md §5, deploy-checklist.md N4-12) null
/// for human messages and any twin message the server couldn't diff
/// against an original draft; otherwise true = edited before send,
/// false = sent verbatim. Purely informational on the client; the server
/// is the source of truth (computed once at send time).
final bool? draftEdited;
/// "이 답장 나답아요?" explicit feedback (vision.md metric, N4-12) null
/// = not rated yet, true = 👍, false = 👎.
final bool? naturalnessRating;
bool get isTwin => senderMode == SenderMode.twin; bool get isTwin => senderMode == SenderMode.twin;
@ -207,8 +148,6 @@ class ChatMessage {
text: json['text'] as String? ?? '', text: json['text'] as String? ?? '',
retracted: json['retracted'] as bool? ?? false, retracted: json['retracted'] as bool? ?? false,
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(), createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(),
draftEdited: json['draft_edited'] as bool?,
naturalnessRating: json['naturalness_rating'] as bool?,
); );
} }
} }
@ -227,30 +166,6 @@ class WhitelistRule {
); );
} }
/// (roadmap.md §2.7-A) GET /conversations/:id/summary result.
class GroupSummaryResult {
GroupSummaryResult({
required this.status,
required this.summary,
required this.unreadCount,
required this.needsReply,
});
final String status; // empty | ok
final String summary;
final int unreadCount;
final bool needsReply;
factory GroupSummaryResult.fromJson(Map<String, dynamic> json) => GroupSummaryResult(
status: json['status'] as String? ?? 'empty',
summary: json['summary'] as String? ?? '',
unreadCount: json['unread_count'] as int? ?? 0,
needsReply: json['needs_reply'] as bool? ?? false,
);
bool get isEmpty => status == 'empty' || unreadCount == 0;
}
class DraftResult { class DraftResult {
DraftResult({required this.status, required this.text}); DraftResult({required this.status, required this.text});

View File

@ -66,27 +66,24 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
body: ListView( body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [ children: [
Container( Card(
decoration: BoxDecoration(
color: AppTheme.glassFill(theme.brightness),
borderRadius: BorderRadius.circular(AppTheme.rPanel),
border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
),
child: Column( child: Column(
children: [ children: [
ListTile( ListTile(
leading: const Icon(Icons.privacy_tip_outlined),
title: const Text('데이터 흐름'), title: const Text('데이터 흐름'),
subtitle: const Text('무엇이 기기에 남고 서버로 가는지'), subtitle: const Text('무엇이 기기에 남고 서버로 가는지'),
trailing: const Icon(Icons.chevron_right, size: 18), trailing: const Icon(Icons.chevron_right),
onTap: () { onTap: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DataFlowScreen())); Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DataFlowScreen()));
}, },
), ),
Divider(height: 1, indent: 16, endIndent: 16, color: AppTheme.glassBorder(theme.brightness)), const Divider(height: 1, indent: 16, endIndent: 16),
ListTile( ListTile(
leading: const Icon(Icons.devices),
title: const Text('로그인 세션'), title: const Text('로그인 세션'),
subtitle: const Text('멀티 디바이스 세션 목록'), subtitle: const Text('멀티 디바이스 세션 목록'),
trailing: const Icon(Icons.chevron_right, size: 18), trailing: const Icon(Icons.chevron_right),
onTap: () { onTap: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SessionsScreen())); Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SessionsScreen()));
}, },
@ -129,21 +126,6 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
), ),
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
Text('기본 관계 설정', style: theme.textTheme.titleMedium),
const SizedBox(height: 6),
Text(
'연락처별로 다르게 설정하지 않은 상대에게는 이 기본값이 적용됩니다.',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
SegmentedButton<RelationshipTier>(
segments: RelationshipTier.values
.map((t) => ButtonSegment(value: t, label: Text(t.label)))
.toList(),
selected: {session.relationshipTier},
onSelectionChanged: (s) => session.setRelationshipTier(s.first),
),
const SizedBox(height: 28),
Text('L2 화이트리스트 주제', style: theme.textTheme.titleMedium), Text('L2 화이트리스트 주제', style: theme.textTheme.titleMedium),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(

View File

@ -5,202 +5,39 @@ import 'package:provider/provider.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../services/message_sync.dart';
import '../services/snooze_service.dart';
import '../services/ws_client.dart'; import '../services/ws_client.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../widgets/message_bubble.dart'; import '../widgets/message_bubble.dart';
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
const ChatScreen({ const ChatScreen({super.key, required this.conversationId, this.title});
super.key,
required this.conversationId,
this.title,
this.isGroup = false,
this.twinDisabledByFlood = false,
this.embedded = false,
});
final int conversationId; final int conversationId;
final String? title; final String? title;
final bool isGroup;
// roadmap.md §2.7-C :
// , ( ) +
// .
final bool twinDisabledByFlood;
/// When true, shown inside the wide master-detail pane (N4-W2) no back affordance.
final bool embedded;
@override @override
State<ChatScreen> createState() => _ChatScreenState(); State<ChatScreen> createState() => _ChatScreenState();
} }
class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver { class _ChatScreenState extends State<ChatScreen> {
final _input = TextEditingController(); final _input = TextEditingController();
final _draftEdit = TextEditingController(); final _draftEdit = TextEditingController();
final _scroll = ScrollController(); final _scroll = ScrollController();
final _messages = <ChatMessage>[]; final _messages = <ChatMessage>[];
ConversationSocket? _socket; ConversationSocket? _socket;
StreamSubscription? _sub; StreamSubscription? _sub;
// (roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11):
// . /
// REST since_id
// , ( ,
// OS가 ) QA .
StreamSubscription? _reconnectSub;
StreamSubscription? _linkSub;
WsLinkState _linkState = WsLinkState.connecting;
String? _banner; String? _banner;
DraftResult? _pendingDraft; DraftResult? _pendingDraft;
bool _busy = false; bool _busy = false;
bool _loadingHistory = true; bool _loadingHistory = true;
late bool _floodBlocked;
late final ApiClient _api;
// "이 답장 나답아요?" (vision.md , deploy-checklist.md N4-12):
// ( ) id들
// ( ) .
final _ratedMessageIds = <int>{};
// (roadmap.md §2.7-F) "이따 답장" .
// DB에서 .
DateTime? _snoozedUntil;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_api = context.read<SessionState>().api;
_floodBlocked = widget.twinDisabledByFlood;
if (_floodBlocked) {
_banner = '도배 감지로 이 대화방의 와카뷰 자동응대가 일시중단되었습니다.';
}
_socket = ConversationSocket(widget.conversationId)..connect(); _socket = ConversationSocket(widget.conversationId)..connect();
_linkState = _socket!.linkState;
_sub = _socket!.events.listen(_onEvent); _sub = _socket!.events.listen(_onEvent);
// (, hiccup )
// REST since_id로 ws_client.dart .
_reconnectSub = _socket!.reconnects.listen((_) => _catchUp());
_linkSub = _socket!.linkStates.listen((s) {
if (!mounted) return;
setState(() => _linkState = s);
});
_loadHistory(); _loadHistory();
_loadSnooze();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
//
// (
// ). OS가 QA .
if (state == AppLifecycleState.resumed) {
_catchUp();
// N4-W3 past-due snooze Notification on web focus (badges already refresh).
context.read<SessionState>().snoozeController?.notifyPastDueOnFocus();
_loadSnooze();
}
}
/// : id
/// REST로 , /REST가 .
Future<void> _catchUp() async {
if (!mounted || _loadingHistory) return;
final sinceId = highestMessageId(_messages);
try {
final fresh = await _api.listMessages(widget.conversationId, sinceId: sinceId);
if (!mounted || fresh.isEmpty) return;
final merged = mergeNewMessages(_messages, fresh);
setState(() {
_messages
..clear()
..addAll(merged);
_syncRatedMessageIds(merged);
});
_scrollToEnd();
_markLatestRead();
} on ApiException {
// Best-effort ,
// / .
}
}
Future<void> _loadSnooze() async {
final controller = context.read<SessionState>().snoozeController;
if (controller == null) return;
final until = await controller.loadSnoozedUntil(widget.conversationId);
if (!mounted) return;
setState(() => _snoozedUntil = until);
}
bool get _snoozePastDue => isSnoozePastDue(DateTime.now(), _snoozedUntil);
String _formatSnoozeUntil(DateTime dt) =>
'${dt.month}/${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
/// "이따 답장" (roadmap.md §2.7-F).
/// .
Future<void> _openSnoozeMenu() async {
final now = DateTime.now();
final choice = await showDialog<Object>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('이따 답장'),
children: [
for (final pick in SnoozeQuickPick.values)
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, pick),
child: Text(pick.label),
),
if (_snoozedUntil != null)
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, 'clear'),
child: Text('스누즈 해제', style: TextStyle(color: Theme.of(ctx).colorScheme.error)),
),
],
),
);
if (choice == null || !mounted) return;
if (choice == 'clear') {
await _clearSnooze();
return;
}
if (choice is SnoozeQuickPick) {
await _applySnooze(resolveSnoozeQuickPick(choice, now));
}
}
Future<void> _applySnooze(DateTime until) async {
final controller = context.read<SessionState>().snoozeController;
if (controller != null) {
await controller.applySnooze(
conversationId: widget.conversationId,
until: until,
title: '답장 마감',
body: '${widget.title ?? "대화방 #${widget.conversationId}"}에 답장할 시간이에요',
);
}
if (!mounted) return;
setState(() {
_snoozedUntil = until;
_banner = '이따 답장: ${_formatSnoozeUntil(until)}에 리마인드합니다 (본인에게만 표시).';
});
}
/// "스누즈 해제" .
Future<void> _clearSnooze() async {
await _clearSnoozeQuiet();
if (!mounted) return;
setState(() => _banner = '스누즈를 해제했습니다.');
}
/// ( ).
Future<void> _clearSnoozeQuiet() async {
if (_snoozedUntil == null) return;
final controller = context.read<SessionState>().snoozeController;
if (controller != null) await controller.clearSnooze(widget.conversationId);
if (!mounted) return;
setState(() => _snoozedUntil = null);
} }
Future<void> _loadHistory() async { Future<void> _loadHistory() async {
@ -213,14 +50,8 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
..clear() ..clear()
..addAll(history); ..addAll(history);
_loadingHistory = false; _loadingHistory = false;
_syncRatedMessageIds(history);
}); });
_scrollToEnd(); _scrollToEnd();
// "나갈 때"(dispose) --
// 0
// "안 본 동안 요약" (roadmap.md
// §2.7-A).
// (_onEvent ).
} on ApiException catch (e) { } on ApiException catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@ -230,17 +61,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
} }
} }
/// (roadmap.md §2.7-A) .
Future<void> _markLatestRead() async {
if (_messages.isEmpty) return;
final latestId = _messages.map((m) => m.id).reduce((a, b) => a > b ? a : b);
try {
await _api.markRead(widget.conversationId, latestId);
} on ApiException {
// Best-effort an unread badge staying stale isn't worth surfacing an error for.
}
}
void _scrollToEnd() { void _scrollToEnd() {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scroll.hasClients) return; if (!_scroll.hasClients) return;
@ -252,28 +72,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
}); });
} }
/// ( / ,
/// ) _ratedMessageIds에 UI를
/// . message_sync.dart의 (ratedMessageIdsFrom)
/// .
void _syncRatedMessageIds(List<ChatMessage> messages) {
_ratedMessageIds.addAll(ratedMessageIdsFrom(messages));
}
/// "이 답장 나답아요?" (vision.md , deploy-checklist.md
/// N4-12) (MessageBubble이 twin +
/// isMine + !retracted로 ). .
Future<void> _submitFeedback(ChatMessage message, bool natural) async {
setState(() => _ratedMessageIds.add(message.id));
try {
await _api.submitMessageFeedback(message.id, natural);
} on ApiException {
// Best-effort this is a soft signal, not a safety-critical action;
// if it fails to save server-side we don't re-offer the tap (that
// would look like a broken button), we just quietly drop it.
}
}
void _onEvent(Map<String, dynamic> event) { void _onEvent(Map<String, dynamic> event) {
final retractionId = _socket!.parseRetractionId(event); final retractionId = _socket!.parseRetractionId(event);
if (retractionId != null) { if (retractionId != null) {
@ -289,8 +87,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
text: m.text, text: m.text,
retracted: true, retracted: true,
createdAt: m.createdAt, createdAt: m.createdAt,
draftEdited: m.draftEdited,
naturalnessRating: m.naturalnessRating,
); );
} }
}); });
@ -301,19 +97,11 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
if (_messages.any((m) => m.id == msg.id)) return; if (_messages.any((m) => m.id == msg.id)) return;
setState(() => _messages.add(msg)); setState(() => _messages.add(msg));
_scrollToEnd(); _scrollToEnd();
_markLatestRead();
} }
@override @override
void dispose() { void dispose() {
// _loadHistory() .
// Fire-and-forget: dispose는 ,
// .
_markLatestRead();
WidgetsBinding.instance.removeObserver(this);
_sub?.cancel(); _sub?.cancel();
_reconnectSub?.cancel();
_linkSub?.cancel();
_socket?.dispose(); _socket?.dispose();
_input.dispose(); _input.dispose();
_draftEdit.dispose(); _draftEdit.dispose();
@ -337,8 +125,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
setState(() => _messages.add(msg)); setState(() => _messages.add(msg));
} }
_scrollToEnd(); _scrollToEnd();
// roadmap.md §2.7-F: .
_clearSnoozeQuiet();
} on ApiException catch (e) { } on ApiException catch (e) {
setState(() => _banner = '전송 실패 (${e.statusCode})'); setState(() => _banner = '전송 실패 (${e.statusCode})');
} finally { } finally {
@ -390,17 +176,13 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
final text = _draftEdit.text.trim(); final text = _draftEdit.text.trim();
if (text.isEmpty) return; if (text.isEmpty) return;
// L0: twin send is forbidden server-side move text to human composer // L0: twin send is forbidden server-side move text to human composer instead.
// instead. L0 if (session.autonomyLevel == AutonomyLevel.L0) {
// (PRD.md §2.3-, roadmap.md §2.7-A "이 시나리오는 L0 고정, 자동 발송 없음").
if (session.autonomyLevel == AutonomyLevel.L0 || widget.isGroup) {
setState(() { setState(() {
_input.text = text; _input.text = text;
_pendingDraft = null; _pendingDraft = null;
_draftEdit.clear(); _draftEdit.clear();
_banner = widget.isGroup _banner = 'L0(비서 모드)에서는 와카뷰로 보낼 수 없습니다. 아래 입력창에서 직접 보내거나, 자율성 설정을 L1으로 바꾸세요.';
? '단톡에서는 와카뷰가 자동으로 보내지 않습니다. 초안을 검토하고 아래 입력창에서 직접 보내세요.'
: 'L0(비서 모드)에서는 와카뷰로 보낼 수 없습니다. 아래 입력창에서 직접 보내거나, 자율성 설정을 L1으로 바꾸세요.';
}); });
return; return;
} }
@ -413,10 +195,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
text: text, text: text,
senderMode: SenderMode.twin, senderMode: SenderMode.twin,
approved: true, approved: true,
// (PRD.md §5, N4-12): draft.text는
// (_draftEdit) AI
// text와 diff해 draft_edited를 .
originalDraftText: draft.text,
); );
setState(() { setState(() {
_pendingDraft = null; _pendingDraft = null;
@ -424,42 +202,13 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg); if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg);
}); });
_scrollToEnd(); _scrollToEnd();
// roadmap.md §2.7-F: .
_clearSnoozeQuiet();
} on ApiException catch (e) { } on ApiException catch (e) {
setState(() { setState(() => _banner = '와카뷰 발송 차단 (${e.statusCode}): ${e.body}');
_banner = '와카뷰 발송 차단 (${e.statusCode}): ${e.body}';
// roadmap.md §2.7-C:
// ,
// .
if (e.body.contains('도배')) _floodBlocked = true;
});
} finally { } finally {
setState(() => _busy = false); setState(() => _busy = false);
} }
} }
/// (roadmap.md §2.7-C,
/// AGENTS.md "every automatic action needs post-hoc notification +
/// one-tap undo"). 거부권(veto)과 달리 이 중단은 시스템이 자동으로 취한
/// .
Future<void> _resumeFlood() async {
final session = context.read<SessionState>();
setState(() => _busy = true);
try {
await session.api.resetFlood(widget.conversationId);
if (!mounted) return;
setState(() {
_floodBlocked = false;
_banner = '와카뷰 자동응대를 다시 켰습니다.';
});
} on ApiException catch (e) {
setState(() => _banner = '재개 실패 (${e.statusCode})');
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _rejectDraft() { void _rejectDraft() {
setState(() { setState(() {
_pendingDraft = null; _pendingDraft = null;
@ -499,57 +248,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
} }
} }
/// (roadmap.md §2.7-A): 3~5
/// .
/// ( L0 ).
Future<void> _openSummary() async {
final session = context.read<SessionState>();
GroupSummaryResult? result;
String? error;
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) {
if (result == null && error == null) {
session.api.getGroupSummary(widget.conversationId).then((r) {
setDialogState(() => result = r);
}).catchError((e) {
setDialogState(() => error = e is ApiException ? '요약 실패 (${e.statusCode})' : '요약 실패');
});
}
final r = result;
return AlertDialog(
title: const Text('안 본 동안 요약'),
content: SizedBox(
width: 320,
child: error != null
? Text(error!, style: TextStyle(color: Theme.of(ctx).colorScheme.error))
: r == null
? const SizedBox(
height: 60,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
)
: r.isEmpty
? const Text('안 본 메시지가 없습니다.')
: Text(r.summary.isEmpty ? '요약할 내용이 없습니다.' : r.summary),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('닫기')),
if (r != null && !r.isEmpty && r.needsReply)
FilledButton(
onPressed: () {
Navigator.pop(ctx);
_requestDraft();
},
child: const Text('초안 요청'),
),
],
);
},
),
);
}
Widget _buildL1Panel(BuildContext context) { Widget _buildL1Panel(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final draft = _pendingDraft; final draft = _pendingDraft;
@ -558,10 +256,10 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
if (draft.isEscalate) { if (draft.isEscalate) {
return Container( return Container(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.errorContainer.withValues(alpha: 0.55), color: theme.colorScheme.errorContainer.withOpacity(0.85),
borderRadius: BorderRadius.circular(AppTheme.rPanel), borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.glassBorder(theme.brightness)), border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
), ),
child: Column( child: Column(
@ -593,7 +291,7 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
} }
final level = context.watch<SessionState>().autonomyLevel; final level = context.watch<SessionState>().autonomyLevel;
final isL0 = level == AutonomyLevel.L0 || widget.isGroup; final isL0 = level == AutonomyLevel.L0;
final title = isL0 final title = isL0
? '초안 (L0) — 직접 보내기' ? '초안 (L0) — 직접 보내기'
: level == AutonomyLevel.L1 : level == AutonomyLevel.L1
@ -602,29 +300,32 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
return Container( return Container(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.glassFill(theme.brightness), color: theme.colorScheme.secondaryContainer.withOpacity(0.85),
borderRadius: BorderRadius.circular(AppTheme.rPanel), borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.glassBorder(theme.brightness)), border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Row(
title, children: [
style: theme.textTheme.titleSmall?.copyWith( Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.onSecondaryContainer),
color: theme.colorScheme.onSurface, const SizedBox(width: 6),
fontWeight: FontWeight.w600, Expanded(
), child: Text(
title,
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onSecondaryContainer),
),
),
],
), ),
if (isL0) ...[ if (isL0) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
widget.isGroup 'L0에서는 와카뷰 발송이 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내거나, 메뉴 → 자율성에서 L1으로 바꾸세요.',
? '단톡에서는 와카뷰 자동 발송이 항상 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내세요.' style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSecondaryContainer),
: 'L0에서는 와카뷰 발송이 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내거나, 메뉴 → 자율성에서 L1으로 바꾸세요.',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
), ),
], ],
const SizedBox(height: 10), const SizedBox(height: 10),
@ -664,36 +365,8 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !widget.embedded, title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.title ?? '대화방 #${widget.conversationId}'),
Text(
wsLinkStateLabel(_linkState),
style: theme.textTheme.labelSmall?.copyWith(
color: _linkState == WsLinkState.live
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
],
),
actions: [ actions: [
if (widget.isGroup)
IconButton(
tooltip: '안 본 동안 요약',
onPressed: _openSummary,
icon: const Icon(Icons.summarize_outlined),
),
IconButton(
// (roadmap.md §2.7-F) "이따 답장" .
// .
tooltip: _snoozedUntil != null ? '이따 답장 (${_formatSnoozeUntil(_snoozedUntil!)})' : '이따 답장',
onPressed: _busy ? null : _openSnoozeMenu,
icon: Icon(_snoozedUntil != null ? Icons.alarm_on : Icons.alarm_add_outlined),
),
IconButton( IconButton(
tooltip: '거부권 (와카뷰 자동응대 중단)', tooltip: '거부권 (와카뷰 자동응대 중단)',
onPressed: _busy ? null : _veto, onPressed: _busy ? null : _veto,
@ -703,42 +376,14 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
), ),
body: Column( body: Column(
children: [ children: [
if (_linkState == WsLinkState.reconnecting)
Material(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
child: SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Text(
'연결이 끊겼습니다. 다시 연결하는 중…',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
),
if (_banner != null) if (_banner != null)
MaterialBanner( MaterialBanner(
leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant), leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant),
content: Text(_banner!), content: Text(_banner!),
actions: [ actions: [
// one-tap undo (AGENTS.md )
// .
if (_floodBlocked)
TextButton(onPressed: _busy ? null : _resumeFlood, child: const Text('자동응대 재개')),
TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')), TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')),
], ],
), ),
if (_snoozePastDue)
MaterialBanner(
leading: Icon(Icons.alarm, color: theme.colorScheme.tertiary),
content: Text('답장 마감 시간이 지났습니다 (${_formatSnoozeUntil(_snoozedUntil!)}).'),
actions: [
TextButton(onPressed: _busy ? null : _clearSnooze, child: const Text('스누즈 해제')),
],
),
Expanded( Expanded(
child: _loadingHistory child: _loadingHistory
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
@ -755,61 +400,40 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
itemCount: _messages.length, itemCount: _messages.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final m = _messages[i]; final m = _messages[i];
final isMine = me != null && m.senderId == me;
return MessageBubble( return MessageBubble(
message: m, message: m,
isMine: isMine, isMine: me != null && m.senderId == me,
onRetract: m.isTwin ? () => _retract(m) : null, onRetract: m.isTwin ? () => _retract(m) : null,
// "이 답장 나답아요?" (vision.md , N4-12):
// (= ) ,
// .
alreadyRated: _ratedMessageIds.contains(m.id),
onFeedback: m.isTwin && isMine && !m.retracted
? (natural) => _submitFeedback(m, natural)
: null,
); );
}, },
), ),
), ),
_buildL1Panel(context), _buildL1Panel(context),
SafeArea( SafeArea(
child: Container( child: Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10), padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
decoration: BoxDecoration(
color: AppTheme.glassFill(theme.brightness),
border: Border(
top: BorderSide(color: AppTheme.glassBorder(theme.brightness).withValues(alpha: 0.7)),
),
),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
IconButton(
tooltip: '초안 요청',
onPressed: _busy ? null : _requestDraft,
icon: const Icon(Icons.auto_awesome_outlined),
),
Expanded( Expanded(
child: TextField( child: TextField(
controller: _input, controller: _input,
minLines: 1, minLines: 1,
maxLines: 4, maxLines: 4,
textInputAction: TextInputAction.send,
onSubmitted: (_) {
if (!_busy) _sendHuman();
},
decoration: const InputDecoration(hintText: '메시지'), decoration: const InputDecoration(hintText: '메시지'),
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: '초안 요청',
onPressed: _busy ? null : _requestDraft,
icon: const Icon(Icons.auto_awesome),
),
const SizedBox(width: 4),
IconButton.filled( IconButton.filled(
tooltip: '보내기', tooltip: '보내기',
onPressed: _busy ? null : _sendHuman, onPressed: _busy ? null : _sendHuman,
style: IconButton.styleFrom( icon: const Icon(Icons.send),
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
),
icon: const Icon(Icons.arrow_upward_rounded),
), ),
], ],
), ),

View File

@ -48,65 +48,47 @@ class _ContactsScreenState extends State<ContactsScreen> {
final noteCtrl = TextEditingController(); final noteCtrl = TextEditingController();
final session = context.read<SessionState>(); final session = context.read<SessionState>();
final myId = session.user?.id; final myId = session.user?.id;
RelationshipTier? tierOverride;
AutonomyLevel? autonomyOverride;
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => StatefulBuilder( builder: (ctx) => AlertDialog(
builder: (ctx, setDialogState) => AlertDialog( title: const Text('연락처 추가'),
title: const Text('연락처 추가'), content: SingleChildScrollView(
content: SingleChildScrollView( child: Column(
child: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: [ if (myId != null) ...[
if (myId != null) ...[ MyUserIdChip(userId: myId),
MyUserIdChip(userId: myId),
const SizedBox(height: 12),
],
TextField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: '표시 이름',
helperText: '목록에 보일 이름 (예: 친구 닉네임)',
),
),
const SizedBox(height: 12), 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: '관계 메모 (선택)'),
),
const SizedBox(height: 12),
Text('이 상대와의 관계 (roadmap.md §2.7-B)', style: Theme.of(ctx).textTheme.bodySmall),
const SizedBox(height: 6),
_RelationshipTierPicker(
value: tierOverride,
onChanged: (t) => setDialogState(() => tierOverride = t),
),
const SizedBox(height: 12),
Text('이 상대에 대한 자율성 (roadmap.md §2.7-D)', style: Theme.of(ctx).textTheme.bodySmall),
const SizedBox(height: 6),
_AutonomyLevelPicker(
value: autonomyOverride,
onChanged: (l) => setDialogState(() => autonomyOverride = l),
),
], ],
), TextField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: '표시 이름',
helperText: '목록에 보일 이름 (예: 친구 닉네임)',
),
),
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('취소')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('추가')),
],
), ),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('추가')),
],
), ),
); );
if (ok != true || !mounted) return; if (ok != true || !mounted) return;
@ -127,8 +109,6 @@ class _ContactsScreenState extends State<ContactsScreen> {
displayName: name, displayName: name,
contactUserId: peer, contactUserId: peer,
relationshipNote: noteCtrl.text.trim(), relationshipNote: noteCtrl.text.trim(),
relationshipTier: tierOverride,
autonomyLevel: autonomyOverride,
); );
setState(() { setState(() {
_contacts = [..._contacts, created]; _contacts = [..._contacts, created];
@ -169,68 +149,48 @@ class _ContactsScreenState extends State<ContactsScreen> {
); );
final noteCtrl = TextEditingController(text: contact.relationshipNote); final noteCtrl = TextEditingController(text: contact.relationshipNote);
final session = context.read<SessionState>(); final session = context.read<SessionState>();
// PATCH는 (=
// ) null로 .
RelationshipTier? tierOverride = contact.relationshipTier;
AutonomyLevel? autonomyOverride = contact.autonomyLevel;
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => StatefulBuilder( builder: (ctx) => AlertDialog(
builder: (ctx, setDialogState) => AlertDialog( title: Text(contact.contactUserId == null ? '사용자 ID 입력' : '연락처 수정'),
title: Text(contact.contactUserId == null ? '사용자 ID 입력' : '연락처 수정'), content: SingleChildScrollView(
content: SingleChildScrollView( child: Column(
child: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: [ Text(
Text( contact.contactUserId == null
contact.contactUserId == null ? '대화하려면 상대의 숫자 사용자 ID가 필요합니다. 삭제하지 말고 여기서 채워 주세요.'
? '대화하려면 상대의 숫자 사용자 ID가 필요합니다. 삭제하지 말고 여기서 채워 주세요.' : '표시 이름·상대 ID·메모를 고칠 수 있습니다.',
: '표시 이름·상대 ID·메모를 고칠 수 있습니다.', style: Theme.of(ctx).textTheme.bodySmall,
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( const SizedBox(height: 12),
controller: nameCtrl, TextField(
decoration: const InputDecoration(labelText: '표시 이름'), controller: noteCtrl,
), 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: '관계 메모 (선택)'),
),
const SizedBox(height: 12),
Text('이 상대와의 관계 (roadmap.md §2.7-B)', style: Theme.of(ctx).textTheme.bodySmall),
const SizedBox(height: 6),
_RelationshipTierPicker(
value: tierOverride,
onChanged: (t) => setDialogState(() => tierOverride = t),
),
const SizedBox(height: 12),
Text('이 상대에 대한 자율성 (roadmap.md §2.7-D)', style: Theme.of(ctx).textTheme.bodySmall),
const SizedBox(height: 6),
_AutonomyLevelPicker(
value: autonomyOverride,
onChanged: (l) => setDialogState(() => autonomyOverride = l),
),
],
),
), ),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('저장')),
],
), ),
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; if (ok != true || !mounted || session.user == null) return;
@ -252,8 +212,6 @@ class _ContactsScreenState extends State<ContactsScreen> {
displayName: name, displayName: name,
contactUserId: peer, contactUserId: peer,
relationshipNote: noteCtrl.text.trim(), relationshipNote: noteCtrl.text.trim(),
relationshipTier: tierOverride,
autonomyLevel: autonomyOverride,
); );
setState(() { setState(() {
_contacts = _contacts.map((c) => c.id == updated.id ? updated : c).toList(); _contacts = _contacts.map((c) => c.id == updated.id ? updated : c).toList();
@ -341,6 +299,8 @@ class _ContactsScreenState extends State<ContactsScreen> {
padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 32), padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 32),
child: Column( child: Column(
children: [ children: [
Icon(Icons.person_add_outlined, size: 40, color: theme.colorScheme.outline),
const SizedBox(height: 12),
Text('연락처가 없습니다', style: theme.textTheme.titleMedium), Text('연락처가 없습니다', style: theme.textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
@ -356,67 +316,66 @@ class _ContactsScreenState extends State<ContactsScreen> {
), ),
), ),
for (final c in _contacts) for (final c in _contacts)
ListTile( Padding(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
leading: CircleAvatar( child: ListTile(
radius: 20, leading: CircleAvatar(
backgroundColor: theme.colorScheme.surfaceContainerHighest, backgroundColor: theme.colorScheme.secondaryContainer,
child: Text( child: Text(
c.displayName.isEmpty ? '?' : c.displayName.substring(0, 1), c.displayName.isEmpty ? '?' : c.displayName.substring(0, 1),
style: TextStyle( style: TextStyle(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
),
), ),
), ),
), title: Text(c.displayName, style: theme.textTheme.titleSmall),
title: Text(c.displayName, style: theme.textTheme.titleSmall), subtitle: Text(
subtitle: Text( c.contactUserId == null
c.contactUserId == null ? '사용자 ID 없음 — 대화 불가 (다시 추가 필요)'
? '사용자 ID 없음 — 대화 불가 (다시 추가 필요)' : [
: [ '사용자 #${c.contactUserId}',
'사용자 #${c.contactUserId}', if (c.relationshipNote.isNotEmpty) c.relationshipNote,
if (c.relationshipTier != null) c.relationshipTier!.label, ].join(' · '),
if (c.autonomyLevel != null) c.autonomyLevel!.label, style: theme.textTheme.bodySmall?.copyWith(
if (c.relationshipNote.isNotEmpty) c.relationshipNote, color: c.contactUserId == null
].join(' · '), ? theme.colorScheme.error
style: theme.textTheme.bodySmall?.copyWith( : theme.colorScheme.onSurfaceVariant,
color: c.contactUserId == null ),
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
), ),
), trailing: Row(
trailing: Row( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ if (c.contactUserId != null) ...[
if (c.contactUserId != null) ...[ FilledButton.tonal(
TextButton( onPressed: () => _startChat(c),
onPressed: () => _startChat(c), child: const Text('대화'),
child: const Text('대화'), ),
), IconButton(
tooltip: '수정',
icon: const Icon(Icons.edit_outlined, size: 20),
onPressed: () => _editContact(c),
),
] else
FilledButton(
onPressed: () => _editContact(c),
child: const Text('ID 입력'),
),
IconButton( IconButton(
tooltip: '수정', tooltip: '삭제',
icon: const Icon(Icons.edit_outlined, size: 20), icon: const Icon(Icons.delete_outline, size: 20),
onPressed: () => _editContact(c), onPressed: () => _delete(c),
), ),
] else ],
FilledButton( ),
onPressed: () => _editContact(c), onTap: () {
child: const Text('ID 입력'), if (c.contactUserId == null) {
), _editContact(c);
IconButton( } else {
tooltip: '삭제', _startChat(c);
icon: const Icon(Icons.delete_outline, size: 20), }
onPressed: () => _delete(c), },
),
],
), ),
onTap: () {
if (c.contactUserId == null) {
_editContact(c);
} else {
_startChat(c);
}
},
), ),
const SizedBox(height: 72), const SizedBox(height: 72),
], ],
@ -425,62 +384,3 @@ class _ContactsScreenState extends State<ContactsScreen> {
); );
} }
} }
/// Per-contact override of the global relationship tier (roadmap.md
/// §2.7-B). `value: null` means "use the global default set in onboarding".
class _RelationshipTierPicker extends StatelessWidget {
const _RelationshipTierPicker({required this.value, required this.onChanged});
final RelationshipTier? value;
final ValueChanged<RelationshipTier?> onChanged;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
children: [
ChoiceChip(
label: const Text('기본값 사용'),
selected: value == null,
onSelected: (_) => onChanged(null),
),
for (final tier in RelationshipTier.values)
ChoiceChip(
label: Text(tier.label),
selected: value == tier,
onSelected: (_) => onChanged(tier),
),
],
);
}
}
/// Per-contact override of the global autonomy level (roadmap.md §2.7-D).
/// `value: null` means "use the global default set in 자율성 설정".
/// Mirrors _RelationshipTierPicker's structure exactly.
class _AutonomyLevelPicker extends StatelessWidget {
const _AutonomyLevelPicker({required this.value, required this.onChanged});
final AutonomyLevel? value;
final ValueChanged<AutonomyLevel?> onChanged;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
children: [
ChoiceChip(
label: const Text('기본값 사용'),
selected: value == null,
onSelected: (_) => onChanged(null),
),
for (final level in AutonomyLevel.values)
ChoiceChip(
label: Text(level.label),
selected: value == level,
onSelected: (_) => onChanged(level),
),
],
);
}
}

View File

@ -3,19 +3,13 @@ import 'package:provider/provider.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../services/snooze_service.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/gradient_text.dart';
import '../widgets/my_user_id_chip.dart'; import '../widgets/my_user_id_chip.dart';
import '../widgets/offline_banner.dart'; import 'autonomy_settings_screen.dart';
import '../widgets/web_preview_banner.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
import 'inbox_screen.dart'; import 'inbox_screen.dart';
import 'settings_screen.dart'; import 'onboarding_tone_screen.dart';
/// Breakpoint for list | chat master-detail (docs/web-upgrade.md N4-W2).
const double kMessengerSplitBreakpoint = 900;
class ConversationListScreen extends StatefulWidget { class ConversationListScreen extends StatefulWidget {
const ConversationListScreen({super.key}); const ConversationListScreen({super.key});
@ -24,49 +18,18 @@ class ConversationListScreen extends StatefulWidget {
State<ConversationListScreen> createState() => _ConversationListScreenState(); State<ConversationListScreen> createState() => _ConversationListScreenState();
} }
class _ConversationListScreenState extends State<ConversationListScreen> class _ConversationListScreenState extends State<ConversationListScreen> {
with WidgetsBindingObserver {
List<ConversationSummary> _rooms = []; List<ConversationSummary> _rooms = [];
Map<int, String> _peerNames = {}; Map<int, String> _peerNames = {};
// (roadmap.md §2.7-F) id별 "이따 답장" .
// (ConversationSummary) DB에서 .
Map<int, DateTime> _snoozes = {};
bool _loading = true; bool _loading = true;
String? _error; String? _error;
/// Wide-layout selection (narrow width still uses Navigator.push).
int? _selectedId;
String? _selectedTitle;
bool _selectedIsGroup = false;
bool _selectedFlood = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_load(); _load();
} }
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// N4-W3 tab/app focus: refresh list badges + optional web Notification.
if (state == AppLifecycleState.resumed) {
_onFocus();
}
}
Future<void> _onFocus() async {
final controller = context.read<SessionState>().snoozeController;
await controller?.notifyPastDueOnFocus();
if (mounted) await _load();
}
Future<void> _load() async { Future<void> _load() async {
final session = context.read<SessionState>(); final session = context.read<SessionState>();
setState(() { setState(() {
@ -89,11 +52,9 @@ class _ConversationListScreenState extends State<ConversationListScreen>
// Names are optional enrichment. // Names are optional enrichment.
} }
} }
final snoozes = await session.snoozeController?.loadAllSnoozes() ?? <int, DateTime>{};
setState(() { setState(() {
_rooms = list; _rooms = list;
_peerNames = names; _peerNames = names;
_snoozes = snoozes;
}); });
} on ApiException catch (e) { } on ApiException catch (e) {
setState(() => _error = '대화 목록 실패 (${e.statusCode}): ${e.body}'); setState(() => _error = '대화 목록 실패 (${e.statusCode}): ${e.body}');
@ -102,48 +63,6 @@ class _ConversationListScreenState extends State<ConversationListScreen>
} }
} }
/// (roadmap.md §2.7-F
/// "마감 지남" ).
Future<void> _clearSnoozeFromList(int conversationId) async {
final controller = context.read<SessionState>().snoozeController;
if (controller != null) await controller.clearSnooze(conversationId);
if (!mounted) return;
setState(() => _snoozes.remove(conversationId));
}
bool get _useSplit {
final width = MediaQuery.sizeOf(context).width;
return width >= kMessengerSplitBreakpoint;
}
Future<void> _openChat({
required int conversationId,
required String title,
required bool isGroup,
bool twinDisabledByFlood = false,
}) async {
if (_useSplit) {
setState(() {
_selectedId = conversationId;
_selectedTitle = title;
_selectedIsGroup = isGroup;
_selectedFlood = twinDisabledByFlood;
});
return;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatScreen(
conversationId: conversationId,
title: title,
isGroup: isGroup,
twinDisabledByFlood: twinDisabledByFlood,
),
),
);
if (mounted) await _load();
}
String _titleFor(ConversationSummary room, int? me) { String _titleFor(ConversationSummary room, int? me) {
if (me == null) return '대화방 #${room.id}'; if (me == null) return '대화방 #${room.id}';
final peers = room.userIds.where((id) => id != me).toList(); final peers = room.userIds.where((id) => id != me).toList();
@ -157,144 +76,91 @@ class _ConversationListScreenState extends State<ConversationListScreen>
String _subtitleFor(ConversationSummary room, int? me) { String _subtitleFor(ConversationSummary room, int? me) {
if (room.twinDisabledByPeer) return '상대가 와카뷰를 거부함'; if (room.twinDisabledByPeer) return '상대가 와카뷰를 거부함';
// roadmap.md §2.7-C (InboxScreen)
// .
if (room.twinDisabledByFlood) return '도배 감지로 자동응대 일시중단 (사후 알림에서 재개)';
final peers = me == null ? const <int>[] : room.userIds.where((id) => id != me).toList(); final peers = me == null ? const <int>[] : room.userIds.where((id) => id != me).toList();
final peerPart = peers.isEmpty ? '참가자 없음' : '상대 ID ${peers.first}'; final peerPart = peers.isEmpty ? '참가자 없음' : '상대 ID ${peers.first}';
return '$peerPart · 방 #${room.id}'; return '$peerPart · 방 #${room.id}';
} }
Future<void> _createConversation() async { Future<void> _createConversation() async {
final peerCtrl = TextEditingController();
final session = context.read<SessionState>(); final session = context.read<SessionState>();
final myId = session.user?.id; final myId = session.user?.id;
// (roadmap.md §2.7-A "단톡 따라잡기"):
// . 1( 1) 1:1 .
final peerCtrls = <TextEditingController>[TextEditingController()];
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => StatefulBuilder( builder: (ctx) => AlertDialog(
builder: (ctx, setDialogState) => AlertDialog( title: const Text('새 대화'),
title: const Text('새 대화'), content: Column(
content: SingleChildScrollView( mainAxisSize: MainAxisSize.min,
child: Column( crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, children: [
crossAxisAlignment: CrossAxisAlignment.stretch, if (myId != null) ...[
children: [ MyUserIdChip(userId: myId),
if (myId != null) ...[ const SizedBox(height: 12),
MyUserIdChip(userId: myId), Text(
const SizedBox(height: 12), '상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요.',
Text( style: Theme.of(ctx).textTheme.bodySmall,
'상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요. ' ),
'2명 이상 넣으면 그룹 대화로 만들어집니다.', const SizedBox(height: 12),
style: Theme.of(ctx).textTheme.bodySmall, ],
), TextField(
const SizedBox(height: 12), controller: peerCtrl,
], keyboardType: TextInputType.number,
for (var i = 0; i < peerCtrls.length; i++) autofocus: true,
Padding( decoration: const InputDecoration(
padding: const EdgeInsets.only(bottom: 8), labelText: '상대 사용자 ID (숫자)',
child: Row( helperText: '이름/닉네임이 아니라 숫자 ID입니다. 연락처에 등록돼 있으면 연락처에서 시작하세요.',
children: [ ),
Expanded(
child: TextField(
controller: peerCtrls[i],
keyboardType: TextInputType.number,
autofocus: i == 0,
decoration: InputDecoration(
labelText: i == 0 ? '상대 사용자 ID (숫자)' : '상대 ${i + 1} 사용자 ID',
helperText: i == 0 ? '이름/닉네임이 아니라 숫자 ID입니다.' : null,
),
),
),
if (peerCtrls.length > 1)
IconButton(
tooltip: '삭제',
onPressed: () => setDialogState(() => peerCtrls.removeAt(i)),
icon: const Icon(Icons.close, size: 18),
),
],
),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => setDialogState(() => peerCtrls.add(TextEditingController())),
icon: const Icon(Icons.add, size: 18),
label: const Text('상대 추가 (그룹으로)'),
),
),
],
), ),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('만들기')),
], ],
), ),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('만들기')),
],
), ),
); );
if (ok != true || !mounted) return; if (ok != true || !mounted) return;
final me = session.user; final me = session.user;
final peer = int.tryParse(peerCtrl.text.trim());
if (me == null) return; if (me == null) return;
if (peer == null) {
final peers = <int>[];
for (final ctrl in peerCtrls) {
final text = ctrl.text.trim();
if (text.isEmpty) continue;
final id = int.tryParse(text);
if (id == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('상대 사용자 ID는 숫자여야 합니다. (예: 12)')),
);
return;
}
if (id == me.id) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('자기 자신과는 대화를 만들 수 없습니다.')),
);
return;
}
if (!peers.contains(id)) peers.add(id);
}
if (peers.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('상대 사용자 ID를 1명 이상 입력하세요.')), const SnackBar(content: Text('상대 사용자 ID는 숫자여야 합니다. (예: 12)')),
);
return;
}
if (peer == me.id) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('자기 자신과는 대화를 만들 수 없습니다.')),
); );
return; return;
} }
final isGroup = peers.length > 1;
try { try {
final conv = await session.api.createConversation( final conv = await session.api.createConversation(userIds: [me.id, peer]);
userIds: [me.id, ...peers],
isGroup: isGroup,
);
if (!mounted) return; if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatScreen(
conversationId: conv.id,
title: _peerNames[peer] ?? '상대 #$peer',
),
),
);
await _load(); await _load();
if (!mounted) return;
await _openChat(
conversationId: conv.id,
title: isGroup ? '그룹 #${conv.id}' : (_peerNames[peers.first] ?? '상대 #${peers.first}'),
isGroup: isGroup,
);
} on ApiException catch (e) { } on ApiException catch (e) {
setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}'); setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}');
} }
} }
Color _avatarColor(BuildContext context, int seed) { Color _avatarColor(BuildContext context, int seed) {
final isDark = Theme.of(context).brightness == Brightness.dark; final scheme = Theme.of(context).colorScheme;
final palette = isDark final palette = [scheme.primaryContainer, scheme.tertiaryContainer, scheme.secondaryContainer];
? const [Color(0xFF2C2C34), Color(0xFF243447), Color(0xFF3A3224)]
: const [Color(0xFFE9E9EB), Color(0xFFD6E8FF), Color(0xFFFFF1D6)];
return palette[seed % palette.length]; return palette[seed % palette.length];
} }
Color _onAvatarColor(BuildContext context, int seed) { Color _onAvatarColor(BuildContext context, int seed) {
final isDark = Theme.of(context).brightness == Brightness.dark; final scheme = Theme.of(context).colorScheme;
final palette = isDark final palette = [scheme.onPrimaryContainer, scheme.onTertiaryContainer, scheme.onSecondaryContainer];
? const [Color(0xFFF2F2F7), Color(0xFF0A84FF), Color(0xFFD4B06A)]
: const [Color(0xFF1C1C1E), Color(0xFF007AFF), Color(0xFFB08A4A)];
return palette[seed % palette.length]; return palette[seed % palette.length];
} }
@ -303,11 +169,10 @@ class _ConversationListScreenState extends State<ConversationListScreen>
final session = context.watch<SessionState>(); final session = context.watch<SessionState>();
final theme = Theme.of(context); final theme = Theme.of(context);
final me = session.user?.id; final me = session.user?.id;
final split = _useSplit;
final listPane = Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: GradientText('와카뷰', style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)), title: const Text('와카뷰'),
actions: [ actions: [
if (me != null) MyUserIdChip(userId: me, compact: true), if (me != null) MyUserIdChip(userId: me, compact: true),
IconButton( IconButton(
@ -325,275 +190,146 @@ class _ConversationListScreenState extends State<ConversationListScreen>
}, },
icon: const Icon(Icons.contacts_outlined), icon: const Icon(Icons.contacts_outlined),
), ),
IconButton( PopupMenuButton<VoidCallback>(
tooltip: '설정', tooltip: '더보기',
onPressed: () { icon: const Icon(Icons.more_vert),
Navigator.of(context).push( onSelected: (action) => action(),
MaterialPageRoute(builder: (_) => const SettingsScreen()), itemBuilder: (context) => [
); PopupMenuItem(
}, value: () => Navigator.of(context)
icon: const Icon(Icons.settings_outlined), .push(MaterialPageRoute(builder: (_) => const OnboardingToneScreen())),
child: const ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.record_voice_over_outlined),
title: Text('말투 샘플'),
),
),
PopupMenuItem(
value: () => Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => const AutonomySettingsScreen())),
child: const ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.tune),
title: Text('자율성 설정'),
),
),
],
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
], ],
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton.extended(
onPressed: _createConversation, onPressed: _createConversation,
tooltip: '새 대화', icon: const Icon(Icons.chat),
child: const Icon(Icons.chat_outlined), label: const Text('새 대화'),
), ),
body: Column( body: RefreshIndicator(
children: [ onRefresh: _load,
const OfflineBanner(), child: _loading
const WebPreviewBanner(), ? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
Expanded( : ListView(
child: RefreshIndicator( padding: const EdgeInsets.symmetric(vertical: 4),
onRefresh: _load, children: [
child: _loading Padding(
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())]) padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
: ListView(
padding: const EdgeInsets.symmetric(vertical: 4),
children: [
Padding(
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),
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
),
if (_rooms.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(32, 48, 32, 32),
child: Column(
children: [
Text('대화방이 없습니다', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'1) 아래 「내 사용자 ID」를 탭해 복사한 뒤 상대에게 알려 주세요\n'
'2) 연락처에 상대의 숫자 ID를 넣고 추가\n'
'3) 연락처에서 「대화」또는 행을 탭하세요',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
),
const SizedBox(height: 18),
OutlinedButton(
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ContactsScreen()),
);
await _load();
},
child: const Text('연락처 열기'),
),
],
),
),
for (final room in _rooms)
Builder(
builder: (context) {
final snoozedUntil = _snoozes[room.id];
final snoozePastDue = isSnoozePastDue(DateTime.now(), snoozedUntil);
return _ConversationRow(
room: room,
me: me,
title: _titleFor(room, me),
subtitle: _subtitleFor(room, me),
avatarColor: _avatarColor(context, room.id),
onAvatarColor: _onAvatarColor(context, room.id),
snoozePastDue: snoozePastDue,
selected: split && _selectedId == room.id,
onClearSnooze: () => _clearSnoozeFromList(room.id),
onTap: () => _openChat(
conversationId: room.id,
title: _titleFor(room, me),
isGroup: room.isGroup,
twinDisabledByFlood: room.twinDisabledByFlood,
),
);
},
),
const SizedBox(height: 72),
],
),
),
),
],
),
);
if (!split) return listPane;
return Row(
children: [
SizedBox(
width: 360,
child: listPane,
),
VerticalDivider(width: 1, thickness: 1, color: theme.dividerColor),
Expanded(
child: _selectedId == null
? Scaffold(
body: Center(
child: Text( child: Text(
'왼쪽에서 대화를 선택하세요', session.user == null ? '' : '안녕하세요, ${session.user!.displayName}',
style: theme.textTheme.bodyLarge?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
color: theme.colorScheme.onSurfaceVariant,
),
), ),
), ),
) if (me != null)
: ChatScreen( Padding(
key: ValueKey(_selectedId), padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
conversationId: _selectedId!, child: MyUserIdChip(userId: me),
title: _selectedTitle,
isGroup: _selectedIsGroup,
twinDisabledByFlood: _selectedFlood,
embedded: true,
),
),
],
);
}
}
class _ConversationRow extends StatelessWidget {
const _ConversationRow({
required this.room,
required this.me,
required this.title,
required this.subtitle,
required this.avatarColor,
required this.onAvatarColor,
required this.snoozePastDue,
required this.onClearSnooze,
required this.onTap,
this.selected = false,
});
final ConversationSummary room;
final int? me;
final String title;
final String subtitle;
final Color avatarColor;
final Color onAvatarColor;
final bool snoozePastDue;
final bool selected;
final VoidCallback onClearSnooze;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final blockedColor = room.twinDisabledByPeer || room.twinDisabledByFlood;
return Material(
color: selected ? theme.colorScheme.primary.withValues(alpha: 0.08) : Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
CircleAvatar(
radius: 22,
backgroundColor: avatarColor,
child: Icon(
room.isGroup ? Icons.groups_outlined : Icons.person_outline,
size: 20,
color: onAvatarColor,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: selected ? theme.colorScheme.primary : null,
),
), ),
const SizedBox(height: 2), if (_error != null)
Row( Padding(
children: [ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
if (blockedColor) ...[ child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
Icon( ),
room.twinDisabledByFlood ? Icons.pause_circle_outline : Icons.block, if (_rooms.isEmpty)
size: 13, Padding(
color: theme.colorScheme.error, padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 32),
), child: Column(
const SizedBox(width: 4), children: [
], Icon(Icons.chat_bubble_outline, size: 40, color: theme.colorScheme.outline),
Expanded( const SizedBox(height: 12),
child: Text( Text('대화방이 없습니다', style: theme.textTheme.titleMedium),
subtitle, const SizedBox(height: 8),
maxLines: 1, Text(
overflow: TextOverflow.ellipsis, '1) 내 ID를 상대에게 알려 주세요\n'
'2) 연락처에 상대의 숫자 ID를 넣고 추가\n'
'3) 연락처에서 「대화」또는 행을 탭하세요',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: blockedColor ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
height: 1.45,
), ),
), ),
), const SizedBox(height: 16),
], FilledButton.tonalIcon(
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ContactsScreen()),
);
await _load();
},
icon: const Icon(Icons.contacts_outlined),
label: const Text('연락처 열기'),
),
],
),
), ),
// (roadmap.md §2.7-F) for (final room in _rooms)
// . . Padding(
if (snoozePastDue) ...[ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
const SizedBox(height: 4), child: ListTile(
InkWell( leading: CircleAvatar(
borderRadius: BorderRadius.circular(4), backgroundColor: _avatarColor(context, room.id),
onTap: onClearSnooze, child: Icon(
child: Row( room.isGroup ? Icons.groups_outlined : Icons.person_outline,
mainAxisSize: MainAxisSize.min, color: _onAvatarColor(context, room.id),
),
),
title: Text(
_titleFor(room, me),
style: theme.textTheme.titleSmall,
),
subtitle: Row(
children: [ children: [
Icon(Icons.alarm, size: 13, color: theme.colorScheme.tertiary), if (room.twinDisabledByPeer) ...[
const SizedBox(width: 4), Icon(Icons.block, size: 13, color: theme.colorScheme.error),
Text( const SizedBox(width: 4),
'답장 마감 (탭하여 해제)', ],
style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.tertiary), 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: _titleFor(room, me),
),
),
);
await _load();
},
), ),
],
],
),
),
if (room.unreadCount > 0) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(10),
),
child: Text(
room.unreadCount > 99 ? '99+' : '${room.unreadCount}',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.w700,
), ),
), const SizedBox(height: 72),
), ],
const SizedBox(width: 8), ),
],
Icon(Icons.chevron_right, size: 18, color: theme.colorScheme.onSurfaceVariant),
],
),
),
), ),
); );
} }

View File

@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -11,28 +10,42 @@ class DataFlowScreen extends StatelessWidget {
Widget _section( Widget _section(
BuildContext context, { BuildContext context, {
required IconData icon,
required Color tint,
required String title, required String title,
required String body, required String body,
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.glassFill(theme.brightness), color: AppTheme.glassFill(theme.brightness),
borderRadius: BorderRadius.circular(AppTheme.rPanel), borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.glassBorder(theme.brightness)), border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
), ),
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, style: theme.textTheme.titleSmall), Container(
const SizedBox(height: 6), width: 36,
Text( height: 36,
body, alignment: Alignment.center,
style: theme.textTheme.bodyMedium?.copyWith( decoration: BoxDecoration(color: tint.withValues(alpha: 0.15), shape: BoxShape.circle),
color: theme.colorScheme.onSurfaceVariant, child: Icon(icon, size: 18, color: tint),
height: 1.45, ),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Text(
body,
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
), ),
), ),
], ],
@ -53,51 +66,42 @@ class DataFlowScreen extends StatelessWidget {
children: [ children: [
_section( _section(
context, context,
icon: Icons.lock_outline,
tint: Colors.green.shade600,
title: '기기 안에만 둡니다', title: '기기 안에만 둡니다',
body: '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. ' body: '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
'원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.', '원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.',
), ),
Container( Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16), child: ListTile(
decoration: BoxDecoration( leading: Icon(Icons.record_voice_over_outlined, color: theme.colorScheme.primary),
color: AppTheme.glassFill(theme.brightness), title: Text('말투 샘플 ${samples.length}'),
borderRadius: BorderRadius.circular(AppTheme.rPanel), subtitle: Text(
border: Border.all(color: AppTheme.glassBorder(theme.brightness)), [
), samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
child: Column( session.localDbEncrypted
crossAxisAlignment: CrossAxisAlignment.start, ? '저장: drift + SQLCipher(암호화)'
children: [ : '저장: 메모리/웹 스텁(이 환경에 SQLCipher 없음 — Chrome·Linux 폴백)',
Text('말투 샘플 ${samples.length}', style: theme.textTheme.titleSmall), ].join('\n'),
const SizedBox(height: 6), ),
Text( isThreeLine: true,
[
samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
session.localDbEncrypted
? '저장: drift + SQLCipher(암호화)'
: kIsWeb
? '저장: 브라우저 저장소(비암호화 · 웹 프리뷰 — 같은 프로필에서는 새로고침 후에도 유지)'
: '저장: 메모리 폴백(이 환경에 SQLCipher 없음 — Linux CI 등)',
].join('\n'),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.45,
),
),
],
), ),
), ),
_section( _section(
context, context,
icon: Icons.cloud_upload_outlined,
tint: Colors.amber.shade800,
title: '서버로 보낼 수 있는 것', title: '서버로 보낼 수 있는 것',
body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n' body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n'
'채팅 릴레이: 보낸 메시지 본문\n' '채팅 릴레이: 보낸 메시지 본문\n'
'계정: 표시 이름·초대 코드·세션 토큰\n' '계정: 표시 이름·초대 코드·세션 토큰\n'
'푸시: Android는 google-services.json, 웹은 FIREBASE_*+VAPID 빌드 주입 시 실 FCM 토큰 등록 · ' '푸시: Firebase 연결 시 실 FCM 토큰 등록 · 미연결 시 install 플레이스홀더 (docs/fcm-setup.md)',
'미연결 시 install 플레이스홀더 (docs/fcm-setup.md · web-upgrade W4)',
), ),
_section( _section(
context, context,
icon: Icons.block,
tint: theme.colorScheme.error,
title: '보내지 않는 것', title: '보내지 않는 것',
body: '전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, ' body: '전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, '
'관계 메모의 자동 클라우드 분석', '관계 메모의 자동 클라우드 분석',

View File

@ -4,7 +4,6 @@ import 'package:provider/provider.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../theme/app_theme.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
/// Post-hoc notification inbox: escalation logs that need human attention. /// Post-hoc notification inbox: escalation logs that need human attention.
@ -54,89 +53,98 @@ class _InboxScreenState extends State<InboxScreen> {
child: _loading child: _loading
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())]) ? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
: ListView( : ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
children: [ children: [
Text( Padding(
'와카뷰가 보류·차단한 내용과 에스컬레이션 기록입니다. ' padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
'이미 보낸 와카뷰 메시지는 해당 대화방에서 되돌릴 수 있습니다.', child: Text(
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), '와카뷰가 보류·차단한 내용과 에스컬레이션 기록입니다. '
'이미 보낸 와카뷰 메시지는 해당 대화방에서 되돌릴 수 있습니다.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
), ),
const SizedBox(height: 12),
if (_error != null) if (_error != null)
Padding( Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)), child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
), ),
if (_logs.isEmpty) if (_logs.isEmpty)
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 56), padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32),
child: Center( child: Column(
child: Text('새 알림이 없습니다', style: theme.textTheme.titleMedium), children: [
Icon(Icons.notifications_none, size: 40, color: theme.colorScheme.outline),
const SizedBox(height: 12),
Text('새 알림이 없습니다', style: theme.textTheme.titleMedium),
],
), ),
), ),
for (final log in _logs) for (final log in _logs)
Material( Padding(
color: Colors.transparent, padding: const EdgeInsets.only(bottom: 8),
child: InkWell( child: Card(
onTap: () { child: InkWell(
Navigator.of(context).push( borderRadius: BorderRadius.circular(16),
MaterialPageRoute( onTap: () {
builder: (_) => ChatScreen(conversationId: log.conversationId), Navigator.of(context).push(
), MaterialPageRoute(
); builder: (_) => ChatScreen(conversationId: log.conversationId),
},
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.rPanel),
border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
color: AppTheme.glassFill(theme.brightness),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
log.reason.isEmpty ? '에스컬레이션' : log.reason,
style: theme.textTheme.titleSmall,
),
),
Text(
log.resolved ? '처리됨' : '확인 필요',
style: theme.textTheme.labelSmall?.copyWith(
color: log.resolved
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.error,
fontWeight: FontWeight.w600,
),
),
],
), ),
const SizedBox(height: 4), );
Text( },
'대화방 #${log.conversationId}', child: Padding(
style: theme.textTheme.bodySmall padding: const EdgeInsets.all(14),
?.copyWith(color: theme.colorScheme.onSurfaceVariant), child: Row(
), crossAxisAlignment: CrossAxisAlignment.start,
if (log.messageSnippet.isNotEmpty) ...[ children: [
const SizedBox(height: 6), CircleAvatar(
Text( radius: 18,
log.messageSnippet, backgroundColor: log.resolved
maxLines: 2, ? theme.colorScheme.primaryContainer
overflow: TextOverflow.ellipsis, : theme.colorScheme.errorContainer,
style: theme.textTheme.bodyMedium, child: Icon(
log.resolved ? Icons.check_circle_outline : Icons.warning_amber_rounded,
size: 18,
color: log.resolved
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onErrorContainer,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
log.reason.isEmpty ? '에스컬레이션' : log.reason,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 2),
Text(
'대화방 #${log.conversationId}',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
if (log.messageSnippet.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
log.messageSnippet,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium,
),
],
const SizedBox(height: 6),
Text(
log.createdAt.toLocal().toString().split('.').first,
style: theme.textTheme.labelSmall
?.copyWith(color: theme.colorScheme.outline),
),
],
),
), ),
], ],
const SizedBox(height: 6), ),
Text(
log.createdAt.toLocal().toString().split('.').first,
style: theme.textTheme.labelSmall
?.copyWith(color: theme.colorScheme.outline),
),
],
), ),
), ),
), ),

View File

@ -1,9 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/models.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/primary_gradient_button.dart';
/// Phase 1 onboarding skeleton: capture a few style samples locally. /// Phase 1 onboarding skeleton: capture a few style samples locally.
/// Fine copy / import UX waits for human PoC (#1) do not invent §3 defaults here. /// Fine copy / import UX waits for human PoC (#1) do not invent §3 defaults here.
@ -17,19 +15,16 @@ class OnboardingToneScreen extends StatefulWidget {
class _OnboardingToneScreenState extends State<OnboardingToneScreen> { class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
final _samples = List.generate(4, (_) => TextEditingController()); final _samples = List.generate(4, (_) => TextEditingController());
var _seeded = false; var _seeded = false;
RelationshipTier _tier = RelationshipTier.formal;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
if (_seeded) return; if (_seeded) return;
_seeded = true; _seeded = true;
final session = context.read<SessionState>(); final existing = context.read<SessionState>().styleExamples;
final existing = session.styleExamples;
for (var i = 0; i < existing.length && i < _samples.length; i++) { for (var i = 0; i < existing.length && i < _samples.length; i++) {
_samples[i].text = existing[i]; _samples[i].text = existing[i];
} }
_tier = session.relationshipTier;
} }
@override @override
@ -46,7 +41,6 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
_samples.map((c) => c.text).toList(), _samples.map((c) => c.text).toList(),
markDone: markDone, markDone: markDone,
); );
await session.setRelationshipTier(_tier);
} }
@override @override
@ -66,16 +60,21 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
), ),
body: SafeArea( body: SafeArea(
child: ListView( child: ListView(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 32), padding: const EdgeInsets.all(24),
children: [ children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(color: scheme.primaryContainer, borderRadius: BorderRadius.circular(16)),
child: Icon(Icons.record_voice_over_outlined, size: 28, color: scheme.onPrimaryContainer),
),
const SizedBox(height: 16),
Text('와카뷰가 따라 쓸 말투', style: theme.textTheme.headlineSmall), Text('와카뷰가 따라 쓸 말투', style: theme.textTheme.headlineSmall),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다.', '자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. '
style: theme.textTheme.bodyMedium?.copyWith( '최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.',
color: scheme.onSurfaceVariant, style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
height: 1.45,
),
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
for (var i = 0; i < _samples.length; i++) ...[ for (var i = 0; i < _samples.length; i++) ...[
@ -85,35 +84,29 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
decoration: InputDecoration( decoration: InputDecoration(
labelText: '샘플 ${i + 1}', labelText: '샘플 ${i + 1}',
hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null, hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null,
prefixIcon: Padding(
padding: const EdgeInsets.only(left: 4, right: 4, top: 4),
child: CircleAvatar(
radius: 12,
backgroundColor: scheme.secondaryContainer,
child: Text(
'${i + 1}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: scheme.onSecondaryContainer,
),
),
),
),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
const SizedBox(height: 16),
Text('기본 관계 설정', style: theme.textTheme.titleSmall),
const SizedBox(height: 6),
Text(
'와카뷰가 초안을 쓸 때 기본으로 쓸 말투 격식이에요. 상대별로 나중에 연락처에서 따로 바꿀 수 있어요.',
style: theme.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant, height: 1.4),
),
const SizedBox(height: 10),
SegmentedButton<RelationshipTier>(
segments: RelationshipTier.values
.map((t) => ButtonSegment(value: t, label: Text(t.label)))
.toList(),
selected: {_tier},
onSelectionChanged: (s) => setState(() => _tier = s.first),
),
const SizedBox(height: 24),
PrimaryGradientButton(
label: '이 말투로 시작',
onPressed: () => _save(markDone: true),
),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( FilledButton(
'말투는 나중에 메뉴에서 다시 수정할 수 있습니다.', onPressed: () => _save(markDone: true),
textAlign: TextAlign.center, child: const Text('이 말투로 시작'),
style: theme.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant),
), ),
], ],
), ),

View File

@ -8,17 +8,6 @@ import '../state/session_state.dart';
class SessionsScreen extends StatefulWidget { class SessionsScreen extends StatefulWidget {
const SessionsScreen({super.key}); const SessionsScreen({super.key});
/// Formats API `expires_at` (UTC RFC3339) for the device local timezone.
static String formatExpiresAtLocal(Object? raw) {
if (raw == null) return '';
final parsed = DateTime.tryParse(raw.toString());
if (parsed == null) return raw.toString();
final local = parsed.toLocal();
String two(int n) => n.toString().padLeft(2, '0');
return '${local.year}-${two(local.month)}-${two(local.day)} '
'${two(local.hour)}:${two(local.minute)}';
}
@override @override
State<SessionsScreen> createState() => _SessionsScreenState(); State<SessionsScreen> createState() => _SessionsScreenState();
} }
@ -68,16 +57,13 @@ class _SessionsScreenState extends State<SessionsScreen> {
], ],
), ),
); );
if (ok != true || !mounted) return; if (ok != true) return;
if (isCurrent) {
// Q8c clear local token and return to entry via AuthGate.
await session.logout();
if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst);
return;
}
try { try {
await session.api.revokeSession(session.user!.id, id.toInt()); await session.api.revokeSession(session.user!.id, id.toInt());
if (isCurrent && mounted) {
// Soft signal full logout clearing is a follow-up; reload list for now.
setState(() => _error = '현재 세션이 종료되었습니다. 앱을 다시 시작해 주세요.');
}
await _load(); await _load();
} on ApiException catch (e) { } on ApiException catch (e) {
setState(() => _error = '세션 종료 실패 (${e.statusCode})'); setState(() => _error = '세션 종료 실패 (${e.statusCode})');
@ -109,20 +95,30 @@ class _SessionsScreenState extends State<SessionsScreen> {
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)), child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
), ),
for (final s in _sessions) for (final s in _sessions)
ListTile( Padding(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), padding: const EdgeInsets.symmetric(vertical: 2),
title: Text( child: ListTile(
s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}', leading: CircleAvatar(
style: theme.textTheme.titleSmall, backgroundColor: s['is_current'] == true
), ? theme.colorScheme.primaryContainer
subtitle: Text( : theme.colorScheme.surfaceContainerHighest,
'만료: ${SessionsScreen.formatExpiresAtLocal(s['expires_at'])}', child: Icon(
style: theme.textTheme.bodySmall, s['is_current'] == true ? Icons.smartphone : Icons.devices_other,
), color: s['is_current'] == true
trailing: IconButton( ? theme.colorScheme.onPrimaryContainer
tooltip: '세션 종료', : theme.colorScheme.onSurfaceVariant,
icon: const Icon(Icons.logout), ),
onPressed: () => _revoke(s), ),
title: Text(
s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}',
style: theme.textTheme.titleSmall,
),
subtitle: Text('만료: ${s['expires_at'] ?? ''}', style: theme.textTheme.bodySmall),
trailing: IconButton(
tooltip: '세션 종료',
icon: const Icon(Icons.logout),
onPressed: () => _revoke(s),
),
), ),
), ),
], ],

View File

@ -1,166 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/snooze_notification_service.dart';
import '../state/session_state.dart';
import 'autonomy_settings_screen.dart';
import 'data_flow_screen.dart';
import 'onboarding_tone_screen.dart';
import 'sessions_screen.dart';
/// Account / settings hub (Q8).
///
/// Groups tone, autonomy, sessions, data-flow, and logout in one place
/// so the conversation list menu stays short.
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
Future<void> _confirmLogout(BuildContext context) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('로그아웃'),
content: const Text(
'이 기기에서 로그아웃할까요?\n'
'다시 쓰려면 초대 코드와 표시 이름으로 로그인해야 합니다.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('취소'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('로그아웃'),
),
],
),
);
if (ok != true || !context.mounted) return;
final session = context.read<SessionState>();
await session.logout();
if (!context.mounted) return;
// Pop back to root; AuthGate rebuilds to SignupScreen.
Navigator.of(context).popUntil((route) => route.isFirst);
}
@override
Widget build(BuildContext context) {
final session = context.watch<SessionState>();
final user = session.user;
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('설정')),
body: ListView(
children: [
if (user != null)
ListTile(
leading: CircleAvatar(
child: Text(
user.displayName.isNotEmpty
? String.fromCharCode(user.displayName.runes.first).toUpperCase()
: '?',
),
),
title: Text(user.displayName),
subtitle: Text(
'user #${user.id} · 탭하여 ID 복사',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
trailing: const Icon(Icons.copy_rounded, size: 18),
onTap: () async {
await Clipboard.setData(ClipboardData(text: '${user.id}'));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('내 사용자 ID ${user.id} 를 복사했습니다')),
);
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.record_voice_over_outlined),
title: const Text('말투 · 페르소나'),
subtitle: const Text('트윈이 쓰는 말투와 역할'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const OnboardingToneScreen()),
);
},
),
ListTile(
leading: const Icon(Icons.tune),
title: const Text('자율성 수준'),
subtitle: const Text('L0 · L1 · L2'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const AutonomySettingsScreen(),
),
);
},
),
ListTile(
leading: const Icon(Icons.devices_outlined),
title: const Text('로그인 세션'),
subtitle: const Text('기기별 세션 조회 · 폐기'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SessionsScreen()),
);
},
),
ListTile(
leading: const Icon(Icons.account_tree_outlined),
title: const Text('데이터 흐름'),
subtitle: const Text('내 데이터가 어디로 가는지'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DataFlowScreen()),
);
},
),
if (kIsWeb)
ListTile(
leading: const Icon(Icons.notifications_active_outlined),
title: const Text('브라우저 알림'),
subtitle: const Text('답장 마감 리마인드 (허용 시)'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
final ok = await LocalSnoozeNotificationScheduler().ensurePermission();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
ok
? '브라우저 알림이 허용되었습니다'
: '알림이 거부되었거나 지원되지 않습니다. 인앱 배지는 그대로 동작합니다.',
),
),
);
},
),
const Divider(height: 24),
ListTile(
leading: Icon(Icons.logout, color: theme.colorScheme.error),
title: Text(
'로그아웃',
style: TextStyle(color: theme.colorScheme.error),
),
subtitle: const Text('이 기기 세션 종료'),
onTap: () => _confirmLogout(context),
),
],
),
);
}
}

View File

@ -5,11 +5,7 @@ import 'package:provider/provider.dart';
import '../config.dart'; import '../config.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../widgets/brand_mark.dart';
import '../widgets/gradient_text.dart';
import '../widgets/primary_gradient_button.dart';
/// Closed-beta entry: signup (new invite) or login (already registered).
class SignupScreen extends StatefulWidget { class SignupScreen extends StatefulWidget {
const SignupScreen({super.key}); const SignupScreen({super.key});
@ -17,26 +13,12 @@ class SignupScreen extends StatefulWidget {
State<SignupScreen> createState() => _SignupScreenState(); State<SignupScreen> createState() => _SignupScreenState();
} }
class _SignupScreenState extends State<SignupScreen> class _SignupScreenState extends State<SignupScreen> {
with SingleTickerProviderStateMixin {
final _invite = TextEditingController(); final _invite = TextEditingController();
final _name = TextEditingController(); final _name = TextEditingController();
late final TabController _tabs;
bool get _isLogin => _tabs.index == 1;
@override
void initState() {
super.initState();
_tabs = TabController(length: 2, vsync: this);
_tabs.addListener(() {
if (!_tabs.indexIsChanging) setState(() {});
});
}
@override @override
void dispose() { void dispose() {
_tabs.dispose();
_invite.dispose(); _invite.dispose();
_name.dispose(); _name.dispose();
super.dispose(); super.dispose();
@ -48,16 +30,6 @@ class _SignupScreenState extends State<SignupScreen>
setState(() {}); setState(() {});
} }
Future<void> _submit(SessionState session) async {
final invite = _invite.text.trim();
final name = _name.text.trim();
if (_isLogin) {
await session.login(invite, name);
} else {
await session.signup(invite, name);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final session = context.watch<SessionState>(); final session = context.watch<SessionState>();
@ -65,100 +37,90 @@ class _SignupScreenState extends State<SignupScreen>
final scheme = theme.colorScheme; final scheme = theme.colorScheme;
return Scaffold( return Scaffold(
body: SafeArea( body: SafeArea(
child: LayoutBuilder( child: Padding(
builder: (context, constraints) { padding: const EdgeInsets.symmetric(horizontal: 24),
return SingleChildScrollView( child: Column(
padding: const EdgeInsets.symmetric(horizontal: 24), crossAxisAlignment: CrossAxisAlignment.stretch,
child: ConstrainedBox( children: [
constraints: BoxConstraints(minHeight: constraints.maxHeight), const Spacer(flex: 3),
child: IntrinsicHeight( Center(
child: Column( child: Container(
crossAxisAlignment: CrossAxisAlignment.stretch, width: 72,
children: [ height: 72,
const Spacer(flex: 2), decoration: BoxDecoration(
const Center(child: BrandMark(size: 72)), color: scheme.primaryContainer,
const SizedBox(height: 20), borderRadius: BorderRadius.circular(22),
GradientText(
'와카뷰',
textAlign: TextAlign.center,
style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 8),
Text(
'나를 대신해 답하는, 나만의 와카뷰',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant),
),
const Spacer(flex: 1),
TabBar(
controller: _tabs,
tabs: const [
Tab(text: '새로 가입'),
Tab(text: '이미 가입'),
],
),
const SizedBox(height: 16),
Text(
_isLogin
? '초대 코드와 표시 이름으로 다시 로그인합니다'
: '초대 코드로 클로즈드 베타에 참여합니다',
style: theme.textTheme.labelLarge,
),
const SizedBox(height: 12),
_DemoTestPanel(
onFill: _fillDemoCredentials,
onCopy: () async {
await Clipboard.setData(
const ClipboardData(text: AppConfig.demoInviteCode),
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('테스트 초대 코드를 복사했습니다')),
);
},
),
const SizedBox(height: 16),
TextField(
controller: _invite,
decoration: const InputDecoration(
labelText: '초대 코드',
prefixIcon: Icon(Icons.vpn_key),
),
textInputAction: TextInputAction.next,
textCapitalization: TextCapitalization.characters,
),
const SizedBox(height: 12),
TextField(
controller: _name,
decoration: InputDecoration(
labelText: '표시 이름',
hintText: _isLogin ? '가입 때 쓴 이름 (DEMO는 필수)' : null,
prefixIcon: const Icon(Icons.person_outline),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) {
if (!session.loading) _submit(session);
},
),
if (session.error != null) ...[
const SizedBox(height: 12),
Text(session.error!, style: TextStyle(color: scheme.error)),
],
const SizedBox(height: 20),
_PressScale(
child: PrimaryGradientButton(
label: _isLogin ? '로그인' : '시작하기',
loading: session.loading,
onPressed: session.loading ? null : () => _submit(session),
),
),
const SizedBox(height: 32),
],
), ),
child: Icon(Icons.auto_awesome, size: 34, color: scheme.onPrimaryContainer),
), ),
), ),
); const SizedBox(height: 20),
}, Text(
'와카뷰',
textAlign: TextAlign.center,
style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 8),
Text(
'나를 대신해 답하는, 나만의 와카뷰',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant),
),
const Spacer(flex: 2),
Text('초대 코드로 클로즈드 베타에 참여합니다', style: theme.textTheme.labelLarge),
const SizedBox(height: 12),
_DemoTestPanel(
onFill: _fillDemoCredentials,
onCopy: () async {
await Clipboard.setData(const ClipboardData(text: AppConfig.demoInviteCode));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('테스트 초대 코드를 복사했습니다')),
);
},
),
const SizedBox(height: 16),
TextField(
controller: _invite,
decoration: const InputDecoration(
labelText: '초대 코드',
prefixIcon: Icon(Icons.vpn_key),
),
textInputAction: TextInputAction.next,
textCapitalization: TextCapitalization.characters,
),
const SizedBox(height: 12),
TextField(
controller: _name,
decoration: const InputDecoration(
labelText: '표시 이름',
prefixIcon: Icon(Icons.person_outline),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) {
if (!session.loading) {
session.signup(_invite.text.trim(), _name.text.trim());
}
},
),
if (session.error != null) ...[
const SizedBox(height: 12),
Text(session.error!, style: TextStyle(color: scheme.error)),
],
const SizedBox(height: 20),
_PressScale(
child: FilledButton(
onPressed: session.loading
? null
: () => session.signup(_invite.text.trim(), _name.text.trim()),
child: session.loading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('시작하기'),
),
),
const SizedBox(height: 32),
],
),
), ),
), ),
); );
@ -178,16 +140,15 @@ class _DemoTestPanel extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final brightness = theme.brightness; final brightness = theme.brightness;
return Material( return Material(
color: Colors.transparent, color: AppTheme.glassFill(brightness),
borderRadius: BorderRadius.circular(AppTheme.rPanel), borderRadius: BorderRadius.circular(16),
child: InkWell( child: InkWell(
onTap: onFill, onTap: onFill,
borderRadius: BorderRadius.circular(AppTheme.rPanel), borderRadius: BorderRadius.circular(16),
child: Container( child: Container(
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14), padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.glassFill(brightness), borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(AppTheme.rPanel),
border: Border.all(color: AppTheme.glassBorder(brightness)), border: Border.all(color: AppTheme.glassBorder(brightness)),
), ),
child: Column( child: Column(
@ -197,8 +158,7 @@ class _DemoTestPanel extends StatelessWidget {
'테스트용 (누구나)', '테스트용 (누구나)',
style: theme.textTheme.labelLarge?.copyWith( style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
letterSpacing: 0.15, letterSpacing: 0.4,
fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@ -209,7 +169,7 @@ class _DemoTestPanel extends StatelessWidget {
'초대 코드 ${AppConfig.demoInviteCode}\n표시 이름 ${AppConfig.demoDisplayName}', '초대 코드 ${AppConfig.demoInviteCode}\n표시 이름 ${AppConfig.demoDisplayName}',
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
height: 1.45, height: 1.45,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
), ),
), ),
), ),

View File

@ -1,8 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../widgets/brand_mark.dart';
import '../widgets/gradient_text.dart';
/// Shown while `SessionState.restore()` runs, before the real app (signup / /// Shown while `SessionState.restore()` runs, before the real app (signup /
/// onboarding / conversation list) is ready to pick. Purely presentational /// onboarding / conversation list) is ready to pick. Purely presentational
/// no navigation logic lives here, `main.dart` swaps it out once restore() /// no navigation logic lives here, `main.dart` swaps it out once restore()
@ -20,9 +17,31 @@ class SplashScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const BrandMark(size: 72), Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: scheme.primary.withOpacity(0.15),
shape: BoxShape.circle,
),
child: Center(
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [scheme.primary, scheme.tertiary],
),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(Icons.auto_awesome, color: Colors.white, size: 30),
),
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
GradientText( Text(
'와카뷰', '와카뷰',
style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w800), style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w800),
), ),
@ -33,12 +52,9 @@ class SplashScreen extends StatelessWidget {
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
SizedBox( SizedBox(
width: 22, width: 28,
height: 22, height: 28,
child: CircularProgressIndicator( child: CircularProgressIndicator(strokeWidth: 2.5, color: scheme.primary),
strokeWidth: 2.2,
color: scheme.primary,
),
), ),
], ],
), ),

View File

@ -76,27 +76,6 @@ class ApiClient {
); );
} }
/// Q8b re-issue a session for an existing invite-based account.
Future<({User user, String token})> login({
required String inviteCode,
required String displayName,
}) async {
final json = await _json('POST', '/auth/login', body: {
'invite_code': inviteCode,
'display_name': displayName,
});
final token = json['token'] as String? ?? '';
authToken = token;
return (
user: User(
id: json['id'] as int,
displayName: json['display_name'] as String? ?? displayName,
inviteCode: inviteCode,
),
token: token,
);
}
Future<List<ConversationSummary>> listConversations() async { Future<List<ConversationSummary>> listConversations() async {
final obj = await _getObject('/conversations'); final obj = await _getObject('/conversations');
final list = (obj['conversations'] as List<dynamic>? ?? const []); final list = (obj['conversations'] as List<dynamic>? ?? const []);
@ -122,15 +101,8 @@ class ApiClient {
); );
} }
/// [sinceId] id보다 Future<List<ChatMessage>> listMessages(int conversationId) async {
/// (roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11) final obj = await _getObject('/conversations/$conversationId/messages');
/// · gap을 .
/// ( ) .
Future<List<ChatMessage>> listMessages(int conversationId, {int? sinceId}) async {
final path = sinceId != null
? '/conversations/$conversationId/messages?since_id=$sinceId'
: '/conversations/$conversationId/messages';
final obj = await _getObject(path);
final list = (obj['messages'] as List<dynamic>? ?? const []); final list = (obj['messages'] as List<dynamic>? ?? const []);
return list.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>)).toList(); return list.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>)).toList();
} }
@ -146,15 +118,11 @@ class ApiClient {
required String displayName, required String displayName,
int? contactUserId, int? contactUserId,
String relationshipNote = '', String relationshipNote = '',
RelationshipTier? relationshipTier,
AutonomyLevel? autonomyLevel,
}) async { }) async {
final json = await _json('POST', '/users/$userId/contacts', body: { final json = await _json('POST', '/users/$userId/contacts', body: {
'display_name': displayName, 'display_name': displayName,
if (contactUserId != null) 'contact_user_id': contactUserId, if (contactUserId != null) 'contact_user_id': contactUserId,
'relationship_note': relationshipNote, 'relationship_note': relationshipNote,
if (relationshipTier != null) 'relationship_tier': relationshipTier.name,
if (autonomyLevel != null) 'autonomy_level': autonomyLevel.name,
}); });
return Contact.fromJson(json); return Contact.fromJson(json);
} }
@ -165,15 +133,11 @@ class ApiClient {
required String displayName, required String displayName,
int? contactUserId, int? contactUserId,
String relationshipNote = '', String relationshipNote = '',
RelationshipTier? relationshipTier,
AutonomyLevel? autonomyLevel,
}) async { }) async {
final json = await _json('PATCH', '/users/$userId/contacts/$contactId', body: { final json = await _json('PATCH', '/users/$userId/contacts/$contactId', body: {
'display_name': displayName, 'display_name': displayName,
if (contactUserId != null) 'contact_user_id': contactUserId, if (contactUserId != null) 'contact_user_id': contactUserId,
'relationship_note': relationshipNote, 'relationship_note': relationshipNote,
if (relationshipTier != null) 'relationship_tier': relationshipTier.name,
if (autonomyLevel != null) 'autonomy_level': autonomyLevel.name,
}); });
return Contact.fromJson(json); return Contact.fromJson(json);
} }
@ -194,28 +158,12 @@ class ApiClient {
required String text, required String text,
SenderMode senderMode = SenderMode.human, SenderMode senderMode = SenderMode.human,
bool approved = false, bool approved = false,
// (PRD.md §5, deploy-checklist.md N4-12):
// - AI diff할
// . null이면( , )
// relationshipTier/autonomyLevel "값 있을 때만
// " 관례.
String? originalDraftText,
}) async { }) async {
final json = await _json('POST', '/conversations/$conversationId/messages', body: { final json = await _json('POST', '/conversations/$conversationId/messages', body: {
'sender_id': senderId, 'sender_id': senderId,
'text': text, 'text': text,
'sender_mode': senderMode == SenderMode.twin ? 'twin' : 'human', 'sender_mode': senderMode == SenderMode.twin ? 'twin' : 'human',
if (approved) 'approved': true, if (approved) 'approved': true,
if (originalDraftText != null) 'original_draft_text': originalDraftText,
});
return ChatMessage.fromJson(json);
}
/// "이 답장 나답아요?" (vision.md , N4-12)
/// . ( ).
Future<ChatMessage> submitMessageFeedback(int messageId, bool natural) async {
final json = await _json('POST', '/messages/$messageId/feedback', body: {
'natural': natural,
}); });
return ChatMessage.fromJson(json); return ChatMessage.fromJson(json);
} }
@ -237,37 +185,13 @@ class ApiClient {
await _json('POST', '/conversations/$conversationId/veto'); await _json('POST', '/conversations/$conversationId/veto');
} }
/// (roadmap.md §2.7-C, AGENTS.md
/// "every automatic action needs post-hoc notification + one-tap undo").
/// (veto) .
Future<void> resetFlood(int conversationId) async {
await _json('POST', '/conversations/$conversationId/flood-reset');
}
/// (roadmap.md §2.7-A): advances the caller's read marker.
Future<void> markRead(int conversationId, int messageId) async {
await _json('POST', '/conversations/$conversationId/read', body: {
'message_id': messageId,
});
}
Future<GroupSummaryResult> getGroupSummary(int conversationId) async {
final json = await _getObject('/conversations/$conversationId/summary');
return GroupSummaryResult.fromJson(json);
}
Future<void> retractMessage(int messageId) async { Future<void> retractMessage(int messageId) async {
await _json('POST', '/messages/$messageId/retract'); await _json('POST', '/messages/$messageId/retract');
} }
Future<TwinSettings> patchTwinSettings({ Future<TwinSettings> patchTwinSettings(int userId, AutonomyLevel level) async {
required int userId,
required AutonomyLevel autonomyLevel,
RelationshipTier? relationshipTier,
}) async {
final json = await _json('PATCH', '/users/$userId/twin-settings', body: { final json = await _json('PATCH', '/users/$userId/twin-settings', body: {
'autonomy_level': autonomyLevel.name, 'autonomy_level': level.name,
if (relationshipTier != null) 'relationship_tier': relationshipTier.name,
}); });
return TwinSettings.fromJson(json); return TwinSettings.fromJson(json);
} }

View File

@ -1,64 +0,0 @@
/// (roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11)
/// . `DateTime.now()`/
/// .
///
/// : (core-backend) DB에 durable하게
/// .
/// "" ,
/// id `GET .../messages?since_id=`
/// gap을 . backoff , REST로
/// .
library;
import '../models/models.dart';
/// backoff의 .
const Duration kReconnectInitialDelay = Duration(seconds: 1);
/// backoff의 .
const Duration kReconnectMaxDelay = Duration(seconds: 30);
/// ([attempt], 0-based: 0)
/// . 1s 2s 4s 8s 16s 30s() ,
/// . attempt를 0 .
/// [attempt] ( )
/// attempt .
Duration nextReconnectDelay(int attempt) {
if (attempt <= 0) return kReconnectInitialDelay;
const maxUsefulAttempt = 10; // 1s * 2^10 = 1024s, (30s) .
final clamped = attempt > maxUsefulAttempt ? maxUsefulAttempt : attempt;
final ms = kReconnectInitialDelay.inMilliseconds * (1 << clamped);
final capped = ms > kReconnectMaxDelay.inMilliseconds ? kReconnectMaxDelay.inMilliseconds : ms;
return Duration(milliseconds: capped);
}
/// id ( null)
/// `since_id` .
int? highestMessageId(List<ChatMessage> messages) {
if (messages.isEmpty) return null;
return messages.map((m) => m.id).reduce((a, b) => a > b ? a : b);
}
/// [existing] [fresh] (id ) id .
/// REST
/// ( race) . [existing]
/// .
List<ChatMessage> mergeNewMessages(
List<ChatMessage> existing,
List<ChatMessage> fresh,
) {
final knownIds = existing.map((m) => m.id).toSet();
final toAdd = fresh.where((m) => !knownIds.contains(m.id)).toList()
..sort((a, b) => a.id.compareTo(b.id));
if (toAdd.isEmpty) return List.of(existing);
return [...existing, ...toAdd];
}
/// "이 답장 나답아요?" (vision.md , deploy-checklist.md N4-12)
/// : [messages] (naturalness_rating != null) id들만
/// . chat_screen.dart는 (
/// ) "이미 평가됨"
/// / .
Set<int> ratedMessageIdsFrom(List<ChatMessage> messages) {
return messages.where((m) => m.naturalnessRating != null).map((m) => m.id).toSet();
}

View File

@ -4,20 +4,13 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../config/firebase_web_config.dart';
/// Resolves a device push token for core-backend registration. /// Resolves a device push token for core-backend registration.
/// ///
/// Prefer a real FCM registration token when Firebase is configured /// Prefer a real FCM registration token when Firebase is configured
/// (`google-services.json` on Android, dart-define Web config + VAPID on web). /// (`google-services.json` on Android). Otherwise fall back to a stable
/// Otherwise fall back to a stable `install:` placeholder that the server /// `install:` placeholder that the server intentionally skips for delivery.
/// intentionally skips for delivery.
class PushTokenService { class PushTokenService {
PushTokenService({ PushTokenService({this.storeInstallId, this.readInstallId});
this.storeInstallId,
this.readInstallId,
FirebaseWebConfig? webConfig,
}) : webConfig = webConfig ?? FirebaseWebConfig.fromEnvironment();
/// Persist a newly generated install id (KV / secure storage). /// Persist a newly generated install id (KV / secure storage).
final Future<void> Function(String installId)? storeInstallId; final Future<void> Function(String installId)? storeInstallId;
@ -25,9 +18,6 @@ class PushTokenService {
/// Load a previously stored install id. /// Load a previously stored install id.
final Future<String?> Function()? readInstallId; final Future<String?> Function()? readInstallId;
/// Injected for tests; production uses [FirebaseWebConfig.fromEnvironment].
final FirebaseWebConfig webConfig;
Future<({String token, String platform, bool isFcm})> resolve() async { Future<({String token, String platform, bool isFcm})> resolve() async {
final platform = _platformLabel(); final platform = _platformLabel();
final fcm = await _tryFirebaseToken(); final fcm = await _tryFirebaseToken();
@ -39,9 +29,8 @@ class PushTokenService {
} }
Future<String?> _tryFirebaseToken() async { Future<String?> _tryFirebaseToken() async {
if (kIsWeb) { // Web push needs a separate Firebase web config + VAPID; skip until provided.
return _tryFirebaseWebToken(); if (kIsWeb) return null;
}
if (defaultTargetPlatform != TargetPlatform.android && if (defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS) { defaultTargetPlatform != TargetPlatform.iOS) {
return null; return null;
@ -61,31 +50,6 @@ class PushTokenService {
} }
} }
/// N4-W4 Web Push via FCM + VAPID (build-time dart-define secrets).
Future<String?> _tryFirebaseWebToken() async {
if (!webConfig.isReady) {
debugPrint(
'FCM web skipped: FIREBASE_* / VAPID dart-defines not set '
'(see docs/fcm-setup.md Web)',
);
return null;
}
try {
final options = webConfig.options!;
if (Firebase.apps.isEmpty) {
await Firebase.initializeApp(options: options);
}
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission(alert: true, badge: true, sound: true);
final token = await messaging.getToken(vapidKey: webConfig.vapidKey);
if (token == null || token.isEmpty) return null;
return token;
} catch (e) {
debugPrint('FCM web token unavailable (using install placeholder): $e');
return null;
}
}
Future<String> _ensureInstallId() async { Future<String> _ensureInstallId() async {
final existing = await readInstallId?.call(); final existing = await readInstallId?.call();
if (existing != null && existing.isNotEmpty) return existing; if (existing != null && existing.isNotEmpty) return existing;

View File

@ -1,54 +0,0 @@
import '../db/app_database.dart';
import 'snooze_notification_service.dart';
import 'snooze_service.dart';
/// (roadmap.md §2.7-F) DB( )
/// (OS / Notification) .
/// `chat_screen.dart` / `conversation_list_screen.dart` .
///
/// AGENTS.md "Tone/style learning: on-device first"
/// , ·
/// . [scheduler] (mock) "DB에 정확히 반영되고
/// id// "까지 검증한다 — 실제 OS 알림이
/// ( / ).
class SnoozeController {
SnoozeController({required AppDatabase db, required SnoozeNotificationScheduler scheduler})
: _db = db,
_scheduler = scheduler;
final AppDatabase _db;
final SnoozeNotificationScheduler _scheduler;
Future<DateTime?> loadSnoozedUntil(int conversationId) => _db.getSnoozedUntil(conversationId);
Future<Map<int, DateTime>> loadAllSnoozes() => _db.loadAllSnoozes();
Future<void> applySnooze({
required int conversationId,
required DateTime until,
required String title,
required String body,
}) async {
await _db.setSnoozedUntil(conversationId, until);
await _scheduler.scheduleReminder(conversationId: conversationId, title: title, body: body, at: until);
}
Future<void> clearSnooze(int conversationId) async {
await _db.clearSnooze(conversationId);
await _scheduler.cancelReminder(conversationId);
}
/// Web N4-W3: when the tab/app resumes, surface past-due snoozes once via
/// [SnoozeNotificationScheduler.showImmediate] (no-op on native).
Future<void> notifyPastDueOnFocus({DateTime? now}) async {
final all = await loadAllSnoozes();
final t = now ?? DateTime.now();
for (final e in all.entries) {
if (!isSnoozePastDue(t, e.value)) continue;
await _scheduler.showImmediate(
title: '답장 마감',
body: '대화 #${e.key} — 답장할 시간입니다',
);
}
}
}

View File

@ -1,9 +0,0 @@
/// (roadmap.md §2.7-F) OS
/// export로 (`../db/app_database.dart` ).
///
/// Flutter Web은 `flutter_local_notifications` Notification API +
/// in-tab Timer를 (`snooze_notification_service_web.dart`, docs/web-upgrade N4-W3).
library;
export 'snooze_notification_service_native.dart'
if (dart.library.html) 'snooze_notification_service_web.dart';

View File

@ -1,93 +0,0 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz;
/// (roadmap.md §2.7-F, PRD.md §3.2 "내가 '이따 답장' 누르면 나에게만
/// ")의 스케줄러 인터페이스. 다른 사람에게는 절대 보이지 않는, 이 기기에만
/// AGENTS.md .
///
/// Android/iOS ·
/// / . `zonedSchedule()` /
/// `cancel()` id·· ,
/// (mock) (`test/snooze_notification_scheduler_test.dart`)
/// .
abstract class SnoozeNotificationScheduler {
Future<void> scheduleReminder({
required int conversationId,
required String title,
required String body,
required DateTime at,
});
Future<void> cancelReminder(int conversationId);
/// Immediate notification (web past-due catch-up). Native uses OS schedule only.
Future<void> showImmediate({required String title, required String body}) async {}
Future<bool> ensurePermission() async => true;
}
class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
LocalSnoozeNotificationScheduler({FlutterLocalNotificationsPlugin? plugin})
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
final FlutterLocalNotificationsPlugin _plugin;
bool _initialized = false;
static const _channelId = 'reply_snooze_reminders';
static const _channelName = '답장 마감 알림';
static const _channelDescription = '"이따 답장" 스누즈 마감 시각 알림 (본인 기기에만 표시, 상대에게는 보이지 않음)';
Future<void> _ensureInitialized() async {
if (_initialized) return;
tz_data.initializeTimeZones();
const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosInit = DarwinInitializationSettings();
await _plugin.initialize(
settings: const InitializationSettings(android: androidInit, iOS: iosInit),
);
_initialized = true;
}
@override
Future<void> scheduleReminder({
required int conversationId,
required String title,
required String body,
required DateTime at,
}) async {
await _ensureInitialized();
await _plugin.zonedSchedule(
id: conversationId,
title: title,
body: body,
scheduledDate: tz.TZDateTime.from(at, tz.local),
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
channelDescription: _channelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
),
iOS: DarwinNotificationDetails(),
),
// SCHEDULE_EXACT_ALARM
// (Doze ).
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
);
}
@override
Future<void> cancelReminder(int conversationId) async {
await _plugin.cancel(id: conversationId);
}
@override
Future<void> showImmediate({required String title, required String body}) async {
// Native path relies on zonedSchedule; no separate immediate channel here.
}
@override
Future<bool> ensurePermission() async => true;
}

View File

@ -1,84 +0,0 @@
import 'dart:async';
// Web-only conditional import target (see snooze_notification_service.dart).
// ignore: avoid_web_libraries_in_flutter, deprecated_member_use
import 'dart:html' as html;
/// Web reminder scheduler (docs/web-upgrade.md N4-W3).
///
/// Uses the browser Notification API when permission is granted. Scheduling is
/// in-tab [Timer]-based (no SW alarm) if the tab is closed before [at], the
/// in-app past-due badge/banner remains the source of truth after reopen.
abstract class SnoozeNotificationScheduler {
Future<void> scheduleReminder({
required int conversationId,
required String title,
required String body,
required DateTime at,
});
Future<void> cancelReminder(int conversationId);
Future<void> showImmediate({required String title, required String body});
Future<bool> ensurePermission();
}
class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
final Map<int, Timer> _timers = {};
bool get permissionGranted {
try {
return html.Notification.permission == 'granted';
} catch (_) {
return false;
}
}
@override
Future<bool> ensurePermission() async {
try {
final current = html.Notification.permission;
if (current == 'granted') return true;
if (current == 'denied') return false;
final result = await html.Notification.requestPermission();
return result == 'granted';
} catch (_) {
return false;
}
}
@override
Future<void> showImmediate({required String title, required String body}) async {
try {
if (!permissionGranted) return;
html.Notification(title, body: body);
} catch (_) {
// Unsupported browser ignore.
}
}
@override
Future<void> scheduleReminder({
required int conversationId,
required String title,
required String body,
required DateTime at,
}) async {
await ensurePermission();
await cancelReminder(conversationId);
final delay = at.toUtc().difference(DateTime.now().toUtc());
if (delay.isNegative || delay == Duration.zero) {
await showImmediate(title: title, body: body);
return;
}
_timers[conversationId] = Timer(delay, () {
_timers.remove(conversationId);
showImmediate(title: title, body: body);
});
}
@override
Future<void> cancelReminder(int conversationId) async {
_timers.remove(conversationId)?.cancel();
}
}

View File

@ -1,49 +0,0 @@
/// (roadmap.md §2.7-F, PRD.md §3.2 P1 "내가 '이따 답장' 누르면 나에게만
/// ") 순수 로직. `DateTime.now()`에 의존하지 않고 `now`를 인자로 받는 함수들이라
/// .
library;
/// "이따 답장" ( ) .
const Duration kDefaultSnoozeDuration = Duration(hours: 2);
/// / .
enum SnoozeQuickPick { oneHour, thisEvening, tomorrowMorning }
extension SnoozeQuickPickLabel on SnoozeQuickPick {
String get label => switch (this) {
SnoozeQuickPick.oneHour => '1시간 후',
SnoozeQuickPick.thisEvening => '저녁에',
SnoozeQuickPick.tomorrowMorning => '내일',
};
}
/// [now] .
///
/// - 1 : 1
/// - : 19( 19)
/// - : 9
DateTime resolveSnoozeQuickPick(SnoozeQuickPick pick, DateTime now) {
switch (pick) {
case SnoozeQuickPick.oneHour:
return now.add(const Duration(hours: 1));
case SnoozeQuickPick.thisEvening:
final evening = DateTime(now.year, now.month, now.day, 19);
return now.isBefore(evening) ? evening : evening.add(const Duration(days: 1));
case SnoozeQuickPick.tomorrowMorning:
final tomorrow = now.add(const Duration(days: 1));
return DateTime(tomorrow.year, tomorrow.month, tomorrow.day, 9);
}
}
/// [now] /
/// . `snoozedUntil` false.
bool isSnoozePastDue(DateTime now, DateTime? snoozedUntil) {
if (snoozedUntil == null) return false;
return !now.isBefore(snoozedUntil);
}
/// (= ).
bool isSnoozeActive(DateTime now, DateTime? snoozedUntil) {
if (snoozedUntil == null) return false;
return now.isBefore(snoozedUntil);
}

View File

@ -5,105 +5,31 @@ import 'package:web_socket_channel/web_socket_channel.dart';
import '../config.dart'; import '../config.dart';
import '../models/models.dart'; import '../models/models.dart';
import 'message_sync.dart';
/// UI-facing WebSocket link state for a conversation (docs/web-upgrade.md N4-W2).
enum WsLinkState {
connecting,
live,
reconnecting,
}
/// Conversation-scoped WebSocket relay (`GET /ws/conversations/:id`). /// Conversation-scoped WebSocket relay (`GET /ws/conversations/:id`).
///
/// (roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11):
/// (, hiccup ) `onDone`/`onError`
/// [nextReconnectDelay] backoff .
/// [reconnects] (chat_screen.dart)
/// REST `since_id` .
class ConversationSocket { class ConversationSocket {
ConversationSocket(this.conversationId); ConversationSocket(this.conversationId);
final int conversationId; final int conversationId;
WebSocketChannel? _channel; WebSocketChannel? _channel;
StreamSubscription? _channelSub;
final _controller = StreamController<Map<String, dynamic>>.broadcast(); final _controller = StreamController<Map<String, dynamic>>.broadcast();
final _reconnectController = StreamController<void>.broadcast();
final _linkController = StreamController<WsLinkState>.broadcast();
Timer? _reconnectTimer;
int _reconnectAttempt = 0;
bool _disposed = false;
bool _everConnected = false;
WsLinkState _linkState = WsLinkState.connecting;
Stream<Map<String, dynamic>> get events => _controller.stream; Stream<Map<String, dynamic>> get events => _controller.stream;
/// (
/// chat_screen.dart는 initState에서 `_loadHistory()`
/// , "끊겼다가 다시 붙었을 때" ).
Stream<void> get reconnects => _reconnectController.stream;
/// connecting live reconnecting live
Stream<WsLinkState> get linkStates => _linkController.stream;
WsLinkState get linkState => _linkState;
void _setLinkState(WsLinkState next) {
if (_disposed || _linkState == next) return;
_linkState = next;
if (!_linkController.isClosed) _linkController.add(next);
}
void connect() { void connect() {
final wasReconnect = _everConnected;
_setLinkState(wasReconnect ? WsLinkState.reconnecting : WsLinkState.connecting);
// onError로 cancelOnError:false라
// leak을 .
_channelSub?.cancel();
final uri = Uri.parse('${AppConfig.wsBase()}/ws/conversations/$conversationId'); final uri = Uri.parse('${AppConfig.wsBase()}/ws/conversations/$conversationId');
final channel = WebSocketChannel.connect(uri); _channel = WebSocketChannel.connect(uri);
_channel = channel; _channel!.stream.listen(
_everConnected = true;
channel.ready.then((_) {
if (_disposed) return;
_setLinkState(WsLinkState.live);
}).catchError((Object _) {
// onError / onDone will schedule reconnect.
});
_channelSub = channel.stream.listen(
(raw) { (raw) {
// backoff를 .
_reconnectAttempt = 0;
_setLinkState(WsLinkState.live);
if (raw is! String) return; if (raw is! String) return;
final decoded = jsonDecode(raw); final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {
_controller.add(decoded); _controller.add(decoded);
} }
}, },
onError: (Object e, StackTrace st) { onError: _controller.addError,
_controller.addError(e, st); onDone: () {},
_scheduleReconnect();
},
onDone: _scheduleReconnect,
cancelOnError: false,
); );
if (wasReconnect) {
_reconnectAttempt = 0;
if (!_reconnectController.isClosed) _reconnectController.add(null);
}
}
void _scheduleReconnect() {
if (_disposed) return;
_setLinkState(WsLinkState.reconnecting);
_reconnectTimer?.cancel();
final delay = nextReconnectDelay(_reconnectAttempt);
_reconnectAttempt++;
_reconnectTimer = Timer(delay, () {
if (_disposed) return;
connect();
});
} }
ChatMessage? parseMessageEvent(Map<String, dynamic> event) { ChatMessage? parseMessageEvent(Map<String, dynamic> event) {
@ -121,24 +47,7 @@ class ConversationSocket {
} }
Future<void> dispose() async { Future<void> dispose() async {
_disposed = true;
_reconnectTimer?.cancel();
await _channelSub?.cancel();
await _channel?.sink.close(); await _channel?.sink.close();
await _controller.close(); await _controller.close();
await _reconnectController.close();
await _linkController.close();
}
}
/// Short Korean label for chat chrome (N4-W2).
String wsLinkStateLabel(WsLinkState state) {
switch (state) {
case WsLinkState.connecting:
return '연결 중…';
case WsLinkState.live:
return '실시간';
case WsLinkState.reconnecting:
return '다시 연결 중…';
} }
} }

View File

@ -7,27 +7,17 @@ import '../db/app_database.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../services/push_token_service.dart'; import '../services/push_token_service.dart';
import '../services/snooze_controller.dart';
import '../services/snooze_notification_service.dart';
class SessionState extends ChangeNotifier { class SessionState extends ChangeNotifier {
SessionState({ApiClient? api, AppDatabase? db, SnoozeNotificationScheduler? snoozeScheduler}) SessionState({ApiClient? api, AppDatabase? db})
: _api = api ?? ApiClient(), : _api = api ?? ApiClient(),
_db = db, _db = db;
_snoozeScheduler = snoozeScheduler ?? LocalSnoozeNotificationScheduler();
final ApiClient _api; final ApiClient _api;
AppDatabase? _db; AppDatabase? _db;
final SnoozeNotificationScheduler _snoozeScheduler;
/// (roadmap.md §2.7-F) DB( )
/// . DB가 ( ) null.
SnoozeController? get snoozeController =>
_db == null ? null : SnoozeController(db: _db!, scheduler: _snoozeScheduler);
User? user; User? user;
AutonomyLevel autonomyLevel = AutonomyLevel.L0; AutonomyLevel autonomyLevel = AutonomyLevel.L0;
RelationshipTier relationshipTier = RelationshipTier.formal;
String? error; String? error;
bool loading = false; bool loading = false;
bool toneOnboardingDone = false; bool toneOnboardingDone = false;
@ -75,8 +65,7 @@ class SessionState extends ChangeNotifier {
} catch (e) { } catch (e) {
// Linux CI / hosts without libsqlcipher.so fall back to memory so the // Linux CI / hosts without libsqlcipher.so fall back to memory so the
// app still boots; Android release path uses SQLCipher. // app still boots; Android release path uses SQLCipher.
// Web uses SharedPreferences-backed AppDatabase via conditional import // Web uses an in-memory stub via conditional import (no FFI).
// (docs/web-upgrade.md N4-W1); this catch is for native open failures.
debugPrint('Encrypted DB unavailable ($e); using in-memory fallback'); debugPrint('Encrypted DB unavailable ($e); using in-memory fallback');
localDbEncrypted = false; localDbEncrypted = false;
return AppDatabase.memory(); return AppDatabase.memory();
@ -110,7 +99,15 @@ class SessionState extends ChangeNotifier {
notifyListeners(); notifyListeners();
try { try {
final result = await _api.signup(inviteCode: inviteCode, displayName: displayName); final result = await _api.signup(inviteCode: inviteCode, displayName: displayName);
await _persistAuth(result.user, result.token, resetToneOnboarding: true); user = result.user;
toneOnboardingDone = false;
_db ??= await _openDb();
await _db!.setBoolKv(_kToneDone, false);
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_kUserId, result.user.id);
await prefs.setString(_kDisplayName, result.user.displayName);
await prefs.setString(_kInvite, result.user.inviteCode);
await prefs.setString(_kToken, result.token);
await _registerDeviceTokenBestEffort(); await _registerDeviceTokenBestEffort();
} on ApiException catch (e) { } on ApiException catch (e) {
error = '가입 실패 (${e.statusCode}): ${e.body}'; error = '가입 실패 (${e.statusCode}): ${e.body}';
@ -122,71 +119,6 @@ class SessionState extends ChangeNotifier {
} }
} }
/// Q8b already registered: invite code + display name new session.
Future<void> login(String inviteCode, String displayName) async {
loading = true;
error = null;
notifyListeners();
try {
final result = await _api.login(inviteCode: inviteCode, displayName: displayName);
await _persistAuth(result.user, result.token, resetToneOnboarding: false);
await _registerDeviceTokenBestEffort();
} on ApiException catch (e) {
error = '로그인 실패 (${e.statusCode}): ${e.body}';
} catch (e) {
error = e.toString();
} finally {
loading = false;
notifyListeners();
}
}
Future<void> _persistAuth(User u, String token, {required bool resetToneOnboarding}) async {
user = u;
_api.authToken = token;
_db ??= await _openDb();
if (resetToneOnboarding) {
toneOnboardingDone = false;
await _db!.setBoolKv(_kToneDone, false);
} else {
toneOnboardingDone = await _db!.getBoolKv(_kToneDone);
styleExamples = await _db!.loadToneSamples();
}
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_kUserId, u.id);
await prefs.setString(_kDisplayName, u.displayName);
await prefs.setString(_kInvite, u.inviteCode);
await prefs.setString(_kToken, token);
}
/// Q8c revoke current server session when possible, then clear local auth.
Future<void> logout() async {
final u = user;
final token = _api.authToken;
if (u != null && token != null && token.isNotEmpty) {
try {
final sessions = await _api.listSessions(u.id);
for (final s in sessions) {
if (s['is_current'] == true && s['id'] is num) {
await _api.revokeSession(u.id, (s['id'] as num).toInt());
break;
}
}
} catch (e) {
debugPrint('logout revoke skipped: $e');
}
}
user = null;
error = null;
_api.authToken = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kUserId);
await prefs.remove(_kDisplayName);
await prefs.remove(_kInvite);
await prefs.remove(_kToken);
notifyListeners();
}
Future<void> saveToneSamples(List<String> samples, {bool markDone = true}) async { Future<void> saveToneSamples(List<String> samples, {bool markDone = true}) async {
final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
styleExamples = cleaned; styleExamples = cleaned;
@ -207,9 +139,8 @@ class SessionState extends ChangeNotifier {
Future<void> setAutonomy(AutonomyLevel level) async { Future<void> setAutonomy(AutonomyLevel level) async {
if (user == null) return; if (user == null) return;
try { try {
final settings = await _api.patchTwinSettings(userId: user!.id, autonomyLevel: level); final settings = await _api.patchTwinSettings(user!.id, level);
autonomyLevel = settings.autonomyLevel; autonomyLevel = settings.autonomyLevel;
relationshipTier = settings.relationshipTier;
notifyListeners(); notifyListeners();
} on ApiException catch (e) { } on ApiException catch (e) {
error = '자율성 변경 실패 (${e.statusCode})'; error = '자율성 변경 실패 (${e.statusCode})';
@ -217,24 +148,6 @@ class SessionState extends ChangeNotifier {
} }
} }
/// (roadmap.md §2.7-B).
Future<void> setRelationshipTier(RelationshipTier tier) async {
if (user == null) return;
try {
final settings = await _api.patchTwinSettings(
userId: user!.id,
autonomyLevel: autonomyLevel,
relationshipTier: tier,
);
autonomyLevel = settings.autonomyLevel;
relationshipTier = settings.relationshipTier;
notifyListeners();
} on ApiException catch (e) {
error = '관계 설정 변경 실패 (${e.statusCode})';
notifyListeners();
}
}
/// Registers a real FCM token when Firebase is configured; otherwise a stable /// Registers a real FCM token when Firebase is configured; otherwise a stable
/// `install:` placeholder (server skips placeholders for delivery). /// `install:` placeholder (server skips placeholders for delivery).
Future<void> _registerDeviceTokenBestEffort() async { Future<void> _registerDeviceTokenBestEffort() async {

View File

@ -1,261 +1,194 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Messenger UI for iMessage-inspired light default + soft charcoal dark. /// Central design tokens for (Ykavu) soft-gradient + glassmorphism
/// Light: white stage, blue mine bubbles, gray peer bubbles. /// direction. `backgroundGradient` paints behind every screen (wired into
/// Dark: elevated charcoal (not pure black), softer blue accents. /// `MaterialApp.builder`); cards/inputs/app bars are semi-transparent
/// "glass" surfaces that let the gradient read through. `twinAccent` stays a
/// separate warm accent so a twin-written bubble never reads as something
/// the human actually typed (PRD §3.1 ).
class AppTheme { class AppTheme {
AppTheme._(); AppTheme._();
// Light iOS Messages / white messenger static const _seed = Color(0xFF7C6FF0);
static const canvasLight = Color(0xFFF2F2F7);
static const surfaceLight = Color(0xFFFFFFFF);
static const inkLight = Color(0xFF1C1C1E);
static const mutedLight = Color(0xFF8E8E93);
static const lineLight = Color(0xFFD1D1D6);
static const softFillLight = Color(0xFFE9E9EB);
// Dark soft charcoal (not #000) static Color twinAccent(Brightness brightness) =>
static const canvasDark = Color(0xFF141418); brightness == Brightness.dark ? Colors.amber.shade300 : Colors.amber.shade800;
static const surfaceDark = Color(0xFF1C1C22);
static const inkDark = Color(0xFFF2F2F7);
static const mutedDark = Color(0xFF98989F);
static const lineDark = Color(0xFF2C2C34);
static const softFillDark = Color(0xFF2C2C34);
/// iMessage-like blue for mine bubbles & primary actions. /// Soft diagonal gradient painted behind every screen. Light: pastel
static const accentLight = Color(0xFF007AFF); /// lavender sky pink. Dark: desaturated indigo navy plum.
static const accentDark = Color(0xFF0A84FF); static LinearGradient backgroundGradient(Brightness brightness) {
static const accentSoftLight = Color(0xFFD6E8FF); if (brightness == Brightness.dark) {
static const accentSoftDark = Color(0xFF163A66); return const LinearGradient(
begin: Alignment.topLeft,
static const twinAmberLight = Color(0xFFB08A4A); end: Alignment.bottomRight,
static const twinAmberDark = Color(0xFFD4B06A); colors: [Color(0xFF1B1730), Color(0xFF161C34), Color(0xFF2A1830)],
);
/// Compat for older call sites. }
static const teal = accentLight; return const LinearGradient(
static const tealBright = accentDark; begin: Alignment.topLeft,
static const canvasDarkCompat = canvasDark; end: Alignment.bottomRight,
static const brandGradient = LinearGradient( colors: [Color(0xFFEDE9FE), Color(0xFFE0F2FE), Color(0xFFFCE7F3)],
colors: [accentLight, accentLight],
);
static Color twinAccent(Brightness b) => b == Brightness.dark ? twinAmberDark : twinAmberLight;
static LinearGradient backgroundGradient(Brightness b) {
final c = b == Brightness.dark ? canvasDark : canvasLight;
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [c, c],
); );
} }
static Color glassFill(Brightness b) => b == Brightness.dark ? surfaceDark : surfaceLight; /// "Glass" fill for cards/panels semi-transparent so the gradient
static Color glassBorder(Brightness b) => b == Brightness.dark ? lineDark : lineLight; /// behind still reads through, with a faint light border for the classic
static Color glassShadow(Brightness b) => Colors.transparent; /// glassmorphism edge highlight.
static Color glassFill(Brightness brightness) => brightness == Brightness.dark
? Colors.white.withOpacity(0.06)
: Colors.white.withOpacity(0.55);
/// Mine = solid messenger blue (both modes). static Color glassBorder(Brightness brightness) => brightness == Brightness.dark
static Color mineBubble(Brightness b) => b == Brightness.dark ? accentDark : accentLight; ? Colors.white.withOpacity(0.10)
static Color mineBubbleFg(Brightness b) => Colors.white; : Colors.white.withOpacity(0.65);
static Color peerBubble(Brightness b) => b == Brightness.dark ? softFillDark : softFillLight;
static const rInput = 20.0;
static const rButton = 14.0;
static const rBubble = 20.0;
static const rPanel = 14.0;
static ThemeData light() => _build(Brightness.light); static ThemeData light() => _build(Brightness.light);
static ThemeData dark() => _build(Brightness.dark); static ThemeData dark() => _build(Brightness.dark);
static ThemeData _build(Brightness brightness) { static ThemeData _build(Brightness brightness) {
final isDark = brightness == Brightness.dark; final scheme = ColorScheme.fromSeed(seedColor: _seed, brightness: brightness);
final primary = isDark ? accentDark : accentLight; final glass = glassFill(brightness);
final onPrimary = Colors.white; final glassEdge = glassBorder(brightness);
final ink = isDark ? inkDark : inkLight; final glassShape = RoundedRectangleBorder(
final muted = isDark ? mutedDark : mutedLight; borderRadius: BorderRadius.circular(18),
final line = isDark ? lineDark : lineLight; side: BorderSide(color: glassEdge, width: 1),
final surface = isDark ? surfaceDark : surfaceLight;
final soft = isDark ? softFillDark : softFillLight;
final scheme = ColorScheme(
brightness: brightness,
primary: primary,
onPrimary: onPrimary,
primaryContainer: isDark ? accentSoftDark : accentSoftLight,
onPrimaryContainer: primary,
secondary: isDark ? twinAmberDark : twinAmberLight,
onSecondary: isDark ? canvasDark : Colors.white,
secondaryContainer: soft,
onSecondaryContainer: muted,
tertiary: primary,
onTertiary: onPrimary,
error: isDark ? const Color(0xFFFF6961) : const Color(0xFFFF3B30),
onError: Colors.white,
surface: surface,
onSurface: ink,
onSurfaceVariant: muted,
outline: line,
outlineVariant: line,
surfaceContainerHighest: soft,
surfaceContainerHigh: soft,
surfaceContainer: surface,
); );
final textTheme = _textTheme(GoogleFonts.plusJakartaSansTextTheme(), ink);
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: brightness, brightness: brightness,
colorScheme: scheme, colorScheme: scheme,
// Transparent so the gradient painted by MaterialApp.builder shows
// through behind every Scaffold.
scaffoldBackgroundColor: Colors.transparent, scaffoldBackgroundColor: Colors.transparent,
visualDensity: VisualDensity.standard, visualDensity: VisualDensity.comfortable,
textTheme: textTheme, textTheme: _textTheme(),
primaryTextTheme: textTheme,
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: surface.withValues(alpha: isDark ? 0.94 : 0.88), backgroundColor: Colors.transparent,
foregroundColor: ink, foregroundColor: scheme.onSurface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
elevation: 0, elevation: 0,
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
centerTitle: true, centerTitle: false,
titleTextStyle: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600, letterSpacing: -0.2), titleTextStyle: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: scheme.onSurface,
letterSpacing: -0.2,
),
), ),
cardTheme: CardThemeData( cardTheme: CardThemeData(
elevation: 0, elevation: 0,
color: surface, color: glass,
shape: RoundedRectangleBorder( shape: glassShape,
borderRadius: BorderRadius.circular(rPanel),
side: BorderSide(color: line.withValues(alpha: isDark ? 0.9 : 0.65)),
),
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
), ),
listTileTheme: ListTileThemeData( listTileTheme: ListTileThemeData(
iconColor: muted, iconColor: scheme.onSurfaceVariant,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rPanel)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
minVerticalPadding: 10,
), ),
dividerTheme: DividerThemeData(color: line, space: 1, thickness: 0.5), dividerTheme: DividerThemeData(color: scheme.outlineVariant, space: 32, thickness: 1),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: isDark ? soft : surface, fillColor: glass,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(rInput), borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: line.withValues(alpha: 0.7)), borderSide: BorderSide(color: glassEdge, width: 1),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(rInput), borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: line.withValues(alpha: 0.7)), borderSide: BorderSide(color: glassEdge, width: 1),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(rInput), borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: primary, width: 1.4), borderSide: BorderSide(color: scheme.primary, width: 1.6),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(rInput), borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.error), borderSide: BorderSide(color: scheme.error, width: 1.2),
), ),
focusedErrorBorder: OutlineInputBorder( focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(rInput), borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.error, width: 1.4), borderSide: BorderSide(color: scheme.error, width: 1.6),
), ),
hintStyle: TextStyle(color: muted.withValues(alpha: 0.85)), hintStyle: TextStyle(color: scheme.onSurfaceVariant.withOpacity(0.7)),
labelStyle: TextStyle(color: muted, fontWeight: FontWeight.w500),
), ),
filledButtonTheme: FilledButtonThemeData( filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: primary, padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
foregroundColor: onPrimary, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), textStyle: const TextStyle(fontWeight: FontWeight.w700),
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rButton)),
textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
elevation: 0,
), ),
), ),
outlinedButtonTheme: OutlinedButtonThemeData( outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: primary, padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rButton)),
side: BorderSide(color: primary.withValues(alpha: 0.45)),
), ),
), ),
textButtonTheme: TextButtonThemeData( textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
), ),
), ),
iconButtonTheme: IconButtonThemeData( iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom( style: IconButton.styleFrom(
foregroundColor: primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
), ),
), ),
segmentedButtonTheme: SegmentedButtonThemeData(
style: SegmentedButton.styleFrom(
backgroundColor: glass,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
),
chipTheme: ChipThemeData( chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(
side: BorderSide(color: line), borderRadius: BorderRadius.circular(10),
backgroundColor: soft, side: BorderSide(color: glassEdge, width: 1),
labelStyle: TextStyle(color: ink, fontWeight: FontWeight.w500, fontSize: 12), ),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), side: BorderSide.none,
backgroundColor: glass,
labelStyle: TextStyle(color: scheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
), ),
floatingActionButtonTheme: FloatingActionButtonThemeData( floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: primary, backgroundColor: scheme.primary,
foregroundColor: onPrimary, foregroundColor: scheme.onPrimary,
elevation: 0, elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
), ),
snackBarTheme: SnackBarThemeData( snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
backgroundColor: isDark ? softFillDark : inkLight, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
contentTextStyle: TextStyle(color: isDark ? inkDark : Colors.white), ),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rPanel)), bannerTheme: MaterialBannerThemeData(
elevation: 0, backgroundColor: glass,
padding: const EdgeInsets.all(16),
), ),
dialogTheme: DialogThemeData( dialogTheme: DialogThemeData(
backgroundColor: surface, backgroundColor: brightness == Brightness.dark
elevation: 0, ? const Color(0xFF211D3D)
shape: RoundedRectangleBorder( : Colors.white.withOpacity(0.92),
borderRadius: BorderRadius.circular(18), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
side: BorderSide(color: line.withValues(alpha: 0.8)),
),
),
progressIndicatorTheme: ProgressIndicatorThemeData(color: primary),
bannerTheme: MaterialBannerThemeData(backgroundColor: surface, padding: const EdgeInsets.all(16)),
segmentedButtonTheme: SegmentedButtonThemeData(
style: SegmentedButton.styleFrom(
backgroundColor: soft,
foregroundColor: muted,
selectedForegroundColor: primary,
selectedBackgroundColor: isDark ? accentSoftDark : accentSoftLight,
side: BorderSide.none,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
), ),
progressIndicatorTheme: ProgressIndicatorThemeData(color: scheme.primary),
); );
} }
static TextTheme _textTheme(TextTheme base, Color ink) { static TextTheme _textTheme() {
TextStyle s(TextStyle? t, {FontWeight? w, double? size, double? ls, double? h}) => return const TextTheme(
(t ?? const TextStyle()).copyWith(color: ink, fontWeight: w, fontSize: size, letterSpacing: ls, height: h); displaySmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.5),
headlineSmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.3),
return base.copyWith( titleLarge: TextStyle(fontWeight: FontWeight.w700),
displayLarge: s(base.displayLarge, w: FontWeight.w700, size: 34, ls: -0.8), titleMedium: TextStyle(fontWeight: FontWeight.w700),
displaySmall: s(base.displaySmall, w: FontWeight.w700, size: 28, ls: -0.5), titleSmall: TextStyle(fontWeight: FontWeight.w700),
headlineSmall: s(base.headlineSmall, w: FontWeight.w600, size: 22, ls: -0.3), bodyLarge: TextStyle(height: 1.4),
titleLarge: s(base.titleLarge, w: FontWeight.w600, size: 17, ls: -0.2), bodyMedium: TextStyle(height: 1.4),
titleMedium: s(base.titleMedium, w: FontWeight.w600, size: 16), labelLarge: TextStyle(fontWeight: FontWeight.w700),
titleSmall: s(base.titleSmall, w: FontWeight.w600, size: 15),
bodyLarge: s(base.bodyLarge, w: FontWeight.w400, size: 17, h: 1.35),
bodyMedium: s(base.bodyMedium, w: FontWeight.w400, size: 16, h: 1.35),
bodySmall: s(base.bodySmall, w: FontWeight.w400, size: 13, h: 1.35),
labelLarge: s(base.labelLarge, w: FontWeight.w500, size: 15),
labelSmall: s(base.labelSmall, w: FontWeight.w500, size: 11),
); );
} }
} }

View File

@ -1,32 +0,0 @@
import 'package:flutter/material.dart';
/// Compact messenger brand mark blue tile, no glow.
class BrandMark extends StatelessWidget {
const BrandMark({super.key, this.size = 64});
final double size;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: scheme.primary,
borderRadius: BorderRadius.circular(size * 0.28),
),
alignment: Alignment.center,
child: Text(
'Y',
style: TextStyle(
color: scheme.onPrimary,
fontWeight: FontWeight.w700,
fontSize: size * 0.42,
height: 1,
letterSpacing: -0.5,
),
),
);
}
}

View File

@ -2,7 +2,10 @@ import 'package:flutter/material.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// Quiet canvas behind every screen near-flat wash, no aurora orbs. /// Paints the app-wide soft gradient behind [child]. Wired into
/// `MaterialApp.builder` so every screen gets it automatically without each
/// Scaffold needing its own background screens just need a transparent
/// `scaffoldBackgroundColor` (set globally in [AppTheme]).
class GradientBackdrop extends StatelessWidget { class GradientBackdrop extends StatelessWidget {
const GradientBackdrop({super.key, required this.child}); const GradientBackdrop({super.key, required this.child});
@ -10,9 +13,8 @@ class GradientBackdrop extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final brightness = Theme.of(context).brightness;
return DecoratedBox( return DecoratedBox(
decoration: BoxDecoration(gradient: AppTheme.backgroundGradient(brightness)), decoration: BoxDecoration(gradient: AppTheme.backgroundGradient(Theme.of(context).brightness)),
child: child, child: child,
); );
} }

View File

@ -1,22 +0,0 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Brand wordmark ink weight, no rainbow gradient.
class GradientText extends StatelessWidget {
const GradientText(this.text, {super.key, this.style, this.textAlign});
final String text;
final TextStyle? style;
final TextAlign? textAlign;
@override
Widget build(BuildContext context) {
final ink = Theme.of(context).brightness == Brightness.dark ? AppTheme.inkDark : AppTheme.inkLight;
return Text(
text,
textAlign: textAlign,
style: (style ?? const TextStyle()).copyWith(color: ink, fontWeight: FontWeight.w700),
);
}
}

View File

@ -3,25 +3,19 @@ import 'package:flutter/material.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// iMessage-like bubble. Twin messages keep a warm label + soft tint (PRD §3.1). /// Twin messages get a dashed border + badge (PRD §3.1 ) so an
/// auto-sent bubble never reads as something the human actually typed.
class MessageBubble extends StatelessWidget { class MessageBubble extends StatelessWidget {
const MessageBubble({ const MessageBubble({
super.key, super.key,
required this.message, required this.message,
required this.isMine, required this.isMine,
this.onRetract, this.onRetract,
this.alreadyRated = false,
this.onFeedback,
}); });
final ChatMessage message; final ChatMessage message;
final bool isMine; final bool isMine;
final VoidCallback? onRetract; final VoidCallback? onRetract;
// "이 답장 나답아요?" (vision.md , deploy-checklist.md N4-12).
// alreadyRated면( )
// .
final bool alreadyRated;
final void Function(bool natural)? onFeedback;
static String _time(DateTime dt) { static String _time(DateTime dt) {
final local = dt.toLocal(); final local = dt.toLocal();
@ -37,55 +31,52 @@ class MessageBubble extends StatelessWidget {
final twin = message.isTwin; final twin = message.isTwin;
final retracted = message.retracted; final retracted = message.retracted;
final accent = AppTheme.twinAccent(theme.brightness); final accent = AppTheme.twinAccent(theme.brightness);
final brightness = theme.brightness;
final Color bg; final Color bg;
final Color fg; final Color fg;
if (retracted) { if (retracted) {
bg = scheme.surfaceContainerHigh; bg = scheme.surfaceContainerHigh;
fg = scheme.onSurfaceVariant; fg = scheme.onSurfaceVariant;
} else if (twin) {
bg = brightness == Brightness.dark
? const Color(0xFF2A2418)
: const Color(0xFFFFF6E8);
fg = scheme.onSurface;
} else if (isMine) { } else if (isMine) {
bg = AppTheme.mineBubble(brightness); bg = scheme.primaryContainer;
fg = AppTheme.mineBubbleFg(brightness); fg = scheme.onPrimaryContainer;
} else { } else {
bg = AppTheme.peerBubble(brightness); bg = scheme.surfaceContainerHighest;
fg = scheme.onSurface; fg = scheme.onSurface;
} }
final radius = BorderRadius.only( final radius = BorderRadius.only(
topLeft: const Radius.circular(AppTheme.rBubble), topLeft: const Radius.circular(18),
topRight: const Radius.circular(AppTheme.rBubble), topRight: const Radius.circular(18),
bottomLeft: Radius.circular(isMine ? AppTheme.rBubble : 6), bottomLeft: Radius.circular(isMine ? 18 : 4),
bottomRight: Radius.circular(isMine ? 6 : AppTheme.rBubble), bottomRight: Radius.circular(isMine ? 4 : 18),
); );
final bubble = Container( final bubble = Container(
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.74), constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.76),
padding: const EdgeInsets.fromLTRB(14, 9, 14, 7), padding: const EdgeInsets.fromLTRB(14, 10, 14, 8),
decoration: BoxDecoration( decoration: BoxDecoration(color: bg, borderRadius: radius),
color: bg,
borderRadius: radius,
border: twin ? Border.all(color: accent.withValues(alpha: 0.35)) : null,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (twin) if (twin)
Padding( Padding(
padding: const EdgeInsets.only(bottom: 3), padding: const EdgeInsets.only(bottom: 6),
child: Text( child: Row(
'와카뷰', mainAxisSize: MainAxisSize.min,
style: theme.textTheme.labelSmall?.copyWith( children: [
color: accent, Icon(Icons.auto_awesome, size: 13, color: accent),
fontWeight: FontWeight.w700, const SizedBox(width: 4),
letterSpacing: 0.15, Text(
), '와카뷰',
style: theme.textTheme.labelSmall?.copyWith(
color: accent,
fontWeight: FontWeight.w800,
letterSpacing: 0.2,
),
),
],
), ),
), ),
if (retracted) if (retracted)
@ -103,15 +94,12 @@ class MessageBubble extends StatelessWidget {
else else
Text( Text(
message.text, message.text,
style: theme.textTheme.bodyMedium?.copyWith(color: fg, height: 1.3), style: theme.textTheme.bodyMedium?.copyWith(color: fg, height: 1.35),
), ),
const SizedBox(height: 3), const SizedBox(height: 4),
Text( Text(
_time(message.createdAt), _time(message.createdAt),
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(color: fg.withOpacity(0.55), fontSize: 10),
color: fg.withValues(alpha: isMine && !twin && !retracted ? 0.7 : 0.5),
fontSize: 10,
),
), ),
], ],
), ),
@ -121,7 +109,12 @@ class MessageBubble extends StatelessWidget {
crossAxisAlignment: isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start, crossAxisAlignment: isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
bubble, twin
? CustomPaint(
painter: _DashedRRectPainter(color: accent, radius: radius),
child: bubble,
)
: bubble,
if (twin && isMine && !retracted && onRetract != null) if (twin && isMine && !retracted && onRetract != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 2, right: 4, left: 4), padding: const EdgeInsets.only(top: 2, right: 4, left: 4),
@ -138,54 +131,11 @@ class MessageBubble extends StatelessWidget {
label: const Text('되돌리기'), label: const Text('되돌리기'),
), ),
), ),
if (twin && !retracted && onFeedback != null)
Padding(
padding: const EdgeInsets.only(top: 2, right: 4, left: 4),
child: alreadyRated
? Text(
'평가 고마워요',
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 11,
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'이 답장 나답아요?',
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
fontSize: 11,
),
),
const SizedBox(width: 2),
IconButton(
tooltip: '나다워요',
onPressed: () => onFeedback!(true),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
iconSize: 16,
color: scheme.onSurfaceVariant,
icon: const Icon(Icons.thumb_up_outlined),
),
IconButton(
tooltip: '나답지 않아요',
onPressed: () => onFeedback!(false),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
iconSize: 16,
color: scheme.onSurfaceVariant,
icon: const Icon(Icons.thumb_down_outlined),
),
],
),
),
], ],
); );
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 10), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
child: Align( child: Align(
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft, alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
child: content, child: content,
@ -193,3 +143,40 @@ class MessageBubble extends StatelessWidget {
); );
} }
} }
class _DashedRRectPainter extends CustomPainter {
_DashedRRectPainter({required this.color, required this.radius});
final Color color;
final BorderRadius radius;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1.4;
final rrect = radius.toRRect(Rect.fromLTWH(0.7, 0.7, size.width - 1.4, size.height - 1.4));
final path = Path()..addRRect(rrect);
final dashed = _dashPath(path, dashLength: 5, gapLength: 4);
canvas.drawPath(dashed, paint);
}
Path _dashPath(Path source, {required double dashLength, required double gapLength}) {
final metrics = source.computeMetrics();
final out = Path();
for (final metric in metrics) {
var distance = 0.0;
while (distance < metric.length) {
final next = distance + dashLength;
out.addPath(metric.extractPath(distance, next.clamp(0, metric.length)), Offset.zero);
distance = next + gapLength;
}
}
return out;
}
@override
bool shouldRepaint(covariant _DashedRRectPainter oldDelegate) =>
oldDelegate.color != color || oldDelegate.radius != radius;
}

View File

@ -1,8 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../theme/app_theme.dart';
/// Shows the signed-in numeric user id with one-tap copy. /// Shows the signed-in numeric user id with one-tap copy.
class MyUserIdChip extends StatelessWidget { class MyUserIdChip extends StatelessWidget {
const MyUserIdChip({super.key, required this.userId, this.compact = false}); const MyUserIdChip({super.key, required this.userId, this.compact = false});
@ -31,23 +29,18 @@ class MyUserIdChip extends StatelessWidget {
icon: const Icon(Icons.badge_outlined), icon: const Icon(Icons.badge_outlined),
); );
} }
final brightness = theme.brightness;
return Material( return Material(
color: AppTheme.glassFill(brightness), color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(AppTheme.rPanel), borderRadius: BorderRadius.circular(12),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(AppTheme.rPanel), borderRadius: BorderRadius.circular(12),
onTap: () => _copy(context), onTap: () => _copy(context),
child: Container( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.rPanel),
border: Border.all(color: AppTheme.glassBorder(brightness)),
),
child: Row( child: Row(
children: [ children: [
Icon(Icons.badge_outlined, size: 18, color: theme.colorScheme.onSurfaceVariant), Icon(Icons.badge_outlined, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 10), const SizedBox(width: 8),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -61,18 +54,17 @@ class MyUserIdChip extends StatelessWidget {
Text( Text(
'$userId', '$userId',
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
letterSpacing: 0.3, letterSpacing: 0.5,
), ),
), ),
], ],
), ),
), ),
Text( Text(
'복사', '탭하여 복사',
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),

View File

@ -1,59 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'web_connectivity.dart';
/// Thin bar when the browser reports offline (docs/web-upgrade.md N4-W5).
/// API/WS still require a network this only sets expectation.
class OfflineBanner extends StatefulWidget {
const OfflineBanner({super.key});
@override
State<OfflineBanner> createState() => _OfflineBannerState();
}
class _OfflineBannerState extends State<OfflineBanner> {
bool _offline = false;
StreamSubscription? _onlineSub;
StreamSubscription? _offlineSub;
@override
void initState() {
super.initState();
if (!kIsWeb) return;
_offline = webNavigatorOffline;
_onlineSub = listenWebOnline(() {
if (mounted) setState(() => _offline = false);
});
_offlineSub = listenWebOffline(() {
if (mounted) setState(() => _offline = true);
});
}
@override
void dispose() {
_onlineSub?.cancel();
_offlineSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!kIsWeb || !_offline) return const SizedBox.shrink();
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'오프라인입니다. 메시지·로그인에는 인터넷 연결이 필요합니다.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
);
}
}

View File

@ -1,52 +0,0 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Primary CTA messenger blue fill (API name kept for call sites).
class PrimaryGradientButton extends StatelessWidget {
const PrimaryGradientButton({
super.key,
required this.label,
required this.onPressed,
this.loading = false,
});
final String label;
final VoidCallback? onPressed;
final bool loading;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final bg = scheme.primary;
final fg = scheme.onPrimary;
final disabled = onPressed == null || loading;
return SizedBox(
height: 50,
width: double.infinity,
child: FilledButton(
onPressed: disabled && !loading ? null : onPressed,
style: FilledButton.styleFrom(
backgroundColor: bg,
foregroundColor: fg,
disabledBackgroundColor: bg.withValues(alpha: 0.35),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.rButton),
),
elevation: 0,
shadowColor: Colors.transparent,
),
child: loading
? SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: fg),
)
: Text(
label,
style: TextStyle(color: fg, fontWeight: FontWeight.w600, fontSize: 16),
),
),
);
}
}

View File

@ -1,2 +0,0 @@
export 'web_connectivity_stub.dart'
if (dart.library.html) 'web_connectivity_web.dart';

View File

@ -1,7 +0,0 @@
import 'dart:async';
bool get webNavigatorOffline => false;
StreamSubscription<void>? listenWebOnline(void Function() onOnline) => null;
StreamSubscription<void>? listenWebOffline(void Function() onOffline) => null;

View File

@ -1,13 +0,0 @@
import 'dart:async';
// ignore: avoid_web_libraries_in_flutter, deprecated_member_use
import 'dart:html' as html;
bool get webNavigatorOffline => !(html.window.navigator.onLine ?? true);
StreamSubscription<void>? listenWebOnline(void Function() onOnline) {
return html.window.onOnline.listen((_) => onOnline());
}
StreamSubscription<void>? listenWebOffline(void Function() onOffline) {
return html.window.onOffline.listen((_) => onOffline());
}

View File

@ -1,85 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../state/session_state.dart';
/// One-time dismissible tips for the web demo/preview surface (N4-W2).
///
/// Hidden on non-web builds. Dismissal is stored in the local DB KV so it
/// survives hard refresh after W1.
class WebPreviewBanner extends StatefulWidget {
const WebPreviewBanner({super.key});
static const dismissKvKey = 'web_preview_tips_dismissed';
@override
State<WebPreviewBanner> createState() => _WebPreviewBannerState();
}
class _WebPreviewBannerState extends State<WebPreviewBanner> {
bool _visible = false;
bool _loaded = false;
@override
void initState() {
super.initState();
if (kIsWeb) {
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
} else {
_loaded = true;
}
}
Future<void> _load() async {
if (!mounted) return;
final session = context.read<SessionState>();
final db = session.db;
final dismissed = db == null
? false
: await db.getBoolKv(WebPreviewBanner.dismissKvKey);
if (!mounted) return;
setState(() {
_visible = !dismissed;
_loaded = true;
});
}
Future<void> _dismiss() async {
setState(() => _visible = false);
final db = context.read<SessionState>().db;
await db?.setBoolKv(WebPreviewBanner.dismissKvKey, true);
}
@override
Widget build(BuildContext context) {
if (!kIsWeb || !_loaded || !_visible) return const SizedBox.shrink();
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.55),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.web_asset, size: 20, color: theme.colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
'웹 프리뷰입니다. 말투·설정은 새로고침 후에도 이 브라우저에 남습니다. '
'브라우저 알림·홈 화면 추가는 곧 이어집니다.',
style: theme.textTheme.bodySmall?.copyWith(height: 1.4),
),
),
IconButton(
tooltip: '닫기',
onPressed: _dismiss,
icon: const Icon(Icons.close, size: 18),
visualDensity: VisualDensity.compact,
),
],
),
),
);
}
}

View File

@ -101,10 +101,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.0"
charcode: charcode:
dependency: transitive dependency: transitive
description: description:
@ -177,14 +177,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
dbus:
dependency: transitive
description:
name: dbus
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
url: "https://pub.dev"
source: hosted
version: "0.7.14"
drift: drift:
dependency: "direct main" dependency: "direct main"
description: description:
@ -294,38 +286,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad"
url: "https://pub.dev"
source: hosted
version: "20.1.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
url: "https://pub.dev"
source: hosted
version: "7.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
url: "https://pub.dev"
source: hosted
version: "10.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a
url: "https://pub.dev"
source: hosted
version: "2.0.1"
flutter_secure_storage: flutter_secure_storage:
dependency: "direct main" dependency: "direct main"
description: description:
@ -452,26 +412,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "11.0.2" version: "10.0.9"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.10" version: "3.0.9"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_testing name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.2" version: "3.0.1"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@ -492,26 +452,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.19" version: "0.12.17"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.16.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@ -592,14 +552,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -817,18 +769,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.4"
timezone:
dependency: "direct main"
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.10.1"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -841,10 +785,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.1.4"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
@ -901,14 +845,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:
@ -918,5 +854,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.10.0-0 <4.0.0" dart: ">=3.8.1 <4.0.0"
flutter: ">=3.32.0" flutter: ">=3.29.0"

View File

@ -47,8 +47,6 @@ dependencies:
google_fonts: ^6.3.2 google_fonts: ^6.3.2
firebase_core: ^4.12.1 firebase_core: ^4.12.1
firebase_messaging: ^16.4.3 firebase_messaging: ^16.4.3
flutter_local_notifications: ^20.1.0
timezone: ^0.10.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -1,46 +0,0 @@
#!/usr/bin/env bash
# Runs inside the Flutter build stage. Reads FIREBASE_* from the environment
# (set via Dockerfile ENV from build-args) so secret values are not expanded
# into the Dockerfile RUN instruction / build logs.
set -euo pipefail
CORE_API_BASE="${CORE_API_BASE:-https://msn.iykyka.com}"
FIREBASE_API_KEY="${FIREBASE_API_KEY:-}"
FIREBASE_AUTH_DOMAIN="${FIREBASE_AUTH_DOMAIN:-}"
FIREBASE_PROJECT_ID="${FIREBASE_PROJECT_ID:-}"
FIREBASE_STORAGE_BUCKET="${FIREBASE_STORAGE_BUCKET:-}"
FIREBASE_MESSAGING_SENDER_ID="${FIREBASE_MESSAGING_SENDER_ID:-}"
FIREBASE_APP_ID="${FIREBASE_APP_ID:-}"
FIREBASE_VAPID_KEY="${FIREBASE_VAPID_KEY:-}"
if [[ -n "${FIREBASE_API_KEY}" && -n "${FIREBASE_APP_ID}" && \
-n "${FIREBASE_MESSAGING_SENDER_ID}" && -n "${FIREBASE_PROJECT_ID}" ]]; then
AUTH_DOMAIN="${FIREBASE_AUTH_DOMAIN:-${FIREBASE_PROJECT_ID}.firebaseapp.com}"
STORAGE_BUCKET="${FIREBASE_STORAGE_BUCKET:-${FIREBASE_PROJECT_ID}.appspot.com}"
# Detect common misconfiguration: Android app id used for web build.
if [[ "${FIREBASE_APP_ID}" == *":android:"* ]]; then
echo "WARN: FIREBASE_APP_ID looks like an Android app id (:android:)."
echo "WARN: Web Push needs a Firebase Web app id (:web:). Token registration may fail."
fi
sed \
-e "s|__FIREBASE_API_KEY__|${FIREBASE_API_KEY}|g" \
-e "s|__FIREBASE_AUTH_DOMAIN__|${AUTH_DOMAIN}|g" \
-e "s|__FIREBASE_PROJECT_ID__|${FIREBASE_PROJECT_ID}|g" \
-e "s|__FIREBASE_STORAGE_BUCKET__|${STORAGE_BUCKET}|g" \
-e "s|__FIREBASE_MESSAGING_SENDER_ID__|${FIREBASE_MESSAGING_SENDER_ID}|g" \
-e "s|__FIREBASE_APP_ID__|${FIREBASE_APP_ID}|g" \
web/firebase-messaging-sw.js.template > web/firebase-messaging-sw.js
echo "firebase-messaging-sw.js: populated for project ${FIREBASE_PROJECT_ID}"
else
echo "firebase-messaging-sw.js: stub (FIREBASE_* build-args empty)"
fi
flutter build web --release \
--dart-define="CORE_API_BASE=${CORE_API_BASE}" \
--dart-define="FIREBASE_API_KEY=${FIREBASE_API_KEY}" \
--dart-define="FIREBASE_AUTH_DOMAIN=${FIREBASE_AUTH_DOMAIN}" \
--dart-define="FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID}" \
--dart-define="FIREBASE_STORAGE_BUCKET=${FIREBASE_STORAGE_BUCKET}" \
--dart-define="FIREBASE_MESSAGING_SENDER_ID=${FIREBASE_MESSAGING_SENDER_ID}" \
--dart-define="FIREBASE_APP_ID=${FIREBASE_APP_ID}" \
--dart-define="FIREBASE_VAPID_KEY=${FIREBASE_VAPID_KEY}"

View File

@ -1,63 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ykavu_mobile/config/firebase_web_config.dart';
void main() {
test('isReady false when any required field is empty', () {
expect(
const FirebaseWebConfig(
apiKey: 'k',
appId: 'a',
messagingSenderId: '1',
projectId: 'p',
vapidKey: '',
).isReady,
isFalse,
);
expect(
const FirebaseWebConfig(
apiKey: '',
appId: 'a',
messagingSenderId: '1',
projectId: 'p',
vapidKey: 'v',
).isReady,
isFalse,
);
});
test('isReady true and options fill default domains', () {
const cfg = FirebaseWebConfig(
apiKey: 'api',
appId: '1:123:web:abc',
messagingSenderId: '123',
projectId: 'iykyka',
vapidKey: 'vapid-public',
);
expect(cfg.isReady, isTrue);
final opts = cfg.options!;
expect(opts.apiKey, 'api');
expect(opts.projectId, 'iykyka');
expect(opts.authDomain, 'iykyka.firebaseapp.com');
expect(opts.storageBucket, 'iykyka.appspot.com');
});
test('explicit authDomain and storageBucket win over defaults', () {
const cfg = FirebaseWebConfig(
apiKey: 'api',
appId: '1:123:web:abc',
messagingSenderId: '123',
projectId: 'iykyka',
vapidKey: 'vapid-public',
authDomain: 'custom.example.com',
storageBucket: 'iykyka.firebasestorage.app',
);
final opts = cfg.options!;
expect(opts.authDomain, 'custom.example.com');
expect(opts.storageBucket, 'iykyka.firebasestorage.app');
});
test('fromEnvironment without defines is not ready', () {
expect(FirebaseWebConfig.fromEnvironment().isReady, isFalse);
expect(FirebaseWebConfig.fromEnvironment().options, isNull);
});
}

View File

@ -15,34 +15,4 @@ void main() {
await db.replaceToneSamples(['하나만']); await db.replaceToneSamples(['하나만']);
expect(await db.loadToneSamples(), ['하나만']); expect(await db.loadToneSamples(), ['하나만']);
}); });
// roadmap.md §2.7-F :
// tone samples와 (in-memory DB round-trip)
// .
test('conversation snooze round-trips in memory database', () async {
final db = AppDatabase.memory();
addTearDown(db.close);
expect(await db.getSnoozedUntil(42), isNull);
final until = DateTime.utc(2026, 8, 3, 20, 0);
await db.setSnoozedUntil(42, until);
expect(await db.getSnoozedUntil(42), until);
// (id 43) .
expect(await db.getSnoozedUntil(43), isNull);
// (insertOnConflictUpdate) .
final rescheduled = DateTime.utc(2026, 8, 4, 9, 0);
await db.setSnoozedUntil(42, rescheduled);
expect(await db.getSnoozedUntil(42), rescheduled);
await db.setSnoozedUntil(43, until);
final all = await db.loadAllSnoozes();
expect(all, {42: rescheduled, 43: until});
await db.clearSnooze(42);
expect(await db.getSnoozedUntil(42), isNull);
expect(await db.loadAllSnoozes(), {43: until});
});
} }

View File

@ -1,109 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ykavu_mobile/models/models.dart';
import 'package:ykavu_mobile/services/message_sync.dart';
ChatMessage _msg(int id, {bool retracted = false, bool? naturalnessRating}) => ChatMessage(
id: id,
conversationId: 1,
senderId: 1,
senderMode: SenderMode.human,
text: 'msg-$id',
retracted: retracted,
createdAt: DateTime(2026, 8, 3),
naturalnessRating: naturalnessRating,
);
void main() {
group('nextReconnectDelay', () {
test('first attempt (0) is the initial 1s delay', () {
expect(nextReconnectDelay(0), const Duration(seconds: 1));
});
test('doubles each attempt: 1s, 2s, 4s, 8s, 16s', () {
expect(nextReconnectDelay(1), const Duration(seconds: 2));
expect(nextReconnectDelay(2), const Duration(seconds: 4));
expect(nextReconnectDelay(3), const Duration(seconds: 8));
expect(nextReconnectDelay(4), const Duration(seconds: 16));
});
test('caps at 30s once doubling would exceed it', () {
expect(nextReconnectDelay(5), const Duration(seconds: 30));
expect(nextReconnectDelay(6), const Duration(seconds: 30));
});
test('stays capped even for a very large attempt count (no overflow)', () {
expect(nextReconnectDelay(1000000), const Duration(seconds: 30));
});
test('negative attempt is treated like the first attempt', () {
expect(nextReconnectDelay(-1), const Duration(seconds: 1));
});
});
group('highestMessageId', () {
test('empty list -> null', () {
expect(highestMessageId(const []), isNull);
});
test('returns the max id regardless of list order', () {
expect(highestMessageId([_msg(3), _msg(1), _msg(5), _msg(2)]), 5);
});
});
group('mergeNewMessages', () {
test('appends fresh messages not already present, sorted by id', () {
final existing = [_msg(1), _msg(2)];
final fresh = [_msg(4), _msg(3)];
final merged = mergeNewMessages(existing, fresh);
expect(merged.map((m) => m.id).toList(), [1, 2, 3, 4]);
});
test('drops fresh messages whose id is already loaded (WS/REST race)', () {
final existing = [_msg(1), _msg(2), _msg(3)];
final fresh = [_msg(2), _msg(3), _msg(4)];
final merged = mergeNewMessages(existing, fresh);
expect(merged.map((m) => m.id).toList(), [1, 2, 3, 4]);
});
test('fresh entirely a subset of existing -> unchanged, same order', () {
final existing = [_msg(1), _msg(2)];
final merged = mergeNewMessages(existing, [_msg(1), _msg(2)]);
expect(merged.map((m) => m.id).toList(), [1, 2]);
});
test('existing empty -> result is just fresh, sorted', () {
final merged = mergeNewMessages(const [], [_msg(2), _msg(1)]);
expect(merged.map((m) => m.id).toList(), [1, 2]);
});
test('does not mutate the existing list instance', () {
final existing = [_msg(1)];
final merged = mergeNewMessages(existing, [_msg(2)]);
expect(existing.length, 1);
expect(merged.length, 2);
});
});
// "이 답장 나답아요?" (vision.md , deploy-checklist.md N4-12):
// chat_screen.dart는 "이미 평가됨" id
// , ( , )
// / .
group('ratedMessageIdsFrom', () {
test('picks up ids with a non-null naturalness rating (true or false)', () {
final messages = [
_msg(1, naturalnessRating: true),
_msg(2, naturalnessRating: false),
_msg(3),
];
expect(ratedMessageIdsFrom(messages), {1, 2});
});
test('empty list -> empty set', () {
expect(ratedMessageIdsFrom(const []), isEmpty);
});
test('no rated messages -> empty set', () {
expect(ratedMessageIdsFrom([_msg(1), _msg(2)]), isEmpty);
});
});
}

View File

@ -13,26 +13,9 @@ void main() {
expect(c.id, 3); expect(c.id, 3);
expect(c.userIds, [1, 2]); expect(c.userIds, [1, 2]);
expect(c.twinDisabledByPeer, isTrue); expect(c.twinDisabledByPeer, isTrue);
expect(c.twinDisabledByFlood, isFalse);
expect(c.titleFor(1), '대화 · 상대 #2'); expect(c.titleFor(1), '대화 · 상대 #2');
}); });
// roadmap.md §2.7-C / : twin_disabled_by_flood parses
// independently of twin_disabled_by_peer and defaults to false when absent
// (covered by the test above).
test('ConversationSummary parses twin_disabled_by_flood', () {
final c = ConversationSummary.fromJson({
'id': 4,
'is_group': false,
'twin_disabled_by_peer': false,
'twin_disabled_by_flood': true,
'user_ids': [1, 2],
'created_at': '2026-07-30T00:00:00Z',
});
expect(c.twinDisabledByPeer, isFalse);
expect(c.twinDisabledByFlood, isTrue);
});
test('EscalationLogEntry and Contact parse', () { test('EscalationLogEntry and Contact parse', () {
final log = EscalationLogEntry.fromJson({ final log = EscalationLogEntry.fromJson({
'id': 9, 'id': 9,
@ -50,61 +33,5 @@ void main() {
'relationship_note': '대학', 'relationship_note': '대학',
}); });
expect(contact.contactUserId, 7); expect(contact.contactUserId, 7);
expect(contact.relationshipTier, isNull);
expect(contact.autonomyLevel, isNull);
});
// roadmap.md §2.7-D: Contact.autonomyLevel is a nullable per-contact
// override of the global autonomy level, parsed only when present --
// mirrors relationshipTier's null-safe parsing above.
test('Contact parses autonomy_level override when present', () {
final withOverride = Contact.fromJson({
'id': 2,
'display_name': '친구2',
'contact_user_id': 8,
'autonomy_level': 'L2',
});
expect(withOverride.autonomyLevel, AutonomyLevel.L2);
expect(withOverride.autonomyLevel!.label, 'L2');
final withoutOverride = Contact.fromJson({
'id': 3,
'display_name': '친구3',
'contact_user_id': 9,
});
expect(withoutOverride.autonomyLevel, isNull);
});
// + (PRD.md §5, deploy-checklist.md
// N4-12): null을 null/true/false를
// .
test('ChatMessage parses draft_edited and naturalness_rating when present', () {
final withBoth = ChatMessage.fromJson({
'id': 10,
'conversation_id': 1,
'sender_id': 1,
'sender_mode': 'twin',
'text': 'ㅇㅇ 좋아',
'retracted': false,
'draft_edited': true,
'naturalness_rating': false,
'created_at': '2026-08-03T00:00:00Z',
});
expect(withBoth.draftEdited, isTrue);
expect(withBoth.naturalnessRating, isFalse);
});
test('ChatMessage defaults draft_edited and naturalness_rating to null when absent', () {
final withoutEither = ChatMessage.fromJson({
'id': 11,
'conversation_id': 1,
'sender_id': 1,
'sender_mode': 'human',
'text': '안녕',
'retracted': false,
'created_at': '2026-08-03T00:00:00Z',
});
expect(withoutEither.draftEdited, isNull);
expect(withoutEither.naturalnessRating, isNull);
}); });
} }

View File

@ -1,24 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ykavu_mobile/screens/sessions_screen.dart';
void main() {
test('formatExpiresAtLocal converts UTC Z to local wall clock', () {
// Fixed offset via DateTime.parse keeps instant; toLocal() uses test TZ.
final raw = '2026-09-02T09:06:45.137172Z';
final out = SessionsScreen.formatExpiresAtLocal(raw);
final expected = DateTime.parse(raw).toLocal();
final two = (int n) => n.toString().padLeft(2, '0');
expect(
out,
'${expected.year}-${two(expected.month)}-${two(expected.day)} '
'${two(expected.hour)}:${two(expected.minute)}',
);
expect(out.contains('T'), isFalse);
expect(out.endsWith('Z'), isFalse);
});
test('formatExpiresAtLocal handles null and garbage', () {
expect(SessionsScreen.formatExpiresAtLocal(null), '');
expect(SessionsScreen.formatExpiresAtLocal('not-a-date'), 'not-a-date');
});
}

View File

@ -1,107 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ykavu_mobile/db/app_database.dart';
import 'package:ykavu_mobile/services/snooze_controller.dart';
import 'package:ykavu_mobile/services/snooze_notification_service.dart';
/// `flutter_local_notifications` (mock).
/// /
/// , "스케줄/취소 호출이 올바른 id·시각·페이로드로 이뤄지는가" .
class _FakeScheduler implements SnoozeNotificationScheduler {
final scheduledCalls = <Map<String, Object?>>[];
final cancelledIds = <int>[];
final immediateCalls = <Map<String, Object?>>[];
@override
Future<void> scheduleReminder({
required int conversationId,
required String title,
required String body,
required DateTime at,
}) async {
scheduledCalls.add({
'conversationId': conversationId,
'title': title,
'body': body,
'at': at,
});
}
@override
Future<void> cancelReminder(int conversationId) async {
cancelledIds.add(conversationId);
}
@override
Future<void> showImmediate({required String title, required String body}) async {
immediateCalls.add({'title': title, 'body': body});
}
@override
Future<bool> ensurePermission() async => true;
}
void main() {
late AppDatabase db;
late _FakeScheduler scheduler;
late SnoozeController controller;
setUp(() {
db = AppDatabase.memory();
scheduler = _FakeScheduler();
controller = SnoozeController(db: db, scheduler: scheduler);
});
tearDown(() => db.close());
test('applySnooze persists to DB and schedules with exact id/time/payload', () async {
final until = DateTime.utc(2026, 8, 3, 21, 0);
await controller.applySnooze(
conversationId: 7,
until: until,
title: '답장 마감',
body: '상대#7에게 답장할 시간이에요',
);
expect(await controller.loadSnoozedUntil(7), until);
expect(scheduler.scheduledCalls, hasLength(1));
expect(scheduler.scheduledCalls.single, {
'conversationId': 7,
'title': '답장 마감',
'body': '상대#7에게 답장할 시간이에요',
'at': until,
});
});
test('clearSnooze removes from DB and cancels the scheduled reminder', () async {
final until = DateTime.utc(2026, 8, 3, 21, 0);
await controller.applySnooze(conversationId: 9, until: until, title: 't', body: 'b');
expect(await controller.loadSnoozedUntil(9), isNotNull);
await controller.clearSnooze(9);
expect(await controller.loadSnoozedUntil(9), isNull);
expect(scheduler.cancelledIds, [9]);
});
test('loadAllSnoozes reflects multiple conversations independently', () async {
final until1 = DateTime.utc(2026, 8, 3, 21, 0);
final until2 = DateTime.utc(2026, 8, 4, 9, 0);
await controller.applySnooze(conversationId: 1, until: until1, title: 't', body: 'b');
await controller.applySnooze(conversationId: 2, until: until2, title: 't', body: 'b');
expect(await controller.loadAllSnoozes(), {1: until1, 2: until2});
expect(scheduler.scheduledCalls, hasLength(2));
});
test('notifyPastDueOnFocus shows immediate only for past-due snoozes', () async {
final past = DateTime.utc(2026, 8, 1, 12, 0);
final future = DateTime.utc(2026, 8, 10, 12, 0);
await controller.applySnooze(conversationId: 1, until: past, title: 't', body: 'b');
await controller.applySnooze(conversationId: 2, until: future, title: 't', body: 'b');
await controller.notifyPastDueOnFocus(now: DateTime.utc(2026, 8, 3, 12, 0));
expect(scheduler.immediateCalls, hasLength(1));
expect(scheduler.immediateCalls.single['body'], contains('#1'));
});
}

View File

@ -1,64 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ykavu_mobile/services/snooze_service.dart';
void main() {
group('isSnoozePastDue / isSnoozeActive', () {
// "now" (DateTime.now() ).
final now = DateTime(2026, 8, 3, 12, 0);
test('no snooze set -> never past due, never active', () {
expect(isSnoozePastDue(now, null), isFalse);
expect(isSnoozeActive(now, null), isFalse);
});
test('snooze in the future -> active, not past due', () {
final until = now.add(const Duration(hours: 1));
expect(isSnoozePastDue(now, until), isFalse);
expect(isSnoozeActive(now, until), isTrue);
});
test('snooze in the past -> past due, not active', () {
final until = now.subtract(const Duration(minutes: 1));
expect(isSnoozePastDue(now, until), isTrue);
expect(isSnoozeActive(now, until), isFalse);
});
test('snooze exactly at now -> counts as past due (boundary)', () {
expect(isSnoozePastDue(now, now), isTrue);
expect(isSnoozeActive(now, now), isFalse);
});
});
group('resolveSnoozeQuickPick', () {
test('oneHour adds exactly one hour', () {
final now = DateTime(2026, 8, 3, 12, 0);
expect(resolveSnoozeQuickPick(SnoozeQuickPick.oneHour, now), DateTime(2026, 8, 3, 13, 0));
});
test('thisEvening resolves to 19:00 today when now is before 19:00', () {
final now = DateTime(2026, 8, 3, 12, 0);
expect(resolveSnoozeQuickPick(SnoozeQuickPick.thisEvening, now), DateTime(2026, 8, 3, 19, 0));
});
test('thisEvening rolls over to tomorrow 19:00 when now is already past 19:00', () {
final now = DateTime(2026, 8, 3, 20, 30);
expect(resolveSnoozeQuickPick(SnoozeQuickPick.thisEvening, now), DateTime(2026, 8, 4, 19, 0));
});
test('thisEvening rolls over exactly at 19:00 (boundary is not "before")', () {
final now = DateTime(2026, 8, 3, 19, 0);
expect(resolveSnoozeQuickPick(SnoozeQuickPick.thisEvening, now), DateTime(2026, 8, 4, 19, 0));
});
test('tomorrowMorning resolves to 09:00 the next day', () {
final now = DateTime(2026, 8, 3, 23, 45);
expect(resolveSnoozeQuickPick(SnoozeQuickPick.tomorrowMorning, now), DateTime(2026, 8, 4, 9, 0));
});
test('quick pick labels are the fixed Korean copy used in the UI', () {
expect(SnoozeQuickPick.oneHour.label, '1시간 후');
expect(SnoozeQuickPick.thisEvening.label, '저녁에');
expect(SnoozeQuickPick.tomorrowMorning.label, '내일');
});
});
}

View File

@ -1,56 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:ykavu_mobile/db/app_database_web.dart';
/// N4-W1 web AppDatabase durable round-trip (imports web impl directly so
/// VM tests exercise prefs persistence, not the native Drift path).
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('web open persists tone, kv, and snooze across reopen', () async {
final db1 = await AppDatabase.open();
addTearDown(db1.close);
await db1.replaceToneSamples(['웹에서도', '유지돼야 함']);
await db1.setBoolKv('tone_onboarding_done', true);
await db1.setKv('device_install_id', 'abc123deadbeef');
final until = DateTime.utc(2026, 8, 4, 15, 30);
await db1.setSnoozedUntil(7, until);
final db2 = await AppDatabase.open();
addTearDown(db2.close);
expect(await db2.loadToneSamples(), ['웹에서도', '유지돼야 함']);
expect(await db2.getBoolKv('tone_onboarding_done'), isTrue);
expect(await db2.getKv('device_install_id'), 'abc123deadbeef');
expect(await db2.getSnoozedUntil(7), until);
expect(db2.encrypted, isFalse);
});
test('web memory factory does not write prefs', () async {
final mem = AppDatabase.memory();
addTearDown(mem.close);
await mem.replaceToneSamples(['ephemeral']);
await mem.setBoolKv('tone_onboarding_done', true);
final opened = await AppDatabase.open();
addTearDown(opened.close);
expect(await opened.loadToneSamples(), isEmpty);
expect(await opened.getBoolKv('tone_onboarding_done'), isFalse);
});
test('corrupt tone JSON is wiped and open still succeeds', () async {
SharedPreferences.setMockInitialValues({
'ykavu_web_db_tone_samples': '{not-json',
});
final db = await AppDatabase.open();
addTearDown(db.close);
expect(await db.loadToneSamples(), isEmpty);
await db.replaceToneSamples(['ok']);
expect(await db.loadToneSamples(), ['ok']);
});
}

View File

@ -1,10 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ykavu_mobile/services/ws_client.dart';
void main() {
test('wsLinkStateLabel covers all states', () {
expect(wsLinkStateLabel(WsLinkState.connecting), '연결 중…');
expect(wsLinkStateLabel(WsLinkState.live), '실시간');
expect(wsLinkStateLabel(WsLinkState.reconnecting), '다시 연결 중…');
});
}

View File

@ -1,5 +0,0 @@
// FCM Web service worker stub.
// When Firebase Web config is injected at Docker build time, this file is
// overwritten with a populated worker (see mobile/Dockerfile).
// An empty/stub file prevents 404s while Web Push secrets are absent.
console.log('[ykavu] firebase-messaging-sw.js stub (Web Push not configured)');

View File

@ -1,15 +0,0 @@
// Generated at Docker build from firebase-messaging-sw.js.template — do not edit on server.
// Compat SDK is required for importScripts-based FCM service workers.
importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js');
firebase.initializeApp({
apiKey: '__FIREBASE_API_KEY__',
authDomain: '__FIREBASE_AUTH_DOMAIN__',
projectId: '__FIREBASE_PROJECT_ID__',
storageBucket: '__FIREBASE_STORAGE_BUCKET__',
messagingSenderId: '__FIREBASE_MESSAGING_SENDER_ID__',
appId: '__FIREBASE_APP_ID__',
});
firebase.messaging();

Some files were not shown because too many files have changed in this diff Show More