스팸/도배 감지 최소 버전(2.7-C) 구현: 대화방 단위 자동 응대 일시중단 + 재개

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m
This commit is contained in:
Claude 2026-08-03 05:12:29 +00:00
parent 73dfa2c33c
commit a8c7b98922
No known key found for this signature in database
12 changed files with 464 additions and 28 deletions

View File

@ -214,6 +214,7 @@ func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
"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,

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -84,10 +84,17 @@ 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"`
TwinDisabledByFlood bool `gorm:"not null;default:false"`
CreatedAt time.Time
}

View File

@ -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의 대응 `[~]`/`[ ]`도 같이 갱신한다.

View File

@ -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**)

View File

@ -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<int> 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? ?? ''),
);

View File

@ -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<ChatScreen> createState() => _ChatScreenState();
@ -32,12 +42,17 @@ class _ChatScreenState extends State<ChatScreen> {
DraftResult? _pendingDraft;
bool _busy = false;
bool _loadingHistory = true;
late bool _floodBlocked;
late final ApiClient _api;
@override
void initState() {
super.initState();
_api = context.read<SessionState>().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<ChatScreen> {
});
_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<void> _resumeFlood() async {
final session = context.read<SessionState>();
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<ChatScreen> {
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('닫기')),
],
),

View File

@ -77,6 +77,9 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
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 <int>[] : 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<ConversationListScreen> {
conversationId: room.id,
title: _titleFor(room, me),
isGroup: room.isGroup,
twinDisabledByFlood: room.twinDisabledByFlood,
),
),
);
@ -372,8 +376,12 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
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<ConversationListScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: room.twinDisabledByPeer
color: room.twinDisabledByPeer || room.twinDisabledByFlood
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
),

View File

@ -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<void> resetFlood(int conversationId) async {
await _json('POST', '/conversations/$conversationId/flood-reset');
}
/// (roadmap.md §2.7-A): advances the caller's read marker.
Future<void> markRead(int conversationId, int messageId) async {
await _json('POST', '/conversations/$conversationId/read', body: {

View File

@ -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,