From 523da50123b31bacee0fdcb3008a7497b250caed Mon Sep 17 00:00:00 2001 From: Cccc_ Date: Sat, 3 Jan 2026 17:02:28 +0800 Subject: [PATCH] feat: optimize Token expiration monitor --- backend/models/user.py | 2 + .../migrate_add_token_expiry_notified.py | 153 ++++++++++++++++++ backend/services/auth_service.py | 2 + backend/services/email_service.py | 113 ++++++++++++- backend/services/scheduler_service.py | 44 ++++- frontend/src/composables/useTokenMonitor.js | 29 +++- 6 files changed, 335 insertions(+), 8 deletions(-) create mode 100644 backend/scripts/migrate_add_token_expiry_notified.py diff --git a/backend/models/user.py b/backend/models/user.py index cad0007..614017b 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -16,6 +16,8 @@ class User(Base): password_hash = Column(String(200), nullable=True, comment="密码哈希(bcrypt加密)") authorization = Column(Text, nullable=True, comment="当前有效的 QQ Token") jwt_exp = Column(String(20), default="0", comment="Token 过期时间戳") + token_expiring_notified = Column(Boolean, default=False, nullable=False, comment="Token 即将过期提醒是否已发送(过期前30分钟)") + token_expired_notified = Column(Boolean, default=False, nullable=False, comment="Token 已过期提醒是否已发送(过期后30分钟内)") role = Column(String(20), default="user", index=True, comment="角色: user/admin") is_approved = Column(Boolean, default=False, index=True, comment="是否已通过管理员审批") created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间") diff --git a/backend/scripts/migrate_add_token_expiry_notified.py b/backend/scripts/migrate_add_token_expiry_notified.py new file mode 100644 index 0000000..c5dccb1 --- /dev/null +++ b/backend/scripts/migrate_add_token_expiry_notified.py @@ -0,0 +1,153 @@ +""" +为 users 表添加 Token 过期提醒字段的迁移脚本 + +新增的列: +- token_expiring_notified (BOOLEAN) - Token 即将过期提醒是否已发送(过期前30分钟内) +- token_expired_notified (BOOLEAN) - Token 已过期提醒是否已发送(过期后30分钟内) + +这两个字段用于实现两次 Token 过期提醒: +1. 过期前 30 分钟内:提醒用户 Token 即将过期 +2. 过期后 30 分钟内:提醒用户 Token 已过期,需要刷新 + +运行方式: + python backend/scripts/migrate_add_token_expiry_notified.py + 或 + venv/Scripts/python.exe backend/scripts/migrate_add_token_expiry_notified.py +""" + +import sys +import os +from pathlib import Path + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(project_root)) + +from sqlalchemy import text, inspect +from backend.models.database import engine + + +def migrate(): + """执行迁移:添加 token_expiring_notified 和 token_expired_notified 列""" + print("开始迁移:为 users 表添加 Token 过期提醒字段...") + print("=" * 60) + + with engine.connect() as conn: + # 检查表结构 + inspector = inspect(engine) + columns = [col['name'] for col in inspector.get_columns('users')] + + print(f"\n当前表列: {', '.join(columns)}") + + # 检查是否已存在新字段 + has_expiring = 'token_expiring_notified' in columns + has_expired = 'token_expired_notified' in columns + + if has_expiring and has_expired: + print("\n[OK] Token 过期提醒字段已存在,跳过迁移") + return + + print(f"\n需要添加的列:") + print(f" - token_expiring_notified (BOOLEAN, DEFAULT False)") + print(f" - token_expired_notified (BOOLEAN, DEFAULT False)") + + # SQLite 不支持直接 ALTER TABLE ADD COLUMN with constraints,需要重建表 + # 步骤: + # 1. 创建新表(包含两个新字段) + # 2. 复制数据 + # 3. 删除旧表 + # 4. 重命名新表 + + print("\n正在重建表结构...") + + # 1. 创建新表 + conn.execute(text(""" + CREATE TABLE users_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + jwt_sub VARCHAR(200) UNIQUE, + alias VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(100), + password_hash VARCHAR(200), + authorization TEXT, + jwt_exp VARCHAR(20) DEFAULT '0', + token_expiring_notified BOOLEAN NOT NULL DEFAULT 0, + token_expired_notified BOOLEAN NOT NULL DEFAULT 0, + role VARCHAR(20) DEFAULT 'user', + is_approved BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME + ) + """)) + print(" [OK] 创建新表结构") + + # 2. 复制数据(为旧数据设置两个字段都为 False) + conn.execute(text(""" + INSERT INTO users_new + (id, jwt_sub, alias, email, password_hash, authorization, jwt_exp, + token_expiring_notified, token_expired_notified, + role, is_approved, created_at, updated_at) + SELECT + id, jwt_sub, alias, email, password_hash, authorization, jwt_exp, + 0, 0, + role, is_approved, created_at, updated_at + FROM users + """)) + print(" [OK] 复制数据到新表(所有用户的提醒字段默认为 False)") + + # 3. 删除旧表 + conn.execute(text("DROP TABLE users")) + print(" [OK] 删除旧表") + + # 4. 重命名新表 + conn.execute(text("ALTER TABLE users_new RENAME TO users")) + print(" [OK] 重命名新表") + + # 5. 重建索引 + conn.execute(text(""" + CREATE INDEX ix_users_jwt_sub ON users(jwt_sub) + """)) + conn.execute(text(""" + CREATE INDEX ix_users_alias ON users(alias) + """)) + conn.execute(text(""" + CREATE INDEX ix_users_role ON users(role) + """)) + conn.execute(text(""" + CREATE INDEX ix_users_is_approved ON users(is_approved) + """)) + conn.execute(text(""" + CREATE INDEX ix_users_id ON users(id) + """)) + conn.execute(text(""" + CREATE INDEX ix_user_role_approved ON users(role, is_approved) + """)) + print(" [OK] 重建索引") + + conn.commit() + + print("\n[SUCCESS] 表结构迁移成功!") + print("\n新的表结构:") + inspector = inspect(engine) + new_columns = [col['name'] for col in inspector.get_columns('users')] + print(f" 列: {', '.join(new_columns)}") + + +if __name__ == "__main__": + try: + migrate() + print("\n" + "=" * 60) + print("[完成] 迁移成功完成!") + print("\n数据库已更新为新架构:") + print(" - 添加了 token_expiring_notified 列(Token 即将过期提醒,过期前30分钟)") + print(" - 添加了 token_expired_notified 列(Token 已过期提醒,过期后30分钟内)") + print(" - 所有现有用户的提醒字段默认为 False") + print("\n提醒逻辑:") + print(" 1. 过期前 30 分钟内:发送\"即将过期\"邮件,设置 token_expiring_notified = True") + print(" 2. 过期后 30 分钟内:发送\"已过期\"邮件,设置 token_expired_notified = True") + print(" 3. 用户刷新 Token 后:重置两个字段为 False") + print("=" * 60) + except Exception as e: + print(f"\n[ERROR] 迁移失败: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py index 7c51336..0a74cbc 100644 --- a/backend/services/auth_service.py +++ b/backend/services/auth_service.py @@ -200,6 +200,8 @@ class AuthService: user.authorization = pure_token # 存储清理后的 token user.jwt_exp = jwt_exp + user.token_expiring_notified = False # 重置"即将过期"提醒标志 + user.token_expired_notified = False # 重置"已过期"提醒标志 user.updated_at = datetime.now() db.commit() db.refresh(user) diff --git a/backend/services/email_service.py b/backend/services/email_service.py index c2b11f0..f80639d 100644 --- a/backend/services/email_service.py +++ b/backend/services/email_service.py @@ -511,7 +511,118 @@ class EmailService:

如何刷新凭证:

  1. 登录系统(扫码或密码登录)
  2. -
  3. 在个人设置中点击"刷新凭证"
  4. +
  5. 在个人设置旁的按钮中进行刷新 Token
  6. +
  7. 使用手机 QQ 扫描二维码完成刷新
  8. +
+ +

+ 立即登录刷新 +

+ + + + + + """ + + return EmailService.send_email([str(user_email)], subject, body_html) + + @staticmethod + def notify_token_expired(user: User) -> bool: + """ + 通知用户 Token 已过期 + + Args: + user: 用户对象 + + Returns: + 是否发送成功 + """ + user_email = user.email + if user_email is None: + logger.info(f"用户 {user.alias} 未设置邮箱,跳过 Token 已过期通知") + return False + + # 构建邮件内容 + subject = f"【接龙自动打卡系统】登录凭证已过期 - {user.alias}" + + body_html = f""" + + + + + + + +
+
+

