Merge planning branch: Phase 1 A1/A2 APIs

Co-authored-by: okuma <o0kuma@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-07-30 04:47:23 +00:00
commit 8c1f9f0b0c
No known key found for this signature in database
11 changed files with 898 additions and 77 deletions

View File

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

View File

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

View File

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

185
core-backend/a1_a2_test.go Normal file
View File

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

107
core-backend/auth.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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에서 신뢰가 검증된 경우에만

View File

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

View File

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