Skip to content

feat(diarychat): Screen 3 voice-to-text chatroom v1 (long-polling + AI welcome/auto-response)#41

Merged
rivkode merged 7 commits into
devfrom
feat/diary-chatroom-v1
Apr 25, 2026
Merged

feat(diarychat): Screen 3 voice-to-text chatroom v1 (long-polling + AI welcome/auto-response)#41
rivkode merged 7 commits into
devfrom
feat/diary-chatroom-v1

Conversation

@rivkode
Copy link
Copy Markdown
Owner

@rivkode rivkode commented Apr 25, 2026

Summary

docs/prd/backend_api_request.md §7(Screen 3) 백엔드 v1을 단일 PR로 구현했습니다. 핵심 결정 → AI 자동 응답(방 단위 토글, default ON) + 일기 3줄 기반 웰컴 메시지 + 롱폴링 단일 인스턴스 + 20명 정원 + 90일 보존.

  • 음성 채팅이 아니라 텍스트 채팅입니다. 클라이언트가 로컬 STT/TTS 후 텍스트만 전송. 서버는 audioUrl 메타만 보관.
  • 단일 인스턴스 전제. DiaryChatPollingHubConcurrentHashMap<roomId, List<DeferredResult>> 로 fan-out.

마일스톤별 커밋

Commit 마일스톤 범위
M1 feat(diarychat): add diary chat room domain entities and repositories 도메인 + V6 room/participant/message 테이블, 엔티티, repositories. events는 diary_chat_messagesource=system + event_type 으로 통합 (단일 cursor 모델)
M2 feat(diarychat): add chatroom CRUD, join/leave, AI-toggle endpoints 방 API POST/GET/join/leave/ai-toggle, 20-participant cap, host-only ai-toggle, idempotent room creation, 비공개 일기 차단
M3 feat(diarychat): add message send and history endpoints 메시지 POST/GET messages, 2000자 제한, before-cursor 30개 default, hasMore size+1 detection
M4 feat(diarychat): add long-polling endpoint with DeferredResult 폴링 DeferredResult + per-subscriber after, 1~60초 wait clamp, 7일 cursor 만료 410
M5 feat(diarychat): add AI welcome and auto-response (Spring AI ChatClient) AI 방 생성 시 일기 기반 웰컴 + 사용자 메시지마다 자동 응답. ApplicationEventPublisher + @TransactionalEventListener(AFTER_COMMIT) + @async, ai_typing/ai_failed 이벤트
M6 feat(diarychat): add 90-day message retention scheduler 보존 매일 04:00 UTC @Scheduled 배치
M7 docs(prd): mark Screen 3 ... as implemented 문서 PRD §7 ✅ 표기 + 결정 매트릭스 + spec diff

결정 7항 회신 (PRD §7.0 에도 기록)

# 항목 결정
1 AI 응답 생성 주체 Spring AI ChatClient 재사용 + 채팅방 전용 prompt(DiaryChatAiService). 페르소나 /chat/generate 재사용 X
2 AI 트리거 방 단위 토글 (default ON) + 사용자 메시지마다 자동. AI 자기 메시지 무시 (loop guard). 방 첫 생성 시 일기 3줄 기반 웰컴
3 참여자 상한 20명 (CHATROOM_PARTICIPANT_LIMIT 409)
4 메시지 보존 90일 hard delete (매일 04:00 UTC)
5 POLL_CURSOR_EXPIRED after 7일 경과 또는 보존 정책으로 삭제된 messageId → 410
6 AI 지연 최적화 AFTER_COMMIT 이벤트 + @async + ai_typing/ai_failed 이벤트 채널
7 참여 권한 공개 일기에 연결된 방은 로그인 사용자 누구나 참여 가능. 비공개 일기 → DIARY_FORBIDDEN

Spec 대비 구현 차이 (PRD §7.0.1)

  • events 저장 모델 — 별도 테이블 대신 diary_chat_message.source=SYSTEM + event_type 으로 통합. 응답 shape 은 spec 그대로 (items/events 분리). 이유: after={messageId} 단일 cursor 정합성 보장
  • aiAssistantEnabled 기본값 — spec false → true 로 변경(사용자 결정)
  • AI 식별자author.userId = -1, username = "Jamo AI" (별도 필드 없이 author 객체에 담김)
  • 방장 leave 정책 — spec 미지정 → v1 = 방장 leave 불가 (CHATROOM_FORBIDDEN). 방 삭제는 v2

신규 파일

db/migration/V6__add_diary_chat_tables.sql

domain/diarychat/
  DiaryChatRoom.kt, Participant.kt, Message.kt
  DiaryChatMessageSource.kt (USER|AI|SYSTEM), DiaryChatEventType.kt
  DiaryChatReader.kt, Writer.kt
  exception/ Chatroom{NotFound,Forbidden,MessageTooLong,ParticipantLimit}, PollCursorExpired

infrastructure/diarychat/
  DiaryChat{Room,Participant,Message}Repository.kt
  DiaryChatReaderImpl.kt, WriterImpl.kt

application/diarychat/
  DiaryChatRoomFacade.kt, MessageFacade.kt, PollingFacade.kt, AiService.kt
  DiaryChatAiEventListener.kt (AFTER_COMMIT + @Async)
  DiaryChatPollingHub.kt (DeferredResult registry)
  DiaryChatPollViewAssembler.kt (items/events 분리)
  DiaryChatAuthorResolver.kt
  DiaryChatRetentionScheduler.kt
  DiaryChatRoomEvents.kt, View.kt, Command.kt

interfaces/diarychat/
  DiaryChatRoomController.kt, MessageController.kt, PollingController.kt
  DiaryChatDto.kt

⚠️ 운영 메모

  • 단일 인스턴스 전제. 다중 배포 시 Redis Pub/Sub fan-out 필요 (v2 작업)
  • 새 ErrorCode: CHATROOM_NOT_FOUND/FORBIDDEN/MESSAGE_TOO_LONG/PARTICIPANT_LIMIT, POLL_CURSOR_EXPIRED. ExceptionHandler 매핑 추가됨
  • Tomcat async thread pool: DeferredResult 가 요청 스레드를 즉시 release 하므로 기본 200 max-threads 로도 수천 동시 폴링 처리 가능. 추가 튜닝 불필요
  • Spring AI ChatClient = gpt-4o-mini (기존 설정 그대로). 별도 모델 설정 없음

Test plan

  • V6 마이그레이션이 로컬 MySQL 에 정상 적용 (4개 테이블)
  • POST /api/v1/chatrooms body {diaryId, aiAssistantEnabled:true} → 신규 생성 시 host 자동 join + AI 웰컴 메시지가 polling 으로 도착
  • 동일 diaryId 재호출 → 동일 roomId 반환 (idempotent), 웰컴 X
  • POST /messagesGET /poll?after=N&wait=25 → user 메시지 즉시 + 잠시 후 ai_typing 이벤트 + AI 응답 메시지 도착
  • AI 토글 OFF 후 메시지 보내면 AI 응답 없음, ai_toggle_changed 이벤트만 폴링됨
  • 21번째 join → CHATROOM_PARTICIPANT_LIMIT 409
  • 비공개 일기로 방 생성 시도 → DIARY_FORBIDDEN 403
  • 폴링 wait=25 시 빈 방에서 25초 후 200 + 빈 items/events
  • after 가 7일 경과한 messageId 또는 무효한 id → 410 POLL_CURSOR_EXPIRED

🤖 Generated with Claude Code

rivkode and others added 7 commits April 25, 2026 01:37
- Flyway V6: diary_chat_room (unique per diary), diary_chat_participant,
  diary_chat_message (source=user|ai|system with optional event_type
  metadata for system rows so join/leave/ai_toggle/ai_typing/ai_failed
  events share the message timeline and single poll cursor)
- Kotlin entities with auto-managed counters (participantCount) and
  last_activity_at for pruning/freshness checks
- DiaryChatRoom.aiAssistantEnabled defaults to true so rooms are
  chat-with-AI by default; host can disable
- Spring Data repositories with helpers for cursor-based history
  (findBeforeDesc / findAfter), retention pruning (deleteOlderThan),
  and idempotent participant counting
- Constants: 20-participant cap, 2000-char message length

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Endpoints (all require Bearer auth):
- POST   /chatrooms                  idempotent by diaryId; first call
  creates room, auto-joins creator as host, records participant_joined
  system event. Rejects private diaries with DIARY_FORBIDDEN
- GET    /chatrooms/{id}             room metadata
- GET    /chatrooms/{id}/participants
- POST   /chatrooms/{id}/join        idempotent; enforces 20-participant
  cap (CHATROOM_PARTICIPANT_LIMIT 409)
- POST   /chatrooms/{id}/leave       host cannot leave (CHATROOM_FORBIDDEN)
- POST   /chatrooms/{id}/ai-toggle   host-only; broadcasts ai_toggle_changed

