This commit is contained in:
2025-11-06 23:10:20 +08:00
commit 0bf6353839
12 changed files with 1260 additions and 0 deletions
+361
View File
@@ -0,0 +1,361 @@
from apscheduler.schedulers.background import BackgroundScheduler
import configparser
import csv
from datetime import datetime, timezone
from filelock import FileLock, Timeout
from flask import Flask, render_template, request, jsonify
import json
import jwt
import logging
from logging.handlers import RotatingFileHandler
import os
import threading
import time
from urllib.parse import unquote
import atexit
# 导入其他模块
from shared_config import (
CONFIG_PATH, LOG_PATH, SCHEDULER_LOCK, SESSIONS_DIR, CONFIG_INI_PATH,
CONFIG_FILE_LOCK, CSV_FIELDNAMES, CHECKIN_HOUR, CHECKIN_MIN
)
from token_refresher import get_token_headless
from email_notifier import notification_worker_loop
from check_in_worker import perform_check_in
# --- Flask App 设置 ---
app = Flask(__name__)
# 1. 定义日志格式和处理器
log_formatter = logging.Formatter('%(asctime)s - %(levelname)s - [%(name)s] - %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
log_handler = RotatingFileHandler(LOG_PATH, mode='a', maxBytes=5*1024*1024, backupCount=5, encoding='utf-8')
log_handler.setFormatter(log_formatter)
# 2. 获取并配置根记录器 (root logger)
# 这是关键:所有模块通过 getLogger(__name__) 创建的子记录器,都会将日志传递给根记录器
root_logger = logging.getLogger()
root_logger.addHandler(log_handler)
root_logger.setLevel(logging.INFO)
# 3. 移除Flask默认的handler,防止日志重复
app.logger.handlers.clear()
app.logger.propagate = True # 确保app.logger也将日志传递给根记录器
# 4. 将werkzeug的日志也重定向到我们的文件
werkzeug_logger = logging.getLogger('werkzeug')
werkzeug_logger.propagate = True # 确保werkzeug日志也传递给根记录器
# --- 辅助函数 ---
def read_configs():
"""加固后的读取函数,增加日志,处理空文件和不存在的情况"""
with CONFIG_FILE_LOCK:
if not os.path.exists(CONFIG_PATH):
app.logger.warning(f"配置文件 {CONFIG_PATH} 不存在,将返回空列表。")
return []
try:
with open(CONFIG_PATH, mode='r', encoding='utf-8-sig') as file:
content = file.read().strip()
if not content:
app.logger.warning(f"配置文件 {CONFIG_PATH} 为空,返回空列表。")
return []
file.seek(0)
reader = csv.DictReader(file)
rows = list(reader)
app.logger.info(f"成功从 {CONFIG_PATH} 读取 {len(rows)} 条配置。")
return rows
except Exception as e:
app.logger.error(f"读取config.csv时出错: {e}")
return []
def write_configs(rows):
"""加固后的写入函数,始终使用全局列名"""
with CONFIG_FILE_LOCK:
try:
with open(CONFIG_PATH, 'w', encoding='utf-8-sig', newline='') as file:
writer = csv.DictWriter(file, fieldnames=CSV_FIELDNAMES)
writer.writeheader()
writer.writerows(rows)
app.logger.info(f"成功将 {len(rows)} 条配置写入到 {CONFIG_PATH}")
except Exception as e:
app.logger.error(f"写入config.csv时出错: {e}")
def append_new_config(new_row_dict):
"""只在文件末尾追加一行新配置,更安全"""
with CONFIG_FILE_LOCK:
# 检查文件是否存在或为空,如果为空,则先写入标题行
file_exists = os.path.exists(CONFIG_PATH)
is_empty = not file_exists or os.path.getsize(CONFIG_PATH) == 0
try:
with open(CONFIG_PATH, 'a', encoding='utf-8-sig', newline='') as file:
writer = csv.DictWriter(file, fieldnames=CSV_FIELDNAMES)
if is_empty:
writer.writeheader()
writer.writerow(new_row_dict)
app.logger.info(f"成功追加新配置到 {CONFIG_PATH}")
except Exception as e:
app.logger.error(f"追加新配置到 {CONFIG_PATH} 时失败: {e}")
def is_token_expired(exp_timestamp):
if not exp_timestamp or not exp_timestamp.isdigit(): return True
return datetime.now(timezone.utc).timestamp() > int(exp_timestamp)
def cleanup_stale_sessions():
"""后台任务:清理超过10分钟未完成的刷新会话,运行一次后退出。"""
# 使用 app.logger 来记录日志
app.logger.info("Scheduler: 正在执行过期会话清理任务...")
try:
now = time.time()
cleared_count = 0
for filename in os.listdir(SESSIONS_DIR):
if filename.endswith(".json"):
filepath = os.path.join(SESSIONS_DIR, filename)
file_time = os.path.getmtime(filepath)
if now - file_time > 600: # 超过10分钟
os.remove(filepath)
cleared_count += 1
if cleared_count > 0:
app.logger.info(f"Scheduler: 成功清理了 {cleared_count} 个过期的会话文件。")
except Exception as e:
app.logger.error(f"Scheduler: 清理会话文件时出错: {e}")
def run_all_checkins(triggered_by="Scheduler"):
try:
app.logger.info(f"开始执行一轮打卡任务 (触发源: {triggered_by})...")
configs = read_configs()
if not configs:
app.logger.warning("配置文件为空,跳过本轮打卡。")
return
email_settings = None
if os.path.exists(CONFIG_INI_PATH):
config_parser = configparser.ConfigParser()
config_parser.read(CONFIG_INI_PATH)
if 'Email' in config_parser:
email_settings = config_parser['Email']
for config in configs:
auth_token = config.get('Authorization')
if auth_token:
perform_check_in(config)
else:
signature = config.get('Signature', '未知用户')
app.logger.warning(f"用户 {signature}'Authorization' Token 为空,已跳过打卡。")
app.logger.debug(f" 该用户的完整配置为: {config}")
app.logger.info(f"本轮打卡任务已全部提交。")
except Exception as e:
# --- 这是关键的顶层异常捕获 ---
app.logger.critical(f"执行 'run_all_checkins' 时发生未捕获的严重错误: {e}", exc_info=True)
# --- 路由 / API ---
@app.route('/')
def index():
configs = read_configs()
for config in configs:
config['show_qrcode'] = is_token_expired(config.get('jwt_exp'))
return render_template('index.html', configs=configs)
@app.route('/request_qrcode', methods=['POST'])
def request_qrcode():
# 1. 从POST请求的JSON body中获取signature
data = request.json
signature = data.get('signature')
if not signature:
return jsonify({'status': 'error', 'message': 'Signature is required'}), 400
# 2. 使用 signature 构建 session_id,以确保唯一性
session_id = f"{signature}_{int(time.time())}"
# 启动线程时,只传递 session_id,这与你的 token_refresher.py 匹配
threading.Thread(target=get_token_headless, args=(session_id,)).start()
return jsonify({'status': 'success', 'session_id': session_id})
@app.route('/get_qrcode_image/<session_id>')
def get_qrcode_image(session_id):
session_filepath = os.path.join(SESSIONS_DIR, f"{session_id}.json")
for _ in range(30):
if os.path.exists(session_filepath):
try:
with open(session_filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
if data.get('qr_image_data'):
return jsonify({'status': 'success', 'image_data': data['qr_image_data']})
except (json.JSONDecodeError, IOError) as e:
app.logger.error(f"读取会话文件 {session_filepath} 失败: {e}")
time.sleep(1)
return jsonify({'status': 'error', 'message': '获取二维码超时'}), 408
@app.route('/check_refresh_status/<session_id>')
def check_refresh_status(session_id):
session_filepath = os.path.join(SESSIONS_DIR, f"{session_id}.json")
if not os.path.exists(session_filepath):
return jsonify({'status': 'waiting'})
try:
with open(session_filepath, 'r', encoding='utf-8') as f:
session = json.load(f)
status = session.get('status')
if status == 'success':
signature_to_update = session_id.split('_')[0]
raw_token_value = session['token']
pure_jwt = unquote(raw_token_value)
if pure_jwt.lower().startswith('bearer '):
pure_jwt = pure_jwt[7:]
new_exp, new_sub = '0', ''
try:
decoded = jwt.decode(pure_jwt, options={"verify_signature": False})
new_exp, new_sub = decoded.get('exp', '0'), decoded.get('sub', '')
app.logger.info(f"成功解码JWT for sub {new_sub}, exp: {new_exp}")
except Exception as e:
app.logger.error(f"解码新的JWT时失败: {e}")
rows = read_configs()
is_updated = False
for row in rows:
# 使用 signature 来查找要更新的行
if row['Signature'] == signature_to_update:
row['Authorization'], row['jwt_exp'], row['jwt_sub'] = pure_jwt, new_exp, new_sub
is_updated = True
# 找到并更新后,可以跳出循环,提高效率
break
if not is_updated:
app.logger.error(f"严重错误:在更新Token时,未在config.csv中找到Signature {signature_to_update}")
write_configs(rows)
os.remove(session_filepath) # 成功后删除临时文件
return jsonify({'status': 'success'})
elif status == 'error':
message = session.get('message', '未知错误')
os.remove(session_filepath) # 失败后也删除
return jsonify({'status': 'error', 'message': message})
return jsonify({'status': status}) # e.g., 'waiting_scan'
except Exception as e:
app.logger.error(f"检查状态时出错 {session_filepath}: {e}")
return jsonify({'status': 'error', 'message': '读取状态文件失败'})
@app.route('/create_user', methods=['POST'])
def create_user():
data = request.json
rows = read_configs() # 读取所有现有用户
# 1. 使用 Signature 检查用户是否已存在
for row in rows:
if row['Signature'] == data['Signature']:
app.logger.warning(f"尝试添加已存在的用户: Signature {data['Signature']}")
# 如果用户已存在,直接为他请求二维码,而不是重复添加
# 这需要模拟 request_qrcode 的逻辑
signature = data['Signature']
session_id = f"{signature}_{int(time.time())}"
threading.Thread(target=get_token_headless, args=(session_id,)).start()
return jsonify({'status': 'success', 'session_id': session_id})
# 创建一个完整的新行
new_row = {field: '' for field in CSV_FIELDNAMES}
new_row.update({
'ThreadId': data['ThreadId'],
'Signature': data['Signature'],
'Texts': data['Texts'],
'Values': data['Values'],
'email': data['Email'],
'jwt_exp': '0'
})
append_new_config(new_row)
signature = data['Signature']
session_id = f"{signature}_{int(time.time())}"
threading.Thread(target=get_token_headless, args=(session_id,)).start()
return jsonify({'status': 'success', 'session_id': session_id})
@app.route('/api/checkin_all', methods=['POST'])
def trigger_checkin_all():
"""API端点,用于手动触发所有用户的打卡"""
try:
# 在后台线程中运行,以防用户数量多时导致请求超时
# 我们传递 "Manual Trigger" 来区分日志来源
threading.Thread(target=run_all_checkins, args=("Manual Trigger",)).start()
return jsonify({'status': 'success', 'message': '已成功触发全部重新打卡,请稍后在日志中查看结果。'})
except Exception as e:
app.logger.error(f"手动触发全部打卡时失败: {e}")
return jsonify({'status': 'error', 'message': '触发失败,请查看服务器日志。'}), 500
# --------------------------------------------------------------------------
# APScheduler 后台任务调度
# --------------------------------------------------------------------------
# 创建一个锁文件路径
# 确保这个文件位于一个所有 worker 都能访问到的地方
lock = FileLock(SCHEDULER_LOCK, timeout=5) # 设置5秒超时
try:
# 尝试非阻塞地获取锁
lock.acquire()
# --- 只有成功获取锁的进程才能执行以下代码 ---
app.logger.info("Scheduler lock acquired by this process. Initializing scheduler...")
# 1. 初始化调度器
scheduler = BackgroundScheduler(daemon=True, timezone='Asia/Shanghai')
# 2. 添加你的后台任务
scheduler.add_job(
func=run_all_checkins,
trigger='cron',
hour=CHECKIN_HOUR,
minute=CHECKIN_MIN,
id='daily_check_in_job',
name='执行每日打卡任务',
replace_existing=True
)
app.logger.info(f"已添加每日打卡任务,将在每天 {CHECKIN_HOUR}:{CHECKIN_MIN:02d} 执行。")
scheduler.add_job(
func=cleanup_stale_sessions,
trigger='interval',
hours=24,
id='cleanup_sessions_job',
name='清理过期的会话文件',
replace_existing=True
)
app.logger.info("已添加定期清理会话任务,每 24 小时执行一次。")
scheduler.add_job(
func=notification_worker_loop,
trigger='interval',
minutes=30,
id='token_expiry_notification_job',
name='检查Token过期并发送邮件',
replace_existing=True
)
app.logger.info("已添加Token过期检查任务,每 30 分钟执行一次。")
# 3. 启动调度器
scheduler.start()
app.logger.info("APScheduler 已成功启动。")
# 4. 注册一个应用退出时的回调函数,确保调度器被安全关闭
# 这个也只在持有锁的进程中注册
atexit.register(lambda: scheduler.shutdown())
except Timeout:
# 如果获取锁超时,说明另一个进程已经启动了调度器
app.logger.info("Could not acquire scheduler lock, another process is handling scheduling.")
finally:
# 确保在进程退出时释放锁 (尽管 with 语句通常能处理好)
# lock.release() # 在这个场景下,锁应该由持有它的进程一直持有,所以不需要手动释放
pass
if __name__ == '__main__':
# 确保sessions目录存在
if not os.path.exists(SESSIONS_DIR):
os.makedirs(SESSIONS_DIR)
app.run(debug=False, host='0.0.0.0', port=5000)