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_API_KEY` | 火山引擎 API Key |
|
||||||
| `VOLC_BASE_URL` | 可选,默认 `https://ark.cn-beijing.volces.com/api/v3` |
|
| `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 推给客户端。后续可升级为上游真流式。
|
> 说明:当前各 Provider 对上游采用 **非流式** `chat/completions` 调用,由网关将完整回复 **切片** 后以 SSE 推给客户端。后续可升级为上游真流式。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -135,6 +147,21 @@ yarn start
|
|||||||
|
|
||||||
浏览器打开:`http://localhost:3000/docs`
|
浏览器打开:`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(用户端)
|
### 统一 SSE Chat(用户端)
|
||||||
|
|
||||||
- **路径**:`POST /api/client/v1/chat/completions/stream`
|
- **路径**:`POST /api/client/v1/chat/completions/stream`
|
||||||
@@ -168,8 +195,19 @@ yarn start
|
|||||||
|
|
||||||
#### curl 示例(流式)
|
#### 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
|
```bash
|
||||||
curl -N \
|
curl -N \
|
||||||
|
-H "Authorization: Bearer 你的accessToken" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-X POST \
|
-X POST \
|
||||||
-d '{"platform":"qwen","model":"qwen-plus","messages":[{"role":"user","content":"你是谁"}]}' \
|
-d '{"platform":"qwen","model":"qwen-plus","messages":[{"role":"user","content":"你是谁"}]}' \
|
||||||
|
|||||||
@@ -328,12 +328,11 @@ REDIS_PORT=6379
|
|||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
|
||||||
# SMS
|
# SMS(Spug 推送助手:https://push.spug.cc)
|
||||||
SMS_PROVIDER=aliyun
|
SPUG_PUSH_SMS_TEMPLATE_ID=你的消息模板编号
|
||||||
SMS_ACCESS_KEY_ID=your_key
|
# SPUG_PUSH_BASE_URL=https://push.spug.cc
|
||||||
SMS_ACCESS_KEY_SECRET=your_secret
|
# SPUG_SMS_NAME=模板要求的 name 变量(可选)
|
||||||
SMS_SIGN_NAME=ChatOne
|
# SMS_MOCK=true
|
||||||
SMS_TEMPLATE_CODE_LOGIN=SMS_123456789
|
|
||||||
SMS_CODE_TTL_SECONDS=300
|
SMS_CODE_TTL_SECONDS=300
|
||||||
|
|
||||||
# Mail (admin login / notice)
|
# Mail (admin login / notice)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
|
|||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
import { ClientAppModule } from './apps/client-app/client-app.module';
|
import { ClientAppModule } from './apps/client-app/client-app.module';
|
||||||
import { AdminAppModule } from './apps/admin-app/admin-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 configuration from './config/configuration';
|
||||||
import { validateEnv } from './config/validation';
|
import { validateEnv } from './config/validation';
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ import { validateEnv } from './config/validation';
|
|||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
RedisModule,
|
||||||
ClientAppModule,
|
ClientAppModule,
|
||||||
AdminAppModule,
|
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 { FastifyReply } from 'fastify';
|
||||||
import { StreamChatRequest } from '@shared/ai-gateway/types/chat.types';
|
import { StreamChatRequest } from '@shared/ai-gateway/types/chat.types';
|
||||||
import { ProviderRouterService } from '@shared/ai-gateway/router/provider-router.service';
|
import { ProviderRouterService } from '@shared/ai-gateway/router/provider-router.service';
|
||||||
|
import { ClientJwtAuthGuard } from '../../auth/client-jwt-auth.guard';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiProduces,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
function formatSse(event: string, data: unknown) {
|
function formatSse(event: string, data: unknown) {
|
||||||
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
@@ -11,6 +21,8 @@ function sleep(ms: number) {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiTags('Client Chat')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
@Controller('client/v1/chat')
|
@Controller('client/v1/chat')
|
||||||
export class ChatController {
|
export class ChatController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -19,6 +31,62 @@ export class ChatController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('completions/stream')
|
@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(
|
async streamChat(
|
||||||
@Body() body: StreamChatRequest,
|
@Body() body: StreamChatRequest,
|
||||||
@Res() reply: FastifyReply,
|
@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 { Module } from '@nestjs/common';
|
||||||
import { ChatModule } from './chat/chat.module';
|
import { ChatModule } from './chat/chat.module';
|
||||||
|
import { ClientAuthModule } from './auth/client-auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ChatModule],
|
imports: [ClientAuthModule, ChatModule],
|
||||||
})
|
})
|
||||||
export class ClientAppModule {}
|
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