# -*- 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 服务预约 验证码存储优先使用 Redis(``sms:code:{phone}``,带 TTL 与尝试次数,跨进程 共享、重启不丢);Redis 未连接时降级内存(仅单进程联调可用)。 **随机数策略**:后端 provider 为 ``aliyun`` 时**强制真实随机 6 位码**,绝不允许 使用演示码 123456;仅 provider 为 ``stub``(未配置阿里云)时才使用演示码 123456 便于联调。 """ 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 from ..infrastructure.cache import cache as _redis_cache # ── 验证码存储 ───────────────────────────────────────────────────────────── # phone -> {"code": str, "expires_at": float, "attempts": int} # 优先 Redis(key: sms:code:{phone},EX=_CODE_TTL);Redis 不可用降级内存。 _STORE: dict[str, dict] = {} _LOCK = threading.Lock() _STORE_PREFIX = "sms:code:" _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) # ── 验证码核心(Redis 优先,降级内存) ───────────────────────────────────── async def _store_save(phone: str, rec: dict) -> None: """写入验证码记录(Redis EX TTL 自动过期;不可用时降级内存)。""" if _redis_cache.available: await _redis_cache.set_json(f"{_STORE_PREFIX}{phone}", rec, ttl=_CODE_TTL) return with _LOCK: _STORE[phone] = rec async def _store_load(phone: str) -> dict | None: if _redis_cache.available: return await _redis_cache.get_json(f"{_STORE_PREFIX}{phone}") with _LOCK: return _STORE.get(phone) async def _store_delete(phone: str) -> None: if _redis_cache.available: await _redis_cache.delete(f"{_STORE_PREFIX}{phone}") return with _LOCK: _STORE.pop(phone, None) async def issue(phone: str) -> str: """为手机号生成 6 位验证码并写入存储,返回明文。 随机数策略与 :func:`_pick_code` 一致:仅系统级 stub 配置(env ``PINEAGENTS_SMS_PROVIDER=stub``)使用演示码 123456;其余一律真实随机。 真实发送由调用方依据场景模板调用 :func:`send_template` 完成。 """ code = DEMO_CODE if config.SMS_PROVIDER == "stub" else f"{secrets.randbelow(1_000_000):06d}" await _store_save(phone, { "code": code, "expires_at": time.monotonic() + _CODE_TTL, "attempts": 0, }) return code async def verify(phone: str, code: str) -> bool: """校验验证码;成功即作废,失败累计次数超限则不可再试。""" rec = await _store_load(phone) if not rec: raise SmsError("verification code not issued") if time.monotonic() > rec["expires_at"]: await _store_delete(phone) 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 await _store_save(phone, rec) raise SmsError("invalid verification code") await _store_delete(phone) return True # 演示验证码:仅 stub provider(未配置阿里云)联调用;aliyun 绝不允许使用。 DEMO_CODE = "123456" def _pick_code(cfg: SmsConfig) -> str: """按 provider 决定验证码:仅 stub 用固定码 123456,其余(含 aliyun)真实随机。""" if cfg.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) await _store_save(phone, { "code": code, "expires_at": time.monotonic() + _CODE_TTL, "attempts": 0, }) # 只传模板声明过的变量:阿里云对模板未声明的变量名会报 # isv.TEMPLATE_PARAMS_ILLEGAL / 变量不匹配。模板未配置时仍传 code # (stub 打印用),保证联调可见。 tpl = cfg.template(scene) declared = set(tpl.variables) if tpl else set() params: dict[str, str] = {} if not declared or "code" in declared: params["code"] = code if "minute" in declared: params["minute"] = str(_CODE_TTL // 60) if extra: for k, v in extra.items(): if k in declared: params[k] = str(v) 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', '')}")