From b288d5ed902a2bc9c8b86e27c5f8015616751dc4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 04:47:06 +0000 Subject: [PATCH] Implement Phase 1 A1/A2 APIs and record build plan in roadmap Add conversation/contact/history/escalation-log endpoints, contact-scoped whitelist matching, bearer sessions, ADMIN_API_TOKEN guards, and `go run . migrate`. Update Flutter client to persist/send session tokens and mark A1/A2 complete in the prioritized checklist. Co-authored-by: okuma --- README.md | 7 +- core-backend/README.md | 26 +-- core-backend/a1_a2_routes.go | 345 ++++++++++++++++++++++++++++ core-backend/a1_a2_test.go | 185 +++++++++++++++ core-backend/auth.go | 107 +++++++++ core-backend/main.go | 67 +++++- core-backend/main_test.go | 109 ++++++--- core-backend/models.go | 11 + docs/roadmap.md | 50 +++- mobile/lib/services/api_client.dart | 54 ++++- mobile/lib/state/session_state.dart | 14 +- 11 files changed, 898 insertions(+), 77 deletions(-) create mode 100644 core-backend/a1_a2_routes.go create mode 100644 core-backend/a1_a2_test.go create mode 100644 core-backend/auth.go diff --git a/README.md b/README.md index 4acd409..ceea6e4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/core-backend/README.md b/core-backend/README.md index 0ada203..3d47140 100644 --- a/core-backend/README.md +++ b/core-backend/README.md @@ -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) diff --git a/core-backend/a1_a2_routes.go b/core-backend/a1_a2_routes.go new file mode 100644 index 0000000..e8a7d1c --- /dev/null +++ b/core-backend/a1_a2_routes.go @@ -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 +} diff --git a/core-backend/a1_a2_test.go b/core-backend/a1_a2_test.go new file mode 100644 index 0000000..1613699 --- /dev/null +++ b/core-backend/a1_a2_test.go @@ -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) + } +} diff --git a/core-backend/auth.go b/core-backend/auth.go new file mode 100644 index 0000000..07c6691 --- /dev/null +++ b/core-backend/auth.go @@ -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 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 +} diff --git a/core-backend/main.go b/core-backend/main.go index d503539..e13a15e 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -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() diff --git a/core-backend/main_test.go b/core-backend/main_test.go index 5661934..275ab89 100644 --- a/core-backend/main_test.go +++ b/core-backend/main_test.go @@ -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 { diff --git a/core-backend/models.go b/core-backend/models.go index e5c850b..47ec75b 100644 --- a/core-backend/models.go +++ b/core-backend/models.go @@ -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{}, diff --git a/docs/roadmap.md b/docs/roadmap.md index f611381..1a8e786 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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에서 신뢰가 검증된 경우에만 diff --git a/mobile/lib/services/api_client.dart b/mobile/lib/services/api_client.dart index 4df6293..88afd34 100644 --- a/mobile/lib/services/api_client.dart +++ b/mobile/lib/services/api_client.dart @@ -22,16 +22,22 @@ class ApiClient { final http.Client _http; final String _base; + String? authToken; Uri _u(String path) => Uri.parse('$_base$path'); + Map _headers() => { + 'Content-Type': 'application/json', + if (authToken != null && authToken!.isNotEmpty) 'Authorization': 'Bearer $authToken', + }; + Future> _json( String method, String path, { Map? 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; } - Future> _jsonList(String path) async { - final res = await _http.get(_u(path)); + Future> _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; + return jsonDecode(res.body) as Map; } - Future 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( - id: json['id'] as int, - displayName: json['display_name'] as String? ?? displayName, - inviteCode: inviteCode, + 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> 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? ?? const []); return list.map((e) => WhitelistRule.fromJson(e as Map)).toList(); } + Future>> listConversations() async { + final obj = await _getObject('/conversations'); + final list = (obj['conversations'] as List? ?? const []); + return list.cast>(); + } + + Future> createConversation({ + required List 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 addWhitelist(int userId, String topicKeyword) async { final json = await _json('POST', '/users/$userId/whitelist-rules', body: { 'topic_keyword': topicKeyword, diff --git a/mobile/lib/state/session_state.dart b/mobile/lib/state/session_state.dart index 343ee0d..7ad5124 100644 --- a/mobile/lib/state/session_state.dart +++ b/mobile/lib/state/session_state.dart @@ -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 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) {