화이트리스트 규칙 CRUD API + 메시지 되돌리기(undo) API 추가

화이트리스트 CRUD:
- POST/GET /users/:id/whitelist-rules, DELETE /users/:id/whitelist-rules/:ruleId
- Flutter의 자율성 설정 화면(roadmap.md 2.3)이 바로 붙여 쓸 수 있게
  준비. contact_id는 저장되지만 매칭 로직(whitelistMatches)은 아직
  전역 키워드만 봄 -- 대화방-연락처 연결 모델링 필요(기존에 문서화된
  한계, 그대로 유지)

되돌리기(one-tap undo, AGENTS.md 안전 불변식):
- Message.Retracted 필드 추가
- POST /messages/:id/retract -- 트윈이 자동발송한 메시지만 대상(사람이
  쓴 메시지는 400), 이미 되돌린 건 409
- 성공 시 같은 대화방 WebSocket에 {"type":"retraction", "id":...}
  브로드캐스트. 일반 메시지 브로드캐스트도 {"type":"message"}를 붙여서
  클라이언트가 두 이벤트를 구분할 수 있게 함

되돌리기 버튼/사후알림 UI 자체는 여전히 Flutter 쪽 몫으로 남아있음
This commit is contained in:
Claude 2026-07-30 03:01:06 +00:00
parent f79108b9cd
commit a741001197
No known key found for this signature in database
5 changed files with 309 additions and 12 deletions

View File

