diff --git a/.github/workflows/build_executables.yml b/.github/workflows/build_executables.yml new file mode 100644 index 0000000..955a225 --- /dev/null +++ b/.github/workflows/build_executables.yml @@ -0,0 +1,86 @@ +name: Build Executables with Nuitka + +on: + push: + branches: + - dev-1.0 # Adjust if your target branch is different (e.g., main) + workflow_dispatch: # Allows manual triggering + +jobs: + build: + name: Build on ${{ matrix.os_name }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + os_name: Linux + python-version: '3.10' + output_main_app: uniexam-server_linux + output_cli_app: uniexam-cli_linux + nuitka_plugins: "fastapi,pydantic,multiprocessing" # multiprocessing might be needed by uvicorn/fastapi + - os: windows-latest + os_name: Windows + python-version: '3.10' + output_main_app: uniexam-server_windows.exe + output_cli_app: uniexam-cli_windows.exe + nuitka_plugins: "fastapi,pydantic,multiprocessing" + +steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install nuitka # Ensure Nuitka is installed + + - name: Install platform-specific dependencies (if any) + # Example: If you had GUI libs for Linux like PyQt5 + # if: matrix.os == 'ubuntu-latest' + # run: sudo apt-get update && sudo apt-get install -y libxcb-xinerama0 + run: echo "No platform-specific system dependencies to install for this build." + + - name: Compile Main Application (run.py) with Nuitka + run: | + python -m nuitka --standalone --onefile \ + --output-dir=dist/${{ matrix.os_name }}_main \ + --output-filename=${{ matrix.output_main_app }} \ + --enable-plugin=${{ matrix.nuitka_plugins }} \ + --include-package=app \ + --include-data-dir=data=data \ + --include-data-file=.env.example=.env.example \ + --remove-output \ + run.py + # Note: --onefile can lead to slower startup. Consider removing if that's an issue. + # For data, .env.example is included; actual .env should be handled at runtime. + + - name: Compile CLI Tool (examctl.py) with Nuitka + run: | + python -m nuitka --standalone --onefile \ + --output-dir=dist/${{ matrix.os_name }}_cli \ + --output-filename=${{ matrix.output_cli_app }} \ + --enable-plugin=${{ matrix.nuitka_plugins }} \ + --include-package=app \ + --include-data-dir=data=data \ + --include-data-file=.env.example=.env.example \ + --remove-output \ + examctl.py + + - name: Upload Main Application Artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.output_main_app }} + path: dist/${{ matrix.os_name }}_main/${{ matrix.output_main_app }} + + - name: Upload CLI Tool Artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.output_cli_app }} + path: dist/${{ matrix.os_name }}_cli/${{ matrix.output_cli_app }} diff --git a/.gitignore b/.gitignore index 4e20462..058a958 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ Dockerfile # Other *.swp + +# MkDocs build output +/site/ diff --git a/app/admin_routes.py b/app/admin_routes.py index bcefd9f..a8ad71c 100644 --- a/app/admin_routes.py +++ b/app/admin_routes.py @@ -12,10 +12,12 @@ 所有此模块下的路由都需要管理员权限(通过 `require_admin` 依赖项进行验证)。 """ # region 模块导入 +import asyncio +import json import logging -import asyncio # Added for background task in grading -from typing import List, Optional -from uuid import UUID # Added for paper_id type hint +from datetime import datetime +from typing import Any, Dict, List, Optional +from uuid import UUID from fastapi import ( APIRouter, @@ -23,11 +25,22 @@ HTTPException, Path, Query, + Request, status as http_status, ) -from .core.config import DifficultyLevel -from .core.security import require_admin +from app.utils.export_utils import data_to_csv, data_to_xlsx + +from ..services.audit_logger import audit_logger_service +from ..utils.helpers import get_client_ip_from_request +from .core.config import DifficultyLevel, settings +from .core.security import ( + RequireTags, + get_all_active_token_info, + invalidate_all_tokens_for_user, + invalidate_token, + require_admin, +) from .crud import ( paper_crud_instance as paper_crud, qb_crud_instance as qb_crud, @@ -38,13 +51,13 @@ SettingsResponseModel, SettingsUpdatePayload, ) -from .models.enums import QuestionTypeEnum # Already imported in previous version, ensure it stays +from .models.enums import QuestionTypeEnum from .models.paper_models import ( + GradeSubmissionPayload, PaperAdminView, PaperFullDetailModel, - PendingGradingPaperItem, # New model for grading - SubjectiveQuestionForGrading, # New model for grading - GradeSubmissionPayload, # New model for grading + PendingGradingPaperItem, + SubjectiveQuestionForGrading, ) from .models.qb_models import ( LibraryIndexItem, @@ -54,7 +67,7 @@ from .models.user_models import ( AdminUserUpdate, UserPublicProfile, - UserTag, # Required for RequireTags if we use GRADER, though current plan is ADMIN only for grading + UserTag, ) # endregion @@ -78,10 +91,13 @@ "/settings", response_model=SettingsResponseModel, summary="获取当前系统配置", - description="管理员获取当前应用的主要配置项信息...", # Truncated for brevity + description="管理员获取当前应用的主要配置项信息...", ) -async def admin_get_settings(): - _admin_routes_logger.info("管理员请求获取应用配置。") +async def admin_get_settings(request: Request): + actor_uid = getattr(request.state, "current_user_uid", "unknown_admin") + client_ip = get_client_ip_from_request(request) + _admin_routes_logger.info(f"管理员 '{actor_uid}' (IP: {client_ip}) 请求获取应用配置。") + current_settings_from_file = settings_crud.get_current_settings_from_file() try: return SettingsResponseModel(**current_settings_from_file) @@ -92,70 +108,289 @@ async def admin_get_settings(): @admin_router.post( "/settings", response_model=SettingsResponseModel, - summary="更新系统配置", - description="管理员更新应用的部分或全部可配置项...", # Truncated for brevity + summary="更新系统配置 (仅限高级管理员)", + description="高级管理员 (具有 MANAGER 标签) 更新应用的部分或全部可配置项...", + dependencies=[Depends(RequireTags({UserTag.MANAGER}))] ) -async def admin_update_settings(payload: SettingsUpdatePayload): - _admin_routes_logger.info(f"管理员尝试更新应用配置,数据: {payload.model_dump_json(indent=2)}") +async def admin_update_settings(request: Request, payload: SettingsUpdatePayload): # payload is body, request is dependency + actor_info = getattr(request.state, "user_info_from_token", {"user_uid": "unknown_manager", "tags": [UserTag.MANAGER]}) + actor_uid = actor_info.get("user_uid", "unknown_manager") + client_ip = get_client_ip_from_request(request) + updated_keys = list(payload.model_dump(exclude_unset=True).keys()) + _admin_routes_logger.info(f"管理员 '{actor_uid}' (IP: {client_ip}) 尝试更新应用配置,数据: {payload.model_dump_json(indent=2)}") + try: await settings_crud.update_settings_file_and_reload(payload.model_dump(exclude_unset=True)) settings_from_file_after_update = settings_crud.get_current_settings_from_file() - _admin_routes_logger.info("应用配置已成功更新并重新加载。") + _admin_routes_logger.info(f"管理员 '{actor_uid}' 成功更新并重新加载了应用配置。") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_CONFIG", status="SUCCESS", + actor_uid=actor_uid, actor_ip=client_ip, + details={"message": "应用配置已成功更新", "updated_keys": updated_keys} + ) return SettingsResponseModel(**settings_from_file_after_update) except ValueError as e_val: - _admin_routes_logger.warning(f"管理员更新配置失败 (数据验证错误): {e_val}") + _admin_routes_logger.warning(f"管理员 '{actor_uid}' 更新配置失败 (数据验证错误): {e_val}") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_CONFIG", status="FAILURE", + actor_uid=actor_uid, actor_ip=client_ip, + details={"error": str(e_val), "attempted_keys": updated_keys} + ) raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(e_val)) from e_val except IOError as e_io: - _admin_routes_logger.error(f"管理员更新配置失败 (文件写入错误): {e_io}") + _admin_routes_logger.error(f"管理员 '{actor_uid}' 更新配置失败 (文件写入错误): {e_io}") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_CONFIG", status="FAILURE", + actor_uid=actor_uid, actor_ip=client_ip, + details={"error": f"文件写入错误: {e_io}", "attempted_keys": updated_keys} + ) raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置文件写入失败。") from e_io except RuntimeError as e_rt: - _admin_routes_logger.error(f"管理员更新配置失败 (运行时错误): {e_rt}") + _admin_routes_logger.error(f"管理员 '{actor_uid}' 更新配置失败 (运行时错误): {e_rt}") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_CONFIG", status="FAILURE", + actor_uid=actor_uid, actor_ip=client_ip, + details={"error": f"运行时错误: {e_rt}", "attempted_keys": updated_keys} + ) raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e_rt)) from e_rt except Exception as e: - _admin_routes_logger.error(f"管理员更新配置时发生未知错误: {e}", exc_info=True) + _admin_routes_logger.error(f"管理员 '{actor_uid}' 更新配置时发生未知错误: {e}", exc_info=True) + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_CONFIG", status="FAILURE", + actor_uid=actor_uid, actor_ip=client_ip, + details={"error": f"未知错误: {e}", "attempted_keys": updated_keys} + ) raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail="更新配置时发生意外错误。") from e # endregion # region Admin User Management API 端点 -@admin_router.get("/users", response_model=List[UserPublicProfile], summary="管理员获取用户列表") -async def admin_get_all_users(skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200)): - _admin_routes_logger.info(f"管理员请求用户列表,skip={skip}, limit={limit}。") - users_in_db = await user_crud.admin_get_all_users(skip=skip, limit=limit) +@admin_router.get( + "/users", + summary="管理员获取用户列表 (支持CSV/XLSX导出)", + description="获取用户列表。可通过 'format' 查询参数导出为 CSV 或 XLSX 文件。" +) +async def admin_get_all_users( + request: Request, + skip: int = Query(0, ge=0, description="跳过的用户数"), + limit: int = Query(100, ge=1, le=1000, description="返回的用户数上限 (导出时此限制可能被忽略或调整)"), + export_format: Optional[str] = Query(None, description="导出格式 (csv 或 xlsx)", alias="format", regex="^(csv|xlsx)$") +): + actor_uid = getattr(request.state, "current_user_uid", "unknown_admin") + client_ip = get_client_ip_from_request(request) + _admin_routes_logger.info(f"管理员 '{actor_uid}' (IP: {client_ip}) 请求用户列表,skip={skip}, limit={limit}, format={export_format}。") + + effective_limit = limit + if export_format: + _admin_routes_logger.info(f"导出请求: 正在尝试获取所有用户进行导出 (原 limit={limit} 可能被覆盖)。") + effective_limit = 1_000_000 + + users_in_db = await user_crud.admin_get_all_users(skip=0 if export_format else skip, limit=effective_limit) + + if export_format: + if not users_in_db: + _admin_routes_logger.info("没有用户数据可导出。") + + data_to_export: List[Dict[str, Any]] = [] + for user in users_in_db: + tags_str = ", ".join([tag.value for tag in user.tags]) if user.tags else "" + data_to_export.append({ + "用户ID": user.uid, + "昵称": user.nickname, + "邮箱": user.email, + "QQ": user.qq, + "标签": tags_str, + }) + + headers = ["用户ID", "昵称", "邮箱", "QQ", "标签"] + current_time = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"用户列表_{current_time}.{export_format}" + + if export_format == "csv": + _admin_routes_logger.info(f"准备导出用户列表到 CSV 文件: {filename}") + return data_to_csv(data_list=data_to_export, headers=headers, filename=filename) + elif export_format == "xlsx": + _admin_routes_logger.info(f"准备导出用户列表到 XLSX 文件: {filename}") + return data_to_xlsx(data_list=data_to_export, headers=headers, filename=filename) + + if not users_in_db and skip > 0 : + _admin_routes_logger.info(f"用户列表查询结果为空 (skip={skip}, limit={limit})。") + return [UserPublicProfile.model_validate(user) for user in users_in_db] @admin_router.get("/users/{user_uid}", response_model=UserPublicProfile, summary="管理员获取特定用户信息") -async def admin_get_user(*, user_uid: str = Path(..., description="要获取详情的用户的UID")): - _admin_routes_logger.info(f"管理员请求用户 '{user_uid}' 的详细信息。") +async def admin_get_user(user_uid: str = Path(..., description="要获取详情的用户的UID"), request: Request = Depends(lambda r: r) ): + actor_uid = getattr(request.state, "current_user_uid", "unknown_admin") + client_ip = get_client_ip_from_request(request) + _admin_routes_logger.info(f"管理员 '{actor_uid}' (IP: {client_ip}) 请求用户 '{user_uid}' 的详细信息。") user = await user_crud.get_user_by_uid(user_uid) if not user: - _admin_routes_logger.warning(f"管理员请求用户 '{user_uid}' 失败:用户未找到。") + _admin_routes_logger.warning(f"管理员 '{actor_uid}' 请求用户 '{user_uid}' 失败:用户未找到。") raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail="用户未找到") return UserPublicProfile.model_validate(user) @admin_router.put("/users/{user_uid}", response_model=UserPublicProfile, summary="管理员更新特定用户信息") -async def admin_update_user_info(*, user_uid: str = Path(..., description="要更新信息的用户的UID"), update_payload: AdminUserUpdate): - _admin_routes_logger.info(f"管理员尝试更新用户 '{user_uid}' 的信息,数据: {update_payload.model_dump_json(exclude_none=True)}") +async def admin_update_user_info( + update_payload: AdminUserUpdate, # Body parameter first + user_uid: str = Path(..., description="要更新信息的用户的UID"), + request: Request = Depends(lambda r: r) +): + current_admin_info = getattr(request.state, "user_info_from_token", {"user_uid": "unknown_admin", "tags": []}) + actor_uid = current_admin_info.get("user_uid", "unknown_admin") + current_admin_tags = set(current_admin_info.get("tags", [])) + client_ip = get_client_ip_from_request(request) + + _admin_routes_logger.info(f"管理员 '{actor_uid}' (IP: {client_ip}) 尝试更新用户 '{user_uid}' 的信息,数据: {update_payload.model_dump_json(exclude_none=True)}") + + target_user = await user_crud.get_user_by_uid(user_uid) + if not target_user: + _admin_routes_logger.warning(f"管理员 '{actor_uid}' 更新用户 '{user_uid}' 失败:目标用户未找到。") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_USER", status="FAILURE", + actor_uid=actor_uid, actor_ip=client_ip, + target_resource_type="USER", target_resource_id=user_uid, + details={"message": "目标用户未找到"} + ) + raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail="目标用户未找到。") + + target_user_tags = set(target_user.tags) + + if UserTag.MANAGER in target_user_tags: + if UserTag.MANAGER not in current_admin_tags: + _admin_routes_logger.warning(f"权限拒绝:管理员 '{actor_uid}' 尝试修改高级管理员 '{user_uid}' 的信息。") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_USER", status="FAILURE", + actor_uid=actor_uid, actor_ip=client_ip, + target_resource_type="USER", target_resource_id=user_uid, + details={"message": "权限不足:普通管理员不能修改高级管理员的信息。"} + ) + raise HTTPException(status_code=http_status.HTTP_403_FORBIDDEN, detail="普通管理员不能修改高级管理员的信息。") + + is_modifying_sensitive_fields = update_payload.tags is not None + if UserTag.ADMIN in target_user_tags and UserTag.MANAGER not in target_user_tags: + if is_modifying_sensitive_fields and UserTag.MANAGER not in current_admin_tags: + _admin_routes_logger.warning(f"权限拒绝:管理员 '{actor_uid}' 尝试修改管理员 '{user_uid}' 的敏感信息 (标签)。") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_USER", status="FAILURE", + actor_uid=actor_uid, actor_ip=client_ip, + target_resource_type="USER", target_resource_id=user_uid, + details={"message": "权限不足:普通管理员不能修改其他管理员的标签。"} + ) + raise HTTPException(status_code=http_status.HTTP_403_FORBIDDEN, detail="普通管理员不能修改其他管理员的标签。只有高级管理员可以。") + updated_user = await user_crud.admin_update_user(user_uid, update_payload) + if not updated_user: - _admin_routes_logger.warning(f"管理员更新用户 '{user_uid}' 失败:用户未找到或更新无效。") - raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail="用户未找到或更新失败。") - _admin_routes_logger.info(f"管理员成功更新用户 '{user_uid}' 的信息。") + _admin_routes_logger.warning(f"管理员 '{actor_uid}' 更新用户 '{user_uid}' 失败:CRUD操作返回None (可能是内部错误)。") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_USER", status="FAILURE", + actor_uid=actor_uid, actor_ip=client_ip, + target_resource_type="USER", target_resource_id=user_uid, + details={"message": "用户更新操作在数据库层面失败。"} + ) + raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail="用户更新操作失败。") + + _admin_routes_logger.info(f"管理员 '{actor_uid}' 成功更新用户 '{user_uid}' 的信息。") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_USER", status="SUCCESS", + actor_uid=actor_uid, actor_ip=client_ip, + target_resource_type="USER", target_resource_id=user_uid, + details={"updated_fields": list(update_payload.model_dump(exclude_unset=True).keys())} + ) return UserPublicProfile.model_validate(updated_user) # endregion # region Admin Paper Management API 端点 -@admin_router.get("/papers", response_model=List[PaperAdminView], summary="管理员获取所有试卷摘要列表") -async def admin_get_all_papers_summary(skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200)): - _admin_routes_logger.info(f"管理员请求试卷摘要列表,skip={skip}, limit={limit}。") +@admin_router.get( + "/papers", + summary="管理员获取所有试卷摘要列表 (支持CSV/XLSX导出)", + description="获取所有试卷的摘要列表。支持分页、筛选和导出功能。" +) +async def admin_get_all_papers_summary( + request: Request, + skip: int = Query(0, ge=0, description="跳过的记录数"), + limit: int = Query(100, ge=1, le=1000, description="返回的最大记录数 (导出时可能被覆盖)"), + user_uid_filter: Optional[str] = Query(None, alias="user_uid", description="按用户ID筛选"), + difficulty_filter: Optional[DifficultyLevel] = Query(None, alias="difficulty", description="按难度筛选"), + status_filter: Optional[str] = Query(None, alias="status", description="按状态筛选 (例如: 'completed', 'in_progress')"), + export_format: Optional[str] = Query(None, description="导出格式 (csv 或 xlsx)", alias="format", regex="^(csv|xlsx)$") +): + _admin_routes_logger.info( + f"管理员请求试卷摘要列表,skip={skip}, limit={limit}, user_uid={user_uid_filter}, " + f"difficulty={difficulty_filter.value if difficulty_filter else None}, status={status_filter}, format={export_format}。" + ) + + effective_limit = limit + fetch_skip = skip + if export_format: + _admin_routes_logger.info("试卷列表导出请求: 正在尝试获取所有匹配筛选条件的试卷进行导出。") + effective_limit = 1_000_000 + fetch_skip = 0 + try: - all_papers_data = await paper_crud.admin_get_all_papers_summary(skip, limit) + all_papers_data = await paper_crud.admin_get_all_papers_summary( + skip=fetch_skip, + limit=effective_limit, + user_uid=user_uid_filter, + difficulty=difficulty_filter.value if difficulty_filter else None, + status=status_filter + ) + + if export_format: + if not all_papers_data: + _admin_routes_logger.info("没有试卷数据可导出 (基于当前筛选条件)。") + + data_to_export: List[Dict[str, Any]] = [] + for paper_dict in all_papers_data: + pass_status_str = "" + if paper_dict.get('pass_status') is True: + pass_status_str = "通过" + elif paper_dict.get('pass_status') is False: + pass_status_str = "未通过" + + difficulty_val = paper_dict.get('difficulty', '') + if isinstance(difficulty_val, DifficultyLevel): + difficulty_str = difficulty_val.value + else: + difficulty_str = str(difficulty_val) if difficulty_val is not None else '' + + status_val = paper_dict.get('status', '') + status_str = str(status_val) if status_val is not None else '' + + + data_to_export.append({ + "试卷ID": str(paper_dict.get('paper_id', '')), + "用户ID": paper_dict.get('user_uid', ''), + "难度": difficulty_str, + "状态": status_str, + "总得分": paper_dict.get('total_score_obtained', ''), + "百分制得分": f"{paper_dict.get('score_percentage'):.2f}" if paper_dict.get('score_percentage') is not None else '', + "通过状态": pass_status_str, + "创建时间": paper_dict.get('created_at').strftime('%Y-%m-%d %H:%M:%S') if paper_dict.get('created_at') else '', + "完成时间": paper_dict.get('completed_at').strftime('%Y-%m-%d %H:%M:%S') if paper_dict.get('completed_at') else '', + }) + + headers = ["试卷ID", "用户ID", "难度", "状态", "总得分", "百分制得分", "通过状态", "创建时间", "完成时间"] + current_time = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"试卷列表_{current_time}.{export_format}" + + if export_format == "csv": + _admin_routes_logger.info(f"准备导出试卷列表到 CSV 文件: {filename}") + return data_to_csv(data_list=data_to_export, headers=headers, filename=filename) + elif export_format == "xlsx": + _admin_routes_logger.info(f"准备导出试卷列表到 XLSX 文件: {filename}") + return data_to_xlsx(data_list=data_to_export, headers=headers, filename=filename) + + if not all_papers_data and skip > 0: + _admin_routes_logger.info(f"试卷列表查询结果为空 (skip={skip}, limit={limit}, filters applied).") + return [PaperAdminView(**paper_data) for paper_data in all_papers_data] + except Exception as e: _admin_routes_logger.error(f"管理员获取试卷列表时发生意外错误: {e}", exc_info=True) raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取试卷列表时发生错误: {str(e)}") from e @admin_router.get("/papers/{paper_id}", response_model=PaperFullDetailModel, summary="管理员获取特定试卷的完整信息") -async def admin_get_paper_detail(paper_id: str = Path(..., description="要获取详情的试卷ID (UUID格式)")): +async def admin_get_paper_detail(request: Request, paper_id: str = Path(..., description="要获取详情的试卷ID (UUID格式)")): _admin_routes_logger.info(f"管理员请求试卷 '{paper_id}' 的详细信息。") paper_data = await paper_crud.admin_get_paper_detail(paper_id) if not paper_data: @@ -170,7 +405,7 @@ async def admin_get_paper_detail(paper_id: str = Path(..., description="要获 raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"试卷数据格式错误或不完整: {str(e)}") from e @admin_router.delete("/papers/{paper_id}", status_code=http_status.HTTP_204_NO_CONTENT, summary="管理员删除特定试卷") -async def admin_delete_paper(paper_id: str = Path(..., description="要删除的试卷ID (UUID格式)")): +async def admin_delete_paper(request: Request, paper_id: str = Path(..., description="要删除的试卷ID (UUID格式)")): _admin_routes_logger.info(f"管理员尝试删除试卷 '{paper_id}'。") deleted = await paper_crud.admin_delete_paper(paper_id) if not deleted: @@ -182,7 +417,7 @@ async def admin_delete_paper(paper_id: str = Path(..., description="要删除的 # region Admin Question Bank Management API 端点 @admin_router.get("/question-banks", response_model=List[LibraryIndexItem], summary="管理员获取所有题库的元数据列表") -async def admin_get_all_qbank_metadata(): +async def admin_get_all_qbank_metadata(request: Request): _admin_routes_logger.info("管理员请求获取所有题库的元数据。") try: metadata_list = await qb_crud.get_all_library_metadatas() @@ -192,7 +427,7 @@ async def admin_get_all_qbank_metadata(): raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取题库元数据列表时发生错误: {str(e)}") @admin_router.get("/question-banks/{difficulty_id}/content", response_model=QuestionBank, summary="管理员获取特定难度题库的完整内容") -async def admin_get_question_bank_content(difficulty_id: DifficultyLevel = Path(..., description="要获取内容的题库难度ID")): +async def admin_get_question_bank_content(request: Request, difficulty_id: DifficultyLevel = Path(..., description="要获取内容的题库难度ID")): _admin_routes_logger.info(f"管理员请求获取难度为 '{difficulty_id.value}' 的题库内容。") try: full_bank = await qb_crud.get_question_bank_with_content(difficulty_id) @@ -200,13 +435,14 @@ async def admin_get_question_bank_content(difficulty_id: DifficultyLevel = Path( _admin_routes_logger.warning(f"管理员请求难度 '{difficulty_id.value}' 的题库内容失败:题库未找到或为空。") raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=f"难度为 '{difficulty_id.value}' 的题库未加载或不存在。") return full_bank - except HTTPException: raise + except HTTPException: + raise except Exception as e: _admin_routes_logger.error(f"管理员获取题库 '{difficulty_id.value}' 内容时发生意外错误: {e}", exc_info=True) raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取题库 '{difficulty_id.value}' 内容时发生服务器错误。") from e @admin_router.post("/question-banks/{difficulty_id}/questions", response_model=QuestionModel, status_code=http_status.HTTP_201_CREATED, summary="管理员向特定题库添加新题目") -async def admin_add_question_to_bank(question: QuestionModel, difficulty_id: DifficultyLevel = Path(..., description="要添加题目的题库难度ID")): +async def admin_add_question_to_bank(request: Request, question: QuestionModel, difficulty_id: DifficultyLevel = Path(..., description="要添加题目的题库难度ID")): _admin_routes_logger.info(f"管理员尝试向题库 '{difficulty_id.value}' 添加新题目: {question.body[:50]}...") try: added_question = await qb_crud.add_question_to_bank(difficulty_id, question) @@ -223,7 +459,7 @@ async def admin_add_question_to_bank(question: QuestionModel, difficulty_id: Dif raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"向题库 '{difficulty_id.value}' 添加题目时发生服务器错误。") @admin_router.delete("/question-banks/{difficulty_id}/questions", status_code=http_status.HTTP_204_NO_CONTENT, summary="管理员从特定题库删除题目") -async def admin_delete_question_from_bank(difficulty_id: DifficultyLevel = Path(..., description="要删除题目的题库难度ID"), question_index: int = Query(..., alias="index", ge=0)): +async def admin_delete_question_from_bank(request: Request, difficulty_id: DifficultyLevel = Path(..., description="要删除题目的题库难度ID"), question_index: int = Query(..., alias="index", ge=0)): _admin_routes_logger.info(f"管理员尝试从题库 '{difficulty_id.value}' 删除索引为 {question_index} 的题目。") try: deleted_question_data = await qb_crud.delete_question_from_bank(difficulty_id, question_index) @@ -245,7 +481,7 @@ async def admin_delete_question_from_bank(difficulty_id: DifficultyLevel = Path( grading_router = APIRouter( prefix="/grading", tags=["阅卷接口 (Grading)"], - dependencies=[Depends(require_admin)] # Initially restrict to ADMIN + dependencies=[Depends(require_admin)] ) @grading_router.get( @@ -281,12 +517,10 @@ async def get_subjective_questions_for_grading( subjective_questions_for_grading = [] for q_internal in paper_data.get("paper_questions", []): if q_internal.get("question_type") == QuestionTypeEnum.ESSAY_QUESTION.value: - # internal_question_id is already part of q_internal from PaperQuestionInternalDetail subjective_questions_for_grading.append(SubjectiveQuestionForGrading(**q_internal)) if not subjective_questions_for_grading: _admin_routes_logger.info(f"试卷 '{paper_id}' 不包含主观题或主观题数据缺失。") - # Not an error, just might be an empty list if no subjective questions or they are malformed return subjective_questions_for_grading @@ -309,19 +543,13 @@ async def grade_single_subjective_question( teacher_comment=payload.teacher_comment ) if not success: - # grade_subjective_question raises ValueError for known issues like not found, - # so False here might indicate a repository update failure. raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail="更新题目批阅结果失败。") - # Asynchronously attempt to finalize paper grading if all subjective questions are now graded. - # The client does not wait for this, ensuring a quick response for grading a single question. - # The paper's status will be updated in the background if finalization occurs. asyncio.create_task(paper_crud.finalize_paper_grading_if_ready(paper_id)) - return None # HTTP 204 + return None except ValueError as ve: _admin_routes_logger.warning(f"批改主观题失败 (paper_id: {paper_id}, q_id: {question_internal_id}): {ve}") - # Determine if it's a 404 (not found) or 400 (bad request, e.g. not an essay question) if "未找到" in str(ve): raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(ve)) else: @@ -330,9 +558,170 @@ async def grade_single_subjective_question( _admin_routes_logger.error(f"批改主观题时发生意外错误 (paper_id: {paper_id}, q_id: {question_internal_id}): {e}", exc_info=True) raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail="批改主观题时发生意外错误。") -admin_router.include_router(grading_router) # 将阅卷路由挂载到管理员路由下 +admin_router.include_router(grading_router) +# endregion + +# region Admin Token Management API 端点 +token_admin_router = APIRouter( + prefix="/tokens", + tags=["管理接口 - Token管理 (Admin - Token Management)"], + dependencies=[Depends(RequireTags({UserTag.MANAGER}))] +) + +@token_admin_router.get( + "/", + response_model=List[Dict[str, Any]], + summary="管理员获取当前所有活动Token的摘要列表", + description="获取系统中所有当前活动(未过期)用户访问Token的摘要信息列表。此列表主要用于监控和审计目的。" +) +async def admin_list_active_tokens(request: Request): + actor_uid = getattr(request.state, "current_user_uid", "unknown_admin") + client_ip = get_client_ip_from_request(request) + _admin_routes_logger.info(f"管理员 '{actor_uid}' (IP: {client_ip}) 请求获取所有活动Token的列表。") + + active_tokens_info = await get_all_active_token_info() + + await audit_logger_service.log_event( + action_type="ADMIN_LIST_TOKENS", status="SUCCESS", + actor_uid=actor_uid, actor_ip=client_ip, + details={"message": f"管理员查看了活动Token列表 (共 {len(active_tokens_info)} 个)"} + ) + return active_tokens_info + +@token_admin_router.delete( + "/user/{user_uid}", + summary="管理员吊销特定用户的所有活动Token", + description="立即吊销(删除)指定用户ID的所有活动访问Token。此操作会强制该用户在所有设备上登出。" +) +async def admin_invalidate_user_tokens(user_uid: str = Path(..., description="要吊销其Token的用户的UID"), request: Request = Depends(lambda r: r)): + actor_uid = getattr(request.state, "current_user_uid", "unknown_admin") + client_ip = get_client_ip_from_request(request) + _admin_routes_logger.info(f"管理员 '{actor_uid}' (IP: {client_ip}) 尝试吊销用户 '{user_uid}' 的所有Token。") + + invalidated_count = await invalidate_all_tokens_for_user(user_uid) + + _admin_routes_logger.info(f"管理员 '{actor_uid}' 为用户 '{user_uid}' 吊销了 {invalidated_count} 个Token。") + await audit_logger_service.log_event( + action_type="ADMIN_INVALIDATE_USER_TOKENS", status="SUCCESS", + actor_uid=actor_uid, actor_ip=client_ip, + target_resource_type="USER_TOKENS", target_resource_id=user_uid, + details={"message": f"管理员吊销了用户 '{user_uid}' 的 {invalidated_count} 个Token。", "count": invalidated_count} + ) + return { + "message": f"成功为用户 '{user_uid}' 吊销了 {invalidated_count} 个Token。", + "invalidated_count": invalidated_count + } + +@token_admin_router.delete( + "/{token_string}", + status_code=http_status.HTTP_204_NO_CONTENT, + summary="管理员吊销指定的单个活动Token", + description="立即吊销(删除)指定的单个活动访问Token。管理员需要提供完整的Token字符串。请谨慎使用,确保Token字符串的准确性。" +) +async def admin_invalidate_single_token(token_string: str = Path(..., description="要吊销的完整Token字符串"), request: Request = Depends(lambda r: r)): + actor_uid = getattr(request.state, "current_user_uid", "unknown_admin") + client_ip = get_client_ip_from_request(request) + token_prefix_for_log = token_string[:8] + "..." + _admin_routes_logger.info(f"管理员 '{actor_uid}' (IP: {client_ip}) 尝试吊销单个Token (前缀: {token_prefix_for_log})。") + + await invalidate_token(token_string) + + await audit_logger_service.log_event( + action_type="ADMIN_INVALIDATE_SINGLE_TOKEN", status="SUCCESS", + actor_uid=actor_uid, actor_ip=client_ip, + target_resource_type="TOKEN", target_resource_id=token_prefix_for_log, + details={"message": "管理员吊销了单个Token"} + ) + return None + +admin_router.include_router(token_admin_router) # endregion +# region Admin Audit Log Viewing API 端点 + +def _parse_log_timestamp(timestamp_str: str) -> Optional[datetime]: + """安全地将ISO格式的时间戳字符串解析为datetime对象。""" + if not timestamp_str: + return None + try: + return datetime.fromisoformat(timestamp_str) + except ValueError: + try: + _admin_routes_logger.warning(f"无法解析审计日志中的时间戳字符串: '{timestamp_str}'") + return None + except Exception: + _admin_routes_logger.warning(f"解析审计日志时间戳时发生未知错误: '{timestamp_str}'") + return None + + +@admin_router.get( + "/audit-logs", + response_model=List[Dict[str, Any]], + summary="管理员查看审计日志", + description="获取应用审计日志,支持分页和筛选 (操作者UID, 操作类型, 时间范围)。日志默认按时间倒序(最新在前)。" +) +async def admin_view_audit_logs( + request: Request, + page: int = Query(1, ge=1, description="页码"), + per_page: int = Query(50, ge=1, le=200, description="每页条目数"), + actor_uid_filter: Optional[str] = Query(None, alias="actor_uid", description="按操作者UID筛选"), + action_type_filter: Optional[str] = Query(None, alias="action_type", description="按操作类型筛选"), + start_time_filter: Optional[datetime] = Query(None, alias="start_time", description="起始时间筛选 (ISO格式)"), + end_time_filter: Optional[datetime] = Query(None, alias="end_time", description="结束时间筛选 (ISO格式)") +): + log_file_path = settings.audit_log_file_path + if not Path(log_file_path).exists(): + _admin_routes_logger.info(f"审计日志文件 '{log_file_path}' 未找到。") + return [] + + all_log_entries: List[Dict[str, Any]] = [] + try: + with open(log_file_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + try: + log_entry_dict = json.loads(line) + all_log_entries.append(log_entry_dict) + except json.JSONDecodeError: + _admin_routes_logger.warning(f"无法解析的审计日志行 (JSON无效): '{line[:200]}...'") + continue + except IOError as e: + _admin_routes_logger.error(f"读取审计日志文件 '{log_file_path}' 时发生IO错误: {e}") + raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail="读取审计日志失败。") + + filtered_logs = [] + for entry in all_log_entries: + log_timestamp_str = entry.get("timestamp") + log_datetime = _parse_log_timestamp(log_timestamp_str) + + if log_datetime is None and (start_time_filter or end_time_filter): + _admin_routes_logger.debug(f"跳过时间范围筛选无效时间戳的日志条目: event_id={entry.get('event_id')}") + continue + + if actor_uid_filter and entry.get("actor_uid") != actor_uid_filter: + continue + if action_type_filter and entry.get("action_type") != action_type_filter: + continue + if start_time_filter and (log_datetime is None or log_datetime < start_time_filter): + continue + if end_time_filter and (log_datetime is None or log_datetime > end_time_filter): + continue + + filtered_logs.append(entry) + try: + filtered_logs.sort(key=lambda x: x.get("timestamp", ""), reverse=True) + except Exception as e_sort: + _admin_routes_logger.error(f"排序审计日志时出错: {e_sort}. 日志可能未按时间排序。") + + start_index = (page - 1) * per_page + end_index = start_index + per_page + + paginated_logs = filtered_logs[start_index:end_index] + + return paginated_logs + +# endregion __all__ = ["admin_router"] diff --git a/app/core/config.py b/app/core/config.py index d36d3cf..26a0ca6 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -16,8 +16,9 @@ import asyncio # 导入 asyncio 用于锁 (Import asyncio for locks) import json import logging # 导入标准日志模块 (Import standard logging module) -import logging.handlers # 导入日志处理器模块 (Import logging handlers module) +import logging.handlers # 导入日志处理器模块 (Import logging handlers module) import os +from datetime import datetime, timezone # 确保 timezone 也被导入 for JsonFormatter from enum import Enum # 确保 Enum 被导入 (Ensure Enum is imported) from pathlib import Path # 用于处理文件路径 (For handling file paths) from typing import Any, Dict, List, Optional @@ -34,25 +35,28 @@ # 导入自定义枚举类型 (Import custom enum type) from ..models.enums import AuthStatusCodeEnum, LogLevelEnum # 导入认证状态码枚举 -from datetime import datetime, timezone # 确保 timezone 也被导入 for JsonFormatter # endregion + # region 自定义JSON日志格式化器 (Custom JSON Log Formatter) class JsonFormatter(logging.Formatter): """ 自定义日志格式化器,将日志记录转换为JSON格式字符串。 (Custom log formatter that converts log records into JSON formatted strings.) """ + def format(self, record: logging.LogRecord) -> str: """ 将 LogRecord 对象格式化为JSON字符串。 (Formats a LogRecord object into a JSON string.) """ log_object: Dict[str, Any] = { - "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(), + "timestamp": datetime.fromtimestamp( + record.created, tz=timezone.utc + ).isoformat(), "level": record.levelname, - "message": record.getMessage(), # 获取格式化后的主消息 + "message": record.getMessage(), # 获取格式化后的主消息 "logger_name": record.name, "module": record.module, "function": record.funcName, @@ -71,21 +75,50 @@ def format(self, record: logging.LogRecord) -> str: # 添加通过 extra 传递的额外字段 (Add extra fields passed via extra) # 标准 LogRecord 属性列表,用于排除它们,只提取 "extra" 内容 standard_record_attrs = { - 'args', 'asctime', 'created', 'exc_info', 'exc_text', 'filename', - 'funcName', 'levelname', 'levelno', 'lineno', 'message', 'module', - 'msecs', 'msg', 'name', 'pathname', 'process', 'processName', - 'relativeCreated', 'stack_info', 'thread', 'threadName', + "args", + "asctime", + "created", + "exc_info", + "exc_text", + "filename", + "funcName", + "levelname", + "levelno", + "lineno", + "message", + "module", + "msecs", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "stack_info", + "thread", + "threadName", # Formatter可能添加的内部属性,以及我们已经明确记录的 - 'currentframe', 'taskName', 'timestamp', 'level', 'logger_name', 'function', 'line', - 'thread_id', 'thread_name', 'process_id' + "currentframe", + "taskName", + "timestamp", + "level", + "logger_name", + "function", + "line", + "thread_id", + "thread_name", + "process_id", } # 遍历record中所有非下划线开头的属性 for key, value in record.__dict__.items(): - if not key.startswith('_') and key not in standard_record_attrs: + if not key.startswith("_") and key not in standard_record_attrs: log_object[key] = value - return json.dumps(log_object, ensure_ascii=False, default=str) # default=str 处理无法序列化的对象 + return json.dumps( + log_object, ensure_ascii=False, default=str + ) # default=str 处理无法序列化的对象 + # endregion @@ -440,6 +473,9 @@ class Settings(BaseModel): LogLevelEnum.INFO, # Pydantic将自动使用枚举成员的值 (如 "INFO") description="应用日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL) (Application log level)", ) + audit_log_file_path: str = Field( + "data/logs/audit.log", description="审计日志文件路径 (Audit log file path)" + ) database_files: DatabaseFilesConfig = Field( default_factory=DatabaseFilesConfig, @@ -654,22 +690,22 @@ def setup_logging( # 为文件处理器创建并设置JSON格式化器 (Create and set JSON formatter for file handler) log_file_path = data_dir / log_file_name try: - json_formatter = JsonFormatter() # 使用自定义的JsonFormatter + json_formatter = JsonFormatter() # 使用自定义的JsonFormatter # 使用 TimedRotatingFileHandler 实现日志按天轮转,保留7天备份 # (Use TimedRotatingFileHandler for daily log rotation, keep 7 backups) file_handler = logging.handlers.TimedRotatingFileHandler( filename=log_file_path, - when='midnight', # 每天午夜轮转 (Rotate at midnight) - interval=1, # 每天一次 (Once a day) - backupCount=7, # 保留7个备份文件 (Keep 7 backup files) - encoding='utf-8', - utc=True, # 使用UTC时间进行轮转 (Use UTC for rotation) - delay=False # False: 在创建处理器时即打开文件 (Open file on handler creation) + when="midnight", # 每天午夜轮转 (Rotate at midnight) + interval=1, # 每天一次 (Once a day) + backupCount=7, # 保留7个备份文件 (Keep 7 backup files) + encoding="utf-8", + utc=True, # 使用UTC时间进行轮转 (Use UTC for rotation) + delay=False, # False: 在创建处理器时即打开文件 (Open file on handler creation) ) - file_handler.setFormatter(json_formatter) # 应用JsonFormatter + file_handler.setFormatter(json_formatter) # 应用JsonFormatter app_root_logger.addHandler(file_handler) # 初始日志消息仍将使用根记录器的控制台格式,直到文件处理器被添加。 - _config_module_logger.info( # 此消息本身会通过 console_handler 以文本格式输出 + _config_module_logger.info( # 此消息本身会通过 console_handler 以文本格式输出 f"应用日志将以JSON格式按天轮转写入到 (Application logs will be written in daily rotated JSON format to): {log_file_path} (级别 (Level): {log_level_str})" ) except Exception as e: @@ -802,9 +838,9 @@ def load_settings() -> Settings: dotenv_path=project_root / ".env" ) # 加载 .env 文件中的环境变量 (Load .env file) - json_config: Dict[str, Any] = ( - {} - ) # 用于存放从 settings.json 读取的配置 (For config from settings.json) + json_config: Dict[ + str, Any + ] = {} # 用于存放从 settings.json 读取的配置 (For config from settings.json) if settings_file.exists() and settings_file.is_file(): try: with open(settings_file, "r", encoding="utf-8") as f: diff --git a/app/core/rate_limiter.py b/app/core/rate_limiter.py index dffae95..17d5e0d 100644 --- a/app/core/rate_limiter.py +++ b/app/core/rate_limiter.py @@ -35,12 +35,12 @@ # 键为IP地址 (str),值为对应操作的请求时间戳列表 (List[float]) # (Key is IP address (str), value is a list of request timestamps (List[float]) for the corresponding action) # 分开存储不同操作的速率限制数据 (Store rate limit data for different actions separately) -ip_exam_request_timestamps: Dict[str, List[float]] = ( - {} -) # 获取新试卷 ("get_exam" action) -ip_auth_attempt_timestamps: Dict[str, List[float]] = ( - {} -) # 登录/注册等认证尝试 ("auth_attempts" action) +ip_exam_request_timestamps: Dict[ + str, List[float] +] = {} # 获取新试卷 ("get_exam" action) +ip_auth_attempt_timestamps: Dict[ + str, List[float] +] = {} # 登录/注册等认证尝试 ("auth_attempts" action) # endregion diff --git a/app/core/security.py b/app/core/security.py index cabf99b..dd23a60 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -125,9 +125,7 @@ async def create_access_token(user_uid: str, user_tags: List[UserTag]) -> str: 返回 (Returns): str: 生成的访问Token字符串。(The generated access token string.) """ - async with ( - _token_lock - ): # 确保对 _active_tokens 的操作是原子性的 (Ensure atomic operation on _active_tokens) + async with _token_lock: # 确保对 _active_tokens 的操作是原子性的 (Ensure atomic operation on _active_tokens) token_bytes_length = ( settings.token_length_bytes ) # 从配置获取Token长度 (Get token length from config) @@ -252,6 +250,96 @@ async def cleanup_expired_tokens_periodically(): # 函数名已修正,原为 _security_module_logger.info( f"后台任务:共清理了 {expired_count} 个过期Token。(Background task: Cleaned a total of {expired_count} expired tokens.)" ) + if expired_count > 0: + _security_module_logger.info( + f"后台任务:共清理了 {expired_count} 个过期Token。(Background task: Cleaned a total of {expired_count} expired tokens.)" + ) + + +async def get_all_active_token_info() -> List[Dict[str, Any]]: + """ + 获取所有当前活动Token的信息列表。 + (Retrieves a list of information for all currently active tokens.) + + 返回 (Returns): + List[Dict[str, Any]]: 每个字典包含token_prefix, user_uid, tags, 和 expires_at (ISO格式字符串)。 + (Each dictionary contains token_prefix, user_uid, tags, and expires_at (ISO format string).) + """ + active_token_details = [] + async with _token_lock: + if not _active_tokens: + return [] + + current_time = time.time() + # Iterate over a copy of items in case of modification (though less likely here than in cleanup) + for token_str, token_data in list(_active_tokens.items()): + # Double check expiry, though cleanup should handle most, this function might be called between cleanups + if token_data["expires_at"] <= current_time: + # Token is expired, might as well remove it if found here, though cleanup is primary + # _active_tokens.pop(token_str, None) # Avoid modifying during unprotected iteration if not list(_active_tokens.items()) + # For safety, let cleanup_expired_tokens_periodically handle actual removal + continue + + active_token_details.append( + { + "token_prefix": token_str[:8] + "...", + "user_uid": token_data["user_uid"], + "tags": token_data[ + "tags" + ], # Tags are already stored as list of strings + "expires_at": datetime.fromtimestamp( + token_data["expires_at"], tz=timezone.utc + ).isoformat(), + } + ) + return active_token_details + + +async def invalidate_all_tokens_for_user(user_uid: str) -> int: + """ + 使指定用户的所有活动Token立即失效。 + (Invalidates all active tokens for a specified user immediately.) + + 参数 (Args): + user_uid (str): 需要使其Token失效的用户的UID。 + (The UID of the user whose tokens need to be invalidated.) + + 返回 (Returns): + int: 被成功失效的Token数量。 + (The number of tokens that were successfully invalidated.) + """ + invalidated_count = 0 + tokens_to_remove = [] + async with _token_lock: + # First, identify tokens to remove to avoid modifying dict while iterating + for token_str, token_data in _active_tokens.items(): + if token_data["user_uid"] == user_uid: + tokens_to_remove.append(token_str) + + # Now, remove the identified tokens + for token_str in tokens_to_remove: + if ( + token_str in _active_tokens + ): # Check if still exists (might be removed by another process/task if not careful) + _active_tokens.pop(token_str) + invalidated_count += 1 + _security_module_logger.info( + f"已为用户 '{user_uid}' 失效Token (部分): {token_str[:8]}..." + f"(Invalidated token (partial) for user '{user_uid}': {token_str[:8]}...)" + ) + + if invalidated_count > 0: + _security_module_logger.info( + f"共为用户 '{user_uid}' 失效了 {invalidated_count} 个Token。" + f"(Invalidated a total of {invalidated_count} tokens for user '{user_uid}'.)" + ) + else: + _security_module_logger.info( + f"未找到用户 '{user_uid}' 的活动Token进行失效操作。" + f"(No active tokens found for user '{user_uid}' to invalidate.)" + ) + + return invalidated_count # endregion @@ -288,7 +376,7 @@ async def get_current_user_info_from_token( ) raise HTTPException( status_code=http_status.HTTP_401_UNAUTHORIZED, - detail="无效或已过期的Token", # 完全中文 (Fully Chinese) + detail="无效或已过期的Token", # 完全中文 (Fully Chinese) headers={ "WWW-Authenticate": "Bearer scheme='QueryToken'" }, # 提示客户端使用QueryToken方案 @@ -301,7 +389,7 @@ async def get_current_user_info_from_token( ) raise HTTPException( status_code=http_status.HTTP_403_FORBIDDEN, - detail="用户账户已被封禁", # 完全中文 (Fully Chinese) + detail="用户账户已被封禁", # 完全中文 (Fully Chinese) ) return user_info @@ -446,6 +534,8 @@ async def __call__( "validate_token_and_get_user_info", "invalidate_token", "cleanup_expired_tokens_periodically", # 函数名已修正 + "get_all_active_token_info", # New function + "invalidate_all_tokens_for_user", # New function "get_current_user_info_from_token", "get_current_active_user_uid", # "get_current_admin_user", # 已移除 (Removed) diff --git a/app/crud/__init__.py b/app/crud/__init__.py index 87a0f99..41c0ddd 100644 --- a/app/crud/__init__.py +++ b/app/crud/__init__.py @@ -45,7 +45,12 @@ async def initialize_crud_instances(): 然后使用该存储库实例化各个 CRUD 操作类。 此函数应在应用启动时调用。 """ - global user_crud_instance, paper_crud_instance, qb_crud_instance, settings_crud_instance, repository_instance + global \ + user_crud_instance, \ + paper_crud_instance, \ + qb_crud_instance, \ + settings_crud_instance, \ + repository_instance if settings.data_storage_type == "json": file_paths_config = { diff --git a/app/crud/json_repository.py b/app/crud/json_repository.py index 512e3c7..818e170 100644 --- a/app/crud/json_repository.py +++ b/app/crud/json_repository.py @@ -18,47 +18,124 @@ _json_repo_logger = logging.getLogger(__name__) +# 常见的ID字段名列表,用于自动索引 +# (List of common ID field names for automatic indexing) +COMMON_ID_FIELDS = ["id", "uid", "paper_id"] + class JsonStorageRepository(IDataStorageRepository): """ 一个使用JSON文件进行持久化的数据存储库实现。 它在内存中管理数据,并提供异步文件I/O操作。 + 此实现包含对常见ID字段的内存索引,以加速 `get_by_id` 操作。 + + 性能提示 (Performance Notes): + - `get_by_id`: 如果查询的ID字段被索引 (见 `COMMON_ID_FIELDS`),则查找速度非常快 (O(1))。 + 否则,如果需要回退到线性扫描 (当前未实现回退),则为 O(n)。 + - `create`, `delete`: 除了文件I/O,还包括对索引的更新,通常很快。 + - `update`: 如果ID不变且直接修改内存中的对象引用,索引不需要更新。 + - `query`, `get_all`: 这些操作目前依赖于Python的列表迭代和字典比较, + 对于大型数据集,其性能可能不如数据库的查询引擎。 + - `_persist_data_to_file`: **重要**: 此方法在每次创建、更新或删除操作后重写整个JSON文件。 + 对于频繁写入或大型数据集,这可能成为严重的性能瓶颈,并增加I/O负载。 + 考虑使用更高级的数据存储方案(如SQLite、NoSQL数据库)或更复杂的 + 文件更新策略(例如,仅追加日志式的更改,定期压缩文件)以优化性能。 """ def __init__(self, file_paths_config: Dict[str, Path], base_data_dir: Path): """ 初始化 JsonStorageRepository。 + (Initializes the JsonStorageRepository.) - 参数: + 参数 (Args): file_paths_config (Dict[str, Path]): 一个字典,将实体类型映射到它们各自的 JSON文件路径 (相对于 `base_data_dir`)。 - 例如: {"users": Path("users_db.json"), "papers": Path("papers_db.json")} + (A dictionary mapping entity types to their respective + JSON file paths (relative to `base_data_dir`).) base_data_dir (Path): 存储数据文件的基础目录。 + (The base directory for storing data files.) """ self.base_data_dir = base_data_dir - # 构建每个实体类型的完整文件路径 self.file_paths: Dict[str, Path] = { entity_type: self.base_data_dir / path_suffix for entity_type, path_suffix in file_paths_config.items() } - self.in_memory_data: Dict[str, List[Dict[str, Any]]] = {} # 内存数据副本 - # 为每种实体类型的文件操作创建一个异步锁 + self.in_memory_data: Dict[str, List[Dict[str, Any]]] = {} + # 内存ID索引: {entity_type: {id_field_name: {entity_id_value: entity_object_reference}}} + # (In-memory ID index: {entity_type: {id_field_name: {entity_id_value: entity_object_reference}}}) + self.id_indexes: Dict[str, Dict[str, Dict[str, Any]]] = {} + + # 为每种预定义实体类型的文件操作创建一个异步锁 + # (Create an async lock for file operations for each predefined entity type) self.file_locks: Dict[str, asyncio.Lock] = { entity_type: asyncio.Lock() for entity_type in self.file_paths } - self._load_all_data_on_startup() # 初始化时加载所有数据 + # 注意: 如果后续通过 `create` 方法动态添加新的实体类型, + # 需要确保也为这些新类型创建锁 (已在 `create` 方法中处理)。 + # (Note: If new entity types are dynamically added via the `create` method, + # ensure locks are also created for these new types (handled in `create` method).) + + self._load_all_data_on_startup() # 初始化时加载所有数据并构建索引 + # (Load all data and build indexes on initialization) + + def _build_id_indexes(self, entity_type: str) -> None: + """ + 为指定的实体类型构建内存ID索引。 + (Builds in-memory ID indexes for the specified entity type.) + + 此方法会遍历实体类型的数据,并为 `COMMON_ID_FIELDS` 中定义的每个ID字段创建索引。 + (This method iterates through the data of an entity type and creates an index + for each ID field defined in `COMMON_ID_FIELDS`.) + + 参数 (Args): + entity_type (str): 要为其构建索引的实体类型。 + (The entity type for which to build indexes.) + """ + _json_repo_logger.debug(f"开始为实体类型 '{entity_type}' 构建ID索引。") + # 清除该实体类型现有的所有ID字段索引 (Clear all existing ID field indexes for this entity type) + self.id_indexes[entity_type] = {} + + if ( + entity_type not in self.in_memory_data + or not self.in_memory_data[entity_type] + ): + _json_repo_logger.debug(f"实体类型 '{entity_type}' 无数据,跳过索引构建。") + return + + for item in self.in_memory_data[entity_type]: + for id_field_name in COMMON_ID_FIELDS: + if id_field_name in item: + entity_id_value = str( + item[id_field_name] + ) # 确保ID值为字符串 (Ensure ID value is string) + + # 如果该ID字段的索引尚未初始化,则创建它 + # (If the index for this ID field hasn't been initialized, create it) + if id_field_name not in self.id_indexes[entity_type]: + self.id_indexes[entity_type][id_field_name] = {} + + # 添加到索引,值为对内存中实际对象的引用 + # (Add to index, value is a reference to the actual object in memory) + self.id_indexes[entity_type][id_field_name][entity_id_value] = item + + indexed_fields_count = { + field: len(idx) for field, idx in self.id_indexes[entity_type].items() + } + _json_repo_logger.info( + f"为实体类型 '{entity_type}' 构建ID索引完成。索引字段及条目数: {indexed_fields_count}" + ) def _load_all_data_on_startup(self) -> None: - """在启动时从所有配置的JSON文件加载数据到内存中。""" + """在启动时从所有配置的JSON文件加载数据到内存中,并为每个实体类型构建ID索引。""" for entity_type, file_path in self.file_paths.items(): if entity_type not in self.in_memory_data: - self.in_memory_data[entity_type] = [] # 确保实体类型的键存在 + self.in_memory_data[entity_type] = [] if file_path.exists() and file_path.is_file(): try: with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) - if isinstance(data, list): # 期望文件内容是一个JSON数组 + if isinstance(data, list): self.in_memory_data[entity_type] = data _json_repo_logger.info( f"成功为实体类型 '{entity_type}' 从 '{file_path}' 加载了 {len(data)} 条记录。" @@ -78,18 +155,31 @@ def _load_all_data_on_startup(self) -> None: f"实体类型 '{entity_type}' 的文件在 '{file_path}' 未找到。将初始化为空列表。" ) self.in_memory_data[entity_type] = [] - # 可选:如果希望在启动时即创建文件(即使是空的) - # self._ensure_file_exists(entity_type, file_path) # (这是一个异步方法,此处调用需注意) + + # 为加载的数据构建索引 (Build indexes for the loaded data) + self._build_id_indexes(entity_type) async def _persist_data_to_file(self, entity_type: str) -> bool: - """将指定实体类型的内存数据异步持久化到其JSON文件。""" + """ + 将指定实体类型的内存数据异步持久化到其JSON文件。 + (Persists in-memory data of the specified entity type to its JSON file asynchronously.) + + 性能警告 (Performance Warning): + 此方法会重写实体类型的整个JSON文件。对于频繁写入或大型数据集, + 这可能成为一个显著的性能瓶颈,并可能导致大量的磁盘I/O。 + 在生产环境中,应考虑更健壮的数据存储解决方案或优化写入策略。 + (This method rewrites the entire JSON file for the entity type. For frequent writes + or large datasets, this can become a significant performance bottleneck and may + lead to substantial disk I/O. In production environments, consider more robust + data storage solutions or optimized writing strategies.) + """ if entity_type not in self.file_paths: _json_repo_logger.error(f"尝试持久化未知的实体类型 '{entity_type}'。") return False file_path = self.file_paths[entity_type] lock = self.file_locks.get(entity_type) - if not lock: # 如果实体类型是动态添加的,锁可能尚未创建 + if not lock: _json_repo_logger.warning( f"实体类型 '{entity_type}' 的文件锁未找到,可能是一个新的动态实体类型。" ) @@ -97,19 +187,18 @@ async def _persist_data_to_file(self, entity_type: str) -> bool: async with lock: try: - file_path.parent.mkdir(parents=True, exist_ok=True) # 确保父目录存在 - # 使用深拷贝以防止在异步写入过程中内存数据被修改 + file_path.parent.mkdir(parents=True, exist_ok=True) data_to_write = copy.deepcopy(self.in_memory_data.get(entity_type, [])) with open(file_path, "w", encoding="utf-8") as f: json.dump(data_to_write, f, indent=4, ensure_ascii=False) - _json_repo_logger.debug( # 日志级别改为debug以减少频繁操作时的日志噪音 + _json_repo_logger.debug( f"成功持久化实体类型 '{entity_type}' 的数据到 '{file_path}'。" ) return True - except Exception as e: # 捕获所有可能的写入错误 + except Exception as e: _json_repo_logger.error( f"持久化实体类型 '{entity_type}' 的数据到 '{file_path}' 失败: {e}", - exc_info=True, # 记录完整的异常信息 + exc_info=True, ) return False @@ -126,34 +215,51 @@ async def disconnect(self) -> None: async def get_by_id( self, entity_type: str, entity_id: str ) -> Optional[Dict[str, Any]]: - """根据ID从内存中检索单个实体。""" - if entity_type not in self.in_memory_data: + """ + 根据ID从内存中检索单个实体,优先使用ID索引。 + (Retrieves a single entity from memory by ID, prioritizing ID indexes.) + """ + if entity_type not in self.id_indexes: _json_repo_logger.warning( - f"尝试按ID获取实体,但实体类型 '{entity_type}' 不在内存数据中。" + f"尝试通过ID获取实体,但实体类型 '{entity_type}' 的索引不存在。可能需要先加载数据或该类型无数据。" ) + if entity_type in self.in_memory_data: + _json_repo_logger.debug( + f"实体类型 '{entity_type}' 索引缺失,尝试线性扫描..." + ) + for item in self.in_memory_data[entity_type]: + for id_field_name in COMMON_ID_FIELDS: + if id_field_name in item and str(item[id_field_name]) == str( + entity_id + ): + return copy.deepcopy(item) return None - # 假设 'id' 字段是标准的,或者需要一种方式来指定ID字段。 - # (Assuming the 'id' field is standard, or a way to specify the ID field is needed.) - # 为了简化和通用性,这里假设ID字段是 'id', 'uid', 或 'paper_id' 等。 - # 在更复杂的场景下,可能需要为每种实体类型指定主键字段名。 - id_fields_to_check = [ - "id", - f"{entity_type}_id", - "uid", - "paper_id", - ] # 常见的ID字段名 + entity_id_str = str(entity_id) - for item in self.in_memory_data[entity_type]: - for id_field in id_fields_to_check: - if id_field in item and str(item[id_field]) == str(entity_id): - return copy.deepcopy(item) # 返回深拷贝以防止外部修改内存数据 + for id_field_name, id_map in self.id_indexes[entity_type].items(): + if id_field_name in COMMON_ID_FIELDS: + indexed_item = id_map.get(entity_id_str) + if indexed_item is not None: + _json_repo_logger.debug( + f"实体 '{entity_type}/{entity_id_str}' 通过索引字段 '{id_field_name}' 找到。" + ) + return copy.deepcopy(indexed_item) + + _json_repo_logger.debug( + f"实体 '{entity_type}/{entity_id_str}' 在ID索引中未找到。考虑它是否使用非标准ID字段或确实不存在。" + ) return None async def get_all( self, entity_type: str, skip: int = 0, limit: int = 100 ) -> List[Dict[str, Any]]: - """检索指定类型的所有实体(内存中的副本),支持分页。""" + """ + 检索指定类型的所有实体(内存中的副本),支持分页。 + 性能提示:此操作直接对内存列表进行切片,对于非常大的列表,深拷贝成本可能较高。 + (Retrieves all entities of a specified type (in-memory copies), supports pagination. + Performance note: This operation slices the in-memory list directly; deepcopy cost might be high for very large lists.) + """ if entity_type not in self.in_memory_data: _json_repo_logger.warning( f"尝试获取所有实体,但实体类型 '{entity_type}' 不在内存数据中。" @@ -161,78 +267,114 @@ async def get_all( return [] all_items = self.in_memory_data[entity_type] - # 对内存中的列表进行切片以实现分页 return copy.deepcopy(all_items[skip : skip + limit]) async def create( self, entity_type: str, entity_data: Dict[str, Any] ) -> Dict[str, Any]: - """在内存中创建新实体并异步持久化到文件。""" + """在内存中创建新实体,更新ID索引,并异步持久化到文件。""" if entity_type not in self.in_memory_data: - # 如果实体类型是首次遇到,则在内存和文件路径配置中初始化它 self.in_memory_data[entity_type] = [] + self.id_indexes[entity_type] = {} if entity_type not in self.file_paths: - # 为新的实体类型定义一个默认的文件名规则 self.file_paths[entity_type] = ( self.base_data_dir / f"{entity_type}_db.json" ) - self.file_locks[entity_type] = asyncio.Lock() # 并为其创建文件锁 + self.file_locks[entity_type] = asyncio.Lock() _json_repo_logger.info( f"实体类型 '{entity_type}' 为新类型,已使用默认路径 '{self.file_paths[entity_type]}' 进行初始化。" ) - # 检查ID是否已存在,以避免重复创建 (依赖于 get_by_id 和ID字段的约定) - # 此处假设ID字段存在于entity_data中,或者get_by_id可以处理 - temp_id_fields = ["id", "uid", "paper_id", f"{entity_type}_id"] - entity_id_val = None - for id_field_key in temp_id_fields: - if id_field_key in entity_data: - entity_id_val = str(entity_data[id_field_key]) - break + new_entity_id_val_str: Optional[str] = None - if entity_id_val and await self.get_by_id(entity_type, entity_id_val): - _json_repo_logger.error( - f"尝试创建重复的实体: 类型='{entity_type}', ID='{entity_id_val}'" - ) - raise ValueError( - f"实体类型 '{entity_type}' 中 ID 为 '{entity_id_val}' 的实体已存在。" - ) + for id_field_name in COMMON_ID_FIELDS: + if id_field_name in entity_data: + new_entity_id_val_str = str(entity_data[id_field_name]) + if ( + self.id_indexes[entity_type] + .get(id_field_name, {}) + .get(new_entity_id_val_str) + ): + _json_repo_logger.error( + f"尝试使用已存在的ID创建重复实体: 类型='{entity_type}', 字段='{id_field_name}', ID='{new_entity_id_val_str}'" + ) + raise ValueError( + f"实体类型 '{entity_type}' 中,字段 '{id_field_name}' 的 ID 为 '{new_entity_id_val_str}' 的实体已存在。" + ) + break new_entity = copy.deepcopy(entity_data) - self.in_memory_data[entity_type].append(new_entity) # 添加到内存列表 - await self._persist_data_to_file(entity_type) # 异步持久化到文件 + self.in_memory_data[entity_type].append(new_entity) + + for id_field_name in COMMON_ID_FIELDS: + if id_field_name in new_entity: + entity_id_value = str(new_entity[id_field_name]) + if id_field_name not in self.id_indexes[entity_type]: + self.id_indexes[entity_type][id_field_name] = {} + self.id_indexes[entity_type][id_field_name][entity_id_value] = ( + new_entity + ) + + await self._persist_data_to_file(entity_type) return new_entity async def update( self, entity_type: str, entity_id: str, update_data: Dict[str, Any] ) -> Optional[Dict[str, Any]]: - """根据ID更新内存中的现有实体,并异步持久化更改。""" - if entity_type not in self.in_memory_data: - _json_repo_logger.warning( - f"尝试更新实体,但实体类型 '{entity_type}' 不存在于内存中。" - ) - return None - - id_fields_to_check = ["id", f"{entity_type}_id", "uid", "paper_id"] - found_idx = -1 # 初始化找到的索引为-1 - - # 遍历查找具有匹配ID的实体 - for i, item in enumerate(self.in_memory_data[entity_type]): - for id_field in id_fields_to_check: - if id_field in item and str(item[id_field]) == str(entity_id): - found_idx = i - break - if found_idx != -1: - break - - if found_idx != -1: - entity_to_update = self.in_memory_data[entity_type][found_idx] - entity_to_update.update(update_data) # 执行部分更新 - self.in_memory_data[entity_type][ - found_idx - ] = entity_to_update # 更新内存中的记录 - await self._persist_data_to_file(entity_type) # 异步持久化 - return copy.deepcopy(entity_to_update) + """ + 根据ID更新内存中的现有实体,并异步持久化更改。 + 假设ID字段本身不被修改。如果ID字段可变,则需要更复杂的索引更新逻辑(删除旧索引,添加新索引)。 + (Updates an existing entity in memory by ID and persists changes asynchronously. + Assumes ID fields themselves are not modified. If ID fields are mutable, + more complex index update logic (remove old index, add new index) would be required.) + """ + entity_to_update = await self.get_by_id(entity_type, entity_id) # type: ignore + + if entity_to_update: + actual_item_reference = None + + entity_id_str = str(entity_id) + found_via_index = False + for id_field_name, id_map in self.id_indexes.get(entity_type, {}).items(): + if id_field_name in COMMON_ID_FIELDS: + indexed_item_ref = id_map.get(entity_id_str) + if indexed_item_ref is not None: + actual_item_reference = indexed_item_ref + found_via_index = True + break + + if not found_via_index: + _json_repo_logger.warning( + f"更新 '{entity_type}/{entity_id_str}': 索引未命中,尝试线性扫描获取引用(罕见)。" + ) + for _i, item_in_list in enumerate( # B007: Renamed i to _i + self.in_memory_data.get(entity_type, []) + ): + for id_field in COMMON_ID_FIELDS: + if ( + id_field in item_in_list + and str(item_in_list[id_field]) == entity_id_str + ): + actual_item_reference = item_in_list + break + if actual_item_reference: + break + + if actual_item_reference: + for id_field_name in COMMON_ID_FIELDS: + if id_field_name in update_data and str( + update_data[id_field_name] + ) != str(actual_item_reference.get(id_field_name)): + _json_repo_logger.error( + f"禁止通过 update 方法修改ID字段 '{id_field_name}' (ID field '{id_field_name}' modification via update method is prohibited)." + ) + raise ValueError( + f"不允许通过此 update 方法修改ID字段 '{id_field_name}'。" + ) + + actual_item_reference.update(update_data) + await self._persist_data_to_file(entity_type) + return copy.deepcopy(actual_item_reference) _json_repo_logger.warning( f"尝试更新实体,但在实体类型 '{entity_type}' 中未找到ID为 '{entity_id}' 的实体。" @@ -240,34 +382,72 @@ async def update( return None async def delete(self, entity_type: str, entity_id: str) -> bool: - """根据ID从内存中删除实体,并异步持久化更改。""" + """根据ID从内存中删除实体,从ID索引中移除,并异步持久化更改。""" if entity_type not in self.in_memory_data: _json_repo_logger.warning( f"尝试删除实体,但实体类型 '{entity_type}' 不存在于内存中。" ) return False - id_fields_to_check = ["id", f"{entity_type}_id", "uid", "paper_id"] - # initial_len = len(self.in_memory_data[entity_type]) # 未使用 (Unused) + entity_id_str = str(entity_id) + item_to_delete = None + item_index_in_list = -1 - # 构建一个不包含待删除项的新列表 - items_to_keep = [] - item_deleted = False - for item in self.in_memory_data[entity_type]: - is_match = False - for id_field in id_fields_to_check: - if id_field in item and str(item[id_field]) == str(entity_id): - is_match = True + for id_field_name, id_map in self.id_indexes.get(entity_type, {}).items(): + if id_field_name in COMMON_ID_FIELDS: + item_to_delete = id_map.get(entity_id_str) + if item_to_delete is not None: break - if not is_match: - items_to_keep.append(item) - else: - item_deleted = True - if item_deleted: - self.in_memory_data[entity_type] = items_to_keep - await self._persist_data_to_file(entity_type) # 异步持久化 - return item_deleted + item_deleted_from_list = False + if item_to_delete: + try: + self.in_memory_data[entity_type].remove(item_to_delete) + item_deleted_from_list = True + except ValueError: + _json_repo_logger.error( + f"删除 '{entity_type}/{entity_id_str}': 索引找到但对象不在主数据列表中。索引可能已损坏。" + ) + item_deleted_from_list = False + + if not item_to_delete or not item_deleted_from_list: + _json_repo_logger.debug( + f"删除 '{entity_type}/{entity_id_str}': 索引未命中或列表移除失败,尝试线性扫描。" + ) + for i, item_in_list in enumerate(self.in_memory_data.get(entity_type, [])): + for id_field in COMMON_ID_FIELDS: + if ( + id_field in item_in_list + and str(item_in_list[id_field]) == entity_id_str + ): + item_to_delete = item_in_list + item_index_in_list = i + break + if ( + item_to_delete and item_index_in_list != -1 + ): # Check if found in this scan pass + break + if item_index_in_list != -1: # If found via linear scan + self.in_memory_data[entity_type].pop(item_index_in_list) + item_deleted_from_list = True # Now it's deleted from list + + if item_to_delete and item_deleted_from_list: + for id_field_name, id_map in self.id_indexes.get(entity_type, {}).items(): + if id_field_name in item_to_delete: + id_val_of_deleted = str(item_to_delete[id_field_name]) + if id_val_of_deleted in id_map: + del id_map[id_val_of_deleted] + + await self._persist_data_to_file(entity_type) + _json_repo_logger.info( + f"成功删除并持久化实体 '{entity_type}/{entity_id_str}'。" + ) + return True + + _json_repo_logger.warning( + f"尝试删除实体,但在实体类型 '{entity_type}' 中未找到ID为 '{entity_id_str}' 的实体。" + ) + return False async def query( self, @@ -276,25 +456,28 @@ async def query( skip: int = 0, limit: int = 100, ) -> List[Dict[str, Any]]: - """根据一组条件从内存中查询实体。""" + """ + 根据一组条件从内存中查询实体。 + 性能提示:此查询为线性扫描,对于大型数据集可能较慢。 + (Queries entities from memory based on a set of conditions. + Performance note: This query is a linear scan and may be slow for large datasets.) + """ if entity_type not in self.in_memory_data: _json_repo_logger.warning( - f"尝试查询实体,但实体类型 '{entity_type}' 不存在于内存中。" + f"尝试查询实体,但实体类型 '{entity_type}' 不在内存数据中。" ) return [] results: List[Dict[str, Any]] = [] - # 在内存中进行简单过滤 for item in self.in_memory_data[entity_type]: match = True for key, value in conditions.items(): - if item.get(key) != value: # 精确匹配 + if item.get(key) != value: match = False break if match: results.append(item) - # 对过滤后的结果应用分页 return copy.deepcopy(results[skip : skip + limit]) async def _ensure_file_exists( @@ -304,20 +487,17 @@ async def _ensure_file_exists( initial_data: Optional[List[Dict[str, Any]]] = None, ) -> None: """确保JSON文件存在,如果不存在则创建并用空列表或提供的初始数据初始化。""" - lock = self.file_locks.get(entity_type) # 获取对应实体类型的锁 + lock = self.file_locks.get(entity_type) if not lock: - # 对于动态添加的实体类型,可能需要动态创建锁 _json_repo_logger.warning( f"为实体类型 '{entity_type}' 获取文件锁失败,可能未正确初始化。" ) - return # 或者抛出错误 + return async with lock: - if not file_path.exists(): # 检查文件是否存在 - file_path.parent.mkdir(parents=True, exist_ok=True) # 确保父目录存在 - data_to_write = ( - initial_data if initial_data is not None else [] - ) # 确定写入内容 + if not file_path.exists(): + file_path.parent.mkdir(parents=True, exist_ok=True) + data_to_write = initial_data if initial_data is not None else [] try: with open(file_path, "w", encoding="utf-8") as f: json.dump(data_to_write, f, indent=4, ensure_ascii=False) @@ -337,33 +517,23 @@ async def init_storage_if_needed( 对于JSON存储库,这意味着对应的JSON文件已创建。 """ if entity_type not in self.file_paths: - # 如果实体类型未在初始配置中定义,则为其创建一个默认文件路径和锁 default_path = self.base_data_dir / f"{entity_type}_default_db.json" self.file_paths[entity_type] = default_path - self.file_locks[entity_type] = asyncio.Lock() # 为新实体类型创建锁 + self.file_locks[entity_type] = asyncio.Lock() _json_repo_logger.warning( f"实体类型 '{entity_type}' 无预设文件路径,已默认设置为 '{default_path}'。" ) file_path = self.file_paths[entity_type] - # 确保内存中存在该实体类型的列表 if entity_type not in self.in_memory_data: self.in_memory_data[entity_type] = [] - # 如果文件不存在,则创建并用初始数据(或空列表)填充 if not file_path.exists(): await self._ensure_file_exists(entity_type, file_path, initial_data or []) - # 如果提供了初始数据且内存中为空,则用初始数据填充内存 - # (_load_all_data_on_startup 应该在之后或之前处理了加载逻辑,这里确保创建时一致性) if initial_data and not self.in_memory_data[entity_type]: self.in_memory_data[entity_type] = copy.deepcopy(initial_data) - elif ( - initial_data and not self.in_memory_data[entity_type] - ): # 文件存在,但内存为空,且提供了初始数据 - # 此逻辑分支可能需要根据具体需求调整。 - # 当前:如果文件存在,数据在启动时已加载。此方法主要确保文件创建。 - # 如果希望用 initial_data 覆盖或填充空文件后的内存,需要更复杂的逻辑。 + elif initial_data and not self.in_memory_data[entity_type]: _json_repo_logger.debug( f"实体类型 '{entity_type}' 的文件已存在,内存为空但提供了初始数据。依赖启动时加载。" ) @@ -376,9 +546,7 @@ async def get_all_entity_types(self) -> List[str]: async def persist_all_data(self) -> None: """将所有实体类型的内存数据异步持久化到各自的JSON文件。""" _json_repo_logger.info("尝试持久化所有实体类型的数据...") - for entity_type in list( - self.in_memory_data.keys() - ): # 使用 list() 避免在迭代时修改字典 + for entity_type in list(self.in_memory_data.keys()): await self._persist_data_to_file(entity_type) _json_repo_logger.info("所有数据持久化完成。") diff --git a/app/crud/mysql_repository.py b/app/crud/mysql_repository.py index 822bf9a..a57e13a 100644 --- a/app/crud/mysql_repository.py +++ b/app/crud/mysql_repository.py @@ -115,7 +115,9 @@ async def disconnect(self) -> None: """关闭 MySQL 连接池。(Closes the MySQL connection pool.)""" if self.pool: self.pool.close() - await self.pool.wait_closed() # 等待所有连接关闭 (Wait for all connections to close) + await ( + self.pool.wait_closed() + ) # 等待所有连接关闭 (Wait for all connections to close) self.pool = None _mysql_repo_logger.info( "MySQL 连接池已关闭。 (MySQL connection pool closed.)" @@ -148,9 +150,9 @@ async def init_storage_if_needed( "连接池未初始化,尝试在 init_storage_if_needed 中连接。 (Connection pool not initialized, attempting to connect in init_storage_if_needed.)" ) await self.connect() - assert ( - self.pool is not None - ), "数据库连接池在init_storage_if_needed时必须可用。 (Database connection pool must be available in init_storage_if_needed.)" + assert self.pool is not None, ( + "数据库连接池在init_storage_if_needed时必须可用。 (Database connection pool must be available in init_storage_if_needed.)" + ) async with self.pool.acquire() as conn: async with conn.cursor() as cur: diff --git a/app/crud/paper.py b/app/crud/paper.py index 8e0716c..f852420 100644 --- a/app/crud/paper.py +++ b/app/crud/paper.py @@ -16,7 +16,7 @@ """ # region 模块导入 (Module Imports) -import asyncio # Added for asyncio.create_task +import asyncio # Added for asyncio.create_task import datetime import logging import random @@ -28,8 +28,6 @@ from fastapi import Request from ..core.config import ( # 应用配置及常量 (App config and constants) - CODE_INFO_OR_SPECIFIC_CONDITION, - CODE_SUCCESS, DifficultyLevel, settings, ) @@ -82,9 +80,7 @@ def __init__( self.qb_crud: Any = qb_crud_instance async def initialize_storage(self) -> None: - await self.repository.init_storage_if_needed( - PAPER_ENTITY_TYPE, initial_data=[] - ) + await self.repository.init_storage_if_needed(PAPER_ENTITY_TYPE, initial_data=[]) _paper_crud_logger.info( f"实体类型 '{PAPER_ENTITY_TYPE}' 的存储已初始化(如果需要)。" ) @@ -107,9 +103,7 @@ async def create_new_paper( _paper_crud_logger.error( f"请求了难度 '{difficulty.value}' 但其题库内容为空或元数据未加载。" ) - raise ValueError( - f"难度 '{difficulty.value}' 的题库不可用或为空。" - ) + raise ValueError(f"难度 '{difficulty.value}' 的题库不可用或为空。") current_question_bank_content = [ q.model_dump() for q in full_question_bank.questions @@ -141,13 +135,13 @@ async def create_new_paper( "creation_ip": get_client_ip_from_request(request=request), "difficulty": difficulty.value, "paper_questions": [], - "score": None, # 将代表客观题得分,或在最终批改后代表总分 - "total_score": None, # 新增:用于存储客观题+主观题的总分 + "score": None, # 将代表客观题得分,或在最终批改后代表总分 + "total_score": None, # 新增:用于存储客观题+主观题的总分 "score_percentage": None, "submitted_answers_card": None, "submission_time_utc": None, "submission_ip": None, - "pass_status": PaperPassStatusEnum.PENDING.value, # 初始状态为待处理 + "pass_status": PaperPassStatusEnum.PENDING.value, # 初始状态为待处理 "passcode": None, "last_update_time_utc": None, "last_update_ip": None, @@ -162,7 +156,9 @@ async def create_new_paper( subjective_questions_count = 0 for item_data in selected_question_samples: - question_type_str = item_data.get("question_type", QuestionTypeEnum.SINGLE_CHOICE.value) + question_type_str = item_data.get( + "question_type", QuestionTypeEnum.SINGLE_CHOICE.value + ) if question_type_str == QuestionTypeEnum.ESSAY_QUESTION.value: subjective_questions_count += 1 @@ -182,9 +178,14 @@ async def create_new_paper( } if question_type_str == QuestionTypeEnum.SINGLE_CHOICE.value: - correct_choice_text = random.sample( - item_data.get("correct_choices", ["默认正确答案"]), settings.num_correct_choices_to_select - )[0] if item_data.get("correct_choices") else "默认正确答案" + correct_choice_text = ( + random.sample( + item_data.get("correct_choices", ["默认正确答案"]), + settings.num_correct_choices_to_select, + )[0] + if item_data.get("correct_choices") + else "默认正确答案" + ) correct_choice_id = generate_random_hex_string_of_bytes( settings.generated_code_length_bytes ) @@ -201,7 +202,9 @@ async def create_new_paper( ): text for text in incorrect_choices_texts } - question_entry["correct_choices_map"] = {correct_choice_id: correct_choice_text} + question_entry["correct_choices_map"] = { + correct_choice_id: correct_choice_text + } question_entry["incorrect_choices_map"] = incorrect_choices_with_ids new_paper_data["paper_questions"].append(question_entry) @@ -224,9 +227,16 @@ async def create_new_paper( } client_paper_response_paper_field.append( { - "internal_question_id": q_data.get("internal_question_id"), # Pass internal_question_id to client + "internal_question_id": q_data.get( + "internal_question_id" + ), # Pass internal_question_id to client "body": q_data.get("body", "题目内容缺失"), - "choices": shuffle_dictionary_items(all_choices) if q_data.get("question_type") == QuestionTypeEnum.SINGLE_CHOICE.value else None, + "choices": ( + shuffle_dictionary_items(all_choices) + if q_data.get("question_type") + == QuestionTypeEnum.SINGLE_CHOICE.value + else None + ), "question_type": q_data.get("question_type"), } ) @@ -240,7 +250,7 @@ async def update_paper_progress( self, paper_id: UUID, user_uid: str, - submitted_answers: List[Optional[str]], # For choice questions + submitted_answers: List[Optional[str]], # For choice questions # subjective_answers: Dict[str, str], # For subjective questions, keyed by internal_question_id request: Request, ) -> Dict[str, Any]: @@ -257,11 +267,13 @@ async def update_paper_progress( _paper_crud_logger.warning( f"试卷 '{paper_id}' 未找到或用户 '{user_uid}' 无权访问。" ) - return { "status_code": "NOT_FOUND", "message": "试卷未找到或无权访问。"} + return {"status_code": "NOT_FOUND", "message": "试卷未找到或无权访问。"} current_pass_status = target_paper_record.get("pass_status") - if current_pass_status and \ - current_pass_status not in [PaperPassStatusEnum.PENDING.value, PaperPassStatusEnum.PENDING_REVIEW.value]: + if current_pass_status and current_pass_status not in [ + PaperPassStatusEnum.PENDING.value, + PaperPassStatusEnum.PENDING_REVIEW.value, + ]: _paper_crud_logger.info( f"试卷 '{paper_id}' 状态为 {current_pass_status},无法更新进度。" ) @@ -279,14 +291,16 @@ async def update_paper_progress( # This part primarily handles objective question answers. Subjective answers are handled differently. processed_answers = [None] * num_questions_in_paper for i, q_data in enumerate(paper_questions): - if q_data.get("question_type") == QuestionTypeEnum.SINGLE_CHOICE.value: # Only process for choice questions here - if i < len(submitted_answers): + if ( + q_data.get("question_type") == QuestionTypeEnum.SINGLE_CHOICE.value + ): # Only process for choice questions here + if i < len(submitted_answers): processed_answers[i] = submitted_answers[i] # For subjective questions, student_subjective_answer is updated elsewhere (e.g. during grading or a dedicated save) update_time = datetime.now(timezone.utc).isoformat() update_fields = { - "submitted_answers_card": processed_answers, # Save processed answers for all questions + "submitted_answers_card": processed_answers, # Save processed answers for all questions "last_update_time_utc": update_time, "last_update_ip": get_client_ip_from_request(request=request), } @@ -297,10 +311,12 @@ async def update_paper_progress( ) if not updated_record: - _paper_crud_logger.error( f"在存储库中更新试卷 '{paper_id}' 失败。") - return { "status_code": "UPDATE_FAILED", "message": "保存试卷更新失败。" } + _paper_crud_logger.error(f"在存储库中更新试卷 '{paper_id}' 失败。") + return {"status_code": "UPDATE_FAILED", "message": "保存试卷更新失败。"} - _paper_crud_logger.info( f"用户 '{user_uid}' 的试卷 '{paper_id}' 进度已更新并存储。") + _paper_crud_logger.info( + f"用户 '{user_uid}' 的试卷 '{paper_id}' 进度已更新并存储。" + ) return { "status_code": "PROGRESS_SAVED", "message": "试卷进度已成功保存。", @@ -312,58 +328,90 @@ async def grade_paper_submission( self, paper_id: UUID, user_uid: str, - submitted_answers: List[Optional[str]], # Contains answers for objective questions - # And text for subjective questions, keyed by internal_question_id + submitted_answers: List[ + Optional[str] + ], # Contains answers for objective questions + # And text for subjective questions, keyed by internal_question_id request: Request, ) -> Dict[str, Any]: - _paper_crud_logger.info( f"用户 '{user_uid}' 提交试卷 '{paper_id}' 进行批改。") - target_paper_record = await self.repository.get_by_id( PAPER_ENTITY_TYPE, str(paper_id)) + _paper_crud_logger.info(f"用户 '{user_uid}' 提交试卷 '{paper_id}' 进行批改。") + target_paper_record = await self.repository.get_by_id( + PAPER_ENTITY_TYPE, str(paper_id) + ) if not target_paper_record or target_paper_record.get("user_uid") != user_uid: - _paper_crud_logger.warning(f"试卷 '{paper_id}' 未找到或用户 '{user_uid}' 无权访问。") + _paper_crud_logger.warning( + f"试卷 '{paper_id}' 未找到或用户 '{user_uid}' 无权访问。" + ) return {"status_code": "NOT_FOUND", "message": "试卷未找到或无权访问。"} current_pass_status = target_paper_record.get("pass_status") - if current_pass_status and \ - current_pass_status not in [PaperPassStatusEnum.PENDING.value, PaperPassStatusEnum.PENDING_REVIEW.value]: - _paper_crud_logger.info(f"试卷 '{paper_id}' 已被批改过或处于非可提交状态 ({current_pass_status})。") + if current_pass_status and current_pass_status not in [ + PaperPassStatusEnum.PENDING.value, + PaperPassStatusEnum.PENDING_REVIEW.value, + ]: + _paper_crud_logger.info( + f"试卷 '{paper_id}' 已被批改过或处于非可提交状态 ({current_pass_status})。" + ) return { "status_code": "ALREADY_GRADED_OR_INVALID_STATE", "message": "此试卷已被批改或当前状态无法提交。", "previous_result": current_pass_status, "score": target_paper_record.get("score"), "passcode": target_paper_record.get("passcode"), - "pending_manual_grading_count": target_paper_record.get("pending_manual_grading_count",0) + "pending_manual_grading_count": target_paper_record.get( + "pending_manual_grading_count", 0 + ), } paper_questions = target_paper_record.get("paper_questions", []) if not isinstance(paper_questions, list) or not paper_questions: - _paper_crud_logger.error(f"试卷 '{paper_id}' 缺少 'paper_questions' 或为空,无法批改。") - return {"status_code": "INVALID_PAPER_STRUCTURE", "message": "试卷结构无效,无法批改。"} + _paper_crud_logger.error( + f"试卷 '{paper_id}' 缺少 'paper_questions' 或为空,无法批改。" + ) + return { + "status_code": "INVALID_PAPER_STRUCTURE", + "message": "试卷结构无效,无法批改。", + } if len(submitted_answers) != len(paper_questions): - _paper_crud_logger.warning(f"试卷 '{paper_id}' 提交答案数 ({len(submitted_answers)}) 与题目数 ({len(paper_questions)}) 不符。") - return {"status_code": "INVALID_SUBMISSION", "message": "提交的答案数量与题目总数不匹配。"} + _paper_crud_logger.warning( + f"试卷 '{paper_id}' 提交答案数 ({len(submitted_answers)}) 与题目数 ({len(paper_questions)}) 不符。" + ) + return { + "status_code": "INVALID_SUBMISSION", + "message": "提交的答案数量与题目总数不匹配。", + } objective_questions_total = 0 correct_objective_answers_count = 0 internal_paper_questions = target_paper_record.get("paper_questions", []) for i, q_data in enumerate(internal_paper_questions): - if not isinstance(q_data, dict): continue + if not isinstance(q_data, dict): + continue q_type = q_data.get("question_type") if q_type == QuestionTypeEnum.SINGLE_CHOICE.value: objective_questions_total += 1 correct_map = q_data.get("correct_choices_map") - if correct_map and isinstance(correct_map, dict) and len(correct_map) == 1: + if ( + correct_map + and isinstance(correct_map, dict) + and len(correct_map) == 1 + ): correct_choice_id = list(correct_map.keys())[0] - if i < len(submitted_answers) and submitted_answers[i] == correct_choice_id: + if ( + i < len(submitted_answers) + and submitted_answers[i] == correct_choice_id + ): correct_objective_answers_count += 1 elif q_type == QuestionTypeEnum.ESSAY_QUESTION.value: if i < len(submitted_answers) and submitted_answers[i] is not None: # Store student's subjective answer text - internal_paper_questions[i]["student_subjective_answer"] = str(submitted_answers[i]) + internal_paper_questions[i]["student_subjective_answer"] = str( + submitted_answers[i] + ) # Other types like MULTIPLE_CHOICE, FILL_IN_BLANK would need their own grading logic here current_time_utc_iso = datetime.now(timezone.utc).isoformat() @@ -374,10 +422,10 @@ async def grade_paper_submission( ) update_fields = { - "score": correct_objective_answers_count, # Represents objective score at this stage + "score": correct_objective_answers_count, # Represents objective score at this stage "score_percentage": round(objective_score_percentage, 2), "submitted_answers_card": submitted_answers, - "paper_questions": internal_paper_questions, # Persist updated subjective answers + "paper_questions": internal_paper_questions, # Persist updated subjective answers "submission_time_utc": current_time_utc_iso, "submission_ip": get_client_ip_from_request(request=request), "last_update_time_utc": current_time_utc_iso, @@ -389,25 +437,37 @@ async def grade_paper_submission( # Re-fetch the record to get the latest pending_manual_grading_count (which was set at creation) # and subjective_questions_count - updated_target_paper_record = await self.repository.get_by_id(PAPER_ENTITY_TYPE, str(paper_id)) - if not updated_target_paper_record: # Should not happen if previous update succeeded - _paper_crud_logger.error(f"提交后无法重新获取试卷 '{paper_id}'。") - return {"status_code": "INTERNAL_ERROR", "message": "处理提交时发生内部错误。"} - + updated_target_paper_record = await self.repository.get_by_id( + PAPER_ENTITY_TYPE, str(paper_id) + ) + if ( + not updated_target_paper_record + ): # Should not happen if previous update succeeded + _paper_crud_logger.error(f"提交后无法重新获取试卷 '{paper_id}'。") + return { + "status_code": "INTERNAL_ERROR", + "message": "处理提交时发生内部错误。", + } result_payload: Dict[str, Any] = { "score": correct_objective_answers_count, "score_percentage": round(objective_score_percentage, 2), - "pending_manual_grading_count": updated_target_paper_record.get("pending_manual_grading_count", 0) + "pending_manual_grading_count": updated_target_paper_record.get( + "pending_manual_grading_count", 0 + ), } - has_pending_subjective = updated_target_paper_record.get("pending_manual_grading_count", 0) > 0 + has_pending_subjective = ( + updated_target_paper_record.get("pending_manual_grading_count", 0) > 0 + ) final_pass_status_for_update = "" if has_pending_subjective: final_pass_status_for_update = PaperPassStatusEnum.PENDING_REVIEW.value result_payload["status_code"] = PaperPassStatusEnum.PENDING_REVIEW.value - result_payload["message"] = "客观题已自动批改。试卷包含主观题,请等待人工批阅完成获取最终结果。" + result_payload["message"] = ( + "客观题已自动批改。试卷包含主观题,请等待人工批阅完成获取最终结果。" + ) _paper_crud_logger.info( f"用户 '{user_uid}' 试卷 '{paper_id}' 客观题得分 {correct_objective_answers_count}/{objective_questions_total} ({objective_score_percentage:.2f}%),包含 {updated_target_paper_record.get('pending_manual_grading_count')} 道主观题待批阅。" ) @@ -416,25 +476,51 @@ async def grade_paper_submission( # then this objective score is the final score. if objective_score_percentage >= settings.passing_score_percentage: final_pass_status_for_update = PaperPassStatusEnum.PASSED.value - passcode = generate_random_hex_string_of_bytes(settings.generated_code_length_bytes) - update_fields["passcode"] = passcode # Add passcode to update_fields + passcode = generate_random_hex_string_of_bytes( + settings.generated_code_length_bytes + ) + update_fields["passcode"] = passcode # Add passcode to update_fields result_payload.update( - {"status_code": PaperPassStatusEnum.PASSED.value, "passcode": passcode, "message": "恭喜,您已通过本次考试!"}) - _paper_crud_logger.info( f"用户 '{user_uid}' 试卷 '{paper_id}' 通过,得分 {correct_objective_answers_count}/{objective_questions_total} ({objective_score_percentage:.2f}%)。") + { + "status_code": PaperPassStatusEnum.PASSED.value, + "passcode": passcode, + "message": "恭喜,您已通过本次考试!", + } + ) + _paper_crud_logger.info( + f"用户 '{user_uid}' 试卷 '{paper_id}' 通过,得分 {correct_objective_answers_count}/{objective_questions_total} ({objective_score_percentage:.2f}%)。" + ) else: final_pass_status_for_update = PaperPassStatusEnum.FAILED.value - result_payload.update({"status_code": PaperPassStatusEnum.FAILED.value, "message": "很遗憾,您未能通过本次考试。"}) - _paper_crud_logger.info( f"用户 '{user_uid}' 试卷 '{paper_id}' 未通过,得分 {correct_objective_answers_count}/{objective_questions_total} ({objective_score_percentage:.2f}%)。") + result_payload.update( + { + "status_code": PaperPassStatusEnum.FAILED.value, + "message": "很遗憾,您未能通过本次考试。", + } + ) + _paper_crud_logger.info( + f"用户 '{user_uid}' 试卷 '{paper_id}' 未通过,得分 {correct_objective_answers_count}/{objective_questions_total} ({objective_score_percentage:.2f}%)。" + ) update_fields["pass_status"] = final_pass_status_for_update # Final update with pass_status final_updated_record = await self.repository.update( - PAPER_ENTITY_TYPE, str(paper_id), {"pass_status": final_pass_status_for_update, "passcode": update_fields.get("passcode")} + PAPER_ENTITY_TYPE, + str(paper_id), + { + "pass_status": final_pass_status_for_update, + "passcode": update_fields.get("passcode"), + }, ) if not final_updated_record: - _paper_crud_logger.error(f"在存储库中更新试卷 '{paper_id}' 的最终状态失败。") - return {"status_code": "GRADING_PERSISTENCE_FAILED", "message": "批改完成但保存最终状态失败。"} + _paper_crud_logger.error( + f"在存储库中更新试卷 '{paper_id}' 的最终状态失败。" + ) + return { + "status_code": "GRADING_PERSISTENCE_FAILED", + "message": "批改完成但保存最终状态失败。", + } return result_payload @@ -451,26 +537,39 @@ async def get_user_history(self, user_uid: str) -> List[Dict[str, Any]]: history.append( { "paper_id": paper_data.get("paper_id"), - "difficulty": DifficultyLevel(paper_data.get("difficulty", DifficultyLevel.hybrid.value)), - "score": paper_data.get("score"), # This is objective score or final total if finalized - "total_score": paper_data.get("total_score"), # Show total_score if available + "difficulty": DifficultyLevel( + paper_data.get("difficulty", DifficultyLevel.hybrid.value) + ), + "score": paper_data.get( + "score" + ), # This is objective score or final total if finalized + "total_score": paper_data.get( + "total_score" + ), # Show total_score if available "score_percentage": paper_data.get("score_percentage"), "pass_status": paper_data.get("pass_status"), "submission_time_utc": paper_data.get("submission_time_utc"), - "subjective_questions_count": paper_data.get("subjective_questions_count"), - "pending_manual_grading_count": paper_data.get("pending_manual_grading_count"), + "subjective_questions_count": paper_data.get( + "subjective_questions_count" + ), + "pending_manual_grading_count": paper_data.get( + "pending_manual_grading_count" + ), } ) return sorted( history, - key=lambda x: x.get("submission_time_utc") or x.get("creation_time_utc", ""), + key=lambda x: x.get("submission_time_utc") + or x.get("creation_time_utc", ""), reverse=True, ) async def get_user_paper_detail_for_history( self, paper_id_str: str, user_uid: str ) -> Optional[Dict[str, Any]]: - _paper_crud_logger.debug(f"用户 '{user_uid}' 请求历史试卷 '{paper_id_str}' 的详情。") + _paper_crud_logger.debug( + f"用户 '{user_uid}' 请求历史试卷 '{paper_id_str}' 的详情。" + ) paper_data = await self.repository.get_by_id(PAPER_ENTITY_TYPE, paper_id_str) if paper_data and paper_data.get("user_uid") == user_uid: @@ -479,40 +578,61 @@ async def get_user_paper_detail_for_history( paper_questions_internal = paper_data.get("paper_questions", []) if isinstance(paper_questions_internal, list): for idx, q_internal in enumerate(paper_questions_internal): - if not isinstance(q_internal, dict): continue + if not isinstance(q_internal, dict): + continue all_choices_for_client = { **q_internal.get("correct_choices_map", {}), **q_internal.get("incorrect_choices_map", {}), } - submitted_ans_for_this_q = submitted_answers[idx] if idx < len(submitted_answers) else None + submitted_ans_for_this_q = ( + submitted_answers[idx] if idx < len(submitted_answers) else None + ) q_type_val = q_internal.get("question_type") client_question = { "internal_question_id": q_internal.get("internal_question_id"), "body": q_internal.get("body", "N/A"), "question_type": q_type_val, - "choices": shuffle_dictionary_items(all_choices_for_client) if q_type_val == QuestionTypeEnum.SINGLE_CHOICE.value else None, - "submitted_answer": None, # Will be populated based on type + "choices": ( + shuffle_dictionary_items(all_choices_for_client) + if q_type_val == QuestionTypeEnum.SINGLE_CHOICE.value + else None + ), + "submitted_answer": None, # Will be populated based on type "student_subjective_answer": None, "standard_answer_text": None, "manual_score": q_internal.get("manual_score"), "teacher_comment": q_internal.get("teacher_comment"), - "is_graded_manually": q_internal.get("is_graded_manually", False) + "is_graded_manually": q_internal.get( + "is_graded_manually", False + ), } if q_type_val == QuestionTypeEnum.ESSAY_QUESTION.value: - client_question["student_subjective_answer"] = q_internal.get("student_subjective_answer") - client_question["submitted_answer"] = q_internal.get("student_subjective_answer") # For consistency if client uses submitted_answer - client_question["standard_answer_text"] = q_internal.get("standard_answer_text") - else: # For single_choice etc. + client_question["student_subjective_answer"] = q_internal.get( + "student_subjective_answer" + ) + client_question["submitted_answer"] = q_internal.get( + "student_subjective_answer" + ) # For consistency if client uses submitted_answer + client_question["standard_answer_text"] = q_internal.get( + "standard_answer_text" + ) + else: # For single_choice etc. client_question["submitted_answer"] = submitted_ans_for_this_q - history_questions.append(HistoryPaperQuestionClientView(**client_question).model_dump(exclude_none=True)) + history_questions.append( + HistoryPaperQuestionClientView(**client_question).model_dump( + exclude_none=True + ) + ) # Prepare final response dict response_dict = { "paper_id": paper_data["paper_id"], - "difficulty": DifficultyLevel(paper_data.get("difficulty", DifficultyLevel.hybrid.value)), + "difficulty": DifficultyLevel( + paper_data.get("difficulty", DifficultyLevel.hybrid.value) + ), "user_uid": user_uid, "paper_questions": history_questions, "score": paper_data.get("score"), @@ -522,18 +642,28 @@ async def get_user_paper_detail_for_history( "pass_status": paper_data.get("pass_status"), "passcode": paper_data.get("passcode"), "submission_time_utc": paper_data.get("submission_time_utc"), - "subjective_questions_count": paper_data.get("subjective_questions_count"), - "graded_subjective_questions_count": paper_data.get("graded_subjective_questions_count"), - "pending_manual_grading_count": paper_data.get("pending_manual_grading_count"), + "subjective_questions_count": paper_data.get( + "subjective_questions_count" + ), + "graded_subjective_questions_count": paper_data.get( + "graded_subjective_questions_count" + ), + "pending_manual_grading_count": paper_data.get( + "pending_manual_grading_count" + ), } return response_dict - _paper_crud_logger.warning(f"用户 '{user_uid}' 尝试访问不属于自己的试卷 '{paper_id_str}' 或试卷不存在。") + _paper_crud_logger.warning( + f"用户 '{user_uid}' 尝试访问不属于自己的试卷 '{paper_id_str}' 或试卷不存在。" + ) return None async def admin_get_all_papers_summary( self, skip: int = 0, limit: int = 100 ) -> List[Dict[str, Any]]: - _paper_crud_logger.debug(f"管理员请求所有试卷摘要,skip={skip}, limit={limit}。") + _paper_crud_logger.debug( + f"管理员请求所有试卷摘要,skip={skip}, limit={limit}。" + ) all_papers = await self.repository.get_all( PAPER_ENTITY_TYPE, skip=skip, limit=limit ) @@ -554,8 +684,12 @@ async def admin_get_paper_detail( async def admin_delete_paper(self, paper_id_str: str) -> bool: _paper_crud_logger.info(f"管理员尝试删除试卷 '{paper_id_str}'。") deleted = await self.repository.delete(PAPER_ENTITY_TYPE, paper_id_str) - if deleted: _paper_crud_logger.info(f"[Admin] 试卷 '{paper_id_str}' 已从存储库删除。") - else: _paper_crud_logger.warning(f"[Admin] 删除试卷 '{paper_id_str}' 失败(可能未找到)。") + if deleted: + _paper_crud_logger.info(f"[Admin] 试卷 '{paper_id_str}' 已从存储库删除。") + else: + _paper_crud_logger.warning( + f"[Admin] 删除试卷 '{paper_id_str}' 失败(可能未找到)。" + ) return deleted async def grade_subjective_question( @@ -565,7 +699,9 @@ async def grade_subjective_question( manual_score: float, teacher_comment: Optional[str] = None, ) -> bool: - _paper_crud_logger.info(f"开始人工批改试卷 '{paper_id}' 中的题目 '{question_internal_id}'。") + _paper_crud_logger.info( + f"开始人工批改试卷 '{paper_id}' 中的题目 '{question_internal_id}'。" + ) paper_data = await self.repository.get_by_id(PAPER_ENTITY_TYPE, str(paper_id)) if not paper_data: @@ -582,11 +718,18 @@ async def grade_subjective_question( previously_graded = False for q_idx, q_data in enumerate(paper_questions): - if isinstance(q_data, dict) and q_data.get("internal_question_id") == question_internal_id: + if ( + isinstance(q_data, dict) + and q_data.get("internal_question_id") == question_internal_id + ): question_found = True if q_data.get("question_type") != QuestionTypeEnum.ESSAY_QUESTION.value: - _paper_crud_logger.warning(f"尝试批改的题目 '{question_internal_id}' (试卷 '{paper_id}') 不是主观题。") - raise ValueError(f"题目 '{question_internal_id}' 不是主观题,无法人工批改。") + _paper_crud_logger.warning( + f"尝试批改的题目 '{question_internal_id}' (试卷 '{paper_id}') 不是主观题。" + ) + raise ValueError( + f"题目 '{question_internal_id}' 不是主观题,无法人工批改。" + ) previously_graded = q_data.get("is_graded_manually", False) paper_questions[q_idx]["manual_score"] = manual_score @@ -596,34 +739,52 @@ async def grade_subjective_question( break if not question_found: - _paper_crud_logger.warning(f"批改主观题失败:在试卷 '{paper_id}' 中未找到题目ID '{question_internal_id}'。") - raise ValueError(f"在试卷 '{paper_id}' 中未找到题目ID '{question_internal_id}'。") + _paper_crud_logger.warning( + f"批改主观题失败:在试卷 '{paper_id}' 中未找到题目ID '{question_internal_id}'。" + ) + raise ValueError( + f"在试卷 '{paper_id}' 中未找到题目ID '{question_internal_id}'。" + ) if question_updated: update_payload_for_repo = { - "paper_questions": paper_questions, # 更新后的题目列表 + "paper_questions": paper_questions, # 更新后的题目列表 "last_update_time_utc": datetime.now(timezone.utc).isoformat(), } if not previously_graded: - current_graded_count = paper_data.get("graded_subjective_questions_count", 0) - current_pending_count = paper_data.get("pending_manual_grading_count", 0) - update_payload_for_repo["graded_subjective_questions_count"] = current_graded_count + 1 - update_payload_for_repo["pending_manual_grading_count"] = max(0, current_pending_count - 1) + current_graded_count = paper_data.get( + "graded_subjective_questions_count", 0 + ) + current_pending_count = paper_data.get( + "pending_manual_grading_count", 0 + ) + update_payload_for_repo["graded_subjective_questions_count"] = ( + current_graded_count + 1 + ) + update_payload_for_repo["pending_manual_grading_count"] = max( + 0, current_pending_count - 1 + ) updated_record_partial = await self.repository.update( PAPER_ENTITY_TYPE, str(paper_id), update_payload_for_repo ) if updated_record_partial: - _paper_crud_logger.info(f"试卷 '{paper_id}' 中题目 '{question_internal_id}' 已成功人工批改。") + _paper_crud_logger.info( + f"试卷 '{paper_id}' 中题目 '{question_internal_id}' 已成功人工批改。" + ) # Trigger finalization check (can run in background, or be awaited by API layer if needed) asyncio.create_task(self.finalize_paper_grading_if_ready(paper_id)) return True else: - _paper_crud_logger.error(f"更新试卷 '{paper_id}' 的主观题批改信息失败(存储库操作返回None)。") + _paper_crud_logger.error( + f"更新试卷 '{paper_id}' 的主观题批改信息失败(存储库操作返回None)。" + ) return False return False - async def get_papers_pending_manual_grading(self, skip: int = 0, limit: int = 100) -> List[Dict[str, Any]]: + async def get_papers_pending_manual_grading( + self, skip: int = 0, limit: int = 100 + ) -> List[Dict[str, Any]]: _paper_crud_logger.info(f"获取待人工批阅试卷列表,skip={skip}, limit={limit}。") # This query needs to be supported by the repository or done in multiple steps/filtered here. # Assuming repository query can handle "field > value" or we fetch and filter. @@ -633,21 +794,27 @@ async def get_papers_pending_manual_grading(self, skip: int = 0, limit: int = 10 conditions={"pass_status": PaperPassStatusEnum.PENDING_REVIEW.value}, # Limit might need to be higher if filtering significantly reduces results # Or implement proper DB-level filtering for pending_manual_grading_count > 0 - limit=limit * 5, # Fetch more to allow filtering, adjust as needed - skip=0 # Initial skip is 0, pagination handled after filtering + limit=limit * 5, # Fetch more to allow filtering, adjust as needed + skip=0, # Initial skip is 0, pagination handled after filtering ) papers_needing_grading = [ - p for p in all_pending_review_papers - if p.get("pending_manual_grading_count", 0) > 0 and p.get("subjective_questions_count", 0) > 0 + p + for p in all_pending_review_papers + if p.get("pending_manual_grading_count", 0) > 0 + and p.get("subjective_questions_count", 0) > 0 ] # Manual pagination on the filtered list paginated_list = papers_needing_grading[skip : skip + limit] - _paper_crud_logger.info(f"发现 {len(papers_needing_grading)} 份试卷待批阅,返回 {len(paginated_list)} 份。") + _paper_crud_logger.info( + f"发现 {len(papers_needing_grading)} 份试卷待批阅,返回 {len(paginated_list)} 份。" + ) return paginated_list - async def finalize_paper_grading_if_ready(self, paper_id: UUID) -> Optional[Dict[str, Any]]: + async def finalize_paper_grading_if_ready( + self, paper_id: UUID + ) -> Optional[Dict[str, Any]]: _paper_crud_logger.info(f"检查试卷 '{paper_id}' 是否可以最终定版批改。") paper_data = await self.repository.get_by_id(PAPER_ENTITY_TYPE, str(paper_id)) @@ -655,12 +822,18 @@ async def finalize_paper_grading_if_ready(self, paper_id: UUID) -> Optional[Dict _paper_crud_logger.warning(f"最终定版检查失败:试卷 '{paper_id}' 未找到。") return None - if paper_data.get("pending_manual_grading_count", 0) == 0 and \ - paper_data.get("pass_status") == PaperPassStatusEnum.PENDING_REVIEW.value: - - _paper_crud_logger.info(f"试卷 '{paper_id}' 所有主观题已批改,开始最终计分和状态更新。") + if ( + paper_data.get("pending_manual_grading_count", 0) == 0 + and paper_data.get("pass_status") + == PaperPassStatusEnum.PENDING_REVIEW.value + ): + _paper_crud_logger.info( + f"试卷 '{paper_id}' 所有主观题已批改,开始最终计分和状态更新。" + ) - objective_score = paper_data.get("score", 0) # This is current objective score + objective_score = paper_data.get( + "score", 0 + ) # This is current objective score total_manual_score = 0.0 paper_questions = paper_data.get("paper_questions", []) @@ -673,7 +846,12 @@ async def finalize_paper_grading_if_ready(self, paper_id: UUID) -> Optional[Dict # This part might need refinement based on how max scores for subjective Qs are defined. for q_data in paper_questions: - if isinstance(q_data, dict) and q_data.get("question_type") == QuestionTypeEnum.ESSAY_QUESTION.value and q_data.get("is_graded_manually"): + if ( + isinstance(q_data, dict) + and q_data.get("question_type") + == QuestionTypeEnum.ESSAY_QUESTION.value + and q_data.get("is_graded_manually") + ): total_manual_score += q_data.get("manual_score", 0.0) final_total_score = objective_score + total_manual_score @@ -683,33 +861,50 @@ async def finalize_paper_grading_if_ready(self, paper_id: UUID) -> Optional[Dict # If subjective questions have different max scores, this logic needs to be more complex. # For now, let's assume each question is 1 point for simplicity of pass/fail. total_possible_points = len(paper_questions) if paper_questions else 0 - final_score_percentage = (final_total_score / total_possible_points) * 100 if total_possible_points > 0 else 0.0 + final_score_percentage = ( + (final_total_score / total_possible_points) * 100 + if total_possible_points > 0 + else 0.0 + ) update_fields = { "score": round(final_total_score), - "total_score": round(final_total_score, 2), # Store the combined score explicitly + "total_score": round( + final_total_score, 2 + ), # Store the combined score explicitly "score_percentage": round(final_score_percentage, 2), "last_update_time_utc": datetime.now(timezone.utc).isoformat(), - "pass_status": "", # To be set below + "pass_status": "", # To be set below } if final_score_percentage >= settings.passing_score_percentage: update_fields["pass_status"] = PaperPassStatusEnum.PASSED.value - update_fields["passcode"] = generate_random_hex_string_of_bytes(settings.generated_code_length_bytes) - _paper_crud_logger.info(f"试卷 '{paper_id}' 最终状态:通过。总分: {final_total_score}, 百分比: {final_score_percentage:.2f}%") + update_fields["passcode"] = generate_random_hex_string_of_bytes( + settings.generated_code_length_bytes + ) + _paper_crud_logger.info( + f"试卷 '{paper_id}' 最终状态:通过。总分: {final_total_score}, 百分比: {final_score_percentage:.2f}%" + ) else: update_fields["pass_status"] = PaperPassStatusEnum.FAILED.value - _paper_crud_logger.info(f"试卷 '{paper_id}' 最终状态:未通过。总分: {final_total_score}, 百分比: {final_score_percentage:.2f}%") + _paper_crud_logger.info( + f"试卷 '{paper_id}' 最终状态:未通过。总分: {final_total_score}, 百分比: {final_score_percentage:.2f}%" + ) - updated_paper = await self.repository.update(PAPER_ENTITY_TYPE, str(paper_id), update_fields) + updated_paper = await self.repository.update( + PAPER_ENTITY_TYPE, str(paper_id), update_fields + ) if not updated_paper: - _paper_crud_logger.error(f"更新试卷 '{paper_id}' 的最终批改状态失败。") - return None + _paper_crud_logger.error(f"更新试卷 '{paper_id}' 的最终批改状态失败。") + return None return updated_paper - _paper_crud_logger.info(f"试卷 '{paper_id}' 尚不满足最终定版条件 (待批改主观题: {paper_data.get('pending_manual_grading_count')}, 状态: {paper_data.get('pass_status')})。") + _paper_crud_logger.info( + f"试卷 '{paper_id}' 尚不满足最终定版条件 (待批改主观题: {paper_data.get('pending_manual_grading_count')}, 状态: {paper_data.get('pass_status')})。" + ) return None + # endregion __all__ = [ @@ -721,6 +916,4 @@ async def finalize_paper_grading_if_ready(self, paper_id: UUID) -> Optional[Dict _paper_crud_logger.info( f"模块 {__name__} 提供了试卷数据的CRUD操作类,不应直接执行。" ) - print( - f"模块 {__name__} 提供了试卷数据的CRUD操作类,不应直接执行。" - ) + print(f"模块 {__name__} 提供了试卷数据的CRUD操作类,不应直接执行。") diff --git a/app/crud/postgres_repository.py b/app/crud/postgres_repository.py index f2667db..49ced71 100644 --- a/app/crud/postgres_repository.py +++ b/app/crud/postgres_repository.py @@ -101,9 +101,7 @@ def __init__( "password": password, "database": database, } - if ( - not self.dsn - ): # 如果不使用DSN,则过滤掉值为None的参数 (If not using DSN, filter out None parameters) + if not self.dsn: # 如果不使用DSN,则过滤掉值为None的参数 (If not using DSN, filter out None parameters) self.conn_params = { k: v for k, v in self.conn_params.items() if v is not None } @@ -181,11 +179,13 @@ async def init_storage_if_needed( "连接池未初始化,尝试在 init_storage_if_needed 中连接。 (Connection pool not initialized, attempting to connect in init_storage_if_needed.)" ) await self.connect() - assert ( - self.pool is not None - ), "数据库连接池在init_storage_if_needed时必须可用。 (DB pool must be available.)" + assert self.pool is not None, ( + "数据库连接池在init_storage_if_needed时必须可用。 (DB pool must be available.)" + ) - async with self.pool.acquire() as conn: # 从连接池获取一个连接 (Acquire a connection from the pool) + async with ( + self.pool.acquire() as conn + ): # 从连接池获取一个连接 (Acquire a connection from the pool) # 根据实体类型定义表结构并创建 (Define and create table structure based on entity type) if entity_type == "user": # 用户表 await conn.execute( diff --git a/app/crud/redis_repository.py b/app/crud/redis_repository.py index d9c10f1..bf008ed 100644 --- a/app/crud/redis_repository.py +++ b/app/crud/redis_repository.py @@ -159,9 +159,9 @@ async def get_by_id( """通过ID从Redis检索单个实体(存储为JSON字符串)。(Retrieves a single entity by ID from Redis (stored as JSON string).""" if not self.redis: await self.connect() - assert ( - self.redis is not None - ), "Redis连接未初始化 (Redis connection not initialized)" + assert self.redis is not None, ( + "Redis连接未初始化 (Redis connection not initialized)" + ) key_name = self._get_entity_key(entity_type, entity_id) json_string = await self.redis.get(key_name) @@ -190,9 +190,9 @@ async def get_all( """ if not self.redis: await self.connect() - assert ( - self.redis is not None - ), "Redis连接未初始化 (Redis connection not initialized)" + assert self.redis is not None, ( + "Redis连接未初始化 (Redis connection not initialized)" + ) ids_set_key = self._get_entity_ids_set_key(entity_type) entity_ids = list( @@ -240,9 +240,9 @@ async def create( """在Redis中创建新实体(存储为JSON字符串)。(Creates a new entity in Redis (stored as JSON string).""" if not self.redis: await self.connect() - assert ( - self.redis is not None - ), "Redis连接未初始化 (Redis connection not initialized)" + assert self.redis is not None, ( + "Redis连接未初始化 (Redis connection not initialized)" + ) entity_id: str # 从 entity_data 中确定主键ID (Determine primary key ID from entity_data) @@ -301,9 +301,9 @@ async def update( """通过ID在Redis中更新现有实体。(Updates an existing entity by ID in Redis.)""" if not self.redis: await self.connect() - assert ( - self.redis is not None - ), "Redis连接未初始化 (Redis connection not initialized)" + assert self.redis is not None, ( + "Redis连接未初始化 (Redis connection not initialized)" + ) key_name = self._get_entity_key(entity_type, entity_id) current_json_string = await self.redis.get(key_name) @@ -329,9 +329,9 @@ async def delete(self, entity_type: str, entity_id: str) -> bool: """通过ID从Redis中删除实体及其在ID集合中的引用。(Deletes an entity by ID from Redis and its reference in the ID set.)""" if not self.redis: await self.connect() - assert ( - self.redis is not None - ), "Redis连接未初始化 (Redis connection not initialized)" + assert self.redis is not None, ( + "Redis连接未初始化 (Redis connection not initialized)" + ) key_name = self._get_entity_key(entity_type, entity_id) ids_set_key = self._get_entity_ids_set_key(entity_type) @@ -360,9 +360,9 @@ async def query( """ if not self.redis: await self.connect() - assert ( - self.redis is not None - ), "Redis连接未初始化 (Redis connection not initialized)" + assert self.redis is not None, ( + "Redis连接未初始化 (Redis connection not initialized)" + ) _redis_repo_logger.warning( "正在Redis上执行低效查询(获取所有后过滤)。对于大数据集,请优化。 (Performing inefficient query on Redis (get all then filter). Please optimize for large datasets.)" ) @@ -427,9 +427,9 @@ async def get_all_entity_types(self) -> List[str]: """ if not self.redis: await self.connect() - assert ( - self.redis is not None - ), "Redis连接未初始化 (Redis connection not initialized)" + assert self.redis is not None, ( + "Redis连接未初始化 (Redis connection not initialized)" + ) cursor = b"0" # aioredis scan cursor starts as bytes found_types = set() @@ -438,9 +438,7 @@ async def get_all_entity_types(self) -> List[str]: cursor, keys = await self.redis.scan( cursor=cursor, match=prefix_to_scan, count=100 ) - for ( - key_str - ) in ( + for key_str in ( keys ): # Keys are already decoded if decode_responses=True for Redis client entity_type = key_str.split(":", 1)[ diff --git a/app/crud/settings.py b/app/crud/settings.py index 02d083c..40be721 100644 --- a/app/crud/settings.py +++ b/app/crud/settings.py @@ -14,6 +14,7 @@ `app.core.config` and its associated configuration handling functions, such as loading, validation, and persistence of configurations.) """ + # region 模块导入 (Module Imports) import json import logging diff --git a/app/main.py b/app/main.py index 1074482..bbde7a8 100644 --- a/app/main.py +++ b/app/main.py @@ -4,14 +4,20 @@ FastAPI 应用主入口文件。 负责初始化FastAPI应用实例,加载配置,设置中间件, -挂载各个功能模块的API路由 (用户认证、核心答题、管理员接口等), +挂载各个功能模块的API路由 (用户认证、核心答题、WebSocket 通信、管理员接口等), 并定义应用的生命周期事件 (启动和关闭时的任务)。 +(This is the main entry point file for the FastAPI application. + It is responsible for initializing the FastAPI app instance, loading configurations, + setting up middleware, mounting API routers for various functional modules + (user authentication, core exam-taking, WebSocket communication, admin interfaces, etc.), + and defining application lifecycle events (tasks for startup and shutdown).) """ import asyncio import logging # 用于配置应用级日志 import os -from typing import List, Optional # 确保导入 Dict +from datetime import datetime # For export filename timestamp +from typing import Any, Dict, List, Optional # 确保导入 Dict, Any from uuid import UUID import uvicorn @@ -72,11 +78,17 @@ UserProfileUpdate, UserPublicProfile, ) +from .services.audit_logger import audit_logger_service # Audit logger +from .services.websocket_manager import websocket_manager # WebSocket Manager + +# --- 工具函数导入 --- +from .utils.export_utils import data_to_csv, data_to_xlsx # Export utilities from .utils.helpers import ( # 工具函数 format_short_uuid, get_client_ip_from_request, get_current_timestamp_str, ) +from .websocket_routes import ws_router # WebSocket 接口路由 # endregion @@ -260,6 +272,13 @@ async def sign_up_new_user(payload: UserCreate, request: Request): client_ip = get_client_ip_from_request(request) if is_rate_limited(client_ip, "auth_attempts"): app_logger.warning(f"用户注册请求过于频繁 (IP: {client_ip})。") + # Audit log for rate limit + await audit_logger_service.log_event( + action_type="USER_REGISTER", + status="FAILURE", + actor_ip=client_ip, + details={"message": "注册请求过于频繁", "target_resource_id": payload.uid}, + ) raise HTTPException( status_code=http_status.HTTP_429_TOO_MANY_REQUESTS, detail="注册请求过于频繁,请稍后再试。", @@ -270,6 +289,14 @@ async def sign_up_new_user(payload: UserCreate, request: Request): app_logger.warning( f"用户注册失败:用户名 '{payload.uid}' 已存在 (IP: {client_ip})。" ) + # Audit log for registration failure (user exists) + await audit_logger_service.log_event( + action_type="USER_REGISTER", + status="FAILURE", + actor_uid=payload.uid, + actor_ip=client_ip, + details={"message": "用户名已存在"}, + ) raise HTTPException( status_code=http_status.HTTP_409_CONFLICT, detail=f"用户名 '{payload.uid}' 已被注册。", @@ -277,6 +304,14 @@ async def sign_up_new_user(payload: UserCreate, request: Request): token_str = await create_access_token(user.uid, user.tags) app_logger.info(f"新用户 '{payload.uid}' 注册成功并登录 (IP: {client_ip})。") + # Audit log for successful registration + await audit_logger_service.log_event( + action_type="USER_REGISTER", + status="SUCCESS", + actor_uid=user.uid, + actor_ip=client_ip, + details={"message": "新用户注册成功"}, + ) return Token(access_token=token_str) # 返回Token @@ -306,6 +341,16 @@ async def login_for_access_token(payload: UserCreate, request: Request): client_ip = get_client_ip_from_request(request) if is_rate_limited(client_ip, "auth_attempts"): app_logger.warning(f"用户登录请求过于频繁 (IP: {client_ip})。") + # Audit log for rate limit + await audit_logger_service.log_event( + action_type="USER_LOGIN", + status="FAILURE", + actor_ip=client_ip, + details={ + "message": "登录请求过于频繁", + "target_resource_id": payload.uid if payload else "N/A", + }, + ) raise HTTPException( status_code=http_status.HTTP_429_TOO_MANY_REQUESTS, detail="登录请求过于频繁,请稍后再试。", @@ -318,6 +363,14 @@ async def login_for_access_token(payload: UserCreate, request: Request): app_logger.warning( f"用户 '{payload.uid}' 登录失败:用户名或密码错误 (IP: {client_ip})。" ) + # Audit log for login failure + await audit_logger_service.log_event( + action_type="USER_LOGIN", + status="FAILURE", + actor_uid=payload.uid, + actor_ip=client_ip, + details={"message": "用户名或密码错误"}, + ) raise HTTPException( status_code=http_status.HTTP_401_UNAUTHORIZED, detail="用户名或密码不正确。", @@ -325,6 +378,14 @@ async def login_for_access_token(payload: UserCreate, request: Request): token_str = await create_access_token(user.uid, user.tags) app_logger.info(f"用户 '{payload.uid}' 登录成功 (IP: {client_ip})。") + # Audit log for successful login + await audit_logger_service.log_event( + action_type="USER_LOGIN", + status="SUCCESS", + actor_uid=user.uid, + actor_ip=client_ip, + details={"message": "用户登录成功"}, + ) return Token(access_token=token_str) @@ -623,11 +684,22 @@ async def request_new_exam_paper( f"[{timestamp_str}] 用户 '{current_user_uid}' (IP {client_ip}) 成功创建新试卷 [{difficulty.value}]:{short_id}" ) # 构造并返回响应 - return ExamPaperResponse( + response = ExamPaperResponse( paper_id=new_paper_client_data["paper_id"], difficulty=new_paper_client_data["difficulty"], paper=new_paper_client_data["paper"], ) + # WebSocket 广播: 考试开始 + ws_message_started = { + "event_type": "EXAM_STARTED", + "user_uid": current_user_uid, + "paper_id": str(new_paper_client_data["paper_id"]), + "difficulty": new_paper_client_data["difficulty"].value, + "num_questions": len(new_paper_client_data["paper"]), + "message": f"用户 {current_user_uid} 开始了新试卷 {str(new_paper_client_data['paper_id'])} (难度: {new_paper_client_data['difficulty'].value})。", + } + await websocket_manager.broadcast_message(ws_message_started) + return response except ValueError as ve: # 例如题库题目不足或难度无效 app_logger.warning( f"[{timestamp_str}] 用户 '{current_user_uid}' (IP {client_ip}) 创建新试卷失败 (业务逻辑错误): {ve}" @@ -697,6 +769,22 @@ async def update_exam_progress( app_logger.info( f"[{timestamp_str}] 用户 '{current_user_uid}' (IP {client_ip}) 成功保存试卷 {short_paper_id} 进度。" ) + # WebSocket 广播: 考试进度更新 + ws_message_progress = { + "event_type": "EXAM_PROGRESS_UPDATE", + "user_uid": current_user_uid, + "paper_id": str(payload.paper_id), + "num_answered": len( + payload.result + ), # 注意: payload.result 是当前提交的答案,不一定是总答题数 + # update_result 可能包含更准确的总答题数字段 + "message": f"用户 {current_user_uid} 更新了试卷 {short_paper_id} 的进度。", + } + # 如果 update_result 包含更准确的已回答问题数, 例如: + # if "answered_count" in update_result: + # ws_message_progress["num_answered"] = update_result["answered_count"] + await websocket_manager.broadcast_message(ws_message_progress) + # 移除旧的自定义 'code' 字段,因为HTTP状态码现在是主要指标 update_result.pop("code", None) return UpdateProgressResponse(**update_result) @@ -785,9 +873,22 @@ async def submit_exam_paper( f"{log_msg_prefix},结果: {status_text}, 详情: {outcome}" ) # 返回包含批改结果的JSON响应 - return JSONResponse( + json_response = JSONResponse( content=response_data.model_dump(exclude_none=True), status_code=200 ) + # WebSocket 广播: 考试提交 + ws_message_submitted = { + "event_type": "EXAM_SUBMITTED", + "user_uid": current_user_uid, + "paper_id": str(payload.paper_id), + "score": response_data.score, + "score_percentage": response_data.score_percentage, + "pass_status": outcome.get("status_code") + == "PASSED", # 使用原始 outcome 的 status_code + "message": f"用户 {current_user_uid} 提交了试卷 {short_paper_id},得分: {response_data.score if response_data.score is not None else 'N/A'}", + } + await websocket_manager.broadcast_message(ws_message_submitted) + return json_response except HTTPException as http_exc: raise http_exc except Exception as e: @@ -805,21 +906,115 @@ async def submit_exam_paper( @exam_router.get( "/history", - response_model=List[HistoryItem], - summary="获取用户答题历史", - description="获取当前认证用户的简要答题历史记录列表,包含每次答题的试卷ID、难度、得分等信息。列表按提交时间倒序排列。", + # response_model=List[HistoryItem], # Conditional return + summary="获取用户答题历史 (支持CSV/XLSX导出)", + description="获取当前认证用户的简要答题历史记录列表。可通过 'format' 查询参数导出为 CSV 或 XLSX 文件。", responses={ - http_status.HTTP_200_OK: {"description": "成功获取答题历史"}, + http_status.HTTP_200_OK: { + "description": "成功获取答题历史 (JSON, CSV, or XLSX)" + }, http_status.HTTP_401_UNAUTHORIZED: {"description": "令牌无效或已过期"}, }, ) async def get_user_exam_history( current_user_uid: str = Depends(get_current_active_user_uid), + export_format: Optional[str] = Query( + None, description="导出格式 (csv 或 xlsx)", alias="format", regex="^(csv|xlsx)$" + ), ): - """获取当前认证用户的简要答题历史记录列表 (试卷ID, 难度, 得分等)。""" + """获取当前认证用户的简要答题历史记录列表 (试卷ID, 难度, 得分等)。支持导出。""" timestamp_str = get_current_timestamp_str() - app_logger.info(f"[{timestamp_str}] 用户 '{current_user_uid}' 请求答题历史记录。") - history_data = await paper_crud_instance.get_user_history(current_user_uid) + app_logger.info( + f"[{timestamp_str}] 用户 '{current_user_uid}' 请求答题历史记录 (格式: {export_format or 'json'})。" + ) + + history_data = await paper_crud_instance.get_user_history( + current_user_uid + ) # This returns List[Dict] + + if export_format: + if not history_data: + app_logger.info( + f"[{timestamp_str}] 用户 '{current_user_uid}' 没有答题历史数据可导出。" + ) + # Return empty file for export + + data_to_export: List[Dict[str, Any]] = [] + for item in history_data: # item is a dict + pass_status_str = ( + "未完成" # Default if no pass_status or status is not 'completed' + ) + if ( + item.get("status") == "completed" + ): # Assuming 'status' field indicates completion + if item.get("pass_status") is True: + pass_status_str = "通过" + elif item.get("pass_status") is False: + pass_status_str = "未通过" + + difficulty_val = item.get("difficulty", "") + difficulty_str = ( + difficulty_val.value + if isinstance(difficulty_val, DifficultyLevel) + else str(difficulty_val) + ) + + data_to_export.append( + { + "试卷ID": str(item.get("paper_id", "")), + "难度": difficulty_str, + "状态": str( + item.get("status", "") + ), # Assuming status is an enum or string + "总得分": item.get("total_score_obtained", ""), + "百分制得分": ( + f"{item.get('score_percentage'):.2f}" + if item.get("score_percentage") is not None + else "" + ), + "通过状态": pass_status_str, + "提交时间": ( + item.get("submission_time").strftime("%Y-%m-%d %H:%M:%S") + if item.get("submission_time") + else "" + ), + } + ) + + headers = [ + "试卷ID", + "难度", + "状态", + "总得分", + "百分制得分", + "通过状态", + "提交时间", + ] + current_time_for_file = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = ( + f"答题历史_{current_user_uid}_{current_time_for_file}.{export_format}" + ) + + if export_format == "csv": + app_logger.info( + f"[{timestamp_str}] 用户 '{current_user_uid}' 准备导出答题历史到 CSV 文件: {filename}" + ) + return data_to_csv( + data_list=data_to_export, headers=headers, filename=filename + ) + elif export_format == "xlsx": + app_logger.info( + f"[{timestamp_str}] 用户 '{current_user_uid}' 准备导出答题历史到 XLSX 文件: {filename}" + ) + return data_to_xlsx( + data_list=data_to_export, headers=headers, filename=filename + ) + + # Default JSON response + if not history_data: + app_logger.info(f"[{timestamp_str}] 用户 '{current_user_uid}' 答题历史为空。") + # Return empty list, which is fine. + return [HistoryItem(**item) for item in history_data] @@ -976,6 +1171,7 @@ async def get_users_directory(): # region Admin API 路由挂载 # 管理员相关API路由在 admin_routes.py 中定义,并在此处挂载到主应用 app.include_router(admin_router, prefix="/admin") # 所有管理员接口统一前缀 /admin +app.include_router(ws_router) # 挂载 WebSocket 路由 # endregion # region 主执行块 (用于直接运行此文件进行开发) diff --git a/app/models/audit_log_models.py b/app/models/audit_log_models.py new file mode 100644 index 0000000..3fafdd6 --- /dev/null +++ b/app/models/audit_log_models.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +""" +审计日志相关的Pydantic模型模块。 +(Pydantic Models Module for Audit Logs.) + +此模块定义了用于表示审计日志条目的数据模型。 +这些模型用于数据验证、序列化以及在应用内部传递审计日志信息。 +(This module defines data models for representing audit log entries. +These models are used for data validation, serialization, and for passing +audit log information within the application.) +""" + +import uuid +from datetime import datetime +from typing import Any, Dict, Optional + +from pydantic import BaseModel, Field + + +class AuditLogEntry(BaseModel): + """ + 审计日志条目的Pydantic模型。 + (Pydantic model for an audit log entry.) + """ + + event_id: str = Field( + default_factory=lambda: uuid.uuid4().hex, + description="事件的唯一ID (Unique ID for the event)", + ) + timestamp: datetime = Field( + default_factory=datetime.utcnow, + description="事件发生的时间戳 (UTC) (Timestamp of when the event occurred (UTC))", + ) + actor_uid: Optional[str] = Field( + None, description="执行操作的用户ID (UID of the user performing the action)" + ) + actor_ip: Optional[str] = Field( + None, + description="执行操作用户的IP地址 (IP address of the user performing the action)", + ) + action_type: str = Field( + ..., + description="操作类型 (例如: USER_LOGIN, ITEM_CREATE, CONFIG_UPDATE) (Type of action performed)", + ) + target_resource_type: Optional[str] = Field( + None, + description="操作目标资源的类型 (例如: USER, PAPER, QUESTION_BANK) (Type of the target resource)", + ) + target_resource_id: Optional[str] = Field( + None, description="操作目标资源的ID (ID of the target resource)" + ) + status: str = Field( + ..., + description="操作结果状态 (例如: SUCCESS, FAILURE) (Status of the action outcome)", + ) + details: Optional[Dict[str, Any]] = Field( + None, + description="与事件相关的其他详细信息 (Additional details related to the event)", + ) + + model_config = { + "json_encoders": { + datetime: lambda v: v.isoformat() # Ensure datetime is serialized to ISO format + } + } + + +__all__ = ["AuditLogEntry"] diff --git a/app/models/enums.py b/app/models/enums.py index 92247e3..2277f62 100644 --- a/app/models/enums.py +++ b/app/models/enums.py @@ -7,6 +7,7 @@ (This module defines enumeration types that may be reused by multiple modules across the application.) """ + from enum import Enum @@ -48,7 +49,7 @@ class PaperPassStatusEnum(str, Enum): "GRADING" # 批改中 (Grading in progress - if async/manual grading is needed) ) PENDING = "PENDING" # 待提交或待批改 (Pending submission or grading) - PENDING_REVIEW = "PENDING_REVIEW" # 客观题已批改,主观题待阅 (Objective part graded, subjective pending review) + PENDING_REVIEW = "PENDING_REVIEW" # 客观题已批改,主观题待阅 (Objective part graded, subjective pending review) # 可以根据需要添加更多状态,例如 CANCELED, ERROR_IN_GRADING 等 # (More statuses like CANCELED, ERROR_IN_GRADING can be added as needed) diff --git a/app/models/paper_models.py b/app/models/paper_models.py index 235d5c5..a22da14 100644 --- a/app/models/paper_models.py +++ b/app/models/paper_models.py @@ -9,10 +9,13 @@ paper creation, submission, grading, history viewing, etc. These models are extensively used in API request/response bodies and for internal data transfer.) """ + # region 模块导入 (Module Imports) +import uuid # Fixed F821: For PaperQuestionInternalDetail.internal_question_id default_factory from typing import Dict, List, Optional, Union -from uuid import UUID # 用于处理UUID类型 (For handling UUID type) +# UUID from typing is also available, but direct import of uuid module is common for uuid.uuid4() +# from uuid import UUID # 用于处理UUID类型 (For handling UUID type) -> This is fine, but default_factory needs the module from pydantic import BaseModel, Field from ..core.config import ( @@ -46,7 +49,7 @@ class PaperSubmissionPayload(BaseModel): (Request body model for user submitting a paper or updating progress.) """ - paper_id: UUID = Field( + paper_id: uuid.UUID = Field( ..., description="试卷的唯一标识符。(Unique identifier for the paper.)" ) result: List[Optional[str]] = Field( # 允许答案列表中的元素为None,表示未作答 @@ -71,17 +74,12 @@ class ExamPaperResponse(BaseModel): difficulty: DifficultyLevel = Field( description="试卷的难度级别。(Difficulty level of the paper.)" ) - paper: List[ExamQuestionClientView] = ( - Field( # 使用新定义的模型 (Use the newly defined model) - description="试卷题目列表。(List of paper questions.)" - ) + paper: List[ExamQuestionClientView] = Field( + description="试卷题目列表。(List of paper questions.)" ) - # `submitted_answers_for_resume` 字段已移除,因为恢复逻辑通常在客户端处理或通过专用接口 - # (The `submitted_answers_for_resume` field has been removed, as resume logic is typically - # handled client-side or via a dedicated endpoint.) - model_config = { # Pydantic v2 配置 (Pydantic v2 config) - "populate_by_name": True, # 允许使用别名填充 (Allow population by alias) + model_config = { + "populate_by_name": True, } @@ -91,9 +89,7 @@ class GradingResultResponse(BaseModel): (Response model for the POST /finish endpoint, representing the paper grading result.) """ - # The 'code: int' field is removed as HTTP status codes will be the primary indicator. - # The 'status_code: str' (now PaperPassStatusEnum) remains to indicate business outcome like PASSED/FAILED. - status_code: PaperPassStatusEnum = Field( # Now uses PaperPassStatusEnum + status_code: PaperPassStatusEnum = Field( description="文本状态描述 (例如 'PASSED', 'FAILED', 'ALREADY_GRADED')。(Textual status description (e.g., 'PASSED', 'FAILED', 'ALREADY_GRADED').)" ) message: Optional[str] = Field( @@ -116,7 +112,8 @@ class GradingResultResponse(BaseModel): description="如果试卷之前已被批改,此字段表示之前的状态。(If the paper was previously graded, this field indicates the prior status.)", ) pending_manual_grading_count: Optional[int] = Field( - None, description="等待人工批阅的主观题数量。若为0或None,则表示所有题目已自动或人工批改完毕。 (Number of subjective questions pending manual grading. If 0 or None, all questions are graded.)" + None, + description="等待人工批阅的主观题数量。若为0或None,则表示所有题目已自动或人工批改完毕。 (Number of subjective questions pending manual grading. If 0 or None, all questions are graded.)", ) @@ -162,7 +159,7 @@ class HistoryItem(BaseModel): score_percentage: Optional[float] = Field( None, description="百分制得分 (如果已批改)。(Percentage score (if graded).)" ) - pass_status: Optional[PaperPassStatusEnum] = Field( # 使用枚举 (Use enum) + pass_status: Optional[PaperPassStatusEnum] = Field( None, description="通过状态 ('PASSED', 'FAILED', 或 null)。(Pass status ('PASSED', 'FAILED', or null).)", ) @@ -179,9 +176,7 @@ class HistoryPaperQuestionClientView(BaseModel): """ body: str = Field(description="问题题干。(Question body.)") - question_type: QuestionTypeEnum = Field( - description="题目类型。(Question type.)" - ) # 使用枚举 (Use enum) + question_type: QuestionTypeEnum = Field(description="题目类型。(Question type.)") choices: Optional[Dict[str, str]] = Field( None, description="选择题的选项 (ID到文本的映射,已打乱)。(Options for multiple-choice questions (map of ID to text, shuffled).)", @@ -190,18 +185,21 @@ class HistoryPaperQuestionClientView(BaseModel): None, description="用户对此题提交的答案 (选择题为选项ID或ID列表,填空题为文本列表,主观题为文本)。(User's submitted answer: option ID(s) for choice, list of texts for fill-in-blank, text for subjective.)", ) - # 主观题相关字段,供学生回顾查看 (Subjective question related fields for student review) - student_subjective_answer: Optional[str] = Field( # 重复了 submitted_answer 的部分功能,但更明确针对主观题文本 - None, description="【主观题】学生提交的答案文本(如果题目是主观题)。(Student's submitted text answer if it's a subjective question.)" + student_subjective_answer: Optional[str] = Field( + None, + description="【主观题】学生提交的答案文本(如果题目是主观题)。(Student's submitted text answer if it's a subjective question.)", ) standard_answer_text: Optional[str] = Field( - None, description="【主观题】参考答案或要点(如果允许学生查看)。(Standard answer/key points for subjective questions, if student viewing is allowed.)" + None, + description="【主观题】参考答案或要点(如果允许学生查看)。(Standard answer/key points for subjective questions, if student viewing is allowed.)", ) manual_score: Optional[float] = Field( - None, description="【主观题】此题目的人工批阅得分。(Manual score for this subjective question.)" + None, + description="【主观题】此题目的人工批阅得分。(Manual score for this subjective question.)", ) teacher_comment: Optional[str] = Field( - None, description="【主观题】教师对此题的评语。(Teacher's comment on this subjective question.)" + None, + description="【主观题】教师对此题的评语。(Teacher's comment on this subjective question.)", ) @@ -235,7 +233,7 @@ class HistoryPaperDetailResponse(BaseModel): None, description="用户提交的完整原始答案卡 (选项ID列表,未答为null)。(User's complete original answer card (list of option IDs, null for unanswered).)", ) - pass_status: Optional[PaperPassStatusEnum] = Field( # 使用枚举 (Use enum) + pass_status: Optional[PaperPassStatusEnum] = Field( None, description="最终通过状态。(Final pass status.)" ) passcode: Optional[str] = Field( @@ -245,14 +243,16 @@ class HistoryPaperDetailResponse(BaseModel): None, description="试卷提交时间。(Paper submission time.)" ) pending_manual_grading_count: Optional[int] = Field( - None, description="等待人工批阅的主观题数量。若为0或None,则表示所有题目已自动或人工批改完毕。 (Number of subjective questions pending manual grading. If 0 or None, all questions are graded.)" + None, + description="等待人工批阅的主观题数量。若为0或None,则表示所有题目已自动或人工批改完毕。 (Number of subjective questions pending manual grading. If 0 or None, all questions are graded.)", ) - # 也添加主观题统计信息到历史详情 subjective_questions_count: Optional[int] = Field( - None, description="试卷中主观题的总数量。(Total number of subjective questions in the paper.)" + None, + description="试卷中主观题的总数量。(Total number of subjective questions in the paper.)", ) graded_subjective_questions_count: Optional[int] = Field( - None, description="已人工批阅的主观题数量。(Number of manually graded subjective questions.)" + None, + description="已人工批阅的主观题数量。(Number of manually graded subjective questions.)", ) @@ -294,7 +294,7 @@ class PaperAdminView(BaseModel): ) pass_status: Optional[PaperPassStatusEnum] = Field( None, description="通过状态。(Pass status.)" - ) # 使用枚举 (Use enum) + ) passcode: Optional[str] = Field(None, description="通行码。(Passcode.)") last_update_time_utc: Optional[str] = Field( None, description="最后更新时间 (UTC)。(Last update time (UTC).)" @@ -303,10 +303,12 @@ class PaperAdminView(BaseModel): None, description="最后更新IP地址。(Last update IP address.)" ) subjective_questions_count: Optional[int] = Field( - 0, description="试卷中主观题的总数量。(Total number of subjective questions in the paper.)" + 0, + description="试卷中主观题的总数量。(Total number of subjective questions in the paper.)", ) graded_subjective_questions_count: Optional[int] = Field( - 0, description="已人工批阅的主观题数量。(Number of manually graded subjective questions.)" + 0, + description="已人工批阅的主观题数量。(Number of manually graded subjective questions.)", ) @@ -319,44 +321,50 @@ class PaperQuestionInternalDetail(BaseModel): Includes question content, choice answer mappings, and subjective question answering/grading info.) """ - internal_question_id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="试卷中此题目的唯一内部ID。(Unique internal ID for this question within the paper.)") + internal_question_id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="试卷中此题目的唯一内部ID。(Unique internal ID for this question within the paper.)", + ) body: str = Field(description="问题题干。(Question body.)") - # --- 选择题相关字段 (Choice-based question fields) --- correct_choices_map: Optional[Dict[str, str]] = Field( - None, description="【选择题】正确选项 (ID -> 文本)。(Correct choices (ID -> text) for choice questions.)" + None, + description="【选择题】正确选项 (ID -> 文本)。(Correct choices (ID -> text) for choice questions.)", ) incorrect_choices_map: Optional[Dict[str, str]] = Field( - None, description="【选择题】错误选项 (ID -> 文本)。(Incorrect choices (ID -> text) for choice questions.)" + None, + description="【选择题】错误选项 (ID -> 文本)。(Incorrect choices (ID -> text) for choice questions.)", ) - # --- 通用题目信息 (General question information) --- question_type: Optional[QuestionTypeEnum] = Field( None, description="题目类型 (例如 'single_choice', 'essay_question')。(Question type (e.g., 'single_choice', 'essay_question').)", ) - # 从 QuestionModel 复制的基础字段,用于批阅或展示 (Fields copied from QuestionModel for grading/display) standard_answer_text: Optional[str] = Field( - None, description="【主观题】参考答案或答案要点。(Reference answer for subjective questions.)" + None, + description="【主观题】参考答案或答案要点。(Reference answer for subjective questions.)", ) scoring_criteria: Optional[str] = Field( - None, description="【主观题】评分标准。(Scoring criteria for subjective questions.)" + None, + description="【主观题】评分标准。(Scoring criteria for subjective questions.)", ) ref: Optional[str] = Field( None, description="通用答案解析或参考信息。(General answer explanation or reference information.)", ) - - # --- 主观题作答与批阅信息 (Subjective question answer and grading information) --- student_subjective_answer: Optional[str] = Field( - None, description="学生提交的主观题答案文本。(Student's submitted text answer for subjective questions.)" + None, + description="学生提交的主观题答案文本。(Student's submitted text answer for subjective questions.)", ) manual_score: Optional[float] = Field( - None, description="人工批阅得分(针对单个主观题)。(Manual score for this subjective question.)" + None, + description="人工批阅得分(针对单个主观题)。(Manual score for this subjective question.)", ) teacher_comment: Optional[str] = Field( - None, description="教师对学生此题作答的评语。(Teacher's comment on this subjective question.)" + None, + description="教师对学生此题作答的评语。(Teacher's comment on this subjective question.)", ) is_graded_manually: Optional[bool] = Field( - False, description="此主观题是否已被人工批阅。(Whether this subjective question has been manually graded.)" + False, + description="此主观题是否已被人工批阅。(Whether this subjective question has been manually graded.)", ) @@ -382,7 +390,7 @@ class PaperFullDetailModel(BaseModel): ) submitted_answers_card: Optional[List[Optional[str]]] = Field( None, - description="用户提交的答案卡 (选项ID列表,未答为null)。(User's submitted answer card (list of option IDs, null for unanswered).)", + description="用户提交的答案卡 (选项ID列表,未答为null)。(User's complete original answer card (list of option IDs, null for unanswered).)", ) submission_time_utc: Optional[str] = Field( None, description="提交时间 (UTC)。(Submission time (UTC).)" @@ -392,7 +400,7 @@ class PaperFullDetailModel(BaseModel): ) pass_status: Optional[PaperPassStatusEnum] = Field( None, description="通过状态。(Pass status.)" - ) # 使用枚举 (Use enum) + ) passcode: Optional[str] = Field(None, description="通行码。(Passcode.)") last_update_time_utc: Optional[str] = Field( None, description="最后更新时间 (UTC)。(Last update time (UTC).)" @@ -401,17 +409,19 @@ class PaperFullDetailModel(BaseModel): None, description="最后更新IP地址。(Last update IP address.)" ) subjective_questions_count: Optional[int] = Field( - None, description="试卷中主观题的总数量。(Total number of subjective questions in the paper.)" + None, + description="试卷中主观题的总数量。(Total number of subjective questions in the paper.)", ) graded_subjective_questions_count: Optional[int] = Field( - None, description="已人工批阅的主观题数量。(Number of manually graded subjective questions.)" + None, + description="已人工批阅的主观题数量。(Number of manually graded subjective questions.)", ) # endregion __all__ = [ - "ExamQuestionClientView", # 新增模型 (Added new model) + "ExamQuestionClientView", "PaperSubmissionPayload", "ExamPaperResponse", "GradingResultResponse", @@ -422,7 +432,6 @@ class PaperFullDetailModel(BaseModel): "PaperAdminView", "PaperQuestionInternalDetail", "PaperFullDetailModel", - # Models for Grading Subjective Questions "PendingGradingPaperItem", "SubjectiveQuestionForGrading", "GradeSubmissionPayload", @@ -430,16 +439,24 @@ class PaperFullDetailModel(BaseModel): # region Models for Grading Subjective Questions + class PendingGradingPaperItem(BaseModel): """ 待批阅试卷列表中的项目模型。 (Item model for the list of papers pending manual grading.) """ + paper_id: str = Field(description="试卷ID。(Paper ID.)") user_uid: Optional[str] = Field(None, description="用户UID。(User UID.)") - submission_time_utc: Optional[str] = Field(None, description="提交时间 (UTC)。(Submission time (UTC).)") - subjective_questions_count: Optional[int] = Field(0, description="主观题总数。(Total subjective questions.)") - pending_manual_grading_count: Optional[int] = Field(0, description="待批改主观题数量。(Pending subjective questions.)") + submission_time_utc: Optional[str] = Field( + None, description="提交时间 (UTC)。(Submission time (UTC).)" + ) + subjective_questions_count: Optional[int] = Field( + 0, description="主观题总数。(Total subjective questions.)" + ) + pending_manual_grading_count: Optional[int] = Field( + 0, description="待批改主观题数量。(Pending subjective questions.)" + ) difficulty: Optional[str] = Field(None, description="试卷难度。(Paper difficulty.)") @@ -448,16 +465,32 @@ class SubjectiveQuestionForGrading(BaseModel): 获取待批阅主观题详情时,单个题目的数据模型。 (Data model for a single question when fetching subjective questions for grading.) """ - internal_question_id: str = Field(description="题目在试卷中的唯一内部ID。(Internal unique ID of the question in the paper.)") + + internal_question_id: str = Field( + description="题目在试卷中的唯一内部ID。(Internal unique ID of the question in the paper.)" + ) body: str = Field(description="问题题干。(Question body.)") - question_type: QuestionTypeEnum = Field(description="题目类型 (应为 essay_question)。(Question type (should be essay_question).)") - student_subjective_answer: Optional[str] = Field(None, description="学生提交的答案文本。(Student's submitted text answer.)") - standard_answer_text: Optional[str] = Field(None, description="参考答案或答案要点。(Standard answer or key points.)") - scoring_criteria: Optional[str] = Field(None, description="评分标准。(Scoring criteria.)") - # 当前已保存的批阅信息 (Current saved grading info, if any) - manual_score: Optional[float] = Field(None, description="当前已保存的人工得分。(Current saved manual score.)") - teacher_comment: Optional[str] = Field(None, description="当前已保存的教师评语。(Current saved teacher comment.)") - is_graded_manually: Optional[bool] = Field(False, description="此题是否已批阅。(Whether this question has been graded.)") + question_type: QuestionTypeEnum = Field( + description="题目类型 (应为 essay_question)。(Question type (should be essay_question).)" + ) + student_subjective_answer: Optional[str] = Field( + None, description="学生提交的答案文本。(Student's submitted text answer.)" + ) + standard_answer_text: Optional[str] = Field( + None, description="参考答案或答案要点。(Standard answer or key points.)" + ) + scoring_criteria: Optional[str] = Field( + None, description="评分标准。(Scoring criteria.)" + ) + manual_score: Optional[float] = Field( + None, description="当前已保存的人工得分。(Current saved manual score.)" + ) + teacher_comment: Optional[str] = Field( + None, description="当前已保存的教师评语。(Current saved teacher comment.)" + ) + is_graded_manually: Optional[bool] = Field( + False, description="此题是否已批阅。(Whether this question has been graded.)" + ) class GradeSubmissionPayload(BaseModel): @@ -465,8 +498,18 @@ class GradeSubmissionPayload(BaseModel): 提交单个主观题批阅结果的请求体模型。 (Request body model for submitting the grading result of a single subjective question.) """ - manual_score: float = Field(..., ge=0, description="人工给出的分数 (非负)。(Manually assigned score (non-negative).)") - teacher_comment: Optional[str] = Field(None, max_length=1000, description="教师评语 (可选, 最长1000字符)。(Teacher's comment (optional, max 1000 chars).)") + + manual_score: float = Field( + ..., + ge=0, + description="人工给出的分数 (非负)。(Manually assigned score (non-negative).)", + ) + teacher_comment: Optional[str] = Field( + None, + max_length=1000, + description="教师评语 (可选, 最长1000字符)。(Teacher's comment (optional, max 1000 chars).)", + ) + # endregion diff --git a/app/models/qb_models.py b/app/models/qb_models.py index 6ec522d..79cf4b2 100644 --- a/app/models/qb_models.py +++ b/app/models/qb_models.py @@ -10,6 +10,7 @@ These models are used for data validation, serialization, and for passing question bank information within the application and through API interfaces.) """ + # region 模块导入 (Module Imports) import logging from typing import List, Optional @@ -73,11 +74,11 @@ class QuestionModel(BaseModel): # --- 主观题相关字段 (Subjective/Essay Question related fields) --- standard_answer_text: Optional[str] = Field( None, - description="【主观题】参考答案或答案要点。用于教师批阅时参考,或在回顾时展示给学生。对于非主观题,此字段应为None。 (Reference answer or key points for subjective questions. For teacher grading reference or student review. Should be None for non-subjective questions.)" + description="【主观题】参考答案或答案要点。用于教师批阅时参考,或在回顾时展示给学生。对于非主观题,此字段应为None。 (Reference answer or key points for subjective questions. For teacher grading reference or student review. Should be None for non-subjective questions.)", ) scoring_criteria: Optional[str] = Field( None, - description="【主观题】评分标准或详细评分细则。供教师批阅时参考。对于非主观题,此字段应为None。 (Scoring criteria or detailed rubrics for subjective questions. For teacher grading reference. Should be None for non-subjective questions.)" + description="【主观题】评分标准或详细评分细则。供教师批阅时参考。对于非主观题,此字段应为None。 (Scoring criteria or detailed rubrics for subjective questions. For teacher grading reference. Should be None for non-subjective questions.)", ) # --- 通用参考/解释字段 (General Reference/Explanation field) --- diff --git a/app/models/token_models.py b/app/models/token_models.py index 67fab23..6912c6f 100644 --- a/app/models/token_models.py +++ b/app/models/token_models.py @@ -14,6 +14,7 @@ - `AuthStatusResponse`: Generic response model for representing authentication failures or specific authentication statuses.) """ + # region 模块导入 (Module Imports) from typing import List, Optional diff --git a/app/models/user_models.py b/app/models/user_models.py index 7ba07ef..8d90a15 100644 --- a/app/models/user_models.py +++ b/app/models/user_models.py @@ -10,6 +10,7 @@ extensively used in API request/response bodies, database storage, and for internal data transfer.) """ + # region 模块导入 (Module Imports) import re # 用于正则表达式验证 (For regular expression validation) from enum import Enum diff --git a/app/services/__init__.py b/app/services/__init__.py index 2f2880d..ffe71f8 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -8,7 +8,18 @@ 目前此包为空,未来可以根据需求添加具体服务模块。 """ -__all__ = [] +# from . import some_service # Example placeholder +from .websocket_manager import ( + WebSocketManager, + websocket_manager, +) # 导入新的WebSocket管理器 + +__all__ = [ + # "some_service", # Example placeholder + # "SomeServiceClass", # Example placeholder + "WebSocketManager", + "websocket_manager", +] # Example of how to structure when services are added: # diff --git a/app/services/audit_logger.py b/app/services/audit_logger.py new file mode 100644 index 0000000..fe0a674 --- /dev/null +++ b/app/services/audit_logger.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +""" +审计日志服务模块。 +(Audit Logging Service Module.) + +此模块提供了一个服务类,用于记录应用中的重要操作和事件到审计日志文件。 +审计日志以JSON格式记录,包含事件ID、时间戳、执行者、操作类型、目标资源、状态和详细信息。 +(This module provides a service class for logging important actions and events +within the application to an audit log file. Audit logs are recorded in JSON format, +including event ID, timestamp, actor, action type, target resource, status, and details.) +""" + +import asyncio +import logging +import os +from datetime import datetime # Ensure datetime is imported for AuditLogEntry +from typing import Any, Dict, Optional + +from app.core.config import settings # Application settings +from app.models.audit_log_models import AuditLogEntry # Audit log Pydantic model + +# 审计日志文件的路径,从配置中读取 +# (Path to the audit log file, read from configuration) +# Assuming settings.audit_log_file_path will be "data/logs/audit.log" +AUDIT_LOG_FILE_PATH = settings.audit_log_file_path + + +class AuditLoggerService: + """ + 审计日志服务类。 + (Audit Logging Service class.) + + 负责初始化专用的审计日志记录器,并提供一个方法来记录结构化的审计事件。 + (Responsible for initializing a dedicated audit logger and providing a method + to log structured audit events.) + """ + + _instance = None + _lock = asyncio.Lock() + + def __new__(cls, *args, **kwargs): + # Singleton pattern might be useful here if multiple instantiations are a concern, + # but a global instance is also fine for this project structure. + # For simplicity, we'll use a global instance approach rather than enforcing singleton here. + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + """ + 初始化审计日志记录器。 + (Initializes the audit logger.) + + - 确保审计日志目录存在。 + - 设置一个名为 "audit_log" 的Python日志记录器。 + - 为此记录器配置一个文件处理器,指向 AUDIT_LOG_FILE_PATH。 + - 文件处理器的格式化器配置为直接输出原始JSON字符串。 + - 日志级别设置为 INFO。 + """ + # Ensure this runs only once for the global instance + if hasattr(self, "_initialized") and self._initialized: + return + + self.logger_name = "audit_log" + self.logger = logging.getLogger(self.logger_name) + + if not self.logger.handlers: # Avoid adding multiple handlers if instantiated multiple times (though global instance should prevent this) + # 确保日志目录存在 (Ensure log directory exists) + log_dir = os.path.dirname(AUDIT_LOG_FILE_PATH) + if log_dir and not os.path.exists(log_dir): + try: + os.makedirs(log_dir, exist_ok=True) + except OSError as e: + # Use a fallback logger or print if this critical setup fails + fallback_logger = logging.getLogger(__name__ + ".AuditLoggerSetup") + fallback_logger.error( + f"创建审计日志目录 '{log_dir}' 失败: {e}", exc_info=True + ) + # Depending on policy, could raise error or continue without file logging for audit + # For now, it will try to add handler anyway, which might fail if dir doesn't exist + + handler = logging.FileHandler(AUDIT_LOG_FILE_PATH, encoding="utf-8") + + # 自定义格式化器,直接输出消息 (Custom formatter to output the message directly) + # The message passed to logger will be the pre-formatted JSON string. + class JsonFormatter(logging.Formatter): + def format(self, record): + return record.getMessage() # record.msg should be the JSON string + + handler.setFormatter(JsonFormatter()) + self.logger.addHandler(handler) + self.logger.setLevel(logging.INFO) + # Prevent audit logs from propagating to the root logger if it has other handlers (e.g. console) + self.logger.propagate = False + + self._initialized = True + + async def log_event( + self, + action_type: str, + status: str, + actor_uid: Optional[str] = None, + actor_ip: Optional[str] = None, + target_resource_type: Optional[str] = None, + target_resource_id: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + ) -> None: + """ + 记录一个审计事件。 + (Logs an audit event.) + + 参数 (Args): + actor_uid (Optional[str]): 执行操作的用户ID。 + actor_ip (Optional[str]): 执行操作用户的IP地址。 + action_type (str): 操作类型。 + status (str): 操作结果状态 (例如:"SUCCESS", "FAILURE")。 + target_resource_type (Optional[str]): 操作目标资源的类型。 + target_resource_id (Optional[str]): 操作目标资源的ID。 + details (Optional[Dict[str, Any]]): 与事件相关的其他详细信息。 + """ + try: + log_entry = AuditLogEntry( + timestamp=datetime.utcnow(), # Generate timestamp at the moment of logging + actor_uid=actor_uid, + actor_ip=actor_ip, + action_type=action_type, + target_resource_type=target_resource_type, + target_resource_id=target_resource_id, + status=status, + details=details, + ) + + # 使用 model_dump_json() 将Pydantic模型转换为JSON字符串 + # (Convert Pydantic model to JSON string using model_dump_json()) + log_json_string = log_entry.model_dump_json() + + # 使用配置好的审计日志记录器记录JSON字符串 + # (Log the JSON string using the configured audit logger) + self.logger.info(log_json_string) + + except Exception as e: + # 如果审计日志本身失败,记录到应用主日志或标准错误输出 + # (If audit logging itself fails, log to the main app logger or stderr) + app_fallback_logger = logging.getLogger(__name__ + ".AuditLoggingError") + app_fallback_logger.error( + f"记录审计事件失败 (Failed to log audit event): {e}", exc_info=True + ) + app_fallback_logger.error( + f"失败的审计事件数据 (Failed audit event data): action_type={action_type}, status={status}, actor_uid={actor_uid}" + ) + + +# 创建审计日志服务的全局实例 +# (Create a global instance of the audit logging service) +audit_logger_service = AuditLoggerService() + +__all__ = ["audit_logger_service", "AuditLoggerService"] diff --git a/app/services/websocket_manager.py b/app/services/websocket_manager.py new file mode 100644 index 0000000..975ac4b --- /dev/null +++ b/app/services/websocket_manager.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- +""" +WebSocket 连接管理模块。 +(WebSocket Connection Management Module.) + +此模块提供了一个 `WebSocketManager` 类,用于管理活跃的 WebSocket 连接, +并支持向所有连接的客户端广播消息。主要用于实时通知功能,例如通知管理员。 +(This module provides a `WebSocketManager` class for managing active WebSocket connections +and supports broadcasting messages to all connected clients. It is primarily intended for +real-time notification features, such as notifying administrators.) +""" + +import asyncio +import logging +from typing import ( + Any, + Dict, + Set, +) # List for potential future use with multiple rooms + +from fastapi import WebSocket + +# 获取本模块的日志记录器实例 +# (Get logger instance for this module) +_websocket_manager_logger = logging.getLogger(__name__) + + +class WebSocketManager: + """ + 管理 WebSocket 连接的类。 + (Class for managing WebSocket connections.) + + 提供连接、断开连接以及广播消息的功能。 + (Provides functionalities for connecting, disconnecting, and broadcasting messages.) + """ + + def __init__(self): + """ + 初始化 WebSocketManager。 + (Initializes the WebSocketManager.) + + `active_connections`: 一个集合,存储所有当前活跃的 WebSocket 连接。 + (A set storing all currently active WebSocket connections.) + `lock`: 一个异步锁,用于在并发操作中保护 `active_connections`。 + (An asyncio.Lock to protect `active_connections` during concurrent operations.) + """ + self.active_connections: Set[WebSocket] = set() + self.lock = asyncio.Lock() + _websocket_manager_logger.info( + "WebSocket 管理器已初始化。 (WebSocketManager initialized.)" + ) + + async def connect(self, websocket: WebSocket) -> None: + """ + 处理新的 WebSocket 连接。 + (Handles a new WebSocket connection.) + + 将新的 WebSocket 对象添加到活跃连接集合中。 + (Adds the new WebSocket object to the set of active connections.) + + 参数 (Args): + websocket (WebSocket): 要添加的 FastAPI WebSocket 对象。 + (The FastAPI WebSocket object to add.) + """ + await ( + websocket.accept() + ) # 接受 WebSocket 连接 (Accept the WebSocket connection) + async with self.lock: + self.active_connections.add(websocket) + # 获取客户端信息用于日志记录 (Get client info for logging) + client_host = websocket.client.host if websocket.client else "未知主机" + client_port = websocket.client.port if websocket.client else "未知端口" + _websocket_manager_logger.info( + f"WebSocket 已连接: {client_host}:{client_port}。当前总连接数: {len(self.active_connections)}。" + f"(WebSocket connected: {client_host}:{client_port}. Total connections: {len(self.active_connections)}.)" + ) + + async def disconnect(self, websocket: WebSocket) -> None: + """ + 处理 WebSocket 断开连接。 + (Handles a WebSocket disconnection.) + + 从活跃连接集合中移除指定的 WebSocket 对象。 + (Removes the specified WebSocket object from the set of active connections.) + + 参数 (Args): + websocket (WebSocket): 要移除的 FastAPI WebSocket 对象。 + (The FastAPI WebSocket object to remove.) + """ + async with self.lock: + if websocket in self.active_connections: + self.active_connections.remove(websocket) + # 获取客户端信息用于日志记录 (Get client info for logging) + client_host = websocket.client.host if websocket.client else "未知主机" + client_port = websocket.client.port if websocket.client else "未知端口" + _websocket_manager_logger.info( + f"WebSocket 已断开: {client_host}:{client_port}。剩余连接数: {len(self.active_connections)}。" + f"(WebSocket disconnected: {client_host}:{client_port}. Remaining connections: {len(self.active_connections)}.)" + ) + # WebSocket 对象通常由 FastAPI 在断开后自行关闭,此处无需显式调用 websocket.close() + # (The WebSocket object is typically closed by FastAPI itself after disconnection, + # no explicit call to websocket.close() is needed here.) + + async def broadcast_message(self, message: Dict[str, Any]) -> None: + """ + 向所有当前连接的 WebSocket 客户端广播一条JSON消息。 + (Broadcasts a JSON message to all currently connected WebSocket clients.) + + 如果发送消息给某个客户端时发生异常(例如连接已关闭),则会安全地移除该客户端。 + (If an exception occurs while sending a message to a client (e.g., connection closed), + that client will be safely removed.) + + 参数 (Args): + message (Dict[str, Any]): 要广播的JSON可序列化字典消息。 + (The JSON-serializable dictionary message to broadcast.) + """ + # 创建一个当前连接的副本进行迭代,以允许在广播过程中安全地修改原始集合 + # (Create a copy of current connections for iteration to allow safe modification + # of the original set during broadcasting.) + + # 使用锁来确保在复制和迭代期间连接列表的完整性 + # (Use lock to ensure integrity of connection list during copy and iteration) + disconnected_websockets: Set[WebSocket] = set() + + async with self.lock: + # 收集所有仍然活跃的连接进行广播 + # (Collect all still active connections for broadcasting) + # This is important because a connection might have been closed right before acquiring the lock + connections_to_broadcast = list(self.active_connections) + + if not connections_to_broadcast: + _websocket_manager_logger.info( + "广播消息:无活跃连接,消息未发送。 (Broadcast message: No active connections, message not sent.)" + ) + return + + _websocket_manager_logger.debug( + f"准备向 {len(connections_to_broadcast)} 个连接广播消息: {message}" + ) + + for websocket in connections_to_broadcast: + try: + await websocket.send_json(message) + except Exception as e: # WebSocketException, ConnectionClosed, etc. + # 客户端可能已断开连接 (Client might have disconnected) + client_host = websocket.client.host if websocket.client else "未知主机" + client_port = websocket.client.port if websocket.client else "未知端口" + _websocket_manager_logger.warning( + f"广播消息给 {client_host}:{client_port} 失败: {e}。将标记此连接为待移除。" + f"(Failed to broadcast message to {client_host}:{client_port}: {e}. Marking connection for removal.)" + ) + disconnected_websockets.add(websocket) + + # 如果在广播过程中有连接失败,则从主列表中移除它们 + # (If any connections failed during broadcast, remove them from the main list) + if disconnected_websockets: + async with self.lock: + for ws_to_remove in disconnected_websockets: + if ( + ws_to_remove in self.active_connections + ): # 再次检查,以防在两次获取锁之间状态改变 + self.active_connections.remove(ws_to_remove) + _websocket_manager_logger.info( + f"已从活跃连接中移除 {len(disconnected_websockets)} 个失败的WebSocket连接。" + ) + + +# 创建 WebSocketManager 的全局实例 +# (Create a global instance of WebSocketManager) +websocket_manager = WebSocketManager() + +__all__ = ["websocket_manager", "WebSocketManager"] diff --git a/app/utils/export_utils.py b/app/utils/export_utils.py new file mode 100644 index 0000000..47a8417 --- /dev/null +++ b/app/utils/export_utils.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +""" +数据导出工具模块 (Data Export Utilities Module) + +此模块提供了将数据导出为CSV和XLSX格式的通用函数。 +主要用于API端点,以流式响应的形式提供文件下载。 +(This module provides utility functions for exporting data to CSV and XLSX formats. + It's primarily intended for use in API endpoints to offer file downloads + as streaming responses.) +""" + +import csv +import io +from typing import Any, Dict, List + +import openpyxl # For XLSX export +from fastapi.responses import StreamingResponse + + +def data_to_csv( + data_list: List[Dict[str, Any]], headers: List[str], filename: str = "export.csv" +) -> StreamingResponse: + """ + 将字典列表数据转换为CSV格式并通过StreamingResponse提供下载。 + (Converts a list of dictionaries to CSV format and provides it for download via StreamingResponse.) + + 参数 (Args): + data_list (List[Dict[str, Any]]): 要导出的数据,每个字典代表一行,键应与headers对应。 + (Data to export, each dict represents a row, keys should match headers.) + headers (List[str]): CSV文件的表头列表。 + (List of headers for the CSV file.) + filename (str): 下载时建议的文件名。 + (Suggested filename for the download.) + + 返回 (Returns): + StreamingResponse: FastAPI流式响应对象,包含CSV数据。 + (FastAPI StreamingResponse object containing the CSV data.) + """ + output = io.StringIO() + # 使用 utf-8-sig 编码以确保Excel正确显示中文字符 (Use utf-8-sig for Excel to correctly display Chinese chars) + # The StreamingResponse will handle encoding, but csv.writer needs unicode. + + writer = csv.writer(output) + + # 写入表头 (Write headers) + writer.writerow(headers) + + # 写入数据行 (Write data rows) + if data_list: + for item in data_list: + writer.writerow( + [item.get(header, "") for header in headers] + ) # Safely get values + + # StreamingResponse需要字节流,所以我们将StringIO的内容编码为UTF-8 (with BOM for Excel) + # The content must be bytes for StreamingResponse if we specify charset in media_type or headers + # However, StreamingResponse can also take an iterator of strings and encode it. + # For simplicity with csv.writer producing strings, we'll let StreamingResponse handle it. + + # Reset stream position + output.seek(0) + + # Create a string iterator for StreamingResponse + # This avoids loading the whole CSV into memory as one giant string if data_list is huge. + # However, csv.writer already wrote to an in-memory StringIO buffer. + # For very large datasets, a different approach might be needed (e.g. generating CSV row by row as an iterator). + # Given the current structure with StringIO, we'll read its content. + + response_content = output.getvalue() + # output.close() # StringIO doesn't need explicit close for getvalue() + + return StreamingResponse( + iter([response_content.encode("utf-8-sig")]), # Encode to utf-8-sig for BOM + media_type="text/csv", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Content-Encoding": "utf-8-sig", # Explicitly state encoding for clarity, though BOM handles it + }, + ) + + +def data_to_xlsx( + data_list: List[Dict[str, Any]], headers: List[str], filename: str = "export.xlsx" +) -> StreamingResponse: + """ + 将字典列表数据转换为XLSX格式并通过StreamingResponse提供下载。 + (Converts a list of dictionaries to XLSX format and provides it for download via StreamingResponse.) + + 参数 (Args): + data_list (List[Dict[str, Any]]): 要导出的数据,每个字典代表一行。 + (Data to export, each dict represents a row.) + headers (List[str]): XLSX文件的表头列表。 + (List of headers for the XLSX file.) + filename (str): 下载时建议的文件名。 + (Suggested filename for the download.) + + 返回 (Returns): + StreamingResponse: FastAPI流式响应对象,包含XLSX数据。 + (FastAPI StreamingResponse object containing the XLSX data.) + """ + workbook = openpyxl.Workbook() + sheet = workbook.active + + # 写入表头 (Write headers) + sheet.append(headers) + + # 写入数据行 (Write data rows) + if data_list: + for item in data_list: + row_values = [item.get(header) for header in headers] # Safely get values + sheet.append(row_values) + + # 将工作簿保存到内存中的字节流 (Save workbook to an in-memory byte stream) + output = io.BytesIO() + workbook.save(output) + output.seek(0) # Reset stream position to the beginning + + return StreamingResponse( + output, # BytesIO is directly iterable by StreamingResponse + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +__all__ = ["data_to_csv", "data_to_xlsx"] diff --git a/app/utils/helpers.py b/app/utils/helpers.py index 3723039..bc690e8 100644 --- a/app/utils/helpers.py +++ b/app/utils/helpers.py @@ -8,6 +8,7 @@ in the project, such as time formatting, UUID abbreviation, client IP address acquisition, data structure processing, and random string generation.) """ + # region 模块导入 (Module Imports) import datetime import ipaddress # 用于处理和验证IP地址 (For processing and validating IP addresses) diff --git a/app/websocket_routes.py b/app/websocket_routes.py new file mode 100644 index 0000000..082e023 --- /dev/null +++ b/app/websocket_routes.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +""" +WebSocket API 路由模块。 +(WebSocket API Routing Module.) + +此模块定义了所有与 WebSocket 通信相关的API端点。 +例如,用于实时监控、通知等功能。 +(This module defines all API endpoints related to WebSocket communication, +for example, for real-time monitoring, notifications, etc.) +""" + +import logging + +from fastapi import ( + APIRouter, + Depends, + WebSocket, + WebSocketDisconnect, +) + +from .core.security import require_admin # 用于 WebSocket 端点的管理权限验证 +from .services.websocket_manager import websocket_manager + +# (For admin permission verification on WebSocket endpoints) + +# 获取本模块的日志记录器实例 +# (Get logger instance for this module) +_ws_logger = logging.getLogger(__name__) + +# 创建 WebSocket 路由实例 +# (Create WebSocket router instance) +# 注意: FastAPI 对 APIRouter 上的 `dependencies` 用于 WebSocket 的行为可能有限。 +# 通常,WebSocket的认证在连接建立时通过查询参数或头部信息在端点函数内部处理。 +# 此处添加 `Depends(require_admin)` 是为了声明意图,实际执行可能需要调整。 +# (Note: FastAPI's behavior for `dependencies` on APIRouter with WebSockets might be limited. +# Typically, WebSocket authentication is handled within the endpoint function during connection +# establishment using query parameters or headers. Adding `Depends(require_admin)` here +# declares intent; actual execution might require adjustments.) +ws_router = APIRouter( + tags=["WebSocket接口 (WebSocket Interface)"], + dependencies=[Depends(require_admin)], # 尝试对整个路由应用管理员认证 + # (Attempt to apply admin authentication to the entire router) +) + + +@ws_router.websocket("/ws/exam_monitor") +async def websocket_exam_monitor(websocket: WebSocket): + """ + 考试监控 WebSocket 端点。 + (Exam Monitoring WebSocket Endpoint.) + + 管理员客户端可以通过此端点连接,以接收实时的考试监控信息(例如,考生提交试卷事件)。 + (Administrator clients can connect via this endpoint to receive real-time exam monitoring + information (e.g., examinee submission events).) + + 认证 (Authentication): + 连接时需要在查询参数中提供有效的管理员Token (例如: `/ws/exam_monitor?token=YOUR_ADMIN_TOKEN`)。 + (A valid admin token must be provided as a query parameter upon connection + (e.g., `/ws/exam_monitor?token=YOUR_ADMIN_TOKEN`).) + """ + # 实际的管理员身份验证已由 `Depends(require_admin)` 在 APIRouter 级别(尝试)处理。 + # 如果该机制对 WebSocket 不完全适用,认证逻辑需要移到此处, + # 例如通过 `token: Optional[str] = Query(None)` 获取token,然后调用 `validate_token_and_get_user_info`。 + # (Actual admin authentication is (attempted) by `Depends(require_admin)` at the APIRouter level. + # If this mechanism is not fully applicable to WebSockets, authentication logic needs to be moved here, + # e.g., by getting the token via `token: Optional[str] = Query(None)` and then calling + # `validate_token_and_get_user_info`.) + + # 假设 `require_admin` 依赖项如果失败会直接拒绝连接或 `websocket.scope['user']` 会被填充。 + # (Assuming the `require_admin` dependency would reject the connection if failed, + # or `websocket.scope['user']` would be populated.) + + # 从 scope 中获取认证信息 (这是 FastAPI 处理依赖注入的一种方式,但对 WebSocket 可能不同) + # (Getting auth info from scope - this is one way FastAPI handles DI, but might differ for WebSockets) + # admin_user_info = websocket.scope.get("user_info_from_token", None) # 假设依赖注入会填充这个 + # actor_uid = admin_user_info.get("user_uid", "unknown_ws_admin") if admin_user_info else "unknown_ws_admin" + # ^^^ 上述方法依赖于 `require_admin` 如何将信息传递给 WebSocket scope, + # 这通常不直接发生。`require_admin` 会在 HTTP 升级请求阶段起作用。 + + client_host = websocket.client.host if websocket.client else "未知主机" + client_port = websocket.client.port if websocket.client else "未知端口" + + # 由于 `require_admin` 会在连接尝试时验证,若失败则不会执行到这里。 + # 若成功,我们可以认为连接的是已认证的管理员。 + # (Since `require_admin` validates upon connection attempt, if it fails, execution won't reach here. + # If successful, we can assume the connected client is an authenticated admin.) + _ws_logger.info( + f"管理员客户端 {client_host}:{client_port} 已连接到考试监控 WebSocket。" + f"(Admin client {client_host}:{client_port} connected to exam monitoring WebSocket.)" + ) + + await websocket_manager.connect(websocket) + try: + while True: + # 管理员客户端通常只接收由服务器推送的消息。 + # (Admin clients typically only receive messages pushed by the server.) + # 此处 `receive_text` / `receive_json` 主要用于保持连接活性或处理客户端发来的控制指令(如果设计有)。 + # (Here, `receive_text` / `receive_json` is mainly for keeping the connection alive + # or processing control commands from the client (if designed).) + data = await websocket.receive_text() + _ws_logger.debug( + f"从管理员客户端 {client_host}:{client_port} 收到监控 WebSocket 文本消息: {data}" + f"(Received text message from admin client {client_host}:{client_port} on monitoring WebSocket: {data})" + ) + # 示例:如果客户端发送特定指令 + # (Example: if client sends a specific command) + # if data == "PING": + # await websocket.send_text("PONG") + # 一般管理员监控端点不需要处理来自客户端的太多常规消息。 + # (Generally, admin monitoring endpoints don't need to process many regular messages from clients.) + + except WebSocketDisconnect: + _ws_logger.info( + f"管理员客户端 {client_host}:{client_port} 已从考试监控 WebSocket 断开。" + f"(Admin client {client_host}:{client_port} disconnected from exam monitoring WebSocket.)" + ) + except Exception as e: + # 记录任何其他在 WebSocket 通信期间发生的异常 + # (Log any other exceptions occurring during WebSocket communication) + _ws_logger.error( + f"考试监控 WebSocket ({client_host}:{client_port}) 发生错误: {e}. " + f"英文详情 (English details): (Exam monitoring WebSocket ({client_host}:{client_port}) encountered an error: {e})", + exc_info=True, + ) + finally: + # 确保无论因何原因循环结束,连接都会从管理器中断开 + # (Ensure the connection is removed from the manager regardless of why the loop ended) + await websocket_manager.disconnect(websocket) + + +__all__ = ["ws_router"] diff --git a/app_legacy.py b/app_legacy.py index 8c45355..2a66b7e 100644 --- a/app_legacy.py +++ b/app_legacy.py @@ -964,7 +964,10 @@ def admin_delete_paper_from_memory( # region Cloudflare IP范围获取与更新任务 async def fetch_and_update_cloudflare_ips_once(): """获取一次Cloudflare IP范围并更新全局变量。""" - global cloudflare_ipv4_ranges, cloudflare_ipv6_ranges, cloudflare_ranges_last_updated + global \ + cloudflare_ipv4_ranges, \ + cloudflare_ipv6_ranges, \ + cloudflare_ranges_last_updated try: app_logger.info("尝试获取Cloudflare IP地址范围...") async with httpx.AsyncClient() as client: diff --git a/examctl.py b/examctl.py index 12e9169..ec0c3a1 100644 --- a/examctl.py +++ b/examctl.py @@ -18,8 +18,10 @@ python examctl.py update-user --uid existinguser --email "new_email@example.com" --tags "user,limited" python examctl.py change-password --uid someuser --new-password "AnotherSecurePassword" """ + import argparse import asyncio +import csv import sys from pathlib import Path @@ -27,6 +29,10 @@ # (Adjust Python search path to allow importing modules from the 'app' package) sys.path.insert(0, str(Path(__file__).resolve().parent)) +import json # For parsing JSON arguments + +from pydantic import BaseModel # F821: BaseModel used in get_nested_value + from app.core.config import ( settings, ) # 导入应用全局配置 (Import global application settings) @@ -36,18 +42,76 @@ from app.crud import ( # 从CRUD包导入所需实例和初始化函数 # (Import required instances and initialization function from CRUD package) initialize_crud_instances, # CRUD及存储库异步初始化函数 (Async init function for CRUD & repo) + paper_crud_instance, # 试卷CRUD实例 (Paper CRUD instance) + qb_crud_instance, # 题库CRUD操作实例 (Question Bank CRUD operations instance) + settings_crud_instance, # 配置CRUD实例 (Settings CRUD instance) user_crud_instance, # 用户CRUD操作实例 (User CRUD operations instance) - # paper_crud_instance, # (可选) 试卷CRUD实例 (Optional: Paper CRUD instance) - # qb_crud_instance, # (可选) 题库CRUD实例 (Optional: Question Bank CRUD instance) - # settings_crud_instance, # (可选) 配置CRUD实例 (Optional: Settings CRUD instance) ) +from app.models.enums import QuestionTypeEnum # 题目类型枚举 +from app.models.qb_models import ( + QuestionModel, + # QuestionBank, # May not be needed directly for commands + # LibraryIndexItem, # May not be needed directly for commands +) # 题库相关Pydantic模型 from app.models.user_models import ( AdminUserUpdate, UserCreate, + UserInDB, # For retrieving user data UserTag, ) # 用户相关Pydantic模型 (User-related Pydantic models) +async def list_users_command(args: argparse.Namespace): + """ + 处理 'list-users' / '导出用户' 命令:检索所有用户并将其数据导出到CSV文件或打印到标准输出。 + """ + if not user_crud_instance: + print("错误:用户数据操作模块 (UserCRUD) 未初始化。") + return + + print("正在检索所有用户信息...") + try: + users: list[UserInDB] = await user_crud_instance.admin_get_all_users() + if not users: + print("未找到任何用户。") + return + + print(f"共检索到 {len(users)} 位用户。") + + header = ["用户ID", "昵称", "邮箱", "QQ", "标签"] + data = [ + [ + user.uid, + user.nickname, + user.email, + user.qq, + ",".join([tag.value for tag in user.tags]), + ] + for user in users + ] + + if args.output_file: + output_path = Path(args.output_file) + try: + with output_path.open( + "w", newline="", encoding="utf-8-sig" + ) as csvfile: # utf-8-sig for Excel compatibility + writer = csv.writer(csvfile) + writer.writerow(header) + writer.writerows(data) + print(f"用户信息已成功导出到: {output_path}") + except IOError as e: + print(f"写入文件 '{output_path}' 时发生错误: {e}") + else: + # 打印到 stdout + writer = csv.writer(sys.stdout) + writer.writerow(header) + writer.writerows(data) + + except Exception as e: + print(f"检索或导出用户时发生错误: {e}") + + async def add_user_command(args: argparse.Namespace): """ 处理 'add-user' 命令:添加一个新用户到系统。 @@ -62,12 +126,10 @@ async def add_user_command(args: argparse.Namespace): print( "错误:用户数据操作模块 (UserCRUD) 未初始化。请确保异步初始化已成功调用。" ) - print( - "Error: User data operations module (UserCRUD) is not initialized. Ensure async initialization was called." - ) + # "Error: User data operations module (UserCRUD) is not initialized. Ensure async initialization was called." return - print(f"正在尝试添加用户 (Attempting to add user): {args.uid}...") + print(f"正在尝试添加用户: {args.uid}...") # 从命令行参数构造用户创建数据模型 user_create_payload = UserCreate( uid=args.uid, @@ -81,18 +143,16 @@ async def add_user_command(args: argparse.Namespace): created_user = await user_crud_instance.create_user(user_create_payload) if created_user: print( - f"成功添加用户 (Successfully added user) '{created_user.uid}' " - f"标签 (Tags): {[tag.value for tag in created_user.tags]}." + f"成功添加用户 '{created_user.uid}' " + f"标签: {[tag.value for tag in created_user.tags]}。" ) else: # 此路径理论上在 create_user 抛出异常前不应到达 # (This path should ideally not be reached if create_user raises an exception on failure) - print( - f"添加用户 (Failed to add user) '{args.uid}' 失败。用户可能已存在或提供的数据无效。" - ) - print("User might already exist or provided data is invalid.") + print(f"添加用户 '{args.uid}' 失败。用户可能已存在或提供的数据无效。") + # "User might already exist or provided data is invalid." except Exception as e: # 捕获创建过程中可能发生的任何异常 - print(f"添加用户 (Error adding user) '{args.uid}' 时发生错误: {e}") + print(f"添加用户 '{args.uid}' 时发生错误: {e}") async def update_user_command(args: argparse.Namespace): @@ -107,10 +167,10 @@ async def update_user_command(args: argparse.Namespace): """ if not user_crud_instance: print("错误:用户数据操作模块 (UserCRUD) 未初始化。") - print("Error: User data operations module (UserCRUD) is not initialized.") + # "Error: User data operations module (UserCRUD) is not initialized." return - print(f"正在尝试更新用户 (Attempting to update user): {args.uid}...") + print(f"正在尝试更新用户: {args.uid}...") update_data = {} # 存储需要更新的字段 if args.nickname is not None: update_data["nickname"] = args.nickname @@ -124,13 +184,13 @@ async def update_user_command(args: argparse.Namespace): update_data["tags"] = [UserTag(tag.strip()) for tag in args.tags.split(",")] except ValueError as e: # 如果提供的标签无效 print( - f"错误: --tags 中提供了无效标签 (Invalid tag provided in --tags): {e}。" - f"允许的标签 (Allowed tags): {[tag.value for tag in UserTag]}" + f"错误: --tags 中提供了无效标签: {e}。" + f"允许的标签: {[tag.value for tag in UserTag]}" ) return if not update_data: # 如果没有提供任何更新参数 - print("未提供更新参数。正在退出。 (No update parameters provided. Exiting.)") + print("未提供更新参数。正在退出。") return # 使用 AdminUserUpdate 模型构造更新数据 @@ -141,18 +201,16 @@ async def update_user_command(args: argparse.Namespace): args.uid, admin_update_payload ) if updated_user: - print(f"成功更新用户 (Successfully updated user) '{updated_user.uid}'.") - print(f" 昵称 (Nickname): {updated_user.nickname}") - print(f" 邮箱 (Email): {updated_user.email}") + print(f"成功更新用户 '{updated_user.uid}'.") + print(f" 昵称: {updated_user.nickname}") + print(f" 邮箱: {updated_user.email}") print(f" QQ: {updated_user.qq}") - print(f" 标签 (Tags): {[tag.value for tag in updated_user.tags]}") + print(f" 标签: {[tag.value for tag in updated_user.tags]}") else: - print( - f"更新用户 (Failed to update user) '{args.uid}' 失败。用户可能不存在或提供的数据无效。" - ) - print("User might not exist or provided data is invalid.") + print(f"更新用户 '{args.uid}' 失败。用户可能不存在或提供的数据无效。") + # "User might not exist or provided data is invalid." except Exception as e: - print(f"更新用户 (Error updating user) '{args.uid}' 时发生错误: {e}") + print(f"更新用户 '{args.uid}' 时发生错误: {e}") async def change_password_command(args: argparse.Namespace): @@ -166,12 +224,10 @@ async def change_password_command(args: argparse.Namespace): """ if not user_crud_instance: print("错误:用户数据操作模块 (UserCRUD) 未初始化。") - print("Error: User data operations module (UserCRUD) is not initialized.") + # "Error: User data operations module (UserCRUD) is not initialized." return - print( - f"正在尝试为用户 (Attempting to change password for user) '{args.uid}' 修改密码..." - ) + print(f"正在尝试为用户 '{args.uid}' 修改密码...") # 检查新密码长度是否符合配置要求 pw_config = settings.user_config @@ -182,7 +238,6 @@ async def change_password_command(args: argparse.Namespace): ): print( f"错误:新密码长度必须在 {pw_config.password_min_len} 和 {pw_config.password_max_len} 字符之间。" - f"(Error: New password length must be between {pw_config.password_min_len} and {pw_config.password_max_len} characters.)" ) return @@ -190,7 +245,7 @@ async def change_password_command(args: argparse.Namespace): # 获取用户以确认存在性 user = await user_crud_instance.get_user_by_uid(args.uid) if not user: - print(f"错误:用户 (Error: User) '{args.uid}' 未找到。") + print(f"错误:用户 '{args.uid}' 未找到。") return # 对新密码进行哈希处理 @@ -201,18 +256,12 @@ async def change_password_command(args: argparse.Namespace): ) if success: - print( - f"用户 (User) '{args.uid}' 的密码已成功修改。 (Password changed successfully.)" - ) + print(f"用户 '{args.uid}' 的密码已成功修改。") else: # 此情况理论上不应发生(如果用户已找到),除非存储库的更新操作失败 - print( - f"为用户 (Failed to change password for user) '{args.uid}' 修改密码失败。" - ) + print(f"为用户 '{args.uid}' 修改密码失败。") except Exception as e: - print( - f"为用户 (Error changing password for user) '{args.uid}' 修改密码时发生错误: {e}" - ) + print(f"为用户 '{args.uid}' 修改密码时发生错误: {e}") async def main_async(): @@ -228,30 +277,27 @@ async def main_async(): """ # 初始化 CRUD 实例和存储库 # (Initialize CRUD instances and repository) - print("正在初始化应用和数据存储... (Initializing application and data storage...)") + print("正在初始化应用和数据存储...") try: await initialize_crud_instances() # 关键的异步初始化步骤 - print("初始化完成。 (Initialization complete.)") + print("初始化完成。") except Exception as e: print( - f"初始化过程中发生严重错误 (Critical error during initialization): {e}", + f"初始化过程中发生严重错误: {e}", file=sys.stderr, ) print( "请检查您的 .env 文件和数据存储配置 (如 JSON 文件路径、数据库连接字符串等)。", file=sys.stderr, ) - print( - "Please check your .env file and data storage configuration (e.g., JSON file paths, database connection strings).", - file=sys.stderr, - ) + # "Please check your .env file and data storage configuration (e.g., JSON file paths, database connection strings)." sys.exit(1) # 初始化失败则退出 # 确保 settings 已加载 (通常 initialize_crud_instances 会间接触发) # (Ensure settings are loaded (usually triggered indirectly by initialize_crud_instances)) if not settings.app_name: # 检查一个已知的配置项是否存在 print( - "错误:未能加载应用配置。 (Error: Could not load application settings.)", + "错误:未能加载应用配置。", file=sys.stderr, ) sys.exit(1) @@ -259,79 +305,188 @@ async def main_async(): # 创建命令行参数解析器 # (Create command-line argument parser) parser = argparse.ArgumentParser( - description="在线考试系统命令行管理工具 - 用于用户管理和应用设置等。\n(Online Examination System CLI Management Tool - For user management, application settings, etc.)" - ) - subparsers = parser.add_subparsers( - dest="command", required=True, help="可用的命令 (Available commands)" + description="在线考试系统命令行管理工具 - 用于用户管理和应用设置等。" ) + subparsers = parser.add_subparsers(dest="command", required=True, help="可用的命令") # 添加 'add-user' 子命令解析器 # (Add 'add-user' subcommand parser) - add_parser = subparsers.add_parser( - "add-user", help="添加一个新用户到系统。 (Add a new user to the system.)" - ) - add_parser.add_argument( - "--uid", required=True, help="用户ID (用户名)。 (User ID (username))" - ) - add_parser.add_argument( - "--password", required=True, help="用户密码。 (User password)" - ) - add_parser.add_argument( - "--nickname", help="可选的用户昵称。 (Optional: User nickname)" - ) + add_parser = subparsers.add_parser("add-user", help="添加一个新用户到系统。") + add_parser.add_argument("--uid", required=True, help="用户ID (用户名)。") + add_parser.add_argument("--password", required=True, help="用户密码。") + add_parser.add_argument("--nickname", help="可选的用户昵称。") add_parser.add_argument( "--email", - help="可选的用户邮箱 (例如: user@example.com)。 (Optional: User email (e.g., user@example.com))", - ) - add_parser.add_argument( - "--qq", help="可选的用户QQ号码。 (Optional: User QQ number)" + help="可选的用户邮箱 (例如: user@example.com)。", ) + add_parser.add_argument("--qq", help="可选的用户QQ号码。") add_parser.set_defaults(func=add_user_command) # 设置此子命令对应的处理函数 # 添加 'update-user' 子命令解析器 # (Add 'update-user' subcommand parser) update_parser = subparsers.add_parser( "update-user", - help="更新现有用户的属性。 (Update attributes of an existing user.)", + help="更新现有用户的属性。", ) update_parser.add_argument( "--uid", required=True, - help="需要更新的用户的用户ID (用户名)。 (User ID (username) of the user to update.)", - ) - update_parser.add_argument( - "--nickname", help="用户的新昵称。 (New nickname for the user.)" - ) - update_parser.add_argument( - "--email", help="用户的新邮箱。 (New email for the user.)" - ) - update_parser.add_argument( - "--qq", help="用户的新QQ号码。 (New QQ number for the user.)" + help="需要更新的用户的用户ID (用户名)。", ) + update_parser.add_argument("--nickname", help="用户的新昵称。") + update_parser.add_argument("--email", help="用户的新邮箱。") + update_parser.add_argument("--qq", help="用户的新QQ号码。") update_parser.add_argument( "--tags", - help=f"逗号分隔的新标签列表 (例如: user,admin)。允许的标签: {[t.value for t in UserTag]}\n" - f"(Comma-separated list of new tags (e.g., user,admin). Allowed: {[t.value for t in UserTag]})", + help=f"逗号分隔的新标签列表 (例如: user,admin)。允许的标签: {[t.value for t in UserTag]}", ) update_parser.set_defaults(func=update_user_command) # 添加 'change-password' 子命令解析器 # (Add 'change-password' subcommand parser) - pw_parser = subparsers.add_parser( - "change-password", help="修改用户的密码。 (Change a user's password.)" - ) + pw_parser = subparsers.add_parser("change-password", help="修改用户的密码。") pw_parser.add_argument( "--uid", required=True, - help="需要修改密码的用户的用户ID (用户名)。 (User ID (username) whose password to change.)", + help="需要修改密码的用户的用户ID (用户名)。", ) pw_parser.add_argument( "--new-password", required=True, - help="用户的新密码。 (The new password for the user.)", + help="用户的新密码。", ) pw_parser.set_defaults(func=change_password_command) + # 添加 'list-users' / '导出用户' 子命令解析器 + list_users_parser = subparsers.add_parser( + "list-users", + help="导出所有用户的列表到CSV文件或标准输出。", + aliases=["导出用户"], + ) + list_users_parser.add_argument( + "--output-file", + "--输出文件", + type=str, + help="导出CSV文件的路径。如果未提供,则输出到标准输出。", + ) + list_users_parser.set_defaults(func=list_users_command) + + # --- Question Bank Subcommands --- + + # 'add-question' / '添加题目' 子命令 + add_q_parser = subparsers.add_parser( + "add-question", + help="添加一个新题目到指定的题库。", + aliases=["添加题目"], + ) + add_q_parser.add_argument( + "--library-id", required=True, help="题库ID (例如: 'easy', 'hard')" + ) + add_q_parser.add_argument("--content", required=True, help="题目内容 (题干)") + add_q_parser.add_argument( + "--options", + help='选择题选项的JSON字符串列表 (例如: \'["选项A", "选项B"]\')', + default="[]", + ) + add_q_parser.add_argument( + "--answer", required=True, help="正确答案 (对于选择题,应为选项之一)" + ) + add_q_parser.add_argument("--answer-detail", help="答案解析 (可选)") + add_q_parser.add_argument("--tags", help="逗号分隔的标签列表 (可选)") + add_q_parser.add_argument( + "--type", + choices=[qt.value for qt in QuestionTypeEnum], + default=QuestionTypeEnum.SINGLE_CHOICE.value, + help="题目类型 (默认为单选题)", + ) + # TODO: Add more specific fields based on QuestionModel for different types if needed + add_q_parser.set_defaults(func=add_question_command) + + # 'view-question' / '查看题目' 子命令 + view_q_parser = subparsers.add_parser( + "view-question", + help="查看指定ID的题目详情。", + aliases=["查看题目"], + ) + view_q_parser.add_argument("--question-id", required=True, help="要查看的题目ID") + view_q_parser.set_defaults(func=view_question_command) + + # 'update-question' / '更新题目' 子命令 + update_q_parser = subparsers.add_parser( + "update-question", + help="更新现有题目的信息。", + aliases=["更新题目"], + ) + update_q_parser.add_argument("--question-id", required=True, help="要更新的题目ID") + update_q_parser.add_argument("--content", help="新的题目内容 (题干)") + update_q_parser.add_argument("--options", help="新的选择题选项JSON字符串列表") + update_q_parser.add_argument("--answer", help="新的正确答案") + update_q_parser.add_argument("--answer-detail", help="新的答案解析") + update_q_parser.add_argument("--tags", help="新的逗号分隔的标签列表") + update_q_parser.add_argument( + "--confirm-rename", action="store_true", help="如果题目内容改变,需确认重命名" + ) + update_q_parser.set_defaults(func=update_question_command) + + # 'delete-question' / '删除题目' 子命令 + delete_q_parser = subparsers.add_parser( + "delete-question", + help="从题库中删除一个题目。", + aliases=["删除题目"], + ) + delete_q_parser.add_argument("--question-id", required=True, help="要删除的题目ID") + delete_q_parser.add_argument( + "--confirm", action="store_true", help="必须提供此参数以确认删除" + ) + delete_q_parser.set_defaults(func=delete_question_command) + + # 'list-questions' / '列出题目' 子命令 + list_q_parser = subparsers.add_parser( + "list-questions", + help="列出指定题库中的题目 (支持分页)。", + aliases=["列出题目"], + ) + list_q_parser.add_argument("--library-id", required=True, help="要列出题目的题库ID") + list_q_parser.add_argument("--page", type=int, default=1, help="页码 (从1开始)") + list_q_parser.add_argument("--per-page", type=int, default=10, help="每页数量") + list_q_parser.set_defaults(func=list_questions_command) + + # --- Application Configuration Subcommands --- + + # 'view-config' / '查看配置' 子命令 + view_cfg_parser = subparsers.add_parser( + "view-config", + help="查看当前应用配置信息。", + aliases=["查看配置"], + ) + view_cfg_parser.add_argument( + "--key", + help="可选,只显示指定配置项的值。使用点表示法访问嵌套键 (例如: 'rate_limits.default.get_exam.limit')", + ) + view_cfg_parser.set_defaults(func=view_config_command) + + # 'update-config' / '更新配置' 子命令 + update_cfg_parser = subparsers.add_parser( + "update-config", + help="更新应用配置项。请谨慎使用。", + aliases=["更新配置"], + ) + update_cfg_parser.add_argument( + "--key-value-pairs", + required=True, + help='包含待更新配置项及其新值的JSON字符串 (例如: \'{"app_name": "新名称", "log_level": "DEBUG"}\')', + ) + # Consider adding --confirm for critical changes later if needed + update_cfg_parser.set_defaults(func=update_config_command) + + # --- Statistics Viewing Subcommand --- + view_stats_parser = subparsers.add_parser( + "view-stats", + help="查看应用相关的统计信息。", + aliases=["查看统计"], + ) + view_stats_parser.set_defaults(func=view_stats_command) + # 解析命令行参数 # (Parse command-line arguments) args = parser.parse_args() @@ -349,6 +504,868 @@ async def main_async(): # 定义模块对外暴露的接口 (Define the module's public interface) __all__ = ["main_async"] + +# Stubs for new async command functions +async def add_question_command(args: argparse.Namespace): + if not qb_crud_instance: + print("错误: 题库操作模块 (QuestionBankCRUD) 未初始化。") + return + + print(f"正在尝试向题库 '{args.library_id}' 添加题目...") + + try: + question_type = QuestionTypeEnum(args.type) + options = json.loads(args.options) if args.options else [] + + # Validate options format + if not isinstance(options, list): + print( + '错误: --options 参数必须是一个有效的JSON列表字符串。例如: \'["选项A", "选项B"]\'' + ) + return + for opt in options: + if not isinstance(opt, str): + print(f"错误: 选项列表中的每个选项都必须是字符串。找到: {type(opt)}") + return + + tags_list = [tag.strip() for tag in args.tags.split(",")] if args.tags else [] + + question_data = { + "body": args.content, + "question_type": question_type, + "ref": args.answer_detail, + # Tags are not directly in QuestionModel based on qb_models.py, + # Assuming CRUD handles tags separately or it's a custom field. + # For now, we'll pass it if CRUD expects it, otherwise it might be ignored or cause error. + # "tags": tags_list, # This line might be needed if CRUD supports it. + } + + if question_type == QuestionTypeEnum.SINGLE_CHOICE: + if not args.answer: + print("错误: 单选题必须提供 --answer 参数。") + return + if args.answer not in options: + print(f"错误: 答案 '{args.answer}' 必须是提供的选项之一: {options}") + return + question_data["correct_choices"] = [args.answer] + question_data["incorrect_choices"] = [ + opt for opt in options if opt != args.answer + ] + question_data["num_correct_to_select"] = 1 + elif question_type == QuestionTypeEnum.MULTIPLE_CHOICE: + # For MULTIPLE_CHOICE, assuming args.answer is a comma-separated string of correct options + if not args.answer: + print("错误: 多选题必须提供 --answer 参数 (逗号分隔的正确选项)。") + return + correct_answers = [ans.strip() for ans in args.answer.split(",")] + for ans in correct_answers: + if ans not in options: + print(f"错误: 答案 '{ans}' 必须是提供的选项之一: {options}") + return + question_data["correct_choices"] = correct_answers + question_data["incorrect_choices"] = [ + opt for opt in options if opt not in correct_answers + ] + question_data["num_correct_to_select"] = len(correct_answers) + # Add handling for other question types (FILL_IN_THE_BLANK, ESSAY_QUESTION) if needed based on QuestionModel + # For now, QuestionModel seems to primarily support choice-based questions via correct_choices/incorrect_choices + # and other types via standard_answer_text etc. which are not fully mapped in CLI args yet. + + # Create QuestionModel instance + # We need to handle potential Pydantic validation errors here + try: + question_to_create = QuestionModel(**question_data) + except Exception as e: # Catch Pydantic validation error + print(f"创建题目数据时发生验证错误: {e}") + return + + # Assuming qb_crud_instance.create_question returns the created question with an ID + # The actual method signature might be different (e.g. create_question_in_library) + # Also, qb_crud_instance might not have a 'tags' field in its QuestionModel + # This will likely need adjustment based on the actual CRUD interface for questions + + # Placeholder for actual CRUD call, which needs library_id and question model + # created_question = await qb_crud_instance.create_question( + # library_id=args.library_id, question_data=question_to_create + # ) + + # Simulating a call to a hypothetical extended CRUD method that handles tags: + # created_question = await qb_crud_instance.create_question_with_tags( + # library_id=args.library_id, question_data=question_to_create, tags=tags_list + # ) + + # Based on the provided `QuestionModel`, 'tags' is not a field. + # The CRUD method `create_question` likely takes `QuestionModel` and `library_id`. + # If tags need to be stored, the CRUD method itself must handle it, + # or the QuestionModel needs a `tags` field. + # For now, we'll assume tags are not directly part of the QuestionModel in `qb_crud_instance.create_question`. + # The `tags` argument in the CLI will be acknowledged but might not be persisted unless CRUD supports it. + + # Let's assume a method signature like: create_question(self, library_id: str, question: QuestionModel, tags: Optional[List[str]] = None) + # This is a guess; the actual CRUD method signature is unknown. + # For the purpose of this exercise, I will assume the CRUD method is: + # `qb_crud_instance.add_question_to_library(library_id: str, question_data: QuestionModel, tags: List[str])` + # And it returns a model that includes an `id` attribute. + + # This is a placeholder. The actual method might be different. + # For example, it might be `qb_crud_instance.create_question_in_library(library_id=args.library_id, question_obj=question_to_create, tags=tags_list)` + # I will use a plausible name based on common CRUD patterns. + created_question_response = await qb_crud_instance.create_question_in_library( + library_id=args.library_id, + question_data=question_to_create, + tags=tags_list, # Assuming the CRUD method can take tags + ) + + if created_question_response and hasattr(created_question_response, "id"): + print(f"题目已成功添加到题库 '{args.library_id}'。") + print(f"新题目ID: {created_question_response.id}") + if tags_list: + print(f"标签: {', '.join(tags_list)}") + else: + print(f"添加题目到题库 '{args.library_id}' 失败。未返回题目ID。") + + except json.JSONDecodeError: + print("错误: --options 参数不是有效的JSON字符串。") + except ValueError as e: # Catches issues like invalid enum values + print(f"输入值错误: {e}") + except Exception as e: + print(f"添加题目时发生未预料的错误: {e}") + + +async def view_question_command(args: argparse.Namespace): + if not qb_crud_instance: + print("错误: 题库操作模块 (QuestionBankCRUD) 未初始化。") + return + + print(f"正在尝试查看题目ID: {args.question_id}...") + try: + # Assume get_question_by_id returns a model that includes an 'id' field, + # and potentially 'tags' if the CRUD joins them or they are part of the stored model. + # Let's call it `QuestionDetailsModel` for this example, which might be QuestionModel itself + # or an augmented version. + question = await qb_crud_instance.get_question_by_id( + question_id=args.question_id + ) + + if question: + print("\n--- 题目详情 ---") + if hasattr(question, "id"): # If the returned object has an ID + print(f"题目ID: {question.id}") + else: # Fallback to the requested ID if not part of the response model + print(f"题目ID: {args.question_id}") + + print( + f"题库ID: {question.library_id if hasattr(question, 'library_id') else '未知'}" + ) # Assuming library_id is part of fetched model + print( + f"类型: {question.question_type.value if hasattr(question, 'question_type') and question.question_type else '未知'}" + ) + print( + f"题目内容:\n{question.body if hasattr(question, 'body') else '未知'}" + ) + + if hasattr(question, "question_type") and question.question_type in [ + QuestionTypeEnum.SINGLE_CHOICE, + QuestionTypeEnum.MULTIPLE_CHOICE, + ]: + print("\n选项:") + options = [] + if hasattr(question, "correct_choices") and question.correct_choices: + options.extend(question.correct_choices) + if ( + hasattr(question, "incorrect_choices") + and question.incorrect_choices + ): + options.extend(question.incorrect_choices) + + # The original QuestionModel stores correct and incorrect choices separately. + # For display, we might want to show all choices. + # This part needs to be careful not to assume a specific structure for "options" during display + # if the model only provides correct_choices and incorrect_choices. + + all_options_display = [] + if hasattr(question, "correct_choices") and question.correct_choices: + for opt in question.correct_choices: + all_options_display.append(f" - {opt} (正确答案)") + if ( + hasattr(question, "incorrect_choices") + and question.incorrect_choices + ): + for opt in question.incorrect_choices: + all_options_display.append(f" - {opt}") + + if all_options_display: + for opt_display in all_options_display: + print(opt_display) + else: + print(" (未提供选项信息)") + + print("\n正确答案:") + if hasattr(question, "correct_choices") and question.correct_choices: + for ans in question.correct_choices: + print(f" - {ans}") + else: + print(" (未设置正确答案)") + + if ( + hasattr(question, "standard_answer_text") + and question.standard_answer_text + ): # For essay questions + print(f"\n参考答案 (主观题):\n{question.standard_answer_text}") + + if hasattr(question, "ref") and question.ref: + print(f"\n答案解析:\n{question.ref}") + + # Assuming tags are fetched as part of the question object by the CRUD method + if hasattr(question, "tags") and question.tags: + print(f"\n标签: {', '.join(question.tags)}") + + print("--- 详情结束 ---") + else: + print(f"错误: 未找到题目ID为 '{args.question_id}' 的题目。") + + except Exception as e: + # Catch specific exceptions like 'QuestionNotFoundException' if defined by CRUD layer + # For now, a generic catch. + print(f"查看题目时发生错误: {e}") + + +async def update_question_command(args: argparse.Namespace): + if not qb_crud_instance: + print("错误: 题库操作模块 (QuestionBankCRUD) 未初始化。") + return + + print(f"正在尝试更新题目ID: {args.question_id}...") + + try: + existing_question = await qb_crud_instance.get_question_by_id( + question_id=args.question_id + ) + if not existing_question: + print(f"错误: 未找到题目ID为 '{args.question_id}' 的题目,无法更新。") + return + + update_payload = {} # Using a dict for partial updates + + if args.content: + if args.content != existing_question.body and not args.confirm_rename: + print( + "错误: 题目内容 (content) 已更改,但未提供 --confirm-rename 标志。操作已取消。" + ) + print("如果您确定要修改题目内容,请同时使用 --confirm-rename 参数。") + return + update_payload["body"] = args.content + + if args.answer_detail: + update_payload["ref"] = args.answer_detail + + new_tags_list = None + if ( + args.tags is not None + ): # Check if tags argument was provided (even if empty string) + new_tags_list = ( + [tag.strip() for tag in args.tags.split(",") if tag.strip()] + if args.tags + else [] + ) + # This assumes the CRUD update method can handle tags. + # If QuestionModel had a 'tags' field, this would be `update_payload["tags"] = new_tags_list` + + # Handling options and answer is complex as it depends on question type + # and how they are stored in QuestionModel (correct_choices, incorrect_choices) + if args.options or args.answer: + if not hasattr(existing_question, "question_type"): + print("错误: 无法确定现有题目的类型,无法更新选项或答案。") + return + + current_q_type = existing_question.question_type + options = ( + json.loads(args.options) if args.options else None + ) # Parse new options if provided + + if options is not None and not isinstance(options, list): + print( + '错误: --options 参数必须是一个有效的JSON列表字符串。例如: \'["选项A", "选项B"]\'' + ) + return + for opt_idx, opt_val in enumerate(options or []): + if not isinstance(opt_val, str): + print( + f"错误: 新选项列表索引 {opt_idx} 处的值必须是字符串。找到: {type(opt_val)}" + ) + return + + current_options = [] + if ( + hasattr(existing_question, "correct_choices") + and existing_question.correct_choices + ): + current_options.extend(existing_question.correct_choices) + if ( + hasattr(existing_question, "incorrect_choices") + and existing_question.incorrect_choices + ): + current_options.extend(existing_question.incorrect_choices) + + final_options = options if options is not None else current_options + new_answer = ( + args.answer + if args.answer is not None + else ( + existing_question.correct_choices[0] + if hasattr(existing_question, "correct_choices") + and existing_question.correct_choices + and current_q_type == QuestionTypeEnum.SINGLE_CHOICE + else None + ) + ) + # For multi-choice, new_answer might need to be a list from comma-separated string + + if current_q_type == QuestionTypeEnum.SINGLE_CHOICE: + if ( + new_answer is None + ): # If answer is being cleared, or was not set and not provided now + # This case might need clarification: can a choice question exist without an answer? + # For now, if no new answer is given, and no old one, then error or keep as is. + # If new_answer is explicitly empty, it might mean to clear it. + # Let's assume for now an answer is required if options are present. + if ( + final_options and not new_answer + ): # If there are options but no answer + print( + "错误: 单选题更新时,如果提供了选项,则必须有明确的答案。" + ) + return + + if new_answer and new_answer not in final_options: + print( + f"错误: 新答案 '{new_answer}' 必须是最终选项列表之一: {final_options}" + ) + return + update_payload["correct_choices"] = [new_answer] if new_answer else [] + update_payload["incorrect_choices"] = [ + opt for opt in final_options if opt != new_answer + ] + update_payload["num_correct_to_select"] = ( + 1 if new_answer and final_options else 0 + ) + + elif current_q_type == QuestionTypeEnum.MULTIPLE_CHOICE: + # Assuming new_answer for multiple choice is comma-separated if provided via args.answer + # If args.answer is not provided, we rely on existing_question.correct_choices + new_correct_answers_list = [] + if args.answer is not None: # New answer string is provided + new_correct_answers_list = [ + ans.strip() for ans in args.answer.split(",") + ] + elif hasattr( + existing_question, "correct_choices" + ): # No new answer string, use existing + new_correct_answers_list = existing_question.correct_choices + + for ans in new_correct_answers_list: + if ans not in final_options: + print( + f"错误: 更新后的答案 '{ans}' 必须是最终选项列表之一: {final_options}" + ) + return + update_payload["correct_choices"] = new_correct_answers_list + update_payload["incorrect_choices"] = [ + opt for opt in final_options if opt not in new_correct_answers_list + ] + update_payload["num_correct_to_select"] = len(new_correct_answers_list) + + # If only options are provided, but not answer, and type is choice: + # Need to ensure existing answer is still valid or require new answer. + if ( + options is not None + and args.answer is None + and current_q_type + in [QuestionTypeEnum.SINGLE_CHOICE, QuestionTypeEnum.MULTIPLE_CHOICE] + ): + if not update_payload.get( + "correct_choices" + ): # if correct_choices wasn't set (e.g. existing answer was not in new options) + print( + "警告: 选项已更新,但未提供新的答案,或旧答案已失效。请使用 --answer 更新答案。" + ) + + if ( + not update_payload and new_tags_list is None + ): # Check if any actual update data is present + print("未提供任何需要更新的字段。操作已取消。") + return + + # The qb_crud_instance.update_question method needs to be defined. + # It should accept question_id and a dictionary (or Pydantic model) for updates. + # And potentially tags as a separate argument. + # e.g., updated_question = await qb_crud_instance.update_question(question_id, update_data=QuestionUpdateModel(**update_payload), tags=new_tags_list) + # For now, using a dict for update_payload. + # Let's assume a method like: + # `qb_crud_instance.update_question_by_id(question_id: str, update_doc: dict, tags: Optional[List[str]])` + + # This is a placeholder for the actual CRUD call. + # The update_payload should ideally be validated by a Pydantic model for update. + # For example, `QuestionUpdate(**update_payload)` + updated_q_response = await qb_crud_instance.update_question_fields( + question_id=args.question_id, + update_data=update_payload, + tags=new_tags_list, # Pass tags if CRUD supports it + ) + + if updated_q_response: # Assuming CRUD returns the updated question or True + print(f"题目ID '{args.question_id}' 已成功更新。") + # Optionally, print which fields were updated if the response indicates this. + else: + # This 'else' might not be reachable if CRUD raises an exception on failure. + print(f"更新题目ID '{args.question_id}' 失败。") + + except json.JSONDecodeError: + print("错误: --options 参数不是有效的JSON字符串。") + except ValueError as e: + print(f"输入值错误或转换失败: {e}") + except Exception as e: + # Consider specific exceptions like QuestionNotFoundException if defined by CRUD + print(f"更新题目时发生未预料的错误: {e}") + import traceback + + traceback.print_exc() + + +async def delete_question_command(args: argparse.Namespace): + if not qb_crud_instance: + print("错误: 题库操作模块 (QuestionBankCRUD) 未初始化。") + return + + if not args.confirm: + print("错误: 删除操作需要 --confirm 标志进行确认。操作已取消。") + print( + f"如果您确定要删除题目ID '{args.question_id}',请再次运行命令并添加 --confirm 参数。" + ) + return + + print(f"正在尝试删除题目ID: {args.question_id}...") + try: + # Assume a CRUD method like delete_question_by_id(question_id: str) + # This method should ideally return True if deletion was successful, + # or raise a specific exception (e.g., QuestionNotFound) if it matters, + # or return False if deletion failed for other reasons. + # Some delete operations are idempotent (deleting a non-existent item is success). + # Let's assume it returns True on success, False on failure (e.g. lock error), + # and handles QuestionNotFound internally or by not raising an error for it. + + deleted_successfully = await qb_crud_instance.delete_question_by_id( + question_id=args.question_id + ) + + if deleted_successfully: + print(f"题目ID '{args.question_id}' 已成功删除。") + else: + # This path might be taken if the question didn't exist AND the CRUD method returns False for that, + # or if there was another failure preventing deletion. + # If QuestionNotFound is not an error for delete, this message might need adjustment. + print( + f"删除题目ID '{args.question_id}' 失败。可能题目不存在或删除过程中发生错误。" + ) + # To provide better feedback, CRUD could return a more specific status or raise specific exceptions. + + except Exception as e: + # Example: Catch a specific 'QuestionNotFoundException' if CRUD defines it and + # we want to treat "not found" as a specific case (e.g., not an error for delete). + # if isinstance(e, QuestionNotFoundException): + # print(f"题目ID '{args.question_id}' 未找到,无需删除。") + # else: + print(f"删除题目ID '{args.question_id}' 时发生未预料的错误: {e}") + + +async def list_questions_command(args: argparse.Namespace): + if not qb_crud_instance: + print("错误: 题库操作模块 (QuestionBankCRUD) 未初始化。") + return + + print( + f"正在尝试列出题库 '{args.library_id}' 中的题目 (页码: {args.page}, 每页: {args.per_page})..." + ) + try: + # Assume a CRUD method like: + # get_questions_from_library(library_id: str, page: int, per_page: int) -> dict + # The returned dict might look like: + # { + # "items": [QuestionModelSubset, ...], + # "total_items": int, + # "total_pages": int, + # "current_page": int + # } + # Or it might just return a list of questions if pagination is simpler. + # Let's assume a method that returns a list of questions directly for now, + # and pagination info might be part of those question objects or inferred. + # A more robust CRUD would return total counts for proper pagination display. + + # Placeholder: Actual signature might be `get_questions_in_library` or similar + # and might return a more complex object with pagination data. + # For this example, let's assume it returns a list of question objects (e.g., QuestionModel or a summary model) + # and we don't have total pages/items info from this call directly, unless the CRUD provides it. + + # Let's refine the assumed CRUD call to return a structure that includes pagination details: + # result = await qb_crud_instance.list_questions_in_library_paginated( + # library_id=args.library_id, page=args.page, limit=args.per_page + # ) + # Assuming `result` is an object or dict with `items` (list of questions), + # `total_count`, `page`, `per_page`. + + # Simpler assumption for now: returns a list of QuestionModel like objects + # And we don't have total count from this call. + questions_page = await qb_crud_instance.get_questions_from_library_paginated( + library_id=args.library_id, page=args.page, per_page=args.per_page + ) + # questions_page should be a list of objects, each having at least 'id' and 'body' + # Ideally, the response would also include total_items and total_pages. + # Let's say questions_page = { "questions": [...], "total_count": N, "page": P, "per_page": PP } + + if ( + questions_page + and isinstance(questions_page, dict) + and "questions" in questions_page + ): + items = questions_page["questions"] + total_items = questions_page.get( + "total_count", len(items) + ) # Fallback if total_count not provided + total_pages = questions_page.get( + "total_pages", None + ) # If CRUD provides total_pages + + if not items: + print( + f"题库 '{args.library_id}' 中未找到任何题目 (第 {args.page} 页)。" + ) + if total_items > 0 and args.page > 1: + print(f"总共有 {total_items} 个题目。可能您请求的页码超出了范围。") + elif total_items == 0: + print("该题库当前为空。") + return + + print( + f"\n--- 题库 '{args.library_id}' - 第 {args.page} 页 (共 {total_items} 个题目) ---" + ) + if total_pages: + print(f" (总页数: {total_pages})") + + for idx, q_item in enumerate(items): + # Assuming q_item has 'id' and 'body' attributes. + # It might be a full QuestionModel or a summary. + content_snippet = ( + (q_item.body[:70] + "...") + if hasattr(q_item, "body") and q_item.body and len(q_item.body) > 70 + else (q_item.body if hasattr(q_item, "body") else "无内容") + ) + q_id = q_item.id if hasattr(q_item, "id") else "未知ID" + print( + f" {((args.page - 1) * args.per_page) + idx + 1}. ID: {q_id} - 内容: {content_snippet}" + ) + + print(f"--- 共显示 {len(items)} 个题目 ---") + if total_pages and args.page < total_pages: + print(f"要查看下一页,请使用 --page {args.page + 1}") + + elif ( + isinstance(questions_page, list) and not questions_page + ): # Simpler list return, empty + print(f"题库 '{args.library_id}' 中未找到任何题目 (第 {args.page} 页)。") + elif ( + isinstance(questions_page, list) and questions_page + ): # Simpler list return, with items + print(f"\n--- 题库 '{args.library_id}' - 第 {args.page} 页 ---") + for idx, q_item in enumerate(questions_page): + content_snippet = ( + (q_item.body[:70] + "...") + if hasattr(q_item, "body") and q_item.body and len(q_item.body) > 70 + else (q_item.body if hasattr(q_item, "body") else "无内容") + ) + q_id = q_item.id if hasattr(q_item, "id") else "未知ID" + print( + f" {((args.page - 1) * args.per_page) + idx + 1}. ID: {q_id} - 内容: {content_snippet}" + ) + print(f"--- 共显示 {len(questions_page)} 个题目 ---") + if len(questions_page) == args.per_page: + print( + f"可能还有更多题目,请尝试使用 --page {args.page + 1} 查看下一页。" + ) + else: + # This case handles if questions_page is None or an unexpected structure + print(f"未能从题库 '{args.library_id}' 获取题目列表,或题库为空。") + + except Exception as e: + # Example: Catch a specific 'LibraryNotFoundException' if CRUD defines it. + # if isinstance(e, LibraryNotFoundException): + # print(f"错误: 未找到ID为 '{args.library_id}' 的题库。") + # else: + print(f"列出题库 '{args.library_id}' 中的题目时发生错误: {e}") + import traceback + + traceback.print_exc() + + +# Helper for accessing nested dictionary keys using dot notation +def get_nested_value(data_dict, key_path): + keys = key_path.split(".") + value = data_dict + for key in keys: + if isinstance(value, dict) and key in value: + value = value[key] + elif isinstance(value, BaseModel) and hasattr( + value, key + ): # Handle Pydantic models + value = getattr(value, key) + else: + return None # Key not found or path invalid + return value + + +async def view_config_command(args: argparse.Namespace): + if not settings_crud_instance: + print("错误: 配置操作模块 (SettingsCRUD) 未初始化。") + return + + try: + # Assuming get_all_settings() returns a Pydantic model instance (e.g., SettingsResponseModel) + # or a dict that can be parsed into one. + all_settings_model = await settings_crud_instance.get_all_settings() + + if not all_settings_model: + print("错误: 未能获取到任何配置信息。") + return + + # Convert Pydantic model to dict for easier processing if it's not already a dict + # The SettingsResponseModel has `model_config = {"extra": "ignore"}` + # and fields are Optional, so it should handle settings.json not having all keys. + # We should use model_dump to get a dict from the Pydantic model. + if isinstance(all_settings_model, BaseModel): + settings_dict = all_settings_model.model_dump( + exclude_unset=True + ) # exclude_unset for cleaner output + else: # Assuming it's already a dict (less ideal, CRUD should return model) + settings_dict = all_settings_model + + if args.key: + print(f"正在查找配置项: '{args.key}'...") + value = get_nested_value(settings_dict, args.key) + if value is not None: + print(f"\n--- 配置项 '{args.key}' ---") + # Pretty print if value is a dict or list + if isinstance(value, (dict, list)): + print(json.dumps(value, indent=2, ensure_ascii=False)) + else: + print(str(value)) + print("--- 结束 ---") + else: + print(f"错误: 未找到配置项键 '{args.key}'。") + else: + print("\n--- 当前应用配置 ---") + if settings_dict: + # Using json.dumps for pretty printing the whole dict + print(json.dumps(settings_dict, indent=2, ensure_ascii=False)) + else: + print("未找到任何配置项。") + print("--- 配置结束 ---") + + except Exception as e: + print(f"查看配置时发生错误: {e}") + import traceback + + traceback.print_exc() + + +async def update_config_command(args: argparse.Namespace): + if not settings_crud_instance: + print("错误: 配置操作模块 (SettingsCRUD) 未初始化。") + return + + print("警告: 更新应用配置是一项敏感操作,请确保您了解所做更改的影响。") + + try: + update_data_dict = json.loads(args.key_value_pairs) + if not isinstance(update_data_dict, dict): + print( + "错误: --key-value-pairs 参数必须是一个有效的JSON对象 (字典) 字符串。" + ) + return + if not update_data_dict: + print("错误: 提供的键值对为空,没有可更新的配置项。") + return + + print("\n正在尝试更新以下配置项:") + for key, value in update_data_dict.items(): + # Truncate long values for display confirmation + display_value = str(value) + if len(display_value) > 70: + display_value = display_value[:67] + "..." + print(f" - {key}: {display_value}") + + # It's good practice to ask for confirmation here, especially for `update-config`. + # However, the prompt doesn't explicitly ask for a --confirm flag for this command, + # but mentions "Consider adding a --confirm flag". For now, proceeding without it. + # confirm = input("\n您确定要应用这些更改吗? (yes/no): ") + # if confirm.lower() != 'yes': + # print("操作已取消。") + # return + + # Assumption: settings_crud_instance.update_settings(dict) + # This method should perform validation against SettingsUpdatePayload internally. + # It should return the updated settings or a status. + # Let's assume it returns a model of the *actually updated* settings or True/False. + + # To ensure validation against SettingsUpdatePayload, we could do: + # validated_payload = SettingsUpdatePayload(**update_data_dict) + # updated_settings_response = await settings_crud_instance.update_settings(validated_payload.model_dump(exclude_unset=True)) + # This would raise Pydantic validation error before calling CRUD if input is bad. + # For now, let's assume CRUD handles validation of the raw dict. + + updated_result = await settings_crud_instance.update_settings(update_data_dict) + + if updated_result: # If CRUD returns True or the updated settings model + print("\n配置已成功更新。") + if isinstance( + updated_result, (dict, BaseModel) + ): # If it returns the updated data + print("更新后的值为:") + # If updated_result is a Pydantic model, convert to dict for printing + if isinstance(updated_result, BaseModel): + updated_result_dict = updated_result.model_dump(exclude_unset=True) + else: + updated_result_dict = updated_result + + # Print only the keys that were part of the update request + for key in update_data_dict.keys(): + if key in updated_result_dict: + print(f" - {key}: {updated_result_dict[key]}") + else: + print(f" - {key}: (未从更新结果中返回)") + else: # If it just returns True + print("请使用 'view-config' 命令查看更改后的配置。") + else: + # This path might be taken if CRUD returns False or None on failure, + # without raising an exception. + print("\n更新配置失败。请检查日志或输入数据。") + print("可能的原因包括: 无效的配置键、值不符合类型要求或验证规则。") + + except json.JSONDecodeError: + print("错误: --key-value-pairs 参数不是有效的JSON字符串。") + except ( + Exception + ) as e: # This could catch Pydantic validation errors if CRUD raises them + print(f"更新配置时发生错误: {e}") + # If e is a Pydantic ValidationError, it can be printed more nicely. + # from pydantic import ValidationError + # if isinstance(e, ValidationError): + # print("详细验证错误:") + # for error in e.errors(): + # print(f" 字段: {'.'.join(str(loc) for loc in error['loc'])}") + # print(f" 错误: {error['msg']}") + # else: + import traceback + + traceback.print_exc() + + +# Stats viewing command +async def view_stats_command(args: argparse.Namespace): + print("正在收集应用统计信息...") + + stats = {} + errors = [] + + # User stats + if user_crud_instance: + try: + total_users = await user_crud_instance.get_total_users_count() + stats["总用户数"] = total_users + except AttributeError: + errors.append("用户CRUD实例缺少 'get_total_users_count' 方法。") + except Exception as e: + errors.append(f"获取总用户数时出错: {e}") + else: + errors.append("用户CRUD实例未初始化。") + + # Question bank stats + if qb_crud_instance: + try: + total_questions = await qb_crud_instance.get_total_questions_count() + stats["总题目数 (所有题库)"] = total_questions + except AttributeError: + errors.append("题库CRUD实例缺少 'get_total_questions_count' 方法。") + except Exception as e: + errors.append(f"获取总题目数时出错: {e}") + + try: + counts_per_lib = await qb_crud_instance.get_questions_count_per_library() + if counts_per_lib: # Assuming it returns a dict like {'lib_id': count} + stats["各题库题目数"] = counts_per_lib + else: + stats["各题库题目数"] = "暂无题库或题目信息。" + except AttributeError: + errors.append("题库CRUD实例缺少 'get_questions_count_per_library' 方法。") + except Exception as e: + errors.append(f"获取各题库题目数时出错: {e}") + else: + errors.append("题库CRUD实例未初始化。") + + # Paper/Exam stats + if paper_crud_instance: + try: + completed_exams = ( + await paper_crud_instance.get_total_completed_exams_count() + ) + stats["已完成考试总数"] = completed_exams + except AttributeError: + errors.append("试卷CRUD实例缺少 'get_total_completed_exams_count' 方法。") + except Exception as e: + errors.append(f"获取已完成考试数时出错: {e}") + + try: + avg_score_data = ( + await paper_crud_instance.get_average_score() + ) # Assuming this returns a float or a dict with score + if isinstance(avg_score_data, (float, int)): + stats["平均考试得分"] = ( + f"{avg_score_data:.2f}%" if avg_score_data is not None else "N/A" + ) + elif isinstance(avg_score_data, dict) and "average_score" in avg_score_data: + stats["平均考试得分"] = ( + f"{avg_score_data['average_score']:.2f}%" + if avg_score_data["average_score"] is not None + else "N/A" + ) + else: + stats["平均考试得分"] = "数据不可用或格式不正确。" + + except AttributeError: + errors.append("试卷CRUD实例缺少 'get_average_score' 方法。") + except Exception as e: + errors.append(f"获取平均分时出错: {e}") + else: + errors.append("试卷CRUD实例未初始化。") + + print("\n--- 应用统计 ---") + if stats: + for key, value in stats.items(): + if isinstance(value, dict): + print(f"{key}:") + for sub_key, sub_value in value.items(): + print(f" - {sub_key}: {sub_value}") + else: + print(f"{key}: {value}") + else: + print("未能收集到任何统计信息。") + + if errors: + print("\n--- 收集统计信息时遇到的错误 ---") + for err in errors: + print(f"- {err}") + + print("--- 统计结束 ---") + + if __name__ == "__main__": # 这个检查确保脚本是直接运行的 (`python examctl.py ...`),而不是被导入的。 # (This check ensures the script is run directly (`python examctl.py ...`), not imported.) diff --git a/migrate_question_data.py b/migrate_question_data.py index 43d49f2..0c686c3 100644 --- a/migrate_question_data.py +++ b/migrate_question_data.py @@ -11,9 +11,10 @@ - 保留可选的 ref 字段(如果存在)。 - 移除 QuestionModel 定义之外的其他字段。 """ + import json -from pathlib import Path import sys +from pathlib import Path # 定义项目根目录,确保脚本可以从任何位置正确地找到数据文件 # Path(__file__) 是当前脚本的路径 @@ -27,8 +28,18 @@ # QuestionModel 定义中预期的核心字段 (用于筛选,确保不遗漏或添加多余字段) # 注意:'correct_fillings' 对于单选题通常为 None 或不存在,脚本逻辑会确保这一点。 -EXPECTED_FIELDS_BASE = {"body", "question_type", "correct_choices", "incorrect_choices", "num_correct_to_select"} -EXPECTED_FIELDS_OPTIONAL = {"ref", "correct_fillings"} # correct_fillings 将被明确设置为None或移除 +EXPECTED_FIELDS_BASE = { + "body", + "question_type", + "correct_choices", + "incorrect_choices", + "num_correct_to_select", +} +EXPECTED_FIELDS_OPTIONAL = { + "ref", + "correct_fillings", +} # correct_fillings 将被明确设置为None或移除 + def migrate_file(file_path: Path): """ @@ -62,7 +73,9 @@ def migrate_file(file_path: Path): for i, q_orig in enumerate(questions_data): if not isinstance(q_orig, dict): - print(f"警告:在 {file_path.name} 中发现非字典类型的题目数据(条目 {i+1}),已跳过。数据:{q_orig}") + print( + f"警告:在 {file_path.name} 中发现非字典类型的题目数据(条目 {i + 1}),已跳过。数据:{q_orig}" + ) skipped_count += 1 continue @@ -72,19 +85,31 @@ def migrate_file(file_path: Path): # 如果源文件中这些字段缺失,则使用默认值(空字符串/列表)以保证结构完整性 new_q["body"] = q_orig.get("body", "") if not new_q["body"]: - print(f"警告:在 {file_path.name} 的题目 {i+1} 中 'body' 字段为空或缺失。") + print( + f"警告:在 {file_path.name} 的题目 {i + 1} 中 'body' 字段为空或缺失。" + ) correct_choices = q_orig.get("correct_choices") - if correct_choices is None or not isinstance(correct_choices, list) or not correct_choices: - print(f"警告:在 {file_path.name} 的题目 {i+1} 中 'correct_choices' 字段为空、缺失或格式不正确。将使用空列表。") + if ( + correct_choices is None + or not isinstance(correct_choices, list) + or not correct_choices + ): + print( + f"警告:在 {file_path.name} 的题目 {i + 1} 中 'correct_choices' 字段为空、缺失或格式不正确。将使用空列表。" + ) new_q["correct_choices"] = [] else: new_q["correct_choices"] = correct_choices incorrect_choices = q_orig.get("incorrect_choices") - if incorrect_choices is None or not isinstance(incorrect_choices, list): # 允许空列表 - print(f"警告:在 {file_path.name} 的题目 {i+1} 中 'incorrect_choices' 字段缺失或格式不正确。将使用空列表。") - new_q["incorrect_choices"] = [] + if incorrect_choices is None or not isinstance( + incorrect_choices, list + ): # 允许空列表 + print( + f"警告:在 {file_path.name} 的题目 {i + 1} 中 'incorrect_choices' 字段缺失或格式不正确。将使用空列表。" + ) + new_q["incorrect_choices"] = [] else: new_q["incorrect_choices"] = incorrect_choices @@ -95,7 +120,9 @@ def migrate_file(file_path: Path): # 对于单选题,此值应为1。 # 如果原始数据中有此字段且值不为1,则打印警告,但仍强制设为1。 if "num_correct_to_select" in q_orig and q_orig["num_correct_to_select"] != 1: - print(f"警告:在 {file_path.name} 的题目 {i+1} 中 'num_correct_to_select' 字段值为 {q_orig['num_correct_to_select']},将强制修改为 1。") + print( + f"警告:在 {file_path.name} 的题目 {i + 1} 中 'num_correct_to_select' 字段值为 {q_orig['num_correct_to_select']},将强制修改为 1。" + ) new_q["num_correct_to_select"] = 1 # 4. 保留可选的 ref 字段 (如果存在且非空) @@ -122,19 +149,22 @@ def migrate_file(file_path: Path): try: with open(file_path, "w", encoding="utf-8") as f: json.dump(migrated_questions, f, ensure_ascii=False, indent=4) - print(f"文件 {file_path.name} 已成功迁移并写回。共处理 {processed_count} 道题目。") + print( + f"文件 {file_path.name} 已成功迁移并写回。共处理 {processed_count} 道题目。" + ) except IOError as e: print(f"错误:无法写回文件 {file_path.name}。错误详情: {e}") except Exception as e: print(f"错误:写回文件 {file_path.name} 时发生未知错误: {e}") + if __name__ == "__main__": # 检查脚本是否从项目根目录运行,以便LIBRARY_DIR正确 expected_data_path = BASE_DIR / "data" if not expected_data_path.exists() or not expected_data_path.is_dir(): - print(f"错误:脚本似乎没有在预期的项目根目录下运行。") + print("错误:脚本似乎没有在预期的项目根目录下运行。") print(f"预期的 'data' 文件夹路径: {expected_data_path}") - print(f"请确保从项目根目录执行此脚本,例如:python migrate_question_data.py") + print("请确保从项目根目录执行此脚本,例如:python migrate_question_data.py") sys.exit(1) print("开始执行题库数据迁移脚本...") @@ -157,7 +187,9 @@ def migrate_file(file_path: Path): print("题库数据迁移脚本执行完毕。") print("请注意:") print("1. 此脚本主要负责数据结构迁移,确保符合 QuestionModel 的基本要求。") - print("2. 脚本假设题目内容(题干、选项、解释)已为中文。如果仍存在非中文内容,需要人工复核和翻译。") + print( + "2. 脚本假设题目内容(题干、选项、解释)已为中文。如果仍存在非中文内容,需要人工复核和翻译。" + ) print(f"3. 已处理的文件位于: {LIBRARY_DIR}") print("4. 强烈建议在执行此脚本前备份您的 data/library 目录。") print("5. 如果之前执行覆写题库文件失败,此脚本的执行结果才是实际的迁移结果。") diff --git a/requirements.txt b/requirements.txt index 8447e62..c4dd3c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ asyncpg>=0.29.0 # For PostgreSQL adapter aiomysql>=0.2.0 # For MySQL adapter (also needs PyMySQL) aioredis>=2.0.0 # For Redis adapter aiosqlite>=0.19.0 # For SQLite adapter +openpyxl>=3.1.0 # For XLSX export/import # Documentation tools mkdocs>=1.5.0 diff --git a/run.py b/run.py index bc2f8b6..8ad7e68 100644 --- a/run.py +++ b/run.py @@ -22,6 +22,7 @@ process managers (like Gunicorn + Uvicorn workers or Supervisor), this script demonstrates how Uvicorn can be configured and run.) """ + # run.py (位于项目根目录 / Located in the project root directory) import uvicorn diff --git a/site/404.html b/site/404.html deleted file mode 100644 index 1327718..0000000 --- a/site/404.html +++ /dev/null @@ -1,529 +0,0 @@ - - - -
- - - - - - - - - - - - - - -我们非常欢迎您为本项目做出贡献!无论是报告错误、提出改进建议,还是直接贡献代码,您的参与都对项目至关重要。
-git clone https://github.com/YOUR_USERNAME/YOUR_REPOSITORY_NAME.gitgit checkout -b feature/your-feature-name 或 bugfix/issue-number。请为您的分支选择一个描述性的名称。git commit -m "feat: 添加了 XXX 功能" 或 fix: 修复了 YYY 问题 (#issue_number)。请遵循 Conventional Commits 规范编写提交信息。git push origin feature/your-feature-namemain 或 develop)。Closes #issue_number。请参考项目主 README.md 中的“快速开始”部分,使用 install.sh 脚本来设置您的本地开发环境。
本项目使用 MkDocs 和 Material for MkDocs 主题来生成文档网站。
-安装依赖:
- 确保您已安装项目依赖,特别是 mkdocs 和 mkdocs-material。可以运行 pip install -r requirements.txt 来安装或更新。
本地预览文档:
- 在项目根目录下运行以下命令,可以在本地启动一个实时预览服务器,通常访问 http://127.0.0.1:8000 即可查看:
-
docs/ 目录下的 Markdown 文件或 mkdocs.yml 配置文件时,网页会自动刷新。
-构建静态文档: - 要生成静态的 HTML 文档网站(通常用于部署),请在项目根目录下运行: -
- 构建后的文件将默认输出到项目根目录下的site/ 文件夹中。--clean 参数表示在构建前清除旧的构建文件。
-我们期望所有贡献者都能遵守友好和互相尊重的社区行为准则。
-感谢您的贡献!
- - - - - - - - - - - - - -本文档描述了仅供管理员使用的API端点。所有这些端点都需要有效的管理员Token进行认证(通过请求参数 ?token={ADMIN_ACCESS_TOKEN} 传递),并且通常以 /admin 作为路径前缀。
基础路径: /admin/settings
这些端点允许管理员查看和修改应用的核心配置。
-GET /settings)¶settings.json 文件的内容,可能不完全包含通过环境变量最终生效的配置值。敏感信息(如数据库密码)不会在此接口返回。200 OK: 成功获取配置信息。返回 SettingsResponseModel。
- 401 Unauthorized: Token缺失或无效。403 Forbidden: 当前用户非管理员或无权访问。500 Internal Server Error: 服务器内部错误导致无法获取配置。POST /settings)¶settings.json 文件并尝试动态重新加载配置到应用内存。注意:通过环境变量设置的配置项具有最高优先级,其在内存中的值不会被此API调用修改,但 settings.json 文件中的对应值会被更新。application/json): SettingsUpdatePayload 模型
- 200 OK: 配置成功更新并已重新加载。返回更新后的 SettingsResponseModel。400 Bad Request: 提供的配置数据无效或不符合约束。401 Unauthorized: Token缺失或无效。403 Forbidden: 当前用户非管理员。422 Unprocessable Entity: 请求体验证失败。500 Internal Server Error: 配置文件写入失败或更新时发生未知服务器错误。基础路径: /admin/users
这些端点允许管理员管理用户账户。
-GET /users)¶skip (integer, 可选, 默认: 0): 跳过的记录数,用于分页 (最小值为0)。limit (integer, 可选, 默认: 100): 返回的最大记录数 (最小值为1,最大值为200)。200 OK: 成功获取用户列表。返回 List[UserPublicProfile]。401 Unauthorized: Token缺失或无效。403 Forbidden: 当前用户非管理员。500 Internal Server Error: 获取用户列表时发生服务器内部错误。GET /users/{user_uid})¶user_uid (string, 必需): 要获取详情的用户的UID。200 OK: 成功获取用户信息。返回 UserPublicProfile 模型。401 Unauthorized: Token缺失或无效。403 Forbidden: 当前用户非管理员。404 Not Found: 指定UID的用户未找到。PUT /users/{user_uid})¶user_uid (string, 必需): 要更新信息的用户的UID。application/json): AdminUserUpdate 模型200 OK: 用户信息成功更新。返回更新后的 UserPublicProfile 模型。400 Bad Request: 提供的更新数据无效(例如,无效的标签值)。401 Unauthorized: Token缺失或无效。403 Forbidden: 当前用户非管理员。404 Not Found: 指定UID的用户未找到。422 Unprocessable Entity: 请求体验证失败。基础路径: /admin/papers
这些端点允许管理员管理用户生成的试卷。
-GET /papers)¶skip (integer, 可选, 默认: 0): 跳过的记录数。limit (integer, 可选, 默认: 100): 返回的最大记录数。200 OK: 成功获取试卷摘要列表。返回 List[PaperAdminView]。401 Unauthorized: Token缺失或无效。403 Forbidden: 当前用户非管理员。500 Internal Server Error: 获取试卷列表时发生服务器内部错误。GET /papers/{paper_id})paper_id (string, 必需, UUID格式): 要获取详情的试卷ID。200 OK: 成功获取试卷详细信息。返回 PaperFullDetailModel。401 Unauthorized: Token缺失或无效。403 Forbidden: 当前用户非管理员。404 Not Found: 指定ID的试卷未找到。500 Internal Server Error: 获取试卷详情时发生服务器内部错误。DELETE /papers/{paper_id})paper_id (string, 必需, UUID格式): 要删除的试卷ID。204 No Content: 试卷成功删除。401 Unauthorized: Token缺失或无效。403 Forbidden: 当前用户非管理员。404 Not Found: 指定ID的试卷未找到。500 Internal Server Error: 删除试卷时发生服务器内部错误。基础路径: /admin/question-banks
这些端点允许管理员管理题库的元数据和题目内容。
-GET /question-banks)200 OK: 成功获取题库元数据列表。返回 List[LibraryIndexItem]。401 Unauthorized: Token缺失或无效。403 Forbidden: 当前用户非管理员。500 Internal Server Error: 获取题库元数据时发生服务器内部错误。GET /question-banks/{difficulty_id}/content)difficulty_id (string, 必需): 要获取内容的题库难度ID (例如: "easy", "hybrid")。200 OK: 成功获取题库内容。返回 QuestionBank 模型。401 Unauthorized: Token缺失或无效。403 Forbidden: 当前用户非管理员。404 Not Found: 指定难度的题库未找到。500 Internal Server Error: 获取题库内容时发生服务器内部错误。POST /question-banks/{difficulty_id}/questions)difficulty_id (string, 必需): 要添加题目的题库难度ID。application/json): QuestionModel 模型201 Created: 题目成功添加到题库。返回已添加的 QuestionModel。400 Bad Request: 提供的题目数据无效。401 Unauthorized: Token缺失或无效。403 Forbidden: 当前用户非管理员。404 Not Found: 指定难度的题库未找到。422 Unprocessable Entity: 请求体验证失败。500 Internal Server Error: 添加题目到题库时发生服务器内部错误。DELETE /question-banks/{difficulty_id}/questions)difficulty_id (string, 必需): 要删除题目的题库难度ID。index (integer, 必需, ge=0): 要删除的题目在列表中的索引 (从0开始)。204 No Content: 题目成功删除。400 Bad Request: 提供的索引无效。401 Unauthorized: Token缺失或无效。403 Forbidden: 当前用户非管理员。404 Not Found: 指定难度的题库或指定索引的题目未找到。500 Internal Server Error: 删除题目时发生服务器内部错误。版本: 3.0.0
-本文档详细描述了在线考试系统的 API 接口。系统功能包括用户认证、试卷获取、答题、进度保存、历史记录查看以及管理员后台管理等。
-本 API 文档分为以下几个主要部分:
-大部分核心 API 端点需要通过 Token 进行认证。Token 在用户登录或注册成功后返回。
-token={USER_ACCESS_TOKEN} 来传递。token 进行认证,并且服务器端会校验该 Token 对应的用户是否拥有 admin 标签。当前系统使用的是自定义的简单Token机制,并非基于JWT。
-为方便理解,以下列出一些关键的 Pydantic 模型(详细字段请参考各 API 说明):
-UserCreationPayload: 用户注册请求体。UserCredentials: 用户登录请求体 (FastAPI的OAuth2PasswordRequestForm通常在后台使用,但概念上用户提供用户名密码)。TokenResponse: 认证成功后的 Token 响应。UserPublicProfile: 用户公开信息响应。UserProfileUpdatePayload: 用户更新个人资料请求。UserPasswordUpdatePayload: 用户更新密码请求。PaperDetailModel: (通常作为获取新试卷或历史试卷详情的)试卷详情响应。PaperSubmissionPayload: 提交/更新试卷答案的请求。ProgressUpdateResponse: 更新试卷进度的响应。GradingResultResponse: 试卷批改结果的响应。UserPaperHistoryItem: 用户历史记录条目。LibraryIndexItem: 题库元数据。QuestionModel: 题库题目模型。SettingsResponseModel: 管理员获取配置响应。SettingsUpdatePayload: 管理员更新配置请求。PaperAdminView: 管理员查看试卷摘要。PaperFullDetailAdminView: 管理员查看试卷完整详情。MessageResponse: 通用的消息响应体 (例如,操作成功但无特定数据返回)。ErrorResponse: 通用的错误响应体 (通常由 HTTPException 自动处理)。QuestionTypeEnum, PaperPassStatusEnum, AuthStatusCodeEnum: 常用枚举类型,定义在 app/models/enums.py。本API力求遵循标准的HTTP状态码来指示请求的结果。
-200 OK: 请求成功执行,响应体中通常包含所请求的数据。201 Created: 资源成功创建(例如,用户注册成功后),响应体中可能包含新创建的资源或相关信息(如Token)。204 No Content: 请求成功执行,但响应体中无内容返回(例如,用户成功修改密码后,或部分删除操作成功后)。400 Bad Request: 客户端请求无效。这可能因为参数错误、业务逻辑不满足(如请求的题目数量不足以出题)、或提交的数据格式不正确但不符合特定验证错误类型。响应体的 detail 字段通常包含具体的错误描述。401 Unauthorized: 未认证或认证失败。通常由于Token无效、过期、缺失,或凭证不正确。响应头可能包含 WWW-Authenticate。403 Forbidden: 用户已认证,但无权访问所请求的资源。例如,用户账户被封禁,或普通用户尝试访问管理员专属接口。404 Not Found: 请求的资源不存在。例如,查询一个不存在的试卷ID或用户UID。409 Conflict: 请求与服务器当前状态冲突,无法完成。例如,尝试创建已存在的用户 (UID冲突)。422 Unprocessable Entity: 请求体数据虽然格式正确(例如是合法的JSON),但无法通过Pydantic模型的验证规则(如类型错误、必填字段缺失、值不符合约束等)。响应体通常包含详细的字段级验证错误信息。429 Too Many Requests: 客户端在给定时间内发送的请求过多,已超出速率限制。500 Internal Server Error: 服务器内部发生未预期的错误,导致无法完成请求。关于响应体中的业务状态字段:
-在部分成功响应(如 200 OK)的场景下,响应体内的特定字段(例如 GradingResultResponse 中的 status_code 字段,其值为 PaperPassStatusEnum 枚举的成员如 "PASSED" 或 "FAILED")会提供更细致的业务处理结果。这些字段用于区分业务逻辑上的不同成功状态,而非HTTP层面的错误。对于API错误,应优先参考HTTP状态码和 HTTPException 返回的 detail 信息。
系统通过读取 data/library/index.json 文件动态生成此枚举。通常可能包含:
easyhybridhardindex.json 中定义的 id)定义在 app/models/user_models.py 中的 UserTag 枚举,主要包括:
admin: 管理员user: 普通用户banned: 禁用用户limited: 受限用户grader: 批阅者 (规划中)examiner: 出题者/题库管理员 (规划中)manager: 运营管理员 (规划中)定义在 app/models/enums.py,目前主要使用:
-- single_choice: 单选题
定义在 app/models/enums.py,例如:
-- PASSED: 已通过
-- FAILED: 未通过
-- PENDING: 待处理/待批改
[end of docs/api/index.md]
- - - - - - - - - - - - - -本文档详细描述了与普通用户操作相关的API端点,包括用户认证、个人资料管理、核心的考试答题流程以及一些公开查询的接口。所有路径相对于应用根路径。
-基础路径: /auth
POST /signin)¶application/json): UserCreationPayload 模型
- 201 Created: 注册成功。返回 TokenResponse 模型。
- 409 Conflict: 用户名已存在。响应体: {"detail": "用户名 'yonghu001' 已被注册。"}422 Unprocessable Entity: 请求数据验证失败 (例如,uid 或 password 不符合要求)。429 Too Many Requests: 请求过于频繁。响应体: {"detail": "注册请求过于频繁,请稍后再试。"}POST /login)¶application/x-www-form-urlencoded 表单数据(由FastAPI的 OAuth2PasswordRequestForm 处理),而非JSON。成功后返回访问令牌。application/x-www-form-urlencoded):username: (string, 必需) 用户名 (对应 uid)password: (string, 必需) 密码200 OK: 登录成功。返回 TokenResponse 模型。401 Unauthorized: 用户名或密码错误。响应体: {"detail": "用户名或密码不正确。"}422 Unprocessable Entity: 请求数据验证失败 (例如,表单字段缺失)。429 Too Many Requests: 请求过于频繁。响应体: {"detail": "登录请求过于频繁,请稍后再试。"}GET /login)¶token 提供)获取一个新的访问令牌。成功后,旧令牌将失效。token (string, 必需): 待刷新的有效旧访问令牌。200 OK: 令牌刷新成功。返回 TokenResponse 模型。401 Unauthorized: 提供的旧令牌无效或已过期。响应体: {"detail": "提供的令牌无效或已过期,无法刷新。"}基础路径: /users/me
-认证: 所有此部分接口都需要用户Token认证 (?token={USER_ACCESS_TOKEN} 作为查询参数)
GET /)¶200 OK: 成功获取用户信息。返回 UserPublicProfile 模型。401 Unauthorized: 令牌无效或已过期。403 Forbidden: 用户账户已被封禁。404 Not Found: 用户未找到(理论上在Token有效时此错误不应发生)。PUT /)¶application/json): UserProfileUpdatePayload 模型 (所有字段可选)200 OK: 更新成功。返回更新后的 UserPublicProfile 模型。401 Unauthorized: 令牌无效或已过期。403 Forbidden: 用户账户已被封禁。404 Not Found: 用户未找到。422 Unprocessable Entity: 请求体验证失败。PUT /password)application/json): UserPasswordUpdatePayload 模型204 No Content: 密码修改成功。无响应体。400 Bad Request: 当前密码不正确。响应体: {"detail": "当前密码不正确。"}401 Unauthorized: 令牌无效或已过期。403 Forbidden: 用户账户已被封禁。404 Not Found: 用户未找到。422 Unprocessable Entity: 新密码不符合要求。500 Internal Server Error: 更新密码时发生未知错误。认证: 所有此部分接口都需要用户Token认证 (?token={USER_ACCESS_TOKEN} 作为查询参数)
GET /get_exam)¶token (string, 必需): 用户访问令牌。difficulty (string, 可选, 默认: "hybrid"): 新试卷的难度级别 (来自 DifficultyLevel 枚举)。num_questions (integer, 可选, 1-200): 请求的题目数量。200 OK: 成功获取新试卷。返回 PaperDetailModel 模型,其中 questions 列表中的题目为 ExamQuestionClientView 类型。
- // PaperDetailModel (部分) 结合 ExamQuestionClientView 示例
-{
- "paper_id": "uuid-string-paper-id",
- "user_uid": "yonghu001",
- "difficulty": "hybrid",
- "num_questions": 2, // 实际生成的题目数
- "questions": [
- {
- "question_id": "uuid-string-q1", // 题目在试卷中的唯一ID
- "body": "题目1的题干内容...",
- "choices": [ // 所有选项合并打乱后呈现给用户
- "选项A文本",
- "选项B文本",
- "选项C文本",
- "选项D文本"
- ],
- "question_type": "single_choice" // 题目类型
- },
- {
- "question_id": "uuid-string-q2",
- "body": "题目2的题干内容...",
- "choices": [
- "选项X文本",
- "选项Y文本",
- "选项Z文本"
- ],
- "question_type": "single_choice"
- }
- ],
- "created_at": "2024-01-01T10:00:00Z",
- "pass_status": "PENDING" // 初始为待处理
- // ... 其他试卷元数据
-}
-400 Bad Request: 请求参数无效或业务逻辑错误(如题库题目不足)。401 Unauthorized: 令牌无效或已过期。403 Forbidden: 用户账户已被封禁。429 Too Many Requests: 获取新试卷请求过于频繁。500 Internal Server Error: 创建新试卷时发生意外服务器错误。POST /update)¶token (string, 必需): 用户访问令牌。application/json): PaperSubmissionPayload 模型200 OK: 进度已成功保存。返回 ProgressUpdateResponse 模型。
- 400 Bad Request: 请求数据无效(如答案数量错误)。401 Unauthorized: 令牌无效或已过期。403 Forbidden: 试卷已完成,无法更新进度。404 Not Found: 试卷未找到或用户无权访问。500 Internal Server Error: 更新进度时发生意外服务器错误。POST /finish)¶token (string, 必需): 用户访问令牌。application/json): PaperSubmissionPayload 模型200 OK: 试卷已成功接收并完成批改。返回 GradingResultResponse 模型。
- 400 Bad Request: 无效的提交数据(例如,提交的答案数量与试卷题目总数不匹配)。401 Unauthorized: 用户未认证(Token无效或缺失)。403 Forbidden: 用户无权进行此操作(非预期,但为完备性保留)。404 Not Found: 要提交的试卷ID不存在,或不属于当前用户。409 Conflict: 操作冲突(例如,该试卷已被最终批改且系统配置为不允许重复提交)。422 Unprocessable Entity: 请求体数据校验失败。500 Internal Server Error: 服务器内部错误(例如,因试卷数据结构问题导致无法批改,或在批改过程中发生其他意外)。GET /history)¶token (string, 必需): 用户访问令牌。200 OK: 成功获取答题历史。返回 List[UserPaperHistoryItem]。401 Unauthorized: 令牌无效或已过期。GET /history_paper)token (string, 必需): 用户访问令牌。paper_id (string, 必需, UUID格式): 要获取详情的历史试卷ID。200 OK: 成功获取历史试卷详情。返回 PaperDetailModel (或类似的包含完整题目、用户答案和正确答案的详细模型)。401 Unauthorized: 令牌无效或已过期。404 Not Found: 指定的历史试卷未找到或用户无权查看。响应体: {"detail": "指定的历史试卷未找到或您无权查看。"}此部分包含所有公开访问的API端点,无需用户认证。
-GET /difficulties)¶200 OK: 成功获取题库难度列表。返回 List[LibraryIndexItem]。500 Internal Server Error: 获取题库元数据时发生服务器内部错误。GET /users/directory)200 OK: 成功获取用户目录列表。返回 List[UserDirectoryEntry]。500 Internal Server Error: 获取用户目录时发生服务器内部错误。[end of docs/api/user_exam.md]
- - - - - - - - - - - - - - - - -