Compare commits

..

3 Commits

Author SHA1 Message Date
Cursor Agent 17e7bc63b4
docs: specify account/settings IA and L2 meaning gaps (Q8/Q9)
Add decision-log Q8/Q9 as proposals, account-settings-ia.md for Phase 1
invite-only auth + logout/settings IA, and align PRD/tech-design/roadmap
so implementation waits for Master confirmation.

Co-authored-by: okuma <o0kuma@users.noreply.github.com>
2026-08-03 07:35:43 +00:00
Claude 193b15d58a
오프라인 메시지 큐(N4-11) 구현: since_id 캐치업 + WS 재연결 backoff
서버가 이미 모든 메시지를 DB에 durable하게 저장하므로 별도 큐를 새로
만들지 않고, 연결이 끊겼던 클라이언트가 그 DB에서 놓친 부분만 다시
받아오는 방식으로 풀었다.

- core-backend: GET /conversations/:id/messages?since_id=<id> 추가
  (파라미터 없으면 기존 전체 히스토리 그대로, 비정상 값은 400, 최댓값
  초과는 빈 배열). TestListMessagesSinceID로 확인.
- mobile/ws_client.dart: 소켓 onDone/onError에서 그냥 멈추던 것을
  backoff(1s→2s→4s→8s→...→30s 캡, 성공 시 리셋) 재연결로 교체하고,
  재연결 성공 신호를 reconnects 스트림으로 노출.
- mobile/chat_screen.dart: WidgetsBindingObserver를 추가해 소켓
  재연결 신호 + didChangeAppLifecycleState(resumed) 양쪽에서 since_id
  캐치업을 호출, 기존 메시지와 id 기준으로 중복 없이 병합.
- mobile/services/message_sync.dart: backoff 계산과 중복 없는 병합을
  순수 함수로 뽑아 message_sync_test.dart에서 결정적으로 검증.

실제 소켓 재연결 타이밍이나 앱 백그라운드/포그라운드 전환에서 OS가
소켓을 어떻게 처리하는지는 단위 테스트로 증명할 수 없어 실기기
Android QA가 남아 있음 — roadmap.md/deploy-checklist.md N4-11에
부분 검증으로 기록.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m
2026-08-03 07:26:37 +00:00
Claude a1415f5ce0
운영 대시보드 낡은 메모 정정 + 트윈 발송 차단 사유별 분해 추가
roadmap.md §2.6의 "모니터링 대시보드 UI·지연시간·오류율 아직 없음" 메모가
낡은 기록이었음을 재확인 -- GET /admin/dashboard(HTML)와 /admin/metrics의
생성 지연시간·오류율 계측은 이미 이전 Phase 1 B 커밋에서 구현돼 있었고,
같은 문서 §B 항목과도 모순되고 있었음. [~] -> [x]로 정정하고 실제 배경을
남김.

실제로 오늘 추가한 것: RuntimeMetrics.TwinSendsBlocked를 사유별
(peer_veto/group_conversation/flood_blocked/flood_detected/
escalate_check_error/escalated/autonomy_l0/autonomy_l1_unapproved/
autonomy_l2_no_whitelist_match/autonomy_unknown_level)로 분해하는
TwinSendsBlockedByReason을 추가하고, /admin/metrics에
twin_sends_blocked_by_reason으로 노출(기존 twin_sends_blocked 총합은
유지). 이미 JSON에는 있었지만 화면엔 안 보이던 escalations_by_reason과
새 필드를 /admin/dashboard에 사유별 표로 렌더링.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m
2026-08-03 07:12:34 +00:00
20 changed files with 792 additions and 86 deletions

View File

@ -10,7 +10,8 @@ copying content into prompts or new files.
When documents disagree, follow this order: When documents disagree, follow this order:
1. [`docs/decision-log.md`](docs/decision-log.md) — working assumptions for Q1~Q7 1. [`docs/decision-log.md`](docs/decision-log.md) — working assumptions for Q1~Q7 (확정)
and Q8/Q9 (계정·설정 IA / L2 의미 — 제안 until Master confirms)
2. [`docs/vision.md`](docs/vision.md) / [`docs/PRD.md`](docs/PRD.md) / [`docs/tech-design.md`](docs/tech-design.md) 2. [`docs/vision.md`](docs/vision.md) / [`docs/PRD.md`](docs/PRD.md) / [`docs/tech-design.md`](docs/tech-design.md)
3. [`docs/roadmap.md`](docs/roadmap.md) / [`docs/risk-log.md`](docs/risk-log.md) 3. [`docs/roadmap.md`](docs/roadmap.md) / [`docs/risk-log.md`](docs/risk-log.md)
4. [`docs/PLANNING.md`](docs/PLANNING.md) — process guide 4. [`docs/PLANNING.md`](docs/PLANNING.md) — process guide

View File

