diff --git a/README.md b/README.md index 37efe5a..03d9a8e 100644 --- a/README.md +++ b/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":"你是谁"}]}' \ diff --git a/docs/project-solution.md b/docs/project-solution.md index 91af7bf..37849d0 100644 --- a/docs/project-solution.md +++ b/docs/project-solution.md @@ -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) diff --git a/src/app.module.ts b/src/app.module.ts index fbcccd4..1122812 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, ], diff --git a/src/apps/client-app/auth/client-auth.controller.ts b/src/apps/client-app/auth/client-auth.controller.ts new file mode 100644 index 0000000..e737d87 --- /dev/null +++ b/src/apps/client-app/auth/client-auth.controller.ts @@ -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); + } +} + diff --git a/src/apps/client-app/auth/client-auth.module.ts b/src/apps/client-app/auth/client-auth.module.ts new file mode 100644 index 0000000..afbc646 --- /dev/null +++ b/src/apps/client-app/auth/client-auth.module.ts @@ -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 {} + diff --git a/src/apps/client-app/auth/client-auth.service.ts b/src/apps/client-app/auth/client-auth.service.ts new file mode 100644 index 0000000..c4cc97e --- /dev/null +++ b/src/apps/client-app/auth/client-auth.service.ts @@ -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(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}`; + } +} + diff --git a/src/apps/client-app/auth/client-jwt-auth.guard.ts b/src/apps/client-app/auth/client-jwt-auth.guard.ts new file mode 100644 index 0000000..c3dc4de --- /dev/null +++ b/src/apps/client-app/auth/client-jwt-auth.guard.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class ClientJwtAuthGuard extends AuthGuard('client-jwt') {} + diff --git a/src/apps/client-app/auth/client-jwt.strategy.ts b/src/apps/client-app/auth/client-jwt.strategy.ts new file mode 100644 index 0000000..e30a8db --- /dev/null +++ b/src/apps/client-app/auth/client-jwt.strategy.ts @@ -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, + }; + } +} + diff --git a/src/apps/client-app/auth/dto/login.dto.ts b/src/apps/client-app/auth/dto/login.dto.ts new file mode 100644 index 0000000..e8ae484 --- /dev/null +++ b/src/apps/client-app/auth/dto/login.dto.ts @@ -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; +} + diff --git a/src/apps/client-app/chat/controllers/chat.controller.ts b/src/apps/client-app/chat/controllers/chat.controller.ts index b1a01a5..9245808 100644 --- a/src/apps/client-app/chat/controllers/chat.controller.ts +++ b/src/apps/client-app/chat/controllers/chat.controller.ts @@ -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, diff --git a/src/apps/client-app/chat/dto/stream-chat.dto.ts b/src/apps/client-app/chat/dto/stream-chat.dto.ts new file mode 100644 index 0000000..5ba68b0 --- /dev/null +++ b/src/apps/client-app/chat/dto/stream-chat.dto.ts @@ -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[]; +} + diff --git a/src/apps/client-app/client-app.module.ts b/src/apps/client-app/client-app.module.ts index bb5b3ca..e4dcc44 100644 --- a/src/apps/client-app/client-app.module.ts +++ b/src/apps/client-app/client-app.module.ts @@ -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 {} diff --git a/src/apps/shared-domain/cache/redis.module.ts b/src/apps/shared-domain/cache/redis.module.ts new file mode 100644 index 0000000..4aa26c0 --- /dev/null +++ b/src/apps/shared-domain/cache/redis.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; +import { RedisService } from './redis.service'; + +@Global() +@Module({ + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} + diff --git a/src/apps/shared-domain/cache/redis.service.ts b/src/apps/shared-domain/cache/redis.service.ts new file mode 100644 index 0000000..c6b837f --- /dev/null +++ b/src/apps/shared-domain/cache/redis.service.ts @@ -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(); + } +} + diff --git a/src/apps/shared-domain/sms/sms.service.ts b/src/apps/shared-domain/sms/sms.service.ts new file mode 100644 index 0000000..294f59a --- /dev/null +++ b/src/apps/shared-domain/sms/sms.service.ts @@ -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 = { + 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; + + // 部分 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, raw: string) { + return ( + (typeof o.msg === 'string' && o.msg) || + (typeof o.message === 'string' && o.message) || + (typeof o.error === 'string' && o.error) || + raw + ); + } +}