send_feedback_email.py 12 KB

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