mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 14:06:28 +00:00
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:
@@ -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
|
||||
@@ -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:
|
||||
# 清洗 Token:URL 解码 + 去除 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": "取消失败或会话不存在"
|
||||
}
|
||||
@@ -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:
|
||||
"""
|
||||
为打卡记录添加用户和任务信息
|
||||
|
||||
注意:如果使用了 joinedload,task 和 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
|
||||
@@ -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()
|
||||
@@ -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 即将过期邮件发送失败")
|
||||
|
||||
# 情况2:Token 已过期
|
||||
# 修改逻辑:只要过期就发送提醒(不限制在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 已过期邮件发送失败")
|
||||
|
||||
# 情况3:Token 正常(剩余时间 > 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}")
|
||||
@@ -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)}")
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user