feat(client): 短信登录、JWT、Redis 与 Spug 短信及流式 Chat
- 新增客户端认证:短信发送/登录、access/refresh JWT、Guard/Strategy\n- Redis 存验证码;可配置 SMS_CODE_TTL_SECONDS;失败时回滚与明确错误\n- 短信改为 Spug 推送助手(code/targets/number/name),移除 UniSMS\n- Chat SSE 接口与 DTO;AppModule 挂载 RedisModule\n- 更新 README 与 project-solution 环境变量说明 Made-with: Cursor
This commit is contained in:
38
README.md
38
README.md
@@ -95,6 +95,18 @@ yarn install
|
||||
| `VOLC_API_KEY` | 火山引擎 API Key |
|
||||
| `VOLC_BASE_URL` | 可选,默认 `https://ark.cn-beijing.volces.com/api/v3` |
|
||||
|
||||
### Spug 推送助手(短信验证码)
|
||||
|
||||
在 [Spug 推送助手](https://push.spug.cc) 创建「短信验证码」类消息模板,复制模板编号(URL 路径中的 ID)。模板中可使用变量 **`number`**(有效期,单位:**分钟**);服务端会根据 `SMS_CODE_TTL_SECONDS` 换算后传入(向上取整,至少为 1),与 Redis 中验证码 TTL 一致。
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `SPUG_PUSH_SMS_TEMPLATE_ID` | 消息模板编号(必填,非 mock 时) |
|
||||
| `SPUG_PUSH_BASE_URL` | 可选,默认 `https://push.spug.cc` |
|
||||
| `SPUG_SMS_NAME` | 可选,模板若要求 `name` 变量则配置(与官方示例一致) |
|
||||
| `SMS_MOCK` | 可选,`true` 时跳过真实短信发送(本地联调用) |
|
||||
| `SMS_CODE_TTL_SECONDS` | 可选,短信验证码 Redis TTL(秒),默认 `300` |
|
||||
|
||||
> 说明:当前各 Provider 对上游采用 **非流式** `chat/completions` 调用,由网关将完整回复 **切片** 后以 SSE 推给客户端。后续可升级为上游真流式。
|
||||
|
||||
---
|
||||
@@ -135,6 +147,21 @@ yarn start
|
||||
|
||||
浏览器打开:`http://localhost:3000/docs`
|
||||
|
||||
### 客户端认证(MVP)
|
||||
|
||||
- `POST /api/client/v1/auth/sms/send`
|
||||
- 入参:`{ "phone": "13800000000", "scene": "login" }`
|
||||
- 返回:`requestId`、`expireIn`、`provider`
|
||||
- 说明:已接入 Spug 推送助手;非生产环境会额外返回 `testCode` 便于联调
|
||||
- `POST /api/client/v1/auth/sms/login`
|
||||
- 入参:`{ "phone": "13800000000", "code": "xxxxxx" }`
|
||||
- 返回:`accessToken`、`refreshToken`、`user`
|
||||
- `POST /api/client/v1/auth/refresh`
|
||||
- 入参:`{ "refreshToken": "..." }`
|
||||
- 返回:新的 `accessToken`、`refreshToken`
|
||||
|
||||
> 验证码当前已接入 Redis 存储(key: `chatone:client:sms:code:{phone}:{scene}`,TTL 300 秒)。
|
||||
|
||||
### 统一 SSE Chat(用户端)
|
||||
|
||||
- **路径**:`POST /api/client/v1/chat/completions/stream`
|
||||
@@ -168,8 +195,19 @@ yarn start
|
||||
|
||||
#### curl 示例(流式)
|
||||
|
||||
先获取 token:
|
||||
|
||||
```bash
|
||||
curl -s -H "Content-Type: application/json" -X POST \
|
||||
-d '{"phone":"13800000000","code":"123456"}' \
|
||||
http://localhost:3000/api/client/v1/auth/sms/login
|
||||
```
|
||||
|
||||
再带 `Authorization` 调用 chat:
|
||||
|
||||
```bash
|
||||
curl -N \
|
||||
-H "Authorization: Bearer 你的accessToken" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
-d '{"platform":"qwen","model":"qwen-plus","messages":[{"role":"user","content":"你是谁"}]}' \
|
||||
|
||||
@@ -328,12 +328,11 @@ 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(Spug 推送助手:https://push.spug.cc)
|
||||
SPUG_PUSH_SMS_TEMPLATE_ID=你的消息模板编号
|
||||
# SPUG_PUSH_BASE_URL=https://push.spug.cc
|
||||
# SPUG_SMS_NAME=模板要求的 name 变量(可选)
|
||||
# SMS_MOCK=true
|
||||
SMS_CODE_TTL_SECONDS=300
|
||||
|
||||
# Mail (admin login / notice)
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { RedisModule } from './apps/shared-domain/cache/redis.module';
|
||||
import configuration from './config/configuration';
|
||||
import { validateEnv } from './config/validation';
|
||||
|
||||
@@ -24,6 +25,7 @@ import { validateEnv } from './config/validation';
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
RedisModule,
|
||||
ClientAppModule,
|
||||
AdminAppModule,
|
||||
],
|
||||
|
||||
108
src/apps/client-app/auth/client-auth.controller.ts
Normal file
108
src/apps/client-app/auth/client-auth.controller.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Body, Controller, Inject, Post } from '@nestjs/common';
|
||||
import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { ClientAuthService } from './client-auth.service';
|
||||
import {
|
||||
ClientLoginDto,
|
||||
ClientRefreshDto,
|
||||
ClientSendSmsDto,
|
||||
} from './dto/login.dto';
|
||||
|
||||
@ApiTags('Client Auth')
|
||||
@Controller('client/v1/auth')
|
||||
export class ClientAuthController {
|
||||
constructor(
|
||||
@Inject(ClientAuthService)
|
||||
private readonly clientAuthService: ClientAuthService,
|
||||
) {}
|
||||
|
||||
@Post('sms/send')
|
||||
@ApiOperation({ summary: '发送短信验证码(MVP 为 mock)' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
description: '发送短信验证码请求体',
|
||||
properties: {
|
||||
phone: { type: 'string', description: '手机号', example: '13800000000' },
|
||||
scene: { type: 'string', description: '业务场景', example: 'login' },
|
||||
},
|
||||
required: ['phone', 'scene'],
|
||||
},
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: '发送成功(MVP mock)',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
requestId: { type: 'string', example: 'sms_1710000000000' },
|
||||
phone: { type: 'string', example: '13800000000' },
|
||||
scene: { type: 'string', example: 'login' },
|
||||
expireIn: { type: 'number', example: 300 },
|
||||
testCode: { type: 'string', example: '123456' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async sendSms(@Body() body: ClientSendSmsDto) {
|
||||
return this.clientAuthService.sendSmsCode(body.phone, body.scene);
|
||||
}
|
||||
|
||||
@Post('sms/login')
|
||||
@ApiOperation({ summary: '短信验证码登录(MVP 固定验证码 123456)' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
description: '短信验证码登录请求体',
|
||||
properties: {
|
||||
phone: { type: 'string', description: '手机号', example: '13800000000' },
|
||||
code: { type: 'string', description: '短信验证码', example: '123456' },
|
||||
},
|
||||
required: ['phone', 'code'],
|
||||
},
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: '登录成功返回 token',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
accessToken: { type: 'string' },
|
||||
refreshToken: { type: 'string' },
|
||||
user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'u_13800000000' },
|
||||
phone: { type: 'string', example: '13800000000' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async smsLogin(@Body() body: ClientLoginDto) {
|
||||
return this.clientAuthService.loginBySms(body.phone, body.code);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: '刷新客户端 access token' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
description: 'refresh token 刷新请求体',
|
||||
properties: {
|
||||
refreshToken: { type: 'string', description: '刷新令牌' },
|
||||
},
|
||||
required: ['refreshToken'],
|
||||
},
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: '刷新成功返回新 token',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
accessToken: { type: 'string' },
|
||||
refreshToken: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async refresh(@Body() body: ClientRefreshDto) {
|
||||
return this.clientAuthService.refreshToken(body.refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
36
src/apps/client-app/auth/client-auth.module.ts
Normal file
36
src/apps/client-app/auth/client-auth.module.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ClientAuthController } from './client-auth.controller';
|
||||
import { ClientAuthService } from './client-auth.service';
|
||||
import { ClientJwtStrategy } from './client-jwt.strategy';
|
||||
import { SmsService } from '@shared/sms/sms.service';
|
||||
|
||||
function parseExpiresToSeconds(value: string, fallbackSeconds: number) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized.endsWith('h')) return Number.parseInt(normalized, 10) * 3600;
|
||||
if (normalized.endsWith('d')) return Number.parseInt(normalized, 10) * 86400;
|
||||
if (normalized.endsWith('m')) return Number.parseInt(normalized, 10) * 60;
|
||||
const asNumber = Number.parseInt(normalized, 10);
|
||||
return Number.isNaN(asNumber) ? fallbackSeconds : asNumber;
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_ACCESS_SECRET || 'change-me-access',
|
||||
signOptions: {
|
||||
expiresIn: parseExpiresToSeconds(
|
||||
process.env.JWT_ACCESS_EXPIRES_IN || '2h',
|
||||
7200,
|
||||
),
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [ClientAuthController],
|
||||
providers: [ClientAuthService, ClientJwtStrategy, SmsService],
|
||||
exports: [ClientAuthService],
|
||||
})
|
||||
export class ClientAuthModule {}
|
||||
|
||||
181
src/apps/client-app/auth/client-auth.service.ts
Normal file
181
src/apps/client-app/auth/client-auth.service.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
Injectable,
|
||||
Inject,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { SmsService } from '@shared/sms/sms.service';
|
||||
import { RedisService } from '@shared/cache/redis.service';
|
||||
|
||||
interface AccessPayload {
|
||||
sub: string;
|
||||
phone: string;
|
||||
role: 'client';
|
||||
type: 'access';
|
||||
}
|
||||
|
||||
interface RefreshPayload {
|
||||
sub: string;
|
||||
type: 'refresh';
|
||||
jti: string;
|
||||
}
|
||||
|
||||
function parseExpiresToSeconds(value: string, fallbackSeconds: number) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized.endsWith('h')) return Number.parseInt(normalized, 10) * 3600;
|
||||
if (normalized.endsWith('d')) return Number.parseInt(normalized, 10) * 86400;
|
||||
if (normalized.endsWith('m')) return Number.parseInt(normalized, 10) * 60;
|
||||
const asNumber = Number.parseInt(normalized, 10);
|
||||
return Number.isNaN(asNumber) ? fallbackSeconds : asNumber;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClientAuthService {
|
||||
constructor(
|
||||
@Inject(JwtService)
|
||||
private readonly jwtService: JwtService,
|
||||
@Inject(SmsService)
|
||||
private readonly smsService: SmsService,
|
||||
@Inject(RedisService)
|
||||
private readonly redisService: RedisService,
|
||||
) {}
|
||||
|
||||
async sendSmsCode(phone: string, scene: string) {
|
||||
const code = this.generateCode();
|
||||
const expireIn = Number(process.env.SMS_CODE_TTL_SECONDS || 300);
|
||||
const key = this.getSmsStoreKey(phone, scene);
|
||||
try {
|
||||
await this.redisService.setex(key, expireIn, code);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new ServiceUnavailableException(
|
||||
`无法写入 Redis(请确认 REDIS_HOST/PORT/PASSWORD 与 Redis 已启动):${msg}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const smsResult = await this.smsService.sendVerificationCode(
|
||||
phone,
|
||||
scene,
|
||||
code,
|
||||
expireIn,
|
||||
);
|
||||
return {
|
||||
requestId: smsResult.requestId,
|
||||
phone,
|
||||
scene,
|
||||
provider: smsResult.provider,
|
||||
expireIn,
|
||||
...(process.env.NODE_ENV !== 'production' ? { testCode: code } : {}),
|
||||
};
|
||||
} catch (e) {
|
||||
await this.redisService.del(key).catch(() => undefined);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async loginBySms(phone: string, code: string) {
|
||||
const key = this.getSmsStoreKey(phone, 'login');
|
||||
const cachedCode = await this.redisService.get(key);
|
||||
|
||||
if (!cachedCode) {
|
||||
throw new BadRequestException('验证码不存在,请先发送验证码');
|
||||
}
|
||||
if (cachedCode !== code) {
|
||||
throw new BadRequestException('验证码错误');
|
||||
}
|
||||
await this.redisService.del(key);
|
||||
|
||||
const userId = `u_${phone}`;
|
||||
const accessToken = await this.signAccessToken({
|
||||
sub: userId,
|
||||
phone,
|
||||
role: 'client',
|
||||
type: 'access',
|
||||
});
|
||||
const refreshToken = await this.signRefreshToken({
|
||||
sub: userId,
|
||||
type: 'refresh',
|
||||
jti: randomUUID(),
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: userId,
|
||||
phone,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string) {
|
||||
let payload: RefreshPayload;
|
||||
try {
|
||||
payload = await this.jwtService.verifyAsync<RefreshPayload>(refreshToken, {
|
||||
secret: process.env.JWT_REFRESH_SECRET || 'change-me-refresh',
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException('refreshToken 无效或已过期');
|
||||
}
|
||||
|
||||
if (payload.type !== 'refresh') {
|
||||
throw new UnauthorizedException('token 类型错误');
|
||||
}
|
||||
|
||||
const accessToken = await this.signAccessToken({
|
||||
sub: payload.sub,
|
||||
phone: payload.sub.replace('u_', ''),
|
||||
role: 'client',
|
||||
type: 'access',
|
||||
});
|
||||
|
||||
const newRefreshToken = await this.signRefreshToken({
|
||||
sub: payload.sub,
|
||||
type: 'refresh',
|
||||
jti: randomUUID(),
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private signAccessToken(payload: AccessPayload) {
|
||||
return this.jwtService.signAsync(payload, {
|
||||
secret: process.env.JWT_ACCESS_SECRET || 'change-me-access',
|
||||
expiresIn: parseExpiresToSeconds(
|
||||
process.env.JWT_ACCESS_EXPIRES_IN || '2h',
|
||||
7200,
|
||||
),
|
||||
issuer: 'chat-one-client',
|
||||
audience: 'chat-one-client-api',
|
||||
});
|
||||
}
|
||||
|
||||
private signRefreshToken(payload: RefreshPayload) {
|
||||
return this.jwtService.signAsync(payload, {
|
||||
secret: process.env.JWT_REFRESH_SECRET || 'change-me-refresh',
|
||||
expiresIn: parseExpiresToSeconds(
|
||||
process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||
2592000,
|
||||
),
|
||||
issuer: 'chat-one-client',
|
||||
audience: 'chat-one-client-api',
|
||||
});
|
||||
}
|
||||
|
||||
private generateCode() {
|
||||
return `${Math.floor(100000 + Math.random() * 900000)}`;
|
||||
}
|
||||
|
||||
private getSmsStoreKey(phone: string, scene: string) {
|
||||
const prefix = process.env.REDIS_KEY_PREFIX_CLIENT || 'chatone:client';
|
||||
return `${prefix}:sms:code:${phone}:${scene}`;
|
||||
}
|
||||
}
|
||||
|
||||
6
src/apps/client-app/auth/client-jwt-auth.guard.ts
Normal file
6
src/apps/client-app/auth/client-jwt-auth.guard.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class ClientJwtAuthGuard extends AuthGuard('client-jwt') {}
|
||||
|
||||
34
src/apps/client-app/auth/client-jwt.strategy.ts
Normal file
34
src/apps/client-app/auth/client-jwt.strategy.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
interface ClientAccessPayload {
|
||||
sub: string;
|
||||
phone: string;
|
||||
role: 'client';
|
||||
type: 'access';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClientJwtStrategy extends PassportStrategy(Strategy, 'client-jwt') {
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_ACCESS_SECRET || 'change-me-access',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: ClientAccessPayload) {
|
||||
if (payload.type !== 'access') {
|
||||
throw new UnauthorizedException('token 类型错误');
|
||||
}
|
||||
|
||||
return {
|
||||
userId: payload.sub,
|
||||
phone: payload.phone,
|
||||
role: payload.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
28
src/apps/client-app/auth/dto/login.dto.ts
Normal file
28
src/apps/client-app/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class ClientSendSmsDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
scene!: string;
|
||||
}
|
||||
|
||||
export class ClientLoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code!: string;
|
||||
}
|
||||
|
||||
export class ClientRefreshDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refreshToken!: string;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { Body, Controller, Inject, Post, Res } from '@nestjs/common';
|
||||
import { Body, Controller, Inject, Post, Res, UseGuards } 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';
|
||||
import { ClientJwtAuthGuard } from '../../auth/client-jwt-auth.guard';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiProduces,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
function formatSse(event: string, data: unknown) {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
@@ -11,6 +21,8 @@ function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@ApiTags('Client Chat')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('client/v1/chat')
|
||||
export class ChatController {
|
||||
constructor(
|
||||
@@ -19,6 +31,62 @@ export class ChatController {
|
||||
) {}
|
||||
|
||||
@Post('completions/stream')
|
||||
@UseGuards(ClientJwtAuthGuard)
|
||||
@ApiOperation({ summary: '统一流式 Chat 接口(SSE)' })
|
||||
@ApiConsumes('application/json')
|
||||
@ApiProduces('text/event-stream')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
description: '统一 chat 请求体,支持指定平台或自动路由',
|
||||
properties: {
|
||||
model: {
|
||||
type: 'string',
|
||||
description: '模型名(不传时 provider 使用默认模型)',
|
||||
example: 'qwen-plus',
|
||||
},
|
||||
platform: {
|
||||
type: 'string',
|
||||
description: '目标平台(auto 或不传表示自动路由)',
|
||||
enum: ['auto', 'qwen', 'deepseek', 'volc'],
|
||||
example: 'qwen',
|
||||
},
|
||||
messages: {
|
||||
type: 'array',
|
||||
description: '聊天消息列表',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
role: {
|
||||
type: 'string',
|
||||
description: '消息角色',
|
||||
enum: ['system', 'user', 'assistant'],
|
||||
example: 'user',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: '消息文本内容',
|
||||
example: '你是谁',
|
||||
},
|
||||
},
|
||||
required: ['role', 'content'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['messages'],
|
||||
},
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: 'SSE 流式响应:meta -> delta -> usage -> done',
|
||||
schema: {
|
||||
type: 'string',
|
||||
example:
|
||||
'event: meta\\ndata: {"requestId":"chatcmpl_xxx","platform":"qwen","model":"qwen-plus"}\\n\\n' +
|
||||
'event: delta\\ndata: {"delta":"你好"}\\n\\n' +
|
||||
'event: usage\\ndata: {"promptTokens":10,"completionTokens":20,"totalTokens":30}\\n\\n' +
|
||||
'event: done\\ndata: {"finishReason":"stop"}\\n\\n',
|
||||
},
|
||||
})
|
||||
async streamChat(
|
||||
@Body() body: StreamChatRequest,
|
||||
@Res() reply: FastifyReply,
|
||||
|
||||
38
src/apps/client-app/chat/dto/stream-chat.dto.ts
Normal file
38
src/apps/client-app/chat/dto/stream-chat.dto.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
import { StreamChatRequest } from '@shared/ai-gateway/types/chat.types';
|
||||
|
||||
class ChatMessageDto {
|
||||
@ApiProperty({ enum: ['system', 'user', 'assistant'] })
|
||||
@IsString()
|
||||
@IsIn(['system', 'user', 'assistant'])
|
||||
role!: 'system' | 'user' | 'assistant';
|
||||
|
||||
@ApiProperty({ description: '消息内容', example: '你是谁' })
|
||||
@IsString()
|
||||
content!: string;
|
||||
}
|
||||
|
||||
export class StreamChatBodyDto implements StreamChatRequest {
|
||||
@ApiPropertyOptional({ description: '模型名', example: 'qwen-plus' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
model?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '指定平台,不传或 auto 由路由自动选择',
|
||||
enum: ['auto', 'qwen', 'deepseek', 'volc'],
|
||||
example: 'qwen',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
platform?: string;
|
||||
|
||||
@ApiProperty({ type: [ChatMessageDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ChatMessageDto)
|
||||
messages!: ChatMessageDto[];
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatModule } from './chat/chat.module';
|
||||
import { ClientAuthModule } from './auth/client-auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [ChatModule],
|
||||
imports: [ClientAuthModule, ChatModule],
|
||||
})
|
||||
export class ClientAppModule {}
|
||||
|
||||
|
||||
10
src/apps/shared-domain/cache/redis.module.ts
vendored
Normal file
10
src/apps/shared-domain/cache/redis.module.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [RedisService],
|
||||
exports: [RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
|
||||
40
src/apps/shared-domain/cache/redis.service.ts
vendored
Normal file
40
src/apps/shared-domain/cache/redis.service.ts
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleDestroy {
|
||||
private readonly client: Redis;
|
||||
|
||||
constructor() {
|
||||
this.client = new Redis({
|
||||
host: process.env.REDIS_HOST || '127.0.0.1',
|
||||
port: Number(process.env.REDIS_PORT || 6379),
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
db: Number(process.env.REDIS_DB || 0),
|
||||
keyPrefix: '',
|
||||
lazyConnect: false,
|
||||
maxRetriesPerRequest: 2,
|
||||
});
|
||||
}
|
||||
|
||||
getClient() {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async get(key: string) {
|
||||
return this.client.get(key);
|
||||
}
|
||||
|
||||
async setex(key: string, seconds: number, value: string) {
|
||||
await this.client.set(key, value, 'EX', seconds);
|
||||
}
|
||||
|
||||
async del(key: string) {
|
||||
await this.client.del(key);
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.client.quit();
|
||||
}
|
||||
}
|
||||
|
||||
153
src/apps/shared-domain/sms/sms.service.ts
Normal file
153
src/apps/shared-domain/sms/sms.service.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { request } from 'undici';
|
||||
|
||||
export interface SendSmsResult {
|
||||
requestId: string;
|
||||
provider: 'spug';
|
||||
recipient: string;
|
||||
scene: string;
|
||||
expireIn: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
async sendVerificationCode(
|
||||
phone: string,
|
||||
scene: string,
|
||||
code: string,
|
||||
ttlSeconds = 300,
|
||||
) {
|
||||
if (process.env.SMS_MOCK === 'true') {
|
||||
return {
|
||||
requestId: `sms_mock_${Date.now()}`,
|
||||
provider: 'spug' as const,
|
||||
recipient: phone,
|
||||
scene,
|
||||
expireIn: ttlSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
const templateId = process.env.SPUG_PUSH_SMS_TEMPLATE_ID;
|
||||
if (!templateId?.trim()) {
|
||||
throw new InternalServerErrorException('SPUG_PUSH_SMS_TEMPLATE_ID 未配置');
|
||||
}
|
||||
|
||||
const baseUrl = (
|
||||
process.env.SPUG_PUSH_BASE_URL || 'https://push.spug.cc'
|
||||
).replace(/\/$/, '');
|
||||
const url = `${baseUrl}/send/${encodeURIComponent(templateId.trim())}`;
|
||||
|
||||
const validMinutes = Math.max(1, Math.ceil(ttlSeconds / 60));
|
||||
const body: Record<string, string | number> = {
|
||||
code: String(code),
|
||||
targets: phone,
|
||||
// Spug 模板变量:验证码有效期(分钟),与 Redis TTL 一致
|
||||
number: validMinutes,
|
||||
};
|
||||
const name = process.env.SPUG_SMS_NAME?.trim();
|
||||
if (name) {
|
||||
body.name = name;
|
||||
}
|
||||
|
||||
let statusCode: number;
|
||||
let raw: string;
|
||||
try {
|
||||
const res = await request(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
statusCode = res.statusCode;
|
||||
raw = await res.body.text();
|
||||
} catch (err: any) {
|
||||
const message = err?.message || JSON.stringify(err);
|
||||
throw new InternalServerErrorException(`Spug 网络异常:${message}`);
|
||||
}
|
||||
|
||||
this.assertSpugResponseOk(statusCode, raw);
|
||||
|
||||
return {
|
||||
requestId: `spug_${Date.now()}`,
|
||||
provider: 'spug' as const,
|
||||
recipient: phone,
|
||||
scene,
|
||||
expireIn: ttlSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
private assertSpugResponseOk(statusCode: number, raw: string) {
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
throw new BadRequestException(`Spug 请求失败(${statusCode}):${raw}`);
|
||||
}
|
||||
throw new InternalServerErrorException(
|
||||
`Spug 服务异常(${statusCode}):${raw}`,
|
||||
);
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (data == null || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return;
|
||||
}
|
||||
const o = data as Record<string, unknown>;
|
||||
|
||||
// 部分 Spug/网关文档使用 error=0 表示成功
|
||||
if (typeof o.error === 'number' && o.error !== 0) {
|
||||
const msg = this.spugErrorMessage(o, raw);
|
||||
throw o.error >= 400 && o.error < 500
|
||||
? new BadRequestException(`Spug 请求失败:${msg}`)
|
||||
: new InternalServerErrorException(`Spug 服务异常:${msg}`);
|
||||
}
|
||||
|
||||
if (typeof o.errno === 'number' && o.errno !== 0) {
|
||||
const msg = this.spugErrorMessage(o, raw);
|
||||
throw o.errno >= 400 && o.errno < 500
|
||||
? new BadRequestException(`Spug 请求失败:${msg}`)
|
||||
: new InternalServerErrorException(`Spug 服务异常:${msg}`);
|
||||
}
|
||||
|
||||
const codeNum = this.parseSpugCode(o.code);
|
||||
if (codeNum !== null && codeNum !== 200 && codeNum !== 0) {
|
||||
const msg = this.spugErrorMessage(o, raw);
|
||||
if (codeNum >= 400 && codeNum < 500) {
|
||||
throw new BadRequestException(`Spug 请求失败:${msg}`);
|
||||
}
|
||||
throw new InternalServerErrorException(`Spug 服务异常:${msg}`);
|
||||
}
|
||||
if (o.success === false) {
|
||||
const msg = this.spugErrorMessage(o, raw);
|
||||
throw new BadRequestException(`Spug 请求失败:${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
private parseSpugCode(value: unknown): number | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'number' && !Number.isNaN(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && /^\d+$/.test(value)) {
|
||||
return Number.parseInt(value, 10);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private spugErrorMessage(o: Record<string, unknown>, raw: string) {
|
||||
return (
|
||||
(typeof o.msg === 'string' && o.msg) ||
|
||||
(typeof o.message === 'string' && o.message) ||
|
||||
(typeof o.error === 'string' && o.error) ||
|
||||
raw
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user