send_sms_gn.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. """发送国内短信验证码服务(准实时)
  2. 从数据库读取待发送的验证码(area_code=+86),使用阿里云短信服务发送。
  3. Prerequisites:
  4. - Python >= 3.7
  5. - `pip install alibabacloud_dysmsapi20170525==4.3.0`
  6. - 配置文件:send.conf(与脚本同目录)
  7. - 必需配置:[aliyun_sms] 下的 access_key_id 和 access_key_secret
  8. - 必需配置:[database] 下的数据库配置
  9. 运行: python send_sms_gn.py
  10. """
  11. import asyncio
  12. import configparser
  13. import os
  14. import sys
  15. import logging
  16. from datetime import datetime
  17. from pathlib import Path
  18. from typing import Dict, Optional, Tuple
  19. from alibabacloud_dysmsapi20170525.client import Client as Dysmsapi20170525Client
  20. from alibabacloud_dysmsapi20170525 import models as dysms_models
  21. from alibabacloud_tea_openapi import models as open_api_models
  22. from alibabacloud_tea_util import models as util_models
  23. from alibabacloud_tea_util.client import Client as UtilClient
  24. from Tea.exceptions import UnretryableException, TeaException
  25. from sqlalchemy import select, update
  26. # 加载配置文件
  27. config_file = Path(__file__).parent / "send.conf"
  28. config = configparser.ConfigParser()
  29. if config_file.exists():
  30. config.read(config_file, encoding='utf-8')
  31. else:
  32. raise FileNotFoundError(f"配置文件不存在: {config_file}")
  33. # 导入数据库相关模块(需要从 backend 目录导入)
  34. backend_dir = Path(__file__).parent.parent / "backend"
  35. sys.path.insert(0, str(backend_dir))
  36. from database import DatabaseManager
  37. from models_db import VerificationCode
  38. logger = logging.getLogger(__name__)
  39. # ------- 从配置文件读取参数 -------
  40. # 阿里云短信配置
  41. ACCESS_KEY_ID = config.get("aliyun_sms", "access_key_id", fallback="").strip()
  42. ACCESS_KEY_SECRET = config.get("aliyun_sms", "access_key_secret", fallback="").strip()
  43. SIGN_NAME = config.get("aliyun_sms", "sign_name", fallback="灵语科技")
  44. TEMPLATE_CODE = config.get("aliyun_sms", "template_code", fallback="SMS_498720756")
  45. ENDPOINT = config.get("aliyun_sms", "endpoint", fallback="dysmsapi.aliyuncs.com")
  46. # 数据库配置
  47. MYSQL_HOST = config.get("database", "mysql_host", fallback="").strip()
  48. MYSQL_PORT = config.get("database", "mysql_port", fallback="").strip()
  49. MYSQL_USER = config.get("database", "mysql_user", fallback="").strip()
  50. MYSQL_PASSWORD = config.get("database", "mysql_password", fallback="").strip()
  51. MYSQL_DATABASE = config.get("database", "mysql_database", fallback="").strip()
  52. # 服务配置
  53. POLL_INTERVAL = float(config.get("service", "poll_interval", fallback="2.0"))
  54. def create_client() -> Dysmsapi20170525Client:
  55. """初始化阿里云短信客户端(从配置文件读取 AccessKey)"""
  56. if not ACCESS_KEY_ID or not ACCESS_KEY_SECRET:
  57. raise RuntimeError(
  58. "Missing AccessKey. Please set access_key_id and access_key_secret "
  59. "in send.conf file."
  60. )
  61. client_config = open_api_models.Config(
  62. access_key_id=ACCESS_KEY_ID,
  63. access_key_secret=ACCESS_KEY_SECRET,
  64. endpoint=ENDPOINT,
  65. )
  66. return Dysmsapi20170525Client(client_config)
  67. async def send_sms_async(
  68. client: Dysmsapi20170525Client,
  69. phone: str,
  70. code: str,
  71. ) -> Tuple[bool, Optional[str]]:
  72. """
  73. 发送短信验证码(异步包装)
  74. Args:
  75. client: 阿里云短信客户端
  76. phone: 手机号
  77. code: 验证码
  78. Returns:
  79. (是否成功, 错误信息)
  80. """
  81. template_params: Dict[str, str] = {"code": code}
  82. request = dysms_models.SendSmsRequest(
  83. phone_numbers=phone,
  84. sign_name=SIGN_NAME,
  85. template_code=TEMPLATE_CODE,
  86. template_param=UtilClient.to_jsonstring(template_params),
  87. )
  88. try:
  89. # 使用 asyncio.to_thread 将同步调用转为异步
  90. resp = await asyncio.to_thread(
  91. client.send_sms_with_options,
  92. request,
  93. util_models.RuntimeOptions()
  94. )
  95. # 检查响应
  96. if resp.body.code == "OK":
  97. logger.info(f"✅ 短信发送成功: phone={phone}, code_id={code[:4]}***")
  98. return True, None
  99. else:
  100. error_msg = f"Code: {resp.body.code}, Message: {resp.body.message}"
  101. logger.error(f"❌ 短信发送失败: phone={phone}, {error_msg}")
  102. return False, error_msg
  103. except UnretryableException as e:
  104. error_msg = f"Network error: {e}"
  105. logger.error(f"❌ 短信发送网络错误: phone={phone}, {error_msg}")
  106. return False, error_msg
  107. except TeaException as e:
  108. error_msg = f"Business error: {e.message}"
  109. if e.data:
  110. error_msg += f", Recommend: {e.data.get('Recommend', '')}"
  111. logger.error(f"❌ 短信发送业务错误: phone={phone}, {error_msg}")
  112. return False, error_msg
  113. except Exception as e:
  114. error_msg = f"Unexpected error: {e}"
  115. logger.error(f"❌ 短信发送异常: phone={phone}, {error_msg}")
  116. return False, error_msg
  117. async def process_domestic_sms_codes(client: Dysmsapi20170525Client, db_manager: DatabaseManager):
  118. """扫描并发送国内短信验证码(area_code=+86)"""
  119. async with db_manager.get_session() as session:
  120. # 查询未发送的国内短信验证码(area_code=+86)
  121. result = await session.execute(
  122. select(VerificationCode)
  123. .where(
  124. VerificationCode.send_type == "domestic_sms",
  125. VerificationCode.area_code == "+86",
  126. VerificationCode.is_sent == False,
  127. VerificationCode.is_valid == True,
  128. VerificationCode.phone.isnot(None)
  129. )
  130. .order_by(VerificationCode.created_at.asc())
  131. .limit(50) # 每次处理50条
  132. )
  133. codes = result.scalars().all()
  134. if not codes:
  135. return 0, 0
  136. logger.info(f"📋 找到 {len(codes)} 条待发送的国内短信验证码")
  137. success_count = 0
  138. fail_count = 0
  139. for code_record in codes:
  140. try:
  141. # 发送短信
  142. success, error_msg = await send_sms_async(
  143. client,
  144. code_record.phone,
  145. code_record.code
  146. )
  147. # 更新数据库状态(使用显式 UPDATE,避免 ORM 同步问题)
  148. now = datetime.now()
  149. if success:
  150. await session.execute(
  151. update(VerificationCode)
  152. .where(VerificationCode.id == code_record.id)
  153. .values(
  154. is_sent=True,
  155. sent_at=now,
  156. metadata_json={
  157. **(code_record.metadata_json or {}),
  158. "send_status": "success",
  159. "send_at": now.isoformat(),
  160. },
  161. )
  162. )
  163. success_count += 1
  164. else:
  165. # 失败:更新 metadata,并将验证码标记为失效,避免重复发送
  166. await session.execute(
  167. update(VerificationCode)
  168. .where(VerificationCode.id == code_record.id)
  169. .values(
  170. is_valid=False,
  171. metadata_json={
  172. **(code_record.metadata_json or {}),
  173. "send_status": "failed",
  174. "error": error_msg,
  175. "send_at": now.isoformat(),
  176. },
  177. )
  178. )
  179. fail_count += 1
  180. await session.commit()
  181. except Exception as e:
  182. logger.error(f"❌ 处理验证码失败: code_id={code_record.code_id}, error={e}")
  183. try:
  184. now = datetime.now()
  185. await session.execute(
  186. update(VerificationCode)
  187. .where(VerificationCode.id == code_record.id)
  188. .values(
  189. is_valid=False,
  190. metadata_json={
  191. **(code_record.metadata_json or {}),
  192. "send_status": "error",
  193. "error": str(e),
  194. "send_at": now.isoformat(),
  195. },
  196. )
  197. )
  198. await session.commit()
  199. except:
  200. pass
  201. fail_count += 1
  202. if success_count > 0 or fail_count > 0:
  203. logger.info(f"📊 处理完成: 成功 {success_count} 条, 失败 {fail_count} 条")
  204. return success_count, fail_count
  205. async def main_loop():
  206. """主循环:准实时轮询并发送验证码"""
  207. # 设置数据库配置到环境变量(DatabaseManager 从环境变量读取)
  208. if MYSQL_HOST:
  209. os.environ["MYSQL_HOST"] = MYSQL_HOST
  210. if MYSQL_PORT:
  211. os.environ["MYSQL_PORT"] = MYSQL_PORT
  212. if MYSQL_USER:
  213. os.environ["MYSQL_USER"] = MYSQL_USER
  214. if MYSQL_PASSWORD:
  215. os.environ["MYSQL_PASSWORD"] = MYSQL_PASSWORD
  216. if MYSQL_DATABASE:
  217. os.environ["MYSQL_DATABASE"] = MYSQL_DATABASE
  218. db_manager = DatabaseManager()
  219. client = None
  220. try:
  221. # 初始化数据库连接
  222. await db_manager.connect()
  223. logger.info("✅ 数据库连接成功")
  224. # 初始化短信客户端
  225. client = create_client()
  226. logger.info("✅ 阿里云短信客户端初始化成功")
  227. logger.info(f"🚀 开始准实时发送服务(轮询间隔: {POLL_INTERVAL}秒)...")
  228. # 主循环:持续轮询
  229. while True:
  230. try:
  231. await process_domestic_sms_codes(client, db_manager)
  232. except Exception as e:
  233. logger.error(f"❌ 处理验证码时出错: {e}", exc_info=True)
  234. # 等待下次轮询
  235. await asyncio.sleep(POLL_INTERVAL)
  236. except KeyboardInterrupt:
  237. logger.info("🛑 收到中断信号,正在关闭...")
  238. except Exception as e:
  239. logger.error(f"❌ 服务异常: {e}", exc_info=True)
  240. sys.exit(1)
  241. finally:
  242. await db_manager.disconnect()
  243. logger.info("✅ 服务已关闭")
  244. def setup_logging():
  245. """配置日志:正常日志输出到 send_sms_gn.log,错误日志输出到 send_sms_gn_error.log"""
  246. log_dir = Path(__file__).parent
  247. log_file = log_dir / "send_sms_gn.log"
  248. error_log_file = log_dir / "send_sms_gn_error.log"
  249. # 创建格式化器
  250. formatter = logging.Formatter(
  251. '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
  252. datefmt='%Y-%m-%d %H:%M:%S'
  253. )
  254. # 配置根日志记录器
  255. root_logger = logging.getLogger()
  256. root_logger.setLevel(logging.INFO)
  257. # 清除已有的处理器
  258. root_logger.handlers.clear()
  259. # 1. 控制台处理器(INFO及以上)
  260. console_handler = logging.StreamHandler(sys.stdout)
  261. console_handler.setLevel(logging.INFO)
  262. console_handler.setFormatter(formatter)
  263. root_logger.addHandler(console_handler)
  264. # 2. 文件处理器(所有日志,INFO及以上)
  265. file_handler = logging.FileHandler(log_file, encoding='utf-8')
  266. file_handler.setLevel(logging.INFO)
  267. file_handler.setFormatter(formatter)
  268. root_logger.addHandler(file_handler)
  269. # 3. 错误日志文件处理器(ERROR及以上)
  270. error_file_handler = logging.FileHandler(error_log_file, encoding='utf-8')
  271. error_file_handler.setLevel(logging.ERROR)
  272. error_file_handler.setFormatter(formatter)
  273. root_logger.addHandler(error_file_handler)
  274. logger.info(f"✅ 日志配置完成:正常日志 -> {log_file}, 错误日志 -> {error_log_file}")
  275. if __name__ == "__main__":
  276. setup_logging()
  277. asyncio.run(main_loop())