feat(auth): SMS 模板按类型分发 + 阿里云 8 模板配置

重构 auth-service 短信发送逻辑,从单一模板改为按验证类型分发不同模板。

变更:
- SmsVerificationType 枚举新增 3 类型: IDENTITY_VERIFY, TRANSACTION_CONFIRM, PAYMENT_VERIFY
- AliyunSmsProvider.getTemplateCode() 通过 TEMPLATE_ENV_MAP 按类型查找环境变量
  优先使用类型专属模板,fallback 到通用 ALIYUN_SMS_TEMPLATE_CODE
- 无模板配置时返回错误而非发送空模板
- 日志增加类型和模板代码,便于排查

阿里云已创建 8 个 Genex 短信模板:
- SMS_501745796 注册验证码 (已通过)
- SMS_501720822 登录验证码
- SMS_501735781 重置密码
- SMS_501925825 身份验证 (已通过)
- SMS_501820752 交易确认 (已通过)
- SMS_501855782 支付验证 (已通过)
- SMS_501780799 异常登录提醒 (已通过)
- SMS_501810819 账户变更通知

环境变量:
- .env.example / docker-compose.yml 新增 ALIYUN_SMS_TPL_* 共 7 项
- aliyun_sms_manager.py 迁移到 CreateSmsTemplate 新 API (旧 AddSmsTemplate 已下线)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-03 05:13:16 -08:00
parent bd6ecaa0fd
commit 41b8a8fcfb
6 changed files with 66 additions and 13 deletions

View File

@ -62,6 +62,13 @@ ALIYUN_ACCESS_KEY_ID=
ALIYUN_ACCESS_KEY_SECRET= ALIYUN_ACCESS_KEY_SECRET=
ALIYUN_SMS_SIGN_NAME=券金融 ALIYUN_SMS_SIGN_NAME=券金融
ALIYUN_SMS_TEMPLATE_CODE= ALIYUN_SMS_TEMPLATE_CODE=
ALIYUN_SMS_TPL_REGISTER=SMS_501745796
ALIYUN_SMS_TPL_LOGIN=SMS_501720822
ALIYUN_SMS_TPL_RESET_PASSWORD=SMS_501735781
ALIYUN_SMS_TPL_CHANGE_PHONE=SMS_501925825
ALIYUN_SMS_TPL_IDENTITY_VERIFY=SMS_501925825
ALIYUN_SMS_TPL_TRANSACTION=SMS_501820752
ALIYUN_SMS_TPL_PAYMENT=SMS_501855782
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
# --- Account Lockout --- # --- Account Lockout ---

View File

@ -516,6 +516,13 @@ services:
- ALIYUN_ACCESS_KEY_SECRET=${ALIYUN_ACCESS_KEY_SECRET:-} - ALIYUN_ACCESS_KEY_SECRET=${ALIYUN_ACCESS_KEY_SECRET:-}
- ALIYUN_SMS_SIGN_NAME=${ALIYUN_SMS_SIGN_NAME:-} - ALIYUN_SMS_SIGN_NAME=${ALIYUN_SMS_SIGN_NAME:-}
- ALIYUN_SMS_TEMPLATE_CODE=${ALIYUN_SMS_TEMPLATE_CODE:-} - ALIYUN_SMS_TEMPLATE_CODE=${ALIYUN_SMS_TEMPLATE_CODE:-}
- ALIYUN_SMS_TPL_REGISTER=${ALIYUN_SMS_TPL_REGISTER:-SMS_501745796}
- ALIYUN_SMS_TPL_LOGIN=${ALIYUN_SMS_TPL_LOGIN:-SMS_501720822}
- ALIYUN_SMS_TPL_RESET_PASSWORD=${ALIYUN_SMS_TPL_RESET_PASSWORD:-SMS_501735781}
- ALIYUN_SMS_TPL_CHANGE_PHONE=${ALIYUN_SMS_TPL_CHANGE_PHONE:-SMS_501925825}
- ALIYUN_SMS_TPL_IDENTITY_VERIFY=${ALIYUN_SMS_TPL_IDENTITY_VERIFY:-SMS_501925825}
- ALIYUN_SMS_TPL_TRANSACTION=${ALIYUN_SMS_TPL_TRANSACTION:-SMS_501820752}
- ALIYUN_SMS_TPL_PAYMENT=${ALIYUN_SMS_TPL_PAYMENT:-SMS_501855782}
- ALIYUN_SMS_ENDPOINT=${ALIYUN_SMS_ENDPOINT:-dysmsapi.aliyuncs.com} - ALIYUN_SMS_ENDPOINT=${ALIYUN_SMS_ENDPOINT:-dysmsapi.aliyuncs.com}
depends_on: depends_on:
postgres: postgres:

View File

@ -25,7 +25,14 @@ SMS_MAX_VERIFY_ATTEMPTS=5
# ALIYUN_ACCESS_KEY_ID= # ALIYUN_ACCESS_KEY_ID=
# ALIYUN_ACCESS_KEY_SECRET= # ALIYUN_ACCESS_KEY_SECRET=
# ALIYUN_SMS_SIGN_NAME=券金融 # ALIYUN_SMS_SIGN_NAME=券金融
# ALIYUN_SMS_TEMPLATE_CODE=SMS_123456789 # ALIYUN_SMS_TEMPLATE_CODE=SMS_501745796
# ALIYUN_SMS_TPL_REGISTER=SMS_501745796
# ALIYUN_SMS_TPL_LOGIN=SMS_501720822
# ALIYUN_SMS_TPL_RESET_PASSWORD=SMS_501735781
# ALIYUN_SMS_TPL_CHANGE_PHONE=SMS_501925825
# ALIYUN_SMS_TPL_IDENTITY_VERIFY=SMS_501925825
# ALIYUN_SMS_TPL_TRANSACTION=SMS_501820752
# ALIYUN_SMS_TPL_PAYMENT=SMS_501855782
# ── Kafka (optional, events silently skipped if unavailable) ── # ── Kafka (optional, events silently skipped if unavailable) ──
KAFKA_BROKERS=localhost:9092 KAFKA_BROKERS=localhost:9092

View File

@ -11,6 +11,9 @@ export enum SmsVerificationType {
LOGIN = 'LOGIN', LOGIN = 'LOGIN',
RESET_PASSWORD = 'RESET_PASSWORD', RESET_PASSWORD = 'RESET_PASSWORD',
CHANGE_PHONE = 'CHANGE_PHONE', CHANGE_PHONE = 'CHANGE_PHONE',
IDENTITY_VERIFY = 'IDENTITY_VERIFY',
TRANSACTION_CONFIRM = 'TRANSACTION_CONFIRM',
PAYMENT_VERIFY = 'PAYMENT_VERIFY',
} }
@Entity('sms_verifications') @Entity('sms_verifications')

View File

