61c572a19a
- 任务统一合并到 /hall:新增 grab/grab-by-code/bid/doing/complete/deliver/recommend 操作端点(走 TaskService 真实逻辑) - 删除 rbac_opc 全部旧任务端点(广场/详情/grab/bid/doing/complete/assign/recommend/select/deliver),/opc/tasks 返回 404 - /opc/my-tasks 改真实聚合(task_claims 承接 + bids 投标,附任务信息),不再读 opc_tasks 演示表 - dashboard_service.opc_dashboard 改 task_claims 统计 - 删除 opc_tasks 演示表(models/seed/repositories) - BidRepository 新增 list_by_opc - 短信生态(上一轮):sms.py 阿里云签名、seed 7 项配置、send-code scene、rbac_hall 预约/订单短信
292 lines
11 KiB
Python
292 lines
11 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 服务预约
|
||
|
||
验证码存储为内存(带 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 规范:空格 → %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', '')}")
|