❌ 登录凭证已过期

+
+
+

您好,{user.alias}!

+

您的 QQ 登录凭证已过期,系统已无法自动执行打卡任务。

+ +
+ ⚠️ 重要提示: +
    +
  • 登录凭证已过期,所有自动打卡任务已暂停
  • +
  • 请尽快登录系统刷新凭证以恢复服务
  • +
  • 如果您已设置密码,可以使用密码登录后扫码刷新凭证
  • +
+
+ +

如何刷新 Token:

+
    +
  1. 登录系统(扫码或密码登录)
  2. +
  3. 在个人设置旁的按钮中进行刷新 Token
  4. 使用手机 QQ 扫描二维码完成刷新
diff --git a/backend/services/scheduler_service.py b/backend/services/scheduler_service.py index 4657fe8..8a9a534 100644 --- a/backend/services/scheduler_service.py +++ b/backend/services/scheduler_service.py @@ -174,18 +174,52 @@ def check_token_expiration(): try: exp_timestamp = int(user.jwt_exp) - # 检查是否在 30 分钟内过期(0 < 剩余时间 < 1800秒) + # 检查 Token 状态并发送对应的提醒 time_until_expiry = exp_timestamp - current_timestamp + # 情况1:Token 即将过期(过期前 30 分钟内,且还未过期) if 0 < time_until_expiry < 1800: # 30分钟 = 1800秒 - # 使用用户账户的邮箱发送通知 - if user.email: + if user.email and not user.token_expiring_notified: logger.info(f"用户 {user.alias} 的 Token 即将过期,发送邮件提醒到 {user.email}...") from backend.services.email_service import EmailService jwt_exp_value = user.jwt_exp jwt_exp_str = str(jwt_exp_value) if jwt_exp_value is not None else "0" - EmailService.notify_token_expiring(user, jwt_exp_str) - notified_count += 1 + + # 发送"即将过期"邮件 + success = EmailService.notify_token_expiring(user, jwt_exp_str) + + if success: + user.token_expiring_notified = True + db.commit() + notified_count += 1 + logger.info(f"用户 {user.alias} 的 Token 即将过期邮件已发送并标记") + else: + logger.warning(f"用户 {user.alias} 的 Token 即将过期邮件发送失败") + + # 情况2:Token 已过期(过期后 30 分钟内) + elif -1800 < time_until_expiry <= 0: # 过期后 30 分钟内 + if user.email and not user.token_expired_notified: + logger.info(f"用户 {user.alias} 的 Token 已过期,发送邮件提醒到 {user.email}...") + from backend.services.email_service import EmailService + + # 发送"已过期"邮件(可以使用不同的邮件模板或内容) + success = EmailService.notify_token_expired(user) + + if success: + user.token_expired_notified = True + db.commit() + notified_count += 1 + logger.info(f"用户 {user.alias} 的 Token 已过期邮件已发送并标记") + else: + logger.warning(f"用户 {user.alias} 的 Token 已过期邮件发送失败") + + # 情况3:Token 正常(剩余时间 > 30 分钟),重置提醒标志 + elif time_until_expiry >= 1800: + if user.token_expiring_notified or user.token_expired_notified: + user.token_expiring_notified = False + user.token_expired_notified = False + db.commit() + logger.info(f"用户 {user.alias} 的 Token 已刷新,重置所有提醒标志") except ValueError: logger.warning(f"用户 {user.alias} 的 jwt_exp 格式不正确: {user.jwt_exp}") diff --git a/frontend/src/composables/useTokenMonitor.js b/frontend/src/composables/useTokenMonitor.js index bc5f695..f4b7055 100644 --- a/frontend/src/composables/useTokenMonitor.js +++ b/frontend/src/composables/useTokenMonitor.js @@ -20,6 +20,10 @@ let monitorTimer = null let warningShown = false let isMonitoring = false // 新增:防止重复启动 +// 检查间隔(毫秒) +const NORMAL_CHECK_INTERVAL = 15 * 60 * 1000 // 正常情况:15 分钟 +const URGENT_CHECK_INTERVAL = 5 * 60 * 1000 // Token 即将过期:5 分钟 + export function useTokenMonitor() { const authStore = useAuthStore() const userStore = useUserStore() @@ -99,11 +103,17 @@ export function useTokenMonitor() { }) warningShown = true } + + // Token 即将过期时,切换到更频繁的检查(5 分钟) + adjustCheckInterval(URGENT_CHECK_INTERVAL) } // Token 状态正常 else if (remainingMinutes !== null && remainingMinutes > 60) { // 重置警告标志 warningShown = false + + // 恢复正常检查频率(15 分钟) + adjustCheckInterval(NORMAL_CHECK_INTERVAL) } } catch (error) { @@ -111,6 +121,21 @@ export function useTokenMonitor() { } } + // 调整检查间隔 + const adjustCheckInterval = (newInterval) => { + if (monitorTimer) { + const currentInterval = monitorTimer._idleTimeout || 0 + + // 只有当新间隔与当前间隔不同时才重启定时器 + if (currentInterval !== newInterval) { + clearInterval(monitorTimer) + monitorTimer = setInterval(() => { + checkTokenStatus() + }, newInterval) + } + } + } + // 启动监控 const startMonitoring = () => { // 避免重复启动(单例模式) @@ -123,10 +148,10 @@ export function useTokenMonitor() { // 立即检查一次 checkTokenStatus() - // 每 2 分钟检查一次 + // 默认使用正常检查频率(15 分钟) monitorTimer = setInterval(() => { checkTokenStatus() - }, 2 * 60 * 1000) // 2 分钟 + }, NORMAL_CHECK_INTERVAL) } // 停止监控