@ -32,8 +32,9 @@ go test ./... -v
`main_test.go`가 초대 코드 발급/검증(400·409 포함)/메시지 저장/존재하지 않는 대화방(404)/WebSocket
브로드캐스트/초안 생성 프록시/에스컬레이션 하드게이트(차단·통과·게이트 불능 시 fail-safe)/자율성
플로우(L0 차단·L1 승인·L2 화이트리스트 자동발송·L2 비대상 승인 필요·에스컬레이션의 레벨 무관 우선)/
거부권(peer veto, 다른 모든 조건보다 우선)/계정 삭제/운영 지표까지 전부 mock AI 서비스로 실제로
돌려서 확인한다 (`../backend/`의 Python TestClient 테스트와 동일한 케이스 + Go에서 새로 추가된 것들).
거부권(peer veto, 다른 모든 조건보다 우선)/화이트리스트 규칙 CRUD/메시지 되돌리기(twin 전용, WebSocket
브로드캐스트 포함)/계정 삭제/운영 지표까지 전부 mock AI 서비스로 실제로 돌려서 확인한다
(`../backend/`의 Python TestClient 테스트와 동일한 케이스 + Go에서 새로 추가된 것들).
## 지금 있는 것
@ -47,6 +48,11 @@ go test ./... -v
- `POST /conversations/{id}/veto` — 거부권 발동: 이 대화방의 `twin_disabled_by_peer`를 켠다.
이후 이 대화방에서는 어떤 자율성 레벨·화이트리스트·승인 여부와도 무관하게 트윈 자동발송이
전부 차단된다 (아래 2.4). v1은 한 방향(끄기만 가능, 되돌리는 API 없음)
- `POST /messages/{id}/retract` — 되돌리기(one-tap undo, PRD.md §3.1): **트윈이 자동발송한
메시지만** 대상. 사람이 직접 쓴 메시지는 400. 이미 되돌린 메시지를 다시 호출하면 409. 성공하면
`Message.Retracted`를 켜고 같은 대화방의 WebSocket 연결에 `{"type": "retraction", "id": ...}`
브로드캐스트한다 (아래 참고: 일반 메시지 브로드캐스트도 이제 `"type": "message"`를 포함해서
클라이언트가 두 이벤트를 구분할 수 있게 함)
- `GET /ws/conversations/{id}` (WebSocket 업그레이드) — 대화방별 실시간 릴레이 (인메모리 커넥션 매니저)
- `DELETE /users/{id}` — 계정 삭제("초기화"): 해당 유저가 걸린 모든 행(트윈 설정·화이트리스트·
연락처·대화 참여·메시지·에스컬레이션 로그·유저 본인)을 트랜잭션으로 삭제하고, 그 유저가 쓴
@ -79,12 +85,16 @@ go test ./... -v
필요해져서 구현)
- `PATCH /users/{id}/twin-settings``{"autonomy_level": "L0"|"L1"|"L2"}`로 전역 자율성 레벨 변경
(가입 시 기본값은 L0, PRD.md §2.1)
- `POST`/`GET /users/{id}/whitelist-rules`, `DELETE /users/{id}/whitelist-rules/{ruleId}`
화이트리스트 규칙 CRUD (`{"topic_keyword": "...", "contact_id": null|uint}`). `contact_id`
저장은 되지만 아직 매칭에는 안 쓰인다 — 바로 아래 참고
- 에스컬레이션 통과 후 트윈 발송이면 레벨을 확인: **L0**은 항상 차단(403, 초안만 가능), **L1**은
요청에 `approved: true`가 없으면 차단(403), **L2**는 `whitelist_rules`에 매칭되는 주제면
`approved` 없이도 즉시 발송, 매칭이 없으면 L1과 동일하게 승인 필요
- 화이트리스트 매칭(`whitelistMatches`)은 v1 최소 구현 — 유저의 모든 `WhitelistRule.TopicKeyword`
메시지 텍스트에 부분 문자열로 매칭. `WhitelistRule.ContactID`(상대별 화이트리스트)는 아직 무시함 —
대화방↔연락처 연결이 아직 모델링되어 있지 않아서(클라이언트 연락처 모델이 생긴 뒤에 다시 설계 필요)
메시지 텍스트에 부분 문자열로 매칭. `WhitelistRule.ContactID`(상대별 화이트리스트)는 CRUD로
저장은 되지만 매칭 로직에서는 아직 무시함 — 대화방↔연락처 연결이 아직 모델링되어 있지 않아서
(클라이언트 연락처 모델이 생긴 뒤에 다시 설계 필요)
- 레벨/화이트리스트와 무관하게 에스컬레이션이 항상 우선한다 — L2 화이트리스트 매칭 + `approved:
true`여도 에스컬레이션 대상이면 무조건 차단 (테스트로 확인함)
@ -99,11 +109,13 @@ go test ./... -v
## 아직 없는 것 (다음 워크스트림)
- 화이트리스트 규칙 CRUD API (지금은 DB에 직접 넣는 걸로 테스트함 — 유저가 화이트리스트를 실제로
등록하는 API는 아직 없음, 2.3 자율성 설정 화면과 같이 설계 필요)
- 상대별(`ContactID`) 화이트리스트/자율성 예외 (지금은 전역 레벨만 지원)
- 상대별(`ContactID`) 화이트리스트/자율성 예외 매칭 (CRUD로 저장은 되지만 발송 시 매칭 로직은
아직 전역 키워드만 봄 — 대화방↔연락처 연결 모델링 필요)
- 되돌리기 "UI" (API·브로드캐스트는 됨 — 사용자에게 사후 알림을 띄우고 되돌리기 버튼을 보여주는
건 Flutter 쪽)
- 에스컬레이션 로그 조회 API (`escalation_logs`는 계속 쌓이지만, 사용자가 "본인 확인이 필요했던
목록"을 조회하는 API는 아직 없음 — 필요해지면 추가)
- 온디바이스 말투 이력 저장 + 서버 최소 전송 (클라이언트 책임)
- 사후 알림 + 되돌리기 "UI" 흐름 (로그 자체는 쌓이지만, 사용자에게 보여주고 되돌리는 건 Flutter 쪽)
- 데이터 흐름 대시보드, 온디바이스 암호화 (둘 다 Flutter 클라이언트 책임 — 이 저장소엔 SDK 없어
로컬 환경에서 진행)
- 생성 지연시간·오류율 계측 (요청 타이밍/로깅 계층 필요, `/admin/metrics`는 카운트만 있음)

View File

@ -36,6 +36,11 @@ type updateTwinSettingsRequest struct {
AutonomyLevel AutonomyLevel `json:"autonomy_level" binding:"required"`
}
type createWhitelistRuleRequest struct {
ContactID *uint `json:"contact_id"`
TopicKeyword string `json:"topic_keyword" binding:"required"`
}
type draftMessageRequest struct {
ContextLines []string `json:"context_lines" binding:"required"`
StyleExamples []string `json:"style_examples"`
@ -242,6 +247,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
}
relay.broadcast(convID, gin.H{
"type": "message",
"id": message.ID,
"sender_id": message.SenderID,
"sender_mode": message.SenderMode,
@ -251,6 +257,44 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
c.JSON(http.StatusOK, gin.H{"id": message.ID})
})
r.POST("/messages/:id/retract", func(c *gin.Context) {
msgID, ok := parseUintParam(c, "id")
if !ok {
return
}
var message Message
if err := db.First(&message, msgID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "message not found"})
return
}
// One-tap undo (PRD.md §3.1, AGENTS.md "every automatic action needs
// post-hoc notification + one-tap undo") applies to unattended
// twin auto-sends -- a human retracting their own words is a
// different, unrelated feature this endpoint doesn't cover.
if message.SenderMode != SenderTwin {
c.JSON(http.StatusBadRequest, gin.H{"detail": "only twin-authored (auto-sent) messages can be retracted"})
return
}
if message.Retracted {
c.JSON(http.StatusConflict, gin.H{"detail": "message already retracted"})
return
}
message.Retracted = true
if err := db.Save(&message).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
relay.broadcast(message.ConversationID, gin.H{
"type": "retraction",
"id": message.ID,
})
c.JSON(http.StatusOK, gin.H{"id": message.ID, "retracted": true})
})
r.POST("/conversations/:id/draft", func(c *gin.Context) {
convID, ok := parseUintParam(c, "id")
if !ok {
@ -346,6 +390,84 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
c.JSON(http.StatusOK, gin.H{"user_id": userID, "autonomy_level": settings.AutonomyLevel})
})
r.POST("/users/:id/whitelist-rules", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
var user User
if err := db.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
return
}
var req createWhitelistRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
rule := WhitelistRule{UserID: userID, ContactID: req.ContactID, TopicKeyword: req.TopicKeyword}
if err := db.Create(&rule).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"id": rule.ID,
"user_id": rule.UserID,
"contact_id": rule.ContactID,
"topic_keyword": rule.TopicKeyword,
})
})
r.GET("/users/:id/whitelist-rules", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
var user User
if err := db.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
return
}
var rules []WhitelistRule
db.Where("user_id = ?", userID).Order("id").Find(&rules)
out := make([]gin.H, 0, len(rules))
for _, rule := range rules {
out = append(out, gin.H{
"id": rule.ID,
"contact_id": rule.ContactID,
"topic_keyword": rule.TopicKeyword,
})
}
c.JSON(http.StatusOK, gin.H{"whitelist_rules": out})
})
r.DELETE("/users/:id/whitelist-rules/:ruleId", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
ruleID, ok := parseUintParam(c, "ruleId")
if !ok {
return
}
var rule WhitelistRule
if err := db.Where("id = ? AND user_id = ?", ruleID, userID).First(&rule).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "whitelist rule not found"})
return
}
db.Delete(&rule)
c.JSON(http.StatusOK, gin.H{"deleted": true})
})
r.DELETE("/users/:id", func(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {

View File

@ -639,6 +639,155 @@ func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) {
}
}
func TestWhitelistRuleCRUD(t *testing.T) {
server, _ := setupTestServer(t)
userID := mustSignup(t, server.URL, "화이트")
base := server.URL + "/users/" + strconv.FormatUint(uint64(userID), 10) + "/whitelist-rules"
createResp := postJSON(t, base, createWhitelistRuleRequest{TopicKeyword: "저녁"})
if createResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 creating rule, got %d", createResp.StatusCode)
}
var created map[string]interface{}
json.NewDecoder(createResp.Body).Decode(&created)
if created["topic_keyword"] != "저녁" || created["contact_id"] != nil {
t.Fatalf("unexpected created rule: %v", created)
}
ruleID := uint(created["id"].(float64))
listResp, err := http.Get(base)
if err != nil {
t.Fatalf("list rules: %v", err)
}
var list map[string]interface{}
json.NewDecoder(listResp.Body).Decode(&list)
rules := list["whitelist_rules"].([]interface{})
if len(rules) != 1 {
t.Fatalf("expected 1 rule listed, got %d", len(rules))
}
delReq, _ := http.NewRequest(http.MethodDelete, base+"/"+strconv.FormatUint(uint64(ruleID), 10), nil)
delResp, err := http.DefaultClient.Do(delReq)
if err != nil {
t.Fatalf("delete rule: %v", err)
}
if delResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 deleting rule, got %d", delResp.StatusCode)
}
listResp2, _ := http.Get(base)
var list2 map[string]interface{}
json.NewDecoder(listResp2.Body).Decode(&list2)
if len(list2["whitelist_rules"].([]interface{})) != 0 {
t.Fatalf("expected 0 rules after delete, got %v", list2)
}
delAgain, _ := http.NewRequest(http.MethodDelete, base+"/"+strconv.FormatUint(uint64(ruleID), 10), nil)
delAgainResp, _ := http.DefaultClient.Do(delAgain)
if delAgainResp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 deleting an already-deleted rule, got %d", delAgainResp.StatusCode)
}
}
func TestWhitelistRuleCRUDMissingUser(t *testing.T) {
server, _ := setupTestServer(t)
resp := postJSON(t, server.URL+"/users/9999/whitelist-rules", createWhitelistRuleRequest{TopicKeyword: "저녁"})
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
}
func TestRetractTwinMessage(t *testing.T) {
server, db := setupTestServer(t)
senderID := mustSignup(t, server.URL, "되돌리기")
setAutonomyLevel(t, server.URL, senderID, 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)
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/conversations/" +
strconv.FormatUint(uint64(conv.ID), 10)
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial ws: %v", err)
}
defer ws.Close()
sendResp := postJSON(t, convBase+"/messages", sendMessageRequest{SenderID: senderID, Text: "저녁 뭐 먹었어?", SenderMode: SenderTwin})
if sendResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 auto-sending, got %d", sendResp.StatusCode)
}
var sent map[string]interface{}
json.NewDecoder(sendResp.Body).Decode(&sent)
msgID := uint(sent["id"].(float64))
var sendBroadcast map[string]interface{}
if err := ws.ReadJSON(&sendBroadcast); err != nil {
t.Fatalf("read send broadcast: %v", err)
}
if sendBroadcast["type"] != "message" {
t.Fatalf("expected type:message on send broadcast, got %v", sendBroadcast)
}
retractResp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/retract", nil)
if retractResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 retracting, got %d", retractResp.StatusCode)
}
var retractBroadcast map[string]interface{}
if err := ws.ReadJSON(&retractBroadcast); err != nil {
t.Fatalf("read retract broadcast: %v", err)
}
if retractBroadcast["type"] != "retraction" || uint(retractBroadcast["id"].(float64)) != msgID {
t.Fatalf("unexpected retraction broadcast: %v", retractBroadcast)
}
var message Message
db.First(&message, msgID)
if !message.Retracted {
t.Fatalf("expected message.Retracted true after retract")
}
// Already retracted -- must not succeed again.
again := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/retract", nil)
if again.StatusCode != http.StatusConflict {
t.Fatalf("expected 409 retracting an already-retracted message, got %d", again.StatusCode)
}
}
func TestRetractRejectsHumanMessage(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)
}
sendResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/messages", sendMessageRequest{
SenderID: senderID, Text: "안녕", SenderMode: SenderHuman,
})
var sent map[string]interface{}
json.NewDecoder(sendResp.Body).Decode(&sent)
msgID := uint(sent["id"].(float64))
resp := postJSON(t, server.URL+"/messages/"+strconv.FormatUint(uint64(msgID), 10)+"/retract", nil)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 retracting a human message, got %d", resp.StatusCode)
}
}
func TestRetractMissingMessage(t *testing.T) {
server, _ := setupTestServer(t)
resp := postJSON(t, server.URL+"/messages/9999/retract", nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
}
func TestDraftMissingConversation(t *testing.T) {
server, _ := setupTestServer(t)
resp := postJSON(t, server.URL+"/conversations/9999/draft", draftMessageRequest{

View File

@ -69,6 +69,11 @@ type Message struct {
SenderID uint `gorm:"not null"`
SenderMode SenderMode `gorm:"not null;default:human"`
Text string `gorm:"not null"`
// Retracted is the one-tap undo for an L2 auto-send (PRD.md §3.1,
// AGENTS.md "every automatic action needs post-hoc notification +
// one-tap undo") -- set via POST /messages/:id/retract, twin-authored
// messages only.
Retracted bool `gorm:"not null;default:false"`
CreatedAt time.Time
}

View File

@ -59,7 +59,13 @@
`ContactID`(상대별) 무시하고 전역 키워드 매칭만 지원 — 대화방↔연락처 연결 모델이 아직 없어서
(Flutter 클라이언트의 연락처 모델이 생긴 뒤 다시 설계 필요, `core-backend/README.md` 참고)
- [ ] 온디바이스 말투 이력 저장 + 서버 최소 전송 원칙 구현
- [ ] 사후 알림 + 되돌리기 로그 스키마/API
- [x] 사후 알림 + 되돌리기 로그 스키마/API — `escalation_logs`는 이미 쌓임(사후 알림용 로그).
되돌리기(one-tap undo, AGENTS.md 안전 불변식)는 `Message.Retracted` 필드 +
`POST /messages/:id/retract` 추가: 트윈이 자동발송한(L2) 메시지만 대상, 사람이 쓴 메시지는 400,
이미 되돌린 건 409, 성공하면 같은 대화방 WebSocket에 `{"type":"retraction", "id":...}` 브로드캐스트.
일반 메시지 브로드캐스트도 `"type":"message"`를 붙여 클라이언트가 두 이벤트를 구분하게 함.
**UI는 아직** — 사용자에게 사후 알림을 띄우고 되돌리기 버튼을 보여주는 건 Flutter 쪽.
에스컬레이션 로그를 "조회"하는 API는 아직 없음(필요해지면 추가)
**2.3 클라이언트 (Flutter, 안드로이드 우선 빌드)** — **Flutter/Dart SDK가 있는 환경(본인 로컬)에서 착수**
- [ ] 기본 채팅 UI (대화 목록, 대화방) — 무관, 착수 가능
@ -140,8 +146,8 @@
불가능**해서 보류 — Flutter는 Windows에 SDK가 설치된 환경(본인 로컬)에서 시작
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동(`core-backend/aiservice.go`,
`POST /conversations/:id/draft`)도 완료·테스트 통과. 자율성 엔진 오케스트레이션도 최소 버전으로
완료(아래 5번과 함께 구현). 남은 건 온디바이스 말투 이력 저장, 사후 알림/되돌리기 로그 — 클라이언트
의존이라 2.3(Flutter) 대기
완료(아래 5번과 함께 구현). 되돌리기 API도 완료(아래 5번 참고). 남은 건 온디바이스 말투 이력
저장 — 클라이언트 의존이라 2.3(Flutter) 대기
4. [~] 2.3 나머지 UX(온보딩·설정·뱃지) — Flutter SDK가 없는 이 환경에서는 여전히 착수 불가.
대신 2.4(안전장치 통합) 중 서버 쪽 몫(에스컬레이션 하드게이트 우회 차단, 삭제 API)을 먼저 진행함
5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 2.4: 하드게이트 우회 차단, 거부권 구현, 삭제 플로우. 2.5:
@ -151,6 +157,9 @@
- 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 실제 초대 코드 발급/검증(`InviteCode`, `POST
/invites`), `GET /admin/metrics`(메시지·에스컬레이션·거부율 카운트). **실제 베타 오픈
시점 자체는 여전히 6번(§3) 이후** — 서버 인프라만 먼저 준비해둔 것
- 5-2. [x] 화이트리스트 규칙 CRUD API (`POST`/`GET /users/:id/whitelist-rules`,
`DELETE /users/:id/whitelist-rules/:ruleId`) — Flutter의 자율성 설정 화면(2.3)이 붙여 쓸 수
있게 미리 준비. `contact_id`는 저장되지만 매칭 로직은 여전히 전역 키워드만 봄(위 2.2 참고)
6. [ ] §3 확정 (PoC 결과 필요 — 1~5번 전부 끝난 뒤에만) → 2.6 실제 베타 오픈
1~5는 PoC 실제 실행(A트랙)과 병행 가능 — PoC가 늦어져도 인프라 작업은 막히지 않는다. 다만