스팸/도배 감지 최소 버전(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:
parent
73dfa2c33c
commit
a8c7b98922
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
||||
|
|
|
|||
|
|
@ -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**)
|
||||
|
||||
|
|
|
|||
|
|
@ -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? ?? ''),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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('닫기')),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue