From ab68f019c5a2828b5c7d4a9d8d63cfb3bf42cce7 Mon Sep 17 00:00:00 2001 From: Cccc_ Date: Sun, 3 May 2026 18:14:23 +0800 Subject: [PATCH] style(backend): apply ruff format --- apps/backend/api/admin.py | 137 ++++++------ apps/backend/api/auth.py | 40 +--- apps/backend/api/check_in.py | 113 +++++----- apps/backend/api/tasks.py | 53 ++--- apps/backend/api/templates.py | 61 ++---- apps/backend/api/users.py | 76 +++---- apps/backend/config.py | 4 +- apps/backend/dependencies.py | 25 +-- apps/backend/limiter.py | 1 + apps/backend/main.py | 26 +-- apps/backend/models/check_in_record.py | 30 ++- apps/backend/models/check_in_task.py | 30 ++- apps/backend/models/database.py | 2 +- apps/backend/models/task_template.py | 8 +- apps/backend/models/user.py | 32 ++- apps/backend/schemas/auth.py | 7 + apps/backend/schemas/check_in.py | 8 +- apps/backend/schemas/response.py | 6 +- apps/backend/schemas/task.py | 32 +-- apps/backend/schemas/template.py | 44 ++-- apps/backend/schemas/user.py | 15 +- apps/backend/scripts/create_admin.py | 3 +- .../scripts/migrate_add_account_lockout.py | 22 +- apps/backend/scripts/test_exceptions.py | 24 +- apps/backend/services/admin_service.py | 31 ++- apps/backend/services/auth_service.py | 207 +++++++----------- apps/backend/services/check_in_service.py | 183 ++++++++-------- apps/backend/services/email_service.py | 40 ++-- apps/backend/services/registration_manager.py | 43 ++-- apps/backend/services/scheduler_service.py | 45 ++-- apps/backend/services/task_service.py | 72 +++--- apps/backend/services/template_service.py | 130 +++++------ apps/backend/services/user_service.py | 21 +- apps/backend/utils/db_helpers.py | 37 +--- apps/backend/utils/json_helpers.py | 20 +- apps/backend/utils/jwt.py | 7 +- apps/backend/utils/time_helpers.py | 3 +- apps/backend/workers/check_in_worker.py | 111 +++++----- apps/backend/workers/email_notifier.py | 44 ++-- apps/backend/workers/token_refresher.py | 115 ++++++---- main.py | 22 +- 41 files changed, 960 insertions(+), 970 deletions(-) diff --git a/apps/backend/api/admin.py b/apps/backend/api/admin.py index 2dabd77..72d6912 100644 --- a/apps/backend/api/admin.py +++ b/apps/backend/api/admin.py @@ -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)}" ) diff --git a/apps/backend/api/auth.py b/apps/backend/api/auth.py index c567262..d449ac3 100644 --- a/apps/backend/api/auth.py +++ b/apps/backend/api/auth.py @@ -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)}" ) diff --git a/apps/backend/api/check_in.py b/apps/backend/api/check_in.py index a3ec232..3c71843 100644 --- a/apps/backend/api/check_in.py +++ b/apps/backend/api/check_in.py @@ -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)}" ) diff --git a/apps/backend/api/tasks.py b/apps/backend/api/tasks.py index 1d94004..b1fb18b 100644 --- a/apps/backend/api/tasks.py +++ b/apps/backend/api/tasks.py @@ -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}") diff --git a/apps/backend/api/templates.py b/apps/backend/api/templates.py index f1ef39d..4c19933 100644 --- a/apps/backend/api/templates.py +++ b/apps/backend/api/templates.py @@ -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 diff --git a/apps/backend/api/users.py b/apps/backend/api/users.py index 8746d4e..6590f07 100644 --- a/apps/backend/api/users.py +++ b/apps/backend/api/users.py @@ -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)}" ) diff --git a/apps/backend/config.py b/apps/backend/config.py index 6fe6cda..968b25e 100644 --- a/apps/backend/config.py +++ b/apps/backend/config.py @@ -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", ) # 项目根目录 diff --git a/apps/backend/dependencies.py b/apps/backend/dependencies.py index d055687..96aeb6e 100644 --- a/apps/backend/dependencies.py +++ b/apps/backend/dependencies.py @@ -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]: """ 可选的用户认证 diff --git a/apps/backend/limiter.py b/apps/backend/limiter.py index 263f9a6..d76ee28 100644 --- a/apps/backend/limiter.py +++ b/apps/backend/limiter.py @@ -3,6 +3,7 @@ 支持Cloudflare Tunnel和其他代理服务 """ + from slowapi import Limiter from fastapi import Request diff --git a/apps/backend/main.py b/apps/backend/main.py index 08a0439..b342e84 100644 --- a/apps/backend/main.py +++ b/apps/backend/main.py @@ -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", diff --git a/apps/backend/models/check_in_record.py b/apps/backend/models/check_in_record.py index 3bc384d..593416b 100644 --- a/apps/backend/models/check_in_record.py +++ b/apps/backend/models/check_in_record.py @@ -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): diff --git a/apps/backend/models/check_in_task.py b/apps/backend/models/check_in_task.py index e0ea1ce..d943b38 100644 --- a/apps/backend/models/check_in_task.py +++ b/apps/backend/models/check_in_task.py @@ -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): diff --git a/apps/backend/models/database.py b/apps/backend/models/database.py index 95f7587..426f034 100644 --- a/apps/backend/models/database.py +++ b/apps/backend/models/database.py @@ -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: diff --git a/apps/backend/models/task_template.py b/apps/backend/models/task_template.py index d5ab98b..407c1cf 100644 --- a/apps/backend/models/task_template.py +++ b/apps/backend/models/task_template.py @@ -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)") diff --git a/apps/backend/models/user.py b/apps/backend/models/user.py index f49ba52..6a7efad 100644 --- a/apps/backend/models/user.py +++ b/apps/backend/models/user.py @@ -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): diff --git a/apps/backend/schemas/auth.py b/apps/backend/schemas/auth.py index dbab7d1..3eb44db 100644 --- a/apps/backend/schemas/auth.py +++ b/apps/backend/schemas/auth.py @@ -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") diff --git a/apps/backend/schemas/check_in.py b/apps/backend/schemas/check_in.py index 29aa3cb..b1803f8 100644 --- a/apps/backend/schemas/check_in.py +++ b/apps/backend/schemas/check_in.py @@ -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="跳过的记录数") diff --git a/apps/backend/schemas/response.py b/apps/backend/schemas/response.py index 7660535..0eb532e 100644 --- a/apps/backend/schemas/response.py +++ b/apps/backend/schemas/response.py @@ -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 diff --git a/apps/backend/schemas/task.py b/apps/backend/schemas/task.py index f105827..f9797dc 100644 --- a/apps/backend/schemas/task.py +++ b/apps/backend/schemas/task.py @@ -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="最后一次打卡时间") diff --git a/apps/backend/schemas/template.py b/apps/backend/schemas/template.py index 9338aa9..bd759cd 100644 --- a/apps/backend/schemas/template.py +++ b/apps/backend/schemas/template.py @@ -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(使用默认值)") diff --git a/apps/backend/schemas/user.py b/apps/backend/schemas/user.py index e06b176..2d9093e 100644 --- a/apps/backend/schemas/user.py +++ b/apps/backend/schemas/user.py @@ -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 时间戳(秒) diff --git a/apps/backend/scripts/create_admin.py b/apps/backend/scripts/create_admin.py index 6802469..59319af 100644 --- a/apps/backend/scripts/create_admin.py +++ b/apps/backend/scripts/create_admin.py @@ -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() diff --git a/apps/backend/scripts/migrate_add_account_lockout.py b/apps/backend/scripts/migrate_add_account_lockout.py index 2fc3eb0..6588e50 100644 --- a/apps/backend/scripts/migrate_add_account_lockout.py +++ b/apps/backend/scripts/migrate_add_account_lockout.py @@ -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: diff --git a/apps/backend/scripts/test_exceptions.py b/apps/backend/scripts/test_exceptions.py index b99ba58..4091cf5 100644 --- a/apps/backend/scripts/test_exceptions.py +++ b/apps/backend/scripts/test_exceptions.py @@ -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: diff --git a/apps/backend/services/admin_service.py b/apps/backend/services/admin_service.py index 4a7a221..5842124 100644 --- a/apps/backend/services/admin_service.py +++ b/apps/backend/services/admin_service.py @@ -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) diff --git a/apps/backend/services/auth_service.py b/apps/backend/services/auth_service.py index 3761c34..50a4799 100644 --- a/apps/backend/services/auth_service.py +++ b/apps/backend/services/auth_service.py @@ -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": "取消失败或会话不存在"} diff --git a/apps/backend/services/check_in_service.py b/apps/backend/services/check_in_service.py index b09b75d..a35a1a1 100644 --- a/apps/backend/services/check_in_service.py +++ b/apps/backend/services/check_in_service.py @@ -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 diff --git a/apps/backend/services/email_service.py b/apps/backend/services/email_service.py index fe6c6d6..e165459 100644 --- a/apps/backend/services/email_service.py +++ b/apps/backend/services/email_service.py @@ -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""" @@ -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""" @@ -270,7 +276,7 @@ class EmailService:
✅ 审批结果: 已通过
- 审批时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} + 审批时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
@@ -391,7 +397,7 @@ class EmailService:
❌ 审批结果: 未通过
- 处理时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} + 处理时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
{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:
- + - + - {f'' if message else ''} + {f"" if message else ""}
执行时间{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
任务 ID{task_info.get('thread_id', '未知')}{task_info.get("thread_id", "未知")}
打卡状态 {status_text}
失败原因{message}
失败原因{message}
- {token_error_section if is_token_error else '

如有问题,请及时检查您的打卡配置。

'} + {token_error_section if is_token_error else "

如有问题,请及时检查您的打卡配置。

"}