Files
server-core/app/services/sms.py
T

292 lines
11 KiB
Python
Raw Normal View History

# -*- 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 服务预约
验证码存储为内存(带 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 规范:空格 → %20quote 默认 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', '')}")