feat(client): 新增会话管理与消息落库能力
补齐客户端会话生命周期接口(创建、列表、消息分页、改名、删除),并在流式 chat 中强制绑定 sessionId 与落库消息,确保会话标题和历史可追踪,同时统一 Swagger 文档为 DTO 驱动以减少重复维护。 Made-with: Cursor
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
58
src/apps/client-app/auth/dto/client-auth-response.dto.ts
Normal file
58
src/apps/client-app/auth/dto/client-auth-response.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
260
src/apps/client-app/chat/application/chat-session.service.ts
Normal file
260
src/apps/client-app/chat/application/chat-session.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
143
src/apps/client-app/chat/controllers/chat-sessions.controller.ts
Normal file
143
src/apps/client-app/chat/controllers/chat-sessions.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
src/apps/client-app/chat/dto/chat-session-query.dto.ts
Normal file
32
src/apps/client-app/chat/dto/chat-session-query.dto.ts
Normal 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;
|
||||
}
|
||||
80
src/apps/client-app/chat/dto/chat-session-response.dto.ts
Normal file
80
src/apps/client-app/chat/dto/chat-session-response.dto.ts
Normal 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;
|
||||
}
|
||||
10
src/apps/client-app/chat/dto/create-chat-session.dto.ts
Normal file
10
src/apps/client-app/chat/dto/create-chat-session.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export interface ChatMessage {
|
||||
export interface StreamChatRequest {
|
||||
model?: string;
|
||||
platform?: string; // qwen | deepseek | volc | auto | demo
|
||||
/** 已有会话 ID;不传则本次对话新建会话并落库 */
|
||||
sessionId?: string;
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user