send_reset_password.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 找回密码工单处理脚本
  5. - 按 scan_interval_seconds 扫描 password_reset_requests 表
  6. - 仅处理 created_at > config 中 last_scan_time 的 pending 工单,处理完后将当前时间写回 config.ini
  7. - 为每条记录生成随机密码、更新 users 表、发送邮件、更新工单状态
  8. """
  9. import os
  10. import sys
  11. import time
  12. import random
  13. import string
  14. import configparser
  15. import smtplib
  16. from email.mime.text import MIMEText
  17. from email.mime.multipart import MIMEMultipart
  18. from email.header import Header
  19. from datetime import datetime, timezone, timedelta
  20. from pathlib import Path
  21. import bcrypt
  22. # 北京时间 UTC+8,与 backend-client 一致
  23. BEIJING_TZ = timezone(timedelta(hours=8))
  24. def beijing_now() -> datetime:
  25. """当前北京时间(naive)"""
  26. return datetime.now(BEIJING_TZ).replace(tzinfo=None)
  27. import pymysql
  28. # bcrypt 与 backend-client 保持一致:密码最长 72 字节
  29. BCRYPT_MAX_PASSWORD_BYTES = 72
  30. def load_config():
  31. """从 config.ini 加载配置,返回 (config, config 文件路径)"""
  32. conf_path = Path(__file__).resolve().parent / "config.ini"
  33. if not conf_path.exists():
  34. print(f"错误: 未找到配置文件 {conf_path},请复制 config.ini.example 为 config.ini 并填写", file=sys.stderr)
  35. sys.exit(1)
  36. config = configparser.ConfigParser()
  37. config.read(conf_path, encoding="utf-8")
  38. return config, conf_path
  39. def get_last_scan_time(config) -> datetime:
  40. """从配置读取上次扫描时间;为空或无效则返回很早的时间以处理全部 pending"""
  41. try:
  42. raw = config.get("script", "last_scan_time", fallback="").strip()
  43. if not raw:
  44. return datetime(2000, 1, 1)
  45. return datetime.strptime(raw, "%Y-%m-%d %H:%M:%S")
  46. except Exception:
  47. return datetime(2000, 1, 1)
  48. def save_last_scan_time(conf_path: Path, dt: datetime) -> None:
  49. """将上次扫描时间写回 config.ini(只改写 last_scan_time 行,保留其余内容)"""
  50. text = conf_path.read_text(encoding="utf-8")
  51. new_line = f"last_scan_time = {dt.strftime('%Y-%m-%d %H:%M:%S')}"
  52. lines = []
  53. for line in text.splitlines():
  54. if line.strip().startswith("last_scan_time"):
  55. lines.append(new_line)
  56. else:
  57. lines.append(line)
  58. conf_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
  59. def hash_password(password: str) -> str:
  60. """与 backend-client 一致的 bcrypt 哈希"""
  61. raw = password.encode("utf-8")
  62. if len(raw) > BCRYPT_MAX_PASSWORD_BYTES:
  63. raw = raw[:BCRYPT_MAX_PASSWORD_BYTES]
  64. return bcrypt.hashpw(raw, bcrypt.gensalt()).decode("utf-8")
  65. def generate_random_password(length: int = 12) -> str:
  66. """生成随机密码:字母+数字"""
  67. chars = string.ascii_letters + string.digits
  68. return "".join(random.choices(chars, k=length))
  69. def send_email(config, to_email: str, username: str, new_password: str) -> None:
  70. """使用 126 SMTP 发送新密码邮件"""
  71. sender = config.get("email", "sender")
  72. smtp_host = config.get("email", "smtp_host")
  73. smtp_port = config.getint("email", "smtp_port")
  74. # 126 要求:登录名须为完整邮箱,密码须为授权码(非登录密码)
  75. smtp_user = config.get("email", "smtp_user").strip()
  76. if "@" not in smtp_user:
  77. smtp_user = sender # 未填完整邮箱时用发件人地址
  78. smtp_password = config.get("email", "smtp_password")
  79. subject = "游铛铛 - 找回密码"
  80. body = f"""您好 {username},
  81. 您已成功找回密码。新密码如下,请登录后及时修改:
  82. 新密码:{new_password}
  83. 请妥善保管,勿泄露给他人。
  84. —— 游铛铛
  85. """
  86. msg = MIMEMultipart()
  87. msg["From"] = Header(sender, "utf-8")
  88. msg["To"] = Header(to_email, "utf-8")
  89. msg["Subject"] = Header(subject, "utf-8")
  90. msg.attach(MIMEText(body, "plain", "utf-8"))
  91. try:
  92. with smtplib.SMTP_SSL(smtp_host, smtp_port) as server:
  93. server.login(smtp_user, smtp_password)
  94. server.sendmail(sender, [to_email], msg.as_string())
  95. except smtplib.SMTPAuthenticationError as e:
  96. # 535 多为:用了登录密码而非授权码,或 smtp_user 未填完整邮箱
  97. err = f"{e}".replace("(535, b'Error: authentication failed')", "(535) SMTP 认证失败")
  98. raise RuntimeError(
  99. f"邮件发送认证失败: {err}\n"
  100. "126 邮箱请检查:1) smtp_user 填完整邮箱(如 xxx@126.com);2) smtp_password 填「授权码」而非登录密码(在 126 设置-POP3/SMTP/IMAP 中开启服务并生成授权码)。"
  101. ) from e
  102. def get_db_connection(config):
  103. """创建数据库连接"""
  104. return pymysql.connect(
  105. host=config.get("database", "host"),
  106. port=config.getint("database", "port"),
  107. user=config.get("database", "user"),
  108. password=config.get("database", "password"),
  109. database=config.get("database", "database"),
  110. charset=config.get("database", "charset"),
  111. cursorclass=pymysql.cursors.DictCursor,
  112. )
  113. def run_once(config, conf_path: Path) -> None:
  114. """执行一次扫描:仅处理 created_at > last_scan_time 的 pending 工单,处理完后更新 config 中的 last_scan_time"""
  115. last_scan = get_last_scan_time(config)
  116. now_dt = beijing_now()
  117. now_str = now_dt.strftime("%Y-%m-%d %H:%M:%S")
  118. last_scan_str = last_scan.strftime("%Y-%m-%d %H:%M:%S")
  119. conn = get_db_connection(config)
  120. try:
  121. with conn.cursor() as cursor:
  122. cursor.execute(
  123. "SELECT id, user_id, username, email FROM password_reset_requests "
  124. "WHERE status = %s AND created_at > %s ORDER BY id",
  125. ("pending", last_scan_str),
  126. )
  127. rows = cursor.fetchall()
  128. finally:
  129. conn.close()
  130. if not rows:
  131. save_last_scan_time(conf_path, beijing_now())
  132. return
  133. conn = get_db_connection(config)
  134. now = now_str
  135. for row in rows:
  136. req_id = row["id"]
  137. user_id = row["user_id"]
  138. username = row["username"]
  139. email = row["email"]
  140. new_password = generate_random_password(12)
  141. hashed = hash_password(new_password)
  142. try:
  143. # 标记为处理中
  144. with conn.cursor() as cursor:
  145. cursor.execute(
  146. "UPDATE password_reset_requests SET status = %s WHERE id = %s",
  147. ("processing", req_id),
  148. )
  149. conn.commit()
  150. # 先写历史表,再改/删:将当前用户行写入 users_his(操作类型 UPDATE),再更新密码
  151. with conn.cursor() as cursor:
  152. cursor.execute(
  153. "SELECT id, username, password, email, machine_code, card_id, expire_time, "
  154. "last_login_time, last_login_ip, status, is_deleted, created_at, updated_at "
  155. "FROM users WHERE id = %s",
  156. (user_id,),
  157. )
  158. user_row = cursor.fetchone()
  159. if user_row:
  160. cursor.execute(
  161. "INSERT INTO users_his (id, username, password, email, machine_code, card_id, "
  162. "expire_time, last_login_time, last_login_ip, status, is_deleted, created_at, "
  163. "updated_at, his_created_at, his_operation) "
  164. "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'UPDATE')",
  165. (
  166. user_row["id"],
  167. user_row["username"],
  168. user_row["password"],
  169. user_row["email"],
  170. user_row["machine_code"],
  171. user_row["card_id"],
  172. user_row["expire_time"],
  173. user_row["last_login_time"],
  174. user_row["last_login_ip"],
  175. user_row["status"],
  176. user_row["is_deleted"],
  177. user_row["created_at"],
  178. user_row["updated_at"],
  179. now,
  180. ),
  181. )
  182. conn.commit()
  183. # 更新用户密码
  184. with conn.cursor() as cursor:
  185. cursor.execute(
  186. "UPDATE users SET password = %s, updated_at = %s WHERE id = %s",
  187. (hashed, now, user_id),
  188. )
  189. conn.commit()
  190. # 发邮件
  191. send_email(config, email, username, new_password)
  192. # 标记为已发送
  193. with conn.cursor() as cursor:
  194. cursor.execute(
  195. "UPDATE password_reset_requests SET status = %s, processed_at = %s, error_message = NULL WHERE id = %s",
  196. ("sent", now, req_id),
  197. )
  198. conn.commit()
  199. print(f"[{now}] 工单 {req_id} 已处理并发送邮件至 {email}")
  200. except Exception as e:
  201. err_msg = str(e)[:500]
  202. try:
  203. with conn.cursor() as cursor:
  204. cursor.execute(
  205. "UPDATE password_reset_requests SET status = %s, error_message = %s, processed_at = %s WHERE id = %s",
  206. ("failed", err_msg, now, req_id),
  207. )
  208. conn.commit()
  209. except Exception:
  210. conn.rollback()
  211. print(f"[{now}] 工单 {req_id} 处理失败: {e}", file=sys.stderr)
  212. conn.close()
  213. # 本轮回笼结束,将当前时间写回 config.ini,下次只处理此时间之后创建的工单
  214. save_last_scan_time(conf_path, beijing_now())
  215. def main():
  216. config, conf_path = load_config()
  217. interval = config.getint("script", "scan_interval_seconds", fallback=60)
  218. print(f"找回密码脚本已启动,每 {interval} 秒扫描一次;上次扫描时间与本次处理时间范围从 config.ini 的 last_scan_time 读取/更新")
  219. while True:
  220. try:
  221. run_once(config, conf_path)
  222. # 每轮后重新加载 config,以便下次 run_once 读到最新的 last_scan_time(由 run_once 写回)
  223. config, conf_path = load_config()
  224. except Exception as e:
  225. print(f"[{beijing_now()}] 本轮扫描异常: {e}", file=sys.stderr)
  226. time.sleep(interval)
  227. if __name__ == "__main__":
  228. main()