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:
2026-04-17 02:27:08 +08:00
parent e5f90078ce
commit 0fa6617341
23 changed files with 3961 additions and 0 deletions

232
docs/jwt-minimal-nestjs.md Normal file
View 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
View File

@@ -0,0 +1,568 @@
# ChatOne Service 项目方案NestJS
本文给出一个主流且简洁、可直接落地的服务端方案,满足以下目标:
- 基于 NestJS 构建;
- 同时提供客户端接口与后台管理接口;
- 支持短信登录、邮箱登录;
- 支持千问、火山引擎、DeepSeek 等多平台 AI Chat 流式输出;
- 对外提供统一调用接口(自动路由)和指定平台调用接口;
- 提供用户管理、平台管理、用量统计。
---
## 1. 技术选型(主流且简洁)
- **运行时**Node.js 20 LTS
- **框架**NestJS 10Fastify 适配器,性能更优)
- **ORM**Prisma类型安全、迁移友好
- **数据库**PostgreSQL 15
- **缓存/队列**Redis 7缓存、限流、验证码、会话黑名单
- **鉴权**JWTAccess + 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 短期2hRefreshToken 长期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
View 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
View 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 {}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
@Module({
imports: [],
})
export class AdminAppModule {}

View File

@@ -0,0 +1,3 @@
// 占位文件,避免 TS include 报错;当前逻辑已直接在 Controller 中调用 ProviderRouterService。
export {};

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

View 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();
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ChatModule } from './chat/chat.module';
@Module({
imports: [ChatModule],
})
export class ClientAppModule {}

View File

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

View File

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

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

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

View File

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

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

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

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

View 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
View 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"]
}

2363
yarn.lock Normal file

File diff suppressed because it is too large Load Diff