sms.py 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  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. def _percent_encode(s: str) -> str:
  16. """RFC 3986 percent encoding"""
  17. return quote(s, safe="").replace("+", "%20").replace("*", "%2A").replace("%7E", "~")
  18. def _build_signature(method: str, params: dict[str, str], secret: str) -> str:
  19. """阿里云 POP 签名"""
  20. sorted_keys = sorted(params.keys())
  21. canonicalized = "&".join(f"{_percent_encode(k)}={_percent_encode(params[k])}" for k in sorted_keys)
  22. string_to_sign = f"{method.upper()}&%2F&{_percent_encode(canonicalized)}"
  23. signing_key = secret + "&"
  24. h = hmac.new(signing_key.encode(), string_to_sign.encode(), hashlib.sha1)
  25. return base64.b64encode(h.digest()).decode()
  26. async def _send_sms(phone: str, template_code: str, template_params: dict) -> bool:
  27. """调用阿里云 SendSms 接口"""
  28. if not settings.sms_access_key_id or not settings.sms_access_key_secret:
  29. logger.warning("阿里云短信配置未设置,跳过短信发送")
  30. return False
  31. now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
  32. params = {
  33. "AccessKeyId": settings.sms_access_key_id,
  34. "Action": "SendSms",
  35. "Format": "JSON",
  36. "PhoneNumbers": phone,
  37. "RegionId": "cn-hangzhou",
  38. "SignName": settings.sms_sign_name,
  39. "SignatureMethod": "HMAC-SHA1",
  40. "SignatureNonce": uuid.uuid4().hex,
  41. "SignatureVersion": "1.0",
  42. "TemplateCode": template_code,
  43. "TemplateParam": str(template_params).replace("'", '"'),
  44. "Timestamp": now,
  45. "Version": "2017-05-25",
  46. }
  47. signature = _build_signature("POST", params, settings.sms_access_key_secret)
  48. params["Signature"] = signature
  49. try:
  50. async with httpx.AsyncClient(timeout=10) as client:
  51. resp = await client.post(ALIYUN_ENDPOINT, data=params)
  52. result = resp.json()
  53. if result.get("Code") == "OK":
  54. logger.info("短信发送成功: %s", phone)
  55. return True
  56. else:
  57. logger.error("短信发送失败: %s - %s", result.get("Code"), result.get("Message"))
  58. return False
  59. except Exception:
  60. logger.exception("短信发送异常: %s", phone)
  61. return False
  62. async def send_sms_code(phone: str, code: str) -> bool:
  63. """发送验证码短信"""
  64. return await _send_sms(phone, settings.sms_template_code_verify, {"code": code})
  65. async def send_license_expired(phone: str, company: str) -> bool:
  66. """发送 License 过期通知短信"""
  67. return await _send_sms(phone, settings.sms_template_code_expired, {"company": company})
  68. async def send_license_restored(phone: str, company: str) -> bool:
  69. """发送 License 恢复通知短信"""
  70. return await _send_sms(phone, settings.sms_template_code_restored, {"company": company})
  71. def generate_verify_code() -> str:
  72. """生成 6 位数字验证码"""
  73. return "".join(random.choices(string.digits, k=6))