Files
server-core/app/services/sms.py
T
Pine 61c572a19a feat(task,sms): 任务功能增量合并到大厅 + 短信生态配置
- 任务统一合并到 /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 预约/订单短信
2026-09-01 11:22:19 +08:00

292 lines
11 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 服务预约
验证码存储为内存(带 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', '')}")