From a8c7b9892217caa62e254b98b3e057de92a3ea10 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 05:12:29 +0000 Subject: [PATCH] =?UTF-8?q?=EC=8A=A4=ED=8C=B8/=EB=8F=84=EB=B0=B0=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80=20=EC=B5=9C=EC=86=8C=20=EB=B2=84=EC=A0=84(2.?= =?UTF-8?q?7-C)=20=EA=B5=AC=ED=98=84:=20=EB=8C=80=ED=99=94=EB=B0=A9=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=EC=9E=90=EB=8F=99=20=EC=9D=91=EB=8C=80=20?= =?UTF-8?q?=EC=9D=BC=EC=8B=9C=EC=A4=91=EB=8B=A8=20+=20=EC=9E=AC=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD.md §4 엣지케이스가 "안전 관련이라 v1 최소 버전 필요"로 명시한 항목(P0)인데 관련 로직이 0건이었던 마지막 콘텐츠 갭(roadmap.md §2.7-C, deploy-checklist.md N4-C3a/b)을 채운다. Track C(콘텐츠 갭) A/B/C 전체 완료. - core-backend/flood_detect.go(신규): floodMessageThreshold=5건 / floodWindow=2분 상수 + floodDetected()/floodReason(). 안전을 위해 반드시 있어야 하는 기술적 최소값이라 명시적으로 placeholder로 남기고 구현했다 — roadmap.md §3의 "PoC 결과가 있어야 정할 수 있는 것"(자율성 기본값 등 UX 기본값)과는 성격이 다름. 카운트는 대화방 내 sender_id != 소유자인 메시지만 집계(트윈 자동발송·소유자 본인 발송은 소유자 ID로 남으므로 자동 제외). - core-backend/main.go: POST /conversations/:id/messages 하드게이트에 peer-veto → 그룹 대화 차단 다음, 에스컬레이션 체크 이전 지점으로 추가 — 트윈 자동발송 시도가 전부 지나가는 동일한 우회 불가 지점. 이미 TwinDisabledByFlood가 켜져 있으면 재계산 없이 즉시 차단(중복 로그/쿼리 방지). 새로 임계치를 넘기면 대화방을 영구 차단하고 EscalationLog 기록 + notifyUser 푸시. - core-backend/models.go: Conversation.TwinDisabledByFlood 필드 추가. TwinDisabledByPeer(거부권, 사람의 일방적 선택 — v1엔 되돌리기 API 없음)와 달리 이건 시스템이 자동으로 취하는 조치라서 AGENTS.md "every automatic action needs post-hoc notification + one-tap undo"가 그대로 적용됨 — POST /conversations/:id/flood-reset로 되돌릴 수 있게 별도 필드로 분리. - core-backend/a1_a2_routes.go: GET /conversations 응답에 twin_disabled_by_flood 추가(twin_disabled_by_peer와 동일한 자리). - core-backend/flood_detect_test.go(신규): 임계치 경계값(정확히 N건은 통과, N+1건은 차단), 사람 발송은 게이트 안 걸림, 윈도우 밖 과거 메시지는 집계 제외, flood-reset으로 재개, 존재하지 않는 대화방 404 — 5개 테스트. - mobile/lib/models/models.dart, services/api_client.dart: 모델·API 클라이언트에 twinDisabledByFlood/resetFlood() 추가. - mobile/lib/screens/conversation_list_screen.dart: 대화 목록에 도배 차단 배지(거부권과 같은 자리, 다른 아이콘/문구). - mobile/lib/screens/chat_screen.dart: 채팅방을 열면(목록에서 넘어온 초기 상태) 또는 발송 시도가 다시 차단되면 배너에 "자동응대 재개" 버튼을 보여줌 — one-tap undo. 새 알림 메커니즘을 만들지 않고 기존 EscalationLog/ InboxScreen을 그대로 재사용. - mobile/test/models_test.dart: twin_disabled_by_flood 파싱 테스트 추가. - docs/roadmap.md §2.7-C, docs/deploy-checklist.md N4-C3a/b·Track C 요약· "바로 다음 5개"를 완료로 갱신. 테스트: go test ./... 51/51, python3 -m pytest tests/ -q 47/47, flutter analyze 클린 + flutter test 5/5 모두 통과. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m --- core-backend/a1_a2_routes.go | 13 +- core-backend/flood_detect.go | 57 +++++ core-backend/flood_detect_test.go | 204 ++++++++++++++++++ core-backend/main.go | 66 +++++- core-backend/models.go | 15 +- docs/deploy-checklist.md | 18 +- docs/roadmap.md | 26 ++- mobile/lib/models/models.dart | 5 + mobile/lib/screens/chat_screen.dart | 50 ++++- .../lib/screens/conversation_list_screen.dart | 14 +- mobile/lib/services/api_client.dart | 7 + mobile/test/models_test.dart | 17 ++ 12 files changed, 464 insertions(+), 28 deletions(-) create mode 100644 core-backend/flood_detect.go create mode 100644 core-backend/flood_detect_test.go diff --git a/core-backend/a1_a2_routes.go b/core-backend/a1_a2_routes.go index 8600b92..b34d05b 100644 --- a/core-backend/a1_a2_routes.go +++ b/core-backend/a1_a2_routes.go @@ -211,12 +211,13 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) { unreadQuery.Count(&unreadCount) out = append(out, gin.H{ - "id": conv.ID, - "is_group": conv.IsGroup, - "twin_disabled_by_peer": conv.TwinDisabledByPeer, - "user_ids": participantIDs, - "created_at": conv.CreatedAt, - "unread_count": unreadCount, + "id": conv.ID, + "is_group": conv.IsGroup, + "twin_disabled_by_peer": conv.TwinDisabledByPeer, + "twin_disabled_by_flood": conv.TwinDisabledByFlood, + "user_ids": participantIDs, + "created_at": conv.CreatedAt, + "unread_count": unreadCount, }) } c.JSON(http.StatusOK, gin.H{"conversations": out}) diff --git a/core-backend/flood_detect.go b/core-backend/flood_detect.go new file mode 100644 index 0000000..dadcfa5 --- /dev/null +++ b/core-backend/flood_detect.go @@ -0,0 +1,57 @@ +package main + +import ( + "fmt" + "time" + + "gorm.io/gorm" +) + +// Flood/spam detection minimum version (roadmap.md §2.7-C, PRD.md §4 +// 엣지케이스: "상대가 짧은 시간에 메시지 도배 -> 스팸 감지 임계치 초과 시 응대 +// 중단, 사용자에게 보고 (P1이지만 안전 관련이라 v1 최소 버전 필요)"). +// +// These two constants are a conservative, clearly-labeled v1-minimum +// placeholder, NOT a PoC-validated number -- unlike the autonomy-default/ +// whitelist-default questions in roadmap.md §3 ("PoC 결과가 있어야 정할 수 +// 있는 것"), this is a technical safety minimum that has to exist for P0 +// coverage, so it's fine to ship a placeholder and revisit with real usage +// data later rather than leaving the gate unbuilt. +const ( + // floodMessageThreshold is how many incoming messages from the + // counterpart within floodWindow count as "도배". + floodMessageThreshold = 5 + // floodWindow is the trailing window floodMessageThreshold is counted over. + floodWindow = 2 * time.Minute +) + +// floodIncomingCount counts messages in conversationID sent by anyone other +// than ownerUserID (i.e. the counterpart's own words, not the twin's +// auto-sends nor the owner's own human-typed messages, both of which use +// SenderID == ownerUserID -- see sendMessageRequest.SenderID) within the +// trailing floodWindow. Retracted messages still count: retraction undoes an +// auto-send the twin/owner made, it says nothing about whether the peer was +// flooding. +func floodIncomingCount(db *gorm.DB, conversationID, ownerUserID uint) int64 { + var count int64 + db.Model(&Message{}). + Where("conversation_id = ? AND sender_id != ? AND created_at >= ?", + conversationID, ownerUserID, time.Now().Add(-floodWindow)). + Count(&count) + return count +} + +// floodDetected reports whether the counterpart has crossed +// floodMessageThreshold within floodWindow for this conversation, plus the +// count for the resulting EscalationLog reason string. +func floodDetected(db *gorm.DB, conversationID, ownerUserID uint) (bool, int64) { + count := floodIncomingCount(db, conversationID, ownerUserID) + return count > floodMessageThreshold, count +} + +// floodReason formats the Korean EscalationLog copy for a flood auto-pause, +// matching the tone of the existing escalation_filter reasons. +func floodReason(count int64) string { + return fmt.Sprintf("도배 감지: 최근 %d분 동안 상대로부터 메시지 %d건 수신 (임계치 %d건) -- 자동응대 일시중단", + int(floodWindow/time.Minute), count, floodMessageThreshold) +} diff --git a/core-backend/flood_detect_test.go b/core-backend/flood_detect_test.go new file mode 100644 index 0000000..7c20fb7 --- /dev/null +++ b/core-backend/flood_detect_test.go @@ -0,0 +1,204 @@ +package main + +import ( + "net/http" + "strconv" + "testing" + "time" + + "gorm.io/gorm" +) + +// seedIncomingMessages inserts n messages from a counterpart (any sender_id +// != ownerID) into conv, timestamped now, simulating a peer flooding the +// conversation. Uses db.Create directly (like the peer-veto tests above) +// rather than going through POST /messages, since that endpoint's gating +// only applies to sender_mode=twin and these are meant to be plain incoming +// human messages from the other party. +func seedIncomingMessages(t *testing.T, db *gorm.DB, conv Conversation, ownerID uint, n int) { + t.Helper() + peerID := ownerID + 1000 // any id distinct from the owner + for i := 0; i < n; i++ { + db.Create(&Message{ + ConversationID: conv.ID, + SenderID: peerID, + SenderMode: SenderHuman, + Text: "spam " + strconv.Itoa(i), + }) + } +} + +func TestFloodDetectionBlocksTwinAutoSendAfterThresholdExceeded(t *testing.T) { + server, db := setupTestServer(t) + + senderID, token := mustSignup(t, server.URL, "도배테스트") + setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2) + db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "ㅇㅇ"}) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + + // Below threshold: the counterpart sending floodMessageThreshold messages + // exactly must NOT trip the gate yet (strictly greater-than semantics). + seedIncomingMessages(t, db, conv, senderID, floodMessageThreshold) + resp := postJSON(t, convBase+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "ㅇㅇ 알겠어", SenderMode: SenderTwin, Approved: true, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 at exactly the threshold (not yet exceeded), got %d", resp.StatusCode) + } + + // One more incoming message tips it over the threshold. + seedIncomingMessages(t, db, conv, senderID, 1) + blocked := postJSON(t, convBase+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "ㅇㅇ 또 왔어", SenderMode: SenderTwin, Approved: true, + }) + if blocked.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 once flood threshold is exceeded, got %d", blocked.StatusCode) + } + + var conv2 Conversation + if err := db.First(&conv2, conv.ID).Error; err != nil { + t.Fatalf("reload conversation: %v", err) + } + if !conv2.TwinDisabledByFlood { + t.Fatal("expected twin_disabled_by_flood to be set after flood detection") + } + + var logs []EscalationLog + db.Where("conversation_id = ?", conv.ID).Find(&logs) + if len(logs) != 1 { + t.Fatalf("expected exactly one EscalationLog row for the flood block, got %d", len(logs)) + } + + // The gate must stay closed on a subsequent attempt without re-counting + // (also prevents duplicate EscalationLog rows piling up per attempt). + again := postJSON(t, convBase+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "ㅇㅇ 세번째", SenderMode: SenderTwin, Approved: true, + }) + if again.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 to persist once flood-blocked, got %d", again.StatusCode) + } + db.Where("conversation_id = ?", conv.ID).Find(&logs) + if len(logs) != 1 { + t.Fatalf("expected the flood EscalationLog to stay at 1 row, got %d", len(logs)) + } +} + +func TestFloodDetectionDoesNotBlockHumanMessages(t *testing.T) { + server, db := setupTestServer(t) + + senderID, _ := mustSignup(t, server.URL, "도배사람") + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + + seedIncomingMessages(t, db, conv, senderID, floodMessageThreshold+5) + + // The owner's own human-authored message is never gated, flood or + // otherwise -- it's their own words. + resp := postJSON(t, convBase+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "괜찮아?", SenderMode: SenderHuman, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("flood detection must not block human-authored messages, got %d", resp.StatusCode) + } +} + +func TestFloodDetectionOnlyCountsMessagesWithinWindow(t *testing.T) { + server, db := setupTestServer(t) + + senderID, token := mustSignup(t, server.URL, "옛도배") + setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + + // Stale messages from outside floodWindow must not count toward the + // threshold, even though there are plenty of them. + peerID := senderID + 1000 + stale := time.Now().Add(-floodWindow * 3) + for i := 0; i < floodMessageThreshold+10; i++ { + db.Create(&Message{ + ConversationID: conv.ID, + SenderID: peerID, + SenderMode: SenderHuman, + Text: "old spam", + CreatedAt: stale, + }) + } + + resp := postJSON(t, convBase+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "ㅇㅇ 알겠어", SenderMode: SenderTwin, Approved: true, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("stale messages outside floodWindow must not trigger the gate, got %d", resp.StatusCode) + } +} + +func TestFloodResetReenablesTwinAutoSend(t *testing.T) { + server, db := setupTestServer(t) + + senderID, token := mustSignup(t, server.URL, "재개테스트") + setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1) + + conv := Conversation{IsGroup: false} + if err := db.Create(&conv).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + convBase := server.URL + "/conversations/" + strconv.FormatUint(uint64(conv.ID), 10) + + seedIncomingMessages(t, db, conv, senderID, floodMessageThreshold+1) + blocked := postJSON(t, convBase+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "ㅇㅇ", SenderMode: SenderTwin, Approved: true, + }) + if blocked.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 once flooded, got %d", blocked.StatusCode) + } + + // One-tap undo (AGENTS.md "every automatic action needs post-hoc + // notification + one-tap undo") -- unlike peer veto, this auto-pause + // must be reversible. + resetResp := postJSON(t, convBase+"/flood-reset", nil) + if resetResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 resetting flood block, got %d", resetResp.StatusCode) + } + + var conv2 Conversation + if err := db.First(&conv2, conv.ID).Error; err != nil { + t.Fatalf("reload conversation: %v", err) + } + if conv2.TwinDisabledByFlood { + t.Fatal("expected twin_disabled_by_flood to be cleared after flood-reset") + } + + // The flood messages that tripped the gate are still inside floodWindow + // right after reset, so without clearing them a resend would immediately + // re-trip the same detection (correct fail-safe behavior, but not what + // this test is checking) -- simulate the message rate having actually + // dropped, which is the case flood-reset is meant for. + db.Where("conversation_id = ?", conv.ID).Delete(&Message{}) + + resent := postJSON(t, convBase+"/messages", sendMessageRequest{ + SenderID: senderID, Text: "ㅇㅇ 다시", SenderMode: SenderTwin, Approved: true, + }) + if resent.StatusCode != http.StatusOK { + t.Fatalf("expected 200 after flood-reset re-enabled auto-send, got %d", resent.StatusCode) + } +} + +func TestFloodResetMissingConversation(t *testing.T) { + server, _ := setupTestServer(t) + resp := postJSON(t, server.URL+"/conversations/9999/flood-reset", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} diff --git a/core-backend/main.go b/core-backend/main.go index ae3c49b..8153d94 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -257,8 +257,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi // words and are never gated. On any doubt (AI service unreachable // or erroring) we fail closed and block the send. Peer veto is // checked first (it's a total kill switch for this conversation, - // independent of content), then escalation, then autonomy level -- - // none of the later checks can override an earlier block. + // independent of content), then the group-chat block, then flood + // detection, then escalation, then autonomy level -- none of the + // later checks can override an earlier block. if req.SenderMode == SenderTwin { if conversation.TwinDisabledByPeer { runtimeMetrics.recordTwinBlocked() @@ -276,6 +277,41 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi return } + // 스팸/도배 감지 최소 버전(PRD.md §4, roadmap.md §2.7-C): 이미 + // 도배로 중단된 대화방이면 재검사 없이 바로 막는다 (중복 + // EscalationLog 방지 + 매 시도마다 카운트 쿼리를 다시 돌리지 + // 않기 위함). 아직 중단되지 않았다면 이번 전송을 계기로 트리거를 + // 검사한다 -- 이 게이트는 에스컬레이션 하드게이트와 같은 우회 + // 불가 지점에 있어, 어떤 자율성 레벨/화이트리스트로도 건너뛸 수 + // 없다. + if conversation.TwinDisabledByFlood { + runtimeMetrics.recordTwinBlocked() + 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() + reason := floodReason(count) + db.Create(&EscalationLog{ + UserID: req.SenderID, + ConversationID: convID, + Reason: reason, + MessageSnippet: req.Text, + }) + // Automatic action -> post-hoc notification (AGENTS.md + // absolute safety invariants), same as the escalation gate + // below. Best-effort push (no-op without FCM_SERVER_KEY / + // real tokens). + _, _, _ = notifyUser(db, req.SenderID, "와카뷰 자동응대 일시중단", reason, map[string]string{ + "type": "flood", + "conversation_id": strconv.FormatUint(uint64(convID), 10), + }) + c.JSON(http.StatusForbidden, gin.H{"detail": "도배 감지로 자동 발송이 일시중단되었습니다", "reason": reason}) + return + } + result, err := ai.checkEscalation(req.Text) runtimeMetrics.recordEscalate(err) if err != nil { @@ -467,6 +503,32 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi c.JSON(http.StatusOK, gin.H{"conversation_id": convID, "twin_disabled_by_peer": true}) }) + // 도배 감지로 자동 발송이 일시중단된 대화방을 다시 켠다 (roadmap.md + // §2.7-C, AGENTS.md "every automatic action needs post-hoc notification + + // one-tap undo"). 거부권(veto)과 달리 이 중단은 사람의 결정이 아니라 + // 시스템이 자동으로 취한 조치라서, 되돌리기 경로가 반드시 있어야 한다 -- + // 거부권처럼 영구적으로 막아두지 않는다. + r.POST("/conversations/:id/flood-reset", func(c *gin.Context) { + convID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var conversation Conversation + if err := db.First(&conversation, convID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "conversation not found"}) + return + } + + conversation.TwinDisabledByFlood = false + if err := db.Save(&conversation).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"conversation_id": convID, "twin_disabled_by_flood": false}) + }) + r.PATCH("/users/:id/twin-settings", func(c *gin.Context) { userID, ok := parseUintParam(c, "id") if !ok { diff --git a/core-backend/models.go b/core-backend/models.go index d63b462..56319fa 100644 --- a/core-backend/models.go +++ b/core-backend/models.go @@ -84,11 +84,18 @@ type Contact struct { // it lives here rather than on Contact. Set by POST /conversations/:id/veto // when the counterpart asks to talk to the human only; checked before any // twin auto-send in that conversation (main.go). +// TwinDisabledByFlood is the flood/spam auto-pause flag (roadmap.md §2.7-C, +// PRD.md §4 "스팸 감지 임계치 초과 시 응대 중단"). Unlike TwinDisabledByPeer +// (a deliberate one-way human/peer choice, no un-veto endpoint by design), +// this is a fully automatic system action, so AGENTS.md's "every automatic +// action needs post-hoc notification + one-tap undo" applies -- it's +// reversible via POST /conversations/:id/flood-reset, unlike peer veto. type Conversation struct { - ID uint `gorm:"primaryKey"` - IsGroup bool `gorm:"not null;default:false"` - TwinDisabledByPeer bool `gorm:"not null;default:false"` - CreatedAt time.Time + ID uint `gorm:"primaryKey"` + IsGroup bool `gorm:"not null;default:false"` + TwinDisabledByPeer bool `gorm:"not null;default:false"` + TwinDisabledByFlood bool `gorm:"not null;default:false"` + CreatedAt time.Time } type ConversationParticipant struct { diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index ea766a3..2a3f24f 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -45,7 +45,13 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다. 그룹 생성 UI, 안 본 동안 요약(`GET /conversations/:id/summary`), 읽음 마커, 안 본 배지, 그룹 트윈 발송 서버측 차단까지. C2(관계별 페르소나)도 **완료** (2026-08-03, 아직 GitHub `main`에만 있고 프로덕션 미배포) — `relationship_tier` - 전역 기본값+연락처별 오버라이드, 초안 생성 톤 프롬프트 분기. 다음은 C3(스팸/도배 감지). + 전역 기본값+연락처별 오버라이드, 초안 생성 톤 프롬프트 분기. C3(스팸/도배 감지)도 + **완료** (2026-08-03, 아직 GitHub `main`에만 있고 프로덕션 미배포) — + `core-backend/flood_detect.go`(`floodMessageThreshold=5`건/`floodWindow=2분`, + 안전 최소값 placeholder), `POST /conversations/:id/messages` 하드게이트에 peer-veto· + 그룹차단 다음 순서로 추가, `TwinDisabledByFlood` 대화방 플래그 + `POST + /conversations/:id/flood-reset` one-tap undo, 기존 `EscalationLog`/`InboxScreen` + 재사용 + 대화 목록/채팅방 배너에 상태·재개 버튼 노출. **Track C 콘텐츠 갭 전체(A/B/C) 완료.** Master 액션(FCM 시크릿, 실기기 탭, 웹 재배포)과 별개로 계속 진행 가능 - 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음 @@ -193,8 +199,8 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | **N4-C2b** | 온보딩 관계 티어 선택 스텝 | **done** (2026-08-03) | `onboarding_tone_screen.dart`, 말투 샘플 다음에 전역 기본값 선택 | | **N4-C2c** | 연락처별 관계 티어 오버라이드 | **done** (2026-08-03) | `contacts_screen.dart` `_RelationshipTierPicker`, 자율성 설정 화면에 전역 기본값 변경 UI | | **N4-C2d** | 초안 생성 시 티어별 톤 프롬프트 분기 | **done** (2026-08-03) | `ai-service/app/generation.py` `RELATIONSHIP_TIER_INSTRUCTIONS` + `core-backend/persona.go` `resolveRelationshipTier()`(연락처 오버라이드 → 전역 기본값 → `formal`) | -| **N4-C3a** | 짧은 시간 내 동일 상대 도배 감지 → 응대 일시중단 | todo | 에스컬레이션 하드게이트와 동일 위치(우회 불가) | -| **N4-C3b** | 도배 중단 시 사후 알림 | todo | 기존 `EscalationLog`/`InboxScreen` 재사용 | +| **N4-C3a** | 짧은 시간 내 동일 상대 도배 감지 → 응대 일시중단 | **done** (2026-08-03) | `core-backend/flood_detect.go`(`floodMessageThreshold=5`건/`floodWindow=2분`, 안전 최소값 placeholder) + `main.go` `POST /conversations/:id/messages`의 peer-veto·그룹차단 다음, 에스컬레이션 이전 지점(우회 불가). `Conversation.TwinDisabledByFlood`로 대화방 단위 차단, `POST /conversations/:id/flood-reset`로 재개(거부권과 달리 되돌리기 가능) | +| **N4-C3b** | 도배 중단 시 사후 알림 | **done** (2026-08-03) | 기존 `EscalationLog`/`InboxScreen` 그대로 재사용(신규 알림 경로 없음). 대화 목록·채팅방 배너에 상태 표시 + "자동응대 재개" one-tap undo 버튼 추가 | ### FCM — [`fcm-setup.md`](./fcm-setup.md) @@ -254,8 +260,10 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P 3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드) 4. ~~N2-B8~B12 컷오버~~ **done** (`msn.iykyka.com` 라이브) 5. ~~N3 안정화 + Track A/B~~ **done**, ~~Track C1 단톡 따라잡기~~ **done** (2026-08-03), - ~~Track C2 관계별 페르소나~~ **done** (2026-08-03) — 다음: **Track C3 스팸/도배 감지** - 병행하며 **Master FCM 시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA (N4-5~10) + ~~Track C2 관계별 페르소나~~ **done** (2026-08-03), ~~Track C3 스팸/도배 감지~~ **done** + (2026-08-03) — **Track C 콘텐츠 갭 전체 완료.** 남은 것: **Master FCM 시크릿(N4-1/3)** + → N4-4 스모크 → Android UI QA (N4-5~10), Track C 프로덕션 재배포(C2/C3는 아직 GitHub + `main`에만 있음) 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. diff --git a/docs/roadmap.md b/docs/roadmap.md index 7f3f4dc..aba90cf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -168,10 +168,23 @@ 선택적 인증 처리 — 토큰 없는 기존 호출도 그대로 동작(티어 해석만 스킵) **2.7-C 스팸/도배 감지 최소 버전** (`PRD.md` §4 엣지케이스: "안전 관련이라 v1 최소 버전 필요"로 -명시 — 현재 코드 전체에 관련 로직 0건) -- [ ] 짧은 시간 내 동일 상대 메시지 N건 초과 시 자동응대 일시중단 — 에스컬레이션 하드게이트와 - 동일 위치(우회 불가 지점)에 추가. `ai-service/app/escalation_filter.py` 또는 신규 규칙 -- [ ] 중단 시 사후 알림 — 기존 `EscalationLog`/`InboxScreen` 스키마·UI 재사용 +명시 — **완료** 2026-08-03) +- [x] 짧은 시간 내 동일 상대 메시지 N건 초과 시 자동응대 일시중단 — `core-backend/flood_detect.go` + (`floodMessageThreshold = 5`건 / `floodWindow = 2분`, **PoC 검증값이 아니라 안전 최소값으로 + 명시한 v1 placeholder** — §3 "PoC 결과가 있어야 정할 수 있는 것"과는 성격이 다른, 반드시 + 있어야 하는 기술적 안전장치라 임시값으로 우선 구현). `main.go`의 `POST + /conversations/:id/messages`에서 peer-veto → 그룹 대화 차단 다음, 에스컬레이션 하드게이트 + 이전 지점에 추가 — 트윈 발송 시도 전체가 지나가는 동일 우회 불가 지점. 카운트는 대화방 내 + `sender_id != 소유자`인 메시지(=상대가 보낸 것, 트윈 자동발송·소유자 본인 발송 모두 제외)만 + 집계. `Conversation.TwinDisabledByFlood`로 대화방 단위 영구 차단(거부권과 동일 저장 패턴) — + 단, 거부권과 달리 **사람의 선택이 아닌 시스템의 자동 조치**라 되돌릴 수 있어야 해서(AGENTS.md + "every automatic action needs post-hoc notification + one-tap undo") `POST + /conversations/:id/flood-reset`로 재개 가능(거부권은 v1에서 되돌리기 API 없음, 의도적으로 다름) +- [x] 중단 시 사후 알림 — 기존 `EscalationLog`/`InboxScreen` 스키마·UI 그대로 재사용(신규 알림 + 경로 없음). 대화 목록(`conversation_list_screen.dart`)에도 `twin_disabled_by_flood` 배지 추가 + (거부권 배지와 같은 자리, 다른 아이콘). 재개(one-tap undo)는 `chat_screen.dart`의 배너에 + "자동응대 재개" 버튼으로 노출 — 채팅방을 열면(목록에서 넘어온 초기 상태) 또는 발송 시도가 + 다시 차단되면 즉시 뜬다 **스코프 밖 (제안 아님, 참고용)**: 이미지/파일 전송·읽음표시·타이핑 인디케이터 등 "일반 메신저" 테이블스테이크 기능은 `PRD.md`에 명시되지 않음 — 콘텐츠 공백의 또 다른 후보일 수 있으나 이건 @@ -207,8 +220,9 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다. §3(사람 PoC) 이후** - 5-2. [x] 화이트리스트 규칙 CRUD API 6. [x] Phase 1 C 베타 직전 — Q1~Q7 확정, 초대 운영, 프로토타입 앵커, Android 릴리즈 경로 - - 6-1. [ ] 2.7 콘텐츠 갭 — PRD P0 대비 미구현 기능 (**우선순위: A 단톡 따라잡기 → B 관계별 - 페르소나 → C 스팸 감지**, 2026-07-31 발견). §3 사람 PoC보다 먼저 끝내야 함 — PRD가 요구하는 + - 6-1. [x] 2.7 콘텐츠 갭 — PRD P0 대비 미구현 기능 (**우선순위: A 단톡 따라잡기 → B 관계별 + 페르소나 → C 스팸 감지**, 2026-07-31 발견 — A/B/C 모두 완료 2026-08-03). §3 사람 PoC보다 + 먼저 끝내야 함 — PRD가 요구하는 v1 P0 범위이므로 §4 순서상 D(사람 PoC) 앞 7. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 실제 베타 오픈 (**맨 마지막 / D**) diff --git a/mobile/lib/models/models.dart b/mobile/lib/models/models.dart index f69471f..565503a 100644 --- a/mobile/lib/models/models.dart +++ b/mobile/lib/models/models.dart @@ -55,6 +55,7 @@ class ConversationSummary { required this.isGroup, required this.userIds, required this.twinDisabledByPeer, + this.twinDisabledByFlood = false, this.unreadCount = 0, this.createdAt, }); @@ -63,6 +64,9 @@ class ConversationSummary { final bool isGroup; final List userIds; final bool twinDisabledByPeer; + // roadmap.md §2.7-C 스팸/도배 감지: 자동으로 켜지지만(사람의 선택이 아님) + // twinDisabledByPeer와 달리 되돌릴 수 있음 (POST /conversations/:id/flood-reset). + final bool twinDisabledByFlood; final int unreadCount; final DateTime? createdAt; @@ -73,6 +77,7 @@ class ConversationSummary { isGroup: json['is_group'] as bool? ?? false, userIds: rawIds.map((e) => e as int).toList(), twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false, + twinDisabledByFlood: json['twin_disabled_by_flood'] as bool? ?? false, unreadCount: json['unread_count'] as int? ?? 0, createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''), ); diff --git a/mobile/lib/screens/chat_screen.dart b/mobile/lib/screens/chat_screen.dart index dc9e3d8..70c904b 100644 --- a/mobile/lib/screens/chat_screen.dart +++ b/mobile/lib/screens/chat_screen.dart @@ -11,11 +11,21 @@ import '../theme/app_theme.dart'; import '../widgets/message_bubble.dart'; class ChatScreen extends StatefulWidget { - const ChatScreen({super.key, required this.conversationId, this.title, this.isGroup = false}); + const ChatScreen({ + super.key, + required this.conversationId, + this.title, + this.isGroup = false, + this.twinDisabledByFlood = false, + }); final int conversationId; final String? title; final bool isGroup; + // roadmap.md §2.7-C 도배 감지: 목록 화면이 이미 알고 있는 초기 상태를 넘겨 + // 받아, 채팅방을 열자마자 (실패한 발송을 기다리지 않고) 배너 + 재개 버튼을 + // 보여줄 수 있게 한다. + final bool twinDisabledByFlood; @override State createState() => _ChatScreenState(); @@ -32,12 +42,17 @@ class _ChatScreenState extends State { DraftResult? _pendingDraft; bool _busy = false; bool _loadingHistory = true; + late bool _floodBlocked; late final ApiClient _api; @override void initState() { super.initState(); _api = context.read().api; + _floodBlocked = widget.twinDisabledByFlood; + if (_floodBlocked) { + _banner = '도배 감지로 이 대화방의 와카뷰 자동응대가 일시중단되었습니다.'; + } _socket = ConversationSocket(widget.conversationId)..connect(); _sub = _socket!.events.listen(_onEvent); _loadHistory(); @@ -231,12 +246,39 @@ class _ChatScreenState extends State { }); _scrollToEnd(); } on ApiException catch (e) { - setState(() => _banner = '와카뷰 발송 차단 (${e.statusCode}): ${e.body}'); + setState(() { + _banner = '와카뷰 발송 차단 (${e.statusCode}): ${e.body}'; + // roadmap.md §2.7-C: 이 화면을 열어둔 채로 있다가 도배 임계치를 + // 새로 넘긴 경우, 목록에서 넘겨받은 초기 상태와 무관하게 지금 + // 바로 재개 버튼을 보여줘야 한다. + if (e.body.contains('도배')) _floodBlocked = true; + }); } finally { setState(() => _busy = false); } } + /// 도배 감지로 자동 중단된 자동응대를 다시 켠다 (roadmap.md §2.7-C, + /// AGENTS.md "every automatic action needs post-hoc notification + + /// one-tap undo"). 거부권(veto)과 달리 이 중단은 시스템이 자동으로 취한 + /// 조치라서 되돌리기 경로가 있어야 한다. + Future _resumeFlood() async { + final session = context.read(); + setState(() => _busy = true); + try { + await session.api.resetFlood(widget.conversationId); + if (!mounted) return; + setState(() { + _floodBlocked = false; + _banner = '와카뷰 자동응대를 다시 켰습니다.'; + }); + } on ApiException catch (e) { + setState(() => _banner = '재개 실패 (${e.statusCode})'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + void _rejectDraft() { setState(() { _pendingDraft = null; @@ -463,6 +505,10 @@ class _ChatScreenState extends State { leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant), content: Text(_banner!), actions: [ + // 도배 감지 자동중단의 one-tap undo (AGENTS.md 안전 불변식) — + // 거부권과 달리 되돌릴 수 있으므로 여기서 바로 재개 가능. + if (_floodBlocked) + TextButton(onPressed: _busy ? null : _resumeFlood, child: const Text('자동응대 재개')), TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')), ], ), diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart index e8a367b..fe3a0d2 100644 --- a/mobile/lib/screens/conversation_list_screen.dart +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -77,6 +77,9 @@ class _ConversationListScreenState extends State { String _subtitleFor(ConversationSummary room, int? me) { if (room.twinDisabledByPeer) return '상대가 와카뷰를 거부함'; + // roadmap.md §2.7-C 도배 감지 — 사후 알림 함(InboxScreen)에서도 보이지만 + // 대화 목록에서 바로 상태를 알 수 있어야 한다. + if (room.twinDisabledByFlood) return '도배 감지로 자동응대 일시중단 (사후 알림에서 재개)'; final peers = me == null ? const [] : room.userIds.where((id) => id != me).toList(); final peerPart = peers.isEmpty ? '참가자 없음' : '상대 ID ${peers.first}'; return '$peerPart · 방 #${room.id}'; @@ -342,6 +345,7 @@ class _ConversationListScreenState extends State { conversationId: room.id, title: _titleFor(room, me), isGroup: room.isGroup, + twinDisabledByFlood: room.twinDisabledByFlood, ), ), ); @@ -372,8 +376,12 @@ class _ConversationListScreenState extends State { const SizedBox(height: 2), Row( children: [ - if (room.twinDisabledByPeer) ...[ - Icon(Icons.block, size: 13, color: theme.colorScheme.error), + if (room.twinDisabledByPeer || room.twinDisabledByFlood) ...[ + Icon( + room.twinDisabledByFlood ? Icons.pause_circle_outline : Icons.block, + size: 13, + color: theme.colorScheme.error, + ), const SizedBox(width: 4), ], Expanded( @@ -382,7 +390,7 @@ class _ConversationListScreenState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( - color: room.twinDisabledByPeer + color: room.twinDisabledByPeer || room.twinDisabledByFlood ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant, ), diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart index 3d3ca1f..9a2f9a6 100644 --- a/mobile/lib/services/api_client.dart +++ b/mobile/lib/services/api_client.dart @@ -189,6 +189,13 @@ class ApiClient { await _json('POST', '/conversations/$conversationId/veto'); } + /// 도배 감지로 자동 중단된 대화방을 다시 켠다 (roadmap.md §2.7-C, AGENTS.md + /// "every automatic action needs post-hoc notification + one-tap undo"). + /// 거부권(veto)과 달리 되돌릴 수 있다. + Future resetFlood(int conversationId) async { + await _json('POST', '/conversations/$conversationId/flood-reset'); + } + /// 단톡 따라잡기(roadmap.md §2.7-A): advances the caller's read marker. Future markRead(int conversationId, int messageId) async { await _json('POST', '/conversations/$conversationId/read', body: { diff --git a/mobile/test/models_test.dart b/mobile/test/models_test.dart index 4ab0470..ffb9f6b 100644 --- a/mobile/test/models_test.dart +++ b/mobile/test/models_test.dart @@ -13,9 +13,26 @@ void main() { expect(c.id, 3); expect(c.userIds, [1, 2]); expect(c.twinDisabledByPeer, isTrue); + expect(c.twinDisabledByFlood, isFalse); expect(c.titleFor(1), '대화 · 상대 #2'); }); + // roadmap.md §2.7-C 스팸/도배 감지: twin_disabled_by_flood parses + // independently of twin_disabled_by_peer and defaults to false when absent + // (covered by the test above). + test('ConversationSummary parses twin_disabled_by_flood', () { + final c = ConversationSummary.fromJson({ + 'id': 4, + 'is_group': false, + 'twin_disabled_by_peer': false, + 'twin_disabled_by_flood': true, + 'user_ids': [1, 2], + 'created_at': '2026-07-30T00:00:00Z', + }); + expect(c.twinDisabledByPeer, isFalse); + expect(c.twinDisabledByFlood, isTrue); + }); + test('EscalationLogEntry and Contact parse', () { final log = EscalationLogEntry.fromJson({ 'id': 9,