Merge planning branch: Phase 1 A1/A2 APIs
Co-authored-by: okuma <o0kuma@users.noreply.github.com>
This commit is contained in:
commit
8c1f9f0b0c
|
|
@ -39,7 +39,12 @@ export GEMINI_API_KEY=... # or repo-root .env
|
|||
uvicorn app.main:app --port 8001
|
||||
|
||||
# 터미널 2 — 코어 백엔드
|
||||
cd core-backend && go run .
|
||||
cd core-backend
|
||||
export ADMIN_API_TOKEN=dev-admin-token
|
||||
go run . migrate && go run .
|
||||
|
||||
# 초대 코드 발급 예:
|
||||
# curl -X POST http://localhost:8080/invites -H "Authorization: Bearer $ADMIN_API_TOKEN"
|
||||
|
||||
# 터미널 3 — Flutter (Android)
|
||||
cd mobile && flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ Go(Gin + gorilla/websocket + GORM), PostgreSQL(프로덕션)/SQLite(로컬 개
|
|||
|
||||
```bash
|
||||
go mod download
|
||||
export ADMIN_API_TOKEN="dev-admin-token" # required for /invites and /admin/metrics
|
||||
go run . migrate # explicit schema migrate (also runs on startup)
|
||||
go run .
|
||||
```
|
||||
|
||||
|
|
@ -23,6 +25,12 @@ AI 서비스(`../ai-service/`)를 호출하려면:
|
|||
export AI_SERVICE_URL="http://localhost:8001" # 기본값도 이 주소
|
||||
```
|
||||
|
||||
인증:
|
||||
|
||||
- 가입 `POST /auth/signup` / 로그인 `POST /auth/login` → `{token}` (Bearer)
|
||||
- 사용자 스코프 API(`PATCH /users/:id/...`, contacts, conversations 목록 등)는 Bearer 필요
|
||||
- `/invites`, `/admin/metrics`는 `Authorization: Bearer $ADMIN_API_TOKEN`
|
||||
|
||||
## 테스트
|
||||
|
||||
```bash
|
||||
|
|
@ -109,18 +117,10 @@ go test ./... -v
|
|||
|
||||
## 아직 없는 것 (다음 워크스트림)
|
||||
|
||||
- 상대별(`ContactID`) 화이트리스트/자율성 예외 매칭 (CRUD로 저장은 되지만 발송 시 매칭 로직은
|
||||
아직 전역 키워드만 봄 — 대화방↔연락처 연결 모델링 필요)
|
||||
- 되돌리기 "UI" (API·브로드캐스트는 됨 — 사용자에게 사후 알림을 띄우고 되돌리기 버튼을 보여주는
|
||||
건 Flutter 쪽)
|
||||
- 에스컬레이션 로그 조회 API (`escalation_logs`는 계속 쌓이지만, 사용자가 "본인 확인이 필요했던
|
||||
목록"을 조회하는 API는 아직 없음 — 필요해지면 추가)
|
||||
- 되돌리기/사후알림 UX 고도화 (Flutter A3)
|
||||
- 온디바이스 말투 이력 저장 + 서버 최소 전송 (클라이언트 책임)
|
||||
- 데이터 흐름 대시보드, 온디바이스 암호화 (둘 다 Flutter 클라이언트 책임 — 이 저장소엔 SDK 없어
|
||||
로컬 환경에서 진행)
|
||||
- 생성 지연시간·오류율 계측 (요청 타이밍/로깅 계층 필요, `/admin/metrics`는 카운트만 있음)
|
||||
- `/invites`·`/admin/metrics` 접근 제어 (지금은 인증이 없어 누구나 호출 가능 — 아래 인증 항목과 같이 해결)
|
||||
- 데이터 흐름 대시보드, 온디바이스 암호화 (Flutter)
|
||||
- 생성 지연시간·오류율 계측
|
||||
- 푸시 알림 연동
|
||||
- 인증 토큰/세션 (지금은 invite_code로 가입만 되고 로그인 세션 개념이 없음)
|
||||
- 프로덕션 마이그레이션 도구 (지금은 `AutoMigrate`로 시작 시 테이블 생성 — 스키마 안정되면 Atlas/golang-migrate 등 도입)
|
||||
- 멀티 디바이스 동기화 (같은 유저가 여러 기기로 접속하는 경우)
|
||||
- 멀티 디바이스 동기화
|
||||
- Atlas/golang-migrate 등 버전드 마이그레이션으로 승격 (지금은 `go run . migrate` + AutoMigrate)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,345 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type createConversationRequest struct {
|
||||
UserIDs []uint `json:"user_ids" binding:"required"`
|
||||
IsGroup bool `json:"is_group"`
|
||||
ContactID *uint `json:"contact_id"` // optional: owner's contact for the peer (DM)
|
||||
}
|
||||
|
||||
type createContactRequest struct {
|
||||
DisplayName string `json:"display_name" binding:"required"`
|
||||
ContactUserID *uint `json:"contact_user_id"`
|
||||
RelationshipNote string `json:"relationship_note"`
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
InviteCode string `json:"invite_code" binding:"required"`
|
||||
}
|
||||
|
||||
func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
||||
r.POST("/auth/login", func(c *gin.Context) {
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
var invite InviteCode
|
||||
if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
|
||||
return
|
||||
}
|
||||
if invite.UsedAt == nil || invite.UsedByUserID == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invite code not yet used for signup"})
|
||||
return
|
||||
}
|
||||
var user User
|
||||
if err := db.First(&user, *invite.UsedByUserID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
|
||||
return
|
||||
}
|
||||
session, err := createSession(db, user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": user.ID,
|
||||
"display_name": user.DisplayName,
|
||||
"token": session.Token,
|
||||
"expires_at": session.ExpiresAt,
|
||||
})
|
||||
})
|
||||
|
||||
r.POST("/conversations", func(c *gin.Context) {
|
||||
actor, ok := currentUser(c, db, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req createConversationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.UserIDs) < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "user_ids must include at least one participant"})
|
||||
return
|
||||
}
|
||||
// Creator must be a participant.
|
||||
includesSelf := false
|
||||
for _, id := range req.UserIDs {
|
||||
if id == actor.ID {
|
||||
includesSelf = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !includesSelf {
|
||||
req.UserIDs = append(req.UserIDs, actor.ID)
|
||||
}
|
||||
|
||||
if req.ContactID != nil {
|
||||
var contact Contact
|
||||
if err := db.Where("id = ? AND owner_user_id = ?", *req.ContactID, actor.ID).First(&contact).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "contact_id must belong to the authenticated user"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var conv Conversation
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
conv = Conversation{IsGroup: req.IsGroup}
|
||||
if err := tx.Create(&conv).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[uint]bool{}
|
||||
for _, uid := range req.UserIDs {
|
||||
if seen[uid] {
|
||||
continue
|
||||
}
|
||||
seen[uid] = true
|
||||
var user User
|
||||
if err := tx.First(&user, uid).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&ConversationParticipant{ConversationID: conv.ID, UserID: uid}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Link owner's contact → conversation via RelationshipNote field? No —
|
||||
// store on Contact by setting ContactUserID already; for whitelist we
|
||||
// resolve peer via participants. Optionally stamp contact's linked user.
|
||||
if req.ContactID != nil {
|
||||
// Ensure contact points at the other participant when possible.
|
||||
var contact Contact
|
||||
if err := tx.First(&contact, *req.ContactID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, uid := range req.UserIDs {
|
||||
if uid != actor.ID {
|
||||
contact.ContactUserID = &uid
|
||||
if err := tx.Save(&contact).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": conv.ID,
|
||||
"is_group": conv.IsGroup,
|
||||
"user_ids": req.UserIDs,
|
||||
})
|
||||
})
|
||||
|
||||
r.GET("/conversations", func(c *gin.Context) {
|
||||
actor, ok := currentUser(c, db, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID := actor.ID
|
||||
if q := c.Query("user_id"); q != "" {
|
||||
parsed, err := strconv.ParseUint(q, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "user_id must be a positive integer"})
|
||||
return
|
||||
}
|
||||
if uint(parsed) != actor.ID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "can only list your own conversations"})
|
||||
return
|
||||
}
|
||||
userID = uint(parsed)
|
||||
}
|
||||
|
||||
var parts []ConversationParticipant
|
||||
db.Where("user_id = ?", userID).Find(&parts)
|
||||
out := make([]gin.H, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
var conv Conversation
|
||||
if err := db.First(&conv, p.ConversationID).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
var participantIDs []uint
|
||||
var pps []ConversationParticipant
|
||||
db.Where("conversation_id = ?", conv.ID).Find(&pps)
|
||||
for _, pp := range pps {
|
||||
participantIDs = append(participantIDs, pp.UserID)
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"conversations": out})
|
||||
})
|
||||
|
||||
r.GET("/conversations/:id/messages", func(c *gin.Context) {
|
||||
actor, ok := currentUser(c, db, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
convID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !isParticipant(db, convID, actor.ID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "not a participant of this conversation"})
|
||||
return
|
||||
}
|
||||
var messages []Message
|
||||
db.Where("conversation_id = ?", convID).Order("id asc").Find(&messages)
|
||||
out := make([]gin.H, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
out = append(out, gin.H{
|
||||
"id": m.ID,
|
||||
"conversation_id": m.ConversationID,
|
||||
"sender_id": m.SenderID,
|
||||
"sender_mode": m.SenderMode,
|
||||
"text": m.Text,
|
||||
"retracted": m.Retracted,
|
||||
"created_at": m.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"messages": out})
|
||||
})
|
||||
|
||||
r.POST("/users/:id/contacts", func(c *gin.Context) {
|
||||
userID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSelf(c, db, userID) {
|
||||
return
|
||||
}
|
||||
var req createContactRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
contact := Contact{
|
||||
OwnerUserID: userID,
|
||||
ContactUserID: req.ContactUserID,
|
||||
DisplayName: req.DisplayName,
|
||||
RelationshipNote: req.RelationshipNote,
|
||||
}
|
||||
if err := db.Create(&contact).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": contact.ID,
|
||||
"display_name": contact.DisplayName,
|
||||
"contact_user_id": contact.ContactUserID,
|
||||
"relationship_note": contact.RelationshipNote,
|
||||
})
|
||||
})
|
||||
|
||||
r.GET("/users/:id/contacts", func(c *gin.Context) {
|
||||
userID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSelf(c, db, userID) {
|
||||
return
|
||||
}
|
||||
var contacts []Contact
|
||||
db.Where("owner_user_id = ?", userID).Order("id").Find(&contacts)
|
||||
out := make([]gin.H, 0, len(contacts))
|
||||
for _, ct := range contacts {
|
||||
out = append(out, gin.H{
|
||||
"id": ct.ID,
|
||||
"display_name": ct.DisplayName,
|
||||
"contact_user_id": ct.ContactUserID,
|
||||
"relationship_note": ct.RelationshipNote,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"contacts": out})
|
||||
})
|
||||
|
||||
r.DELETE("/users/:id/contacts/:contactId", func(c *gin.Context) {
|
||||
userID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSelf(c, db, userID) {
|
||||
return
|
||||
}
|
||||
contactID, ok := parseUintParam(c, "contactId")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var contact Contact
|
||||
if err := db.Where("id = ? AND owner_user_id = ?", contactID, userID).First(&contact).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "contact not found"})
|
||||
return
|
||||
}
|
||||
db.Delete(&contact)
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
})
|
||||
|
||||
r.GET("/users/:id/escalation-logs", func(c *gin.Context) {
|
||||
userID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSelf(c, db, userID) {
|
||||
return
|
||||
}
|
||||
var logs []EscalationLog
|
||||
db.Where("user_id = ?", userID).Order("id desc").Find(&logs)
|
||||
out := make([]gin.H, 0, len(logs))
|
||||
for _, l := range logs {
|
||||
out = append(out, gin.H{
|
||||
"id": l.ID,
|
||||
"conversation_id": l.ConversationID,
|
||||
"reason": l.Reason,
|
||||
"message_snippet": l.MessageSnippet,
|
||||
"resolved": l.Resolved,
|
||||
"created_at": l.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"escalation_logs": out})
|
||||
})
|
||||
}
|
||||
|
||||
func isParticipant(db *gorm.DB, conversationID, userID uint) bool {
|
||||
var n int64
|
||||
db.Model(&ConversationParticipant{}).
|
||||
Where("conversation_id = ? AND user_id = ?", conversationID, userID).
|
||||
Count(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// resolvePeerContactID finds the owner's Contact row for the other party in
|
||||
// a conversation (Contact.ContactUserID == other participant).
|
||||
func resolvePeerContactID(db *gorm.DB, ownerUserID, conversationID uint) *uint {
|
||||
var parts []ConversationParticipant
|
||||
db.Where("conversation_id = ?", conversationID).Find(&parts)
|
||||
for _, p := range parts {
|
||||
if p.UserID == ownerUserID {
|
||||
continue
|
||||
}
|
||||
var contact Contact
|
||||
if err := db.Where("owner_user_id = ? AND contact_user_id = ?", ownerUserID, p.UserID).First(&contact).Error; err == nil {
|
||||
id := contact.ID
|
||||
return &id
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInvitesRequireAdminToken(t *testing.T) {
|
||||
server, _ := setupTestServer(t)
|
||||
resp := postJSON(t, server.URL+"/invites", nil)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 without admin token, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginReturnsSessionToken(t *testing.T) {
|
||||
server, _ := setupTestServer(t)
|
||||
code := mintInvite(t, server.URL)
|
||||
signup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "로그인"})
|
||||
if signup.StatusCode != http.StatusOK {
|
||||
t.Fatalf("signup: %d", signup.StatusCode)
|
||||
}
|
||||
|
||||
login := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: code})
|
||||
if login.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 login, got %d", login.StatusCode)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
json.NewDecoder(login.Body).Decode(&out)
|
||||
if out["token"] == nil || out["token"] == "" {
|
||||
t.Fatalf("login missing token: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationContactMessageHistoryAndEscalationLogs(t *testing.T) {
|
||||
server, _ := setupTestServer(t)
|
||||
ownerID, ownerToken := mustSignup(t, server.URL, "주인")
|
||||
peerID, _ := mustSignup(t, server.URL, "상대")
|
||||
|
||||
// Create contact linking peer user.
|
||||
contactResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
|
||||
DisplayName: "상대방",
|
||||
ContactUserID: &peerID,
|
||||
})
|
||||
if contactResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("create contact: %d", contactResp.StatusCode)
|
||||
}
|
||||
var contact map[string]interface{}
|
||||
json.NewDecoder(contactResp.Body).Decode(&contact)
|
||||
contactID := uint(contact["id"].(float64))
|
||||
|
||||
convResp := postJSONAuth(t, server.URL+"/conversations", ownerToken, createConversationRequest{
|
||||
UserIDs: []uint{ownerID, peerID},
|
||||
ContactID: &contactID,
|
||||
})
|
||||
if convResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("create conversation: %d", convResp.StatusCode)
|
||||
}
|
||||
var conv map[string]interface{}
|
||||
json.NewDecoder(convResp.Body).Decode(&conv)
|
||||
convID := uint(conv["id"].(float64))
|
||||
|
||||
listResp, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations", nil)
|
||||
listResp.Header.Set("Authorization", "Bearer "+ownerToken)
|
||||
listed, err := http.DefaultClient.Do(listResp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var listOut map[string]interface{}
|
||||
json.NewDecoder(listed.Body).Decode(&listOut)
|
||||
convs := listOut["conversations"].([]interface{})
|
||||
if len(convs) != 1 {
|
||||
t.Fatalf("expected 1 conversation, got %v", listOut)
|
||||
}
|
||||
|
||||
postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{
|
||||
SenderID: ownerID,
|
||||
Text: "안녕 저녁 어때",
|
||||
})
|
||||
|
||||
histReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", nil)
|
||||
histReq.Header.Set("Authorization", "Bearer "+ownerToken)
|
||||
histResp, err := http.DefaultClient.Do(histReq)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if histResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("history: %d", histResp.StatusCode)
|
||||
}
|
||||
var hist map[string]interface{}
|
||||
json.NewDecoder(histResp.Body).Decode(&hist)
|
||||
if len(hist["messages"].([]interface{})) != 1 {
|
||||
t.Fatalf("expected 1 message, got %v", hist)
|
||||
}
|
||||
|
||||
// Force an escalation log via twin send.
|
||||
setAutonomyLevel(t, server.URL, ownerID, ownerToken, AutonomyL1)
|
||||
postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{
|
||||
SenderID: ownerID,
|
||||
Text: "계좌번호 알려줄게",
|
||||
SenderMode: SenderTwin,
|
||||
Approved: true,
|
||||
})
|
||||
|
||||
logReq, _ := http.NewRequest(http.MethodGet, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/escalation-logs", nil)
|
||||
logReq.Header.Set("Authorization", "Bearer "+ownerToken)
|
||||
logResp, err := http.DefaultClient.Do(logReq)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if logResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("escalation logs: %d", logResp.StatusCode)
|
||||
}
|
||||
var logs map[string]interface{}
|
||||
json.NewDecoder(logResp.Body).Decode(&logs)
|
||||
if len(logs["escalation_logs"].([]interface{})) < 1 {
|
||||
t.Fatalf("expected escalation log, got %v", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactScopedWhitelistMatch(t *testing.T) {
|
||||
server, db := setupTestServer(t)
|
||||
ownerID, ownerToken := mustSignup(t, server.URL, "화이트주인")
|
||||
peerID, _ := mustSignup(t, server.URL, "화이트상대")
|
||||
otherID, _ := mustSignup(t, server.URL, "다른사람")
|
||||
|
||||
contactResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
|
||||
DisplayName: "상대",
|
||||
ContactUserID: &peerID,
|
||||
})
|
||||
var contact map[string]interface{}
|
||||
json.NewDecoder(contactResp.Body).Decode(&contact)
|
||||
contactID := uint(contact["id"].(float64))
|
||||
|
||||
// Rule only for this contact + keyword 저녁
|
||||
cid := contactID
|
||||
postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/whitelist-rules", ownerToken, createWhitelistRuleRequest{
|
||||
ContactID: &cid,
|
||||
TopicKeyword: "저녁",
|
||||
})
|
||||
setAutonomyLevel(t, server.URL, ownerID, ownerToken, AutonomyL2)
|
||||
|
||||
// Conversation with the whitelisted peer -- should auto-send.
|
||||
convPeer := postJSONAuth(t, server.URL+"/conversations", ownerToken, createConversationRequest{
|
||||
UserIDs: []uint{ownerID, peerID},
|
||||
ContactID: &contactID,
|
||||
})
|
||||
var conv1 map[string]interface{}
|
||||
json.NewDecoder(convPeer.Body).Decode(&conv1)
|
||||
conv1ID := uint(conv1["id"].(float64))
|
||||
|
||||
okResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv1ID), 10)+"/messages", sendMessageRequest{
|
||||
SenderID: ownerID,
|
||||
Text: "오늘 저녁 뭐 먹을래",
|
||||
SenderMode: SenderTwin,
|
||||
})
|
||||
if okResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected L2 auto-send for matching contact+keyword, got %d", okResp.StatusCode)
|
||||
}
|
||||
|
||||
// Conversation with a different peer -- same keyword must NOT match contact-scoped rule.
|
||||
convOther := postJSONAuth(t, server.URL+"/conversations", ownerToken, createConversationRequest{
|
||||
UserIDs: []uint{ownerID, otherID},
|
||||
})
|
||||
var conv2 map[string]interface{}
|
||||
json.NewDecoder(convOther.Body).Decode(&conv2)
|
||||
conv2ID := uint(conv2["id"].(float64))
|
||||
|
||||
blocked := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv2ID), 10)+"/messages", sendMessageRequest{
|
||||
SenderID: ownerID,
|
||||
Text: "오늘 저녁 뭐 먹을래",
|
||||
SenderMode: SenderTwin,
|
||||
})
|
||||
if blocked.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 when contact-scoped rule does not apply, got %d", blocked.StatusCode)
|
||||
}
|
||||
|
||||
var n int64
|
||||
db.Model(&Message{}).Where("conversation_id = ? AND sender_mode = ?", conv2ID, SenderTwin).Count(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("must not store twin message for non-matching contact, got %d", n)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const sessionTTL = 30 * 24 * time.Hour
|
||||
|
||||
func adminAPIToken() string {
|
||||
return os.Getenv("ADMIN_API_TOKEN")
|
||||
}
|
||||
|
||||
func bearerToken(c *gin.Context) string {
|
||||
h := c.GetHeader("Authorization")
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(h, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
func requireAdmin(c *gin.Context) bool {
|
||||
expected := adminAPIToken()
|
||||
if expected == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"detail": "ADMIN_API_TOKEN is not configured; refusing privileged endpoint",
|
||||
})
|
||||
return false
|
||||
}
|
||||
if bearerToken(c) != expected {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "admin authorization required"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func createSession(db *gorm.DB, userID uint) (Session, error) {
|
||||
buf := make([]byte, 24)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
session := Session{
|
||||
Token: hex.EncodeToString(buf),
|
||||
UserID: userID,
|
||||
ExpiresAt: time.Now().Add(sessionTTL),
|
||||
}
|
||||
if err := db.Create(&session).Error; err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// currentUser resolves Authorization: Bearer <session token> to a User.
|
||||
// Returns false after writing an error response when required is true.
|
||||
func currentUser(c *gin.Context, db *gorm.DB, required bool) (User, bool) {
|
||||
token := bearerToken(c)
|
||||
if token == "" {
|
||||
if required {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Authorization Bearer token required"})
|
||||
}
|
||||
return User{}, false
|
||||
}
|
||||
var session Session
|
||||
if err := db.Where("token = ?", token).First(&session).Error; err != nil {
|
||||
if required {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "invalid session token"})
|
||||
}
|
||||
return User{}, false
|
||||
}
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
if required {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "session expired"})
|
||||
}
|
||||
return User{}, false
|
||||
}
|
||||
var user User
|
||||
if err := db.First(&user, session.UserID).Error; err != nil {
|
||||
if required {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "session user not found"})
|
||||
}
|
||||
return User{}, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
func requireSelf(c *gin.Context, db *gorm.DB, pathUserID uint) bool {
|
||||
user, ok := currentUser(c, db, true)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if user.ID != pathUserID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "token user does not match path user id"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -55,7 +56,12 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
registerA1A2Routes(r, db)
|
||||
|
||||
r.POST("/invites", func(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
code, err := generateInviteCode()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
|
|
@ -70,6 +76,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
})
|
||||
|
||||
r.GET("/admin/metrics", func(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
// v1-minimal (roadmap.md §2.6): only counts honestly derivable from
|
||||
// the current schema. Draft-generation latency and AI-service error
|
||||
// rate need a request-timing/logging layer that doesn't exist yet --
|
||||
|
|
@ -151,7 +160,18 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
invite.UsedByUserID = &user.ID
|
||||
db.Save(&invite)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id": user.ID, "display_name": user.DisplayName})
|
||||
session, err := createSession(db, user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": user.ID,
|
||||
"display_name": user.DisplayName,
|
||||
"token": session.Token,
|
||||
"expires_at": session.ExpiresAt,
|
||||
})
|
||||
})
|
||||
|
||||
r.POST("/conversations/:id/messages", func(c *gin.Context) {
|
||||
|
|
@ -225,7 +245,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
return
|
||||
}
|
||||
case AutonomyL2:
|
||||
if !req.Approved && !whitelistMatches(db, req.SenderID, req.Text) {
|
||||
if !req.Approved && !whitelistMatches(db, req.SenderID, convID, req.Text) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "화이트리스트에 없는 주제는 L1과 동일하게 사용자 승인이 필요합니다"})
|
||||
return
|
||||
}
|
||||
|
|
@ -365,6 +385,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSelf(c, db, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
var req updateTwinSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
|
@ -395,6 +418,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSelf(c, db, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
var user User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
|
|
@ -427,6 +453,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSelf(c, db, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
var user User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
|
|
@ -453,6 +482,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSelf(c, db, userID) {
|
||||
return
|
||||
}
|
||||
ruleID, ok := parseUintParam(c, "ruleId")
|
||||
if !ok {
|
||||
return
|
||||
|
|
@ -473,6 +505,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSelf(c, db, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
var user User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
|
|
@ -489,6 +524,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
if res := tx.Model(&InviteCode{}).Where("used_by_user_id = ?", userID).Update("used_by_user_id", nil); res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res := tx.Where("user_id = ?", userID).Delete(&Session{}); res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res := tx.Where("user_id = ?", userID).Delete(&TwinSettings{}); res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
|
|
@ -550,17 +588,22 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
return r
|
||||
}
|
||||
|
||||
// whitelistMatches is a v1-minimal check: any of the user's WhitelistRule
|
||||
// keywords appearing as a substring of the message text counts as a match.
|
||||
// It intentionally ignores WhitelistRule.ContactID (per-counterpart
|
||||
// whitelisting) because conversations aren't yet linked to a Contact row --
|
||||
// that link needs its own design pass once the client's contact model
|
||||
// exists, so this only supports the "any counterpart" case for now.
|
||||
func whitelistMatches(db *gorm.DB, userID uint, text string) bool {
|
||||
// whitelistMatches: keyword substring match, scoped by ContactID when set.
|
||||
// Rules with ContactID == nil apply to any counterpart. Rules with a
|
||||
// ContactID apply only when that contact is the peer in this conversation
|
||||
// (resolved via Contact.ContactUserID ↔ other participant).
|
||||
func whitelistMatches(db *gorm.DB, userID, conversationID uint, text string) bool {
|
||||
var rules []WhitelistRule
|
||||
db.Where("user_id = ?", userID).Find(&rules)
|
||||
peerContactID := resolvePeerContactID(db, userID, conversationID)
|
||||
for _, rule := range rules {
|
||||
if strings.Contains(text, rule.TopicKeyword) {
|
||||
if !strings.Contains(text, rule.TopicKeyword) {
|
||||
continue
|
||||
}
|
||||
if rule.ContactID == nil {
|
||||
return true
|
||||
}
|
||||
if peerContactID != nil && *rule.ContactID == *peerContactID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -585,6 +628,10 @@ func parseUintParam(c *gin.Context, name string) (uint, bool) {
|
|||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "migrate" {
|
||||
_ = openDB()
|
||||
return
|
||||
}
|
||||
db := openDB()
|
||||
relay := newConnectionManager()
|
||||
ai := newAIServiceClient()
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ func setupTestServer(t *testing.T) (*httptest.Server, *gorm.DB) {
|
|||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("ADMIN_API_TOKEN", "test-admin-token")
|
||||
|
||||
ai := &AIServiceClient{BaseURL: mockAIService(t).URL, HTTP: http.DefaultClient}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
|
@ -88,9 +90,24 @@ func postJSON(t *testing.T, url string, body interface{}) *http.Response {
|
|||
return resp
|
||||
}
|
||||
|
||||
func postJSONAuth(t *testing.T, url, token string, body interface{}) *http.Response {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("post %s: %v", url, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func mintInvite(t *testing.T, serverURL string) string {
|
||||
t.Helper()
|
||||
resp := postJSON(t, serverURL+"/invites", nil)
|
||||
resp := postJSONAuth(t, serverURL+"/invites", "test-admin-token", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 minting invite, got %d", resp.StatusCode)
|
||||
}
|
||||
|
|
@ -99,7 +116,7 @@ func mintInvite(t *testing.T, serverURL string) string {
|
|||
return out["code"].(string)
|
||||
}
|
||||
|
||||
func mustSignup(t *testing.T, serverURL, displayName string) uint {
|
||||
func mustSignup(t *testing.T, serverURL, displayName string) (uint, string) {
|
||||
t.Helper()
|
||||
resp := postJSON(t, serverURL+"/auth/signup", signupRequest{InviteCode: mintInvite(t, serverURL), DisplayName: displayName})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
|
|
@ -107,14 +124,19 @@ func mustSignup(t *testing.T, serverURL, displayName string) uint {
|
|||
}
|
||||
var out map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&out)
|
||||
return uint(out["id"].(float64))
|
||||
token, _ := out["token"].(string)
|
||||
if token == "" {
|
||||
t.Fatalf("signup response missing token: %v", out)
|
||||
}
|
||||
return uint(out["id"].(float64)), token
|
||||
}
|
||||
|
||||
func setAutonomyLevel(t *testing.T, serverURL string, userID uint, level AutonomyLevel) {
|
||||
func setAutonomyLevel(t *testing.T, serverURL string, userID uint, token string, level AutonomyLevel) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(updateTwinSettingsRequest{AutonomyLevel: level})
|
||||
req, _ := http.NewRequest(http.MethodPatch, serverURL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/twin-settings", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("set autonomy level: %v", err)
|
||||
|
|
@ -183,14 +205,14 @@ func TestSendMessageToMissingConversation(t *testing.T) {
|
|||
func TestSendMessageAndWebSocketBroadcast(t *testing.T) {
|
||||
server, db := setupTestServer(t)
|
||||
|
||||
senderID := mustSignup(t, server.URL, "정우")
|
||||
senderID, token := mustSignup(t, server.URL, "정우")
|
||||
|
||||
conv := Conversation{IsGroup: false}
|
||||
if err := db.Create(&conv).Error; err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
|
||||
setAutonomyLevel(t, server.URL, senderID, AutonomyL1)
|
||||
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/conversations/" +
|
||||
strconv.FormatUint(uint64(conv.ID), 10)
|
||||
|
|
@ -222,7 +244,7 @@ func TestSendMessageAndWebSocketBroadcast(t *testing.T) {
|
|||
func TestTwinMessageEscalatedIsBlockedAndNotBroadcast(t *testing.T) {
|
||||
server, db := setupTestServer(t)
|
||||
|
||||
senderID := mustSignup(t, server.URL, "민지")
|
||||
senderID, _ := mustSignup(t, server.URL, "민지")
|
||||
|
||||
conv := Conversation{IsGroup: false}
|
||||
if err := db.Create(&conv).Error; err != nil {
|
||||
|
|
@ -271,7 +293,7 @@ func TestTwinMessageEscalatedIsBlockedAndNotBroadcast(t *testing.T) {
|
|||
func TestTwinSendBlockedAtDefaultL0(t *testing.T) {
|
||||
server, db := setupTestServer(t)
|
||||
|
||||
senderID := mustSignup(t, server.URL, "하늘")
|
||||
senderID, _ := mustSignup(t, server.URL, "하늘")
|
||||
|
||||
conv := Conversation{IsGroup: false}
|
||||
if err := db.Create(&conv).Error; err != nil {
|
||||
|
|
@ -299,8 +321,8 @@ func TestTwinSendBlockedAtDefaultL0(t *testing.T) {
|
|||
func TestTwinSendRequiresApprovalAtL1(t *testing.T) {
|
||||
server, db := setupTestServer(t)
|
||||
|
||||
senderID := mustSignup(t, server.URL, "서준")
|
||||
setAutonomyLevel(t, server.URL, senderID, AutonomyL1)
|
||||
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 {
|
||||
|
|
@ -328,8 +350,8 @@ func TestTwinSendRequiresApprovalAtL1(t *testing.T) {
|
|||
func TestTwinSendAutoSendsAtL2WithWhitelistMatch(t *testing.T) {
|
||||
server, db := setupTestServer(t)
|
||||
|
||||
senderID := mustSignup(t, server.URL, "가은")
|
||||
setAutonomyLevel(t, server.URL, senderID, AutonomyL2)
|
||||
senderID, token := mustSignup(t, server.URL, "가은")
|
||||
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
|
||||
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
|
||||
|
||||
conv := Conversation{IsGroup: false}
|
||||
|
|
@ -355,8 +377,8 @@ func TestTwinSendAutoSendsAtL2WithWhitelistMatch(t *testing.T) {
|
|||
func TestTwinSendRequiresApprovalAtL2WithoutWhitelistMatch(t *testing.T) {
|
||||
server, db := setupTestServer(t)
|
||||
|
||||
senderID := mustSignup(t, server.URL, "도윤")
|
||||
setAutonomyLevel(t, server.URL, senderID, AutonomyL2)
|
||||
senderID, token := mustSignup(t, server.URL, "도윤")
|
||||
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
|
||||
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
|
||||
|
||||
conv := Conversation{IsGroup: false}
|
||||
|
|
@ -381,8 +403,8 @@ func TestTwinSendRequiresApprovalAtL2WithoutWhitelistMatch(t *testing.T) {
|
|||
func TestEscalationOverridesAutonomyLevelAndWhitelist(t *testing.T) {
|
||||
server, db := setupTestServer(t)
|
||||
|
||||
senderID := mustSignup(t, server.URL, "은서")
|
||||
setAutonomyLevel(t, server.URL, senderID, AutonomyL2)
|
||||
senderID, token := mustSignup(t, server.URL, "은서")
|
||||
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
|
||||
// Whitelisted keyword happens to be the same word that also triggers
|
||||
// the money escalation pattern in the mock AI service.
|
||||
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "계좌"})
|
||||
|
|
@ -409,7 +431,7 @@ func TestEscalationOverridesAutonomyLevelAndWhitelist(t *testing.T) {
|
|||
func TestHumanMessageBypassesEscalationGate(t *testing.T) {
|
||||
server, db := setupTestServer(t)
|
||||
|
||||
senderID := mustSignup(t, server.URL, "재민")
|
||||
senderID, _ := mustSignup(t, server.URL, "재민")
|
||||
|
||||
conv := Conversation{IsGroup: false}
|
||||
if err := db.Create(&conv).Error; err != nil {
|
||||
|
|
@ -429,6 +451,7 @@ func TestHumanMessageBypassesEscalationGate(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestTwinMessageFailsClosedWhenAIServiceUnreachable(t *testing.T) {
|
||||
t.Setenv("ADMIN_API_TOKEN", "test-admin-token")
|
||||
dbPath := t.TempDir() + "/test.db"
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
|
|
@ -447,7 +470,7 @@ func TestTwinMessageFailsClosedWhenAIServiceUnreachable(t *testing.T) {
|
|||
server := httptest.NewServer(router)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
senderID := mustSignup(t, server.URL, "소연")
|
||||
senderID, _ := mustSignup(t, server.URL, "소연")
|
||||
|
||||
conv := Conversation{IsGroup: false}
|
||||
if err := db.Create(&conv).Error; err != nil {
|
||||
|
|
@ -473,7 +496,7 @@ func TestTwinMessageFailsClosedWhenAIServiceUnreachable(t *testing.T) {
|
|||
func TestDeleteUserPurgesData(t *testing.T) {
|
||||
server, db := setupTestServer(t)
|
||||
|
||||
userID := mustSignup(t, server.URL, "유나")
|
||||
userID, token := mustSignup(t, server.URL, "유나")
|
||||
|
||||
conv := Conversation{IsGroup: false}
|
||||
if err := db.Create(&conv).Error; err != nil {
|
||||
|
|
@ -490,6 +513,7 @@ func TestDeleteUserPurgesData(t *testing.T) {
|
|||
db.Create(&EscalationLog{UserID: userID, ConversationID: conv.ID, Reason: "금전", MessageSnippet: "..."})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("delete request: %v", err)
|
||||
|
|
@ -515,20 +539,22 @@ func TestDeleteUserPurgesData(t *testing.T) {
|
|||
}
|
||||
|
||||
req2, _ := http.NewRequest(http.MethodDelete, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10), nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+token)
|
||||
resp2, err := http.DefaultClient.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("second delete request: %v", err)
|
||||
}
|
||||
if resp2.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 on repeat delete, got %d", resp2.StatusCode)
|
||||
// After delete, session is gone -- unauthorized (or 404 if we checked user first).
|
||||
if resp2.StatusCode != http.StatusUnauthorized && resp2.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("expected 401/404 on repeat delete, got %d", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerVetoBlocksTwinAutoSendEvenAtL2WithWhitelistMatch(t *testing.T) {
|
||||
server, db := setupTestServer(t)
|
||||
|
||||
senderID := mustSignup(t, server.URL, "지민")
|
||||
setAutonomyLevel(t, server.URL, senderID, AutonomyL2)
|
||||
senderID, token := mustSignup(t, server.URL, "지민")
|
||||
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
|
||||
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
|
||||
|
||||
conv := Conversation{IsGroup: false}
|
||||
|
|
@ -561,7 +587,7 @@ func TestPeerVetoBlocksTwinAutoSendEvenAtL2WithWhitelistMatch(t *testing.T) {
|
|||
func TestPeerVetoDoesNotBlockHumanMessages(t *testing.T) {
|
||||
server, db := setupTestServer(t)
|
||||
|
||||
senderID := mustSignup(t, server.URL, "다은")
|
||||
senderID, _ := mustSignup(t, server.URL, "다은")
|
||||
|
||||
conv := Conversation{IsGroup: false}
|
||||
if err := db.Create(&conv).Error; err != nil {
|
||||
|
|
@ -588,8 +614,8 @@ func TestVetoMissingConversation(t *testing.T) {
|
|||
func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) {
|
||||
server, db := setupTestServer(t)
|
||||
|
||||
senderID := mustSignup(t, server.URL, "메트릭")
|
||||
setAutonomyLevel(t, server.URL, senderID, AutonomyL1)
|
||||
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 {
|
||||
|
|
@ -605,7 +631,9 @@ func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) {
|
|||
db.Create(&conv2)
|
||||
postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv2.ID), 10)+"/veto", nil)
|
||||
|
||||
resp, err := http.Get(server.URL + "/admin/metrics")
|
||||
mreq, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil)
|
||||
mreq.Header.Set("Authorization", "Bearer test-admin-token")
|
||||
resp, err := http.DefaultClient.Do(mreq)
|
||||
if err != nil {
|
||||
t.Fatalf("get metrics: %v", err)
|
||||
}
|
||||
|
|
@ -641,10 +669,10 @@ func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) {
|
|||
|
||||
func TestWhitelistRuleCRUD(t *testing.T) {
|
||||
server, _ := setupTestServer(t)
|
||||
userID := mustSignup(t, server.URL, "화이트")
|
||||
userID, token := mustSignup(t, server.URL, "화이트")
|
||||
base := server.URL + "/users/" + strconv.FormatUint(uint64(userID), 10) + "/whitelist-rules"
|
||||
|
||||
createResp := postJSON(t, base, createWhitelistRuleRequest{TopicKeyword: "저녁"})
|
||||
createResp := postJSONAuth(t, base, token, createWhitelistRuleRequest{TopicKeyword: "저녁"})
|
||||
if createResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 creating rule, got %d", createResp.StatusCode)
|
||||
}
|
||||
|
|
@ -655,7 +683,9 @@ func TestWhitelistRuleCRUD(t *testing.T) {
|
|||
}
|
||||
ruleID := uint(created["id"].(float64))
|
||||
|
||||
listResp, err := http.Get(base)
|
||||
listReq, _ := http.NewRequest(http.MethodGet, base, nil)
|
||||
listReq.Header.Set("Authorization", "Bearer "+token)
|
||||
listResp, err := http.DefaultClient.Do(listReq)
|
||||
if err != nil {
|
||||
t.Fatalf("list rules: %v", err)
|
||||
}
|
||||
|
|
@ -667,6 +697,7 @@ func TestWhitelistRuleCRUD(t *testing.T) {
|
|||
}
|
||||
|
||||
delReq, _ := http.NewRequest(http.MethodDelete, base+"/"+strconv.FormatUint(uint64(ruleID), 10), nil)
|
||||
delReq.Header.Set("Authorization", "Bearer "+token)
|
||||
delResp, err := http.DefaultClient.Do(delReq)
|
||||
if err != nil {
|
||||
t.Fatalf("delete rule: %v", err)
|
||||
|
|
@ -675,7 +706,12 @@ func TestWhitelistRuleCRUD(t *testing.T) {
|
|||
t.Fatalf("expected 200 deleting rule, got %d", delResp.StatusCode)
|
||||
}
|
||||
|
||||
listResp2, _ := http.Get(base)
|
||||
listReq2, _ := http.NewRequest(http.MethodGet, base, nil)
|
||||
listReq2.Header.Set("Authorization", "Bearer "+token)
|
||||
listResp2, err := http.DefaultClient.Do(listReq2)
|
||||
if err != nil {
|
||||
t.Fatalf("list after delete: %v", err)
|
||||
}
|
||||
var list2 map[string]interface{}
|
||||
json.NewDecoder(listResp2.Body).Decode(&list2)
|
||||
if len(list2["whitelist_rules"].([]interface{})) != 0 {
|
||||
|
|
@ -683,6 +719,7 @@ func TestWhitelistRuleCRUD(t *testing.T) {
|
|||
}
|
||||
|
||||
delAgain, _ := http.NewRequest(http.MethodDelete, base+"/"+strconv.FormatUint(uint64(ruleID), 10), nil)
|
||||
delAgain.Header.Set("Authorization", "Bearer "+token)
|
||||
delAgainResp, _ := http.DefaultClient.Do(delAgain)
|
||||
if delAgainResp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 deleting an already-deleted rule, got %d", delAgainResp.StatusCode)
|
||||
|
|
@ -692,15 +729,15 @@ func TestWhitelistRuleCRUD(t *testing.T) {
|
|||
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)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 without token, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetractTwinMessage(t *testing.T) {
|
||||
server, db := setupTestServer(t)
|
||||
senderID := mustSignup(t, server.URL, "되돌리기")
|
||||
setAutonomyLevel(t, server.URL, senderID, AutonomyL2)
|
||||
senderID, token := mustSignup(t, server.URL, "되돌리기")
|
||||
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
|
||||
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
|
||||
|
||||
conv := Conversation{IsGroup: false}
|
||||
|
|
@ -761,7 +798,7 @@ func TestRetractTwinMessage(t *testing.T) {
|
|||
|
||||
func TestRetractRejectsHumanMessage(t *testing.T) {
|
||||
server, db := setupTestServer(t)
|
||||
senderID := mustSignup(t, server.URL, "사람")
|
||||
senderID, _ := mustSignup(t, server.URL, "사람")
|
||||
|
||||
conv := Conversation{IsGroup: false}
|
||||
if err := db.Create(&conv).Error; err != nil {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,16 @@ type User struct {
|
|||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Session is a bearer token issued at signup/login (roadmap A2).
|
||||
// v1 beta: opaque random token, no refresh rotation yet.
|
||||
type Session struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Token string `gorm:"uniqueIndex;not null"`
|
||||
UserID uint `gorm:"not null;index"`
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
}
|
||||
|
||||
// InviteCode is a pre-minted, single-use code (roadmap.md Phase 1 §2.6
|
||||
// "초대 기반 베타 가입 플로우") -- signup validates against this table instead
|
||||
// of just deduping User.InviteCode, so joining actually requires a code
|
||||
|
|
@ -111,6 +121,7 @@ type EscalationLog struct {
|
|||
|
||||
var allModels = []interface{}{
|
||||
&User{},
|
||||
&Session{},
|
||||
&InviteCode{},
|
||||
&Contact{},
|
||||
&Conversation{},
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다.
|
|||
|
||||
1. [x] §1 기술 스택 결정 (Go 코어 + Python AI 서비스로 재확정, `backend/`는 Python 프로토타입 —
|
||||
설계 참고용으로 남기고 Go로 포팅 필요)
|
||||
2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket 릴레이 완료, 푸시 알림만 남음)
|
||||
2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket + A1 대화/연락처/히스토리 + A2 세션/관리자 토큰). 푸시 알림만 남음
|
||||
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션·
|
||||
되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서
|
||||
4. [~] 2.3 Flutter 클라이언트 — `mobile/` 골격 착수 완료(가입·채팅·뱃지·거부권·되돌리기·자율성
|
||||
|
|
@ -157,6 +157,54 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다.
|
|||
6. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 2.6 실제 베타 오픈 (**맨 마지막**)
|
||||
|
||||
|
||||
#### 5. 앞으로의 개발 계획 (우선순위 체크리스트)
|
||||
|
||||
Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase 게이트 전 구현 금지.
|
||||
|
||||
##### A. 지금 바로 — 앱이 실제로 돌아가게
|
||||
|
||||
**A1. 서버 — 대화/관계 모델 완성**
|
||||
- [x] 대화방 생성·목록 API (`POST`/`GET /conversations`)
|
||||
- [x] 연락처 CRUD + 대화방↔연락처 연결
|
||||
- [x] 메시지 히스토리 조회 (`GET /conversations/:id/messages`)
|
||||
- [x] 상대별 화이트리스트/자율성 예외 매칭
|
||||
- [x] 에스컬레이션 로그 조회 API
|
||||
|
||||
**A2. 인증·보안**
|
||||
- [x] 로그인 세션/토큰 (`Session`, signup/login 시 Bearer 발급)
|
||||
- [x] `/invites`, `/admin/metrics` 접근 제어 (`ADMIN_API_TOKEN`)
|
||||
- [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`)
|
||||
|
||||
**A3. Flutter — 메신저답게 다듬기** (A1/A2 이후)
|
||||
- [ ] 대화 목록/연락처 UI를 서버 API에 연결
|
||||
- [ ] 온보딩 뼈대 확장 (말투 샘플 입력 UI)
|
||||
- [ ] L1 승인 플로우 UX 정리
|
||||
- [ ] 사후 알림 함
|
||||
- [ ] 에뮬레이터/실기기 E2E 수동 QA
|
||||
|
||||
##### B. 그다음 — 베타 품질
|
||||
- [ ] FCM 푸시 연동
|
||||
- [ ] 멀티 디바이스 동기화
|
||||
- [ ] drift + SQLCipher 로컬 저장
|
||||
- [ ] 말투 이력 기기 내 저장 + 서버 최소 전송
|
||||
- [ ] 데이터 흐름 표시 UI
|
||||
- [ ] 생성 지연시간·오류율 계측
|
||||
- [ ] 모니터링 대시보드(최소)
|
||||
- [ ] 본인확인 응답 문구 고정/검증
|
||||
|
||||
##### C. 베타 직전
|
||||
- [ ] Android 릴리즈 빌드·서명·배포 경로
|
||||
- [ ] 초대 코드 운영 절차(발급자 권한)
|
||||
- [ ] Q1~Q7 회의 확정 (제안 → 확정)
|
||||
- [ ] 클릭 프로토타입 공유 링크 docs 고정
|
||||
|
||||
##### D. 맨 마지막 — 사람 PoC (지금 안 함)
|
||||
- §3 항목과 동일. A~C 완료 후에만 착수.
|
||||
|
||||
##### E. 베타 이후 (지금은 설계만, 구현 금지)
|
||||
- Phase 2 L3 / Phase 3 OS 레이어 / Phase 4 L4·B2B
|
||||
|
||||
|
||||
## Phase 2 — L3 확장 + 베타 확대
|
||||
|
||||
- 자리비움 전면 응대(L3) 추가 — Phase 1에서 신뢰가 검증된 경우에만
|
||||
|
|
|
|||
|
|
@ -22,16 +22,22 @@ class ApiClient {
|
|||
|
||||
final http.Client _http;
|
||||
final String _base;
|
||||
String? authToken;
|
||||
|
||||
Uri _u(String path) => Uri.parse('$_base$path');
|
||||
|
||||
Map<String, String> _headers() => {
|
||||
'Content-Type': 'application/json',
|
||||
if (authToken != null && authToken!.isNotEmpty) 'Authorization': 'Bearer $authToken',
|
||||
};
|
||||
|
||||
Future<Map<String, dynamic>> _json(
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, dynamic>? body,
|
||||
}) async {
|
||||
final req = http.Request(method, _u(path));
|
||||
req.headers['Content-Type'] = 'application/json';
|
||||
req.headers.addAll(_headers());
|
||||
if (body != null) req.body = jsonEncode(body);
|
||||
final streamed = await _http.send(req);
|
||||
final res = await http.Response.fromStream(streamed);
|
||||
|
|
@ -42,24 +48,31 @@ class ApiClient {
|
|||
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<List<dynamic>> _jsonList(String path) async {
|
||||
final res = await _http.get(_u(path));
|
||||
Future<Map<String, dynamic>> _getObject(String path) async {
|
||||
final res = await _http.get(_u(path), headers: _headers());
|
||||
if (res.statusCode >= 400) {
|
||||
throw ApiException(res.statusCode, res.body);
|
||||
}
|
||||
return jsonDecode(res.body) as List<dynamic>;
|
||||
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<User> signup({required String inviteCode, required String displayName}) async {
|
||||
Future<({User user, String token})> signup({
|
||||
required String inviteCode,
|
||||
required String displayName,
|
||||
}) async {
|
||||
final json = await _json('POST', '/auth/signup', body: {
|
||||
'invite_code': inviteCode,
|
||||
'display_name': displayName,
|
||||
});
|
||||
// core-backend returns {id, display_name} only — keep the invite we sent.
|
||||
return User(
|
||||
final token = json['token'] as String? ?? '';
|
||||
authToken = token;
|
||||
return (
|
||||
user: User(
|
||||
id: json['id'] as int,
|
||||
displayName: json['display_name'] as String? ?? displayName,
|
||||
inviteCode: inviteCode,
|
||||
),
|
||||
token: token,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -108,10 +121,29 @@ class ApiClient {
|
|||
}
|
||||
|
||||
Future<List<WhitelistRule>> listWhitelist(int userId) async {
|
||||
final list = await _jsonList('/users/$userId/whitelist-rules');
|
||||
final obj = await _getObject('/users/$userId/whitelist-rules');
|
||||
final list = (obj['whitelist_rules'] as List<dynamic>? ?? const []);
|
||||
return list.map((e) => WhitelistRule.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> listConversations() async {
|
||||
final obj = await _getObject('/conversations');
|
||||
final list = (obj['conversations'] as List<dynamic>? ?? const []);
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> createConversation({
|
||||
required List<int> userIds,
|
||||
int? contactId,
|
||||
bool isGroup = false,
|
||||
}) async {
|
||||
return _json('POST', '/conversations', body: {
|
||||
'user_ids': userIds,
|
||||
'is_group': isGroup,
|
||||
if (contactId != null) 'contact_id': contactId,
|
||||
});
|
||||
}
|
||||
|
||||
Future<WhitelistRule> addWhitelist(int userId, String topicKeyword) async {
|
||||
final json = await _json('POST', '/users/$userId/whitelist-rules', body: {
|
||||
'topic_keyword': topicKeyword,
|
||||
|
|
|
|||
|
|
@ -16,14 +16,17 @@ class SessionState extends ChangeNotifier {
|
|||
static const _kUserId = 'user_id';
|
||||
static const _kDisplayName = 'display_name';
|
||||
static const _kInvite = 'invite_code';
|
||||
static const _kToken = 'session_token';
|
||||
|
||||
Future<void> restore() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final id = prefs.getInt(_kUserId);
|
||||
final name = prefs.getString(_kDisplayName);
|
||||
final invite = prefs.getString(_kInvite);
|
||||
final token = prefs.getString(_kToken);
|
||||
if (id != null && name != null && invite != null) {
|
||||
user = User(id: id, displayName: name, inviteCode: invite);
|
||||
_api.authToken = token;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
@ -33,12 +36,13 @@ class SessionState extends ChangeNotifier {
|
|||
error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final created = await _api.signup(inviteCode: inviteCode, displayName: displayName);
|
||||
user = created;
|
||||
final result = await _api.signup(inviteCode: inviteCode, displayName: displayName);
|
||||
user = result.user;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_kUserId, created.id);
|
||||
await prefs.setString(_kDisplayName, created.displayName);
|
||||
await prefs.setString(_kInvite, created.inviteCode);
|
||||
await prefs.setInt(_kUserId, result.user.id);
|
||||
await prefs.setString(_kDisplayName, result.user.displayName);
|
||||
await prefs.setString(_kInvite, result.user.inviteCode);
|
||||
await prefs.setString(_kToken, result.token);
|
||||
} on ApiException catch (e) {
|
||||
error = '가입 실패 (${e.statusCode}): ${e.body}';
|
||||
} catch (e) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue