feat(auth): integrate Aliyun SMS verification flow with docs update

Wire auth verification-code delivery to Aliyun SMS with mock fallback, config validation, and a standalone SMS smoke-test script. Update deployment checklist and system status docs with required env vars and rollout notes.

Made-with: Cursor
This commit is contained in:
2026-03-09 20:30:52 +08:00
parent 5c5fec52c1
commit d30bf95815
9 changed files with 443 additions and 11 deletions

View File

@@ -13,6 +13,7 @@ import { prisma } from '../../config/database.js';
import { jwtService } from './jwt.service.js';
import type { JWTPayload, TokenResponse } from './jwt.service.js';
import { logger } from '../logging/index.js';
import { sendVerificationCodeSms } from '../sms/aliyunSms.service.js';
/**
* 登录请求 - 密码方式
@@ -419,7 +420,10 @@ export class AuthService {
// 4. 设置5分钟过期
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
// 5. 保存验证码
// 5. 发送短信(失败则不落库,避免生成无效验证码
await sendVerificationCodeSms(phone, code, type);
// 6. 保存验证码
await prisma.verification_codes.create({
data: {
phone,
@@ -429,10 +433,11 @@ export class AuthService {
},
});
// TODO: 实际发送短信
// 开发环境直接打印验证码
logger.info('📱 验证码已生成', { phone, code, type, expiresAt });
console.log(`\n📱 验证码: ${code} (有效期5分钟)\n`);
// 非生产环境保留本地调试日志
logger.info('📱 验证码已生成', { phone, type, expiresAt });
if (process.env.NODE_ENV !== 'production') {
console.log(`\n📱 验证码: ${code} (有效期5分钟)\n`);
}
return { expiresIn: 300 }; // 5分钟
}

View File

@@ -0,0 +1,92 @@
import { createRequire } from 'module'
import { config } from '../../config/env.js'
import { logger } from '../logging/index.js'
const require = createRequire(import.meta.url)
const dysmsClientModule = require('@alicloud/dysmsapi20170525/dist/client.js')
const credentialModule = require('@alicloud/credentials/dist/src/client.js')
const DysmsClient = dysmsClientModule.default as any
const SendSmsRequest = dysmsClientModule.SendSmsRequest as any
const Credential = credentialModule.default as any
export type SmsCodeType = 'LOGIN' | 'RESET_PASSWORD'
function resolveTemplateCode(type: SmsCodeType): string {
return type === 'RESET_PASSWORD'
? (config.smsTemplateCodeReset || config.smsTemplateCodeLogin)
: config.smsTemplateCodeLogin
}
function createClient(): any {
const credential = new Credential()
const clientConfig = {
credential,
endpoint: config.smsEndpoint || 'dysmsapi.aliyuncs.com',
}
return new DysmsClient(clientConfig as any)
}
function assertSmsConfig(type: SmsCodeType): void {
const templateCode = resolveTemplateCode(type)
if (!config.smsSignName) {
throw new Error('短信配置缺失SMS_SIGN_NAME 未设置')
}
if (!templateCode) {
throw new Error('短信配置缺失SMS_TEMPLATE_CODE_LOGIN / SMS_TEMPLATE_CODE_RESET 未设置')
}
}
export async function sendVerificationCodeSms(
phone: string,
code: string,
type: SmsCodeType,
): Promise<void> {
// 非阿里云模式:本地开发默认 mock避免阻塞联调
if (config.smsProvider !== 'aliyun') {
logger.info('[SMS] 非 aliyun 模式,跳过真实短信发送', {
phone,
type,
provider: config.smsProvider,
code: config.nodeEnv !== 'production' ? code : '[hidden]',
})
return
}
assertSmsConfig(type)
const client = createClient()
const request = new SendSmsRequest({
signName: config.smsSignName,
templateCode: resolveTemplateCode(type),
phoneNumbers: phone,
templateParam: JSON.stringify({ code }),
})
try {
const resp = await client.sendSms(request)
if (resp.body?.code !== 'OK') {
logger.error('[SMS] 阿里云短信发送失败', {
phone,
type,
code: resp.body?.code,
message: resp.body?.message,
requestId: resp.body?.requestId,
})
throw new Error(resp.body?.message || '短信发送失败')
}
logger.info('[SMS] 阿里云短信发送成功', {
phone,
type,
bizId: resp.body?.bizId,
requestId: resp.body?.requestId,
})
} catch (error: any) {
logger.error('[SMS] 阿里云短信调用异常', {
phone,
type,
error: error?.message || String(error),
recommend: error?.data?.Recommend,
})
throw new Error('短信发送失败,请稍后重试')
}
}

View File

@@ -123,6 +123,23 @@ export const config = {
/** CORS允许的源 */
corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:5173',
// ==================== 短信配置(认证模块)====================
/** 短信服务商mock | aliyun */
smsProvider: process.env.SMS_PROVIDER || (process.env.NODE_ENV === 'production' ? 'aliyun' : 'mock'),
/** 阿里云短信 Endpoint */
smsEndpoint: process.env.SMS_ENDPOINT || 'dysmsapi.aliyuncs.com',
/** 短信签名 */
smsSignName: process.env.SMS_SIGN_NAME || '',
/** 登录验证码模板 */
smsTemplateCodeLogin: process.env.SMS_TEMPLATE_CODE_LOGIN || '',
/** 找回密码验证码模板(可选,不填则复用登录模板) */
smsTemplateCodeReset: process.env.SMS_TEMPLATE_CODE_RESET || '',
// ==================== LLM API配置 ====================
/** DeepSeek API Key */
@@ -232,6 +249,14 @@ export function validateEnv(): void {
}
}
// 使用阿里云短信时,检查必要配置
if (config.smsProvider === 'aliyun') {
if (!config.smsSignName) errors.push('SMS_SIGN_NAME is required when SMS_PROVIDER=aliyun')
if (!config.smsTemplateCodeLogin) {
errors.push('SMS_TEMPLATE_CODE_LOGIN is required when SMS_PROVIDER=aliyun')
}
}
// ========== 安全配置验证 ==========
if (config.nodeEnv === 'production') {