mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
style(backend): apply ruff format
This commit is contained in:
+64
-73
@@ -18,6 +18,7 @@ router = APIRouter()
|
||||
|
||||
class BatchToggleTasksRequest(BaseModel):
|
||||
"""批量启用/禁用任务请求"""
|
||||
|
||||
task_ids: List[int]
|
||||
is_active: bool
|
||||
|
||||
@@ -26,7 +27,7 @@ class BatchToggleTasksRequest(BaseModel):
|
||||
async def batch_toggle_tasks(
|
||||
request: BatchToggleTasksRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
批量启用或禁用任务的自动打卡功能(需要管理员权限)
|
||||
@@ -47,12 +48,11 @@ async def batch_toggle_tasks(
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"已{'启用' if request.is_active else '禁用'} {count} 个任务",
|
||||
"count": count
|
||||
"count": count,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"批量操作失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"批量操作失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ async def batch_toggle_tasks(
|
||||
async def batch_check_in(
|
||||
request: BatchCheckInRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
批量触发任务打卡(需要管理员权限)
|
||||
@@ -74,15 +74,14 @@ async def batch_check_in(
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"批量打卡失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"批量打卡失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logs", summary="获取系统日志")
|
||||
async def get_system_logs(
|
||||
lines: int = Query(200, ge=1, le=2000, description="读取的日志行数"),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
获取系统日志(需要管理员权限)
|
||||
@@ -95,40 +94,34 @@ async def get_system_logs(
|
||||
log_file = settings.LOG_FILE
|
||||
|
||||
if not log_file.exists():
|
||||
return {
|
||||
"success": True,
|
||||
"message": "日志文件不存在",
|
||||
"logs": "日志文件不存在"
|
||||
}
|
||||
return {"success": True, "message": "日志文件不存在", "logs": "日志文件不存在"}
|
||||
|
||||
# 使用 deque 高效读取最后 N 行,避免将整个文件加载到内存
|
||||
from collections import deque
|
||||
|
||||
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
|
||||
# 使用 deque 保持最后 N 行,内存占用固定
|
||||
last_lines = deque(f, maxlen=lines)
|
||||
|
||||
# 返回字符串格式(不是数组)
|
||||
log_content = ''.join(last_lines)
|
||||
log_content = "".join(last_lines)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"读取了最后 {len(last_lines)} 行日志",
|
||||
"logs": log_content
|
||||
"logs": log_content,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"读取日志失败: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"读取日志失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"读取日志失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", summary="获取系统统计")
|
||||
async def get_system_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
获取系统统计信息(需要管理员权限)
|
||||
@@ -159,33 +152,39 @@ async def get_system_stats(
|
||||
|
||||
# 今日打卡记录数
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_records = db.query(CheckInRecord).filter(
|
||||
CheckInRecord.check_in_time >= today_start
|
||||
).count()
|
||||
today_records = (
|
||||
db.query(CheckInRecord).filter(CheckInRecord.check_in_time >= today_start).count()
|
||||
)
|
||||
|
||||
# 今日成功打卡数
|
||||
today_success = db.query(CheckInRecord).filter(
|
||||
CheckInRecord.check_in_time >= today_start,
|
||||
CheckInRecord.status == "success"
|
||||
).count()
|
||||
today_success = (
|
||||
db.query(CheckInRecord)
|
||||
.filter(CheckInRecord.check_in_time >= today_start, CheckInRecord.status == "success")
|
||||
.count()
|
||||
)
|
||||
|
||||
# 今日失败打卡数
|
||||
today_failure = db.query(CheckInRecord).filter(
|
||||
CheckInRecord.check_in_time >= today_start,
|
||||
CheckInRecord.status == "failure"
|
||||
).count()
|
||||
today_failure = (
|
||||
db.query(CheckInRecord)
|
||||
.filter(CheckInRecord.check_in_time >= today_start, CheckInRecord.status == "failure")
|
||||
.count()
|
||||
)
|
||||
|
||||
# 今日时间范围外打卡数
|
||||
today_out_of_time = db.query(CheckInRecord).filter(
|
||||
CheckInRecord.check_in_time >= today_start,
|
||||
CheckInRecord.status == "out_of_time"
|
||||
).count()
|
||||
today_out_of_time = (
|
||||
db.query(CheckInRecord)
|
||||
.filter(
|
||||
CheckInRecord.check_in_time >= today_start, CheckInRecord.status == "out_of_time"
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
# 今日异常打卡数
|
||||
today_unknown = db.query(CheckInRecord).filter(
|
||||
CheckInRecord.check_in_time >= today_start,
|
||||
CheckInRecord.status == "unknown"
|
||||
).count()
|
||||
today_unknown = (
|
||||
db.query(CheckInRecord)
|
||||
.filter(CheckInRecord.check_in_time >= today_start, CheckInRecord.status == "unknown")
|
||||
.count()
|
||||
)
|
||||
|
||||
# Token 即将过期的用户数(7天内)
|
||||
# 使用 SQL 直接查询,避免 N+1 问题
|
||||
@@ -198,28 +197,32 @@ async def get_system_stats(
|
||||
# 条件:authorization 不为空、jwt_exp 不为 "0"、且在未来 7 天内过期
|
||||
from sqlalchemy import cast, Integer, and_
|
||||
|
||||
expiring_users = db.query(User).filter(
|
||||
and_(
|
||||
User.authorization.isnot(None),
|
||||
User.authorization != "",
|
||||
User.jwt_exp.isnot(None),
|
||||
User.jwt_exp != "0",
|
||||
cast(User.jwt_exp, Integer) > current_timestamp, # 未过期
|
||||
cast(User.jwt_exp, Integer) < expiring_soon_timestamp # 7天内过期
|
||||
expiring_users = (
|
||||
db.query(User)
|
||||
.filter(
|
||||
and_(
|
||||
User.authorization.isnot(None),
|
||||
User.authorization != "",
|
||||
User.jwt_exp.isnot(None),
|
||||
User.jwt_exp != "0",
|
||||
cast(User.jwt_exp, Integer) > current_timestamp, # 未过期
|
||||
cast(User.jwt_exp, Integer) < expiring_soon_timestamp, # 7天内过期
|
||||
)
|
||||
)
|
||||
).count()
|
||||
.count()
|
||||
)
|
||||
|
||||
return {
|
||||
"users": {
|
||||
"total": total_users,
|
||||
"admin": admin_users,
|
||||
"regular": total_users - admin_users,
|
||||
"active": approved_users # 使用已审批用户数
|
||||
"active": approved_users, # 使用已审批用户数
|
||||
},
|
||||
"tasks": {
|
||||
"total": total_tasks,
|
||||
"active": active_tasks,
|
||||
"inactive": total_tasks - active_tasks
|
||||
"inactive": total_tasks - active_tasks,
|
||||
},
|
||||
"check_in_records": {
|
||||
"total": total_records,
|
||||
@@ -227,24 +230,20 @@ async def get_system_stats(
|
||||
"today_success": today_success,
|
||||
"today_failure": today_failure,
|
||||
"today_out_of_time": today_out_of_time,
|
||||
"today_unknown": today_unknown
|
||||
"today_unknown": today_unknown,
|
||||
},
|
||||
"tokens": {
|
||||
"expiring_soon": expiring_users
|
||||
}
|
||||
"tokens": {"expiring_soon": expiring_users},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取统计失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取统计失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users/pending", response_model=List[UserResponse], summary="获取待审批用户")
|
||||
async def get_pending_users(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
获取所有待审批的用户(需要管理员权限)
|
||||
@@ -255,7 +254,7 @@ async def get_pending_users(
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取待审批用户失败: {str(e)}"
|
||||
detail=f"获取待审批用户失败: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@@ -263,7 +262,7 @@ async def get_pending_users(
|
||||
async def approve_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
审批通过指定用户(需要管理员权限)
|
||||
@@ -272,18 +271,14 @@ async def approve_user(
|
||||
result = AdminService.approve_user(user_id, db)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result["message"]
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=result["message"])
|
||||
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"审批用户失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"审批用户失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -291,7 +286,7 @@ async def approve_user(
|
||||
async def reject_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
拒绝并删除指定用户(需要管理员权限)
|
||||
@@ -300,16 +295,12 @@ async def reject_user(
|
||||
result = AdminService.reject_user(user_id, db)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result["message"]
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=result["message"])
|
||||
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"拒绝用户失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"拒绝用户失败: {str(e)}"
|
||||
)
|
||||
|
||||
+12
-28
@@ -21,10 +21,7 @@ router = APIRouter()
|
||||
@router.post("/request_qrcode", response_model=dict, summary="请求 QQ 扫码二维码")
|
||||
@limiter.limit("10/minute") # 每分钟最多10次请求
|
||||
async def request_qrcode(
|
||||
request_obj: QRCodeRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db)
|
||||
request_obj: QRCodeRequest, request: Request, response: Response, db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
请求 QQ 扫码二维码
|
||||
@@ -44,7 +41,7 @@ async def request_qrcode(
|
||||
raise BusinessLogicError(
|
||||
message="注册过于频繁,请 10 分钟后再试",
|
||||
error_code="RATE_LIMIT_EXCEEDED",
|
||||
status_code=429
|
||||
status_code=429,
|
||||
)
|
||||
else:
|
||||
# 生成新的 Cookie
|
||||
@@ -67,22 +64,18 @@ async def request_qrcode(
|
||||
value=reg_cookie,
|
||||
max_age=600, # 10 分钟
|
||||
httponly=True,
|
||||
samesite="lax"
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"创建扫码会话失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"创建扫码会话失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/qrcode_status/{session_id}", response_model=dict, summary="检查二维码扫描状态")
|
||||
async def get_qrcode_status(
|
||||
session_id: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
async def get_qrcode_status(session_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
检查二维码扫描状态
|
||||
|
||||
@@ -104,15 +97,12 @@ async def get_qrcode_status(
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"查询扫码状态失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"查询扫码状态失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/qrcode_session/{session_id}", response_model=dict, summary="取消二维码登录会话")
|
||||
async def cancel_qrcode_session(
|
||||
session_id: str
|
||||
):
|
||||
async def cancel_qrcode_session(session_id: str):
|
||||
"""
|
||||
取消二维码登录会话
|
||||
|
||||
@@ -125,16 +115,12 @@ async def cancel_qrcode_session(
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"取消会话失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"取消会话失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify_token", response_model=dict, summary="验证 JWT Token 有效性")
|
||||
async def verify_token(
|
||||
request: TokenVerifyRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
async def verify_token(request: TokenVerifyRequest, db: Session = Depends(get_db)):
|
||||
"""
|
||||
验证 JWT Token 有效性(网站登录认证)
|
||||
|
||||
@@ -152,8 +138,7 @@ async def verify_token(
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"验证 Token 失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"验证 Token 失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -162,7 +147,7 @@ async def verify_token(
|
||||
async def alias_login(
|
||||
login_data: AliasLoginRequest,
|
||||
request: Request, # slowapi需要的request参数
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
别名+密码登录(仅限已设置密码的用户)
|
||||
@@ -187,6 +172,5 @@ async def alias_login(
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"别名登录失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"别名登录失败: {str(e)}"
|
||||
)
|
||||
|
||||
@@ -18,9 +18,7 @@ router = APIRouter()
|
||||
|
||||
@router.post("/manual/{task_id}", summary="手动触发打卡(异步)")
|
||||
async def manual_check_in(
|
||||
task_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
task_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
手动触发指定任务的打卡(异步方式,立即返回)
|
||||
@@ -31,33 +29,24 @@ async def manual_check_in(
|
||||
"""
|
||||
# 验证任务归属
|
||||
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问此任务"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此任务")
|
||||
|
||||
task = TaskService.get_task(task_id, db)
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="任务不存在"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在")
|
||||
|
||||
try:
|
||||
result = CheckInService.start_async_check_in(task, "manual", db)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"启动打卡任务失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"启动打卡任务失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/record/{record_id}/status", summary="查询打卡记录状态")
|
||||
async def get_check_in_record_status(
|
||||
record_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
record_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
查询指定打卡记录的状态
|
||||
@@ -73,10 +62,7 @@ async def get_check_in_record_status(
|
||||
|
||||
# 验证记录归属(通过任务归属)
|
||||
if not TaskService.verify_task_ownership(record.task_id, current_user.id, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问此记录"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此记录")
|
||||
|
||||
return {
|
||||
"record_id": record.id,
|
||||
@@ -85,19 +71,25 @@ async def get_check_in_record_status(
|
||||
"response_text": record.response_text,
|
||||
"error_message": record.error_message,
|
||||
"trigger_type": record.trigger_type,
|
||||
"check_in_time": record.check_in_time
|
||||
"check_in_time": record.check_in_time,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/task/{task_id}/records", response_model=PaginatedResponse[CheckInRecordResponse], summary="查看任务的打卡记录")
|
||||
@router.get(
|
||||
"/task/{task_id}/records",
|
||||
response_model=PaginatedResponse[CheckInRecordResponse],
|
||||
summary="查看任务的打卡记录",
|
||||
)
|
||||
async def get_task_check_in_records(
|
||||
task_id: int,
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
|
||||
status_filter: Optional[str] = Query(None, alias="status", description="过滤状态 (success/failure)"),
|
||||
status_filter: Optional[str] = Query(
|
||||
None, alias="status", description="过滤状态 (success/failure)"
|
||||
),
|
||||
trigger_type: Optional[str] = Query(None, description="过滤触发类型 (scheduler/manual)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
查看指定任务的打卡记录
|
||||
@@ -112,36 +104,33 @@ async def get_task_check_in_records(
|
||||
"""
|
||||
# 验证任务归属
|
||||
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问此任务"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此任务")
|
||||
|
||||
try:
|
||||
records, total = CheckInService.get_task_records(
|
||||
task_id, db, skip, limit, status_filter, trigger_type
|
||||
)
|
||||
return PaginatedResponse(
|
||||
records=records,
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
return PaginatedResponse(records=records, total=total, skip=skip, limit=limit)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取打卡记录失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取打卡记录失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/my-records", response_model=PaginatedResponse[CheckInRecordResponse], summary="查看当前用户的所有打卡记录")
|
||||
@router.get(
|
||||
"/my-records",
|
||||
response_model=PaginatedResponse[CheckInRecordResponse],
|
||||
summary="查看当前用户的所有打卡记录",
|
||||
)
|
||||
async def get_my_check_in_records(
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
|
||||
status_filter: Optional[str] = Query(None, alias="status", description="过滤状态 (success/failure)"),
|
||||
status_filter: Optional[str] = Query(
|
||||
None, alias="status", description="过滤状态 (success/failure)"
|
||||
),
|
||||
trigger_type: Optional[str] = Query(None, description="过滤触发类型 (scheduler/manual)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
查看当前用户所有任务的打卡记录
|
||||
@@ -155,28 +144,27 @@ async def get_my_check_in_records(
|
||||
records, total = CheckInService.get_user_records(
|
||||
current_user.id, db, skip, limit, status_filter, trigger_type
|
||||
)
|
||||
return PaginatedResponse(
|
||||
records=records,
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
return PaginatedResponse(records=records, total=total, skip=skip, limit=limit)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取打卡记录失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取打卡记录失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get("/records", response_model=PaginatedResponse[CheckInRecordResponse], summary="查看所有打卡记录(管理员)")
|
||||
@router.get(
|
||||
"/records",
|
||||
response_model=PaginatedResponse[CheckInRecordResponse],
|
||||
summary="查看所有打卡记录(管理员)",
|
||||
)
|
||||
async def get_all_check_in_records(
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
|
||||
task_id: Optional[int] = Query(None, description="过滤任务 ID"),
|
||||
status_filter: Optional[str] = Query(None, alias="status", description="过滤状态 (success/failure)"),
|
||||
status_filter: Optional[str] = Query(
|
||||
None, alias="status", description="过滤状态 (success/failure)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
查看所有打卡记录(需要管理员权限)
|
||||
@@ -189,26 +177,24 @@ async def get_all_check_in_records(
|
||||
try:
|
||||
records, total = CheckInService.get_all_records(db, skip, limit, task_id, status_filter)
|
||||
# 为每条记录添加用户和任务信息
|
||||
enriched_records = [CheckInService.enrich_record_with_user_task_info(record, db) for record in records]
|
||||
return PaginatedResponse(
|
||||
records=enriched_records,
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
enriched_records = [
|
||||
CheckInService.enrich_record_with_user_task_info(record, db) for record in records
|
||||
]
|
||||
return PaginatedResponse(records=enriched_records, total=total, skip=skip, limit=limit)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取打卡记录失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取打卡记录失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/records/count", summary="获取打卡记录统计(管理员)")
|
||||
async def get_check_in_records_count(
|
||||
task_id: Optional[int] = Query(None, description="过滤任务 ID"),
|
||||
status_filter: Optional[str] = Query(None, alias="status", description="过滤状态 (success/failure)"),
|
||||
status_filter: Optional[str] = Query(
|
||||
None, alias="status", description="过滤状态 (success/failure)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
获取打卡记录统计(需要管理员权限)
|
||||
@@ -229,6 +215,5 @@ async def get_check_in_records_count(
|
||||
return {"total": total}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取统计失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取统计失败: {str(e)}"
|
||||
)
|
||||
|
||||
+19
-34
@@ -14,15 +14,18 @@ router = APIRouter()
|
||||
|
||||
class CronValidateRequest(BaseModel):
|
||||
"""Cron 表达式验证请求"""
|
||||
|
||||
cron_expression: str = Field(..., min_length=9, description="Crontab 表达式")
|
||||
|
||||
|
||||
# create_task_from_template: 已在 templates.py 中定义
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TaskResponse], summary="获取当前用户的任务列表")
|
||||
async def get_tasks(
|
||||
include_inactive: bool = True,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取当前用户的所有打卡任务
|
||||
@@ -36,16 +39,13 @@ async def get_tasks(
|
||||
return enriched_tasks
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取任务列表失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取任务列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskResponse, summary="获取任务详情")
|
||||
async def get_task(
|
||||
task_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
task_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取指定任务的详情
|
||||
@@ -66,7 +66,7 @@ async def update_task(
|
||||
task_id: int,
|
||||
task_data: TaskUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新指定任务的信息
|
||||
@@ -82,19 +82,14 @@ async def update_task(
|
||||
task = TaskService.update_task(task_id, task_data, db)
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="任务不存在"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在")
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT, summary="删除任务")
|
||||
async def delete_task(
|
||||
task_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
task_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
删除指定任务
|
||||
@@ -110,17 +105,12 @@ async def delete_task(
|
||||
success = TaskService.delete_task(task_id, db)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="任务不存在"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在")
|
||||
|
||||
|
||||
@router.post("/{task_id}/toggle", response_model=TaskResponse, summary="切换任务启用状态")
|
||||
async def toggle_task(
|
||||
task_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
task_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
切换任务的启用/禁用状态
|
||||
@@ -136,10 +126,7 @@ async def toggle_task(
|
||||
task = TaskService.toggle_task(task_id, db)
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="任务不存在"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在")
|
||||
|
||||
return task
|
||||
|
||||
@@ -167,8 +154,7 @@ async def validate_cron_expression(request: CronValidateRequest):
|
||||
|
||||
if not cron_expr:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="cron_expression 是必需的"
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="cron_expression 是必需的"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -179,18 +165,17 @@ async def validate_cron_expression(request: CronValidateRequest):
|
||||
|
||||
# 生成接下来的 5 个执行时间
|
||||
cron = croniter(cron_expr, datetime.now())
|
||||
next_times = [cron.get_next(datetime).strftime('%Y-%m-%d %H:%M:%S') for _ in range(5)]
|
||||
next_times = [cron.get_next(datetime).strftime("%Y-%m-%d %H:%M:%S") for _ in range(5)]
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"message": "有效的 Crontab 表达式",
|
||||
"next_times": next_times,
|
||||
"description": generate_cron_description(cron_expr)
|
||||
"description": generate_cron_description(cron_expr),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效的 Crontab 表达式: {str(e)}"
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"无效的 Crontab 表达式: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -203,11 +188,11 @@ def generate_cron_description(cron_expr: str) -> str:
|
||||
minute, hour, day, month, dow = parts
|
||||
|
||||
descriptions = []
|
||||
if hour == '*' and minute == '*':
|
||||
if hour == "*" and minute == "*":
|
||||
descriptions.append("每分钟")
|
||||
elif hour == '*':
|
||||
elif hour == "*":
|
||||
descriptions.append(f"每小时的第 {minute} 分钟")
|
||||
elif day == '*' and month == '*' and dow == '*':
|
||||
elif day == "*" and month == "*" and dow == "*":
|
||||
descriptions.append(f"每天 {hour}:{minute:0>2}")
|
||||
else:
|
||||
descriptions.append(f"复杂的时间表: {cron_expr}")
|
||||
|
||||
@@ -9,7 +9,7 @@ from backend.schemas.template import (
|
||||
TemplateUpdate,
|
||||
TemplateResponse,
|
||||
TaskFromTemplateRequest,
|
||||
TemplatePreviewResponse
|
||||
TemplatePreviewResponse,
|
||||
)
|
||||
from backend.schemas.task import TaskResponse
|
||||
from backend.services.template_service import TemplateService
|
||||
@@ -23,7 +23,7 @@ async def get_all_templates(
|
||||
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
|
||||
is_active: Optional[bool] = Query(None, description="过滤启用状态"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取所有模板列表(普通用户可访问)
|
||||
@@ -37,8 +37,7 @@ async def get_all_templates(
|
||||
return templates
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取模板列表失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取模板列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -47,7 +46,7 @@ async def get_active_templates(
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取所有启用的模板(用户创建任务时使用)
|
||||
@@ -60,16 +59,13 @@ async def get_active_templates(
|
||||
return templates
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取模板列表失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取模板列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{template_id}", response_model=TemplateResponse, summary="获取单个模板详情")
|
||||
async def get_template(
|
||||
template_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
template_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取单个模板的详细信息(普通用户只能访问启用的模板)
|
||||
@@ -78,26 +74,22 @@ async def get_template(
|
||||
"""
|
||||
template = TemplateService.get_template(template_id, db)
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="模板不存在"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
# 普通用户只能访问启用的模板
|
||||
if not current_user.is_admin and template.is_active is not True:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问此模板"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此模板")
|
||||
|
||||
return template
|
||||
|
||||
|
||||
@router.get("/{template_id}/preview", response_model=TemplatePreviewResponse, summary="预览模板生成的 payload")
|
||||
@router.get(
|
||||
"/{template_id}/preview",
|
||||
response_model=TemplatePreviewResponse,
|
||||
summary="预览模板生成的 payload",
|
||||
)
|
||||
async def preview_template(
|
||||
template_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
template_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
预览模板生成的 payload(使用默认值,普通用户只能访问启用的模板)
|
||||
@@ -106,17 +98,11 @@ async def preview_template(
|
||||
"""
|
||||
template = TemplateService.get_template(template_id, db)
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="模板不存在"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
# 普通用户只能访问启用的模板
|
||||
if not current_user.is_admin and template.is_active is not True:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问此模板"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此模板")
|
||||
|
||||
try:
|
||||
preview_payload = TemplateService.generate_preview_payload(template, db)
|
||||
@@ -127,12 +113,11 @@ async def preview_template(
|
||||
"template_id": template.id,
|
||||
"template_name": template.name,
|
||||
"preview_payload": preview_payload,
|
||||
"field_config": merged_config
|
||||
"field_config": merged_config,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"生成预览失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"生成预览失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -140,7 +125,7 @@ async def preview_template(
|
||||
async def create_template(
|
||||
template_data: TemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
创建新的打卡任务模板(仅管理员)
|
||||
@@ -158,7 +143,7 @@ async def update_template(
|
||||
template_id: int,
|
||||
template_data: TemplateUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
更新模板信息(仅管理员)
|
||||
@@ -176,7 +161,7 @@ async def update_template(
|
||||
async def delete_template(
|
||||
template_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
删除模板(仅管理员)
|
||||
@@ -191,7 +176,7 @@ async def delete_template(
|
||||
async def create_task_from_template(
|
||||
request: TaskFromTemplateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
从模板创建打卡任务
|
||||
@@ -209,6 +194,6 @@ async def create_task_from_template(
|
||||
user_id=current_user.id,
|
||||
task_name=request.task_name,
|
||||
db=db,
|
||||
cron_expression=request.cron_expression
|
||||
cron_expression=request.cron_expression,
|
||||
)
|
||||
return task
|
||||
|
||||
+36
-40
@@ -3,7 +3,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import get_db, User
|
||||
from backend.schemas.user import UserCreate, UserUpdate, UserResponse, TokenStatus, UserUpdateProfile
|
||||
from backend.schemas.user import (
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
UserResponse,
|
||||
TokenStatus,
|
||||
UserUpdateProfile,
|
||||
)
|
||||
from backend.schemas.task import TaskResponse
|
||||
from backend.services.user_service import UserService
|
||||
from backend.services.task_service import TaskService
|
||||
@@ -13,11 +19,16 @@ from backend.exceptions import ValidationError, AuthorizationError, ResourceNotF
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED, summary="创建用户(管理员)")
|
||||
@router.post(
|
||||
"",
|
||||
response_model=UserResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="创建用户(管理员)",
|
||||
)
|
||||
async def create_user(
|
||||
user_data: UserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
创建用户(需要管理员权限)
|
||||
@@ -33,15 +44,12 @@ async def create_user(
|
||||
raise ValidationError(str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"创建用户失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"创建用户失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse, summary="获取当前用户信息")
|
||||
async def get_current_user_info(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
async def get_current_user_info(current_user: User = Depends(get_current_user)):
|
||||
"""
|
||||
获取当前登录用户的信息
|
||||
"""
|
||||
@@ -61,9 +69,7 @@ async def get_current_user_info(
|
||||
|
||||
|
||||
@router.get("/me/status", response_model=dict, summary="获取当前用户审批状态")
|
||||
async def get_user_status(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
async def get_user_status(current_user: User = Depends(get_current_user)):
|
||||
"""
|
||||
获取用户审批状态(不要求审批通过)
|
||||
"""
|
||||
@@ -71,7 +77,7 @@ async def get_user_status(
|
||||
"user_id": current_user.id,
|
||||
"alias": current_user.alias,
|
||||
"is_approved": current_user.is_approved,
|
||||
"created_at": current_user.created_at.isoformat() if current_user.created_at else None
|
||||
"created_at": current_user.created_at.isoformat() if current_user.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +85,7 @@ async def get_user_status(
|
||||
async def update_current_user_profile(
|
||||
profile_data: UserUpdateProfile,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
更新当前用户的个人信息
|
||||
@@ -99,15 +105,12 @@ async def update_current_user_profile(
|
||||
raise ValidationError(str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"更新个人信息失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"更新个人信息失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/token_status", response_model=TokenStatus, summary="获取当前用户打卡 Token 状态")
|
||||
async def get_current_user_token_status(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
async def get_current_user_token_status(current_user: User = Depends(get_current_user)):
|
||||
"""
|
||||
获取当前用户的打卡 Token 状态(authorization token,非 JWT)
|
||||
|
||||
@@ -123,7 +126,7 @@ async def get_current_user_token_status(
|
||||
"jwt_exp": current_user.jwt_exp,
|
||||
"expires_at": result.get("expires_at"),
|
||||
"days_until_expiry": result.get("days_remaining"),
|
||||
"expiring_soon": result.get("expiring_soon", False)
|
||||
"expiring_soon": result.get("expiring_soon", False),
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +134,7 @@ async def get_current_user_token_status(
|
||||
async def get_current_user_tasks(
|
||||
include_inactive: bool = Query(True, description="是否包含未启用的任务"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取当前登录用户的所有打卡任务
|
||||
@@ -143,8 +146,7 @@ async def get_current_user_tasks(
|
||||
return tasks
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取任务列表失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取任务列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -155,7 +157,7 @@ async def get_all_users(
|
||||
search: Optional[str] = Query(None, description="搜索关键词(alias)"),
|
||||
role: Optional[str] = Query(None, description="过滤角色 (user/admin)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
获取所有用户列表(需要管理员权限)
|
||||
@@ -170,16 +172,13 @@ async def get_all_users(
|
||||
return users
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取用户列表失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取用户列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse, summary="获取指定用户")
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
user_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取指定用户信息
|
||||
@@ -203,7 +202,7 @@ async def update_user(
|
||||
user_id: int,
|
||||
user_data: UserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
更新用户信息
|
||||
@@ -236,28 +235,26 @@ async def update_user(
|
||||
new_approved_value = user.is_approved
|
||||
is_approved_now = True if new_approved_value else False
|
||||
|
||||
is_admin = (current_user.role == "admin")
|
||||
needs_notification = (is_admin and (not was_approved_before) and is_approved_now)
|
||||
is_admin = current_user.role == "admin"
|
||||
needs_notification = is_admin and (not was_approved_before) and is_approved_now
|
||||
|
||||
if needs_notification:
|
||||
try:
|
||||
from backend.services.email_service import EmailService
|
||||
|
||||
EmailService.notify_user_approved(user)
|
||||
except Exception as e:
|
||||
# 邮件发送失败不影响审批操作
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).error(f"发送审批通过邮件失败: {e}")
|
||||
|
||||
return user
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"更新用户失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"更新用户失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -265,7 +262,7 @@ async def update_user(
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
删除用户(需要管理员权限)
|
||||
@@ -277,6 +274,5 @@ async def delete_user(
|
||||
raise ResourceNotFoundError(str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"删除用户失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"删除用户失败: {str(e)}"
|
||||
)
|
||||
|
||||
@@ -11,9 +11,9 @@ class Settings(BaseSettings):
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=str(BASE_DIR / ".env"),
|
||||
env_file_encoding='utf-8',
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True,
|
||||
extra='ignore'
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# 项目根目录
|
||||
|
||||
@@ -11,8 +11,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
authorization: Optional[str] = Header(None),
|
||||
db: Session = Depends(get_db)
|
||||
authorization: Optional[str] = Header(None), db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""
|
||||
获取当前用户(使用 JWT 认证)
|
||||
@@ -30,7 +29,11 @@ async def get_current_user(
|
||||
)
|
||||
|
||||
# 移除 "Bearer " 前缀(如果存在)
|
||||
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
|
||||
token = (
|
||||
authorization.replace("Bearer ", "")
|
||||
if authorization.startswith("Bearer ")
|
||||
else authorization
|
||||
)
|
||||
|
||||
try:
|
||||
# 验证 JWT token
|
||||
@@ -77,39 +80,33 @@ async def get_current_user(
|
||||
)
|
||||
|
||||
|
||||
async def require_approved_user(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
async def require_approved_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""
|
||||
要求用户已通过审批
|
||||
"""
|
||||
if not current_user.is_approved:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="您的账户正在等待管理员审批,请耐心等待(24小时内)"
|
||||
detail="您的账户正在等待管理员审批,请耐心等待(24小时内)",
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_current_admin_user(
|
||||
current_user: User = Depends(require_approved_user)
|
||||
) -> User:
|
||||
async def get_current_admin_user(current_user: User = Depends(require_approved_user)) -> User:
|
||||
"""
|
||||
获取当前管理员用户
|
||||
验证用户是否具有管理员权限
|
||||
"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="权限不足,需要管理员权限"
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="权限不足,需要管理员权限"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
authorization: Optional[str] = Header(None),
|
||||
db: Session = Depends(get_db)
|
||||
authorization: Optional[str] = Header(None), db: Session = Depends(get_db)
|
||||
) -> Optional[User]:
|
||||
"""
|
||||
可选的用户认证
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
支持Cloudflare Tunnel和其他代理服务
|
||||
"""
|
||||
|
||||
from slowapi import Limiter
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
+10
-16
@@ -44,6 +44,7 @@ async def lifespan(app: FastAPI):
|
||||
# 启动调度器
|
||||
logger.info("正在启动调度器...")
|
||||
from backend.services.scheduler_service import start_scheduler
|
||||
|
||||
start_scheduler()
|
||||
|
||||
logger.info(f"CheckIn API 服务已启动,版本: {settings.VERSION}")
|
||||
@@ -53,6 +54,7 @@ async def lifespan(app: FastAPI):
|
||||
# 关闭时执行
|
||||
logger.info("正在关闭 CheckIn API 服务...")
|
||||
from backend.services.scheduler_service import stop_scheduler
|
||||
|
||||
stop_scheduler()
|
||||
logger.info("CheckIn API 服务已关闭")
|
||||
|
||||
@@ -85,11 +87,8 @@ async def api_exception_handler(request: Request, exc: BaseAPIException):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code=exc.error_code,
|
||||
message=exc.message
|
||||
)
|
||||
).model_dump()
|
||||
error=ErrorDetail(code=exc.error_code, message=exc.message)
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@@ -105,12 +104,8 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content=ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code="VALIDATION_ERROR",
|
||||
message=message,
|
||||
field=field or None
|
||||
)
|
||||
).model_dump()
|
||||
error=ErrorDetail(code="VALIDATION_ERROR", message=message, field=field or None)
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@@ -123,11 +118,8 @@ async def general_exception_handler(request: Request, exc: Exception):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code="INTERNAL_ERROR",
|
||||
message="服务器内部错误,请稍后重试"
|
||||
)
|
||||
).model_dump()
|
||||
error=ErrorDetail(code="INTERNAL_ERROR", message="服务器内部错误,请稍后重试")
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@@ -156,6 +148,7 @@ async def root():
|
||||
|
||||
# 注册路由
|
||||
from backend.api import auth, users, check_in, admin, tasks, templates
|
||||
|
||||
app.include_router(auth.router, prefix=f"{settings.API_PREFIX}/auth", tags=["认证"])
|
||||
app.include_router(users.router, prefix=f"{settings.API_PREFIX}/users", tags=["用户"])
|
||||
app.include_router(tasks.router, prefix=f"{settings.API_PREFIX}/tasks", tags=["打卡任务"])
|
||||
@@ -166,6 +159,7 @@ app.include_router(templates.router, prefix=f"{settings.API_PREFIX}/templates",
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"backend.main:app",
|
||||
host="0.0.0.0",
|
||||
|
||||
@@ -10,21 +10,39 @@ class CheckInRecord(Base):
|
||||
__tablename__ = "check_in_records"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
task_id = Column(Integer, ForeignKey("check_in_tasks.id", ondelete="CASCADE"), nullable=False, index=True, comment="任务 ID")
|
||||
status = Column(String(20), nullable=False, index=True, comment="状态: success/failure/out_of_time/unknown/pending")
|
||||
task_id = Column(
|
||||
Integer,
|
||||
ForeignKey("check_in_tasks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="任务 ID",
|
||||
)
|
||||
status = Column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="状态: success/failure/out_of_time/unknown/pending",
|
||||
)
|
||||
response_text = Column(Text, default="", comment="响应文本")
|
||||
error_message = Column(Text, default="", comment="错误信息")
|
||||
location = Column(Text, default="{}", comment="位置信息 JSON")
|
||||
trigger_type = Column(String(50), default="scheduled", comment="触发类型: scheduled/manual/admin")
|
||||
check_in_time = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True, comment="打卡时间(UTC)")
|
||||
trigger_type = Column(
|
||||
String(50), default="scheduled", comment="触发类型: scheduled/manual/admin"
|
||||
)
|
||||
check_in_time = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
index=True,
|
||||
comment="打卡时间(UTC)",
|
||||
)
|
||||
|
||||
# 关联任务
|
||||
task = relationship("CheckInTask", back_populates="check_in_records")
|
||||
|
||||
# 添加复合索引:加速常见查询
|
||||
__table_args__ = (
|
||||
Index('ix_record_task_time', 'task_id', 'check_in_time'), # 获取任务的打卡记录(按时间排序)
|
||||
Index('ix_record_status_time', 'status', 'check_in_time'), # 按状态和时间查询
|
||||
Index("ix_record_task_time", "task_id", "check_in_time"), # 获取任务的打卡记录(按时间排序)
|
||||
Index("ix_record_status_time", "status", "check_in_time"), # 按状态和时间查询
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
@@ -10,11 +10,27 @@ class CheckInTask(Base):
|
||||
__tablename__ = "check_in_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, comment="用户 ID")
|
||||
payload_config = Column(Text, default="{}", nullable=False, comment="完整的 payload 配置 JSON(从模板生成,包含 ThreadId 和所有字段)")
|
||||
user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="用户 ID",
|
||||
)
|
||||
payload_config = Column(
|
||||
Text,
|
||||
default="{}",
|
||||
nullable=False,
|
||||
comment="完整的 payload 配置 JSON(从模板生成,包含 ThreadId 和所有字段)",
|
||||
)
|
||||
name = Column(String(100), default="", comment="任务名称(用户自定义)")
|
||||
is_active = Column(Boolean, default=True, comment="是否启用自动打卡(不影响手动打卡)")
|
||||
cron_expression = Column(String(100), default="0 20 * * *", nullable=True, comment="Crontab 表达式(NULL 表示禁用自动打卡,否则按表达式执行)")
|
||||
cron_expression = Column(
|
||||
String(100),
|
||||
default="0 20 * * *",
|
||||
nullable=True,
|
||||
comment="Crontab 表达式(NULL 表示禁用自动打卡,否则按表达式执行)",
|
||||
)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
@@ -22,12 +38,14 @@ class CheckInTask(Base):
|
||||
user = relationship("User", back_populates="tasks")
|
||||
|
||||
# 关联打卡记录
|
||||
check_in_records = relationship("CheckInRecord", back_populates="task", cascade="all, delete-orphan")
|
||||
check_in_records = relationship(
|
||||
"CheckInRecord", back_populates="task", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# 添加索引:加速查询
|
||||
__table_args__ = (
|
||||
Index('ix_task_user_active', 'user_id', 'is_active'),
|
||||
Index('ix_task_cron', 'cron_expression'), # 加速查询启用了定时打卡的任务
|
||||
Index("ix_task_user_active", "user_id", "is_active"),
|
||||
Index("ix_task_cron", "cron_expression"), # 加速查询启用了定时打卡的任务
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
@@ -24,7 +24,7 @@ def receive_load(target, context):
|
||||
"""在从数据库加载对象后,将所有 datetime 字段转换为 timezone-aware (UTC)"""
|
||||
for attr_name in dir(target):
|
||||
# 跳过私有属性和方法
|
||||
if attr_name.startswith('_'):
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
|
||||
try:
|
||||
|
||||
@@ -6,6 +6,7 @@ from backend.models.database import Base
|
||||
|
||||
class TaskTemplate(Base):
|
||||
"""打卡任务模板"""
|
||||
|
||||
__tablename__ = "task_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
@@ -13,7 +14,12 @@ class TaskTemplate(Base):
|
||||
description = Column(Text, nullable=True, comment="模板描述")
|
||||
|
||||
# 父模板 ID(用于继承)
|
||||
parent_id = Column(Integer, ForeignKey("task_templates.id", ondelete="SET NULL"), nullable=True, comment="父模板 ID")
|
||||
parent_id = Column(
|
||||
Integer,
|
||||
ForeignKey("task_templates.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
comment="父模板 ID",
|
||||
)
|
||||
|
||||
# 字段配置(JSON 格式)
|
||||
field_config = Column(Text, nullable=False, comment="字段配置(JSON)")
|
||||
|
||||
@@ -10,21 +10,41 @@ class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
jwt_sub = Column(String(200), unique=True, nullable=True, index=True, comment="QQ 扫码登录的唯一用户标识(注册时为空)")
|
||||
alias = Column(String(50), unique=True, nullable=False, index=True, comment="用户别名(用于登录)")
|
||||
jwt_sub = Column(
|
||||
String(200),
|
||||
unique=True,
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="QQ 扫码登录的唯一用户标识(注册时为空)",
|
||||
)
|
||||
alias = Column(
|
||||
String(50), unique=True, nullable=False, index=True, comment="用户别名(用于登录)"
|
||||
)
|
||||
email = Column(String(100), nullable=True, comment="用户邮箱(用于接收通知)")
|
||||
password_hash = Column(String(200), nullable=True, comment="密码哈希(bcrypt加密)")
|
||||
authorization = Column(Text, nullable=True, comment="当前有效的 QQ Token")
|
||||
jwt_exp = Column(String(20), default="0", comment="Token 过期时间戳")
|
||||
token_expiring_notified = Column(Boolean, default=False, nullable=False, comment="Token 即将过期提醒是否已发送(过期前30分钟)")
|
||||
token_expired_notified = Column(Boolean, default=False, nullable=False, comment="Token 已过期提醒是否已发送(过期后30分钟内)")
|
||||
token_expiring_notified = Column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False,
|
||||
comment="Token 即将过期提醒是否已发送(过期前30分钟)",
|
||||
)
|
||||
token_expired_notified = Column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False,
|
||||
comment="Token 已过期提醒是否已发送(过期后30分钟内)",
|
||||
)
|
||||
role = Column(String(20), default="user", index=True, comment="角色: user/admin")
|
||||
is_approved = Column(Boolean, default=False, index=True, comment="是否已通过管理员审批")
|
||||
|
||||
# 账户锁定相关字段
|
||||
failed_login_attempts = Column(Integer, default=0, nullable=False, comment="连续登录失败次数")
|
||||
locked_until = Column(DateTime(timezone=True), nullable=True, comment="账户锁定到期时间")
|
||||
last_failed_login = Column(DateTime(timezone=True), nullable=True, comment="最后一次登录失败时间")
|
||||
last_failed_login = Column(
|
||||
DateTime(timezone=True), nullable=True, comment="最后一次登录失败时间"
|
||||
)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), comment="更新时间")
|
||||
@@ -34,7 +54,7 @@ class User(Base):
|
||||
|
||||
# 添加复合索引:加速审批管理查询
|
||||
__table_args__ = (
|
||||
Index('ix_user_role_approved', 'role', 'is_approved'), # 管理员查询待审批用户
|
||||
Index("ix_user_role_approved", "role", "is_approved"), # 管理员查询待审批用户
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
@@ -4,17 +4,20 @@ from pydantic import BaseModel, Field
|
||||
|
||||
class QRCodeRequest(BaseModel):
|
||||
"""请求二维码 Schema"""
|
||||
|
||||
alias: str = Field(..., description="用户别名")
|
||||
|
||||
|
||||
class QRCodeResponse(BaseModel):
|
||||
"""二维码响应 Schema"""
|
||||
|
||||
session_id: str = Field(..., description="会话 ID")
|
||||
qrcode_image: str = Field(..., description="二维码 Base64 图片")
|
||||
|
||||
|
||||
class QRCodeStatusResponse(BaseModel):
|
||||
"""二维码状态响应 Schema"""
|
||||
|
||||
status: str = Field(..., description="状态: pending/waiting_scan/success/error")
|
||||
message: Optional[str] = Field(None, description="状态消息")
|
||||
user_id: Optional[int] = Field(None, description="用户 ID (扫码成功时返回)")
|
||||
@@ -24,11 +27,13 @@ class QRCodeStatusResponse(BaseModel):
|
||||
|
||||
class TokenVerifyRequest(BaseModel):
|
||||
"""Token 验证请求 Schema"""
|
||||
|
||||
authorization: str = Field(..., description="Token")
|
||||
|
||||
|
||||
class TokenVerifyResponse(BaseModel):
|
||||
"""Token 验证响应 Schema"""
|
||||
|
||||
is_valid: bool = Field(..., description="Token 是否有效")
|
||||
message: str = Field(..., description="验证消息")
|
||||
user_id: Optional[int] = Field(None, description="用户 ID")
|
||||
@@ -36,12 +41,14 @@ class TokenVerifyResponse(BaseModel):
|
||||
|
||||
class AliasLoginRequest(BaseModel):
|
||||
"""别名+密码登录请求 Schema"""
|
||||
|
||||
alias: str = Field(..., min_length=2, max_length=50, description="用户别名")
|
||||
password: str = Field(..., min_length=6, description="密码")
|
||||
|
||||
|
||||
class AliasLoginResponse(BaseModel):
|
||||
"""别名+密码登录响应 Schema"""
|
||||
|
||||
success: bool = Field(..., description="登录是否成功")
|
||||
message: str = Field(..., description="登录消息")
|
||||
user_id: Optional[int] = Field(None, description="用户 ID")
|
||||
|
||||
@@ -2,21 +2,24 @@ from datetime import datetime
|
||||
from typing import Optional, List, Generic, TypeVar
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
T = TypeVar('T')
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ManualCheckInRequest(BaseModel):
|
||||
"""手动打卡请求 Schema(已废弃,现在使用路径参数 task_id)"""
|
||||
|
||||
task_id: Optional[int] = Field(None, description="任务 ID")
|
||||
|
||||
|
||||
class BatchCheckInRequest(BaseModel):
|
||||
"""批量打卡请求 Schema"""
|
||||
|
||||
task_ids: list[int] = Field(..., description="任务 ID 列表")
|
||||
|
||||
|
||||
class CheckInRecordResponse(BaseModel):
|
||||
"""打卡记录响应 Schema"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
@@ -37,6 +40,7 @@ class CheckInRecordResponse(BaseModel):
|
||||
|
||||
class CheckInRecordWithTaskInfo(CheckInRecordResponse):
|
||||
"""带任务信息的打卡记录响应 Schema"""
|
||||
|
||||
task_name: str
|
||||
task_signature: str
|
||||
user_alias: str
|
||||
@@ -44,6 +48,7 @@ class CheckInRecordWithTaskInfo(CheckInRecordResponse):
|
||||
|
||||
class CheckInResultResponse(BaseModel):
|
||||
"""打卡结果响应 Schema"""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
record_id: Optional[int] = None
|
||||
@@ -52,6 +57,7 @@ class CheckInResultResponse(BaseModel):
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
"""分页响应 Schema"""
|
||||
|
||||
records: List[T] = Field(..., description="记录列表")
|
||||
total: int = Field(..., description="总记录数")
|
||||
skip: int = Field(..., description="跳过的记录数")
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""
|
||||
统一的 API 响应 Schema
|
||||
"""
|
||||
|
||||
from typing import Generic, TypeVar, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
T = TypeVar('T')
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ApiResponse(BaseModel, Generic[T]):
|
||||
"""统一成功响应"""
|
||||
|
||||
success: bool = True
|
||||
data: Optional[T] = None
|
||||
message: Optional[str] = None
|
||||
@@ -17,6 +19,7 @@ class ApiResponse(BaseModel, Generic[T]):
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
"""错误详情"""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
field: Optional[str] = None # 字段验证错误时使用
|
||||
@@ -24,5 +27,6 @@ class ErrorDetail(BaseModel):
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""统一错误响应"""
|
||||
|
||||
success: bool = False
|
||||
error: ErrorDetail
|
||||
|
||||
@@ -5,11 +5,14 @@ from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
class TaskBase(BaseModel):
|
||||
"""打卡任务基础 Schema"""
|
||||
payload_config: str = Field(..., description="完整的 payload 配置 JSON(包含 ThreadId 和所有字段)")
|
||||
|
||||
payload_config: str = Field(
|
||||
..., description="完整的 payload 配置 JSON(包含 ThreadId 和所有字段)"
|
||||
)
|
||||
name: Optional[str] = Field("", max_length=100, description="任务名称(用户自定义)")
|
||||
is_active: Optional[bool] = Field(True, description="是否启用自动打卡")
|
||||
|
||||
@field_validator('payload_config')
|
||||
@field_validator("payload_config")
|
||||
@classmethod
|
||||
def validate_payload_config(cls, v: str) -> str:
|
||||
"""
|
||||
@@ -38,13 +41,14 @@ class TaskBase(BaseModel):
|
||||
|
||||
class TaskCreate(TaskBase):
|
||||
"""创建打卡任务 Schema"""
|
||||
|
||||
cron_expression: Optional[str] = Field(
|
||||
None,
|
||||
max_length=100,
|
||||
description="Crontab 表达式(例如 '0 20 * * *' 表示每天 20:00)。NULL 表示禁用定时打卡"
|
||||
description="Crontab 表达式(例如 '0 20 * * *' 表示每天 20:00)。NULL 表示禁用定时打卡",
|
||||
)
|
||||
|
||||
@field_validator('cron_expression')
|
||||
@field_validator("cron_expression")
|
||||
@classmethod
|
||||
def validate_cron_expression(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""验证 Crontab 表达式格式"""
|
||||
@@ -56,6 +60,7 @@ class TaskCreate(TaskBase):
|
||||
|
||||
try:
|
||||
from croniter import croniter
|
||||
|
||||
if not croniter.is_valid(v):
|
||||
raise ValueError(f"无效的 Crontab 表达式: '{v}'")
|
||||
except Exception as e:
|
||||
@@ -66,16 +71,15 @@ class TaskCreate(TaskBase):
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
"""更新打卡任务 Schema"""
|
||||
|
||||
payload_config: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
cron_expression: Optional[str] = Field(
|
||||
None,
|
||||
max_length=100,
|
||||
description="Crontab 表达式。NULL 表示禁用定时打卡"
|
||||
None, max_length=100, description="Crontab 表达式。NULL 表示禁用定时打卡"
|
||||
)
|
||||
|
||||
@field_validator('payload_config')
|
||||
@field_validator("payload_config")
|
||||
@classmethod
|
||||
def validate_payload_config(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
@@ -104,7 +108,7 @@ class TaskUpdate(BaseModel):
|
||||
|
||||
return v
|
||||
|
||||
@field_validator('cron_expression')
|
||||
@field_validator("cron_expression")
|
||||
@classmethod
|
||||
def validate_cron_expression(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""验证 Crontab 表达式(与 TaskCreate 相同)"""
|
||||
@@ -116,6 +120,7 @@ class TaskUpdate(BaseModel):
|
||||
|
||||
try:
|
||||
from croniter import croniter
|
||||
|
||||
if not croniter.is_valid(v):
|
||||
raise ValueError(f"无效的 Crontab 表达式: '{v}'")
|
||||
except Exception as e:
|
||||
@@ -126,18 +131,15 @@ class TaskUpdate(BaseModel):
|
||||
|
||||
class TaskResponse(TaskBase):
|
||||
"""打卡任务响应 Schema"""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
cron_expression: Optional[str] = Field(
|
||||
None,
|
||||
description="当前 Crontab 表达式(NULL = 禁用定时打卡)"
|
||||
)
|
||||
is_scheduled_enabled: Optional[bool] = Field(
|
||||
None,
|
||||
description="是否启用了定时打卡"
|
||||
None, description="当前 Crontab 表达式(NULL = 禁用定时打卡)"
|
||||
)
|
||||
is_scheduled_enabled: Optional[bool] = Field(None, description="是否启用了定时打卡")
|
||||
|
||||
# 新增字段:最后一次打卡信息
|
||||
last_check_in_time: Optional[datetime] = Field(None, description="最后一次打卡时间")
|
||||
|
||||
@@ -6,12 +6,14 @@ import json
|
||||
|
||||
class FieldOption(BaseModel):
|
||||
"""字段选项(用于 select 类型)"""
|
||||
|
||||
label: str = Field(..., description="选项显示文本")
|
||||
value: str = Field(..., description="选项值")
|
||||
|
||||
|
||||
class FieldConfigItem(BaseModel):
|
||||
"""单个字段配置项"""
|
||||
|
||||
display_name: str = Field(..., description="字段显示名称")
|
||||
field_type: str = Field(..., description="字段输入类型:text, textarea, number, select")
|
||||
default_value: str = Field(default="", description="默认值")
|
||||
@@ -21,33 +23,35 @@ class FieldConfigItem(BaseModel):
|
||||
value_type: str = Field(default="string", description="值类型:string, int, double")
|
||||
options: Optional[List[FieldOption]] = Field(None, description="选项列表(仅 select 类型)")
|
||||
|
||||
@field_validator('field_type')
|
||||
@field_validator("field_type")
|
||||
@classmethod
|
||||
def validate_field_type(cls, v):
|
||||
allowed_types = ['text', 'textarea', 'number', 'select']
|
||||
allowed_types = ["text", "textarea", "number", "select"]
|
||||
if v not in allowed_types:
|
||||
raise ValueError(f'field_type must be one of {allowed_types}')
|
||||
raise ValueError(f"field_type must be one of {allowed_types}")
|
||||
return v
|
||||
|
||||
@field_validator('value_type')
|
||||
@field_validator("value_type")
|
||||
@classmethod
|
||||
def validate_value_type(cls, v):
|
||||
allowed_types = ['string', 'int', 'double']
|
||||
allowed_types = ["string", "int", "double"]
|
||||
if v not in allowed_types:
|
||||
raise ValueError(f'value_type must be one of {allowed_types}')
|
||||
raise ValueError(f"value_type must be one of {allowed_types}")
|
||||
return v
|
||||
|
||||
|
||||
class FieldConfigValues(BaseModel):
|
||||
"""Values 字段的嵌套配置(如 location, temperature 等)"""
|
||||
|
||||
pass
|
||||
|
||||
class Config:
|
||||
extra = 'allow' # 允许任意字段
|
||||
extra = "allow" # 允许任意字段
|
||||
|
||||
|
||||
class FieldConfig(BaseModel):
|
||||
"""完整的字段配置"""
|
||||
|
||||
signature: Optional[FieldConfigItem] = None
|
||||
texts: Optional[FieldConfigItem] = None
|
||||
values: Optional[Dict[str, FieldConfigItem]] = Field(None, description="Values 字段的嵌套配置")
|
||||
@@ -55,13 +59,14 @@ class FieldConfig(BaseModel):
|
||||
|
||||
class TemplateBase(BaseModel):
|
||||
"""模板基础 Schema"""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100, description="模板名称")
|
||||
description: Optional[str] = Field(None, description="模板描述")
|
||||
parent_id: Optional[int] = Field(None, description="父模板 ID(用于继承)")
|
||||
field_config: Union[str, FieldConfig] = Field(..., description="字段配置(JSON 字符串或对象)")
|
||||
is_active: bool = Field(default=True, description="是否启用")
|
||||
|
||||
@field_validator('field_config')
|
||||
@field_validator("field_config")
|
||||
@classmethod
|
||||
def validate_field_config(cls, v):
|
||||
"""验证并转换 field_config"""
|
||||
@@ -71,7 +76,7 @@ class TemplateBase(BaseModel):
|
||||
config_dict = json.loads(v)
|
||||
return json.dumps(config_dict) # 返回格式化的 JSON 字符串
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError('field_config must be valid JSON string')
|
||||
raise ValueError("field_config must be valid JSON string")
|
||||
elif isinstance(v, dict):
|
||||
# 如果是字典,转换为 JSON 字符串
|
||||
return json.dumps(v)
|
||||
@@ -79,23 +84,27 @@ class TemplateBase(BaseModel):
|
||||
# 如果是 FieldConfig 对象,转换为 JSON 字符串
|
||||
return v.model_dump_json(exclude_none=True)
|
||||
else:
|
||||
raise ValueError('field_config must be JSON string, dict, or FieldConfig object')
|
||||
raise ValueError("field_config must be JSON string, dict, or FieldConfig object")
|
||||
|
||||
|
||||
class TemplateCreate(TemplateBase):
|
||||
"""创建模板 Schema"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
"""更新模板 Schema"""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100, description="模板名称")
|
||||
description: Optional[str] = Field(None, description="模板描述")
|
||||
parent_id: Optional[int] = Field(None, description="父模板 ID(用于继承)")
|
||||
field_config: Optional[Union[str, FieldConfig]] = Field(None, description="字段配置(JSON 字符串或对象)")
|
||||
field_config: Optional[Union[str, FieldConfig]] = Field(
|
||||
None, description="字段配置(JSON 字符串或对象)"
|
||||
)
|
||||
is_active: Optional[bool] = Field(None, description="是否启用")
|
||||
|
||||
@field_validator('field_config')
|
||||
@field_validator("field_config")
|
||||
@classmethod
|
||||
def validate_field_config(cls, v):
|
||||
"""验证并转换 field_config"""
|
||||
@@ -107,17 +116,18 @@ class TemplateUpdate(BaseModel):
|
||||
config_dict = json.loads(v)
|
||||
return json.dumps(config_dict)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError('field_config must be valid JSON string')
|
||||
raise ValueError("field_config must be valid JSON string")
|
||||
elif isinstance(v, dict):
|
||||
return json.dumps(v)
|
||||
elif isinstance(v, FieldConfig):
|
||||
return v.model_dump_json(exclude_none=True)
|
||||
else:
|
||||
raise ValueError('field_config must be JSON string, dict, or FieldConfig object')
|
||||
raise ValueError("field_config must be JSON string, dict, or FieldConfig object")
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
"""模板响应 Schema"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
@@ -133,15 +143,19 @@ class TemplateResponse(BaseModel):
|
||||
|
||||
class TaskFromTemplateRequest(BaseModel):
|
||||
"""从模板创建任务的请求 Schema"""
|
||||
|
||||
template_id: int = Field(..., description="模板 ID")
|
||||
thread_id: str = Field(..., min_length=1, description="接龙项目 ID")
|
||||
field_values: Dict[str, Any] = Field(default_factory=dict, description="用户填写的字段值")
|
||||
task_name: Optional[str] = Field(None, max_length=100, description="任务名称(可选)")
|
||||
cron_expression: Optional[str] = Field("0 20 * * *", description="Cron 表达式(可选,默认每天 20:00)")
|
||||
cron_expression: Optional[str] = Field(
|
||||
"0 20 * * *", description="Cron 表达式(可选,默认每天 20:00)"
|
||||
)
|
||||
|
||||
|
||||
class TemplatePreviewResponse(BaseModel):
|
||||
"""模板预览响应 Schema"""
|
||||
|
||||
template_id: int
|
||||
template_name: str
|
||||
preview_payload: Dict[str, Any] = Field(..., description="预览生成的 payload(使用默认值)")
|
||||
|
||||
@@ -5,11 +5,13 @@ from pydantic import BaseModel, Field, EmailStr
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""用户基础 Schema"""
|
||||
|
||||
alias: str = Field(..., min_length=2, max_length=50, description="用户别名(用于登录)")
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""创建用户 Schema(管理员手动创建,只需要别名)"""
|
||||
|
||||
role: Optional[str] = Field("user", description="角色: user/admin")
|
||||
email: Optional[EmailStr] = Field(None, description="邮箱地址")
|
||||
password: Optional[str] = Field(None, min_length=6, description="初始密码(可选)")
|
||||
@@ -18,24 +20,31 @@ class UserCreate(UserBase):
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""更新用户 Schema(管理员编辑用户)"""
|
||||
|
||||
alias: Optional[str] = Field(None, min_length=2, max_length=50, description="用户别名")
|
||||
role: Optional[str] = None
|
||||
is_approved: Optional[bool] = None
|
||||
email: Optional[EmailStr] = None
|
||||
password: Optional[str] = Field(None, min_length=6, description="新密码(可选,留空表示不修改)")
|
||||
password: Optional[str] = Field(
|
||||
None, min_length=6, description="新密码(可选,留空表示不修改)"
|
||||
)
|
||||
reset_password: Optional[bool] = Field(False, description="是否清空密码")
|
||||
|
||||
|
||||
class UserUpdateProfile(BaseModel):
|
||||
"""用户更新个人信息 Schema"""
|
||||
|
||||
alias: Optional[str] = Field(None, min_length=2, max_length=50, description="新别名")
|
||||
email: Optional[EmailStr] = Field(None, description="邮箱地址")
|
||||
current_password: Optional[str] = Field(None, min_length=6, description="当前密码(修改密码时必填)")
|
||||
current_password: Optional[str] = Field(
|
||||
None, min_length=6, description="当前密码(修改密码时必填)"
|
||||
)
|
||||
new_password: Optional[str] = Field(None, min_length=6, description="新密码")
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""用户响应 Schema"""
|
||||
|
||||
id: int
|
||||
alias: str
|
||||
role: str
|
||||
@@ -52,11 +61,13 @@ class UserResponse(BaseModel):
|
||||
|
||||
class UserWithToken(UserResponse):
|
||||
"""带 Token 的用户响应 Schema"""
|
||||
|
||||
authorization: Optional[str] = None
|
||||
|
||||
|
||||
class TokenStatus(BaseModel):
|
||||
"""Token 状态 Schema"""
|
||||
|
||||
is_valid: bool
|
||||
jwt_exp: str
|
||||
expires_at: Optional[int] = None # Unix 时间戳(秒)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
使用方法:
|
||||
uv run python apps/backend/scripts/create_admin.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -51,7 +52,7 @@ def create_admin_user(alias: str):
|
||||
|
||||
# 升级为管理员
|
||||
response = input("\n是否将该用户升级为管理员?(y/n): ")
|
||||
if response.lower() == 'y':
|
||||
if response.lower() == "y":
|
||||
existing_user.role = "admin"
|
||||
existing_user.is_approved = True # 确保已审批
|
||||
db.commit()
|
||||
|
||||
@@ -34,33 +34,31 @@ def migrate():
|
||||
columns = [row[1] for row in result]
|
||||
|
||||
# 添加 failed_login_attempts 字段
|
||||
if 'failed_login_attempts' not in columns:
|
||||
if "failed_login_attempts" not in columns:
|
||||
logger.info("添加 failed_login_attempts 字段...")
|
||||
conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER DEFAULT 0 NOT NULL"
|
||||
))
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER DEFAULT 0 NOT NULL"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("✓ failed_login_attempts 字段添加成功")
|
||||
else:
|
||||
logger.info("✓ failed_login_attempts 字段已存在,跳过")
|
||||
|
||||
# 添加 locked_until 字段
|
||||
if 'locked_until' not in columns:
|
||||
if "locked_until" not in columns:
|
||||
logger.info("添加 locked_until 字段...")
|
||||
conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN locked_until DATETIME"
|
||||
))
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN locked_until DATETIME"))
|
||||
conn.commit()
|
||||
logger.info("✓ locked_until 字段添加成功")
|
||||
else:
|
||||
logger.info("✓ locked_until 字段已存在,跳过")
|
||||
|
||||
# 添加 last_failed_login 字段
|
||||
if 'last_failed_login' not in columns:
|
||||
if "last_failed_login" not in columns:
|
||||
logger.info("添加 last_failed_login 字段...")
|
||||
conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN last_failed_login DATETIME"
|
||||
))
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN last_failed_login DATETIME"))
|
||||
conn.commit()
|
||||
logger.info("✓ last_failed_login 字段添加成功")
|
||||
else:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
测试新的异常处理系统
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -16,6 +17,7 @@ from backend.exceptions import (
|
||||
)
|
||||
from backend.schemas.response import ErrorResponse, ErrorDetail
|
||||
|
||||
|
||||
def test_exceptions():
|
||||
"""测试自定义异常"""
|
||||
print("=" * 60)
|
||||
@@ -32,7 +34,9 @@ def test_exceptions():
|
||||
try:
|
||||
raise AuthenticationError("Token已过期")
|
||||
except AuthenticationError as e:
|
||||
print(f"✅ AuthenticationError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
|
||||
print(
|
||||
f"✅ AuthenticationError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})"
|
||||
)
|
||||
|
||||
# 测试 AuthorizationError
|
||||
try:
|
||||
@@ -44,7 +48,9 @@ def test_exceptions():
|
||||
try:
|
||||
raise ResourceNotFoundError("用户不存在")
|
||||
except ResourceNotFoundError as e:
|
||||
print(f"✅ ResourceNotFoundError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
|
||||
print(
|
||||
f"✅ ResourceNotFoundError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})"
|
||||
)
|
||||
|
||||
# 测试 BusinessLogicError
|
||||
try:
|
||||
@@ -61,11 +67,7 @@ def test_response_schemas():
|
||||
|
||||
# 测试 ErrorResponse
|
||||
error_response = ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code="VALIDATION_ERROR",
|
||||
message="邮箱格式不正确",
|
||||
field="email"
|
||||
)
|
||||
error=ErrorDetail(code="VALIDATION_ERROR", message="邮箱格式不正确", field="email")
|
||||
)
|
||||
|
||||
response_dict = error_response.model_dump()
|
||||
@@ -90,18 +92,18 @@ def check_old_exception_patterns():
|
||||
|
||||
patterns = {
|
||||
"HTTPException with detail": r'raise HTTPException.*detail=f?".*{',
|
||||
"except Exception": r'except Exception as',
|
||||
"except Exception": r"except Exception as",
|
||||
}
|
||||
|
||||
results = {}
|
||||
for pattern_name, pattern in patterns.items():
|
||||
results[pattern_name] = []
|
||||
|
||||
for root, dirs, files in os.walk(APPS_DIR / 'backend' / 'api'):
|
||||
for root, dirs, files in os.walk(APPS_DIR / "backend" / "api"):
|
||||
for file in files:
|
||||
if file.endswith('.py'):
|
||||
if file.endswith(".py"):
|
||||
filepath = os.path.join(root, file)
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
matches = re.findall(pattern, content, re.MULTILINE)
|
||||
if matches:
|
||||
|
||||
@@ -14,10 +14,12 @@ 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()
|
||||
users = (
|
||||
db.query(User)
|
||||
.filter(User.is_approved == False, User.role == "user")
|
||||
.order_by(User.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return users
|
||||
|
||||
@@ -38,11 +40,7 @@ class AdminService:
|
||||
|
||||
logger.info(f"管理员审批通过用户: {user.alias} (ID: {user.id})")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "审批成功",
|
||||
"user_id": user.id
|
||||
}
|
||||
return {"success": True, "message": "审批成功", "user_id": user.id}
|
||||
|
||||
@staticmethod
|
||||
def reject_user(user_id: int, db: Session) -> Dict[str, Any]:
|
||||
@@ -58,21 +56,18 @@ class AdminService:
|
||||
|
||||
logger.info(f"管理员拒绝用户: {alias} (ID: {user_id})")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "已拒绝并删除用户"
|
||||
}
|
||||
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()
|
||||
expired_users = (
|
||||
db.query(User)
|
||||
.filter(User.is_approved == False, User.role == "user", User.created_at < cutoff_time)
|
||||
.all()
|
||||
)
|
||||
|
||||
count = len(expired_users)
|
||||
|
||||
|
||||
@@ -45,10 +45,7 @@ class AuthService:
|
||||
# 检查是否为空 jwt_sub(测试账号)
|
||||
if not existing_user.jwt_sub:
|
||||
logger.warning(f"用户 {alias} 是测试账号(未绑定 QQ),禁止扫码登录")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "此账户为测试账号,暂未绑定 QQ,无法扫码登录"
|
||||
}
|
||||
return {"status": "error", "message": "此账户为测试账号,暂未绑定 QQ,无法扫码登录"}
|
||||
|
||||
# 老用户:刷新 Token
|
||||
logger.info(f"老用户 {alias} 请求刷新 Token,会话: {session_id}")
|
||||
@@ -57,7 +54,7 @@ class AuthService:
|
||||
thread = threading.Thread(
|
||||
target=get_token_headless,
|
||||
args=(session_id, existing_user.jwt_sub, alias, client_ip),
|
||||
daemon=True
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
@@ -67,16 +64,14 @@ class AuthService:
|
||||
logger.warning(f"用户名 {alias} 已被预占")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "该用户名正在被其他人注册,请稍后再试或更换用户名"
|
||||
"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
|
||||
target=get_token_headless, args=(session_id, None, alias, client_ip), daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
@@ -96,29 +91,20 @@ class AuthService:
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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"生成二维码超时,请重试"
|
||||
}
|
||||
return {"status": "error", "message": f"生成二维码超时,请重试"}
|
||||
|
||||
@staticmethod
|
||||
def get_qrcode_status(session_id: str, db: Session) -> Dict[str, Any]:
|
||||
@@ -135,10 +121,7 @@ class AuthService:
|
||||
session_data = get_session_data(session_id)
|
||||
|
||||
if not session_data:
|
||||
return {
|
||||
"status": "pending",
|
||||
"message": "会话不存在或正在初始化"
|
||||
}
|
||||
return {"status": "pending", "message": "会话不存在或正在初始化"}
|
||||
|
||||
status = session_data.get("status")
|
||||
jwt_sub = session_data.get("jwt_sub") # 使用 jwt_sub 而非 signature
|
||||
@@ -147,7 +130,7 @@ class AuthService:
|
||||
return {
|
||||
"status": "waiting_scan",
|
||||
"message": "请使用手机 QQ 扫描二维码",
|
||||
"qrcode_image": session_data.get("qr_image_data")
|
||||
"qrcode_image": session_data.get("qr_image_data"),
|
||||
}
|
||||
|
||||
elif status == "success":
|
||||
@@ -160,15 +143,12 @@ class AuthService:
|
||||
|
||||
if not token:
|
||||
logger.error("Token 为空")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Token 为空"
|
||||
}
|
||||
return {"status": "error", "message": "Token 为空"}
|
||||
|
||||
try:
|
||||
# 清洗 Token:URL 解码 + 去除 Bearer 前缀(参考 v1 实现)
|
||||
pure_token = unquote(token) # URL 解码
|
||||
if pure_token.lower().startswith('bearer '):
|
||||
if pure_token.lower().startswith("bearer "):
|
||||
pure_token = pure_token[7:] # 去除 "Bearer " 前缀
|
||||
|
||||
decoded = jwt.decode(pure_token, options={"verify_signature": False})
|
||||
@@ -177,10 +157,7 @@ class AuthService:
|
||||
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)}"
|
||||
}
|
||||
return {"status": "error", "message": f"Token 解析失败: {str(e)}"}
|
||||
|
||||
# 查找用户(通过 jwt_sub)
|
||||
user = db.query(User).filter(User.jwt_sub == jwt_sub).first()
|
||||
@@ -191,12 +168,18 @@ class AuthService:
|
||||
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}")
|
||||
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号扫码登录"
|
||||
"message": "QQ账号不匹配,请使用正确的QQ号扫码登录",
|
||||
}
|
||||
|
||||
user.authorization = pure_token # 存储清理后的 token
|
||||
@@ -221,9 +204,9 @@ class AuthService:
|
||||
"alias": user.alias,
|
||||
"role": user.role,
|
||||
"is_approved": user.is_approved,
|
||||
"jwt_sub": user.jwt_sub
|
||||
"jwt_sub": user.jwt_sub,
|
||||
},
|
||||
"is_new_user": False
|
||||
"is_new_user": False,
|
||||
}
|
||||
|
||||
else:
|
||||
@@ -233,20 +216,14 @@ class AuthService:
|
||||
# 验证用户名是否被预占
|
||||
if not alias or not registration_manager.is_alias_reserved(alias):
|
||||
logger.error(f"新用户注册失败:用户名 {alias} 未预占或已过期")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "注册失败:会话已过期,请重新扫码"
|
||||
}
|
||||
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": "注册失败:用户名已被占用,请更换用户名"
|
||||
}
|
||||
return {"status": "error", "message": "注册失败:用户名已被占用,请更换用户名"}
|
||||
|
||||
# 创建新用户(待审批状态)
|
||||
new_user = User(
|
||||
@@ -270,6 +247,7 @@ class AuthService:
|
||||
# 发送邮件通知管理员
|
||||
try:
|
||||
from backend.services.email_service import EmailService
|
||||
|
||||
EmailService.notify_new_user_registration(new_user, db)
|
||||
except Exception as e:
|
||||
logger.error(f"发送注册通知邮件失败: {e}")
|
||||
@@ -286,22 +264,16 @@ class AuthService:
|
||||
"alias": new_user.alias,
|
||||
"role": new_user.role,
|
||||
"is_approved": new_user.is_approved,
|
||||
"jwt_sub": new_user.jwt_sub
|
||||
"jwt_sub": new_user.jwt_sub,
|
||||
},
|
||||
"is_new_user": True
|
||||
"is_new_user": True,
|
||||
}
|
||||
|
||||
elif status == "error":
|
||||
return {
|
||||
"status": "error",
|
||||
"message": session_data.get("message", "未知错误")
|
||||
}
|
||||
return {"status": "error", "message": session_data.get("message", "未知错误")}
|
||||
|
||||
else:
|
||||
return {
|
||||
"status": "pending",
|
||||
"message": "正在初始化..."
|
||||
}
|
||||
return {"status": "pending", "message": "正在初始化..."}
|
||||
|
||||
@staticmethod
|
||||
def verify_token(authorization: str, db: Session) -> Dict[str, Any]:
|
||||
@@ -318,7 +290,11 @@ class AuthService:
|
||||
from backend.utils.jwt import JWTManager
|
||||
|
||||
# 移除 "Bearer " 前缀
|
||||
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
|
||||
token = (
|
||||
authorization.replace("Bearer ", "")
|
||||
if authorization.startswith("Bearer ")
|
||||
else authorization
|
||||
)
|
||||
|
||||
try:
|
||||
# 验证 JWT token
|
||||
@@ -326,19 +302,13 @@ class AuthService:
|
||||
user_id = payload.get("user_id")
|
||||
|
||||
if not user_id:
|
||||
return {
|
||||
"is_valid": False,
|
||||
"message": "Token 格式错误"
|
||||
}
|
||||
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": False, "message": "用户不存在"}
|
||||
|
||||
return {
|
||||
"is_valid": True,
|
||||
@@ -346,25 +316,16 @@ class AuthService:
|
||||
"user_id": user.id,
|
||||
"alias": user.alias,
|
||||
"role": user.role,
|
||||
"is_approved": user.is_approved
|
||||
"is_approved": user.is_approved,
|
||||
}
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return {
|
||||
"is_valid": False,
|
||||
"message": "JWT Token 已过期"
|
||||
}
|
||||
return {"is_valid": False, "message": "JWT Token 已过期"}
|
||||
except jwt.InvalidTokenError:
|
||||
return {
|
||||
"is_valid": False,
|
||||
"message": "JWT Token 无效"
|
||||
}
|
||||
return {"is_valid": False, "message": "JWT Token 无效"}
|
||||
except Exception as e:
|
||||
logger.error(f"验证 JWT Token 失败: {str(e)}")
|
||||
return {
|
||||
"is_valid": False,
|
||||
"message": "Token 验证失败"
|
||||
}
|
||||
return {"is_valid": False, "message": "Token 验证失败"}
|
||||
|
||||
@staticmethod
|
||||
def verify_checkin_authorization(user: User) -> Dict[str, Any]:
|
||||
@@ -386,25 +347,17 @@ class AuthService:
|
||||
is_timestamp_expired,
|
||||
days_until_expiry,
|
||||
minutes_until_expiry,
|
||||
seconds_until_expiry
|
||||
seconds_until_expiry,
|
||||
)
|
||||
|
||||
# 检查是否有 authorization token
|
||||
if not user.authorization or user.authorization == "":
|
||||
return {
|
||||
"is_valid": False,
|
||||
"message": "未设置打卡凭证",
|
||||
"reason": "no_token"
|
||||
}
|
||||
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"
|
||||
}
|
||||
return {"is_valid": False, "message": "打卡凭证无效", "reason": "invalid_expiry"}
|
||||
|
||||
# 检查是否过期
|
||||
if is_timestamp_expired(exp_timestamp):
|
||||
@@ -413,7 +366,7 @@ class AuthService:
|
||||
"is_valid": False,
|
||||
"message": f"打卡凭证已过期 {days_expired} 天",
|
||||
"reason": "expired",
|
||||
"days_expired": days_expired
|
||||
"days_expired": days_expired,
|
||||
}
|
||||
|
||||
# Token 有效,计算剩余时间
|
||||
@@ -430,7 +383,7 @@ class AuthService:
|
||||
"days_remaining": days_remaining,
|
||||
"minutes_remaining": minutes_remaining,
|
||||
"expiring_soon": expiring_soon,
|
||||
"expires_at": exp_timestamp
|
||||
"expires_at": exp_timestamp,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -451,10 +404,7 @@ class AuthService:
|
||||
|
||||
if not user:
|
||||
logger.warning(f"别名登录失败:用户 {alias} 不存在")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "用户名或密码错误"
|
||||
}
|
||||
return {"success": False, "message": "用户名或密码错误"}
|
||||
|
||||
# 检查账户是否被锁定
|
||||
if user.locked_until:
|
||||
@@ -462,10 +412,12 @@ class AuthService:
|
||||
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} 分钟")
|
||||
logger.warning(
|
||||
f"别名登录失败:用户 {alias} 账户已锁定,剩余 {remaining_minutes} 分钟"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"账户已锁定,请 {remaining_minutes} 分钟后再试"
|
||||
"message": f"账户已锁定,请 {remaining_minutes} 分钟后再试",
|
||||
}
|
||||
else:
|
||||
# 锁定时间已过,重置锁定状态
|
||||
@@ -477,15 +429,12 @@ class AuthService:
|
||||
# 检查用户是否设置了密码
|
||||
if not user.password_hash:
|
||||
logger.warning(f"别名登录失败:用户 {alias} 未设置密码")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "该用户未设置密码,请使用扫码登录"
|
||||
}
|
||||
return {"success": False, "message": "该用户未设置密码,请使用扫码登录"}
|
||||
|
||||
# 验证密码
|
||||
try:
|
||||
password_bytes = password.encode('utf-8')
|
||||
hash_bytes = user.password_hash.encode('utf-8')
|
||||
password_bytes = password.encode("utf-8")
|
||||
hash_bytes = user.password_hash.encode("utf-8")
|
||||
|
||||
if not bcrypt.checkpw(password_bytes, hash_bytes):
|
||||
# 密码错误,增加失败次数
|
||||
@@ -497,24 +446,20 @@ class AuthService:
|
||||
user.locked_until = datetime.now() + timedelta(minutes=15)
|
||||
db.commit()
|
||||
logger.warning(f"别名登录失败:用户 {alias} 密码错误次数过多,账户已锁定15分钟")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "密码错误次数过多,账户已锁定15分钟"
|
||||
}
|
||||
return {"success": False, "message": "密码错误次数过多,账户已锁定15分钟"}
|
||||
|
||||
db.commit()
|
||||
remaining_attempts = 5 - user.failed_login_attempts
|
||||
logger.warning(f"别名登录失败:用户 {alias} 密码错误,剩余尝试次数: {remaining_attempts}")
|
||||
logger.warning(
|
||||
f"别名登录失败:用户 {alias} 密码错误,剩余尝试次数: {remaining_attempts}"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"用户名或密码错误,剩余尝试次数: {remaining_attempts}"
|
||||
"message": f"用户名或密码错误,剩余尝试次数: {remaining_attempts}",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"密码验证异常:{e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "登录失败,请稍后重试"
|
||||
}
|
||||
return {"success": False, "message": "登录失败,请稍后重试"}
|
||||
|
||||
# 密码正确,重置失败次数
|
||||
user.failed_login_attempts = 0
|
||||
@@ -551,17 +496,21 @@ class AuthService:
|
||||
"id": user.id,
|
||||
"alias": user.alias,
|
||||
"role": user.role,
|
||||
"is_approved": user.is_approved
|
||||
}
|
||||
"is_approved": user.is_approved,
|
||||
},
|
||||
}
|
||||
|
||||
# 如果打卡 Token 有问题,添加警告信息(不影响网站使用)
|
||||
if token_warning:
|
||||
result["token_warning"] = token_warning
|
||||
if token_warning == "token_invalid":
|
||||
result["warning_message"] = "登录成功,但检测到打卡凭证无效,无法自动打卡,建议扫码更新"
|
||||
result["warning_message"] = (
|
||||
"登录成功,但检测到打卡凭证无效,无法自动打卡,建议扫码更新"
|
||||
)
|
||||
elif token_warning == "token_expired":
|
||||
result["warning_message"] = "登录成功,但检测到打卡凭证已过期,无法自动打卡,建议扫码更新"
|
||||
result["warning_message"] = (
|
||||
"登录成功,但检测到打卡凭证已过期,无法自动打卡,建议扫码更新"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@@ -576,10 +525,10 @@ class AuthService:
|
||||
Returns:
|
||||
加密后的密码哈希
|
||||
"""
|
||||
password_bytes = password.encode('utf-8')
|
||||
password_bytes = password.encode("utf-8")
|
||||
salt = bcrypt.gensalt()
|
||||
hash_bytes = bcrypt.hashpw(password_bytes, salt)
|
||||
return hash_bytes.decode('utf-8')
|
||||
return hash_bytes.decode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
@@ -594,8 +543,8 @@ class AuthService:
|
||||
密码是否正确
|
||||
"""
|
||||
try:
|
||||
password_bytes = password.encode('utf-8')
|
||||
hash_bytes = password_hash.encode('utf-8')
|
||||
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}")
|
||||
@@ -617,12 +566,6 @@ class AuthService:
|
||||
success = cancel_session(session_id)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "会话已取消"
|
||||
}
|
||||
return {"success": True, "message": "会话已取消"}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "取消失败或会话不存在"
|
||||
}
|
||||
return {"success": False, "message": "取消失败或会话不存在"}
|
||||
|
||||
@@ -39,7 +39,9 @@ class CheckInService:
|
||||
task_info = build_task_info(task)
|
||||
|
||||
# 发送打卡失败通知(内容包含 Token 失效说明和刷新指引)
|
||||
EmailService.notify_check_in_result(user, task_info, False, "Token 已失效,需要重新授权")
|
||||
EmailService.notify_check_in_result(
|
||||
user, task_info, False, "Token 已失效,需要重新授权"
|
||||
)
|
||||
logger.info(f"已发送 Token 过期邮件到 {user.email}")
|
||||
|
||||
# 标记已发送 Token 过期通知
|
||||
@@ -63,7 +65,9 @@ class CheckInService:
|
||||
Returns:
|
||||
打卡记录 ID
|
||||
"""
|
||||
logger.info(f"🎯 创建待处理打卡记录 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id})")
|
||||
logger.info(
|
||||
f"🎯 创建待处理打卡记录 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id})"
|
||||
)
|
||||
|
||||
# 创建一个 pending 状态的记录
|
||||
record = CheckInRecord(
|
||||
@@ -72,7 +76,7 @@ class CheckInService:
|
||||
response_text="",
|
||||
error_message="",
|
||||
location="{}",
|
||||
trigger_type=trigger_type
|
||||
trigger_type=trigger_type,
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
@@ -106,10 +110,9 @@ class CheckInService:
|
||||
# 更新记录状态为失败
|
||||
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.query(CheckInRecord).filter(CheckInRecord.id == record_id).update(
|
||||
{"status": "failure", "error_message": "任务不存在"}
|
||||
)
|
||||
db.commit()
|
||||
return
|
||||
|
||||
@@ -121,26 +124,31 @@ class CheckInService:
|
||||
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.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']}")
|
||||
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)}")
|
||||
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.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)}")
|
||||
@@ -175,17 +183,13 @@ class CheckInService:
|
||||
response_text="",
|
||||
error_message=error_msg,
|
||||
location="{}",
|
||||
trigger_type=trigger_type
|
||||
trigger_type=trigger_type,
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
return {
|
||||
"record_id": record.id,
|
||||
"status": "failure",
|
||||
"message": error_msg
|
||||
}
|
||||
return {"record_id": record.id, "status": "failure", "message": error_msg}
|
||||
|
||||
# 不再提前验证 Token,交给统一的打卡逻辑处理
|
||||
# 这样可以确保所有错误(包括 Token 过期)都通过统一的流程处理
|
||||
@@ -195,10 +199,11 @@ class CheckInService:
|
||||
|
||||
# 在后台线程中执行打卡
|
||||
import threading
|
||||
|
||||
thread = threading.Thread(
|
||||
target=CheckInService.execute_check_in_async,
|
||||
args=(task.id, record_id, user.authorization),
|
||||
daemon=True
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
@@ -207,7 +212,7 @@ class CheckInService:
|
||||
return {
|
||||
"record_id": record_id,
|
||||
"status": "pending",
|
||||
"message": "打卡任务已启动,正在后台处理"
|
||||
"message": "打卡任务已启动,正在后台处理",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -223,13 +228,17 @@ class CheckInService:
|
||||
Returns:
|
||||
打卡结果字典
|
||||
"""
|
||||
logger.info(f"🎯 开始打卡 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id}), 触发: {trigger_type}")
|
||||
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'}")
|
||||
logger.error(
|
||||
f"❌ {error_msg} - Task ID: {task.id}, User ID: {user.id if user else 'None'}"
|
||||
)
|
||||
|
||||
# 记录失败
|
||||
record = CheckInRecord(
|
||||
@@ -238,20 +247,17 @@ class CheckInService:
|
||||
response_text="",
|
||||
error_message=error_msg,
|
||||
location="{}",
|
||||
trigger_type=trigger_type
|
||||
trigger_type=trigger_type,
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"message": error_msg,
|
||||
"record_id": record.id
|
||||
}
|
||||
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"]:
|
||||
@@ -268,7 +274,7 @@ class CheckInService:
|
||||
response_text="",
|
||||
error_message=error_msg,
|
||||
location="{}",
|
||||
trigger_type=trigger_type
|
||||
trigger_type=trigger_type,
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
@@ -277,7 +283,7 @@ class CheckInService:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"{error_msg},请重新扫码登录",
|
||||
"record_id": record.id
|
||||
"record_id": record.id,
|
||||
}
|
||||
|
||||
# 执行打卡(传递 task 对象和用户 token)
|
||||
@@ -295,7 +301,7 @@ class CheckInService:
|
||||
response_text=result["response_text"],
|
||||
error_message=result["error_message"],
|
||||
location="{}",
|
||||
trigger_type=trigger_type
|
||||
trigger_type=trigger_type,
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
@@ -309,7 +315,7 @@ class CheckInService:
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": "打卡成功" if result["success"] else f"打卡失败: {result['error_message']}",
|
||||
"record_id": record.id
|
||||
"record_id": record.id,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -326,13 +332,7 @@ class CheckInService:
|
||||
"""
|
||||
logger.info(f"🚀 开始批量打卡,任务数量: {len(task_ids)}")
|
||||
|
||||
results = {
|
||||
"total": len(task_ids),
|
||||
"success": 0,
|
||||
"failure": 0,
|
||||
"skipped": 0,
|
||||
"details": []
|
||||
}
|
||||
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()
|
||||
@@ -344,11 +344,9 @@ class CheckInService:
|
||||
if not task:
|
||||
logger.warning(f"⚠️ 任务 ID {task_id} 不存在,跳过")
|
||||
results["skipped"] += 1
|
||||
results["details"].append({
|
||||
"task_id": task_id,
|
||||
"success": False,
|
||||
"message": "任务不存在"
|
||||
})
|
||||
results["details"].append(
|
||||
{"task_id": task_id, "success": False, "message": "任务不存在"}
|
||||
)
|
||||
continue
|
||||
|
||||
# 执行打卡(移除 is_active 检查,允许手动打卡)
|
||||
@@ -361,24 +359,26 @@ class CheckInService:
|
||||
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")
|
||||
})
|
||||
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)}"
|
||||
})
|
||||
results["details"].append(
|
||||
{"task_id": task_id, "success": False, "message": f"异常: {str(e)}"}
|
||||
)
|
||||
|
||||
logger.info(f"📊 批量打卡完成 - 成功: {results['success']}, 失败: {results['failure']}, 跳过: {results['skipped']}")
|
||||
logger.info(
|
||||
f"📊 批量打卡完成 - 成功: {results['success']}, 失败: {results['failure']}, 跳过: {results['skipped']}"
|
||||
)
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
@@ -388,7 +388,7 @@ class CheckInService:
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[str] = None,
|
||||
trigger_type: Optional[str] = None
|
||||
trigger_type: Optional[str] = None,
|
||||
) -> tuple[List[CheckInRecord], int]:
|
||||
"""
|
||||
获取任务的打卡记录
|
||||
@@ -416,9 +416,7 @@ class CheckInService:
|
||||
total = query.count()
|
||||
|
||||
# 获取分页数据
|
||||
records = query.order_by(
|
||||
CheckInRecord.check_in_time.desc()
|
||||
).offset(skip).limit(limit).all()
|
||||
records = query.order_by(CheckInRecord.check_in_time.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return records, total
|
||||
|
||||
@@ -429,7 +427,7 @@ class CheckInService:
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[str] = None,
|
||||
trigger_type: Optional[str] = None
|
||||
trigger_type: Optional[str] = None,
|
||||
) -> tuple[List[CheckInRecord], int]:
|
||||
"""
|
||||
获取用户的所有打卡记录
|
||||
@@ -462,9 +460,7 @@ class CheckInService:
|
||||
total = query.count()
|
||||
|
||||
# 获取分页数据
|
||||
records = query.order_by(
|
||||
CheckInRecord.check_in_time.desc()
|
||||
).offset(skip).limit(limit).all()
|
||||
records = query.order_by(CheckInRecord.check_in_time.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return records, total
|
||||
|
||||
@@ -474,7 +470,7 @@ class CheckInService:
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
task_id: Optional[int] = None,
|
||||
status: Optional[str] = None
|
||||
status: Optional[str] = None,
|
||||
) -> tuple[List[CheckInRecord], int]:
|
||||
"""
|
||||
获取所有打卡记录(管理员)- 使用联表查询优化性能
|
||||
@@ -506,9 +502,7 @@ class CheckInService:
|
||||
total = query.count()
|
||||
|
||||
# 获取分页数据
|
||||
records = query.order_by(
|
||||
CheckInRecord.check_in_time.desc()
|
||||
).offset(skip).limit(limit).all()
|
||||
records = query.order_by(CheckInRecord.check_in_time.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return records, total
|
||||
|
||||
@@ -527,8 +521,11 @@ class CheckInService:
|
||||
包含额外信息的记录字典
|
||||
"""
|
||||
# 尝试使用已加载的关联对象,如果没有则查询
|
||||
task = record.task if hasattr(record, 'task') and record.task else \
|
||||
db.query(CheckInTask).filter(CheckInTask.id == record.task_id).first()
|
||||
task = (
|
||||
record.task
|
||||
if hasattr(record, "task") and record.task
|
||||
else db.query(CheckInTask).filter(CheckInTask.id == record.task_id).first()
|
||||
)
|
||||
|
||||
# 获取用户信息
|
||||
user = None
|
||||
@@ -537,28 +534,32 @@ class CheckInService:
|
||||
|
||||
if task:
|
||||
# 尝试使用已加载的 user,否则查询
|
||||
user = task.user if hasattr(task, 'user') and task.user else \
|
||||
db.query(User).filter(User.id == task.user_id).first()
|
||||
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,
|
||||
"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
|
||||
|
||||
@@ -69,7 +69,11 @@ class EmailService:
|
||||
|
||||
# 安全获取创建时间
|
||||
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 '未知'
|
||||
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>
|
||||
@@ -191,7 +195,9 @@ class EmailService:
|
||||
|
||||
# 安全获取创建时间
|
||||
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 '未知'
|
||||
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>
|
||||
@@ -270,7 +276,7 @@ class EmailService:
|
||||
<div class="success-box">
|
||||
<strong>✅ 审批结果:</strong> 已通过
|
||||
<br>
|
||||
<strong>审批时间:</strong> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
<strong>审批时间:</strong> {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
||||
</div>
|
||||
|
||||
<table class="info-table">
|
||||
@@ -391,7 +397,7 @@ class EmailService:
|
||||
<div class="error-box">
|
||||
<strong>❌ 审批结果:</strong> 未通过
|
||||
<br>
|
||||
<strong>处理时间:</strong> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
<strong>处理时间:</strong> {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
||||
</div>
|
||||
|
||||
{reason_html}
|
||||
@@ -409,7 +415,6 @@ class EmailService:
|
||||
|
||||
return EmailService.send_email([str(user_email)], subject, body_html)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def notify_token_expiring(user: User, jwt_exp: str) -> bool:
|
||||
"""
|
||||
@@ -640,7 +645,9 @@ class EmailService:
|
||||
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:
|
||||
def notify_check_in_result(
|
||||
user: User, task_info: dict, success: bool, message: str = ""
|
||||
) -> bool:
|
||||
"""
|
||||
通知用户打卡结果
|
||||
|
||||
@@ -665,9 +672,16 @@ class EmailService:
|
||||
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
|
||||
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 失效时的额外提示内容
|
||||
@@ -768,20 +782,20 @@ class EmailService:
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<td>执行时间</td>
|
||||
<td>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</td>
|
||||
<td>{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>任务 ID</td>
|
||||
<td>{task_info.get('thread_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 ''}
|
||||
{f"<tr><td>失败原因</td><td>{message}</td></tr>" if message else ""}
|
||||
</table>
|
||||
|
||||
{token_error_section if is_token_error else '<p>如有问题,请及时检查您的打卡配置。</p>'}
|
||||
{token_error_section if is_token_error else "<p>如有问题,请及时检查您的打卡配置。</p>"}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>此邮件由系统自动发送,请勿直接回复。</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
用户名预占和注册限流管理器
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
@@ -47,23 +48,22 @@ class RegistrationManager:
|
||||
reservation = self._reserved_aliases[alias]
|
||||
|
||||
# 检查是否过期
|
||||
if reservation['expire_time'] > current_time:
|
||||
if reservation["expire_time"] > current_time:
|
||||
# 未过期,检查是否是同一个 session
|
||||
if reservation['session_id'] == session_id:
|
||||
if reservation["session_id"] == session_id:
|
||||
# 同一个 session,更新过期时间
|
||||
reservation['expire_time'] = expire_time
|
||||
reservation["expire_time"] = expire_time
|
||||
logger.info(f"用户名 {alias} 预占时间已更新(session: {session_id})")
|
||||
return True
|
||||
else:
|
||||
# 不同 session,预占失败
|
||||
logger.warning(f"用户名 {alias} 已被占用(session: {reservation['session_id']})")
|
||||
logger.warning(
|
||||
f"用户名 {alias} 已被占用(session: {reservation['session_id']})"
|
||||
)
|
||||
return False
|
||||
|
||||
# 预占用户名
|
||||
self._reserved_aliases[alias] = {
|
||||
'session_id': session_id,
|
||||
'expire_time': expire_time
|
||||
}
|
||||
self._reserved_aliases[alias] = {"session_id": session_id, "expire_time": expire_time}
|
||||
logger.info(f"用户名 {alias} 已预占(session: {session_id}, 超时: {timeout_seconds}s)")
|
||||
return True
|
||||
|
||||
@@ -85,7 +85,7 @@ class RegistrationManager:
|
||||
reservation = self._reserved_aliases[alias]
|
||||
|
||||
# 如果指定了 session_id,则只释放匹配的
|
||||
if session_id and reservation['session_id'] != session_id:
|
||||
if session_id and reservation["session_id"] != session_id:
|
||||
logger.warning(f"尝试释放用户名 {alias},但 session 不匹配")
|
||||
return False
|
||||
|
||||
@@ -111,7 +111,7 @@ class RegistrationManager:
|
||||
current_time = time.time()
|
||||
|
||||
# 检查是否过期
|
||||
if reservation['expire_time'] <= current_time:
|
||||
if reservation["expire_time"] <= current_time:
|
||||
# 已过期,自动释放
|
||||
del self._reserved_aliases[alias]
|
||||
return False
|
||||
@@ -138,7 +138,9 @@ class RegistrationManager:
|
||||
# 检查是否过期
|
||||
if expire_time > current_time:
|
||||
remaining = int(expire_time - current_time)
|
||||
logger.warning(f"Cookie {cookie_value[:8]}... 在限流期内(剩余 {remaining} 秒)")
|
||||
logger.warning(
|
||||
f"Cookie {cookie_value[:8]}... 在限流期内(剩余 {remaining} 秒)"
|
||||
)
|
||||
return False
|
||||
else:
|
||||
# 已过期,移除记录
|
||||
@@ -168,8 +170,9 @@ class RegistrationManager:
|
||||
|
||||
# 清理过期的用户名预占
|
||||
expired_aliases = [
|
||||
alias for alias, reservation in self._reserved_aliases.items()
|
||||
if reservation['expire_time'] <= current_time
|
||||
alias
|
||||
for alias, reservation in self._reserved_aliases.items()
|
||||
if reservation["expire_time"] <= current_time
|
||||
]
|
||||
|
||||
for alias in expired_aliases:
|
||||
@@ -178,7 +181,8 @@ class RegistrationManager:
|
||||
|
||||
# 清理过期的注册限流记录
|
||||
expired_cookies = [
|
||||
cookie for cookie, expire_time in self._registration_cookies.items()
|
||||
cookie
|
||||
for cookie, expire_time in self._registration_cookies.items()
|
||||
if expire_time <= current_time
|
||||
]
|
||||
|
||||
@@ -187,10 +191,13 @@ class RegistrationManager:
|
||||
logger.debug(f"Cookie {cookie[:8]}... 限流记录已过期,自动清理")
|
||||
|
||||
if expired_aliases or expired_cookies:
|
||||
logger.info(f"清理完成:{len(expired_aliases)} 个用户名,{len(expired_cookies)} 个 Cookie")
|
||||
logger.info(
|
||||
f"清理完成:{len(expired_aliases)} 个用户名,{len(expired_cookies)} 个 Cookie"
|
||||
)
|
||||
|
||||
def _start_cleanup_thread(self) -> None:
|
||||
"""启动定期清理线程"""
|
||||
|
||||
def cleanup_loop():
|
||||
while True:
|
||||
try:
|
||||
@@ -207,9 +214,9 @@ class RegistrationManager:
|
||||
"""获取当前状态统计"""
|
||||
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()),
|
||||
"reserved_aliases_count": len(self._reserved_aliases),
|
||||
"rate_limited_cookies_count": len(self._registration_cookies),
|
||||
"reserved_aliases": list(self._reserved_aliases.keys()),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -39,14 +39,15 @@ def load_scheduled_tasks(db: Session, scheduler_instance):
|
||||
|
||||
# 移除所有现有的动态任务(保留系统任务)
|
||||
for job in scheduler_instance.get_jobs():
|
||||
if job.id.startswith('task_'):
|
||||
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()
|
||||
tasks = (
|
||||
db.query(CheckInTask)
|
||||
.filter(CheckInTask.is_active == True, CheckInTask.cron_expression.isnot(None))
|
||||
.all()
|
||||
)
|
||||
|
||||
loaded_count = 0
|
||||
skipped_count = 0
|
||||
@@ -76,7 +77,7 @@ def load_scheduled_tasks(db: Session, scheduler_instance):
|
||||
id=job_id,
|
||||
name=f"CheckIn-Task-{task.id}",
|
||||
args=[task.id],
|
||||
replace_existing=True
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
logger.info(f"✅ 加载任务 {task.id}: {task.name} (Cron: {task.cron_expression})")
|
||||
@@ -90,7 +91,7 @@ def load_scheduled_tasks(db: Session, scheduler_instance):
|
||||
"loaded": loaded_count,
|
||||
"skipped": skipped_count,
|
||||
"errors": error_count,
|
||||
"total": len(tasks)
|
||||
"total": len(tasks),
|
||||
}
|
||||
|
||||
logger.info(f"任务加载完成: {result}")
|
||||
@@ -114,7 +115,9 @@ def scheduled_check_in_task(task_id: int):
|
||||
return
|
||||
|
||||
if not task.is_scheduled_enabled:
|
||||
logger.info(f"任务 {task_id} 未启用定时打卡 (is_active={task.is_active}, cron={task.cron_expression})")
|
||||
logger.info(
|
||||
f"任务 {task_id} 未启用定时打卡 (is_active={task.is_active}, cron={task.cron_expression})"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"🤖 执行定时打卡任务 {task_id}")
|
||||
@@ -184,14 +187,18 @@ def check_token_expiration():
|
||||
# 计算剩余时间
|
||||
time_until_expiry = seconds_until_expiry(exp_timestamp)
|
||||
|
||||
logger.debug(f"用户 {user.alias}: 剩余 {time_until_expiry} 秒 (即将过期标志={user.token_expiring_notified}, 已过期标志={user.token_expired_notified})")
|
||||
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}...")
|
||||
logger.info(
|
||||
f"用户 {user.alias} 的打卡 Token 即将过期,发送邮件提醒到 {user_email}..."
|
||||
)
|
||||
from backend.services.email_service import EmailService
|
||||
|
||||
# 发送"即将过期"邮件
|
||||
@@ -212,7 +219,9 @@ def check_token_expiration():
|
||||
# 检查是否已发送过提醒
|
||||
expired_notified = bool(user.token_expired_notified)
|
||||
if not expired_notified:
|
||||
logger.info(f"用户 {user.alias} 的打卡 Token 已过期,发送邮件提醒到 {user_email}...")
|
||||
logger.info(
|
||||
f"用户 {user.alias} 的打卡 Token 已过期,发送邮件提醒到 {user_email}..."
|
||||
)
|
||||
from backend.services.email_service import EmailService
|
||||
|
||||
# 发送"已过期"邮件
|
||||
@@ -320,11 +329,9 @@ def start_scheduler():
|
||||
minutes=settings.TOKEN_CHECK_INTERVAL_MINUTES,
|
||||
id="check_token_expiration",
|
||||
name="Token 过期检查任务",
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info(
|
||||
f"已添加 Token 过期检查任务: 每 {settings.TOKEN_CHECK_INTERVAL_MINUTES} 分钟"
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info(f"已添加 Token 过期检查任务: 每 {settings.TOKEN_CHECK_INTERVAL_MINUTES} 分钟")
|
||||
|
||||
# 添加会话文件清理任务(每隔指定小时)
|
||||
scheduler.add_job(
|
||||
@@ -333,11 +340,9 @@ def start_scheduler():
|
||||
hours=settings.SESSION_CLEANUP_INTERVAL_HOURS,
|
||||
id="cleanup_old_sessions",
|
||||
name="清理旧会话文件任务",
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info(
|
||||
f"已添加会话清理任务: 每 {settings.SESSION_CLEANUP_INTERVAL_HOURS} 小时"
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info(f"已添加会话清理任务: 每 {settings.SESSION_CLEANUP_INTERVAL_HOURS} 小时")
|
||||
|
||||
# 添加清理过期未审批用户任务(每小时执行一次)
|
||||
scheduler.add_job(
|
||||
@@ -346,7 +351,7 @@ def start_scheduler():
|
||||
hours=1,
|
||||
id="cleanup_expired_pending_users",
|
||||
name="清理过期未审批用户任务",
|
||||
replace_existing=True
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info("已添加清理过期未审批用户任务: 每 1 小时")
|
||||
|
||||
|
||||
@@ -36,22 +36,22 @@ class TaskService:
|
||||
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')
|
||||
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()
|
||||
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}")
|
||||
logger.warning(
|
||||
f"⚠️ 任务创建冲突 - User: {user.alias}({user_id}), ThreadId: {thread_id}"
|
||||
)
|
||||
raise ValueError(f"该接龙中已存在任务。ThreadId: {thread_id}")
|
||||
|
||||
# 4. 记录日志
|
||||
@@ -63,14 +63,16 @@ class TaskService:
|
||||
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
|
||||
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}")
|
||||
logger.info(
|
||||
f"✅ 任务创建成功 - ID: {task.id}, Name: {task.name}, ThreadId: {thread_id}"
|
||||
)
|
||||
|
||||
# 如果任务启用且包含 cron_expression,立即添加到调度器
|
||||
if task.is_scheduled_enabled:
|
||||
@@ -111,33 +113,38 @@ class TaskService:
|
||||
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()
|
||||
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,
|
||||
"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]:
|
||||
def get_user_tasks(
|
||||
user_id: int, db: Session, include_inactive: bool = True
|
||||
) -> List[CheckInTask]:
|
||||
"""
|
||||
获取用户的所有任务
|
||||
|
||||
@@ -191,8 +198,8 @@ class TaskService:
|
||||
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
|
||||
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)
|
||||
@@ -297,10 +304,11 @@ class TaskService:
|
||||
Returns:
|
||||
是否属于该用户
|
||||
"""
|
||||
task = db.query(CheckInTask).filter(
|
||||
CheckInTask.id == task_id,
|
||||
CheckInTask.user_id == user_id
|
||||
).first()
|
||||
task = (
|
||||
db.query(CheckInTask)
|
||||
.filter(CheckInTask.id == task_id, CheckInTask.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
return task is not None
|
||||
|
||||
@@ -342,7 +350,7 @@ class TaskService:
|
||||
id=job_id,
|
||||
name=f"CheckIn-Task-{task.id}",
|
||||
args=[task.id],
|
||||
replace_existing=True
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info(f"✅ 任务 {task.id} 已重新加载到调度器: {cron_str}")
|
||||
else:
|
||||
|
||||
@@ -132,15 +132,13 @@ class TemplateService:
|
||||
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)}"
|
||||
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)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"创建模板失败: {str(e)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -159,10 +157,7 @@ class TemplateService:
|
||||
|
||||
@staticmethod
|
||||
def get_all_templates(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
is_active: Optional[bool] = None
|
||||
db: Session, skip: int = 0, limit: int = 100, is_active: Optional[bool] = None
|
||||
) -> List[TaskTemplate]:
|
||||
"""
|
||||
获取所有模板列表
|
||||
@@ -185,9 +180,7 @@ class TemplateService:
|
||||
|
||||
@staticmethod
|
||||
def update_template(
|
||||
template_id: int,
|
||||
template_data: TemplateUpdate,
|
||||
db: Session
|
||||
template_id: int, template_data: TemplateUpdate, db: Session
|
||||
) -> TaskTemplate:
|
||||
"""
|
||||
更新模板
|
||||
@@ -202,18 +195,15 @@ class TemplateService:
|
||||
"""
|
||||
template = TemplateService.get_template(template_id, db)
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="模板不存在"
|
||||
)
|
||||
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'])
|
||||
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)
|
||||
@@ -227,15 +217,13 @@ class TemplateService:
|
||||
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)}"
|
||||
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)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"更新模板失败: {str(e)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -252,10 +240,7 @@ class TemplateService:
|
||||
"""
|
||||
template = TemplateService.get_template(template_id, db)
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="模板不存在"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
try:
|
||||
db.delete(template)
|
||||
@@ -266,28 +251,26 @@ class TemplateService:
|
||||
logger.error(f"删除模板失败: {str(e)}")
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"删除模板失败: {str(e)}"
|
||||
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
|
||||
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:
|
||||
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
|
||||
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:
|
||||
@@ -304,12 +287,12 @@ class TemplateService:
|
||||
"""
|
||||
# 1. 普通字段配置
|
||||
if TemplateService._is_field_config(config):
|
||||
if config.get('hidden', False):
|
||||
value = config.get('default_value', '')
|
||||
if config.get("hidden", False):
|
||||
value = config.get("default_value", "")
|
||||
else:
|
||||
value = field_values.get(key, config.get('default_value', ''))
|
||||
value = field_values.get(key, config.get("default_value", ""))
|
||||
|
||||
value_type = config.get('value_type', 'string')
|
||||
value_type = config.get("value_type", "string")
|
||||
return TemplateService._validate_and_convert_value(value, value_type, key)
|
||||
|
||||
# 2. 数组字段
|
||||
@@ -319,10 +302,10 @@ class TemplateService:
|
||||
# 检查数组元素是否是字段配置对象
|
||||
if TemplateService._is_field_config(item_config):
|
||||
# 数组元素是字段配置对象,需要序列化为 JSON 字符串
|
||||
value = item_config.get('default_value', '')
|
||||
value_type = item_config.get('value_type', 'string')
|
||||
value = item_config.get("default_value", "")
|
||||
value_type = item_config.get("value_type", "string")
|
||||
# 将对象序列化为 JSON 字符串
|
||||
if value_type == 'json':
|
||||
if value_type == "json":
|
||||
if isinstance(value, str):
|
||||
# 如果是字符串,验证 JSON 格式
|
||||
try:
|
||||
@@ -333,15 +316,16 @@ class TemplateService:
|
||||
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
|
||||
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))
|
||||
result.append(
|
||||
TemplateService._validate_and_convert_value(value, value_type, key)
|
||||
)
|
||||
elif isinstance(item_config, dict):
|
||||
# 数组元素是普通对象,递归处理
|
||||
item = {}
|
||||
@@ -388,9 +372,7 @@ class TemplateService:
|
||||
field_config = TemplateService.merge_parent_config(template, db)
|
||||
|
||||
# 初始化 payload,只包含 ThreadId(唯一必需,不在模板中配置)
|
||||
payload = {
|
||||
"ThreadId": "<接龙项目ID>"
|
||||
}
|
||||
payload = {"ThreadId": "<接龙项目ID>"}
|
||||
|
||||
# 递归处理所有字段,保持键名原样
|
||||
for key, config in field_config.items():
|
||||
@@ -402,15 +384,12 @@ class TemplateService:
|
||||
logger.error(f"解析模板配置失败: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"解析模板配置失败: {str(e)}"
|
||||
detail=f"解析模板配置失败: {str(e)}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def assemble_payload_from_template(
|
||||
template: TaskTemplate,
|
||||
thread_id: str,
|
||||
field_values: Dict[str, Any],
|
||||
db: Session
|
||||
template: TaskTemplate, thread_id: str, field_values: Dict[str, Any], db: Session
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
根据模板和用户输入组装完整的 payload
|
||||
@@ -432,9 +411,7 @@ class TemplateService:
|
||||
field_config = TemplateService.merge_parent_config(template, db)
|
||||
|
||||
# 初始化 payload,只包含 ThreadId(唯一必需)
|
||||
payload = {
|
||||
"ThreadId": thread_id
|
||||
}
|
||||
payload = {"ThreadId": thread_id}
|
||||
|
||||
# 递归处理所有字段,保持键名原样
|
||||
for key, config in field_config.items():
|
||||
@@ -445,14 +422,13 @@ class TemplateService:
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"解析模板配置失败: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"解析模板配置失败"
|
||||
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)}"
|
||||
detail=f"组装 payload 失败: {str(e)}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -469,17 +445,17 @@ class TemplateService:
|
||||
转换后的值
|
||||
"""
|
||||
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 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 value.lower() in ("true", "1", "yes")
|
||||
return bool(value)
|
||||
elif value_type == 'json':
|
||||
elif value_type == "json":
|
||||
# JSON 类型:如果是字符串,尝试解析后再序列化;如果是对象,直接序列化
|
||||
if isinstance(value, str):
|
||||
# 验证是否为有效 JSON
|
||||
@@ -493,7 +469,7 @@ class TemplateService:
|
||||
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)}"
|
||||
detail=f"字段 '{field_name}' 类型错误:期望 {value_type},实际值为 '{value}',错误: {str(e)}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -504,7 +480,7 @@ class TemplateService:
|
||||
user_id: int,
|
||||
task_name: Optional[str],
|
||||
db: Session,
|
||||
cron_expression: Optional[str] = "0 20 * * *"
|
||||
cron_expression: Optional[str] = "0 20 * * *",
|
||||
) -> CheckInTask:
|
||||
"""
|
||||
从模板创建打卡任务
|
||||
@@ -524,16 +500,12 @@ class TemplateService:
|
||||
# 获取模板
|
||||
template = TemplateService.get_template(template_id, db)
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="模板不存在"
|
||||
)
|
||||
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="该模板未启用,无法创建任务"
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="该模板未启用,无法创建任务"
|
||||
)
|
||||
|
||||
# 组装 payload
|
||||
@@ -543,7 +515,7 @@ class TemplateService:
|
||||
|
||||
# 生成任务名称
|
||||
if not task_name:
|
||||
signature = payload.get('Signature', 'Unknown')
|
||||
signature = payload.get("Signature", "Unknown")
|
||||
task_name = f"{template.name} - {signature}"
|
||||
|
||||
# 创建任务(包含 cron_expression)
|
||||
@@ -553,17 +525,20 @@ class TemplateService:
|
||||
payload_config=json.dumps(payload, ensure_ascii=False),
|
||||
name=task_name,
|
||||
is_active=True,
|
||||
cron_expression=cron_expression or "0 20 * * *"
|
||||
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})")
|
||||
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
|
||||
@@ -572,6 +547,5 @@ class TemplateService:
|
||||
logger.error(f"从模板创建任务失败: {str(e)}")
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"创建任务失败: {str(e)}"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"创建任务失败: {str(e)}"
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ def escape_like_pattern(text: str) -> str:
|
||||
Returns:
|
||||
转义后的文本
|
||||
"""
|
||||
return text.replace('%', r'\%').replace('_', r'\_')
|
||||
return text.replace("%", r"\%").replace("_", r"\_")
|
||||
|
||||
|
||||
class UserService:
|
||||
@@ -49,7 +49,9 @@ class UserService:
|
||||
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, # 使用请求中的值,默认已审批
|
||||
is_approved=user_data.is_approved
|
||||
if user_data.is_approved is not None
|
||||
else True, # 使用请求中的值,默认已审批
|
||||
jwt_exp="0",
|
||||
authorization=None,
|
||||
)
|
||||
@@ -57,14 +59,17 @@ class UserService:
|
||||
# 如果提供了密码,则设置密码
|
||||
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'))
|
||||
|
||||
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 '未设置'})")
|
||||
logger.info(
|
||||
f"管理员创建用户成功: {user.alias} (ID: {user.id}, 角色: {user.role}, 密码: {'已设置' if user_data.password else '未设置'})"
|
||||
)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
@@ -115,7 +120,7 @@ class UserService:
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
search: Optional[str] = None,
|
||||
role: Optional[str] = None
|
||||
role: Optional[str] = None,
|
||||
) -> List[User]:
|
||||
"""
|
||||
获取所有用户
|
||||
@@ -241,7 +246,9 @@ class UserService:
|
||||
raise ValueError("修改密码时必须提供当前密码")
|
||||
|
||||
# 验证当前密码
|
||||
if not AuthService.verify_password(update_data["current_password"], user.password_hash):
|
||||
if not AuthService.verify_password(
|
||||
update_data["current_password"], user.password_hash
|
||||
):
|
||||
raise ValueError("当前密码错误")
|
||||
|
||||
# 设置新密码
|
||||
|
||||
@@ -3,18 +3,16 @@
|
||||
|
||||
提供统一的资源查询、权限验证等通用功能
|
||||
"""
|
||||
|
||||
from typing import TypeVar, Type, Optional, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
T = TypeVar('T')
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def get_or_404(
|
||||
model: Type[T],
|
||||
model_id: int,
|
||||
db: Session,
|
||||
error_message: Optional[str] = None
|
||||
model: Type[T], model_id: int, db: Session, error_message: Optional[str] = None
|
||||
) -> T:
|
||||
"""
|
||||
查询资源,不存在则抛出 404
|
||||
@@ -35,18 +33,13 @@ def get_or_404(
|
||||
if not obj:
|
||||
default_message = f"{model.__name__}不存在"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=error_message or default_message
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=error_message or default_message
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
def get_owned_or_403(
|
||||
model: Type[T],
|
||||
model_id: int,
|
||||
user_id: int,
|
||||
db: Session,
|
||||
error_message: Optional[str] = None
|
||||
model: Type[T], model_id: int, user_id: int, db: Session, error_message: Optional[str] = None
|
||||
) -> T:
|
||||
"""
|
||||
查询资源并验证归属,否则抛出 403
|
||||
@@ -64,24 +57,19 @@ def get_owned_or_403(
|
||||
Raises:
|
||||
HTTPException: 403 无权访问此资源
|
||||
"""
|
||||
obj = db.query(model).filter(
|
||||
model.id == model_id,
|
||||
model.user_id == user_id
|
||||
).first()
|
||||
obj = db.query(model).filter(model.id == model_id, model.user_id == user_id).first()
|
||||
|
||||
if not obj:
|
||||
# 先检查资源是否存在
|
||||
exists = db.query(model).filter(model.id == model_id).first()
|
||||
if not exists:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"{model.__name__}不存在"
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"{model.__name__}不存在"
|
||||
)
|
||||
# 资源存在但不属于当前用户
|
||||
default_message = f"无权访问此{model.__name__}"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=error_message or default_message
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=error_message or default_message
|
||||
)
|
||||
|
||||
return obj
|
||||
@@ -92,7 +80,7 @@ def get_by_field_or_404(
|
||||
field_name: str,
|
||||
field_value: Any,
|
||||
db: Session,
|
||||
error_message: Optional[str] = None
|
||||
error_message: Optional[str] = None,
|
||||
) -> T:
|
||||
"""
|
||||
根据字段查询资源,不存在则抛出 404
|
||||
@@ -110,14 +98,11 @@ def get_by_field_or_404(
|
||||
Raises:
|
||||
HTTPException: 404 资源不存在
|
||||
"""
|
||||
obj = db.query(model).filter(
|
||||
getattr(model, field_name) == field_value
|
||||
).first()
|
||||
obj = db.query(model).filter(getattr(model, field_name) == field_value).first()
|
||||
|
||||
if not obj:
|
||||
default_message = f"{model.__name__}不存在"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=error_message or default_message
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=error_message or default_message
|
||||
)
|
||||
return obj
|
||||
|
||||
@@ -3,6 +3,7 @@ JSON 处理辅助函数
|
||||
|
||||
提供安全的 JSON 解析和数据提取功能
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Any, Dict
|
||||
@@ -10,11 +11,7 @@ from typing import Optional, Any, Dict
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def safe_parse_json(
|
||||
json_str: Optional[str],
|
||||
default: Any = None,
|
||||
log_error: bool = True
|
||||
) -> Any:
|
||||
def safe_parse_json(json_str: Optional[str], default: Any = None, log_error: bool = True) -> Any:
|
||||
"""
|
||||
安全解析 JSON 字符串,失败时返回默认值
|
||||
|
||||
@@ -37,10 +34,7 @@ def safe_parse_json(
|
||||
return default
|
||||
|
||||
|
||||
def safe_parse_payload(
|
||||
payload_config: Optional[str],
|
||||
default: Optional[Dict] = None
|
||||
) -> Dict:
|
||||
def safe_parse_payload(payload_config: Optional[str], default: Optional[Dict] = None) -> Dict:
|
||||
"""
|
||||
安全解析 payload_config,失败时返回默认字典
|
||||
|
||||
@@ -70,7 +64,7 @@ def extract_thread_id(payload_config: Optional[str]) -> Optional[str]:
|
||||
ThreadId 或 None
|
||||
"""
|
||||
payload = safe_parse_payload(payload_config)
|
||||
return payload.get('ThreadId')
|
||||
return payload.get("ThreadId")
|
||||
|
||||
|
||||
def extract_signature(payload_config: Optional[str]) -> Optional[str]:
|
||||
@@ -84,7 +78,7 @@ def extract_signature(payload_config: Optional[str]) -> Optional[str]:
|
||||
Signature 或 None
|
||||
"""
|
||||
payload = safe_parse_payload(payload_config)
|
||||
return payload.get('Signature')
|
||||
return payload.get("Signature")
|
||||
|
||||
|
||||
def build_task_info(task) -> Dict[str, str]:
|
||||
@@ -98,6 +92,6 @@ def build_task_info(task) -> Dict[str, str]:
|
||||
包含 thread_id 和 name 的字典
|
||||
"""
|
||||
return {
|
||||
'thread_id': extract_thread_id(getattr(task, 'payload_config', None)) or '未知',
|
||||
'name': getattr(task, 'name', None) or f'Task-{getattr(task, "id", "Unknown")}'
|
||||
"thread_id": extract_thread_id(getattr(task, "payload_config", None)) or "未知",
|
||||
"name": getattr(task, "name", None) or f"Task-{getattr(task, 'id', 'Unknown')}",
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ class JWTManager:
|
||||
"alias": user_alias,
|
||||
"iat": now, # Issued At - 签发时间
|
||||
"exp": exp, # Expiration Time - 过期时间
|
||||
"type": "access" # Token 类型
|
||||
"type": "access", # Token 类型
|
||||
}
|
||||
|
||||
token = jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
||||
@@ -97,10 +97,7 @@ class JWTManager:
|
||||
try:
|
||||
# decode 时设置 verify=False 跳过过期验证
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
JWT_SECRET_KEY,
|
||||
algorithms=[JWT_ALGORITHM],
|
||||
options={"verify_exp": False}
|
||||
token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM], options={"verify_exp": False}
|
||||
)
|
||||
return payload.get("user_id")
|
||||
except Exception as e:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
提供统一的时间戳处理和格式化功能
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
@@ -85,7 +86,7 @@ def minutes_until_expiry(timestamp: int) -> int:
|
||||
return seconds // 60
|
||||
|
||||
|
||||
def format_timestamp(timestamp: int, format_str: str = '%Y-%m-%d %H:%M:%S') -> str:
|
||||
def format_timestamp(timestamp: int, format_str: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||
"""
|
||||
格式化时间戳为人类可读格式
|
||||
|
||||
|
||||
@@ -42,17 +42,17 @@ def get_live_x_api_payload(auth_token: str) -> str:
|
||||
chrome_options.binary_location = CHROME_BINARY_PATH
|
||||
|
||||
# 开启性能日志记录功能
|
||||
logging_prefs = {'performance': 'ALL'}
|
||||
chrome_options.set_capability('goog:loggingPrefs', logging_prefs)
|
||||
logging_prefs = {"performance": "ALL"}
|
||||
chrome_options.set_capability("goog:loggingPrefs", logging_prefs)
|
||||
|
||||
# Headless 模式配置
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
|
||||
chrome_options.add_argument(f'user-agent={user_agent}')
|
||||
chrome_options.add_argument(f"user-agent={user_agent}")
|
||||
chrome_options.add_argument("--headless")
|
||||
chrome_options.add_argument("--no-sandbox")
|
||||
chrome_options.add_argument("--disable-dev-shm-usage")
|
||||
chrome_options.add_argument("--window-size=1920,1080")
|
||||
chrome_options.add_argument('--ignore-certificate-errors')
|
||||
chrome_options.add_argument("--ignore-certificate-errors")
|
||||
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
|
||||
driver = webdriver.Chrome(service=service, options=chrome_options)
|
||||
@@ -63,11 +63,7 @@ def get_live_x_api_payload(auth_token: str) -> str:
|
||||
driver.get("https://i.jielong.com/my-class")
|
||||
|
||||
# 注入长期 Token
|
||||
driver.add_cookie({
|
||||
'name': 'token',
|
||||
'value': auth_token,
|
||||
'domain': '.jielong.com'
|
||||
})
|
||||
driver.add_cookie({"name": "token", "value": auth_token, "domain": ".jielong.com"})
|
||||
|
||||
# 导航到触发 API 的页面
|
||||
driver.get("https://i.jielong.com/my-form")
|
||||
@@ -78,14 +74,14 @@ def get_live_x_api_payload(auth_token: str) -> str:
|
||||
found = False
|
||||
|
||||
while time.time() - start_time < max_wait_time:
|
||||
logs = driver.get_log('performance')
|
||||
logs = driver.get_log("performance")
|
||||
for entry in logs:
|
||||
log = json.loads(entry['message'])['message']
|
||||
if log['method'] == 'Network.requestWillBeSent':
|
||||
headers = log.get('params', {}).get('request', {}).get('headers', {})
|
||||
log = json.loads(entry["message"])["message"]
|
||||
if log["method"] == "Network.requestWillBeSent":
|
||||
headers = log.get("params", {}).get("request", {}).get("headers", {})
|
||||
headers_lower = {k.lower(): v for k, v in headers.items()}
|
||||
if 'x-api-request-payload' in headers_lower:
|
||||
payload_signature = headers_lower['x-api-request-payload']
|
||||
if "x-api-request-payload" in headers_lower:
|
||||
payload_signature = headers_lower["x-api-request-payload"]
|
||||
logger.info("成功通过网络日志捕获到现场的 x-api-request-payload!")
|
||||
found = True
|
||||
break
|
||||
@@ -94,12 +90,14 @@ def get_live_x_api_payload(auth_token: str) -> str:
|
||||
time.sleep(1)
|
||||
|
||||
if not payload_signature:
|
||||
raise Exception(f"在 {max_wait_time} 秒内未能通过网络日志捕获到 x-api-request-payload。")
|
||||
raise Exception(
|
||||
f"在 {max_wait_time} 秒内未能通过网络日志捕获到 x-api-request-payload。"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取现场 x-api-request-payload 时失败: {e}")
|
||||
try:
|
||||
debug_screenshot = os.path.join(settings.BASE_DIR, 'payload_debug.png')
|
||||
debug_screenshot = os.path.join(settings.BASE_DIR, "payload_debug.png")
|
||||
driver.save_screenshot(debug_screenshot)
|
||||
except Exception as screenshot_error:
|
||||
logger.warning(f"保存调试截图失败: {screenshot_error}")
|
||||
@@ -135,7 +133,7 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
|
||||
from backend.utils.json_helpers import safe_parse_payload, extract_signature
|
||||
|
||||
payload_dict = safe_parse_payload(task.payload_config)
|
||||
signature = extract_signature(task.payload_config) or 'Unknown'
|
||||
signature = extract_signature(task.payload_config) or "Unknown"
|
||||
|
||||
logger.info(f"Selenium打卡: 正在为任务 ID: {task.id} (Signature: {signature}) 执行打卡...")
|
||||
|
||||
@@ -146,7 +144,7 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": "",
|
||||
"error_message": error_msg
|
||||
"error_message": error_msg,
|
||||
}
|
||||
|
||||
# 获取 x-api-request-payload
|
||||
@@ -158,7 +156,7 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": "",
|
||||
"error_message": error_msg
|
||||
"error_message": error_msg,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -175,19 +173,19 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": "",
|
||||
"error_message": error_msg
|
||||
"error_message": error_msg,
|
||||
}
|
||||
|
||||
headers = {
|
||||
'User-Agent': "Mozilla%2f5.0+(Linux%3b+Android+16%3b+wv)+AppleWebKit%2f537.36+(KHTML%2c+like+Gecko)+Chrome%2f142.0.0.0+Safari%2f537.36+QQ%2f9.2.30.31620+QQ%2fMiniApp",
|
||||
'Accept-Encoding': "gzip",
|
||||
'Content-Type': "application/json",
|
||||
'authorization': f"Bearer {user_token}",
|
||||
'x-api-request-referer': "https://appservice.qq.com/1110276759",
|
||||
'x-api-request-payload': payload_signature,
|
||||
'referer': "https://appservice.qq.com/1110276759/8.10.1.7/page-frame.html",
|
||||
'platform': "qq",
|
||||
'x-api-request-mode': "cors",
|
||||
"User-Agent": "Mozilla%2f5.0+(Linux%3b+Android+16%3b+wv)+AppleWebKit%2f537.36+(KHTML%2c+like+Gecko)+Chrome%2f142.0.0.0+Safari%2f537.36+QQ%2f9.2.30.31620+QQ%2fMiniApp",
|
||||
"Accept-Encoding": "gzip",
|
||||
"Content-Type": "application/json",
|
||||
"authorization": f"Bearer {user_token}",
|
||||
"x-api-request-referer": "https://appservice.qq.com/1110276759",
|
||||
"x-api-request-payload": payload_signature,
|
||||
"referer": "https://appservice.qq.com/1110276759/8.10.1.7/page-frame.html",
|
||||
"platform": "qq",
|
||||
"x-api-request-mode": "cors",
|
||||
}
|
||||
|
||||
url = "https://api.jielong.com/api/CheckIn/EditRecord"
|
||||
@@ -203,7 +201,9 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
|
||||
response.raise_for_status()
|
||||
response_text = response.text
|
||||
|
||||
logger.info(f"✉️ 任务 ID: {task.id} (Signature: {signature}) 打卡请求完成!响应: {response_text}")
|
||||
logger.info(
|
||||
f"✉️ 任务 ID: {task.id} (Signature: {signature}) 打卡请求完成!响应: {response_text}"
|
||||
)
|
||||
|
||||
# 判断响应内容(参考 V1 实现逻辑)
|
||||
# 情况1: 明确包含"打卡成功" → 成功
|
||||
@@ -213,9 +213,10 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
|
||||
if task.user and task.user.email:
|
||||
try:
|
||||
from backend.services.email_service import EmailService
|
||||
|
||||
task_info = {
|
||||
'thread_id': payload.get('ThreadId', '未知'),
|
||||
'name': getattr(task, 'name', '打卡任务')
|
||||
"thread_id": payload.get("ThreadId", "未知"),
|
||||
"name": getattr(task, "name", "打卡任务"),
|
||||
}
|
||||
EmailService.notify_check_in_result(task.user, task_info, True, "打卡成功")
|
||||
except Exception as e:
|
||||
@@ -225,38 +226,45 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
|
||||
"success": True,
|
||||
"status": "success",
|
||||
"response_text": response_text,
|
||||
"error_message": ""
|
||||
"error_message": "",
|
||||
}
|
||||
|
||||
# 情况2: 已经提交过了(重复提交)→ 视为成功,但不发送邮件
|
||||
# 匹配 "已被提交" 或 "已经打卡"
|
||||
elif ("已被提交" in response_text or "已经打卡" in response_text or
|
||||
"重复提交" in response_text):
|
||||
elif (
|
||||
"已被提交" in response_text
|
||||
or "已经打卡" in response_text
|
||||
or "重复提交" in response_text
|
||||
):
|
||||
logger.info(f"✅ 检测到'已被提交',本次打卡已完成(重复提交,不发送邮件)")
|
||||
return {
|
||||
"success": True,
|
||||
"status": "success",
|
||||
"response_text": response_text,
|
||||
"error_message": ""
|
||||
"error_message": "",
|
||||
}
|
||||
|
||||
# 情况3: 不在打卡时间范围 → 标记为时间范围外
|
||||
# 匹配 Data 或 Description 中的内容
|
||||
elif ("不在打卡时间范围" in response_text or
|
||||
"不在打卡时间" in response_text):
|
||||
elif "不在打卡时间范围" in response_text or "不在打卡时间" in response_text:
|
||||
logger.warning(f"⏰ 检测到'不在打卡时间范围',打卡时间不符")
|
||||
return {
|
||||
"success": False,
|
||||
"status": "out_of_time",
|
||||
"response_text": response_text,
|
||||
"error_message": "不在打卡时间范围内"
|
||||
"error_message": "不在打卡时间范围内",
|
||||
}
|
||||
|
||||
# 情况4: Token 失效的特征标识 → 失败
|
||||
# 扩展检测条件:检测多种 Token 失效的响应特征
|
||||
elif ("登录" in response_text or "授权" in response_text or
|
||||
"未登录" in response_text or "token" in response_text.lower() or
|
||||
"Unauthorized" in response_text or response.status_code == 401):
|
||||
elif (
|
||||
"登录" in response_text
|
||||
or "授权" in response_text
|
||||
or "未登录" in response_text
|
||||
or "token" in response_text.lower()
|
||||
or "Unauthorized" in response_text
|
||||
or response.status_code == 401
|
||||
):
|
||||
logger.warning(f"⚠️ 检测到Token失效特征,Token 可能已失效")
|
||||
# 发送打卡失败邮件通知(邮件内容已包含Token失效提醒和刷新指引)
|
||||
if task.user and task.user.email:
|
||||
@@ -268,7 +276,9 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
|
||||
task_info = build_task_info(task)
|
||||
|
||||
# 只发送打卡失败通知(内容已说明Token失效)
|
||||
EmailService.notify_check_in_result(task.user, task_info, False, "Token 已失效,需要重新授权")
|
||||
EmailService.notify_check_in_result(
|
||||
task.user, task_info, False, "Token 已失效,需要重新授权"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"发送打卡失败邮件失败: {e}")
|
||||
|
||||
@@ -276,7 +286,7 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
|
||||
"success": False,
|
||||
"status": "token_expired", # 特殊状态,用于标识 Token 过期
|
||||
"response_text": response_text,
|
||||
"error_message": "Token 已失效,需要重新授权"
|
||||
"error_message": "Token 已失效,需要重新授权",
|
||||
}
|
||||
|
||||
# 情况5: 其他响应 → 需要人工确认(标记为异常)
|
||||
@@ -287,7 +297,7 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
|
||||
"success": False,
|
||||
"status": "unknown",
|
||||
"response_text": response_text,
|
||||
"error_message": "未识别的响应,请人工确认"
|
||||
"error_message": "未识别的响应,请人工确认",
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
@@ -303,15 +313,10 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": response_text,
|
||||
"error_message": str(e)
|
||||
"error_message": str(e),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"为任务 ID: {task.id} (Signature: {signature}) 打卡时发生未知错误: {e}"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
"success": False,
|
||||
"status": "failure",
|
||||
"response_text": "",
|
||||
"error_message": str(e)
|
||||
}
|
||||
return {"success": False, "status": "failure", "response_text": "", "error_message": str(e)}
|
||||
|
||||
@@ -32,7 +32,9 @@ class EmailNotifier:
|
||||
"""
|
||||
# 检查必要的邮件配置是否存在
|
||||
if not settings.SMTP_SERVER or not settings.SMTP_SENDER_EMAIL:
|
||||
logger.debug("邮件配置未完成(SMTP_SERVER 或 SMTP_SENDER_EMAIL 为空),邮件发送功能已禁用")
|
||||
logger.debug(
|
||||
"邮件配置未完成(SMTP_SERVER 或 SMTP_SENDER_EMAIL 为空),邮件发送功能已禁用"
|
||||
)
|
||||
return None
|
||||
|
||||
if not settings.SMTP_PORT:
|
||||
@@ -41,19 +43,16 @@ class EmailNotifier:
|
||||
|
||||
# 返回配置字典
|
||||
return {
|
||||
'smtp_server': settings.SMTP_SERVER,
|
||||
'smtp_port': settings.SMTP_PORT,
|
||||
'sender_email': settings.SMTP_SENDER_EMAIL,
|
||||
'sender_password': settings.SMTP_SENDER_PASSWORD,
|
||||
'use_ssl': settings.SMTP_USE_SSL
|
||||
"smtp_server": settings.SMTP_SERVER,
|
||||
"smtp_port": settings.SMTP_PORT,
|
||||
"sender_email": settings.SMTP_SENDER_EMAIL,
|
||||
"sender_password": settings.SMTP_SENDER_PASSWORD,
|
||||
"use_ssl": settings.SMTP_USE_SSL,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def send_email(
|
||||
to_emails: List[str],
|
||||
subject: str,
|
||||
html_content: str,
|
||||
from_email: Optional[str] = None
|
||||
to_emails: List[str], subject: str, html_content: str, from_email: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
发送邮件(底层方法)
|
||||
@@ -74,30 +73,26 @@ class EmailNotifier:
|
||||
|
||||
try:
|
||||
# 创建邮件
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['From'] = from_email or email_config['sender_email']
|
||||
msg['To'] = ', '.join(to_emails)
|
||||
msg['Subject'] = subject
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = from_email or email_config["sender_email"]
|
||||
msg["To"] = ", ".join(to_emails)
|
||||
msg["Subject"] = subject
|
||||
|
||||
# 添加 HTML 正文
|
||||
html_part = MIMEText(html_content, 'html', 'utf-8')
|
||||
html_part = MIMEText(html_content, "html", "utf-8")
|
||||
msg.attach(html_part)
|
||||
|
||||
# 连接 SMTP 服务器并发送
|
||||
if email_config.get('use_ssl', True):
|
||||
if email_config.get("use_ssl", True):
|
||||
server = smtplib.SMTP_SSL(
|
||||
email_config['smtp_server'],
|
||||
int(email_config['smtp_port'])
|
||||
email_config["smtp_server"], int(email_config["smtp_port"])
|
||||
)
|
||||
else:
|
||||
server = smtplib.SMTP(
|
||||
email_config['smtp_server'],
|
||||
int(email_config['smtp_port'])
|
||||
)
|
||||
server = smtplib.SMTP(email_config["smtp_server"], int(email_config["smtp_port"]))
|
||||
server.starttls()
|
||||
|
||||
server.login(email_config['sender_email'], email_config['sender_password'])
|
||||
server.sendmail(msg['From'], to_emails, msg.as_string())
|
||||
server.login(email_config["sender_email"], email_config["sender_password"])
|
||||
server.sendmail(msg["From"], to_emails, msg.as_string())
|
||||
server.quit()
|
||||
|
||||
logger.info(f"邮件发送成功: {subject} -> {', '.join(to_emails)}")
|
||||
@@ -116,4 +111,3 @@ class EmailNotifier:
|
||||
邮件功能是否可用
|
||||
"""
|
||||
return EmailNotifier.get_email_config() is not None
|
||||
|
||||
|
||||
@@ -27,11 +27,10 @@ def get_chrome_config():
|
||||
"""获取 Chrome 配置(从 settings 读取)"""
|
||||
return {
|
||||
"chrome_binary": settings.CHROME_BINARY_PATH,
|
||||
"chromedriver": settings.CHROMEDRIVER_PATH
|
||||
"chromedriver": settings.CHROMEDRIVER_PATH,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def update_session_file(session_id: str, data: dict) -> None:
|
||||
"""线程安全地写入会话文件"""
|
||||
filepath = settings.SESSION_DIR / f"{session_id}.json"
|
||||
@@ -39,7 +38,7 @@ def update_session_file(session_id: str, data: dict) -> None:
|
||||
|
||||
try:
|
||||
with FileLock(lock_path, timeout=5):
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"写入会话文件 {filepath} 失败: {e}")
|
||||
@@ -55,13 +54,14 @@ def get_session_status(session_id: str) -> str:
|
||||
|
||||
try:
|
||||
with FileLock(lock_path, timeout=5):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if not content:
|
||||
return None
|
||||
from backend.utils.json_helpers import safe_parse_json
|
||||
|
||||
data = safe_parse_json(content, {})
|
||||
return data.get('status')
|
||||
return data.get("status")
|
||||
except IOError as e:
|
||||
logger.error(f"读取会话文件 {filepath} 失败: {e}")
|
||||
return None
|
||||
@@ -77,11 +77,12 @@ def get_session_data(session_id: str) -> dict:
|
||||
|
||||
try:
|
||||
with FileLock(lock_path, timeout=5):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if not content:
|
||||
return None
|
||||
from backend.utils.json_helpers import safe_parse_json
|
||||
|
||||
return safe_parse_json(content, {})
|
||||
except IOError as e:
|
||||
logger.error(f"读取会话文件 {filepath} 失败: {e}")
|
||||
@@ -110,23 +111,23 @@ def cancel_session(session_id: str) -> bool:
|
||||
# 读取当前会话数据
|
||||
from backend.utils.json_helpers import safe_parse_json
|
||||
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if not content:
|
||||
return False
|
||||
data = safe_parse_json(content, {})
|
||||
|
||||
# 如果已经成功,不允许取消
|
||||
if data.get('status') == 'success':
|
||||
if data.get("status") == "success":
|
||||
logger.info(f"会话 {session_id} 已成功,无法取消")
|
||||
return False
|
||||
|
||||
# 标记为已取消
|
||||
data['status'] = 'cancelled'
|
||||
data['message'] = '用户取消登录'
|
||||
data["status"] = "cancelled"
|
||||
data["message"] = "用户取消登录"
|
||||
|
||||
# 写回文件
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"✅ 会话 {session_id} 已取消")
|
||||
@@ -137,7 +138,9 @@ def cancel_session(session_id: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def get_token_headless(session_id: str, jwt_sub: str = None, alias: str = None, client_ip: str = "") -> None:
|
||||
def get_token_headless(
|
||||
session_id: str, jwt_sub: str = None, alias: str = None, client_ip: str = ""
|
||||
) -> None:
|
||||
"""
|
||||
使用 Selenium 获取 QQ 扫码登录的 Token
|
||||
|
||||
@@ -171,12 +174,12 @@ def get_token_headless(session_id: str, jwt_sub: str = None, alias: str = None,
|
||||
|
||||
# Headless 模式配置
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
|
||||
chrome_options.add_argument(f'user-agent={user_agent}')
|
||||
chrome_options.add_argument(f"user-agent={user_agent}")
|
||||
chrome_options.add_argument("--headless")
|
||||
chrome_options.add_argument("--no-sandbox")
|
||||
chrome_options.add_argument("--disable-dev-shm-usage")
|
||||
chrome_options.add_argument("--window-size=1920,1080")
|
||||
chrome_options.add_argument('--ignore-certificate-errors')
|
||||
chrome_options.add_argument("--ignore-certificate-errors")
|
||||
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
|
||||
# 启动浏览器
|
||||
@@ -203,7 +206,9 @@ def get_token_headless(session_id: str, jwt_sub: str = None, alias: str = None,
|
||||
current_step = "查找并点击切换按钮"
|
||||
toggle_button_selector = "div.login-wrap .toggle"
|
||||
logger.info(f"Selenium ({session_id}): {current_step} ({toggle_button_selector})...")
|
||||
toggle_button = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, toggle_button_selector)))
|
||||
toggle_button = wait.until(
|
||||
EC.element_to_be_clickable((By.CSS_SELECTOR, toggle_button_selector))
|
||||
)
|
||||
toggle_button.click()
|
||||
|
||||
# --- 步骤 2: 勾选同意服务协议 ---
|
||||
@@ -219,27 +224,35 @@ def get_token_headless(session_id: str, jwt_sub: str = None, alias: str = None,
|
||||
current_step = "点击立即登录按钮"
|
||||
login_button_selector = "button.css-1wli0ry.ant-btn.ant-btn-default.login-btn"
|
||||
logger.info(f"Selenium ({session_id}): {current_step} ({login_button_selector})...")
|
||||
login_button = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, login_button_selector)))
|
||||
login_button = wait.until(
|
||||
EC.element_to_be_clickable((By.CSS_SELECTOR, login_button_selector))
|
||||
)
|
||||
login_button.click()
|
||||
|
||||
# --- 步骤 4: 等待二维码加载 ---
|
||||
import time
|
||||
|
||||
time.sleep(3) # 等待几秒让二维码刷新出来
|
||||
|
||||
current_step = "等待QQ二维码图片加载"
|
||||
qq_qr_image_selector = "#login_container img"
|
||||
logger.info(f"Selenium ({session_id}): {current_step} ({qq_qr_image_selector})...")
|
||||
qr_element = wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, qq_qr_image_selector)))
|
||||
qr_element = wait.until(
|
||||
EC.visibility_of_element_located((By.CSS_SELECTOR, qq_qr_image_selector))
|
||||
)
|
||||
|
||||
logger.info(f"Selenium ({session_id}): 成功找到QQ二维码元素,正在截图...")
|
||||
qr_base64 = qr_element.screenshot_as_base64
|
||||
update_session_file(session_id, {
|
||||
'status': 'waiting_scan',
|
||||
'qr_image_data': qr_base64,
|
||||
'jwt_sub': jwt_sub,
|
||||
'alias': alias, # 新增:保存 alias
|
||||
'client_ip': client_ip # 新增:保存 IP
|
||||
})
|
||||
update_session_file(
|
||||
session_id,
|
||||
{
|
||||
"status": "waiting_scan",
|
||||
"qr_image_data": qr_base64,
|
||||
"jwt_sub": jwt_sub,
|
||||
"alias": alias, # 新增:保存 alias
|
||||
"client_ip": client_ip, # 新增:保存 IP
|
||||
},
|
||||
)
|
||||
|
||||
current_step = "等待用户扫描登录 (Cookie 'token' 出现)"
|
||||
cookie_name_to_find = "token"
|
||||
@@ -248,10 +261,11 @@ def get_token_headless(session_id: str, jwt_sub: str = None, alias: str = None,
|
||||
# 自定义等待逻辑:每秒检查cookie和session状态
|
||||
max_wait_seconds = 120
|
||||
import time
|
||||
|
||||
for i in range(max_wait_seconds):
|
||||
# 检查session是否被取消
|
||||
status = get_session_status(session_id)
|
||||
if status == 'cancelled':
|
||||
if status == "cancelled":
|
||||
logger.info(f"Selenium ({session_id}): 用户取消了登录,终止会话")
|
||||
raise Exception("用户取消登录")
|
||||
|
||||
@@ -268,22 +282,28 @@ def get_token_headless(session_id: str, jwt_sub: str = None, alias: str = None,
|
||||
cookie = driver.get_cookie(cookie_name_to_find)
|
||||
if cookie:
|
||||
logger.info(f"Selenium ({session_id}): 成功在Cookie中捕获到Token!")
|
||||
update_session_file(session_id, {
|
||||
'status': 'success',
|
||||
'token': cookie['value'],
|
||||
'alias': alias, # 保存 alias
|
||||
'client_ip': client_ip # 保存 IP
|
||||
})
|
||||
update_session_file(
|
||||
session_id,
|
||||
{
|
||||
"status": "success",
|
||||
"token": cookie["value"],
|
||||
"alias": alias, # 保存 alias
|
||||
"client_ip": client_ip, # 保存 IP
|
||||
},
|
||||
)
|
||||
else:
|
||||
raise Exception("等待Cookie成功但获取失败")
|
||||
|
||||
except TimeoutException:
|
||||
if get_session_status(session_id) == 'success':
|
||||
logger.warning(f"Selenium ({session_id}): 一个并发线程超时,但会话已成功,将忽略此超时。")
|
||||
if get_session_status(session_id) == "success":
|
||||
logger.warning(
|
||||
f"Selenium ({session_id}): 一个并发线程超时,但会话已成功,将忽略此超时。"
|
||||
)
|
||||
else:
|
||||
# 释放预占的用户名
|
||||
if alias:
|
||||
from backend.services.registration_manager import registration_manager
|
||||
|
||||
registration_manager.release_alias(alias, session_id)
|
||||
logger.info(f"超时释放用户名预占: {alias}")
|
||||
|
||||
@@ -294,34 +314,35 @@ def get_token_headless(session_id: str, jwt_sub: str = None, alias: str = None,
|
||||
if driver:
|
||||
try:
|
||||
driver.save_screenshot(DEBUG_SCREENSHOT_PATH)
|
||||
with open(DEBUG_PAGE_SOURCE_PATH, 'w', encoding='utf-8') as f:
|
||||
with open(DEBUG_PAGE_SOURCE_PATH, "w", encoding="utf-8") as f:
|
||||
f.write(driver.page_source)
|
||||
logger.error(f"Selenium ({session_id}): 调试截图和源码已保存。当前URL: {driver.current_url}")
|
||||
logger.error(
|
||||
f"Selenium ({session_id}): 调试截图和源码已保存。当前URL: {driver.current_url}"
|
||||
)
|
||||
except Exception as debug_error:
|
||||
logger.error(f"Selenium ({session_id}): 保存调试信息失败: {debug_error}")
|
||||
|
||||
update_session_file(session_id, {
|
||||
'status': 'error',
|
||||
'message': error_message,
|
||||
'jwt_sub': jwt_sub
|
||||
})
|
||||
update_session_file(
|
||||
session_id, {"status": "error", "message": error_message, "jwt_sub": jwt_sub}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if get_session_status(session_id) == 'success':
|
||||
logger.warning(f"Selenium ({session_id}): 一个并发线程出错 ({e}),但会话已成功,将忽略此错误。")
|
||||
if get_session_status(session_id) == "success":
|
||||
logger.warning(
|
||||
f"Selenium ({session_id}): 一个并发线程出错 ({e}),但会话已成功,将忽略此错误。"
|
||||
)
|
||||
else:
|
||||
# 释放预占的用户名
|
||||
if alias:
|
||||
from backend.services.registration_manager import registration_manager
|
||||
|
||||
registration_manager.release_alias(alias, session_id)
|
||||
logger.info(f"异常释放用户名预占: {alias}")
|
||||
|
||||
logger.error(f"Selenium ({session_id}): 发生未知错误: {e}", exc_info=True)
|
||||
update_session_file(session_id, {
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
'jwt_sub': jwt_sub
|
||||
})
|
||||
update_session_file(
|
||||
session_id, {"status": "error", "message": str(e), "jwt_sub": jwt_sub}
|
||||
)
|
||||
|
||||
finally:
|
||||
if driver:
|
||||
|
||||
@@ -173,7 +173,15 @@ def start_frontend_daemon(args: argparse.Namespace) -> int:
|
||||
|
||||
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_file = FRONTEND_LOG.open("a", encoding="utf-8")
|
||||
cmd = [get_python(), str(REPO_ROOT / "main.py"), "frontend", "--host", args.host, "--port", str(args.port)]
|
||||
cmd = [
|
||||
get_python(),
|
||||
str(REPO_ROOT / "main.py"),
|
||||
"frontend",
|
||||
"--host",
|
||||
args.host,
|
||||
"--port",
|
||||
str(args.port),
|
||||
]
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=REPO_ROOT,
|
||||
@@ -263,13 +271,17 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
frontend.add_argument("--port", type=int, default=FRONTEND_PORT)
|
||||
frontend.set_defaults(func=run_frontend)
|
||||
|
||||
frontend_daemon = sub.add_parser("frontend-daemon", help="start frontend dev server in the background")
|
||||
frontend_daemon = sub.add_parser(
|
||||
"frontend-daemon", help="start frontend dev server in the background"
|
||||
)
|
||||
frontend_daemon.add_argument("--host", default="0.0.0.0")
|
||||
frontend_daemon.add_argument("--port", type=int, default=FRONTEND_PORT)
|
||||
frontend_daemon.set_defaults(func=start_frontend_daemon)
|
||||
|
||||
frontend_build = sub.add_parser("frontend-build", help="build current frontend")
|
||||
frontend_build.add_argument("--install", action="store_true", help="run npm install if node_modules is missing")
|
||||
frontend_build.add_argument(
|
||||
"--install", action="store_true", help="run npm install if node_modules is missing"
|
||||
)
|
||||
frontend_build.set_defaults(func=build_frontend)
|
||||
|
||||
deploy = sub.add_parser("frontend-deploy", help="show frontend deployment output path")
|
||||
@@ -279,7 +291,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
service_status.set_defaults(func=status)
|
||||
|
||||
service_stop = sub.add_parser("stop", help="stop managed daemon processes")
|
||||
service_stop.add_argument("target", choices=["backend", "frontend", "all"], nargs="?", default="all")
|
||||
service_stop.add_argument(
|
||||
"target", choices=["backend", "frontend", "all"], nargs="?", default="all"
|
||||
)
|
||||
service_stop.set_defaults(func=stop)
|
||||
|
||||
return parser
|
||||
|
||||
Reference in New Issue
Block a user