sms_service.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. """
  2. 阿里云短信服务
  3. 提供验证码发送、余额预警短信发送功能
  4. """
  5. import os
  6. import json
  7. import random
  8. import logging
  9. from typing import Optional
  10. logger = logging.getLogger(__name__)
  11. SMS_ACCESS_KEY_ID = os.getenv("SMS_ACCESS_KEY_ID", os.getenv("ALIBABA_CLOUD_ACCESS_KEY_ID", ""))
  12. SMS_ACCESS_KEY_SECRET = os.getenv("SMS_ACCESS_KEY_SECRET", os.getenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET", ""))
  13. SMS_SIGN_NAME = os.getenv("SMS_SIGN_NAME", "智创空间")
  14. SMS_TEMPLATE_VERIFY = os.getenv("SMS_TEMPLATE_CODE_VERIFY", "") # 验证码模板
  15. SMS_TEMPLATE_WARN = os.getenv("SMS_TEMPLATE_CODE_WARN", "") # 余额预警模板
  16. # 验证码有效期(秒)
  17. CODE_TTL = 300 # 5分钟
  18. # 同一手机号发送间隔(秒)
  19. SEND_INTERVAL = 60
  20. def _get_client():
  21. from alibabacloud_dysmsapi20170525.client import Client
  22. from alibabacloud_tea_openapi import models as open_api_models
  23. config = open_api_models.Config(
  24. access_key_id=SMS_ACCESS_KEY_ID,
  25. access_key_secret=SMS_ACCESS_KEY_SECRET,
  26. )
  27. config.endpoint = "dysmsapi.aliyuncs.com"
  28. return Client(config)
  29. def send_sms(phone: str, template_code: str, params: dict) -> bool:
  30. """发送短信,返回是否成功"""
  31. try:
  32. from alibabacloud_dysmsapi20170525 import models as sms_models
  33. client = _get_client()
  34. request = sms_models.SendSmsRequest(
  35. phone_numbers=phone,
  36. sign_name=SMS_SIGN_NAME,
  37. template_code=template_code,
  38. template_param=json.dumps(params, ensure_ascii=False),
  39. )
  40. resp = client.send_sms(request)
  41. if resp.body.code == "OK":
  42. logger.info(f"短信发送成功: phone={phone}, template={template_code}")
  43. return True
  44. else:
  45. logger.warning(f"短信发送失败: phone={phone}, code={resp.body.code}, msg={resp.body.message}")
  46. return False
  47. except Exception as e:
  48. logger.error(f"短信发送异常: phone={phone}, error={e}")
  49. return False
  50. class SmsCodeService:
  51. """验证码服务(基于 Redis)"""
  52. REDIS_PREFIX_CODE = "sms:code:"
  53. REDIS_PREFIX_INTERVAL = "sms:interval:"
  54. def __init__(self):
  55. from app.core.redis import redis_manager
  56. self._redis_manager = redis_manager
  57. @property
  58. def redis(self):
  59. return self._redis_manager.get_client()
  60. async def send_code(self, phone: str) -> tuple[bool, str]:
  61. """
  62. 发送验证码
  63. 返回 (success, message)
  64. """
  65. if not self.redis:
  66. return False, "短信服务暂不可用"
  67. # 检查发送间隔
  68. interval_key = f"{self.REDIS_PREFIX_INTERVAL}{phone}"
  69. if await self.redis.exists(interval_key):
  70. ttl = await self.redis.ttl(interval_key)
  71. return False, f"请 {ttl} 秒后再试"
  72. # 生成6位验证码
  73. code = str(random.randint(100000, 999999))
  74. # 发送短信
  75. if not SMS_TEMPLATE_VERIFY:
  76. logger.warning("未配置验证码短信模板 SMS_TEMPLATE_CODE_VERIFY")
  77. # 开发模式:不实际发送,直接存储
  78. else:
  79. ok = send_sms(phone, SMS_TEMPLATE_VERIFY, {"code": code})
  80. if not ok:
  81. return False, "短信发送失败,请稍后重试"
  82. # 存储验证码
  83. code_key = f"{self.REDIS_PREFIX_CODE}{phone}"
  84. await self.redis.setex(code_key, CODE_TTL, code)
  85. # 设置发送间隔
  86. await self.redis.setex(interval_key, SEND_INTERVAL, "1")
  87. logger.info(f"验证码已发送: phone={phone}, code={code}")
  88. return True, "验证码已发送"
  89. async def verify_code(self, phone: str, code: str, delete_after: bool = True) -> bool:
  90. """验证验证码,验证成功后默认删除"""
  91. if not self.redis:
  92. logger.warning(f"Redis 不可用,验证码验证失败: phone={phone}")
  93. return False
  94. code_key = f"{self.REDIS_PREFIX_CODE}{phone}"
  95. stored = await self.redis.get(code_key)
  96. logger.info(f"验证码校验: phone={phone}, input={code}, stored={stored}")
  97. if stored and stored == code:
  98. if delete_after:
  99. await self.redis.delete(code_key)
  100. return True
  101. return False
  102. # 全局单例
  103. sms_code_service = SmsCodeService()
  104. class TenantWarnSmsService:
  105. """企业余额预警短信服务"""
  106. # 已发送预警记录(避免重复发送),key: tenant_id:level
  107. REDIS_PREFIX_WARN = "sms:warn:"
  108. # 同一档位预警间隔(秒):24小时
  109. WARN_INTERVAL = 86400
  110. def __init__(self):
  111. from app.core.redis import redis_manager
  112. self._redis_manager = redis_manager
  113. @property
  114. def redis(self):
  115. return self._redis_manager.get_client()
  116. async def send_warn(self, tenant_id: int, phone: str, company_name: str,
  117. balance: float, level: str) -> bool:
  118. """发送余额预警短信,同一档位24小时内不重复发送"""
  119. if not SMS_TEMPLATE_WARN:
  120. logger.warning("未配置预警短信模板 SMS_TEMPLATE_CODE_WARN")
  121. return False
  122. # 检查是否已发送过该档位
  123. if self.redis:
  124. warn_key = f"{self.REDIS_PREFIX_WARN}{tenant_id}:{level}"
  125. if await self.redis.exists(warn_key):
  126. return False # 已发送,跳过
  127. ok = send_sms(phone, SMS_TEMPLATE_WARN, {
  128. "companyName": company_name,
  129. "remainAmount": f"{balance:.2f}",
  130. })
  131. if ok and self.redis:
  132. warn_key = f"{self.REDIS_PREFIX_WARN}{tenant_id}:{level}"
  133. await self.redis.setex(warn_key, self.WARN_INTERVAL, "1")
  134. return ok
  135. async def check_and_warn(self, db) -> list[dict]:
  136. """
  137. 检查所有企业余额,对达到预警阈值的企业发送短信
  138. 返回已发送的记录列表
  139. """
  140. from app.models.tenant import Tenant, TenantApplication
  141. from decimal import Decimal
  142. from sqlalchemy import and_
  143. sent = []
  144. tenants = db.query(Tenant).filter(Tenant.status == "active").all()
  145. for tenant in tenants:
  146. balance = float(tenant.balance or 0)
  147. level = None
  148. if balance <= 0:
  149. level = "critical"
  150. elif balance <= 100:
  151. level = "low"
  152. elif balance <= 200:
  153. level = "medium"
  154. elif balance <= 500:
  155. level = "warning"
  156. if not level:
  157. continue
  158. # 获取联系电话(从 TenantApplication 里取)
  159. app = db.query(TenantApplication).filter(
  160. TenantApplication.tenant_id == tenant.id
  161. ).order_by(TenantApplication.id.desc()).first()
  162. phone = app.contact_phone if app else None
  163. if not phone:
  164. continue
  165. ok = await self.send_warn(
  166. tenant_id=tenant.id,
  167. phone=phone,
  168. company_name=tenant.company_name,
  169. balance=balance,
  170. level=level,
  171. )
  172. if ok:
  173. sent.append({
  174. "tenant_id": tenant.id,
  175. "company_name": tenant.company_name,
  176. "phone": phone,
  177. "balance": balance,
  178. "level": level,
  179. })
  180. return sent
  181. tenant_warn_sms = TenantWarnSmsService()