Files
outlookEmailPlus/outlook_web/services/scheduler.py
T
胡绍康andCopilot 4adb255e87 fix: telegram push Graph API proxy support, client_id SELECT, and frontend null checks
- Fix _fetch_new_emails_graph to use get_access_token_graph (not get_access_token)
- Add client_id to get_telegram_push_accounts() SELECT
- JOIN groups table to get proxy_url per account
- Pass proxy_url to get_access_token_graph() and requests.get()
- Add null checks for Telegram DOM elements in loadSettings/saveSettings
- Add job start/end logging to run_telegram_push_job
- Add scheduler registration log message

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-05 16:16:49 +08:00

434 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
定时任务调度器服务
功能:
- 初始化调度器
- 配置定时任务
- 心跳任务
- 定时刷新任务
"""
from __future__ import annotations
import json
import os
import time
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from outlook_web import config
from outlook_web.db import create_sqlite_connection
from outlook_web.errors import generate_trace_id
from outlook_web.repositories import settings as settings_repo
from outlook_web.repositories.distributed_locks import (
acquire_distributed_lock,
release_distributed_lock,
)
from outlook_web.repositories.refresh_runs import create_refresh_run, finish_refresh_run
from outlook_web.security.crypto import decrypt_data
# 调度器实例
_scheduler_instance = None
# 刷新锁名称
REFRESH_LOCK_NAME = "refresh_all_tokens"
def utcnow() -> datetime:
"""返回 naive UTC 时间(等价于旧的 datetime.utcnow())"""
return datetime.now(timezone.utc).replace(tzinfo=None)
def scheduler_heartbeat_task():
"""调度器心跳,用于验证后台任务是否真实运行"""
try:
payload = {"at": utcnow().isoformat() + "Z", "pid": os.getpid()}
conn = create_sqlite_connection()
try:
conn.execute(
"""
INSERT OR REPLACE INTO settings (key, value, updated_at)
VALUES ('scheduler_heartbeat', ?, CURRENT_TIMESTAMP)
""",
(json.dumps(payload, ensure_ascii=False),),
)
conn.commit()
finally:
conn.close()
except Exception:
pass
def _get_telegram_interval(app) -> int:
"""读取 telegram_poll_interval 设置,默认 600 秒,最小 60 秒。"""
try:
with app.app_context():
val = settings_repo.get_setting("telegram_poll_interval", "600")
return max(60, int(val or "600"))
except Exception:
return 600
def _configure_telegram_push_job(scheduler, app) -> None:
"""注册/更新 Telegram 推送 Job。"""
from outlook_web.services.telegram_push import run_telegram_push_job
try:
scheduler.remove_job("telegram_push_job")
except Exception:
pass
interval = _get_telegram_interval(app)
scheduler.add_job(
func=run_telegram_push_job,
args=[app],
trigger="interval",
seconds=interval,
id="telegram_push_job",
name="Telegram 推送",
replace_existing=True,
max_instances=1,
coalesce=True,
misfire_grace_time=interval,
)
print(f"✓ Telegram 推送 Job 已配置(轮询间隔:{interval} 秒)")
def configure_scheduler_jobs(scheduler, app, test_refresh_token) -> None:
"""根据当前 settings 重新配置定时刷新 Job(配置变更即时生效)"""
try:
from apscheduler.triggers.cron import CronTrigger
except Exception:
return
with app.app_context():
enable_scheduled = settings_repo.get_setting("enable_scheduled_refresh", "true").lower() == "true"
use_cron = settings_repo.get_setting("use_cron_schedule", "false").lower() == "true"
refresh_interval_days = int(settings_repo.get_setting("refresh_interval_days", "30"))
cron_expr = settings_repo.get_setting("refresh_cron", "0 2 * * *")
# 心跳 Job:始终存在(可服务/可刷新可验证)
try:
scheduler.remove_job("scheduler_heartbeat")
except Exception:
pass
scheduler.add_job(
func=scheduler_heartbeat_task,
trigger="interval",
seconds=60,
id="scheduler_heartbeat",
name="Scheduler Heartbeat",
replace_existing=True,
max_instances=1,
coalesce=True,
misfire_grace_time=60,
)
# Telegram 推送 Job
_configure_telegram_push_job(scheduler, app)
# 刷新 Job:根据 enable_scheduled 决定是否启用
try:
scheduler.remove_job("token_refresh")
except Exception:
pass
if not enable_scheduled:
print("✓ 定时刷新已禁用(调度器仍运行心跳)")
return
# 创建一个包含 app 和 test_refresh_token 的闭包
def scheduled_task():
scheduled_refresh_task(app, test_refresh_token)
if use_cron:
try:
from croniter import croniter
croniter(cron_expr, datetime.now())
parts = cron_expr.split()
if len(parts) != 5:
raise ValueError("Cron 表达式格式错误,应为 5 段")
minute, hour, day, month, day_of_week = parts
trigger = CronTrigger(minute=minute, hour=hour, day=day, month=month, day_of_week=day_of_week)
scheduler.add_job(
func=scheduled_task,
trigger=trigger,
id="token_refresh",
name="Token 定时刷新",
replace_existing=True,
max_instances=1,
coalesce=True,
misfire_grace_time=600,
)
print(f"✓ 定时任务已配置:Cron 表达式 '{cron_expr}'")
return
except Exception as e:
print(f"⚠ Cron 配置无效:{str(e)},回退到默认配置")
scheduler.add_job(
func=scheduled_task,
trigger=CronTrigger(hour=2, minute=0),
id="token_refresh",
name="Token 定时刷新",
replace_existing=True,
max_instances=1,
coalesce=True,
misfire_grace_time=600,
)
print(f"✓ 定时任务已配置:每天凌晨 2:00 检查刷新(周期:{refresh_interval_days} 天)")
def init_scheduler(app, test_refresh_token):
"""初始化定时任务调度器"""
global _scheduler_instance
if _scheduler_instance is not None:
return _scheduler_instance
try:
import atexit
from apscheduler.schedulers.background import BackgroundScheduler
scheduler = BackgroundScheduler()
configure_scheduler_jobs(scheduler, app, test_refresh_token)
atexit.register(lambda: scheduler.shutdown())
scheduler.start()
_scheduler_instance = scheduler
print("✓ 调度器已启动")
return scheduler
except ImportError:
print("⚠ APScheduler 未安装,定时任务功能不可用")
print(" 安装命令:pip install APScheduler>=3.10.0")
return None
except Exception as e:
print(f"⚠ 定时任务初始化失败:{str(e)}")
return None
def scheduled_refresh_task(app, test_refresh_token):
"""定时刷新任务(由调度器调用)"""
trace_id = generate_trace_id()
run_id = None
lock_owner_id = uuid.uuid4().hex
lock_acquired = False
conn = create_sqlite_connection()
try:
with app.app_context():
enable_scheduled = settings_repo.get_setting("enable_scheduled_refresh", "true").lower() == "true"
use_cron = settings_repo.get_setting("use_cron_schedule", "false").lower() == "true"
refresh_interval_days = int(settings_repo.get_setting("refresh_interval_days", "30"))
delay_seconds = int(settings_repo.get_setting("refresh_delay_seconds", "5"))
run_id = create_refresh_run(conn, "scheduled", trace_id, total=0)
if not enable_scheduled:
finish_refresh_run(conn, run_id, "skipped", 0, 0, 0, "定时刷新已禁用")
return
# 按天数策略:未到周期则跳过(不产生账号级刷新日志)
if not use_cron:
row = conn.execute(
"""
SELECT finished_at
FROM refresh_runs
WHERE trigger_source = 'scheduled'
AND status IN ('completed', 'failed')
AND finished_at IS NOT NULL
ORDER BY finished_at DESC
LIMIT 1
"""
).fetchone()
if row and row["finished_at"]:
try:
last_time = datetime.fromisoformat(row["finished_at"])
except Exception:
last_time = None
if last_time:
next_due = last_time + timedelta(days=refresh_interval_days)
if utcnow() < next_due:
finish_refresh_run(
conn,
run_id,
"skipped",
0,
0,
0,
f"距离上次刷新未满 {refresh_interval_days} 天,下次最早:{next_due.strftime('%Y-%m-%d %H:%M:%S')}",
)
return
# 获取刷新间隔配置
delay_row = conn.execute("SELECT value FROM settings WHERE key = 'refresh_delay_seconds'").fetchone()
delay_seconds = int(delay_row["value"]) if delay_row else 5
# 清理超过半年的刷新记录
try:
conn.execute("DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')")
conn.execute("DELETE FROM distributed_locks WHERE expires_at < ?", (time.time(),))
conn.commit()
except Exception:
pass
# PRD-00005 / TDD-00005:定时刷新只处理 Outlook 账号(IMAP 账号无 OAuth token 刷新语义)
accounts = conn.execute(
"""
SELECT id, email, client_id, refresh_token, group_id
FROM accounts
WHERE status = 'active'
AND (account_type = 'outlook' OR account_type IS NULL)
"""
).fetchall()
total = len(accounts)
# 更新 run_id 的 total
conn.execute("UPDATE refresh_runs SET total = ? WHERE id = ?", (total, run_id))
conn.commit()
# 计算锁 TTL
estimated = int(total * (max(delay_seconds, 0) + 2) + 600)
ttl_seconds = max(60 * 60 * 2, estimated)
ttl_seconds = min(ttl_seconds, 60 * 60 * 24)
ok, lock_info = acquire_distributed_lock(conn, REFRESH_LOCK_NAME, lock_owner_id, ttl_seconds)
if not ok:
finish_refresh_run(conn, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行")
return
lock_acquired = True
success_count = 0
failed_count = 0
for index, account in enumerate(accounts, 1):
account_id = account["id"]
account_email = account["email"]
client_id = account["client_id"]
encrypted_refresh_token = account["refresh_token"]
# 解密 refresh_token
try:
refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token
except Exception as e:
failed_count += 1
error_msg = f"解密 token 失败: {str(e)}"
try:
conn.execute(
"""
INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
account_id,
account_email,
"scheduled",
"failed",
error_msg,
run_id,
),
)
conn.commit()
except Exception:
pass
continue
# 获取分组代理设置
proxy_url = ""
group_id = account["group_id"]
if group_id:
try:
group_row = conn.execute("SELECT proxy_url FROM groups WHERE id = ?", (group_id,)).fetchone()
if group_row:
proxy_url = group_row["proxy_url"] or ""
except Exception:
proxy_url = ""
success, error_msg = test_refresh_token(client_id, refresh_token, proxy_url)
try:
conn.execute(
"""
INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message, run_id)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
account_id,
account_email,
"scheduled",
"success" if success else "failed",
error_msg,
run_id,
),
)
if success:
conn.execute(
"""
UPDATE accounts
SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(account_id,),
)
conn.commit()
except Exception:
pass
if success:
success_count += 1
else:
failed_count += 1
if index < total and delay_seconds > 0:
time.sleep(delay_seconds)
finish_refresh_run(
conn,
run_id,
"completed",
total,
success_count,
failed_count,
f"完成:成功 {success_count},失败 {failed_count}",
)
except Exception as e:
try:
if run_id:
finish_refresh_run(conn, run_id, "failed", 0, 0, 0, str(e))
except Exception:
pass
try:
app.logger.exception("Scheduled refresh task failed trace_id=%s", trace_id)
except Exception:
pass
finally:
if lock_acquired:
release_distributed_lock(conn, REFRESH_LOCK_NAME, lock_owner_id)
conn.close()
def should_autostart_scheduler() -> bool:
"""在 WSGI/Gunicorn 场景下自动启动调度器;避免 Flask CLI/重载器导致重复启动"""
autostart = config.get_scheduler_autostart_default()
if not autostart:
return False
# Flask CLI (flask run) + reloader:父进程不启动
if config.env_true("FLASK_RUN_FROM_CLI", False) and not config.env_true("WERKZEUG_RUN_MAIN", False):
return False
return True
def get_scheduler_instance():
"""获取调度器实例"""
return _scheduler_instance