diff --git a/docs/project-solution.md b/docs/project-solution.md index ab82faa..3a96690 100644 --- a/docs/project-solution.md +++ b/docs/project-solution.md @@ -197,33 +197,57 @@ chat-one-service/ ### 4.2 客户端 AI Chat 接口(流式) - `POST /api/client/v1/chat/completions/stream` - - 说明:统一接口,后台自动路由平台 + - 说明:统一接口,后台自动路由平台;支持会话续聊 - 入参示例: ```json { - "model": "gpt-4o-mini-like", + "model": "qwen-plus", "messages": [ { "role": "system", "content": "你是助手" }, { "role": "user", "content": "介绍一下NestJS" } ], - "temperature": 0.7, - "platform": "auto" + "platform": "auto", + "sessionId": "1" } ``` + - 备注: + - `sessionId` 必填,需先调用创建会话接口; + - `meta` 会返回 `sessionId`(字符串),前端可缓存用于后续续聊。 + - `done` 前会先落库用户消息与会话标题,便于列表即时刷新。 - 返回:`text/event-stream`(SSE) -- `POST /api/client/v1/chat/completions/stream/:platform` - - 说明:指定平台(`qwen | volc | deepseek`) - - 其余参数同上 +### 4.3 客户端会话接口 -### 4.3 建议统一 SSE 事件格式 +- `POST /api/client/v1/chat/sessions` + - 说明:创建空会话(推荐在首次发送前调用) + - 入参:`{ title? }` + - 出参:`{ id, userId, title, createdAt, updatedAt }` + +- `GET /api/client/v1/chat/sessions?limit=&offset=` + - 说明:会话列表(按 `updatedAt desc`) + - 出参:`{ items, total, limit, offset }` + +- `GET /api/client/v1/chat/sessions/:sessionId/messages?limit=&offset=` + - 说明:分页查询会话消息 + - 出参:`{ sessionId, items, total, limit, offset }` + +- `DELETE /api/client/v1/chat/sessions/:sessionId` + - 说明:删除会话并级联删除该会话下全部消息 + - 出参:删除成功后返回被删除会话信息 + +- `PATCH /api/client/v1/chat/sessions/:sessionId/title` + - 说明:修改会话标题(可传空字符串清空) + - 入参:`{ title }` + - 出参:更新后的会话信息 + +### 4.4 建议统一 SSE 事件格式 ```text event: meta -data: {"requestId":"xxx","platform":"qwen","model":"qwen-turbo"} +data: {"requestId":"xxx","platform":"qwen","model":"qwen-plus","sessionId":"1"} event: delta -data: {"content":"你好"} +data: {"delta":"你好"} event: usage data: {"promptTokens":120,"completionTokens":80,"totalTokens":200} @@ -235,7 +259,7 @@ event: error data: {"code":"PLATFORM_TIMEOUT","message":"upstream timeout"} ``` -### 4.4 管理端认证接口(邮箱) +### 4.5 管理端认证接口(邮箱) - `POST /api/admin/v1/auth/login` - 入参:`{ email, password }` @@ -244,21 +268,21 @@ data: {"code":"PLATFORM_TIMEOUT","message":"upstream timeout"} - `POST /api/admin/v1/auth/refresh` - `POST /api/admin/v1/auth/logout` -### 4.5 管理端用户管理 +### 4.6 管理端用户管理 - `GET /api/admin/v1/users` - `GET /api/admin/v1/users/:id` - `PATCH /api/admin/v1/users/:id/status`(启用/禁用) - `PATCH /api/admin/v1/users/:id/role` -### 4.6 管理端平台管理 +### 4.7 管理端平台管理 - `GET /api/admin/v1/platforms` - `POST /api/admin/v1/platforms` - `PATCH /api/admin/v1/platforms/:id` - `PATCH /api/admin/v1/platforms/:id/health-check` -### 4.7 管理端统计 +### 4.8 管理端统计 - `GET /api/admin/v1/stats/overview?startDate=&endDate=` - `GET /api/admin/v1/stats/platforms` @@ -434,13 +458,14 @@ REDIS_KEY_PREFIX_ADMIN=chatone:admin - `enabled` (bool) - unique(platform_id, biz_model) -### 8.3 会话与消息(可选落库) +### 8.3 会话与消息(已落库) - `chat_sessions` - `id` - `user_id` (fk) - `title` - `created_at`, `updated_at` + - index: `user_id` - `chat_messages` - `id` @@ -450,6 +475,8 @@ REDIS_KEY_PREFIX_ADMIN=chatone:admin - `token_count` (int) - `provider` (varchar) - `created_at` + - index: `session_id` + - `session_id` 外键 `onDelete: Cascade` ### 8.4 请求审计与统计 diff --git a/prisma/migrations/20260422180000_add_chat_sessions_messages/migration.sql b/prisma/migrations/20260422180000_add_chat_sessions_messages/migration.sql new file mode 100644 index 0000000..bb800dc --- /dev/null +++ b/prisma/migrations/20260422180000_add_chat_sessions_messages/migration.sql @@ -0,0 +1,35 @@ +-- CreateTable +CREATE TABLE "chat_sessions" ( + "id" BIGSERIAL NOT NULL, + "user_id" BIGINT NOT NULL, + "title" VARCHAR(200), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "chat_sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "chat_messages" ( + "id" BIGSERIAL NOT NULL, + "session_id" BIGINT NOT NULL, + "role" VARCHAR(20) NOT NULL, + "content" TEXT NOT NULL, + "token_count" INTEGER NOT NULL DEFAULT 0, + "provider" VARCHAR(64), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "chat_messages_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "chat_sessions_user_id_idx" ON "chat_sessions"("user_id"); + +-- CreateIndex +CREATE INDEX "chat_messages_session_id_idx" ON "chat_messages"("session_id"); + +-- AddForeignKey +ALTER TABLE "chat_sessions" ADD CONSTRAINT "chat_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "chat_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 780dac8..d481845 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,5 +15,34 @@ model User { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + sessions ChatSession[] + @@map("users") } + +model ChatSession { + id BigInt @id @default(autoincrement()) + userId BigInt @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + title String? @db.VarChar(200) + messages ChatMessage[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([userId]) + @@map("chat_sessions") +} + +model ChatMessage { + id BigInt @id @default(autoincrement()) + sessionId BigInt @map("session_id") + session ChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + role String @db.VarChar(20) + content String @db.Text + tokenCount Int @default(0) @map("token_count") + provider String? @db.VarChar(64) + createdAt DateTime @default(now()) @map("created_at") + + @@index([sessionId]) + @@map("chat_messages") +} diff --git a/src/apps/client-app/auth/client-auth.controller.ts b/src/apps/client-app/auth/client-auth.controller.ts index e737d87..54f0c8b 100644 --- a/src/apps/client-app/auth/client-auth.controller.ts +++ b/src/apps/client-app/auth/client-auth.controller.ts @@ -1,6 +1,11 @@ import { Body, Controller, Inject, Post } from '@nestjs/common'; import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ClientAuthService } from './client-auth.service'; +import { + ClientLoginResponseDto, + ClientRefreshResponseDto, + ClientSendSmsResponseDto, +} from './dto/client-auth-response.dto'; import { ClientLoginDto, ClientRefreshDto, @@ -17,90 +22,24 @@ export class ClientAuthController { @Post('sms/send') @ApiOperation({ summary: '发送短信验证码(MVP 为 mock)' }) - @ApiBody({ - schema: { - type: 'object', - description: '发送短信验证码请求体', - properties: { - phone: { type: 'string', description: '手机号', example: '13800000000' }, - scene: { type: 'string', description: '业务场景', example: 'login' }, - }, - required: ['phone', 'scene'], - }, - }) - @ApiOkResponse({ - description: '发送成功(MVP mock)', - schema: { - type: 'object', - properties: { - requestId: { type: 'string', example: 'sms_1710000000000' }, - phone: { type: 'string', example: '13800000000' }, - scene: { type: 'string', example: 'login' }, - expireIn: { type: 'number', example: 300 }, - testCode: { type: 'string', example: '123456' }, - }, - }, - }) + @ApiBody({ type: ClientSendSmsDto }) + @ApiOkResponse({ description: '发送成功(MVP mock)', type: ClientSendSmsResponseDto }) async sendSms(@Body() body: ClientSendSmsDto) { return this.clientAuthService.sendSmsCode(body.phone, body.scene); } @Post('sms/login') @ApiOperation({ summary: '短信验证码登录(MVP 固定验证码 123456)' }) - @ApiBody({ - schema: { - type: 'object', - description: '短信验证码登录请求体', - properties: { - phone: { type: 'string', description: '手机号', example: '13800000000' }, - code: { type: 'string', description: '短信验证码', example: '123456' }, - }, - required: ['phone', 'code'], - }, - }) - @ApiOkResponse({ - description: '登录成功返回 token', - schema: { - type: 'object', - properties: { - accessToken: { type: 'string' }, - refreshToken: { type: 'string' }, - user: { - type: 'object', - properties: { - id: { type: 'string', example: 'u_13800000000' }, - phone: { type: 'string', example: '13800000000' }, - }, - }, - }, - }, - }) + @ApiBody({ type: ClientLoginDto }) + @ApiOkResponse({ description: '登录成功返回 token', type: ClientLoginResponseDto }) async smsLogin(@Body() body: ClientLoginDto) { return this.clientAuthService.loginBySms(body.phone, body.code); } @Post('refresh') @ApiOperation({ summary: '刷新客户端 access token' }) - @ApiBody({ - schema: { - type: 'object', - description: 'refresh token 刷新请求体', - properties: { - refreshToken: { type: 'string', description: '刷新令牌' }, - }, - required: ['refreshToken'], - }, - }) - @ApiOkResponse({ - description: '刷新成功返回新 token', - schema: { - type: 'object', - properties: { - accessToken: { type: 'string' }, - refreshToken: { type: 'string' }, - }, - }, - }) + @ApiBody({ type: ClientRefreshDto }) + @ApiOkResponse({ description: '刷新成功返回新 token', type: ClientRefreshResponseDto }) async refresh(@Body() body: ClientRefreshDto) { return this.clientAuthService.refreshToken(body.refreshToken); } diff --git a/src/apps/client-app/auth/dto/client-auth-response.dto.ts b/src/apps/client-app/auth/dto/client-auth-response.dto.ts new file mode 100644 index 0000000..e9129f2 --- /dev/null +++ b/src/apps/client-app/auth/dto/client-auth-response.dto.ts @@ -0,0 +1,58 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ClientSendSmsResponseDto { + @ApiProperty({ type: String, example: 'sms_1710000000000' }) + requestId!: string; + + @ApiProperty({ type: String, example: '13800000000' }) + phone!: string; + + @ApiProperty({ type: String, example: 'login' }) + scene!: string; + + @ApiProperty({ type: String, description: '短信渠道标识' }) + provider!: string; + + @ApiProperty({ type: Number, example: 300, description: '验证码有效期(秒)' }) + expireIn!: number; + + @ApiPropertyOptional({ + type: String, + description: '非生产环境返回,便于联调', + example: '123456', + }) + testCode?: string; +} + +export class ClientAuthUserDto { + @ApiProperty({ type: String, example: '1', description: '用户 ID(数字字符串)' }) + id!: string; + + @ApiProperty({ type: String, example: '13800000000' }) + phone!: string; + + @ApiProperty({ type: String, example: 'Chat0000' }) + nickname!: string; + + @ApiProperty({ type: String, example: 'https://example.com/a.png' }) + avatarUrl!: string; +} + +export class ClientLoginResponseDto { + @ApiProperty({ type: String }) + accessToken!: string; + + @ApiProperty({ type: String }) + refreshToken!: string; + + @ApiProperty({ type: () => ClientAuthUserDto }) + user!: ClientAuthUserDto; +} + +export class ClientRefreshResponseDto { + @ApiProperty({ type: String }) + accessToken!: string; + + @ApiProperty({ type: String }) + refreshToken!: string; +} diff --git a/src/apps/client-app/auth/dto/login.dto.ts b/src/apps/client-app/auth/dto/login.dto.ts index e8ae484..e497953 100644 --- a/src/apps/client-app/auth/dto/login.dto.ts +++ b/src/apps/client-app/auth/dto/login.dto.ts @@ -1,26 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; export class ClientSendSmsDto { + @ApiProperty({ type: String, description: '手机号', example: '13800000000' }) @IsString() @IsNotEmpty() phone!: string; + @ApiProperty({ type: String, description: '业务场景', example: 'login' }) @IsString() @IsNotEmpty() scene!: string; } export class ClientLoginDto { + @ApiProperty({ type: String, description: '手机号', example: '13800000000' }) @IsString() @IsNotEmpty() phone!: string; + @ApiProperty({ type: String, description: '短信验证码', example: '123456' }) @IsString() @IsNotEmpty() code!: string; } export class ClientRefreshDto { + @ApiProperty({ type: String, description: '刷新令牌' }) @IsString() @IsNotEmpty() refreshToken!: string; diff --git a/src/apps/client-app/chat/application/chat-session.service.ts b/src/apps/client-app/chat/application/chat-session.service.ts new file mode 100644 index 0000000..0fa505c --- /dev/null +++ b/src/apps/client-app/chat/application/chat-session.service.ts @@ -0,0 +1,260 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '@prisma/prisma.service'; +import { + ChatMessage, + ProviderUsage, + StreamChatRequest, +} from '@shared/ai-gateway/types/chat.types'; +import { Prisma } from '@prisma/client'; + +function parseBigIntId(value: string, field: string): bigint { + try { + return BigInt(value.trim()); + } catch { + throw new BadRequestException(`${field} 格式无效`); + } +} + +@Injectable() +export class ChatSessionService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async createSession(userId: bigint, title?: string | null) { + const row = await this.prisma.chatSession.create({ + data: { + userId, + title: title?.trim() || null, + }, + }); + return this.mapSession(row); + } + + async listSessions(userId: bigint, limit: number, offset: number) { + const take = Math.min(100, Math.max(1, limit)); + const skip = Math.max(0, offset); + const [rows, total] = await Promise.all([ + this.prisma.chatSession.findMany({ + where: { userId }, + orderBy: { updatedAt: 'desc' }, + take, + skip, + }), + this.prisma.chatSession.count({ where: { userId } }), + ]); + return { + items: rows.map((r) => this.mapSession(r)), + total, + limit: take, + offset: skip, + }; + } + + async listMessages( + userId: bigint, + sessionIdStr: string, + limit: number, + offset: number, + ) { + const sessionId = parseBigIntId(sessionIdStr, 'sessionId'); + await this.assertSessionOwned(userId, sessionId); + const take = Math.min(100, Math.max(1, limit)); + const skip = Math.max(0, offset); + + const [rows, total] = await Promise.all([ + this.prisma.chatMessage.findMany({ + where: { sessionId }, + orderBy: { id: 'asc' }, + take, + skip, + }), + this.prisma.chatMessage.count({ where: { sessionId } }), + ]); + + return { + sessionId: String(sessionId), + items: rows.map((m) => ({ + id: String(m.id), + role: m.role, + content: m.content, + tokenCount: m.tokenCount, + provider: m.provider, + createdAt: m.createdAt.toISOString(), + })), + total, + limit: take, + offset: skip, + }; + } + + async deleteSession(userId: bigint, sessionIdStr: string) { + const sessionId = parseBigIntId(sessionIdStr, 'sessionId'); + await this.assertSessionOwned(userId, sessionId); + const deleted = await this.prisma.chatSession.delete({ + where: { id: sessionId }, + }); + return this.mapSession(deleted); + } + + async updateSessionTitle(userId: bigint, sessionIdStr: string, title: string) { + const sessionId = parseBigIntId(sessionIdStr, 'sessionId'); + await this.assertSessionOwned(userId, sessionId); + const updated = await this.prisma.chatSession.update({ + where: { id: sessionId }, + data: { title: title.trim() || null }, + }); + return this.mapSession(updated); + } + + /** + * 解析并校验会话;stream 场景必须传入 sessionId + */ + async resolveSessionForStream( + userId: bigint, + body: StreamChatRequest, + ): Promise { + const raw = body.sessionId?.trim(); + if (!raw) { + throw new BadRequestException('sessionId 必填,请先创建会话'); + } + const sessionId = parseBigIntId(raw, 'sessionId'); + await this.assertSessionOwned(userId, sessionId); + return sessionId; + } + + async persistRoundtrip( + sessionId: bigint, + messages: ChatMessage[], + assistantText: string, + providerCode: string, + usage: ProviderUsage, + ) { + const lastUser = [...messages].reverse().find((m) => m.role === 'user'); + if (lastUser?.content) { + await this.prisma.chatMessage.create({ + data: { + sessionId, + role: 'user', + content: lastUser.content, + tokenCount: 0, + provider: null, + }, + }); + const session = await this.prisma.chatSession.findUnique({ + where: { id: sessionId }, + select: { title: true }, + }); + if (session && !session.title) { + const t = lastUser.content.trim().slice(0, 80); + if (t) { + await this.prisma.chatSession.update({ + where: { id: sessionId }, + data: { title: t }, + }); + } + } + } + + await this.prisma.chatMessage.create({ + data: { + sessionId, + role: 'assistant', + content: assistantText, + tokenCount: usage.completionTokens ?? 0, + provider: providerCode, + }, + }); + + await this.prisma.$executeRaw( + Prisma.sql`UPDATE chat_sessions SET updated_at = NOW() WHERE id = ${sessionId}`, + ); + } + + /** + * 为了让前端在 SSE 结束后立即看到会话标题,先持久化用户消息与标题。 + */ + async persistUserMessageAndTitle(sessionId: bigint, messages: ChatMessage[]) { + const lastUser = [...messages].reverse().find((m) => m.role === 'user'); + if (!lastUser?.content) return; + + await this.prisma.chatMessage.create({ + data: { + sessionId, + role: 'user', + content: lastUser.content, + tokenCount: 0, + provider: null, + }, + }); + + const title = lastUser.content.trim().slice(0, 80); + if (title) { + await this.prisma.chatSession.updateMany({ + where: { + id: sessionId, + OR: [{ title: null }, { title: '' }], + }, + data: { title }, + }); + } + + await this.prisma.$executeRaw( + Prisma.sql`UPDATE chat_sessions SET updated_at = NOW() WHERE id = ${sessionId}`, + ); + } + + async persistAssistantMessage( + sessionId: bigint, + assistantText: string, + providerCode: string, + usage: ProviderUsage, + ) { + await this.prisma.chatMessage.create({ + data: { + sessionId, + role: 'assistant', + content: assistantText, + tokenCount: usage.completionTokens ?? 0, + provider: providerCode, + }, + }); + + await this.prisma.$executeRaw( + Prisma.sql`UPDATE chat_sessions SET updated_at = NOW() WHERE id = ${sessionId}`, + ); + } + + private async assertSessionOwned(userId: bigint, sessionId: bigint) { + const row = await this.prisma.chatSession.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + if (!row) { + throw new NotFoundException('会话不存在'); + } + if (row.userId !== userId) { + throw new ForbiddenException('无权访问该会话'); + } + } + + private mapSession(row: { + id: bigint; + userId: bigint; + title: string | null; + createdAt: Date; + updatedAt: Date; + }) { + return { + id: String(row.id), + userId: String(row.userId), + title: row.title ?? '', + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } +} diff --git a/src/apps/client-app/chat/chat.module.ts b/src/apps/client-app/chat/chat.module.ts index 080953e..209ce65 100644 --- a/src/apps/client-app/chat/chat.module.ts +++ b/src/apps/client-app/chat/chat.module.ts @@ -1,13 +1,21 @@ import { Module } from '@nestjs/common'; import { ChatController } from './controllers/chat.controller'; +import { ChatSessionsController } from './controllers/chat-sessions.controller'; +import { ChatSessionService } from './application/chat-session.service'; import { ProviderRouterService } from '@shared/ai-gateway/router/provider-router.service'; import { QwenProvider } from '@shared/ai-gateway/providers/qwen.provider'; import { DeepseekProvider } from '@shared/ai-gateway/providers/deepseek.provider'; import { VolcProvider } from '@shared/ai-gateway/providers/volc.provider'; @Module({ - controllers: [ChatController], - providers: [ProviderRouterService, QwenProvider, DeepseekProvider, VolcProvider], + controllers: [ChatController, ChatSessionsController], + providers: [ + ChatSessionService, + ProviderRouterService, + QwenProvider, + DeepseekProvider, + VolcProvider, + ], }) export class ChatModule {} diff --git a/src/apps/client-app/chat/controllers/chat-sessions.controller.ts b/src/apps/client-app/chat/controllers/chat-sessions.controller.ts new file mode 100644 index 0000000..d53dec2 --- /dev/null +++ b/src/apps/client-app/chat/controllers/chat-sessions.controller.ts @@ -0,0 +1,143 @@ +import { + Body, + Controller, + Delete, + Get, + Inject, + Patch, + Param, + Post, + Query, + Req, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import type { FastifyRequest } from 'fastify'; +import { ClientJwtAuthGuard } from '../../auth/client-jwt-auth.guard'; +import { ChatSessionService } from '../application/chat-session.service'; +import { PaginationQueryDto } from '../dto/chat-session-query.dto'; +import { + ChatMessageListResponseDto, + ChatSessionListResponseDto, + ChatSessionRowDto, +} from '../dto/chat-session-response.dto'; +import { CreateChatSessionDto } from '../dto/create-chat-session.dto'; +import { UpdateChatSessionTitleDto } from '../dto/update-chat-session-title.dto'; + +type ClientJwtRequest = FastifyRequest & { user: { userId: string } }; + +@ApiTags('Client Chat') +@ApiBearerAuth('access-token') +@Controller('client/v1/chat/sessions') +@UseGuards(ClientJwtAuthGuard) +export class ChatSessionsController { + constructor( + @Inject(ChatSessionService) + private readonly chatSessions: ChatSessionService, + ) {} + + @Post() + @UsePipes( + new ValidationPipe({ + whitelist: true, + transform: true, + }), + ) + @ApiOperation({ summary: '创建空会话' }) + @ApiBody({ type: CreateChatSessionDto }) + @ApiOkResponse({ type: ChatSessionRowDto }) + async create(@Req() req: ClientJwtRequest, @Body() body: CreateChatSessionDto) { + const userId = BigInt(req.user.userId); + return this.chatSessions.createSession(userId, body.title); + } + + @Get() + @UsePipes( + new ValidationPipe({ + whitelist: true, + transform: true, + }), + ) + @ApiOperation({ summary: '会话列表' }) + @ApiOkResponse({ type: ChatSessionListResponseDto }) + async list(@Req() req: ClientJwtRequest, @Query() query: PaginationQueryDto) { + const userId = BigInt(req.user.userId); + const limit = Math.min(100, Math.max(1, query.limit ?? 20)); + const offset = Math.max(0, query.offset ?? 0); + return this.chatSessions.listSessions(userId, limit, offset); + } + + @Get(':sessionId/messages') + @UsePipes( + new ValidationPipe({ + whitelist: true, + transform: true, + }), + ) + @ApiOperation({ summary: '会话消息分页' }) + @ApiParam({ + name: 'sessionId', + description: '会话 ID(数字字符串)', + example: '1', + }) + @ApiOkResponse({ type: ChatMessageListResponseDto }) + async messages( + @Req() req: ClientJwtRequest, + @Param('sessionId') sessionId: string, + @Query() query: PaginationQueryDto, + ) { + const userId = BigInt(req.user.userId); + const limit = Math.min(100, Math.max(1, query.limit ?? 50)); + const offset = Math.max(0, query.offset ?? 0); + return this.chatSessions.listMessages(userId, sessionId, limit, offset); + } + + @Delete(':sessionId') + @ApiOperation({ summary: '删除会话(级联删除消息)' }) + @ApiParam({ + name: 'sessionId', + description: '会话 ID(数字字符串)', + example: '1', + }) + @ApiOkResponse({ type: ChatSessionRowDto }) + async remove( + @Req() req: ClientJwtRequest, + @Param('sessionId') sessionId: string, + ) { + const userId = BigInt(req.user.userId); + return this.chatSessions.deleteSession(userId, sessionId); + } + + @Patch(':sessionId/title') + @UsePipes( + new ValidationPipe({ + whitelist: true, + transform: true, + }), + ) + @ApiOperation({ summary: '修改会话标题' }) + @ApiParam({ + name: 'sessionId', + description: '会话 ID(数字字符串)', + example: '1', + }) + @ApiBody({ type: UpdateChatSessionTitleDto }) + @ApiOkResponse({ type: ChatSessionRowDto }) + async updateTitle( + @Req() req: ClientJwtRequest, + @Param('sessionId') sessionId: string, + @Body() body: UpdateChatSessionTitleDto, + ) { + const userId = BigInt(req.user.userId); + return this.chatSessions.updateSessionTitle(userId, sessionId, body.title); + } +} diff --git a/src/apps/client-app/chat/controllers/chat.controller.ts b/src/apps/client-app/chat/controllers/chat.controller.ts index 9245808..a0bf757 100644 --- a/src/apps/client-app/chat/controllers/chat.controller.ts +++ b/src/apps/client-app/chat/controllers/chat.controller.ts @@ -1,8 +1,20 @@ -import { Body, Controller, Inject, Post, Res, UseGuards } from '@nestjs/common'; -import { FastifyReply } from 'fastify'; -import { StreamChatRequest } from '@shared/ai-gateway/types/chat.types'; +import { + Body, + Controller, + Inject, + Logger, + Post, + Req, + Res, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import type { FastifyReply, FastifyRequest } from 'fastify'; import { ProviderRouterService } from '@shared/ai-gateway/router/provider-router.service'; import { ClientJwtAuthGuard } from '../../auth/client-jwt-auth.guard'; +import { ChatSessionService } from '../application/chat-session.service'; +import { StreamChatBodyDto } from '../dto/stream-chat.dto'; import { ApiBearerAuth, ApiBody, @@ -21,13 +33,19 @@ function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } +type ClientJwtRequest = FastifyRequest & { user: { userId: string } }; + @ApiTags('Client Chat') @ApiBearerAuth('access-token') @Controller('client/v1/chat') export class ChatController { + private readonly logger = new Logger(ChatController.name); + constructor( @Inject(ProviderRouterService) private readonly router: ProviderRouterService, + @Inject(ChatSessionService) + private readonly chatSessions: ChatSessionService, ) {} @Post('completions/stream') @@ -36,62 +54,41 @@ export class ChatController { @ApiConsumes('application/json') @ApiProduces('text/event-stream') @ApiBody({ - schema: { - type: 'object', - description: '统一 chat 请求体,支持指定平台或自动路由', - properties: { - model: { - type: 'string', - description: '模型名(不传时 provider 使用默认模型)', - example: 'qwen-plus', - }, - platform: { - type: 'string', - description: '目标平台(auto 或不传表示自动路由)', - enum: ['auto', 'qwen', 'deepseek', 'volc'], - example: 'qwen', - }, - messages: { - type: 'array', - description: '聊天消息列表', - items: { - type: 'object', - properties: { - role: { - type: 'string', - description: '消息角色', - enum: ['system', 'user', 'assistant'], - example: 'user', - }, - content: { - type: 'string', - description: '消息文本内容', - example: '你是谁', - }, - }, - required: ['role', 'content'], - }, - }, - }, - required: ['messages'], - }, + type: StreamChatBodyDto, + description: '统一 chat 请求体;需先手动创建会话并传入 sessionId', }) @ApiOkResponse({ - description: 'SSE 流式响应:meta -> delta -> usage -> done', - schema: { - type: 'string', - example: - 'event: meta\\ndata: {"requestId":"chatcmpl_xxx","platform":"qwen","model":"qwen-plus"}\\n\\n' + - 'event: delta\\ndata: {"delta":"你好"}\\n\\n' + - 'event: usage\\ndata: {"promptTokens":10,"completionTokens":20,"totalTokens":30}\\n\\n' + - 'event: done\\ndata: {"finishReason":"stop"}\\n\\n', + description: + 'SSE 流式响应:event 序列为 meta → delta(多次)→ usage → done', + content: { + 'text/event-stream': { + schema: { + type: 'string', + example: + 'event: meta\\ndata: {"requestId":"chatcmpl_xxx","platform":"qwen","model":"qwen-plus","sessionId":"1"}\\n\\n' + + 'event: delta\\ndata: {"delta":"你好"}\\n\\n' + + 'event: usage\\ndata: {"promptTokens":10,"completionTokens":20,"totalTokens":30}\\n\\n' + + 'event: done\\ndata: {"finishReason":"stop"}\\n\\n', + }, + }, }, }) + @UsePipes( + new ValidationPipe({ + whitelist: true, + transform: true, + }), + ) async streamChat( - @Body() body: StreamChatRequest, + @Body() body: StreamChatBodyDto, + @Req() req: ClientJwtRequest, @Res() reply: FastifyReply, ) { - const response = await this.router.routeAndStream(body); + const userId = BigInt(req.user.userId); + const sessionId = await this.chatSessions.resolveSessionForStream( + userId, + body, + ); reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); @@ -99,22 +96,51 @@ export class ChatController { reply.raw.setHeader('X-Accel-Buffering', 'no'); reply.raw.flushHeaders?.(); + const response = await this.router.routeAndStream(body); + reply.raw.write( formatSse('meta', { requestId: response.requestId, platform: response.providerCode, model: response.model, + sessionId: String(sessionId), }), ); + let assistantText = ''; for (const chunk of response.chunks) { + assistantText += chunk.content; reply.raw.write(formatSse('delta', { delta: chunk.content })); await sleep(120); } + // 在 done 之前完成用户消息与标题落库,确保前端紧接着查列表能看到最新标题。 + try { + await this.chatSessions.persistUserMessageAndTitle(sessionId, body.messages); + } catch (err) { + this.logger.error( + { err, sessionId: String(sessionId) }, + 'persist user message/title failed', + ); + } + reply.raw.write(formatSse('usage', response.usage)); reply.raw.write(formatSse('done', { finishReason: 'stop' })); reply.raw.end(); + + try { + await this.chatSessions.persistAssistantMessage( + sessionId, + assistantText, + response.providerCode, + response.usage, + ); + } catch (err) { + this.logger.error( + { err, sessionId: String(sessionId) }, + 'persist chat roundtrip failed', + ); + } } } diff --git a/src/apps/client-app/chat/dto/chat-session-query.dto.ts b/src/apps/client-app/chat/dto/chat-session-query.dto.ts new file mode 100644 index 0000000..5e6c4f4 --- /dev/null +++ b/src/apps/client-app/chat/dto/chat-session-query.dto.ts @@ -0,0 +1,32 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +/** 会话列表、消息列表共用的分页查询(limit 默认值在控制器中按路由区分) */ +export class PaginationQueryDto { + @ApiPropertyOptional({ + type: Number, + minimum: 1, + maximum: 100, + description: '分页条数;会话列表默认 20,消息列表默认 50', + example: 20, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @ApiPropertyOptional({ + type: Number, + minimum: 0, + description: '偏移量', + example: 0, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + offset?: number; +} diff --git a/src/apps/client-app/chat/dto/chat-session-response.dto.ts b/src/apps/client-app/chat/dto/chat-session-response.dto.ts new file mode 100644 index 0000000..ea570bb --- /dev/null +++ b/src/apps/client-app/chat/dto/chat-session-response.dto.ts @@ -0,0 +1,80 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ChatSessionRowDto { + @ApiProperty({ type: String, example: '1', description: '会话 ID(数字字符串)' }) + id!: string; + + @ApiProperty({ type: String, example: '1', description: '用户 ID(数字字符串)' }) + userId!: string; + + @ApiProperty({ + type: String, + description: '标题(可能为空字符串)', + example: '你好', + }) + title!: string; + + @ApiProperty({ type: String, description: '创建时间 ISO8601' }) + createdAt!: string; + + @ApiProperty({ type: String, description: '更新时间 ISO8601' }) + updatedAt!: string; +} + +export class ChatSessionListResponseDto { + @ApiProperty({ type: () => ChatSessionRowDto, isArray: true }) + items!: ChatSessionRowDto[]; + + @ApiProperty({ type: Number }) + total!: number; + + @ApiProperty({ type: Number }) + limit!: number; + + @ApiProperty({ type: Number }) + offset!: number; +} + +export class ChatMessageRowDto { + @ApiProperty({ type: String, example: '1', description: '消息 ID(数字字符串)' }) + id!: string; + + @ApiProperty({ type: String, example: 'user' }) + role!: string; + + @ApiProperty({ type: String }) + content!: string; + + @ApiProperty({ + type: Number, + description: 'completion token 数(用户消息为 0)', + }) + tokenCount!: number; + + @ApiPropertyOptional({ + type: String, + nullable: true, + description: '大模型渠道(用户消息一般为 null)', + }) + provider?: string | null; + + @ApiProperty({ type: String, description: '创建时间 ISO8601' }) + createdAt!: string; +} + +export class ChatMessageListResponseDto { + @ApiProperty({ type: String, example: '1' }) + sessionId!: string; + + @ApiProperty({ type: () => ChatMessageRowDto, isArray: true }) + items!: ChatMessageRowDto[]; + + @ApiProperty({ type: Number }) + total!: number; + + @ApiProperty({ type: Number }) + limit!: number; + + @ApiProperty({ type: Number }) + offset!: number; +} diff --git a/src/apps/client-app/chat/dto/create-chat-session.dto.ts b/src/apps/client-app/chat/dto/create-chat-session.dto.ts new file mode 100644 index 0000000..fce6f83 --- /dev/null +++ b/src/apps/client-app/chat/dto/create-chat-session.dto.ts @@ -0,0 +1,10 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateChatSessionDto { + @ApiPropertyOptional({ type: String, description: '会话标题', maxLength: 200 }) + @IsOptional() + @IsString() + @MaxLength(200) + title?: string; +} diff --git a/src/apps/client-app/chat/dto/stream-chat.dto.ts b/src/apps/client-app/chat/dto/stream-chat.dto.ts index 5ba68b0..fc1c214 100644 --- a/src/apps/client-app/chat/dto/stream-chat.dto.ts +++ b/src/apps/client-app/chat/dto/stream-chat.dto.ts @@ -3,24 +3,38 @@ import { Type } from 'class-transformer'; import { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator'; import { StreamChatRequest } from '@shared/ai-gateway/types/chat.types'; -class ChatMessageDto { - @ApiProperty({ enum: ['system', 'user', 'assistant'] }) +export class ChatMessageDto { + @ApiProperty({ + type: String, + enum: ['system', 'user', 'assistant'], + description: '消息角色', + example: 'user', + }) @IsString() @IsIn(['system', 'user', 'assistant']) role!: 'system' | 'user' | 'assistant'; - @ApiProperty({ description: '消息内容', example: '你是谁' }) + @ApiProperty({ type: String, description: '消息内容', example: '你是谁' }) @IsString() content!: string; } export class StreamChatBodyDto implements StreamChatRequest { - @ApiPropertyOptional({ description: '模型名', example: 'qwen-plus' }) + @ApiPropertyOptional({ type: String, description: '模型名', example: 'qwen-plus' }) @IsOptional() @IsString() model?: string; + @ApiProperty({ + type: String, + description: '会话 ID(数字字符串);需先调用创建会话接口获取', + example: '1', + }) + @IsString() + sessionId!: string; + @ApiPropertyOptional({ + type: String, description: '指定平台,不传或 auto 由路由自动选择', enum: ['auto', 'qwen', 'deepseek', 'volc'], example: 'qwen', @@ -29,7 +43,11 @@ export class StreamChatBodyDto implements StreamChatRequest { @IsString() platform?: string; - @ApiProperty({ type: [ChatMessageDto] }) + @ApiProperty({ + type: () => ChatMessageDto, + isArray: true, + description: '聊天消息列表(至少一条用户消息)', + }) @IsArray() @ValidateNested({ each: true }) @Type(() => ChatMessageDto) diff --git a/src/apps/client-app/chat/dto/update-chat-session-title.dto.ts b/src/apps/client-app/chat/dto/update-chat-session-title.dto.ts new file mode 100644 index 0000000..5d8bf20 --- /dev/null +++ b/src/apps/client-app/chat/dto/update-chat-session-title.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MaxLength } from 'class-validator'; + +export class UpdateChatSessionTitleDto { + @ApiProperty({ + type: String, + description: '会话标题(可传空字符串清空)', + maxLength: 200, + example: '产品需求讨论', + }) + @IsString() + @MaxLength(200) + title!: string; +} diff --git a/src/apps/shared-domain/ai-gateway/types/chat.types.ts b/src/apps/shared-domain/ai-gateway/types/chat.types.ts index f875fad..ac1004d 100644 --- a/src/apps/shared-domain/ai-gateway/types/chat.types.ts +++ b/src/apps/shared-domain/ai-gateway/types/chat.types.ts @@ -8,6 +8,8 @@ export interface ChatMessage { export interface StreamChatRequest { model?: string; platform?: string; // qwen | deepseek | volc | auto | demo + /** 已有会话 ID;不传则本次对话新建会话并落库 */ + sessionId?: string; messages: ChatMessage[]; }