send_international_sms.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. """
  4. 国际短信发送服务(准实时)
  5. 说明:
  6. - 从数据库扫描待发送的验证码(area_code != +86),使用阿里云国际短信 API 发送。
  7. - 使用同步 SDK 调用并通过 asyncio.to_thread 包装为异步以不阻塞事件循环。
  8. - 配置文件:send.conf(与脚本同目录),缺失配置会尝试从环境变量读取。
  9. 运行: python send_international_sms.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 sqlalchemy import select, update, or_, and_
  20. try:
  21. from alibabacloud_dysmsapi20180501.client import Client as Dysmsapi20180501Client
  22. from alibabacloud_dysmsapi20180501 import models as dysms_models
  23. from alibabacloud_tea_openapi import models as open_api_models
  24. from alibabacloud_tea_util import models as util_models
  25. except Exception:
  26. print("❌ 缺少依赖包,请先安装:")
  27. print(" pip install alibabacloud-dysmsapi20180501 alibabacloud_tea_openapi alibabacloud_tea_util")
  28. sys.exit(1)
  29. # 加载配置文件
  30. config_file = Path(__file__).parent / "send.conf"
  31. config = configparser.ConfigParser()
  32. if config_file.exists():
  33. config.read(config_file, encoding='utf-8')
  34. else:
  35. raise FileNotFoundError(f"配置文件不存在: {config_file}")
  36. # 导入数据库相关模块(需要从 backend 目录导入)
  37. backend_dir = Path(__file__).parent.parent / "backend"
  38. sys.path.insert(0, str(backend_dir))
  39. from database import DatabaseManager
  40. from models_db import VerificationCode
  41. logger = logging.getLogger(__name__)
  42. # ------- 从配置文件读取参数 -------
  43. # 阿里云短信配置
  44. ACCESS_KEY_ID = config.get("aliyun_sms", "access_key_id", fallback="").strip()
  45. ACCESS_KEY_SECRET = config.get("aliyun_sms", "access_key_secret", fallback="").strip()
  46. SENDER_ID = config.get("aliyun_sms", "sender_id", fallback="Lyrria")
  47. TEMPLATE_MESSAGE = config.get(
  48. "aliyun_sms", "template_message",
  49. fallback="[Lyrria] Your verification code is {code}. It expires in 5 minutes. Do not share this code with anyone."
  50. )
  51. ENDPOINT = config.get("aliyun_sms", "endpoint", fallback="dysmsapi.ap-southeast-1.aliyuncs.com")
  52. # 数据库配置
  53. MYSQL_HOST = config.get("database", "mysql_host", fallback="").strip()
  54. MYSQL_PORT = config.get("database", "mysql_port", fallback="").strip()
  55. MYSQL_USER = config.get("database", "mysql_user", fallback="").strip()
  56. MYSQL_PASSWORD = config.get("database", "mysql_password", fallback="").strip()
  57. MYSQL_DATABASE = config.get("database", "mysql_database", fallback="").strip()
  58. # 服务配置
  59. POLL_INTERVAL = float(config.get("service", "poll_interval", fallback="2.0"))
  60. SMS_TYPE = config.get("aliyun_sms", "sms_type", fallback="OTP") # OTP/NOTIFY/MKT
  61. def create_client() -> Dysmsapi20180501Client:
  62. """初始化阿里云国际短信客户端(从配置文件或环境读取 AccessKey)"""
  63. if not ACCESS_KEY_ID or not ACCESS_KEY_SECRET:
  64. raise RuntimeError(
  65. "Missing AccessKey. Please set access_key_id and access_key_secret in send.conf or environment."
  66. )
  67. client_config = open_api_models.Config(
  68. access_key_id=ACCESS_KEY_ID,
  69. access_key_secret=ACCESS_KEY_SECRET,
  70. # endpoint for international API is region-specific; keep as configured
  71. )
  72. client_config.endpoint = ENDPOINT
  73. return Dysmsapi20180501Client(client_config)
  74. async def send_sms_async(
  75. client: Dysmsapi20180501Client,
  76. to: str,
  77. message: str,
  78. sender_id: str,
  79. sms_type: str = "OTP",
  80. ) -> Tuple[bool, Optional[str]]:
  81. """
  82. 发送国际短信(异步包装同步 SDK)
  83. Returns: (success, error_message)
  84. """
  85. request = dysms_models.SendMessageToGlobeRequest()
  86. # 阿里云国际接口要求不带前导 '+',去掉可能的 '+' 和空白
  87. normalized_to = (to or "").lstrip("+").strip()
  88. request.to = normalized_to
  89. request.from_ = sender_id
  90. request.message = message
  91. request.type = sms_type
  92. try:
  93. resp = await asyncio.to_thread(
  94. client.send_message_to_globe_with_options,
  95. request,
  96. util_models.RuntimeOptions()
  97. )
  98. body = getattr(resp, "body", None)
  99. if body and getattr(body, "response_code", None) == "OK":
  100. logger.info(f"✅ 国际短信发送成功: to={to}, message_id={getattr(body, 'message_id', '')}")
  101. return True, None
  102. else:
  103. error_desc = getattr(body, "response_description", "") if body else "No response body"
  104. error_code = getattr(body, "response_code", "") if body else ""
  105. error_msg = f"Code: {error_code}, Desc: {error_desc}"
  106. logger.error(f"❌ 国际短信发送失败: to={to}, {error_msg}")
  107. return False, error_msg
  108. except Exception as e:
  109. error_msg = f"Unexpected error: {e}"
  110. logger.error(f"❌ 国际短信发送异常: to={to}, {error_msg}", exc_info=True)
  111. return False, error_msg
  112. async def process_international_sms_codes(client: Dysmsapi20180501Client, db_manager: DatabaseManager):
  113. """扫描并发送国际短信验证码(area_code != +86)"""
  114. async with db_manager.get_session() as session:
  115. result = await session.execute(
  116. select(VerificationCode)
  117. .where(
  118. or_(
  119. VerificationCode.send_type == "international_sms",
  120. and_(
  121. VerificationCode.send_type == "domestic_sms",
  122. VerificationCode.area_code != "+86",
  123. )
  124. ),
  125. VerificationCode.is_sent == False,
  126. VerificationCode.is_valid == True,
  127. VerificationCode.phone.isnot(None)
  128. )
  129. .order_by(VerificationCode.created_at.asc())
  130. .limit(50)
  131. )
  132. codes = result.scalars().all()
  133. if not codes:
  134. return 0, 0
  135. logger.info(f"📋 找到 {len(codes)} 条待发送的国际短信验证码")
  136. success_count = 0
  137. fail_count = 0
  138. for code_record in codes:
  139. try:
  140. area_code = (code_record.area_code or "").strip()
  141. phone = (code_record.phone or "").strip()
  142. code_value = code_record.code
  143. record_id = code_record.id
  144. if not phone:
  145. raise ValueError("empty phone")
  146. if area_code.startswith("+"):
  147. to = f"{area_code}{phone}"
  148. elif area_code:
  149. to = f"+{area_code}{phone}"
  150. else:
  151. to = phone
  152. message = TEMPLATE_MESSAGE.format(code=code_value)
  153. success, error_msg = await send_sms_async(
  154. client,
  155. to,
  156. message,
  157. SENDER_ID,
  158. sms_type=SMS_TYPE
  159. )
  160. now = datetime.now()
  161. if success:
  162. await session.execute(
  163. update(VerificationCode)
  164. .where(VerificationCode.id == record_id)
  165. .values(
  166. is_sent=True,
  167. sent_at=now,
  168. metadata_json={
  169. **(code_record.metadata_json or {}),
  170. "send_status": "success",
  171. "send_at": now.isoformat(),
  172. },
  173. )
  174. )
  175. success_count += 1
  176. else:
  177. await session.execute(
  178. update(VerificationCode)
  179. .where(VerificationCode.id == record_id)
  180. .values(
  181. is_valid=False,
  182. metadata_json={
  183. **(code_record.metadata_json or {}),
  184. "send_status": "failed",
  185. "error": error_msg,
  186. "send_at": now.isoformat(),
  187. },
  188. )
  189. )
  190. fail_count += 1
  191. await session.commit()
  192. except Exception as e:
  193. logger.error(f"❌ 处理国际验证码失败: id={getattr(code_record, 'id', 'n/a')}, error={e}", exc_info=True)
  194. try:
  195. now = datetime.now()
  196. await session.execute(
  197. update(VerificationCode)
  198. .where(VerificationCode.id == getattr(code_record, "id", None))
  199. .values(
  200. is_valid=False,
  201. metadata_json={
  202. **(getattr(code_record, "metadata_json", {}) or {}),
  203. "send_status": "error",
  204. "error": str(e),
  205. "send_at": now.isoformat(),
  206. },
  207. )
  208. )
  209. await session.commit()
  210. except:
  211. pass
  212. fail_count += 1
  213. if success_count > 0 or fail_count > 0:
  214. logger.info(f"📊 处理完成: 成功 {success_count} 条, 失败 {fail_count} 条")
  215. return success_count, fail_count
  216. async def main_loop():
  217. """主循环:准实时轮询并发送国际验证码"""
  218. # 设置数据库配置到环境变量(DatabaseManager 从环境变量读取)
  219. if MYSQL_HOST:
  220. os.environ["MYSQL_HOST"] = MYSQL_HOST
  221. if MYSQL_PORT:
  222. os.environ["MYSQL_PORT"] = MYSQL_PORT
  223. if MYSQL_USER:
  224. os.environ["MYSQL_USER"] = MYSQL_USER
  225. if MYSQL_PASSWORD:
  226. os.environ["MYSQL_PASSWORD"] = MYSQL_PASSWORD
  227. if MYSQL_DATABASE:
  228. os.environ["MYSQL_DATABASE"] = MYSQL_DATABASE
  229. db_manager = DatabaseManager()
  230. client = None
  231. try:
  232. # 初始化数据库连接
  233. await db_manager.connect()
  234. logger.info("✅ 数据库连接成功")
  235. # 初始化短信客户端
  236. client = create_client()
  237. logger.info("✅ 阿里云国际短信客户端初始化成功")
  238. logger.info(f"🚀 开始准实时发送国际短信服务(轮询间隔: {POLL_INTERVAL}秒)...")
  239. # 主循环:持续轮询
  240. while True:
  241. try:
  242. await process_international_sms_codes(client, db_manager)
  243. except Exception as e:
  244. logger.error(f"❌ 处理国际验证码时出错: {e}", exc_info=True)
  245. # 等待下次轮询
  246. await asyncio.sleep(POLL_INTERVAL)
  247. except KeyboardInterrupt:
  248. logger.info("🛑 收到中断信号,正在关闭...")
  249. except Exception as e:
  250. logger.error(f"❌ 服务异常: {e}", exc_info=True)
  251. sys.exit(1)
  252. finally:
  253. await db_manager.disconnect()
  254. logger.info("✅ 服务已关闭")
  255. def setup_logging():
  256. """配置日志:正常日志输出到 send_international_sms.log,错误日志输出到 send_international_sms_error.log"""
  257. log_dir = Path(__file__).parent
  258. log_file = log_dir / "send_international_sms.log"
  259. error_log_file = log_dir / "send_international_sms_error.log"
  260. formatter = logging.Formatter(
  261. '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
  262. datefmt='%Y-%m-%d %H:%M:%S'
  263. )
  264. root_logger = logging.getLogger()
  265. root_logger.setLevel(logging.INFO)
  266. root_logger.handlers.clear()
  267. console_handler = logging.StreamHandler(sys.stdout)
  268. console_handler.setLevel(logging.INFO)
  269. console_handler.setFormatter(formatter)
  270. root_logger.addHandler(console_handler)
  271. file_handler = logging.FileHandler(log_file, encoding='utf-8')
  272. file_handler.setLevel(logging.INFO)
  273. file_handler.setFormatter(formatter)
  274. root_logger.addHandler(file_handler)
  275. error_file_handler = logging.FileHandler(error_log_file, encoding='utf-8')
  276. error_file_handler.setLevel(logging.ERROR)
  277. error_file_handler.setFormatter(formatter)
  278. root_logger.addHandler(error_file_handler)
  279. logger.info(f"✅ 日志配置完成:正常日志 -> {log_file}, 错误日志 -> {error_log_file}")
  280. if __name__ == "__main__":
  281. setup_logging()
  282. asyncio.run(main_loop())