feat(client): 新增会话管理与消息落库能力

补齐客户端会话生命周期接口(创建、列表、消息分页、改名、删除),并在流式 chat 中强制绑定 sessionId 与落库消息,确保会话标题和历史可追踪,同时统一 Swagger 文档为 DTO 驱动以减少重复维护。

Made-with: Cursor
This commit is contained in:
2026-04-22 23:32:10 +08:00
parent bc13417efd
commit 32303d099a
16 changed files with 833 additions and 146 deletions

View File

@@ -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 请求审计与统计

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<bigint> {
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(),
};
}
}

View File

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

View File

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

View File

@@ -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',
);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,8 @@ export interface ChatMessage {
export interface StreamChatRequest {
model?: string;
platform?: string; // qwen | deepseek | volc | auto | demo
/** 已有会话 ID不传则本次对话新建会话并落库 */
sessionId?: string;
messages: ChatMessage[];
}