feat: 初始化 Nest 服务骨架与多平台 Chat SSE 网关
- 新增 NestJS + Fastify 入口、配置模块与 Swagger 集成 - 划分 client-app / admin-app 与 shared-domain ai-gateway - 实现统一 SSE Chat 接口,支持千问、DeepSeek、火山引擎非流式上游与网关分片输出 - 补充项目方案与 JWT 最小实现文档 Made-with: Cursor
This commit is contained in:
232
docs/jwt-minimal-nestjs.md
Normal file
232
docs/jwt-minimal-nestjs.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# NestJS JWT 最小实现(可直接改造)
|
||||
|
||||
适用目标:
|
||||
- 登录成功签发 `accessToken`、`refreshToken`
|
||||
- 受保护接口通过 `JwtAuthGuard` 鉴权
|
||||
- 支持刷新 token
|
||||
|
||||
> 示例使用 NestJS + `@nestjs/jwt` + `passport-jwt`,偏最小可用,不含完整业务细节。
|
||||
|
||||
---
|
||||
|
||||
## 1) 安装依赖
|
||||
|
||||
```bash
|
||||
npm i @nestjs/jwt @nestjs/passport passport passport-jwt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2) 约定 payload
|
||||
|
||||
建议 access token payload 只放必要字段:
|
||||
|
||||
```ts
|
||||
type JwtPayload = {
|
||||
sub: string; // 用户ID
|
||||
role: 'user' | 'admin';
|
||||
type: 'access';
|
||||
};
|
||||
```
|
||||
|
||||
refresh token 可增加 `jti`:
|
||||
|
||||
```ts
|
||||
type RefreshPayload = {
|
||||
sub: string;
|
||||
type: 'refresh';
|
||||
jti: string;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3) `auth.module.ts`
|
||||
|
||||
```ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
signOptions: { expiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h' },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4) `jwt.strategy.ts`
|
||||
|
||||
```ts
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_ACCESS_SECRET,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: { sub: string; role: string; type: string }) {
|
||||
if (payload.type !== 'access') {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
return {
|
||||
userId: payload.sub,
|
||||
role: payload.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5) `jwt-auth.guard.ts`
|
||||
|
||||
```ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6) `auth.service.ts`(登录与刷新核心)
|
||||
|
||||
```ts
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private readonly jwtService: JwtService) {}
|
||||
|
||||
// 这里仅示例;真实项目应校验数据库用户和密码/验证码
|
||||
async login(user: { id: string; role: 'user' | 'admin' }) {
|
||||
const accessToken = await this.signAccessToken(user.id, user.role);
|
||||
const refreshToken = await this.signRefreshToken(user.id);
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
let payload: { sub: string; type: string; jti: string };
|
||||
try {
|
||||
payload = await this.jwtService.verifyAsync(refreshToken, {
|
||||
secret: process.env.JWT_REFRESH_SECRET,
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
if (payload.type !== 'refresh') {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
|
||||
// 生产建议:校验 jti 是否在黑名单中(Redis)
|
||||
const newAccessToken = await this.signAccessToken(payload.sub, 'user');
|
||||
const newRefreshToken = await this.signRefreshToken(payload.sub);
|
||||
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
|
||||
}
|
||||
|
||||
private signAccessToken(sub: string, role: string) {
|
||||
return this.jwtService.signAsync(
|
||||
{ sub, role, type: 'access' },
|
||||
{
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
expiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
|
||||
issuer: 'chat-one-client',
|
||||
audience: 'chat-one-client-api',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private signRefreshToken(sub: string) {
|
||||
return this.jwtService.signAsync(
|
||||
{ sub, type: 'refresh', jti: randomUUID() },
|
||||
{
|
||||
secret: process.env.JWT_REFRESH_SECRET,
|
||||
expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||
issuer: 'chat-one-client',
|
||||
audience: 'chat-one-client-api',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7) `auth.controller.ts`
|
||||
|
||||
```ts
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
@Controller('api/client/v1/auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
async login(@Body() body: { userId: string }) {
|
||||
// 示例:实际应先校验短信验证码
|
||||
return this.authService.login({ id: body.userId, role: 'user' });
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
async refresh(@Body() body: { refreshToken: string }) {
|
||||
return this.authService.refresh(body.refreshToken);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('profile')
|
||||
async profile(@Req() req: { user: { userId: string; role: string } }) {
|
||||
return req.user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8) 双端隔离要点(你项目强烈建议)
|
||||
|
||||
- 客户端和管理端使用不同密钥:
|
||||
- `CLIENT_JWT_ACCESS_SECRET` / `CLIENT_JWT_REFRESH_SECRET`
|
||||
- `ADMIN_JWT_ACCESS_SECRET` / `ADMIN_JWT_REFRESH_SECRET`
|
||||
- 客户端和管理端使用不同 `issuer`、`audience`
|
||||
- guard 分离:`ClientJwtAuthGuard` 与 `AdminJwtAuthGuard`
|
||||
- token 绝不互认(即使都是 JWT)
|
||||
|
||||
---
|
||||
|
||||
## 9) `.env.example` 最小补充
|
||||
|
||||
```env
|
||||
JWT_ACCESS_SECRET=replace_me_access
|
||||
JWT_REFRESH_SECRET=replace_me_refresh
|
||||
JWT_ACCESS_EXPIRES_IN=2h
|
||||
JWT_REFRESH_EXPIRES_IN=30d
|
||||
```
|
||||
|
||||
如果你下一步要,我可以按你现在文档里的目录(`apps/client-app` + `apps/admin-app`)直接生成一套可运行的代码骨架(含双端 JWT 隔离版本)。
|
||||
568
docs/project-solution.md
Normal file
568
docs/project-solution.md
Normal file
@@ -0,0 +1,568 @@
|
||||
# ChatOne Service 项目方案(NestJS)
|
||||
|
||||
本文给出一个主流且简洁、可直接落地的服务端方案,满足以下目标:
|
||||
- 基于 NestJS 构建;
|
||||
- 同时提供客户端接口与后台管理接口;
|
||||
- 支持短信登录、邮箱登录;
|
||||
- 支持千问、火山引擎、DeepSeek 等多平台 AI Chat 流式输出;
|
||||
- 对外提供统一调用接口(自动路由)和指定平台调用接口;
|
||||
- 提供用户管理、平台管理、用量统计。
|
||||
|
||||
---
|
||||
|
||||
## 1. 技术选型(主流且简洁)
|
||||
|
||||
- **运行时**:Node.js 20 LTS
|
||||
- **框架**:NestJS 10(Fastify 适配器,性能更优)
|
||||
- **ORM**:Prisma(类型安全、迁移友好)
|
||||
- **数据库**:PostgreSQL 15
|
||||
- **缓存/队列**:Redis 7(缓存、限流、验证码、会话黑名单)
|
||||
- **鉴权**:JWT(Access + Refresh)+ RBAC
|
||||
- **文档**:Swagger(`@nestjs/swagger`)
|
||||
- **校验**:`class-validator` + `class-transformer`
|
||||
- **日志**:Pino(`nestjs-pino`)
|
||||
- **任务调度**:`@nestjs/schedule`(统计聚合作业)
|
||||
- **流式输出**:SSE(标准 EventSource,前端接入简单)
|
||||
|
||||
---
|
||||
|
||||
## 2. 项目结构(推荐:按用户端/管理端强隔离)
|
||||
|
||||
```text
|
||||
chat-one-service/
|
||||
├─ src/
|
||||
│ ├─ main.ts # 单体双端统一启动入口(MVP)
|
||||
│ ├─ app.module.ts # 根模块:聚合 client/admin/shared
|
||||
│ ├─ common/
|
||||
│ │ ├─ constants/ # 全局常量(错误码前缀、header key 等)
|
||||
│ │ ├─ decorators/ # 自定义装饰器(当前用户、角色、公开路由)
|
||||
│ │ ├─ filters/ # 全局异常过滤(统一错误响应格式)
|
||||
│ │ ├─ guards/ # 通用 guard(按需被 client/admin 复用)
|
||||
│ │ ├─ interceptors/ # 请求日志、traceId、响应耗时统计
|
||||
│ │ ├─ pipes/ # DTO 校验与类型转换
|
||||
│ │ └─ utils/ # 通用工具函数(时间、脱敏、签名等)
|
||||
│ ├─ config/
|
||||
│ │ ├─ configuration.ts # 读取并组织配置(按模块导出)
|
||||
│ │ ├─ validation.ts # 启动前环境变量校验(fail fast)
|
||||
│ │ └─ swagger.ts # Swagger 初始化与分组(client/admin)
|
||||
│ ├─ apps/
|
||||
│ │ ├─ client-app/ # 用户端边界:面向 C 端业务能力
|
||||
│ │ │ ├─ client-app.module.ts # 用户端聚合模块(auth/chat/sessions/profile)
|
||||
│ │ │ ├─ auth/ # 用户端认证:短信验证码、登录、refresh
|
||||
│ │ │ ├─ chat/
|
||||
│ │ │ │ ├─ chat.module.ts # chat 子模块聚合入口
|
||||
│ │ │ │ ├─ controllers/ # 对外接口层(统一/指定平台 SSE 入口)
|
||||
│ │ │ │ ├─ application/ # 用例编排层(配额检查、路由、日志)
|
||||
│ │ │ │ ├─ domain/ # 领域规则层(会话/消息/策略)
|
||||
│ │ │ │ ├─ infrastructure/ # 基础设施层(Prisma/Redis/SSE/事件)
|
||||
│ │ │ │ ├─ dto/ # 请求/响应 DTO(仅用户端可见字段)
|
||||
│ │ │ │ └─ constants/ # chat 常量(事件类型、错误码、模型别名)
|
||||
│ │ │ ├─ sessions/ # 会话与历史查询(可逐步并入 chat)
|
||||
│ │ │ └─ profile/ # 用户资料、偏好设置、个人配置
|
||||
│ │ ├─ admin-app/ # 管理端边界:面向运营/管理能力
|
||||
│ │ │ ├─ admin-app.module.ts # 管理端聚合模块(auth/users/platforms/stats)
|
||||
│ │ │ ├─ auth/ # 管理端认证:邮箱登录、RBAC、审计
|
||||
│ │ │ ├─ users/ # 用户管理:查询、禁用、角色与额度治理
|
||||
│ │ │ ├─ platforms/ # 平台管理:密钥、权重、模型映射、健康检查
|
||||
│ │ │ ├─ stats/ # 统计报表:请求量、token、成本、错误率
|
||||
│ │ │ └─ audits/ # 审计日志:关键管理操作留痕
|
||||
│ │ └─ shared-domain/ # 双端共享能力(无业务端特有语义)
|
||||
│ │ ├─ ai-gateway/
|
||||
│ │ │ ├─ providers/ # 第三方平台适配实现(qwen/volc/deepseek)
|
||||
│ │ │ │ ├─ provider.interface.ts# provider 抽象协议,统一调用方式
|
||||
│ │ │ │ ├─ qwen.provider.ts
|
||||
│ │ │ │ ├─ volc.provider.ts
|
||||
│ │ │ │ └─ deepseek.provider.ts
|
||||
│ │ │ ├─ router/
|
||||
│ │ │ │ └─ provider-router.service.ts # 自动路由与降级策略
|
||||
│ │ │ └─ formatter/
|
||||
│ │ │ └─ stream-normalizer.service.ts # 统一 SSE chunk 输出协议
|
||||
│ │ ├─ identity/ # 身份能力:JWT 签发、策略、guard、token 工具
|
||||
│ │ ├─ sms/ # 短信能力:验证码发送、频控、模板封装
|
||||
│ │ ├─ mail/ # 邮件能力:登录通知、告警通知、模板发送
|
||||
│ │ └─ stats-core/ # 统计核心:聚合计算、指标定义、通用查询
|
||||
│ ├─ prisma/
|
||||
│ │ ├─ prisma.module.ts # Prisma 注入模块
|
||||
│ │ └─ prisma.service.ts # PrismaClient 生命周期与扩展
|
||||
│ └─ types/ # 全局类型声明(枚举、共享接口)
|
||||
├─ prisma/
|
||||
│ ├─ schema.prisma # 数据模型定义
|
||||
│ └─ migrations/ # 数据库迁移历史
|
||||
├─ docs/
|
||||
│ └─ project-solution.md # 架构与实现方案文档
|
||||
├─ .env.example # 环境变量示例
|
||||
├─ package.json # 依赖与脚本
|
||||
└─ tsconfig.json # TypeScript 编译配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 模块职责
|
||||
|
||||
### 3.1 用户端(`client-app`)
|
||||
|
||||
- **client-auth**
|
||||
- 仅负责短信验证码发送/校验、客户端 token 签发与刷新
|
||||
- **chat**
|
||||
- 提供统一 chat 接口与指定平台 chat 接口
|
||||
- 对外只暴露用户侧可见字段,不暴露平台敏感信息
|
||||
- 按 `controller -> application -> domain -> infrastructure` 分层,便于平滑拆分
|
||||
- **sessions/profile(可选)**
|
||||
- 会话记录、消息历史、个人资料
|
||||
|
||||
#### chat 子目录建议(简化版)
|
||||
|
||||
- **controllers**
|
||||
- 仅处理 HTTP/SSE 协议、参数校验、响应头,不写业务逻辑
|
||||
- **application**
|
||||
- 编排用例流程(鉴权通过后,做配额检查、路由 provider、写 usage)
|
||||
- **domain**
|
||||
- 放会话、消息、配额策略等核心业务规则与仓储抽象接口
|
||||
- **infrastructure**
|
||||
- 对接 Prisma、Redis、SSE writer、消息队列等外部依赖实现
|
||||
- **dto**
|
||||
- 负责 API 入参与出参定义,避免直接暴露内部模型
|
||||
- **constants**
|
||||
- 统一维护 chat 错误码、模型别名、事件类型常量
|
||||
|
||||
建议保持这个粒度即可,后续按开发规模再向下细分,不必一开始拆到文件级别。
|
||||
|
||||
### 3.2 管理端(`admin-app`)
|
||||
|
||||
- **admin-auth**
|
||||
- 仅负责邮箱密码登录(可选二次验证码)
|
||||
- **users**
|
||||
- 用户管理、封禁/解禁、角色治理
|
||||
- **platforms**
|
||||
- AI 平台开关、密钥、权重、模型映射、健康检查
|
||||
- **stats/audits**
|
||||
- 用量统计、成本分析、审计日志
|
||||
|
||||
### 3.3 共享域(`shared-domain`)
|
||||
|
||||
- **ai-gateway**
|
||||
- 第三方平台适配、自动路由、流式格式统一
|
||||
- **identity**
|
||||
- JWT strategy、通用 guard、token 工具
|
||||
- **sms/mail/stats-core**
|
||||
- 可复用基础设施能力,避免双端重复实现
|
||||
|
||||
> 设计原则:控制器和应用服务严格按端隔离,后续拆分时优先迁移 `client-app` 或 `admin-app` 整个目录,shared 部分按需复制或抽公共包。
|
||||
|
||||
---
|
||||
|
||||
## 4. API 设计(示例)
|
||||
|
||||
统一前缀建议:
|
||||
- 客户端:`/api/client/v1`
|
||||
- 管理端:`/api/admin/v1`
|
||||
|
||||
为避免后期冲突,建议从现在开始执行以下规范:
|
||||
- DTO 分离:`client-app/dto` 与 `admin-app/dto` 禁止互相引用;
|
||||
- Guard 分离:`ClientJwtGuard` 与 `AdminJwtGuard` 不混用;
|
||||
- Swagger 分离:客户端文档与管理端文档分组输出(如 `/docs-client`、`/docs-admin`);
|
||||
- 错误码分离:客户端错误码与管理端错误码使用不同前缀(如 `C_` / `A_`)。
|
||||
|
||||
### 4.1 客户端认证接口(短信)
|
||||
|
||||
- `POST /api/client/v1/auth/sms/send`
|
||||
- 入参:`{ phone, scene }`
|
||||
- 出参:`{ requestId, expireIn }`
|
||||
|
||||
- `POST /api/client/v1/auth/sms/login`
|
||||
- 入参:`{ phone, code }`
|
||||
- 出参:`{ accessToken, refreshToken, user }`
|
||||
|
||||
- `POST /api/client/v1/auth/refresh`
|
||||
- 入参:`{ refreshToken }`
|
||||
- 出参:`{ accessToken, refreshToken }`
|
||||
|
||||
### 4.2 客户端 AI Chat 接口(流式)
|
||||
|
||||
- `POST /api/client/v1/chat/completions/stream`
|
||||
- 说明:统一接口,后台自动路由平台
|
||||
- 入参示例:
|
||||
```json
|
||||
{
|
||||
"model": "gpt-4o-mini-like",
|
||||
"messages": [
|
||||
{ "role": "system", "content": "你是助手" },
|
||||
{ "role": "user", "content": "介绍一下NestJS" }
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"platform": "auto"
|
||||
}
|
||||
```
|
||||
- 返回:`text/event-stream`(SSE)
|
||||
|
||||
- `POST /api/client/v1/chat/completions/stream/:platform`
|
||||
- 说明:指定平台(`qwen | volc | deepseek`)
|
||||
- 其余参数同上
|
||||
|
||||
### 4.3 建议统一 SSE 事件格式
|
||||
|
||||
```text
|
||||
event: meta
|
||||
data: {"requestId":"xxx","platform":"qwen","model":"qwen-turbo"}
|
||||
|
||||
event: delta
|
||||
data: {"content":"你好"}
|
||||
|
||||
event: usage
|
||||
data: {"promptTokens":120,"completionTokens":80,"totalTokens":200}
|
||||
|
||||
event: done
|
||||
data: {"finishReason":"stop"}
|
||||
|
||||
event: error
|
||||
data: {"code":"PLATFORM_TIMEOUT","message":"upstream timeout"}
|
||||
```
|
||||
|
||||
### 4.4 管理端认证接口(邮箱)
|
||||
|
||||
- `POST /api/admin/v1/auth/login`
|
||||
- 入参:`{ email, password }`
|
||||
- 出参:`{ accessToken, refreshToken, admin }`
|
||||
|
||||
- `POST /api/admin/v1/auth/refresh`
|
||||
- `POST /api/admin/v1/auth/logout`
|
||||
|
||||
### 4.5 管理端用户管理
|
||||
|
||||
- `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 管理端平台管理
|
||||
|
||||
- `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 管理端统计
|
||||
|
||||
- `GET /api/admin/v1/stats/overview?startDate=&endDate=`
|
||||
- `GET /api/admin/v1/stats/platforms`
|
||||
- `GET /api/admin/v1/stats/users/top`
|
||||
|
||||
---
|
||||
|
||||
## 5. 自动路由与统一格式方案
|
||||
|
||||
### 5.1 自动路由策略(简版)
|
||||
|
||||
按优先级执行:
|
||||
1. 过滤禁用平台;
|
||||
2. 过滤健康检查失败平台;
|
||||
3. 根据模型支持能力过滤;
|
||||
4. 根据权重 + 当前错误率 + 当前限流剩余综合打分;
|
||||
5. 选择得分最高平台;
|
||||
6. 失败时自动降级重试下一个平台(最多 1~2 次)。
|
||||
|
||||
### 5.2 统一消息格式(内部 DTO)
|
||||
|
||||
- `ChatMessageDto`: `{ role, content, name?, toolCall? }`
|
||||
- `ChatRequestDto`: `{ model, messages, temperature, topP, stream, userId }`
|
||||
- `ChatChunkDto`: `{ type: 'meta'|'delta'|'usage'|'done'|'error', payload }`
|
||||
|
||||
第三方差异全部在 provider 内部适配,Controller 对外始终输出统一 SSE 事件。
|
||||
|
||||
---
|
||||
|
||||
## 6. 依赖清单(核心)
|
||||
|
||||
```bash
|
||||
# Nest 基础
|
||||
npm i @nestjs/common @nestjs/core @nestjs/platform-fastify @nestjs/config @nestjs/jwt @nestjs/passport @nestjs/swagger
|
||||
npm i class-validator class-transformer passport passport-jwt
|
||||
|
||||
# 数据库/缓存
|
||||
npm i @prisma/client ioredis
|
||||
npm i -D prisma
|
||||
|
||||
# 日志/安全/限流
|
||||
npm i nestjs-pino pino-http helmet @nestjs/throttler
|
||||
|
||||
# 任务/工具
|
||||
npm i @nestjs/schedule dayjs
|
||||
|
||||
# 第三方请求
|
||||
npm i undici
|
||||
|
||||
# 开发依赖
|
||||
npm i -D typescript ts-node tsx @types/node eslint prettier
|
||||
```
|
||||
|
||||
可选增强:
|
||||
- `argon2`:密码哈希
|
||||
- `zod`:配置或响应结构额外校验
|
||||
- `@opentelemetry/api`:链路追踪
|
||||
|
||||
---
|
||||
|
||||
## 7. 配置文件示例
|
||||
|
||||
### 7.1 `.env.example`
|
||||
|
||||
```env
|
||||
# App
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
APP_NAME=chat-one-service
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
|
||||
# JWT
|
||||
JWT_ACCESS_SECRET=replace_me_access
|
||||
JWT_REFRESH_SECRET=replace_me_refresh
|
||||
JWT_ACCESS_EXPIRES_IN=2h
|
||||
JWT_REFRESH_EXPIRES_IN=30d
|
||||
|
||||
# PostgreSQL
|
||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/chat_one?schema=public
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# SMS
|
||||
SMS_PROVIDER=aliyun
|
||||
SMS_ACCESS_KEY_ID=your_key
|
||||
SMS_ACCESS_KEY_SECRET=your_secret
|
||||
SMS_SIGN_NAME=ChatOne
|
||||
SMS_TEMPLATE_CODE_LOGIN=SMS_123456789
|
||||
SMS_CODE_TTL_SECONDS=300
|
||||
|
||||
# Mail (admin login / notice)
|
||||
MAIL_HOST=smtp.qq.com
|
||||
MAIL_PORT=465
|
||||
MAIL_SECURE=true
|
||||
MAIL_USER=xxx@qq.com
|
||||
MAIL_PASS=app_password
|
||||
MAIL_FROM=ChatOne <xxx@qq.com>
|
||||
|
||||
# AI Providers
|
||||
QWEN_API_KEY=your_qwen_key
|
||||
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
VOLC_API_KEY=your_volc_key
|
||||
VOLC_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
DEEPSEEK_API_KEY=your_deepseek_key
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
|
||||
# Route Strategy
|
||||
AI_ROUTE_RETRY_TIMES=1
|
||||
AI_ROUTE_TIMEOUT_MS=45000
|
||||
|
||||
# Split-ready(拆分预留)
|
||||
CLIENT_PORT=3000
|
||||
ADMIN_PORT=3001
|
||||
CLIENT_JWT_ACCESS_SECRET=replace_me_client_access
|
||||
ADMIN_JWT_ACCESS_SECRET=replace_me_admin_access
|
||||
REDIS_KEY_PREFIX_CLIENT=chatone:client
|
||||
REDIS_KEY_PREFIX_ADMIN=chatone:admin
|
||||
```
|
||||
|
||||
### 7.2 `src/config/validation.ts`(建议)
|
||||
|
||||
使用 `class-validator` 或 `zod` 校验环境变量,应用启动即失败(fail fast),避免线上缺配置。
|
||||
|
||||
---
|
||||
|
||||
## 8. 数据库设计(PostgreSQL)
|
||||
|
||||
以下为简化且够用的核心表:
|
||||
|
||||
### 8.1 用户与管理员
|
||||
|
||||
- `users`
|
||||
- `id` (bigserial pk)
|
||||
- `phone` (varchar unique)
|
||||
- `nickname` (varchar)
|
||||
- `status` (smallint, 1正常 0禁用)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
- `admins`
|
||||
- `id`
|
||||
- `email` (varchar unique)
|
||||
- `password_hash` (varchar)
|
||||
- `role` (varchar: super_admin/admin/ops)
|
||||
- `status`
|
||||
- `last_login_at`
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
### 8.2 平台配置与模型映射
|
||||
|
||||
- `ai_platforms`
|
||||
- `id`
|
||||
- `code` (varchar unique: qwen/volc/deepseek)
|
||||
- `name`
|
||||
- `enabled` (bool)
|
||||
- `weight` (int default 100)
|
||||
- `priority` (int default 100)
|
||||
- `base_url`
|
||||
- `api_key_encrypted`
|
||||
- `timeout_ms`
|
||||
- `rpm_limit`
|
||||
- `health_status` (varchar: healthy/unhealthy/unknown)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
- `ai_platform_models`
|
||||
- `id`
|
||||
- `platform_id` (fk)
|
||||
- `biz_model` (varchar) # 业务统一模型名
|
||||
- `provider_model` (varchar) # 平台真实模型名
|
||||
- `enabled` (bool)
|
||||
- unique(platform_id, biz_model)
|
||||
|
||||
### 8.3 会话与消息(可选落库)
|
||||
|
||||
- `chat_sessions`
|
||||
- `id`
|
||||
- `user_id` (fk)
|
||||
- `title`
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
- `chat_messages`
|
||||
- `id`
|
||||
- `session_id` (fk)
|
||||
- `role` (user/assistant/system)
|
||||
- `content` (text)
|
||||
- `token_count` (int)
|
||||
- `provider` (varchar)
|
||||
- `created_at`
|
||||
|
||||
### 8.4 请求审计与统计
|
||||
|
||||
- `ai_request_logs`
|
||||
- `id`
|
||||
- `request_id` (varchar unique)
|
||||
- `user_id` (fk nullable)
|
||||
- `platform_code`
|
||||
- `biz_model`
|
||||
- `provider_model`
|
||||
- `status` (success/fail)
|
||||
- `error_code`
|
||||
- `latency_ms`
|
||||
- `prompt_tokens`
|
||||
- `completion_tokens`
|
||||
- `total_tokens`
|
||||
- `estimated_cost`
|
||||
- `created_at`
|
||||
|
||||
- `usage_daily_stats`
|
||||
- `id`
|
||||
- `stat_date` (date)
|
||||
- `dimension` (platform/user/total)
|
||||
- `dimension_key` (varchar)
|
||||
- `request_count`
|
||||
- `success_count`
|
||||
- `fail_count`
|
||||
- `total_tokens`
|
||||
- `estimated_cost`
|
||||
- unique(stat_date, dimension, dimension_key)
|
||||
|
||||
---
|
||||
|
||||
## 9. 缓存方案(Redis)
|
||||
|
||||
建议键设计:
|
||||
|
||||
- 短信验证码:`sms:code:{phone}:{scene}`(TTL 5 分钟)
|
||||
- 短信发送频控:`sms:send:limit:{phone}`(TTL 60 秒)
|
||||
- 刷新令牌黑名单:`auth:rt:blacklist:{jti}`(TTL 到 token 过期)
|
||||
- 用户会话缓存:`auth:user:{userId}`(TTL 30 分钟,可选)
|
||||
- 平台健康状态:`ai:platform:health:{code}`(TTL 30 秒)
|
||||
- 平台动态权重:`ai:platform:weight:{code}`(TTL 60 秒)
|
||||
- 统计临时聚合:`stats:daily:{date}:{dimension}`(TTL 1 天)
|
||||
|
||||
为便于拆分,建议从第一天就加命名空间前缀:
|
||||
- 用户端:`chatone:client:*`
|
||||
- 管理端:`chatone:admin:*`
|
||||
- 共享:`chatone:shared:*`
|
||||
|
||||
同时启用 `@nestjs/throttler` 做接口限流:
|
||||
- 客户端 chat 接口:按用户 + IP 双维度限流;
|
||||
- 登录接口:按手机号/邮箱严格限流,防刷。
|
||||
|
||||
---
|
||||
|
||||
## 10. 登录与鉴权方案
|
||||
|
||||
### 10.1 客户端(短信登录)
|
||||
|
||||
1. 用户请求发送验证码;
|
||||
2. 服务端生成验证码写入 Redis(哈希存储,避免明文);
|
||||
3. 用户提交验证码登录,校验通过后签发 JWT;
|
||||
4. AccessToken 短期(2h),RefreshToken 长期(30d);
|
||||
5. 刷新时校验 RefreshToken 的 `jti` 与黑名单状态;
|
||||
6. 注销时将 RefreshToken `jti` 拉黑至过期。
|
||||
|
||||
### 10.2 管理端(邮箱登录)
|
||||
|
||||
1. 邮箱 + 密码登录(密码 `argon2` 哈希);
|
||||
2. 可选开启邮箱二次验证码;
|
||||
3. JWT + RBAC(`super_admin` / `admin` / `ops`);
|
||||
4. 管理端接口统一加 `JwtAuthGuard + RolesGuard`;
|
||||
5. 关键操作(平台密钥修改、用户封禁)记录审计日志。
|
||||
|
||||
### 10.3 Token 建议
|
||||
|
||||
- AccessToken:只放必要字段(`sub`, `role`, `type`)
|
||||
- RefreshToken:包含 `jti`,便于撤销
|
||||
- 密钥轮转:支持双密钥平滑切换(`kid`)
|
||||
- 传输:优先 `Authorization: Bearer`;管理端也可改 HttpOnly Cookie
|
||||
- 强隔离建议:客户端与管理端使用不同 `issuer`、`audience`、`secret`,token 绝不互认
|
||||
|
||||
---
|
||||
|
||||
## 11. 拆分演进路线(低成本)
|
||||
|
||||
### 阶段 A:单体双端(当前推荐)
|
||||
- 一个 Nest 进程,两个 AppModule(`client-app` / `admin-app`);
|
||||
- 共用数据库与 Redis,但 key 前缀、token 体系已隔离;
|
||||
- 发布快,开发成本最低。
|
||||
|
||||
### 阶段 B:双进程同仓
|
||||
- 启动两个入口:`main.client.ts`、`main.admin.ts`;
|
||||
- 用户端和管理端可独立扩容、独立发布;
|
||||
- 共享逻辑仍来自 `shared-domain`。
|
||||
|
||||
### 阶段 C:双服务拆仓(可选)
|
||||
- `client-service`:保留短信、chat、会话;
|
||||
- `admin-service`:保留邮箱登录、平台管理、统计审计;
|
||||
- `shared-domain` 抽为内部 npm 包或 Git 子模块。
|
||||
|
||||
---
|
||||
|
||||
## 12. 最小可用开发流程(MVP)
|
||||
|
||||
建议按下面顺序实现,2~3 周可交付可用版本:
|
||||
|
||||
1. 初始化 NestJS + Prisma + PostgreSQL + Redis;
|
||||
2. 完成客户端短信登录与 JWT 刷新;
|
||||
3. 完成 Qwen 单平台流式 chat;
|
||||
4. 抽象 provider 接口,接入 Volc、DeepSeek;
|
||||
5. 实现统一流式格式 + 自动路由;
|
||||
6. 完成管理端邮箱登录;
|
||||
7. 完成平台管理、用户管理;
|
||||
8. 完成请求日志与基础统计报表;
|
||||
9. 完成 Swagger、限流、异常处理与日志。
|
||||
|
||||
---
|
||||
|
||||
## 13. 非功能建议(上线前)
|
||||
|
||||
- **安全**:API Key 加密存储;生产环境开启 Helmet/CORS 白名单;
|
||||
- **稳定性**:上游超时、熔断、重试与降级;
|
||||
- **可观测性**:请求链路 ID、结构化日志、错误告警;
|
||||
- **成本控制**:平台权重 + 单用户日额度 + 模型白名单;
|
||||
- **测试**:核心流程至少有 e2e(登录、chat、管理端鉴权)。
|
||||
|
||||
---
|
||||
|
||||
## 14. 一句话总结
|
||||
|
||||
该方案以 NestJS + Prisma + PostgreSQL + Redis 为核心,采用“**用户端/管理端强隔离 + shared-domain 复用**”组织代码,在保持开发效率的同时确保后续可平滑拆分为两个独立服务,并持续支持多平台 AI 路由与统一流式协议。
|
||||
52
package.json
Normal file
52
package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "chat-one-service",
|
||||
"version": "1.0.0",
|
||||
"description": "ChatOne service",
|
||||
"main": "dist/main.js",
|
||||
"repository": "ssh://git@git.alboped.com:8022/alboped/chat-one-service.git",
|
||||
"author": "alboped <shi_zhaojun@aliyun.com>",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/main.js",
|
||||
"start:dev": "tsx watch src/main.ts",
|
||||
"start:debug": "node --inspect=0.0.0.0:9229 dist/main.js",
|
||||
"lint": "eslint \"src/**/*.ts\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/static": "^9.1.1",
|
||||
"@nestjs/common": "^11.1.19",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.19",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-fastify": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/swagger": "^11.3.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/client": "^7.7.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"nestjs-pino": "^4.6.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pino-http": "^11.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"undici": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"eslint": "^10.2.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prettier": "^3.8.3",
|
||||
"prisma": "^7.7.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
32
src/app.module.ts
Normal file
32
src/app.module.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { ClientAppModule } from './apps/client-app/client-app.module';
|
||||
import { AdminAppModule } from './apps/admin-app/admin-app.module';
|
||||
import configuration from './config/configuration';
|
||||
import { validateEnv } from './config/validation';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [configuration],
|
||||
// validate: validateEnv,
|
||||
}),
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
transport:
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: { colorize: true, translateTime: 'SYS:standard' },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
ClientAppModule,
|
||||
AdminAppModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
7
src/apps/admin-app/admin-app.module.ts
Normal file
7
src/apps/admin-app/admin-app.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
})
|
||||
export class AdminAppModule {}
|
||||
|
||||
3
src/apps/client-app/chat/application/chat.service.ts
Normal file
3
src/apps/client-app/chat/application/chat.service.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// 占位文件,避免 TS include 报错;当前逻辑已直接在 Controller 中调用 ProviderRouterService。
|
||||
export {};
|
||||
|
||||
13
src/apps/client-app/chat/chat.module.ts
Normal file
13
src/apps/client-app/chat/chat.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatController } from './controllers/chat.controller';
|
||||
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],
|
||||
})
|
||||
export class ChatModule {}
|
||||
|
||||
52
src/apps/client-app/chat/controllers/chat.controller.ts
Normal file
52
src/apps/client-app/chat/controllers/chat.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Body, Controller, Inject, Post, Res } from '@nestjs/common';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { StreamChatRequest } from '@shared/ai-gateway/types/chat.types';
|
||||
import { ProviderRouterService } from '@shared/ai-gateway/router/provider-router.service';
|
||||
|
||||
function formatSse(event: string, data: unknown) {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@Controller('client/v1/chat')
|
||||
export class ChatController {
|
||||
constructor(
|
||||
@Inject(ProviderRouterService)
|
||||
private readonly router: ProviderRouterService,
|
||||
) {}
|
||||
|
||||
@Post('completions/stream')
|
||||
async streamChat(
|
||||
@Body() body: StreamChatRequest,
|
||||
@Res() reply: FastifyReply,
|
||||
) {
|
||||
const response = await this.router.routeAndStream(body);
|
||||
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
reply.raw.flushHeaders?.();
|
||||
|
||||
reply.raw.write(
|
||||
formatSse('meta', {
|
||||
requestId: response.requestId,
|
||||
platform: response.providerCode,
|
||||
model: response.model,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const chunk of response.chunks) {
|
||||
reply.raw.write(formatSse('delta', { delta: chunk.content }));
|
||||
await sleep(120);
|
||||
}
|
||||
|
||||
reply.raw.write(formatSse('usage', response.usage));
|
||||
reply.raw.write(formatSse('done', { finishReason: 'stop' }));
|
||||
reply.raw.end();
|
||||
}
|
||||
}
|
||||
|
||||
8
src/apps/client-app/client-app.module.ts
Normal file
8
src/apps/client-app/client-app.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatModule } from './chat/chat.module';
|
||||
|
||||
@Module({
|
||||
imports: [ChatModule],
|
||||
})
|
||||
export class ClientAppModule {}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { request } from 'undici';
|
||||
import { ProviderStreamResult, StreamChatRequest } from '../types/chat.types';
|
||||
import { AiProvider } from './provider.interface';
|
||||
|
||||
@Injectable()
|
||||
export class DeepseekProvider implements AiProvider {
|
||||
readonly code = 'deepseek';
|
||||
|
||||
supports(model?: string): boolean {
|
||||
if (!model) return true;
|
||||
return model.toLowerCase().includes('deepseek');
|
||||
}
|
||||
|
||||
async streamChat(req: StreamChatRequest): Promise<ProviderStreamResult> {
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY;
|
||||
const baseUrl = process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/v1';
|
||||
|
||||
if (!apiKey) {
|
||||
throw new InternalServerErrorException('DEEPSEEK_API_KEY 未配置');
|
||||
}
|
||||
|
||||
const model = req.model || 'deepseek-chat';
|
||||
const upstreamBody = {
|
||||
model,
|
||||
stream: false,
|
||||
messages: (req.messages || []).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
};
|
||||
|
||||
const { statusCode, body } = await request(`${baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(upstreamBody),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const text = await body.text();
|
||||
throw new InternalServerErrorException(
|
||||
`DeepSeek 调用失败: ${statusCode} - ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await body.json()) as any;
|
||||
const content: string =
|
||||
json.choices?.[0]?.message?.content ??
|
||||
'[DeepSeek] 未返回内容,请检查请求参数或模型配置。';
|
||||
|
||||
const promptTokens: number = json.usage?.prompt_tokens ?? 0;
|
||||
const completionTokens: number = json.usage?.completion_tokens ?? 0;
|
||||
const totalTokens: number =
|
||||
json.usage?.total_tokens ?? Math.max(1, promptTokens + completionTokens);
|
||||
|
||||
const chunks = this.splitText(content, 24).map((c) => ({ content: c }));
|
||||
|
||||
return {
|
||||
requestId: json.id || `deepseek_${Date.now()}`,
|
||||
providerCode: this.code,
|
||||
model,
|
||||
chunks,
|
||||
usage: { promptTokens, completionTokens, totalTokens },
|
||||
};
|
||||
}
|
||||
|
||||
private splitText(text: string, size: number) {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < text.length; i += size) {
|
||||
result.push(text.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StreamChatRequest, ProviderStreamResult } from '../types/chat.types';
|
||||
|
||||
export interface AiProvider {
|
||||
readonly code: string; // qwen | deepseek | volc | demo
|
||||
|
||||
supports(model?: string): boolean;
|
||||
|
||||
streamChat(req: StreamChatRequest): Promise<ProviderStreamResult>;
|
||||
}
|
||||
|
||||
92
src/apps/shared-domain/ai-gateway/providers/qwen.provider.ts
Normal file
92
src/apps/shared-domain/ai-gateway/providers/qwen.provider.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { ProviderStreamResult, StreamChatRequest } from '../types/chat.types';
|
||||
import { AiProvider } from './provider.interface';
|
||||
import { request } from 'undici';
|
||||
|
||||
@Injectable()
|
||||
export class QwenProvider implements AiProvider {
|
||||
readonly code = 'qwen';
|
||||
|
||||
supports(model?: string): boolean {
|
||||
if (!model) return true;
|
||||
return model.toLowerCase().includes('qwen');
|
||||
}
|
||||
|
||||
async streamChat(req: StreamChatRequest): Promise<ProviderStreamResult> {
|
||||
const apiKey = process.env.QWEN_API_KEY;
|
||||
const baseUrl =
|
||||
process.env.QWEN_BASE_URL ||
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
|
||||
if (!apiKey) {
|
||||
throw new InternalServerErrorException('QWEN_API_KEY 未配置');
|
||||
}
|
||||
|
||||
const model = req.model || 'qwen-plus';
|
||||
|
||||
const upstreamBody = {
|
||||
model,
|
||||
stream: false, // 先用非流式,统一在网关层拆分为 SSE
|
||||
messages: (req.messages || []).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
};
|
||||
|
||||
const { statusCode, body } = await request(
|
||||
`${baseUrl}/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(upstreamBody),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const text = await body.text();
|
||||
throw new InternalServerErrorException(
|
||||
`Qwen 调用失败: ${statusCode} - ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await body.json()) as any;
|
||||
|
||||
const choice = json.choices?.[0];
|
||||
const content: string =
|
||||
choice?.message?.content ??
|
||||
'[Qwen] 未返回内容,请检查请求参数或模型配置。';
|
||||
|
||||
const promptTokens: number = json.usage?.prompt_tokens ?? 0;
|
||||
const completionTokens: number = json.usage?.completion_tokens ?? 0;
|
||||
const totalTokens: number =
|
||||
json.usage?.total_tokens ??
|
||||
Math.max(1, promptTokens + completionTokens);
|
||||
|
||||
const chunks = this.splitText(content, 24).map((c) => ({ content: c }));
|
||||
|
||||
return {
|
||||
requestId: json.id || `qwen_${Date.now()}`,
|
||||
providerCode: this.code,
|
||||
model,
|
||||
chunks,
|
||||
usage: {
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private splitText(text: string, size: number) {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < text.length; i += size) {
|
||||
result.push(text.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
82
src/apps/shared-domain/ai-gateway/providers/volc.provider.ts
Normal file
82
src/apps/shared-domain/ai-gateway/providers/volc.provider.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { request } from 'undici';
|
||||
import { ProviderStreamResult, StreamChatRequest } from '../types/chat.types';
|
||||
import { AiProvider } from './provider.interface';
|
||||
|
||||
@Injectable()
|
||||
export class VolcProvider implements AiProvider {
|
||||
readonly code = 'volc';
|
||||
|
||||
supports(model?: string): boolean {
|
||||
if (!model) return true;
|
||||
return (
|
||||
model.toLowerCase().includes('volc') ||
|
||||
model.toLowerCase().includes('ark')
|
||||
);
|
||||
}
|
||||
|
||||
async streamChat(req: StreamChatRequest): Promise<ProviderStreamResult> {
|
||||
const apiKey = process.env.VOLC_API_KEY;
|
||||
const baseUrl =
|
||||
process.env.VOLC_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3';
|
||||
|
||||
if (!apiKey) {
|
||||
throw new InternalServerErrorException('VOLC_API_KEY 未配置');
|
||||
}
|
||||
|
||||
const model = req.model || 'ep-default';
|
||||
const upstreamBody = {
|
||||
model,
|
||||
stream: false,
|
||||
messages: (req.messages || []).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
};
|
||||
|
||||
const { statusCode, body } = await request(`${baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(upstreamBody),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const text = await body.text();
|
||||
throw new InternalServerErrorException(
|
||||
`火山引擎调用失败: ${statusCode} - ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await body.json()) as any;
|
||||
const content: string =
|
||||
json.choices?.[0]?.message?.content ??
|
||||
'[Volc] 未返回内容,请检查请求参数或模型配置。';
|
||||
|
||||
const promptTokens: number = json.usage?.prompt_tokens ?? 0;
|
||||
const completionTokens: number = json.usage?.completion_tokens ?? 0;
|
||||
const totalTokens: number =
|
||||
json.usage?.total_tokens ?? Math.max(1, promptTokens + completionTokens);
|
||||
|
||||
const chunks = this.splitText(content, 24).map((c) => ({ content: c }));
|
||||
|
||||
return {
|
||||
requestId: json.id || `volc_${Date.now()}`,
|
||||
providerCode: this.code,
|
||||
model,
|
||||
chunks,
|
||||
usage: { promptTokens, completionTokens, totalTokens },
|
||||
};
|
||||
}
|
||||
|
||||
private splitText(text: string, size: number) {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < text.length; i += size) {
|
||||
result.push(text.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ProviderStreamResult,
|
||||
StreamChatRequest,
|
||||
} from '../types/chat.types';
|
||||
import { AiProvider } from '../providers/provider.interface';
|
||||
import { QwenProvider } from '../providers/qwen.provider';
|
||||
import { DeepseekProvider } from '../providers/deepseek.provider';
|
||||
import { VolcProvider } from '../providers/volc.provider';
|
||||
|
||||
@Injectable()
|
||||
export class ProviderRouterService {
|
||||
private readonly providers: AiProvider[];
|
||||
|
||||
constructor(
|
||||
@Inject(QwenProvider)
|
||||
private readonly qwen: QwenProvider,
|
||||
@Inject(DeepseekProvider)
|
||||
private readonly deepseek: DeepseekProvider,
|
||||
@Inject(VolcProvider)
|
||||
private readonly volc: VolcProvider,
|
||||
) {
|
||||
this.providers = [qwen, deepseek, volc];
|
||||
}
|
||||
|
||||
async routeAndStream(req: StreamChatRequest): Promise<ProviderStreamResult> {
|
||||
const platform = (req.platform || 'auto').toLowerCase();
|
||||
|
||||
if (platform !== 'auto') {
|
||||
const target = this.providers.find((p) => p.code === platform);
|
||||
if (!target) {
|
||||
return this.buildFallback(req, `未知平台:${platform}`);
|
||||
}
|
||||
return target.streamChat(req);
|
||||
}
|
||||
|
||||
const candidate =
|
||||
this.providers.find((p) => p.supports(req.model)) || this.qwen;
|
||||
return candidate.streamChat(req);
|
||||
}
|
||||
|
||||
private async buildFallback(
|
||||
req: StreamChatRequest,
|
||||
reason: string,
|
||||
): Promise<ProviderStreamResult> {
|
||||
const lastUserMessage =
|
||||
[...(req.messages || [])].reverse().find((m) => m.role === 'user')
|
||||
?.content || '';
|
||||
|
||||
const text = `【路由降级】${reason}。直接返回 demo 内容:${lastUserMessage}`;
|
||||
const chunks = this.splitText(text, 12).map((c) => ({ content: c }));
|
||||
|
||||
return {
|
||||
requestId: `fallback_${Date.now()}`,
|
||||
providerCode: 'demo',
|
||||
model: req.model || 'demo-model',
|
||||
chunks,
|
||||
usage: {
|
||||
promptTokens: Math.max(1, lastUserMessage.length),
|
||||
completionTokens: Math.max(1, text.length),
|
||||
totalTokens: Math.max(2, lastUserMessage.length + text.length),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private splitText(text: string, size: number) {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < text.length; i += size) {
|
||||
result.push(text.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
31
src/apps/shared-domain/ai-gateway/types/chat.types.ts
Normal file
31
src/apps/shared-domain/ai-gateway/types/chat.types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export type ChatRole = 'system' | 'user' | 'assistant';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface StreamChatRequest {
|
||||
model?: string;
|
||||
platform?: string; // qwen | deepseek | volc | auto | demo
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
export interface ProviderStreamChunk {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ProviderUsage {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface ProviderStreamResult {
|
||||
requestId: string;
|
||||
providerCode: string;
|
||||
model: string;
|
||||
chunks: ProviderStreamChunk[];
|
||||
usage: ProviderUsage;
|
||||
}
|
||||
|
||||
68
src/config/configuration.ts
Normal file
68
src/config/configuration.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export interface AppConfig {
|
||||
port: number;
|
||||
appName: string;
|
||||
}
|
||||
|
||||
export interface JwtConfig {
|
||||
accessSecret: string;
|
||||
refreshSecret: string;
|
||||
accessExpiresIn: string;
|
||||
refreshExpiresIn: string;
|
||||
}
|
||||
|
||||
export interface DatabaseConfig {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RedisConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
password?: string;
|
||||
db: number;
|
||||
keyPrefixClient: string;
|
||||
keyPrefixAdmin: string;
|
||||
}
|
||||
|
||||
export interface AiRouteConfig {
|
||||
retryTimes: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface AppConfiguration {
|
||||
app: AppConfig;
|
||||
jwt: JwtConfig;
|
||||
database: DatabaseConfig;
|
||||
redis: RedisConfig;
|
||||
aiRoute: AiRouteConfig;
|
||||
}
|
||||
|
||||
export default (): AppConfiguration => ({
|
||||
app: {
|
||||
port: Number(process.env.PORT || 3000),
|
||||
appName: process.env.APP_NAME || 'chat-one-service',
|
||||
},
|
||||
jwt: {
|
||||
accessSecret: process.env.JWT_ACCESS_SECRET || 'change-me-access',
|
||||
refreshSecret: process.env.JWT_REFRESH_SECRET || 'change-me-refresh',
|
||||
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||
},
|
||||
database: {
|
||||
url:
|
||||
process.env.DATABASE_URL ||
|
||||
'postgresql://postgres:postgres@127.0.0.1:5432/chat_one?schema=public',
|
||||
},
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || '127.0.0.1',
|
||||
port: Number(process.env.REDIS_PORT || 6379),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
db: Number(process.env.REDIS_DB || 0),
|
||||
keyPrefixClient: process.env.REDIS_KEY_PREFIX_CLIENT || 'chatone:client',
|
||||
keyPrefixAdmin: process.env.REDIS_KEY_PREFIX_ADMIN || 'chatone:admin',
|
||||
},
|
||||
aiRoute: {
|
||||
retryTimes: Number(process.env.AI_ROUTE_RETRY_TIMES || 1),
|
||||
timeoutMs: Number(process.env.AI_ROUTE_TIMEOUT_MS || 45000),
|
||||
},
|
||||
});
|
||||
|
||||
22
src/config/swagger.ts
Normal file
22
src/config/swagger.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
|
||||
export function setupSwagger(app: INestApplication) {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('ChatOne Service')
|
||||
.setDescription('ChatOne API (client & admin)')
|
||||
.setVersion('1.0.0')
|
||||
.addBearerAuth(
|
||||
{
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
'access-token',
|
||||
)
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('/docs', app, document);
|
||||
}
|
||||
|
||||
64
src/config/validation.ts
Normal file
64
src/config/validation.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
validateSync,
|
||||
} from 'class-validator';
|
||||
|
||||
class EnvironmentVariables {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
PORT?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
APP_NAME?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
JWT_ACCESS_SECRET?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
JWT_REFRESH_SECRET?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
DATABASE_URL?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
REDIS_HOST?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
REDIS_PORT?: number;
|
||||
}
|
||||
|
||||
export function validateEnv(config: Record<string, unknown>) {
|
||||
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
const errors = validateSync(validatedConfig, {
|
||||
skipMissingProperties: true,
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Environment validation failed', JSON.stringify(errors));
|
||||
throw new Error('Environment validation failed');
|
||||
}
|
||||
return validatedConfig;
|
||||
}
|
||||
|
||||
36
src/main.ts
Normal file
36
src/main.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'reflect-metadata';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import {
|
||||
FastifyAdapter,
|
||||
NestFastifyApplication,
|
||||
} from '@nestjs/platform-fastify';
|
||||
import helmet from 'helmet';
|
||||
import { AppModule } from './app.module';
|
||||
import { setupSwagger } from './config/swagger';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter({ logger: false }),
|
||||
);
|
||||
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
|
||||
|
||||
await app.register(helmet as any);
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
setupSwagger(app);
|
||||
|
||||
await app.listen({ port, host: '0.0.0.0' });
|
||||
logger.log(`Application is running on http://localhost:${port}`);
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Fatal bootstrap error', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
10
src/prisma/prisma.module.ts
Normal file
10
src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
|
||||
30
src/prisma/prisma.service.ts
Normal file
30
src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
INestApplication,
|
||||
Injectable,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
// Prisma v7 默认导出 PrismaClient
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
|
||||
async enableShutdownHooks(app: INestApplication) {
|
||||
this.$on('beforeExit', async () => {
|
||||
await app.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": false,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"rootDir": "./src",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"strictNullChecks": true,
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@common/*": ["src/common/*"],
|
||||
"@config/*": ["src/config/*"],
|
||||
"@apps/*": ["src/apps/*"],
|
||||
"@shared/*": ["src/apps/shared-domain/*"],
|
||||
"@prisma/*": ["src/prisma/*"]
|
||||
},
|
||||
"ignoreDeprecations": "6.0"
|
||||
},
|
||||
"include": ["src/**/*", "prisma/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user