Compare commits
3 Commits
97edabc47d
...
17e7bc63b4
| Author | SHA1 | Date |
|---|---|---|
|
|
17e7bc63b4 | |
|
|
193b15d58a | |
|
|
a1415f5ce0 |
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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, "화이트주인")
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -139,21 +140,22 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
// this as a first approximation, not the final definition.
|
// this as a first approximation, not the final definition.
|
||||||
"peer_veto_rate": peerVetoRate,
|
"peer_veto_rate": peerVetoRate,
|
||||||
// Process-local draft/AI timings (roadmap B). Reset on restart.
|
// Process-local draft/AI timings (roadmap B). Reset on restart.
|
||||||
"draft_requests": rt.DraftRequests,
|
"draft_requests": rt.DraftRequests,
|
||||||
"draft_errors": rt.DraftErrors,
|
"draft_errors": rt.DraftErrors,
|
||||||
"draft_error_rate": rt.DraftErrorRate,
|
"draft_error_rate": rt.DraftErrorRate,
|
||||||
"draft_latency_avg_ms": rt.DraftLatencyAvgMs,
|
"draft_latency_avg_ms": rt.DraftLatencyAvgMs,
|
||||||
"draft_latency_max_ms": rt.DraftLatencyMaxMs,
|
"draft_latency_max_ms": rt.DraftLatencyMaxMs,
|
||||||
"draft_latency_samples": rt.DraftLatencySamples,
|
"draft_latency_samples": rt.DraftLatencySamples,
|
||||||
"escalate_checks": rt.EscalateChecks,
|
"escalate_checks": rt.EscalateChecks,
|
||||||
"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,
|
||||||
"push_attempts": rt.PushAttempts,
|
"twin_sends_blocked_by_reason": rt.TwinSendsBlockedByReason,
|
||||||
"push_skipped": rt.PushSkipped,
|
"push_attempts": rt.PushAttempts,
|
||||||
"push_delivered": rt.PushDelivered,
|
"push_skipped": rt.PushSkipped,
|
||||||
"invites_minted": invitesMinted,
|
"push_delivered": rt.PushDelivered,
|
||||||
"invites_used": invitesUsed,
|
"invites_minted": invitesMinted,
|
||||||
|
"invites_used": invitesUsed,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,16 @@ type RuntimeMetrics struct {
|
||||||
EscalateChecks int64
|
EscalateChecks int64
|
||||||
EscalateErrors int64
|
EscalateErrors int64
|
||||||
TwinSendsBlocked int64
|
TwinSendsBlocked int64
|
||||||
PushAttempts int64
|
// TwinSendsBlockedByReason breaks the total above down by *why* a
|
||||||
PushSkipped int64
|
// twin-authored send was blocked (peer_veto, flood_blocked,
|
||||||
PushDelivered int64
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxLatencySamples = 200
|
const maxLatencySamples = 200
|
||||||
|
|
@ -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,35 +99,41 @@ 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,
|
||||||
DraftErrorRate: errRate,
|
DraftErrorRate: errRate,
|
||||||
DraftLatencyAvgMs: avgMs,
|
DraftLatencyAvgMs: avgMs,
|
||||||
DraftLatencyMaxMs: float64(max.Milliseconds()),
|
DraftLatencyMaxMs: float64(max.Milliseconds()),
|
||||||
DraftLatencySamples: len(m.DraftLatencies),
|
DraftLatencySamples: len(m.DraftLatencies),
|
||||||
EscalateChecks: m.EscalateChecks,
|
EscalateChecks: m.EscalateChecks,
|
||||||
EscalateErrors: m.EscalateErrors,
|
EscalateErrors: m.EscalateErrors,
|
||||||
EscalateErrorRate: escErrRate,
|
EscalateErrorRate: escErrRate,
|
||||||
TwinSendsBlocked: m.TwinSendsBlocked,
|
TwinSendsBlocked: m.TwinSendsBlocked,
|
||||||
PushAttempts: m.PushAttempts,
|
TwinSendsBlockedByReason: byReason,
|
||||||
PushSkipped: m.PushSkipped,
|
PushAttempts: m.PushAttempts,
|
||||||
PushDelivered: m.PushDelivered,
|
PushSkipped: m.PushSkipped,
|
||||||
|
PushDelivered: m.PushDelivered,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ginHMetrics struct {
|
type ginHMetrics struct {
|
||||||
DraftRequests int64
|
DraftRequests int64
|
||||||
DraftErrors int64
|
DraftErrors int64
|
||||||
DraftErrorRate float64
|
DraftErrorRate float64
|
||||||
DraftLatencyAvgMs float64
|
DraftLatencyAvgMs float64
|
||||||
DraftLatencyMaxMs float64
|
DraftLatencyMaxMs float64
|
||||||
DraftLatencySamples int
|
DraftLatencySamples int
|
||||||
EscalateChecks int64
|
EscalateChecks int64
|
||||||
EscalateErrors int64
|
EscalateErrors int64
|
||||||
EscalateErrorRate float64
|
EscalateErrorRate float64
|
||||||
TwinSendsBlocked int64
|
TwinSendsBlocked int64
|
||||||
PushAttempts int64
|
TwinSendsBlockedByReason map[string]int64
|
||||||
PushSkipped int64
|
PushAttempts int64
|
||||||
PushDelivered int64
|
PushSkipped int64
|
||||||
|
PushDelivered int64
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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).
|
||||||
|
|
||||||
| # | 질문 | 결정 | 상태 |
|
| # | 질문 | 결정 | 상태 |
|
||||||
|---|------|------|------|
|
|---|------|------|------|
|
||||||
|
|
|
||||||
24
docs/PRD.md
24
docs/PRD.md
|
|
@ -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 초안을 한 탭으로 검토·수정·발송 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 확정
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
||||||
|
|
|
||||||
|
|
@ -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에 샘플만 전달
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue