feat: AI 대화 DB 영속 + 다중 대화/기록 목록

- AiConversation·AiMessage 엔티티(user/conversation CASCADE), 제목 자동 생성
- 대화 스코프 API: GET/POST /ai/conversations, GET/:id, POST/:id/messages, DELETE/:id (전부 본인 소유 스코프 → IDOR 차단)
- 클라는 새 메시지 1건만 전송, 서버가 DB 이력으로 Claude 호출(무상태 /ai/chat 폐기)
- Claude 실패 시 사용자 메시지·빈 대화 롤백(고아 데이터 방지)
- 프론트: AgentChatPanel 채팅↔지난 대화 목록 뷰(열기/삭제), ai.store 재작성(목록·현재 대화)
- 다른 기기·재로그인·새로고침에도 기록 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 08:58:40 +09:00
parent 5da3ec8818
commit e432832c92
13 changed files with 697 additions and 128 deletions
+75 -10
View File
@@ -1,32 +1,97 @@
import {
Body,
Controller,
DefaultValuePipe,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AiService } from './ai.service';
import { ChatDto } from './dto/chat.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import type { PaginatedResult } from '../../common/pagination/pagination';
import {
AiService,
type ConversationDetail,
type ConversationSummary,
type SendResult,
} from './ai.service';
import { SendMessageDto } from './dto/send-message.dto';
// AI 채팅 컨트롤러 — 인증 필요. 외부 API 호출 비용이 있어 라우트 단위 throttle 적용.
// AI 채팅 컨트롤러 — 인증 필요. 대화는 본인 소유만 접근(서비스에서 스코프).
@ApiTags('AI')
@Controller('ai')
@Controller('ai/conversations')
@UseGuards(JwtAuthGuard)
export class AiController {
constructor(private readonly aiService: AiService) {}
@Post('chat')
@Get()
@ApiOperation({ summary: '내 대화 목록 (최근순, 페이지네이션)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
list(
@CurrentUser() user: PublicUser,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
): Promise<PaginatedResult<ConversationSummary>> {
return this.aiService.listConversations(user.id, page, size);
}
@Get(':id')
@ApiOperation({ summary: '대화 상세 (메시지 포함)' })
@ApiResponse({ status: 200, description: '조회 성공' })
@ApiResponse({ status: 404, description: '대화 없음 (RES_001)' })
get(
@CurrentUser() user: PublicUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<ConversationDetail> {
return this.aiService.getConversation(user.id, id);
}
@Post()
@HttpCode(HttpStatus.CREATED)
@Throttle({ default: { limit: 20, ttl: 60_000 } })
@ApiOperation({ summary: '새 대화 시작 (첫 메시지)' })
@ApiResponse({ status: 201, description: '생성 + AI 응답' })
start(
@CurrentUser() user: PublicUser,
@Body() dto: SendMessageDto,
): Promise<SendResult> {
return this.aiService.startConversation(user.id, dto.content);
}
@Post(':id/messages')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 20, ttl: 60_000 } })
@ApiOperation({ summary: 'AI 채팅 (Claude)' })
@ApiOperation({ summary: '대화에 메시지 추가' })
@ApiResponse({ status: 200, description: 'AI 응답' })
@ApiResponse({ status: 502, description: 'AI 응답 실패 (BIZ_001)' })
async chat(@Body() dto: ChatDto): Promise<{ reply: string }> {
const reply = await this.aiService.chat(dto.messages);
return { reply };
@ApiResponse({ status: 404, description: '대화 없음 (RES_001)' })
send(
@CurrentUser() user: PublicUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SendMessageDto,
): Promise<SendResult> {
return this.aiService.addMessage(user.id, id, dto.content);
}
@Delete(':id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '대화 삭제' })
@ApiResponse({ status: 200, description: '삭제 성공' })
@ApiResponse({ status: 404, description: '대화 없음 (RES_001)' })
async remove(
@CurrentUser() user: PublicUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<null> {
await this.aiService.deleteConversation(user.id, id);
return null;
}
}
+5 -1
View File
@@ -1,9 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
import { AiConversation } from './entities/ai-conversation.entity';
import { AiMessage } from './entities/ai-message.entity';
// AI 모듈 — Claude(Anthropic) 채팅 프록시
// AI 모듈 — Claude(Anthropic) 채팅 프록시 + 대화/메시지 영속
@Module({
imports: [TypeOrmModule.forFeature([AiConversation, AiMessage])],
controllers: [AiController],
providers: [AiService],
})
+187 -7
View File
@@ -1,22 +1,48 @@
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BusinessException } from '../../common/exceptions/business.exception';
import type { ChatMessageDto } from './dto/chat.dto';
import {
paginated,
resolvePage,
type PaginatedResult,
} from '../../common/pagination/pagination';
import { AiConversation } from './entities/ai-conversation.entity';
import { AiMessage } from './entities/ai-message.entity';
// Anthropic Messages API 응답(필요 필드만)
interface AnthropicResponse {
content?: { type: string; text?: string }[];
}
// 외부 노출 형태
export interface ConversationSummary {
id: string;
title: string;
updatedAt: Date;
}
export interface ConversationDetail {
id: string;
title: string;
messages: { role: 'user' | 'assistant'; content: string }[];
}
export interface SendResult {
conversationId: string;
title: string;
reply: string;
}
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
const REQUEST_TIMEOUT_MS = 30_000;
const MAX_TOKENS = 1024;
const TITLE_MAX = 60;
const SYSTEM_PROMPT =
'당신은 업무 협업 도구 "Relay" 의 AI 어시스턴트입니다. ' +
'사용자의 질문에 한국어로 간결하고 정확하게 답하세요. ' +
'모르는 내용은 추측하지 말고 모른다고 답하세요.';
// AI(Claude) 채팅 서비스 — Anthropic Messages API 프록시.
// AI(Claude) 채팅 서비스 — 대화/메시지를 DB 에 보관하고 Anthropic Messages API 를 호출.
// 키(CLAUDE_API_KEY)는 서버 env 에만 두고 클라이언트에 노출하지 않는다.
@Injectable()
export class AiService {
@@ -24,7 +50,13 @@ export class AiService {
private readonly apiKey: string;
private readonly model: string;
constructor(config: ConfigService) {
constructor(
config: ConfigService,
@InjectRepository(AiConversation)
private readonly convRepo: Repository<AiConversation>,
@InjectRepository(AiMessage)
private readonly msgRepo: Repository<AiMessage>,
) {
this.apiKey = (config.get<string>('CLAUDE_API_KEY') ?? '').trim();
this.model = (
config.get<string>('CLAUDE_MODEL') ?? 'claude-sonnet-4-6'
@@ -38,8 +70,152 @@ export class AiService {
return this.apiKey.length > 0;
}
// 대화 이력을 받아 Claude 응답 텍스트를 반환
async chat(messages: ChatMessageDto[]): Promise<string> {
// 대화 목록(최근순, 페이지네이션)
async listConversations(
userId: string,
page?: number,
size?: number,
): Promise<PaginatedResult<ConversationSummary>> {
const params = resolvePage(page, size);
const [rows, total] = await this.convRepo.findAndCount({
where: { user: { id: userId } },
order: { updatedAt: 'DESC' },
skip: params.skip,
take: params.take,
});
return paginated(
rows.map((c) => ({ id: c.id, title: c.title, updatedAt: c.updatedAt })),
total,
params,
);
}
// 대화 상세(본인 소유만) — 메시지 시간순
async getConversation(
userId: string,
conversationId: string,
): Promise<ConversationDetail> {
const conv = await this.getOwnedConversation(userId, conversationId);
const messages = await this.msgRepo.find({
where: { conversation: { id: conv.id } },
order: { createdAt: 'ASC' },
});
return {
id: conv.id,
title: conv.title,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
};
}
// 새 대화 시작 — 첫 메시지로 대화 생성 후 응답
async startConversation(
userId: string,
content: string,
): Promise<SendResult> {
this.ensureEnabled();
const conv = await this.convRepo.save(
this.convRepo.create({
user: { id: userId },
title: this.makeTitle(content),
}),
);
return this.appendAndReply(conv, content);
}
// 기존 대화에 메시지 추가 후 응답(본인 소유만)
async addMessage(
userId: string,
conversationId: string,
content: string,
): Promise<SendResult> {
this.ensureEnabled();
const conv = await this.getOwnedConversation(userId, conversationId);
return this.appendAndReply(conv, content);
}
// 대화 삭제(본인 소유만) — 메시지 CASCADE
async deleteConversation(
userId: string,
conversationId: string,
): Promise<void> {
const conv = await this.getOwnedConversation(userId, conversationId);
await this.convRepo.remove(conv);
}
// 사용자 메시지 저장 → 전체 이력으로 Claude 호출 → 응답 저장 → 대화 updatedAt 갱신.
// Claude 실패 시 방금 저장한 사용자 메시지를 롤백(빈 대화면 대화도 삭제)해 고아 데이터 방지.
private async appendAndReply(
conv: AiConversation,
content: string,
): Promise<SendResult> {
const userMsg = await this.msgRepo.save(
this.msgRepo.create({
conversation: { id: conv.id },
role: 'user',
content,
}),
);
const history = await this.msgRepo.find({
where: { conversation: { id: conv.id } },
order: { createdAt: 'ASC' },
});
let reply: string;
try {
reply = await this.callClaude(
history.map((m) => ({ role: m.role, content: m.content })),
);
} catch (e) {
// 롤백 — 방금 사용자 메시지 제거, 남은 메시지가 없으면(첫 메시지였다면) 대화도 삭제
await this.msgRepo.delete(userMsg.id);
const remaining = await this.msgRepo.count({
where: { conversation: { id: conv.id } },
});
if (remaining === 0) {
await this.convRepo.delete(conv.id);
}
throw e;
}
await this.msgRepo.save(
this.msgRepo.create({
conversation: { id: conv.id },
role: 'assistant',
content: reply,
}),
);
// 최근 대화로 정렬되도록 updatedAt 갱신(@UpdateDateColumn)
await this.convRepo.save(conv);
return { conversationId: conv.id, title: conv.title, reply };
}
// 소유권 확인 — 본인 대화가 아니면 404(존재 비노출)
private async getOwnedConversation(
userId: string,
conversationId: string,
): Promise<AiConversation> {
const conv = await this.convRepo.findOne({
where: { id: conversationId, user: { id: userId } },
});
if (!conv) {
throw new BusinessException(
'RES_001',
'대화를 찾을 수 없습니다.',
HttpStatus.NOT_FOUND,
);
}
return conv;
}
private makeTitle(content: string): string {
const firstLine = content.trim().split('\n')[0].trim();
if (!firstLine) return '새 대화';
return firstLine.length > TITLE_MAX
? `${firstLine.slice(0, TITLE_MAX)}`
: firstLine;
}
private ensureEnabled(): void {
if (!this.enabled) {
throw new BusinessException(
'BIZ_001',
@@ -47,7 +223,12 @@ export class AiService {
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
// Anthropic Messages API 호출 — 실패 시 내부/키 노출 없이 일반 문구
private async callClaude(
messages: { role: 'user' | 'assistant'; content: string }[],
): Promise<string> {
let response: Response;
try {
response = await fetch(ANTHROPIC_URL, {
@@ -61,12 +242,11 @@ export class AiService {
model: this.model,
max_tokens: MAX_TOKENS,
system: SYSTEM_PROMPT,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
messages,
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (e) {
// 네트워크 오류/타임아웃 — 키나 내부 정보는 노출하지 않는다
this.logger.error(
`Anthropic 요청 실패: ${e instanceof Error ? e.message : String(e)}`,
);
-36
View File
@@ -1,36 +0,0 @@
import {
ArrayMaxSize,
ArrayNotEmpty,
IsArray,
IsIn,
IsNotEmpty,
IsString,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
// 대화 메시지 1건 — 역할(user/assistant)과 내용
export class ChatMessageDto {
@ApiProperty({ description: '역할', enum: ['user', 'assistant'] })
@IsIn(['user', 'assistant'], { message: '잘못된 메시지 역할입니다.' })
role!: 'user' | 'assistant';
@ApiProperty({ description: '메시지 내용' })
@IsString()
@IsNotEmpty({ message: '메시지 내용을 입력해 주세요.' })
@MaxLength(8000, { message: '메시지가 너무 깁니다.' })
content!: string;
}
// AI 채팅 요청 — 대화 이력 전체를 전달(서버는 무상태)
export class ChatDto {
@ApiProperty({ description: '대화 이력', type: [ChatMessageDto] })
@IsArray()
@ArrayNotEmpty({ message: '메시지를 입력해 주세요.' })
@ArrayMaxSize(50, { message: '대화가 너무 깁니다. 새 대화를 시작해 주세요.' })
@ValidateNested({ each: true })
@Type(() => ChatMessageDto)
messages!: ChatMessageDto[];
}
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 메시지 전송 — 대화 생성/이어가기 공통. 이력은 서버가 DB 에서 불러온다.
export class SendMessageDto {
@ApiProperty({ description: '사용자 메시지 내용' })
@IsString()
@IsNotEmpty({ message: '메시지 내용을 입력해 주세요.' })
@MaxLength(8000, { message: '메시지가 너무 깁니다.' })
content!: string;
}
@@ -0,0 +1,33 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// AI 대화 — 사용자별 채팅 스레드. 메시지는 AiMessage 로 분리(1:N).
@Entity('ai_conversations')
export class AiConversation {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'user_id' })
user!: Relation<User>;
// 첫 사용자 메시지로 자동 생성되는 제목
@Column({ type: 'varchar', length: 120 })
title!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
// 새 메시지 추가 시 갱신(최근 대화 정렬용)
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -0,0 +1,30 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { AiConversation } from './ai-conversation.entity';
// AI 대화 메시지 1건 — 대화에 종속(CASCADE).
@Entity('ai_messages')
export class AiMessage {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => AiConversation, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'conversation_id' })
conversation!: Relation<AiConversation>;
@Column({ type: 'varchar' })
role!: 'user' | 'assistant';
@Column({ type: 'text' })
content!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}