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

331 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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}
# 优先 Rediskey: 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 规范:空格 → %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', '')}")