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:
@@ -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分钟
|
||||
}
|
||||
|
||||
92
backend/src/common/sms/aliyunSms.service.ts
Normal file
92
backend/src/common/sms/aliyunSms.service.ts
Normal 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('短信发送失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user