Closes the biggest content gap found against PRD.md §3.1 P0: group chat catch-up was one of only two v1 MVP scenarios and had zero implementation. Backend (core-backend): - ConversationParticipant.LastReadMessageID read marker. - POST /conversations/:id/read advances the caller's marker (never backward). - GET /conversations/:id/summary builds context from messages since that marker and calls ai-service's new POST /summarize; returns unread_count and needs_reply. - GET /conversations now reports unread_count per room. - Safety: group conversations now unconditionally block twin-authored sends (POST /conversations/:id/messages), regardless of the sender's global autonomy level. PRD.md §2.3-③ requires this scenario stay L0-fixed with no auto-send; autonomy level is a per-user global setting today, so this closes the only path a global L2 whitelist match could otherwise auto-send into a group. AI service (ai-service): new summarize.py module (same Gemini-call shape as generation.py's draft_reply, no escalation/identity gating since nothing generated here is ever sent) + POST /summarize. Mobile: "새 대화" dialog now supports adding/removing multiple peer fields (2+ peers -> is_group:true automatically); unread badge on conversation rows; ChatScreen takes isGroup and renders its L1 panel as L0-locked for groups; new "안 본 동안 요약" AppBar action opens a dialog with the summary and, when a reply looks needed, a button that feeds straight into the existing draft-request flow. Verified with a real Flutter build against a live core-backend + ai-service instance (Playwright driving 3 demo accounts through group creation, unread badges, and the summary dialog) — this caught a real ordering bug: marking the read marker on chat *open* meant the summary was always empty by the time you could tap it, since opening the room already advanced the marker past everything you'd come to catch up on. Fixed by marking read on screen *exit* (dispose) instead, so the marker reflects what was unread that whole visit and updates once you leave. go test / pytest / flutter analyze+test all pass. |
||
|---|---|---|
| .. | ||
| .dockerignore | ||
| Dockerfile | ||
| README.md | ||
| a1_a2_routes.go | ||
| a1_a2_test.go | ||
| aiservice.go | ||
| auth.go | ||
| b_routes.go | ||
| b_test.go | ||
| config.go | ||
| db.go | ||
| demo.go | ||
| demo_test.go | ||
| go.mod | ||
| go.sum | ||
| group_summary.go | ||
| group_summary_test.go | ||
| invite_ops.go | ||
| invite_ops_test.go | ||
| main.go | ||
| main_test.go | ||
| metrics.go | ||
| models.go | ||
| push.go | ||
| relay.go | ||
README.md
와카뷰 core-backend (Go)
Phase 1 코어 백엔드 (docs/roadmap.md Phase 1 §2.1). 스택 결정은 docs/tech-design.md §8 참고 —
Go(Gin + gorilla/websocket + GORM), PostgreSQL(프로덕션)/SQLite(로컬 개발). ../backend/(Python
프로토타입)의 동작을 그대로 재현한 것이다 — API·DB 스키마는 거기서 이미 검증된 것과 동일하다.
실행
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 .
기본은 sqlite:./dev.db로 뜬다. 프로덕션 DB를 쓰려면:
export DATABASE_URL="postgres://user:pass@host/dbname"
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,/admin/dashboard,/admin/push-test는Authorization: Bearer $ADMIN_API_TOKEN(/admin/dashboard는?token=쿼리 가능)- 초대 운영:
POST /invites(note,expires_in_days,count),GET /invites,POST /invites/:code/revoke— 절차는docs/invite-ops.md
메시지 생성(POST /conversations/:id/messages)은 전체 메시지 JSON을 반환한다
(id, conversation_id, sender_id, sender_mode, text, retracted, created_at).
Phase 1 B (베타 품질) 추가분:
- draft/escalate 지연·오류율 →
/admin/metrics의draft_*/escalate_*필드 (프로세스 메모리) GET /admin/dashboard— 최소 HTML 대시보드POST|GET /users/:id/device-tokens— FCM 토큰 등록(전송은 후속)GET /users/:id/sessions— 활성 세션 목록(멀티 디바이스 1차)DELETE /users/:id/sessions/:sessionId— 세션 종료POST /admin/push-test— FCM 스모크 테스트 (FCM_SERVER_KEY필요, 없으면 skipped)- 에스컬레이션 시 사용자 디바이스로 푸시 시도 (
notifyUser)
테스트
go test ./... -v
main_test.go가 초대 코드 발급/검증(400·409 포함)/메시지 저장/존재하지 않는 대화방(404)/WebSocket
브로드캐스트/초안 생성 프록시/에스컬레이션 하드게이트(차단·통과·게이트 불능 시 fail-safe)/자율성
플로우(L0 차단·L1 승인·L2 화이트리스트 자동발송·L2 비대상 승인 필요·에스컬레이션의 레벨 무관 우선)/
거부권(peer veto, 다른 모든 조건보다 우선)/화이트리스트 규칙 CRUD/메시지 되돌리기(twin 전용, WebSocket
브로드캐스트 포함)/계정 삭제/운영 지표까지 전부 mock AI 서비스로 실제로 돌려서 확인한다
(../backend/의 Python TestClient 테스트와 동일한 케이스 + Go에서 새로 추가된 것들).
지금 있는 것
2.1 코어 백엔드
GET /health— 헬스체크POST /auth/signup— 초대 코드 기반 가입. 미리 발급된(POST /invites) 미사용 코드가 아니면 400, 이미 쓴 코드면 409 (아래 2.6)POST /conversations/{id}/messages— 메시지 저장 + 같은 대화방 WebSocket 커넥션에 브로드캐스트.sender_mode: "twin"인 요청은 저장 전에 반드시 거부권·에스컬레이션 하드게이트를 통과해야 한다 (아래 2.4)POST /conversations/{id}/veto— 거부권 발동: 이 대화방의twin_disabled_by_peer를 켠다. 이후 이 대화방에서는 어떤 자율성 레벨·화이트리스트·승인 여부와도 무관하게 트윈 자동발송이 전부 차단된다 (아래 2.4). v1은 한 방향(끄기만 가능, 되돌리는 API 없음)POST /messages/{id}/retract— 되돌리기(one-tap undo, PRD.md §3.1): 트윈이 자동발송한 메시지만 대상. 사람이 직접 쓴 메시지는 400. 이미 되돌린 메시지를 다시 호출하면 409. 성공하면Message.Retracted를 켜고 같은 대화방의 WebSocket 연결에{"type": "retraction", "id": ...}를 브로드캐스트한다 (아래 참고: 일반 메시지 브로드캐스트도 이제"type": "message"를 포함해서 클라이언트가 두 이벤트를 구분할 수 있게 함)GET /ws/conversations/{id}(WebSocket 업그레이드) — 대화방별 실시간 릴레이 (인메모리 커넥션 매니저)DELETE /users/{id}— 계정 삭제("초기화"): 해당 유저가 걸린 모든 행(트윈 설정·화이트리스트· 연락처·대화 참여·메시지·에스컬레이션 로그·유저 본인)을 트랜잭션으로 삭제하고, 그 유저가 쓴 초대 코드는 "사용됨" 상태는 유지한 채 유저 참조만 지운다 (tech-design.md§5 "사용자가 언제든 초기화 가능")- DB 모델 (
models.go):users,invite_codes,contacts,conversations,conversation_participants,messages,twin_settings,whitelist_rules,escalation_logs—../backend/app/models.py와 동일한 스키마(+invite_codes는 여기서 새로 추가)
2.2 AI 서비스 연동
POST /conversations/{id}/draft—ai-service/의POST /draft를 호출해 초안을 프록시 반환 (style_examples/history중 하나 필수, 없으면 400)
2.4 안전장치 통합
- 트윈 발송 시 체크 순서: 거부권 → 에스컬레이션 → 자율성 레벨. 앞 단계에서 막히면 뒤 단계는 아예 확인하지 않는다 — 순서가 바뀌면 안 되는 이유는 거부권/에스컬레이션이 레벨·화이트리스트· 승인 여부보다 항상 우선해야 하기 때문 (테스트로 확인함)
- 거부권:
conversation.TwinDisabledByPeer가 켜져 있으면 그 자리에서 403 — AI 서비스 호출조차 하지 않는다. 사람이 직접 보내는 메시지는 영향받지 않는다 AIServiceClient.checkEscalation(aiservice.go) —ai-service의POST /escalate/check호출./conversations/{id}/messages가sender_mode: "twin"을 받을 때마다 이걸 호출해서, 어떤 경로로 왔든(그리고/draft를 거쳤든 안 거쳤든) 트윈 자동발송은 전부 이 게이트를 통과하게 만드는 하나의 초크포인트다. 에스컬레이션되면 메시지는 저장·브로드캐스트되지 않고escalation_logs에 기록만 남는다(403). AI 서비스가 응답하지 않으면 fail-safe로 발송 자체를 막는다(502) — 게이트를 확인 못 했다는 불확실성을 자동발송 허용 쪽으로 풀지 않는다.- 사람이 직접 보내는 메시지(
sender_mode기본값human)는 이 게이트를 타지 않는다 — 본인이 직접 쓴 말을 막을 이유가 없다.
2.2/2.5 자율성 엔진(L0~L2) 최소 오케스트레이션 (PRD.md §2.1/§2.2, roadmap.md §2.5 QA 작업 중 필요해져서 구현)
PATCH /users/{id}/twin-settings—{"autonomy_level": "L0"|"L1"|"L2"}로 전역 자율성 레벨 변경 (가입 시 기본값은 L0, PRD.md §2.1)POST/GET /users/{id}/whitelist-rules,DELETE /users/{id}/whitelist-rules/{ruleId}— 화이트리스트 규칙 CRUD ({"topic_keyword": "...", "contact_id": null|uint}).contact_id는 저장은 되지만 아직 매칭에는 안 쓰인다 — 바로 아래 참고- 에스컬레이션 통과 후 트윈 발송이면 레벨을 확인: L0은 항상 차단(403, 초안만 가능), L1은
요청에
approved: true가 없으면 차단(403), L2는whitelist_rules에 매칭되는 주제면approved없이도 즉시 발송, 매칭이 없으면 L1과 동일하게 승인 필요 - 화이트리스트 매칭(
whitelistMatches)은 v1 최소 구현 — 유저의 모든WhitelistRule.TopicKeyword를 메시지 텍스트에 부분 문자열로 매칭.WhitelistRule.ContactID(상대별 화이트리스트)는 CRUD로 저장은 되지만 매칭 로직에서는 아직 무시함 — 대화방↔연락처 연결이 아직 모델링되어 있지 않아서 (클라이언트 연락처 모델이 생긴 뒤에 다시 설계 필요) - 레벨/화이트리스트와 무관하게 에스컬레이션이 항상 우선한다 — L2 화이트리스트 매칭 +
approved: true여도 에스컬레이션 대상이면 무조건 차단 (테스트로 확인함)
2.6 베타 배포 준비
POST /invites— 새 초대 코드 발급(무작위 10자 hex). 아직 발급자 인증이 없어 누구나 호출 가능 — 인증/세션이 생기기 전까지는 서버 콘솔·내부 도구에서만 호출한다고 가정GET /admin/metrics—users_total·messages_human_total·messages_twin_total·escalations_total·escalations_by_reason·conversations_total·conversations_vetoed·peer_veto_rate·invites_minted·invites_used. 지금 스키마로 정직하게 계산 가능한 것만 —peer_veto_rate는 vision.md "와카뷰 거부율" 지표의 1차 근사치(대화방 단위)이지 확정 정의는 아님. 생성 지연시간·AI 서비스 오류율은 별도 계측/로깅 계층이 없어서 넣지 않음 (아래 "아직 없는 것")
아직 없는 것 (다음 워크스트림)
- 되돌리기/사후알림 UX 고도화 (Flutter A3)
- 온디바이스 말투 이력 저장 + 서버 최소 전송 (클라이언트 책임)
- 데이터 흐름 대시보드, 온디바이스 암호화 (Flutter)
- 생성 지연시간·오류율 계측
- 푸시 알림 연동
- 멀티 디바이스 동기화
- Atlas/golang-migrate 등 버전드 마이그레이션으로 승격 (지금은
go run . migrate+ AutoMigrate)