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:
2026-04-21 06:30:50 +08:00
parent 61ac181b83
commit 6cc89062e1
15 changed files with 750 additions and 8 deletions

View File

@@ -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":"你是谁"}]}' \

View File

@@ -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
# SMSSpug 推送助手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)

View File

@@ -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,
],

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

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

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

View File

@@ -0,0 +1,6 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class ClientJwtAuthGuard extends AuthGuard('client-jwt') {}

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

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

View File

@@ -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,

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

View File

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

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

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

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