Compare commits
No commits in common. "main" and "cursor/track-a-messenger-ux-382d" have entirely different histories.
main
...
cursor/tra
19
.env.example
19
.env.example
|
|
@ -8,12 +8,9 @@ ADMIN_API_TOKEN=
|
|||
# Shared demo invite DEMO-YKAVU on signup UI (multiple testers). Set 0 to disable.
|
||||
ALLOW_DEMO_INVITE=1
|
||||
|
||||
# FCM push (escalation notify + /admin/push-test). Prefer HTTP v1 service account:
|
||||
# place JSON at secrets/firebase-service-account.json (see docs/fcm-setup.md)
|
||||
# Optional legacy key (often disabled in new Firebase projects):
|
||||
# Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test).
|
||||
# Without this, notifyUser soft-skips and records push_skipped metrics.
|
||||
FCM_SERVER_KEY=
|
||||
FCM_SERVICE_ACCOUNT_FILE=/secrets/firebase-service-account.json
|
||||
# FCM_SERVICE_ACCOUNT_JSON= # alternative: paste minified JSON (avoid if possible)
|
||||
|
||||
# --- docker compose (N2-B / msn.iykyka.com) ---
|
||||
POSTGRES_USER=ykavu
|
||||
|
|
@ -27,15 +24,3 @@ PUBLIC_API_BASE=https://msn.iykyka.com
|
|||
|
||||
# Host port mapped to web:80
|
||||
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=
|
||||
|
|
|
|||
|
|
@ -9,11 +9,6 @@ poc/tone-corpus/data/
|
|||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Firebase / FCM service account JSON (never commit)
|
||||
secrets/**
|
||||
!secrets/.gitkeep
|
||||
!secrets/README.md
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
|
|
|
|||
13
AGENTS.md
13
AGENTS.md
|
|
@ -10,8 +10,7 @@ copying content into prompts or new files.
|
|||
|
||||
When documents disagree, follow this order:
|
||||
|
||||
1. [`docs/decision-log.md`](docs/decision-log.md) — working assumptions for Q1~Q8 (확정)
|
||||
and Q9 (L2 의미 — 제안 until Master confirms)
|
||||
1. [`docs/decision-log.md`](docs/decision-log.md) — working assumptions for Q1~Q7
|
||||
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)
|
||||
4. [`docs/PLANNING.md`](docs/PLANNING.md) — process guide
|
||||
|
|
@ -19,12 +18,10 @@ When documents disagree, follow this order:
|
|||
|
||||
Notes:
|
||||
|
||||
- Q1~Q8 in `decision-log.md` are **확정** (Q1~Q7: Phase 1 C, 2026-07-30;
|
||||
Q8 account/settings IA: 2026-08-03). PoC-dependent sub-questions (default
|
||||
autonomy level, whitelist defaults, final branding) stay open — do not invent
|
||||
those. Q9 (L2 receive-triggered auto-reply) remains **제안** — implement only
|
||||
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.
|
||||
- Q1~Q7 in `decision-log.md` are **확정** (Phase 1 C, 2026-07-30). PoC-dependent
|
||||
sub-questions (default autonomy level, whitelist defaults, final branding)
|
||||
stay open — do not invent those. 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
|
||||
current working answers.
|
||||
- Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later**
|
||||
|
|
|
|||
76
README.md
76
README.md
|
|
@ -1,4 +1,4 @@
|
|||
# hikikomori / 와카뷰 (Ykavu)
|
||||
# hikikomori / 와카뷰 (가칭)
|
||||
|
||||
카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 와카뷰".
|
||||
|
||||
|
|
@ -6,80 +6,35 @@
|
|||
|
||||
| 경로 | 역할 |
|
||||
|---|---|
|
||||
| `docs/` | 기획·PRD·기술설계·로드맵·배포 체크리스트·PoC |
|
||||
| `docs/` | 기획·PRD·기술설계·로드맵·PoC 계획 |
|
||||
| `core-backend/` | Go 코어 (가입·메시지·WebSocket·자율성·안전장치) |
|
||||
| `ai-service/` | Python AI 내부 API (`/draft`, `/escalate/check`) |
|
||||
| `backend/` | 초기 Python 프로토타입 (참고용) |
|
||||
| `poc/tone-corpus/` | PoC 실험 스크립트 |
|
||||
| `mobile/` | Flutter 클라이언트 (Android 우선 · Web 배포) |
|
||||
| `secrets/` | FCM 등 시크릿 마운트용 (git에 실파일 커밋 금지) |
|
||||
| `mobile/` | Flutter 클라이언트 (Android 우선) |
|
||||
|
||||
## 문서 지도
|
||||
## 문서
|
||||
|
||||
권위 순서·에이전트 규칙: [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.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페이지 요약 |
|
||||
- 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) (Q1~Q7 **확정**)
|
||||
- Vision / PRD / 기술설계: [`docs/vision.md`](./docs/vision.md) · [`docs/PRD.md`](./docs/PRD.md) · [`docs/tech-design.md`](./docs/tech-design.md)
|
||||
- 로드맵 (작업 체크리스트): [`docs/roadmap.md`](./docs/roadmap.md)
|
||||
- **배포·잔여 실행 트랙:** [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) (N1 스모크 → N2 Docker → … → N5 사람 PoC)
|
||||
- 베타 직전(C): [`docs/invite-ops.md`](./docs/invite-ops.md) · [`docs/android-release.md`](./docs/android-release.md) · [`docs/prototype.md`](./docs/prototype.md)
|
||||
- PoC 계획/준비물: [`docs/poc-plan.md`](./docs/poc-plan.md) · [`docs/poc-materials.md`](./docs/poc-materials.md)
|
||||
|
||||
## AI 에이전트 규칙
|
||||
|
||||
- [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md)
|
||||
- **브랜치:** 작업·머지는 항상 `main`
|
||||
- **리모트:** GitHub `origin` + Gitea `gitea`(iykyka) — `main` 갱신 후 `./scripts/push-both.sh`
|
||||
- **Q9** (L2 수신 트리거 자동응대)는 Master 확정 전 구현 금지
|
||||
|
||||
## 현재 단계
|
||||
|
||||
- Phase 1 **A~C** + Track A/B/C + **Q8**(로그인·설정·로그아웃) 코드 완료
|
||||
- 라이브: [`https://msn.iykyka.com`](https://msn.iykyka.com) · 데모 코드 **`DEMO-YKAVU`**
|
||||
- **웹 고도화 (W0~W7):** 구현·배포 완료 — [`docs/web-upgrade.md`](./docs/web-upgrade.md).
|
||||
**남은 것:** W4 **accept** (하드 리프레시·알림 허용·로그인 후 `/admin/push-test` → `sent >= 1`)
|
||||
- **후순위 (Master):** Android N4 — `google-services.json` PC 배치 · 실기기 FCM · UI 탭 · APK · `SHARE_URL`
|
||||
- **결정 대기:** Q9 — L2 의미 (카피 정렬 ± 수신 자동응대) — 웹 트랙에도 **비범위**
|
||||
- **맨 마지막:** N5 / D — 사람 PoC. §3 기본값 추측 금지
|
||||
|
||||
상세 현황: [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) §0.
|
||||
- Phase 1 **A~C** + **N2 컷오버 + N3 안정화 완료** — 라이브: [`https://msn.iykyka.com`](https://msn.iykyka.com)
|
||||
- 테스터 안내: [`docs/tester-guide.md`](./docs/tester-guide.md) · 데모 코드 **`DEMO-YKAVU`**
|
||||
- **다음 (선택):** N4 FCM·Android QA
|
||||
- **맨 마지막:** N5 / D — 사람 PoC (`docs/roadmap.md` §3). §3 기본값 추측 금지
|
||||
- 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입
|
||||
|
||||
## 로컬 실행 (요약)
|
||||
|
||||
|
|
@ -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)
|
||||
- Portainer·컷오버: [`docs/deploy-portainer.md`](./docs/deploy-portainer.md)
|
||||
- FCM: [`docs/fcm-setup.md`](./docs/fcm-setup.md)
|
||||
- 서버 기동: `./scripts/server-up.sh` (호스트에서 `.env` 채운 뒤)
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
examples = "\n".join(f"- {s}" for s in style_examples)
|
||||
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
|
||||
|
||||
|
||||
def draft_reply(
|
||||
style_examples,
|
||||
context_lines,
|
||||
model="gemini-2.5-flash",
|
||||
api_key=None,
|
||||
relationship_tier=None,
|
||||
relationship_note=None,
|
||||
):
|
||||
def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key=None):
|
||||
"""Returns (status, text). status is one of "escalate" | "no_key" | "ok".
|
||||
|
||||
"escalate": text is the escalation reason (금전/약속 확정/감정적으로 무거운 주제).
|
||||
"no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing).
|
||||
"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)
|
||||
gate = check_escalation(incoming)
|
||||
|
|
@ -126,9 +89,6 @@ def draft_reply(
|
|||
resp = client.models.generate_content(
|
||||
model=model,
|
||||
contents=build_user_prompt(style_examples, context_lines),
|
||||
config=types.GenerateContentConfig(
|
||||
system_instruction=system_prompt_for_tier(relationship_tier, relationship_note),
|
||||
max_output_tokens=300,
|
||||
),
|
||||
config=types.GenerateContentConfig(system_instruction=SYSTEM_PROMPT, max_output_tokens=300),
|
||||
)
|
||||
return "ok", resp.text.strip()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from pydantic import BaseModel, model_validator
|
|||
from .escalation_filter import check as check_escalation
|
||||
from .generation import draft_reply, last_incoming_text, load_dotenv_if_present
|
||||
from .retrieve_style import retrieve as retrieve_style_examples
|
||||
from .summarize import summarize_messages
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -30,8 +29,6 @@ class DraftRequest(BaseModel):
|
|||
history: Optional[List[str]] = None
|
||||
k: int = 6
|
||||
model: str = "gemini-2.5-flash"
|
||||
relationship_tier: Optional[str] = None
|
||||
relationship_note: Optional[str] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
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
|
||||
)
|
||||
|
||||
status, text = draft_reply(
|
||||
style_examples,
|
||||
req.context_lines,
|
||||
model=req.model,
|
||||
relationship_tier=req.relationship_tier,
|
||||
relationship_note=req.relationship_note,
|
||||
)
|
||||
status, text = draft_reply(style_examples, req.context_lines, model=req.model)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -6,14 +6,7 @@ import sys
|
|||
import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.generation import (
|
||||
RELATIONSHIP_TIER_INSTRUCTIONS,
|
||||
SYSTEM_PROMPT,
|
||||
build_user_prompt,
|
||||
draft_reply,
|
||||
last_incoming_text,
|
||||
system_prompt_for_tier,
|
||||
)
|
||||
from app.generation import build_user_prompt, draft_reply, last_incoming_text
|
||||
|
||||
|
||||
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
|
||||
assert kwargs["model"] == "gemini-2.5-flash"
|
||||
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"]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
/escalate/check into pytest -- roadmap.md Phase 1 §2.5."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main_module
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
|
@ -91,98 +90,3 @@ def test_draft_rejects_both_style_sources():
|
|||
def test_draft_rejects_neither_style_source():
|
||||
resp = client.post("/draft", json={"context_lines": ["상대: 안녕"]})
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -3,7 +3,6 @@ package main
|
|||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
|
@ -19,77 +18,32 @@ type createContactRequest struct {
|
|||
DisplayName string `json:"display_name" binding:"required"`
|
||||
ContactUserID *uint `json:"contact_user_id"`
|
||||
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 {
|
||||
DisplayName string `json:"display_name" binding:"required"`
|
||||
ContactUserID *uint `json:"contact_user_id"`
|
||||
RelationshipNote string `json:"relationship_note"`
|
||||
RelationshipTier *RelationshipTier `json:"relationship_tier"`
|
||||
AutonomyLevel *AutonomyLevel `json:"autonomy_level"`
|
||||
}
|
||||
|
||||
func contactJSON(ct Contact) gin.H {
|
||||
return gin.H{
|
||||
"id": ct.ID,
|
||||
"display_name": ct.DisplayName,
|
||||
"contact_user_id": ct.ContactUserID,
|
||||
"relationship_note": ct.RelationshipNote,
|
||||
"relationship_tier": ct.RelationshipTier,
|
||||
"autonomy_level": ct.AutonomyLevel,
|
||||
}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
InviteCode string `json:"invite_code" binding:"required"`
|
||||
DisplayName string `json:"display_name"`
|
||||
InviteCode string `json:"invite_code" binding:"required"`
|
||||
}
|
||||
|
||||
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) {
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
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
|
||||
if demoInviteEnabled() && isDemoInviteCode(req.InviteCode) {
|
||||
name := strings.TrimSpace(req.DisplayName)
|
||||
if name == "" {
|
||||
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
|
||||
}
|
||||
if err := db.First(&user, *invite.UsedByUserID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
|
||||
return
|
||||
}
|
||||
session, err := createSession(db, user.ID)
|
||||
if err != nil {
|
||||
|
|
@ -224,26 +178,12 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
|||
for _, pp := range pps {
|
||||
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{
|
||||
"id": conv.ID,
|
||||
"is_group": conv.IsGroup,
|
||||
"twin_disabled_by_peer": conv.TwinDisabledByPeer,
|
||||
"twin_disabled_by_flood": conv.TwinDisabledByFlood,
|
||||
"user_ids": participantIDs,
|
||||
"created_at": conv.CreatedAt,
|
||||
"unread_count": unreadCount,
|
||||
"id": conv.ID,
|
||||
"is_group": conv.IsGroup,
|
||||
"twin_disabled_by_peer": conv.TwinDisabledByPeer,
|
||||
"user_ids": participantIDs,
|
||||
"created_at": conv.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"conversations": out})
|
||||
|
|
@ -262,23 +202,19 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
|||
c.JSON(http.StatusForbidden, gin.H{"detail": "not a participant of this conversation"})
|
||||
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
|
||||
query.Order("id asc").Find(&messages)
|
||||
db.Where("conversation_id = ?", convID).Order("id asc").Find(&messages)
|
||||
out := make([]gin.H, 0, len(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})
|
||||
})
|
||||
|
|
@ -296,27 +232,22 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
|||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
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{
|
||||
OwnerUserID: userID,
|
||||
ContactUserID: req.ContactUserID,
|
||||
DisplayName: req.DisplayName,
|
||||
RelationshipNote: req.RelationshipNote,
|
||||
RelationshipTier: req.RelationshipTier,
|
||||
AutonomyLevel: req.AutonomyLevel,
|
||||
}
|
||||
if err := db.Create(&contact).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, contactJSON(contact))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": contact.ID,
|
||||
"display_name": contact.DisplayName,
|
||||
"contact_user_id": contact.ContactUserID,
|
||||
"relationship_note": contact.RelationshipNote,
|
||||
})
|
||||
})
|
||||
|
||||
r.GET("/users/:id/contacts", func(c *gin.Context) {
|
||||
|
|
@ -331,57 +262,16 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
|||
db.Where("owner_user_id = ?", userID).Order("id").Find(&contacts)
|
||||
out := make([]gin.H, 0, len(contacts))
|
||||
for _, ct := range contacts {
|
||||
out = append(out, contactJSON(ct))
|
||||
out = append(out, gin.H{
|
||||
"id": ct.ID,
|
||||
"display_name": ct.DisplayName,
|
||||
"contact_user_id": ct.ContactUserID,
|
||||
"relationship_note": ct.RelationshipNote,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"contacts": out})
|
||||
})
|
||||
|
||||
r.PATCH("/users/:id/contacts/:contactId", func(c *gin.Context) {
|
||||
userID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSelf(c, db, userID) {
|
||||
return
|
||||
}
|
||||
contactID, ok := parseUintParam(c, "contactId")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updateContactRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
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
|
||||
if err := db.Where("id = ? AND owner_user_id = ?", contactID, userID).First(&contact).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "contact not found"})
|
||||
return
|
||||
}
|
||||
if req.ContactUserID != nil && *req.ContactUserID == userID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "cannot set contact_user_id to yourself"})
|
||||
return
|
||||
}
|
||||
contact.DisplayName = req.DisplayName
|
||||
contact.ContactUserID = req.ContactUserID
|
||||
contact.RelationshipNote = req.RelationshipNote
|
||||
contact.RelationshipTier = req.RelationshipTier
|
||||
contact.AutonomyLevel = req.AutonomyLevel
|
||||
if err := db.Save(&contact).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, contactJSON(contact))
|
||||
})
|
||||
|
||||
r.DELETE("/users/:id/contacts/:contactId", func(c *gin.Context) {
|
||||
userID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -7,46 +7,6 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
func TestPatchContactSetsPeerUserID(t *testing.T) {
|
||||
server, _ := setupTestServer(t)
|
||||
ownerID, ownerToken := mustSignup(t, server.URL, "주인")
|
||||
peerID, _ := mustSignup(t, server.URL, "상대")
|
||||
|
||||
createResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
|
||||
DisplayName: "이름만",
|
||||
})
|
||||
if createResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("create contact without peer: %d", createResp.StatusCode)
|
||||
}
|
||||
var created map[string]interface{}
|
||||
json.NewDecoder(createResp.Body).Decode(&created)
|
||||
contactID := uint(created["id"].(float64))
|
||||
if created["contact_user_id"] != nil {
|
||||
t.Fatalf("expected null peer id, got %v", created["contact_user_id"])
|
||||
}
|
||||
|
||||
patchResp := patchJSONAuth(
|
||||
t,
|
||||
server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts/"+strconv.FormatUint(uint64(contactID), 10),
|
||||
ownerToken,
|
||||
updateContactRequest{
|
||||
DisplayName: "상대방",
|
||||
ContactUserID: &peerID,
|
||||
},
|
||||
)
|
||||
if patchResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("patch contact: %d", patchResp.StatusCode)
|
||||
}
|
||||
var updated map[string]interface{}
|
||||
json.NewDecoder(patchResp.Body).Decode(&updated)
|
||||
if uint(updated["contact_user_id"].(float64)) != peerID {
|
||||
t.Fatalf("expected peer %d, got %v", peerID, updated["contact_user_id"])
|
||||
}
|
||||
if updated["display_name"] != "상대방" {
|
||||
t.Fatalf("display_name: %v", updated["display_name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvitesRequireAdminToken(t *testing.T) {
|
||||
server, _ := setupTestServer(t)
|
||||
resp := postJSON(t, server.URL+"/invites", nil)
|
||||
|
|
@ -63,7 +23,7 @@ func TestLoginReturnsSessionToken(t *testing.T) {
|
|||
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 {
|
||||
t.Fatalf("expected 200 login, got %d", login.StatusCode)
|
||||
}
|
||||
|
|
@ -74,23 +34,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) {
|
||||
server, _ := setupTestServer(t)
|
||||
ownerID, ownerToken := mustSignup(t, server.URL, "주인")
|
||||
|
|
@ -177,90 +120,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) {
|
||||
server, db := setupTestServer(t)
|
||||
ownerID, ownerToken := mustSignup(t, server.URL, "화이트주인")
|
||||
|
|
|
|||
|
|
@ -19,19 +19,10 @@ func newAIServiceClient() *AIServiceClient {
|
|||
}
|
||||
|
||||
type draftRequest struct {
|
||||
ContextLines []string `json:"context_lines"`
|
||||
// RelationshipTier (roadmap.md §2.7-B): "close" | "formal". Empty is
|
||||
// treated by ai-service as "no tier info" and falls back to its own
|
||||
// default tone, same as before this field existed.
|
||||
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"`
|
||||
ContextLines []string `json:"context_lines"`
|
||||
StyleExamples []string `json:"style_examples,omitempty"`
|
||||
History []string `json:"history,omitempty"`
|
||||
K int `json:"k,omitempty"`
|
||||
}
|
||||
|
||||
type draftResponse struct {
|
||||
|
|
@ -62,42 +53,6 @@ func (c *AIServiceClient) requestDraft(req draftRequest) (*draftResponse, error)
|
|||
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 {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"])
|
||||
}
|
||||
}
|
||||
|
|
@ -15,15 +15,13 @@ type registerDeviceRequest struct {
|
|||
|
||||
func messageJSON(m Message) gin.H {
|
||||
return gin.H{
|
||||
"id": m.ID,
|
||||
"conversation_id": m.ConversationID,
|
||||
"sender_id": m.SenderID,
|
||||
"sender_mode": m.SenderMode,
|
||||
"text": m.Text,
|
||||
"retracted": m.Retracted,
|
||||
"draft_edited": m.DraftEdited,
|
||||
"naturalness_rating": m.NaturalnessRating,
|
||||
"created_at": m.CreatedAt,
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -203,12 +201,6 @@ const adminDashboardHTML = `<!doctype html>
|
|||
.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; }
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -216,25 +208,9 @@ const adminDashboardHTML = `<!doctype html>
|
|||
<p>Bearer ADMIN_API_TOKEN 으로 /admin/metrics 를 불러옵니다. 생성 지연·오류율은 프로세스 메모리 샘플입니다.</p>
|
||||
<button onclick="load()">새로고침</button>
|
||||
<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>
|
||||
<pre id="raw">loading…</pre>
|
||||
<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() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
let token = params.get('token') || localStorage.ADMIN_API_TOKEN || '';
|
||||
|
|
@ -253,8 +229,6 @@ const adminDashboardHTML = `<!doctype html>
|
|||
['twin msgs', data.messages_twin_total],
|
||||
['escalations', data.escalations_total],
|
||||
['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 err rate', (data.draft_error_rate || 0).toFixed(3)],
|
||||
['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]) =>
|
||||
'<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();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ func TestRevokeSessionAndPushTestWithoutFCM(t *testing.T) {
|
|||
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{}{
|
||||
"user_id": userID,
|
||||
"title": "t",
|
||||
|
|
|
|||
|
|
@ -63,17 +63,6 @@ func registerDemoRoutes(r *gin.Engine) {
|
|||
"demo_invite_code": demoInviteCode,
|
||||
"demo_display_name": "테스터",
|
||||
"hint": "회원가입 화면에 표시된 테스트 코드를 그대로 쓰면 됩니다.",
|
||||
"pairing_steps": []string{
|
||||
"두 명이 같은 DEMO-YKAVU 코드로 각각 가입한다 (시크릿/다른 브라우저).",
|
||||
"각자 대화 목록의 내 사용자 ID를 복사해 상대에게 알려 준다.",
|
||||
"연락처에 상대 표시 이름 + 숫자 ID를 넣고 추가한 뒤 「대화」를 누른다.",
|
||||
"메시지를 보내고, 자율성 L1에서 와카뷰 초안을 한 번 승인·전송해 본다.",
|
||||
"L0(비서)에서는 초안을 「입력창으로 옮기기」만 되며 — 직접 보낸다.",
|
||||
},
|
||||
"notes": []string{
|
||||
"대화는 표시 이름이 아니라 숫자 사용자 ID로 연결됩니다.",
|
||||
"ID 없는 옛 연락처는 연락처 화면에서 「ID 입력」으로 고치면 됩니다.",
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,10 +24,6 @@ func TestDemoInviteReusableForMultipleSignups(t *testing.T) {
|
|||
if demo["demo_invite_code"] != demoInviteCode {
|
||||
t.Fatalf("demo code: %v", demo["demo_invite_code"])
|
||||
}
|
||||
steps, ok := demo["pairing_steps"].([]any)
|
||||
if !ok || len(steps) < 3 {
|
||||
t.Fatalf("expected pairing_steps on /demo, got %#v", demo["pairing_steps"])
|
||||
}
|
||||
|
||||
a := postJSON(t, server.URL+"/auth/signup", signupRequest{
|
||||
InviteCode: demoInviteCode,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,27 +3,19 @@ module hikikomori/core-backend
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
|
|
@ -50,4 +42,7 @@ require (
|
|||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.2 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
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/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
|
|
@ -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-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
|
|
@ -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-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
|
|
@ -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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
|
|
@ -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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
|
@ -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=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -31,27 +31,10 @@ type sendMessageRequest struct {
|
|||
// L2 draft outside the whitelist (PRD.md §2.2). Ignored for
|
||||
// human-authored messages.
|
||||
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 {
|
||||
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 {
|
||||
|
|
@ -101,17 +84,15 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
registerBRoutes(r, db)
|
||||
registerInviteOpsRoutes(r, db)
|
||||
registerDemoRoutes(r)
|
||||
registerGroupSummaryRoutes(r, db, ai)
|
||||
|
||||
r.GET("/admin/metrics", func(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
// v1-minimal (roadmap.md §2.6): counts honestly derivable from the
|
||||
// current schema, plus process-local draft/escalation timings kept
|
||||
// in runtimeMetrics (see metrics.go) -- the latter reset on restart
|
||||
// and are not a substitute for a real time-series DB, but are real
|
||||
// numbers, not placeholders.
|
||||
// v1-minimal (roadmap.md §2.6): only counts honestly derivable from
|
||||
// the current schema. Draft-generation latency and AI-service error
|
||||
// rate need a request-timing/logging layer that doesn't exist yet --
|
||||
// not fabricated here, left for that future work.
|
||||
var usersTotal, humanMessages, twinMessages, escalationsTotal int64
|
||||
var conversationsTotal, conversationsVetoed, invitesMinted, invitesUsed int64
|
||||
db.Model(&User{}).Count(&usersTotal)
|
||||
|
|
@ -139,34 +120,6 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
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()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"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
|
||||
// this as a first approximation, not the final definition.
|
||||
"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.
|
||||
"draft_requests": rt.DraftRequests,
|
||||
"draft_errors": rt.DraftErrors,
|
||||
"draft_error_rate": rt.DraftErrorRate,
|
||||
"draft_latency_avg_ms": rt.DraftLatencyAvgMs,
|
||||
"draft_latency_max_ms": rt.DraftLatencyMaxMs,
|
||||
"draft_latency_samples": rt.DraftLatencySamples,
|
||||
"escalate_checks": rt.EscalateChecks,
|
||||
"escalate_errors": rt.EscalateErrors,
|
||||
"escalate_error_rate": rt.EscalateErrorRate,
|
||||
"twin_sends_blocked": rt.TwinSendsBlocked,
|
||||
"twin_sends_blocked_by_reason": rt.TwinSendsBlockedByReason,
|
||||
"push_attempts": rt.PushAttempts,
|
||||
"push_skipped": rt.PushSkipped,
|
||||
"push_delivered": rt.PushDelivered,
|
||||
"invites_minted": invitesMinted,
|
||||
"invites_used": invitesUsed,
|
||||
"draft_requests": rt.DraftRequests,
|
||||
"draft_errors": rt.DraftErrors,
|
||||
"draft_error_rate": rt.DraftErrorRate,
|
||||
"draft_latency_avg_ms": rt.DraftLatencyAvgMs,
|
||||
"draft_latency_max_ms": rt.DraftLatencyMaxMs,
|
||||
"draft_latency_samples": rt.DraftLatencySamples,
|
||||
"escalate_checks": rt.EscalateChecks,
|
||||
"escalate_errors": rt.EscalateErrors,
|
||||
"escalate_error_rate": rt.EscalateErrorRate,
|
||||
"twin_sends_blocked": rt.TwinSendsBlocked,
|
||||
"push_attempts": rt.PushAttempts,
|
||||
"push_skipped": rt.PushSkipped,
|
||||
"push_delivered": rt.PushDelivered,
|
||||
"invites_minted": invitesMinted,
|
||||
"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()})
|
||||
return
|
||||
}
|
||||
db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0, RelationshipTier: RelationshipFormal})
|
||||
db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0})
|
||||
|
||||
if !demo {
|
||||
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
|
||||
// or erroring) we fail closed and block the send. Peer veto is
|
||||
// checked first (it's a total kill switch for this conversation,
|
||||
// independent of content), then the group-chat block, then flood
|
||||
// detection, then escalation, then autonomy level -- none of the
|
||||
// later checks can override an earlier block.
|
||||
// independent of content), then escalation, then autonomy level --
|
||||
// none of the later checks can override an earlier block.
|
||||
if req.SenderMode == SenderTwin {
|
||||
if conversation.TwinDisabledByPeer {
|
||||
runtimeMetrics.recordTwinBlocked("peer_veto")
|
||||
runtimeMetrics.recordTwinBlocked()
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 와카뷰를 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"})
|
||||
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)
|
||||
runtimeMetrics.recordEscalate(err)
|
||||
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()})
|
||||
return
|
||||
}
|
||||
if result.Escalate {
|
||||
runtimeMetrics.recordTwinBlocked("escalated")
|
||||
runtimeMetrics.recordTwinBlocked()
|
||||
db.Create(&EscalationLog{
|
||||
UserID: req.SenderID,
|
||||
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
|
||||
// settings fail closed to L0, the documented default. roadmap.md
|
||||
// §2.7-D: the level itself can be overridden per-contact (1:1
|
||||
// only -- group conversations already returned above and never
|
||||
// reach this point), so resolve it the same way relationship
|
||||
// tier is resolved instead of reading only the global default.
|
||||
level := resolveAutonomyLevel(db, req.SenderID, convID)
|
||||
// settings fail closed to L0, the documented default.
|
||||
level := AutonomyL0
|
||||
var settings TwinSettings
|
||||
if err := db.Where("user_id = ?", req.SenderID).First(&settings).Error; err == nil {
|
||||
level = settings.AutonomyLevel
|
||||
}
|
||||
|
||||
switch level {
|
||||
case AutonomyL0:
|
||||
runtimeMetrics.recordTwinBlocked("autonomy_l0")
|
||||
runtimeMetrics.recordTwinBlocked()
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 와카뷰 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"})
|
||||
return
|
||||
case AutonomyL1:
|
||||
if !req.Approved {
|
||||
runtimeMetrics.recordTwinBlocked("autonomy_l1_unapproved")
|
||||
runtimeMetrics.recordTwinBlocked()
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "L1은 발송 전 사용자 승인이 필요합니다"})
|
||||
return
|
||||
}
|
||||
case AutonomyL2:
|
||||
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과 동일하게 사용자 승인이 필요합니다"})
|
||||
return
|
||||
}
|
||||
default:
|
||||
runtimeMetrics.recordTwinBlocked("autonomy_unknown_level")
|
||||
runtimeMetrics.recordTwinBlocked()
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "알 수 없는 자율성 레벨이라 발송을 차단합니다"})
|
||||
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{
|
||||
ConversationID: convID,
|
||||
SenderID: req.SenderID,
|
||||
SenderMode: req.SenderMode,
|
||||
Text: req.Text,
|
||||
DraftEdited: draftEdited,
|
||||
}
|
||||
if err := db.Create(&message).Error; err != nil {
|
||||
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})
|
||||
})
|
||||
|
||||
// "이 답장 나답아요?" 자연스러움 피드백(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) {
|
||||
convID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
|
|
@ -556,28 +402,12 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
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()
|
||||
result, err := ai.requestDraft(draftRequest{
|
||||
ContextLines: req.ContextLines,
|
||||
RelationshipTier: string(tier),
|
||||
RelationshipNote: note,
|
||||
StyleExamples: req.StyleExamples,
|
||||
History: req.History,
|
||||
K: req.K,
|
||||
ContextLines: req.ContextLines,
|
||||
StyleExamples: req.StyleExamples,
|
||||
History: req.History,
|
||||
K: req.K,
|
||||
})
|
||||
runtimeMetrics.recordDraft(time.Since(started), err)
|
||||
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})
|
||||
})
|
||||
|
||||
// 도배 감지로 자동 발송이 일시중단된 대화방을 다시 켠다 (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) {
|
||||
userID, ok := parseUintParam(c, "id")
|
||||
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()})
|
||||
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"})
|
||||
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
|
||||
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
|
||||
}
|
||||
settings.AutonomyLevel = req.AutonomyLevel
|
||||
if req.RelationshipTier != nil {
|
||||
settings.RelationshipTier = *req.RelationshipTier
|
||||
}
|
||||
if err := db.Save(&settings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": userID,
|
||||
"autonomy_level": settings.AutonomyLevel,
|
||||
"relationship_tier": settings.RelationshipTier,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"user_id": userID, "autonomy_level": settings.AutonomyLevel})
|
||||
})
|
||||
|
||||
r.POST("/users/:id/whitelist-rules", func(c *gin.Context) {
|
||||
|
|
|
|||
|
|
@ -31,14 +31,7 @@ func mockAIService(t *testing.T) *httptest.Server {
|
|||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Echo relationship_tier and relationship_note into the response
|
||||
// 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, " | "),
|
||||
})
|
||||
json.NewEncoder(w).Encode(draftResponse{Status: "ok", Text: "mock draft for: " + strings.Join(req.ContextLines, " | ")})
|
||||
case "/escalate/check":
|
||||
// Mirrors escalation_filter.py's rule set closely enough to
|
||||
// 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")
|
||||
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:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
|
|
@ -123,21 +105,6 @@ func postJSONAuth(t *testing.T, url, token string, body interface{}) *http.Respo
|
|||
return resp
|
||||
}
|
||||
|
||||
func patchJSONAuth(t *testing.T, url, token string, body interface{}) *http.Response {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest(http.MethodPatch, url, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("patch %s: %v", url, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func deleteJSONAuth(t *testing.T, url, token string) *http.Response {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(http.MethodDelete, url, nil)
|
||||
|
|
@ -658,11 +625,6 @@ func TestVetoMissingConversation(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)
|
||||
|
||||
senderID, token := mustSignup(t, server.URL, "메트릭")
|
||||
|
|
@ -716,67 +678,6 @@ func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) {
|
|||
if out["invites_minted"].(float64) < 1 || out["invites_used"].(float64) < 1 {
|
||||
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) {
|
||||
|
|
@ -937,304 +838,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) {
|
||||
server, _ := setupTestServer(t)
|
||||
resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{
|
||||
|
|
|
|||
|
|
@ -17,16 +17,9 @@ type RuntimeMetrics struct {
|
|||
EscalateChecks int64
|
||||
EscalateErrors int64
|
||||
TwinSendsBlocked int64
|
||||
// TwinSendsBlockedByReason breaks the total above down by *why* a
|
||||
// twin-authored send was blocked (peer_veto, flood_blocked,
|
||||
// autonomy_l0, ...) -- see the call sites in main.go's
|
||||
// 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
|
||||
PushAttempts int64
|
||||
PushSkipped int64
|
||||
PushDelivered int64
|
||||
}
|
||||
|
||||
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()
|
||||
defer m.mu.Unlock()
|
||||
m.TwinSendsBlocked++
|
||||
if m.TwinSendsBlockedByReason == nil {
|
||||
m.TwinSendsBlockedByReason = map[string]int64{}
|
||||
}
|
||||
m.TwinSendsBlockedByReason[reason]++
|
||||
}
|
||||
|
||||
func (m *RuntimeMetrics) recordPush(delivered int, skipped bool) {
|
||||
|
|
@ -99,41 +88,35 @@ func (m *RuntimeMetrics) snapshot() ginHMetrics {
|
|||
if m.EscalateChecks > 0 {
|
||||
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{
|
||||
DraftRequests: m.DraftRequests,
|
||||
DraftErrors: m.DraftErrors,
|
||||
DraftErrorRate: errRate,
|
||||
DraftLatencyAvgMs: avgMs,
|
||||
DraftLatencyMaxMs: float64(max.Milliseconds()),
|
||||
DraftLatencySamples: len(m.DraftLatencies),
|
||||
EscalateChecks: m.EscalateChecks,
|
||||
EscalateErrors: m.EscalateErrors,
|
||||
EscalateErrorRate: escErrRate,
|
||||
TwinSendsBlocked: m.TwinSendsBlocked,
|
||||
TwinSendsBlockedByReason: byReason,
|
||||
PushAttempts: m.PushAttempts,
|
||||
PushSkipped: m.PushSkipped,
|
||||
PushDelivered: m.PushDelivered,
|
||||
DraftRequests: m.DraftRequests,
|
||||
DraftErrors: m.DraftErrors,
|
||||
DraftErrorRate: errRate,
|
||||
DraftLatencyAvgMs: avgMs,
|
||||
DraftLatencyMaxMs: float64(max.Milliseconds()),
|
||||
DraftLatencySamples: len(m.DraftLatencies),
|
||||
EscalateChecks: m.EscalateChecks,
|
||||
EscalateErrors: m.EscalateErrors,
|
||||
EscalateErrorRate: escErrRate,
|
||||
TwinSendsBlocked: m.TwinSendsBlocked,
|
||||
PushAttempts: m.PushAttempts,
|
||||
PushSkipped: m.PushSkipped,
|
||||
PushDelivered: m.PushDelivered,
|
||||
}
|
||||
}
|
||||
|
||||
type ginHMetrics struct {
|
||||
DraftRequests int64
|
||||
DraftErrors int64
|
||||
DraftErrorRate float64
|
||||
DraftLatencyAvgMs float64
|
||||
DraftLatencyMaxMs float64
|
||||
DraftLatencySamples int
|
||||
EscalateChecks int64
|
||||
EscalateErrors int64
|
||||
EscalateErrorRate float64
|
||||
TwinSendsBlocked int64
|
||||
TwinSendsBlockedByReason map[string]int64
|
||||
PushAttempts int64
|
||||
PushSkipped int64
|
||||
PushDelivered int64
|
||||
DraftRequests int64
|
||||
DraftErrors int64
|
||||
DraftErrorRate float64
|
||||
DraftLatencyAvgMs float64
|
||||
DraftLatencyMaxMs float64
|
||||
DraftLatencySamples int
|
||||
EscalateChecks int64
|
||||
EscalateErrors int64
|
||||
EscalateErrorRate float64
|
||||
TwinSendsBlocked int64
|
||||
PushAttempts int64
|
||||
PushSkipped int64
|
||||
PushDelivered int64
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -17,27 +17,6 @@ const (
|
|||
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 {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
InviteCode string `gorm:"uniqueIndex;not null"`
|
||||
|
|
@ -76,17 +55,7 @@ type Contact struct {
|
|||
ContactUserID *uint
|
||||
DisplayName string `gorm:"not null"`
|
||||
RelationshipNote string
|
||||
// RelationshipTier overrides the owner's global TwinSettings.RelationshipTier
|
||||
// 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
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// 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
|
||||
// when the counterpart asks to talk to the human only; checked before any
|
||||
// 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 {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
IsGroup bool `gorm:"not null;default:false"`
|
||||
TwinDisabledByPeer bool `gorm:"not null;default:false"`
|
||||
TwinDisabledByFlood bool `gorm:"not null;default:false"`
|
||||
CreatedAt time.Time
|
||||
ID uint `gorm:"primaryKey"`
|
||||
IsGroup bool `gorm:"not null;default:false"`
|
||||
TwinDisabledByPeer bool `gorm:"not null;default:false"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ConversationParticipant struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ConversationID 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 {
|
||||
|
|
@ -129,31 +87,15 @@ type Message struct {
|
|||
// one-tap undo") -- set via POST /messages/:id/retract, twin-authored
|
||||
// messages only.
|
||||
Retracted bool `gorm:"not null;default:false"`
|
||||
// DraftEdited is the implicit L1-approval-rate signal (PRD.md §5's
|
||||
// "초안을 수정 없이 그대로 발송한 비율", 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
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type TwinSettings struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
UserID uint `gorm:"uniqueIndex;not null"`
|
||||
AutonomyLevel AutonomyLevel `gorm:"not null;default:L0"`
|
||||
RelationshipTier RelationshipTier `gorm:"not null;default:formal"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID uint `gorm:"primaryKey"`
|
||||
UserID uint `gorm:"uniqueIndex;not null"`
|
||||
AutonomyLevel AutonomyLevel `gorm:"not null;default:L0"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// WhitelistRule is the L2 auto-send whitelist -- a (contact, topic) pair the
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,111 +2,28 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const fcmMessagingScope = "https://www.googleapis.com/auth/firebase.messaging"
|
||||
|
||||
// FCM_SERVER_KEY = legacy HTTP API (often disabled in new Firebase projects).
|
||||
// Prefer FCM HTTP v1 via service account:
|
||||
// FCM_SERVICE_ACCOUNT_FILE=/path/to.json
|
||||
// or FCM_SERVICE_ACCOUNT_JSON='{"type":"service_account",...}'
|
||||
// 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.
|
||||
func fcmServerKey() string {
|
||||
return strings.TrimSpace(os.Getenv("FCM_SERVER_KEY"))
|
||||
}
|
||||
|
||||
func loadFCMServiceAccountJSON() ([]byte, error) {
|
||||
if path := strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_FILE")); path != "" {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read FCM_SERVICE_ACCOUNT_FILE: %w", err)
|
||||
}
|
||||
return bytes.TrimSpace(b), nil
|
||||
}
|
||||
if raw := strings.TrimSpace(os.Getenv("FCM_SERVICE_ACCOUNT_JSON")); raw != "" {
|
||||
return []byte(raw), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type fcmServiceAccount struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
}
|
||||
|
||||
type fcmLegacyPayload struct {
|
||||
To string `json:"to,omitempty"`
|
||||
Registration []string `json:"registration_ids,omitempty"`
|
||||
Priority string `json:"priority"`
|
||||
Notification map[string]string `json:"notification"`
|
||||
Data map[string]string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
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
|
||||
To string `json:"to,omitempty"`
|
||||
Registration []string `json:"registration_ids,omitempty"`
|
||||
Priority string `json:"priority"`
|
||||
Notification map[string]string `json:"notification"`
|
||||
Data map[string]string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
regIDs := collectFCMRegistrationIDs(tokens)
|
||||
if len(regIDs) == 0 {
|
||||
runtimeMetrics.recordPush(0, true)
|
||||
return 0, "only_placeholder_tokens", nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||
defer cancel()
|
||||
|
||||
projectID, ts, v1err := fcmV1Ready(ctx)
|
||||
if v1err != nil {
|
||||
runtimeMetrics.recordPush(0, true)
|
||||
return 0, "", v1err
|
||||
}
|
||||
if projectID != "" && ts != nil {
|
||||
n, err := sendFCMv1(ctx, projectID, ts, regIDs, title, body, data)
|
||||
if err != nil {
|
||||
runtimeMetrics.recordPush(0, true)
|
||||
return 0, "", err
|
||||
}
|
||||
runtimeMetrics.recordPush(n, false)
|
||||
return n, "", nil
|
||||
}
|
||||
|
||||
// Legacy fallback when service account is not configured.
|
||||
key := fcmServerKey()
|
||||
if key == "" {
|
||||
runtimeMetrics.recordPush(0, true)
|
||||
return 0, "fcm_not_configured", nil
|
||||
}
|
||||
n, err := sendFCMLegacy(key, regIDs, title, body, data)
|
||||
if err != nil {
|
||||
|
||||
// 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)
|
||||
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{
|
||||
Registration: regIDs,
|
||||
Priority: "high",
|
||||
|
|
@ -208,7 +61,7 @@ func sendFCMLegacy(key string, regIDs []string, title, body string, data map[str
|
|||
raw, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPost, "https://fcm.googleapis.com/fcm/send", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "key="+key)
|
||||
|
|
@ -216,11 +69,14 @@ func sendFCMLegacy(key string, regIDs []string, title, body string, data map[str
|
|||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
runtimeMetrics.recordPush(0, true)
|
||||
return 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,25 +12,7 @@ server {
|
|||
# Docker Compose embedded DNS
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
# Entrypoint + service worker: always revalidate (PWA / docs/web-upgrade N4-W5).
|
||||
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.
|
||||
# Flutter SPA
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,11 +48,6 @@ services:
|
|||
ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?set ADMIN_API_TOKEN in .env}
|
||||
ALLOW_DEMO_INVITE: ${ALLOW_DEMO_INVITE:-1}
|
||||
FCM_SERVER_KEY: ${FCM_SERVER_KEY:-}
|
||||
# Prefer HTTP v1 (service account). Mount secrets/firebase-service-account.json on the host.
|
||||
FCM_SERVICE_ACCOUNT_FILE: ${FCM_SERVICE_ACCOUNT_FILE:-/secrets/firebase-service-account.json}
|
||||
FCM_SERVICE_ACCOUNT_JSON: ${FCM_SERVICE_ACCOUNT_JSON:-}
|
||||
volumes:
|
||||
- ./secrets:/secrets:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
|
@ -72,14 +67,6 @@ services:
|
|||
dockerfile: mobile/Dockerfile
|
||||
args:
|
||||
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:
|
||||
- "${WEB_HOST_PORT:-8088}:80"
|
||||
depends_on:
|
||||
|
|
|
|||
|
|
@ -30,9 +30,7 @@
|
|||
|
||||
기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다.
|
||||
현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30).
|
||||
PoC 의존 하위 질문(자율성 기본값 등)과 **Q9(L2 의미, 제안)** 가 열려 있다.
|
||||
**Q8(계정·설정 IA)은 2026-08-03 확정·구현** — decision-log를 단일 기준으로 따른다.
|
||||
계정 UX 상세는 [`account-settings-ia.md`](./account-settings-ia.md).
|
||||
PoC 의존 하위 질문(자율성 기본값 등)만 열려 있다 — decision-log를 단일 기준으로 따른다.
|
||||
|
||||
| # | 질문 | 결정 | 상태 |
|
||||
|---|------|------|------|
|
||||
|
|
@ -107,8 +105,7 @@ PoC 의존 하위 질문(자율성 기본값 등)과 **Q9(L2 의미, 제안)**
|
|||
3. [`tech-design.md`](./tech-design.md) — 온디바이스/서버 경계, 데이터 흐름, 에스컬레이션 로직
|
||||
4. [`risk-log.md`](./risk-log.md) — 회의 자료 §2-6, §oslayer §4의 리스크를 완화 상태와 함께 추적
|
||||
5. [`roadmap.md`](./roadmap.md) — L0~L4 단계, 자체 앱→OS 레이어 확장 시점 반영
|
||||
6. [`decision-log.md`](./decision-log.md) — Q1~Q8 확정 + Q9 제안, 계속 누적 기록
|
||||
6a. [`account-settings-ia.md`](./account-settings-ia.md) — 초대 가입·로그아웃·설정 IA·L2 갭 명세
|
||||
6. [`decision-log.md`](./decision-log.md) — Q1~Q7 결정과 근거, 계속 누적 기록
|
||||
7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준
|
||||
8. [`poc-materials.md`](./poc-materials.md) — 모집 문구, 동의 안내, 역할극 스크립트, 인터뷰 질문지 초안
|
||||
9. [`user-interview-guide.md`](./user-interview-guide.md) — Q3 자율성 수용성 인터뷰 (와카뷰 사용자 관점)
|
||||
|
|
|
|||
24
docs/PRD.md
24
docs/PRD.md
|
|
@ -12,17 +12,6 @@
|
|||
|
||||
## 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 온보딩 (말투 학습)
|
||||
1. 가입 시 기존 대화 일부 임포트 또는 짧은 질문 응답으로 말투 초기 세팅 (목표: 5분 이내)
|
||||
2. 관계별 페르소나 초기값 설정 — 최소 "가까운 사이 / 공식적인 사이" 2종만 v1에서 지원
|
||||
|
|
@ -31,18 +20,12 @@ Phase 1은 **이메일/비밀번호 계정이 아니라 초대 코드 입장**
|
|||
### 2.2 읽씹 종결 시나리오 (핵심)
|
||||
1. 사용자가 "방해금지"(수면 중 등) 상태를 켜거나, 앱이 비활성 상태 감지 시 자동 제안
|
||||
2. 상대가 메시지를 보냄 → 와카뷰가 맥락(상태, 최근 활동 패턴)을 보고 응답 초안 생성
|
||||
3. **L1**: 사용자에게 "지금 자동 응답 보낼까요?" 알림 → 승인 시 발송
|
||||
4. **L2 (목표)**: 사용자가 화이트리스트에 등록한 상대·주제(예: "가벼운 안부, 시간 문의")에 한해
|
||||
3. **L1**: 사용자에게 "지금 자동 응답 보낼까요?" 알림 → 승인 시 발송 (기본값)
|
||||
4. **L2**: 사용자가 화이트리스트에 등록한 상대·주제(예: "가벼운 안부, 시간 문의")에 한해
|
||||
즉시 자동 발송, 사후 알림
|
||||
5. 상대에게는 와카뷰 뱃지가 붙은 말풍선으로 표시됨 (3.1 참고)
|
||||
6. 사용자가 복귀하면 미응답/보류 항목 요약 제공, 필요 시 후속 메시지 작성
|
||||
|
||||
> **구현 갭 (Q9 제안):** 2026-08 현재 클라이언트는 L2를 “수신 즉시 자동응대”가 아니라
|
||||
> **와카뷰 발송 시 승인 생략 게이트트**로만 구현한다. 수신 트리거 자동응대는
|
||||
> `account-settings-ia.md` §3.4 / `roadmap.md` 후속 항목으로 분리한다. 그전까지 제품 카피는
|
||||
> 게이트 현실에 맞게 쓴다.
|
||||
|
||||
|
||||
### 2.3 단톡 따라잡기 시나리오
|
||||
1. 사용자가 오랜만에 단톡방 진입 또는 "안 본 동안 요약" 요청
|
||||
2. 와카뷰가 **나에게 멘션된 것 / 결정된 사항** 위주로 3~5줄 요약
|
||||
|
|
@ -55,9 +38,6 @@ Phase 1은 **이메일/비밀번호 계정이 아니라 초대 코드 입장**
|
|||
|
||||
| 기능 | 설명 |
|
||||
|---|---|
|
||||
| 초대 코드 가입·재로그인 | 클로즈드 베타 입장. 이메일/비번 없음 (`account-settings-ia.md`) |
|
||||
| 로그아웃 | 설정에서 세션 종료 + 로컬 토큰 클리어 |
|
||||
| 설정 IA | 말투·자율성·세션·데이터흐름·로그아웃을 한 진입점에서 발견 가능 |
|
||||
| 와카뷰 뱃지 | 와카뷰가 작성한 말풍선은 사람 말풍선과 시각적으로 구분(점선 테두리 + 뱃지 라벨) |
|
||||
| 자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정 |
|
||||
| 응답 승인 UI | L1 초안을 한 탭으로 검토·수정·발송 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -34,31 +34,9 @@
|
|||
**순서:** ① 자체 앱 클로즈드 베타로 핵심 가설 검증 → ② 검증되면 OS 레이어 읽기 전용 허브 →
|
||||
③ 자체 앱은 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 이후)
|
||||
|
||||
- 와카뷰 응답 임계점·화이트리스트 기본 주제 목록 (실사용 데이터)
|
||||
- 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰)
|
||||
- 상표 정식 등록 여부 (Q6은 "와카뷰"로 명칭 확정, 등록 절차는 베타 반응 이후)
|
||||
- 베타 참가자 모집 규모와 방식
|
||||
- Q9 제안 → Master 확정 (Q8은 2026-08-03 확정)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,13 +17,6 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
|
|||
- [x] 스택 확정: Flutter + Go core + Python AI
|
||||
- [x] `core-backend/` · `ai-service/` · 하드게이트·거부권·초대·되돌리기·L0~L2 QA
|
||||
- [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 후속
|
||||
|
||||
|
|
@ -36,23 +29,14 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
|
|||
### NOW
|
||||
|
||||
- 앱 코드는 클로즈드 베타 직전 수준
|
||||
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
|
||||
- **웹 고도화:** [`web-upgrade.md`](./web-upgrade.md) — W0~W7 구현·배포 **done** · **W4 accept만** (하드 리프레시·알림·push-test)
|
||||
- 후순위: **N4 Android FCM/UI** — `google-services.json` PC 배치·실기기 (서버 SA는 배치됨)
|
||||
- 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 트랙)
|
||||
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK**. 다음: N4 FCM·Android QA (선택)
|
||||
- 실 FCM · Android UI 수동 QA · 사람 PoC 실행은 남음
|
||||
|
||||
### NEXT 순서
|
||||
|
||||
```
|
||||
N1 스모크 → N2 Docker(msn.iykyka.com) → N3 배포 안정화
|
||||
→ N4-W 웹 고도화(W1→…→W7) 병행 가능
|
||||
→ N4 Android FCM·UI QA 등 → N5 사람 PoC(D) → 실제 베타 오픈
|
||||
→ N4 FCM·Android QA 등 → N5 사람 PoC(D) → 실제 베타 오픈
|
||||
```
|
||||
|
||||
### LOCKED
|
||||
|
|
@ -164,96 +148,36 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
|
|||
| **N4-A3** | 빈 상태·에러·L0 패널 | done | L0는「입력창으로 옮기기」 |
|
||||
| **N4-A4** | 대화 목록 이름·밀도 | done | 연락처 표시명 매핑 |
|
||||
| **N4-A5** | 프로덕션 web 재빌드 | done | `msn.iykyka.com` health 200 (`3d00b00`) |
|
||||
| **N4-A6** | ID 없는 연락처 수정(PATCH)·배너 | done | 「ID 입력」으로 peer ID 보강 |
|
||||
|
||||
### Track B — 데모 콘텐츠 (테스터 페어링)
|
||||
### FCM
|
||||
|
||||
| ID | 작업 | Status | 완료 조건 |
|
||||
|----|------|--------|-----------|
|
||||
| **N4-B1** | `/demo` pairing_steps·notes | done | GET `/demo`에 페어링 단계 |
|
||||
| **N4-B2** | 테스터 가이드 페어링 문서화 | done | `docs/tester-guide.md` ID 교환 플로우 |
|
||||
| **N4-B3** | 가입 화면 페어링 안내 | done | Signup 데모 패널에 한 줄 팁 |
|
||||
| **N4-B4** | 프로덕션 core+web 재배포 | done | `/demo` pairing_steps OK (`c7029ab`) |
|
||||
|
||||
### Track C — 콘텐츠 갭 (PRD P0 대비 미구현, 2026-07-31 발견)
|
||||
|
||||
`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 | 완료 조건 |
|
||||
|----|------|--------|-----------|
|
||||
| **N4-C1a** | 그룹 대화 생성 UI(복수 상대) | **done** (2026-08-03) | "새 대화" 다이얼로그가 상대 여러 명 입력 받아 `is_group:true`로 생성 |
|
||||
| **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-3** | 서버 FCM 자격증명 (HTTP v1) | **done** (2026-08) | 프로덕션 `secrets/firebase-service-account.json` 마운트됨. 웹 W4와 공유 |
|
||||
| **N4-4** | 푸시 수신 (Android) | blocked | N4-1+N4-3 후 `/admin/push-test` + 기기 수신 |
|
||||
| **N4-1** | Firebase + `google-services.json` | todo | Android 앱 연결 |
|
||||
| **N4-2** | 실 FCM registration token | todo | install-id 플레이스홀더 제거 |
|
||||
| **N4-3** | 서버 FCM 자격증명 | todo | env만 (`FCM_SERVER_KEY` 또는 HTTP v1) |
|
||||
| **N4-4** | 푸시 수신 | todo | `/admin/push-test` + 에스컬레이션 수신 |
|
||||
|
||||
### Android UI 탭 (`mobile/README.md`)
|
||||
|
||||
API 계층은 프로덕션에서 검증됨 (`CORE_API_BASE=https://msn.iykyka.com` → e2e **16/16**, 2026-07-31).
|
||||
실기기/에뮬레이터 **화면 탭**은 Master 로컬에서 1회.
|
||||
|
||||
| ID | 작업 | Status | 비고 |
|
||||
|----|------|--------|------|
|
||||
| **N4-5** | 가입 → 말투 저장 | api-done | 실기기 UI 탭 남음 |
|
||||
| **N4-6** | 연락처 → 대화 → 메시지·히스토리 | api-done | 실기기 UI 탭 남음 |
|
||||
| **N4-7** | L1 초안 수정/버리기/승인·뱃지 | api-done | 실기기 UI 탭 남음 |
|
||||
| **N4-8** | 에스컬레이션 → 사후알림 함 | api-done | 실기기 UI 탭 남음 |
|
||||
| **N4-9** | 되돌리기·거부권 | api-done | 실기기 UI 탭 남음 |
|
||||
| **N4-10** | 자율성 L0~L2 + 화이트리스트 | api-done | 실기기 UI 탭 남음 |
|
||||
| ID | 작업 | Status |
|
||||
|----|------|--------|
|
||||
| **N4-5** | 가입 → 말투 저장 | todo |
|
||||
| **N4-6** | 연락처 → 대화 → 메시지·히스토리 | todo |
|
||||
| **N4-7** | L1 초안 수정/버리기/승인·뱃지 | todo |
|
||||
| **N4-8** | 에스컬레이션 → 사후알림 함 | todo |
|
||||
| **N4-9** | 되돌리기·거부권 | todo |
|
||||
| **N4-10** | 자율성 L0~L2 + 화이트리스트 | todo |
|
||||
|
||||
### 후순위
|
||||
|
||||
| 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-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-11** | 오프라인 메시지 큐 | todo | 멀티디바이스 고도화 |
|
||||
| **N4-12** | 자연스러움 피드백 UI | todo | vision 지표 |
|
||||
| **N4-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 |
|
||||
| **N4-14** | 내부 release APK | todo | `docs/android-release.md` |
|
||||
| **N4-15** | roadmap/`[~]` 동기화 | **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 +203,7 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P
|
|||
2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터)
|
||||
3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드)
|
||||
4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브)
|
||||
5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03),
|
||||
~~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만 남음).
|
||||
5. ~~N3 배포 안정화~~ **done** — 다음: **N4 FCM/Android QA** 또는 서버에 `GEMINI_API_KEY` 주입
|
||||
|
||||
|
||||
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
||||
|
|
|
|||
|
|
@ -1,121 +0,0 @@
|
|||
# FCM 설정 (N4-1 ~ N4-4 · N4-W4)
|
||||
|
||||
서버·클라이언트의 푸시 **코드 경로는 준비됨**.
|
||||
새 Firebase 프로젝트는 **레거시 서버 키가 비활성**인 경우가 많아, **HTTP v1 + 서비스 계정 JSON**을 쓴다.
|
||||
|
||||
## 이미 된 것 (코드 · 프로덕션)
|
||||
|
||||
| 계층 | 동작 |
|
||||
|------|------|
|
||||
| Flutter | `PushTokenService` — Firebase 가능하면 실 FCM 토큰, 아니면 `install:` 플레이스홀더 |
|
||||
| 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 HTTP v1**(서비스 계정) 우선 |
|
||||
| 서버 SA (N4-3) | 프로덕션 `secrets/firebase-service-account.json` 마운트 **done** |
|
||||
|
||||
## Master가 할 일
|
||||
|
||||
### N4-1 — Firebase Android 앱
|
||||
|
||||
1. [Firebase Console](https://console.firebase.google.com/) → Android 앱 추가
|
||||
package: **`com.ykavu.ykavu_mobile`**
|
||||
2. `google-services.json`을 **Android 빌드 PC**에만 배치 (git 금지):
|
||||
|
||||
```bash
|
||||
cp ~/Downloads/google-services.json mobile/android/app/google-services.json
|
||||
```
|
||||
|
||||
### N4-3 — 서비스 계정 JSON (HTTP v1) — **프로덕션 done**
|
||||
|
||||
레거시 **서버 키**가 Cloud Messaging 탭에서 `사용 중지됨`이면 정상이다.
|
||||
|
||||
프로덕션 호스트에 이미 배치됨:
|
||||
|
||||
```bash
|
||||
# ~/project/ykavu/secrets/firebase-service-account.json
|
||||
# compose: ./secrets → /secrets, FCM_SERVICE_ACCOUNT_FILE=/secrets/firebase-service-account.json
|
||||
```
|
||||
|
||||
새 환경에 다시 놓을 때:
|
||||
|
||||
```bash
|
||||
mkdir -p secrets
|
||||
mv ~/Downloads/iykyka-*.json secrets/firebase-service-account.json
|
||||
chmod 600 secrets/firebase-service-account.json
|
||||
docker compose up -d --build core-backend
|
||||
```
|
||||
|
||||
**git / 채팅에 JSON 내용을 붙여넣지 않는다.**
|
||||
|
||||
### N4-2 / N4-4 — Android 실기기 스모크
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
flutter run --release --dart-define=CORE_API_BASE=https://msn.iykyka.com
|
||||
# 로그: device token registered (FCM)
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -sS -X POST https://msn.iykyka.com/admin/push-test \
|
||||
-H "Authorization: Bearer $ADMIN_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": <USER_ID>, "title":"와카뷰","body":"push smoke"}'
|
||||
```
|
||||
|
||||
기대: `sent >= 1`.
|
||||
`only_placeholder_tokens` → Android에 `google-services.json` 넣고 재설치.
|
||||
`fcm_not_configured` → 서비스 계정 경로 확인.
|
||||
|
||||
## Web (N4-W4)
|
||||
|
||||
설계·수락: [`web-upgrade.md`](./web-upgrade.md) §N4-W4 · 실행 표: [`deploy-checklist.md`](./deploy-checklist.md) §N4-W.
|
||||
|
||||
**프로덕션:** Firebase Web config + VAPID + `:web:` APP_ID가 서버 `.env`에 있고 `web` 이미지에 빌드 주입됨.
|
||||
SW(`firebase-messaging-sw.js`) populated. 서버 SA는 Android와 공유.
|
||||
|
||||
### 남은 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
|
||||
233
docs/roadmap.md
233
docs/roadmap.md
|
|
@ -35,17 +35,11 @@
|
|||
**2.1 코어 백엔드** (Go, PoC 결과 무관 — 지금 착수 가능)
|
||||
- [x] 계정/인증 (초대 코드 기반 가입) — `core-backend/` (Go, Gin), 중복 코드 409 실제 테스트로 확인함
|
||||
- [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,
|
||||
escalation_logs, whitelist_rules — `core-backend/models.go` (GORM), `backend/app/models.py`
|
||||
(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)가 실제로 쓰는 것.
|
||||
|
||||
|
|
@ -78,8 +72,7 @@
|
|||
- [x] 기본 채팅 UI (대화 목록, 대화방) — `GET`/`POST /conversations`, 히스토리 로드,
|
||||
WebSocket 수신·인간 메시지 전송. 연락처 화면에서 DM 시작
|
||||
- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 가입 + 말투 샘플 입력 UI(`OnboardingToneScreen`,
|
||||
기기 로컬) + 관계 티어 기본값 선택(2.7-B, 2026-08-03 추가). 말투 학습 UX 디테일·카피는
|
||||
**사람 PoC #1 결과를 맨 마지막에 반영** — 이 부분만 여전히 열려 있음
|
||||
기기 로컬). 말투 학습 UX 디테일·카피는 **사람 PoC #1 결과를 맨 마지막에 반영**
|
||||
- [x] 와카뷰 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는
|
||||
서버/AI 프롬프트 측과 이어서 다듬을 것
|
||||
- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외는
|
||||
|
|
@ -116,10 +109,7 @@
|
|||
구현과 함께: L0 차단, L1 미승인 차단/승인 시 발송, L2 화이트리스트 매칭 자동발송/비매칭 시 승인
|
||||
필요, 에스컬레이션이 레벨·화이트리스트·승인 여부와 무관하게 항상 우선한다는 것까지 6개 케이스
|
||||
전부 실제 테스트로 확인함
|
||||
- [ ] 온보딩·채팅·설정 수동 QA — `mobile/` 골격 위에 실기기/에뮬레이터로 진행 (후속). API 계층은
|
||||
이미 검증됨(`deploy-checklist.md` N4-5~10 "api-done") — 남은 건 화면 탭뿐. 그중 N4-C6b(스누즈
|
||||
로컬 알림)·N4-11(오프라인 재연결)은 이 세션 도구엔 실기기/에뮬레이터가 없어 순수 로직만
|
||||
단위테스트로 확인했고 실제 동작 확인은 이 수동 QA 라운드에서 처음 이뤄짐
|
||||
- [ ] 온보딩·채팅·설정 수동 QA — `mobile/` 골격 위에 실기기/에뮬레이터로 진행 (후속)
|
||||
|
||||
**2.6 베타 배포 준비**
|
||||
- [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라
|
||||
|
|
@ -128,164 +118,14 @@
|
|||
409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. 발급자 인증은
|
||||
`ADMIN_API_TOKEN`(A2). 운영 절차·만료/회수는 C (`docs/invite-ops.md`)
|
||||
- [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은
|
||||
`/admin/metrics`의 `peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). **2026-08-03
|
||||
자연스러움 계측 인프라 추가(deploy-checklist.md N4-12, 아래 참고)**: 암묵 신호(초안 무수정
|
||||
발송률)와 명시 신호(원탭 "이 답장 나답아요?" 피드백) 둘 다 캡처되어 `/admin/metrics`에
|
||||
집계된다. **주의 — 이건 PoC 실행이나 실제 수치 결론이 아니라 계측/캡처 장치일 뿐이다.**
|
||||
실제 자연스러움 %는 실 베타 사용 데이터가 쌓여야 나온다(N5 이후). 안전선 위반 0건은 런타임에
|
||||
"수집"하는 지표라기보다 지금까지의 하드게이트 테스트들이 이미 보증하는 것 — 별도 계측 불필요
|
||||
- [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`
|
||||
하드 밴 그대로 유지.
|
||||
`/admin/metrics`의 `peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움
|
||||
피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기
|
||||
보다 지금까지의 하드게이트 테스트들이 이미 보증하는 것 — 별도 계측 불필요
|
||||
- [~] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) — `GET /admin/metrics`로
|
||||
카운트 기반 데이터(메시지 수, 에스컬레이션 사유별 집계, 거부권 발동률, 초대 코드 발급/사용 수)는
|
||||
노출함. **대시보드 UI 자체와 생성 지연시간·오류율**은 아직 없음 — UI는 Flutter/관리자 웹 쪽이고,
|
||||
지연시간·오류율은 요청 타이밍/로깅 계측 계층이 따로 필요해서 이번엔 만들지 않음(허위로 채우지
|
||||
않고 명시적으로 비워둠)
|
||||
|
||||
#### 3. PoC 결과가 있어야 정할 수 있는 것 — **전체 빌드가 끝난 뒤 맨 마지막**
|
||||
|
||||
|
|
@ -309,19 +149,13 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다.
|
|||
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션·
|
||||
되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서
|
||||
4. [~] 2.3 Flutter 클라이언트 — A3 + B(drift/SQLCipher·데이터흐름·세션) 완료 + API E2E.
|
||||
**2026-08-03 동기화**: "C 배포 준비"는 6번에서 이미 완료됐으므로 여기 "남은 것"에서 제거.
|
||||
실제로 남은 건 Master의 Firebase FCM 시크릿 배치(N4-1/3)와 Android UI 탭(N4-5~10,
|
||||
`mobile/README.md`)뿐
|
||||
남은 것: 실제 Firebase FCM 토큰, Android UI 탭(`mobile/README.md`), C 배포 준비
|
||||
5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료.
|
||||
클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께
|
||||
- 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은
|
||||
§3(사람 PoC) 이후**
|
||||
- 5-2. [x] 화이트리스트 규칙 CRUD API
|
||||
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**)
|
||||
|
||||
|
||||
|
|
@ -342,11 +176,6 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
|
|||
- [x] 로그인 세션/토큰 (`Session`, signup/login 시 Bearer 발급)
|
||||
- [x] `/invites`, `/admin/metrics` 접근 제어 (`ADMIN_API_TOKEN`)
|
||||
- [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 이후)
|
||||
- [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 통과(가입·연락처·대화·히스토리·
|
||||
draft/L1·에스컬레이션·알림 로그·되돌리기·거부권·화이트리스트). `go test`/`pytest`/`flutter test`
|
||||
동시 통과. Android 에뮬레이터 UI 탭은 이 환경에 SDK가 없어 체크리스트는 `mobile/README.md`에 유지
|
||||
- [ ] **L2 카피·의미 정렬** (`decision-log` Q9 제안) — 현재는 발송 게이트; UI가
|
||||
“수신 자동응대”로 오해되지 않게 설명 수정. 수신 트리거 자동응대는 별 항목
|
||||
- [ ] **L2 수신 트리거 자동응대** (PRD §2.2 목표) — Q9 확정·안전 게이트 테스트 후.
|
||||
상대 메시지 수신 → 초안 → 화이트리스트면 twin 발송 + 사후 알림/되돌리기
|
||||
|
||||
|
||||
##### B. 그다음 — 베타 품질
|
||||
- [~] FCM 푸시 연동 — 토큰 등록 + 에스컬레이션 시 `notifyUser` + `POST /admin/push-test`.
|
||||
`FCM_SERVER_KEY`와 실제 FCM registration token이 있으면 전송, 없으면 soft-skip.
|
||||
Flutter는 install-id 플레이스홀더 등록(Firebase Messaging 앱 키는 배포 환경에서 교체)
|
||||
- [~] 멀티 디바이스 동기화 — 세션 목록 + 세션 종료(`DELETE /users/:id/sessions/:id`).
|
||||
메시지 히스토리 서버 동기화는 이미 REST/WS. 오프라인 메시지 큐(deploy-checklist N4-11)는
|
||||
구현 완료(부분 검증) — 서버는 이미 모든 메시지를 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 스누즈 알림과 같은 "부분 검증" 프레이밍)
|
||||
메시지 히스토리 서버 동기화는 이미 REST/WS; 오프라인 큐는 후속
|
||||
- [x] drift + SQLCipher 로컬 저장 — `mobile/lib/db/` (말투 샘플·KV). 키는
|
||||
`flutter_secure_storage`. Linux CI는 SQLCipher SO 없으면 메모리 폴백
|
||||
- [x] 말투 이력 기기 내 저장 + 서버 최소 전송 — drift 암호화 저장, draft에 샘플만 전달
|
||||
|
|
@ -406,23 +221,9 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
|
|||
|
||||
단일 실행 체크리스트: **[`deploy-checklist.md`](./deploy-checklist.md)**.
|
||||
|
||||
순서: **N1 스모크 → N2 Docker(`msn.iykyka.com`) → N3 안정화 → N4-W 웹 고도화(병행) /
|
||||
N4 Android FCM·UI QA → N5 사람 PoC(D)**.
|
||||
Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5, **N4-W0…W7**)는 해당 문서를 본다.
|
||||
완료 시 그 문서와 본 로드맵 §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 공유.
|
||||
순서: **N1 스모크 → N2 Docker(`msn.iykyka.com`) → N3 안정화 → N4 FCM/Android QA → N5 사람 PoC(D)**.
|
||||
Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5)는 해당 문서를 본다. 완료 시 그 문서와
|
||||
본 로드맵 §2/§5의 `[~]`/`[ ]`를 함께 갱신한다.
|
||||
|
||||
|
||||
## Phase 2 — L3 확장 + 베타 확대
|
||||
|
|
|
|||
|
|
@ -55,20 +55,6 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색
|
|||
- **v2 이후 온디바이스 증류 대비**: 배터리/지연/프라이버시 압박으로 자체 경량 모델이 필요해지면,
|
||||
이 코퍼스가 그 모델의 기반 학습 데이터가 된다. 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)
|
||||
|
||||
1. 수신 메시지 → 에스컬레이션 판정기 먼저 통과 (금전/약속 확정/민감 키워드+의도 분류)
|
||||
|
|
@ -76,18 +62,9 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색
|
|||
3. 아니면 자율성 레벨 확인:
|
||||
- L0: 초안만 생성해 사용자에게 보여줌, 발송 없음
|
||||
- L1: 초안 생성 + 발송 승인 요청 알림
|
||||
- L2 **(목표)**: 상대·주제가 화이트리스트에 있으면 즉시 발송, 아니면 L1과 동일하게 강등
|
||||
- L2: 상대·주제가 화이트리스트에 있으면 즉시 발송, 아니면 L1과 동일하게 강등
|
||||
4. 발송된 모든 자동 응답은 로컬 이벤트 로그에 기록 (사후 알림 + 되돌리기 버튼 노출)
|
||||
|
||||
### 3-1. 현재 서버 게이트 vs 목표 오케스트레이션
|
||||
|
||||
- **현재 (`core-backend` 메시지 POST):** `sender_mode=twin`일 때만 레벨 검사.
|
||||
L2 + `whitelistMatches(text)`면 `approved` 없이 통과. 수신 이벤트가 이 경로를
|
||||
자동 호출하지는 않는다.
|
||||
- **목표 (PRD §2.2 / decision-log Q9):** 상대 메시지 수신 → (게이트 통과 시) 초안 →
|
||||
L2면 twin 발송. 구현은 별도 로드맵 항목으로 분리한다.
|
||||
|
||||
|
||||
에스컬레이션 판정기는 v1에서는 규칙 기반(키워드 + 간단한 의도 분류) + 온디바이스 모델의 결합으로
|
||||
시작하고, 오탐/누락 사례를 베타 로그로 계속 튜닝한다. 100% 정확도를 목표하지 않는다 — 애매하면
|
||||
항상 에스컬레이션 쪽으로 fail-safe.
|
||||
|
|
|
|||
|
|
@ -1,51 +1,25 @@
|
|||
# 와카뷰 테스터 안내 (N3-6 / Track B)
|
||||
# 와카뷰 테스터 안내 (N3-6)
|
||||
|
||||
## 접속
|
||||
|
||||
- 웹: **https://msn.iykyka.com** (데모/프리뷰 1차 표면)
|
||||
- 권장 브라우저: **Desktop Chrome**
|
||||
- 웹: **https://msn.iykyka.com**
|
||||
- 공용 데모 초대 코드: **`DEMO-YKAVU`**
|
||||
- 표시 이름 예시: `테스터` (원하는 이름으로 변경 가능 — **이미 가입** 탭 재로그인 시 같은 이름 필요)
|
||||
- 표시 이름 예시: `테스터` (원하는 이름으로 변경 가능)
|
||||
|
||||
같은 코드를 여러 명이 쓸 수 있습니다 (`ALLOW_DEMO_INVITE=1`).
|
||||
|
||||
API 메타: `GET https://msn.iykyka.com/demo` — `pairing_steps` / `notes` 포함.
|
||||
## 권장 플로우 (5분)
|
||||
|
||||
### 웹 프리뷰 팁 (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분)
|
||||
|
||||
두 명(또는 시크릿 창 두 개)으로 진행합니다. **대화는 닉네임이 아니라 숫자 사용자 ID로 연결됩니다.**
|
||||
|
||||
1. **가입** — 「새로 가입」탭 · 초대 코드 `DEMO-YKAVU` (각자 다른 표시 이름 권장). 시크릿 창 두 개로 두 사용자 가능.
|
||||
2. **말투 샘플** — 몇 줄 적거나 스킵 (하드 리프레시 후에도 유지됨)
|
||||
3. **내 ID 복사** — 대화 목록 상단「내 사용자 ID」칩을 탭해 복사하고 상대에게 전달
|
||||
4. **연락처 추가** — 상대 표시 이름 + **상대의 숫자 ID(필수)** → 추가 → 「대화」
|
||||
5. **메시지** — 사람 모드로 한두 줄 주고받기 · 채팅 앱바의 「실시간」상태 확인
|
||||
6. **와카뷰 초안** — 설정 → 자율성에서 **L1** → 채팅에서 ✨ 초안 → 수정/승인하고 보내기
|
||||
7. (선택) L0에서는「입력창으로 옮기기」만 됩니다. L2·거부권·사후알림 함·이따 답장도 눌러 보세요.
|
||||
|
||||
### ID 없는 옛 연락처
|
||||
|
||||
이름만 넣고 ID를 비운 연락처는 대화가 안 됩니다. 연락처 화면에서 **「ID 입력」**으로 숫자 ID를 채우면 됩니다 (삭제 후 재추가 불필요).
|
||||
1. 가입 — 초대 코드 `DEMO-YKAVU` 입력
|
||||
2. 말투 샘플 온보딩 — 몇 줄 적거나 스킵
|
||||
3. 연락처에 상대 유저 등록 → 대화 시작
|
||||
4. 메시지 전송 · 분신 초안(L1) 한 번 시도
|
||||
5. (선택) 자율성 L0~L2 / 거부권 / 사후알림 함 확인
|
||||
|
||||
## 알아둘 점
|
||||
|
||||
- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다.
|
||||
- **L0(비서)**: 와카뷰 발송이 서버에서 막혀 있습니다. 초안 → 입력창 → 직접 전송.
|
||||
- 푸시: 웹은 프로덕션에 Firebase Web(`:web:`)·VAPID가 이미 주입되어 있습니다.
|
||||
**하드 리프레시**(또는 사이트 데이터 삭제) → 알림 **허용** → 로그인하면 실 FCM 토큰이 등록됩니다.
|
||||
Android 실기기 푸시는 `google-services.json` 배치 후 (`docs/fcm-setup.md`).
|
||||
- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다. (이전에 `no_key`이던 상태는 해소됨)
|
||||
- 푸시(FCM)는 아직 플레이스홀더 단계일 수 있습니다.
|
||||
- 문제/스크린샷은 Master에게 전달해 주세요.
|
||||
- 민감 정보·실명 대화는 베타 특성상 최소화해 주세요.
|
||||
|
||||
|
|
|
|||
|
|
@ -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) | 별트랙 (본 문서 비범위) |
|
||||
|
|
@ -1,37 +1,17 @@
|
|||
# Flutter Web → nginx. Build from repository root:
|
||||
# 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
|
||||
FROM ${FLUTTER_IMAGE} AS build
|
||||
|
||||
WORKDIR /app
|
||||
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 ./
|
||||
RUN flutter pub get
|
||||
|
||||
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
|
||||
COPY --from=build /app/build/web /usr/share/nginx/html
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
# 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 \
|
||||
# # optional W4: --dart-define=FIREBASE_API_KEY=... --dart-define=FIREBASE_VAPID_KEY=...
|
||||
# cd mobile && flutter build web --release --dart-define=CORE_API_BASE=https://msn.iykyka.com
|
||||
# docker build -f mobile/Dockerfile.prebuilt -t ykavu-web .
|
||||
# Production/Portainer should prefer mobile/Dockerfile (multi-stage flutter build).
|
||||
FROM nginx:1.27-alpine
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
# 와카뷰 mobile (Flutter)
|
||||
|
||||
Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3).
|
||||
**릴리즈 타깃(암호화 DB·OS 알림):** Android.
|
||||
**데모/프리뷰 1차 표면:** Flutter **Web** (`https://msn.iykyka.com`) —
|
||||
고도화 설계·단계: [`docs/web-upgrade.md`](../docs/web-upgrade.md) (W0~W7).
|
||||
Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3). **v1은 Android 빌드만** 대상으로 한다
|
||||
(`docs/tech-design.md` §8).
|
||||
|
||||
## 지금 있는 것
|
||||
|
||||
|
|
@ -65,29 +63,9 @@ HTTP로 검증한다.
|
|||
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)
|
||||
|
||||
- 코드: `lib/services/push_token_service.dart` — Firebase 가능 시 실 토큰, 아니면 `install:`
|
||||
- Android: [`docs/fcm-setup.md`](../docs/fcm-setup.md) (`google-services.json`)
|
||||
- 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))
|
||||
- Android: `google-services.json` PC 배치 후 실기기 푸시 스모크 (N4-1/4; 서버 SA는 N4-3 done)
|
||||
- 멀티디바이스 실시간 설정 동기화 고도화
|
||||
- Firebase 프로젝트의 실제 FCM registration token 연동 (`google-services.json`)
|
||||
- 오프라인 메시지 큐 / 멀티디바이스 실시간 설정 동기화 고도화
|
||||
- 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
|
||||
- iOS 빌드 (v1 범위 밖)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,3 @@ key.properties
|
|||
**/*.jks
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Firebase — never commit real project credentials (see docs/fcm-setup.md).
|
||||
app/google-services.json
|
||||
|
|
|
|||
|
|
@ -8,11 +8,6 @@ plugins {
|
|||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// Apply only when Master drops google-services.json (see docs/fcm-setup.md).
|
||||
if (file("google-services.json").exists()) {
|
||||
apply(plugin = "com.google.gms.google-services")
|
||||
}
|
||||
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val hasReleaseKeystore = keystorePropertiesFile.exists()
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"project_info": {
|
||||
"project_number": "REPLACE_WITH_FIREBASE_PROJECT_NUMBER",
|
||||
"project_id": "ykavu-REPLACE",
|
||||
"storage_bucket": "ykavu-REPLACE.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:REPLACE:android:REPLACE",
|
||||
"android_client_info": {
|
||||
"package_name": "com.ykavu.ykavu_mobile"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "REPLACE_WITH_ANDROID_API_KEY"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<application
|
||||
android:label="와카뷰"
|
||||
android:name="${applicationName}"
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ plugins {
|
|||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
id("com.google.gms.google-services") version "4.4.2" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
_$AppDatabase(QueryExecutor e) : super(e);
|
||||
$AppDatabaseManager get managers => $AppDatabaseManager(this);
|
||||
late final $ToneSamplesTable toneSamples = $ToneSamplesTable(this);
|
||||
late final $LocalKvTable localKv = $LocalKvTable(this);
|
||||
late final $ConversationSnoozesTable conversationSnoozes =
|
||||
$ConversationSnoozesTable(this);
|
||||
@override
|
||||
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||
@override
|
||||
List<DatabaseSchemaEntity> get allSchemaEntities => [
|
||||
toneSamples,
|
||||
localKv,
|
||||
conversationSnoozes,
|
||||
];
|
||||
List<DatabaseSchemaEntity> get allSchemaEntities => [toneSamples, localKv];
|
||||
}
|
||||
|
||||
typedef $$ToneSamplesTableCreateCompanionBuilder =
|
||||
|
|
@ -1102,169 +862,6 @@ typedef $$LocalKvTableProcessedTableManager =
|
|||
LocalKvData,
|
||||
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 {
|
||||
final _$AppDatabase _db;
|
||||
|
|
@ -1273,6 +870,4 @@ class $AppDatabaseManager {
|
|||
$$ToneSamplesTableTableManager(_db, _db.toneSamples);
|
||||
$$LocalKvTableTableManager get localKv =>
|
||||
$$LocalKvTableTableManager(_db, _db.localKv);
|
||||
$$ConversationSnoozesTableTableManager get conversationSnoozes =>
|
||||
$$ConversationSnoozesTableTableManager(_db, _db.conversationSnoozes);
|
||||
}
|
||||
|
|
@ -13,9 +13,9 @@ import 'package:sqlite3/open.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 {
|
||||
AppDatabase(super.e, {this.encrypted = false});
|
||||
|
||||
|
|
@ -23,18 +23,7 @@ class AppDatabase extends _$AppDatabase {
|
|||
final bool encrypted;
|
||||
|
||||
@override
|
||||
int get schemaVersion => 2;
|
||||
|
||||
@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);
|
||||
}
|
||||
},
|
||||
);
|
||||
int get schemaVersion => 1;
|
||||
|
||||
/// In-memory DB for unit tests (no SQLCipher / filesystem).
|
||||
factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory(), encrypted: false);
|
||||
|
|
@ -87,42 +76,6 @@ class AppDatabase extends _$AppDatabase {
|
|||
Future<void> setBoolKv(String key, bool value) async {
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -1,125 +1,31 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Web/Chrome [AppDatabase] backed by [SharedPreferences] (IndexedDB under the hood).
|
||||
/// Web/Chrome stub for [AppDatabase].
|
||||
///
|
||||
/// SQLCipher / dart:ffi cannot compile for Flutter Web. Persistence is
|
||||
/// **not encrypted** — acceptable for the demo/preview surface
|
||||
/// (`docs/web-upgrade.md` N4-W1). Drift WASM is intentionally not used.
|
||||
///
|
||||
/// [AppDatabase.memory] stays ephemeral (unit tests / fallbacks).
|
||||
/// SQLCipher / dart:ffi cannot compile for Flutter Web. Chrome runs use an
|
||||
/// in-tab memory map so UI + API flows still work. Persistence is not encrypted
|
||||
/// and is lost on refresh (web is a preview surface, not the release target).
|
||||
class AppDatabase {
|
||||
AppDatabase({
|
||||
this.encrypted = false,
|
||||
SharedPreferences? prefs,
|
||||
bool persist = false,
|
||||
}) : _prefs = prefs,
|
||||
_persist = persist && prefs != null;
|
||||
AppDatabase({this.encrypted = false});
|
||||
|
||||
/// Always false on web — there is no SQLCipher path.
|
||||
final bool encrypted;
|
||||
|
||||
final SharedPreferences? _prefs;
|
||||
final bool _persist;
|
||||
|
||||
final Map<String, String> _kv = {};
|
||||
List<String> _toneSamples = [];
|
||||
final Map<int, DateTime> _snoozes = {};
|
||||
|
||||
static const _kTone = 'ykavu_web_db_tone_samples';
|
||||
static const _kSnoozes = 'ykavu_web_db_snoozes';
|
||||
static const _kvPrefix = 'ykavu_web_db_kv_';
|
||||
factory AppDatabase.memory() => AppDatabase(encrypted: false);
|
||||
|
||||
factory AppDatabase.memory() => AppDatabase(encrypted: false, persist: 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);
|
||||
}
|
||||
static Future<AppDatabase> open() async => AppDatabase(encrypted: false);
|
||||
|
||||
Future<List<String>> loadToneSamples() async => List<String>.from(_toneSamples);
|
||||
|
||||
Future<void> replaceToneSamples(List<String> samples) async {
|
||||
_toneSamples = List<String>.from(samples);
|
||||
await _persistTone();
|
||||
}
|
||||
|
||||
Future<String?> getKv(String key) async => _kv[key];
|
||||
|
||||
Future<void> setKv(String key, String value) async {
|
||||
_kv[key] = value;
|
||||
await _persistKv(key, value);
|
||||
}
|
||||
|
||||
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
|
||||
|
|
@ -132,20 +38,5 @@ class AppDatabase {
|
|||
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 {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,16 +19,3 @@ class LocalKv extends Table {
|
|||
@override
|
||||
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};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,8 +89,7 @@ class _BootstrapState extends State<_Bootstrap> {
|
|||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
// Default to light messenger UI; OS dark still maps to soft charcoal.
|
||||
themeMode: ThemeMode.light,
|
||||
themeMode: ThemeMode.system,
|
||||
builder: (context, child) => GradientBackdrop(child: child),
|
||||
home: const SplashScreen(),
|
||||
);
|
||||
|
|
@ -113,8 +112,7 @@ class YkavuApp extends StatelessWidget {
|
|||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
// Default to light messenger UI; OS dark still maps to soft charcoal.
|
||||
themeMode: ThemeMode.light,
|
||||
themeMode: ThemeMode.system,
|
||||
builder: (context, child) => GradientBackdrop(child: child),
|
||||
home: Consumer<SessionState>(
|
||||
builder: (context, s, _) {
|
||||
|
|
|
|||
|
|
@ -4,26 +4,6 @@ enum SenderMode { human, twin }
|
|||
// ignore: constant_identifier_names
|
||||
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 {
|
||||
User({required this.id, required this.displayName, required this.inviteCode});
|
||||
|
||||
|
|
@ -39,10 +19,9 @@ class User {
|
|||
}
|
||||
|
||||
class TwinSettings {
|
||||
TwinSettings({required this.autonomyLevel, required this.relationshipTier});
|
||||
TwinSettings({required this.autonomyLevel});
|
||||
|
||||
final AutonomyLevel autonomyLevel;
|
||||
final RelationshipTier relationshipTier;
|
||||
|
||||
factory TwinSettings.fromJson(Map<String, dynamic> json) {
|
||||
final raw = (json['autonomy_level'] as String? ?? 'L0').toUpperCase();
|
||||
|
|
@ -51,7 +30,6 @@ class TwinSettings {
|
|||
(e) => e.name == raw,
|
||||
orElse: () => AutonomyLevel.L0,
|
||||
),
|
||||
relationshipTier: RelationshipTier.fromJson(json['relationship_tier'] as String?),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -62,8 +40,6 @@ class ConversationSummary {
|
|||
required this.isGroup,
|
||||
required this.userIds,
|
||||
required this.twinDisabledByPeer,
|
||||
this.twinDisabledByFlood = false,
|
||||
this.unreadCount = 0,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
|
|
@ -71,10 +47,6 @@ class ConversationSummary {
|
|||
final bool isGroup;
|
||||
final List<int> userIds;
|
||||
final bool twinDisabledByPeer;
|
||||
// roadmap.md §2.7-C 스팸/도배 감지: 자동으로 켜지지만(사람의 선택이 아님)
|
||||
// twinDisabledByPeer와 달리 되돌릴 수 있음 (POST /conversations/:id/flood-reset).
|
||||
final bool twinDisabledByFlood;
|
||||
final int unreadCount;
|
||||
final DateTime? createdAt;
|
||||
|
||||
factory ConversationSummary.fromJson(Map<String, dynamic> json) {
|
||||
|
|
@ -84,8 +56,6 @@ class ConversationSummary {
|
|||
isGroup: json['is_group'] as bool? ?? false,
|
||||
userIds: rawIds.map((e) => e as int).toList(),
|
||||
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? ?? ''),
|
||||
);
|
||||
}
|
||||
|
|
@ -105,36 +75,18 @@ class Contact {
|
|||
required this.displayName,
|
||||
this.contactUserId,
|
||||
this.relationshipNote = '',
|
||||
this.relationshipTier,
|
||||
this.autonomyLevel,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String displayName;
|
||||
final int? contactUserId;
|
||||
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(
|
||||
id: json['id'] as int,
|
||||
displayName: json['display_name'] as String? ?? '',
|
||||
contactUserId: json['contact_user_id'] as int?,
|
||||
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.retracted,
|
||||
required this.createdAt,
|
||||
this.draftEdited,
|
||||
this.naturalnessRating,
|
||||
});
|
||||
|
||||
final int id;
|
||||
|
|
@ -185,15 +135,6 @@ class ChatMessage {
|
|||
final String text;
|
||||
final bool retracted;
|
||||
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;
|
||||
|
||||
|
|
@ -207,8 +148,6 @@ class ChatMessage {
|
|||
text: json['text'] as String? ?? '',
|
||||
retracted: json['retracted'] as bool? ?? false,
|
||||
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 {
|
||||
DraftResult({required this.status, required this.text});
|
||||
|
||||
|
|
|
|||
|
|
@ -66,27 +66,24 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
|
|||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassFill(theme.brightness),
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPanel),
|
||||
border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
|
||||
),
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.privacy_tip_outlined),
|
||||
title: const Text('데이터 흐름'),
|
||||
subtitle: const Text('무엇이 기기에 남고 서버로 가는지'),
|
||||
trailing: const Icon(Icons.chevron_right, size: 18),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
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(
|
||||
leading: const Icon(Icons.devices),
|
||||
title: const Text('로그인 세션'),
|
||||
subtitle: const Text('멀티 디바이스 세션 목록'),
|
||||
trailing: const Icon(Icons.chevron_right, size: 18),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SessionsScreen()));
|
||||
},
|
||||
|
|
@ -129,21 +126,6 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
|
|||
),
|
||||
),
|
||||
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),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
|
|
|
|||
|
|
@ -5,202 +5,39 @@ import 'package:provider/provider.dart';
|
|||
|
||||
import '../models/models.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/message_sync.dart';
|
||||
import '../services/snooze_service.dart';
|
||||
import '../services/ws_client.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/message_bubble.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
const ChatScreen({
|
||||
super.key,
|
||||
required this.conversationId,
|
||||
this.title,
|
||||
this.isGroup = false,
|
||||
this.twinDisabledByFlood = false,
|
||||
this.embedded = false,
|
||||
});
|
||||
const ChatScreen({super.key, required this.conversationId, this.title});
|
||||
|
||||
final int conversationId;
|
||||
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
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
final _input = TextEditingController();
|
||||
final _draftEdit = TextEditingController();
|
||||
final _scroll = ScrollController();
|
||||
final _messages = <ChatMessage>[];
|
||||
ConversationSocket? _socket;
|
||||
StreamSubscription? _sub;
|
||||
// 오프라인 큐 캐치업(roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11):
|
||||
// 소켓 재연결 신호를 듣기 위한 별도 구독. 실제 소켓 끊김/재연결 타이밍은
|
||||
// 단위 테스트로 검증할 수 없어 — 신호가 왔을 때 REST since_id 캐치업을
|
||||
// 호출한다는 것만 여기서 보장하고, 나머지(진짜 네트워크 단절, 백그라운드
|
||||
// 전환에서 OS가 소켓을 실제로 끊는지)는 실기기 QA 대상이다.
|
||||
StreamSubscription? _reconnectSub;
|
||||
StreamSubscription? _linkSub;
|
||||
WsLinkState _linkState = WsLinkState.connecting;
|
||||
String? _banner;
|
||||
DraftResult? _pendingDraft;
|
||||
bool _busy = false;
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_api = context.read<SessionState>().api;
|
||||
_floodBlocked = widget.twinDisabledByFlood;
|
||||
if (_floodBlocked) {
|
||||
_banner = '도배 감지로 이 대화방의 와카뷰 자동응대가 일시중단되었습니다.';
|
||||
}
|
||||
_socket = ConversationSocket(widget.conversationId)..connect();
|
||||
_linkState = _socket!.linkState;
|
||||
_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();
|
||||
_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 {
|
||||
|
|
@ -213,14 +50,8 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
..clear()
|
||||
..addAll(history);
|
||||
_loadingHistory = false;
|
||||
_syncRatedMessageIds(history);
|
||||
});
|
||||
_scrollToEnd();
|
||||
// 읽음 마커는 화면에 들어올 때가 아니라 "나갈 때"(dispose) 찍는다 --
|
||||
// 즉시 찍으면 그룹 채팅방을 여는 순간 안 본 메시지가 0이 되어 버려서
|
||||
// "안 본 동안 요약" 버튼이 항상 빈 결과만 보여주게 된다(roadmap.md
|
||||
// §2.7-A). 화면이 열려 있는 동안 실시간으로 온 새 메시지는 지금
|
||||
// 보고 있는 것이니 그대로 마커를 따라가도 된다 (_onEvent 참고).
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
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() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
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) {
|
||||
final retractionId = _socket!.parseRetractionId(event);
|
||||
if (retractionId != null) {
|
||||
|
|
@ -289,8 +87,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
text: m.text,
|
||||
retracted: true,
|
||||
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;
|
||||
setState(() => _messages.add(msg));
|
||||
_scrollToEnd();
|
||||
_markLatestRead();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// 화면을 나가는 시점에 읽음 마커를 찍는다 — _loadHistory()의 주석 참고.
|
||||
// Fire-and-forget: dispose는 동기라 기다릴 수 없고, 실패해도 안 읽음
|
||||
// 배지가 조금 늦게 갱신되는 정도라 굳이 에러를 보여줄 필요 없음.
|
||||
_markLatestRead();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_sub?.cancel();
|
||||
_reconnectSub?.cancel();
|
||||
_linkSub?.cancel();
|
||||
_socket?.dispose();
|
||||
_input.dispose();
|
||||
_draftEdit.dispose();
|
||||
|
|
@ -337,8 +125,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
setState(() => _messages.add(msg));
|
||||
}
|
||||
_scrollToEnd();
|
||||
// roadmap.md §2.7-F: 답장을 실제로 보냈으니 걸려 있던 스누즈는 자동 해제.
|
||||
_clearSnoozeQuiet();
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _banner = '전송 실패 (${e.statusCode})');
|
||||
} finally {
|
||||
|
|
@ -390,17 +176,13 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
final text = _draftEdit.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
// L0: twin send is forbidden server-side — move text to human composer
|
||||
// instead. 그룹 대화는 전역 자율성 레벨과 무관하게 항상 L0 취급
|
||||
// (PRD.md §2.3-③, roadmap.md §2.7-A "이 시나리오는 L0 고정, 자동 발송 없음").
|
||||
if (session.autonomyLevel == AutonomyLevel.L0 || widget.isGroup) {
|
||||
// L0: twin send is forbidden server-side — move text to human composer instead.
|
||||
if (session.autonomyLevel == AutonomyLevel.L0) {
|
||||
setState(() {
|
||||
_input.text = text;
|
||||
_pendingDraft = null;
|
||||
_draftEdit.clear();
|
||||
_banner = widget.isGroup
|
||||
? '단톡에서는 와카뷰가 자동으로 보내지 않습니다. 초안을 검토하고 아래 입력창에서 직접 보내세요.'
|
||||
: 'L0(비서 모드)에서는 와카뷰로 보낼 수 없습니다. 아래 입력창에서 직접 보내거나, 자율성 설정을 L1으로 바꾸세요.';
|
||||
_banner = 'L0(비서 모드)에서는 와카뷰로 보낼 수 없습니다. 아래 입력창에서 직접 보내거나, 자율성 설정을 L1으로 바꾸세요.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -413,10 +195,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
text: text,
|
||||
senderMode: SenderMode.twin,
|
||||
approved: true,
|
||||
// 초안 무수정 발송률(PRD.md §5, N4-12): draft.text는 사용자가 편집
|
||||
// 컨트롤러(_draftEdit)를 건드리기 전 AI 초안 원문 — 서버가 이걸
|
||||
// 최종 text와 diff해 draft_edited를 계산한다.
|
||||
originalDraftText: draft.text,
|
||||
);
|
||||
setState(() {
|
||||
_pendingDraft = null;
|
||||
|
|
@ -424,42 +202,13 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg);
|
||||
});
|
||||
_scrollToEnd();
|
||||
// roadmap.md §2.7-F: 와카뷰가 대신 답장을 보냈어도 답장은 답장이므로 스누즈 해제.
|
||||
_clearSnoozeQuiet();
|
||||
} on ApiException catch (e) {
|
||||
setState(() {
|
||||
_banner = '와카뷰 발송 차단 (${e.statusCode}): ${e.body}';
|
||||
// roadmap.md §2.7-C: 이 화면을 열어둔 채로 있다가 도배 임계치를
|
||||
// 새로 넘긴 경우, 목록에서 넘겨받은 초기 상태와 무관하게 지금
|
||||
// 바로 재개 버튼을 보여줘야 한다.
|
||||
if (e.body.contains('도배')) _floodBlocked = true;
|
||||
});
|
||||
setState(() => _banner = '와카뷰 발송 차단 (${e.statusCode}): ${e.body}');
|
||||
} finally {
|
||||
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() {
|
||||
setState(() {
|
||||
_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) {
|
||||
final theme = Theme.of(context);
|
||||
final draft = _pendingDraft;
|
||||
|
|
@ -558,10 +256,10 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
if (draft.isEscalate) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPanel),
|
||||
color: theme.colorScheme.errorContainer.withOpacity(0.85),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
|
||||
),
|
||||
child: Column(
|
||||
|
|
@ -593,7 +291,7 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
}
|
||||
|
||||
final level = context.watch<SessionState>().autonomyLevel;
|
||||
final isL0 = level == AutonomyLevel.L0 || widget.isGroup;
|
||||
final isL0 = level == AutonomyLevel.L0;
|
||||
final title = isL0
|
||||
? '초안 (L0) — 직접 보내기'
|
||||
: level == AutonomyLevel.L1
|
||||
|
|
@ -602,29 +300,32 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassFill(theme.brightness),
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPanel),
|
||||
color: theme.colorScheme.secondaryContainer.withOpacity(0.85),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.onSecondaryContainer),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onSecondaryContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isL0) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
widget.isGroup
|
||||
? '단톡에서는 와카뷰 자동 발송이 항상 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내세요.'
|
||||
: 'L0에서는 와카뷰 발송이 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내거나, 메뉴 → 자율성에서 L1으로 바꾸세요.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
'L0에서는 와카뷰 발송이 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내거나, 메뉴 → 자율성에서 L1으로 바꾸세요.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSecondaryContainer),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
|
|
@ -664,36 +365,8 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !widget.embedded,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
|
||||
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(
|
||||
tooltip: '거부권 (와카뷰 자동응대 중단)',
|
||||
onPressed: _busy ? null : _veto,
|
||||
|
|
@ -703,42 +376,14 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
),
|
||||
body: Column(
|
||||
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)
|
||||
MaterialBanner(
|
||||
leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant),
|
||||
content: Text(_banner!),
|
||||
actions: [
|
||||
// 도배 감지 자동중단의 one-tap undo (AGENTS.md 안전 불변식) —
|
||||
// 거부권과 달리 되돌릴 수 있으므로 여기서 바로 재개 가능.
|
||||
if (_floodBlocked)
|
||||
TextButton(onPressed: _busy ? null : _resumeFlood, 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(
|
||||
child: _loadingHistory
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
|
|
@ -755,61 +400,40 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
|
|||
itemCount: _messages.length,
|
||||
itemBuilder: (context, i) {
|
||||
final m = _messages[i];
|
||||
final isMine = me != null && m.senderId == me;
|
||||
return MessageBubble(
|
||||
message: m,
|
||||
isMine: isMine,
|
||||
isMine: me != null && m.senderId == me,
|
||||
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),
|
||||
SafeArea(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassFill(theme.brightness),
|
||||
border: Border(
|
||||
top: BorderSide(color: AppTheme.glassBorder(theme.brightness).withValues(alpha: 0.7)),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: '초안 요청',
|
||||
onPressed: _busy ? null : _requestDraft,
|
||||
icon: const Icon(Icons.auto_awesome_outlined),
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _input,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) {
|
||||
if (!_busy) _sendHuman();
|
||||
},
|
||||
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(
|
||||
tooltip: '보내기',
|
||||
onPressed: _busy ? null : _sendHuman,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
),
|
||||
icon: const Icon(Icons.arrow_upward_rounded),
|
||||
icon: const Icon(Icons.send),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -48,65 +48,47 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
final noteCtrl = TextEditingController();
|
||||
final session = context.read<SessionState>();
|
||||
final myId = session.user?.id;
|
||||
RelationshipTier? tierOverride;
|
||||
AutonomyLevel? autonomyOverride;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
title: const Text('연락처 추가'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (myId != null) ...[
|
||||
MyUserIdChip(userId: myId),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '표시 이름',
|
||||
helperText: '목록에 보일 이름 (예: 친구 닉네임)',
|
||||
),
|
||||
),
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('연락처 추가'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (myId != null) ...[
|
||||
MyUserIdChip(userId: myId),
|
||||
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;
|
||||
|
|
@ -127,8 +109,6 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
displayName: name,
|
||||
contactUserId: peer,
|
||||
relationshipNote: noteCtrl.text.trim(),
|
||||
relationshipTier: tierOverride,
|
||||
autonomyLevel: autonomyOverride,
|
||||
);
|
||||
setState(() {
|
||||
_contacts = [..._contacts, created];
|
||||
|
|
@ -143,7 +123,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
final session = context.read<SessionState>();
|
||||
final me = session.user;
|
||||
if (me == null || contact.contactUserId == null) {
|
||||
setState(() => _error = '이 연락처에는 상대 사용자 ID가 없습니다. 「ID 입력」으로 숫자 ID를 넣으세요.');
|
||||
setState(() => _error = '이 연락처에는 상대 사용자 ID가 없습니다. 삭제 후 숫자 ID와 함께 다시 추가하세요.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -162,108 +142,6 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _editContact(Contact contact) async {
|
||||
final nameCtrl = TextEditingController(text: contact.displayName);
|
||||
final peerCtrl = TextEditingController(
|
||||
text: contact.contactUserId == null ? '' : '${contact.contactUserId}',
|
||||
);
|
||||
final noteCtrl = TextEditingController(text: contact.relationshipNote);
|
||||
final session = context.read<SessionState>();
|
||||
// PATCH는 전체 교체라 매번 이 값을 그대로 다시 보낸다 — 안 보내면(=이전
|
||||
// 코드처럼 필드 자체를 안 넣으면) 서버가 기존 오버라이드를 null로 되돌림.
|
||||
RelationshipTier? tierOverride = contact.relationshipTier;
|
||||
AutonomyLevel? autonomyOverride = contact.autonomyLevel;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
title: Text(contact.contactUserId == null ? '사용자 ID 입력' : '연락처 수정'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
contact.contactUserId == null
|
||||
? '대화하려면 상대의 숫자 사용자 ID가 필요합니다. 삭제하지 말고 여기서 채워 주세요.'
|
||||
: '표시 이름·상대 ID·메모를 고칠 수 있습니다.',
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(labelText: '표시 이름'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: peerCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
autofocus: contact.contactUserId == null,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '상대 사용자 ID (숫자, 필수)',
|
||||
helperText: '상대 대화 목록에 보이는 숫자 ID',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: noteCtrl,
|
||||
decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
|
||||
),
|
||||
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('저장')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (ok != true || !mounted || session.user == null) return;
|
||||
final name = nameCtrl.text.trim();
|
||||
final peer = int.tryParse(peerCtrl.text.trim());
|
||||
if (name.isEmpty) return;
|
||||
if (peer == null) {
|
||||
setState(() => _error = '상대 사용자 ID(숫자)를 입력해야 합니다.');
|
||||
return;
|
||||
}
|
||||
if (peer == session.user!.id) {
|
||||
setState(() => _error = '자기 자신은 연락처에 넣을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final updated = await session.api.updateContact(
|
||||
userId: session.user!.id,
|
||||
contactId: contact.id,
|
||||
displayName: name,
|
||||
contactUserId: peer,
|
||||
relationshipNote: noteCtrl.text.trim(),
|
||||
relationshipTier: tierOverride,
|
||||
autonomyLevel: autonomyOverride,
|
||||
);
|
||||
setState(() {
|
||||
_contacts = _contacts.map((c) => c.id == updated.id ? updated : c).toList();
|
||||
_error = null;
|
||||
});
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _error = '수정 실패 (${e.statusCode}): ${e.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete(Contact c) async {
|
||||
final session = context.read<SessionState>();
|
||||
if (session.user == null) return;
|
||||
|
|
@ -288,7 +166,6 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final me = context.watch<SessionState>().user?.id;
|
||||
final missingIdCount = _contacts.where((c) => c.contactUserId == null).length;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('연락처'),
|
||||
|
|
@ -313,24 +190,6 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: MyUserIdChip(userId: me),
|
||||
),
|
||||
if (missingIdCount > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Material(
|
||||
color: theme.colorScheme.errorContainer.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'사용자 ID가 없는 연락처 $missingIdCount개 — 「ID 입력」으로 숫자 ID를 채우면 대화를 시작할 수 있습니다.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
|
|
@ -341,6 +200,8 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.person_add_outlined, size: 40, color: theme.colorScheme.outline),
|
||||
const SizedBox(height: 12),
|
||||
Text('연락처가 없습니다', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
|
|
@ -356,67 +217,58 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
),
|
||||
),
|
||||
for (final c in _contacts)
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
leading: CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: theme.colorScheme.surfaceContainerHighest,
|
||||
child: Text(
|
||||
c.displayName.isEmpty ? '?' : c.displayName.substring(0, 1),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: theme.colorScheme.secondaryContainer,
|
||||
child: Text(
|
||||
c.displayName.isEmpty ? '?' : c.displayName.substring(0, 1),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(c.displayName, style: theme.textTheme.titleSmall),
|
||||
subtitle: Text(
|
||||
c.contactUserId == null
|
||||
? '사용자 ID 없음 — 대화 불가 (다시 추가 필요)'
|
||||
: [
|
||||
'사용자 #${c.contactUserId}',
|
||||
if (c.relationshipTier != null) c.relationshipTier!.label,
|
||||
if (c.autonomyLevel != null) c.autonomyLevel!.label,
|
||||
if (c.relationshipNote.isNotEmpty) c.relationshipNote,
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: c.contactUserId == null
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
title: Text(c.displayName, style: theme.textTheme.titleSmall),
|
||||
subtitle: Text(
|
||||
c.contactUserId == null
|
||||
? '사용자 ID 없음 — 대화 불가 (다시 추가 필요)'
|
||||
: [
|
||||
'사용자 #${c.contactUserId}',
|
||||
if (c.relationshipNote.isNotEmpty) c.relationshipNote,
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: c.contactUserId == null
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (c.contactUserId != null) ...[
|
||||
TextButton(
|
||||
onPressed: () => _startChat(c),
|
||||
child: const Text('대화'),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (c.contactUserId != null)
|
||||
FilledButton.tonal(
|
||||
onPressed: () => _startChat(c),
|
||||
child: const Text('대화'),
|
||||
)
|
||||
else
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _error =
|
||||
'${c.displayName}: 숫자 ID가 없어 대화할 수 없습니다. 삭제 후 ID와 함께 다시 추가하세요.');
|
||||
},
|
||||
child: const Text('안내'),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '수정',
|
||||
icon: const Icon(Icons.edit_outlined, size: 20),
|
||||
onPressed: () => _editContact(c),
|
||||
tooltip: '삭제',
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
onPressed: () => _delete(c),
|
||||
),
|
||||
] else
|
||||
FilledButton(
|
||||
onPressed: () => _editContact(c),
|
||||
child: const Text('ID 입력'),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '삭제',
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
onPressed: () => _delete(c),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
onTap: c.contactUserId == null ? null : () => _startChat(c),
|
||||
),
|
||||
onTap: () {
|
||||
if (c.contactUserId == null) {
|
||||
_editContact(c);
|
||||
} else {
|
||||
_startChat(c);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 72),
|
||||
],
|
||||
|
|
@ -425,62 +277,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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,13 @@ import 'package:provider/provider.dart';
|
|||
|
||||
import '../models/models.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/snooze_service.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/gradient_text.dart';
|
||||
import '../widgets/my_user_id_chip.dart';
|
||||
import '../widgets/offline_banner.dart';
|
||||
import '../widgets/web_preview_banner.dart';
|
||||
import 'autonomy_settings_screen.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'contacts_screen.dart';
|
||||
import 'inbox_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
/// Breakpoint for list | chat master-detail (docs/web-upgrade.md N4-W2).
|
||||
const double kMessengerSplitBreakpoint = 900;
|
||||
import 'onboarding_tone_screen.dart';
|
||||
|
||||
class ConversationListScreen extends StatefulWidget {
|
||||
const ConversationListScreen({super.key});
|
||||
|
|
@ -24,49 +18,18 @@ class ConversationListScreen extends StatefulWidget {
|
|||
State<ConversationListScreen> createState() => _ConversationListScreenState();
|
||||
}
|
||||
|
||||
class _ConversationListScreenState extends State<ConversationListScreen>
|
||||
with WidgetsBindingObserver {
|
||||
class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||
List<ConversationSummary> _rooms = [];
|
||||
Map<int, String> _peerNames = {};
|
||||
// 답장 마감 알림(roadmap.md §2.7-F) — 대화방 id별 "이따 답장" 스누즈 마감 시각.
|
||||
// 서버 응답(ConversationSummary)에는 없는 순수 온디바이스 상태라 로컬 DB에서 별도 로드.
|
||||
Map<int, DateTime> _snoozes = {};
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
/// Wide-layout selection (narrow width still uses Navigator.push).
|
||||
int? _selectedId;
|
||||
String? _selectedTitle;
|
||||
bool _selectedIsGroup = false;
|
||||
bool _selectedFlood = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_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 {
|
||||
final session = context.read<SessionState>();
|
||||
setState(() {
|
||||
|
|
@ -89,11 +52,9 @@ class _ConversationListScreenState extends State<ConversationListScreen>
|
|||
// Names are optional enrichment.
|
||||
}
|
||||
}
|
||||
final snoozes = await session.snoozeController?.loadAllSnoozes() ?? <int, DateTime>{};
|
||||
setState(() {
|
||||
_rooms = list;
|
||||
_peerNames = names;
|
||||
_snoozes = snoozes;
|
||||
});
|
||||
} on ApiException catch (e) {
|
||||
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) {
|
||||
if (me == null) return '대화방 #${room.id}';
|
||||
final peers = room.userIds.where((id) => id != me).toList();
|
||||
|
|
@ -157,144 +76,91 @@ class _ConversationListScreenState extends State<ConversationListScreen>
|
|||
|
||||
String _subtitleFor(ConversationSummary room, int? me) {
|
||||
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 peerPart = peers.isEmpty ? '참가자 없음' : '상대 ID ${peers.first}';
|
||||
return '$peerPart · 방 #${room.id}';
|
||||
}
|
||||
|
||||
Future<void> _createConversation() async {
|
||||
final peerCtrl = TextEditingController();
|
||||
final session = context.read<SessionState>();
|
||||
final myId = session.user?.id;
|
||||
// 그룹 대화 생성(roadmap.md §2.7-A "단톡 따라잡기"): 상대를 여러 명 추가하면
|
||||
// 자동으로 그룹이 된다. 필드 1개(상대 1명)면 기존 1:1 흐름과 동일.
|
||||
final peerCtrls = <TextEditingController>[TextEditingController()];
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
title: const Text('새 대화'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (myId != null) ...[
|
||||
MyUserIdChip(userId: myId),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요. '
|
||||
'2명 이상 넣으면 그룹 대화로 만들어집니다.',
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
for (var i = 0; i < peerCtrls.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
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('상대 추가 (그룹으로)'),
|
||||
),
|
||||
),
|
||||
],
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('새 대화'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (myId != null) ...[
|
||||
MyUserIdChip(userId: myId),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'상대에게 위 ID를 알려 주고, 아래에 상대의 숫자 ID를 입력하세요.',
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
TextField(
|
||||
controller: peerCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '상대 사용자 ID (숫자)',
|
||||
helperText: '이름/닉네임이 아니라 숫자 ID입니다. 연락처에 등록돼 있으면 연락처에서 시작하세요.',
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
|
||||
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;
|
||||
final me = session.user;
|
||||
final peer = int.tryParse(peerCtrl.text.trim());
|
||||
if (me == null) return;
|
||||
|
||||
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) {
|
||||
if (peer == null) {
|
||||
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;
|
||||
}
|
||||
final isGroup = peers.length > 1;
|
||||
try {
|
||||
final conv = await session.api.createConversation(
|
||||
userIds: [me.id, ...peers],
|
||||
isGroup: isGroup,
|
||||
);
|
||||
final conv = await session.api.createConversation(userIds: [me.id, peer]);
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatScreen(
|
||||
conversationId: conv.id,
|
||||
title: _peerNames[peer] ?? '상대 #$peer',
|
||||
),
|
||||
),
|
||||
);
|
||||
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) {
|
||||
setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Color _avatarColor(BuildContext context, int seed) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final palette = isDark
|
||||
? const [Color(0xFF2C2C34), Color(0xFF243447), Color(0xFF3A3224)]
|
||||
: const [Color(0xFFE9E9EB), Color(0xFFD6E8FF), Color(0xFFFFF1D6)];
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final palette = [scheme.primaryContainer, scheme.tertiaryContainer, scheme.secondaryContainer];
|
||||
return palette[seed % palette.length];
|
||||
}
|
||||
|
||||
Color _onAvatarColor(BuildContext context, int seed) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final palette = isDark
|
||||
? const [Color(0xFFF2F2F7), Color(0xFF0A84FF), Color(0xFFD4B06A)]
|
||||
: const [Color(0xFF1C1C1E), Color(0xFF007AFF), Color(0xFFB08A4A)];
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final palette = [scheme.onPrimaryContainer, scheme.onTertiaryContainer, scheme.onSecondaryContainer];
|
||||
return palette[seed % palette.length];
|
||||
}
|
||||
|
||||
|
|
@ -303,11 +169,10 @@ class _ConversationListScreenState extends State<ConversationListScreen>
|
|||
final session = context.watch<SessionState>();
|
||||
final theme = Theme.of(context);
|
||||
final me = session.user?.id;
|
||||
final split = _useSplit;
|
||||
|
||||
final listPane = Scaffold(
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: GradientText('와카뷰', style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)),
|
||||
title: const Text('와카뷰'),
|
||||
actions: [
|
||||
if (me != null) MyUserIdChip(userId: me, compact: true),
|
||||
IconButton(
|
||||
|
|
@ -325,275 +190,135 @@ class _ConversationListScreenState extends State<ConversationListScreen>
|
|||
},
|
||||
icon: const Icon(Icons.contacts_outlined),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '설정',
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
PopupMenuButton<VoidCallback>(
|
||||
tooltip: '더보기',
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: (action) => action(),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: () => Navigator.of(context)
|
||||
.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),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _createConversation,
|
||||
tooltip: '새 대화',
|
||||
child: const Icon(Icons.chat_outlined),
|
||||
icon: const Icon(Icons.chat),
|
||||
label: const Text('새 대화'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
const OfflineBanner(),
|
||||
const WebPreviewBanner(),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: _loading
|
||||
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
|
||||
: 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(
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: _loading
|
||||
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
|
||||
: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
child: Text(
|
||||
'왼쪽에서 대화를 선택하세요',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
session.user == null ? '' : '안녕하세요, ${session.user!.displayName}님',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ChatScreen(
|
||||
key: ValueKey(_selectedId),
|
||||
conversationId: _selectedId!,
|
||||
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,
|
||||
),
|
||||
if (me != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: MyUserIdChip(userId: me),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
if (blockedColor) ...[
|
||||
Icon(
|
||||
room.twinDisabledByFlood ? Icons.pause_circle_outline : Icons.block,
|
||||
size: 13,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
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.symmetric(vertical: 48, horizontal: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline, size: 40, color: theme.colorScheme.outline),
|
||||
const SizedBox(height: 12),
|
||||
Text('대화방이 없습니다', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'1) 내 ID를 상대에게 알려 주세요\n'
|
||||
'2) 연락처에 상대의 숫자 ID를 넣고 추가\n'
|
||||
'3) 연락처에서 「대화」또는 행을 탭하세요',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: blockedColor ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// 답장 마감 알림(roadmap.md §2.7-F) — 마감이 지난 스누즈만 배지로
|
||||
// 노출. 탭하면 채팅방을 열지 않고도 바로 해제할 수 있다.
|
||||
if (snoozePastDue) ...[
|
||||
const SizedBox(height: 4),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
onTap: onClearSnooze,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
for (final room in _rooms)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _avatarColor(context, room.id),
|
||||
child: Icon(
|
||||
room.isGroup ? Icons.groups_outlined : Icons.person_outline,
|
||||
color: _onAvatarColor(context, room.id),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
_titleFor(room, me),
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Icon(Icons.alarm, size: 13, color: theme.colorScheme.tertiary),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'답장 마감 (탭하여 해제)',
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.tertiary),
|
||||
if (room.twinDisabledByPeer) ...[
|
||||
Icon(Icons.block, size: 13, color: theme.colorScheme.error),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
_subtitleFor(room, me),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: room.twinDisabledByPeer
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right, size: 20),
|
||||
onTap: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatScreen(
|
||||
conversationId: room.id,
|
||||
title: _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(width: 8),
|
||||
],
|
||||
Icon(Icons.chevron_right, size: 18, color: theme.colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 72),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
|
@ -11,28 +10,42 @@ class DataFlowScreen extends StatelessWidget {
|
|||
|
||||
Widget _section(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required Color tint,
|
||||
required String title,
|
||||
required String body,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassFill(theme.brightness),
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPanel),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
|
||||
),
|
||||
child: Column(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
body,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.45,
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: tint.withValues(alpha: 0.15), shape: BoxShape.circle),
|
||||
child: Icon(icon, size: 18, color: tint),
|
||||
),
|
||||
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: [
|
||||
_section(
|
||||
context,
|
||||
icon: Icons.lock_outline,
|
||||
tint: Colors.green.shade600,
|
||||
title: '기기 안에만 둡니다',
|
||||
body: '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
|
||||
'원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.',
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassFill(theme.brightness),
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPanel),
|
||||
border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('말투 샘플 ${samples.length}개', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
[
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.record_voice_over_outlined, color: theme.colorScheme.primary),
|
||||
title: Text('말투 샘플 ${samples.length}개'),
|
||||
subtitle: Text(
|
||||
[
|
||||
samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
|
||||
session.localDbEncrypted
|
||||
? '저장: drift + SQLCipher(암호화)'
|
||||
: '저장: 메모리/웹 스텁(이 환경에 SQLCipher 없음 — Chrome·Linux 폴백)',
|
||||
].join('\n'),
|
||||
),
|
||||
isThreeLine: true,
|
||||
),
|
||||
),
|
||||
_section(
|
||||
context,
|
||||
icon: Icons.cloud_upload_outlined,
|
||||
tint: Colors.amber.shade800,
|
||||
title: '서버로 보낼 수 있는 것',
|
||||
body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n'
|
||||
'채팅 릴레이: 보낸 메시지 본문\n'
|
||||
'계정: 표시 이름·초대 코드·세션 토큰\n'
|
||||
'푸시: Android는 google-services.json, 웹은 FIREBASE_*+VAPID 빌드 주입 시 실 FCM 토큰 등록 · '
|
||||
'미연결 시 install 플레이스홀더 (docs/fcm-setup.md · web-upgrade W4)',
|
||||
'푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후',
|
||||
),
|
||||
_section(
|
||||
context,
|
||||
icon: Icons.block,
|
||||
tint: theme.colorScheme.error,
|
||||
title: '보내지 않는 것',
|
||||
body: '전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, '
|
||||
'관계 메모의 자동 클라우드 분석',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import 'package:provider/provider.dart';
|
|||
import '../models/models.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'chat_screen.dart';
|
||||
|
||||
/// Post-hoc notification inbox: escalation logs that need human attention.
|
||||
|
|
@ -54,89 +53,98 @@ class _InboxScreenState extends State<InboxScreen> {
|
|||
child: _loading
|
||||
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
|
||||
: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
|
||||
children: [
|
||||
Text(
|
||||
'와카뷰가 보류·차단한 내용과 에스컬레이션 기록입니다. '
|
||||
'이미 보낸 와카뷰 메시지는 해당 대화방에서 되돌릴 수 있습니다.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
|
||||
child: Text(
|
||||
'와카뷰가 보류·차단한 내용과 에스컬레이션 기록입니다. '
|
||||
'이미 보낸 와카뷰 메시지는 해당 대화방에서 되돌릴 수 있습니다.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
),
|
||||
if (_logs.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 56),
|
||||
child: Center(
|
||||
child: Text('새 알림이 없습니다', style: theme.textTheme.titleMedium),
|
||||
padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32),
|
||||
child: Column(
|
||||
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)
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Card(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatScreen(conversationId: log.conversationId),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
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,
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: log.resolved
|
||||
? theme.colorScheme.primaryContainer
|
||||
: theme.colorScheme.errorContainer,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/models.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/primary_gradient_button.dart';
|
||||
|
||||
/// 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.
|
||||
|
|
@ -17,19 +15,16 @@ class OnboardingToneScreen extends StatefulWidget {
|
|||
class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
|
||||
final _samples = List.generate(4, (_) => TextEditingController());
|
||||
var _seeded = false;
|
||||
RelationshipTier _tier = RelationshipTier.formal;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_seeded) return;
|
||||
_seeded = true;
|
||||
final session = context.read<SessionState>();
|
||||
final existing = session.styleExamples;
|
||||
final existing = context.read<SessionState>().styleExamples;
|
||||
for (var i = 0; i < existing.length && i < _samples.length; i++) {
|
||||
_samples[i].text = existing[i];
|
||||
}
|
||||
_tier = session.relationshipTier;
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -46,7 +41,6 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
|
|||
_samples.map((c) => c.text).toList(),
|
||||
markDone: markDone,
|
||||
);
|
||||
await session.setRelationshipTier(_tier);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -66,16 +60,21 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
|
|||
),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 32),
|
||||
padding: const EdgeInsets.all(24),
|
||||
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),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
height: 1.45,
|
||||
),
|
||||
'자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. '
|
||||
'최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
for (var i = 0; i < _samples.length; i++) ...[
|
||||
|
|
@ -85,35 +84,29 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
|
|||
decoration: InputDecoration(
|
||||
labelText: '샘플 ${i + 1}',
|
||||
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: 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),
|
||||
Text(
|
||||
'말투는 나중에 메뉴에서 다시 수정할 수 있습니다.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant),
|
||||
FilledButton(
|
||||
onPressed: () => _save(markDone: true),
|
||||
child: const Text('이 말투로 시작'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -8,17 +8,6 @@ import '../state/session_state.dart';
|
|||
class SessionsScreen extends StatefulWidget {
|
||||
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
|
||||
State<SessionsScreen> createState() => _SessionsScreenState();
|
||||
}
|
||||
|
|
@ -68,16 +57,13 @@ class _SessionsScreenState extends State<SessionsScreen> {
|
|||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !mounted) 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;
|
||||
}
|
||||
if (ok != true) return;
|
||||
try {
|
||||
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();
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _error = '세션 종료 실패 (${e.statusCode})');
|
||||
|
|
@ -109,20 +95,30 @@ class _SessionsScreenState extends State<SessionsScreen> {
|
|||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
),
|
||||
for (final s in _sessions)
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
title: Text(
|
||||
s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}',
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
subtitle: Text(
|
||||
'만료: ${SessionsScreen.formatExpiresAtLocal(s['expires_at'])}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
trailing: IconButton(
|
||||
tooltip: '세션 종료',
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () => _revoke(s),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: s['is_current'] == true
|
||||
? theme.colorScheme.primaryContainer
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
s['is_current'] == true ? Icons.smartphone : Icons.devices_other,
|
||||
color: s['is_current'] == true
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,11 +5,7 @@ import 'package:provider/provider.dart';
|
|||
import '../config.dart';
|
||||
import '../state/session_state.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 {
|
||||
const SignupScreen({super.key});
|
||||
|
||||
|
|
@ -17,26 +13,12 @@ class SignupScreen extends StatefulWidget {
|
|||
State<SignupScreen> createState() => _SignupScreenState();
|
||||
}
|
||||
|
||||
class _SignupScreenState extends State<SignupScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
class _SignupScreenState extends State<SignupScreen> {
|
||||
final _invite = 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
|
||||
void dispose() {
|
||||
_tabs.dispose();
|
||||
_invite.dispose();
|
||||
_name.dispose();
|
||||
super.dispose();
|
||||
|
|
@ -48,16 +30,6 @@ class _SignupScreenState extends State<SignupScreen>
|
|||
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
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionState>();
|
||||
|
|
@ -65,100 +37,90 @@ class _SignupScreenState extends State<SignupScreen>
|
|||
final scheme = theme.colorScheme;
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: IntrinsicHeight(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
const Center(child: BrandMark(size: 72)),
|
||||
const SizedBox(height: 20),
|
||||
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: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Spacer(flex: 3),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
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 brightness = theme.brightness;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPanel),
|
||||
color: AppTheme.glassFill(brightness),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
onTap: onFill,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPanel),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.glassFill(brightness),
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPanel),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppTheme.glassBorder(brightness)),
|
||||
),
|
||||
child: Column(
|
||||
|
|
@ -197,8 +158,7 @@ class _DemoTestPanel extends StatelessWidget {
|
|||
'테스트용 (누구나)',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
letterSpacing: 0.15,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
|
@ -209,7 +169,7 @@ class _DemoTestPanel extends StatelessWidget {
|
|||
'초대 코드 ${AppConfig.demoInviteCode}\n표시 이름 ${AppConfig.demoDisplayName}',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
height: 1.45,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -225,14 +185,6 @@ class _DemoTestPanel extends StatelessWidget {
|
|||
'탭하면 입력란에 채워집니다 · 여러 명이 같은 코드로 가입 가능',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'페어링: 두 명이 각자 가입 → 내 사용자 ID를 교환 → 연락처에 상대 숫자 ID로 대화 시작',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
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 /
|
||||
/// onboarding / conversation list) is ready to pick. Purely presentational —
|
||||
/// no navigation logic lives here, `main.dart` swaps it out once restore()
|
||||
|
|
@ -20,9 +17,31 @@ class SplashScreen extends StatelessWidget {
|
|||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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),
|
||||
GradientText(
|
||||
Text(
|
||||
'와카뷰',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w800),
|
||||
),
|
||||
|
|
@ -33,12 +52,9 @@ class SplashScreen extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.2,
|
||||
color: scheme.primary,
|
||||
),
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5, color: scheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
final obj = await _getObject('/conversations');
|
||||
final list = (obj['conversations'] as List<dynamic>? ?? const []);
|
||||
|
|
@ -122,15 +101,8 @@ class ApiClient {
|
|||
);
|
||||
}
|
||||
|
||||
/// [sinceId]가 있으면 그 id보다 큰 메시지만 받아온다 — 오프라인 큐
|
||||
/// 캐치업(roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11)에서
|
||||
/// 재연결·앱 복귀 시 이미 로드된 마지막 메시지 이후만 다시 받아 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);
|
||||
Future<List<ChatMessage>> listMessages(int conversationId) async {
|
||||
final obj = await _getObject('/conversations/$conversationId/messages');
|
||||
final list = (obj['messages'] as List<dynamic>? ?? const []);
|
||||
return list.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
|
@ -146,34 +118,11 @@ class ApiClient {
|
|||
required String displayName,
|
||||
int? contactUserId,
|
||||
String relationshipNote = '',
|
||||
RelationshipTier? relationshipTier,
|
||||
AutonomyLevel? autonomyLevel,
|
||||
}) async {
|
||||
final json = await _json('POST', '/users/$userId/contacts', body: {
|
||||
'display_name': displayName,
|
||||
if (contactUserId != null) 'contact_user_id': contactUserId,
|
||||
'relationship_note': relationshipNote,
|
||||
if (relationshipTier != null) 'relationship_tier': relationshipTier.name,
|
||||
if (autonomyLevel != null) 'autonomy_level': autonomyLevel.name,
|
||||
});
|
||||
return Contact.fromJson(json);
|
||||
}
|
||||
|
||||
Future<Contact> updateContact({
|
||||
required int userId,
|
||||
required int contactId,
|
||||
required String displayName,
|
||||
int? contactUserId,
|
||||
String relationshipNote = '',
|
||||
RelationshipTier? relationshipTier,
|
||||
AutonomyLevel? autonomyLevel,
|
||||
}) async {
|
||||
final json = await _json('PATCH', '/users/$userId/contacts/$contactId', body: {
|
||||
'display_name': displayName,
|
||||
if (contactUserId != null) 'contact_user_id': contactUserId,
|
||||
'relationship_note': relationshipNote,
|
||||
if (relationshipTier != null) 'relationship_tier': relationshipTier.name,
|
||||
if (autonomyLevel != null) 'autonomy_level': autonomyLevel.name,
|
||||
});
|
||||
return Contact.fromJson(json);
|
||||
}
|
||||
|
|
@ -194,28 +143,12 @@ class ApiClient {
|
|||
required String text,
|
||||
SenderMode senderMode = SenderMode.human,
|
||||
bool approved = false,
|
||||
// 초안 무수정 발송률(PRD.md §5, deploy-checklist.md N4-12): 트윈
|
||||
// 승인-발송 경로에서만 AI 초안 원문을 같이 보내 서버가 diff할 수 있게
|
||||
// 한다. null이면(사람 메시지, 또는 초안 기반이 아닌 발송) 아예 필드를
|
||||
// 넣지 않음 — relationshipTier/autonomyLevel 등과 같은 "값 있을 때만
|
||||
// 포함" 관례.
|
||||
String? originalDraftText,
|
||||
}) async {
|
||||
final json = await _json('POST', '/conversations/$conversationId/messages', body: {
|
||||
'sender_id': senderId,
|
||||
'text': text,
|
||||
'sender_mode': senderMode == SenderMode.twin ? 'twin' : 'human',
|
||||
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);
|
||||
}
|
||||
|
|
@ -237,37 +170,13 @@ class ApiClient {
|
|||
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 {
|
||||
await _json('POST', '/messages/$messageId/retract');
|
||||
}
|
||||
|
||||
Future<TwinSettings> patchTwinSettings({
|
||||
required int userId,
|
||||
required AutonomyLevel autonomyLevel,
|
||||
RelationshipTier? relationshipTier,
|
||||
}) async {
|
||||
Future<TwinSettings> patchTwinSettings(int userId, AutonomyLevel level) async {
|
||||
final json = await _json('PATCH', '/users/$userId/twin-settings', body: {
|
||||
'autonomy_level': autonomyLevel.name,
|
||||
if (relationshipTier != null) 'relationship_tier': relationshipTier.name,
|
||||
'autonomy_level': level.name,
|
||||
});
|
||||
return TwinSettings.fromJson(json);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../config/firebase_web_config.dart';
|
||||
|
||||
/// Resolves a device push token for core-backend registration.
|
||||
///
|
||||
/// Prefer a real FCM registration token when Firebase is configured
|
||||
/// (`google-services.json` on Android, dart-define Web config + VAPID on web).
|
||||
/// Otherwise fall back to a stable `install:` placeholder that the server
|
||||
/// intentionally skips for delivery.
|
||||
class PushTokenService {
|
||||
PushTokenService({
|
||||
this.storeInstallId,
|
||||
this.readInstallId,
|
||||
FirebaseWebConfig? webConfig,
|
||||
}) : webConfig = webConfig ?? FirebaseWebConfig.fromEnvironment();
|
||||
|
||||
/// Persist a newly generated install id (KV / secure storage).
|
||||
final Future<void> Function(String installId)? storeInstallId;
|
||||
|
||||
/// Load a previously stored install id.
|
||||
final Future<String?> Function()? readInstallId;
|
||||
|
||||
/// Injected for tests; production uses [FirebaseWebConfig.fromEnvironment].
|
||||
final FirebaseWebConfig webConfig;
|
||||
|
||||
Future<({String token, String platform, bool isFcm})> resolve() async {
|
||||
final platform = _platformLabel();
|
||||
final fcm = await _tryFirebaseToken();
|
||||
if (fcm != null && fcm.isNotEmpty) {
|
||||
return (token: fcm, platform: platform, isFcm: true);
|
||||
}
|
||||
final installId = await _ensureInstallId();
|
||||
return (token: 'install:$installId', platform: platform, isFcm: false);
|
||||
}
|
||||
|
||||
Future<String?> _tryFirebaseToken() async {
|
||||
if (kIsWeb) {
|
||||
return _tryFirebaseWebToken();
|
||||
}
|
||||
if (defaultTargetPlatform != TargetPlatform.android &&
|
||||
defaultTargetPlatform != TargetPlatform.iOS) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (Firebase.apps.isEmpty) {
|
||||
await Firebase.initializeApp();
|
||||
}
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
await messaging.requestPermission(alert: true, badge: true, sound: true);
|
||||
final token = await messaging.getToken();
|
||||
if (token == null || token.isEmpty) return null;
|
||||
return token;
|
||||
} catch (e) {
|
||||
debugPrint('FCM token unavailable (using install placeholder): $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final existing = await readInstallId?.call();
|
||||
if (existing != null && existing.isNotEmpty) return existing;
|
||||
final rand = Random.secure();
|
||||
final installId =
|
||||
List.generate(16, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')).join();
|
||||
await storeInstallId?.call(installId);
|
||||
return installId;
|
||||
}
|
||||
|
||||
String _platformLabel() {
|
||||
if (kIsWeb) return 'web';
|
||||
if (defaultTargetPlatform == TargetPlatform.android) return 'android';
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS) return 'ios';
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
|
@ -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} — 답장할 시간입니다',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -5,105 +5,31 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
|||
|
||||
import '../config.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`).
|
||||
///
|
||||
/// 오프라인 큐 캐치업(roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11):
|
||||
/// 소켓이 끊기면(백그라운드, 네트워크 hiccup 등) `onDone`/`onError`에서 그냥
|
||||
/// 멈추는 게 아니라 [nextReconnectDelay]로 계산한 backoff 후 재연결을 시도한다.
|
||||
/// 재연결에 성공하면 [reconnects] 스트림으로 신호를 보내 — 화면(chat_screen.dart)이
|
||||
/// 그 신호를 받아 REST `since_id` 캐치업을 트리거할 수 있게 한다.
|
||||
class ConversationSocket {
|
||||
ConversationSocket(this.conversationId);
|
||||
|
||||
final int conversationId;
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _channelSub;
|
||||
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;
|
||||
|
||||
/// 재연결에 성공할 때마다 한 번씩 이벤트가 발생한다 (최초 연결은 포함하지
|
||||
/// 않음 — 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() {
|
||||
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 channel = WebSocketChannel.connect(uri);
|
||||
_channel = channel;
|
||||
_everConnected = true;
|
||||
channel.ready.then((_) {
|
||||
if (_disposed) return;
|
||||
_setLinkState(WsLinkState.live);
|
||||
}).catchError((Object _) {
|
||||
// onError / onDone will schedule reconnect.
|
||||
});
|
||||
_channelSub = channel.stream.listen(
|
||||
_channel = WebSocketChannel.connect(uri);
|
||||
_channel!.stream.listen(
|
||||
(raw) {
|
||||
// 데이터가 왔다는 건 연결이 살아 있다는 뜻 — backoff를 리셋한다.
|
||||
_reconnectAttempt = 0;
|
||||
_setLinkState(WsLinkState.live);
|
||||
if (raw is! String) return;
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
_controller.add(decoded);
|
||||
}
|
||||
},
|
||||
onError: (Object e, StackTrace st) {
|
||||
_controller.addError(e, st);
|
||||
_scheduleReconnect();
|
||||
},
|
||||
onDone: _scheduleReconnect,
|
||||
cancelOnError: false,
|
||||
onError: _controller.addError,
|
||||
onDone: () {},
|
||||
);
|
||||
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) {
|
||||
|
|
@ -121,24 +47,7 @@ class ConversationSocket {
|
|||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_disposed = true;
|
||||
_reconnectTimer?.cancel();
|
||||
await _channelSub?.cancel();
|
||||
await _channel?.sink.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 '다시 연결 중…';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
|
@ -6,28 +7,17 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||
import '../db/app_database.dart';
|
||||
import '../models/models.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/push_token_service.dart';
|
||||
import '../services/snooze_controller.dart';
|
||||
import '../services/snooze_notification_service.dart';
|
||||
|
||||
class SessionState extends ChangeNotifier {
|
||||
SessionState({ApiClient? api, AppDatabase? db, SnoozeNotificationScheduler? snoozeScheduler})
|
||||
SessionState({ApiClient? api, AppDatabase? db})
|
||||
: _api = api ?? ApiClient(),
|
||||
_db = db,
|
||||
_snoozeScheduler = snoozeScheduler ?? LocalSnoozeNotificationScheduler();
|
||||
_db = db;
|
||||
|
||||
final ApiClient _api;
|
||||
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;
|
||||
AutonomyLevel autonomyLevel = AutonomyLevel.L0;
|
||||
RelationshipTier relationshipTier = RelationshipTier.formal;
|
||||
String? error;
|
||||
bool loading = false;
|
||||
bool toneOnboardingDone = false;
|
||||
|
|
@ -75,8 +65,7 @@ class SessionState extends ChangeNotifier {
|
|||
} catch (e) {
|
||||
// Linux CI / hosts without libsqlcipher.so — fall back to memory so the
|
||||
// app still boots; Android release path uses SQLCipher.
|
||||
// Web uses SharedPreferences-backed AppDatabase via conditional import
|
||||
// (docs/web-upgrade.md N4-W1); this catch is for native open failures.
|
||||
// Web uses an in-memory stub via conditional import (no FFI).
|
||||
debugPrint('Encrypted DB unavailable ($e); using in-memory fallback');
|
||||
localDbEncrypted = false;
|
||||
return AppDatabase.memory();
|
||||
|
|
@ -110,7 +99,15 @@ class SessionState extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
try {
|
||||
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();
|
||||
} on ApiException catch (e) {
|
||||
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 {
|
||||
final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||
styleExamples = cleaned;
|
||||
|
|
@ -207,9 +139,8 @@ class SessionState extends ChangeNotifier {
|
|||
Future<void> setAutonomy(AutonomyLevel level) async {
|
||||
if (user == null) return;
|
||||
try {
|
||||
final settings = await _api.patchTwinSettings(userId: user!.id, autonomyLevel: level);
|
||||
final settings = await _api.patchTwinSettings(user!.id, level);
|
||||
autonomyLevel = settings.autonomyLevel;
|
||||
relationshipTier = settings.relationshipTier;
|
||||
notifyListeners();
|
||||
} on ApiException catch (e) {
|
||||
error = '자율성 변경 실패 (${e.statusCode})';
|
||||
|
|
@ -217,43 +148,22 @@ 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
|
||||
/// `install:` placeholder (server skips placeholders for delivery).
|
||||
/// Registers a stable install id as the push token until Firebase Messaging
|
||||
/// is wired with a real FCM registration token (roadmap B).
|
||||
Future<void> _registerDeviceTokenBestEffort() async {
|
||||
if (user == null) return;
|
||||
try {
|
||||
_db ??= await _openDb();
|
||||
final resolved = await PushTokenService(
|
||||
readInstallId: () => _db!.getKv(_kDeviceInstallId),
|
||||
storeInstallId: (id) => _db!.setKv(_kDeviceInstallId, id),
|
||||
).resolve();
|
||||
var installId = await _db!.getKv(_kDeviceInstallId);
|
||||
if (installId == null || installId.isEmpty) {
|
||||
final rand = Random.secure();
|
||||
installId = List.generate(16, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')).join();
|
||||
await _db!.setKv(_kDeviceInstallId, installId);
|
||||
}
|
||||
await _api.registerDeviceToken(
|
||||
userId: user!.id,
|
||||
token: resolved.token,
|
||||
platform: resolved.platform,
|
||||
);
|
||||
debugPrint(
|
||||
resolved.isFcm
|
||||
? 'device token registered (FCM)'
|
||||
: 'device token registered (install placeholder)',
|
||||
token: 'install:$installId',
|
||||
platform: defaultTargetPlatform == TargetPlatform.android ? 'android' : 'other',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('device token register skipped: $e');
|
||||
|
|
|
|||
|
|
@ -1,261 +1,194 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
/// Messenger UI for 와카뷰 — iMessage-inspired light default + soft charcoal dark.
|
||||
/// Light: white stage, blue mine bubbles, gray peer bubbles.
|
||||
/// Dark: elevated charcoal (not pure black), softer blue accents.
|
||||
/// Central design tokens for 와카뷰 (Ykavu) — soft-gradient + glassmorphism
|
||||
/// direction. `backgroundGradient` paints behind every screen (wired into
|
||||
/// `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 {
|
||||
AppTheme._();
|
||||
|
||||
// Light — iOS Messages / white messenger
|
||||
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);
|
||||
static const _seed = Color(0xFF7C6FF0);
|
||||
|
||||
// Dark — soft charcoal (not #000)
|
||||
static const canvasDark = Color(0xFF141418);
|
||||
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);
|
||||
static Color twinAccent(Brightness brightness) =>
|
||||
brightness == Brightness.dark ? Colors.amber.shade300 : Colors.amber.shade800;
|
||||
|
||||
/// iMessage-like blue for mine bubbles & primary actions.
|
||||
static const accentLight = Color(0xFF007AFF);
|
||||
static const accentDark = Color(0xFF0A84FF);
|
||||
static const accentSoftLight = Color(0xFFD6E8FF);
|
||||
static const accentSoftDark = Color(0xFF163A66);
|
||||
|
||||
static const twinAmberLight = Color(0xFFB08A4A);
|
||||
static const twinAmberDark = Color(0xFFD4B06A);
|
||||
|
||||
/// Compat for older call sites.
|
||||
static const teal = accentLight;
|
||||
static const tealBright = accentDark;
|
||||
static const canvasDarkCompat = canvasDark;
|
||||
static const brandGradient = LinearGradient(
|
||||
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],
|
||||
/// Soft diagonal gradient painted behind every screen. Light: pastel
|
||||
/// lavender → sky → pink. Dark: desaturated indigo → navy → plum.
|
||||
static LinearGradient backgroundGradient(Brightness brightness) {
|
||||
if (brightness == Brightness.dark) {
|
||||
return const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF1B1730), Color(0xFF161C34), Color(0xFF2A1830)],
|
||||
);
|
||||
}
|
||||
return const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFEDE9FE), Color(0xFFE0F2FE), Color(0xFFFCE7F3)],
|
||||
);
|
||||
}
|
||||
|
||||
static Color glassFill(Brightness b) => b == Brightness.dark ? surfaceDark : surfaceLight;
|
||||
static Color glassBorder(Brightness b) => b == Brightness.dark ? lineDark : lineLight;
|
||||
static Color glassShadow(Brightness b) => Colors.transparent;
|
||||
/// "Glass" fill for cards/panels — semi-transparent so the gradient
|
||||
/// behind still reads through, with a faint light border for the classic
|
||||
/// 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 mineBubble(Brightness b) => b == Brightness.dark ? accentDark : accentLight;
|
||||
static Color mineBubbleFg(Brightness b) => Colors.white;
|
||||
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 Color glassBorder(Brightness brightness) => brightness == Brightness.dark
|
||||
? Colors.white.withOpacity(0.10)
|
||||
: Colors.white.withOpacity(0.65);
|
||||
|
||||
static ThemeData light() => _build(Brightness.light);
|
||||
static ThemeData dark() => _build(Brightness.dark);
|
||||
|
||||
static ThemeData _build(Brightness brightness) {
|
||||
final isDark = brightness == Brightness.dark;
|
||||
final primary = isDark ? accentDark : accentLight;
|
||||
final onPrimary = Colors.white;
|
||||
final ink = isDark ? inkDark : inkLight;
|
||||
final muted = isDark ? mutedDark : mutedLight;
|
||||
final line = isDark ? lineDark : lineLight;
|
||||
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 scheme = ColorScheme.fromSeed(seedColor: _seed, brightness: brightness);
|
||||
final glass = glassFill(brightness);
|
||||
final glassEdge = glassBorder(brightness);
|
||||
final glassShape = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
side: BorderSide(color: glassEdge, width: 1),
|
||||
);
|
||||
|
||||
final textTheme = _textTheme(GoogleFonts.plusJakartaSansTextTheme(), ink);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: brightness,
|
||||
colorScheme: scheme,
|
||||
// Transparent so the gradient painted by MaterialApp.builder shows
|
||||
// through behind every Scaffold.
|
||||
scaffoldBackgroundColor: Colors.transparent,
|
||||
visualDensity: VisualDensity.standard,
|
||||
textTheme: textTheme,
|
||||
primaryTextTheme: textTheme,
|
||||
visualDensity: VisualDensity.comfortable,
|
||||
textTheme: _textTheme(),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: surface.withValues(alpha: isDark ? 0.94 : 0.88),
|
||||
foregroundColor: ink,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: scheme.onSurface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: true,
|
||||
titleTextStyle: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600, letterSpacing: -0.2),
|
||||
centerTitle: false,
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: scheme.onSurface,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
color: surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(rPanel),
|
||||
side: BorderSide(color: line.withValues(alpha: isDark ? 0.9 : 0.65)),
|
||||
),
|
||||
color: glass,
|
||||
shape: glassShape,
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
listTileTheme: ListTileThemeData(
|
||||
iconColor: muted,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rPanel)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
minVerticalPadding: 10,
|
||||
iconColor: scheme.onSurfaceVariant,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
),
|
||||
dividerTheme: DividerThemeData(color: line, space: 1, thickness: 0.5),
|
||||
dividerTheme: DividerThemeData(color: scheme.outlineVariant, space: 32, thickness: 1),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: isDark ? soft : surface,
|
||||
fillColor: glass,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(rInput),
|
||||
borderSide: BorderSide(color: line.withValues(alpha: 0.7)),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: glassEdge, width: 1),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(rInput),
|
||||
borderSide: BorderSide(color: line.withValues(alpha: 0.7)),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: glassEdge, width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(rInput),
|
||||
borderSide: BorderSide(color: primary, width: 1.4),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: scheme.primary, width: 1.6),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(rInput),
|
||||
borderSide: BorderSide(color: scheme.error),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: scheme.error, width: 1.2),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(rInput),
|
||||
borderSide: BorderSide(color: scheme.error, width: 1.4),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: scheme.error, width: 1.6),
|
||||
),
|
||||
hintStyle: TextStyle(color: muted.withValues(alpha: 0.85)),
|
||||
labelStyle: TextStyle(color: muted, fontWeight: FontWeight.w500),
|
||||
hintStyle: TextStyle(color: scheme.onSurfaceVariant.withOpacity(0.7)),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: onPrimary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
minimumSize: const Size(0, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rButton)),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: primary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
minimumSize: const Size(0, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rButton)),
|
||||
side: BorderSide(color: primary.withValues(alpha: 0.45)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: primary,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
),
|
||||
iconButtonTheme: IconButtonThemeData(
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: primary,
|
||||
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(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
side: BorderSide(color: line),
|
||||
backgroundColor: soft,
|
||||
labelStyle: TextStyle(color: ink, fontWeight: FontWeight.w500, fontSize: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(color: glassEdge, width: 1),
|
||||
),
|
||||
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(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: onPrimary,
|
||||
elevation: 0,
|
||||
backgroundColor: scheme.primary,
|
||||
foregroundColor: scheme.onPrimary,
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: isDark ? softFillDark : inkLight,
|
||||
contentTextStyle: TextStyle(color: isDark ? inkDark : Colors.white),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rPanel)),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
bannerTheme: MaterialBannerThemeData(
|
||||
backgroundColor: glass,
|
||||
padding: const EdgeInsets.all(16),
|
||||
),
|
||||
dialogTheme: DialogThemeData(
|
||||
backgroundColor: surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
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),
|
||||
),
|
||||
backgroundColor: brightness == Brightness.dark
|
||||
? const Color(0xFF211D3D)
|
||||
: Colors.white.withOpacity(0.92),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
),
|
||||
progressIndicatorTheme: ProgressIndicatorThemeData(color: scheme.primary),
|
||||
);
|
||||
}
|
||||
|
||||
static TextTheme _textTheme(TextTheme base, Color ink) {
|
||||
TextStyle s(TextStyle? t, {FontWeight? w, double? size, double? ls, double? h}) =>
|
||||
(t ?? const TextStyle()).copyWith(color: ink, fontWeight: w, fontSize: size, letterSpacing: ls, height: h);
|
||||
|
||||
return base.copyWith(
|
||||
displayLarge: s(base.displayLarge, w: FontWeight.w700, size: 34, ls: -0.8),
|
||||
displaySmall: s(base.displaySmall, w: FontWeight.w700, size: 28, ls: -0.5),
|
||||
headlineSmall: s(base.headlineSmall, w: FontWeight.w600, size: 22, ls: -0.3),
|
||||
titleLarge: s(base.titleLarge, w: FontWeight.w600, size: 17, ls: -0.2),
|
||||
titleMedium: s(base.titleMedium, w: FontWeight.w600, size: 16),
|
||||
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),
|
||||
static TextTheme _textTheme() {
|
||||
return const TextTheme(
|
||||
displaySmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.5),
|
||||
headlineSmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.3),
|
||||
titleLarge: TextStyle(fontWeight: FontWeight.w700),
|
||||
titleMedium: TextStyle(fontWeight: FontWeight.w700),
|
||||
titleSmall: TextStyle(fontWeight: FontWeight.w700),
|
||||
bodyLarge: TextStyle(height: 1.4),
|
||||
bodyMedium: TextStyle(height: 1.4),
|
||||
labelLarge: TextStyle(fontWeight: FontWeight.w700),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,10 @@ import 'package:flutter/material.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 {
|
||||
const GradientBackdrop({super.key, required this.child});
|
||||
|
||||
|
|
@ -10,9 +13,8 @@ class GradientBackdrop extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final brightness = Theme.of(context).brightness;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(gradient: AppTheme.backgroundGradient(brightness)),
|
||||
decoration: BoxDecoration(gradient: AppTheme.backgroundGradient(Theme.of(context).brightness)),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,25 +3,19 @@ import 'package:flutter/material.dart';
|
|||
import '../models/models.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 {
|
||||
const MessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.isMine,
|
||||
this.onRetract,
|
||||
this.alreadyRated = false,
|
||||
this.onFeedback,
|
||||
});
|
||||
|
||||
final ChatMessage message;
|
||||
final bool isMine;
|
||||
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) {
|
||||
final local = dt.toLocal();
|
||||
|
|
@ -37,55 +31,52 @@ class MessageBubble extends StatelessWidget {
|
|||
final twin = message.isTwin;
|
||||
final retracted = message.retracted;
|
||||
final accent = AppTheme.twinAccent(theme.brightness);
|
||||
final brightness = theme.brightness;
|
||||
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
if (retracted) {
|
||||
bg = scheme.surfaceContainerHigh;
|
||||
fg = scheme.onSurfaceVariant;
|
||||
} else if (twin) {
|
||||
bg = brightness == Brightness.dark
|
||||
? const Color(0xFF2A2418)
|
||||
: const Color(0xFFFFF6E8);
|
||||
fg = scheme.onSurface;
|
||||
} else if (isMine) {
|
||||
bg = AppTheme.mineBubble(brightness);
|
||||
fg = AppTheme.mineBubbleFg(brightness);
|
||||
bg = scheme.primaryContainer;
|
||||
fg = scheme.onPrimaryContainer;
|
||||
} else {
|
||||
bg = AppTheme.peerBubble(brightness);
|
||||
bg = scheme.surfaceContainerHighest;
|
||||
fg = scheme.onSurface;
|
||||
}
|
||||
|
||||
final radius = BorderRadius.only(
|
||||
topLeft: const Radius.circular(AppTheme.rBubble),
|
||||
topRight: const Radius.circular(AppTheme.rBubble),
|
||||
bottomLeft: Radius.circular(isMine ? AppTheme.rBubble : 6),
|
||||
bottomRight: Radius.circular(isMine ? 6 : AppTheme.rBubble),
|
||||
topLeft: const Radius.circular(18),
|
||||
topRight: const Radius.circular(18),
|
||||
bottomLeft: Radius.circular(isMine ? 18 : 4),
|
||||
bottomRight: Radius.circular(isMine ? 4 : 18),
|
||||
);
|
||||
|
||||
final bubble = Container(
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.74),
|
||||
padding: const EdgeInsets.fromLTRB(14, 9, 14, 7),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: radius,
|
||||
border: twin ? Border.all(color: accent.withValues(alpha: 0.35)) : null,
|
||||
),
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.76),
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 8),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: radius),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (twin)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 3),
|
||||
child: Text(
|
||||
'와카뷰',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: accent,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.15,
|
||||
),
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.auto_awesome, size: 13, color: accent),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'와카뷰',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: accent,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (retracted)
|
||||
|
|
@ -103,15 +94,12 @@ class MessageBubble extends StatelessWidget {
|
|||
else
|
||||
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(
|
||||
_time(message.createdAt),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: fg.withValues(alpha: isMine && !twin && !retracted ? 0.7 : 0.5),
|
||||
fontSize: 10,
|
||||
),
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: fg.withOpacity(0.55), fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -121,7 +109,12 @@ class MessageBubble extends StatelessWidget {
|
|||
crossAxisAlignment: isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
bubble,
|
||||
twin
|
||||
? CustomPaint(
|
||||
painter: _DashedRRectPainter(color: accent, radius: radius),
|
||||
child: bubble,
|
||||
)
|
||||
: bubble,
|
||||
if (twin && isMine && !retracted && onRetract != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2, right: 4, left: 4),
|
||||
|
|
@ -138,54 +131,11 @@ class MessageBubble extends StatelessWidget {
|
|||
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(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 10),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
||||
child: Align(
|
||||
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Shows the signed-in numeric user id with one-tap copy.
|
||||
class MyUserIdChip extends StatelessWidget {
|
||||
const MyUserIdChip({super.key, required this.userId, this.compact = false});
|
||||
|
|
@ -31,23 +29,18 @@ class MyUserIdChip extends StatelessWidget {
|
|||
icon: const Icon(Icons.badge_outlined),
|
||||
);
|
||||
}
|
||||
final brightness = theme.brightness;
|
||||
return Material(
|
||||
color: AppTheme.glassFill(brightness),
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPanel),
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPanel),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => _copy(context),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPanel),
|
||||
border: Border.all(color: AppTheme.glassBorder(brightness)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.badge_outlined, size: 18, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 10),
|
||||
Icon(Icons.badge_outlined, size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -61,18 +54,17 @@ class MyUserIdChip extends StatelessWidget {
|
|||
Text(
|
||||
'$userId',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.3,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'복사',
|
||||
'탭하여 복사',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export 'web_connectivity_stub.dart'
|
||||
if (dart.library.html) 'web_connectivity_web.dart';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,14 +9,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "88.0.0"
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _flutterfire_internals
|
||||
sha256: "460e9e684edb461d85498fc166ff8416f303f22216838d302d80676b348c6a4c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.75"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -177,14 +169,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.14"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -225,54 +209,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
firebase_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_core
|
||||
sha256: "6f22d1c62e0c20976f02cd842c7b7cd3c0f561cc2052586411871045c08860c9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.1"
|
||||
firebase_core_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
sha256: f74d1d6fabccf7743b0144c2ed363d81049e258e22428ebb6fb0faec2fa7d938
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
firebase_core_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_web
|
||||
sha256: ddab99d709b8c27dd47576eb05a1e719e07a2aa45c009a49ae92ac4d2a8ca555
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.9.1"
|
||||
firebase_messaging:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_messaging
|
||||
sha256: "30ad2d59bcd86117dc49d278c8998a0fb390c5a3202f6e43e4bd215d3f1d0556"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "16.4.3"
|
||||
firebase_messaging_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging_platform_interface
|
||||
sha256: "4d144cb42b9a5a42855596be2d7682d32c50169f42e7df2cb3278ecf935e7d63"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.2"
|
||||
firebase_messaging_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging_web
|
||||
sha256: fcd25d0b9da55766ef4d28ae05a7460f5189635d6b42607adcd9f08818fd35f0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.3"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -294,38 +230,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -592,14 +496,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -821,14 +717,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: timezone
|
||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -901,14 +789,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -919,4 +799,4 @@ packages:
|
|||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.0-0 <4.0.0"
|
||||
flutter: ">=3.32.0"
|
||||
flutter: ">=3.29.0"
|
||||
|
|
|
|||
|
|
@ -45,10 +45,6 @@ dependencies:
|
|||
flutter_secure_storage: ^10.3.1
|
||||
sqlcipher_flutter_libs: ^0.6.8
|
||||
google_fonts: ^6.3.2
|
||||
firebase_core: ^4.12.1
|
||||
firebase_messaging: ^16.4.3
|
||||
flutter_local_notifications: ^20.1.0
|
||||
timezone: ^0.10.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -15,34 +15,4 @@ void main() {
|
|||
await db.replaceToneSamples(['하나만']);
|
||||
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});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -13,26 +13,9 @@ void main() {
|
|||
expect(c.id, 3);
|
||||
expect(c.userIds, [1, 2]);
|
||||
expect(c.twinDisabledByPeer, isTrue);
|
||||
expect(c.twinDisabledByFlood, isFalse);
|
||||
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', () {
|
||||
final log = EscalationLogEntry.fromJson({
|
||||
'id': 9,
|
||||
|
|
@ -50,61 +33,5 @@ void main() {
|
|||
'relationship_note': '대학',
|
||||
});
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue