refactor(structure): reorganize app layout

BREAKING CHANGE: root backend/frontend directories and old run/manage entrypoints were removed. Use apps/backend, apps/frontend, and python main.py commands instead.
This commit is contained in:
2026-05-03 16:43:11 +08:00
parent 7e8852877e
commit d4d6f87730
112 changed files with 347 additions and 1596 deletions
+85
View File
@@ -0,0 +1,85 @@
import logging
from datetime import datetime, timedelta
from typing import List, Dict, Any
from sqlalchemy.orm import Session
from backend.models import User
logger = logging.getLogger(__name__)
class AdminService:
"""管理员服务"""
@staticmethod
def get_pending_users(db: Session) -> List[User]:
"""获取待审批用户列表"""
users = db.query(User).filter(
User.is_approved == False,
User.role == "user"
).order_by(User.created_at.desc()).all()
return users
@staticmethod
def approve_user(user_id: int, db: Session) -> Dict[str, Any]:
"""审批通过用户"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return {"success": False, "message": "用户不存在"}
if user.is_approved:
return {"success": False, "message": "用户已经通过审批"}
user.is_approved = True
user.updated_at = datetime.now()
db.commit()
logger.info(f"管理员审批通过用户: {user.alias} (ID: {user.id})")
return {
"success": True,
"message": "审批成功",
"user_id": user.id
}
@staticmethod
def reject_user(user_id: int, db: Session) -> Dict[str, Any]:
"""拒绝并删除用户"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return {"success": False, "message": "用户不存在"}
alias = user.alias
db.delete(user)
db.commit()
logger.info(f"管理员拒绝用户: {alias} (ID: {user_id})")
return {
"success": True,
"message": "已拒绝并删除用户"
}
@staticmethod
def delete_expired_pending_users(db: Session) -> int:
"""删除24小时未审批的用户"""
cutoff_time = datetime.now() - timedelta(hours=24)
expired_users = db.query(User).filter(
User.is_approved == False,
User.role == "user",
User.created_at < cutoff_time
).all()
count = len(expired_users)
for user in expired_users:
logger.info(f"删除过期未审批用户: {user.alias} (ID: {user.id})")
db.delete(user)
db.commit()
return count
+628
View File
@@ -0,0 +1,628 @@
import uuid
import logging
import threading
import jwt
import bcrypt
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from urllib.parse import unquote
from sqlalchemy.orm import Session
from backend.models import User
from backend.workers.token_refresher import get_token_headless, get_session_data
from backend.config import settings
from backend.utils.jwt import JWTManager
logger = logging.getLogger(__name__)
class AuthService:
"""认证服务"""
@staticmethod
def request_qrcode(alias: str, client_ip: str, db: Session) -> Dict[str, Any]:
"""
请求 QQ 扫码二维码(支持新用户注册)
Args:
alias: 用户别名
client_ip: 客户端 IP 地址(用于会话标识)
db: 数据库会话
Returns:
包含 session_id 和 qrcode_base64 的字典
"""
from backend.services.registration_manager import registration_manager
import time
# 检查用户名是否已在数据库中存在
existing_user = db.query(User).filter(User.alias == alias).first()
# 生成唯一的会话 ID
session_id = str(uuid.uuid4())
if existing_user:
# 检查是否为空 jwt_sub(测试账号)
if not existing_user.jwt_sub:
logger.warning(f"用户 {alias} 是测试账号(未绑定 QQ),禁止扫码登录")
return {
"status": "error",
"message": "此账户为测试账号,暂未绑定 QQ,无法扫码登录"
}
# 老用户:刷新 Token
logger.info(f"老用户 {alias} 请求刷新 Token,会话: {session_id}")
# 在后台线程启动 Selenium,传入 jwt_sub
thread = threading.Thread(
target=get_token_headless,
args=(session_id, existing_user.jwt_sub, alias, client_ip),
daemon=True
)
thread.start()
else:
# 新用户:预占用户名
if not registration_manager.reserve_alias(alias, session_id, timeout_seconds=120):
logger.warning(f"用户名 {alias} 已被预占")
return {
"status": "error",
"message": "该用户名正在被其他人注册,请稍后再试或更换用户名"
}
logger.info(f"新用户 {alias} 请求注册,会话: {session_id},已预占用户名")
# 在后台线程启动 Selenium,不传入 jwt_sub(新用户)
thread = threading.Thread(
target=get_token_headless,
args=(session_id, None, alias, client_ip),
daemon=True
)
thread.start()
# 等待二维码生成(最多等待 30 秒)
logger.info(f"等待会话 {session_id} 的二维码生成...")
max_wait_time = 30
start_time = time.time()
while time.time() - start_time < max_wait_time:
session_data = get_session_data(session_id)
if session_data:
status = session_data.get("status")
# 二维码已生成
if status == "waiting_scan":
qr_image_data = session_data.get("qr_image_data")
if qr_image_data:
logger.info(f"会话 {session_id} 的二维码已生成")
return {
"session_id": session_id,
"qrcode_base64": qr_image_data
}
# 如果已经失败,直接返回错误
elif status == "failed":
error_msg = session_data.get("message", "生成二维码失败")
logger.error(f"会话 {session_id} 生成二维码失败: {error_msg}")
return {
"status": "error",
"message": error_msg
}
# 每 0.5 秒检查一次
time.sleep(0.5)
# 超时
logger.error(f"会话 {session_id} 等待二维码生成超时({max_wait_time}秒)")
return {
"status": "error",
"message": f"生成二维码超时,请重试"
}
@staticmethod
def get_qrcode_status(session_id: str, db: Session) -> Dict[str, Any]:
"""
检查二维码扫描状态
Args:
session_id: 会话 ID
db: 数据库会话
Returns:
包含状态信息的字典
"""
session_data = get_session_data(session_id)
if not session_data:
return {
"status": "pending",
"message": "会话不存在或正在初始化"
}
status = session_data.get("status")
jwt_sub = session_data.get("jwt_sub") # 使用 jwt_sub 而非 signature
if status == "waiting_scan":
return {
"status": "waiting_scan",
"message": "请使用手机 QQ 扫描二维码",
"qrcode_image": session_data.get("qr_image_data")
}
elif status == "success":
token = session_data.get("token")
alias = session_data.get("alias") # 新增:从 session 中获取 alias
# 解析 JWT Token 获取 jwt_exp 和 jwt_sub
jwt_exp = "0"
jwt_sub = ""
if not token:
logger.error("Token 为空")
return {
"status": "error",
"message": "Token 为空"
}
try:
# 清洗 TokenURL 解码 + 去除 Bearer 前缀(参考 v1 实现)
pure_token = unquote(token) # URL 解码
if pure_token.lower().startswith('bearer '):
pure_token = pure_token[7:] # 去除 "Bearer " 前缀
decoded = jwt.decode(pure_token, options={"verify_signature": False})
jwt_exp = str(decoded.get("exp", 0))
jwt_sub = decoded.get("sub", "")
logger.info(f"成功解析 JWT for sub={jwt_sub}, exp={jwt_exp}")
except Exception as e:
logger.error(f"解析 JWT Token 失败: {e}")
return {
"status": "error",
"message": f"Token 解析失败: {str(e)}"
}
# 查找用户(通过 jwt_sub
user = db.query(User).filter(User.jwt_sub == jwt_sub).first()
if user:
# 已注册用户:更新 Token(存储清理后的 token
# 注意:如果通过别名登录,需要验证 jwt_sub 是否匹配
if alias and alias == user.alias:
# 用户使用别名登录,验证 jwt_sub 是否一致
# 如果用户之前的 jwt_sub 不为空且与当前不一致,说明QQ号被换绑了
existing_jwt_sub = getattr(user, 'jwt_sub', '')
if isinstance(existing_jwt_sub, str) and existing_jwt_sub.strip() and existing_jwt_sub != jwt_sub:
logger.warning(f"⚠️ 用户 {user.alias} 的 jwt_sub 不匹配!数据库: {existing_jwt_sub}, 当前: {jwt_sub}")
return {
"status": "error",
"message": "QQ账号不匹配,请使用正确的QQ号扫码登录"
}
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)
logger.info(f"更新已注册用户 {user.alias} 的 Token")
# 生成 JWT access token(用于网站登录)
access_token = JWTManager.create_access_token(user.id, user.alias)
return {
"status": "success",
"message": "登录成功",
"token": access_token, # 返回 JWT token(用于网站登录)
"user": {
"id": user.id,
"alias": user.alias,
"role": user.role,
"is_approved": user.is_approved,
"jwt_sub": user.jwt_sub
},
"is_new_user": False
}
else:
# 新用户:创建账户
from backend.services.registration_manager import registration_manager
# 验证用户名是否被预占
if not alias or not registration_manager.is_alias_reserved(alias):
logger.error(f"新用户注册失败:用户名 {alias} 未预占或已过期")
return {
"status": "error",
"message": "注册失败:会话已过期,请重新扫码"
}
# 检查用户名是否已被其他人注册(防止竞态)
existing_user_by_alias = db.query(User).filter(User.alias == alias).first()
if existing_user_by_alias:
registration_manager.release_alias(alias)
logger.error(f"新用户注册失败:用户名 {alias} 已被占用")
return {
"status": "error",
"message": "注册失败:用户名已被占用,请更换用户名"
}
# 创建新用户(待审批状态)
new_user = User(
jwt_sub=jwt_sub,
alias=alias,
authorization=pure_token, # 存储清理后的 token
jwt_exp=jwt_exp,
role="user",
is_approved=False, # 待审批
)
db.add(new_user)
db.commit()
db.refresh(new_user)
# 释放用户名预占
registration_manager.release_alias(alias)
logger.info(f"✅ 新用户 {alias} 注册成功(待审批),ID: {new_user.id}")
# 发送邮件通知管理员
try:
from backend.services.email_service import EmailService
EmailService.notify_new_user_registration(new_user, db)
except Exception as e:
logger.error(f"发送注册通知邮件失败: {e}")
# 生成 JWT access token(用于网站登录)
access_token = JWTManager.create_access_token(new_user.id, new_user.alias)
return {
"status": "success",
"message": "注册成功,请等待管理员审批(24小时内)",
"token": access_token, # 返回 JWT token(用于网站登录)
"user": {
"id": new_user.id,
"alias": new_user.alias,
"role": new_user.role,
"is_approved": new_user.is_approved,
"jwt_sub": new_user.jwt_sub
},
"is_new_user": True
}
elif status == "error":
return {
"status": "error",
"message": session_data.get("message", "未知错误")
}
else:
return {
"status": "pending",
"message": "正在初始化..."
}
@staticmethod
def verify_token(authorization: str, db: Session) -> Dict[str, Any]:
"""
验证 JWT Token 有效性
Args:
authorization: JWT Token(可带或不带 "Bearer " 前缀)
db: 数据库会话
Returns:
包含验证结果的字典
"""
from backend.utils.jwt import JWTManager
# 移除 "Bearer " 前缀
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
try:
# 验证 JWT token
payload = JWTManager.verify_token(token)
user_id = payload.get("user_id")
if not user_id:
return {
"is_valid": False,
"message": "Token 格式错误"
}
# 从数据库获取用户
user = db.query(User).filter(User.id == user_id).first()
if not user:
return {
"is_valid": False,
"message": "用户不存在"
}
return {
"is_valid": True,
"message": "Token 有效",
"user_id": user.id,
"alias": user.alias,
"role": user.role,
"is_approved": user.is_approved
}
except pyjwt.ExpiredSignatureError:
return {
"is_valid": False,
"message": "JWT Token 已过期"
}
except pyjwt.InvalidTokenError:
return {
"is_valid": False,
"message": "JWT Token 无效"
}
except Exception as e:
logger.error(f"验证 JWT Token 失败: {str(e)}")
return {
"is_valid": False,
"message": "Token 验证失败"
}
@staticmethod
def verify_checkin_authorization(user: User) -> Dict[str, Any]:
"""
验证打卡业务 authorization token 的有效性
注意:这与 JWT token 验证不同
- JWT token 用于网站登录认证
- authorization token 用于打卡业务操作(存储在 User.authorization
Args:
user: 用户对象
Returns:
包含打卡 token 验证结果的字典
"""
from backend.utils.time_helpers import (
parse_jwt_exp,
is_timestamp_expired,
days_until_expiry,
minutes_until_expiry,
seconds_until_expiry
)
# 检查是否有 authorization token
if not user.authorization or user.authorization == "":
return {
"is_valid": False,
"message": "未设置打卡凭证",
"reason": "no_token"
}
# 解析 jwt_exp
exp_timestamp = parse_jwt_exp(user.jwt_exp)
if not exp_timestamp:
return {
"is_valid": False,
"message": "打卡凭证无效",
"reason": "invalid_expiry"
}
# 检查是否过期
if is_timestamp_expired(exp_timestamp):
days_expired = abs(days_until_expiry(exp_timestamp))
return {
"is_valid": False,
"message": f"打卡凭证已过期 {days_expired}",
"reason": "expired",
"days_expired": days_expired
}
# Token 有效,计算剩余时间
seconds_remaining = seconds_until_expiry(exp_timestamp)
days_remaining = days_until_expiry(exp_timestamp)
minutes_remaining = minutes_until_expiry(exp_timestamp)
# 判断是否即将过期(30分钟内)
expiring_soon = minutes_remaining <= 30
return {
"is_valid": True,
"message": "打卡凭证有效",
"days_remaining": days_remaining,
"minutes_remaining": minutes_remaining,
"expiring_soon": expiring_soon,
"expires_at": exp_timestamp
}
@staticmethod
def alias_login(alias: str, password: str, db: Session) -> Dict[str, Any]:
"""
别名+密码登录
Args:
alias: 用户别名
password: 密码
db: 数据库会话
Returns:
包含登录结果的字典
"""
# 查找用户
user = db.query(User).filter(User.alias == alias).first()
if not user:
logger.warning(f"别名登录失败:用户 {alias} 不存在")
return {
"success": False,
"message": "用户名或密码错误"
}
# 检查账户是否被锁定
if user.locked_until:
# 如果锁定时间还未到期
if datetime.now() < user.locked_until:
remaining_seconds = (user.locked_until - datetime.now()).total_seconds()
remaining_minutes = int(remaining_seconds / 60) + 1
logger.warning(f"别名登录失败:用户 {alias} 账户已锁定,剩余 {remaining_minutes} 分钟")
return {
"success": False,
"message": f"账户已锁定,请 {remaining_minutes} 分钟后再试"
}
else:
# 锁定时间已过,重置锁定状态
user.locked_until = None
user.failed_login_attempts = 0
db.commit()
logger.info(f"用户 {alias} 的账户锁定已自动解除")
# 检查用户是否设置了密码
if not user.password_hash:
logger.warning(f"别名登录失败:用户 {alias} 未设置密码")
return {
"success": False,
"message": "该用户未设置密码,请使用扫码登录"
}
# 验证密码
try:
password_bytes = password.encode('utf-8')
hash_bytes = user.password_hash.encode('utf-8')
if not bcrypt.checkpw(password_bytes, hash_bytes):
# 密码错误,增加失败次数
user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
user.last_failed_login = datetime.now()
# 如果失败次数达到5次,锁定账户15分钟
if user.failed_login_attempts >= 5:
user.locked_until = datetime.now() + timedelta(minutes=15)
db.commit()
logger.warning(f"别名登录失败:用户 {alias} 密码错误次数过多,账户已锁定15分钟")
return {
"success": False,
"message": "密码错误次数过多,账户已锁定15分钟"
}
db.commit()
remaining_attempts = 5 - user.failed_login_attempts
logger.warning(f"别名登录失败:用户 {alias} 密码错误,剩余尝试次数: {remaining_attempts}")
return {
"success": False,
"message": f"用户名或密码错误,剩余尝试次数: {remaining_attempts}"
}
except Exception as e:
logger.error(f"密码验证异常:{e}")
return {
"success": False,
"message": "登录失败,请稍后重试"
}
# 密码正确,重置失败次数
user.failed_login_attempts = 0
user.locked_until = None
user.last_failed_login = None
db.commit()
# 检查 Token 状态(仅作提示,不阻止登录)
token_warning = None
if not user.authorization or user.jwt_exp == "0":
logger.info(f"用户 {alias} Token 无效,允许密码登录但需提示用户更新")
token_warning = "token_invalid"
else:
# 检查 Token 是否过期
from backend.utils.time_helpers import parse_jwt_exp, is_timestamp_expired
exp_timestamp = parse_jwt_exp(user.jwt_exp)
if exp_timestamp and is_timestamp_expired(exp_timestamp):
logger.info(f"用户 {alias} Token 已过期,允许密码登录但需提示用户更新")
token_warning = "token_expired"
# 登录成功
logger.info(f"✅ 用户 {alias} (ID: {user.id}) 别名登录成功")
# 生成 JWT access token(用于网站登录)
access_token = JWTManager.create_access_token(user.id, user.alias)
result = {
"success": True,
"message": "登录成功",
"token": access_token, # 返回 JWT token(用于网站登录)
"user": {
"id": user.id,
"alias": user.alias,
"role": user.role,
"is_approved": user.is_approved
}
}
# 如果打卡 Token 有问题,添加警告信息(不影响网站使用)
if token_warning:
result["token_warning"] = token_warning
if token_warning == "token_invalid":
result["warning_message"] = "登录成功,但检测到打卡凭证无效,无法自动打卡,建议扫码更新"
elif token_warning == "token_expired":
result["warning_message"] = "登录成功,但检测到打卡凭证已过期,无法自动打卡,建议扫码更新"
return result
@staticmethod
def hash_password(password: str) -> str:
"""
使用 bcrypt 加密密码
Args:
password: 明文密码
Returns:
加密后的密码哈希
"""
password_bytes = password.encode('utf-8')
salt = bcrypt.gensalt()
hash_bytes = bcrypt.hashpw(password_bytes, salt)
return hash_bytes.decode('utf-8')
@staticmethod
def verify_password(password: str, password_hash: str) -> bool:
"""
验证密码
Args:
password: 明文密码
password_hash: 密码哈希
Returns:
密码是否正确
"""
try:
password_bytes = password.encode('utf-8')
hash_bytes = password_hash.encode('utf-8')
return bcrypt.checkpw(password_bytes, hash_bytes)
except Exception as e:
logger.error(f"密码验证异常:{e}")
return False
@staticmethod
def cancel_qrcode_session(session_id: str) -> Dict[str, Any]:
"""
取消二维码登录会话
Args:
session_id: 会话 ID
Returns:
包含取消结果的字典
"""
from backend.workers.token_refresher import cancel_session
success = cancel_session(session_id)
if success:
return {
"success": True,
"message": "会话已取消"
}
else:
return {
"success": False,
"message": "取消失败或会话不存在"
}
+564
View File
@@ -0,0 +1,564 @@
import logging
from typing import List, Dict, Any, Optional
from datetime import datetime
from sqlalchemy.orm import Session
import threading
from backend.models import User, CheckInTask, CheckInRecord
from backend.workers.check_in_worker import perform_check_in
logger = logging.getLogger(__name__)
class CheckInService:
"""打卡服务"""
@staticmethod
def handle_token_expired(user: User, task: CheckInTask, db: Session) -> None:
"""
处理 Token 过期情况:发送邮件通知并标记标志位
Args:
user: 用户对象
task: 打卡任务对象
db: 数据库会话
"""
if not user or not user.email:
return
# 检查是否已经发送过通知
if user.token_expired_notified:
logger.debug(f"用户 {user.alias} 已发送过 Token 过期通知,跳过")
return
try:
from backend.services.email_service import EmailService
from backend.utils.json_helpers import build_task_info
# 使用辅助函数构建 task_info
task_info = build_task_info(task)
# 发送打卡失败通知(内容包含 Token 失效说明和刷新指引)
EmailService.notify_check_in_result(user, task_info, False, "Token 已失效,需要重新授权")
logger.info(f"已发送 Token 过期邮件到 {user.email}")
# 标记已发送 Token 过期通知
user.token_expired_notified = True
db.commit()
logger.info(f"标记用户 {user.alias} 的 token_expired_notified 为 True")
except Exception as e:
logger.error(f"处理 Token 过期失败: {e}")
@staticmethod
def create_pending_check_in_record(task: CheckInTask, trigger_type: str, db: Session) -> int:
"""
创建一个待处理的打卡记录并返回 record_id
Args:
task: 打卡任务对象
trigger_type: 触发类型 (manual/scheduled/admin)
db: 数据库会话
Returns:
打卡记录 ID
"""
logger.info(f"🎯 创建待处理打卡记录 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id})")
# 创建一个 pending 状态的记录
record = CheckInRecord(
task_id=task.id,
status="pending",
response_text="",
error_message="",
location="{}",
trigger_type=trigger_type
)
db.add(record)
db.commit()
db.refresh(record)
logger.info(f"✅ 创建待处理记录成功 - Record ID: {record.id}")
return record.id
@staticmethod
def execute_check_in_async(task_id: int, record_id: int, user_token: str):
"""
在后台线程中执行打卡操作
Args:
task_id: 任务 ID
record_id: 打卡记录 ID
user_token: 用户 Token
"""
from backend.models.database import SessionLocal
# 创建独立的数据库会话
db = SessionLocal()
try:
logger.info(f"🤖 后台线程开始执行打卡 - Task ID: {task_id}, Record ID: {record_id}")
# 获取任务对象
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
if not task:
logger.error(f"❌ 任务不存在 - Task ID: {task_id}")
# 更新记录状态为失败
record = db.query(CheckInRecord).filter(CheckInRecord.id == record_id).first()
if record:
db.query(CheckInRecord).filter(CheckInRecord.id == record_id).update({
"status": "failure",
"error_message": "任务不存在"
})
db.commit()
return
# 执行打卡
result = perform_check_in(task, user_token)
# 如果是 Token 过期导致的失败,处理 Token 过期情况
if result["status"] == "token_expired" and task.user:
CheckInService.handle_token_expired(task.user, task, db)
# 更新记录
db.query(CheckInRecord).filter(CheckInRecord.id == record_id).update({
"status": result["status"],
"response_text": result["response_text"],
"error_message": result["error_message"]
})
db.commit()
if result["success"]:
logger.info(f"✅ 后台打卡成功 - Record ID: {record_id}")
else:
logger.error(f"❌ 后台打卡失败 - Record ID: {record_id}, 错误: {result['error_message']}")
except Exception as e:
logger.error(f"💥 后台打卡异常 - Task ID: {task_id}, Record ID: {record_id}, 错误: {str(e)}")
# 更新记录状态
try:
db.query(CheckInRecord).filter(CheckInRecord.id == record_id).update({
"status": "failure",
"error_message": f"后台执行异常: {str(e)}"
})
db.commit()
except Exception as inner_e:
logger.error(f"💥 更新记录失败: {str(inner_e)}")
finally:
db.close()
@staticmethod
def start_async_check_in(task: CheckInTask, trigger_type: str, db: Session) -> Dict[str, Any]:
"""
启动异步打卡任务
Args:
task: 打卡任务对象
trigger_type: 触发类型 (manual/scheduled/admin)
db: 数据库会话
Returns:
包含 record_id 的字典
"""
logger.info(f"🚀 启动异步打卡 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id})")
# 获取用户的打卡 Token
user = task.user
if not user or not user.authorization:
error_msg = f"用户没有有效的打卡 Token"
logger.error(f"{error_msg} - Task ID: {task.id}")
# 创建失败记录
record = CheckInRecord(
task_id=task.id,
status="failure",
response_text="",
error_message=error_msg,
location="{}",
trigger_type=trigger_type
)
db.add(record)
db.commit()
db.refresh(record)
return {
"record_id": record.id,
"status": "failure",
"message": error_msg
}
# 不再提前验证 Token,交给统一的打卡逻辑处理
# 这样可以确保所有错误(包括 Token 过期)都通过统一的流程处理
# 创建待处理记录
record_id = CheckInService.create_pending_check_in_record(task, trigger_type, db)
# 在后台线程中执行打卡
import threading
thread = threading.Thread(
target=CheckInService.execute_check_in_async,
args=(task.id, record_id, user.authorization),
daemon=True
)
thread.start()
logger.info(f"✅ 异步打卡任务已启动 - Record ID: {record_id}")
return {
"record_id": record_id,
"status": "pending",
"message": "打卡任务已启动,正在后台处理"
}
@staticmethod
def perform_task_check_in(task: CheckInTask, trigger_type: str, db: Session) -> Dict[str, Any]:
"""
执行单个任务的打卡
Args:
task: 打卡任务对象
trigger_type: 触发类型 (manual/scheduled/admin)
db: 数据库会话
Returns:
打卡结果字典
"""
logger.info(f"🎯 开始打卡 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id}), 触发: {trigger_type}")
# 获取用户的打卡 Token
user = task.user
if not user or not user.authorization:
error_msg = f"用户没有有效的打卡 Token"
logger.error(f"{error_msg} - Task ID: {task.id}, User ID: {user.id if user else 'None'}")
# 记录失败
record = CheckInRecord(
task_id=task.id,
status="failure",
response_text="",
error_message=error_msg,
location="{}",
trigger_type=trigger_type
)
db.add(record)
db.commit()
db.refresh(record)
return {
"success": False,
"message": error_msg,
"record_id": record.id
}
# 使用统一的打卡 Token 验证方法
from backend.services.auth_service import AuthService
token_result = AuthService.verify_checkin_authorization(user)
if not token_result["is_valid"]:
error_msg = token_result["message"]
logger.warning(f"{error_msg} - 用户: {user.alias}, Task ID: {task.id}")
# 处理 Token 过期:发送邮件并标记
CheckInService.handle_token_expired(user, task, db)
# 记录失败
record = CheckInRecord(
task_id=task.id,
status="token_expired", # 使用统一的状态标识
response_text="",
error_message=error_msg,
location="{}",
trigger_type=trigger_type
)
db.add(record)
db.commit()
db.refresh(record)
return {
"success": False,
"message": f"{error_msg},请重新扫码登录",
"record_id": record.id
}
# 执行打卡(传递 task 对象和用户 token)
logger.info(f"🤖 调用 Selenium Worker 执行打卡...")
result = perform_check_in(task, user.authorization)
# 如果是 Token 过期导致的失败,处理 Token 过期情况
if result["status"] == "token_expired" and user:
CheckInService.handle_token_expired(user, task, db)
# 保存打卡记录
record = CheckInRecord(
task_id=task.id,
status=result["status"],
response_text=result["response_text"],
error_message=result["error_message"],
location="{}",
trigger_type=trigger_type
)
db.add(record)
db.commit()
db.refresh(record)
if result["success"]:
logger.info(f"✅ 打卡成功 - Record ID: {record.id}")
else:
logger.error(f"❌ 打卡失败 - {result['error_message']}")
return {
"success": result["success"],
"message": "打卡成功" if result["success"] else f"打卡失败: {result['error_message']}",
"record_id": record.id
}
@staticmethod
def batch_check_in_tasks(task_ids: List[int], db: Session) -> Dict[str, Any]:
"""
批量打卡任务
Args:
task_ids: 任务 ID 列表
db: 数据库会话
Returns:
批量打卡结果
"""
logger.info(f"🚀 开始批量打卡,任务数量: {len(task_ids)}")
results = {
"total": len(task_ids),
"success": 0,
"failure": 0,
"skipped": 0,
"details": []
}
# 优化:一次性查询所有任务,避免 N+1 查询
tasks = db.query(CheckInTask).filter(CheckInTask.id.in_(task_ids)).all()
tasks_dict = {task.id: task for task in tasks}
for task_id in task_ids:
try:
task = tasks_dict.get(task_id)
if not task:
logger.warning(f"⚠️ 任务 ID {task_id} 不存在,跳过")
results["skipped"] += 1
results["details"].append({
"task_id": task_id,
"success": False,
"message": "任务不存在"
})
continue
# 执行打卡(移除 is_active 检查,允许手动打卡)
result = CheckInService.perform_task_check_in(task, "admin", db)
if result["success"]:
results["success"] += 1
logger.info(f"✅ 任务 {task_id} 批量打卡成功")
else:
results["failure"] += 1
logger.error(f"❌ 任务 {task_id} 批量打卡失败: {result['message']}")
results["details"].append({
"task_id": task_id,
"task_name": task.name or f'Task-{task.id}',
"success": result["success"],
"message": result["message"],
"record_id": result.get("record_id")
})
except Exception as e:
logger.error(f"💥 任务 {task_id} 处理异常: {str(e)}")
results["failure"] += 1
results["details"].append({
"task_id": task_id,
"success": False,
"message": f"异常: {str(e)}"
})
logger.info(f"📊 批量打卡完成 - 成功: {results['success']}, 失败: {results['failure']}, 跳过: {results['skipped']}")
return results
@staticmethod
def get_task_records(
task_id: int,
db: Session,
skip: int = 0,
limit: int = 100,
status: Optional[str] = None,
trigger_type: Optional[str] = None
) -> tuple[List[CheckInRecord], int]:
"""
获取任务的打卡记录
Args:
task_id: 任务 ID
db: 数据库会话
skip: 跳过记录数
limit: 限制记录数
status: 过滤状态 (success/failure)
trigger_type: 过滤触发类型 (scheduler/manual)
Returns:
(打卡记录列表, 总记录数)
"""
query = db.query(CheckInRecord).filter(CheckInRecord.task_id == task_id)
if status:
query = query.filter(CheckInRecord.status == status)
if trigger_type:
query = query.filter(CheckInRecord.trigger_type == trigger_type)
# 获取总数
total = query.count()
# 获取分页数据
records = query.order_by(
CheckInRecord.check_in_time.desc()
).offset(skip).limit(limit).all()
return records, total
@staticmethod
def get_user_records(
user_id: int,
db: Session,
skip: int = 0,
limit: int = 100,
status: Optional[str] = None,
trigger_type: Optional[str] = None
) -> tuple[List[CheckInRecord], int]:
"""
获取用户的所有打卡记录
Args:
user_id: 用户 ID
db: 数据库会话
skip: 跳过记录数
limit: 限制记录数
status: 过滤状态 (success/failure)
trigger_type: 过滤触发类型 (scheduler/manual)
Returns:
(打卡记录列表, 总记录数)
"""
# 获取用户的所有任务ID
user_task_ids = db.query(CheckInTask.id).filter(CheckInTask.user_id == user_id).all()
task_ids = [task_id for (task_id,) in user_task_ids]
# 查询这些任务的打卡记录
query = db.query(CheckInRecord).filter(CheckInRecord.task_id.in_(task_ids))
if status:
query = query.filter(CheckInRecord.status == status)
if trigger_type:
query = query.filter(CheckInRecord.trigger_type == trigger_type)
# 获取总数
total = query.count()
# 获取分页数据
records = query.order_by(
CheckInRecord.check_in_time.desc()
).offset(skip).limit(limit).all()
return records, total
@staticmethod
def get_all_records(
db: Session,
skip: int = 0,
limit: int = 100,
task_id: Optional[int] = None,
status: Optional[str] = None
) -> tuple[List[CheckInRecord], int]:
"""
获取所有打卡记录(管理员)- 使用联表查询优化性能
Args:
db: 数据库会话
skip: 跳过记录数
limit: 限制记录数
task_id: 过滤任务 ID
status: 过滤状态
Returns:
(打卡记录列表, 总记录数)
"""
from sqlalchemy.orm import joinedload
# 使用 joinedload 预加载关联的 task 和 user,避免 N+1 查询
query = db.query(CheckInRecord).options(
joinedload(CheckInRecord.task).joinedload(CheckInTask.user)
)
if task_id:
query = query.filter(CheckInRecord.task_id == task_id)
if status:
query = query.filter(CheckInRecord.status == status)
# 获取总数
total = query.count()
# 获取分页数据
records = query.order_by(
CheckInRecord.check_in_time.desc()
).offset(skip).limit(limit).all()
return records, total
@staticmethod
def enrich_record_with_user_task_info(record: CheckInRecord, db: Session) -> dict:
"""
为打卡记录添加用户和任务信息
注意:如果使用了 joinedloadtask 和 user 已经预加载,不会产生额外查询
Args:
record: 打卡记录对象
db: 数据库会话(可选,仅在未使用 joinedload 时使用)
Returns:
包含额外信息的记录字典
"""
# 尝试使用已加载的关联对象,如果没有则查询
task = record.task if hasattr(record, 'task') and record.task else \
db.query(CheckInTask).filter(CheckInTask.id == record.task_id).first()
# 获取用户信息
user = None
task_name = None
thread_id = None
if task:
# 尝试使用已加载的 user,否则查询
user = task.user if hasattr(task, 'user') and task.user else \
db.query(User).filter(User.id == task.user_id).first()
task_name = task.name
# 从 payload_config 提取 ThreadId
from backend.utils.json_helpers import extract_thread_id
thread_id = extract_thread_id(task.payload_config) # type: ignore
# 转换为字典并添加额外字段
record_dict = {
'id': record.id,
'task_id': record.task_id,
'status': record.status,
'response_text': record.response_text,
'error_message': record.error_message,
'location': record.location,
'trigger_type': record.trigger_type,
'check_in_time': record.check_in_time,
'user_id': user.id if user else None,
'user_email': user.email if user else None,
'task_name': task_name,
'thread_id': thread_id,
}
return record_dict
+795
View File
@@ -0,0 +1,795 @@
"""
邮件业务服务 (高级)
职能:提供业务相关的邮件操作
- 新用户注册通知
- 用户审批通知
- 打卡结果通知
- Token 到期提醒
- 调用底层 EmailNotifier 发送邮件
"""
import logging
from datetime import datetime
from typing import List
from sqlalchemy.orm import Session
from backend.models import User
from backend.workers.email_notifier import EmailNotifier
from backend.config import settings
logger = logging.getLogger(__name__)
class EmailService:
"""邮件业务服务(高级服务)"""
@staticmethod
def send_email(to_emails: List[str], subject: str, body_html: str) -> bool:
"""
发送邮件(业务层方法,调用底层 EmailNotifier
Args:
to_emails: 收件人邮箱列表
subject: 邮件主题
body_html: 邮件正文(HTML 格式)
Returns:
是否发送成功
"""
return EmailNotifier.send_email(to_emails, subject, body_html)
@staticmethod
def notify_new_user_registration(user: User, db: Session) -> bool:
"""
通知管理员有新用户注册
Args:
user: 新注册的用户
db: 数据库会话
Returns:
是否发送成功
"""
# 查询所有管理员邮箱
admins = db.query(User).filter(User.role == "admin", User.email.isnot(None)).all()
# 使用 str() 转换避免类型检查问题,并过滤空值
admin_emails: List[str] = []
for admin in admins:
email_value = admin.email
if email_value is not None: # 使用 is not None 避免布尔转换
admin_emails.append(str(email_value))
if not admin_emails:
logger.warning("没有找到管理员邮箱,无法发送通知")
return False
# 构建邮件内容
subject = f"【接龙自动打卡系统】新用户注册通知 - {user.alias}"
# 安全获取创建时间
created_at_value = user.created_at
created_time = created_at_value.strftime('%Y-%m-%d %H:%M:%S') if created_at_value is not None else '未知'
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background-color: #667eea;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}}
.content {{
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
border-radius: 0 0 5px 5px;
}}
.info-table {{
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}}
.info-table td {{
padding: 10px;
border-bottom: 1px solid #ddd;
}}
.info-table td:first-child {{
font-weight: bold;
width: 120px;
}}
.footer {{
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}}
.warning {{
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 10px;
margin: 15px 0;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>🔔 新用户注册通知</h2>
</div>
<div class="content">
<p>尊敬的管理员,</p>
<p>有新用户注册了接龙自动打卡系统,请及时审批。</p>
<table class="info-table">
<tr>
<td>用户名</td>
<td>{user.alias}</td>
</tr>
<tr>
<td>用户 ID</td>
<td>{user.id}</td>
</tr>
<tr>
<td>注册时间</td>
<td>{created_time}</td>
</tr>
</table>
<div class="warning">
<strong>⚠️ 重要提示:</strong>
<p>该用户需要在 24 小时内通过审批,否则账户将被自动删除。</p>
<p>请登录管理后台进行审批操作。</p>
</div>
<p>登录地址:<a href="{settings.FRONTEND_URL}/admin/users">{settings.FRONTEND_URL}/admin/users</a></p>
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
<p>接龙自动打卡系统 © {datetime.now().year}</p>
</div>
</div>
</body>
</html>
"""
return EmailService.send_email(admin_emails, subject, body_html)
@staticmethod
def notify_user_approved(user: User) -> bool:
"""
通知用户审批已通过
Args:
user: 已通过审批的用户
Returns:
是否发送成功
"""
user_email = user.email
if user_email is None:
logger.info(f"用户 {user.alias} 未设置邮箱,跳过审批通知")
return False
# 构建邮件内容
subject = f"【接龙自动打卡系统】账户审批通过 - {user.alias}"
# 安全获取创建时间
user_created_at = user.created_at
created_time = user_created_at.strftime('%Y-%m-%d %H:%M:%S') if user_created_at is not None else '未知'
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background-color: #28a745;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}}
.content {{
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
border-radius: 0 0 5px 5px;
}}
.info-table {{
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}}
.info-table td {{
padding: 10px;
border-bottom: 1px solid #ddd;
}}
.info-table td:first-child {{
font-weight: bold;
width: 120px;
}}
.footer {{
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}}
.success-box {{
background-color: #d4edda;
border-left: 4px solid #28a745;
padding: 15px;
margin: 15px 0;
}}
.btn {{
display: inline-block;
padding: 12px 24px;
background-color: #667eea;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 10px 0;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>🎉 恭喜!账户审批通过</h2>
</div>
<div class="content">
<p>您好,{user.alias}</p>
<p>恭喜您的账户已通过管理员审批,现在可以使用所有功能了。</p>
<div class="success-box">
<strong>✅ 审批结果:</strong> 已通过
<br>
<strong>审批时间:</strong> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
</div>
<table class="info-table">
<tr>
<td>用户名</td>
<td>{user.alias}</td>
</tr>
<tr>
<td>账户角色</td>
<td>{user.role}</td>
</tr>
<tr>
<td>注册时间</td>
<td>{created_time}</td>
</tr>
</table>
<p><strong>接下来您可以:</strong></p>
<ul>
<li>登录系统创建自动打卡任务</li>
<li>配置打卡时间和内容</li>
<li>查看打卡记录和统计</li>
</ul>
<p style="text-align: center;">
<a href="{settings.FRONTEND_URL}/login" class="btn">立即登录</a>
</p>
<p style="color: #666; font-size: 14px;">
💡 <strong>温馨提示:</strong>如果您还没有设置密码,建议在个人设置中设置密码,方便后续登录。
</p>
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
<p>接龙自动打卡系统 © {datetime.now().year}</p>
</div>
</div>
</body>
</html>
"""
return EmailService.send_email([str(user_email)], subject, body_html)
@staticmethod
def notify_user_rejected(user: User, reason: str = "") -> bool:
"""
通知用户审批被拒绝
Args:
user: 被拒绝的用户
reason: 拒绝原因(可选)
Returns:
是否发送成功
"""
user_email = user.email
if user_email is None:
logger.info(f"用户 {user.alias} 未设置邮箱,跳过拒绝通知")
return False
# 构建邮件内容
subject = f"【接龙自动打卡系统】账户审批结果 - {user.alias}"
reason_html = f"<p><strong>拒绝原因:</strong>{reason}</p>" if reason else ""
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background-color: #dc3545;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}}
.content {{
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
border-radius: 0 0 5px 5px;
}}
.footer {{
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}}
.error-box {{
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 15px;
margin: 15px 0;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>账户审批结果通知</h2>
</div>
<div class="content">
<p>您好,{user.alias}</p>
<p>很遗憾,您的账户注册申请未能通过审批。</p>
<div class="error-box">
<strong>❌ 审批结果:</strong> 未通过
<br>
<strong>处理时间:</strong> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
</div>
{reason_html}
<p>如有疑问,请联系系统管理员。</p>
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
<p>接龙自动打卡系统 © {datetime.now().year}</p>
</div>
</div>
</body>
</html>
"""
return EmailService.send_email([str(user_email)], subject, body_html)
@staticmethod
def notify_token_expiring(user: User, jwt_exp: str) -> bool:
"""
通知用户 Token 即将过期
Args:
user: 用户对象
jwt_exp: Token 过期时间戳
Returns:
是否发送成功
"""
user_email = user.email
if user_email is None:
logger.info(f"用户 {user.alias} 未设置邮箱,跳过 Token 过期通知")
return False
# 计算剩余时间
from backend.utils.time_helpers import parse_jwt_exp, minutes_until_expiry
exp_timestamp = parse_jwt_exp(jwt_exp)
minutes_left = minutes_until_expiry(exp_timestamp) if exp_timestamp else 0
# 构建邮件内容
subject = f"【接龙自动打卡系统】登录凭证即将过期 - {user.alias}"
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background-color: #ff9800;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}}
.content {{
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
border-radius: 0 0 5px 5px;
}}
.warning-box {{
background-color: #fff3cd;
border-left: 4px solid #ff9800;
padding: 15px;
margin: 15px 0;
}}
.footer {{
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}}
.btn {{
display: inline-block;
padding: 12px 24px;
background-color: #667eea;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 10px 0;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>⚠️ 登录凭证即将过期</h2>
</div>
<div class="content">
<p>您好,{user.alias}</p>
<p>您的 QQ 登录凭证即将在 <strong>{minutes_left} 分钟</strong>后过期。</p>
<div class="warning-box">
<strong>⚠️ 重要提示:</strong>
<ul style="margin: 10px 0; padding-left: 20px;">
<li>登录凭证过期后,系统将无法自动执行您的打卡任务</li>
<li>建议尽快登录系统刷新凭证</li>
<li>如果您已设置密码,可以使用密码登录后扫码刷新凭证</li>
</ul>
</div>
<p><strong>如何刷新凭证:</strong></p>
<ol style="margin: 10px 0; padding-left: 20px;">
<li>登录系统(扫码或密码登录)</li>
<li>在个人设置旁的按钮中进行刷新 Token</li>
<li>使用手机 QQ 扫描二维码完成刷新</li>
</ol>
<p style="text-align: center;">
<a href="{settings.FRONTEND_URL}/login" class="btn">立即登录刷新</a>
</p>
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
<p>接龙自动打卡系统 © {datetime.now().year}</p>
</div>
</div>
</body>
</html>
"""
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"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background-color: #dc3545;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}}
.content {{
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
border-radius: 0 0 5px 5px;
}}
.error-box {{
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 15px;
margin: 15px 0;
}}
.footer {{
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}}
.btn {{
display: inline-block;
padding: 12px 24px;
background-color: #667eea;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 10px 0;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>❌ 登录凭证已过期</h2>
</div>
<div class="content">
<p>您好,{user.alias}</p>
<p>您的 QQ 登录凭证已过期,系统已无法自动执行打卡任务。</p>
<div class="error-box">
<strong>⚠️ 重要提示:</strong>
<ul style="margin: 10px 0; padding-left: 20px;">
<li>登录凭证已过期,所有自动打卡任务已暂停</li>
<li>请尽快登录系统刷新凭证以恢复服务</li>
<li>如果您已设置密码,可以使用密码登录后扫码刷新凭证</li>
</ul>
</div>
<p><strong>如何刷新 Token</strong></p>
<ol style="margin: 10px 0; padding-left: 20px;">
<li>登录系统(扫码或密码登录)</li>
<li>在个人设置旁的按钮中进行刷新 Token</li>
<li>使用手机 QQ 扫描二维码完成刷新</li>
</ol>
<p style="text-align: center;">
<a href="{settings.FRONTEND_URL}/login" class="btn">立即登录刷新</a>
</p>
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
<p>接龙自动打卡系统 © {datetime.now().year}</p>
</div>
</div>
</body>
</html>
"""
return EmailService.send_email([str(user_email)], subject, body_html)
@staticmethod
def notify_check_in_result(user: User, task_info: dict, success: bool, message: str = "") -> bool:
"""
通知用户打卡结果
Args:
user: 用户对象
task_info: 打卡任务信息(包含 thread_id, texts, values 等)
success: 打卡是否成功
message: 额外消息
Returns:
是否发送成功
"""
user_email = user.email
if user_email is None:
logger.info(f"用户 {user.alias} 未设置邮箱,跳过打卡通知")
return False
# 构建邮件内容
status_text = "✅ 成功" if success else "❌ 失败"
status_color = "#28a745" if success else "#dc3545"
subject = f"【接龙自动打卡】打卡{status_text} - {user.alias}"
# 判断是否是 Token 失效导致的失败
is_token_error = not success and message and (
"Token" in message or "token" in message or
"失效" in message or "授权" in message or "登录" in message
)
# Token 失效时的额外提示内容
token_error_section = ""
if is_token_error:
token_error_section = f"""
<div class="error-box">
<strong>⚠️ 打卡凭证已过期</strong>
<p style="margin: 10px 0;">打卡凭证已过期,无法自动打卡。所有自动打卡任务已暂停,请尽快刷新 Token 以恢复服务。</p>
</div>
<p><strong>如何刷新 Token</strong></p>
<ol style="margin: 10px 0; padding-left: 20px;">
<li>登录系统(扫码或密码登录)</li>
<li>进入"仪表盘"或点击右上角的"刷新 Token"按钮</li>
<li>使用手机 QQ 扫描二维码完成刷新</li>
</ol>
<p style="text-align: center; margin-top: 20px;">
<a href="{settings.FRONTEND_URL}/dashboard" class="btn">立即登录刷新</a>
</p>
"""
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background-color: {status_color};
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}}
.content {{
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
border-radius: 0 0 5px 5px;
}}
.info-table {{
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}}
.info-table td {{
padding: 10px;
border-bottom: 1px solid #ddd;
}}
.info-table td:first-child {{
font-weight: bold;
width: 120px;
}}
.footer {{
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}}
.error-box {{
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 15px;
margin: 15px 0;
}}
.btn {{
display: inline-block;
padding: 12px 24px;
background-color: #667eea;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 10px 0;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>打卡通知 {status_text}</h2>
</div>
<div class="content">
<p>您好,{user.alias}</p>
<p>您的接龙自动打卡任务已执行。</p>
<table class="info-table">
<tr>
<td>执行时间</td>
<td>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</td>
</tr>
<tr>
<td>任务 ID</td>
<td>{task_info.get('thread_id', '未知')}</td>
</tr>
<tr>
<td>打卡状态</td>
<td><strong style="color: {status_color};">{status_text}</strong></td>
</tr>
{f'<tr><td>失败原因</td><td>{message}</td></tr>' if message else ''}
</table>
{token_error_section if is_token_error else '<p>如有问题,请及时检查您的打卡配置。</p>'}
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
<p>接龙自动打卡系统 © {datetime.now().year}</p>
</div>
</div>
</body>
</html>
"""
return EmailService.send_email([str(user_email)], subject, body_html)
@@ -0,0 +1,217 @@
"""
用户名预占和注册限流管理器
"""
import time
import threading
import logging
from typing import Optional, Dict
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
class RegistrationManager:
"""用户注册管理器 - 处理用户名预占和注册限流"""
def __init__(self):
# 用户名预占记录: {alias: {session_id: str, expire_time: float}}
self._reserved_aliases: Dict[str, Dict] = {}
# Cookie 注册限流记录: {cookie_value: expire_time}
self._registration_cookies: Dict[str, float] = {}
# 线程锁
self._lock = threading.RLock()
# 启动清理线程
self._start_cleanup_thread()
def reserve_alias(self, alias: str, session_id: str, timeout_seconds: int = 120) -> bool:
"""
预占用户名
Args:
alias: 用户名
session_id: 会话 ID
timeout_seconds: 超时时间(秒),默认 120 秒(2 分钟)
Returns:
是否预占成功
"""
with self._lock:
current_time = time.time()
expire_time = current_time + timeout_seconds
# 检查用户名是否已被预占
if alias in self._reserved_aliases:
reservation = self._reserved_aliases[alias]
# 检查是否过期
if reservation['expire_time'] > current_time:
# 未过期,检查是否是同一个 session
if reservation['session_id'] == session_id:
# 同一个 session,更新过期时间
reservation['expire_time'] = expire_time
logger.info(f"用户名 {alias} 预占时间已更新(session: {session_id}")
return True
else:
# 不同 session,预占失败
logger.warning(f"用户名 {alias} 已被占用(session: {reservation['session_id']}")
return False
# 预占用户名
self._reserved_aliases[alias] = {
'session_id': session_id,
'expire_time': expire_time
}
logger.info(f"用户名 {alias} 已预占(session: {session_id}, 超时: {timeout_seconds}s")
return True
def release_alias(self, alias: str, session_id: Optional[str] = None) -> bool:
"""
释放用户名预占
Args:
alias: 用户名
session_id: 会话 ID(可选,如果提供则只释放匹配的 session)
Returns:
是否释放成功
"""
with self._lock:
if alias not in self._reserved_aliases:
return False
reservation = self._reserved_aliases[alias]
# 如果指定了 session_id,则只释放匹配的
if session_id and reservation['session_id'] != session_id:
logger.warning(f"尝试释放用户名 {alias},但 session 不匹配")
return False
del self._reserved_aliases[alias]
logger.info(f"用户名 {alias} 预占已释放")
return True
def is_alias_reserved(self, alias: str) -> bool:
"""
检查用户名是否被预占
Args:
alias: 用户名
Returns:
是否被预占
"""
with self._lock:
if alias not in self._reserved_aliases:
return False
reservation = self._reserved_aliases[alias]
current_time = time.time()
# 检查是否过期
if reservation['expire_time'] <= current_time:
# 已过期,自动释放
del self._reserved_aliases[alias]
return False
return True
def check_registration_cookie(self, cookie_value: str) -> bool:
"""
检查 Cookie 是否在限流期内
Args:
cookie_value: Cookie 值
Returns:
True 表示可以注册,False 表示在限流期内
"""
with self._lock:
current_time = time.time()
# 检查 Cookie 是否存在
if cookie_value in self._registration_cookies:
expire_time = self._registration_cookies[cookie_value]
# 检查是否过期
if expire_time > current_time:
remaining = int(expire_time - current_time)
logger.warning(f"Cookie {cookie_value[:8]}... 在限流期内(剩余 {remaining} 秒)")
return False
else:
# 已过期,移除记录
del self._registration_cookies[cookie_value]
return True
def record_registration(self, cookie_value: str, cooldown_seconds: int = 600) -> None:
"""
记录注册操作(10 分钟冷却)
Args:
cookie_value: Cookie 值
cooldown_seconds: 冷却时间(秒),默认 600 秒(10 分钟)
"""
with self._lock:
current_time = time.time()
expire_time = current_time + cooldown_seconds
self._registration_cookies[cookie_value] = expire_time
logger.info(f"Cookie {cookie_value[:8]}... 已记录注册(冷却 {cooldown_seconds} 秒)")
def _cleanup_expired_records(self) -> None:
"""清理过期的预占记录和限流记录"""
with self._lock:
current_time = time.time()
# 清理过期的用户名预占
expired_aliases = [
alias for alias, reservation in self._reserved_aliases.items()
if reservation['expire_time'] <= current_time
]
for alias in expired_aliases:
del self._reserved_aliases[alias]
logger.debug(f"用户名 {alias} 预占已过期,自动释放")
# 清理过期的注册限流记录
expired_cookies = [
cookie for cookie, expire_time in self._registration_cookies.items()
if expire_time <= current_time
]
for cookie in expired_cookies:
del self._registration_cookies[cookie]
logger.debug(f"Cookie {cookie[:8]}... 限流记录已过期,自动清理")
if expired_aliases or expired_cookies:
logger.info(f"清理完成:{len(expired_aliases)} 个用户名,{len(expired_cookies)} 个 Cookie")
def _start_cleanup_thread(self) -> None:
"""启动定期清理线程"""
def cleanup_loop():
while True:
try:
time.sleep(60) # 每 60 秒清理一次
self._cleanup_expired_records()
except Exception as e:
logger.error(f"清理线程异常: {e}")
thread = threading.Thread(target=cleanup_loop, daemon=True)
thread.start()
logger.info("注册管理器清理线程已启动")
def get_stats(self) -> Dict:
"""获取当前状态统计"""
with self._lock:
return {
'reserved_aliases_count': len(self._reserved_aliases),
'rate_limited_cookies_count': len(self._registration_cookies),
'reserved_aliases': list(self._reserved_aliases.keys()),
}
# 全局单例
registration_manager = RegistrationManager()
+386
View File
@@ -0,0 +1,386 @@
import logging
import os
import time
from pathlib import Path
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from filelock import FileLock
from sqlalchemy.orm import Session
from croniter import croniter
from backend.config import settings
from backend.models import get_db, User, CheckInTask
from backend.services.check_in_service import CheckInService
from backend.services.admin_service import AdminService
logger = logging.getLogger(__name__)
# 全局调度器实例
scheduler = None
scheduler_lock = None
def load_scheduled_tasks(db: Session, scheduler_instance):
"""
从数据库加载所有启用的定时任务并添加到 APScheduler
只加载满足以下条件的任务:
- is_active = True
- cron_expression IS NOT NULL
Args:
db: 数据库会话
scheduler_instance: APScheduler BackgroundScheduler 实例
Returns:
包含统计信息的字典
"""
logger.info("正在从数据库加载定时任务...")
# 移除所有现有的动态任务(保留系统任务)
for job in scheduler_instance.get_jobs():
if job.id.startswith('task_'):
scheduler_instance.remove_job(job.id)
# 查询所有启用且有 cron 表达式的任务
tasks = db.query(CheckInTask).filter(
CheckInTask.is_active == True,
CheckInTask.cron_expression.isnot(None)
).all()
loaded_count = 0
skipped_count = 0
error_count = 0
for task in tasks:
try:
# 验证 cron 表达式
cron_str = str(task.cron_expression) if task.cron_expression else None
if not cron_str or not croniter.is_valid(cron_str):
logger.warning(f"跳过任务 {task.id}: 无效的 cron 表达式 '{task.cron_expression}'")
skipped_count += 1
continue
# 创建任务 ID
job_id = f"task_{task.id}"
# 检查任务是否已存在
if scheduler_instance.get_job(job_id):
logger.debug(f"任务 {task.id} 已存在,跳过")
continue
# 添加任务到调度器
scheduler_instance.add_job(
func=scheduled_check_in_task,
trigger=CronTrigger.from_crontab(cron_str),
id=job_id,
name=f"CheckIn-Task-{task.id}",
args=[task.id],
replace_existing=True
)
logger.info(f"✅ 加载任务 {task.id}: {task.name} (Cron: {task.cron_expression})")
loaded_count += 1
except Exception as e:
logger.error(f"❌ 加载任务 {task.id} 时出错: {str(e)}")
error_count += 1
result = {
"loaded": loaded_count,
"skipped": skipped_count,
"errors": error_count,
"total": len(tasks)
}
logger.info(f"任务加载完成: {result}")
return result
def scheduled_check_in_task(task_id: int):
"""
执行指定任务的定时打卡
这是由 APScheduler 在 cron 触发器触发时调用的函数
使用与批量打卡相同的逻辑
"""
from backend.models.database import SessionLocal
db = SessionLocal()
try:
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
if not task:
logger.error(f"任务 {task_id} 不存在")
return
if not task.is_scheduled_enabled:
logger.info(f"任务 {task_id} 未启用定时打卡 (is_active={task.is_active}, cron={task.cron_expression})")
return
logger.info(f"🤖 执行定时打卡任务 {task_id}")
# 开始异步打卡
CheckInService.start_async_check_in(task, "scheduled", db)
except Exception as e:
logger.error(f"执行定时打卡任务 {task_id} 时出错: {str(e)}", exc_info=True)
finally:
db.close()
def cleanup_expired_pending_users():
"""定时清理过期未审批用户(24小时未审批)"""
logger.info("Scheduler: 正在清理过期未审批用户...")
try:
# 创建数据库会话
db = next(get_db())
try:
count = AdminService.delete_expired_pending_users(db)
logger.info(f"Scheduler: 已删除 {count} 个过期未审批用户")
finally:
db.close()
except Exception as e:
logger.error(f"Scheduler: 清理过期用户任务发生错误: {e}", exc_info=True)
def check_token_expiration():
"""
检查打卡 Token 是否即将过期,并发送邮件提醒
检查所有用户的打卡 authorization token,如果在 30 分钟内过期,发送提醒邮件
注意:检查的是打卡业务 token,不是网站登录 JWT token
"""
from backend.utils.time_helpers import seconds_until_expiry, parse_jwt_exp
logger.info("Scheduler: 正在执行打卡 Token 过期检查...")
try:
# 创建数据库会话
db = next(get_db())
try:
# 获取所有用户
users = db.query(User).all()
notified_count = 0
for user in users:
# 跳过没有邮箱的用户
user_email = user.email
if not user_email:
logger.debug(f"用户 {user.alias} 未设置邮箱,跳过检查")
continue
# 解析 jwt_exp
jwt_exp_value = user.jwt_exp
jwt_exp_str = str(jwt_exp_value) if jwt_exp_value is not None else "0"
exp_timestamp = parse_jwt_exp(jwt_exp_str)
if not exp_timestamp:
logger.debug(f"用户 {user.alias} 的 jwt_exp 无效,跳过检查")
continue
# 计算剩余时间
time_until_expiry = seconds_until_expiry(exp_timestamp)
logger.debug(f"用户 {user.alias}: 剩余 {time_until_expiry} 秒 (即将过期标志={user.token_expiring_notified}, 已过期标志={user.token_expired_notified})")
# 情况1:Token 即将过期(过期前 30 分钟内,且还未过期)
if 0 < time_until_expiry < 1800: # 30分钟 = 1800秒
# 检查是否已发送过提醒
expiring_notified = bool(user.token_expiring_notified)
if not expiring_notified:
logger.info(f"用户 {user.alias} 的打卡 Token 即将过期,发送邮件提醒到 {user_email}...")
from backend.services.email_service import EmailService
# 发送"即将过期"邮件
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 即将过期邮件发送失败")
# 情况2Token 已过期
# 修改逻辑:只要过期就发送提醒(不限制在30分钟内)
# 但为了避免频繁发送,使用 token_expired_notified 标志
elif time_until_expiry <= 0: # Token 已过期
# 检查是否已发送过提醒
expired_notified = bool(user.token_expired_notified)
if not 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 已过期邮件发送失败")
# 情况3Token 正常(剩余时间 > 30 分钟),重置提醒标志
elif time_until_expiry >= 1800:
expiring_notified = bool(user.token_expiring_notified)
expired_notified = bool(user.token_expired_notified)
if expiring_notified or expired_notified:
user.token_expiring_notified = False
user.token_expired_notified = False
db.commit()
logger.info(f"用户 {user.alias} 的打卡 Token 已刷新,重置所有提醒标志")
logger.info(f"Scheduler: 打卡 Token 过期检查完成,共发送 {notified_count} 封提醒邮件")
finally:
db.close()
except Exception as e:
logger.error(f"Scheduler: Token 过期检查任务发生错误: {e}", exc_info=True)
def cleanup_old_sessions():
"""
清理旧的会话文件
删除超过指定时间的会话文件
"""
logger.info("Scheduler: 开始清理旧会话文件...")
try:
session_dir = settings.SESSION_DIR
if not session_dir.exists():
logger.info("Scheduler: 会话目录不存在,跳过清理")
return
current_time = time.time()
cleanup_threshold = settings.SESSION_CLEANUP_HOURS * 3600 # 转换为秒
deleted_count = 0
for file_path in session_dir.glob("*.json"):
try:
# 获取文件修改时间
file_mtime = file_path.stat().st_mtime
file_age = current_time - file_mtime
# 如果文件超过阈值,删除它
if file_age > cleanup_threshold:
# 同时删除对应的锁文件
lock_file = session_dir / f"{file_path.stem}.json.lock"
file_path.unlink()
if lock_file.exists():
lock_file.unlink()
deleted_count += 1
logger.debug(f"删除旧会话文件: {file_path.name}")
except Exception as e:
logger.error(f"删除会话文件 {file_path.name} 时出错: {e}")
logger.info(f"Scheduler: 会话文件清理完成,共删除 {deleted_count} 个文件")
except Exception as e:
logger.error(f"Scheduler: 清理会话文件任务发生错误: {e}", exc_info=True)
def start_scheduler():
"""
启动调度器
使用文件锁确保在多进程部署时只有一个调度器运行
"""
global scheduler, scheduler_lock
# 创建调度器锁文件
lock_file = settings.BASE_DIR / "scheduler.lock"
scheduler_lock = FileLock(lock_file, timeout=1)
try:
# 尝试获取锁
scheduler_lock.acquire(blocking=False)
logger.info("成功获取调度器锁,启动调度器...")
# 创建后台调度器
scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
# 添加 Token 过期检查任务(每隔指定分钟)
scheduler.add_job(
check_token_expiration,
trigger="interval",
minutes=settings.TOKEN_CHECK_INTERVAL_MINUTES,
id="check_token_expiration",
name="Token 过期检查任务",
replace_existing=True
)
logger.info(
f"已添加 Token 过期检查任务: 每 {settings.TOKEN_CHECK_INTERVAL_MINUTES} 分钟"
)
# 添加会话文件清理任务(每隔指定小时)
scheduler.add_job(
cleanup_old_sessions,
trigger="interval",
hours=settings.SESSION_CLEANUP_INTERVAL_HOURS,
id="cleanup_old_sessions",
name="清理旧会话文件任务",
replace_existing=True
)
logger.info(
f"已添加会话清理任务: 每 {settings.SESSION_CLEANUP_INTERVAL_HOURS} 小时"
)
# 添加清理过期未审批用户任务(每小时执行一次)
scheduler.add_job(
cleanup_expired_pending_users,
trigger="interval",
hours=1,
id="cleanup_expired_pending_users",
name="清理过期未审批用户任务",
replace_existing=True
)
logger.info("已添加清理过期未审批用户任务: 每 1 小时")
# 新增:从数据库加载动态任务
db = next(get_db())
try:
load_scheduled_tasks(db, scheduler)
finally:
db.close()
# 启动调度器
scheduler.start()
logger.info("调度器已启动")
except Exception as e:
logger.warning(f"无法获取调度器锁或启动失败: {e}")
logger.info("可能其他进程已经在运行调度器,跳过启动")
scheduler_lock = None
def stop_scheduler():
"""
停止调度器并释放锁
"""
global scheduler, scheduler_lock
if scheduler:
logger.info("正在停止调度器...")
scheduler.shutdown()
logger.info("调度器已停止")
if scheduler_lock:
try:
scheduler_lock.release()
logger.info("已释放调度器锁")
except Exception as e:
logger.warning(f"释放调度器锁时出错: {e}")
+376
View File
@@ -0,0 +1,376 @@
import logging
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import desc
from backend.models import User, CheckInTask, CheckInRecord
from backend.schemas.task import TaskCreate, TaskUpdate
logger = logging.getLogger(__name__)
class TaskService:
"""打卡任务服务"""
@staticmethod
def create_task(user_id: int, task_data: TaskCreate, db: Session) -> CheckInTask:
"""
创建打卡任务
Args:
user_id: 用户 ID
task_data: 任务数据
db: 数据库会话
Returns:
创建的任务对象
"""
import json
# 1. 检查用户是否存在
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError(f"用户 ID {user_id} 不存在")
# 2. 从 payload_config 中提取 ThreadId 用于唯一性校验
from backend.utils.json_helpers import safe_parse_payload, extract_thread_id
payload = safe_parse_payload(task_data.payload_config)
thread_id = payload.get('ThreadId')
if not thread_id:
raise ValueError("payload_config 中缺少 ThreadId")
# 3. 验证唯一性:同一用户在同一个接龙中不能有重复的任务
existing_tasks = db.query(
CheckInTask.payload_config
).filter(
CheckInTask.user_id == user_id
).all()
for (payload_config,) in existing_tasks:
existing_thread_id = extract_thread_id(payload_config)
# extract_thread_id 已处理异常,失败时返回 None
if existing_thread_id and existing_thread_id == thread_id:
logger.warning(f"⚠️ 任务创建冲突 - User: {user.alias}({user_id}), ThreadId: {thread_id}")
raise ValueError(f"该接龙中已存在任务。ThreadId: {thread_id}")
# 4. 记录日志
task_name = task_data.name or f"接龙任务 {thread_id}"
logger.info(f"📝 用户 {user.alias}({user_id}) 正在创建任务: {task_name}")
# 5. 创建任务
task = CheckInTask(
user_id=user_id,
payload_config=task_data.payload_config,
name=task_data.name or task_name,
is_active=task_data.is_active if task_data.is_active is not None else True
)
try:
db.add(task)
db.commit()
db.refresh(task)
logger.info(f"✅ 任务创建成功 - ID: {task.id}, Name: {task.name}, ThreadId: {thread_id}")
# 如果任务启用且包含 cron_expression,立即添加到调度器
if task.is_scheduled_enabled:
TaskService._reload_scheduler_for_task(task, db)
return task
except Exception as e:
db.rollback()
logger.error(f"❌ 任务创建失败: {str(e)}")
raise ValueError(f"任务创建失败: {str(e)}")
@staticmethod
def get_task(task_id: int, db: Session) -> Optional[CheckInTask]:
"""
获取任务详情
Args:
task_id: 任务 ID
db: 数据库会话
Returns:
任务对象或 None
"""
return db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
@staticmethod
def enrich_task_with_check_in_info(task: CheckInTask, db: Session) -> dict:
"""
为任务添加最后一次打卡信息和 ThreadId
Args:
task: 任务对象
db: 数据库会话
Returns:
包含额外信息的任务字典
"""
from backend.utils.json_helpers import extract_thread_id
# 获取最后一次打卡记录
last_record = db.query(CheckInRecord).filter(
CheckInRecord.task_id == task.id
).order_by(desc(CheckInRecord.check_in_time)).first()
# 从 payload_config 提取 ThreadId
thread_id = extract_thread_id(task.payload_config) # type: ignore
# 转换为字典并添加额外字段
task_dict = {
'id': task.id,
'user_id': task.user_id,
'payload_config': task.payload_config,
'name': task.name,
'is_active': task.is_active,
'cron_expression': task.cron_expression,
'is_scheduled_enabled': task.is_scheduled_enabled,
'created_at': task.created_at,
'updated_at': task.updated_at,
'thread_id': thread_id,
'last_check_in_time': last_record.check_in_time if last_record else None,
'last_check_in_status': last_record.status if last_record else None,
}
return task_dict
@staticmethod
def get_user_tasks(user_id: int, db: Session, include_inactive: bool = True) -> List[CheckInTask]:
"""
获取用户的所有任务
Args:
user_id: 用户 ID
db: 数据库会话
include_inactive: 是否包含未启用的任务
Returns:
任务列表
"""
query = db.query(CheckInTask).filter(CheckInTask.user_id == user_id)
if not include_inactive:
query = query.filter(CheckInTask.is_active == True)
return query.order_by(desc(CheckInTask.created_at)).all()
@staticmethod
def get_all_active_tasks(db: Session) -> List[CheckInTask]:
"""
获取所有启用的任务(用于定时打卡)
Args:
db: 数据库会话
Returns:
启用的任务列表
"""
return db.query(CheckInTask).filter(CheckInTask.is_active == True).all()
@staticmethod
def update_task(task_id: int, task_data: TaskUpdate, db: Session) -> Optional[CheckInTask]:
"""
更新任务
Args:
task_id: 任务 ID
task_data: 更新数据
db: 数据库会话
Returns:
更新后的任务对象或 None
"""
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
if not task:
return None
# 更新字段
update_data = task_data.model_dump(exclude_unset=True)
# 检查是否更新了 cron_expression 或 is_active
cron_changed = 'cron_expression' in update_data
active_changed = 'is_active' in update_data
for field, value in update_data.items():
setattr(task, field, value)
db.commit()
db.refresh(task)
logger.info(f"任务 {task_id} 已更新")
# 如果 cron_expression 或 is_active 发生变化,重新加载调度器
if cron_changed or active_changed:
TaskService._reload_scheduler_for_task(task, db)
return task
@staticmethod
def delete_task(task_id: int, db: Session) -> bool:
"""
删除任务
Args:
task_id: 任务 ID
db: 数据库会话
Returns:
是否删除成功
"""
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
if not task:
return False
db.delete(task)
db.commit()
logger.info(f"任务 {task_id} 已删除")
# 从调度器中移除该任务
TaskService._remove_task_from_scheduler(task_id)
return True
@staticmethod
def toggle_task(task_id: int, db: Session) -> Optional[CheckInTask]:
"""
切换任务的启用状态
Args:
task_id: 任务 ID
db: 数据库会话
Returns:
更新后的任务对象或 None
"""
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
if not task:
return None
task.is_active = not task.is_active
db.commit()
db.refresh(task)
logger.info(f"任务 {task_id} 状态已切换为: {'启用' if task.is_active else '禁用'}")
# 重新加载调度器
TaskService._reload_scheduler_for_task(task, db)
return task
@staticmethod
def get_task_records(task_id: int, db: Session, limit: int = 50) -> List[CheckInRecord]:
"""
获取任务的打卡记录
Args:
task_id: 任务 ID
db: 数据库会话
limit: 返回记录数量限制
Returns:
打卡记录列表
"""
return (
db.query(CheckInRecord)
.filter(CheckInRecord.task_id == task_id)
.order_by(desc(CheckInRecord.check_in_time))
.limit(limit)
.all()
)
@staticmethod
def verify_task_ownership(task_id: int, user_id: int, db: Session) -> bool:
"""
验证任务是否属于指定用户
Args:
task_id: 任务 ID
user_id: 用户 ID
db: 数据库会话
Returns:
是否属于该用户
"""
task = db.query(CheckInTask).filter(
CheckInTask.id == task_id,
CheckInTask.user_id == user_id
).first()
return task is not None
@staticmethod
def _reload_scheduler_for_task(task: CheckInTask, db: Session):
"""
重新加载指定任务到调度器
Args:
task: 任务对象
db: 数据库会话
"""
try:
from backend.services.scheduler_service import scheduler
from apscheduler.triggers.cron import CronTrigger
from croniter import croniter
if not scheduler:
logger.warning(f"调度器未启动,无法加载任务 {task.id}")
return
job_id = f"task_{task.id}"
# 先移除旧的任务(如果存在)
existing_job = scheduler.get_job(job_id)
if existing_job:
scheduler.remove_job(job_id)
logger.info(f"从调度器移除旧任务: {job_id}")
# 如果任务启用且有有效的 cron 表达式,添加新任务
if task.is_scheduled_enabled:
cron_str = str(task.cron_expression)
if croniter.is_valid(cron_str):
from backend.services.scheduler_service import scheduled_check_in_task
scheduler.add_job(
func=scheduled_check_in_task,
trigger=CronTrigger.from_crontab(cron_str),
id=job_id,
name=f"CheckIn-Task-{task.id}",
args=[task.id],
replace_existing=True
)
logger.info(f"✅ 任务 {task.id} 已重新加载到调度器: {cron_str}")
else:
logger.warning(f"任务 {task.id} 的 cron 表达式无效: {cron_str}")
else:
logger.info(f"任务 {task.id} 未启用或无 cron 表达式,已从调度器移除")
except Exception as e:
logger.error(f"重新加载任务 {task.id} 到调度器失败: {str(e)}")
@staticmethod
def _remove_task_from_scheduler(task_id: int):
"""
从调度器中移除指定任务
Args:
task_id: 任务 ID
"""
try:
from backend.services.scheduler_service import scheduler
if not scheduler:
return
job_id = f"task_{task_id}"
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
logger.info(f"✅ 任务 {task_id} 已从调度器移除")
except Exception as e:
logger.error(f"从调度器移除任务 {task_id} 失败: {str(e)}")
+577
View File
@@ -0,0 +1,577 @@
import logging
import json
from typing import List, Dict, Any, Optional
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from backend.models import TaskTemplate, CheckInTask
from backend.schemas.template import TemplateCreate, TemplateUpdate
logger = logging.getLogger(__name__)
class TemplateService:
"""模板服务"""
@staticmethod
def _deep_merge(parent: Any, child: Any) -> Any:
"""
深度合并配置,子配置会覆盖父配置
Args:
parent: 父配置
child: 子配置
Returns:
合并后的配置
"""
# 如果子配置不是字典或数组,直接返回子配置(覆盖)
if not isinstance(child, (dict, list)):
return child
# 如果父配置不是同类型,直接返回子配置
if type(parent) != type(child):
return child
# 处理字典合并
if isinstance(child, dict):
result = dict(parent) # 先复制父配置
for key, value in child.items():
if key in parent:
# 递归合并
result[key] = TemplateService._deep_merge(parent[key], value)
else:
# 新字段,直接添加
result[key] = value
return result
# 处理数组合并
if isinstance(child, list):
# 数组按索引位置合并
result = []
max_len = max(len(parent), len(child))
for i in range(max_len):
if i < len(child):
if i < len(parent):
# 两边都有,递归合并
result.append(TemplateService._deep_merge(parent[i], child[i]))
else:
# 只有子配置有,直接添加
result.append(child[i])
else:
# 只有父配置有,保留父配置
result.append(parent[i])
return result
return child
@staticmethod
def merge_parent_config(template: TaskTemplate, db: Session) -> Dict[str, Any]:
"""
合并父模板的字段配置到当前模板
Args:
template: 当前模板对象
db: 数据库会话
Returns:
合并后的完整字段配置
"""
# 解析当前模板配置
current_config = json.loads(str(template.field_config))
# 如果没有父模板,直接返回当前配置
if template.parent_id is None:
return current_config
# 获取父模板
parent = db.query(TaskTemplate).filter(TaskTemplate.id == template.parent_id).first()
if not parent:
logger.warning(f"模板 {template.id} 的父模板 {template.parent_id} 不存在")
return current_config
# 递归获取父模板的完整配置(支持多层继承)
parent_config = TemplateService.merge_parent_config(parent, db)
# 深度合并配置:子模板的配置会覆盖父模板的同名字段
merged = TemplateService._deep_merge(parent_config, current_config)
return merged
@staticmethod
def create_template(template_data: TemplateCreate, db: Session) -> TaskTemplate:
"""
创建新模板
Args:
template_data: 模板创建数据
db: 数据库会话
Returns:
创建的模板对象
"""
try:
# 验证 field_config 是有效的 JSON
if isinstance(template_data.field_config, str):
json.loads(template_data.field_config)
template = TaskTemplate(
name=template_data.name,
description=template_data.description,
field_config=template_data.field_config,
parent_id=template_data.parent_id,
is_active=template_data.is_active,
)
db.add(template)
db.commit()
db.refresh(template)
logger.info(f"创建模板成功: {template.name} (ID: {template.id})")
return template
except json.JSONDecodeError as e:
logger.error(f"模板字段配置 JSON 格式错误: {str(e)}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"字段配置 JSON 格式错误: {str(e)}"
)
except Exception as e:
logger.error(f"创建模板失败: {str(e)}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"创建模板失败: {str(e)}"
)
@staticmethod
def get_template(template_id: int, db: Session) -> Optional[TaskTemplate]:
"""
获取单个模板
Args:
template_id: 模板 ID
db: 数据库会话
Returns:
模板对象或 None
"""
return db.query(TaskTemplate).filter(TaskTemplate.id == template_id).first()
@staticmethod
def get_all_templates(
db: Session,
skip: int = 0,
limit: int = 100,
is_active: Optional[bool] = None
) -> List[TaskTemplate]:
"""
获取所有模板列表
Args:
db: 数据库会话
skip: 跳过记录数
limit: 限制记录数
is_active: 过滤启用状态
Returns:
模板列表
"""
query = db.query(TaskTemplate)
if is_active is not None:
query = query.filter(TaskTemplate.is_active == is_active)
return query.order_by(TaskTemplate.created_at.desc()).offset(skip).limit(limit).all()
@staticmethod
def update_template(
template_id: int,
template_data: TemplateUpdate,
db: Session
) -> TaskTemplate:
"""
更新模板
Args:
template_id: 模板 ID
template_data: 更新数据
db: 数据库会话
Returns:
更新后的模板对象
"""
template = TemplateService.get_template(template_id, db)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在"
)
try:
# 更新字段
update_data = template_data.model_dump(exclude_unset=True)
# 验证 field_config 如果有更新
if 'field_config' in update_data and update_data['field_config']:
json.loads(update_data['field_config'])
for field, value in update_data.items():
setattr(template, field, value)
db.commit()
db.refresh(template)
logger.info(f"更新模板成功: {template.name} (ID: {template.id})")
return template
except json.JSONDecodeError as e:
logger.error(f"模板字段配置 JSON 格式错误: {str(e)}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"字段配置 JSON 格式错误: {str(e)}"
)
except Exception as e:
logger.error(f"更新模板失败: {str(e)}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"更新模板失败: {str(e)}"
)
@staticmethod
def delete_template(template_id: int, db: Session) -> bool:
"""
删除模板
Args:
template_id: 模板 ID
db: 数据库会话
Returns:
是否删除成功
"""
template = TemplateService.get_template(template_id, db)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在"
)
try:
db.delete(template)
db.commit()
logger.info(f"删除模板成功: {template.name} (ID: {template_id})")
return True
except Exception as e:
logger.error(f"删除模板失败: {str(e)}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"删除模板失败: {str(e)}"
)
@staticmethod
def _is_field_config(obj: Any) -> bool:
"""判断是否为字段配置对象"""
return isinstance(obj, dict) and 'display_name' in obj
@staticmethod
def _is_object_field(obj: Any) -> bool:
"""判断是否为对象字段(包含多个子字段配置)"""
if not isinstance(obj, dict):
return False
if 'display_name' in obj:
return False
# 检查所有值是否都是字段配置对象
return all(
TemplateService._is_field_config(v)
for v in obj.values()
if isinstance(v, dict)
) and len(obj) > 0
@staticmethod
def _process_field_value(key: str, config: Any, field_values: Dict[str, Any]) -> Any:
"""
递归处理字段配置,生成 payload 值
Args:
key: 字段名
config: 字段配置
field_values: 用户输入值
Returns:
处理后的值
"""
# 1. 普通字段配置
if TemplateService._is_field_config(config):
if config.get('hidden', False):
value = config.get('default_value', '')
else:
value = field_values.get(key, config.get('default_value', ''))
value_type = config.get('value_type', 'string')
return TemplateService._validate_and_convert_value(value, value_type, key)
# 2. 数组字段
if isinstance(config, list):
result = []
for item_config in config:
# 检查数组元素是否是字段配置对象
if TemplateService._is_field_config(item_config):
# 数组元素是字段配置对象,需要序列化为 JSON 字符串
value = item_config.get('default_value', '')
value_type = item_config.get('value_type', 'string')
# 将对象序列化为 JSON 字符串
if value_type == 'json':
if isinstance(value, str):
# 如果是字符串,验证 JSON 格式
try:
json.loads(value)
except json.JSONDecodeError as e:
# 提供更详细的错误信息
error_detail = f"数组元素的默认值不是有效的 JSON: {value}\n"
error_detail += f"JSON 解析错误: {str(e)}\n"
error_detail += "常见问题: 数字不能有前导零(如 00.00 应改为 0.0)"
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_detail
)
result.append(value)
else:
# 如果是对象,序列化为 JSON 字符串
result.append(json.dumps(value, ensure_ascii=False))
else:
result.append(TemplateService._validate_and_convert_value(value, value_type, key))
elif isinstance(item_config, dict):
# 数组元素是普通对象,递归处理
item = {}
for item_key, item_value in item_config.items():
# 保持键名原样
item[item_key] = TemplateService._process_field_value(
item_key, item_value, field_values
)
result.append(item)
else:
result.append(item_config)
return result
# 3. 对象字段(包含多个子字段)
if TemplateService._is_object_field(config):
result = {}
for sub_key, sub_config in config.items():
# 保持键名原样
result[sub_key] = TemplateService._process_field_value(
sub_key, sub_config, field_values
)
return result
# 4. 其他情况,返回原值
return config
@staticmethod
def generate_preview_payload(template: TaskTemplate, db: Session) -> Dict[str, Any]:
"""
生成模板预览 payload(使用默认值)
完全根据模板配置动态生成
新架构:配置完全映射到 Payload 结构
Args:
template: 模板对象
db: 数据库会话
Returns:
预览 payload
"""
try:
# 合并父模板配置
field_config = TemplateService.merge_parent_config(template, db)
# 初始化 payload,只包含 ThreadId(唯一必需,不在模板中配置)
payload = {
"ThreadId": "<接龙项目ID>"
}
# 递归处理所有字段,保持键名原样
for key, config in field_config.items():
payload[key] = TemplateService._process_field_value(key, config, {})
return payload
except json.JSONDecodeError as e:
logger.error(f"解析模板配置失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"解析模板配置失败: {str(e)}"
)
@staticmethod
def assemble_payload_from_template(
template: TaskTemplate,
thread_id: str,
field_values: Dict[str, Any],
db: Session
) -> Dict[str, Any]:
"""
根据模板和用户输入组装完整的 payload
完全根据模板配置动态生成
新架构:配置完全映射到 Payload 结构
Args:
template: 模板对象
thread_id: 接龙项目 ID
field_values: 用户填写的字段值
db: 数据库会话
Returns:
完整的 payload
"""
try:
# 合并父模板配置
field_config = TemplateService.merge_parent_config(template, db)
# 初始化 payload,只包含 ThreadId(唯一必需)
payload = {
"ThreadId": thread_id
}
# 递归处理所有字段,保持键名原样
for key, config in field_config.items():
payload[key] = TemplateService._process_field_value(key, config, field_values)
return payload
except json.JSONDecodeError as e:
logger.error(f"解析模板配置失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"解析模板配置失败"
)
except Exception as e:
logger.error(f"组装 payload 失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"组装 payload 失败: {str(e)}"
)
@staticmethod
def _validate_and_convert_value(value: Any, value_type: str, field_name: str) -> Any:
"""
验证并转换字段值类型
Args:
value: 字段值
value_type: 期望的类型 (string, int, double, bool, json)
field_name: 字段名(用于错误提示)
Returns:
转换后的值
"""
try:
if value_type == 'int':
return int(value) if value != '' else 0
elif value_type == 'double':
return float(value) if value != '' else 0.0
elif value_type == 'bool':
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() in ('true', '1', 'yes')
return bool(value)
elif value_type == 'json':
# JSON 类型:如果是字符串,尝试解析后再序列化;如果是对象,直接序列化
if isinstance(value, str):
# 验证是否为有效 JSON
json.loads(value)
return value
else:
# 将对象序列化为 JSON 字符串
return json.dumps(value, ensure_ascii=False)
else: # string
return str(value)
except (ValueError, TypeError, json.JSONDecodeError) as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"字段 '{field_name}' 类型错误:期望 {value_type},实际值为 '{value}',错误: {str(e)}"
)
@staticmethod
def create_task_from_template(
template_id: int,
thread_id: str,
field_values: Dict[str, Any],
user_id: int,
task_name: Optional[str],
db: Session,
cron_expression: Optional[str] = "0 20 * * *"
) -> CheckInTask:
"""
从模板创建打卡任务
Args:
template_id: 模板 ID
thread_id: 接龙项目 ID
field_values: 用户填写的字段值
user_id: 用户 ID
task_name: 任务名称(可选)
db: 数据库会话
cron_expression: Cron 表达式(可选,默认每天 20:00)
Returns:
创建的任务对象
"""
# 获取模板
template = TemplateService.get_template(template_id, db)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在"
)
# 检查模板是否启用
if template.is_active is not True:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="该模板未启用,无法创建任务"
)
# 组装 payload
payload = TemplateService.assemble_payload_from_template(
template, thread_id, field_values, db
)
# 生成任务名称
if not task_name:
signature = payload.get('Signature', 'Unknown')
task_name = f"{template.name} - {signature}"
# 创建任务(包含 cron_expression
try:
task = CheckInTask(
user_id=user_id,
payload_config=json.dumps(payload, ensure_ascii=False),
name=task_name,
is_active=True,
cron_expression=cron_expression or "0 20 * * *"
)
db.add(task)
db.commit()
db.refresh(task)
logger.info(f"从模板创建任务成功: {task.name} (ID: {task.id}, 模板: {template.name}, ThreadId: {thread_id})")
# 如果任务启用且包含 cron_expression,立即添加到调度器
if task.is_scheduled_enabled:
from backend.services.task_service import TaskService
TaskService._reload_scheduler_for_task(task, db)
return task
except Exception as e:
logger.error(f"从模板创建任务失败: {str(e)}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"创建任务失败: {str(e)}"
)
+310
View File
@@ -0,0 +1,310 @@
import logging
from typing import List, Optional
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import or_
from backend.models import User
from backend.schemas.user import UserCreate, UserUpdate, UserUpdateProfile
logger = logging.getLogger(__name__)
def escape_like_pattern(text: str) -> str:
"""
转义 LIKE 查询中的特殊字符
Args:
text: 原始搜索文本
Returns:
转义后的文本
"""
return text.replace('%', r'\%').replace('_', r'\_')
class UserService:
"""用户服务"""
@staticmethod
def create_user(user_data: UserCreate, db: Session) -> User:
"""
创建用户(管理员手动创建)
Args:
user_data: 用户创建数据(包括 alias, role, email, password 等)
db: 数据库会话
Returns:
创建的用户对象
"""
# 检查 alias 是否已存在
existing_alias = db.query(User).filter(User.alias == user_data.alias).first()
if existing_alias:
raise ValueError(f"用户别名 {user_data.alias} 已存在")
# 创建用户(管理员创建的用户没有 jwt_sub,需要后续扫码绑定)
user = User(
jwt_sub=None, # NULL 表示未绑定 QQ
alias=user_data.alias,
email=user_data.email,
role=user_data.role or "user",
is_approved=user_data.is_approved if user_data.is_approved is not None else True, # 使用请求中的值,默认已审批
jwt_exp="0",
authorization=None,
)
# 如果提供了密码,则设置密码
if user_data.password:
import bcrypt
password_hash = bcrypt.hashpw(user_data.password.encode('utf-8'), bcrypt.gensalt())
setattr(user, 'password_hash', password_hash.decode('utf-8'))
db.add(user)
db.commit()
db.refresh(user)
logger.info(f"管理员创建用户成功: {user.alias} (ID: {user.id}, 角色: {user.role}, 密码: {'已设置' if user_data.password else '未设置'})")
return user
@staticmethod
def get_user_by_id(user_id: int, db: Session) -> Optional[User]:
"""
根据 ID 获取用户
Args:
user_id: 用户 ID
db: 数据库会话
Returns:
用户对象或 None
"""
return db.query(User).filter(User.id == user_id).first()
@staticmethod
def get_user_by_alias(alias: str, db: Session) -> Optional[User]:
"""
根据 alias 获取用户
Args:
alias: 用户别名
db: 数据库会话
Returns:
用户对象或 None
"""
return db.query(User).filter(User.alias == alias).first()
@staticmethod
def get_user_by_jwt_sub(jwt_sub: str, db: Session) -> Optional[User]:
"""
根据 jwt_sub 获取用户
Args:
jwt_sub: QQ 用户标识
db: 数据库会话
Returns:
用户对象或 None
"""
return db.query(User).filter(User.jwt_sub == jwt_sub).first()
@staticmethod
def get_all_users(
db: Session,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
role: Optional[str] = None
) -> List[User]:
"""
获取所有用户
Args:
db: 数据库会话
skip: 跳过记录数
limit: 限制记录数
search: 搜索关键词(alias 或 jwt_sub
role: 过滤角色(user/admin
Returns:
用户列表
"""
query = db.query(User)
# 搜索过滤
if search:
# 转义 LIKE 特殊字符,防止通配符滥用
escaped_search = escape_like_pattern(search)
# 注意:jwt_sub 可能为 NULL,需要处理
search_conditions = [User.alias.ilike(f"%{escaped_search}%")]
# 只有当 jwt_sub 不为空时才搜索
search_conditions.append(User.jwt_sub.ilike(f"%{escaped_search}%"))
query = query.filter(or_(*search_conditions))
# 角色过滤
if role:
query = query.filter(User.role == role)
return query.offset(skip).limit(limit).all()
@staticmethod
def update_user(user_id: int, user_data: UserUpdate, db: Session) -> User:
"""
更新用户信息(管理员操作)
Args:
user_id: 用户 ID
user_data: 用户更新数据
db: 数据库会话
Returns:
更新后的用户对象
"""
from backend.services.auth_service import AuthService
user = UserService.get_user_by_id(user_id, db)
if not user:
raise ValueError(f"用户 ID {user_id} 不存在")
# 更新字段
update_data = user_data.model_dump(exclude_unset=True)
# 如果更新 alias,检查是否重复
if "alias" in update_data and update_data["alias"] != user.alias:
existing_user = db.query(User).filter(User.alias == update_data["alias"]).first()
if existing_user:
raise ValueError(f"用户别名 {update_data['alias']} 已存在")
# 处理密码重置
if update_data.get("reset_password"):
user.password_hash = None
logger.info(f"管理员重置用户 {user.alias} (ID: {user_id}) 的密码")
# 处理密码修改
elif "password" in update_data and update_data["password"]:
user.password_hash = AuthService.hash_password(update_data["password"])
logger.info(f"管理员修改用户 {user.alias} (ID: {user_id}) 的密码")
# 更新其他字段(排除密码相关字段)
excluded_fields = {"password", "reset_password"}
for key, value in update_data.items():
if key not in excluded_fields:
setattr(user, key, value)
user.updated_at = datetime.now()
db.commit()
db.refresh(user)
logger.info(f"更新用户成功: {user.alias} (ID: {user.id})")
return user
@staticmethod
def update_user_profile(user_id: int, profile_data: UserUpdateProfile, db: Session) -> User:
"""
更新用户个人信息(别名、邮箱和密码)
Args:
user_id: 用户 ID
profile_data: 个人信息更新数据
db: 数据库会话
Returns:
更新后的用户对象
"""
from backend.services.auth_service import AuthService
user = UserService.get_user_by_id(user_id, db)
if not user:
raise ValueError(f"用户 ID {user_id} 不存在")
update_data = profile_data.model_dump(exclude_unset=True)
# 更新别名
if "alias" in update_data and update_data["alias"] != user.alias:
existing_user = db.query(User).filter(User.alias == update_data["alias"]).first()
if existing_user:
raise ValueError(f"用户别名 {update_data['alias']} 已存在")
user.alias = update_data["alias"]
logger.info(f"用户 ID {user_id} 别名更新: {user.alias}")
# 更新邮箱
if "email" in update_data:
user.email = update_data["email"]
logger.info(f"用户 ID {user_id} 邮箱更新: {user.email}")
# 更新密码
if "new_password" in update_data and update_data["new_password"]:
# 如果用户已设置密码,需要验证当前密码
if user.password_hash:
if "current_password" not in update_data or not update_data["current_password"]:
raise ValueError("修改密码时必须提供当前密码")
# 验证当前密码
if not AuthService.verify_password(update_data["current_password"], user.password_hash):
raise ValueError("当前密码错误")
# 设置新密码
user.password_hash = AuthService.hash_password(update_data["new_password"])
logger.info(f"用户 ID {user_id} 密码已更新")
user.updated_at = datetime.now()
db.commit()
db.refresh(user)
logger.info(f"✅ 更新用户个人信息成功: {user.alias} (ID: {user.id})")
return user
@staticmethod
def delete_user(user_id: int, db: Session) -> bool:
"""
删除用户
Args:
user_id: 用户 ID
db: 数据库会话
Returns:
是否删除成功
"""
user = UserService.get_user_by_id(user_id, db)
if not user:
raise ValueError(f"用户 ID {user_id} 不存在")
alias = user.alias
db.delete(user)
db.commit()
logger.info(f"删除用户成功: {alias} (ID: {user_id})")
return True
@staticmethod
def get_users_by_role(role: str, db: Session) -> List[User]:
"""
获取指定角色的用户
Args:
role: 角色(user/admin
db: 数据库会话
Returns:
用户列表
"""
return db.query(User).filter(User.role == role).all()
@staticmethod
def count_users(db: Session, role: Optional[str] = None) -> int:
"""
统计用户数量
Args:
db: 数据库会话
role: 角色过滤(可选)
Returns:
用户数量
"""
query = db.query(User)
if role:
query = query.filter(User.role == role)
return query.count()