send_new_user_email.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. """新用户注册邮件通知服务
  2. 每 N 分钟扫描 user 表,将本周期内新注册用户信息汇总后通过邮件发送给管理员。
  3. Prerequisites:
  4. - Python >= 3.7
  5. - 配置文件:send_new_user.conf(与脚本同目录)
  6. - [smtp] 发件配置
  7. - [database] 数据库配置
  8. - [admin] admin_emails 收件人,多个逗号分隔
  9. 运行: python send_new_user_email.py
  10. """
  11. import asyncio
  12. import configparser
  13. import logging
  14. import os
  15. import re
  16. import smtplib
  17. import sys
  18. from datetime import datetime, timedelta
  19. from pathlib import Path
  20. from typing import Optional
  21. from email.header import Header
  22. from email.mime.multipart import MIMEMultipart
  23. from email.mime.text import MIMEText
  24. from email.utils import formataddr
  25. from sqlalchemy import select
  26. config_file = Path(__file__).parent / "send_new_user.conf"
  27. config = configparser.ConfigParser()
  28. if config_file.exists():
  29. config.read(config_file, encoding="utf-8")
  30. else:
  31. raise FileNotFoundError(f"配置文件不存在: {config_file}")
  32. backend_dir = Path(__file__).parent.parent / "backend"
  33. sys.path.insert(0, str(backend_dir))
  34. from database import DatabaseManager
  35. from models_db import User
  36. logger = logging.getLogger(__name__)
  37. # ------- 从配置文件读取参数 -------
  38. SMTP_HOST = config.get("smtp", "smtp_host", fallback="smtpdm.aliyun.com")
  39. SMTP_PORT_SSL = int(config.get("smtp", "smtp_port_ssl", fallback="465"))
  40. SMTP_USERNAME = config.get("smtp", "smtp_username", fallback="").strip()
  41. SMTP_PASSWORD = config.get("smtp", "smtp_password", fallback="").strip()
  42. EMAIL_SUBJECT = config.get("smtp", "email_subject", fallback="Lyrria 新用户注册通知")
  43. MYSQL_HOST = config.get("database", "mysql_host", fallback="").strip()
  44. MYSQL_PORT = config.get("database", "mysql_port", fallback="").strip()
  45. MYSQL_USER = config.get("database", "mysql_user", fallback="").strip()
  46. MYSQL_PASSWORD = config.get("database", "mysql_password", fallback="").strip()
  47. MYSQL_DATABASE = config.get("database", "mysql_database", fallback="").strip()
  48. POLL_INTERVAL = float(config.get("service", "poll_interval", fallback="600"))
  49. ADMIN_EMAILS_RAW = config.get("admin", "admin_emails", fallback="").strip()
  50. ADMIN_EMAILS = [e.strip() for e in ADMIN_EMAILS_RAW.split(",") if e.strip()]
  51. def build_message(
  52. sender: str,
  53. recipients: list[str],
  54. subject: str,
  55. body_text: str,
  56. ) -> MIMEMultipart:
  57. """构建纯文本邮件消息"""
  58. msg = MIMEMultipart("alternative")
  59. msg["Subject"] = Header(subject, "utf-8")
  60. msg["From"] = formataddr(["Lyrria 管理员通知", sender])
  61. msg["To"] = ",".join(recipients)
  62. msg.attach(MIMEText(body_text, "plain", "utf-8"))
  63. return msg
  64. def format_user_line(idx: int, u: User) -> str:
  65. """将单条用户格式化为一行中文说明"""
  66. created = u.created_at.strftime("%Y-%m-%d %H:%M:%S") if u.created_at else "-"
  67. reg = u.registered_at.strftime("%Y-%m-%d %H:%M:%S") if u.registered_at else "-"
  68. parts = [
  69. f"用户{idx}:",
  70. f"昵称 {u.username or '-'}",
  71. f"用户ID {u.user_id}",
  72. f"类型 {u.user_type or 'guest'}",
  73. f"邮箱 {u.email or '-'}",
  74. f"手机 {u.phone or '-'}",
  75. f"区号 {u.area_code or '-'}",
  76. f"注册时间 {reg}",
  77. f"创建时间 {created}",
  78. ]
  79. return ",".join(parts)
  80. def build_body(users: list[User], period_start: datetime, period_end: datetime) -> str:
  81. """组合新用户信息为邮件正文(汉字)"""
  82. start_str = period_start.strftime("%Y-%m-%d %H:%M:%S")
  83. end_str = period_end.strftime("%Y-%m-%d %H:%M:%S")
  84. lines = [
  85. "【Lyrria 新用户注册通知】",
  86. "",
  87. f"统计周期:{start_str} 至 {end_str}",
  88. f"本周期新注册用户数:{len(users)}",
  89. "",
  90. ]
  91. if not users:
  92. lines.append("本周期暂无新用户。")
  93. return "\n".join(lines)
  94. for i, u in enumerate(users, 1):
  95. lines.append(format_user_line(i, u))
  96. lines.append("")
  97. lines.append("本邮件由系统自动发送,请勿直接回复。")
  98. return "\n".join(lines)
  99. def send_email_sync(
  100. recipients: list[str],
  101. subject: str,
  102. body: str,
  103. smtp_username: str,
  104. smtp_password: str,
  105. ) -> tuple[bool, Optional[str]]:
  106. """同步发送邮件。返回 (成功与否, 错误信息)。"""
  107. if not recipients:
  108. return False, "收件人列表为空"
  109. if not smtp_username or not smtp_password:
  110. return False, "SMTP 未配置 smtp_username / smtp_password"
  111. try:
  112. msg = build_message(smtp_username, recipients, subject, body)
  113. with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT_SSL) as client:
  114. client.login(smtp_username, smtp_password)
  115. client.sendmail(smtp_username, recipients, msg.as_string())
  116. logger.info("✅ 新用户通知邮件已发送,收件人: %s", recipients)
  117. return True, None
  118. except smtplib.SMTPAuthenticationError as e:
  119. err = f"SMTP 认证失败: {e}"
  120. logger.error("❌ %s", err)
  121. return False, err
  122. except smtplib.SMTPException as e:
  123. err = f"SMTP 错误: {e}"
  124. logger.error("❌ %s", err)
  125. return False, err
  126. except Exception as e:
  127. err = f"发送异常: {e}"
  128. logger.error("❌ %s", err)
  129. return False, err
  130. async def fetch_new_users(
  131. db_manager: DatabaseManager,
  132. period_start: datetime,
  133. period_end: datetime,
  134. ) -> list[User]:
  135. """查询 (period_start, period_end] 内创建的非 guest 用户"""
  136. async with db_manager.get_session() as session:
  137. result = await session.execute(
  138. select(User)
  139. .where(
  140. User.created_at > period_start,
  141. User.created_at <= period_end,
  142. )
  143. .where(User.user_type != "guest")
  144. .order_by(User.created_at.asc())
  145. )
  146. return list(result.scalars().all())
  147. def _parse_last_scan_end() -> Optional[datetime]:
  148. """从配置文件读取并解析 last_scan_end,无效或为空返回 None"""
  149. raw = config.get("service", "last_scan_end", fallback="").strip()
  150. if not raw:
  151. return None
  152. try:
  153. return datetime.fromisoformat(raw.replace("Z", "+00:00"))
  154. except (ValueError, TypeError):
  155. return None
  156. def _write_last_scan_end(period_end: datetime) -> None:
  157. """将本次扫描结束时间写回配置文件(仅修改 last_scan_end 行,保留注释)"""
  158. try:
  159. value = period_end.isoformat()
  160. path = Path(config_file)
  161. text = path.read_text(encoding="utf-8")
  162. new_text = re.sub(
  163. r"^(\s*last_scan_end\s*=\s*).*$",
  164. rf"\g<1>{value}",
  165. text,
  166. count=1,
  167. flags=re.MULTILINE,
  168. )
  169. if new_text != text:
  170. path.write_text(new_text, encoding="utf-8")
  171. logger.debug("已更新 last_scan_end = %s", value)
  172. except Exception as e:
  173. logger.warning("写回 last_scan_end 失败: %s", e)
  174. async def run_once(db_manager: DatabaseManager) -> None:
  175. """执行一次:按 last_scan_end 确定扫描范围,查新用户并发邮件,成功后写回 last_scan_end"""
  176. if not ADMIN_EMAILS:
  177. logger.warning("⚠️ 未配置 admin_emails,跳过发送")
  178. return
  179. config.read(config_file, encoding="utf-8")
  180. period_end = datetime.now()
  181. last_end = _parse_last_scan_end()
  182. if last_end is not None:
  183. period_start = last_end
  184. logger.info("📅 从上次扫描结束时间继续: %s", last_end.strftime("%Y-%m-%d %H:%M:%S"))
  185. else:
  186. period_start = period_end - timedelta(seconds=POLL_INTERVAL)
  187. users = await fetch_new_users(db_manager, period_start, period_end)
  188. if not users:
  189. _write_last_scan_end(period_end)
  190. logger.debug("本周期无新用户,未发邮件,已更新 last_scan_end")
  191. return
  192. body = build_body(users, period_start, period_end)
  193. logger.info("📋 本周期新用户数: %d,正在发送通知邮件", len(users))
  194. ok, _ = await asyncio.to_thread(
  195. send_email_sync,
  196. ADMIN_EMAILS,
  197. EMAIL_SUBJECT,
  198. body,
  199. SMTP_USERNAME,
  200. SMTP_PASSWORD,
  201. )
  202. if ok:
  203. _write_last_scan_end(period_end)
  204. async def main_loop() -> None:
  205. """主循环:每 POLL_INTERVAL 秒扫描并发邮件"""
  206. if MYSQL_HOST:
  207. os.environ["MYSQL_HOST"] = MYSQL_HOST
  208. if MYSQL_PORT:
  209. os.environ["MYSQL_PORT"] = MYSQL_PORT
  210. if MYSQL_USER:
  211. os.environ["MYSQL_USER"] = MYSQL_USER
  212. if MYSQL_PASSWORD:
  213. os.environ["MYSQL_PASSWORD"] = MYSQL_PASSWORD
  214. if MYSQL_DATABASE:
  215. os.environ["MYSQL_DATABASE"] = MYSQL_DATABASE
  216. db_manager = DatabaseManager()
  217. try:
  218. await db_manager.connect()
  219. logger.info("✅ 数据库连接成功")
  220. logger.info("🚀 新用户通知服务已启动,扫描间隔: %s 秒", POLL_INTERVAL)
  221. while True:
  222. try:
  223. await run_once(db_manager)
  224. except Exception as e:
  225. logger.error("❌ 本周期处理异常: %s", e, exc_info=True)
  226. await asyncio.sleep(POLL_INTERVAL)
  227. except KeyboardInterrupt:
  228. logger.info("🛑 收到中断信号,正在关闭...")
  229. except Exception as e:
  230. logger.error("❌ 服务异常: %s", e, exc_info=True)
  231. sys.exit(1)
  232. finally:
  233. await db_manager.disconnect()
  234. logger.info("✅ 服务已关闭")
  235. def setup_logging() -> None:
  236. """日志:正常 -> send_new_user_email.log,错误 -> send_new_user_email_error.log"""
  237. log_dir = Path(__file__).parent
  238. log_file = log_dir / "send_new_user_email.log"
  239. error_log_file = log_dir / "send_new_user_email_error.log"
  240. formatter = logging.Formatter(
  241. "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
  242. datefmt="%Y-%m-%d %H:%M:%S",
  243. )
  244. root = logging.getLogger()
  245. root.setLevel(logging.INFO)
  246. root.handlers.clear()
  247. console = logging.StreamHandler(sys.stdout)
  248. console.setLevel(logging.INFO)
  249. console.setFormatter(formatter)
  250. root.addHandler(console)
  251. fh = logging.FileHandler(log_file, encoding="utf-8")
  252. fh.setLevel(logging.INFO)
  253. fh.setFormatter(formatter)
  254. root.addHandler(fh)
  255. efh = logging.FileHandler(error_log_file, encoding="utf-8")
  256. efh.setLevel(logging.ERROR)
  257. efh.setFormatter(formatter)
  258. root.addHandler(efh)
  259. logger.info("✅ 日志已配置: %s, %s", log_file, error_log_file)
  260. if __name__ == "__main__":
  261. setup_logging()
  262. asyncio.run(main_loop())