mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
refactor: split some logic into util func
This commit is contained in:
@@ -189,7 +189,9 @@ async def get_system_stats(
|
|||||||
|
|
||||||
# Token 即将过期的用户数(7天内)
|
# Token 即将过期的用户数(7天内)
|
||||||
# 使用 SQL 直接查询,避免 N+1 问题
|
# 使用 SQL 直接查询,避免 N+1 问题
|
||||||
current_timestamp = int(datetime.now().timestamp())
|
from backend.utils.time_helpers import now_timestamp
|
||||||
|
|
||||||
|
current_timestamp = now_timestamp()
|
||||||
expiring_soon_timestamp = current_timestamp + (7 * 24 * 60 * 60) # 7天后
|
expiring_soon_timestamp = current_timestamp + (7 * 24 * 60 * 60) # 7天后
|
||||||
|
|
||||||
# 直接在数据库层面筛选即将过期的用户
|
# 直接在数据库层面筛选即将过期的用户
|
||||||
|
|||||||
@@ -66,13 +66,10 @@ async def get_check_in_record_status(
|
|||||||
|
|
||||||
返回状态:pending(进行中)、success(成功)、failure(失败)
|
返回状态:pending(进行中)、success(成功)、failure(失败)
|
||||||
"""
|
"""
|
||||||
|
from backend.utils.db_helpers import get_or_404
|
||||||
|
|
||||||
# 获取打卡记录
|
# 获取打卡记录
|
||||||
record = db.query(CheckInRecord).filter(CheckInRecord.id == record_id).first()
|
record = get_or_404(CheckInRecord, record_id, db, "打卡记录不存在")
|
||||||
if not record:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="打卡记录不存在"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 验证记录归属(通过任务归属)
|
# 验证记录归属(通过任务归属)
|
||||||
if not TaskService.verify_task_ownership(record.task_id, current_user.id, db):
|
if not TaskService.verify_task_ownership(record.task_id, current_user.id, db):
|
||||||
|
|||||||
+17
-29
@@ -52,20 +52,11 @@ async def get_task(
|
|||||||
|
|
||||||
需要验证任务属于当前用户
|
需要验证任务属于当前用户
|
||||||
"""
|
"""
|
||||||
# 验证任务归属
|
from backend.models import CheckInTask
|
||||||
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
|
from backend.utils.db_helpers import get_owned_or_403
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="无权访问此任务"
|
|
||||||
)
|
|
||||||
|
|
||||||
task = TaskService.get_task(task_id, db)
|
# 获取任务并验证归属
|
||||||
|
task = get_owned_or_403(CheckInTask, task_id, current_user.id, db) # type: ignore
|
||||||
if not task:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="任务不存在"
|
|
||||||
)
|
|
||||||
|
|
||||||
return task
|
return task
|
||||||
|
|
||||||
@@ -82,12 +73,11 @@ async def update_task(
|
|||||||
|
|
||||||
需要验证任务属于当前用户
|
需要验证任务属于当前用户
|
||||||
"""
|
"""
|
||||||
# 验证任务归属
|
from backend.models import CheckInTask
|
||||||
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
|
from backend.utils.db_helpers import get_owned_or_403
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
# 验证任务归属并获取任务
|
||||||
detail="无权访问此任务"
|
get_owned_or_403(CheckInTask, task_id, current_user.id, db) # type: ignore
|
||||||
)
|
|
||||||
|
|
||||||
task = TaskService.update_task(task_id, task_data, db)
|
task = TaskService.update_task(task_id, task_data, db)
|
||||||
|
|
||||||
@@ -111,12 +101,11 @@ async def delete_task(
|
|||||||
|
|
||||||
需要验证任务属于当前用户,删除后会同时删除所有关联的打卡记录
|
需要验证任务属于当前用户,删除后会同时删除所有关联的打卡记录
|
||||||
"""
|
"""
|
||||||
|
from backend.models import CheckInTask
|
||||||
|
from backend.utils.db_helpers import get_owned_or_403
|
||||||
|
|
||||||
# 验证任务归属
|
# 验证任务归属
|
||||||
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
|
get_owned_or_403(CheckInTask, task_id, current_user.id, db) # type: ignore
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="无权访问此任务"
|
|
||||||
)
|
|
||||||
|
|
||||||
success = TaskService.delete_task(task_id, db)
|
success = TaskService.delete_task(task_id, db)
|
||||||
|
|
||||||
@@ -138,12 +127,11 @@ async def toggle_task(
|
|||||||
|
|
||||||
需要验证任务属于当前用户
|
需要验证任务属于当前用户
|
||||||
"""
|
"""
|
||||||
|
from backend.models import CheckInTask
|
||||||
|
from backend.utils.db_helpers import get_owned_or_403
|
||||||
|
|
||||||
# 验证任务归属
|
# 验证任务归属
|
||||||
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
|
get_owned_or_403(CheckInTask, task_id, current_user.id, db) # type: ignore
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="无权访问此任务"
|
|
||||||
)
|
|
||||||
|
|
||||||
task = TaskService.toggle_task(task_id, db)
|
task = TaskService.toggle_task(task_id, db)
|
||||||
|
|
||||||
|
|||||||
+14
-19
@@ -1,6 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import json
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
@@ -16,25 +15,23 @@ class TaskBase(BaseModel):
|
|||||||
"""
|
"""
|
||||||
验证 payload_config 是否为有效的 JSON,并且包含必需的 ThreadId 字段
|
验证 payload_config 是否为有效的 JSON,并且包含必需的 ThreadId 字段
|
||||||
"""
|
"""
|
||||||
|
from backend.utils.json_helpers import safe_parse_json, extract_thread_id
|
||||||
|
|
||||||
if not v or not v.strip():
|
if not v or not v.strip():
|
||||||
raise ValueError("payload_config 不能为空")
|
raise ValueError("payload_config 不能为空")
|
||||||
|
|
||||||
try:
|
payload = safe_parse_json(v)
|
||||||
payload = json.loads(v)
|
if payload is None:
|
||||||
except json.JSONDecodeError as e:
|
raise ValueError("payload_config 必须是有效的 JSON 格式")
|
||||||
raise ValueError(f"payload_config 必须是有效的 JSON 格式: {str(e)}")
|
|
||||||
|
|
||||||
# 检查是否为字典类型
|
# 检查是否为字典类型
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise ValueError("payload_config 必须是 JSON 对象(字典)")
|
raise ValueError("payload_config 必须是 JSON 对象(字典)")
|
||||||
|
|
||||||
# 检查必需字段 ThreadId
|
# 检查必需字段 ThreadId
|
||||||
if 'ThreadId' not in payload:
|
thread_id = extract_thread_id(v)
|
||||||
raise ValueError("payload_config 必须包含 ThreadId 字段")
|
|
||||||
|
|
||||||
thread_id = payload.get('ThreadId')
|
|
||||||
if not thread_id or not str(thread_id).strip():
|
if not thread_id or not str(thread_id).strip():
|
||||||
raise ValueError("ThreadId 不能为空")
|
raise ValueError("payload_config 必须包含有效的 ThreadId 字段")
|
||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -84,28 +81,26 @@ class TaskUpdate(BaseModel):
|
|||||||
"""
|
"""
|
||||||
验证 payload_config 是否为有效的 JSON(如果提供的话)
|
验证 payload_config 是否为有效的 JSON(如果提供的话)
|
||||||
"""
|
"""
|
||||||
|
from backend.utils.json_helpers import safe_parse_json, extract_thread_id
|
||||||
|
|
||||||
if v is None:
|
if v is None:
|
||||||
return v
|
return v
|
||||||
|
|
||||||
if not v.strip():
|
if not v.strip():
|
||||||
raise ValueError("payload_config 不能为空字符串")
|
raise ValueError("payload_config 不能为空字符串")
|
||||||
|
|
||||||
try:
|
payload = safe_parse_json(v)
|
||||||
payload = json.loads(v)
|
if payload is None:
|
||||||
except json.JSONDecodeError as e:
|
raise ValueError("payload_config 必须是有效的 JSON 格式")
|
||||||
raise ValueError(f"payload_config 必须是有效的 JSON 格式: {str(e)}")
|
|
||||||
|
|
||||||
# 检查是否为字典类型
|
# 检查是否为字典类型
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise ValueError("payload_config 必须是 JSON 对象(字典)")
|
raise ValueError("payload_config 必须是 JSON 对象(字典)")
|
||||||
|
|
||||||
# 检查必需字段 ThreadId
|
# 检查必需字段 ThreadId
|
||||||
if 'ThreadId' not in payload:
|
thread_id = extract_thread_id(v)
|
||||||
raise ValueError("payload_config 必须包含 ThreadId 字段")
|
|
||||||
|
|
||||||
thread_id = payload.get('ThreadId')
|
|
||||||
if not thread_id or not str(thread_id).strip():
|
if not thread_id or not str(thread_id).strip():
|
||||||
raise ValueError("ThreadId 不能为空")
|
raise ValueError("payload_config 必须包含有效的 ThreadId 字段")
|
||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|||||||
@@ -381,6 +381,14 @@ class AuthService:
|
|||||||
Returns:
|
Returns:
|
||||||
包含打卡 token 验证结果的字典
|
包含打卡 token 验证结果的字典
|
||||||
"""
|
"""
|
||||||
|
from backend.utils.time_helpers import (
|
||||||
|
parse_jwt_exp,
|
||||||
|
is_timestamp_expired,
|
||||||
|
days_until_expiry,
|
||||||
|
minutes_until_expiry,
|
||||||
|
seconds_until_expiry
|
||||||
|
)
|
||||||
|
|
||||||
# 检查是否有 authorization token
|
# 检查是否有 authorization token
|
||||||
if not user.authorization or user.authorization == "":
|
if not user.authorization or user.authorization == "":
|
||||||
return {
|
return {
|
||||||
@@ -389,20 +397,18 @@ class AuthService:
|
|||||||
"reason": "no_token"
|
"reason": "no_token"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 检查 Token 是否过期
|
# 解析 jwt_exp
|
||||||
if not user.jwt_exp or user.jwt_exp == "0":
|
exp_timestamp = parse_jwt_exp(user.jwt_exp)
|
||||||
|
if not exp_timestamp:
|
||||||
return {
|
return {
|
||||||
"is_valid": False,
|
"is_valid": False,
|
||||||
"message": "打卡凭证无效",
|
"message": "打卡凭证无效",
|
||||||
"reason": "invalid_expiry"
|
"reason": "invalid_expiry"
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
# 检查是否过期
|
||||||
exp_timestamp = int(user.jwt_exp)
|
if is_timestamp_expired(exp_timestamp):
|
||||||
current_timestamp = int(datetime.now().timestamp())
|
days_expired = abs(days_until_expiry(exp_timestamp))
|
||||||
|
|
||||||
if current_timestamp > exp_timestamp:
|
|
||||||
days_expired = (current_timestamp - exp_timestamp) // 86400
|
|
||||||
return {
|
return {
|
||||||
"is_valid": False,
|
"is_valid": False,
|
||||||
"message": f"打卡凭证已过期 {days_expired} 天",
|
"message": f"打卡凭证已过期 {days_expired} 天",
|
||||||
@@ -410,10 +416,10 @@ class AuthService:
|
|||||||
"days_expired": days_expired
|
"days_expired": days_expired
|
||||||
}
|
}
|
||||||
|
|
||||||
# 计算剩余时间
|
# Token 有效,计算剩余时间
|
||||||
seconds_remaining = exp_timestamp - current_timestamp
|
seconds_remaining = seconds_until_expiry(exp_timestamp)
|
||||||
days_remaining = seconds_remaining // 86400
|
days_remaining = days_until_expiry(exp_timestamp)
|
||||||
minutes_remaining = seconds_remaining // 60
|
minutes_remaining = minutes_until_expiry(exp_timestamp)
|
||||||
|
|
||||||
# 判断是否即将过期(30分钟内)
|
# 判断是否即将过期(30分钟内)
|
||||||
expiring_soon = minutes_remaining <= 30
|
expiring_soon = minutes_remaining <= 30
|
||||||
@@ -427,14 +433,6 @@ class AuthService:
|
|||||||
"expires_at": exp_timestamp
|
"expires_at": exp_timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
except ValueError:
|
|
||||||
logger.error(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}")
|
|
||||||
return {
|
|
||||||
"is_valid": False,
|
|
||||||
"message": "打卡凭证格式错误",
|
|
||||||
"reason": "invalid_format"
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def alias_login(alias: str, password: str, db: Session) -> Dict[str, Any]:
|
def alias_login(alias: str, password: str, db: Session) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -532,15 +530,12 @@ class AuthService:
|
|||||||
token_warning = "token_invalid"
|
token_warning = "token_invalid"
|
||||||
else:
|
else:
|
||||||
# 检查 Token 是否过期
|
# 检查 Token 是否过期
|
||||||
try:
|
from backend.utils.time_helpers import parse_jwt_exp, is_timestamp_expired
|
||||||
exp_timestamp = int(user.jwt_exp)
|
|
||||||
current_timestamp = int(datetime.now().timestamp())
|
|
||||||
|
|
||||||
if current_timestamp > exp_timestamp:
|
exp_timestamp = parse_jwt_exp(user.jwt_exp)
|
||||||
|
if exp_timestamp and is_timestamp_expired(exp_timestamp):
|
||||||
logger.info(f"用户 {alias} Token 已过期,允许密码登录但需提示用户更新")
|
logger.info(f"用户 {alias} Token 已过期,允许密码登录但需提示用户更新")
|
||||||
token_warning = "token_expired"
|
token_warning = "token_expired"
|
||||||
except ValueError:
|
|
||||||
logger.error(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}")
|
|
||||||
|
|
||||||
# 登录成功
|
# 登录成功
|
||||||
logger.info(f"✅ 用户 {alias} (ID: {user.id}) 别名登录成功")
|
logger.info(f"✅ 用户 {alias} (ID: {user.id}) 别名登录成功")
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import logging
|
|||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
import json
|
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from backend.models import User, CheckInTask, CheckInRecord
|
from backend.models import User, CheckInTask, CheckInRecord
|
||||||
@@ -34,20 +33,10 @@ class CheckInService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from backend.services.email_service import EmailService
|
from backend.services.email_service import EmailService
|
||||||
|
from backend.utils.json_helpers import build_task_info
|
||||||
|
|
||||||
# 构建 task_info
|
# 使用辅助函数构建 task_info
|
||||||
task_info = {
|
task_info = build_task_info(task)
|
||||||
'thread_id': '未知',
|
|
||||||
'name': task.name or f'Task-{task.id}'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 尝试从 payload_config 中获取 ThreadId
|
|
||||||
if task.payload_config:
|
|
||||||
try:
|
|
||||||
payload = json.loads(task.payload_config)
|
|
||||||
task_info['thread_id'] = payload.get('ThreadId', '未知')
|
|
||||||
except (json.JSONDecodeError, KeyError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 发送打卡失败通知(内容包含 Token 失效说明和刷新指引)
|
# 发送打卡失败通知(内容包含 Token 失效说明和刷新指引)
|
||||||
EmailService.notify_check_in_result(user, task_info, False, "Token 已失效,需要重新授权")
|
EmailService.notify_check_in_result(user, task_info, False, "Token 已失效,需要重新授权")
|
||||||
@@ -553,12 +542,8 @@ class CheckInService:
|
|||||||
task_name = task.name
|
task_name = task.name
|
||||||
|
|
||||||
# 从 payload_config 提取 ThreadId
|
# 从 payload_config 提取 ThreadId
|
||||||
try:
|
from backend.utils.json_helpers import extract_thread_id
|
||||||
payload = json.loads(str(task.payload_config))
|
thread_id = extract_thread_id(task.payload_config) # type: ignore
|
||||||
thread_id = payload.get('ThreadId')
|
|
||||||
except (json.JSONDecodeError, KeyError, TypeError, AttributeError) as e:
|
|
||||||
logger.debug(f"解析任务 {task.id} 的 payload_config 失败: {e}")
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 转换为字典并添加额外字段
|
# 转换为字典并添加额外字段
|
||||||
record_dict = {
|
record_dict = {
|
||||||
|
|||||||
@@ -428,12 +428,10 @@ class EmailService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# 计算剩余时间
|
# 计算剩余时间
|
||||||
try:
|
from backend.utils.time_helpers import parse_jwt_exp, minutes_until_expiry
|
||||||
exp_timestamp = int(jwt_exp)
|
|
||||||
current_timestamp = int(datetime.now().timestamp())
|
exp_timestamp = parse_jwt_exp(jwt_exp)
|
||||||
minutes_left = (exp_timestamp - current_timestamp) // 60
|
minutes_left = minutes_until_expiry(exp_timestamp) if exp_timestamp else 0
|
||||||
except ValueError:
|
|
||||||
minutes_left = 0
|
|
||||||
|
|
||||||
# 构建邮件内容
|
# 构建邮件内容
|
||||||
subject = f"【接龙自动打卡系统】登录凭证即将过期 - {user.alias}"
|
subject = f"【接龙自动打卡系统】登录凭证即将过期 - {user.alias}"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
@@ -154,6 +153,8 @@ def check_token_expiration():
|
|||||||
检查所有用户的打卡 authorization token,如果在 30 分钟内过期,发送提醒邮件
|
检查所有用户的打卡 authorization token,如果在 30 分钟内过期,发送提醒邮件
|
||||||
注意:检查的是打卡业务 token,不是网站登录 JWT token
|
注意:检查的是打卡业务 token,不是网站登录 JWT token
|
||||||
"""
|
"""
|
||||||
|
from backend.utils.time_helpers import seconds_until_expiry
|
||||||
|
|
||||||
logger.info("Scheduler: 正在执行打卡 Token 过期检查...")
|
logger.info("Scheduler: 正在执行打卡 Token 过期检查...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -165,8 +166,6 @@ def check_token_expiration():
|
|||||||
from backend.services.auth_service import AuthService
|
from backend.services.auth_service import AuthService
|
||||||
|
|
||||||
users = db.query(User).all()
|
users = db.query(User).all()
|
||||||
current_timestamp = int(datetime.now().timestamp())
|
|
||||||
|
|
||||||
notified_count = 0
|
notified_count = 0
|
||||||
|
|
||||||
for user in users:
|
for user in users:
|
||||||
@@ -178,7 +177,7 @@ def check_token_expiration():
|
|||||||
if not exp_timestamp:
|
if not exp_timestamp:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
time_until_expiry = exp_timestamp - current_timestamp
|
time_until_expiry = seconds_until_expiry(exp_timestamp)
|
||||||
|
|
||||||
# 情况1:Token 即将过期(过期前 30 分钟内,且还未过期)
|
# 情况1:Token 即将过期(过期前 30 分钟内,且还未过期)
|
||||||
if 0 < time_until_expiry < 1800: # 30分钟 = 1800秒
|
if 0 < time_until_expiry < 1800: # 30分钟 = 1800秒
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import logging
|
|||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import desc
|
from sqlalchemy import desc
|
||||||
import json
|
|
||||||
|
|
||||||
from backend.models import User, CheckInTask, CheckInRecord
|
from backend.models import User, CheckInTask, CheckInRecord
|
||||||
from backend.schemas.task import TaskCreate, TaskUpdate
|
from backend.schemas.task import TaskCreate, TaskUpdate
|
||||||
@@ -34,35 +33,26 @@ class TaskService:
|
|||||||
raise ValueError(f"用户 ID {user_id} 不存在")
|
raise ValueError(f"用户 ID {user_id} 不存在")
|
||||||
|
|
||||||
# 2. 从 payload_config 中提取 ThreadId 用于唯一性校验
|
# 2. 从 payload_config 中提取 ThreadId 用于唯一性校验
|
||||||
try:
|
from backend.utils.json_helpers import safe_parse_payload, extract_thread_id
|
||||||
payload = json.loads(task_data.payload_config)
|
|
||||||
|
payload = safe_parse_payload(task_data.payload_config)
|
||||||
thread_id = payload.get('ThreadId')
|
thread_id = payload.get('ThreadId')
|
||||||
if not thread_id:
|
if not thread_id:
|
||||||
raise ValueError("payload_config 中缺少 ThreadId")
|
raise ValueError("payload_config 中缺少 ThreadId")
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise ValueError("payload_config 格式错误,必须是有效的 JSON")
|
|
||||||
|
|
||||||
# 3. 验证唯一性:同一用户在同一个接龙中不能有重复的任务
|
# 3. 验证唯一性:同一用户在同一个接龙中不能有重复的任务
|
||||||
# 优化:只查询必要的字段(id 和 payload_config),避免加载完整对象
|
|
||||||
existing_tasks = db.query(
|
existing_tasks = db.query(
|
||||||
CheckInTask.id,
|
|
||||||
CheckInTask.payload_config
|
CheckInTask.payload_config
|
||||||
).filter(
|
).filter(
|
||||||
CheckInTask.user_id == user_id
|
CheckInTask.user_id == user_id
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
for task_id, payload_config in existing_tasks:
|
for (payload_config,) in existing_tasks:
|
||||||
try:
|
existing_thread_id = extract_thread_id(payload_config)
|
||||||
existing_payload = json.loads(payload_config)
|
# extract_thread_id 已处理异常,失败时返回 None
|
||||||
if existing_payload.get('ThreadId') == thread_id:
|
if existing_thread_id and existing_thread_id == thread_id:
|
||||||
logger.warning(f"⚠️ 任务创建冲突 - User: {user.alias}({user_id}), ThreadId: {thread_id}")
|
logger.warning(f"⚠️ 任务创建冲突 - User: {user.alias}({user_id}), ThreadId: {thread_id}")
|
||||||
raise ValueError(
|
raise ValueError(f"该接龙中已存在任务。ThreadId: {thread_id}")
|
||||||
f"该接龙中已存在任务。ThreadId: {thread_id}"
|
|
||||||
)
|
|
||||||
except (json.JSONDecodeError, AttributeError, TypeError):
|
|
||||||
# 跳过无法解析的 payload_config
|
|
||||||
logger.debug(f"跳过无法解析的任务配置 - Task ID: {task_id}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 4. 记录日志
|
# 4. 记录日志
|
||||||
task_name = task_data.name or f"接龙任务 {thread_id}"
|
task_name = task_data.name or f"接龙任务 {thread_id}"
|
||||||
@@ -118,19 +108,15 @@ class TaskService:
|
|||||||
Returns:
|
Returns:
|
||||||
包含额外信息的任务字典
|
包含额外信息的任务字典
|
||||||
"""
|
"""
|
||||||
|
from backend.utils.json_helpers import extract_thread_id
|
||||||
|
|
||||||
# 获取最后一次打卡记录
|
# 获取最后一次打卡记录
|
||||||
last_record = db.query(CheckInRecord).filter(
|
last_record = db.query(CheckInRecord).filter(
|
||||||
CheckInRecord.task_id == task.id
|
CheckInRecord.task_id == task.id
|
||||||
).order_by(desc(CheckInRecord.check_in_time)).first()
|
).order_by(desc(CheckInRecord.check_in_time)).first()
|
||||||
|
|
||||||
# 从 payload_config 提取 ThreadId
|
# 从 payload_config 提取 ThreadId
|
||||||
thread_id = None
|
thread_id = extract_thread_id(task.payload_config) # type: ignore
|
||||||
try:
|
|
||||||
payload = json.loads(str(task.payload_config))
|
|
||||||
thread_id = payload.get('ThreadId')
|
|
||||||
except (json.JSONDecodeError, AttributeError, TypeError):
|
|
||||||
logger.debug(f"无法从任务 {task.id} 的 payload_config 中提取 ThreadId")
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 转换为字典并添加额外字段
|
# 转换为字典并添加额外字段
|
||||||
task_dict = {
|
task_dict = {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
数据库操作辅助函数
|
||||||
|
|
||||||
|
提供统一的资源查询、权限验证等通用功能
|
||||||
|
"""
|
||||||
|
from typing import TypeVar, Type, Optional, Any
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_404(
|
||||||
|
model: Type[T],
|
||||||
|
model_id: int,
|
||||||
|
db: Session,
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
) -> T:
|
||||||
|
"""
|
||||||
|
查询资源,不存在则抛出 404
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: SQLAlchemy 模型类
|
||||||
|
model_id: 资源 ID
|
||||||
|
db: 数据库会话
|
||||||
|
error_message: 自定义错误消息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
查询到的资源对象
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 资源不存在
|
||||||
|
"""
|
||||||
|
obj = db.query(model).filter(model.id == model_id).first()
|
||||||
|
if not obj:
|
||||||
|
default_message = f"{model.__name__}不存在"
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=error_message or default_message
|
||||||
|
)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def get_owned_or_403(
|
||||||
|
model: Type[T],
|
||||||
|
model_id: int,
|
||||||
|
user_id: int,
|
||||||
|
db: Session,
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
) -> T:
|
||||||
|
"""
|
||||||
|
查询资源并验证归属,否则抛出 403
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: SQLAlchemy 模型类(必须有 user_id 字段)
|
||||||
|
model_id: 资源 ID
|
||||||
|
user_id: 当前用户 ID
|
||||||
|
db: 数据库会话
|
||||||
|
error_message: 自定义错误消息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
查询到的资源对象
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 403 无权访问此资源
|
||||||
|
"""
|
||||||
|
obj = db.query(model).filter(
|
||||||
|
model.id == model_id,
|
||||||
|
model.user_id == user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not obj:
|
||||||
|
# 先检查资源是否存在
|
||||||
|
exists = db.query(model).filter(model.id == model_id).first()
|
||||||
|
if not exists:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"{model.__name__}不存在"
|
||||||
|
)
|
||||||
|
# 资源存在但不属于当前用户
|
||||||
|
default_message = f"无权访问此{model.__name__}"
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=error_message or default_message
|
||||||
|
)
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_field_or_404(
|
||||||
|
model: Type[T],
|
||||||
|
field_name: str,
|
||||||
|
field_value: Any,
|
||||||
|
db: Session,
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
) -> T:
|
||||||
|
"""
|
||||||
|
根据字段查询资源,不存在则抛出 404
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: SQLAlchemy 模型类
|
||||||
|
field_name: 字段名
|
||||||
|
field_value: 字段值
|
||||||
|
db: 数据库会话
|
||||||
|
error_message: 自定义错误消息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
查询到的资源对象
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 资源不存在
|
||||||
|
"""
|
||||||
|
obj = db.query(model).filter(
|
||||||
|
getattr(model, field_name) == field_value
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not obj:
|
||||||
|
default_message = f"{model.__name__}不存在"
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=error_message or default_message
|
||||||
|
)
|
||||||
|
return obj
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
JSON 处理辅助函数
|
||||||
|
|
||||||
|
提供安全的 JSON 解析和数据提取功能
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Any, Dict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_parse_json(
|
||||||
|
json_str: Optional[str],
|
||||||
|
default: Any = None,
|
||||||
|
log_error: bool = True
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
安全解析 JSON 字符串,失败时返回默认值
|
||||||
|
|
||||||
|
Args:
|
||||||
|
json_str: JSON 字符串
|
||||||
|
default: 解析失败时的默认值
|
||||||
|
log_error: 是否记录解析错误日志
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
解析后的对象,失败时返回 default
|
||||||
|
"""
|
||||||
|
if not json_str:
|
||||||
|
return default
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(str(json_str))
|
||||||
|
except (json.JSONDecodeError, AttributeError, TypeError) as e:
|
||||||
|
if log_error:
|
||||||
|
logger.debug(f"JSON 解析失败: {str(e)}, 原始数据: {json_str[:100]}...")
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def safe_parse_payload(
|
||||||
|
payload_config: Optional[str],
|
||||||
|
default: Optional[Dict] = None
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
安全解析 payload_config,失败时返回默认字典
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload_config: payload 配置字符串
|
||||||
|
default: 解析失败时的默认值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
解析后的字典
|
||||||
|
"""
|
||||||
|
result = safe_parse_json(payload_config, default or {})
|
||||||
|
# 确保返回值是字典类型
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
logger.warning(f"payload_config 不是字典类型: {type(result)}")
|
||||||
|
return default or {}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def extract_thread_id(payload_config: Optional[str]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
从 payload_config 中提取 ThreadId
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload_config: payload 配置字符串
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ThreadId 或 None
|
||||||
|
"""
|
||||||
|
payload = safe_parse_payload(payload_config)
|
||||||
|
return payload.get('ThreadId')
|
||||||
|
|
||||||
|
|
||||||
|
def extract_signature(payload_config: Optional[str]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
从 payload_config 中提取 Signature
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload_config: payload 配置字符串
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Signature 或 None
|
||||||
|
"""
|
||||||
|
payload = safe_parse_payload(payload_config)
|
||||||
|
return payload.get('Signature')
|
||||||
|
|
||||||
|
|
||||||
|
def build_task_info(task) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
从 task 对象构建 task_info 字典(用于邮件通知等场景)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: CheckInTask 对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含 thread_id 和 name 的字典
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'thread_id': extract_thread_id(getattr(task, 'payload_config', None)) or '未知',
|
||||||
|
'name': getattr(task, 'name', None) or f'Task-{getattr(task, "id", "Unknown")}'
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""
|
||||||
|
时间处理辅助函数
|
||||||
|
|
||||||
|
提供统一的时间戳处理和格式化功能
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def now_timestamp() -> int:
|
||||||
|
"""
|
||||||
|
获取当前时间戳(秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
当前时间戳
|
||||||
|
"""
|
||||||
|
return int(datetime.now().timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def is_timestamp_expired(timestamp: int) -> bool:
|
||||||
|
"""
|
||||||
|
检查时间戳是否已过期
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timestamp: 时间戳(秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否已过期
|
||||||
|
"""
|
||||||
|
return now_timestamp() > timestamp
|
||||||
|
|
||||||
|
|
||||||
|
def seconds_until_expiry(timestamp: int) -> int:
|
||||||
|
"""
|
||||||
|
计算距离过期的秒数(负数表示已过期)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timestamp: 时间戳(秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
距离过期的秒数
|
||||||
|
"""
|
||||||
|
return timestamp - now_timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
def days_until_expiry(timestamp: int) -> int:
|
||||||
|
"""
|
||||||
|
计算距离过期的天数(负数表示已过期)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timestamp: 时间戳(秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
距离过期的天数
|
||||||
|
"""
|
||||||
|
seconds = seconds_until_expiry(timestamp)
|
||||||
|
return seconds // 86400
|
||||||
|
|
||||||
|
|
||||||
|
def hours_until_expiry(timestamp: int) -> int:
|
||||||
|
"""
|
||||||
|
计算距离过期的小时数(负数表示已过期)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timestamp: 时间戳(秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
距离过期的小时数
|
||||||
|
"""
|
||||||
|
seconds = seconds_until_expiry(timestamp)
|
||||||
|
return seconds // 3600
|
||||||
|
|
||||||
|
|
||||||
|
def minutes_until_expiry(timestamp: int) -> int:
|
||||||
|
"""
|
||||||
|
计算距离过期的分钟数(负数表示已过期)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timestamp: 时间戳(秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
距离过期的分钟数
|
||||||
|
"""
|
||||||
|
seconds = seconds_until_expiry(timestamp)
|
||||||
|
return seconds // 60
|
||||||
|
|
||||||
|
|
||||||
|
def format_timestamp(timestamp: int, format_str: str = '%Y-%m-%d %H:%M:%S') -> str:
|
||||||
|
"""
|
||||||
|
格式化时间戳为人类可读格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timestamp: 时间戳(秒)
|
||||||
|
format_str: 时间格式字符串
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
格式化后的时间字符串
|
||||||
|
"""
|
||||||
|
dt = datetime.fromtimestamp(timestamp)
|
||||||
|
return dt.strftime(format_str)
|
||||||
|
|
||||||
|
|
||||||
|
def format_expiry_time(timestamp: int) -> str:
|
||||||
|
"""
|
||||||
|
格式化过期时间为人类可读格式(带中文说明)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timestamp: 时间戳(秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
格式化后的时间字符串,如 "2024-01-01 12:00:00 (已过期 2 天)"
|
||||||
|
"""
|
||||||
|
formatted_time = format_timestamp(timestamp)
|
||||||
|
days = days_until_expiry(timestamp)
|
||||||
|
|
||||||
|
if days > 0:
|
||||||
|
return f"{formatted_time} (还剩 {days} 天)"
|
||||||
|
elif days == 0:
|
||||||
|
hours = hours_until_expiry(timestamp)
|
||||||
|
if hours > 0:
|
||||||
|
return f"{formatted_time} (还剩 {hours} 小时)"
|
||||||
|
else:
|
||||||
|
minutes = minutes_until_expiry(timestamp)
|
||||||
|
if minutes > 0:
|
||||||
|
return f"{formatted_time} (还剩 {minutes} 分钟)"
|
||||||
|
else:
|
||||||
|
return f"{formatted_time} (即将过期)"
|
||||||
|
else:
|
||||||
|
return f"{formatted_time} (已过期 {abs(days)} 天)"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_jwt_exp(jwt_exp: Optional[str]) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
解析 jwt_exp 字段为时间戳
|
||||||
|
|
||||||
|
Args:
|
||||||
|
jwt_exp: jwt_exp 字符串(可能是 "0" 或数字字符串)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
时间戳,无效时返回 None
|
||||||
|
"""
|
||||||
|
if not jwt_exp or jwt_exp == "0":
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return int(jwt_exp)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
@@ -132,12 +132,10 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
|
|||||||
- error_message: 错误信息
|
- error_message: 错误信息
|
||||||
"""
|
"""
|
||||||
# 从 payload_config 中提取 Signature 用于日志
|
# 从 payload_config 中提取 Signature 用于日志
|
||||||
try:
|
from backend.utils.json_helpers import safe_parse_payload, extract_signature
|
||||||
payload_dict = json.loads(task.payload_config) if task.payload_config else {}
|
|
||||||
signature = payload_dict.get('Signature', 'Unknown')
|
payload_dict = safe_parse_payload(task.payload_config)
|
||||||
except (json.JSONDecodeError, KeyError, TypeError, AttributeError) as e:
|
signature = extract_signature(task.payload_config) or 'Unknown'
|
||||||
logger.debug(f"解析任务 {task.id} 的 payload_config 失败: {e}")
|
|
||||||
signature = 'Unknown'
|
|
||||||
|
|
||||||
logger.info(f"Selenium打卡: 正在为任务 ID: {task.id} (Signature: {signature}) 执行打卡...")
|
logger.info(f"Selenium打卡: 正在为任务 ID: {task.id} (Signature: {signature}) 执行打卡...")
|
||||||
|
|
||||||
@@ -165,9 +163,12 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# 使用任务的 payload_config(从模板生成的完整配置,包含 ThreadId)
|
# 使用任务的 payload_config(从模板生成的完整配置,包含 ThreadId)
|
||||||
payload = json.loads(task.payload_config) if task.payload_config else {}
|
from backend.utils.json_helpers import safe_parse_payload, extract_thread_id
|
||||||
|
|
||||||
if not payload.get('ThreadId'):
|
payload = safe_parse_payload(task.payload_config)
|
||||||
|
thread_id = extract_thread_id(task.payload_config)
|
||||||
|
|
||||||
|
if not thread_id:
|
||||||
error_msg = f"任务 ID: {task.id} 的 payload_config 缺少 ThreadId"
|
error_msg = f"任务 ID: {task.id} 的 payload_config 缺少 ThreadId"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return {
|
return {
|
||||||
@@ -261,10 +262,11 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
|
|||||||
if task.user and task.user.email:
|
if task.user and task.user.email:
|
||||||
try:
|
try:
|
||||||
from backend.services.email_service import EmailService
|
from backend.services.email_service import EmailService
|
||||||
task_info = {
|
from backend.utils.json_helpers import build_task_info
|
||||||
'thread_id': payload.get('ThreadId', '未知'),
|
|
||||||
'name': getattr(task, 'name', '打卡任务')
|
# 使用辅助函数构建 task_info(从 task 对象提取信息)
|
||||||
}
|
task_info = build_task_info(task)
|
||||||
|
|
||||||
# 只发送打卡失败通知(内容已说明Token失效)
|
# 只发送打卡失败通知(内容已说明Token失效)
|
||||||
EmailService.notify_check_in_result(task.user, task_info, False, "Token 已失效,需要重新授权")
|
EmailService.notify_check_in_result(task.user, task_info, False, "Token 已失效,需要重新授权")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -59,9 +59,10 @@ def get_session_status(session_id: str) -> str:
|
|||||||
content = f.read()
|
content = f.read()
|
||||||
if not content:
|
if not content:
|
||||||
return None
|
return None
|
||||||
data = json.loads(content)
|
from backend.utils.json_helpers import safe_parse_json
|
||||||
|
data = safe_parse_json(content, {})
|
||||||
return data.get('status')
|
return data.get('status')
|
||||||
except (IOError, json.JSONDecodeError) as e:
|
except IOError as e:
|
||||||
logger.error(f"读取会话文件 {filepath} 失败: {e}")
|
logger.error(f"读取会话文件 {filepath} 失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -80,8 +81,9 @@ def get_session_data(session_id: str) -> dict:
|
|||||||
content = f.read()
|
content = f.read()
|
||||||
if not content:
|
if not content:
|
||||||
return None
|
return None
|
||||||
return json.loads(content)
|
from backend.utils.json_helpers import safe_parse_json
|
||||||
except (IOError, json.JSONDecodeError) as e:
|
return safe_parse_json(content, {})
|
||||||
|
except IOError as e:
|
||||||
logger.error(f"读取会话文件 {filepath} 失败: {e}")
|
logger.error(f"读取会话文件 {filepath} 失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -106,11 +108,13 @@ def cancel_session(session_id: str) -> bool:
|
|||||||
try:
|
try:
|
||||||
with FileLock(lock_path, timeout=5):
|
with FileLock(lock_path, timeout=5):
|
||||||
# 读取当前会话数据
|
# 读取当前会话数据
|
||||||
|
from backend.utils.json_helpers import safe_parse_json
|
||||||
|
|
||||||
with open(filepath, 'r', encoding='utf-8') as f:
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
if not content:
|
if not content:
|
||||||
return False
|
return False
|
||||||
data = json.loads(content)
|
data = safe_parse_json(content, {})
|
||||||
|
|
||||||
# 如果已经成功,不允许取消
|
# 如果已经成功,不允许取消
|
||||||
if data.get('status') == 'success':
|
if data.get('status') == 'success':
|
||||||
|
|||||||
Reference in New Issue
Block a user