# -*- coding: utf-8 -*- """短信生态服务(云超服平台统一短信)。 支持两类 provider(由系统配置 ``sms.provider`` 控制): - ``stub``:仅打印短信内容不发真实短信(默认,便于无短信资质联调)。 - ``aliyun``:阿里云短信服务(dysmsapi 2017-05-25),凭据/签名/模板全部来自 ``system_configs`` 里的短信生态配置(admin 端「系统配置 → 短信生态」可在线维护)。 短信生态配置结构(system_configs 键值,均为 JSON 字符串或简单值): - ``sms.provider`` : provider 名(stub | aliyun) - ``sms.aliyun`` : 阿里云总配置 JSON {"access_key_id": "", "access_key_secret": "", "sign_name": "", "region_id": "cn-hangzhou", "endpoint": "dysmsapi.aliyuncs.com"} - ``sms.template.`` : 场景模板 JSON {"template_id": "SMS_xxx", "variables": ["code", "minute"]} 场景(scene):login 登录 / register 注册 / notification 通知 / order_status 订单状态变动 / appointment 服务预约 验证码存储为内存(带 TTL 与尝试次数),进程重启即失效——生产应换 Redis。 模块保留无 db 依赖的验证码核心(issue/verify);配置由调用方从 ``system_configs`` 解析后传入(``load_sms_config(db)`` / ``parse_sms_config``)。 """ from __future__ import annotations import base64 import hashlib import hmac import json import secrets import threading import time import urllib.parse from dataclasses import dataclass, field import httpx from .. import config # ── 验证码存储(内存) ────────────────────────────────────────────────────── # phone -> {"code": str, "expires_at": float, "attempts": int} _STORE: dict[str, dict] = {} _LOCK = threading.Lock() _CODE_TTL = config.SMS_CODE_TTL_SECONDS _MAX_ATTEMPTS = config.SMS_RATE_LIMIT # 短信生态支持的全部场景(admin 端按此枚举展示/维护模板)。 SMS_SCENES = ("login", "register", "notification", "order_status", "appointment") SMS_SCENE_LABELS = { "login": "登录验证码", "register": "注册验证码", "notification": "通知提醒", "order_status": "订单状态变动", "appointment": "服务预约", } class SmsError(Exception): """短信业务异常(校验失败 / 限流 / 发送失败等)。""" # ── 配置模型 ──────────────────────────────────────────────────────────────── @dataclass class AliyunSmsConfig: access_key_id: str = "" access_key_secret: str = "" sign_name: str = "" region_id: str = "cn-hangzhou" endpoint: str = "dysmsapi.aliyuncs.com" @property def configured(self) -> bool: return bool(self.access_key_id and self.access_key_secret and self.sign_name) @dataclass class TemplateConfig: scene: str = "" template_id: str = "" variables: list[str] = field(default_factory=list) @dataclass class SmsConfig: provider: str = "stub" aliyun: AliyunSmsConfig = field(default_factory=AliyunSmsConfig) templates: dict[str, TemplateConfig] = field(default_factory=dict) def template(self, scene: str) -> TemplateConfig | None: return self.templates.get(scene) def _json_dct(raw: str) -> dict: try: v = json.loads(raw or "{}") return v if isinstance(v, dict) else {} except (json.JSONDecodeError, TypeError): return {} def parse_sms_config(rows: dict[str, str]) -> SmsConfig: """从 system_configs 键值对(key -> value 字符串)解析短信生态配置。""" cfg = SmsConfig(provider=(rows.get("sms.provider") or "stub").strip().lower()) al = _json_dct(rows.get("sms.aliyun") or "") cfg.aliyun = AliyunSmsConfig( access_key_id=str(al.get("access_key_id", "")).strip(), access_key_secret=str(al.get("access_key_secret", "")).strip(), sign_name=str(al.get("sign_name", "")).strip(), region_id=str(al.get("region_id", "") or "cn-hangzhou").strip(), endpoint=str(al.get("endpoint", "") or "dysmsapi.aliyuncs.com").strip(), ) for scene in SMS_SCENES: tpl = _json_dct(rows.get(f"sms.template.{scene}") or "") vars_ = tpl.get("variables") if isinstance(vars_, str): vars_ = [v.strip() for v in vars_.split(",") if v.strip()] cfg.templates[scene] = TemplateConfig( scene=scene, template_id=str(tpl.get("template_id", "")).strip(), variables=[str(v).strip() for v in (vars_ or []) if str(v).strip()], ) return cfg async def load_sms_config(db) -> SmsConfig: """从数据库 system_configs 加载短信生态配置(未配置的键保持默认)。""" rows: dict[str, str] = {} for c in await db.config.all(): rows[c["key"]] = str(c.get("value") or "") return parse_sms_config(rows) # ── 验证码核心(无 db 依赖) ─────────────────────────────────────────────── def issue(phone: str) -> str: """为手机号生成 6 位验证码并写入存储,返回明文。 真实发送由调用方依据场景模板调用 :func:`send_template` 完成; 若 provider 为 stub 且未指定场景模板,也可直接调用 :func:`send_code`。 """ code = DEMO_CODE if config.SMS_PROVIDER == "stub" else f"{secrets.randbelow(1_000_000):06d}" with _LOCK: _STORE[phone] = { "code": code, "expires_at": time.monotonic() + _CODE_TTL, "attempts": 0, } return code def verify(phone: str, code: str) -> bool: """校验验证码;成功即作废,失败累计次数超限则不可再试。""" with _LOCK: rec = _STORE.get(phone) if not rec: raise SmsError("verification code not issued") if time.monotonic() > rec["expires_at"]: _STORE.pop(phone, None) raise SmsError("verification code expired") if rec["attempts"] >= _MAX_ATTEMPTS: raise SmsError("too many attempts, request a new code") if rec["code"] != code.strip(): rec["attempts"] += 1 raise SmsError("invalid verification code") _STORE.pop(phone, None) return True # 演示验证码:stub provider 统一用固定码(先跑通流程,生产换真实短信)。 DEMO_CODE = "123456" def _pick_code(cfg: SmsConfig) -> str: """按 provider 决定验证码:stub 固定码,aliyun 随机码。""" if cfg.provider == "stub" or config.SMS_PROVIDER == "stub": return DEMO_CODE return f"{secrets.randbelow(1_000_000):06d}" async def send_code(cfg: SmsConfig, phone: str, scene: str = "login", extra: dict[str, str] | None = None) -> str: """生成并发送验证码(场景模板),返回明文供 stub 展示。 - stub provider:仅打印(含模板信息),返回固定码 123456。 - aliyun provider:按场景模板 + 变量(code/minute)真实发送,返回随机码。 """ scene = scene if scene in SMS_SCENES else "login" code = _pick_code(cfg) with _LOCK: _STORE[phone] = { "code": code, "expires_at": time.monotonic() + _CODE_TTL, "attempts": 0, } params = {"code": code, "minute": str(_CODE_TTL // 60)} if extra: params.update(extra) await send_template(cfg, scene, phone, params) return code # ── 场景短信发送 ──────────────────────────────────────────────────────────── async def send_template(cfg: SmsConfig, scene: str, phone: str, params: dict[str, str]) -> None: """按场景模板发送一条短信(非验证码也可用)。 未配置该场景模板或 provider 为 stub 时仅打印(不报错),保证业务不因 短信配置缺失而失败;aliyun 模板缺失 / 凭据未配置时抛 SmsError。 """ scene = scene if scene in SMS_SCENES else "notification" tpl = cfg.template(scene) if cfg.provider == "stub" or not tpl or not tpl.template_id: _print_stub(cfg, scene, phone, tpl, params) return if cfg.provider == "aliyun": if not cfg.aliyun.configured: raise SmsError("阿里云短信未配置(access_key_id/secret/sign_name)") await _send_aliyun(cfg.aliyun, tpl, phone, params) return raise SmsError(f"unsupported sms provider: {cfg.provider}") def _print_stub(cfg: SmsConfig, scene: str, phone: str, tpl: TemplateConfig | None, params: dict[str, str]) -> None: label = SMS_SCENE_LABELS.get(scene, scene) tid = tpl.template_id if tpl else "" body = " ".join(f"{k}={v}" for k, v in params.items()) or "-" print(f"[sms:stub] [{label}] template={tid or '-'} -> {phone}: {body}") # ── 阿里云短信(dysmsapi 2017-05-25, RPC 签名) ───────────────────────────── def _percent_encode(s: str) -> str: res = urllib.parse.quote(str(s), safe="~") # RPC 规范:空格 → %20(quote 默认 safe 不含空格,已满足),/ 需编码 res = res.replace("+", "%20").replace("*", "%2A").replace("%7E", "~") return res def _aliyun_sign(secret: str, params: dict[str, str]) -> str: """阿里云 RPC 签名:HMAC-SHA1(secret + '&', 规范化查询串)。""" query = "&".join( f"{_percent_encode(k)}={_percent_encode(params[k])}" for k in sorted(params) ) string_to_sign = "GET&%2F&" + _percent_encode(query) key = (secret + "&").encode("utf-8") digest = hmac.new(key, string_to_sign.encode("utf-8"), hashlib.sha1).digest() return base64.b64encode(digest).decode("utf-8") async def _send_aliyun(aliyun: AliyunSmsConfig, tpl: TemplateConfig, phone: str, params: dict[str, str]) -> None: common = { "AccessKeyId": aliyun.access_key_id, "Action": "SendSms", "Format": "JSON", "RegionId": aliyun.region_id or "cn-hangzhou", "SignatureMethod": "HMAC-SHA1", "SignatureNonce": secrets.token_hex(16), "SignatureVersion": "1.0", "Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "Version": "2017-05-25", "PhoneNumbers": phone, "SignName": aliyun.sign_name, "TemplateCode": tpl.template_id, } if params: common["TemplateParam"] = json.dumps(params, ensure_ascii=False) common["Signature"] = _aliyun_sign(aliyun.access_key_secret, common) url = f"https://{aliyun.endpoint or 'dysmsapi.aliyuncs.com'}/" try: async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client: resp = await client.get(url, params=common) except httpx.HTTPError as exc: raise SmsError(f"阿里云短信请求失败: {exc}") if resp.status_code != 200: raise SmsError(f"阿里云短信返回 {resp.status_code}: {resp.text[:200]}") try: data = resp.json() except ValueError: raise SmsError(f"阿里云短信响应解析失败: {resp.text[:200]}") code = str(data.get("Code", "")) if code != "OK": raise SmsError(f"阿里云短信发送失败: {code} {data.get('Message', '')}")