@ -5,14 +5,17 @@ import { ISmsProvider, SmsDeliveryResult } from './sms-provider.interface';
/** /**
* SMS Provider * SMS Provider
* *
* rwdurian identity-service
* 使 sendSmsWithOptions + RuntimeOptions API
*
* : * :
* - ALIYUN_ACCESS_KEY_ID * - ALIYUN_ACCESS_KEY_ID / ALIYUN_ACCESS_KEY_SECRET
* - ALIYUN_ACCESS_KEY_SECRET
* - ALIYUN_SMS_SIGN_NAME () * - ALIYUN_SMS_SIGN_NAME ()
* - ALIYUN_SMS_TEMPLATE_CODE () * - ALIYUN_SMS_TEMPLATE_CODE ( fallback )
* - ALIYUN_SMS_TPL_REGISTER
* - ALIYUN_SMS_TPL_LOGIN
* - ALIYUN_SMS_TPL_RESET_PASSWORD
* - ALIYUN_SMS_TPL_CHANGE_PHONE
* - ALIYUN_SMS_TPL_IDENTITY_VERIFY
* - ALIYUN_SMS_TPL_TRANSACTION
* - ALIYUN_SMS_TPL_PAYMENT
* - ALIYUN_SMS_ENDPOINT ( dysmsapi.aliyuncs.com) * - ALIYUN_SMS_ENDPOINT ( dysmsapi.aliyuncs.com)
*/ */
@Injectable() @Injectable()
@ -20,6 +23,17 @@ export class AliyunSmsProvider implements ISmsProvider {
private readonly logger = new Logger('AliyunSmsProvider'); private readonly logger = new Logger('AliyunSmsProvider');
private client: any; // Dysmsapi20170525 client (lazy init) private client: any; // Dysmsapi20170525 client (lazy init)
/** 类型 → 环境变量名 映射 */
private static readonly TEMPLATE_ENV_MAP: Record<SmsVerificationType, string> = {
[SmsVerificationType.REGISTER]: 'ALIYUN_SMS_TPL_REGISTER',
[SmsVerificationType.LOGIN]: 'ALIYUN_SMS_TPL_LOGIN',
[SmsVerificationType.RESET_PASSWORD]: 'ALIYUN_SMS_TPL_RESET_PASSWORD',
[SmsVerificationType.CHANGE_PHONE]: 'ALIYUN_SMS_TPL_CHANGE_PHONE',
[SmsVerificationType.IDENTITY_VERIFY]: 'ALIYUN_SMS_TPL_IDENTITY_VERIFY',
[SmsVerificationType.TRANSACTION_CONFIRM]: 'ALIYUN_SMS_TPL_TRANSACTION',
[SmsVerificationType.PAYMENT_VERIFY]: 'ALIYUN_SMS_TPL_PAYMENT',
};
async send( async send(
phone: string, phone: string,
code: string, code: string,
@ -35,6 +49,11 @@ export class AliyunSmsProvider implements ISmsProvider {
const signName = process.env.ALIYUN_SMS_SIGN_NAME || '券金融'; const signName = process.env.ALIYUN_SMS_SIGN_NAME || '券金融';
const templateCode = this.getTemplateCode(type); const templateCode = this.getTemplateCode(type);
if (!templateCode) {
this.logger.error(`No SMS template configured for type: ${type}`);
return { success: false, errorMsg: `No template for type: ${type}` };
}
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const Dysmsapi = require('@alicloud/dysmsapi20170525'); const Dysmsapi = require('@alicloud/dysmsapi20170525');
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
@ -56,7 +75,7 @@ export class AliyunSmsProvider implements ISmsProvider {
if (response.body?.code === 'OK') { if (response.body?.code === 'OK') {
this.logger.log( this.logger.log(
`SMS sent: phone=${phone.slice(0, 3)}****${phone.slice(-4)} bizId=${response.body.bizId}`, `SMS sent [${type}]: phone=${phone.slice(0, 3)}****${phone.slice(-4)} tpl=${templateCode} bizId=${response.body.bizId}`,
); );
return { return {
success: true, success: true,
@ -78,7 +97,9 @@ export class AliyunSmsProvider implements ISmsProvider {
} }
private getTemplateCode(type: SmsVerificationType): string { private getTemplateCode(type: SmsVerificationType): string {
return process.env.ALIYUN_SMS_TEMPLATE_CODE || ''; const envKey = AliyunSmsProvider.TEMPLATE_ENV_MAP[type];
const specific = envKey ? process.env[envKey] : undefined;
return specific || process.env.ALIYUN_SMS_TEMPLATE_CODE || '';
} }
private async getClient() { private async getClient() {

View File

@ -107,17 +107,23 @@ def list_templates(args):
def create_template(args): def create_template(args):
client = get_client() client = get_client()
from alibabacloud_dysmsapi20170525.models import AddSmsTemplateRequest # Use new CreateSmsTemplate API (AddSmsTemplate is deprecated)
req = AddSmsTemplateRequest( from alibabacloud_dysmsapi20170525.models import CreateSmsTemplateRequest
sign_name = getattr(args, 'sign', None) or os.environ.get('ALIYUN_SMS_SIGN_NAME', '')
rule = getattr(args, 'rule', None) or ''
req = CreateSmsTemplateRequest(
template_type=args.type, template_type=args.type,
template_name=args.name, template_name=args.name,
template_content=args.content, template_content=args.content,
remark=args.remark, remark=args.remark,
related_sign_name=sign_name,
template_rule=rule if rule else None,
) )
resp = client.add_sms_template(req) resp = client.create_sms_template(req)
body = resp.body body = resp.body
if body.code == 'OK': if body.code == 'OK':
print(f'✅ 模板 "{args.name}" 提交成功 (Code: {body.template_code}),等待审核') code = getattr(body, 'template_code', None) or getattr(body, 'order_id', 'N/A')
print(f'✅ 模板 "{args.name}" 提交成功 (Code/Order: {code}),等待审核')
else: else:
print(f'ERROR: {body.code} - {body.message}') print(f'ERROR: {body.code} - {body.message}')
@ -246,6 +252,8 @@ def main():
p.add_argument('--content', required=True, help='模板内容,如: 您的验证码为${code}') p.add_argument('--content', required=True, help='模板内容,如: 您的验证码为${code}')
p.add_argument('--type', type=int, default=0, help='类型: 0=验证码 1=通知 2=推广') p.add_argument('--type', type=int, default=0, help='类型: 0=验证码 1=通知 2=推广')
p.add_argument('--remark', default='', help='备注说明') p.add_argument('--remark', default='', help='备注说明')
p.add_argument('--sign', default='', help='关联签名名称新API必填默认读ALIYUN_SMS_SIGN_NAME')
p.add_argument('--rule', default='', help='变量规则JSON如: {"code":"numberCaptcha"}')
p = sub.add_parser('delete-template', help='删除短信模板') p = sub.add_parser('delete-template', help='删除短信模板')
p.add_argument('--code', required=True, help='模板Code') p.add_argument('--code', required=True, help='模板Code')