2026-08-24 14:16:34 +08:00
|
|
|
|
# -*- coding: utf-8 -*-
|
2026-09-01 11:22:19 +08:00
|
|
|
|
"""短信生态服务(云超服平台统一短信)。
|
2026-08-24 14:16:34 +08:00
|
|
|
|
|
2026-09-01 11:22:19 +08:00
|
|
|
|
支持两类 provider(由系统配置 ``sms.provider`` 控制):
|
2026-08-24 14:16:34 +08:00
|
|
|
|
|
2026-09-01 11:22:19 +08:00
|
|
|
|
- ``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 服务预约
|
|
|
|
|
|
|
|
|
|
|
|
验证码存储为内存(带 TTL 与尝试次数),进程重启即失效——生产应换 Redis。
|
|
|
|
|
|
|
|
|
|
|
|
模块保留无 db 依赖的验证码核心(issue/verify);配置由调用方从
|
|
|
|
|
|
``system_configs`` 解析后传入(``load_sms_config(db)`` / ``parse_sms_config``)。
|
2026-08-24 14:16:34 +08:00
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-09-01 11:22:19 +08:00
|
|
|
|
import base64
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import hmac
|
|
|
|
|
|
import json
|
2026-08-24 14:16:34 +08:00
|
|
|
|
import secrets
|
|
|
|
|
|
import threading
|
|
|
|
|
|
import time
|
2026-09-01 11:22:19 +08:00
|
|
|
|
import urllib.parse
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
2026-08-24 14:16:34 +08:00
|
|
|
|
|
|
|
|
|
|
from .. import config
|
|
|
|
|
|
|
2026-09-01 11:22:19 +08:00
|
|
|
|
# ── 验证码存储(内存) ──────────────────────────────────────────────────────
|
2026-08-24 14:16:34 +08:00
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-09-01 11:22:19 +08:00
|
|
|
|
# 短信生态支持的全部场景(admin 端按此枚举展示/维护模板)。
|
|
|
|
|
|
SMS_SCENES = ("login", "register", "notification", "order_status", "appointment")
|
|
|
|
|
|
SMS_SCENE_LABELS = {
|
|
|
|
|
|
"login": "登录验证码",
|
|
|
|
|
|
"register": "注册验证码",
|
|
|
|
|
|
"notification": "通知提醒",
|
|
|
|
|
|
"order_status": "订单状态变动",
|
|
|
|
|
|
"appointment": "服务预约",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 14:16:34 +08:00
|
|
|
|
|
|
|
|
|
|
class SmsError(Exception):
|
2026-09-01 11:22:19 +08:00
|
|
|
|
"""短信业务异常(校验失败 / 限流 / 发送失败等)。"""
|
2026-08-24 14:16:34 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 11:22:19 +08:00
|
|
|
|
# ── 配置模型 ────────────────────────────────────────────────────────────────
|
2026-08-24 14:16:34 +08:00
|
|
|
|
|
2026-09-01 11:22:19 +08:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class AliyunSmsConfig:
|
|
|
|
|
|
access_key_id: str = ""
|
|
|
|
|
|
access_key_secret: str = ""
|
|
|
|
|
|
sign_name: str = ""
|
|
|
|
|
|
region_id: str = "cn-hangzhou"
|
|
|
|
|
|
endpoint: str = "dysmsapi.aliyuncs.com"
|
2026-08-24 14:16:34 +08:00
|
|
|
|
|
2026-09-01 11:22:19 +08:00
|
|
|
|
@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)
|
2026-08-25 22:47:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 11:22:19 +08:00
|
|
|
|
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 依赖) ───────────────────────────────────────────────
|
|
|
|
|
|
|
2026-08-24 14:16:34 +08:00
|
|
|
|
def issue(phone: str) -> str:
|
2026-09-01 11:22:19 +08:00
|
|
|
|
"""为手机号生成 6 位验证码并写入存储,返回明文。
|
2026-08-25 22:47:27 +08:00
|
|
|
|
|
2026-09-01 11:22:19 +08:00
|
|
|
|
真实发送由调用方依据场景模板调用 :func:`send_template` 完成;
|
|
|
|
|
|
若 provider 为 stub 且未指定场景模板,也可直接调用 :func:`send_code`。
|
2026-08-25 22:47:27 +08:00
|
|
|
|
"""
|
|
|
|
|
|
code = DEMO_CODE if config.SMS_PROVIDER == "stub" else f"{secrets.randbelow(1_000_000):06d}"
|
2026-08-24 14:16:34 +08:00
|
|
|
|
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
|
2026-09-01 11:22:19 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 演示验证码: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', '')}")
|