Merge pull request #10 from o0kuma/claude/project-planning-approach-ukdz31
Sync main: Phase 1 A–C (Flutter, APIs, beta-ready)
This commit is contained in:
commit
86f65d27ce
|
|
@ -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=
|
||||
|
|
|
|||
15
AGENTS.md
15
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**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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가 기입
|
||||
|
||||
## 로컬 실행 (요약)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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`)
|
||||
|
||||
## 테스트
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = `<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>분신 · admin metrics</title>
|
||||
<style>
|
||||
:root { font-family: ui-sans-serif, system-ui, sans-serif; color: #14231e; background: #f3f7f5; }
|
||||
body { margin: 0; padding: 24px; }
|
||||
h1 { margin: 0 0 8px; font-size: 1.4rem; }
|
||||
p { color: #40554c; margin: 0 0 20px; }
|
||||
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
|
||||
.card { background: #fff; border: 1px solid #d5e2db; border-radius: 10px; padding: 14px; }
|
||||
.k { font-size: .75rem; color: #5b7267; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.v { font-size: 1.4rem; font-weight: 700; margin-top: 4px; }
|
||||
pre { background: #fff; border: 1px solid #d5e2db; border-radius: 10px; padding: 14px; overflow: auto; }
|
||||
button { margin: 12px 0; padding: 8px 14px; border-radius: 8px; border: 0; background: #1f6f5b; color: #fff; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>분신 운영 지표</h1>
|
||||
<p>Bearer ADMIN_API_TOKEN 으로 /admin/metrics 를 불러옵니다. 생성 지연·오류율은 프로세스 메모리 샘플입니다.</p>
|
||||
<button onclick="load()">새로고침</button>
|
||||
<div class="grid" id="cards"></div>
|
||||
<h2 style="margin-top:28px;font-size:1rem">raw JSON</h2>
|
||||
<pre id="raw">loading…</pre>
|
||||
<script>
|
||||
async function load() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
let token = params.get('token') || localStorage.ADMIN_API_TOKEN || '';
|
||||
if (!token) {
|
||||
token = prompt('ADMIN_API_TOKEN') || '';
|
||||
if (token) localStorage.ADMIN_API_TOKEN = token;
|
||||
} else if (params.get('token')) {
|
||||
localStorage.ADMIN_API_TOKEN = token;
|
||||
}
|
||||
const r = await fetch('/admin/metrics', { headers: token ? { 'Authorization': 'Bearer ' + token } : {} });
|
||||
const data = await r.json();
|
||||
document.getElementById('raw').textContent = JSON.stringify(data, null, 2);
|
||||
const cards = [
|
||||
['users', data.users_total],
|
||||
['human msgs', data.messages_human_total],
|
||||
['twin msgs', data.messages_twin_total],
|
||||
['escalations', data.escalations_total],
|
||||
['peer veto rate', (data.peer_veto_rate || 0).toFixed(3)],
|
||||
['draft req', data.draft_requests],
|
||||
['draft err rate', (data.draft_error_rate || 0).toFixed(3)],
|
||||
['draft avg ms', (data.draft_latency_avg_ms || 0).toFixed(1)],
|
||||
['draft max ms', (data.draft_latency_max_ms || 0).toFixed(1)],
|
||||
['escalate err rate', (data.escalate_error_rate || 0).toFixed(3)],
|
||||
];
|
||||
document.getElementById('cards').innerHTML = cards.map(([k,v]) =>
|
||||
'<div class="card"><div class="k">'+k+'</div><div class="v">'+v+'</div></div>').join('');
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,6 +109,20 @@ 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,
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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{},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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명, 스크리닝 → 본 인터뷰 → 결과 반영)
|
||||
|
|
|
|||
|
|
@ -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회
|
||||
|
|
@ -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은 가칭 확정)
|
||||
- 베타 참가자 모집 규모와 방식
|
||||
|
|
|
|||
|
|
@ -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/<CODE>/revoke" \
|
||||
-H "Authorization: Bearer $ADMIN_API_TOKEN"
|
||||
```
|
||||
|
||||
사용 현황은 `GET /admin/metrics`의 `invites_minted` / `invites_used`와 목록의
|
||||
`status`(`unused`|`used`|`expired`|`revoked`)로 본다.
|
||||
|
||||
## 배포 전 체크
|
||||
|
||||
- [ ] 프로덕션 `ADMIN_API_TOKEN` 회전·길이 충분(≥32자 권장)
|
||||
- [ ] 초대 코드를 공개 채널(SNS/이슈)에 올리지 않음
|
||||
- [ ] 만료·회수된 코드로 가입이 거절되는지 스모크 테스트
|
||||
|
|
@ -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 확정 외)
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
|
||||
`bunsin-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미
|
||||
다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다.
|
||||
공식 링크 앵커: [`prototype.md`](./prototype.md) (공유 URL은 그 파일의 `SHARE_URL`).
|
||||
|
||||
### 시나리오 E — 감정적인 대화 (에스컬레이션 케이스, 프로토타입 미포함)
|
||||
|
||||
|
|
|
|||
|
|
@ -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`과 이 앵커 문서는 유지한다.
|
||||
|
|
@ -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 완료 후에만 착수.
|
||||
|
|
|
|||
|
|
@ -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에
|
||||
|
|
|
|||
|
|
@ -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://<your-lan-ip>: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 범위 밖)
|
||||
|
|
|
|||
|
|
@ -12,3 +12,5 @@ GeneratedPluginRegistrant.java
|
|||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
*.jks
|
||||
*.keystore
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
storePassword=replace-me
|
||||
keyPassword=replace-me
|
||||
keyAlias=bunsin
|
||||
storeFile=/absolute/path/to/bunsin-release.jks
|
||||
|
|
@ -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<AppDatabase> 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<List<String>> loadToneSamples() async {
|
||||
final rows = await (select(toneSamples)..orderBy([(t) => OrderingTerm.asc(t.sortOrder)])).get();
|
||||
return rows.map((r) => r.sampleText).toList();
|
||||
}
|
||||
|
||||
Future<void> replaceToneSamples(List<String> 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<String?> getKv(String key) async {
|
||||
final row = await (select(localKv)..where((t) => t.key.equals(key))).getSingleOrNull();
|
||||
return row?.value;
|
||||
}
|
||||
|
||||
Future<void> setKv(String key, String value) async {
|
||||
await into(localKv).insertOnConflictUpdate(LocalKvCompanion.insert(key: key, value: value));
|
||||
}
|
||||
|
||||
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
|
||||
final v = await getKv(key);
|
||||
if (v == null) return defaultValue;
|
||||
return v == '1' || v.toLowerCase() == 'true';
|
||||
}
|
||||
|
||||
Future<void> 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<void> _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<String> _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<int>.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;
|
||||
}
|
||||
|
|
@ -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<String> id = GeneratedColumn<String>(
|
||||
'id',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _sampleTextMeta = const VerificationMeta(
|
||||
'sampleText',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> sampleText = GeneratedColumn<String>(
|
||||
'sample_text',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _createdAtMsMeta = const VerificationMeta(
|
||||
'createdAtMs',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<int> createdAtMs = GeneratedColumn<int>(
|
||||
'created_at_ms',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _sortOrderMeta = const VerificationMeta(
|
||||
'sortOrder',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<int> sortOrder = GeneratedColumn<int>(
|
||||
'sort_order',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> 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<ToneSample> 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<GeneratedColumn> get $primaryKey => {id};
|
||||
@override
|
||||
ToneSample map(Map<String, dynamic> 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<ToneSample> {
|
||||
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<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
map['id'] = Variable<String>(id);
|
||||
map['sample_text'] = Variable<String>(sampleText);
|
||||
map['created_at_ms'] = Variable<int>(createdAtMs);
|
||||
map['sort_order'] = Variable<int>(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<String, dynamic> json, {
|
||||
ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return ToneSample(
|
||||
id: serializer.fromJson<String>(json['id']),
|
||||
sampleText: serializer.fromJson<String>(json['sampleText']),
|
||||
createdAtMs: serializer.fromJson<int>(json['createdAtMs']),
|
||||
sortOrder: serializer.fromJson<int>(json['sortOrder']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'id': serializer.toJson<String>(id),
|
||||
'sampleText': serializer.toJson<String>(sampleText),
|
||||
'createdAtMs': serializer.toJson<int>(createdAtMs),
|
||||
'sortOrder': serializer.toJson<int>(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<ToneSample> {
|
||||
final Value<String> id;
|
||||
final Value<String> sampleText;
|
||||
final Value<int> createdAtMs;
|
||||
final Value<int> sortOrder;
|
||||
final Value<int> 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<ToneSample> custom({
|
||||
Expression<String>? id,
|
||||
Expression<String>? sampleText,
|
||||
Expression<int>? createdAtMs,
|
||||
Expression<int>? sortOrder,
|
||||
Expression<int>? 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<String>? id,
|
||||
Value<String>? sampleText,
|
||||
Value<int>? createdAtMs,
|
||||
Value<int>? sortOrder,
|
||||
Value<int>? 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<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
if (id.present) {
|
||||
map['id'] = Variable<String>(id.value);
|
||||
}
|
||||
if (sampleText.present) {
|
||||
map['sample_text'] = Variable<String>(sampleText.value);
|
||||
}
|
||||
if (createdAtMs.present) {
|
||||
map['created_at_ms'] = Variable<int>(createdAtMs.value);
|
||||
}
|
||||
if (sortOrder.present) {
|
||||
map['sort_order'] = Variable<int>(sortOrder.value);
|
||||
}
|
||||
if (rowid.present) {
|
||||
map['rowid'] = Variable<int>(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<String> key = GeneratedColumn<String>(
|
||||
'key',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _valueMeta = const VerificationMeta('value');
|
||||
@override
|
||||
late final GeneratedColumn<String> value = GeneratedColumn<String>(
|
||||
'value',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> 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<LocalKvData> 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<GeneratedColumn> get $primaryKey => {key};
|
||||
@override
|
||||
LocalKvData map(Map<String, dynamic> 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<LocalKvData> {
|
||||
final String key;
|
||||
final String value;
|
||||
const LocalKvData({required this.key, required this.value});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
map['key'] = Variable<String>(key);
|
||||
map['value'] = Variable<String>(value);
|
||||
return map;
|
||||
}
|
||||
|
||||
LocalKvCompanion toCompanion(bool nullToAbsent) {
|
||||
return LocalKvCompanion(key: Value(key), value: Value(value));
|
||||
}
|
||||
|
||||
factory LocalKvData.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return LocalKvData(
|
||||
key: serializer.fromJson<String>(json['key']),
|
||||
value: serializer.fromJson<String>(json['value']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'key': serializer.toJson<String>(key),
|
||||
'value': serializer.toJson<String>(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<LocalKvData> {
|
||||
final Value<String> key;
|
||||
final Value<String> value;
|
||||
final Value<int> 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<LocalKvData> custom({
|
||||
Expression<String>? key,
|
||||
Expression<String>? value,
|
||||
Expression<int>? rowid,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
if (key != null) 'key': key,
|
||||
if (value != null) 'value': value,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
});
|
||||
}
|
||||
|
||||
LocalKvCompanion copyWith({
|
||||
Value<String>? key,
|
||||
Value<String>? value,
|
||||
Value<int>? rowid,
|
||||
}) {
|
||||
return LocalKvCompanion(
|
||||
key: key ?? this.key,
|
||||
value: value ?? this.value,
|
||||
rowid: rowid ?? this.rowid,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
if (key.present) {
|
||||
map['key'] = Variable<String>(key.value);
|
||||
}
|
||||
if (value.present) {
|
||||
map['value'] = Variable<String>(value.value);
|
||||
}
|
||||
if (rowid.present) {
|
||||
map['rowid'] = Variable<int>(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<TableInfo<Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||
@override
|
||||
List<DatabaseSchemaEntity> get allSchemaEntities => [toneSamples, localKv];
|
||||
}
|
||||
|
||||
typedef $$ToneSamplesTableCreateCompanionBuilder =
|
||||
ToneSamplesCompanion Function({
|
||||
required String id,
|
||||
required String sampleText,
|
||||
required int createdAtMs,
|
||||
required int sortOrder,
|
||||
Value<int> rowid,
|
||||
});
|
||||
typedef $$ToneSamplesTableUpdateCompanionBuilder =
|
||||
ToneSamplesCompanion Function({
|
||||
Value<String> id,
|
||||
Value<String> sampleText,
|
||||
Value<int> createdAtMs,
|
||||
Value<int> sortOrder,
|
||||
Value<int> rowid,
|
||||
});
|
||||
|
||||
class $$ToneSamplesTableFilterComposer
|
||||
extends Composer<_$AppDatabase, $ToneSamplesTable> {
|
||||
$$ToneSamplesTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnFilters<String> get id => $composableBuilder(
|
||||
column: $table.id,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get sampleText => $composableBuilder(
|
||||
column: $table.sampleText,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get createdAtMs => $composableBuilder(
|
||||
column: $table.createdAtMs,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> 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<String> get id => $composableBuilder(
|
||||
column: $table.id,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get sampleText => $composableBuilder(
|
||||
column: $table.sampleText,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get createdAtMs => $composableBuilder(
|
||||
column: $table.createdAtMs,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> 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<String> get id =>
|
||||
$composableBuilder(column: $table.id, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get sampleText => $composableBuilder(
|
||||
column: $table.sampleText,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<int> get createdAtMs => $composableBuilder(
|
||||
column: $table.createdAtMs,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<int> 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<String> id = const Value.absent(),
|
||||
Value<String> sampleText = const Value.absent(),
|
||||
Value<int> createdAtMs = const Value.absent(),
|
||||
Value<int> sortOrder = const Value.absent(),
|
||||
Value<int> 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<int> 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<int> rowid,
|
||||
});
|
||||
typedef $$LocalKvTableUpdateCompanionBuilder =
|
||||
LocalKvCompanion Function({
|
||||
Value<String> key,
|
||||
Value<String> value,
|
||||
Value<int> rowid,
|
||||
});
|
||||
|
||||
class $$LocalKvTableFilterComposer
|
||||
extends Composer<_$AppDatabase, $LocalKvTable> {
|
||||
$$LocalKvTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnFilters<String> get key => $composableBuilder(
|
||||
column: $table.key,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> 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<String> get key => $composableBuilder(
|
||||
column: $table.key,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> 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<String> get key =>
|
||||
$composableBuilder(column: $table.key, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> 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<String> key = const Value.absent(),
|
||||
Value<String> value = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => LocalKvCompanion(key: key, value: value, rowid: rowid),
|
||||
createCompanionCallback:
|
||||
({
|
||||
required String key,
|
||||
required String value,
|
||||
Value<int> 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);
|
||||
}
|
||||
|
|
@ -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<Column<Object>> 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<Column<Object>> get primaryKey => {key};
|
||||
}
|
||||
|
|
@ -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<SessionState>(
|
||||
builder: (context, s, _) {
|
||||
if (s.user == null) return const SignupScreen();
|
||||
if (!s.toneOnboardingDone) return const OnboardingToneScreen();
|
||||
return const ConversationListScreen();
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<int> userIds;
|
||||
final bool twinDisabledByPeer;
|
||||
final DateTime? createdAt;
|
||||
|
||||
factory ConversationSummary.fromJson(Map<String, dynamic> json) {
|
||||
final rawIds = json['user_ids'] as List<dynamic>? ?? 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<String, dynamic> json) => Contact(
|
||||
id: json['id'] as int,
|
||||
displayName: json['display_name'] as String? ?? '',
|
||||
contactUserId: json['contact_user_id'] as int?,
|
||||
relationshipNote: json['relationship_note'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
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<String, dynamic> 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,
|
||||
|
|
|
|||
|
|
@ -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<AutonomySettingsScreen> {
|
|||
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<AutonomyLevel>(
|
||||
|
|
|
|||
|
|
@ -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<ChatScreen> createState() => _ChatScreenState();
|
||||
|
|
@ -20,18 +21,54 @@ class ChatScreen extends StatefulWidget {
|
|||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
final _input = TextEditingController();
|
||||
final _draftEdit = TextEditingController();
|
||||
final _scroll = ScrollController();
|
||||
final _messages = <ChatMessage>[];
|
||||
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<void> _loadHistory() async {
|
||||
final session = context.read<SessionState>();
|
||||
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<String, dynamic> event) {
|
||||
|
|
@ -58,6 +95,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
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<ChatScreen> {
|
|||
_sub?.cancel();
|
||||
_socket?.dispose();
|
||||
_input.dispose();
|
||||
_draftEdit.dispose();
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +123,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
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<ChatScreen> {
|
|||
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<ChatScreen> {
|
|||
final session = context.read<SessionState>();
|
||||
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<ChatScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
void _rejectDraft() {
|
||||
setState(() {
|
||||
_pendingDraft = null;
|
||||
_draftEdit.clear();
|
||||
_banner = '초안을 버렸습니다. 직접 쓰거나 다시 초안을 요청하세요.';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _veto() async {
|
||||
final session = context.read<SessionState>();
|
||||
try {
|
||||
|
|
@ -166,6 +223,84 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
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<SessionState>();
|
||||
|
|
@ -173,7 +308,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('대화방 #${widget.conversationId}'),
|
||||
title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
|
||||
actions: [
|
||||
TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')),
|
||||
],
|
||||
|
|
@ -188,7 +323,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
],
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
child: _loadingHistory
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView.builder(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (context, i) {
|
||||
|
|
@ -201,18 +339,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
},
|
||||
),
|
||||
),
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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<ContactsScreen> createState() => _ContactsScreenState();
|
||||
}
|
||||
|
||||
class _ContactsScreenState extends State<ContactsScreen> {
|
||||
List<Contact> _contacts = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final session = context.read<SessionState>();
|
||||
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<void> _showAddDialog() async {
|
||||
final nameCtrl = TextEditingController();
|
||||
final peerCtrl = TextEditingController();
|
||||
final noteCtrl = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
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<SessionState>();
|
||||
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<void> _startChat(Contact contact) async {
|
||||
final session = context.read<SessionState>();
|
||||
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<SessionState>();
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ConversationListScreen> {
|
||||
final _convId = TextEditingController(text: '1');
|
||||
final _rooms = <int>{1};
|
||||
List<ConversationSummary> _rooms = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_convId.dispose();
|
||||
super.dispose();
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final session = context.read<SessionState>();
|
||||
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<void> _createConversation() async {
|
||||
final peerCtrl = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
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<SessionState>();
|
||||
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<SessionState>();
|
||||
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,48 +126,53 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
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: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _convId,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '대화방 ID',
|
||||
border: OutlineInputBorder(),
|
||||
helperText: 'core-backend에 대화방 목록 API가 생기기 전 임시 진입',
|
||||
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
),
|
||||
if (_rooms.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('대화방이 없습니다. + 버튼이나 연락처에서 대화를 시작하세요.'),
|
||||
),
|
||||
),
|
||||
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)
|
||||
for (final room 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)),
|
||||
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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SessionState>();
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<InboxScreen> createState() => _InboxScreenState();
|
||||
}
|
||||
|
||||
class _InboxScreenState extends State<InboxScreen> {
|
||||
List<EscalationLogEntry> _logs = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final session = context.read<SessionState>();
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<OnboardingToneScreen> createState() => _OnboardingToneScreenState();
|
||||
}
|
||||
|
||||
class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
|
||||
final _samples = List.generate(4, (_) => TextEditingController());
|
||||
var _seeded = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_seeded) return;
|
||||
_seeded = true;
|
||||
final existing = context.read<SessionState>().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<void> _save({required bool markDone}) async {
|
||||
final session = context.read<SessionState>();
|
||||
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<SessionState>().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('저장하고 시작'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SessionsScreen> createState() => _SessionsScreenState();
|
||||
}
|
||||
|
||||
class _SessionsScreenState extends State<SessionsScreen> {
|
||||
List<Map<String, dynamic>> _sessions = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final session = context.read<SessionState>();
|
||||
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<void> _revoke(Map<String, dynamic> s) async {
|
||||
final session = context.read<SessionState>();
|
||||
if (session.user == null) return;
|
||||
final id = s['id'];
|
||||
if (id is! num) return;
|
||||
final isCurrent = s['is_current'] == true;
|
||||
final ok = await showDialog<bool>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +76,67 @@ class ApiClient {
|
|||
);
|
||||
}
|
||||
|
||||
Future<List<ConversationSummary>> listConversations() async {
|
||||
final obj = await _getObject('/conversations');
|
||||
final list = (obj['conversations'] as List<dynamic>? ?? const []);
|
||||
return list.map((e) => ConversationSummary.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<ConversationSummary> createConversation({
|
||||
required List<int> 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<dynamic>? ?? 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<List<ChatMessage>> listMessages(int conversationId) async {
|
||||
final obj = await _getObject('/conversations/$conversationId/messages');
|
||||
final list = (obj['messages'] as List<dynamic>? ?? const []);
|
||||
return list.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<List<Contact>> listContacts(int userId) async {
|
||||
final obj = await _getObject('/users/$userId/contacts');
|
||||
final list = (obj['contacts'] as List<dynamic>? ?? const []);
|
||||
return list.map((e) => Contact.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<Contact> 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<void> deleteContact(int userId, int contactId) async {
|
||||
await _json('DELETE', '/users/$userId/contacts/$contactId');
|
||||
}
|
||||
|
||||
Future<List<EscalationLogEntry>> listEscalationLogs(int userId) async {
|
||||
final obj = await _getObject('/users/$userId/escalation-logs');
|
||||
final list = (obj['escalation_logs'] as List<dynamic>? ?? const []);
|
||||
return list.map((e) => EscalationLogEntry.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<ChatMessage> sendMessage({
|
||||
required int conversationId,
|
||||
required int senderId,
|
||||
|
|
@ -126,24 +187,6 @@ class ApiClient {
|
|||
return list.map((e) => WhitelistRule.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> listConversations() async {
|
||||
final obj = await _getObject('/conversations');
|
||||
final list = (obj['conversations'] as List<dynamic>? ?? const []);
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> createConversation({
|
||||
required List<int> 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<WhitelistRule> 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<void> deleteWhitelist(int userId, int ruleId) async {
|
||||
await _json('DELETE', '/users/$userId/whitelist-rules/$ruleId');
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> listSessions(int userId) async {
|
||||
final obj = await _getObject('/users/$userId/sessions');
|
||||
final list = (obj['sessions'] as List<dynamic>? ?? const []);
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
Future<void> registerDeviceToken({
|
||||
required int userId,
|
||||
required String token,
|
||||
String platform = 'android',
|
||||
}) async {
|
||||
await _json('POST', '/users/$userId/device-tokens', body: {
|
||||
'token': token,
|
||||
'platform': platform,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> revokeSession(int userId, int sessionId) async {
|
||||
await _json('DELETE', '/users/$userId/sessions/$sessionId');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> 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<void> 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<AppDatabase> _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<void> _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<dynamic>)
|
||||
.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<void> 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<void> saveToneSamples(List<String> 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<void> skipToneOnboarding() async {
|
||||
toneOnboardingDone = true;
|
||||
_db ??= await _openDb();
|
||||
await _db!.setBoolKv(_kToneDone, true);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> 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<void> _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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(), ['하나만']);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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())
|
||||
Loading…
Reference in New Issue