mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 14:06:28 +00:00
init
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user