331 lines
13 KiB
Python
331 lines
13 KiB
Python
# -*- 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.<scene>`` : 场景模板 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', '')}")
|