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

View File

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

View File

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

View File

@ -17,6 +17,13 @@ type RuntimeMetrics struct {
EscalateChecks int64
EscalateErrors int64
TwinSendsBlocked int64
// TwinSendsBlockedByReason breaks the total above down by *why* a
// twin-authored send was blocked (peer_veto, flood_blocked,
// autonomy_l0, ...) -- see the call sites in main.go's
// POST /conversations/:id/messages handler for the full set of keys.
// This lets an operator tell a healthy signal (escalations catching
// sensitive content) apart from a bad one (lots of peer_veto/flood).
TwinSendsBlockedByReason map[string]int64
PushAttempts int64
PushSkipped int64
PushDelivered int64
@ -48,10 +55,14 @@ func (m *RuntimeMetrics) recordEscalate(err error) {
}
}
func (m *RuntimeMetrics) recordTwinBlocked() {
func (m *RuntimeMetrics) recordTwinBlocked(reason string) {
m.mu.Lock()
defer m.mu.Unlock()
m.TwinSendsBlocked++
if m.TwinSendsBlockedByReason == nil {
m.TwinSendsBlockedByReason = map[string]int64{}
}
m.TwinSendsBlockedByReason[reason]++
}
func (m *RuntimeMetrics) recordPush(delivered int, skipped bool) {
@ -88,6 +99,10 @@ func (m *RuntimeMetrics) snapshot() ginHMetrics {
if m.EscalateChecks > 0 {
escErrRate = float64(m.EscalateErrors) / float64(m.EscalateChecks)
}
byReason := make(map[string]int64, len(m.TwinSendsBlockedByReason))
for k, v := range m.TwinSendsBlockedByReason {
byReason[k] = v
}
return ginHMetrics{
DraftRequests: m.DraftRequests,
DraftErrors: m.DraftErrors,
@ -99,6 +114,7 @@ func (m *RuntimeMetrics) snapshot() ginHMetrics {
EscalateErrors: m.EscalateErrors,
EscalateErrorRate: escErrRate,
TwinSendsBlocked: m.TwinSendsBlocked,
TwinSendsBlockedByReason: byReason,
PushAttempts: m.PushAttempts,
PushSkipped: m.PushSkipped,
PushDelivered: m.PushDelivered,
@ -116,6 +132,7 @@ type ginHMetrics struct {
EscalateErrors int64
EscalateErrorRate float64
TwinSendsBlocked int64
TwinSendsBlockedByReason map[string]int64
PushAttempts int64
PushSkipped 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).
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.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 온보딩 (말투 학습)
1. 가입 시 기존 대화 일부 임포트 또는 짧은 질문 응답으로 말투 초기 세팅 (목표: 5분 이내)
2. 관계별 페르소나 초기값 설정 — 최소 "가까운 사이 / 공식적인 사이" 2종만 v1에서 지원
@ -20,12 +31,18 @@
### 2.2 읽씹 종결 시나리오 (핵심)
1. 사용자가 "방해금지"(수면 중 등) 상태를 켜거나, 앱이 비활성 상태 감지 시 자동 제안
2. 상대가 메시지를 보냄 → 와카뷰가 맥락(상태, 최근 활동 패턴)을 보고 응답 초안 생성
3. **L1**: 사용자에게 "지금 자동 응답 보낼까요?" 알림 → 승인 시 발송 (기본값)
4. **L2**: 사용자가 화이트리스트에 등록한 상대·주제(예: "가벼운 안부, 시간 문의")에 한해
3. **L1**: 사용자에게 "지금 자동 응답 보낼까요?" 알림 → 승인 시 발송
4. **L2 (목표)**: 사용자가 화이트리스트에 등록한 상대·주제(예: "가벼운 안부, 시간 문의")에 한해
즉시 자동 발송, 사후 알림
5. 상대에게는 와카뷰 뱃지가 붙은 말풍선으로 표시됨 (3.1 참고)
6. 사용자가 복귀하면 미응답/보류 항목 요약 제공, 필요 시 후속 메시지 작성
> **구현 갭 (Q9 제안):** 2026-08 현재 클라이언트는 L2를 “수신 즉시 자동응대”가 아니라
> **와카뷰 발송 시 승인 생략 게이트트**로만 구현한다. 수신 트리거 자동응대는
> `account-settings-ia.md` §3.4 / `roadmap.md` 후속 항목으로 분리한다. 그전까지 제품 카피는
> 게이트 현실에 맞게 쓴다.
### 2.3 단톡 따라잡기 시나리오
1. 사용자가 오랜만에 단톡방 진입 또는 "안 본 동안 요약" 요청
2. 와카뷰가 **나에게 멘션된 것 / 결정된 사항** 위주로 3~5줄 요약
@ -38,6 +55,9 @@
| 기능 | 설명 |
|---|---|
| 초대 코드 가입·재로그인 | 클로즈드 베타 입장. 이메일/비번 없음 (`account-settings-ia.md`) |
| 로그아웃 | 설정에서 세션 종료 + 로컬 토큰 클리어 (P0 갭 — UI 미완) |
| 설정 IA | 말투·자율성·세션·데이터흐름·로그아웃을 한 진입점에서 발견 가능 |
| 와카뷰 뱃지 | 와카뷰가 작성한 말풍선은 사람 말풍선과 시각적으로 구분(점선 테두리 + 뱃지 라벨) |
| 자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정 |
| 응답 승인 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 레이어 읽기 전용 허브 →
③ 자체 앱은 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 이후)
- 와카뷰 응답 임계점·화이트리스트 기본 주제 목록 (실사용 데이터)
- 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰)
- 상표 정식 등록 여부 (Q6은 "와카뷰"로 명칭 확정, 등록 절차는 베타 반응 이후)
- 베타 참가자 모집 규모와 방식
- Q8 / Q9 제안 → Master 확정

View File

@ -39,13 +39,11 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
- 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA
- UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영
(`ee8a41d`, 2026-08-03) — 기본 `ThemeMode.light`, 내 버블 `#007AFF`,
다크 캔버스 `#141418` · web/core/ai 재배포 완료
- **Track C 콘텐츠 갭 A~F**: **GitHub+Gitea `main` 동기화 · 프로덕션 재배포 완료**
(`95422bb`, 2026-08-03) — web/core/ai 재빌드. C1 단톡 따라잡기 · C2 관계별 페르소나 ·
C3 스팸/도배 · C4 자율성 상대별 예외 · C5 관계 메모 프롬프트 · C6 답장 마감(인앱
배지·배너 포함; OS 로컬 알림 실기기 발사는 N4 Android QA에서 확인).
- 실 FCM 기기 수신 · Android 실기기 탭(답장 마감 알림 실제 발사 확인 포함) · 사람 PoC 실행은 남음
- **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대)
- **문서 갭 (2026-08-03):** 로그아웃·설정 IA·L2 의미 —
[`decision-log.md`](./decision-log.md) Q8/Q9 **제안**,
[`account-settings-ia.md`](./account-settings-ia.md). **Master 확정 전 구현 착수하지 않음**
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
### NEXT 순서
@ -233,7 +231,7 @@ API 계층은 프로덕션에서 검증됨 (`CORE_API_BASE=https://msn.iykyka.co
| 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-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 |
| **N4-14** | 내부 release APK | todo | `docs/android-release.md` |

View File

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

View File

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

View File

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

View File

@ -101,8 +101,15 @@ class ApiClient {
);
}
Future<List<ChatMessage>> listMessages(int conversationId) async {
final obj = await _getObject('/conversations/$conversationId/messages');
/// [sinceId] id보다
/// (roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11)
/// · gap을 .
/// ( ) .
Future<List<ChatMessage>> listMessages(int conversationId, {int? sinceId}) async {
final path = sinceId != null
? '/conversations/$conversationId/messages?since_id=$sinceId'
: '/conversations/$conversationId/messages';
final obj = await _getObject(path);
final list = (obj['messages'] as List<dynamic>? ?? const []);
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 '../models/models.dart';
import 'message_sync.dart';
/// 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 {
ConversationSocket(this.conversationId);
final int conversationId;
WebSocketChannel? _channel;
StreamSubscription? _channelSub;
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;
/// (
/// chat_screen.dart는 initState에서 `_loadHistory()`
/// , "끊겼다가 다시 붙었을 때" ).
Stream<void> get reconnects => _reconnectController.stream;
void connect() {
final wasReconnect = _everConnected;
// onError로 cancelOnError:false라
// leak을 .
_channelSub?.cancel();
final uri = Uri.parse('${AppConfig.wsBase()}/ws/conversations/$conversationId');
_channel = WebSocketChannel.connect(uri);
_channel!.stream.listen(
_everConnected = true;
_channelSub = _channel!.stream.listen(
(raw) {
// backoff를 .
_reconnectAttempt = 0;
if (raw is! String) return;
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
_controller.add(decoded);
}
},
onError: _controller.addError,
onDone: () {},
onError: (Object e, StackTrace st) {
_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) {
@ -47,7 +94,11 @@ class ConversationSocket {
}
Future<void> dispose() async {
_disposed = true;
_reconnectTimer?.cancel();
await _channelSub?.cancel();
await _channel?.sink.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);
});
});
}