Architecture
- DiaryChatReader/Writer interfaces + impls wrap the JPA repositories
- DiaryChatRoomFacade orchestrates room lifecycle + event emission
- Events (participant_joined/left, ai_toggle_changed) persisted as
  source=SYSTEM rows in diary_chat_message with JSON payload so both
  messages and events share a single cursor (messageId)
- DiaryChatPollViewAssembler projects system rows into events[] and
  user/ai rows into items[] for the poll API (M4)
- DiaryChatPollingHub skeleton (ConcurrentHashMap<roomId, DeferredResult>
  registry) — subscribe/unsubscribe + notifyRoom hook; DeferredResult
  wiring completes in M4
- DiaryChatAuthorResolver batches User lookup; defines the synthetic
  AI author (userId=-1, username="Jamo AI")

Errors
- CHATROOM_NOT_FOUND (404), CHATROOM_FORBIDDEN (403),
  CHATROOM_PARTICIPANT_LIMIT (409), POLL_CURSOR_EXPIRED (410)
- New GlobalExceptionHandler mappings for the 409/410 cases

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- POST /chatrooms/{id}/messages
  - Requires caller to be a room participant (CHATROOM_FORBIDDEN)
  - Rejects empty/oversize text with CHATROOM_MESSAGE_TOO_LONG (≤ 2000)
  - Persists with source=USER, touches lastActivityAt, notifies pollers
  - AI response hook is deferred to M5 (keyed on room.aiAssistantEnabled)
- GET /chatrooms/{id}/messages?before=&size=
  - Cursor by messageId; default size 30, capped 1..100
  - Fetches size+1 and drops the oldest row to detect hasMore
  - Returns items ascending by messageId, oldestMessageId for paging
  - Filters out SYSTEM rows so events never pollute the history view

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- GET /chatrooms/{id}/messages/poll?after=&wait=
  - Immediate DB check: returns at once if items/events exist after cursor
  - Otherwise registers a DeferredResult subscription, configures
    onTimeout → empty 200 response (never 408), onCompletion cleanup
  - wait is clamped to 1..60 seconds to stay under typical gateway limits
- DiaryChatPollingHub holds per-room CopyOnWriteArrayList of
  (after, DeferredResult); notifyRoom(roomId, supplier) asks the
  assembler for each subscriber's own after and only wakes those whose
  view is non-empty (no busy-spin on no-op notifications)
- Cursor validation
  - after=0 always accepted (first connection)
  - after beyond last message: accepted (client ahead of server)
  - after refers to purged message OR createdAt > 7 days old:
    410 POLL_CURSOR_EXPIRED so the client reloads history
- RoomFacade/MessageFacade switched to supplier form
  `{ after -> assembler.pollForRoom(roomId, after) }` so every waiter
  sees only the delta it needs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- DiaryChatAiService.sendWelcome runs once when a room is first created;
  builds a Korean tutor prompt seeded with the diary's three lines and
  posts an AI message inviting participants to chat
- DiaryChatAiService.respondToUserMessage runs after every user-source
  message (room.aiAssistantEnabled=true) using the last 20 non-system
  messages as conversational context. AI-source messages do not trigger
  recursion (loop guard at the entry)
- AI flow announces ai_typing immediately and falls back to ai_failed
  when the model call throws; both ride the existing system-message
  event channel so pollers see them through the single cursor
- Triggering uses Spring's ApplicationEventPublisher with
  TransactionalEventListener(AFTER_COMMIT) + @async, so the AI work
  starts only after the originating transaction commits and runs on
  the existing async executor (no nested-transaction reads of
  uncommitted rows)
- ChatClient is reused (not /chat/generate persona service) with a
  chatroom-specific prompt; fallback strings keep the room alive on
  AI errors
- DiaryChatMessageRepository.findRecentNonSystem + Reader.findRecentNonSystemMessages added for prompt history assembly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Daily cron at 04:00 UTC drops chat messages older than 90 days
  (covers user/AI/system rows). Reuses the existing @EnableScheduling
  context wired by AuthPropertiesConfig
- Aligns with the 7-day POLL_CURSOR_EXPIRED window (any cursor that
  could survive retention is still recent enough to validate)
- Transactional wrapper required by @Modifying delete query

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Flag §7 header as ✅ implementation complete
- Add §7.0 decision matrix answering the seven open questions raised
  by the original PRD (AI subject/trigger, participant cap, retention,
  cursor expiry, async hop, permission policy)
- Add §7.0.1 spec-vs-implementation diff:
  events stored on diary_chat_message via source=system + event_type
  (single cursor model); aiAssistantEnabled default flipped to true;
  AI identity (-1, "Jamo AI"); single-instance polling; host cannot
  leave; audioUrl stored as-is

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant