diff --git a/.env.example b/.env.example index 070cc05..19052ef 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,10 @@ # Copy to .env and fill in values. Never commit .env. # Get a key from Google AI Studio: https://aistudio.google.com/apikey GEMINI_API_KEY= + +# core-backend privileged endpoints (/invites, /admin/metrics, /admin/dashboard, /admin/push-test) +ADMIN_API_TOKEN= + +# 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= diff --git a/AGENTS.md b/AGENTS.md index f10ddc7..d9f85e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ # Project Instructions — hikikomori / 분신 (가칭) -This repository is currently a **planning-docs repo** for an AI-twin messenger -("나를 대신해 남과 대화하는 AI 분신"). There is no application code yet. +This repository is an AI-twin messenger ("나를 대신해 남과 대화하는 AI 분신") with +Phase 1 app code (`core-backend/`, `ai-service/`, `mobile/`) plus planning docs. -Canonical docs live under `docs/`. Prefer linking to them over copying content -into prompts or new files. +Canonical product decisions live under `docs/`. Prefer linking to them over +copying content into prompts or new files. ## Document authority @@ -18,9 +18,10 @@ When documents disagree, follow this order: Notes: -- Q1~Q7 in `decision-log.md` are **제안 (tentative)**, not final meeting - decisions. Do not silently reverse them. If a change is required, update - `decision-log.md` and all 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** diff --git a/CLAUDE.md b/CLAUDE.md index c17c295..4eb633c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,10 @@ Follow the project instructions in [@AGENTS.md](./AGENTS.md). Quick context: -- This repo started as planning docs only; Phase 1 app-build has now begun. - Check `docs/roadmap.md` Phase 1 checklist before starting app-build work. -- Working product name: **분신** (tentative). -- Source of working decisions: `docs/decision-log.md` (status: 제안). +- Phase 1 A~C are in place (`core-backend/`, `ai-service/`, `mobile/`). Next is + **D — human PoC** (`docs/roadmap.md`). Do not start PoC early or invent §3 defaults. +- Working product name: **분신** (가칭 확정). +- Source of decisions: `docs/decision-log.md` (Q1~Q7 **확정**; PoC sub-questions open). - v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first. - Hard bans for v1: L3/L4, OS-layer over third-party messengers, B2B. - Never weaken escalation, twin badge, peer veto, or undo. diff --git a/README.md b/README.md index ceea6e4..68c4735 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ ## 문서 -- 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) +- 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) (Q1~Q7 **확정**) - Vision / PRD / 기술설계: [`docs/vision.md`](./docs/vision.md) · [`docs/PRD.md`](./docs/PRD.md) · [`docs/tech-design.md`](./docs/tech-design.md) - 로드맵 (작업 체크리스트): [`docs/roadmap.md`](./docs/roadmap.md) +- 베타 직전(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 에이전트 규칙 @@ -26,9 +27,9 @@ ## 현재 단계 -- 기획 문서 + Phase 1 **서버(Go/Python)** + Flutter **클라이언트 골격**까지 진행됨 -- Q1~Q7은 아직 **잠정(제안)** — 회의 확정 전 -- **사람 대상 PoC #1/#3·Q3 인터뷰는 맨 마지막 작업**으로 미룸 (`docs/roadmap.md` §3) +- Phase 1 **A~C**까지 반영됨 (서버·Flutter·베타 직전 문서/배포 경로) +- 다음: **D — 사람 PoC #1/#3·Q3 인터뷰** (`docs/roadmap.md` §3, 맨 마지막) +- 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입 ## 로컬 실행 (요약) diff --git a/ai-service/app/generation.py b/ai-service/app/generation.py index bfc6d6f..397b3e4 100644 --- a/ai-service/app/generation.py +++ b/ai-service/app/generation.py @@ -9,6 +9,7 @@ import os from pathlib import Path from .escalation_filter import check as check_escalation +from .identity import check_identity_question def load_dotenv_if_present(): @@ -39,6 +40,8 @@ SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화 - 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 이 문장만 출력한다: \ [ESCALATE] 이 내용은 본인 확인이 필요합니다. - 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다. +- 상대가 "본인이야/분신이야?"처럼 정체를 물으면 분신임을 정직하게 밝힌다 \ +(서버가 고정 문구로 먼저 처리하지만, 여기까지 온 경우에도 분신이라고 답한다). (이 지침은 2차 방어선이다 -- 1차는 escalation_filter.py의 규칙 기반 하드 게이트로, 이미 걸러진 내용은 여기까지 오지 않는다. 이 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)""" @@ -65,10 +68,16 @@ def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key "no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing). "ok": text is the generated draft. """ - gate = check_escalation(last_incoming_text(context_lines)) + incoming = last_incoming_text(context_lines) + gate = check_escalation(incoming) if gate.escalate: return "escalate", gate.reason + # Honest identity answers must be fixed copy, not LLM-invented (AGENTS.md). + identity = check_identity_question(incoming) + if identity.matched: + return "ok", identity.reply + api_key = api_key or os.environ.get("GEMINI_API_KEY") if not api_key: return "no_key", build_user_prompt(style_examples, context_lines) diff --git a/ai-service/app/identity.py b/ai-service/app/identity.py new file mode 100644 index 0000000..cfffc45 --- /dev/null +++ b/ai-service/app/identity.py @@ -0,0 +1,40 @@ +"""Fixed honest-identity answers (AGENTS.md / PRD §3.1). + +When the peer asks whether they are talking to the human or the twin, the +twin must answer as a twin with a stable phrase — not invent wording via LLM. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +# Fixed product copy — tune only after PoC #3; do not invent alternate defaults. +IDENTITY_REPLY = ( + "지금은 분신이 답하고 있어. 본인이랑 바로 이야기하고 싶으면 그렇게 말해줘." +) + +# Incoming peer questions that should trigger the fixed identity reply. +_IDENTITY_QUESTION = re.compile( + r"(" + r"본인\s*(이야|인가요|이니|임\??|맞아|맞음)" + r"|분신\s*(이야|인가요|이니|임\??|맞아|맞음)" + r"|지금\s*(본인|분신)" + r"|진짜\s*(야|임|인가요)" + r"|너\s*(사람|본인|분신)" + r")", + re.IGNORECASE, +) + + +@dataclass +class IdentityHit: + matched: bool + reply: str = "" + + +def check_identity_question(text: str) -> IdentityHit: + if not text or not text.strip(): + return IdentityHit(False) + if _IDENTITY_QUESTION.search(text.strip()): + return IdentityHit(True, IDENTITY_REPLY) + return IdentityHit(False) diff --git a/ai-service/tests/test_identity.py b/ai-service/tests/test_identity.py new file mode 100644 index 0000000..1170ba9 --- /dev/null +++ b/ai-service/tests/test_identity.py @@ -0,0 +1,29 @@ +from app.identity import IDENTITY_REPLY, check_identity_question +from app.generation import draft_reply + + +def test_identity_phrases_match(): + for q in [ + "지금 본인이야 분신이야?", + "분신이야?", + "너 본인 맞아?", + "진짜야?", + ]: + hit = check_identity_question(q) + assert hit.matched, q + assert hit.reply == IDENTITY_REPLY + + +def test_identity_non_match(): + assert not check_identity_question("오늘 저녁 뭐 먹을래?").matched + + +def test_draft_returns_fixed_identity_without_llm(monkeypatch): + # Ensure we never need an API key for this path. + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + status, text = draft_reply( + ["ㅇㅇ 알겠음"], + ["상대: 지금 본인이야 분신이야?"], + ) + assert status == "ok" + assert text == IDENTITY_REPLY diff --git a/core-backend/README.md b/core-backend/README.md index 3d47140..1f9bebc 100644 --- a/core-backend/README.md +++ b/core-backend/README.md @@ -29,7 +29,22 @@ export AI_SERVICE_URL="http://localhost:8001" # 기본값도 이 주소 - 가입 `POST /auth/signup` / 로그인 `POST /auth/login` → `{token}` (Bearer) - 사용자 스코프 API(`PATCH /users/:id/...`, contacts, conversations 목록 등)는 Bearer 필요 -- `/invites`, `/admin/metrics`는 `Authorization: Bearer $ADMIN_API_TOKEN` +- `/invites`, `/admin/metrics`, `/admin/dashboard`, `/admin/push-test`는 + `Authorization: Bearer $ADMIN_API_TOKEN` (`/admin/dashboard`는 `?token=` 쿼리 가능) +- 초대 운영: `POST /invites`(`note`, `expires_in_days`, `count`), `GET /invites`, + `POST /invites/:code/revoke` — 절차는 `docs/invite-ops.md` + +메시지 생성(`POST /conversations/:id/messages`)은 전체 메시지 JSON을 반환한다 +(`id`, `conversation_id`, `sender_id`, `sender_mode`, `text`, `retracted`, `created_at`). + +Phase 1 B (베타 품질) 추가분: +- draft/escalate 지연·오류율 → `/admin/metrics`의 `draft_*` / `escalate_*` 필드 (프로세스 메모리) +- `GET /admin/dashboard` — 최소 HTML 대시보드 +- `POST|GET /users/:id/device-tokens` — FCM 토큰 등록(전송은 후속) +- `GET /users/:id/sessions` — 활성 세션 목록(멀티 디바이스 1차) +- `DELETE /users/:id/sessions/:sessionId` — 세션 종료 +- `POST /admin/push-test` — FCM 스모크 테스트 (`FCM_SERVER_KEY` 필요, 없으면 skipped) +- 에스컬레이션 시 사용자 디바이스로 푸시 시도 (`notifyUser`) ## 테스트 diff --git a/core-backend/b_routes.go b/core-backend/b_routes.go new file mode 100644 index 0000000..dee1b01 --- /dev/null +++ b/core-backend/b_routes.go @@ -0,0 +1,245 @@ +package main + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type registerDeviceRequest struct { + Token string `json:"token" binding:"required"` + Platform string `json:"platform"` +} + +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, + "created_at": m.CreatedAt, + } +} + +func registerBRoutes(r *gin.Engine, db *gorm.DB) { + // Minimal HTML dashboard for operators (roadmap B). JSON stays on /admin/metrics. + r.GET("/admin/dashboard", func(c *gin.Context) { + if !requireAdmin(c) { + return + } + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, adminDashboardHTML) + }) + + r.POST("/users/:id/device-tokens", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + if !requireSelf(c, db, userID) { + return + } + var req registerDeviceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + platform := req.Platform + if platform == "" { + platform = "android" + } + var existing DeviceToken + err := db.Where("token = ?", req.Token).First(&existing).Error + if err == nil { + existing.UserID = userID + existing.Platform = platform + existing.UpdatedAt = time.Now() + db.Save(&existing) + c.JSON(http.StatusOK, gin.H{"id": existing.ID, "token": existing.Token, "platform": existing.Platform}) + return + } + row := DeviceToken{UserID: userID, Token: req.Token, Platform: platform} + if err := db.Create(&row).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"id": row.ID, "token": row.Token, "platform": row.Platform}) + }) + + r.GET("/users/:id/device-tokens", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + if !requireSelf(c, db, userID) { + return + } + var tokens []DeviceToken + db.Where("user_id = ?", userID).Order("id desc").Find(&tokens) + out := make([]gin.H, 0, len(tokens)) + for _, t := range tokens { + out = append(out, gin.H{ + "id": t.ID, + "platform": t.Platform, + "token_tail": trimToken(t.Token), + "updated_at": t.UpdatedAt, + }) + } + c.JSON(http.StatusOK, gin.H{"device_tokens": out}) + }) + + // Multi-device awareness: list active sessions for the authenticated user. + r.GET("/users/:id/sessions", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + actor, ok := currentUser(c, db, true) + if !ok { + return + } + if actor.ID != userID { + c.JSON(http.StatusForbidden, gin.H{"detail": "can only list your own sessions"}) + return + } + var sessions []Session + db.Where("user_id = ? AND expires_at > ?", userID, time.Now()).Order("id desc").Find(&sessions) + out := make([]gin.H, 0, len(sessions)) + current := bearerToken(c) + for _, s := range sessions { + out = append(out, gin.H{ + "id": s.ID, + "created_at": s.CreatedAt, + "expires_at": s.ExpiresAt, + "is_current": s.Token == current, + }) + } + c.JSON(http.StatusOK, gin.H{"sessions": out}) + }) + + // Revoke another (or current) session — multi-device control. + r.DELETE("/users/:id/sessions/:sessionId", func(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + sessionID, ok := parseUintParam(c, "sessionId") + if !ok { + return + } + actor, ok := currentUser(c, db, true) + if !ok { + return + } + if actor.ID != userID { + c.JSON(http.StatusForbidden, gin.H{"detail": "can only revoke your own sessions"}) + return + } + var session Session + if err := db.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "session not found"}) + return + } + db.Delete(&session) + c.JSON(http.StatusOK, gin.H{"deleted": true, "id": sessionID}) + }) + + // Admin smoke-test push to a user (requires FCM_SERVER_KEY for real delivery). + r.POST("/admin/push-test", func(c *gin.Context) { + if !requireAdmin(c) { + return + } + var req struct { + UserID uint `json:"user_id" binding:"required"` + Title string `json:"title"` + Body string `json:"body"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + if req.Title == "" { + req.Title = "분신 테스트" + } + if req.Body == "" { + req.Body = "push test" + } + sent, reason, err := notifyUser(db, req.UserID, req.Title, req.Body, map[string]string{"type": "test"}) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"sent": sent, "skipped_reason": reason}) + }) +} + +func trimToken(token string) string { + if len(token) <= 8 { + return "****" + } + return "…" + token[len(token)-6:] +} + +const adminDashboardHTML = ` + + + + + 분신 · admin metrics + + + +

분신 운영 지표

+

Bearer ADMIN_API_TOKEN 으로 /admin/metrics 를 불러옵니다. 생성 지연·오류율은 프로세스 메모리 샘플입니다.

+ +
+

raw JSON

+
loading…
+ + + +` \ No newline at end of file diff --git a/core-backend/b_test.go b/core-backend/b_test.go new file mode 100644 index 0000000..52b92b4 --- /dev/null +++ b/core-backend/b_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" +) + +func TestMessageCreateReturnsFullPayload(t *testing.T) { + server, _ := setupTestServer(t) + userID, token := mustSignup(t, server.URL, "full-msg") + peerID, _ := mustSignup(t, server.URL, "full-msg-peer") + + convResp := postJSONAuth(t, server.URL+"/conversations", token, createConversationRequest{ + UserIDs: []uint{userID, 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)) + + msgResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{ + SenderID: userID, + Text: "hello full", + }) + if msgResp.StatusCode != http.StatusOK { + t.Fatalf("send: %d", msgResp.StatusCode) + } + var out map[string]interface{} + json.NewDecoder(msgResp.Body).Decode(&out) + for _, key := range []string{"id", "conversation_id", "sender_id", "sender_mode", "text", "retracted"} { + if _, ok := out[key]; !ok { + t.Fatalf("missing key %s in %v", key, out) + } + } + if out["text"] != "hello full" || out["sender_mode"] != "human" { + t.Fatalf("unexpected payload %v", out) + } +} + +func TestDeviceTokenAndSessions(t *testing.T) { + server, _ := setupTestServer(t) + userID, token := mustSignup(t, server.URL, "devices") + + reg := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/device-tokens", token, registerDeviceRequest{ + Token: "fcm-test-token-abc", + Platform: "android", + }) + if reg.StatusCode != http.StatusOK { + t.Fatalf("device token: %d", reg.StatusCode) + } + + req, _ := http.NewRequest(http.MethodGet, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/sessions", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var sout map[string]interface{} + json.NewDecoder(resp.Body).Decode(&sout) + sessions := sout["sessions"].([]interface{}) + if len(sessions) < 1 { + t.Fatalf("expected at least one session, got %v", sout) + } + first := sessions[0].(map[string]interface{}) + if first["is_current"] != true { + t.Fatalf("expected current session flag, got %v", first) + } +} + +func TestRevokeSessionAndPushTestWithoutFCM(t *testing.T) { + server, _ := setupTestServer(t) + userID, token := mustSignup(t, server.URL, "revoke") + + // Second login via invite re-use is blocked; mint another session by signup of peer then + // just revoke the only session after listing. + req, _ := http.NewRequest(http.MethodGet, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/sessions", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + var sout map[string]interface{} + json.NewDecoder(resp.Body).Decode(&sout) + resp.Body.Close() + sessions := sout["sessions"].([]interface{}) + sid := uint(sessions[0].(map[string]interface{})["id"].(float64)) + + del := deleteJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/sessions/"+strconv.FormatUint(uint64(sid), 10), token) + if del.StatusCode != http.StatusOK { + t.Fatalf("revoke: %d", del.StatusCode) + } + + // 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", + "body": "b", + }) + if push.StatusCode != http.StatusOK { + t.Fatalf("push-test: %d", push.StatusCode) + } + var pout map[string]interface{} + json.NewDecoder(push.Body).Decode(&pout) + // Without device tokens / FCM key: sent==0 and a skipped_reason is expected. + if pout["sent"].(float64) != 0 { + t.Fatalf("expected zero sent without FCM tokens, got %v", pout) + } + if pout["skipped_reason"] == nil || pout["skipped_reason"] == "" { + t.Fatalf("expected skipped_reason, got %v", pout) + } +} + +func TestAdminMetricsIncludesDraftLatencyFields(t *testing.T) { + server, _ := setupTestServer(t) + req, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil) + req.Header.Set("Authorization", "Bearer test-admin-token") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out map[string]interface{} + json.NewDecoder(resp.Body).Decode(&out) + for _, key := range []string{"draft_requests", "draft_error_rate", "draft_latency_avg_ms", "escalate_error_rate"} { + if _, ok := out[key]; !ok { + t.Fatalf("missing metrics key %s in %v", key, out) + } + } +} diff --git a/core-backend/invite_ops.go b/core-backend/invite_ops.go new file mode 100644 index 0000000..8af970d --- /dev/null +++ b/core-backend/invite_ops.go @@ -0,0 +1,148 @@ +package main + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type mintInvitesRequest struct { + Note string `json:"note"` + ExpiresInDays int `json:"expires_in_days"` // 0 → default 14; <0 → no expiry + Count int `json:"count"` // default 1, max 20 +} + +func inviteStatus(inv InviteCode, now time.Time) string { + if inv.RevokedAt != nil { + return "revoked" + } + if inv.UsedAt != nil { + return "used" + } + if inv.ExpiresAt != nil && !inv.ExpiresAt.After(now) { + return "expired" + } + return "unused" +} + +func inviteUsable(inv InviteCode, now time.Time) (ok bool, detail string) { + switch inviteStatus(inv, now) { + case "unused": + return true, "" + case "used": + return false, "invite code already used" + case "revoked": + return false, "invite code revoked" + case "expired": + return false, "invite code expired" + default: + return false, "invite code not usable" + } +} + +func inviteJSON(inv InviteCode, now time.Time) gin.H { + out := gin.H{ + "code": inv.Code, + "note": inv.Note, + "status": inviteStatus(inv, now), + "created_at": inv.CreatedAt, + } + if inv.ExpiresAt != nil { + out["expires_at"] = inv.ExpiresAt + } + if inv.RevokedAt != nil { + out["revoked_at"] = inv.RevokedAt + } + if inv.UsedAt != nil { + out["used_at"] = inv.UsedAt + out["used_by_user_id"] = inv.UsedByUserID + } + return out +} + +func registerInviteOpsRoutes(r *gin.Engine, db *gorm.DB) { + r.POST("/invites", func(c *gin.Context) { + if !requireAdmin(c) { + return + } + var req mintInvitesRequest + _ = c.ShouldBindJSON(&req) // body optional for backward compat + count := req.Count + if count <= 0 { + count = 1 + } + if count > 20 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "count must be <= 20"}) + return + } + expiresIn := req.ExpiresInDays + if expiresIn == 0 { + expiresIn = 14 + } + + now := time.Now() + codes := make([]gin.H, 0, count) + for i := 0; i < count; i++ { + code, err := generateInviteCode() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + invite := InviteCode{Code: code, Note: req.Note} + if expiresIn > 0 { + exp := now.Add(time.Duration(expiresIn) * 24 * time.Hour) + invite.ExpiresAt = &exp + } + if err := db.Create(&invite).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + codes = append(codes, inviteJSON(invite, now)) + } + if count == 1 { + c.JSON(http.StatusOK, codes[0]) + return + } + c.JSON(http.StatusOK, gin.H{"invites": codes}) + }) + + r.GET("/invites", func(c *gin.Context) { + if !requireAdmin(c) { + return + } + var invites []InviteCode + db.Order("id desc").Limit(200).Find(&invites) + now := time.Now() + out := make([]gin.H, 0, len(invites)) + for _, inv := range invites { + out = append(out, inviteJSON(inv, now)) + } + c.JSON(http.StatusOK, gin.H{"invites": out}) + }) + + r.POST("/invites/:code/revoke", func(c *gin.Context) { + if !requireAdmin(c) { + return + } + code := c.Param("code") + var invite InviteCode + if err := db.Where("code = ?", code).First(&invite).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "invite not found"}) + return + } + if invite.UsedAt != nil { + c.JSON(http.StatusConflict, gin.H{"detail": "cannot revoke a used invite"}) + return + } + if invite.RevokedAt != nil { + c.JSON(http.StatusOK, inviteJSON(invite, time.Now())) + return + } + now := time.Now() + invite.RevokedAt = &now + db.Save(&invite) + c.JSON(http.StatusOK, inviteJSON(invite, now)) + }) +} diff --git a/core-backend/invite_ops_test.go b/core-backend/invite_ops_test.go new file mode 100644 index 0000000..770ca42 --- /dev/null +++ b/core-backend/invite_ops_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "net/http" + "testing" + "time" +) + +func TestMintInviteWithNoteExpiryAndRevoke(t *testing.T) { + server, _ := setupTestServer(t) + + resp := postJSONAuth(t, server.URL+"/invites", "test-admin-token", mintInvitesRequest{ + Note: "e2e-friend", + ExpiresInDays: 7, + Count: 1, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("mint: %d", resp.StatusCode) + } + var one map[string]interface{} + json.NewDecoder(resp.Body).Decode(&one) + code, _ := one["code"].(string) + if code == "" || one["note"] != "e2e-friend" || one["status"] != "unused" { + t.Fatalf("unexpected mint payload %v", one) + } + if one["expires_at"] == nil { + t.Fatalf("expected expires_at, got %v", one) + } + + batch := postJSONAuth(t, server.URL+"/invites", "test-admin-token", mintInvitesRequest{ + Note: "batch", + Count: 3, + }) + if batch.StatusCode != http.StatusOK { + t.Fatalf("batch: %d", batch.StatusCode) + } + var bout map[string]interface{} + json.NewDecoder(batch.Body).Decode(&bout) + if len(bout["invites"].([]interface{})) != 3 { + t.Fatalf("expected 3 invites, got %v", bout) + } + + listReq, _ := http.NewRequest(http.MethodGet, server.URL+"/invites", nil) + listReq.Header.Set("Authorization", "Bearer test-admin-token") + listResp, err := http.DefaultClient.Do(listReq) + if err != nil { + t.Fatal(err) + } + if listResp.StatusCode != http.StatusOK { + t.Fatalf("list: %d", listResp.StatusCode) + } + + rev := postJSONAuth(t, server.URL+"/invites/"+code+"/revoke", "test-admin-token", nil) + if rev.StatusCode != http.StatusOK { + t.Fatalf("revoke: %d", rev.StatusCode) + } + var revOut map[string]interface{} + json.NewDecoder(rev.Body).Decode(&revOut) + if revOut["status"] != "revoked" { + t.Fatalf("expected revoked, got %v", revOut) + } + + signup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "X"}) + if signup.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for revoked invite, got %d", signup.StatusCode) + } +} + +func TestExpiredInviteRejected(t *testing.T) { + server, db := setupTestServer(t) + code := mintInvite(t, server.URL) + past := time.Now().Add(-time.Hour) + db.Model(&InviteCode{}).Where("code = ?", code).Update("expires_at", past) + + signup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "Late"}) + if signup.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for expired invite, got %d", signup.StatusCode) + } +} diff --git a/core-backend/main.go b/core-backend/main.go index e13a15e..58d08d6 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -57,23 +57,8 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi }) registerA1A2Routes(r, db) - - r.POST("/invites", func(c *gin.Context) { - if !requireAdmin(c) { - return - } - code, err := generateInviteCode() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - invite := InviteCode{Code: code} - if err := db.Create(&invite).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"code": invite.Code}) - }) + registerBRoutes(r, db) + registerInviteOpsRoutes(r, db) r.GET("/admin/metrics", func(c *gin.Context) { if !requireAdmin(c) { @@ -110,6 +95,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi peerVetoRate = float64(conversationsVetoed) / float64(conversationsTotal) } + rt := runtimeMetrics.snapshot() c.JSON(http.StatusOK, gin.H{ "users_total": usersTotal, "messages_human_total": humanMessages, @@ -123,7 +109,21 @@ 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, - "invites_minted": invitesMinted, + // 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, + "push_attempts": rt.PushAttempts, + "push_skipped": rt.PushSkipped, + "push_delivered": rt.PushDelivered, + "invites_minted": invitesMinted, "invites_used": invitesUsed, }) }) @@ -143,8 +143,12 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"}) return } - if invite.UsedAt != nil { - c.JSON(http.StatusConflict, gin.H{"detail": "invite code already used"}) + if ok, detail := inviteUsable(invite, time.Now()); !ok { + status := http.StatusBadRequest + if detail == "invite code already used" { + status = http.StatusConflict + } + c.JSON(status, gin.H{"detail": detail}) return } @@ -207,22 +211,32 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi // none of the later checks can override an earlier block. if req.SenderMode == SenderTwin { if conversation.TwinDisabledByPeer { + runtimeMetrics.recordTwinBlocked() c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 분신을 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"}) return } result, err := ai.checkEscalation(req.Text) + runtimeMetrics.recordEscalate(err) if err != nil { + runtimeMetrics.recordTwinBlocked() c.JSON(http.StatusBadGateway, gin.H{"detail": "escalation gate unavailable, twin send blocked: " + err.Error()}) return } if result.Escalate { + runtimeMetrics.recordTwinBlocked() db.Create(&EscalationLog{ UserID: req.SenderID, ConversationID: convID, Reason: result.Reason, MessageSnippet: req.Text, }) + // Best-effort push (no-op without FCM_SERVER_KEY / real tokens). + _, _, _ = notifyUser(db, req.SenderID, "분신 확인 필요", result.Reason, map[string]string{ + "type": "escalation", + "conversation_id": strconv.FormatUint(uint64(convID), 10), + "reason": result.Reason, + }) c.JSON(http.StatusForbidden, gin.H{"detail": "escalated", "reason": result.Reason}) return } @@ -237,19 +251,23 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi switch level { case AutonomyL0: + runtimeMetrics.recordTwinBlocked() c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 분신 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"}) return case AutonomyL1: if !req.Approved { + 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() c.JSON(http.StatusForbidden, gin.H{"detail": "화이트리스트에 없는 주제는 L1과 동일하게 사용자 승인이 필요합니다"}) return } default: + runtimeMetrics.recordTwinBlocked() c.JSON(http.StatusForbidden, gin.H{"detail": "알 수 없는 자율성 레벨이라 발송을 차단합니다"}) return } @@ -274,7 +292,8 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi "text": message.Text, }) - c.JSON(http.StatusOK, gin.H{"id": message.ID}) + // Return the full message so Flutter can render without waiting on WS. + c.JSON(http.StatusOK, messageJSON(message)) }) r.POST("/messages/:id/retract", func(c *gin.Context) { @@ -337,16 +356,14 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi return } - // EscalationLog persistence (sender's post-hoc notification + undo - // trail) is a separate checklist item -- roadmap.md Phase 1 §2.2 - // "사후 알림 + 되돌리기 로그 스키마/API". This endpoint only proxies - // to the AI service for now. + started := time.Now() result, err := ai.requestDraft(draftRequest{ ContextLines: req.ContextLines, StyleExamples: req.StyleExamples, History: req.History, K: req.K, }) + runtimeMetrics.recordDraft(time.Since(started), err) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()}) return diff --git a/core-backend/main_test.go b/core-backend/main_test.go index 275ab89..4c49d86 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -105,6 +105,19 @@ func postJSONAuth(t *testing.T, url, token string, body interface{}) *http.Respo return resp } +func deleteJSONAuth(t *testing.T, url, token string) *http.Response { + t.Helper() + req, _ := http.NewRequest(http.MethodDelete, url, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("delete %s: %v", url, err) + } + return resp +} + func mintInvite(t *testing.T, serverURL string) string { t.Helper() resp := postJSONAuth(t, serverURL+"/invites", "test-admin-token", nil) diff --git a/core-backend/metrics.go b/core-backend/metrics.go new file mode 100644 index 0000000..df862d4 --- /dev/null +++ b/core-backend/metrics.go @@ -0,0 +1,122 @@ +package main + +import ( + "sync" + "time" +) + +// RuntimeMetrics tracks draft-generation latency and AI error rates in process +// memory (roadmap B). Survives for the life of the process; not a substitute +// for a time-series DB, but enough for a minimal admin dashboard. +type RuntimeMetrics struct { + mu sync.Mutex + + DraftRequests int64 + DraftErrors int64 + DraftLatencies []time.Duration // capped ring of recent samples + EscalateChecks int64 + EscalateErrors int64 + TwinSendsBlocked int64 + PushAttempts int64 + PushSkipped int64 + PushDelivered int64 +} + +const maxLatencySamples = 200 + +var runtimeMetrics = &RuntimeMetrics{} + +func (m *RuntimeMetrics) recordDraft(latency time.Duration, err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.DraftRequests++ + if err != nil { + m.DraftErrors++ + } + m.DraftLatencies = append(m.DraftLatencies, latency) + if len(m.DraftLatencies) > maxLatencySamples { + m.DraftLatencies = m.DraftLatencies[len(m.DraftLatencies)-maxLatencySamples:] + } +} + +func (m *RuntimeMetrics) recordEscalate(err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.EscalateChecks++ + if err != nil { + m.EscalateErrors++ + } +} + +func (m *RuntimeMetrics) recordTwinBlocked() { + m.mu.Lock() + defer m.mu.Unlock() + m.TwinSendsBlocked++ +} + +func (m *RuntimeMetrics) recordPush(delivered int, skipped bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.PushAttempts++ + if skipped { + m.PushSkipped++ + } + m.PushDelivered += int64(delivered) +} + +func (m *RuntimeMetrics) snapshot() ginHMetrics { + m.mu.Lock() + defer m.mu.Unlock() + + var sum time.Duration + var max time.Duration + for _, d := range m.DraftLatencies { + sum += d + if d > max { + max = d + } + } + var avgMs float64 + if n := len(m.DraftLatencies); n > 0 { + avgMs = float64(sum.Milliseconds()) / float64(n) + } + var errRate float64 + if m.DraftRequests > 0 { + errRate = float64(m.DraftErrors) / float64(m.DraftRequests) + } + var escErrRate float64 + if m.EscalateChecks > 0 { + escErrRate = float64(m.EscalateErrors) / float64(m.EscalateChecks) + } + 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, + 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 + PushAttempts int64 + PushSkipped int64 + PushDelivered int64 +} diff --git a/core-backend/models.go b/core-backend/models.go index 47ec75b..c5938a7 100644 --- a/core-backend/models.go +++ b/core-backend/models.go @@ -41,7 +41,10 @@ type Session struct { type InviteCode struct { ID uint `gorm:"primaryKey"` Code string `gorm:"uniqueIndex;not null"` + Note string // operator memo (recipient/channel) — roadmap C invite ops CreatedAt time.Time + ExpiresAt *time.Time + RevokedAt *time.Time UsedAt *time.Time UsedByUserID *uint } @@ -119,6 +122,17 @@ type EscalationLog struct { CreatedAt time.Time } +// DeviceToken stores an FCM registration token for push (roadmap B). +// Sending pushes is wired later; v1 persists tokens per user/device. +type DeviceToken struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"not null;index"` + Token string `gorm:"uniqueIndex;not null"` + Platform string `gorm:"not null;default:android"` + CreatedAt time.Time + UpdatedAt time.Time +} + var allModels = []interface{}{ &User{}, &Session{}, @@ -130,4 +144,5 @@ var allModels = []interface{}{ &TwinSettings{}, &WhitelistRule{}, &EscalationLog{}, + &DeviceToken{}, } diff --git a/core-backend/push.go b/core-backend/push.go new file mode 100644 index 0000000..d187004 --- /dev/null +++ b/core-backend/push.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "gorm.io/gorm" +) + +// FCM legacy HTTP API (server key). When FCM_SERVER_KEY is unset, notifyUser +// records a metric and returns without error so product flows stay usable. +func fcmServerKey() string { + return strings.TrimSpace(os.Getenv("FCM_SERVER_KEY")) +} + +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"` +} + +func notifyUser(db *gorm.DB, userID uint, title, body string, data map[string]string) (sent int, skippedReason string, err error) { + var tokens []DeviceToken + db.Where("user_id = ?", userID).Find(&tokens) + if len(tokens) == 0 { + return 0, "no_device_tokens", nil + } + + key := fcmServerKey() + if key == "" { + runtimeMetrics.recordPush(0, true) + return 0, "fcm_not_configured", nil + } + + // Skip placeholder install:* tokens — they are not real FCM registration IDs. + regIDs := make([]string, 0, len(tokens)) + for _, t := range tokens { + if strings.HasPrefix(t.Token, "install:") { + continue + } + regIDs = append(regIDs, t.Token) + } + if len(regIDs) == 0 { + runtimeMetrics.recordPush(0, true) + return 0, "only_placeholder_tokens", nil + } + + payload := fcmLegacyPayload{ + Registration: regIDs, + Priority: "high", + Notification: map[string]string{"title": title, "body": body}, + Data: data, + } + 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 + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "key="+key) + + client := &http.Client{Timeout: 8 * time.Second} + resp, err := client.Do(req) + if err != nil { + runtimeMetrics.recordPush(0, true) + return 0, "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + runtimeMetrics.recordPush(0, true) + return 0, "", fmt.Errorf("fcm returned %d", resp.StatusCode) + } + runtimeMetrics.recordPush(len(regIDs), false) + return len(regIDs), "", nil +} diff --git a/docs/PLANNING.md b/docs/PLANNING.md index cf86a00..10adf6d 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -143,8 +143,8 @@ 검색기의 recency 가중치가 키워드 겹침을 압도하는 버그 발견·수정함 (`poc/tone-corpus/README.md` "말투 검색기" 참고) - [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정 - 4개 장면을 실제로 눌러볼 수 있는 프로토타입으로 제작 (Claude 아티팩트, 필요 시 공유 링크로 배포). - PoC #3 역할극 진행 시 이 프로토타입을 그대로 자극재로 사용 가능 + 4개 장면. 공유 링크 docs 앵커: [`prototype.md`](./prototype.md) (`bunsin-prototype`). + PoC #3 역할극 자극재로 사용 - [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md` (질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장) - [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영) diff --git a/docs/android-release.md b/docs/android-release.md new file mode 100644 index 0000000..26307fa --- /dev/null +++ b/docs/android-release.md @@ -0,0 +1,64 @@ +# Android 릴리즈·내부 배포 경로 (Phase 1 C) + +v1은 **Android만** 대상 (`tech-design.md` §8). Play 스토어 공개가 아니라 +**지인 클로즈드 베타용 내부 APK/AAB** 경로를 먼저 고정한다. + +## 산출물 + +| 산출물 | 명령 | 용도 | +|--------|------|------| +| 내부 APK | `./scripts/build_release_apk.sh` | 직접 설치·메신저 공유 | +| AAB | `./scripts/build_release_aab.sh` | Play 내부 테스트 트랙 (선택) | + +## 서명 키 (Master 로컬 전용) + +1. 키스토어는 **절대 git에 넣지 않는다** (`mobile/android/key.properties`, `*.jks`, `*.keystore`는 gitignore). +2. Master 머신에서 한 번 생성: + +```bash +keytool -genkey -v -keystore ~/bunsin-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias bunsin +``` + +3. `mobile/android/key.properties.example`을 복사해 `mobile/android/key.properties` 작성: + +``` +storePassword=... +keyPassword=... +keyAlias=bunsin +storeFile=/absolute/path/to/bunsin-release.jks +``` + +4. `key.properties`가 없으면 release 빌드는 **디버그 서명으로 폴백**하며 경고를 낸다 + (내부 스모크용). 베타 배포본은 반드시 release keystore로 서명한다. + +## 빌드 + +```bash +cd mobile +flutter pub get +../scripts/build_release_apk.sh +# → build/app/outputs/flutter-apk/app-release.apk + +../scripts/build_release_aab.sh # Play 내부 테스트용 (선택) +``` + +API 베이스 URL은 배포 대상 서버에 맞게: + +```bash +flutter build apk --release \ + --dart-define=CORE_API_BASE=https://api.example.com +``` + +## 배포 채널 (권장 순서) + +1. **직접 APK** — 초대받은 지인에게만 파일 전달 (1차 클로즈드 베타) +2. **Play 내부 테스트** — 규모가 커지면 AAB + 테스터 이메일 목록 +3. 공개 트랙 — Phase 게이트(`vision.md`) 통과 전 금지 + +## 체크리스트 + +- [ ] `key.properties` + jks가 Master 백업에만 존재 +- [ ] `CORE_API_BASE`가 베타 서버를 가리킴 +- [ ] `ADMIN_API_TOKEN` / `GEMINI_API_KEY` / `FCM_SERVER_KEY`가 서버에만 설정 +- [ ] 초대 코드 운영 (`docs/invite-ops.md`) 숙지 +- [ ] 실기기에서 `mobile/README.md` E2E UI 체크 1회 diff --git a/docs/decision-log.md b/docs/decision-log.md index f50ce29..2c783d3 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -1,41 +1,42 @@ # 결정 로그 회의 자료(`idea-meeting-2026-06-29.html`)의 논점 Q1~Q7에 대한 결정을 추적한다. -상태가 **제안**인 항목은 실제 회의에서 확정되지 않았고, 이 문서 이후의 Vision/PRD/기술설계서를 -쓰기 위해 잠정적으로 내린 판단이다. 다음 회의에서 뒤집히면 이 로그와 그 근거로 만들어진 -하위 문서를 함께 갱신한다. (이 "결정 로그" 자체가 회의 자료 §1의 "번복 추적 결정 로그" 아이디어를 -그대로 실천하는 것이기도 하다.) + +**상태 범례** +- **제안**: 문서 작성용 잠정안 (회의 전) +- **확정**: Master가 Phase 1 C(베타 직전)에서 승인·고정한 값. 이후 번복 시 이 로그와 + 근거로 만든 Vision/PRD/기술설계를 함께 갱신한다. | # | 질문 | 결정 | 근거 | 상태 | |---|------|------|------|------| -| Q1 | AI 분신으로 확정? | **예** | "기록/관계 피로/프라이버시" 축은 기능 묶음이라 한 문장 정체성이 약함. 분신은 "나를 대신해 남과 대화하는 나"로 명확히 정의됨 | 제안 | -| Q2 | 타깃: 대중 vs 회사? | **대중 우선** | 회사용은 워크스페이스 도입 결정권자를 설득해야 해 진입 장벽이 큼. 대중은 개인 대 개인으로 바로 가치 체감 가능(읽씹 종결). B2B는 v3 확장 항목으로 남김 | 제안 | -| Q3 | 자율성 몇 단계까지 출시? | **L0~L2만** | L3(자리비움 응대)·L4(분신 협상)는 신뢰가 쌓이기 전엔 리스크 대비 이득이 낮음. "켜는 만큼만 만든다" 원칙 적용 | 제안 | -| Q4 | 사칭 우려 대응 충분한가? | **1차로는 충분, 실사용 검증 필요** | 분신 뱃지·거부권·확정 불가 원칙은 설계상 합리적이나, 실제 상대방이 이를 신뢰하는지는 §4 기술 PoC #3(사용자 테스트)로 검증 전까지는 가설 | 제안 | -| Q5 | MVP 데모 시나리오 1개는? | **읽씹 종결 + 단톡 따라잡기 (묶음)** | 둘 다 L0~L2, 같은 파이프라인(수신 요약 → 맥락 응답 초안) 공유. 발송 자동화 없이도(단톡 따라잡기) 가치 증명 가능해 안전하게 시작 가능 | 제안 | -| Q6 | 서비스 이름 | **"분신" (가칭)** | 한국어로 직관적이고 정체성을 바로 전달함. 최종 브랜딩/상표 조사는 MVP 검증 이후 진행 | 제안 (가칭) | -| Q7 | 자체 앱 vs OS 레이어 시작점 | **자체 앱(클로즈드 베타) 먼저 → OS 레이어는 v2 성장 전략** | 근거는 아래 "Q7 상세 근거" 참고 | 제안 | +| Q1 | AI 분신으로 확정? | **예** | "기록/관계 피로/프라이버시" 축은 기능 묶음이라 한 문장 정체성이 약함. 분신은 "나를 대신해 남과 대화하는 나"로 명확히 정의됨 | 확정 | +| Q2 | 타깃: 대중 vs 회사? | **대중 우선** | 회사용은 워크스페이스 도입 결정권자를 설득해야 해 진입 장벽이 큼. 대중은 개인 대 개인으로 바로 가치 체감 가능(읽씹 종결). B2B는 v3 확장 항목으로 남김 | 확정 | +| Q3 | 자율성 몇 단계까지 출시? | **L0~L2만** | L3(자리비움 응대)·L4(분신 협상)는 신뢰가 쌓이기 전엔 리스크 대비 이득이 낮음. "켜는 만큼만 만든다" 원칙 적용. *기본 자율성 레벨(L1 vs L2 시작점)과 화이트리스트 기본 주제는 사람 PoC/실사용 후 확정* | 확정 | +| Q4 | 사칭 우려 대응 충분한가? | **1차 설계로 베타 진입, PoC #3로 검증** | 분신 뱃지·거부권·확정 불가·본인확인 고정 문구는 설계상 필수. 상대 신뢰 여부는 D(사람 PoC #3)에서 측정 | 확정 | +| Q5 | MVP 데모 시나리오 1개는? | **읽씹 종결 + 단톡 따라잡기 (묶음)** | 둘 다 L0~L2, 같은 파이프라인(수신 요약 → 맥락 응답 초안) 공유. 발송 자동화 없이도(단톡 따라잡기) 가치 증명 가능 | 확정 | +| Q6 | 서비스 이름 | **"분신" (가칭)** | 한국어로 직관적. 최종 브랜딩/상표는 MVP·베타 반응 이후 | 확정 (가칭 유지) | +| Q7 | 자체 앱 vs OS 레이어 시작점 | **자체 앱(클로즈드 베타) 먼저 → OS 레이어는 v2** | 아래 "Q7 상세 근거" | 확정 | + +확정일: 2026-07-30 (Phase 1 C — Master 승인 하에 제안 → 확정). ## Q7 상세 근거 — 왜 자체 앱을 먼저 만드는가 `PLANNING.md` §6에서는 채택 장벽이 낮다는 이유로 OS 레이어(읽기 전용 허브)를 먼저 검증하는 안을 -제시했다. 하지만 실제 실행 순서를 정할 때는 아래 이유로 **자체 앱 클로즈드 베타를 먼저** 두는 것이 더 낫다고 판단한다. +제시했다. 하지만 실제 실행 순서를 정할 때는 아래 이유로 **자체 앱 클로즈드 베타를 먼저** 둔다. - 카카오톡·인스타그램은 자동화/크롤링을 정책상 제한하는 경우가 많아, OS 레이어의 "발송" 쪽은 - 처음부터 제3자 API 제약을 받는다. 아직 검증되지 않은 핵심 가설(분신이 자연스럽게 느껴지는가, - 신뢰를 얻는가)을 제3자 플랫폼 제약과 동시에 검증하면 실패 원인을 구분할 수 없다. + 처음부터 제3자 API 제약을 받는다. 아직 검증되지 않은 핵심 가설을 제3자 플랫폼 제약과 동시에 + 검증하면 실패 원인을 구분할 수 없다. - 자체 앱(초대 기반 클로즈드 베타)에서는 분신 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히 구현해 통제된 소규모 그룹으로 "이게 정말 쓸만한가"만 순수하게 검증할 수 있다. -- 이 단계에서 만든 요약/응답 초안 생성 로직은 이후 OS 레이어(문자·이메일 우선)로 그대로 재사용 가능 — - 버리는 작업이 아니다. +- 이 단계에서 만든 요약/응답 초안 생성 로직은 이후 OS 레이어(문자·이메일 우선)로 그대로 재사용 가능. -**따라서 순서:** ① 자체 앱 클로즈드 베타로 핵심 가설(분신의 자연스러움·신뢰) 검증 → ② 검증되면 -OS 레이어 읽기 전용 허브(문자·이메일 우선, 안드로이드)로 채택 장벽을 낮춰 확산 → ③ 자체 앱은 -L3·L4 등 완전한 기능의 최종 목적지로 유지. +**순서:** ① 자체 앱 클로즈드 베타로 핵심 가설 검증 → ② 검증되면 OS 레이어 읽기 전용 허브 → +③ 자체 앱은 L3·L4 등 완전한 기능의 최종 목적지로 유지. -## 아직 열려 있는 하위 질문 +## 아직 열려 있는 하위 질문 (PoC/실사용 의존 — D 이후) -- 분신 응답 임계점(어디까지 자동, 어디부터 사람에게) — Q3 확정 후에도 화이트리스트 주제 목록은 - 실사용 데이터로 계속 조정 필요 -- 상표/네이밍 최종안 (Q6은 가칭) -- 베타 참가자 모집 규모와 방식 (§로드맵 참고) +- 분신 응답 임계점·화이트리스트 기본 주제 목록 (실사용 데이터) +- 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰) +- 상표/네이밍 최종안 (Q6은 가칭 확정) +- 베타 참가자 모집 규모와 방식 diff --git a/docs/invite-ops.md b/docs/invite-ops.md new file mode 100644 index 0000000..8d4ab5b --- /dev/null +++ b/docs/invite-ops.md @@ -0,0 +1,57 @@ +# 초대 코드 운영 절차 (Phase 1 C) + +클로즈드 베타 가입은 **미리 발급된 1회용 초대 코드**로만 가능하다 +(`POST /auth/signup` → `InviteCode` 검증). + +## 발급자 권한 + +| 역할 | 권한 | +|------|------| +| **발급자 (Issuer)** | Master(또는 Master가 지정한 1명). `ADMIN_API_TOKEN`을 가진 주체만 발급·목록·회수 | +| 일반 사용자 | 초대 발급 불가. 받은 코드로 가입만 가능 | +| CI / 에이전트 | 프로덕션 `ADMIN_API_TOKEN`을 저장·공유하지 않음 | + +토큰은 서버 환경변수 `ADMIN_API_TOKEN`으로만 주입한다. 레포·채팅·커밋에 넣지 않는다. + +## 발급 규칙 + +1. **용도 메모 필수 권장** — `note`에 수신자/채널(예: `지인-김ㅇㅇ`, `PoC#3-참가자3`)을 남긴다. +2. **만료** — 기본 14일. 장기 코드는 만들지 않는다 (`expires_in_days`). +3. **배치** — 한 번에 최대 20개 (`count`). 대량 발급 전 Master 확인. +4. **1코드 = 1가입**. 재사용 불가. 계정 삭제 후에도 코드는 “사용됨”으로 남는다. +5. **회수** — 전달 전 유출·오배포 시 `POST /invites/:code/revoke`. 이미 사용된 코드는 회수 불가. + +## 운영 커맨드 + +```bash +export ADMIN_API_TOKEN=... # 로컬/서버에만 +export CORE=http://127.0.0.1:8080 + +# 단일 발급 (14일, 메모) +curl -sS -X POST "$CORE/invites" \ + -H "Authorization: Bearer $ADMIN_API_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"note":"지인-테스트","expires_in_days":14}' + +# 배치 5개 +curl -sS -X POST "$CORE/invites" \ + -H "Authorization: Bearer $ADMIN_API_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"note":"베타-1차","expires_in_days":14,"count":5}' + +# 목록 +curl -sS "$CORE/invites" -H "Authorization: Bearer $ADMIN_API_TOKEN" + +# 회수 +curl -sS -X POST "$CORE/invites//revoke" \ + -H "Authorization: Bearer $ADMIN_API_TOKEN" +``` + +사용 현황은 `GET /admin/metrics`의 `invites_minted` / `invites_used`와 목록의 +`status`(`unused`|`used`|`expired`|`revoked`)로 본다. + +## 배포 전 체크 + +- [ ] 프로덕션 `ADMIN_API_TOKEN` 회전·길이 충분(≥32자 권장) +- [ ] 초대 코드를 공개 채널(SNS/이슈)에 올리지 않음 +- [ ] 만료·회수된 코드로 가입이 거절되는지 스모크 테스트 diff --git a/docs/meeting-review-summary.md b/docs/meeting-review-summary.md index 5a387ec..895d4a7 100644 --- a/docs/meeting-review-summary.md +++ b/docs/meeting-review-summary.md @@ -16,7 +16,7 @@ | `roadmap.md` | Phase 0~4 게이트 기반 로드맵 | 결정 반영 완료 | | `poc-plan.md` / `poc-materials.md` | PoC #1·#3 방법론 + 바로 쓸 모집문구·스크립트 | 계획 완료, **실행 전** | | `user-interview-guide.md` | Q3 위임 의향 인터뷰 (분신 사용자 관점) | 계획 완료, **실행 전** | -| 클릭 프로토타입 | 읽씹종결·본인확인·거부권·에스컬레이션·자율성설정 4장면 | 완료, PoC#3 자극재로 사용 가능 | +| 클릭 프로토타입 | 읽씹종결·본인확인·거부권·에스컬레이션·자율성설정 4장면 | 완료, 앵커 [`prototype.md`](./prototype.md) | **아직 안 된 것 (사람이 직접 해야 함):** 실제 참가자 모집, 대화 샘플 수집, 역할극/인터뷰 진행, 그 결과의 Go/No-Go 판정. 문서·계획·자극재는 다 준비돼 있어 착수만 하면 된다. @@ -25,13 +25,13 @@ 각 항목을 "확정" 또는 "재검토"로 표시하고, 재검토라면 이유와 다음 액션을 적는다. -- [ ] **Q1** AI 분신 컨셉 확정? → 잠정: 예 -- [ ] **Q2** 타깃 대중 우선? → 잠정: 예 (B2B는 이후) -- [ ] **Q3** 자율성 L0~L2만 출시? → 잠정: 예 (단, `user-interview-guide.md` 실행 전이라 근거는 아직 가설) -- [ ] **Q4** 사칭 우려 대응(뱃지·거부권) 충분? → 잠정: 1차 설계는 충분, 실사용 검증 필요 (PoC#3 미실행) -- [ ] **Q5** MVP 시나리오 = 읽씹종결 + 단톡따라잡기? → 잠정: 예 -- [ ] **Q6** 서비스 이름 "분신"(가칭)? → 잠정: 가칭, 정식 브랜딩 조사 전 -- [ ] **Q7** 자체 앱 클로즈드 베타 먼저, OS 레이어는 이후? → 잠정: 예 +- [x] **Q1** AI 분신 컨셉 확정? → 확정: 예 (`decision-log.md`) +- [x] **Q2** 타깃 대중 우선? → 확정: 예 (B2B는 이후) +- [x] **Q3** 자율성 L0~L2만 출시? → 확정: 예 (기본 레벨·화이트리스트는 PoC/실사용 후) +- [x] **Q4** 사칭 우려 대응(뱃지·거부권) 충분? → 확정: 1차 설계로 베타, PoC#3로 검증 +- [x] **Q5** MVP 시나리오 = 읽씹종결 + 단톡따라잡기? → 확정: 예 +- [x] **Q6** 서비스 이름 "분신"(가칭)? → 확정: 가칭 유지 +- [x] **Q7** 자체 앱 클로즈드 베타 먼저, OS 레이어는 이후? → 확정: 예 ## 회의에서 정할 것 (Q1~Q7 확정 외) diff --git a/docs/poc-materials.md b/docs/poc-materials.md index ccd7665..b8313b2 100644 --- a/docs/poc-materials.md +++ b/docs/poc-materials.md @@ -34,6 +34,7 @@ `bunsin-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미 다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다. +공식 링크 앵커: [`prototype.md`](./prototype.md) (공유 URL은 그 파일의 `SHARE_URL`). ### 시나리오 E — 감정적인 대화 (에스컬레이션 케이스, 프로토타입 미포함) diff --git a/docs/prototype.md b/docs/prototype.md new file mode 100644 index 0000000..1609159 --- /dev/null +++ b/docs/prototype.md @@ -0,0 +1,36 @@ +# 클릭 프로토타입 (PoC #3 자극재) — 공유 링크 고정 + +Phase 1 C: PoC #3 역할극에서 쓰는 **클릭 프로토타입**의 공식 참조 위치다. +장면: 읽씹 종결 · 본인확인 · 거부권 · 에스컬레이션 (+ 자율성 설정). + +## 공식 링크 + +| 항목 | 값 | +|------|-----| +| **식별자** | `bunsin-prototype` (`poc-materials.md` §2와 동일) | +| **공유 URL** | **TBD — Master 기입** (Claude Artifacts 공개 permalink 또는 정적 호스팅 URL) | +| **docs 앵커** | 이 파일. 다른 문서는 프로토타입을 말할 때 여기로 링크한다 | + +URL을 받으면 아래 한 줄만 채운다 (추측으로 가짜 링크를 넣지 않는다): + +``` +SHARE_URL= +``` + +현재 값: + +``` +SHARE_URL= +``` + +## 사용처 + +- [`poc-plan.md`](./poc-plan.md) — PoC #3 자극재 +- [`poc-materials.md`](./poc-materials.md) §2 — 역할극 시연 순서 +- [`meeting-review-summary.md`](./meeting-review-summary.md) — 회의 산출물 표 + +## 갱신 규칙 + +1. 프로토타입 장면을 바꾸면 이 파일에 변경일·요약 한 줄을 추가한다. +2. `SHARE_URL`이 비어 있거나 404면 PoC #3 / 외부 공유 전에 반드시 채운다. +3. 호스팅을 옮겨도 식별자 `bunsin-prototype`과 이 앵커 문서는 유지한다. diff --git a/docs/roadmap.md b/docs/roadmap.md index 1a8e786..da3b70d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -58,26 +58,27 @@ 생성은 이미 있는 `/draft` 흐름을 그대로 쓰면 되므로 새로 만들지 않았고, 화이트리스트는 `ContactID`(상대별) 무시하고 전역 키워드 매칭만 지원 — 대화방↔연락처 연결 모델이 아직 없어서 (Flutter 클라이언트의 연락처 모델이 생긴 뒤 다시 설계 필요, `core-backend/README.md` 참고) -- [ ] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현 +- [x] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현 — drift+SQLCipher(`mobile/lib/db/`), + draft에 샘플만 전달, `DataFlowScreen`으로 원칙 노출 - [x] 사후 알림 + 되돌리기 로그 스키마/API — `escalation_logs`는 이미 쌓임(사후 알림용 로그). 되돌리기(one-tap undo, AGENTS.md 안전 불변식)는 `Message.Retracted` 필드 + `POST /messages/:id/retract` 추가: 트윈이 자동발송한(L2) 메시지만 대상, 사람이 쓴 메시지는 400, 이미 되돌린 건 409, 성공하면 같은 대화방 WebSocket에 `{"type":"retraction", "id":...}` 브로드캐스트. 일반 메시지 브로드캐스트도 `"type":"message"`를 붙여 클라이언트가 두 이벤트를 구분하게 함. - **되돌리기 UI는 `mobile/`에 있음** — 사후알림 전용 함/푸시는 아직. - 에스컬레이션 로그를 "조회"하는 API는 아직 없음(필요해지면 추가) + **되돌리기 UI는 `mobile/`에 있음**. 사후알림 함은 A3에서 `InboxScreen` + + `GET /users/:id/escalation-logs`로 연결. FCM 푸시는 B. **2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** — `mobile/` -- [x] 기본 채팅 UI (대화 목록, 대화방) — `mobile/` 골격. 대화방 목록 API는 아직 없어 ID 직접 - 입장. WebSocket 수신·인간 메시지 전송 연결됨 -- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 초대 코드 가입 화면만 있음. 말투 학습 UX 디테일은 - **사람 PoC #1 결과를 맨 마지막에 반영** (지금은 뼈대만) +- [x] 기본 채팅 UI (대화 목록, 대화방) — `GET`/`POST /conversations`, 히스토리 로드, + WebSocket 수신·인간 메시지 전송. 연락처 화면에서 DM 시작 +- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 가입 + 말투 샘플 입력 UI(`OnboardingToneScreen`, + 기기 로컬). 말투 학습 UX 디테일·카피는 **사람 PoC #1 결과를 맨 마지막에 반영** - [x] 분신 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는 서버/AI 프롬프트 측과 이어서 다듬을 것 -- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외 매칭은 - 서버가 전역 키워드만 지원하는 동안 UI도 주제 키워드 CRUD만 -- [x] 에스컬레이션 배너·되돌리기 UI — 초안 escalate 배너 + twin 메시지 되돌리기 버튼. - 사후알림 전용 함/푸시는 아직 없음 +- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외는 + 서버 contact-scoped 매칭과 함께 화이트리스트 CRUD +- [x] 에스컬레이션·L1 승인·되돌리기 UI — L1 수정/버리기/재초안/승인 패널 + twin 되돌리기 + + 사후알림 함. FCM 푸시는 B **2.4 안전장치 통합** (전 구간 필수, 타협 불가) - [x] 에스컬레이션 하드게이트가 클라이언트·서버 전 구간에서 우회 불가하게 설계 — 실제 발견한 우회 @@ -97,7 +98,7 @@ 무조건 차단되는 것까지 테스트로 확인함 - [~] 데이터 프라이버시: 온디바이스 암호화, 삭제 플로우, 데이터 흐름 대시보드 — 서버 쪽 삭제 플로우(`DELETE /users/:id`, 유저가 걸린 모든 행을 트랜잭션으로 삭제)만 완료·테스트함. 온디바이스 - 암호화(drift+SQLCipher)와 실시간 데이터 흐름 대시보드는 Flutter(`mobile/`) 쪽 후속 작업 + 암호화(drift+SQLCipher)와 데이터 흐름 UI는 Flutter(`mobile/`) B에서 반영 **2.5 QA/테스트** - [x] `escalation_filter.py`의 자체 테스트를 정식 테스트 스위트로 승격, `generate_draft`·`retrieve_style`도 @@ -114,8 +115,8 @@ - [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라 실제로는 초대 기반이 아니었음. `InviteCode` 테이블 + `POST /invites`(발급) 추가하고 `/auth/signup`이 미리 발급된 미사용 코드인지 검증하도록 변경(모르는 코드 400, 이미 쓴 코드 - 409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. **아직 없는 것**: 발급자 - 인증(`/invites`를 지금은 누구나 호출 가능 — 세션/인증 도입 시 같이 잠글 것) + 409). 계정 삭제 시 코드는 "사용됨" 상태를 유지한 채 유저 참조만 지움. 발급자 인증은 + `ADMIN_API_TOKEN`(A2). 운영 절차·만료/회수는 C (`docs/invite-ops.md`) - [~] `vision.md` 성공 지표(자연스러움·거부율·안전선 위반) 계측용 분석/피드백 수집 — 거부율은 `/admin/metrics`의 `peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움 피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기 @@ -147,14 +148,15 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다. 2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket + A1 대화/연락처/히스토리 + A2 세션/관리자 토큰). 푸시 알림만 남음 3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션· 되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서 -4. [~] 2.3 Flutter 클라이언트 — `mobile/` 골격 착수 완료(가입·채팅·뱃지·거부권·되돌리기·자율성 - 설정). 남은 것: 대화방/연락처 목록 API 연동, 온보딩 말투 UX, drift+SQLCipher, 수동 QA +4. [~] 2.3 Flutter 클라이언트 — A3 + B(drift/SQLCipher·데이터흐름·세션) 완료 + API E2E. + 남은 것: 실제 Firebase FCM 토큰, Android UI 탭(`mobile/README.md`), C 배포 준비 5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료. - 클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 2.3 나머지와 함께 + 클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께 - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은 §3(사람 PoC) 이후** - 5-2. [x] 화이트리스트 규칙 CRUD API -6. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 2.6 실제 베타 오픈 (**맨 마지막**) +6. [x] Phase 1 C 베타 직전 — Q1~Q7 확정, 초대 운영, 프로토타입 앵커, Android 릴리즈 경로 +7. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 실제 베타 오픈 (**맨 마지막 / D**) #### 5. 앞으로의 개발 계획 (우선순위 체크리스트) @@ -176,27 +178,37 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase - [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`) **A3. Flutter — 메신저답게 다듬기** (A1/A2 이후) -- [ ] 대화 목록/연락처 UI를 서버 API에 연결 -- [ ] 온보딩 뼈대 확장 (말투 샘플 입력 UI) -- [ ] L1 승인 플로우 UX 정리 -- [ ] 사후 알림 함 -- [ ] 에뮬레이터/실기기 E2E 수동 QA +- [x] 대화 목록/연락처 UI를 서버 API에 연결 +- [x] 온보딩 뼈대 확장 (말투 샘플 입력 UI) +- [x] L1 승인 플로우 UX 정리 +- [x] 사후 알림 함 +- [x] E2E QA — `scripts/e2e_a3.py`로 A3 HTTP 플로우 16/16 통과(가입·연락처·대화·히스토리· + draft/L1·에스컬레이션·알림 로그·되돌리기·거부권·화이트리스트). `go test`/`pytest`/`flutter test` + 동시 통과. Android 에뮬레이터 UI 탭은 이 환경에 SDK가 없어 체크리스트는 `mobile/README.md`에 유지 ##### B. 그다음 — 베타 품질 -- [ ] FCM 푸시 연동 -- [ ] 멀티 디바이스 동기화 -- [ ] drift + SQLCipher 로컬 저장 -- [ ] 말투 이력 기기 내 저장 + 서버 최소 전송 -- [ ] 데이터 흐름 표시 UI -- [ ] 생성 지연시간·오류율 계측 -- [ ] 모니터링 대시보드(최소) -- [ ] 본인확인 응답 문구 고정/검증 +- [~] 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; 오프라인 큐는 후속 +- [x] drift + SQLCipher 로컬 저장 — `mobile/lib/db/` (말투 샘플·KV). 키는 + `flutter_secure_storage`. Linux CI는 SQLCipher SO 없으면 메모리 폴백 +- [x] 말투 이력 기기 내 저장 + 서버 최소 전송 — drift 암호화 저장, draft에 샘플만 전달 +- [x] 데이터 흐름 표시 UI — `DataFlowScreen` +- [x] 생성 지연시간·오류율 계측 — process-local `RuntimeMetrics` → `/admin/metrics` +- [x] 모니터링 대시보드(최소) — `GET /admin/dashboard` +- [x] 본인확인 응답 문구 고정/검증 — `ai-service/app/identity.py` + draft 경로 테스트 ##### C. 베타 직전 -- [ ] Android 릴리즈 빌드·서명·배포 경로 -- [ ] 초대 코드 운영 절차(발급자 권한) -- [ ] Q1~Q7 회의 확정 (제안 → 확정) -- [ ] 클릭 프로토타입 공유 링크 docs 고정 +- [x] Android 릴리즈 빌드·서명·배포 경로 — `docs/android-release.md`, + `scripts/build_release_apk.sh` / `build_release_aab.sh`, `key.properties` 서명 훅. + keystore는 Master 로컬 전용(내부 APK 우선) +- [x] 초대 코드 운영 절차(발급자 권한) — `docs/invite-ops.md` + note/만료/배치/회수 API + (`GET|POST /invites`, `POST /invites/:code/revoke`) +- [x] Q1~Q7 회의 확정 (제안 → 확정) — `docs/decision-log.md` (2026-07-30). PoC 의존 하위 + 질문만 열어둠 +- [x] 클릭 프로토타입 공유 링크 docs 고정 — `docs/prototype.md` 앵커. `SHARE_URL`은 Master 기입 ##### D. 맨 마지막 — 사람 PoC (지금 안 함) - §3 항목과 동일. A~C 완료 후에만 착수. diff --git a/docs/tech-design.md b/docs/tech-design.md index 3e3ce4a..a193e57 100644 --- a/docs/tech-design.md +++ b/docs/tech-design.md @@ -130,7 +130,7 @@ Python으로 남긴다. | 서비스 간 통신 | Go 코어 → Python AI 서비스, 내부망 HTTP(REST) | 처음부터 gRPC 등으로 과설계하지 않음 — 필요해지면 그때 전환 | | 메시지 릴레이 | Go 코어 서비스 내 WebSocket | 자체 서버로 충분한 규모. Kafka·관리형 pub-sub은 지금 시점에 과한 인프라 | | 데이터베이스 | PostgreSQL | users/contacts/conversations/messages/escalation_logs/whitelist_rules 관계형 스키마에 적합. Go 쪽 접근은 `pgx`나 GORM | -| 온디바이스 저장소 | Flutter `drift`(SQLite) + `sqlcipher_flutter_libs` 암호화 | 말투 이력·설정을 기기 내 암호화 저장한다는 §2/§5 원칙을 그대로 구현 | +| 온디바이스 저장소 | Flutter `drift` + `sqlcipher_flutter_libs` (`PRAGMA key`, 패스프레이즈는 `flutter_secure_storage`) | 말투 이력·설정을 기기 내 암호화 저장한다는 §2/§5 원칙. 구현: `mobile/lib/db/` | | Gemini API 키 관리 | Python AI 서비스에서만 보관 (Go 코어는 키를 안 가짐) | `poc/tone-corpus/.env`는 PoC 전용 — 프로덕션 키·쿼터는 AI 서비스 환경에서만 분리 관리 | 이 표 밖의 결정(자율성 기본값, 화이트리스트 기본 주제, 신뢰 UX 문구)은 `roadmap.md` Phase 1 §3에 diff --git a/mobile/README.md b/mobile/README.md index b575924..22b0501 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -1,16 +1,19 @@ # 분신 mobile (Flutter) -Phase 1 클라이언트 골격 (`docs/roadmap.md` §2.3). **v1은 Android 빌드만** 대상으로 한다 +Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3). **v1은 Android 빌드만** 대상으로 한다 (`docs/tech-design.md` §8). ## 지금 있는 것 -- 초대 코드 가입 (`POST /auth/signup`) -- 대화방 입장(임시: 대화방 ID 직접 입력 — 목록 API는 아직 core-backend에 없음) -- 채팅 전송 + WebSocket 수신 -- 분신 초안 요청 / L1 승인 발송 -- 분신 뱃지(점선 + 라벨) · 거부권 · 되돌리기 -- 자율성 L0~L2 설정 + 화이트리스트 CRUD UI +- 초대 코드 가입 (`POST /auth/signup`) + Bearer 세션 +- 말투 샘플 온보딩(기기 로컬 저장; 최종 카피는 PoC 이후) +- 대화방 목록/생성 (`GET`/`POST /conversations`) — 수동 ID 입력 제거 +- 연락처 CRUD + 대화 시작 (`/users/:id/contacts`) +- 채팅 히스토리 로드 + WebSocket 수신 +- 분신 초안 / L1 승인(수정·버리기·재초안·승인 발송) +- 분신 뱃지 · 거부권 · 되돌리기 +- 사후 알림 함 (`GET /users/:id/escalation-logs`) +- 자율성 L0~L2 + 화이트리스트 CRUD UI ## 실행 @@ -26,10 +29,43 @@ flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080 flutter run --dart-define=CORE_API_BASE=http://:8080 ``` +## E2E + +**API 레벨 (클라우드/CI에서 실행 가능)** + +```bash +# core-backend :8080 + ai-service :8001 기동 후 +export ADMIN_API_TOKEN=dev-admin-token +python3 scripts/e2e_a3.py +``` + +A3 체크리스트(가입·연락처·대화·히스토리·draft/L1·에스컬레이션·되돌리기·거부권·화이트리스트)를 +HTTP로 검증한다. + +**에뮬레이터/실기기 UI 탭 (Android SDK 필요)** + +1. 초대 코드로 가입 → 말투 샘플 저장(또는 나중에) +2. 연락처에 상대 사용자 ID 등록 → 대화 시작 +3. 메시지 전송 · 새로고침 후 히스토리 유지 +4. 초안 요청 → L1 패널에서 수정/버리기/승인 발송 → 분신 뱃지 확인 +5. 민감 문장으로 에스컬레이션 → 사후 알림 함 진입 +6. 분신 메시지 되돌리기 · 거부권 +7. 자율성 L0~L2 + 화이트리스트 추가/삭제 + +## 로컬 DB (drift + SQLCipher) + +말투 샘플·온보딩 플래그는 `lib/db/` 암호화 SQLite에 저장한다. 패스프레이즈는 +`flutter_secure_storage`. Linux 개발 호스트에 `libsqlcipher.so`가 없으면 메모리 폴백으로 +기동한다(Android 릴리즈 경로에서는 SQLCipher 사용). + +```bash +# Linux에서 drift 테스트/암호화 DB를 쓰려면 +sudo apt-get install -y libsqlite3-dev libsqlcipher1 +``` + ## 아직 없는 것 -- 대화방/연락처 목록 API 연동 (서버에 엔드포인트 추가 필요) -- 온보딩 말투 학습 UX 디테일 (PoC #1 결과는 맨 마지막에 반영) -- drift + SQLCipher 온디바이스 저장 -- 푸시 알림 +- Firebase 프로젝트의 실제 FCM registration token 연동 (`google-services.json`) +- 오프라인 메시지 큐 / 멀티디바이스 실시간 설정 동기화 고도화 +- 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영) - iOS 빌드 (v1 범위 밖) diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore index be3943c..a291f3c 100644 --- a/mobile/android/.gitignore +++ b/mobile/android/.gitignore @@ -12,3 +12,5 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks +*.jks +*.keystore diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 9ed1068..5617d6a 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.io.FileInputStream +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -5,6 +8,13 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +val hasReleaseKeystore = keystorePropertiesFile.exists() +if (hasReleaseKeystore) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { namespace = "com.bunsin.bunsin_mobile" compileSdk = flutter.compileSdkVersion @@ -20,21 +30,33 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.bunsin.bunsin_mobile" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } + signingConfigs { + if (hasReleaseKeystore) { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Prefer release keystore when key.properties exists; else debug + // (smoke only — see docs/android-release.md). + signingConfig = if (hasReleaseKeystore) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } } diff --git a/mobile/android/key.properties.example b/mobile/android/key.properties.example new file mode 100644 index 0000000..7565a5b --- /dev/null +++ b/mobile/android/key.properties.example @@ -0,0 +1,4 @@ +storePassword=replace-me +keyPassword=replace-me +keyAlias=bunsin +storeFile=/absolute/path/to/bunsin-release.jks diff --git a/mobile/lib/db/app_database.dart b/mobile/lib/db/app_database.dart new file mode 100644 index 0000000..d8b8a20 --- /dev/null +++ b/mobile/lib/db/app_database.dart @@ -0,0 +1,128 @@ +import 'dart:ffi'; +import 'dart:io'; +import 'dart:math'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart'; +import 'package:sqlite3/open.dart'; + +import 'tables.dart'; + +part 'app_database.g.dart'; + +@DriftDatabase(tables: [ToneSamples, LocalKv]) +class AppDatabase extends _$AppDatabase { + AppDatabase(super.e); + + @override + int get schemaVersion => 1; + + /// In-memory DB for unit tests (no SQLCipher / filesystem). + factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory()); + + /// Production opener: encrypted on-device file (tech-design.md §8). + static Future open() async { + final db = AppDatabase(_openEncryptedExecutor()); + // Force open so missing SQLCipher SO fails here (caller can fall back). + await db.customSelect('SELECT 1').get(); + return db; + } + + Future> loadToneSamples() async { + final rows = await (select(toneSamples)..orderBy([(t) => OrderingTerm.asc(t.sortOrder)])).get(); + return rows.map((r) => r.sampleText).toList(); + } + + Future replaceToneSamples(List samples) async { + final now = DateTime.now().millisecondsSinceEpoch; + await transaction(() async { + await delete(toneSamples).go(); + for (var i = 0; i < samples.length; i++) { + await into(toneSamples).insert( + ToneSamplesCompanion.insert( + id: 'tone_${i}_$now', + sampleText: samples[i], + createdAtMs: now, + sortOrder: i, + ), + ); + } + }); + } + + Future getKv(String key) async { + final row = await (select(localKv)..where((t) => t.key.equals(key))).getSingleOrNull(); + return row?.value; + } + + Future setKv(String key, String value) async { + await into(localKv).insertOnConflictUpdate(LocalKvCompanion.insert(key: key, value: value)); + } + + Future getBoolKv(String key, {bool defaultValue = false}) async { + final v = await getKv(key); + if (v == null) return defaultValue; + return v == '1' || v.toLowerCase() == 'true'; + } + + Future setBoolKv(String key, bool value) async { + await setKv(key, value ? '1' : '0'); + } +} + +const _kDbPassphrase = 'db_passphrase_v1'; + +QueryExecutor _openEncryptedExecutor() { + return LazyDatabase(() async { + final dir = await getApplicationDocumentsDirectory(); + final file = File(p.join(dir.path, 'bunsin_encrypted.db')); + final passphrase = await _loadOrCreatePassphrase(); + + // Background isolate does not inherit open.overrideFor — re-apply there. + final token = RootIsolateToken.instance; + return NativeDatabase.createInBackground( + file, + isolateSetup: () async { + if (token != null) { + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + } + await _configureSqlCipherOpen(); + }, + setup: (rawDb) { + // SQLCipher key must be set before any other statement. + final escaped = passphrase.replaceAll("'", "''"); + rawDb.execute("PRAGMA key = '$escaped'"); + rawDb.config.doubleQuotedStringLiterals = false; + }, + ); + }); +} + +Future _configureSqlCipherOpen() async { + if (Platform.isAndroid) { + await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions(); + open.overrideFor(OperatingSystem.android, openCipherOnAndroid); + } else if (Platform.isLinux) { + // Desktop/dev: try SQLCipher SO; fall back is handled by open() failure upstream. + open.overrideFor(OperatingSystem.linux, () => DynamicLibrary.open('libsqlcipher.so')); + } else if (Platform.isWindows) { + open.overrideFor(OperatingSystem.windows, () => DynamicLibrary.open('sqlcipher.dll')); + } + // iOS/macOS: sqlcipher_flutter_libs links into the process. +} + +Future _loadOrCreatePassphrase() async { + const storage = FlutterSecureStorage(); + final existing = await storage.read(key: _kDbPassphrase); + if (existing != null && existing.isNotEmpty) return existing; + final rand = Random.secure(); + final bytes = List.generate(32, (_) => rand.nextInt(256)); + final passphrase = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + await storage.write(key: _kDbPassphrase, value: passphrase); + return passphrase; +} diff --git a/mobile/lib/db/app_database.g.dart b/mobile/lib/db/app_database.g.dart new file mode 100644 index 0000000..b64050c --- /dev/null +++ b/mobile/lib/db/app_database.g.dart @@ -0,0 +1,873 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_database.dart'; + +// ignore_for_file: type=lint +class $ToneSamplesTable extends ToneSamples + with TableInfo<$ToneSamplesTable, ToneSample> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ToneSamplesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _sampleTextMeta = const VerificationMeta( + 'sampleText', + ); + @override + late final GeneratedColumn sampleText = GeneratedColumn( + 'sample_text', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMsMeta = const VerificationMeta( + 'createdAtMs', + ); + @override + late final GeneratedColumn createdAtMs = GeneratedColumn( + 'created_at_ms', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _sortOrderMeta = const VerificationMeta( + 'sortOrder', + ); + @override + late final GeneratedColumn sortOrder = GeneratedColumn( + 'sort_order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + sampleText, + createdAtMs, + sortOrder, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tone_samples'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('sample_text')) { + context.handle( + _sampleTextMeta, + sampleText.isAcceptableOrUnknown(data['sample_text']!, _sampleTextMeta), + ); + } else if (isInserting) { + context.missing(_sampleTextMeta); + } + if (data.containsKey('created_at_ms')) { + context.handle( + _createdAtMsMeta, + createdAtMs.isAcceptableOrUnknown( + data['created_at_ms']!, + _createdAtMsMeta, + ), + ); + } else if (isInserting) { + context.missing(_createdAtMsMeta); + } + if (data.containsKey('sort_order')) { + context.handle( + _sortOrderMeta, + sortOrder.isAcceptableOrUnknown(data['sort_order']!, _sortOrderMeta), + ); + } else if (isInserting) { + context.missing(_sortOrderMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ToneSample map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ToneSample( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + sampleText: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}sample_text'], + )!, + createdAtMs: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at_ms'], + )!, + sortOrder: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sort_order'], + )!, + ); + } + + @override + $ToneSamplesTable createAlias(String alias) { + return $ToneSamplesTable(attachedDatabase, alias); + } +} + +class ToneSample extends DataClass implements Insertable { + final String id; + final String sampleText; + final int createdAtMs; + final int sortOrder; + const ToneSample({ + required this.id, + required this.sampleText, + required this.createdAtMs, + required this.sortOrder, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['sample_text'] = Variable(sampleText); + map['created_at_ms'] = Variable(createdAtMs); + map['sort_order'] = Variable(sortOrder); + return map; + } + + ToneSamplesCompanion toCompanion(bool nullToAbsent) { + return ToneSamplesCompanion( + id: Value(id), + sampleText: Value(sampleText), + createdAtMs: Value(createdAtMs), + sortOrder: Value(sortOrder), + ); + } + + factory ToneSample.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ToneSample( + id: serializer.fromJson(json['id']), + sampleText: serializer.fromJson(json['sampleText']), + createdAtMs: serializer.fromJson(json['createdAtMs']), + sortOrder: serializer.fromJson(json['sortOrder']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'sampleText': serializer.toJson(sampleText), + 'createdAtMs': serializer.toJson(createdAtMs), + 'sortOrder': serializer.toJson(sortOrder), + }; + } + + ToneSample copyWith({ + String? id, + String? sampleText, + int? createdAtMs, + int? sortOrder, + }) => ToneSample( + id: id ?? this.id, + sampleText: sampleText ?? this.sampleText, + createdAtMs: createdAtMs ?? this.createdAtMs, + sortOrder: sortOrder ?? this.sortOrder, + ); + ToneSample copyWithCompanion(ToneSamplesCompanion data) { + return ToneSample( + id: data.id.present ? data.id.value : this.id, + sampleText: data.sampleText.present + ? data.sampleText.value + : this.sampleText, + createdAtMs: data.createdAtMs.present + ? data.createdAtMs.value + : this.createdAtMs, + sortOrder: data.sortOrder.present ? data.sortOrder.value : this.sortOrder, + ); + } + + @override + String toString() { + return (StringBuffer('ToneSample(') + ..write('id: $id, ') + ..write('sampleText: $sampleText, ') + ..write('createdAtMs: $createdAtMs, ') + ..write('sortOrder: $sortOrder') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, sampleText, createdAtMs, sortOrder); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ToneSample && + other.id == this.id && + other.sampleText == this.sampleText && + other.createdAtMs == this.createdAtMs && + other.sortOrder == this.sortOrder); +} + +class ToneSamplesCompanion extends UpdateCompanion { + final Value id; + final Value sampleText; + final Value createdAtMs; + final Value sortOrder; + final Value rowid; + const ToneSamplesCompanion({ + this.id = const Value.absent(), + this.sampleText = const Value.absent(), + this.createdAtMs = const Value.absent(), + this.sortOrder = const Value.absent(), + this.rowid = const Value.absent(), + }); + ToneSamplesCompanion.insert({ + required String id, + required String sampleText, + required int createdAtMs, + required int sortOrder, + this.rowid = const Value.absent(), + }) : id = Value(id), + sampleText = Value(sampleText), + createdAtMs = Value(createdAtMs), + sortOrder = Value(sortOrder); + static Insertable custom({ + Expression? id, + Expression? sampleText, + Expression? createdAtMs, + Expression? sortOrder, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (sampleText != null) 'sample_text': sampleText, + if (createdAtMs != null) 'created_at_ms': createdAtMs, + if (sortOrder != null) 'sort_order': sortOrder, + if (rowid != null) 'rowid': rowid, + }); + } + + ToneSamplesCompanion copyWith({ + Value? id, + Value? sampleText, + Value? createdAtMs, + Value? sortOrder, + Value? rowid, + }) { + return ToneSamplesCompanion( + id: id ?? this.id, + sampleText: sampleText ?? this.sampleText, + createdAtMs: createdAtMs ?? this.createdAtMs, + sortOrder: sortOrder ?? this.sortOrder, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (sampleText.present) { + map['sample_text'] = Variable(sampleText.value); + } + if (createdAtMs.present) { + map['created_at_ms'] = Variable(createdAtMs.value); + } + if (sortOrder.present) { + map['sort_order'] = Variable(sortOrder.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ToneSamplesCompanion(') + ..write('id: $id, ') + ..write('sampleText: $sampleText, ') + ..write('createdAtMs: $createdAtMs, ') + ..write('sortOrder: $sortOrder, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $LocalKvTable extends LocalKv with TableInfo<$LocalKvTable, LocalKvData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $LocalKvTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _keyMeta = const VerificationMeta('key'); + @override + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _valueMeta = const VerificationMeta('value'); + @override + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_kv'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('key')) { + context.handle( + _keyMeta, + key.isAcceptableOrUnknown(data['key']!, _keyMeta), + ); + } else if (isInserting) { + context.missing(_keyMeta); + } + if (data.containsKey('value')) { + context.handle( + _valueMeta, + value.isAcceptableOrUnknown(data['value']!, _valueMeta), + ); + } else if (isInserting) { + context.missing(_valueMeta); + } + return context; + } + + @override + Set get $primaryKey => {key}; + @override + LocalKvData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalKvData( + key: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + $LocalKvTable createAlias(String alias) { + return $LocalKvTable(attachedDatabase, alias); + } +} + +class LocalKvData extends DataClass implements Insertable { + final String key; + final String value; + const LocalKvData({required this.key, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + map['value'] = Variable(value); + return map; + } + + LocalKvCompanion toCompanion(bool nullToAbsent) { + return LocalKvCompanion(key: Value(key), value: Value(value)); + } + + factory LocalKvData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalKvData( + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + }; + } + + LocalKvData copyWith({String? key, String? value}) => + LocalKvData(key: key ?? this.key, value: value ?? this.value); + LocalKvData copyWithCompanion(LocalKvCompanion data) { + return LocalKvData( + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('LocalKvData(') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalKvData && + other.key == this.key && + other.value == this.value); +} + +class LocalKvCompanion extends UpdateCompanion { + final Value key; + final Value value; + final Value rowid; + const LocalKvCompanion({ + this.key = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + LocalKvCompanion.insert({ + required String key, + required String value, + this.rowid = const Value.absent(), + }) : key = Value(key), + value = Value(value); + static Insertable custom({ + Expression? key, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + LocalKvCompanion copyWith({ + Value? key, + Value? value, + Value? rowid, + }) { + return LocalKvCompanion( + key: key ?? this.key, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalKvCompanion(') + ..write('key: $key, ') + ..write('value: $value, ') + ..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); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [toneSamples, localKv]; +} + +typedef $$ToneSamplesTableCreateCompanionBuilder = + ToneSamplesCompanion Function({ + required String id, + required String sampleText, + required int createdAtMs, + required int sortOrder, + Value rowid, + }); +typedef $$ToneSamplesTableUpdateCompanionBuilder = + ToneSamplesCompanion Function({ + Value id, + Value sampleText, + Value createdAtMs, + Value sortOrder, + Value rowid, + }); + +class $$ToneSamplesTableFilterComposer + extends Composer<_$AppDatabase, $ToneSamplesTable> { + $$ToneSamplesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sampleText => $composableBuilder( + column: $table.sampleText, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAtMs => $composableBuilder( + column: $table.createdAtMs, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sortOrder => $composableBuilder( + column: $table.sortOrder, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ToneSamplesTableOrderingComposer + extends Composer<_$AppDatabase, $ToneSamplesTable> { + $$ToneSamplesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sampleText => $composableBuilder( + column: $table.sampleText, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAtMs => $composableBuilder( + column: $table.createdAtMs, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sortOrder => $composableBuilder( + column: $table.sortOrder, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ToneSamplesTableAnnotationComposer + extends Composer<_$AppDatabase, $ToneSamplesTable> { + $$ToneSamplesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get sampleText => $composableBuilder( + column: $table.sampleText, + builder: (column) => column, + ); + + GeneratedColumn get createdAtMs => $composableBuilder( + column: $table.createdAtMs, + builder: (column) => column, + ); + + GeneratedColumn get sortOrder => + $composableBuilder(column: $table.sortOrder, builder: (column) => column); +} + +class $$ToneSamplesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ToneSamplesTable, + ToneSample, + $$ToneSamplesTableFilterComposer, + $$ToneSamplesTableOrderingComposer, + $$ToneSamplesTableAnnotationComposer, + $$ToneSamplesTableCreateCompanionBuilder, + $$ToneSamplesTableUpdateCompanionBuilder, + ( + ToneSample, + BaseReferences<_$AppDatabase, $ToneSamplesTable, ToneSample>, + ), + ToneSample, + PrefetchHooks Function() + > { + $$ToneSamplesTableTableManager(_$AppDatabase db, $ToneSamplesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ToneSamplesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ToneSamplesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ToneSamplesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value sampleText = const Value.absent(), + Value createdAtMs = const Value.absent(), + Value sortOrder = const Value.absent(), + Value rowid = const Value.absent(), + }) => ToneSamplesCompanion( + id: id, + sampleText: sampleText, + createdAtMs: createdAtMs, + sortOrder: sortOrder, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String sampleText, + required int createdAtMs, + required int sortOrder, + Value rowid = const Value.absent(), + }) => ToneSamplesCompanion.insert( + id: id, + sampleText: sampleText, + createdAtMs: createdAtMs, + sortOrder: sortOrder, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ToneSamplesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ToneSamplesTable, + ToneSample, + $$ToneSamplesTableFilterComposer, + $$ToneSamplesTableOrderingComposer, + $$ToneSamplesTableAnnotationComposer, + $$ToneSamplesTableCreateCompanionBuilder, + $$ToneSamplesTableUpdateCompanionBuilder, + ( + ToneSample, + BaseReferences<_$AppDatabase, $ToneSamplesTable, ToneSample>, + ), + ToneSample, + PrefetchHooks Function() + >; +typedef $$LocalKvTableCreateCompanionBuilder = + LocalKvCompanion Function({ + required String key, + required String value, + Value rowid, + }); +typedef $$LocalKvTableUpdateCompanionBuilder = + LocalKvCompanion Function({ + Value key, + Value value, + Value rowid, + }); + +class $$LocalKvTableFilterComposer + extends Composer<_$AppDatabase, $LocalKvTable> { + $$LocalKvTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get key => $composableBuilder( + column: $table.key, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnFilters(column), + ); +} + +class $$LocalKvTableOrderingComposer + extends Composer<_$AppDatabase, $LocalKvTable> { + $$LocalKvTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get key => $composableBuilder( + column: $table.key, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$LocalKvTableAnnotationComposer + extends Composer<_$AppDatabase, $LocalKvTable> { + $$LocalKvTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get key => + $composableBuilder(column: $table.key, builder: (column) => column); + + GeneratedColumn get value => + $composableBuilder(column: $table.value, builder: (column) => column); +} + +class $$LocalKvTableTableManager + extends + RootTableManager< + _$AppDatabase, + $LocalKvTable, + LocalKvData, + $$LocalKvTableFilterComposer, + $$LocalKvTableOrderingComposer, + $$LocalKvTableAnnotationComposer, + $$LocalKvTableCreateCompanionBuilder, + $$LocalKvTableUpdateCompanionBuilder, + ( + LocalKvData, + BaseReferences<_$AppDatabase, $LocalKvTable, LocalKvData>, + ), + LocalKvData, + PrefetchHooks Function() + > { + $$LocalKvTableTableManager(_$AppDatabase db, $LocalKvTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$LocalKvTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$LocalKvTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$LocalKvTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value key = const Value.absent(), + Value value = const Value.absent(), + Value rowid = const Value.absent(), + }) => LocalKvCompanion(key: key, value: value, rowid: rowid), + createCompanionCallback: + ({ + required String key, + required String value, + Value rowid = const Value.absent(), + }) => + LocalKvCompanion.insert(key: key, value: value, rowid: rowid), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$LocalKvTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $LocalKvTable, + LocalKvData, + $$LocalKvTableFilterComposer, + $$LocalKvTableOrderingComposer, + $$LocalKvTableAnnotationComposer, + $$LocalKvTableCreateCompanionBuilder, + $$LocalKvTableUpdateCompanionBuilder, + (LocalKvData, BaseReferences<_$AppDatabase, $LocalKvTable, LocalKvData>), + LocalKvData, + PrefetchHooks Function() + >; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$ToneSamplesTableTableManager get toneSamples => + $$ToneSamplesTableTableManager(_db, _db.toneSamples); + $$LocalKvTableTableManager get localKv => + $$LocalKvTableTableManager(_db, _db.localKv); +} diff --git a/mobile/lib/db/tables.dart b/mobile/lib/db/tables.dart new file mode 100644 index 0000000..9ae5ada --- /dev/null +++ b/mobile/lib/db/tables.dart @@ -0,0 +1,21 @@ +import 'package:drift/drift.dart'; + +/// On-device tone / style samples (tech-design.md §2 — never upload the full corpus). +class ToneSamples extends Table { + TextColumn get id => text()(); + TextColumn get sampleText => text()(); + IntColumn get createdAtMs => integer()(); + IntColumn get sortOrder => integer()(); + + @override + Set> get primaryKey => {id}; +} + +/// Small key/value bag for onboarding flags and local prefs that must stay encrypted. +class LocalKv extends Table { + TextColumn get key => text()(); + TextColumn get value => text()(); + + @override + Set> get primaryKey => {key}; +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 1e3c25e..3b35f5c 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'screens/conversation_list_screen.dart'; +import 'screens/onboarding_tone_screen.dart'; import 'screens/signup_screen.dart'; import 'state/session_state.dart'; @@ -33,6 +34,7 @@ class BunsinApp extends StatelessWidget { home: Consumer( builder: (context, s, _) { if (s.user == null) return const SignupScreen(); + if (!s.toneOnboardingDone) return const OnboardingToneScreen(); return const ConversationListScreen(); }, ), diff --git a/mobile/lib/models/models.dart b/mobile/lib/models/models.dart index d639edc..926fe90 100644 --- a/mobile/lib/models/models.dart +++ b/mobile/lib/models/models.dart @@ -34,6 +34,89 @@ class TwinSettings { } } +class ConversationSummary { + ConversationSummary({ + required this.id, + required this.isGroup, + required this.userIds, + required this.twinDisabledByPeer, + this.createdAt, + }); + + final int id; + final bool isGroup; + final List userIds; + final bool twinDisabledByPeer; + final DateTime? createdAt; + + factory ConversationSummary.fromJson(Map json) { + final rawIds = json['user_ids'] as List? ?? const []; + return ConversationSummary( + id: json['id'] as int, + isGroup: json['is_group'] as bool? ?? false, + userIds: rawIds.map((e) => e as int).toList(), + twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false, + createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''), + ); + } + + /// Title helper when peer display names are not yet loaded. + String titleFor(int myUserId) { + final peers = userIds.where((id) => id != myUserId).toList(); + if (isGroup) return '그룹 #$id'; + if (peers.isEmpty) return '나와의 대화 #$id'; + return '대화 · 상대 #${peers.first}'; + } +} + +class Contact { + Contact({ + required this.id, + required this.displayName, + this.contactUserId, + this.relationshipNote = '', + }); + + final int id; + final String displayName; + final int? contactUserId; + final String relationshipNote; + + factory Contact.fromJson(Map 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? ?? '', + ); +} + +class EscalationLogEntry { + EscalationLogEntry({ + required this.id, + required this.conversationId, + required this.reason, + required this.messageSnippet, + required this.resolved, + required this.createdAt, + }); + + final int id; + final int conversationId; + final String reason; + final String messageSnippet; + final bool resolved; + final DateTime createdAt; + + factory EscalationLogEntry.fromJson(Map json) => EscalationLogEntry( + id: json['id'] as int, + conversationId: json['conversation_id'] as int? ?? 0, + reason: json['reason'] as String? ?? '', + messageSnippet: json['message_snippet'] as String? ?? '', + resolved: json['resolved'] as bool? ?? false, + createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(), + ); +} + class ChatMessage { ChatMessage({ required this.id, diff --git a/mobile/lib/screens/autonomy_settings_screen.dart b/mobile/lib/screens/autonomy_settings_screen.dart index b718537..9954a05 100644 --- a/mobile/lib/screens/autonomy_settings_screen.dart +++ b/mobile/lib/screens/autonomy_settings_screen.dart @@ -4,6 +4,8 @@ import 'package:provider/provider.dart'; import '../models/models.dart'; import '../services/api_client.dart'; import '../state/session_state.dart'; +import 'data_flow_screen.dart'; +import 'sessions_screen.dart'; class AutonomySettingsScreen extends StatefulWidget { const AutonomySettingsScreen({super.key}); @@ -56,6 +58,25 @@ class _AutonomySettingsScreenState extends State { body: ListView( padding: const EdgeInsets.all(16), children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.privacy_tip_outlined), + title: const Text('데이터 흐름'), + subtitle: const Text('무엇이 기기에 남고 서버로 가는지'), + onTap: () { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DataFlowScreen())); + }, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.devices), + title: const Text('로그인 세션'), + subtitle: const Text('멀티 디바이스 세션 목록'), + onTap: () { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SessionsScreen())); + }, + ), + const Divider(height: 32), Text('전역 레벨', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), SegmentedButton( diff --git a/mobile/lib/screens/chat_screen.dart b/mobile/lib/screens/chat_screen.dart index 0c6c537..0e62e99 100644 --- a/mobile/lib/screens/chat_screen.dart +++ b/mobile/lib/screens/chat_screen.dart @@ -10,9 +10,10 @@ import '../state/session_state.dart'; import '../widgets/message_bubble.dart'; class ChatScreen extends StatefulWidget { - const ChatScreen({super.key, required this.conversationId}); + const ChatScreen({super.key, required this.conversationId, this.title}); final int conversationId; + final String? title; @override State createState() => _ChatScreenState(); @@ -20,18 +21,54 @@ class ChatScreen extends StatefulWidget { class _ChatScreenState extends State { final _input = TextEditingController(); + final _draftEdit = TextEditingController(); + final _scroll = ScrollController(); final _messages = []; ConversationSocket? _socket; StreamSubscription? _sub; String? _banner; DraftResult? _pendingDraft; bool _busy = false; + bool _loadingHistory = true; @override void initState() { super.initState(); _socket = ConversationSocket(widget.conversationId)..connect(); _sub = _socket!.events.listen(_onEvent); + _loadHistory(); + } + + Future _loadHistory() async { + final session = context.read(); + try { + final history = await session.api.listMessages(widget.conversationId); + if (!mounted) return; + setState(() { + _messages + ..clear() + ..addAll(history); + _loadingHistory = false; + }); + _scrollToEnd(); + } on ApiException catch (e) { + if (!mounted) return; + setState(() { + _loadingHistory = false; + _banner = '히스토리 로드 실패 (${e.statusCode})'; + }); + } + } + + void _scrollToEnd() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_scroll.hasClients) return; + _scroll.animateTo( + _scroll.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + }); } void _onEvent(Map event) { @@ -58,6 +95,7 @@ class _ChatScreenState extends State { if (msg == null) return; if (_messages.any((m) => m.id == msg.id)) return; setState(() => _messages.add(msg)); + _scrollToEnd(); } @override @@ -65,6 +103,8 @@ class _ChatScreenState extends State { _sub?.cancel(); _socket?.dispose(); _input.dispose(); + _draftEdit.dispose(); + _scroll.dispose(); super.dispose(); } @@ -83,6 +123,7 @@ class _ChatScreenState extends State { if (!_messages.any((m) => m.id == msg.id)) { setState(() => _messages.add(msg)); } + _scrollToEnd(); } on ApiException catch (e) { setState(() => _banner = '전송 실패 (${e.statusCode})'); } finally { @@ -105,15 +146,19 @@ class _ChatScreenState extends State { if (_input.text.trim().isNotEmpty) { contextLines.add('상대: ${_input.text.trim()}'); } + final style = session.styleExamples.isNotEmpty + ? session.styleExamples + : const ['ㅇㅇ 알겠음', 'ㅋㅋ 그래', '나중에 연락할게']; final draft = await session.api.requestDraft( conversationId: widget.conversationId, contextLines: contextLines.isEmpty ? ['상대: 안녕'] : contextLines, - styleExamples: const ['ㅇㅇ 알겠음', 'ㅋㅋ 그래', '나중에 연락할게'], + styleExamples: style, ); setState(() { _pendingDraft = draft; + _draftEdit.text = draft.isEscalate ? '' : draft.text; if (draft.isEscalate) { - _banner = '에스컬레이션: ${draft.text}'; + _banner = null; } }); } on ApiException catch (e) { @@ -127,19 +172,23 @@ class _ChatScreenState extends State { final session = context.read(); final draft = _pendingDraft; if (draft == null || draft.isEscalate || session.user == null) return; + final text = _draftEdit.text.trim(); + if (text.isEmpty) return; setState(() => _busy = true); try { final msg = await session.api.sendMessage( conversationId: widget.conversationId, senderId: session.user!.id, - text: draft.text, + text: text, senderMode: SenderMode.twin, approved: true, ); setState(() { _pendingDraft = null; + _draftEdit.clear(); if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg); }); + _scrollToEnd(); } on ApiException catch (e) { setState(() => _banner = '분신 발송 차단 (${e.statusCode}): ${e.body}'); } finally { @@ -147,6 +196,14 @@ class _ChatScreenState extends State { } } + void _rejectDraft() { + setState(() { + _pendingDraft = null; + _draftEdit.clear(); + _banner = '초안을 버렸습니다. 직접 쓰거나 다시 초안을 요청하세요.'; + }); + } + Future _veto() async { final session = context.read(); try { @@ -166,6 +223,84 @@ class _ChatScreenState extends State { } } + Widget _buildL1Panel(BuildContext context) { + final theme = Theme.of(context); + final draft = _pendingDraft; + if (draft == null) return const SizedBox.shrink(); + + if (draft.isEscalate) { + return Material( + color: theme.colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('직접 확인 필요', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + Text(draft.text.isEmpty ? '민감·확정성 내용으로 분신 발송이 보류되었습니다.' : draft.text), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton(onPressed: _rejectDraft, child: const Text('닫기')), + ), + ], + ), + ), + ); + } + + return Material( + elevation: 2, + color: theme.colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.onSecondaryContainer), + const SizedBox(width: 6), + Text( + 'L1 승인 — 수정 후 보내기', + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: _draftEdit, + minLines: 2, + maxLines: 5, + decoration: const InputDecoration( + border: OutlineInputBorder(), + filled: true, + fillColor: Colors.white70, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + TextButton(onPressed: _busy ? null : _rejectDraft, child: const Text('버리기')), + const Spacer(), + FilledButton.tonal( + onPressed: _busy ? null : _requestDraft, + child: const Text('다시 초안'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _busy ? null : _sendTwinApproved, + child: const Text('승인하고 보내기'), + ), + ], + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final session = context.watch(); @@ -173,7 +308,7 @@ class _ChatScreenState extends State { return Scaffold( appBar: AppBar( - title: Text('대화방 #${widget.conversationId}'), + title: Text(widget.title ?? '대화방 #${widget.conversationId}'), actions: [ TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')), ], @@ -188,31 +323,23 @@ class _ChatScreenState extends State { ], ), Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _messages.length, - itemBuilder: (context, i) { - final m = _messages[i]; - return MessageBubble( - message: m, - isMine: me != null && m.senderId == me, - onRetract: m.isTwin ? () => _retract(m) : null, - ); - }, - ), + child: _loadingHistory + ? const Center(child: CircularProgressIndicator()) + : ListView.builder( + controller: _scroll, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _messages.length, + itemBuilder: (context, i) { + final m = _messages[i]; + return MessageBubble( + message: m, + isMine: me != null && m.senderId == me, + onRetract: m.isTwin ? () => _retract(m) : null, + ); + }, + ), ), - if (_pendingDraft != null && !_pendingDraft!.isEscalate) - Material( - color: Theme.of(context).colorScheme.secondaryContainer, - child: ListTile( - title: const Text('분신 초안 (L1 승인)'), - subtitle: Text(_pendingDraft!.text), - trailing: FilledButton( - onPressed: _busy ? null : _sendTwinApproved, - child: const Text('보내기'), - ), - ), - ), + _buildL1Panel(context), SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), diff --git a/mobile/lib/screens/contacts_screen.dart b/mobile/lib/screens/contacts_screen.dart new file mode 100644 index 0000000..ec86ac3 --- /dev/null +++ b/mobile/lib/screens/contacts_screen.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/models.dart'; +import '../services/api_client.dart'; +import '../state/session_state.dart'; +import 'chat_screen.dart'; + +class ContactsScreen extends StatefulWidget { + const ContactsScreen({super.key}); + + @override + State createState() => _ContactsScreenState(); +} + +class _ContactsScreenState extends State { + List _contacts = []; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final session = context.read(); + if (session.user == null) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final list = await session.api.listContacts(session.user!.id); + setState(() => _contacts = list); + } on ApiException catch (e) { + setState(() => _error = '연락처 로드 실패 (${e.statusCode})'); + } finally { + setState(() => _loading = false); + } + } + + Future _showAddDialog() async { + final nameCtrl = TextEditingController(); + final peerCtrl = TextEditingController(); + final noteCtrl = TextEditingController(); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('연락처 추가'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameCtrl, + decoration: const InputDecoration(labelText: '표시 이름', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: peerCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '상대 사용자 ID (선택)', + helperText: '대화 시작에 필요', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: noteCtrl, + decoration: const InputDecoration(labelText: '관계 메모 (선택)', border: OutlineInputBorder()), + ), + ], + ), + 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 session = context.read(); + final name = nameCtrl.text.trim(); + if (name.isEmpty || session.user == null) return; + try { + final peer = int.tryParse(peerCtrl.text.trim()); + final created = await session.api.createContact( + userId: session.user!.id, + displayName: name, + contactUserId: peer, + relationshipNote: noteCtrl.text.trim(), + ); + setState(() => _contacts = [..._contacts, created]); + } on ApiException catch (e) { + setState(() => _error = '추가 실패 (${e.statusCode})'); + } + } + + Future _startChat(Contact contact) async { + final session = context.read(); + final me = session.user; + if (me == null || contact.contactUserId == null) { + setState(() => _error = '상대 사용자 ID가 있는 연락처만 대화를 시작할 수 있습니다.'); + return; + } + try { + final conv = await session.api.createConversation( + userIds: [me.id, contact.contactUserId!], + contactId: contact.id, + ); + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ChatScreen(conversationId: conv.id, title: contact.displayName)), + ); + } on ApiException catch (e) { + setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('연락처')), + floatingActionButton: FloatingActionButton( + onPressed: _showAddDialog, + child: const Icon(Icons.person_add_alt_1), + ), + body: RefreshIndicator( + onRefresh: _load, + child: _loading + ? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())]) + : ListView( + children: [ + if (_error != null) + Padding( + padding: const EdgeInsets.all(16), + child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), + if (_contacts.isEmpty) + const Padding( + padding: EdgeInsets.all(24), + child: Text('연락처가 없습니다. + 버튼으로 추가하세요.'), + ), + for (final c in _contacts) + ListTile( + leading: const CircleAvatar(child: Icon(Icons.person_outline)), + title: Text(c.displayName), + subtitle: Text( + [ + if (c.contactUserId != null) '사용자 #${c.contactUserId}', + if (c.relationshipNote.isNotEmpty) c.relationshipNote, + ].join(' · '), + ), + trailing: c.contactUserId == null + ? null + : TextButton(onPressed: () => _startChat(c), child: const Text('대화')), + onLongPress: () async { + final session = context.read(); + if (session.user == null) return; + await session.api.deleteContact(session.user!.id, c.id); + setState(() => _contacts = _contacts.where((x) => x.id != c.id).toList()); + }, + ), + const Padding( + padding: EdgeInsets.all(16), + child: Text('길게 누르면 삭제됩니다.', textAlign: TextAlign.center), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart index 9b31bb1..2003b4e 100644 --- a/mobile/lib/screens/conversation_list_screen.dart +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../models/models.dart'; +import '../services/api_client.dart'; import '../state/session_state.dart'; import 'autonomy_settings_screen.dart'; import 'chat_screen.dart'; +import 'contacts_screen.dart'; +import 'inbox_screen.dart'; +import 'onboarding_tone_screen.dart'; -/// v1 scaffold: conversation list is local until list API exists on core-backend. -/// Enter a conversation id to open a room (matches current Go API shape). class ConversationListScreen extends StatefulWidget { const ConversationListScreen({super.key}); @@ -15,24 +18,103 @@ class ConversationListScreen extends StatefulWidget { } class _ConversationListScreenState extends State { - final _convId = TextEditingController(text: '1'); - final _rooms = {1}; + List _rooms = []; + bool _loading = true; + String? _error; @override - void dispose() { - _convId.dispose(); - super.dispose(); + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final session = context.read(); + setState(() { + _loading = true; + _error = null; + }); + try { + final list = await session.api.listConversations(); + list.sort((a, b) => b.id.compareTo(a.id)); + setState(() => _rooms = list); + } on ApiException catch (e) { + setState(() => _error = '대화 목록 실패 (${e.statusCode}): ${e.body}'); + } finally { + setState(() => _loading = false); + } + } + + Future _createConversation() async { + final peerCtrl = TextEditingController(); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('새 대화'), + content: TextField( + controller: peerCtrl, + keyboardType: TextInputType.number, + autofocus: true, + decoration: const InputDecoration( + labelText: '상대 사용자 ID', + helperText: '연락처에 등록된 상대면 연락처 화면에서 시작하는 편이 낫습니다.', + border: OutlineInputBorder(), + ), + ), + 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 session = context.read(); + final me = session.user; + final peer = int.tryParse(peerCtrl.text.trim()); + if (me == null || peer == null) return; + try { + final conv = await session.api.createConversation(userIds: [me.id, peer]); + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ChatScreen(conversationId: conv.id)), + ); + await _load(); + } on ApiException catch (e) { + setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}'); + } } @override Widget build(BuildContext context) { final session = context.watch(); - final rooms = _rooms.toList()..sort(); + final me = session.user?.id; return Scaffold( appBar: AppBar( title: Text('분신 · ${session.user?.displayName ?? ''}'), actions: [ + IconButton( + tooltip: '사후 알림', + onPressed: () { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const InboxScreen())); + }, + icon: const Icon(Icons.notifications_none), + ), + IconButton( + tooltip: '연락처', + onPressed: () async { + await Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ContactsScreen())); + await _load(); + }, + icon: const Icon(Icons.contacts_outlined), + ), + IconButton( + tooltip: '말투 샘플', + onPressed: () { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const OnboardingToneScreen())); + }, + icon: const Icon(Icons.record_voice_over_outlined), + ), IconButton( tooltip: '자율성 설정', onPressed: () { @@ -44,47 +126,52 @@ class _ConversationListScreenState extends State { ), ], ), - body: ListView( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _convId, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: '대화방 ID', - border: OutlineInputBorder(), - helperText: 'core-backend에 대화방 목록 API가 생기기 전 임시 진입', + floatingActionButton: FloatingActionButton( + onPressed: _createConversation, + child: const Icon(Icons.chat), + ), + body: RefreshIndicator( + onRefresh: _load, + child: _loading + ? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())]) + : ListView( + children: [ + if (_error != null) + Padding( + padding: const EdgeInsets.all(16), + child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), ), - ), - ), - const SizedBox(width: 8), - FilledButton( - onPressed: () { - final id = int.tryParse(_convId.text.trim()); - if (id == null) return; - setState(() => _rooms.add(id)); - }, - child: const Text('추가'), - ), - ], - ), - ), - for (final id in rooms) - ListTile( - leading: const CircleAvatar(child: Icon(Icons.chat_bubble_outline)), - title: Text('대화방 #$id'), - subtitle: const Text('탭해서 입장'), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => ChatScreen(conversationId: id)), - ); - }, - ), - ], + if (_rooms.isEmpty) + const Padding( + padding: EdgeInsets.all(24), + child: Text('대화방이 없습니다. + 버튼이나 연락처에서 대화를 시작하세요.'), + ), + for (final room in _rooms) + ListTile( + leading: CircleAvatar( + child: Icon(room.isGroup ? Icons.groups_outlined : Icons.chat_bubble_outline), + ), + title: Text(me == null ? '대화방 #${room.id}' : room.titleFor(me)), + subtitle: Text( + [ + 'ID ${room.id}', + if (room.twinDisabledByPeer) '상대가 분신 거부', + ].join(' · '), + ), + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChatScreen( + conversationId: room.id, + title: me == null ? null : room.titleFor(me), + ), + ), + ); + await _load(); + }, + ), + ], + ), ), ); } diff --git a/mobile/lib/screens/data_flow_screen.dart b/mobile/lib/screens/data_flow_screen.dart new file mode 100644 index 0000000..ab994a8 --- /dev/null +++ b/mobile/lib/screens/data_flow_screen.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../state/session_state.dart'; + +/// Shows what stays on-device vs what may leave the device (roadmap B / privacy). +class DataFlowScreen extends StatelessWidget { + const DataFlowScreen({super.key}); + + @override + Widget build(BuildContext context) { + final session = context.watch(); + final theme = Theme.of(context); + final samples = session.styleExamples; + + return Scaffold( + appBar: AppBar(title: const Text('데이터 흐름')), + body: ListView( + padding: const EdgeInsets.all(20), + children: [ + Text('기기 안에만 둡니다', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), + const SizedBox(height: 8), + Text( + '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. ' + '원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 12), + Card( + child: ListTile( + title: Text('말투 샘플 ${samples.length}개'), + subtitle: Text( + [ + samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '), + session.localDbEncrypted ? '저장: drift + SQLCipher(암호화)' : '저장: 메모리 폴백(이 환경에 SQLCipher 없음)', + ].join('\n'), + ), + isThreeLine: true, + ), + ), + const SizedBox(height: 24), + Text('서버로 보낼 수 있는 것', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), + const SizedBox(height: 8), + Text( + '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트).\n' + '채팅 릴레이: 보낸 메시지 본문.\n' + '계정: 표시 이름·초대 코드·세션 토큰.\n' + '푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후.', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 24), + Text('보내지 않는 것', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), + const SizedBox(height: 8), + Text( + '전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, ' + '관계 메모의 자동 클라우드 분석.', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/screens/inbox_screen.dart b/mobile/lib/screens/inbox_screen.dart new file mode 100644 index 0000000..7c1e34c --- /dev/null +++ b/mobile/lib/screens/inbox_screen.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/models.dart'; +import '../services/api_client.dart'; +import '../state/session_state.dart'; +import 'chat_screen.dart'; + +/// Post-hoc notification inbox: escalation logs that need human attention. +/// Twin "되돌리기" remains available inside each chat bubble (PRD). +class InboxScreen extends StatefulWidget { + const InboxScreen({super.key}); + + @override + State createState() => _InboxScreenState(); +} + +class _InboxScreenState extends State { + List _logs = []; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final session = context.read(); + if (session.user == null) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final logs = await session.api.listEscalationLogs(session.user!.id); + setState(() => _logs = logs); + } on ApiException catch (e) { + setState(() => _error = '알림 로드 실패 (${e.statusCode})'); + } finally { + setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar(title: const Text('사후 알림')), + body: RefreshIndicator( + onRefresh: _load, + child: _loading + ? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())]) + : ListView( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + '분신이 보류·차단한 내용과 에스컬레이션 기록입니다. ' + '이미 보낸 분신 메시지는 해당 대화방에서 되돌릴 수 있습니다.', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.all(16), + child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)), + ), + if (_logs.isEmpty) + const Padding( + padding: EdgeInsets.all(24), + child: Text('새 알림이 없습니다.'), + ), + for (final log in _logs) + ListTile( + leading: Icon( + log.resolved ? Icons.check_circle_outline : Icons.warning_amber_rounded, + color: log.resolved ? theme.colorScheme.primary : theme.colorScheme.error, + ), + title: Text(log.reason.isEmpty ? '에스컬레이션' : log.reason), + subtitle: Text( + [ + '대화방 #${log.conversationId}', + if (log.messageSnippet.isNotEmpty) log.messageSnippet, + log.createdAt.toLocal().toString().split('.').first, + ].join('\n'), + ), + isThreeLine: true, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChatScreen(conversationId: log.conversationId), + ), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/screens/onboarding_tone_screen.dart b/mobile/lib/screens/onboarding_tone_screen.dart new file mode 100644 index 0000000..6983f10 --- /dev/null +++ b/mobile/lib/screens/onboarding_tone_screen.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../state/session_state.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. +class OnboardingToneScreen extends StatefulWidget { + const OnboardingToneScreen({super.key}); + + @override + State createState() => _OnboardingToneScreenState(); +} + +class _OnboardingToneScreenState extends State { + final _samples = List.generate(4, (_) => TextEditingController()); + var _seeded = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_seeded) return; + _seeded = true; + final existing = context.read().styleExamples; + for (var i = 0; i < existing.length && i < _samples.length; i++) { + _samples[i].text = existing[i]; + } + } + + @override + void dispose() { + for (final c in _samples) { + c.dispose(); + } + super.dispose(); + } + + Future _save({required bool markDone}) async { + final session = context.read(); + await session.saveToneSamples( + _samples.map((c) => c.text).toList(), + markDone: markDone, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: const Text('말투 샘플'), + actions: [ + TextButton( + onPressed: () => context.read().skipToneOnboarding(), + child: const Text('나중에'), + ), + ], + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + Text('분신이 따라 쓸 말투', style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700)), + const SizedBox(height: 8), + Text( + '자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. ' + '최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 24), + for (var i = 0; i < _samples.length; i++) ...[ + TextField( + controller: _samples[i], + maxLines: 2, + decoration: InputDecoration( + labelText: '샘플 ${i + 1}', + hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + ], + const SizedBox(height: 8), + FilledButton( + onPressed: () => _save(markDone: true), + child: const Text('저장하고 시작'), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/screens/sessions_screen.dart b/mobile/lib/screens/sessions_screen.dart new file mode 100644 index 0000000..097a1b0 --- /dev/null +++ b/mobile/lib/screens/sessions_screen.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../services/api_client.dart'; +import '../state/session_state.dart'; + +/// Multi-device awareness: list / revoke active sessions (roadmap B). +class SessionsScreen extends StatefulWidget { + const SessionsScreen({super.key}); + + @override + State createState() => _SessionsScreenState(); +} + +class _SessionsScreenState extends State { + List> _sessions = []; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final session = context.read(); + if (session.user == null) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final list = await session.api.listSessions(session.user!.id); + setState(() => _sessions = list); + } on ApiException catch (e) { + setState(() => _error = '세션 목록 실패 (${e.statusCode})'); + } finally { + setState(() => _loading = false); + } + } + + Future _revoke(Map s) async { + final session = context.read(); + if (session.user == null) return; + final id = s['id']; + if (id is! num) return; + final isCurrent = s['is_current'] == true; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(isCurrent ? '이 기기에서 로그아웃?' : '다른 세션 종료?'), + content: Text(isCurrent ? '현재 기기 세션이 삭제됩니다.' : '선택한 기기의 로그인이 해제됩니다.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('종료')), + ], + ), + ); + 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})'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('로그인 세션')), + body: RefreshIndicator( + onRefresh: _load, + child: _loading + ? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())]) + : ListView( + children: [ + const Padding( + padding: EdgeInsets.all(16), + child: Text('이 계정에 연결된 활성 세션입니다. 다른 기기를 종료하면 해당 토큰이 즉시 무효화됩니다.'), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.all(16), + child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), + for (final s in _sessions) + ListTile( + leading: Icon(s['is_current'] == true ? Icons.smartphone : Icons.devices_other), + title: Text(s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}'), + subtitle: Text('만료: ${s['expires_at'] ?? ''}'), + trailing: IconButton( + tooltip: '세션 종료', + icon: const Icon(Icons.logout), + onPressed: () => _revoke(s), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart index 88afd34..0e27a35 100644 --- a/mobile/lib/services/api_client.dart +++ b/mobile/lib/services/api_client.dart @@ -76,6 +76,67 @@ class ApiClient { ); } + Future> listConversations() async { + final obj = await _getObject('/conversations'); + final list = (obj['conversations'] as List? ?? const []); + return list.map((e) => ConversationSummary.fromJson(e as Map)).toList(); + } + + Future createConversation({ + required List userIds, + int? contactId, + bool isGroup = false, + }) async { + final json = await _json('POST', '/conversations', body: { + 'user_ids': userIds, + 'is_group': isGroup, + if (contactId != null) 'contact_id': contactId, + }); + final rawIds = json['user_ids'] as List? ?? userIds; + return ConversationSummary( + id: json['id'] as int, + isGroup: json['is_group'] as bool? ?? isGroup, + userIds: rawIds.map((e) => e as int).toList(), + twinDisabledByPeer: false, + ); + } + + Future> listMessages(int conversationId) async { + final obj = await _getObject('/conversations/$conversationId/messages'); + final list = (obj['messages'] as List? ?? const []); + return list.map((e) => ChatMessage.fromJson(e as Map)).toList(); + } + + Future> listContacts(int userId) async { + final obj = await _getObject('/users/$userId/contacts'); + final list = (obj['contacts'] as List? ?? const []); + return list.map((e) => Contact.fromJson(e as Map)).toList(); + } + + Future createContact({ + required int userId, + required String displayName, + int? contactUserId, + String relationshipNote = '', + }) async { + final json = await _json('POST', '/users/$userId/contacts', body: { + 'display_name': displayName, + if (contactUserId != null) 'contact_user_id': contactUserId, + 'relationship_note': relationshipNote, + }); + return Contact.fromJson(json); + } + + Future deleteContact(int userId, int contactId) async { + await _json('DELETE', '/users/$userId/contacts/$contactId'); + } + + Future> listEscalationLogs(int userId) async { + final obj = await _getObject('/users/$userId/escalation-logs'); + final list = (obj['escalation_logs'] as List? ?? const []); + return list.map((e) => EscalationLogEntry.fromJson(e as Map)).toList(); + } + Future sendMessage({ required int conversationId, required int senderId, @@ -126,24 +187,6 @@ class ApiClient { return list.map((e) => WhitelistRule.fromJson(e as Map)).toList(); } - Future>> listConversations() async { - final obj = await _getObject('/conversations'); - final list = (obj['conversations'] as List? ?? const []); - return list.cast>(); - } - - Future> createConversation({ - required List userIds, - int? contactId, - bool isGroup = false, - }) async { - return _json('POST', '/conversations', body: { - 'user_ids': userIds, - 'is_group': isGroup, - if (contactId != null) 'contact_id': contactId, - }); - } - Future addWhitelist(int userId, String topicKeyword) async { final json = await _json('POST', '/users/$userId/whitelist-rules', body: { 'topic_keyword': topicKeyword, @@ -154,4 +197,25 @@ class ApiClient { Future deleteWhitelist(int userId, int ruleId) async { await _json('DELETE', '/users/$userId/whitelist-rules/$ruleId'); } + + Future>> listSessions(int userId) async { + final obj = await _getObject('/users/$userId/sessions'); + final list = (obj['sessions'] as List? ?? const []); + return list.cast>(); + } + + Future registerDeviceToken({ + required int userId, + required String token, + String platform = 'android', + }) async { + await _json('POST', '/users/$userId/device-tokens', body: { + 'token': token, + 'platform': platform, + }); + } + + Future revokeSession(int userId, int sessionId) async { + await _json('DELETE', '/users/$userId/sessions/$sessionId'); + } } diff --git a/mobile/lib/state/session_state.dart b/mobile/lib/state/session_state.dart index 7ad5124..9032af1 100644 --- a/mobile/lib/state/session_state.dart +++ b/mobile/lib/state/session_state.dart @@ -1,24 +1,49 @@ +import 'dart:convert'; +import 'dart:math'; + import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../db/app_database.dart'; import '../models/models.dart'; import '../services/api_client.dart'; class SessionState extends ChangeNotifier { - SessionState({ApiClient? api}) : _api = api ?? ApiClient(); + SessionState({ApiClient? api, AppDatabase? db}) + : _api = api ?? ApiClient(), + _db = db; final ApiClient _api; + AppDatabase? _db; + User? user; AutonomyLevel autonomyLevel = AutonomyLevel.L0; String? error; bool loading = false; + bool toneOnboardingDone = false; + List styleExamples = const []; + bool localDbEncrypted = false; static const _kUserId = 'user_id'; static const _kDisplayName = 'display_name'; static const _kInvite = 'invite_code'; static const _kToken = 'session_token'; + // Legacy SharedPreferences keys — migrated into encrypted drift on first open. + static const _kToneDoneLegacy = 'tone_onboarding_done'; + static const _kStyleExamplesLegacy = 'style_examples_json'; + + static const _kToneDone = 'tone_onboarding_done'; + static const _kDeviceInstallId = 'device_install_id'; + + AppDatabase? get db => _db; Future restore() async { + _db ??= await _openDb(); + await _migrateLegacyTonePrefs(); + + toneOnboardingDone = await _db!.getBoolKv(_kToneDone); + styleExamples = await _db!.loadToneSamples(); + final prefs = await SharedPreferences.getInstance(); final id = prefs.getInt(_kUserId); final name = prefs.getString(_kDisplayName); @@ -27,10 +52,46 @@ class SessionState extends ChangeNotifier { if (id != null && name != null && invite != null) { user = User(id: id, displayName: name, inviteCode: invite); _api.authToken = token; + await _registerDeviceTokenBestEffort(); notifyListeners(); } } + Future _openDb() async { + try { + final db = await AppDatabase.open(); + localDbEncrypted = true; + return db; + } catch (e) { + // Linux CI / hosts without libsqlcipher.so — fall back to memory so the + // app still boots; Android release path uses SQLCipher. + debugPrint('Encrypted DB unavailable ($e); using in-memory fallback'); + localDbEncrypted = false; + return AppDatabase.memory(); + } + } + + Future _migrateLegacyTonePrefs() async { + final prefs = await SharedPreferences.getInstance(); + final rawExamples = prefs.getString(_kStyleExamplesLegacy); + final legacyDone = prefs.getBool(_kToneDoneLegacy); + final existing = await _db!.loadToneSamples(); + if (existing.isEmpty && rawExamples != null && rawExamples.isNotEmpty) { + final decoded = (jsonDecode(rawExamples) as List) + .map((e) => e.toString()) + .where((s) => s.trim().isNotEmpty) + .toList(); + if (decoded.isNotEmpty) { + await _db!.replaceToneSamples(decoded); + } + await prefs.remove(_kStyleExamplesLegacy); + } + if (legacyDone != null) { + await _db!.setBoolKv(_kToneDone, legacyDone); + await prefs.remove(_kToneDoneLegacy); + } + } + Future signup(String inviteCode, String displayName) async { loading = true; error = null; @@ -38,11 +99,15 @@ class SessionState extends ChangeNotifier { try { final result = await _api.signup(inviteCode: inviteCode, displayName: displayName); 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}'; } catch (e) { @@ -53,6 +118,23 @@ class SessionState extends ChangeNotifier { } } + Future saveToneSamples(List samples, {bool markDone = true}) async { + final cleaned = samples.map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); + styleExamples = cleaned; + if (markDone) toneOnboardingDone = true; + _db ??= await _openDb(); + await _db!.replaceToneSamples(cleaned); + if (markDone) await _db!.setBoolKv(_kToneDone, true); + notifyListeners(); + } + + Future skipToneOnboarding() async { + toneOnboardingDone = true; + _db ??= await _openDb(); + await _db!.setBoolKv(_kToneDone, true); + notifyListeners(); + } + Future setAutonomy(AutonomyLevel level) async { if (user == null) return; try { @@ -65,5 +147,27 @@ class SessionState extends ChangeNotifier { } } + /// Registers a stable install id as the push token until Firebase Messaging + /// is wired with a real FCM registration token (roadmap B). + Future _registerDeviceTokenBestEffort() async { + if (user == null) return; + try { + _db ??= await _openDb(); + var installId = await _db!.getKv(_kDeviceInstallId); + if (installId == null || installId.isEmpty) { + final rand = Random.secure(); + installId = List.generate(16, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')).join(); + await _db!.setKv(_kDeviceInstallId, installId); + } + await _api.registerDeviceToken( + userId: user!.id, + token: 'install:$installId', + platform: defaultTargetPlatform == TargetPlatform.android ? 'android' : 'other', + ); + } catch (e) { + debugPrint('device token register skipped: $e'); + } + } + ApiClient get api => _api; } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index e33e219..49b1427 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1,6 +1,30 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a + url: "https://pub.dev" + source: hosted + version: "88.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f" + url: "https://pub.dev" + source: hosted + version: "8.1.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -17,6 +41,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + build_config: + dependency: transitive + description: + name: build_config + sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8" + url: "https://pub.dev" + source: hosted + version: "1.3.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78 + url: "https://pub.dev" + source: hosted + version: "4.1.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" characters: dependency: transitive description: @@ -25,6 +97,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -41,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" crypto: dependency: transitive description: @@ -57,6 +161,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + drift: + dependency: "direct main" + description: + name: drift + sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e" + url: "https://pub.dev" + source: hosted + version: "2.31.0" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587" + url: "https://pub.dev" + source: hosted + version: "2.31.0" fake_async: dependency: transitive description: @@ -81,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -94,6 +230,54 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.dev" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + url: "https://pub.dev" + source: hosted + version: "4.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -104,6 +288,22 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" http: dependency: "direct main" description: @@ -112,6 +312,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: @@ -120,6 +328,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -152,6 +376,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -176,6 +408,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" nested: dependency: transitive description: @@ -184,14 +424,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - path: + package_config: dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" + url: "https://pub.dev" + source: hosted + version: "2.2.19" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" path_provider_linux: dependency: transitive description: @@ -232,6 +504,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" provider: dependency: "direct main" description: @@ -240,6 +520,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" shared_preferences: dependency: "direct main" description: @@ -296,11 +600,35 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" + url: "https://pub.dev" + source: hosted + version: "4.2.0" source_span: dependency: transitive description: @@ -309,6 +637,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sqlcipher_flutter_libs: + dependency: "direct main" + description: + name: sqlcipher_flutter_libs + sha256: dd1fcc74d5baf3c36ad53e2652b2d06c9f8747494a3ccde0076e88b159dfe622 + url: "https://pub.dev" + source: hosted + version: "0.6.8" + sqlite3: + dependency: "direct main" + description: + name: sqlite3 + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + url: "https://pub.dev" + source: hosted + version: "2.9.4" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19" + url: "https://pub.dev" + source: hosted + version: "0.43.1" stack_trace: dependency: transitive description: @@ -325,6 +677,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -373,6 +733,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" web: dependency: transitive description: @@ -397,6 +765,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: @@ -405,6 +781,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.8.1 <4.0.0" flutter: ">=3.29.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f10ba98..ec43028 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -38,6 +38,12 @@ dependencies: web_socket_channel: ^3.0.3 provider: ^6.1.5+1 shared_preferences: ^2.5.3 + drift: ^2.31.0 + path: ^1.9.1 + path_provider: ^2.1.5 + sqlite3: ^2.9.4 + flutter_secure_storage: ^10.3.1 + sqlcipher_flutter_libs: ^0.6.8 dev_dependencies: flutter_test: @@ -49,6 +55,8 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^5.0.0 + drift_dev: ^2.31.0 + build_runner: ^2.15.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/mobile/test/local_db_test.dart b/mobile/test/local_db_test.dart new file mode 100644 index 0000000..056afe8 --- /dev/null +++ b/mobile/test/local_db_test.dart @@ -0,0 +1,18 @@ +import 'package:bunsin_mobile/db/app_database.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('tone samples round-trip in memory database', () async { + final db = AppDatabase.memory(); + addTearDown(db.close); + + await db.replaceToneSamples(['ㅇㅇ 알겠음', 'ㅋㅋ 그래']); + expect(await db.loadToneSamples(), ['ㅇㅇ 알겠음', 'ㅋㅋ 그래']); + + await db.setBoolKv('tone_onboarding_done', true); + expect(await db.getBoolKv('tone_onboarding_done'), isTrue); + + await db.replaceToneSamples(['하나만']); + expect(await db.loadToneSamples(), ['하나만']); + }); +} diff --git a/mobile/test/models_test.dart b/mobile/test/models_test.dart new file mode 100644 index 0000000..c5b8dda --- /dev/null +++ b/mobile/test/models_test.dart @@ -0,0 +1,37 @@ +import 'package:bunsin_mobile/models/models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('ConversationSummary parses list payload', () { + final c = ConversationSummary.fromJson({ + 'id': 3, + 'is_group': false, + 'twin_disabled_by_peer': true, + 'user_ids': [1, 2], + 'created_at': '2026-07-30T00:00:00Z', + }); + expect(c.id, 3); + expect(c.userIds, [1, 2]); + expect(c.twinDisabledByPeer, isTrue); + expect(c.titleFor(1), '대화 · 상대 #2'); + }); + + test('EscalationLogEntry and Contact parse', () { + final log = EscalationLogEntry.fromJson({ + 'id': 9, + 'conversation_id': 2, + 'reason': 'money', + 'message_snippet': '보내줄게', + 'resolved': false, + 'created_at': '2026-07-30T01:00:00Z', + }); + expect(log.reason, 'money'); + final contact = Contact.fromJson({ + 'id': 1, + 'display_name': '친구', + 'contact_user_id': 7, + 'relationship_note': '대학', + }); + expect(contact.contactUserId, 7); + }); +} diff --git a/scripts/build_release_aab.sh b/scripts/build_release_aab.sh new file mode 100755 index 0000000..c2c8d78 --- /dev/null +++ b/scripts/build_release_aab.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Optional Play internal-testing bundle (docs/android-release.md). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT/mobile" + +if [[ ! -f android/key.properties ]]; then + echo "WARN: android/key.properties missing — AAB will use debug signing." >&2 +fi + +flutter pub get +flutter build appbundle --release "$@" +echo +echo "AAB: $ROOT/mobile/build/app/outputs/bundle/release/app-release.aab" diff --git a/scripts/build_release_apk.sh b/scripts/build_release_apk.sh new file mode 100755 index 0000000..4790571 --- /dev/null +++ b/scripts/build_release_apk.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Internal closed-beta APK (docs/android-release.md). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT/mobile" + +if [[ ! -f android/key.properties ]]; then + echo "WARN: android/key.properties missing — release APK will use debug signing." >&2 + echo " Copy android/key.properties.example for a real beta build." >&2 +fi + +flutter pub get +flutter build apk --release "$@" +echo +echo "APK: $ROOT/mobile/build/app/outputs/flutter-apk/app-release.apk" diff --git a/scripts/e2e_a3.py b/scripts/e2e_a3.py new file mode 100755 index 0000000..d742bb1 --- /dev/null +++ b/scripts/e2e_a3.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""A3 API-level E2E against a running core-backend (+ ai-service for draft/escalate). + +Covers mobile/README.md checklist at the HTTP layer. +Android emulator/device UI taps are out of scope in this cloud VM (no Android SDK). +""" +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request + +BASE = os.environ.get("CORE_API_BASE", "http://127.0.0.1:8080") +ADMIN = os.environ.get("ADMIN_API_TOKEN", "dev-admin-token") + +passed = 0 +failed = 0 + + +def ok(msg: str) -> None: + global passed + passed += 1 + print(f" OK: {msg}") + + +def fail(msg: str) -> None: + global failed + failed += 1 + print(f" FAIL: {msg}") + + +def step(msg: str) -> None: + print(f"\n==> {msg}") + + +def req(method: str, path: str, body=None, token: str | None = None, admin: bool = False): + data = None if body is None else json.dumps(body).encode() + headers = {"Content-Type": "application/json"} + if admin: + headers["Authorization"] = f"Bearer {ADMIN}" + elif token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(BASE + path, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request) as resp: + raw = resp.read().decode() + return resp.status, json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + raw = e.read().decode() + try: + payload = json.loads(raw) if raw else {} + except json.JSONDecodeError: + payload = {"raw": raw} + return e.code, payload + + +def main() -> int: + step("health") + code, body = req("GET", "/health") + ok("core health") if code == 200 else fail(f"health {code} {body}") + + step("1) invite + signup (two users)") + _, ia = req("POST", "/invites", {}, admin=True) + _, ib = req("POST", "/invites", {}, admin=True) + code_a, sa = req("POST", "/auth/signup", {"invite_code": ia["code"], "display_name": "E2E Alice"}) + code_b, sb = req("POST", "/auth/signup", {"invite_code": ib["code"], "display_name": "E2E Bob"}) + if code_a == 200 and code_b == 200 and sa.get("token") and sb.get("token"): + ok(f"signup tokens for #{sa['id']} and #{sb['id']}") + else: + fail(f"signup a={code_a} b={code_b}") + return 1 + token_a, token_b = sa["token"], sb["token"] + id_a, id_b = sa["id"], sb["id"] + + step("2) contact + conversation create") + _, contact = req( + "POST", + f"/users/{id_a}/contacts", + {"display_name": "Bob", "contact_user_id": id_b, "relationship_note": "e2e"}, + token=token_a, + ) + contact_id = contact["id"] + _, conv = req( + "POST", + "/conversations", + {"user_ids": [id_a, id_b], "contact_id": contact_id}, + token=token_a, + ) + conv_id = conv["id"] + ok(f"conversation #{conv_id} via contact #{contact_id}") + _, clist = req("GET", "/conversations", token=token_a) + ids = [c["id"] for c in clist.get("conversations", [])] + ok("conversation list includes room") if conv_id in ids else fail(f"list missing room: {ids}") + + step("3) human message + history") + _, msg = req( + "POST", + f"/conversations/{conv_id}/messages", + {"sender_id": id_a, "text": "안녕 Bob", "sender_mode": "human"}, + token=token_a, + ) + msg_id = msg["id"] + _, hist = req("GET", f"/conversations/{conv_id}/messages", token=token_a) + hist_ids = [m["id"] for m in hist.get("messages", [])] + ok(f"history retains message #{msg_id}") if msg_id in hist_ids else fail("history") + + step("4) draft + L1 approved twin send") + dcode, draft = req( + "POST", + f"/conversations/{conv_id}/draft", + {"context_lines": ["상대: 오늘 뭐해?"], "style_examples": ["ㅇㅇ 알겠음", "ㅋㅋ 그래"]}, + token=token_a, + ) + if dcode == 200 and draft.get("status") in {"ok", "no_key", "escalate"}: + ok(f"draft status={draft.get('status')}") + else: + fail(f"draft {dcode} {draft}") + req("PATCH", f"/users/{id_a}/twin-settings", {"autonomy_level": "L1"}, token=token_a) + twin_text = "ㅇㅇ 알겠음 나중에" + if draft.get("status") == "ok" and draft.get("text"): + twin_text = draft["text"] + tcode, twin = req( + "POST", + f"/conversations/{conv_id}/messages", + {"sender_id": id_a, "text": twin_text, "sender_mode": "twin", "approved": True}, + token=token_a, + ) + twin_id = twin.get("id") + if tcode == 200 and twin_id: + ok(f"L1 approved twin message #{twin_id}") + else: + fail(f"twin send {tcode} {twin}") + return 1 + + step("5) escalation path + inbox logs") + ecode, _ = req( + "POST", + f"/conversations/{conv_id}/messages", + { + "sender_id": id_a, + "text": "계좌로 돈 보내줄게 계좌번호 알려줘", + "sender_mode": "twin", + "approved": True, + }, + token=token_a, + ) + if ecode != 200: + ok(f"escalating twin blocked (HTTP {ecode})") + else: + fail("escalating twin unexpectedly accepted") + _, logs = req("GET", f"/users/{id_a}/escalation-logs", token=token_a) + if "escalation_logs" in logs: + ok(f"escalation-logs count={len(logs['escalation_logs'])}") + else: + fail("escalation-logs shape") + + step("6) retract twin + veto") + rcode, _ = req("POST", f"/messages/{twin_id}/retract", token=token_a) + ok(f"retract HTTP {rcode}") if rcode == 200 else fail(f"retract {rcode}") + vcode, _ = req("POST", f"/conversations/{conv_id}/veto", token=token_b) + ok(f"peer veto HTTP {vcode}") if vcode == 200 else fail(f"veto {vcode}") + bcode, _ = req( + "POST", + f"/conversations/{conv_id}/messages", + {"sender_id": id_a, "text": "veto 이후", "sender_mode": "twin", "approved": True}, + token=token_a, + ) + ok(f"twin blocked after veto (HTTP {bcode})") if bcode != 200 else fail("twin allowed after veto") + + step("7) whitelist CRUD + autonomy") + _, wl = req( + "POST", + f"/users/{id_a}/whitelist-rules", + {"topic_keyword": "날씨"}, + token=token_a, + ) + _, wlist = req("GET", f"/users/{id_a}/whitelist-rules", token=token_a) + rules = wlist.get("whitelist_rules", []) + ok("whitelist add/list") if any(r.get("topic_keyword") == "날씨" for r in rules) else fail("whitelist") + dcode, _ = req("DELETE", f"/users/{id_a}/whitelist-rules/{wl['id']}", token=token_a) + ok("whitelist delete") if dcode == 200 else fail(f"whitelist delete {dcode}") + acode, _ = req("PATCH", f"/users/{id_a}/twin-settings", {"autonomy_level": "L0"}, token=token_a) + ok("autonomy L0 reset") if acode == 200 else fail(f"autonomy {acode}") + + step("8) contacts list") + _, contacts = req("GET", f"/users/{id_a}/contacts", token=token_a) + cids = [c["id"] for c in contacts.get("contacts", [])] + ok("contacts list") if contact_id in cids else fail("contacts list") + + print(f"\n==== E2E RESULT: {passed} passed, {failed} failed ====") + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main())