feat(client): 新增会话管理与消息落库能力
补齐客户端会话生命周期接口(创建、列表、消息分页、改名、删除),并在流式 chat 中强制绑定 sessionId 与落库消息,确保会话标题和历史可追踪,同时统一 Swagger 文档为 DTO 驱动以减少重复维护。 Made-with: Cursor
This commit is contained in:
@@ -197,33 +197,57 @@ chat-one-service/
|
|||||||
### 4.2 客户端 AI Chat 接口(流式)
|
### 4.2 客户端 AI Chat 接口(流式)
|
||||||
|
|
||||||
- `POST /api/client/v1/chat/completions/stream`
|
- `POST /api/client/v1/chat/completions/stream`
|
||||||
- 说明:统一接口,后台自动路由平台
|
- 说明:统一接口,后台自动路由平台;支持会话续聊
|
||||||
- 入参示例:
|
- 入参示例:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "gpt-4o-mini-like",
|
"model": "qwen-plus",
|
||||||
"messages": [
|
"messages": [
|
||||||
{ "role": "system", "content": "你是助手" },
|
{ "role": "system", "content": "你是助手" },
|
||||||
{ "role": "user", "content": "介绍一下NestJS" }
|
{ "role": "user", "content": "介绍一下NestJS" }
|
||||||
],
|
],
|
||||||
"temperature": 0.7,
|
"platform": "auto",
|
||||||
"platform": "auto"
|
"sessionId": "1"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
- 备注:
|
||||||
|
- `sessionId` 必填,需先调用创建会话接口;
|
||||||
|
- `meta` 会返回 `sessionId`(字符串),前端可缓存用于后续续聊。
|
||||||
|
- `done` 前会先落库用户消息与会话标题,便于列表即时刷新。
|
||||||
- 返回:`text/event-stream`(SSE)
|
- 返回:`text/event-stream`(SSE)
|
||||||
|
|
||||||
- `POST /api/client/v1/chat/completions/stream/:platform`
|
### 4.3 客户端会话接口
|
||||||
- 说明:指定平台(`qwen | volc | deepseek`)
|
|
||||||
- 其余参数同上
|
|
||||||
|
|
||||||
### 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
|
```text
|
||||||
event: meta
|
event: meta
|
||||||
data: {"requestId":"xxx","platform":"qwen","model":"qwen-turbo"}
|
data: {"requestId":"xxx","platform":"qwen","model":"qwen-plus","sessionId":"1"}
|
||||||
|
|
||||||
event: delta
|
event: delta
|
||||||
data: {"content":"你好"}
|
data: {"delta":"你好"}
|
||||||
|
|
||||||
event: usage
|
event: usage
|
||||||
data: {"promptTokens":120,"completionTokens":80,"totalTokens":200}
|
data: {"promptTokens":120,"completionTokens":80,"totalTokens":200}
|
||||||
@@ -235,7 +259,7 @@ event: error
|
|||||||
data: {"code":"PLATFORM_TIMEOUT","message":"upstream timeout"}
|
data: {"code":"PLATFORM_TIMEOUT","message":"upstream timeout"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.4 管理端认证接口(邮箱)
|
### 4.5 管理端认证接口(邮箱)
|
||||||
|
|
||||||
- `POST /api/admin/v1/auth/login`
|
- `POST /api/admin/v1/auth/login`
|
||||||
- 入参:`{ email, password }`
|
- 入参:`{ email, password }`
|
||||||
@@ -244,21 +268,21 @@ data: {"code":"PLATFORM_TIMEOUT","message":"upstream timeout"}
|
|||||||
- `POST /api/admin/v1/auth/refresh`
|
- `POST /api/admin/v1/auth/refresh`
|
||||||
- `POST /api/admin/v1/auth/logout`
|
- `POST /api/admin/v1/auth/logout`
|
||||||
|
|
||||||
### 4.5 管理端用户管理
|
### 4.6 管理端用户管理
|
||||||
|
|
||||||
- `GET /api/admin/v1/users`
|
- `GET /api/admin/v1/users`
|
||||||
- `GET /api/admin/v1/users/:id`
|
- `GET /api/admin/v1/users/:id`
|
||||||
- `PATCH /api/admin/v1/users/:id/status`(启用/禁用)
|
- `PATCH /api/admin/v1/users/:id/status`(启用/禁用)
|
||||||
- `PATCH /api/admin/v1/users/:id/role`
|
- `PATCH /api/admin/v1/users/:id/role`
|
||||||
|
|
||||||
### 4.6 管理端平台管理
|
### 4.7 管理端平台管理
|
||||||
|
|
||||||
- `GET /api/admin/v1/platforms`
|
- `GET /api/admin/v1/platforms`
|
||||||
- `POST /api/admin/v1/platforms`
|
- `POST /api/admin/v1/platforms`
|
||||||
- `PATCH /api/admin/v1/platforms/:id`
|
- `PATCH /api/admin/v1/platforms/:id`
|
||||||
- `PATCH /api/admin/v1/platforms/:id/health-check`
|
- `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/overview?startDate=&endDate=`
|
||||||
- `GET /api/admin/v1/stats/platforms`
|
- `GET /api/admin/v1/stats/platforms`
|
||||||
@@ -434,13 +458,14 @@ REDIS_KEY_PREFIX_ADMIN=chatone:admin
|
|||||||
- `enabled` (bool)
|
- `enabled` (bool)
|
||||||
- unique(platform_id, biz_model)
|
- unique(platform_id, biz_model)
|
||||||
|
|
||||||
### 8.3 会话与消息(可选落库)
|
### 8.3 会话与消息(已落库)
|
||||||
|
|
||||||
- `chat_sessions`
|
- `chat_sessions`
|
||||||
- `id`
|
- `id`
|
||||||
- `user_id` (fk)
|
- `user_id` (fk)
|
||||||
- `title`
|
- `title`
|
||||||
- `created_at`, `updated_at`
|
- `created_at`, `updated_at`
|
||||||
|
- index: `user_id`
|
||||||
|
|
||||||
- `chat_messages`
|
- `chat_messages`
|
||||||
- `id`
|
- `id`
|
||||||
@@ -450,6 +475,8 @@ REDIS_KEY_PREFIX_ADMIN=chatone:admin
|
|||||||
- `token_count` (int)
|
- `token_count` (int)
|
||||||
- `provider` (varchar)
|
- `provider` (varchar)
|
||||||
- `created_at`
|
- `created_at`
|
||||||
|
- index: `session_id`
|
||||||
|
- `session_id` 外键 `onDelete: Cascade`
|
||||||
|
|
||||||
### 8.4 请求审计与统计
|
### 8.4 请求审计与统计
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -15,5 +15,34 @@ model User {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
sessions ChatSession[]
|
||||||
|
|
||||||
@@map("users")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Body, Controller, Inject, Post } from '@nestjs/common';
|
import { Body, Controller, Inject, Post } from '@nestjs/common';
|
||||||
import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { ClientAuthService } from './client-auth.service';
|
import { ClientAuthService } from './client-auth.service';
|
||||||
|
import {
|
||||||
|
ClientLoginResponseDto,
|
||||||
|
ClientRefreshResponseDto,
|
||||||
|
ClientSendSmsResponseDto,
|
||||||
|
} from './dto/client-auth-response.dto';
|
||||||
import {
|
import {
|
||||||
ClientLoginDto,
|
ClientLoginDto,
|
||||||
ClientRefreshDto,
|
ClientRefreshDto,
|
||||||
@@ -17,90 +22,24 @@ export class ClientAuthController {
|
|||||||
|
|
||||||
@Post('sms/send')
|
@Post('sms/send')
|
||||||
@ApiOperation({ summary: '发送短信验证码(MVP 为 mock)' })
|
@ApiOperation({ summary: '发送短信验证码(MVP 为 mock)' })
|
||||||
@ApiBody({
|
@ApiBody({ type: ClientSendSmsDto })
|
||||||
schema: {
|
@ApiOkResponse({ description: '发送成功(MVP mock)', type: ClientSendSmsResponseDto })
|
||||||
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' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
async sendSms(@Body() body: ClientSendSmsDto) {
|
async sendSms(@Body() body: ClientSendSmsDto) {
|
||||||
return this.clientAuthService.sendSmsCode(body.phone, body.scene);
|
return this.clientAuthService.sendSmsCode(body.phone, body.scene);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('sms/login')
|
@Post('sms/login')
|
||||||
@ApiOperation({ summary: '短信验证码登录(MVP 固定验证码 123456)' })
|
@ApiOperation({ summary: '短信验证码登录(MVP 固定验证码 123456)' })
|
||||||
@ApiBody({
|
@ApiBody({ type: ClientLoginDto })
|
||||||
schema: {
|
@ApiOkResponse({ description: '登录成功返回 token', type: ClientLoginResponseDto })
|
||||||
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' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
async smsLogin(@Body() body: ClientLoginDto) {
|
async smsLogin(@Body() body: ClientLoginDto) {
|
||||||
return this.clientAuthService.loginBySms(body.phone, body.code);
|
return this.clientAuthService.loginBySms(body.phone, body.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
@ApiOperation({ summary: '刷新客户端 access token' })
|
@ApiOperation({ summary: '刷新客户端 access token' })
|
||||||
@ApiBody({
|
@ApiBody({ type: ClientRefreshDto })
|
||||||
schema: {
|
@ApiOkResponse({ description: '刷新成功返回新 token', type: ClientRefreshResponseDto })
|
||||||
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' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
async refresh(@Body() body: ClientRefreshDto) {
|
async refresh(@Body() body: ClientRefreshDto) {
|
||||||
return this.clientAuthService.refreshToken(body.refreshToken);
|
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';
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class ClientSendSmsDto {
|
export class ClientSendSmsDto {
|
||||||
|
@ApiProperty({ type: String, description: '手机号', example: '13800000000' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
phone!: string;
|
phone!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, description: '业务场景', example: 'login' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
scene!: string;
|
scene!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ClientLoginDto {
|
export class ClientLoginDto {
|
||||||
|
@ApiProperty({ type: String, description: '手机号', example: '13800000000' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
phone!: string;
|
phone!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, description: '短信验证码', example: '123456' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
code!: string;
|
code!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ClientRefreshDto {
|
export class ClientRefreshDto {
|
||||||
|
@ApiProperty({ type: String, description: '刷新令牌' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
refreshToken!: string;
|
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 { Module } from '@nestjs/common';
|
||||||
import { ChatController } from './controllers/chat.controller';
|
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 { ProviderRouterService } from '@shared/ai-gateway/router/provider-router.service';
|
||||||
import { QwenProvider } from '@shared/ai-gateway/providers/qwen.provider';
|
import { QwenProvider } from '@shared/ai-gateway/providers/qwen.provider';
|
||||||
import { DeepseekProvider } from '@shared/ai-gateway/providers/deepseek.provider';
|
import { DeepseekProvider } from '@shared/ai-gateway/providers/deepseek.provider';
|
||||||
import { VolcProvider } from '@shared/ai-gateway/providers/volc.provider';
|
import { VolcProvider } from '@shared/ai-gateway/providers/volc.provider';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [ChatController],
|
controllers: [ChatController, ChatSessionsController],
|
||||||
providers: [ProviderRouterService, QwenProvider, DeepseekProvider, VolcProvider],
|
providers: [
|
||||||
|
ChatSessionService,
|
||||||
|
ProviderRouterService,
|
||||||
|
QwenProvider,
|
||||||
|
DeepseekProvider,
|
||||||
|
VolcProvider,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class ChatModule {}
|
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 {
|
||||||
import { FastifyReply } from 'fastify';
|
Body,
|
||||||
import { StreamChatRequest } from '@shared/ai-gateway/types/chat.types';
|
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 { ProviderRouterService } from '@shared/ai-gateway/router/provider-router.service';
|
||||||
import { ClientJwtAuthGuard } from '../../auth/client-jwt-auth.guard';
|
import { ClientJwtAuthGuard } from '../../auth/client-jwt-auth.guard';
|
||||||
|
import { ChatSessionService } from '../application/chat-session.service';
|
||||||
|
import { StreamChatBodyDto } from '../dto/stream-chat.dto';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
@@ -21,13 +33,19 @@ function sleep(ms: number) {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClientJwtRequest = FastifyRequest & { user: { userId: string } };
|
||||||
|
|
||||||
@ApiTags('Client Chat')
|
@ApiTags('Client Chat')
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@Controller('client/v1/chat')
|
@Controller('client/v1/chat')
|
||||||
export class ChatController {
|
export class ChatController {
|
||||||
|
private readonly logger = new Logger(ChatController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(ProviderRouterService)
|
@Inject(ProviderRouterService)
|
||||||
private readonly router: ProviderRouterService,
|
private readonly router: ProviderRouterService,
|
||||||
|
@Inject(ChatSessionService)
|
||||||
|
private readonly chatSessions: ChatSessionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('completions/stream')
|
@Post('completions/stream')
|
||||||
@@ -36,62 +54,41 @@ export class ChatController {
|
|||||||
@ApiConsumes('application/json')
|
@ApiConsumes('application/json')
|
||||||
@ApiProduces('text/event-stream')
|
@ApiProduces('text/event-stream')
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
schema: {
|
type: StreamChatBodyDto,
|
||||||
type: 'object',
|
description: '统一 chat 请求体;需先手动创建会话并传入 sessionId',
|
||||||
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'],
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
description: 'SSE 流式响应:meta -> delta -> usage -> done',
|
description:
|
||||||
schema: {
|
'SSE 流式响应:event 序列为 meta → delta(多次)→ usage → done',
|
||||||
type: 'string',
|
content: {
|
||||||
example:
|
'text/event-stream': {
|
||||||
'event: meta\\ndata: {"requestId":"chatcmpl_xxx","platform":"qwen","model":"qwen-plus"}\\n\\n' +
|
schema: {
|
||||||
'event: delta\\ndata: {"delta":"你好"}\\n\\n' +
|
type: 'string',
|
||||||
'event: usage\\ndata: {"promptTokens":10,"completionTokens":20,"totalTokens":30}\\n\\n' +
|
example:
|
||||||
'event: done\\ndata: {"finishReason":"stop"}\\n\\n',
|
'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(
|
async streamChat(
|
||||||
@Body() body: StreamChatRequest,
|
@Body() body: StreamChatBodyDto,
|
||||||
|
@Req() req: ClientJwtRequest,
|
||||||
@Res() reply: FastifyReply,
|
@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('Content-Type', 'text/event-stream; charset=utf-8');
|
||||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
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.setHeader('X-Accel-Buffering', 'no');
|
||||||
reply.raw.flushHeaders?.();
|
reply.raw.flushHeaders?.();
|
||||||
|
|
||||||
|
const response = await this.router.routeAndStream(body);
|
||||||
|
|
||||||
reply.raw.write(
|
reply.raw.write(
|
||||||
formatSse('meta', {
|
formatSse('meta', {
|
||||||
requestId: response.requestId,
|
requestId: response.requestId,
|
||||||
platform: response.providerCode,
|
platform: response.providerCode,
|
||||||
model: response.model,
|
model: response.model,
|
||||||
|
sessionId: String(sessionId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let assistantText = '';
|
||||||
for (const chunk of response.chunks) {
|
for (const chunk of response.chunks) {
|
||||||
|
assistantText += chunk.content;
|
||||||
reply.raw.write(formatSse('delta', { delta: chunk.content }));
|
reply.raw.write(formatSse('delta', { delta: chunk.content }));
|
||||||
await sleep(120);
|
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('usage', response.usage));
|
||||||
reply.raw.write(formatSse('done', { finishReason: 'stop' }));
|
reply.raw.write(formatSse('done', { finishReason: 'stop' }));
|
||||||
reply.raw.end();
|
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 { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||||
import { StreamChatRequest } from '@shared/ai-gateway/types/chat.types';
|
import { StreamChatRequest } from '@shared/ai-gateway/types/chat.types';
|
||||||
|
|
||||||
class ChatMessageDto {
|
export class ChatMessageDto {
|
||||||
@ApiProperty({ enum: ['system', 'user', 'assistant'] })
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
|
enum: ['system', 'user', 'assistant'],
|
||||||
|
description: '消息角色',
|
||||||
|
example: 'user',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['system', 'user', 'assistant'])
|
@IsIn(['system', 'user', 'assistant'])
|
||||||
role!: 'system' | 'user' | 'assistant';
|
role!: 'system' | 'user' | 'assistant';
|
||||||
|
|
||||||
@ApiProperty({ description: '消息内容', example: '你是谁' })
|
@ApiProperty({ type: String, description: '消息内容', example: '你是谁' })
|
||||||
@IsString()
|
@IsString()
|
||||||
content!: string;
|
content!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StreamChatBodyDto implements StreamChatRequest {
|
export class StreamChatBodyDto implements StreamChatRequest {
|
||||||
@ApiPropertyOptional({ description: '模型名', example: 'qwen-plus' })
|
@ApiPropertyOptional({ type: String, description: '模型名', example: 'qwen-plus' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
model?: string;
|
model?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
|
description: '会话 ID(数字字符串);需先调用创建会话接口获取',
|
||||||
|
example: '1',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
sessionId!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
|
type: String,
|
||||||
description: '指定平台,不传或 auto 由路由自动选择',
|
description: '指定平台,不传或 auto 由路由自动选择',
|
||||||
enum: ['auto', 'qwen', 'deepseek', 'volc'],
|
enum: ['auto', 'qwen', 'deepseek', 'volc'],
|
||||||
example: 'qwen',
|
example: 'qwen',
|
||||||
@@ -29,7 +43,11 @@ export class StreamChatBodyDto implements StreamChatRequest {
|
|||||||
@IsString()
|
@IsString()
|
||||||
platform?: string;
|
platform?: string;
|
||||||
|
|
||||||
@ApiProperty({ type: [ChatMessageDto] })
|
@ApiProperty({
|
||||||
|
type: () => ChatMessageDto,
|
||||||
|
isArray: true,
|
||||||
|
description: '聊天消息列表(至少一条用户消息)',
|
||||||
|
})
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => ChatMessageDto)
|
@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 {
|
export interface StreamChatRequest {
|
||||||
model?: string;
|
model?: string;
|
||||||
platform?: string; // qwen | deepseek | volc | auto | demo
|
platform?: string; // qwen | deepseek | volc | auto | demo
|
||||||
|
/** 已有会话 ID;不传则本次对话新建会话并落库 */
|
||||||
|
sessionId?: string;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user