@ -241,8 +241,20 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
c.JSON(http.StatusForbidden, gin.H{"detail": "not a participant of this conversation"}) c.JSON(http.StatusForbidden, gin.H{"detail": "not a participant of this conversation"})
return return
} }
query := db.Where("conversation_id = ?", convID)
// 오프라인 큐 캐치업(roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11):
// 재연결·앱 복귀 시 클라이언트가 이미 가진 마지막 메시지 id 이후만
// 다시 받아 gap을 메운다. 파라미터가 없으면 기존 동작(전체 히스토리) 그대로.
if q := c.Query("since_id"); q != "" {
sinceID, err := strconv.ParseUint(q, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "since_id must be a positive integer"})
return
}
query = query.Where("id > ?", sinceID)
}
var messages []Message var messages []Message
db.Where("conversation_id = ?", convID).Order("id asc").Find(&messages) query.Order("id asc").Find(&messages)
out := make([]gin.H, 0, len(messages)) out := make([]gin.H, 0, len(messages))
for _, m := range messages { for _, m := range messages {
out = append(out, gin.H{ out = append(out, gin.H{

View File

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

View File

@ -201,6 +201,12 @@ const adminDashboardHTML = `<!doctype html>
.v { font-size: 1.4rem; font-weight: 700; margin-top: 4px; } .v { font-size: 1.4rem; font-weight: 700; margin-top: 4px; }
pre { background: #fff; border: 1px solid #d5e2db; border-radius: 10px; padding: 14px; overflow: auto; } pre { background: #fff; border: 1px solid #d5e2db; border-radius: 10px; padding: 14px; overflow: auto; }
button { margin: 12px 0; padding: 8px 14px; border-radius: 8px; border: 0; background: #1f6f5b; color: #fff; cursor: pointer; } button { margin: 12px 0; padding: 8px 14px; border-radius: 8px; border: 0; background: #1f6f5b; color: #fff; cursor: pointer; }
.breakdowns { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); margin-top: 20px; }
.breakdown table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #d5e2db; border-radius: 10px; overflow: hidden; }
.breakdown th, .breakdown td { text-align: left; padding: 8px 12px; font-size: .85rem; }
.breakdown th { background: #eaf1ed; color: #40554c; }
.breakdown tr + tr td { border-top: 1px solid #e4ede8; }
.breakdown td.n { text-align: right; font-weight: 600; }
</style> </style>
</head> </head>
<body> <body>
@ -208,9 +214,25 @@ const adminDashboardHTML = `<!doctype html>
<p>Bearer ADMIN_API_TOKEN 으로 /admin/metrics 불러옵니다. 생성 지연·오류율은 프로세스 메모리 샘플입니다.</p> <p>Bearer ADMIN_API_TOKEN 으로 /admin/metrics 불러옵니다. 생성 지연·오류율은 프로세스 메모리 샘플입니다.</p>
<button onclick="load()">새로고침</button> <button onclick="load()">새로고침</button>
<div class="grid" id="cards"></div> <div class="grid" id="cards"></div>
<div class="breakdowns">
<div class="breakdown">
<h2 style="margin:0 0 8px;font-size:1rem">에스컬레이션 사유별</h2>
<table><thead><tr><th>reason</th><th style="text-align:right">count</th></tr></thead><tbody id="escByReason"></tbody></table>
</div>
<div class="breakdown">
<h2 style="margin:0 0 8px;font-size:1rem">와카뷰 발송 차단 사유별</h2>
<table><thead><tr><th>reason</th><th style="text-align:right">count</th></tr></thead><tbody id="blockedByReason"></tbody></table>
</div>
</div>
<h2 style="margin-top:28px;font-size:1rem">raw JSON</h2> <h2 style="margin-top:28px;font-size:1rem">raw JSON</h2>
<pre id="raw">loading</pre> <pre id="raw">loading</pre>
<script> <script>
function renderBreakdown(elId, obj) {
const rows = Object.entries(obj || {}).sort((a, b) => b[1] - a[1]);
document.getElementById(elId).innerHTML = rows.length
? rows.map(([k, v]) => '<tr><td>'+k+'</td><td class="n">'+v+'</td></tr>').join('')
: '<tr><td colspan="2">없음</td></tr>';
}
async function load() { async function load() {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
let token = params.get('token') || localStorage.ADMIN_API_TOKEN || ''; let token = params.get('token') || localStorage.ADMIN_API_TOKEN || '';
@ -237,6 +259,8 @@ const adminDashboardHTML = `<!doctype html>
]; ];
document.getElementById('cards').innerHTML = cards.map(([k,v]) => document.getElementById('cards').innerHTML = cards.map(([k,v]) =>
'<div class="card"><div class="k">'+k+'</div><div class="v">'+v+'</div></div>').join(''); '<div class="card"><div class="k">'+k+'</div><div class="v">'+v+'</div></div>').join('');
renderBreakdown('escByReason', data.escalations_by_reason);
renderBreakdown('blockedByReason', data.twin_sends_blocked_by_reason);
} }
load(); load();
</script> </script>

View File

@ -93,10 +93,11 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
if !requireAdmin(c) { if !requireAdmin(c) {
return return
} }
// v1-minimal (roadmap.md §2.6): only counts honestly derivable from // v1-minimal (roadmap.md §2.6): counts honestly derivable from the
// the current schema. Draft-generation latency and AI-service error // current schema, plus process-local draft/escalation timings kept
// rate need a request-timing/logging layer that doesn't exist yet -- // in runtimeMetrics (see metrics.go) -- the latter reset on restart
// not fabricated here, left for that future work. // and are not a substitute for a real time-series DB, but are real
// numbers, not placeholders.
var usersTotal, humanMessages, twinMessages, escalationsTotal int64 var usersTotal, humanMessages, twinMessages, escalationsTotal int64
var conversationsTotal, conversationsVetoed, invitesMinted, invitesUsed int64 var conversationsTotal, conversationsVetoed, invitesMinted, invitesUsed int64
db.Model(&User{}).Count(&usersTotal) db.Model(&User{}).Count(&usersTotal)
@ -149,6 +150,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
"escalate_errors": rt.EscalateErrors, "escalate_errors": rt.EscalateErrors,
"escalate_error_rate": rt.EscalateErrorRate, "escalate_error_rate": rt.EscalateErrorRate,
"twin_sends_blocked": rt.TwinSendsBlocked, "twin_sends_blocked": rt.TwinSendsBlocked,
"twin_sends_blocked_by_reason": rt.TwinSendsBlockedByReason,
"push_attempts": rt.PushAttempts, "push_attempts": rt.PushAttempts,
"push_skipped": rt.PushSkipped, "push_skipped": rt.PushSkipped,
"push_delivered": rt.PushDelivered, "push_delivered": rt.PushDelivered,
@ -262,7 +264,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
// later checks can override an earlier block. // later checks can override an earlier block.
if req.SenderMode == SenderTwin { if req.SenderMode == SenderTwin {
if conversation.TwinDisabledByPeer { if conversation.TwinDisabledByPeer {
runtimeMetrics.recordTwinBlocked() runtimeMetrics.recordTwinBlocked("peer_veto")
c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 와카뷰를 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 와카뷰를 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"})
return return
} }
@ -272,7 +274,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
// 무관하게 그룹 대화에서는 와카뷰 발송 자체를 막는다. 초안은 // 무관하게 그룹 대화에서는 와카뷰 발송 자체를 막는다. 초안은
// 항상 사람이 검토해서 직접 보낸다. // 항상 사람이 검토해서 직접 보낸다.
if conversation.IsGroup { if conversation.IsGroup {
runtimeMetrics.recordTwinBlocked() runtimeMetrics.recordTwinBlocked("group_conversation")
c.JSON(http.StatusForbidden, gin.H{"detail": "그룹 대화에서는 와카뷰 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"}) c.JSON(http.StatusForbidden, gin.H{"detail": "그룹 대화에서는 와카뷰 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"})
return return
} }
@ -285,14 +287,14 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
// 불가 지점에 있어, 어떤 자율성 레벨/화이트리스트로도 건너뛸 수 // 불가 지점에 있어, 어떤 자율성 레벨/화이트리스트로도 건너뛸 수
// 없다. // 없다.
if conversation.TwinDisabledByFlood { if conversation.TwinDisabledByFlood {
runtimeMetrics.recordTwinBlocked() runtimeMetrics.recordTwinBlocked("flood_blocked")
c.JSON(http.StatusForbidden, gin.H{"detail": "도배 감지로 이 대화방의 와카뷰 자동 발송이 일시중단되어 있습니다 -- 사후 알림에서 확인 후 재개할 수 있습니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "도배 감지로 이 대화방의 와카뷰 자동 발송이 일시중단되어 있습니다 -- 사후 알림에서 확인 후 재개할 수 있습니다"})
return return
} }
if exceeded, count := floodDetected(db, convID, req.SenderID); exceeded { if exceeded, count := floodDetected(db, convID, req.SenderID); exceeded {
conversation.TwinDisabledByFlood = true conversation.TwinDisabledByFlood = true
db.Save(&conversation) db.Save(&conversation)
runtimeMetrics.recordTwinBlocked() runtimeMetrics.recordTwinBlocked("flood_detected")
reason := floodReason(count) reason := floodReason(count)
db.Create(&EscalationLog{ db.Create(&EscalationLog{
UserID: req.SenderID, UserID: req.SenderID,
@ -315,12 +317,12 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
result, err := ai.checkEscalation(req.Text) result, err := ai.checkEscalation(req.Text)
runtimeMetrics.recordEscalate(err) runtimeMetrics.recordEscalate(err)
if err != nil { if err != nil {
runtimeMetrics.recordTwinBlocked() runtimeMetrics.recordTwinBlocked("escalate_check_error")
c.JSON(http.StatusBadGateway, gin.H{"detail": "escalation gate unavailable, twin send blocked: " + err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"detail": "escalation gate unavailable, twin send blocked: " + err.Error()})
return return
} }
if result.Escalate { if result.Escalate {
runtimeMetrics.recordTwinBlocked() runtimeMetrics.recordTwinBlocked("escalated")
db.Create(&EscalationLog{ db.Create(&EscalationLog{
UserID: req.SenderID, UserID: req.SenderID,
ConversationID: convID, ConversationID: convID,
@ -347,23 +349,23 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
switch level { switch level {
case AutonomyL0: case AutonomyL0:
runtimeMetrics.recordTwinBlocked() runtimeMetrics.recordTwinBlocked("autonomy_l0")
c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 와카뷰 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"}) c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 와카뷰 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"})
return return
case AutonomyL1: case AutonomyL1:
if !req.Approved { if !req.Approved {
runtimeMetrics.recordTwinBlocked() runtimeMetrics.recordTwinBlocked("autonomy_l1_unapproved")
c.JSON(http.StatusForbidden, gin.H{"detail": "L1은 발송 전 사용자 승인이 필요합니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "L1은 발송 전 사용자 승인이 필요합니다"})
return return
} }
case AutonomyL2: case AutonomyL2:
if !req.Approved && !whitelistMatches(db, req.SenderID, convID, req.Text) { if !req.Approved && !whitelistMatches(db, req.SenderID, convID, req.Text) {
runtimeMetrics.recordTwinBlocked() runtimeMetrics.recordTwinBlocked("autonomy_l2_no_whitelist_match")
c.JSON(http.StatusForbidden, gin.H{"detail": "화이트리스트에 없는 주제는 L1과 동일하게 사용자 승인이 필요합니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "화이트리스트에 없는 주제는 L1과 동일하게 사용자 승인이 필요합니다"})
return return
} }
default: default:
runtimeMetrics.recordTwinBlocked() runtimeMetrics.recordTwinBlocked("autonomy_unknown_level")
c.JSON(http.StatusForbidden, gin.H{"detail": "알 수 없는 자율성 레벨이라 발송을 차단합니다"}) c.JSON(http.StatusForbidden, gin.H{"detail": "알 수 없는 자율성 레벨이라 발송을 차단합니다"})
return return
} }

View File

@ -658,6 +658,11 @@ func TestVetoMissingConversation(t *testing.T) {
} }
func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) { func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) {
// runtimeMetrics is a package-level in-process counter shared across the
// whole test binary (not reset per-test like the per-test sqlite db), so
// tests that assert on its exact totals must reset it first to avoid
// picking up counts left behind by earlier tests in this file.
runtimeMetrics = &RuntimeMetrics{}
server, db := setupTestServer(t) server, db := setupTestServer(t)
senderID, token := mustSignup(t, server.URL, "메트릭") senderID, token := mustSignup(t, server.URL, "메트릭")
@ -711,6 +716,67 @@ func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) {
if out["invites_minted"].(float64) < 1 || out["invites_used"].(float64) < 1 { if out["invites_minted"].(float64) < 1 || out["invites_used"].(float64) < 1 {
t.Fatalf("expected at least 1 minted/used invite, got %v", out) t.Fatalf("expected at least 1 minted/used invite, got %v", out)
} }
// The "계좌번호 알려줄게" twin send above was blocked by the escalation
// gate, not by veto/flood/autonomy -- confirm it landed under the right
// by-reason key (roadmap.md monitoring gap: distinguish *why* a
// twin-authored send was blocked, not just a raw total).
blockedByReason := out["twin_sends_blocked_by_reason"].(map[string]interface{})
if blockedByReason["escalated"].(float64) != 1 {
t.Fatalf("expected 1 escalated block, got %v", blockedByReason)
}
if out["twin_sends_blocked"].(float64) != 1 {
t.Fatalf("expected top-line twin_sends_blocked to match the sum of by-reason counts, got %v", out["twin_sends_blocked"])
}
}
// TestAdminMetricsBlockedByReasonAttributesDistinctCauses exercises two
// different twin-send block paths (peer veto, autonomy L0) through the real
// HTTP handler and checks /admin/metrics attributes each to its own reason
// key instead of collapsing them into one undifferentiated total.
func TestAdminMetricsBlockedByReasonAttributesDistinctCauses(t *testing.T) {
runtimeMetrics = &RuntimeMetrics{} // see comment in TestAdminMetricsCountsMessagesEscalationsAndVeto
server, db := setupTestServer(t)
senderID, _ := mustSignup(t, server.URL, "차단사유") // defaults to AutonomyL0
vetoedConv := Conversation{IsGroup: false}
if err := db.Create(&vetoedConv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
vetoedBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(vetoedConv.ID), 10)
postJSON(t, vetoedBase+"/veto", nil)
if resp := postJSON(t, vetoedBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "안녕", SenderMode: SenderTwin}); resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected peer-veto block, got %d", resp.StatusCode)
}
l0Conv := Conversation{IsGroup: false}
if err := db.Create(&l0Conv).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
l0Base := server.URL + "/conversations/" + strconv.FormatUint(uint64(l0Conv.ID), 10)
if resp := postJSON(t, l0Base+"/messages", sendMessageRequest{SenderID: senderID, Text: "안녕", SenderMode: SenderTwin}); resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected L0 autonomy block, got %d", resp.StatusCode)
}
mreq, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil)
mreq.Header.Set("Authorization", "Bearer test-admin-token")
resp, err := http.DefaultClient.Do(mreq)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
blockedByReason := out["twin_sends_blocked_by_reason"].(map[string]interface{})
if blockedByReason["peer_veto"].(float64) != 1 {
t.Fatalf(`expected twin_sends_blocked_by_reason["peer_veto"] == 1, got %v`, blockedByReason)
}
if blockedByReason["autonomy_l0"].(float64) != 1 {
t.Fatalf(`expected twin_sends_blocked_by_reason["autonomy_l0"] == 1, got %v`, blockedByReason)
}
if out["twin_sends_blocked"].(float64) != 2 {
t.Fatalf("expected top-line twin_sends_blocked == 2, got %v", out["twin_sends_blocked"])
}
} }
func TestWhitelistRuleCRUD(t *testing.T) { func TestWhitelistRuleCRUD(t *testing.T) {

View File

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

View File

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

View File

@ -30,7 +30,9 @@
기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다. 기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다.
현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30). 현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30).
PoC 의존 하위 질문(자율성 기본값 등)만 열려 있다 — decision-log를 단일 기준으로 따른다. PoC 의존 하위 질문(자율성 기본값 등)과 **Q8/Q9(계정·설정 IA / L2 의미, 2026-08-03 제안)**
열려 있다 — decision-log를 단일 기준으로 따른다. 계정 UX 상세는
[`account-settings-ia.md`](./account-settings-ia.md).
| # | 질문 | 결정 | 상태 | | # | 질문 | 결정 | 상태 |
|---|------|------|------| |---|------|------|------|

View File

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

111
docs/account-settings-ia.md Normal file
View File

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

View File

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

View File

@ -39,13 +39,11 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료** - **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
- 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA - 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA
- UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영 - UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영
(`ee8a41d`, 2026-08-03) — 기본 `ThemeMode.light`, 내 버블 `#007AFF`, - **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대)
다크 캔버스 `#141418` · web/core/ai 재배포 완료 - **문서 갭 (2026-08-03):** 로그아웃·설정 IA·L2 의미 —
- **Track C 콘텐츠 갭 A~F**: **GitHub+Gitea `main` 동기화 · 프로덕션 재배포 완료** [`decision-log.md`](./decision-log.md) Q8/Q9 **제안**,
(`95422bb`, 2026-08-03) — web/core/ai 재빌드. C1 단톡 따라잡기 · C2 관계별 페르소나 · [`account-settings-ia.md`](./account-settings-ia.md). **Master 확정 전 구현 착수하지 않음**
C3 스팸/도배 · C4 자율성 상대별 예외 · C5 관계 메모 프롬프트 · C6 답장 마감(인앱 - 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
배지·배너 포함; OS 로컬 알림 실기기 발사는 N4 Android QA에서 확인).
- 실 FCM 기기 수신 · Android 실기기 탭(답장 마감 알림 실제 발사 확인 포함) · 사람 PoC 실행은 남음
### NEXT 순서 ### NEXT 순서
@ -233,7 +231,7 @@ API 계층은 프로덕션에서 검증됨 (`CORE_API_BASE=https://msn.iykyka.co
| ID | 작업 | Status | 비고 | | ID | 작업 | Status | 비고 |
|----|------|--------|------| |----|------|--------|------|
| **N4-11** | 오프라인 메시지 큐 | todo | 멀티디바이스 고도화 | | **N4-11** | 오프라인 메시지 큐 | **done (부분 검증)** (2026-08-03) | 서버는 이미 모든 메시지를 DB에 durable하게 저장 — 별도 큐를 새로 만들지 않고 `GET /conversations/:id/messages?since_id=`(신규, 파라미터 없으면 기존 전체 히스토리 그대로) + 클라이언트 WS 재연결(backoff 1s→2s→...→30s 캡, 성공 시 리셋, `mobile/lib/services/ws_client.dart`) + 재연결/앱 포그라운드 복귀 시 since_id 캐치업(`mobile/lib/screens/chat_screen.dart`)으로 gap을 메움. `core-backend/a1_a2_test.go`(`TestListMessagesSinceID`)와 `mobile/test/message_sync_test.dart`(backoff 계산 + 중복 없는 병합을 순수 함수로 뽑아 검증)로 확인한 부분과, **실제 소켓 재연결 타이밍·앱 백그라운드/포그라운드 전환에서 OS가 소켓을 어떻게 처리하는지는 단위 테스트로 증명 불가 — 실기기 Android QA 남음**(N4-C6b와 같은 프레이밍) |
| **N4-12** | 자연스러움 피드백 UI | todo | vision 지표 | | **N4-12** | 자연스러움 피드백 UI | todo | vision 지표 |
| **N4-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 | | **N4-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 |
| **N4-14** | 내부 release APK | todo | `docs/android-release.md` | | **N4-14** | 내부 release APK | todo | `docs/android-release.md` |

View File

@ -35,7 +35,10 @@
**2.1 코어 백엔드** (Go, PoC 결과 무관 — 지금 착수 가능) **2.1 코어 백엔드** (Go, PoC 결과 무관 — 지금 착수 가능)
- [x] 계정/인증 (초대 코드 기반 가입) — `core-backend/` (Go, Gin), 중복 코드 409 실제 테스트로 확인함 - [x] 계정/인증 (초대 코드 기반 가입) — `core-backend/` (Go, Gin), 중복 코드 409 실제 테스트로 확인함
- [x] 메시지 릴레이 서버 (송수신) — `core-backend/` WebSocket + REST, 실제 테스트로 브로드캐스트 확인함. - [x] 메시지 릴레이 서버 (송수신) — `core-backend/` WebSocket + REST, 실제 테스트로 브로드캐스트 확인함.
멀티 디바이스 동기화(같은 유저 여러 기기)는 아직 — 지금은 대화방 단위 인메모리 커넥션 매니저뿐 멀티 디바이스 동기화(같은 유저 여러 기기)는 아직 — 지금은 대화방 단위 인메모리 커넥션 매니저뿐.
오프라인 큐(deploy-checklist N4-11)는 DB가 이미 모든 메시지를 durable하게 들고 있으므로
서버에 별도 큐를 새로 만들지 않고, `GET /conversations/:id/messages?since_id=`로 재연결 시
gap을 메우는 방식으로 구현함(Go 테스트로 확인) — 자세한 내용은 §4 N4-11 항목 참고
- [x] DB 스키마: users, invite_codes, contacts, conversations, messages, twin_settings, - [x] DB 스키마: users, invite_codes, contacts, conversations, messages, twin_settings,
escalation_logs, whitelist_rules — `core-backend/models.go` (GORM), `backend/app/models.py` escalation_logs, whitelist_rules — `core-backend/models.go` (GORM), `backend/app/models.py`
(Python 프로토타입)와 동일 스키마(+ invite_codes는 여기서 새로 추가) (Python 프로토타입)와 동일 스키마(+ invite_codes는 여기서 새로 추가)
@ -121,11 +124,21 @@
`/admin/metrics``peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움 `/admin/metrics``peer_veto_rate`로 1차 근사 가능해짐(대화방 단위, 확정 정의 아님). 자연스러움
피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기 피드백 수집 UI는 Flutter 클라이언트 책임이라 보류. 안전선 위반 0건은 런타임에 "수집"하는 지표라기
보다 지금까지의 하드게이트 테스트들이 이미 보증하는 것 — 별도 계측 불필요 보다 지금까지의 하드게이트 테스트들이 이미 보증하는 것 — 별도 계측 불필요
- [~] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) — `GET /admin/metrics` - [x] 모니터링 대시보드 (에스컬레이션 트리거율, 생성 지연시간, 오류율) — **2026-08-03 재확인**:
카운트 기반 데이터(메시지 수, 에스컬레이션 사유별 집계, 거부권 발동률, 초대 코드 발급/사용 수)는 이 항목의 `[~]` 상태와 "대시보드 UI 자체와 생성 지연시간·오류율은 아직 없음" 설명은 실제로는
노출함. **대시보드 UI 자체와 생성 지연시간·오류율**은 아직 없음 — UI는 Flutter/관리자 웹 쪽이고, 낡은 기록이었음(§B의 "생성 지연시간·오류율 계측"/"모니터링 대시보드(최소)" `[x]` 항목과 서로
지연시간·오류율은 요청 타이밍/로깅 계측 계층이 따로 필요해서 이번엔 만들지 않음(허위로 채우지 모순되고 있었던 걸 오늘 발견). 실제로는 이전 Phase 1 B 커밋에서 이미 `GET /admin/dashboard`
않고 명시적으로 비워둠) (HTML, `core-backend/b_routes.go``adminDashboardHTML`)와 그 위에서 읽는
`GET /admin/metrics`(`core-backend/main.go`, 카드: 사용자·메시지·에스컬레이션·거부권 발동률·
생성 지연시간 평균/최대·에스컬레이션 오류율 등)가 구현되어 있었음. 오늘 실제로 추가한 것은
두 가지뿐: (1) 트윈 발송 차단을 사유별로 분해하는 `twin_sends_blocked_by_reason`
(peer_veto/group_conversation/flood_blocked/flood_detected/escalate_check_error/escalated/
autonomy_l0/autonomy_l1_unapproved/autonomy_l2_no_whitelist_match/autonomy_unknown_level —
이전엔 전부 `twin_sends_blocked` 한 총합으로만 뭉쳐 있었음), (2) 이미 JSON에는 있었지만
대시보드 화면에는 안 보이던 `escalations_by_reason`과 새 `twin_sends_blocked_by_reason`
대시보드에 사유별 표로 렌더링. 정직하게 남겨둘 한계: 이 계측은 여전히 프로세스 메모리
전용(`RuntimeMetrics`)이라 재시작하면 리셋되는 근사치이고, 진짜 타임시리즈 DB가 아님 —
트렌드 분석이 필요해지면 별도 작업으로 남음
**2.7 콘텐츠 갭 — PRD 3.1 P0 대비 미구현 기능** (2026-07-31 발견, 2026-08-03 2차 재분석으로 **2.7 콘텐츠 갭 — PRD 3.1 P0 대비 미구현 기능** (2026-07-31 발견, 2026-08-03 2차 재분석으로
2.7-D~F 추가) 2.7-D~F 추가)
@ -317,6 +330,11 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
- [x] 로그인 세션/토큰 (`Session`, signup/login 시 Bearer 발급) - [x] 로그인 세션/토큰 (`Session`, signup/login 시 Bearer 발급)
- [x] `/invites`, `/admin/metrics` 접근 제어 (`ADMIN_API_TOKEN`) - [x] `/invites`, `/admin/metrics` 접근 제어 (`ADMIN_API_TOKEN`)
- [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`) - [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`)
- [ ] **계정·설정 IA 갭** (`decision-log` Q8 제안, [`account-settings-ia.md`](./account-settings-ia.md))
— Master 확정 후 착수. 이메일/비번 도입 금지(Phase 1)
- [ ] 입장 화면: 새로 가입 / 이미 가입(`POST /auth/login`) 구분
- [ ] 로그아웃: 세션 revoke + 로컬 토큰 클리어 + 입장 화면 복귀
- [ ] 설정 진입점에 로그아웃·세션·자율성·말투·데이터흐름 묶기
**A3. Flutter — 메신저답게 다듬기** (A1/A2 이후) **A3. Flutter — 메신저답게 다듬기** (A1/A2 이후)
- [x] 대화 목록/연락처 UI를 서버 API에 연결 - [x] 대화 목록/연락처 UI를 서버 API에 연결
@ -326,13 +344,27 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
- [x] E2E QA — `scripts/e2e_a3.py`로 A3 HTTP 플로우 16/16 통과(가입·연락처·대화·히스토리· - [x] E2E QA — `scripts/e2e_a3.py`로 A3 HTTP 플로우 16/16 통과(가입·연락처·대화·히스토리·
draft/L1·에스컬레이션·알림 로그·되돌리기·거부권·화이트리스트). `go test`/`pytest`/`flutter test` draft/L1·에스컬레이션·알림 로그·되돌리기·거부권·화이트리스트). `go test`/`pytest`/`flutter test`
동시 통과. Android 에뮬레이터 UI 탭은 이 환경에 SDK가 없어 체크리스트는 `mobile/README.md`에 유지 동시 통과. Android 에뮬레이터 UI 탭은 이 환경에 SDK가 없어 체크리스트는 `mobile/README.md`에 유지
- [ ] **L2 카피·의미 정렬** (`decision-log` Q9 제안) — 현재는 발송 게이트; UI가
“수신 자동응대”로 오해되지 않게 설명 수정. 수신 트리거 자동응대는 별 항목
- [ ] **L2 수신 트리거 자동응대** (PRD §2.2 목표) — Q9 확정·안전 게이트 테스트 후.
상대 메시지 수신 → 초안 → 화이트리스트면 twin 발송 + 사후 알림/되돌리기
##### B. 그다음 — 베타 품질 ##### B. 그다음 — 베타 품질
- [~] FCM 푸시 연동 — 토큰 등록 + 에스컬레이션 시 `notifyUser` + `POST /admin/push-test`. - [~] FCM 푸시 연동 — 토큰 등록 + 에스컬레이션 시 `notifyUser` + `POST /admin/push-test`.
`FCM_SERVER_KEY`와 실제 FCM registration token이 있으면 전송, 없으면 soft-skip. `FCM_SERVER_KEY`와 실제 FCM registration token이 있으면 전송, 없으면 soft-skip.
Flutter는 install-id 플레이스홀더 등록(Firebase Messaging 앱 키는 배포 환경에서 교체) Flutter는 install-id 플레이스홀더 등록(Firebase Messaging 앱 키는 배포 환경에서 교체)
- [~] 멀티 디바이스 동기화 — 세션 목록 + 세션 종료(`DELETE /users/:id/sessions/:id`). - [~] 멀티 디바이스 동기화 — 세션 목록 + 세션 종료(`DELETE /users/:id/sessions/:id`).
메시지 히스토리 서버 동기화는 이미 REST/WS; 오프라인 큐는 후속 메시지 히스토리 서버 동기화는 이미 REST/WS. 오프라인 메시지 큐(deploy-checklist N4-11)는
구현 완료(부분 검증) — 서버는 이미 모든 메시지를 DB에 durable하게 저장하므로 별도
큐를 새로 만들지 않고, `GET /conversations/:id/messages?since_id=`(신규, 없으면 기존
전체 히스토리 동작 그대로) + 클라이언트 WS 재연결(backoff 1s→2s→4s→8s→...→30s 캡, 성공
시 리셋) + 재연결/앱 포그라운드 복귀 시 그 since_id 캐치업으로 gap을 메움.
`core-backend/a1_a2_test.go`(`TestListMessagesSinceID`)와
`mobile/test/message_sync_test.dart`(backoff 계산 + 중복 없는 병합 순수 함수)로
결정적으로 검증한 부분과, 실제 소켓 재연결 타이밍·앱 백그라운드/포그라운드 전환에서
OS가 소켓을 실제로 어떻게 처리하는지는 단위 테스트로 증명할 수 없어 실기기 Android
QA가 남아 있음(N4-C6b 스누즈 알림과 같은 "부분 검증" 프레이밍)
- [x] drift + SQLCipher 로컬 저장 — `mobile/lib/db/` (말투 샘플·KV). 키는 - [x] drift + SQLCipher 로컬 저장 — `mobile/lib/db/` (말투 샘플·KV). 키는
`flutter_secure_storage`. Linux CI는 SQLCipher SO 없으면 메모리 폴백 `flutter_secure_storage`. Linux CI는 SQLCipher SO 없으면 메모리 폴백
- [x] 말투 이력 기기 내 저장 + 서버 최소 전송 — drift 암호화 저장, draft에 샘플만 전달 - [x] 말투 이력 기기 내 저장 + 서버 최소 전송 — drift 암호화 저장, draft에 샘플만 전달

View File

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

View File

@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../services/message_sync.dart';
import '../services/snooze_service.dart'; import '../services/snooze_service.dart';
import '../services/ws_client.dart'; import '../services/ws_client.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
@ -32,13 +33,19 @@ class ChatScreen extends StatefulWidget {
State<ChatScreen> createState() => _ChatScreenState(); State<ChatScreen> createState() => _ChatScreenState();
} }
class _ChatScreenState extends State<ChatScreen> { class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
final _input = TextEditingController(); final _input = TextEditingController();
final _draftEdit = TextEditingController(); final _draftEdit = TextEditingController();
final _scroll = ScrollController(); final _scroll = ScrollController();
final _messages = <ChatMessage>[]; final _messages = <ChatMessage>[];
ConversationSocket? _socket; ConversationSocket? _socket;
StreamSubscription? _sub; StreamSubscription? _sub;
// (roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11):
// . /
// REST since_id
// , ( ,
// OS가 ) QA .
StreamSubscription? _reconnectSub;
String? _banner; String? _banner;
DraftResult? _pendingDraft; DraftResult? _pendingDraft;
bool _busy = false; bool _busy = false;
@ -52,6 +59,7 @@ class _ChatScreenState extends State<ChatScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_api = context.read<SessionState>().api; _api = context.read<SessionState>().api;
_floodBlocked = widget.twinDisabledByFlood; _floodBlocked = widget.twinDisabledByFlood;
if (_floodBlocked) { if (_floodBlocked) {
@ -59,10 +67,45 @@ class _ChatScreenState extends State<ChatScreen> {
} }
_socket = ConversationSocket(widget.conversationId)..connect(); _socket = ConversationSocket(widget.conversationId)..connect();
_sub = _socket!.events.listen(_onEvent); _sub = _socket!.events.listen(_onEvent);
// (, hiccup )
// REST since_id로 ws_client.dart .
_reconnectSub = _socket!.reconnects.listen((_) => _catchUp());
_loadHistory(); _loadHistory();
_loadSnooze(); _loadSnooze();
} }
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
//
// (
// ). OS가 QA .
if (state == AppLifecycleState.resumed) {
_catchUp();
}
}
/// : id
/// REST로 , /REST가 .
Future<void> _catchUp() async {
if (!mounted || _loadingHistory) return;
final sinceId = highestMessageId(_messages);
try {
final fresh = await _api.listMessages(widget.conversationId, sinceId: sinceId);
if (!mounted || fresh.isEmpty) return;
final merged = mergeNewMessages(_messages, fresh);
setState(() {
_messages
..clear()
..addAll(merged);
});
_scrollToEnd();
_markLatestRead();
} on ApiException {
// Best-effort ,
// / .
}
}
Future<void> _loadSnooze() async { Future<void> _loadSnooze() async {
final controller = context.read<SessionState>().snoozeController; final controller = context.read<SessionState>().snoozeController;
if (controller == null) return; if (controller == null) return;
@ -223,7 +266,9 @@ class _ChatScreenState extends State<ChatScreen> {
// Fire-and-forget: dispose는 , // Fire-and-forget: dispose는 ,
// . // .
_markLatestRead(); _markLatestRead();
WidgetsBinding.instance.removeObserver(this);
_sub?.cancel(); _sub?.cancel();
_reconnectSub?.cancel();
_socket?.dispose(); _socket?.dispose();
_input.dispose(); _input.dispose();
_draftEdit.dispose(); _draftEdit.dispose();

View File

@ -101,8 +101,15 @@ class ApiClient {
); );
} }
Future<List<ChatMessage>> listMessages(int conversationId) async { /// [sinceId] id보다
final obj = await _getObject('/conversations/$conversationId/messages'); /// (roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11)
/// · gap을 .
/// ( ) .
Future<List<ChatMessage>> listMessages(int conversationId, {int? sinceId}) async {
final path = sinceId != null
? '/conversations/$conversationId/messages?since_id=$sinceId'
: '/conversations/$conversationId/messages';
final obj = await _getObject(path);
final list = (obj['messages'] as List<dynamic>? ?? const []); final list = (obj['messages'] as List<dynamic>? ?? const []);
return list.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>)).toList(); return list.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>)).toList();
} }

View File

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

View File

@ -5,31 +5,78 @@ import 'package:web_socket_channel/web_socket_channel.dart';
import '../config.dart'; import '../config.dart';
import '../models/models.dart'; import '../models/models.dart';
import 'message_sync.dart';
/// Conversation-scoped WebSocket relay (`GET /ws/conversations/:id`). /// Conversation-scoped WebSocket relay (`GET /ws/conversations/:id`).
///
/// (roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11):
/// (, hiccup ) `onDone`/`onError`
/// [nextReconnectDelay] backoff .
/// [reconnects] (chat_screen.dart)
/// REST `since_id` .
/// ( )
/// QA가 [nextReconnectDelay]
/// (mobile/test/message_sync_test.dart).
class ConversationSocket { class ConversationSocket {
ConversationSocket(this.conversationId); ConversationSocket(this.conversationId);
final int conversationId; final int conversationId;
WebSocketChannel? _channel; WebSocketChannel? _channel;
StreamSubscription? _channelSub;
final _controller = StreamController<Map<String, dynamic>>.broadcast(); final _controller = StreamController<Map<String, dynamic>>.broadcast();
final _reconnectController = StreamController<void>.broadcast();
Timer? _reconnectTimer;
int _reconnectAttempt = 0;
bool _disposed = false;
bool _everConnected = false;
Stream<Map<String, dynamic>> get events => _controller.stream; Stream<Map<String, dynamic>> get events => _controller.stream;
/// (
/// chat_screen.dart는 initState에서 `_loadHistory()`
/// , "끊겼다가 다시 붙었을 때" ).
Stream<void> get reconnects => _reconnectController.stream;
void connect() { void connect() {
final wasReconnect = _everConnected;
// onError로 cancelOnError:false라
// leak을 .
_channelSub?.cancel();
final uri = Uri.parse('${AppConfig.wsBase()}/ws/conversations/$conversationId'); final uri = Uri.parse('${AppConfig.wsBase()}/ws/conversations/$conversationId');
_channel = WebSocketChannel.connect(uri); _channel = WebSocketChannel.connect(uri);
_channel!.stream.listen( _everConnected = true;
_channelSub = _channel!.stream.listen(
(raw) { (raw) {
// backoff를 .
_reconnectAttempt = 0;
if (raw is! String) return; if (raw is! String) return;
final decoded = jsonDecode(raw); final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {
_controller.add(decoded); _controller.add(decoded);
} }
}, },
onError: _controller.addError, onError: (Object e, StackTrace st) {
onDone: () {}, _controller.addError(e, st);
_scheduleReconnect();
},
onDone: _scheduleReconnect,
cancelOnError: false,
); );
if (wasReconnect) {
_reconnectAttempt = 0;
if (!_reconnectController.isClosed) _reconnectController.add(null);
}
}
void _scheduleReconnect() {
if (_disposed) return;
_reconnectTimer?.cancel();
final delay = nextReconnectDelay(_reconnectAttempt);
_reconnectAttempt++;
_reconnectTimer = Timer(delay, () {
if (_disposed) return;
connect();
});
} }
ChatMessage? parseMessageEvent(Map<String, dynamic> event) { ChatMessage? parseMessageEvent(Map<String, dynamic> event) {
@ -47,7 +94,11 @@ class ConversationSocket {
} }
Future<void> dispose() async { Future<void> dispose() async {
_disposed = true;
_reconnectTimer?.cancel();
await _channelSub?.cancel();
await _channel?.sink.close(); await _channel?.sink.close();
await _controller.close(); await _controller.close();
await _reconnectController.close();
} }
} }

View File

@ -0,0 +1,85 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ykavu_mobile/models/models.dart';
import 'package:ykavu_mobile/services/message_sync.dart';
ChatMessage _msg(int id, {bool retracted = false}) => ChatMessage(
id: id,
conversationId: 1,
senderId: 1,
senderMode: SenderMode.human,
text: 'msg-$id',
retracted: retracted,
createdAt: DateTime(2026, 8, 3),
);
void main() {
group('nextReconnectDelay', () {
test('first attempt (0) is the initial 1s delay', () {
expect(nextReconnectDelay(0), const Duration(seconds: 1));
});
test('doubles each attempt: 1s, 2s, 4s, 8s, 16s', () {
expect(nextReconnectDelay(1), const Duration(seconds: 2));
expect(nextReconnectDelay(2), const Duration(seconds: 4));
expect(nextReconnectDelay(3), const Duration(seconds: 8));
expect(nextReconnectDelay(4), const Duration(seconds: 16));
});
test('caps at 30s once doubling would exceed it', () {
expect(nextReconnectDelay(5), const Duration(seconds: 30));
expect(nextReconnectDelay(6), const Duration(seconds: 30));
});
test('stays capped even for a very large attempt count (no overflow)', () {
expect(nextReconnectDelay(1000000), const Duration(seconds: 30));
});
test('negative attempt is treated like the first attempt', () {
expect(nextReconnectDelay(-1), const Duration(seconds: 1));
});
});
group('highestMessageId', () {
test('empty list -> null', () {
expect(highestMessageId(const []), isNull);
});
test('returns the max id regardless of list order', () {
expect(highestMessageId([_msg(3), _msg(1), _msg(5), _msg(2)]), 5);
});
});
group('mergeNewMessages', () {
test('appends fresh messages not already present, sorted by id', () {
final existing = [_msg(1), _msg(2)];
final fresh = [_msg(4), _msg(3)];
final merged = mergeNewMessages(existing, fresh);
expect(merged.map((m) => m.id).toList(), [1, 2, 3, 4]);
});
test('drops fresh messages whose id is already loaded (WS/REST race)', () {
final existing = [_msg(1), _msg(2), _msg(3)];
final fresh = [_msg(2), _msg(3), _msg(4)];
final merged = mergeNewMessages(existing, fresh);
expect(merged.map((m) => m.id).toList(), [1, 2, 3, 4]);
});
test('fresh entirely a subset of existing -> unchanged, same order', () {
final existing = [_msg(1), _msg(2)];
final merged = mergeNewMessages(existing, [_msg(1), _msg(2)]);
expect(merged.map((m) => m.id).toList(), [1, 2]);
});
test('existing empty -> result is just fresh, sorted', () {
final merged = mergeNewMessages(const [], [_msg(2), _msg(1)]);
expect(merged.map((m) => m.id).toList(), [1, 2]);
});
test('does not mutate the existing list instance', () {
final existing = [_msg(1)];
final merged = mergeNewMessages(existing, [_msg(2)]);
expect(existing.length, 1);
expect(merged.length, 2);
});
});
}