sms.py 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. """阿里云短信服务封装"""
  2. import base64
  3. import hashlib
  4. import hmac
  5. import logging
  6. import random
  7. import string
  8. import uuid
  9. from datetime import datetime, timezone
  10. from urllib.parse import quote
  11. import httpx
  12. from app.config import settings
  13. logger = logging.getLogger(__name__)
  14. ALIYUN_ENDPOINT = "https://dysmsapi.aliyuncs.com/"
  15. # 内存防重:记录每个手机号的最后一次发送时间,60 秒内不发第二次
  16. _send_times: dict[str, float] = {}
  17. _SMS_COOLDOWN = 60 # 秒
  18. def _percent_encode(s: str) -> str:
  19. """RFC 3986 percent encoding"""
  20. return quote(s, safe="").replace("+", "%20").replace("*", "%2A").replace("%7E", "~")
  21. def _build_signature(method: str, params: dict[str, str], secret: str) -> str:
  22. """阿里云 POP 签名"""
  23. sorted_keys = sorted(params.keys())
  24. canonicalized = "&".join(f"{_percent_encode(k)}={_percent_encode(params[k])}" for k in sorted_keys)
  25. string_to_sign = f"{method.upper()}&%2F&{_percent_encode(canonicalized)}"
  26. signing_key = secret + "&"
  27. h = hmac.new(signing_key.encode(), string_to_sign.encode(), hashlib.sha1)
  28. return base64.b64encode(h.digest()).decode()
  29. async def _send_sms(phone: str, template_code: str, template_params: dict) -> tuple[bool, str]:
  30. """调用阿里云 SendSms 接口。返回 (success, reason)"""
  31. if not settings.sms_access_key_id or not settings.sms_access_key_secret:
  32. logger.warning("阿里云短信配置未设置(AK/SK为空),跳过短信发送")
  33. return False, "AK/SK未配置"
  34. now_ts = datetime.now(timezone.utc).timestamp()
  35. last = _send_times.get(phone, 0)
  36. if now_ts - last < _SMS_COOLDOWN:
  37. logger.info("手机号 %s 距离上次发送不足 %d 秒,跳过防重", phone, _SMS_COOLDOWN)
  38. return False, "冷却期内,跳过发送"
  39. _send_times[phone] = now_ts
  40. now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
  41. params = {
  42. "AccessKeyId": settings.sms_access_key_id,
  43. "Action": "SendSms",
  44. "Format": "JSON",
  45. "PhoneNumbers": phone,
  46. "RegionId": "cn-hangzhou",
  47. "SignName": settings.sms_sign_name,
  48. "SignatureMethod": "HMAC-SHA1",
  49. "SignatureNonce": uuid.uuid4().hex,
  50. "SignatureVersion": "1.0",
  51. "TemplateCode": template_code,
  52. "TemplateParam": str(template_params).replace("'", '"'),
  53. "Timestamp": now,
  54. "Version": "2017-05-25",
  55. }
  56. signature = _build_signature("POST", params, settings.sms_access_key_secret)
  57. params["Signature"] = signature
  58. try:
  59. async with httpx.AsyncClient(timeout=10) as client:
  60. resp = await client.post(ALIYUN_ENDPOINT, data=params)
  61. result = resp.json()
  62. if result.get("Code") == "OK":
  63. logger.info("短信发送成功: %s", phone)
  64. return True, "发送成功"
  65. else:
  66. code = result.get("Code")
  67. msg = result.get("Message")
  68. logger.error("短信发送失败: phone=%s, code=%s, msg=%s", phone, code, msg)
  69. return False, f"阿里云错误: {code} - {msg}"
  70. except Exception as e:
  71. logger.exception("短信发送异常: %s", phone)
  72. return False, f"异常: {e}"
  73. async def send_sms_code(phone: str, code: str) -> tuple[bool, str]:
  74. """发送验证码短信。返回 (success, reason)"""
  75. return await _send_sms(phone, settings.sms_template_code_verify, {"code": code})
  76. async def send_license_expired(phone: str, company: str) -> tuple[bool, str]:
  77. """发送 License 过期通知短信。返回 (success, reason)"""
  78. return await _send_sms(phone, settings.sms_template_code_expired, {"company": company})
  79. async def send_license_restored(phone: str, company: str) -> tuple[bool, str]:
  80. """发送 License 恢复通知短信。返回 (success, reason)"""
  81. return await _send_sms(phone, settings.sms_template_code_restored, {"company": company})
  82. async def send_license_warning(phone: str, company: str, days: int) -> tuple[bool, str]:
  83. """发送 License 即将过期预警短信。返回 (success, reason)"""
  84. return await _send_sms(phone, settings.sms_template_code_warning, {"company": company, "days": str(days)})
  85. def generate_verify_code() -> str:
  86. """生成 6 位数字验证码"""
  87. return "".join(random.choices(string.digits, k=6))