From 998211c483cd2a42d6a6d0d4848ae0ae916bb377 Mon Sep 17 00:00:00 2001 From: 111 Date: Sat, 24 Jan 2026 19:33:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96=E8=80=83?= =?UTF-8?q?=E5=9F=B9=E7=BB=83=E7=B3=BB=E7=BB=9F=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL --- .cursorignore | 1 + .cursorrules | 18 + .drone.yml | 142 + .env.admin | 20 + .env.development | 25 + .env.kpl | 26 + .gitignore | 71 + CONTEXT.md | 124 + admin-frontend/Dockerfile | 47 + admin-frontend/env.d.ts | 22 + admin-frontend/index.html | 14 + admin-frontend/nginx.conf | 47 + admin-frontend/package.json | 42 + admin-frontend/public/favicon.svg | 19 + admin-frontend/src/App.vue | 18 + admin-frontend/src/api/index.js | 108 + admin-frontend/src/assets/styles/main.scss | 58 + admin-frontend/src/main.ts | 24 + admin-frontend/src/router/index.js | 96 + admin-frontend/src/stores/auth.js | 40 + admin-frontend/src/views/Dashboard.vue | 204 + admin-frontend/src/views/Layout.vue | 234 + admin-frontend/src/views/Login.vue | 157 + admin-frontend/src/views/Logs.vue | 178 + admin-frontend/src/views/NotFound.vue | 31 + .../src/views/prompts/PromptDetail.vue | 179 + .../src/views/prompts/PromptList.vue | 159 + .../src/views/tenants/TenantConfigs.vue | 142 + .../src/views/tenants/TenantDetail.vue | 217 + .../src/views/tenants/TenantFeatures.vue | 129 + .../src/views/tenants/TenantList.vue | 250 + admin-frontend/tsconfig.app.json | 16 + admin-frontend/tsconfig.json | 8 + admin-frontend/tsconfig.node.json | 19 + admin-frontend/vite.config.ts | 39 + backend/.env.ex | 74 + backend/.env.example | 8 + backend/.env.fw | 69 + backend/.env.hl | 69 + backend/.env.hua | 69 + backend/.env.xy | 68 + backend/.env.yy | 69 + backend/.gitignore | 79 + backend/.pre-commit-config.yaml | 25 + backend/Dockerfile | 57 + backend/Dockerfile.admin | 66 + backend/Dockerfile.dev | 59 + backend/Makefile | 52 + backend/README.md | 410 + backend/SQL_EXECUTOR_FINAL_SUMMARY.md | 142 + backend/__init__.py | 0 .../alembic/versions/add_course_fields.sql | 10 + .../versions/add_mistake_mastery_fields.sql | 12 + .../versions/create_system_logs_table.sql | 30 + .../alembic/versions/create_tasks_table.sql | 50 + backend/app/__init__.py | 1 + backend/app/api/__init__.py | 1 + .../api/v1/03-Agent-Course/api_contract.yaml | 497 + backend/app/api/v1/__init__.py | 105 + backend/app/api/v1/ability.py | 187 + backend/app/api/v1/admin.py | 509 + backend/app/api/v1/admin_portal/__init__.py | 24 + backend/app/api/v1/admin_portal/auth.py | 277 + backend/app/api/v1/admin_portal/configs.py | 480 + backend/app/api/v1/admin_portal/features.py | 424 + backend/app/api/v1/admin_portal/prompts.py | 637 ++ backend/app/api/v1/admin_portal/schemas.py | 352 + backend/app/api/v1/admin_portal/tenants.py | 379 + backend/app/api/v1/admin_positions_backup.py | 158 + backend/app/api/v1/auth.py | 156 + backend/app/api/v1/broadcast.py | 145 + backend/app/api/v1/course_chat.py | 190 + backend/app/api/v1/courses.py | 786 ++ backend/app/api/v1/coze_gateway.py | 275 + backend/app/api/v1/endpoints/employee_sync.py | 236 + backend/app/api/v1/exam.py | 761 ++ backend/app/api/v1/knowledge_analysis.py | 201 + backend/app/api/v1/manager/__init__.py | 8 + .../app/api/v1/manager/student_practice.py | 345 + backend/app/api/v1/manager/student_scores.py | 447 + backend/app/api/v1/notifications.py | 255 + backend/app/api/v1/positions.py | 658 ++ backend/app/api/v1/practice.py | 1139 ++ backend/app/api/v1/preview.py | 285 + backend/app/api/v1/scrm.py | 311 + backend/app/api/v1/sql_executor.py | 363 + .../app/api/v1/sql_executor_simple_auth.py | 5 + backend/app/api/v1/statistics.py | 238 + backend/app/api/v1/system.py | 139 + backend/app/api/v1/system_logs.py | 184 + backend/app/api/v1/tasks.py | 228 + backend/app/api/v1/team_dashboard.py | 750 ++ backend/app/api/v1/team_management.py | 896 ++ backend/app/api/v1/teams.py | 55 + backend/app/api/v1/training.py | 507 + backend/app/api/v1/training_api_contract.yaml | 854 ++ backend/app/api/v1/upload.py | 275 + backend/app/api/v1/users.py | 474 + backend/app/api/v1/yanji.py | 120 + backend/app/config/__init__.py | 0 backend/app/config/database.py | 49 + backend/app/core/__init__.py | 3 + backend/app/core/config.py | 323 + backend/app/core/database.py | 31 + backend/app/core/deps.py | 166 + backend/app/core/events.py | 28 + backend/app/core/exceptions.py | 89 + backend/app/core/logger.py | 76 + backend/app/core/middleware.py | 64 + backend/app/core/redis.py | 44 + backend/app/core/security.py | 72 + backend/app/core/simple_auth.py | 81 + backend/app/core/tenant_config.py | 421 + backend/app/main.py | 140 + backend/app/models/__init__.py | 49 + backend/app/models/ability.py | 64 + backend/app/models/base.py | 47 + backend/app/models/course.py | 270 + backend/app/models/course_exam_settings.py | 34 + backend/app/models/exam.py | 153 + backend/app/models/exam_mistake.py | 43 + backend/app/models/notification.py | 106 + backend/app/models/position.py | 54 + backend/app/models/position_course.py | 28 + backend/app/models/position_member.py | 26 + backend/app/models/practice.py | 109 + backend/app/models/system_log.py | 60 + backend/app/models/task.py | 100 + backend/app/models/training.py | 263 + backend/app/models/user.py | 171 + backend/app/schemas/__init__.py | 1 + backend/app/schemas/ability.py | 50 + backend/app/schemas/auth.py | 35 + backend/app/schemas/base.py | 73 + backend/app/schemas/course.py | 364 + backend/app/schemas/exam.py | 316 + backend/app/schemas/notification.py | 102 + backend/app/schemas/practice.py | 318 + backend/app/schemas/scrm.py | 128 + backend/app/schemas/system_log.py | 59 + backend/app/schemas/task.py | 67 + backend/app/schemas/training.py | 260 + backend/app/schemas/user.py | 154 + backend/app/schemas/yanji.py | 61 + backend/app/services/__init__.py | 1 + .../services/ability_assessment_service.py | 272 + backend/app/services/ai/__init__.py | 151 + .../services/ai/ability_analysis_service.py | 479 + backend/app/services/ai/ai_service.py | 747 ++ .../app/services/ai/answer_judge_service.py | 197 + .../app/services/ai/course_chat_service.py | 757 ++ backend/app/services/ai/coze/__init__.py | 61 + backend/app/services/ai/coze/client.py | 203 + backend/app/services/ai/coze/client_backup.py | 44 + backend/app/services/ai/coze/exceptions.py | 101 + backend/app/services/ai/coze/models.py | 136 + backend/app/services/ai/coze/service.py | 335 + .../app/services/ai/exam_generator_service.py | 512 + .../app/services/ai/knowledge_analysis_v2.py | 548 + backend/app/services/ai/llm_json_parser.py | 707 ++ .../services/ai/practice_analysis_service.py | 377 + .../app/services/ai/practice_scene_service.py | 379 + backend/app/services/ai/prompts/__init__.py | 57 + .../ai/prompts/ability_analysis_prompts.py | 215 + .../ai/prompts/answer_judge_prompts.py | 48 + .../ai/prompts/course_chat_prompts.py | 74 + .../ai/prompts/exam_generator_prompts.py | 300 + .../ai/prompts/knowledge_analysis_prompts.py | 148 + .../ai/prompts/practice_analysis_prompts.py | 193 + .../ai/prompts/practice_scene_prompts.py | 192 + backend/app/services/auth_service.py | 141 + backend/app/services/base_service.py | 112 + backend/app/services/course_exam_service.py | 137 + .../app/services/course_position_service.py | 194 + backend/app/services/course_service.py | 837 ++ .../app/services/course_statistics_service.py | 65 + .../app/services/coze_broadcast_service.py | 97 + backend/app/services/coze_service.py | 199 + backend/app/services/document_converter.py | 305 + backend/app/services/employee_sync_service.py | 739 ++ backend/app/services/exam_report_service.py | 486 + backend/app/services/exam_service.py | 439 + backend/app/services/external/__init__.py | 0 backend/app/services/notification_service.py | 330 + backend/app/services/scrm_service.py | 356 + backend/app/services/statistics_service.py | 708 ++ backend/app/services/system_log_service.py | 170 + backend/app/services/task_service.py | 214 + backend/app/services/training_service.py | 372 + backend/app/services/user_service.py | 423 + backend/app/services/yanji_service.py | 510 + backend/backups/backup_status.json | 1 + .../kaopeilian_backup_20250923_032422.sql.gz | Bin 0 -> 9579 bytes .../kaopeilian_backup_20250923_034650.sql.gz | Bin 0 -> 9580 bytes .../kaopeilian_backup_20250923_040001.sql.gz | Bin 0 -> 9580 bytes .../kaopeilian_backup_20250923_040223.sql.gz | Bin 0 -> 9580 bytes .../kaopeilian_backup_20250923_050001.sql.gz | Bin 0 -> 9581 bytes .../kaopeilian_backup_20250923_050032.sql.gz | Bin 0 -> 9581 bytes .../kaopeilian_backup_20250923_060001.sql.gz | Bin 0 -> 9582 bytes .../kaopeilian_backup_20250923_060334.sql.gz | Bin 0 -> 9712 bytes .../kaopeilian_backup_20250923_070001.sql.gz | Bin 0 -> 9912 bytes .../kaopeilian_backup_20250923_070101.sql.gz | Bin 0 -> 9909 bytes .../kaopeilian_backup_20250923_080001.sql.gz | Bin 0 -> 9912 bytes .../kaopeilian_backup_20250923_080321.sql.gz | Bin 0 -> 9913 bytes .../kaopeilian_backup_20250923_090001.sql.gz | Bin 0 -> 9913 bytes .../kaopeilian_backup_20250923_090323.sql.gz | Bin 0 -> 9913 bytes .../kaopeilian_backup_20250923_100001.sql.gz | Bin 0 -> 9913 bytes .../kaopeilian_backup_20250923_100017.sql.gz | Bin 0 -> 9913 bytes .../kaopeilian_backup_20250923_110001.sql.gz | Bin 0 -> 9913 bytes .../kaopeilian_backup_20250923_110245.sql.gz | Bin 0 -> 9912 bytes .../kaopeilian_backup_20250923_120001.sql.gz | Bin 0 -> 9913 bytes .../kaopeilian_backup_20250923_120123.sql.gz | Bin 0 -> 9910 bytes .../kaopeilian_backup_20250923_130001.sql.gz | Bin 0 -> 9913 bytes .../kaopeilian_backup_20250923_130020.sql.gz | Bin 0 -> 9913 bytes .../kaopeilian_backup_20250923_140001.sql.gz | Bin 0 -> 9913 bytes .../kaopeilian_backup_20250923_140334.sql.gz | Bin 0 -> 9913 bytes .../kaopeilian_backup_20250923_150001.sql.gz | Bin 0 -> 9913 bytes .../kaopeilian_backup_20250923_150304.sql.gz | Bin 0 -> 9913 bytes .../kaopeilian_backup_20250923_160001.sql.gz | Bin 0 -> 10051 bytes .../kaopeilian_backup_20250923_160053.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250923_170001.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250923_170057.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250923_180001.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250923_180429.sql.gz | Bin 0 -> 10051 bytes .../kaopeilian_backup_20250923_190001.sql.gz | Bin 0 -> 10050 bytes .../kaopeilian_backup_20250923_190043.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250923_200001.sql.gz | Bin 0 -> 10050 bytes .../kaopeilian_backup_20250923_200124.sql.gz | Bin 0 -> 10049 bytes .../kaopeilian_backup_20250923_210001.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250923_210154.sql.gz | Bin 0 -> 10048 bytes .../kaopeilian_backup_20250923_220001.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250923_220101.sql.gz | Bin 0 -> 10050 bytes .../kaopeilian_backup_20250923_230001.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250923_230241.sql.gz | Bin 0 -> 10051 bytes .../kaopeilian_backup_20250924_000001.sql.gz | Bin 0 -> 10051 bytes .../kaopeilian_backup_20250924_000429.sql.gz | Bin 0 -> 10051 bytes .../kaopeilian_backup_20250924_010001.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250924_010153.sql.gz | Bin 0 -> 10049 bytes .../kaopeilian_backup_20250924_020001.sql.gz | Bin 0 -> 10051 bytes .../kaopeilian_backup_20250924_020216.sql.gz | Bin 0 -> 10050 bytes .../kaopeilian_backup_20250924_030001.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250924_030304.sql.gz | Bin 0 -> 10053 bytes .../kaopeilian_backup_20250924_040001.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250924_040030.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250924_050001.sql.gz | Bin 0 -> 10051 bytes .../kaopeilian_backup_20250924_050404.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250924_060001.sql.gz | Bin 0 -> 10050 bytes .../kaopeilian_backup_20250924_060054.sql.gz | Bin 0 -> 10050 bytes .../kaopeilian_backup_20250924_070001.sql.gz | Bin 0 -> 10051 bytes .../kaopeilian_backup_20250924_070451.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250924_080001.sql.gz | Bin 0 -> 10053 bytes .../kaopeilian_backup_20250924_080155.sql.gz | Bin 0 -> 10051 bytes .../kaopeilian_backup_20250924_090002.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250924_090129.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250924_100001.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250924_100028.sql.gz | Bin 0 -> 10054 bytes .../kaopeilian_backup_20250924_110001.sql.gz | Bin 0 -> 10054 bytes .../kaopeilian_backup_20250924_110249.sql.gz | Bin 0 -> 10053 bytes .../kaopeilian_backup_20250924_120001.sql.gz | Bin 0 -> 10054 bytes .../kaopeilian_backup_20250924_120238.sql.gz | Bin 0 -> 10051 bytes .../kaopeilian_backup_20250924_130001.sql.gz | Bin 0 -> 10054 bytes .../kaopeilian_backup_20250924_130451.sql.gz | Bin 0 -> 10054 bytes .../kaopeilian_backup_20250924_140001.sql.gz | Bin 0 -> 10053 bytes .../kaopeilian_backup_20250924_140404.sql.gz | Bin 0 -> 10053 bytes .../kaopeilian_backup_20250924_150001.sql.gz | Bin 0 -> 10054 bytes .../kaopeilian_backup_20250924_150347.sql.gz | Bin 0 -> 10054 bytes .../kaopeilian_backup_20250924_160001.sql.gz | Bin 0 -> 10054 bytes .../kaopeilian_backup_20250924_160424.sql.gz | Bin 0 -> 10053 bytes .../kaopeilian_backup_20250924_170001.sql.gz | Bin 0 -> 10054 bytes .../kaopeilian_backup_20250924_170155.sql.gz | Bin 0 -> 10051 bytes .../kaopeilian_backup_20250924_180001.sql.gz | Bin 0 -> 10054 bytes .../kaopeilian_backup_20250924_180200.sql.gz | Bin 0 -> 10053 bytes .../kaopeilian_backup_20250924_190001.sql.gz | Bin 0 -> 10054 bytes .../kaopeilian_backup_20250924_190155.sql.gz | Bin 0 -> 10049 bytes .../kaopeilian_backup_20250924_200001.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250924_200050.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250924_210001.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250924_210100.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250924_220001.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250924_220035.sql.gz | Bin 0 -> 10053 bytes .../kaopeilian_backup_20250924_230001.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250924_230129.sql.gz | Bin 0 -> 10052 bytes .../kaopeilian_backup_20250925_000001.sql.gz | Bin 0 -> 10053 bytes .../kaopeilian_backup_20250925_000321.sql.gz | Bin 0 -> 10053 bytes .../kaopeilian_backup_20250925_010001.sql.gz | Bin 0 -> 10054 bytes .../kaopeilian_backup_20250925_010038.sql.gz | Bin 0 -> 10053 bytes .../kaopeilian_backup_20250925_020001.sql.gz | Bin 0 -> 10061 bytes .../kaopeilian_backup_20250925_020104.sql.gz | Bin 0 -> 10058 bytes backend/create_admin_users.py | 90 + backend/create_simple_users.py | 134 + backend/create_system_accounts.py | 146 + backend/create_system_users.py | 141 + backend/create_team_data.py | 287 + backend/create_test_user.py | 62 + backend/create_test_user_exam.py | 50 + backend/create_user_simple.py | 114 + backend/database_schema_unified.md | 389 + backend/debug_api.py | 82 + backend/debug_update_api.py | 97 + backend/debug_user.py | 76 + backend/deploy/quick_deploy.sh | 223 + backend/deploy/server_setup_guide.md | 285 + backend/docker-compose.dev.yml | 59 + backend/docker-compose.yml | 46 + backend/docker/__init__.py | 0 .../docker/mysql/conf.d/mysql-rollback.cnf | 46 + backend/docker/nginx/__init__.py | 0 backend/docs/__init__.py | 0 backend/docs/api/__init__.py | 0 backend/docs/database_rollback_guide.md | 227 + backend/docs/deployment/__init__.py | 0 backend/docs/development/__init__.py | 0 backend/docs/openapi_sql_executor.json | 664 ++ backend/docs/openapi_sql_executor.yaml | 568 + backend/docs/sql_executor_checklist.md | 124 + backend/examples/coze_integration_example.py | 140 + backend/insert_test_logs.sql | 27 + backend/migrations/__init__.py | 0 backend/migrations/add_broadcast_fields.sql | 20 + .../add_broadcast_status_fields.sql | 24 + .../migrations/add_course_allow_download.sql | 25 + backend/migrations/admin_platform_schema.sql | 298 + backend/migrations/alembic/__init__.py | 0 .../migrations/alembic/versions/__init__.py | 0 .../migrations/cleanup_broadcast_fields.sql | 17 + .../migrations/create_ability_assessments.sql | 0 backend/migrations/env.py | 104 + backend/migrations/manual_course_tables.sql | 131 + ...al_modify_knowledge_points_material_id.sql | 26 + backend/migrations/manual_training_tables.sql | 92 + backend/migrations/script.py.mako | 26 + .../update_production_broadcast_fields.sql | 13 + ...roduction_broadcast_fields_step1_check.sql | 25 + ...oduction_broadcast_fields_step2_update.sql | 86 + ...0487635b5e95_add_position_courses_table.py | 46 + .../20250921_align_schema_to_design.py | 157 + .../versions/20250922_add_positions_table.py | 55 + .../3d5b88fe1875_merge_multiple_heads.py | 26 + ...5448c81e7afd_add_position_members_table.py | 46 + .../9245f8845fe1_add_training_models.py | 708 ++ .../versions/add_position_skills_level.py | 35 + .../versions/add_users_soft_delete.py | 36 + backend/mysql.cnf | 46 + backend/pyproject.toml | 63 + backend/pytest.ini | 9 + backend/requirements-admin.txt | 20 + backend/requirements-dev.txt | 18 + backend/requirements.txt | 53 + backend/requirements/__init__.py | 0 backend/requirements/base.txt | 34 + backend/requirements/dev.txt | 21 + backend/requirements/prod.txt | 10 + backend/scripts/__init__.py | 0 backend/scripts/add_admin_exam_data.sql | 215 + backend/scripts/add_admin_learning_data.sql | 164 + .../add_exam_and_mistakes_demo_data.sql | 290 + backend/scripts/add_exam_data.sql | 78 + backend/scripts/add_exam_tables.sql | 88 + backend/scripts/add_school_major_fields.py | 109 + backend/scripts/add_training_data.sql | 63 + .../alter_exam_mistakes_add_question_type.sql | 23 + backend/scripts/alter_exams_add_rounds.sql | 36 + .../scripts/alter_users_email_nullable.sql | 10 + backend/scripts/apply_sql_file.py | 80 + backend/scripts/backup_database.sh | 144 + backend/scripts/binlog_rollback_tool.py | 353 + backend/scripts/check_backup_status.sh | 123 + backend/scripts/check_database_status.py | 178 + backend/scripts/cleanup_users.py | 78 + .../scripts/create_course_exam_settings.sql | 35 + .../create_practice_analysis_tables.sql | 79 + backend/scripts/create_practice_scenes.sql | 107 + backend/scripts/create_practice_table.py | 198 + backend/scripts/create_test_data.py | 170 + backend/scripts/fix_chinese_data.py | 267 + backend/scripts/init_database_unified.sql | 606 ++ backend/scripts/init_db.py | 64 + backend/scripts/init_db.sql | 113 + backend/scripts/init_project.sh | 49 + backend/scripts/kaopeilian_rollback.py | 394 + backend/scripts/migrate_env_to_db.py | 317 + backend/scripts/migrate_prompts_to_db.py | 384 + backend/scripts/mock_data_beauty.sql | 329 + backend/scripts/rollback_example.py | 83 + backend/scripts/run_practice_scenes_setup.py | 93 + backend/scripts/seed_beauty_data.py | 430 + backend/scripts/seed_positions.py | 76 + backend/scripts/seed_practice_sessions.sql | 164 + backend/scripts/seed_statistics_demo_data.py | 408 + backend/scripts/seed_statistics_demo_data.sql | 220 + .../scripts/seed_statistics_demo_data_v2.sql | 207 + backend/scripts/seed_statistics_for_user6.sql | 172 + backend/scripts/simple_init.py | 83 + backend/scripts/simple_rollback.py | 247 + backend/scripts/sync_core_tables.py | 138 + backend/scripts/sync_users_table.py | 94 + .../scripts/update_position_descriptions.py | 133 + backend/setup.cfg | 40 + backend/simple_main.py | 225 + backend/simple_test.py | 42 + backend/start.sh | 64 + backend/start_backend.py | 30 + backend/start_dev.py | 58 + backend/start_dev.sh | 71 + backend/start_mysql.py | 91 + backend/start_remote.py | 100 + backend/start_simple.py | 28 + backend/test_api.py | 172 + backend/test_api_endpoint.py | 64 + backend/test_check_schema_fields.py | 46 + backend/test_course_7_exam_settings.py | 76 + backend/test_exam_records_api.py | 52 + backend/test_practice_api.py | 121 + backend/test_remote_db.py | 71 + backend/test_schema_validation.py | 66 + backend/test_statistics_api.py | 149 + backend/test_team_api.py | 114 + backend/test_team_dashboard.py | 186 + backend/test_team_management_api.py | 272 + backend/test_user_id4.py | 124 + backend/test_user_position_sync.py | 197 + backend/test_user_statistics.py | 137 + backend/tests/__init__.py | 1 + backend/tests/conftest.py | 100 + backend/tests/e2e/__init__.py | 0 backend/tests/integration/__init__.py | 0 backend/tests/test_courses.py | 284 + backend/tests/test_coze_api.py | 306 + backend/tests/test_coze_client.py | 168 + backend/tests/test_coze_service.py | 274 + backend/tests/test_main.py | 35 + backend/tests/test_training.py | 399 + backend/tests/test_user_service.py | 256 + backend/tests/unit/__init__.py | 0 backend/tests/unit/test_auth.py | 208 + backend/verify_exam_settings.py | 122 + backend/团队看板功能验证指南.md | 224 + ...练kaopeilian_final_complete_backup_20250923_025629.sql | 841 ++ backend/数据库架构-统一版.md | 777 ++ backend/数据库配置切换说明.md | 103 + backend/验证备份质量.py | 215 + deploy/docker/docker-compose.admin.yml | 115 + deploy/docker/docker-compose.dev.yml | 186 + deploy/docker/docker-compose.kpl.yml | 207 + deploy/docker/docker-compose.prod-multi.yml | 482 + deploy/docker/docker-compose.yml | 178 + deploy/nginx/conf.d/admin.conf | 91 + deploy/nginx/conf.d/ex.conf | 102 + deploy/nginx/conf.d/fw.conf | 101 + deploy/nginx/conf.d/hl.conf | 101 + deploy/nginx/conf.d/hua.conf | 101 + deploy/nginx/conf.d/kaopeilian.conf | 150 + deploy/nginx/conf.d/kpl.conf | 118 + deploy/nginx/conf.d/pl.conf | 73 + deploy/nginx/conf.d/pl.conf.disabled | 99 + deploy/nginx/conf.d/xy.conf | 96 + deploy/nginx/conf.d/yy.conf | 101 + deploy/nginx/nginx.conf | 53 + deploy/scripts/auto_update.sh | 152 + deploy/scripts/check-config.sh | 223 + deploy/scripts/check_environment.sh | 91 + deploy/scripts/cleanup_docker.sh | 62 + deploy/scripts/deploy.sh | 113 + deploy/scripts/diagnose.sh | 34 + deploy/scripts/diagnose_dify_network.sh | 59 + deploy/scripts/force_restart.sh | 50 + deploy/scripts/quick_test_practice.sh | 60 + deploy/scripts/quick_test_score_mistakes.sh | 84 + deploy/scripts/robust_start.sh | 68 + deploy/scripts/setup_environment.sh | 246 + deploy/scripts/setup_git_strategy.sh | 59 + deploy/scripts/start-dev.sh | 208 + deploy/scripts/start-kpl.sh | 186 + deploy/scripts/start.sh | 86 + deploy/scripts/stop-dev.sh | 50 + deploy/scripts/stop-kpl.sh | 74 + deploy/scripts/test_course_chat.sh | 58 + deploy/scripts/test_statistics_apis.sh | 39 + deploy/scripts/validate_config.py | 205 + deploy/scripts/webhook_handler.py | 164 + deploy/scripts/启动资料预览功能.sh | 57 + deploy/scripts/测试资料预览功能.sh | 92 + docs/README.md | 68 + docs/SETUP.md | 116 + docs/同步清单.md | 61 + docs/规划/README.md | 120 + docs/规划/RIPER-5-CN.md | 505 + .../恩喜-00-SQL 执行器-考陪练专用.yml | 187 + .../dify 工作流/恩喜-01-知识点分析-考陪练.yml | 771 ++ .../dify 工作流/恩喜-02-试题生成器-考陪练.yml | 658 ++ .../dify 工作流/恩喜-03-陪练知识准备-考陪练.yml | 290 + .../dify 工作流/恩喜-04-与课程对话-考陪练.yml | 273 + .../恩喜-05-智能工牌能力分析与课程推荐-考陪练.yml | 380 + .../dify 工作流/通用-答案判断器-考陪练.yml | 214 + .../dify 工作流/通用-陪练分析报告-考陪练.yml | 202 + .../全链路联调/Ai工作流/coze/Coze-API文档.md | 434 + .../全链路联调/Ai工作流/coze/Coze集成方案.md | 132 + docs/规划/全链路联调/Ai工作流/coze/README.md | 222 + .../全链路联调/Ai工作流/coze/⚠️核心差异点速查.md | 334 + .../Ai工作流/coze/✅陪练功能完整开发报告.md | 303 + .../规划/全链路联调/Ai工作流/coze/基础信息.md | 296 + .../Ai工作流/coze/播课/Coze工作流运行API文档.md | 183 + .../全链路联调/Ai工作流/coze/播课/README.md | 69 + .../Ai工作流/coze/播课/播课功能API接口规范.md | 137 + .../Ai工作流/coze/播课/播课功能技术方案.md | 296 + .../Ai工作流/coze/陪练功能API接口规范.md | 923 ++ .../Ai工作流/coze/陪练功能技术方案.md | 720 ++ .../Ai工作流/coze/陪练功能数据流程图.md | 760 ++ .../Ai工作流/dify/Dify_API_Keys_配置管理经验.md | 256 + .../Ai工作流/dify/Dify系统对接分析报告.md | 623 ++ docs/规划/全链路联调/Ai工作流/dify/README.md | 69 + .../Ai工作流/dify/对话流/Dify对话流API文档.md | 500 + .../Ai工作流/dify/对话流/与课程对话功能实施总结.md | 283 + .../Ai工作流/dify/数据库api 服务/README.md | 76 + .../dify/数据库api 服务/openapi_sql_executor.json | 664 ++ .../全链路联调/Ai工作流/dify/知识拆解工作流.md | 52 + .../Ai工作流/dify/考试工作流-最终版.md | 224 + .../Ai工作流/dify/考试工作流联调文档.md | 164 + .../dify/试题生成器的核心提示词与输出示例.md | 124 + .../全链路联调/Ai工作流/知识点拆解工作流.md | 78 + .../全链路联调/Dify-SQL执行器功能开发总结.md | 341 + .../old/Dify-SQL执行器功能开发总结-备份.md | 341 + .../全链路联调/old/一次性完成度核对清单.md | 135 + .../全链路联调/old/实操联调完整Todos清单.md | 258 + docs/规划/全链路联调/old/本地数据库连接.md | 23 + .../全链路联调/old/联调经验汇总-完整版备份.md | 1128 ++ docs/规划/全链路联调/old/联调结果汇总报告.md | 350 + docs/规划/全链路联调/异常处理规范.md | 193 + .../全链路联调/联调经验汇总-完整版备份.md | 1128 ++ docs/规划/全链路联调/联调经验汇总.md | 431 + docs/规划/全链路联调/规范与约定-团队基线.md | 318 + .../言迹智能工牌/API探索成果总结 2.md | 277 + .../全链路联调/言迹智能工牌/API探索成果总结.md | 277 + .../全链路联调/言迹智能工牌/API探索报告.md | 356 + .../全链路联调/言迹智能工牌/API接口测试清单.md | 296 + docs/规划/全链路联调/言迹智能工牌/README 2.md | 96 + docs/规划/全链路联调/言迹智能工牌/README.md | 96 + .../言迹智能工牌/完整API测试报告 2.md | 427 + .../全链路联调/言迹智能工牌/完整API测试报告.md | 429 + .../全链路联调/言迹智能工牌/实施总结 2.md | 248 + docs/规划/全链路联调/言迹智能工牌/实施总结.md | 248 + .../全链路联调/言迹智能工牌/授权认证 2.md | 68 + docs/规划/全链路联调/言迹智能工牌/授权认证.md | 68 + .../智能工牌能力分析-Dify工作流测试报告.md | 334 + .../智能工牌能力分析-配置完成与使用指南.md | 340 + .../言迹智能工牌/智能工牌能力分析实施完成报告 2.md | 336 + .../言迹智能工牌/智能工牌能力分析实施完成报告.md | 336 + .../言迹智能工牌/测试报告-2025-10-15.md | 280 + .../全链路联调/言迹智能工牌/真实数据获取报告.md | 268 + .../言迹智能工牌/获取员工录音信息 2.md | 241 + .../全链路联调/言迹智能工牌/获取员工录音信息.md | 243 + .../言迹智能工牌/获取客户来访列表 2.md | 118 + .../全链路联调/言迹智能工牌/获取客户来访列表.md | 118 + .../言迹智能工牌/获取录音ASR分析结果 2.md | 108 + .../言迹智能工牌/获取录音ASR分析结果.md | 108 + .../言迹智能工牌/获取来访录音信息 2.md | 88 + .../全链路联调/言迹智能工牌/获取来访录音信息.md | 88 + .../按时长分类/10-20秒/夏雨沫_12秒_2025-08-17_1.mp3 | Bin 0 -> 46449 bytes .../按时长分类/10-20秒/张永梅_14秒_2025-09-16_2.mp3 | Bin 0 -> 54513 bytes .../按时长分类/10-20秒/张永梅_14秒_2025-10-10_1.mp3 | Bin 0 -> 56529 bytes .../音频样本/按时长分类/10-20秒/杨敏_13秒_2025-09-15_2.mp3 | Bin 0 -> 49329 bytes .../音频样本/按时长分类/10-20秒/杨敏_13秒_2025-09-15_3.mp3 | Bin 0 -> 52209 bytes .../音频样本/按时长分类/10-20秒/杨敏_13秒_2025-09-23_1.mp3 | Bin 0 -> 50049 bytes .../按时长分类/10-20秒/熊媱媱_15秒_2025-06-17_1.mp3 | Bin 0 -> 55233 bytes .../音频样本/按时长分类/10-20秒/陈谊_11秒_2025-08-29_2.mp3 | Bin 0 -> 44433 bytes .../音频样本/按时长分类/10-20秒/陈谊_11秒_2025-09-30_1.mp3 | Bin 0 -> 40689 bytes .../言迹智能工牌/页面布局优化完成报告.md | 280 + .../全链路联调/课程资料预览功能-实施完成报告.md | 377 + docs/规划/关于部署/分支管理策略.md | 588 + docs/规划/关于部署/本地开发.md | 315 + docs/规划/初始沟通文件/NJ的初步设计理念.md | 64 + .../初始沟通文件/考培练其他补充细节需求.txt | 7 + .../初始沟通文件/考陪练系统定制需求功能清单.xml | 3 + docs/规划/初始沟通文件/需求确认会议.md | 235 + docs/规划/后端开发拆分策略/README.md | 166 + docs/规划/后端开发拆分策略/协作机制设计.md | 1066 ++ .../子agent/00-通用基础/base_prompt.md | 425 + .../子agent/00-通用基础/essential_docs.md | 98 + .../子agent/00-通用基础/integration_experience.md | 731 ++ .../子agent/00-通用基础/project_structure.md | 71 + .../子agent/01-Agent-Auth/api_contract.yaml | 185 + .../子agent/01-Agent-Auth/checklist.md | 119 + .../子agent/01-Agent-Auth/context.md | 137 + .../子agent/01-Agent-Auth/dependencies.md | 93 + .../子agent/01-Agent-Auth/examples/auth_api_example.py | 201 + .../01-Agent-Auth/examples/auth_service_example.py | 163 + .../子agent/01-Agent-Auth/prompt.md | 188 + .../子agent/02-Agent-User/api_contract.yaml | 122 + .../子agent/02-Agent-User/checklist.md | 21 + .../子agent/02-Agent-User/context.md | 29 + .../子agent/02-Agent-User/examples/README.md | 17 + .../子agent/02-Agent-User/prompt.md | 22 + .../子agent/03-Agent-Course/api_contract.yaml | 138 + .../子agent/03-Agent-Course/checklist.md | 22 + .../子agent/03-Agent-Course/context.md | 18 + .../子agent/03-Agent-Course/examples/README.md | 17 + .../子agent/03-Agent-Course/prompt.md | 46 + .../子agent/04-Agent-Exam/api_contract.yaml | 104 + .../子agent/04-Agent-Exam/checklist.md | 20 + .../子agent/04-Agent-Exam/context.md | 18 + .../子agent/04-Agent-Exam/examples/README.md | 17 + .../子agent/04-Agent-Exam/prompt.md | 22 + .../子agent/05-Agent-Training/api_contract.yaml | 60 + .../子agent/05-Agent-Training/checklist.md | 20 + .../子agent/05-Agent-Training/context.md | 18 + .../子agent/05-Agent-Training/examples/README.md | 17 + .../子agent/05-Agent-Training/prompt.md | 50 + .../子agent/06-Agent-Analytics/api_contract.yaml | 55 + .../子agent/06-Agent-Analytics/checklist.md | 19 + .../子agent/06-Agent-Analytics/context.md | 18 + .../子agent/06-Agent-Analytics/examples/README.md | 17 + .../子agent/06-Agent-Analytics/prompt.md | 22 + .../子agent/07-Agent-Admin/api_contract.yaml | 50 + .../子agent/07-Agent-Admin/checklist.md | 21 + .../子agent/07-Agent-Admin/context.md | 19 + .../子agent/07-Agent-Admin/examples/README.md | 15 + .../子agent/07-Agent-Admin/prompt.md | 22 + .../子agent/08-Agent-Coze/api_contract.yaml | 67 + .../子agent/08-Agent-Coze/checklist.md | 19 + .../子agent/08-Agent-Coze/context.md | 19 + .../子agent/08-Agent-Coze/examples/README.md | 17 + .../子agent/08-Agent-Coze/prompt.md | 54 + docs/规划/后端开发拆分策略/子agent/README.md | 43 + .../后端开发拆分策略/子agent/package_agent.sh | 154 + .../后端开发拆分策略/子agent/prompt_template.md | 21 + .../子agent/test_agent_understanding.md | 70 + .../子agent/update_all_agents.sh | 83 + .../子agent/云端协作最佳实践.md | 179 + .../子agent/使用时的最佳实践.md | 218 + .../后端开发拆分策略/子agent/创建完成说明.md | 130 + .../后端开发拆分策略/子agent/快速使用指南.md | 207 + .../后端开发拆分策略/子agent集成todos清单.md | 104 + docs/规划/后端开发拆分策略/开发规范文档.md | 380 + docs/规划/后端开发拆分策略/快速开始指南.md | 521 + docs/规划/后端开发拆分策略/模块分工指南.md | 571 + docs/规划/后端开发拆分策略/统一基础代码.md | 1282 +++ docs/规划/后端开发拆分策略/质量保证机制.md | 925 ++ .../后端开发拆分策略/配置一致性检查清单.md | 123 + .../规划/后端开发拆分策略/配置管理使用说明.md | 176 + .../后端开发拆分策略/项目脚手架创建完成.md | 91 + docs/规划/后端开发拆分策略/项目脚手架结构.md | 229 + docs/规划/团队基线.md | 77 + .../完成审核的文件备份/Ai_EDU_Frontend_Plan.md | 180 + docs/规划/完成审核的文件备份/README.md | 75 + docs/规划/完成审核的文件备份/导航栏规划.md | 30 + .../完成审核的文件备份/考试工作流联调文档 2.md | 726 ++ .../完成审核的文件备份/考试工作流联调文档.md | 726 ++ .../规划/完成审核的文件备份/页面与按钮速查.md | 257 + docs/规划/客户的Ai应用匹配/应用配置清单.md | 106 + ...-考陪练kaopeilian_final_complete_backup_20250923_025629 2.sql | 841 ++ ...半-考陪练kaopeilian_final_complete_backup_20250923_025629.sql | 841 ++ .../10-细节分支开发前-开发库-20251111_191631.sql | 1189 +++ .../10-细节分支开发前-生产库-20251111_191631.sql | 1189 +++ .../2-联调 dify 知识拆解已完成 2.sql | 782 ++ .../2-联调 dify 知识拆解已完成.sql | 782 ++ .../3、成绩报告与错题本_20251013_171138 2.sql | 838 ++ .../3、成绩报告与错题本_20251013_171138.sql | 838 ++ .../4-完成陪练模块_20251015_010038 2.sql | 1011 ++ .../4-完成陪练模块_20251015_010038.sql | 1011 ++ .../5-准备开始智能工牌模块-20251016-041011 2.sql | 1005 ++ .../5-准备开始智能工牌模块-20251016-041011.sql | 1005 ++ .../6-完成智能工牌模块-20251016_051345 2.sql | 1025 ++ .../6-完成智能工牌模块-20251016_051345.sql | 1025 ++ .../7-完成数据分析模块-20251016_075159.sql | 1048 ++ .../8-完成1.0 收尾工作-20251017_015836.sql | 1183 +++ .../8-时区修复前备份-20251017_053824.sql | 1181 ++ .../9-完成服务器测试-20251017_084942.sql | 1197 +++ docs/规划/数据盘规划方案.md | 178 + docs/规划/服务器端 MYSQL.md | 83 + docs/规划/流式输出视觉呈现规范.md | 855 ++ docs/规划/瑞小美AI接入规范.md | 604 ++ docs/规划/瑞小美系统技术栈标准与字符标准.md | 190 + docs/规划/系统架构.md | 299 + docs/规划/考陪练系统API对接规范.md | 549 + docs/规划/部署架构-统一版.md | 702 ++ docs/项目状态快照.md | 58 + frontend/.editorconfig | 30 + frontend/.env.development | 40 + frontend/.eslintrc.cjs | 145 + frontend/.prettierignore | 55 + frontend/.prettierrc | 17 + frontend/Dockerfile | 97 + frontend/Dockerfile.dev | 29 + frontend/Dockerfile.shared | 14 + frontend/Dockerfile.simple | 52 + frontend/README.md | 387 + frontend/docker/default.conf | 91 + frontend/docker/nginx.conf | 61 + frontend/docker/scripts/build.sh | 75 + frontend/docker/scripts/deploy.sh | 132 + frontend/env.example | 43 + frontend/index.html | 148 + frontend/nginx-shared.conf | 32 + frontend/nginx.conf | 20 + frontend/package-lock.json | 9462 +++++++++++++++++ frontend/package.json | 74 + frontend/public/bot-avatar.svg | 16 + frontend/public/favicon.ico | 10 + frontend/public/favicon.svg | 10 + frontend/public/manifest.json | 25 + frontend/public/pdfjs/cmaps/78-EUC-H.bcmap | Bin 0 -> 2404 bytes frontend/public/pdfjs/cmaps/78-EUC-V.bcmap | Bin 0 -> 173 bytes frontend/public/pdfjs/cmaps/78-H.bcmap | Bin 0 -> 2379 bytes frontend/public/pdfjs/cmaps/78-RKSJ-H.bcmap | Bin 0 -> 2398 bytes frontend/public/pdfjs/cmaps/78-RKSJ-V.bcmap | Bin 0 -> 173 bytes frontend/public/pdfjs/cmaps/78-V.bcmap | Bin 0 -> 169 bytes frontend/public/pdfjs/cmaps/78ms-RKSJ-H.bcmap | Bin 0 -> 2651 bytes frontend/public/pdfjs/cmaps/78ms-RKSJ-V.bcmap | Bin 0 -> 290 bytes frontend/public/pdfjs/cmaps/83pv-RKSJ-H.bcmap | Bin 0 -> 905 bytes frontend/public/pdfjs/cmaps/90ms-RKSJ-H.bcmap | Bin 0 -> 721 bytes frontend/public/pdfjs/cmaps/90ms-RKSJ-V.bcmap | Bin 0 -> 290 bytes .../public/pdfjs/cmaps/90msp-RKSJ-H.bcmap | Bin 0 -> 715 bytes .../public/pdfjs/cmaps/90msp-RKSJ-V.bcmap | Bin 0 -> 291 bytes frontend/public/pdfjs/cmaps/90pv-RKSJ-H.bcmap | Bin 0 -> 982 bytes frontend/public/pdfjs/cmaps/90pv-RKSJ-V.bcmap | Bin 0 -> 260 bytes frontend/public/pdfjs/cmaps/Add-H.bcmap | Bin 0 -> 2419 bytes frontend/public/pdfjs/cmaps/Add-RKSJ-H.bcmap | Bin 0 -> 2413 bytes frontend/public/pdfjs/cmaps/Add-RKSJ-V.bcmap | Bin 0 -> 287 bytes frontend/public/pdfjs/cmaps/Add-V.bcmap | Bin 0 -> 282 bytes .../public/pdfjs/cmaps/Adobe-CNS1-0.bcmap | Bin 0 -> 317 bytes .../public/pdfjs/cmaps/Adobe-CNS1-1.bcmap | Bin 0 -> 371 bytes .../public/pdfjs/cmaps/Adobe-CNS1-2.bcmap | Bin 0 -> 376 bytes .../public/pdfjs/cmaps/Adobe-CNS1-3.bcmap | Bin 0 -> 401 bytes .../public/pdfjs/cmaps/Adobe-CNS1-4.bcmap | Bin 0 -> 405 bytes .../public/pdfjs/cmaps/Adobe-CNS1-5.bcmap | Bin 0 -> 406 bytes .../public/pdfjs/cmaps/Adobe-CNS1-6.bcmap | Bin 0 -> 406 bytes .../public/pdfjs/cmaps/Adobe-CNS1-UCS2.bcmap | Bin 0 -> 41193 bytes frontend/public/pdfjs/cmaps/Adobe-GB1-0.bcmap | Bin 0 -> 217 bytes frontend/public/pdfjs/cmaps/Adobe-GB1-1.bcmap | Bin 0 -> 250 bytes frontend/public/pdfjs/cmaps/Adobe-GB1-2.bcmap | Bin 0 -> 465 bytes frontend/public/pdfjs/cmaps/Adobe-GB1-3.bcmap | Bin 0 -> 470 bytes frontend/public/pdfjs/cmaps/Adobe-GB1-4.bcmap | Bin 0 -> 601 bytes frontend/public/pdfjs/cmaps/Adobe-GB1-5.bcmap | Bin 0 -> 625 bytes .../public/pdfjs/cmaps/Adobe-GB1-UCS2.bcmap | Bin 0 -> 33974 bytes .../public/pdfjs/cmaps/Adobe-Japan1-0.bcmap | Bin 0 -> 225 bytes .../public/pdfjs/cmaps/Adobe-Japan1-1.bcmap | Bin 0 -> 226 bytes .../public/pdfjs/cmaps/Adobe-Japan1-2.bcmap | Bin 0 -> 233 bytes .../public/pdfjs/cmaps/Adobe-Japan1-3.bcmap | Bin 0 -> 242 bytes .../public/pdfjs/cmaps/Adobe-Japan1-4.bcmap | Bin 0 -> 337 bytes .../public/pdfjs/cmaps/Adobe-Japan1-5.bcmap | Bin 0 -> 430 bytes .../public/pdfjs/cmaps/Adobe-Japan1-6.bcmap | Bin 0 -> 485 bytes .../pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap | Bin 0 -> 40951 bytes .../public/pdfjs/cmaps/Adobe-Korea1-0.bcmap | Bin 0 -> 241 bytes .../public/pdfjs/cmaps/Adobe-Korea1-1.bcmap | Bin 0 -> 386 bytes .../public/pdfjs/cmaps/Adobe-Korea1-2.bcmap | Bin 0 -> 391 bytes .../pdfjs/cmaps/Adobe-Korea1-UCS2.bcmap | Bin 0 -> 23293 bytes frontend/public/pdfjs/cmaps/B5-H.bcmap | Bin 0 -> 1086 bytes frontend/public/pdfjs/cmaps/B5-V.bcmap | Bin 0 -> 142 bytes frontend/public/pdfjs/cmaps/B5pc-H.bcmap | Bin 0 -> 1099 bytes frontend/public/pdfjs/cmaps/B5pc-V.bcmap | Bin 0 -> 144 bytes frontend/public/pdfjs/cmaps/CNS-EUC-H.bcmap | Bin 0 -> 1780 bytes frontend/public/pdfjs/cmaps/CNS-EUC-V.bcmap | Bin 0 -> 1920 bytes frontend/public/pdfjs/cmaps/CNS1-H.bcmap | Bin 0 -> 706 bytes frontend/public/pdfjs/cmaps/CNS1-V.bcmap | Bin 0 -> 143 bytes frontend/public/pdfjs/cmaps/CNS2-H.bcmap | Bin 0 -> 504 bytes frontend/public/pdfjs/cmaps/CNS2-V.bcmap | 3 + frontend/public/pdfjs/cmaps/ETHK-B5-H.bcmap | Bin 0 -> 4426 bytes frontend/public/pdfjs/cmaps/ETHK-B5-V.bcmap | Bin 0 -> 158 bytes frontend/public/pdfjs/cmaps/ETen-B5-H.bcmap | Bin 0 -> 1125 bytes frontend/public/pdfjs/cmaps/ETen-B5-V.bcmap | Bin 0 -> 158 bytes frontend/public/pdfjs/cmaps/ETenms-B5-H.bcmap | 3 + frontend/public/pdfjs/cmaps/ETenms-B5-V.bcmap | Bin 0 -> 172 bytes frontend/public/pdfjs/cmaps/EUC-H.bcmap | Bin 0 -> 578 bytes frontend/public/pdfjs/cmaps/EUC-V.bcmap | Bin 0 -> 170 bytes frontend/public/pdfjs/cmaps/Ext-H.bcmap | Bin 0 -> 2536 bytes frontend/public/pdfjs/cmaps/Ext-RKSJ-H.bcmap | Bin 0 -> 2542 bytes frontend/public/pdfjs/cmaps/Ext-RKSJ-V.bcmap | Bin 0 -> 218 bytes frontend/public/pdfjs/cmaps/Ext-V.bcmap | Bin 0 -> 215 bytes frontend/public/pdfjs/cmaps/GB-EUC-H.bcmap | Bin 0 -> 549 bytes frontend/public/pdfjs/cmaps/GB-EUC-V.bcmap | Bin 0 -> 179 bytes frontend/public/pdfjs/cmaps/GB-H.bcmap | 4 + frontend/public/pdfjs/cmaps/GB-V.bcmap | Bin 0 -> 175 bytes frontend/public/pdfjs/cmaps/GBK-EUC-H.bcmap | Bin 0 -> 14692 bytes frontend/public/pdfjs/cmaps/GBK-EUC-V.bcmap | Bin 0 -> 180 bytes frontend/public/pdfjs/cmaps/GBK2K-H.bcmap | Bin 0 -> 19662 bytes frontend/public/pdfjs/cmaps/GBK2K-V.bcmap | Bin 0 -> 219 bytes frontend/public/pdfjs/cmaps/GBKp-EUC-H.bcmap | Bin 0 -> 14686 bytes frontend/public/pdfjs/cmaps/GBKp-EUC-V.bcmap | Bin 0 -> 181 bytes frontend/public/pdfjs/cmaps/GBT-EUC-H.bcmap | Bin 0 -> 7290 bytes frontend/public/pdfjs/cmaps/GBT-EUC-V.bcmap | Bin 0 -> 180 bytes frontend/public/pdfjs/cmaps/GBT-H.bcmap | Bin 0 -> 7269 bytes frontend/public/pdfjs/cmaps/GBT-V.bcmap | Bin 0 -> 176 bytes frontend/public/pdfjs/cmaps/GBTpc-EUC-H.bcmap | Bin 0 -> 7298 bytes frontend/public/pdfjs/cmaps/GBTpc-EUC-V.bcmap | Bin 0 -> 182 bytes frontend/public/pdfjs/cmaps/GBpc-EUC-H.bcmap | Bin 0 -> 557 bytes frontend/public/pdfjs/cmaps/GBpc-EUC-V.bcmap | Bin 0 -> 181 bytes frontend/public/pdfjs/cmaps/H.bcmap | Bin 0 -> 553 bytes frontend/public/pdfjs/cmaps/HKdla-B5-H.bcmap | Bin 0 -> 2654 bytes frontend/public/pdfjs/cmaps/HKdla-B5-V.bcmap | Bin 0 -> 148 bytes frontend/public/pdfjs/cmaps/HKdlb-B5-H.bcmap | Bin 0 -> 2414 bytes frontend/public/pdfjs/cmaps/HKdlb-B5-V.bcmap | Bin 0 -> 148 bytes frontend/public/pdfjs/cmaps/HKgccs-B5-H.bcmap | Bin 0 -> 2292 bytes frontend/public/pdfjs/cmaps/HKgccs-B5-V.bcmap | Bin 0 -> 149 bytes frontend/public/pdfjs/cmaps/HKm314-B5-H.bcmap | Bin 0 -> 1772 bytes frontend/public/pdfjs/cmaps/HKm314-B5-V.bcmap | Bin 0 -> 149 bytes frontend/public/pdfjs/cmaps/HKm471-B5-H.bcmap | Bin 0 -> 2171 bytes frontend/public/pdfjs/cmaps/HKm471-B5-V.bcmap | Bin 0 -> 149 bytes frontend/public/pdfjs/cmaps/HKscs-B5-H.bcmap | Bin 0 -> 4437 bytes frontend/public/pdfjs/cmaps/HKscs-B5-V.bcmap | Bin 0 -> 159 bytes frontend/public/pdfjs/cmaps/Hankaku.bcmap | Bin 0 -> 132 bytes frontend/public/pdfjs/cmaps/Hiragana.bcmap | Bin 0 -> 124 bytes frontend/public/pdfjs/cmaps/KSC-EUC-H.bcmap | Bin 0 -> 1848 bytes frontend/public/pdfjs/cmaps/KSC-EUC-V.bcmap | Bin 0 -> 164 bytes frontend/public/pdfjs/cmaps/KSC-H.bcmap | Bin 0 -> 1831 bytes frontend/public/pdfjs/cmaps/KSC-Johab-H.bcmap | Bin 0 -> 16791 bytes frontend/public/pdfjs/cmaps/KSC-Johab-V.bcmap | Bin 0 -> 166 bytes frontend/public/pdfjs/cmaps/KSC-V.bcmap | Bin 0 -> 160 bytes frontend/public/pdfjs/cmaps/KSCms-UHC-H.bcmap | Bin 0 -> 2787 bytes .../public/pdfjs/cmaps/KSCms-UHC-HW-H.bcmap | Bin 0 -> 2789 bytes .../public/pdfjs/cmaps/KSCms-UHC-HW-V.bcmap | Bin 0 -> 169 bytes frontend/public/pdfjs/cmaps/KSCms-UHC-V.bcmap | Bin 0 -> 166 bytes frontend/public/pdfjs/cmaps/KSCpc-EUC-H.bcmap | Bin 0 -> 2024 bytes frontend/public/pdfjs/cmaps/KSCpc-EUC-V.bcmap | Bin 0 -> 166 bytes frontend/public/pdfjs/cmaps/Katakana.bcmap | Bin 0 -> 100 bytes frontend/public/pdfjs/cmaps/LICENSE | 36 + frontend/public/pdfjs/cmaps/NWP-H.bcmap | Bin 0 -> 2765 bytes frontend/public/pdfjs/cmaps/NWP-V.bcmap | Bin 0 -> 252 bytes frontend/public/pdfjs/cmaps/RKSJ-H.bcmap | Bin 0 -> 534 bytes frontend/public/pdfjs/cmaps/RKSJ-V.bcmap | Bin 0 -> 170 bytes frontend/public/pdfjs/cmaps/Roman.bcmap | Bin 0 -> 96 bytes .../public/pdfjs/cmaps/UniCNS-UCS2-H.bcmap | Bin 0 -> 48280 bytes .../public/pdfjs/cmaps/UniCNS-UCS2-V.bcmap | Bin 0 -> 156 bytes .../public/pdfjs/cmaps/UniCNS-UTF16-H.bcmap | Bin 0 -> 50419 bytes .../public/pdfjs/cmaps/UniCNS-UTF16-V.bcmap | Bin 0 -> 156 bytes .../public/pdfjs/cmaps/UniCNS-UTF32-H.bcmap | Bin 0 -> 52679 bytes .../public/pdfjs/cmaps/UniCNS-UTF32-V.bcmap | Bin 0 -> 160 bytes .../public/pdfjs/cmaps/UniCNS-UTF8-H.bcmap | Bin 0 -> 53629 bytes .../public/pdfjs/cmaps/UniCNS-UTF8-V.bcmap | Bin 0 -> 157 bytes .../public/pdfjs/cmaps/UniGB-UCS2-H.bcmap | Bin 0 -> 43366 bytes .../public/pdfjs/cmaps/UniGB-UCS2-V.bcmap | Bin 0 -> 193 bytes .../public/pdfjs/cmaps/UniGB-UTF16-H.bcmap | Bin 0 -> 44086 bytes .../public/pdfjs/cmaps/UniGB-UTF16-V.bcmap | Bin 0 -> 178 bytes .../public/pdfjs/cmaps/UniGB-UTF32-H.bcmap | Bin 0 -> 45738 bytes .../public/pdfjs/cmaps/UniGB-UTF32-V.bcmap | Bin 0 -> 182 bytes .../public/pdfjs/cmaps/UniGB-UTF8-H.bcmap | Bin 0 -> 46837 bytes .../public/pdfjs/cmaps/UniGB-UTF8-V.bcmap | Bin 0 -> 181 bytes .../public/pdfjs/cmaps/UniJIS-UCS2-H.bcmap | Bin 0 -> 25439 bytes .../public/pdfjs/cmaps/UniJIS-UCS2-HW-H.bcmap | Bin 0 -> 119 bytes .../public/pdfjs/cmaps/UniJIS-UCS2-HW-V.bcmap | Bin 0 -> 680 bytes .../public/pdfjs/cmaps/UniJIS-UCS2-V.bcmap | Bin 0 -> 664 bytes .../public/pdfjs/cmaps/UniJIS-UTF16-H.bcmap | Bin 0 -> 39443 bytes .../public/pdfjs/cmaps/UniJIS-UTF16-V.bcmap | Bin 0 -> 643 bytes .../public/pdfjs/cmaps/UniJIS-UTF32-H.bcmap | Bin 0 -> 40539 bytes .../public/pdfjs/cmaps/UniJIS-UTF32-V.bcmap | Bin 0 -> 677 bytes .../public/pdfjs/cmaps/UniJIS-UTF8-H.bcmap | Bin 0 -> 41695 bytes .../public/pdfjs/cmaps/UniJIS-UTF8-V.bcmap | Bin 0 -> 678 bytes .../pdfjs/cmaps/UniJIS2004-UTF16-H.bcmap | Bin 0 -> 39534 bytes .../pdfjs/cmaps/UniJIS2004-UTF16-V.bcmap | Bin 0 -> 647 bytes .../pdfjs/cmaps/UniJIS2004-UTF32-H.bcmap | Bin 0 -> 40630 bytes .../pdfjs/cmaps/UniJIS2004-UTF32-V.bcmap | Bin 0 -> 681 bytes .../pdfjs/cmaps/UniJIS2004-UTF8-H.bcmap | Bin 0 -> 41779 bytes .../pdfjs/cmaps/UniJIS2004-UTF8-V.bcmap | Bin 0 -> 682 bytes .../pdfjs/cmaps/UniJISPro-UCS2-HW-V.bcmap | Bin 0 -> 705 bytes .../public/pdfjs/cmaps/UniJISPro-UCS2-V.bcmap | Bin 0 -> 689 bytes .../public/pdfjs/cmaps/UniJISPro-UTF8-V.bcmap | Bin 0 -> 726 bytes .../pdfjs/cmaps/UniJISX0213-UTF32-H.bcmap | Bin 0 -> 40517 bytes .../pdfjs/cmaps/UniJISX0213-UTF32-V.bcmap | Bin 0 -> 684 bytes .../pdfjs/cmaps/UniJISX02132004-UTF32-H.bcmap | Bin 0 -> 40608 bytes .../pdfjs/cmaps/UniJISX02132004-UTF32-V.bcmap | Bin 0 -> 688 bytes .../public/pdfjs/cmaps/UniKS-UCS2-H.bcmap | Bin 0 -> 25783 bytes .../public/pdfjs/cmaps/UniKS-UCS2-V.bcmap | Bin 0 -> 178 bytes .../public/pdfjs/cmaps/UniKS-UTF16-H.bcmap | Bin 0 -> 26327 bytes .../public/pdfjs/cmaps/UniKS-UTF16-V.bcmap | Bin 0 -> 164 bytes .../public/pdfjs/cmaps/UniKS-UTF32-H.bcmap | Bin 0 -> 26451 bytes .../public/pdfjs/cmaps/UniKS-UTF32-V.bcmap | Bin 0 -> 168 bytes .../public/pdfjs/cmaps/UniKS-UTF8-H.bcmap | Bin 0 -> 27790 bytes .../public/pdfjs/cmaps/UniKS-UTF8-V.bcmap | Bin 0 -> 169 bytes frontend/public/pdfjs/cmaps/V.bcmap | Bin 0 -> 166 bytes frontend/public/pdfjs/cmaps/WP-Symbol.bcmap | Bin 0 -> 179 bytes .../pdfjs/standard_fonts/FoxitDingbats.pfb | Bin 0 -> 29513 bytes .../pdfjs/standard_fonts/FoxitFixed.pfb | Bin 0 -> 17597 bytes .../pdfjs/standard_fonts/FoxitFixedBold.pfb | Bin 0 -> 18055 bytes .../standard_fonts/FoxitFixedBoldItalic.pfb | Bin 0 -> 19151 bytes .../pdfjs/standard_fonts/FoxitFixedItalic.pfb | Bin 0 -> 18746 bytes .../pdfjs/standard_fonts/FoxitSerif.pfb | Bin 0 -> 19469 bytes .../pdfjs/standard_fonts/FoxitSerifBold.pfb | Bin 0 -> 19395 bytes .../standard_fonts/FoxitSerifBoldItalic.pfb | Bin 0 -> 20733 bytes .../pdfjs/standard_fonts/FoxitSerifItalic.pfb | Bin 0 -> 21227 bytes .../pdfjs/standard_fonts/FoxitSymbol.pfb | Bin 0 -> 16729 bytes .../public/pdfjs/standard_fonts/LICENSE_FOXIT | 27 + .../pdfjs/standard_fonts/LICENSE_LIBERATION | 102 + .../standard_fonts/LiberationSans-Bold.ttf | Bin 0 -> 137052 bytes .../LiberationSans-BoldItalic.ttf | Bin 0 -> 135124 bytes .../standard_fonts/LiberationSans-Italic.ttf | Bin 0 -> 162036 bytes .../standard_fonts/LiberationSans-Regular.ttf | Bin 0 -> 139512 bytes frontend/public/test-practice-records.html | 338 + frontend/scripts/check_environment.sh | 38 + frontend/scripts/switch_environment.sh | 75 + frontend/src/App.vue | 23 + frontend/src/api/__tests__/auth.test.ts | 123 + frontend/src/api/admin/dashboard.ts | 57 + frontend/src/api/admin/position.ts | 81 + frontend/src/api/admin/user.ts | 124 + frontend/src/api/analysis/index.ts | 428 + frontend/src/api/auth/index.ts | 104 + frontend/src/api/broadcast.ts | 18 + frontend/src/api/config.ts | 38 + frontend/src/api/course/index.ts | 316 + frontend/src/api/courseChat.ts | 132 + frontend/src/api/coze/index.ts | 150 + frontend/src/api/dashboard.ts | 20 + frontend/src/api/exam.ts | 164 + frontend/src/api/exam/index.ts | 546 + frontend/src/api/index.ts | 39 + frontend/src/api/manager/index.ts | 518 + frontend/src/api/manager/practice.ts | 83 + frontend/src/api/manager/scores.ts | 116 + frontend/src/api/material.ts | 72 + .../mock/admin-dashboard-course-completion.ts | 12 + .../src/api/mock/admin-dashboard-stats.ts | 30 + .../api/mock/admin-dashboard-user-growth.ts | 32 + frontend/src/api/mock/admin-positions-tree.ts | 96 + frontend/src/api/mock/admin-positions.ts | 183 + .../api/mock/admin-users-1-reset-password.ts | 29 + .../src/api/mock/admin-users-statistics.ts | 12 + frontend/src/api/mock/admin-users.ts | 133 + frontend/src/api/notification.ts | 144 + frontend/src/api/practice.ts | 190 + frontend/src/api/practiceScene.ts | 117 + frontend/src/api/request.ts | 273 + frontend/src/api/score.ts | 126 + frontend/src/api/statistics.ts | 137 + frontend/src/api/systemLogs.ts | 72 + frontend/src/api/task.ts | 108 + frontend/src/api/teamDashboard.ts | 117 + frontend/src/api/teamManagement.ts | 134 + frontend/src/api/trainee/index.ts | 447 + frontend/src/api/user/index.ts | 248 + frontend/src/components/NotificationBell.vue | 395 + frontend/src/components/TextChat.vue | 457 + frontend/src/components/VoiceChat.vue | 697 ++ .../components/common/EnvironmentBadge.vue | 49 + frontend/src/components/common/README.md | 41 + frontend/src/config/env.ts | 223 + frontend/src/layout/index.vue | 766 ++ frontend/src/main.ts | 45 + frontend/src/router/guard.ts | 270 + frontend/src/router/index.ts | 315 + frontend/src/stores/practiceStore.ts | 763 ++ frontend/src/style/index.scss | 126 + frontend/src/style/variables.scss | 127 + frontend/src/test/setup.ts | 215 + frontend/src/test/utils.ts | 309 + frontend/src/types/broadcast.ts | 9 + frontend/src/types/material.ts | 129 + frontend/src/types/practice.ts | 149 + frontend/src/utils/__tests__/auth.test.ts | 242 + .../src/utils/__tests__/errorHandler.test.ts | 251 + frontend/src/utils/auth.ts | 412 + frontend/src/utils/cozeVoiceClient.ts | 305 + frontend/src/utils/errorHandler.ts | 490 + frontend/src/utils/http.ts | 438 + frontend/src/utils/loadingManager.ts | 216 + frontend/src/views/admin/dashboard.vue | 1086 ++ frontend/src/views/admin/logs.vue | 378 + .../src/views/admin/position-management.vue | 1337 +++ frontend/src/views/admin/user-management.vue | 1683 +++ frontend/src/views/analysis/mistakes.vue | 1116 ++ frontend/src/views/analysis/report.vue | 692 ++ frontend/src/views/analysis/statistics.vue | 984 ++ frontend/src/views/dashboard/index.vue | 478 + frontend/src/views/error/404.vue | 157 + frontend/src/views/exam/practice.vue | 1704 +++ frontend/src/views/login/index.vue | 367 + .../src/views/manager/assignment-center.vue | 769 ++ .../src/views/manager/course-management.vue | 646 ++ .../manager/course-management.vue.current | 3233 ++++++ frontend/src/views/manager/edit-course.vue | 3080 ++++++ .../views/manager/growth-path-management.vue | 955 ++ .../manager/practice-scene-management.vue | 1235 +++ .../src/views/manager/student-practice.vue | 1059 ++ frontend/src/views/manager/student-scores.vue | 1006 ++ frontend/src/views/manager/team-dashboard.vue | 1254 +++ .../src/views/manager/team-management.vue | 1463 +++ frontend/src/views/register/index.vue | 406 + .../src/views/trainee/ai-practice-center.vue | 1151 ++ .../src/views/trainee/ai-practice-coze.vue | 754 ++ frontend/src/views/trainee/ai-practice.vue | 119 + frontend/src/views/trainee/audio-player.vue | 638 ++ .../src/views/trainee/broadcast-course.vue | 314 + frontend/src/views/trainee/chat-course.vue | 1015 ++ frontend/src/views/trainee/course-center.vue | 914 ++ frontend/src/views/trainee/course-detail.vue | 1298 +++ frontend/src/views/trainee/exam-result.vue | 810 ++ frontend/src/views/trainee/growth-path.vue | 2259 ++++ .../src/views/trainee/practice-records.vue | 1035 ++ .../src/views/trainee/practice-report.vue | 913 ++ frontend/src/views/trainee/score-query.vue | 790 ++ frontend/src/views/user/change-password.vue | 376 + frontend/src/views/user/notifications.vue | 554 + frontend/src/views/user/profile.vue | 652 ++ frontend/src/views/user/settings.vue | 478 + frontend/src/vite-env.d.ts | 7 + frontend/tsconfig.json | 24 + frontend/tsconfig.node.json | 10 + frontend/vite.config.prod.ts | 55 + frontend/vite.config.ts | 226 + ....timestamp-1758830290264-a627566ebc111.mjs | 198 + ....timestamp-1758830334806-d249cef516968.mjs | 198 + frontend/vitest.config.ts | 47 + frontend/前后端接口约定.md | 293 + frontend/导航栏规划.md | 30 + frontend/课程资料预览功能测试指南.md | 265 + frontend/页面与按钮速查.md | 257 + tests/test_ai_functions.py | 570 + tests/test_course_chat.py | 165 + tests/test_coze_conversation.py | 95 + tests/test_exam_api.py | 199 + tests/test_frontend_login.py | 124 + tests/test_integration.py | 134 + tests/test_login.html | 177 + tests/test_login_manual.py | 113 + tests/test_practice_api.py | 350 + tests/test_practice_records_frontend.html | 338 + tests/test_practice_scenes_api.html | 123 + tests/test_practice_sessions_api.py | 117 + tests/test_score_report_api.py | 164 + tests/test_update_profile.py | 102 + 知识库/BUG_REPORT.md | 123 + 知识库/Coze集成方案.md | 132 + 知识库/DIFY_API_KEYS_UPDATE_SUMMARY.md | 170 + 知识库/DIFY_QUICK_REFERENCE.md | 103 + 知识库/Dify工作流-SQL执行器使用指南.md | 544 + 知识库/Dify工作流-知识点管理SQL.md | 399 + 知识库/backup_summary.md | 71 + .../backup_20251017_033000.sql | 1 + .../backup_20251017_033012.sql | 2 + .../backup_20251017_033024.sql | 0 .../backup_20251017_033041.sql | 1024 ++ .../backup_before_production_20251016_080603.sql | 1 + .../updates/backup_20250926_044558_database.sql | 817 ++ .../updates/backup_20250926_051946_database.sql | 817 ++ .../updates/backup_20251016_052230_database.sql | 0 .../updates/backup_20251016_075849_database.sql | 1025 ++ .../backup_before_import_20251016_060537.sql | 783 ++ .../sql/backup_before_init_20250923_011804.sql | 169 + .../backup_before_regenerate_20250923_023604.sql | 824 ++ .../backup_before_rollback_20250923_013456.sql | 822 ++ .../sql/full_database_backup_20250923_014658.sql | 1906 ++++ ...aopeilian_complete_backup_20250923_025548.sql | 825 ++ ...n_complete_fixed_comments_20250923_025109.sql | 821 ++ ...ian_final_complete_backup_20250923_025629.sql | 841 ++ ...ian_super_complete_backup_20250923_025622.sql | 825 ++ 知识库/download_yanji_audios.py | 214 + 知识库/insert_enhancement_features_data.sql | 213 + 知识库/kpl域名访问问题-已解决.md | 172 + 知识库/migrate_timezone_data.sql | 168 + 知识库/migrate_timezone_data_v2.sql | 232 + 知识库/migrate_timezone_simple.sql | 95 + 知识库/token.txt | 1 + .../参考代码/coze-chat-frontend/eslint.config.js | 28 + .../coze-chat-frontend/package-lock.json | 6012 +++++++++++ .../参考代码/coze-chat-frontend/src/App.tsx | 35 + .../coze-chat-frontend/src/assets/images/home_bg.jpg | Bin 0 -> 15074 bytes .../coze-chat-frontend/src/assets/images/menu1.png | Bin 0 -> 2988 bytes .../coze-chat-frontend/src/assets/images/menu2.png | Bin 0 -> 2571 bytes .../coze-chat-frontend/src/assets/images/menu3.png | Bin 0 -> 2907 bytes .../coze-chat-frontend/src/assets/images/menu4.png | Bin 0 -> 2825 bytes .../coze-chat-frontend/src/assets/images/menu5.png | Bin 0 -> 2958 bytes .../coze-chat-frontend/src/assets/images/menu6.png | Bin 0 -> 2271 bytes .../coze-chat-frontend/src/assets/images/menu7.png | Bin 0 -> 2734 bytes .../coze-chat-frontend/src/assets/images/menu8.png | Bin 0 -> 3011 bytes .../src/assets/images/training_logo.png | Bin 0 -> 44353 bytes .../coze-chat-frontend/src/assets/images/user.jpg | Bin 0 -> 4822 bytes .../src/components/MessageList/index.scss | 245 + .../src/components/MessageList/index.tsx | 349 + .../src/components/SenderBox/index.scss | 70 + .../src/components/SenderBox/index.tsx | 168 + .../coze-chat-frontend/src/hooks/index.ts | 1 + .../coze-chat-frontend/src/hooks/use-media-query.ts | 142 + .../参考代码/coze-chat-frontend/src/index.scss | 15 + .../参考代码/coze-chat-frontend/src/main.tsx | 27 + .../src/pages/AudioTest/index.scss | 214 + .../coze-chat-frontend/src/pages/AudioTest/index.tsx | 494 + .../src/pages/Chat/Header/index.scss | 34 + .../src/pages/Chat/Header/index.tsx | 59 + .../src/pages/Chat/MessageList/index.scss | 155 + .../src/pages/Chat/MessageList/index.tsx | 287 + .../coze-chat-frontend/src/pages/Chat/Nav/index.scss | 165 + .../coze-chat-frontend/src/pages/Chat/Nav/index.tsx | 175 + .../src/pages/Chat/SenderBox/index.scss | 70 + .../src/pages/Chat/SenderBox/index.tsx | 160 + .../src/pages/Chat/SiderNav/index.scss | 21 + .../src/pages/Chat/SiderNav/index.tsx | 85 + .../coze-chat-frontend/src/pages/Chat/index.scss | 8 + .../coze-chat-frontend/src/pages/Chat/index.tsx | 21 + .../coze-chat-frontend/src/pages/Content/index.scss | 12 + .../coze-chat-frontend/src/pages/Content/index.tsx | 59 + .../coze-chat-frontend/src/pages/Exam/index.tsx | 59 + .../coze-chat-frontend/src/pages/Home/index.scss | 221 + .../coze-chat-frontend/src/pages/Home/index.tsx | 110 + .../coze-chat-frontend/src/pages/NewChat/index.tsx | 58 + .../src/pages/Training/TextChat.scss | 16 + .../src/pages/Training/TextChat.tsx | 67 + .../src/pages/Training/VoiceChat.scss | 286 + .../src/pages/Training/VoiceChat.tsx | 224 + .../coze-chat-frontend/src/pages/Training/index.tsx | 19 + .../参考代码/coze-chat-frontend/src/server/ai.ts | 77 + .../coze-chat-frontend/src/server/api.ts | 77 + .../coze-chat-frontend/src/server/global.ts | 85 + .../coze-chat-frontend/src/server/type.ts | 14 + .../coze-chat-frontend/src/stores/BotStore.ts | 68 + .../coze-chat-frontend/src/stores/ChatStore.ts | 534 + .../coze-chat-frontend/src/stores/ExamStore.ts | 355 + .../coze-chat-frontend/src/stores/NewChatStore.ts | 293 + .../coze-chat-frontend/src/stores/TrainingStore.ts | 525 + .../coze-chat-frontend/src/stores/config.ts | 9 + .../coze-chat-frontend/src/stores/index.ts | 9 + .../coze-chat-frontend/src/stores/utils.ts | 8 + .../coze-chat-frontend/src/style/functions.scss | 3 + .../coze-chat-frontend/src/style/global.scss | 142 + .../src/style/iconfonts/iconfont.css | 68 + .../src/style/iconfonts/iconfont.woff2 | Bin 0 -> 2332 bytes .../src/style/mixins/fontSize.scss | 5 + .../src/style/mixins/interval.scss | 63 + .../coze-chat-frontend/src/style/variables.scss | 2 + .../参考代码/coze-chat-frontend/src/utils/api.ts | 4 + .../coze-chat-frontend/src/utils/request.ts | 119 + .../coze-chat-frontend/src/utils/tools.ts | 103 + .../参考代码/coze-chat-frontend/src/utils/tts.ts | 44 + .../coze-chat-frontend/src/vite-env.d.ts | 1 + .../coze-chat-frontend/tsconfig.app.json | 30 + .../参考代码/coze-chat-frontend/tsconfig.json | 7 + .../参考代码/python_dev_project/.env.example | 0 知识库/参考代码/python_dev_project/Makefile | 0 .../python_dev_project/docs/api_contract_dify.yaml | 0 .../docs/api_contracts/exam_api_contract.yaml | 590 + .../python_dev_project/docs/dify_integration.md | 0 .../python_dev_project/docs/modules/exam_module.md | 220 + .../python_dev_project/src/api/v1/dify_gateway.py | 0 .../python_dev_project/src/api/v1/exams.py | 187 + .../python_dev_project/src/api/v1/router.py | 15 + .../参考代码/python_dev_project/src/core/deps.py | 0 .../python_dev_project/src/models/__init__.py | 0 .../python_dev_project/src/models/base.py | 0 .../python_dev_project/src/models/exam.py | 0 .../python_dev_project/src/models/user.py | 0 .../python_dev_project/src/schemas/__init__.py | 0 .../python_dev_project/src/schemas/base.py | 0 .../python_dev_project/src/schemas/exam.py | 0 .../python_dev_project/src/services/__init__.py | 1 + .../python_dev_project/src/services/ai/__init__.py | 1 + .../src/services/ai/dify/__init__.py | 1 + .../src/services/ai/dify/client.py | 217 + .../src/services/ai/dify/exceptions.py | 0 .../src/services/ai/dify/models.py | 0 .../src/services/ai/dify/service.py | 0 .../python_dev_project/src/services/exam_service.py | 0 .../python_dev_project/src/utils/cache.py | 0 .../tests/api/v1/test_dify_gateway.py | 0 .../python_dev_project/tests/conftest.py | 0 .../python_dev_project/tests/services/__init__.py | 0 .../python_dev_project/tests/services/ai/__init__.py | 0 .../tests/services/ai/dify/__init__.py | 0 .../tests/services/ai/dify/test_client.py | 0 .../tests/services/ai/dify/test_models.py | 0 .../tests/services/ai/dify/test_service.py | 0 .../python_dev_project/tests/test_exam.py | 0 .../python_dev_project/tests/test_exam_api.py | 0 知识库/员工同步功能总结.md | 158 + 知识库/备份摘要.md | 71 + 知识库/安装指南.md | 116 + 知识库/开发环境使用指南.md | 317 + 知识库/开发记录/DIFY_API_KEYS_配置完成报告.md | 243 + 知识库/开发记录/Dify-DELETE权限验证报告.md | 301 + 知识库/开发记录/Dify系统对接分析报告.md | 623 ++ 知识库/开发记录/双系统部署完成报告.md | 407 + 知识库/开发记录/员工同步实施报告-最终版.md | 323 + 知识库/开发记录/文档整理完成报告.md | 82 + 知识库/开发记录/登录问题解决报告.md | 178 + 知识库/开发记录/真实取库落库确认报告.md | 334 + 知识库/开发记录/管理员页面数据对接完成报告.md | 282 + 知识库/开发记录/系统增强功能完成报告.md | 341 + 知识库/开发记录/统计分析数据注入完成报告.md | 161 + .../开发记录/课程资料预览功能-实施完成报告.md | 377 + 知识库/开发记录/资料34知识点清理完成报告.md | 256 + .../远程HTTP数据库写入服务开发情况报告.md | 370 + 知识库/开发记录/问题报告.md | 123 + 知识库/开发记录/陪练记录问题排查报告.md | 161 + 知识库/清空资料知识点.sql | 55 + 知识库/清除浏览器缓存指南.md | 182 + 知识库/瑞小美岗位数据总结.md | 198 + 知识库/用户管理页面调试指南.md | 221 + 知识库/知识点删除问题分析.md | 378 + 知识库/系统登录账号密码.md | 198 + 知识库/统计分析功能-快速测试指南.md | 161 + 知识库/部署完成-快速参考.txt | 70 + 知识库/配置Docker代理.md | 89 + 知识库/配置说明.md | 68 + 知识库/问题修复/CORS问题修复记录.md | 228 + .../问题修复/文件上传和AI分析问题修复报告.md | 344 + 知识库/问题修复/文件上传失败问题修复报告.md | 121 + 知识库/问题修复/用户管理页面修复说明.md | 268 + .../问题修复/资料上传数据库持久化问题修复报告.md | 300 + .../问题修复报告-姓名职位与课程真实性.md | 410 + 知识库/问题修复/问题修复记录.md | 154 + 知识库/陪练记录问题排查指南.md | 167 + 1197 files changed, 228429 insertions(+) create mode 100644 .cursorignore create mode 100644 .cursorrules create mode 100644 .drone.yml create mode 100644 .env.admin create mode 100644 .env.development create mode 100644 .env.kpl create mode 100644 .gitignore create mode 100644 CONTEXT.md create mode 100644 admin-frontend/Dockerfile create mode 100644 admin-frontend/env.d.ts create mode 100644 admin-frontend/index.html create mode 100644 admin-frontend/nginx.conf create mode 100644 admin-frontend/package.json create mode 100644 admin-frontend/public/favicon.svg create mode 100644 admin-frontend/src/App.vue create mode 100644 admin-frontend/src/api/index.js create mode 100644 admin-frontend/src/assets/styles/main.scss create mode 100644 admin-frontend/src/main.ts create mode 100644 admin-frontend/src/router/index.js create mode 100644 admin-frontend/src/stores/auth.js create mode 100644 admin-frontend/src/views/Dashboard.vue create mode 100644 admin-frontend/src/views/Layout.vue create mode 100644 admin-frontend/src/views/Login.vue create mode 100644 admin-frontend/src/views/Logs.vue create mode 100644 admin-frontend/src/views/NotFound.vue create mode 100644 admin-frontend/src/views/prompts/PromptDetail.vue create mode 100644 admin-frontend/src/views/prompts/PromptList.vue create mode 100644 admin-frontend/src/views/tenants/TenantConfigs.vue create mode 100644 admin-frontend/src/views/tenants/TenantDetail.vue create mode 100644 admin-frontend/src/views/tenants/TenantFeatures.vue create mode 100644 admin-frontend/src/views/tenants/TenantList.vue create mode 100644 admin-frontend/tsconfig.app.json create mode 100644 admin-frontend/tsconfig.json create mode 100644 admin-frontend/tsconfig.node.json create mode 100644 admin-frontend/vite.config.ts create mode 100644 backend/.env.ex create mode 100644 backend/.env.example create mode 100644 backend/.env.fw create mode 100644 backend/.env.hl create mode 100644 backend/.env.hua create mode 100644 backend/.env.xy create mode 100644 backend/.env.yy create mode 100644 backend/.gitignore create mode 100644 backend/.pre-commit-config.yaml create mode 100644 backend/Dockerfile create mode 100644 backend/Dockerfile.admin create mode 100644 backend/Dockerfile.dev create mode 100644 backend/Makefile create mode 100644 backend/README.md create mode 100644 backend/SQL_EXECUTOR_FINAL_SUMMARY.md create mode 100644 backend/__init__.py create mode 100644 backend/alembic/versions/add_course_fields.sql create mode 100644 backend/alembic/versions/add_mistake_mastery_fields.sql create mode 100644 backend/alembic/versions/create_system_logs_table.sql create mode 100644 backend/alembic/versions/create_tasks_table.sql create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/v1/03-Agent-Course/api_contract.yaml create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/ability.py create mode 100644 backend/app/api/v1/admin.py create mode 100644 backend/app/api/v1/admin_portal/__init__.py create mode 100644 backend/app/api/v1/admin_portal/auth.py create mode 100644 backend/app/api/v1/admin_portal/configs.py create mode 100644 backend/app/api/v1/admin_portal/features.py create mode 100644 backend/app/api/v1/admin_portal/prompts.py create mode 100644 backend/app/api/v1/admin_portal/schemas.py create mode 100644 backend/app/api/v1/admin_portal/tenants.py create mode 100644 backend/app/api/v1/admin_positions_backup.py create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/api/v1/broadcast.py create mode 100644 backend/app/api/v1/course_chat.py create mode 100644 backend/app/api/v1/courses.py create mode 100644 backend/app/api/v1/coze_gateway.py create mode 100644 backend/app/api/v1/endpoints/employee_sync.py create mode 100644 backend/app/api/v1/exam.py create mode 100644 backend/app/api/v1/knowledge_analysis.py create mode 100644 backend/app/api/v1/manager/__init__.py create mode 100644 backend/app/api/v1/manager/student_practice.py create mode 100644 backend/app/api/v1/manager/student_scores.py create mode 100644 backend/app/api/v1/notifications.py create mode 100644 backend/app/api/v1/positions.py create mode 100644 backend/app/api/v1/practice.py create mode 100644 backend/app/api/v1/preview.py create mode 100644 backend/app/api/v1/scrm.py create mode 100644 backend/app/api/v1/sql_executor.py create mode 100644 backend/app/api/v1/sql_executor_simple_auth.py create mode 100644 backend/app/api/v1/statistics.py create mode 100644 backend/app/api/v1/system.py create mode 100644 backend/app/api/v1/system_logs.py create mode 100644 backend/app/api/v1/tasks.py create mode 100644 backend/app/api/v1/team_dashboard.py create mode 100644 backend/app/api/v1/team_management.py create mode 100644 backend/app/api/v1/teams.py create mode 100644 backend/app/api/v1/training.py create mode 100644 backend/app/api/v1/training_api_contract.yaml create mode 100644 backend/app/api/v1/upload.py create mode 100644 backend/app/api/v1/users.py create mode 100644 backend/app/api/v1/yanji.py create mode 100644 backend/app/config/__init__.py create mode 100644 backend/app/config/database.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/database.py create mode 100644 backend/app/core/deps.py create mode 100644 backend/app/core/events.py create mode 100644 backend/app/core/exceptions.py create mode 100644 backend/app/core/logger.py create mode 100644 backend/app/core/middleware.py create mode 100644 backend/app/core/redis.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/core/simple_auth.py create mode 100644 backend/app/core/tenant_config.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/ability.py create mode 100644 backend/app/models/base.py create mode 100644 backend/app/models/course.py create mode 100644 backend/app/models/course_exam_settings.py create mode 100644 backend/app/models/exam.py create mode 100644 backend/app/models/exam_mistake.py create mode 100644 backend/app/models/notification.py create mode 100644 backend/app/models/position.py create mode 100644 backend/app/models/position_course.py create mode 100644 backend/app/models/position_member.py create mode 100644 backend/app/models/practice.py create mode 100644 backend/app/models/system_log.py create mode 100644 backend/app/models/task.py create mode 100644 backend/app/models/training.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/ability.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/schemas/base.py create mode 100644 backend/app/schemas/course.py create mode 100644 backend/app/schemas/exam.py create mode 100644 backend/app/schemas/notification.py create mode 100644 backend/app/schemas/practice.py create mode 100644 backend/app/schemas/scrm.py create mode 100644 backend/app/schemas/system_log.py create mode 100644 backend/app/schemas/task.py create mode 100644 backend/app/schemas/training.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/schemas/yanji.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/ability_assessment_service.py create mode 100644 backend/app/services/ai/__init__.py create mode 100644 backend/app/services/ai/ability_analysis_service.py create mode 100644 backend/app/services/ai/ai_service.py create mode 100644 backend/app/services/ai/answer_judge_service.py create mode 100644 backend/app/services/ai/course_chat_service.py create mode 100644 backend/app/services/ai/coze/__init__.py create mode 100644 backend/app/services/ai/coze/client.py create mode 100644 backend/app/services/ai/coze/client_backup.py create mode 100644 backend/app/services/ai/coze/exceptions.py create mode 100644 backend/app/services/ai/coze/models.py create mode 100644 backend/app/services/ai/coze/service.py create mode 100644 backend/app/services/ai/exam_generator_service.py create mode 100644 backend/app/services/ai/knowledge_analysis_v2.py create mode 100644 backend/app/services/ai/llm_json_parser.py create mode 100644 backend/app/services/ai/practice_analysis_service.py create mode 100644 backend/app/services/ai/practice_scene_service.py create mode 100644 backend/app/services/ai/prompts/__init__.py create mode 100644 backend/app/services/ai/prompts/ability_analysis_prompts.py create mode 100644 backend/app/services/ai/prompts/answer_judge_prompts.py create mode 100644 backend/app/services/ai/prompts/course_chat_prompts.py create mode 100644 backend/app/services/ai/prompts/exam_generator_prompts.py create mode 100644 backend/app/services/ai/prompts/knowledge_analysis_prompts.py create mode 100644 backend/app/services/ai/prompts/practice_analysis_prompts.py create mode 100644 backend/app/services/ai/prompts/practice_scene_prompts.py create mode 100644 backend/app/services/auth_service.py create mode 100644 backend/app/services/base_service.py create mode 100644 backend/app/services/course_exam_service.py create mode 100644 backend/app/services/course_position_service.py create mode 100644 backend/app/services/course_service.py create mode 100644 backend/app/services/course_statistics_service.py create mode 100644 backend/app/services/coze_broadcast_service.py create mode 100644 backend/app/services/coze_service.py create mode 100644 backend/app/services/document_converter.py create mode 100644 backend/app/services/employee_sync_service.py create mode 100644 backend/app/services/exam_report_service.py create mode 100644 backend/app/services/exam_service.py create mode 100644 backend/app/services/external/__init__.py create mode 100644 backend/app/services/notification_service.py create mode 100644 backend/app/services/scrm_service.py create mode 100644 backend/app/services/statistics_service.py create mode 100644 backend/app/services/system_log_service.py create mode 100644 backend/app/services/task_service.py create mode 100644 backend/app/services/training_service.py create mode 100644 backend/app/services/user_service.py create mode 100644 backend/app/services/yanji_service.py create mode 100644 backend/backups/backup_status.json create mode 100644 backend/backups/kaopeilian_backup_20250923_032422.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_034650.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_040001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_040223.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_050001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_050032.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_060001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_060334.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_070001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_070101.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_080001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_080321.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_090001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_090323.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_100001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_100017.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_110001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_110245.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_120001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_120123.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_130001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_130020.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_140001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_140334.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_150001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_150304.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_160001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_160053.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_170001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_170057.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_180001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_180429.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_190001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_190043.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_200001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_200124.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_210001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_210154.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_220001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_220101.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_230001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250923_230241.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_000001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_000429.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_010001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_010153.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_020001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_020216.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_030001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_030304.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_040001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_040030.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_050001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_050404.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_060001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_060054.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_070001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_070451.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_080001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_080155.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_090002.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_090129.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_100001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_100028.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_110001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_110249.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_120001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_120238.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_130001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_130451.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_140001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_140404.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_150001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_150347.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_160001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_160424.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_170001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_170155.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_180001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_180200.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_190001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_190155.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_200001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_200050.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_210001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_210100.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_220001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_220035.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_230001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250924_230129.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250925_000001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250925_000321.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250925_010001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250925_010038.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250925_020001.sql.gz create mode 100644 backend/backups/kaopeilian_backup_20250925_020104.sql.gz create mode 100644 backend/create_admin_users.py create mode 100644 backend/create_simple_users.py create mode 100644 backend/create_system_accounts.py create mode 100644 backend/create_system_users.py create mode 100644 backend/create_team_data.py create mode 100644 backend/create_test_user.py create mode 100644 backend/create_test_user_exam.py create mode 100644 backend/create_user_simple.py create mode 100644 backend/database_schema_unified.md create mode 100644 backend/debug_api.py create mode 100644 backend/debug_update_api.py create mode 100644 backend/debug_user.py create mode 100644 backend/deploy/quick_deploy.sh create mode 100644 backend/deploy/server_setup_guide.md create mode 100644 backend/docker-compose.dev.yml create mode 100644 backend/docker-compose.yml create mode 100644 backend/docker/__init__.py create mode 100644 backend/docker/mysql/conf.d/mysql-rollback.cnf create mode 100644 backend/docker/nginx/__init__.py create mode 100644 backend/docs/__init__.py create mode 100644 backend/docs/api/__init__.py create mode 100644 backend/docs/database_rollback_guide.md create mode 100644 backend/docs/deployment/__init__.py create mode 100644 backend/docs/development/__init__.py create mode 100644 backend/docs/openapi_sql_executor.json create mode 100644 backend/docs/openapi_sql_executor.yaml create mode 100644 backend/docs/sql_executor_checklist.md create mode 100644 backend/examples/coze_integration_example.py create mode 100644 backend/insert_test_logs.sql create mode 100644 backend/migrations/__init__.py create mode 100644 backend/migrations/add_broadcast_fields.sql create mode 100644 backend/migrations/add_broadcast_status_fields.sql create mode 100644 backend/migrations/add_course_allow_download.sql create mode 100644 backend/migrations/admin_platform_schema.sql create mode 100644 backend/migrations/alembic/__init__.py create mode 100644 backend/migrations/alembic/versions/__init__.py create mode 100644 backend/migrations/cleanup_broadcast_fields.sql create mode 100644 backend/migrations/create_ability_assessments.sql create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/manual_course_tables.sql create mode 100644 backend/migrations/manual_modify_knowledge_points_material_id.sql create mode 100644 backend/migrations/manual_training_tables.sql create mode 100644 backend/migrations/script.py.mako create mode 100644 backend/migrations/update_production_broadcast_fields.sql create mode 100644 backend/migrations/update_production_broadcast_fields_step1_check.sql create mode 100644 backend/migrations/update_production_broadcast_fields_step2_update.sql create mode 100644 backend/migrations/versions/0487635b5e95_add_position_courses_table.py create mode 100644 backend/migrations/versions/20250921_align_schema_to_design.py create mode 100644 backend/migrations/versions/20250922_add_positions_table.py create mode 100644 backend/migrations/versions/3d5b88fe1875_merge_multiple_heads.py create mode 100644 backend/migrations/versions/5448c81e7afd_add_position_members_table.py create mode 100644 backend/migrations/versions/9245f8845fe1_add_training_models.py create mode 100644 backend/migrations/versions/add_position_skills_level.py create mode 100644 backend/migrations/versions/add_users_soft_delete.py create mode 100644 backend/mysql.cnf create mode 100644 backend/pyproject.toml create mode 100644 backend/pytest.ini create mode 100644 backend/requirements-admin.txt create mode 100644 backend/requirements-dev.txt create mode 100644 backend/requirements.txt create mode 100644 backend/requirements/__init__.py create mode 100644 backend/requirements/base.txt create mode 100644 backend/requirements/dev.txt create mode 100644 backend/requirements/prod.txt create mode 100644 backend/scripts/__init__.py create mode 100644 backend/scripts/add_admin_exam_data.sql create mode 100644 backend/scripts/add_admin_learning_data.sql create mode 100644 backend/scripts/add_exam_and_mistakes_demo_data.sql create mode 100644 backend/scripts/add_exam_data.sql create mode 100644 backend/scripts/add_exam_tables.sql create mode 100644 backend/scripts/add_school_major_fields.py create mode 100644 backend/scripts/add_training_data.sql create mode 100644 backend/scripts/alter_exam_mistakes_add_question_type.sql create mode 100644 backend/scripts/alter_exams_add_rounds.sql create mode 100644 backend/scripts/alter_users_email_nullable.sql create mode 100644 backend/scripts/apply_sql_file.py create mode 100755 backend/scripts/backup_database.sh create mode 100644 backend/scripts/binlog_rollback_tool.py create mode 100755 backend/scripts/check_backup_status.sh create mode 100644 backend/scripts/check_database_status.py create mode 100644 backend/scripts/cleanup_users.py create mode 100644 backend/scripts/create_course_exam_settings.sql create mode 100644 backend/scripts/create_practice_analysis_tables.sql create mode 100644 backend/scripts/create_practice_scenes.sql create mode 100644 backend/scripts/create_practice_table.py create mode 100644 backend/scripts/create_test_data.py create mode 100644 backend/scripts/fix_chinese_data.py create mode 100644 backend/scripts/init_database_unified.sql create mode 100644 backend/scripts/init_db.py create mode 100644 backend/scripts/init_db.sql create mode 100755 backend/scripts/init_project.sh create mode 100644 backend/scripts/kaopeilian_rollback.py create mode 100644 backend/scripts/migrate_env_to_db.py create mode 100644 backend/scripts/migrate_prompts_to_db.py create mode 100644 backend/scripts/mock_data_beauty.sql create mode 100644 backend/scripts/rollback_example.py create mode 100644 backend/scripts/run_practice_scenes_setup.py create mode 100644 backend/scripts/seed_beauty_data.py create mode 100644 backend/scripts/seed_positions.py create mode 100644 backend/scripts/seed_practice_sessions.sql create mode 100755 backend/scripts/seed_statistics_demo_data.py create mode 100644 backend/scripts/seed_statistics_demo_data.sql create mode 100644 backend/scripts/seed_statistics_demo_data_v2.sql create mode 100644 backend/scripts/seed_statistics_for_user6.sql create mode 100644 backend/scripts/simple_init.py create mode 100644 backend/scripts/simple_rollback.py create mode 100644 backend/scripts/sync_core_tables.py create mode 100644 backend/scripts/sync_users_table.py create mode 100644 backend/scripts/update_position_descriptions.py create mode 100644 backend/setup.cfg create mode 100644 backend/simple_main.py create mode 100644 backend/simple_test.py create mode 100644 backend/start.sh create mode 100644 backend/start_backend.py create mode 100644 backend/start_dev.py create mode 100644 backend/start_dev.sh create mode 100644 backend/start_mysql.py create mode 100644 backend/start_remote.py create mode 100755 backend/start_simple.py create mode 100644 backend/test_api.py create mode 100644 backend/test_api_endpoint.py create mode 100644 backend/test_check_schema_fields.py create mode 100644 backend/test_course_7_exam_settings.py create mode 100644 backend/test_exam_records_api.py create mode 100644 backend/test_practice_api.py create mode 100644 backend/test_remote_db.py create mode 100644 backend/test_schema_validation.py create mode 100644 backend/test_statistics_api.py create mode 100644 backend/test_team_api.py create mode 100644 backend/test_team_dashboard.py create mode 100644 backend/test_team_management_api.py create mode 100644 backend/test_user_id4.py create mode 100644 backend/test_user_position_sync.py create mode 100644 backend/test_user_statistics.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/e2e/__init__.py create mode 100644 backend/tests/integration/__init__.py create mode 100644 backend/tests/test_courses.py create mode 100644 backend/tests/test_coze_api.py create mode 100644 backend/tests/test_coze_client.py create mode 100644 backend/tests/test_coze_service.py create mode 100644 backend/tests/test_main.py create mode 100644 backend/tests/test_training.py create mode 100644 backend/tests/test_user_service.py create mode 100644 backend/tests/unit/__init__.py create mode 100644 backend/tests/unit/test_auth.py create mode 100644 backend/verify_exam_settings.py create mode 100644 backend/团队看板功能验证指南.md create mode 100644 backend/将调用工作流-联调过半-考陪练kaopeilian_final_complete_backup_20250923_025629.sql create mode 100644 backend/数据库架构-统一版.md create mode 100644 backend/数据库配置切换说明.md create mode 100755 backend/验证备份质量.py create mode 100644 deploy/docker/docker-compose.admin.yml create mode 100644 deploy/docker/docker-compose.dev.yml create mode 100644 deploy/docker/docker-compose.kpl.yml create mode 100644 deploy/docker/docker-compose.prod-multi.yml create mode 100644 deploy/docker/docker-compose.yml create mode 100644 deploy/nginx/conf.d/admin.conf create mode 100644 deploy/nginx/conf.d/ex.conf create mode 100644 deploy/nginx/conf.d/fw.conf create mode 100644 deploy/nginx/conf.d/hl.conf create mode 100644 deploy/nginx/conf.d/hua.conf create mode 100644 deploy/nginx/conf.d/kaopeilian.conf create mode 100644 deploy/nginx/conf.d/kpl.conf create mode 100644 deploy/nginx/conf.d/pl.conf create mode 100644 deploy/nginx/conf.d/pl.conf.disabled create mode 100644 deploy/nginx/conf.d/xy.conf create mode 100644 deploy/nginx/conf.d/yy.conf create mode 100644 deploy/nginx/nginx.conf create mode 100755 deploy/scripts/auto_update.sh create mode 100755 deploy/scripts/check-config.sh create mode 100644 deploy/scripts/check_environment.sh create mode 100755 deploy/scripts/cleanup_docker.sh create mode 100755 deploy/scripts/deploy.sh create mode 100644 deploy/scripts/diagnose.sh create mode 100755 deploy/scripts/diagnose_dify_network.sh create mode 100644 deploy/scripts/force_restart.sh create mode 100755 deploy/scripts/quick_test_practice.sh create mode 100755 deploy/scripts/quick_test_score_mistakes.sh create mode 100755 deploy/scripts/robust_start.sh create mode 100644 deploy/scripts/setup_environment.sh create mode 100644 deploy/scripts/setup_git_strategy.sh create mode 100755 deploy/scripts/start-dev.sh create mode 100755 deploy/scripts/start-kpl.sh create mode 100755 deploy/scripts/start.sh create mode 100755 deploy/scripts/stop-dev.sh create mode 100755 deploy/scripts/stop-kpl.sh create mode 100755 deploy/scripts/test_course_chat.sh create mode 100755 deploy/scripts/test_statistics_apis.sh create mode 100644 deploy/scripts/validate_config.py create mode 100755 deploy/scripts/webhook_handler.py create mode 100755 deploy/scripts/启动资料预览功能.sh create mode 100755 deploy/scripts/测试资料预览功能.sh create mode 100644 docs/README.md create mode 100644 docs/SETUP.md create mode 100644 docs/同步清单.md create mode 100644 docs/规划/README.md create mode 100644 docs/规划/RIPER-5-CN.md create mode 100644 docs/规划/dify 工作流/恩喜-00-SQL 执行器-考陪练专用.yml create mode 100644 docs/规划/dify 工作流/恩喜-01-知识点分析-考陪练.yml create mode 100644 docs/规划/dify 工作流/恩喜-02-试题生成器-考陪练.yml create mode 100644 docs/规划/dify 工作流/恩喜-03-陪练知识准备-考陪练.yml create mode 100644 docs/规划/dify 工作流/恩喜-04-与课程对话-考陪练.yml create mode 100644 docs/规划/dify 工作流/恩喜-05-智能工牌能力分析与课程推荐-考陪练.yml create mode 100644 docs/规划/dify 工作流/通用-答案判断器-考陪练.yml create mode 100644 docs/规划/dify 工作流/通用-陪练分析报告-考陪练.yml create mode 100644 docs/规划/全链路联调/Ai工作流/coze/Coze-API文档.md create mode 100644 docs/规划/全链路联调/Ai工作流/coze/Coze集成方案.md create mode 100644 docs/规划/全链路联调/Ai工作流/coze/README.md create mode 100644 docs/规划/全链路联调/Ai工作流/coze/⚠️核心差异点速查.md create mode 100644 docs/规划/全链路联调/Ai工作流/coze/✅陪练功能完整开发报告.md create mode 100644 docs/规划/全链路联调/Ai工作流/coze/基础信息.md create mode 100644 docs/规划/全链路联调/Ai工作流/coze/播课/Coze工作流运行API文档.md create mode 100644 docs/规划/全链路联调/Ai工作流/coze/播课/README.md create mode 100644 docs/规划/全链路联调/Ai工作流/coze/播课/播课功能API接口规范.md create mode 100644 docs/规划/全链路联调/Ai工作流/coze/播课/播课功能技术方案.md create mode 100644 docs/规划/全链路联调/Ai工作流/coze/陪练功能API接口规范.md create mode 100644 docs/规划/全链路联调/Ai工作流/coze/陪练功能技术方案.md create mode 100644 docs/规划/全链路联调/Ai工作流/coze/陪练功能数据流程图.md create mode 100644 docs/规划/全链路联调/Ai工作流/dify/Dify_API_Keys_配置管理经验.md create mode 100644 docs/规划/全链路联调/Ai工作流/dify/Dify系统对接分析报告.md create mode 100644 docs/规划/全链路联调/Ai工作流/dify/README.md create mode 100644 docs/规划/全链路联调/Ai工作流/dify/对话流/Dify对话流API文档.md create mode 100644 docs/规划/全链路联调/Ai工作流/dify/对话流/与课程对话功能实施总结.md create mode 100644 docs/规划/全链路联调/Ai工作流/dify/数据库api 服务/README.md create mode 100644 docs/规划/全链路联调/Ai工作流/dify/数据库api 服务/openapi_sql_executor.json create mode 100644 docs/规划/全链路联调/Ai工作流/dify/知识拆解工作流.md create mode 100644 docs/规划/全链路联调/Ai工作流/dify/考试工作流-最终版.md create mode 100644 docs/规划/全链路联调/Ai工作流/dify/考试工作流联调文档.md create mode 100644 docs/规划/全链路联调/Ai工作流/dify/试题生成器的核心提示词与输出示例.md create mode 100644 docs/规划/全链路联调/Ai工作流/知识点拆解工作流.md create mode 100644 docs/规划/全链路联调/Dify-SQL执行器功能开发总结.md create mode 100644 docs/规划/全链路联调/old/Dify-SQL执行器功能开发总结-备份.md create mode 100644 docs/规划/全链路联调/old/一次性完成度核对清单.md create mode 100644 docs/规划/全链路联调/old/实操联调完整Todos清单.md create mode 100644 docs/规划/全链路联调/old/本地数据库连接.md create mode 100644 docs/规划/全链路联调/old/联调经验汇总-完整版备份.md create mode 100644 docs/规划/全链路联调/old/联调结果汇总报告.md create mode 100644 docs/规划/全链路联调/异常处理规范.md create mode 100644 docs/规划/全链路联调/联调经验汇总-完整版备份.md create mode 100644 docs/规划/全链路联调/联调经验汇总.md create mode 100644 docs/规划/全链路联调/规范与约定-团队基线.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/API探索成果总结 2.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/API探索成果总结.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/API探索报告.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/API接口测试清单.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/README 2.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/README.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/完整API测试报告 2.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/完整API测试报告.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/实施总结 2.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/实施总结.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/授权认证 2.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/授权认证.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/智能工牌能力分析-Dify工作流测试报告.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/智能工牌能力分析-配置完成与使用指南.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/智能工牌能力分析实施完成报告 2.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/智能工牌能力分析实施完成报告.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/测试报告-2025-10-15.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/真实数据获取报告.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/获取员工录音信息 2.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/获取员工录音信息.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/获取客户来访列表 2.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/获取客户来访列表.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/获取录音ASR分析结果 2.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/获取录音ASR分析结果.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/获取来访录音信息 2.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/获取来访录音信息.md create mode 100644 docs/规划/全链路联调/言迹智能工牌/音频样本/按时长分类/10-20秒/夏雨沫_12秒_2025-08-17_1.mp3 create mode 100644 docs/规划/全链路联调/言迹智能工牌/音频样本/按时长分类/10-20秒/张永梅_14秒_2025-09-16_2.mp3 create mode 100644 docs/规划/全链路联调/言迹智能工牌/音频样本/按时长分类/10-20秒/张永梅_14秒_2025-10-10_1.mp3 create mode 100644 docs/规划/全链路联调/言迹智能工牌/音频样本/按时长分类/10-20秒/杨敏_13秒_2025-09-15_2.mp3 create mode 100644 docs/规划/全链路联调/言迹智能工牌/音频样本/按时长分类/10-20秒/杨敏_13秒_2025-09-15_3.mp3 create mode 100644 docs/规划/全链路联调/言迹智能工牌/音频样本/按时长分类/10-20秒/杨敏_13秒_2025-09-23_1.mp3 create mode 100644 docs/规划/全链路联调/言迹智能工牌/音频样本/按时长分类/10-20秒/熊媱媱_15秒_2025-06-17_1.mp3 create mode 100644 docs/规划/全链路联调/言迹智能工牌/音频样本/按时长分类/10-20秒/陈谊_11秒_2025-08-29_2.mp3 create mode 100644 docs/规划/全链路联调/言迹智能工牌/音频样本/按时长分类/10-20秒/陈谊_11秒_2025-09-30_1.mp3 create mode 100644 docs/规划/全链路联调/言迹智能工牌/页面布局优化完成报告.md create mode 100644 docs/规划/全链路联调/课程资料预览功能-实施完成报告.md create mode 100644 docs/规划/关于部署/分支管理策略.md create mode 100644 docs/规划/关于部署/本地开发.md create mode 100644 docs/规划/初始沟通文件/NJ的初步设计理念.md create mode 100644 docs/规划/初始沟通文件/考培练其他补充细节需求.txt create mode 100644 docs/规划/初始沟通文件/考陪练系统定制需求功能清单.xml create mode 100644 docs/规划/初始沟通文件/需求确认会议.md create mode 100644 docs/规划/后端开发拆分策略/README.md create mode 100644 docs/规划/后端开发拆分策略/协作机制设计.md create mode 100644 docs/规划/后端开发拆分策略/子agent/00-通用基础/base_prompt.md create mode 100644 docs/规划/后端开发拆分策略/子agent/00-通用基础/essential_docs.md create mode 100644 docs/规划/后端开发拆分策略/子agent/00-通用基础/integration_experience.md create mode 100644 docs/规划/后端开发拆分策略/子agent/00-通用基础/project_structure.md create mode 100644 docs/规划/后端开发拆分策略/子agent/01-Agent-Auth/api_contract.yaml create mode 100644 docs/规划/后端开发拆分策略/子agent/01-Agent-Auth/checklist.md create mode 100644 docs/规划/后端开发拆分策略/子agent/01-Agent-Auth/context.md create mode 100644 docs/规划/后端开发拆分策略/子agent/01-Agent-Auth/dependencies.md create mode 100644 docs/规划/后端开发拆分策略/子agent/01-Agent-Auth/examples/auth_api_example.py create mode 100644 docs/规划/后端开发拆分策略/子agent/01-Agent-Auth/examples/auth_service_example.py create mode 100644 docs/规划/后端开发拆分策略/子agent/01-Agent-Auth/prompt.md create mode 100644 docs/规划/后端开发拆分策略/子agent/02-Agent-User/api_contract.yaml create mode 100644 docs/规划/后端开发拆分策略/子agent/02-Agent-User/checklist.md create mode 100644 docs/规划/后端开发拆分策略/子agent/02-Agent-User/context.md create mode 100644 docs/规划/后端开发拆分策略/子agent/02-Agent-User/examples/README.md create mode 100644 docs/规划/后端开发拆分策略/子agent/02-Agent-User/prompt.md create mode 100644 docs/规划/后端开发拆分策略/子agent/03-Agent-Course/api_contract.yaml create mode 100644 docs/规划/后端开发拆分策略/子agent/03-Agent-Course/checklist.md create mode 100644 docs/规划/后端开发拆分策略/子agent/03-Agent-Course/context.md create mode 100644 docs/规划/后端开发拆分策略/子agent/03-Agent-Course/examples/README.md create mode 100644 docs/规划/后端开发拆分策略/子agent/03-Agent-Course/prompt.md create mode 100644 docs/规划/后端开发拆分策略/子agent/04-Agent-Exam/api_contract.yaml create mode 100644 docs/规划/后端开发拆分策略/子agent/04-Agent-Exam/checklist.md create mode 100644 docs/规划/后端开发拆分策略/子agent/04-Agent-Exam/context.md create mode 100644 docs/规划/后端开发拆分策略/子agent/04-Agent-Exam/examples/README.md create mode 100644 docs/规划/后端开发拆分策略/子agent/04-Agent-Exam/prompt.md create mode 100644 docs/规划/后端开发拆分策略/子agent/05-Agent-Training/api_contract.yaml create mode 100644 docs/规划/后端开发拆分策略/子agent/05-Agent-Training/checklist.md create mode 100644 docs/规划/后端开发拆分策略/子agent/05-Agent-Training/context.md create mode 100644 docs/规划/后端开发拆分策略/子agent/05-Agent-Training/examples/README.md create mode 100644 docs/规划/后端开发拆分策略/子agent/05-Agent-Training/prompt.md create mode 100644 docs/规划/后端开发拆分策略/子agent/06-Agent-Analytics/api_contract.yaml create mode 100644 docs/规划/后端开发拆分策略/子agent/06-Agent-Analytics/checklist.md create mode 100644 docs/规划/后端开发拆分策略/子agent/06-Agent-Analytics/context.md create mode 100644 docs/规划/后端开发拆分策略/子agent/06-Agent-Analytics/examples/README.md create mode 100644 docs/规划/后端开发拆分策略/子agent/06-Agent-Analytics/prompt.md create mode 100644 docs/规划/后端开发拆分策略/子agent/07-Agent-Admin/api_contract.yaml create mode 100644 docs/规划/后端开发拆分策略/子agent/07-Agent-Admin/checklist.md create mode 100644 docs/规划/后端开发拆分策略/子agent/07-Agent-Admin/context.md create mode 100644 docs/规划/后端开发拆分策略/子agent/07-Agent-Admin/examples/README.md create mode 100644 docs/规划/后端开发拆分策略/子agent/07-Agent-Admin/prompt.md create mode 100644 docs/规划/后端开发拆分策略/子agent/08-Agent-Coze/api_contract.yaml create mode 100644 docs/规划/后端开发拆分策略/子agent/08-Agent-Coze/checklist.md create mode 100644 docs/规划/后端开发拆分策略/子agent/08-Agent-Coze/context.md create mode 100644 docs/规划/后端开发拆分策略/子agent/08-Agent-Coze/examples/README.md create mode 100644 docs/规划/后端开发拆分策略/子agent/08-Agent-Coze/prompt.md create mode 100644 docs/规划/后端开发拆分策略/子agent/README.md create mode 100755 docs/规划/后端开发拆分策略/子agent/package_agent.sh create mode 100644 docs/规划/后端开发拆分策略/子agent/prompt_template.md create mode 100644 docs/规划/后端开发拆分策略/子agent/test_agent_understanding.md create mode 100755 docs/规划/后端开发拆分策略/子agent/update_all_agents.sh create mode 100644 docs/规划/后端开发拆分策略/子agent/云端协作最佳实践.md create mode 100644 docs/规划/后端开发拆分策略/子agent/使用时的最佳实践.md create mode 100644 docs/规划/后端开发拆分策略/子agent/创建完成说明.md create mode 100644 docs/规划/后端开发拆分策略/子agent/快速使用指南.md create mode 100644 docs/规划/后端开发拆分策略/子agent集成todos清单.md create mode 100644 docs/规划/后端开发拆分策略/开发规范文档.md create mode 100644 docs/规划/后端开发拆分策略/快速开始指南.md create mode 100644 docs/规划/后端开发拆分策略/模块分工指南.md create mode 100644 docs/规划/后端开发拆分策略/统一基础代码.md create mode 100644 docs/规划/后端开发拆分策略/质量保证机制.md create mode 100644 docs/规划/后端开发拆分策略/配置一致性检查清单.md create mode 100644 docs/规划/后端开发拆分策略/配置管理使用说明.md create mode 100644 docs/规划/后端开发拆分策略/项目脚手架创建完成.md create mode 100644 docs/规划/后端开发拆分策略/项目脚手架结构.md create mode 100644 docs/规划/团队基线.md create mode 100644 docs/规划/完成审核的文件备份/Ai_EDU_Frontend_Plan.md create mode 100644 docs/规划/完成审核的文件备份/README.md create mode 100644 docs/规划/完成审核的文件备份/导航栏规划.md create mode 100644 docs/规划/完成审核的文件备份/考试工作流联调文档 2.md create mode 100644 docs/规划/完成审核的文件备份/考试工作流联调文档.md create mode 100644 docs/规划/完成审核的文件备份/页面与按钮速查.md create mode 100644 docs/规划/客户的Ai应用匹配/应用配置清单.md create mode 100644 docs/规划/数据库-里程碑备份/1-将调用工作流-联调过半-考陪练kaopeilian_final_complete_backup_20250923_025629 2.sql create mode 100644 docs/规划/数据库-里程碑备份/1-将调用工作流-联调过半-考陪练kaopeilian_final_complete_backup_20250923_025629.sql create mode 100644 docs/规划/数据库-里程碑备份/10-细节分支开发前-开发库-20251111_191631.sql create mode 100644 docs/规划/数据库-里程碑备份/10-细节分支开发前-生产库-20251111_191631.sql create mode 100644 docs/规划/数据库-里程碑备份/2-联调 dify 知识拆解已完成 2.sql create mode 100644 docs/规划/数据库-里程碑备份/2-联调 dify 知识拆解已完成.sql create mode 100644 docs/规划/数据库-里程碑备份/3、成绩报告与错题本_20251013_171138 2.sql create mode 100644 docs/规划/数据库-里程碑备份/3、成绩报告与错题本_20251013_171138.sql create mode 100644 docs/规划/数据库-里程碑备份/4-完成陪练模块_20251015_010038 2.sql create mode 100644 docs/规划/数据库-里程碑备份/4-完成陪练模块_20251015_010038.sql create mode 100644 docs/规划/数据库-里程碑备份/5-准备开始智能工牌模块-20251016-041011 2.sql create mode 100644 docs/规划/数据库-里程碑备份/5-准备开始智能工牌模块-20251016-041011.sql create mode 100644 docs/规划/数据库-里程碑备份/6-完成智能工牌模块-20251016_051345 2.sql create mode 100644 docs/规划/数据库-里程碑备份/6-完成智能工牌模块-20251016_051345.sql create mode 100644 docs/规划/数据库-里程碑备份/7-完成数据分析模块-20251016_075159.sql create mode 100644 docs/规划/数据库-里程碑备份/8-完成1.0 收尾工作-20251017_015836.sql create mode 100644 docs/规划/数据库-里程碑备份/8-时区修复前备份-20251017_053824.sql create mode 100644 docs/规划/数据库-里程碑备份/9-完成服务器测试-20251017_084942.sql create mode 100644 docs/规划/数据盘规划方案.md create mode 100644 docs/规划/服务器端 MYSQL.md create mode 100644 docs/规划/流式输出视觉呈现规范.md create mode 100644 docs/规划/瑞小美AI接入规范.md create mode 100644 docs/规划/瑞小美系统技术栈标准与字符标准.md create mode 100644 docs/规划/系统架构.md create mode 100644 docs/规划/考陪练系统API对接规范.md create mode 100644 docs/规划/部署架构-统一版.md create mode 100644 docs/项目状态快照.md create mode 100644 frontend/.editorconfig create mode 100644 frontend/.env.development create mode 100644 frontend/.eslintrc.cjs create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc create mode 100644 frontend/Dockerfile create mode 100644 frontend/Dockerfile.dev create mode 100644 frontend/Dockerfile.shared create mode 100644 frontend/Dockerfile.simple create mode 100644 frontend/README.md create mode 100644 frontend/docker/default.conf create mode 100644 frontend/docker/nginx.conf create mode 100755 frontend/docker/scripts/build.sh create mode 100755 frontend/docker/scripts/deploy.sh create mode 100644 frontend/env.example create mode 100644 frontend/index.html create mode 100644 frontend/nginx-shared.conf create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/bot-avatar.svg create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/manifest.json create mode 100644 frontend/public/pdfjs/cmaps/78-EUC-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/78-EUC-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/78-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/78-RKSJ-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/78-RKSJ-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/78-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/78ms-RKSJ-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/78ms-RKSJ-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/83pv-RKSJ-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/90ms-RKSJ-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/90ms-RKSJ-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/90msp-RKSJ-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/90msp-RKSJ-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/90pv-RKSJ-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/90pv-RKSJ-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Add-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Add-RKSJ-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Add-RKSJ-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Add-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-CNS1-0.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-CNS1-1.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-CNS1-2.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-CNS1-3.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-CNS1-4.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-CNS1-5.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-CNS1-6.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-CNS1-UCS2.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-GB1-0.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-GB1-1.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-GB1-2.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-GB1-3.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-GB1-4.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-GB1-5.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-GB1-UCS2.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-Japan1-0.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-Japan1-1.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-Japan1-2.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-Japan1-3.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-Japan1-4.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-Japan1-5.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-Japan1-6.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-Korea1-0.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-Korea1-1.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-Korea1-2.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Adobe-Korea1-UCS2.bcmap create mode 100644 frontend/public/pdfjs/cmaps/B5-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/B5-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/B5pc-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/B5pc-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/CNS-EUC-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/CNS-EUC-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/CNS1-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/CNS1-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/CNS2-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/CNS2-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/ETHK-B5-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/ETHK-B5-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/ETen-B5-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/ETen-B5-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/ETenms-B5-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/ETenms-B5-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/EUC-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/EUC-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Ext-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Ext-RKSJ-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Ext-RKSJ-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Ext-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GB-EUC-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GB-EUC-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GB-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GB-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GBK-EUC-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GBK-EUC-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GBK2K-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GBK2K-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GBKp-EUC-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GBKp-EUC-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GBT-EUC-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GBT-EUC-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GBT-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GBT-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GBTpc-EUC-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GBTpc-EUC-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GBpc-EUC-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/GBpc-EUC-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/HKdla-B5-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/HKdla-B5-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/HKdlb-B5-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/HKdlb-B5-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/HKgccs-B5-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/HKgccs-B5-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/HKm314-B5-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/HKm314-B5-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/HKm471-B5-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/HKm471-B5-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/HKscs-B5-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/HKscs-B5-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Hankaku.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Hiragana.bcmap create mode 100644 frontend/public/pdfjs/cmaps/KSC-EUC-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/KSC-EUC-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/KSC-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/KSC-Johab-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/KSC-Johab-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/KSC-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/KSCms-UHC-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/KSCms-UHC-HW-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/KSCms-UHC-HW-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/KSCms-UHC-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/KSCpc-EUC-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/KSCpc-EUC-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Katakana.bcmap create mode 100644 frontend/public/pdfjs/cmaps/LICENSE create mode 100644 frontend/public/pdfjs/cmaps/NWP-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/NWP-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/RKSJ-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/RKSJ-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/Roman.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniCNS-UCS2-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniCNS-UCS2-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniCNS-UTF16-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniCNS-UTF16-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniCNS-UTF32-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniCNS-UTF32-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniCNS-UTF8-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniCNS-UTF8-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniGB-UCS2-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniGB-UCS2-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniGB-UTF16-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniGB-UTF16-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniGB-UTF32-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniGB-UTF32-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniGB-UTF8-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniGB-UTF8-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJIS-UCS2-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJIS-UCS2-HW-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJIS-UCS2-HW-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJIS-UCS2-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJIS-UTF16-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJIS-UTF16-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJIS-UTF32-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJIS-UTF32-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJIS-UTF8-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJIS-UTF8-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJIS2004-UTF16-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJIS2004-UTF16-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJIS2004-UTF32-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJIS2004-UTF32-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJIS2004-UTF8-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJIS2004-UTF8-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJISPro-UCS2-HW-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJISPro-UCS2-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJISPro-UTF8-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJISX0213-UTF32-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJISX0213-UTF32-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJISX02132004-UTF32-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniJISX02132004-UTF32-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniKS-UCS2-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniKS-UCS2-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniKS-UTF16-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniKS-UTF16-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniKS-UTF32-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniKS-UTF32-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniKS-UTF8-H.bcmap create mode 100644 frontend/public/pdfjs/cmaps/UniKS-UTF8-V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/V.bcmap create mode 100644 frontend/public/pdfjs/cmaps/WP-Symbol.bcmap create mode 100644 frontend/public/pdfjs/standard_fonts/FoxitDingbats.pfb create mode 100644 frontend/public/pdfjs/standard_fonts/FoxitFixed.pfb create mode 100644 frontend/public/pdfjs/standard_fonts/FoxitFixedBold.pfb create mode 100644 frontend/public/pdfjs/standard_fonts/FoxitFixedBoldItalic.pfb create mode 100644 frontend/public/pdfjs/standard_fonts/FoxitFixedItalic.pfb create mode 100644 frontend/public/pdfjs/standard_fonts/FoxitSerif.pfb create mode 100644 frontend/public/pdfjs/standard_fonts/FoxitSerifBold.pfb create mode 100644 frontend/public/pdfjs/standard_fonts/FoxitSerifBoldItalic.pfb create mode 100644 frontend/public/pdfjs/standard_fonts/FoxitSerifItalic.pfb create mode 100644 frontend/public/pdfjs/standard_fonts/FoxitSymbol.pfb create mode 100644 frontend/public/pdfjs/standard_fonts/LICENSE_FOXIT create mode 100644 frontend/public/pdfjs/standard_fonts/LICENSE_LIBERATION create mode 100644 frontend/public/pdfjs/standard_fonts/LiberationSans-Bold.ttf create mode 100644 frontend/public/pdfjs/standard_fonts/LiberationSans-BoldItalic.ttf create mode 100644 frontend/public/pdfjs/standard_fonts/LiberationSans-Italic.ttf create mode 100644 frontend/public/pdfjs/standard_fonts/LiberationSans-Regular.ttf create mode 100644 frontend/public/test-practice-records.html create mode 100755 frontend/scripts/check_environment.sh create mode 100755 frontend/scripts/switch_environment.sh create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/__tests__/auth.test.ts create mode 100644 frontend/src/api/admin/dashboard.ts create mode 100644 frontend/src/api/admin/position.ts create mode 100644 frontend/src/api/admin/user.ts create mode 100644 frontend/src/api/analysis/index.ts create mode 100644 frontend/src/api/auth/index.ts create mode 100644 frontend/src/api/broadcast.ts create mode 100644 frontend/src/api/config.ts create mode 100644 frontend/src/api/course/index.ts create mode 100644 frontend/src/api/courseChat.ts create mode 100644 frontend/src/api/coze/index.ts create mode 100644 frontend/src/api/dashboard.ts create mode 100644 frontend/src/api/exam.ts create mode 100644 frontend/src/api/exam/index.ts create mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/api/manager/index.ts create mode 100644 frontend/src/api/manager/practice.ts create mode 100644 frontend/src/api/manager/scores.ts create mode 100644 frontend/src/api/material.ts create mode 100644 frontend/src/api/mock/admin-dashboard-course-completion.ts create mode 100644 frontend/src/api/mock/admin-dashboard-stats.ts create mode 100644 frontend/src/api/mock/admin-dashboard-user-growth.ts create mode 100644 frontend/src/api/mock/admin-positions-tree.ts create mode 100644 frontend/src/api/mock/admin-positions.ts create mode 100644 frontend/src/api/mock/admin-users-1-reset-password.ts create mode 100644 frontend/src/api/mock/admin-users-statistics.ts create mode 100644 frontend/src/api/mock/admin-users.ts create mode 100644 frontend/src/api/notification.ts create mode 100644 frontend/src/api/practice.ts create mode 100644 frontend/src/api/practiceScene.ts create mode 100644 frontend/src/api/request.ts create mode 100644 frontend/src/api/score.ts create mode 100644 frontend/src/api/statistics.ts create mode 100644 frontend/src/api/systemLogs.ts create mode 100644 frontend/src/api/task.ts create mode 100644 frontend/src/api/teamDashboard.ts create mode 100644 frontend/src/api/teamManagement.ts create mode 100644 frontend/src/api/trainee/index.ts create mode 100644 frontend/src/api/user/index.ts create mode 100644 frontend/src/components/NotificationBell.vue create mode 100644 frontend/src/components/TextChat.vue create mode 100644 frontend/src/components/VoiceChat.vue create mode 100644 frontend/src/components/common/EnvironmentBadge.vue create mode 100644 frontend/src/components/common/README.md create mode 100644 frontend/src/config/env.ts create mode 100644 frontend/src/layout/index.vue create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/guard.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/stores/practiceStore.ts create mode 100644 frontend/src/style/index.scss create mode 100644 frontend/src/style/variables.scss create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/src/test/utils.ts create mode 100644 frontend/src/types/broadcast.ts create mode 100644 frontend/src/types/material.ts create mode 100644 frontend/src/types/practice.ts create mode 100644 frontend/src/utils/__tests__/auth.test.ts create mode 100644 frontend/src/utils/__tests__/errorHandler.test.ts create mode 100644 frontend/src/utils/auth.ts create mode 100644 frontend/src/utils/cozeVoiceClient.ts create mode 100644 frontend/src/utils/errorHandler.ts create mode 100644 frontend/src/utils/http.ts create mode 100644 frontend/src/utils/loadingManager.ts create mode 100644 frontend/src/views/admin/dashboard.vue create mode 100644 frontend/src/views/admin/logs.vue create mode 100644 frontend/src/views/admin/position-management.vue create mode 100644 frontend/src/views/admin/user-management.vue create mode 100644 frontend/src/views/analysis/mistakes.vue create mode 100644 frontend/src/views/analysis/report.vue create mode 100644 frontend/src/views/analysis/statistics.vue create mode 100644 frontend/src/views/dashboard/index.vue create mode 100644 frontend/src/views/error/404.vue create mode 100644 frontend/src/views/exam/practice.vue create mode 100644 frontend/src/views/login/index.vue create mode 100644 frontend/src/views/manager/assignment-center.vue create mode 100644 frontend/src/views/manager/course-management.vue create mode 100644 frontend/src/views/manager/course-management.vue.current create mode 100644 frontend/src/views/manager/edit-course.vue create mode 100644 frontend/src/views/manager/growth-path-management.vue create mode 100644 frontend/src/views/manager/practice-scene-management.vue create mode 100644 frontend/src/views/manager/student-practice.vue create mode 100644 frontend/src/views/manager/student-scores.vue create mode 100644 frontend/src/views/manager/team-dashboard.vue create mode 100644 frontend/src/views/manager/team-management.vue create mode 100644 frontend/src/views/register/index.vue create mode 100644 frontend/src/views/trainee/ai-practice-center.vue create mode 100644 frontend/src/views/trainee/ai-practice-coze.vue create mode 100644 frontend/src/views/trainee/ai-practice.vue create mode 100644 frontend/src/views/trainee/audio-player.vue create mode 100644 frontend/src/views/trainee/broadcast-course.vue create mode 100644 frontend/src/views/trainee/chat-course.vue create mode 100644 frontend/src/views/trainee/course-center.vue create mode 100644 frontend/src/views/trainee/course-detail.vue create mode 100644 frontend/src/views/trainee/exam-result.vue create mode 100644 frontend/src/views/trainee/growth-path.vue create mode 100644 frontend/src/views/trainee/practice-records.vue create mode 100644 frontend/src/views/trainee/practice-report.vue create mode 100644 frontend/src/views/trainee/score-query.vue create mode 100644 frontend/src/views/user/change-password.vue create mode 100644 frontend/src/views/user/notifications.vue create mode 100644 frontend/src/views/user/profile.vue create mode 100644 frontend/src/views/user/settings.vue create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.prod.ts create mode 100644 frontend/vite.config.ts create mode 100644 frontend/vite.config.ts.timestamp-1758830290264-a627566ebc111.mjs create mode 100644 frontend/vite.config.ts.timestamp-1758830334806-d249cef516968.mjs create mode 100644 frontend/vitest.config.ts create mode 100644 frontend/前后端接口约定.md create mode 100644 frontend/导航栏规划.md create mode 100644 frontend/课程资料预览功能测试指南.md create mode 100644 frontend/页面与按钮速查.md create mode 100644 tests/test_ai_functions.py create mode 100644 tests/test_course_chat.py create mode 100644 tests/test_coze_conversation.py create mode 100644 tests/test_exam_api.py create mode 100644 tests/test_frontend_login.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_login.html create mode 100644 tests/test_login_manual.py create mode 100644 tests/test_practice_api.py create mode 100644 tests/test_practice_records_frontend.html create mode 100644 tests/test_practice_scenes_api.html create mode 100644 tests/test_practice_sessions_api.py create mode 100644 tests/test_score_report_api.py create mode 100644 tests/test_update_profile.py create mode 100644 知识库/BUG_REPORT.md create mode 100644 知识库/Coze集成方案.md create mode 100644 知识库/DIFY_API_KEYS_UPDATE_SUMMARY.md create mode 100644 知识库/DIFY_QUICK_REFERENCE.md create mode 100644 知识库/Dify工作流-SQL执行器使用指南.md create mode 100644 知识库/Dify工作流-知识点管理SQL.md create mode 100644 知识库/backup_summary.md create mode 100644 知识库/backups/before-restore-1.0/backup_20251017_033000.sql create mode 100644 知识库/backups/before-restore-1.0/backup_20251017_033012.sql create mode 100644 知识库/backups/before-restore-1.0/backup_20251017_033024.sql create mode 100644 知识库/backups/before-restore-1.0/backup_20251017_033041.sql create mode 100644 知识库/backups/pre-production/backup_before_production_20251016_080603.sql create mode 100644 知识库/backups/updates/backup_20250926_044558_database.sql create mode 100644 知识库/backups/updates/backup_20250926_051946_database.sql create mode 100644 知识库/backups/updates/backup_20251016_052230_database.sql create mode 100644 知识库/backups/updates/backup_20251016_075849_database.sql create mode 100644 知识库/backups/updates/backup_before_import_20251016_060537.sql create mode 100644 知识库/backups/updates/sql/backup_before_init_20250923_011804.sql create mode 100644 知识库/backups/updates/sql/backup_before_regenerate_20250923_023604.sql create mode 100644 知识库/backups/updates/sql/backup_before_rollback_20250923_013456.sql create mode 100644 知识库/backups/updates/sql/full_database_backup_20250923_014658.sql create mode 100644 知识库/backups/updates/sql/kaopeilian_complete_backup_20250923_025548.sql create mode 100644 知识库/backups/updates/sql/kaopeilian_complete_fixed_comments_20250923_025109.sql create mode 100644 知识库/backups/updates/sql/kaopeilian_final_complete_backup_20250923_025629.sql create mode 100644 知识库/backups/updates/sql/kaopeilian_super_complete_backup_20250923_025622.sql create mode 100644 知识库/download_yanji_audios.py create mode 100644 知识库/insert_enhancement_features_data.sql create mode 100644 知识库/kpl域名访问问题-已解决.md create mode 100644 知识库/migrate_timezone_data.sql create mode 100644 知识库/migrate_timezone_data_v2.sql create mode 100644 知识库/migrate_timezone_simple.sql create mode 100644 知识库/token.txt create mode 100755 知识库/参考代码/coze-chat-frontend/eslint.config.js create mode 100644 知识库/参考代码/coze-chat-frontend/package-lock.json create mode 100755 知识库/参考代码/coze-chat-frontend/src/App.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/assets/images/home_bg.jpg create mode 100755 知识库/参考代码/coze-chat-frontend/src/assets/images/menu1.png create mode 100755 知识库/参考代码/coze-chat-frontend/src/assets/images/menu2.png create mode 100755 知识库/参考代码/coze-chat-frontend/src/assets/images/menu3.png create mode 100755 知识库/参考代码/coze-chat-frontend/src/assets/images/menu4.png create mode 100755 知识库/参考代码/coze-chat-frontend/src/assets/images/menu5.png create mode 100755 知识库/参考代码/coze-chat-frontend/src/assets/images/menu6.png create mode 100755 知识库/参考代码/coze-chat-frontend/src/assets/images/menu7.png create mode 100755 知识库/参考代码/coze-chat-frontend/src/assets/images/menu8.png create mode 100755 知识库/参考代码/coze-chat-frontend/src/assets/images/training_logo.png create mode 100755 知识库/参考代码/coze-chat-frontend/src/assets/images/user.jpg create mode 100755 知识库/参考代码/coze-chat-frontend/src/components/MessageList/index.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/components/MessageList/index.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/components/SenderBox/index.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/components/SenderBox/index.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/hooks/index.ts create mode 100755 知识库/参考代码/coze-chat-frontend/src/hooks/use-media-query.ts create mode 100755 知识库/参考代码/coze-chat-frontend/src/index.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/main.tsx create mode 100644 知识库/参考代码/coze-chat-frontend/src/pages/AudioTest/index.scss create mode 100644 知识库/参考代码/coze-chat-frontend/src/pages/AudioTest/index.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Chat/Header/index.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Chat/Header/index.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Chat/MessageList/index.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Chat/MessageList/index.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Chat/Nav/index.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Chat/Nav/index.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Chat/SenderBox/index.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Chat/SenderBox/index.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Chat/SiderNav/index.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Chat/SiderNav/index.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Chat/index.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Chat/index.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Content/index.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Content/index.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Exam/index.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Home/index.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Home/index.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/NewChat/index.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Training/TextChat.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Training/TextChat.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Training/VoiceChat.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Training/VoiceChat.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/pages/Training/index.tsx create mode 100755 知识库/参考代码/coze-chat-frontend/src/server/ai.ts create mode 100644 知识库/参考代码/coze-chat-frontend/src/server/api.ts create mode 100755 知识库/参考代码/coze-chat-frontend/src/server/global.ts create mode 100755 知识库/参考代码/coze-chat-frontend/src/server/type.ts create mode 100644 知识库/参考代码/coze-chat-frontend/src/stores/BotStore.ts create mode 100755 知识库/参考代码/coze-chat-frontend/src/stores/ChatStore.ts create mode 100755 知识库/参考代码/coze-chat-frontend/src/stores/ExamStore.ts create mode 100755 知识库/参考代码/coze-chat-frontend/src/stores/NewChatStore.ts create mode 100755 知识库/参考代码/coze-chat-frontend/src/stores/TrainingStore.ts create mode 100755 知识库/参考代码/coze-chat-frontend/src/stores/config.ts create mode 100755 知识库/参考代码/coze-chat-frontend/src/stores/index.ts create mode 100755 知识库/参考代码/coze-chat-frontend/src/stores/utils.ts create mode 100755 知识库/参考代码/coze-chat-frontend/src/style/functions.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/style/global.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/style/iconfonts/iconfont.css create mode 100755 知识库/参考代码/coze-chat-frontend/src/style/iconfonts/iconfont.woff2 create mode 100755 知识库/参考代码/coze-chat-frontend/src/style/mixins/fontSize.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/style/mixins/interval.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/style/variables.scss create mode 100755 知识库/参考代码/coze-chat-frontend/src/utils/api.ts create mode 100755 知识库/参考代码/coze-chat-frontend/src/utils/request.ts create mode 100755 知识库/参考代码/coze-chat-frontend/src/utils/tools.ts create mode 100644 知识库/参考代码/coze-chat-frontend/src/utils/tts.ts create mode 100755 知识库/参考代码/coze-chat-frontend/src/vite-env.d.ts create mode 100755 知识库/参考代码/coze-chat-frontend/tsconfig.app.json create mode 100755 知识库/参考代码/coze-chat-frontend/tsconfig.json create mode 100644 知识库/参考代码/python_dev_project/.env.example create mode 100644 知识库/参考代码/python_dev_project/Makefile create mode 100644 知识库/参考代码/python_dev_project/docs/api_contract_dify.yaml create mode 100644 知识库/参考代码/python_dev_project/docs/api_contracts/exam_api_contract.yaml create mode 100644 知识库/参考代码/python_dev_project/docs/dify_integration.md create mode 100644 知识库/参考代码/python_dev_project/docs/modules/exam_module.md create mode 100644 知识库/参考代码/python_dev_project/src/api/v1/dify_gateway.py create mode 100644 知识库/参考代码/python_dev_project/src/api/v1/exams.py create mode 100644 知识库/参考代码/python_dev_project/src/api/v1/router.py create mode 100644 知识库/参考代码/python_dev_project/src/core/deps.py create mode 100644 知识库/参考代码/python_dev_project/src/models/__init__.py create mode 100644 知识库/参考代码/python_dev_project/src/models/base.py create mode 100644 知识库/参考代码/python_dev_project/src/models/exam.py create mode 100644 知识库/参考代码/python_dev_project/src/models/user.py create mode 100644 知识库/参考代码/python_dev_project/src/schemas/__init__.py create mode 100644 知识库/参考代码/python_dev_project/src/schemas/base.py create mode 100644 知识库/参考代码/python_dev_project/src/schemas/exam.py create mode 100644 知识库/参考代码/python_dev_project/src/services/__init__.py create mode 100644 知识库/参考代码/python_dev_project/src/services/ai/__init__.py create mode 100644 知识库/参考代码/python_dev_project/src/services/ai/dify/__init__.py create mode 100644 知识库/参考代码/python_dev_project/src/services/ai/dify/client.py create mode 100644 知识库/参考代码/python_dev_project/src/services/ai/dify/exceptions.py create mode 100644 知识库/参考代码/python_dev_project/src/services/ai/dify/models.py create mode 100644 知识库/参考代码/python_dev_project/src/services/ai/dify/service.py create mode 100644 知识库/参考代码/python_dev_project/src/services/exam_service.py create mode 100644 知识库/参考代码/python_dev_project/src/utils/cache.py create mode 100644 知识库/参考代码/python_dev_project/tests/api/v1/test_dify_gateway.py create mode 100644 知识库/参考代码/python_dev_project/tests/conftest.py create mode 100644 知识库/参考代码/python_dev_project/tests/services/__init__.py create mode 100644 知识库/参考代码/python_dev_project/tests/services/ai/__init__.py create mode 100644 知识库/参考代码/python_dev_project/tests/services/ai/dify/__init__.py create mode 100644 知识库/参考代码/python_dev_project/tests/services/ai/dify/test_client.py create mode 100644 知识库/参考代码/python_dev_project/tests/services/ai/dify/test_models.py create mode 100644 知识库/参考代码/python_dev_project/tests/services/ai/dify/test_service.py create mode 100644 知识库/参考代码/python_dev_project/tests/test_exam.py create mode 100644 知识库/参考代码/python_dev_project/tests/test_exam_api.py create mode 100644 知识库/员工同步功能总结.md create mode 100644 知识库/备份摘要.md create mode 100644 知识库/安装指南.md create mode 100644 知识库/开发环境使用指南.md create mode 100644 知识库/开发记录/DIFY_API_KEYS_配置完成报告.md create mode 100644 知识库/开发记录/Dify-DELETE权限验证报告.md create mode 100644 知识库/开发记录/Dify系统对接分析报告.md create mode 100644 知识库/开发记录/双系统部署完成报告.md create mode 100644 知识库/开发记录/员工同步实施报告-最终版.md create mode 100644 知识库/开发记录/文档整理完成报告.md create mode 100644 知识库/开发记录/登录问题解决报告.md create mode 100644 知识库/开发记录/真实取库落库确认报告.md create mode 100644 知识库/开发记录/管理员页面数据对接完成报告.md create mode 100644 知识库/开发记录/系统增强功能完成报告.md create mode 100644 知识库/开发记录/统计分析数据注入完成报告.md create mode 100644 知识库/开发记录/课程资料预览功能-实施完成报告.md create mode 100644 知识库/开发记录/资料34知识点清理完成报告.md create mode 100644 知识库/开发记录/远程HTTP数据库写入服务开发情况报告.md create mode 100644 知识库/开发记录/问题报告.md create mode 100644 知识库/开发记录/陪练记录问题排查报告.md create mode 100644 知识库/清空资料知识点.sql create mode 100644 知识库/清除浏览器缓存指南.md create mode 100644 知识库/瑞小美岗位数据总结.md create mode 100644 知识库/用户管理页面调试指南.md create mode 100644 知识库/知识点删除问题分析.md create mode 100644 知识库/系统登录账号密码.md create mode 100644 知识库/统计分析功能-快速测试指南.md create mode 100644 知识库/部署完成-快速参考.txt create mode 100644 知识库/配置Docker代理.md create mode 100644 知识库/配置说明.md create mode 100644 知识库/问题修复/CORS问题修复记录.md create mode 100644 知识库/问题修复/文件上传和AI分析问题修复报告.md create mode 100644 知识库/问题修复/文件上传失败问题修复报告.md create mode 100644 知识库/问题修复/用户管理页面修复说明.md create mode 100644 知识库/问题修复/资料上传数据库持久化问题修复报告.md create mode 100644 知识库/问题修复/问题修复报告-姓名职位与课程真实性.md create mode 100644 知识库/问题修复/问题修复记录.md create mode 100644 知识库/陪练记录问题排查指南.md diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..3725d47 --- /dev/null +++ b/.cursorignore @@ -0,0 +1 @@ +# 不忽略任何文件,所有文件均可被Cursor访问和编辑 diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..c9d5aa2 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,18 @@ +# Cursor 规则配置文件 +# 允许查看和编辑私钥文件 + +# 文件访问规则 +- 允许查看 .pem 文件 +- 允许查看 .key 文件 +- 允许查看 .crt 文件 +- 允许查看 .cert 文件 + +# 安全提醒 +- 私钥文件包含敏感信息,请谨慎处理 +- 建议使用环境变量管理密钥 +- 不要将私钥提交到版本控制系统 + +# 项目特定规则 +- 本项目使用 Python + Vue3 + MySQL + FastAPI +- 支持 Docker 容器化部署 +- 使用中文注释和文档 diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..632e3d6 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,142 @@ +--- +kind: pipeline +type: docker +name: test-deploy + +# 仅在 test 分支触发测试环境部署 +trigger: + branch: + - test + event: + - push + +steps: + # Step 1: 构建后端镜像 + - name: build-backend + image: plugins/docker + settings: + registry: registry.cn-shenzhen.aliyuncs.com + repo: registry.cn-shenzhen.aliyuncs.com/ruimeiyun/kaopeilian-backend + username: + from_secret: docker_username + password: + from_secret: docker_password + dockerfile: backend/Dockerfile + context: backend + tags: + - test + - ${DRONE_COMMIT_SHA:0:8} + + # Step 2: 构建前端镜像 + - name: build-frontend + image: plugins/docker + settings: + registry: registry.cn-shenzhen.aliyuncs.com + repo: registry.cn-shenzhen.aliyuncs.com/ruimeiyun/kaopeilian-frontend + username: + from_secret: docker_username + password: + from_secret: docker_password + dockerfile: frontend/Dockerfile + context: frontend + tags: + - test + - ${DRONE_COMMIT_SHA:0:8} + + # Step 3: 部署到测试服务器 + - name: deploy-test + image: appleboy/drone-ssh + settings: + host: 47.107.172.23 + username: root + password: + from_secret: ssh_password + port: 22 + script: + - echo "=== 部署考培练系统测试环境 ===" + - cd /data/kaopeilian-test || mkdir -p /data/kaopeilian-test + - | + cat > docker-compose.yml << 'EOF' + version: '3.8' + services: + backend: + image: registry.cn-shenzhen.aliyuncs.com/ruimeiyun/kaopeilian-backend:test + container_name: kaopeilian-backend-test + restart: always + ports: + - "18000:8000" + environment: + - DATABASE_URL=${DATABASE_URL} + - REDIS_HOST=${REDIS_HOST} + - REDIS_PORT=${REDIS_PORT} + - REDIS_PASSWORD=${REDIS_PASSWORD} + networks: + - kaopeilian-net + + frontend: + image: registry.cn-shenzhen.aliyuncs.com/ruimeiyun/kaopeilian-frontend:test + container_name: kaopeilian-frontend-test + restart: always + ports: + - "13001:80" + depends_on: + - backend + networks: + - kaopeilian-net + + networks: + kaopeilian-net: + driver: bridge + EOF + - docker-compose pull + - docker-compose up -d + - docker ps | grep kaopeilian + - echo "=== 部署完成 ===" + + # Step 4: 通知部署结果 + - name: notify + image: plugins/webhook + settings: + urls: + from_secret: webhook_url + content_type: application/json + template: | + { + "msgtype": "text", + "text": { + "content": "🚀 考培练系统测试环境部署完成\n分支: ${DRONE_BRANCH}\n提交: ${DRONE_COMMIT_SHA:0:8}\n作者: ${DRONE_COMMIT_AUTHOR}" + } + } + when: + status: + - success + - failure + +--- +kind: pipeline +type: docker +name: code-check + +# 所有分支推送时进行代码检查 +trigger: + event: + - push + - pull_request + +steps: + # Python 代码检查 + - name: python-lint + image: python:3.9-slim + commands: + - cd backend + - pip install flake8 -q + - flake8 app --count --select=E9,F63,F7,F82 --show-source --statistics || true + - echo "Python lint completed" + + # Node.js 代码检查 + - name: frontend-lint + image: node:18-alpine + commands: + - cd frontend + - npm install -q 2>/dev/null || true + - npm run lint 2>/dev/null || echo "Frontend lint completed" diff --git a/.env.admin b/.env.admin new file mode 100644 index 0000000..56633b8 --- /dev/null +++ b/.env.admin @@ -0,0 +1,20 @@ +# ============================================ +# 考培练系统 SaaS 管理后台环境变量 +# +# 注意:此文件包含敏感信息,请确保: +# 1. 文件权限设置为 600(chmod 600 .env.admin) +# 2. 不要提交到 Git 仓库 +# ============================================ + +# 管理后台数据库配置 +ADMIN_DB_HOST=prod-mysql +ADMIN_DB_PORT=3306 +ADMIN_DB_USER=root +ADMIN_DB_PASSWORD=ProdMySQL2025!@# +ADMIN_DB_NAME=kaopeilian_admin + +# JWT 密钥 +ADMIN_JWT_SECRET=admin-secret-key-kaopeilian-2026-production + +# JWT Token 过期时间(秒) +ADMIN_JWT_EXPIRE_SECONDS=86400 diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..4cf0432 --- /dev/null +++ b/.env.development @@ -0,0 +1,25 @@ +# 全局开发环境配置文件 +WORKSPACE_NAME="本地开发与测试" +ENVIRONMENT="development" +DEBUG=true +LOG_LEVEL="DEBUG" + +# 端口分配 +PYTHON_DEV_PORT=8000 +COZE_BACKEND_PORT=8001 +COZE_FRONTEND_PORT=3000 + +# 数据库配置 +MYSQL_HOST="localhost" +MYSQL_PORT=3306 +MYSQL_USER="root" +MYSQL_PASSWORD="password" + +# Redis配置 +REDIS_HOST="localhost" +REDIS_PORT=6379 + +# 开发模式 +HOT_RELOAD=true +AUTO_RELOAD=true +DEVELOPMENT_MODE=true diff --git a/.env.kpl b/.env.kpl new file mode 100644 index 0000000..af86cb9 --- /dev/null +++ b/.env.kpl @@ -0,0 +1,26 @@ +# 瑞小美团队 AI 服务配置 +# ⚠️ 此文件包含敏感信息,禁止提交到 Git +# 文件权限应设置为 600 + +# === AI 服务配置 === +# 通用 Key(用于 Gemini/DeepSeek 等非 Claude 模型) +AI_PRIMARY_API_KEY=sk-V9QfxYscJzD54ZRIDyAvnd4Lew4IdtPUQqwDQ0swNkIYObxT + +# Claude 专属 Key(用于 Claude 模型) +AI_ANTHROPIC_API_KEY=sk-wNAkUW3zwBeKBN8EeK5KnXXgOixnW5rhZmKolo8fBQuHepkX + +# OpenRouter 备选 Key(可选,用于降级) +AI_FALLBACK_API_KEY= + +# === 数据库配置 === +MYSQL_PASSWORD=nj861021 + +# 租户配置(用于多租户部署) +TENANT_CODE=kpl + +# 管理库连接配置(用于从 tenant_configs 表读取配置) +ADMIN_DB_HOST=prod-mysql +ADMIN_DB_PORT=3306 +ADMIN_DB_USER=root +ADMIN_DB_PASSWORD=ProdMySQL2025!@# +ADMIN_DB_NAME=kaopeilian_admin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2e474a --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# ================================ +# AgentWD 项目 .gitignore +# ================================ + +# ---------------- +# 环境配置(敏感) +# ---------------- +.env +.env.local +.env.*.local +.env.production +.env.staging + +# ---------------- +# 依赖目录 +# ---------------- +node_modules/ +.pnpm-store/ +__pycache__/ +*.pyc +.venv/ +venv/ + +# ---------------- +# 构建产物 +# ---------------- +dist/ +build/ +.output/ +*.egg-info/ + +# ---------------- +# IDE 配置 +# ---------------- +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store + +# ---------------- +# 日志文件 +# ---------------- +logs/ +*.log +npm-debug.log* +pnpm-debug.log* + +# ---------------- +# 测试覆盖率 +# ---------------- +coverage/ +.nyc_output/ + +# ---------------- +# n8n 敏感信息 +# ---------------- +n8n-workflows/*-credentials.json +n8n-workflows/credentials.json + +# ---------------- +# 历史备份(.history插件) +# ---------------- +.history/ + +# ---------------- +# 临时文件 +# ---------------- +*.tmp +*.temp +.cache/ diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..8fab738 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,124 @@ +# 项目上下文 + +> AI启动时必读此文件,快速了解项目全貌 + +## 一、项目信息 + +| 项目 | 内容 | +|------|------| +| **项目编号** | 012-考培练系统-2601 | +| **项目路径** | `projects/012-考培练系统-2601/` | +| **当前阶段** | 开发阶段 | +| **项目状态** | 🟢 活跃 | +| **启动日期** | 2026-01-24 | +| **技术栈** | Vue3 + TypeScript + FastAPI + MySQL | + +## 二、AI启动指令 + +请依次阅读以下文件: + +1. **框架层**(了解规则) + - `../../_framework/agents/00-框架总览.md` + - 检查 `agents/` 是否有项目覆盖 + +2. **项目文档**(了解当前状态) + - `docs/同步清单.md` + - `docs/项目状态快照.md` + - `docs/规划/系统架构.md` + +3. **技术文档** + - `docs/README.md` - 项目总览 + - `backend/README.md` - 后端开发指南 + +## 三、项目简介 + +考培练系统是一个革命性的员工能力提升平台,专为轻医美连锁品牌瑞小美打造。通过集成 Coze 和 Dify 双 AI 平台,实现智能化的培训、考核和陪练功能。 + +### 核心功能 + +- **智能考试系统**:动态题目生成(千人千卷)、三轮考试机制 +- **AI陪练中心**:模拟客户对话、语音交互支持 +- **课程管理**:知识点自动提取、分岗位内容推送 +- **数据分析**:能力雷达图、学习进度追踪 + +### 技术架构 + +| 层级 | 技术栈 | +|------|--------| +| 前端 | Vue3 + TypeScript + Element Plus + Vite | +| 后端 | Python 3.9+ + FastAPI + SQLAlchemy | +| 数据库 | MySQL 8.0 + Redis | +| AI平台 | Dify(动态考试)+ Coze(AI陪练) | +| 部署 | Docker 容器化 | + +## 四、项目结构 + +``` +012-考培练系统-2601/ +├── backend/ # 后端 (FastAPI) +│ ├── app/ # 应用主目录 +│ │ ├── api/ # API路由 +│ │ ├── models/ # 数据模型 +│ │ ├── schemas/ # Pydantic schemas +│ │ └── services/ # 业务逻辑 +│ └── requirements.txt +├── frontend/ # 用户端前端 (Vue3) +│ └── src/ +│ ├── api/ # API调用 +│ ├── views/ # 页面视图 +│ └── components/ # 组件 +├── admin-frontend/ # 管理端前端 (Vue3) +├── deploy/ # 部署配置 +│ ├── docker/ # Docker compose 文件 +│ ├── nginx/ # Nginx 配置 +│ └── scripts/ # 启动/部署脚本 +├── docs/ # 文档 +│ └── 规划/ # 系统规划文档 +├── tests/ # 测试文件 +├── 知识库/ # 开发记录、问题修复 +└── .env.* # 环境配置 +``` + +## 五、关键配置 + +### 服务端口 + +| 服务 | 端口 | +|------|------| +| 前端 | 3001 | +| 后端 API | 8000 | +| MySQL | 3306 | +| Redis | 6379 | + +### 系统账户 + +| 角色 | 用户名 | 密码 | +|------|--------|------| +| 超级管理员 | superadmin | Superadmin123! | +| 系统管理员 | admin | Admin123! | +| 测试学员 | testuser | TestPass123! | + +### 数据库连接 + +- **服务器**: 120.79.247.16 +- **端口**: 3306 +- **数据库**: kaopeilian + +## 六、文件访问边界 + +| 区域 | 读取 | 写入 | +|------|------|------| +| ✅ 本项目目录 | 允许 | 允许 | +| ✅ `_framework/` | 允许 | ⚠️ 需确认 | +| ⚠️ `_private/` | 需许可 | ❌ **绝对禁止** | +| ❌ 其他项目 | 禁止 | 禁止 | + +## 七、注意事项 + +- 多租户架构:支持多个机构独立部署(.env.fw, .env.hl 等) +- AI集成:需配置 Coze 和 Dify API 密钥 +- 文件上传:使用 LibreOffice 转换 Office 文档 + +--- + +> 最后更新:2026-01-24 diff --git a/admin-frontend/Dockerfile b/admin-frontend/Dockerfile new file mode 100644 index 0000000..4a41214 --- /dev/null +++ b/admin-frontend/Dockerfile @@ -0,0 +1,47 @@ +# 考培练系统管理后台前端 Dockerfile +# 多阶段构建:Node.js 构建 + Nginx 运行 +# +# 技术栈:Vue 3 + TypeScript + pnpm(符合瑞小美系统技术栈标准) + +# ============================================ +# 阶段1:构建 +# ============================================ +FROM node:20.11-alpine AS builder + +WORKDIR /app + +# 安装 pnpm(符合规范:使用 pnpm 包管理器) +RUN corepack enable && corepack prepare pnpm@9.0.0 --activate + +# 设置 pnpm 镜像 +RUN pnpm config set registry https://registry.npmmirror.com + +# 安装依赖 +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile || pnpm install + +# 复制源码并构建 +COPY . . +RUN pnpm run build + +# ============================================ +# 阶段2:运行 +# ============================================ +FROM nginx:1.25.4-alpine + +# 复制构建产物 +COPY --from=builder /app/dist /usr/share/nginx/html + +# 复制 nginx 配置 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# 暴露端口 +EXPOSE 80 + +# 健康检查(符合规范) +HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1 + +# 启动 nginx +CMD ["nginx", "-g", "daemon off;"] + diff --git a/admin-frontend/env.d.ts b/admin-frontend/env.d.ts new file mode 100644 index 0000000..35b7f02 --- /dev/null +++ b/admin-frontend/env.d.ts @@ -0,0 +1,22 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} + +// Element Plus 中文语言包类型声明 +declare module 'element-plus/dist/locale/zh-cn.mjs' { + const zhCn: any + export default zhCn +} + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + diff --git a/admin-frontend/index.html b/admin-frontend/index.html new file mode 100644 index 0000000..0e6f144 --- /dev/null +++ b/admin-frontend/index.html @@ -0,0 +1,14 @@ + + + + + + 考培练系统 - 管理后台 + + + +
+ + + + diff --git a/admin-frontend/nginx.conf b/admin-frontend/nginx.conf new file mode 100644 index 0000000..6cb1a9a --- /dev/null +++ b/admin-frontend/nginx.conf @@ -0,0 +1,47 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # gzip 压缩 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml; + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # HTML 不缓存 + location ~* \.html$ { + expires -1; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # API 代理到后端 + location /api/ { + proxy_pass http://kaopeilian-admin-backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } + + # SPA 路由支持 + location / { + try_files $uri $uri/ /index.html; + } +} + diff --git a/admin-frontend/package.json b/admin-frontend/package.json new file mode 100644 index 0000000..18c4072 --- /dev/null +++ b/admin-frontend/package.json @@ -0,0 +1,42 @@ +{ + "name": "kaopeilian-admin-frontend", + "version": "1.0.0", + "description": "考培练系统 SaaS 超级管理后台", + "private": true, + "type": "module", + "packageManager": "pnpm@9.0.0", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", + "type-check": "vue-tsc --noEmit" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.2.0", + "pinia": "^2.1.0", + "element-plus": "^2.5.0", + "@element-plus/icons-vue": "^2.3.0", + "axios": "^1.6.0", + "monaco-editor": "^0.45.0", + "dayjs": "^1.11.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0", + "sass": "^1.69.0", + "unplugin-auto-import": "^0.17.0", + "unplugin-vue-components": "^0.26.0", + "typescript": "~5.3.0", + "vue-tsc": "^2.0.0", + "@tsconfig/node20": "^20.1.0", + "@types/node": "^20.11.0", + "@vue/tsconfig": "^0.5.0", + "eslint": "^8.57.0", + "@vue/eslint-config-typescript": "^13.0.0", + "eslint-plugin-vue": "^9.22.0", + "@rushstack/eslint-patch": "^1.7.0" + } +} + diff --git a/admin-frontend/public/favicon.svg b/admin-frontend/public/favicon.svg new file mode 100644 index 0000000..a89c613 --- /dev/null +++ b/admin-frontend/public/favicon.svg @@ -0,0 +1,19 @@ + + + + + + + + + A + + + + + + + + + + diff --git a/admin-frontend/src/App.vue b/admin-frontend/src/App.vue new file mode 100644 index 0000000..0804c37 --- /dev/null +++ b/admin-frontend/src/App.vue @@ -0,0 +1,18 @@ + + + + + + diff --git a/admin-frontend/src/api/index.js b/admin-frontend/src/api/index.js new file mode 100644 index 0000000..d8c5125 --- /dev/null +++ b/admin-frontend/src/api/index.js @@ -0,0 +1,108 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' +import router from '@/router' + +// 创建 axios 实例 +const request = axios.create({ + baseURL: '/api/v1/admin', + timeout: 30000, +}) + +// 请求拦截器 +request.interceptors.request.use( + config => { + const token = localStorage.getItem('admin_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + error => { + return Promise.reject(error) + } +) + +// 响应拦截器 +request.interceptors.response.use( + response => { + return response.data + }, + error => { + const { response } = error + if (response) { + if (response.status === 401) { + localStorage.removeItem('admin_token') + localStorage.removeItem('admin_user') + router.push('/login') + ElMessage.error('登录已过期,请重新登录') + } else if (response.status === 403) { + ElMessage.error('没有权限执行此操作') + } else if (response.data?.detail) { + ElMessage.error(response.data.detail) + } else { + ElMessage.error('请求失败') + } + } else { + ElMessage.error('网络错误') + } + return Promise.reject(error) + } +) + +// API 模块 +const api = { + // 认证 + auth: { + login: data => request.post('/auth/login', data), + me: () => request.get('/auth/me'), + changePassword: data => request.post('/auth/change-password', data), + logout: () => request.post('/auth/logout'), + }, + + // 租户 + tenants: { + list: params => request.get('/tenants', { params }), + get: id => request.get(`/tenants/${id}`), + create: data => request.post('/tenants', data), + update: (id, data) => request.put(`/tenants/${id}`, data), + delete: id => request.delete(`/tenants/${id}`), + enable: id => request.post(`/tenants/${id}/enable`), + disable: id => request.post(`/tenants/${id}/disable`), + }, + + // 配置 + configs: { + templates: params => request.get('/configs/templates', { params }), + groups: () => request.get('/configs/groups'), + getTenantConfigs: (tenantId, params) => request.get(`/configs/tenants/${tenantId}`, { params }), + updateConfig: (tenantId, group, key, data) => request.put(`/configs/tenants/${tenantId}/${group}/${key}`, data), + batchUpdate: (tenantId, data) => request.put(`/configs/tenants/${tenantId}/batch`, data), + deleteConfig: (tenantId, group, key) => request.delete(`/configs/tenants/${tenantId}/${group}/${key}`), + refreshCache: tenantId => request.post(`/configs/tenants/${tenantId}/refresh-cache`), + }, + + // 提示词 + prompts: { + list: params => request.get('/prompts', { params }), + get: id => request.get(`/prompts/${id}`), + create: data => request.post('/prompts', data), + update: (id, data) => request.put(`/prompts/${id}`, data), + getVersions: id => request.get(`/prompts/${id}/versions`), + rollback: (id, version) => request.post(`/prompts/${id}/rollback/${version}`), + getTenantPrompts: tenantId => request.get(`/prompts/tenants/${tenantId}`), + updateTenantPrompt: (tenantId, promptId, data) => request.put(`/prompts/tenants/${tenantId}/${promptId}`, data), + deleteTenantPrompt: (tenantId, promptId) => request.delete(`/prompts/tenants/${tenantId}/${promptId}`), + }, + + // 功能开关 + features: { + getDefaults: () => request.get('/features/defaults'), + getTenantFeatures: tenantId => request.get(`/features/tenants/${tenantId}`), + updateFeature: (tenantId, code, data) => request.put(`/features/tenants/${tenantId}/${code}`, data), + resetFeature: (tenantId, code) => request.delete(`/features/tenants/${tenantId}/${code}`), + batchUpdate: (tenantId, data) => request.post(`/features/tenants/${tenantId}/batch`, data), + }, +} + +export default api + diff --git a/admin-frontend/src/assets/styles/main.scss b/admin-frontend/src/assets/styles/main.scss new file mode 100644 index 0000000..72b18d3 --- /dev/null +++ b/admin-frontend/src/assets/styles/main.scss @@ -0,0 +1,58 @@ +// 全局样式 +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +// Element Plus 样式覆盖 +.el-card { + border-radius: 8px; + + &__header { + font-weight: 600; + } +} + +.el-button { + border-radius: 6px; +} + +.el-input { + .el-input__wrapper { + border-radius: 6px; + } +} + +.el-table { + th.el-table__cell { + background-color: #f5f7fa; + } +} + +// 滚动条样式 +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 4px; + + &:hover { + background: #a8a8a8; + } +} + diff --git a/admin-frontend/src/main.ts b/admin-frontend/src/main.ts new file mode 100644 index 0000000..b902093 --- /dev/null +++ b/admin-frontend/src/main.ts @@ -0,0 +1,24 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import 'element-plus/dist/index.css' + +import App from './App.vue' +import router from './router' +import './assets/styles/main.scss' + +const app = createApp(App) + +// 注册所有图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) + +app.mount('#app') + diff --git a/admin-frontend/src/router/index.js b/admin-frontend/src/router/index.js new file mode 100644 index 0000000..0d4946f --- /dev/null +++ b/admin-frontend/src/router/index.js @@ -0,0 +1,96 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { requiresAuth: false } + }, + { + path: '/', + component: () => import('@/views/Layout.vue'), + meta: { requiresAuth: true }, + children: [ + { + path: '', + redirect: '/dashboard' + }, + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/Dashboard.vue'), + meta: { title: '控制台' } + }, + { + path: 'tenants', + name: 'Tenants', + component: () => import('@/views/tenants/TenantList.vue'), + meta: { title: '租户管理' } + }, + { + path: 'tenants/:id', + name: 'TenantDetail', + component: () => import('@/views/tenants/TenantDetail.vue'), + meta: { title: '租户详情' } + }, + { + path: 'tenants/:id/configs', + name: 'TenantConfigs', + component: () => import('@/views/tenants/TenantConfigs.vue'), + meta: { title: '租户配置' } + }, + { + path: 'tenants/:id/features', + name: 'TenantFeatures', + component: () => import('@/views/tenants/TenantFeatures.vue'), + meta: { title: '功能开关' } + }, + { + path: 'prompts', + name: 'Prompts', + component: () => import('@/views/prompts/PromptList.vue'), + meta: { title: '提示词管理' } + }, + { + path: 'prompts/:id', + name: 'PromptDetail', + component: () => import('@/views/prompts/PromptDetail.vue'), + meta: { title: '提示词详情' } + }, + { + path: 'logs', + name: 'Logs', + component: () => import('@/views/Logs.vue'), + meta: { title: '操作日志' } + }, + ] + }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('@/views/NotFound.vue') + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// 路由守卫 +router.beforeEach((to, from, next) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth !== false && !authStore.isLoggedIn) { + next({ name: 'Login', query: { redirect: to.fullPath } }) + } else if (to.name === 'Login' && authStore.isLoggedIn) { + next({ name: 'Dashboard' }) + } else { + next() + } +}) + +export default router + diff --git a/admin-frontend/src/stores/auth.js b/admin-frontend/src/stores/auth.js new file mode 100644 index 0000000..3a968ff --- /dev/null +++ b/admin-frontend/src/stores/auth.js @@ -0,0 +1,40 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import api from '@/api' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('admin_token') || '') + const user = ref(JSON.parse(localStorage.getItem('admin_user') || 'null')) + + const isLoggedIn = computed(() => !!token.value) + + async function login(username, password) { + const res = await api.auth.login({ username, password }) + token.value = res.access_token + user.value = res.admin_user + localStorage.setItem('admin_token', res.access_token) + localStorage.setItem('admin_user', JSON.stringify(res.admin_user)) + return res + } + + function logout() { + token.value = '' + user.value = null + localStorage.removeItem('admin_token') + localStorage.removeItem('admin_user') + } + + async function fetchUser() { + if (!token.value) return + try { + const res = await api.auth.me() + user.value = res + localStorage.setItem('admin_user', JSON.stringify(res)) + } catch (e) { + logout() + } + } + + return { token, user, isLoggedIn, login, logout, fetchUser } +}) + diff --git a/admin-frontend/src/views/Dashboard.vue b/admin-frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..252eeba --- /dev/null +++ b/admin-frontend/src/views/Dashboard.vue @@ -0,0 +1,204 @@ + + + + + + diff --git a/admin-frontend/src/views/Layout.vue b/admin-frontend/src/views/Layout.vue new file mode 100644 index 0000000..632cb18 --- /dev/null +++ b/admin-frontend/src/views/Layout.vue @@ -0,0 +1,234 @@ + + + + + + diff --git a/admin-frontend/src/views/Login.vue b/admin-frontend/src/views/Login.vue new file mode 100644 index 0000000..ed7d654 --- /dev/null +++ b/admin-frontend/src/views/Login.vue @@ -0,0 +1,157 @@ + + + + + + diff --git a/admin-frontend/src/views/Logs.vue b/admin-frontend/src/views/Logs.vue new file mode 100644 index 0000000..17da819 --- /dev/null +++ b/admin-frontend/src/views/Logs.vue @@ -0,0 +1,178 @@ + + + + + + diff --git a/admin-frontend/src/views/NotFound.vue b/admin-frontend/src/views/NotFound.vue new file mode 100644 index 0000000..79cd9e9 --- /dev/null +++ b/admin-frontend/src/views/NotFound.vue @@ -0,0 +1,31 @@ + + + + diff --git a/admin-frontend/src/views/prompts/PromptDetail.vue b/admin-frontend/src/views/prompts/PromptDetail.vue new file mode 100644 index 0000000..fb5335a --- /dev/null +++ b/admin-frontend/src/views/prompts/PromptDetail.vue @@ -0,0 +1,179 @@ + + + + + + diff --git a/admin-frontend/src/views/prompts/PromptList.vue b/admin-frontend/src/views/prompts/PromptList.vue new file mode 100644 index 0000000..2905700 --- /dev/null +++ b/admin-frontend/src/views/prompts/PromptList.vue @@ -0,0 +1,159 @@ + + + + + + diff --git a/admin-frontend/src/views/tenants/TenantConfigs.vue b/admin-frontend/src/views/tenants/TenantConfigs.vue new file mode 100644 index 0000000..0c06373 --- /dev/null +++ b/admin-frontend/src/views/tenants/TenantConfigs.vue @@ -0,0 +1,142 @@ + + + + + + diff --git a/admin-frontend/src/views/tenants/TenantDetail.vue b/admin-frontend/src/views/tenants/TenantDetail.vue new file mode 100644 index 0000000..2b9a5f9 --- /dev/null +++ b/admin-frontend/src/views/tenants/TenantDetail.vue @@ -0,0 +1,217 @@ + + + + + + diff --git a/admin-frontend/src/views/tenants/TenantFeatures.vue b/admin-frontend/src/views/tenants/TenantFeatures.vue new file mode 100644 index 0000000..c9c99fc --- /dev/null +++ b/admin-frontend/src/views/tenants/TenantFeatures.vue @@ -0,0 +1,129 @@ + + + + + + diff --git a/admin-frontend/src/views/tenants/TenantList.vue b/admin-frontend/src/views/tenants/TenantList.vue new file mode 100644 index 0000000..8374675 --- /dev/null +++ b/admin-frontend/src/views/tenants/TenantList.vue @@ -0,0 +1,250 @@ + + + + + + diff --git a/admin-frontend/tsconfig.app.json b/admin-frontend/tsconfig.app.json new file mode 100644 index 0000000..ddcfd4d --- /dev/null +++ b/admin-frontend/tsconfig.app.json @@ -0,0 +1,16 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "exclude": ["src/**/__tests__/*"], + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "allowJs": true, + "checkJs": false + } +} + diff --git a/admin-frontend/tsconfig.json b/admin-frontend/tsconfig.json new file mode 100644 index 0000000..d3b34d5 --- /dev/null +++ b/admin-frontend/tsconfig.json @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.app.json" } + ] +} + diff --git a/admin-frontend/tsconfig.node.json b/admin-frontend/tsconfig.node.json new file mode 100644 index 0000000..1730ebc --- /dev/null +++ b/admin-frontend/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "include": [ + "vite.config.*", + "vitest.config.*", + "cypress.config.*", + "nightwatch.conf.*", + "playwright.config.*" + ], + "compilerOptions": { + "composite": true, + "noEmit": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"] + } +} + diff --git a/admin-frontend/vite.config.ts b/admin-frontend/vite.config.ts new file mode 100644 index 0000000..a46dd1d --- /dev/null +++ b/admin-frontend/vite.config.ts @@ -0,0 +1,39 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [ + vue(), + AutoImport({ + resolvers: [ElementPlusResolver()], + imports: ['vue', 'vue-router', 'pinia'], + }), + Components({ + resolvers: [ElementPlusResolver()], + }), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + host: '0.0.0.0', + port: 3030, + proxy: { + '/api': { + target: 'http://localhost:8030', + changeOrigin: true, + } + } + }, + build: { + outDir: 'dist', + sourcemap: false, + } +}) + diff --git a/backend/.env.ex b/backend/.env.ex new file mode 100644 index 0000000..3740659 --- /dev/null +++ b/backend/.env.ex @@ -0,0 +1,74 @@ +# 恩喜成都总院生产环境配置 +APP_NAME="恩喜成都总院-考培练系统" +APP_VERSION="1.0.0" +DEBUG=false +HOST=0.0.0.0 +PORT=8000 + +# 数据库配置 - 共享MySQL实例 +DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_ex?charset=utf8mb4 +MYSQL_HOST=prod-mysql +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=ProdMySQL2025!@# +MYSQL_DATABASE=kaopeilian_ex + +# Redis配置 +REDIS_URL=redis://ex-redis:6379/0 +REDIS_HOST=ex-redis +REDIS_PORT=6379 +REDIS_DB=0 + +# 安全配置 +SECRET_KEY=ex_8f7a9c3e1b4d6f2a5c8e7b9d1f3a6c4e8b2d5f7a9c1e3b6d8f2a4c7e9b1d3f5a +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# CORS配置 +CORS_ORIGINS=["https://ex.ireborn.com.cn", "http://ex.ireborn.com.cn"] + +# 日志配置 +LOG_LEVEL=INFO +LOG_FORMAT=json + +# 文件上传配置 +UPLOAD_MAX_SIZE=10485760 +UPLOAD_ALLOWED_TYPES=["image/jpeg", "image/png", "application/pdf", "audio/mpeg", "audio/wav", "audio/webm"] +UPLOAD_DIR=uploads + +# Coze OAuth配置 +COZE_OAUTH_CLIENT_ID=1114009328887 +COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I +COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem +COZE_PRACTICE_BOT_ID=7560643598174683145 + +# Dify 工作流 API Key 配置 +# 01-知识点分析 +# 02-试题生成器 +# 03-陪练知识准备 +# 04-与课程对话 +# 05-智能工牌能力分析与课程推荐 + +# Coze 播课配置 +COZE_BROADCAST_WORKFLOW_ID=7577978749833838602 +COZE_BROADCAST_SPACE_ID=7474971491470688296 +COZE_BROADCAST_BOT_ID=7560643598174683145 + +# AI 服务配置(知识点分析 V2 - 测试阶段 Key) +AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw +AI_PRIMARY_BASE_URL=https://4sapi.com/v1 +AI_FALLBACK_API_KEY=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 +AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1 +AI_DEFAULT_MODEL=gemini-3-flash-preview +AI_TIMEOUT=120 + +# 租户配置(用于多租户部署) +TENANT_CODE=ex + +# 管理库连接配置(用于从 tenant_configs 表读取配置) +ADMIN_DB_HOST=prod-mysql +ADMIN_DB_PORT=3306 +ADMIN_DB_USER=root +ADMIN_DB_PASSWORD=ProdMySQL2025!@# +ADMIN_DB_NAME=kaopeilian_admin diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d24964b --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,8 @@ +# 开发环境配置示例 +DATABASE_URL=mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4 +REDIS_URL=redis://localhost:6379/0 +DEBUG=true +SECRET_KEY=kaopeilian-secret-key-dev +CORS_ORIGINS=["http://localhost:3001","http://localhost:3000"] +HOST=0.0.0.0 +PORT=8000 diff --git a/backend/.env.fw b/backend/.env.fw new file mode 100644 index 0000000..18c8c8b --- /dev/null +++ b/backend/.env.fw @@ -0,0 +1,69 @@ +# 飞沃生产环境配置 +APP_NAME="飞沃-考培练系统" +APP_VERSION="1.0.0" +DEBUG=false +HOST=0.0.0.0 +PORT=8000 + +# 数据库配置 - 共享MySQL实例 +DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_fw?charset=utf8mb4 +MYSQL_HOST=prod-mysql +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=ProdMySQL2025!@# +MYSQL_DATABASE=kaopeilian_fw + +# Redis配置 +REDIS_URL=redis://fw-redis:6379/0 +REDIS_HOST=fw-redis +REDIS_PORT=6379 +REDIS_DB=0 + +# 安全配置 +SECRET_KEY=fw_00e0e0e6i5h28g6g2f7fhi46f1e6i6f2f1h22f5i1h5g8j2h3e6g0i5j8fd1g7h +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# CORS配置 +CORS_ORIGINS=["https://fw.ireborn.com.cn", "http://fw.ireborn.com.cn"] + +# 日志配置 +LOG_LEVEL=INFO +LOG_FORMAT=json + +# 文件上传配置 +UPLOAD_MAX_SIZE=10485760 +UPLOAD_ALLOWED_TYPES=["image/jpeg", "image/png", "application/pdf", "audio/mpeg", "audio/wav", "audio/webm"] +UPLOAD_DIR=uploads + +# Coze OAuth配置 +COZE_OAUTH_CLIENT_ID=1114009328887 +COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I +COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem +COZE_PRACTICE_BOT_ID=7560643598174683145 + +# Dify 工作流 API Key 配置 + +# Coze 播课配置 +COZE_BROADCAST_WORKFLOW_ID=7577980956000534578 +COZE_BROADCAST_SPACE_ID=7474971491470688296 +COZE_BROADCAST_BOT_ID=7560643598174683145 + +# AI 服务配置(知识点分析 V2 - 测试阶段 Key) +AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw +AI_PRIMARY_BASE_URL=https://4sapi.com/v1 +AI_FALLBACK_API_KEY=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 +AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1 +AI_DEFAULT_MODEL=gemini-3-flash-preview +AI_TIMEOUT=120 + +# 租户配置(用于多租户部署) +TENANT_CODE=fw + +# 管理库连接配置(用于从 tenant_configs 表读取配置) +ADMIN_DB_HOST=prod-mysql +ADMIN_DB_PORT=3306 +ADMIN_DB_USER=root +ADMIN_DB_PASSWORD=ProdMySQL2025!@# +ADMIN_DB_NAME=kaopeilian_admin diff --git a/backend/.env.hl b/backend/.env.hl new file mode 100644 index 0000000..45acc31 --- /dev/null +++ b/backend/.env.hl @@ -0,0 +1,69 @@ +# 武汉禾丽生产环境配置 +APP_NAME="武汉禾丽-考培练系统" +APP_VERSION="1.0.0" +DEBUG=false +HOST=0.0.0.0 +PORT=8000 + +# 数据库配置 - 共享MySQL实例 +DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_hl?charset=utf8mb4 +MYSQL_HOST=prod-mysql +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=ProdMySQL2025!@# +MYSQL_DATABASE=kaopeilian_hl + +# Redis配置 +REDIS_URL=redis://hl-redis:6379/0 +REDIS_HOST=hl-redis +REDIS_PORT=6379 +REDIS_DB=0 + +# 安全配置 +SECRET_KEY=hl_88c8c8c4g3f06e4e0d5fdg24d9c4g4d0d9f00d3g9f3e6h0f1c4e8g3h6db9e5f +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# CORS配置 +CORS_ORIGINS=["https://hl.ireborn.com.cn", "http://hl.ireborn.com.cn"] + +# 日志配置 +LOG_LEVEL=INFO +LOG_FORMAT=json + +# 文件上传配置 +UPLOAD_MAX_SIZE=10485760 +UPLOAD_ALLOWED_TYPES=["image/jpeg", "image/png", "application/pdf", "audio/mpeg", "audio/wav", "audio/webm"] +UPLOAD_DIR=uploads + +# Coze OAuth配置 +COZE_OAUTH_CLIENT_ID=1114009328887 +COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I +COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem +COZE_PRACTICE_BOT_ID=7560643598174683145 + +# Dify 工作流 API Key 配置 + +# Coze 播课配置 +COZE_BROADCAST_WORKFLOW_ID=7577981581995409450 +COZE_BROADCAST_SPACE_ID=7474971491470688296 +COZE_BROADCAST_BOT_ID=7560643598174683145 + +# AI 服务配置(知识点分析 V2 - 测试阶段 Key) +AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw +AI_PRIMARY_BASE_URL=https://4sapi.com/v1 +AI_FALLBACK_API_KEY=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 +AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1 +AI_DEFAULT_MODEL=gemini-3-flash-preview +AI_TIMEOUT=120 + +# 租户配置(用于多租户部署) +TENANT_CODE=hl + +# 管理库连接配置(用于从 tenant_configs 表读取配置) +ADMIN_DB_HOST=prod-mysql +ADMIN_DB_PORT=3306 +ADMIN_DB_USER=root +ADMIN_DB_PASSWORD=ProdMySQL2025!@# +ADMIN_DB_NAME=kaopeilian_admin diff --git a/backend/.env.hua b/backend/.env.hua new file mode 100644 index 0000000..5455eac --- /dev/null +++ b/backend/.env.hua @@ -0,0 +1,69 @@ +# 华尔倍丽生产环境配置 +APP_NAME="华尔倍丽-考培练系统" +APP_VERSION="1.0.0" +DEBUG=false +HOST=0.0.0.0 +PORT=8000 + +# 数据库配置 - 共享MySQL实例 +DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_hua?charset=utf8mb4 +MYSQL_HOST=prod-mysql +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=ProdMySQL2025!@# +MYSQL_DATABASE=kaopeilian_hua + +# Redis配置 +REDIS_URL=redis://hua-redis:6379/0 +REDIS_HOST=hua-redis +REDIS_PORT=6379 +REDIS_DB=0 + +# 安全配置 +SECRET_KEY=hua_66a6a6a2f1d84c2c8b3dbf02b7a2e2b8b7d88b1e7d1c4f8d9a2c6e1f4b9a7c3d +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# CORS配置 +CORS_ORIGINS=["https://hua.ireborn.com.cn", "http://hua.ireborn.com.cn"] + +# 日志配置 +LOG_LEVEL=INFO +LOG_FORMAT=json + +# 文件上传配置 +UPLOAD_MAX_SIZE=10485760 +UPLOAD_ALLOWED_TYPES=["image/jpeg", "image/png", "application/pdf", "audio/mpeg", "audio/wav", "audio/webm"] +UPLOAD_DIR=uploads + +# Coze OAuth配置 +COZE_OAUTH_CLIENT_ID=1114009328887 +COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I +COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem +COZE_PRACTICE_BOT_ID=7560643598174683145 + +# Dify 工作流 API Key 配置 + +# Coze 播课配置 +COZE_BROADCAST_WORKFLOW_ID=7577978749833838602 +COZE_BROADCAST_SPACE_ID=7474971491470688296 +COZE_BROADCAST_BOT_ID=7560643598174683145 + +# AI 服务配置(知识点分析 V2 - 测试阶段 Key) +AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw +AI_PRIMARY_BASE_URL=https://4sapi.com/v1 +AI_FALLBACK_API_KEY=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 +AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1 +AI_DEFAULT_MODEL=gemini-3-flash-preview +AI_TIMEOUT=120 + +# 租户配置(用于多租户部署) +TENANT_CODE=hua + +# 管理库连接配置(用于从 tenant_configs 表读取配置) +ADMIN_DB_HOST=prod-mysql +ADMIN_DB_PORT=3306 +ADMIN_DB_USER=root +ADMIN_DB_PASSWORD=ProdMySQL2025!@# +ADMIN_DB_NAME=kaopeilian_admin diff --git a/backend/.env.xy b/backend/.env.xy new file mode 100644 index 0000000..0d8c4ee --- /dev/null +++ b/backend/.env.xy @@ -0,0 +1,68 @@ +# 芯颜定制生产环境配置 +APP_NAME="芯颜定制-考培练系统" +APP_VERSION="1.0.0" +DEBUG=false +HOST=0.0.0.0 +PORT=8000 + +# 数据库配置 - 共享MySQL实例 +DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_xy?charset=utf8mb4 +MYSQL_HOST=prod-mysql +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=ProdMySQL2025!@# +MYSQL_DATABASE=kaopeilian_xy + +# Redis配置 +REDIS_URL=redis://xy-redis:6379/0 +REDIS_HOST=xy-redis +REDIS_PORT=6379 +REDIS_DB=0 + +# 安全配置 +SECRET_KEY=xy_99d9d9d5h4g17f5f1e6geh35e0d5h5e1e0g11e4h0g4f7i1g2d5f9h4i7ec0f6g +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# CORS配置 +CORS_ORIGINS=["https://xy.ireborn.com.cn", "http://xy.ireborn.com.cn"] + +# 日志配置 +LOG_LEVEL=INFO +LOG_FORMAT=json + +# 文件上传配置 +UPLOAD_MAX_SIZE=10485760 +UPLOAD_ALLOWED_TYPES=["image/jpeg", "image/png", "application/pdf", "audio/mpeg", "audio/wav", "audio/webm"] +UPLOAD_DIR=uploads + +# Coze OAuth配置 +COZE_OAUTH_CLIENT_ID=1114009328887 +COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I +COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem +COZE_PRACTICE_BOT_ID=7560643598174683145 +# Coze 播课配置 +COZE_BROADCAST_WORKFLOW_ID=7577968943668084745 +COZE_BROADCAST_SPACE_ID=7474971491470688296 +COZE_BROADCAST_BOT_ID=7560643598174683145 + +# Dify 工作流 API Key 配置 + +# AI 服务配置(知识点分析 V2 - 测试阶段 Key) +AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw +AI_PRIMARY_BASE_URL=https://4sapi.com/v1 +AI_FALLBACK_API_KEY=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 +AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1 +AI_DEFAULT_MODEL=gemini-3-flash-preview +AI_TIMEOUT=120 + +# 租户配置(用于多租户部署) +TENANT_CODE=xy + +# 管理库连接配置(用于从 tenant_configs 表读取配置) +ADMIN_DB_HOST=prod-mysql +ADMIN_DB_PORT=3306 +ADMIN_DB_USER=root +ADMIN_DB_PASSWORD=ProdMySQL2025!@# +ADMIN_DB_NAME=kaopeilian_admin diff --git a/backend/.env.yy b/backend/.env.yy new file mode 100644 index 0000000..62a1e84 --- /dev/null +++ b/backend/.env.yy @@ -0,0 +1,69 @@ +# 杨扬宠物生产环境配置 +APP_NAME="杨扬宠物-考培练系统" +APP_VERSION="1.0.0" +DEBUG=false +HOST=0.0.0.0 +PORT=8000 + +# 数据库配置 - 共享MySQL实例 +DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_yy?charset=utf8mb4 +MYSQL_HOST=prod-mysql +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=ProdMySQL2025!@# +MYSQL_DATABASE=kaopeilian_yy + +# Redis配置 +REDIS_URL=redis://yy-redis:6379/0 +REDIS_HOST=yy-redis +REDIS_PORT=6379 +REDIS_DB=0 + +# 安全配置 +SECRET_KEY=yy_77b7b7b3f2e95d3d9c4ecf13c8b3f3c9c8e99c2f8e2d5g9e0b3d7f2g5ca8d4e +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# CORS配置 +CORS_ORIGINS=["https://yy.ireborn.com.cn", "http://yy.ireborn.com.cn"] + +# 日志配置 +LOG_LEVEL=INFO +LOG_FORMAT=json + +# 文件上传配置 +UPLOAD_MAX_SIZE=10485760 +UPLOAD_ALLOWED_TYPES=["image/jpeg", "image/png", "application/pdf", "audio/mpeg", "audio/wav", "audio/webm"] +UPLOAD_DIR=uploads + +# Coze OAuth配置 +COZE_OAUTH_CLIENT_ID=1114009328887 +COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I +COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem +COZE_PRACTICE_BOT_ID=7560643598174683145 + +# Dify 工作流 API Key 配置 + +# Coze 播课配置 +COZE_BROADCAST_WORKFLOW_ID=7577980363517018150 +COZE_BROADCAST_SPACE_ID=7474971491470688296 +COZE_BROADCAST_BOT_ID=7560643598174683145 + +# AI 服务配置(知识点分析 V2 - 测试阶段 Key) +AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw +AI_PRIMARY_BASE_URL=https://4sapi.com/v1 +AI_FALLBACK_API_KEY=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 +AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1 +AI_DEFAULT_MODEL=gemini-3-flash-preview +AI_TIMEOUT=120 + +# 租户配置(用于多租户部署) +TENANT_CODE=yy + +# 管理库连接配置(用于从 tenant_configs 表读取配置) +ADMIN_DB_HOST=prod-mysql +ADMIN_DB_PORT=3306 +ADMIN_DB_USER=root +ADMIN_DB_PASSWORD=ProdMySQL2025!@# +ADMIN_DB_NAME=kaopeilian_admin diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..f7bdf70 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,79 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environment +venv/ +ENV/ +env/ +.venv + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Testing +.coverage +.pytest_cache/ +htmlcov/ +.tox/ +.mypy_cache/ +.dmypy.json +dmypy.json + +# Environment +.env +.env.local +.env.*.local +local_config.py + +# Logs +*.log +logs/ + +# Database +*.db +*.sqlite +*.sqlite3 + +# Uploads +uploads/ + +# OS +.DS_Store +Thumbs.db + +# Docker +docker-compose.override.yml + +# Alembic +alembic.ini + +# Private keys +*.pem +*.key +*.crt +*.cert \ No newline at end of file diff --git a/backend/.pre-commit-config.yaml b/backend/.pre-commit-config.yaml new file mode 100644 index 0000000..23a7c74 --- /dev/null +++ b/backend/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +repos: + - repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + language_version: python3.8 + + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + args: ["--profile", "black"] + + - repo: https://github.com/pycqa/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + args: ["--max-line-length", "88", "--extend-ignore", "E203"] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.3.0 + hooks: + - id: mypy + additional_dependencies: [types-all] + exclude: ^(migrations/|tests/) diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..6ecb944 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,57 @@ +# 使用Python 3.11作为基础镜像,使用阿里云镜像 +FROM python:3.11.9-slim + +# 设置工作目录 +WORKDIR /app + +# 设置环境变量 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +# 配置阿里云镜像源 +RUN echo "deb http://mirrors.aliyun.com/debian/ bookworm main" > /etc/apt/sources.list && \ + echo "deb http://mirrors.aliyun.com/debian-security/ bookworm-security main" >> /etc/apt/sources.list && \ + echo "deb http://mirrors.aliyun.com/debian/ bookworm-updates main" >> /etc/apt/sources.list + +# 安装系统依赖(包括LibreOffice用于文档转换) +RUN apt-get update && apt-get install -y \ + gcc \ + default-libmysqlclient-dev \ + pkg-config \ + curl \ + libreoffice-writer \ + libreoffice-impress \ + libreoffice-calc \ + libreoffice-core \ + fonts-wqy-zenhei \ + fonts-wqy-microhei \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +# 复制依赖文件 +COPY requirements.txt . + +# 配置pip使用阿里云镜像 +RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \ + pip config set global.trusted-host mirrors.aliyun.com + +# 安装Python依赖 +RUN pip install --upgrade pip && \ + pip install -r requirements.txt + +# 复制应用代码 +COPY app/ ./app/ + +# 创建上传目录和日志目录 +RUN mkdir -p uploads logs + +# 暴露端口 +EXPOSE 8000 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# 启动命令(生产模式,无热重载) +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--timeout-keep-alive", "600"] \ No newline at end of file diff --git a/backend/Dockerfile.admin b/backend/Dockerfile.admin new file mode 100644 index 0000000..84de7fd --- /dev/null +++ b/backend/Dockerfile.admin @@ -0,0 +1,66 @@ +# 考培练系统 SaaS 超级后台 - 开发环境 Dockerfile +# 使用阿里云镜像 + 热重载配置 + +# 使用 Python 3.11 slim 版本 +FROM python:3.11.9-slim + +# 设置工作目录 +WORKDIR /app + +# 设置环境变量 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/app + +# 配置阿里云 apt 镜像源 +RUN echo "deb http://mirrors.aliyun.com/debian/ bookworm main" > /etc/apt/sources.list && \ + echo "deb http://mirrors.aliyun.com/debian-security/ bookworm-security main" >> /etc/apt/sources.list && \ + echo "deb http://mirrors.aliyun.com/debian/ bookworm-updates main" >> /etc/apt/sources.list + +# 安装系统依赖 +RUN apt-get update && apt-get install -y \ + gcc \ + default-libmysqlclient-dev \ + pkg-config \ + curl \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +# 配置 pip 使用阿里云镜像 +RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \ + pip config set global.trusted-host mirrors.aliyun.com + +# 复制依赖文件 +COPY requirements.txt . +COPY requirements-admin.txt . + +# 安装 Python 依赖 +RUN pip install --upgrade pip && \ + pip install -r requirements.txt && \ + pip install -r requirements-admin.txt + +# 复制应用代码(开发环境会通过 volume 覆盖) +COPY app/ ./app/ + +# 创建目录 +RUN mkdir -p uploads logs + +# 暴露端口 +EXPOSE 8000 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# 启动命令 - 开发模式,启用热重载 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--reload-dir", "/app/app"] + + + + + + + + + diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 0000000..3849886 --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,59 @@ +# 后端开发环境 Dockerfile(支持热重载) +FROM python:3.11.9-slim + +# 设置工作目录 +WORKDIR /app + +# 设置环境变量 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/app + +# 配置阿里云镜像源 +RUN echo "deb http://mirrors.aliyun.com/debian/ bookworm main" > /etc/apt/sources.list && \ + echo "deb http://mirrors.aliyun.com/debian-security/ bookworm-security main" >> /etc/apt/sources.list && \ + echo "deb http://mirrors.aliyun.com/debian/ bookworm-updates main" >> /etc/apt/sources.list + +# 安装系统依赖(包括LibreOffice用于文档转换) +RUN apt-get update && apt-get install -y \ + gcc \ + default-libmysqlclient-dev \ + pkg-config \ + curl \ + libreoffice-writer \ + libreoffice-impress \ + libreoffice-calc \ + libreoffice-core \ + fonts-wqy-zenhei \ + fonts-wqy-microhei \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +# 复制依赖文件 +COPY requirements.txt . + +# 配置pip使用阿里云镜像 +RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \ + pip config set global.trusted-host mirrors.aliyun.com + +# 安装Python依赖 +RUN pip install --upgrade pip && \ + pip install -r requirements.txt + +# 复制应用代码(开发时会被volume覆盖) +COPY . . + +# 创建必要的目录 +RUN mkdir -p uploads logs + +# 暴露端口 +EXPOSE 8000 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# 启动开发服务器(支持热重载) +# 设置超时为10分钟(600秒),以支持AI试题生成等长时间处理 +CMD ["uvicorn", "app.main:app", "--reload", "--host", "0.0.0.0", "--port", "8000", "--reload-dir", "/app/app", "--timeout-keep-alive", "600"] diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..4c6c4ca --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,52 @@ +.PHONY: help install install-dev format lint type-check test test-cov run migrate clean + +help: + @echo "可用的命令:" + @echo " make install - 安装生产环境依赖" + @echo " make install-dev - 安装开发环境依赖" + @echo " make format - 格式化代码" + @echo " make lint - 运行代码检查" + @echo " make type-check - 运行类型检查" + @echo " make test - 运行测试" + @echo " make test-cov - 运行测试并生成覆盖率报告" + @echo " make run - 启动开发服务器" + @echo " make migrate - 运行数据库迁移" + @echo " make clean - 清理临时文件" + +install: + pip install -r requirements.txt + +install-dev: + pip install -r requirements-dev.txt + +format: + black app/ tests/ + isort app/ tests/ + +lint: + flake8 app/ tests/ --max-line-length=100 --ignore=E203,W503 + pylint app/ tests/ --disable=C0111,R0903,R0913 + +type-check: + mypy app/ --ignore-missing-imports + +test: + pytest tests/ -v + +test-cov: + pytest tests/ -v --cov=app --cov-report=html --cov-report=term + +run: + python -m app.main + +migrate: + alembic upgrade head + +clean: + find . -type d -name "__pycache__" -exec rm -rf {} + + find . -type f -name "*.pyc" -delete + find . -type f -name "*.pyo" -delete + find . -type f -name ".coverage" -delete + rm -rf htmlcov/ + rm -rf .pytest_cache/ + rm -rf .mypy_cache/ \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..7aab273 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,410 @@ +## 考培练系统后端(FastAPI) + +简要说明:本项目为考培练系统的后端服务,基于 FastAPI 开发,配套前端为 Vue3。支持本地开发与测试,默认仅在 localhost 环境运行。 + +### 如何运行(How to Run) + +1. 进入项目目录: +```bash +cd kaopeilian-backend +``` + +2. 可选:创建并激活虚拟环境(推荐) +```bash +python3 -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +``` + +3. 安装依赖: +```bash +pip install -r requirements.txt +``` + +4. 运行主程序 `main.py`: +```bash +python app/main.py +``` + +可选运行方式(等价): +```bash +uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload +``` + +启动后访问接口文档: +``` +http://localhost:8000/docs +``` + +> 提示:如需设置数据库连接,请使用本地开发 DSN:`mysql+aiomysql://root:root@localhost:3306/kaopeilian?charset=utf8mb4`,可在 `local_config.py` 或环境变量 `DATABASE_URL` 中覆盖。 + +### 如何测试(How to Test) + +1. 安装测试依赖(如未安装): +```bash +pip install pytest +``` + +2. 仅运行 `test_main.py`: +```bash +pytest tests/test_main.py +``` + +(或运行全部测试) +```bash +pytest +``` + +# 考培练系统后端 + +## 项目概述 + +考培练系统是一个革命性的员工能力提升平台,通过集成Coze和Dify双AI平台,实现智能化的培训、考核和陪练功能。 + +## 系统账户 + +系统预置了以下测试账户: + +| 角色 | 用户名 | 密码 | 权限说明 | +| ---------- | ---------- | -------------- | ---------------------------- | +| 超级管理员 | superadmin | Superadmin123! | 系统最高权限,可管理所有功能 | +| 系统管理员 | admin | Admin123! | 可管理除“系统管理”模块外的全部功能(管理员仪表盘、用户管理、岗位管理、系统日志) | +| 测试学员 | testuser | TestPass123! | 可学习课程、参加考试和训练 | + +**注意**: + +- 这些账户支持两种密码加密方式(bcrypt 和 SHA256) +- 使用 `create_system_accounts.py` 创建 bcrypt 加密的账户(推荐用于生产环境) +- 使用 `create_simple_users.py` 创建 SHA256 加密的账户(用于 simple_main.py) + +## 技术栈 + +- **后端框架**: Python 3.8+ + FastAPI +- **数据库**: MySQL 8.0 + Redis +- **ORM**: SQLAlchemy 2.0 +- **AI平台**: Coze(陪练和对话) + Dify(考试和评估) +- **认证**: JWT +- **文档转换**: LibreOffice(用于Office文档在线预览) +- **部署**: Docker + +## 项目结构 + +``` +kaopeilian-backend/ +├── app/ # 应用主目录 +│ ├── api/ # API路由 +│ │ └── v1/ # API v1版本 +│ │ ├── training.py # 陪练模块API +│ │ └── ... # 其他模块API +│ ├── config/ # 配置管理 +│ │ ├── settings.py # 系统配置 +│ │ └── database.py # 数据库配置 +│ ├── core/ # 核心功能 +│ │ ├── deps.py # 依赖注入 +│ │ ├── exceptions.py # 异常定义 +│ │ └── ... +│ ├── models/ # 数据库模型 +│ │ ├── base.py # 基础模型 +│ │ ├── training.py # 陪练模型 +│ │ └── ... +│ ├── schemas/ # Pydantic模式 +│ │ ├── base.py # 基础模式 +│ │ ├── training.py # 陪练模式 +│ │ └── ... +│ ├── services/ # 业务逻辑 +│ │ ├── base_service.py # 基础服务类 +│ │ ├── training_service.py # 陪练服务 +│ │ └── ai/ # AI平台集成 +│ │ ├── coze/ # Coze集成 +│ │ └── dify/ # Dify集成 +│ └── main.py # 应用入口 +├── tests/ # 测试目录 +├── migrations/ # 数据库迁移 +├── requirements.txt # 生产依赖 +├── requirements-dev.txt # 开发依赖 +├── Makefile # 开发命令 +└── README.md # 项目说明 +``` + +## 快速开始 + +### 1. 环境准备 + +- Python 3.8+ +- MySQL 8.0 +- Redis + +### 2. 安装依赖 + +```bash +# 安装生产依赖 +make install + +# 或安装开发依赖(包含测试和代码检查工具) +make install-dev +``` + +### 3. 配置环境变量 + +复制环境变量示例文件并修改配置: + +```bash +cp .env.example .env +``` + +主要配置项: + +- 数据库连接:`DATABASE_URL` +- Redis连接:`REDIS_URL` +- JWT密钥:`SECRET_KEY` +- Coze配置:`COZE_API_TOKEN`, `COZE_TRAINING_BOT_ID` +- Dify配置:`DIFY_API_KEY` + +### 4. 数据库初始化 + +```bash +# 运行数据库迁移 +make migrate +# 数据库结构说明更新 + +- 统一主键:根据当前 ORM 定义,所有表主键均为 `INT AUTO_INCREMENT`。 +- 用户相关引用:`teams.leader_id`、`exams.user_id`、`user_teams.user_id` 统一为 `INT`。 +- 陪练模块: + - `training_scenes.status` 使用枚举 `DRAFT/ACTIVE/INACTIVE`; + - `training_sessions.status` 使用枚举 `CREATED/IN_PROGRESS/COMPLETED/CANCELLED/ERROR`; + - `training_messages.role` 使用 `USER/ASSISTANT/SYSTEM`;`type` 使用 `TEXT/VOICE/SYSTEM`; + - `training_sessions.user_id` 和 `training_reports.user_id` 为 `INT`(取消 `training_sessions.user_id` 外键); + - `training_reports.session_id` 对 `training_sessions.id` 唯一外键保持不变。 + +如需全量初始化,请使用 `scripts/init_database_unified.sql`。 +``` + +### 5. 启动服务 + +```bash +# 开发模式(自动重载) +make run + +# 或直接运行 +python -m app.main +``` + +服务将在 http://localhost:8000 启动 + +## 数据库连接信息 + +- 公网数据库(当前使用) + - Host: `120.79.247.16` 或 `aiedu.ireborn.com.cn` + - Port: `3306` + - User: `root` + - Password: `Kaopeilian2025!@#` + - Database: `kaopeilian` + - DSN (Python SQLAlchemy): `mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4` + +- 本地数据库(备用) + - Host: `127.0.0.1` + - Port: `3306` + - User: `root` + - Password: `root` + - Database: `kaopeilian` + - DSN (Python SQLAlchemy): `mysql+aiomysql://root:root@localhost:3306/kaopeilian?charset=utf8mb4` + +- 配置写入位置 + - 代码内用于本地开发覆盖:`local_config.py` 中的 `os.environ["DATABASE_URL"]` + - Docker 开发环境:`docker-compose.dev.yml` 中 `backend.environment.DATABASE_URL` + - 运行时环境变量文件:`.env`(如存在,将被容器挂载) + +> 提示:开发测试环境仅用于本机 `localhost` 访问,已开启代码自动重载。 + +## 文件管理 + +### 文件存储结构 +- **基础路径**: `/kaopeilian-backend/uploads/` +- **课程资料**: `uploads/courses/{course_id}/{filename}` +- **文件命名规则**: `{时间戳}_{8位哈希}.{扩展名}` + - 示例: `20250922213126_e21775bc.pdf` + +### 文件上传 +- **上传接口**: + - 通用上传: `POST /api/v1/upload/file` + - 课程资料上传: `POST /api/v1/upload/course/{course_id}/materials` +- **支持格式**: pdf、doc、docx、ppt、pptx、xls、xlsx、txt、md、zip、mp4、mp3、png、jpg、jpeg +- **大小限制**: 50MB +- **静态访问路径**: `http://localhost:8000/static/uploads/{相对路径}` + +### 文件删除策略 +1. **删除资料时**: + - 软删除数据库记录(标记 `is_deleted=true`) + - 同步删除物理文件 + - 文件删除失败仅记录日志,不影响业务流程 + +2. **删除课程时**: + - 软删除课程记录 + - 删除整个课程文件夹 (`uploads/courses/{course_id}/`) + - 使用 `shutil.rmtree` 递归删除 + - 文件夹删除失败仅记录日志,不影响业务流程 + +### 相关配置 +- **上传路径配置**: `app/core/config.py` 中的 `UPLOAD_PATH` 属性 +- **静态文件服务**: `app/main.py` 中使用 `StaticFiles` 挂载 +- **文件上传模块**: `app/api/v1/upload.py` + +### 文档预览功能 + +#### 支持的文件格式 +- **直接预览**: PDF、TXT、Markdown (md, mdx)、HTML、CSV、VTT、Properties +- **转换预览**: Word (doc, docx)、Excel (xls, xlsx) - 通过LibreOffice转换为PDF后预览 + +#### 系统依赖 +- **LibreOffice**: 用于Office文档转换 + - libreoffice-writer: Word文档支持 + - libreoffice-calc: Excel文档支持 + - libreoffice-impress: PowerPoint文档支持(未启用) + - libreoffice-core: 核心组件 +- **中文字体**: 支持中文文档预览 + - fonts-wqy-zenhei: 文泉驿正黑 + - fonts-wqy-microhei: 文泉驿微米黑 + +#### 预览API +- 获取预览信息: `GET /api/v1/preview/material/{material_id}` +- 检查转换服务: `GET /api/v1/preview/check-converter` + +#### 转换缓存机制 +- 转换后的PDF存储在: `uploads/converted/{course_id}/{material_id}.pdf` +- 仅在源文件更新时重新转换 +- 转换失败时自动降级为下载模式 + +## API文档 + +启动服务后,可以访问: + +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## 开发指南 + +### 代码规范 + +```bash +# 格式化代码 +make format + +# 运行代码检查 +make lint + +# 运行类型检查 +make type-check +``` + +### 测试 + +```bash +# 运行测试 +make test + +# 运行测试并生成覆盖率报告 +make test-cov +``` + +### 模块开发流程 + +1. 在 `app/models/` 创建数据模型 +2. 在 `app/schemas/` 创建Pydantic模式 +3. 在 `app/services/` 实现业务逻辑 +4. 在 `app/api/v1/` 创建API路由 +5. 编写测试用例 +6. 更新API契约文档 + +## 已实现功能 + +### 陪练模块 (Training) + +- **场景管理** + + - 获取场景列表(支持分类、状态筛选) + - 创建/更新/删除场景(管理员权限) + - 获取场景详情 +- **会话管理** + + - 开始陪练会话 + - 结束陪练会话 + - 获取会话列表 + - 获取会话详情 +- **消息管理** + + - 获取会话消息列表 + - 支持文本/语音消息 +- **报告管理** + + - 生成陪练报告 + - 获取报告列表 + - 获取报告详情 + +## 待实现功能 + +- [ ] 用户认证模块 (Auth) +- [ ] 用户管理模块 (User) +- [ ] 课程管理模块 (Course) +- [ ] 考试模块 (Exam) +- [ ] 数据分析模块 (Analytics) +- [ ] 系统管理模块 (Admin) +- [ ] Coze网关模块 +- [ ] WebSocket实时通信 + +## 部署 +## 常见问题与排错 + +### 1. 登录失败相关 + +- 报错 Unknown column 'users.is_deleted':请更新数据库,确保 `users` 表包含 `is_deleted` 与 `deleted_at` 字段(参见 `scripts/init_database_unified.sql` 或执行迁移)。 +- 默认账户无法登录:重置默认账户密码哈希或运行 `create_system_accounts.py`。默认账户见上方“系统账户”。 + +### 2. 依赖冲突(httpx 与 cozepy) + +- `cozepy==0.2.0` 依赖 `httpx<0.25.0`,请将 `requirements.txt` 中 `httpx` 固定为 `0.24.1`,并避免在 `requirements-dev.txt` 再次指定其他版本。 + +### 3. Docker 拉取镜像超时 + +- 可先本地直接运行后端(确保本机 MySQL/Redis 就绪),调通后再处理 Docker 网络问题。 + + +### Docker部署 + +```bash +# 构建镜像 +docker build -t kaopeilian-backend . + +# 运行容器 +docker run -d -p 8000:8000 --env-file .env kaopeilian-backend +``` + +### Docker Compose部署 + +```bash +# 启动所有服务 +docker-compose up -d + +# 查看日志 +docker-compose logs -f +``` + +## 贡献指南 + +1. Fork项目 +2. 创建功能分支 (`git checkout -b feature/AmazingFeature`) +3. 提交代码 (`git commit -m 'feat: Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 创建Pull Request + +### 提交规范 + +- `feat`: 新功能 +- `fix`: 修复bug +- `docs`: 文档更新 +- `style`: 代码格式调整 +- `refactor`: 代码重构 +- `test`: 测试相关 +- `chore`: 构建过程或辅助工具的变动 + +## 许可证 + +本项目采用 MIT 许可证 diff --git a/backend/SQL_EXECUTOR_FINAL_SUMMARY.md b/backend/SQL_EXECUTOR_FINAL_SUMMARY.md new file mode 100644 index 0000000..427e739 --- /dev/null +++ b/backend/SQL_EXECUTOR_FINAL_SUMMARY.md @@ -0,0 +1,142 @@ +# 🎯 SQL 执行器 API 开发完成总结 + +## ✅ 项目状态:开发完成,本地测试通过 + +## 📦 交付内容 + +### 1. API 端点 +- ✅ `/api/v1/sql/execute` - 标准JWT认证版 +- ✅ `/api/v1/sql/execute-simple` - 简化认证版(推荐Dify使用) +- ✅ `/api/v1/sql/validate` - SQL语法验证 +- ✅ `/api/v1/sql/tables` - 获取表列表 +- ✅ `/api/v1/sql/table/{name}/schema` - 获取表结构 + +### 2. 认证方式 +- ✅ **API Key**(推荐): `X-API-Key: dify-2025-kaopeilian` +- ✅ **长期Token**: `Authorization: Bearer permanent-token-for-dify-2025` +- ✅ **标准JWT**: 通过登录接口获取(30分钟有效期) + +### 3. 文档 +- ✅ `docs/openapi_sql_executor.yaml` - OpenAPI 3.1规范(YAML) +- ✅ `docs/openapi_sql_executor.json` - OpenAPI 3.1规范(JSON) +- ✅ `docs/dify_integration_summary.md` - Dify集成指南 +- ✅ `deploy/server_setup_guide.md` - 服务器部署指南 +- ✅ `deploy/quick_deploy.sh` - 一键部署脚本 + +### 4. 核心代码 +- ✅ `app/api/v1/sql_executor.py` - 主要API实现 +- ✅ `app/core/simple_auth.py` - 简化认证实现 +- ✅ `test_sql_executor.py` - 测试脚本 + +## 🚀 Dify 快速配置 + +### 方式一:导入OpenAPI(推荐) +1. 导入 `openapi_sql_executor.yaml` +2. 选择服务器:120.79.247.16:8000 +3. 配置认证(见下方) + +### 方式二:手动配置 +``` +URL: http://120.79.247.16:8000/api/v1/sql/execute-simple +方法: POST +鉴权类型: 请求头 +鉴权头部前缀: Custom +键: X-API-Key +值: dify-2025-kaopeilian +``` + +## 💡 使用示例 + +### 简单查询 +```json +{ + "sql": "SELECT * FROM users LIMIT 5" +} +``` + +### 参数化查询 +```json +{ + "sql": "SELECT * FROM courses WHERE category = :category", + "params": {"category": "护肤"} +} +``` + +### 数据插入 +```json +{ + "sql": "INSERT INTO knowledge_points (title, content, course_id) VALUES (:title, :content, :course_id)", + "params": { + "title": "面部护理", + "content": "详细内容", + "course_id": 1 + } +} +``` + +## 🌐 服务器部署步骤 + +1. **上传代码到服务器** + ```bash + scp -r * root@120.79.247.16:/opt/kaopeilian/backend/ + ``` + +2. **运行部署脚本** + ```bash + ssh root@120.79.247.16 + cd /opt/kaopeilian/backend + bash deploy/quick_deploy.sh + ``` + +3. **验证部署** + ```bash + curl http://120.79.247.16:8000/health + ``` + +## 📊 测试结果 + +### 本地测试(全部通过 ✅) +- 健康检查:✅ 正常 +- API Key认证:✅ 成功 +- 长期Token认证:✅ 成功 +- 参数化查询:✅ 成功 +- 数据写入:✅ 成功 + +### 公网测试(待部署) +- 服务尚未部署到公网服务器 +- 需要执行部署脚本 + +## 🔐 安全建议 + +1. **生产环境** + - 修改默认API Key和Token + - 使用环境变量管理密钥 + - 启用HTTPS加密传输 + +2. **访问控制** + - 配置防火墙限制IP + - 定期更换认证密钥 + - 监控异常访问 + +## 📞 技术支持 + +- 本地测试端口:8000 +- 服务器地址:120.79.247.16 +- 数据库:kaopeilian +- 认证密钥:已在文档中提供 + +## ⏰ 时间线 + +- 开发开始:2025-09-23 14:00 +- 开发完成:2025-09-23 16:30 +- 本地测试:✅ 通过 +- 生产部署:⏳ 待执行 + +--- + +**当前状态**:开发完成,本地测试通过,等待部署到生产环境。 + +**下一步**: +1. 执行服务器部署 +2. 在Dify中配置使用 +3. 集成到实际工作流 diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/alembic/versions/add_course_fields.sql b/backend/alembic/versions/add_course_fields.sql new file mode 100644 index 0000000..0e608a8 --- /dev/null +++ b/backend/alembic/versions/add_course_fields.sql @@ -0,0 +1,10 @@ +-- 为课程表添加学员统计等字段 + +ALTER TABLE `courses` +ADD COLUMN `student_count` INT DEFAULT 0 COMMENT '学习人数' AFTER `is_featured`, +ADD COLUMN `is_new` BOOLEAN DEFAULT TRUE COMMENT '是否新课程(最近30天内发布)' AFTER `student_count`, +ADD INDEX `idx_student_count` (`student_count`), +ADD INDEX `idx_is_new` (`is_new`); + + + diff --git a/backend/alembic/versions/add_mistake_mastery_fields.sql b/backend/alembic/versions/add_mistake_mastery_fields.sql new file mode 100644 index 0000000..14780b9 --- /dev/null +++ b/backend/alembic/versions/add_mistake_mastery_fields.sql @@ -0,0 +1,12 @@ +-- 为错题表添加掌握状态和统计字段 + +ALTER TABLE `exam_mistakes` +ADD COLUMN `mastery_status` VARCHAR(20) DEFAULT 'unmastered' COMMENT '掌握状态: unmastered-未掌握, mastered-已掌握' AFTER `question_type`, +ADD COLUMN `difficulty` VARCHAR(20) DEFAULT 'medium' COMMENT '题目难度: easy-简单, medium-中等, hard-困难' AFTER `mastery_status`, +ADD COLUMN `wrong_count` INT DEFAULT 1 COMMENT '错误次数统计' AFTER `difficulty`, +ADD COLUMN `mastered_at` DATETIME NULL COMMENT '标记掌握时间' AFTER `wrong_count`, +ADD INDEX `idx_mastery_status` (`mastery_status`), +ADD INDEX `idx_difficulty` (`difficulty`); + + + diff --git a/backend/alembic/versions/create_system_logs_table.sql b/backend/alembic/versions/create_system_logs_table.sql new file mode 100644 index 0000000..c682730 --- /dev/null +++ b/backend/alembic/versions/create_system_logs_table.sql @@ -0,0 +1,30 @@ +-- 创建系统日志表 +-- 用于记录系统操作、错误、安全事件等日志信息 + +CREATE TABLE IF NOT EXISTS `system_logs` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '日志ID', + `level` VARCHAR(20) NOT NULL COMMENT '日志级别: debug, info, warning, error', + `type` VARCHAR(50) NOT NULL COMMENT '日志类型: system, user, api, error, security', + `user` VARCHAR(100) NULL COMMENT '操作用户', + `user_id` INT NULL COMMENT '用户ID', + `ip` VARCHAR(100) NULL COMMENT 'IP地址', + `message` TEXT NOT NULL COMMENT '日志消息', + `user_agent` VARCHAR(500) NULL COMMENT 'User Agent', + `path` VARCHAR(500) NULL COMMENT '请求路径(API路径)', + `method` VARCHAR(10) NULL COMMENT '请求方法', + `extra_data` TEXT NULL COMMENT '额外数据(JSON格式)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_system_logs_level` (`level`), + INDEX `idx_system_logs_type` (`type`), + INDEX `idx_system_logs_user` (`user`), + INDEX `idx_system_logs_user_id` (`user_id`), + INDEX `idx_system_logs_path` (`path`), + INDEX `idx_system_logs_created_at` (`created_at`), + INDEX `idx_system_logs_level_type` (`level`, `type`), + INDEX `idx_system_logs_user_created` (`user`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统日志表'; + + + diff --git a/backend/alembic/versions/create_tasks_table.sql b/backend/alembic/versions/create_tasks_table.sql new file mode 100644 index 0000000..eead2bb --- /dev/null +++ b/backend/alembic/versions/create_tasks_table.sql @@ -0,0 +1,50 @@ +-- 创建任务表 +CREATE TABLE `tasks` ( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `title` VARCHAR(200) NOT NULL COMMENT '任务标题', + `description` TEXT COMMENT '任务描述', + `priority` ENUM('low', 'medium', 'high') DEFAULT 'medium' COMMENT '优先级', + `status` ENUM('pending', 'ongoing', 'completed', 'expired') DEFAULT 'pending' COMMENT '任务状态', + `creator_id` INT NOT NULL COMMENT '创建人ID', + `deadline` DATETIME COMMENT '截止时间', + `requirements` JSON COMMENT '任务要求配置', + `progress` INT DEFAULT 0 COMMENT '完成进度(0-100)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` BOOLEAN DEFAULT FALSE, + INDEX `idx_status` (`status`), + INDEX `idx_creator` (`creator_id`), + INDEX `idx_deadline` (`deadline`), + FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务表'; + +-- 创建任务课程关联表 +CREATE TABLE `task_courses` ( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `task_id` INT NOT NULL COMMENT '任务ID', + `course_id` INT NOT NULL COMMENT '课程ID', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uk_task_course` (`task_id`, `course_id`), + FOREIGN KEY (`task_id`) REFERENCES `tasks`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务课程关联表'; + +-- 创建任务分配表 +CREATE TABLE `task_assignments` ( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `task_id` INT NOT NULL COMMENT '任务ID', + `user_id` INT NOT NULL COMMENT '分配用户ID', + `team_id` INT DEFAULT NULL COMMENT '团队ID(如果按团队分配)', + `status` ENUM('not_started', 'in_progress', 'completed') DEFAULT 'not_started' COMMENT '完成状态', + `progress` INT DEFAULT 0 COMMENT '个人完成进度(0-100)', + `completed_at` DATETIME COMMENT '完成时间', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY `uk_task_user` (`task_id`, `user_id`), + INDEX `idx_status` (`status`), + FOREIGN KEY (`task_id`) REFERENCES `tasks`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务分配表'; + + + diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..2f881a5 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""考培练系统后端应用包""" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..557b975 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +# API 路由模块 diff --git a/backend/app/api/v1/03-Agent-Course/api_contract.yaml b/backend/app/api/v1/03-Agent-Course/api_contract.yaml new file mode 100644 index 0000000..1a7d70a --- /dev/null +++ b/backend/app/api/v1/03-Agent-Course/api_contract.yaml @@ -0,0 +1,497 @@ +openapi: 3.0.0 +info: + title: 课程管理模块API契约 + description: 定义课程管理模块对外提供的所有API接口 + version: 1.0.0 + +servers: + - url: http://localhost:8000/api/v1 + description: 本地开发服务器 + +paths: + /courses: + get: + summary: 获取课程列表 + description: 支持分页和多条件筛选 + operationId: getCourses + security: + - bearerAuth: [] + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: size + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + - name: status + in: query + schema: + type: string + enum: [draft, published, archived] + - name: category + in: query + schema: + type: string + enum: [technology, management, business, general] + - name: is_featured + in: query + schema: + type: boolean + - name: keyword + in: query + schema: + type: string + responses: + "200": + description: 成功获取课程列表 + content: + application/json: + schema: + $ref: "#/components/schemas/CoursePageResponse" + "401": + $ref: "#/components/responses/UnauthorizedError" + + post: + summary: 创建课程 + description: 创建新课程(需要管理员权限) + operationId: createCourse + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CourseCreate" + responses: + "201": + description: 成功创建课程 + content: + application/json: + schema: + $ref: "#/components/schemas/CourseResponse" + "401": + $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/ForbiddenError" + "409": + $ref: "#/components/responses/ConflictError" + + /courses/{courseId}: + get: + summary: 获取课程详情 + operationId: getCourse + security: + - bearerAuth: [] + parameters: + - name: courseId + in: path + required: true + schema: + type: integer + responses: + "200": + description: 成功获取课程详情 + content: + application/json: + schema: + $ref: "#/components/schemas/CourseResponse" + "401": + $ref: "#/components/responses/UnauthorizedError" + "404": + $ref: "#/components/responses/NotFoundError" + + put: + summary: 更新课程 + description: 更新课程信息(需要管理员权限) + operationId: updateCourse + security: + - bearerAuth: [] + parameters: + - name: courseId + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CourseUpdate" + responses: + "200": + description: 成功更新课程 + content: + application/json: + schema: + $ref: "#/components/schemas/CourseResponse" + "401": + $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + + delete: + summary: 删除课程 + description: 软删除课程(需要管理员权限) + operationId: deleteCourse + security: + - bearerAuth: [] + parameters: + - name: courseId + in: path + required: true + schema: + type: integer + responses: + "200": + description: 成功删除课程 + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteResponse" + "400": + $ref: "#/components/responses/BadRequestError" + "401": + $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + + /courses/{courseId}/knowledge-points: + get: + summary: 获取课程知识点列表 + operationId: getCourseKnowledgePoints + security: + - bearerAuth: [] + parameters: + - name: courseId + in: path + required: true + schema: + type: integer + - name: parent_id + in: query + schema: + type: integer + nullable: true + responses: + "200": + description: 成功获取知识点列表 + content: + application/json: + schema: + $ref: "#/components/schemas/KnowledgePointListResponse" + "401": + $ref: "#/components/responses/UnauthorizedError" + "404": + $ref: "#/components/responses/NotFoundError" + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + schemas: + ResponseBase: + type: object + required: + - code + - message + properties: + code: + type: integer + default: 200 + message: + type: string + request_id: + type: string + timestamp: + type: string + format: date-time + + CourseBase: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: string + category: + type: string + enum: [technology, management, business, general] + default: general + cover_image: + type: string + maxLength: 500 + duration_hours: + type: number + format: float + minimum: 0 + difficulty_level: + type: integer + minimum: 1 + maximum: 5 + tags: + type: array + items: + type: string + sort_order: + type: integer + default: 0 + is_featured: + type: boolean + default: false + + CourseCreate: + allOf: + - $ref: "#/components/schemas/CourseBase" + - type: object + required: + - name + properties: + status: + type: string + enum: [draft, published, archived] + default: draft + + CourseUpdate: + allOf: + - $ref: "#/components/schemas/CourseBase" + - type: object + properties: + status: + type: string + enum: [draft, published, archived] + + Course: + allOf: + - $ref: "#/components/schemas/CourseBase" + - type: object + required: + - id + - status + - created_at + - updated_at + properties: + id: + type: integer + status: + type: string + enum: [draft, published, archived] + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + published_at: + type: string + format: date-time + nullable: true + publisher_id: + type: integer + nullable: true + created_by: + type: integer + nullable: true + updated_by: + type: integer + nullable: true + + CourseResponse: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + data: + $ref: "#/components/schemas/Course" + + CoursePageResponse: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + data: + type: object + required: + - items + - total + - page + - size + - pages + properties: + items: + type: array + items: + $ref: "#/components/schemas/Course" + total: + type: integer + page: + type: integer + size: + type: integer + pages: + type: integer + + KnowledgePoint: + type: object + required: + - id + - course_id + - name + - level + - created_at + - updated_at + properties: + id: + type: integer + course_id: + type: integer + name: + type: string + maxLength: 200 + description: + type: string + parent_id: + type: integer + nullable: true + level: + type: integer + path: + type: string + nullable: true + sort_order: + type: integer + weight: + type: number + format: float + is_required: + type: boolean + estimated_hours: + type: number + format: float + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + KnowledgePointListResponse: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/KnowledgePoint" + + DeleteResponse: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + data: + type: boolean + + ErrorDetail: + type: object + required: + - message + properties: + message: + type: string + error_code: + type: string + field: + type: string + details: + type: object + + responses: + BadRequestError: + description: 请求参数错误 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + code: + example: 400 + detail: + $ref: "#/components/schemas/ErrorDetail" + + UnauthorizedError: + description: 未认证 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + code: + example: 401 + detail: + $ref: "#/components/schemas/ErrorDetail" + + ForbiddenError: + description: 权限不足 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + code: + example: 403 + detail: + $ref: "#/components/schemas/ErrorDetail" + + NotFoundError: + description: 资源不存在 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + code: + example: 404 + detail: + $ref: "#/components/schemas/ErrorDetail" + + ConflictError: + description: 资源冲突 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + code: + example: 409 + detail: + $ref: "#/components/schemas/ErrorDetail" diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..2a8a627 --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -0,0 +1,105 @@ +""" +API v1 版本模块 +整合所有 v1 版本的路由 +""" + +from fastapi import APIRouter + +# 先只导入必要的路由 +from .coze_gateway import router as coze_router + +# 创建 v1 版本的主路由 +api_router = APIRouter() + +# 包含各个子路由 +api_router.include_router(coze_router, tags=["coze"]) + +# TODO: 逐步添加其他路由 +from .auth import router as auth_router +from .courses import router as courses_router +from .users import router as users_router +from .training import router as training_router +from .admin import router as admin_router +from .positions import router as positions_router +from .upload import router as upload_router +from .teams import router as teams_router +from .knowledge_analysis import router as knowledge_analysis_router +from .system import router as system_router +from .sql_executor import router as sql_executor_router + +from .exam import router as exam_router +from .practice import router as practice_router +from .course_chat import router as course_chat_router +from .broadcast import router as broadcast_router +from .preview import router as preview_router +from .yanji import router as yanji_router +from .ability import router as ability_router +from .statistics import router as statistics_router +from .team_dashboard import router as team_dashboard_router +from .team_management import router as team_management_router +# Manager 模块路由 +from .manager import student_scores_router, student_practice_router +from .system_logs import router as system_logs_router +from .tasks import router as tasks_router +from .endpoints.employee_sync import router as employee_sync_router +from .notifications import router as notifications_router +from .scrm import router as scrm_router +# 管理后台路由 +from .admin_portal import router as admin_portal_router + +api_router.include_router(auth_router, prefix="/auth", tags=["auth"]) +# courses_router 已在内部定义了 prefix="/courses",此处不再额外添加前缀 +api_router.include_router(courses_router, tags=["courses"]) +api_router.include_router(users_router, prefix="/users", tags=["users"]) +# training_router 已在内部定义了 prefix="/training",此处不再额外添加前缀 +api_router.include_router(training_router, tags=["training"]) +# admin_router 已在内部定义了 prefix="/admin",此处不再额外添加前缀 +api_router.include_router(admin_router, tags=["admin"]) +api_router.include_router(positions_router, tags=["positions"]) +# upload_router 已在内部定义了 prefix="/upload",此处不再额外添加前缀 +api_router.include_router(upload_router, tags=["upload"]) +api_router.include_router(teams_router, tags=["teams"]) +# knowledge_analysis_router 不需要额外前缀,路径已在路由中定义 +api_router.include_router(knowledge_analysis_router, tags=["knowledge-analysis"]) +# system_router 已在内部定义了 prefix="/system",此处不再额外添加前缀 +api_router.include_router(system_router, tags=["system"]) +# sql_executor_router SQL 执行器 +api_router.include_router(sql_executor_router, prefix="/sql", tags=["sql-executor"]) +# exam_router 已在内部定义了 prefix="/exams",此处不再额外添加前缀 +api_router.include_router(exam_router, tags=["exams"]) +# practice_router 陪练功能路由 +api_router.include_router(practice_router, prefix="/practice", tags=["practice"]) +# course_chat_router 与课程对话路由 +api_router.include_router(course_chat_router, prefix="/course", tags=["course-chat"]) +# broadcast_router 播课功能路由(不添加prefix,路径在router内部定义) +api_router.include_router(broadcast_router, tags=["broadcast"]) +# preview_router 文件预览路由 +api_router.include_router(preview_router, prefix="/preview", tags=["preview"]) +# yanji_router 言迹智能工牌路由 +api_router.include_router(yanji_router, prefix="/yanji", tags=["yanji"]) +# ability_router 能力评估路由 +api_router.include_router(ability_router, prefix="/ability", tags=["ability"]) +# statistics_router 统计分析路由(不添加prefix,路径在router内部定义) +api_router.include_router(statistics_router, tags=["statistics"]) +# team_dashboard_router 团队看板路由(不添加prefix,路径在router内部定义为/team/dashboard) +api_router.include_router(team_dashboard_router, tags=["team-dashboard"]) +# team_management_router 团队成员管理路由(不添加prefix,路径在router内部定义为/team/management) +api_router.include_router(team_management_router, tags=["team-management"]) +# student_scores_router 学员考试成绩管理路由(不添加prefix,路径在router内部定义为/manager/student-scores) +api_router.include_router(student_scores_router, tags=["manager-student-scores"]) +# student_practice_router 学员陪练记录管理路由(不添加prefix,路径在router内部定义为/manager/student-practice) +api_router.include_router(student_practice_router, tags=["manager-student-practice"]) +# system_logs_router 系统日志路由(不添加prefix,路径在router内部定义为/admin/logs) +api_router.include_router(system_logs_router, tags=["system-logs"]) +# tasks_router 任务管理路由(不添加prefix,路径在router内部定义为/manager/tasks) +api_router.include_router(tasks_router, tags=["tasks"]) +# employee_sync_router 员工同步路由 +api_router.include_router(employee_sync_router, prefix="/employee-sync", tags=["employee-sync"]) +# notifications_router 站内消息通知路由(不添加prefix,路径在router内部定义为/notifications) +api_router.include_router(notifications_router, tags=["notifications"]) +# scrm_router SCRM系统对接路由(prefix在router内部定义为/scrm) +api_router.include_router(scrm_router, tags=["scrm"]) +# admin_portal_router SaaS超级管理后台路由(prefix在router内部定义为/admin) +api_router.include_router(admin_portal_router, tags=["admin-portal"]) + +__all__ = ["api_router"] diff --git a/backend/app/api/v1/ability.py b/backend/app/api/v1/ability.py new file mode 100644 index 0000000..cc1225c --- /dev/null +++ b/backend/app/api/v1/ability.py @@ -0,0 +1,187 @@ +""" +能力评估API接口 +用于智能工牌数据分析、能力评估报告生成等 +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from typing import List + +from app.core.deps import get_current_user, get_db +from app.models.user import User +from app.schemas.base import ResponseModel +from app.schemas.ability import AbilityAssessmentResponse, AbilityAssessmentHistory +from app.services.yanji_service import YanjiService +from app.services.ability_assessment_service import get_ability_assessment_service + +import logging + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.post("/analyze-yanji", response_model=ResponseModel) +async def analyze_yanji_badge_data( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 分析智能工牌数据生成能力评估和课程推荐 + + 使用 Python 原生 AI 服务实现。 + + 功能说明: + 1. 从言迹智能工牌获取员工的最近10条录音记录 + 2. 分析对话数据,进行能力评估(6个维度) + 3. 基于能力短板生成课程推荐(3-5门) + 4. 保存评估记录到数据库 + + 要求: + - 用户必须已绑定手机号(用于匹配言迹数据) + + 返回: + - assessment_id: 评估记录ID + - total_score: 综合评分(0-100) + - dimensions: 能力维度列表(6个维度) + - recommended_courses: 推荐课程列表(3-5门) + - conversation_count: 分析的对话数量 + """ + # 检查用户是否绑定手机号 + if not current_user.phone: + logger.warning(f"用户未绑定手机号: user_id={current_user.id}") + raise HTTPException( + status_code=400, + detail="用户未绑定手机号,无法匹配言迹数据" + ) + + # 获取服务实例 + yanji_service = YanjiService() + assessment_service = get_ability_assessment_service() + + try: + logger.info( + f"开始分析智能工牌数据: user_id={current_user.id}, " + f"phone={current_user.phone}" + ) + + # 调用能力评估服务(使用 Python 原生实现) + result = await assessment_service.analyze_yanji_conversations( + user_id=current_user.id, + phone=current_user.phone, + db=db, + yanji_service=yanji_service, + engine="v2" # 固定使用 V2 + ) + + logger.info( + f"智能工牌数据分析完成: user_id={current_user.id}, " + f"assessment_id={result['assessment_id']}, " + f"total_score={result['total_score']}" + ) + + return ResponseModel( + code=200, + message="智能工牌数据分析完成", + data=result + ) + + except ValueError as e: + # 业务逻辑错误(如未找到录音记录) + logger.warning(f"智能工牌数据分析失败: {e}") + raise HTTPException(status_code=404, detail=str(e)) + + except Exception as e: + # 系统错误 + logger.error(f"分析智能工牌数据失败: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"分析失败: {str(e)}" + ) + + +@router.get("/history", response_model=ResponseModel) +async def get_assessment_history( + limit: int = Query(default=10, ge=1, le=50, description="返回记录数量"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取用户的能力评估历史记录 + + 参数: + - limit: 返回记录数量(默认10,最大50) + + 返回: + - 评估历史记录列表 + """ + assessment_service = get_ability_assessment_service() + + try: + history = await assessment_service.get_user_assessment_history( + user_id=current_user.id, + db=db, + limit=limit + ) + + return ResponseModel( + code=200, + message=f"获取评估历史成功,共{len(history)}条", + data={"history": history, "total": len(history)} + ) + + except Exception as e: + logger.error(f"获取评估历史失败: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"获取评估历史失败: {str(e)}" + ) + + +@router.get("/{assessment_id}", response_model=ResponseModel) +async def get_assessment_detail( + assessment_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取单个评估记录的详细信息 + + 参数: + - assessment_id: 评估记录ID + + 返回: + - 评估详细信息 + """ + assessment_service = get_ability_assessment_service() + + try: + detail = await assessment_service.get_assessment_detail( + assessment_id=assessment_id, + db=db + ) + + # 权限检查:只能查看自己的评估记录 + if detail['user_id'] != current_user.id: + raise HTTPException( + status_code=403, + detail="无权访问该评估记录" + ) + + return ResponseModel( + code=200, + message="获取评估详情成功", + data=detail + ) + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + except HTTPException: + raise + + except Exception as e: + logger.error(f"获取评估详情失败: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"获取评估详情失败: {str(e)}" + ) + diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py new file mode 100644 index 0000000..2c58de4 --- /dev/null +++ b/backend/app/api/v1/admin.py @@ -0,0 +1,509 @@ +""" +管理员相关API路由 +""" + +from typing import Optional, List, Dict, Any +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.core.deps import get_current_active_user as get_current_user, get_db +from app.models.user import User +from app.models.course import Course, CourseStatus +from app.schemas.base import ResponseModel + +router = APIRouter(prefix="/admin") + + +@router.get("/dashboard/stats") +async def get_dashboard_stats( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> ResponseModel: + """ + 获取管理员仪表盘统计数据 + + 需要管理员权限 + """ + # 权限检查 + if current_user.role != "admin": + return ResponseModel( + code=403, + message="权限不足,需要管理员权限" + ) + + # 用户统计 + total_users = await db.scalar(select(func.count(User.id))) + + # 计算最近30天的新增用户 + thirty_days_ago = datetime.now() - timedelta(days=30) + new_users_count = await db.scalar( + select(func.count(User.id)) + .where(User.created_at >= thirty_days_ago) + ) + + # 计算增长率(假设上个月也是30天) + sixty_days_ago = datetime.now() - timedelta(days=60) + last_month_users = await db.scalar( + select(func.count(User.id)) + .where(User.created_at >= sixty_days_ago) + .where(User.created_at < thirty_days_ago) + ) + + growth_rate = 0.0 + if last_month_users > 0: + growth_rate = ((new_users_count - last_month_users) / last_month_users) * 100 + + # 课程统计 + total_courses = await db.scalar( + select(func.count(Course.id)) + .where(Course.status == CourseStatus.PUBLISHED) + ) + + # TODO: 完成的课程数需要根据用户课程进度表计算 + completed_courses = 0 # 暂时设为0 + + # 考试统计(如果有考试表的话) + total_exams = 0 + avg_score = 0.0 + pass_rate = "0%" + + # 学习时长统计(如果有学习记录表的话) + total_learning_hours = 0 + avg_learning_hours = 0.0 + active_rate = "0%" + + # 构建响应数据 + stats = { + "users": { + "total": total_users, + "growth": new_users_count, + "growthRate": f"{growth_rate:.1f}%" + }, + "courses": { + "total": total_courses, + "completed": completed_courses, + "completionRate": f"{(completed_courses / total_courses * 100) if total_courses > 0 else 0:.1f}%" + }, + "exams": { + "total": total_exams, + "avgScore": avg_score, + "passRate": pass_rate + }, + "learning": { + "totalHours": total_learning_hours, + "avgHours": avg_learning_hours, + "activeRate": active_rate + } + } + + return ResponseModel( + code=200, + message="获取仪表盘统计数据成功", + data=stats + ) + + +@router.get("/dashboard/user-growth") +async def get_user_growth_data( + days: int = Query(30, description="统计天数", ge=7, le=90), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> ResponseModel: + """ + 获取用户增长数据 + + Args: + days: 统计天数,默认30天 + + 需要管理员权限 + """ + # 权限检查 + if current_user.role != "admin": + return ResponseModel( + code=403, + message="权限不足,需要管理员权限" + ) + + # 准备日期列表 + dates = [] + new_users = [] + active_users = [] + + end_date = datetime.now().date() + + for i in range(days): + current_date = end_date - timedelta(days=days-1-i) + dates.append(current_date.strftime("%Y-%m-%d")) + + # 统计当天新增用户 + next_date = current_date + timedelta(days=1) + new_count = await db.scalar( + select(func.count(User.id)) + .where(func.date(User.created_at) == current_date) + ) + new_users.append(new_count or 0) + + # 统计当天活跃用户(有登录记录) + active_count = await db.scalar( + select(func.count(User.id)) + .where(func.date(User.last_login_at) == current_date) + ) + active_users.append(active_count or 0) + + return ResponseModel( + code=200, + message="获取用户增长数据成功", + data={ + "dates": dates, + "newUsers": new_users, + "activeUsers": active_users + } + ) + + +@router.get("/dashboard/course-completion") +async def get_course_completion_data( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> ResponseModel: + """ + 获取课程完成率数据 + + 需要管理员权限 + """ + # 权限检查 + if current_user.role != "admin": + return ResponseModel( + code=403, + message="权限不足,需要管理员权限" + ) + + # 获取所有已发布的课程 + courses_result = await db.execute( + select(Course.name, Course.id) + .where(Course.status == CourseStatus.PUBLISHED) + .order_by(Course.sort_order, Course.id) + .limit(10) # 限制显示前10个课程 + ) + courses = courses_result.all() + + course_names = [] + completion_rates = [] + + for course_name, course_id in courses: + course_names.append(course_name) + + # TODO: 根据用户课程进度表计算完成率 + # 这里暂时生成模拟数据 + import random + completion_rate = random.randint(60, 95) + completion_rates.append(completion_rate) + + return ResponseModel( + code=200, + message="获取课程完成率数据成功", + data={ + "courses": course_names, + "completionRates": completion_rates + } + ) + + +# ===== 岗位管理(最小可用 stub 版本)===== + +def _ensure_admin(user: User) -> Optional[ResponseModel]: + if user.role != "admin": + return ResponseModel(code=403, message="权限不足,需要管理员权限") + return None + + +# 注意:positions相关路由已移至positions.py +# _sample_positions函数和所有positions路由已删除,避免与positions.py冲突 + + +# ===== 用户批量操作 ===== + +from pydantic import BaseModel +from app.models.position_member import PositionMember + + +class BatchUserOperation(BaseModel): + """批量用户操作请求模型""" + ids: List[int] + action: str # delete, activate, deactivate, change_role, assign_position, assign_team + value: Optional[Any] = None # 角色值、岗位ID、团队ID等 + + +@router.post("/users/batch", response_model=ResponseModel) +async def batch_user_operation( + operation: BatchUserOperation, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> ResponseModel: + """ + 批量用户操作 + + 支持的操作类型: + - delete: 批量删除用户(软删除) + - activate: 批量启用用户 + - deactivate: 批量禁用用户 + - change_role: 批量修改角色(需要 value 参数) + - assign_position: 批量分配岗位(需要 value 参数为岗位ID) + - assign_team: 批量分配团队(需要 value 参数为团队ID) + + 权限:需要管理员权限 + """ + # 权限检查 + if current_user.role != "admin": + return ResponseModel( + code=403, + message="权限不足,需要管理员权限" + ) + + if not operation.ids: + return ResponseModel( + code=400, + message="请选择要操作的用户" + ) + + # 不能操作自己 + if current_user.id in operation.ids: + return ResponseModel( + code=400, + message="不能对自己执行批量操作" + ) + + # 获取要操作的用户 + result = await db.execute( + select(User).where(User.id.in_(operation.ids), User.is_deleted == False) + ) + users = result.scalars().all() + + if not users: + return ResponseModel( + code=404, + message="未找到要操作的用户" + ) + + success_count = 0 + failed_count = 0 + errors = [] + + try: + if operation.action == "delete": + # 批量软删除 + for user in users: + try: + user.is_deleted = True + user.deleted_at = datetime.now() + success_count += 1 + except Exception as e: + failed_count += 1 + errors.append(f"删除用户 {user.username} 失败: {str(e)}") + + await db.commit() + + elif operation.action == "activate": + # 批量启用 + for user in users: + try: + user.is_active = True + success_count += 1 + except Exception as e: + failed_count += 1 + errors.append(f"启用用户 {user.username} 失败: {str(e)}") + + await db.commit() + + elif operation.action == "deactivate": + # 批量禁用 + for user in users: + try: + user.is_active = False + success_count += 1 + except Exception as e: + failed_count += 1 + errors.append(f"禁用用户 {user.username} 失败: {str(e)}") + + await db.commit() + + elif operation.action == "change_role": + # 批量修改角色 + if not operation.value: + return ResponseModel( + code=400, + message="请指定要修改的角色" + ) + + valid_roles = ["trainee", "manager", "admin"] + if operation.value not in valid_roles: + return ResponseModel( + code=400, + message=f"无效的角色,可选值: {', '.join(valid_roles)}" + ) + + for user in users: + try: + user.role = operation.value + success_count += 1 + except Exception as e: + failed_count += 1 + errors.append(f"修改用户 {user.username} 角色失败: {str(e)}") + + await db.commit() + + elif operation.action == "assign_position": + # 批量分配岗位 + if not operation.value: + return ResponseModel( + code=400, + message="请指定要分配的岗位ID" + ) + + position_id = int(operation.value) + + # 获取岗位信息用于通知 + from app.models.position import Position + position_result = await db.execute( + select(Position).where(Position.id == position_id) + ) + position = position_result.scalar_one_or_none() + position_name = position.name if position else "未知岗位" + + # 记录新分配成功的用户ID(用于发送通知) + newly_assigned_user_ids = [] + + for user in users: + try: + # 检查是否已有该岗位 + existing = await db.execute( + select(PositionMember).where( + PositionMember.user_id == user.id, + PositionMember.position_id == position_id, + PositionMember.is_deleted == False + ) + ) + if existing.scalar_one_or_none(): + # 已有该岗位,跳过 + success_count += 1 + continue + + # 添加岗位关联(PositionMember模型没有created_by字段) + member = PositionMember( + position_id=position_id, + user_id=user.id, + joined_at=datetime.now() + ) + db.add(member) + newly_assigned_user_ids.append(user.id) + success_count += 1 + except Exception as e: + failed_count += 1 + errors.append(f"为用户 {user.username} 分配岗位失败: {str(e)}") + + await db.commit() + + # 发送岗位分配通知给新分配的用户 + if newly_assigned_user_ids: + try: + from app.services.notification_service import notification_service + from app.schemas.notification import NotificationBatchCreate, NotificationType + + notification_batch = NotificationBatchCreate( + user_ids=newly_assigned_user_ids, + title="岗位分配通知", + content=f"您已被分配到「{position_name}」岗位,请查看相关培训课程。", + type=NotificationType.POSITION_ASSIGN, + related_id=position_id, + related_type="position", + sender_id=current_user.id + ) + + await notification_service.batch_create_notifications( + db=db, + batch_in=notification_batch + ) + except Exception as e: + # 通知发送失败不影响岗位分配结果 + import logging + logging.getLogger(__name__).error(f"发送岗位分配通知失败: {str(e)}") + + elif operation.action == "assign_team": + # 批量分配团队 + if not operation.value: + return ResponseModel( + code=400, + message="请指定要分配的团队ID" + ) + + from app.models.user import user_teams + + team_id = int(operation.value) + + for user in users: + try: + # 检查是否已在该团队 + existing = await db.execute( + select(user_teams).where( + user_teams.c.user_id == user.id, + user_teams.c.team_id == team_id + ) + ) + if existing.first(): + # 已在该团队,跳过 + success_count += 1 + continue + + # 添加团队关联 + await db.execute( + user_teams.insert().values( + user_id=user.id, + team_id=team_id, + role="member", + joined_at=datetime.now() + ) + ) + success_count += 1 + except Exception as e: + failed_count += 1 + errors.append(f"为用户 {user.username} 分配团队失败: {str(e)}") + + await db.commit() + + else: + return ResponseModel( + code=400, + message=f"不支持的操作类型: {operation.action}" + ) + + # 返回结果 + action_names = { + "delete": "删除", + "activate": "启用", + "deactivate": "禁用", + "change_role": "修改角色", + "assign_position": "分配岗位", + "assign_team": "分配团队" + } + action_name = action_names.get(operation.action, operation.action) + + return ResponseModel( + code=200, + message=f"批量{action_name}完成:成功 {success_count} 个,失败 {failed_count} 个", + data={ + "success_count": success_count, + "failed_count": failed_count, + "errors": errors + } + ) + + except Exception as e: + await db.rollback() + return ResponseModel( + code=500, + message=f"批量操作失败: {str(e)}" + ) + + diff --git a/backend/app/api/v1/admin_portal/__init__.py b/backend/app/api/v1/admin_portal/__init__.py new file mode 100644 index 0000000..bb69b5c --- /dev/null +++ b/backend/app/api/v1/admin_portal/__init__.py @@ -0,0 +1,24 @@ +""" +SaaS 超级管理后台 API + +提供租户管理、配置管理、提示词管理等功能 +""" + +from fastapi import APIRouter + +from .auth import router as auth_router +from .tenants import router as tenants_router +from .configs import router as configs_router +from .prompts import router as prompts_router +from .features import router as features_router + +# 创建管理后台主路由 +router = APIRouter(prefix="/admin", tags=["管理后台"]) + +# 注册子路由 +router.include_router(auth_router) +router.include_router(tenants_router) +router.include_router(configs_router) +router.include_router(prompts_router) +router.include_router(features_router) + diff --git a/backend/app/api/v1/admin_portal/auth.py b/backend/app/api/v1/admin_portal/auth.py new file mode 100644 index 0000000..7f12b94 --- /dev/null +++ b/backend/app/api/v1/admin_portal/auth.py @@ -0,0 +1,277 @@ +""" +管理员认证 API +""" + +import os +from datetime import datetime, timedelta +from typing import Optional + +import jwt +import pymysql +from fastapi import APIRouter, Depends, HTTPException, status, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from passlib.context import CryptContext + +from .schemas import ( + AdminLoginRequest, + AdminLoginResponse, + AdminUserInfo, + AdminChangePasswordRequest, + ResponseModel, +) + +router = APIRouter(prefix="/auth", tags=["管理员认证"]) + +# 密码加密 +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# JWT 配置 +SECRET_KEY = os.getenv("ADMIN_JWT_SECRET", "admin-secret-key-kaopeilian-2026") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_HOURS = 24 + +# 安全认证 +security = HTTPBearer() + +# 管理库连接配置 +ADMIN_DB_CONFIG = { + "host": os.getenv("ADMIN_DB_HOST", "prod-mysql"), + "port": int(os.getenv("ADMIN_DB_PORT", "3306")), + "user": os.getenv("ADMIN_DB_USER", "root"), + "password": os.getenv("ADMIN_DB_PASSWORD", "ProdMySQL2025!@#"), + "db": os.getenv("ADMIN_DB_NAME", "kaopeilian_admin"), + "charset": "utf8mb4", +} + + +def get_db_connection(): + """获取数据库连接""" + return pymysql.connect(**ADMIN_DB_CONFIG, cursorclass=pymysql.cursors.DictCursor) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """验证密码""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """获取密码哈希""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """创建访问令牌""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def decode_access_token(token: str) -> dict: + """解码访问令牌""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token已过期", + ) + except jwt.InvalidTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的Token", + ) + + +async def get_current_admin( + credentials: HTTPAuthorizationCredentials = Depends(security) +) -> AdminUserInfo: + """获取当前登录的管理员""" + token = credentials.credentials + payload = decode_access_token(token) + + admin_id = payload.get("sub") + if not admin_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的Token", + ) + + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT id, username, email, full_name, role, is_active, last_login_at + FROM admin_users WHERE id = %s + """, + (admin_id,) + ) + admin = cursor.fetchone() + + if not admin: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="管理员不存在", + ) + + if not admin["is_active"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="账户已被禁用", + ) + + return AdminUserInfo( + id=admin["id"], + username=admin["username"], + email=admin["email"], + full_name=admin["full_name"], + role=admin["role"], + last_login_at=admin["last_login_at"], + ) + finally: + conn.close() + + +async def require_superadmin( + admin: AdminUserInfo = Depends(get_current_admin) +) -> AdminUserInfo: + """要求超级管理员权限""" + if admin.role != "superadmin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要超级管理员权限", + ) + return admin + + +@router.post("/login", response_model=AdminLoginResponse, summary="管理员登录") +async def admin_login(request: Request, login_data: AdminLoginRequest): + """ + 管理员登录 + + - **username**: 用户名 + - **password**: 密码 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 查询管理员 + cursor.execute( + """ + SELECT id, username, email, full_name, role, password_hash, is_active, last_login_at + FROM admin_users WHERE username = %s + """, + (login_data.username,) + ) + admin = cursor.fetchone() + + if not admin: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + ) + + if not admin["is_active"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="账户已被禁用", + ) + + # 验证密码 + if not verify_password(login_data.password, admin["password_hash"]): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + ) + + # 更新最后登录时间和IP + client_ip = request.client.host if request.client else None + cursor.execute( + """ + UPDATE admin_users + SET last_login_at = NOW(), last_login_ip = %s + WHERE id = %s + """, + (client_ip, admin["id"]) + ) + conn.commit() + + # 创建 Token + access_token = create_access_token( + data={"sub": str(admin["id"]), "username": admin["username"], "role": admin["role"]} + ) + + return AdminLoginResponse( + access_token=access_token, + token_type="bearer", + expires_in=ACCESS_TOKEN_EXPIRE_HOURS * 3600, + admin_user=AdminUserInfo( + id=admin["id"], + username=admin["username"], + email=admin["email"], + full_name=admin["full_name"], + role=admin["role"], + last_login_at=datetime.now(), + ), + ) + finally: + conn.close() + + +@router.get("/me", response_model=AdminUserInfo, summary="获取当前管理员信息") +async def get_me(admin: AdminUserInfo = Depends(get_current_admin)): + """获取当前登录管理员的信息""" + return admin + + +@router.post("/change-password", response_model=ResponseModel, summary="修改密码") +async def change_password( + data: AdminChangePasswordRequest, + admin: AdminUserInfo = Depends(get_current_admin), +): + """ + 修改当前管理员密码 + + - **old_password**: 旧密码 + - **new_password**: 新密码 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证旧密码 + cursor.execute( + "SELECT password_hash FROM admin_users WHERE id = %s", + (admin.id,) + ) + row = cursor.fetchone() + + if not verify_password(data.old_password, row["password_hash"]): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="旧密码错误", + ) + + # 更新密码 + new_hash = get_password_hash(data.new_password) + cursor.execute( + "UPDATE admin_users SET password_hash = %s WHERE id = %s", + (new_hash, admin.id) + ) + conn.commit() + + return ResponseModel(message="密码修改成功") + finally: + conn.close() + + +@router.post("/logout", response_model=ResponseModel, summary="退出登录") +async def admin_logout(admin: AdminUserInfo = Depends(get_current_admin)): + """退出登录(客户端需清除 Token)""" + return ResponseModel(message="退出成功") + diff --git a/backend/app/api/v1/admin_portal/configs.py b/backend/app/api/v1/admin_portal/configs.py new file mode 100644 index 0000000..98811b2 --- /dev/null +++ b/backend/app/api/v1/admin_portal/configs.py @@ -0,0 +1,480 @@ +""" +配置管理 API +""" + +import os +import json +from typing import Optional, List, Dict + +import pymysql +from fastapi import APIRouter, Depends, HTTPException, status, Query + +from .auth import get_current_admin, get_db_connection, AdminUserInfo +from .schemas import ( + ConfigTemplateResponse, + TenantConfigResponse, + TenantConfigCreate, + TenantConfigUpdate, + TenantConfigGroupResponse, + ConfigBatchUpdate, + ResponseModel, +) + +router = APIRouter(prefix="/configs", tags=["配置管理"]) + +# 配置分组显示名称 +CONFIG_GROUP_NAMES = { + "database": "数据库配置", + "redis": "Redis配置", + "security": "安全配置", + "coze": "Coze配置", + "ai": "AI服务配置", + "yanji": "言迹工牌配置", + "storage": "文件存储配置", + "basic": "基础配置", +} + + +def log_operation(cursor, admin: AdminUserInfo, tenant_id: int, tenant_code: str, + operation_type: str, resource_type: str, resource_id: int, + resource_name: str, old_value: dict = None, new_value: dict = None): + """记录操作日志""" + cursor.execute( + """ + INSERT INTO operation_logs + (admin_user_id, admin_username, tenant_id, tenant_code, operation_type, + resource_type, resource_id, resource_name, old_value, new_value) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + (admin.id, admin.username, tenant_id, tenant_code, operation_type, + resource_type, resource_id, resource_name, + json.dumps(old_value, ensure_ascii=False) if old_value else None, + json.dumps(new_value, ensure_ascii=False) if new_value else None) + ) + + +@router.get("/templates", response_model=List[ConfigTemplateResponse], summary="获取配置模板") +async def get_config_templates( + config_group: Optional[str] = Query(None, description="配置分组筛选"), + admin: AdminUserInfo = Depends(get_current_admin), +): + """ + 获取配置模板列表 + + 配置模板定义了所有可配置项的元数据 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + if config_group: + cursor.execute( + """ + SELECT * FROM config_templates + WHERE config_group = %s + ORDER BY sort_order, id + """, + (config_group,) + ) + else: + cursor.execute( + "SELECT * FROM config_templates ORDER BY config_group, sort_order, id" + ) + + rows = cursor.fetchall() + + result = [] + for row in rows: + # 解析 options 字段 + options = None + if row.get("options"): + try: + options = json.loads(row["options"]) + except: + pass + + result.append(ConfigTemplateResponse( + id=row["id"], + config_group=row["config_group"], + config_key=row["config_key"], + display_name=row["display_name"], + description=row["description"], + value_type=row["value_type"], + default_value=row["default_value"], + is_required=row["is_required"], + is_secret=row["is_secret"], + options=options, + sort_order=row["sort_order"], + )) + + return result + finally: + conn.close() + + +@router.get("/groups", response_model=List[Dict], summary="获取配置分组列表") +async def get_config_groups(admin: AdminUserInfo = Depends(get_current_admin)): + """获取配置分组列表""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT config_group, COUNT(*) as count + FROM config_templates + GROUP BY config_group + ORDER BY config_group + """ + ) + rows = cursor.fetchall() + + return [ + { + "group_name": row["config_group"], + "group_display_name": CONFIG_GROUP_NAMES.get(row["config_group"], row["config_group"]), + "config_count": row["count"], + } + for row in rows + ] + finally: + conn.close() + + +@router.get("/tenants/{tenant_id}", response_model=List[TenantConfigGroupResponse], summary="获取租户配置") +async def get_tenant_configs( + tenant_id: int, + config_group: Optional[str] = Query(None, description="配置分组筛选"), + admin: AdminUserInfo = Depends(get_current_admin), +): + """ + 获取租户的所有配置 + + 返回按分组整理的配置列表,包含模板信息 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 查询配置模板和租户配置 + group_filter = "AND ct.config_group = %s" if config_group else "" + params = [tenant_id, config_group] if config_group else [tenant_id] + + cursor.execute( + f""" + SELECT + ct.config_group, + ct.config_key, + ct.display_name, + ct.description, + ct.value_type, + ct.default_value, + ct.is_required, + ct.is_secret, + ct.sort_order, + tc.id as config_id, + tc.config_value, + tc.is_encrypted, + tc.created_at, + tc.updated_at + FROM config_templates ct + LEFT JOIN tenant_configs tc + ON tc.config_group = ct.config_group + AND tc.config_key = ct.config_key + AND tc.tenant_id = %s + WHERE 1=1 {group_filter} + ORDER BY ct.config_group, ct.sort_order, ct.id + """, + params + ) + rows = cursor.fetchall() + + # 按分组整理 + groups: Dict[str, List] = {} + for row in rows: + group = row["config_group"] + if group not in groups: + groups[group] = [] + + # 如果是敏感信息且有值,隐藏部分内容 + config_value = row["config_value"] + if row["is_secret"] and config_value: + if len(config_value) > 8: + config_value = config_value[:4] + "****" + config_value[-4:] + else: + config_value = "****" + + groups[group].append(TenantConfigResponse( + id=row["config_id"] or 0, + config_group=row["config_group"], + config_key=row["config_key"], + config_value=config_value if not row["is_secret"] else row["config_value"], + value_type=row["value_type"], + is_encrypted=row["is_encrypted"] or False, + description=row["description"], + created_at=row["created_at"] or None, + updated_at=row["updated_at"] or None, + display_name=row["display_name"], + is_required=row["is_required"], + is_secret=row["is_secret"], + )) + + return [ + TenantConfigGroupResponse( + group_name=group, + group_display_name=CONFIG_GROUP_NAMES.get(group, group), + configs=configs, + ) + for group, configs in groups.items() + ] + finally: + conn.close() + + +@router.put("/tenants/{tenant_id}/{config_group}/{config_key}", response_model=ResponseModel, summary="更新单个配置") +async def update_tenant_config( + tenant_id: int, + config_group: str, + config_key: str, + data: TenantConfigUpdate, + admin: AdminUserInfo = Depends(get_current_admin), +): + """更新租户的单个配置项""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 验证配置模板存在 + cursor.execute( + """ + SELECT value_type, is_secret FROM config_templates + WHERE config_group = %s AND config_key = %s + """, + (config_group, config_key) + ) + template = cursor.fetchone() + if not template: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无效的配置项", + ) + + # 检查是否已有配置 + cursor.execute( + """ + SELECT id, config_value FROM tenant_configs + WHERE tenant_id = %s AND config_group = %s AND config_key = %s + """, + (tenant_id, config_group, config_key) + ) + existing = cursor.fetchone() + + if existing: + # 更新 + old_value = existing["config_value"] + cursor.execute( + """ + UPDATE tenant_configs + SET config_value = %s, is_encrypted = %s + WHERE id = %s + """, + (data.config_value, template["is_secret"], existing["id"]) + ) + else: + # 插入 + old_value = None + cursor.execute( + """ + INSERT INTO tenant_configs + (tenant_id, config_group, config_key, config_value, value_type, is_encrypted) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (tenant_id, config_group, config_key, data.config_value, + template["value_type"], template["is_secret"]) + ) + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, tenant["code"], + "update", "config", tenant_id, f"{config_group}.{config_key}", + old_value={"value": old_value} if old_value else None, + new_value={"value": data.config_value} + ) + + conn.commit() + + return ResponseModel(message="配置已更新") + finally: + conn.close() + + +@router.put("/tenants/{tenant_id}/batch", response_model=ResponseModel, summary="批量更新配置") +async def batch_update_tenant_configs( + tenant_id: int, + data: ConfigBatchUpdate, + admin: AdminUserInfo = Depends(get_current_admin), +): + """批量更新租户配置""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + updated_count = 0 + for config in data.configs: + # 获取模板信息 + cursor.execute( + """ + SELECT value_type, is_secret FROM config_templates + WHERE config_group = %s AND config_key = %s + """, + (config.config_group, config.config_key) + ) + template = cursor.fetchone() + if not template: + continue + + # 检查是否已有配置 + cursor.execute( + """ + SELECT id FROM tenant_configs + WHERE tenant_id = %s AND config_group = %s AND config_key = %s + """, + (tenant_id, config.config_group, config.config_key) + ) + existing = cursor.fetchone() + + if existing: + cursor.execute( + """ + UPDATE tenant_configs + SET config_value = %s, is_encrypted = %s + WHERE id = %s + """, + (config.config_value, template["is_secret"], existing["id"]) + ) + else: + cursor.execute( + """ + INSERT INTO tenant_configs + (tenant_id, config_group, config_key, config_value, value_type, is_encrypted) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (tenant_id, config.config_group, config.config_key, config.config_value, + template["value_type"], template["is_secret"]) + ) + + updated_count += 1 + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, tenant["code"], + "batch_update", "config", tenant_id, f"批量更新 {updated_count} 项配置" + ) + + conn.commit() + + return ResponseModel(message=f"已更新 {updated_count} 项配置") + finally: + conn.close() + + +@router.delete("/tenants/{tenant_id}/{config_group}/{config_key}", response_model=ResponseModel, summary="删除配置") +async def delete_tenant_config( + tenant_id: int, + config_group: str, + config_key: str, + admin: AdminUserInfo = Depends(get_current_admin), +): + """删除租户的配置项(恢复为默认值)""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 删除配置 + cursor.execute( + """ + DELETE FROM tenant_configs + WHERE tenant_id = %s AND config_group = %s AND config_key = %s + """, + (tenant_id, config_group, config_key) + ) + + if cursor.rowcount == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="配置不存在", + ) + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, tenant["code"], + "delete", "config", tenant_id, f"{config_group}.{config_key}" + ) + + conn.commit() + + return ResponseModel(message="配置已删除,将使用默认值") + finally: + conn.close() + + +@router.post("/tenants/{tenant_id}/refresh-cache", response_model=ResponseModel, summary="刷新配置缓存") +async def refresh_tenant_config_cache( + tenant_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """刷新租户的配置缓存""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 获取租户编码 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 刷新缓存 + try: + from app.core.config import DynamicConfig + import asyncio + asyncio.create_task(DynamicConfig.refresh_cache(tenant["code"])) + except Exception as e: + pass # 缓存刷新失败不影响主流程 + + return ResponseModel(message="缓存刷新请求已发送") + finally: + conn.close() + diff --git a/backend/app/api/v1/admin_portal/features.py b/backend/app/api/v1/admin_portal/features.py new file mode 100644 index 0000000..370cebc --- /dev/null +++ b/backend/app/api/v1/admin_portal/features.py @@ -0,0 +1,424 @@ +""" +功能开关管理 API +""" + +import os +import json +from typing import Optional, List, Dict + +import pymysql +from fastapi import APIRouter, Depends, HTTPException, status, Query + +from .auth import get_current_admin, get_db_connection, AdminUserInfo +from .schemas import ( + FeatureSwitchCreate, + FeatureSwitchUpdate, + FeatureSwitchResponse, + FeatureSwitchGroupResponse, + ResponseModel, +) + +router = APIRouter(prefix="/features", tags=["功能开关"]) + +# 功能分组显示名称 +FEATURE_GROUP_NAMES = { + "exam": "考试模块", + "practice": "陪练模块", + "broadcast": "播课模块", + "course": "课程模块", + "yanji": "智能工牌模块", +} + + +def log_operation(cursor, admin: AdminUserInfo, tenant_id: int, tenant_code: str, + operation_type: str, resource_type: str, resource_id: int, + resource_name: str, old_value: dict = None, new_value: dict = None): + """记录操作日志""" + cursor.execute( + """ + INSERT INTO operation_logs + (admin_user_id, admin_username, tenant_id, tenant_code, operation_type, + resource_type, resource_id, resource_name, old_value, new_value) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + (admin.id, admin.username, tenant_id, tenant_code, operation_type, + resource_type, resource_id, resource_name, + json.dumps(old_value, ensure_ascii=False) if old_value else None, + json.dumps(new_value, ensure_ascii=False) if new_value else None) + ) + + +@router.get("/defaults", response_model=List[FeatureSwitchGroupResponse], summary="获取默认功能开关") +async def get_default_features( + admin: AdminUserInfo = Depends(get_current_admin), +): + """获取全局默认的功能开关配置""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT * FROM feature_switches + WHERE tenant_id IS NULL + ORDER BY feature_group, id + """ + ) + rows = cursor.fetchall() + + # 按分组整理 + groups: Dict[str, List] = {} + for row in rows: + group = row["feature_group"] or "other" + if group not in groups: + groups[group] = [] + + config = None + if row.get("config"): + try: + config = json.loads(row["config"]) + except: + pass + + groups[group].append(FeatureSwitchResponse( + id=row["id"], + tenant_id=row["tenant_id"], + feature_code=row["feature_code"], + feature_name=row["feature_name"], + feature_group=row["feature_group"], + is_enabled=row["is_enabled"], + config=config, + description=row["description"], + created_at=row["created_at"], + updated_at=row["updated_at"], + )) + + return [ + FeatureSwitchGroupResponse( + group_name=group, + group_display_name=FEATURE_GROUP_NAMES.get(group, group), + features=features, + ) + for group, features in groups.items() + ] + finally: + conn.close() + + +@router.get("/tenants/{tenant_id}", response_model=List[FeatureSwitchGroupResponse], summary="获取租户功能开关") +async def get_tenant_features( + tenant_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """ + 获取租户的功能开关配置 + + 返回租户自定义配置和默认配置的合并结果 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 获取默认配置 + cursor.execute( + """ + SELECT * FROM feature_switches + WHERE tenant_id IS NULL + ORDER BY feature_group, id + """ + ) + default_rows = cursor.fetchall() + + # 获取租户配置 + cursor.execute( + """ + SELECT * FROM feature_switches + WHERE tenant_id = %s + """, + (tenant_id,) + ) + tenant_rows = cursor.fetchall() + + # 合并配置 + tenant_features = {row["feature_code"]: row for row in tenant_rows} + + groups: Dict[str, List] = {} + for row in default_rows: + group = row["feature_group"] or "other" + if group not in groups: + groups[group] = [] + + # 使用租户配置覆盖默认配置 + effective_row = tenant_features.get(row["feature_code"], row) + + config = None + if effective_row.get("config"): + try: + config = json.loads(effective_row["config"]) + except: + pass + + groups[group].append(FeatureSwitchResponse( + id=effective_row["id"], + tenant_id=effective_row["tenant_id"], + feature_code=effective_row["feature_code"], + feature_name=effective_row["feature_name"], + feature_group=effective_row["feature_group"], + is_enabled=effective_row["is_enabled"], + config=config, + description=effective_row["description"], + created_at=effective_row["created_at"], + updated_at=effective_row["updated_at"], + )) + + return [ + FeatureSwitchGroupResponse( + group_name=group, + group_display_name=FEATURE_GROUP_NAMES.get(group, group), + features=features, + ) + for group, features in groups.items() + ] + finally: + conn.close() + + +@router.put("/tenants/{tenant_id}/{feature_code}", response_model=ResponseModel, summary="更新租户功能开关") +async def update_tenant_feature( + tenant_id: int, + feature_code: str, + data: FeatureSwitchUpdate, + admin: AdminUserInfo = Depends(get_current_admin), +): + """更新租户的功能开关""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 获取默认配置 + cursor.execute( + """ + SELECT * FROM feature_switches + WHERE tenant_id IS NULL AND feature_code = %s + """, + (feature_code,) + ) + default_feature = cursor.fetchone() + + if not default_feature: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无效的功能编码", + ) + + # 检查租户是否已有配置 + cursor.execute( + """ + SELECT id, is_enabled FROM feature_switches + WHERE tenant_id = %s AND feature_code = %s + """, + (tenant_id, feature_code) + ) + existing = cursor.fetchone() + + if existing: + # 更新 + old_enabled = existing["is_enabled"] + + update_fields = [] + update_values = [] + + if data.is_enabled is not None: + update_fields.append("is_enabled = %s") + update_values.append(data.is_enabled) + + if data.config is not None: + update_fields.append("config = %s") + update_values.append(json.dumps(data.config)) + + if update_fields: + update_values.append(existing["id"]) + cursor.execute( + f"UPDATE feature_switches SET {', '.join(update_fields)} WHERE id = %s", + update_values + ) + else: + # 创建租户配置 + old_enabled = default_feature["is_enabled"] + + cursor.execute( + """ + INSERT INTO feature_switches + (tenant_id, feature_code, feature_name, feature_group, is_enabled, config, description) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + (tenant_id, feature_code, default_feature["feature_name"], + default_feature["feature_group"], + data.is_enabled if data.is_enabled is not None else default_feature["is_enabled"], + json.dumps(data.config) if data.config else default_feature["config"], + default_feature["description"]) + ) + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, tenant["code"], + "update", "feature", tenant_id, feature_code, + old_value={"is_enabled": old_enabled}, + new_value={"is_enabled": data.is_enabled, "config": data.config} + ) + + conn.commit() + + status_text = "启用" if data.is_enabled else "禁用" + return ResponseModel(message=f"功能 {default_feature['feature_name']} 已{status_text}") + finally: + conn.close() + + +@router.delete("/tenants/{tenant_id}/{feature_code}", response_model=ResponseModel, summary="重置租户功能开关") +async def reset_tenant_feature( + tenant_id: int, + feature_code: str, + admin: AdminUserInfo = Depends(get_current_admin), +): + """重置租户的功能开关为默认值""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 删除租户配置 + cursor.execute( + """ + DELETE FROM feature_switches + WHERE tenant_id = %s AND feature_code = %s + """, + (tenant_id, feature_code) + ) + + if cursor.rowcount == 0: + return ResponseModel(message="功能配置已是默认值") + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, tenant["code"], + "reset", "feature", tenant_id, feature_code + ) + + conn.commit() + + return ResponseModel(message="功能配置已重置为默认值") + finally: + conn.close() + + +@router.post("/tenants/{tenant_id}/batch", response_model=ResponseModel, summary="批量更新功能开关") +async def batch_update_tenant_features( + tenant_id: int, + features: List[Dict], + admin: AdminUserInfo = Depends(get_current_admin), +): + """ + 批量更新租户的功能开关 + + 请求体格式: + [ + {"feature_code": "exam_module", "is_enabled": true}, + {"feature_code": "practice_voice", "is_enabled": false} + ] + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + updated_count = 0 + for feature in features: + feature_code = feature.get("feature_code") + is_enabled = feature.get("is_enabled") + + if not feature_code or is_enabled is None: + continue + + # 获取默认配置 + cursor.execute( + """ + SELECT * FROM feature_switches + WHERE tenant_id IS NULL AND feature_code = %s + """, + (feature_code,) + ) + default_feature = cursor.fetchone() + + if not default_feature: + continue + + # 检查租户是否已有配置 + cursor.execute( + """ + SELECT id FROM feature_switches + WHERE tenant_id = %s AND feature_code = %s + """, + (tenant_id, feature_code) + ) + existing = cursor.fetchone() + + if existing: + cursor.execute( + "UPDATE feature_switches SET is_enabled = %s WHERE id = %s", + (is_enabled, existing["id"]) + ) + else: + cursor.execute( + """ + INSERT INTO feature_switches + (tenant_id, feature_code, feature_name, feature_group, is_enabled, description) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (tenant_id, feature_code, default_feature["feature_name"], + default_feature["feature_group"], is_enabled, default_feature["description"]) + ) + + updated_count += 1 + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, tenant["code"], + "batch_update", "feature", tenant_id, f"批量更新 {updated_count} 项功能开关" + ) + + conn.commit() + + return ResponseModel(message=f"已更新 {updated_count} 项功能开关") + finally: + conn.close() + diff --git a/backend/app/api/v1/admin_portal/prompts.py b/backend/app/api/v1/admin_portal/prompts.py new file mode 100644 index 0000000..cf2fd84 --- /dev/null +++ b/backend/app/api/v1/admin_portal/prompts.py @@ -0,0 +1,637 @@ +""" +AI 提示词管理 API +""" + +import os +import json +from typing import Optional, List + +import pymysql +from fastapi import APIRouter, Depends, HTTPException, status, Query + +from .auth import get_current_admin, require_superadmin, get_db_connection, AdminUserInfo +from .schemas import ( + AIPromptCreate, + AIPromptUpdate, + AIPromptResponse, + AIPromptVersionResponse, + TenantPromptResponse, + TenantPromptUpdate, + ResponseModel, +) + +router = APIRouter(prefix="/prompts", tags=["提示词管理"]) + + +def log_operation(cursor, admin: AdminUserInfo, tenant_id: int, tenant_code: str, + operation_type: str, resource_type: str, resource_id: int, + resource_name: str, old_value: dict = None, new_value: dict = None): + """记录操作日志""" + cursor.execute( + """ + INSERT INTO operation_logs + (admin_user_id, admin_username, tenant_id, tenant_code, operation_type, + resource_type, resource_id, resource_name, old_value, new_value) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + (admin.id, admin.username, tenant_id, tenant_code, operation_type, + resource_type, resource_id, resource_name, + json.dumps(old_value, ensure_ascii=False) if old_value else None, + json.dumps(new_value, ensure_ascii=False) if new_value else None) + ) + + +@router.get("", response_model=List[AIPromptResponse], summary="获取提示词列表") +async def list_prompts( + module: Optional[str] = Query(None, description="模块筛选"), + is_active: Optional[bool] = Query(None, description="是否启用"), + admin: AdminUserInfo = Depends(get_current_admin), +): + """ + 获取所有 AI 提示词模板 + + - **module**: 模块筛选(course, exam, practice, ability) + - **is_active**: 是否启用 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + conditions = [] + params = [] + + if module: + conditions.append("module = %s") + params.append(module) + + if is_active is not None: + conditions.append("is_active = %s") + params.append(is_active) + + where_clause = " AND ".join(conditions) if conditions else "1=1" + + cursor.execute( + f""" + SELECT * FROM ai_prompts + WHERE {where_clause} + ORDER BY module, id + """, + params + ) + rows = cursor.fetchall() + + result = [] + for row in rows: + # 解析 JSON 字段 + variables = None + if row.get("variables"): + try: + variables = json.loads(row["variables"]) + except: + pass + + output_schema = None + if row.get("output_schema"): + try: + output_schema = json.loads(row["output_schema"]) + except: + pass + + result.append(AIPromptResponse( + id=row["id"], + code=row["code"], + name=row["name"], + description=row["description"], + module=row["module"], + system_prompt=row["system_prompt"], + user_prompt_template=row["user_prompt_template"], + variables=variables, + output_schema=output_schema, + model_recommendation=row["model_recommendation"], + max_tokens=row["max_tokens"], + temperature=float(row["temperature"]) if row["temperature"] else 0.7, + is_system=row["is_system"], + is_active=row["is_active"], + version=row["version"], + created_at=row["created_at"], + updated_at=row["updated_at"], + )) + + return result + finally: + conn.close() + + +@router.get("/{prompt_id}", response_model=AIPromptResponse, summary="获取提示词详情") +async def get_prompt( + prompt_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """获取提示词详情""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute("SELECT * FROM ai_prompts WHERE id = %s", (prompt_id,)) + row = cursor.fetchone() + + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="提示词不存在", + ) + + # 解析 JSON 字段 + variables = None + if row.get("variables"): + try: + variables = json.loads(row["variables"]) + except: + pass + + output_schema = None + if row.get("output_schema"): + try: + output_schema = json.loads(row["output_schema"]) + except: + pass + + return AIPromptResponse( + id=row["id"], + code=row["code"], + name=row["name"], + description=row["description"], + module=row["module"], + system_prompt=row["system_prompt"], + user_prompt_template=row["user_prompt_template"], + variables=variables, + output_schema=output_schema, + model_recommendation=row["model_recommendation"], + max_tokens=row["max_tokens"], + temperature=float(row["temperature"]) if row["temperature"] else 0.7, + is_system=row["is_system"], + is_active=row["is_active"], + version=row["version"], + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + finally: + conn.close() + + +@router.post("", response_model=AIPromptResponse, summary="创建提示词") +async def create_prompt( + data: AIPromptCreate, + admin: AdminUserInfo = Depends(require_superadmin), +): + """ + 创建新的提示词模板 + + 需要超级管理员权限 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 检查编码是否已存在 + cursor.execute("SELECT id FROM ai_prompts WHERE code = %s", (data.code,)) + if cursor.fetchone(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="提示词编码已存在", + ) + + # 创建提示词 + cursor.execute( + """ + INSERT INTO ai_prompts + (code, name, description, module, system_prompt, user_prompt_template, + variables, output_schema, model_recommendation, max_tokens, temperature, + is_system, created_by) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, %s) + """, + (data.code, data.name, data.description, data.module, + data.system_prompt, data.user_prompt_template, + json.dumps(data.variables) if data.variables else None, + json.dumps(data.output_schema) if data.output_schema else None, + data.model_recommendation, data.max_tokens, data.temperature, + admin.id) + ) + prompt_id = cursor.lastrowid + + # 记录操作日志 + log_operation( + cursor, admin, None, None, + "create", "prompt", prompt_id, data.name, + new_value=data.model_dump() + ) + + conn.commit() + + return await get_prompt(prompt_id, admin) + finally: + conn.close() + + +@router.put("/{prompt_id}", response_model=AIPromptResponse, summary="更新提示词") +async def update_prompt( + prompt_id: int, + data: AIPromptUpdate, + admin: AdminUserInfo = Depends(get_current_admin), +): + """ + 更新提示词模板 + + 更新会自动保存版本历史 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 获取原提示词 + cursor.execute("SELECT * FROM ai_prompts WHERE id = %s", (prompt_id,)) + old_prompt = cursor.fetchone() + + if not old_prompt: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="提示词不存在", + ) + + # 保存版本历史(如果系统提示词或用户提示词有变化) + if data.system_prompt or data.user_prompt_template: + new_version = old_prompt["version"] + 1 + + cursor.execute( + """ + INSERT INTO ai_prompt_versions + (prompt_id, version, system_prompt, user_prompt_template, variables, + output_schema, change_summary, created_by) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, + (prompt_id, old_prompt["version"], + old_prompt["system_prompt"], old_prompt["user_prompt_template"], + old_prompt["variables"], old_prompt["output_schema"], + f"版本 {old_prompt['version']} 备份", + admin.id) + ) + else: + new_version = old_prompt["version"] + + # 构建更新语句 + update_fields = [] + update_values = [] + + if data.name is not None: + update_fields.append("name = %s") + update_values.append(data.name) + + if data.description is not None: + update_fields.append("description = %s") + update_values.append(data.description) + + if data.system_prompt is not None: + update_fields.append("system_prompt = %s") + update_values.append(data.system_prompt) + + if data.user_prompt_template is not None: + update_fields.append("user_prompt_template = %s") + update_values.append(data.user_prompt_template) + + if data.variables is not None: + update_fields.append("variables = %s") + update_values.append(json.dumps(data.variables)) + + if data.output_schema is not None: + update_fields.append("output_schema = %s") + update_values.append(json.dumps(data.output_schema)) + + if data.model_recommendation is not None: + update_fields.append("model_recommendation = %s") + update_values.append(data.model_recommendation) + + if data.max_tokens is not None: + update_fields.append("max_tokens = %s") + update_values.append(data.max_tokens) + + if data.temperature is not None: + update_fields.append("temperature = %s") + update_values.append(data.temperature) + + if data.is_active is not None: + update_fields.append("is_active = %s") + update_values.append(data.is_active) + + if not update_fields: + return await get_prompt(prompt_id, admin) + + # 更新版本号 + if data.system_prompt or data.user_prompt_template: + update_fields.append("version = %s") + update_values.append(new_version) + + update_fields.append("updated_by = %s") + update_values.append(admin.id) + update_values.append(prompt_id) + + cursor.execute( + f"UPDATE ai_prompts SET {', '.join(update_fields)} WHERE id = %s", + update_values + ) + + # 记录操作日志 + log_operation( + cursor, admin, None, None, + "update", "prompt", prompt_id, old_prompt["name"], + old_value={"version": old_prompt["version"]}, + new_value=data.model_dump(exclude_unset=True) + ) + + conn.commit() + + return await get_prompt(prompt_id, admin) + finally: + conn.close() + + +@router.get("/{prompt_id}/versions", response_model=List[AIPromptVersionResponse], summary="获取提示词版本历史") +async def get_prompt_versions( + prompt_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """获取提示词的版本历史""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT * FROM ai_prompt_versions + WHERE prompt_id = %s + ORDER BY version DESC + """, + (prompt_id,) + ) + rows = cursor.fetchall() + + result = [] + for row in rows: + variables = None + if row.get("variables"): + try: + variables = json.loads(row["variables"]) + except: + pass + + result.append(AIPromptVersionResponse( + id=row["id"], + prompt_id=row["prompt_id"], + version=row["version"], + system_prompt=row["system_prompt"], + user_prompt_template=row["user_prompt_template"], + variables=variables, + change_summary=row["change_summary"], + created_at=row["created_at"], + )) + + return result + finally: + conn.close() + + +@router.post("/{prompt_id}/rollback/{version}", response_model=AIPromptResponse, summary="回滚提示词版本") +async def rollback_prompt_version( + prompt_id: int, + version: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """回滚到指定版本的提示词""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 获取指定版本 + cursor.execute( + """ + SELECT * FROM ai_prompt_versions + WHERE prompt_id = %s AND version = %s + """, + (prompt_id, version) + ) + version_row = cursor.fetchone() + + if not version_row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="版本不存在", + ) + + # 获取当前提示词 + cursor.execute("SELECT * FROM ai_prompts WHERE id = %s", (prompt_id,)) + current = cursor.fetchone() + + if not current: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="提示词不存在", + ) + + # 保存当前版本到历史 + new_version = current["version"] + 1 + cursor.execute( + """ + INSERT INTO ai_prompt_versions + (prompt_id, version, system_prompt, user_prompt_template, variables, + output_schema, change_summary, created_by) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, + (prompt_id, current["version"], + current["system_prompt"], current["user_prompt_template"], + current["variables"], current["output_schema"], + f"回滚前备份(版本 {current['version']})", + admin.id) + ) + + # 回滚 + cursor.execute( + """ + UPDATE ai_prompts + SET system_prompt = %s, user_prompt_template = %s, variables = %s, + output_schema = %s, version = %s, updated_by = %s + WHERE id = %s + """, + (version_row["system_prompt"], version_row["user_prompt_template"], + version_row["variables"], version_row["output_schema"], + new_version, admin.id, prompt_id) + ) + + # 记录操作日志 + log_operation( + cursor, admin, None, None, + "rollback", "prompt", prompt_id, current["name"], + old_value={"version": current["version"]}, + new_value={"version": new_version, "rollback_from": version} + ) + + conn.commit() + + return await get_prompt(prompt_id, admin) + finally: + conn.close() + + +@router.get("/tenants/{tenant_id}", response_model=List[TenantPromptResponse], summary="获取租户自定义提示词") +async def get_tenant_prompts( + tenant_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """获取租户的自定义提示词列表""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT tp.*, ap.code as prompt_code, ap.name as prompt_name + FROM tenant_prompts tp + JOIN ai_prompts ap ON tp.prompt_id = ap.id + WHERE tp.tenant_id = %s + ORDER BY ap.module, ap.id + """, + (tenant_id,) + ) + rows = cursor.fetchall() + + return [ + TenantPromptResponse( + id=row["id"], + tenant_id=row["tenant_id"], + prompt_id=row["prompt_id"], + prompt_code=row["prompt_code"], + prompt_name=row["prompt_name"], + system_prompt=row["system_prompt"], + user_prompt_template=row["user_prompt_template"], + is_active=row["is_active"], + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + for row in rows + ] + finally: + conn.close() + + +@router.put("/tenants/{tenant_id}/{prompt_id}", response_model=ResponseModel, summary="更新租户自定义提示词") +async def update_tenant_prompt( + tenant_id: int, + prompt_id: int, + data: TenantPromptUpdate, + admin: AdminUserInfo = Depends(get_current_admin), +): + """创建或更新租户的自定义提示词""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 验证提示词存在 + cursor.execute("SELECT name FROM ai_prompts WHERE id = %s", (prompt_id,)) + prompt = cursor.fetchone() + if not prompt: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="提示词不存在", + ) + + # 检查是否已有自定义 + cursor.execute( + """ + SELECT id FROM tenant_prompts + WHERE tenant_id = %s AND prompt_id = %s + """, + (tenant_id, prompt_id) + ) + existing = cursor.fetchone() + + if existing: + # 更新 + update_fields = [] + update_values = [] + + if data.system_prompt is not None: + update_fields.append("system_prompt = %s") + update_values.append(data.system_prompt) + + if data.user_prompt_template is not None: + update_fields.append("user_prompt_template = %s") + update_values.append(data.user_prompt_template) + + if data.is_active is not None: + update_fields.append("is_active = %s") + update_values.append(data.is_active) + + if update_fields: + update_fields.append("updated_by = %s") + update_values.append(admin.id) + update_values.append(existing["id"]) + + cursor.execute( + f"UPDATE tenant_prompts SET {', '.join(update_fields)} WHERE id = %s", + update_values + ) + else: + # 创建 + cursor.execute( + """ + INSERT INTO tenant_prompts + (tenant_id, prompt_id, system_prompt, user_prompt_template, is_active, created_by) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (tenant_id, prompt_id, data.system_prompt, data.user_prompt_template, + data.is_active if data.is_active is not None else True, admin.id) + ) + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, tenant["code"], + "update", "tenant_prompt", prompt_id, prompt["name"], + new_value=data.model_dump(exclude_unset=True) + ) + + conn.commit() + + return ResponseModel(message="自定义提示词已保存") + finally: + conn.close() + + +@router.delete("/tenants/{tenant_id}/{prompt_id}", response_model=ResponseModel, summary="删除租户自定义提示词") +async def delete_tenant_prompt( + tenant_id: int, + prompt_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """删除租户的自定义提示词(恢复使用默认)""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + """ + DELETE FROM tenant_prompts + WHERE tenant_id = %s AND prompt_id = %s + """, + (tenant_id, prompt_id) + ) + + if cursor.rowcount == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="自定义提示词不存在", + ) + + conn.commit() + + return ResponseModel(message="自定义提示词已删除,将使用默认模板") + finally: + conn.close() + diff --git a/backend/app/api/v1/admin_portal/schemas.py b/backend/app/api/v1/admin_portal/schemas.py new file mode 100644 index 0000000..71970b1 --- /dev/null +++ b/backend/app/api/v1/admin_portal/schemas.py @@ -0,0 +1,352 @@ +""" +管理后台数据模型 +""" + +from datetime import datetime +from typing import Optional, List, Any, Dict +from pydantic import BaseModel, Field + + +# ============================================ +# 通用模型 +# ============================================ + +class ResponseModel(BaseModel): + """通用响应模型""" + code: int = 0 + message: str = "success" + data: Optional[Any] = None + + +class PaginationParams(BaseModel): + """分页参数""" + page: int = Field(default=1, ge=1) + page_size: int = Field(default=20, ge=1, le=100) + + +class PaginatedResponse(BaseModel): + """分页响应""" + items: List[Any] + total: int + page: int + page_size: int + total_pages: int + + +# ============================================ +# 认证相关 +# ============================================ + +class AdminLoginRequest(BaseModel): + """管理员登录请求""" + username: str = Field(..., min_length=1, max_length=50) + password: str = Field(..., min_length=6) + + +class AdminLoginResponse(BaseModel): + """管理员登录响应""" + access_token: str + token_type: str = "bearer" + expires_in: int + admin_user: "AdminUserInfo" + + +class AdminUserInfo(BaseModel): + """管理员信息""" + id: int + username: str + email: Optional[str] + full_name: Optional[str] + role: str + last_login_at: Optional[datetime] + + +class AdminChangePasswordRequest(BaseModel): + """修改密码请求""" + old_password: str = Field(..., min_length=6) + new_password: str = Field(..., min_length=6) + + +# ============================================ +# 租户相关 +# ============================================ + +class TenantBase(BaseModel): + """租户基础信息""" + code: str = Field(..., min_length=2, max_length=20, pattern=r'^[a-z0-9_]+$') + name: str = Field(..., min_length=1, max_length=100) + display_name: Optional[str] = Field(None, max_length=200) + domain: str = Field(..., min_length=1, max_length=200) + logo_url: Optional[str] = None + favicon_url: Optional[str] = None + contact_name: Optional[str] = None + contact_phone: Optional[str] = None + contact_email: Optional[str] = None + industry: str = Field(default="medical_beauty") + remarks: Optional[str] = None + + +class TenantCreate(TenantBase): + """创建租户请求""" + pass + + +class TenantUpdate(BaseModel): + """更新租户请求""" + name: Optional[str] = Field(None, min_length=1, max_length=100) + display_name: Optional[str] = Field(None, max_length=200) + domain: Optional[str] = Field(None, min_length=1, max_length=200) + logo_url: Optional[str] = None + favicon_url: Optional[str] = None + contact_name: Optional[str] = None + contact_phone: Optional[str] = None + contact_email: Optional[str] = None + industry: Optional[str] = None + status: Optional[str] = None + expire_at: Optional[datetime] = None + remarks: Optional[str] = None + + +class TenantResponse(TenantBase): + """租户响应""" + id: int + status: str + expire_at: Optional[datetime] + created_at: datetime + updated_at: datetime + config_count: int = 0 # 配置项数量 + + class Config: + from_attributes = True + + +class TenantListResponse(BaseModel): + """租户列表响应""" + items: List[TenantResponse] + total: int + page: int + page_size: int + + +# ============================================ +# 配置相关 +# ============================================ + +class ConfigTemplateResponse(BaseModel): + """配置模板响应""" + id: int + config_group: str + config_key: str + display_name: str + description: Optional[str] + value_type: str + default_value: Optional[str] + is_required: bool + is_secret: bool + options: Optional[List[str]] + sort_order: int + + +class TenantConfigBase(BaseModel): + """租户配置基础""" + config_group: str + config_key: str + config_value: Optional[str] = None + + +class TenantConfigCreate(TenantConfigBase): + """创建租户配置请求""" + pass + + +class TenantConfigUpdate(BaseModel): + """更新租户配置请求""" + config_value: Optional[str] = None + + +class TenantConfigResponse(TenantConfigBase): + """租户配置响应""" + id: int + value_type: str + is_encrypted: bool + description: Optional[str] + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + # 从模板获取的额外信息 + display_name: Optional[str] = None + is_required: bool = False + is_secret: bool = False + + class Config: + from_attributes = True + + +class TenantConfigGroupResponse(BaseModel): + """租户配置分组响应""" + group_name: str + group_display_name: str + configs: List[TenantConfigResponse] + + +class ConfigBatchUpdate(BaseModel): + """批量更新配置请求""" + configs: List[TenantConfigCreate] + + +# ============================================ +# 提示词相关 +# ============================================ + +class AIPromptBase(BaseModel): + """AI提示词基础""" + code: str = Field(..., min_length=1, max_length=50) + name: str = Field(..., min_length=1, max_length=100) + description: Optional[str] = None + module: str + system_prompt: str + user_prompt_template: Optional[str] = None + variables: Optional[List[str]] = None + output_schema: Optional[Dict] = None + model_recommendation: Optional[str] = None + max_tokens: int = 4096 + temperature: float = 0.7 + + +class AIPromptCreate(AIPromptBase): + """创建提示词请求""" + pass + + +class AIPromptUpdate(BaseModel): + """更新提示词请求""" + name: Optional[str] = Field(None, min_length=1, max_length=100) + description: Optional[str] = None + system_prompt: Optional[str] = None + user_prompt_template: Optional[str] = None + variables: Optional[List[str]] = None + output_schema: Optional[Dict] = None + model_recommendation: Optional[str] = None + max_tokens: Optional[int] = None + temperature: Optional[float] = None + is_active: Optional[bool] = None + + +class AIPromptResponse(AIPromptBase): + """提示词响应""" + id: int + is_system: bool + is_active: bool + version: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class AIPromptVersionResponse(BaseModel): + """提示词版本响应""" + id: int + prompt_id: int + version: int + system_prompt: str + user_prompt_template: Optional[str] + variables: Optional[List[str]] + change_summary: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + + +class TenantPromptResponse(BaseModel): + """租户自定义提示词响应""" + id: int + tenant_id: int + prompt_id: int + prompt_code: str + prompt_name: str + system_prompt: Optional[str] + user_prompt_template: Optional[str] + is_active: bool + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class TenantPromptUpdate(BaseModel): + """更新租户自定义提示词""" + system_prompt: Optional[str] = None + user_prompt_template: Optional[str] = None + is_active: Optional[bool] = None + + +# ============================================ +# 功能开关相关 +# ============================================ + +class FeatureSwitchBase(BaseModel): + """功能开关基础""" + feature_code: str + feature_name: str + feature_group: Optional[str] = None + is_enabled: bool = True + config: Optional[Dict] = None + description: Optional[str] = None + + +class FeatureSwitchCreate(FeatureSwitchBase): + """创建功能开关请求""" + pass + + +class FeatureSwitchUpdate(BaseModel): + """更新功能开关请求""" + is_enabled: Optional[bool] = None + config: Optional[Dict] = None + + +class FeatureSwitchResponse(FeatureSwitchBase): + """功能开关响应""" + id: int + tenant_id: Optional[int] + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class FeatureSwitchGroupResponse(BaseModel): + """功能开关分组响应""" + group_name: str + group_display_name: str + features: List[FeatureSwitchResponse] + + +# ============================================ +# 操作日志相关 +# ============================================ + +class OperationLogResponse(BaseModel): + """操作日志响应""" + id: int + admin_username: Optional[str] + tenant_code: Optional[str] + operation_type: str + resource_type: str + resource_name: Optional[str] + old_value: Optional[Dict] + new_value: Optional[Dict] + ip_address: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + + +# 更新前向引用 +AdminLoginResponse.model_rebuild() + diff --git a/backend/app/api/v1/admin_portal/tenants.py b/backend/app/api/v1/admin_portal/tenants.py new file mode 100644 index 0000000..9d946b7 --- /dev/null +++ b/backend/app/api/v1/admin_portal/tenants.py @@ -0,0 +1,379 @@ +""" +租户管理 API +""" + +import os +import json +from datetime import datetime +from typing import Optional, List + +import pymysql +from fastapi import APIRouter, Depends, HTTPException, status, Query + +from .auth import get_current_admin, require_superadmin, get_db_connection, AdminUserInfo +from .schemas import ( + TenantCreate, + TenantUpdate, + TenantResponse, + TenantListResponse, + ResponseModel, +) + +router = APIRouter(prefix="/tenants", tags=["租户管理"]) + + +def log_operation(cursor, admin: AdminUserInfo, tenant_id: int, tenant_code: str, + operation_type: str, resource_type: str, resource_id: int, + resource_name: str, old_value: dict = None, new_value: dict = None): + """记录操作日志""" + cursor.execute( + """ + INSERT INTO operation_logs + (admin_user_id, admin_username, tenant_id, tenant_code, operation_type, + resource_type, resource_id, resource_name, old_value, new_value) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + (admin.id, admin.username, tenant_id, tenant_code, operation_type, + resource_type, resource_id, resource_name, + json.dumps(old_value, ensure_ascii=False) if old_value else None, + json.dumps(new_value, ensure_ascii=False) if new_value else None) + ) + + +@router.get("", response_model=TenantListResponse, summary="获取租户列表") +async def list_tenants( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + status: Optional[str] = Query(None, description="状态筛选"), + keyword: Optional[str] = Query(None, description="关键词搜索"), + admin: AdminUserInfo = Depends(get_current_admin), +): + """ + 获取租户列表 + + - **page**: 页码 + - **page_size**: 每页数量 + - **status**: 状态筛选(active, inactive, suspended) + - **keyword**: 关键词搜索(匹配名称、编码、域名) + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 构建查询条件 + conditions = [] + params = [] + + if status: + conditions.append("t.status = %s") + params.append(status) + + if keyword: + conditions.append("(t.name LIKE %s OR t.code LIKE %s OR t.domain LIKE %s)") + params.extend([f"%{keyword}%"] * 3) + + where_clause = " AND ".join(conditions) if conditions else "1=1" + + # 查询总数 + cursor.execute( + f"SELECT COUNT(*) as total FROM tenants t WHERE {where_clause}", + params + ) + total = cursor.fetchone()["total"] + + # 查询列表 + offset = (page - 1) * page_size + cursor.execute( + f""" + SELECT t.*, + (SELECT COUNT(*) FROM tenant_configs tc WHERE tc.tenant_id = t.id) as config_count + FROM tenants t + WHERE {where_clause} + ORDER BY t.id DESC + LIMIT %s OFFSET %s + """, + params + [page_size, offset] + ) + rows = cursor.fetchall() + + items = [TenantResponse(**row) for row in rows] + + return TenantListResponse( + items=items, + total=total, + page=page, + page_size=page_size, + ) + finally: + conn.close() + + +@router.get("/{tenant_id}", response_model=TenantResponse, summary="获取租户详情") +async def get_tenant( + tenant_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """获取租户详情""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT t.*, + (SELECT COUNT(*) FROM tenant_configs tc WHERE tc.tenant_id = t.id) as config_count + FROM tenants t + WHERE t.id = %s + """, + (tenant_id,) + ) + row = cursor.fetchone() + + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + return TenantResponse(**row) + finally: + conn.close() + + +@router.post("", response_model=TenantResponse, summary="创建租户") +async def create_tenant( + data: TenantCreate, + admin: AdminUserInfo = Depends(require_superadmin), +): + """ + 创建新租户 + + 需要超级管理员权限 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 检查编码是否已存在 + cursor.execute("SELECT id FROM tenants WHERE code = %s", (data.code,)) + if cursor.fetchone(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="租户编码已存在", + ) + + # 检查域名是否已存在 + cursor.execute("SELECT id FROM tenants WHERE domain = %s", (data.domain,)) + if cursor.fetchone(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="域名已被使用", + ) + + # 创建租户 + cursor.execute( + """ + INSERT INTO tenants + (code, name, display_name, domain, logo_url, favicon_url, + contact_name, contact_phone, contact_email, industry, remarks, created_by) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + (data.code, data.name, data.display_name, data.domain, + data.logo_url, data.favicon_url, data.contact_name, + data.contact_phone, data.contact_email, data.industry, + data.remarks, admin.id) + ) + tenant_id = cursor.lastrowid + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, data.code, + "create", "tenant", tenant_id, data.name, + new_value=data.model_dump() + ) + + conn.commit() + + # 返回创建的租户 + return await get_tenant(tenant_id, admin) + finally: + conn.close() + + +@router.put("/{tenant_id}", response_model=TenantResponse, summary="更新租户") +async def update_tenant( + tenant_id: int, + data: TenantUpdate, + admin: AdminUserInfo = Depends(get_current_admin), +): + """更新租户信息""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 获取原租户信息 + cursor.execute("SELECT * FROM tenants WHERE id = %s", (tenant_id,)) + old_tenant = cursor.fetchone() + + if not old_tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 如果更新域名,检查是否已被使用 + if data.domain and data.domain != old_tenant["domain"]: + cursor.execute( + "SELECT id FROM tenants WHERE domain = %s AND id != %s", + (data.domain, tenant_id) + ) + if cursor.fetchone(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="域名已被使用", + ) + + # 构建更新语句 + update_fields = [] + update_values = [] + + for field, value in data.model_dump(exclude_unset=True).items(): + if value is not None: + update_fields.append(f"{field} = %s") + update_values.append(value) + + if not update_fields: + return await get_tenant(tenant_id, admin) + + update_fields.append("updated_by = %s") + update_values.append(admin.id) + update_values.append(tenant_id) + + cursor.execute( + f"UPDATE tenants SET {', '.join(update_fields)} WHERE id = %s", + update_values + ) + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, old_tenant["code"], + "update", "tenant", tenant_id, old_tenant["name"], + old_value=dict(old_tenant), + new_value=data.model_dump(exclude_unset=True) + ) + + conn.commit() + + return await get_tenant(tenant_id, admin) + finally: + conn.close() + + +@router.delete("/{tenant_id}", response_model=ResponseModel, summary="删除租户") +async def delete_tenant( + tenant_id: int, + admin: AdminUserInfo = Depends(require_superadmin), +): + """ + 删除租户 + + 需要超级管理员权限 + 警告:此操作将删除租户及其所有配置 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 获取租户信息 + cursor.execute("SELECT * FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, tenant["code"], + "delete", "tenant", tenant_id, tenant["name"], + old_value=dict(tenant) + ) + + # 删除租户(级联删除配置) + cursor.execute("DELETE FROM tenants WHERE id = %s", (tenant_id,)) + + conn.commit() + + return ResponseModel(message=f"租户 {tenant['name']} 已删除") + finally: + conn.close() + + +@router.post("/{tenant_id}/enable", response_model=ResponseModel, summary="启用租户") +async def enable_tenant( + tenant_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """启用租户""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + "UPDATE tenants SET status = 'active', updated_by = %s WHERE id = %s", + (admin.id, tenant_id) + ) + + if cursor.rowcount == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 获取租户信息并记录日志 + cursor.execute("SELECT code, name FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + + log_operation( + cursor, admin, tenant_id, tenant["code"], + "enable", "tenant", tenant_id, tenant["name"] + ) + + conn.commit() + + return ResponseModel(message="租户已启用") + finally: + conn.close() + + +@router.post("/{tenant_id}/disable", response_model=ResponseModel, summary="禁用租户") +async def disable_tenant( + tenant_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """禁用租户""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + "UPDATE tenants SET status = 'inactive', updated_by = %s WHERE id = %s", + (admin.id, tenant_id) + ) + + if cursor.rowcount == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 获取租户信息并记录日志 + cursor.execute("SELECT code, name FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + + log_operation( + cursor, admin, tenant_id, tenant["code"], + "disable", "tenant", tenant_id, tenant["name"] + ) + + conn.commit() + + return ResponseModel(message="租户已禁用") + finally: + conn.close() + diff --git a/backend/app/api/v1/admin_positions_backup.py b/backend/app/api/v1/admin_positions_backup.py new file mode 100644 index 0000000..c8710b7 --- /dev/null +++ b/backend/app/api/v1/admin_positions_backup.py @@ -0,0 +1,158 @@ +# 此文件备份了admin.py中的positions相关路由代码 +# 这些路由已移至positions.py,为避免冲突,从admin.py中移除 + +@router.get("/positions") +async def list_positions( + keyword: Optional[str] = Query(None, description="关键词"), + page: int = Query(1, ge=1), + pageSize: int = Query(20, ge=1, le=100), + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取岗位列表(stub 数据) + + 返回结构兼容前端:data.list/total/page/pageSize + """ + not_admin = _ensure_admin(current_user) + if not_admin: + return not_admin + + try: + items = _sample_positions() + if keyword: + kw = keyword.lower() + items = [ + p for p in items if kw in (p.get("name", "") + p.get("description", "")).lower() + ] + + total = len(items) + start = (page - 1) * pageSize + end = start + pageSize + page_items = items[start:end] + + return ResponseModel( + code=200, + message="获取岗位列表成功", + data={ + "list": page_items, + "total": total, + "page": page, + "pageSize": pageSize, + }, + ) + except Exception as exc: + # 记录错误堆栈由全局异常中间件处理;此处返回统一结构 + return ResponseModel(code=500, message=f"服务器错误:{exc}") + + +@router.get("/positions/tree") +async def get_position_tree( + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取岗位树(stub 数据) + """ + not_admin = _ensure_admin(current_user) + if not_admin: + return not_admin + + try: + items = _sample_positions() + id_to_node: Dict[int, Dict[str, Any]] = {} + for p in items: + node = {**p, "children": []} + id_to_node[p["id"]] = node + + roots: List[Dict[str, Any]] = [] + for p in items: + parent_id = p.get("parentId") + if parent_id and parent_id in id_to_node: + id_to_node[parent_id]["children"].append(id_to_node[p["id"]]) + else: + roots.append(id_to_node[p["id"]]) + + return ResponseModel(code=200, message="获取岗位树成功", data=roots) + except Exception as exc: + return ResponseModel(code=500, message=f"服务器错误:{exc}") + + +@router.get("/positions/{position_id}") +async def get_position_detail( + position_id: int, + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +) -> ResponseModel: + not_admin = _ensure_admin(current_user) + if not_admin: + return not_admin + + items = _sample_positions() + for p in items: + if p["id"] == position_id: + return ResponseModel(code=200, message="获取岗位详情成功", data=p) + return ResponseModel(code=404, message="岗位不存在") + + +@router.get("/positions/{position_id}/check-delete") +async def check_position_delete( + position_id: int, + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +) -> ResponseModel: + not_admin = _ensure_admin(current_user) + if not_admin: + return not_admin + + # stub:允许删除非根岗位 + deletable = position_id != 1 + reason = "根岗位不允许删除" if not deletable else "" + return ResponseModel(code=200, message="检查成功", data={"deletable": deletable, "reason": reason}) + + +@router.post("/positions") +async def create_position( + payload: Dict[str, Any], + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +) -> ResponseModel: + not_admin = _ensure_admin(current_user) + if not_admin: + return not_admin + + # stub:直接回显并附带一个伪ID + payload = dict(payload) + payload.setdefault("id", 999) + payload.setdefault("createTime", datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + return ResponseModel(code=200, message="创建岗位成功", data=payload) + + +@router.put("/positions/{position_id}") +async def update_position( + position_id: int, + payload: Dict[str, Any], + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +) -> ResponseModel: + not_admin = _ensure_admin(current_user) + if not_admin: + return not_admin + + # stub:直接回显 + updated = {"id": position_id, **payload} + return ResponseModel(code=200, message="更新岗位成功", data=updated) + + +@router.delete("/positions/{position_id}") +async def delete_position( + position_id: int, + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +) -> ResponseModel: + not_admin = _ensure_admin(current_user) + if not_admin: + return not_admin + + # stub:直接返回成功 + return ResponseModel(code=200, message="删除岗位成功", data={"id": position_id}) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..411e4a2 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,156 @@ +""" +认证 API +""" +from fastapi import APIRouter, Depends, status, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_active_user, get_db +from app.core.logger import logger +from app.models.user import User +from app.schemas.auth import LoginRequest, RefreshTokenRequest, Token +from app.schemas.base import ResponseModel +from app.schemas.user import User as UserSchema +from app.services.auth_service import AuthService +from app.services.system_log_service import system_log_service +from app.schemas.system_log import SystemLogCreate +from app.core.exceptions import UnauthorizedError + +router = APIRouter() + + +@router.post("/login", response_model=ResponseModel) +async def login( + login_data: LoginRequest, + request: Request, + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 用户登录 + + 支持使用用户名、邮箱或手机号登录 + """ + auth_service = AuthService(db) + try: + user, token = await auth_service.login( + username=login_data.username, + password=login_data.password, + ) + + # 记录登录成功日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="security", + message=f"用户 {user.username} 登录成功", + user_id=user.id, + user=user.username, + ip=request.client.host if request.client else None, + path="/api/v1/auth/login", + method="POST", + user_agent=request.headers.get("user-agent") + ) + ) + + return ResponseModel( + message="登录成功", + data={ + "user": UserSchema.model_validate(user).model_dump(), + "token": token.model_dump(), + }, + ) + except UnauthorizedError as e: + # 记录登录失败日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="WARNING", + type="security", + message=f"用户 {login_data.username} 登录失败:密码错误", + user=login_data.username, + ip=request.client.host if request.client else None, + path="/api/v1/auth/login", + method="POST", + user_agent=request.headers.get("user-agent") + ) + ) + # 不返回 401,统一返回 HTTP 200 + 业务失败码,便于前端友好提示 + logger.warning("login_failed_wrong_credentials", username=login_data.username) + return ResponseModel( + code=400, + message=str(e) or "用户名或密码错误", + data=None, + ) + except Exception as e: + logger.error("login_failed_unexpected", error=str(e)) + return ResponseModel( + code=500, + message="登录失败,请稍后重试", + data=None, + ) + + +@router.post("/refresh", response_model=ResponseModel) +async def refresh_token( + refresh_data: RefreshTokenRequest, + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 刷新访问令牌 + + 使用刷新令牌获取新的访问令牌 + """ + auth_service = AuthService(db) + token = await auth_service.refresh_token(refresh_data.refresh_token) + + return ResponseModel(message="令牌刷新成功", data=token.model_dump()) + + +@router.post("/logout", response_model=ResponseModel) +async def logout( + request: Request, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 用户登出 + + 注意:客户端需要删除本地存储的令牌 + """ + auth_service = AuthService(db) + await auth_service.logout(current_user.id) + + # 记录登出日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="security", + message=f"用户 {current_user.username} 登出", + user_id=current_user.id, + user=current_user.username, + ip=request.client.host if request.client else None, + path="/api/v1/auth/logout", + method="POST", + user_agent=request.headers.get("user-agent") + ) + ) + + return ResponseModel(message="登出成功") + + +@router.get("/verify", response_model=ResponseModel) +async def verify_token( + current_user: User = Depends(get_current_active_user), +) -> ResponseModel: + """ + 验证令牌 + + 用于检查当前令牌是否有效 + """ + return ResponseModel( + message="令牌有效", + data={ + "user": UserSchema.model_validate(current_user).model_dump(), + }, + ) diff --git a/backend/app/api/v1/broadcast.py b/backend/app/api/v1/broadcast.py new file mode 100644 index 0000000..c6eb854 --- /dev/null +++ b/backend/app/api/v1/broadcast.py @@ -0,0 +1,145 @@ +""" +播课功能 API 接口 +""" + +import logging +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db, get_current_user, require_admin_or_manager +from app.schemas.base import ResponseModel +from app.models.course import Course +from app.models.user import User +from app.services.coze_broadcast_service import broadcast_service + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# Schema 定义 +class GenerateBroadcastResponse(BaseModel): + """生成播课响应""" + message: str = Field(..., description="提示信息") + + +class BroadcastInfo(BaseModel): + """播课信息""" + has_broadcast: bool = Field(..., description="是否有播课") + mp3_url: Optional[str] = Field(None, description="播课音频URL") + generated_at: Optional[datetime] = Field(None, description="生成时间") + + +@router.post("/courses/{course_id}/generate-broadcast", response_model=ResponseModel[GenerateBroadcastResponse]) +async def generate_broadcast( + course_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_admin_or_manager) +): + """ + 触发播课音频生成(立即返回,Coze工作流会直接写数据库) + + 权限:manager、admin + + Args: + course_id: 课程ID + db: 数据库会话 + current_user: 当前用户 + + Returns: + 启动提示信息 + + Raises: + HTTPException 404: 课程不存在 + """ + logger.info( + f"请求生成播课", + extra={"course_id": course_id, "user_id": current_user.id} + ) + + # 查询课程 + result = await db.execute( + select(Course) + .where(Course.id == course_id) + .where(Course.is_deleted == False) + ) + course = result.scalar_one_or_none() + + if not course: + logger.warning(f"课程不存在", extra={"course_id": course_id}) + raise HTTPException(status_code=404, detail="课程不存在") + + # 调用 Coze 工作流(不等待结果,工作流会直接写数据库) + try: + await broadcast_service.trigger_workflow(course_id) + + logger.info( + f"播课生成工作流已触发", + extra={"course_id": course_id, "user_id": current_user.id} + ) + + return ResponseModel( + code=200, + message="播课生成已启动", + data=GenerateBroadcastResponse( + message="播课生成工作流已启动,生成完成后将自动更新" + ) + ) + except Exception as e: + logger.error( + f"触发播课生成失败", + extra={"course_id": course_id, "error": str(e)} + ) + raise HTTPException(status_code=500, detail=f"触发播课生成失败: {str(e)}") + + +@router.get("/courses/{course_id}/broadcast", response_model=ResponseModel[BroadcastInfo]) +async def get_broadcast_info( + course_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取播课信息 + + 权限:所有登录用户 + + Args: + course_id: 课程ID + db: 数据库会话 + current_user: 当前用户 + + Returns: + 播课信息 + + Raises: + HTTPException 404: 课程不存在 + """ + # 查询课程 + result = await db.execute( + select(Course) + .where(Course.id == course_id) + .where(Course.is_deleted == False) + ) + course = result.scalar_one_or_none() + + if not course: + raise HTTPException(status_code=404, detail="课程不存在") + + # 构建播课信息 + has_broadcast = bool(course.broadcast_audio_url) + + return ResponseModel( + code=200, + message="success", + data=BroadcastInfo( + has_broadcast=has_broadcast, + mp3_url=course.broadcast_audio_url if has_broadcast else None, + generated_at=course.broadcast_generated_at if has_broadcast else None + ) + ) diff --git a/backend/app/api/v1/course_chat.py b/backend/app/api/v1/course_chat.py new file mode 100644 index 0000000..45e7a2a --- /dev/null +++ b/backend/app/api/v1/course_chat.py @@ -0,0 +1,190 @@ +""" +与课程对话 API + +使用 Python 原生 AI 服务实现 +""" + +import json +import logging +from typing import Optional, Any + +from fastapi import APIRouter, HTTPException, Depends +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db, get_current_user +from app.models.user import User +from app.services.ai.course_chat_service import course_chat_service_v2 + +router = APIRouter() +logger = logging.getLogger(__name__) + + +class CourseChatRequest(BaseModel): + """课程对话请求""" + course_id: int = Field(..., description="课程ID") + query: str = Field(..., description="用户问题") + conversation_id: Optional[str] = Field(None, description="会话ID(续接对话时传入)") + + +class ResponseModel(BaseModel): + """通用响应模型""" + code: int = 200 + message: str = "success" + data: Optional[Any] = None + + +async def _chat_with_course( + request: CourseChatRequest, + current_user: User, + db: AsyncSession +): + """ + Python 原生实现的流式对话 + """ + logger.info( + f"用户 {current_user.username} 与课程 {request.course_id} 对话: " + f"{request.query[:50]}..." + ) + + async def generate_stream(): + """生成 SSE 流""" + try: + async for event_type, data in course_chat_service_v2.chat_stream( + db=db, + course_id=request.course_id, + query=request.query, + user_id=current_user.id, + conversation_id=request.conversation_id + ): + if event_type == "conversation_started": + yield f"data: {json.dumps({'event': 'conversation_started', 'conversation_id': data})}\n\n" + logger.info(f"会话已创建: {data}") + + elif event_type == "chunk": + yield f"data: {json.dumps({'event': 'message_chunk', 'chunk': data})}\n\n" + + elif event_type == "done": + yield f"data: {json.dumps({'event': 'message_end', 'message': data})}\n\n" + logger.info(f"对话完成,总长度: {len(data)}") + + elif event_type == "error": + yield f"data: {json.dumps({'event': 'error', 'message': data})}\n\n" + logger.error(f"对话错误: {data}") + + except Exception as e: + logger.error(f"流式对话异常: {e}", exc_info=True) + yield f"data: {json.dumps({'event': 'error', 'message': str(e)})}\n\n" + + return StreamingResponse( + generate_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + ) + + +@router.post("/chat") +async def chat_with_course( + request: CourseChatRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 与课程对话(流式响应) + + 使用 Python 原生 AI 服务实现,支持多轮对话。 + """ + return await _chat_with_course(request, current_user, db) + + +@router.get("/conversations") +async def get_conversations( + course_id: Optional[int] = None, + limit: int = 20, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 获取会话列表 + + 返回当前用户的历史会话列表 + """ + try: + conversations = await course_chat_service_v2.get_conversations( + user_id=current_user.id, + course_id=course_id, + limit=limit + ) + + return ResponseModel( + code=200, + message="获取会话列表成功", + data={ + "conversations": conversations, + "total": len(conversations) + } + ) + + except Exception as e: + logger.error(f"获取会话列表失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"获取会话列表失败: {str(e)}") + + +@router.get("/messages") +async def get_messages( + conversation_id: str, + limit: int = 50, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 获取历史消息 + + 返回指定会话的历史消息 + """ + try: + messages = await course_chat_service_v2.get_messages( + conversation_id=conversation_id, + user_id=current_user.id, + limit=limit + ) + + return ResponseModel( + code=200, + message="获取历史消息成功", + data={ + "messages": messages, + "total": len(messages) + } + ) + + except Exception as e: + logger.error(f"获取历史消息失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"获取历史消息失败: {str(e)}") + + +@router.get("/engines") +async def list_chat_engines(): + """ + 获取可用的对话引擎列表 + """ + return ResponseModel( + code=200, + message="获取对话引擎列表成功", + data={ + "engines": [ + { + "id": "native", + "name": "Python 原生实现", + "description": "使用本地 AI 服务(4sapi.com + OpenRouter),支持流式输出和多轮对话", + "default": True + } + ], + "default_engine": "native" + } + ) diff --git a/backend/app/api/v1/courses.py b/backend/app/api/v1/courses.py new file mode 100644 index 0000000..4cfa94b --- /dev/null +++ b/backend/app/api/v1/courses.py @@ -0,0 +1,786 @@ +""" +课程管理API路由 +""" +from typing import List, Optional + +from fastapi import APIRouter, Depends, Query, status, BackgroundTasks, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db, get_current_user, require_admin, require_admin_or_manager, User +from app.core.exceptions import NotFoundError, BadRequestError +from app.core.logger import get_logger +from app.services.system_log_service import system_log_service +from app.schemas.system_log import SystemLogCreate +from app.models.course import CourseStatus, CourseCategory +from app.schemas.base import ResponseModel, PaginationParams, PaginatedResponse +from app.schemas.course import ( + CourseCreate, + CourseUpdate, + CourseInDB, + CourseList, + CourseMaterialCreate, + CourseMaterialInDB, + KnowledgePointCreate, + KnowledgePointUpdate, + KnowledgePointInDB, + GrowthPathCreate, + GrowthPathInDB, + CourseExamSettingsCreate, + CourseExamSettingsUpdate, + CourseExamSettingsInDB, + CoursePositionAssignment, + CoursePositionAssignmentInDB, +) +from app.services.course_service import ( + course_service, + knowledge_point_service, + growth_path_service, +) + +logger = get_logger(__name__) +router = APIRouter(prefix="/courses", tags=["courses"]) + + +@router.get("", response_model=ResponseModel[PaginatedResponse[CourseInDB]]) +async def get_courses( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(20, ge=1, le=100, description="每页数量"), + status: Optional[CourseStatus] = Query(None, description="课程状态"), + category: Optional[CourseCategory] = Query(None, description="课程分类"), + is_featured: Optional[bool] = Query(None, description="是否推荐"), + keyword: Optional[str] = Query(None, description="搜索关键词"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取课程列表(支持分页和筛选) + + - **page**: 页码 + - **size**: 每页数量 + - **status**: 课程状态筛选 + - **category**: 课程分类筛选 + - **is_featured**: 是否推荐筛选 + - **keyword**: 关键词搜索(搜索名称和描述) + """ + page_params = PaginationParams(page=page, page_size=size) + filters = CourseList( + status=status, category=category, is_featured=is_featured, keyword=keyword + ) + + result = await course_service.get_course_list( + db, page_params=page_params, filters=filters, user_id=current_user.id + ) + + return ResponseModel(data=result, message="获取课程列表成功") + + +@router.post( + "", response_model=ResponseModel[CourseInDB], status_code=status.HTTP_201_CREATED +) +async def create_course( + course_in: CourseCreate, + request: Request, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 创建课程(需要管理员权限) + + - **name**: 课程名称 + - **description**: 课程描述 + - **category**: 课程分类 + - **status**: 课程状态(默认为草稿) + - **cover_image**: 封面图片URL + - **duration_hours**: 课程时长(小时) + - **difficulty_level**: 难度等级(1-5) + - **tags**: 标签列表 + - **is_featured**: 是否推荐 + """ + course = await course_service.create_course( + db, course_in=course_in, created_by=current_user.id + ) + + # 记录课程创建日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="api", + message=f"创建课程: {course.name}", + user_id=current_user.id, + user=current_user.username, + ip=request.client.host if request.client else None, + path="/api/v1/courses", + method="POST", + user_agent=request.headers.get("user-agent") + ) + ) + + return ResponseModel(data=course, message="创建课程成功") + + +@router.get("/{course_id}", response_model=ResponseModel[CourseInDB]) +async def get_course( + course_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取课程详情 + + - **course_id**: 课程ID + """ + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + logger.info(f"查看课程详情 - course_id: {course_id}, user_id: {current_user.id}") + + return ResponseModel(data=course, message="获取课程详情成功") + + +@router.put("/{course_id}", response_model=ResponseModel[CourseInDB]) +async def update_course( + course_id: int, + course_in: CourseUpdate, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 更新课程(需要管理员权限) + + - **course_id**: 课程ID + - **course_in**: 更新的课程数据(所有字段都是可选的) + """ + course = await course_service.update_course( + db, course_id=course_id, course_in=course_in, updated_by=current_user.id + ) + + return ResponseModel(data=course, message="更新课程成功") + + +@router.delete("/{course_id}", response_model=ResponseModel[bool]) +async def delete_course( + course_id: int, + request: Request, + current_user: User = Depends(require_admin_or_manager), + db: AsyncSession = Depends(get_db), +): + """ + 删除课程(需要管理员权限) + + - **course_id**: 课程ID + + 说明:任意状态均可软删除(is_deleted=1),请谨慎操作 + """ + # 先获取课程信息 + course = await course_service.get_by_id(db, course_id) + course_name = course.name if course else f"ID:{course_id}" + + success = await course_service.delete_course( + db, course_id=course_id, deleted_by=current_user.id + ) + + # 记录课程删除日志 + if success: + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="api", + message=f"删除课程: {course_name}", + user_id=current_user.id, + user=current_user.username, + ip=request.client.host if request.client else None, + path=f"/api/v1/courses/{course_id}", + method="DELETE", + user_agent=request.headers.get("user-agent") + ) + ) + + return ResponseModel(data=success, message="删除课程成功" if success else "删除课程失败") + + +# 课程资料相关API +@router.post( + "/{course_id}/materials", + response_model=ResponseModel[CourseMaterialInDB], + status_code=status.HTTP_201_CREATED, +) +async def add_course_material( + course_id: int, + material_in: CourseMaterialCreate, + background_tasks: BackgroundTasks, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 添加课程资料(需要管理员权限) + + - **course_id**: 课程ID + - **name**: 资料名称 + - **description**: 资料描述 + - **file_url**: 文件URL + - **file_type**: 文件类型(pdf, doc, docx, ppt, pptx, xls, xlsx, mp4, mp3, zip) + - **file_size**: 文件大小(字节) + + 添加资料后会自动触发知识点分析 + """ + material = await course_service.add_course_material( + db, course_id=course_id, material_in=material_in, created_by=current_user.id + ) + + # 获取课程信息用于知识点分析 + course = await course_service.get_by_id(db, course_id) + if course: + # 异步触发知识点分析 + from app.services.ai.knowledge_analysis_v2 import knowledge_analysis_service_v2 + background_tasks.add_task( + _trigger_knowledge_analysis, + db, + course_id, + material.id, + material.file_url, + course.name, + current_user.id + ) + + logger.info( + f"资料添加成功,已触发知识点分析 - course_id: {course_id}, material_id: {material.id}, user_id: {current_user.id}" + ) + + return ResponseModel(data=material, message="添加课程资料成功") + + +@router.get( + "/{course_id}/materials", + response_model=ResponseModel[List[CourseMaterialInDB]], +) +async def list_course_materials( + course_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取课程资料列表 + + - **course_id**: 课程ID + """ + materials = await course_service.get_course_materials(db, course_id=course_id) + return ResponseModel(data=materials, message="获取课程资料列表成功") + + +@router.delete( + "/{course_id}/materials/{material_id}", + response_model=ResponseModel[bool], +) +async def delete_course_material( + course_id: int, + material_id: int, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 删除课程资料(需要管理员权限) + + - **course_id**: 课程ID + - **material_id**: 资料ID + """ + success = await course_service.delete_course_material( + db, course_id=course_id, material_id=material_id, deleted_by=current_user.id + ) + return ResponseModel(data=success, message="删除课程资料成功" if success else "删除课程资料失败") + + +# 知识点相关API +@router.get( + "/{course_id}/knowledge-points", + response_model=ResponseModel[List[KnowledgePointInDB]], +) +async def get_course_knowledge_points( + course_id: int, + material_id: Optional[int] = Query(None, description="资料ID"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取课程的知识点列表 + + - **course_id**: 课程ID + - **material_id**: 资料ID(可选,用于筛选特定资料的知识点) + """ + # 先检查课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + knowledge_points = await knowledge_point_service.get_knowledge_points_by_course( + db, course_id=course_id, material_id=material_id + ) + + return ResponseModel(data=knowledge_points, message="获取知识点列表成功") + + +@router.post( + "/{course_id}/knowledge-points", + response_model=ResponseModel[KnowledgePointInDB], + status_code=status.HTTP_201_CREATED, +) +async def create_knowledge_point( + course_id: int, + point_in: KnowledgePointCreate, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 创建知识点(需要管理员权限) + + - **course_id**: 课程ID + - **name**: 知识点名称 + - **description**: 知识点描述 + - **parent_id**: 父知识点ID + - **weight**: 权重(0-10) + - **is_required**: 是否必修 + - **estimated_hours**: 预计学习时间(小时) + """ + knowledge_point = await knowledge_point_service.create_knowledge_point( + db, course_id=course_id, point_in=point_in, created_by=current_user.id + ) + + return ResponseModel(data=knowledge_point, message="创建知识点成功") + + +@router.put( + "/knowledge-points/{point_id}", response_model=ResponseModel[KnowledgePointInDB] +) +async def update_knowledge_point( + point_id: int, + point_in: KnowledgePointUpdate, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 更新知识点(需要管理员权限) + + - **point_id**: 知识点ID + - **point_in**: 更新的知识点数据(所有字段都是可选的) + """ + knowledge_point = await knowledge_point_service.update_knowledge_point( + db, point_id=point_id, point_in=point_in, updated_by=current_user.id + ) + + return ResponseModel(data=knowledge_point, message="更新知识点成功") + + +@router.delete("/knowledge-points/{point_id}", response_model=ResponseModel[bool]) +async def delete_knowledge_point( + point_id: int, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 删除知识点(需要管理员权限) + + - **point_id**: 知识点ID + """ + success = await knowledge_point_service.delete( + db, id=point_id, soft=True, deleted_by=current_user.id + ) + + if success: + logger.warning("删除知识点", knowledge_point_id=point_id, deleted_by=current_user.id) + + return ResponseModel(data=success, message="删除知识点成功" if success else "删除知识点失败") + + +# 资料知识点关联API +@router.get( + "/materials/{material_id}/knowledge-points", + response_model=ResponseModel[List[KnowledgePointInDB]], +) +async def get_material_knowledge_points( + material_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取资料关联的知识点列表 + """ + knowledge_points = await course_service.get_material_knowledge_points( + db, material_id=material_id + ) + return ResponseModel(data=knowledge_points, message="获取知识点列表成功") + + +@router.post( + "/materials/{material_id}/knowledge-points", + response_model=ResponseModel[List[KnowledgePointInDB]], + status_code=status.HTTP_201_CREATED, +) +async def add_material_knowledge_points( + material_id: int, + knowledge_point_ids: List[int], + current_user: User = Depends(require_admin_or_manager), + db: AsyncSession = Depends(get_db), +): + """ + 为资料添加知识点关联(需要管理员或经理权限) + """ + knowledge_points = await course_service.add_material_knowledge_points( + db, material_id=material_id, knowledge_point_ids=knowledge_point_ids + ) + return ResponseModel(data=knowledge_points, message="添加知识点成功") + + +@router.delete( + "/materials/{material_id}/knowledge-points/{knowledge_point_id}", + response_model=ResponseModel[bool], +) +async def remove_material_knowledge_point( + material_id: int, + knowledge_point_id: int, + current_user: User = Depends(require_admin_or_manager), + db: AsyncSession = Depends(get_db), +): + """ + 移除资料的知识点关联(需要管理员或经理权限) + """ + success = await course_service.remove_material_knowledge_point( + db, material_id=material_id, knowledge_point_id=knowledge_point_id + ) + return ResponseModel(data=success, message="移除知识点成功" if success else "移除失败") + + +# 成长路径相关API +@router.post( + "/growth-paths", + response_model=ResponseModel[GrowthPathInDB], + status_code=status.HTTP_201_CREATED, +) +async def create_growth_path( + path_in: GrowthPathCreate, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 创建成长路径(需要管理员权限) + + - **name**: 路径名称 + - **description**: 路径描述 + - **target_role**: 目标角色 + - **courses**: 课程列表(包含course_id、order、is_required) + - **estimated_duration_days**: 预计完成天数 + - **is_active**: 是否启用 + """ + growth_path = await growth_path_service.create_growth_path( + db, path_in=path_in, created_by=current_user.id + ) + + return ResponseModel(data=growth_path, message="创建成长路径成功") + + +@router.get( + "/growth-paths", response_model=ResponseModel[PaginatedResponse[GrowthPathInDB]] +) +async def get_growth_paths( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(20, ge=1, le=100, description="每页数量"), + is_active: Optional[bool] = Query(None, description="是否启用"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取成长路径列表 + + - **page**: 页码 + - **size**: 每页数量 + - **is_active**: 是否启用筛选 + """ + page_params = PaginationParams(page=page, page_size=size) + + filters = [] + if is_active is not None: + from app.models.course import GrowthPath + + filters.append(GrowthPath.is_active == is_active) + + result = await growth_path_service.get_page( + db, page_params=page_params, filters=filters + ) + + return ResponseModel(data=result, message="获取成长路径列表成功") + + +# 课程考试设置相关API +@router.get( + "/{course_id}/exam-settings", + response_model=ResponseModel[Optional[CourseExamSettingsInDB]], +) +async def get_course_exam_settings( + course_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取课程的考试设置 + + - **course_id**: 课程ID + """ + # 检查课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 获取考试设置 + from app.services.course_exam_service import course_exam_service + settings = await course_exam_service.get_by_course_id(db, course_id) + + # 添加调试日志 + if settings: + logger.info( + f"📊 获取考试设置成功 - course_id: {course_id}, " + f"单选: {settings.single_choice_count}, 多选: {settings.multiple_choice_count}, " + f"判断: {settings.true_false_count}, 填空: {settings.fill_blank_count}, " + f"问答: {settings.essay_count}, 难度: {settings.difficulty_level}" + ) + else: + logger.warning(f"⚠️ 课程 {course_id} 没有配置考试设置,将使用默认值") + + return ResponseModel(data=settings, message="获取考试设置成功") + + +@router.post( + "/{course_id}/exam-settings", + response_model=ResponseModel[CourseExamSettingsInDB], + status_code=status.HTTP_201_CREATED, +) +async def create_course_exam_settings( + course_id: int, + settings_in: CourseExamSettingsCreate, + current_user: User = Depends(require_admin_or_manager), + db: AsyncSession = Depends(get_db), +): + """ + 创建或更新课程的考试设置(需要管理员权限) + + - **course_id**: 课程ID + - **settings_in**: 考试设置数据 + """ + # 检查课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 创建或更新考试设置 + from app.services.course_exam_service import course_exam_service + settings = await course_exam_service.create_or_update( + db, course_id=course_id, settings_in=settings_in, user_id=current_user.id + ) + + return ResponseModel(data=settings, message="保存考试设置成功") + + +@router.put( + "/{course_id}/exam-settings", + response_model=ResponseModel[CourseExamSettingsInDB], +) +async def update_course_exam_settings( + course_id: int, + settings_in: CourseExamSettingsUpdate, + current_user: User = Depends(require_admin_or_manager), + db: AsyncSession = Depends(get_db), +): + """ + 更新课程的考试设置(需要管理员权限) + + - **course_id**: 课程ID + - **settings_in**: 更新的考试设置数据 + """ + # 检查课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 更新考试设置 + from app.services.course_exam_service import course_exam_service + settings = await course_exam_service.update( + db, course_id=course_id, settings_in=settings_in, user_id=current_user.id + ) + + return ResponseModel(data=settings, message="更新考试设置成功") + + +# 课程岗位分配相关API +@router.get( + "/{course_id}/positions", + response_model=ResponseModel[List[CoursePositionAssignmentInDB]], +) +async def get_course_positions( + course_id: int, + course_type: Optional[str] = Query(None, pattern="^(required|optional)$", description="课程类型筛选"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取课程的岗位分配列表 + + - **course_id**: 课程ID + - **course_type**: 课程类型筛选(required必修/optional选修) + """ + # 检查课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 获取岗位分配列表 + from app.services.course_position_service import course_position_service + assignments = await course_position_service.get_course_positions( + db, course_id=course_id, course_type=course_type + ) + + return ResponseModel(data=assignments, message="获取岗位分配列表成功") + + +@router.post( + "/{course_id}/positions", + response_model=ResponseModel[List[CoursePositionAssignmentInDB]], + status_code=status.HTTP_201_CREATED, +) +async def assign_course_positions( + course_id: int, + assignments: List[CoursePositionAssignment], + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 批量分配课程到岗位(需要管理员权限) + + - **course_id**: 课程ID + - **assignments**: 岗位分配列表 + """ + # 检查课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 批量分配岗位 + from app.services.course_position_service import course_position_service + result = await course_position_service.batch_assign_positions( + db, course_id=course_id, assignments=assignments, user_id=current_user.id + ) + + # 发送课程分配通知给相关岗位的学员 + try: + from app.models.position_member import PositionMember + from app.services.notification_service import notification_service + from app.schemas.notification import NotificationBatchCreate, NotificationType + + # 获取所有分配岗位的学员ID + position_ids = [a.position_id for a in assignments] + if position_ids: + member_result = await db.execute( + select(PositionMember.user_id).where( + PositionMember.position_id.in_(position_ids), + PositionMember.is_deleted == False + ).distinct() + ) + user_ids = [row[0] for row in member_result.fetchall()] + + if user_ids: + notification_batch = NotificationBatchCreate( + user_ids=user_ids, + title="新课程通知", + content=f"您所在岗位有新课程「{course.name}」已分配,请及时学习。", + type=NotificationType.COURSE_ASSIGN, + related_id=course_id, + related_type="course", + sender_id=current_user.id + ) + + await notification_service.batch_create_notifications( + db=db, + batch_in=notification_batch + ) + except Exception as e: + # 通知发送失败不影响课程分配结果 + import logging + logging.getLogger(__name__).error(f"发送课程分配通知失败: {str(e)}") + + return ResponseModel(data=result, message="岗位分配成功") + + +@router.delete( + "/{course_id}/positions/{position_id}", + response_model=ResponseModel[bool], +) +async def remove_course_position( + course_id: int, + position_id: int, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 移除课程的岗位分配(需要管理员权限) + + - **course_id**: 课程ID + - **position_id**: 岗位ID + """ + # 检查课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 移除岗位分配 + from app.services.course_position_service import course_position_service + success = await course_position_service.remove_position_assignment( + db, course_id=course_id, position_id=position_id, user_id=current_user.id + ) + + return ResponseModel(data=success, message="移除岗位分配成功" if success else "移除岗位分配失败") + + +async def _trigger_knowledge_analysis( + db: AsyncSession, + course_id: int, + material_id: int, + file_url: str, + course_title: str, + user_id: int +): + """ + 后台触发知识点分析任务 + + 注意:此函数在后台任务中执行,异常不会影响资料添加的成功响应 + """ + try: + from app.services.ai.knowledge_analysis_v2 import knowledge_analysis_service_v2 + + logger.info( + f"后台知识点分析开始 - course_id: {course_id}, material_id: {material_id}, file_url: {file_url}, user_id: {user_id}" + ) + + result = await knowledge_analysis_service_v2.analyze_course_material( + db=db, + course_id=course_id, + material_id=material_id, + file_url=file_url, + course_title=course_title, + user_id=user_id + ) + + logger.info( + f"后台知识点分析完成 - course_id: {course_id}, material_id: {material_id}, knowledge_points_count: {result.get('knowledge_points_count', 0)}, user_id: {user_id}" + ) + + except FileNotFoundError as e: + # 文件不存在时记录警告,但不记录完整堆栈 + logger.warning( + f"后台知识点分析失败(文件不存在) - course_id: {course_id}, material_id: {material_id}, " + f"file_url: {file_url}, error: {str(e)}, user_id: {user_id}" + ) + except Exception as e: + # 其他异常记录详细信息 + logger.error( + f"后台知识点分析失败 - course_id: {course_id}, material_id: {material_id}, error: {str(e)}", + exc_info=True + ) diff --git a/backend/app/api/v1/coze_gateway.py b/backend/app/api/v1/coze_gateway.py new file mode 100644 index 0000000..e38ae3c --- /dev/null +++ b/backend/app/api/v1/coze_gateway.py @@ -0,0 +1,275 @@ +""" +Coze 网关 API 路由 +提供课程对话和陪练功能的统一接口 +""" + +import logging +from typing import Dict, Any +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import StreamingResponse +from sse_starlette.sse import EventSourceResponse + +from app.services.ai.coze import ( + get_coze_service, + CreateSessionRequest, + SendMessageRequest, + EndSessionRequest, + SessionType, + CozeException, + StreamEventType, +) + + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["coze-gateway"]) + + +# TODO: 依赖注入获取当前用户 +async def get_current_user(): + """获取当前登录用户(临时实现)""" + # 实际应该从 Auth 模块获取 + return {"user_id": "test-user-123", "username": "test_user"} + + +@router.post("/course-chat/sessions") +async def create_course_chat_session(course_id: str, user=Depends(get_current_user)): + """ + 创建课程对话会话 + + - **course_id**: 课程ID + """ + try: + service = get_coze_service() + request = CreateSessionRequest( + session_type=SessionType.COURSE_CHAT, + user_id=user["user_id"], + course_id=course_id, + metadata={"username": user["username"], "course_id": course_id}, + ) + + response = await service.create_session(request) + + return {"code": 200, "message": "success", "data": response.dict()} + + except CozeException as e: + logger.error(f"创建课程对话会话失败: {e}") + raise HTTPException( + status_code=e.status_code or 500, + detail={"code": e.code, "message": e.message, "details": e.details}, + ) + except Exception as e: + logger.error(f"未知错误: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"}, + ) + + +@router.post("/training/sessions") +async def create_training_session( + training_topic: str = None, user=Depends(get_current_user) +): + """ + 创建陪练会话 + + - **training_topic**: 陪练主题(可选) + """ + try: + service = get_coze_service() + request = CreateSessionRequest( + session_type=SessionType.TRAINING, + user_id=user["user_id"], + training_topic=training_topic, + metadata={"username": user["username"], "training_topic": training_topic}, + ) + + response = await service.create_session(request) + + return {"code": 200, "message": "success", "data": response.dict()} + + except CozeException as e: + logger.error(f"创建陪练会话失败: {e}") + raise HTTPException( + status_code=e.status_code or 500, + detail={"code": e.code, "message": e.message, "details": e.details}, + ) + except Exception as e: + logger.error(f"未知错误: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"}, + ) + + +@router.post("/training/sessions/{session_id}/end") +async def end_training_session( + session_id: str, request: EndSessionRequest, user=Depends(get_current_user) +): + """ + 结束陪练会话 + + - **session_id**: 会话ID + """ + try: + service = get_coze_service() + response = await service.end_session(session_id, request) + + return {"code": 200, "message": "success", "data": response.dict()} + + except CozeException as e: + logger.error(f"结束会话失败: {e}") + raise HTTPException( + status_code=e.status_code or 500, + detail={"code": e.code, "message": e.message, "details": e.details}, + ) + except Exception as e: + logger.error(f"未知错误: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"}, + ) + + +@router.post("/chat/messages") +async def send_message(request: SendMessageRequest, user=Depends(get_current_user)): + """ + 发送消息(支持流式响应) + + - **session_id**: 会话ID + - **content**: 消息内容 + - **stream**: 是否流式响应(默认True) + """ + try: + service = get_coze_service() + + if request.stream: + # 流式响应 + async def event_generator(): + async for event in service.send_message(request): + # 转换为 SSE 格式 + if event.event == StreamEventType.MESSAGE_DELTA: + yield { + "event": "message", + "data": { + "type": "delta", + "content": event.content, + "content_type": event.content_type.value, + "message_id": event.message_id, + }, + } + elif event.event == StreamEventType.MESSAGE_COMPLETED: + yield { + "event": "message", + "data": { + "type": "completed", + "content": event.content, + "content_type": event.content_type.value, + "message_id": event.message_id, + "usage": event.data.get("usage", {}), + }, + } + elif event.event == StreamEventType.ERROR: + yield {"event": "error", "data": {"error": event.error}} + elif event.event == StreamEventType.DONE: + yield { + "event": "done", + "data": {"session_id": event.data.get("session_id")}, + } + + return EventSourceResponse(event_generator()) + + else: + # 非流式响应(收集完整响应) + full_content = "" + content_type = None + message_id = None + + async for event in service.send_message(request): + if event.event == StreamEventType.MESSAGE_COMPLETED: + full_content = event.content + content_type = event.content_type + message_id = event.message_id + break + + return { + "code": 200, + "message": "success", + "data": { + "message_id": message_id, + "content": full_content, + "content_type": content_type.value if content_type else "text", + "role": "assistant", + }, + } + + except CozeException as e: + logger.error(f"发送消息失败: {e}") + if request.stream: + # 流式响应的错误处理 + async def error_generator(): + yield { + "event": "error", + "data": { + "code": e.code, + "message": e.message, + "details": e.details, + }, + } + + return EventSourceResponse(error_generator()) + else: + raise HTTPException( + status_code=e.status_code or 500, + detail={"code": e.code, "message": e.message, "details": e.details}, + ) + except Exception as e: + logger.error(f"未知错误: {e}", exc_info=True) + if request.stream: + + async def error_generator(): + yield { + "event": "error", + "data": {"code": "INTERNAL_ERROR", "message": "服务器内部错误"}, + } + + return EventSourceResponse(error_generator()) + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"}, + ) + + +@router.get("/sessions/{session_id}/messages") +async def get_session_messages( + session_id: str, limit: int = 50, offset: int = 0, user=Depends(get_current_user) +): + """ + 获取会话消息历史 + + - **session_id**: 会话ID + - **limit**: 返回消息数量限制 + - **offset**: 偏移量 + """ + try: + service = get_coze_service() + messages = await service.get_session_messages(session_id, limit, offset) + + return { + "code": 200, + "message": "success", + "data": { + "messages": [msg.dict() for msg in messages], + "total": len(messages), + "limit": limit, + "offset": offset, + }, + } + + except Exception as e: + logger.error(f"获取消息历史失败: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"}, + ) diff --git a/backend/app/api/v1/endpoints/employee_sync.py b/backend/app/api/v1/endpoints/employee_sync.py new file mode 100644 index 0000000..50146b5 --- /dev/null +++ b/backend/app/api/v1/endpoints/employee_sync.py @@ -0,0 +1,236 @@ +""" +员工同步API接口 +提供从钉钉员工表同步员工数据的功能 +""" + +from typing import Any, Dict +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logger import get_logger +from app.core.deps import get_current_user, get_db +from app.services.employee_sync_service import EmployeeSyncService +from app.models.user import User + +logger = get_logger(__name__) + +router = APIRouter() + + +@router.post("/sync", summary="执行员工同步") +async def sync_employees( + *, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +) -> Dict[str, Any]: + """ + 从钉钉员工表同步在职员工数据到考培练系统 + + 权限要求: 仅管理员可执行 + + 同步内容: + - 创建用户账号(用户名=手机号,初始密码=123456) + - 创建部门团队 + - 创建岗位并关联用户 + - 设置领导为团队负责人 + + Returns: + 同步结果统计 + """ + # 权限检查:仅管理员可执行 + if current_user.role != 'admin': + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有管理员可以执行员工同步" + ) + + logger.info(f"管理员 {current_user.username} 开始执行员工同步") + + try: + async with EmployeeSyncService(db) as sync_service: + stats = await sync_service.sync_employees() + + return { + "success": True, + "message": "员工同步完成", + "data": stats + } + + except Exception as e: + logger.error(f"员工同步失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"员工同步失败: {str(e)}" + ) + + +@router.get("/preview", summary="预览待同步员工数据") +async def preview_sync_data( + *, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +) -> Dict[str, Any]: + """ + 预览待同步的员工数据(不执行实际同步) + + 权限要求: 仅管理员可查看 + + Returns: + 预览数据,包括员工列表、部门列表、岗位列表等 + """ + # 权限检查:仅管理员可查看 + if current_user.role != 'admin': + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有管理员可以预览员工数据" + ) + + logger.info(f"管理员 {current_user.username} 预览员工同步数据") + + try: + async with EmployeeSyncService(db) as sync_service: + preview_data = await sync_service.preview_sync_data() + + return { + "success": True, + "message": "预览数据获取成功", + "data": preview_data + } + + except Exception as e: + logger.error(f"预览数据获取失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"预览数据获取失败: {str(e)}" + ) + + +@router.post("/incremental-sync", summary="增量同步员工") +async def incremental_sync_employees( + *, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +) -> Dict[str, Any]: + """ + 增量同步钉钉员工数据 + + 功能说明: + - 新增:钉钉有但系统没有的员工 + - 删除:系统有但钉钉没有的员工(物理删除) + - 跳过:两边都存在的员工(不修改任何信息) + + 权限要求: 管理员(admin 或 manager)可执行 + + Returns: + 同步结果统计 + """ + # 权限检查:管理员或经理可执行 + if current_user.role not in ['admin', 'manager']: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有管理员可以执行员工同步" + ) + + logger.info(f"用户 {current_user.username} ({current_user.role}) 开始执行增量员工同步") + + try: + async with EmployeeSyncService(db) as sync_service: + stats = await sync_service.incremental_sync_employees() + + return { + "success": True, + "message": "增量同步完成", + "data": { + "added_count": stats['added_count'], + "deleted_count": stats['deleted_count'], + "skipped_count": stats['skipped_count'], + "added_users": stats['added_users'], + "deleted_users": stats['deleted_users'], + "errors": stats['errors'], + "duration": stats['duration'] + } + } + + except Exception as e: + logger.error(f"增量同步失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"增量同步失败: {str(e)}" + ) + + +@router.get("/status", summary="查询同步状态") +async def get_sync_status( + *, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +) -> Dict[str, Any]: + """ + 查询当前系统的用户、团队、岗位统计信息 + + Returns: + 统计信息 + """ + from sqlalchemy import select, func + from app.models.user import User, Team + from app.models.position import Position + + try: + # 统计用户数量 + user_count_stmt = select(func.count(User.id)).where(User.is_deleted == False) + user_result = await db.execute(user_count_stmt) + total_users = user_result.scalar() + + # 统计各角色用户数量 + admin_count_stmt = select(func.count(User.id)).where( + User.is_deleted == False, + User.role == 'admin' + ) + admin_result = await db.execute(admin_count_stmt) + admin_count = admin_result.scalar() + + manager_count_stmt = select(func.count(User.id)).where( + User.is_deleted == False, + User.role == 'manager' + ) + manager_result = await db.execute(manager_count_stmt) + manager_count = manager_result.scalar() + + trainee_count_stmt = select(func.count(User.id)).where( + User.is_deleted == False, + User.role == 'trainee' + ) + trainee_result = await db.execute(trainee_count_stmt) + trainee_count = trainee_result.scalar() + + # 统计团队数量 + team_count_stmt = select(func.count(Team.id)).where(Team.is_deleted == False) + team_result = await db.execute(team_count_stmt) + total_teams = team_result.scalar() + + # 统计岗位数量 + position_count_stmt = select(func.count(Position.id)).where(Position.is_deleted == False) + position_result = await db.execute(position_count_stmt) + total_positions = position_result.scalar() + + return { + "success": True, + "data": { + "users": { + "total": total_users, + "admin": admin_count, + "manager": manager_count, + "trainee": trainee_count + }, + "teams": total_teams, + "positions": total_positions + } + } + + except Exception as e: + logger.error(f"查询统计信息失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"查询统计信息失败: {str(e)}" + ) + diff --git a/backend/app/api/v1/exam.py b/backend/app/api/v1/exam.py new file mode 100644 index 0000000..5cbc1fa --- /dev/null +++ b/backend/app/api/v1/exam.py @@ -0,0 +1,761 @@ +""" +考试相关API路由 +""" +from typing import List, Optional +import json +from datetime import datetime +from fastapi import APIRouter, Depends, Query, HTTPException, status, Request +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.core.deps import get_db, get_current_user +from app.core.config import get_settings +from app.core.logger import get_logger +from app.models.user import User +from app.models.exam import Exam +from app.models.exam_mistake import ExamMistake +from app.models.position_member import PositionMember +from app.models.position_course import PositionCourse +from app.schemas.base import ResponseModel +from app.schemas.exam import ( + StartExamRequest, + StartExamResponse, + SubmitExamRequest, + SubmitExamResponse, + ExamDetailResponse, + ExamRecordResponse, + GenerateExamRequest, + GenerateExamResponse, + JudgeAnswerRequest, + JudgeAnswerResponse, + RecordMistakeRequest, + RecordMistakeResponse, + GetMistakesResponse, + MistakeRecordItem, + # 新增的Schema + ExamReportResponse, + MistakeListResponse, + MistakesStatisticsResponse, + UpdateRoundScoreRequest, +) +from app.services.exam_report_service import ExamReportService, MistakeService +from app.services.course_statistics_service import course_statistics_service +from app.services.system_log_service import system_log_service +from app.schemas.system_log import SystemLogCreate + +# V2 原生服务:Python 实现 +from app.services.ai import exam_generator_service, ExamGeneratorConfig +from app.services.ai.answer_judge_service import answer_judge_service +from app.core.exceptions import ExternalServiceError + +logger = get_logger(__name__) +settings = get_settings() + +router = APIRouter(prefix="/exams", tags=["考试"]) + + +@router.post("/start", response_model=ResponseModel[StartExamResponse]) +async def start_exam( + request: StartExamRequest, + http_request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """开始考试""" + exam = await ExamService.start_exam( + db=db, + user_id=current_user.id, + course_id=request.course_id, + question_count=request.count, + ) + + # 异步更新课程学员数统计 + try: + await course_statistics_service.update_course_student_count(db, request.course_id) + except Exception as e: + logger.warning(f"更新课程学员数失败: {str(e)}") + # 不影响主流程,只记录警告 + + # 记录考试开始日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="api", + message=f"用户 {current_user.username} 开始考试(课程ID: {request.course_id})", + user_id=current_user.id, + user=current_user.username, + ip=http_request.client.host if http_request.client else None, + path="/api/v1/exams/start", + method="POST", + user_agent=http_request.headers.get("user-agent") + ) + ) + + return ResponseModel(code=200, data=StartExamResponse(exam_id=exam.id), message="考试开始") + + +@router.post("/submit", response_model=ResponseModel[SubmitExamResponse]) +async def submit_exam( + request: SubmitExamRequest, + http_request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """提交考试答案""" + result = await ExamService.submit_exam( + db=db, user_id=current_user.id, exam_id=request.exam_id, answers=request.answers + ) + + # 获取考试记录以获取course_id + exam_stmt = select(Exam).where(Exam.id == request.exam_id) + exam_result = await db.execute(exam_stmt) + exam = exam_result.scalar_one_or_none() + + # 异步更新课程学员数统计 + if exam and exam.course_id: + try: + await course_statistics_service.update_course_student_count(db, exam.course_id) + except Exception as e: + logger.warning(f"更新课程学员数失败: {str(e)}") + # 不影响主流程,只记录警告 + + # 记录考试提交日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="api", + message=f"用户 {current_user.username} 提交考试(考试ID: {request.exam_id},得分: {result.get('score', 0)})", + user_id=current_user.id, + user=current_user.username, + ip=http_request.client.host if http_request.client else None, + path="/api/v1/exams/submit", + method="POST", + user_agent=http_request.headers.get("user-agent") + ) + ) + + return ResponseModel(code=200, data=SubmitExamResponse(**result), message="考试提交成功") + + +@router.get("/mistakes", response_model=ResponseModel[GetMistakesResponse]) +async def get_mistakes( + exam_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 获取错题记录 + + 用于第二、三轮考试时获取上一轮的错题记录 + 返回的数据可直接序列化为JSON字符串作为mistake_records参数传给考试生成接口 + """ + logger.info(f"📋 GET /mistakes 收到请求") + try: + logger.info(f"📋 获取错题记录 - exam_id: {exam_id}, user_id: {current_user.id}") + + # 查询指定考试的错题记录 + result = await db.execute( + select(ExamMistake).where( + ExamMistake.exam_id == exam_id, + ExamMistake.user_id == current_user.id, + ).order_by(ExamMistake.id) + ) + mistakes = result.scalars().all() + + logger.info(f"✅ 查询到错题记录数量: {len(mistakes)}") + + # 转换为响应格式 + mistake_items = [ + MistakeRecordItem( + id=m.id, + question_id=m.question_id, + knowledge_point_id=m.knowledge_point_id, + question_content=m.question_content, + correct_answer=m.correct_answer, + user_answer=m.user_answer, + created_at=m.created_at, + ) + for m in mistakes + ] + + logger.info( + f"获取错题记录成功 - user_id: {current_user.id}, exam_id: {exam_id}, " + f"count: {len(mistake_items)}" + ) + + # 返回统一的ResponseModel格式,让Pydantic自动处理序列化 + return ResponseModel( + code=200, + message="获取错题记录成功", + data=GetMistakesResponse( + mistakes=mistake_items + ) + ) + + except Exception as e: + logger.error(f"获取错题记录失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"获取错题记录失败: {str(e)}" + ) + + +@router.get("/{exam_id}", response_model=ResponseModel[ExamDetailResponse]) +async def get_exam_detail( + exam_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取考试详情""" + exam_data = await ExamService.get_exam_detail( + db=db, user_id=current_user.id, exam_id=exam_id + ) + + return ResponseModel(code=200, data=ExamDetailResponse(**exam_data), message="获取成功") + + +@router.get("/records", response_model=ResponseModel[dict]) +async def get_exam_records( + page: int = Query(1, ge=1), + size: int = Query(10, ge=1, le=100), + course_id: Optional[int] = Query(None), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取考试记录列表""" + records = await ExamService.get_exam_records( + db=db, user_id=current_user.id, page=page, size=size, course_id=course_id + ) + + return ResponseModel(code=200, data=records, message="获取成功") + + +@router.get("/statistics/summary", response_model=ResponseModel[dict]) +async def get_exam_statistics( + course_id: Optional[int] = Query(None), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取考试统计信息""" + stats = await ExamService.get_exam_statistics( + db=db, user_id=current_user.id, course_id=course_id + ) + + return ResponseModel(code=200, data=stats, message="获取成功") + + +# ==================== 试题生成接口 ==================== + +@router.post("/generate", response_model=ResponseModel[GenerateExamResponse]) +async def generate_exam( + request: GenerateExamRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 生成考试试题 + + 使用 Python 原生 AI 服务实现。 + + 考试轮次说明: + - 第一轮考试:mistake_records 传空或不传 + - 第二、三轮错题重考:mistake_records 传入上一轮错题记录的JSON字符串 + """ + try: + # 从用户信息中自动获取岗位ID(如果未提供) + position_id = request.position_id + if not position_id: + # 1. 首先查询用户已分配的岗位 + result = await db.execute( + select(PositionMember).where( + PositionMember.user_id == current_user.id, + PositionMember.is_deleted == False + ).limit(1) + ) + position_member = result.scalar_one_or_none() + if position_member: + position_id = position_member.position_id + else: + # 2. 如果用户没有岗位,从课程关联的岗位中获取第一个 + result = await db.execute( + select(PositionCourse.position_id).where( + PositionCourse.course_id == request.course_id, + PositionCourse.is_deleted == False + ).limit(1) + ) + course_position = result.scalar_one_or_none() + if course_position: + position_id = course_position + logger.info(f"用户 {current_user.id} 没有分配岗位,使用课程关联的岗位ID: {position_id}") + else: + # 3. 如果课程也没有关联岗位,抛出错误 + logger.warning(f"用户 {current_user.id} 没有分配岗位,且课程 {request.course_id} 未关联任何岗位") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无法生成试题:用户未分配岗位,且课程未关联任何岗位" + ) + + # 记录详细的题型设置(用于调试) + logger.info( + f"考试题型设置 - 单选:{request.single_choice_count}, 多选:{request.multiple_choice_count}, " + f"判断:{request.true_false_count}, 填空:{request.fill_blank_count}, 问答:{request.essay_count}, " + f"难度:{request.difficulty_level}" + ) + + # 调用 Python 原生试题生成服务 + logger.info( + f"调用原生试题生成服务 - user_id: {current_user.id}, " + f"course_id: {request.course_id}, position_id: {position_id}" + ) + + # 构建配置 + config = ExamGeneratorConfig( + course_id=request.course_id, + position_id=position_id, + single_choice_count=request.single_choice_count or 0, + multiple_choice_count=request.multiple_choice_count or 0, + true_false_count=request.true_false_count or 0, + fill_blank_count=request.fill_blank_count or 0, + essay_count=request.essay_count or 0, + difficulty_level=request.difficulty_level or 3, + mistake_records=request.mistake_records or "", + ) + + # 调用原生服务 + gen_result = await exam_generator_service.generate_exam(db, config) + + if not gen_result.get("success"): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="试题生成服务返回失败" + ) + + # 将题目列表转为 JSON 字符串(兼容原有前端格式) + result_data = json.dumps(gen_result.get("questions", []), ensure_ascii=False) + + logger.info( + f"试题生成完成 - questions: {gen_result.get('total_count')}, " + f"provider: {gen_result.get('ai_provider')}, latency: {gen_result.get('ai_latency_ms')}ms" + ) + + if result_data is None or result_data == "": + logger.error(f"试题生成未返回有效结果数据") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="试题生成失败: 未返回结果数据" + ) + + # 创建或复用考试记录 + question_count = sum([ + request.single_choice_count or 0, + request.multiple_choice_count or 0, + request.true_false_count or 0, + request.fill_blank_count or 0, + request.essay_count or 0 + ]) + + # 第一轮:创建新的exam记录 + if request.current_round == 1: + exam = Exam( + user_id=current_user.id, + course_id=request.course_id, + exam_name=f"课程{request.course_id}考试", + question_count=question_count, + total_score=100.0, + pass_score=60.0, + duration_minutes=60, + status="started", + start_time=datetime.now(), + questions=None, + answers=None, + ) + + db.add(exam) + await db.commit() + await db.refresh(exam) + + logger.info(f"第{request.current_round}轮:创建考试记录成功 - exam_id: {exam.id}") + else: + # 第二、三轮:复用已有exam记录 + if not request.exam_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"第{request.current_round}轮考试必须提供exam_id" + ) + + exam = await db.get(Exam, request.exam_id) + if not exam: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="考试记录不存在" + ) + + if exam.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="无权访问此考试记录" + ) + + logger.info(f"第{request.current_round}轮:复用考试记录 - exam_id: {exam.id}") + + return ResponseModel( + code=200, + message="试题生成成功", + data=GenerateExamResponse( + result=result_data, + workflow_run_id=f"{gen_result.get('ai_provider')}_{gen_result.get('ai_latency_ms')}ms", + task_id=f"native_{request.course_id}", + exam_id=exam.id, + ) + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"生成考试试题失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"试题生成失败: {str(e)}" + ) + + +@router.post("/judge-answer", response_model=ResponseModel[JudgeAnswerResponse]) +async def judge_answer( + request: JudgeAnswerRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 判断主观题答案 + + 适用于填空题和问答题的答案判断。 + 使用 Python 原生 AI 服务实现。 + """ + try: + logger.info( + f"调用原生答案判断服务 - user_id: {current_user.id}, " + f"question: {request.question[:50]}..." + ) + + result = await answer_judge_service.judge( + question=request.question, + correct_answer=request.correct_answer, + user_answer=request.user_answer, + analysis=request.analysis, + db=db # 传入 db_session 用于记录调用日志 + ) + + logger.info( + f"答案判断完成 - is_correct: {result.is_correct}, " + f"provider: {result.ai_provider}, latency: {result.ai_latency_ms}ms" + ) + + return ResponseModel( + code=200, + message="答案判断完成", + data=JudgeAnswerResponse( + is_correct=result.is_correct, + correct_answer=request.correct_answer, + feedback=result.raw_response if not result.is_correct else None, + ) + ) + + except Exception as e: + logger.error(f"答案判断失败: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"答案判断失败: {str(e)}" + ) + + +@router.post("/record-mistake", response_model=ResponseModel[RecordMistakeResponse]) +async def record_mistake( + request: RecordMistakeRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 记录错题 + + 当用户答错题目时,立即调用此接口记录到错题表 + """ + try: + # 创建错题记录 + # 注意:knowledge_point_id暂时设置为None,避免外键约束失败 + mistake = ExamMistake( + user_id=current_user.id, + exam_id=request.exam_id, + question_id=request.question_id, + knowledge_point_id=None, # 暂时设为None,避免外键约束 + question_content=request.question_content, + correct_answer=request.correct_answer, + user_answer=request.user_answer, + question_type=request.question_type, # 新增:记录题型 + ) + + if request.knowledge_point_id: + logger.info(f"原始knowledge_point_id={request.knowledge_point_id},已设置为NULL(待同步生产数据)") + + db.add(mistake) + await db.commit() + await db.refresh(mistake) + + logger.info( + f"记录错题成功 - user_id: {current_user.id}, exam_id: {request.exam_id}, " + f"mistake_id: {mistake.id}" + ) + + return ResponseModel( + code=200, + message="错题记录成功", + data=RecordMistakeResponse( + id=mistake.id, + created_at=mistake.created_at, + ) + ) + + except Exception as e: + await db.rollback() + logger.error(f"记录错题失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"记录错题失败: {str(e)}" + ) + + +@router.get("/mistakes-debug") +async def get_mistakes_debug( + exam_id: int, +): + """调试endpoint - 不需要认证""" + logger.info(f"🔍 调试 - exam_id: {exam_id}, type: {type(exam_id)}") + return {"exam_id": exam_id, "type": str(type(exam_id))} + + +# ==================== 成绩报告和错题本相关接口 ==================== + +@router.get("/statistics/report", response_model=ResponseModel[ExamReportResponse]) +async def get_exam_report( + start_date: Optional[str] = Query(None, description="开始日期(YYYY-MM-DD)"), + end_date: Optional[str] = Query(None, description="结束日期(YYYY-MM-DD)"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 获取成绩报告汇总 + + 返回包含概览、趋势、科目分析、最近考试记录的完整报告 + """ + try: + report_data = await ExamReportService.get_exam_report( + db=db, + user_id=current_user.id, + start_date=start_date, + end_date=end_date + ) + + return ResponseModel(code=200, data=report_data, message="获取成绩报告成功") + + except Exception as e: + logger.error(f"获取成绩报告失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"获取成绩报告失败: {str(e)}" + ) + + +@router.get("/mistakes/list", response_model=ResponseModel[MistakeListResponse]) +async def get_mistakes_list( + exam_id: Optional[int] = Query(None, description="考试ID"), + course_id: Optional[int] = Query(None, description="课程ID"), + question_type: Optional[str] = Query(None, description="题型(single/multiple/judge/blank/essay)"), + search: Optional[str] = Query(None, description="关键词搜索"), + start_date: Optional[str] = Query(None, description="开始日期(YYYY-MM-DD)"), + end_date: Optional[str] = Query(None, description="结束日期(YYYY-MM-DD)"), + page: int = Query(1, ge=1, description="页码"), + size: int = Query(10, ge=1, le=100, description="每页数量"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 获取错题列表(支持多维度筛选) + + - 不传exam_id时返回用户所有错题 + - 支持按course_id、question_type、关键词、时间范围筛选 + """ + try: + mistakes_data = await MistakeService.get_mistakes_list( + db=db, + user_id=current_user.id, + exam_id=exam_id, + course_id=course_id, + question_type=question_type, + search=search, + start_date=start_date, + end_date=end_date, + page=page, + size=size + ) + + return ResponseModel(code=200, data=mistakes_data, message="获取错题列表成功") + + except Exception as e: + logger.error(f"获取错题列表失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"获取错题列表失败: {str(e)}" + ) + + +@router.get("/mistakes/statistics", response_model=ResponseModel[MistakesStatisticsResponse]) +async def get_mistakes_statistics( + course_id: Optional[int] = Query(None, description="课程ID"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 获取错题统计数据 + + 返回按课程、题型、时间维度的统计数据 + """ + try: + stats_data = await MistakeService.get_mistakes_statistics( + db=db, + user_id=current_user.id, + course_id=course_id + ) + + return ResponseModel(code=200, data=stats_data, message="获取错题统计成功") + + except Exception as e: + logger.error(f"获取错题统计失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"获取错题统计失败: {str(e)}" + ) + + +@router.put("/{exam_id}/round-score", response_model=ResponseModel[dict]) +async def update_round_score( + exam_id: int, + request: UpdateRoundScoreRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 更新某轮的得分 + + 用于前端每轮考试结束后更新对应轮次的得分 + """ + try: + # 查询考试记录 + exam = await db.get(Exam, exam_id) + if not exam: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="考试记录不存在" + ) + + # 验证权限 + if exam.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="无权修改此考试记录" + ) + + # 更新对应轮次的得分 + if request.round == 1: + exam.round1_score = request.score + elif request.round == 2: + exam.round2_score = request.score + elif request.round == 3: + exam.round3_score = request.score + # 第三轮默认就是 final + request.is_final = True + + # 如果是最终轮次(可能是第1/2轮就全对了),更新总分和状态 + if request.is_final: + exam.score = request.score + exam.status = "submitted" + # 计算是否通过 (pass_score 为空默认 60) + exam.is_passed = request.score >= (exam.pass_score or 60) + # 更新结束时间 + from datetime import datetime + exam.end_time = datetime.now() + + await db.commit() + + logger.info(f"更新轮次得分成功 - exam_id: {exam_id}, round: {request.round}, score: {request.score}") + + return ResponseModel(code=200, data={"exam_id": exam_id}, message="更新得分成功") + + except HTTPException: + raise + except Exception as e: + await db.rollback() + logger.error(f"更新轮次得分失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"更新轮次得分失败: {str(e)}" + ) + + +@router.put("/mistakes/{mistake_id}/mastered", response_model=ResponseModel) +async def mark_mistake_mastered( + mistake_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 标记错题为已掌握 + + Args: + mistake_id: 错题记录ID + db: 数据库会话 + current_user: 当前用户 + + Returns: + ResponseModel: 标记结果 + """ + try: + # 查询错题记录 + stmt = select(ExamMistake).where( + ExamMistake.id == mistake_id, + ExamMistake.user_id == current_user.id + ) + result = await db.execute(stmt) + mistake = result.scalar_one_or_none() + + if not mistake: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="错题记录不存在或无权访问" + ) + + # 更新掌握状态 + from datetime import datetime as dt + mistake.mastery_status = 'mastered' + mistake.mastered_at = dt.utcnow() + + await db.commit() + + logger.info(f"标记错题已掌握成功 - mistake_id: {mistake_id}, user_id: {current_user.id}") + + return ResponseModel( + code=200, + message="已标记为掌握", + data={"mistake_id": mistake_id, "mastery_status": "mastered"} + ) + + except HTTPException: + raise + except Exception as e: + await db.rollback() + logger.error(f"标记错题已掌握失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"标记失败: {str(e)}" + ) + + diff --git a/backend/app/api/v1/knowledge_analysis.py b/backend/app/api/v1/knowledge_analysis.py new file mode 100644 index 0000000..65a9caa --- /dev/null +++ b/backend/app/api/v1/knowledge_analysis.py @@ -0,0 +1,201 @@ +""" +知识点分析 API + +使用 Python 原生 AI 服务实现 +""" +import logging +from typing import Dict, Any + +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db, get_current_user +from app.schemas.base import ResponseModel +from app.models.user import User +from app.services.course_service import course_service +from app.services.ai.knowledge_analysis_v2 import knowledge_analysis_service_v2 + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.post("/courses/{course_id}/materials/{material_id}/analyze", response_model=ResponseModel[Dict[str, Any]]) +async def analyze_material_knowledge_points( + course_id: int, + material_id: int, + background_tasks: BackgroundTasks, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 分析单个资料的知识点 + + - **course_id**: 课程ID + - **material_id**: 资料ID + + 使用 Python 原生 AI 服务: + - 本地 AI 服务调用(4sapi.com 首选,OpenRouter 备选) + - 多层 JSON 解析兜底 + - 无外部平台依赖,更稳定 + """ + try: + # 验证课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"课程 {course_id} 不存在" + ) + + # 获取资料信息 + materials = await course_service.get_course_materials(db, course_id=course_id) + material = next((m for m in materials if m.id == material_id), None) + if not material: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"资料 {material_id} 不存在" + ) + + logger.info( + f"准备启动知识点分析 - course_id: {course_id}, material_id: {material_id}, " + f"file_url: {material.file_url}, user_id: {current_user.id}" + ) + + # 调用 Python 原生知识点分析服务 + result = await knowledge_analysis_service_v2.analyze_course_material( + db=db, + course_id=course_id, + material_id=material_id, + file_url=material.file_url, + course_title=course.name, + user_id=current_user.id + ) + + logger.info( + f"知识点分析完成 - course_id: {course_id}, material_id: {material_id}, " + f"knowledge_points: {result.get('knowledge_points_count', 0)}, " + f"provider: {result.get('ai_provider')}" + ) + + # 构建响应 + response_data = { + "message": "知识点分析完成", + "course_id": course_id, + "material_id": material_id, + "status": result.get("status", "completed"), + "knowledge_points_count": result.get("knowledge_points_count", 0), + "ai_provider": result.get("ai_provider"), + "ai_model": result.get("ai_model"), + "ai_tokens": result.get("ai_tokens"), + "ai_latency_ms": result.get("ai_latency_ms"), + } + + return ResponseModel( + data=response_data, + message="知识点分析完成" + ) + + except HTTPException: + raise + except Exception as e: + logger.error( + f"知识点分析失败 - course_id: {course_id}, material_id: {material_id}, error: {e}", + exc_info=True + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"知识点分析失败: {str(e)}" + ) + + +@router.post("/courses/{course_id}/reanalyze", response_model=ResponseModel[Dict[str, Any]]) +async def reanalyze_course_materials( + course_id: int, + background_tasks: BackgroundTasks, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 重新分析课程的所有资料 + + - **course_id**: 课程ID + + 该接口会重新分析课程下的所有资料,提取知识点 + """ + try: + # 验证课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"课程 {course_id} 不存在" + ) + + # 获取课程资料信息 + materials = await course_service.get_course_materials(db, course_id=course_id) + + if not materials: + return ResponseModel( + data={ + "message": "该课程暂无资料需要分析", + "course_id": course_id, + "status": "stopped", + "materials_count": 0 + }, + message="无资料需要分析" + ) + + # 调用 Python 原生知识点分析服务 + result = await knowledge_analysis_service_v2.reanalyze_course_materials( + db=db, + course_id=course_id, + course_title=course.name, + user_id=current_user.id + ) + + return ResponseModel( + data={ + "message": "课程资料重新分析完成", + "course_id": course_id, + "status": "completed", + "materials_count": result.get("materials_count", 0), + "success_count": result.get("success_count", 0), + "knowledge_points_count": result.get("knowledge_points_count", 0), + "analysis_results": result.get("analysis_results", []) + }, + message="重新分析完成" + ) + + except HTTPException: + raise + except Exception as e: + logger.error( + f"启动课程资料重新分析失败 - course_id: {course_id}, error: {e}", + exc_info=True + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="启动重新分析失败" + ) + + +@router.get("/engines", response_model=ResponseModel[Dict[str, Any]]) +async def list_analysis_engines(): + """ + 获取可用的分析引擎列表 + """ + return ResponseModel( + data={ + "engines": [ + { + "id": "native", + "name": "Python 原生实现", + "description": "使用本地 AI 服务(4sapi.com + OpenRouter),稳定可靠", + "default": True + } + ], + "default_engine": "native" + }, + message="获取分析引擎列表成功" + ) diff --git a/backend/app/api/v1/manager/__init__.py b/backend/app/api/v1/manager/__init__.py new file mode 100644 index 0000000..917c681 --- /dev/null +++ b/backend/app/api/v1/manager/__init__.py @@ -0,0 +1,8 @@ +""" +管理员相关API模块 +""" +from .student_scores import router as student_scores_router +from .student_practice import router as student_practice_router + +__all__ = ["student_scores_router", "student_practice_router"] + diff --git a/backend/app/api/v1/manager/student_practice.py b/backend/app/api/v1/manager/student_practice.py new file mode 100644 index 0000000..532231e --- /dev/null +++ b/backend/app/api/v1/manager/student_practice.py @@ -0,0 +1,345 @@ +""" +管理员查看学员陪练记录API +""" +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user, get_db +from app.core.logger import logger +from app.models.position import Position +from app.models.position_member import PositionMember +from app.models.practice import PracticeReport, PracticeSession, PracticeDialogue +from app.models.user import User +from app.schemas.base import PaginatedResponse, ResponseModel + +router = APIRouter(prefix="/manager/student-practice", tags=["manager-student-practice"]) + + +@router.get("/", response_model=ResponseModel[PaginatedResponse]) +async def get_student_practice_records( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(20, ge=1, le=100, description="每页数量"), + student_name: Optional[str] = Query(None, description="学员姓名搜索"), + position: Optional[str] = Query(None, description="岗位筛选"), + scene_type: Optional[str] = Query(None, description="场景类型筛选"), + result: Optional[str] = Query(None, description="结果筛选: excellent/good/average/needs_improvement"), + start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"), + end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取所有用户的陪练记录列表(管理员和manager可访问) + + 包含所有角色(trainee/admin/manager)的陪练记录,方便测试和全面管理 + + 支持筛选: + - student_name: 按用户姓名模糊搜索 + - position: 按岗位筛选 + - scene_type: 按场景类型筛选 + - result: 按结果筛选(优秀/良好/一般/需改进) + - start_date/end_date: 按日期范围筛选 + """ + try: + # 权限检查 + if current_user.role not in ['admin', 'manager']: + return ResponseModel(code=403, message="无权访问", data=None) + + # 构建基础查询 + # 关联User、PracticeReport来获取完整信息 + query = ( + select( + PracticeSession, + User.full_name.label('student_name'), + User.id.label('student_id'), + PracticeReport.total_score + ) + .join(User, PracticeSession.user_id == User.id) + .outerjoin( + PracticeReport, + PracticeSession.session_id == PracticeReport.session_id + ) + .where( + # 管理员可以查看所有人的陪练记录(包括其他管理员的),方便测试和全面管理 + PracticeSession.status == 'completed', # 只查询已完成的陪练 + PracticeSession.is_deleted == False + ) + ) + + # 学员姓名筛选 + if student_name: + query = query.where(User.full_name.contains(student_name)) + + # 岗位筛选 + if position: + # 通过position_members关联查询 + query = query.join( + PositionMember, + and_( + PositionMember.user_id == User.id, + PositionMember.is_deleted == False + ) + ).join( + Position, + Position.id == PositionMember.position_id + ).where( + Position.name == position + ) + + # 场景类型筛选 + if scene_type: + query = query.where(PracticeSession.scene_type == scene_type) + + # 结果筛选(根据分数) + if result: + if result == 'excellent': + query = query.where(PracticeReport.total_score >= 90) + elif result == 'good': + query = query.where(and_( + PracticeReport.total_score >= 80, + PracticeReport.total_score < 90 + )) + elif result == 'average': + query = query.where(and_( + PracticeReport.total_score >= 70, + PracticeReport.total_score < 80 + )) + elif result == 'needs_improvement': + query = query.where(PracticeReport.total_score < 70) + + # 日期范围筛选 + if start_date: + try: + start_dt = datetime.strptime(start_date, '%Y-%m-%d') + query = query.where(PracticeSession.start_time >= start_dt) + except ValueError: + pass + + if end_date: + try: + end_dt = datetime.strptime(end_date, '%Y-%m-%d') + end_dt = end_dt.replace(hour=23, minute=59, second=59) + query = query.where(PracticeSession.start_time <= end_dt) + except ValueError: + pass + + # 按开始时间倒序 + query = query.order_by(PracticeSession.start_time.desc()) + + # 计算总数 + count_query = select(func.count()).select_from(query.subquery()) + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + # 分页查询 + offset = (page - 1) * size + results = await db.execute(query.offset(offset).limit(size)) + + # 构建响应数据 + items = [] + for session, student_name, student_id, total_score in results: + # 查询该学员的所有岗位 + position_query = ( + select(Position.name) + .join(PositionMember, Position.id == PositionMember.position_id) + .where( + PositionMember.user_id == student_id, + PositionMember.is_deleted == False, + Position.is_deleted == False + ) + ) + position_result = await db.execute(position_query) + positions = position_result.scalars().all() + position_str = ', '.join(positions) if positions else None + + # 根据分数计算结果等级 + result_level = "needs_improvement" + if total_score: + if total_score >= 90: + result_level = "excellent" + elif total_score >= 80: + result_level = "good" + elif total_score >= 70: + result_level = "average" + + items.append({ + "id": session.id, + "student_id": student_id, + "student_name": student_name, + "position": position_str, # 所有岗位,逗号分隔 + "session_id": session.session_id, + "scene_name": session.scene_name, + "scene_type": session.scene_type, + "duration_seconds": session.duration_seconds, + "round_count": session.turns, # turns字段表示对话轮数 + "score": total_score, + "result": result_level, + "practice_time": session.start_time.strftime('%Y-%m-%d %H:%M:%S') if session.start_time else None + }) + + # 计算分页信息 + pages = (total + size - 1) // size + + return ResponseModel( + code=200, + message="success", + data=PaginatedResponse( + items=items, + total=total, + page=page, + page_size=size, + pages=pages + ) + ) + + except Exception as e: + logger.error(f"获取学员陪练记录失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取学员陪练记录失败: {str(e)}", data=None) + + +@router.get("/statistics", response_model=ResponseModel) +async def get_student_practice_statistics( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取学员陪练统计数据 + + 返回: + - total_count: 总陪练次数 + - avg_score: 平均评分 + - total_duration_hours: 总陪练时长(小时) + - excellent_rate: 优秀率 + """ + try: + # 权限检查 + if current_user.role not in ['admin', 'manager']: + return ResponseModel(code=403, message="无权访问", data=None) + + # 查询所有已完成陪练(包括所有角色) + query = ( + select(PracticeSession, PracticeReport.total_score) + .join(User, PracticeSession.user_id == User.id) + .outerjoin( + PracticeReport, + PracticeSession.session_id == PracticeReport.session_id + ) + .where( + PracticeSession.status == 'completed', + PracticeSession.is_deleted == False + ) + ) + + result = await db.execute(query) + records = result.all() + + if not records: + return ResponseModel( + code=200, + message="success", + data={ + "total_count": 0, + "avg_score": 0, + "total_duration_hours": 0, + "excellent_rate": 0 + } + ) + + total_count = len(records) + + # 计算总时长(秒转小时) + total_duration_seconds = sum( + session.duration_seconds for session, _ in records if session.duration_seconds + ) + total_duration_hours = round(total_duration_seconds / 3600, 1) + + # 计算平均分 + scores = [score for _, score in records if score is not None] + avg_score = round(sum(scores) / len(scores), 1) if scores else 0 + + # 计算优秀率(>=90分) + excellent = sum(1 for _, score in records if score and score >= 90) + excellent_rate = round((excellent / total_count) * 100, 1) if total_count > 0 else 0 + + return ResponseModel( + code=200, + message="success", + data={ + "total_count": total_count, + "avg_score": avg_score, + "total_duration_hours": total_duration_hours, + "excellent_rate": excellent_rate + } + ) + + except Exception as e: + logger.error(f"获取学员陪练统计失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取学员陪练统计失败: {str(e)}", data=None) + + +@router.get("/{session_id}/conversation", response_model=ResponseModel) +async def get_session_conversation( + session_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取指定会话的对话记录 + + 返回对话列表,按sequence排序 + """ + try: + # 权限检查 + if current_user.role not in ['admin', 'manager']: + return ResponseModel(code=403, message="无权访问", data=None) + + # 1. 查询会话是否存在 + session_query = select(PracticeSession).where( + PracticeSession.session_id == session_id, + PracticeSession.is_deleted == False + ) + session_result = await db.execute(session_query) + session = session_result.scalar_one_or_none() + + if not session: + return ResponseModel(code=404, message="会话不存在", data=None) + + # 2. 查询对话记录 + dialogue_query = ( + select(PracticeDialogue) + .where(PracticeDialogue.session_id == session_id) + .order_by(PracticeDialogue.sequence) + ) + dialogue_result = await db.execute(dialogue_query) + dialogues = dialogue_result.scalars().all() + + # 3. 构建响应数据 + conversation = [] + for dialogue in dialogues: + conversation.append({ + "role": dialogue.speaker, # "user" 或 "ai" + "content": dialogue.content, + "timestamp": dialogue.timestamp.strftime('%Y-%m-%d %H:%M:%S') if dialogue.timestamp else None, + "sequence": dialogue.sequence + }) + + logger.info(f"获取会话对话记录: session_id={session_id}, 对话数={len(conversation)}") + + return ResponseModel( + code=200, + message="success", + data={ + "session_id": session_id, + "conversation": conversation, + "total_count": len(conversation) + } + ) + + except Exception as e: + logger.error(f"获取会话对话记录失败: {e}, session_id={session_id}", exc_info=True) + return ResponseModel(code=500, message=f"获取对话记录失败: {str(e)}", data=None) + diff --git a/backend/app/api/v1/manager/student_scores.py b/backend/app/api/v1/manager/student_scores.py new file mode 100644 index 0000000..1611f38 --- /dev/null +++ b/backend/app/api/v1/manager/student_scores.py @@ -0,0 +1,447 @@ +""" +管理员查看学员考试成绩API +""" +from datetime import datetime +from typing import List, Optional + +from fastapi import APIRouter, Body, Depends, Query +from pydantic import BaseModel +from sqlalchemy import and_, delete, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.deps import get_current_user, get_db +from app.core.logger import logger +from app.models.course import Course +from app.models.exam import Exam +from app.models.exam_mistake import ExamMistake +from app.models.position_member import PositionMember +from app.models.position import Position +from app.models.user import User +from app.schemas.base import PaginatedResponse, ResponseModel + +router = APIRouter(prefix="/manager/student-scores", tags=["manager-student-scores"]) + + +class BatchDeleteRequest(BaseModel): + """批量删除请求""" + ids: List[int] + + +@router.get("/{exam_id}/mistakes", response_model=ResponseModel[PaginatedResponse]) +async def get_exam_mistakes( + exam_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取指定考试的错题记录(管理员和manager可访问) + """ + try: + # 权限检查 + if current_user.role not in ['admin', 'manager']: + return ResponseModel(code=403, message="无权访问", data=None) + + # 查询错题记录 + query = ( + select(ExamMistake) + .options(selectinload(ExamMistake.question)) + .where(ExamMistake.exam_id == exam_id) + .order_by(ExamMistake.created_at.desc()) + ) + + result = await db.execute(query) + mistakes = result.scalars().all() + + items = [] + for mistake in mistakes: + # 获取解析:优先从关联题目获取,如果是AI生成的题目可能没有关联题目 + analysis = "" + if mistake.question and mistake.question.explanation: + analysis = mistake.question.explanation + + items.append({ + "id": mistake.id, + "question_content": mistake.question_content, + "correct_answer": mistake.correct_answer, + "user_answer": mistake.user_answer, + "question_type": mistake.question_type, + "analysis": analysis, + "created_at": mistake.created_at.strftime('%Y-%m-%d %H:%M:%S') if mistake.created_at else None + }) + + return ResponseModel( + code=200, + message="success", + data=PaginatedResponse( + items=items, + total=len(items), + page=1, + page_size=len(items), + pages=1 + ) + ) + + except Exception as e: + logger.error(f"获取错题记录失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取错题记录失败: {str(e)}", data=None) + + +@router.get("/", response_model=ResponseModel[PaginatedResponse]) +async def get_student_scores( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(20, ge=1, le=100, description="每页数量"), + student_name: Optional[str] = Query(None, description="学员姓名搜索"), + position: Optional[str] = Query(None, description="岗位筛选"), + course_id: Optional[int] = Query(None, description="课程ID筛选"), + score_range: Optional[str] = Query(None, description="成绩范围: excellent/good/pass/fail"), + start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"), + end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取所有学员的考试成绩列表(管理员和manager可访问) + + 支持筛选: + - student_name: 按学员姓名模糊搜索 + - position: 按岗位筛选 + - course_id: 按课程筛选 + - score_range: 按成绩范围筛选(excellent>=90, good>=80, pass>=60, fail<60) + - start_date/end_date: 按日期范围筛选 + """ + try: + # 权限检查 + if current_user.role not in ['admin', 'manager']: + return ResponseModel(code=403, message="无权访问", data=None) + + # 构建基础查询 + # 关联User、Course、ExamMistake来获取完整信息 + query = ( + select( + Exam, + User.full_name.label('student_name'), + User.id.label('student_id'), + Course.name.label('course_name'), + func.count(ExamMistake.id).label('wrong_count') + ) + .join(User, Exam.user_id == User.id) + .join(Course, Exam.course_id == Course.id) + .outerjoin(ExamMistake, and_( + ExamMistake.exam_id == Exam.id, + ExamMistake.user_id == User.id + )) + .where( + Exam.status.in_(['completed', 'submitted']) # 只查询已完成的考试 + ) + .group_by(Exam.id, User.id, User.full_name, Course.id, Course.name) + ) + + # 学员姓名筛选 + if student_name: + query = query.where(User.full_name.contains(student_name)) + + # 岗位筛选 + if position: + # 通过position_members关联查询 + query = query.join( + PositionMember, + and_( + PositionMember.user_id == User.id, + PositionMember.is_deleted == False + ) + ).join( + Position, + Position.id == PositionMember.position_id + ).where( + Position.name == position + ) + + # 课程筛选 + if course_id: + query = query.where(Exam.course_id == course_id) + + # 成绩范围筛选 + if score_range: + score_field = Exam.round1_score # 使用第一轮成绩 + if score_range == 'excellent': + query = query.where(score_field >= 90) + elif score_range == 'good': + query = query.where(and_(score_field >= 80, score_field < 90)) + elif score_range == 'pass': + query = query.where(and_(score_field >= 60, score_field < 80)) + elif score_range == 'fail': + query = query.where(score_field < 60) + + # 日期范围筛选 + if start_date: + try: + start_dt = datetime.strptime(start_date, '%Y-%m-%d') + query = query.where(Exam.created_at >= start_dt) + except ValueError: + pass + + if end_date: + try: + end_dt = datetime.strptime(end_date, '%Y-%m-%d') + end_dt = end_dt.replace(hour=23, minute=59, second=59) + query = query.where(Exam.created_at <= end_dt) + except ValueError: + pass + + # 按创建时间倒序 + query = query.order_by(Exam.created_at.desc()) + + # 计算总数 + count_query = select(func.count()).select_from(query.subquery()) + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + # 分页查询 + offset = (page - 1) * size + results = await db.execute(query.offset(offset).limit(size)) + + # 构建响应数据 + items = [] + for exam, student_name, student_id, course_name, wrong_count in results: + # 查询该学员的所有岗位 + position_query = ( + select(Position.name) + .join(PositionMember, Position.id == PositionMember.position_id) + .where( + PositionMember.user_id == student_id, + PositionMember.is_deleted == False, + Position.is_deleted == False + ) + ) + position_result = await db.execute(position_query) + positions = position_result.scalars().all() + position_str = ', '.join(positions) if positions else None + + # 计算正确率和用时 + accuracy = None + correct_count = None + duration_seconds = None + + if exam.question_count and exam.question_count > 0: + correct_count = exam.question_count - wrong_count + accuracy = round((correct_count / exam.question_count) * 100, 1) + + if exam.start_time and exam.end_time: + duration_seconds = int((exam.end_time - exam.start_time).total_seconds()) + + items.append({ + "id": exam.id, + "student_id": student_id, + "student_name": student_name, + "position": position_str, # 所有岗位,逗号分隔 + "course_id": exam.course_id, + "course_name": course_name, + "exam_type": "assessment", # 简化处理,统一为assessment + "score": float(exam.round1_score) if exam.round1_score else 0, + "round1_score": float(exam.round1_score) if exam.round1_score else None, + "round2_score": float(exam.round2_score) if exam.round2_score else None, + "round3_score": float(exam.round3_score) if exam.round3_score else None, + "total_score": float(exam.total_score) if exam.total_score else 100, + "accuracy": accuracy, + "correct_count": correct_count, + "wrong_count": wrong_count, + "total_count": exam.question_count, + "duration_seconds": duration_seconds, + "exam_date": exam.created_at.strftime('%Y-%m-%d %H:%M:%S') if exam.created_at else None + }) + + # 计算分页信息 + pages = (total + size - 1) // size + + return ResponseModel( + code=200, + message="success", + data=PaginatedResponse( + items=items, + total=total, + page=page, + page_size=size, + pages=pages + ) + ) + + except Exception as e: + logger.error(f"获取学员考试成绩失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取学员考试成绩失败: {str(e)}", data=None) + + +@router.get("/statistics", response_model=ResponseModel) +async def get_student_scores_statistics( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取学员考试成绩统计数据 + + 返回: + - total_exams: 总考试次数 + - avg_score: 平均分 + - pass_rate: 通过率 + - excellent_rate: 优秀率 + """ + try: + # 权限检查 + if current_user.role not in ['admin', 'manager']: + return ResponseModel(code=403, message="无权访问", data=None) + + # 查询所有用户的已完成考试 + query = ( + select(Exam) + .join(User, Exam.user_id == User.id) + .where( + Exam.status.in_(['completed', 'submitted']), + Exam.round1_score.isnot(None) + ) + ) + + result = await db.execute(query) + exams = result.scalars().all() + + if not exams: + return ResponseModel( + code=200, + message="success", + data={ + "total_exams": 0, + "avg_score": 0, + "pass_rate": 0, + "excellent_rate": 0 + } + ) + + total_exams = len(exams) + total_score = sum(exam.round1_score for exam in exams if exam.round1_score) + avg_score = round(total_score / total_exams, 1) if total_exams > 0 else 0 + + passed = sum(1 for exam in exams if exam.round1_score and exam.round1_score >= 60) + pass_rate = round((passed / total_exams) * 100, 1) if total_exams > 0 else 0 + + excellent = sum(1 for exam in exams if exam.round1_score and exam.round1_score >= 90) + excellent_rate = round((excellent / total_exams) * 100, 1) if total_exams > 0 else 0 + + return ResponseModel( + code=200, + message="success", + data={ + "total_exams": total_exams, + "avg_score": avg_score, + "pass_rate": pass_rate, + "excellent_rate": excellent_rate + } + ) + + except Exception as e: + logger.error(f"获取学员考试成绩统计失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取学员考试成绩统计失败: {str(e)}", data=None) + + +@router.delete("/{exam_id}", response_model=ResponseModel) +async def delete_exam_record( + exam_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 删除单条考试记录(管理员可访问) + + 会同时删除关联的错题记录 + """ + try: + # 权限检查 - 仅管理员可删除 + if current_user.role != 'admin': + return ResponseModel(code=403, message="无权操作,仅管理员可删除考试记录", data=None) + + # 查询考试记录 + result = await db.execute( + select(Exam).where(Exam.id == exam_id) + ) + exam = result.scalar_one_or_none() + + if not exam: + return ResponseModel(code=404, message="考试记录不存在", data=None) + + # 删除关联的错题记录 + await db.execute( + delete(ExamMistake).where(ExamMistake.exam_id == exam_id) + ) + + # 删除考试记录 + await db.delete(exam) + await db.commit() + + logger.info(f"管理员 {current_user.username} 删除了考试记录 {exam_id}") + + return ResponseModel( + code=200, + message="考试记录已删除", + data={"deleted_id": exam_id} + ) + + except Exception as e: + await db.rollback() + logger.error(f"删除考试记录失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"删除考试记录失败: {str(e)}", data=None) + + +@router.delete("/batch/delete", response_model=ResponseModel) +async def batch_delete_exam_records( + request: BatchDeleteRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 批量删除考试记录(管理员可访问) + + 会同时删除关联的错题记录 + """ + try: + # 权限检查 - 仅管理员可删除 + if current_user.role != 'admin': + return ResponseModel(code=403, message="无权操作,仅管理员可删除考试记录", data=None) + + if not request.ids: + return ResponseModel(code=400, message="请选择要删除的记录", data=None) + + # 查询存在的考试记录 + result = await db.execute( + select(Exam.id).where(Exam.id.in_(request.ids)) + ) + existing_ids = [row[0] for row in result.all()] + + if not existing_ids: + return ResponseModel(code=404, message="未找到要删除的记录", data=None) + + # 删除关联的错题记录 + await db.execute( + delete(ExamMistake).where(ExamMistake.exam_id.in_(existing_ids)) + ) + + # 删除考试记录 + await db.execute( + delete(Exam).where(Exam.id.in_(existing_ids)) + ) + await db.commit() + + deleted_count = len(existing_ids) + logger.info(f"管理员 {current_user.username} 批量删除了 {deleted_count} 条考试记录") + + return ResponseModel( + code=200, + message=f"成功删除 {deleted_count} 条考试记录", + data={ + "deleted_count": deleted_count, + "deleted_ids": existing_ids + } + ) + + except Exception as e: + await db.rollback() + logger.error(f"批量删除考试记录失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"批量删除考试记录失败: {str(e)}", data=None) + diff --git a/backend/app/api/v1/notifications.py b/backend/app/api/v1/notifications.py new file mode 100644 index 0000000..39b36df --- /dev/null +++ b/backend/app/api/v1/notifications.py @@ -0,0 +1,255 @@ +""" +站内消息通知 API +提供通知的查询、标记已读、删除等功能 +""" +import logging +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db, get_current_user +from app.models.user import User +from app.schemas.base import ResponseModel +from app.schemas.notification import ( + NotificationCreate, + NotificationBatchCreate, + NotificationResponse, + NotificationListResponse, + NotificationCountResponse, + MarkReadRequest, +) +from app.services.notification_service import notification_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/notifications") + + +@router.get("", response_model=ResponseModel[NotificationListResponse]) +async def get_notifications( + is_read: Optional[bool] = Query(None, description="是否已读筛选"), + type: Optional[str] = Query(None, description="通知类型筛选"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取当前用户的通知列表 + + 支持按已读状态和通知类型筛选 + """ + try: + skip = (page - 1) * page_size + + notifications, total, unread_count = await notification_service.get_user_notifications( + db=db, + user_id=current_user.id, + skip=skip, + limit=page_size, + is_read=is_read, + notification_type=type + ) + + response_data = NotificationListResponse( + items=notifications, + total=total, + unread_count=unread_count + ) + + return ResponseModel( + code=200, + message="获取通知列表成功", + data=response_data + ) + + except Exception as e: + logger.error(f"获取通知列表失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取通知列表失败: {str(e)}") + + +@router.get("/unread-count", response_model=ResponseModel[NotificationCountResponse]) +async def get_unread_count( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取当前用户的未读通知数量 + + 用于顶部导航栏显示未读消息数 + """ + try: + unread_count, total = await notification_service.get_unread_count( + db=db, + user_id=current_user.id + ) + + return ResponseModel( + code=200, + message="获取未读数量成功", + data=NotificationCountResponse( + unread_count=unread_count, + total=total + ) + ) + + except Exception as e: + logger.error(f"获取未读数量失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取未读数量失败: {str(e)}") + + +@router.post("/mark-read", response_model=ResponseModel) +async def mark_notifications_read( + request: MarkReadRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 标记通知为已读 + + - 传入 notification_ids 则标记指定通知 + - 不传则标记全部未读通知为已读 + """ + try: + updated_count = await notification_service.mark_as_read( + db=db, + user_id=current_user.id, + notification_ids=request.notification_ids + ) + + return ResponseModel( + code=200, + message=f"成功标记 {updated_count} 条通知为已读", + data={"updated_count": updated_count} + ) + + except Exception as e: + logger.error(f"标记已读失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"标记已读失败: {str(e)}") + + +@router.delete("/{notification_id}", response_model=ResponseModel) +async def delete_notification( + notification_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 删除单条通知 + + 只能删除自己的通知 + """ + try: + success = await notification_service.delete_notification( + db=db, + user_id=current_user.id, + notification_id=notification_id + ) + + if not success: + raise HTTPException(status_code=404, detail="通知不存在或无权删除") + + return ResponseModel( + code=200, + message="删除通知成功", + data={"deleted": True} + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"删除通知失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"删除通知失败: {str(e)}") + + +# ==================== 管理员接口 ==================== + +@router.post("/send", response_model=ResponseModel[NotificationResponse]) +async def send_notification( + notification_in: NotificationCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 发送单条通知(管理员接口) + + 向指定用户发送通知 + """ + try: + # 权限检查:仅管理员和管理者可发送通知 + if current_user.role not in ["admin", "manager"]: + raise HTTPException(status_code=403, detail="无权限发送通知") + + # 设置发送者 + notification_in.sender_id = current_user.id + + notification = await notification_service.create_notification( + db=db, + notification_in=notification_in + ) + + # 构建响应 + response = NotificationResponse( + id=notification.id, + user_id=notification.user_id, + title=notification.title, + content=notification.content, + type=notification.type, + is_read=notification.is_read, + related_id=notification.related_id, + related_type=notification.related_type, + sender_id=notification.sender_id, + sender_name=current_user.full_name, + created_at=notification.created_at, + updated_at=notification.updated_at + ) + + return ResponseModel( + code=200, + message="发送通知成功", + data=response + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"发送通知失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"发送通知失败: {str(e)}") + + +@router.post("/send-batch", response_model=ResponseModel) +async def send_batch_notifications( + batch_in: NotificationBatchCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 批量发送通知(管理员接口) + + 向多个用户发送相同的通知 + """ + try: + # 权限检查:仅管理员和管理者可发送通知 + if current_user.role not in ["admin", "manager"]: + raise HTTPException(status_code=403, detail="无权限发送通知") + + # 设置发送者 + batch_in.sender_id = current_user.id + + notifications = await notification_service.batch_create_notifications( + db=db, + batch_in=batch_in + ) + + return ResponseModel( + code=200, + message=f"成功发送 {len(notifications)} 条通知", + data={"sent_count": len(notifications)} + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"批量发送通知失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"批量发送通知失败: {str(e)}") + diff --git a/backend/app/api/v1/positions.py b/backend/app/api/v1/positions.py new file mode 100644 index 0000000..20884f3 --- /dev/null +++ b/backend/app/api/v1/positions.py @@ -0,0 +1,658 @@ +""" +岗位管理 API(真实数据库) +""" + +from typing import Optional, List +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, func +from sqlalchemy.orm import selectinload +import sqlalchemy as sa + +from app.core.deps import get_current_active_user as get_current_user, get_db, require_admin, require_admin_or_manager +from app.schemas.base import ResponseModel, PaginationParams, PaginatedResponse +from app.models.position import Position +from app.models.position_member import PositionMember +from app.models.position_course import PositionCourse +from app.models.user import User +from app.models.course import Course + + +router = APIRouter(prefix="/admin/positions") + + +@router.get("") +async def list_positions( + pagination: PaginationParams = Depends(), + keyword: Optional[str] = Query(None, description="关键词"), + current_user=Depends(require_admin_or_manager), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """分页获取岗位列表(管理员或经理)。""" + stmt = select(Position).where(Position.is_deleted == False) + if keyword: + like = f"%{keyword}%" + stmt = stmt.where((Position.name.ilike(like)) | (Position.description.ilike(like))) + rows = (await db.execute(stmt)).scalars().all() + total = len(rows) + sliced = rows[pagination.offset : pagination.offset + pagination.limit] + + async def to_dict(p: Position) -> dict: + """将Position对象转换为字典,并添加统计数据""" + d = p.__dict__.copy() + d.pop("_sa_instance_state", None) + + # 统计岗位成员数量 + member_count_result = await db.execute( + select(func.count(PositionMember.id)).where( + and_( + PositionMember.position_id == p.id, + PositionMember.is_deleted == False + ) + ) + ) + d["memberCount"] = member_count_result.scalar() or 0 + + # 统计必修课程数量 + required_count_result = await db.execute( + select(func.count(PositionCourse.id)).where( + and_( + PositionCourse.position_id == p.id, + PositionCourse.course_type == "required", + PositionCourse.is_deleted == False + ) + ) + ) + d["requiredCourses"] = required_count_result.scalar() or 0 + + # 统计选修课程数量 + optional_count_result = await db.execute( + select(func.count(PositionCourse.id)).where( + and_( + PositionCourse.position_id == p.id, + PositionCourse.course_type == "optional", + PositionCourse.is_deleted == False + ) + ) + ) + d["optionalCourses"] = optional_count_result.scalar() or 0 + + return d + + # 为每个岗位添加统计数据(使用异步) + items = [] + for p in sliced: + item = await to_dict(p) + items.append(item) + + paged = { + "items": items, + "total": total, + "page": pagination.page, + "page_size": pagination.page_size, + "pages": (total + pagination.page_size - 1) // pagination.page_size if pagination.page_size else 1, + } + return ResponseModel(message="获取岗位列表成功", data=paged) + + +@router.get("/tree") +async def get_position_tree( + current_user=Depends(require_admin_or_manager), db: AsyncSession = Depends(get_db) +) -> ResponseModel: + """获取岗位树(管理员或经理)。""" + rows = (await db.execute(select(Position).where(Position.is_deleted == False))).scalars().all() + id_to_node = {p.id: {**p.__dict__, "children": []} for p in rows} + roots: List[dict] = [] + for p in rows: + node = id_to_node[p.id] + parent_id = p.parent_id + if parent_id and parent_id in id_to_node: + id_to_node[parent_id]["children"].append(node) + else: + roots.append(node) + # 清理 _sa_instance_state + def clean(d: dict): + d.pop("_sa_instance_state", None) + for c in d.get("children", []): + clean(c) + for r in roots: + clean(r) + return ResponseModel(message="获取岗位树成功", data=roots) + + +@router.post("") +async def create_position( + payload: dict, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db) +) -> ResponseModel: + obj = Position( + name=payload.get("name"), + code=payload.get("code"), + description=payload.get("description"), + parent_id=payload.get("parentId"), + status=payload.get("status", "active"), + skills=payload.get("skills"), + level=payload.get("level"), + sort_order=payload.get("sort_order", 0), + created_by=current_user.id, + ) + db.add(obj) + await db.commit() + await db.refresh(obj) + return ResponseModel(message="创建岗位成功", data={"id": obj.id}) + + +@router.put("/{position_id}") +async def update_position( + position_id: int, payload: dict, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db) +) -> ResponseModel: + obj = await db.get(Position, position_id) + if not obj or obj.is_deleted: + return ResponseModel(code=404, message="岗位不存在") + obj.name = payload.get("name", obj.name) + obj.code = payload.get("code", obj.code) + obj.description = payload.get("description", obj.description) + obj.parent_id = payload.get("parentId", obj.parent_id) + obj.status = payload.get("status", obj.status) + obj.skills = payload.get("skills", obj.skills) + obj.level = payload.get("level", obj.level) + obj.sort_order = payload.get("sort_order", obj.sort_order) + obj.updated_by = current_user.id + await db.commit() + await db.refresh(obj) + + # 返回更新后的完整数据 + data = obj.__dict__.copy() + data.pop("_sa_instance_state", None) + return ResponseModel(message="更新岗位成功", data=data) + + +@router.get("/{position_id}") +async def get_position_detail( + position_id: int, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db) +) -> ResponseModel: + obj = await db.get(Position, position_id) + if not obj or obj.is_deleted: + return ResponseModel(code=404, message="岗位不存在") + data = obj.__dict__.copy() + data.pop("_sa_instance_state", None) + return ResponseModel(data=data) + + +@router.get("/{position_id}/check-delete") +async def check_position_delete( + position_id: int, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db) +) -> ResponseModel: + obj = await db.get(Position, position_id) + if not obj or obj.is_deleted: + return ResponseModel(code=404, message="岗位不存在") + + # 检查是否有子岗位 + child_count_result = await db.execute( + select(func.count(Position.id)).where( + and_( + Position.parent_id == position_id, + Position.is_deleted == False + ) + ) + ) + child_count = child_count_result.scalar() or 0 + + if child_count > 0: + return ResponseModel(data={ + "deletable": False, + "reason": f"该岗位下有 {child_count} 个子岗位,请先删除或移动子岗位" + }) + + # 检查是否有成员(仅作为提醒,不阻止删除) + member_count_result = await db.execute( + select(func.count(PositionMember.id)).where( + and_( + PositionMember.position_id == position_id, + PositionMember.is_deleted == False + ) + ) + ) + member_count = member_count_result.scalar() or 0 + + warning = "" + if member_count > 0: + warning = f"注意:该岗位当前有 {member_count} 名成员,删除后这些成员将不再属于此岗位" + + return ResponseModel(data={"deletable": True, "reason": "", "warning": warning, "member_count": member_count}) + + +@router.delete("/{position_id}") +async def delete_position( + position_id: int, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db) +) -> ResponseModel: + obj = await db.get(Position, position_id) + if not obj or obj.is_deleted: + return ResponseModel(code=404, message="岗位不存在") + + # 检查是否有子岗位 + child_count_result = await db.execute( + select(func.count(Position.id)).where( + and_( + Position.parent_id == position_id, + Position.is_deleted == False + ) + ) + ) + child_count = child_count_result.scalar() or 0 + + if child_count > 0: + return ResponseModel( + code=400, + message=f"该岗位下有 {child_count} 个子岗位,请先删除或移动子岗位" + ) + + # 软删除岗位成员关联 + await db.execute( + sa.update(PositionMember) + .where(PositionMember.position_id == position_id) + .values(is_deleted=True) + ) + + # 软删除岗位课程关联 + await db.execute( + sa.update(PositionCourse) + .where(PositionCourse.position_id == position_id) + .values(is_deleted=True) + ) + + # 软删除岗位 + obj.is_deleted = True + await db.commit() + return ResponseModel(message="岗位已删除") + + +# ========== 岗位成员管理 API ========== + +@router.get("/{position_id}/members") +async def get_position_members( + position_id: int, + pagination: PaginationParams = Depends(), + keyword: Optional[str] = Query(None, description="搜索关键词"), + current_user=Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """获取岗位成员列表""" + # 验证岗位存在 + position = await db.get(Position, position_id) + if not position or position.is_deleted: + return ResponseModel(code=404, message="岗位不存在") + + # 构建查询 + stmt = ( + select(PositionMember, User) + .join(User, PositionMember.user_id == User.id) + .where( + and_( + PositionMember.position_id == position_id, + PositionMember.is_deleted == False, + User.is_deleted == False + ) + ) + ) + + # 关键词搜索 + if keyword: + like = f"%{keyword}%" + stmt = stmt.where( + (User.username.ilike(like)) | + (User.full_name.ilike(like)) | + (User.email.ilike(like)) + ) + + # 执行查询 + result = await db.execute(stmt) + rows = result.all() + total = len(rows) + sliced = rows[pagination.offset : pagination.offset + pagination.limit] + + # 格式化数据 + items = [] + for pm, user in sliced: + items.append({ + "id": pm.id, + "user_id": user.id, + "username": user.username, + "full_name": user.full_name, + "email": user.email, + "phone": user.phone, + "role": pm.role, + "joined_at": pm.joined_at.isoformat() if pm.joined_at else None, + "user_role": user.role, # 系统角色 + "is_active": user.is_active, + }) + + return ResponseModel( + message="获取成员列表成功", + data={ + "items": items, + "total": total, + "page": pagination.page, + "page_size": pagination.page_size, + "pages": (total + pagination.page_size - 1) // pagination.page_size if pagination.page_size else 1, + } + ) + + +@router.post("/{position_id}/members") +async def add_position_members( + position_id: int, + payload: dict, + current_user=Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """批量添加岗位成员""" + # 验证岗位存在 + position = await db.get(Position, position_id) + if not position or position.is_deleted: + return ResponseModel(code=404, message="岗位不存在") + + user_ids = payload.get("user_ids", []) + if not user_ids: + return ResponseModel(code=400, message="请选择要添加的用户") + + # 验证用户存在 + users = await db.execute( + select(User).where( + and_( + User.id.in_(user_ids), + User.is_deleted == False + ) + ) + ) + valid_users = {u.id: u for u in users.scalars().all()} + + if len(valid_users) != len(user_ids): + invalid_ids = set(user_ids) - set(valid_users.keys()) + return ResponseModel(code=400, message=f"部分用户不存在: {invalid_ids}") + + # 检查是否已存在 + existing = await db.execute( + select(PositionMember).where( + and_( + PositionMember.position_id == position_id, + PositionMember.user_id.in_(user_ids), + PositionMember.is_deleted == False + ) + ) + ) + existing_user_ids = {pm.user_id for pm in existing.scalars().all()} + + # 添加新成员 + added_count = 0 + for user_id in user_ids: + if user_id not in existing_user_ids: + member = PositionMember( + position_id=position_id, + user_id=user_id, + role=payload.get("role") + ) + db.add(member) + added_count += 1 + + await db.commit() + + return ResponseModel( + message=f"成功添加 {added_count} 个成员", + data={"added_count": added_count} + ) + + +@router.delete("/{position_id}/members/{user_id}") +async def remove_position_member( + position_id: int, + user_id: int, + current_user=Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """移除岗位成员""" + # 查找成员关系 + member = await db.execute( + select(PositionMember).where( + and_( + PositionMember.position_id == position_id, + PositionMember.user_id == user_id, + PositionMember.is_deleted == False + ) + ) + ) + member = member.scalar_one_or_none() + + if not member: + return ResponseModel(code=404, message="成员关系不存在") + + # 软删除 + member.is_deleted = True + await db.commit() + + return ResponseModel(message="成员已移除") + + +# ========== 岗位课程管理 API ========== + +@router.get("/{position_id}/courses") +async def get_position_courses( + position_id: int, + course_type: Optional[str] = Query(None, description="课程类型:required/optional"), + current_user=Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """获取岗位课程列表""" + # 验证岗位存在 + position = await db.get(Position, position_id) + if not position or position.is_deleted: + return ResponseModel(code=404, message="岗位不存在") + + # 构建查询 + stmt = ( + select(PositionCourse, Course) + .join(Course, PositionCourse.course_id == Course.id) + .where( + and_( + PositionCourse.position_id == position_id, + PositionCourse.is_deleted == False, + Course.is_deleted == False + ) + ) + ) + + # 课程类型筛选 + if course_type: + stmt = stmt.where(PositionCourse.course_type == course_type) + + # 按优先级排序 + stmt = stmt.order_by(PositionCourse.priority, PositionCourse.id) + + # 执行查询 + result = await db.execute(stmt) + rows = result.all() + + # 格式化数据 + items = [] + for pc, course in rows: + items.append({ + "id": pc.id, + "course_id": course.id, + "course_name": course.name, + "course_description": course.description, + "course_category": course.category, + "course_status": course.status, + "course_duration_hours": course.duration_hours, + "course_difficulty_level": course.difficulty_level, + "course_type": pc.course_type, + "priority": pc.priority, + "created_at": pc.created_at.isoformat() if pc.created_at else None, + }) + + # 统计 + stats = { + "total": len(items), + "required_count": sum(1 for item in items if item["course_type"] == "required"), + "optional_count": sum(1 for item in items if item["course_type"] == "optional"), + } + + return ResponseModel( + message="获取课程列表成功", + data={ + "items": items, + "stats": stats + } + ) + + +@router.post("/{position_id}/courses") +async def add_position_courses( + position_id: int, + payload: dict, + current_user=Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """批量添加岗位课程""" + # 验证岗位存在 + position = await db.get(Position, position_id) + if not position or position.is_deleted: + return ResponseModel(code=404, message="岗位不存在") + + course_ids = payload.get("course_ids", []) + if not course_ids: + return ResponseModel(code=400, message="请选择要添加的课程") + + course_type = payload.get("course_type", "required") + if course_type not in ["required", "optional"]: + return ResponseModel(code=400, message="课程类型无效") + + # 验证课程存在 + courses = await db.execute( + select(Course).where( + and_( + Course.id.in_(course_ids), + Course.is_deleted == False + ) + ) + ) + valid_courses = {c.id: c for c in courses.scalars().all()} + + if len(valid_courses) != len(course_ids): + invalid_ids = set(course_ids) - set(valid_courses.keys()) + return ResponseModel(code=400, message=f"部分课程不存在: {invalid_ids}") + + # 检查是否已存在 + existing = await db.execute( + select(PositionCourse).where( + and_( + PositionCourse.position_id == position_id, + PositionCourse.course_id.in_(course_ids), + PositionCourse.is_deleted == False + ) + ) + ) + existing_course_ids = {pc.course_id for pc in existing.scalars().all()} + + # 获取当前最大优先级 + max_priority_result = await db.execute( + select(sa.func.max(PositionCourse.priority)).where( + and_( + PositionCourse.position_id == position_id, + PositionCourse.is_deleted == False + ) + ) + ) + max_priority = max_priority_result.scalar() or 0 + + # 添加新课程 + added_count = 0 + for idx, course_id in enumerate(course_ids): + if course_id not in existing_course_ids: + pc = PositionCourse( + position_id=position_id, + course_id=course_id, + course_type=course_type, + priority=max_priority + idx + 1, + ) + db.add(pc) + added_count += 1 + + await db.commit() + + return ResponseModel( + message=f"成功添加 {added_count} 门课程", + data={"added_count": added_count} + ) + + +@router.put("/{position_id}/courses/{pc_id}") +async def update_position_course( + position_id: int, + pc_id: int, + payload: dict, + current_user=Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """更新岗位课程设置""" + # 查找课程关系 + pc = await db.execute( + select(PositionCourse).where( + and_( + PositionCourse.id == pc_id, + PositionCourse.position_id == position_id, + PositionCourse.is_deleted == False + ) + ) + ) + pc = pc.scalar_one_or_none() + + if not pc: + return ResponseModel(code=404, message="课程关系不存在") + + # 更新课程类型 + if "course_type" in payload: + course_type = payload["course_type"] + if course_type not in ["required", "optional"]: + return ResponseModel(code=400, message="课程类型无效") + pc.course_type = course_type + + # 更新优先级 + if "priority" in payload: + pc.priority = payload["priority"] + + # PositionCourse 未继承审计字段,避免写入不存在字段 + await db.commit() + + return ResponseModel(message="更新成功") + + +@router.delete("/{position_id}/courses/{course_id}") +async def remove_position_course( + position_id: int, + course_id: int, + current_user=Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """移除岗位课程""" + # 查找课程关系 + pc = await db.execute( + select(PositionCourse).where( + and_( + PositionCourse.position_id == position_id, + PositionCourse.course_id == course_id, + PositionCourse.is_deleted == False + ) + ) + ) + pc = pc.scalar_one_or_none() + + if not pc: + return ResponseModel(code=404, message="课程关系不存在") + + # 软删除 + pc.is_deleted = True + # PositionCourse 未继承审计字段,避免写入不存在字段 + await db.commit() + + return ResponseModel(message="课程已移除") + + diff --git a/backend/app/api/v1/practice.py b/backend/app/api/v1/practice.py new file mode 100644 index 0000000..b89d737 --- /dev/null +++ b/backend/app/api/v1/practice.py @@ -0,0 +1,1139 @@ +""" +陪练功能API +""" +from typing import Optional +import json +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, Query, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from cozepy import ChatEventType +from cozepy.exception import CozeError, CozeAPIError + +from app.core.deps import get_db, get_current_user +from app.models.user import User +from app.models.practice import PracticeScene, PracticeSession, PracticeDialogue, PracticeReport +from app.schemas.practice import ( + PracticeSceneResponse, + PracticeSceneCreate, + PracticeSceneUpdate, + StartPracticeRequest, + InterruptPracticeRequest, + ConversationsResponse, + ExtractSceneRequest, + ExtractSceneResponse, + ExtractedSceneData, + PracticeSessionCreate, + PracticeSessionResponse, + SaveDialogueRequest, + PracticeDialogueResponse, + PracticeReportResponse, + PracticeAnalysisResult +) +from app.schemas.base import ResponseModel, PaginatedResponse +from app.services.coze_service import get_coze_service, CozeService +from app.services.ai.coze.client import get_auth_manager +from app.services.ai.practice_analysis_service import practice_analysis_service +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/coze-token") +async def get_coze_token( + current_user: User = Depends(get_current_user) +): + """ + 获取Coze OAuth Token用于前端直连WebSocket + + 前端语音对话需要直连Coze WebSocket,但不能暴露私钥, + 因此通过此接口从后端获取临时Token + """ + try: + auth_manager = get_auth_manager() + token = auth_manager.get_oauth_token() + + return ResponseModel( + code=200, + message="Token获取成功", + data={"token": token} + ) + except Exception as e: + logger.error(f"获取Coze Token失败: {e}") + raise HTTPException(status_code=500, detail=f"获取Token失败: {str(e)}") + + +@router.get("/scenes", response_model=ResponseModel[PaginatedResponse[PracticeSceneResponse]]) +async def get_practice_scenes( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(20, ge=1, le=100, description="每页数量"), + type: Optional[str] = Query(None, description="场景类型筛选"), + difficulty: Optional[str] = Query(None, description="难度筛选"), + search: Optional[str] = Query(None, description="关键词搜索(名称、描述)"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取可用陪练场景列表 + + 仅返回status=active且未删除的场景 + 支持分页、筛选和搜索 + """ + # 构建查询 + query = select(PracticeScene).where( + PracticeScene.is_deleted == False, + PracticeScene.status == "active" + ) + + # 类型筛选 + if type: + query = query.where(PracticeScene.type == type) + + # 难度筛选 + if difficulty: + query = query.where(PracticeScene.difficulty == difficulty) + + # 关键词搜索(搜索名称和描述) + if search: + search_pattern = f"%{search}%" + query = query.where( + or_( + PracticeScene.name.like(search_pattern), + PracticeScene.description.like(search_pattern) + ) + ) + + # 查询总数 + count_query = select(func.count()).select_from(query.subquery()) + total = await db.scalar(count_query) + + # 分页查询 + query = query.offset((page - 1) * size).limit(size).order_by(PracticeScene.created_at.desc()) + result = await db.scalars(query) + scenes = list(result.all()) + + logger.info( + f"用户{current_user.id}查询陪练场景列表," + f"类型={type}, 难度={difficulty}, 搜索={search}, " + f"返回{len(scenes)}条记录" + ) + + return ResponseModel( + code=200, + message="success", + data=PaginatedResponse( + items=scenes, + total=total or 0, + page=page, + page_size=size, + pages=(total + size - 1) // size if total else 0 + ) + ) + + +@router.get("/scenes/{scene_id}", response_model=ResponseModel[PracticeSceneResponse]) +async def get_practice_scene_detail( + scene_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取陪练场景详情 + + 返回指定ID的场景完整信息 + """ + # 查询场景 + result = await db.execute( + select(PracticeScene).where( + PracticeScene.id == scene_id, + PracticeScene.is_deleted == False, + PracticeScene.status == "active" + ) + ) + scene = result.scalar_one_or_none() + + if not scene: + logger.warning(f"用户{current_user.id}查询场景{scene_id}不存在或已禁用") + raise HTTPException(status_code=404, detail="场景不存在或已禁用") + + logger.info(f"用户{current_user.id}查询场景{scene_id}详情") + + return ResponseModel(code=200, message="success", data=scene) + + +@router.post("/scenes", response_model=ResponseModel[PracticeSceneResponse]) +async def create_practice_scene( + scene_data: PracticeSceneCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 创建陪练场景 + + 仅管理员和经理可以创建场景 + """ + # 权限检查 + if current_user.role not in ["admin", "manager"]: + raise HTTPException(status_code=403, detail="无权限创建陪练场景") + + # 创建场景 + scene = PracticeScene( + **scene_data.model_dump(), + created_by=current_user.id, + updated_by=current_user.id + ) + + db.add(scene) + await db.commit() + await db.refresh(scene) + + logger.info(f"用户{current_user.id}创建陪练场景: {scene.name} (ID: {scene.id})") + + return ResponseModel( + code=200, + message="场景创建成功", + data=scene + ) + + +@router.put("/scenes/{scene_id}", response_model=ResponseModel[PracticeSceneResponse]) +async def update_practice_scene( + scene_id: int, + scene_data: PracticeSceneUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 更新陪练场景 + + 仅管理员和经理可以更新场景 + """ + # 权限检查 + if current_user.role not in ["admin", "manager"]: + raise HTTPException(status_code=403, detail="无权限更新陪练场景") + + # 查询场景 + result = await db.execute( + select(PracticeScene).where( + PracticeScene.id == scene_id, + PracticeScene.is_deleted == False + ) + ) + scene = result.scalar_one_or_none() + + if not scene: + raise HTTPException(status_code=404, detail="场景不存在") + + # 更新字段 + update_data = scene_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(scene, field, value) + + scene.updated_by = current_user.id + + await db.commit() + await db.refresh(scene) + + logger.info(f"用户{current_user.id}更新陪练场景: {scene.name} (ID: {scene.id})") + + return ResponseModel( + code=200, + message="场景更新成功", + data=scene + ) + + +@router.delete("/scenes/{scene_id}", response_model=ResponseModel) +async def delete_practice_scene( + scene_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 删除陪练场景(软删除) + + 仅管理员和经理可以删除场景 + """ + # 权限检查 + if current_user.role not in ["admin", "manager"]: + raise HTTPException(status_code=403, detail="无权限删除陪练场景") + + # 查询场景 + result = await db.execute( + select(PracticeScene).where( + PracticeScene.id == scene_id, + PracticeScene.is_deleted == False + ) + ) + scene = result.scalar_one_or_none() + + if not scene: + raise HTTPException(status_code=404, detail="场景不存在") + + # 软删除 + scene.is_deleted = True + scene.updated_by = current_user.id + + await db.commit() + + logger.info(f"用户{current_user.id}删除陪练场景: {scene.name} (ID: {scene.id})") + + return ResponseModel( + code=200, + message="场景删除成功", + data={"scene_id": scene_id} + ) + + +@router.post("/start") +async def start_practice( + request: StartPracticeRequest, + current_user: User = Depends(get_current_user), + coze_service: CozeService = Depends(get_coze_service) +): + """ + 开始陪练对话(SSE流式返回) + + ⚠️ 核心功能: + - 首次消息(is_first=true):构建完整场景提示词发送给Coze + - 后续消息(is_first=false):仅发送用户消息 + - 使用conversation_id保持对话上下文 + """ + logger.info( + f"用户{current_user.id}开始陪练对话," + f"场景={request.scene_name}, " + f"is_first={request.is_first}, " + f"conversation_id={request.conversation_id}" + ) + + # 构建发送给Coze的消息 + if request.is_first: + # 首次消息:构建完整场景提示词 + message = coze_service.build_scene_prompt( + scene_name=request.scene_name, + scene_background=request.scene_background, + scene_ai_role=request.scene_ai_role, + scene_objectives=request.scene_objectives, + scene_keywords=request.scene_keywords, + scene_description=request.scene_description, + user_message=request.user_message + ) + logger.debug(f"场景提示词已构建,长度={len(message)}字符") + else: + # 后续消息:仅发送用户输入 + message = request.user_message + logger.debug(f"用户消息: {message}") + + def generate_stream(): + """SSE流式生成器""" + try: + # 创建Coze流式对话 + stream = coze_service.create_stream_chat( + user_id=str(current_user.id), + message=message, + conversation_id=request.conversation_id + ) + + # 处理Coze事件流 + for event in stream: + # 对话创建事件 + if event.event == ChatEventType.CONVERSATION_CHAT_CREATED: + # 优先使用请求中的conversation_id(续接对话) + # 如果没有,使用Coze返回的新对话ID(首次对话) + final_conversation_id = request.conversation_id or event.chat.conversation_id + event_data = { + "conversation_id": final_conversation_id, + "chat_id": event.chat.id + } + yield f"event: conversation.chat.created\ndata: {json.dumps(event_data)}\n\n" + logger.debug(f"对话已创建/续接: conversation_id={final_conversation_id}, 来源={'请求参数' if request.conversation_id else 'Coze创建'}") + + # 消息增量事件(实时打字效果) + elif event.event == ChatEventType.CONVERSATION_MESSAGE_DELTA: + event_data = {"content": event.message.content} + yield f"event: message.delta\ndata: {json.dumps(event_data)}\n\n" + + # 消息完成事件 + elif event.event == ChatEventType.CONVERSATION_MESSAGE_COMPLETED: + event_data = {} # 不需要返回完整内容,前端已通过delta累积 + yield f"event: message.completed\ndata: {json.dumps(event_data)}\n\n" + logger.info(f"消息已完成") + + # 对话完成事件 + elif event.event == ChatEventType.CONVERSATION_CHAT_COMPLETED: + # 安全地获取token用量 + token_count = 0 + input_count = 0 + output_count = 0 + if hasattr(event.chat, 'usage') and event.chat.usage: + token_count = getattr(event.chat.usage, 'token_count', 0) + input_count = getattr(event.chat.usage, 'input_count', 0) + output_count = getattr(event.chat.usage, 'output_count', 0) + + event_data = { + "token_count": token_count, + "input_count": input_count, + "output_count": output_count + } + yield f"event: conversation.completed\ndata: {json.dumps(event_data)}\n\n" + logger.info(f"对话已完成,Token用量={event_data['token_count']}") + break + + # 对话失败事件 + elif event.event == ChatEventType.CONVERSATION_CHAT_FAILED: + error_msg = str(event.chat.last_error) if event.chat.last_error else "对话失败" + event_data = {"error": error_msg} + yield f"event: error\ndata: {json.dumps(event_data)}\n\n" + logger.error(f"对话失败: {error_msg}") + break + + # 发送结束标记 + yield f"event: done\ndata: [DONE]\n\n" + logger.info(f"SSE流结束") + + except (CozeError, CozeAPIError) as e: + logger.error(f"Coze API错误: {e}", exc_info=True) + error_data = {"error": f"对话失败: {str(e)}"} + yield f"event: error\ndata: {json.dumps(error_data)}\n\n" + except Exception as e: + logger.error(f"陪练对话异常: {e}", exc_info=True) + error_data = {"error": f"系统错误: {str(e)}"} + yield f"event: error\ndata: {json.dumps(error_data)}\n\n" + + # 返回SSE流式响应 + return StreamingResponse( + generate_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" # 禁用Nginx缓冲 + } + ) + + +@router.post("/interrupt", response_model=ResponseModel) +async def interrupt_practice( + request: InterruptPracticeRequest, + current_user: User = Depends(get_current_user), + coze_service: CozeService = Depends(get_coze_service) +): + """ + 中断陪练对话 + + 调用Coze API中断当前进行中的对话 + """ + logger.info( + f"用户{current_user.id}中断对话," + f"conversation_id={request.conversation_id}, " + f"chat_id={request.chat_id}" + ) + + try: + result = coze_service.cancel_chat( + conversation_id=request.conversation_id, + chat_id=request.chat_id + ) + + return ResponseModel( + code=200, + message="对话已中断", + data={ + "conversation_id": request.conversation_id, + "chat_id": request.chat_id + } + ) + except (CozeError, CozeAPIError) as e: + logger.error(f"中断对话失败: {e}") + raise HTTPException(status_code=500, detail=f"中断对话失败: {str(e)}") + except Exception as e: + logger.error(f"中断对话异常: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"系统错误: {str(e)}") + + +@router.post("/conversation/create", response_model=ResponseModel) +async def create_practice_conversation( + current_user: User = Depends(get_current_user), + coze_service: CozeService = Depends(get_coze_service) +): + """ + 创建新的陪练对话 + + ⚠️ 关键:必须先创建conversation,然后才能续接对话 + 返回conversation_id供后续对话使用 + """ + try: + # 调用Coze API创建对话 + conversation = coze_service.client.conversations.create() + + conversation_id = conversation.id + + logger.info(f"用户{current_user.id}创建陪练对话,conversation_id={conversation_id}") + + return ResponseModel( + code=200, + message="对话创建成功", + data={"conversation_id": conversation_id} + ) + except (CozeError, CozeAPIError) as e: + logger.error(f"创建对话失败: {e}") + raise HTTPException(status_code=500, detail=f"创建对话失败: {str(e)}") + except Exception as e: + logger.error(f"创建对话异常: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"系统错误: {str(e)}") + + +@router.get("/conversations", response_model=ResponseModel[ConversationsResponse]) +async def get_conversations( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(20, ge=1, le=100, description="每页数量"), + current_user: User = Depends(get_current_user) +): + """ + 获取对话列表 + + 查询用户在Coze平台上的对话历史 + + 注意:语音陪练使用前端直连Coze WebSocket,不经过后端中转 + """ + # TODO: 实现对话列表查询 + # 将在阶段四实现 + logger.info(f"用户{current_user.id}查询对话列表") + raise HTTPException(status_code=501, detail="对话列表功能正在开发中") + + +@router.post("/extract-scene", response_model=ResponseModel[ExtractSceneResponse]) +async def extract_scene( + request: ExtractSceneRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 从课程提取陪练场景 + + 使用 Python 原生 AI 服务实现,直接调用 AI API 生成场景。 + + 流程: + 1. 验证课程是否存在 + 2. 获取课程知识点 + 3. 调用 AI 生成陪练场景 + 4. 解析并返回场景数据 + """ + from app.models.course import Course + from app.services.ai import practice_scene_service + + # 验证课程存在 + course = await db.get(Course, request.course_id) + if not course: + logger.warning(f"课程不存在: course_id={request.course_id}") + raise HTTPException(status_code=404, detail="课程不存在") + + logger.info(f"用户{current_user.id}开始提取课程{request.course_id}的陪练场景") + + # 调用 Python 原生服务 + result = await practice_scene_service.prepare_practice_knowledge( + db=db, + course_id=request.course_id + ) + + if not result.success: + # 根据错误类型返回适当的 HTTP 状态码 + if "没有可用的知识点" in result.error or "没有知识点" in result.error: + raise HTTPException( + status_code=400, + detail="该课程尚未添加知识点,无法生成陪练场景。请先在课程管理中上传资料并分析知识点。" + ) + raise HTTPException(status_code=500, detail=f"场景提取失败: {result.error}") + + # 将 PracticeScene 转换为 ExtractedSceneData + scene = result.scene + scene_data = ExtractedSceneData( + name=scene.name, + description=scene.description, + type=scene.type, + difficulty=scene.difficulty, + background=scene.background, + ai_role=scene.ai_role, + objectives=scene.objectives, + keywords=scene.keywords + ) + + logger.info( + f"场景提取成功: {scene.name}, course_id={request.course_id}, " + f"provider={result.ai_provider}, tokens={result.ai_tokens}" + ) + + return ResponseModel( + code=200, + message="场景提取成功", + data=ExtractSceneResponse( + scene=scene_data, + workflow_run_id=f"{result.ai_provider}_{result.ai_latency_ms}ms", + task_id=f"native_{request.course_id}" + ) + ) + + +# ==================== 陪练会话管理API ==================== + +@router.post("/sessions/create", response_model=ResponseModel[PracticeSessionResponse]) +async def create_practice_session( + request: PracticeSessionCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 创建陪练会话 + + 用户开始陪练时调用,创建session记录 + """ + try: + # 生成session_id(格式:PS + 时间戳后6位) + session_id = f"PS{str(int(datetime.now().timestamp() * 1000))[-6:]}" + + # 创建session记录 + session = PracticeSession( + session_id=session_id, + user_id=current_user.id, + scene_id=request.scene_id, + scene_name=request.scene_name, + scene_type=request.scene_type, + conversation_id=request.conversation_id, + start_time=datetime.now(), + status="in_progress" + ) + + db.add(session) + await db.commit() + await db.refresh(session) + + logger.info(f"创建陪练会话: session_id={session_id}, user_id={current_user.id}, scene={request.scene_name}") + + return ResponseModel( + code=200, + message="会话创建成功", + data=session + ) + + except Exception as e: + logger.error(f"创建会话失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"创建会话失败: {str(e)}") + + +@router.post("/dialogues/save", response_model=ResponseModel) +async def save_dialogue( + request: SaveDialogueRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 保存对话记录 + + 每一条对话(用户或AI)都实时保存 + """ + try: + # 创建对话记录 + dialogue = PracticeDialogue( + session_id=request.session_id, + speaker=request.speaker, + content=request.content, + timestamp=datetime.now(), + sequence=request.sequence + ) + + db.add(dialogue) + await db.commit() + + logger.debug(f"保存对话: session_id={request.session_id}, speaker={request.speaker}, seq={request.sequence}") + + return ResponseModel( + code=200, + message="对话保存成功", + data={"session_id": request.session_id, "sequence": request.sequence} + ) + + except Exception as e: + logger.error(f"保存对话失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"保存对话失败: {str(e)}") + + +@router.post("/sessions/{session_id}/end", response_model=ResponseModel[PracticeSessionResponse]) +async def end_practice_session( + session_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 结束陪练会话 + + 用户结束陪练时调用,更新会话状态和时长 + """ + try: + # 查询会话 + result = await db.execute( + select(PracticeSession).where( + PracticeSession.session_id == session_id, + PracticeSession.user_id == current_user.id, + PracticeSession.is_deleted == False + ) + ) + session = result.scalar_one_or_none() + + if not session: + raise HTTPException(status_code=404, detail="会话不存在") + + # 查询对话数量 + result = await db.execute( + select(func.count(PracticeDialogue.id)).where( + PracticeDialogue.session_id == session_id + ) + ) + dialogue_count = result.scalar() or 0 + + # 更新会话状态 + session.end_time = datetime.now() + session.duration_seconds = int((session.end_time - session.start_time).total_seconds()) + session.turns = dialogue_count + session.status = "completed" + + await db.commit() + await db.refresh(session) + + logger.info(f"结束陪练会话: session_id={session_id}, 时长={session.duration_seconds}秒, 轮次={session.turns}") + + return ResponseModel( + code=200, + message="会话已结束", + data=session + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"结束会话失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"结束会话失败: {str(e)}") + + +@router.post("/sessions/{session_id}/analyze", response_model=ResponseModel) +async def analyze_practice_session( + session_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 生成陪练分析报告 + + 使用 Python 原生 AI 服务实现。 + """ + try: + # 1. 查询会话信息 + result = await db.execute( + select(PracticeSession).where( + PracticeSession.session_id == session_id, + PracticeSession.user_id == current_user.id, + PracticeSession.is_deleted == False + ) + ) + session = result.scalar_one_or_none() + + if not session: + raise HTTPException(status_code=404, detail="会话不存在") + + # 2. 查询对话历史 + result = await db.execute( + select(PracticeDialogue).where( + PracticeDialogue.session_id == session_id + ).order_by(PracticeDialogue.sequence) + ) + dialogues = result.scalars().all() + + if not dialogues or len(dialogues) < 2: + raise HTTPException(status_code=400, detail="对话数量太少,无法生成分析报告") + + # 3. 构建对话历史数据 + dialogue_history = [ + { + "speaker": d.speaker, + "content": d.content, + "timestamp": d.timestamp.isoformat() + } + for d in dialogues + ] + + logger.info(f"开始分析陪练会话: session_id={session_id}, 对话数={len(dialogue_history)}") + + # 调用 Python 原生陪练分析服务 + v2_result = await practice_analysis_service.analyze(dialogue_history, db=db) + + if not v2_result.success: + raise HTTPException(status_code=500, detail=f"分析失败: {v2_result.error}") + + analysis_data = v2_result.to_dict() + + logger.info( + f"陪练分析完成 - total_score: {v2_result.total_score}, " + f"provider: {v2_result.ai_provider}, latency: {v2_result.ai_latency_ms}ms" + ) + + # 解析分析结果 + analysis_result = analysis_data.get("analysis", {}) + + # 保存分析报告 + report = PracticeReport( + session_id=session_id, + total_score=analysis_result.get("total_score"), + score_breakdown=analysis_result.get("score_breakdown"), + ability_dimensions=analysis_result.get("ability_dimensions"), + dialogue_review=analysis_result.get("dialogue_annotations"), + suggestions=analysis_result.get("suggestions"), + workflow_run_id=f"{v2_result.ai_provider}_{v2_result.ai_latency_ms}ms", + task_id=None + ) + + db.add(report) + await db.commit() + + logger.info(f"分析报告已保存: session_id={session_id}, total_score={report.total_score}") + + return ResponseModel( + code=200, + message="分析报告生成成功", + data={ + "session_id": session_id, + "total_score": report.total_score, + "workflow_run_id": report.workflow_run_id + } + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"生成分析报告失败: {e}, session_id={session_id}", exc_info=True) + raise HTTPException(status_code=500, detail=f"生成分析报告失败: {str(e)}") + + +@router.get("/reports/{session_id}", response_model=ResponseModel[PracticeReportResponse]) +async def get_practice_report( + session_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取陪练分析报告详情 + + 合并数据库对话记录和AI标注,生成完整的对话复盘 + """ + try: + # 1. 查询会话信息 + result = await db.execute( + select(PracticeSession).where( + PracticeSession.session_id == session_id, + PracticeSession.user_id == current_user.id, + PracticeSession.is_deleted == False + ) + ) + session = result.scalar_one_or_none() + + if not session: + raise HTTPException(status_code=404, detail="会话不存在") + + # 2. 查询分析报告 + result = await db.execute( + select(PracticeReport).where( + PracticeReport.session_id == session_id + ) + ) + report = result.scalar_one_or_none() + + if not report: + raise HTTPException(status_code=404, detail="分析报告不存在,请先生成报告") + + # 3. 查询完整对话记录(从数据库) + result = await db.execute( + select(PracticeDialogue).where( + PracticeDialogue.session_id == session_id + ).order_by(PracticeDialogue.sequence) + ) + dialogues = result.scalars().all() + + # 4. 合并对话记录和AI标注 + # dialogue_review字段存储的是标注信息(包含sequence, tags, comment) + ai_annotations = report.dialogue_review or [] + + # 创建标注映射(sequence -> {tags, comment}) + annotations_map = {} + for annotation in ai_annotations: + seq = annotation.get('sequence') + if seq: + annotations_map[seq] = { + 'tags': annotation.get('tags', []), + 'comment': annotation.get('comment', '') + } + + # 构建完整对话复盘(数据库对话 + AI标注) + dialogue_review = [] + for dialogue in dialogues: + # 计算时间(从会话开始时间算起) + time_offset = int((dialogue.timestamp - session.start_time).total_seconds()) + time_str = f"{time_offset // 60:02d}:{time_offset % 60:02d}" + + # 获取标注 + annotation = annotations_map.get(dialogue.sequence, {}) + + dialogue_review.append({ + "speaker": "顾问" if dialogue.speaker == "user" else "客户", + "time": time_str, + "content": dialogue.content, + "tags": annotation.get('tags', []), + "comment": annotation.get('comment', '') + }) + + # 5. 构建响应数据 + # 5.1 处理score_breakdown字段(兼容字典和列表格式) + score_breakdown_data = report.score_breakdown or [] + if isinstance(score_breakdown_data, str): + try: + score_breakdown_data = json.loads(score_breakdown_data) + except json.JSONDecodeError: + logger.warning(f"无法解析score_breakdown JSON: {score_breakdown_data}") + score_breakdown_data = [] + + # 如果是字典格式,转换为列表格式 + if isinstance(score_breakdown_data, dict): + score_breakdown_data = [ + {"name": k, "score": int(v), "description": ""} + for k, v in score_breakdown_data.items() + ] + + # 5.2 处理ability_dimensions字段(兼容字典和列表格式) + ability_dimensions_data = report.ability_dimensions or [] + if isinstance(ability_dimensions_data, str): + try: + ability_dimensions_data = json.loads(ability_dimensions_data) + except json.JSONDecodeError: + logger.warning(f"无法解析ability_dimensions JSON: {ability_dimensions_data}") + ability_dimensions_data = [] + + # 如果是字典格式,转换为列表格式 + if isinstance(ability_dimensions_data, dict): + ability_dimensions_data = [ + {"name": k, "score": int(v), "feedback": ""} + for k, v in ability_dimensions_data.items() + ] + + # 5.3 处理suggestions字段 + suggestions_data = report.suggestions or [] + if isinstance(suggestions_data, str): + try: + suggestions_data = json.loads(suggestions_data) + except json.JSONDecodeError: + logger.warning(f"无法解析suggestions JSON: {suggestions_data}") + suggestions_data = [] + + analysis = PracticeAnalysisResult( + total_score=report.total_score, + score_breakdown=score_breakdown_data, + ability_dimensions=ability_dimensions_data, + dialogue_review=dialogue_review, # 使用合并后的对话 + suggestions=suggestions_data + ) + + response_data = PracticeReportResponse( + session_info=session, + analysis=analysis + ) + + logger.info(f"获取分析报告: session_id={session_id}, total_score={report.total_score}, 对话数={len(dialogue_review)}") + + return ResponseModel( + code=200, + message="success", + data=response_data + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取报告失败: {e}, session_id={session_id}", exc_info=True) + raise HTTPException(status_code=500, detail=f"获取报告失败: {str(e)}") + + +# ==================== 陪练记录查询API ==================== + +@router.get("/sessions/list", response_model=ResponseModel[PaginatedResponse]) +async def get_practice_sessions_list( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(20, ge=1, le=100, description="每页数量"), + keyword: Optional[str] = Query(None, description="关键词搜索"), + scene_type: Optional[str] = Query(None, description="场景类型"), + start_date: Optional[str] = Query(None, description="开始日期"), + end_date: Optional[str] = Query(None, description="结束日期"), + min_score: Optional[int] = Query(None, ge=0, le=100, description="最低分数"), + max_score: Optional[int] = Query(None, ge=0, le=100, description="最高分数"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取陪练记录列表 + + 支持关键词搜索、场景筛选、时间范围筛选、分数筛选 + """ + try: + # 构建查询(关联practice_reports表获取分数) + query = select( + PracticeSession, + PracticeReport.total_score + ).outerjoin( + PracticeReport, + PracticeSession.session_id == PracticeReport.session_id + ).where( + PracticeSession.user_id == current_user.id, + PracticeSession.is_deleted == False, + PracticeSession.status == "completed" # 只查询已完成的会话 + ) + + # 关键词搜索 + if keyword: + query = query.where( + or_( + PracticeSession.scene_name.contains(keyword), + PracticeSession.session_id.contains(keyword) + ) + ) + + # 场景类型筛选 + if scene_type: + query = query.where(PracticeSession.scene_type == scene_type) + + # 时间范围筛选 + if start_date: + query = query.where(PracticeSession.start_time >= start_date) + if end_date: + query = query.where(PracticeSession.start_time <= end_date) + + # 分数筛选 + if min_score is not None: + query = query.where(PracticeReport.total_score >= min_score) + if max_score is not None: + query = query.where(PracticeReport.total_score <= max_score) + + # 按开始时间倒序排列 + query = query.order_by(PracticeSession.start_time.desc()) + + # 计算总数 + count_query = select(func.count()).select_from(query.subquery()) + total = await db.scalar(count_query) or 0 + + # 分页查询 + results = await db.execute( + query.offset((page - 1) * size).limit(size) + ) + + # 构建响应数据 + items = [] + for session, total_score in results: + # 计算result等级 + result_level = "needs_improvement" + if total_score: + if total_score >= 90: + result_level = "excellent" + elif total_score >= 80: + result_level = "good" + elif total_score >= 70: + result_level = "average" + + items.append({ + "session_id": session.session_id, + "scene_name": session.scene_name, + "scene_type": session.scene_type, + "start_time": session.start_time, + "duration_seconds": session.duration_seconds, + "turns": session.turns, + "total_score": total_score, + "result": result_level + }) + + logger.info(f"查询陪练记录: user_id={current_user.id}, 返回{len(items)}条记录") + + return ResponseModel( + code=200, + message="success", + data=PaginatedResponse( + items=items, + total=total, + page=page, + page_size=size, + pages=(total + size - 1) // size + ) + ) + + except Exception as e: + logger.error(f"查询陪练记录失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}") + + +@router.get("/stats", response_model=ResponseModel) +async def get_practice_stats( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取陪练统计数据 + + 返回:总次数、平均分、总时长、本月进步 + """ + try: + # 查询总次数和总时长 + result = await db.execute( + select( + func.count(PracticeSession.id).label('total_count'), + func.sum(PracticeSession.duration_seconds).label('total_duration') + ).where( + PracticeSession.user_id == current_user.id, + PracticeSession.is_deleted == False, + PracticeSession.status == "completed" + ) + ) + stats = result.first() + + total_count = stats.total_count or 0 + total_duration = stats.total_duration or 0 + total_duration_hours = round(total_duration / 3600, 1) + + # 查询平均分 + result = await db.execute( + select(func.avg(PracticeReport.total_score)).where( + PracticeReport.session_id.in_( + select(PracticeSession.session_id).where( + PracticeSession.user_id == current_user.id, + PracticeSession.is_deleted == False + ) + ) + ) + ) + avg_score = result.scalar() or 0 + avg_score = round(float(avg_score), 1) if avg_score else 0 + + # 计算本月进步(简化:与上月平均分对比) + # TODO: 实现真实的月度对比逻辑 + month_improvement = 15 # 暂时使用固定值 + + logger.info(f"查询陪练统计: user_id={current_user.id}, total={total_count}, avg={avg_score}") + + return ResponseModel( + code=200, + message="success", + data={ + "total_count": total_count, + "avg_score": avg_score, + "total_duration_hours": total_duration_hours, + "month_improvement": month_improvement + } + ) + + except Exception as e: + logger.error(f"查询统计数据失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}") diff --git a/backend/app/api/v1/preview.py b/backend/app/api/v1/preview.py new file mode 100644 index 0000000..0287951 --- /dev/null +++ b/backend/app/api/v1/preview.py @@ -0,0 +1,285 @@ +""" +文件预览API +提供课程资料的在线预览功能 +""" +import logging +from pathlib import Path +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.deps import get_db, get_current_user +from app.schemas.base import ResponseModel +from app.core.config import settings +from app.models.user import User +from app.models.course import CourseMaterial +from app.services.document_converter import document_converter + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class PreviewType: + """预览类型常量 + 支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties + """ + PDF = "pdf" + TEXT = "text" + HTML = "html" + EXCEL_HTML = "excel_html" # Excel转HTML预览 + VIDEO = "video" + AUDIO = "audio" + IMAGE = "image" + DOWNLOAD = "download" + + +# 文件类型到预览类型的映射 +FILE_TYPE_MAPPING = { + # PDF - 直接预览 + '.pdf': PreviewType.PDF, + + # 文本 - 直接显示内容 + '.txt': PreviewType.TEXT, + '.md': PreviewType.TEXT, + '.mdx': PreviewType.TEXT, + '.csv': PreviewType.TEXT, + '.vtt': PreviewType.TEXT, + '.properties': PreviewType.TEXT, + + # HTML - 在iframe中预览 + '.html': PreviewType.HTML, + '.htm': PreviewType.HTML, +} + + +def get_preview_type(file_ext: str) -> str: + """ + 根据文件扩展名获取预览类型 + + Args: + file_ext: 文件扩展名(带点,如 .pdf) + + Returns: + 预览类型 + """ + file_ext_lower = file_ext.lower() + + # 直接映射的类型 + if file_ext_lower in FILE_TYPE_MAPPING: + return FILE_TYPE_MAPPING[file_ext_lower] + + # Excel文件使用HTML预览(避免分页问题) + if file_ext_lower in {'.xlsx', '.xls'}: + return PreviewType.EXCEL_HTML + + # 其他Office文档,需要转换为PDF预览 + if document_converter.is_convertible(file_ext_lower): + return PreviewType.PDF + + # 其他类型,只提供下载 + return PreviewType.DOWNLOAD + + +def get_file_path_from_url(file_url: str) -> Optional[Path]: + """ + 从文件URL获取本地文件路径 + + Args: + file_url: 文件URL(如 /static/uploads/courses/1/xxx.pdf) + + Returns: + 本地文件路径,如果无效返回None + """ + try: + # 移除 /static/uploads/ 前缀 + if file_url.startswith('/static/uploads/'): + relative_path = file_url.replace('/static/uploads/', '') + full_path = Path(settings.UPLOAD_PATH) / relative_path + return full_path + return None + except Exception: + return None + + +@router.get("/material/{material_id}", response_model=ResponseModel[dict]) +async def get_material_preview( + material_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取资料预览信息 + + Args: + material_id: 资料ID + + Returns: + 预览信息,包括预览类型、预览URL等 + """ + try: + # 查询资料信息 + stmt = select(CourseMaterial).where( + CourseMaterial.id == material_id, + CourseMaterial.is_deleted == False + ) + result = await db.execute(stmt) + material = result.scalar_one_or_none() + + if not material: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="资料不存在" + ) + + # TODO: 权限检查 - 确认当前用户是否有权访问该课程的资料 + # 可以通过查询 position_courses 表和用户的岗位关系来判断 + + # 获取文件扩展名 + file_ext = Path(material.name).suffix.lower() + + # 确定预览类型 + preview_type = get_preview_type(file_ext) + + logger.info( + f"资料预览请求 - material_id: {material_id}, " + f"file_type: {file_ext}, preview_type: {preview_type}, " + f"user_id: {current_user.id}" + ) + + # 构建响应数据 + response_data = { + "preview_type": preview_type, + "file_name": material.name, + "original_url": material.file_url, + "file_size": material.file_size, + } + + # 根据预览类型处理 + if preview_type == PreviewType.TEXT: + # 文本类型,读取文件内容 + file_path = get_file_path_from_url(material.file_url) + if file_path and file_path.exists(): + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + response_data["content"] = content + response_data["preview_url"] = None + except Exception as e: + logger.error(f"读取文本文件失败: {str(e)}") + # 读取失败,改为下载模式 + response_data["preview_type"] = PreviewType.DOWNLOAD + response_data["preview_url"] = material.file_url + else: + response_data["preview_type"] = PreviewType.DOWNLOAD + response_data["preview_url"] = material.file_url + + elif preview_type == PreviewType.EXCEL_HTML: + # Excel文件转换为HTML预览 + file_path = get_file_path_from_url(material.file_url) + if file_path and file_path.exists(): + converted_url = document_converter.convert_excel_to_html( + str(file_path), + material.course_id, + material.id + ) + if converted_url: + response_data["preview_url"] = converted_url + response_data["preview_type"] = "html" # 前端使用html类型渲染 + response_data["is_converted"] = True + else: + logger.warning(f"Excel转HTML失败,改为下载模式 - material_id: {material_id}") + response_data["preview_type"] = PreviewType.DOWNLOAD + response_data["preview_url"] = material.file_url + response_data["is_converted"] = False + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="文件不存在" + ) + + elif preview_type == PreviewType.PDF and document_converter.is_convertible(file_ext): + # Office文档,需要转换为PDF + file_path = get_file_path_from_url(material.file_url) + if file_path and file_path.exists(): + # 执行转换 + converted_url = document_converter.convert_to_pdf( + str(file_path), + material.course_id, + material.id + ) + if converted_url: + response_data["preview_url"] = converted_url + response_data["is_converted"] = True + else: + # 转换失败,改为下载模式 + logger.warning(f"文档转换失败,改为下载模式 - material_id: {material_id}") + response_data["preview_type"] = PreviewType.DOWNLOAD + response_data["preview_url"] = material.file_url + response_data["is_converted"] = False + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="文件不存在" + ) + + else: + # 其他类型,直接返回原始URL + response_data["preview_url"] = material.file_url + + return ResponseModel(data=response_data, message="获取预览信息成功") + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取资料预览信息失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="获取预览信息失败" + ) + + +@router.get("/check-converter", response_model=ResponseModel[dict]) +async def check_converter_status( + current_user: User = Depends(get_current_user), +): + """ + 检查文档转换服务状态(用于调试) + + Returns: + 转换服务状态信息 + """ + try: + import subprocess + + # 检查 LibreOffice 是否安装 + try: + result = subprocess.run( + ['libreoffice', '--version'], + capture_output=True, + text=True, + timeout=5 + ) + libreoffice_installed = result.returncode == 0 + libreoffice_version = result.stdout.strip() if libreoffice_installed else None + except Exception: + libreoffice_installed = False + libreoffice_version = None + + return ResponseModel( + data={ + "libreoffice_installed": libreoffice_installed, + "libreoffice_version": libreoffice_version, + "supported_formats": list(document_converter.SUPPORTED_FORMATS), + "converted_path": str(document_converter.converted_path), + }, + message="转换服务状态检查完成" + ) + + except Exception as e: + logger.error(f"检查转换服务状态失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="检查转换服务状态失败" + ) + diff --git a/backend/app/api/v1/scrm.py b/backend/app/api/v1/scrm.py new file mode 100644 index 0000000..073cfae --- /dev/null +++ b/backend/app/api/v1/scrm.py @@ -0,0 +1,311 @@ +""" +SCRM 系统对接 API 路由 + +提供给 SCRM 系统调用的数据查询接口 +认证方式:Bearer Token (SCRM_API_KEY) +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db, verify_scrm_api_key +from app.services.scrm_service import SCRMService +from app.schemas.scrm import ( + EmployeePositionResponse, + EmployeePositionData, + PositionCoursesResponse, + PositionCoursesData, + KnowledgePointSearchRequest, + KnowledgePointSearchResponse, + KnowledgePointSearchData, + KnowledgePointDetailResponse, + KnowledgePointDetailData, + SCRMErrorResponse, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/scrm", tags=["scrm"]) + + +# ==================== 1. 获取员工岗位 ==================== + +@router.get( + "/employees/{userid}/position", + response_model=EmployeePositionResponse, + summary="获取员工岗位(通过userid)", + description="根据企微 userid 查询员工在考陪练系统中的岗位信息", + responses={ + 200: {"model": EmployeePositionResponse, "description": "成功"}, + 401: {"model": SCRMErrorResponse, "description": "认证失败"}, + 404: {"model": SCRMErrorResponse, "description": "员工不存在"}, + } +) +async def get_employee_position_by_userid( + userid: str, + _: bool = Depends(verify_scrm_api_key), + db: AsyncSession = Depends(get_db) +): + """ + 获取员工岗位(通过企微userid) + + - **userid**: 企微员工 userid + """ + service = SCRMService(db) + result = await service.get_employee_position(userid=userid) + + if result is None: + raise HTTPException( + status_code=404, + detail={ + "code": 404, + "message": "员工不存在", + "data": None + } + ) + + # 检查是否有多个匹配结果 + if result.get("multiple_matches"): + return { + "code": 0, + "message": f"找到 {result['count']} 个匹配的员工,请确认", + "data": result + } + + return EmployeePositionResponse( + code=0, + message="success", + data=EmployeePositionData(**result) + ) + + +@router.get( + "/employees/search/by-name", + summary="获取员工岗位(通过姓名搜索)", + description="根据员工姓名查询员工在考陪练系统中的岗位信息,支持精确匹配和模糊匹配", + responses={ + 200: {"description": "成功"}, + 401: {"model": SCRMErrorResponse, "description": "认证失败"}, + 404: {"model": SCRMErrorResponse, "description": "员工不存在"}, + } +) +async def get_employee_position_by_name( + name: str = Query(..., description="员工姓名,支持精确匹配和模糊匹配"), + _: bool = Depends(verify_scrm_api_key), + db: AsyncSession = Depends(get_db) +): + """ + 获取员工岗位(通过姓名搜索) + + - **name**: 员工姓名(必填),优先精确匹配,无结果时模糊匹配 + + 注意:如果有多个同名员工,会返回员工列表供确认 + """ + service = SCRMService(db) + result = await service.get_employee_position(name=name) + + if result is None: + raise HTTPException( + status_code=404, + detail={ + "code": 404, + "message": f"未找到姓名包含 '{name}' 的员工", + "data": None + } + ) + + # 检查是否有多个匹配结果 + if result.get("multiple_matches"): + return { + "code": 0, + "message": f"找到 {result['count']} 个匹配的员工,请确认后使用 employee_id 精确查询", + "data": result + } + + return EmployeePositionResponse( + code=0, + message="success", + data=EmployeePositionData(**result) + ) + + +@router.get( + "/employees/by-id/{employee_id}/position", + response_model=EmployeePositionResponse, + summary="获取员工岗位(通过员工ID)", + description="根据员工ID精确查询员工岗位信息,用于多个同名员工时的精确查询", + responses={ + 200: {"model": EmployeePositionResponse, "description": "成功"}, + 401: {"model": SCRMErrorResponse, "description": "认证失败"}, + 404: {"model": SCRMErrorResponse, "description": "员工不存在"}, + } +) +async def get_employee_position_by_id( + employee_id: int, + _: bool = Depends(verify_scrm_api_key), + db: AsyncSession = Depends(get_db) +): + """ + 获取员工岗位(通过员工ID精确查询) + + - **employee_id**: 员工ID(考陪练系统用户ID) + + 适用场景:通过姓名搜索返回多个匹配结果后,使用此接口精确查询 + """ + service = SCRMService(db) + result = await service.get_employee_position_by_id(employee_id) + + if result is None: + raise HTTPException( + status_code=404, + detail={ + "code": 404, + "message": "员工不存在", + "data": None + } + ) + + return EmployeePositionResponse( + code=0, + message="success", + data=EmployeePositionData(**result) + ) + + +# ==================== 2. 获取岗位课程列表 ==================== + +@router.get( + "/positions/{position_id}/courses", + response_model=PositionCoursesResponse, + summary="获取岗位课程列表", + description="获取指定岗位的必修/选修课程列表", + responses={ + 200: {"model": PositionCoursesResponse, "description": "成功"}, + 401: {"model": SCRMErrorResponse, "description": "认证失败"}, + 404: {"model": SCRMErrorResponse, "description": "岗位不存在"}, + } +) +async def get_position_courses( + position_id: int, + course_type: Optional[str] = Query( + default="all", + description="课程类型:required/optional/all", + regex="^(required|optional|all)$" + ), + _: bool = Depends(verify_scrm_api_key), + db: AsyncSession = Depends(get_db) +): + """ + 获取岗位课程列表 + + - **position_id**: 岗位ID + - **course_type**: 课程类型筛选(required/optional/all,默认 all) + """ + service = SCRMService(db) + result = await service.get_position_courses(position_id, course_type) + + if result is None: + raise HTTPException( + status_code=404, + detail={ + "code": 40002, + "message": "position_id 不存在", + "data": None + } + ) + + return PositionCoursesResponse( + code=0, + message="success", + data=PositionCoursesData(**result) + ) + + +# ==================== 3. 搜索知识点 ==================== + +@router.post( + "/knowledge-points/search", + response_model=KnowledgePointSearchResponse, + summary="搜索知识点", + description="根据关键词和岗位搜索匹配的知识点", + responses={ + 200: {"model": KnowledgePointSearchResponse, "description": "成功"}, + 401: {"model": SCRMErrorResponse, "description": "认证失败"}, + 400: {"model": SCRMErrorResponse, "description": "请求参数错误"}, + } +) +async def search_knowledge_points( + request: KnowledgePointSearchRequest, + _: bool = Depends(verify_scrm_api_key), + db: AsyncSession = Depends(get_db) +): + """ + 搜索知识点 + + - **keywords**: 搜索关键词列表(必填) + - **position_id**: 岗位ID(用于优先排序,可选) + - **course_ids**: 限定课程范围(可选) + - **knowledge_type**: 知识点类型筛选(可选) + - **limit**: 返回数量,默认10,最大100 + """ + service = SCRMService(db) + result = await service.search_knowledge_points( + keywords=request.keywords, + position_id=request.position_id, + course_ids=request.course_ids, + knowledge_type=request.knowledge_type, + limit=request.limit + ) + + return KnowledgePointSearchResponse( + code=0, + message="success", + data=KnowledgePointSearchData(**result) + ) + + +# ==================== 4. 获取知识点详情 ==================== + +@router.get( + "/knowledge-points/{knowledge_point_id}", + response_model=KnowledgePointDetailResponse, + summary="获取知识点详情", + description="获取知识点的完整信息", + responses={ + 200: {"model": KnowledgePointDetailResponse, "description": "成功"}, + 401: {"model": SCRMErrorResponse, "description": "认证失败"}, + 404: {"model": SCRMErrorResponse, "description": "知识点不存在"}, + } +) +async def get_knowledge_point_detail( + knowledge_point_id: int, + _: bool = Depends(verify_scrm_api_key), + db: AsyncSession = Depends(get_db) +): + """ + 获取知识点详情 + + - **knowledge_point_id**: 知识点ID + """ + service = SCRMService(db) + result = await service.get_knowledge_point_detail(knowledge_point_id) + + if result is None: + raise HTTPException( + status_code=404, + detail={ + "code": 40003, + "message": "knowledge_point_id 不存在", + "data": None + } + ) + + return KnowledgePointDetailResponse( + code=0, + message="success", + data=KnowledgePointDetailData(**result) + ) + diff --git a/backend/app/api/v1/sql_executor.py b/backend/app/api/v1/sql_executor.py new file mode 100644 index 0000000..c1231c7 --- /dev/null +++ b/backend/app/api/v1/sql_executor.py @@ -0,0 +1,363 @@ +""" +SQL 执行器 API - 用于内部服务调用 +支持执行查询和写入操作的 SQL 语句 +""" +import json +from typing import Any, Dict, List, Optional, Union +from datetime import datetime, date + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.engine.result import Result +import structlog + +from app.core.deps import get_current_user, get_db +try: + from app.core.simple_auth import get_current_user_simple +except ImportError: + get_current_user_simple = None +from app.core.config import settings +from app.models.user import User +from app.schemas.base import ResponseModel + +logger = structlog.get_logger(__name__) + +router = APIRouter(tags=["SQL Executor"]) + + +class SQLExecutorRequest: + """SQL执行请求模型""" + def __init__(self, sql: str, params: Optional[Dict[str, Any]] = None): + self.sql = sql + self.params = params or {} + + +class DateTimeEncoder(json.JSONEncoder): + """处理日期时间对象的 JSON 编码器""" + def default(self, obj): + if isinstance(obj, (datetime, date)): + return obj.isoformat() + return super().default(obj) + + +def serialize_row(row: Any) -> Union[Dict[str, Any], Any]: + """序列化数据库行结果""" + if hasattr(row, '_mapping'): + # 处理 SQLAlchemy Row 对象 + return dict(row._mapping) + elif hasattr(row, '__dict__'): + # 处理 ORM 对象 + return {k: v for k, v in row.__dict__.items() if not k.startswith('_')} + else: + # 处理单值结果 + return row + + +@router.post("/execute", response_model=ResponseModel) +async def execute_sql( + request: Dict[str, Any], + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> ResponseModel: + """ + 执行 SQL 语句 + + Args: + request: 包含 sql 和可选的 params 字段 + - sql: SQL 语句 + - params: 参数字典(可选) + + Returns: + 执行结果,包括: + - 查询操作:返回数据行 + - 写入操作:返回影响的行数 + + 安全说明: + - 需要用户身份验证 + - 所有操作都会记录日志 + - 建议在生产环境中限制可执行的 SQL 类型 + """ + try: + # 提取参数 + sql = request.get('sql', '').strip() + params = request.get('params', {}) + + if not sql: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="SQL 语句不能为空" + ) + + # 记录 SQL 执行日志 + logger.info( + "sql_execution_request", + user_id=current_user.id, + username=current_user.username, + sql_type=sql.split()[0].upper() if sql else "UNKNOWN", + sql_length=len(sql), + has_params=bool(params) + ) + + # 判断 SQL 类型 + sql_upper = sql.upper().strip() + is_select = sql_upper.startswith('SELECT') + is_show = sql_upper.startswith('SHOW') + is_describe = sql_upper.startswith(('DESCRIBE', 'DESC')) + is_query = is_select or is_show or is_describe + + # 执行 SQL + try: + result = await db.execute(text(sql), params) + + if is_query: + # 查询操作 + rows = result.fetchall() + columns = list(result.keys()) if result.keys() else [] + + # 序列化结果 + data = [] + for row in rows: + serialized_row = serialize_row(row) + if isinstance(serialized_row, dict): + data.append(serialized_row) + else: + # 单列结果 + data.append({columns[0] if columns else 'value': serialized_row}) + + # 使用自定义编码器处理日期时间 + response_data = { + "type": "query", + "columns": columns, + "rows": json.loads(json.dumps(data, cls=DateTimeEncoder)), + "row_count": len(data) + } + + logger.info( + "sql_query_success", + user_id=current_user.id, + row_count=len(data), + column_count=len(columns) + ) + + else: + # 写入操作 + await db.commit() + affected_rows = result.rowcount + + response_data = { + "type": "execute", + "affected_rows": affected_rows, + "success": True + } + + logger.info( + "sql_execute_success", + user_id=current_user.id, + affected_rows=affected_rows + ) + + return ResponseModel( + code=200, + message="SQL 执行成功", + data=response_data + ) + + except Exception as e: + # 回滚事务 + await db.rollback() + logger.error( + "sql_execution_error", + user_id=current_user.id, + sql_type=sql.split()[0].upper() if sql else "UNKNOWN", + error=str(e), + exc_info=True + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"SQL 执行失败: {str(e)}" + ) + + except HTTPException: + raise + except Exception as e: + logger.error( + "sql_executor_error", + user_id=current_user.id, + error=str(e), + exc_info=True + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"处理请求时发生错误: {str(e)}" + ) + + +@router.post("/validate", response_model=ResponseModel) +async def validate_sql( + request: Dict[str, Any], + current_user: User = Depends(get_current_user) +) -> ResponseModel: + """ + 验证 SQL 语句的语法(不执行) + + Args: + request: 包含 sql 字段的请求 + + Returns: + 验证结果 + """ + try: + sql = request.get('sql', '').strip() + + if not sql: + return ResponseModel( + code=400, + message="SQL 语句不能为空", + data={"valid": False, "error": "SQL 语句不能为空"} + ) + + # 基本的 SQL 验证 + sql_upper = sql.upper().strip() + + # 检查危险操作(可根据需要调整) + dangerous_keywords = ['DROP', 'TRUNCATE', 'DELETE FROM', 'UPDATE'] + warnings = [] + + for keyword in dangerous_keywords: + if keyword in sql_upper: + warnings.append(f"包含危险操作: {keyword}") + + return ResponseModel( + code=200, + message="SQL 验证完成", + data={ + "valid": True, + "warnings": warnings, + "sql_type": sql_upper.split()[0] if sql_upper else "UNKNOWN" + } + ) + + except Exception as e: + logger.error( + "sql_validation_error", + user_id=current_user.id, + error=str(e) + ) + return ResponseModel( + code=500, + message="SQL 验证失败", + data={"valid": False, "error": str(e)} + ) + + +@router.get("/tables", response_model=ResponseModel) +async def get_tables( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> ResponseModel: + """ + 获取数据库中的所有表 + + Returns: + 数据库表列表 + """ + try: + result = await db.execute(text("SHOW TABLES")) + tables = [row[0] for row in result.fetchall()] + + return ResponseModel( + code=200, + message="获取表列表成功", + data={ + "tables": tables, + "count": len(tables) + } + ) + + except Exception as e: + logger.error( + "get_tables_error", + user_id=current_user.id, + error=str(e) + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"获取表列表失败: {str(e)}" + ) + + +@router.get("/table/{table_name}/schema", response_model=ResponseModel) +async def get_table_schema( + table_name: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> ResponseModel: + """ + 获取指定表的结构信息 + + Args: + table_name: 表名 + + Returns: + 表结构信息 + """ + try: + # MySQL 的 DESCRIBE 不支持参数化,需要直接拼接 + # 但为了安全,先验证表名 + if not table_name.replace('_', '').isalnum(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无效的表名" + ) + + result = await db.execute(text(f"DESCRIBE {table_name}")) + + columns = [] + for row in result.fetchall(): + columns.append({ + "field": row[0], + "type": row[1], + "null": row[2], + "key": row[3], + "default": row[4], + "extra": row[5] + }) + + return ResponseModel( + code=200, + message="获取表结构成功", + data={ + "table_name": table_name, + "columns": columns, + "column_count": len(columns) + } + ) + + except Exception as e: + logger.error( + "get_table_schema_error", + user_id=current_user.id, + table_name=table_name, + error=str(e) + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"获取表结构失败: {str(e)}" + ) + + +# 简化认证版本的端点(如果启用) +if get_current_user_simple: + @router.post("/execute-simple", response_model=ResponseModel) + async def execute_sql_simple( + request: Dict[str, Any], + current_user: User = Depends(get_current_user_simple), + db: AsyncSession = Depends(get_db) + ) -> ResponseModel: + """ + 执行 SQL 语句(简化认证版本) + + 支持 API Key 和 Token 两种认证方式,专为内部服务设计。 + """ + return await execute_sql(request, current_user, db) diff --git a/backend/app/api/v1/sql_executor_simple_auth.py b/backend/app/api/v1/sql_executor_simple_auth.py new file mode 100644 index 0000000..b0d017f --- /dev/null +++ b/backend/app/api/v1/sql_executor_simple_auth.py @@ -0,0 +1,5 @@ +""" +SQL 执行器 API - 简化认证版本(已删除,功能已整合到主文件) +""" +# 此文件的功能已经整合到 sql_executor.py 中 +# 请使用 /api/v1/sql/execute-simple 端点 diff --git a/backend/app/api/v1/statistics.py b/backend/app/api/v1/statistics.py new file mode 100644 index 0000000..47b8745 --- /dev/null +++ b/backend/app/api/v1/statistics.py @@ -0,0 +1,238 @@ +""" +统计分析API路由 +""" +from typing import Optional +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db, get_current_user +from app.models.user import User +from app.schemas.base import ResponseModel +from app.services.statistics_service import StatisticsService +from app.core.logger import get_logger + +logger = get_logger(__name__) +router = APIRouter(prefix="/statistics", tags=["statistics"]) + + +@router.get("/key-metrics", response_model=ResponseModel) +async def get_key_metrics( + course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"), + period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 获取关键指标 + + 返回: + - learningEfficiency: 学习效率 + - knowledgeCoverage: 知识覆盖率 + - avgTimePerQuestion: 平均用时 + - progressSpeed: 进步速度 + """ + try: + metrics = await StatisticsService.get_key_metrics( + db=db, + user_id=current_user.id, + course_id=course_id, + period=period + ) + + return ResponseModel( + code=200, + message="获取关键指标成功", + data=metrics + ) + except Exception as e: + logger.error(f"获取关键指标失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取关键指标失败: {str(e)}" + ) + + +@router.get("/score-distribution", response_model=ResponseModel) +async def get_score_distribution( + course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"), + period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 获取成绩分布统计 + + 返回各分数段的考试数量: + - excellent: 优秀(90-100) + - good: 良好(80-89) + - medium: 中等(70-79) + - pass: 及格(60-69) + - fail: 不及格(<60) + """ + try: + distribution = await StatisticsService.get_score_distribution( + db=db, + user_id=current_user.id, + course_id=course_id, + period=period + ) + + return ResponseModel( + code=200, + message="获取成绩分布成功", + data=distribution + ) + except Exception as e: + logger.error(f"获取成绩分布失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取成绩分布失败: {str(e)}" + ) + + +@router.get("/difficulty-analysis", response_model=ResponseModel) +async def get_difficulty_analysis( + course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"), + period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 获取题目难度分析 + + 返回各难度题目的正确率: + - 简单题 + - 中等题 + - 困难题 + - 综合题 + - 应用题 + """ + try: + analysis = await StatisticsService.get_difficulty_analysis( + db=db, + user_id=current_user.id, + course_id=course_id, + period=period + ) + + return ResponseModel( + code=200, + message="获取难度分析成功", + data=analysis + ) + except Exception as e: + logger.error(f"获取难度分析失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取难度分析失败: {str(e)}" + ) + + +@router.get("/knowledge-mastery", response_model=ResponseModel) +async def get_knowledge_mastery( + course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 获取知识点掌握度 + + 返回知识点列表及其掌握度: + - name: 知识点名称 + - mastery: 掌握度(0-100) + """ + try: + mastery = await StatisticsService.get_knowledge_mastery( + db=db, + user_id=current_user.id, + course_id=course_id + ) + + return ResponseModel( + code=200, + message="获取知识点掌握度成功", + data=mastery + ) + except Exception as e: + logger.error(f"获取知识点掌握度失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取知识点掌握度失败: {str(e)}" + ) + + +@router.get("/study-time", response_model=ResponseModel) +async def get_study_time_stats( + course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"), + period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 获取学习时长统计 + + 返回学习时长和练习时长的日期分布: + - labels: 日期标签列表 + - studyTime: 学习时长列表(小时) + - practiceTime: 练习时长列表(小时) + """ + try: + time_stats = await StatisticsService.get_study_time_stats( + db=db, + user_id=current_user.id, + course_id=course_id, + period=period + ) + + return ResponseModel( + code=200, + message="获取学习时长统计成功", + data=time_stats + ) + except Exception as e: + logger.error(f"获取学习时长统计失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取学习时长统计失败: {str(e)}" + ) + + +@router.get("/detail", response_model=ResponseModel) +async def get_detail_data( + course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"), + period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 获取详细统计数据(按日期) + + 返回每日详细统计数据: + - date: 日期 + - examCount: 考试次数 + - avgScore: 平均分 + - studyTime: 学习时长(小时) + - questionCount: 练习题数 + - accuracy: 正确率 + - improvement: 进步指数 + """ + try: + detail = await StatisticsService.get_detail_data( + db=db, + user_id=current_user.id, + course_id=course_id, + period=period + ) + + return ResponseModel( + code=200, + message="获取详细数据成功", + data=detail + ) + except Exception as e: + logger.error(f"获取详细数据失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取详细数据失败: {str(e)}" + ) + diff --git a/backend/app/api/v1/system.py b/backend/app/api/v1/system.py new file mode 100644 index 0000000..9625d69 --- /dev/null +++ b/backend/app/api/v1/system.py @@ -0,0 +1,139 @@ +""" +系统API - 供外部服务回调使用 +""" +import logging +from typing import List, Dict, Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, status, Header +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, Field + +from app.core.deps import get_db +from app.schemas.base import ResponseModel +from app.schemas.course import KnowledgePointCreate +from app.services.course_service import knowledge_point_service, course_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/system") + + +class KnowledgePointData(BaseModel): + """知识点数据模型""" + name: str = Field(..., description="知识点名称") + description: str = Field(default="", description="知识点描述") + type: str = Field(default="理论知识", description="知识点类型") + source: int = Field(default=1, description="来源:0=手动,1=AI分析") + topic_relation: Optional[str] = Field(None, description="与主题的关系描述") + + +class KnowledgeCallbackRequest(BaseModel): + """知识点回调请求模型(已弃用,保留向后兼容)""" + course_id: int = Field(..., description="课程ID") + material_id: int = Field(..., description="资料ID") + knowledge_points: List[KnowledgePointData] = Field(..., description="知识点列表") + + +@router.post("/knowledge", response_model=ResponseModel[Dict[str, Any]]) +async def create_knowledge_points_callback( + request: KnowledgeCallbackRequest, + authorization: str = Header(None), + db: AsyncSession = Depends(get_db), +): + """ + 创建知识点回调接口(已弃用) + + 注意:此接口已弃用,知识点分析现使用 Python 原生实现。 + 保留此接口仅为向后兼容。 + """ + try: + # API密钥验证(已弃用的接口,保留向后兼容) + expected_token = "Bearer callback-token-2025" + if authorization != expected_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的授权令牌" + ) + + # 验证课程是否存在 + course = await course_service.get_by_id(db, request.course_id) + if not course: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"课程 {request.course_id} 不存在" + ) + + # 验证资料是否存在 + materials = await course_service.get_course_materials(db, course_id=request.course_id) + material = next((m for m in materials if m.id == request.material_id), None) + if not material: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"资料 {request.material_id} 不存在" + ) + + # 创建知识点 + created_points = [] + for kp_data in request.knowledge_points: + try: + knowledge_point_create = KnowledgePointCreate( + name=kp_data.name, + description=kp_data.description, + type=kp_data.type, + source=kp_data.source, # AI分析来源=1 + topic_relation=kp_data.topic_relation, + material_id=request.material_id # 关联资料ID + ) + + # 使用系统用户ID (假设为1,或者可以配置) + system_user_id = 1 + knowledge_point = await knowledge_point_service.create_knowledge_point( + db=db, + course_id=request.course_id, + point_in=knowledge_point_create, + created_by=system_user_id + ) + + created_points.append({ + "id": knowledge_point.id, + "name": knowledge_point.name, + "description": knowledge_point.description, + "type": knowledge_point.type, + "source": knowledge_point.source, + "material_id": knowledge_point.material_id + }) + + except Exception as e: + logger.error( + f"创建知识点失败 - name: {kp_data.name}, error: {str(e)}" + ) + # 继续处理其他知识点,不因为单个失败而中断 + continue + + logger.info( + f"知识点回调成功 - course_id: {request.course_id}, material_id: {request.material_id}, created_points: {len(created_points)}" + ) + + return ResponseModel( + data={ + "course_id": request.course_id, + "material_id": request.material_id, + "knowledge_points_count": len(created_points), + "knowledge_points": created_points + }, + message=f"成功创建 {len(created_points)} 个知识点" + ) + + except HTTPException: + raise + except Exception as e: + logger.error( + f"知识点回调处理失败 - course_id: {request.course_id}, material_id: {request.material_id}, error: {str(e)}", + exc_info=True + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="知识点创建失败" + ) + + diff --git a/backend/app/api/v1/system_logs.py b/backend/app/api/v1/system_logs.py new file mode 100644 index 0000000..22c377b --- /dev/null +++ b/backend/app/api/v1/system_logs.py @@ -0,0 +1,184 @@ +""" +系统日志 API +提供日志查询、筛选、详情查看等功能 +""" +import logging +from typing import Optional +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db, get_current_user +from app.models.user import User +from app.schemas.base import ResponseModel +from app.schemas.system_log import ( + SystemLogCreate, + SystemLogResponse, + SystemLogQuery, + SystemLogListResponse +) +from app.services.system_log_service import system_log_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/logs") + + +@router.get("", response_model=ResponseModel[SystemLogListResponse]) +async def get_system_logs( + level: Optional[str] = Query(None, description="日志级别筛选"), + type: Optional[str] = Query(None, description="日志类型筛选"), + user: Optional[str] = Query(None, description="用户筛选"), + keyword: Optional[str] = Query(None, description="关键词搜索"), + start_date: Optional[datetime] = Query(None, description="开始日期"), + end_date: Optional[datetime] = Query(None, description="结束日期"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取系统日志列表 + 支持按级别、类型、用户、关键词、日期范围筛选 + 仅管理员可访问 + """ + try: + # 权限检查:仅管理员可查看系统日志 + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="无权限访问系统日志") + + # 构建查询参数 + query_params = SystemLogQuery( + level=level, + type=type, + user=user, + keyword=keyword, + start_date=start_date, + end_date=end_date, + page=page, + page_size=page_size + ) + + # 查询日志 + logs, total = await system_log_service.get_logs(db, query_params) + + # 计算总页数 + total_pages = (total + page_size - 1) // page_size + + # 转换为响应格式 + log_responses = [SystemLogResponse.model_validate(log) for log in logs] + + response_data = SystemLogListResponse( + items=log_responses, + total=total, + page=page, + page_size=page_size, + total_pages=total_pages + ) + + return ResponseModel( + code=200, + message="获取系统日志成功", + data=response_data + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取系统日志失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取系统日志失败: {str(e)}") + + +@router.get("/{log_id}", response_model=ResponseModel[SystemLogResponse]) +async def get_log_detail( + log_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取日志详情 + 仅管理员可访问 + """ + try: + # 权限检查 + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="无权限访问系统日志") + + # 查询日志 + log = await system_log_service.get_log_by_id(db, log_id) + + if not log: + raise HTTPException(status_code=404, detail="日志不存在") + + return ResponseModel( + code=200, + message="获取日志详情成功", + data=SystemLogResponse.model_validate(log) + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取日志详情失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取日志详情失败: {str(e)}") + + +@router.post("", response_model=ResponseModel[SystemLogResponse]) +async def create_system_log( + log_data: SystemLogCreate, + db: AsyncSession = Depends(get_db) +): + """ + 创建系统日志(内部API,供系统各模块调用) + 注意:此接口不需要用户认证,但应该只供内部调用 + """ + try: + log = await system_log_service.create_log(db, log_data) + + return ResponseModel( + code=200, + message="创建日志成功", + data=SystemLogResponse.model_validate(log) + ) + + except Exception as e: + logger.error(f"创建日志失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"创建日志失败: {str(e)}") + + +@router.delete("/cleanup") +async def cleanup_old_logs( + before_days: int = Query(90, ge=1, description="删除多少天之前的日志"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 清理旧日志 + 仅管理员可访问 + """ + try: + # 权限检查 + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="无权限执行此操作") + + # 计算截止日期 + from datetime import timedelta + before_date = datetime.now() - timedelta(days=before_days) + + # 删除旧日志 + deleted_count = await system_log_service.delete_logs_before_date(db, before_date) + + return ResponseModel( + code=200, + message=f"成功清理 {deleted_count} 条日志", + data={"deleted_count": deleted_count} + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"清理日志失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"清理日志失败: {str(e)}") + + + diff --git a/backend/app/api/v1/tasks.py b/backend/app/api/v1/tasks.py new file mode 100644 index 0000000..b8893fe --- /dev/null +++ b/backend/app/api/v1/tasks.py @@ -0,0 +1,228 @@ +""" +任务管理API +""" +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.deps import get_db, get_current_user, require_admin_or_manager +from app.schemas.base import ResponseModel, PaginatedResponse +from app.schemas.task import TaskCreate, TaskUpdate, TaskResponse, TaskStatsResponse +from app.services.task_service import task_service +from app.services.system_log_service import system_log_service +from app.schemas.system_log import SystemLogCreate +from app.models.user import User + +router = APIRouter(prefix="/manager/tasks", tags=["Tasks"], redirect_slashes=False) + + +@router.post("", response_model=ResponseModel[TaskResponse], summary="创建任务") +async def create_task( + task_in: TaskCreate, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_admin_or_manager) +): + """创建新任务""" + task = await task_service.create_task(db, task_in, current_user.id) + + # 记录任务创建日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="api", + message=f"创建任务: {task.title}", + user_id=current_user.id, + user=current_user.username, + ip=request.client.host if request.client else None, + path="/api/v1/manager/tasks", + method="POST", + user_agent=request.headers.get("user-agent") + ) + ) + + # 构建响应 + courses = [link.course.name for link in task.course_links] + return ResponseModel( + data=TaskResponse( + id=task.id, + title=task.title, + description=task.description, + priority=task.priority.value, + status=task.status.value, + creator_id=task.creator_id, + deadline=task.deadline, + requirements=task.requirements, + progress=task.progress, + created_at=task.created_at, + updated_at=task.updated_at, + courses=courses, + assigned_count=len(task.assignments), + completed_count=sum(1 for a in task.assignments if a.status.value == "completed") + ) + ) + + +@router.get("", response_model=ResponseModel[PaginatedResponse[TaskResponse]], summary="获取任务列表") +async def get_tasks( + status: Optional[str] = Query(None, description="任务状态筛选"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_admin_or_manager) +): + """获取任务列表""" + tasks, total = await task_service.get_tasks(db, status, page, page_size) + + # 构建响应 + items = [] + for task in tasks: + # 加载关联数据 + task_detail = await task_service.get_task_detail(db, task.id) + if task_detail: + courses = [link.course.name for link in task_detail.course_links] + items.append(TaskResponse( + id=task.id, + title=task.title, + description=task.description, + priority=task.priority.value, + status=task.status.value, + creator_id=task.creator_id, + deadline=task.deadline, + requirements=task.requirements, + progress=task.progress, + created_at=task.created_at, + updated_at=task.updated_at, + courses=courses, + assigned_count=len(task_detail.assignments), + completed_count=sum(1 for a in task_detail.assignments if a.status.value == "completed") + )) + + return ResponseModel( + data=PaginatedResponse.create( + items=items, + total=total, + page=page, + page_size=page_size + ) + ) + + +@router.get("/stats", response_model=ResponseModel[TaskStatsResponse], summary="获取任务统计") +async def get_task_stats( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_admin_or_manager) +): + """获取任务统计数据""" + stats = await task_service.get_task_stats(db) + return ResponseModel(data=stats) + + +@router.get("/{task_id}", response_model=ResponseModel[TaskResponse], summary="获取任务详情") +async def get_task( + task_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_admin_or_manager) +): + """获取任务详情""" + task = await task_service.get_task_detail(db, task_id) + + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + courses = [link.course.name for link in task.course_links] + return ResponseModel( + data=TaskResponse( + id=task.id, + title=task.title, + description=task.description, + priority=task.priority.value, + status=task.status.value, + creator_id=task.creator_id, + deadline=task.deadline, + requirements=task.requirements, + progress=task.progress, + created_at=task.created_at, + updated_at=task.updated_at, + courses=courses, + assigned_count=len(task.assignments), + completed_count=sum(1 for a in task.assignments if a.status.value == "completed") + ) + ) + + +@router.put("/{task_id}", response_model=ResponseModel[TaskResponse], summary="更新任务") +async def update_task( + task_id: int, + task_in: TaskUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_admin_or_manager) +): + """更新任务""" + task = await task_service.update_task(db, task_id, task_in) + + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + # 自动更新任务进度和状态 + await task_service.update_task_status(db, task_id) + + # 重新加载详情 + task_detail = await task_service.get_task_detail(db, task.id) + courses = [link.course.name for link in task_detail.course_links] if task_detail else [] + + return ResponseModel( + data=TaskResponse( + id=task.id, + title=task.title, + description=task.description, + priority=task.priority.value, + status=task.status.value, + creator_id=task.creator_id, + deadline=task.deadline, + requirements=task.requirements, + progress=task.progress, + created_at=task.created_at, + updated_at=task.updated_at, + courses=courses, + assigned_count=len(task_detail.assignments) if task_detail else 0, + completed_count=sum(1 for a in task_detail.assignments if a.status.value == "completed") if task_detail else 0 + ) + ) + + +@router.delete("/{task_id}", response_model=ResponseModel, summary="删除任务") +async def delete_task( + task_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_admin_or_manager) +): + """删除任务""" + # 先获取任务信息用于日志 + task_detail = await task_service.get_task_detail(db, task_id) + task_title = task_detail.title if task_detail else f"ID:{task_id}" + + success = await task_service.delete_task(db, task_id) + + if not success: + raise HTTPException(status_code=404, detail="任务不存在") + + # 记录任务删除日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="api", + message=f"删除任务: {task_title}", + user_id=current_user.id, + user=current_user.username, + ip=request.client.host if request.client else None, + path=f"/api/v1/manager/tasks/{task_id}", + method="DELETE", + user_agent=request.headers.get("user-agent") + ) + ) + + return ResponseModel(message="任务已删除") + diff --git a/backend/app/api/v1/team_dashboard.py b/backend/app/api/v1/team_dashboard.py new file mode 100644 index 0000000..43e4710 --- /dev/null +++ b/backend/app/api/v1/team_dashboard.py @@ -0,0 +1,750 @@ +""" +团队看板 API 路由 +提供团队概览、学习进度、排行榜、动态等数据 +""" + +import json +from datetime import datetime, timedelta +from typing import Any, Dict, List + +from fastapi import APIRouter, Depends +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_active_user as get_current_user, get_db +from app.core.logger import logger +from app.models.course import Course +from app.models.exam import Exam +from app.models.position import Position +from app.models.position_member import PositionMember +from app.models.practice import PracticeReport, PracticeSession +from app.models.user import Team, User, UserTeam +from app.schemas.base import ResponseModel + +router = APIRouter(prefix="/team/dashboard", tags=["team-dashboard"]) + + +async def get_accessible_teams( + current_user: User, + db: AsyncSession +) -> List[int]: + """获取用户可访问的团队ID列表""" + if current_user.role in ['admin', 'manager']: + # 管理员查看所有团队 + stmt = select(Team.id).where(Team.is_deleted == False) # noqa: E712 + result = await db.execute(stmt) + return [row[0] for row in result.all()] + else: + # 普通用户只查看自己的团队 + stmt = select(UserTeam.team_id).where(UserTeam.user_id == current_user.id) + result = await db.execute(stmt) + return [row[0] for row in result.all()] + + +async def get_team_member_ids( + team_ids: List[int], + db: AsyncSession +) -> List[int]: + """获取团队成员ID列表""" + if not team_ids: + return [] + + stmt = select(UserTeam.user_id).where( + UserTeam.team_id.in_(team_ids) + ).distinct() + result = await db.execute(stmt) + return [row[0] for row in result.all()] + + +@router.get("/overview", response_model=ResponseModel) +async def get_team_overview( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取团队概览统计 + + 返回团队总数、成员数、平均学习进度、平均成绩、课程完成率等 + """ + try: + # 获取可访问的团队 + team_ids = await get_accessible_teams(current_user, db) + + # 获取团队成员ID + member_ids = await get_team_member_ids(team_ids, db) + + # 统计团队数 + team_count = len(team_ids) + + # 统计成员数 + member_count = len(member_ids) + + # 计算平均考试成绩(使用round1_score) + avg_score = 0.0 + if member_ids: + stmt = select(func.avg(Exam.round1_score)).where( + and_( + Exam.user_id.in_(member_ids), + Exam.round1_score.isnot(None), + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(stmt) + avg_score_value = result.scalar() + avg_score = float(avg_score_value) if avg_score_value else 0.0 + + # 计算平均学习进度(基于考试完成情况) + avg_progress = 0.0 + if member_ids: + # 统计每个成员完成的考试数 + stmt = select(func.count(Exam.id)).where( + and_( + Exam.user_id.in_(member_ids), + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(stmt) + completed_exams = result.scalar() or 0 + + # 假设每个成员应完成10个考试,计算完成率作为进度 + total_expected = member_count * 10 + if total_expected > 0: + avg_progress = (completed_exams / total_expected) * 100 + + # 计算课程完成率 + course_completion_rate = 0.0 + if member_ids: + # 统计已完成的课程数(有考试记录且成绩>=60) + stmt = select(func.count(func.distinct(Exam.course_id))).where( + and_( + Exam.user_id.in_(member_ids), + Exam.round1_score >= 60, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(stmt) + completed_courses = result.scalar() or 0 + + # 统计总课程数 + stmt = select(func.count(Course.id)).where( + and_( + Course.is_deleted == False, # noqa: E712 + Course.status == 'published' + ) + ) + result = await db.execute(stmt) + total_courses = result.scalar() or 0 + + if total_courses > 0: + course_completion_rate = (completed_courses / total_courses) * 100 + + # 趋势数据(暂时返回固定值,后续可实现真实趋势计算) + trends = { + "member_trend": 0, + "progress_trend": 12.3 if avg_progress > 0 else 0, + "score_trend": 5.8 if avg_score > 0 else 0, + "completion_trend": -3.2 if course_completion_rate > 0 else 0 + } + + data = { + "team_count": team_count, + "member_count": member_count, + "avg_progress": round(avg_progress, 1), + "avg_score": round(avg_score, 1), + "course_completion_rate": round(course_completion_rate, 1), + "trends": trends + } + + return ResponseModel(code=200, message="success", data=data) + + except Exception as e: + logger.error(f"获取团队概览失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取团队概览失败: {str(e)}", data=None) + + +@router.get("/progress", response_model=ResponseModel) +async def get_progress_data( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取学习进度数据 + + 返回Top 5成员的8周学习进度数据 + """ + try: + # 获取可访问的团队 + team_ids = await get_accessible_teams(current_user, db) + member_ids = await get_team_member_ids(team_ids, db) + + if not member_ids: + return ResponseModel( + code=200, + message="success", + data={"members": [], "weeks": [], "data": []} + ) + + # 获取Top 5学习时长最高的成员 + stmt = ( + select( + User.id, + User.full_name, + func.sum(PracticeSession.duration_seconds).label('total_duration') + ) + .join(PracticeSession, PracticeSession.user_id == User.id) + .where( + and_( + User.id.in_(member_ids), + PracticeSession.status == 'completed' + ) + ) + .group_by(User.id, User.full_name) + .order_by(func.sum(PracticeSession.duration_seconds).desc()) + .limit(5) + ) + result = await db.execute(stmt) + top_members = result.all() + + if not top_members: + # 如果没有陪练记录,按考试成绩选择Top 5 + stmt = ( + select( + User.id, + User.full_name, + func.avg(Exam.round1_score).label('avg_score') + ) + .join(Exam, Exam.user_id == User.id) + .where( + and_( + User.id.in_(member_ids), + Exam.round1_score.isnot(None), + Exam.status.in_(['completed', 'submitted']) + ) + ) + .group_by(User.id, User.full_name) + .order_by(func.avg(Exam.round1_score).desc()) + .limit(5) + ) + result = await db.execute(stmt) + top_members = result.all() + + # 生成周标签 + weeks = [f"第{i+1}周" for i in range(8)] + + # 为每个成员生成进度数据 + members = [] + data = [] + + for member in top_members: + member_name = member.full_name or f"用户{member.id}" + members.append(member_name) + + # 查询该成员8周内的考试完成情况 + eight_weeks_ago = datetime.now() - timedelta(weeks=8) + stmt = select(Exam).where( + and_( + Exam.user_id == member.id, + Exam.created_at >= eight_weeks_ago, + Exam.status.in_(['completed', 'submitted']) + ) + ).order_by(Exam.created_at) + result = await db.execute(stmt) + exams = result.scalars().all() + + # 计算每周的进度(0-100) + values = [] + for week in range(8): + week_start = datetime.now() - timedelta(weeks=8-week) + week_end = week_start + timedelta(weeks=1) + + # 统计该周完成的考试数 + week_exams = [ + e for e in exams + if week_start <= e.created_at < week_end + ] + + # 进度 = 累计完成考试数 * 10(假设每个考试代表10%进度) + cumulative_exams = len([e for e in exams if e.created_at < week_end]) + progress = min(cumulative_exams * 10, 100) + values.append(progress) + + data.append({"name": member_name, "values": values}) + + return ResponseModel( + code=200, + message="success", + data={"members": members, "weeks": weeks, "data": data} + ) + + except Exception as e: + logger.error(f"获取学习进度数据失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取学习进度数据失败: {str(e)}", data=None) + + +@router.get("/course-distribution", response_model=ResponseModel) +async def get_course_distribution( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取课程完成分布 + + 返回已完成、进行中、未开始的课程数量 + """ + try: + # 获取可访问的团队 + team_ids = await get_accessible_teams(current_user, db) + member_ids = await get_team_member_ids(team_ids, db) + + # 统计所有已发布的课程 + stmt = select(func.count(Course.id)).where( + and_( + Course.is_deleted == False, # noqa: E712 + Course.status == 'published' + ) + ) + result = await db.execute(stmt) + total_courses = result.scalar() or 0 + + if not member_ids or total_courses == 0: + return ResponseModel( + code=200, + message="success", + data={"completed": 0, "in_progress": 0, "not_started": 0} + ) + + # 统计已完成的课程(有及格成绩) + stmt = select(func.count(func.distinct(Exam.course_id))).where( + and_( + Exam.user_id.in_(member_ids), + Exam.round1_score >= 60, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(stmt) + completed = result.scalar() or 0 + + # 统计进行中的课程(有考试记录但未及格) + stmt = select(func.count(func.distinct(Exam.course_id))).where( + and_( + Exam.user_id.in_(member_ids), + or_( + Exam.round1_score < 60, + Exam.status == 'started' + ) + ) + ) + result = await db.execute(stmt) + in_progress = result.scalar() or 0 + + # 未开始 = 总数 - 已完成 - 进行中 + not_started = max(0, total_courses - completed - in_progress) + + data = { + "completed": completed, + "in_progress": in_progress, + "not_started": not_started + } + + return ResponseModel(code=200, message="success", data=data) + + except Exception as e: + logger.error(f"获取课程分布失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取课程分布失败: {str(e)}", data=None) + + +@router.get("/ability-analysis", response_model=ResponseModel) +async def get_ability_analysis( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取能力分析数据 + + 返回团队能力雷达图数据和短板列表 + """ + try: + # 获取可访问的团队 + team_ids = await get_accessible_teams(current_user, db) + member_ids = await get_team_member_ids(team_ids, db) + + if not member_ids: + return ResponseModel( + code=200, + message="success", + data={ + "radar_data": { + "dimensions": [], + "values": [] + }, + "weaknesses": [] + } + ) + + # 查询所有陪练报告的能力维度数据 + # 需要通过PracticeSession关联,因为PracticeReport没有user_id + stmt = ( + select(PracticeReport.ability_dimensions) + .join(PracticeSession, PracticeSession.session_id == PracticeReport.session_id) + .where(PracticeSession.user_id.in_(member_ids)) + ) + result = await db.execute(stmt) + all_dimensions = result.scalars().all() + + if not all_dimensions: + # 如果没有陪练报告,返回默认能力维度 + default_dimensions = ["沟通表达", "倾听理解", "需求挖掘", "异议处理", "成交技巧", "客户维护"] + return ResponseModel( + code=200, + message="success", + data={ + "radar_data": { + "dimensions": default_dimensions, + "values": [0] * len(default_dimensions) + }, + "weaknesses": [] + } + ) + + # 聚合能力数据 + ability_scores: Dict[str, List[float]] = {} + + # 能力维度名称映射 + dimension_name_map = { + "sales_ability": "销售能力", + "service_attitude": "服务态度", + "technical_skills": "技术能力", + "沟通表达": "沟通表达", + "倾听理解": "倾听理解", + "需求挖掘": "需求挖掘", + "异议处理": "异议处理", + "成交技巧": "成交技巧", + "客户维护": "客户维护" + } + + for dimensions in all_dimensions: + if dimensions: + # 如果是字符串,进行JSON反序列化 + if isinstance(dimensions, str): + try: + dimensions = json.loads(dimensions) + except json.JSONDecodeError: + logger.warning(f"无法解析能力维度数据: {dimensions}") + continue + + # 处理字典格式:{"sales_ability": 79.0, ...} + if isinstance(dimensions, dict): + for key, score in dimensions.items(): + name = dimension_name_map.get(key, key) + if name not in ability_scores: + ability_scores[name] = [] + ability_scores[name].append(float(score)) + + # 处理列表格式:[{"name": "沟通表达", "score": 85}, ...] + elif isinstance(dimensions, list): + for dim in dimensions: + if not isinstance(dim, dict): + logger.warning(f"能力维度项格式错误: {type(dim)}") + continue + + name = dim.get('name', '') + score = dim.get('score', 0) + if name: + mapped_name = dimension_name_map.get(name, name) + if mapped_name not in ability_scores: + ability_scores[mapped_name] = [] + ability_scores[mapped_name].append(float(score)) + else: + logger.warning(f"能力维度数据格式错误: {type(dimensions)}") + + # 计算平均分 + avg_scores = { + name: sum(scores) / len(scores) + for name, scores in ability_scores.items() + } + + # 按固定顺序排列维度(支持多种维度组合) + # 优先使用六维度,如果没有则使用三维度 + standard_dimensions_six = ["沟通表达", "倾听理解", "需求挖掘", "异议处理", "成交技巧", "客户维护"] + standard_dimensions_three = ["销售能力", "服务态度", "技术能力"] + + # 判断使用哪种维度标准 + has_six_dimensions = any(dim in avg_scores for dim in standard_dimensions_six) + has_three_dimensions = any(dim in avg_scores for dim in standard_dimensions_three) + + if has_six_dimensions: + standard_dimensions = standard_dimensions_six + elif has_three_dimensions: + standard_dimensions = standard_dimensions_three + else: + # 如果都没有,使用实际数据的维度 + standard_dimensions = list(avg_scores.keys()) + + dimensions = [] + values = [] + + for dim in standard_dimensions: + if dim in avg_scores: + dimensions.append(dim) + values.append(round(avg_scores[dim], 1)) + + # 找出短板(平均分<80) + weaknesses = [] + weakness_suggestions = { + # 六维度建议 + "异议处理": "建议加强异议处理专项训练,增加实战演练", + "成交技巧": "需要系统学习成交话术和时机把握", + "需求挖掘": "提升提问技巧,深入了解客户需求", + "沟通表达": "加强沟通技巧训练,提升表达能力", + "倾听理解": "培养同理心,提高倾听和理解能力", + "客户维护": "学习客户关系管理,提升服务质量", + # 三维度建议 + "销售能力": "建议加强销售技巧训练,提升成交率", + "服务态度": "需要改善服务态度,提高客户满意度", + "技术能力": "建议学习产品知识,提升专业能力" + } + + for name, score in avg_scores.items(): + if score < 80: + weaknesses.append({ + "name": name, + "avg_score": int(score), + "suggestion": weakness_suggestions.get(name, f"建议加强{name}专项训练") + }) + + # 按分数升序排列 + weaknesses.sort(key=lambda x: x['avg_score']) + + data = { + "radar_data": { + "dimensions": dimensions, + "values": values + }, + "weaknesses": weaknesses + } + + return ResponseModel(code=200, message="success", data=data) + + except Exception as e: + logger.error(f"获取能力分析失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取能力分析失败: {str(e)}", data=None) + + +@router.get("/rankings", response_model=ResponseModel) +async def get_rankings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取排行榜数据 + + 返回学习时长排行和成绩排行Top 5 + """ + try: + # 获取可访问的团队 + team_ids = await get_accessible_teams(current_user, db) + member_ids = await get_team_member_ids(team_ids, db) + + if not member_ids: + return ResponseModel( + code=200, + message="success", + data={ + "study_time_ranking": [], + "score_ranking": [] + } + ) + + # 学习时长排行(基于陪练会话) + stmt = ( + select( + User.id, + User.full_name, + User.avatar_url, + Position.name.label('position_name'), + func.sum(PracticeSession.duration_seconds).label('total_duration') + ) + .join(PracticeSession, PracticeSession.user_id == User.id) + .outerjoin(PositionMember, and_( + PositionMember.user_id == User.id, + PositionMember.is_deleted == False # noqa: E712 + )) + .outerjoin(Position, Position.id == PositionMember.position_id) + .where( + and_( + User.id.in_(member_ids), + PracticeSession.status == 'completed' + ) + ) + .group_by(User.id, User.full_name, User.avatar_url, Position.name) + .order_by(func.sum(PracticeSession.duration_seconds).desc()) + .limit(5) + ) + result = await db.execute(stmt) + study_time_data = result.all() + + study_time_ranking = [] + for row in study_time_data: + study_time_ranking.append({ + "id": row.id, + "name": row.full_name or f"用户{row.id}", + "position": row.position_name or "未分配岗位", + "avatar": row.avatar_url or "", + "study_time": round(row.total_duration / 3600, 1) # 转换为小时 + }) + + # 成绩排行(基于考试round1_score) + stmt = ( + select( + User.id, + User.full_name, + User.avatar_url, + Position.name.label('position_name'), + func.avg(Exam.round1_score).label('avg_score') + ) + .join(Exam, Exam.user_id == User.id) + .outerjoin(PositionMember, and_( + PositionMember.user_id == User.id, + PositionMember.is_deleted == False # noqa: E712 + )) + .outerjoin(Position, Position.id == PositionMember.position_id) + .where( + and_( + User.id.in_(member_ids), + Exam.round1_score.isnot(None), + Exam.status.in_(['completed', 'submitted']) + ) + ) + .group_by(User.id, User.full_name, User.avatar_url, Position.name) + .order_by(func.avg(Exam.round1_score).desc()) + .limit(5) + ) + result = await db.execute(stmt) + score_data = result.all() + + score_ranking = [] + for row in score_data: + score_ranking.append({ + "id": row.id, + "name": row.full_name or f"用户{row.id}", + "position": row.position_name or "未分配岗位", + "avatar": row.avatar_url or "", + "avg_score": round(row.avg_score, 1) + }) + + data = { + "study_time_ranking": study_time_ranking, + "score_ranking": score_ranking + } + + return ResponseModel(code=200, message="success", data=data) + + except Exception as e: + logger.error(f"获取排行榜失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取排行榜失败: {str(e)}", data=None) + + +@router.get("/activities", response_model=ResponseModel) +async def get_activities( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取团队学习动态 + + 返回最近20条活动记录(考试、陪练等) + """ + try: + # 获取可访问的团队 + team_ids = await get_accessible_teams(current_user, db) + member_ids = await get_team_member_ids(team_ids, db) + + if not member_ids: + return ResponseModel( + code=200, + message="success", + data={"activities": []} + ) + + activities = [] + + # 获取最近的考试记录 + stmt = ( + select(Exam, User.full_name, Course.name.label('course_name')) + .join(User, User.id == Exam.user_id) + .join(Course, Course.id == Exam.course_id) + .where( + and_( + Exam.user_id.in_(member_ids), + Exam.status.in_(['completed', 'submitted']) + ) + ) + .order_by(Exam.updated_at.desc()) + .limit(10) + ) + result = await db.execute(stmt) + exam_records = result.all() + + for exam, user_name, course_name in exam_records: + score = exam.round1_score or 0 + activity_type = "success" if score >= 60 else "danger" + result_type = "success" if score >= 60 else "danger" + result_text = f"成绩:{int(score)}分" if score >= 60 else "未通过" + + activities.append({ + "id": f"exam_{exam.id}", + "user_name": user_name or f"用户{exam.user_id}", + "action": "完成了" if score >= 60 else "参加了", + "target": f"《{course_name}》课程考试", + "time": exam.updated_at.strftime("%Y-%m-%d %H:%M"), + "type": activity_type, + "result": {"type": result_type, "text": result_text} + }) + + # 获取最近的陪练记录 + stmt = ( + select(PracticeSession, User.full_name, PracticeReport.total_score) + .join(User, User.id == PracticeSession.user_id) + .outerjoin(PracticeReport, PracticeReport.session_id == PracticeSession.session_id) + .where( + and_( + PracticeSession.user_id.in_(member_ids), + PracticeSession.status == 'completed' + ) + ) + .order_by(PracticeSession.end_time.desc()) + .limit(10) + ) + result = await db.execute(stmt) + practice_records = result.all() + + for session, user_name, total_score in practice_records: + activity_type = "primary" + result_data = None + if total_score: + result_data = {"type": "", "text": f"评分:{int(total_score)}分"} + + activities.append({ + "id": f"practice_{session.id}", + "user_name": user_name or f"用户{session.user_id}", + "action": "参加了", + "target": "AI陪练训练", + "time": session.end_time.strftime("%Y-%m-%d %H:%M") if session.end_time else "", + "type": activity_type, + "result": result_data + }) + + # 按时间倒序排列,取前20条 + activities.sort(key=lambda x: x['time'], reverse=True) + activities = activities[:20] + + return ResponseModel( + code=200, + message="success", + data={"activities": activities} + ) + + except Exception as e: + logger.error(f"获取团队动态失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取团队动态失败: {str(e)}", data=None) + diff --git a/backend/app/api/v1/team_management.py b/backend/app/api/v1/team_management.py new file mode 100644 index 0000000..438d9e8 --- /dev/null +++ b/backend/app/api/v1/team_management.py @@ -0,0 +1,896 @@ +""" +团队成员管理 API 路由 +提供团队统计、成员列表、成员详情、学习报告等功能 +""" + +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_active_user as get_current_user, get_db +from app.core.logger import logger +from app.models.course import Course +from app.models.exam import Exam +from app.models.position import Position +from app.models.position_course import PositionCourse +from app.models.position_member import PositionMember +from app.models.practice import PracticeReport, PracticeSession +from app.models.user import User, UserTeam +from app.schemas.base import PaginatedResponse, ResponseModel + +router = APIRouter(prefix="/team/management", tags=["team-management"]) + + +async def get_accessible_team_member_ids( + current_user: User, + db: AsyncSession +) -> List[int]: + """获取用户可访问的团队成员ID列表""" + if current_user.role in ['admin', 'manager']: + # 管理员查看所有团队成员 + stmt = select(UserTeam.user_id).distinct() + result = await db.execute(stmt) + return [row[0] for row in result.all()] + else: + # 普通用户只查看自己团队的成员 + # 1. 先查询用户所在的团队 + stmt = select(UserTeam.team_id).where(UserTeam.user_id == current_user.id) + result = await db.execute(stmt) + team_ids = [row[0] for row in result.all()] + + if not team_ids: + return [] + + # 2. 查询这些团队的所有成员 + stmt = select(UserTeam.user_id).where( + UserTeam.team_id.in_(team_ids) + ).distinct() + result = await db.execute(stmt) + return [row[0] for row in result.all()] + + +def calculate_member_status( + last_login: Optional[datetime], + last_exam: Optional[datetime], + last_practice: Optional[datetime], + has_ongoing: bool +) -> str: + """ + 计算成员活跃状态 + + Args: + last_login: 最后登录时间 + last_exam: 最后考试时间 + last_practice: 最后陪练时间 + has_ongoing: 是否有进行中的活动 + + Returns: + 状态: active(活跃), learning(学习中), rest(休息) + """ + # 获取最近活跃时间 + times = [t for t in [last_login, last_exam, last_practice] if t is not None] + if not times: + return 'rest' + + last_active = max(times) + thirty_days_ago = datetime.now() - timedelta(days=30) + + # 判断状态 + if last_active >= thirty_days_ago: + if has_ongoing: + return 'learning' + else: + return 'active' + else: + return 'rest' + + +@router.get("/statistics", response_model=ResponseModel) +async def get_team_statistics( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取团队统计数据 + + 返回:团队总人数、活跃成员数、平均学习进度、团队平均分 + """ + try: + # 获取可访问的团队成员ID + member_ids = await get_accessible_team_member_ids(current_user, db) + + # 团队总人数 + team_count = len(member_ids) + + if team_count == 0: + return ResponseModel( + code=200, + message="success", + data={ + "teamCount": 0, + "activeMembers": 0, + "avgProgress": 0, + "avgScore": 0 + } + ) + + # 统计活跃成员数(最近30天有活动) + thirty_days_ago = datetime.now() - timedelta(days=30) + + # 统计最近30天有登录或有考试或有陪练的用户 + active_users_stmt = select(func.count(func.distinct(User.id))).where( + and_( + User.id.in_(member_ids), + or_( + User.last_login_at >= thirty_days_ago, + User.id.in_( + select(Exam.user_id).where( + and_( + Exam.user_id.in_(member_ids), + Exam.created_at >= thirty_days_ago + ) + ) + ), + User.id.in_( + select(PracticeSession.user_id).where( + and_( + PracticeSession.user_id.in_(member_ids), + PracticeSession.start_time >= thirty_days_ago + ) + ) + ) + ) + ) + ) + result = await db.execute(active_users_stmt) + active_members = result.scalar() or 0 + + # 计算平均学习进度(每个成员的完成课程/应完成课程的平均值) + # 统计每个成员的进度,然后计算平均值 + total_progress = 0.0 + members_with_courses = 0 + + for member_id in member_ids: + # 获取该成员岗位分配的课程数 + member_courses_stmt = select( + func.count(func.distinct(PositionCourse.course_id)) + ).select_from(PositionMember).join( + PositionCourse, + PositionCourse.position_id == PositionMember.position_id + ).where( + and_( + PositionMember.user_id == member_id, + PositionMember.is_deleted == False # noqa: E712 + ) + ) + result = await db.execute(member_courses_stmt) + member_total_courses = result.scalar() or 0 + + if member_total_courses > 0: + # 获取该成员已完成(及格)的课程数 + member_completed_stmt = select( + func.count(func.distinct(Exam.course_id)) + ).where( + and_( + Exam.user_id == member_id, + Exam.round1_score >= 60, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(member_completed_stmt) + member_completed = result.scalar() or 0 + + # 计算该成员的进度(最大100%) + member_progress = min((member_completed / member_total_courses) * 100, 100) + total_progress += member_progress + members_with_courses += 1 + + avg_progress = round(total_progress / members_with_courses, 1) if members_with_courses > 0 else 0.0 + + # 计算团队平均分(使用round1_score) + avg_score_stmt = select(func.avg(Exam.round1_score)).where( + and_( + Exam.user_id.in_(member_ids), + Exam.round1_score.isnot(None), + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(avg_score_stmt) + avg_score_value = result.scalar() + avg_score = round(float(avg_score_value), 1) if avg_score_value else 0.0 + + data = { + "teamCount": team_count, + "activeMembers": active_members, + "avgProgress": avg_progress, + "avgScore": avg_score + } + + return ResponseModel(code=200, message="success", data=data) + + except Exception as e: + logger.error(f"获取团队统计失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取团队统计失败: {str(e)}", data=None) + + +@router.get("/members", response_model=ResponseModel[PaginatedResponse]) +async def get_team_members( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(20, ge=1, le=100, description="每页数量"), + search_text: Optional[str] = Query(None, description="搜索姓名、岗位"), + status: Optional[str] = Query(None, description="筛选状态: active/learning/rest"), + position: Optional[str] = Query(None, description="筛选岗位"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取团队成员列表(带筛选、搜索、分页) + + 返回成员基本信息、学习进度、成绩、学习时长等 + """ + try: + # 获取可访问的团队成员ID + member_ids = await get_accessible_team_member_ids(current_user, db) + + if not member_ids: + return ResponseModel( + code=200, + message="success", + data=PaginatedResponse( + items=[], + total=0, + page=page, + page_size=size, + pages=0 + ) + ) + + # 构建基础查询 + stmt = select(User).where( + and_( + User.id.in_(member_ids), + User.is_deleted == False # noqa: E712 + ) + ) + + # 搜索条件(姓名) + if search_text: + like_pattern = f"%{search_text}%" + stmt = stmt.where( + or_( + User.full_name.ilike(like_pattern), + User.username.ilike(like_pattern) + ) + ) + + # 先获取所有符合条件的用户,然后在Python中过滤状态和岗位 + result = await db.execute(stmt) + all_users = result.scalars().all() + + # 为每个用户计算详细信息 + member_list = [] + thirty_days_ago = datetime.now() - timedelta(days=30) + + for user in all_users: + # 获取用户岗位 + position_stmt = select(Position.name).select_from(PositionMember).join( + Position, + Position.id == PositionMember.position_id + ).where( + and_( + PositionMember.user_id == user.id, + PositionMember.is_deleted == False # noqa: E712 + ) + ).limit(1) + result = await db.execute(position_stmt) + position_name = result.scalar() + + # 如果有岗位筛选且不匹配,跳过 + if position and position_name != position: + continue + + # 获取最近考试时间 + last_exam_stmt = select(func.max(Exam.created_at)).where( + Exam.user_id == user.id + ) + result = await db.execute(last_exam_stmt) + last_exam = result.scalar() + + # 获取最近陪练时间 + last_practice_stmt = select(func.max(PracticeSession.start_time)).where( + PracticeSession.user_id == user.id + ) + result = await db.execute(last_practice_stmt) + last_practice = result.scalar() + + # 检查是否有进行中的活动 + has_ongoing_stmt = select(func.count(Exam.id)).where( + and_( + Exam.user_id == user.id, + Exam.status == 'started' + ) + ) + result = await db.execute(has_ongoing_stmt) + has_ongoing = (result.scalar() or 0) > 0 + + # 计算状态 + member_status = calculate_member_status( + user.last_login_at, + last_exam, + last_practice, + has_ongoing + ) + + # 如果有状态筛选且不匹配,跳过 + if status and member_status != status: + continue + + # 统计学习进度 + # 1. 获取岗位分配的课程总数 + total_courses_stmt = select( + func.count(func.distinct(PositionCourse.course_id)) + ).select_from(PositionMember).join( + PositionCourse, + PositionCourse.position_id == PositionMember.position_id + ).where( + and_( + PositionMember.user_id == user.id, + PositionMember.is_deleted == False # noqa: E712 + ) + ) + result = await db.execute(total_courses_stmt) + total_courses = result.scalar() or 0 + + # 2. 统计已完成的考试(及格) + completed_courses_stmt = select( + func.count(func.distinct(Exam.course_id)) + ).where( + and_( + Exam.user_id == user.id, + Exam.round1_score >= 60, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(completed_courses_stmt) + completed_courses = result.scalar() or 0 + + # 3. 计算进度 + progress = 0 + if total_courses > 0: + progress = int((completed_courses / total_courses) * 100) + + # 统计平均成绩 + avg_score_stmt = select(func.avg(Exam.round1_score)).where( + and_( + Exam.user_id == user.id, + Exam.round1_score.isnot(None), + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(avg_score_stmt) + avg_score_value = result.scalar() + avg_score = round(float(avg_score_value), 1) if avg_score_value else 0.0 + + # 统计学习时长(考试时长+陪练时长) + exam_time_stmt = select( + func.coalesce(func.sum(Exam.duration_minutes), 0) + ).where(Exam.user_id == user.id) + result = await db.execute(exam_time_stmt) + exam_minutes = float(result.scalar() or 0) + + practice_time_stmt = select( + func.coalesce(func.sum(PracticeSession.duration_seconds), 0) + ).where( + and_( + PracticeSession.user_id == user.id, + PracticeSession.status == 'completed' + ) + ) + result = await db.execute(practice_time_stmt) + practice_seconds = float(result.scalar() or 0) + + total_hours = round(exam_minutes / 60 + practice_seconds / 3600, 1) + + # 获取最近活跃时间 + active_times = [t for t in [user.last_login_at, last_exam, last_practice] if t is not None] + last_active = max(active_times).strftime("%Y-%m-%d %H:%M") if active_times else "-" + + member_list.append({ + "id": user.id, + "name": user.full_name or user.username, + "avatar": user.avatar_url or "", + "position": position_name or "未分配岗位", + "status": member_status, + "progress": progress, + "completedCourses": completed_courses, + "totalCourses": total_courses, + "avgScore": avg_score, + "studyTime": total_hours, + "lastActive": last_active, + "joinTime": user.created_at.strftime("%Y-%m-%d") if user.created_at else "-", + "email": user.email or "", + "phone": user.phone or "", + "passRate": 100 if completed_courses > 0 else 0 # 简化计算 + }) + + # 分页 + total = len(member_list) + pages = (total + size - 1) // size if size > 0 else 0 + start = (page - 1) * size + end = start + size + items = member_list[start:end] + + return ResponseModel( + code=200, + message="success", + data=PaginatedResponse( + items=items, + total=total, + page=page, + page_size=size, + pages=pages + ) + ) + + except Exception as e: + logger.error(f"获取团队成员列表失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取团队成员列表失败: {str(e)}", + data=None + ) + + +@router.get("/members/{member_id}/detail", response_model=ResponseModel) +async def get_member_detail( + member_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取成员详情 + + 返回完整的成员信息和最近学习记录 + """ + try: + # 权限检查:确保member_id在可访问范围内 + accessible_ids = await get_accessible_team_member_ids(current_user, db) + if member_id not in accessible_ids: + return ResponseModel( + code=403, + message="无权访问该成员信息", + data=None + ) + + # 获取用户基本信息 + stmt = select(User).where( + and_( + User.id == member_id, + User.is_deleted == False # noqa: E712 + ) + ) + result = await db.execute(stmt) + user = result.scalar_one_or_none() + + if not user: + return ResponseModel(code=404, message="成员不存在", data=None) + + # 获取岗位 + position_stmt = select(Position.name).select_from(PositionMember).join( + Position, + Position.id == PositionMember.position_id + ).where( + and_( + PositionMember.user_id == user.id, + PositionMember.is_deleted == False # noqa: E712 + ) + ).limit(1) + result = await db.execute(position_stmt) + position_name = result.scalar() or "未分配岗位" + + # 计算状态 + last_exam_stmt = select(func.max(Exam.created_at)).where(Exam.user_id == user.id) + result = await db.execute(last_exam_stmt) + last_exam = result.scalar() + + last_practice_stmt = select(func.max(PracticeSession.start_time)).where( + PracticeSession.user_id == user.id + ) + result = await db.execute(last_practice_stmt) + last_practice = result.scalar() + + has_ongoing_stmt = select(func.count(Exam.id)).where( + and_( + Exam.user_id == user.id, + Exam.status == 'started' + ) + ) + result = await db.execute(has_ongoing_stmt) + has_ongoing = (result.scalar() or 0) > 0 + + member_status = calculate_member_status( + user.last_login_at, + last_exam, + last_practice, + has_ongoing + ) + + # 统计学习数据 + # 学习时长 + exam_time_stmt = select(func.coalesce(func.sum(Exam.duration_minutes), 0)).where( + Exam.user_id == user.id + ) + result = await db.execute(exam_time_stmt) + exam_minutes = result.scalar() or 0 + + practice_time_stmt = select( + func.coalesce(func.sum(PracticeSession.duration_seconds), 0) + ).where( + and_( + PracticeSession.user_id == user.id, + PracticeSession.status == 'completed' + ) + ) + result = await db.execute(practice_time_stmt) + practice_seconds = result.scalar() or 0 + + study_time = round(exam_minutes / 60 + practice_seconds / 3600, 1) + + # 完成课程数 + completed_courses_stmt = select( + func.count(func.distinct(Exam.course_id)) + ).where( + and_( + Exam.user_id == user.id, + Exam.round1_score >= 60, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(completed_courses_stmt) + completed_courses = result.scalar() or 0 + + # 平均成绩 + avg_score_stmt = select(func.avg(Exam.round1_score)).where( + and_( + Exam.user_id == user.id, + Exam.round1_score.isnot(None), + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(avg_score_stmt) + avg_score_value = result.scalar() + avg_score = round(float(avg_score_value), 1) if avg_score_value else 0.0 + + # 通过率 + total_exams_stmt = select(func.count(Exam.id)).where( + and_( + Exam.user_id == user.id, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(total_exams_stmt) + total_exams = result.scalar() or 0 + + passed_exams_stmt = select(func.count(Exam.id)).where( + and_( + Exam.user_id == user.id, + Exam.round1_score >= 60, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(passed_exams_stmt) + passed_exams = result.scalar() or 0 + + pass_rate = round((passed_exams / total_exams) * 100) if total_exams > 0 else 0 + + # 获取最近学习记录(最近10条考试和陪练) + recent_records = [] + + # 考试记录 + exam_records_stmt = ( + select(Exam, Course.name.label('course_name')) + .join(Course, Course.id == Exam.course_id) + .where( + and_( + Exam.user_id == user.id, + Exam.status.in_(['completed', 'submitted']) + ) + ) + .order_by(Exam.updated_at.desc()) + .limit(10) + ) + result = await db.execute(exam_records_stmt) + exam_records = result.all() + + for exam, course_name in exam_records: + score = exam.round1_score or 0 + record_type = "success" if score >= 60 else "danger" + recent_records.append({ + "id": f"exam_{exam.id}", + "time": exam.updated_at.strftime("%Y-%m-%d %H:%M"), + "content": f"完成《{course_name}》课程考试,成绩:{int(score)}分", + "type": record_type + }) + + # 陪练记录 + practice_records_stmt = ( + select(PracticeSession) + .where( + and_( + PracticeSession.user_id == user.id, + PracticeSession.status == 'completed' + ) + ) + .order_by(PracticeSession.end_time.desc()) + .limit(5) + ) + result = await db.execute(practice_records_stmt) + practice_records = result.scalars().all() + + for session in practice_records: + recent_records.append({ + "id": f"practice_{session.id}", + "time": session.end_time.strftime("%Y-%m-%d %H:%M") if session.end_time else "", + "content": "参加AI陪练训练", + "type": "primary" + }) + + # 按时间排序 + recent_records.sort(key=lambda x: x['time'], reverse=True) + recent_records = recent_records[:10] + + data = { + "id": user.id, + "name": user.full_name or user.username, + "avatar": user.avatar_url or "", + "position": position_name, + "status": member_status, + "joinTime": user.created_at.strftime("%Y-%m-%d") if user.created_at else "-", + "email": user.email or "", + "phone": user.phone or "", + "studyTime": study_time, + "completedCourses": completed_courses, + "avgScore": avg_score, + "passRate": pass_rate, + "recentRecords": recent_records + } + + return ResponseModel(code=200, message="success", data=data) + + except Exception as e: + logger.error(f"获取成员详情失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取成员详情失败: {str(e)}", + data=None + ) + + +@router.get("/members/{member_id}/report", response_model=ResponseModel) +async def get_member_report( + member_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取成员学习报告 + + 返回学习概览、30天进度趋势、能力评估、详细学习记录 + """ + try: + # 权限检查 + accessible_ids = await get_accessible_team_member_ids(current_user, db) + if member_id not in accessible_ids: + return ResponseModel(code=403, message="无权访问该成员信息", data=None) + + # 获取用户信息 + stmt = select(User).where( + and_( + User.id == member_id, + User.is_deleted == False # noqa: E712 + ) + ) + result = await db.execute(stmt) + user = result.scalar_one_or_none() + + if not user: + return ResponseModel(code=404, message="成员不存在", data=None) + + # 1. 报告概览 + # 学习总时长 + exam_time_stmt = select(func.coalesce(func.sum(Exam.duration_minutes), 0)).where( + Exam.user_id == user.id + ) + result = await db.execute(exam_time_stmt) + exam_minutes = result.scalar() or 0 + + practice_time_stmt = select( + func.coalesce(func.sum(PracticeSession.duration_seconds), 0) + ).where( + and_( + PracticeSession.user_id == user.id, + PracticeSession.status == 'completed' + ) + ) + result = await db.execute(practice_time_stmt) + practice_seconds = result.scalar() or 0 + + total_hours = round(exam_minutes / 60 + practice_seconds / 3600, 1) + + # 完成课程数 + completed_courses_stmt = select( + func.count(func.distinct(Exam.course_id)) + ).where( + and_( + Exam.user_id == user.id, + Exam.round1_score >= 60, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(completed_courses_stmt) + completed_courses = result.scalar() or 0 + + # 平均成绩 + avg_score_stmt = select(func.avg(Exam.round1_score)).where( + and_( + Exam.user_id == user.id, + Exam.round1_score.isnot(None), + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(avg_score_stmt) + avg_score_value = result.scalar() + avg_score = round(float(avg_score_value), 1) if avg_score_value else 0.0 + + # 学习排名(简化:在团队中的排名) + # TODO: 实现真实排名计算 + ranking = "第5名" + + overview = [ + { + "label": "学习总时长", + "value": f"{total_hours}小时", + "icon": "Clock", + "color": "#667eea", + "bgColor": "rgba(102, 126, 234, 0.1)" + }, + { + "label": "完成课程", + "value": f"{completed_courses}门", + "icon": "CircleCheck", + "color": "#67c23a", + "bgColor": "rgba(103, 194, 58, 0.1)" + }, + { + "label": "平均成绩", + "value": f"{avg_score}分", + "icon": "Trophy", + "color": "#e6a23c", + "bgColor": "rgba(230, 162, 60, 0.1)" + }, + { + "label": "学习排名", + "value": ranking, + "icon": "Medal", + "color": "#f56c6c", + "bgColor": "rgba(245, 108, 108, 0.1)" + } + ] + + # 2. 30天学习进度趋势 + thirty_days_ago = datetime.now() - timedelta(days=30) + dates = [] + progress_data = [] + + for i in range(30): + date = thirty_days_ago + timedelta(days=i) + dates.append(date.strftime("%m-%d")) + + # 统计该日期之前完成的考试数 + cumulative_exams_stmt = select(func.count(Exam.id)).where( + and_( + Exam.user_id == user.id, + Exam.created_at <= date, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(cumulative_exams_stmt) + cumulative = result.scalar() or 0 + + # 进度 = 累计考试数 * 10(简化计算) + progress = min(cumulative * 10, 100) + progress_data.append(progress) + + # 3. 能力评估(从陪练报告聚合) + ability_stmt = select(PracticeReport.ability_dimensions).where( + PracticeReport.user_id == user.id + ) + result = await db.execute(ability_stmt) + all_dimensions = result.scalars().all() + + abilities = [] + if all_dimensions: + # 聚合能力数据 + ability_scores: Dict[str, List[float]] = {} + + for dimensions in all_dimensions: + if dimensions: + for dim in dimensions: + name = dim.get('name', '') + score = dim.get('score', 0) + if name: + if name not in ability_scores: + ability_scores[name] = [] + ability_scores[name].append(float(score)) + + # 计算平均分 + for name, scores in ability_scores.items(): + avg = sum(scores) / len(scores) + description = "表现良好" if avg >= 80 else "需要加强" + abilities.append({ + "name": name, + "score": int(avg), + "description": description + }) + else: + # 默认能力评估 + default_abilities = [ + {"name": "沟通表达", "score": 0, "description": "暂无数据"}, + {"name": "需求挖掘", "score": 0, "description": "暂无数据"}, + {"name": "产品知识", "score": 0, "description": "暂无数据"}, + {"name": "成交技巧", "score": 0, "description": "暂无数据"} + ] + abilities = default_abilities + + # 4. 详细学习记录(最近20条) + records = [] + + # 考试记录 + exam_records_stmt = ( + select(Exam, Course.name.label('course_name')) + .join(Course, Course.id == Exam.course_id) + .where( + and_( + Exam.user_id == user.id, + Exam.status.in_(['completed', 'submitted']) + ) + ) + .order_by(Exam.updated_at.desc()) + .limit(20) + ) + result = await db.execute(exam_records_stmt) + exam_records = result.all() + + for exam, course_name in exam_records: + score = exam.round1_score or 0 + records.append({ + "date": exam.updated_at.strftime("%Y-%m-%d"), + "course": course_name, + "duration": exam.duration_minutes or 0, + "score": int(score), + "status": "completed" + }) + + data = { + "overview": overview, + "progressTrend": { + "dates": dates, + "data": progress_data + }, + "abilities": abilities, + "records": records[:20] + } + + return ResponseModel(code=200, message="success", data=data) + + except Exception as e: + logger.error(f"获取成员学习报告失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取成员学习报告失败: {str(e)}", + data=None + ) + diff --git a/backend/app/api/v1/teams.py b/backend/app/api/v1/teams.py new file mode 100644 index 0000000..cfaa82a --- /dev/null +++ b/backend/app/api/v1/teams.py @@ -0,0 +1,55 @@ +""" +团队相关 API 路由 +""" + +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_active_user as get_current_user, get_db +from app.core.logger import logger +from app.models.user import Team +from app.schemas.base import ResponseModel + + +router = APIRouter(prefix="/teams", tags=["teams"]) + + +@router.get("/", response_model=ResponseModel) +async def list_teams( + keyword: Optional[str] = Query(None, description="按名称或编码模糊搜索"), + current_user=Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取团队列表 + + 任何登录用户均可查询团队列表,用于前端下拉选择。 + """ + try: + stmt = select(Team).where(Team.is_deleted == False) # noqa: E712 + if keyword: + like = f"%{keyword}%" + stmt = stmt.where(or_(Team.name.ilike(like), Team.code.ilike(like))) + + rows: List[Team] = (await db.execute(stmt)).scalars().all() + data = [ + { + "id": t.id, + "name": t.name, + "code": t.code, + "team_type": t.team_type, + } + for t in rows + ] + return ResponseModel(code=200, message="OK", data=data) + except Exception: + logger.error("查询团队列表失败", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="查询团队列表失败", + ) + + diff --git a/backend/app/api/v1/training.py b/backend/app/api/v1/training.py new file mode 100644 index 0000000..a61d51b --- /dev/null +++ b/backend/app/api/v1/training.py @@ -0,0 +1,507 @@ +"""陪练模块API路由""" +import logging +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db, get_current_user, require_admin +from app.schemas.base import ResponseModel +from app.schemas.training import ( + TrainingSceneCreate, + TrainingSceneUpdate, + TrainingSceneResponse, + TrainingSessionResponse, + TrainingMessageResponse, + TrainingReportResponse, + StartTrainingRequest, + StartTrainingResponse, + EndTrainingRequest, + EndTrainingResponse, + TrainingSceneListQuery, + TrainingSessionListQuery, + PaginatedResponse, +) +from app.services.training_service import ( + TrainingSceneService, + TrainingSessionService, + TrainingMessageService, + TrainingReportService, +) +from app.models.training import TrainingSceneStatus, TrainingSessionStatus + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/training", tags=["陪练模块"]) + +# 服务实例 +scene_service = TrainingSceneService() +session_service = TrainingSessionService() +message_service = TrainingMessageService() +report_service = TrainingReportService() + + +# ========== 陪练场景管理 ========== + + +@router.get( + "/scenes", response_model=ResponseModel[PaginatedResponse[TrainingSceneResponse]] +) +async def get_training_scenes( + category: Optional[str] = Query(None, description="场景分类"), + status: Optional[TrainingSceneStatus] = Query(None, description="场景状态"), + is_public: Optional[bool] = Query(None, description="是否公开"), + search: Optional[str] = Query(None, description="搜索关键词"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取陪练场景列表 + + - 支持按分类、状态、是否公开筛选 + - 支持关键词搜索 + - 支持分页 + """ + try: + # 计算分页参数 + skip = (page - 1) * page_size + + # 获取用户等级(TODO: 从User服务获取) + user_level = 1 + + # 获取场景列表 + scenes = await scene_service.get_active_scenes( + db, + category=category, + is_public=is_public, + user_level=user_level, + skip=skip, + limit=page_size, + ) + + # 获取总数 + from sqlalchemy import select, func, and_ + from app.models.training import TrainingScene + + count_query = ( + select(func.count()) + .select_from(TrainingScene) + .where( + and_( + TrainingScene.status == TrainingSceneStatus.ACTIVE, + TrainingScene.is_deleted == False, + ) + ) + ) + + if category: + count_query = count_query.where(TrainingScene.category == category) + if is_public is not None: + count_query = count_query.where(TrainingScene.is_public == is_public) + + result = await db.execute(count_query) + total = result.scalar_one() + + # 计算总页数 + pages = (total + page_size - 1) // page_size + + return ResponseModel( + data=PaginatedResponse( + items=scenes, total=total, page=page, page_size=page_size, pages=pages + ), + message="获取陪练场景列表成功", + ) + + except Exception as e: + logger.error(f"获取陪练场景列表失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="获取陪练场景列表失败" + ) + + +@router.get("/scenes/{scene_id}", response_model=ResponseModel[TrainingSceneResponse]) +async def get_training_scene( + scene_id: int, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取陪练场景详情""" + scene = await scene_service.get(db, scene_id) + + if not scene or scene.is_deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练场景不存在") + + # 检查访问权限 + if not scene.is_public and current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此场景") + + return ResponseModel(data=scene, message="获取陪练场景成功") + + +@router.post("/scenes", response_model=ResponseModel[TrainingSceneResponse]) +async def create_training_scene( + scene_in: TrainingSceneCreate, + current_user: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 创建陪练场景(管理员) + + - 需要管理员权限 + - 场景默认为草稿状态 + """ + try: + scene = await scene_service.create_scene( + db, scene_in=scene_in, created_by=current_user["id"] + ) + + logger.info(f"管理员 {current_user['id']} 创建了陪练场景: {scene.id}") + + return ResponseModel(data=scene, message="创建陪练场景成功") + + except Exception as e: + logger.error(f"创建陪练场景失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="创建陪练场景失败" + ) + + +@router.put("/scenes/{scene_id}", response_model=ResponseModel[TrainingSceneResponse]) +async def update_training_scene( + scene_id: int, + scene_in: TrainingSceneUpdate, + current_user: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """更新陪练场景(管理员)""" + scene = await scene_service.update_scene( + db, scene_id=scene_id, scene_in=scene_in, updated_by=current_user["id"] + ) + + if not scene: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练场景不存在") + + logger.info(f"管理员 {current_user['id']} 更新了陪练场景: {scene_id}") + + return ResponseModel(data=scene, message="更新陪练场景成功") + + +@router.delete("/scenes/{scene_id}", response_model=ResponseModel[bool]) +async def delete_training_scene( + scene_id: int, + current_user: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """删除陪练场景(管理员)""" + success = await scene_service.soft_delete(db, id=scene_id) + + if not success: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练场景不存在") + + logger.info(f"管理员 {current_user['id']} 删除了陪练场景: {scene_id}") + + return ResponseModel(data=True, message="删除陪练场景成功") + + +# ========== 陪练会话管理 ========== + + +@router.post("/sessions", response_model=ResponseModel[StartTrainingResponse]) +async def start_training( + request: StartTrainingRequest, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 开始陪练会话 + + - 需要登录 + - 创建会话记录 + - 初始化Coze对话(如果配置了Bot) + - 返回会话信息和WebSocket连接地址(如果支持) + """ + try: + response = await session_service.start_training( + db, request=request, user_id=current_user["id"] + ) + + logger.info(f"用户 {current_user['id']} 开始陪练会话: {response.session_id}") + + return ResponseModel(data=response, message="开始陪练成功") + + except HTTPException: + raise + except Exception as e: + logger.error(f"开始陪练失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="开始陪练失败" + ) + + +@router.post( + "/sessions/{session_id}/end", response_model=ResponseModel[EndTrainingResponse] +) +async def end_training( + session_id: int, + request: EndTrainingRequest, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 结束陪练会话 + + - 需要登录且是会话创建者 + - 更新会话状态 + - 可选生成陪练报告 + """ + try: + response = await session_service.end_training( + db, session_id=session_id, request=request, user_id=current_user["id"] + ) + + logger.info(f"用户 {current_user['id']} 结束陪练会话: {session_id}") + + return ResponseModel(data=response, message="结束陪练成功") + + except HTTPException: + raise + except Exception as e: + logger.error(f"结束陪练失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="结束陪练失败" + ) + + +@router.get( + "/sessions", + response_model=ResponseModel[PaginatedResponse[TrainingSessionResponse]], +) +async def get_training_sessions( + scene_id: Optional[int] = Query(None, description="场景ID"), + status: Optional[TrainingSessionStatus] = Query(None, description="会话状态"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取用户的陪练会话列表""" + try: + skip = (page - 1) * page_size + + sessions = await session_service.get_user_sessions( + db, + user_id=current_user["id"], + scene_id=scene_id, + status=status, + skip=skip, + limit=page_size, + ) + + # 获取总数 + from sqlalchemy import select, func + from app.models.training import TrainingSession + + count_query = ( + select(func.count()) + .select_from(TrainingSession) + .where(TrainingSession.user_id == current_user["id"]) + ) + + if scene_id: + count_query = count_query.where(TrainingSession.scene_id == scene_id) + if status: + count_query = count_query.where(TrainingSession.status == status) + + result = await db.execute(count_query) + total = result.scalar_one() + + pages = (total + page_size - 1) // page_size + + # 加载关联的场景信息 + for session in sessions: + await db.refresh(session, ["scene"]) + + return ResponseModel( + data=PaginatedResponse( + items=sessions, total=total, page=page, page_size=page_size, pages=pages + ), + message="获取陪练会话列表成功", + ) + + except Exception as e: + logger.error(f"获取陪练会话列表失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="获取陪练会话列表失败" + ) + + +@router.get( + "/sessions/{session_id}", response_model=ResponseModel[TrainingSessionResponse] +) +async def get_training_session( + session_id: int, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取陪练会话详情""" + session = await session_service.get(db, session_id) + + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练会话不存在") + + # 检查访问权限 + if session.user_id != current_user["id"] and current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此会话") + + # 加载关联数据 + await db.refresh(session, ["scene"]) + + # 获取消息数量 + messages = await message_service.get_session_messages(db, session_id=session_id) + session.message_count = len(messages) + + return ResponseModel(data=session, message="获取陪练会话成功") + + +# ========== 消息管理 ========== + + +@router.get( + "/sessions/{session_id}/messages", + response_model=ResponseModel[List[TrainingMessageResponse]], +) +async def get_training_messages( + session_id: int, + skip: int = Query(0, ge=0, description="跳过数量"), + limit: int = Query(100, ge=1, le=500, description="返回数量"), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取陪练会话的消息列表""" + # 验证会话访问权限 + session = await session_service.get(db, session_id) + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练会话不存在") + + if session.user_id != current_user["id"] and current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此会话消息") + + messages = await message_service.get_session_messages( + db, session_id=session_id, skip=skip, limit=limit + ) + + return ResponseModel(data=messages, message="获取消息列表成功") + + +# ========== 报告管理 ========== + + +@router.get( + "/reports", response_model=ResponseModel[PaginatedResponse[TrainingReportResponse]] +) +async def get_training_reports( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取用户的陪练报告列表""" + try: + skip = (page - 1) * page_size + + reports = await report_service.get_user_reports( + db, user_id=current_user["id"], skip=skip, limit=page_size + ) + + # 获取总数 + from sqlalchemy import select, func + from app.models.training import TrainingReport + + count_query = ( + select(func.count()) + .select_from(TrainingReport) + .where(TrainingReport.user_id == current_user["id"]) + ) + + result = await db.execute(count_query) + total = result.scalar_one() + + pages = (total + page_size - 1) // page_size + + # 加载关联的会话信息 + for report in reports: + await db.refresh(report, ["session"]) + if report.session: + await db.refresh(report.session, ["scene"]) + + return ResponseModel( + data=PaginatedResponse( + items=reports, total=total, page=page, page_size=page_size, pages=pages + ), + message="获取陪练报告列表成功", + ) + + except Exception as e: + logger.error(f"获取陪练报告列表失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="获取陪练报告列表失败" + ) + + +@router.get( + "/reports/{report_id}", response_model=ResponseModel[TrainingReportResponse] +) +async def get_training_report( + report_id: int, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取陪练报告详情""" + report = await report_service.get(db, report_id) + + if not report: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练报告不存在") + + # 检查访问权限 + if report.user_id != current_user["id"] and current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此报告") + + # 加载关联数据 + await db.refresh(report, ["session"]) + if report.session: + await db.refresh(report.session, ["scene"]) + + return ResponseModel(data=report, message="获取陪练报告成功") + + +@router.get( + "/sessions/{session_id}/report", + response_model=ResponseModel[TrainingReportResponse], +) +async def get_session_report( + session_id: int, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """根据会话ID获取陪练报告""" + # 验证会话访问权限 + session = await session_service.get(db, session_id) + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练会话不存在") + + if session.user_id != current_user["id"] and current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此会话报告") + + # 获取报告 + report = await report_service.get_by_session(db, session_id=session_id) + + if not report: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="该会话暂无报告") + + # 加载关联数据 + await db.refresh(report, ["session"]) + if report.session: + await db.refresh(report.session, ["scene"]) + + return ResponseModel(data=report, message="获取会话报告成功") diff --git a/backend/app/api/v1/training_api_contract.yaml b/backend/app/api/v1/training_api_contract.yaml new file mode 100644 index 0000000..894a476 --- /dev/null +++ b/backend/app/api/v1/training_api_contract.yaml @@ -0,0 +1,854 @@ +openapi: 3.0.0 +info: + title: Training Module API + description: 考培练系统陪练模块API契约 + version: 1.0.0 + +servers: + - url: http://localhost:8000/api/v1 + description: 本地开发服务器 + +paths: + /training/scenes: + get: + summary: 获取陪练场景列表 + tags: + - 陪练场景 + security: + - bearerAuth: [] + parameters: + - name: category + in: query + description: 场景分类 + schema: + type: string + - name: status + in: query + description: 场景状态 + schema: + type: string + enum: [draft, active, inactive] + - name: is_public + in: query + description: 是否公开 + schema: + type: boolean + - name: search + in: query + description: 搜索关键词 + schema: + type: string + - name: page + in: query + description: 页码 + schema: + type: integer + minimum: 1 + default: 1 + - name: page_size + in: query + description: 每页数量 + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedScenesResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + post: + summary: 创建陪练场景(管理员) + tags: + - 陪练场景 + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingSceneCreate' + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingSceneResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /training/scenes/{scene_id}: + get: + summary: 获取陪练场景详情 + tags: + - 陪练场景 + security: + - bearerAuth: [] + parameters: + - name: scene_id + in: path + required: true + description: 场景ID + schema: + type: integer + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingSceneResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + put: + summary: 更新陪练场景(管理员) + tags: + - 陪练场景 + security: + - bearerAuth: [] + parameters: + - name: scene_id + in: path + required: true + description: 场景ID + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingSceneUpdate' + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingSceneResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + delete: + summary: 删除陪练场景(管理员) + tags: + - 陪练场景 + security: + - bearerAuth: [] + parameters: + - name: scene_id + in: path + required: true + description: 场景ID + schema: + type: integer + responses: + '200': + description: 成功 + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "删除陪练场景成功" + data: + type: boolean + example: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /training/sessions: + post: + summary: 开始陪练会话 + tags: + - 陪练会话 + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/StartTrainingRequest' + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/StartTrainingResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: 场景不存在 + + get: + summary: 获取用户的陪练会话列表 + tags: + - 陪练会话 + security: + - bearerAuth: [] + parameters: + - name: scene_id + in: query + description: 场景ID + schema: + type: integer + - name: status + in: query + description: 会话状态 + schema: + type: string + enum: [created, in_progress, completed, cancelled, error] + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: page_size + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedSessionsResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /training/sessions/{session_id}: + get: + summary: 获取陪练会话详情 + tags: + - 陪练会话 + security: + - bearerAuth: [] + parameters: + - name: session_id + in: path + required: true + description: 会话ID + schema: + type: integer + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingSessionResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /training/sessions/{session_id}/end: + post: + summary: 结束陪练会话 + tags: + - 陪练会话 + security: + - bearerAuth: [] + parameters: + - name: session_id + in: path + required: true + description: 会话ID + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EndTrainingRequest' + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/EndTrainingResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /training/sessions/{session_id}/messages: + get: + summary: 获取陪练会话的消息列表 + tags: + - 陪练消息 + security: + - bearerAuth: [] + parameters: + - name: session_id + in: path + required: true + description: 会话ID + schema: + type: integer + - name: skip + in: query + description: 跳过数量 + schema: + type: integer + minimum: 0 + default: 0 + - name: limit + in: query + description: 返回数量 + schema: + type: integer + minimum: 1 + maximum: 500 + default: 100 + responses: + '200': + description: 成功 + content: + application/json: + schema: + type: object + properties: + code: + type: integer + message: + type: string + data: + type: array + items: + $ref: '#/components/schemas/TrainingMessage' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /training/reports: + get: + summary: 获取用户的陪练报告列表 + tags: + - 陪练报告 + security: + - bearerAuth: [] + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: page_size + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedReportsResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /training/reports/{report_id}: + get: + summary: 获取陪练报告详情 + tags: + - 陪练报告 + security: + - bearerAuth: [] + parameters: + - name: report_id + in: path + required: true + description: 报告ID + schema: + type: integer + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingReportResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /training/sessions/{session_id}/report: + get: + summary: 根据会话ID获取陪练报告 + tags: + - 陪练报告 + security: + - bearerAuth: [] + parameters: + - name: session_id + in: path + required: true + description: 会话ID + schema: + type: integer + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingReportResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + responses: + Unauthorized: + description: 未授权 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + Forbidden: + description: 禁止访问 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + NotFound: + description: 资源未找到 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + schemas: + ErrorResponse: + type: object + properties: + code: + type: integer + message: + type: string + detail: + type: object + + BaseResponse: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "success" + request_id: + type: string + + PaginationMeta: + type: object + properties: + total: + type: integer + page: + type: integer + page_size: + type: integer + pages: + type: integer + + TrainingSceneCreate: + type: object + required: + - name + - category + properties: + name: + type: string + maxLength: 100 + description: 场景名称 + description: + type: string + description: 场景描述 + category: + type: string + maxLength: 50 + description: 场景分类 + ai_config: + type: object + description: AI配置 + prompt_template: + type: string + description: 提示词模板 + evaluation_criteria: + type: object + description: 评估标准 + is_public: + type: boolean + default: true + description: 是否公开 + required_level: + type: integer + description: 所需用户等级 + status: + type: string + enum: [draft, active, inactive] + default: draft + + TrainingSceneUpdate: + type: object + properties: + name: + type: string + maxLength: 100 + description: + type: string + category: + type: string + maxLength: 50 + ai_config: + type: object + prompt_template: + type: string + evaluation_criteria: + type: object + status: + type: string + enum: [draft, active, inactive] + is_public: + type: boolean + required_level: + type: integer + + TrainingScene: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + category: + type: string + ai_config: + type: object + prompt_template: + type: string + evaluation_criteria: + type: object + status: + type: string + enum: [draft, active, inactive] + is_public: + type: boolean + required_level: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + TrainingSceneResponse: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/TrainingScene' + + PaginatedScenesResponse: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/TrainingScene' + total: + type: integer + page: + type: integer + page_size: + type: integer + pages: + type: integer + + StartTrainingRequest: + type: object + required: + - scene_id + properties: + scene_id: + type: integer + description: 场景ID + config: + type: object + description: 会话配置 + + StartTrainingResponse: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + type: object + properties: + session_id: + type: integer + coze_conversation_id: + type: string + scene: + $ref: '#/components/schemas/TrainingScene' + websocket_url: + type: string + + EndTrainingRequest: + type: object + properties: + generate_report: + type: boolean + default: true + description: 是否生成报告 + + EndTrainingResponse: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + type: object + properties: + session: + $ref: '#/components/schemas/TrainingSession' + report: + $ref: '#/components/schemas/TrainingReport' + + TrainingSession: + type: object + properties: + id: + type: integer + user_id: + type: integer + scene_id: + type: integer + coze_conversation_id: + type: string + start_time: + type: string + format: date-time + end_time: + type: string + format: date-time + duration_seconds: + type: integer + status: + type: string + enum: [created, in_progress, completed, cancelled, error] + session_config: + type: object + total_score: + type: number + evaluation_result: + type: object + scene: + $ref: '#/components/schemas/TrainingScene' + message_count: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + TrainingSessionResponse: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/TrainingSession' + + PaginatedSessionsResponse: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/TrainingSession' + total: + type: integer + page: + type: integer + page_size: + type: integer + pages: + type: integer + + TrainingMessage: + type: object + properties: + id: + type: integer + session_id: + type: integer + role: + type: string + enum: [user, assistant, system] + type: + type: string + enum: [text, voice, system] + content: + type: string + voice_url: + type: string + voice_duration: + type: number + metadata: + type: object + coze_message_id: + type: string + created_at: + type: string + format: date-time + + TrainingReport: + type: object + properties: + id: + type: integer + session_id: + type: integer + user_id: + type: integer + overall_score: + type: number + dimension_scores: + type: object + additionalProperties: + type: number + strengths: + type: array + items: + type: string + weaknesses: + type: array + items: + type: string + suggestions: + type: array + items: + type: string + detailed_analysis: + type: string + transcript: + type: string + statistics: + type: object + session: + $ref: '#/components/schemas/TrainingSession' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + TrainingReportResponse: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/TrainingReport' + + PaginatedReportsResponse: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/TrainingReport' + total: + type: integer + page: + type: integer + page_size: + type: integer + pages: + type: integer diff --git a/backend/app/api/v1/upload.py b/backend/app/api/v1/upload.py new file mode 100644 index 0000000..2255de2 --- /dev/null +++ b/backend/app/api/v1/upload.py @@ -0,0 +1,275 @@ +""" +文件上传API接口 +""" +import os +import shutil +from pathlib import Path +from typing import List, Optional +from datetime import datetime +import hashlib + +from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.deps import get_current_user, get_db +from app.models.user import User +from app.models.course import Course +from app.schemas.base import ResponseModel +from app.core.logger import get_logger + +logger = get_logger(__name__) + +router = APIRouter(prefix="/upload") + +# 支持的文件类型和大小限制 +# 支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties +ALLOWED_EXTENSIONS = { + 'txt', 'md', 'mdx', 'pdf', 'html', 'htm', + 'xlsx', 'xls', 'docx', 'doc', 'csv', 'vtt', 'properties' +} +MAX_FILE_SIZE = 15 * 1024 * 1024 # 15MB + + +def get_file_extension(filename: str) -> str: + """获取文件扩展名""" + return filename.rsplit('.', 1)[1].lower() if '.' in filename else '' + + +def generate_unique_filename(original_filename: str) -> str: + """生成唯一的文件名""" + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + random_str = hashlib.md5(f"{original_filename}{timestamp}".encode()).hexdigest()[:8] + ext = get_file_extension(original_filename) + return f"{timestamp}_{random_str}.{ext}" + + +def get_upload_path(file_type: str = "general") -> Path: + """获取上传路径""" + base_path = Path(settings.UPLOAD_PATH) + upload_path = base_path / file_type + upload_path.mkdir(parents=True, exist_ok=True) + return upload_path + + +@router.post("/file", response_model=ResponseModel[dict]) +async def upload_file( + file: UploadFile = File(...), + file_type: str = "general", + current_user: User = Depends(get_current_user), +): + """ + 上传单个文件 + + - **file**: 要上传的文件 + - **file_type**: 文件类型分类(general, course, avatar等) + + 返回: + - **file_url**: 文件访问URL + - **file_name**: 原始文件名 + - **file_size**: 文件大小 + - **file_type**: 文件类型 + """ + try: + # 检查文件扩展名 + file_ext = get_file_extension(file.filename) + if file_ext not in ALLOWED_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"不支持的文件类型: {file_ext}" + ) + + # 读取文件内容 + contents = await file.read() + file_size = len(contents) + + # 检查文件大小 + if file_size > MAX_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"文件大小超过限制,最大允许 {MAX_FILE_SIZE // 1024 // 1024}MB" + ) + + # 生成唯一文件名 + unique_filename = generate_unique_filename(file.filename) + + # 获取上传路径 + upload_path = get_upload_path(file_type) + file_path = upload_path / unique_filename + + # 保存文件 + with open(file_path, "wb") as f: + f.write(contents) + + # 生成文件访问URL + file_url = f"/static/uploads/{file_type}/{unique_filename}" + + logger.info( + "文件上传成功", + user_id=current_user.id, + original_filename=file.filename, + saved_filename=unique_filename, + file_size=file_size, + file_type=file_type, + ) + + return ResponseModel( + data={ + "file_url": file_url, + "file_name": file.filename, + "file_size": file_size, + "file_type": file_ext, + }, + message="文件上传成功" + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"文件上传失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="文件上传失败" + ) + + +@router.post("/course/{course_id}/materials", response_model=ResponseModel[dict]) +async def upload_course_material( + course_id: int, + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 上传课程资料 + + - **course_id**: 课程ID + - **file**: 要上传的文件 + + 返回上传结果,包含文件URL等信息 + """ + try: + # 验证课程是否存在 + from sqlalchemy import select + from app.models.course import Course + + stmt = select(Course).where(Course.id == course_id, Course.is_deleted == False) + result = await db.execute(stmt) + course = result.scalar_one_or_none() + if not course: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"课程 {course_id} 不存在" + ) + + # 检查文件扩展名 + file_ext = get_file_extension(file.filename) + if file_ext not in ALLOWED_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"不支持的文件类型: {file_ext}" + ) + + # 读取文件内容 + contents = await file.read() + file_size = len(contents) + + # 检查文件大小 + if file_size > MAX_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"文件大小超过限制,最大允许 {MAX_FILE_SIZE // 1024 // 1024}MB" + ) + + # 生成唯一文件名 + unique_filename = generate_unique_filename(file.filename) + + # 创建课程专属目录 + course_upload_path = Path(settings.UPLOAD_PATH) / "courses" / str(course_id) + course_upload_path.mkdir(parents=True, exist_ok=True) + + # 保存文件 + file_path = course_upload_path / unique_filename + with open(file_path, "wb") as f: + f.write(contents) + + # 生成文件访问URL + file_url = f"/static/uploads/courses/{course_id}/{unique_filename}" + + logger.info( + "课程资料上传成功", + user_id=current_user.id, + course_id=course_id, + original_filename=file.filename, + saved_filename=unique_filename, + file_size=file_size, + ) + + return ResponseModel( + data={ + "file_url": file_url, + "file_name": file.filename, + "file_size": file_size, + "file_type": file_ext, + }, + message="课程资料上传成功" + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"课程资料上传失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="课程资料上传失败" + ) + + +@router.delete("/file", response_model=ResponseModel[bool]) +async def delete_file( + file_url: str, + current_user: User = Depends(get_current_user), +): + """ + 删除已上传的文件 + + - **file_url**: 文件URL路径 + """ + try: + # 解析文件路径 + if not file_url.startswith("/static/uploads/"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无效的文件URL" + ) + + # 转换为实际文件路径 + relative_path = file_url.replace("/static/uploads/", "") + file_path = Path(settings.UPLOAD_PATH) / relative_path + + # 检查文件是否存在 + if not file_path.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="文件不存在" + ) + + # 删除文件 + os.remove(file_path) + + logger.info( + "文件删除成功", + user_id=current_user.id, + file_url=file_url, + ) + + return ResponseModel(data=True, message="文件删除成功") + + except HTTPException: + raise + except Exception as e: + logger.error(f"文件删除失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="文件删除失败" + ) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..cdbf4dc --- /dev/null +++ b/backend/app/api/v1/users.py @@ -0,0 +1,474 @@ +""" +用户管理 API +""" + +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Query, status, Request +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_active_user, get_db, require_admin +from app.core.logger import logger +from app.models.user import User +from app.schemas.base import PaginatedResponse, PaginationParams, ResponseModel +from app.schemas.user import User as UserSchema +from app.schemas.user import UserCreate, UserFilter, UserPasswordUpdate, UserUpdate +from app.services.user_service import UserService +from app.services.system_log_service import system_log_service +from app.schemas.system_log import SystemLogCreate +from app.models.exam import Exam, ExamResult +from app.models.training import TrainingSession +from app.models.position_member import PositionMember +from app.models.position import Position +from app.models.course import Course + +router = APIRouter() + + +@router.get("/me", response_model=ResponseModel) +async def get_current_user_info( + current_user: dict = Depends(get_current_active_user), +) -> ResponseModel: + """ + 获取当前用户信息 + + 权限:需要登录 + """ + return ResponseModel(data=UserSchema.model_validate(current_user)) + + +@router.get("/me/statistics", response_model=ResponseModel) +async def get_current_user_statistics( + current_user: dict = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取当前用户学习统计 + + 返回字段: + - learningDays: 学习天数(按陪练会话开始日期去重) + - totalHours: 学习总时长(小时,取整到1位小数) + - practiceQuestions: 练习题数(答题记录条数汇总) + - averageScore: 平均成绩(已提交考试的平均分,保留1位小数) + - examsCompleted: 已完成考试数量 + """ + try: + user_id = current_user.id + + # 学习天数:按会话开始日期去重 + learning_days_stmt = select(func.count(func.distinct(func.date(TrainingSession.start_time)))).where( + TrainingSession.user_id == user_id + ) + learning_days = (await db.scalar(learning_days_stmt)) or 0 + + # 总时长(小时) + total_seconds_stmt = select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0)).where( + TrainingSession.user_id == user_id + ) + total_seconds = (await db.scalar(total_seconds_stmt)) or 0 + total_hours = round(float(total_seconds) / 3600.0, 1) if total_seconds else 0.0 + + # 练习题数:用户所有考试的题目总数 + practice_questions_stmt = ( + select(func.coalesce(func.sum(Exam.question_count), 0)) + .where(Exam.user_id == user_id, Exam.status == "completed") + ) + practice_questions = (await db.scalar(practice_questions_stmt)) or 0 + + # 平均成绩:用户已完成考试的平均分 + avg_score_stmt = select(func.avg(Exam.score)).where( + Exam.user_id == user_id, Exam.status == "completed" + ) + avg_score_val = await db.scalar(avg_score_stmt) + average_score = round(float(avg_score_val), 1) if avg_score_val is not None else 0.0 + + # 已完成考试数量 + exams_completed_stmt = select(func.count(Exam.id)).where( + Exam.user_id == user_id, + Exam.status == "completed" + ) + exams_completed = (await db.scalar(exams_completed_stmt)) or 0 + + return ResponseModel( + data={ + "learningDays": int(learning_days), + "totalHours": total_hours, + "practiceQuestions": int(practice_questions), + "averageScore": average_score, + "examsCompleted": int(exams_completed), + } + ) + except Exception as e: + logger.error("获取用户学习统计失败", exc_info=True) + raise HTTPException(status_code=500, detail=f"获取用户学习统计失败: {str(e)}") + + +@router.get("/me/recent-exams", response_model=ResponseModel) +async def get_recent_exams( + limit: int = Query(5, ge=1, le=20, description="返回数量"), + current_user: dict = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取当前用户最近的考试记录 + + 返回最近的考试列表,按创建时间降序排列 + 只返回已完成或已提交的考试(不包括started状态) + """ + try: + user_id = current_user.id + + # 查询最近的考试记录,关联课程表获取课程名称 + stmt = ( + select(Exam, Course.name.label("course_name")) + .join(Course, Exam.course_id == Course.id) + .where( + Exam.user_id == user_id, + Exam.status.in_(["completed", "submitted"]) + ) + .order_by(Exam.created_at.desc()) + .limit(limit) + ) + + results = await db.execute(stmt) + rows = results.all() + + # 构建返回数据 + exams_list = [] + for exam, course_name in rows: + exams_list.append({ + "id": exam.id, + "title": exam.exam_name, + "courseName": course_name, + "courseId": exam.course_id, + "time": exam.created_at.strftime("%Y-%m-%d %H:%M") if exam.created_at else "", + "questions": exam.question_count or 0, + "status": exam.status, + "score": exam.score + }) + + return ResponseModel(data=exams_list) + + except Exception as e: + logger.error("获取最近考试记录失败", exc_info=True) + raise HTTPException(status_code=500, detail=f"获取最近考试记录失败: {str(e)}") + + +@router.put("/me", response_model=ResponseModel) +async def update_current_user( + user_in: UserUpdate, + current_user: dict = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 更新当前用户信息 + + 权限:需要登录 + """ + user_service = UserService(db) + user = await user_service.update_user( + user_id=current_user.id, + obj_in=user_in, + updated_by=current_user.id, + ) + return ResponseModel(data=UserSchema.model_validate(user)) + + +@router.put("/me/password", response_model=ResponseModel) +async def update_current_user_password( + password_in: UserPasswordUpdate, + current_user: dict = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 更新当前用户密码 + + 权限:需要登录 + """ + user_service = UserService(db) + user = await user_service.update_password( + user_id=current_user.id, + old_password=password_in.old_password, + new_password=password_in.new_password, + ) + return ResponseModel(message="密码更新成功", data=UserSchema.model_validate(user)) + + +@router.get("/", response_model=ResponseModel) +async def get_users( + pagination: PaginationParams = Depends(), + role: str = Query(None, description="用户角色"), + is_active: bool = Query(None, description="是否激活"), + team_id: int = Query(None, description="团队ID"), + keyword: str = Query(None, description="搜索关键词"), + current_user: dict = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取用户列表 + + 权限:需要登录 + - 普通用户只能看到激活的用户 + - 管理员可以看到所有用户 + """ + # 构建筛选条件 + filter_params = UserFilter( + role=role, + is_active=is_active, + team_id=team_id, + keyword=keyword, + ) + + # 普通用户只能看到激活的用户 + if current_user.role == "trainee": + filter_params.is_active = True + + # 获取用户列表 + user_service = UserService(db) + users, total = await user_service.get_users_with_filter( + skip=pagination.offset, + limit=pagination.limit, + filter_params=filter_params, + ) + + # 构建分页响应 + paginated = PaginatedResponse.create( + items=[UserSchema.model_validate(user) for user in users], + total=total, + page=pagination.page, + page_size=pagination.page_size, + ) + + return ResponseModel(data=paginated.model_dump()) + + +@router.post("/", response_model=ResponseModel, status_code=status.HTTP_201_CREATED) +async def create_user( + user_in: UserCreate, + request: Request, + current_user: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 创建用户 + + 权限:需要管理员权限 + """ + user_service = UserService(db) + user = await user_service.create_user( + obj_in=user_in, + created_by=current_user.id, + ) + + logger.info( + "管理员创建用户", + admin_id=current_user.id, + admin_username=current_user.username, + new_user_id=user.id, + new_username=user.username, + ) + + # 记录用户创建日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="user", + message=f"管理员 {current_user.username} 创建用户: {user.username}", + user_id=current_user.id, + user=current_user.username, + ip=request.client.host if request.client else None, + path="/api/v1/users/", + method="POST", + user_agent=request.headers.get("user-agent") + ) + ) + + return ResponseModel(message="用户创建成功", data=UserSchema.model_validate(user)) + + +@router.get("/{user_id}", response_model=ResponseModel) +async def get_user( + user_id: int, + current_user: dict = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取用户详情 + + 权限:需要登录 + - 普通用户只能查看自己的信息 + - 管理员和经理可以查看所有用户信息 + """ + # 权限检查 + if current_user.role == "trainee" and current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="没有权限查看其他用户信息" + ) + + # 获取用户 + user_service = UserService(db) + user = await user_service.get_by_id(user_id) + + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") + + return ResponseModel(data=UserSchema.model_validate(user)) + + +@router.put("/{user_id}", response_model=ResponseModel) +async def update_user( + user_id: int, + user_in: UserUpdate, + current_user: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 更新用户信息 + + 权限:需要管理员权限 + """ + user_service = UserService(db) + user = await user_service.update_user( + user_id=user_id, + obj_in=user_in, + updated_by=current_user.id, + ) + + logger.info( + "管理员更新用户", + admin_id=current_user.id, + admin_username=current_user.username, + updated_user_id=user.id, + updated_username=user.username, + ) + + return ResponseModel(data=UserSchema.model_validate(user)) + + +@router.delete("/{user_id}", response_model=ResponseModel) +async def delete_user( + user_id: int, + request: Request, + current_user: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 删除用户(软删除) + + 权限:需要管理员权限 + """ + # 不能删除自己 + if user_id == current_user.id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能删除自己") + + # 获取用户 + user_service = UserService(db) + user = await user_service.get_by_id(user_id) + + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") + + # 软删除 + await user_service.soft_delete(db_obj=user) + + logger.info( + "管理员删除用户", + admin_id=current_user.id, + admin_username=current_user.username, + deleted_user_id=user.id, + deleted_username=user.username, + ) + + # 记录用户删除日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="user", + message=f"管理员 {current_user.username} 删除用户: {user.username}", + user_id=current_user.id, + user=current_user.username, + ip=request.client.host if request.client else None, + path=f"/api/v1/users/{user_id}", + method="DELETE", + user_agent=request.headers.get("user-agent") + ) + ) + + return ResponseModel(message="用户删除成功") + + +@router.post("/{user_id}/teams/{team_id}", response_model=ResponseModel) +async def add_user_to_team( + user_id: int, + team_id: int, + role: str = Query("member", regex="^(member|leader)$"), + current_user: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 将用户添加到团队 + + 权限:需要管理员权限 + """ + user_service = UserService(db) + await user_service.add_user_to_team( + user_id=user_id, + team_id=team_id, + role=role, + ) + + return ResponseModel(message="用户已添加到团队") + + +@router.delete("/{user_id}/teams/{team_id}", response_model=ResponseModel) +async def remove_user_from_team( + user_id: int, + team_id: int, + current_user: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 从团队中移除用户 + + 权限:需要管理员权限 + """ + user_service = UserService(db) + await user_service.remove_user_from_team( + user_id=user_id, + team_id=team_id, + ) + + return ResponseModel(message="用户已从团队中移除") + + +@router.get("/{user_id}/positions", response_model=ResponseModel) +async def get_user_positions( + user_id: int, + current_user: dict = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取用户所属岗位列表(用于前端展示与编辑) + + 权限:登录即可;普通用户仅能查看自己的信息 + 返回:[{id,name,code}] + """ + # 权限检查 + if current_user.role == "trainee" and current_user.id != user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="没有权限查看其他用户信息") + + stmt = ( + select(Position) + .join(PositionMember, PositionMember.position_id == Position.id) + .where(PositionMember.user_id == user_id, PositionMember.is_deleted == False, Position.is_deleted == False) + .order_by(Position.id) + ) + rows = (await db.execute(stmt)).scalars().all() + data = [ + {"id": p.id, "name": p.name, "code": p.code} + for p in rows + ] + return ResponseModel(data=data) diff --git a/backend/app/api/v1/yanji.py b/backend/app/api/v1/yanji.py new file mode 100644 index 0000000..b4d1f83 --- /dev/null +++ b/backend/app/api/v1/yanji.py @@ -0,0 +1,120 @@ +""" +言迹智能工牌API接口 +""" + +import logging +from typing import List + +from fastapi import APIRouter, Depends, Query + +from app.core.deps import get_current_user +from app.models.user import User +from app.schemas.base import ResponseModel +from app.schemas.yanji import ( + GetConversationsByVisitIdsResponse, + GetConversationsResponse, + YanjiConversation, +) +from app.services.yanji_service import YanjiService + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.post("/conversations/by-visit-ids", response_model=ResponseModel[GetConversationsByVisitIdsResponse]) +async def get_conversations_by_visit_ids( + external_visit_ids: List[str] = Query( + ..., + min_length=1, + max_length=10, + description="三方来访单ID列表(最多10个)", + ), + current_user: User = Depends(get_current_user), +): + """ + 根据来访单ID获取对话记录(ASR转写文字) + + 这是获取对话记录的主要接口,适用于: + 1. 已知来访单ID的场景 + 2. 获取特定对话记录用于AI评分 + 3. 批量获取多个对话记录 + """ + try: + yanji_service = YanjiService() + conversations = await yanji_service.get_conversations_by_visit_ids( + external_visit_ids=external_visit_ids + ) + + return ResponseModel( + code=200, + message="获取成功", + data=GetConversationsByVisitIdsResponse( + conversations=conversations, total=len(conversations) + ), + ) + + except Exception as e: + logger.error(f"获取对话记录失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取失败: {str(e)}", data=None) + + +@router.get("/conversations", response_model=ResponseModel[GetConversationsResponse]) +async def get_employee_conversations( + consultant_phone: str = Query(..., description="员工手机号"), + limit: int = Query(10, ge=1, le=100, description="获取数量"), + current_user: User = Depends(get_current_user), +): + """ + 获取员工最近的对话记录 + + 注意:目前此接口功能有限,因为言迹API没有直接通过员工手机号查询录音的接口。 + 推荐使用 /conversations/by-visit-ids 接口。 + + 后续可扩展: + 1. 先查询员工的来访单列表 + 2. 再获取这些来访单的对话记录 + """ + try: + yanji_service = YanjiService() + conversations = await yanji_service.get_recent_conversations( + consultant_phone=consultant_phone, limit=limit + ) + + return ResponseModel( + code=200, + message="获取成功", + data=GetConversationsResponse( + conversations=conversations, total=len(conversations) + ), + ) + + except Exception as e: + logger.error(f"获取员工对话记录失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取失败: {str(e)}", data=None) + + +@router.get("/test-auth") +async def test_yanji_auth(current_user: User = Depends(get_current_user)): + """ + 测试言迹API认证 + + 用于验证OAuth2.0认证是否正常工作 + """ + try: + yanji_service = YanjiService() + access_token = await yanji_service.get_access_token() + + return ResponseModel( + code=200, + message="认证成功", + data={ + "access_token": access_token[:20] + "...", # 只显示前20个字符 + "base_url": yanji_service.base_url, + }, + ) + + except Exception as e: + logger.error(f"言迹API认证失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"认证失败: {str(e)}", data=None) + diff --git a/backend/app/config/__init__.py b/backend/app/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config/database.py b/backend/app/config/database.py new file mode 100644 index 0000000..6c2a74e --- /dev/null +++ b/backend/app/config/database.py @@ -0,0 +1,49 @@ +"""数据库配置""" +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.pool import NullPool + +from app.core.config import get_settings + +settings = get_settings() + +# 创建异步引擎 +if settings.DEBUG: + # 开发环境使用 NullPool,不需要连接池参数 + engine = create_async_engine( + settings.DATABASE_URL, + echo=False, + pool_pre_ping=True, + poolclass=NullPool, + # 确保 MySQL 连接使用 UTF-8 字符集 + connect_args={ + "charset": "utf8mb4", + "use_unicode": True, + "autocommit": False, + "init_command": "SET character_set_client=utf8mb4, character_set_connection=utf8mb4, character_set_results=utf8mb4, collation_connection=utf8mb4_unicode_ci", + } if "mysql" in settings.DATABASE_URL else {}, + ) +else: + # 生产环境使用连接池 + engine = create_async_engine( + settings.DATABASE_URL, + echo=False, + pool_size=20, + max_overflow=0, + pool_pre_ping=True, + # 确保 MySQL 连接使用 UTF-8 字符集 + connect_args={ + "charset": "utf8mb4", + "use_unicode": True, + "autocommit": False, + "init_command": "SET character_set_client=utf8mb4, character_set_connection=utf8mb4, character_set_results=utf8mb4, collation_connection=utf8mb4_unicode_ci", + } if "mysql" in settings.DATABASE_URL else {}, + ) + +# 创建异步会话工厂 +SessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, +) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..a907119 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1,3 @@ +""" +核心功能模块 +""" diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..0354865 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,323 @@ +""" +系统配置 + +支持两种配置来源: +1. 环境变量 / .env 文件(传统方式,向后兼容) +2. 数据库 tenant_configs 表(新方式,支持热更新) + +配置优先级:数据库 > 环境变量 > 默认值 +""" + +import os +import json +from functools import lru_cache +from typing import Optional, Any + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """系统配置""" + + # 应用基础配置 + APP_NAME: str = "KaoPeiLian" + APP_VERSION: str = "1.0.0" + DEBUG: bool = Field(default=True) + + # 租户配置(用于多租户部署) + TENANT_CODE: str = Field(default="demo", description="租户编码,如 hua, yy, hl") + + # 服务器配置 + HOST: str = Field(default="0.0.0.0") + PORT: int = Field(default=8000) + + # 数据库配置 + DATABASE_URL: Optional[str] = Field(default=None) + MYSQL_HOST: str = Field(default="localhost") + MYSQL_PORT: int = Field(default=3306) + MYSQL_USER: str = Field(default="root") + MYSQL_PASSWORD: str = Field(default="password") + MYSQL_DATABASE: str = Field(default="kaopeilian") + + @property + def database_url(self) -> str: + """构建数据库连接URL""" + if self.DATABASE_URL: + return self.DATABASE_URL + + # 使用urllib.parse.quote_plus来正确编码特殊字符 + import urllib.parse + password = urllib.parse.quote_plus(self.MYSQL_PASSWORD) + + return f"mysql+aiomysql://{self.MYSQL_USER}:{password}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DATABASE}?charset=utf8mb4" + + # Redis配置 + REDIS_URL: str = Field(default="redis://localhost:6379/0") + + # JWT配置 + SECRET_KEY: str = Field(default="your-secret-key-here") + ALGORITHM: str = Field(default="HS256") + ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30) + REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7) + + # 跨域配置 + CORS_ORIGINS: list[str] = Field( + default=[ + "http://localhost:3000", + "http://localhost:3001", + "http://localhost:5173", + "http://127.0.0.1:3000", + "http://127.0.0.1:3001", + "http://127.0.0.1:5173", + ] + ) + + @field_validator('CORS_ORIGINS', mode='before') + @classmethod + def parse_cors_origins(cls, v): + """解析 CORS_ORIGINS 环境变量(支持 JSON 格式字符串)""" + if isinstance(v, str): + try: + return json.loads(v) + except json.JSONDecodeError: + # 如果不是 JSON 格式,尝试按逗号分割 + return [origin.strip() for origin in v.split(',')] + return v + + # 日志配置 + LOG_LEVEL: str = Field(default="INFO") + LOG_FORMAT: str = Field(default="text") # text 或 json + LOG_DIR: str = Field(default="logs") + + # 上传配置 + UPLOAD_DIR: str = Field(default="uploads") + MAX_UPLOAD_SIZE: int = Field(default=15 * 1024 * 1024) # 15MB + + @property + def UPLOAD_PATH(self) -> str: + """获取上传文件的完整路径""" + import os + return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), self.UPLOAD_DIR) + + # Coze 平台配置(陪练对话、播课等) + COZE_API_BASE: Optional[str] = Field(default="https://api.coze.cn") + COZE_WORKSPACE_ID: Optional[str] = Field(default=None) + COZE_API_TOKEN: Optional[str] = Field(default="pat_Sa5OiuUl0gDflnKstQTToIz0sSMshBV06diX0owOeuI1ZK1xDLH5YZH9fSeuKLIi") + COZE_TRAINING_BOT_ID: Optional[str] = Field(default=None) + COZE_CHAT_BOT_ID: Optional[str] = Field(default=None) + COZE_PRACTICE_BOT_ID: Optional[str] = Field(default="7560643598174683145") # 陪练专用Bot ID + # 播课工作流配置(多租户需在环境变量中覆盖,参见:应用配置清单.md) + COZE_BROADCAST_WORKFLOW_ID: str = Field(default="7577983042284486666") # 默认:演示版播课工作流 + COZE_BROADCAST_SPACE_ID: str = Field(default="7474971491470688296") # 播课工作流空间ID + COZE_BROADCAST_BOT_ID: Optional[str] = Field(default=None) # 播课工作流专用Bot ID + # OAuth配置(可选) + COZE_OAUTH_CLIENT_ID: Optional[str] = Field(default=None) + COZE_OAUTH_PUBLIC_KEY_ID: Optional[str] = Field(default=None) + COZE_OAUTH_PRIVATE_KEY_PATH: Optional[str] = Field(default=None) + + # WebSocket语音配置 + COZE_WS_BASE_URL: str = Field(default="wss://ws.coze.cn") + COZE_AUDIO_FORMAT: str = Field(default="pcm") # 音频格式 + COZE_SAMPLE_RATE: int = Field(default=16000) # 采样率(Hz) + COZE_AUDIO_CHANNELS: int = Field(default=1) # 声道数(单声道) + COZE_AUDIO_BIT_DEPTH: int = Field(default=16) # 位深度 + + # 服务器公开访问域名 + PUBLIC_DOMAIN: str = Field(default="http://aiedu.ireborn.com.cn") + + # 言迹智能工牌API配置 + YANJI_API_BASE: str = Field(default="https://open.yanjiai.com") # 正式环境 + YANJI_CLIENT_ID: str = Field(default="1Fld4LCWt2vpJNG5") + YANJI_CLIENT_SECRET: str = Field(default="XE8w413qNtJBOdWc2aCezV0yMIHpUuTZ") + YANJI_TENANT_ID: str = Field(default="516799409476866048") + YANJI_ESTATE_ID: str = Field(default="516799468310364162") + + # SCRM 系统对接 API Key(用于内部服务间调用) + SCRM_API_KEY: str = Field(default="scrm-kpl-api-key-2026-ruixiaomei") + + # AI 服务配置(知识点分析 V2 使用) + # 首选服务商:4sapi.com(国内优化) + AI_PRIMARY_API_KEY: str = Field(default="sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw") # 测试阶段 Key + AI_PRIMARY_BASE_URL: str = Field(default="https://4sapi.com/v1") + # 备选服务商:OpenRouter(模型全,稳定性好) + AI_FALLBACK_API_KEY: str = Field(default="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") # 测试阶段 Key + AI_FALLBACK_BASE_URL: str = Field(default="https://openrouter.ai/api/v1") + # 默认模型 + AI_DEFAULT_MODEL: str = Field(default="gemini-3-flash-preview") + # 请求超时(秒) + AI_TIMEOUT: float = Field(default=120.0) + + model_config = { + "env_file": ".env", + "env_file_encoding": "utf-8", + "case_sensitive": True, + "extra": "allow", # 允许额外的环境变量 + } + + +@lru_cache() +def get_settings() -> Settings: + """获取系统配置(缓存)""" + return Settings() + + +settings = get_settings() + + +# ============================================ +# 动态配置获取(支持从数据库读取) +# ============================================ + +class DynamicConfig: + """ + 动态配置管理器 + + 用于在运行时从数据库获取配置,支持热更新。 + 向后兼容:如果数据库不可用,回退到环境变量配置。 + """ + + _tenant_loader = None + _initialized = False + + @classmethod + async def init(cls, redis_url: Optional[str] = None): + """ + 初始化动态配置管理器 + + Args: + redis_url: Redis URL(可选,用于缓存) + """ + if cls._initialized: + return + + try: + from app.core.tenant_config import TenantConfigManager + + if redis_url: + await TenantConfigManager.init_redis(redis_url) + + cls._initialized = True + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"动态配置初始化失败: {e}") + + @classmethod + async def get(cls, key: str, default: Any = None, tenant_code: Optional[str] = None) -> Any: + """ + 获取配置值 + + Args: + key: 配置键(如 AI_PRIMARY_API_KEY) + default: 默认值 + tenant_code: 租户编码(可选,默认使用环境变量中的 TENANT_CODE) + + Returns: + 配置值 + """ + # 确定租户编码 + if tenant_code is None: + tenant_code = settings.TENANT_CODE + + # 配置键到分组的映射 + config_mapping = { + # 数据库 + "MYSQL_HOST": ("database", "MYSQL_HOST"), + "MYSQL_PORT": ("database", "MYSQL_PORT"), + "MYSQL_USER": ("database", "MYSQL_USER"), + "MYSQL_PASSWORD": ("database", "MYSQL_PASSWORD"), + "MYSQL_DATABASE": ("database", "MYSQL_DATABASE"), + # Redis + "REDIS_HOST": ("redis", "REDIS_HOST"), + "REDIS_PORT": ("redis", "REDIS_PORT"), + "REDIS_DB": ("redis", "REDIS_DB"), + # 安全 + "SECRET_KEY": ("security", "SECRET_KEY"), + "CORS_ORIGINS": ("security", "CORS_ORIGINS"), + # Coze + "COZE_PRACTICE_BOT_ID": ("coze", "COZE_PRACTICE_BOT_ID"), + "COZE_BROADCAST_WORKFLOW_ID": ("coze", "COZE_BROADCAST_WORKFLOW_ID"), + "COZE_BROADCAST_SPACE_ID": ("coze", "COZE_BROADCAST_SPACE_ID"), + "COZE_OAUTH_CLIENT_ID": ("coze", "COZE_OAUTH_CLIENT_ID"), + "COZE_OAUTH_PUBLIC_KEY_ID": ("coze", "COZE_OAUTH_PUBLIC_KEY_ID"), + # AI + "AI_PRIMARY_API_KEY": ("ai", "AI_PRIMARY_API_KEY"), + "AI_PRIMARY_BASE_URL": ("ai", "AI_PRIMARY_BASE_URL"), + "AI_FALLBACK_API_KEY": ("ai", "AI_FALLBACK_API_KEY"), + "AI_FALLBACK_BASE_URL": ("ai", "AI_FALLBACK_BASE_URL"), + "AI_DEFAULT_MODEL": ("ai", "AI_DEFAULT_MODEL"), + "AI_TIMEOUT": ("ai", "AI_TIMEOUT"), + # 言迹 + "YANJI_CLIENT_ID": ("yanji", "YANJI_CLIENT_ID"), + "YANJI_CLIENT_SECRET": ("yanji", "YANJI_CLIENT_SECRET"), + "YANJI_TENANT_ID": ("yanji", "YANJI_TENANT_ID"), + "YANJI_ESTATE_ID": ("yanji", "YANJI_ESTATE_ID"), + } + + # 尝试从数据库获取 + if cls._initialized and key in config_mapping: + try: + from app.core.tenant_config import TenantConfigManager + + config_group, config_key = config_mapping[key] + loader = TenantConfigManager.get_loader(tenant_code) + value = await loader.get_config(config_group, config_key) + + if value is not None: + return value + except Exception: + pass + + # 回退到环境变量 / Settings + env_value = getattr(settings, key, None) + if env_value is not None: + return env_value + + return default + + @classmethod + async def is_feature_enabled(cls, feature_code: str, tenant_code: Optional[str] = None) -> bool: + """ + 检查功能是否启用 + + Args: + feature_code: 功能编码 + tenant_code: 租户编码 + + Returns: + 是否启用 + """ + if tenant_code is None: + tenant_code = settings.TENANT_CODE + + if cls._initialized: + try: + from app.core.tenant_config import TenantConfigManager + + loader = TenantConfigManager.get_loader(tenant_code) + return await loader.is_feature_enabled(feature_code) + except Exception: + pass + + return True # 默认启用 + + @classmethod + async def refresh_cache(cls, tenant_code: Optional[str] = None): + """ + 刷新配置缓存 + + Args: + tenant_code: 租户编码(为空则刷新所有) + """ + if not cls._initialized: + return + + try: + from app.core.tenant_config import TenantConfigManager + + if tenant_code: + await TenantConfigManager.refresh_tenant_cache(tenant_code) + else: + await TenantConfigManager.refresh_all_cache() + except Exception: + pass diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..7ae09a6 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,31 @@ +""" +数据库配置 +""" + +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker + +from .config import settings + +# 创建异步引擎 +engine = create_async_engine( + settings.database_url, + echo=settings.DEBUG, + pool_pre_ping=True, + pool_size=10, + max_overflow=20, + # 确保 MySQL 连接使用 UTF-8 字符集 + connect_args={ + "charset": "utf8mb4", + "use_unicode": True, + "autocommit": False, + "init_command": "SET character_set_client=utf8mb4, character_set_connection=utf8mb4, character_set_results=utf8mb4, collation_connection=utf8mb4_unicode_ci", + } if "mysql" in settings.database_url else {}, +) + +# 创建异步会话工厂 +AsyncSessionLocal = sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..a4b488d --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,166 @@ +"""依赖注入模块""" +from typing import AsyncGenerator, Optional +from sqlalchemy import select +import redis.asyncio as redis + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import AsyncSessionLocal +from app.core.config import get_settings +from app.models.user import User + +# JWT Bearer认证 +security = HTTPBearer() + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """ + 获取数据库会话 + """ + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db), +) -> User: + """ + 获取当前用户(基于JWT) + + - 从 Authorization Bearer Token 中解析用户ID + - 查询数据库返回完整的 User 对象 + - 失败时抛出 401 未授权 + """ + from app.core.security import decode_token # 延迟导入避免循环依赖 + + if not credentials or not credentials.scheme or not credentials.credentials: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未提供认证信息") + + if credentials.scheme.lower() != "bearer": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="认证方式不支持") + + token = credentials.credentials + try: + payload = decode_token(token) + user_id = int(payload.get("sub")) + except Exception: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的令牌") + + result = await db.execute( + select(User).where(User.id == user_id, User.is_deleted == False) + ) + user = result.scalar_one_or_none() + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已被禁用" + ) + + return user + + +async def require_admin(current_user: User = Depends(get_current_user)) -> User: + """ + 需要管理员权限 + """ + if getattr(current_user, "role", None) != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限") + return current_user + + +async def require_admin_or_manager(current_user: User = Depends(get_current_user)) -> User: + """ + 需要管理者或管理员权限 + """ + if getattr(current_user, "role", None) not in ("admin", "manager"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理者或管理员权限") + return current_user + +async def get_optional_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + db: AsyncSession = Depends(get_db), +) -> Optional[User]: + """ + 获取可选的当前用户(不强制登录) + """ + if not credentials: + return None + + try: + return await get_current_user(credentials, db) + except: + return None + + +async def get_current_active_user( + current_user: User = Depends(get_current_user), +) -> User: + """ + 获取当前活跃用户 + """ + # TODO: 检查用户是否被禁用 + return current_user + + +async def verify_scrm_api_key( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> bool: + """ + 验证 SCRM 系统 API Key + + 用于内部服务间调用认证,SCRM 系统通过固定 API Key 访问考陪练数据查询接口 + 请求头格式: Authorization: Bearer {SCRM_API_KEY} + """ + settings = get_settings() + + if not credentials or not credentials.credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未提供认证信息" + ) + + if credentials.scheme.lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="认证方式不支持,需要 Bearer Token" + ) + + if credentials.credentials != settings.SCRM_API_KEY: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的 API Key" + ) + + return True + + +# Redis 连接池 +_redis_pool: Optional[redis.ConnectionPool] = None + + +async def get_redis() -> AsyncGenerator[redis.Redis, None]: + """ + 获取 Redis 连接 + """ + global _redis_pool + + if _redis_pool is None: + settings = get_settings() + _redis_pool = redis.ConnectionPool.from_url( + settings.REDIS_URL, encoding="utf-8", decode_responses=True + ) + + client = redis.Redis(connection_pool=_redis_pool) + try: + yield client + finally: + await client.close() diff --git a/backend/app/core/events.py b/backend/app/core/events.py new file mode 100644 index 0000000..8155045 --- /dev/null +++ b/backend/app/core/events.py @@ -0,0 +1,28 @@ +""" +应用生命周期事件处理 +""" +from app.core.logger import logger + + +async def startup_handler(): + """应用启动时执行的任务""" + logger.info("执行启动任务...") + + # TODO: 初始化数据库连接池 + # TODO: 初始化Redis连接 + # TODO: 初始化AI平台客户端 + # TODO: 加载缓存数据 + + logger.info("启动任务完成") + + +async def shutdown_handler(): + """应用关闭时执行的任务""" + logger.info("执行关闭任务...") + + # TODO: 关闭数据库连接池 + # TODO: 关闭Redis连接 + # TODO: 清理临时文件 + # TODO: 保存应用状态 + + logger.info("关闭任务完成") diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000..e4bf8b4 --- /dev/null +++ b/backend/app/core/exceptions.py @@ -0,0 +1,89 @@ +"""统一异常定义""" +from typing import Optional, Dict, Any +from fastapi import HTTPException, status + + +class BusinessError(HTTPException): + """业务异常基类""" + + def __init__( + self, + message: str, + code: int = status.HTTP_400_BAD_REQUEST, + error_code: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + ): + super().__init__( + status_code=code, + detail={ + "message": message, + "error_code": error_code or f"ERR_{code}", + "detail": detail, + }, + ) + self.message = message + self.code = code + self.error_code = error_code + + +class BadRequestError(BusinessError): + """400 错误请求""" + + def __init__(self, message: str = "错误的请求", **kwargs): + super().__init__(message, status.HTTP_400_BAD_REQUEST, **kwargs) + + +class UnauthorizedError(BusinessError): + """401 未授权""" + + def __init__(self, message: str = "未授权", **kwargs): + super().__init__(message, status.HTTP_401_UNAUTHORIZED, **kwargs) + + +class ForbiddenError(BusinessError): + """403 禁止访问""" + + def __init__(self, message: str = "禁止访问", **kwargs): + super().__init__(message, status.HTTP_403_FORBIDDEN, **kwargs) + + +class NotFoundError(BusinessError): + """404 未找到""" + + def __init__(self, message: str = "资源未找到", **kwargs): + super().__init__(message, status.HTTP_404_NOT_FOUND, **kwargs) + + +class ConflictError(BusinessError): + """409 冲突""" + + def __init__(self, message: str = "资源冲突", **kwargs): + super().__init__(message, status.HTTP_409_CONFLICT, **kwargs) + + +class ValidationError(BusinessError): + """422 验证错误""" + + def __init__(self, message: str = "验证失败", **kwargs): + super().__init__(message, status.HTTP_422_UNPROCESSABLE_ENTITY, **kwargs) + + +class InternalServerError(BusinessError): + """500 内部服务器错误""" + + def __init__(self, message: str = "内部服务器错误", **kwargs): + super().__init__(message, status.HTTP_500_INTERNAL_SERVER_ERROR, **kwargs) + + +class InsufficientPermissionsError(ForbiddenError): + """权限不足""" + + def __init__(self, message: str = "权限不足", **kwargs): + super().__init__(message, error_code="INSUFFICIENT_PERMISSIONS", **kwargs) + + +class ExternalServiceError(BusinessError): + """外部服务错误""" + + def __init__(self, message: str = "外部服务异常", **kwargs): + super().__init__(message, status.HTTP_502_BAD_GATEWAY, error_code="EXTERNAL_SERVICE_ERROR", **kwargs) diff --git a/backend/app/core/logger.py b/backend/app/core/logger.py new file mode 100644 index 0000000..044256b --- /dev/null +++ b/backend/app/core/logger.py @@ -0,0 +1,76 @@ +""" +日志配置 +""" +import logging +import sys +from typing import Any + +import structlog +from structlog.stdlib import LoggerFactory + +from app.core.config import get_settings + +settings = get_settings() + + +def setup_logging(): + """ + 配置日志系统 + """ + # 设置日志级别 + log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO) + + # 配置标准库日志 + logging.basicConfig( + format="%(message)s", + stream=sys.stdout, + level=log_level, + ) + + # 配置处理器 + processors = [ + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + ] + + # 根据配置选择输出格式 + if getattr(settings, "LOG_FORMAT", "text") == "json": + processors.append(structlog.processors.JSONRenderer()) + else: + processors.append(structlog.dev.ConsoleRenderer()) + + # 配置 structlog + structlog.configure( + processors=processors, + context_class=dict, + logger_factory=LoggerFactory(), + cache_logger_on_first_use=True, + ) + + +# 设置日志 +setup_logging() + + +# 获取日志器 +def get_logger(name: str = __name__) -> Any: + """ + 获取日志器 + + Args: + name: 日志器名称 + + Returns: + 日志器实例 + """ + return structlog.get_logger(name) + + +# 默认日志器 +logger = get_logger("app") diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py new file mode 100644 index 0000000..9a4232b --- /dev/null +++ b/backend/app/core/middleware.py @@ -0,0 +1,64 @@ +""" +中间件定义 +""" +import time +import uuid +from typing import Callable + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware + +from app.core.logger import logger + + +class RequestIDMiddleware(BaseHTTPMiddleware): + """请求ID中间件""" + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + # 生成请求ID + request_id = str(uuid.uuid4()) + + # 将请求ID添加到request状态 + request.state.request_id = request_id + + # 记录请求开始 + start_time = time.time() + + # 处理请求 + response = await call_next(request) + + # 计算处理时间 + process_time = time.time() - start_time + + # 添加响应头 + response.headers["X-Request-ID"] = request_id + response.headers["X-Process-Time"] = str(process_time) + + # 记录请求日志 + logger.info( + "HTTP请求", + method=request.method, + url=str(request.url), + status_code=response.status_code, + process_time=process_time, + request_id=request_id, + ) + + return response + + +class GlobalContextMiddleware(BaseHTTPMiddleware): + """全局上下文中间件""" + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + # 设置追踪ID(用于分布式追踪) + trace_id = request.headers.get("X-Trace-ID", str(uuid.uuid4())) + request.state.trace_id = trace_id + + # 处理请求 + response = await call_next(request) + + # 添加追踪ID到响应头 + response.headers["X-Trace-ID"] = trace_id + + return response diff --git a/backend/app/core/redis.py b/backend/app/core/redis.py new file mode 100644 index 0000000..e98c4a1 --- /dev/null +++ b/backend/app/core/redis.py @@ -0,0 +1,44 @@ +""" +Redis连接管理 +""" +from typing import Optional +from redis import asyncio as aioredis +from app.core.config import settings +from app.core.logger import logger + +# 全局Redis连接实例 +redis_client: Optional[aioredis.Redis] = None + + +async def init_redis() -> aioredis.Redis: + """初始化Redis连接""" + global redis_client + + try: + redis_client = await aioredis.from_url( + settings.REDIS_URL, encoding="utf-8", decode_responses=True + ) + # 测试连接 + await redis_client.ping() + logger.info("Redis连接成功", url=settings.REDIS_URL) + return redis_client + except Exception as e: + logger.error("Redis连接失败", error=str(e), url=settings.REDIS_URL) + raise + + +async def close_redis(): + """关闭Redis连接""" + global redis_client + + if redis_client: + await redis_client.close() + logger.info("Redis连接已关闭") + redis_client = None + + +def get_redis_client() -> aioredis.Redis: + """获取Redis客户端实例""" + if not redis_client: + raise RuntimeError("Redis client not initialized") + return redis_client diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..d5ec9d8 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,72 @@ +""" +安全相关功能 +""" + +from datetime import datetime, timedelta +from typing import Any, Dict, Optional, Union + +import bcrypt +from jose import JWTError, jwt + +from .config import settings + + +def create_access_token( + subject: Union[str, Any], + expires_delta: Optional[timedelta] = None, +) -> str: + """创建访问令牌""" + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + + to_encode = {"exp": expire, "sub": str(subject), "type": "access"} + encoded_jwt = jwt.encode( + to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM + ) + return encoded_jwt + + +def create_refresh_token( + subject: Union[str, Any], + expires_delta: Optional[timedelta] = None, +) -> str: + """创建刷新令牌""" + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + + to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"} + encoded_jwt = jwt.encode( + to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM + ) + return encoded_jwt + + +def decode_token(token: str) -> Dict[str, Any]: + """解码令牌""" + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + return payload + except JWTError: + raise ValueError("Invalid token") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """验证密码""" + return bcrypt.checkpw( + plain_password.encode("utf-8"), hashed_password.encode("utf-8") + ) + + +def get_password_hash(password: str) -> str: + """生成密码哈希""" + salt = bcrypt.gensalt() + hashed_password = bcrypt.hashpw(password.encode("utf-8"), salt) + return hashed_password.decode("utf-8") diff --git a/backend/app/core/simple_auth.py b/backend/app/core/simple_auth.py new file mode 100644 index 0000000..c614888 --- /dev/null +++ b/backend/app/core/simple_auth.py @@ -0,0 +1,81 @@ +""" +简化认证中间件 - 支持 API Key 和长期 Token +用于内部服务间调用 +""" +from typing import Optional +from fastapi import HTTPException, Header, status +from app.models.user import User + +# 配置 API Keys(用于内部服务调用) +API_KEYS = { + "internal-service-2025-kaopeilian": { + "service": "internal", + "user_id": 1, + "username": "internal_service", + "role": "admin" + } +} + +# 长期有效的 Token(用于内部服务调用) +LONG_TERM_TOKENS = { + "permanent-token-for-internal-2025": { + "service": "internal", + "user_id": 1, + "username": "internal_service", + "role": "admin" + } +} + + +def get_current_user_by_api_key( + x_api_key: Optional[str] = Header(None), + authorization: Optional[str] = Header(None) +) -> Optional[User]: + """ + 通过 API Key 或长期 Token 获取用户 + 支持两种方式: + 1. X-API-Key: internal-service-2025-kaopeilian + 2. Authorization: Bearer permanent-token-for-internal-2025 + """ + + # 方式1:检查 API Key + if x_api_key and x_api_key in API_KEYS: + api_key_info = API_KEYS[x_api_key] + # 创建一个虚拟用户对象 + user = User() + user.id = api_key_info["user_id"] + user.username = api_key_info["username"] + user.role = api_key_info["role"] + return user + + # 方式2:检查长期 Token + if authorization and authorization.startswith("Bearer "): + token = authorization.replace("Bearer ", "") + if token in LONG_TERM_TOKENS: + token_info = LONG_TERM_TOKENS[token] + user = User() + user.id = token_info["user_id"] + user.username = token_info["username"] + user.role = token_info["role"] + return user + + return None + + +def get_current_user_simple( + x_api_key: Optional[str] = Header(None), + authorization: Optional[str] = Header(None) +) -> User: + """ + 简化的用户认证依赖项 + """ + # 尝试 API Key 或长期 Token 认证 + user = get_current_user_by_api_key(x_api_key, authorization) + if user: + return user + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/backend/app/core/tenant_config.py b/backend/app/core/tenant_config.py new file mode 100644 index 0000000..fe7d8dd --- /dev/null +++ b/backend/app/core/tenant_config.py @@ -0,0 +1,421 @@ +""" +租户配置加载器 + +功能: +1. 从数据库 tenant_configs 表加载租户配置 +2. 支持 Redis 缓存 +3. 数据库不可用时回退到环境变量 +4. 支持配置热更新 +""" + +import os +import json +import logging +from typing import Optional, Dict, Any +from functools import lru_cache + +import aiomysql +import redis.asyncio as redis + +logger = logging.getLogger(__name__) + + +# ============================================ +# 平台管理库连接配置 +# +# 注意:敏感信息必须通过环境变量传递,禁止硬编码 +# 参考:瑞小美系统技术栈标准与字符标准.md - 敏感信息管理 +# ============================================ +ADMIN_DB_CONFIG = { + "host": os.getenv("ADMIN_DB_HOST", "prod-mysql"), + "port": int(os.getenv("ADMIN_DB_PORT", "3306")), + "user": os.getenv("ADMIN_DB_USER", "root"), + "password": os.getenv("ADMIN_DB_PASSWORD"), # 必须从环境变量获取 + "db": os.getenv("ADMIN_DB_NAME", "kaopeilian_admin"), + "charset": "utf8mb4", +} + +# 校验必填环境变量 +if not ADMIN_DB_CONFIG["password"]: + logger.warning( + "ADMIN_DB_PASSWORD 环境变量未设置,租户配置加载功能将不可用。" + "请在 .env.admin 文件中配置此变量。" + ) + +# Redis 缓存配置 +CACHE_PREFIX = "tenant_config:" +CACHE_TTL = 300 # 5分钟缓存 + + +class TenantConfigLoader: + """租户配置加载器""" + + def __init__(self, tenant_code: str, redis_client: Optional[redis.Redis] = None): + """ + 初始化租户配置加载器 + + Args: + tenant_code: 租户编码(如 hua, yy, hl) + redis_client: Redis 客户端(可选) + """ + self.tenant_code = tenant_code + self.redis_client = redis_client + self._config_cache: Dict[str, Any] = {} + self._tenant_id: Optional[int] = None + + async def get_config(self, config_group: str, config_key: str, default: Any = None) -> Any: + """ + 获取配置项 + + 优先级: + 1. 内存缓存 + 2. Redis 缓存 + 3. 数据库 + 4. 环境变量 + 5. 默认值 + + Args: + config_group: 配置分组(database, redis, coze, ai, yanji, security) + config_key: 配置键 + default: 默认值 + + Returns: + 配置值 + """ + cache_key = f"{config_group}.{config_key}" + + # 1. 内存缓存 + if cache_key in self._config_cache: + return self._config_cache[cache_key] + + # 2. Redis 缓存 + if self.redis_client: + try: + redis_key = f"{CACHE_PREFIX}{self.tenant_code}:{cache_key}" + cached_value = await self.redis_client.get(redis_key) + if cached_value: + value = json.loads(cached_value) + self._config_cache[cache_key] = value + return value + except Exception as e: + logger.warning(f"Redis 缓存读取失败: {e}") + + # 3. 数据库 + try: + value = await self._get_from_database(config_group, config_key) + if value is not None: + self._config_cache[cache_key] = value + # 写入 Redis 缓存 + if self.redis_client: + try: + redis_key = f"{CACHE_PREFIX}{self.tenant_code}:{cache_key}" + await self.redis_client.setex( + redis_key, + CACHE_TTL, + json.dumps(value) + ) + except Exception as e: + logger.warning(f"Redis 缓存写入失败: {e}") + return value + except Exception as e: + logger.warning(f"数据库配置读取失败: {e}") + + # 4. 环境变量 + env_value = os.getenv(config_key) + if env_value is not None: + return env_value + + # 5. 默认值 + return default + + async def _get_from_database(self, config_group: str, config_key: str) -> Optional[Any]: + """从数据库获取配置""" + conn = None + try: + conn = await aiomysql.connect(**ADMIN_DB_CONFIG) + async with conn.cursor(aiomysql.DictCursor) as cursor: + # 获取租户 ID + if self._tenant_id is None: + await cursor.execute( + "SELECT id FROM tenants WHERE code = %s AND status = 'active'", + (self.tenant_code,) + ) + row = await cursor.fetchone() + if row: + self._tenant_id = row['id'] + else: + return None + + # 获取配置值 + await cursor.execute( + """ + SELECT config_value, value_type, is_encrypted + FROM tenant_configs + WHERE tenant_id = %s AND config_group = %s AND config_key = %s + """, + (self._tenant_id, config_group, config_key) + ) + row = await cursor.fetchone() + + if row: + return self._parse_value(row['config_value'], row['value_type'], row['is_encrypted']) + + # 如果租户没有配置,获取默认值 + await cursor.execute( + """ + SELECT default_value, value_type + FROM config_templates + WHERE config_group = %s AND config_key = %s + """, + (config_group, config_key) + ) + row = await cursor.fetchone() + if row and row['default_value']: + return self._parse_value(row['default_value'], row['value_type'], False) + + return None + finally: + if conn: + conn.close() + + def _parse_value(self, value: str, value_type: str, is_encrypted: bool) -> Any: + """解析配置值""" + if value is None: + return None + + # TODO: 如果是加密值,先解密 + if is_encrypted: + # 这里可以实现解密逻辑 + pass + + if value_type == 'int': + return int(value) + elif value_type == 'bool': + return value.lower() in ('true', '1', 'yes') + elif value_type == 'json': + return json.loads(value) + elif value_type == 'float': + return float(value) + else: + return value + + async def get_all_configs(self) -> Dict[str, Any]: + """获取租户的所有配置""" + configs = {} + conn = None + try: + conn = await aiomysql.connect(**ADMIN_DB_CONFIG) + async with conn.cursor(aiomysql.DictCursor) as cursor: + # 获取租户 ID + await cursor.execute( + "SELECT id FROM tenants WHERE code = %s AND status = 'active'", + (self.tenant_code,) + ) + row = await cursor.fetchone() + if not row: + return configs + + tenant_id = row['id'] + + # 获取所有配置 + await cursor.execute( + """ + SELECT config_group, config_key, config_value, value_type, is_encrypted + FROM tenant_configs + WHERE tenant_id = %s + """, + (tenant_id,) + ) + rows = await cursor.fetchall() + + for row in rows: + key = f"{row['config_group']}.{row['config_key']}" + configs[key] = self._parse_value( + row['config_value'], + row['value_type'], + row['is_encrypted'] + ) + + return configs + finally: + if conn: + conn.close() + + async def refresh_cache(self): + """刷新缓存""" + self._config_cache.clear() + + if self.redis_client: + try: + # 删除该租户的所有缓存 + pattern = f"{CACHE_PREFIX}{self.tenant_code}:*" + cursor = 0 + while True: + cursor, keys = await self.redis_client.scan(cursor, match=pattern, count=100) + if keys: + await self.redis_client.delete(*keys) + if cursor == 0: + break + except Exception as e: + logger.warning(f"Redis 缓存刷新失败: {e}") + + async def is_feature_enabled(self, feature_code: str) -> bool: + """ + 检查功能是否启用 + + Args: + feature_code: 功能编码 + + Returns: + 是否启用 + """ + conn = None + try: + conn = await aiomysql.connect(**ADMIN_DB_CONFIG) + async with conn.cursor(aiomysql.DictCursor) as cursor: + # 获取租户 ID + if self._tenant_id is None: + await cursor.execute( + "SELECT id FROM tenants WHERE code = %s AND status = 'active'", + (self.tenant_code,) + ) + row = await cursor.fetchone() + if row: + self._tenant_id = row['id'] + + # 先查租户级别的配置 + if self._tenant_id: + await cursor.execute( + """ + SELECT is_enabled FROM feature_switches + WHERE tenant_id = %s AND feature_code = %s + """, + (self._tenant_id, feature_code) + ) + row = await cursor.fetchone() + if row: + return bool(row['is_enabled']) + + # 再查全局默认配置 + await cursor.execute( + """ + SELECT is_enabled FROM feature_switches + WHERE tenant_id IS NULL AND feature_code = %s + """, + (feature_code,) + ) + row = await cursor.fetchone() + if row: + return bool(row['is_enabled']) + + return True # 默认启用 + except Exception as e: + logger.warning(f"功能开关查询失败: {e}, 默认启用") + return True + finally: + if conn: + conn.close() + + +class TenantConfigManager: + """租户配置管理器(单例)""" + + _instances: Dict[str, TenantConfigLoader] = {} + _redis_client: Optional[redis.Redis] = None + + @classmethod + async def init_redis(cls, redis_url: str): + """初始化 Redis 连接""" + try: + cls._redis_client = redis.from_url(redis_url) + await cls._redis_client.ping() + logger.info("TenantConfigManager Redis 连接成功") + except Exception as e: + logger.warning(f"TenantConfigManager Redis 连接失败: {e}") + cls._redis_client = None + + @classmethod + def get_loader(cls, tenant_code: str) -> TenantConfigLoader: + """获取租户配置加载器""" + if tenant_code not in cls._instances: + cls._instances[tenant_code] = TenantConfigLoader( + tenant_code, + cls._redis_client + ) + return cls._instances[tenant_code] + + @classmethod + async def refresh_tenant_cache(cls, tenant_code: str): + """刷新指定租户的缓存""" + if tenant_code in cls._instances: + await cls._instances[tenant_code].refresh_cache() + + @classmethod + async def refresh_all_cache(cls): + """刷新所有租户的缓存""" + for loader in cls._instances.values(): + await loader.refresh_cache() + + +# ============================================ +# 辅助函数 +# ============================================ + +def get_tenant_code_from_domain(domain: str) -> str: + """ + 从域名提取租户编码 + + Examples: + hua.ireborn.com.cn -> hua + yy.ireborn.com.cn -> yy + aiedu.ireborn.com.cn -> demo + """ + if not domain: + return "demo" + + # 移除 https:// 或 http:// + domain = domain.replace("https://", "").replace("http://", "") + + # 获取子域名 + parts = domain.split(".") + if len(parts) >= 3: + subdomain = parts[0] + # 特殊处理 + if subdomain == "aiedu": + return "demo" + return subdomain + + return "demo" + + +async def get_tenant_config(tenant_code: str, config_group: str, config_key: str, default: Any = None) -> Any: + """ + 快捷函数:获取租户配置 + + Args: + tenant_code: 租户编码 + config_group: 配置分组 + config_key: 配置键 + default: 默认值 + + Returns: + 配置值 + """ + loader = TenantConfigManager.get_loader(tenant_code) + return await loader.get_config(config_group, config_key, default) + + +async def is_tenant_feature_enabled(tenant_code: str, feature_code: str) -> bool: + """ + 快捷函数:检查租户功能是否启用 + + Args: + tenant_code: 租户编码 + feature_code: 功能编码 + + Returns: + 是否启用 + """ + loader = TenantConfigManager.get_loader(tenant_code) + return await loader.is_feature_enabled(feature_code) + diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..de2cb8b --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,140 @@ +"""考培练系统后端主应用""" +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles +import json +import os + +from app.core.config import get_settings +from app.api.v1 import api_router + +# 配置日志 +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +settings = get_settings() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期管理""" + # 启动时执行 + logger.info(f"启动 {settings.APP_NAME} v{settings.APP_VERSION}") + + # 初始化 Redis + try: + from app.core.redis import init_redis, close_redis + await init_redis() + logger.info("Redis 初始化成功") + except Exception as e: + logger.warning(f"Redis 初始化失败(非致命): {e}") + + yield + + # 关闭时执行 + try: + from app.core.redis import close_redis + await close_redis() + logger.info("Redis 连接已关闭") + except Exception as e: + logger.warning(f"关闭 Redis 连接失败: {e}") + logger.info("应用关闭") + + +# 自定义 JSON 响应类,确保中文正确编码 +class UTF8JSONResponse(JSONResponse): + def render(self, content) -> bytes: + return json.dumps( + content, + ensure_ascii=False, + allow_nan=False, + indent=None, + separators=(",", ":"), + ).encode("utf-8") + +# 创建FastAPI应用 +app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + description="考培练系统后端API", + lifespan=lifespan, + # 确保响应正确的 UTF-8 编码 + default_response_class=UTF8JSONResponse, +) + +# 配置CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# 健康检查端点 +@app.get("/health") +async def health_check(): + """健康检查""" + return { + "status": "healthy", + "service": settings.APP_NAME, + "version": settings.APP_VERSION, + } + + +# 根路径 +@app.get("/") +async def root(): + """根路径""" + return { + "message": f"欢迎使用{settings.APP_NAME}", + "version": settings.APP_VERSION, + "docs": "/docs", + } + + +# 注册路由 +app.include_router(api_router, prefix="/api/v1") + +# 挂载静态文件目录 +# 创建上传目录(如果不存在) +upload_path = settings.UPLOAD_PATH +os.makedirs(upload_path, exist_ok=True) + +# 挂载上传文件目录为静态文件服务 +app.mount("/static/uploads", StaticFiles(directory=upload_path), name="uploads") + + +# 全局异常处理 +@app.exception_handler(Exception) +async def global_exception_handler(request, exc): + """全局异常处理""" + logger.error(f"未处理的异常: {exc}", exc_info=True) + return JSONResponse( + status_code=500, + content={ + "code": 500, + "message": "内部服务器错误", + "detail": str(exc) if settings.DEBUG else None, + }, + ) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "app.main:app", + host=settings.HOST, + port=settings.PORT, + reload=settings.DEBUG, + log_level=settings.LOG_LEVEL.lower(), + ) +# 测试热重载 - Fri Sep 26 03:37:07 CST 2025 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..c802572 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,49 @@ +"""数据库模型包""" +from app.models.base import Base, BaseModel +from app.models.user import User +from app.models.course import Course, CourseMaterial, KnowledgePoint, GrowthPath +from app.models.training import ( + TrainingScene, + TrainingSession, + TrainingMessage, + TrainingReport, +) +from app.models.exam import Exam, Question, ExamResult +from app.models.exam_mistake import ExamMistake +from app.models.position import Position +from app.models.position_member import PositionMember +from app.models.position_course import PositionCourse +from app.models.practice import PracticeScene, PracticeSession, PracticeDialogue, PracticeReport +from app.models.system_log import SystemLog +from app.models.task import Task, TaskCourse, TaskAssignment +from app.models.notification import Notification + +__all__ = [ + "Base", + "BaseModel", + "User", + "Course", + "CourseMaterial", + "KnowledgePoint", + "GrowthPath", + "TrainingScene", + "TrainingSession", + "TrainingMessage", + "TrainingReport", + "Exam", + "Question", + "ExamResult", + "ExamMistake", + "Position", + "PositionMember", + "PositionCourse", + "PracticeScene", + "PracticeSession", + "PracticeDialogue", + "PracticeReport", + "SystemLog", + "Task", + "TaskCourse", + "TaskAssignment", + "Notification", +] diff --git a/backend/app/models/ability.py b/backend/app/models/ability.py new file mode 100644 index 0000000..6af3936 --- /dev/null +++ b/backend/app/models/ability.py @@ -0,0 +1,64 @@ +""" +能力评估模型 +用于存储智能工牌数据分析、练习报告等产生的能力评估结果 +""" +from sqlalchemy import Column, Integer, String, DateTime, JSON, ForeignKey, Text +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.models.base import Base + + +class AbilityAssessment(Base): + """能力评估历史表""" + __tablename__ = "ability_assessments" + + id = Column(Integer, primary_key=True, index=True, comment='主键ID') + user_id = Column( + Integer, + ForeignKey('users.id', ondelete='CASCADE'), + nullable=False, + comment='用户ID' + ) + source_type = Column( + String(50), + nullable=False, + comment='数据来源: yanji_badge(智能工牌), practice_report(练习报告), manual(手动评估)' + ) + source_id = Column( + String(100), + comment='来源记录ID(如录音ID列表,逗号分隔)' + ) + total_score = Column( + Integer, + comment='综合评分(0-100)' + ) + ability_dimensions = Column( + JSON, + nullable=False, + comment='6个能力维度评分JSON数组' + ) + recommended_courses = Column( + JSON, + comment='推荐课程列表JSON数组' + ) + conversation_count = Column( + Integer, + comment='分析的对话数量' + ) + analyzed_at = Column( + DateTime, + server_default=func.now(), + comment='分析时间' + ) + created_at = Column( + DateTime, + server_default=func.now(), + comment='创建时间' + ) + + # 关系 + # user = relationship("User", back_populates="ability_assessments") + + def __repr__(self): + return f"" + diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..41841a4 --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,47 @@ +"""基础模型定义""" +from datetime import datetime +from typing import Optional + +from sqlalchemy import Column, DateTime, Integer, Boolean, func +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import Mapped, mapped_column + +# 创建基础模型类 +Base = declarative_base() + + +class BaseModel(Base): + """ + 基础模型类,所有模型都应继承此类 + 包含通用字段:id, created_at, updated_at + 时区:使用北京时间(Asia/Shanghai, UTC+8) + """ + + __abstract__ = True + __allow_unmapped__ = True # SQLAlchemy 2.0 兼容性 + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), nullable=False, comment="创建时间(北京时间)" + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now(), nullable=False, comment="更新时间(北京时间)" + ) + + +class SoftDeleteMixin: + """软删除混入类""" + + __allow_unmapped__ = True + + is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + +class AuditMixin: + """审计字段混入类""" + + __allow_unmapped__ = True + + created_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + updated_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) diff --git a/backend/app/models/course.py b/backend/app/models/course.py new file mode 100644 index 0000000..032e62b --- /dev/null +++ b/backend/app/models/course.py @@ -0,0 +1,270 @@ +""" +课程相关数据库模型 +""" +from enum import Enum +from typing import List, Optional +from datetime import datetime + +from sqlalchemy import ( + String, + Text, + Integer, + Boolean, + ForeignKey, + Enum as SQLEnum, + Float, + JSON, + DateTime, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import BaseModel, SoftDeleteMixin, AuditMixin + + +class CourseStatus(str, Enum): + """课程状态枚举""" + + DRAFT = "draft" # 草稿 + PUBLISHED = "published" # 已发布 + ARCHIVED = "archived" # 已归档 + + +class CourseCategory(str, Enum): + """课程分类枚举""" + + TECHNOLOGY = "technology" # 技术 + MANAGEMENT = "management" # 管理 + BUSINESS = "business" # 业务 + GENERAL = "general" # 通用 + + +class Course(BaseModel, SoftDeleteMixin, AuditMixin): + """ + 课程表 + """ + + __tablename__ = "courses" + + # 基本信息 + name: Mapped[str] = mapped_column(String(200), nullable=False, comment="课程名称") + description: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="课程描述" + ) + category: Mapped[CourseCategory] = mapped_column( + SQLEnum( + CourseCategory, + values_callable=lambda enum_cls: [e.value for e in enum_cls], + validate_strings=True, + ), + default=CourseCategory.GENERAL, + nullable=False, + comment="课程分类", + ) + status: Mapped[CourseStatus] = mapped_column( + SQLEnum( + CourseStatus, + values_callable=lambda enum_cls: [e.value for e in enum_cls], + validate_strings=True, + ), + default=CourseStatus.DRAFT, + nullable=False, + comment="课程状态", + ) + + # 课程详情 + cover_image: Mapped[Optional[str]] = mapped_column( + String(500), nullable=True, comment="封面图片URL" + ) + duration_hours: Mapped[Optional[float]] = mapped_column( + Float, nullable=True, comment="课程时长(小时)" + ) + difficulty_level: Mapped[Optional[int]] = mapped_column( + Integer, nullable=True, comment="难度等级(1-5)" + ) + tags: Mapped[Optional[List[str]]] = mapped_column( + JSON, nullable=True, comment="标签列表" + ) + + # 发布信息 + published_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True, comment="发布时间" + ) + publisher_id: Mapped[Optional[int]] = mapped_column( + Integer, nullable=True, comment="发布人ID" + ) + + # 播课信息 + # 播课功能(Coze工作流直接写数据库) + broadcast_audio_url: Mapped[Optional[str]] = mapped_column( + String(500), nullable=True, comment="播课音频URL" + ) + broadcast_generated_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True, comment="播课生成时间" + ) + + # 排序和权重 + sort_order: Mapped[int] = mapped_column( + Integer, default=0, nullable=False, comment="排序顺序" + ) + is_featured: Mapped[bool] = mapped_column( + Boolean, default=False, nullable=False, comment="是否推荐" + ) + + # 统计信息 + student_count: Mapped[int] = mapped_column( + Integer, default=0, nullable=False, comment="学习人数" + ) + is_new: Mapped[bool] = mapped_column( + Boolean, default=True, nullable=False, comment="是否新课程" + ) + + # 资料下载设置 + allow_download: Mapped[bool] = mapped_column( + Boolean, default=False, nullable=False, comment="是否允许下载资料" + ) + + # 关联关系 + materials: Mapped[List["CourseMaterial"]] = relationship( + "CourseMaterial", back_populates="course" + ) + knowledge_points: Mapped[List["KnowledgePoint"]] = relationship( + "KnowledgePoint", back_populates="course" + ) + + # 岗位分配关系(通过关联表) + position_assignments = relationship("PositionCourse", back_populates="course", cascade="all, delete-orphan") + exams = relationship("Exam", back_populates="course") + questions = relationship("Question", back_populates="course") + + +class CourseMaterial(BaseModel, SoftDeleteMixin, AuditMixin): + """ + 课程资料表 + """ + + __tablename__ = "course_materials" + + course_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("courses.id", ondelete="CASCADE"), + nullable=False, + comment="课程ID", + ) + name: Mapped[str] = mapped_column(String(200), nullable=False, comment="资料名称") + description: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="资料描述" + ) + file_url: Mapped[str] = mapped_column(String(500), nullable=False, comment="文件URL") + file_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="文件类型") + file_size: Mapped[int] = mapped_column(Integer, nullable=False, comment="文件大小(字节)") + + # 排序 + sort_order: Mapped[int] = mapped_column( + Integer, default=0, nullable=False, comment="排序顺序" + ) + + # 关联关系 + course: Mapped["Course"] = relationship("Course", back_populates="materials") + # 关联的知识点(直接关联) + knowledge_points: Mapped[List["KnowledgePoint"]] = relationship( + "KnowledgePoint", back_populates="material" + ) + + +class KnowledgePoint(BaseModel, SoftDeleteMixin, AuditMixin): + """ + 知识点表 + """ + + __tablename__ = "knowledge_points" + + course_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("courses.id", ondelete="CASCADE"), + nullable=False, + comment="课程ID", + ) + material_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("course_materials.id", ondelete="CASCADE"), + nullable=False, + comment="关联资料ID", + ) + name: Mapped[str] = mapped_column(String(200), nullable=False, comment="知识点名称") + description: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="知识点描述" + ) + type: Mapped[str] = mapped_column( + String(50), default="概念定义", nullable=False, comment="知识点类型" + ) + source: Mapped[int] = mapped_column( + Integer, default=0, nullable=False, comment="来源:0=手动,1=AI分析" + ) + topic_relation: Mapped[Optional[str]] = mapped_column( + String(200), nullable=True, comment="与主题的关系描述" + ) + + # 关联关系 + course: Mapped["Course"] = relationship("Course", back_populates="knowledge_points") + material: Mapped["CourseMaterial"] = relationship("CourseMaterial") + + +class GrowthPath(BaseModel, SoftDeleteMixin): + """ + 成长路径表 + """ + + __tablename__ = "growth_paths" + + name: Mapped[str] = mapped_column(String(200), nullable=False, comment="路径名称") + description: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="路径描述" + ) + target_role: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True, comment="目标角色" + ) + + # 路径配置 + courses: Mapped[Optional[List[dict]]] = mapped_column( + JSON, nullable=True, comment="课程列表[{course_id, order, is_required}]" + ) + + # 预计时长 + estimated_duration_days: Mapped[Optional[int]] = mapped_column( + Integer, nullable=True, comment="预计完成天数" + ) + + # 状态 + is_active: Mapped[bool] = mapped_column( + Boolean, default=True, nullable=False, comment="是否启用" + ) + sort_order: Mapped[int] = mapped_column( + Integer, default=0, nullable=False, comment="排序顺序" + ) + + +class MaterialKnowledgePoint(BaseModel, SoftDeleteMixin): + """ + 资料知识点关联表 + """ + + __tablename__ = "material_knowledge_points" + + material_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("course_materials.id", ondelete="CASCADE"), + nullable=False, + comment="资料ID", + ) + knowledge_point_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("knowledge_points.id", ondelete="CASCADE"), + nullable=False, + comment="知识点ID", + ) + sort_order: Mapped[int] = mapped_column( + Integer, default=0, nullable=False, comment="排序顺序" + ) + is_ai_generated: Mapped[bool] = mapped_column( + Boolean, default=False, nullable=False, comment="是否AI生成" + ) diff --git a/backend/app/models/course_exam_settings.py b/backend/app/models/course_exam_settings.py new file mode 100644 index 0000000..9eff324 --- /dev/null +++ b/backend/app/models/course_exam_settings.py @@ -0,0 +1,34 @@ +""" +课程考试设置模型 +""" +from sqlalchemy import Column, Integer, ForeignKey, Boolean +from sqlalchemy.orm import relationship +from app.models.base import BaseModel, SoftDeleteMixin, AuditMixin + + +class CourseExamSettings(BaseModel, SoftDeleteMixin, AuditMixin): + """课程考试设置表""" + __tablename__ = "course_exam_settings" + + course_id = Column(Integer, ForeignKey("courses.id"), unique=True, nullable=False, comment="课程ID") + + # 题型数量设置 + single_choice_count = Column(Integer, default=4, nullable=False, comment="单选题数量") + multiple_choice_count = Column(Integer, default=2, nullable=False, comment="多选题数量") + true_false_count = Column(Integer, default=1, nullable=False, comment="判断题数量") + fill_blank_count = Column(Integer, default=2, nullable=False, comment="填空题数量") + essay_count = Column(Integer, default=1, nullable=False, comment="问答题数量") + + # 考试参数设置 + duration_minutes = Column(Integer, default=10, nullable=False, comment="考试时长(分钟)") + difficulty_level = Column(Integer, default=3, nullable=False, comment="难度系数(1-5)") + passing_score = Column(Integer, default=60, nullable=False, comment="及格分数") + + # 其他设置 + is_enabled = Column(Boolean, default=True, nullable=False, comment="是否启用") + show_answer_immediately = Column(Boolean, default=False, nullable=False, comment="是否立即显示答案") + allow_retake = Column(Boolean, default=True, nullable=False, comment="是否允许重考") + max_retake_times = Column(Integer, default=3, nullable=True, comment="最大重考次数") + + # 关系 + course = relationship("Course", backref="exam_settings", uselist=False) diff --git a/backend/app/models/exam.py b/backend/app/models/exam.py new file mode 100644 index 0000000..4e803a1 --- /dev/null +++ b/backend/app/models/exam.py @@ -0,0 +1,153 @@ +""" +考试相关模型定义 +""" +from datetime import datetime +from typing import List, Optional +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Float, func +from sqlalchemy.orm import relationship, Mapped, mapped_column +from app.models.base import BaseModel + + +class Exam(BaseModel): + """考试记录模型""" + + __tablename__ = "exams" + __allow_unmapped__ = True + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False, index=True + ) + course_id: Mapped[int] = mapped_column( + Integer, ForeignKey("courses.id"), nullable=False, index=True + ) + + # 考试信息 + exam_name: Mapped[str] = mapped_column(String(255), nullable=False) + question_count: Mapped[int] = mapped_column(Integer, default=10) + total_score: Mapped[float] = mapped_column(Float, default=100.0) + pass_score: Mapped[float] = mapped_column(Float, default=60.0) + + # 考试时间 + start_time: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), comment="开始时间(北京时间)") + end_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="结束时间(北京时间)") + duration_minutes: Mapped[int] = mapped_column(Integer, default=60) # 考试时长(分钟) + + # 考试结果 + score: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + + # 三轮考试得分 + round1_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True, comment="第一轮得分") + round2_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True, comment="第二轮得分") + round3_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True, comment="第三轮得分") + + is_passed: Mapped[Optional[bool]] = mapped_column(nullable=True) + + # 考试状态: started, submitted, timeout + status: Mapped[str] = mapped_column(String(20), default="started", index=True) + + # 考试数据(JSON格式存储题目和答案) + questions: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + answers: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + + # 关系 + user = relationship("User", back_populates="exams") + course = relationship("Course", back_populates="exams") + results = relationship("ExamResult", back_populates="exam") + + def __repr__(self): + return f"" + + +class Question(BaseModel): + """题目模型""" + + __tablename__ = "questions" + __allow_unmapped__ = True + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + course_id: Mapped[int] = mapped_column( + Integer, ForeignKey("courses.id"), nullable=False, index=True + ) + + # 题目类型: single_choice, multiple_choice, true_false, fill_blank, essay + question_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + + # 题目内容 + title: Mapped[str] = mapped_column(Text, nullable=False) + content: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + # 选项(JSON格式,适用于选择题) + options: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + + # 答案 + correct_answer: Mapped[str] = mapped_column(Text, nullable=False) + explanation: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + # 分值 + score: Mapped[float] = mapped_column(Float, default=10.0) + + # 难度等级: easy, medium, hard + difficulty: Mapped[str] = mapped_column(String(10), default="medium", index=True) + + # 标签(JSON格式) + tags: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) + + # 使用统计 + usage_count: Mapped[int] = mapped_column(Integer, default=0) + correct_count: Mapped[int] = mapped_column(Integer, default=0) + + # 状态 + is_active: Mapped[bool] = mapped_column(default=True, index=True) + + # 关系 + course = relationship("Course", back_populates="questions") + + def __repr__(self): + return f"" + + +class ExamResult(BaseModel): + """考试结果详情模型""" + + __tablename__ = "exam_results" + __allow_unmapped__ = True + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + exam_id: Mapped[int] = mapped_column( + Integer, ForeignKey("exams.id"), nullable=False, index=True + ) + question_id: Mapped[int] = mapped_column( + Integer, ForeignKey("questions.id"), nullable=False + ) + + # 用户答案 + user_answer: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + # 是否正确 + is_correct: Mapped[bool] = mapped_column(default=False) + + # 得分 + score: Mapped[float] = mapped_column(Float, default=0.0) + + # 答题时长(秒) + answer_time: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + + # 关系 + exam = relationship("Exam", back_populates="results") + question = relationship("Question") + + def __repr__(self): + return f"" + + +# 在模型文件末尾添加关系定义 +# 需要在User模型中添加 +# exams = relationship("Exam", back_populates="user") + +# 需要在Course模型中添加 +# exams = relationship("Exam", back_populates="course") +# questions = relationship("Question", back_populates="course") + +# 需要在Exam模型中添加 +# results = relationship("ExamResult", back_populates="exam") diff --git a/backend/app/models/exam_mistake.py b/backend/app/models/exam_mistake.py new file mode 100644 index 0000000..69a999f --- /dev/null +++ b/backend/app/models/exam_mistake.py @@ -0,0 +1,43 @@ +""" +错题记录模型 +""" +from sqlalchemy import Column, Integer, ForeignKey, Text, DateTime, func +from sqlalchemy.orm import relationship +from datetime import datetime +from app.models.base import BaseModel + + +class ExamMistake(BaseModel): + """错题记录表""" + __tablename__ = "exam_mistakes" + + # 核心关联字段 + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, comment="用户ID") + exam_id = Column(Integer, ForeignKey("exams.id", ondelete="CASCADE"), nullable=False, index=True, comment="考试ID") + question_id = Column(Integer, ForeignKey("questions.id", ondelete="SET NULL"), nullable=True, index=True, comment="题目ID(AI生成的题目可能为空)") + knowledge_point_id = Column(Integer, ForeignKey("knowledge_points.id", ondelete="SET NULL"), nullable=True, index=True, comment="关联的知识点ID") + + # 题目核心信息 + question_content = Column(Text, nullable=False, comment="题目内容") + correct_answer = Column(Text, nullable=False, comment="正确答案") + user_answer = Column(Text, nullable=True, comment="用户答案") + question_type = Column(Text, nullable=True, index=True, comment="题型(single/multiple/judge/blank/essay)") + + # 掌握状态和统计字段 + mastery_status = Column(Text, nullable=False, default='unmastered', index=True, comment="掌握状态: unmastered-未掌握, mastered-已掌握") + difficulty = Column(Text, nullable=False, default='medium', index=True, comment="题目难度: easy-简单, medium-中等, hard-困难") + wrong_count = Column(Integer, nullable=False, default=1, comment="错误次数统计") + mastered_at = Column(DateTime, nullable=True, comment="标记掌握时间") + + # 审计字段(继承自BaseModel,但这里重写以匹配数据库实际结构) + created_at = Column(DateTime, nullable=False, server_default=func.now(), comment="创建时间(北京时间)") + updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now(), comment="更新时间(北京时间)") + + # 关系 + user = relationship("User", backref="exam_mistakes") + exam = relationship("Exam", backref="mistakes") + question = relationship("Question", backref="mistake_records") + knowledge_point = relationship("KnowledgePoint", backref="mistake_records") + + def __repr__(self): + return f"" diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..1e89f53 --- /dev/null +++ b/backend/app/models/notification.py @@ -0,0 +1,106 @@ +""" +站内消息通知模型 +用于记录用户的站内消息通知 +""" +from datetime import datetime +from typing import Optional +from sqlalchemy import String, Text, Integer, Boolean, Index, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import BaseModel + + +class Notification(BaseModel): + """ + 站内消息通知模型 + + 用于存储发送给用户的各类站内通知消息,如: + - 岗位分配通知 + - 课程分配通知 + - 考试提醒通知 + - 系统公告通知 + """ + __tablename__ = "notifications" + + # 接收用户ID(外键关联到users表) + user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="接收用户ID" + ) + + # 通知标题 + title: Mapped[str] = mapped_column( + String(200), + nullable=False, + comment="通知标题" + ) + + # 通知内容 + content: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + comment="通知内容" + ) + + # 通知类型 + # position_assign: 岗位分配 + # course_assign: 课程分配 + # exam_remind: 考试提醒 + # task_assign: 任务分配 + # system: 系统通知 + type: Mapped[str] = mapped_column( + String(50), + nullable=False, + default="system", + index=True, + comment="通知类型:position_assign/course_assign/exam_remind/task_assign/system" + ) + + # 是否已读 + is_read: Mapped[bool] = mapped_column( + Boolean, + default=False, + nullable=False, + index=True, + comment="是否已读" + ) + + # 关联数据ID(可选,如岗位ID、课程ID等) + related_id: Mapped[Optional[int]] = mapped_column( + Integer, + nullable=True, + comment="关联数据ID(岗位ID/课程ID等)" + ) + + # 关联数据类型(可选,如position、course等) + related_type: Mapped[Optional[str]] = mapped_column( + String(50), + nullable=True, + comment="关联数据类型" + ) + + # 发送者ID(可选,系统通知时为空) + sender_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + comment="发送者用户ID" + ) + + # 关联关系 + user = relationship("User", foreign_keys=[user_id], backref="notifications") + sender = relationship("User", foreign_keys=[sender_id]) + + # 创建索引以优化查询性能 + __table_args__ = ( + Index('idx_notifications_user_read', 'user_id', 'is_read'), + Index('idx_notifications_user_created', 'user_id', 'created_at'), + Index('idx_notifications_type', 'type'), + ) + + def __repr__(self): + return f"" + diff --git a/backend/app/models/position.py b/backend/app/models/position.py new file mode 100644 index 0000000..621933e --- /dev/null +++ b/backend/app/models/position.py @@ -0,0 +1,54 @@ +""" +岗位(Position)数据模型 +""" + +from typing import Optional +from sqlalchemy import String, Integer, Text, ForeignKey, Boolean, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import Optional, List + +from .base import BaseModel, SoftDeleteMixin, AuditMixin + + +class Position(BaseModel, SoftDeleteMixin, AuditMixin): + """ + 岗位表 + + 字段说明: + - name: 岗位名称 + - code: 岗位编码(唯一),用于稳定引用 + - description: 岗位描述 + - parent_id: 上级岗位ID,支持树形结构 + - status: 状态(active/inactive) + """ + + __tablename__ = "positions" + __allow_unmapped__ = True + + name: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("positions.id", ondelete="SET NULL")) + status: Mapped[str] = mapped_column(String(20), default="active", nullable=False) + + # 新增字段 + skills: Mapped[Optional[List]] = mapped_column(JSON, nullable=True, comment="核心技能") + level: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, comment="岗位等级") + sort_order: Mapped[Optional[int]] = mapped_column(Integer, default=0, nullable=True, comment="排序") + + # 关系 + parent: Mapped[Optional["Position"]] = relationship( + "Position", remote_side="Position.id", backref="children", lazy="selectin" + ) + + # 成员关系(通过关联表) + members = relationship("PositionMember", back_populates="position", cascade="all, delete-orphan") + + # 课程关系(通过关联表) + courses = relationship("PositionCourse", back_populates="position", cascade="all, delete-orphan") + + def __repr__(self) -> str: + return f"" + + diff --git a/backend/app/models/position_course.py b/backend/app/models/position_course.py new file mode 100644 index 0000000..c5caadb --- /dev/null +++ b/backend/app/models/position_course.py @@ -0,0 +1,28 @@ +""" +岗位课程关联模型 +""" +from datetime import datetime +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean, Enum, UniqueConstraint +from sqlalchemy.orm import relationship +from app.models.base import BaseModel, SoftDeleteMixin + + +class PositionCourse(BaseModel, SoftDeleteMixin): + """岗位课程关联表""" + __tablename__ = "position_courses" + + # 添加唯一约束:同一岗位下同一课程只能有一条有效记录 + __table_args__ = ( + UniqueConstraint('position_id', 'course_id', 'is_deleted', name='uix_position_course'), + ) + + position_id = Column(Integer, ForeignKey("positions.id"), nullable=False, comment="岗位ID") + course_id = Column(Integer, ForeignKey("courses.id"), nullable=False, comment="课程ID") + + # 课程类型:required(必修)、optional(选修) + course_type = Column(String(20), default="required", nullable=False, comment="课程类型") + priority = Column(Integer, default=0, comment="优先级/排序") + + # 关系 + position = relationship("Position", back_populates="courses") + course = relationship("Course", back_populates="position_assignments") diff --git a/backend/app/models/position_member.py b/backend/app/models/position_member.py new file mode 100644 index 0000000..af37256 --- /dev/null +++ b/backend/app/models/position_member.py @@ -0,0 +1,26 @@ +""" +岗位成员关联模型 +""" +from datetime import datetime +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean, UniqueConstraint, func +from sqlalchemy.orm import relationship +from app.models.base import BaseModel, SoftDeleteMixin + + +class PositionMember(BaseModel, SoftDeleteMixin): + """岗位成员关联表""" + __tablename__ = "position_members" + + # 添加唯一约束:同一岗位下同一用户只能有一条有效记录 + __table_args__ = ( + UniqueConstraint('position_id', 'user_id', 'is_deleted', name='uix_position_user'), + ) + + position_id = Column(Integer, ForeignKey("positions.id"), nullable=False, comment="岗位ID") + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, comment="用户ID") + role = Column(String(50), comment="成员角色(预留字段)") + joined_at = Column(DateTime, server_default=func.now(), comment="加入时间(北京时间)") + + # 关系 + position = relationship("Position", back_populates="members") + user = relationship("User", back_populates="position_memberships") diff --git a/backend/app/models/practice.py b/backend/app/models/practice.py new file mode 100644 index 0000000..1d607ad --- /dev/null +++ b/backend/app/models/practice.py @@ -0,0 +1,109 @@ +""" +陪练场景模型 +""" +from sqlalchemy import Column, Integer, String, Text, JSON, DECIMAL, Boolean, DateTime, ForeignKey +from sqlalchemy.sql import func +from app.models.base import Base + + +class PracticeScene(Base): + """陪练场景模型""" + __tablename__ = "practice_scenes" + + id = Column(Integer, primary_key=True, index=True, comment="场景ID") + name = Column(String(200), nullable=False, comment="场景名称") + description = Column(Text, comment="场景描述") + type = Column(String(50), nullable=False, index=True, comment="场景类型: phone/face/complaint/after-sales/product-intro") + difficulty = Column(String(50), nullable=False, index=True, comment="难度等级: beginner/junior/intermediate/senior/expert") + status = Column(String(20), default="active", index=True, comment="状态: active/inactive") + background = Column(Text, comment="场景背景设定") + ai_role = Column(Text, comment="AI角色描述") + objectives = Column(JSON, comment="练习目标数组") + keywords = Column(JSON, comment="关键词数组") + duration = Column(Integer, default=10, comment="预计时长(分钟)") + usage_count = Column(Integer, default=0, comment="使用次数") + rating = Column(DECIMAL(3, 1), default=0.0, comment="评分") + + # 审计字段 + created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="创建人ID") + updated_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="更新人ID") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + # 软删除字段 + is_deleted = Column(Boolean, default=False, index=True, comment="是否删除") + deleted_at = Column(DateTime, comment="删除时间") + + def __repr__(self): + return f"" + + +class PracticeSession(Base): + """陪练会话模型""" + __tablename__ = "practice_sessions" + + id = Column(Integer, primary_key=True, index=True, comment="会话ID") + session_id = Column(String(50), unique=True, nullable=False, index=True, comment="会话唯一标识") + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, comment="学员ID") + scene_id = Column(Integer, ForeignKey("practice_scenes.id", ondelete="SET NULL"), comment="场景ID") + scene_name = Column(String(200), comment="场景名称") + scene_type = Column(String(50), comment="场景类型") + conversation_id = Column(String(100), comment="Coze对话ID") + + # 会话时间信息 + start_time = Column(DateTime, nullable=False, index=True, comment="开始时间") + end_time = Column(DateTime, comment="结束时间") + duration_seconds = Column(Integer, default=0, comment="时长(秒)") + turns = Column(Integer, default=0, comment="对话轮次") + status = Column(String(20), default="in_progress", index=True, comment="状态: in_progress/completed/canceled") + + # 审计字段 + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + is_deleted = Column(Boolean, default=False, comment="是否删除") + + def __repr__(self): + return f"" + + +class PracticeDialogue(Base): + """陪练对话记录模型""" + __tablename__ = "practice_dialogues" + + id = Column(Integer, primary_key=True, index=True, comment="对话ID") + session_id = Column(String(50), nullable=False, index=True, comment="会话ID") + speaker = Column(String(20), nullable=False, comment="说话人: user/ai") + content = Column(Text, nullable=False, comment="对话内容") + timestamp = Column(DateTime, nullable=False, comment="时间戳") + sequence = Column(Integer, nullable=False, comment="顺序号") + + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + + def __repr__(self): + return f"" + + +class PracticeReport(Base): + """陪练分析报告模型""" + __tablename__ = "practice_reports" + + id = Column(Integer, primary_key=True, index=True, comment="报告ID") + session_id = Column(String(50), unique=True, nullable=False, index=True, comment="会话ID") + + # AI分析结果 + total_score = Column(Integer, comment="综合得分(0-100)") + score_breakdown = Column(JSON, comment="分数细分") + ability_dimensions = Column(JSON, comment="能力维度") + dialogue_review = Column(JSON, comment="对话复盘") + suggestions = Column(JSON, comment="改进建议") + + # AI分析元数据 + workflow_run_id = Column(String(100), comment="AI分析运行ID") + task_id = Column(String(100), comment="AI分析任务ID") + + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + def __repr__(self): + return f"" + diff --git a/backend/app/models/system_log.py b/backend/app/models/system_log.py new file mode 100644 index 0000000..1c85ba4 --- /dev/null +++ b/backend/app/models/system_log.py @@ -0,0 +1,60 @@ +""" +系统日志模型 +用于记录系统操作、错误、安全事件等日志信息 +""" +from datetime import datetime +from sqlalchemy import Column, Integer, String, Text, DateTime, Index +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BaseModel + + +class SystemLog(BaseModel): + """ + 系统日志模型 + 记录系统各类操作日志 + """ + __tablename__ = "system_logs" + + # 日志级别: debug, info, warning, error + level: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + + # 日志类型: system, user, api, error, security + type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + + # 操作用户(可能为空,如系统自动操作) + user: Mapped[str] = mapped_column(String(100), nullable=True, index=True) + + # 用户ID(可能为空) + user_id: Mapped[int] = mapped_column(Integer, nullable=True, index=True) + + # IP地址 + ip: Mapped[str] = mapped_column(String(100), nullable=True) + + # 日志消息 + message: Mapped[str] = mapped_column(Text, nullable=False) + + # User Agent + user_agent: Mapped[str] = mapped_column(String(500), nullable=True) + + # 请求路径(API路径) + path: Mapped[str] = mapped_column(String(500), nullable=True, index=True) + + # 请求方法 + method: Mapped[str] = mapped_column(String(10), nullable=True) + + # 额外数据(JSON格式,可存储详细信息) + extra_data: Mapped[str] = mapped_column(Text, nullable=True) + + # 创建索引以优化查询性能 + __table_args__ = ( + Index('idx_system_logs_created_at', 'created_at'), + Index('idx_system_logs_level_type', 'level', 'type'), + Index('idx_system_logs_user_created', 'user', 'created_at'), + ) + + def __repr__(self): + return f"" + + + diff --git a/backend/app/models/task.py b/backend/app/models/task.py new file mode 100644 index 0000000..ecf568f --- /dev/null +++ b/backend/app/models/task.py @@ -0,0 +1,100 @@ +""" +任务相关模型 +""" +from datetime import datetime +from typing import List, Optional +from sqlalchemy import Column, Integer, String, Text, DateTime, Enum as SQLEnum, JSON, Boolean, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.models.base import BaseModel +from enum import Enum + + +class TaskPriority(str, Enum): + """任务优先级""" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class TaskStatus(str, Enum): + """任务状态""" + PENDING = "pending" # 待开始 + ONGOING = "ongoing" # 进行中 + COMPLETED = "completed" # 已完成 + EXPIRED = "expired" # 已过期 + + +class AssignmentStatus(str, Enum): + """分配状态""" + NOT_STARTED = "not_started" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + + +class Task(BaseModel): + """任务表""" + __tablename__ = "tasks" + + title: Mapped[str] = mapped_column(String(200), nullable=False, comment="任务标题") + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="任务描述") + priority: Mapped[TaskPriority] = mapped_column( + SQLEnum(TaskPriority, values_callable=lambda x: [e.value for e in x]), + default=TaskPriority.MEDIUM, + nullable=False, + comment="优先级" + ) + status: Mapped[TaskStatus] = mapped_column( + SQLEnum(TaskStatus, values_callable=lambda x: [e.value for e in x]), + default=TaskStatus.PENDING, + nullable=False, + comment="任务状态" + ) + creator_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, comment="创建人ID") + deadline: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="截止时间") + requirements: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="任务要求配置") + progress: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="完成进度") + is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + # 关系 + creator = relationship("User", backref="created_tasks", foreign_keys=[creator_id]) + course_links = relationship("TaskCourse", back_populates="task", cascade="all, delete-orphan") + assignments = relationship("TaskAssignment", back_populates="task", cascade="all, delete-orphan") + + +class TaskCourse(BaseModel): + """任务课程关联表""" + __tablename__ = "task_courses" + + task_id: Mapped[int] = mapped_column(Integer, ForeignKey("tasks.id"), nullable=False, comment="任务ID") + course_id: Mapped[int] = mapped_column(Integer, ForeignKey("courses.id"), nullable=False, comment="课程ID") + + # 关系 + task = relationship("Task", back_populates="course_links") + course = relationship("Course") + + +class TaskAssignment(BaseModel): + """任务分配表""" + __tablename__ = "task_assignments" + + task_id: Mapped[int] = mapped_column(Integer, ForeignKey("tasks.id"), nullable=False, comment="任务ID") + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, comment="分配用户ID") + team_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, comment="团队ID") + status: Mapped[AssignmentStatus] = mapped_column( + SQLEnum(AssignmentStatus, values_callable=lambda x: [e.value for e in x]), + default=AssignmentStatus.NOT_STARTED, + nullable=False, + comment="完成状态" + ) + progress: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="个人完成进度") + completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="完成时间") + + # 关系 + task = relationship("Task", back_populates="assignments") + user = relationship("User") + + +__all__ = ["Task", "TaskCourse", "TaskAssignment", "TaskPriority", "TaskStatus", "AssignmentStatus"] + + + diff --git a/backend/app/models/training.py b/backend/app/models/training.py new file mode 100644 index 0000000..2f3716b --- /dev/null +++ b/backend/app/models/training.py @@ -0,0 +1,263 @@ +"""陪练模块数据模型""" +from datetime import datetime +from typing import Optional +from enum import Enum + +from sqlalchemy import ( + Column, + String, + Integer, + ForeignKey, + Text, + JSON, + Enum as SQLEnum, + Float, + Boolean, + DateTime, + func, +) +from sqlalchemy.orm import relationship, Mapped, mapped_column + +from app.models.base import BaseModel, SoftDeleteMixin, AuditMixin + + +class TrainingSceneStatus(str, Enum): + """陪练场景状态枚举""" + + DRAFT = "draft" # 草稿 + ACTIVE = "active" # 已激活 + INACTIVE = "inactive" # 已停用 + + +class TrainingSessionStatus(str, Enum): + """陪练会话状态枚举""" + + CREATED = "created" # 已创建 + IN_PROGRESS = "in_progress" # 进行中 + COMPLETED = "completed" # 已完成 + CANCELLED = "cancelled" # 已取消 + ERROR = "error" # 异常结束 + + +class MessageType(str, Enum): + """消息类型枚举""" + + TEXT = "text" # 文本消息 + VOICE = "voice" # 语音消息 + SYSTEM = "system" # 系统消息 + + +class MessageRole(str, Enum): + """消息角色枚举""" + + USER = "user" # 用户 + ASSISTANT = "assistant" # AI助手 + SYSTEM = "system" # 系统 + + +class TrainingScene(BaseModel, SoftDeleteMixin, AuditMixin): + """ + 陪练场景模型 + 定义不同的陪练场景,如面试训练、演讲训练等 + """ + + __tablename__ = "training_scenes" + __allow_unmapped__ = True + + # 基础信息 + name: Mapped[str] = mapped_column(String(100), nullable=False, comment="场景名称") + description: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="场景描述" + ) + category: Mapped[str] = mapped_column(String(50), nullable=False, comment="场景分类") + + # 配置信息 + ai_config: Mapped[Optional[dict]] = mapped_column( + JSON, nullable=True, comment="AI配置(如Coze Bot ID等)" + ) + prompt_template: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="提示词模板" + ) + evaluation_criteria: Mapped[Optional[dict]] = mapped_column( + JSON, nullable=True, comment="评估标准" + ) + + # 状态和权限 + status: Mapped[TrainingSceneStatus] = mapped_column( + SQLEnum(TrainingSceneStatus), + default=TrainingSceneStatus.DRAFT, + nullable=False, + comment="场景状态", + ) + is_public: Mapped[bool] = mapped_column( + Boolean, default=True, nullable=False, comment="是否公开" + ) + required_level: Mapped[Optional[int]] = mapped_column( + Integer, nullable=True, comment="所需用户等级" + ) + + # 关联 + sessions: Mapped[list["TrainingSession"]] = relationship( + "TrainingSession", back_populates="scene", cascade="all, delete-orphan" + ) + + +class TrainingSession(BaseModel, AuditMixin): + """ + 陪练会话模型 + 记录每次陪练会话的信息 + """ + + __tablename__ = "training_sessions" + __allow_unmapped__ = True + + # 基础信息 + user_id: Mapped[int] = mapped_column( + Integer, nullable=False, index=True, comment="用户ID" + ) + scene_id: Mapped[int] = mapped_column( + Integer, ForeignKey("training_scenes.id"), nullable=False, comment="场景ID" + ) + + # 会话信息 + coze_conversation_id: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True, comment="Coze会话ID" + ) + start_time: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), nullable=False, comment="开始时间(北京时间)" + ) + end_time: Mapped[Optional[datetime]] = mapped_column( + DateTime, nullable=True, comment="结束时间(北京时间)" + ) + duration_seconds: Mapped[Optional[int]] = mapped_column( + Integer, nullable=True, comment="持续时长(秒)" + ) + + # 状态和配置 + status: Mapped[TrainingSessionStatus] = mapped_column( + SQLEnum(TrainingSessionStatus), + default=TrainingSessionStatus.CREATED, + nullable=False, + comment="会话状态", + ) + session_config: Mapped[Optional[dict]] = mapped_column( + JSON, nullable=True, comment="会话配置" + ) + + # 评估信息 + total_score: Mapped[Optional[float]] = mapped_column( + Float, nullable=True, comment="总分" + ) + evaluation_result: Mapped[Optional[dict]] = mapped_column( + JSON, nullable=True, comment="评估结果详情" + ) + + # 关联 + scene: Mapped["TrainingScene"] = relationship( + "TrainingScene", back_populates="sessions" + ) + messages: Mapped[list["TrainingMessage"]] = relationship( + "TrainingMessage", + back_populates="session", + cascade="all, delete-orphan", + order_by="TrainingMessage.created_at", + ) + report: Mapped[Optional["TrainingReport"]] = relationship( + "TrainingReport", back_populates="session", uselist=False + ) + + +class TrainingMessage(BaseModel): + """ + 陪练消息模型 + 记录会话中的每条消息 + """ + + __tablename__ = "training_messages" + __allow_unmapped__ = True + + # 基础信息 + session_id: Mapped[int] = mapped_column( + Integer, ForeignKey("training_sessions.id"), nullable=False, comment="会话ID" + ) + + # 消息内容 + role: Mapped[MessageRole] = mapped_column( + SQLEnum(MessageRole), nullable=False, comment="消息角色" + ) + type: Mapped[MessageType] = mapped_column( + SQLEnum(MessageType), nullable=False, comment="消息类型" + ) + content: Mapped[str] = mapped_column(Text, nullable=False, comment="消息内容") + + # 语音消息相关 + voice_url: Mapped[Optional[str]] = mapped_column( + String(500), nullable=True, comment="语音文件URL" + ) + voice_duration: Mapped[Optional[float]] = mapped_column( + Float, nullable=True, comment="语音时长(秒)" + ) + + # 元数据 + message_metadata: Mapped[Optional[dict]] = mapped_column( + JSON, nullable=True, comment="消息元数据" + ) + coze_message_id: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True, comment="Coze消息ID" + ) + + # 关联 + session: Mapped["TrainingSession"] = relationship( + "TrainingSession", back_populates="messages" + ) + + +class TrainingReport(BaseModel, AuditMixin): + """ + 陪练报告模型 + 存储陪练会话的分析报告 + """ + + __tablename__ = "training_reports" + __allow_unmapped__ = True + + # 基础信息 + session_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("training_sessions.id"), + unique=True, + nullable=False, + comment="会话ID", + ) + user_id: Mapped[int] = mapped_column( + Integer, nullable=False, index=True, comment="用户ID" + ) + + # 评分信息 + overall_score: Mapped[float] = mapped_column(Float, nullable=False, comment="总体得分") + dimension_scores: Mapped[dict] = mapped_column( + JSON, nullable=False, comment="各维度得分" + ) + + # 分析内容 + strengths: Mapped[list[str]] = mapped_column(JSON, nullable=False, comment="优势点") + weaknesses: Mapped[list[str]] = mapped_column(JSON, nullable=False, comment="待改进点") + suggestions: Mapped[list[str]] = mapped_column(JSON, nullable=False, comment="改进建议") + + # 详细内容 + detailed_analysis: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="详细分析" + ) + transcript: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="对话文本记录" + ) + + # 统计信息 + statistics: Mapped[Optional[dict]] = mapped_column( + JSON, nullable=True, comment="统计数据" + ) + + # 关联 + session: Mapped["TrainingSession"] = relationship( + "TrainingSession", back_populates="report" + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..66f8293 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,171 @@ +""" +用户相关数据模型 +""" + +from datetime import datetime +from typing import List, Optional + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + String, + Table, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base, BaseModel, SoftDeleteMixin + +# 用户-团队关联表(用于多对多关系) +user_teams = Table( + "user_teams", + BaseModel.metadata, + Column( + "user_id", Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ), + Column( + "team_id", Integer, ForeignKey("teams.id", ondelete="CASCADE"), primary_key=True + ), + Column("role", String(50), default="member", nullable=False), # member, leader + Column("joined_at", DateTime, server_default=func.now(), nullable=False), + UniqueConstraint("user_id", "team_id", name="uq_user_team"), +) + + +class UserTeam(Base): + """用户团队关联模型(用于直接查询关联表)""" + + __allow_unmapped__ = True + __table__ = user_teams # 重用已定义的表 + + # 定义列映射(不需要id,因为使用复合主键) + user_id: Mapped[int] + team_id: Mapped[int] + role: Mapped[str] + joined_at: Mapped[datetime] + + def __repr__(self) -> str: + return f"" + + +class User(BaseModel, SoftDeleteMixin): + """用户模型""" + + __allow_unmapped__ = True + + __tablename__ = "users" + + # 基础信息 + username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + email: Mapped[Optional[str]] = mapped_column(String(100), unique=True, nullable=True) + phone: Mapped[Optional[str]] = mapped_column(String(20), unique=True, nullable=True) + hashed_password: Mapped[str] = mapped_column( + "password_hash", String(200), nullable=False + ) + + # 个人信息 + full_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + avatar_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + bio: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + # 性别: male/female(可扩展) + gender: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) + # 学校 + school: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + # 专业 + major: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + # 企微员工userid(用于SCRM系统对接) + wework_userid: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, comment="企微员工userid") + + # 系统角色:admin, manager, trainee + role: Mapped[str] = mapped_column(String(20), default="trainee", nullable=False) + + # 账号状态 + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + # 时间记录 + last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + password_changed_at: Mapped[Optional[datetime]] = mapped_column( + DateTime, nullable=True + ) + + # 关联关系 + teams: Mapped[List["Team"]] = relationship( + "Team", + secondary=user_teams, + back_populates="members", + lazy="selectin", + ) + exams = relationship("Exam", back_populates="user") + + # 岗位关系(通过关联表) + position_memberships = relationship("PositionMember", back_populates="user", cascade="all, delete-orphan") + + def __repr__(self) -> str: + return f"" + + +class Team(BaseModel, SoftDeleteMixin): + """团队模型""" + + __allow_unmapped__ = True + + __tablename__ = "teams" + + # 基础信息 + name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + # 团队类型:department, project, study_group + team_type: Mapped[str] = mapped_column( + String(50), default="department", nullable=False + ) + + # 状态 + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + # 团队负责人 + leader_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + + # 父团队(支持层级结构) + parent_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("teams.id", ondelete="CASCADE"), nullable=True + ) + + # 关联关系 + members: Mapped[List["User"]] = relationship( + "User", + secondary=user_teams, + back_populates="teams", + lazy="selectin", + ) + + leader: Mapped[Optional["User"]] = relationship( + "User", + foreign_keys=[leader_id], + lazy="selectin", + ) + + parent: Mapped[Optional["Team"]] = relationship( + "Team", + remote_side="Team.id", + foreign_keys=[parent_id], + lazy="selectin", + ) + + children: Mapped[List["Team"]] = relationship( + "Team", + back_populates="parent", + lazy="selectin", + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..b45dd2c --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic模式包""" diff --git a/backend/app/schemas/ability.py b/backend/app/schemas/ability.py new file mode 100644 index 0000000..22ddca8 --- /dev/null +++ b/backend/app/schemas/ability.py @@ -0,0 +1,50 @@ +""" +能力评估相关的Pydantic Schema +""" +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime + + +class AbilityDimension(BaseModel): + """能力维度评分""" + name: str = Field(..., description="能力维度名称") + score: int = Field(..., ge=0, le=100, description="评分(0-100)") + feedback: str = Field(..., description="反馈建议") + + +class CourseRecommendation(BaseModel): + """课程推荐""" + course_id: int = Field(..., description="课程ID") + course_name: str = Field(..., description="课程名称") + recommendation_reason: str = Field(..., description="推荐理由") + priority: str = Field(..., description="优先级: high/medium/low") + match_score: int = Field(..., ge=0, le=100, description="匹配度(0-100)") + + +class AbilityAssessmentResponse(BaseModel): + """能力评估响应""" + assessment_id: int = Field(..., description="评估记录ID") + total_score: int = Field(..., ge=0, le=100, description="综合评分") + dimensions: List[AbilityDimension] = Field(..., description="能力维度列表") + recommended_courses: List[CourseRecommendation] = Field(..., description="推荐课程列表") + conversation_count: int = Field(..., description="分析的对话数量") + analyzed_at: Optional[datetime] = Field(None, description="分析时间") + + +class AbilityAssessmentHistory(BaseModel): + """能力评估历史记录""" + id: int + user_id: int + source_type: str + source_id: Optional[str] + total_score: Optional[int] + ability_dimensions: List[AbilityDimension] + recommended_courses: Optional[List[CourseRecommendation]] + conversation_count: Optional[int] + analyzed_at: datetime + created_at: datetime + + class Config: + from_attributes = True + diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..94a4809 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,35 @@ +""" +认证相关 Schema +""" +from pydantic import EmailStr, Field + +from .base import BaseSchema + + +class LoginRequest(BaseSchema): + """登录请求""" + + username: str = Field(..., description="用户名/邮箱/手机号") + password: str = Field(..., min_length=6) + + +class Token(BaseSchema): + """令牌响应""" + + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class TokenPayload(BaseSchema): + """令牌载荷""" + + sub: str # 用户ID + type: str # access 或 refresh + exp: int # 过期时间 + + +class RefreshTokenRequest(BaseSchema): + """刷新令牌请求""" + + refresh_token: str diff --git a/backend/app/schemas/base.py b/backend/app/schemas/base.py new file mode 100644 index 0000000..05abea7 --- /dev/null +++ b/backend/app/schemas/base.py @@ -0,0 +1,73 @@ +"""基础响应模式""" +from typing import Generic, TypeVar, Optional, Any, List +from pydantic import BaseModel, Field +from datetime import datetime + +DataT = TypeVar("DataT") + + +class ResponseModel(BaseModel, Generic[DataT]): + """ + 统一响应格式模型 + """ + + code: int = Field(default=200, description="响应状态码") + message: str = Field(default="success", description="响应消息") + data: Optional[DataT] = Field(default=None, description="响应数据") + request_id: Optional[str] = Field(default=None, description="请求ID") + + +class BaseSchema(BaseModel): + """基础模式""" + + class Config: + from_attributes = True # Pydantic V2 + json_encoders = {datetime: lambda v: v.isoformat()} + + +class TimestampMixin(BaseModel): + """时间戳混入""" + + created_at: datetime + updated_at: datetime + + +class IDMixin(BaseModel): + """ID混入""" + + id: int + + +class PaginationParams(BaseModel): + """分页参数""" + + page: int = Field(default=1, ge=1, description="页码") + page_size: int = Field(default=20, ge=1, le=100, description="每页数量") + + @property + def offset(self) -> int: + """计算偏移量""" + return (self.page - 1) * self.page_size + + @property + def limit(self) -> int: + """计算限制数量""" + return self.page_size + + +class PaginatedResponse(BaseModel, Generic[DataT]): + """分页响应模型""" + + items: list[DataT] = Field(default_factory=list, description="数据列表") + total: int = Field(default=0, description="总数量") + page: int = Field(default=1, description="当前页码") + page_size: int = Field(default=20, description="每页数量") + pages: int = Field(default=1, description="总页数") + + @classmethod + def create(cls, items: list[DataT], total: int, page: int, page_size: int): + """创建分页响应""" + pages = (total + page_size - 1) // page_size if page_size > 0 else 1 + return cls( + items=items, total=total, page=page, page_size=page_size, pages=pages + ) diff --git a/backend/app/schemas/course.py b/backend/app/schemas/course.py new file mode 100644 index 0000000..e6b24c9 --- /dev/null +++ b/backend/app/schemas/course.py @@ -0,0 +1,364 @@ +""" +课程相关的数据验证模型 +""" +from typing import Optional, List +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, Field, ConfigDict, field_validator + +from app.models.course import CourseStatus, CourseCategory + + +class CourseBase(BaseModel): + """ + 课程基础模型 + """ + + name: str = Field(..., min_length=1, max_length=200, description="课程名称") + description: Optional[str] = Field(None, description="课程描述") + category: CourseCategory = Field(default=CourseCategory.GENERAL, description="课程分类") + cover_image: Optional[str] = Field(None, max_length=500, description="封面图片URL") + duration_hours: Optional[float] = Field(None, ge=0, description="课程时长(小时)") + difficulty_level: Optional[int] = Field(None, ge=1, le=5, description="难度等级(1-5)") + tags: Optional[List[str]] = Field(default_factory=list, description="标签列表") + sort_order: int = Field(default=0, description="排序顺序") + is_featured: bool = Field(default=False, description="是否推荐") + allow_download: bool = Field(default=False, description="是否允许下载资料") + + @field_validator("category", mode="before") + @classmethod + def normalize_category(cls, v): + """允许使用枚举的名称或值(忽略大小写)。空字符串使用默认值。""" + if isinstance(v, CourseCategory): + return v + if isinstance(v, str): + s = v.strip() + # 空字符串使用默认值 + if not s: + return CourseCategory.GENERAL + # 优先按值匹配(technology 等) + try: + return CourseCategory(s.lower()) + except Exception: + pass + # 再按名称匹配(TECHNOLOGY 等) + try: + return CourseCategory[s.upper()] + except Exception: + pass + return v + + +class CourseCreate(CourseBase): + """ + 创建课程模型 + """ + + status: CourseStatus = Field(default=CourseStatus.DRAFT, description="课程状态") + + +class CourseUpdate(BaseModel): + """ + 更新课程模型 + """ + + name: Optional[str] = Field(None, min_length=1, max_length=200, description="课程名称") + description: Optional[str] = Field(None, description="课程描述") + category: Optional[CourseCategory] = Field(None, description="课程分类") + status: Optional[CourseStatus] = Field(None, description="课程状态") + cover_image: Optional[str] = Field(None, max_length=500, description="封面图片URL") + duration_hours: Optional[float] = Field(None, ge=0, description="课程时长(小时)") + difficulty_level: Optional[int] = Field(None, ge=1, le=5, description="难度等级(1-5)") + tags: Optional[List[str]] = Field(None, description="标签列表") + sort_order: Optional[int] = Field(None, description="排序顺序") + is_featured: Optional[bool] = Field(None, description="是否推荐") + allow_download: Optional[bool] = Field(None, description="是否允许下载资料") + + @field_validator("category", mode="before") + @classmethod + def normalize_category_update(cls, v): + if v is None: + return v + if isinstance(v, CourseCategory): + return v + if isinstance(v, str): + s = v.strip() + if not s: # 空字符串视为None(不更新) + return None + try: + return CourseCategory(s.lower()) + except Exception: + pass + try: + return CourseCategory[s.upper()] + except Exception: + pass + return v + + +class CourseInDB(CourseBase): + """ + 数据库中的课程模型 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int = Field(..., description="课程ID") + status: CourseStatus = Field(..., description="课程状态") + created_at: datetime = Field(..., description="创建时间") + updated_at: datetime = Field(..., description="更新时间") + published_at: Optional[datetime] = Field(None, description="发布时间") + publisher_id: Optional[int] = Field(None, description="发布人ID") + created_by: Optional[int] = Field(None, description="创建人ID") + updated_by: Optional[int] = Field(None, description="更新人ID") + # 用户岗位相关的课程类型(必修/选修),非数据库字段,由API动态计算 + course_type: Optional[str] = Field(None, description="课程类型:required=必修, optional=选修") + + +class CourseList(BaseModel): + """ + 课程列表查询参数 + """ + + status: Optional[CourseStatus] = Field(None, description="课程状态") + category: Optional[CourseCategory] = Field(None, description="课程分类") + is_featured: Optional[bool] = Field(None, description="是否推荐") + keyword: Optional[str] = Field(None, description="搜索关键词") + + +# 课程资料相关模型 +class CourseMaterialBase(BaseModel): + """ + 课程资料基础模型 + """ + + name: str = Field(..., min_length=1, max_length=200, description="资料名称") + description: Optional[str] = Field(None, description="资料描述") + sort_order: int = Field(default=0, description="排序顺序") + + +class CourseMaterialCreate(CourseMaterialBase): + """ + 创建课程资料模型 + """ + + file_url: str = Field(..., max_length=500, description="文件URL") + file_type: str = Field(..., max_length=50, description="文件类型") + file_size: int = Field(..., gt=0, description="文件大小(字节)") + + @field_validator("file_type") + def validate_file_type(cls, v): + """验证文件类型 + 支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties + """ + allowed_types = [ + "txt", "md", "mdx", "pdf", "html", "htm", + "xlsx", "xls", "docx", "doc", "csv", "vtt", "properties" + ] + file_ext = v.lower() + if file_ext not in allowed_types: + raise ValueError(f"不支持的文件类型: {v}。允许的类型: TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties") + return file_ext + + +class CourseMaterialInDB(CourseMaterialBase): + """ + 数据库中的课程资料模型 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int = Field(..., description="资料ID") + course_id: int = Field(..., description="课程ID") + file_url: str = Field(..., description="文件URL") + file_type: str = Field(..., description="文件类型") + file_size: int = Field(..., description="文件大小(字节)") + created_at: datetime = Field(..., description="创建时间") + updated_at: datetime = Field(..., description="更新时间") + + +# 知识点相关模型 +class KnowledgePointBase(BaseModel): + """ + 知识点基础模型 + """ + + name: str = Field(..., min_length=1, max_length=200, description="知识点名称") + description: Optional[str] = Field(None, description="知识点描述") + type: str = Field(default="理论知识", description="知识点类型") + source: int = Field(default=0, description="来源:0=手动,1=AI分析") + topic_relation: Optional[str] = Field(None, description="与主题的关系描述") + + +class KnowledgePointCreate(KnowledgePointBase): + """ + 创建知识点模型 + """ + + material_id: int = Field(..., description="关联资料ID(必填)") + + +class KnowledgePointUpdate(BaseModel): + """ + 更新知识点模型 + """ + + name: Optional[str] = Field(None, min_length=1, max_length=200, description="知识点名称") + description: Optional[str] = Field(None, description="知识点描述") + type: Optional[str] = Field(None, description="知识点类型") + source: Optional[int] = Field(None, description="来源:0=手动,1=AI分析") + topic_relation: Optional[str] = Field(None, description="与主题的关系描述") + material_id: int = Field(..., description="关联资料ID(必填)") + + +class KnowledgePointInDB(KnowledgePointBase): + """ + 数据库中的知识点模型 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int = Field(..., description="知识点ID") + course_id: int = Field(..., description="课程ID") + material_id: int = Field(..., description="关联资料ID") + created_at: datetime = Field(..., description="创建时间") + updated_at: datetime = Field(..., description="更新时间") + + +class KnowledgePointTree(KnowledgePointInDB): + """ + 知识点树形结构 + """ + + children: List["KnowledgePointTree"] = Field( + default_factory=list, description="子知识点" + ) + + +# 成长路径相关模型 +class GrowthPathCourse(BaseModel): + """ + 成长路径中的课程 + """ + + course_id: int = Field(..., description="课程ID") + order: int = Field(..., ge=0, description="排序") + is_required: bool = Field(default=True, description="是否必修") + + +class GrowthPathBase(BaseModel): + """ + 成长路径基础模型 + """ + + name: str = Field(..., min_length=1, max_length=200, description="路径名称") + description: Optional[str] = Field(None, description="路径描述") + target_role: Optional[str] = Field(None, max_length=100, description="目标角色") + courses: List[GrowthPathCourse] = Field(default_factory=list, description="课程列表") + estimated_duration_days: Optional[int] = Field(None, ge=1, description="预计完成天数") + is_active: bool = Field(default=True, description="是否启用") + sort_order: int = Field(default=0, description="排序顺序") + + +class GrowthPathCreate(GrowthPathBase): + """ + 创建成长路径模型 + """ + + pass + + +class GrowthPathInDB(GrowthPathBase): + """ + 数据库中的成长路径模型 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int = Field(..., description="路径ID") + created_at: datetime = Field(..., description="创建时间") + updated_at: datetime = Field(..., description="更新时间") + + +# 课程考试设置相关Schema +class CourseExamSettingsBase(BaseModel): + """ + 课程考试设置基础模型 + """ + single_choice_count: int = Field(default=4, ge=0, le=50, description="单选题数量") + multiple_choice_count: int = Field(default=2, ge=0, le=30, description="多选题数量") + true_false_count: int = Field(default=1, ge=0, le=20, description="判断题数量") + fill_blank_count: int = Field(default=2, ge=0, le=10, description="填空题数量") + essay_count: int = Field(default=1, ge=0, le=10, description="问答题数量") + + duration_minutes: int = Field(default=10, ge=10, le=180, description="考试时长(分钟)") + difficulty_level: int = Field(default=3, ge=1, le=5, description="难度系数(1-5)") + passing_score: int = Field(default=60, ge=0, le=100, description="及格分数") + + is_enabled: bool = Field(default=True, description="是否启用") + show_answer_immediately: bool = Field(default=False, description="是否立即显示答案") + allow_retake: bool = Field(default=True, description="是否允许重考") + max_retake_times: Optional[int] = Field(None, ge=1, le=10, description="最大重考次数") + + +class CourseExamSettingsCreate(CourseExamSettingsBase): + """ + 创建课程考试设置模型 + """ + pass + + +class CourseExamSettingsUpdate(BaseModel): + """ + 更新课程考试设置模型 + """ + single_choice_count: Optional[int] = Field(None, ge=0, le=50, description="单选题数量") + multiple_choice_count: Optional[int] = Field(None, ge=0, le=30, description="多选题数量") + true_false_count: Optional[int] = Field(None, ge=0, le=20, description="判断题数量") + fill_blank_count: Optional[int] = Field(None, ge=0, le=10, description="填空题数量") + essay_count: Optional[int] = Field(None, ge=0, le=10, description="问答题数量") + + duration_minutes: Optional[int] = Field(None, ge=10, le=180, description="考试时长(分钟)") + difficulty_level: Optional[int] = Field(None, ge=1, le=5, description="难度系数(1-5)") + passing_score: Optional[int] = Field(None, ge=0, le=100, description="及格分数") + + is_enabled: Optional[bool] = Field(None, description="是否启用") + show_answer_immediately: Optional[bool] = Field(None, description="是否立即显示答案") + allow_retake: Optional[bool] = Field(None, description="是否允许重考") + max_retake_times: Optional[int] = Field(None, ge=1, le=10, description="最大重考次数") + + +class CourseExamSettingsInDB(CourseExamSettingsBase): + """ + 数据库中的课程考试设置模型 + """ + model_config = ConfigDict(from_attributes=True) + + id: int = Field(..., description="设置ID") + course_id: int = Field(..., description="课程ID") + created_at: datetime = Field(..., description="创建时间") + updated_at: datetime = Field(..., description="更新时间") + + +# 岗位分配相关Schema +class CoursePositionAssignment(BaseModel): + """ + 课程岗位分配模型 + """ + position_id: int = Field(..., description="岗位ID") + course_type: str = Field(default="required", pattern="^(required|optional)$", description="课程类型:required必修/optional选修") + priority: int = Field(default=0, description="优先级/排序") + + +class CoursePositionAssignmentInDB(CoursePositionAssignment): + """ + 数据库中的课程岗位分配模型 + """ + model_config = ConfigDict(from_attributes=True) + + id: int = Field(..., description="分配ID") + course_id: int = Field(..., description="课程ID") + position_name: Optional[str] = Field(None, description="岗位名称") + position_description: Optional[str] = Field(None, description="岗位描述") + member_count: Optional[int] = Field(None, description="岗位成员数") diff --git a/backend/app/schemas/exam.py b/backend/app/schemas/exam.py new file mode 100644 index 0000000..aa4a068 --- /dev/null +++ b/backend/app/schemas/exam.py @@ -0,0 +1,316 @@ +""" +考试相关的Schema定义 +""" +from typing import List, Optional, Dict, Any +from datetime import datetime +from pydantic import BaseModel, Field + + +class StartExamRequest(BaseModel): + """开始考试请求""" + + course_id: int = Field(..., description="课程ID") + count: int = Field(10, ge=1, le=100, description="题目数量") + + +class StartExamResponse(BaseModel): + """开始考试响应""" + + exam_id: int = Field(..., description="考试ID") + + +class ExamAnswer(BaseModel): + """考试答案""" + + question_id: str = Field(..., description="题目ID") + answer: str = Field(..., description="答案") + + +class SubmitExamRequest(BaseModel): + """提交考试请求""" + + exam_id: int = Field(..., description="考试ID") + answers: List[ExamAnswer] = Field(..., description="答案列表") + + +class SubmitExamResponse(BaseModel): + """提交考试响应""" + + exam_id: int = Field(..., description="考试ID") + total_score: float = Field(..., description="总分") + pass_score: float = Field(..., description="及格分") + is_passed: bool = Field(..., description="是否通过") + correct_count: int = Field(..., description="正确题数") + total_count: int = Field(..., description="总题数") + accuracy: float = Field(..., description="正确率") + + +class QuestionInfo(BaseModel): + """题目信息""" + + id: str = Field(..., description="题目ID") + type: str = Field(..., description="题目类型") + title: str = Field(..., description="题目标题") + content: Optional[str] = Field(None, description="题目内容") + options: Optional[Dict[str, Any]] = Field(None, description="选项") + score: float = Field(..., description="分值") + + +class ExamResultInfo(BaseModel): + """答题结果信息""" + + question_id: int = Field(..., description="题目ID") + user_answer: Optional[str] = Field(None, description="用户答案") + is_correct: bool = Field(..., description="是否正确") + score: float = Field(..., description="得分") + + +class ExamDetailResponse(BaseModel): + """考试详情响应""" + + id: int = Field(..., description="考试ID") + course_id: int = Field(..., description="课程ID") + exam_name: str = Field(..., description="考试名称") + question_count: int = Field(..., description="题目数量") + total_score: float = Field(..., description="总分") + pass_score: float = Field(..., description="及格分") + start_time: Optional[str] = Field(None, description="开始时间") + end_time: Optional[str] = Field(None, description="结束时间") + duration_minutes: int = Field(..., description="考试时长(分钟)") + status: str = Field(..., description="考试状态") + score: Optional[float] = Field(None, description="得分") + is_passed: Optional[bool] = Field(None, description="是否通过") + questions: Optional[Dict[str, Any]] = Field(None, description="题目数据") + results: Optional[List[ExamResultInfo]] = Field(None, description="答题结果") + answers: Optional[Dict[str, Any]] = Field(None, description="用户答案") + + +class ExamRecordInfo(BaseModel): + """考试记录信息""" + + id: int = Field(..., description="考试ID") + course_id: int = Field(..., description="课程ID") + exam_name: str = Field(..., description="考试名称") + question_count: int = Field(..., description="题目数量") + total_score: float = Field(..., description="总分") + score: Optional[float] = Field(None, description="得分") + is_passed: Optional[bool] = Field(None, description="是否通过") + status: str = Field(..., description="考试状态") + start_time: Optional[str] = Field(None, description="开始时间") + end_time: Optional[str] = Field(None, description="结束时间") + created_at: str = Field(..., description="创建时间") + # 新增统计字段 + accuracy: Optional[float] = Field(None, description="正确率(%)") + correct_count: Optional[int] = Field(None, description="正确题数") + wrong_count: Optional[int] = Field(None, description="错题数") + duration_seconds: Optional[int] = Field(None, description="考试用时(秒)") + course_name: Optional[str] = Field(None, description="课程名称") + question_type_stats: Optional[List[Dict[str, Any]]] = Field(None, description="分题型统计") + + +class ExamRecordResponse(BaseModel): + """考试记录列表响应""" + + items: List[ExamRecordInfo] = Field(..., description="考试记录列表") + total: int = Field(..., description="总数") + page: int = Field(..., description="当前页") + size: int = Field(..., description="每页数量") + pages: int = Field(..., description="总页数") + + +# ==================== AI服务响应Schema ==================== + +class MistakeRecord(BaseModel): + """错题记录详情""" + question_id: Optional[int] = Field(None, description="题目ID") + knowledge_point_id: Optional[int] = Field(None, description="知识点ID") + question_content: str = Field(..., description="题目内容") + correct_answer: str = Field(..., description="正确答案") + user_answer: str = Field(..., description="用户答案") + + +class GenerateExamRequest(BaseModel): + """生成考试试题请求""" + course_id: int = Field(..., description="课程ID") + position_id: Optional[int] = Field(None, description="岗位ID,如果不提供则从用户信息中自动获取") + current_round: int = Field(1, ge=1, le=3, description="当前轮次(1/2/3)") + exam_id: Optional[int] = Field(None, description="已存在的exam_id(第2、3轮传入)") + mistake_records: Optional[str] = Field(None, description="错题记录JSON字符串,第一轮不传此参数,第二三轮传入上一轮错题的JSON字符串") + single_choice_count: int = Field(4, ge=0, le=50, description="单选题数量") + multiple_choice_count: int = Field(2, ge=0, le=30, description="多选题数量") + true_false_count: int = Field(1, ge=0, le=20, description="判断题数量") + fill_blank_count: int = Field(2, ge=0, le=10, description="填空题数量") + essay_count: int = Field(1, ge=0, le=10, description="问答题数量") + difficulty_level: int = Field(3, ge=1, le=5, description="难度系数(1-5)") + + +class GenerateExamResponse(BaseModel): + """生成考试试题响应""" + result: str = Field(..., description="试题JSON数组(字符串格式)") + workflow_run_id: Optional[str] = Field(None, description="AI服务调用ID") + task_id: Optional[str] = Field(None, description="任务ID") + exam_id: int = Field(..., description="考试ID(真实的数据库ID)") + + +class JudgeAnswerRequest(BaseModel): + """判断主观题答案请求""" + question: str = Field(..., description="题目内容") + correct_answer: str = Field(..., description="标准答案") + user_answer: str = Field(..., description="用户提交的答案") + analysis: str = Field(..., description="正确答案的解析(来源于试题生成器)") + + +class JudgeAnswerResponse(BaseModel): + """判断主观题答案响应""" + is_correct: bool = Field(..., description="是否正确") + correct_answer: str = Field(..., description="标准答案") + feedback: Optional[str] = Field(None, description="判断反馈信息") + + +class RecordMistakeRequest(BaseModel): + """记录错题请求""" + exam_id: int = Field(..., description="考试ID") + question_id: Optional[int] = Field(None, description="题目ID(AI生成的题目可能为空)") + knowledge_point_id: Optional[int] = Field(None, description="知识点ID") + question_content: str = Field(..., description="题目内容") + correct_answer: str = Field(..., description="正确答案") + user_answer: str = Field(..., description="用户答案") + question_type: Optional[str] = Field(None, description="题型(single/multiple/judge/blank/essay)") + + +class RecordMistakeResponse(BaseModel): + """记录错题响应""" + id: int = Field(..., description="错题记录ID") + created_at: datetime = Field(..., description="创建时间") + + +class MistakeRecordItem(BaseModel): + """错题记录项""" + id: int = Field(..., description="错题记录ID") + question_id: Optional[int] = Field(None, description="题目ID") + knowledge_point_id: Optional[int] = Field(None, description="知识点ID") + question_content: str = Field(..., description="题目内容") + correct_answer: str = Field(..., description="正确答案") + user_answer: str = Field(..., description="用户答案") + created_at: datetime = Field(..., description="创建时间") + + +class GetMistakesResponse(BaseModel): + """获取错题记录响应""" + mistakes: List[MistakeRecordItem] = Field(..., description="错题列表") + + +# ==================== 成绩报告和错题本相关Schema ==================== + +class RoundScores(BaseModel): + """三轮得分""" + round1: Optional[float] = Field(None, description="第一轮得分") + round2: Optional[float] = Field(None, description="第二轮得分") + round3: Optional[float] = Field(None, description="第三轮得分") + + +class ExamReportOverview(BaseModel): + """成绩报告概览""" + avg_score: float = Field(..., description="平均成绩(基于round1_score)") + total_exams: int = Field(..., description="考试总数") + pass_rate: float = Field(..., description="及格率") + total_questions: int = Field(..., description="答题总数") + + +class ExamTrendItem(BaseModel): + """成绩趋势项""" + date: str = Field(..., description="日期(YYYY-MM-DD)") + avg_score: float = Field(..., description="平均分") + + +class SubjectStatItem(BaseModel): + """科目统计项""" + course_id: int = Field(..., description="课程ID") + course_name: str = Field(..., description="课程名称") + avg_score: float = Field(..., description="平均分") + exam_count: int = Field(..., description="考试次数") + max_score: float = Field(..., description="最高分") + min_score: float = Field(..., description="最低分") + pass_rate: float = Field(..., description="及格率") + + +class RecentExamItem(BaseModel): + """最近考试记录项""" + id: int = Field(..., description="考试ID") + course_id: int = Field(..., description="课程ID") + course_name: str = Field(..., description="课程名称") + score: Optional[float] = Field(None, description="最终得分") + total_score: float = Field(..., description="总分") + is_passed: Optional[bool] = Field(None, description="是否通过") + duration_seconds: Optional[int] = Field(None, description="考试用时(秒)") + start_time: str = Field(..., description="开始时间") + end_time: Optional[str] = Field(None, description="结束时间") + round_scores: RoundScores = Field(..., description="三轮得分") + + +class ExamReportResponse(BaseModel): + """成绩报告响应""" + overview: ExamReportOverview = Field(..., description="概览数据") + trends: List[ExamTrendItem] = Field(..., description="趋势数据") + subjects: List[SubjectStatItem] = Field(..., description="科目分析") + recent_exams: List[RecentExamItem] = Field(..., description="最近考试记录") + + +class MistakeListItem(BaseModel): + """错题列表项""" + id: int = Field(..., description="错题记录ID") + exam_id: int = Field(..., description="考试ID") + course_id: int = Field(..., description="课程ID") + course_name: str = Field(..., description="课程名称") + question_content: str = Field(..., description="题目内容") + correct_answer: str = Field(..., description="正确答案") + user_answer: str = Field(..., description="用户答案") + question_type: Optional[str] = Field(None, description="题型") + knowledge_point_id: Optional[int] = Field(None, description="知识点ID") + knowledge_point_name: Optional[str] = Field(None, description="知识点名称") + created_at: datetime = Field(..., description="创建时间") + + +class MistakeListResponse(BaseModel): + """错题列表响应""" + items: List[MistakeListItem] = Field(..., description="错题列表") + total: int = Field(..., description="总数") + page: int = Field(..., description="当前页") + size: int = Field(..., description="每页数量") + pages: int = Field(..., description="总页数") + + +class MistakeByCourse(BaseModel): + """按课程统计错题""" + course_id: int = Field(..., description="课程ID") + course_name: str = Field(..., description="课程名称") + count: int = Field(..., description="错题数量") + + +class MistakeByType(BaseModel): + """按题型统计错题""" + type: str = Field(..., description="题型代码") + type_name: str = Field(..., description="题型名称") + count: int = Field(..., description="错题数量") + + +class MistakeByTime(BaseModel): + """按时间统计错题""" + week: int = Field(..., description="最近一周") + month: int = Field(..., description="最近一月") + quarter: int = Field(..., description="最近三月") + + +class MistakesStatisticsResponse(BaseModel): + """错题统计响应""" + total: int = Field(..., description="错题总数") + by_course: List[MistakeByCourse] = Field(..., description="按课程统计") + by_type: List[MistakeByType] = Field(..., description="按题型统计") + by_time: MistakeByTime = Field(..., description="按时间统计") + + +class UpdateRoundScoreRequest(BaseModel): + """更新轮次得分请求""" + round: int = Field(..., ge=1, le=3, description="轮次(1/2/3)") + score: float = Field(..., ge=0, le=100, description="得分") + is_final: bool = Field(False, description="是否为最终轮次(如果是,则同时更新总分和状态)") diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..9a59d58 --- /dev/null +++ b/backend/app/schemas/notification.py @@ -0,0 +1,102 @@ +""" +站内消息通知相关的数据验证模型 +""" +from typing import Optional, List +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, Field, ConfigDict + + +class NotificationType(str, Enum): + """通知类型枚举""" + POSITION_ASSIGN = "position_assign" # 岗位分配 + COURSE_ASSIGN = "course_assign" # 课程分配 + EXAM_REMIND = "exam_remind" # 考试提醒 + TASK_ASSIGN = "task_assign" # 任务分配 + SYSTEM = "system" # 系统通知 + + +class NotificationBase(BaseModel): + """ + 通知基础模型 + """ + title: str = Field(..., min_length=1, max_length=200, description="通知标题") + content: Optional[str] = Field(None, description="通知内容") + type: NotificationType = Field(default=NotificationType.SYSTEM, description="通知类型") + related_id: Optional[int] = Field(None, description="关联数据ID") + related_type: Optional[str] = Field(None, max_length=50, description="关联数据类型") + + +class NotificationCreate(NotificationBase): + """ + 创建通知模型 + """ + user_id: int = Field(..., description="接收用户ID") + sender_id: Optional[int] = Field(None, description="发送者用户ID") + + +class NotificationBatchCreate(BaseModel): + """ + 批量创建通知模型(发送给多个用户) + """ + user_ids: List[int] = Field(..., min_length=1, description="接收用户ID列表") + title: str = Field(..., min_length=1, max_length=200, description="通知标题") + content: Optional[str] = Field(None, description="通知内容") + type: NotificationType = Field(default=NotificationType.SYSTEM, description="通知类型") + related_id: Optional[int] = Field(None, description="关联数据ID") + related_type: Optional[str] = Field(None, max_length=50, description="关联数据类型") + sender_id: Optional[int] = Field(None, description="发送者用户ID") + + +class NotificationUpdate(BaseModel): + """ + 更新通知模型 + """ + is_read: Optional[bool] = Field(None, description="是否已读") + + +class NotificationInDB(NotificationBase): + """ + 数据库中的通知模型 + """ + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + is_read: bool + sender_id: Optional[int] = None + created_at: datetime + updated_at: datetime + + +class NotificationResponse(NotificationInDB): + """ + 通知响应模型(可扩展发送者信息) + """ + sender_name: Optional[str] = Field(None, description="发送者姓名") + + +class NotificationListResponse(BaseModel): + """ + 通知列表响应模型 + """ + items: List[NotificationResponse] + total: int + unread_count: int + + +class NotificationCountResponse(BaseModel): + """ + 未读通知数量响应模型 + """ + unread_count: int + total: int + + +class MarkReadRequest(BaseModel): + """ + 标记已读请求模型 + """ + notification_ids: Optional[List[int]] = Field(None, description="通知ID列表,为空则标记全部已读") + diff --git a/backend/app/schemas/practice.py b/backend/app/schemas/practice.py new file mode 100644 index 0000000..ec13f61 --- /dev/null +++ b/backend/app/schemas/practice.py @@ -0,0 +1,318 @@ +""" +陪练功能相关Schema定义 +""" +from typing import Optional, List +from datetime import datetime +from pydantic import BaseModel, Field, field_validator + + +# ==================== 枚举类型 ==================== + +class SceneType: + """场景类型枚举""" + PHONE = "phone" # 电话销售 + FACE = "face" # 面对面销售 + COMPLAINT = "complaint" # 客户投诉 + AFTER_SALES = "after-sales" # 售后服务 + PRODUCT_INTRO = "product-intro" # 产品介绍 + + +class Difficulty: + """难度等级枚举""" + BEGINNER = "beginner" # 入门 + JUNIOR = "junior" # 初级 + INTERMEDIATE = "intermediate" # 中级 + SENIOR = "senior" # 高级 + EXPERT = "expert" # 专家 + + +class SceneStatus: + """场景状态枚举""" + ACTIVE = "active" # 启用 + INACTIVE = "inactive" # 禁用 + + +# ==================== 场景Schema ==================== + +class PracticeSceneBase(BaseModel): + """陪练场景基础Schema""" + name: str = Field(..., max_length=200, description="场景名称") + description: Optional[str] = Field(None, description="场景描述") + type: str = Field(..., description="场景类型: phone/face/complaint/after-sales/product-intro") + difficulty: str = Field(..., description="难度等级: beginner/junior/intermediate/senior/expert") + status: str = Field(default="active", description="状态: active/inactive") + background: str = Field(..., description="场景背景设定") + ai_role: str = Field(..., description="AI角色描述") + objectives: List[str] = Field(..., description="练习目标数组") + keywords: Optional[List[str]] = Field(default=None, description="关键词数组") + duration: int = Field(default=10, ge=1, le=120, description="预计时长(分钟)") + + @field_validator('type') + @classmethod + def validate_type(cls, v): + """验证场景类型""" + valid_types = ['phone', 'face', 'complaint', 'after-sales', 'product-intro'] + if v not in valid_types: + raise ValueError(f"场景类型必须是: {', '.join(valid_types)}") + return v + + @field_validator('difficulty') + @classmethod + def validate_difficulty(cls, v): + """验证难度等级""" + valid_difficulties = ['beginner', 'junior', 'intermediate', 'senior', 'expert'] + if v not in valid_difficulties: + raise ValueError(f"难度等级必须是: {', '.join(valid_difficulties)}") + return v + + @field_validator('status') + @classmethod + def validate_status(cls, v): + """验证状态""" + valid_statuses = ['active', 'inactive'] + if v not in valid_statuses: + raise ValueError(f"状态必须是: {', '.join(valid_statuses)}") + return v + + @field_validator('objectives') + @classmethod + def validate_objectives(cls, v): + """验证练习目标""" + if not v or len(v) < 1: + raise ValueError("至少需要1个练习目标") + if len(v) > 10: + raise ValueError("练习目标不能超过10个") + return v + + +class PracticeSceneCreate(PracticeSceneBase): + """创建陪练场景Schema""" + pass + + +class PracticeSceneUpdate(BaseModel): + """更新陪练场景Schema(所有字段可选)""" + name: Optional[str] = Field(None, max_length=200, description="场景名称") + description: Optional[str] = Field(None, description="场景描述") + type: Optional[str] = Field(None, description="场景类型") + difficulty: Optional[str] = Field(None, description="难度等级") + status: Optional[str] = Field(None, description="状态") + background: Optional[str] = Field(None, description="场景背景设定") + ai_role: Optional[str] = Field(None, description="AI角色描述") + objectives: Optional[List[str]] = Field(None, description="练习目标数组") + keywords: Optional[List[str]] = Field(None, description="关键词数组") + duration: Optional[int] = Field(None, ge=1, le=120, description="预计时长(分钟)") + + +class PracticeSceneResponse(PracticeSceneBase): + """陪练场景响应Schema""" + id: int + usage_count: int + rating: float + created_by: Optional[int] = None + updated_by: Optional[int] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# ==================== 对话Schema ==================== + +class StartPracticeRequest(BaseModel): + """开始陪练对话请求Schema""" + # 场景信息(首次消息必填,后续消息可选) + scene_id: Optional[int] = Field(None, description="场景ID(可选)") + scene_name: Optional[str] = Field(None, description="场景名称") + scene_description: Optional[str] = Field(None, description="场景描述") + scene_background: Optional[str] = Field(None, description="场景背景") + scene_ai_role: Optional[str] = Field(None, description="AI角色") + scene_objectives: Optional[List[str]] = Field(None, description="练习目标") + scene_keywords: Optional[List[str]] = Field(None, description="关键词") + + # 对话信息 + user_message: str = Field(..., description="用户消息") + conversation_id: Optional[str] = Field(None, description="对话ID(续接对话时必填)") + is_first: bool = Field(..., description="是否首次消息") + + @field_validator('scene_name') + @classmethod + def validate_scene_name_for_first(cls, v, info): + """首次消息时场景名称必填""" + if info.data.get('is_first') and not v: + raise ValueError("首次消息时场景名称必填") + return v + + @field_validator('scene_background') + @classmethod + def validate_scene_background_for_first(cls, v, info): + """首次消息时场景背景必填""" + if info.data.get('is_first') and not v: + raise ValueError("首次消息时场景背景必填") + return v + + @field_validator('scene_ai_role') + @classmethod + def validate_scene_ai_role_for_first(cls, v, info): + """首次消息时AI角色必填""" + if info.data.get('is_first') and not v: + raise ValueError("首次消息时AI角色必填") + return v + + @field_validator('scene_objectives') + @classmethod + def validate_scene_objectives_for_first(cls, v, info): + """首次消息时练习目标必填""" + if info.data.get('is_first') and (not v or len(v) == 0): + raise ValueError("首次消息时练习目标必填") + return v + + +class InterruptPracticeRequest(BaseModel): + """中断对话请求Schema""" + conversation_id: str = Field(..., description="对话ID") + chat_id: str = Field(..., description="聊天ID") + + +class ConversationInfo(BaseModel): + """对话信息Schema""" + id: str = Field(..., description="对话ID") + name: str = Field(..., description="对话名称") + created_at: int = Field(..., description="创建时间(时间戳)") + + +class ConversationsResponse(BaseModel): + """对话列表响应Schema""" + items: List[ConversationInfo] + has_more: bool + page: int + size: int + + +# ==================== 场景提取Schema ==================== + +class ExtractSceneRequest(BaseModel): + """提取场景请求Schema""" + course_id: int = Field(..., description="课程ID") + + +class ExtractedSceneData(BaseModel): + """提取的场景数据Schema""" + name: str = Field(..., description="场景名称") + description: str = Field(..., description="场景描述") + type: str = Field(..., description="场景类型") + difficulty: str = Field(..., description="难度等级") + background: str = Field(..., description="场景背景") + ai_role: str = Field(..., description="AI角色描述") + objectives: List[str] = Field(..., description="练习目标数组") + keywords: Optional[List[str]] = Field(default=[], description="关键词数组") + + +class ExtractSceneResponse(BaseModel): + """提取场景响应Schema""" + scene: ExtractedSceneData = Field(..., description="场景数据") + workflow_run_id: str = Field(..., description="工作流运行ID") + task_id: str = Field(..., description="任务ID") + + +# ==================== 陪练会话Schema ==================== + +class PracticeSessionCreate(BaseModel): + """创建陪练会话请求Schema""" + scene_id: Optional[int] = Field(None, description="场景ID") + scene_name: str = Field(..., description="场景名称") + scene_type: Optional[str] = Field(None, description="场景类型") + conversation_id: Optional[str] = Field(None, description="Coze对话ID") + + +class PracticeSessionResponse(BaseModel): + """陪练会话响应Schema""" + id: int + session_id: str + user_id: int + scene_id: Optional[int] + scene_name: str + scene_type: Optional[str] + conversation_id: Optional[str] + start_time: datetime + end_time: Optional[datetime] + duration_seconds: int + turns: int + status: str + created_at: datetime + + class Config: + from_attributes = True + + +class SaveDialogueRequest(BaseModel): + """保存对话记录请求Schema""" + session_id: str = Field(..., description="会话ID") + speaker: str = Field(..., description="说话人: user/ai") + content: str = Field(..., description="对话内容") + sequence: int = Field(..., ge=1, description="顺序号(从1开始)") + + +class PracticeDialogueResponse(BaseModel): + """对话记录响应Schema""" + id: int + session_id: str + speaker: str + content: str + timestamp: datetime + sequence: int + + class Config: + from_attributes = True + + +# ==================== 分析报告Schema ==================== + +class ScoreBreakdownItem(BaseModel): + """分数细分项""" + name: str + score: int = Field(..., ge=0, le=100) + description: str + + +class AbilityDimensionItem(BaseModel): + """能力维度项""" + name: str + score: int = Field(..., ge=0, le=100) + feedback: str + + +class DialogueReviewItem(BaseModel): + """对话复盘项""" + speaker: str + time: str + content: str + tags: List[str] = Field(default_factory=list) + comment: str = Field(default="") + + +class SuggestionItem(BaseModel): + """改进建议项""" + title: str + content: str + example: Optional[str] = None + + +class PracticeAnalysisResult(BaseModel): + """陪练分析结果Schema""" + total_score: int = Field(..., ge=0, le=100, description="综合得分") + score_breakdown: List[ScoreBreakdownItem] = Field(..., description="分数细分") + ability_dimensions: List[AbilityDimensionItem] = Field(..., description="能力维度") + dialogue_review: List[DialogueReviewItem] = Field(..., description="对话复盘") + suggestions: List[SuggestionItem] = Field(..., description="改进建议") + + +class PracticeReportResponse(BaseModel): + """陪练报告响应Schema""" + session_info: PracticeSessionResponse + analysis: PracticeAnalysisResult + + class Config: + from_attributes = True + diff --git a/backend/app/schemas/scrm.py b/backend/app/schemas/scrm.py new file mode 100644 index 0000000..cf86661 --- /dev/null +++ b/backend/app/schemas/scrm.py @@ -0,0 +1,128 @@ +""" +SCRM 系统对接 API Schema 定义 + +用于 SCRM 系统调用考陪练系统的数据查询接口 +""" + +from typing import List, Optional +from pydantic import BaseModel, Field +from datetime import datetime + + +# ==================== 通用响应 ==================== + +class SCRMBaseResponse(BaseModel): + """SCRM API 通用响应基类""" + code: int = Field(default=0, description="响应码,0=成功") + message: str = Field(default="success", description="响应消息") + + +# ==================== 1. 获取员工岗位 ==================== + +class PositionInfo(BaseModel): + """岗位信息""" + position_id: int = Field(..., description="岗位ID") + position_name: str = Field(..., description="岗位名称") + is_primary: bool = Field(default=True, description="是否主岗位") + joined_at: Optional[str] = Field(None, description="加入时间") + + +class EmployeePositionData(BaseModel): + """员工岗位数据""" + employee_id: int = Field(..., description="员工ID") + userid: Optional[str] = Field(None, description="企微员工userid(可能为空)") + name: str = Field(..., description="员工姓名") + positions: List[PositionInfo] = Field(default=[], description="岗位列表") + + +class EmployeePositionResponse(SCRMBaseResponse): + """获取员工岗位响应""" + data: Optional[EmployeePositionData] = None + + +# ==================== 2. 获取岗位课程 ==================== + +class CourseInfo(BaseModel): + """课程信息""" + course_id: int = Field(..., description="课程ID") + course_name: str = Field(..., description="课程名称") + course_type: str = Field(..., description="课程类型:required/optional") + priority: int = Field(default=0, description="优先级") + knowledge_point_count: int = Field(default=0, description="知识点数量") + + +class PositionCoursesData(BaseModel): + """岗位课程数据""" + position_id: int = Field(..., description="岗位ID") + position_name: str = Field(..., description="岗位名称") + courses: List[CourseInfo] = Field(default=[], description="课程列表") + + +class PositionCoursesResponse(SCRMBaseResponse): + """获取岗位课程响应""" + data: Optional[PositionCoursesData] = None + + +# ==================== 3. 搜索知识点 ==================== + +class KnowledgePointSearchRequest(BaseModel): + """搜索知识点请求""" + keywords: List[str] = Field(..., min_length=1, description="搜索关键词列表") + position_id: Optional[int] = Field(None, description="岗位ID(用于优先排序)") + course_ids: Optional[List[int]] = Field(None, description="限定课程范围") + knowledge_type: Optional[str] = Field(None, description="知识点类型筛选") + limit: int = Field(default=10, ge=1, le=100, description="返回数量") + + +class KnowledgePointBrief(BaseModel): + """知识点简要信息""" + knowledge_point_id: int = Field(..., description="知识点ID") + name: str = Field(..., description="知识点名称") + course_id: int = Field(..., description="课程ID") + course_name: str = Field(..., description="课程名称") + type: str = Field(..., description="知识点类型") + relevance_score: float = Field(default=1.0, description="相关度分数") + + +class KnowledgePointSearchData(BaseModel): + """知识点搜索结果数据""" + total: int = Field(..., description="匹配总数") + items: List[KnowledgePointBrief] = Field(default=[], description="知识点列表") + + +class KnowledgePointSearchResponse(SCRMBaseResponse): + """搜索知识点响应""" + data: Optional[KnowledgePointSearchData] = None + + +# ==================== 4. 获取知识点详情 ==================== + +class KnowledgePointDetailData(BaseModel): + """知识点详情数据""" + knowledge_point_id: int = Field(..., description="知识点ID") + name: str = Field(..., description="知识点名称") + course_id: int = Field(..., description="课程ID") + course_name: str = Field(..., description="课程名称") + type: str = Field(..., description="知识点类型") + content: str = Field(..., description="知识点完整内容(description)") + material_id: Optional[int] = Field(None, description="关联的课程资料ID") + material_type: Optional[str] = Field(None, description="资料文件类型") + material_url: Optional[str] = Field(None, description="资料文件URL") + topic_relation: Optional[str] = Field(None, description="与主题的关系描述") + source: int = Field(default=0, description="来源:0=手动创建,1=AI分析生成") + created_at: Optional[str] = Field(None, description="创建时间") + + +class KnowledgePointDetailResponse(SCRMBaseResponse): + """获取知识点详情响应""" + data: Optional[KnowledgePointDetailData] = None + + +# ==================== 错误响应 ==================== + +class SCRMErrorResponse(SCRMBaseResponse): + """错误响应""" + code: int = Field(..., description="错误码") + message: str = Field(..., description="错误消息") + data: None = None + diff --git a/backend/app/schemas/system_log.py b/backend/app/schemas/system_log.py new file mode 100644 index 0000000..7c742e6 --- /dev/null +++ b/backend/app/schemas/system_log.py @@ -0,0 +1,59 @@ +""" +系统日志 Schema +""" +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field + + +class SystemLogBase(BaseModel): + """系统日志基础Schema""" + level: str = Field(..., description="日志级别: debug, info, warning, error") + type: str = Field(..., description="日志类型: system, user, api, error, security") + user: Optional[str] = Field(None, description="操作用户") + user_id: Optional[int] = Field(None, description="用户ID") + ip: Optional[str] = Field(None, description="IP地址") + message: str = Field(..., description="日志消息") + user_agent: Optional[str] = Field(None, description="User Agent") + path: Optional[str] = Field(None, description="请求路径") + method: Optional[str] = Field(None, description="请求方法") + extra_data: Optional[str] = Field(None, description="额外数据(JSON格式)") + + +class SystemLogCreate(SystemLogBase): + """创建系统日志Schema""" + pass + + +class SystemLogResponse(SystemLogBase): + """系统日志响应Schema""" + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class SystemLogQuery(BaseModel): + """系统日志查询参数""" + level: Optional[str] = Field(None, description="日志级别筛选") + type: Optional[str] = Field(None, description="日志类型筛选") + user: Optional[str] = Field(None, description="用户筛选") + keyword: Optional[str] = Field(None, description="关键词搜索(搜索message字段)") + start_date: Optional[datetime] = Field(None, description="开始日期") + end_date: Optional[datetime] = Field(None, description="结束日期") + page: int = Field(1, ge=1, description="页码") + page_size: int = Field(20, ge=1, le=100, description="每页数量") + + +class SystemLogListResponse(BaseModel): + """系统日志列表响应""" + items: list[SystemLogResponse] + total: int + page: int + page_size: int + total_pages: int + + + diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py new file mode 100644 index 0000000..b3e6267 --- /dev/null +++ b/backend/app/schemas/task.py @@ -0,0 +1,67 @@ +""" +任务相关Schema +""" +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + + +class TaskBase(BaseModel): + """任务基础Schema""" + title: str = Field(..., description="任务标题") + description: Optional[str] = Field(None, description="任务描述") + priority: str = Field("medium", description="优先级(low/medium/high)") + deadline: Optional[datetime] = Field(None, description="截止时间") + requirements: Optional[dict] = Field(None, description="任务要求配置") + course_ids: List[int] = Field(default_factory=list, description="关联课程ID列表") + user_ids: List[int] = Field(default_factory=list, description="分配用户ID列表") + + +class TaskCreate(TaskBase): + """创建任务""" + pass + + +class TaskUpdate(BaseModel): + """更新任务""" + title: Optional[str] = None + description: Optional[str] = None + priority: Optional[str] = None + status: Optional[str] = None + deadline: Optional[datetime] = None + requirements: Optional[dict] = None + progress: Optional[int] = None + + +class TaskResponse(BaseModel): + """任务响应""" + id: int + title: str + description: Optional[str] + priority: str + status: str + creator_id: int + deadline: Optional[datetime] + requirements: Optional[dict] + progress: int + created_at: datetime + updated_at: datetime + # 扩展字段 + courses: List[str] = Field(default_factory=list, description="课程名称列表") + assigned_count: int = Field(0, description="分配人数") + completed_count: int = Field(0, description="完成人数") + + class Config: + from_attributes = True + + +class TaskStatsResponse(BaseModel): + """任务统计响应""" + total: int = Field(0, description="总任务数") + ongoing: int = Field(0, description="进行中") + completed: int = Field(0, description="已完成") + expired: int = Field(0, description="已过期") + avg_completion_rate: float = Field(0.0, description="平均完成率") + + + diff --git a/backend/app/schemas/training.py b/backend/app/schemas/training.py new file mode 100644 index 0000000..1c449e5 --- /dev/null +++ b/backend/app/schemas/training.py @@ -0,0 +1,260 @@ +"""陪练模块Pydantic模式""" +from typing import Optional, List, Dict, Any, Generic, TypeVar +from datetime import datetime +from pydantic import BaseModel, Field, ConfigDict + +# 定义泛型类型变量 +DataT = TypeVar("DataT") + +from app.models.training import ( + TrainingSceneStatus, + TrainingSessionStatus, + MessageType, + MessageRole, +) +from app.schemas.base import BaseSchema, TimestampMixin, IDMixin + + +# ========== 陪练场景相关 ========== + + +class TrainingSceneBase(BaseSchema): + """陪练场景基础模式""" + + name: str = Field(..., max_length=100, description="场景名称") + description: Optional[str] = Field(None, description="场景描述") + category: str = Field(..., max_length=50, description="场景分类") + ai_config: Optional[Dict[str, Any]] = Field(None, description="AI配置") + prompt_template: Optional[str] = Field(None, description="提示词模板") + evaluation_criteria: Optional[Dict[str, Any]] = Field(None, description="评估标准") + is_public: bool = Field(True, description="是否公开") + required_level: Optional[int] = Field(None, description="所需用户等级") + + +class TrainingSceneCreate(TrainingSceneBase): + """创建陪练场景模式""" + + status: TrainingSceneStatus = Field( + default=TrainingSceneStatus.DRAFT, description="场景状态" + ) + + +class TrainingSceneUpdate(BaseSchema): + """更新陪练场景模式""" + + name: Optional[str] = Field(None, max_length=100) + description: Optional[str] = None + category: Optional[str] = Field(None, max_length=50) + ai_config: Optional[Dict[str, Any]] = None + prompt_template: Optional[str] = None + evaluation_criteria: Optional[Dict[str, Any]] = None + status: Optional[TrainingSceneStatus] = None + is_public: Optional[bool] = None + required_level: Optional[int] = None + + +class TrainingSceneInDB(TrainingSceneBase, IDMixin, TimestampMixin): + """数据库中的陪练场景模式""" + + status: TrainingSceneStatus + is_deleted: bool = False + created_by: Optional[int] = None + updated_by: Optional[int] = None + + +class TrainingSceneResponse(TrainingSceneInDB): + """陪练场景响应模式""" + + pass + + +# ========== 陪练会话相关 ========== + + +class TrainingSessionBase(BaseSchema): + """陪练会话基础模式""" + + scene_id: int = Field(..., description="场景ID") + session_config: Optional[Dict[str, Any]] = Field(None, description="会话配置") + + +class TrainingSessionCreate(TrainingSessionBase): + """创建陪练会话模式""" + + pass + + +class TrainingSessionUpdate(BaseSchema): + """更新陪练会话模式""" + + status: Optional[TrainingSessionStatus] = None + end_time: Optional[datetime] = None + duration_seconds: Optional[int] = None + total_score: Optional[float] = None + evaluation_result: Optional[Dict[str, Any]] = None + + +class TrainingSessionInDB(TrainingSessionBase, IDMixin, TimestampMixin): + """数据库中的陪练会话模式""" + + user_id: int + coze_conversation_id: Optional[str] = None + start_time: datetime + end_time: Optional[datetime] = None + duration_seconds: Optional[int] = None + status: TrainingSessionStatus + total_score: Optional[float] = None + evaluation_result: Optional[Dict[str, Any]] = None + created_by: Optional[int] = None + updated_by: Optional[int] = None + + +class TrainingSessionResponse(TrainingSessionInDB): + """陪练会话响应模式""" + + scene: Optional["TrainingSceneResponse"] = None + message_count: Optional[int] = Field(None, description="消息数量") + + +# ========== 消息相关 ========== + + +class TrainingMessageBase(BaseSchema): + """陪练消息基础模式""" + + role: MessageRole = Field(..., description="消息角色") + type: MessageType = Field(..., description="消息类型") + content: str = Field(..., description="消息内容") + voice_url: Optional[str] = Field(None, max_length=500, description="语音文件URL") + voice_duration: Optional[float] = Field(None, description="语音时长(秒)") + metadata: Optional[Dict[str, Any]] = Field(None, description="消息元数据") + + +class TrainingMessageCreate(TrainingMessageBase): + """创建陪练消息模式""" + + session_id: int = Field(..., description="会话ID") + coze_message_id: Optional[str] = Field(None, max_length=100, description="Coze消息ID") + + +class TrainingMessageInDB(TrainingMessageBase, IDMixin, TimestampMixin): + """数据库中的陪练消息模式""" + + session_id: int + coze_message_id: Optional[str] = None + + +class TrainingMessageResponse(TrainingMessageInDB): + """陪练消息响应模式""" + + pass + + +# ========== 报告相关 ========== + + +class TrainingReportBase(BaseSchema): + """陪练报告基础模式""" + + overall_score: float = Field(..., ge=0, le=100, description="总体得分") + dimension_scores: Dict[str, float] = Field(..., description="各维度得分") + strengths: List[str] = Field(..., description="优势点") + weaknesses: List[str] = Field(..., description="待改进点") + suggestions: List[str] = Field(..., description="改进建议") + detailed_analysis: Optional[str] = Field(None, description="详细分析") + transcript: Optional[str] = Field(None, description="对话文本记录") + statistics: Optional[Dict[str, Any]] = Field(None, description="统计数据") + + +class TrainingReportCreate(TrainingReportBase): + """创建陪练报告模式""" + + session_id: int = Field(..., description="会话ID") + user_id: int = Field(..., description="用户ID") + + +class TrainingReportInDB(TrainingReportBase, IDMixin, TimestampMixin): + """数据库中的陪练报告模式""" + + session_id: int + user_id: int + created_by: Optional[int] = None + updated_by: Optional[int] = None + + +class TrainingReportResponse(TrainingReportInDB): + """陪练报告响应模式""" + + session: Optional[TrainingSessionResponse] = None + + +# ========== 会话操作相关 ========== + + +class StartTrainingRequest(BaseSchema): + """开始陪练请求""" + + scene_id: int = Field(..., description="场景ID") + config: Optional[Dict[str, Any]] = Field(None, description="会话配置") + + +class StartTrainingResponse(BaseSchema): + """开始陪练响应""" + + session_id: int = Field(..., description="会话ID") + coze_conversation_id: Optional[str] = Field(None, description="Coze会话ID") + scene: TrainingSceneResponse = Field(..., description="场景信息") + websocket_url: Optional[str] = Field(None, description="WebSocket连接URL") + + +class EndTrainingRequest(BaseSchema): + """结束陪练请求""" + + generate_report: bool = Field(True, description="是否生成报告") + + +class EndTrainingResponse(BaseSchema): + """结束陪练响应""" + + session: TrainingSessionResponse = Field(..., description="会话信息") + report: Optional[TrainingReportResponse] = Field(None, description="陪练报告") + + +# ========== 列表查询相关 ========== + + +class TrainingSceneListQuery(BaseSchema): + """陪练场景列表查询参数""" + + category: Optional[str] = Field(None, description="场景分类") + status: Optional[TrainingSceneStatus] = Field(None, description="场景状态") + is_public: Optional[bool] = Field(None, description="是否公开") + search: Optional[str] = Field(None, description="搜索关键词") + page: int = Field(1, ge=1, description="页码") + page_size: int = Field(20, ge=1, le=100, description="每页数量") + + +class TrainingSessionListQuery(BaseSchema): + """陪练会话列表查询参数""" + + scene_id: Optional[int] = Field(None, description="场景ID") + status: Optional[TrainingSessionStatus] = Field(None, description="会话状态") + start_date: Optional[datetime] = Field(None, description="开始日期") + end_date: Optional[datetime] = Field(None, description="结束日期") + page: int = Field(1, ge=1, description="页码") + page_size: int = Field(20, ge=1, le=100, description="每页数量") + + +class PaginatedResponse(BaseModel, Generic[DataT]): + """分页响应模式""" + + items: List[DataT] = Field(..., description="数据列表") + total: int = Field(..., description="总数量") + page: int = Field(..., description="当前页码") + page_size: int = Field(..., description="每页数量") + pages: int = Field(..., description="总页数") + + +# 更新前向引用 +TrainingSessionResponse.model_rebuild() +TrainingReportResponse.model_rebuild() diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..3dd3315 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,154 @@ +""" +用户相关 Schema +""" + +from datetime import datetime +from typing import List, Optional + +from pydantic import EmailStr, Field, field_validator + +from .base import BaseSchema + + +class UserBase(BaseSchema): + """用户基础信息""" + + username: str = Field(..., min_length=3, max_length=50) + email: Optional[EmailStr] = None + phone: Optional[str] = Field(None, pattern=r"^1[3-9]\d{9}$") + full_name: Optional[str] = Field(None, max_length=100) + avatar_url: Optional[str] = None + bio: Optional[str] = None + role: str = Field(default="trainee", pattern="^(admin|manager|trainee)$") + gender: Optional[str] = Field(None, pattern="^(male|female)$") + school: Optional[str] = Field(None, max_length=100) + major: Optional[str] = Field(None, max_length=100) + + +class UserCreate(UserBase): + """创建用户""" + + password: str = Field(..., min_length=6, max_length=100) + + @field_validator("password") + def validate_password(cls, v): + if len(v) < 6: + raise ValueError("密码长度至少为6位") + return v + + +class UserUpdate(BaseSchema): + """更新用户""" + + email: Optional[EmailStr] = None + phone: Optional[str] = Field(None, pattern=r"^1[3-9]\d{9}$") + full_name: Optional[str] = Field(None, max_length=100) + avatar_url: Optional[str] = None + bio: Optional[str] = None + role: Optional[str] = Field(None, pattern="^(admin|manager|trainee)$") + is_active: Optional[bool] = None + gender: Optional[str] = Field(None, pattern="^(male|female)$") + school: Optional[str] = Field(None, max_length=100) + major: Optional[str] = Field(None, max_length=100) + + +class UserPasswordUpdate(BaseSchema): + """更新密码""" + + old_password: str + new_password: str = Field(..., min_length=6, max_length=100) + + +class UserInDBBase(UserBase): + """数据库中的用户基础信息""" + + id: int + is_active: bool + is_verified: bool + created_at: datetime + updated_at: datetime + last_login_at: Optional[datetime] = None + + +class User(UserInDBBase): + """用户信息(不含敏感数据)""" + + teams: List["TeamBasic"] = [] + + +class UserWithPassword(UserInDBBase): + """用户信息(含密码)""" + + hashed_password: str + + +# Team Schemas +class TeamBase(BaseSchema): + """团队基础信息""" + + name: str = Field(..., min_length=2, max_length=100) + code: str = Field(..., min_length=2, max_length=50) + description: Optional[str] = None + team_type: str = Field( + default="department", pattern="^(department|project|study_group)$" + ) + + +class TeamCreate(TeamBase): + """创建团队""" + + leader_id: Optional[int] = None + parent_id: Optional[int] = None + + +class TeamUpdate(BaseSchema): + """更新团队""" + + name: Optional[str] = Field(None, min_length=2, max_length=100) + description: Optional[str] = None + leader_id: Optional[int] = None + is_active: Optional[bool] = None + + +class TeamBasic(BaseSchema): + """团队基本信息""" + + id: int + name: str + code: str + team_type: str + + +class Team(TeamBase): + """团队完整信息""" + + id: int + is_active: bool + leader_id: Optional[int] = None + parent_id: Optional[int] = None + created_at: datetime + updated_at: datetime + member_count: Optional[int] = 0 + + +class TeamWithMembers(Team): + """团队信息(含成员)""" + + members: List[User] = [] + leader: Optional[User] = None + + +# 避免循环引用 +UserBase.model_rebuild() +User.model_rebuild() +Team.model_rebuild() + + +# Filter schemas +class UserFilter(BaseSchema): + """用户筛选条件""" + + role: Optional[str] = Field(None, pattern="^(admin|manager|trainee)$") + is_active: Optional[bool] = None + team_id: Optional[int] = None + keyword: Optional[str] = None # 搜索用户名、邮箱、姓名 diff --git a/backend/app/schemas/yanji.py b/backend/app/schemas/yanji.py new file mode 100644 index 0000000..dba2f43 --- /dev/null +++ b/backend/app/schemas/yanji.py @@ -0,0 +1,61 @@ +""" +言迹智能工牌相关Schema定义 +""" + +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class ConversationMessage(BaseModel): + """单条对话消息""" + + role: str = Field(..., description="角色:consultant=销售人员,customer=客户") + text: str = Field(..., description="对话文本内容") + begin_time: Optional[str] = Field(None, description="开始时间偏移量(毫秒)") + end_time: Optional[str] = Field(None, description="结束时间偏移量(毫秒)") + + +class YanjiConversation(BaseModel): + """完整的对话记录""" + + audio_id: int = Field(..., description="录音ID") + visit_id: str = Field(..., description="来访单ID") + start_time: str = Field(..., description="录音开始时间") + duration: int = Field(..., description="录音时长(毫秒)") + consultant_name: str = Field(..., description="销售人员姓名") + consultant_phone: str = Field(..., description="销售人员手机号") + conversation: List[ConversationMessage] = Field(..., description="对话内容列表") + + +class GetConversationsByVisitIdsRequest(BaseModel): + """根据来访单ID获取对话记录请求""" + + external_visit_ids: List[str] = Field( + ..., + min_length=1, + max_length=10, + description="三方来访单ID列表(最多10个)", + ) + + +class GetConversationsByVisitIdsResponse(BaseModel): + """获取对话记录响应""" + + conversations: List[YanjiConversation] = Field(..., description="对话记录列表") + total: int = Field(..., description="总数量") + + +class GetConversationsRequest(BaseModel): + """获取员工对话记录请求""" + + consultant_phone: str = Field(..., description="员工手机号") + limit: int = Field(default=10, ge=1, le=100, description="获取数量") + + +class GetConversationsResponse(BaseModel): + """获取员工对话记录响应""" + + conversations: List[YanjiConversation] = Field(..., description="对话记录列表") + total: int = Field(..., description="总数量") + diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..607c22e --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""业务逻辑服务包""" diff --git a/backend/app/services/ability_assessment_service.py b/backend/app/services/ability_assessment_service.py new file mode 100644 index 0000000..0bdac52 --- /dev/null +++ b/backend/app/services/ability_assessment_service.py @@ -0,0 +1,272 @@ +""" +能力评估服务 +用于分析用户对话数据,生成能力评估报告和课程推荐 + +使用 Python 原生实现 +""" +import json +import logging +from typing import Dict, Any, List, Literal +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.models.ability import AbilityAssessment +from app.services.ai import ability_analysis_service + +logger = logging.getLogger(__name__) + + +class AbilityAssessmentService: + """能力评估服务类""" + + async def analyze_yanji_conversations( + self, + user_id: int, + phone: str, + db: AsyncSession, + yanji_service, + engine: Literal["v2"] = "v2" + ) -> Dict[str, Any]: + """ + 分析言迹对话并生成能力评估及课程推荐 + + Args: + user_id: 用户ID + phone: 用户手机号(用于获取言迹数据) + db: 数据库会话 + yanji_service: 言迹服务实例 + engine: 引擎类型(v2=Python原生) + + Returns: + 评估结果字典,包含: + - assessment_id: 评估记录ID + - total_score: 综合评分 + - dimensions: 能力维度列表 + - recommended_courses: 推荐课程列表 + - conversation_count: 分析的对话数量 + + Raises: + ValueError: 未找到员工的录音记录 + Exception: API调用失败或其他错误 + """ + logger.info(f"开始分析言迹对话: user_id={user_id}, phone={phone}, engine={engine}") + + # 1. 获取员工对话数据(最多10条录音) + conversations = await yanji_service.get_employee_conversations_for_analysis( + phone=phone, + limit=10 + ) + + if not conversations: + logger.warning(f"未找到员工的录音记录: user_id={user_id}, phone={phone}") + raise ValueError("未找到该员工的录音记录") + + # 2. 合并所有对话历史 + all_dialogues = [] + for conv in conversations: + all_dialogues.extend(conv['dialogue_history']) + + logger.info( + f"准备分析: user_id={user_id}, " + f"对话数={len(conversations)}, " + f"总轮次={len(all_dialogues)}" + ) + + used_engine = "v2" + + # Python 原生实现 + logger.info(f"调用原生能力分析服务") + + # 将对话历史格式化为文本 + dialogue_text = self._format_dialogues_for_analysis(all_dialogues) + + # 调用原生服务 + result = await ability_analysis_service.analyze( + db=db, + user_id=user_id, + dialogue_history=dialogue_text + ) + + if not result.success: + raise Exception(f"能力分析失败: {result.error}") + + # 转换为兼容格式 + analysis_result = { + "analysis": { + "total_score": result.total_score, + "ability_dimensions": [ + {"name": d.name, "score": d.score, "feedback": d.feedback} + for d in result.ability_dimensions + ], + "course_recommendations": [ + { + "course_id": c.course_id, + "course_name": c.course_name, + "recommendation_reason": c.recommendation_reason, + "priority": c.priority, + "match_score": c.match_score, + } + for c in result.course_recommendations + ] + } + } + + logger.info( + f"能力分析完成 - total_score: {result.total_score}, " + f"provider: {result.ai_provider}, latency: {result.ai_latency_ms}ms" + ) + + # 4. 提取结果 + analysis = analysis_result.get('analysis', {}) + ability_dims = analysis.get('ability_dimensions', []) + course_recs = analysis.get('course_recommendations', []) + total_score = analysis.get('total_score') + + logger.info( + f"分析完成 (engine={used_engine}): total_score={total_score}, " + f"dimensions={len(ability_dims)}, courses={len(course_recs)}" + ) + + # 5. 保存能力评估记录到数据库 + assessment = AbilityAssessment( + user_id=user_id, + source_type='yanji_badge', + source_id=','.join([str(c['audio_id']) for c in conversations]), + total_score=total_score, + ability_dimensions=ability_dims, + recommended_courses=course_recs, + conversation_count=len(conversations) + ) + + db.add(assessment) + await db.commit() + await db.refresh(assessment) + + logger.info( + f"评估记录已保存: assessment_id={assessment.id}, " + f"user_id={user_id}, total_score={total_score}" + ) + + # 6. 返回评估结果 + return { + "assessment_id": assessment.id, + "total_score": total_score, + "dimensions": ability_dims, + "recommended_courses": course_recs, + "conversation_count": len(conversations), + "analyzed_at": assessment.analyzed_at, + "engine": used_engine, + } + + def _format_dialogues_for_analysis(self, dialogues: List[Dict[str, Any]]) -> str: + """ + 将对话历史列表格式化为文本 + + Args: + dialogues: 对话历史列表,每项包含 speaker, content 等字段 + + Returns: + 格式化后的对话文本 + """ + lines = [] + for i, d in enumerate(dialogues, 1): + speaker = d.get('speaker', 'unknown') + content = d.get('content', '') + + # 统一说话者标识 + if speaker in ['consultant', 'employee', 'user', '员工']: + speaker_label = '员工' + elif speaker in ['customer', 'client', '顾客', '客户']: + speaker_label = '顾客' + else: + speaker_label = speaker + + lines.append(f"[{i}] {speaker_label}: {content}") + + return '\n'.join(lines) + + async def get_user_assessment_history( + self, + user_id: int, + db: AsyncSession, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + 获取用户的能力评估历史记录 + + Args: + user_id: 用户ID + db: 数据库会话 + limit: 返回记录数量限制 + + Returns: + 评估历史记录列表 + """ + stmt = ( + select(AbilityAssessment) + .where(AbilityAssessment.user_id == user_id) + .order_by(AbilityAssessment.analyzed_at.desc()) + .limit(limit) + ) + + result = await db.execute(stmt) + assessments = result.scalars().all() + + history = [] + for assessment in assessments: + history.append({ + "id": assessment.id, + "source_type": assessment.source_type, + "total_score": assessment.total_score, + "ability_dimensions": assessment.ability_dimensions, + "recommended_courses": assessment.recommended_courses, + "conversation_count": assessment.conversation_count, + "analyzed_at": assessment.analyzed_at.isoformat() if assessment.analyzed_at else None, + "created_at": assessment.created_at.isoformat() if assessment.created_at else None + }) + + logger.info(f"获取评估历史: user_id={user_id}, count={len(history)}") + return history + + async def get_assessment_detail( + self, + assessment_id: int, + db: AsyncSession + ) -> Dict[str, Any]: + """ + 获取单个评估记录的详细信息 + + Args: + assessment_id: 评估记录ID + db: 数据库会话 + + Returns: + 评估详细信息 + + Raises: + ValueError: 评估记录不存在 + """ + stmt = select(AbilityAssessment).where(AbilityAssessment.id == assessment_id) + result = await db.execute(stmt) + assessment = result.scalar_one_or_none() + + if not assessment: + raise ValueError(f"评估记录不存在: assessment_id={assessment_id}") + + return { + "id": assessment.id, + "user_id": assessment.user_id, + "source_type": assessment.source_type, + "source_id": assessment.source_id, + "total_score": assessment.total_score, + "ability_dimensions": assessment.ability_dimensions, + "recommended_courses": assessment.recommended_courses, + "conversation_count": assessment.conversation_count, + "analyzed_at": assessment.analyzed_at.isoformat() if assessment.analyzed_at else None, + "created_at": assessment.created_at.isoformat() if assessment.created_at else None + } + + +def get_ability_assessment_service() -> AbilityAssessmentService: + """获取能力评估服务实例(依赖注入)""" + return AbilityAssessmentService() diff --git a/backend/app/services/ai/__init__.py b/backend/app/services/ai/__init__.py new file mode 100644 index 0000000..48749e6 --- /dev/null +++ b/backend/app/services/ai/__init__.py @@ -0,0 +1,151 @@ +""" +AI 服务模块 + +包含: +- AIService: 本地 AI 服务(支持 4sapi + OpenRouter 降级) +- LLM JSON Parser: 大模型 JSON 输出解析器 +- KnowledgeAnalysisServiceV2: 知识点分析服务(Python 原生实现) +- ExamGeneratorService: 试题生成服务(Python 原生实现) +- CourseChatServiceV2: 课程对话服务(Python 原生实现) +- PracticeSceneService: 陪练场景准备服务(Python 原生实现) +- AbilityAnalysisService: 智能工牌能力分析服务(Python 原生实现) +- AnswerJudgeService: 答案判断服务(Python 原生实现) +- PracticeAnalysisService: 陪练分析报告服务(Python 原生实现) +""" + +from .ai_service import ( + AIService, + AIResponse, + AIConfig, + AIServiceError, + AIProvider, + DEFAULT_MODEL, + MODEL_ANALYSIS, + MODEL_CREATIVE, + MODEL_IMAGE_GEN, + quick_chat, +) + +from .llm_json_parser import ( + parse_llm_json, + parse_with_fallback, + safe_json_loads, + clean_llm_output, + diagnose_json_error, + validate_json_schema, + ParseResult, + JSONParseError, + JSONUnrecoverableError, +) + +from .knowledge_analysis_v2 import ( + KnowledgeAnalysisServiceV2, + knowledge_analysis_service_v2, +) + +from .exam_generator_service import ( + ExamGeneratorService, + ExamGeneratorConfig, + exam_generator_service, + generate_exam, +) + +from .course_chat_service import ( + CourseChatServiceV2, + course_chat_service_v2, +) + +from .practice_scene_service import ( + PracticeSceneService, + PracticeScene, + PracticeSceneResult, + practice_scene_service, + prepare_practice_knowledge, +) + +from .ability_analysis_service import ( + AbilityAnalysisService, + AbilityAnalysisResult, + AbilityDimension, + CourseRecommendation, + ability_analysis_service, +) + +from .answer_judge_service import ( + AnswerJudgeService, + JudgeResult, + answer_judge_service, + judge_answer, +) + +from .practice_analysis_service import ( + PracticeAnalysisService, + PracticeAnalysisResult, + ScoreBreakdownItem, + AbilityDimensionItem, + DialogueAnnotation, + Suggestion, + practice_analysis_service, + analyze_practice_session, +) + +__all__ = [ + # AI Service + "AIService", + "AIResponse", + "AIConfig", + "AIServiceError", + "AIProvider", + "DEFAULT_MODEL", + "MODEL_ANALYSIS", + "MODEL_CREATIVE", + "MODEL_IMAGE_GEN", + "quick_chat", + # JSON Parser + "parse_llm_json", + "parse_with_fallback", + "safe_json_loads", + "clean_llm_output", + "diagnose_json_error", + "validate_json_schema", + "ParseResult", + "JSONParseError", + "JSONUnrecoverableError", + # Knowledge Analysis V2 + "KnowledgeAnalysisServiceV2", + "knowledge_analysis_service_v2", + # Exam Generator V2 + "ExamGeneratorService", + "ExamGeneratorConfig", + "exam_generator_service", + "generate_exam", + # Course Chat V2 + "CourseChatServiceV2", + "course_chat_service_v2", + # Practice Scene V2 + "PracticeSceneService", + "PracticeScene", + "PracticeSceneResult", + "practice_scene_service", + "prepare_practice_knowledge", + # Ability Analysis V2 + "AbilityAnalysisService", + "AbilityAnalysisResult", + "AbilityDimension", + "CourseRecommendation", + "ability_analysis_service", + # Answer Judge V2 + "AnswerJudgeService", + "JudgeResult", + "answer_judge_service", + "judge_answer", + # Practice Analysis V2 + "PracticeAnalysisService", + "PracticeAnalysisResult", + "ScoreBreakdownItem", + "AbilityDimensionItem", + "DialogueAnnotation", + "Suggestion", + "practice_analysis_service", + "analyze_practice_session", +] diff --git a/backend/app/services/ai/ability_analysis_service.py b/backend/app/services/ai/ability_analysis_service.py new file mode 100644 index 0000000..5d4f234 --- /dev/null +++ b/backend/app/services/ai/ability_analysis_service.py @@ -0,0 +1,479 @@ +""" +智能工牌能力分析与课程推荐服务 - Python 原生实现 + +功能: +- 分析员工与顾客的对话记录 +- 评估多维度能力得分 +- 基于能力短板推荐课程 + +提供稳定可靠的能力分析和课程推荐能力。 +""" + +import json +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import ExternalServiceError + +from .ai_service import AIService, AIResponse +from .llm_json_parser import parse_with_fallback, clean_llm_output +from .prompts.ability_analysis_prompts import ( + SYSTEM_PROMPT, + USER_PROMPT, + ABILITY_ANALYSIS_SCHEMA, + ABILITY_DIMENSIONS, +) + +logger = logging.getLogger(__name__) + + +# ==================== 数据结构 ==================== + +@dataclass +class AbilityDimension: + """能力维度评分""" + name: str + score: float + feedback: str + + +@dataclass +class CourseRecommendation: + """课程推荐""" + course_id: int + course_name: str + recommendation_reason: str + priority: str # high, medium, low + match_score: float + + +@dataclass +class AbilityAnalysisResult: + """能力分析结果""" + success: bool + total_score: float = 0.0 + ability_dimensions: List[AbilityDimension] = field(default_factory=list) + course_recommendations: List[CourseRecommendation] = field(default_factory=list) + ai_provider: str = "" + ai_model: str = "" + ai_tokens: int = 0 + ai_latency_ms: int = 0 + error: str = "" + + def to_dict(self) -> Dict[str, Any]: + """转换为字典""" + return { + "success": self.success, + "total_score": self.total_score, + "ability_dimensions": [ + {"name": d.name, "score": d.score, "feedback": d.feedback} + for d in self.ability_dimensions + ], + "course_recommendations": [ + { + "course_id": c.course_id, + "course_name": c.course_name, + "recommendation_reason": c.recommendation_reason, + "priority": c.priority, + "match_score": c.match_score, + } + for c in self.course_recommendations + ], + "ai_provider": self.ai_provider, + "ai_model": self.ai_model, + "ai_tokens": self.ai_tokens, + "ai_latency_ms": self.ai_latency_ms, + "error": self.error, + } + + +@dataclass +class UserPositionInfo: + """用户岗位信息""" + position_id: int + position_name: str + code: str + description: str + skills: Optional[Dict[str, Any]] + level: str + status: str + + +@dataclass +class CourseInfo: + """课程信息""" + id: int + name: str + description: str + category: str + tags: Optional[List[str]] + difficulty_level: int + duration_hours: float + + +# ==================== 服务类 ==================== + +class AbilityAnalysisService: + """ + 智能工牌能力分析服务 + + 使用 Python 原生实现。 + + 使用示例: + ```python + service = AbilityAnalysisService() + result = await service.analyze( + db=db_session, + user_id=1, + dialogue_history="顾客:你好,我想了解一下你们的服务..." + ) + print(result.total_score) + print(result.course_recommendations) + ``` + """ + + def __init__(self): + """初始化服务""" + self.ai_service = AIService(module_code="ability_analysis") + + async def analyze( + self, + db: AsyncSession, + user_id: int, + dialogue_history: str + ) -> AbilityAnalysisResult: + """ + 分析员工能力并推荐课程 + + Args: + db: 数据库会话(支持多租户,每个租户传入各自的会话) + user_id: 用户ID + dialogue_history: 对话记录 + + Returns: + AbilityAnalysisResult 分析结果 + """ + try: + logger.info(f"开始能力分析 - user_id: {user_id}") + + # 1. 验证输入 + if not dialogue_history or not dialogue_history.strip(): + return AbilityAnalysisResult( + success=False, + error="对话记录不能为空" + ) + + # 2. 查询用户岗位信息 + user_positions = await self._get_user_positions(db, user_id) + user_info_str = self._format_user_info(user_positions) + + logger.info(f"用户岗位信息: {len(user_positions)} 个岗位") + + # 3. 查询所有可选课程 + courses = await self._get_published_courses(db) + courses_str = self._format_courses(courses) + + logger.info(f"可选课程: {len(courses)} 门") + + # 4. 调用 AI 分析 + ai_response = await self._call_ai_analysis( + dialogue_history=dialogue_history, + user_info=user_info_str, + courses=courses_str + ) + + logger.info( + f"AI 分析完成 - provider: {ai_response.provider}, " + f"tokens: {ai_response.total_tokens}, latency: {ai_response.latency_ms}ms" + ) + + # 5. 解析 JSON 结果 + analysis_data = self._parse_analysis_result(ai_response.content, courses) + + # 6. 构建返回结果 + result = AbilityAnalysisResult( + success=True, + total_score=analysis_data.get("total_score", 0), + ability_dimensions=[ + AbilityDimension( + name=d.get("name", ""), + score=d.get("score", 0), + feedback=d.get("feedback", "") + ) + for d in analysis_data.get("ability_dimensions", []) + ], + course_recommendations=[ + CourseRecommendation( + course_id=c.get("course_id", 0), + course_name=c.get("course_name", ""), + recommendation_reason=c.get("recommendation_reason", ""), + priority=c.get("priority", "medium"), + match_score=c.get("match_score", 0) + ) + for c in analysis_data.get("course_recommendations", []) + ], + ai_provider=ai_response.provider, + ai_model=ai_response.model, + ai_tokens=ai_response.total_tokens, + ai_latency_ms=ai_response.latency_ms, + ) + + logger.info( + f"能力分析完成 - user_id: {user_id}, total_score: {result.total_score}, " + f"recommendations: {len(result.course_recommendations)}" + ) + + return result + + except Exception as e: + logger.error( + f"能力分析失败 - user_id: {user_id}, error: {e}", + exc_info=True + ) + return AbilityAnalysisResult( + success=False, + error=str(e) + ) + + async def _get_user_positions( + self, + db: AsyncSession, + user_id: int + ) -> List[UserPositionInfo]: + """ + 查询用户的岗位信息 + + 获取用户基本信息 + """ + query = text(""" + SELECT + p.id as position_id, + p.name as position_name, + p.code, + p.description, + p.skills, + p.level, + p.status + FROM positions p + INNER JOIN position_members pm ON p.id = pm.position_id + WHERE pm.user_id = :user_id + AND pm.is_deleted = 0 + AND p.is_deleted = 0 + """) + + result = await db.execute(query, {"user_id": user_id}) + rows = result.fetchall() + + positions = [] + for row in rows: + # 解析 skills JSON + skills = None + if row.skills: + if isinstance(row.skills, str): + try: + skills = json.loads(row.skills) + except json.JSONDecodeError: + skills = None + else: + skills = row.skills + + positions.append(UserPositionInfo( + position_id=row.position_id, + position_name=row.position_name, + code=row.code or "", + description=row.description or "", + skills=skills, + level=row.level or "", + status=row.status or "" + )) + + return positions + + async def _get_published_courses(self, db: AsyncSession) -> List[CourseInfo]: + """ + 查询所有已发布的课程 + + 获取所有课程列表 + """ + query = text(""" + SELECT + id, + name, + description, + category, + tags, + difficulty_level, + duration_hours + FROM courses + WHERE status = 'published' + AND is_deleted = FALSE + ORDER BY sort_order + """) + + result = await db.execute(query) + rows = result.fetchall() + + courses = [] + for row in rows: + # 解析 tags JSON + tags = None + if row.tags: + if isinstance(row.tags, str): + try: + tags = json.loads(row.tags) + except json.JSONDecodeError: + tags = None + else: + tags = row.tags + + courses.append(CourseInfo( + id=row.id, + name=row.name, + description=row.description or "", + category=row.category or "", + tags=tags, + difficulty_level=row.difficulty_level or 3, + duration_hours=row.duration_hours or 0 + )) + + return courses + + def _format_user_info(self, positions: List[UserPositionInfo]) -> str: + """格式化用户岗位信息为文本""" + if not positions: + return "暂无岗位信息" + + lines = [] + for p in positions: + info = f"- 岗位:{p.position_name}({p.code})" + if p.level: + info += f",级别:{p.level}" + if p.description: + info += f"\n 描述:{p.description}" + if p.skills: + skills_str = json.dumps(p.skills, ensure_ascii=False) + info += f"\n 核心技能:{skills_str}" + lines.append(info) + + return "\n".join(lines) + + def _format_courses(self, courses: List[CourseInfo]) -> str: + """格式化课程列表为文本""" + if not courses: + return "暂无可选课程" + + lines = [] + for c in courses: + info = f"- ID: {c.id}, 课程名称: {c.name}" + if c.category: + info += f", 分类: {c.category}" + if c.difficulty_level: + info += f", 难度: {c.difficulty_level}" + if c.duration_hours: + info += f", 时长: {c.duration_hours}小时" + if c.description: + # 截断过长的描述 + desc = c.description[:100] + "..." if len(c.description) > 100 else c.description + info += f"\n 描述: {desc}" + lines.append(info) + + return "\n".join(lines) + + async def _call_ai_analysis( + self, + dialogue_history: str, + user_info: str, + courses: str + ) -> AIResponse: + """调用 AI 进行能力分析""" + # 构建用户消息 + user_message = USER_PROMPT.format( + dialogue_history=dialogue_history, + user_info=user_info, + courses=courses + ) + + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_message} + ] + + # 调用 AI(自动支持 4sapi → OpenRouter 降级) + response = await self.ai_service.chat( + messages=messages, + temperature=0.7, # 保持一定创意性 + prompt_name="ability_analysis" + ) + + return response + + def _parse_analysis_result( + self, + ai_output: str, + courses: List[CourseInfo] + ) -> Dict[str, Any]: + """ + 解析 AI 输出的分析结果 JSON + + 使用 LLM JSON Parser 进行多层兜底解析 + """ + # 先清洗输出 + cleaned_output, rules = clean_llm_output(ai_output) + if rules: + logger.debug(f"AI 输出已清洗: {rules}") + + # 使用带 Schema 校验的解析 + parsed = parse_with_fallback( + cleaned_output, + schema=ABILITY_ANALYSIS_SCHEMA, + default={"analysis": {}}, + validate_schema=True, + on_error="default" + ) + + # 提取 analysis 部分 + analysis = parsed.get("analysis", {}) + + # 后处理:验证课程推荐的有效性 + valid_course_ids = {c.id for c in courses} + valid_recommendations = [] + + for rec in analysis.get("course_recommendations", []): + course_id = rec.get("course_id") + if course_id in valid_course_ids: + valid_recommendations.append(rec) + else: + logger.warning(f"推荐的课程ID不存在: {course_id}") + + analysis["course_recommendations"] = valid_recommendations + + # 确保能力维度完整 + existing_dims = {d.get("name") for d in analysis.get("ability_dimensions", [])} + for dim_name in ABILITY_DIMENSIONS: + if dim_name not in existing_dims: + logger.warning(f"缺少能力维度: {dim_name},使用默认值") + analysis.setdefault("ability_dimensions", []).append({ + "name": dim_name, + "score": 70, + "feedback": "暂无具体评价" + }) + + return analysis + + +# ==================== 全局实例 ==================== + +ability_analysis_service = AbilityAnalysisService() + + + + + + + + + diff --git a/backend/app/services/ai/ai_service.py b/backend/app/services/ai/ai_service.py new file mode 100644 index 0000000..1213942 --- /dev/null +++ b/backend/app/services/ai/ai_service.py @@ -0,0 +1,747 @@ +""" +本地 AI 服务 - 遵循瑞小美 AI 接入规范 + +功能: +- 支持 4sapi.com(首选)和 OpenRouter(备选)自动降级 +- 统一的请求/响应格式 +- 调用日志记录 +""" + +import json +import logging +import time +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, Dict, List, Optional, Union +from enum import Enum + +import httpx + +logger = logging.getLogger(__name__) + + +class AIProvider(Enum): + """AI 服务商""" + PRIMARY = "4sapi" # 首选:4sapi.com + FALLBACK = "openrouter" # 备选:OpenRouter + + +@dataclass +class AIResponse: + """AI 响应结果""" + content: str # AI 回复内容 + model: str = "" # 使用的模型 + provider: str = "" # 实际使用的服务商 + input_tokens: int = 0 # 输入 token 数 + output_tokens: int = 0 # 输出 token 数 + total_tokens: int = 0 # 总 token 数 + cost: float = 0.0 # 费用(美元) + latency_ms: int = 0 # 响应延迟(毫秒) + raw_response: Dict[str, Any] = field(default_factory=dict) # 原始响应 + images: List[str] = field(default_factory=list) # 图像生成结果 + annotations: Dict[str, Any] = field(default_factory=dict) # PDF 解析注释 + + +@dataclass +class AIConfig: + """AI 服务配置""" + primary_api_key: str # 通用 Key(Gemini/DeepSeek 等) + anthropic_api_key: str = "" # Claude 专属 Key + primary_base_url: str = "https://4sapi.com/v1" + fallback_api_key: str = "" + fallback_base_url: str = "https://openrouter.ai/api/v1" + default_model: str = "claude-opus-4-5-20251101-thinking" # 默认使用最强模型 + timeout: float = 120.0 + max_retries: int = 2 + + +# Claude 模型列表(需要使用 anthropic_api_key) +CLAUDE_MODELS = [ + "claude-opus-4-5-20251101-thinking", + "claude-opus-4-5-20251101", + "claude-sonnet-4-20250514", + "claude-3-opus", + "claude-3-sonnet", + "claude-3-haiku", +] + + +def is_claude_model(model: str) -> bool: + """判断是否为 Claude 模型""" + model_lower = model.lower() + return any(claude in model_lower for claude in ["claude", "anthropic"]) + + +# 模型名称映射:4sapi -> OpenRouter +MODEL_MAPPING = { + # 4sapi 使用简短名称,OpenRouter 使用完整路径 + "gemini-3-flash-preview": "google/gemini-3-flash-preview", + "gemini-3-pro-preview": "google/gemini-3-pro-preview", + "claude-opus-4-5-20251101-thinking": "anthropic/claude-opus-4.5", + "gemini-2.5-flash-image-preview": "google/gemini-2.0-flash-exp:free", +} + +# 反向映射:OpenRouter -> 4sapi +MODEL_MAPPING_REVERSE = {v: k for k, v in MODEL_MAPPING.items()} + + +class AIServiceError(Exception): + """AI 服务错误""" + def __init__(self, message: str, provider: str = "", status_code: int = 0): + super().__init__(message) + self.provider = provider + self.status_code = status_code + + +class AIService: + """ + 本地 AI 服务 + + 遵循瑞小美 AI 接入规范: + - 首选 4sapi.com,失败自动降级到 OpenRouter + - 统一的响应格式 + - 自动模型名称转换 + + 使用示例: + ```python + ai = AIService(module_code="knowledge_analysis") + response = await ai.chat( + messages=[ + {"role": "system", "content": "你是助手"}, + {"role": "user", "content": "你好"} + ], + prompt_name="greeting" + ) + print(response.content) + ``` + """ + + def __init__( + self, + module_code: str = "default", + config: Optional[AIConfig] = None, + db_session: Any = None + ): + """ + 初始化 AI 服务 + + 配置加载优先级(遵循瑞小美 AI 接入规范): + 1. 显式传入的 config 参数 + 2. 数据库 ai_config 表(推荐) + 3. 环境变量(fallback) + + Args: + module_code: 模块标识,用于统计 + config: AI 配置,None 则从数据库/环境变量读取 + db_session: 数据库会话,用于记录调用日志和读取配置 + """ + self.module_code = module_code + self.db_session = db_session + self.config = config or self._load_config(db_session) + + logger.info(f"AIService 初始化: module={module_code}, primary={self.config.primary_base_url}") + + def _load_config(self, db_session: Any) -> AIConfig: + """ + 加载配置 + + 配置加载优先级(遵循瑞小美 AI 接入规范): + 1. 管理库 tenant_configs 表(推荐,通过 DynamicConfig) + 2. 环境变量(fallback) + + Args: + db_session: 数据库会话(可选,用于日志记录) + + Returns: + AIConfig 配置对象 + """ + # 优先从管理库加载(同步方式) + try: + config = self._load_config_from_admin_db() + if config: + logger.info("✅ AI 配置已从管理库(tenant_configs)加载") + return config + except Exception as e: + logger.debug(f"从管理库加载 AI 配置失败: {e}") + + # Fallback 到环境变量 + logger.info("AI 配置从环境变量加载") + return self._load_config_from_env() + + def _load_config_from_admin_db(self) -> Optional[AIConfig]: + """ + 从管理库 tenant_configs 表加载配置 + + 使用同步方式直接查询 kaopeilian_admin.tenant_configs 表 + + Returns: + AIConfig 配置对象,如果无数据则返回 None + """ + import os + + # 获取当前租户编码 + tenant_code = os.getenv("TENANT_CODE", "demo") + + # 获取管理库连接信息 + admin_db_host = os.getenv("ADMIN_DB_HOST", "prod-mysql") + admin_db_port = int(os.getenv("ADMIN_DB_PORT", "3306")) + admin_db_user = os.getenv("ADMIN_DB_USER", "root") + admin_db_password = os.getenv("ADMIN_DB_PASSWORD", "") + admin_db_name = os.getenv("ADMIN_DB_NAME", "kaopeilian_admin") + + if not admin_db_password: + logger.debug("ADMIN_DB_PASSWORD 未配置,跳过管理库配置加载") + return None + + try: + from sqlalchemy import create_engine, text + import urllib.parse + + # 构建连接 URL + encoded_password = urllib.parse.quote_plus(admin_db_password) + admin_db_url = f"mysql+pymysql://{admin_db_user}:{encoded_password}@{admin_db_host}:{admin_db_port}/{admin_db_name}?charset=utf8mb4" + + engine = create_engine(admin_db_url, pool_pre_ping=True) + + with engine.connect() as conn: + # 1. 获取租户 ID + result = conn.execute( + text("SELECT id FROM tenants WHERE code = :code AND status = 'active'"), + {"code": tenant_code} + ) + row = result.fetchone() + if not row: + logger.debug(f"租户 {tenant_code} 不存在或未激活") + engine.dispose() + return None + + tenant_id = row[0] + + # 2. 获取 AI 配置 + result = conn.execute( + text(""" + SELECT config_key, config_value + FROM tenant_configs + WHERE tenant_id = :tenant_id AND config_group = 'ai' + """), + {"tenant_id": tenant_id} + ) + rows = result.fetchall() + + engine.dispose() + + if not rows: + logger.debug(f"租户 {tenant_code} 无 AI 配置") + return None + + # 转换为字典 + config_dict = {row[0]: row[1] for row in rows} + + # 检查必要的配置是否存在 + primary_key = config_dict.get("AI_PRIMARY_API_KEY", "") + if not primary_key: + logger.warning(f"租户 {tenant_code} 的 AI_PRIMARY_API_KEY 为空") + return None + + logger.info(f"✅ 从管理库加载租户 {tenant_code} 的 AI 配置成功") + + return AIConfig( + primary_api_key=primary_key, + anthropic_api_key=config_dict.get("AI_ANTHROPIC_API_KEY", ""), + primary_base_url=config_dict.get("AI_PRIMARY_BASE_URL", "https://4sapi.com/v1"), + fallback_api_key=config_dict.get("AI_FALLBACK_API_KEY", ""), + fallback_base_url=config_dict.get("AI_FALLBACK_BASE_URL", "https://openrouter.ai/api/v1"), + default_model=config_dict.get("AI_DEFAULT_MODEL", "claude-opus-4-5-20251101-thinking"), + timeout=float(config_dict.get("AI_TIMEOUT", "120")), + ) + except Exception as e: + logger.debug(f"从管理库读取 AI 配置异常: {e}") + return None + + def _load_config_from_env(self) -> AIConfig: + """ + 从环境变量加载配置 + + ⚠️ 强制要求(遵循瑞小美 AI 接入规范): + - 禁止在代码中硬编码 API Key + - 必须通过环境变量配置 Key + + 必须配置的环境变量: + - AI_PRIMARY_API_KEY: 通用 Key(用于 Gemini/DeepSeek 等) + - AI_ANTHROPIC_API_KEY: Claude 专属 Key + """ + import os + + primary_api_key = os.getenv("AI_PRIMARY_API_KEY", "") + anthropic_api_key = os.getenv("AI_ANTHROPIC_API_KEY", "") + + # 检查必要的 Key 是否已配置 + if not primary_api_key: + logger.warning("⚠️ AI_PRIMARY_API_KEY 未配置,AI 服务可能无法正常工作") + if not anthropic_api_key: + logger.warning("⚠️ AI_ANTHROPIC_API_KEY 未配置,Claude 模型调用将失败") + + return AIConfig( + # 通用 Key(Gemini/DeepSeek 等非 Anthropic 模型) + primary_api_key=primary_api_key, + # Claude 专属 Key + anthropic_api_key=anthropic_api_key, + primary_base_url=os.getenv("AI_PRIMARY_BASE_URL", "https://4sapi.com/v1"), + fallback_api_key=os.getenv("AI_FALLBACK_API_KEY", ""), + fallback_base_url=os.getenv("AI_FALLBACK_BASE_URL", "https://openrouter.ai/api/v1"), + # 默认模型:遵循"优先最强"原则,使用 Claude Opus 4.5 + default_model=os.getenv("AI_DEFAULT_MODEL", "claude-opus-4-5-20251101-thinking"), + timeout=float(os.getenv("AI_TIMEOUT", "120")), + ) + + def _convert_model_name(self, model: str, provider: AIProvider) -> str: + """ + 转换模型名称以匹配服务商格式 + + Args: + model: 原始模型名称 + provider: 目标服务商 + + Returns: + 转换后的模型名称 + """ + if provider == AIProvider.FALLBACK: + # 4sapi -> OpenRouter + return MODEL_MAPPING.get(model, f"google/{model}" if "/" not in model else model) + else: + # OpenRouter -> 4sapi + return MODEL_MAPPING_REVERSE.get(model, model.split("/")[-1] if "/" in model else model) + + async def chat( + self, + messages: List[Dict[str, str]], + model: Optional[str] = None, + temperature: float = 0.7, + max_tokens: Optional[int] = None, + prompt_name: str = "default", + **kwargs + ) -> AIResponse: + """ + 文本聊天 + + Args: + messages: 消息列表 [{"role": "system/user/assistant", "content": "..."}] + model: 模型名称,None 使用默认模型 + temperature: 温度参数 + max_tokens: 最大输出 token 数 + prompt_name: 提示词名称,用于统计 + **kwargs: 其他参数 + + Returns: + AIResponse 响应对象 + """ + model = model or self.config.default_model + + # 构建请求体 + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + } + if max_tokens: + payload["max_tokens"] = max_tokens + + # 首选服务商 + try: + return await self._call_provider( + provider=AIProvider.PRIMARY, + endpoint="/chat/completions", + payload=payload, + prompt_name=prompt_name + ) + except AIServiceError as e: + logger.warning(f"首选服务商调用失败: {e}, 尝试降级到备选服务商") + + # 如果没有备选 API Key,直接抛出异常 + if not self.config.fallback_api_key: + raise + + # 降级到备选服务商 + # 转换模型名称 + fallback_model = self._convert_model_name(model, AIProvider.FALLBACK) + payload["model"] = fallback_model + + return await self._call_provider( + provider=AIProvider.FALLBACK, + endpoint="/chat/completions", + payload=payload, + prompt_name=prompt_name + ) + + async def chat_stream( + self, + messages: List[Dict[str, str]], + model: Optional[str] = None, + temperature: float = 0.7, + max_tokens: Optional[int] = None, + prompt_name: str = "default", + **kwargs + ) -> AsyncGenerator[str, None]: + """ + 流式文本聊天 + + Args: + messages: 消息列表 [{"role": "system/user/assistant", "content": "..."}] + model: 模型名称,None 使用默认模型 + temperature: 温度参数 + max_tokens: 最大输出 token 数 + prompt_name: 提示词名称,用于统计 + **kwargs: 其他参数 + + Yields: + str: 文本块(逐字返回) + """ + model = model or self.config.default_model + + # 构建请求体 + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + "stream": True, + } + if max_tokens: + payload["max_tokens"] = max_tokens + + # 首选服务商 + try: + async for chunk in self._call_provider_stream( + provider=AIProvider.PRIMARY, + endpoint="/chat/completions", + payload=payload, + prompt_name=prompt_name + ): + yield chunk + return + except AIServiceError as e: + logger.warning(f"首选服务商流式调用失败: {e}, 尝试降级到备选服务商") + + # 如果没有备选 API Key,直接抛出异常 + if not self.config.fallback_api_key: + raise + + # 降级到备选服务商 + # 转换模型名称 + fallback_model = self._convert_model_name(model, AIProvider.FALLBACK) + payload["model"] = fallback_model + + async for chunk in self._call_provider_stream( + provider=AIProvider.FALLBACK, + endpoint="/chat/completions", + payload=payload, + prompt_name=prompt_name + ): + yield chunk + + async def _call_provider_stream( + self, + provider: AIProvider, + endpoint: str, + payload: Dict[str, Any], + prompt_name: str + ) -> AsyncGenerator[str, None]: + """ + 流式调用指定服务商 + + Args: + provider: 服务商 + endpoint: API 端点 + payload: 请求体 + prompt_name: 提示词名称 + + Yields: + str: 文本块 + """ + # 获取配置 + if provider == AIProvider.PRIMARY: + base_url = self.config.primary_base_url + # 根据模型选择 API Key:Claude 用专属 Key,其他用通用 Key + model = payload.get("model", "") + if is_claude_model(model) and self.config.anthropic_api_key: + api_key = self.config.anthropic_api_key + logger.debug(f"[Stream] 使用 Claude 专属 Key 调用模型: {model}") + else: + api_key = self.config.primary_api_key + else: + api_key = self.config.fallback_api_key + base_url = self.config.fallback_base_url + + url = f"{base_url.rstrip('/')}{endpoint}" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + # OpenRouter 需要额外的 header + if provider == AIProvider.FALLBACK: + headers["HTTP-Referer"] = "https://kaopeilian.ireborn.com.cn" + headers["X-Title"] = "KaoPeiLian" + + start_time = time.time() + + try: + timeout = httpx.Timeout(self.config.timeout, connect=10.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + logger.info(f"流式调用 AI 服务: provider={provider.value}, model={payload.get('model')}") + + async with client.stream("POST", url, json=payload, headers=headers) as response: + # 检查响应状态 + if response.status_code != 200: + error_text = await response.aread() + logger.error(f"AI 服务流式返回错误: status={response.status_code}, body={error_text[:500]}") + raise AIServiceError( + f"API 流式请求失败: HTTP {response.status_code}", + provider=provider.value, + status_code=response.status_code + ) + + # 处理 SSE 流 + async for line in response.aiter_lines(): + if not line or not line.strip(): + continue + + # 解析 SSE 数据行 + if line.startswith("data: "): + data_str = line[6:] # 移除 "data: " 前缀 + + # 检查是否是结束标记 + if data_str.strip() == "[DONE]": + logger.info(f"流式响应完成: provider={provider.value}") + return + + try: + event_data = json.loads(data_str) + + # 提取 delta 内容 + choices = event_data.get("choices", []) + if choices: + delta = choices[0].get("delta", {}) + content = delta.get("content", "") + if content: + yield content + + except json.JSONDecodeError as e: + logger.debug(f"解析流式数据失败: {e} - 数据: {data_str[:100]}") + continue + + latency_ms = int((time.time() - start_time) * 1000) + logger.info(f"流式调用完成: provider={provider.value}, latency={latency_ms}ms") + + except httpx.TimeoutException: + latency_ms = int((time.time() - start_time) * 1000) + logger.error(f"AI 服务流式超时: provider={provider.value}, latency={latency_ms}ms") + raise AIServiceError(f"流式请求超时({self.config.timeout}秒)", provider=provider.value) + + except httpx.RequestError as e: + logger.error(f"AI 服务流式网络错误: provider={provider.value}, error={e}") + raise AIServiceError(f"流式网络错误: {e}", provider=provider.value) + + async def _call_provider( + self, + provider: AIProvider, + endpoint: str, + payload: Dict[str, Any], + prompt_name: str + ) -> AIResponse: + """ + 调用指定服务商 + + Args: + provider: 服务商 + endpoint: API 端点 + payload: 请求体 + prompt_name: 提示词名称 + + Returns: + AIResponse 响应对象 + """ + # 获取配置 + if provider == AIProvider.PRIMARY: + base_url = self.config.primary_base_url + # 根据模型选择 API Key:Claude 用专属 Key,其他用通用 Key + model = payload.get("model", "") + if is_claude_model(model) and self.config.anthropic_api_key: + api_key = self.config.anthropic_api_key + logger.debug(f"使用 Claude 专属 Key 调用模型: {model}") + else: + api_key = self.config.primary_api_key + else: + api_key = self.config.fallback_api_key + base_url = self.config.fallback_base_url + + url = f"{base_url.rstrip('/')}{endpoint}" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + # OpenRouter 需要额外的 header + if provider == AIProvider.FALLBACK: + headers["HTTP-Referer"] = "https://kaopeilian.ireborn.com.cn" + headers["X-Title"] = "KaoPeiLian" + + start_time = time.time() + + try: + async with httpx.AsyncClient(timeout=self.config.timeout) as client: + logger.info(f"调用 AI 服务: provider={provider.value}, model={payload.get('model')}") + + response = await client.post(url, json=payload, headers=headers) + + latency_ms = int((time.time() - start_time) * 1000) + + # 检查响应状态 + if response.status_code != 200: + error_text = response.text + logger.error(f"AI 服务返回错误: status={response.status_code}, body={error_text[:500]}") + raise AIServiceError( + f"API 请求失败: HTTP {response.status_code}", + provider=provider.value, + status_code=response.status_code + ) + + data = response.json() + + # 解析响应 + ai_response = self._parse_response(data, provider, latency_ms) + + # 记录日志 + logger.info( + f"AI 调用成功: provider={provider.value}, model={ai_response.model}, " + f"tokens={ai_response.total_tokens}, latency={latency_ms}ms" + ) + + # 保存到数据库(如果有 session) + await self._log_call(prompt_name, ai_response) + + return ai_response + + except httpx.TimeoutException: + latency_ms = int((time.time() - start_time) * 1000) + logger.error(f"AI 服务超时: provider={provider.value}, latency={latency_ms}ms") + raise AIServiceError(f"请求超时({self.config.timeout}秒)", provider=provider.value) + + except httpx.RequestError as e: + logger.error(f"AI 服务网络错误: provider={provider.value}, error={e}") + raise AIServiceError(f"网络错误: {e}", provider=provider.value) + + def _parse_response( + self, + data: Dict[str, Any], + provider: AIProvider, + latency_ms: int + ) -> AIResponse: + """解析 API 响应""" + # 提取内容 + choices = data.get("choices", []) + if not choices: + raise AIServiceError("响应中没有 choices") + + message = choices[0].get("message", {}) + content = message.get("content", "") + + # 提取 usage + usage = data.get("usage", {}) + input_tokens = usage.get("prompt_tokens", 0) + output_tokens = usage.get("completion_tokens", 0) + total_tokens = usage.get("total_tokens", input_tokens + output_tokens) + + # 提取费用(如果有) + cost = usage.get("total_cost", 0.0) + + return AIResponse( + content=content, + model=data.get("model", ""), + provider=provider.value, + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=total_tokens, + cost=cost, + latency_ms=latency_ms, + raw_response=data + ) + + async def _log_call(self, prompt_name: str, response: AIResponse) -> None: + """记录调用日志到数据库""" + if not self.db_session: + return + + try: + # TODO: 实现调用日志记录 + # 可以参考 ai_call_logs 表结构 + pass + except Exception as e: + logger.warning(f"记录 AI 调用日志失败: {e}") + + async def analyze_document( + self, + content: str, + prompt: str, + model: Optional[str] = None, + prompt_name: str = "document_analysis" + ) -> AIResponse: + """ + 分析文档内容 + + Args: + content: 文档内容 + prompt: 分析提示词 + model: 模型名称 + prompt_name: 提示词名称 + + Returns: + AIResponse 响应对象 + """ + messages = [ + {"role": "user", "content": f"{prompt}\n\n文档内容:\n{content}"} + ] + + return await self.chat( + messages=messages, + model=model, + temperature=0.1, # 文档分析使用低温度 + prompt_name=prompt_name + ) + + +# 便捷函数 +async def quick_chat( + messages: List[Dict[str, str]], + model: Optional[str] = None, + module_code: str = "quick" +) -> str: + """ + 快速聊天,返回纯文本 + + Args: + messages: 消息列表 + model: 模型名称 + module_code: 模块标识 + + Returns: + AI 回复的文本内容 + """ + ai = AIService(module_code=module_code) + response = await ai.chat(messages, model=model) + return response.content + + +# 模型常量(遵循瑞小美 AI 接入规范) +# 按优先级排序:首选 > 标准 > 快速 +MODEL_PRIMARY = "claude-opus-4-5-20251101-thinking" # 🥇 首选:所有任务首先尝试 +MODEL_STANDARD = "gemini-3-pro-preview" # 🥈 标准:Claude 失败后降级 +MODEL_FAST = "gemini-3-flash-preview" # 🥉 快速:最终保底 +MODEL_IMAGE = "gemini-2.5-flash-image-preview" # 🖼️ 图像生成专用 +MODEL_VIDEO = "veo3.1-pro" # 🎬 视频生成专用 + +# 兼容旧代码的别名 +DEFAULT_MODEL = MODEL_PRIMARY # 默认使用最强模型 +MODEL_ANALYSIS = MODEL_PRIMARY +MODEL_CREATIVE = MODEL_STANDARD +MODEL_IMAGE_GEN = MODEL_IMAGE + diff --git a/backend/app/services/ai/answer_judge_service.py b/backend/app/services/ai/answer_judge_service.py new file mode 100644 index 0000000..9601002 --- /dev/null +++ b/backend/app/services/ai/answer_judge_service.py @@ -0,0 +1,197 @@ +""" +答案判断服务 - Python 原生实现 + +功能: +- 判断填空题与问答题的答案是否正确 +- 通过 AI 语义理解比对用户答案与标准答案 + +提供稳定可靠的答案判断能力。 +""" + +import logging +from dataclasses import dataclass +from typing import Any, Optional + +from .ai_service import AIService, AIResponse +from .prompts.answer_judge_prompts import ( + SYSTEM_PROMPT, + USER_PROMPT, + CORRECT_KEYWORDS, + INCORRECT_KEYWORDS, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class JudgeResult: + """判断结果""" + is_correct: bool + raw_response: str + ai_provider: str = "" + ai_model: str = "" + ai_tokens: int = 0 + ai_latency_ms: int = 0 + + +class AnswerJudgeService: + """ + 答案判断服务 + + 使用 Python 原生实现。 + + 使用示例: + ```python + service = AnswerJudgeService() + result = await service.judge( + db=db_session, # 传入 db_session 用于记录调用日志 + question="玻尿酸的主要作用是什么?", + correct_answer="补水保湿、填充塑形", + user_answer="保湿和塑形", + analysis="玻尿酸具有补水保湿和填充塑形两大功能" + ) + print(result.is_correct) # True + ``` + """ + + MODULE_CODE = "answer_judge" + + async def judge( + self, + question: str, + correct_answer: str, + user_answer: str, + analysis: str = "", + db: Any = None # 数据库会话,用于记录 AI 调用日志 + ) -> JudgeResult: + """ + 判断答案是否正确 + + Args: + question: 题目内容 + correct_answer: 标准答案 + user_answer: 用户答案 + analysis: 答案解析(可选) + db: 数据库会话,用于记录调用日志(符合 AI 接入规范) + + Returns: + JudgeResult 判断结果 + """ + try: + logger.info( + f"开始判断答案 - question: {question[:50]}..., " + f"user_answer: {user_answer[:50]}..." + ) + + # 创建 AIService 实例(传入 db_session 用于记录调用日志) + ai_service = AIService(module_code=self.MODULE_CODE, db_session=db) + + # 构建提示词 + user_prompt = USER_PROMPT.format( + question=question, + correct_answer=correct_answer, + user_answer=user_answer, + analysis=analysis or "无" + ) + + # 调用 AI + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt} + ] + + ai_response = await ai_service.chat( + messages=messages, + temperature=0.1, # 低温度,确保输出稳定 + prompt_name="answer_judge" + ) + + logger.info( + f"AI 判断完成 - provider: {ai_response.provider}, " + f"response: {ai_response.content}, " + f"latency: {ai_response.latency_ms}ms" + ) + + # 解析 AI 输出 + is_correct = self._parse_judge_result(ai_response.content) + + logger.info(f"答案判断结果: {is_correct}") + + return JudgeResult( + is_correct=is_correct, + raw_response=ai_response.content, + ai_provider=ai_response.provider, + ai_model=ai_response.model, + ai_tokens=ai_response.total_tokens, + ai_latency_ms=ai_response.latency_ms, + ) + + except Exception as e: + logger.error(f"答案判断失败: {e}", exc_info=True) + # 出错时默认返回错误,保守处理 + return JudgeResult( + is_correct=False, + raw_response=f"判断失败: {e}", + ) + + def _parse_judge_result(self, ai_output: str) -> bool: + """ + 解析 AI 输出的判断结果 + + Args: + ai_output: AI 返回的文本 + + Returns: + bool: True 表示正确,False 表示错误 + """ + # 清洗输出 + output = ai_output.strip().lower() + + # 检查是否包含正确关键词 + for keyword in CORRECT_KEYWORDS: + if keyword.lower() in output: + return True + + # 检查是否包含错误关键词 + for keyword in INCORRECT_KEYWORDS: + if keyword.lower() in output: + return False + + # 无法识别时,默认返回错误(保守处理) + logger.warning(f"无法解析判断结果,默认返回错误: {ai_output}") + return False + + +# ==================== 全局实例 ==================== + +answer_judge_service = AnswerJudgeService() + + +# ==================== 便捷函数 ==================== + +async def judge_answer( + question: str, + correct_answer: str, + user_answer: str, + analysis: str = "" +) -> bool: + """ + 便捷函数:判断答案是否正确 + + Args: + question: 题目内容 + correct_answer: 标准答案 + user_answer: 用户答案 + analysis: 答案解析 + + Returns: + bool: True 表示正确,False 表示错误 + """ + result = await answer_judge_service.judge( + question=question, + correct_answer=correct_answer, + user_answer=user_answer, + analysis=analysis + ) + return result.is_correct + diff --git a/backend/app/services/ai/course_chat_service.py b/backend/app/services/ai/course_chat_service.py new file mode 100644 index 0000000..92a6014 --- /dev/null +++ b/backend/app/services/ai/course_chat_service.py @@ -0,0 +1,757 @@ +""" +课程对话服务 V2 - Python 原生实现 + +功能: +- 查询课程知识点作为知识库 +- 调用 AI 进行对话 +- 支持流式输出 +- 多轮对话历史管理(Redis 缓存) + +提供稳定可靠的课程对话能力。 +""" + +import json +import logging +import time +import uuid +from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import ExternalServiceError + +from .ai_service import AIService +from .prompts.course_chat_prompts import ( + SYSTEM_PROMPT, + USER_PROMPT, + KNOWLEDGE_ITEM_TEMPLATE, + CONVERSATION_WINDOW_SIZE, + CONVERSATION_TTL, + MAX_KNOWLEDGE_POINTS, + MAX_KNOWLEDGE_BASE_LENGTH, + DEFAULT_CHAT_MODEL, + DEFAULT_TEMPERATURE, +) + +logger = logging.getLogger(__name__) + +# 会话索引 Redis key 前缀/后缀 +CONVERSATION_INDEX_PREFIX = "course_chat:user:" +CONVERSATION_INDEX_SUFFIX = ":conversations" +# 会话元数据 key 前缀 +CONVERSATION_META_PREFIX = "course_chat:meta:" +# 会话索引过期时间(与会话数据一致) +CONVERSATION_INDEX_TTL = CONVERSATION_TTL + + +class CourseChatServiceV2: + """ + 课程对话服务 V2 + + 使用 Python 原生实现。 + + 使用示例: + ```python + service = CourseChatServiceV2() + + # 非流式对话 + response = await service.chat( + db=db_session, + course_id=1, + query="什么是玻尿酸?", + user_id=1, + conversation_id=None + ) + + # 流式对话 + async for chunk in service.chat_stream( + db=db_session, + course_id=1, + query="什么是玻尿酸?", + user_id=1, + conversation_id=None + ): + print(chunk, end="", flush=True) + ``` + """ + + # Redis key 前缀 + CONVERSATION_KEY_PREFIX = "course_chat:conversation:" + # 模块标识 + MODULE_CODE = "course_chat" + + def __init__(self): + """初始化服务(AIService 在方法中动态创建,以传入 db_session)""" + pass + + async def chat( + self, + db: AsyncSession, + course_id: int, + query: str, + user_id: int, + conversation_id: Optional[str] = None + ) -> Dict[str, Any]: + """ + 与课程对话(非流式) + + Args: + db: 数据库会话 + course_id: 课程ID + query: 用户问题 + user_id: 用户ID + conversation_id: 会话ID(续接对话时传入) + + Returns: + 包含 answer、conversation_id 等字段的字典 + """ + try: + logger.info( + f"开始课程对话 V2 - course_id: {course_id}, user_id: {user_id}, " + f"conversation_id: {conversation_id}" + ) + + # 1. 获取课程知识点 + knowledge_base = await self._get_course_knowledge(db, course_id) + + if not knowledge_base: + logger.warning(f"课程 {course_id} 没有知识点,使用空知识库") + knowledge_base = "(该课程暂无知识点内容)" + + # 2. 获取或创建会话ID + is_new_conversation = False + if not conversation_id: + conversation_id = self._generate_conversation_id(user_id, course_id) + is_new_conversation = True + logger.info(f"创建新会话: {conversation_id}") + + # 3. 构建消息列表 + messages = await self._build_messages( + knowledge_base=knowledge_base, + query=query, + user_id=user_id, + conversation_id=conversation_id + ) + + # 4. 创建 AIService 并调用(传入 db_session 以记录调用日志) + ai_service = AIService(module_code=self.MODULE_CODE, db_session=db) + response = await ai_service.chat( + messages=messages, + model=DEFAULT_CHAT_MODEL, + temperature=DEFAULT_TEMPERATURE, + prompt_name="course_chat" + ) + + answer = response.content + + # 5. 保存对话历史 + await self._save_conversation_history( + conversation_id=conversation_id, + user_message=query, + assistant_message=answer + ) + + # 6. 更新会话索引 + if is_new_conversation: + await self._add_to_conversation_index(user_id, conversation_id, course_id) + else: + await self._update_conversation_index(user_id, conversation_id) + + logger.info( + f"课程对话完成 - course_id: {course_id}, conversation_id: {conversation_id}, " + f"provider: {response.provider}, tokens: {response.total_tokens}" + ) + + return { + "success": True, + "answer": answer, + "conversation_id": conversation_id, + "ai_provider": response.provider, + "ai_model": response.model, + "ai_tokens": response.total_tokens, + "ai_latency_ms": response.latency_ms, + } + + except Exception as e: + logger.error( + f"课程对话失败 - course_id: {course_id}, user_id: {user_id}, error: {e}", + exc_info=True + ) + raise ExternalServiceError(f"课程对话失败: {e}") + + async def chat_stream( + self, + db: AsyncSession, + course_id: int, + query: str, + user_id: int, + conversation_id: Optional[str] = None + ) -> AsyncGenerator[Tuple[str, Optional[str]], None]: + """ + 与课程对话(流式输出) + + Args: + db: 数据库会话 + course_id: 课程ID + query: 用户问题 + user_id: 用户ID + conversation_id: 会话ID(续接对话时传入) + + Yields: + Tuple[str, Optional[str]]: (事件类型, 数据) + - ("conversation_started", conversation_id): 会话开始 + - ("chunk", text): 文本块 + - ("end", None): 结束 + - ("error", message): 错误 + """ + full_answer = "" + + try: + logger.info( + f"开始流式课程对话 V2 - course_id: {course_id}, user_id: {user_id}, " + f"conversation_id: {conversation_id}" + ) + + # 1. 获取课程知识点 + knowledge_base = await self._get_course_knowledge(db, course_id) + + if not knowledge_base: + logger.warning(f"课程 {course_id} 没有知识点,使用空知识库") + knowledge_base = "(该课程暂无知识点内容)" + + # 2. 获取或创建会话ID + is_new_conversation = False + if not conversation_id: + conversation_id = self._generate_conversation_id(user_id, course_id) + is_new_conversation = True + logger.info(f"创建新会话: {conversation_id}") + + # 3. 发送会话开始事件(如果是新会话) + if is_new_conversation: + yield ("conversation_started", conversation_id) + + # 4. 构建消息列表 + messages = await self._build_messages( + knowledge_base=knowledge_base, + query=query, + user_id=user_id, + conversation_id=conversation_id + ) + + # 5. 创建 AIService 并流式调用(传入 db_session 以记录调用日志) + ai_service = AIService(module_code=self.MODULE_CODE, db_session=db) + async for chunk in ai_service.chat_stream( + messages=messages, + model=DEFAULT_CHAT_MODEL, + temperature=DEFAULT_TEMPERATURE, + prompt_name="course_chat" + ): + full_answer += chunk + yield ("chunk", chunk) + + # 6. 发送结束事件 + yield ("end", None) + + # 7. 保存对话历史 + await self._save_conversation_history( + conversation_id=conversation_id, + user_message=query, + assistant_message=full_answer + ) + + # 8. 更新会话索引 + if is_new_conversation: + await self._add_to_conversation_index(user_id, conversation_id, course_id) + else: + await self._update_conversation_index(user_id, conversation_id) + + logger.info( + f"流式课程对话完成 - course_id: {course_id}, conversation_id: {conversation_id}, " + f"answer_length: {len(full_answer)}" + ) + + except Exception as e: + logger.error( + f"流式课程对话失败 - course_id: {course_id}, user_id: {user_id}, error: {e}", + exc_info=True + ) + yield ("error", str(e)) + + async def _get_course_knowledge( + self, + db: AsyncSession, + course_id: int + ) -> str: + """ + 获取课程知识点,构建知识库文本 + + Args: + db: 数据库会话 + course_id: 课程ID + + Returns: + 知识库文本 + """ + try: + # 查询知识点(课程知识点查询) + query = text(""" + SELECT kp.name, kp.description + FROM knowledge_points kp + INNER JOIN course_materials cm ON kp.material_id = cm.id + WHERE kp.course_id = :course_id + AND kp.is_deleted = 0 + AND cm.is_deleted = 0 + ORDER BY kp.id + LIMIT :limit + """) + + result = await db.execute( + query, + {"course_id": course_id, "limit": MAX_KNOWLEDGE_POINTS} + ) + rows = result.fetchall() + + if not rows: + logger.warning(f"课程 {course_id} 没有关联的知识点") + return "" + + # 构建知识库文本 + knowledge_items = [] + total_length = 0 + + for row in rows: + name = row[0] or "" + description = row[1] or "" + + item = KNOWLEDGE_ITEM_TEMPLATE.format( + name=name, + description=description + ) + + # 检查是否超过长度限制 + if total_length + len(item) > MAX_KNOWLEDGE_BASE_LENGTH: + logger.warning( + f"知识库文本已达到最大长度限制 {MAX_KNOWLEDGE_BASE_LENGTH}," + f"停止添加更多知识点" + ) + break + + knowledge_items.append(item) + total_length += len(item) + + knowledge_base = "\n".join(knowledge_items) + + logger.info( + f"获取课程知识点成功 - course_id: {course_id}, " + f"count: {len(knowledge_items)}, length: {len(knowledge_base)}" + ) + + return knowledge_base + + except Exception as e: + logger.error(f"获取课程知识点失败: {e}") + raise + + async def _build_messages( + self, + knowledge_base: str, + query: str, + user_id: int, + conversation_id: str + ) -> List[Dict[str, str]]: + """ + 构建消息列表(包含历史对话) + + Args: + knowledge_base: 知识库文本 + query: 当前用户问题 + user_id: 用户ID + conversation_id: 会话ID + + Returns: + 消息列表 + """ + messages = [] + + # 1. 系统提示词 + system_content = SYSTEM_PROMPT.format(knowledge_base=knowledge_base) + messages.append({"role": "system", "content": system_content}) + + # 2. 获取历史对话 + history = await self._get_conversation_history(conversation_id) + + # 限制历史窗口大小 + if len(history) > CONVERSATION_WINDOW_SIZE * 2: + history = history[-(CONVERSATION_WINDOW_SIZE * 2):] + + # 添加历史消息 + messages.extend(history) + + # 3. 当前用户问题 + user_content = USER_PROMPT.format(query=query) + messages.append({"role": "user", "content": user_content}) + + logger.debug( + f"构建消息列表 - total: {len(messages)}, history: {len(history)}" + ) + + return messages + + def _generate_conversation_id(self, user_id: int, course_id: int) -> str: + """生成会话ID""" + unique_id = uuid.uuid4().hex[:8] + return f"conv_{user_id}_{course_id}_{unique_id}" + + async def _get_conversation_history( + self, + conversation_id: str + ) -> List[Dict[str, str]]: + """ + 从 Redis 获取会话历史 + + Args: + conversation_id: 会话ID + + Returns: + 消息列表 [{"role": "user/assistant", "content": "..."}] + """ + try: + from app.core.redis import get_redis_client + + redis = get_redis_client() + key = f"{self.CONVERSATION_KEY_PREFIX}{conversation_id}" + + data = await redis.get(key) + if not data: + return [] + + history = json.loads(data) + return history + + except RuntimeError: + # Redis 未初始化,返回空历史 + logger.warning("Redis 未初始化,无法获取会话历史") + return [] + except Exception as e: + logger.warning(f"获取会话历史失败: {e}") + return [] + + async def _save_conversation_history( + self, + conversation_id: str, + user_message: str, + assistant_message: str + ) -> None: + """ + 保存对话历史到 Redis + + Args: + conversation_id: 会话ID + user_message: 用户消息 + assistant_message: AI 回复 + """ + try: + from app.core.redis import get_redis_client + + redis = get_redis_client() + key = f"{self.CONVERSATION_KEY_PREFIX}{conversation_id}" + + # 获取现有历史 + history = await self._get_conversation_history(conversation_id) + + # 添加新消息 + history.append({"role": "user", "content": user_message}) + history.append({"role": "assistant", "content": assistant_message}) + + # 限制历史长度 + max_messages = CONVERSATION_WINDOW_SIZE * 2 + if len(history) > max_messages: + history = history[-max_messages:] + + # 保存到 Redis + await redis.setex( + key, + CONVERSATION_TTL, + json.dumps(history, ensure_ascii=False) + ) + + logger.debug( + f"保存会话历史成功 - conversation_id: {conversation_id}, " + f"messages: {len(history)}" + ) + + except RuntimeError: + # Redis 未初始化,跳过保存 + logger.warning("Redis 未初始化,无法保存会话历史") + except Exception as e: + logger.warning(f"保存会话历史失败: {e}") + + async def get_conversation_messages( + self, + conversation_id: str, + user_id: int + ) -> List[Dict[str, Any]]: + """ + 获取会话的历史消息 + + Args: + conversation_id: 会话ID + user_id: 用户ID(用于权限验证) + + Returns: + 消息列表 + """ + # 验证会话ID是否属于该用户 + if not conversation_id.startswith(f"conv_{user_id}_"): + logger.warning( + f"用户 {user_id} 尝试访问不属于自己的会话: {conversation_id}" + ) + return [] + + history = await self._get_conversation_history(conversation_id) + + # 格式化返回数据 + messages = [] + for i, msg in enumerate(history): + messages.append({ + "id": i, + "role": msg["role"], + "content": msg["content"], + }) + + return messages + + async def _add_to_conversation_index( + self, + user_id: int, + conversation_id: str, + course_id: int + ) -> None: + """ + 将会话添加到用户索引 + + Args: + user_id: 用户ID + conversation_id: 会话ID + course_id: 课程ID + """ + try: + from app.core.redis import get_redis_client + + redis = get_redis_client() + + # 1. 添加到用户的会话索引(Sorted Set,score 为时间戳) + index_key = f"{CONVERSATION_INDEX_PREFIX}{user_id}{CONVERSATION_INDEX_SUFFIX}" + timestamp = time.time() + await redis.zadd(index_key, {conversation_id: timestamp}) + await redis.expire(index_key, CONVERSATION_INDEX_TTL) + + # 2. 保存会话元数据 + meta_key = f"{CONVERSATION_META_PREFIX}{conversation_id}" + meta_data = { + "conversation_id": conversation_id, + "user_id": user_id, + "course_id": course_id, + "created_at": timestamp, + "updated_at": timestamp, + } + await redis.setex( + meta_key, + CONVERSATION_INDEX_TTL, + json.dumps(meta_data, ensure_ascii=False) + ) + + logger.debug( + f"会话已添加到索引 - user_id: {user_id}, conversation_id: {conversation_id}" + ) + + except RuntimeError: + logger.warning("Redis 未初始化,无法添加会话索引") + except Exception as e: + logger.warning(f"添加会话索引失败: {e}") + + async def _update_conversation_index( + self, + user_id: int, + conversation_id: str + ) -> None: + """ + 更新会话的最后活跃时间 + + Args: + user_id: 用户ID + conversation_id: 会话ID + """ + try: + from app.core.redis import get_redis_client + + redis = get_redis_client() + + # 更新索引中的时间戳 + index_key = f"{CONVERSATION_INDEX_PREFIX}{user_id}{CONVERSATION_INDEX_SUFFIX}" + timestamp = time.time() + await redis.zadd(index_key, {conversation_id: timestamp}) + await redis.expire(index_key, CONVERSATION_INDEX_TTL) + + # 更新元数据中的 updated_at + meta_key = f"{CONVERSATION_META_PREFIX}{conversation_id}" + meta_data = await redis.get(meta_key) + if meta_data: + meta = json.loads(meta_data) + meta["updated_at"] = timestamp + await redis.setex( + meta_key, + CONVERSATION_INDEX_TTL, + json.dumps(meta, ensure_ascii=False) + ) + + logger.debug( + f"会话索引已更新 - user_id: {user_id}, conversation_id: {conversation_id}" + ) + + except RuntimeError: + logger.warning("Redis 未初始化,无法更新会话索引") + except Exception as e: + logger.warning(f"更新会话索引失败: {e}") + + async def list_user_conversations( + self, + user_id: int, + limit: int = 20 + ) -> List[Dict[str, Any]]: + """ + 获取用户的会话列表 + + Args: + user_id: 用户ID + limit: 返回数量限制 + + Returns: + 会话列表,按更新时间倒序 + """ + try: + from app.core.redis import get_redis_client + + redis = get_redis_client() + + # 1. 从索引获取最近的会话ID列表(倒序) + index_key = f"{CONVERSATION_INDEX_PREFIX}{user_id}{CONVERSATION_INDEX_SUFFIX}" + conversation_ids = await redis.zrevrange(index_key, 0, limit - 1) + + if not conversation_ids: + logger.debug(f"用户 {user_id} 没有会话记录") + return [] + + # 2. 获取每个会话的元数据和最后消息 + conversations = [] + for conv_id in conversation_ids: + # 确保是字符串 + if isinstance(conv_id, bytes): + conv_id = conv_id.decode('utf-8') + + # 获取元数据 + meta_key = f"{CONVERSATION_META_PREFIX}{conv_id}" + meta_data = await redis.get(meta_key) + + if meta_data: + if isinstance(meta_data, bytes): + meta_data = meta_data.decode('utf-8') + meta = json.loads(meta_data) + else: + # 从 conversation_id 解析 course_id + # 格式: conv_{user_id}_{course_id}_{uuid} + parts = conv_id.split('_') + course_id = int(parts[2]) if len(parts) >= 3 else 0 + meta = { + "conversation_id": conv_id, + "user_id": user_id, + "course_id": course_id, + "created_at": time.time(), + "updated_at": time.time(), + } + + # 获取最后一条消息作为预览 + history = await self._get_conversation_history(conv_id) + last_message = "" + if history: + # 获取最后一条 assistant 消息 + for msg in reversed(history): + if msg["role"] == "assistant": + last_message = msg["content"][:100] # 截取前100字符 + if len(msg["content"]) > 100: + last_message += "..." + break + + conversations.append({ + "id": conv_id, + "course_id": meta.get("course_id"), + "created_at": meta.get("created_at"), + "updated_at": meta.get("updated_at"), + "last_message": last_message, + "message_count": len(history), + }) + + logger.info(f"获取用户会话列表 - user_id: {user_id}, count: {len(conversations)}") + return conversations + + except RuntimeError: + logger.warning("Redis 未初始化,无法获取会话列表") + return [] + except Exception as e: + logger.warning(f"获取会话列表失败: {e}") + return [] + + # 别名方法,供 API 层调用 + async def get_conversations( + self, + user_id: int, + course_id: Optional[int] = None, + limit: int = 20 + ) -> List[Dict[str, Any]]: + """ + 获取用户的会话列表(别名方法) + + Args: + user_id: 用户ID + course_id: 课程ID(可选,用于过滤) + limit: 返回数量限制 + + Returns: + 会话列表 + """ + conversations = await self.list_user_conversations(user_id, limit) + + # 如果指定了 course_id,进行过滤 + if course_id is not None: + conversations = [ + c for c in conversations + if c.get("course_id") == course_id + ] + + return conversations + + async def get_messages( + self, + conversation_id: str, + user_id: int, + limit: int = 50 + ) -> List[Dict[str, Any]]: + """ + 获取会话历史消息(别名方法) + + Args: + conversation_id: 会话ID + user_id: 用户ID(用于权限验证) + limit: 返回数量限制 + + Returns: + 消息列表 + """ + messages = await self.get_conversation_messages(conversation_id, limit) + return messages + + +# 创建全局实例 +course_chat_service_v2 = CourseChatServiceV2() + diff --git a/backend/app/services/ai/coze/__init__.py b/backend/app/services/ai/coze/__init__.py new file mode 100644 index 0000000..71156df --- /dev/null +++ b/backend/app/services/ai/coze/__init__.py @@ -0,0 +1,61 @@ +""" +Coze AI 服务模块 +""" + +from .client import get_coze_client, get_auth_manager, get_bot_config, get_workspace_id +from .service import get_coze_service, CozeService +from .models import ( + SessionType, + MessageRole, + ContentType, + StreamEventType, + CozeSession, + CozeMessage, + StreamEvent, + CreateSessionRequest, + CreateSessionResponse, + SendMessageRequest, + EndSessionRequest, + EndSessionResponse, +) +from .exceptions import ( + CozeException, + CozeAuthError, + CozeAPIError, + CozeRateLimitError, + CozeTimeoutError, + CozeStreamError, + map_coze_error_to_exception, +) + +__all__ = [ + # Client + "get_coze_client", + "get_auth_manager", + "get_bot_config", + "get_workspace_id", + # Service + "get_coze_service", + "CozeService", + # Models + "SessionType", + "MessageRole", + "ContentType", + "StreamEventType", + "CozeSession", + "CozeMessage", + "StreamEvent", + "CreateSessionRequest", + "CreateSessionResponse", + "SendMessageRequest", + "EndSessionRequest", + "EndSessionResponse", + # Exceptions + "CozeException", + "CozeAuthError", + "CozeAPIError", + "CozeRateLimitError", + "CozeTimeoutError", + "CozeStreamError", + "map_coze_error_to_exception", +] diff --git a/backend/app/services/ai/coze/client.py b/backend/app/services/ai/coze/client.py new file mode 100644 index 0000000..acbe855 --- /dev/null +++ b/backend/app/services/ai/coze/client.py @@ -0,0 +1,203 @@ +""" +Coze AI 客户端管理 +负责管理 Coze API 的认证和客户端实例 +""" +from functools import lru_cache +from typing import Optional, Dict, Any +import logging +from pathlib import Path + +from cozepy import Coze, TokenAuth, JWTAuth, COZE_CN_BASE_URL + +from app.core.config import get_settings + +logger = logging.getLogger(__name__) + + +class CozeAuthManager: + """Coze 认证管理器""" + + def __init__(self): + self.settings = get_settings() + self._client: Optional[Coze] = None + + def _create_pat_auth(self) -> TokenAuth: + """创建个人访问令牌认证""" + if not self.settings.COZE_API_TOKEN: + raise ValueError("COZE_API_TOKEN 未配置") + + return TokenAuth(token=self.settings.COZE_API_TOKEN) + + def _create_oauth_auth(self) -> JWTAuth: + """创建 OAuth 认证""" + if not all( + [ + self.settings.COZE_OAUTH_CLIENT_ID, + self.settings.COZE_OAUTH_PUBLIC_KEY_ID, + self.settings.COZE_OAUTH_PRIVATE_KEY_PATH, + ] + ): + raise ValueError("OAuth 配置不完整") + + # 读取私钥 + private_key_path = Path(self.settings.COZE_OAUTH_PRIVATE_KEY_PATH) + if not private_key_path.exists(): + raise FileNotFoundError(f"私钥文件不存在: {private_key_path}") + + with open(private_key_path, "r") as f: + private_key = f.read() + + try: + return JWTAuth( + client_id=self.settings.COZE_OAUTH_CLIENT_ID, + private_key=private_key, + public_key_id=self.settings.COZE_OAUTH_PUBLIC_KEY_ID, + base_url=self.settings.COZE_API_BASE or COZE_CN_BASE_URL, # 使用中国区API + ) + except Exception as e: + logger.error(f"创建 OAuth 认证失败: {e}") + raise + + def get_client(self, force_new: bool = False) -> Coze: + """ + 获取 Coze 客户端实例 + + Args: + force_new: 是否强制创建新客户端(用于长时间运行的请求,避免token过期) + + 认证优先级: + 1. OAuth(推荐):配置完整时使用,自动刷新token + 2. PAT:仅当OAuth未配置时使用(注意:PAT会过期) + """ + if self._client is not None and not force_new: + return self._client + + auth = None + auth_type = None + + # 检查 OAuth 配置是否完整 + oauth_configured = all([ + self.settings.COZE_OAUTH_CLIENT_ID, + self.settings.COZE_OAUTH_PUBLIC_KEY_ID, + self.settings.COZE_OAUTH_PRIVATE_KEY_PATH, + ]) + + if oauth_configured: + # OAuth 配置完整,必须使用 OAuth(不fallback到PAT) + try: + auth = self._create_oauth_auth() + auth_type = "OAuth" + logger.info("使用 OAuth 认证") + except Exception as e: + # OAuth 配置完整但创建失败,直接抛出异常(不fallback到可能过期的PAT) + logger.error(f"OAuth 认证创建失败: {e}") + raise ValueError(f"OAuth 认证失败,请检查私钥文件和配置: {e}") + else: + # OAuth 未配置,使用 PAT + if self.settings.COZE_API_TOKEN: + auth = self._create_pat_auth() + auth_type = "PAT" + logger.warning("使用 PAT 认证(注意:PAT会过期,建议配置OAuth)") + else: + raise ValueError("Coze 认证未配置:需要配置 OAuth 或 PAT Token") + + # 创建客户端 + client = Coze( + auth=auth, base_url=self.settings.COZE_API_BASE or COZE_CN_BASE_URL + ) + + logger.debug(f"Coze客户端创建成功,认证方式: {auth_type}, force_new: {force_new}") + + # 只有非强制创建时才缓存 + if not force_new: + self._client = client + + return client + + def reset(self): + """重置客户端实例""" + self._client = None + + def get_oauth_token(self) -> str: + """ + 获取OAuth JWT Token用于前端直连 + + Returns: + JWT token字符串 + """ + if not all([ + self.settings.COZE_OAUTH_CLIENT_ID, + self.settings.COZE_OAUTH_PUBLIC_KEY_ID, + self.settings.COZE_OAUTH_PRIVATE_KEY_PATH, + ]): + raise ValueError("OAuth 配置不完整") + + # 读取私钥 + private_key_path = Path(self.settings.COZE_OAUTH_PRIVATE_KEY_PATH) + if not private_key_path.exists(): + raise FileNotFoundError(f"私钥文件不存在: {private_key_path}") + + with open(private_key_path, "r") as f: + private_key = f.read() + + # 创建JWTAuth实例(必须指定中国区base_url) + jwt_auth = JWTAuth( + client_id=self.settings.COZE_OAUTH_CLIENT_ID, + private_key=private_key, + public_key_id=self.settings.COZE_OAUTH_PUBLIC_KEY_ID, + base_url=self.settings.COZE_API_BASE or COZE_CN_BASE_URL, # 使用中国区API + ) + + # 获取token(JWTAuth内部会自动生成) + # JWTAuth.token属性返回已签名的JWT + return jwt_auth.token + + +@lru_cache() +def get_auth_manager() -> CozeAuthManager: + """获取认证管理器单例""" + return CozeAuthManager() + + +def get_coze_client(force_new: bool = False) -> Coze: + """ + 获取 Coze 客户端 + + Args: + force_new: 是否强制创建新客户端(用于工作流等长时间运行的请求) + """ + return get_auth_manager().get_client(force_new=force_new) + + +def get_workspace_id() -> str: + """获取工作空间 ID""" + settings = get_settings() + if not settings.COZE_WORKSPACE_ID: + raise ValueError("COZE_WORKSPACE_ID 未配置") + return settings.COZE_WORKSPACE_ID + + +def get_bot_config(session_type: str) -> Dict[str, Any]: + """ + 根据会话类型获取 Bot 配置 + + Args: + session_type: 会话类型 (course_chat 或 training) + + Returns: + 包含 bot_id 等配置的字典 + """ + settings = get_settings() + + if session_type == "course_chat": + bot_id = settings.COZE_CHAT_BOT_ID + if not bot_id: + raise ValueError("COZE_CHAT_BOT_ID 未配置") + elif session_type == "training": + bot_id = settings.COZE_TRAINING_BOT_ID + if not bot_id: + raise ValueError("COZE_TRAINING_BOT_ID 未配置") + else: + raise ValueError(f"不支持的会话类型: {session_type}") + + return {"bot_id": bot_id, "workspace_id": settings.COZE_WORKSPACE_ID} diff --git a/backend/app/services/ai/coze/client_backup.py b/backend/app/services/ai/coze/client_backup.py new file mode 100644 index 0000000..4ddbbd5 --- /dev/null +++ b/backend/app/services/ai/coze/client_backup.py @@ -0,0 +1,44 @@ +"""Coze客户端(临时模拟,等Agent-Coze实现后替换)""" +import logging +from typing import Dict, Any, Optional + +logger = logging.getLogger(__name__) + + +class CozeClient: + """ + Coze客户端模拟类 + TODO: 等Agent-Coze模块实现后,这个类将被真实的Coze网关客户端替换 + """ + + async def create_conversation( + self, bot_id: str, user_id: str, meta_data: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """创建会话(模拟)""" + logger.info(f"模拟创建Coze会话: bot_id={bot_id}, user_id={user_id}") + + # 返回模拟的会话信息 + return { + "conversation_id": f"mock_conversation_{user_id}_{bot_id[:8]}", + "bot_id": bot_id, + "status": "active", + } + + async def send_message( + self, conversation_id: str, content: str, message_type: str = "text" + ) -> Dict[str, Any]: + """发送消息(模拟)""" + logger.info(f"模拟发送消息到会话 {conversation_id}: {content[:50]}...") + + # 返回模拟的消息响应 + return { + "message_id": f"mock_msg_{conversation_id[:8]}", + "content": f"这是对'{content[:30]}...'的模拟回复", + "role": "assistant", + } + + async def end_conversation(self, conversation_id: str) -> Dict[str, Any]: + """结束会话(模拟)""" + logger.info(f"模拟结束会话: {conversation_id}") + + return {"status": "completed", "conversation_id": conversation_id} diff --git a/backend/app/services/ai/coze/exceptions.py b/backend/app/services/ai/coze/exceptions.py new file mode 100644 index 0000000..30eb348 --- /dev/null +++ b/backend/app/services/ai/coze/exceptions.py @@ -0,0 +1,101 @@ +""" +Coze 服务异常定义 +""" + +from typing import Optional, Dict, Any + + +class CozeException(Exception): + """Coze 服务基础异常""" + + def __init__( + self, + message: str, + code: Optional[str] = None, + status_code: Optional[int] = None, + details: Optional[Dict[str, Any]] = None, + ): + super().__init__(message) + self.message = message + self.code = code + self.status_code = status_code + self.details = details or {} + + +class CozeAuthError(CozeException): + """认证异常""" + + pass + + +class CozeAPIError(CozeException): + """API 调用异常""" + + pass + + +class CozeRateLimitError(CozeException): + """速率限制异常""" + + pass + + +class CozeTimeoutError(CozeException): + """超时异常""" + + pass + + +class CozeStreamError(CozeException): + """流式响应异常""" + + pass + + +def map_coze_error_to_exception(error: Exception) -> CozeException: + """ + 将 Coze SDK 错误映射为统一异常 + + Args: + error: 原始异常 + + Returns: + CozeException: 映射后的异常 + """ + error_message = str(error) + + # 根据错误消息判断错误类型 + if ( + "authentication" in error_message.lower() + or "unauthorized" in error_message.lower() + ): + return CozeAuthError( + message="Coze 认证失败", + code="COZE_AUTH_ERROR", + status_code=401, + details={"original_error": error_message}, + ) + + if "rate limit" in error_message.lower(): + return CozeRateLimitError( + message="Coze API 速率限制", + code="COZE_RATE_LIMIT", + status_code=429, + details={"original_error": error_message}, + ) + + if "timeout" in error_message.lower(): + return CozeTimeoutError( + message="Coze API 调用超时", + code="COZE_TIMEOUT", + status_code=504, + details={"original_error": error_message}, + ) + + # 默认映射为 API 错误 + return CozeAPIError( + message="Coze API 调用失败", + code="COZE_API_ERROR", + status_code=500, + details={"original_error": error_message}, + ) diff --git a/backend/app/services/ai/coze/models.py b/backend/app/services/ai/coze/models.py new file mode 100644 index 0000000..c9ca290 --- /dev/null +++ b/backend/app/services/ai/coze/models.py @@ -0,0 +1,136 @@ +""" +Coze 服务数据模型 +""" + +from typing import Optional, List, Dict, Any, Literal +from datetime import datetime +from pydantic import BaseModel, Field +from enum import Enum + + +class SessionType(str, Enum): + """会话类型""" + + COURSE_CHAT = "course_chat" # 课程对话 + TRAINING = "training" # 陪练会话 + EXAM = "exam" # 考试会话 + + +class MessageRole(str, Enum): + """消息角色""" + + USER = "user" + ASSISTANT = "assistant" + SYSTEM = "system" + + +class ContentType(str, Enum): + """内容类型""" + + TEXT = "text" + CARD = "card" + IMAGE = "image" + FILE = "file" + + +class StreamEventType(str, Enum): + """流式事件类型""" + + MESSAGE_START = "conversation.message.start" + MESSAGE_DELTA = "conversation.message.delta" + MESSAGE_COMPLETED = "conversation.message.completed" + ERROR = "error" + DONE = "done" + + +class CozeSession(BaseModel): + """Coze 会话模型""" + + session_id: str = Field(..., description="会话ID") + conversation_id: str = Field(..., description="Coze对话ID") + session_type: SessionType = Field(..., description="会话类型") + user_id: str = Field(..., description="用户ID") + bot_id: str = Field(..., description="Bot ID") + created_at: datetime = Field(default_factory=datetime.now, description="创建时间") + ended_at: Optional[datetime] = Field(None, description="结束时间") + metadata: Dict[str, Any] = Field(default_factory=dict, description="元数据") + + class Config: + json_encoders = {datetime: lambda v: v.isoformat()} + + +class CozeMessage(BaseModel): + """Coze 消息模型""" + + message_id: str = Field(..., description="消息ID") + session_id: str = Field(..., description="会话ID") + role: MessageRole = Field(..., description="消息角色") + content: str = Field(..., description="消息内容") + content_type: ContentType = Field(ContentType.TEXT, description="内容类型") + created_at: datetime = Field(default_factory=datetime.now, description="创建时间") + metadata: Dict[str, Any] = Field(default_factory=dict, description="元数据") + + class Config: + json_encoders = {datetime: lambda v: v.isoformat()} + + +class StreamEvent(BaseModel): + """流式事件模型""" + + event: StreamEventType = Field(..., description="事件类型") + data: Dict[str, Any] = Field(..., description="事件数据") + message_id: Optional[str] = Field(None, description="消息ID") + content: Optional[str] = Field(None, description="内容") + content_type: Optional[ContentType] = Field(None, description="内容类型") + role: Optional[MessageRole] = Field(None, description="角色") + error: Optional[str] = Field(None, description="错误信息") + + +class CreateSessionRequest(BaseModel): + """创建会话请求""" + + session_type: SessionType = Field(..., description="会话类型") + user_id: str = Field(..., description="用户ID") + course_id: Optional[str] = Field(None, description="课程ID (课程对话时必需)") + training_topic: Optional[str] = Field(None, description="陪练主题 (陪练时可选)") + metadata: Dict[str, Any] = Field(default_factory=dict, description="额外元数据") + + +class CreateSessionResponse(BaseModel): + """创建会话响应""" + + session_id: str = Field(..., description="会话ID") + conversation_id: str = Field(..., description="Coze对话ID") + bot_id: str = Field(..., description="Bot ID") + created_at: datetime = Field(..., description="创建时间") + + class Config: + json_encoders = {datetime: lambda v: v.isoformat()} + + +class SendMessageRequest(BaseModel): + """发送消息请求""" + + session_id: str = Field(..., description="会话ID") + content: str = Field(..., description="消息内容") + file_ids: List[str] = Field(default_factory=list, description="附件ID列表") + stream: bool = Field(True, description="是否流式响应") + + +class EndSessionRequest(BaseModel): + """结束会话请求""" + + reason: Optional[str] = Field(None, description="结束原因") + feedback: Optional[Dict[str, Any]] = Field(None, description="用户反馈") + + +class EndSessionResponse(BaseModel): + """结束会话响应""" + + session_id: str = Field(..., description="会话ID") + ended_at: datetime = Field(..., description="结束时间") + duration_seconds: int = Field(..., description="会话时长(秒)") + message_count: int = Field(..., description="消息数量") + + class Config: + json_encoders = {datetime: lambda v: v.isoformat()} diff --git a/backend/app/services/ai/coze/service.py b/backend/app/services/ai/coze/service.py new file mode 100644 index 0000000..fed4618 --- /dev/null +++ b/backend/app/services/ai/coze/service.py @@ -0,0 +1,335 @@ +""" +Coze 服务层实现 +处理会话管理、消息发送、流式响应等核心功能 +""" + +import asyncio +import json +import logging +import uuid +from typing import AsyncIterator, Dict, Any, List, Optional +from datetime import datetime + +from cozepy import ChatEventType, Message, MessageContentType + +from .client import get_coze_client, get_bot_config, get_workspace_id +from .models import ( + CozeSession, + CozeMessage, + StreamEvent, + SessionType, + MessageRole, + ContentType, + StreamEventType, + CreateSessionRequest, + CreateSessionResponse, + SendMessageRequest, + EndSessionRequest, + EndSessionResponse, +) +from .exceptions import ( + CozeAPIError, + CozeStreamError, + CozeTimeoutError, + map_coze_error_to_exception, +) + + +logger = logging.getLogger(__name__) + + +class CozeService: + """Coze 服务类""" + + def __init__(self): + self.client = get_coze_client() + self.bot_config = get_bot_config() + self.workspace_id = get_workspace_id() + + # 内存中的会话存储(生产环境应使用 Redis) + self._sessions: Dict[str, CozeSession] = {} + self._messages: Dict[str, List[CozeMessage]] = {} + + async def create_session( + self, request: CreateSessionRequest + ) -> CreateSessionResponse: + """ + 创建新会话 + + Args: + request: 创建会话请求 + + Returns: + CreateSessionResponse: 会话信息 + """ + try: + # 根据会话类型选择 Bot + bot_id = self._get_bot_id_by_type(request.session_type) + + # 创建 Coze 对话 + conversation = await asyncio.to_thread( + self.client.conversations.create, bot_id=bot_id + ) + + # 创建本地会话记录 + session = CozeSession( + session_id=str(uuid.uuid4()), + conversation_id=conversation.id, + session_type=request.session_type, + user_id=request.user_id, + bot_id=bot_id, + metadata=request.metadata, + ) + + # 保存会话 + self._sessions[session.session_id] = session + self._messages[session.session_id] = [] + + logger.info( + f"创建会话成功", + extra={ + "session_id": session.session_id, + "conversation_id": conversation.id, + "session_type": request.session_type.value, + "user_id": request.user_id, + }, + ) + + return CreateSessionResponse( + session_id=session.session_id, + conversation_id=session.conversation_id, + bot_id=session.bot_id, + created_at=session.created_at, + ) + + except Exception as e: + logger.error(f"创建会话失败: {e}", exc_info=True) + raise map_coze_error_to_exception(e) + + async def send_message( + self, request: SendMessageRequest + ) -> AsyncIterator[StreamEvent]: + """ + 发送消息并处理流式响应 + + Args: + request: 发送消息请求 + + Yields: + StreamEvent: 流式事件 + """ + session = self._get_session(request.session_id) + if not session: + raise CozeAPIError(f"会话不存在: {request.session_id}") + + # 记录用户消息 + user_message = CozeMessage( + message_id=str(uuid.uuid4()), + session_id=session.session_id, + role=MessageRole.USER, + content=request.content, + ) + self._messages[session.session_id].append(user_message) + + try: + # 构建消息历史 + messages = self._build_message_history(session.session_id) + + # 调用 Coze API + stream = await asyncio.to_thread( + self.client.chat.stream, + bot_id=session.bot_id, + conversation_id=session.conversation_id, + additional_messages=messages, + auto_save_history=True, + ) + + # 处理流式响应 + async for event in self._process_stream(stream, session.session_id): + yield event + + except asyncio.TimeoutError: + logger.error(f"消息发送超时: session_id={request.session_id}") + raise CozeTimeoutError("消息处理超时") + except Exception as e: + logger.error(f"发送消息失败: {e}", exc_info=True) + raise map_coze_error_to_exception(e) + + async def end_session( + self, session_id: str, request: EndSessionRequest + ) -> EndSessionResponse: + """ + 结束会话 + + Args: + session_id: 会话ID + request: 结束会话请求 + + Returns: + EndSessionResponse: 结束会话响应 + """ + session = self._get_session(session_id) + if not session: + raise CozeAPIError(f"会话不存在: {session_id}") + + # 更新会话状态 + session.ended_at = datetime.now() + + # 计算会话统计 + duration_seconds = int((session.ended_at - session.created_at).total_seconds()) + message_count = len(self._messages.get(session_id, [])) + + # 记录结束原因和反馈 + if request.reason: + session.metadata["end_reason"] = request.reason + if request.feedback: + session.metadata["feedback"] = request.feedback + + logger.info( + f"会话结束", + extra={ + "session_id": session_id, + "duration_seconds": duration_seconds, + "message_count": message_count, + "reason": request.reason, + }, + ) + + return EndSessionResponse( + session_id=session_id, + ended_at=session.ended_at, + duration_seconds=duration_seconds, + message_count=message_count, + ) + + async def get_session_messages( + self, session_id: str, limit: int = 50, offset: int = 0 + ) -> List[CozeMessage]: + """获取会话消息历史""" + messages = self._messages.get(session_id, []) + return messages[offset : offset + limit] + + def _get_bot_id_by_type(self, session_type: SessionType) -> str: + """根据会话类型获取 Bot ID""" + mapping = { + SessionType.COURSE_CHAT: self.bot_config["course_chat"], + SessionType.TRAINING: self.bot_config["training"], + SessionType.EXAM: self.bot_config["exam"], + } + return mapping.get(session_type, self.bot_config["training"]) + + def _get_session(self, session_id: str) -> Optional[CozeSession]: + """获取会话""" + return self._sessions.get(session_id) + + def _build_message_history(self, session_id: str) -> List[Message]: + """构建消息历史""" + messages = self._messages.get(session_id, []) + history = [] + + for msg in messages[-10:]: # 只发送最近10条消息作为上下文 + history.append( + Message( + role=msg.role.value, + content=msg.content, + content_type=MessageContentType.TEXT, + ) + ) + + return history + + async def _process_stream( + self, stream, session_id: str + ) -> AsyncIterator[StreamEvent]: + """处理流式响应""" + assistant_message_id = str(uuid.uuid4()) + accumulated_content = [] + content_type = ContentType.TEXT + + try: + for event in stream: + if event.event == ChatEventType.CONVERSATION_MESSAGE_DELTA: + # 消息片段 + content = event.message.content + accumulated_content.append(content) + + # 检测卡片类型 + if ( + hasattr(event.message, "content_type") + and event.message.content_type == "card" + ): + content_type = ContentType.CARD + + yield StreamEvent( + event=StreamEventType.MESSAGE_DELTA, + data={ + "conversation_id": event.conversation_id, + "message_id": assistant_message_id, + "content": content, + "content_type": content_type.value, + }, + message_id=assistant_message_id, + content=content, + content_type=content_type, + role=MessageRole.ASSISTANT, + ) + + elif event.event == ChatEventType.CONVERSATION_MESSAGE_COMPLETED: + # 消息完成 + full_content = "".join(accumulated_content) + + # 保存助手消息 + assistant_message = CozeMessage( + message_id=assistant_message_id, + session_id=session_id, + role=MessageRole.ASSISTANT, + content=full_content, + content_type=content_type, + ) + self._messages[session_id].append(assistant_message) + + yield StreamEvent( + event=StreamEventType.MESSAGE_COMPLETED, + data={ + "conversation_id": event.conversation_id, + "message_id": assistant_message_id, + "content": full_content, + "content_type": content_type.value, + "usage": getattr(event, "usage", {}), + }, + message_id=assistant_message_id, + content=full_content, + content_type=content_type, + role=MessageRole.ASSISTANT, + ) + + elif event.event == ChatEventType.ERROR: + # 错误事件 + yield StreamEvent( + event=StreamEventType.ERROR, + data={"error": str(event)}, + error=str(event), + ) + + except Exception as e: + logger.error(f"流式处理错误: {e}", exc_info=True) + yield StreamEvent( + event=StreamEventType.ERROR, data={"error": str(e)}, error=str(e) + ) + finally: + # 发送结束事件 + yield StreamEvent( + event=StreamEventType.DONE, data={"session_id": session_id} + ) + + +# 全局服务实例 +_service: Optional[CozeService] = None + + +def get_coze_service() -> CozeService: + """获取 Coze 服务单例""" + global _service + if _service is None: + _service = CozeService() + return _service diff --git a/backend/app/services/ai/exam_generator_service.py b/backend/app/services/ai/exam_generator_service.py new file mode 100644 index 0000000..692bce6 --- /dev/null +++ b/backend/app/services/ai/exam_generator_service.py @@ -0,0 +1,512 @@ +""" +试题生成服务 V2 - Python 原生实现 + +功能: +- 根据岗位和知识点动态生成考试题目 +- 支持错题重出模式 +- 调用 AI 生成并解析 JSON 结果 + +提供稳定可靠的试题生成能力。 +""" + +import json +import logging +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import ExternalServiceError + +from .ai_service import AIService, AIResponse +from .llm_json_parser import parse_with_fallback, clean_llm_output +from .prompts.exam_generator_prompts import ( + SYSTEM_PROMPT, + USER_PROMPT, + MISTAKE_REGEN_SYSTEM_PROMPT, + MISTAKE_REGEN_USER_PROMPT, + QUESTION_SCHEMA, + DEFAULT_QUESTION_COUNTS, + DEFAULT_DIFFICULTY_LEVEL, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class ExamGeneratorConfig: + """考试生成配置""" + course_id: int + position_id: int + single_choice_count: int = DEFAULT_QUESTION_COUNTS["single_choice_count"] + multiple_choice_count: int = DEFAULT_QUESTION_COUNTS["multiple_choice_count"] + true_false_count: int = DEFAULT_QUESTION_COUNTS["true_false_count"] + fill_blank_count: int = DEFAULT_QUESTION_COUNTS["fill_blank_count"] + essay_count: int = DEFAULT_QUESTION_COUNTS["essay_count"] + difficulty_level: int = DEFAULT_DIFFICULTY_LEVEL + mistake_records: str = "" + + @property + def total_count(self) -> int: + """计算总题量""" + return ( + self.single_choice_count + + self.multiple_choice_count + + self.true_false_count + + self.fill_blank_count + + self.essay_count + ) + + @property + def has_mistakes(self) -> bool: + """是否有错题记录""" + return bool(self.mistake_records and self.mistake_records.strip()) + + +class ExamGeneratorService: + """ + 试题生成服务 V2 + + 使用 Python 原生实现。 + + 使用示例: + ```python + service = ExamGeneratorService() + result = await service.generate_exam( + db=db_session, + config=ExamGeneratorConfig( + course_id=1, + position_id=1, + single_choice_count=5, + multiple_choice_count=3, + difficulty_level=3 + ) + ) + ``` + """ + + def __init__(self): + """初始化服务""" + self.ai_service = AIService(module_code="exam_generator") + + async def generate_exam( + self, + db: AsyncSession, + config: ExamGeneratorConfig + ) -> Dict[str, Any]: + """ + 生成考试题目(主入口) + + Args: + db: 数据库会话 + config: 考试生成配置 + + Returns: + 生成结果,包含 success、questions、total_count 等字段 + """ + try: + logger.info( + f"开始生成试题 - course_id: {config.course_id}, position_id: {config.position_id}, " + f"total_count: {config.total_count}, has_mistakes: {config.has_mistakes}" + ) + + # 根据是否有错题记录,走不同分支 + if config.has_mistakes: + return await self._regenerate_from_mistakes(db, config) + else: + return await self._generate_from_knowledge(db, config) + + except ExternalServiceError: + raise + except Exception as e: + logger.error( + f"试题生成失败 - course_id: {config.course_id}, error: {e}", + exc_info=True + ) + raise ExternalServiceError(f"试题生成失败: {e}") + + async def _generate_from_knowledge( + self, + db: AsyncSession, + config: ExamGeneratorConfig + ) -> Dict[str, Any]: + """ + 基于知识点生成题目(无错题模式) + + 流程: + 1. 查询岗位信息 + 2. 随机查询知识点 + 3. 调用 AI 生成题目 + 4. 解析并返回结果 + """ + # 1. 查询岗位信息 + position_info = await self._query_position(db, config.position_id) + if not position_info: + raise ExternalServiceError(f"岗位不存在: position_id={config.position_id}") + + logger.info(f"岗位信息: {position_info.get('name', 'unknown')}") + + # 2. 随机查询知识点 + knowledge_points = await self._query_knowledge_points( + db, + config.course_id, + config.total_count + ) + if not knowledge_points: + raise ExternalServiceError( + f"课程没有可用的知识点: course_id={config.course_id}" + ) + + logger.info(f"查询到 {len(knowledge_points)} 个知识点") + + # 3. 构建提示词 + system_prompt = SYSTEM_PROMPT.format( + total_count=config.total_count, + single_choice_count=config.single_choice_count, + multiple_choice_count=config.multiple_choice_count, + true_false_count=config.true_false_count, + fill_blank_count=config.fill_blank_count, + essay_count=config.essay_count, + difficulty_level=config.difficulty_level, + ) + + user_prompt = USER_PROMPT.format( + position_info=self._format_position_info(position_info), + knowledge_points=self._format_knowledge_points(knowledge_points), + ) + + # 4. 调用 AI 生成 + ai_response = await self._call_ai_generate(system_prompt, user_prompt) + + logger.info( + f"AI 生成完成 - provider: {ai_response.provider}, " + f"tokens: {ai_response.total_tokens}, latency: {ai_response.latency_ms}ms" + ) + + # 5. 解析题目 + questions = self._parse_questions(ai_response.content) + + logger.info(f"试题解析成功,数量: {len(questions)}") + + return { + "success": True, + "questions": questions, + "total_count": len(questions), + "mode": "knowledge_based", + "ai_provider": ai_response.provider, + "ai_model": ai_response.model, + "ai_tokens": ai_response.total_tokens, + "ai_latency_ms": ai_response.latency_ms, + } + + async def _regenerate_from_mistakes( + self, + db: AsyncSession, + config: ExamGeneratorConfig + ) -> Dict[str, Any]: + """ + 错题重出模式 + + 流程: + 1. 构建错题重出提示词 + 2. 调用 AI 生成新题 + 3. 解析并返回结果 + """ + logger.info("进入错题重出模式") + + # 1. 构建提示词 + system_prompt = MISTAKE_REGEN_SYSTEM_PROMPT.format( + difficulty_level=config.difficulty_level, + ) + + user_prompt = MISTAKE_REGEN_USER_PROMPT.format( + mistake_records=config.mistake_records, + ) + + # 2. 调用 AI 生成 + ai_response = await self._call_ai_generate(system_prompt, user_prompt) + + logger.info( + f"错题重出完成 - provider: {ai_response.provider}, " + f"tokens: {ai_response.total_tokens}, latency: {ai_response.latency_ms}ms" + ) + + # 3. 解析题目 + questions = self._parse_questions(ai_response.content) + + logger.info(f"错题重出解析成功,数量: {len(questions)}") + + return { + "success": True, + "questions": questions, + "total_count": len(questions), + "mode": "mistake_regen", + "ai_provider": ai_response.provider, + "ai_model": ai_response.model, + "ai_tokens": ai_response.total_tokens, + "ai_latency_ms": ai_response.latency_ms, + } + + async def _query_position( + self, + db: AsyncSession, + position_id: int + ) -> Optional[Dict[str, Any]]: + """ + 查询岗位信息 + + SQL:SELECT id, name, description, skills, level FROM positions + WHERE id = :id AND is_deleted = FALSE + """ + try: + result = await db.execute( + text(""" + SELECT id, name, description, skills, level + FROM positions + WHERE id = :position_id AND is_deleted = FALSE + """), + {"position_id": position_id} + ) + row = result.fetchone() + + if not row: + return None + + # 将 Row 转换为字典 + return { + "id": row[0], + "name": row[1], + "description": row[2], + "skills": row[3], # JSON 字段 + "level": row[4], + } + + except Exception as e: + logger.error(f"查询岗位信息失败: {e}") + raise ExternalServiceError(f"查询岗位信息失败: {e}") + + async def _query_knowledge_points( + self, + db: AsyncSession, + course_id: int, + limit: int + ) -> List[Dict[str, Any]]: + """ + 随机查询知识点 + + SQL:SELECT kp.id, kp.name, kp.description, kp.topic_relation + FROM knowledge_points kp + INNER JOIN course_materials cm ON kp.material_id = cm.id + WHERE kp.course_id = :course_id + AND kp.is_deleted = FALSE + AND cm.is_deleted = FALSE + ORDER BY RAND() + LIMIT :limit + """ + try: + result = await db.execute( + text(""" + SELECT kp.id, kp.name, kp.description, kp.topic_relation + FROM knowledge_points kp + INNER JOIN course_materials cm ON kp.material_id = cm.id + WHERE kp.course_id = :course_id + AND kp.is_deleted = FALSE + AND cm.is_deleted = FALSE + ORDER BY RAND() + LIMIT :limit + """), + {"course_id": course_id, "limit": limit} + ) + rows = result.fetchall() + + return [ + { + "id": row[0], + "name": row[1], + "description": row[2], + "topic_relation": row[3], + } + for row in rows + ] + + except Exception as e: + logger.error(f"查询知识点失败: {e}") + raise ExternalServiceError(f"查询知识点失败: {e}") + + async def _call_ai_generate( + self, + system_prompt: str, + user_prompt: str + ) -> AIResponse: + """调用 AI 生成题目""" + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ] + + response = await self.ai_service.chat( + messages=messages, + temperature=0.7, # 适当的创造性 + prompt_name="exam_generator" + ) + + return response + + def _parse_questions(self, ai_output: str) -> List[Dict[str, Any]]: + """ + 解析 AI 输出的题目 JSON + + 使用 LLM JSON Parser 进行多层兜底解析 + """ + # 先清洗输出 + cleaned_output, rules = clean_llm_output(ai_output) + if rules: + logger.debug(f"AI 输出已清洗: {rules}") + + # 使用带 Schema 校验的解析 + questions = parse_with_fallback( + cleaned_output, + schema=QUESTION_SCHEMA, + default=[], + validate_schema=True, + on_error="default" + ) + + # 后处理:确保每个题目有必要字段 + processed_questions = [] + for i, q in enumerate(questions): + if isinstance(q, dict): + # 确保有 num 字段 + if "num" not in q: + q["num"] = i + 1 + + # 确保 num 是整数 + try: + q["num"] = int(q["num"]) + except (ValueError, TypeError): + q["num"] = i + 1 + + # 确保有 type 字段 + if "type" not in q: + # 根据是否有 options 推断类型 + if q.get("topic", {}).get("options"): + q["type"] = "single_choice" + else: + q["type"] = "essay" + + # 确保 knowledge_point_id 是整数或 None + kp_id = q.get("knowledge_point_id") + if kp_id is not None: + try: + q["knowledge_point_id"] = int(kp_id) + except (ValueError, TypeError): + q["knowledge_point_id"] = None + + # 验证必要字段 + if q.get("topic") and q.get("correct"): + processed_questions.append(q) + else: + logger.warning(f"题目缺少必要字段,已跳过: {q}") + + if not processed_questions: + logger.warning("未能解析出有效的题目") + + return processed_questions + + def _format_position_info(self, position: Dict[str, Any]) -> str: + """格式化岗位信息为文本""" + lines = [ + f"岗位名称: {position.get('name', '未知')}", + f"岗位等级: {position.get('level', '未设置')}", + ] + + if position.get('description'): + lines.append(f"岗位描述: {position['description']}") + + skills = position.get('skills') + if skills: + # skills 可能是 JSON 字符串或列表 + if isinstance(skills, str): + try: + skills = json.loads(skills) + except json.JSONDecodeError: + skills = [skills] + + if isinstance(skills, list) and skills: + lines.append(f"核心技能: {', '.join(str(s) for s in skills)}") + + return '\n'.join(lines) + + def _format_knowledge_points(self, knowledge_points: List[Dict[str, Any]]) -> str: + """格式化知识点列表为文本""" + lines = [] + for kp in knowledge_points: + kp_text = f"【知识点 ID: {kp['id']}】{kp['name']}" + if kp.get('description'): + kp_text += f"\n{kp['description']}" + if kp.get('topic_relation'): + kp_text += f"\n关系描述: {kp['topic_relation']}" + lines.append(kp_text) + + return '\n\n'.join(lines) + + +# 创建全局实例 +exam_generator_service = ExamGeneratorService() + + +# ==================== 便捷函数 ==================== + +async def generate_exam( + db: AsyncSession, + course_id: int, + position_id: int, + single_choice_count: int = 4, + multiple_choice_count: int = 2, + true_false_count: int = 1, + fill_blank_count: int = 2, + essay_count: int = 1, + difficulty_level: int = 3, + mistake_records: str = "" +) -> Dict[str, Any]: + """ + 便捷函数:生成考试题目 + + Args: + db: 数据库会话 + course_id: 课程ID + position_id: 岗位ID + single_choice_count: 单选题数量 + multiple_choice_count: 多选题数量 + true_false_count: 判断题数量 + fill_blank_count: 填空题数量 + essay_count: 问答题数量 + difficulty_level: 难度等级(1-5) + mistake_records: 错题记录JSON字符串 + + Returns: + 生成结果 + """ + config = ExamGeneratorConfig( + course_id=course_id, + position_id=position_id, + single_choice_count=single_choice_count, + multiple_choice_count=multiple_choice_count, + true_false_count=true_false_count, + fill_blank_count=fill_blank_count, + essay_count=essay_count, + difficulty_level=difficulty_level, + mistake_records=mistake_records, + ) + + return await exam_generator_service.generate_exam(db, config) + + + + + + + + + diff --git a/backend/app/services/ai/knowledge_analysis_v2.py b/backend/app/services/ai/knowledge_analysis_v2.py new file mode 100644 index 0000000..9f4d6c0 --- /dev/null +++ b/backend/app/services/ai/knowledge_analysis_v2.py @@ -0,0 +1,548 @@ +""" +知识点分析服务 V2 - Python 原生实现 + +功能: +- 读取文档内容(PDF/Word/TXT) +- 调用 AI 分析提取知识点 +- 解析 JSON 结果 +- 写入数据库 + +提供稳定可靠的知识点分析能力。 +""" + +import logging +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.exceptions import ExternalServiceError +from app.schemas.course import KnowledgePointCreate + +from .ai_service import AIService, AIResponse +from .llm_json_parser import parse_with_fallback, clean_llm_output +from .prompts.knowledge_analysis_prompts import ( + SYSTEM_PROMPT, + USER_PROMPT, + KNOWLEDGE_POINT_SCHEMA, + DEFAULT_KNOWLEDGE_TYPE, +) + +logger = logging.getLogger(__name__) + +# 配置常量 +STATIC_UPLOADS_PREFIX = '/static/uploads/' +MAX_CONTENT_LENGTH = 100000 # 最大文档内容长度(字符) +MAX_KNOWLEDGE_POINTS = 20 # 最大知识点数量 + + +class KnowledgeAnalysisServiceV2: + """ + 知识点分析服务 V2 + + 使用 Python 原生实现。 + + 使用示例: + ```python + service = KnowledgeAnalysisServiceV2() + result = await service.analyze_course_material( + db=db_session, + course_id=1, + material_id=10, + file_url="/static/uploads/courses/1/doc.pdf", + course_title="医美产品知识", + user_id=1 + ) + ``` + """ + + def __init__(self): + """初始化服务""" + self.ai_service = AIService(module_code="knowledge_analysis") + self.upload_path = getattr(settings, 'UPLOAD_PATH', 'uploads') + + async def analyze_course_material( + self, + db: AsyncSession, + course_id: int, + material_id: int, + file_url: str, + course_title: str, + user_id: int + ) -> Dict[str, Any]: + """ + 分析课程资料并提取知识点 + + Args: + db: 数据库会话 + course_id: 课程ID + material_id: 资料ID + file_url: 文件URL(相对路径) + course_title: 课程标题 + user_id: 用户ID + + Returns: + 分析结果,包含 success、knowledge_points_count 等字段 + """ + try: + logger.info( + f"开始知识点分析 V2 - course_id: {course_id}, material_id: {material_id}, " + f"file_url: {file_url}" + ) + + # 1. 解析文件路径 + file_path = self._resolve_file_path(file_url) + if not file_path.exists(): + raise FileNotFoundError(f"文件不存在: {file_path}") + + logger.info(f"文件路径解析成功: {file_path}") + + # 2. 提取文档内容 + content = await self._extract_document_content(file_path) + if not content or not content.strip(): + raise ValueError("文档内容为空") + + logger.info(f"文档内容提取成功,长度: {len(content)} 字符") + + # 3. 调用 AI 分析 + ai_response = await self._call_ai_analysis(content, course_title) + + logger.info( + f"AI 分析完成 - provider: {ai_response.provider}, " + f"tokens: {ai_response.total_tokens}, latency: {ai_response.latency_ms}ms" + ) + + # 4. 解析 JSON 结果 + knowledge_points = self._parse_knowledge_points(ai_response.content) + + logger.info(f"知识点解析成功,数量: {len(knowledge_points)}") + + # 5. 删除旧的知识点 + await self._delete_old_knowledge_points(db, material_id) + + # 6. 保存到数据库 + saved_count = await self._save_knowledge_points( + db=db, + course_id=course_id, + material_id=material_id, + knowledge_points=knowledge_points, + user_id=user_id + ) + + logger.info( + f"知识点分析完成 - course_id: {course_id}, material_id: {material_id}, " + f"saved_count: {saved_count}" + ) + + return { + "success": True, + "status": "completed", + "knowledge_points_count": saved_count, + "ai_provider": ai_response.provider, + "ai_model": ai_response.model, + "ai_tokens": ai_response.total_tokens, + "ai_latency_ms": ai_response.latency_ms, + } + + except FileNotFoundError as e: + logger.error(f"文件不存在: {e}") + raise ExternalServiceError(f"分析文件不存在: {e}") + except ValueError as e: + logger.error(f"参数错误: {e}") + raise ExternalServiceError(f"分析参数错误: {e}") + except Exception as e: + logger.error( + f"知识点分析失败 - course_id: {course_id}, material_id: {material_id}, " + f"error: {e}", + exc_info=True + ) + raise ExternalServiceError(f"知识点分析失败: {e}") + + def _resolve_file_path(self, file_url: str) -> Path: + """解析文件 URL 为本地路径""" + if file_url.startswith(STATIC_UPLOADS_PREFIX): + relative_path = file_url.replace(STATIC_UPLOADS_PREFIX, '') + return Path(self.upload_path) / relative_path + elif file_url.startswith('/'): + # 绝对路径 + return Path(file_url) + else: + # 相对路径 + return Path(self.upload_path) / file_url + + async def _extract_document_content(self, file_path: Path) -> str: + """ + 提取文档内容 + + 支持:PDF、Word(docx)、文本文件 + """ + suffix = file_path.suffix.lower() + + try: + if suffix == '.pdf': + return await self._extract_pdf_content(file_path) + elif suffix in ['.docx', '.doc']: + return await self._extract_docx_content(file_path) + elif suffix in ['.txt', '.md', '.text']: + return await self._extract_text_content(file_path) + else: + # 尝试作为文本读取 + return await self._extract_text_content(file_path) + except Exception as e: + logger.error(f"文档内容提取失败: {file_path}, error: {e}") + raise ValueError(f"无法读取文档内容: {e}") + + async def _extract_pdf_content(self, file_path: Path) -> str: + """提取 PDF 内容""" + try: + from PyPDF2 import PdfReader + + reader = PdfReader(str(file_path)) + text_parts = [] + + for page in reader.pages: + text = page.extract_text() + if text: + text_parts.append(text) + + content = '\n'.join(text_parts) + + # 清理和截断 + content = self._clean_content(content) + + return content + + except ImportError: + logger.error("PyPDF2 未安装,无法读取 PDF") + raise ValueError("服务器未安装 PDF 读取组件") + except Exception as e: + logger.error(f"PDF 读取失败: {e}") + raise ValueError(f"PDF 读取失败: {e}") + + async def _extract_docx_content(self, file_path: Path) -> str: + """提取 Word 文档内容""" + try: + from docx import Document + + doc = Document(str(file_path)) + text_parts = [] + + for para in doc.paragraphs: + if para.text.strip(): + text_parts.append(para.text) + + # 也提取表格内容 + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + if cell.text.strip(): + text_parts.append(cell.text) + + content = '\n'.join(text_parts) + content = self._clean_content(content) + + return content + + except ImportError: + logger.error("python-docx 未安装,无法读取 Word 文档") + raise ValueError("服务器未安装 Word 读取组件") + except Exception as e: + logger.error(f"Word 文档读取失败: {e}") + raise ValueError(f"Word 文档读取失败: {e}") + + async def _extract_text_content(self, file_path: Path) -> str: + """提取文本文件内容""" + try: + # 尝试多种编码 + encodings = ['utf-8', 'gbk', 'gb2312', 'latin-1'] + + for encoding in encodings: + try: + with open(file_path, 'r', encoding=encoding) as f: + content = f.read() + return self._clean_content(content) + except UnicodeDecodeError: + continue + + raise ValueError("无法识别文件编码") + + except Exception as e: + logger.error(f"文本文件读取失败: {e}") + raise ValueError(f"文本文件读取失败: {e}") + + def _clean_content(self, content: str) -> str: + """清理和截断内容""" + # 移除多余空白 + import re + content = re.sub(r'\n{3,}', '\n\n', content) + content = re.sub(r' {2,}', ' ', content) + + # 截断过长内容 + if len(content) > MAX_CONTENT_LENGTH: + logger.warning(f"文档内容过长,截断至 {MAX_CONTENT_LENGTH} 字符") + content = content[:MAX_CONTENT_LENGTH] + "\n\n[内容已截断...]" + + return content.strip() + + async def _call_ai_analysis( + self, + content: str, + course_title: str + ) -> AIResponse: + """调用 AI 进行知识点分析""" + # 构建消息 + user_message = USER_PROMPT.format( + course_name=course_title, + content=content + ) + + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_message} + ] + + # 调用 AI + response = await self.ai_service.chat( + messages=messages, + temperature=0.1, # 低温度,保持输出稳定 + prompt_name="knowledge_analysis" + ) + + return response + + def _parse_knowledge_points(self, ai_output: str) -> List[Dict[str, Any]]: + """ + 解析 AI 输出的知识点 JSON + + 使用 LLM JSON Parser 进行多层兜底解析 + """ + # 先清洗输出 + cleaned_output, rules = clean_llm_output(ai_output) + if rules: + logger.debug(f"AI 输出已清洗: {rules}") + + # 使用带 Schema 校验的解析 + knowledge_points = parse_with_fallback( + cleaned_output, + schema=KNOWLEDGE_POINT_SCHEMA, + default=[], + validate_schema=True, + on_error="default" + ) + + # 后处理:确保每个知识点有必要字段 + processed_points = [] + for i, kp in enumerate(knowledge_points): + if i >= MAX_KNOWLEDGE_POINTS: + logger.warning(f"知识点数量超过限制 {MAX_KNOWLEDGE_POINTS},截断") + break + + if isinstance(kp, dict): + # 提取字段(兼容多种字段名) + title = ( + kp.get('title') or + kp.get('name') or + kp.get('知识点名称') or + f"知识点 {i + 1}" + ) + content = ( + kp.get('content') or + kp.get('description') or + kp.get('知识点描述') or + '' + ) + kp_type = ( + kp.get('type') or + kp.get('知识点类型') or + DEFAULT_KNOWLEDGE_TYPE + ) + topic_relation = ( + kp.get('topic_relation') or + kp.get('关系描述') or + '' + ) + + if title and (content or topic_relation): + processed_points.append({ + 'title': title[:200], # 限制长度 + 'content': content, + 'type': kp_type, + 'topic_relation': topic_relation, + }) + + if not processed_points: + logger.warning("未能解析出有效的知识点") + + return processed_points + + async def _delete_old_knowledge_points( + self, + db: AsyncSession, + material_id: int + ) -> int: + """删除资料关联的旧知识点""" + try: + from sqlalchemy import text + + result = await db.execute( + text("DELETE FROM knowledge_points WHERE material_id = :material_id"), + {"material_id": material_id} + ) + await db.commit() + + deleted_count = result.rowcount + if deleted_count > 0: + logger.info(f"已删除旧知识点: material_id={material_id}, count={deleted_count}") + + return deleted_count + + except Exception as e: + logger.error(f"删除旧知识点失败: {e}") + await db.rollback() + raise + + async def _save_knowledge_points( + self, + db: AsyncSession, + course_id: int, + material_id: int, + knowledge_points: List[Dict[str, Any]], + user_id: int + ) -> int: + """保存知识点到数据库""" + from app.services.course_service import knowledge_point_service + + saved_count = 0 + + for kp_data in knowledge_points: + try: + kp_create = KnowledgePointCreate( + name=kp_data['title'], + description=kp_data.get('content', ''), + type=kp_data.get('type', DEFAULT_KNOWLEDGE_TYPE), + source=1, # AI 分析来源 + topic_relation=kp_data.get('topic_relation'), + material_id=material_id + ) + + await knowledge_point_service.create_knowledge_point( + db=db, + course_id=course_id, + point_in=kp_create, + created_by=user_id + ) + saved_count += 1 + + except Exception as e: + logger.warning( + f"保存单个知识点失败: title={kp_data.get('title')}, error={e}" + ) + continue + + return saved_count + + async def reanalyze_course_materials( + self, + db: AsyncSession, + course_id: int, + course_title: str, + user_id: int + ) -> Dict[str, Any]: + """ + 重新分析课程的所有资料 + + Args: + db: 数据库会话 + course_id: 课程ID + course_title: 课程标题 + user_id: 用户ID + + Returns: + 分析结果汇总 + """ + try: + from app.services.course_service import course_service + + # 获取课程的所有资料 + materials = await course_service.get_course_materials(db, course_id=course_id) + + if not materials: + return { + "success": True, + "message": "该课程暂无资料需要分析", + "materials_count": 0, + "knowledge_points_count": 0 + } + + total_knowledge_points = 0 + analysis_results = [] + + for material in materials: + try: + result = await self.analyze_course_material( + db=db, + course_id=course_id, + material_id=material.id, + file_url=material.file_url, + course_title=course_title, + user_id=user_id + ) + + kp_count = result.get('knowledge_points_count', 0) + total_knowledge_points += kp_count + + analysis_results.append({ + "material_id": material.id, + "material_name": material.name, + "success": True, + "knowledge_points_count": kp_count + }) + + except Exception as e: + logger.error( + f"资料分析失败: material_id={material.id}, error={e}" + ) + analysis_results.append({ + "material_id": material.id, + "material_name": material.name, + "success": False, + "error": str(e) + }) + + success_count = sum(1 for r in analysis_results if r['success']) + + logger.info( + f"课程资料重新分析完成 - course_id: {course_id}, " + f"materials: {len(materials)}, success: {success_count}, " + f"total_knowledge_points: {total_knowledge_points}" + ) + + return { + "success": True, + "materials_count": len(materials), + "success_count": success_count, + "knowledge_points_count": total_knowledge_points, + "analysis_results": analysis_results + } + + except Exception as e: + logger.error( + f"课程资料重新分析失败 - course_id: {course_id}, error: {e}", + exc_info=True + ) + raise ExternalServiceError(f"重新分析失败: {e}") + + +# 创建全局实例 +knowledge_analysis_service_v2 = KnowledgeAnalysisServiceV2() + + + + + + + + + diff --git a/backend/app/services/ai/llm_json_parser.py b/backend/app/services/ai/llm_json_parser.py new file mode 100644 index 0000000..24b4264 --- /dev/null +++ b/backend/app/services/ai/llm_json_parser.py @@ -0,0 +1,707 @@ +""" +LLM JSON Parser - 大模型 JSON 输出解析器 + +功能: +- 使用 json-repair 库修复 AI 输出的 JSON +- 处理中文标点、尾部逗号、Python 风格等问题 +- Schema 校验确保数据完整性 + +使用示例: +```python +from app.services.ai.llm_json_parser import parse_llm_json, parse_with_fallback + +# 简单解析 +result = parse_llm_json(ai_response) + +# 带 Schema 校验和默认值 +result = parse_with_fallback( + ai_response, + schema=MY_SCHEMA, + default=[] +) +``` +""" + +import json +import re +import logging +from typing import Any, Dict, List, Optional, Tuple, Union +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +# 尝试导入 json-repair +try: + from json_repair import loads as json_repair_loads + from json_repair import repair_json + HAS_JSON_REPAIR = True +except ImportError: + HAS_JSON_REPAIR = False + logger.warning("json-repair 未安装,将使用内置修复逻辑") + +# 尝试导入 jsonschema +try: + from jsonschema import validate, ValidationError, Draft7Validator + HAS_JSONSCHEMA = True +except ImportError: + HAS_JSONSCHEMA = False + logger.warning("jsonschema 未安装,将跳过 Schema 校验") + + +# ==================== 异常类 ==================== + +class JSONParseError(Exception): + """JSON 解析错误基类""" + def __init__(self, message: str, raw_text: str = "", issues: List[dict] = None): + super().__init__(message) + self.raw_text = raw_text + self.issues = issues or [] + + +class JSONUnrecoverableError(JSONParseError): + """不可恢复的 JSON 错误""" + pass + + +# ==================== 解析结果 ==================== + +@dataclass +class ParseResult: + """解析结果""" + success: bool + data: Any = None + method: str = "" # direct / json_repair / preprocessed / fixed / completed / default + issues: List[dict] = field(default_factory=list) + raw_text: str = "" + error: str = "" + + +# ==================== 核心解析函数 ==================== + +def parse_llm_json( + text: str, + *, + strict: bool = False, + return_result: bool = False +) -> Union[Any, ParseResult]: + """ + 智能解析 LLM 输出的 JSON + + Args: + text: 原始文本 + strict: 严格模式,不进行自动修复 + return_result: 返回 ParseResult 对象而非直接数据 + + Returns: + 解析后的 JSON 对象,或 ParseResult(如果 return_result=True) + + Raises: + JSONUnrecoverableError: 所有修复尝试都失败 + """ + if not text or not text.strip(): + if return_result: + return ParseResult(success=False, error="Empty input") + raise JSONUnrecoverableError("Empty input", text) + + text = text.strip() + issues = [] + + # 第一层:直接解析 + try: + data = json.loads(text) + result = ParseResult(success=True, data=data, method="direct", raw_text=text) + return result if return_result else data + except json.JSONDecodeError: + pass + + if strict: + if return_result: + return ParseResult(success=False, error="Strict mode: direct parse failed", raw_text=text) + raise JSONUnrecoverableError("Strict mode: direct parse failed", text) + + # 第二层:使用 json-repair(推荐) + if HAS_JSON_REPAIR: + try: + data = json_repair_loads(text) + issues.append({"type": "json_repair", "action": "Auto-repaired by json-repair library"}) + result = ParseResult(success=True, data=data, method="json_repair", issues=issues, raw_text=text) + return result if return_result else data + except Exception as e: + logger.debug(f"json-repair 修复失败: {e}") + + # 第三层:预处理(提取代码块、清理文字) + preprocessed = _preprocess_text(text) + if preprocessed != text: + try: + data = json.loads(preprocessed) + issues.append({"type": "preprocessed", "action": "Extracted JSON from text"}) + result = ParseResult(success=True, data=data, method="preprocessed", issues=issues, raw_text=text) + return result if return_result else data + except json.JSONDecodeError: + pass + + # 再次尝试 json-repair + if HAS_JSON_REPAIR: + try: + data = json_repair_loads(preprocessed) + issues.append({"type": "json_repair_preprocessed", "action": "Repaired after preprocessing"}) + result = ParseResult(success=True, data=data, method="json_repair", issues=issues, raw_text=text) + return result if return_result else data + except Exception: + pass + + # 第四层:自动修复 + fixed, fix_issues = _fix_json_format(preprocessed) + issues.extend(fix_issues) + + if fixed != preprocessed: + try: + data = json.loads(fixed) + result = ParseResult(success=True, data=data, method="fixed", issues=issues, raw_text=text) + return result if return_result else data + except json.JSONDecodeError: + pass + + # 第五层:尝试补全截断的 JSON + completed = _try_complete_json(fixed) + if completed: + try: + data = json.loads(completed) + issues.append({"type": "completed", "action": "Auto-completed truncated JSON"}) + result = ParseResult(success=True, data=data, method="completed", issues=issues, raw_text=text) + return result if return_result else data + except json.JSONDecodeError: + pass + + # 所有尝试都失败 + diagnosis = diagnose_json_error(fixed) + if return_result: + return ParseResult( + success=False, + method="failed", + issues=issues + diagnosis.get("issues", []), + raw_text=text, + error=f"All parse attempts failed. Issues: {diagnosis}" + ) + raise JSONUnrecoverableError(f"All parse attempts failed: {diagnosis}", text, issues) + + +def parse_with_fallback( + raw_text: str, + schema: dict = None, + default: Any = None, + *, + validate_schema: bool = True, + on_error: str = "default" # "default" / "raise" / "none" +) -> Any: + """ + 带兜底的 JSON 解析 + + Args: + raw_text: 原始文本 + schema: JSON Schema(可选) + default: 默认值 + validate_schema: 是否进行 Schema 校验 + on_error: 错误处理方式 + + Returns: + 解析后的数据或默认值 + """ + try: + result = parse_llm_json(raw_text, return_result=True) + + if not result.success: + logger.warning(f"JSON 解析失败: {result.error}") + if on_error == "raise": + raise JSONUnrecoverableError(result.error, raw_text, result.issues) + elif on_error == "none": + return None + return default + + data = result.data + + # Schema 校验 + if validate_schema and schema and HAS_JSONSCHEMA: + is_valid, errors = validate_json_schema(data, schema) + if not is_valid: + logger.warning(f"Schema 校验失败: {errors}") + if on_error == "raise": + raise JSONUnrecoverableError(f"Schema validation failed: {errors}", raw_text) + elif on_error == "none": + return None + return default + + # 记录解析方法 + if result.method != "direct": + logger.info(f"JSON 解析成功: method={result.method}, issues={result.issues}") + + return data + + except Exception as e: + logger.error(f"JSON 解析异常: {e}") + if on_error == "raise": + raise + elif on_error == "none": + return None + return default + + +# ==================== 预处理函数 ==================== + +def _preprocess_text(text: str) -> str: + """预处理文本:提取代码块、清理前后文字""" + # 移除 BOM + text = text.lstrip('\ufeff') + + # 移除零宽字符 + text = re.sub(r'[\u200b\u200c\u200d\ufeff]', '', text) + + # 提取 Markdown 代码块 + patterns = [ + r'```json\s*([\s\S]*?)\s*```', + r'```\s*([\s\S]*?)\s*```', + r'`([^`]+)`', + ] + + for pattern in patterns: + match = re.search(pattern, text, re.IGNORECASE) + if match: + extracted = match.group(1).strip() + if extracted.startswith(('{', '[')): + text = extracted + break + + # 找到 JSON 边界 + text = _find_json_boundaries(text) + + return text.strip() + + +def _find_json_boundaries(text: str) -> str: + """找到 JSON 的起止位置""" + # 找第一个 { 或 [ + start = -1 + for i, c in enumerate(text): + if c in '{[': + start = i + break + + if start == -1: + return text + + # 找最后一个匹配的 } 或 ] + depth = 0 + end = -1 + in_string = False + escape = False + + for i in range(start, len(text)): + c = text[i] + + if escape: + escape = False + continue + + if c == '\\': + escape = True + continue + + if c == '"': + in_string = not in_string + continue + + if in_string: + continue + + if c in '{[': + depth += 1 + elif c in '}]': + depth -= 1 + if depth == 0: + end = i + 1 + break + + if end == -1: + # 找最后一个 } 或 ] + for i in range(len(text) - 1, start, -1): + if text[i] in '}]': + end = i + 1 + break + + if end > start: + return text[start:end] + + return text[start:] + + +# ==================== 修复函数 ==================== + +def _fix_json_format(text: str) -> Tuple[str, List[dict]]: + """修复常见 JSON 格式问题""" + issues = [] + + # 1. 中文标点转英文 + cn_punctuation = { + ',': ',', '。': '.', ':': ':', ';': ';', + '"': '"', '"': '"', ''': "'", ''': "'", + '【': '[', '】': ']', '(': '(', ')': ')', + '{': '{', '}': '}', + } + for cn, en in cn_punctuation.items(): + if cn in text: + text = text.replace(cn, en) + issues.append({"type": "chinese_punctuation", "from": cn, "to": en}) + + # 2. 移除注释 + if '//' in text: + text = re.sub(r'//[^\n]*', '', text) + issues.append({"type": "removed_comments", "style": "single-line"}) + + if '/*' in text: + text = re.sub(r'/\*[\s\S]*?\*/', '', text) + issues.append({"type": "removed_comments", "style": "multi-line"}) + + # 3. Python 风格转 JSON + python_replacements = [ + (r'\bTrue\b', 'true'), + (r'\bFalse\b', 'false'), + (r'\bNone\b', 'null'), + ] + for pattern, replacement in python_replacements: + if re.search(pattern, text): + text = re.sub(pattern, replacement, text) + issues.append({"type": "python_style", "from": pattern, "to": replacement}) + + # 4. 移除尾部逗号 + trailing_comma_patterns = [ + (r',(\s*})', r'\1'), + (r',(\s*\])', r'\1'), + ] + for pattern, replacement in trailing_comma_patterns: + if re.search(pattern, text): + text = re.sub(pattern, replacement, text) + issues.append({"type": "trailing_comma", "action": "removed"}) + + # 5. 修复单引号(谨慎处理) + if text.count("'") > text.count('"') and re.match(r"^\s*\{?\s*'", text): + text = re.sub(r"'([^']*)'(\s*:)", r'"\1"\2', text) + text = re.sub(r":\s*'([^']*)'", r': "\1"', text) + issues.append({"type": "single_quotes", "action": "replaced"}) + + return text, issues + + +def _try_complete_json(text: str) -> Optional[str]: + """尝试补全截断的 JSON""" + if not text: + return None + + # 统计括号 + stack = [] + in_string = False + escape = False + + for c in text: + if escape: + escape = False + continue + + if c == '\\': + escape = True + continue + + if c == '"': + in_string = not in_string + continue + + if in_string: + continue + + if c in '{[': + stack.append(c) + elif c == '}': + if stack and stack[-1] == '{': + stack.pop() + elif c == ']': + if stack and stack[-1] == '[': + stack.pop() + + if not stack: + return None # 已经平衡了 + + # 如果在字符串中,先闭合字符串 + if in_string: + text += '"' + + # 补全括号 + completion = "" + for bracket in reversed(stack): + if bracket == '{': + completion += '}' + elif bracket == '[': + completion += ']' + + return text + completion + + +# ==================== Schema 校验 ==================== + +def validate_json_schema(data: Any, schema: dict) -> Tuple[bool, List[dict]]: + """ + 校验 JSON 是否符合 Schema + + Returns: + (is_valid, errors) + """ + if not HAS_JSONSCHEMA: + logger.warning("jsonschema 未安装,跳过校验") + return True, [] + + try: + validator = Draft7Validator(schema) + errors = list(validator.iter_errors(data)) + + if errors: + error_messages = [ + { + "path": list(e.absolute_path), + "message": e.message, + "validator": e.validator + } + for e in errors + ] + return False, error_messages + + return True, [] + + except Exception as e: + return False, [{"message": str(e)}] + + +# ==================== 诊断函数 ==================== + +def diagnose_json_error(text: str) -> dict: + """诊断 JSON 错误""" + issues = [] + + # 检查是否为空 + if not text or not text.strip(): + issues.append({ + "type": "empty_input", + "severity": "critical", + "suggestion": "输入为空" + }) + return {"issues": issues, "fixable": False} + + # 检查中文标点 + cn_punctuation = [',', '。', ':', ';', '"', '"', ''', '''] + for p in cn_punctuation: + if p in text: + issues.append({ + "type": "chinese_punctuation", + "char": p, + "severity": "low", + "suggestion": f"将 {p} 替换为对应英文标点" + }) + + # 检查代码块包裹 + if '```' in text: + issues.append({ + "type": "markdown_wrapped", + "severity": "low", + "suggestion": "需要提取代码块内容" + }) + + # 检查注释 + if '//' in text or '/*' in text: + issues.append({ + "type": "has_comments", + "severity": "low", + "suggestion": "需要移除注释" + }) + + # 检查 Python 风格 + if re.search(r'\b(True|False|None)\b', text): + issues.append({ + "type": "python_style", + "severity": "low", + "suggestion": "将 True/False/None 转为 true/false/null" + }) + + # 检查尾部逗号 + if re.search(r',\s*[}\]]', text): + issues.append({ + "type": "trailing_comma", + "severity": "low", + "suggestion": "移除 } 或 ] 前的逗号" + }) + + # 检查括号平衡 + open_braces = text.count('{') - text.count('}') + open_brackets = text.count('[') - text.count(']') + + if open_braces > 0: + issues.append({ + "type": "unclosed_brace", + "count": open_braces, + "severity": "medium", + "suggestion": f"缺少 {open_braces} 个 }}" + }) + elif open_braces < 0: + issues.append({ + "type": "extra_brace", + "count": -open_braces, + "severity": "medium", + "suggestion": f"多余 {-open_braces} 个 }}" + }) + + if open_brackets > 0: + issues.append({ + "type": "unclosed_bracket", + "count": open_brackets, + "severity": "medium", + "suggestion": f"缺少 {open_brackets} 个 ]" + }) + elif open_brackets < 0: + issues.append({ + "type": "extra_bracket", + "count": -open_brackets, + "severity": "medium", + "suggestion": f"多余 {-open_brackets} 个 ]" + }) + + # 检查引号平衡 + quote_count = text.count('"') + if quote_count % 2 != 0: + issues.append({ + "type": "unbalanced_quotes", + "severity": "high", + "suggestion": "引号数量不平衡,可能有未闭合的字符串" + }) + + # 判断是否可修复 + fixable_types = { + "chinese_punctuation", "markdown_wrapped", "has_comments", + "python_style", "trailing_comma", "unclosed_brace", "unclosed_bracket" + } + fixable = all(i["type"] in fixable_types for i in issues) + + return { + "issues": issues, + "issue_count": len(issues), + "fixable": fixable, + "severity": max( + (i.get("severity", "low") for i in issues), + key=lambda x: {"low": 1, "medium": 2, "high": 3, "critical": 4}.get(x, 0), + default="low" + ) + } + + +# ==================== 便捷函数 ==================== + +def safe_json_loads(text: str, default: Any = None) -> Any: + """安全的 json.loads,失败返回默认值""" + try: + return parse_llm_json(text) + except Exception: + return default + + +def extract_json_from_text(text: str) -> Optional[str]: + """从文本中提取 JSON 字符串""" + preprocessed = _preprocess_text(text) + fixed, _ = _fix_json_format(preprocessed) + + try: + json.loads(fixed) + return fixed + except Exception: + completed = _try_complete_json(fixed) + if completed: + try: + json.loads(completed) + return completed + except Exception: + pass + + return None + + +def clean_llm_output(text: str) -> Tuple[str, List[str]]: + """ + 清洗大模型输出,返回清洗后的文本和应用的清洗规则 + + Args: + text: 原始输出文本 + + Returns: + (cleaned_text, applied_rules) + """ + if not text: + return "", ["empty_input"] + + applied_rules = [] + original = text + + # 1. 去除 BOM 头 + if text.startswith('\ufeff'): + text = text.lstrip('\ufeff') + applied_rules.append("removed_bom") + + # 2. 去除 ANSI 转义序列 + ansi_pattern = re.compile(r'\x1b\[[0-9;]*m') + if ansi_pattern.search(text): + text = ansi_pattern.sub('', text) + applied_rules.append("removed_ansi") + + # 3. 去除首尾空白 + text = text.strip() + + # 4. 去除开头的客套话 + polite_patterns = [ + r'^好的[,,。.]?\s*', + r'^当然[,,。.]?\s*', + r'^没问题[,,。.]?\s*', + r'^根据您的要求[,,。.]?\s*', + r'^以下是.*?[::]\s*', + r'^分析结果如下[::]\s*', + r'^我来为您.*?[::]\s*', + r'^这是.*?结果[::]\s*', + ] + for pattern in polite_patterns: + if re.match(pattern, text, re.IGNORECASE): + text = re.sub(pattern, '', text, flags=re.IGNORECASE) + applied_rules.append("removed_polite_prefix") + break + + # 5. 提取 Markdown JSON 代码块 + json_block_patterns = [ + r'```json\s*([\s\S]*?)\s*```', + r'```\s*([\s\S]*?)\s*```', + ] + for pattern in json_block_patterns: + match = re.search(pattern, text, re.IGNORECASE) + if match: + extracted = match.group(1).strip() + if extracted.startswith(('{', '[')): + text = extracted + applied_rules.append("extracted_code_block") + break + + # 6. 处理零宽字符 + zero_width = re.compile(r'[\u200b\u200c\u200d\ufeff]') + if zero_width.search(text): + text = zero_width.sub('', text) + applied_rules.append("removed_zero_width") + + return text.strip(), applied_rules + + + + + + + + + diff --git a/backend/app/services/ai/practice_analysis_service.py b/backend/app/services/ai/practice_analysis_service.py new file mode 100644 index 0000000..909c113 --- /dev/null +++ b/backend/app/services/ai/practice_analysis_service.py @@ -0,0 +1,377 @@ +""" +陪练分析报告服务 - Python 原生实现 + +功能: +- 分析陪练对话历史 +- 生成综合评分、能力维度评估 +- 提供对话标注和改进建议 + +提供稳定可靠的陪练分析报告生成能力。 +""" + +import json +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from .ai_service import AIService, AIResponse +from .llm_json_parser import parse_with_fallback, clean_llm_output +from .prompts.practice_analysis_prompts import ( + SYSTEM_PROMPT, + USER_PROMPT, + PRACTICE_ANALYSIS_SCHEMA, + SCORE_BREAKDOWN_ITEMS, + ABILITY_DIMENSIONS, +) + +logger = logging.getLogger(__name__) + + +# ==================== 数据结构 ==================== + +@dataclass +class ScoreBreakdownItem: + """分数细分项""" + name: str + score: float + description: str + + +@dataclass +class AbilityDimensionItem: + """能力维度项""" + name: str + score: float + feedback: str + + +@dataclass +class DialogueAnnotation: + """对话标注""" + sequence: int + tags: List[str] + comment: str + + +@dataclass +class Suggestion: + """改进建议""" + title: str + content: str + example: str + + +@dataclass +class PracticeAnalysisResult: + """陪练分析结果""" + success: bool + total_score: float = 0.0 + score_breakdown: List[ScoreBreakdownItem] = field(default_factory=list) + ability_dimensions: List[AbilityDimensionItem] = field(default_factory=list) + dialogue_annotations: List[DialogueAnnotation] = field(default_factory=list) + suggestions: List[Suggestion] = field(default_factory=list) + ai_provider: str = "" + ai_model: str = "" + ai_tokens: int = 0 + ai_latency_ms: int = 0 + error: str = "" + + def to_dict(self) -> Dict[str, Any]: + """转换为字典(兼容原有数据格式)""" + return { + "analysis": { + "total_score": self.total_score, + "score_breakdown": [ + {"name": s.name, "score": s.score, "description": s.description} + for s in self.score_breakdown + ], + "ability_dimensions": [ + {"name": d.name, "score": d.score, "feedback": d.feedback} + for d in self.ability_dimensions + ], + "dialogue_annotations": [ + {"sequence": a.sequence, "tags": a.tags, "comment": a.comment} + for a in self.dialogue_annotations + ], + "suggestions": [ + {"title": s.title, "content": s.content, "example": s.example} + for s in self.suggestions + ], + }, + "ai_provider": self.ai_provider, + "ai_model": self.ai_model, + "ai_tokens": self.ai_tokens, + "ai_latency_ms": self.ai_latency_ms, + } + + def to_db_format(self) -> Dict[str, Any]: + """转换为数据库存储格式(兼容 PracticeReport 模型)""" + return { + "total_score": int(self.total_score), + "score_breakdown": [ + {"name": s.name, "score": s.score, "description": s.description} + for s in self.score_breakdown + ], + "ability_dimensions": [ + {"name": d.name, "score": d.score, "feedback": d.feedback} + for d in self.ability_dimensions + ], + "dialogue_review": [ + {"sequence": a.sequence, "tags": a.tags, "comment": a.comment} + for a in self.dialogue_annotations + ], + "suggestions": [ + {"title": s.title, "content": s.content, "example": s.example} + for s in self.suggestions + ], + } + + +# ==================== 服务类 ==================== + +class PracticeAnalysisService: + """ + 陪练分析报告服务 + + 使用 Python 原生实现。 + + 使用示例: + ```python + service = PracticeAnalysisService() + result = await service.analyze( + db=db_session, # 传入 db_session 用于记录调用日志 + dialogue_history=[ + {"speaker": "user", "content": "您好,我想咨询一下..."}, + {"speaker": "ai", "content": "您好!很高兴为您服务..."} + ] + ) + print(result.total_score) + print(result.suggestions) + ``` + """ + + MODULE_CODE = "practice_analysis" + + async def analyze( + self, + dialogue_history: List[Dict[str, Any]], + db: Any = None # 数据库会话,用于记录 AI 调用日志 + ) -> PracticeAnalysisResult: + """ + 分析陪练对话 + + Args: + dialogue_history: 对话历史列表,每项包含 speaker, content, timestamp 等字段 + db: 数据库会话,用于记录调用日志(符合 AI 接入规范) + + Returns: + PracticeAnalysisResult 分析结果 + """ + try: + logger.info(f"开始分析陪练对话 - 对话轮次: {len(dialogue_history)}") + + # 1. 验证输入 + if not dialogue_history or len(dialogue_history) < 2: + return PracticeAnalysisResult( + success=False, + error="对话记录太少,无法生成分析报告(至少需要2轮对话)" + ) + + # 2. 格式化对话历史 + dialogue_text = self._format_dialogue_history(dialogue_history) + + # 3. 创建 AIService 实例(传入 db_session 用于记录调用日志) + self._ai_service = AIService(module_code=self.MODULE_CODE, db_session=db) + + # 4. 调用 AI 分析 + ai_response = await self._call_ai_analysis(dialogue_text) + + logger.info( + f"AI 分析完成 - provider: {ai_response.provider}, " + f"tokens: {ai_response.total_tokens}, latency: {ai_response.latency_ms}ms" + ) + + # 4. 解析 JSON 结果 + analysis_data = self._parse_analysis_result(ai_response.content) + + # 5. 构建返回结果 + result = PracticeAnalysisResult( + success=True, + total_score=analysis_data.get("total_score", 0), + score_breakdown=[ + ScoreBreakdownItem( + name=s.get("name", ""), + score=s.get("score", 0), + description=s.get("description", "") + ) + for s in analysis_data.get("score_breakdown", []) + ], + ability_dimensions=[ + AbilityDimensionItem( + name=d.get("name", ""), + score=d.get("score", 0), + feedback=d.get("feedback", "") + ) + for d in analysis_data.get("ability_dimensions", []) + ], + dialogue_annotations=[ + DialogueAnnotation( + sequence=a.get("sequence", 0), + tags=a.get("tags", []), + comment=a.get("comment", "") + ) + for a in analysis_data.get("dialogue_annotations", []) + ], + suggestions=[ + Suggestion( + title=s.get("title", ""), + content=s.get("content", ""), + example=s.get("example", "") + ) + for s in analysis_data.get("suggestions", []) + ], + ai_provider=ai_response.provider, + ai_model=ai_response.model, + ai_tokens=ai_response.total_tokens, + ai_latency_ms=ai_response.latency_ms, + ) + + logger.info( + f"陪练分析完成 - total_score: {result.total_score}, " + f"annotations: {len(result.dialogue_annotations)}, " + f"suggestions: {len(result.suggestions)}" + ) + + return result + + except Exception as e: + logger.error(f"陪练分析失败: {e}", exc_info=True) + return PracticeAnalysisResult( + success=False, + error=str(e) + ) + + def _format_dialogue_history(self, dialogue_history: List[Dict[str, Any]]) -> str: + """ + 格式化对话历史为文本 + + Args: + dialogue_history: 对话历史列表 + + Returns: + 格式化后的对话文本 + """ + lines = [] + for i, d in enumerate(dialogue_history, 1): + speaker = d.get('speaker', 'unknown') + content = d.get('content', '') + + # 统一说话者标识 + if speaker in ['user', 'employee', 'consultant', '员工', '用户']: + speaker_label = '员工' + elif speaker in ['ai', 'customer', 'client', '顾客', '客户', 'AI']: + speaker_label = '顾客' + else: + speaker_label = speaker + + lines.append(f"[{i}] {speaker_label}: {content}") + + return '\n'.join(lines) + + async def _call_ai_analysis(self, dialogue_text: str) -> AIResponse: + """调用 AI 进行分析""" + user_message = USER_PROMPT.format(dialogue_history=dialogue_text) + + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_message} + ] + + response = await self._ai_service.chat( + messages=messages, + temperature=0.7, + prompt_name="practice_analysis" + ) + + return response + + def _parse_analysis_result(self, ai_output: str) -> Dict[str, Any]: + """ + 解析 AI 输出的分析结果 JSON + + 使用 LLM JSON Parser 进行多层兜底解析 + """ + # 先清洗输出 + cleaned_output, rules = clean_llm_output(ai_output) + if rules: + logger.debug(f"AI 输出已清洗: {rules}") + + # 使用带 Schema 校验的解析 + parsed = parse_with_fallback( + cleaned_output, + schema=PRACTICE_ANALYSIS_SCHEMA, + default={"analysis": {}}, + validate_schema=True, + on_error="default" + ) + + # 提取 analysis 部分 + analysis = parsed.get("analysis", {}) + + # 确保 score_breakdown 完整 + existing_breakdown = {s.get("name") for s in analysis.get("score_breakdown", [])} + for item_name in SCORE_BREAKDOWN_ITEMS: + if item_name not in existing_breakdown: + logger.warning(f"缺少分数维度: {item_name},使用默认值") + analysis.setdefault("score_breakdown", []).append({ + "name": item_name, + "score": 75, + "description": "暂无详细评价" + }) + + # 确保 ability_dimensions 完整 + existing_dims = {d.get("name") for d in analysis.get("ability_dimensions", [])} + for dim_name in ABILITY_DIMENSIONS: + if dim_name not in existing_dims: + logger.warning(f"缺少能力维度: {dim_name},使用默认值") + analysis.setdefault("ability_dimensions", []).append({ + "name": dim_name, + "score": 75, + "feedback": "暂无详细评价" + }) + + # 确保有建议 + if not analysis.get("suggestions"): + analysis["suggestions"] = [ + { + "title": "持续练习", + "content": "建议继续进行陪练练习,提升整体表现", + "example": "每周进行2-3次陪练,针对薄弱环节重点练习" + } + ] + + return analysis + + +# ==================== 全局实例 ==================== + +practice_analysis_service = PracticeAnalysisService() + + +# ==================== 便捷函数 ==================== + +async def analyze_practice_session( + dialogue_history: List[Dict[str, Any]] +) -> Dict[str, Any]: + """ + 便捷函数:分析陪练会话 + + Args: + dialogue_history: 对话历史列表 + + Returns: + 分析结果字典(兼容原有格式) + """ + result = await practice_analysis_service.analyze(dialogue_history) + return result.to_dict() + diff --git a/backend/app/services/ai/practice_scene_service.py b/backend/app/services/ai/practice_scene_service.py new file mode 100644 index 0000000..86afa70 --- /dev/null +++ b/backend/app/services/ai/practice_scene_service.py @@ -0,0 +1,379 @@ +""" +陪练场景准备服务 - Python 原生实现 + +功能: +- 根据课程ID获取知识点 +- 调用 AI 生成陪练场景配置 +- 解析并返回结构化场景数据 + +提供稳定可靠的陪练场景提取能力。 +""" + +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import ExternalServiceError + +from .ai_service import AIService, AIResponse +from .llm_json_parser import parse_with_fallback, clean_llm_output +from .prompts.practice_scene_prompts import ( + SYSTEM_PROMPT, + USER_PROMPT, + PRACTICE_SCENE_SCHEMA, + DEFAULT_SCENE_TYPE, + DEFAULT_DIFFICULTY, +) + +logger = logging.getLogger(__name__) + + +# ==================== 数据结构 ==================== + +@dataclass +class PracticeScene: + """陪练场景数据结构""" + name: str + description: str + background: str + ai_role: str + objectives: List[str] + keywords: List[str] + type: str = DEFAULT_SCENE_TYPE + difficulty: str = DEFAULT_DIFFICULTY + + +@dataclass +class PracticeSceneResult: + """陪练场景生成结果""" + success: bool + scene: Optional[PracticeScene] = None + raw_response: Dict[str, Any] = field(default_factory=dict) + ai_provider: str = "" + ai_model: str = "" + ai_tokens: int = 0 + ai_latency_ms: int = 0 + knowledge_points_count: int = 0 + error: str = "" + + +# ==================== 服务类 ==================== + +class PracticeSceneService: + """ + 陪练场景准备服务 + + 使用 Python 原生实现。 + + 使用示例: + ```python + service = PracticeSceneService() + result = await service.prepare_practice_knowledge( + db=db_session, + course_id=1 + ) + if result.success: + print(result.scene.name) + print(result.scene.objectives) + ``` + """ + + def __init__(self): + """初始化服务""" + self.ai_service = AIService(module_code="practice_scene") + + async def prepare_practice_knowledge( + self, + db: AsyncSession, + course_id: int + ) -> PracticeSceneResult: + """ + 准备陪练所需的知识内容并生成场景 + + 陪练知识准备的 Python 实现。 + + Args: + db: 数据库会话(支持多租户,由调用方传入对应租户的数据库连接) + course_id: 课程ID + + Returns: + PracticeSceneResult: 包含场景配置和元信息的结果对象 + """ + try: + logger.info(f"开始陪练知识准备 - course_id: {course_id}") + + # 1. 查询知识点 + knowledge_points = await self._fetch_knowledge_points(db, course_id) + + if not knowledge_points: + logger.warning(f"课程没有知识点 - course_id: {course_id}") + return PracticeSceneResult( + success=False, + error=f"课程 {course_id} 没有可用的知识点" + ) + + logger.info(f"获取到 {len(knowledge_points)} 个知识点 - course_id: {course_id}") + + # 2. 格式化知识点为文本 + knowledge_text = self._format_knowledge_points(knowledge_points) + + # 3. 调用 AI 生成场景 + ai_response = await self._call_ai_generation(knowledge_text) + + logger.info( + f"AI 生成完成 - provider: {ai_response.provider}, " + f"tokens: {ai_response.total_tokens}, latency: {ai_response.latency_ms}ms" + ) + + # 4. 解析 JSON 结果 + scene_data = self._parse_scene_response(ai_response.content) + + if not scene_data: + logger.error(f"场景解析失败 - course_id: {course_id}") + return PracticeSceneResult( + success=False, + raw_response={"ai_output": ai_response.content}, + ai_provider=ai_response.provider, + ai_model=ai_response.model, + ai_tokens=ai_response.total_tokens, + ai_latency_ms=ai_response.latency_ms, + knowledge_points_count=len(knowledge_points), + error="AI 输出解析失败" + ) + + # 5. 构建场景对象 + scene = self._build_scene_object(scene_data) + + logger.info( + f"陪练场景生成成功 - course_id: {course_id}, " + f"scene_name: {scene.name}, type: {scene.type}" + ) + + return PracticeSceneResult( + success=True, + scene=scene, + raw_response=scene_data, + ai_provider=ai_response.provider, + ai_model=ai_response.model, + ai_tokens=ai_response.total_tokens, + ai_latency_ms=ai_response.latency_ms, + knowledge_points_count=len(knowledge_points) + ) + + except Exception as e: + logger.error( + f"陪练知识准备失败 - course_id: {course_id}, error: {e}", + exc_info=True + ) + return PracticeSceneResult( + success=False, + error=str(e) + ) + + async def _fetch_knowledge_points( + self, + db: AsyncSession, + course_id: int + ) -> List[Dict[str, Any]]: + """ + 从数据库获取课程知识点 + + 获取课程知识点 + """ + # 知识点查询 SQL: + # SELECT kp.name, kp.description + # FROM knowledge_points kp + # INNER JOIN course_materials cm ON kp.material_id = cm.id + # WHERE kp.course_id = {course_id} + # AND kp.is_deleted = 0 + # AND cm.is_deleted = 0 + # ORDER BY kp.id; + + sql = text(""" + SELECT kp.name, kp.description + FROM knowledge_points kp + INNER JOIN course_materials cm ON kp.material_id = cm.id + WHERE kp.course_id = :course_id + AND kp.is_deleted = 0 + AND cm.is_deleted = 0 + ORDER BY kp.id + """) + + try: + result = await db.execute(sql, {"course_id": course_id}) + rows = result.fetchall() + + knowledge_points = [] + for row in rows: + knowledge_points.append({ + "name": row[0], + "description": row[1] or "" + }) + + return knowledge_points + + except Exception as e: + logger.error(f"查询知识点失败: {e}") + raise ExternalServiceError(f"数据库查询失败: {e}") + + def _format_knowledge_points(self, knowledge_points: List[Dict[str, Any]]) -> str: + """ + 将知识点列表格式化为文本 + + Args: + knowledge_points: 知识点列表 + + Returns: + 格式化后的文本 + """ + lines = [] + for i, kp in enumerate(knowledge_points, 1): + name = kp.get("name", "") + description = kp.get("description", "") + + if description: + lines.append(f"{i}. {name}\n {description}") + else: + lines.append(f"{i}. {name}") + + return "\n\n".join(lines) + + async def _call_ai_generation(self, knowledge_text: str) -> AIResponse: + """ + 调用 AI 生成陪练场景 + + Args: + knowledge_text: 格式化后的知识点文本 + + Returns: + AI 响应对象 + """ + # 构建用户消息 + user_message = USER_PROMPT.format(knowledge_points=knowledge_text) + + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_message} + ] + + # 调用 AI(自动降级:4sapi.com → OpenRouter) + response = await self.ai_service.chat( + messages=messages, + temperature=0.7, # 适中的创意性 + prompt_name="practice_scene_generation" + ) + + return response + + def _parse_scene_response(self, ai_output: str) -> Optional[Dict[str, Any]]: + """ + 解析 AI 输出的场景 JSON + + 使用 LLM JSON Parser 进行多层兜底解析 + + Args: + ai_output: AI 原始输出 + + Returns: + 解析后的字典,失败返回 None + """ + # 先清洗输出 + cleaned_output, rules = clean_llm_output(ai_output) + if rules: + logger.debug(f"AI 输出已清洗: {rules}") + + # 使用带 Schema 校验的解析 + result = parse_with_fallback( + cleaned_output, + schema=PRACTICE_SCENE_SCHEMA, + default=None, + validate_schema=True, + on_error="none" + ) + + return result + + def _build_scene_object(self, scene_data: Dict[str, Any]) -> PracticeScene: + """ + 从解析的字典构建场景对象 + + Args: + scene_data: 解析后的场景数据 + + Returns: + PracticeScene 对象 + """ + # 提取 scene 字段(JSON 格式为 {"scene": {...}}) + scene = scene_data.get("scene", scene_data) + + return PracticeScene( + name=scene.get("name", "陪练场景"), + description=scene.get("description", ""), + background=scene.get("background", ""), + ai_role=scene.get("ai_role", "AI扮演客户"), + objectives=scene.get("objectives", []), + keywords=scene.get("keywords", []), + type=scene.get("type", DEFAULT_SCENE_TYPE), + difficulty=scene.get("difficulty", DEFAULT_DIFFICULTY) + ) + + def scene_to_dict(self, scene: PracticeScene) -> Dict[str, Any]: + """ + 将场景对象转换为字典 + + 便于 API 响应序列化 + + Args: + scene: PracticeScene 对象 + + Returns: + 字典格式的场景数据 + """ + return { + "scene": { + "name": scene.name, + "description": scene.description, + "background": scene.background, + "ai_role": scene.ai_role, + "objectives": scene.objectives, + "keywords": scene.keywords, + "type": scene.type, + "difficulty": scene.difficulty + } + } + + +# ==================== 全局实例 ==================== + +practice_scene_service = PracticeSceneService() + + +# ==================== 便捷函数 ==================== + +async def prepare_practice_knowledge( + db: AsyncSession, + course_id: int +) -> PracticeSceneResult: + """ + 准备陪练所需的知识内容(便捷函数) + + Args: + db: 数据库会话 + course_id: 课程ID + + Returns: + PracticeSceneResult 结果对象 + """ + return await practice_scene_service.prepare_practice_knowledge(db, course_id) + + + + + + + + + diff --git a/backend/app/services/ai/prompts/__init__.py b/backend/app/services/ai/prompts/__init__.py new file mode 100644 index 0000000..6934036 --- /dev/null +++ b/backend/app/services/ai/prompts/__init__.py @@ -0,0 +1,57 @@ +""" +提示词模板模块 + +遵循瑞小美提示词规范 +""" + +from .knowledge_analysis_prompts import ( + PROMPT_META as KNOWLEDGE_ANALYSIS_PROMPT_META, + SYSTEM_PROMPT as KNOWLEDGE_ANALYSIS_SYSTEM_PROMPT, + USER_PROMPT as KNOWLEDGE_ANALYSIS_USER_PROMPT, + KNOWLEDGE_POINT_SCHEMA, +) + +from .exam_generator_prompts import ( + PROMPT_META as EXAM_GENERATOR_PROMPT_META, + SYSTEM_PROMPT as EXAM_GENERATOR_SYSTEM_PROMPT, + USER_PROMPT as EXAM_GENERATOR_USER_PROMPT, + MISTAKE_REGEN_SYSTEM_PROMPT, + MISTAKE_REGEN_USER_PROMPT, + QUESTION_SCHEMA, + QUESTION_TYPES, + DEFAULT_QUESTION_COUNTS, + DEFAULT_DIFFICULTY_LEVEL, +) + +from .ability_analysis_prompts import ( + PROMPT_META as ABILITY_ANALYSIS_PROMPT_META, + SYSTEM_PROMPT as ABILITY_ANALYSIS_SYSTEM_PROMPT, + USER_PROMPT as ABILITY_ANALYSIS_USER_PROMPT, + ABILITY_ANALYSIS_SCHEMA, + ABILITY_DIMENSIONS, +) + +__all__ = [ + # Knowledge Analysis Prompts + "KNOWLEDGE_ANALYSIS_PROMPT_META", + "KNOWLEDGE_ANALYSIS_SYSTEM_PROMPT", + "KNOWLEDGE_ANALYSIS_USER_PROMPT", + "KNOWLEDGE_POINT_SCHEMA", + # Exam Generator Prompts + "EXAM_GENERATOR_PROMPT_META", + "EXAM_GENERATOR_SYSTEM_PROMPT", + "EXAM_GENERATOR_USER_PROMPT", + "MISTAKE_REGEN_SYSTEM_PROMPT", + "MISTAKE_REGEN_USER_PROMPT", + "QUESTION_SCHEMA", + "QUESTION_TYPES", + "DEFAULT_QUESTION_COUNTS", + "DEFAULT_DIFFICULTY_LEVEL", + # Ability Analysis Prompts + "ABILITY_ANALYSIS_PROMPT_META", + "ABILITY_ANALYSIS_SYSTEM_PROMPT", + "ABILITY_ANALYSIS_USER_PROMPT", + "ABILITY_ANALYSIS_SCHEMA", + "ABILITY_DIMENSIONS", +] + diff --git a/backend/app/services/ai/prompts/ability_analysis_prompts.py b/backend/app/services/ai/prompts/ability_analysis_prompts.py new file mode 100644 index 0000000..1bdccf3 --- /dev/null +++ b/backend/app/services/ai/prompts/ability_analysis_prompts.py @@ -0,0 +1,215 @@ +""" +智能工牌能力分析与课程推荐提示词模板 + +功能:分析员工与顾客的对话记录,评估能力维度得分,并推荐适合的课程 +""" + +# ==================== 元数据 ==================== + +PROMPT_META = { + "name": "ability_analysis", + "display_name": "智能工牌能力分析", + "description": "分析员工与顾客对话,评估多维度能力得分,推荐个性化课程", + "module": "kaopeilian", + "variables": ["dialogue_history", "user_info", "courses"], + "version": "1.0.0", + "author": "kaopeilian-team", +} + + +# ==================== 系统提示词 ==================== + +SYSTEM_PROMPT = """你是话术分析专家,用户是一家轻医美连锁品牌的员工,用户提交的是用户自己与顾客的对话记录,你做分析与评分。并严格按照以下格式输出。并根据课程列表,为该用户提供选课建议。 + +输出标准: +{ + "analysis": { + "total_score": 82, + "ability_dimensions": [ + { + "name": "专业知识", + "score": 88, + "feedback": "产品知识扎实,能准确回答客户问题。建议:继续深化对新产品的了解。" + }, + { + "name": "沟通技巧", + "score": 92, + "feedback": "语言表达清晰流畅,善于倾听客户需求。建议:可以多使用开放式问题引导。" + }, + { + "name": "操作技能", + "score": 85, + "feedback": "基本操作熟练,流程规范。建议:提升复杂场景的应对速度。" + }, + { + "name": "客户服务", + "score": 90, + "feedback": "服务态度优秀,客户体验良好。建议:进一步提升个性化服务能力。" + }, + { + "name": "安全意识", + "score": 79, + "feedback": "基本安全规范掌握,但在细节提醒上还可加强。" + }, + { + "name": "应变能力", + "score": 76, + "feedback": "面对突发情况反应较快,但处理方式可以更灵活多样。" + } + ], + "course_recommendations": [ + { + "course_id": 5, + "course_name": "应变能力提升训练营", + "recommendation_reason": "该课程专注于提升应变能力,包含大量实战案例分析和模拟演练,针对您当前的薄弱环节(应变能力76分)设计。通过学习可提升15分左右。", + "priority": "high", + "match_score": 95 + }, + { + "course_id": 3, + "course_name": "安全规范与操作标准", + "recommendation_reason": "系统讲解安全规范和操作标准,通过案例教学帮助建立安全意识。当前您的安全意识得分为79分,通过本课程学习预计可提升12分。", + "priority": "high", + "match_score": 88 + }, + { + "course_id": 7, + "course_name": "高级销售技巧", + "recommendation_reason": "进阶课程,帮助您将已有的沟通优势(92分)转化为更高级的销售技能,进一步巩固客户服务能力(90分)。", + "priority": "medium", + "match_score": 82 + } + ] + } +} + +## 输出要求(严格执行) +1. 直接输出纯净的 JSON,不要包含 Markdown 标记(如 ```json) +2. 不要包含任何解释性文字 +3. 能力维度必须包含:专业知识、沟通技巧、操作技能、客户服务、安全意识、应变能力 +4. 课程推荐必须来自提供的课程列表,使用真实的 course_id +5. 推荐课程数量:1-5个,优先推荐能补齐短板的课程 +6. priority 取值:high(得分<80的薄弱项)、medium(得分80-85)、low(锦上添花) + +## 评分标准 +- 90-100:优秀 +- 80-89:良好 +- 70-79:一般 +- 60-69:需改进 +- <60:亟需提升""" + + +# ==================== 用户提示词模板 ==================== + +USER_PROMPT = """对话记录:{dialogue_history} + +--- + +用户的信息和岗位:{user_info} + +--- + +所有可选课程:{courses}""" + + +# ==================== JSON Schema ==================== + +ABILITY_ANALYSIS_SCHEMA = { + "type": "object", + "required": ["analysis"], + "properties": { + "analysis": { + "type": "object", + "required": ["total_score", "ability_dimensions", "course_recommendations"], + "properties": { + "total_score": { + "type": "number", + "description": "总体评分(0-100)", + "minimum": 0, + "maximum": 100 + }, + "ability_dimensions": { + "type": "array", + "description": "能力维度评分列表", + "items": { + "type": "object", + "required": ["name", "score", "feedback"], + "properties": { + "name": { + "type": "string", + "description": "能力维度名称" + }, + "score": { + "type": "number", + "description": "该维度得分(0-100)", + "minimum": 0, + "maximum": 100 + }, + "feedback": { + "type": "string", + "description": "该维度的反馈和建议" + } + } + }, + "minItems": 1 + }, + "course_recommendations": { + "type": "array", + "description": "课程推荐列表", + "items": { + "type": "object", + "required": ["course_id", "course_name", "recommendation_reason", "priority", "match_score"], + "properties": { + "course_id": { + "type": "integer", + "description": "课程ID" + }, + "course_name": { + "type": "string", + "description": "课程名称" + }, + "recommendation_reason": { + "type": "string", + "description": "推荐理由" + }, + "priority": { + "type": "string", + "description": "推荐优先级", + "enum": ["high", "medium", "low"] + }, + "match_score": { + "type": "number", + "description": "匹配度得分(0-100)", + "minimum": 0, + "maximum": 100 + } + } + } + } + } + } + } +} + + +# ==================== 能力维度常量 ==================== + +ABILITY_DIMENSIONS = [ + "专业知识", + "沟通技巧", + "操作技能", + "客户服务", + "安全意识", + "应变能力", +] + +PRIORITY_LEVELS = ["high", "medium", "low"] + + + + + + + + + diff --git a/backend/app/services/ai/prompts/answer_judge_prompts.py b/backend/app/services/ai/prompts/answer_judge_prompts.py new file mode 100644 index 0000000..6580979 --- /dev/null +++ b/backend/app/services/ai/prompts/answer_judge_prompts.py @@ -0,0 +1,48 @@ +""" +答案判断器提示词模板 + +功能:判断填空题与问答题是否回答正确 +""" + +# ==================== 元数据 ==================== + +PROMPT_META = { + "name": "answer_judge", + "display_name": "答案判断器", + "description": "判断填空题与问答题的答案是否正确", + "module": "kaopeilian", + "variables": ["question", "correct_answer", "user_answer", "analysis"], + "version": "1.0.0", + "author": "kaopeilian-team", +} + + +# ==================== 系统提示词 ==================== + +SYSTEM_PROMPT = """你是一个答案判断器,根据用户提交的答案,比对题目、答案、解析。给出正确或错误的判断。 + +注意:仅输出"正确"或"错误",无需更多字符和说明。""" + + +# ==================== 用户提示词模板 ==================== + +USER_PROMPT = """题目:{question}。 +正确答案:{correct_answer}。 +解析:{analysis}。 + +考生的回答:{user_answer}。""" + + +# ==================== 判断结果常量 ==================== + +CORRECT_KEYWORDS = ["正确", "correct", "true", "yes", "对", "是"] +INCORRECT_KEYWORDS = ["错误", "incorrect", "false", "no", "wrong", "不正确", "错"] + + + + + + + + + diff --git a/backend/app/services/ai/prompts/course_chat_prompts.py b/backend/app/services/ai/prompts/course_chat_prompts.py new file mode 100644 index 0000000..ac5ab4e --- /dev/null +++ b/backend/app/services/ai/prompts/course_chat_prompts.py @@ -0,0 +1,74 @@ +""" +课程对话提示词模板 + +功能:基于课程知识点进行智能问答 +""" + +# ==================== 元数据 ==================== + +PROMPT_META = { + "name": "course_chat", + "display_name": "与课程对话", + "description": "基于课程知识点内容,为用户提供智能问答服务", + "module": "kaopeilian", + "variables": ["knowledge_base", "query"], + "version": "2.0.0", + "author": "kaopeilian-team", +} + + +# ==================== 系统提示词 ==================== + +SYSTEM_PROMPT = """你是知识拆解专家,精通以下知识库(课程)内容。请根据用户的问题,从知识库中找到最相关的信息,进行深入分析后,用简洁清晰的语言回答用户。为用户提供与课程对话的服务。 + +回答要求: + +1. 直接针对问题核心,避免冗长铺垫 +2. 使用通俗易懂的语言,必要时举例说明 +3. 突出关键要点,帮助用户快速理解 +4. 如果知识库中没有相关内容,请如实告知 + +知识库: +{knowledge_base}""" + + +# ==================== 用户提示词模板 ==================== + +USER_PROMPT = """{query}""" + + +# ==================== 知识库格式模板 ==================== + +KNOWLEDGE_ITEM_TEMPLATE = """【{name}】 +{description} +""" + + +# ==================== 配置常量 ==================== + +# 会话历史窗口大小(保留最近 N 轮对话) +CONVERSATION_WINDOW_SIZE = 10 + +# 会话 TTL(秒)- 30 分钟 +CONVERSATION_TTL = 1800 + +# 最大知识点数量 +MAX_KNOWLEDGE_POINTS = 50 + +# 知识库最大字符数 +MAX_KNOWLEDGE_BASE_LENGTH = 50000 + +# 默认模型 +DEFAULT_CHAT_MODEL = "gemini-3-flash-preview" + +# 温度参数(对话场景使用较高温度) +DEFAULT_TEMPERATURE = 0.7 + + + + + + + + + diff --git a/backend/app/services/ai/prompts/exam_generator_prompts.py b/backend/app/services/ai/prompts/exam_generator_prompts.py new file mode 100644 index 0000000..e979dfa --- /dev/null +++ b/backend/app/services/ai/prompts/exam_generator_prompts.py @@ -0,0 +1,300 @@ +""" +试题生成器提示词模板 + +功能:根据岗位和知识点动态生成考试题目 +""" + +# ==================== 元数据 ==================== + +PROMPT_META = { + "name": "exam_generator", + "display_name": "试题生成器", + "description": "根据课程知识点和岗位特征,动态生成考试题目(单选、多选、判断、填空、问答)", + "module": "kaopeilian", + "variables": [ + "total_count", + "single_choice_count", + "multiple_choice_count", + "true_false_count", + "fill_blank_count", + "essay_count", + "difficulty_level", + "position_info", + "knowledge_points", + ], + "version": "2.0.0", + "author": "kaopeilian-team", +} + + +# ==================== 系统提示词(第一轮出题) ==================== + +SYSTEM_PROMPT = """## 角色 +你是一位经验丰富的考试出题专家,能够依据用户提供的知识内容,结合用户的岗位特征,随机地生成{total_count}题考题。你会以专业、严谨且清晰的方式出题。 + +## 输出{single_choice_count}道单选题 +1、每道题目只能有 1 个正确答案。 +2、干扰项要具有合理性和迷惑性,且所有选项必须与主题相关。 +3、答案解析要简明扼要,说明选择理由。 +4、为每道题记录出题来源的知识点 id。 +5、请以 JSON 格式输出。 +6、为每道题输出一个序号。 + +### 输出结构: +{{ + "num": "题号", + "type": "single_choice", + "topic": {{ + "title": "清晰完整的题目描述", + "options": {{ + "opt1": "A:符合语境的选项", + "opt2": "B:符合语境的选项", + "opt3": "C:符合语境的选项", + "opt4": "D:符合语境的选项" + }} + }}, + "knowledge_point_id": "出题来源知识点的id", + "correct": "其中一个选项的全部原文", + "analysis": "准确的答案解析,包含选择原因和知识点说明" +}} + +- 严格按照以上格式输出 + +## 输出{multiple_choice_count}道多选题 +1、每道题目有多个正确答案。 +2、"type": "multiple_choice" +3、其它事项同单选题。 + +## 输出{true_false_count}道判断题 +1、每道题目只有 "正确" 或 "错误" 两种答案。 +2、题目表述应明确清晰,避免歧义。 +3、题目应直接陈述事实或观点,便于做出是非判断。 +4、其它事项同单选题。 + +### 输出结构: +{{ + "num": "题号", + "type": "true_false", + "topic": {{ + "title": "清晰完整的题目描述" + }}, + "knowledge_point_id": " 出题来源知识点的id", + "correct": "正确", + "analysis": "准确的答案解析,包含判断原因和知识点说明" +}} + +- 严格按照以上格式输出 + +## 输出{fill_blank_count}道填空题 +1. 题干应明确完整,空缺处需用横线"___"标示,且只能有一处空缺 +2. 答案应唯一且明确,避免开放性表述 +3. 空缺长度应与答案长度大致匹配 +4. 解析需说明答案依据及相关知识点 +5. 其余要求与单选题一致 + +### 输出结构: +{{ + "num": "题号", + "type": "fill_blank", + "topic": {{ + "title": "包含___空缺的题目描述" + }}, + "knowledge_point_id": "出题来源知识点的id", + "correct": "准确的填空答案", + "analysis": "解析答案的依据和相关知识点说明" +}} + +- 严格按照以上格式输出 + +### 输出{essay_count}道问答题 +1. 问题应具体明确,限定回答范围 +2. 答案需条理清晰,突出核心要点 +3. 解析可补充扩展说明或评分要点 +4. 避免过于宽泛或需要主观发挥的问题 +5. 其余要求同单选题 + +### 输出结构: +{{ + "num": "题号", + "type": "essay", + "topic": {{ + "title": "需要详细回答的问题描述" + }}, + "knowledge_point_id": "出题来源知识点的id", + "correct": "完整准确的参考答案(分点或连贯表述)", + "analysis": "对答案的补充说明、评分要点或相关知识点扩展" +}} + +## 特殊要求 +1. 题目难度:{difficulty_level}级(5 级为最难) +2. 避免使用模棱两可的表述 +3. 选项内容要互斥,不能有重叠 +4. 每个选项长度尽量均衡 +5. 正确答案(A、B、C、D)分布要合理,避免规律性 +6. 正确答案必须使用其中一个选项中的全部原文,严禁修改 +7. knowledge_point_id 必须是唯一的,即每道题的知识点来源只允许填一个 id。 + +## 输出格式要求 +请直接输出一个纯净的 JSON 数组(Array),不要包含 Markdown 标记(如 ```json),也不要包含任何解释性文字。 + +请按以上要求生成题目,确保每道题目质量。""" + + +# ==================== 用户提示词模板(第一轮出题) ==================== + +USER_PROMPT = """# 请针对岗位特征、待出题的知识点内容进行出题。 + +## 岗位信息: + +{position_info} + +--- + +## 知识点: + +{knowledge_points}""" + + +# ==================== 错题重出系统提示词 ==================== + +MISTAKE_REGEN_SYSTEM_PROMPT = """## 角色 +你是一位经验丰富的考试出题专家,能够依据用户提供的错题记录,重新为用户出题。你会为每道错题重新出一题,你会以专业、严谨且清晰的方式出题。 + +## 输出单选题 +1、每道题目只能有 1 个正确答案。 +2、干扰项要具有合理性和迷惑性,且所有选项必须与主题相关。 +3、答案解析要简明扼要,说明选择理由。 +4、为每道题记录出题来源的知识点 id。 +5、请以 JSON 格式输出。 +6、为每道题输出一个序号。 + +### 输出结构: +{{ + "num": "题号", + "type": "single_choice", + "topic": {{ + "title": "清晰完整的题目描述", + "options": {{ + "opt1": "A:符合语境的选项", + "opt2": "B:符合语境的选项", + "opt3": "C:符合语境的选项", + "opt4": "D:符合语境的选项" + }} + }}, + "knowledge_point_id": "出题来源知识点的id", + "correct": "其中一个选项的全部原文", + "analysis": "准确的答案解析,包含选择原因和知识点说明" +}} + +- 严格按照以上格式输出 + + +## 特殊要求 +1. 题目难度:{difficulty_level}级(5 级为最难) +2. 避免使用模棱两可的表述 +3. 选项内容要互斥,不能有重叠 +4. 每个选项长度尽量均衡 +5. 正确答案(A、B、C、D)分布要合理,避免规律性 +6. 正确答案必须使用其中一个选项中的全部原文,严禁修改 +7. knowledge_point_id 必须是唯一的,即每道题的知识点来源只允许填一个 id。 + +## 输出格式要求 +请直接输出一个纯净的 JSON 数组(Array),不要包含 Markdown 标记(如 ```json),也不要包含任何解释性文字。 + +请按以上要求生成题目,确保每道题目质量。""" + + +# ==================== 错题重出用户提示词 ==================== + +MISTAKE_REGEN_USER_PROMPT = """## 错题记录: + +{mistake_records}""" + + +# ==================== JSON Schema ==================== + +QUESTION_SCHEMA = { + "type": "array", + "items": { + "type": "object", + "required": ["num", "type", "topic", "correct"], + "properties": { + "num": { + "oneOf": [ + {"type": "integer"}, + {"type": "string"} + ], + "description": "题号" + }, + "type": { + "type": "string", + "enum": ["single_choice", "multiple_choice", "true_false", "fill_blank", "essay"], + "description": "题目类型" + }, + "topic": { + "type": "object", + "required": ["title"], + "properties": { + "title": { + "type": "string", + "description": "题目标题" + }, + "options": { + "type": "object", + "description": "选项(选择题必填)" + } + } + }, + "knowledge_point_id": { + "oneOf": [ + {"type": "integer"}, + {"type": "string"}, + {"type": "null"} + ], + "description": "知识点ID" + }, + "correct": { + "type": "string", + "description": "正确答案" + }, + "analysis": { + "type": "string", + "description": "答案解析" + } + } + }, + "minItems": 1, + "maxItems": 50 +} + + +# ==================== 题目类型常量 ==================== + +QUESTION_TYPES = { + "single_choice": "单选题", + "multiple_choice": "多选题", + "true_false": "判断题", + "fill_blank": "填空题", + "essay": "问答题", +} + +# 默认题目数量配置 +DEFAULT_QUESTION_COUNTS = { + "single_choice_count": 4, + "multiple_choice_count": 2, + "true_false_count": 1, + "fill_blank_count": 2, + "essay_count": 1, +} + +DEFAULT_DIFFICULTY_LEVEL = 3 +MAX_DIFFICULTY_LEVEL = 5 + + + + + + + + + diff --git a/backend/app/services/ai/prompts/knowledge_analysis_prompts.py b/backend/app/services/ai/prompts/knowledge_analysis_prompts.py new file mode 100644 index 0000000..ab918a3 --- /dev/null +++ b/backend/app/services/ai/prompts/knowledge_analysis_prompts.py @@ -0,0 +1,148 @@ +""" +知识点分析提示词模板 + +功能:从课程资料中提取知识点 +""" + +# ==================== 元数据 ==================== + +PROMPT_META = { + "name": "knowledge_analysis", + "display_name": "知识点分析", + "description": "从课程资料中提取和分析知识点,支持PDF/Word/文本等格式", + "module": "kaopeilian", + "variables": ["course_name", "content"], + "version": "2.0.0", + "author": "kaopeilian-team", +} + + +# ==================== 系统提示词 ==================== + +SYSTEM_PROMPT = """# 角色 +你是一个文件拆解高手,擅长将用户提交的内容进行精准拆分,拆分后的内容做个简单的优化处理使其更具可读性,但要尽量使用原文的原词原句。 + +## 技能 +### 技能 1: 内容拆分 +1. 当用户提交内容后,拆分为多段。 +2. 对拆分后的内容做简单优化,使其更具可读性,比如去掉奇怪符号(如换行符、乱码),若语句不通顺,或格式原因导致错位,则重新表达。用户可能会提交录音转文字的内容,因此可能是有错字的,注意修复这些小瑕疵。 +3. 优化过程中,尽量使用原文的原词原句,特别是话术类,必须保持原有的句式、保持原词原句,而不是重构。 +4. 注意是拆分而不是重写,不需要润色,尽量不做任何处理。 +5. 输出到 content。 + +### 技能 2: 为每一个选段概括一个标题 +1. 为每个拆分出来的选段概括一个标题,并输出到 title。 + +### 技能 3: 为每一个选段说明与主题的关联 +1. 详细说明这一段与全文核心主题的关联,并输出到 topic_relation。 + +### 技能 4: 为每一个选段打上一个类型标签 +1. 用户提交的内容很有可能是一个课程、一篇讲义、一个产品的说明书,通常是用户希望他公司的员工或高管学习的知识。 +2. 用户通常是医疗美容机构或轻医美、生活美容连锁品牌。 +3. 你要为每个选段打上一个知识类型的标签,最好是这几个类型中的一个:"理论知识", "诊断设计", "操作步骤", "沟通话术", "案例分析", "注意事项", "技巧方法", "客诉处理"。当然你也可以为这个选段匹配一个更适合的。 + +## 输出要求(严格按要求输出) +请直接输出一个纯净的 JSON 数组(Array),不要包含 Markdown 标记(如 ```json),也不要包含任何解释性文字。格式如下: + +[ + { + "title": "知识点标题", + "content": "知识点内容", + "topic_relation": "知识点与主题的关系", + "type": "知识点类型" + }, + { + "title": "第二个知识点标题", + "content": "第二个知识点内容...", + "topic_relation": "...", + "type": "..." + } +] + +## 限制 +- 仅围绕用户提交的内容进行拆分和关联标注,不涉及其他无关内容。 +- 拆分后的内容必须最大程度保持与原文一致。 +- 关联说明需清晰合理。 +- 不论如何,不要拆分超过 20 段!""" + + +# ==================== 用户提示词模板 ==================== + +USER_PROMPT = """课程主题:{course_name} + +## 用户提交的内容: + +{content} + +## 注意 + +- 以json的格式输出 +- 不论如何,不要拆分超过20 段!""" + + +# ==================== JSON Schema ==================== + +KNOWLEDGE_POINT_SCHEMA = { + "type": "array", + "items": { + "type": "object", + "required": ["title", "content", "type"], + "properties": { + "title": { + "type": "string", + "description": "知识点标题", + "maxLength": 200 + }, + "content": { + "type": "string", + "description": "知识点内容" + }, + "topic_relation": { + "type": "string", + "description": "与主题的关系描述" + }, + "type": { + "type": "string", + "description": "知识点类型", + "enum": [ + "理论知识", + "诊断设计", + "操作步骤", + "沟通话术", + "案例分析", + "注意事项", + "技巧方法", + "客诉处理", + "其他" + ] + } + } + }, + "minItems": 1, + "maxItems": 20 +} + + +# ==================== 知识点类型常量 ==================== + +KNOWLEDGE_POINT_TYPES = [ + "理论知识", + "诊断设计", + "操作步骤", + "沟通话术", + "案例分析", + "注意事项", + "技巧方法", + "客诉处理", +] + +DEFAULT_KNOWLEDGE_TYPE = "理论知识" + + + + + + + + + diff --git a/backend/app/services/ai/prompts/practice_analysis_prompts.py b/backend/app/services/ai/prompts/practice_analysis_prompts.py new file mode 100644 index 0000000..45f1298 --- /dev/null +++ b/backend/app/services/ai/prompts/practice_analysis_prompts.py @@ -0,0 +1,193 @@ +""" +陪练分析报告提示词模板 + +功能:分析陪练对话,生成综合评分和改进建议 +""" + +# ==================== 元数据 ==================== + +PROMPT_META = { + "name": "practice_analysis", + "display_name": "陪练分析报告", + "description": "分析陪练对话,生成综合评分、能力维度评估、对话标注和改进建议", + "module": "kaopeilian", + "variables": ["dialogue_history"], + "version": "1.0.0", + "author": "kaopeilian-team", +} + + +# ==================== 系统提示词 ==================== + +SYSTEM_PROMPT = """你是话术分析专家,用户是一家轻医美连锁品牌的员工,用户提交的是用户自己与顾客的对话记录,你做分析与评分。并严格按照以下格式输出。 + +输出标准: +{ + "analysis": { + "total_score": 88, + "score_breakdown": [ + {"name": "开场技巧", "score": 92, "description": "开场自然,快速建立信任"}, + {"name": "需求挖掘", "score": 90, "description": "能够有效识别客户需求"}, + {"name": "产品介绍", "score": 88, "description": "产品介绍清晰,重点突出"}, + {"name": "异议处理", "score": 85, "description": "处理客户异议还需加强"}, + {"name": "成交技巧", "score": 86, "description": "成交话术运用良好"} + ], + "ability_dimensions": [ + {"name": "沟通表达", "score": 90, "feedback": "语言流畅,表达清晰,语调富有亲和力"}, + {"name": "倾听理解", "score": 92, "feedback": "能够准确理解客户意图,给予恰当回应"}, + {"name": "情绪控制", "score": 88, "feedback": "整体情绪稳定,面对异议时保持专业"}, + {"name": "专业知识", "score": 93, "feedback": "对医美项目知识掌握扎实"}, + {"name": "销售技巧", "score": 87, "feedback": "销售流程把控良好"}, + {"name": "应变能力", "score": 85, "feedback": "面对突发问题能够快速反应"} + ], + "dialogue_annotations": [ + {"sequence": 1, "tags": ["亮点话术"], "comment": "开场专业,身份介绍清晰"}, + {"sequence": 3, "tags": ["金牌话术"], "comment": "巧妙引导,从客户角度出发"}, + {"sequence": 5, "tags": ["亮点话术"], "comment": "类比生动,让客户容易理解"}, + {"sequence": 7, "tags": ["金牌话术"], "comment": "专业解答,打消客户疑虑"} + ], + "suggestions": [ + {"title": "控制语速", "content": "您的语速偏快,建议适当放慢,给客户更多思考时间", "example": "说完产品优势后,停顿2-3秒,观察客户反应"}, + {"title": "多用开放式问题", "content": "增加开放式问题的使用,更深入了解客户需求", "example": "您对未来的保障有什么期望?而不是您需要保险吗?"}, + {"title": "强化成交信号识别", "content": "客户已经表现出兴趣时,要及时推进成交", "example": "当客户问费用多少时,这是购买信号,应该立即报价并促成"} + ] + } +} + +## 输出要求(严格执行) +1. 直接输出纯净的 JSON,不要包含 Markdown 标记(如 ```json) +2. 不要包含任何解释性文字 +3. score_breakdown 必须包含 5 项:开场技巧、需求挖掘、产品介绍、异议处理、成交技巧 +4. ability_dimensions 必须包含 6 项:沟通表达、倾听理解、情绪控制、专业知识、销售技巧、应变能力 +5. dialogue_annotations 标注有亮点或问题的对话轮次,tags 可选:亮点话术、金牌话术、待改进、问题话术 +6. suggestions 提供 2-4 条具体可操作的改进建议,每条包含 title、content、example + +## 评分标准 +- 90-100:优秀 +- 80-89:良好 +- 70-79:一般 +- 60-69:需改进 +- <60:亟需提升""" + + +# ==================== 用户提示词模板 ==================== + +USER_PROMPT = """{dialogue_history}""" + + +# ==================== JSON Schema ==================== + +PRACTICE_ANALYSIS_SCHEMA = { + "type": "object", + "required": ["analysis"], + "properties": { + "analysis": { + "type": "object", + "required": ["total_score", "score_breakdown", "ability_dimensions", "dialogue_annotations", "suggestions"], + "properties": { + "total_score": { + "type": "number", + "description": "总体评分(0-100)", + "minimum": 0, + "maximum": 100 + }, + "score_breakdown": { + "type": "array", + "description": "分数细分(5项)", + "items": { + "type": "object", + "required": ["name", "score", "description"], + "properties": { + "name": {"type": "string", "description": "维度名称"}, + "score": {"type": "number", "description": "得分(0-100)"}, + "description": {"type": "string", "description": "评价描述"} + } + }, + "minItems": 5 + }, + "ability_dimensions": { + "type": "array", + "description": "能力维度评分(6项)", + "items": { + "type": "object", + "required": ["name", "score", "feedback"], + "properties": { + "name": {"type": "string", "description": "能力维度名称"}, + "score": {"type": "number", "description": "得分(0-100)"}, + "feedback": {"type": "string", "description": "反馈评语"} + } + }, + "minItems": 6 + }, + "dialogue_annotations": { + "type": "array", + "description": "对话标注", + "items": { + "type": "object", + "required": ["sequence", "tags", "comment"], + "properties": { + "sequence": {"type": "integer", "description": "对话轮次序号"}, + "tags": { + "type": "array", + "description": "标签列表", + "items": {"type": "string"} + }, + "comment": {"type": "string", "description": "点评内容"} + } + } + }, + "suggestions": { + "type": "array", + "description": "改进建议", + "items": { + "type": "object", + "required": ["title", "content", "example"], + "properties": { + "title": {"type": "string", "description": "建议标题"}, + "content": {"type": "string", "description": "建议内容"}, + "example": {"type": "string", "description": "示例"} + } + }, + "minItems": 2, + "maxItems": 5 + } + } + } + } +} + + +# ==================== 常量定义 ==================== + +SCORE_BREAKDOWN_ITEMS = [ + "开场技巧", + "需求挖掘", + "产品介绍", + "异议处理", + "成交技巧", +] + +ABILITY_DIMENSIONS = [ + "沟通表达", + "倾听理解", + "情绪控制", + "专业知识", + "销售技巧", + "应变能力", +] + +ANNOTATION_TAGS = [ + "亮点话术", + "金牌话术", + "待改进", + "问题话术", +] + + + + + + + + + diff --git a/backend/app/services/ai/prompts/practice_scene_prompts.py b/backend/app/services/ai/prompts/practice_scene_prompts.py new file mode 100644 index 0000000..df391cd --- /dev/null +++ b/backend/app/services/ai/prompts/practice_scene_prompts.py @@ -0,0 +1,192 @@ +""" +陪练场景生成提示词模板 + +功能:根据课程知识点生成陪练场景配置 +""" + +# ==================== 元数据 ==================== + +PROMPT_META = { + "name": "practice_scene_generation", + "display_name": "陪练场景生成", + "description": "根据课程知识点生成 AI 陪练场景配置,包含场景名称、背景、AI 角色、练习目标等", + "module": "kaopeilian", + "variables": ["knowledge_points"], + "version": "1.0.0", + "author": "kaopeilian-team", +} + + +# ==================== 系统提示词 ==================== + +SYSTEM_PROMPT = """你是一个训练场景研究专家,能将用户提交的知识点,转变为一个模拟陪练的场景,并严格按照以下格式输出。 + +输出标准: + +{ +"scene": { +"name": "轻医美产品咨询陪练", +"description": "模拟客户咨询轻医美产品的场景", +"background": "客户对脸部抗衰项目感兴趣。", +"ai_role": "AI扮演一位30岁女性客户", +"objectives": ["了解客户需求", "介绍产品优势", "处理价格异议"], +"keywords": ["抗衰", "玻尿酸", "价格"], +"type": "product-intro", +"difficulty": "intermediate" +} +} + +## 字段说明 + +- **name**: 场景名称,简洁明了,体现陪练主题 +- **description**: 场景描述,说明这是什么样的模拟场景 +- **background**: 场景背景设定,描述客户的情况和需求 +- **ai_role**: AI 角色描述,说明 AI 扮演什么角色(通常是客户) +- **objectives**: 练习目标数组,列出学员需要达成的目标 +- **keywords**: 关键词数组,从知识点中提取的核心关键词 +- **type**: 场景类型,可选值: + - phone: 电话销售 + - face: 面对面销售 + - complaint: 客户投诉 + - after-sales: 售后服务 + - product-intro: 产品介绍 +- **difficulty**: 难度等级,可选值: + - beginner: 入门 + - junior: 初级 + - intermediate: 中级 + - senior: 高级 + - expert: 专家 + +## 输出要求 + +1. 直接输出纯净的 JSON 对象,不要包含 Markdown 标记(如 ```json) +2. 不要包含任何解释性文字 +3. 根据知识点内容合理设计场景,确保场景与知识点紧密相关 +4. objectives 至少包含 2-3 个具体可操作的目标 +5. keywords 提取 3-5 个核心关键词 +6. 根据知识点的复杂程度选择合适的 difficulty +7. 根据知识点的应用场景选择合适的 type""" + + +# ==================== 用户提示词模板 ==================== + +USER_PROMPT = """请根据以下知识点内容,生成一个模拟陪练场景: + +## 知识点内容 + +{knowledge_points} + +## 要求 + +- 以 JSON 格式输出 +- 场景要贴合知识点的实际应用场景 +- AI 角色要符合轻医美行业的客户特征 +- 练习目标要具体、可评估""" + + +# ==================== JSON Schema ==================== + +PRACTICE_SCENE_SCHEMA = { + "type": "object", + "required": ["scene"], + "properties": { + "scene": { + "type": "object", + "required": ["name", "description", "background", "ai_role", "objectives", "keywords", "type", "difficulty"], + "properties": { + "name": { + "type": "string", + "description": "场景名称", + "maxLength": 100 + }, + "description": { + "type": "string", + "description": "场景描述", + "maxLength": 500 + }, + "background": { + "type": "string", + "description": "场景背景设定", + "maxLength": 500 + }, + "ai_role": { + "type": "string", + "description": "AI 角色描述", + "maxLength": 200 + }, + "objectives": { + "type": "array", + "description": "练习目标", + "items": { + "type": "string" + }, + "minItems": 2, + "maxItems": 5 + }, + "keywords": { + "type": "array", + "description": "关键词", + "items": { + "type": "string" + }, + "minItems": 2, + "maxItems": 8 + }, + "type": { + "type": "string", + "description": "场景类型", + "enum": [ + "phone", + "face", + "complaint", + "after-sales", + "product-intro" + ] + }, + "difficulty": { + "type": "string", + "description": "难度等级", + "enum": [ + "beginner", + "junior", + "intermediate", + "senior", + "expert" + ] + } + } + } + } +} + + +# ==================== 场景类型常量 ==================== + +SCENE_TYPES = { + "phone": "电话销售", + "face": "面对面销售", + "complaint": "客户投诉", + "after-sales": "售后服务", + "product-intro": "产品介绍", +} + +DIFFICULTY_LEVELS = { + "beginner": "入门", + "junior": "初级", + "intermediate": "中级", + "senior": "高级", + "expert": "专家", +} + +# 默认值 +DEFAULT_SCENE_TYPE = "product-intro" +DEFAULT_DIFFICULTY = "intermediate" + + + + + + + + + diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..1199df3 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,141 @@ +""" +认证服务 +""" +from datetime import datetime, timedelta +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import UnauthorizedError +from app.core.logger import logger +from app.core.security import create_access_token, create_refresh_token, decode_token +from app.models.user import User +from app.schemas.auth import Token +from app.services.user_service import UserService + + +class AuthService: + """认证服务""" + + def __init__(self, db: AsyncSession): + self.db = db + self.user_service = UserService(db) + + async def login(self, username: str, password: str) -> tuple[User, Token]: + """ + 用户登录 + + Args: + username: 用户名/邮箱/手机号 + password: 密码 + + Returns: + 用户对象和令牌 + """ + # 验证用户 + user = await self.user_service.authenticate( + username=username, password=password + ) + + if not user: + logger.warning( + "登录失败:用户名或密码错误", + username=username, + ) + raise UnauthorizedError("用户名或密码错误") + + if not user.is_active: + logger.warning( + "登录失败:用户已被禁用", + user_id=user.id, + username=user.username, + ) + raise UnauthorizedError("用户已被禁用") + + # 生成令牌 + access_token = create_access_token(subject=user.id) + refresh_token = create_refresh_token(subject=user.id) + + # 更新最后登录时间 + await self.user_service.update_last_login(user.id) + + # 记录日志 + logger.info( + "用户登录成功", + user_id=user.id, + username=user.username, + role=user.role, + ) + + return user, Token( + access_token=access_token, + refresh_token=refresh_token, + ) + + async def refresh_token(self, refresh_token: str) -> Token: + """ + 刷新访问令牌 + + Args: + refresh_token: 刷新令牌 + + Returns: + 新的令牌 + """ + try: + # 解码刷新令牌 + payload = decode_token(refresh_token) + + # 验证令牌类型 + if payload.get("type") != "refresh": + raise UnauthorizedError("无效的刷新令牌") + + # 获取用户ID + user_id = int(payload.get("sub")) + + # 获取用户 + user = await self.user_service.get_by_id(user_id) + if not user: + raise UnauthorizedError("用户不存在") + + if not user.is_active: + raise UnauthorizedError("用户已被禁用") + + # 生成新的访问令牌 + access_token = create_access_token(subject=user.id) + + logger.info( + "令牌刷新成功", + user_id=user.id, + username=user.username, + ) + + return Token( + access_token=access_token, + refresh_token=refresh_token, # 保持原刷新令牌 + ) + + except Exception as e: + logger.error( + "令牌刷新失败", + error=str(e), + ) + raise UnauthorizedError("无效的刷新令牌") + + async def logout(self, user_id: int) -> None: + """ + 用户登出 + + 注意:JWT是无状态的,实际的登出需要在客户端删除令牌 + 这里只是记录日志,如果需要可以将令牌加入黑名单 + + Args: + user_id: 用户ID + """ + user = await self.user_service.get_by_id(user_id) + if user: + logger.info( + "用户登出", + user_id=user.id, + username=user.username, + ) diff --git a/backend/app/services/base_service.py b/backend/app/services/base_service.py new file mode 100644 index 0000000..dfb20af --- /dev/null +++ b/backend/app/services/base_service.py @@ -0,0 +1,112 @@ +"""基础服务类""" +from typing import TypeVar, Generic, Type, Optional, List, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from sqlalchemy.sql import Select + +from app.models.base import BaseModel + +ModelType = TypeVar("ModelType", bound=BaseModel) + + +class BaseService(Generic[ModelType]): + """ + 基础服务类,提供通用的CRUD操作 + """ + + def __init__(self, model: Type[ModelType]): + self.model = model + + async def get(self, db: AsyncSession, id: int) -> Optional[ModelType]: + """根据ID获取单个对象""" + result = await db.execute(select(self.model).where(self.model.id == id)) + return result.scalar_one_or_none() + + async def get_by_id(self, db: AsyncSession, id: int) -> Optional[ModelType]: + """别名:按ID获取对象(兼容旧代码)""" + return await self.get(db, id) + + async def get_multi( + self, + db: AsyncSession, + *, + skip: int = 0, + limit: int = 100, + query: Optional[Select] = None, + ) -> List[ModelType]: + """获取多个对象""" + if query is None: + query = select(self.model) + + result = await db.execute(query.offset(skip).limit(limit)) + return result.scalars().all() + + async def count(self, db: AsyncSession, *, query: Optional[Select] = None) -> int: + """统计数量""" + if query is None: + query = select(func.count()).select_from(self.model) + else: + query = select(func.count()).select_from(query.subquery()) + + result = await db.execute(query) + return result.scalar_one() + + async def create(self, db: AsyncSession, *, obj_in: Any, **kwargs) -> ModelType: + """创建对象""" + if hasattr(obj_in, "model_dump"): + create_data = obj_in.model_dump() + else: + create_data = obj_in + + # 合并额外参数 + create_data.update(kwargs) + + db_obj = self.model(**create_data) + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj + + async def update( + self, db: AsyncSession, *, db_obj: ModelType, obj_in: Any, **kwargs + ) -> ModelType: + """更新对象""" + if hasattr(obj_in, "model_dump"): + update_data = obj_in.model_dump(exclude_unset=True) + else: + update_data = obj_in + + # 合并额外参数(如 updated_by 等审计字段) + if kwargs: + update_data.update(kwargs) + + for field, value in update_data.items(): + setattr(db_obj, field, value) + + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj + + async def delete(self, db: AsyncSession, *, id: int) -> bool: + """删除对象""" + obj = await self.get(db, id) + if obj: + await db.delete(obj) + await db.commit() + return True + return False + + async def soft_delete(self, db: AsyncSession, *, id: int) -> bool: + """软删除对象""" + from datetime import datetime + + obj = await self.get(db, id) + if obj and hasattr(obj, "is_deleted"): + obj.is_deleted = True + if hasattr(obj, "deleted_at"): + obj.deleted_at = datetime.now() + db.add(obj) + await db.commit() + return True + return False diff --git a/backend/app/services/course_exam_service.py b/backend/app/services/course_exam_service.py new file mode 100644 index 0000000..502cc38 --- /dev/null +++ b/backend/app/services/course_exam_service.py @@ -0,0 +1,137 @@ +""" +课程考试设置服务 +""" +from typing import Optional +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logger import get_logger +from app.models.course_exam_settings import CourseExamSettings +from app.schemas.course import CourseExamSettingsCreate, CourseExamSettingsUpdate +from app.services.base_service import BaseService + +logger = get_logger(__name__) + + +class CourseExamService(BaseService[CourseExamSettings]): + """课程考试设置服务""" + + def __init__(self): + super().__init__(CourseExamSettings) + + async def get_by_course_id(self, db: AsyncSession, course_id: int) -> Optional[CourseExamSettings]: + """ + 根据课程ID获取考试设置 + + Args: + db: 数据库会话 + course_id: 课程ID + + Returns: + 考试设置实例或None + """ + stmt = select(CourseExamSettings).where( + CourseExamSettings.course_id == course_id, + CourseExamSettings.is_deleted == False + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def create_or_update( + self, + db: AsyncSession, + course_id: int, + settings_in: CourseExamSettingsCreate, + user_id: int + ) -> CourseExamSettings: + """ + 创建或更新课程考试设置 + + Args: + db: 数据库会话 + course_id: 课程ID + settings_in: 考试设置数据 + user_id: 操作用户ID + + Returns: + 考试设置实例 + """ + # 检查是否已存在设置 + existing_settings = await self.get_by_course_id(db, course_id) + + if existing_settings: + # 更新现有设置 + update_data = settings_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(existing_settings, field, value) + existing_settings.updated_by = user_id + + await db.commit() + await db.refresh(existing_settings) + + logger.info(f"更新课程考试设置成功", course_id=course_id, user_id=user_id) + return existing_settings + else: + # 创建新设置 + create_data = settings_in.model_dump() + create_data.update({ + "course_id": course_id, + "created_by": user_id, + "updated_by": user_id + }) + + new_settings = CourseExamSettings(**create_data) + db.add(new_settings) + + await db.commit() + await db.refresh(new_settings) + + logger.info(f"创建课程考试设置成功", course_id=course_id, user_id=user_id) + return new_settings + + async def update( + self, + db: AsyncSession, + course_id: int, + settings_in: CourseExamSettingsUpdate, + user_id: int + ) -> CourseExamSettings: + """ + 更新课程考试设置 + + Args: + db: 数据库会话 + course_id: 课程ID + settings_in: 更新的考试设置数据 + user_id: 操作用户ID + + Returns: + 更新后的考试设置实例 + """ + # 获取现有设置 + settings = await self.get_by_course_id(db, course_id) + if not settings: + # 如果不存在,创建新的 + create_data = settings_in.model_dump(exclude_unset=True) + return await self.create_or_update( + db, + course_id=course_id, + settings_in=CourseExamSettingsCreate(**create_data), + user_id=user_id + ) + + # 更新设置 + update_data = settings_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(settings, field, value) + settings.updated_by = user_id + + await db.commit() + await db.refresh(settings) + + logger.info(f"更新课程考试设置成功", course_id=course_id, user_id=user_id) + return settings + + +# 创建服务实例 +course_exam_service = CourseExamService() diff --git a/backend/app/services/course_position_service.py b/backend/app/services/course_position_service.py new file mode 100644 index 0000000..4b9531e --- /dev/null +++ b/backend/app/services/course_position_service.py @@ -0,0 +1,194 @@ +""" +课程岗位分配服务 +""" +from typing import List, Optional +from sqlalchemy import select, and_, delete, func +from sqlalchemy.orm import selectinload +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logger import get_logger +from app.models.position_course import PositionCourse +from app.models.position import Position +from app.models.position_member import PositionMember +from app.schemas.course import CoursePositionAssignment, CoursePositionAssignmentInDB +from app.services.base_service import BaseService + +logger = get_logger(__name__) + + +class CoursePositionService(BaseService[PositionCourse]): + """课程岗位分配服务""" + + def __init__(self): + super().__init__(PositionCourse) + + async def get_course_positions( + self, + db: AsyncSession, + course_id: int, + course_type: Optional[str] = None + ) -> List[CoursePositionAssignmentInDB]: + """ + 获取课程的岗位分配列表 + + Args: + db: 数据库会话 + course_id: 课程ID + course_type: 课程类型筛选 + + Returns: + 岗位分配列表 + """ + # 构建查询 + conditions = [ + PositionCourse.course_id == course_id, + PositionCourse.is_deleted == False + ] + + if course_type: + conditions.append(PositionCourse.course_type == course_type) + + stmt = ( + select(PositionCourse) + .options(selectinload(PositionCourse.position)) + .where(and_(*conditions)) + .order_by(PositionCourse.priority, PositionCourse.id) + ) + + result = await db.execute(stmt) + assignments = result.scalars().all() + + # 转换为返回格式,并查询每个岗位的成员数量 + result_list = [] + for assignment in assignments: + # 查询岗位成员数量 + member_count = 0 + if assignment.position_id: + member_count_result = await db.execute( + select(func.count(PositionMember.id)).where( + and_( + PositionMember.position_id == assignment.position_id, + PositionMember.is_deleted == False + ) + ) + ) + member_count = member_count_result.scalar() or 0 + + result_list.append( + CoursePositionAssignmentInDB( + id=assignment.id, + course_id=assignment.course_id, + position_id=assignment.position_id, + course_type=assignment.course_type, + priority=assignment.priority, + position_name=assignment.position.name if assignment.position else None, + position_description=assignment.position.description if assignment.position else None, + member_count=member_count + ) + ) + + return result_list + + async def batch_assign_positions( + self, + db: AsyncSession, + course_id: int, + assignments: List[CoursePositionAssignment], + user_id: int + ) -> List[CoursePositionAssignmentInDB]: + """ + 批量分配课程到岗位 + + Args: + db: 数据库会话 + course_id: 课程ID + assignments: 岗位分配列表 + user_id: 操作用户ID + + Returns: + 分配结果列表 + """ + created_assignments = [] + + for assignment in assignments: + # 检查是否已存在(注意:Result 只能消费一次,需保存结果) + result = await db.execute( + select(PositionCourse).where( + PositionCourse.course_id == course_id, + PositionCourse.position_id == assignment.position_id, + PositionCourse.is_deleted == False, + ) + ) + existing_assignment = result.scalar_one_or_none() + + if existing_assignment: + # 已存在则更新类型与优先级 + existing_assignment.course_type = assignment.course_type + existing_assignment.priority = assignment.priority + # PositionCourse 未继承 AuditMixin,不强制写入审计字段 + created_assignments.append(existing_assignment) + else: + # 新建分配关系 + new_assignment = PositionCourse( + course_id=course_id, + position_id=assignment.position_id, + course_type=assignment.course_type, + priority=assignment.priority, + ) + db.add(new_assignment) + created_assignments.append(new_assignment) + + await db.commit() + + # 重新加载关联数据 + for obj in created_assignments: + await db.refresh(obj) + + logger.info("批量分配课程到岗位成功", course_id=course_id, count=len(assignments), user_id=user_id) + + # 返回分配结果 + return await self.get_course_positions(db, course_id) + + async def remove_position_assignment( + self, + db: AsyncSession, + course_id: int, + position_id: int, + user_id: int + ) -> bool: + """ + 移除课程的岗位分配 + + Args: + db: 数据库会话 + course_id: 课程ID + position_id: 岗位ID + user_id: 操作用户ID + + Returns: + 是否成功 + """ + # 查找分配记录 + stmt = select(PositionCourse).where( + PositionCourse.course_id == course_id, + PositionCourse.position_id == position_id, + PositionCourse.is_deleted == False + ) + + result = await db.execute(stmt) + assignment = result.scalar_one_or_none() + + if assignment: + # 软删除 + assignment.is_deleted = True + assignment.deleted_by = user_id + await db.commit() + + logger.info(f"移除课程岗位分配成功", course_id=course_id, position_id=position_id, user_id=user_id) + return True + + return False + + +# 创建服务实例 +course_position_service = CoursePositionService() diff --git a/backend/app/services/course_service.py b/backend/app/services/course_service.py new file mode 100644 index 0000000..b0a825a --- /dev/null +++ b/backend/app/services/course_service.py @@ -0,0 +1,837 @@ +""" +课程服务层 +""" +from typing import Optional, List, Dict, Any +from datetime import datetime + +from sqlalchemy import select, or_, and_, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.logger import get_logger +from app.core.exceptions import NotFoundError, BadRequestError, ConflictError +from app.models.course import ( + Course, + CourseStatus, + CourseMaterial, + KnowledgePoint, + GrowthPath, +) +from app.models.course_exam_settings import CourseExamSettings +from app.models.position_member import PositionMember +from app.models.position_course import PositionCourse +from app.schemas.course import ( + CourseCreate, + CourseUpdate, + CourseList, + CourseInDB, + CourseMaterialCreate, + KnowledgePointCreate, + KnowledgePointUpdate, + KnowledgePointInDB, + GrowthPathCreate, +) +from app.schemas.base import PaginationParams, PaginatedResponse +from app.services.base_service import BaseService + +logger = get_logger(__name__) + + +class CourseService(BaseService[Course]): + """ + 课程服务类 + """ + + def __init__(self): + super().__init__(Course) + + async def get_course_list( + self, + db: AsyncSession, + *, + page_params: PaginationParams, + filters: CourseList, + user_id: Optional[int] = None, + ) -> PaginatedResponse[CourseInDB]: + """ + 获取课程列表(支持筛选) + + Args: + db: 数据库会话 + page_params: 分页参数 + filters: 筛选条件 + user_id: 用户ID(用于记录访问日志) + + Returns: + 分页的课程列表 + """ + # 构建筛选条件 + filter_conditions = [] + + # 状态筛选(默认只显示已发布的课程) + if filters.status is not None: + filter_conditions.append(Course.status == filters.status) + else: + # 如果没有指定状态,默认只返回已发布的课程 + filter_conditions.append(Course.status == CourseStatus.PUBLISHED) + + # 分类筛选 + if filters.category is not None: + filter_conditions.append(Course.category == filters.category) + + # 是否推荐筛选 + if filters.is_featured is not None: + filter_conditions.append(Course.is_featured == filters.is_featured) + + # 关键词搜索 + if filters.keyword: + keyword = f"%{filters.keyword}%" + filter_conditions.append( + or_(Course.name.like(keyword), Course.description.like(keyword)) + ) + + # 记录查询日志 + logger.info( + "查询课程列表", + user_id=user_id, + filters=filters.model_dump(exclude_none=True), + page=page_params.page, + size=page_params.page_size, + ) + + # 执行分页查询 + query = select(Course).where(Course.is_deleted == False) + + # 添加筛选条件 + if filter_conditions: + query = query.where(and_(*filter_conditions)) + + # 添加排序:优先按sort_order升序,其次按创建时间降序(新课程优先) + query = query.order_by(Course.sort_order.asc(), Course.created_at.desc()) + + # 获取总数 + count_query = ( + select(func.count()).select_from(Course).where(Course.is_deleted == False) + ) + if filter_conditions: + count_query = count_query.where(and_(*filter_conditions)) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + # 分页 + query = query.offset(page_params.offset).limit(page_params.limit) + query = query.options(selectinload(Course.materials)) + + # 执行查询 + result = await db.execute(query) + courses = result.scalars().all() + + # 获取用户所属的岗位ID列表 + user_position_ids = [] + if user_id: + position_result = await db.execute( + select(PositionMember.position_id).where( + PositionMember.user_id == user_id, + PositionMember.is_deleted == False + ) + ) + user_position_ids = [row[0] for row in position_result.fetchall()] + + # 批量查询课程的岗位分配信息 + course_ids = [c.id for c in courses] + course_type_map = {} + if course_ids and user_position_ids: + position_course_result = await db.execute( + select(PositionCourse.course_id, PositionCourse.course_type).where( + PositionCourse.course_id.in_(course_ids), + PositionCourse.position_id.in_(user_position_ids), + PositionCourse.is_deleted == False + ) + ) + # 构建课程类型映射:如果有多个岗位,优先取required + for course_id, course_type in position_course_result.fetchall(): + if course_id not in course_type_map: + course_type_map[course_id] = course_type + elif course_type == 'required': + course_type_map[course_id] = 'required' + + # 转换为 Pydantic 模型,并附加课程类型 + course_list = [] + for course in courses: + course_data = CourseInDB.model_validate(course) + # 设置课程类型:如果用户有岗位分配则使用分配类型,否则为None + course_data.course_type = course_type_map.get(course.id) + course_list.append(course_data) + + # 计算总页数 + pages = (total + page_params.page_size - 1) // page_params.page_size + + return PaginatedResponse( + items=course_list, + total=total, + page=page_params.page, + page_size=page_params.page_size, + pages=pages, + ) + + async def create_course( + self, db: AsyncSession, *, course_in: CourseCreate, created_by: int + ) -> Course: + """ + 创建课程 + + Args: + db: 数据库会话 + course_in: 课程创建数据 + created_by: 创建人ID + + Returns: + 创建的课程 + """ + # 检查名称是否重复 + existing = await db.execute( + select(Course).where( + and_(Course.name == course_in.name, Course.is_deleted == False) + ) + ) + if existing.scalar_one_or_none(): + raise ConflictError(f"课程名称 '{course_in.name}' 已存在") + + # 创建课程 + course_data = course_in.model_dump() + course = await self.create(db, obj_in=course_data, created_by=created_by) + + # 自动创建默认考试设置 + default_exam_settings = CourseExamSettings( + course_id=course.id, + created_by=created_by, + updated_by=created_by + # 其他字段使用模型定义的默认值: + # single_choice_count=4, multiple_choice_count=2, true_false_count=1, + # fill_blank_count=2, essay_count=1, duration_minutes=10, 等 + ) + db.add(default_exam_settings) + await db.commit() + await db.refresh(course) + + logger.info( + "创建课程", course_id=course.id, course_name=course.name, created_by=created_by + ) + logger.info( + "自动创建默认考试设置", course_id=course.id, exam_settings_id=default_exam_settings.id + ) + + return course + + async def update_course( + self, + db: AsyncSession, + *, + course_id: int, + course_in: CourseUpdate, + updated_by: int, + ) -> Course: + """ + 更新课程 + + Args: + db: 数据库会话 + course_id: 课程ID + course_in: 课程更新数据 + updated_by: 更新人ID + + Returns: + 更新后的课程 + """ + # 获取课程 + course = await self.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 检查名称是否重复(如果修改了名称) + if course_in.name and course_in.name != course.name: + existing = await db.execute( + select(Course).where( + and_( + Course.name == course_in.name, + Course.id != course_id, + Course.is_deleted == False, + ) + ) + ) + if existing.scalar_one_or_none(): + raise ConflictError(f"课程名称 '{course_in.name}' 已存在") + + # 记录状态变更 + old_status = course.status + + # 更新课程 + update_data = course_in.model_dump(exclude_unset=True) + + # 如果状态变为已发布,记录发布时间 + if ( + update_data.get("status") == CourseStatus.PUBLISHED + and old_status != CourseStatus.PUBLISHED + ): + update_data["published_at"] = datetime.now() + update_data["publisher_id"] = updated_by + + course = await self.update( + db, db_obj=course, obj_in=update_data, updated_by=updated_by + ) + + logger.info( + "更新课程", + course_id=course.id, + course_name=course.name, + old_status=old_status, + new_status=course.status, + updated_by=updated_by, + ) + + return course + + async def delete_course( + self, db: AsyncSession, *, course_id: int, deleted_by: int + ) -> bool: + """ + 删除课程(软删除 + 删除相关文件) + + Args: + db: 数据库会话 + course_id: 课程ID + deleted_by: 删除人ID + + Returns: + 是否删除成功 + """ + import shutil + from pathlib import Path + from app.core.config import settings + + course = await self.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 放开删除限制:任意状态均可软删除,由业务方自行控制 + + # 执行软删除(标记 is_deleted,记录删除时间),由审计日志记录操作者 + success = await self.soft_delete(db, id=course_id) + + if success: + # 删除课程文件夹及其所有内容 + course_folder = Path(settings.UPLOAD_PATH) / "courses" / str(course_id) + if course_folder.exists() and course_folder.is_dir(): + try: + shutil.rmtree(course_folder) + logger.info( + "删除课程文件夹成功", + course_id=course_id, + folder_path=str(course_folder), + ) + except Exception as e: + # 文件夹删除失败不影响业务流程,仅记录日志 + logger.error( + "删除课程文件夹失败", + course_id=course_id, + folder_path=str(course_folder), + error=str(e), + ) + + logger.warning( + "删除课程", + course_id=course_id, + course_name=course.name, + deleted_by=deleted_by, + folder_deleted=course_folder.exists(), + ) + + return success + + async def add_course_material( + self, + db: AsyncSession, + *, + course_id: int, + material_in: CourseMaterialCreate, + created_by: int, + ) -> CourseMaterial: + """ + 添加课程资料 + + Args: + db: 数据库会话 + course_id: 课程ID + material_in: 资料创建数据 + created_by: 创建人ID + + Returns: + 创建的课程资料 + """ + # 检查课程是否存在 + course = await self.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 创建资料 + material_data = material_in.model_dump() + material_data.update({ + "course_id": course_id, + "created_by": created_by, + "updated_by": created_by + }) + + material = CourseMaterial(**material_data) + db.add(material) + await db.commit() + await db.refresh(material) + + logger.info( + "添加课程资料", + course_id=course_id, + material_id=material.id, + material_name=material.name, + file_type=material.file_type, + file_size=material.file_size, + created_by=created_by, + ) + + return material + + async def get_course_materials( + self, + db: AsyncSession, + *, + course_id: int, + ) -> List[CourseMaterial]: + """ + 获取课程资料列表 + + Args: + db: 数据库会话 + course_id: 课程ID + + Returns: + 课程资料列表 + """ + # 确认课程存在 + course = await self.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + stmt = ( + select(CourseMaterial) + .where( + CourseMaterial.course_id == course_id, + CourseMaterial.is_deleted == False, + ) + .order_by(CourseMaterial.sort_order.asc(), CourseMaterial.id.asc()) + ) + result = await db.execute(stmt) + materials = result.scalars().all() + + logger.info( + "查询课程资料列表", course_id=course_id, count=len(materials) + ) + + return materials + + async def delete_course_material( + self, + db: AsyncSession, + *, + course_id: int, + material_id: int, + deleted_by: int, + ) -> bool: + """ + 删除课程资料(软删除 + 删除物理文件) + + Args: + db: 数据库会话 + course_id: 课程ID + material_id: 资料ID + deleted_by: 删除人ID + + Returns: + 是否删除成功 + """ + import os + from pathlib import Path + from app.core.config import settings + + # 先确认课程存在 + course = await self.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 查找资料并校验归属 + material_stmt = select(CourseMaterial).where( + CourseMaterial.id == material_id, + CourseMaterial.course_id == course_id, + CourseMaterial.is_deleted == False, + ) + result = await db.execute(material_stmt) + material = result.scalar_one_or_none() + if not material: + raise NotFoundError(f"课程资料ID {material_id} 不存在或已删除") + + # 获取文件路径信息用于删除物理文件 + file_url = material.file_url + + # 软删除数据库记录 + material.is_deleted = True + material.deleted_at = datetime.now() + if hasattr(material, "deleted_by"): + # 兼容存在该字段的表 + setattr(material, "deleted_by", deleted_by) + + db.add(material) + await db.commit() + + # 删除物理文件 + if file_url and file_url.startswith("/static/uploads/"): + try: + # 从URL中提取相对路径 + relative_path = file_url.replace("/static/uploads/", "") + file_path = Path(settings.UPLOAD_PATH) / relative_path + + # 检查文件是否存在并删除 + if file_path.exists() and file_path.is_file(): + os.remove(file_path) + logger.info( + "删除物理文件成功", + file_path=str(file_path), + material_id=material_id, + ) + except Exception as e: + # 物理文件删除失败不影响业务流程,仅记录日志 + logger.error( + "删除物理文件失败", + file_url=file_url, + material_id=material_id, + error=str(e), + ) + + logger.warning( + "删除课程资料", + course_id=course_id, + material_id=material_id, + deleted_by=deleted_by, + file_deleted=file_url is not None, + ) + return True + + async def get_material_knowledge_points( + self, db: AsyncSession, material_id: int + ) -> List[KnowledgePointInDB]: + """获取资料关联的知识点列表""" + + # 获取资料信息 + result = await db.execute( + select(CourseMaterial).where( + CourseMaterial.id == material_id, + CourseMaterial.is_deleted == False + ) + ) + material = result.scalar_one_or_none() + + if not material: + raise NotFoundError(f"资料ID {material_id} 不存在") + + # 直接查询关联到该资料的知识点 + query = select(KnowledgePoint).where( + KnowledgePoint.material_id == material_id, + KnowledgePoint.is_deleted == False + ).order_by(KnowledgePoint.created_at.desc()) + + result = await db.execute(query) + knowledge_points = result.scalars().all() + + from app.schemas.course import KnowledgePointInDB + return [KnowledgePointInDB.model_validate(kp) for kp in knowledge_points] + + async def add_material_knowledge_points( + self, db: AsyncSession, material_id: int, knowledge_point_ids: List[int] + ) -> List[KnowledgePointInDB]: + """ + 为资料添加知识点关联 + + 注意:自2025-09-27起,知识点直接通过material_id关联到资料, + material_knowledge_points中间表已废弃。此方法将更新知识点的material_id字段。 + """ + # 验证资料是否存在 + result = await db.execute( + select(CourseMaterial).where( + CourseMaterial.id == material_id, + CourseMaterial.is_deleted == False + ) + ) + material = result.scalar_one_or_none() + + if not material: + raise NotFoundError(f"资料ID {material_id} 不存在") + + # 验证知识点是否存在且属于同一课程 + result = await db.execute( + select(KnowledgePoint).where( + KnowledgePoint.id.in_(knowledge_point_ids), + KnowledgePoint.course_id == material.course_id, + KnowledgePoint.is_deleted == False + ) + ) + valid_knowledge_points = result.scalars().all() + + if len(valid_knowledge_points) != len(knowledge_point_ids): + raise BadRequestError("部分知识点不存在或不属于同一课程") + + # 更新知识点的material_id字段 + added_knowledge_points = [] + for kp in valid_knowledge_points: + # 更新知识点的资料关联 + kp.material_id = material_id + added_knowledge_points.append(kp) + + await db.commit() + + # 刷新对象以获取更新后的数据 + for kp in added_knowledge_points: + await db.refresh(kp) + + from app.schemas.course import KnowledgePointInDB + return [KnowledgePointInDB.model_validate(kp) for kp in added_knowledge_points] + + async def remove_material_knowledge_point( + self, db: AsyncSession, material_id: int, knowledge_point_id: int + ) -> bool: + """ + 移除资料的知识点关联(软删除知识点) + + 注意:自2025-09-27起,知识点直接通过material_id关联到资料, + material_knowledge_points中间表已废弃。此方法将软删除知识点。 + """ + # 查找知识点并验证归属 + result = await db.execute( + select(KnowledgePoint).where( + KnowledgePoint.id == knowledge_point_id, + KnowledgePoint.material_id == material_id, + KnowledgePoint.is_deleted == False + ) + ) + knowledge_point = result.scalar_one_or_none() + + if not knowledge_point: + raise NotFoundError(f"知识点ID {knowledge_point_id} 不存在或不属于该资料") + + # 软删除知识点 + knowledge_point.is_deleted = True + knowledge_point.deleted_at = datetime.now() + + await db.commit() + + logger.info( + "移除资料知识点关联", + material_id=material_id, + knowledge_point_id=knowledge_point_id, + ) + + return True + + +class KnowledgePointService(BaseService[KnowledgePoint]): + """ + 知识点服务类 + """ + + def __init__(self): + super().__init__(KnowledgePoint) + + async def get_knowledge_points_by_course( + self, db: AsyncSession, *, course_id: int, material_id: Optional[int] = None + ) -> List[KnowledgePoint]: + """ + 获取课程的知识点列表 + + Args: + db: 数据库会话 + course_id: 课程ID + material_id: 资料ID(可选,用于筛选特定资料的知识点) + + Returns: + 知识点列表 + """ + query = select(KnowledgePoint).where( + and_( + KnowledgePoint.course_id == course_id, + KnowledgePoint.is_deleted == False, + ) + ) + + if material_id is not None: + query = query.where(KnowledgePoint.material_id == material_id) + + query = query.order_by(KnowledgePoint.created_at.desc()) + + result = await db.execute(query) + return result.scalars().all() + + async def create_knowledge_point( + self, + db: AsyncSession, + *, + course_id: int, + point_in: KnowledgePointCreate, + created_by: int, + ) -> KnowledgePoint: + """ + 创建知识点 + + Args: + db: 数据库会话 + course_id: 课程ID + point_in: 知识点创建数据 + created_by: 创建人ID + + Returns: + 创建的知识点 + """ + # 检查课程是否存在 + course_service = CourseService() + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 创建知识点 + point_data = point_in.model_dump() + point_data.update({"course_id": course_id}) + + knowledge_point = await self.create( + db, obj_in=point_data, created_by=created_by + ) + + logger.info( + "创建知识点", + course_id=course_id, + knowledge_point_id=knowledge_point.id, + knowledge_point_name=knowledge_point.name, + created_by=created_by, + ) + + return knowledge_point + + async def update_knowledge_point( + self, + db: AsyncSession, + *, + point_id: int, + point_in: KnowledgePointUpdate, + updated_by: int, + ) -> KnowledgePoint: + """ + 更新知识点 + + Args: + db: 数据库会话 + point_id: 知识点ID + point_in: 知识点更新数据 + updated_by: 更新人ID + + Returns: + 更新后的知识点 + """ + knowledge_point = await self.get_by_id(db, point_id) + if not knowledge_point: + raise NotFoundError(f"知识点ID {point_id} 不存在") + + # 验证关联资料是否存在 + if hasattr(point_in, 'material_id') and point_in.material_id: + result = await db.execute( + select(CourseMaterial).where( + CourseMaterial.id == point_in.material_id, + CourseMaterial.is_deleted == False + ) + ) + material = result.scalar_one_or_none() + if not material: + raise NotFoundError(f"资料ID {point_in.material_id} 不存在") + + # 更新知识点 + update_data = point_in.model_dump(exclude_unset=True) + knowledge_point = await self.update( + db, db_obj=knowledge_point, obj_in=update_data, updated_by=updated_by + ) + + logger.info( + "更新知识点", + knowledge_point_id=knowledge_point.id, + knowledge_point_name=knowledge_point.name, + updated_by=updated_by, + ) + + return knowledge_point + + +class GrowthPathService(BaseService[GrowthPath]): + """ + 成长路径服务类 + """ + + def __init__(self): + super().__init__(GrowthPath) + + async def create_growth_path( + self, db: AsyncSession, *, path_in: GrowthPathCreate, created_by: int + ) -> GrowthPath: + """ + 创建成长路径 + + Args: + db: 数据库会话 + path_in: 成长路径创建数据 + created_by: 创建人ID + + Returns: + 创建的成长路径 + """ + # 检查名称是否重复 + existing = await db.execute( + select(GrowthPath).where( + and_(GrowthPath.name == path_in.name, GrowthPath.is_deleted == False) + ) + ) + if existing.scalar_one_or_none(): + raise ConflictError(f"成长路径名称 '{path_in.name}' 已存在") + + # 验证课程是否存在 + if path_in.courses: + course_ids = [c.course_id for c in path_in.courses] + course_service = CourseService() + for course_id in course_ids: + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 创建成长路径 + path_data = path_in.model_dump() + # 转换课程列表为JSON格式 + if path_data.get("courses"): + path_data["courses"] = [c.model_dump() for c in path_in.courses] + + growth_path = await self.create(db, obj_in=path_data, created_by=created_by) + + logger.info( + "创建成长路径", + growth_path_id=growth_path.id, + growth_path_name=growth_path.name, + course_count=len(path_in.courses) if path_in.courses else 0, + created_by=created_by, + ) + + return growth_path + + +# 创建服务实例 +course_service = CourseService() +knowledge_point_service = KnowledgePointService() +growth_path_service = GrowthPathService() diff --git a/backend/app/services/course_statistics_service.py b/backend/app/services/course_statistics_service.py new file mode 100644 index 0000000..4be32f5 --- /dev/null +++ b/backend/app/services/course_statistics_service.py @@ -0,0 +1,65 @@ +""" +课程统计服务 +""" +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.exam import Exam +from app.models.course import Course +from app.core.logger import get_logger + +logger = get_logger(__name__) + + +class CourseStatisticsService: + """课程统计服务类""" + + async def update_course_student_count( + self, + db: AsyncSession, + course_id: int + ) -> int: + """ + 更新课程学员数统计 + + Args: + db: 数据库会话 + course_id: 课程ID + + Returns: + 更新后的学员数 + """ + try: + # 统计该课程的不同学员数(基于考试记录) + stmt = select(func.count(func.distinct(Exam.user_id))).where( + Exam.course_id == course_id, + Exam.is_deleted == False + ) + result = await db.execute(stmt) + student_count = result.scalar_one() or 0 + + # 更新课程表 + course_stmt = select(Course).where( + Course.id == course_id, + Course.is_deleted == False + ) + course_result = await db.execute(course_stmt) + course = course_result.scalar_one_or_none() + + if course: + course.student_count = student_count + await db.commit() + logger.info(f"更新课程 {course_id} 学员数: {student_count}") + return student_count + else: + logger.warning(f"课程 {course_id} 不存在") + return 0 + + except Exception as e: + logger.error(f"更新课程学员数失败: {str(e)}", exc_info=True) + await db.rollback() + raise + + +# 创建全局实例 +course_statistics_service = CourseStatisticsService() + diff --git a/backend/app/services/coze_broadcast_service.py b/backend/app/services/coze_broadcast_service.py new file mode 100644 index 0000000..ff724a5 --- /dev/null +++ b/backend/app/services/coze_broadcast_service.py @@ -0,0 +1,97 @@ +""" +Coze 播课服务 +""" + +import logging +from typing import Optional + +from cozepy.exception import CozeError + +from app.core.config import settings +from app.services.ai.coze.client import get_coze_client + +logger = logging.getLogger(__name__) + + +class CozeBroadcastService: + """Coze 播课服务""" + + def __init__(self): + """初始化配置""" + self.workflow_id = settings.COZE_BROADCAST_WORKFLOW_ID + self.space_id = settings.COZE_BROADCAST_SPACE_ID + + def _get_client(self): + """获取新的 Coze 客户端(每次调用都创建新认证,避免token过期)""" + return get_coze_client(force_new=True) + + async def trigger_workflow(self, course_id: int) -> None: + """ + 触发播课生成工作流(不等待结果) + + Coze工作流会: + 1. 生成播课音频 + 2. 直接将结果写入数据库 + + Args: + course_id: 课程ID + + Raises: + CozeError: Coze API 调用失败 + """ + logger.info( + f"触发播课生成工作流", + extra={ + "course_id": course_id, + "workflow_id": self.workflow_id, + "bot_id": settings.COZE_BROADCAST_BOT_ID or settings.COZE_PRACTICE_BOT_ID # 关联到同一工作空间的Bot + } + ) + + try: + # 每次调用都获取新客户端(确保OAuth token有效) + coze = self._get_client() + + # 调用工作流(触发即返回,不等待结果) + # 关键:添加bot_id参数,关联到OAuth应用下的Bot + import asyncio + result = await asyncio.to_thread( + coze.workflows.runs.create, + workflow_id=self.workflow_id, + parameters={"course_id": str(course_id)}, + bot_id=settings.COZE_BROADCAST_BOT_ID or settings.COZE_PRACTICE_BOT_ID # 关联Bot,确保OAuth权限 + ) + + logger.info( + f"播课生成工作流已触发", + extra={ + "course_id": course_id, + "execute_id": getattr(result, 'execute_id', None), + "debug_url": getattr(result, 'debug_url', None) + } + ) + + except CozeError as e: + logger.error( + f"触发 Coze 工作流失败", + extra={ + "course_id": course_id, + "error": str(e), + "error_code": getattr(e, 'code', None) + } + ) + raise + except Exception as e: + logger.error( + f"触发播课生成工作流异常", + extra={ + "course_id": course_id, + "error": str(e), + "error_type": type(e).__name__ + } + ) + raise + + +# 全局单例 +broadcast_service = CozeBroadcastService() diff --git a/backend/app/services/coze_service.py b/backend/app/services/coze_service.py new file mode 100644 index 0000000..ca08efd --- /dev/null +++ b/backend/app/services/coze_service.py @@ -0,0 +1,199 @@ +""" +Coze AI对话服务 +""" +import logging +from typing import Optional +from cozepy import Coze, COZE_CN_BASE_URL, Message +from cozepy.exception import CozeError, CozeAPIError + +from app.core.config import settings +from app.services.ai.coze.client import get_auth_manager + +# 注意:不再直接使用 TokenAuth,统一通过 get_auth_manager() 管理认证 + +logger = logging.getLogger(__name__) + + +class CozeService: + """Coze对话服务""" + + def __init__(self): + """初始化Coze客户端""" + if not settings.COZE_PRACTICE_BOT_ID: + raise ValueError("COZE_PRACTICE_BOT_ID 未配置") + + self.bot_id = settings.COZE_PRACTICE_BOT_ID + self._auth_manager = get_auth_manager() + + logger.info( + f"CozeService初始化成功,Bot ID={self.bot_id}, " + f"Base URL={COZE_CN_BASE_URL}" + ) + + @property + def client(self) -> Coze: + """获取Coze客户端(每次获取确保OAuth token有效)""" + return self._auth_manager.get_client(force_new=True) + + def build_scene_prompt( + self, + scene_name: str, + scene_background: str, + scene_ai_role: str, + scene_objectives: list, + scene_keywords: Optional[list] = None, + scene_description: Optional[str] = None, + user_message: str = "" + ) -> str: + """ + 构建场景提示词(Markdown格式) + + 参数: + scene_name: 场景名称 + scene_background: 场景背景 + scene_ai_role: AI角色描述 + scene_objectives: 练习目标列表 + scene_keywords: 关键词列表 + scene_description: 场景描述(可选) + user_message: 用户第一句话 + + 返回: + 完整的场景提示词(Markdown格式) + """ + # 构建练习目标 + objectives_text = "\n".join( + f"{i+1}. {obj}" for i, obj in enumerate(scene_objectives) + ) + + # 构建关键词 + keywords_text = ", ".join(scene_keywords) if scene_keywords else "" + + # 构建完整提示词 + prompt = f"""# 陪练场景设定 + +## 场景名称 +{scene_name} +""" + + # 添加场景描述(如果有) + if scene_description: + prompt += f""" +## 场景描述 +{scene_description} +""" + + prompt += f""" +## 场景背景 +{scene_background} + +## AI角色要求 +{scene_ai_role} + +## 练习目标 +{objectives_text} +""" + + # 添加关键词(如果有) + if keywords_text: + prompt += f""" +## 关键词 +{keywords_text} +""" + + prompt += f""" +--- + +现在开始陪练对话。请你严格按照上述场景设定扮演角色,与学员进行实战对话练习。 +不要提及"场景设定"或"角色扮演"等元信息,直接进入角色开始对话。 + +学员的第一句话:{user_message} +""" + + return prompt + + def create_stream_chat( + self, + user_id: str, + message: str, + conversation_id: Optional[str] = None + ): + """ + 创建流式对话 + + 参数: + user_id: 用户ID + message: 消息内容 + conversation_id: 对话ID(续接对话时使用) + + 返回: + Coze流式对话迭代器 + """ + try: + logger.info( + f"创建Coze流式对话,user_id={user_id}, " + f"conversation_id={conversation_id}, " + f"message_length={len(message)}" + ) + + stream = self.client.chat.stream( + bot_id=self.bot_id, + user_id=user_id, + additional_messages=[Message.build_user_question_text(message)], + conversation_id=conversation_id + ) + + # 记录LogID用于排查问题 + if hasattr(stream, 'response') and hasattr(stream.response, 'logid'): + logger.info(f"Coze对话创建成功,logid={stream.response.logid}") + + return stream + + except (CozeError, CozeAPIError) as e: + logger.error(f"Coze API调用失败: {e}") + raise + except Exception as e: + logger.error(f"创建Coze对话失败: {e}") + raise + + def cancel_chat(self, conversation_id: str, chat_id: str): + """ + 中断对话 + + 参数: + conversation_id: 对话ID + chat_id: 聊天ID + """ + try: + logger.info(f"中断Coze对话,conversation_id={conversation_id}, chat_id={chat_id}") + + result = self.client.chat.cancel( + conversation_id=conversation_id, + chat_id=chat_id + ) + + logger.info(f"对话中断成功") + return result + + except (CozeError, CozeAPIError) as e: + logger.error(f"中断对话失败: {e}") + raise + except Exception as e: + logger.error(f"中断对话异常: {e}") + raise + + +# 单例模式 +_coze_service: Optional[CozeService] = None + + +def get_coze_service() -> CozeService: + """ + 获取CozeService单例 + + 用于FastAPI依赖注入 + """ + global _coze_service + if _coze_service is None: + _coze_service = CozeService() + return _coze_service + diff --git a/backend/app/services/document_converter.py b/backend/app/services/document_converter.py new file mode 100644 index 0000000..f68910d --- /dev/null +++ b/backend/app/services/document_converter.py @@ -0,0 +1,305 @@ +""" +文档转换服务 +使用 LibreOffice 将 Office 文档转换为 PDF +""" +import os +import logging +import subprocess +from pathlib import Path +from typing import Optional +from datetime import datetime + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +class DocumentConverterService: + """文档转换服务类""" + + # 支持转换的文件格式 + SUPPORTED_FORMATS = {'.docx', '.doc', '.pptx', '.ppt', '.xlsx', '.xls'} + + # Excel文件格式(需要特殊处理页面布局) + EXCEL_FORMATS = {'.xlsx', '.xls'} + + def __init__(self): + """初始化转换服务""" + self.converted_path = Path(settings.UPLOAD_PATH) / "converted" + self.converted_path.mkdir(parents=True, exist_ok=True) + + def get_converted_file_path(self, course_id: int, material_id: int) -> Path: + """ + 获取转换后的文件路径 + + Args: + course_id: 课程ID + material_id: 资料ID + + Returns: + 转换后的PDF文件路径 + """ + course_dir = self.converted_path / str(course_id) + course_dir.mkdir(parents=True, exist_ok=True) + return course_dir / f"{material_id}.pdf" + + def need_convert(self, source_file: Path, converted_file: Path) -> bool: + """ + 判断是否需要重新转换 + + Args: + source_file: 源文件路径 + converted_file: 转换后的文件路径 + + Returns: + 是否需要转换 + """ + # 如果转换文件不存在,需要转换 + if not converted_file.exists(): + return True + + # 如果源文件不存在,不需要转换 + if not source_file.exists(): + return False + + # 如果源文件修改时间晚于转换文件,需要重新转换 + source_mtime = source_file.stat().st_mtime + converted_mtime = converted_file.stat().st_mtime + + return source_mtime > converted_mtime + + def convert_excel_to_html( + self, + source_file: str, + course_id: int, + material_id: int + ) -> Optional[str]: + """ + 将Excel文件转换为HTML(避免PDF分页问题) + + Args: + source_file: 源文件路径 + course_id: 课程ID + material_id: 资料ID + + Returns: + 转换后的HTML文件URL,失败返回None + """ + try: + try: + import openpyxl + from openpyxl.utils import get_column_letter + except ImportError as ie: + logger.error(f"Excel转换依赖缺失: openpyxl 未安装。请运行 pip install openpyxl 或重建Docker镜像。错误: {str(ie)}") + return None + + source_path = Path(source_file) + logger.info(f"开始Excel转HTML: source={source_file}, course_id={course_id}, material_id={material_id}") + + # 获取HTML输出路径 + course_dir = self.converted_path / str(course_id) + course_dir.mkdir(parents=True, exist_ok=True) + html_file = course_dir / f"{material_id}.html" + + # 检查缓存 + if html_file.exists(): + source_mtime = source_path.stat().st_mtime + html_mtime = html_file.stat().st_mtime + if source_mtime <= html_mtime: + logger.info(f"使用缓存的HTML文件: {html_file}") + return f"/static/uploads/converted/{course_id}/{material_id}.html" + + # 读取Excel文件 + wb = openpyxl.load_workbook(source_file, data_only=True) + + # 构建HTML + html_content = ''' + + + + + + +''' + + # 生成sheet选项卡 + sheet_names = wb.sheetnames + html_content += '
\n' + for i, name in enumerate(sheet_names): + active = 'active' if i == 0 else '' + html_content += f'
{name}
\n' + html_content += '
\n' + + # 生成每个sheet的表格 + for i, sheet_name in enumerate(sheet_names): + ws = wb[sheet_name] + active = 'active' if i == 0 else '' + html_content += f'
\n' + html_content += '
\n' + + # 获取有效数据范围 + max_row = ws.max_row or 1 + max_col = ws.max_column or 1 + + for row_idx in range(1, min(max_row + 1, 1001)): # 限制最多1000行 + html_content += '' + for col_idx in range(1, min(max_col + 1, 51)): # 限制最多50列 + cell = ws.cell(row=row_idx, column=col_idx) + value = cell.value if cell.value is not None else '' + tag = 'th' if row_idx == 1 else 'td' + # 转义HTML特殊字符 + if isinstance(value, str): + value = value.replace('&', '&').replace('<', '<').replace('>', '>') + html_content += f'<{tag}>{value}' + html_content += '\n' + + html_content += '
\n' + + # 添加JavaScript + html_content += ''' + + +''' + + # 写入HTML文件 + with open(html_file, 'w', encoding='utf-8') as f: + f.write(html_content) + + logger.info(f"Excel转HTML成功: {html_file}") + return f"/static/uploads/converted/{course_id}/{material_id}.html" + + except Exception as e: + logger.error(f"Excel转HTML失败: {source_file}, 错误: {str(e)}", exc_info=True) + return None + + def convert_to_pdf( + self, + source_file: str, + course_id: int, + material_id: int + ) -> Optional[str]: + """ + 将Office文档转换为PDF + + Args: + source_file: 源文件路径(绝对路径或相对路径) + course_id: 课程ID + material_id: 资料ID + + Returns: + 转换后的PDF文件URL,失败返回None + """ + try: + source_path = Path(source_file) + + # 检查源文件是否存在 + if not source_path.exists(): + logger.error(f"源文件不存在: {source_file}") + return None + + # 检查文件格式是否支持 + file_ext = source_path.suffix.lower() + if file_ext not in self.SUPPORTED_FORMATS: + logger.error(f"不支持的文件格式: {file_ext}") + return None + + # Excel文件使用HTML预览(避免分页问题) + if file_ext in self.EXCEL_FORMATS: + return self.convert_excel_to_html(source_file, course_id, material_id) + + # 获取转换后的文件路径 + converted_file = self.get_converted_file_path(course_id, material_id) + + # 检查是否需要转换 + if not self.need_convert(source_path, converted_file): + logger.info(f"使用缓存的转换文件: {converted_file}") + return f"/static/uploads/converted/{course_id}/{material_id}.pdf" + + # 执行转换 + logger.info(f"开始转换文档: {source_file} -> {converted_file}") + + # 使用 LibreOffice 转换 + # --headless: 无界面模式 + # --convert-to pdf: 转换为PDF + # --outdir: 输出目录 + output_dir = converted_file.parent + + cmd = [ + 'libreoffice', + '--headless', + '--convert-to', 'pdf', + '--outdir', str(output_dir), + str(source_path) + ] + + # 执行转换命令(设置超时时间为60秒) + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=60, + check=True + ) + + # LibreOffice 转换后的文件名是源文件名.pdf + # 需要重命名为 material_id.pdf + temp_converted = output_dir / f"{source_path.stem}.pdf" + if temp_converted.exists() and temp_converted != converted_file: + temp_converted.rename(converted_file) + + # 检查转换结果 + if converted_file.exists(): + logger.info(f"文档转换成功: {converted_file}") + return f"/static/uploads/converted/{course_id}/{material_id}.pdf" + else: + logger.error(f"文档转换失败,输出文件不存在: {converted_file}") + return None + + except subprocess.TimeoutExpired: + logger.error(f"文档转换超时: {source_file}") + return None + except subprocess.CalledProcessError as e: + logger.error(f"文档转换失败: {source_file}, 错误: {e.stderr}") + return None + except Exception as e: + logger.error(f"文档转换异常: {source_file}, 错误: {str(e)}", exc_info=True) + return None + + def is_convertible(self, file_ext: str) -> bool: + """ + 判断文件格式是否可转换 + + Args: + file_ext: 文件扩展名(带点,如 .docx) + + Returns: + 是否可转换 + """ + return file_ext.lower() in self.SUPPORTED_FORMATS + + +# 创建全局实例 +document_converter = DocumentConverterService() + diff --git a/backend/app/services/employee_sync_service.py b/backend/app/services/employee_sync_service.py new file mode 100644 index 0000000..5e93c73 --- /dev/null +++ b/backend/app/services/employee_sync_service.py @@ -0,0 +1,739 @@ +""" +员工同步服务 +从外部钉钉员工表同步员工数据到考培练系统 +""" + +from typing import List, Dict, Any, Optional, Tuple +from datetime import datetime +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import selectinload +import asyncio + +from app.core.logger import get_logger +from app.core.security import get_password_hash +from app.models.user import User, Team +from app.models.position import Position +from app.models.position_member import PositionMember +from app.schemas.user import UserCreate + +logger = get_logger(__name__) + + +class EmployeeSyncService: + """员工同步服务""" + + # 外部数据库连接配置 + EXTERNAL_DB_URL = "mysql+aiomysql://neuron_new:NWxGM6CQoMLKyEszXhfuLBIIo1QbeK@120.77.144.233:29613/neuron_new?charset=utf8mb4" + + def __init__(self, db: AsyncSession): + self.db = db + self.external_engine = None + + async def __aenter__(self): + """异步上下文管理器入口""" + self.external_engine = create_async_engine( + self.EXTERNAL_DB_URL, + echo=False, + pool_pre_ping=True, + pool_recycle=3600 + ) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """异步上下文管理器出口""" + if self.external_engine: + await self.external_engine.dispose() + + async def fetch_employees_from_dingtalk(self) -> List[Dict[str, Any]]: + """ + 从钉钉员工表获取在职员工数据 + + Returns: + 员工数据列表 + """ + logger.info("开始从钉钉员工表获取数据...") + + query = """ + SELECT + 员工姓名, + 手机号, + 邮箱, + 所属部门, + 职位, + 工号, + 是否领导, + 是否在职, + 钉钉用户ID, + 入职日期, + 工作地点 + FROM v_钉钉员工表 + WHERE 是否在职 = 1 + ORDER BY 员工姓名 + """ + + async with self.external_engine.connect() as conn: + result = await conn.execute(text(query)) + rows = result.fetchall() + + employees = [] + for row in rows: + employees.append({ + 'full_name': row[0], + 'phone': row[1], + 'email': row[2], + 'department': row[3], + 'position': row[4], + 'employee_no': row[5], + 'is_leader': bool(row[6]), + 'is_active': bool(row[7]), + 'dingtalk_id': row[8], + 'join_date': row[9], + 'work_location': row[10] + }) + + logger.info(f"获取到 {len(employees)} 条在职员工数据") + return employees + + def generate_email(self, phone: str, original_email: Optional[str]) -> Optional[str]: + """ + 生成邮箱地址 + 如果原始邮箱为空或格式无效,生成 {手机号}@rxm.com + + Args: + phone: 手机号 + original_email: 原始邮箱 + + Returns: + 邮箱地址 + """ + if original_email and original_email.strip(): + email = original_email.strip() + # 验证邮箱格式:检查是否有@后直接跟点号等无效格式 + if '@' in email and not email.startswith('@') and not email.endswith('@'): + # 检查@后面是否直接是点号 + at_index = email.index('@') + if at_index + 1 < len(email) and email[at_index + 1] != '.': + # 检查是否有域名部分 + domain = email[at_index + 1:] + if '.' in domain and len(domain) > 2: + return email + + # 如果邮箱无效或为空,使用手机号生成 + if phone: + return f"{phone}@rxm.com" + + return None + + def determine_role(self, is_leader: bool) -> str: + """ + 确定用户角色 + + Args: + is_leader: 是否领导 + + Returns: + 角色: manager 或 trainee + """ + return 'manager' if is_leader else 'trainee' + + async def create_or_get_team(self, department_name: str, leader_id: Optional[int] = None) -> Team: + """ + 创建或获取部门团队 + + Args: + department_name: 部门名称 + leader_id: 负责人ID + + Returns: + 团队对象 + """ + if not department_name or department_name.strip() == '': + return None + + department_name = department_name.strip() + + # 检查团队是否已存在 + stmt = select(Team).where( + Team.name == department_name, + Team.is_deleted == False + ) + result = await self.db.execute(stmt) + team = result.scalar_one_or_none() + + if team: + # 更新负责人 + if leader_id and not team.leader_id: + team.leader_id = leader_id + logger.info(f"更新团队 {department_name} 的负责人") + return team + + # 创建新团队 + # 生成团队代码:使用部门名称的拼音首字母或简化处理 + team_code = f"DEPT_{hash(department_name) % 100000:05d}" + + team = Team( + name=department_name, + code=team_code, + description=f"{department_name}", + team_type='department', + is_active=True, + leader_id=leader_id + ) + + self.db.add(team) + await self.db.flush() # 获取ID但不提交 + + logger.info(f"创建团队: {department_name} (ID: {team.id})") + return team + + async def create_or_get_position(self, position_name: str) -> Optional[Position]: + """ + 创建或获取岗位 + + Args: + position_name: 岗位名称 + + Returns: + 岗位对象 + """ + if not position_name or position_name.strip() == '': + return None + + position_name = position_name.strip() + + # 检查岗位是否已存在 + stmt = select(Position).where( + Position.name == position_name, + Position.is_deleted == False + ) + result = await self.db.execute(stmt) + position = result.scalar_one_or_none() + + if position: + return position + + # 创建新岗位 + position_code = f"POS_{hash(position_name) % 100000:05d}" + + position = Position( + name=position_name, + code=position_code, + description=f"{position_name}", + status='active' + ) + + self.db.add(position) + await self.db.flush() + + logger.info(f"创建岗位: {position_name} (ID: {position.id})") + return position + + async def create_user(self, employee_data: Dict[str, Any]) -> Optional[User]: + """ + 创建用户 + + Args: + employee_data: 员工数据 + + Returns: + 用户对象或None(如果创建失败) + """ + phone = employee_data.get('phone') + full_name = employee_data.get('full_name') + + if not phone: + logger.warning(f"员工 {full_name} 没有手机号,跳过") + return None + + # 检查用户是否已存在(通过手机号) + stmt = select(User).where( + User.phone == phone, + User.is_deleted == False + ) + result = await self.db.execute(stmt) + existing_user = result.scalar_one_or_none() + + if existing_user: + logger.info(f"用户已存在: {phone} ({full_name})") + return existing_user + + # 生成邮箱 + email = self.generate_email(phone, employee_data.get('email')) + + # 检查邮箱是否已被其他用户使用(避免唯一索引冲突) + if email: + email_check_stmt = select(User).where( + User.email == email, + User.is_deleted == False + ) + email_result = await self.db.execute(email_check_stmt) + if email_result.scalar_one_or_none(): + # 邮箱已存在,使用手机号生成唯一邮箱 + email = f"{phone}@rxm.com" + logger.warning(f"邮箱 {employee_data.get('email')} 已被使用,为员工 {full_name} 生成新邮箱: {email}") + + # 确定角色 + role = self.determine_role(employee_data.get('is_leader', False)) + + # 创建用户 + user = User( + username=phone, # 使用手机号作为用户名 + email=email, + phone=phone, + hashed_password=get_password_hash('123456'), # 初始密码 + full_name=full_name, + role=role, + is_active=True, + is_verified=True + ) + + self.db.add(user) + await self.db.flush() + + logger.info(f"创建用户: {phone} ({full_name}) - 角色: {role}") + return user + + async def sync_employees(self) -> Dict[str, Any]: + """ + 执行完整的员工同步流程 + + Returns: + 同步结果统计 + """ + logger.info("=" * 60) + logger.info("开始员工同步") + logger.info("=" * 60) + + stats = { + 'total_employees': 0, + 'users_created': 0, + 'users_skipped': 0, + 'teams_created': 0, + 'positions_created': 0, + 'errors': [], + 'start_time': datetime.now() + } + + try: + # 1. 获取员工数据 + employees = await self.fetch_employees_from_dingtalk() + stats['total_employees'] = len(employees) + + if not employees: + logger.warning("没有获取到员工数据") + return stats + + # 2. 创建用户和相关数据 + for employee in employees: + try: + # 创建用户 + user = await self.create_user(employee) + if not user: + stats['users_skipped'] += 1 + continue + + stats['users_created'] += 1 + + # 创建部门团队 + department = employee.get('department') + if department: + team = await self.create_or_get_team( + department, + leader_id=user.id if employee.get('is_leader') else None + ) + if team: + # 用SQL直接插入user_teams关联表(避免懒加载问题) + await self._add_user_to_team(user.id, team.id) + logger.info(f"关联用户 {user.full_name} 到团队 {team.name}") + + # 创建岗位 + position_name = employee.get('position') + if position_name: + position = await self.create_or_get_position(position_name) + if position: + # 检查是否已经关联 + stmt = select(PositionMember).where( + PositionMember.position_id == position.id, + PositionMember.user_id == user.id, + PositionMember.is_deleted == False + ) + result = await self.db.execute(stmt) + existing_member = result.scalar_one_or_none() + + if not existing_member: + # 创建岗位成员关联 + position_member = PositionMember( + position_id=position.id, + user_id=user.id, + role='member' + ) + self.db.add(position_member) + logger.info(f"关联用户 {user.full_name} 到岗位 {position.name}") + + except Exception as e: + error_msg = f"处理员工 {employee.get('full_name')} 时出错: {str(e)}" + logger.error(error_msg) + stats['errors'].append(error_msg) + continue + + # 3. 提交所有更改 + await self.db.commit() + logger.info("✅ 数据库事务已提交") + + except Exception as e: + logger.error(f"员工同步失败: {str(e)}") + await self.db.rollback() + stats['errors'].append(str(e)) + raise + + finally: + stats['end_time'] = datetime.now() + stats['duration'] = (stats['end_time'] - stats['start_time']).total_seconds() + + # 4. 输出统计信息 + logger.info("=" * 60) + logger.info("同步完成统计") + logger.info("=" * 60) + logger.info(f"总员工数: {stats['total_employees']}") + logger.info(f"创建用户: {stats['users_created']}") + logger.info(f"跳过用户: {stats['users_skipped']}") + logger.info(f"耗时: {stats['duration']:.2f}秒") + + if stats['errors']: + logger.warning(f"错误数量: {len(stats['errors'])}") + for error in stats['errors']: + logger.warning(f" - {error}") + + return stats + + async def preview_sync_data(self) -> Dict[str, Any]: + """ + 预览待同步的员工数据(不执行实际同步) + + Returns: + 预览数据 + """ + logger.info("预览待同步员工数据...") + + employees = await self.fetch_employees_from_dingtalk() + + preview = { + 'total_count': len(employees), + 'employees': [], + 'departments': set(), + 'positions': set(), + 'leaders_count': 0, + 'trainees_count': 0 + } + + for emp in employees: + role = self.determine_role(emp.get('is_leader', False)) + email = self.generate_email(emp.get('phone'), emp.get('email')) + + preview['employees'].append({ + 'full_name': emp.get('full_name'), + 'phone': emp.get('phone'), + 'email': email, + 'department': emp.get('department'), + 'position': emp.get('position'), + 'role': role, + 'is_leader': emp.get('is_leader') + }) + + if emp.get('department'): + preview['departments'].add(emp.get('department')) + if emp.get('position'): + preview['positions'].add(emp.get('position')) + + if role == 'manager': + preview['leaders_count'] += 1 + else: + preview['trainees_count'] += 1 + + preview['departments'] = list(preview['departments']) + preview['positions'] = list(preview['positions']) + + return preview + + async def _add_user_to_team(self, user_id: int, team_id: int) -> None: + """ + 将用户添加到团队(直接SQL操作,避免懒加载问题) + + Args: + user_id: 用户ID + team_id: 团队ID + """ + # 先检查是否已存在关联 + check_result = await self.db.execute( + text("SELECT 1 FROM user_teams WHERE user_id = :user_id AND team_id = :team_id"), + {"user_id": user_id, "team_id": team_id} + ) + if check_result.scalar() is None: + # 不存在则插入 + await self.db.execute( + text("INSERT INTO user_teams (user_id, team_id, role) VALUES (:user_id, :team_id, 'member')"), + {"user_id": user_id, "team_id": team_id} + ) + + async def _cleanup_user_related_data(self, user_id: int) -> None: + """ + 清理用户关联数据(用于删除用户前) + + Args: + user_id: 要清理的用户ID + """ + logger.info(f"清理用户 {user_id} 的关联数据...") + + # 删除用户的考试记录 + await self.db.execute( + text("DELETE FROM exam_results WHERE exam_id IN (SELECT id FROM exams WHERE user_id = :user_id)"), + {"user_id": user_id} + ) + await self.db.execute( + text("DELETE FROM exams WHERE user_id = :user_id"), + {"user_id": user_id} + ) + + # 删除用户的错题记录 + await self.db.execute( + text("DELETE FROM exam_mistakes WHERE user_id = :user_id"), + {"user_id": user_id} + ) + + # 删除用户的能力评估记录 + await self.db.execute( + text("DELETE FROM ability_assessments WHERE user_id = :user_id"), + {"user_id": user_id} + ) + + # 删除用户的岗位关联 + await self.db.execute( + text("DELETE FROM position_members WHERE user_id = :user_id"), + {"user_id": user_id} + ) + + # 删除用户的团队关联 + await self.db.execute( + text("DELETE FROM user_teams WHERE user_id = :user_id"), + {"user_id": user_id} + ) + + # 删除用户的陪练会话 + await self.db.execute( + text("DELETE FROM practice_sessions WHERE user_id = :user_id"), + {"user_id": user_id} + ) + + # 删除用户的任务分配 + await self.db.execute( + text("DELETE FROM task_assignments WHERE user_id = :user_id"), + {"user_id": user_id} + ) + + # 删除用户创建的任务的分配记录 + await self.db.execute( + text("DELETE FROM task_assignments WHERE task_id IN (SELECT id FROM tasks WHERE creator_id = :user_id)"), + {"user_id": user_id} + ) + # 删除用户创建的任务 + await self.db.execute( + text("DELETE FROM tasks WHERE creator_id = :user_id"), + {"user_id": user_id} + ) + + # 将用户作为负责人的团队的leader_id设为NULL + await self.db.execute( + text("UPDATE teams SET leader_id = NULL WHERE leader_id = :user_id"), + {"user_id": user_id} + ) + + logger.info(f"用户 {user_id} 的关联数据清理完成") + + async def incremental_sync_employees(self) -> Dict[str, Any]: + """ + 增量同步员工数据 + - 新增钉钉有但系统没有的员工 + - 删除系统有但钉钉没有的员工(物理删除) + - 跳过两边都存在的员工(不做任何修改) + + Returns: + 同步结果统计 + """ + logger.info("=" * 60) + logger.info("开始增量员工同步") + logger.info("=" * 60) + + stats = { + 'added_count': 0, + 'deleted_count': 0, + 'skipped_count': 0, + 'added_users': [], + 'deleted_users': [], + 'errors': [], + 'start_time': datetime.now() + } + + try: + # 1. 获取钉钉在职员工数据 + dingtalk_employees = await self.fetch_employees_from_dingtalk() + dingtalk_phones = {emp.get('phone') for emp in dingtalk_employees if emp.get('phone')} + logger.info(f"钉钉在职员工数量: {len(dingtalk_phones)}") + + # 2. 获取系统现有用户(排除admin和已软删除的) + stmt = select(User).where( + User.is_deleted == False, + User.username != 'admin' + ) + result = await self.db.execute(stmt) + system_users = result.scalars().all() + system_phones = {user.phone for user in system_users if user.phone} + logger.info(f"系统现有员工数量(排除admin): {len(system_phones)}") + + # 3. 计算需要新增、删除、跳过的员工 + phones_to_add = dingtalk_phones - system_phones + phones_to_delete = system_phones - dingtalk_phones + phones_to_skip = dingtalk_phones & system_phones + + logger.info(f"待新增: {len(phones_to_add)}, 待删除: {len(phones_to_delete)}, 跳过: {len(phones_to_skip)}") + + stats['skipped_count'] = len(phones_to_skip) + + # 4. 新增员工 + for employee in dingtalk_employees: + phone = employee.get('phone') + if not phone or phone not in phones_to_add: + continue + + try: + # 创建用户 + user = await self.create_user(employee) + if not user: + continue + + stats['added_count'] += 1 + stats['added_users'].append({ + 'full_name': user.full_name, + 'phone': user.phone, + 'role': user.role + }) + + # 创建部门团队 + department = employee.get('department') + if department: + team = await self.create_or_get_team( + department, + leader_id=user.id if employee.get('is_leader') else None + ) + if team: + # 用SQL直接插入user_teams关联表(避免懒加载问题) + await self._add_user_to_team(user.id, team.id) + logger.info(f"关联用户 {user.full_name} 到团队 {team.name}") + + # 创建岗位 + position_name = employee.get('position') + if position_name: + position = await self.create_or_get_position(position_name) + if position: + # 检查是否已经关联 + stmt = select(PositionMember).where( + PositionMember.position_id == position.id, + PositionMember.user_id == user.id, + PositionMember.is_deleted == False + ) + result = await self.db.execute(stmt) + existing_member = result.scalar_one_or_none() + + if not existing_member: + position_member = PositionMember( + position_id=position.id, + user_id=user.id, + role='member' + ) + self.db.add(position_member) + logger.info(f"关联用户 {user.full_name} 到岗位 {position.name}") + + logger.info(f"✅ 新增员工: {user.full_name} ({user.phone})") + + except Exception as e: + error_msg = f"新增员工 {employee.get('full_name')} 失败: {str(e)}" + logger.error(error_msg) + stats['errors'].append(error_msg) + continue + + # 5. 删除离职员工(物理删除) + # 先flush之前的新增操作,避免与删除操作冲突 + await self.db.flush() + + # 收集需要删除的用户ID + users_to_delete = [] + for user in system_users: + if user.phone and user.phone in phones_to_delete: + # 双重保护:确保不删除admin + if user.username == 'admin' or user.role == 'admin': + logger.warning(f"⚠️ 跳过删除管理员账户: {user.username}") + continue + + users_to_delete.append({ + 'id': user.id, + 'full_name': user.full_name, + 'phone': user.phone, + 'username': user.username + }) + + # 批量删除用户及其关联数据 + for user_info in users_to_delete: + try: + user_id = user_info['id'] + + # 先清理关联数据(外键约束) + await self._cleanup_user_related_data(user_id) + + # 用SQL直接删除用户(避免ORM的级联操作冲突) + await self.db.execute( + text("DELETE FROM users WHERE id = :user_id"), + {"user_id": user_id} + ) + + stats['deleted_users'].append({ + 'full_name': user_info['full_name'], + 'phone': user_info['phone'], + 'username': user_info['username'] + }) + stats['deleted_count'] += 1 + logger.info(f"🗑️ 删除离职员工: {user_info['full_name']} ({user_info['phone']})") + + except Exception as e: + error_msg = f"删除员工 {user_info['full_name']} 失败: {str(e)}" + logger.error(error_msg) + stats['errors'].append(error_msg) + continue + + # 6. 提交所有更改 + await self.db.commit() + logger.info("✅ 数据库事务已提交") + + except Exception as e: + logger.error(f"增量同步失败: {str(e)}") + await self.db.rollback() + stats['errors'].append(str(e)) + raise + + finally: + stats['end_time'] = datetime.now() + stats['duration'] = (stats['end_time'] - stats['start_time']).total_seconds() + + # 7. 输出统计信息 + logger.info("=" * 60) + logger.info("增量同步完成统计") + logger.info("=" * 60) + logger.info(f"新增员工: {stats['added_count']}") + logger.info(f"删除员工: {stats['deleted_count']}") + logger.info(f"跳过员工: {stats['skipped_count']}") + logger.info(f"耗时: {stats['duration']:.2f}秒") + + if stats['errors']: + logger.warning(f"错误数量: {len(stats['errors'])}") + + return stats + diff --git a/backend/app/services/exam_report_service.py b/backend/app/services/exam_report_service.py new file mode 100644 index 0000000..2f7338d --- /dev/null +++ b/backend/app/services/exam_report_service.py @@ -0,0 +1,486 @@ +""" +考试报告和错题统计服务 +""" +from datetime import datetime, timedelta +from typing import List, Optional, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, or_, desc, case, text +from app.models.exam import Exam +from app.models.exam_mistake import ExamMistake +from app.models.course import Course, KnowledgePoint +from app.core.logger import get_logger + +logger = get_logger(__name__) + + +class ExamReportService: + """考试报告服务类""" + + @staticmethod + async def get_exam_report( + db: AsyncSession, + user_id: int, + start_date: Optional[str] = None, + end_date: Optional[str] = None + ) -> Dict[str, Any]: + """ + 获取成绩报告汇总数据 + + Args: + db: 数据库会话 + user_id: 用户ID + start_date: 开始日期(YYYY-MM-DD) + end_date: 结束日期(YYYY-MM-DD) + + Returns: + Dict: 包含overview、trends、subjects、recent_exams的完整报告数据 + """ + logger.info(f"获取成绩报告 - user_id: {user_id}, start_date: {start_date}, end_date: {end_date}") + + # 构建基础查询条件 + conditions = [Exam.user_id == user_id] + + # 添加时间范围条件 + if start_date: + conditions.append(Exam.start_time >= start_date) + if end_date: + # 结束日期包含当天全部 + end_datetime = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) + conditions.append(Exam.start_time < end_datetime) + + # 1. 获取概览数据 + overview = await ExamReportService._get_overview(db, conditions) + + # 2. 获取趋势数据(最近30天) + trends = await ExamReportService._get_trends(db, user_id, conditions) + + # 3. 获取科目分析 + subjects = await ExamReportService._get_subjects(db, conditions) + + # 4. 获取最近考试记录 + recent_exams = await ExamReportService._get_recent_exams(db, conditions) + + return { + "overview": overview, + "trends": trends, + "subjects": subjects, + "recent_exams": recent_exams + } + + @staticmethod + async def _get_overview(db: AsyncSession, conditions: List) -> Dict[str, Any]: + """获取概览数据""" + # 查询统计数据(使用round1_score作为主要成绩) + stmt = select( + func.count(Exam.id).label("total_exams"), + func.avg(Exam.round1_score).label("avg_score"), + func.sum(Exam.question_count).label("total_questions"), + func.count(case((Exam.is_passed == True, 1))).label("passed_exams") + ).where( + and_(*conditions, Exam.round1_score.isnot(None)) + ) + + result = await db.execute(stmt) + stats = result.one() + + total_exams = stats.total_exams or 0 + passed_exams = stats.passed_exams or 0 + + return { + "avg_score": round(float(stats.avg_score or 0), 1), + "total_exams": total_exams, + "pass_rate": round((passed_exams / total_exams * 100) if total_exams > 0 else 0, 1), + "total_questions": stats.total_questions or 0 + } + + @staticmethod + async def _get_trends( + db: AsyncSession, + user_id: int, + base_conditions: List + ) -> List[Dict[str, Any]]: + """获取成绩趋势(最近30天)""" + # 计算30天前的日期 + thirty_days_ago = datetime.now() - timedelta(days=30) + + # 查询最近30天的考试数据,按日期分组 + stmt = select( + func.date(Exam.start_time).label("exam_date"), + func.avg(Exam.round1_score).label("avg_score") + ).where( + and_( + Exam.user_id == user_id, + Exam.start_time >= thirty_days_ago, + Exam.round1_score.isnot(None) + ) + ).group_by( + func.date(Exam.start_time) + ).order_by( + func.date(Exam.start_time) + ) + + result = await db.execute(stmt) + trend_data = result.all() + + # 转换为前端需要的格式 + trends = [] + for row in trend_data: + trends.append({ + "date": row.exam_date.strftime("%Y-%m-%d") if row.exam_date else "", + "avg_score": round(float(row.avg_score or 0), 1) + }) + + return trends + + @staticmethod + async def _get_subjects(db: AsyncSession, conditions: List) -> List[Dict[str, Any]]: + """获取科目分析""" + # 关联course表,按课程分组统计 + stmt = select( + Exam.course_id, + Course.name.label("course_name"), + func.avg(Exam.round1_score).label("avg_score"), + func.count(Exam.id).label("exam_count"), + func.max(Exam.round1_score).label("max_score"), + func.min(Exam.round1_score).label("min_score"), + func.count(case((Exam.is_passed == True, 1))).label("passed_count") + ).join( + Course, Exam.course_id == Course.id + ).where( + and_(*conditions, Exam.round1_score.isnot(None)) + ).group_by( + Exam.course_id, Course.name + ).order_by( + desc(func.count(Exam.id)) + ) + + result = await db.execute(stmt) + subject_data = result.all() + + subjects = [] + for row in subject_data: + exam_count = row.exam_count or 0 + passed_count = row.passed_count or 0 + + subjects.append({ + "course_id": row.course_id, + "course_name": row.course_name, + "avg_score": round(float(row.avg_score or 0), 1), + "exam_count": exam_count, + "max_score": round(float(row.max_score or 0), 1), + "min_score": round(float(row.min_score or 0), 1), + "pass_rate": round((passed_count / exam_count * 100) if exam_count > 0 else 0, 1) + }) + + return subjects + + @staticmethod + async def _get_recent_exams(db: AsyncSession, conditions: List) -> List[Dict[str, Any]]: + """获取最近10次考试记录""" + # 查询最近10次考试,包含三轮得分 + stmt = select( + Exam.id, + Exam.course_id, + Course.name.label("course_name"), + Exam.score, + Exam.total_score, + Exam.is_passed, + Exam.start_time, + Exam.end_time, + Exam.round1_score, + Exam.round2_score, + Exam.round3_score + ).join( + Course, Exam.course_id == Course.id + ).where( + and_(*conditions) + ).order_by( + desc(Exam.created_at) # 改为按创建时间排序,避免start_time为NULL的问题 + ).limit(10) + + result = await db.execute(stmt) + exam_data = result.all() + + recent_exams = [] + for row in exam_data: + # 计算考试用时 + duration_seconds = None + if row.start_time and row.end_time: + duration_seconds = int((row.end_time - row.start_time).total_seconds()) + + recent_exams.append({ + "id": row.id, + "course_id": row.course_id, + "course_name": row.course_name, + "score": round(float(row.score), 1) if row.score else None, + "total_score": round(float(row.total_score or 100), 1), + "is_passed": row.is_passed, + "duration_seconds": duration_seconds, + "start_time": row.start_time.isoformat() if row.start_time else None, + "end_time": row.end_time.isoformat() if row.end_time else None, + "round_scores": { + "round1": round(float(row.round1_score), 1) if row.round1_score else None, + "round2": round(float(row.round2_score), 1) if row.round2_score else None, + "round3": round(float(row.round3_score), 1) if row.round3_score else None + } + }) + + return recent_exams + + +class MistakeService: + """错题服务类""" + + @staticmethod + async def get_mistakes_list( + db: AsyncSession, + user_id: int, + exam_id: Optional[int] = None, + course_id: Optional[int] = None, + question_type: Optional[str] = None, + search: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + page: int = 1, + size: int = 10 + ) -> Dict[str, Any]: + """ + 获取错题列表(支持多维度筛选) + + Args: + db: 数据库会话 + user_id: 用户ID + exam_id: 考试ID(可选) + course_id: 课程ID(可选) + question_type: 题型(可选) + search: 关键词搜索(可选) + start_date: 开始日期(可选) + end_date: 结束日期(可选) + page: 页码 + size: 每页数量 + + Returns: + Dict: 包含items、total、page、size、pages的分页数据 + """ + logger.info(f"获取错题列表 - user_id: {user_id}, exam_id: {exam_id}, course_id: {course_id}") + + # 构建查询条件 + conditions = [ExamMistake.user_id == user_id] + + if exam_id: + conditions.append(ExamMistake.exam_id == exam_id) + + if question_type: + conditions.append(ExamMistake.question_type == question_type) + + if search: + conditions.append(ExamMistake.question_content.like(f"%{search}%")) + + if start_date: + conditions.append(ExamMistake.created_at >= start_date) + + if end_date: + end_datetime = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) + conditions.append(ExamMistake.created_at < end_datetime) + + # 如果指定了course_id,需要通过exam关联 + if course_id: + conditions.append(Exam.course_id == course_id) + + # 查询总数 + count_stmt = select(func.count(ExamMistake.id)).select_from(ExamMistake).join( + Exam, ExamMistake.exam_id == Exam.id + ).where(and_(*conditions)) + + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 查询分页数据 + offset = (page - 1) * size + + stmt = select( + ExamMistake.id, + ExamMistake.exam_id, + Exam.course_id, + Course.name.label("course_name"), + ExamMistake.question_content, + ExamMistake.correct_answer, + ExamMistake.user_answer, + ExamMistake.question_type, + ExamMistake.knowledge_point_id, + KnowledgePoint.name.label("knowledge_point_name"), + ExamMistake.created_at + ).select_from(ExamMistake).join( + Exam, ExamMistake.exam_id == Exam.id + ).join( + Course, Exam.course_id == Course.id + ).outerjoin( + KnowledgePoint, ExamMistake.knowledge_point_id == KnowledgePoint.id + ).where( + and_(*conditions) + ).order_by( + desc(ExamMistake.created_at) + ).offset(offset).limit(size) + + result = await db.execute(stmt) + mistakes = result.all() + + # 构建返回数据 + items = [] + for row in mistakes: + items.append({ + "id": row.id, + "exam_id": row.exam_id, + "course_id": row.course_id, + "course_name": row.course_name, + "question_content": row.question_content, + "correct_answer": row.correct_answer, + "user_answer": row.user_answer, + "question_type": row.question_type, + "knowledge_point_id": row.knowledge_point_id, + "knowledge_point_name": row.knowledge_point_name, + "created_at": row.created_at + }) + + pages = (total + size - 1) // size + + return { + "items": items, + "total": total, + "page": page, + "size": size, + "pages": pages + } + + @staticmethod + async def get_mistakes_statistics( + db: AsyncSession, + user_id: int, + course_id: Optional[int] = None + ) -> Dict[str, Any]: + """ + 获取错题统计数据 + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID(可选) + + Returns: + Dict: 包含total、by_course、by_type、by_time的统计数据 + """ + logger.info(f"获取错题统计 - user_id: {user_id}, course_id: {course_id}") + + # 基础条件 + base_conditions = [ExamMistake.user_id == user_id] + if course_id: + base_conditions.append(Exam.course_id == course_id) + + # 1. 总数统计 + count_stmt = select(func.count(ExamMistake.id)).select_from(ExamMistake).join( + Exam, ExamMistake.exam_id == Exam.id + ).where(and_(*base_conditions)) + + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 2. 按课程统计 + by_course_stmt = select( + Exam.course_id, + Course.name.label("course_name"), + func.count(ExamMistake.id).label("count") + ).select_from(ExamMistake).join( + Exam, ExamMistake.exam_id == Exam.id + ).join( + Course, Exam.course_id == Course.id + ).where( + ExamMistake.user_id == user_id + ).group_by( + Exam.course_id, Course.name + ).order_by( + desc(func.count(ExamMistake.id)) + ) + + by_course_result = await db.execute(by_course_stmt) + by_course_data = by_course_result.all() + + by_course = [ + { + "course_id": row.course_id, + "course_name": row.course_name, + "count": row.count + } + for row in by_course_data + ] + + # 3. 按题型统计 + by_type_stmt = select( + ExamMistake.question_type, + func.count(ExamMistake.id).label("count") + ).where( + and_(ExamMistake.user_id == user_id, ExamMistake.question_type.isnot(None)) + ).group_by( + ExamMistake.question_type + ) + + by_type_result = await db.execute(by_type_stmt) + by_type_data = by_type_result.all() + + # 题型名称映射 + type_names = { + "single": "单选题", + "multiple": "多选题", + "judge": "判断题", + "blank": "填空题", + "essay": "问答题" + } + + by_type = [ + { + "type": row.question_type, + "type_name": type_names.get(row.question_type, "未知类型"), + "count": row.count + } + for row in by_type_data + ] + + # 4. 按时间统计 + now = datetime.now() + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + quarter_ago = now - timedelta(days=90) + + # 最近一周 + week_stmt = select(func.count(ExamMistake.id)).where( + and_(ExamMistake.user_id == user_id, ExamMistake.created_at >= week_ago) + ) + week_result = await db.execute(week_stmt) + week_count = week_result.scalar() or 0 + + # 最近一月 + month_stmt = select(func.count(ExamMistake.id)).where( + and_(ExamMistake.user_id == user_id, ExamMistake.created_at >= month_ago) + ) + month_result = await db.execute(month_stmt) + month_count = month_result.scalar() or 0 + + # 最近三月 + quarter_stmt = select(func.count(ExamMistake.id)).where( + and_(ExamMistake.user_id == user_id, ExamMistake.created_at >= quarter_ago) + ) + quarter_result = await db.execute(quarter_stmt) + quarter_count = quarter_result.scalar() or 0 + + by_time = { + "week": week_count, + "month": month_count, + "quarter": quarter_count + } + + return { + "total": total, + "by_course": by_course, + "by_type": by_type, + "by_time": by_time + } + diff --git a/backend/app/services/exam_service.py b/backend/app/services/exam_service.py new file mode 100644 index 0000000..55c82e6 --- /dev/null +++ b/backend/app/services/exam_service.py @@ -0,0 +1,439 @@ +""" +考试服务层 +""" +import json +import random +from datetime import datetime, timedelta +from typing import List, Optional, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, or_, desc +from app.models.exam import Exam, Question, ExamResult +from app.models.exam_mistake import ExamMistake +from app.models.course import Course, KnowledgePoint +from app.core.exceptions import BusinessException, ErrorCode + + +class ExamService: + """考试服务类""" + + @staticmethod + async def start_exam( + db: AsyncSession, user_id: int, course_id: int, question_count: int = 10 + ) -> Exam: + """ + 开始考试 + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID + question_count: 题目数量 + + Returns: + Exam: 考试实例 + """ + # 检查课程是否存在 + course = await db.get(Course, course_id) + if not course: + raise BusinessException(error_code=ErrorCode.NOT_FOUND, message="课程不存在") + + # 获取该课程的所有可用题目 + stmt = select(Question).where( + and_(Question.course_id == course_id, Question.is_active == True) + ) + result = await db.execute(stmt) + all_questions = result.scalars().all() + + if not all_questions: + raise BusinessException( + error_code=ErrorCode.VALIDATION_ERROR, message="该课程暂无题目" + ) + + # 随机选择题目 + selected_questions = random.sample( + all_questions, min(question_count, len(all_questions)) + ) + + # 构建题目数据 + questions_data = [] + for q in selected_questions: + question_data = { + "id": str(q.id), + "type": q.question_type, + "title": q.title, + "content": q.content, + "options": q.options, + "score": q.score, + } + questions_data.append(question_data) + + # 创建考试记录 + exam = Exam( + user_id=user_id, + course_id=course_id, + exam_name=f"{course.name} - 随机测试", + question_count=len(selected_questions), + total_score=sum(q.score for q in selected_questions), + pass_score=sum(q.score for q in selected_questions) * 0.6, + duration_minutes=60, + status="started", + questions={"questions": questions_data}, + ) + + db.add(exam) + await db.commit() + await db.refresh(exam) + + return exam + + @staticmethod + async def submit_exam( + db: AsyncSession, user_id: int, exam_id: int, answers: List[Dict[str, str]] + ) -> Dict[str, Any]: + """ + 提交考试答案 + + Args: + db: 数据库会话 + user_id: 用户ID + exam_id: 考试ID + answers: 答案列表 + + Returns: + Dict: 考试结果 + """ + # 获取考试记录 + stmt = select(Exam).where(and_(Exam.id == exam_id, Exam.user_id == user_id)) + result = await db.execute(stmt) + exam = result.scalar_one_or_none() + + if not exam: + raise BusinessException(error_code=ErrorCode.NOT_FOUND, message="考试记录不存在") + + if exam.status != "started": + raise BusinessException( + error_code=ErrorCode.VALIDATION_ERROR, message="考试已结束或已提交" + ) + + # 检查考试是否超时 + if datetime.now() > exam.start_time + timedelta( + minutes=exam.duration_minutes + ): + exam.status = "timeout" + await db.commit() + raise BusinessException( + error_code=ErrorCode.VALIDATION_ERROR, message="考试已超时" + ) + + # 处理答案 + answers_dict = {ans["question_id"]: ans["answer"] for ans in answers} + total_score = 0.0 + correct_count = 0 + + # 批量获取题目 + question_ids = [int(ans["question_id"]) for ans in answers] + stmt = select(Question).where(Question.id.in_(question_ids)) + result = await db.execute(stmt) + questions_map = {str(q.id): q for q in result.scalars().all()} + + # 创建答题结果记录 + for question_data in exam.questions["questions"]: + question_id = question_data["id"] + question = questions_map.get(question_id) + + if not question: + continue + + user_answer = answers_dict.get(question_id, "") + is_correct = user_answer == question.correct_answer + + if is_correct: + total_score += question.score + correct_count += 1 + + # 创建答题结果记录 + exam_result = ExamResult( + exam_id=exam_id, + question_id=int(question_id), + user_answer=user_answer, + is_correct=is_correct, + score=question.score if is_correct else 0.0, + ) + db.add(exam_result) + + # 更新题目使用统计 + question.usage_count += 1 + if is_correct: + question.correct_count += 1 + + # 更新考试记录 + exam.end_time = datetime.now() + exam.score = total_score + exam.is_passed = total_score >= exam.pass_score + exam.status = "submitted" + exam.answers = {"answers": answers} + + await db.commit() + + return { + "exam_id": exam_id, + "total_score": total_score, + "pass_score": exam.pass_score, + "is_passed": exam.is_passed, + "correct_count": correct_count, + "total_count": exam.question_count, + "accuracy": correct_count / exam.question_count + if exam.question_count > 0 + else 0, + } + + @staticmethod + async def get_exam_detail( + db: AsyncSession, user_id: int, exam_id: int + ) -> Dict[str, Any]: + """ + 获取考试详情 + + Args: + db: 数据库会话 + user_id: 用户ID + exam_id: 考试ID + + Returns: + Dict: 考试详情 + """ + # 获取考试记录 + stmt = select(Exam).where(and_(Exam.id == exam_id, Exam.user_id == user_id)) + result = await db.execute(stmt) + exam = result.scalar_one_or_none() + + if not exam: + raise BusinessException(error_code=ErrorCode.NOT_FOUND, message="考试记录不存在") + + # 构建返回数据 + exam_data = { + "id": exam.id, + "course_id": exam.course_id, + "exam_name": exam.exam_name, + "question_count": exam.question_count, + "total_score": exam.total_score, + "pass_score": exam.pass_score, + "start_time": exam.start_time.isoformat() if exam.start_time else None, + "end_time": exam.end_time.isoformat() if exam.end_time else None, + "duration_minutes": exam.duration_minutes, + "status": exam.status, + "score": exam.score, + "is_passed": exam.is_passed, + "questions": exam.questions, + } + + # 如果考试已提交,获取答题详情 + if exam.status == "submitted" and exam.answers: + stmt = select(ExamResult).where(ExamResult.exam_id == exam_id) + result = await db.execute(stmt) + results = result.scalars().all() + + results_data = [] + for r in results: + results_data.append( + { + "question_id": r.question_id, + "user_answer": r.user_answer, + "is_correct": r.is_correct, + "score": r.score, + } + ) + + exam_data["results"] = results_data + exam_data["answers"] = exam.answers + + return exam_data + + @staticmethod + async def get_exam_records( + db: AsyncSession, + user_id: int, + page: int = 1, + size: int = 10, + course_id: Optional[int] = None, + ) -> Dict[str, Any]: + """ + 获取考试记录列表(包含统计数据) + + Args: + db: 数据库会话 + user_id: 用户ID + page: 页码 + size: 每页数量 + course_id: 课程ID(可选) + + Returns: + Dict: 考试记录列表(包含统计信息) + """ + # 构建查询条件 + conditions = [Exam.user_id == user_id] + if course_id: + conditions.append(Exam.course_id == course_id) + + # 查询总数 + count_stmt = select(func.count(Exam.id)).where(and_(*conditions)) + total = await db.scalar(count_stmt) + + # 查询考试数据(JOIN courses表获取课程名称) + offset = (page - 1) * size + stmt = ( + select(Exam, Course.name.label("course_name")) + .join(Course, Exam.course_id == Course.id) + .where(and_(*conditions)) + .order_by(Exam.created_at.desc()) + .offset(offset) + .limit(size) + ) + result = await db.execute(stmt) + rows = result.all() + + # 构建返回数据 + items = [] + for exam, course_name in rows: + # 1. 计算用时 + duration_seconds = None + if exam.start_time and exam.end_time: + duration_seconds = int((exam.end_time - exam.start_time).total_seconds()) + + # 2. 统计错题数 + mistakes_stmt = select(func.count(ExamMistake.id)).where( + ExamMistake.exam_id == exam.id + ) + wrong_count = await db.scalar(mistakes_stmt) or 0 + + # 3. 计算正确数和正确率 + correct_count = exam.question_count - wrong_count if exam.question_count else 0 + accuracy = None + if exam.question_count and exam.question_count > 0: + accuracy = round((correct_count / exam.question_count) * 100, 1) + + # 4. 分题型统计 + question_type_stats = [] + if exam.questions: + try: + # 解析questions JSON,统计每种题型的总数 + questions_data = json.loads(exam.questions) if isinstance(exam.questions, str) else exam.questions + type_totals = {} + type_scores = {} # 存储每种题型的总分 + + for q in questions_data: + q_type = q.get("type", "unknown") + q_score = q.get("score", 0) + type_totals[q_type] = type_totals.get(q_type, 0) + 1 + type_scores[q_type] = type_scores.get(q_type, 0) + q_score + + # 查询错题按题型统计 + mistakes_by_type_stmt = ( + select(ExamMistake.question_type, func.count(ExamMistake.id)) + .where(ExamMistake.exam_id == exam.id) + .group_by(ExamMistake.question_type) + ) + mistakes_by_type_result = await db.execute(mistakes_by_type_stmt) + mistakes_by_type = dict(mistakes_by_type_result.all()) + + # 题型名称映射 + type_name_map = { + "single": "单选题", + "multiple": "多选题", + "judge": "判断题", + "blank": "填空题", + "essay": "问答题" + } + + # 组装分题型统计 + for q_type, total in type_totals.items(): + wrong = mistakes_by_type.get(q_type, 0) + correct = total - wrong + type_accuracy = round((correct / total) * 100, 1) if total > 0 else 0 + + question_type_stats.append({ + "type": type_name_map.get(q_type, q_type), + "type_code": q_type, + "total": total, + "correct": correct, + "wrong": wrong, + "accuracy": type_accuracy, + "total_score": type_scores.get(q_type, 0) + }) + except (json.JSONDecodeError, TypeError, KeyError) as e: + # 如果JSON解析失败,返回空统计 + question_type_stats = [] + + items.append( + { + "id": exam.id, + "course_id": exam.course_id, + "course_name": course_name, + "exam_name": exam.exam_name, + "question_count": exam.question_count, + "total_score": exam.total_score, + "score": exam.score, + "is_passed": exam.is_passed, + "status": exam.status, + "start_time": exam.start_time.isoformat() if exam.start_time else None, + "end_time": exam.end_time.isoformat() if exam.end_time else None, + "created_at": exam.created_at.isoformat(), + # 统计字段 + "accuracy": accuracy, + "correct_count": correct_count, + "wrong_count": wrong_count, + "duration_seconds": duration_seconds, + "question_type_stats": question_type_stats, + } + ) + + return { + "items": items, + "total": total, + "page": page, + "size": size, + "pages": (total + size - 1) // size, + } + + @staticmethod + async def get_exam_statistics( + db: AsyncSession, user_id: int, course_id: Optional[int] = None + ) -> Dict[str, Any]: + """ + 获取考试统计信息 + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID(可选) + + Returns: + Dict: 统计信息 + """ + # 构建查询条件 + conditions = [Exam.user_id == user_id, Exam.status == "submitted"] + if course_id: + conditions.append(Exam.course_id == course_id) + + # 查询统计数据 + stmt = select( + func.count(Exam.id).label("total_exams"), + func.count(func.nullif(Exam.is_passed, False)).label("passed_exams"), + func.avg(Exam.score).label("avg_score"), + func.max(Exam.score).label("max_score"), + func.min(Exam.score).label("min_score"), + ).where(and_(*conditions)) + + result = await db.execute(stmt) + stats = result.one() + + return { + "total_exams": stats.total_exams or 0, + "passed_exams": stats.passed_exams or 0, + "pass_rate": (stats.passed_exams / stats.total_exams * 100) + if stats.total_exams > 0 + else 0, + "avg_score": float(stats.avg_score or 0), + "max_score": float(stats.max_score or 0), + "min_score": float(stats.min_score or 0), + } diff --git a/backend/app/services/external/__init__.py b/backend/app/services/external/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 0000000..bc9e79c --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,330 @@ +""" +站内消息通知服务 +提供通知的CRUD操作和业务逻辑 +""" +from typing import List, Optional, Tuple +from sqlalchemy import select, and_, desc, func, update +from sqlalchemy.orm import selectinload +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logger import get_logger +from app.models.notification import Notification +from app.models.user import User +from app.schemas.notification import ( + NotificationCreate, + NotificationBatchCreate, + NotificationResponse, + NotificationType, +) +from app.services.base_service import BaseService + +logger = get_logger(__name__) + + +class NotificationService(BaseService[Notification]): + """ + 站内消息通知服务 + + 提供通知的创建、查询、标记已读等功能 + """ + + def __init__(self): + super().__init__(Notification) + + async def create_notification( + self, + db: AsyncSession, + notification_in: NotificationCreate + ) -> Notification: + """ + 创建单个通知 + + Args: + db: 数据库会话 + notification_in: 通知创建数据 + + Returns: + 创建的通知对象 + """ + notification = Notification( + user_id=notification_in.user_id, + title=notification_in.title, + content=notification_in.content, + type=notification_in.type.value if isinstance(notification_in.type, NotificationType) else notification_in.type, + related_id=notification_in.related_id, + related_type=notification_in.related_type, + sender_id=notification_in.sender_id, + is_read=False + ) + + db.add(notification) + await db.commit() + await db.refresh(notification) + + logger.info( + "创建通知成功", + notification_id=notification.id, + user_id=notification_in.user_id, + type=notification_in.type + ) + + return notification + + async def batch_create_notifications( + self, + db: AsyncSession, + batch_in: NotificationBatchCreate + ) -> List[Notification]: + """ + 批量创建通知(发送给多个用户) + + Args: + db: 数据库会话 + batch_in: 批量通知创建数据 + + Returns: + 创建的通知列表 + """ + notifications = [] + notification_type = batch_in.type.value if isinstance(batch_in.type, NotificationType) else batch_in.type + + for user_id in batch_in.user_ids: + notification = Notification( + user_id=user_id, + title=batch_in.title, + content=batch_in.content, + type=notification_type, + related_id=batch_in.related_id, + related_type=batch_in.related_type, + sender_id=batch_in.sender_id, + is_read=False + ) + notifications.append(notification) + db.add(notification) + + await db.commit() + + # 刷新所有对象 + for notification in notifications: + await db.refresh(notification) + + logger.info( + "批量创建通知成功", + count=len(notifications), + user_ids=batch_in.user_ids, + type=batch_in.type + ) + + return notifications + + async def get_user_notifications( + self, + db: AsyncSession, + user_id: int, + skip: int = 0, + limit: int = 20, + is_read: Optional[bool] = None, + notification_type: Optional[str] = None + ) -> Tuple[List[NotificationResponse], int, int]: + """ + 获取用户的通知列表 + + Args: + db: 数据库会话 + user_id: 用户ID + skip: 跳过数量 + limit: 返回数量 + is_read: 是否已读筛选 + notification_type: 通知类型筛选 + + Returns: + (通知列表, 总数, 未读数) + """ + # 构建基础查询条件 + conditions = [Notification.user_id == user_id] + + if is_read is not None: + conditions.append(Notification.is_read == is_read) + + if notification_type: + conditions.append(Notification.type == notification_type) + + # 查询通知列表(带发送者信息) + stmt = ( + select(Notification) + .where(and_(*conditions)) + .order_by(desc(Notification.created_at)) + .offset(skip) + .limit(limit) + ) + + result = await db.execute(stmt) + notifications = result.scalars().all() + + # 统计总数 + count_stmt = select(func.count()).select_from(Notification).where(and_(*conditions)) + total_result = await db.execute(count_stmt) + total = total_result.scalar_one() + + # 统计未读数 + unread_stmt = ( + select(func.count()) + .select_from(Notification) + .where(and_(Notification.user_id == user_id, Notification.is_read == False)) + ) + unread_result = await db.execute(unread_stmt) + unread_count = unread_result.scalar_one() + + # 获取发送者信息 + sender_ids = [n.sender_id for n in notifications if n.sender_id] + sender_names = {} + if sender_ids: + sender_stmt = select(User.id, User.full_name).where(User.id.in_(sender_ids)) + sender_result = await db.execute(sender_stmt) + sender_names = {row[0]: row[1] for row in sender_result.fetchall()} + + # 构建响应 + responses = [] + for notification in notifications: + response = NotificationResponse( + id=notification.id, + user_id=notification.user_id, + title=notification.title, + content=notification.content, + type=notification.type, + is_read=notification.is_read, + related_id=notification.related_id, + related_type=notification.related_type, + sender_id=notification.sender_id, + sender_name=sender_names.get(notification.sender_id) if notification.sender_id else None, + created_at=notification.created_at, + updated_at=notification.updated_at + ) + responses.append(response) + + return responses, total, unread_count + + async def get_unread_count( + self, + db: AsyncSession, + user_id: int + ) -> Tuple[int, int]: + """ + 获取用户未读通知数量 + + Args: + db: 数据库会话 + user_id: 用户ID + + Returns: + (未读数, 总数) + """ + # 统计未读数 + unread_stmt = ( + select(func.count()) + .select_from(Notification) + .where(and_(Notification.user_id == user_id, Notification.is_read == False)) + ) + unread_result = await db.execute(unread_stmt) + unread_count = unread_result.scalar_one() + + # 统计总数 + total_stmt = ( + select(func.count()) + .select_from(Notification) + .where(Notification.user_id == user_id) + ) + total_result = await db.execute(total_stmt) + total = total_result.scalar_one() + + return unread_count, total + + async def mark_as_read( + self, + db: AsyncSession, + user_id: int, + notification_ids: Optional[List[int]] = None + ) -> int: + """ + 标记通知为已读 + + Args: + db: 数据库会话 + user_id: 用户ID + notification_ids: 通知ID列表,为空则标记全部 + + Returns: + 更新的数量 + """ + conditions = [ + Notification.user_id == user_id, + Notification.is_read == False + ] + + if notification_ids: + conditions.append(Notification.id.in_(notification_ids)) + + stmt = ( + update(Notification) + .where(and_(*conditions)) + .values(is_read=True) + ) + + result = await db.execute(stmt) + await db.commit() + + updated_count = result.rowcount + + logger.info( + "标记通知已读", + user_id=user_id, + notification_ids=notification_ids, + updated_count=updated_count + ) + + return updated_count + + async def delete_notification( + self, + db: AsyncSession, + user_id: int, + notification_id: int + ) -> bool: + """ + 删除通知 + + Args: + db: 数据库会话 + user_id: 用户ID + notification_id: 通知ID + + Returns: + 是否删除成功 + """ + stmt = select(Notification).where( + and_( + Notification.id == notification_id, + Notification.user_id == user_id + ) + ) + + result = await db.execute(stmt) + notification = result.scalar_one_or_none() + + if notification: + await db.delete(notification) + await db.commit() + + logger.info( + "删除通知成功", + notification_id=notification_id, + user_id=user_id + ) + return True + + return False + + +# 创建服务实例 +notification_service = NotificationService() + diff --git a/backend/app/services/scrm_service.py b/backend/app/services/scrm_service.py new file mode 100644 index 0000000..b657848 --- /dev/null +++ b/backend/app/services/scrm_service.py @@ -0,0 +1,356 @@ +""" +SCRM 系统对接服务 + +提供给 SCRM 系统调用的数据查询服务 +""" + +import logging +from typing import List, Optional, Dict, Any +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.user import User +from app.models.position import Position +from app.models.position_member import PositionMember +from app.models.position_course import PositionCourse +from app.models.course import Course, KnowledgePoint, CourseMaterial + +logger = logging.getLogger(__name__) + + +class SCRMService: + """SCRM 系统数据查询服务""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def get_employee_position( + self, + userid: Optional[str] = None, + name: Optional[str] = None + ) -> Optional[Dict[str, Any]]: + """ + 根据企微 userid 或员工姓名获取员工岗位信息 + + Args: + userid: 企微员工 userid(可选) + name: 员工姓名(可选,支持模糊匹配) + + Returns: + 员工岗位信息字典,包含 employee_id, userid, name, positions + 如果员工不存在返回 None + 如果按姓名搜索有多个结果,返回列表 + """ + query = ( + select(User) + .options(selectinload(User.position_memberships).selectinload(PositionMember.position)) + .where(User.is_deleted.is_(False)) + ) + + # 优先按 wework_userid 精确匹配 + if userid: + query = query.where(User.wework_userid == userid) + result = await self.db.execute(query) + user = result.scalar_one_or_none() + + if user: + return self._build_employee_position_data(user) + + # 其次按姓名匹配(支持精确匹配和模糊匹配) + if name: + # 先尝试精确匹配 + exact_query = query.where(User.full_name == name) + result = await self.db.execute(exact_query) + users = result.scalars().all() + + # 如果精确匹配没有结果,尝试模糊匹配 + if not users: + fuzzy_query = query.where(User.full_name.ilike(f"%{name}%")) + result = await self.db.execute(fuzzy_query) + users = result.scalars().all() + + if len(users) == 1: + return self._build_employee_position_data(users[0]) + elif len(users) > 1: + # 多个匹配结果,返回列表供选择 + return { + "multiple_matches": True, + "count": len(users), + "employees": [ + { + "employee_id": u.id, + "userid": u.wework_userid, + "name": u.full_name or u.username, + "phone": u.phone[-4:] if u.phone else None # 只显示手机号后4位 + } + for u in users + ] + } + + return None + + def _build_employee_position_data(self, user: User) -> Dict[str, Any]: + """构建员工岗位数据""" + positions = [] + for i, pm in enumerate(user.position_memberships): + if pm.is_deleted or pm.position.is_deleted: + continue + positions.append({ + "position_id": pm.position.id, + "position_name": pm.position.name, + "is_primary": i == 0, # 第一个为主岗位 + "joined_at": pm.joined_at.strftime("%Y-%m-%d") if pm.joined_at else None + }) + + return { + "employee_id": user.id, + "userid": user.wework_userid, + "name": user.full_name or user.username, + "positions": positions + } + + async def get_employee_position_by_id(self, employee_id: int) -> Optional[Dict[str, Any]]: + """ + 根据员工ID获取岗位信息 + + Args: + employee_id: 员工ID(users表主键) + + Returns: + 员工岗位信息字典 + """ + result = await self.db.execute( + select(User) + .options(selectinload(User.position_memberships).selectinload(PositionMember.position)) + .where(User.id == employee_id, User.is_deleted == False) + ) + user = result.scalar_one_or_none() + + if not user: + return None + + return self._build_employee_position_data(user) + + async def get_position_courses( + self, + position_id: int, + course_type: Optional[str] = None + ) -> Optional[Dict[str, Any]]: + """ + 获取指定岗位的课程列表 + + Args: + position_id: 岗位ID + course_type: 课程类型筛选(required/optional/all) + + Returns: + 岗位课程信息字典,包含 position_id, position_name, courses + 如果岗位不存在返回 None + """ + # 查询岗位 + position_result = await self.db.execute( + select(Position).where(Position.id == position_id, Position.is_deleted.is_(False)) + ) + position = position_result.scalar_one_or_none() + + if not position: + return None + + # 查询岗位课程关联 + query = ( + select(PositionCourse, Course) + .join(Course, PositionCourse.course_id == Course.id) + .where( + PositionCourse.position_id == position_id, + PositionCourse.is_deleted.is_(False), + Course.is_deleted.is_(False), + Course.status == "published" # 只返回已发布的课程 + ) + .order_by(PositionCourse.priority.desc()) + ) + + # 课程类型筛选 + if course_type and course_type != "all": + query = query.where(PositionCourse.course_type == course_type) + + result = await self.db.execute(query) + pc_courses = result.all() + + # 构建课程列表,并统计知识点数量 + courses = [] + for pc, course in pc_courses: + # 统计该课程的知识点数量 + kp_count_result = await self.db.execute( + select(func.count(KnowledgePoint.id)) + .where( + KnowledgePoint.course_id == course.id, + KnowledgePoint.is_deleted.is_(False) + ) + ) + kp_count = kp_count_result.scalar() or 0 + + courses.append({ + "course_id": course.id, + "course_name": course.name, + "course_type": pc.course_type, + "priority": pc.priority, + "knowledge_point_count": kp_count + }) + + return { + "position_id": position.id, + "position_name": position.name, + "courses": courses + } + + async def search_knowledge_points( + self, + keywords: List[str], + position_id: Optional[int] = None, + course_ids: Optional[List[int]] = None, + knowledge_type: Optional[str] = None, + limit: int = 10 + ) -> Dict[str, Any]: + """ + 搜索知识点 + + Args: + keywords: 搜索关键词列表 + position_id: 岗位ID(用于优先排序) + course_ids: 限定课程范围 + knowledge_type: 知识点类型筛选 + limit: 返回数量限制 + + Returns: + 搜索结果字典,包含 total 和 items + """ + # 基础查询 + query = ( + select(KnowledgePoint, Course) + .join(Course, KnowledgePoint.course_id == Course.id) + .where( + KnowledgePoint.is_deleted.is_(False), + Course.is_deleted.is_(False), + Course.status == "published" + ) + ) + + # 关键词搜索条件(在名称和描述中搜索) + keyword_conditions = [] + for keyword in keywords: + keyword_conditions.append( + or_( + KnowledgePoint.name.ilike(f"%{keyword}%"), + KnowledgePoint.description.ilike(f"%{keyword}%") + ) + ) + if keyword_conditions: + query = query.where(or_(*keyword_conditions)) + + # 课程范围筛选 + if course_ids: + query = query.where(KnowledgePoint.course_id.in_(course_ids)) + + # 知识点类型筛选 + if knowledge_type: + query = query.where(KnowledgePoint.type == knowledge_type) + + # 如果指定了岗位,优先返回该岗位相关课程的知识点 + if position_id: + # 获取该岗位的课程ID列表 + pos_course_result = await self.db.execute( + select(PositionCourse.course_id) + .where( + PositionCourse.position_id == position_id, + PositionCourse.is_deleted.is_(False) + ) + ) + pos_course_ids = [row[0] for row in pos_course_result.all()] + + if pos_course_ids: + # 使用 CASE WHEN 进行排序:岗位相关课程优先 + from sqlalchemy import case + priority_order = case( + (KnowledgePoint.course_id.in_(pos_course_ids), 0), + else_=1 + ) + query = query.order_by(priority_order, KnowledgePoint.id.desc()) + else: + query = query.order_by(KnowledgePoint.id.desc()) + else: + query = query.order_by(KnowledgePoint.id.desc()) + + # 执行查询 + result = await self.db.execute(query.limit(limit)) + kp_courses = result.all() + + # 计算相关度分数(简单实现:匹配的关键词越多分数越高) + def calc_relevance(kp: KnowledgePoint) -> float: + text = f"{kp.name} {kp.description or ''}" + matched = sum(1 for kw in keywords if kw.lower() in text.lower()) + return round(matched / len(keywords), 2) if keywords else 1.0 + + # 构建结果 + items = [] + for kp, course in kp_courses: + items.append({ + "knowledge_point_id": kp.id, + "name": kp.name, + "course_id": course.id, + "course_name": course.name, + "type": kp.type, + "relevance_score": calc_relevance(kp) + }) + + # 按相关度分数排序 + items.sort(key=lambda x: x["relevance_score"], reverse=True) + + return { + "total": len(items), + "items": items + } + + async def get_knowledge_point_detail(self, kp_id: int) -> Optional[Dict[str, Any]]: + """ + 获取知识点详情 + + Args: + kp_id: 知识点ID + + Returns: + 知识点详情字典 + 如果知识点不存在返回 None + """ + # 查询知识点及关联的课程和资料 + result = await self.db.execute( + select(KnowledgePoint, Course, CourseMaterial) + .join(Course, KnowledgePoint.course_id == Course.id) + .outerjoin(CourseMaterial, KnowledgePoint.material_id == CourseMaterial.id) + .where( + KnowledgePoint.id == kp_id, + KnowledgePoint.is_deleted.is_(False) + ) + ) + row = result.one_or_none() + + if not row: + return None + + kp, course, material = row + + return { + "knowledge_point_id": kp.id, + "name": kp.name, + "course_id": course.id, + "course_name": course.name, + "type": kp.type, + "content": kp.description or "", # description 作为知识点内容 + "material_id": material.id if material else None, + "material_type": material.file_type if material else None, + "material_url": material.file_url if material else None, + "topic_relation": kp.topic_relation, + "source": kp.source, + "created_at": kp.created_at.strftime("%Y-%m-%d %H:%M:%S") if kp.created_at else None + } + diff --git a/backend/app/services/statistics_service.py b/backend/app/services/statistics_service.py new file mode 100644 index 0000000..06c436e --- /dev/null +++ b/backend/app/services/statistics_service.py @@ -0,0 +1,708 @@ +""" +统计分析服务 +""" +from datetime import datetime, timedelta +from typing import List, Optional, Dict, Any, Tuple +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, or_, case, desc, distinct +from app.models.exam import Exam, Question +from app.models.exam_mistake import ExamMistake +from app.models.course import Course, KnowledgePoint +from app.models.practice import PracticeSession +from app.models.training import TrainingSession +from app.core.logger import get_logger + +logger = get_logger(__name__) + + +class StatisticsService: + """统计分析服务类""" + + @staticmethod + def _get_date_range(period: str) -> Tuple[datetime, datetime]: + """ + 根据period返回开始和结束日期 + + Args: + period: 时间范围 (week/month/quarter/halfYear/year) + + Returns: + Tuple[datetime, datetime]: (开始日期, 结束日期) + """ + end_date = datetime.now() + + if period == "week": + start_date = end_date - timedelta(days=7) + elif period == "month": + start_date = end_date - timedelta(days=30) + elif period == "quarter": + start_date = end_date - timedelta(days=90) + elif period == "halfYear": + start_date = end_date - timedelta(days=180) + elif period == "year": + start_date = end_date - timedelta(days=365) + else: + # 默认一个月 + start_date = end_date - timedelta(days=30) + + return start_date, end_date + + @staticmethod + def _calculate_change_rate(current: float, previous: float) -> float: + """ + 计算环比变化率 + + Args: + current: 当前值 + previous: 上期值 + + Returns: + float: 变化率(百分比) + """ + if previous == 0: + return 0 if current == 0 else 100 + return round(((current - previous) / previous) * 100, 1) + + @staticmethod + async def get_key_metrics( + db: AsyncSession, + user_id: int, + course_id: Optional[int] = None, + period: str = "month" + ) -> Dict[str, Any]: + """ + 获取关键指标 + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID(可选) + period: 时间范围 + + Returns: + Dict: 包含学习效率、知识覆盖率、平均用时、进步速度的指标数据 + """ + logger.info(f"获取关键指标 - user_id: {user_id}, course_id: {course_id}, period: {period}") + + start_date, end_date = StatisticsService._get_date_range(period) + + # 构建基础查询条件 + exam_conditions = [ + Exam.user_id == user_id, + Exam.start_time >= start_date, + Exam.start_time <= end_date, + Exam.round1_score.isnot(None) + ] + if course_id: + exam_conditions.append(Exam.course_id == course_id) + + # 1. 学习效率 = (总题数 - 错题数) / 总题数 + # 获取总题数 + total_questions_stmt = select( + func.coalesce(func.sum(Exam.question_count), 0) + ).where(and_(*exam_conditions)) + total_questions = await db.scalar(total_questions_stmt) or 0 + + # 获取错题数 + mistake_conditions = [ExamMistake.user_id == user_id] + if course_id: + mistake_conditions.append( + ExamMistake.exam_id.in_( + select(Exam.id).where(Exam.course_id == course_id) + ) + ) + mistake_stmt = select(func.count(ExamMistake.id)).where( + and_(*mistake_conditions) + ) + mistake_count = await db.scalar(mistake_stmt) or 0 + + # 计算学习效率 + learning_efficiency = 0.0 + if total_questions > 0: + correct_questions = total_questions - mistake_count + learning_efficiency = round((correct_questions / total_questions) * 100, 1) + + # 计算上期学习效率(用于环比) + prev_start_date = start_date - (end_date - start_date) + prev_exam_conditions = [ + Exam.user_id == user_id, + Exam.start_time >= prev_start_date, + Exam.start_time < start_date, + Exam.round1_score.isnot(None) + ] + if course_id: + prev_exam_conditions.append(Exam.course_id == course_id) + + prev_total_questions = await db.scalar( + select(func.coalesce(func.sum(Exam.question_count), 0)).where( + and_(*prev_exam_conditions) + ) + ) or 0 + + prev_mistake_count = await db.scalar( + select(func.count(ExamMistake.id)).where( + and_( + ExamMistake.user_id == user_id, + ExamMistake.exam_id.in_( + select(Exam.id).where(and_(*prev_exam_conditions)) + ) + ) + ) + ) or 0 + + prev_efficiency = 0.0 + if prev_total_questions > 0: + prev_correct = prev_total_questions - prev_mistake_count + prev_efficiency = (prev_correct / prev_total_questions) * 100 + + efficiency_change = StatisticsService._calculate_change_rate( + learning_efficiency, prev_efficiency + ) + + # 2. 知识覆盖率 = 已掌握知识点数 / 总知识点数 + # 获取总知识点数 + kp_conditions = [] + if course_id: + kp_conditions.append(KnowledgePoint.course_id == course_id) + + total_kp_stmt = select(func.count(KnowledgePoint.id)).where( + and_(KnowledgePoint.is_deleted == False, *kp_conditions) + ) + total_kp = await db.scalar(total_kp_stmt) or 0 + + # 获取错误的知识点数(至少错过一次的) + mistake_kp_stmt = select( + func.count(distinct(ExamMistake.knowledge_point_id)) + ).where( + and_( + ExamMistake.user_id == user_id, + ExamMistake.knowledge_point_id.isnot(None), + *([ExamMistake.exam_id.in_( + select(Exam.id).where(Exam.course_id == course_id) + )] if course_id else []) + ) + ) + mistake_kp = await db.scalar(mistake_kp_stmt) or 0 + + # 计算知识覆盖率(掌握的知识点 = 总知识点 - 错误知识点) + knowledge_coverage = 0.0 + if total_kp > 0: + mastered_kp = max(0, total_kp - mistake_kp) + knowledge_coverage = round((mastered_kp / total_kp) * 100, 1) + + # 上期知识覆盖率(简化:假设知识点总数不变) + prev_mistake_kp = await db.scalar( + select(func.count(distinct(ExamMistake.knowledge_point_id))).where( + and_( + ExamMistake.user_id == user_id, + ExamMistake.knowledge_point_id.isnot(None), + ExamMistake.exam_id.in_( + select(Exam.id).where(and_(*prev_exam_conditions)) + ) + ) + ) + ) or 0 + + prev_coverage = 0.0 + if total_kp > 0: + prev_mastered = max(0, total_kp - prev_mistake_kp) + prev_coverage = (prev_mastered / total_kp) * 100 + + coverage_change = StatisticsService._calculate_change_rate( + knowledge_coverage, prev_coverage + ) + + # 3. 平均用时 = 总考试时长 / 总题数 + total_duration_stmt = select( + func.coalesce(func.sum(Exam.duration_minutes), 0) + ).where(and_(*exam_conditions)) + total_duration = await db.scalar(total_duration_stmt) or 0 + + avg_time_per_question = 0.0 + if total_questions > 0: + avg_time_per_question = round((total_duration / total_questions), 1) + + # 上期平均用时 + prev_total_duration = await db.scalar( + select(func.coalesce(func.sum(Exam.duration_minutes), 0)).where( + and_(*prev_exam_conditions) + ) + ) or 0 + + prev_avg_time = 0.0 + if prev_total_questions > 0: + prev_avg_time = prev_total_duration / prev_total_questions + + # 平均用时的环比是负增长表示好(时间减少) + time_change = StatisticsService._calculate_change_rate( + avg_time_per_question, prev_avg_time + ) + + # 4. 进步速度 = (本期平均分 - 上期平均分) / 上期平均分 + avg_score_stmt = select(func.avg(Exam.round1_score)).where( + and_(*exam_conditions) + ) + avg_score = await db.scalar(avg_score_stmt) or 0 + + prev_avg_score_stmt = select(func.avg(Exam.round1_score)).where( + and_(*prev_exam_conditions) + ) + prev_avg_score = await db.scalar(prev_avg_score_stmt) or 0 + + progress_speed = StatisticsService._calculate_change_rate( + float(avg_score), float(prev_avg_score) + ) + + return { + "learningEfficiency": { + "value": learning_efficiency, + "unit": "%", + "change": efficiency_change, + "description": "正确题数/总练习题数" + }, + "knowledgeCoverage": { + "value": knowledge_coverage, + "unit": "%", + "change": coverage_change, + "description": "已掌握知识点/总知识点" + }, + "avgTimePerQuestion": { + "value": avg_time_per_question, + "unit": "分/题", + "change": time_change, + "description": "平均每道题的答题时间" + }, + "progressSpeed": { + "value": abs(progress_speed), + "unit": "%", + "change": progress_speed, + "description": "成绩提升速度" + } + } + + @staticmethod + async def get_score_distribution( + db: AsyncSession, + user_id: int, + course_id: Optional[int] = None, + period: str = "month" + ) -> Dict[str, Any]: + """ + 获取成绩分布统计 + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID(可选) + period: 时间范围 + + Returns: + Dict: 成绩分布数据(优秀、良好、中等、及格、不及格) + """ + logger.info(f"获取成绩分布 - user_id: {user_id}, course_id: {course_id}, period: {period}") + + start_date, end_date = StatisticsService._get_date_range(period) + + # 构建查询条件 + conditions = [ + Exam.user_id == user_id, + Exam.start_time >= start_date, + Exam.start_time <= end_date, + Exam.round1_score.isnot(None) + ] + if course_id: + conditions.append(Exam.course_id == course_id) + + # 使用case when统计各分数段的数量 + stmt = select( + func.count(case((Exam.round1_score >= 90, 1))).label("excellent"), + func.count(case((and_(Exam.round1_score >= 80, Exam.round1_score < 90), 1))).label("good"), + func.count(case((and_(Exam.round1_score >= 70, Exam.round1_score < 80), 1))).label("medium"), + func.count(case((and_(Exam.round1_score >= 60, Exam.round1_score < 70), 1))).label("pass_count"), + func.count(case((Exam.round1_score < 60, 1))).label("fail") + ).where(and_(*conditions)) + + result = await db.execute(stmt) + row = result.one() + + return { + "excellent": row.excellent or 0, # 优秀(90-100) + "good": row.good or 0, # 良好(80-89) + "medium": row.medium or 0, # 中等(70-79) + "pass": row.pass_count or 0, # 及格(60-69) + "fail": row.fail or 0 # 不及格(<60) + } + + @staticmethod + async def get_difficulty_analysis( + db: AsyncSession, + user_id: int, + course_id: Optional[int] = None, + period: str = "month" + ) -> Dict[str, Any]: + """ + 获取题目难度分析 + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID(可选) + period: 时间范围 + + Returns: + Dict: 各难度题目的正确率统计 + """ + logger.info(f"获取难度分析 - user_id: {user_id}, course_id: {course_id}, period: {period}") + + start_date, end_date = StatisticsService._get_date_range(period) + + # 获取用户在时间范围内的考试 + exam_conditions = [ + Exam.user_id == user_id, + Exam.start_time >= start_date, + Exam.start_time <= end_date + ] + if course_id: + exam_conditions.append(Exam.course_id == course_id) + + exam_ids_stmt = select(Exam.id).where(and_(*exam_conditions)) + result = await db.execute(exam_ids_stmt) + exam_ids = [row[0] for row in result.all()] + + if not exam_ids: + # 没有考试数据,返回默认值 + return { + "easy": 100.0, + "medium": 100.0, + "hard": 100.0, + "综合题": 100.0, + "应用题": 100.0 + } + + # 统计各难度的总题数和错题数 + difficulty_stats = {} + + for difficulty in ["easy", "medium", "hard"]: + # 总题数:从exams的questions字段中统计(这里简化处理) + # 由于questions字段是JSON,我们通过question_count估算 + # 实际应用中可以解析JSON或通过exam_results表统计 + + # 错题数:从exam_mistakes通过question_id关联查询 + mistake_stmt = select(func.count(ExamMistake.id)).select_from( + ExamMistake + ).join( + Question, ExamMistake.question_id == Question.id + ).where( + and_( + ExamMistake.user_id == user_id, + ExamMistake.exam_id.in_(exam_ids), + Question.difficulty == difficulty + ) + ) + + mistake_count = await db.scalar(mistake_stmt) or 0 + + # 总题数:该难度的题目在用户考试中出现的次数 + # 简化处理:假设每次考试平均包含该难度题目的比例 + total_questions_stmt = select( + func.coalesce(func.sum(Exam.question_count), 0) + ).where(and_(*exam_conditions)) + total_count = await db.scalar(total_questions_stmt) or 0 + total_count = int(total_count) # 转换为int避免Decimal类型问题 + + # 简化算法:假设easy:medium:hard = 3:2:1 + if difficulty == "easy": + estimated_count = int(total_count * 0.5) + elif difficulty == "medium": + estimated_count = int(total_count * 0.3) + else: # hard + estimated_count = int(total_count * 0.2) + + # 计算正确率 + if estimated_count > 0: + correct_count = max(0, estimated_count - mistake_count) + accuracy = round((correct_count / estimated_count) * 100, 1) + else: + accuracy = 100.0 + + difficulty_stats[difficulty] = accuracy + + # 综合题和应用题使用中等和困难题的平均值 + difficulty_stats["综合题"] = round((difficulty_stats["medium"] + difficulty_stats["hard"]) / 2, 1) + difficulty_stats["应用题"] = round((difficulty_stats["medium"] + difficulty_stats["hard"]) / 2, 1) + + return { + "简单题": difficulty_stats["easy"], + "中等题": difficulty_stats["medium"], + "困难题": difficulty_stats["hard"], + "综合题": difficulty_stats["综合题"], + "应用题": difficulty_stats["应用题"] + } + + @staticmethod + async def get_knowledge_mastery( + db: AsyncSession, + user_id: int, + course_id: Optional[int] = None + ) -> List[Dict[str, Any]]: + """ + 获取知识点掌握度 + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID(可选) + + Returns: + List[Dict]: 知识点掌握度列表 + """ + logger.info(f"获取知识点掌握度 - user_id: {user_id}, course_id: {course_id}") + + # 获取知识点列表 + kp_conditions = [KnowledgePoint.is_deleted == False] + if course_id: + kp_conditions.append(KnowledgePoint.course_id == course_id) + + kp_stmt = select(KnowledgePoint).where(and_(*kp_conditions)).limit(10) + result = await db.execute(kp_stmt) + knowledge_points = result.scalars().all() + + if not knowledge_points: + # 没有知识点数据,返回默认数据 + return [ + {"name": "基础概念", "mastery": 85.0}, + {"name": "核心知识", "mastery": 72.0}, + {"name": "实践应用", "mastery": 68.0}, + {"name": "综合运用", "mastery": 58.0}, + {"name": "高级技巧", "mastery": 75.0}, + {"name": "案例分析", "mastery": 62.0} + ] + + mastery_list = [] + + for kp in knowledge_points: + # 统计该知识点的错误次数 + mistake_stmt = select(func.count(ExamMistake.id)).where( + and_( + ExamMistake.user_id == user_id, + ExamMistake.knowledge_point_id == kp.id + ) + ) + mistake_count = await db.scalar(mistake_stmt) or 0 + + # 假设每个知识点平均被考查10次(简化处理) + estimated_total = 10 + + # 计算掌握度 + if estimated_total > 0: + correct_count = max(0, estimated_total - mistake_count) + mastery = round((correct_count / estimated_total) * 100, 1) + else: + mastery = 100.0 + + mastery_list.append({ + "name": kp.name[:10], # 限制名称长度 + "mastery": mastery + }) + + return mastery_list[:6] # 最多返回6个知识点 + + @staticmethod + async def get_study_time_stats( + db: AsyncSession, + user_id: int, + course_id: Optional[int] = None, + period: str = "month" + ) -> Dict[str, Any]: + """ + 获取学习时长统计 + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID(可选) + period: 时间范围 + + Returns: + Dict: 学习时长和练习时长的日期分布数据 + """ + logger.info(f"获取学习时长统计 - user_id: {user_id}, course_id: {course_id}, period: {period}") + + start_date, end_date = StatisticsService._get_date_range(period) + + # 获取天数 + days = (end_date - start_date).days + if days > 30: + days = 30 # 最多显示30天 + + # 生成日期列表 + date_list = [] + for i in range(days): + date = end_date - timedelta(days=days - i - 1) + date_list.append(date.date()) + + # 初始化数据 + study_time_data = {str(d): 0.0 for d in date_list} + practice_time_data = {str(d): 0.0 for d in date_list} + + # 统计考试时长(学习时长) + exam_conditions = [ + Exam.user_id == user_id, + Exam.start_time >= start_date, + Exam.start_time <= end_date + ] + if course_id: + exam_conditions.append(Exam.course_id == course_id) + + exam_stmt = select( + func.date(Exam.start_time).label("date"), + func.sum(Exam.duration_minutes).label("total_minutes") + ).where( + and_(*exam_conditions) + ).group_by( + func.date(Exam.start_time) + ) + + exam_result = await db.execute(exam_stmt) + for row in exam_result.all(): + date_str = str(row.date) + if date_str in study_time_data: + study_time_data[date_str] = round(float(row.total_minutes) / 60, 1) + + # 统计陪练时长(练习时长) + practice_conditions = [ + PracticeSession.user_id == user_id, + PracticeSession.start_time >= start_date, + PracticeSession.start_time <= end_date, + PracticeSession.status == "completed" + ] + + practice_stmt = select( + func.date(PracticeSession.start_time).label("date"), + func.sum(PracticeSession.duration_seconds).label("total_seconds") + ).where( + and_(*practice_conditions) + ).group_by( + func.date(PracticeSession.start_time) + ) + + practice_result = await db.execute(practice_stmt) + for row in practice_result.all(): + date_str = str(row.date) + if date_str in practice_time_data: + practice_time_data[date_str] = round(float(row.total_seconds) / 3600, 1) + + # 如果period是week,返回星期几标签 + if period == "week": + weekday_labels = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] + labels = weekday_labels[:len(date_list)] + else: + # 其他情况返回日期 + labels = [d.strftime("%m-%d") for d in date_list] + + study_values = [study_time_data[str(d)] for d in date_list] + practice_values = [practice_time_data[str(d)] for d in date_list] + + return { + "labels": labels, + "studyTime": study_values, + "practiceTime": practice_values + } + + @staticmethod + async def get_detail_data( + db: AsyncSession, + user_id: int, + course_id: Optional[int] = None, + period: str = "month" + ) -> List[Dict[str, Any]]: + """ + 获取详细统计数据(按日期) + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID(可选) + period: 时间范围 + + Returns: + List[Dict]: 每日详细统计数据 + """ + logger.info(f"获取详细数据 - user_id: {user_id}, course_id: {course_id}, period: {period}") + + start_date, end_date = StatisticsService._get_date_range(period) + + # 构建查询条件 + exam_conditions = [ + Exam.user_id == user_id, + Exam.start_time >= start_date, + Exam.start_time <= end_date, + Exam.round1_score.isnot(None) + ] + if course_id: + exam_conditions.append(Exam.course_id == course_id) + + # 按日期分组统计 + stmt = select( + func.date(Exam.start_time).label("date"), + func.count(Exam.id).label("exam_count"), + func.avg(Exam.round1_score).label("avg_score"), + func.sum(Exam.duration_minutes).label("total_duration"), + func.sum(Exam.question_count).label("total_questions") + ).where( + and_(*exam_conditions) + ).group_by( + func.date(Exam.start_time) + ).order_by( + desc(func.date(Exam.start_time)) + ).limit(10) # 最多返回10条 + + result = await db.execute(stmt) + rows = result.all() + + detail_list = [] + + for row in rows: + date_str = row.date.strftime("%Y-%m-%d") + exam_count = row.exam_count or 0 + avg_score = round(float(row.avg_score or 0), 1) + study_time = round(float(row.total_duration or 0) / 60, 1) + question_count = row.total_questions or 0 + + # 统计当天的错题数 + mistake_stmt = select(func.count(ExamMistake.id)).where( + and_( + ExamMistake.user_id == user_id, + ExamMistake.exam_id.in_( + select(Exam.id).where( + and_( + Exam.user_id == user_id, + func.date(Exam.start_time) == row.date + ) + ) + ) + ) + ) + mistake_count = await db.scalar(mistake_stmt) or 0 + + # 计算正确率 + accuracy = 0.0 + if question_count > 0: + correct_count = question_count - mistake_count + accuracy = round((correct_count / question_count) * 100, 1) + + # 计算进步指数(基于平均分) + improvement = min(100, max(0, int(avg_score))) + + detail_list.append({ + "date": date_str, + "examCount": exam_count, + "avgScore": avg_score, + "studyTime": study_time, + "questionCount": question_count, + "accuracy": accuracy, + "improvement": improvement + }) + + return detail_list + diff --git a/backend/app/services/system_log_service.py b/backend/app/services/system_log_service.py new file mode 100644 index 0000000..6fc86b7 --- /dev/null +++ b/backend/app/services/system_log_service.py @@ -0,0 +1,170 @@ +""" +系统日志服务 +""" +import logging +from typing import Optional +from datetime import datetime +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.system_log import SystemLog +from app.schemas.system_log import SystemLogCreate, SystemLogQuery + +logger = logging.getLogger(__name__) + + +class SystemLogService: + """系统日志服务类""" + + async def create_log( + self, + db: AsyncSession, + log_data: SystemLogCreate + ) -> SystemLog: + """ + 创建系统日志 + + Args: + db: 数据库会话 + log_data: 日志数据 + + Returns: + 创建的日志对象 + """ + try: + log = SystemLog(**log_data.model_dump()) + db.add(log) + await db.commit() + await db.refresh(log) + return log + except Exception as e: + await db.rollback() + logger.error(f"创建系统日志失败: {str(e)}") + raise + + async def get_logs( + self, + db: AsyncSession, + query_params: SystemLogQuery + ) -> tuple[list[SystemLog], int]: + """ + 查询系统日志列表 + + Args: + db: 数据库会话 + query_params: 查询参数 + + Returns: + (日志列表, 总数) + """ + try: + # 构建基础查询 + stmt = select(SystemLog) + count_stmt = select(func.count(SystemLog.id)) + + # 应用筛选条件 + filters = [] + + if query_params.level: + filters.append(SystemLog.level == query_params.level) + + if query_params.type: + filters.append(SystemLog.type == query_params.type) + + if query_params.user: + filters.append(SystemLog.user == query_params.user) + + if query_params.keyword: + filters.append(SystemLog.message.like(f"%{query_params.keyword}%")) + + if query_params.start_date: + filters.append(SystemLog.created_at >= query_params.start_date) + + if query_params.end_date: + filters.append(SystemLog.created_at <= query_params.end_date) + + # 应用所有筛选条件 + if filters: + stmt = stmt.where(*filters) + count_stmt = count_stmt.where(*filters) + + # 获取总数 + result = await db.execute(count_stmt) + total = result.scalar_one() + + # 应用排序和分页 + stmt = stmt.order_by(SystemLog.created_at.desc()) + stmt = stmt.offset((query_params.page - 1) * query_params.page_size) + stmt = stmt.limit(query_params.page_size) + + # 执行查询 + result = await db.execute(stmt) + logs = result.scalars().all() + + return list(logs), total + + except Exception as e: + logger.error(f"查询系统日志失败: {str(e)}") + raise + + async def get_log_by_id( + self, + db: AsyncSession, + log_id: int + ) -> Optional[SystemLog]: + """ + 根据ID获取日志详情 + + Args: + db: 数据库会话 + log_id: 日志ID + + Returns: + 日志对象或None + """ + try: + stmt = select(SystemLog).where(SystemLog.id == log_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + except Exception as e: + logger.error(f"获取日志详情失败: {str(e)}") + raise + + async def delete_logs_before_date( + self, + db: AsyncSession, + before_date: datetime + ) -> int: + """ + 删除指定日期之前的日志(用于日志清理) + + Args: + db: 数据库会话 + before_date: 截止日期 + + Returns: + 删除的日志数量 + """ + try: + stmt = select(SystemLog).where(SystemLog.created_at < before_date) + result = await db.execute(stmt) + logs = result.scalars().all() + + count = len(logs) + for log in logs: + await db.delete(log) + + await db.commit() + logger.info(f"已删除 {count} 条日志记录") + return count + except Exception as e: + await db.rollback() + logger.error(f"删除日志失败: {str(e)}") + raise + + +# 创建全局服务实例 +system_log_service = SystemLogService() + + + diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py new file mode 100644 index 0000000..f6b30dc --- /dev/null +++ b/backend/app/services/task_service.py @@ -0,0 +1,214 @@ +""" +任务服务 +""" +from typing import List, Optional +from datetime import datetime +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload +from app.models.task import Task, TaskCourse, TaskAssignment, TaskStatus, AssignmentStatus +from app.models.course import Course +from app.schemas.task import TaskCreate, TaskUpdate, TaskStatsResponse +from app.services.base_service import BaseService + + +class TaskService(BaseService[Task]): + """任务服务""" + + def __init__(self): + super().__init__(Task) + + async def create_task(self, db: AsyncSession, task_in: TaskCreate, creator_id: int) -> Task: + """创建任务""" + # 创建任务 + task = Task( + title=task_in.title, + description=task_in.description, + priority=task_in.priority, + deadline=task_in.deadline, + requirements=task_in.requirements, + creator_id=creator_id, + status=TaskStatus.PENDING + ) + db.add(task) + await db.flush() + + # 关联课程 + for course_id in task_in.course_ids: + task_course = TaskCourse(task_id=task.id, course_id=course_id) + db.add(task_course) + + # 分配用户 + for user_id in task_in.user_ids: + assignment = TaskAssignment(task_id=task.id, user_id=user_id) + db.add(assignment) + + await db.commit() + await db.refresh(task) + return task + + async def get_tasks( + self, + db: AsyncSession, + status: Optional[str] = None, + page: int = 1, + page_size: int = 20 + ) -> (List[Task], int): + """获取任务列表""" + stmt = select(Task).where(Task.is_deleted == False) + + if status: + stmt = stmt.where(Task.status == status) + + stmt = stmt.order_by(Task.created_at.desc()) + + # 获取总数 + count_stmt = select(func.count()).select_from(Task).where(Task.is_deleted == False) + if status: + count_stmt = count_stmt.where(Task.status == status) + total = (await db.execute(count_stmt)).scalar_one() + + # 分页 + stmt = stmt.offset((page - 1) * page_size).limit(page_size) + result = await db.execute(stmt) + tasks = result.scalars().all() + + return tasks, total + + async def get_task_detail(self, db: AsyncSession, task_id: int) -> Optional[Task]: + """获取任务详情""" + stmt = select(Task).where( + and_(Task.id == task_id, Task.is_deleted == False) + ).options( + joinedload(Task.course_links).joinedload(TaskCourse.course), + joinedload(Task.assignments) + ) + result = await db.execute(stmt) + return result.unique().scalar_one_or_none() + + async def update_task(self, db: AsyncSession, task_id: int, task_in: TaskUpdate) -> Optional[Task]: + """更新任务""" + stmt = select(Task).where(and_(Task.id == task_id, Task.is_deleted == False)) + result = await db.execute(stmt) + task = result.scalar_one_or_none() + + if not task: + return None + + update_data = task_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(task, field, value) + + await db.commit() + await db.refresh(task) + return task + + async def delete_task(self, db: AsyncSession, task_id: int) -> bool: + """删除任务(软删除)""" + stmt = select(Task).where(and_(Task.id == task_id, Task.is_deleted == False)) + result = await db.execute(stmt) + task = result.scalar_one_or_none() + + if not task: + return False + + task.is_deleted = True + await db.commit() + return True + + async def get_task_stats(self, db: AsyncSession) -> TaskStatsResponse: + """获取任务统计""" + # 总任务数 + total_stmt = select(func.count()).select_from(Task).where(Task.is_deleted == False) + total = (await db.execute(total_stmt)).scalar_one() + + # 各状态任务数 + status_stmt = select( + Task.status, + func.count(Task.id) + ).where(Task.is_deleted == False).group_by(Task.status) + status_result = await db.execute(status_stmt) + status_counts = dict(status_result.all()) + + # 平均完成率 + avg_stmt = select(func.avg(Task.progress)).where( + and_(Task.is_deleted == False, Task.status != TaskStatus.EXPIRED) + ) + avg_completion = (await db.execute(avg_stmt)).scalar_one() or 0.0 + + return TaskStatsResponse( + total=total, + ongoing=status_counts.get(TaskStatus.ONGOING.value, 0), + completed=status_counts.get(TaskStatus.COMPLETED.value, 0), + expired=status_counts.get(TaskStatus.EXPIRED.value, 0), + avg_completion_rate=round(avg_completion, 1) + ) + + async def update_task_progress(self, db: AsyncSession, task_id: int) -> int: + """ + 更新任务进度 + + 计算已完成的分配数占总分配数的百分比 + """ + # 统计总分配数和完成数 + stmt = select( + func.count(TaskAssignment.id).label('total'), + func.sum( + func.case( + (TaskAssignment.status == AssignmentStatus.COMPLETED, 1), + else_=0 + ) + ).label('completed') + ).where(TaskAssignment.task_id == task_id) + + result = (await db.execute(stmt)).first() + total = result.total or 0 + completed = result.completed or 0 + + if total == 0: + progress = 0 + else: + progress = int((completed / total) * 100) + + # 更新任务进度 + task_stmt = select(Task).where(and_(Task.id == task_id, Task.is_deleted == False)) + task_result = await db.execute(task_stmt) + task = task_result.scalar_one_or_none() + + if task: + task.progress = progress + await db.commit() + + return progress + + async def update_task_status(self, db: AsyncSession, task_id: int): + """ + 更新任务状态 + + 根据进度和截止时间自动更新任务状态 + """ + task = await self.get_task_detail(db, task_id) + if not task: + return + + # 计算并更新进度 + progress = await self.update_task_progress(db, task_id) + + # 自动更新状态 + now = datetime.now() + + if progress == 100: + # 完全完成 + task.status = TaskStatus.COMPLETED + elif task.deadline and now > task.deadline and task.status != TaskStatus.COMPLETED: + # 已过期且未完成 + task.status = TaskStatus.EXPIRED + elif progress > 0 and task.status == TaskStatus.PENDING: + # 已开始但未完成 + task.status = TaskStatus.ONGOING + + await db.commit() + + +task_service = TaskService() + diff --git a/backend/app/services/training_service.py b/backend/app/services/training_service.py new file mode 100644 index 0000000..0926ced --- /dev/null +++ b/backend/app/services/training_service.py @@ -0,0 +1,372 @@ +"""陪练服务层""" +import logging +from typing import List, Optional, Dict, Any +from datetime import datetime +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, or_, func +from fastapi import HTTPException, status + +from app.models.training import ( + TrainingScene, + TrainingSession, + TrainingMessage, + TrainingReport, + TrainingSceneStatus, + TrainingSessionStatus, + MessageRole, + MessageType, +) +from app.schemas.training import ( + TrainingSceneCreate, + TrainingSceneUpdate, + TrainingSessionCreate, + TrainingSessionUpdate, + TrainingMessageCreate, + TrainingReportCreate, + StartTrainingRequest, + StartTrainingResponse, + EndTrainingRequest, + EndTrainingResponse, +) +from app.services.base_service import BaseService + +# from app.services.ai.coze.client import CozeClient +from app.core.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + + +class TrainingSceneService(BaseService[TrainingScene]): + """陪练场景服务""" + + def __init__(self): + super().__init__(TrainingScene) + + async def get_active_scenes( + self, + db: AsyncSession, + *, + category: Optional[str] = None, + is_public: Optional[bool] = None, + user_level: Optional[int] = None, + skip: int = 0, + limit: int = 20, + ) -> List[TrainingScene]: + """获取激活的陪练场景列表""" + query = select(self.model).where( + and_( + self.model.status == TrainingSceneStatus.ACTIVE, + self.model.is_deleted == False, + ) + ) + + if category: + query = query.where(self.model.category == category) + + if is_public is not None: + query = query.where(self.model.is_public == is_public) + + if user_level is not None: + query = query.where( + or_( + self.model.required_level == None, + self.model.required_level <= user_level, + ) + ) + + return await self.get_multi(db, skip=skip, limit=limit, query=query) + + async def create_scene( + self, db: AsyncSession, *, scene_in: TrainingSceneCreate, created_by: int + ) -> TrainingScene: + """创建陪练场景""" + return await self.create( + db, obj_in=scene_in, created_by=created_by, updated_by=created_by + ) + + async def update_scene( + self, + db: AsyncSession, + *, + scene_id: int, + scene_in: TrainingSceneUpdate, + updated_by: int, + ) -> Optional[TrainingScene]: + """更新陪练场景""" + scene = await self.get(db, scene_id) + if not scene or scene.is_deleted: + return None + + scene.updated_by = updated_by + return await self.update(db, db_obj=scene, obj_in=scene_in) + + +class TrainingSessionService(BaseService[TrainingSession]): + """陪练会话服务""" + + def __init__(self): + super().__init__(TrainingSession) + self.scene_service = TrainingSceneService() + self.message_service = TrainingMessageService() + self.report_service = TrainingReportService() + # TODO: 等Coze网关模块实现后替换 + self._coze_client = None + + @property + def coze_client(self): + """延迟初始化Coze客户端""" + if self._coze_client is None: + try: + # from app.services.ai.coze.client import CozeClient + # self._coze_client = CozeClient() + logger.warning("Coze客户端暂未实现,使用模拟模式") + self._coze_client = None + except ImportError: + logger.warning("Coze客户端未实现,使用模拟模式") + return self._coze_client + + async def start_training( + self, db: AsyncSession, *, request: StartTrainingRequest, user_id: int + ) -> StartTrainingResponse: + """开始陪练会话""" + # 验证场景 + scene = await self.scene_service.get(db, request.scene_id) + if not scene or scene.is_deleted or scene.status != TrainingSceneStatus.ACTIVE: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="陪练场景不存在或未激活" + ) + + # 检查用户等级 + # TODO: 从User服务获取用户等级 + user_level = 1 # 临时模拟 + if scene.required_level and user_level < scene.required_level: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="用户等级不足") + + # 创建会话 + session_data = TrainingSessionCreate( + scene_id=request.scene_id, session_config=request.config + ) + + session = await self.create( + db, obj_in=session_data, user_id=user_id, created_by=user_id + ) + + # 初始化Coze会话 + coze_conversation_id = None + if self.coze_client and scene.ai_config: + try: + bot_id = scene.ai_config.get("bot_id", settings.coze_training_bot_id) + if bot_id: + # 创建Coze会话 + coze_result = await self.coze_client.create_conversation( + bot_id=bot_id, + user_id=str(user_id), + meta_data={ + "scene_id": scene.id, + "scene_name": scene.name, + "session_id": session.id, + }, + ) + coze_conversation_id = coze_result.get("conversation_id") + + # 更新会话的Coze ID + session.coze_conversation_id = coze_conversation_id + await db.commit() + except Exception as e: + logger.error(f"创建Coze会话失败: {e}") + + # 加载场景信息 + await db.refresh(session, ["scene"]) + + # 构造WebSocket URL(如果需要) + websocket_url = None + if coze_conversation_id: + websocket_url = f"ws://localhost:8000/ws/v1/training/{session.id}" + + return StartTrainingResponse( + session_id=session.id, + coze_conversation_id=coze_conversation_id, + scene=scene, + websocket_url=websocket_url, + ) + + async def end_training( + self, + db: AsyncSession, + *, + session_id: int, + request: EndTrainingRequest, + user_id: int, + ) -> EndTrainingResponse: + """结束陪练会话""" + # 获取会话 + session = await self.get(db, session_id) + if not session or session.user_id != user_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在") + + if session.status in [ + TrainingSessionStatus.COMPLETED, + TrainingSessionStatus.CANCELLED, + ]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="会话已结束") + + # 计算持续时间 + end_time = datetime.now() + duration_seconds = int((end_time - session.start_time).total_seconds()) + + # 更新会话状态 + update_data = TrainingSessionUpdate( + status=TrainingSessionStatus.COMPLETED, + end_time=end_time, + duration_seconds=duration_seconds, + ) + session = await self.update(db, db_obj=session, obj_in=update_data) + + # 生成报告 + report = None + if request.generate_report: + report = await self._generate_report( + db, session_id=session_id, user_id=user_id + ) + + # 加载关联数据 + await db.refresh(session, ["scene"]) + if report: + await db.refresh(report, ["session"]) + + return EndTrainingResponse(session=session, report=report) + + async def get_user_sessions( + self, + db: AsyncSession, + *, + user_id: int, + scene_id: Optional[int] = None, + status: Optional[TrainingSessionStatus] = None, + skip: int = 0, + limit: int = 20, + ) -> List[TrainingSession]: + """获取用户的陪练会话列表""" + query = select(self.model).where(self.model.user_id == user_id) + + if scene_id: + query = query.where(self.model.scene_id == scene_id) + + if status: + query = query.where(self.model.status == status) + + query = query.order_by(self.model.created_at.desc()) + + return await self.get_multi(db, skip=skip, limit=limit, query=query) + + async def _generate_report( + self, db: AsyncSession, *, session_id: int, user_id: int + ) -> Optional[TrainingReport]: + """生成陪练报告(内部方法)""" + # 获取会话消息 + messages = await self.message_service.get_session_messages( + db, session_id=session_id + ) + + # TODO: 调用AI分析服务生成报告 + # 这里先生成模拟报告 + report_data = TrainingReportCreate( + session_id=session_id, + user_id=user_id, + overall_score=85.5, + dimension_scores={"表达能力": 88.0, "逻辑思维": 85.0, "专业知识": 82.0, "应变能力": 87.0}, + strengths=["表达清晰,语言流畅", "能够快速理解问题并作出回应", "展现了良好的专业素养"], + weaknesses=["部分专业术语使用不够准确", "回答有时过于冗长,需要更加精炼"], + suggestions=["加强专业知识的学习,特别是术语的准确使用", "练习更加简洁有力的表达方式", "增加实际案例的积累,丰富回答内容"], + detailed_analysis="整体表现良好,展现了扎实的基础知识和良好的沟通能力...", + statistics={ + "total_messages": len(messages), + "user_messages": len( + [m for m in messages if m.role == MessageRole.USER] + ), + "avg_response_time": 2.5, + "total_words": 1500, + }, + ) + + return await self.report_service.create( + db, obj_in=report_data, created_by=user_id + ) + + +class TrainingMessageService(BaseService[TrainingMessage]): + """陪练消息服务""" + + def __init__(self): + super().__init__(TrainingMessage) + + async def create_message( + self, db: AsyncSession, *, message_in: TrainingMessageCreate + ) -> TrainingMessage: + """创建消息""" + return await self.create(db, obj_in=message_in) + + async def get_session_messages( + self, db: AsyncSession, *, session_id: int, skip: int = 0, limit: int = 100 + ) -> List[TrainingMessage]: + """获取会话的所有消息""" + query = ( + select(self.model) + .where(self.model.session_id == session_id) + .order_by(self.model.created_at) + ) + + return await self.get_multi(db, skip=skip, limit=limit, query=query) + + async def save_voice_message( + self, + db: AsyncSession, + *, + session_id: int, + role: MessageRole, + content: str, + voice_url: str, + voice_duration: float, + metadata: Optional[Dict[str, Any]] = None, + ) -> TrainingMessage: + """保存语音消息""" + message_data = TrainingMessageCreate( + session_id=session_id, + role=role, + type=MessageType.VOICE, + content=content, + voice_url=voice_url, + voice_duration=voice_duration, + metadata=metadata, + ) + + return await self.create(db, obj_in=message_data) + + +class TrainingReportService(BaseService[TrainingReport]): + """陪练报告服务""" + + def __init__(self): + super().__init__(TrainingReport) + + async def get_by_session( + self, db: AsyncSession, *, session_id: int + ) -> Optional[TrainingReport]: + """根据会话ID获取报告""" + result = await db.execute( + select(self.model).where(self.model.session_id == session_id) + ) + return result.scalar_one_or_none() + + async def get_user_reports( + self, db: AsyncSession, *, user_id: int, skip: int = 0, limit: int = 20 + ) -> List[TrainingReport]: + """获取用户的所有报告""" + query = ( + select(self.model) + .where(self.model.user_id == user_id) + .order_by(self.model.created_at.desc()) + ) + + return await self.get_multi(db, skip=skip, limit=limit, query=query) diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..3adc275 --- /dev/null +++ b/backend/app/services/user_service.py @@ -0,0 +1,423 @@ +""" +用户服务 +""" + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from sqlalchemy import and_, or_, select, func +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.exceptions import ConflictError, NotFoundError +from app.core.logger import logger +from app.core.security import get_password_hash, verify_password +from app.models.user import Team, User, user_teams +from app.schemas.user import UserCreate, UserFilter, UserUpdate +from app.services.base_service import BaseService + + +class UserService(BaseService[User]): + """用户服务""" + + def __init__(self, db: AsyncSession): + super().__init__(User) + self.db = db + + async def get_by_id(self, user_id: int) -> Optional[User]: + """根据ID获取用户""" + result = await self.db.execute( + select(User).where(User.id == user_id, User.is_deleted == False) + ) + return result.scalar_one_or_none() + + async def get_by_username(self, username: str) -> Optional[User]: + """根据用户名获取用户""" + result = await self.db.execute( + select(User).where( + User.username == username, + User.is_deleted == False, + ) + ) + return result.scalar_one_or_none() + + async def get_by_email(self, email: str) -> Optional[User]: + """根据邮箱获取用户""" + result = await self.db.execute( + select(User).where( + User.email == email, + User.is_deleted == False, + ) + ) + return result.scalar_one_or_none() + + async def get_by_phone(self, phone: str) -> Optional[User]: + """根据手机号获取用户""" + result = await self.db.execute( + select(User).where( + User.phone == phone, + User.is_deleted == False, + ) + ) + return result.scalar_one_or_none() + + async def _check_username_exists_all(self, username: str) -> Optional[User]: + """ + 检查用户名是否已存在(包括已删除的用户) + 用于创建用户时检查唯一性约束 + """ + result = await self.db.execute( + select(User).where(User.username == username) + ) + return result.scalar_one_or_none() + + async def _check_email_exists_all(self, email: str) -> Optional[User]: + """ + 检查邮箱是否已存在(包括已删除的用户) + 用于创建用户时检查唯一性约束 + """ + result = await self.db.execute( + select(User).where(User.email == email) + ) + return result.scalar_one_or_none() + + async def _check_phone_exists_all(self, phone: str) -> Optional[User]: + """ + 检查手机号是否已存在(包括已删除的用户) + 用于创建用户时检查唯一性约束 + """ + result = await self.db.execute( + select(User).where(User.phone == phone) + ) + return result.scalar_one_or_none() + + async def create_user( + self, + *, + obj_in: UserCreate, + created_by: Optional[int] = None, + ) -> User: + """创建用户""" + # 检查用户名是否已存在(包括已删除的用户,防止唯一键冲突) + existing_user = await self._check_username_exists_all(obj_in.username) + if existing_user: + if existing_user.is_deleted: + raise ConflictError(f"用户名 {obj_in.username} 已被使用(历史用户),请更换其他用户名") + else: + raise ConflictError(f"用户名 {obj_in.username} 已存在") + + # 检查邮箱是否已存在(包括已删除的用户) + if obj_in.email: + existing_email = await self._check_email_exists_all(obj_in.email) + if existing_email: + if existing_email.is_deleted: + raise ConflictError(f"邮箱 {obj_in.email} 已被使用(历史用户),请更换其他邮箱") + else: + raise ConflictError(f"邮箱 {obj_in.email} 已存在") + + # 检查手机号是否已存在(包括已删除的用户) + if obj_in.phone: + existing_phone = await self._check_phone_exists_all(obj_in.phone) + if existing_phone: + if existing_phone.is_deleted: + raise ConflictError(f"手机号 {obj_in.phone} 已被使用(历史用户),请更换其他手机号") + else: + raise ConflictError(f"手机号 {obj_in.phone} 已存在") + + # 创建用户数据 + user_data = obj_in.model_dump(exclude={"password"}) + user_data["hashed_password"] = get_password_hash(obj_in.password) + # 注意:User模型不包含created_by字段,该信息记录在日志中 + # user_data["created_by"] = created_by + + try: + # 创建用户 + user = await self.create(db=self.db, obj_in=user_data) + except IntegrityError as e: + # 捕获数据库唯一键冲突异常,返回友好错误信息 + await self.db.rollback() + error_msg = str(e.orig) if e.orig else str(e) + logger.warning( + "创建用户时发生唯一键冲突", + username=obj_in.username, + email=obj_in.email, + error=error_msg, + ) + if "username" in error_msg.lower(): + raise ConflictError(f"用户名 {obj_in.username} 已被占用,请更换其他用户名") + elif "email" in error_msg.lower(): + raise ConflictError(f"邮箱 {obj_in.email} 已被占用,请更换其他邮箱") + elif "phone" in error_msg.lower(): + raise ConflictError(f"手机号 {obj_in.phone} 已被占用,请更换其他手机号") + else: + raise ConflictError(f"创建用户失败:数据冲突,请检查用户名、邮箱或手机号是否重复") + + # 记录日志 + logger.info( + "用户创建成功", + user_id=user.id, + username=user.username, + role=user.role, + created_by=created_by, + ) + + return user + + async def update_user( + self, + *, + user_id: int, + obj_in: UserUpdate, + updated_by: Optional[int] = None, + ) -> User: + """更新用户""" + user = await self.get_by_id(user_id) + if not user: + raise NotFoundError("用户不存在") + + # 如果更新邮箱,检查是否已存在 + if obj_in.email and obj_in.email != user.email: + if await self.get_by_email(obj_in.email): + raise ConflictError(f"邮箱 {obj_in.email} 已存在") + + # 如果更新手机号,检查是否已存在 + if obj_in.phone and obj_in.phone != user.phone: + if await self.get_by_phone(obj_in.phone): + raise ConflictError(f"手机号 {obj_in.phone} 已存在") + + # 更新用户数据 + update_data = obj_in.model_dump(exclude_unset=True) + update_data["updated_by"] = updated_by + + user = await self.update(db=self.db, db_obj=user, obj_in=update_data) + + # 记录日志 + logger.info( + "用户更新成功", + user_id=user.id, + username=user.username, + updated_fields=list(update_data.keys()), + updated_by=updated_by, + ) + + return user + + async def update_password( + self, + *, + user_id: int, + old_password: str, + new_password: str, + ) -> User: + """更新密码""" + user = await self.get_by_id(user_id) + if not user: + raise NotFoundError("用户不存在") + + # 验证旧密码 + if not verify_password(old_password, user.hashed_password): + raise ConflictError("旧密码错误") + + # 更新密码 + update_data = { + "hashed_password": get_password_hash(new_password), + "password_changed_at": datetime.now(), + } + user = await self.update(db=self.db, db_obj=user, obj_in=update_data) + + # 记录日志 + logger.info( + "用户密码更新成功", + user_id=user.id, + username=user.username, + ) + + return user + + async def update_last_login(self, user_id: int) -> None: + """更新最后登录时间""" + user = await self.get_by_id(user_id) + if user: + await self.update( + db=self.db, + db_obj=user, + obj_in={"last_login_at": datetime.now()}, + ) + + async def get_users_with_filter( + self, + *, + skip: int = 0, + limit: int = 100, + filter_params: UserFilter, + ) -> tuple[List[User], int]: + """根据筛选条件获取用户列表""" + # 构建筛选条件 + filters = [User.is_deleted == False] + + if filter_params.role: + filters.append(User.role == filter_params.role) + + if filter_params.is_active is not None: + filters.append(User.is_active == filter_params.is_active) + + if filter_params.keyword: + keyword = f"%{filter_params.keyword}%" + filters.append( + or_( + User.username.like(keyword), + User.email.like(keyword), + User.full_name.like(keyword), + ) + ) + + if filter_params.team_id: + # 通过团队ID筛选用户 + subquery = select(user_teams.c.user_id).where( + user_teams.c.team_id == filter_params.team_id + ) + filters.append(User.id.in_(subquery)) + + # 构建查询 + query = select(User).where(and_(*filters)) + + # 获取用户列表 + users = await self.get_multi(self.db, skip=skip, limit=limit, query=query) + + # 获取总数 + count_query = select(func.count(User.id)).where(and_(*filters)) + count_result = await self.db.execute(count_query) + total = count_result.scalar() + + return users, total + + async def add_user_to_team( + self, + *, + user_id: int, + team_id: int, + role: str = "member", + ) -> None: + """将用户添加到团队""" + # 检查用户是否存在 + user = await self.get_by_id(user_id) + if not user: + raise NotFoundError("用户不存在") + + # 检查团队是否存在 + team_result = await self.db.execute( + select(Team).where(Team.id == team_id, Team.is_deleted == False) + ) + team = team_result.scalar_one_or_none() + if not team: + raise NotFoundError("团队不存在") + + # 检查是否已在团队中 + existing = await self.db.execute( + select(user_teams).where( + user_teams.c.user_id == user_id, + user_teams.c.team_id == team_id, + ) + ) + if existing.first(): + raise ConflictError("用户已在该团队中") + + # 添加到团队 + await self.db.execute( + user_teams.insert().values( + user_id=user_id, + team_id=team_id, + role=role, + joined_at=datetime.now(), + ) + ) + await self.db.commit() + + # 记录日志 + logger.info( + "用户加入团队", + user_id=user_id, + username=user.username, + team_id=team_id, + team_name=team.name, + role=role, + ) + + async def remove_user_from_team( + self, + *, + user_id: int, + team_id: int, + ) -> None: + """从团队中移除用户""" + # 删除关联 + result = await self.db.execute( + user_teams.delete().where( + user_teams.c.user_id == user_id, + user_teams.c.team_id == team_id, + ) + ) + + if result.rowcount == 0: + raise NotFoundError("用户不在该团队中") + + await self.db.commit() + + # 记录日志 + logger.info( + "用户离开团队", + user_id=user_id, + team_id=team_id, + ) + + async def soft_delete(self, *, db_obj: User) -> User: + """ + 软删除用户 + + Args: + db_obj: 用户对象 + + Returns: + 软删除后的用户对象 + """ + db_obj.is_deleted = True + db_obj.deleted_at = datetime.now() + self.db.add(db_obj) + await self.db.commit() + await self.db.refresh(db_obj) + + logger.info( + "用户软删除成功", + user_id=db_obj.id, + username=db_obj.username, + ) + + return db_obj + + async def authenticate( + self, + *, + username: str, + password: str, + ) -> Optional[User]: + """用户认证""" + # 尝试用户名登录 + user = await self.get_by_username(username) + + # 尝试邮箱登录 + if not user: + user = await self.get_by_email(username) + + # 尝试手机号登录 + if not user: + user = await self.get_by_phone(username) + + if not user: + return None + + # 验证密码 + if not verify_password(password, user.hashed_password): + return None + + return user diff --git a/backend/app/services/yanji_service.py b/backend/app/services/yanji_service.py new file mode 100644 index 0000000..8bc2b1c --- /dev/null +++ b/backend/app/services/yanji_service.py @@ -0,0 +1,510 @@ +""" +言迹智能工牌API服务 +""" + +import logging +import random +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Any + +import httpx + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +class YanjiService: + """言迹智能工牌API服务类""" + + def __init__(self): + self.base_url = settings.YANJI_API_BASE + self.client_id = settings.YANJI_CLIENT_ID + self.client_secret = settings.YANJI_CLIENT_SECRET + self.tenant_id = settings.YANJI_TENANT_ID + self.estate_id = int(settings.YANJI_ESTATE_ID) + + # Token缓存 + self._access_token: Optional[str] = None + self._token_expires_at: Optional[datetime] = None + + async def get_access_token(self) -> str: + """ + 获取或刷新access_token + + Returns: + access_token字符串 + """ + # 检查缓存的token是否仍然有效(提前5分钟刷新) + if self._access_token and self._token_expires_at: + if datetime.now() < self._token_expires_at - timedelta(minutes=5): + return self._access_token + + # 获取新的token + url = f"{self.base_url}/oauth/token" + params = { + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + } + + async with httpx.AsyncClient() as client: + response = await client.get(url, params=params, timeout=30.0) + response.raise_for_status() + data = response.json() + + self._access_token = data["access_token"] + expires_in = data.get("expires_in", 3600) # 默认1小时 + self._token_expires_at = datetime.now() + timedelta(seconds=expires_in) + + logger.info(f"言迹API token获取成功,有效期至: {self._token_expires_at}") + return self._access_token + + async def _request( + self, + method: str, + path: str, + params: Optional[Dict] = None, + json_data: Optional[Dict] = None, + ) -> Dict: + """ + 统一的HTTP请求方法 + + Args: + method: HTTP方法(GET/POST等) + path: API路径 + params: Query参数 + json_data: Body参数(JSON) + + Returns: + 响应数据(data字段) + + Raises: + Exception: API调用失败 + """ + token = await self.get_access_token() + url = f"{self.base_url}{path}" + headers = {"Authorization": f"Bearer {token}"} + + async with httpx.AsyncClient() as client: + response = await client.request( + method=method, + url=url, + params=params, + json=json_data, + headers=headers, + timeout=60.0, + ) + response.raise_for_status() + result = response.json() + + # 言迹API: code='0'或code=0表示成功 + code = result.get("code") + if str(code) != '0': + error_msg = result.get("msg", "Unknown error") + logger.error(f"言迹API调用失败: {error_msg}, result={result}") + raise Exception(f"言迹API错误: {error_msg}") + + # data可能为None,返回空字典或空列表由调用方判断 + return result.get("data") + + async def get_visit_audios( + self, external_visit_ids: List[str] + ) -> List[Dict]: + """ + 根据来访单ID获取录音信息 + + Args: + external_visit_ids: 三方来访ID列表(最多10个) + + Returns: + 录音信息列表 + """ + if not external_visit_ids: + return [] + + if len(external_visit_ids) > 10: + logger.warning(f"来访单ID数量超过限制,截取前10个") + external_visit_ids = external_visit_ids[:10] + + data = await self._request( + method="POST", + path="/api/beauty/v1/visit/audios", + json_data={ + "estateId": self.estate_id, + "externalVisitIds": external_visit_ids, + }, + ) + + if data is None: + logger.info(f"获取来访录音信息: 无数据") + return [] + + records = data.get("records", []) + logger.info(f"获取来访录音信息成功: {len(records)}条") + return records + + async def get_audio_asr_result(self, audio_id: int) -> Dict: + """ + 获取录音的ASR分析结果(对话文本) + + Args: + audio_id: 录音ID + + Returns: + ASR分析结果,包含对话文本数组 + """ + data = await self._request( + method="GET", + path="/api/beauty/v1/audio/asr-analysed", + params={"estateId": self.estate_id, "audioId": audio_id}, + ) + + # 检查data是否为None + if data is None: + logger.warning(f"录音ASR结果为None: audio_id={audio_id}") + return {} + + # data是一个数组,取第一个元素 + if isinstance(data, list) and len(data) > 0: + result = data[0] + logger.info( + f"获取录音ASR结果成功: audio_id={audio_id}, " + f"对话数={len(result.get('result', []))}" + ) + return result + else: + logger.warning(f"录音ASR结果为空: audio_id={audio_id}") + return {} + + async def get_recent_conversations( + self, consultant_phone: str, limit: int = 10 + ) -> List[Dict]: + """ + 获取员工最近N条对话记录 + + 业务逻辑: + 1. 通过员工手机号获取录音列表(目前使用模拟数据) + 2. 对每个录音获取ASR分析结果 + 3. 组合返回完整的对话记录 + + Args: + consultant_phone: 员工手机号 + limit: 获取数量,默认10条 + + Returns: + 对话记录列表,格式: + [{ + "audio_id": 123, + "visit_id": "xxx", + "start_time": "2025-01-15 10:30:00", + "duration": 120000, + "consultant_name": "张三", + "consultant_phone": "13800138000", + "conversation": [ + {"role": "consultant", "text": "您好..."}, + {"role": "customer", "text": "你好..."} + ] + }] + """ + # TODO: 目前言迹API没有直接通过手机号查询录音的接口 + # 需要先获取来访单列表,再获取录音 + # 这里暂时返回空列表,后续根据实际业务需求补充 + + logger.warning( + f"获取员工对话记录功能需要额外的业务逻辑支持 " + f"(consultant_phone={consultant_phone}, limit={limit})" + ) + + # 返回空列表,表示暂未实现 + return [] + + async def get_conversations_by_visit_ids( + self, external_visit_ids: List[str] + ) -> List[Dict]: + """ + 根据来访单ID列表获取对话记录 + + Args: + external_visit_ids: 三方来访ID列表 + + Returns: + 对话记录列表 + """ + if not external_visit_ids: + return [] + + # 1. 获取录音信息 + audio_records = await self.get_visit_audios(external_visit_ids) + + if not audio_records: + logger.info("没有找到录音记录") + return [] + + # 2. 对每个录音获取ASR分析结果 + conversations = [] + for audio in audio_records: + audio_id = audio.get("id") + if not audio_id: + continue + + try: + asr_result = await self.get_audio_asr_result(audio_id) + + # 解析对话文本 + conversation_messages = [] + for item in asr_result.get("result", []): + role = "consultant" if item.get("role") == -1 else "customer" + conversation_messages.append({ + "role": role, + "text": item.get("text", ""), + "begin_time": item.get("beginTime"), + "end_time": item.get("endTime"), + }) + + # 组合完整对话记录 + conversations.append({ + "audio_id": audio_id, + "visit_id": audio.get("externalVisitId", ""), + "start_time": audio.get("startTime", ""), + "duration": audio.get("duration", 0), + "consultant_name": audio.get("consultantName", ""), + "consultant_phone": audio.get("consultantPhone", ""), + "conversation": conversation_messages, + }) + + except Exception as e: + logger.error(f"获取录音ASR结果失败: audio_id={audio_id}, error={e}") + continue + + logger.info(f"成功获取{len(conversations)}条对话记录") + return conversations + + async def get_audio_list(self, phone: str) -> List[Dict]: + """ + 获取员工的录音列表(模拟) + + 注意:言迹API暂时没有提供通过手机号直接查询录音列表的接口 + 这里使用模拟数据,返回假想的录音列表 + + Args: + phone: 员工手机号 + + Returns: + 录音信息列表 + """ + logger.info(f"获取员工录音列表(模拟): phone={phone}") + + # 模拟返回10条录音记录 + mock_audios = [] + base_time = datetime.now() + + for i in range(10): + # 模拟不同时长的录音 + durations = [25000, 45000, 180000, 240000, 120000, 90000, 60000, 300000, 420000, 150000] + + mock_audios.append({ + "id": f"mock_audio_{i+1}", + "externalVisitId": f"visit_{i+1}", + "startTime": (base_time - timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"), + "duration": durations[i], # 毫秒 + "consultantName": "模拟员工", + "consultantPhone": phone + }) + + return mock_audios + + async def get_employee_conversations_for_analysis( + self, + phone: str, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + 获取员工最近N条录音的模拟对话数据(用于能力分析) + + Args: + phone: 员工手机号 + limit: 获取数量,默认10条 + + Returns: + 对话数据列表,格式: + [{ + "audio_id": "mock_audio_1", + "duration_seconds": 25, + "start_time": "2025-10-15 10:30:00", + "dialogue_history": [ + {"speaker": "consultant", "content": "您好..."}, + {"speaker": "customer", "content": "你好..."} + ] + }] + """ + # 1. 获取录音列表 + audios = await self.get_audio_list(phone) + + if not audios: + logger.warning(f"未找到员工的录音记录: phone={phone}") + return [] + + # 2. 筛选前limit条 + selected_audios = audios[:limit] + + # 3. 为每条录音生成模拟对话 + conversations = [] + for audio in selected_audios: + conversation = self._generate_mock_conversation(audio) + conversations.append(conversation) + + logger.info(f"生成模拟对话数据: phone={phone}, count={len(conversations)}") + return conversations + + def _generate_mock_conversation(self, audio: Dict) -> Dict: + """ + 为录音生成模拟对话数据 + + 根据录音时长选择不同复杂度的对话模板: + - <30秒: 短对话(4-6轮) + - 30秒-5分钟: 中等对话(8-12轮) + - >5分钟: 长对话(15-20轮,完整销售流程) + + Args: + audio: 录音信息字典 + + Returns: + 对话数据字典 + """ + duration = int(audio.get('duration', 60000)) // 1000 # 转换为秒 + + # 根据时长选择对话模板 + if duration < 30: + dialogue = self._short_conversation_template() + elif duration < 300: + dialogue = self._medium_conversation_template() + else: + dialogue = self._long_conversation_template() + + return { + "audio_id": audio.get('id'), + "duration_seconds": duration, + "start_time": audio.get('startTime'), + "dialogue_history": dialogue + } + + def _short_conversation_template(self) -> List[Dict]: + """短对话模板(<30秒)- 4-6轮对话""" + templates = [ + [ + {"speaker": "consultant", "content": "您好,欢迎光临曼尼斐绮,请问有什么可以帮到您?"}, + {"speaker": "customer", "content": "你好,我想了解一下面部护理项目"}, + {"speaker": "consultant", "content": "好的,我们有多种面部护理方案,请问您主要关注哪方面呢?"}, + {"speaker": "customer", "content": "主要是想改善皮肤暗沉"}, + {"speaker": "consultant", "content": "明白了,针对皮肤暗沉,我推荐我们的美白焕肤套餐"} + ], + [ + {"speaker": "consultant", "content": "您好,请问需要什么帮助吗?"}, + {"speaker": "customer", "content": "我想咨询一下祛斑项目"}, + {"speaker": "consultant", "content": "好的,请问您主要是哪种类型的斑点呢?"}, + {"speaker": "customer", "content": "脸颊两侧有些黄褐斑"}, + {"speaker": "consultant", "content": "了解,我们有专门针对黄褐斑的光子嫩肤项目,效果很不错"} + ], + [ + {"speaker": "consultant", "content": "欢迎光临,有什么可以帮您的吗?"}, + {"speaker": "customer", "content": "我想预约做个面部护理"}, + {"speaker": "consultant", "content": "好的,请问您之前做过我们的项目吗?"}, + {"speaker": "customer", "content": "没有,第一次来"}, + {"speaker": "consultant", "content": "那我建议您先做个免费的皮肤检测,帮您制定个性化方案"}, + {"speaker": "customer", "content": "好的,那现在可以吗?"} + ] + ] + return random.choice(templates) + + def _medium_conversation_template(self) -> List[Dict]: + """中等对话模板(30秒-5分钟)- 8-12轮对话""" + templates = [ + [ + {"speaker": "consultant", "content": "您好,欢迎光临曼尼斐绮,我是美容顾问小王,请问怎么称呼您?"}, + {"speaker": "customer", "content": "你好,我姓李"}, + {"speaker": "consultant", "content": "李女士您好,请问今天是第一次了解我们的项目吗?"}, + {"speaker": "customer", "content": "是的,之前在网上看到你们的介绍"}, + {"speaker": "consultant", "content": "好的,您对哪方面的美容项目比较感兴趣呢?"}, + {"speaker": "customer", "content": "我想改善面部松弛的问题,最近感觉皮肤没有以前紧致了"}, + {"speaker": "consultant", "content": "我理解您的困扰。请问您多大年龄?平时有做面部护理吗?"}, + {"speaker": "customer", "content": "我35岁,平时就是用护肤品,没做过专业护理"}, + {"speaker": "consultant", "content": "明白了。35岁开始注重抗衰是很及时的。我们有几种方案,比如射频紧肤、超声刀提拉,还有胶原蛋白再生项目"}, + {"speaker": "customer", "content": "这几种有什么区别吗?"}, + {"speaker": "consultant", "content": "射频主要是刺激胶原蛋白增生,效果温和持久。超声刀作用更深层,提拉效果更明显但价格稍高。我建议您先做个皮肤检测,看具体适合哪种"}, + {"speaker": "customer", "content": "好的,那先做个检测吧"} + ], + [ + {"speaker": "consultant", "content": "您好,欢迎光临,我是美容顾问晓雯,请问您是第一次来吗?"}, + {"speaker": "customer", "content": "是的,朋友推荐过来看看"}, + {"speaker": "consultant", "content": "太好了,请问您朋友是做的什么项目呢?"}, + {"speaker": "customer", "content": "她做的好像是什么水光针"}, + {"speaker": "consultant", "content": "水光针确实是我们很受欢迎的项目。请问您今天主要想了解哪方面呢?"}, + {"speaker": "customer", "content": "我主要是皮肤有点粗糙,毛孔也大"}, + {"speaker": "consultant", "content": "嗯,针对毛孔粗大和皮肤粗糙,水光针确实有不错的效果。不过我建议先看看您的具体情况"}, + {"speaker": "customer", "content": "需要检查吗?"}, + {"speaker": "consultant", "content": "是的,我们有专业的皮肤检测仪,可以看到肉眼看不到的皮肤问题,这样制定方案更精准"}, + {"speaker": "customer", "content": "好的,那检查一下吧"}, + {"speaker": "consultant", "content": "好的,请这边来,检查大概需要5分钟"} + ] + ] + return random.choice(templates) + + def _long_conversation_template(self) -> List[Dict]: + """长对话模板(>5分钟)- 15-20轮对话,完整销售流程""" + templates = [ + [ + {"speaker": "consultant", "content": "您好,欢迎光临曼尼斐绮,我是资深美容顾问晓雯,请问怎么称呼您?"}, + {"speaker": "customer", "content": "你好,我姓陈"}, + {"speaker": "consultant", "content": "陈女士您好,看您气色很好,平时应该很注重保养吧?"}, + {"speaker": "customer", "content": "还好吧,基本的护肤品会用"}, + {"speaker": "consultant", "content": "这样啊。那今天是专程过来了解我们的项目,还是朋友推荐的呢?"}, + {"speaker": "customer", "content": "我闺蜜在你们这做过,说效果不错,所以想来看看"}, + {"speaker": "consultant", "content": "太好了,请问您闺蜜做的是什么项目呢?"}, + {"speaker": "customer", "content": "好像是什么光子嫩肤"}, + {"speaker": "consultant", "content": "明白了,光子嫩肤确实是我们的明星项目。不过每个人的皮肤状况不同,我先帮您做个详细的皮肤检测,看看最适合您的方案好吗?"}, + {"speaker": "customer", "content": "好的"}, + {"speaker": "consultant", "content": "陈女士,通过检测我看到您的皮肤主要有三个问题:一是T区毛孔粗大,二是两颊有轻微色斑,三是皮肤缺水。您平时有感觉到这些问题吗?"}, + {"speaker": "customer", "content": "对,毛孔确实有点大,色斑是最近才发现的"}, + {"speaker": "consultant", "content": "嗯,这些问题如果不及时处理会越来越明显。针对您的情况,我建议做一个综合性的美白嫩肤方案"}, + {"speaker": "customer", "content": "具体是怎么做的?"}, + {"speaker": "consultant", "content": "我们采用光子嫩肤配合水光针的组合疗程。光子嫩肤主要解决色斑和毛孔问题,水光针补水锁水,效果相辅相成"}, + {"speaker": "customer", "content": "听起来不错,大概需要多少钱?"}, + {"speaker": "consultant", "content": "我们现在正好有活动,光子嫩肤单次原价3800,水光针单次2600,组合套餐优惠后只要5800,相当于打了九折"}, + {"speaker": "customer", "content": "嗯...还是有点贵"}, + {"speaker": "consultant", "content": "我理解您的顾虑。但是陈女士,您想想,这个价格是一次性投入,效果却能维持3-6个月。平均下来每天不到30块钱,换来的是皮肤的明显改善"}, + {"speaker": "customer", "content": "这倒也是..."}, + {"speaker": "consultant", "content": "而且这个活动就到本月底,下个月恢复原价的话就要6400了。您今天如果确定的话,我还可以帮您申请赠送一次基础补水护理"}, + {"speaker": "customer", "content": "那行吧,今天就定了"}, + {"speaker": "consultant", "content": "太好了!陈女士您做了个很明智的决定。我现在帮您预约最近的时间,您看周三下午方便吗?"} + ], + [ + {"speaker": "consultant", "content": "您好,欢迎光临,我是美容顾问小张,请问您贵姓?"}, + {"speaker": "customer", "content": "我姓王"}, + {"speaker": "consultant", "content": "王女士您好,请坐。今天想了解什么项目呢?"}, + {"speaker": "customer", "content": "我想做个面部提升,感觉脸有点下垂了"}, + {"speaker": "consultant", "content": "嗯,我看得出来您平时很注重保养。请问您今年多大年龄?"}, + {"speaker": "customer", "content": "我42了"}, + {"speaker": "consultant", "content": "42岁这个年龄段,确实容易出现轻微松弛。您之前有做过抗衰项目吗?"}, + {"speaker": "customer", "content": "做过几次普通的面部护理,但感觉效果不明显"}, + {"speaker": "consultant", "content": "普通护理主要是表层保养,对于松弛问题作用有限。您的情况需要更深层的治疗"}, + {"speaker": "customer", "content": "那有什么好的方案吗?"}, + {"speaker": "consultant", "content": "针对您的情况,我推荐热玛吉或者超声刀。这两种都是通过热能刺激深层胶原蛋白重组,达到紧致提升的效果"}, + {"speaker": "customer", "content": "这两种有什么区别?"}, + {"speaker": "consultant", "content": "热玛吉作用在真皮层,效果更自然持久,适合轻中度松弛。超声刀能到达筋膜层,提拉力度更强,适合松弛比较明显的情况"}, + {"speaker": "customer", "content": "我的情况适合哪种?"}, + {"speaker": "consultant", "content": "从您的面部状况来看,我建议选择热玛吉。您的松弛程度属于轻度,热玛吉的效果会更自然,恢复期也更短"}, + {"speaker": "customer", "content": "费用大概多少?"}, + {"speaker": "consultant", "content": "热玛吉全脸的话,我们的价格是28800元。不过您今天来的时机很好,我们正在做周年庆活动,可以优惠到23800"}, + {"speaker": "customer", "content": "还是挺贵的啊"}, + {"speaker": "consultant", "content": "王女士,我理解您的感受。但是热玛吉一次治疗效果可以维持2-3年,平均每天只要20多块钱。而且这是一次性投入,不需要反复做"}, + {"speaker": "customer", "content": "效果真的能维持那么久吗?"}, + {"speaker": "consultant", "content": "这是有科学依据的。热玛吉刺激的是您自身的胶原蛋白再生,不是外来填充,所以效果持久自然。我们有很多客户都做过,反馈都很好"}, + {"speaker": "customer", "content": "那我考虑一下吧"}, + {"speaker": "consultant", "content": "可以的。不过这个活动优惠就到本周日,下周就恢复原价了。而且名额有限,您要是确定的话最好尽快预约"}, + {"speaker": "customer", "content": "好吧,那我今天就定下来吧"} + ] + ] + return random.choice(templates) + + diff --git a/backend/backups/backup_status.json b/backend/backups/backup_status.json new file mode 100644 index 0000000..205b666 --- /dev/null +++ b/backend/backups/backup_status.json @@ -0,0 +1 @@ +{"timestamp":"2025-09-25T02:01:05+08:00","status":"SUCCESS","message":"数据库备份成功完成"} diff --git a/backend/backups/kaopeilian_backup_20250923_032422.sql.gz b/backend/backups/kaopeilian_backup_20250923_032422.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..0d0b54b39ea1c41f8e81ea6a72742adf2012d193 GIT binary patch literal 9579 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 Ve2avt?~Wb&=6|t*(xRN!000X0uATq@ literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_034650.sql.gz b/backend/backups/kaopeilian_backup_20250923_034650.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..8733cafc50a94ba9bc7b091a215eb0b235587c72 GIT binary patch literal 9580 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 W@!g50mJ`hl-~1oTMlv~^)&Kxh3D$rB literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_040001.sql.gz b/backend/backups/kaopeilian_backup_20250923_040001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..060d25ba7ab1278f9d40f6acf0b422dbcf4ee865 GIT binary patch literal 9580 zcmV-yC6n48iwFq8s?lfw18ZS#aAj$1X<=?(Vqs%zb#PxYFfuhTIWjX}Ff=eQFflH3 zacltWJ!@CnNS2>Bze3wz?94jdws|Ida!%9Wo?)9n50KrR+1}%JKnd;`%=nQGyJyca z2?X;rdGH935E2qdCm7P96R?B%F>y)qr}+zet4fkfmTb#dJoapd07k01y0`AF`>Lv| z3m3R6LtTGv<9eh0gPhCxrK^VHTKwTi$bXG%{L=YlZH{lh)G_|Ppr z#KCvi9~}4xiqyGtU;W`9Yn?8qi|g`qb6fF9xHdt*xoMwWA$AwYPhk@kb28;Iaat&ROTIArRV| zu6Vk*Xykfh|Fv570X6vtbhlpdc>mhb?g6OkXMNjzGuuBootK>ET{1B8nL?wNDH5_=cl)jdcn&5( zv?me`@nix-$h9APg3(Zz_wu)W{oXJiiTDR@gnz`{&b4%Qe9v_^ebeUQTEFEy|K8dK zGGg4mklL{C7T@db34412etv+=BknTy_18UpzL2jc!iR8I<)%((3|n2+NKtdAr>WZ` zV`q#LXFtbrKl*!r_Vfk)J@6|S9f%ZkZ1H^C1T%`Wxj;x&C}?^v7aPw#Ulitca$^Y!tUnrv z_yOSt_&QYps5ct& zML^AY`$6AE_;4{m^(-VQc30Y-6Ba(?<__&BelPc6^}GzufBm|@2gYp38{lv8fnwll z7-+er3`)Hc-=_gDyX!)o3~JC9#+>wqdx9a}6j~@TAw2s8_y8aZ!yoqY19%>R=z!D> z0T8?EypbRD0j#-%O(E z_rhXETu+HxkA$^2h3^Xl-~k~%;=9RLj_ByHl-$dWO#@eCnf2Y4fq5hTeubwB2<_An zR$^#Cs~0|DKzekVuXc6<7-Ay2x|^jNvLcT^lMz5sELPbSj%lDf&P`r*t=Ac~_Xl`&!-$aQ+Y^#JplJ*qJZD^ECgp7XT-u@zi|)`5Xw z%Qpqe{xbN2s&s${1h(d~VpOO*<5B+hMhE;o!Cp|Ker41y%VvNoCn=c`50cW_rmevC z6)|2qlgL=-7I0O8c+6FwIn&(M(fl1Xe#I;@_ivr8-5$lWaXPuCHgNY!8_bs0E^-^j zoH~Q8?OmSEZVtHBQLGPfR=PO5%i(g=IcgkkhtpB-gkOzLhl_mGIBagGyY7PX;stjN z=X77HbzQ2du@$+BdpI3LobiwI4!awG)jHhdtF{hqH`G}KxyDgLFvj0?D$e-ZT_xmN z2mZovVN~icWUFwlbKoyGfn1AGZBWL!u1d)Dj(T-8tR3eDM}x{iYsb0K(a1_&O@5rK zg?!O*k>Fel*Xt~mXBPl+9@9v>;F3!_A{VV1YZr)|TOAWCWv?tJoVd%F#j0MPKz5migOe03Hb+cqyXL!e>+n7V+x=PiG*~xqee5lHyR4$DXFuF z5@CKUoBnXMvyFLBWN46QpJd^K#CK`*bdq^i*#BRJu}eW0@V?Pb!j85Uq=`Fn=o1cx zBHm!Amk(*3L~Teh^8lq1_{2D>=$I@Ek-THGECf1hS$crmH-e#|D)qgw8kC8N z-vhNTEAEId5)HG%R>i|?y&>QAh^_E>;=~6bHY}4DyhTz{z8ieCwzG()-xQ$1ym>CI@n^+ zRHnTEVP;dBo;@=@#L1-h&br51m_B#5UFq&AE?jE2AQ)q8b+8E9)By-Of|NdzzyO+xDJB{!L^d^Ee+nmi_ymonPm|7Cg}s2BxSY(U zC&lzMX77VdY5O_!leS-p>3e$i()K_tn!&=4>70uTPgf{=v2-kqVcsTY(exOFMui-# zI)J?v4f_Z9aQKADtpkD%6bc^2(4(X(YIMp{9Dyu)SK59etZZZ1O+FXmA9E`);oTj) zR{3-mvob!LO|4T1Dy_JC`Zb>D6U(PZXgv@_P!?9E(Zm{XRG65Pw#U)>2yhF_FF}^x z1@VMQtD?ND?e`6UTiXws`-BN@fC;;vLBBkZ zW?usD@KoIxMyspR;VfKH`HNoN$sWw*VoTED7_scaq^g2~J>f985T?OGSP&x7}PUDG8)$1wI1RfCjg|cIg3H}aA|Ty#VA9W1#pYxjZt*Z6%MpZ1^0j@-quDFOyb6a z^Zz$Oe3j95mFL<98s^$Hyw)vAu)6kFUiZzoWDRinDnROF&k}>;#i{!tF{?}|n4n}+duaQzNl01#2Rh5jl> zkc1+#J};Lb{uDzSlWc;RALupL6;Jplt87=}ZO)~}+Guxk1!OZu4D2QzH1ipC8dDy} ziqaqqA!sk%R&I7piP6y|+yhmpgz2RHU%EQlF_%9j&a-yAGz98e6oYWqZMUjR?5bKh z!j1#uB!8i~EoRTv2WxCBzFMI@FJ{QC+rLn~5touX=wR;njd(HZZN_X@X0NR<)Hg!G zpCf&sUm|@a?X{-eDVuE#E~mXAL5RWR;87+ z6WXSw*NL@l*~Ma#?#-=batldz5OCf%J%i#;#MB1c2E}*qZoiN3=$|;p4fAtum+hO+ zHp(l=F}lPoFJF;WD4Hb3Yk343`v^A2=*s643-g5+FV!~cvxwv>T_UD1aawvWE*&oC z7C)$@vA6I#1Hq7OgIBnl4TI|>q7Qm z8m;c5(KO4N>|RVv9m=WR_zv3`GlgldvLpBW9@_o{gAcr*?J40n2cB90_I-t>QfPHm zOee^~jRj<=OSyEl6-I~3gi1-lT|ix4kxkr3@8(njKtxdLmAn+luJw}CwJJsh&@3Rq z+~lj;`?@(0{5in)-r&81K`<>PJuqF{Q})7i_#FA9LXn)oiJexU(j?y5KQ8}GeE{pi z4f;Yb#AOCSjDJu&D}%tCi#BwA(7QXJn&{L~9u?_3p6#6~a>~rE!q@tl_uuG?=Ei7VJo^EuNl{eN?i9NS!wAzSJ|| zH~zj0(sUMunTmF1URvzR7A8{yu`K+HW)m$PB$*F4Wa-=EImlf2G3R*&7-Hfy|g z{g9OO-1W^(CVTW9tM)vUQ!L&}T#(|OPT3bJj+l9;M1@ge#fBVsQdD2Tvq~)t6`Js) zXCr)mFWsL}foW-K#k-N>XJ>={88a4a84Rq#_0oiO9V;IWBj#)EfJU6Oy|oRE#K>M0 zKC94+N_`PaqQSwymS|2Ln=~qAaL`#7Bf6|IGgYk-kg1)I1V-OW!;g;Fz?4zJ8cS0` z!Id;QCHk4PJtZJiZG;AcVZX8jUG5yDuI(wC934L9Esff>xW%9uog;1~keh3dsUK;ruC4M@rw+(7droYHf;gCtTSRj=G#(F8*;hR%NQF zZjhJSPQXqXYiW&n(ksJ~R!QHy^fHFNJzJHk-jUZz@MmF38uz6dx4uvfy~^%j#=po4 zg3Hm!Le!VlsSsj~gB0yyKcgLJbXYpVr`YYMq69Z6bk%F2;!0ElrC*M zsJC<+FXC}@vLN-flPUyBs!3_tU0*d!m!sBVe#^z9tYo;WhSXWJlH)E6u+CDJyXpYO zMQd4JkJ)1}%NrbZRr{b^^$Yma^Q9OQLD{Q#H@sZbyUF#;gWF zYPC_-406|Em~IO>WZq4%F5>Lx`>)|`j%FfGrR`aXI2C-3G2*Ofr@M9+nuYao(8?x7 zE4#NPE{(9e(S8d02h67h^AIPVq0yIRbCSP@nkO7;VtLaCnu-(_2{TJ?p=O}V6UI!4 zsWv^f5L5F6U5II7OLn>NvcZDr5^ue{zj&h2rA8wgN0+MNGZ$T|%}qv^YI`=itSQ_) zDyX_0NwY;{g?8KyBh|h+t-(4na>`J$mW%nk;)P~mWv-YqU-4Sjp#FRZN z7QX2Z1j?1t+e3$Ul`X5x2DoFW^d}<-!xDdU^zwP9y}bVadgqt+off@3U4rOk44VzZ_S9@(}JO~R32h}TiUmRw>ysSuKGkmnWh-{4w6Y2(b+` zHIGY0t;K;DDY<4$0BAHmB)@UV0y6X`8*q6l0N>*ac#SBcgy25&3(5fCBMb~JwkK0@BHHput*eE}AGxh5PbfhX*y(Zr^>HIL#CaCye@4a^s9Rl5@yc@ws!>QF3S z{h&lBmTz>*p%`0F=ZL3w%)Mtstu|uiuB3uu670(}BK`D)s>3*%*9K0!5Ha zTsNi4=#4@~_eMddQOLnT6V)?f#$igH?oi!I{?o=gBd4r@+E5xCOE z3*qUkrbNp1K+wmYZ(_qHdC21TQ0!A-8F)5FP)+QLRZg^D|B}f{iomNvBW`7sWfgGL zj0)~(H^Utq;sqA=Wr{L$%k_leMBDpp&#Dg1pV1Jnvco+En#W$CinNVcL9(-AW&Z3t z8yu?Y^`zhLQe9+71^0Mk!U8*~i3u62R5B)H+Lew8nTBj`-==xfuT8C?zBEGKYC4bJ z-GNDwO~KuwIadZ}uEpYiaf8(UW&Mq1q&pn9a`u4MNaJWH6@W}0MlF}SFsxsya zb?40RBKPRAw7qS}1x0{WG^ED|;>ZX3E(K}oE?VA2`-ixg#=*U8dKz%c?!`b_@*;%O z9@7+goM$7LTO!VQE1oU?a&sJw-WeCRABelpfG5B}i1)qlkDk^B2O99evuXdmlu8-4 z{v4bA9J_DWo|FrDiAJZ)D>Z>OK3JAtjzqOA1ob8p{1eeXHvo~OL<4tRoElnXCzu(#BKIGg7~ zCqtA5^6Q_Kmz(n3Q%Dnp7`n3ftbF_5aQDBrCX4w;^6V;HqEl}GNb?}Y`_{f?d6&Gy z0C2!sH<-kMJ3oLesOHWOe^+RxYhUfG#D$U!cLd&O9E6|`ixH>Ql$e_}qukWbNqA<>?EDFTtas-RuCv?;Z zpKI@KKSMC4TM?(<%L`k|;s%lfz(JwBaz~zdgw9|U1)CB)`gmEWvJ~B)iH-Gb4a;*s z74z@mA@BvTX!6&1&-vKJ_P$QEp!7)+P7dk{R?F7N&l^z2&Mib#Nnj|-&& zPCeNjyBv?5JKxoFB^F~bh)#3pb5g$mn<`6EHkX))kBCBU%=aW+sDOV_Smcr!Y@1K> z5;i+Ii^V43gBFoZ6Y?%DOQ?%;uv2m8=kofpb^KC5CTU#s4KW)gWT_^WZ+HwVkNPxB z+dYD{HIHMBL_8;^YLis5y#8VD#c#^fd&R=<{TI8~XR1lW9mQqnURlhEE+n*|vK;2x z&Q8bh&eKy+UP8s}O~87ywwg4UyM}At^8QDb|1%B3s5ZEi8yV4blRig>p^4}9nySyp za>*2?(itu_Atn7k0}5nAO=v1}h+GDgZT``-zKzx(RMF9+g^~z8LoRii3NOlQuMCXz zYoLlzz2uRF+66pYGEz#1{?GX6>>K&vY@PV1fy-F4QH_{;J33;bI68nh`;qs7*m9`ZhCn(OHq!*h}I_mEq)w zBlT|Y+?$>+eOXgeIST2qY&^@S$5Lpz{<-49gVO7~vieF{-lg8f<6J5Sg`yCyN$ydX zl2dnzJ0GFedVPXe2_o4bR$QMESIQpp>|;CqUf#Jyl@BzFPP}$et;!GB^sClm)*YC? zhN>Qx6SQR&N8Qa?BMcFpNSLfc>Jp4F>(Ofk12^l7zl8l%0ZaBxome}|{H!^<%4EDT zb`{#iHd6`C2B5OeRfyi*$94wzQCfN-uRYfs_h@7wLDwHUbEU#mar-6fYlP$WHdi!< zEkpDjqmo9Hqw`Ws(^I=fPPtar0tXiAvbFOBO$@v4KAJ%%gW}e_{9umtl7iSfwhMW| z=gQ3A^}eJsnEE=koqrRyqF`4ba0@~U66OG(03TXc=2_n=WolZ!`=BNre4Z?=-Umn$Z})>dy$a5$#r%ELC9E2k z4B?M8dj)JY0uKseDl4z8me;V`Wn~>RW0-QO@K0rN#<~kN&D3(*o~c8C!n=C-@+7{i zz=uYF*N=`~Mfj?8S_I!tx$Fq-)YGBg#4%TyabA=-Eel?)boZbpVG^p?mn(P5Q&*GP z%%s?1H63Li|ylN!s^px-nkhN1ci4<{7p%$`#Gl&5FTA0m&#_&uCHFv_1V zmDk@Qrtww2-AAo+eM3j|&0TG!a})WoQ+;UWBKNy8WmQa&I0`!OK!fjR04Z9n*z78GtgBgiRwH52 z^MR@_uhR$(9TDSAmO|(Vw;>H4vTwSVM5=s>$wg9|pDzbVUnTU&N}^JB9g=)j?o0#G zVYh)JwP69B58^DOA>bE0U|B(*N%u%Ia)u!E*jC2BrIynP*Sr36Q4_Cf%C|E|d$100wmJ`1PN&(~sX*mh$q#rW#!#N!JB25g+BGQNIU83$D3-xAbyS zemo;@&A|hQbE#B3h!`K610hi_$}5kxuQh@WlQ>6AAgNL+k5YQ>Ol};p0kL*|g7WJP zd1^uP5FAVj{=G}K-l20O9Vi$>F4TQ`O$Px$-(yCZsqi8&3%%$&43k6;6Wj;K$Ol zg8XHXRikKh0iiB4;?eR&HhXb*O?k3?s4Afz>R2qDYen?mCMe2tBJ_vpK4lmY`oql| z)iu(pwmElT$J8T4iIH?na<~@}+>`WeG=53BF<#^n>0~C%UYrzjXx$_mhJ-X~k2alX zY=r;N)sbU^N214$Ts=K{?Mi=BLq{Tdt$kQ*ZT@rP@g|;|IRAaFU2MGhm&=#?FT@f( z6Q?hx+rJ;WakC*AZN#}d%D+#S@+;IvK$8`ww5);<rP%6BD$S5tXB1H0F`@-@r zHmop-I}y3wd-iJ>GJvX$TFei9y{N&~u;0)r6<3aEI_4iufC3gx!@L<$kV@77yZNU0 zWyw31z{X_6QiZVJ()?{pYuFpw*bZzk7tMqlALNr)(b5lVEe(TL?XcM7AB!}4&6r}? zj7B!djYf{Ci$@)yP+EB?zg|H&BoRp)a()&y!ljnxH-IJ9tm8_;+-#IfGRTrf?U2>% zA6Ybt&WJ3lRmX-af`Hr&r zsUm%{5oI*937aN)YvzxgxfXeQqgeQnBzj&zGLilPCf~^UO*`ai`&AX~f%avotDl23uj=S#5u<6 zx5Q2uQz=vY4U2-~7CjLF3ozz$wxVZjI%gzku<4w2tS#OxZ){a!ZSk%=`$~fh4qq-( zo=2S;pB@uLPMiphC;96Y$~Dq7Fb)<#n(cB-6h=f|a#Xh2^q}f9^w~nB-=)sjWe?r( zO<5ol9!%+c)Lm#Wz82ZGe>wF?9jpD?H5H9CHu zLU0r;+7yDei*4v6FJuQ!4y1%3E;TAh*?~~+g)TISj(*hriVCOqMUQaZpc0=#b4D9+ zpn<5{4ZD=I>Z%Ikh8-AUx-h$zwCf!Vo0sg@NCJmcW$;sZ7~-gDN28q_A$MWx9iFQY?f9C$w>OgjThdE%hUwSG9S& zR<*#bH>&0n5v803>*eBq>*+Os#lDgMYNf~QG0V7 zvF>=s*;vPSz0kwlZte>zG($H-6Z--%D@}tOM_2KVu1>7bA|0*nxb?pn>+SFA>$Mty z-m(~hDA+oCy5k)^-Q9?%qMvjCi#tsJsE*g}p1xSGj#zT3s?!L|Lf<+)M%12Y3xpV5 W#IeS28_{3%oBshhCGixT)&Kw*+ri}k literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_040223.sql.gz b/backend/backups/kaopeilian_backup_20250923_040223.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..e9e161fb6586a8ecb48a6cafcfeac1062ebd9805 GIT binary patch literal 9580 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 W9Bcd*iB`v&zWE=uz=L0$)&Ky*`qTsf literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_050001.sql.gz b/backend/backups/kaopeilian_backup_20250923_050001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..859828725d554ffd5fe02c87ec4058f9c9ae1e1d GIT binary patch literal 9581 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 XI2t|C&~T#R=r?}?WOjD*oYnvU=99zx literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_050032.sql.gz b/backend/backups/kaopeilian_backup_20250923_050032.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..30051baef2131559161dfbbc336e41df927b9938 GIT binary patch literal 9581 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 XI2t|C&~T#h=r?}?J;*TeoYnvU-ebfO literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_060001.sql.gz b/backend/backups/kaopeilian_backup_20250923_060001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..56f190ae91d6fa3302cae845f8a765e1d29300b7 GIT binary patch literal 9582 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 YVs#NmPc$@~Xo!CEC-{Vj#GKXu09NqZ;s5{u literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_060334.sql.gz b/backend/backups/kaopeilian_backup_20250923_060334.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..ee373b96bb28a930400c9ea4eb7225dcc2d44112 GIT binary patch literal 9712 zcmV^Z^) zBi{JI!+?#ku_18~I3_ko27XLb-K|ga7xq?FcdJ_6l3J*F?AaItqWjkEx^-{eS5@7< zbcwq**!AZ&t|#6%z`2~CyF47%5{$+o!5dt|=g!Y-JPz(=IKs6B`{Q>x``v~*UtP_` z3zsgzf9_A=XzU6X3J3h5UiiqtzZQSYf5RW;uW+~h;Q>Av3i|so=%r)*!!5h`$Q?ey z!FMzm?*9kcsS6h_|KT5NoGzz}>+*JUUv#v!_?rLJ)Y;VB?d|lzC0}z}tGB)T>K9)a zUFQC9`Jc;T==647Z|m;Tp^&c^!_m>!*3{kF(GH*5+r7>BBSv9xS;0{2taW+_hW4gw z-Yza4yV=lpqlSHeC;x!%)@xqhUpv~pfK~mhhs}f8{>kaQ;&j^bFiuxZ9^du$)<0kO z0`1=BuQ9Qjza5FoH1>u&n8&C9iZ#d-g|wF_j# z7`~9&sQ(V%;|oN6fl!d|C-aEA%6;)gpw}Pq2V#5#H&t%xgvJo+sz!>MJH1WaUb%P1 zed6pFIPSY(&v#r9n!})Q?bqAdI5Nbo?SSAK%o_)Wk(=v4n>(&y7|tebABvk3tt~bJ z9tHFv-WTW%2Ltdc9Pf`6G;HyH)dVw&v$;S>RV-*~E|(a~y<8OLc5^No7r@&gO*=K2> z%F*y5_jEnqnQuBHS1VEc*~ zFP%wbtaA&vsz5yEs^6SxZtH0Nni{`i7Mc6E&em?P;@LQzTvHpkd!-F#OKTUo4P#E7 z!PfRJZ)Z0L-0CRShBzx-oZaPcIcgmqhuh(F)H&f-gVW(6Uml0e?R3{(ayDLadpM{2 zN{#D^$73sU6}NCYh&baP7aevtAggh>$yZG++^(;+2(!oGAw9<5wQ8U7x4TNrH4glR z@xrLoV$4?cxz>Td+yrwC?rOa<&b3uyu5;9>qhakh*E{M}4q7|T4UPs@>OA>zt`>8n zqmlHv2CmmyD$g!Jm+JJis?rvJ%j(xd@Sj)mIz3wpJ~#6Ep$(D{I{@MB3w8|lyL0ez}Q2ko^Oxp z`j&8y0f-$xJ3E@iTw`^bSLYUE+FHDAUQAn4S94Q~x2&UFZ-t{w=HaQh$OXFG$USCM zrWx0m0XO9qGw`ZQTnBc}CLT@;6Jz-2uM-eAT!P8?`9RN2u#NElax~_T1p}Ak1EH|L zCwf_C+-28gY&e~bZjZCh>2}rm+>L%uz!|8Oo06Zdx<*%{2P~;z27ueQ!jZu$jh3-4mx)O}0NW*7 zACUQYlpVGz9%k!__;1E+h0hbmKM08-nY{2Fl2`NJ;;VH4ikPdNq1>~VX!%ea8!gCs z_F|C&`@;v|`5}nj+!srZlalk?nSoD5bDoxroc8f@l5}fKOmE1!WIy({zmJCdi)FN4 zjEY-_D7k=DJWZ*_Z-j!;UXw(mOSNQDX%+Z0swZ<39174>rd=6f zdQ+O3JvWZf>EtNSyW6Fx61$>Q3cNB^vDx!VPUn;5CBd!{j7prhe2*AWw6lVFI?2iG z(V8%{eHuPjaaYTnhh@^S(xez|;!;Yww=Oa+EV(3xg^k^OWcFDFs;SI zb)a9Eo)l)*C_L3_!gvCG+&D|JJ;YH)sr_u`C-ihvcs!LoSOK!d%r0;V+R;u^f>}sR zFaZ|k_l5U!K(_FHQy8AfrYCZ1`#`*~@EZ6}@nnylp^?m4(pjUh7dj^{r?Qy|F*AkP z`)E_zehKZQ?KfiPfu6k-9;ijrSokrWb4lU(3S}>rj)hUo+nHH3HA=BjAqT4tXm7-$ z!G1m(J!NuhfuIA0f+q>|B&CWPowBr#Ko-3#Z9fxMwz2G{UJA*Nxs` znVik0*C_^-R@^-EBcABv%V$Su9S}siEUZkS@ipM6Fg_=3kD>Kp;1-r&(ph>J#1kg9 zIuqrF3_KQUBF>m-g($TWCHc5#A9fbCeu%0FZLFoj7 z!GjUsPEfSMuR~-@!CCRQ!C}iSY@+l6rB6()QMOVl=`C5)kXu-UmtW= zWRSMNCU8Qqu&$***-~*wsWC8GXf`Qr35=*2MA&i^l%YfZ~ zZ?m6qv@ySS<0PC)^DoH91w{j zVe`t#W)AfFNs~U%s)CX^1TCm8kzl5gCEB*Enmrf#wGzcjR9|sDNj09DAFOQWG+}@h zYbHff=u|Z_>n7?XV>U4t$$u05uh~l-j`>4ABc)sOvraXxir2!a1 z&|bRz=2dYpBQz`qubak|2E`OT2$lC4F7^rJe48wW1-Ks9Jt7_#K+a`>Y z{DtPWm_1h?tg*59YK8W^m?5`r6Grt$TuSYrgSnG8;>E1D8M9rPy|%(o--?8Pi1mVg ziS?GW*BZDpHrtxMob`qTAvp_bUi!F=4u?-{TLQt(Zc6|wcnbzA5)LsWxh(V-ux@@a zDs4QH#(rV_nbIm*+0c+*_Qs~5k7!lZg7)|aOPbHz%V8B2ygB8S;o7P!13P(7)-veztj0iSRa!|qp#?3y zPOODx7mG={H@9|}TS&2kfb+hYX_R~>rZ-p^l-$8v7C*kDf8rcB%+I-9wrxJ!D6b$V z=n}KMd_`8FXp$JOS`BiJ0H%V_5w%@;OascFz>5y@4$L`-qwwDew5I$F*xeo#qc zZ{c+Y(nA&ouYd#jBt1%3z=81G+eH`+y2waVrGScFWD#1@f4tU9mO;=j3)zDyw7QQ* zGAwJddkHapB&T|lJ1j6}3IngQBlq$F+WrKC54@o8lyIB_PcH!bE~CjbT3r=0Gi2e$ z0y5O4T)Nr{qeEpvrKI34bX{JNoq34f&8Y-{h@kWvc`1-x>m{jcRg4O-SwMoh$yc@a zb-O?OLx}IW#rp=rU|LFgU^>_{_QG`iIredjA~}OoJ90s#NxZjzQvRFz0M>;Y@JC>X z%M5~;{Gc{g27x);ZRq@)dP^%BJn0uZsXT#91d7gp3Kkf;dx~iP^yv{jMkKNcdYrJ;- zkd*Y?_24FxJ^F@KdmhRu7GEVUNbyEz?28odn0co}#Zh9#hMd|`RA0ffN-Yc(n(*Ui zBm6-h-Jel`X=!T3yOEOTXM_GZGZt(u53IuV(u8##D<2Lc=4>fzKA8!^ufTEXpT;sG%98KptCMUbXjF)s#+r;Q#&6CjJ%hIo}8?KDWiflmZpS) zD`|2{v@>aYN?@qk2n~dzL1hWL+&M@c>=~OJU4P758ntP0i$OCw$J|OFgKN*)A8XxL zUvNnYwDhM6$?2TOKa6R@>&U36)32y#Ob1tLzSD{EMt0 zxEu{EMtyCd3Nd;dr1B7hQeVobLXm9XLv%G;6+_RMBjig4Kl`MBV0cE45EO+C&Zd(3S&2zWgQE#;|^8iY% zHmaII?i!5KZ6Sxuy9w4soPB)X4ZO|KOvI_co|lMI>CXv9oE7bK*X}~Ius#l2*`(dd z?rn)n!|ZOf@58}<^J&36#_<W{B85f5%+gz^8SL_eF%x2{ zpeGh$YM!7AF->gAE*D-}5nbY~mk$?DHM-PjWaH>kRea{6OBLK?bg9C#(WR$w^N^c! zHIz1Rg=OVjS88jtdy<$~v!)vSWg|}`K?2AbwoVx&8$4*8%p16U1Ln3K{G0 z=VhXjtChP=sytifHktC5&W)pV{KEmq+eu%{sbaU4VOYxx;L1)?=9Tzj$ok!DvPGzL z>soeiTug6h-AZc@qtPAbw!)sWXVId!gP~BlQhj^q=)STgmf2u;0+j^i4#Kb`-yXj_ z-&rr;|G(Z@rhV~84^Njkdf0;xrnZx*N=iQTYo{uxsNY!BB`vaDOXrX>3+UwSV=(&V zJjq52Jjv6jx>sPH44p@_UZZJuQG1|5yM^8s;cpQOOzIXz@J99BRcZGXT3F^M|Z@hay`2nStC}O%VUPvyAbFXDrKv944U;NzxJ`%H8Q)Q1F3jmO4 zEF9r=RInwTSQ2objS4$aij2D*<@81m?HIQ10chn0}*bBZ?uLp1@_w z;b}yQTU()iXfS|E=!fs8^w$sjDSv~n8O1ms&gd9aEVx^m0Y0W#HYeC^K6cw zn%EVqoOqx9O`Me!nOB8I+&Wa2ZNO18D(Iu#YqnH_!If~w*TH8dN&#i0mDtpMQGk9#gLBP>n>3AvFya@IT}y^(9cmuCPt_u|t7;p3Vx|IiE@FiHDI(%Nfb zXV(-nb~mKaU0NBz3^UMlCifIZ^PQO9FUpTE@wdtkwo5Sd>~NrwkHX3r`jkfN8+d3a zq+BxG0)F5%C|kjsym1({Gn{+*P_0&vBR}BC(WJOAiY7;diJ#HqpR(!YqD(g~ z&G;vDznStVcmDvJ$Kr#6xweu3sTIfeQSvP<@u$_O>Ax6aiMdAw4}1hdSxQp0Uk%A_r`?nN8;`a z;0Z7g;(Z_dqo={afrftI*|h&&N~aB>e@o2%mUw6gPs)+JMk7<`;2nBAB~5LMV?ScQ zbniZW!@+NfdkPzB43XDVikA*j)go1gx5E``VY!w#NlZbfz!>0npT8k5<|wO$QQ{EI z{>;An;D6gYx~3+Q?fd?UJZx2B)s~kCDEC%TC52JBi=YBp-j^_t0isEU%>)#mKtx#t z@eB95g5WL+>muk~Tt0&GV{T@WJndh&-F z8Li=gcFyJ}Ox;Lf55ArMdwz5gsw!|1<&SgOWm-E0qR2Q|rS-dXwn5PXs3E>M8Q zgH=rY3@y_`lss(31x$d}*3jNk-@oAyR91e?-=9(*E-EjVc;+TI`Uufi_2EBb{OH`rUMK%A}Pp@Shx17-PVb!khPehz7f5JOkzUgYll2cG_S zbS$&`M44KJOLXe>0BIajeA(K!l<1Pz7yu4g>jr~3bms@K1y$VnfxM<-&JUz_Q|d*8 zTrcVv+gQ)Z`&dn5$r=`QAM3+4R|X;CS~C#z>5S=}H#HFq_Q)9Q579Tm-B-YB<>&Au zV5Tp?T$Jc)z^vE+vwRPxaw4?FM%~1WkSn9G=7-?$4({|c8qGVPLaV%@&XfjK!PS9b zKqi$I{d`At7KL$EWljKcb_C|3Xt+(>a1M!-_burp#`ubDKyG00)KK%3Wph z2|9xn6oiV1X!24}#VL3&6B`?+=~t$I%IvPeOJD}DX!6f+`^C`Zrp^|$po~EhP-Xsu zGQA6@*`iJuUJtSs8@i{|Pg@0p>^NU+kN{fsL81dKnAxG2P<|YjQtS*hpAVD$*wS{+ zKqJXJ$wK2<9vxSgw!I!zJ)9TkXRw;;{w$2@Qdd)JOXsyPTCZ1HnnQ(_Y~m&W@Y$D- zvkSjs4}xbqx&4=3kM1N}aS0IdVK$q_sV&<=SHqzT7hBt}g+eR_(P<7%C-n=ksp2H3 zh9V>3{-iWCSoR=Ys6hFma8igSux&mr%Gm7WEEZdU4_idG42WM`mQfSuV5-dC&$*3- z;_*=)nWS-1ImB$3kf@4SzTq{jJX)q<+U^yst$7%0B*H_Z(yUi-L7VdW&5ti-VEMICJP>508l9anlg%8d|HMO6;v!f*?7H;60x?=e~ zD_^z^#afl4pt)?b?#>DNFNvwDe>h1vyF7iFZB4*j3;(Wy7eWhs^)f|A{S;Ivy zA~gL{a-=c^qv+R6-a}_aTEj1jBUSpM{f^YTsU<%#ll{D|rHYi|gQ;*zj1R`paLeb4 zvyZaNyXxv|bzz@+7Y_@uAt*$Ja7}WXx|FoKo7wvSwcM){#7Yp!2C?G$kGK+dFSn2F z#G0}hqNUaVAdmV zg#>88dW9%BVi*2S5oDD!_ovRSNdw}f>@FP3FsI0%z z9rtKtAVJq(d(+wUcxGpb^) z)lXyD)dv7c;_ZG^CRV{YHM9EwHSuf4B~$og#XbRBjlhF~n96eNtGRXTc3D^_%^aqj zP5-9OO%`uLO*54oHaB(fP`HjcSU z42wxw(6j7SN_P)x5+BQ z;5eZ`WA>Dil2Ss68c$tQ$(%ouA`au{IDKH0znaf&yhTjot9*NaTI$Az4j7xe)Jp0W z@?)p^(qCYs_|nu5)l+-H60VkPY~n38(y>m#Ipt#=zEsVZ(XwprN!FID2QUM5$o!HS z2{6i%m>_W!bl`y|-%k%xz!qb(tJJZsV(DMagvH1=s(rjcBXo2G%nw-#VIbUwG;n11 zbT7G7_!f(cq&~i~93;ys(MMJim9ndlM6_^cnurd&4IHTr3+Q|hXCX}izu*CDPT{Dh zlcX6x{g9W@9*C4paOu=duOlICZa^n_dOFF|(I%fsQv**=4LH=v z8Hi^+c)~nf`Nol88KoYp%+?W)Xg{SOk7$WsF)l=7-iN5<@2qFnKT^}ffRrfLf6h)n z{y?4IRc5!e=n7fBDJ3J}0YM%p_n=sWYi``jE{!QqCzb7KcwygAEEe`6#>eJBNYsnU z%2R!|`q5z$=ZNtnRZ8Je%E+B53?nv>tel^qvb?E`&*~n6JyEHA?~?6z=vYgZEmy(rv4d{-&_jT3J^F){z*#kY02Ua31D@+PC9u||) zz9_$`D*tXew?UN&F+om+V^V)K!DFq$NiZHg%)Uq~pXXRLicS|0>Jt5&mM^l|GyCi6 zvz;SVY4u3QVi{a3VEnc~QJxc^KP=BF!wAqHZr-S_kye$>xdS_<8X-!Iq+^l8c|>q7 z>Dz4ll7|Mxq!5Wm6MpvMbaDu-o9OXgDUR+3Ya426;2*mB_C(LO!4uzJKRa;aT37Ay z=1A~HQ(y9A-QQ|X)r!K%#UF;6k~O#ge)VeCrBI}Ol z=AxN!!#!g3I$HXE{m${8>vmYIEgy?Cdd-|-*osCr$c;vhX^TheA)Q@$tSqk}9FmBn zO=Wiq-Goa$&94Vbtj@rdjJer>5M_`h57;5Au6$(CC^|E;imf^}+^!H^%QNavZvjz& zAM29y7-RL{`Hj53VW3gQ4RmdXvU(4Q*{8ee*2jYM$!3(%%qDD_<7byIXe1)Ay?;+IfLObkHYRdMWnEZwJJk^nkpXQw5t*rcRF`36TZC zd3PWiGx_}7{{8IhHSDMG_IZB%bHVPUIgrV3FR_I4ifD#m2W*}44qG%UwEY4D z5QHuD=|p~RKfmxxetZ@1VbHRf-*E=!F`Q$(dP}UqoJyJEuP-S%ZqWu0Sb#a7b1{0x zrgKJu2Aj^=z}nos+~#&6*5>XhQ?GSM@9^a!<#FoN#Q30;6p|yp;i!1CK)FV4bq|9D zkY>9aOiKMpQFc_e*|ebQGc;{J((X!2=qg7uJou8eb)~E7^s!N$9T}b7n0!qgsNdF5 zbEoecUl3mz<_-1+Is52$n9|+_x6E+jEg?>`#p?Hj;eKip*KhTRXpg`>4M&^@4a+G* z97cC#GU@oh(Id#B&woRZ2E}MBDkS`5vdvAB(hxd%ihPaEMiWBpU`y=4YesKqyLdSD zz1BX!w?KC2LxZsGM2cbWh=kaSiJj4j6pK;!Y2%@#kD|k+ecVu(O|9qJ(TVi@g~qNnbiPK%?=uLFf(2`RaPM*>I>}3^?$g~dsaJ>% zNOG#%*Kw&84WhXVHNU388Ew%kTsNpJ#?YA2M(jRL)a|BSO70q}3iF2T?q!-VyO!KH z8kjaO*{^{J4yj7uw~ElkG0TodJ2^m}!qz=jxV_`TE$ee;ulo=~jQausjnJbn@nMTC zlXw`7^O!Ft_NC|p{Ca#FHI@>A0Ccb0s~dF`9rFo^hz}k!>6sgS8bVkA&8MT$=a+|i z;?Yz}jQEeCKOaf4;OiOD`!x_+(poO6AK_lr*6mu-0x!N%b)SeJ`FsigiFA73qN}*jn1#!p-e%ZHT9$pR|J_ yPnh;W1FvoEouLi`vE))yrxBKg<~lw|)ShSy_zH9pwg0HC`{sY^Z^) zBi{JI!+?#ku_18~I3_ko27XLb-K|ga7xq?FcdJ_6l3J*F?AaItqWjkEx^-{eS5@7< zbcwq**!AZ&t|#6%z`2~CyF47%5{$+o!5dt|=g!Y-JPz(=IKs6B`{Q>x``v~*UtP_` z3zsgzf9_A=XzU6X3J3h5UiiqtzZQSYf5RW;uW+~h;Q>Av3i|so=%r)*!!5h`$Q?ey z!FMzm?*9kcsS6h_|KT5NoGzz}>+*JUUv#v!_?rLJ)Y;VB?d|lzC0}z}tGB)T>K9)a zUFQC9`Jc;T==647Z|m;Tp^&c^!_m>!*3{kF(GH*5+r7>BBSv9xS;0{2taW+_hW4gw z-Yza4yV=lpqlSHeC;x!%)@xqhUpv~pfK~mhhs}f8{>kaQ;&j^bFiuxZ9^du$)<0kO z0`1=BuQ9Qjza5FoH1>u&n8&C9iZ#d-g|wF_j# z7`~9&sQ(V%;|oN6fl!d|C-aEA%6;)gpw}Pq2V#5#H&t%xgvJo+sz!>MJH1WaUb%P1 zed6pFIPSY(&v#r9n!})Q?bqAdI5Nbo?SSAK%o_)Wk(=v4n>(&y7|tebABvk3tt~bJ z9tHFv-WTW%2Ltdc9Pf`6G;HyH)dVw&v$;S>RV-*~E|(a~y<8OLc5^No7r@&gO*=K2> z%F*y5_jEnqnQuBHS1VEc*~ zFP%wbtaA&vsz5yEs^6SxZtH0Nni{`i7Mc6E&em?P;@LQzTvHpkd!-F#OKTUo4P#E7 z!PfRJZ)Z0L-0CRShBzx-oZaPcIcgmqhuh(F)H&f-gVW(6Uml0e?R3{(ayDLadpM{2 zN{#D^$73sU6}NCYh&baP7aevtAggh>$yZG++^(;+2(!oGAw9<5wQ8U7x4TNrH4glR z@xrLoV$4?cxz>Td+yrwC?rOa<&b3uyu5;9>qhakh*E{M}4q7|T4UPs@>OA>zt`>8n zqmlHv2CmmyD$g!Jm+JJis?rvJ%j(xd@Sj)mIz3wpJ~#6Ep$(D{I{@MB3w8|lyL0ez}Q2ko^Oxp z`j&8y0f-$xJ3E@iTw`^bSLYUE+FHDAUQAn4S94Q~x2&UFZ-t{w=HaQh$OXFG$USCM zrWx0m0XO9qGw`ZQTnBc}CLT@;6Jz-2uM-eAT!P8?`9RN2u#NElax~_T1p}Ak1EH|L zCwf_C+-28gY&e~bZjZCh>2}rm+>L%uz!|8Oo06Zdx<*%{2P~;z27ueQ!jZu$jh3-4mx)O}0NW*7 zACUQYlpVGz9%k!__;1E+h0hbmKM08-nY{2Fl2`NJ;;VH4ikPdNq1>~VX!%ea8!gCs z_F|C&`@;v|`5}nj+!srZlalk?nSoD5bDoxroc8f@l5}fKOmE1!WIy({zmJCdi)FN4 zjEY-_D7k=DJWZ*_Z-j!;UXw(mOSNQDX%+Z0swZ<39174>rd=6f zdQ+O3JvWZf>EtNSyW6Fx61$>Q3cNB^vDx!VPUn;5CBd!{j7prhe2*AWw6lVFI?2iG z(V8%{eHuPjaaYTnhh@^S(xez|;!;Yww=Oa+EV(3xg^k^OWcFDFs;SI zb)a9Eo)l)*C_L3_!gvCG+&D|JJ;YH)sr_u`C-ihvcs!LoSOK!d%r0;V+R;u^f>}sR zFaZ|k_l5U!K(_FHQy8AfrYCZ1`#`*~@EZ6}@nnylp^?m4(pjUh7dj^{r?Qy|F*AkP z`)E_zehKZQ?KfiPfu6k-9;ijrSokrWb4lU(3S}>rj)hUo+nHH3HA=BjAqT4tXm7-$ z!G1m(J!NuhfuIA0f+q>|B&CWPowBr#Ko-3#Z9fxMwz2G{UJA*Nxs` znVik0*C_^-R@^-EBcABv%V$Su9S}siEUZkS@ipM6Fg_=3kD>Kp;1-r&(ph>J#1kg9 zIuqrF3_KQUBF>m-g($TWCHc5#A9fbCeu%0FZLFoj7 z!GjUsPEfSMuR~-@!CCRQ!C}iSY@+l6rB6()QMOVl=`C5)kXu-UmtW= zWRSMNCU8Qqu&$***-~*wsWC8GXf`Qr35=*2MA&i^l%YfZ~ zZ?m6qv@ySS<0PC)^DoH91w{j zVe`t#W)AfFNs~U%s)CX^1TCm8kzl5gCEB*Enmrf#wGzcjR9|sDNj09DAFOQWG+}@h zYbHff=u|Z_>n7?XV>U4t$$u05uh~l-j`>4ABc)sOvraXxir2!a1 z&|bRz=2dYpBQz`qubak|2E`OT2$lC4F7^rJe48wW1-Ks9Jt7_#K+a`>Y z{DtPWm_1h?tg*59YK8W^m?5`r6Grt$TuSYrgSnG8;>E1D8M9rPy|%(o--?8Pi1mVg ziS?GW*BZDpHrtxMob`qTAvp_bUi!F=4u?-{TLQt(Zc6|wcnbzA5)LsWxh(V-ux@@a zDs4QH#(rV_nbIm*+0c+*_Qs~5k7!lZg7)|aOPbHz%V8B2ygB8S;o7P!13P(7)-veztj0iSRa!|qp#?3y zPOODx7mG={H@9|}TS&2kfb+hYX_R~>rZ-p^l-$8v7C*kDf8rcB%+I-9wrxJ!D6b$V z=n}KMd_`8FXp$JOS`BiJ0H%V_5w%@;OascFz>5y@4$L`-qwwDew5I$F*xeo#qc zZ{c+Y(nA&ouYd#jBt1%3z=81G+eH`+y2waVrGScFWD#1@f4tU9mO;=j3)zDyw7QQ* zGAwJddkHapB&T|lJ1j6}3IngQBlq$F+WrKC54@o8lyIB_PcH!bE~CjbT3r=0Gi2e$ z0y5O4T)Nr{qeEpvrKI34bX{JNoq34f&8Y-{h@kWvc`1-x>m{jcRg4O-SwMoh$yc@a zb-O?OLx}IW#rp=rU|LFgU^>_{_QG`iIredjA~}OoJ90s#NxZjzQvRFz0M>;Y@JC>X z%M5~;{Gc{g27x);ZRq@)dP^%BJn0uZsXT#91d7gp3Kkf;dx~iP^yv{jMkKNcdYrJ;- zkd*Y?_24FxJ^F@KdmhRu7GEVUNbyEz?28odn0co}#Zh9#hMd|`RA0ffN-Yc(n(*Ui zBm6-h-Jel`X=!T3yOEOTXM_GZGZt(u53IuV(u8##D<2Lc=4>fzKA8!^ufTEXpT;sG%98KptCMUbXjF)s#+r;Q#&6CjJ%hIo}8?KDWiflmZpS) zD`|2{v@>aYN?@qk2n~dzL1hWL+&M@c>=~OJU4P758ntP0i$OCw$J|OFgKN*)A8XxL zUvNnYwDhM6$?2TOKa6R@>&U36)32y#Ob1tLzSD{EMt0 zxEu{EMtyCd3Nd;dr1B7hQeVobLXm9XLv%G;6+_RMBjig4Kl`MBV0cE45EO+C&Zd(3S&2zWgQE#;|^8iY% zHmaII?i!5KZ6Sxuy9w4soPB)X4ZO|KOvI_co|lMI>CXv9oE7bK*X}~Ius#l2*`(dd z?rn)n!|ZOf@58}<^J&36#_<W{B85f5%+gz^8SL_eF%x2{ zpeGh$YM!7AF->gAE*D-}5nbY~mk$?DHM-PjWaH>kRea{6OBLK?bg9C#(WR$w^N^c! zHIz1Rg=OVjS88jtdy<$~v!)vSWg|}`K?2AbwoVx&8$4*8%p16U1Ln3K{G0 z=VhXjtChP=sytifHktC5&W)pV{KEmq+eu%{sbaU4VOYxx;L1)?=9Tzj$ok!DvPGzL z>soeiTug6h-AZc@qtPAbw!)sWXVId!gP~BlQhj^q=)STgmf2u;0+j^i4#Kb`-yXj_ z-&rr;|G(Z@rhV~84^Njkdf0;xrnZx*N=iQTYo{uxsNY!BB`vaDOXrX>3+UwSV=(&V zJjq52Jjv6jx>sPH44p@_UZZJuQG1|5yM^8s;cpQOOzIXz@J99BRcZGXT3F^M|Z@hay`2nStC}O%VUPvyAbFXDrKv944U;NzxJ`%H8Q)Q1F3jmO4 zEF9r=RInwTSQ2objS4$aij2D*<@81m?HIQ10chn0}*bBZ?uLp1@_w z;b}yQTU()iXfS|E=!fs8^w$sjDSv~n8O1ms&gd9aEVx^m0Y0W#HYeC^K6cw zn%EVqoOqx9O`Me!nOB8I+&Wa2ZNO18D(Iu#YqnH_!If~w*TH8dN&#i0mDtpMQGk9#gLBP>n>3AvFya@IT}y^(9cmuCPt_u|t7;p3Vx|IiE@FiHDI(%Nfb zXV(-nb~mKaU0NBz3^UMlCifIZ^PQO9FUpTE@wdtkwo5Sd>~NrwkHX3r`jkfN8+d3a zq+BxG0)F5%C|kjsym1({Gn{+*P_0&vBR}BC(WJOAiY7;diJ#HqpR(!YqD(g~ z&G;vDznStVcmDvJ$Kr#6xweu3sTIfeQSvP<@u$_O>Ax6aiMdAw4}1hdSxQp0Uk%A_r`?nN8;`a z;0Z7g;(Z_dqo={afrftI*|h&&N~aB>e@o2%mUw6gPs)+JMk7<`;2nBAB~5LMV?ScQ zbniZW!@+NfdkPzB43XDVikA*j)go1gx5E``VY!w#NlZbfz!>0npT8k5<|wO$QQ{EI z{>;An;D6gYwx%YM_51#cJXn=jWqF8zvRg%!6h`GNf(mGPUgAIo7)>%bNl<(OF~D*W zZ@9-51b0zb7eUYBatX?hIhjfFw146BZDx8hw+@*EPL(`RnVGM9dcN-dZv9Rc(%2?RiGuKjlDpsnEFP?4 z|ObNCW0wyMp z?6XngH86Sj7?P1s(nEsNu>Lz)v*|S09zd$efBgzJ5=?IoW)9)Yj44lMsNuw!BARR{ z5rM2;^6Y!e6V#$#aB<`FmMRcu^LWT$kkLS1{zX~ZlBZwbG(>=*D|0XN5B`In{&#pJ zyZcn0T*a3_>-7L>3{rgA*0;o+Xqdmp_oR}d6i$MEovd(X;6t{?nFqvfm4o163onUc z$3F}>$VF4cJZ94Lp~{{}a3f9AX`wdj#h{H^%!;E{ zY60)8omw=YPA<<0y+egpV}R9>Dy(pGk@Bkb^{`Mm=0-TFaL1KaVqJf^+H|=^nSCm! z-$DkC+TvuqW)G%%=)q*WK`q8t_C`(eE(ypnz#(r)JcvX0Qo@I}757p?uc?@q66)QM zBNZUmi-O}eZZ7D3^pQZxrch-c9$cwDM_k0UxF8xTD}Lf4I|oOV;81I$dvk9br^DZ2Xh`NmF6AL@?2i^bwY!x z;FCl#AOp82pYO=w35+vWCqQ&kSA2B;=>cHCmzvs|E+CBQ$NlLwd3IZw+XMnT_9V)$ zJd`J%%1@_@Dz`;=FnLK_ZY_AQvv4|~+AmN4oZVf+FTpbab%=k4+b@T%Hg&du1*H!X zfy(nA<>_5)cCz%A@_LZ9&?9A1`Mgy$$c~FvgT$a!A7tGLKlX>gRlf?Ou;yuJsQF?T z?Z=k33pyH6f>;_F&hqf6vb62>sOqs{XMP6SlgDTAsIGK1wYGHL2!r){t))3s>~ezq zc`$tL)sx)9Z_w4}`A&ZSmDi&?O_xT)i1;|0%fKA8ZK3Po(51_*?KeUp8iU#GXJ9&s z1CLF)wL~%%84CAFVrro5LAp?Z@5?D}44svbh=m7l9=}}d+#hAU%tLqUhE^RLB z>plDUX7CdH_9G*=yH%X0wp)PSd`2+5%K~_}nZ?%P{ca<|(%l>7OSJ<6QLPMem^YB! zURlx7nxGrSOf%VZ7CFuEj0xf_T6)K_Bz{1YoRgB8V2=vJQpZy{epOPGzBmZ0x|UeA z4chiM9%_}yB7l6v(GXED&hBZjM0;ja-hD^cG6U1i$NNzB{4xzbFlF52e(u4JnmwGm z4U1p%<@YRqO=DqH>pYR_>(dSq%IqC?jY1^WReMH~6QYnx$GPZGLMZi(_M3`RLi$0c#MK)6&EG1c06%mx67X#r*mk10($wQD!tRF|ts*%m*BB|d84GS+NVBj#*eR0A)SiE0hKE0SWH0t_kZ75W^hcT-CtJ(K&guI6+VV*|-> zl8+5U!EnpxinEV%%e%_z8)adigaN=aWLz;0;2I?%cBwu0FuV6rSsIte^;)qNjW$P! zq@&GYq1IG)ROn%LhEK1_d&9)|K+2ye4f&pqhvX(M&JAEmyatATMqJ2{()=o1&YtF7R88*hQLc5&J6ob=|;OCxGX^Zq@;wEd&Ba|6fH6F@rYeN z#paf0%EBVuR4c)p-~k#Uj#ZeI>5*LKgX!?3!ZJ0sZyGW<5&Hy#hJqW@T1rw(awS?k zHA^L>e@Hycx51!&K$O3p&u_c~Oyiq;YnE@FGj>7&SliG6eRH$O$d;BbmHgP5zO)zE z7`{~VL-ovFcnMcaHa74U8|hf1;EeLI24AY>OK({=_e5*U)dQGe239{_rx6-D0>+0lh0qahLmC*;@VeJVD}IZ~ zMN%7ISq_qA)zU;OiAdR1$o5;jGYv$C-Ug1;h6Z$2#A!%FWIpzQHAX6Ed0$CtUO!Ht zY7d0!o4EB<2LqtpfKU|$x7VTAQ#YW~EIplO>FErL+;#py2_AD>d@cjehF zHM$~^xGPGLa6gymFIQ2t;F=p_xup^L*@V13jbGTCibkzs?7S-0j?9bl$}?@Y`hYSC zlR0^k)2LXL(v#eBgMbZ4mGcvnmpA3nS=AVxJIaIjw@(Y92qzz0Am8=4dmDMi&6;;(d%KUud&u_t%x@J4dP{@{x|k z(z#YZ|80UIJSRYYn4S}c5ge#LG{kLy))t+pQ1aCL>N~i1o zQFEr2=Y}rdNi|6|_rJe>z3WOS(mr(lYOLu_&%OI6gP#Jasxh}l?)sIv4syDTmAM$xY!&$>YL#I?+ zIil%Q`Dnr@pwZOJn-PU`J{w@S-e$kdvttRLF&VLB5iMTpXG0s^fDYy&DXYWXyl@LF zeZO|+WcMvQEY_BfMH0Pc%mr>nBOT;MBFEIlqxO)=tvr#JR{#!CMAD|bI|**$OD!L_ z2TQC@$5jGyvwlvXkd^4SLsnh+$Rbg6Mr2v7Iy&607+uRV%FpjGqA-5UOU`4A)t~U| z3An?UM(H=ewH?apJs@VEA1Yg)it?NrQASb_(fQ1`C;r}>u9tT}4FVHn;k4=K^Yi=T zxi@RjPvPB*!swTx-AR&_qu*X)2_^tiGo}+vY1a_YX$K^+PvR4f z#x|Q4#C!&(&4=1uYYAOvXa)ygvbC;sQ=K+8VzWb|vm2AIsRQ)e8fxzJedi0pEB$zb zzJYWf?T((=xA>M3j=#snNLpv@9yi!W&cwA_-7skrzNg}dQPD7I8T=r)n?REe6^?E$ z!TbETIdOm&q5>E9qscZlk;D|xJbAW8=Y=>IJ=hXE@S4#Y+AbbOd#||<>|2m<=tG6D z?L>@1?}#|x1Bsp9i5QI%_i6p1sSnHHl0IPbc+jm4{Q(YbtVUyK6HeUaBSNAdD#KQr zsHycrJJ3ikUTW-W1Nt>kzs~_U0v4?G;d@sbfhI2|Z=Ssw6??d7zX-EQbzEr$gJ|vo z&2Ol1dRy=c*A1HBqhQQnBi=lT%o*+Vs^Z^) zBi{JI!+?#ku_18~I3_ko27XLb-K|ga7xq?FcdJ_6l3J*F?AaItqWjkEx^-{eS5@7< zbcwq**!AZ&t|#6%z`2~CyF47%5{$+o!5dt|=g!Y-JPz(=IKs6B`{Q>x``v~*UtP_` z3zsgzf9_A=XzU6X3J3h5UiiqtzZQSYf5RW;uW+~h;Q>Av3i|so=%r)*!!5h`$Q?ey z!FMzm?*9kcsS6h_|KT5NoGzz}>+*JUUv#v!_?rLJ)Y;VB?d|lzC0}z}tGB)T>K9)a zUFQC9`Jc;T==647Z|m;Tp^&c^!_m>!*3{kF(GH*5+r7>BBSv9xS;0{2taW+_hW4gw z-Yza4yV=lpqlSHeC;x!%)@xqhUpv~pfK~mhhs}f8{>kaQ;&j^bFiuxZ9^du$)<0kO z0`1=BuQ9Qjza5FoH1>u&n8&C9iZ#d-g|wF_j# z7`~9&sQ(V%;|oN6fl!d|C-aEA%6;)gpw}Pq2V#5#H&t%xgvJo+sz!>MJH1WaUb%P1 zed6pFIPSY(&v#r9n!})Q?bqAdI5Nbo?SSAK%o_)Wk(=v4n>(&y7|tebABvk3tt~bJ z9tHFv-WTW%2Ltdc9Pf`6G;HyH)dVw&v$;S>RV-*~E|(a~y<8OLc5^No7r@&gO*=K2> z%F*y5_jEnqnQuBHS1VEc*~ zFP%wbtaA&vsz5yEs^6SxZtH0Nni{`i7Mc6E&em?P;@LQzTvHpkd!-F#OKTUo4P#E7 z!PfRJZ)Z0L-0CRShBzx-oZaPcIcgmqhuh(F)H&f-gVW(6Uml0e?R3{(ayDLadpM{2 zN{#D^$73sU6}NCYh&baP7aevtAggh>$yZG++^(;+2(!oGAw9<5wQ8U7x4TNrH4glR z@xrLoV$4?cxz>Td+yrwC?rOa<&b3uyu5;9>qhakh*E{M}4q7|T4UPs@>OA>zt`>8n zqmlHv2CmmyD$g!Jm+JJis?rvJ%j(xd@Sj)mIz3wpJ~#6Ep$(D{I{@MB3w8|lyL0ez}Q2ko^Oxp z`j&8y0f-$xJ3E@iTw`^bSLYUE+FHDAUQAn4S94Q~x2&UFZ-t{w=HaQh$OXFG$USCM zrWx0m0XO9qGw`ZQTnBc}CLT@;6Jz-2uM-eAT!P8?`9RN2u#NElax~_T1p}Ak1EH|L zCwf_C+-28gY&e~bZjZCh>2}rm+>L%uz!|8Oo06Zdx<*%{2P~;z27ueQ!jZu$jh3-4mx)O}0NW*7 zACUQYlpVGz9%k!__;1E+h0hbmKM08-nY{2Fl2`NJ;;VH4ikPdNq1>~VX!%ea8!gCs z_F|C&`@;v|`5}nj+!srZlalk?nSoD5bDoxroc8f@l5}fKOmE1!WIy({zmJCdi)FN4 zjEY-_D7k=DJWZ*_Z-j!;UXw(mOSNQDX%+Z0swZ<39174>rd=6f zdQ+O3JvWZf>EtNSyW6Fx61$>Q3cNB^vDx!VPUn;5CBd!{j7prhe2*AWw6lVFI?2iG z(V8%{eHuPjaaYTnhh@^S(xez|;!;Yww=Oa+EV(3xg^k^OWcFDFs;SI zb)a9Eo)l)*C_L3_!gvCG+&D|JJ;YH)sr_u`C-ihvcs!LoSOK!d%r0;V+R;u^f>}sR zFaZ|k_l5U!K(_FHQy8AfrYCZ1`#`*~@EZ6}@nnylp^?m4(pjUh7dj^{r?Qy|F*AkP z`)E_zehKZQ?KfiPfu6k-9;ijrSokrWb4lU(3S}>rj)hUo+nHH3HA=BjAqT4tXm7-$ z!G1m(J!NuhfuIA0f+q>|B&CWPowBr#Ko-3#Z9fxMwz2G{UJA*Nxs` znVik0*C_^-R@^-EBcABv%V$Su9S}siEUZkS@ipM6Fg_=3kD>Kp;1-r&(ph>J#1kg9 zIuqrF3_KQUBF>m-g($TWCHc5#A9fbCeu%0FZLFoj7 z!GjUsPEfSMuR~-@!CCRQ!C}iSY@+l6rB6()QMOVl=`C5)kXu-UmtW= zWRSMNCU8Qqu&$***-~*wsWC8GXf`Qr35=*2MA&i^l%YfZ~ zZ?m6qv@ySS<0PC)^DoH91w{j zVe`t#W)AfFNs~U%s)CX^1TCm8kzl5gCEB*Enmrf#wGzcjR9|sDNj09DAFOQWG+}@h zYbHff=u|Z_>n7?XV>U4t$$u05uh~l-j`>4ABc)sOvraXxir2!a1 z&|bRz=2dYpBQz`qubak|2E`OT2$lC4F7^rJe48wW1-Ks9Jt7_#K+a`>Y z{DtPWm_1h?tg*59YK8W^m?5`r6Grt$TuSYrgSnG8;>E1D8M9rPy|%(o--?8Pi1mVg ziS?GW*BZDpHrtxMob`qTAvp_bUi!F=4u?-{TLQt(Zc6|wcnbzA5)LsWxh(V-ux@@a zDs4QH#(rV_nbIm*+0c+*_Qs~5k7!lZg7)|aOPbHz%V8B2ygB8S;o7P!13P(7)-veztj0iSRa!|qp#?3y zPOODx7mG={H@9|}TS&2kfb+hYX_R~>rZ-p^l-$8v7C*kDf8rcB%+I-9wrxJ!D6b$V z=n}KMd_`8FXp$JOS`BiJ0H%V_5w%@;OascFz>5y@4$L`-qwwDew5I$F*xeo#qc zZ{c+Y(nA&ouYd#jBt1%3z=81G+eH`+y2waVrGScFWD#1@f4tU9mO;=j3)zDyw7QQ* zGAwJddkHapB&T|lJ1j6}3IngQBlq$F+WrKC54@o8lyIB_PcH!bE~CjbT3r=0Gi2e$ z0y5O4T)Nr{qeEpvrKI34bX{JNoq34f&8Y-{h@kWvc`1-x>m{jcRg4O-SwMoh$yc@a zb-O?OLx}IW#rp=rU|LFgU^>_{_QG`iIredjA~}OoJ90s#NxZjzQvRFz0M>;Y@JC>X z%M5~;{Gc{g27x);ZRq@)dP^%BJn0uZsXT#91d7gp3Kkf;dx~iP^yv{jMkKNcdYrJ;- zkd*Y?_24FxJ^F@KdmhRu7GEVUNbyEz?28odn0co}#Zh9#hMd|`RA0ffN-Yc(n(*Ui zBm6-h-Jel`X=!T3yOEOTXM_GZGZt(u53IuV(u8##D<2Lc=4>fzKA8!^ufTEXpT;sG%98KptCMUbXjF)s#+r;Q#&6CjJ%hIo}8?KDWiflmZpS) zD`|2{v@>aYN?@qk2n~dzL1hWL+&M@c>=~OJU4P758ntP0i$OCw$J|OFgKN*)A8XxL zUvNnYwDhM6$?2TOKa6R@>&U36)32y#Ob1tLzSD{EMt0 zxEu{EMtyCd3Nd;dr1B7hQeVobLXm9XLv%G;6+_RMBjig4Kl`MBV0cE45EO+C&Zd(3S&2zWgQE#;|^8iY% zHmaII?i!5KZ6Sxuy9w4soPB)X4ZO|KOvI_co|lMI>CXv9oE7bK*X}~Ius#l2*`(dd z?rn)n!|ZOf@58}<^J&36#_<W{B85f5%+gz^8SL_eF%x2{ zpeGh$YM!7AF->gAE*D-}5nbY~mk$?DHM-PjWaH>kRea{6OBLK?bg9C#(WR$w^N^c! zHIz1Rg=OVjS88jtdy<$~v!)vSWg|}`K?2AbwoVx&8$4*8%p16U1Ln3K{G0 z=VhXjtChP=sytifHktC5&W)pV{KEmq+eu%{sbaU4VOYxx;L1)?=9Tzj$ok!DvPGzL z>soeiTug6h-AZc@qtPAbw!)sWXVId!gP~BlQhj^q=)STgmf2u;0+j^i4#Kb`-yXj_ z-&rr;|G(Z@rhV~84^Njkdf0;xrnZx*N=iQTYo{uxsNY!BB`vaDOXrX>3+UwSV=(&V zJjq52Jjv6jx>sPH44p@_UZZJuQG1|5yM^8s;cpQOOzIXz@J99BRcZGXT3F^M|Z@hay`2nStC}O%VUPvyAbFXDrKv944U;NzxJ`%H8Q)Q1F3jmO4 zEF9r=RInwTSQ2objS4$aij2D*<@81m?HIQ10chn0}*bBZ?uLp1@_w z;b}yQTU()iXfS|E=!fs8^w$sjDSv~n8O1ms&gd9aEVx^m0Y0W#HYeC^K6cw zn%EVqoOqx9O`Me!nOB8I+&Wa2ZNO18D(Iu#YqnH_!If~w*TH8dN&#i0mDtpMQGk9#gLBP>n>3AvFya@IT}y^(9cmuCPt_u|t7;p3Vx|IiE@FiHDI(%Nfb zXV(-nb~mKaU0NBz3^UMlCifIZ^PQO9FUpTE@wdtkwo5Sd>~NrwkHX3r`jkfN8+d3a zq+BxG0)F5%C|kjsym1({Gn{+*P_0&vBR}BC(WJOAiY7;diJ#HqpR(!YqD(g~ z&G;vDznStVcmDvJ$Kr#6xweu3sTIfeQSvP<@u$_O>Ax6aiMdAw4}1hdSxQp0Uk%A_r`?nN8;`a z;0Z7g;(Z_dqo={afrftI*|h&&N~aB>e@o2%mUw6gPs)+JMk7<`;2nBAB~5LMV?ScQ zbniZW!@+NfdkPzB43XDVikA*j)go1gx5E``VY!w#NlZbfz!>0npT8k5<|wO$QQ{EI z{>;An;D6gYwx%YM_51#cJXn=jwdElK%05sfg;6;NK?SrtFL59Pj3ybJBq%twSb(QzZ{nX6Eajp0B&VTYndpMMi3P zfSt4T8B#Y?*n@57|6Ukg#8nlTi1Meo+%l=1f}_Y7S*7{AG`2}nq9A;s{A1}(UmssW|Kl}vHSN#1fQ-UppfQiW? z2W*sh9ZViRhGgWE^pM~*Z2U&nY&s3L2asy=U%!Hl1k>AtnM3$8W6F~mYWV(a5luFf zh(J~^dGxh|Ew%+%hNA$8X~~ZmAO~>hyTS-{~8|2 z?md$y*YG9KdObiIgA`x3^(}EX8s_iwJ*lK9g_EFPCo7y8_=v4><^i!=F=(R}v*M_g zTEIJNrxp#Ulgsl$?@;0O7+`gz3M<@Pq`Ycb=Ey-}0AO9FBXaKsxD58}wZl<=W##l4i!YbxfYgnBpR zNCn9CqTslVn+tj$eI!t_DO5Rt2Un`k5f^bS?uie+C?6Tt9AbE|$Huef2%VVRoqEjf zI1Da7)bs_YixPc;s1+Namha(g!-%%n*oCPPGNt}j9flmRQ1@fGd~0E$>XzlR9Cy2T3b4AhQWHh-qIW@b~!=* zJQzOr`e|42An7F*Q*3AYG_H`J%AI332E|1Jo{fKI|+On}Cm6L^chGU0hCpCeBfh z+5MmMn+w)~j2`)p!0L~H*-){I6>(9)uc779GCfG_UO_vB3_{akI3-0ZlU+8y`Ju4* zhw^efoB6ZrS}XldwVHsVxQv?@S9K!`2`s222f4O$bO8S5^r))ZV$43^)%A!Lmo}I6 z^`3ovGk6Jp`>~PR-73yg+buwEJ|h_3WdXd~%wlWtezy@}>F$m4rP=|3s8$9!%p1sV zudHZkP0)>ErkQLyi=1Y7#sqN|Exlt|5@jjG2zf6MhRCmw1!s_mo$g zl`0N`d#4XBdH|v66Q!ZbRCXg@Gcg8qMN({2fFWhQLZ2h`ZfYr{XL4UQ)SRwjY#<-*UEAEA$iT9{prH24-%eq1zY%W|%IJc33T z0y>c}oerr>FhH$G)>0;Jr!VDI?jQzOv~Q}!+G*xz&Dm8Z!^YTEXqU5@VsJWAoc4kQ z^ll2CGvFV&`9*o-jpkuXLU<9n{@$O?Wk$2ROSDrU46xl^)jTT=VX~A-8c>dw>mkif z?Hc0fT4E1-ni1d1{TEb3vKktnw7p0gO@M z#%g4-pN{{=kDzA`;)u%Uk=)u8hD3p}Kg#Jf>_wj4n*vSz>fw?hPPt;A08fpu2L(DR z%WtgZH=uuXaX~|4K7m~3H)U?Zx(PX&spPPkse^~YyQ+LKiSH`#u@T^P)sa<%FUh8b z@a>e;646e*F2VyC92pp9;9-B$1%tsOsmj$Swl$5Y?e^_3fq9rCi9=>RGHUc+p+h)u9`lNgn6D;=rMVqCBm}8q(I|AUJ`l*_M@u8`|129 zF(yR0L^2!|`-C{lX%);?^W<^vRYv|YN1IVJx&TlY?_)gqLYqB%u%W!zJys==k991T z&b0#iZxa;ZIRWy+^qeq^0QuqOjp`a{RoUcNc*j&DM2V7gOmaAj2+kyZ8xOw{sR3T% zA~7NEr!US)DX?yYQ$1n~+z-|^)YRaA;OfNb?i0b&CvIQpzjL#z_Ed8uc&DjXI#c(L znzOY$H+1E0s!6JO@WYK8T~|Yq_Mr>cVoi5@?msvs1Z!ZDo&2Ba+}aj1|0#~-f#(Yg2k>Bp%CHN_b>Y=7ew?aTy{X0gkk1Pm&KeFII;G;u z5lyGdM-xT?jiz4Sj3}J**#Nu!F8gJk9ZUF($%rM3Xz^M<8`|gwbTAi5Ssm`?h1+21 z`?Wi#x^LTIv9^3HlIS&KE^sp%=^!@}Ii@ZiwTDb@^{KqP3UG)blD6c%NpKTiYWcW5 zSYmZLt`d-&^>YG+tVF*Zvg*o57Kx%WBFk#k(cyN*=vtmpepD%oW7zP2@Ql6&^a|eZm zUkamZ7#}(@8J%|>2pV+WbscMS zWBIL}Vyw-L$&+t2Nbm6FBIPmK)cDwdC~?w|Z&2Xx6&csity_cm1dybq9gxI6iBC8h z+iY48^BI^nA8L2KC3J(K8614c*1FP7b=ug7%?^#uZcM(W4$yCFsJYYkoi7Nl^y3Zs z2GV`BJ9=i{;#)>I{yrBYX`Quu++ZI$6W4Ba!=z34o{A$zMZ=_J@PpuP0!=zpIJ&t6 z@AKc`!~tH23S8WeCfnFV5>r6)BNjm3B%fVhz~!e@~AiZRD{p~s!vCw&!0$j$An~(kN8i5KOai5 z;OidJ`qdFy(pt8(k1(%l^L8z1fm?4>%_kyAI19APh5y~&7HT}#-g-W)U1D2~!3qRc zbog@nc><|kQEnE&Is`|~o5O&0hnp{jn*Y^-TbS>g+hP-%p_$7`PpI}m9j|TeouLjL nvFK7Yry-UF<~lZj%${Hi_-I>1EiBeq6a4Of0r0-sYTEz+4n|r@ literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_080001.sql.gz b/backend/backups/kaopeilian_backup_20250923_080001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..24b3ea77015f3e5c3b11c8e3496aa9cb6b9b62e4 GIT binary patch literal 9912 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 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_080321.sql.gz b/backend/backups/kaopeilian_backup_20250923_080321.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..6a6c82254af39186a1810b2beca33f1b8eceb088 GIT binary patch literal 9913 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 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_090323.sql.gz b/backend/backups/kaopeilian_backup_20250923_090323.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..d54956d3656a43ad6f76b29ea1bcd15a6b0d9876 GIT binary patch literal 9913 zcmV;qCPvvGiwFqY@zH1i18ZS#aAj$1X<=?(Vqs%zb#PxYFfuhTIWjX}FgY+YGBYl7 zacltWJ!@ARN0y)0zoIu^B+tsR9z4Cr=Y)VV!v;eJ@b1oR_9(Jw8^r=KdN^kH>^Z^) zBi{JI!+?#ku_18~I3_ko27XLb-K|ga7xq?FcdJ_6l3J*F?AaItqWjkEx^-{eS5@7< zbcwq**!AZ&t|#6%z`2~CyF47%5{$+o!5dt|=g!Y-JPz(=IKs6B`{Q>x``v~*UtP_` z3zsgzf9_A=XzU6X3J3h5UiiqtzZQSYf5RW;uW+~h;Q>Av3i|so=%r)*!!5h`$Q?ey z!FMzm?*9kcsS6h_|KT5NoGzz}>+*JUUv#v!_?rLJ)Y;VB?d|lzC0}z}tGB)T>K9)a zUFQC9`Jc;T==647Z|m;Tp^&c^!_m>!*3{kF(GH*5+r7>BBSv9xS;0{2taW+_hW4gw z-Yza4yV=lpqlSHeC;x!%)@xqhUpv~pfK~mhhs}f8{>kaQ;&j^bFiuxZ9^du$)<0kO z0`1=BuQ9Qjza5FoH1>u&n8&C9iZ#d-g|wF_j# z7`~9&sQ(V%;|oN6fl!d|C-aEA%6;)gpw}Pq2V#5#H&t%xgvJo+sz!>MJH1WaUb%P1 zed6pFIPSY(&v#r9n!})Q?bqAdI5Nbo?SSAK%o_)Wk(=v4n>(&y7|tebABvk3tt~bJ z9tHFv-WTW%2Ltdc9Pf`6G;HyH)dVw&v$;S>RV-*~E|(a~y<8OLc5^No7r@&gO*=K2> z%F*y5_jEnqnQuBHS1VEc*~ zFP%wbtaA&vsz5yEs^6SxZtH0Nni{`i7Mc6E&em?P;@LQzTvHpkd!-F#OKTUo4P#E7 z!PfRJZ)Z0L-0CRShBzx-oZaPcIcgmqhuh(F)H&f-gVW(6Uml0e?R3{(ayDLadpM{2 zN{#D^$73sU6}NCYh&baP7aevtAggh>$yZG++^(;+2(!oGAw9<5wQ8U7x4TNrH4glR z@xrLoV$4?cxz>Td+yrwC?rOa<&b3uyu5;9>qhakh*E{M}4q7|T4UPs@>OA>zt`>8n zqmlHv2CmmyD$g!Jm+JJis?rvJ%j(xd@Sj)mIz3wpJ~#6Ep$(D{I{@MB3w8|lyL0ez}Q2ko^Oxp z`j&8y0f-$xJ3E@iTw`^bSLYUE+FHDAUQAn4S94Q~x2&UFZ-t{w=HaQh$OXFG$USCM zrWx0m0XO9qGw`ZQTnBc}CLT@;6Jz-2uM-eAT!P8?`9RN2u#NElax~_T1p}Ak1EH|L zCwf_C+-28gY&e~bZjZCh>2}rm+>L%uz!|8Oo06Zdx<*%{2P~;z27ueQ!jZu$jh3-4mx)O}0NW*7 zACUQYlpVGz9%k!__;1E+h0hbmKM08-nY{2Fl2`NJ;;VH4ikPdNq1>~VX!%ea8!gCs z_F|C&`@;v|`5}nj+!srZlalk?nSoD5bDoxroc8f@l5}fKOmE1!WIy({zmJCdi)FN4 zjEY-_D7k=DJWZ*_Z-j!;UXw(mOSNQDX%+Z0swZ<39174>rd=6f zdQ+O3JvWZf>EtNSyW6Fx61$>Q3cNB^vDx!VPUn;5CBd!{j7prhe2*AWw6lVFI?2iG z(V8%{eHuPjaaYTnhh@^S(xez|;!;Yww=Oa+EV(3xg^k^OWcFDFs;SI zb)a9Eo)l)*C_L3_!gvCG+&D|JJ;YH)sr_u`C-ihvcs!LoSOK!d%r0;V+R;u^f>}sR zFaZ|k_l5U!K(_FHQy8AfrYCZ1`#`*~@EZ6}@nnylp^?m4(pjUh7dj^{r?Qy|F*AkP z`)E_zehKZQ?KfiPfu6k-9;ijrSokrWb4lU(3S}>rj)hUo+nHH3HA=BjAqT4tXm7-$ z!G1m(J!NuhfuIA0f+q>|B&CWPowBr#Ko-3#Z9fxMwz2G{UJA*Nxs` znVik0*C_^-R@^-EBcABv%V$Su9S}siEUZkS@ipM6Fg_=3kD>Kp;1-r&(ph>J#1kg9 zIuqrF3_KQUBF>m-g($TWCHc5#A9fbCeu%0FZLFoj7 z!GjUsPEfSMuR~-@!CCRQ!C}iSY@+l6rB6()QMOVl=`C5)kXu-UmtW= zWRSMNCU8Qqu&$***-~*wsWC8GXf`Qr35=*2MA&i^l%YfZ~ zZ?m6qv@ySS<0PC)^DoH91w{j zVe`t#W)AfFNs~U%s)CX^1TCm8kzl5gCEB*Enmrf#wGzcjR9|sDNj09DAFOQWG+}@h zYbHff=u|Z_>n7?XV>U4t$$u05uh~l-j`>4ABc)sOvraXxir2!a1 z&|bRz=2dYpBQz`qubak|2E`OT2$lC4F7^rJe48wW1-Ks9Jt7_#K+a`>Y z{DtPWm_1h?tg*59YK8W^m?5`r6Grt$TuSYrgSnG8;>E1D8M9rPy|%(o--?8Pi1mVg ziS?GW*BZDpHrtxMob`qTAvp_bUi!F=4u?-{TLQt(Zc6|wcnbzA5)LsWxh(V-ux@@a zDs4QH#(rV_nbIm*+0c+*_Qs~5k7!lZg7)|aOPbHz%V8B2ygB8S;o7P!13P(7)-veztj0iSRa!|qp#?3y zPOODx7mG={H@9|}TS&2kfb+hYX_R~>rZ-p^l-$8v7C*kDf8rcB%+I-9wrxJ!D6b$V z=n}KMd_`8FXp$JOS`BiJ0H%V_5w%@;OascFz>5y@4$L`-qwwDew5I$F*xeo#qc zZ{c+Y(nA&ouYd#jBt1%3z=81G+eH`+y2waVrGScFWD#1@f4tU9mO;=j3)zDyw7QQ* zGAwJddkHapB&T|lJ1j6}3IngQBlq$F+WrKC54@o8lyIB_PcH!bE~CjbT3r=0Gi2e$ z0y5O4T)Nr{qeEpvrKI34bX{JNoq34f&8Y-{h@kWvc`1-x>m{jcRg4O-SwMoh$yc@a zb-O?OLx}IW#rp=rU|LFgU^>_{_QG`iIredjA~}OoJ90s#NxZjzQvRFz0M>;Y@JC>X z%M5~;{Gc{g27x);ZRq@)dP^%BJn0uZsXT#91d7gp3Kkf;dx~iP^yv{jMkKNcdYrJ;- zkd*Y?_24FxJ^F@KdmhRu7GEVUNbyEz?28odn0co}#Zh9#hMd|`RA0ffN-Yc(n(*Ui zBm6-h-Jel`X=!T3yOEOTXM_GZGZt(u53IuV(u8##D<2Lc=4>fzKA8!^ufTEXpT;sG%98KptCMUbXjF)s#+r;Q#&6CjJ%hIo}8?KDWiflmZpS) zD`|2{v@>aYN?@qk2n~dzL1hWL+&M@c>=~OJU4P758ntP0i$OCw$J|OFgKN*)A8XxL zUvNnYwDhM6$?2TOKa6R@>&U36)32y#Ob1tLzSD{EMt0 zxEu{EMtyCd3Nd;dr1B7hQeVobLXm9XLv%G;6+_RMBjig4Kl`MBV0cE45EO+C&Zd(3S&2zWgQE#;|^8iY% zHmaII?i!5KZ6Sxuy9w4soPB)X4ZO|KOvI_co|lMI>CXv9oE7bK*X}~Ius#l2*`(dd z?rn)n!|ZOf@58}<^J&36#_<W{B85f5%+gz^8SL_eF%x2{ zpeGh$YM!7AF->gAE*D-}5nbY~mk$?DHM-PjWaH>kRea{6OBLK?bg9C#(WR$w^N^c! zHIz1Rg=OVjS88jtdy<$~v!)vSWg|}`K?2AbwoVx&8$4*8%p16U1Ln3K{G0 z=VhXjtChP=sytifHktC5&W)pV{KEmq+eu%{sbaU4VOYxx;L1)?=9Tzj$ok!DvPGzL z>soeiTug6h-AZc@qtPAbw!)sWXVId!gP~BlQhj^q=)STgmf2u;0+j^i4#Kb`-yXj_ z-&rr;|G(Z@rhV~84^Njkdf0;xrnZx*N=iQTYo{uxsNY!BB`vaDOXrX>3+UwSV=(&V zJjq52Jjv6jx>sPH44p@_UZZJuQG1|5yM^8s;cpQOOzIXz@J99BRcZGXT3F^M|Z@hay`2nStC}O%VUPvyAbFXDrKv944U;NzxJ`%H8Q)Q1F3jmO4 zEF9r=RInwTSQ2objS4$aij2D*<@81m?HIQ10chn0}*bBZ?uLp1@_w z;b}yQTU()iXfS|E=!fs8^w$sjDSv~n8O1ms&gd9aEVx^m0Y0W#HYeC^K6cw zn%EVqoOqx9O`Me!nOB8I+&Wa2ZNO18D(Iu#YqnH_!If~w*TH8dN&#i0mDtpMQGk9#gLBP>n>3AvFya@IT}y^(9cmuCPt_u|t7;p3Vx|IiE@FiHDI(%Nfb zXV(-nb~mKaU0NBz3^UMlCifIZ^PQO9FUpTE@wdtkwo5Sd>~NrwkHX3r`jkfN8+d3a zq+BxG0)F5%C|kjsym1({Gn{+*P_0&vBR}BC(WJOAiY7;diJ#HqpR(!YqD(g~ z&G;vDznStVcmDvJ$Kr#6xweu3sTIfeQSvP<@u$_O>Ax6aiMdAw4}1hdSxQp0Uk%A_r`?nN8;`a z;0Z7g;(Z_dqo={afrftI*|h&&N~aB>e@o2%mUw6gPs)+JMk7<`;2nBAB~5LMV?ScQ zbniZW!@+NfdkPzB43XDVikA*j)go1gx5E``VY!w#NlZbfz!>0npT8k5<|wO$QQ{EI z{>;An;D6gYwx%YM_51#cJXn=jWqF8zvRg%!6h`GNf(mGPUgAIo7)>%bNl<(OF~D*W zZ@9-51b0zb7eUYBatX?hIhjfFw146BZDx8hw+@*EPL(`RnVGM9dcN-dZv9Rc(%2?RiGuKjlDpsnEFP?4 z|ObNCW0wyMp z?6XngH86Sj7?P1s(nEsNu>Lz)v*|S09zd$efBgzJ5=?IoW)9)Yj44lMsNuw!BARR{ z5rM2;^6Y!e6V#$#aB<`FmMRcu^LWT$kkLS1{zX~ZlBZwbG(>=*D|0XN5B`In{&#pJ zyZcn0T*a3_>-7L>3{rgA*0;o+Xqdmp_oR}d6i$MEovd(X;6t{?nFqvfm4o163onUc z$3F}>$VF4cJZ94Lp~{{}a3f9AX`wdj#h{H^%!;E{ zY60)8omw=YPA<<0y+egpV}R9>Dy(pGk@Bkb^{`Mm=0-TFaL1KaVqJf^+H|=^nSCm! z-$DkC+TvuqW)G%%=)q*WK`q8t_C`(eE(ypnz#(r)JcvX0Qo@I}757p?uc?@q66)QM zBNZUmi-O}eZZ7D3^pQZxrch-c9$cwDM_k0UxF8xTD}Lf4I|oOV;81I$dvk9br^DZ2Xh`NmF6AL@?2i^bwY!x z;FCl#AOp82pYO=w35+vWCqQ&kSA2B;=>cHCmzvs|E+CBQ$NlLwd3IZw+XMnT_9V)$ zJd`J%%1@_@Dz`;=FnLK_ZY_AQvv4|~+AmN4oZVf+FTpbab%=k4+b@T%Hg&du1*H!X zfy(nA<>_5)cCz%A@_LZ9&?9A1`Mgy$$c~FvgT$a!A7tGLKlX>gRlf?Ou;yuJsQF?T z?Z=k33pyH6f>;_F&hqf6vb62>sOqs{XMP6SlgDTAsIGK1wYGHL2!r){t))3s>~ezq zc`$tL)sx)9Z_w4}`A&ZSmDi&?O_xT)i1;|0%fKA8ZK3Po(51_*?KeUp8iU#GXJ9&s z1CLF)wL~%%84CAFVrro5LAp?Z@5?D}44svbh=m7l9=}}d+#hAU%tLqUhE^RLB z>plDUX7CdH_9G*=yH%X0wp)PSd`2+5%K~_}nZ?%P{ca<|(%l>7OSJ<6QLPMem^YB! zURlx7nxGrSOf%VZ7CFuEj0xf_T6)K_Bz{1YoRgB8V2=vJQpZy{epOPGzBmZ0x|UeA z4chiM9%_}yB7l6v(GXED&hBZjM0;ja-hD^cG6U1i$NNzB{4xzbFlF52e(u4JnmwGm z4U1p%<@YRqO=DqH>pYR_>(dSq%IqC?jY1^WReMH~6QYnx$GPZGLMZi(_M3`RLi$0c#MK)6&EG1c06%mx67X#r*mk10($wQD!tRF|ts*%m*BB|d84GS+NVBj#*eR0A)SiE0hKE0SWH0t_kZ75W^hcT-CtJ(K&guI6+VV*|-> zl8+5U!EnpxinEV%%e%_z8)adigaN=aWLz;0;2I?%cBwu0FuV6rSsIte^;)qNjW$P! zq@&GYq1IG)ROn%LhEK1_d&9)|K+2ye4f&pqhvX(M&JAEmyatATMqJ2{()=o1&YtF7R88*hQLc5&J6ob=|;OCxGX^Zq@;wEd&Ba|6fH6F@rYeN z#paf0%EBVuR4c)p-~k#Uj#ZeI>5*LKgX!?3!ZJ0sZyGW<5&Hy#hJqW@T1rw(awS?k zHA^L>e@Hycx51!&K$O3p&u_c~Oyiq;YnE@FGj>7&SliG6eRH$O$d;BbmHgP5zO)zE z7`{~VL-ovFcnMcaHa74U8|hf1;EeLI24AY>OK({=_e5*U)dQGe239{_rx6-D0>+0lh0qahLmC*;@VeJVD}IZ~ zMN%7ISq_qA)zU;OiAdR1$o5;jGYv$C-Ug1;h6Z$2#A!%FWIpzQHAX6Ed0$CtUO!Ht zY7d0!o4EB<2LqtpfKU|$x7VTAQ#YW~EIplO>FErL+;#py2_AD>d@cjehF zHM$~^xGPGLa6gymFIQ2t;F=p_xup^L*@V13jbGTCibkzs?7S-0j?9bl$}?@Y`hYSC zlR0^k)2LXL(v#eBgMbZ4mGcvnmpA3nS=AVxJIaIjw@(Y92qzz0Am8=4dmDMi&6;;(d%KUud&u_t%x@J4dP{@{x|k z(z#YZ|80UIJSRYYn4S}c5ge#LG{kLy))t+pQ1aCL>N~i1o zQFEr2=Y}rdNi|6|_rJe>z3WOS(mr(lYOLu_&%OI6gP#Jasxh}l?)sIv4syDTmAM$xY!&$>YL#I?+ zIil%Q`Dnr@pwZOJn-PU`J{w@S-e$kdvttRLF&VLB5iMTpXG0s^fDYy&DXYWXyl@LF zeZO|+WcMvQEY_BfMH0Pc%mr>nBOT;MBFEIlqxO)=tvr#JR{#!CMAD|bI|**$OD!L_ z2TQC@$5jGyvwlvXkd^4SLsnh+$Rbg6Mr2v7Iy&607+uRV%FpjGqA-5UOU`4A)t~U| z3An?UM(H=ewH?apJs@VEA1Yg)it?NrQASb_(fQ1`C;r}>u9tT}4FVHn;k4=K^Yi=T zxi@RjPvPB*!swTx-AR&_qu*X)2_^tiGo}+vY1a_YX$K^+PvR4f z#x|Q4#C!&(&4=1uYYAOvXa)ygvbC;sQ=K+8VzWb|vm2AIsRQ)e8fxzJedi0pEB$zb zzJYWf?T((=xA>M3j=#snNLpv@9yi!W&cwA_-7skrzNg}dQPD7I8T=r)n?REe6^?E$ z!TbETIdOm&q5>E9qscZlk;D|xJbAW8=Y=>IJ=hXE@S4#Y+AbbOd#||<>|2m<=tG6D z?L>@1?}#|x1Bsp9i5QI%_i6p1sSnHHl0IPbc+jm4{Q(YbtVUyK6HeUaBSNAdD#KQr zsHycrJJ3ikUTW-W1Nt>kzs~_U0v4?G;d@sbfhI2|Z=Ssw6??d7zX-EQbzEr$gJ|vo z&2Ol1dRy=c*A1HBqhQQnBi=lT%o*+Vs^Z^) zBi{JI!+?#ku_18~I3_ko27XLb-K|ga7xq?FcdJ_6l3J*F?AaItqWjkEx^-{eS5@7< zbcwq**!AZ&t|#6%z`2~CyF47%5{$+o!5dt|=g!Y-JPz(=IKs6B`{Q>x``v~*UtP_` z3zsgzf9_A=XzU6X3J3h5UiiqtzZQSYf5RW;uW+~h;Q>Av3i|so=%r)*!!5h`$Q?ey z!FMzm?*9kcsS6h_|KT5NoGzz}>+*JUUv#v!_?rLJ)Y;VB?d|lzC0}z}tGB)T>K9)a zUFQC9`Jc;T==647Z|m;Tp^&c^!_m>!*3{kF(GH*5+r7>BBSv9xS;0{2taW+_hW4gw z-Yza4yV=lpqlSHeC;x!%)@xqhUpv~pfK~mhhs}f8{>kaQ;&j^bFiuxZ9^du$)<0kO z0`1=BuQ9Qjza5FoH1>u&n8&C9iZ#d-g|wF_j# z7`~9&sQ(V%;|oN6fl!d|C-aEA%6;)gpw}Pq2V#5#H&t%xgvJo+sz!>MJH1WaUb%P1 zed6pFIPSY(&v#r9n!})Q?bqAdI5Nbo?SSAK%o_)Wk(=v4n>(&y7|tebABvk3tt~bJ z9tHFv-WTW%2Ltdc9Pf`6G;HyH)dVw&v$;S>RV-*~E|(a~y<8OLc5^No7r@&gO*=K2> z%F*y5_jEnqnQuBHS1VEc*~ zFP%wbtaA&vsz5yEs^6SxZtH0Nni{`i7Mc6E&em?P;@LQzTvHpkd!-F#OKTUo4P#E7 z!PfRJZ)Z0L-0CRShBzx-oZaPcIcgmqhuh(F)H&f-gVW(6Uml0e?R3{(ayDLadpM{2 zN{#D^$73sU6}NCYh&baP7aevtAggh>$yZG++^(;+2(!oGAw9<5wQ8U7x4TNrH4glR z@xrLoV$4?cxz>Td+yrwC?rOa<&b3uyu5;9>qhakh*E{M}4q7|T4UPs@>OA>zt`>8n zqmlHv2CmmyD$g!Jm+JJis?rvJ%j(xd@Sj)mIz3wpJ~#6Ep$(D{I{@MB3w8|lyL0ez}Q2ko^Oxp z`j&8y0f-$xJ3E@iTw`^bSLYUE+FHDAUQAn4S94Q~x2&UFZ-t{w=HaQh$OXFG$USCM zrWx0m0XO9qGw`ZQTnBc}CLT@;6Jz-2uM-eAT!P8?`9RN2u#NElax~_T1p}Ak1EH|L zCwf_C+-28gY&e~bZjZCh>2}rm+>L%uz!|8Oo06Zdx<*%{2P~;z27ueQ!jZu$jh3-4mx)O}0NW*7 zACUQYlpVGz9%k!__;1E+h0hbmKM08-nY{2Fl2`NJ;;VH4ikPdNq1>~VX!%ea8!gCs z_F|C&`@;v|`5}nj+!srZlalk?nSoD5bDoxroc8f@l5}fKOmE1!WIy({zmJCdi)FN4 zjEY-_D7k=DJWZ*_Z-j!;UXw(mOSNQDX%+Z0swZ<39174>rd=6f zdQ+O3JvWZf>EtNSyW6Fx61$>Q3cNB^vDx!VPUn;5CBd!{j7prhe2*AWw6lVFI?2iG z(V8%{eHuPjaaYTnhh@^S(xez|;!;Yww=Oa+EV(3xg^k^OWcFDFs;SI zb)a9Eo)l)*C_L3_!gvCG+&D|JJ;YH)sr_u`C-ihvcs!LoSOK!d%r0;V+R;u^f>}sR zFaZ|k_l5U!K(_FHQy8AfrYCZ1`#`*~@EZ6}@nnylp^?m4(pjUh7dj^{r?Qy|F*AkP z`)E_zehKZQ?KfiPfu6k-9;ijrSokrWb4lU(3S}>rj)hUo+nHH3HA=BjAqT4tXm7-$ z!G1m(J!NuhfuIA0f+q>|B&CWPowBr#Ko-3#Z9fxMwz2G{UJA*Nxs` znVik0*C_^-R@^-EBcABv%V$Su9S}siEUZkS@ipM6Fg_=3kD>Kp;1-r&(ph>J#1kg9 zIuqrF3_KQUBF>m-g($TWCHc5#A9fbCeu%0FZLFoj7 z!GjUsPEfSMuR~-@!CCRQ!C}iSY@+l6rB6()QMOVl=`C5)kXu-UmtW= zWRSMNCU8Qqu&$***-~*wsWC8GXf`Qr35=*2MA&i^l%YfZ~ zZ?m6qv@ySS<0PC)^DoH91w{j zVe`t#W)AfFNs~U%s)CX^1TCm8kzl5gCEB*Enmrf#wGzcjR9|sDNj09DAFOQWG+}@h zYbHff=u|Z_>n7?XV>U4t$$u05uh~l-j`>4ABc)sOvraXxir2!a1 z&|bRz=2dYpBQz`qubak|2E`OT2$lC4F7^rJe48wW1-Ks9Jt7_#K+a`>Y z{DtPWm_1h?tg*59YK8W^m?5`r6Grt$TuSYrgSnG8;>E1D8M9rPy|%(o--?8Pi1mVg ziS?GW*BZDpHrtxMob`qTAvp_bUi!F=4u?-{TLQt(Zc6|wcnbzA5)LsWxh(V-ux@@a zDs4QH#(rV_nbIm*+0c+*_Qs~5k7!lZg7)|aOPbHz%V8B2ygB8S;o7P!13P(7)-veztj0iSRa!|qp#?3y zPOODx7mG={H@9|}TS&2kfb+hYX_R~>rZ-p^l-$8v7C*kDf8rcB%+I-9wrxJ!D6b$V z=n}KMd_`8FXp$JOS`BiJ0H%V_5w%@;OascFz>5y@4$L`-qwwDew5I$F*xeo#qc zZ{c+Y(nA&ouYd#jBt1%3z=81G+eH`+y2waVrGScFWD#1@f4tU9mO;=j3)zDyw7QQ* zGAwJddkHapB&T|lJ1j6}3IngQBlq$F+WrKC54@o8lyIB_PcH!bE~CjbT3r=0Gi2e$ z0y5O4T)Nr{qeEpvrKI34bX{JNoq34f&8Y-{h@kWvc`1-x>m{jcRg4O-SwMoh$yc@a zb-O?OLx}IW#rp=rU|LFgU^>_{_QG`iIredjA~}OoJ90s#NxZjzQvRFz0M>;Y@JC>X z%M5~;{Gc{g27x);ZRq@)dP^%BJn0uZsXT#91d7gp3Kkf;dx~iP^yv{jMkKNcdYrJ;- zkd*Y?_24FxJ^F@KdmhRu7GEVUNbyEz?28odn0co}#Zh9#hMd|`RA0ffN-Yc(n(*Ui zBm6-h-Jel`X=!T3yOEOTXM_GZGZt(u53IuV(u8##D<2Lc=4>fzKA8!^ufTEXpT;sG%98KptCMUbXjF)s#+r;Q#&6CjJ%hIo}8?KDWiflmZpS) zD`|2{v@>aYN?@qk2n~dzL1hWL+&M@c>=~OJU4P758ntP0i$OCw$J|OFgKN*)A8XxL zUvNnYwDhM6$?2TOKa6R@>&U36)32y#Ob1tLzSD{EMt0 zxEu{EMtyCd3Nd;dr1B7hQeVobLXm9XLv%G;6+_RMBjig4Kl`MBV0cE45EO+C&Zd(3S&2zWgQE#;|^8iY% zHmaII?i!5KZ6Sxuy9w4soPB)X4ZO|KOvI_co|lMI>CXv9oE7bK*X}~Ius#l2*`(dd z?rn)n!|ZOf@58}<^J&36#_<W{B85f5%+gz^8SL_eF%x2{ zpeGh$YM!7AF->gAE*D-}5nbY~mk$?DHM-PjWaH>kRea{6OBLK?bg9C#(WR$w^N^c! zHIz1Rg=OVjS88jtdy<$~v!)vSWg|}`K?2AbwoVx&8$4*8%p16U1Ln3K{G0 z=VhXjtChP=sytifHktC5&W)pV{KEmq+eu%{sbaU4VOYxx;L1)?=9Tzj$ok!DvPGzL z>soeiTug6h-AZc@qtPAbw!)sWXVId!gP~BlQhj^q=)STgmf2u;0+j^i4#Kb`-yXj_ z-&rr;|G(Z@rhV~84^Njkdf0;xrnZx*N=iQTYo{uxsNY!BB`vaDOXrX>3+UwSV=(&V zJjq52Jjv6jx>sPH44p@_UZZJuQG1|5yM^8s;cpQOOzIXz@J99BRcZGXT3F^M|Z@hay`2nStC}O%VUPvyAbFXDrKv944U;NzxJ`%H8Q)Q1F3jmO4 zEF9r=RInwTSQ2objS4$aij2D*<@81m?HIQ10chn0}*bBZ?uLp1@_w z;b}yQTU()iXfS|E=!fs8^w$sjDSv~n8O1ms&gd9aEVx^m0Y0W#HYeC^K6cw zn%EVqoOqx9O`Me!nOB8I+&Wa2ZNO18D(Iu#YqnH_!If~w*TH8dN&#i0mDtpMQGk9#gLBP>n>3AvFya@IT}y^(9cmuCPt_u|t7;p3Vx|IiE@FiHDI(%Nfb zXV(-nb~mKaU0NBz3^UMlCifIZ^PQO9FUpTE@wdtkwo5Sd>~NrwkHX3r`jkfN8+d3a zq+BxG0)F5%C|kjsym1({Gn{+*P_0&vBR}BC(WJOAiY7;diJ#HqpR(!YqD(g~ z&G;vDznStVcmDvJ$Kr#6xweu3sTIfeQSvP<@u$_O>Ax6aiMdAw4}1hdSxQp0Uk%A_r`?nN8;`a z;0Z7g;(Z_dqo={afrftI*|h&&N~aB>e@o2%mUw6gPs)+JMk7<`;2nBAB~5LMV?ScQ zbniZW!@+NfdkPzB43XDVikA*j)go1gx5E``VY!w#NlZbfz!>0npT8k5<|wO$QQ{EI z{>;An;D6gYwx%YM_51#cJXn=jWqF8zvRg%!6h`GNf(mGPUgAIo7)>%bNl<(OF~D*W zZ@9-51b0zb7eUYBatX?hIhjfFw146BZDx8hw+@*EPL(`RnVGM9dcN-dZv9Rc(%2?RiGuKjlDpsnEFP?4 z|ObNCW0wyMp z?6XngH86Sj7?P1s(nEsNu>Lz)v*|S09zd$efBgzJ5=?IoW)9)Yj44lMsNuw!BARR{ z5rM2;^6Y!e6V#$#aB<`FmMRcu^LWT$kkLS1{zX~ZlBZwbG(>=*D|0XN5B`In{&#pJ zyZcn0T*a3_>-7L>3{rgA*0;o+Xqdmp_oR}d6i$MEovd(X;6t{?nFqvfm4o163onUc z$3F}>$VF4cJZ94Lp~{{}a3f9AX`wdj#h{H^%!;E{ zY60)8omw=YPA<<0y+egpV}R9>Dy(pGk@Bkb^{`Mm=0-TFaL1KaVqJf^+H|=^nSCm! z-$DkC+TvuqW)G%%=)q*WK`q8t_C`(eE(ypnz#(r)JcvX0Qo@I}757p?uc?@q66)QM zBNZUmi-O}eZZ7D3^pQZxrch-c9$cwDM_k0UxF8xTD}Lf4I|oOV;81I$dvk9br^DZ2Xh`NmF6AL@?2i^bwY!x z;FCl#AOp82pYO=w35+vWCqQ&kSA2B;=>cHCmzvs|E+CBQ$NlLwd3IZw+XMnT_9V)$ zJd`J%%1@_@Dz`;=FnLK_ZY_AQvv4|~+AmN4oZVf+FTpbab%=k4+b@T%Hg&du1*H!X zfy(nA<>_5)cCz%A@_LZ9&?9A1`Mgy$$c~FvgT$a!A7tGLKlX>gRlf?Ou;yuJsQF?T z?Z=k33pyH6f>;_F&hqf6vb62>sOqs{XMP6SlgDTAsIGK1wYGHL2!r){t))3s>~ezq zc`$tL)sx)9Z_w4}`A&ZSmDi&?O_xT)i1;|0%fKA8ZK3Po(51_*?KeUp8iU#GXJ9&s z1CLF)wL~%%84CAFVrro5LAp?Z@5?D}44svbh=m7l9=}}d+#hAU%tLqUhE^RLB z>plDUX7CdH_9G*=yH%X0wp)PSd`2+5%K~_}nZ?%P{ca<|(%l>7OSJ<6QLPMem^YB! zURlx7nxGrSOf%VZ7CFuEj0xf_T6)K_Bz{1YoRgB8V2=vJQpZy{epOPGzBmZ0x|UeA z4chiM9%_}yB7l6v(GXED&hBZjM0;ja-hD^cG6U1i$NNzB{4xzbFlF52e(u4JnmwGm z4U1p%<@YRqO=DqH>pYR_>(dSq%IqC?jY1^WReMH~6QYnx$GPZGLMZi(_M3`RLi$0c#MK)6&EG1c06%mx67X#r*mk10($wQD!tRF|ts*%m*BB|d84GS+NVBj#*eR0A)SiE0hKE0SWH0t_kZ75W^hcT-CtJ(K&guI6+VV*|-> zl8+5U!EnpxinEV%%e%_z8)adigaN=aWLz;0;2I?%cBwu0FuV6rSsIte^;)qNjW$P! zq@&GYq1IG)ROn%LhEK1_d&9)|K+2ye4f&pqhvX(M&JAEmyatATMqJ2{()=o1&YtF7R88*hQLc5&J6ob=|;OCxGX^Zq@;wEd&Ba|6fH6F@rYeN z#paf0%EBVuR4c)p-~k#Uj#ZeI>5*LKgX!?3!ZJ0sZyGW<5&Hy#hJqW@T1rw(awS?k zHA^L>e@Hycx51!&K$O3p&u_c~Oyiq;YnE@FGj>7&SliG6eRH$O$d;BbmHgP5zO)zE z7`{~VL-ovFcnMcaHa74U8|hf1;EeLI24AY>OK({=_e5*U)dQGe239{_rx6-D0>+0lh0qahLmC*;@VeJVD}IZ~ zMN%7ISq_qA)zU;OiAdR1$o5;jGYv$C-Ug1;h6Z$2#A!%FWIpzQHAX6Ed0$CtUO!Ht zY7d0!o4EB<2LqtpfKU|$x7VTAQ#YW~EIplO>FErL+;#py2_AD>d@cjehF zHM$~^xGPGLa6gymFIQ2t;F=p_xup^L*@V13jbGTCibkzs?7S-0j?9bl$}?@Y`hYSC zlR0^k)2LXL(v#eBgMbZ4mGcvnmpA3nS=AVxJIaIjw@(Y92qzz0Am8=4dmDMi&6;;(d%KUud&u_t%x@J4dP{@{x|k z(z#YZ|80UIJSRYYn4S}c5ge#LG{kLy))t+pQ1aCL>N~i1o zQFEr2=Y}rdNi|6|_rJe>z3WOS(mr(lYOLu_&%OI6gP#Jasxh}l?)sIv4syDTmAM$xY!&$>YL#I?+ zIil%Q`Dnr@pwZOJn-PU`J{w@S-e$kdvttRLF&VLB5iMTpXG0s^fDYy&DXYWXyl@LF zeZO|+WcMvQEY_BfMH0Pc%mr>nBOT;MBFEIlqxO)=tvr#JR{#!CMAD|bI|**$OD!L_ z2TQC@$5jGyvwlvXkd^4SLsnh+$Rbg6Mr2v7Iy&607+uRV%FpjGqA-5UOU`4A)t~U| z3An?UM(H=ewH?apJs@VEA1Yg)it?NrQASb_(fQ1`C;r}>u9tT}4FVHn;k4=K^Yi=T zxi@RjPvPB*!swTx-AR&_qu*X)2_^tiGo}+vY1a_YX$K^+PvR4f z#x|Q4#C!&(&4=1uYYAOvXa)ygvbC;sQ=K+8VzWb|vm2AIsRQ)e8fxzJedi0pEB$zb zzJYWf?T((=xA>M3j=#snNLpv@9yi!W&cwA_-7skrzNg}dQPD7I8T=r)n?REe6^?E$ z!TbETIdOm&q5>E9qscZlk;D|xJbAW8=Y=>IJ=hXE@S4#Y+AbbOd#||<>|2m<=tG6D z?L>@1?}#|x1Bsp9i5QI%_i6p1sSnHHl0IPbc+jm4{Q(YbtVUyK6HeUaBSNAdD#KQr zsHycrJJ3ikUTW-W1Nt>kzs~_U0v4?G;d@sbfhI2|Z=Ssw6??d7zX-EQbzEr$gJ|vo z&2Ol1dRy=c*A1HBqhQQnBi=lT%o*+Vs%Q}aVj@Voy37;MaFYTEz+rzcmv literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_100017.sql.gz b/backend/backups/kaopeilian_backup_20250923_100017.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..6fa8959f1db64e839e4a8e56ca501ae214d50df7 GIT binary patch literal 9913 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 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_110001.sql.gz b/backend/backups/kaopeilian_backup_20250923_110001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..0db915d03238f0c4c672022819dfc36161e9f564 GIT binary patch literal 9913 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 z z=iaVEKZTW-h0!lXyOSgBlDGZ^o-8C4g?K4@4Ak) zxv~7#PBGTz#^lMj8l-pla*^^FZEAdMK$JLX$TukP_lk^b=+>=4d;&<)(hf*spTs8| zjcqn9i1`dmn-8_S-V(aO&~_-a2bTkp2GQIF zn%`96^tRv?t{XJLN5PoEM!a<--4JZwob^Yi~Ut)-JIv$6y5l zD>{6+{XBtGuP8T*U>$-Z=gncjy2H(vLe2l|z%9)8&26y>&Ctwni~aySD$Rl%23O(c z))r{cLJh5+xV66)>ga0i?6B&9JhJEk8Q5Cd+rrK5ZEb+3f}f-Vizih3ppMtJ_Rdg; qj#zZ5n$r-=0&^W3KxR*{1$>lT<*WTWtOr{E-TweSQp3G!+W-J#6Eh9~ literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_120001.sql.gz b/backend/backups/kaopeilian_backup_20250923_120001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..5dc4bc4f9e33aab940cbfd81185891b4d32f3c0a GIT binary patch literal 9913 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 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_130001.sql.gz b/backend/backups/kaopeilian_backup_20250923_130001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..101cf3a1ad688242540df328fc3bfba4976dead2 GIT binary patch literal 9913 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 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_140334.sql.gz b/backend/backups/kaopeilian_backup_20250923_140334.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..a10a6039e3825f6c5bf86107ae553ed678b97ec2 GIT binary patch literal 9913 zcmV;qCPvvGiwFoeIMQeU18ZS#aAj$1X<=?(Vqs%zb#PxYFfuhTIWjX}F*GnUGc+!9 zacltWJ!@ARN0y)0zoIu^B+tsR9z4Cr=Y)VV!v;eJ@b1oR_9(Jw8^r=KdN^kH>^Z^) zBi{JI!+?#ku_18~I3_ko27XLb-K|ga7xq?FcdJ_6l3J*F?AaItqWjkEx^-{eS5@7< zbcwq**!AZ&t|#6%z`2~CyF47%5{$+o!5dt|=g!Y-JPz(=IKs6B`{Q>x``v~*UtP_` z3zsgzf9_A=XzU6X3J3h5UiiqtzZQSYf5RW;uW+~h;Q>Av3i|so=%r)*!!5h`$Q?ey z!FMzm?*9kcsS6h_|KT5NoGzz}>+*JUUv#v!_?rLJ)Y;VB?d|lzC0}z}tGB)T>K9)a zUFQC9`Jc;T==647Z|m;Tp^&c^!_m>!*3{kF(GH*5+r7>BBSv9xS;0{2taW+_hW4gw z-Yza4yV=lpqlSHeC;x!%)@xqhUpv~pfK~mhhs}f8{>kaQ;&j^bFiuxZ9^du$)<0kO z0`1=BuQ9Qjza5FoH1>u&n8&C9iZ#d-g|wF_j# z7`~9&sQ(V%;|oN6fl!d|C-aEA%6;)gpw}Pq2V#5#H&t%xgvJo+sz!>MJH1WaUb%P1 zed6pFIPSY(&v#r9n!})Q?bqAdI5Nbo?SSAK%o_)Wk(=v4n>(&y7|tebABvk3tt~bJ z9tHFv-WTW%2Ltdc9Pf`6G;HyH)dVw&v$;S>RV-*~E|(a~y<8OLc5^No7r@&gO*=K2> z%F*y5_jEnqnQuBHS1VEc*~ zFP%wbtaA&vsz5yEs^6SxZtH0Nni{`i7Mc6E&em?P;@LQzTvHpkd!-F#OKTUo4P#E7 z!PfRJZ)Z0L-0CRShBzx-oZaPcIcgmqhuh(F)H&f-gVW(6Uml0e?R3{(ayDLadpM{2 zN{#D^$73sU6}NCYh&baP7aevtAggh>$yZG++^(;+2(!oGAw9<5wQ8U7x4TNrH4glR z@xrLoV$4?cxz>Td+yrwC?rOa<&b3uyu5;9>qhakh*E{M}4q7|T4UPs@>OA>zt`>8n zqmlHv2CmmyD$g!Jm+JJis?rvJ%j(xd@Sj)mIz3wpJ~#6Ep$(D{I{@MB3w8|lyL0ez}Q2ko^Oxp z`j&8y0f-$xJ3E@iTw`^bSLYUE+FHDAUQAn4S94Q~x2&UFZ-t{w=HaQh$OXFG$USCM zrWx0m0XO9qGw`ZQTnBc}CLT@;6Jz-2uM-eAT!P8?`9RN2u#NElax~_T1p}Ak1EH|L zCwf_C+-28gY&e~bZjZCh>2}rm+>L%uz!|8Oo06Zdx<*%{2P~;z27ueQ!jZu$jh3-4mx)O}0NW*7 zACUQYlpVGz9%k!__;1E+h0hbmKM08-nY{2Fl2`NJ;;VH4ikPdNq1>~VX!%ea8!gCs z_F|C&`@;v|`5}nj+!srZlalk?nSoD5bDoxroc8f@l5}fKOmE1!WIy({zmJCdi)FN4 zjEY-_D7k=DJWZ*_Z-j!;UXw(mOSNQDX%+Z0swZ<39174>rd=6f zdQ+O3JvWZf>EtNSyW6Fx61$>Q3cNB^vDx!VPUn;5CBd!{j7prhe2*AWw6lVFI?2iG z(V8%{eHuPjaaYTnhh@^S(xez|;!;Yww=Oa+EV(3xg^k^OWcFDFs;SI zb)a9Eo)l)*C_L3_!gvCG+&D|JJ;YH)sr_u`C-ihvcs!LoSOK!d%r0;V+R;u^f>}sR zFaZ|k_l5U!K(_FHQy8AfrYCZ1`#`*~@EZ6}@nnylp^?m4(pjUh7dj^{r?Qy|F*AkP z`)E_zehKZQ?KfiPfu6k-9;ijrSokrWb4lU(3S}>rj)hUo+nHH3HA=BjAqT4tXm7-$ z!G1m(J!NuhfuIA0f+q>|B&CWPowBr#Ko-3#Z9fxMwz2G{UJA*Nxs` znVik0*C_^-R@^-EBcABv%V$Su9S}siEUZkS@ipM6Fg_=3kD>Kp;1-r&(ph>J#1kg9 zIuqrF3_KQUBF>m-g($TWCHc5#A9fbCeu%0FZLFoj7 z!GjUsPEfSMuR~-@!CCRQ!C}iSY@+l6rB6()QMOVl=`C5)kXu-UmtW= zWRSMNCU8Qqu&$***-~*wsWC8GXf`Qr35=*2MA&i^l%YfZ~ zZ?m6qv@ySS<0PC)^DoH91w{j zVe`t#W)AfFNs~U%s)CX^1TCm8kzl5gCEB*Enmrf#wGzcjR9|sDNj09DAFOQWG+}@h zYbHff=u|Z_>n7?XV>U4t$$u05uh~l-j`>4ABc)sOvraXxir2!a1 z&|bRz=2dYpBQz`qubak|2E`OT2$lC4F7^rJe48wW1-Ks9Jt7_#K+a`>Y z{DtPWm_1h?tg*59YK8W^m?5`r6Grt$TuSYrgSnG8;>E1D8M9rPy|%(o--?8Pi1mVg ziS?GW*BZDpHrtxMob`qTAvp_bUi!F=4u?-{TLQt(Zc6|wcnbzA5)LsWxh(V-ux@@a zDs4QH#(rV_nbIm*+0c+*_Qs~5k7!lZg7)|aOPbHz%V8B2ygB8S;o7P!13P(7)-veztj0iSRa!|qp#?3y zPOODx7mG={H@9|}TS&2kfb+hYX_R~>rZ-p^l-$8v7C*kDf8rcB%+I-9wrxJ!D6b$V z=n}KMd_`8FXp$JOS`BiJ0H%V_5w%@;OascFz>5y@4$L`-qwwDew5I$F*xeo#qc zZ{c+Y(nA&ouYd#jBt1%3z=81G+eH`+y2waVrGScFWD#1@f4tU9mO;=j3)zDyw7QQ* zGAwJddkHapB&T|lJ1j6}3IngQBlq$F+WrKC54@o8lyIB_PcH!bE~CjbT3r=0Gi2e$ z0y5O4T)Nr{qeEpvrKI34bX{JNoq34f&8Y-{h@kWvc`1-x>m{jcRg4O-SwMoh$yc@a zb-O?OLx}IW#rp=rU|LFgU^>_{_QG`iIredjA~}OoJ90s#NxZjzQvRFz0M>;Y@JC>X z%M5~;{Gc{g27x);ZRq@)dP^%BJn0uZsXT#91d7gp3Kkf;dx~iP^yv{jMkKNcdYrJ;- zkd*Y?_24FxJ^F@KdmhRu7GEVUNbyEz?28odn0co}#Zh9#hMd|`RA0ffN-Yc(n(*Ui zBm6-h-Jel`X=!T3yOEOTXM_GZGZt(u53IuV(u8##D<2Lc=4>fzKA8!^ufTEXpT;sG%98KptCMUbXjF)s#+r;Q#&6CjJ%hIo}8?KDWiflmZpS) zD`|2{v@>aYN?@qk2n~dzL1hWL+&M@c>=~OJU4P758ntP0i$OCw$J|OFgKN*)A8XxL zUvNnYwDhM6$?2TOKa6R@>&U36)32y#Ob1tLzSD{EMt0 zxEu{EMtyCd3Nd;dr1B7hQeVobLXm9XLv%G;6+_RMBjig4Kl`MBV0cE45EO+C&Zd(3S&2zWgQE#;|^8iY% zHmaII?i!5KZ6Sxuy9w4soPB)X4ZO|KOvI_co|lMI>CXv9oE7bK*X}~Ius#l2*`(dd z?rn)n!|ZOf@58}<^J&36#_<W{B85f5%+gz^8SL_eF%x2{ zpeGh$YM!7AF->gAE*D-}5nbY~mk$?DHM-PjWaH>kRea{6OBLK?bg9C#(WR$w^N^c! zHIz1Rg=OVjS88jtdy<$~v!)vSWg|}`K?2AbwoVx&8$4*8%p16U1Ln3K{G0 z=VhXjtChP=sytifHktC5&W)pV{KEmq+eu%{sbaU4VOYxx;L1)?=9Tzj$ok!DvPGzL z>soeiTug6h-AZc@qtPAbw!)sWXVId!gP~BlQhj^q=)STgmf2u;0+j^i4#Kb`-yXj_ z-&rr;|G(Z@rhV~84^Njkdf0;xrnZx*N=iQTYo{uxsNY!BB`vaDOXrX>3+UwSV=(&V zJjq52Jjv6jx>sPH44p@_UZZJuQG1|5yM^8s;cpQOOzIXz@J99BRcZGXT3F^M|Z@hay`2nStC}O%VUPvyAbFXDrKv944U;NzxJ`%H8Q)Q1F3jmO4 zEF9r=RInwTSQ2objS4$aij2D*<@81m?HIQ10chn0}*bBZ?uLp1@_w z;b}yQTU()iXfS|E=!fs8^w$sjDSv~n8O1ms&gd9aEVx^m0Y0W#HYeC^K6cw zn%EVqoOqx9O`Me!nOB8I+&Wa2ZNO18D(Iu#YqnH_!If~w*TH8dN&#i0mDtpMQGk9#gLBP>n>3AvFya@IT}y^(9cmuCPt_u|t7;p3Vx|IiE@FiHDI(%Nfb zXV(-nb~mKaU0NBz3^UMlCifIZ^PQO9FUpTE@wdtkwo5Sd>~NrwkHX3r`jkfN8+d3a zq+BxG0)F5%C|kjsym1({Gn{+*P_0&vBR}BC(WJOAiY7;diJ#HqpR(!YqD(g~ z&G;vDznStVcmDvJ$Kr#6xweu3sTIfeQSvP<@u$_O>Ax6aiMdAw4}1hdSxQp0Uk%A_r`?nN8;`a z;0Z7g;(Z_dqo={afrftI*|h&&N~aB>e@o2%mUw6gPs)+JMk7<`;2nBAB~5LMV?ScQ zbniZW!@+NfdkPzB43XDVikA*j)go1gx5E``VY!w#NlZbfz!>0npT8k5<|wO$QQ{EI z{>;An;D6gYwx%YM_51#cJXn=jWqF8PWVebcDU8Zl1QpQoyu^VFFq&j=lA!nmVu0l$ z-f)jA2<{@VE`pxLRc(%2?RiGuKjlDpsnEFP?4 z|ObNCW0wyL8 z@3T?jH86Sj7?P1s(nEsNu>Lz)v*|S09zd$efBgzJ5=?IoW)9)Yj44lMsQ%R1BARR{ z5rM2;^2~e86V#$#aB<`FmO~)U=JAlhAfti2^oz2%B~QJ?X@~$rS7u-3AN~hF{qN9l zcK4Y)v5GH&*6RV%7^L{Jt#66D(J+6X?@lE}DVzlTI$7b&z(;J2GY^Q}DhI*C7G4s? zj(-?%fQzPxdCa8gLzO*|`MwZ+MJ%^pm((1Xc#gIbKQ?2Ve_T@sLEfFs_Jco0YKrGyV{EAFL)UQ;nICDgki zM=C(B7X`;{++5K6=p%uWO`*y@Jh)PQj<|?xaZh~kMfu3E<`Bb!JvN>-N9e@l?$l#; z$6;{!p{6fDU6kk(M6K8WwR{g|8%DIn#x6{akSX=I>M-Q+4(2>kD$P5f<+;4->x2d! zf=?2~fDGK8e7+-xCos-jodD5EUGdTVr-y(6Uv6w|yofNSANQx%@zxwYWI&cf+{YQH@7b9Q$PzXZ}o;)^#M|HKcv8B1=W*Ds3>&;D}VwV%- z&x7G}ub<}Te}k?*FLv_#ue}~!9bFm?BjV#sE(3GWwuWwmLzl0#wA~DaXbfhzpM&Wn z4m>vH))L87WH8(-iK+gw2kAlu$`^$tPKZM%8lZN;^I>PP*aUpkBC=^f?Ba3)G;xl4 z%;mgzxa_X^r6WB{5D!zn3Rne4LpjSq!| zKb4nb+00*^*IMXzs?`J>#bw;QxT+gjNMJ!FImor0qXY0ar$$7stl7^3k)g1J)oer=^GW2>?CaE(O~%3;Ff821fcVqRePsVq~FqnGY-(DWOCE zXMA+xo&0p-koc&9%UH8fjhM4>Q4PFQCaN{`j)x|?`8cD}M?jAY2PMNf>Jo4A+^+I! zqf*5|aPRcNMK>Tcy`nT&naXbDYsN=`u1Jb)3NWOsN9c8=-i^(L^mOjax|-8fjP)nO zNj}ye1;Z_$E6zO0E$u3+Z-14*Y9u3Y%J@)PvYPz$r`kp|yl%8!c%ZCTD$k4Ml5 zLqI1IrqdyH2?nV3$Xd$8?ewL*${oZ2i}uYSv38pIS#x%k$*?hY724%&rWl-#6sNr) z0lk}q=M4BqZf-$df1`QWk`P{muD|!Da+#6r&LZs;2m@@lRy5B_LzpaOk_MEc`FcpR zQ@e&Zx|Z0(o@T_ia_Zpa z{%UxN0)6(d{~lP7cno+7pwhZBO*=sNl zaAP&H*iXlQ<44dl2XRE@^Kfo;5<{ZE*q`L|D)u7J?oNUxe)Vw45T{(RPk^UJ*nWy6VU(!k1*z zLilz{YKds4UKim3433Ns@KS=)(qUH0c8|>@ROTZIh|2=hPfAKyxi=)QOwtk)ACK7e zQ*3T|uFNmcO|=rt2_B#!;#h@QnHtVzK9~+qDlAiD`=%j-6R}S)XehWbt)(QzBv+!v zQ?pc3`iI2Bd>ahf2SoXsx%|d5U>e`#TeE!YoUs!Mz}kim=$o5GMz*wkspQAb^rgMP z#_*+@9}ds#g_rPQ$;JlWVj~@E6r52$*5FIEeCaLA=ALM6xq1LoP)E!!sgYntSrQXO zj)D$6(BS*&L9S*iHoHn3>nfI~*+^LQ#K7vu>oh_`N5J@yrVu*9ZAb${8eaF>XvJ?a zxkzf`E6YK$tXi6AB@rq65VHLi?@R;Hp|^n}wV?r>6>%EU5SfoXV2zOqTHaTZn%9pL zsM-Ue`X+8Y)xZE~Hy~6+!R>V@_S6lij-{tMmY&X{$Zc03hMwwKda8#piOxVQ>%kM| z;mS9T1?o~$qGE19LG`av#7w=^}`9hmLyT7iy*f~}uk&ksO zmd>>T`fn2y;W+{F!}Odmi~#xJ=8ftaX;s6qkj77?6D`ZgYZB~tyo z#6@C4+)rPelTu*a2&cQn7`Pv-uCJ=X|G?FWGhHWwXHML{(0Au%XZ7i(NbpW$k5pIl zkE*lPJU4jdZmLnLdhq>?8=Y4}k+#7L*J6!#yYD|ZEd;AzlAZit>D=xLIU^uhMWb5! zPaNfu`!kcvEa1o}E&nNwuYR1WSG}pl{E*KJ8qOLH8ak!o z$`MUx%10AM0ga{}-i#=m^VtBq^)CBmjvY(*jLC>4i)is`KO5TU26QkNNm(84;)UB_ z>HD=ir@L<3VX?Y=ERyIoV=izr8tEW65;>+W9<_%|Zsn=Gv;uI5B9b=c-3f3LUuyZd zJy>EjI<69soAq%5g{(xM9kOc5M;3{qGa}1s)zRU0#pqg^R(@W_h{E_WFFB7fR(r~? zC*Tfa8l~R=*LEnY^?;atexz)DD#~+iL>WmzMCUW#9{+o9s#f0F%w`^=jO;T&CekNh z_?^7FWrsX%zpA307bpaZK1!!eu!nv-Ag;~>@@CBGZA6(eH4G#~8U!ZD!fDgd=jZpw za&On5pThFX!pN7R-AR&_qu*X)2_^tiGo}+vY1apfrr#>DGZ^o-8C4g?K4@4Ak) z+0p#wb}`mwN9BpP8l-pla*^^FZEAe1Uz9j$&^I9P_lk^b=+>{@#CDhd6`_31HSNib= zeFNz}+8sT!Z}BZ79DkpSk+ja*J#L_voQZ3-H-RP{DjZ#0 zg7^9FaAH3%L<--4JYYjD=Yil_l)-JIv$6y5l zD>{6+?L2{0uP8T*U=4yJ=gncjy2DMELQOxm;}+)o=C;^`W@u)(MSlPum1aQ>gR5{; zOEWZRp@vpZ+}d9YwRg63v|DvR9$EB&3~bG9t>LD&)>gn%!B5hG#S^N1P{(U)TSur} rM=ZKj&1r~bfw_+LBeN&i0zOKv^40xNRsBPC?RWnJZ(|J2YTEz+7esd8 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_150001.sql.gz b/backend/backups/kaopeilian_backup_20250923_150001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..10d65acb4057c0546752710d821b820b59980763 GIT binary patch literal 9913 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 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_160001.sql.gz b/backend/backups/kaopeilian_backup_20250923_160001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..05a84b9cc520c2910ec38a33e33ef0293e6d9dca GIT binary patch literal 10051 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 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_180001.sql.gz b/backend/backups/kaopeilian_backup_20250923_180001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..20f9e3d756e76f5403b3993897f2d5c385c1ec23 GIT binary patch literal 10052 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 a3j`RsD%|vsCiL&r-~I=mpF={|+yDSb>6yU* literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_180429.sql.gz b/backend/backups/kaopeilian_backup_20250923_180429.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..98b12a52d6c72cd34414d7121a95ad52bac7ce3d GIT binary patch literal 10051 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 Z$yL#&FPd7uIPv)>{{#9ovsc&L0059ugQWlf literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_190001.sql.gz b/backend/backups/kaopeilian_backup_20250923_190001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..09885c3dfe23ee5b06375d037be5d63a71c36a92 GIT binary patch literal 10050 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 Y$yL$l7fnrHG@bb5e*vHAj@R4(0NLz(&Hw-a literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_190043.sql.gz b/backend/backups/kaopeilian_backup_20250923_190043.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..8143a255449ebfd76480f089a744933b8a4e585c GIT binary patch literal 10052 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 Y$yL!4UomUe<90DE7#lr00t0yrvLx| literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_200124.sql.gz b/backend/backups/kaopeilian_backup_20250923_200124.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..9b45497428813eaf525565a2fbaccf187700f261 GIT binary patch literal 10049 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 Yz{pkM7F;5+(AV4m06xTkO8@`> literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250923_230001.sql.gz b/backend/backups/kaopeilian_backup_20250923_230001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..daad1d4855038d998bc0e68a7cf2a8bc7b2a88fa GIT binary patch literal 10052 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 Z1Q@w0-1<*kZ#4YD{{VzJM_kw3003n|o?HL` literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_000001.sql.gz b/backend/backups/kaopeilian_backup_20250924_000001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..62619f235cc12b3bd21dbd42f182034d77d3d293 GIT binary patch literal 10051 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 Zlw1{U`>d(yv!+uY{SO$HEQHtG007rhX+r=2 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_000429.sql.gz b/backend/backups/kaopeilian_backup_20250924_000429.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..9c50e67b8df63ef7900bb10ddb01da581c2668c9 GIT binary patch literal 10051 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 Zlw1{U`>d(uvs0gb^gn$)+>zJZ007&JYM}rC literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_010001.sql.gz b/backend/backups/kaopeilian_backup_20250924_010001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..966efd4e0cafa139164df2ae08595801678a32d3 GIT binary patch literal 10052 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 Xz{pkM)1*Xb^GE*!oJ=-q*W3UAP}YER literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_020001.sql.gz b/backend/backups/kaopeilian_backup_20250924_020001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..b2fb24418d7e7ddaa94e8f1ef97f1c41b4716d12 GIT binary patch literal 10051 zcmV-JC%o7niwFoJ;L>OS18ZS#aAj$1X<=?(Vqs%zb#PxYFfuhTIWja~FfuSOFflH3 zacltWUF%aDNs|A3{ww``wj)E=!@)*LA(EyGutxzFv$*M_XG{cWXyGylQXvHRG3J(eXVbsZ)W=^r?b)NwB_44T{U@n*V|kF zc-;rQ`MTmUe(dldbeY^h~-=Ff7^cNkRK|^PenFO`;U+ZYWd@3&; zoO;pL-r;Y$-reE<*QPcYA583@eVrW)lP*^Qt@e&?Uzbdbyr$IXWjY91ue$?1VV;9U zFc^#vMtQOT;^f-*!N_1V#{2oZfj)nXkHJojbW>+8ZBz>^fh(+ z1A%L>ZXCFc++EkSx#Jpc!`Y6HcXDI1lvv+j zI35}>NzThsTYsjf1_I{&Hv-{UC4`de!opSop&Oxa*xwTl^xrN`NW^!-tEJ z^DxwM&kj-gt@t4WTw2kK8qPcDH$)>DPw<16K1O2fdLFGezeSB{y5a+`~WoSBeG{tw5 zX!?V&bSSQ;#jPj8T9VQagu`%yC?5~p<|`lQ=&+R9%Z*KgRAikE+?9#>qa52g-0tcN3O(g@qla8|Y3iALip_b;l{IK5eY! zn!T5%RXHAB=AN$S2lH)b>$RrNKXKs1I6JnP7x7iW2h{Oup?4+SH=U`9jAtX-Ae05wih>QFpLNo$+7 z0^e7}dFfmtry9$S&CxQEk0)EWP{=&-wiSdGI?zG}SicAeKE${vS@j2M4=)iL95ca@ZD z9QX^%d=bLb(PHwN9C5Z*3NUiqn_0|Pkx@O zrQG0XAY-n9>t0Ls*#(50Cp6P8xa88#NP|^#?E;l^t8-$d?p0=`eC=8zb-kUjh809S zec(f*A;8KeqMdx(^AYLf$75lXBaA1|`xn|+r$5jKi1AJ!ibra9JDnFfHFrQbDDa=0 zf&Vm!r+ys~7M`NHX>nsm0XMN=G&F!C1wcdm-FW4nUB{y+n5{0hX#1|P8Qxse4jxtCYgK1LjP45yA*T*^o@3A?PzOJnz$#2KCwtN z?vF%!`KT5oYD0>dM<_ju|IdCZiC9YnBot?w#IJ?$6vuyyyCuSfV?+tZ4i1bFGDW_< zD)cP@kD(Dee|B~@i$P;`nO6skF>futHXr7#sjIoE#a9+6*INOU$vQk0h+JUHjo>k3 zGR;6^rnxCt%(Pb_aV_{cn|L@QOpfE9zfMBja0wRU<$>NC;2Yupl~_Cw4+XCb4um6t z-q;mca93Pcu;X+#xINBVr`uKQcQ*t)L1)k_cO^evwGFNY4|r19m*$G{jO zk>Fjk19y2nPM62u5bSXWdfYWiU;4{cS8qIWfC+Vt9%rMg#%6rA99UrFU=dI-fe+3J zET{wxoRDKD3;)C%JE6`x_8>_6W+XaPrMoruLNYVS2jF#OzX?h|7-OfciksPbqk$W7 zTjBl0iH|~JSY|JBhhzi;H~DIzOA$xAGn{+=3N0Us<6{N++FMwqz;p6JxPKVpPWQ!< z^Q2^JcV^*L(QK_HGpD`1oZj6U7t|?stFSb^l9TP z+4c~Kk5c>D%+KiQrto+=d$0m*iu&^(DmMZ7RLaXFREOp2LlEZ#?(()KIpCvCqKGY|CQrR{-R zG=r5N^EsasUaU~@V(nNQ!?K;7L(^lF8dY-e>Oi(%UVKy0L{ z8eOtb+;r%_lRs3QOi!wQvO|Mf5DzCVE<|n++Czj8S z(OO`L3|Uy2MiXlwQDI_U+8#&iBOooTzhtoVT~JS0)apuck4lmV>VYIp`9nHZNRlex$z3jn! zF7Zq{8iV#AKIoD?cq|<)OKVe<8L&ZU{|MV@G7*$dU>FdL_->Mt6@DEihat{NzV!}U zZgCT(7b$;YYK@AOa!KEkEe&}KtMKxx+|nvAA*Bw1IjX1hUA=tIQj`tA#`HUIs=~*l zu>A<@?#QaJ`dFCRK*^7JQIb_GtfaxaVmm~;yc|Pg4=@{~FNg_12v*j$6c}5o?kF`5 zP7BQ?rHv7wc9ZYtqk-@#GwE_F9){*+fG21y+gYM_^g1;;>e>{3Sq0@SFgfxn&ftYi z&FdNQgi}wTM2C~`6DAiV2a5_64PHxA_X{LP-x8Pi(C7!YmntnPAu%&c7w{SJ1ONzP z5cF^feR_jN-$=t#XmwvoZQ0oCN2A%J6h*w)P)v=Yxp&#m!x+;ojG?Ioy!;51qxcEy zCjklS#GD0lW$|#;sW!657v*Dv;dse>o2K0vC*3q-F`s!O-AbG_B*#mXF!nEle2k=H zig$p}0PvL>F2{fYdX%hW3It#^_E^jzF$fHUk!X|;#*NP-6=oH-Uc=&(vl#RUohDaQ zj5Bn&2(U;#mqw4g;Xu1oa1W!zrwVC;N!)mJ@&87MuL|0(%3Rw(BV4;i*18=bR@sW| z2B|mE>;>VgC3b?!-^X2eAI&&5_fp=eWap`>%mL9T5;m`#Z0103pET(Mttlv(L(n$W zB@)bZvc%e!HM8f!zE)ybiRmkDC#l6#^MjS0`6dFeV$Y;V3Z0rpX5U0zWX$pFBKdD( z|23z;Bk@4kZ)B8i>%)+O@Ts-VieB1?^bPzT{SDmIPhVdbH z$49Dn;!-g=ZEx%f z`iNCkJ!o%WsHFSMy&92HYbb?t+GzbJ_E9KMlh7UB*bSC>0LuI}cy2AtZib)rZuWU~ zV2n8IjBHMMXV_bnXJ8la$$AF8oz*x9tw}5CC$vpV?-Og=vYW*u-J4rG%q^zaNx*sE z%nV9C7t}V3UAAvN+bHiKC+HTlynRJ>p=gpA@8uC{>@Cy&Vi>FL3~%xR2r?WikVrmabp1+>Q*k@ZH3vPI-zn>@GcBp z-jSVsh~Ceu41kKD^jmo=klpJgscTh@3aDAYg1N<4HTt^UANeuN_ul0F0}*g7B@vje z?HQvm-FS}uph%IN!KpngLbXY}w|`RjO}zmN;RXUxnBp>%ASOSmos~&o9!oa_Kj{5E zFimvnD7T8=I+^30>fn@>T?N+qkq_Ox70+i!JZyHv=DO_Ebbo$7H#V(;v?%|V!BEi? ziw4k;?2;8i)PfVKHO132GDam^h!nJG&!wIN-1yrmNYhyqW+~d4b!jn_Elivg8c?er z!7+C~UCxH#UvobLdw<+#e`>1&?RlGXqKMttH)~M4eo9IrcYSk{$sT>js*#8C6pOzS z2vWS$8Do*+12YdwR2n6AY{>Iwis>u3SE-GmLKlAgY(yaBr{^;&a4k)(_%u@T{2b6f zXU>AHLV``WUYoG3WA(#fBz&zO(5REPx3-~?8rh4|XAOE$tuNw9G-ELEC7Or6O*)k_ zW6*gQBfhM1Gga*okfoiE1V%qd!%t4u!IUw<8c$Qgz?F14CHk54JtZhq?Suv*v5>L_ zUG6zZUE4EuIlA$f_cUtP;vR!$bdGtHKyI$B5^$`2Uwx@4CD778RY(d4*lA#OC6^e7 zUrHUcXebg5#fM7xy6opAG@4YOulucb*NN2bD)0UVLl6yVHJijljK(?`b#xfVL1s#M z6|SEWWxVuF4c!|%NUcv%-icLqhvRN%my3Vg4OO`+Y8&LOwo~v^##UP6p7h4Bq*u~6 zFTIVSZ_gG5t99h960SH>#)t^(;kwRig?idK{!?5<^m7 zgsDQ3HJGHEC0SD~NnFy&W$8@PVqaFyWF1iSS~`*SK%&;td2GP*=wwOiOIKA$k`(jO zio3RInl49;#qySGcUdWLmxq+Ovr^(NOweno$X#Bb(O|8}Yq5AN7I~e+dkm42x+or( zc2i9uSx-s@S}O7eQeDnUk-MEhvcXc3yKzN2t$eDMx!diiv)Y+?fTY$MRV^TQ4W{X~ zRzmfp+$2NNX{3Z+L)G*ekTj1xtP5iM_`V)|a?(r?tF%2YL98;K6AWT2Iy|pE*k)nL z9kjAZhn3yi5}%E*2i$&$g!;{A8uJtAHm_a?;jh8DJ3`}`a|ByTGmDf{?b>~<&|E>~S} z4;|fCPVq8l0#Bg$p*%pCmgKwRR}?(!69@mV55s9cWu=#=yE47(vl^yOz^O(`eoom= zO;Ax>S`_jYIbo&)yv&|I`KULT{nJ3Ir%@=dz&#m)Q?sblG#u4F1EM|kZ;SFbi3cX7 zmZJD<{qCx?`x>o3L#dCniW3f)nHo=OsdkUYr%O=9s#l-F^=R-s~9z+*P5MMXI>9(IiH4@IID4BMl94hL88 z;sb;6A%74=H5iTuNV%)*URth#HanL+Tvn^X$rYRELl1b|KMRQsG_`=MXRRfH87Z}9 z%m7$4J}Q3(m<4R;ANIf%yMTN!5cV5!L>a+-78aEWz)y8BypRWo2qRRbPFNCN?Xy97 zNyKmQ(ZE0`R$dVD<3glpnXs}=>tLPA{MY6Ozm8tN%MZREr#SJ$Hq*0c;wL=cPgdpP za?Gf)v3~hft6(;InnB&uEc1M_bgAPZDJqq#!4bs_{tq~Of zY4f+laPHNJn0~8TBT6Bgp2U^aBx=3n=*rSF9Z0z;e+R`a6Z0H{n~V4#n~XAxeZ|`9^0P zim@ezj`={7xlg`P3$R#uP^+N62K#X?@lYHm)sGii6}y4(atvN5fg)%oE+tcC^hPP8 z2cuxqDCNk2iGm`Daa`4w=_MB%Q;U^Z#W^iZ##S~1BohY2Va>y0gs!ylT6i(1sTy-5 z90{1OoDw3U5EA!<5*x*n#uc!Tq zL)ArwRB=x>CM@uinwXIBN+n}Lrd{cnkZH*7P1rP_J#ACV%0C++pR>Dw-rs{okX@jnhu(^6bnHEQ{l zLCa^U?B1l984)vMy7tZb_IPJ^cN2!IYuo~j{}VWC!r#ZBw0|V6y%BbHO$lRnLmJzq#T?8C1I=V|PhmFSi|PHM^7smW ztMXun1XIsW2O9k(tc;`2X|%q9r-rtatDIXP54;0nEBM%S97gSo_@rQ|tt3Ee#qoWVd`CZf z00iW9C{W_~A|j^(^PfeJlL|Y#XmmXHD2Yq{&m{qHgO1>t+ubD9N^n$VEEfvr%*Z15 zIWV@;?l@R2#u9>Nk2ySeXlFws4`ew* z@5Jz+m`HH_KuEm_wb8o|)coV}_9~bYFo8hd=fEEhUxCTk5Nq z41&t~&(-@g$~P;@vsIS4sf;fG`ij4wWlFH65HK-i;((13Z-U9g$B>MCk{%MAhOJ-7 zn$6|F_5e~%<=s25kzhuCFmniBR!jvlLv5d&siDcHsusxVrOf||d4g7_3@&bb-f{@U z**YFF7-Te1Ui_f0?kKb0;mk>Zp{t8eD|i2kpS~ZTC>0(nGaL94XuSa-jX{d{ZGB7M zN^rt$VIZ54WG(~xb+f`*fsfc4XB`l`RStr4HeM37j)fR-luu-ddCa2eLzO+9;gcf_ zE}T{c-7_AHSu<_=w5bWE02mM(r-eGK7n3$>Gb^rIsSUilc52grI=MV24vtlyO#xO% zO5VcFMQYi$w!*6Lm>c0d$i0`kh;{wxM*GE1b^fuEdkGmhYK!v+Tm7b5q2H9_2DKTV z?~R%fJaVREfFs_Jco0YKrGyV{8}6lqUehoyCDgkqtt&uo5Y^os++5K6=p%u;O`+-m zJh;+)j`+A|JyLw|Mfu3Erai-hJvN>-N9e@l?bKs+$6;{!p{6fFUDW9lM6K8ewR}I! zHjHSCgI$;!Ayc+;&0)yp9n7h(G@AE;mgh76e3CE?Is~62iUFCpJ^6fB4o_g*xjF%& z^U&g>``32?1HRDS-To!Qm~q^n+f?Rv)x~Wfuwzf6%KANJ`myqOwx-xzTmX}o!4=}7 zA9fZ_$AE{G+3!n*P5cr(15k(fC)aZ^cDemZCsgXM*$h2W zX4E%3HG}LuZ#PH`dgy~}`6P_}VQ`_c>Le`FdL`CzojBDtIxN4m4jzN zkM1;G%?%^s_xW-Wrp@h+UFBjIE_U@?i^XURmUf?j=_C$34i)0knQVNF8${ro+>cv>8gj}#gr>c!nX z4OW#eZYzZ~y8IfLZXq><68`%%_`oc8GY93nds@PC{wA!~E%@(Qd6&b&sNQ)xJ2a#p zBKYhbca1_MwlsT2h8Gi%N~ie5SX%V|j!J&5T%5x7?1{`F^C@g>^N*hV9$1680+cpIl zi|XL@GEu!@a6B|UAfyLfYh3ub`T+W9Xtm^xNQ1v)%8!c%ZCioXh)2)~L&P8wmeV0^2}Y>($Xb2H>-5E6 zu@Pc`Mf>KESUb)9>^Zx}WY`$H2JLY+QwvTD>=T|Y^PVE`u=vfsIdzum7%KdM_z@U%) z<7uE7ly>Kog<0C25eLcAP0$Das80XKXp5>EYM;~C@i)U$6zH>u{rA9v#ACoy0F~DD zIob(Qoy;jyZ;&@2gw(wL;85Y2N`6^g${+F+v;d4z;l^5IahQ((#*d(94&sRFn~Czq zLkx)mV;?BF4eUi;Dm(;D!rI}IDNeazp8!vdum=S?DywX5RJNdhbZrerb3TD`@fUS* z+P(=nnrY;)nW;m7!n>w?F^TUQ@Uao#4b_oVgs;n{h4Af`G!xNIqm;uP7#x`z734Ip zr`v3l?H-#+sLV$)5SK-$pVXBQbAMb}e@IJALMramPp!G-iMqT(H`S{~rv-q9xNG5P zb#|g$d}TR2X|POQGJKsDPu)^U=^wHH^KCF_9}wj~E>-et zfNA_K-&*Bc_l%%W0M<8j#Ms;{GP14ZOCvu{rZ4>k4u&ty{BU??FT8{gOExy~76<8A zr{Ik8u?}Ca<;!U4n|q?Q<>>)TK^-x_q(*`nWnD}VISRV)K$Guh0J)&;*z5*ztZP^v zXft6k5)W%1Z_o%G9TD?GnnD-|cOVT6X@0%Sxz)bK;v%V!&zFOwukxR0B@rq65V8r^ z?o1QWp|^o6wV?ss6>%EU6q%1bV9k*VdR|$Q`ZtUds5%3ok}O_5wZH&qFCbL-!RvJ> z_S6gLG)qsXS$aByBDXz(78TCIB)S8!tcO6DhbP}S7A&J)waR=t5)kbt6l8&v z4;PZWmZrFwU(y!DzKJq$}Jzy5Ph_VI`6QbC#D(V{ET>8~X@&JFYFVZVx^4cE+1 zl~*T}C)3LAEPml&Hj%K4u?wocJTfmT>reFA8Uo5BOy(3wPNP~?%1Cm{j{-IzH_lH` zd9kfb&g&k6{i5XGyJYuQpjVZ*#!JPAnyq12l6)_1hYxtQrmj9G?uq;jNp8$*u9_l{K+=VKjFkIg^YNsZ3ey*#%UQ5)#FGP365NkA zw>354f8grlQ~e)DPknsji{YEs`kGI6#G^Oc2j$Z(|7beXEbwC&Z)Mx%raPZrz1nvv z7VjDR;&QV6*1+vMC&g$JOtMq?GgmIGlQRO6RWzZe|HM%q6EBs*juZ0q{i=y}L7)&Q`Y4?? z!5;eUg1FNGkT-KqZ!^l&*$E&a(jYKF7EYUvKEHBszx;d?`YEh^SDk!YvpY$$a`Zb$ zEWrdoTE=vODgD|7TW5m97D?akyudIBz?S+XS6w`)F8@@W+`#xSXxZFvFje{lECz6R zORUYD^PJ#sP?lV`Xj=d*z?^v9j-JtZ*MXox=Uq3jwm4PU-mS&j;*>J;T!#z}Umj8( zqfJdnjz}^ukA+4>;p-aX8oG6T6rTW+w6r6#G$adYS7Vz)3t~P4(-uPQzUqu!WoQNm zU$?ccep8)3He$0wqjMTlsJ$2T+ZF4$68b07E(K3Wla5s%69V#6Cd|C*FZ}QTJASOgU6-JY7ZX!!r zpm_>xjlK|5eB#5F*axo}y`kgcVYK&J`@p^hX_r1U2**yO1oVzb2?LPW8J$Rp1aY4> z9$Naa9WLnuHcv&p+Rz{1(#CEyrZ!yqYauSChoLfTw~5-jzU%=S>G=z1`?`UC4b<;* z0FHn~n?v~C<+DJOmonEszn+i=_{6XTvq|+{>H>r4=mX8KX>dkc@CwfjnidjZ%wQv4 zKZ(rkrd^8e8m0>KhP^&OHKBGbx^FZvZCmI~wfd2zrXQ?upv% zo#by@pHq811Q=p66p3hr9(+j%p4d_u!ofICgc8DFhCG0;hqpmvDHVy}?)CHPvpR}S zg!oiEgdbCR)X#=AgwOz*PsiC%IGycJikXZM51#;kA(Ubv)IX;8Yaq0)wQOr2VP4hN z?ON9Yx8JC`Pehb(7HF5_{@v3ZJNtQ0*EvqV#I_uh6$q?o?n2Kw0;ye5ZWhrN1V_%B zIl#KPj`OjOfA!)P*8A4B*o0OS18ZS#aAj$1X<=?(Vqs%zb#PxYFfuhTIWja~FfuSQF*Yu9 zacltWUF%aDNs|A3{ww``wj)E=!@)*LA(EyGutxzFv$*M_XG{cWXyGylQXvHRG3J(eXVbsZ)W=^r?b)NwB_44T{U@n*V|kF zc-;rQ`MTmUe(dldbeY^h~-=Ff7^cNkRK|^PenFO`;U+ZYWd@3&; zoO;pL-r;Y$-reE<*QPcYA583@eVrW)lP*^Qt@e&?Uzbdbyr$IXWjY91ue$?1VV;9U zFc^#vMtQOT;^f-*!N_1V#{2oZfj)nXkHJojbW>+8ZBz>^fh(+ z1A%L>ZXCFc++EkSx#Jpc!`Y6HcXDI1lvv+j zI35}>NzThsTYsjf1_I{&Hv-{UC4`de!opSop&Oxa*xwTl^xrN`NW^!-tEJ z^DxwM&kj-gt@t4WTw2kK8qPcDH$)>DPw<16K1O2fdLFGezeSB{y5a+`~WoSBeG{tw5 zX!?V&bSSQ;#jPj8T9VQagu`%yC?5~p<|`lQ=&+R9%Z*KgRAikE+?9#>qa52g-0tcN3O(g@qla8|Y3iALip_b;l{IK5eY! zn!T5%RXHAB=AN$S2lH)b>$RrNKXKs1I6JnP7x7iW2h{Oup?4+SH=U`9jAtX-Ae05wih>QFpLNo$+7 z0^e7}dFfmtry9$S&CxQEk0)EWP{=&-wiSdGI?zG}SicAeKE${vS@j2M4=)iL95ca@ZD z9QX^%d=bLb(PHwN9C5Z*3NUiqn_0|Pkx@O zrQG0XAY-n9>t0Ls*#(50Cp6P8xa88#NP|^#?E;l^t8-$d?p0=`eC=8zb-kUjh809S zec(f*A;8KeqMdx(^AYLf$75lXBaA1|`xn|+r$5jKi1AJ!ibra9JDnFfHFrQbDDa=0 zf&Vm!r+ys~7M`NHX>nsm0XMN=G&F!C1wcdm-FW4nUB{y+n5{0hX#1|P8Qxse4jxtCYgK1LjP45yA*T*^o@3A?PzOJnz$#2KCwtN z?vF%!`KT5oYD0>dM<_ju|IdCZiC9YnBot?w#IJ?$6vuyyyCuSfV?+tZ4i1bFGDW_< zD)cP@kD(Dee|B~@i$P;`nO6skF>futHXr7#sjIoE#a9+6*INOU$vQk0h+JUHjo>k3 zGR;6^rnxCt%(Pb_aV_{cn|L@QOpfE9zfMBja0wRU<$>NC;2Yupl~_Cw4+XCb4um6t z-q;mca93Pcu;X+#xINBVr`uKQcQ*t)L1)k_cO^evwGFNY4|r19m*$G{jO zk>Fjk19y2nPM62u5bSXWdfYWiU;4{cS8qIWfC+Vt9%rMg#%6rA99UrFU=dI-fe+3J zET{wxoRDKD3;)C%JE6`x_8>_6W+XaPrMoruLNYVS2jF#OzX?h|7-OfciksPbqk$W7 zTjBl0iH|~JSY|JBhhzi;H~DIzOA$xAGn{+=3N0Us<6{N++FMwqz;p6JxPKVpPWQ!< z^Q2^JcV^*L(QK_HGpD`1oZj6U7t|?stFSb^l9TP z+4c~Kk5c>D%+KiQrto+=d$0m*iu&^(DmMZ7RLaXFREOp2LlEZ#?(()KIpCvCqKGY|CQrR{-R zG=r5N^EsasUaU~@V(nNQ!?K;7L(^lF8dY-e>Oi(%UVKy0L{ z8eOtb+;r%_lRs3QOi!wQvO|Mf5DzCVE<|n++Czj8S z(OO`L3|Uy2MiXlwQDI_U+8#&iBOooTzhtoVT~JS0)apuck4lmV>VYIp`9nHZNRlex$z3jn! zF7Zq{8iV#AKIoD?cq|<)OKVe<8L&ZU{|MV@G7*$dU>FdL_->Mt6@DEihat{NzV!}U zZgCT(7b$;YYK@AOa!KEkEe&}KtMKxx+|nvAA*Bw1IjX1hUA=tIQj`tA#`HUIs=~*l zu>A<@?#QaJ`dFCRK*^7JQIb_GtfaxaVmm~;yc|Pg4=@{~FNg_12v*j$6c}5o?kF`5 zP7BQ?rHv7wc9ZYtqk-@#GwE_F9){*+fG21y+gYM_^g1;;>e>{3Sq0@SFgfxn&ftYi z&FdNQgi}wTM2C~`6DAiV2a5_64PHxA_X{LP-x8Pi(C7!YmntnPAu%&c7w{SJ1ONzP z5cF^feR_jN-$=t#XmwvoZQ0oCN2A%J6h*w)P)v=Yxp&#m!x+;ojG?Ioy!;51qxcEy zCjklS#GD0lW$|#;sW!657v*Dv;dse>o2K0vC*3q-F`s!O-AbG_B*#mXF!nEle2k=H zig$p}0PvL>F2{fYdX%hW3It#^_E^jzF$fHUk!X|;#*NP-6=oH-Uc=&(vl#RUohDaQ zj5Bn&2(U;#mqw4g;Xu1oa1W!zrwVC;N!)mJ@&87MuL|0(%3Rw(BV4;i*18=bR@sW| z2B|mE>;>VgC3b?!-^X2eAI&&5_fp=eWap`>%mL9T5;m`#Z0103pET(Mttlv(L(n$W zB@)bZvc%e!HM8f!zE)ybiRmkDC#l6#^MjS0`6dFeV$Y;V3Z0rpX5U0zWX$pFBKdD( z|23z;Bk@4kZ)B8i>%)+O@Ts-VieB1?^bPzT{SDmIPhVdbH z$49Dn;!-g=ZEx%f z`iNCkJ!o%WsHFSMy&92HYbb?t+GzbJ_E9KMlh7UB*bSC>0LuI}cy2AtZib)rZuWU~ zV2n8IjBHMMXV_bnXJ8la$$AF8oz*x9tw}5CC$vpV?-Og=vYW*u-J4rG%q^zaNx*sE z%nV9C7t}V3UAAvN+bHiKC+HTlynRJ>p=gpA@8uC{>@Cy&Vi>FL3~%xR2r?WikVrmabp1+>Q*k@ZH3vPI-zn>@GcBp z-jSVsh~Ceu41kKD^jmo=klpJgscTh@3aDAYg1N<4HTt^UANeuN_ul0F0}*g7B@vje z?HQvm-FS}uph%IN!KpngLbXY}w|`RjO}zmN;RXUxnBp>%ASOSmos~&o9!oa_Kj{5E zFimvnD7T8=I+^30>fn@>T?N+qkq_Ox70+i!JZyHv=DO_Ebbo$7H#V(;v?%|V!BEi? ziw4k;?2;8i)PfVKHO132GDam^h!nJG&!wIN-1yrmNYhyqW+~d4b!jn_Elivg8c?er z!7+C~UCxH#UvobLdw<+#e`>1&?RlGXqKMttH)~M4eo9IrcYSk{$sT>js*#8C6pOzS z2vWS$8Do*+12YdwR2n6AY{>Iwis>u3SE-GmLKlAgY(yaBr{^;&a4k)(_%u@T{2b6f zXU>AHLV``WUYoG3WA(#fBz&zO(5REPx3-~?8rh4|XAOE$tuNw9G-ELEC7Or6O*)k_ zW6*gQBfhM1Gga*okfoiE1V%qd!%t4u!IUw<8c$Qgz?F14CHk54JtZhq?Suv*v5>L_ zUG6zZUE4EuIlA$f_cUtP;vR!$bdGtHKyI$B5^$`2Uwx@4CD778RY(d4*lA#OC6^e7 zUrHUcXebg5#fM7xy6opAG@4YOulucb*NN2bD)0UVLl6yVHJijljK(?`b#xfVL1s#M z6|SEWWxVuF4c!|%NUcv%-icLqhvRN%my3Vg4OO`+Y8&LOwo~v^##UP6p7h4Bq*u~6 zFTIVSZ_gG5t99h960SH>#)t^(;kwRig?idK{!?5<^m7 zgsDQ3HJGHEC0SD~NnFy&W$8@PVqaFyWF1iSS~`*SK%&;td2GP*=wwOiOIKA$k`(jO zio3RInl49;#qySGcUdWLmxq+Ovr^(NOweno$X#Bb(O|8}Yq5AN7I~e+dkm42x+or( zc2i9uSx-s@S}O7eQeDnUk-MEhvcXc3yKzN2t$eDMx!diiv)Y+?fTY$MRV^TQ4W{X~ zRzmfp+$2NNX{3Z+L)G*ekTj1xtP5iM_`V)|a?(r?tF%2YL98;K6AWT2Iy|pE*k)nL z9kjAZhn3yi5}%E*2i$&$g!;{A8uJtAHm_a?;jh8DJ3`}`a|ByTGmDf{?b>~<&|E>~S} z4;|fCPVq8l0#Bg$p*%pCmgKwRR}?(!69@mV55s9cWu=#=yE47(vl^yOz^O(`eoom= zO;Ax>S`_jYIbo&)yv&|I`KULT{nJ3Ir%@=dz&#m)Q?sblG#u4F1EM|kZ;SFbi3cX7 zmZJD<{qCx?`x>o3L#dCniW3f)nHo=OsdkUYr%O=9s#l-F^=R-s~9z+*P5MMXI>9(IiH4@IID4BMl94hL88 z;sb;6A%74=H5iTuNV%)*URth#HanL+Tvn^X$rYRELl1b|KMRQsG_`=MXRRfH87Z}9 z%m7$4J}Q3(m<4R;ANIf%yMTN!5cV5!L>a+-78aEWz)y8BypRWo2qRRbPFNCN?Xy97 zNyKmQ(ZE0`R$dVD<3glpnXs}=>tLPA{MY6Ozm8tN%MZREr#SJ$Hq*0c;wL=cPgdpP za?Gf)v3~hft6(;InnB&uEc1M_bgAPZDJqq#!4bs_{tq~Of zY4f+laPHNJn0~8TBT6Bgp2U^aBx=3n=*rSF9Z0z;e+R`a6Z0H{n~V4#n~XAxeZ|`9^0P zim@ezj`={7xlg`P3$R#uP^+N62K#X?@lYHm)sGii6}y4(atvN5fg)%oE+tcC^hPP8 z2cuxqDCNk2iGm`Daa`4w=_MB%Q;U^Z#W^iZ##S~1BohY2Va>y0gs!ylT6i(1sTy-5 z90{1OoDw3U5EA!<5*x*n#uc!Tq zL)ArwRB=x>CM@uinwXIBN+n}Lrd{cnkZH*7P1rP_J#ACV%0C++pR>Dw-rs{okX@jnhu(^6bnHEQ{l zLCa^U?B1l984)vMy7tZb_IPJ^cN2!IYuo~j{}VWC!r#ZBw0|V6y%BbHO$lRnLmJzq#T?8C1I=V|PhmFSi|PHM^7smW ztMXun1XIsW2O9k(tc;`2X|%q9r-rtatDIXP54;0nEBM%S97gSo_@rQ|tt3Ee#qoWVd`CZf z00iW9C{W_~A|j^(^PfeJlL|Y#XmmXHD2Yq{&m{qHgO1>t+ubD9N^n$VEEfvr%*Z15 zk|B=r^xBq%?|>?Zlt zzi`jn?w-xQowys|&d3KQyZfB8XPIWV@;?l@R2#u9>Nk2ySeXlFws4`ew* z@5Jz+m`HH_KuEm_wb48G)%>IK_9~bYFo8hd=fEEhUxCTk6Xf z41&t~FV%Z9%C{@Z(^Zzasf;fG`ij4wWlFH65HK-i;((13Z-U9g$B>MCk{%MAhOJ-8 zn$6|F_5e~%?kKb0ipS~NPC>0(lGaL94XuSa-jX{d{ZGB6B zo#2F9!az17$y^5X>t=#FaQX6=8?bN0Lb#i%D92~1YodT?m zl)QzTi`24hZG~0gF*m|_kb5t75$pPsjrI$j>ii=m_X09-)E4Itw)#!ALcb};4Qew! z-y1a}c;rmS07twb@gR=eO9>y^Hrz`Iy{2JaN~m{JT33MFAga4NxVfPB(MJMxn?ltC zcyOip9Px3_dZhT^i}I0SO?!q1du%*wj?jt8+o{Luj>F*cLrq_Vx~S79h+44`YWcpO zZ5YuO2fHveLZ)oxn!}LGJD5{nX*BOWEzhU@`6OW)bO=646azAGd-D0N9G<|qb9Dkl z=b^<%_pj~%27JD~yZuXqG2^&Dx2eqUs*Bq|V8@E z-|sA(jsXuVvp{9#XPOzYiK_XCP=?`VLfXz;}-cmsivKe}$ z%&4z-Y6jVR&Tfzx^w0;{@<|x`!{9U^>VD^9EbK}XZM!|8c|ML8XC^>_@uhJ z8}z7Jv0-Ov4%(CN&ErvB>}&7pynKxV>-9=!N37Q61o`t|`0~?-<>gi^XURmUbV5=_C$34i)0knQVNF8bulMzf&uK;3_WT z=Cy_B$U*`OD#Jsr?H(O~zd1LlskRuiw*_rIqQ#}pWov6-AKw99g5Q2@${rp|Mcv>8gj}#gr>c!nX z4OW#eZYzZ~y8IfLZXq><68`%%_`oc8GY926ds@PC{wA!~E%@(Qd7Hz+sNQ)xJ2a#p zBKYhbca1_MwlsT2h8Gi%N~ie5SX%V|j!J&5T%5x7?1{`F^C@g>^N*hV9$1680+_sH@T`O9k~wzESl|aPRcN#Q-2QLy|n!m|}F~ zYo@1wu1M;GiZG;XP#kil-tC>$++6w1mX?NAN{(c>jF22jfZ_V*it`KQ=LL1+nYw&H z!T{hIGA?olaEF*%Z$+#9DNL3!NdwB!c_pUXsXap+J*xs@Pc!0Mx&Iv)81%7! zGz~O^((atHFiX2L;viYN3HrdF)am~iZBbQ2?Q|2PlR0JTHSz|8keb&Y94b6h$uFx*`9q$97JxA-+*pe&4%6}9_!0EXK^#$iJyG6x zfFV&}?0qG-fxXB}g$JNXSUX%Y#VI%J6X2;4_MkvVWtFXs$`bV{;Doc z+czOcGmRWJGj#}1c-NFKCh=VZJ~jfpp*pgP@O9a=5Wd}#W+K{YlybNYgCkR;f}G~{ zbeoN`-D5KemH9{p;<51N3hOg7&saq;3{X-UDz6}QL1ETz=rAmGc zFpUrLtyR8t&j<+tnjzKoW>xhGm%o*uvy)DiPbY9yFZ*2M&oqo4~9H2Ho8kPF(5&2A9Kx`yR} zHWL;j@v!#s2940s5ivicDTIM=2hzZh=GVKNTkTscE|U8Ad^t$^D*uUA5|OeGA)9dR z&NLAndK8ye7E5vL(dk@?sI)*Pvz=anU?f5SL|sxuHO$>P;h3k-nv0z!o!yk3W5 zPrZOnvGjC`rKi&p$mYAAg`O6_oiMExIC|{zj7H+%TUW_Nyq`aLxQw zd38d0Jgw}`;uj8P6A8N*yP)dJBlDuN{#c)_A)rjcWKMzPG^$mlj3l@GC}0C}gqG%p2**jzi(Rb!K5;T>}rAxe~_W0AvIL~thQ+kE(y&W;E&A5V&@Fn#g4oCWJfJUJjG!To4+ zTT>JM2d+N;wEyGiryt+=V)*8@zUGr1@#xL=LHSh6KblTA3;fuHue0rP)9uf$UhTUW zi}#FuaVgpU^}wy$C&g$JOtMq?D_1V8lQRO6RWzZe|HM%qsZJ+-#T^DP*OGosiYy zA6X=d&WtR(RY!;0)uQY9ochBWMij=6b;$*cvDQz*MgndQ(?)2aLv#rYBcB%L+%E&$sWFlh%CSE9o9Vg`J`&AR|f-zB>7)W_OZg<>+^i zSb_oyy<3a5#VKXxnGP8ozC5Hn zMw^HFWFxC_VusX=z7fX-F2*uEsWp7Q}o8rY(fpUFnQnWoQNm zU$?ccep8)3He$0wqjMTlsJ$2T+ZF4$9Qr5}g;&P$27LqRKKdOavv2J!Gn{aXPm;9G z`aOPhh@6S*xB6kyCVWrB5u>7E(K3Wla5s%69V#6Cd|C*FZ}QTJASOgU6-JY7ZX!!r zpm_>xjlK|5eB%9<*n6)Ty`kgcVYK&J`@p^hX_r1U2**yO1oVzb2?LPW8J$Rp1aY4> z9$Naa9WLnuHcv&p+Rz{1(#CEyrZ!yq8zC;HhoLfTw~5-jzU%=S>ACY~`nrLB4b<;i-6=mX8KX>dkc@CwfjnidjZ%wQv4 zKZ(rkrd^8e8m0>KhP^&OHKBGbx^FZvZCmI~wfd2zrXQ?upv% zo#by>pHq811Q=p66p3hr9(+j%p4d_u!ofICgc8DFhCG0;hqpmvDHVy}?)CHPGdhY+ zg!oiEgdbCR)X#)8gwOz*Psf>1IGycJikXZM51#;kA(Ubv)IX;8Yaq0)wQOr2VP4hN z?ON9Yx8JC`Pehb(7HF5_{@v3ZJM(!@*I7=##I_uh6$q?o?tIT#0;ye5ZWhrN1V_%B zIl#KPj&re&uX=F{>wRlmY(g_NGu@&;0FO$yAame~>*(r)1})Uk?ul3XOR?U*uFJi4 z9gs&hJs<;HXHPfR(bL@xcq;fwI{wT~Kj?e4i8>opLIE;Vx+Vp(9WlOxFN3AR9p YlB=R^{{*5{^z=vn12{?3m)G0?0CqBb$N&HU literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_030001.sql.gz b/backend/backups/kaopeilian_backup_20250924_030001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..a1acd48f6e4dd050d151fa1ab0f85183c5f5bb37 GIT binary patch literal 10052 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 a5Mbo0@F$-(HGSF?{@{Oi*CBS-+yDUXETXIc literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_030304.sql.gz b/backend/backups/kaopeilian_backup_20250924_030304.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..151f1439c5d0d53521e62f9c6c30e34e7b91898e GIT binary patch literal 10053 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 b5Mbo0@F$-(HGkUF^1=TA;#u}g*W3UAnk=Ol literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_040001.sql.gz b/backend/backups/kaopeilian_backup_20250924_040001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..81b07e960d5e605742a8e2d4697ff51b6bc3d316 GIT binary patch literal 10052 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 a3j`Rs>QvJ|n$W-S-~I=g@Z(U|+yDS_fSTn1 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_040030.sql.gz b/backend/backups/kaopeilian_backup_20250924_040030.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..5528d4a932bbceabcc46e2395b71f048d04618fb GIT binary patch literal 10052 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 a0*qXBqT!2%hA)~LKKUPkf4)oC+yDSdzIpNh literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_050001.sql.gz b/backend/backups/kaopeilian_backup_20250924_050001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..f6daf8a6c5f339ca8fe006908286a7e36283be22 GIT binary patch literal 10051 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 a3j`Rs>Qwk2O)co(-~I=JqP`T@+yDSWYnW#M literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_060001.sql.gz b/backend/backups/kaopeilian_backup_20250924_060001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..9fa2e2eac60c7a10336843268d750fceb35e8250 GIT binary patch literal 10050 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 Y1Q@vr+aj9&*%bcZf8_VZ64%@S0LOWry#N3J literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_060054.sql.gz b/backend/backups/kaopeilian_backup_20250924_060054.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..789195812f8a97c6ed1208c9f8d44e02781a806b GIT binary patch literal 10050 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 Y0*qXRZ4piXY-{`Ae+%$oaM#=b04ScC@&Et; literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_070001.sql.gz b/backend/backups/kaopeilian_backup_20250924_070001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..8feccdb5b291c52c5f88fcb55eb89fd75c7d476b GIT binary patch literal 10051 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 Z$yFzszi4XuqAB{x{{VXTY~I)0008USeZ2qx literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_070451.sql.gz b/backend/backups/kaopeilian_backup_20250924_070451.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..81d1aaa2b31ec4bb6b96406c251a30ea6a344309 GIT binary patch literal 10052 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 a0*qXBqUno<<}X@KeDXh%zzPP}+yDTbba`q3 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_080001.sql.gz b/backend/backups/kaopeilian_backup_20250924_080001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..f21bf30804a600d4dbf987bd6dd2834df0b23c10 GIT binary patch literal 10053 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 ZV8~U?fj|Q(5!(96{{V6F1drF;001`wfV2Pr literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_090002.sql.gz b/backend/backups/kaopeilian_backup_20250924_090002.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..535764660292bcf5602de1e4e7119c9de98ace96 GIT binary patch literal 10052 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 a%T+C*Q1pwYrZ1XKeDXiA+-V%w+yDSK8GVTW literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_090129.sql.gz b/backend/backups/kaopeilian_backup_20250924_090129.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..8e32c571050a7ddf30b8c76e82e8304f9ae00674 GIT binary patch literal 10052 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 aV8~T1fj}6S2tD=bNB;w*_<6zC+yDR{2!iSW literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_100001.sql.gz b/backend/backups/kaopeilian_backup_20250924_100001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..d06a2b99525fd0226592a4cfcb18010bebf6952b GIT binary patch literal 10052 zcmV-KC%f1miwFpzP}67t18ZS#aAj$1X<=?(Vqs%zb#PxYFfuhTIWja~F)%PNFflH3 zacltWUF%aDNs|A3{ww``wj)E=!@)*LA(EyGutxzFv$*M_XG{cWXyGylQXvHRG3J(eXVbsZ)W=^r?b)NwB_44T{U@n*V|kF zc-;rQ`MTmUe(dldbeY^h~-=Ff7^cNkRK|^PenFO`;U+ZYWd@3&; zoO;pL-r;Y$-reE<*QPcYA583@eVrW)lP*^Qt@e&?Uzbdbyr$IXWjY91ue$?1VV;9U zFc^#vMtQOT;^f-*!N_1V#{2oZfj)nXkHJojbW>+8ZBz>^fh(+ z1A%L>ZXCFc++EkSx#Jpc!`Y6HcXDI1lvv+j zI35}>NzThsTYsjf1_I{&Hv-{UC4`de!opSop&Oxa*xwTl^xrN`NW^!-tEJ z^DxwM&kj-gt@t4WTw2kK8qPcDH$)>DPw<16K1O2fdLFGezeSB{y5a+`~WoSBeG{tw5 zX!?V&bSSQ;#jPj8T9VQagu`%yC?5~p<|`lQ=&+R9%Z*KgRAikE+?9#>qa52g-0tcN3O(g@qla8|Y3iALip_b;l{IK5eY! zn!T5%RXHAB=AN$S2lH)b>$RrNKXKs1I6JnP7x7iW2h{Oup?4+SH=U`9jAtX-Ae05wih>QFpLNo$+7 z0^e7}dFfmtry9$S&CxQEk0)EWP{=&-wiSdGI?zG}SicAeKE${vS@j2M4=)iL95ca@ZD z9QX^%d=bLb(PHwN9C5Z*3NUiqn_0|Pkx@O zrQG0XAY-n9>t0Ls*#(50Cp6P8xa88#NP|^#?E;l^t8-$d?p0=`eC=8zb-kUjh809S zec(f*A;8KeqMdx(^AYLf$75lXBaA1|`xn|+r$5jKi1AJ!ibra9JDnFfHFrQbDDa=0 zf&Vm!r+ys~7M`NHX>nsm0XMN=G&F!C1wcdm-FW4nUB{y+n5{0hX#1|P8Qxse4jxtCYgK1LjP45yA*T*^o@3A?PzOJnz$#2KCwtN z?vF%!`KT5oYD0>dM<_ju|IdCZiC9YnBot?w#IJ?$6vuyyyCuSfV?+tZ4i1bFGDW_< zD)cP@kD(Dee|B~@i$P;`nO6skF>futHXr7#sjIoE#a9+6*INOU$vQk0h+JUHjo>k3 zGR;6^rnxCt%(Pb_aV_{cn|L@QOpfE9zfMBja0wRU<$>NC;2Yupl~_Cw4+XCb4um6t z-q;mca93Pcu;X+#xINBVr`uKQcQ*t)L1)k_cO^evwGFNY4|r19m*$G{jO zk>Fjk19y2nPM62u5bSXWdfYWiU;4{cS8qIWfC+Vt9%rMg#%6rA99UrFU=dI-fe+3J zET{wxoRDKD3;)C%JE6`x_8>_6W+XaPrMoruLNYVS2jF#OzX?h|7-OfciksPbqk$W7 zTjBl0iH|~JSY|JBhhzi;H~DIzOA$xAGn{+=3N0Us<6{N++FMwqz;p6JxPKVpPWQ!< z^Q2^JcV^*L(QK_HGpD`1oZj6U7t|?stFSb^l9TP z+4c~Kk5c>D%+KiQrto+=d$0m*iu&^(DmMZ7RLaXFREOp2LlEZ#?(()KIpCvCqKGY|CQrR{-R zG=r5N^EsasUaU~@V(nNQ!?K;7L(^lF8dY-e>Oi(%UVKy0L{ z8eOtb+;r%_lRs3QOi!wQvO|Mf5DzCVE<|n++Czj8S z(OO`L3|Uy2MiXlwQDI_U+8#&iBOooTzhtoVT~JS0)apuck4lmV>VYIp`9nHZNRlex$z3jn! zF7Zq{8iV#AKIoD?cq|<)OKVe<8L&ZU{|MV@G7*$dU>FdL_->Mt6@DEihat{NzV!}U zZgCT(7b$;YYK@AOa!KEkEe&}KtMKxx+|nvAA*Bw1IjX1hUA=tIQj`tA#`HUIs=~*l zu>A<@?#QaJ`dFCRK*^7JQIb_GtfaxaVmm~;yc|Pg4=@{~FNg_12v*j$6c}5o?kF`5 zP7BQ?rHv7wc9ZYtqk-@#GwE_F9){*+fG21y+gYM_^g1;;>e>{3Sq0@SFgfxn&ftYi z&FdNQgi}wTM2C~`6DAiV2a5_64PHxA_X{LP-x8Pi(C7!YmntnPAu%&c7w{SJ1ONzP z5cF^feR_jN-$=t#XmwvoZQ0oCN2A%J6h*w)P)v=Yxp&#m!x+;ojG?Ioy!;51qxcEy zCjklS#GD0lW$|#;sW!657v*Dv;dse>o2K0vC*3q-F`s!O-AbG_B*#mXF!nEle2k=H zig$p}0PvL>F2{fYdX%hW3It#^_E^jzF$fHUk!X|;#*NP-6=oH-Uc=&(vl#RUohDaQ zj5Bn&2(U;#mqw4g;Xu1oa1W!zrwVC;N!)mJ@&87MuL|0(%3Rw(BV4;i*18=bR@sW| z2B|mE>;>VgC3b?!-^X2eAI&&5_fp=eWap`>%mL9T5;m`#Z0103pET(Mttlv(L(n$W zB@)bZvc%e!HM8f!zE)ybiRmkDC#l6#^MjS0`6dFeV$Y;V3Z0rpX5U0zWX$pFBKdD( z|23z;Bk@4kZ)B8i>%)+O@Ts-VieB1?^bPzT{SDmIPhVdbH z$49Dn;!-g=ZEx%f z`iNCkJ!o%WsHFSMy&92HYbb?t+GzbJ_E9KMlh7UB*bSC>0LuI}cy2AtZib)rZuWU~ zV2n8IjBHMMXV_bnXJ8la$$AF8oz*x9tw}5CC$vpV?-Og=vYW*u-J4rG%q^zaNx*sE z%nV9C7t}V3UAAvN+bHiKC+HTlynRJ>p=gpA@8uC{>@Cy&Vi>FL3~%xR2r?WikVrmabp1+>Q*k@ZH3vPI-zn>@GcBp z-jSVsh~Ceu41kKD^jmo=klpJgscTh@3aDAYg1N<4HTt^UANeuN_ul0F0}*g7B@vje z?HQvm-FS}uph%IN!KpngLbXY}w|`RjO}zmN;RXUxnBp>%ASOSmos~&o9!oa_Kj{5E zFimvnD7T8=I+^30>fn@>T?N+qkq_Ox70+i!JZyHv=DO_Ebbo$7H#V(;v?%|V!BEi? ziw4k;?2;8i)PfVKHO132GDam^h!nJG&!wIN-1yrmNYhyqW+~d4b!jn_Elivg8c?er z!7+C~UCxH#UvobLdw<+#e`>1&?RlGXqKMttH)~M4eo9IrcYSk{$sT>js*#8C6pOzS z2vWS$8Do*+12YdwR2n6AY{>Iwis>u3SE-GmLKlAgY(yaBr{^;&a4k)(_%u@T{2b6f zXU>AHLV``WUYoG3WA(#fBz&zO(5REPx3-~?8rh4|XAOE$tuNw9G-ELEC7Or6O*)k_ zW6*gQBfhM1Gga*okfoiE1V%qd!%t4u!IUw<8c$Qgz?F14CHk54JtZhq?Suv*v5>L_ zUG6zZUE4EuIlA$f_cUtP;vR!$bdGtHKyI$B5^$`2Uwx@4CD778RY(d4*lA#OC6^e7 zUrHUcXebg5#fM7xy6opAG@4YOulucb*NN2bD)0UVLl6yVHJijljK(?`b#xfVL1s#M z6|SEWWxVuF4c!|%NUcv%-icLqhvRN%my3Vg4OO`+Y8&LOwo~v^##UP6p7h4Bq*u~6 zFTIVSZ_gG5t99h960SH>#)t^(;kwRig?idK{!?5<^m7 zgsDQ3HJGHEC0SD~NnFy&W$8@PVqaFyWF1iSS~`*SK%&;td2GP*=wwOiOIKA$k`(jO zio3RInl49;#qySGcUdWLmxq+Ovr^(NOweno$X#Bb(O|8}Yq5AN7I~e+dkm42x+or( zc2i9uSx-s@S}O7eQeDnUk-MEhvcXc3yKzN2t$eDMx!diiv)Y+?fTY$MRV^TQ4W{X~ zRzmfp+$2NNX{3Z+L)G*ekTj1xtP5iM_`V)|a?(r?tF%2YL98;K6AWT2Iy|pE*k)nL z9kjAZhn3yi5}%E*2i$&$g!;{A8uJtAHm_a?;jh8DJ3`}`a|ByTGmDf{?b>~<&|E>~S} z4;|fCPVq8l0#Bg$p*%pCmgKwRR}?(!69@mV55s9cWu=#=yE47(vl^yOz^O(`eoom= zO;Ax>S`_jYIbo&)yv&|I`KULT{nJ3Ir%@=dz&#m)Q?sblG#u4F1EM|kZ;SFbi3cX7 zmZJD<{qCx?`x>o3L#dCniW3f)nHo=OsdkUYr%O=9s#l-F^=R-s~9z+*P5MMXI>9(IiH4@IID4BMl94hL88 z;sb;6A%74=H5iTuNV%)*URth#HanL+Tvn^X$rYRELl1b|KMRQsG_`=MXRRfH87Z}9 z%m7$4J}Q3(m<4R;ANIf%yMTN!5cV5!L>a+-78aEWz)y8BypRWo2qRRbPFNCN?Xy97 zNyKmQ(ZE0`R$dVD<3glpnXs}=>tLPA{MY6Ozm8tN%MZREr#SJ$Hq*0c;wL=cPgdpP za?Gf)v3~hft6(;InnB&uEc1M_bgAPZDJqq#!4bs_{tq~Of zY4f+laPHNJn0~8TBT6Bgp2U^aBx=3n=*rSF9Z0z;e+R`a6Z0H{n~V4#n~XAxeZ|`9^0P zim@ezj`={7xlg`P3$R#uP^+N62K#X?@lYHm)sGii6}y4(atvN5fg)%oE+tcC^hPP8 z2cuxqDCNk2iGm`Daa`4w=_MB%Q;U^Z#W^iZ##S~1BohY2Va>y0gs!ylT6i(1sTy-5 z90{1OoDw3U5EA!<5*x*n#uc!Tq zL)ArwRB=x>CM@uinwXIBN+n}Lrd{cnkZH*7P1rP_J#ACV%0C++pR>Dw-rs{okX@jnhu(^6bnHEQ{l zLCa^U?B1l984)vMy7tZb_IPJ^cN2!IYuo~j{}VWC!r#ZBw0|V6y%BbHO$lRnLmJzq#T?8C1I=V|PhmFSi|PHM^7smW ztMXun1XIsW2O9k(tc;`2X|%q9r-rtatDIXP54;0nEBM%S97gSo_@rQ|tt3Ee#qoWVd`CZf z00iW9C{W_~A|j^(^PfeJlL|Y#XmmXHD2Yq{&m{qHgO1>t+ubD9N^n$VEEfvr%*Z15 zB4HUGH0wF2e@Od!zrIq-)A*nt2I#sPQJ?w^%H!4~@c*!=sk zN4D@NgUU-KH>V0Xt{w4Ww?U775$V|Ghf4j0-_9 z5#`ql|ObNCW0w$)6@3T?j4KR857?P1s(nEsNu=y)l zv$-7D9zd$8yn6>W63oaCW)9)Yim5M7eA^i+se%MICBzU=<34L%ANn>r|-wcONGbE^g6x-T5kYIW02x~Ti?<* z6P$2M=+9;(nahBF-K=m{;6t{?SqH>!m4o1%jh95NV<83{;S*V69Y9>k%0Dd9ughI=WY*EGya3H5GD>k5z?M0IxuHy89i`beN|Q>eNR z53V$yBR=j~j}#w#Q9d%PY0vOrkBn!{Av!U6JM~!IaTr{FsOgJP7j^msQ7bk=E#D8b z4I|p(U>BxF$ds*Ia~N`Y2XpExjpluz<@uC9pCn9!4!|dgVn8NtPd?w3!xI>Hu1hs-hW&c^w zqdQ4gbHj-EW3F6;X>+?`SGd^u3!U9pV=)?orJW~WI*9|1Lxs3>CL15+24yKb?0b+N zRKUL|Ec0RtI?({N3!V?Vi^Ue;!xoV(17a7K)1ZlaRAp)J$4Y+5K2S0s-w{}_5-=Mo zHnJfuD)=?DJnGYf#OW2ZQ^*K39dcPY(U^p|mHeyf@}KJW_e#aTdM|a-@6-w)xQffT zd2Jy&vXH=n%J7hDyGIA$Z_Z6BZY>DdU1D8 zgH`2=TS{S-F24q*TSyI}g#SJbJ}}GO^nUryu9onezX9uY3;ugn-sP|`s&}5w4i4&v z2tIqqU84|*P0gN>;l%``(kVVMnil=Pqmo}O7bkH&dm?kldkE0X%4A`B@T5C>hUcUwm_H(P$YsimQnlEWD;BP53tV7UId;@o`sML}JEt}gA9 zFaUUljEmd>T%)|n9_8}xmiGQoSMDkIjPkY(jW$P!q@&GYZP{#JLhNUDhRm+qwcvE5IPC=q=-mT&&VYZE7nhaIpL7pf62gnn z_4nRPxj0eUU7?)c*{ry23B-1`m;4EoqV zo&uUdX=hfMpP}6uagZ$C1byI7>ePRXwy3J1_Bo9me=|Hqfj)cKe-A83JO(@kP-$J8 zrJW$viJUU|26+QQNX_dH4i%oMp{~_P&x^$6n;6!UNDGtQ{_y;*=Zq3Gmbidr+XGvdZRqWfS^G*VbS(=MyLwe^nQz z?3<9InMMwqnK}e0ylcuAllZOy9~lAOP#sxC__}Ob2;Xi=GZF1HN;%wy!I7yEK~D2} zy3Iz}?y;GK%6udPaan}=NnHss_r{d92eiZ_q~cEf)S6qKs7uRqQ@v_*S^#K>yB3aC zXU5CLSC+$*2FujgzG=$fMC=m`8VYVqYbhBi!`Er?)Gd{i{viu6-v)#B0a5)+*n+X9R@;u)d)q#^z>`k!>ws8u@WDed#Z7Fnnp|hl4YF;U#=fvayM`I7r7j z1!t6xb@+NMUq(yc+!L)WPY+-U>X7**H4@Ay>tce)QP714ntVS4$OUc3X4i>hUBmJ~ zn+c1Ncv$;*gGT7+h?pPJ6v9Bb18HDL^XpyCt@bSz7fF45z8oZdmH$L5iAdQ8kWILD zXPSr(y$xKc4Grk7h|`d!$b9SpYmQXV^U9LczhRs})fottWbx{$1qMKS0inVVUav#3 zr(QrOS$aCj($gsvx$OzW(9>y_o=(G#FPXNSGIBg&va!S|aonCIuRg2(maJvL97df19b~i7_F;r!!na8WdA3r&Tap&HTgi z)1vZrfi|P)bOE3)HOP4Kg*JO>e^dQ#_efQ7KGLyR2G@!hzb#ON=S0X4%X7joBIJjc zH>zi(Rb!K5;T>}jAxe~_W0AvIL~thQ+kE(y&JGJQA5V&@Fn#f*oCWJfJl-!Q!To6S z>82+94_tkEqVLn_iBGS8HFV=@Z}ai?c=SfwfPAv$A5Ev41%C9x&1{?8bo=uwS9&kT z;@zWPT}rmy?7wyUxEO7MNp>oK<;sOMaz;S1iYD~*pE$~+{AaOTT*i@6dj3-!$pg<9 zmiFPn3YB3Okn8HxpTjs+uXa<9`5~VdHJvqlXy}xND_1m~@Q)^p0vb&Nf)!CX=d%fR z`(^3vB0HAw8Iu`HHqqkEVK%hU4d`GllCqlX6U6Ia>4)_@$NR23VX@gi7D@D)ITyGU zjdYM3i5$}wkJdx6y!J?Wu?BF6B9gY0!Zf&vFZF!f0W7f=16OIt&4zf9LRNan30W=v zkwv2D%*e7^b#%C0ExKOJsz0n^L}C0`mt4RYYyB*2B;e*SjWTY4YbTVo20+Z-+*P+< z*W@`jql}~=qVt*WO#Qt#)2i%lm5L8hM)o-%6B!dQ{!%GyJ0VZsubOBV1PXzokJ4!q z?4jQ-h&veoc{AtqHls|P83z&~4FVHn;k4=K^DF!J%Fj2TpTg?*)rq$?yOSgBl?VgLuX z#7>)Yo)i2H$dc<8JskiGFehHOqi1y9bs%WadDjiBElgInc51P|1-w3@6;;lO(OP zevcm+BxmCKtv;Bv3E$Ij#HeUkvkx1={7fe|LAq&V1S3d6v^Ju`S1B1p+IYJKuelKx$W%n?$9*}{pqq~c1@9ydXJQe&T9oRgf+D8q%c6DEl^%#gnmzp^Zu`Dpx$zf#n1Y01a a%T+C*(1|aan!ad?e)2z%h|9Ux+yDSh7=B~` literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_100028.sql.gz b/backend/backups/kaopeilian_backup_20250924_100028.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..8958c4ec51a2debc96e57074303c41f9bcd64eba GIT binary patch literal 10054 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 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_110001.sql.gz b/backend/backups/kaopeilian_backup_20250924_110001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..8e5013eb2f62fd3e1f247f757c064860c7de2586 GIT binary patch literal 10054 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 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_120238.sql.gz b/backend/backups/kaopeilian_backup_20250924_120238.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..d32f0f38b1338551075a5e0ebb72deacc4d3154d GIT binary patch literal 10051 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 Z0)|}G8VI!hlZaLy{10c>;MUjN001|IoAm$y literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_130001.sql.gz b/backend/backups/kaopeilian_backup_20250924_130001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..ddbf99de12f3c556e43c15e09a9f30399334fae2 GIT binary patch literal 10054 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 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_140001.sql.gz b/backend/backups/kaopeilian_backup_20250924_140001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..37f03467ef29f7b59be57c3b933455e2dcb735bf GIT binary patch literal 10053 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 bx?I%~3blRS)bx2%^rQa)YKDP5*W3UA0|SC# literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_140404.sql.gz b/backend/backups/kaopeilian_backup_20250924_140404.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..b64680ab40788a03b4f26dbdbf619cc0ca7a2071 GIT binary patch literal 10053 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 bfq)@bH3tH%|7>VR|33O3@6>j+*W3UAz`lMT literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_150001.sql.gz b/backend/backups/kaopeilian_backup_20250924_150001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..ea4e00b480fb08dec8ff140fbb369c46876ae4be GIT binary patch literal 10054 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 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_150347.sql.gz b/backend/backups/kaopeilian_backup_20250924_150347.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..03500cee4f6d457c964b6bf4fe58ed45c58f9fa3 GIT binary patch literal 10054 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 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_160001.sql.gz b/backend/backups/kaopeilian_backup_20250924_160001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..ad14040240e893520b47a46756248679853907f4 GIT binary patch literal 10054 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 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_160424.sql.gz b/backend/backups/kaopeilian_backup_20250924_160424.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..2367e6c8496d41d5120b5020b827e9a22008b6ae GIT binary patch literal 10053 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 b0Yk284g^~N+0cySs*nB$vl4it*W3UA;xBi+ literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_170001.sql.gz b/backend/backups/kaopeilian_backup_20250924_170001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..b21ee3d617761c9dd981191136321d0a77e5af5d GIT binary patch literal 10054 zcmV-MC%M=kiwFo2v(sn*18ZS#aAj$1X<=?(Vqs%zb#PxYFfuhTIWja~F*h(UFflH3 zacltWUF%aDNs|A3{ww``wj)E=!@)*LA(EyGutxzFv$*M_XG{cWXyGylQXvHRG3J(eXVbsZ)W=^r?b)NwB_44T{U@n*V|kF zc-;rQ`MTmUe(dldbeY^h~-=Ff7^cNkRK|^PenFO`;U+ZYWd@3&; zoO;pL-r;Y$-reE<*QPcYA583@eVrW)lP*^Qt@e&?Uzbdbyr$IXWjY91ue$?1VV;9U zFc^#vMtQOT;^f-*!N_1V#{2oZfj)nXkHJojbW>+8ZBz>^fh(+ z1A%L>ZXCFc++EkSx#Jpc!`Y6HcXDI1lvv+j zI35}>NzThsTYsjf1_I{&Hv-{UC4`de!opSop&Oxa*xwTl^xrN`NW^!-tEJ z^DxwM&kj-gt@t4WTw2kK8qPcDH$)>DPw<16K1O2fdLFGezeSB{y5a+`~WoSBeG{tw5 zX!?V&bSSQ;#jPj8T9VQagu`%yC?5~p<|`lQ=&+R9%Z*KgRAikE+?9#>qa52g-0tcN3O(g@qla8|Y3iALip_b;l{IK5eY! zn!T5%RXHAB=AN$S2lH)b>$RrNKXKs1I6JnP7x7iW2h{Oup?4+SH=U`9jAtX-Ae05wih>QFpLNo$+7 z0^e7}dFfmtry9$S&CxQEk0)EWP{=&-wiSdGI?zG}SicAeKE${vS@j2M4=)iL95ca@ZD z9QX^%d=bLb(PHwN9C5Z*3NUiqn_0|Pkx@O zrQG0XAY-n9>t0Ls*#(50Cp6P8xa88#NP|^#?E;l^t8-$d?p0=`eC=8zb-kUjh809S zec(f*A;8KeqMdx(^AYLf$75lXBaA1|`xn|+r$5jKi1AJ!ibra9JDnFfHFrQbDDa=0 zf&Vm!r+ys~7M`NHX>nsm0XMN=G&F!C1wcdm-FW4nUB{y+n5{0hX#1|P8Qxse4jxtCYgK1LjP45yA*T*^o@3A?PzOJnz$#2KCwtN z?vF%!`KT5oYD0>dM<_ju|IdCZiC9YnBot?w#IJ?$6vuyyyCuSfV?+tZ4i1bFGDW_< zD)cP@kD(Dee|B~@i$P;`nO6skF>futHXr7#sjIoE#a9+6*INOU$vQk0h+JUHjo>k3 zGR;6^rnxCt%(Pb_aV_{cn|L@QOpfE9zfMBja0wRU<$>NC;2Yupl~_Cw4+XCb4um6t z-q;mca93Pcu;X+#xINBVr`uKQcQ*t)L1)k_cO^evwGFNY4|r19m*$G{jO zk>Fjk19y2nPM62u5bSXWdfYWiU;4{cS8qIWfC+Vt9%rMg#%6rA99UrFU=dI-fe+3J zET{wxoRDKD3;)C%JE6`x_8>_6W+XaPrMoruLNYVS2jF#OzX?h|7-OfciksPbqk$W7 zTjBl0iH|~JSY|JBhhzi;H~DIzOA$xAGn{+=3N0Us<6{N++FMwqz;p6JxPKVpPWQ!< z^Q2^JcV^*L(QK_HGpD`1oZj6U7t|?stFSb^l9TP z+4c~Kk5c>D%+KiQrto+=d$0m*iu&^(DmMZ7RLaXFREOp2LlEZ#?(()KIpCvCqKGY|CQrR{-R zG=r5N^EsasUaU~@V(nNQ!?K;7L(^lF8dY-e>Oi(%UVKy0L{ z8eOtb+;r%_lRs3QOi!wQvO|Mf5DzCVE<|n++Czj8S z(OO`L3|Uy2MiXlwQDI_U+8#&iBOooTzhtoVT~JS0)apuck4lmV>VYIp`9nHZNRlex$z3jn! zF7Zq{8iV#AKIoD?cq|<)OKVe<8L&ZU{|MV@G7*$dU>FdL_->Mt6@DEihat{NzV!}U zZgCT(7b$;YYK@AOa!KEkEe&}KtMKxx+|nvAA*Bw1IjX1hUA=tIQj`tA#`HUIs=~*l zu>A<@?#QaJ`dFCRK*^7JQIb_GtfaxaVmm~;yc|Pg4=@{~FNg_12v*j$6c}5o?kF`5 zP7BQ?rHv7wc9ZYtqk-@#GwE_F9){*+fG21y+gYM_^g1;;>e>{3Sq0@SFgfxn&ftYi z&FdNQgi}wTM2C~`6DAiV2a5_64PHxA_X{LP-x8Pi(C7!YmntnPAu%&c7w{SJ1ONzP z5cF^feR_jN-$=t#XmwvoZQ0oCN2A%J6h*w)P)v=Yxp&#m!x+;ojG?Ioy!;51qxcEy zCjklS#GD0lW$|#;sW!657v*Dv;dse>o2K0vC*3q-F`s!O-AbG_B*#mXF!nEle2k=H zig$p}0PvL>F2{fYdX%hW3It#^_E^jzF$fHUk!X|;#*NP-6=oH-Uc=&(vl#RUohDaQ zj5Bn&2(U;#mqw4g;Xu1oa1W!zrwVC;N!)mJ@&87MuL|0(%3Rw(BV4;i*18=bR@sW| z2B|mE>;>VgC3b?!-^X2eAI&&5_fp=eWap`>%mL9T5;m`#Z0103pET(Mttlv(L(n$W zB@)bZvc%e!HM8f!zE)ybiRmkDC#l6#^MjS0`6dFeV$Y;V3Z0rpX5U0zWX$pFBKdD( z|23z;Bk@4kZ)B8i>%)+O@Ts-VieB1?^bPzT{SDmIPhVdbH z$49Dn;!-g=ZEx%f z`iNCkJ!o%WsHFSMy&92HYbb?t+GzbJ_E9KMlh7UB*bSC>0LuI}cy2AtZib)rZuWU~ zV2n8IjBHMMXV_bnXJ8la$$AF8oz*x9tw}5CC$vpV?-Og=vYW*u-J4rG%q^zaNx*sE z%nV9C7t}V3UAAvN+bHiKC+HTlynRJ>p=gpA@8uC{>@Cy&Vi>FL3~%xR2r?WikVrmabp1+>Q*k@ZH3vPI-zn>@GcBp z-jSVsh~Ceu41kKD^jmo=klpJgscTh@3aDAYg1N<4HTt^UANeuN_ul0F0}*g7B@vje z?HQvm-FS}uph%IN!KpngLbXY}w|`RjO}zmN;RXUxnBp>%ASOSmos~&o9!oa_Kj{5E zFimvnD7T8=I+^30>fn@>T?N+qkq_Ox70+i!JZyHv=DO_Ebbo$7H#V(;v?%|V!BEi? ziw4k;?2;8i)PfVKHO132GDam^h!nJG&!wIN-1yrmNYhyqW+~d4b!jn_Elivg8c?er z!7+C~UCxH#UvobLdw<+#e`>1&?RlGXqKMttH)~M4eo9IrcYSk{$sT>js*#8C6pOzS z2vWS$8Do*+12YdwR2n6AY{>Iwis>u3SE-GmLKlAgY(yaBr{^;&a4k)(_%u@T{2b6f zXU>AHLV``WUYoG3WA(#fBz&zO(5REPx3-~?8rh4|XAOE$tuNw9G-ELEC7Or6O*)k_ zW6*gQBfhM1Gga*okfoiE1V%qd!%t4u!IUw<8c$Qgz?F14CHk54JtZhq?Suv*v5>L_ zUG6zZUE4EuIlA$f_cUtP;vR!$bdGtHKyI$B5^$`2Uwx@4CD778RY(d4*lA#OC6^e7 zUrHUcXebg5#fM7xy6opAG@4YOulucb*NN2bD)0UVLl6yVHJijljK(?`b#xfVL1s#M z6|SEWWxVuF4c!|%NUcv%-icLqhvRN%my3Vg4OO`+Y8&LOwo~v^##UP6p7h4Bq*u~6 zFTIVSZ_gG5t99h960SH>#)t^(;kwRig?idK{!?5<^m7 zgsDQ3HJGHEC0SD~NnFy&W$8@PVqaFyWF1iSS~`*SK%&;td2GP*=wwOiOIKA$k`(jO zio3RInl49;#qySGcUdWLmxq+Ovr^(NOweno$X#Bb(O|8}Yq5AN7I~e+dkm42x+or( zc2i9uSx-s@S}O7eQeDnUk-MEhvcXc3yKzN2t$eDMx!diiv)Y+?fTY$MRV^TQ4W{X~ zRzmfp+$2NNX{3Z+L)G*ekTj1xtP5iM_`V)|a?(r?tF%2YL98;K6AWT2Iy|pE*k)nL z9kjAZhn3yi5}%E*2i$&$g!;{A8uJtAHm_a?;jh8DJ3`}`a|ByTGmDf{?b>~<&|E>~S} z4;|fCPVq8l0#Bg$p*%pCmgKwRR}?(!69@mV55s9cWu=#=yE47(vl^yOz^O(`eoom= zO;Ax>S`_jYIbo&)yv&|I`KULT{nJ3Ir%@=dz&#m)Q?sblG#u4F1EM|kZ;SFbi3cX7 zmZJD<{qCx?`x>o3L#dCniW3f)nHo=OsdkUYr%O=9s#l-F^=R-s~9z+*P5MMXI>9(IiH4@IID4BMl94hL88 z;sb;6A%74=H5iTuNV%)*URth#HanL+Tvn^X$rYRELl1b|KMRQsG_`=MXRRfH87Z}9 z%m7$4J}Q3(m<4R;ANIf%yMTN!5cV5!L>a+-78aEWz)y8BypRWo2qRRbPFNCN?Xy97 zNyKmQ(ZE0`R$dVD<3glpnXs}=>tLPA{MY6Ozm8tN%MZREr#SJ$Hq*0c;wL=cPgdpP za?Gf)v3~hft6(;InnB&uEc1M_bgAPZDJqq#!4bs_{tq~Of zY4f+laPHNJn0~8TBT6Bgp2U^aBx=3n=*rSF9Z0z;e+R`a6Z0H{n~V4#n~XAxeZ|`9^0P zim@ezj`={7xlg`P3$R#uP^+N62K#X?@lYHm)sGii6}y4(atvN5fg)%oE+tcC^hPP8 z2cuxqDCNk2iGm`Daa`4w=_MB%Q;U^Z#W^iZ##S~1BohY2Va>y0gs!ylT6i(1sTy-5 z90{1OoDw3U5EA!<5*x*n#uc!Tq zL)ArwRB=x>CM@uinwXIBN+n}Lrd{cnkZH*7P1rP_J#ACV%0C++pR>Dw-rs{okX@jnhu(^6bnHEQ{l zLCa^U?B1l984)vMy7tZb_IPJ^cN2!IYuo~j{}VWC!r#ZBw0|V6y%BbHO$lRnLmJzq#T?8C1I=V|PhmFSi|PHM^7smW ztMXun1XIsW2O9k(tc;`2X|%q9r-rtatDIXP54;0nEBM%S97gSo_@rQ|tt3Ee#qoWVd`CZf z00iW9C{W_~A|j^(^PfeJlL|Y#XmmXHD2Yq{&m{qHgO1>t+ubD9N^n$VEEfvr%*Z15 z1tz#LuGv$G?q_eBl+1>NC=VK0yt*|?eR*SiWu-Rh{29ND*WaPdq zXY8FA9FSu%p$`eEH?cN)=f0MGRNP)gbAl!i>H7lug8+6UK%;Ts-L&^dDVKAEemB1G zZv3GmJjtN)Le0#nZ(ga7=Sp+g;{9K+T)I06MH$|W-_fU`#j*32TKfeg)xD%nVGq7a zDooR#6T=i56k-Or(EJ*yCE`hi)$zCL!X{g;foGA?vLk5cY`@0TjnpFH+xfqh$5)^b z1Q$_$wOD*k%e_G08z-x@f0w~FX%-iPFOrW2YR34miivBaZF-22hbeE%8xhah=Ao>d5&od=pDMU<6ojBm5#G7dH;24sVPqIV8)3Egm zU9*`C+8#)%DZPD*HWJRrk7kbG%Z@2uX6VdErz>c(rK$z7da3ij0#DHEl;On<=Pido zobBVGgCRx(_4yCl>W(`59b`^I3|(7%Qo8eBc=~R9qL6!}&TPOX(t3SB8iy2LxAiUc zRZNg>N&O>fB`Ks)ziw7IJMa-(&2ptI?RfzR_XxnuAMqGU`{U2$pd5Mr&Ea4 z(UP}#bJ1G1t*y8!Ja8k(gWPkelUmoGY_whM(B>bhnHQLWleUmQ*zPygiv6alZcvBu z)xA;El1I*T065|e34=ItFC{p%ZMc^bc}>H-lt}NEw5|xbepGj_;^so$#~umPYzoy5 z@WGYtb0kJR>yg627vm$toAwL_du%*wj?jt8+o{Lyj>F;d!%Sa@xv0@6h*_~AX8FFK zZJ5xODt2LJgk0Imb%!CBcW|e^(rMm%TAokW=aU39=n#C87zSkF_Vn{zIXs1N=js%Q z%|i=E_pk0C27IBdtL+PdG4r@Tv#HMSYKz-QU`jPr*wxZZvR6>)Nh6-`v z_d5$`W59#z?DvJ-CcFe@0P7I{6uK`)F1KCjKnuzoBmq^IephF6V0Lo!mhyX$&DbMl zMti+eG02|tPJ;x{Lmy2 z_o!OIu(LFW?aBA%VN{oT+d4b0TocfG{j#GyQt5I+{CNPr`1E0M`Dg6v^X*>g;HlrE zJH=LW14R5jU(DmQxm}T~Lgd25&hBfG2#dkO?qf8a)Pbi;g}79DBswMxCY6!lx(De& z1?m@tlcJo!PBcjE!so;8VzCYQs6}MkfcVAb6l&rgRaw~op_E;A4wUrCcLW!#M9hYX zjckaE3cQAuN9*(;srCxiDP$Cz4uz3qtT733OWBv@l|Qua?iKQX_FnE}->DZsa21!K zd1WCwx{%OMU_bSCIlG1E>q-9=72JZDZj zhnD`atfVxoB;E6<+F*|h!_vpoIDS=A764@w7ox4P&>XhyXYU%t)1rucB*+j+FYfMX zxT<`9Tg|Pp<=4=3ONl{}@V`!j56*Hob5OjqrzboYZ{m90QvE%vZ!;i_8l9&`1_zBp zggSeNu1Sc*mTu2Ti*gK8>4X>?OUd=Wqm*4M=BJ>ZJ(W34y@3xL|X0G^VOHV_q#D~*DT8aSnA!HH>+v$+L1VhYvS{$_lNf_?VDe-AB47z0cJR$ABR zSSLtrGNVqtCffodBt}9<$;=2xfYy@~yb>tP{YqDt(e7hyhB(&2kp)FXIJO3}3qW;qc5}u!IjwHn#AVD$=n* z!8zq)1HM+vm)Wvz?n%~`rw1?tb;SIV83{1TnwTJQ6m;Q%7T-@FazQ(>*$wJg*RVX$ zR>EQ?9@anJq!9)>Le_^Yg)k9bg*0%a`SmX6R{0j2i=;8Wx*R0yD*s7V5|y$KA)9dJ z&a@C6b{n`-8y3)A5oaMSk@?^OYmHPe^2*ZGzd=Z#S{;a#Wbx{$1qVQT0g=KFUaupu zr(Qs(czQa;)6;1Zx$OzW(bE~8p3dNyM0X&b_23Kh@Z=lEf@RdIR#{I+e4_o7f-KPT z;Zj_bWB!Mzl<#a6w_eeshan|Vum4;%`}hNGDW}fw=+PCa)YnQfDh!FKp?VcX2dX7KCBt@WBd`VHc6>@{^x}kg8X|X~g`{&kI}58s0Z_O2?Henm(={O@IOxO#_l0QIPZ5 z0=x5~@Mei0OK`?y#gap`cyo{sZEOQJn2V;Y7WyRl23q<-E!cE2ltB4HnE?=+IQv2Hx;{+CMzeu z{lpSZ0HkM3rlQuZ0}HSwUU#BrY~FPwXs~(LO{^_Wm9}>)v9>s+&O9?9{lk}s zl*egPlj6flQcR8oMrG;i3ga5Nb$t|00BKs<;iNK{lv1w7wkj>C`3y~4fVBIvBXX6a z86JGi*1FnFb;j7J&5n$&+L!`uJ*eN#Nc)w*2Z1oYGLJXd8$|ap?wFZG-|g{Y5%VY>g<2(_FNdsy60KXpJMvaw3CGGkMg{26Tk50J=}d*+4Kg(ifN0X(<{!f&Ky{#X_KO%;?udXiaO`(LTbx zs_omgrUmZ2Q4ODnFy$=RE+_oEyDM_`v+mAwf^iA99E%kQt!Uvw_c;owUombL;T8f% z&zl9rx`p=hk@kP}KnweQds{G}S(;gHu^#}VGAzgfx)RzuJFr2EG<166)&6p%r?>M; zk5dQqkwXu}z}C^-CA4>Ubs?ULe$oyco-pmhCSJR`uS9xG#F9(hoJLp{n(O#5F?*sd c5HRGbmO$W>Pn()PZ3=(zKgN`y``wj)E=!@)*LA(EyGutxzFv$*M_XG{cWXyGylQXvHRG3J(eXVbsZ)W=^r?b)NwB_44T{U@n*V|kF zc-;rQ`MTmUe(dldbeY^h~-=Ff7^cNkRK|^PenFO`;U+ZYWd@3&; zoO;pL-r;Y$-reE<*QPcYA583@eVrW)lP*^Qt@e&?Uzbdbyr$IXWjY91ue$?1VV;9U zFc^#vMtQOT;^f-*!N_1V#{2oZfj)nXkHJojbW>+8ZBz>^fh(+ z1A%L>ZXCFc++EkSx#Jpc!`Y6HcXDI1lvv+j zI35}>NzThsTYsjf1_I{&Hv-{UC4`de!opSop&Oxa*xwTl^xrN`NW^!-tEJ z^DxwM&kj-gt@t4WTw2kK8qPcDH$)>DPw<16K1O2fdLFGezeSB{y5a+`~WoSBeG{tw5 zX!?V&bSSQ;#jPj8T9VQagu`%yC?5~p<|`lQ=&+R9%Z*KgRAikE+?9#>qa52g-0tcN3O(g@qla8|Y3iALip_b;l{IK5eY! zn!T5%RXHAB=AN$S2lH)b>$RrNKXKs1I6JnP7x7iW2h{Oup?4+SH=U`9jAtX-Ae05wih>QFpLNo$+7 z0^e7}dFfmtry9$S&CxQEk0)EWP{=&-wiSdGI?zG}SicAeKE${vS@j2M4=)iL95ca@ZD z9QX^%d=bLb(PHwN9C5Z*3NUiqn_0|Pkx@O zrQG0XAY-n9>t0Ls*#(50Cp6P8xa88#NP|^#?E;l^t8-$d?p0=`eC=8zb-kUjh809S zec(f*A;8KeqMdx(^AYLf$75lXBaA1|`xn|+r$5jKi1AJ!ibra9JDnFfHFrQbDDa=0 zf&Vm!r+ys~7M`NHX>nsm0XMN=G&F!C1wcdm-FW4nUB{y+n5{0hX#1|P8Qxse4jxtCYgK1LjP45yA*T*^o@3A?PzOJnz$#2KCwtN z?vF%!`KT5oYD0>dM<_ju|IdCZiC9YnBot?w#IJ?$6vuyyyCuSfV?+tZ4i1bFGDW_< zD)cP@kD(Dee|B~@i$P;`nO6skF>futHXr7#sjIoE#a9+6*INOU$vQk0h+JUHjo>k3 zGR;6^rnxCt%(Pb_aV_{cn|L@QOpfE9zfMBja0wRU<$>NC;2Yupl~_Cw4+XCb4um6t z-q;mca93Pcu;X+#xINBVr`uKQcQ*t)L1)k_cO^evwGFNY4|r19m*$G{jO zk>Fjk19y2nPM62u5bSXWdfYWiU;4{cS8qIWfC+Vt9%rMg#%6rA99UrFU=dI-fe+3J zET{wxoRDKD3;)C%JE6`x_8>_6W+XaPrMoruLNYVS2jF#OzX?h|7-OfciksPbqk$W7 zTjBl0iH|~JSY|JBhhzi;H~DIzOA$xAGn{+=3N0Us<6{N++FMwqz;p6JxPKVpPWQ!< z^Q2^JcV^*L(QK_HGpD`1oZj6U7t|?stFSb^l9TP z+4c~Kk5c>D%+KiQrto+=d$0m*iu&^(DmMZ7RLaXFREOp2LlEZ#?(()KIpCvCqKGY|CQrR{-R zG=r5N^EsasUaU~@V(nNQ!?K;7L(^lF8dY-e>Oi(%UVKy0L{ z8eOtb+;r%_lRs3QOi!wQvO|Mf5DzCVE<|n++Czj8S z(OO`L3|Uy2MiXlwQDI_U+8#&iBOooTzhtoVT~JS0)apuck4lmV>VYIp`9nHZNRlex$z3jn! zF7Zq{8iV#AKIoD?cq|<)OKVe<8L&ZU{|MV@G7*$dU>FdL_->Mt6@DEihat{NzV!}U zZgCT(7b$;YYK@AOa!KEkEe&}KtMKxx+|nvAA*Bw1IjX1hUA=tIQj`tA#`HUIs=~*l zu>A<@?#QaJ`dFCRK*^7JQIb_GtfaxaVmm~;yc|Pg4=@{~FNg_12v*j$6c}5o?kF`5 zP7BQ?rHv7wc9ZYtqk-@#GwE_F9){*+fG21y+gYM_^g1;;>e>{3Sq0@SFgfxn&ftYi z&FdNQgi}wTM2C~`6DAiV2a5_64PHxA_X{LP-x8Pi(C7!YmntnPAu%&c7w{SJ1ONzP z5cF^feR_jN-$=t#XmwvoZQ0oCN2A%J6h*w)P)v=Yxp&#m!x+;ojG?Ioy!;51qxcEy zCjklS#GD0lW$|#;sW!657v*Dv;dse>o2K0vC*3q-F`s!O-AbG_B*#mXF!nEle2k=H zig$p}0PvL>F2{fYdX%hW3It#^_E^jzF$fHUk!X|;#*NP-6=oH-Uc=&(vl#RUohDaQ zj5Bn&2(U;#mqw4g;Xu1oa1W!zrwVC;N!)mJ@&87MuL|0(%3Rw(BV4;i*18=bR@sW| z2B|mE>;>VgC3b?!-^X2eAI&&5_fp=eWap`>%mL9T5;m`#Z0103pET(Mttlv(L(n$W zB@)bZvc%e!HM8f!zE)ybiRmkDC#l6#^MjS0`6dFeV$Y;V3Z0rpX5U0zWX$pFBKdD( z|23z;Bk@4kZ)B8i>%)+O@Ts-VieB1?^bPzT{SDmIPhVdbH z$49Dn;!-g=ZEx%f z`iNCkJ!o%WsHFSMy&92HYbb?t+GzbJ_E9KMlh7UB*bSC>0LuI}cy2AtZib)rZuWU~ zV2n8IjBHMMXV_bnXJ8la$$AF8oz*x9tw}5CC$vpV?-Og=vYW*u-J4rG%q^zaNx*sE z%nV9C7t}V3UAAvN+bHiKC+HTlynRJ>p=gpA@8uC{>@Cy&Vi>FL3~%xR2r?WikVrmabp1+>Q*k@ZH3vPI-zn>@GcBp z-jSVsh~Ceu41kKD^jmo=klpJgscTh@3aDAYg1N<4HTt^UANeuN_ul0F0}*g7B@vje z?HQvm-FS}uph%IN!KpngLbXY}w|`RjO}zmN;RXUxnBp>%ASOSmos~&o9!oa_Kj{5E zFimvnD7T8=I+^30>fn@>T?N+qkq_Ox70+i!JZyHv=DO_Ebbo$7H#V(;v?%|V!BEi? ziw4k;?2;8i)PfVKHO132GDam^h!nJG&!wIN-1yrmNYhyqW+~d4b!jn_Elivg8c?er z!7+C~UCxH#UvobLdw<+#e`>1&?RlGXqKMttH)~M4eo9IrcYSk{$sT>js*#8C6pOzS z2vWS$8Do*+12YdwR2n6AY{>Iwis>u3SE-GmLKlAgY(yaBr{^;&a4k)(_%u@T{2b6f zXU>AHLV``WUYoG3WA(#fBz&zO(5REPx3-~?8rh4|XAOE$tuNw9G-ELEC7Or6O*)k_ zW6*gQBfhM1Gga*okfoiE1V%qd!%t4u!IUw<8c$Qgz?F14CHk54JtZhq?Suv*v5>L_ zUG6zZUE4EuIlA$f_cUtP;vR!$bdGtHKyI$B5^$`2Uwx@4CD778RY(d4*lA#OC6^e7 zUrHUcXebg5#fM7xy6opAG@4YOulucb*NN2bD)0UVLl6yVHJijljK(?`b#xfVL1s#M z6|SEWWxVuF4c!|%NUcv%-icLqhvRN%my3Vg4OO`+Y8&LOwo~v^##UP6p7h4Bq*u~6 zFTIVSZ_gG5t99h960SH>#)t^(;kwRig?idK{!?5<^m7 zgsDQ3HJGHEC0SD~NnFy&W$8@PVqaFyWF1iSS~`*SK%&;td2GP*=wwOiOIKA$k`(jO zio3RInl49;#qySGcUdWLmxq+Ovr^(NOweno$X#Bb(O|8}Yq5AN7I~e+dkm42x+or( zc2i9uSx-s@S}O7eQeDnUk-MEhvcXc3yKzN2t$eDMx!diiv)Y+?fTY$MRV^TQ4W{X~ zRzmfp+$2NNX{3Z+L)G*ekTj1xtP5iM_`V)|a?(r?tF%2YL98;K6AWT2Iy|pE*k)nL z9kjAZhn3yi5}%E*2i$&$g!;{A8uJtAHm_a?;jh8DJ3`}`a|ByTGmDf{?b>~<&|E>~S} z4;|fCPVq8l0#Bg$p*%pCmgKwRR}?(!69@mV55s9cWu=#=yE47(vl^yOz^O(`eoom= zO;Ax>S`_jYIbo&)yv&|I`KULT{nJ3Ir%@=dz&#m)Q?sblG#u4F1EM|kZ;SFbi3cX7 zmZJD<{qCx?`x>o3L#dCniW3f)nHo=OsdkUYr%O=9s#l-F^=R-s~9z+*P5MMXI>9(IiH4@IID4BMl94hL88 z;sb;6A%74=H5iTuNV%)*URth#HanL+Tvn^X$rYRELl1b|KMRQsG_`=MXRRfH87Z}9 z%m7$4J}Q3(m<4R;ANIf%yMTN!5cV5!L>a+-78aEWz)y8BypRWo2qRRbPFNCN?Xy97 zNyKmQ(ZE0`R$dVD<3glpnXs}=>tLPA{MY6Ozm8tN%MZREr#SJ$Hq*0c;wL=cPgdpP za?Gf)v3~hft6(;InnB&uEc1M_bgAPZDJqq#!4bs_{tq~Of zY4f+laPHNJn0~8TBT6Bgp2U^aBx=3n=*rSF9Z0z;e+R`a6Z0H{n~V4#n~XAxeZ|`9^0P zim@ezj`={7xlg`P3$R#uP^+N62K#X?@lYHm)sGii6}y4(atvN5fg)%oE+tcC^hPP8 z2cuxqDCNk2iGm`Daa`4w=_MB%Q;U^Z#W^iZ##S~1BohY2Va>y0gs!ylT6i(1sTy-5 z90{1OoDw3U5EA!<5*x*n#uc!Tq zL)ArwRB=x>CM@uinwXIBN+n}Lrd{cnkZH*7P1rP_J#ACV%0C++pR>Dw-rs{okX@jnhu(^6bnHEQ{l zLCa^U?B1l984)vMy7tZb_IPJ^cN2!IYuo~j{}VWC!r#ZBw0|V6y%BbHO$lRnLmJzq#T?8C1I=V|PhmFSi|PHM^7smW ztMXun1XIsW2O9k(tc;`2X|%q9r-rtatDIXP54;0nEBM%S97gSo_@rQ|tt3Ee#qoWVd`CZf z00iW9C{W_~A|j^(^PfeJlL|Y#XmmXHD2Yq{&m{qHgO1>t+ubD9N^n$VEEfvr%*Z15 z!z|2|p&5 zY(Moc+}%AZX=TaMI+lSuQyv&gI=fn(-92A>KIYKa3cKTIwU|o?n?2@W@Yv2qM()dU z#@>m+K{*-~`jL=&6KkV)A86Uf#qCuzCujnZzAvCZ2w+D7G#UrqO?!Woaydun_u~ui z#~(SulME^^)y$mw_O<$Ct~8e|KKKR8rMr_*l;QpOU40r_9XoHWwO>F|-An2e_Ta0e z!ZiIkF-)OBA!dLJ&99MKBA!%O9e<}TY_jDVcorEgJA!u3_8UyyNG%e+o&Q^Td<6i32`Lyon|cjv+buBs(NL4O_p^ zHJizx?SZ74(z|zPBjJqvXyypM?3nUphR%F)x`HNKs#*}MmpcC|@C3b18D89Q-f{@U z**+dR7-BR~U;Lo0?x?fhLFOdH(6z;@1uz(;J2vk!>hDu=-Z2QP_A$3g%c6{Bft9+Hh5P;6{)Kx%W~RwXQ$iXusI0%|BK%FEIlrZ6SZK-EXQ5`%P8dpbq1! zd!wc#kDTcMaKsxD265zGN^oe~a4#kDnud8Pk=`w7T@iBqsP10H&4s*=Jrbzd6sjHI zgDc(VNQ`*aBZY%6#z%%X?HLUA*m%|)p%at0Q;*#phr{KEnZ6KnQKL@~vtmQc^8GN| zFrh6~?83|lxw4h(4nr>Q;7)y|)4UI~JfE)5CkbfKA^0RQ49LRm>F2w0cnagr)hQ60 zhZc_RU*AOx_(FSk`!Q=Q+{7PpbW4xU7%^?T~{WA*WDMX|YvgeETq72=vd z>@1v(0S~IP-xqS5@Di8-tV8@$=(!la+E$3YC>7ahcXD;g0hH*^iywUz%tnIc-^JJj>&g+Ul;~ zqiO@g&e9yVC*PlkQC;e5@9MmAO+f4QtIm#arOOHN=K=WQvq#0{pRudYw|k|7XMT_F z6kE*=5b^tbF^|*cc89MD;R_eLdai}TECvg^PtbHy2c9Yw;*zO!WK0-JDCv>92kAit z>KBC*q8!IgG)V2j=fmz|u?_gBMP%E6_{HTUYT_PMS=j%flwEcXl=R7W1Q)DC%!Y}L zY>0~ryoQxW>+~S0_6pW1WE7hYg>)j?n1r~c?5pz1AKG{K3;93$E_bo-)C(ZEip$Wv zvJf3zNN7Q&M9j6_qXXcZGn2Y%3z)ql>Fbd!E@Lj++I;)?4)hXu`>~PRJ=`VO?G|D; zpA`&tSqOHUU2Lt~?^Q%twtK65sdqpj4lC&$@CLfu8!K8m6LhPXX(yZRBBvdmGpC(H zOaE9_LK;yL?s-&gu*ZdA>Eme}zp5zU#GzbXStg>DBj)E6P}AValLM-{+`u$84yN|&Xeh(A>$CC z&fcMG5+bps+cQ$49K}>RE=I?aa{ccpW!H-NDX3>pWsZax2V-0P=*jM(H3${J%&wB*q$3jE;WI z^c2z+X?;)`hm;M^ z>AHl*g`aB=u#bjbOWuq$_&cWjxMip#=$JfGNOA>-rq) z1gTAC)TuYb8xTY4UVnI~_)I0ctSw~^c?wz)#+YzpJ+e5+#(%>j?3qJ2qV{H@xbYAm zNnq>)HM0R;uFk15o6!X7mi_^|c z=+R6ghs{kLd=%bw<%>&v*MX0X0B@>}ydr!}HZ6j0x1^bbcABLe?%?3a_^6afiblH4 zM%nJcOu}S7N+5Ar#`;N32{HG_)%Az0#3aQd)%vM4w>;67SJ2oo6C4@}G-kDwl#&u_w0IhpN=E-kNI2gHhxUO{{^L?9yM~y? z-}0?pzID$CiUnX}Lx;@G%_Ac_TE2AhQ_b{cyr7EVOE*6pp4khQ@L|cu7T!`tIyNXc zr+jR{*J}APTh`4z$=dSt0A`?$m|rp@0Y+I96C{p;E+b297kp-sRjX-(qu-G{#q#gJfOhKgmj>QuZNa6RzBu z7NWy$16OLp0=g^WETkneA3R{KkqSm$S(^Gc2nkfH1Cf#}UOlzq0BA2DQux8^btLxG z3+NP2Pp5c#I!z+CJ%KoSI>Xb`861=74#cw_d|@7*eB)TKj9S$y>*- zG_Ss0WX&iBT|lUd4{@G+k*wVh;J62Vkk991T$+be}ZyOZlIU)MP_M9?|5dGoh zjp`X`)!5`%uwxD*M2V4fY;rh{2+k#aTMxgI=@BU*Mq+Y2$Xv*oPeLqn%@T)Cp@)B4c_C}7bvDA^GOIiD@C zJ1+}wm-w*+XG~TsIYf)M1o_a$HeiFfXv%7#Uy^U2r5`lzob10*4T~-HW06L$S#yEg z(Z~k5(a14<@#sC|i|dcn7wZUzBqC{B&CQ^jaB1Y@_F;*&nz%}0ZZ<5+46>5L)sWR% zKeA{PofTP5tBwt~t3=m}Iqmy3KosD|zT|wySlcH-GXb{%G|IezuB)M}%?D!k=AO3m zx+2fH6=gI95u49^clxjW**0}=yO95eWMrR5GLbm}6ED@=PBr8i`&Ae1{6Ha6^hr8x ziaqk%1#zc*AaB;3-d2=pvlB={WI=F(EJ&M9KEHHuzxaF;`zfq_SDt)Zu{&w9a`M|x zEa3z|dd75$DdXA&Tc`cQ7ERw?eSrZ8!j|?VQ(inMFaK1Y+yH!-v~2D-oGN_+7XvuF zC3eP|^PJ*uFrm0^(K9}<0BhoPCwj)_T}Ofjn|IyB+Tv7cd$$s6i&N^%a|6;pe0fND zoHjKnHlifN#8_ZdmcFhqu8~{UN8tpJrllQ8C_@P;>1u4N(t?`L(6j|eyRSOKS2>#D z!Pji9tKC#*jE&mt$mpt#DbU`F`t1sLTnT&>XvSCO@dkT?=sw0BGqZ2yEi0UKTa3}P z&c;1)bcmjb8@Kv#(k8g4@la3XRelaNpf;UBFM3SSj7!Q)kwl+y9 zX{32de2u=4<6`u~me>cc8KYs<#lvauwf6zO1xc4abcm{*C{gSk5tjxqu`@eSqEYHT zZ9cU1;W%8{2W}p3_G-g^fJ+;v(OB9D$*-k|oE*l=u+t`L@A|R_X{6^bobBsI`ZZF& z&mlMp*4z?+dza55ON?Xw_A28xoes#tQ+?F0MmrowdB6pz_NMCeho!%NL3uaRe~-~*mgA9$szI-w(g0_ z?Hv^LUx1`o2=tE`{hA1^X)Qb2N4QtD zeY@7Qz@0a$;SrWGLdW@V$G>`^h5f$0Ett?O%`CUr4}eh_7Gwck2_0RX*q}ukIz91fe>vRS*L9`W zsRR1Rp$B4M>+I(~e}d!j86 ZFyyM%K;Sb{BDC$J{{fl0X1mwi004t)hS2~3 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_180001.sql.gz b/backend/backups/kaopeilian_backup_20250924_180001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..e0b2842a22c0837a67700ec7dca183dde1ffb38d GIT binary patch literal 10054 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 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_180200.sql.gz b/backend/backups/kaopeilian_backup_20250924_180200.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..a0d04c378a420528aa74cbcbcfc8c8b7ca008f73 GIT binary patch literal 10053 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 a%T+C*=!q|yn!ad?e)2z}-f#lf+yDStP<{Xa literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_200050.sql.gz b/backend/backups/kaopeilian_backup_20250924_200050.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..e2a51ce0da26e5a2261559d624366a57abf1fb90 GIT binary patch literal 10052 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 a%T+C*=!q|yn!adl`s9BIEubCO+yDT0A$`UG literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250924_210001.sql.gz b/backend/backups/kaopeilian_backup_20250924_210001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..85d648c45a7f9fb05f52422c6c676db4cc5b6fe3 GIT binary patch literal 10052 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 aV8~T1fp9A>5qj#=kNyX3posq0+yDT!Sb~xO literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250925_000001.sql.gz b/backend/backups/kaopeilian_backup_20250925_000001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..bb7c593fdd8e012e5a1ce59fa35a6b41b4d04bc9 GIT binary patch literal 10053 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 bx?I%~ine{$)bv@?sgM2#n}Zjv*W3UA1gmS> literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250925_000321.sql.gz b/backend/backups/kaopeilian_backup_20250925_000321.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..6ff377afe9cfa012169ac69543d7298656077df2 GIT binary patch literal 10053 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 bx?I%~ine{$)co10=tutp=6)W+*W3UAIcaQL literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250925_010001.sql.gz b/backend/backups/kaopeilian_backup_20250925_010001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..07d2cbf531e83db175f778b3c82f3e4cc1d4df4c GIT binary patch literal 10054 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 b1Pr;VB@jOS&!#3MT7B?8*3}f5*W3UAR+65x literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250925_020001.sql.gz b/backend/backups/kaopeilian_backup_20250925_020001.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..ab0a205d3ad7305c9bf8c60866eac0246c0ef0db GIT binary patch literal 10061 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 literal 0 HcmV?d00001 diff --git a/backend/backups/kaopeilian_backup_20250925_020104.sql.gz b/backend/backups/kaopeilian_backup_20250925_020104.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..e2a91304fe1d7497daf89359330dd588ed435928 GIT binary patch literal 10058 zcmV-QC$-ogiwFqeG1O=P18ZS#aAj$1X<=?(Vqs%zb#PxYFfuhTIWje0FfuSPFf=Z6 zacltWUF%aDNs|A3{ww``wj)E=!@)*LA(EyGutxzFv$*M_XG{cWXyGylQXvHRG3J(eXVbsZ)W=^r?b)NwB_44T{U@n*V|kF zc-;rQ`MTmUe(dldbeY^h~-=Ff7^cNkRK|^PenFO`;U+ZYWd@3&; zoO;pL-r;Y$-reE<*QPcYA583@eVrW)lP*^Qt@e&?Uzbdbyr$IXWjY91ue$?1VV;9U zFc^#vMtQOT;^f-*!N_1V#{2oZfj)nXkHJojbW>+8ZBz>^fh(+ z1A%L>ZXCFc++EkSx#Jpc!`Y6HcXDI1lvv+j zI35}>NzThsTYsjf1_I{&Hv-{UC4`de!opSop&Oxa*xwTl^xrN`NW^!-tEJ z^DxwM&kj-gt@t4WTw2kK8qPcDH$)>DPw<16K1O2fdLFGezeSB{y5a+`~WoSBeG{tw5 zX!?V&bSSQ;#jPj8T9VQagu`%yC?5~p<|`lQ=&+R9%Z*KgRAikE+?9#>qa52g-0tcN3O(g@qla8|Y3iALip_b;l{IK5eY! zn!T5%RXHAB=AN$S2lH)b>$RrNKXKs1I6JnP7x7iW2h{Oup?4+SH=U`9jAtX-Ae05wih>QFpLNo$+7 z0^e7}dFfmtry9$S&CxQEk0)EWP{=&-wiSdGI?zG}SicAeKE${vS@j2M4=)iL95ca@ZD z9QX^%d=bLb(PHwN9C5Z*3NUiqn_0|Pkx@O zrQG0XAY-n9>t0Ls*#(50Cp6P8xa88#NP|^#?E;l^t8-$d?p0=`eC=8zb-kUjh809S zec(f*A;8KeqMdx(^AYLf$75lXBaA1|`xn|+r$5jKi1AJ!ibra9JDnFfHFrQbDDa=0 zf&Vm!r+ys~7M`NHX>nsm0XMN=G&F!C1wcdm-FW4nUB{y+n5{0hX#1|P8Qxse4jxtCYgK1LjP45yA*T*^o@3A?PzOJnz$#2KCwtN z?vF%!`KT5oYD0>dM<_ju|IdCZiC9YnBot?w#IJ?$6vuyyyCuSfV?+tZ4i1bFGDW_< zD)cP@kD(Dee|B~@i$P;`nO6skF>futHXr7#sjIoE#a9+6*INOU$vQk0h+JUHjo>k3 zGR;6^rnxCt%(Pb_aV_{cn|L@QOpfE9zfMBja0wRU<$>NC;2Yupl~_Cw4+XCb4um6t z-q;mca93Pcu;X+#xINBVr`uKQcQ*t)L1)k_cO^evwGFNY4|r19m*$G{jO zk>Fjk19y2nPM62u5bSXWdfYWiU;4{cS8qIWfC+Vt9%rMg#%6rA99UrFU=dI-fe+3J zET{wxoRDKD3;)C%JE6`x_8>_6W+XaPrMoruLNYVS2jF#OzX?h|7-OfciksPbqk$W7 zTjBl0iH|~JSY|JBhhzi;H~DIzOA$xAGn{+=3N0Us<6{N++FMwqz;p6JxPKVpPWQ!< z^Q2^JcV^*L(QK_HGpD`1oZj6U7t|?stFSb^l9TP z+4c~Kk5c>D%+KiQrto+=d$0m*iu&^(DmMZ7RLaXFREOp2LlEZ#?(()KIpCvCqKGY|CQrR{-R zG=r5N^EsasUaU~@V(nNQ!?K;7L(^lF8dY-e>Oi(%UVKy0L{ z8eOtb+;r%_lRs3QOi!wQvO|Mf5DzCVE<|n++Czj8S z(OO`L3|Uy2MiXlwQDI_U+8#&iBOooTzhtoVT~JS0)apuck4lmV>VYIp`9nHZNRlex$z3jn! zF7Zq{8iV#AKIoD?cq|<)OKVe<8L&ZU{|MV@G7*$dU>FdL_->Mt6@DEihat{NzV!}U zZgCT(7b$;YYK@AOa!KEkEe&}KtMKxx+|nvAA*Bw1IjX1hUA=tIQj`tA#`HUIs=~*l zu>A<@?#QaJ`dFCRK*^7JQIb_GtfaxaVmm~;yc|Pg4=@{~FNg_12v*j$6c}5o?kF`5 zP7BQ?rHv7wc9ZYtqk-@#GwE_F9){*+fG21y+gYM_^g1;;>e>{3Sq0@SFgfxn&ftYi z&FdNQgi}wTM2C~`6DAiV2a5_64PHxA_X{LP-x8Pi(C7!YmntnPAu%&c7w{SJ1ONzP z5cF^feR_jN-$=t#XmwvoZQ0oCN2A%J6h*w)P)v=Yxp&#m!x+;ojG?Ioy!;51qxcEy zCjklS#GD0lW$|#;sW!657v*Dv;dse>o2K0vC*3q-F`s!O-AbG_B*#mXF!nEle2k=H zig$p}0PvL>F2{fYdX%hW3It#^_E^jzF$fHUk!X|;#*NP-6=oH-Uc=&(vl#RUohDaQ zj5Bn&2(U;#mqw4g;Xu1oa1W!zrwVC;N!)mJ@&87MuL|0(%3Rw(BV4;i*18=bR@sW| z2B|mE>;>VgC3b?!-^X2eAI&&5_fp=eWap`>%mL9T5;m`#Z0103pET(Mttlv(L(n$W zB@)bZvc%e!HM8f!zE)ybiRmkDC#l6#^MjS0`6dFeV$Y;V3Z0rpX5U0zWX$pFBKdD( z|23z;Bk@4kZ)B8i>%)+O@Ts-VieB1?^bPzT{SDmIPhVdbH z$49Dn;!-g=ZEx%f z`iNCkJ!o%WsHFSMy&92HYbb?t+GzbJ_E9KMlh7UB*bSC>0LuI}cy2AtZib)rZuWU~ zV2n8IjBHMMXV_bnXJ8la$$AF8oz*x9tw}5CC$vpV?-Og=vYW*u-J4rG%q^zaNx*sE z%nV9C7t}V3UAAvN+bHiKC+HTlynRJ>p=gpA@8uC{>@Cy&Vi>FL3~%xR2r?WikVrmabp1+>Q*k@ZH3vPI-zn>@GcBp z-jSVsh~Ceu41kKD^jmo=klpJgscTh@3aDAYg1N<4HTt^UANeuN_ul0F0}*g7B@vje z?HQvm-FS}uph%IN!KpngLbXY}w|`RjO}zmN;RXUxnBp>%ASOSmos~&o9!oa_Kj{5E zFimvnD7T8=I+^30>fn@>T?N+qkq_Ox70+i!JZyHv=DO_Ebbo$7H#V(;v?%|V!BEi? ziw4k;?2;8i)PfVKHO132GDam^h!nJG&!wIN-1yrmNYhyqW+~d4b!jn_Elivg8c?er z!7+C~UCxH#UvobLdw<+#e`>1&?RlGXqKMttH)~M4eo9IrcYSk{$sT>js*#8C6pOzS z2vWS$8Do*+12YdwR2n6AY{>Iwis>u3SE-GmLKlAgY(yaBr{^;&a4k)(_%u@T{2b6f zXU>AHLV``WUYoG3WA(#fBz&zO(5REPx3-~?8rh4|XAOE$tuNw9G-ELEC7Or6O*)k_ zW6*gQBfhM1Gga*okfoiE1V%qd!%t4u!IUw<8c$Qgz?F14CHk54JtZhq?Suv*v5>L_ zUG6zZUE4EuIlA$f_cUtP;vR!$bdGtHKyI$B5^$`2Uwx@4CD778RY(d4*lA#OC6^e7 zUrHUcXebg5#fM7xy6opAG@4YOulucb*NN2bD)0UVLl6yVHJijljK(?`b#xfVL1s#M z6|SEWWxVuF4c!|%NUcv%-icLqhvRN%my3Vg4OO`+Y8&LOwo~v^##UP6p7h4Bq*u~6 zFTIVSZ_gG5t99h960SH>#)t^(;kwRig?idK{!?5<^m7 zgsDQ3HJGHEC0SD~NnFy&W$8@PVqaFyWF1iSS~`*SK%&;td2GP*=wwOiOIKA$k`(jO zio3RInl49;#qySGcUdWLmxq+Ovr^(NOweno$X#Bb(O|8}Yq5AN7I~e+dkm42x+or( zc2i9uSx-s@S}O7eQeDnUk-MEhvcXc3yKzN2t$eDMx!diiv)Y+?fTY$MRV^TQ4W{X~ zRzmfp+$2NNX{3Z+L)G*ekTj1xtP5iM_`V)|a?(r?tF%2YL98;K6AWT2Iy|pE*k)nL z9kjAZhn3yi5}%E*2i$&$g!;{A8uJtAHm_a?;jh8DJ3`}`a|ByTGmDf{?b>~<&|E>~S} z4;|fCPVq8l0#Bg$p*%pCmgKwRR}?(!69@mV55s9cWu=#=yE47(vl^yOz^O(`eoom= zO;Ax>S`_jYIbo&)yv&|I`KULT{nJ3Ir%@=dz&#m)Q?sblG#u4F1EM|kZ;SFbi3cX7 zmZJD<{qCx?`x>o3L#dCniW3f)nHo=OsdkUYr%O=9s#l-F^=R-s~9z+*P5MMXI>9(IiH4@IID4BMl94hL88 z;sb;6A%74=H5iTuNV%)*URth#HanL+Tvn^X$rYRELl1b|KMRQsG_`=MXRRfH87Z}9 z%m7$4J}Q3(m<4R;ANIf%yMTN!5cV5!L>a+-78aEWz)y8BypRWo2qRRbPFNCN?Xy97 zNyKmQ(ZE0`R$dVD<3glpnXs}=>tLPA{MY6Ozm8tN%MZREr#SJ$Hq*0c;wL=cPgdpP za?Gf)v3~hft6(;InnB&uEc1M_bgAPZDJqq#!4bs_{tq~Of zY4f+laPHNJn0~8TBT6Bgp2U^aBx=3n=*rSF9Z0z;e+R`a6Z0H{n~V4#n~XAxeZ|`9^0P zim@ezj`={7xlg`P3$R#uP^+N62K#X?@lYHm)sGii6}y4(atvN5fg)%oE+tcC^hPP8 z2cuxqDCNk2iGm`Daa`4w=_MB%Q;U^Z#W^iZ##S~1BohY2Va>y0gs!ylT6i(1sTy-5 z90{1OoDw3U5EA!<5*x*n#uc!Tq zL)ArwRB=x>CM@uinwXIBN+n}Lrd{cnkZH*7P1rP_J#ACV%0C++pR>Dw-rs{okX@jnhu(^6bnHEQ{l zLCa^U?B1l984)vMy7tZb_IPJ^cN2!IYuo~j{}VWC!r#ZBw0|V6y%BbHO$lRnLmJzq#T?8C1I=V|PhmFSi|PHM^7smW ztMXun1XIsW2O9k(tc;`2X|%q9r-rtatDIXP54;0nEBM%S97gSo_@rQ|tt3Ee#qoWVd`CZf z00iW9C{W_~A|j^(^PfeJlL|Y#XmmXHD2Yq{&m{qHgO1>t+ubD9N^n$VEEfvr%*Z15 zB>b3I zvi;P*aCY}wNh|B>SO(5ad0;H*+tupZ-S4*FWgd;KxHo}Ti@t=g*<&6CkL_$^(Lx;F(y8Qx9YQKzBBvh$W&`voM`y`a#VG0cjF#}v^ex1}3aizlY#9MiBi!ImSWs%XcBWUOByvEdx)FSb=^M9*MtU@6O zPel3EQt3G@_X2@$9$BUNyEL{*v$zm^k$f~zGscHiOk5*P(?g6rY>@~|fVQ^K-cnw^ z@F1vc{9L&=D}S>pKV9=OH|2>%L|@_iy-W#M3K0{NCl9?*;w?0Ja17~@PqIV8)3E&u zU9;IN+8#)%DZhP-HWJRrk7kbG%Zw>sW~lw6GgUO%P}PE1z2t>ofhVYS%JAZb^OhqZ z&gSvZ!4RW?{QL)HZC9TA4l*YphOR6&2jrTFi>0R%!w7tesjkU`{U2i9_R+r_+el z(UP}#bJ1G1t*y8!Ja8k(gWPwin_AbOY<67iQWhS`*%z3BleUmQ*z7meiv6Z+H>kz< z>fWen!6j!p037p%gh3p;ml7P>Hrz{zyryAZN~Cu~T33WzKdQUixVez`u}1HrP93jv^<}w&nF3J&=L3~F$~DS?dj(`a(D{k%+)Cn zn}-&T?qA(O4ERDvPsbMoWBPG_c1vE^Q{HTvf5TsDLIf4He?T z?{^l?#()Rqx$lelEqDpc0M;S?$@N~0T<*Big%*@PNCGM^|1Qtv!R%z|E#>zho3TgA ztnzxdYLI>Btp*98M?T1wkAmP21BJ>eQ@Bj)l}P7#j_k*-o-cGXlAN|IG@j*&DP?WX z?@_gaVP|FLAL%Fo!<=iB}A;Zwgy z*Tz1HiH>u_Nhvd0_aI%U zK>ea{k{1)$i3X`%_|Vh-g^Xd-A(u(U8j}#WoO@YW{X_ZgUa|0J|K)D>ooWFDM{yaN zR~MqA3kfZ#G>^Hqb94ZFb9PEqZ2_~l1$8}=#ih+$+A zBJ>Qp)R-%*mbaf780p^;Wk&OoM;2se$j5Qn8h`Bc| zs)5(aM74(g@zB(uknpJVQP7jZLFsUgyDFW$oL8RY8dc8(dS?$V1`(kdmXhO*DMm-X zW@Z}cinKnch(pST#9>G3-O*La&X?Y7t7&MZ_-L9-3-Qqy8gBhuabdCaJg;m%Q&tXX z7yv#)h9Y+e*CcPUOS!ze#e?6KwR`eCy}WHhqs<8-*=TcITQ)Ng69+vz!)LeTg9&PU zpy|4}#)Y3N_py(LT1#G!H26EF{G@2mmKA9Acm$0wgmfZdIvrA%V2D|dUaPOToxapp zY=jzM$-X%v*3L3NYtF7R8E=eTg?2fcsRm~w#aS;%MDHHpa|ZZPT3(g6f7Co|X$UVt z*Ix&7rNUHke~ont!~wRu8=7aOAxxGrNh8Y9^<_k}Q@e&Zx>f}QPc!OUdGIY781}J$ zG=nsQ;@-TxILEp(LXa%mgnZzS%FKWCwxp_|`Z;pdd$OfodBt}0)i#CH|=#0c=Z>gZL3ugRuG@a>c|lh97Rl*4Tt9GMssk||zG zx7jG$J(x+D%tuKiE{j+{sVO1m!Gyf=fR&hpMAWXIYIDnDWo4CZs#T3n2?z~Q$HLLd z++?Zn(sX#zV3`{1n}!Td)IQ-sLxIMumXemze2o@Q%~HweA4vh{+wh=$V3hy3T+Xc{ zrt!CYYnE@FGlF6PSliGceRF$}ku5D>D*3T9eQ7VSF?^}!hodul!4f_y+1S8aY@}n2 zf_s#YHTYUBUwX^BxhGj$t{%V))G_l*W+cEUYhr@LQP6=08hk%}$OUc1W;dy0UBmJ~ z8wrb^cv$^-oknQr2pJ!;6hcS14QcR@=GVQPTlHH^E|S{#>T-~*tNbTfNmR-{f^5Ro zJJUdP*lplQZCF5OMVy5+MCOABtT9qS%PUJ${{|s}syz@X$>P>i3l4yG10sbV++Igw zPu+mpy!6!OrKd9_a@!TiLr?8qdTPfpiOxV?)`Kt1!&n_Q>Ym8okdlpg4cLzT z_jT3uc_PdUtU?duft3jB3X?*O$AqLfBzixps{A@v&QW7Rj8CPxm^3UVyqs3yY&DDD zl%5piH%qJ;MWYJ{b%|k*CtqZ<7Z10UZ}(4B73UKji=}g|kp9~QMR`t${xChK3?oE; zxOt@`FRd!!h{BI@BflLG*X9FxGv@R*qD+~aL=qwkf)ivx+H~^y<->cWXIt1$Vg0+x z)SIf^Nt2b6-+p2VCje42rc+F5*ACb^;~%zY`gZ#T1|SGq%HwQh>9Df$Q)Oxs@S)SP z`QLD=^hsO{;OLfEyD{fE#otg;a@?ZrKCl2|;&m%}#^zl|f(Dy+UB}weba`j58f#0_ z^6WDW(m#B;NO>M@YC?QeO7h9^z?dj}U1eM&x2})D2_Q{NJDQY+lS0bT*k;p$n$OU* z1xULuyCPRTG{b|h*;-e-sZJXkwb_x;*^MdC(TDo&j&xoLd=LoZEB$zby+L#z?T((= zxB8Y5PPoO#XW*!wN9_g*tvL)*pU(cWwB1AGfo4t=N)ww*{Z>>ZI1 z1~IYIJCR~B>OQSMH1%OQT-pb2o(Q|OVL!m3jn!xjZMf9eLR3tRU}e~96LoZd(TgNP7BfCDfAa0DHZ|)<66Hu zLTg&fmi7_PtJ=I>Yg*ve8&&g(2vg32?Q-0|dwU{hKkMy2$7z>f%Q0Ai(2C|R^q!-T z>J{T=5pE%H^t_ovtefjRAL;y8AG9#vH@5{7nxUED7W)A(D$Rn-p)0Ply9*n%NJFb9 zZtX8e`ue-C^jUR4A6fK33~XJ!JzQsRPY>d$=qK&K;tA6}tmCz(_e!KsM=ZHi&1r;X gp}CHa60;}T0s+<*(T+=mHno26KRty=OxN510NGTPUjP6A literal 0 HcmV?d00001 diff --git a/backend/create_admin_users.py b/backend/create_admin_users.py new file mode 100644 index 0000000..bf25414 --- /dev/null +++ b/backend/create_admin_users.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +创建管理员用户脚本 +""" +import asyncio +import sys +import os + +# 添加项目根目录到Python路径 +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +async def create_admin_users(): + """创建管理员用户""" + try: + from app.config.database import AsyncSessionLocal + from app.models.user import User + from app.core.security import get_password_hash + from sqlalchemy import select + + print("🔧 创建管理员用户...") + + # 管理员用户配置 + admin_users = [ + { + "username": "admin", + "password": "Admin123!", + "email": "admin@kaopeilian.com", + "full_name": "系统管理员", + "role": "admin", + "is_superuser": True, + "is_active": True + }, + { + "username": "superadmin", + "password": "Superadmin123!", + "email": "superadmin@kaopeilian.com", + "full_name": "超级管理员", + "role": "admin", + "is_superuser": True, + "is_active": True + } + ] + + async with AsyncSessionLocal() as session: + for user_data in admin_users: + # 检查用户是否已存在 + result = await session.execute( + select(User).where(User.username == user_data["username"]) + ) + existing_user = result.scalar_one_or_none() + + if existing_user: + print(f"⚠️ 用户 '{user_data['username']}' 已存在,跳过创建") + continue + + # 创建新用户 + hashed_password = get_password_hash(user_data["password"]) + user = User( + username=user_data["username"], + email=user_data["email"], + hashed_password=hashed_password, + full_name=user_data["full_name"], + role=user_data["role"], + is_active=user_data["is_active"] + ) + + session.add(user) + await session.commit() + await session.refresh(user) + + print(f"✅ 管理员用户创建成功:") + print(f" 用户名: {user.username}") + print(f" 密码: {user_data['password']}") + print(f" 角色: {user.role}") + print(f" 邮箱: {user.email}") + print(" ---") + + print("\n🎉 管理员用户创建完成!") + print("\n📋 登录信息汇总:") + print("1. 超级管理员: superadmin / Superadmin123!") + print("2. 系统管理员: admin / Admin123!") + print("3. 测试学员: testuser / TestPass123!") + + except Exception as e: + print(f"❌ 创建管理员用户失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(create_admin_users()) diff --git a/backend/create_simple_users.py b/backend/create_simple_users.py new file mode 100644 index 0000000..88f28d5 --- /dev/null +++ b/backend/create_simple_users.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +为简化版API创建测试用户(使用SHA256加密) +""" +import mysql.connector +import hashlib +from datetime import datetime + +# 数据库配置 +DB_CONFIG = { + 'host': '127.0.0.1', + 'user': 'root', + 'password': '', + 'database': 'kaopeilian', + 'charset': 'utf8mb4', + 'autocommit': True +} + +# 要创建的系统账户 +SYSTEM_USERS = [ + { + "username": "superadmin", + "password": "Superadmin123!", + "email": "superadmin@kaopeilian.com", + "full_name": "超级管理员", + "role": "admin", + "is_superuser": 1, + }, + { + "username": "admin", + "password": "Admin123!", + "email": "admin@kaopeilian.com", + "full_name": "系统管理员", + "role": "admin", + "is_superuser": 0, + }, + { + "username": "testuser", + "password": "TestPass123!", + "email": "testuser@kaopeilian.com", + "full_name": "测试学员", + "role": "trainee", + "is_superuser": 0, + }, +] + +def hash_password(password: str) -> str: + """使用SHA256加密密码(与simple_main.py保持一致)""" + return hashlib.sha256(password.encode()).hexdigest() + +def create_simple_users(): + """创建测试用户""" + try: + conn = mysql.connector.connect(**DB_CONFIG) + cursor = conn.cursor() + + print("🔧 开始创建系统测试账户(SHA256版本)...\n") + + created_count = 0 + updated_count = 0 + + for user_data in SYSTEM_USERS: + username = user_data["username"] + password_hash = hash_password(user_data["password"]) + + # 检查用户是否存在 + cursor.execute("SELECT id FROM users WHERE username = %s", (username,)) + existing = cursor.fetchone() + + if existing: + # 更新现有用户 + cursor.execute(""" + UPDATE users + SET hashed_password = %s, + email = %s, + full_name = %s, + role = %s, + is_active = 1, + updated_at = NOW() + WHERE username = %s + """, ( + password_hash, + user_data["email"], + user_data["full_name"], + user_data["role"], + username + )) + updated_count += 1 + print(f"✓ 更新用户: {username} ({user_data['full_name']})") + else: + # 创建新用户 + cursor.execute(""" + INSERT INTO users ( + username, email, hashed_password, role, + is_active, full_name, + created_at, updated_at + ) + VALUES (%s, %s, %s, %s, 1, %s, NOW(), NOW()) + """, ( + username, + user_data["email"], + password_hash, + user_data["role"], + user_data["full_name"] + )) + created_count += 1 + print(f"✓ 创建用户: {username} ({user_data['full_name']})") + + conn.commit() + + # 打印总结 + print("\n" + "="*50) + print("✅ 系统用户创建/更新完成!") + print(f"新创建用户数: {created_count}") + print(f"更新用户数: {updated_count}") + print("\n系统账户信息:") + print("-"*50) + print("| 角色 | 用户名 | 密码 | 权限说明 |") + print("| ---------- | ---------- | -------------- | ---------------------------- |") + print("| 超级管理员 | superadmin | Superadmin123! | 系统最高权限,可管理所有功能 |") + print("| 系统管理员 | admin | Admin123! | 可管理除“系统管理”模块外的全部功能(管理员仪表盘、用户管理、岗位管理、系统日志) |") + print("| 测试学员 | testuser | TestPass123! | 可学习课程、参加考试和训练 |") + print("-"*50) + + except mysql.connector.Error as e: + print(f"❌ 数据库操作失败: {e}") + finally: + if 'cursor' in locals(): + cursor.close() + if 'conn' in locals(): + conn.close() + +if __name__ == "__main__": + create_simple_users() diff --git a/backend/create_system_accounts.py b/backend/create_system_accounts.py new file mode 100644 index 0000000..4678199 --- /dev/null +++ b/backend/create_system_accounts.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +""" +创建系统测试账户脚本 +""" +import asyncio +import sys +import os + +# 添加项目根目录到Python路径 +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +# 加载本地配置 +try: + import local_config + print("✅ 本地配置已加载") +except ImportError: + print("⚠️ 未找到local_config.py") + +# 要创建的系统账户 +SYSTEM_USERS = [ + { + "username": "superadmin", + "password": "Superadmin123!", + "email": "superadmin@kaopeilian.com", + "full_name": "超级管理员", + "role": "admin", # 使用现有的admin角色 + "is_superuser": 1, + }, + { + "username": "admin", + "password": "Admin123!", + "email": "admin@kaopeilian.com", + "full_name": "系统管理员", + "role": "admin", + "is_superuser": 0, + }, + { + "username": "testuser", + "password": "TestPass123!", + "email": "testuser@kaopeilian.com", + "full_name": "测试学员", + "role": "trainee", # 学员角色 + "is_superuser": 0, + }, +] + +async def create_system_accounts(): + """创建系统测试账户""" + try: + from app.core.database import AsyncSessionLocal + from app.core.security import get_password_hash + from sqlalchemy import text + + print("🔧 开始创建系统测试账户...\n") + + async with AsyncSessionLocal() as session: + created_count = 0 + updated_count = 0 + + for user_data in SYSTEM_USERS: + username = user_data["username"] + password = user_data["password"] + hashed_password = get_password_hash(password) + + # 检查用户是否存在 + result = await session.execute( + text("SELECT id FROM users WHERE username = :username"), + {"username": username} + ) + existing = result.fetchone() + + if existing: + # 更新现有用户 + await session.execute( + text(""" + UPDATE users + SET password_hash = :password_hash, + email = :email, + full_name = :full_name, + role = :role, + is_superuser = :is_superuser, + is_active = 1, + updated_at = NOW() + WHERE username = :username + """), + { + "username": username, + "password_hash": hashed_password, + "email": user_data["email"], + "full_name": user_data["full_name"], + "role": user_data["role"], + "is_superuser": user_data["is_superuser"] + } + ) + await session.commit() + updated_count += 1 + print(f"✓ 更新用户: {username} ({user_data['full_name']})") + else: + # 创建新用户 + await session.execute( + text(""" + INSERT INTO users ( + username, email, password_hash, role, + is_active, full_name, is_superuser, + created_at, updated_at + ) + VALUES ( + :username, :email, :password_hash, :role, + 1, :full_name, :is_superuser, + NOW(), NOW() + ) + """), + { + "username": username, + "email": user_data["email"], + "password_hash": hashed_password, + "role": user_data["role"], + "full_name": user_data["full_name"], + "is_superuser": user_data["is_superuser"] + } + ) + await session.commit() + created_count += 1 + print(f"✓ 创建用户: {username} ({user_data['full_name']})") + + # 打印总结 + print("\n" + "="*50) + print("✅ 系统用户创建/更新完成!") + print(f"新创建用户数: {created_count}") + print(f"更新用户数: {updated_count}") + print("\n系统账户信息:") + print("-"*50) + print("| 角色 | 用户名 | 密码 | 权限说明 |") + print("| ---------- | ---------- | -------------- | ---------------------------- |") + print("| 超级管理员 | superadmin | Superadmin123! | 系统最高权限,可管理所有功能 |") + print("| 系统管理员 | admin | Admin123! | 可管理除“系统管理”模块外的全部功能(管理员仪表盘、用户管理、岗位管理、系统日志) |") + print("| 测试学员 | testuser | TestPass123! | 可学习课程、参加考试和训练 |") + print("-"*50) + + except Exception as e: + print(f"❌ 创建用户失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(create_system_accounts()) diff --git a/backend/create_system_users.py b/backend/create_system_users.py new file mode 100644 index 0000000..b933aa7 --- /dev/null +++ b/backend/create_system_users.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +创建系统测试账户脚本 +""" + +import asyncio +import sys +from datetime import datetime +from pathlib import Path + +# 添加项目根目录到 Python 路径 +sys.path.insert(0, str(Path(__file__).parent)) + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker + +from app.core.config import settings +from app.core.security import get_password_hash +from app.models.user import User +from app.models.base import Base + +# 要创建的系统账户 +SYSTEM_USERS = [ + { + "username": "superadmin", + "password": "Superadmin123!", + "email": "superadmin@kaopeilian.com", + "full_name": "超级管理员", + "role": "super_admin", + "phone": "13800000001", + "is_active": True, + "department": "系统管理部", + "position": "超级管理员", + }, + { + "username": "admin", + "password": "Admin123!", + "email": "admin@kaopeilian.com", + "full_name": "系统管理员", + "role": "admin", + "phone": "13800000002", + "is_active": True, + "department": "系统管理部", + "position": "系统管理员", + }, + { + "username": "testuser", + "password": "TestPass123!", + "email": "testuser@kaopeilian.com", + "full_name": "测试学员", + "role": "student", + "phone": "13800000004", + "is_active": True, + "department": "学员部", + "position": "学员", + }, +] + + +async def create_system_users(): + """创建系统测试账户""" + # 创建数据库引擎 + engine = create_async_engine(settings.DATABASE_URL, echo=False) + + # 创建会话 + async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with async_session() as session: + created_users = [] + updated_users = [] + + for user_data in SYSTEM_USERS: + try: + # 检查用户是否已存在 + result = await session.execute( + select(User).where(User.username == user_data["username"]) + ) + existing_user = result.scalar_one_or_none() + + if existing_user: + # 更新现有用户的密码和信息 + existing_user.hashed_password = get_password_hash(user_data["password"]) + existing_user.email = user_data["email"] + existing_user.full_name = user_data["full_name"] + existing_user.role = user_data["role"] + existing_user.phone = user_data["phone"] + existing_user.is_active = user_data["is_active"] + existing_user.department = user_data["department"] + existing_user.position = user_data["position"] + existing_user.is_deleted = False + existing_user.updated_at = datetime.utcnow() + + await session.commit() + updated_users.append(user_data["username"]) + print(f"✓ 更新用户: {user_data['username']} ({user_data['full_name']})") + else: + # 创建新用户 + new_user = User( + username=user_data["username"], + hashed_password=get_password_hash(user_data["password"]), + email=user_data["email"], + full_name=user_data["full_name"], + role=user_data["role"], + phone=user_data["phone"], + is_active=user_data["is_active"], + department=user_data["department"], + position=user_data["position"], + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + + session.add(new_user) + await session.commit() + created_users.append(user_data["username"]) + print(f"✓ 创建用户: {user_data['username']} ({user_data['full_name']})") + + except Exception as e: + print(f"✗ 处理用户 {user_data['username']} 时出错: {str(e)}") + await session.rollback() + + # 打印总结 + print("\n" + "="*50) + print("系统用户创建/更新完成!") + print(f"新创建用户数: {len(created_users)}") + print(f"更新用户数: {len(updated_users)}") + print("\n系统账户信息:") + print("-"*50) + print("| 角色 | 用户名 | 密码 | 权限说明 |") + print("| ---------- | ---------- | -------------- | ---------------------------- |") + print("| 超级管理员 | superadmin | Superadmin123! | 系统最高权限,可管理所有功能 |") + print("| 系统管理员 | admin | Admin123! | 可管理除“系统管理”模块外的全部功能(管理员仪表盘、用户管理、岗位管理、系统日志) |") + print("| 测试学员 | testuser | TestPass123! | 可学习课程、参加考试和训练 |") + print("-"*50) + + await engine.dispose() + + +if __name__ == "__main__": + print("开始创建系统测试账户...") + asyncio.run(create_system_users()) diff --git a/backend/create_team_data.py b/backend/create_team_data.py new file mode 100644 index 0000000..101cc54 --- /dev/null +++ b/backend/create_team_data.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +""" +创建团队管理页面测试数据 +""" +import asyncio +import sys +from datetime import datetime, timedelta +from pathlib import Path +import random + +# 添加项目根目录到 Python 路径 +sys.path.insert(0, str(Path(__file__).parent)) + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.database import engine, AsyncSessionLocal +from app.models.user import User, Team, UserTeam +from app.models.position import Position +from app.models.position_member import PositionMember +from app.models.position_course import PositionCourse +from app.models.course import Course +from app.models.exam import Exam +from app.models.practice import PracticeSession, PracticeReport +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +async def create_team_members(): + """创建团队成员数据""" + async with AsyncSessionLocal() as db: + try: + # 1. 检查或创建团队 + result = await db.execute(select(Team).where(Team.name == "轻医美销售团队")) + team = result.scalar_one_or_none() + + if not team: + team = Team( + name="轻医美销售团队", + code="SALES_TEAM_001", + description="负责轻医美产品销售的核心团队", + leader_id=1, # admin + created_at=datetime.now(), + updated_at=datetime.now() + ) + db.add(team) + await db.flush() + print(f"✅ 创建团队: {team.name}") + else: + print(f"ℹ️ 团队已存在: {team.name}") + + # 2. 检查或创建岗位 + positions_data = [ + {"name": "销售专员", "code": "POS_SALES_001", "description": "负责客户接待和产品销售"}, + {"name": "销售主管", "code": "POS_MANAGER_001", "description": "负责团队管理和业绩指导"}, + {"name": "高级顾问", "code": "POS_CONSULTANT_001", "description": "负责高端客户维护和方案设计"}, + ] + + positions = {} + for pos_data in positions_data: + result = await db.execute( + select(Position).where(Position.name == pos_data["name"]) + ) + position = result.scalar_one_or_none() + + if not position: + position = Position( + name=pos_data["name"], + code=pos_data["code"], + description=pos_data["description"], + created_at=datetime.now(), + updated_at=datetime.now() + ) + db.add(position) + await db.flush() + print(f"✅ 创建岗位: {position.name}") + else: + print(f"ℹ️ 岗位已存在: {position.name}") + + positions[pos_data["name"]] = position + + # 3. 检查现有课程 + result = await db.execute(select(Course).limit(5)) + courses = result.scalars().all() + if not courses: + print("⚠️ 警告:没有课程数据,跳过岗位课程分配") + course_ids = [] + else: + course_ids = [c.id for c in courses] + print(f"ℹ️ 找到 {len(courses)} 门课程") + + # 4. 为岗位分配课程 + if course_ids: + for position in positions.values(): + # 每个岗位分配2-4门课程 + assigned_courses = random.sample(course_ids, min(random.randint(2, 4), len(course_ids))) + for course_id in assigned_courses: + result = await db.execute( + select(PositionCourse).where( + PositionCourse.position_id == position.id, + PositionCourse.course_id == course_id + ) + ) + if not result.scalar_one_or_none(): + pc = PositionCourse( + position_id=position.id, + course_id=course_id, + course_type="required" + ) + db.add(pc) + print(f"✅ 为岗位分配了课程") + + # 5. 创建团队成员 + members_data = [ + {"username": "zhangsan", "real_name": "张三", "email": "zhangsan@example.com", "position": "销售专员", "days_ago": 15}, + {"username": "lisi", "real_name": "李四", "email": "lisi@example.com", "position": "销售专员", "days_ago": 25}, + {"username": "wangwu", "real_name": "王五", "email": "wangwu@example.com", "position": "销售主管", "days_ago": 5}, + {"username": "zhaoliu", "real_name": "赵六", "email": "zhaoliu@example.com", "position": "高级顾问", "days_ago": 8}, + {"username": "sunqi", "real_name": "孙七", "email": "sunqi@example.com", "position": "销售专员", "days_ago": 60}, + {"username": "zhouba", "real_name": "周八", "email": "zhouba@example.com", "position": "销售主管", "days_ago": 3}, + {"username": "wujiu", "real_name": "吴九", "email": "wujiu@example.com", "position": "高级顾问", "days_ago": 12}, + {"username": "zhengshi", "real_name": "郑十", "email": "zhengshi@example.com", "position": "销售专员", "days_ago": 45}, + ] + + created_users = [] + for member_data in members_data: + # 检查用户是否存在 + result = await db.execute( + select(User).where(User.username == member_data["username"]) + ) + user = result.scalar_one_or_none() + + if not user: + user = User( + username=member_data["username"], + hashed_password=pwd_context.hash("Pass123!"), + email=member_data["email"], + full_name=member_data["real_name"], + role="trainee", + phone=f"138{random.randint(10000000, 99999999)}", + is_active=True, + last_login_at=datetime.now() - timedelta(days=member_data["days_ago"]), + created_at=datetime.now() - timedelta(days=member_data["days_ago"] + 30), + updated_at=datetime.now() + ) + db.add(user) + await db.flush() + print(f"✅ 创建用户: {user.full_name} ({user.username})") + else: + print(f"ℹ️ 用户已存在: {user.full_name} ({user.username})") + + created_users.append((user, member_data)) + + # 添加到团队 + result = await db.execute( + select(UserTeam).where( + UserTeam.user_id == user.id, + UserTeam.team_id == team.id + ) + ) + if not result.scalar_one_or_none(): + user_team = UserTeam( + user_id=user.id, + team_id=team.id, + joined_at=datetime.now() - timedelta(days=member_data["days_ago"]) + ) + db.add(user_team) + + # 分配岗位 + position = positions[member_data["position"]] + result = await db.execute( + select(PositionMember).where( + PositionMember.user_id == user.id, + PositionMember.position_id == position.id + ) + ) + if not result.scalar_one_or_none(): + position_member = PositionMember( + position_id=position.id, + user_id=user.id, + joined_at=datetime.now() - timedelta(days=member_data["days_ago"]) + ) + db.add(position_member) + + print(f"✅ 创建了 {len(created_users)} 个团队成员") + + # 6. 为成员创建考试记录 + if course_ids: + for user, member_data in created_users: + # 每个用户完成1-3门课程的考试 + exam_count = random.randint(1, min(3, len(course_ids))) + exam_courses = random.sample(course_ids, exam_count) + + for course_id in exam_courses: + # 创建1-2次考试记录 + for _ in range(random.randint(1, 2)): + days_ago = random.randint(1, member_data["days_ago"]) + score = random.randint(60, 98) + + exam = Exam( + user_id=user.id, + course_id=course_id, + status="completed", + round1_score=score, + round2_score=score + random.randint(0, 5) if score < 90 else None, + round3_score=None, + duration_minutes=random.randint(10, 30), + start_time=datetime.now() - timedelta(days=days_ago, hours=random.randint(0, 8)), + end_time=datetime.now() - timedelta(days=days_ago, hours=random.randint(0, 8)) + ) + db.add(exam) + + print(f"✅ 为成员创建了考试记录") + + # 7. 为成员创建陪练记录 + for user, member_data in created_users: + # 活跃用户(最近30天登录)创建陪练记录 + if member_data["days_ago"] <= 30: + practice_count = random.randint(2, 5) + for _ in range(practice_count): + days_ago = random.randint(1, min(member_data["days_ago"], 25)) + duration = random.randint(300, 1800) # 5-30分钟 + + session = PracticeSession( + session_id=f"PS{user.id}{random.randint(1000, 9999)}", + user_id=user.id, + scene_id=random.randint(1, 5), # 假设有5个场景 + status="completed", + duration_seconds=duration, + turns=random.randint(8, 20), + start_time=datetime.now() - timedelta(days=days_ago, hours=random.randint(0, 8)), + end_time=datetime.now() - timedelta(days=days_ago, hours=random.randint(0, 8)) + ) + db.add(session) + await db.flush() + + # 创建陪练报告 + total_score = random.randint(75, 95) + report = PracticeReport( + session_id=session.session_id, + total_score=total_score, + score_breakdown={ + "communication": random.randint(70, 95), + "product_knowledge": random.randint(70, 95), + "sales_skill": random.randint(70, 95), + "service_attitude": random.randint(75, 98), + "problem_handling": random.randint(70, 92) + }, + ability_dimensions={ + "沟通表达": random.randint(75, 95), + "产品知识": random.randint(70, 92), + "销售技巧": random.randint(72, 90), + "服务态度": random.randint(80, 98), + "应变能力": random.randint(70, 88), + "专业素养": random.randint(75, 92) + }, + suggestions=["态度积极", "表达清晰", "建议加强产品知识学习"] + ) + db.add(report) + + print(f"✅ 为活跃成员创建了陪练记录") + + await db.commit() + print("\n🎉 所有测试数据创建完成!") + print(f"\n团队:{team.name}") + print(f"成员数:{len(created_users)}") + print(f"岗位数:{len(positions)}") + print("\n测试账号信息:") + print("=" * 50) + for user, _ in created_users[:3]: # 显示前3个 + print(f"用户名: {user.username}") + print(f"密码: Pass123!") + print(f"姓名: {user.full_name}") + print("-" * 50) + + except Exception as e: + await db.rollback() + print(f"\n❌ 错误: {e}") + import traceback + traceback.print_exc() + raise + + +if __name__ == "__main__": + print("开始创建团队管理测试数据...\n") + asyncio.run(create_team_members()) + diff --git a/backend/create_test_user.py b/backend/create_test_user.py new file mode 100644 index 0000000..7042b85 --- /dev/null +++ b/backend/create_test_user.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +创建测试用户 +""" +import asyncio +import os +import sys +from pathlib import Path + +# 设置环境变量 +os.environ["DATABASE_URL"] = "mysql+aiomysql://root:root@localhost:3306/kaopeilian" +os.environ["SECRET_KEY"] = "dev-secret-key-for-testing-only-not-for-production" + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +async def create_test_user(): + """创建测试用户""" + try: + from app.config.database import AsyncSessionLocal + from app.models.user import User + from app.core.security import create_password_hash + from sqlalchemy import select + + print("🔍 创建测试用户...") + + async with AsyncSessionLocal() as session: + # 检查用户是否已存在 + result = await session.execute( + select(User).where(User.username == "testuser") + ) + existing_user = result.scalar_one_or_none() + + if existing_user: + print("⚠️ 用户 'testuser' 已存在") + return + + # 创建新用户 + hashed_password = create_password_hash("TestPass123!") + user = User( + username="testuser", + email="test@example.com", + password_hash=hashed_password, + full_name="Test User", + is_active=True, + role="trainee" + ) + + session.add(user) + await session.commit() + await session.refresh(user) + + print(f"✅ 测试用户创建成功: {user.username} (ID: {user.id})") + + except Exception as e: + print(f"❌ 创建用户失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(create_test_user()) diff --git a/backend/create_test_user_exam.py b/backend/create_test_user_exam.py new file mode 100644 index 0000000..d45e5ab --- /dev/null +++ b/backend/create_test_user_exam.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +""" +创建测试用户以便测试exam模块 +""" +import asyncio +import sys +from pathlib import Path + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from app.core.database import AsyncSessionLocal, engine +from app.models.base import Base +from app.models.user import User +from app.core.security import get_password_hash + + +async def create_test_user(): + """创建测试用户""" + async with AsyncSessionLocal() as db: + try: + # 创建一个测试用户 + test_user = User( + username="test_exam", + email="test_exam@example.com", + hashed_password=get_password_hash("test123"), + full_name="Test Exam User", + role="trainee", + is_active=True, + is_verified=True + ) + + db.add(test_user) + await db.commit() + await db.refresh(test_user) + + print(f"✅ 测试用户创建成功:") + print(f" 用户名:{test_user.username}") + print(f" 邮箱:{test_user.email}") + print(f" 密码:test123") + print(f" ID:{test_user.id}") + + except Exception as e: + print(f"❌ 创建测试用户失败:{e}") + await db.rollback() + + +if __name__ == "__main__": + asyncio.run(create_test_user()) diff --git a/backend/create_user_simple.py b/backend/create_user_simple.py new file mode 100644 index 0000000..53e047c --- /dev/null +++ b/backend/create_user_simple.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +简单创建用户脚本 +""" +import asyncio +import sys +import os + +# 添加项目根目录到Python路径 +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +# 加载本地配置 +try: + import local_config + print("✅ 本地配置已加载") +except ImportError: + print("⚠️ 未找到local_config.py") + +async def create_user_simple(): + """简单创建用户""" + try: + from app.core.database import AsyncSessionLocal + from app.core.security import get_password_hash + from sqlalchemy import text + + print("🔧 创建测试用户...") + + async with AsyncSessionLocal() as session: + # 创建一个测试用户 + test_username = "test_dify" + test_password = "password123" + hashed_password = get_password_hash(test_password) + + # 检查用户是否存在 + result = await session.execute( + text("SELECT id FROM users WHERE username = :username"), + {"username": test_username} + ) + existing = result.fetchone() + + if existing: + print(f"⚠️ 用户 '{test_username}' 已存在") + else: + # 插入新用户 + await session.execute( + text(""" + INSERT INTO users (username, email, password_hash, role, is_active, full_name, created_at, updated_at) + VALUES (:username, :email, :password_hash, :role, :is_active, :full_name, NOW(), NOW()) + """), + { + "username": test_username, + "email": "test_dify@example.com", + "password_hash": hashed_password, + "role": "trainee", + "is_active": 1, + "full_name": "Test Dify User" + } + ) + await session.commit() + + print(f"\n✅ 用户创建成功:") + print(f" 用户名: {test_username}") + print(f" 密码: {test_password}") + print(f" 角色: trainee") + print(f" 邮箱: test_dify@example.com") + + # 创建管理员用户 + admin_username = "admin" + admin_password = "admin123" + admin_hashed_password = get_password_hash(admin_password) + + # 检查管理员是否存在 + result = await session.execute( + text("SELECT id FROM users WHERE username = :username"), + {"username": admin_username} + ) + existing = result.fetchone() + + if existing: + print(f"\n⚠️ 用户 '{admin_username}' 已存在") + else: + # 插入管理员 + await session.execute( + text(""" + INSERT INTO users (username, email, password_hash, role, is_active, full_name, is_superuser, created_at, updated_at) + VALUES (:username, :email, :password_hash, :role, :is_active, :full_name, :is_superuser, NOW(), NOW()) + """), + { + "username": admin_username, + "email": "admin@kaopeilian.com", + "password_hash": admin_hashed_password, + "role": "admin", + "is_active": 1, + "full_name": "系统管理员", + "is_superuser": 1 + } + ) + await session.commit() + + print(f"\n✅ 管理员创建成功:") + print(f" 用户名: {admin_username}") + print(f" 密码: {admin_password}") + print(f" 角色: admin") + print(f" 邮箱: admin@kaopeilian.com") + + print("\n✅ 用户创建完成!") + + except Exception as e: + print(f"❌ 创建用户失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(create_user_simple()) diff --git a/backend/database_schema_unified.md b/backend/database_schema_unified.md new file mode 100644 index 0000000..7ecfbf3 --- /dev/null +++ b/backend/database_schema_unified.md @@ -0,0 +1,389 @@ +# 考培练系统统一数据库架构设计 + +## 数据库基本信息 +- 数据库名称:kaopeilian +- 字符集:utf8mb4 +- 排序规则:utf8mb4_unicode_ci +- 存储引擎:InnoDB + +## 一、用户管理模块 + +### 1.1 用户表 (users) +```sql +CREATE TABLE `users` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', + `email` VARCHAR(100) NOT NULL UNIQUE COMMENT '邮箱', + `phone` VARCHAR(20) UNIQUE COMMENT '手机号', + `password_hash` VARCHAR(200) NOT NULL COMMENT '密码哈希', + `full_name` VARCHAR(100) COMMENT '姓名', + `avatar_url` VARCHAR(500) COMMENT '头像URL', + `bio` TEXT COMMENT '个人简介', + `role` VARCHAR(20) DEFAULT 'trainee' COMMENT '系统角色: admin, manager, trainee', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否激活', + `is_verified` BOOLEAN DEFAULT FALSE COMMENT '是否验证', + `last_login_at` DATETIME COMMENT '最后登录时间', + `password_changed_at` DATETIME COMMENT '密码修改时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_role (role), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; +``` + +### 1.2 团队表 (teams) +```sql +CREATE TABLE `teams` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL UNIQUE COMMENT '团队名称', + `code` VARCHAR(50) NOT NULL UNIQUE COMMENT '团队代码', + `description` TEXT COMMENT '团队描述', + `team_type` VARCHAR(50) DEFAULT 'department' COMMENT '团队类型: department, project, study_group', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否激活', + `leader_id` INT COMMENT '负责人ID', + `parent_id` INT COMMENT '父团队ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (leader_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (parent_id) REFERENCES teams(id) ON DELETE CASCADE, + INDEX idx_team_type (team_type), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='团队表'; +``` + +### 1.3 用户团队关联表 (user_teams) +```sql +CREATE TABLE `user_teams` ( + `user_id` INT NOT NULL, + `team_id` INT NOT NULL, + `role` VARCHAR(50) DEFAULT 'member' COMMENT '团队角色: member, leader', + `joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + PRIMARY KEY (user_id, team_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户团队关联表'; +``` + +## 二、课程管理模块 + +### 2.1 课程表 (courses) +```sql +CREATE TABLE `courses` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '课程名称', + `description` TEXT COMMENT '课程描述', + `category` ENUM('technology', 'management', 'business', 'general') DEFAULT 'general' COMMENT '课程分类', + `status` ENUM('draft', 'published', 'archived') DEFAULT 'draft' COMMENT '课程状态', + `cover_image` VARCHAR(500) COMMENT '封面图片URL', + `duration_hours` FLOAT COMMENT '课程时长(小时)', + `difficulty_level` INT COMMENT '难度等级(1-5)', + `tags` JSON COMMENT '标签列表', + `published_at` DATETIME COMMENT '发布时间', + `publisher_id` INT COMMENT '发布人ID', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_featured` BOOLEAN DEFAULT FALSE COMMENT '是否推荐', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_status (status), + INDEX idx_category (category), + INDEX idx_is_featured (is_featured), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程表'; +``` + +### 2.2 课程资料表 (course_materials) +```sql +CREATE TABLE `course_materials` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `name` VARCHAR(200) NOT NULL COMMENT '资料名称', + `description` TEXT COMMENT '资料描述', + `file_url` VARCHAR(500) NOT NULL COMMENT '文件URL', + `file_type` VARCHAR(50) NOT NULL COMMENT '文件类型', + `file_size` INT NOT NULL COMMENT '文件大小(字节)', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程资料表'; +``` + +### 2.3 知识点表 (knowledge_points) +```sql +CREATE TABLE `knowledge_points` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `name` VARCHAR(200) NOT NULL COMMENT '知识点名称', + `description` TEXT COMMENT '知识点描述', + `parent_id` INT COMMENT '父知识点ID', + `level` INT DEFAULT 1 COMMENT '层级深度', + `path` VARCHAR(500) COMMENT '路径(如: 1.2.3)', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `weight` FLOAT DEFAULT 1.0 COMMENT '权重', + `is_required` BOOLEAN DEFAULT TRUE COMMENT '是否必修', + `estimated_hours` FLOAT COMMENT '预计学习时间(小时)', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES knowledge_points(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_parent_id (parent_id), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识点表'; +``` + +### 2.4 成长路径表 (growth_paths) +```sql +CREATE TABLE `growth_paths` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '路径名称', + `description` TEXT COMMENT '路径描述', + `target_role` VARCHAR(100) COMMENT '目标角色', + `courses` JSON COMMENT '课程列表[{course_id, order, is_required}]', + `estimated_duration_days` INT COMMENT '预计完成天数', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_is_active (is_active), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成长路径表'; +``` + +## 三、考试模块 + +### 3.1 题目表 (questions) +```sql +CREATE TABLE `questions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `question_type` VARCHAR(20) NOT NULL COMMENT '题目类型: single_choice, multiple_choice, true_false, fill_blank, essay', + `title` TEXT NOT NULL COMMENT '题目标题', + `content` TEXT COMMENT '题目内容', + `options` JSON COMMENT '选项(JSON格式)', + `correct_answer` TEXT NOT NULL COMMENT '正确答案', + `explanation` TEXT COMMENT '答案解释', + `score` FLOAT DEFAULT 10.0 COMMENT '分值', + `difficulty` VARCHAR(10) DEFAULT 'medium' COMMENT '难度等级: easy, medium, hard', + `tags` JSON COMMENT '标签(JSON格式)', + `usage_count` INT DEFAULT 0 COMMENT '使用次数', + `correct_count` INT DEFAULT 0 COMMENT '答对次数', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_question_type (question_type), + INDEX idx_difficulty (difficulty), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题目表'; +``` + +### 3.2 考试记录表 (exams) +```sql +CREATE TABLE `exams` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL COMMENT '用户ID', + `course_id` INT NOT NULL COMMENT '课程ID', + `exam_name` VARCHAR(255) NOT NULL COMMENT '考试名称', + `question_count` INT DEFAULT 10 COMMENT '题目数量', + `total_score` FLOAT DEFAULT 100.0 COMMENT '总分', + `pass_score` FLOAT DEFAULT 60.0 COMMENT '及格分', + `start_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + `end_time` DATETIME COMMENT '结束时间', + `duration_minutes` INT DEFAULT 60 COMMENT '考试时长(分钟)', + `score` FLOAT COMMENT '得分', + `is_passed` BOOLEAN COMMENT '是否通过', + `status` VARCHAR(20) DEFAULT 'started' COMMENT '状态: started, submitted, timeout', + `questions` JSON COMMENT '题目数据(JSON格式)', + `answers` JSON COMMENT '答案数据(JSON格式)', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_course_id (course_id), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试记录表'; +``` + +### 3.3 考试结果详情表 (exam_results) +```sql +CREATE TABLE `exam_results` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `exam_id` INT NOT NULL COMMENT '考试ID', + `question_id` INT NOT NULL COMMENT '题目ID', + `user_answer` TEXT COMMENT '用户答案', + `is_correct` BOOLEAN DEFAULT FALSE COMMENT '是否正确', + `score` FLOAT DEFAULT 0.0 COMMENT '得分', + `answer_time` INT COMMENT '答题时长(秒)', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE, + FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE, + INDEX idx_exam_id (exam_id), + INDEX idx_question_id (question_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试结果详情表'; +``` + +## 四、陪练模块 + +### 4.1 陪练场景表 (training_scenes) +```sql +CREATE TABLE `training_scenes` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL COMMENT '场景名称', + `description` TEXT COMMENT '场景描述', + `category` VARCHAR(50) NOT NULL COMMENT '场景分类', + `ai_config` JSON COMMENT 'AI配置(如Coze Bot ID等)', + `prompt_template` TEXT COMMENT '提示词模板', + `evaluation_criteria` JSON COMMENT '评估标准', + `status` ENUM('DRAFT', 'ACTIVE', 'INACTIVE') DEFAULT 'DRAFT' COMMENT '场景状态', + `is_public` BOOLEAN DEFAULT TRUE COMMENT '是否公开', + `required_level` INT COMMENT '所需用户等级', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_status (status), + INDEX idx_category (category), + INDEX idx_is_public (is_public), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练场景表'; +``` + +### 4.2 陪练会话表 (training_sessions) +```sql +CREATE TABLE `training_sessions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL COMMENT '用户ID', + `scene_id` INT NOT NULL COMMENT '场景ID', + `coze_conversation_id` VARCHAR(100) COMMENT 'Coze会话ID', + `start_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + `end_time` DATETIME COMMENT '结束时间', + `duration_seconds` INT COMMENT '持续时长(秒)', + `status` ENUM('CREATED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'ERROR') DEFAULT 'CREATED' COMMENT '会话状态', + `session_config` JSON COMMENT '会话配置', + `total_score` FLOAT COMMENT '总分', + `evaluation_result` JSON COMMENT '评估结果详情', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (scene_id) REFERENCES training_scenes(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_scene_id (scene_id), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练会话表'; +``` + +### 4.3 陪练消息表 (training_messages) +```sql +CREATE TABLE `training_messages` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` INT NOT NULL COMMENT '会话ID', + `role` ENUM('USER', 'ASSISTANT', 'SYSTEM') NOT NULL COMMENT '消息角色', + `type` ENUM('TEXT', 'VOICE', 'SYSTEM') NOT NULL COMMENT '消息类型', + `content` TEXT NOT NULL COMMENT '消息内容', + `voice_url` VARCHAR(500) COMMENT '语音文件URL', + `voice_duration` FLOAT COMMENT '语音时长(秒)', + `message_metadata` JSON COMMENT '消息元数据', + `coze_message_id` VARCHAR(100) COMMENT 'Coze消息ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES training_sessions(id) ON DELETE CASCADE, + INDEX idx_session_id (session_id), + INDEX idx_role (role) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练消息表'; +``` + +### 4.4 陪练报告表 (training_reports) +```sql +CREATE TABLE `training_reports` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` INT NOT NULL UNIQUE COMMENT '会话ID', + `user_id` INT NOT NULL COMMENT '用户ID', + `overall_score` FLOAT NOT NULL COMMENT '总体得分', + `dimension_scores` JSON NOT NULL COMMENT '各维度得分', + `strengths` JSON NOT NULL COMMENT '优势点', + `weaknesses` JSON NOT NULL COMMENT '待改进点', + `suggestions` JSON NOT NULL COMMENT '改进建议', + `detailed_analysis` TEXT COMMENT '详细分析', + `transcript` TEXT COMMENT '对话文本记录', + `statistics` JSON COMMENT '统计数据', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES training_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练报告表'; +``` + +## 五、数据库设计原则 + +### 5.1 主键规范 +- 所有表使用INT作为主键,满足当前规模与 ORM 定义 +- 所有主键均设置为AUTO_INCREMENT + +### 5.2 外键约束 +- 所有外键关系都明确定义 +- 删除策略: + - CASCADE:级联删除(用于强关联关系) + - SET NULL:置空(用于弱关联关系) + +### 5.3 索引策略 +- 所有外键字段自动创建索引 +- 常用查询字段创建索引(如status, type等) +- 唯一约束字段自动创建唯一索引 + +### 5.4 字段命名规范 +- 使用下划线命名法(snake_case) +- 布尔字段使用is_前缀 +- 时间字段使用_at后缀 +- JSON字段明确标注数据结构 + +### 5.5 软删除设计 +- 使用is_deleted和deleted_at字段实现软删除 +- 保留数据完整性,便于数据恢复 + +### 5.6 审计字段 +- created_at:创建时间 +- updated_at:更新时间 +- created_by:创建人ID +- updated_by:更新人ID + +## 六、性能优化建议 + +1. **分表策略** + - training_messages表可能增长较快,考虑按月分表 + - exam_results表可考虑按年分表 + +2. **缓存策略** + - 用户信息使用Redis缓存 + - 课程列表使用Redis缓存 + - 热门题目使用Redis缓存 + +3. **查询优化** + - 使用分页查询避免大量数据加载 + - 合理使用JOIN,避免N+1查询 + - 统计类查询考虑使用物化视图 diff --git a/backend/debug_api.py b/backend/debug_api.py new file mode 100644 index 0000000..3f0a67c --- /dev/null +++ b/backend/debug_api.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +""" +调试API问题 +""" +import asyncio +import os +import sys +from pathlib import Path + +# 设置环境变量 +os.environ["DATABASE_URL"] = "mysql+aiomysql://root:root@localhost:3306/kaopeilian" +os.environ["SECRET_KEY"] = "dev-secret-key-for-testing-only-not-for-production" + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +async def debug_login(): + """调试登录问题""" + try: + # 导入所有必要的模块 + from app.config.database import AsyncSessionLocal + from app.services.auth_service import AuthService + from app.schemas.auth import UserLogin, Token + from app.schemas.base import ResponseModel + from app.core.exceptions import BaseError + from app.core.logger import logger + from fastapi import HTTPException, status + + print("🔍 开始调试登录流程...") + + # 创建登录数据 + login_data = UserLogin(username="testuser", password="TestPass123!") + print(f"📝 登录数据: {login_data}") + + async with AsyncSessionLocal() as db: + try: + print("🔐 开始认证...") + auth_service = AuthService(db) + + # 验证用户 + user = await auth_service.authenticate_user( + username=login_data.username, + password=login_data.password + ) + print(f"✅ 用户认证成功: {user.username}") + + # 创建tokens + tokens = await auth_service.create_tokens_for_user(user) + print(f"✅ Token创建成功") + + # 创建响应 + response = ResponseModel(data=tokens) + print(f"✅ 响应创建成功: {response}") + + return response + + except BaseError as e: + print(f"❌ 业务错误: {e}") + raise HTTPException( + status_code=e.code, + detail={ + "message": e.message, + "error_code": e.error_code + } + ) + except Exception as e: + print(f"❌ 系统错误: {e}") + import traceback + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"message": "登录失败,请稍后重试"} + ) + + except Exception as e: + print(f"❌ 调试失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(debug_login()) diff --git a/backend/debug_update_api.py b/backend/debug_update_api.py new file mode 100644 index 0000000..b7693fd --- /dev/null +++ b/backend/debug_update_api.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +""" +调试用户更新API +""" + +import asyncio +import sys +from pathlib import Path +from datetime import datetime + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent +sys.path.append(str(project_root)) + +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.deps import get_db +from app.services.user_service import UserService +from app.schemas.user import UserUpdate +from app.models.user import User +from app.core.logger import logger + +async def debug_update(): + """调试更新流程""" + + # 获取数据库会话 + async for db in get_db(): + try: + # 1. 创建服务实例 + user_service = UserService(db) + + # 2. 获取superadmin用户 + print("1. 获取superadmin用户...") + user = await user_service.get_by_username("superadmin") + if not user: + print("用户不存在") + return + + print(f"用户ID: {user.id}") + print(f"用户名: {user.username}") + print(f"当前学校: {user.school}") + print(f"当前专业: {user.major}") + + # 3. 创建更新数据 + print("\n2. 创建更新数据...") + update_data = UserUpdate( + school="北京大学", + major="软件工程", + bio="测试更新" + ) + + print(f"更新数据: {update_data.model_dump(exclude_unset=True)}") + + # 4. 执行更新 + print("\n3. 执行更新...") + updated_user = await user_service.update_user( + user_id=user.id, + obj_in=update_data, + updated_by=user.id + ) + + # 5. 检查更新结果 + print("\n4. 更新后的数据:") + print(f"用户ID: {updated_user.id}") + print(f"用户名: {updated_user.username}") + print(f"学校: {updated_user.school}") + print(f"专业: {updated_user.major}") + print(f"个人简介: {updated_user.bio}") + + # 6. 再次查询验证 + print("\n5. 再次查询验证...") + verified_user = await user_service.get_by_id(user.id) + print(f"验证 - 学校: {verified_user.school}") + print(f"验证 - 专业: {verified_user.major}") + + # 7. 检查数据库中的实际值 + print("\n6. 检查数据库实际值...") + from sqlalchemy import text + result = await db.execute( + text("SELECT school, major FROM users WHERE id = :user_id"), + {"user_id": user.id} + ) + row = result.fetchone() + if row: + print(f"数据库 - 学校: {row[0]}") + print(f"数据库 - 专业: {row[1]}") + + except Exception as e: + print(f"错误: {e}") + import traceback + traceback.print_exc() + finally: + await db.close() + break + +if __name__ == "__main__": + print("调试用户更新API...") + asyncio.run(debug_update()) diff --git a/backend/debug_user.py b/backend/debug_user.py new file mode 100644 index 0000000..487712c --- /dev/null +++ b/backend/debug_user.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +调试用户查询 +""" +import asyncio +import sys +import os + +# 添加项目根目录到Python路径 +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +# 加载本地配置 +try: + import local_config + print("✅ 本地配置已加载") +except ImportError: + print("⚠️ 未找到local_config.py") + +async def debug_user(): + """调试用户查询""" + try: + from app.core.database import AsyncSessionLocal + from app.services.user_service import UserService + from app.core.security import verify_password + from sqlalchemy import text + + print("🔧 调试用户查询...") + + async with AsyncSessionLocal() as session: + # 直接查询用户 + result = await session.execute( + text("SELECT id, username, email, password_hash, role, is_active FROM users WHERE username = :username"), + {"username": "test_dify"} + ) + user_data = result.fetchone() + + if user_data: + print(f"\n✅ 找到用户:") + print(f" ID: {user_data[0]}") + print(f" 用户名: {user_data[1]}") + print(f" 邮箱: {user_data[2]}") + print(f" 密码哈希: {user_data[3]}") + print(f" 角色: {user_data[4]}") + print(f" 激活状态: {user_data[5]}") + + # 验证密码 + password_ok = verify_password("password123", user_data[3]) + print(f"\n密码验证结果: {password_ok}") + else: + print("\n❌ 未找到用户 test_dify") + + # 使用UserService查询 + user_service = UserService(session) + user = await user_service.get_by_username("test_dify") + if user: + print(f"\n✅ UserService找到用户: {user.username}") + print(f" Model字段: hashed_password = {user.hashed_password}") + else: + print("\n❌ UserService未找到用户") + + # 测试认证 + auth_user = await user_service.authenticate(username="test_dify", password="password123") + if auth_user: + print(f"\n✅ 认证成功: {auth_user.username}") + else: + print("\n❌ 认证失败") + + print("\n✅ 调试完成!") + + except Exception as e: + print(f"❌ 调试失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(debug_user()) diff --git a/backend/deploy/quick_deploy.sh b/backend/deploy/quick_deploy.sh new file mode 100644 index 0000000..ba7593e --- /dev/null +++ b/backend/deploy/quick_deploy.sh @@ -0,0 +1,223 @@ +#!/bin/bash +# 快速部署脚本 - 考陪练 SQL 执行器 +# 使用方法: bash quick_deploy.sh + +set -e # 遇到错误立即退出 + +echo "===================================" +echo "考陪练 SQL 执行器快速部署脚本" +echo "服务器: 120.79.247.16" +echo "===================================" + +# 配置变量 +APP_DIR="/opt/kaopeilian/backend" +SERVICE_NAME="kaopeilian-backend" +PYTHON_VERSION="python3" + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 打印带颜色的信息 +print_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +# 检查是否为 root 用户 +if [[ $EUID -ne 0 ]]; then + print_error "此脚本必须以 root 用户运行" + exit 1 +fi + +# 1. 安装系统依赖 +print_info "安装系统依赖..." +apt update +apt install -y python3 python3-pip python3-venv git mysql-client + +# 2. 创建应用目录 +print_info "创建应用目录..." +mkdir -p $APP_DIR + +# 3. 检查代码是否存在 +if [ ! -f "$APP_DIR/app/main.py" ]; then + print_warning "代码不存在,请先上传代码到 $APP_DIR" + print_info "可以使用以下命令上传:" + echo "scp -r /path/to/kaopeilian-backend/* root@120.79.247.16:$APP_DIR/" + exit 1 +fi + +cd $APP_DIR + +# 4. 创建虚拟环境 +print_info "创建 Python 虚拟环境..." +if [ ! -d "venv" ]; then + $PYTHON_VERSION -m venv venv +fi + +# 5. 激活虚拟环境并安装依赖 +print_info "安装 Python 依赖..." +source venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt + +# 6. 创建环境配置文件 +if [ ! -f ".env" ]; then + print_info "创建环境配置文件..." + cat > .env << EOF +# 应用配置 +APP_NAME=KaoPeiLian +DEBUG=False +HOST=0.0.0.0 +PORT=8000 + +# 数据库配置 +DATABASE_URL=mysql+aiomysql://root:Kaopeilian2025!@#@localhost:3306/kaopeilian?charset=utf8mb4 + +# Redis配置 +REDIS_URL=redis://localhost:6379/0 + +# JWT配置 +SECRET_KEY=production-secret-key-for-kaopeilian-2025-at-least-32-chars +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# CORS配置 +CORS_ORIGINS=["http://120.79.247.16:8000", "http://aiedu.ireborn.com.cn", "http://localhost:3000"] + +# 日志配置 +LOG_LEVEL=INFO +LOG_FORMAT=json +EOF + print_info "环境配置文件已创建" +else + print_warning "环境配置文件已存在,跳过创建" +fi + +# 7. 创建 systemd 服务 +print_info "配置 systemd 服务..." +cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF +[Unit] +Description=KaoPeiLian Backend API +After=network.target mysql.service + +[Service] +Type=simple +User=root +WorkingDirectory=$APP_DIR +Environment="PATH=$APP_DIR/venv/bin" +Environment="PYTHONPATH=$APP_DIR" +ExecStart=$APP_DIR/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2 +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +EOF + +# 8. 重新加载 systemd 并启动服务 +print_info "启动服务..." +systemctl daemon-reload +systemctl enable ${SERVICE_NAME} + +# 检查服务是否已经运行 +if systemctl is-active --quiet ${SERVICE_NAME}; then + print_info "重启服务..." + systemctl restart ${SERVICE_NAME} +else + print_info "启动服务..." + systemctl start ${SERVICE_NAME} +fi + +# 9. 等待服务启动 +print_info "等待服务启动..." +sleep 5 + +# 10. 检查服务状态 +if systemctl is-active --quiet ${SERVICE_NAME}; then + print_info "服务启动成功!" +else + print_error "服务启动失败,请检查日志" + journalctl -u ${SERVICE_NAME} -n 50 + exit 1 +fi + +# 11. 配置防火墙 +print_info "配置防火墙..." +if command -v ufw &> /dev/null; then + ufw allow 8000/tcp + ufw --force enable +elif command -v firewall-cmd &> /dev/null; then + firewall-cmd --permanent --add-port=8000/tcp + firewall-cmd --reload +else + print_warning "未检测到防火墙,请手动配置端口 8000" +fi + +# 12. 测试部署 +print_info "测试部署..." +sleep 2 + +# 健康检查 +if curl -s http://localhost:8000/health > /dev/null; then + print_info "健康检查通过 ✓" +else + print_error "健康检查失败" +fi + +# 测试 SQL 执行器 +print_info "测试 SQL 执行器..." +RESPONSE=$(curl -s -X POST http://localhost:8000/api/v1/sql/execute-simple \ + -H "X-API-Key: dify-2025-kaopeilian" \ + -H "Content-Type: application/json" \ + -d '{"sql": "SELECT COUNT(*) as total FROM users"}') + +if [[ $RESPONSE == *"SQL 执行成功"* ]]; then + print_info "SQL 执行器测试通过 ✓" + echo "响应: $RESPONSE" +else + print_error "SQL 执行器测试失败" + echo "响应: $RESPONSE" +fi + +# 13. 打印部署信息 +echo "" +echo "===================================" +echo -e "${GREEN}部署完成!${NC}" +echo "===================================" +echo "" +echo "服务信息:" +echo "- 服务状态: $(systemctl is-active ${SERVICE_NAME})" +echo "- API 地址: http://120.79.247.16:8000" +echo "- 健康检查: http://120.79.247.16:8000/health" +echo "- SQL 执行器: http://120.79.247.16:8000/api/v1/sql/execute-simple" +echo "" +echo "认证信息:" +echo "- API Key: dify-2025-kaopeilian" +echo "- 请求头: X-API-Key" +echo "" +echo "常用命令:" +echo "- 查看日志: journalctl -u ${SERVICE_NAME} -f" +echo "- 重启服务: systemctl restart ${SERVICE_NAME}" +echo "- 停止服务: systemctl stop ${SERVICE_NAME}" +echo "- 服务状态: systemctl status ${SERVICE_NAME}" +echo "" +echo "Dify 配置:" +echo "1. 鉴权类型: 请求头" +echo "2. 鉴权头部前缀: Custom" +echo "3. 键: X-API-Key" +echo "4. 值: dify-2025-kaopeilian" +echo "==================================="# + + diff --git a/backend/deploy/server_setup_guide.md b/backend/deploy/server_setup_guide.md new file mode 100644 index 0000000..e479017 --- /dev/null +++ b/backend/deploy/server_setup_guide.md @@ -0,0 +1,285 @@ +# 考陪练系统 SQL 执行器服务器部署指南 + +## 服务器信息 +- **IP地址**: 120.79.247.16 +- **域名**: aiedu.ireborn.com.cn +- **端口**: 8000 +- **数据库**: MySQL (已配置在同一服务器) + +## 部署步骤 + +### 1. 连接到服务器 + +```bash +ssh root@120.79.247.16 +``` + +### 2. 准备环境 + +```bash +# 更新系统包 +apt update && apt upgrade -y + +# 安装 Python 3.9+ +apt install python3 python3-pip python3-venv -y + +# 安装 MySQL 客户端(如果需要) +apt install mysql-client -y + +# 安装 Git +apt install git -y + +# 创建应用目录 +mkdir -p /opt/kaopeilian +cd /opt/kaopeilian +``` + +### 3. 克隆或上传代码 + +```bash +# 如果使用 Git +git clone backend +cd backend + +# 或者使用 scp 上传 +# 在本地执行: +# scp -r /Users/nongjun/Desktop/Ai公司/本地开发与测试/kaopeilian-backend root@120.79.247.16:/opt/kaopeilian/backend +``` + +### 4. 创建虚拟环境和安装依赖 + +```bash +cd /opt/kaopeilian/backend + +# 创建虚拟环境 +python3 -m venv venv + +# 激活虚拟环境 +source venv/bin/activate + +# 升级 pip +pip install --upgrade pip + +# 安装依赖 +pip install -r requirements.txt +``` + +### 5. 配置环境变量 + +创建 `.env` 文件: + +```bash +cat > .env << EOF +# 应用配置 +APP_NAME=KaoPeiLian +DEBUG=False +HOST=0.0.0.0 +PORT=8000 + +# 数据库配置 +DATABASE_URL=mysql+aiomysql://root:Kaopeilian2025!@#@localhost:3306/kaopeilian?charset=utf8mb4 + +# Redis配置(如果有) +REDIS_URL=redis://localhost:6379/0 + +# JWT配置 +SECRET_KEY=your-production-secret-key-at-least-32-chars +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# CORS配置 +CORS_ORIGINS=["http://120.79.247.16:8000", "http://aiedu.ireborn.com.cn", "http://localhost:3000"] + +# 日志配置 +LOG_LEVEL=INFO +LOG_FORMAT=json +EOF +``` + +### 6. 创建 systemd 服务 + +```bash +cat > /etc/systemd/system/kaopeilian-backend.service << EOF +[Unit] +Description=KaoPeiLian Backend API +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/kaopeilian/backend +Environment="PATH=/opt/kaopeilian/backend/venv/bin" +ExecStart=/opt/kaopeilian/backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +EOF +``` + +### 7. 启动服务 + +```bash +# 重新加载 systemd +systemctl daemon-reload + +# 启用服务(开机自启) +systemctl enable kaopeilian-backend + +# 启动服务 +systemctl start kaopeilian-backend + +# 查看服务状态 +systemctl status kaopeilian-backend + +# 查看日志 +journalctl -u kaopeilian-backend -f +``` + +### 8. 配置防火墙 + +```bash +# 如果使用 ufw +ufw allow 8000/tcp +ufw reload + +# 如果使用 iptables +iptables -A INPUT -p tcp --dport 8000 -j ACCEPT +iptables-save > /etc/iptables/rules.v4 +``` + +### 9. 测试部署 + +```bash +# 健康检查 +curl http://localhost:8000/health + +# 测试 SQL 执行器(简化认证) +curl -X POST http://localhost:8000/api/v1/sql/execute-simple \ + -H "X-API-Key: dify-2025-kaopeilian" \ + -H "Content-Type: application/json" \ + -d '{ + "sql": "SELECT COUNT(*) as total FROM users" + }' +``` + +## 更新部署 + +```bash +# 进入项目目录 +cd /opt/kaopeilian/backend + +# 拉取最新代码(如果使用 Git) +git pull + +# 或者重新上传文件 + +# 重启服务 +systemctl restart kaopeilian-backend +``` + +## 日志和监控 + +### 查看实时日志 +```bash +journalctl -u kaopeilian-backend -f +``` + +### 查看错误日志 +```bash +journalctl -u kaopeilian-backend -p err -n 100 +``` + +### 检查服务状态 +```bash +systemctl status kaopeilian-backend +``` + +## 故障排除 + +### 1. 服务无法启动 +```bash +# 检查 Python 路径 +which python3 + +# 检查虚拟环境 +ls -la /opt/kaopeilian/backend/venv/bin/ + +# 手动测试启动 +cd /opt/kaopeilian/backend +source venv/bin/activate +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +### 2. 数据库连接失败 +```bash +# 测试数据库连接 +mysql -h localhost -u root -p'Kaopeilian2025!@#' -e "SELECT 1" + +# 检查数据库服务 +systemctl status mysql +``` + +### 3. 端口被占用 +```bash +# 查看端口占用 +netstat -tlnp | grep 8000 + +# 或 +lsof -i :8000 +``` + +## 性能优化(可选) + +### 使用 Gunicorn + Uvicorn +```bash +# 安装 gunicorn +pip install gunicorn + +# 修改服务文件的 ExecStart +ExecStart=/opt/kaopeilian/backend/venv/bin/gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 +``` + +### 使用 Nginx 反向代理 +```bash +# 安装 Nginx +apt install nginx -y + +# 配置 Nginx +cat > /etc/nginx/sites-available/kaopeilian << EOF +server { + listen 80; + server_name aiedu.ireborn.com.cn; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + } +} +EOF + +# 启用配置 +ln -s /etc/nginx/sites-available/kaopeilian /etc/nginx/sites-enabled/ +nginx -t +systemctl restart nginx +``` + +## 安全建议 + +1. **修改默认 API Key**:编辑 `/opt/kaopeilian/backend/app/core/simple_auth.py` +2. **使用 HTTPS**:配置 SSL 证书 +3. **限制 IP 访问**:只允许 Dify 服务器访问 +4. **定期备份**:数据库和代码 +5. **监控日志**:及时发现异常 + +## Dify 配置 + +部署完成后,在 Dify 中使用: +- **URL**: http://120.79.247.16:8000/api/v1/sql/execute-simple +- **认证方式**: 请求头 +- **X-API-Key**: dify-2025-kaopeilian + + diff --git a/backend/docker-compose.dev.yml b/backend/docker-compose.dev.yml new file mode 100644 index 0000000..7d3f8f8 --- /dev/null +++ b/backend/docker-compose.dev.yml @@ -0,0 +1,59 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.0 + container_name: kaopeilian-mysql + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: kaopeilian + MYSQL_CHARACTER_SET: utf8mb4 + MYSQL_COLLATION: utf8mb4_unicode_ci + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql + command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + networks: + - kaopeilian-network + + redis: + image: redis:7-alpine + container_name: kaopeilian-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - kaopeilian-network + + backend: + build: + context: . + dockerfile: Dockerfile.dev + container_name: kaopeilian-backend + depends_on: + - mysql + - redis + environment: + DATABASE_URL: mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4 + REDIS_URL: redis://redis:6379/0 + ports: + - "8000:8000" + volumes: + - ./app:/app/app + - ./tests:/app/tests + - ./scripts:/app/scripts + - ./.env:/app/.env + command: uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + networks: + - kaopeilian-network + +volumes: + mysql_data: + redis_data: + +networks: + kaopeilian-network: + driver: bridge diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..33ffa72 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,46 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.0.43 + container_name: kaopeilian_mysql + restart: always + environment: + MYSQL_ROOT_PASSWORD: Kaopeilian2025!@# + MYSQL_DATABASE: kaopeilian + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./将调用工作流-联调过半-考陪练kaopeilian_final_complete_backup_20250923_025629.sql:/docker-entrypoint-initdb.d/init.sql + - ./mysql.cnf:/etc/mysql/conf.d/mysql.cnf + command: --default-authentication-plugin=mysql_native_password + networks: + - kaopeilian_network + + # 可选:phpMyAdmin管理界面 + phpmyadmin: + image: phpmyadmin/phpmyadmin:latest + container_name: kaopeilian_phpmyadmin + restart: always + environment: + PMA_HOST: mysql + PMA_PORT: 3306 + PMA_USER: root + PMA_PASSWORD: Kaopeilian2025!@# + ports: + - "8080:80" + depends_on: + - mysql + networks: + - kaopeilian_network + +volumes: + mysql_data: + driver: local + +networks: + kaopeilian_network: + driver: bridge \ No newline at end of file diff --git a/backend/docker/__init__.py b/backend/docker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/docker/mysql/conf.d/mysql-rollback.cnf b/backend/docker/mysql/conf.d/mysql-rollback.cnf new file mode 100644 index 0000000..e7c7de3 --- /dev/null +++ b/backend/docker/mysql/conf.d/mysql-rollback.cnf @@ -0,0 +1,46 @@ +# MySQL Binlog 回滚优化配置 +# 用于考培练系统的数据库回滚功能 + +[mysqld] +# Binlog 配置 - 确保回滚功能可用 +log-bin = mysql-bin +binlog_format = ROW +binlog_row_image = FULL +expire_logs_days = 7 +max_binlog_size = 100M + +# 事务配置 - 支持更好的回滚 +innodb_flush_log_at_trx_commit = 1 +sync_binlog = 1 + +# 字符集配置 +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci + +# 性能优化 +innodb_buffer_pool_size = 256M +innodb_log_file_size = 64M +innodb_log_buffer_size = 16M + +# 连接配置 +max_connections = 200 +wait_timeout = 28800 +interactive_timeout = 28800 + +# 查询缓存(MySQL 8.0已移除,保留注释) +# query_cache_type = 1 +# query_cache_size = 32M + +# 慢查询日志 +slow_query_log = 1 +slow_query_log_file = /var/log/mysql/slow.log +long_query_time = 2 + +# 错误日志 +log-error = /var/log/mysql/error.log + +# 二进制日志 +log-bin-trust-function-creators = 1 + +# 时区设置 +default-time-zone = '+08:00' diff --git a/backend/docker/nginx/__init__.py b/backend/docker/nginx/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/docs/__init__.py b/backend/docs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/docs/api/__init__.py b/backend/docs/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/docs/database_rollback_guide.md b/backend/docs/database_rollback_guide.md new file mode 100644 index 0000000..de5472c --- /dev/null +++ b/backend/docs/database_rollback_guide.md @@ -0,0 +1,227 @@ +# 考培练系统数据库回滚指南 + +## 概述 + +考培练系统支持基于MySQL Binlog的数据库回滚功能,可以快速恢复误操作导致的数据变更。本指南提供了完整的回滚操作流程和最佳实践。 + +## 回滚方案对比 + +| 方案 | 适用场景 | 优点 | 缺点 | 推荐度 | +|------|----------|------|------|--------| +| **Binlog回滚** | 精确时间点回滚 | 精确、完整 | 需要技术知识 | ⭐⭐⭐⭐⭐ | +| **软删除恢复** | 删除操作回滚 | 简单、安全 | 仅限软删除 | ⭐⭐⭐⭐ | +| **备份恢复** | 大规模回滚 | 完整恢复 | 可能丢失新数据 | ⭐⭐⭐ | +| **手动修复** | 小范围修复 | 灵活 | 容易出错 | ⭐⭐ | + +## 一、Binlog回滚(推荐) + +### 1.1 前提条件检查 + +```bash +# 检查Binlog是否启用 +docker exec kaopeilian-mysql mysql -uroot -proot -e "SHOW VARIABLES LIKE 'log_bin';" + +# 检查Binlog格式(推荐ROW格式) +docker exec kaopeilian-mysql mysql -uroot -proot -e "SHOW VARIABLES LIKE 'binlog_format';" + +# 查看可用的Binlog文件 +docker exec kaopeilian-mysql mysql -uroot -proot -e "SHOW BINARY LOGS;" +``` + +### 1.2 使用专用回滚工具 + +#### 查看最近变更 +```bash +cd /Users/nongjun/Desktop/Ai公司/本地开发与测试/kaopeilian-backend +python scripts/kaopeilian_rollback.py --list --hours 24 +``` + +#### 回滚用户操作 +```bash +# 模拟回滚(查看会执行什么操作) +python scripts/kaopeilian_rollback.py --rollback-user 123 --operation-type delete + +# 实际执行回滚 +python scripts/kaopeilian_rollback.py --rollback-user 123 --operation-type delete --execute +``` + +#### 回滚课程操作 +```bash +# 回滚课程删除 +python scripts/kaopeilian_rollback.py --rollback-course 456 --operation-type delete --execute + +# 回滚课程更新(需要手动处理) +python scripts/kaopeilian_rollback.py --rollback-course 456 --operation-type update +``` + +#### 回滚考试操作 +```bash +# 回滚考试记录(会同时删除考试和考试结果) +python scripts/kaopeilian_rollback.py --rollback-exam 789 --execute +``` + +### 1.3 使用简化回滚工具 + +#### 查看Binlog文件 +```bash +python scripts/simple_rollback.py --list +``` + +#### 基于时间点回滚 +```bash +# 模拟回滚到指定时间点 +python scripts/simple_rollback.py --time "2024-12-20 10:30:00" + +# 实际执行回滚 +python scripts/simple_rollback.py --time "2024-12-20 10:30:00" --execute +``` + +### 1.4 使用完整Binlog工具 + +```bash +# 查看帮助 +python scripts/binlog_rollback_tool.py --help + +# 列出Binlog文件 +python scripts/binlog_rollback_tool.py --list-binlogs + +# 回滚到指定时间点 +python scripts/binlog_rollback_tool.py --time "2024-12-20 10:30:00" --execute +``` + +## 二、软删除恢复 + +### 2.1 恢复用户 +```sql +-- 恢复软删除的用户 +UPDATE users SET is_deleted = FALSE, deleted_at = NULL WHERE id = 123; +``` + +### 2.2 恢复课程 +```sql +-- 恢复软删除的课程 +UPDATE courses SET is_deleted = FALSE, deleted_at = NULL WHERE id = 456; +``` + +### 2.3 恢复岗位 +```sql +-- 恢复软删除的岗位 +UPDATE positions SET is_deleted = FALSE, deleted_at = NULL WHERE id = 789; +``` + +## 三、备份恢复 + +### 3.1 创建完整备份 +```bash +# 创建数据库完整备份 +docker exec kaopeilian-mysql mysqldump -uroot -proot --single-transaction --routines --triggers kaopeilian > backup_$(date +%Y%m%d_%H%M%S).sql +``` + +### 3.2 恢复备份 +```bash +# 恢复数据库备份 +docker exec -i kaopeilian-mysql mysql -uroot -proot kaopeilian < backup_20241220_103000.sql +``` + +## 四、常见回滚场景 + +### 4.1 误删用户 +```bash +# 1. 查看最近删除的用户 +python scripts/kaopeilian_rollback.py --list --hours 1 + +# 2. 恢复软删除的用户 +python scripts/kaopeilian_rollback.py --rollback-user 123 --operation-type delete --execute +``` + +### 4.2 误删课程 +```bash +# 1. 恢复软删除的课程 +python scripts/kaopeilian_rollback.py --rollback-course 456 --operation-type delete --execute + +# 2. 恢复课程关联数据(如果需要) +# 手动执行SQL恢复课程资料、知识点等 +``` + +### 4.3 误删考试记录 +```bash +# 1. 恢复考试记录(会同时恢复考试结果) +python scripts/kaopeilian_rollback.py --rollback-exam 789 --execute +``` + +### 4.4 批量误操作 +```bash +# 1. 基于时间点回滚 +python scripts/simple_rollback.py --time "2024-12-20 10:30:00" --execute + +# 2. 或使用完整备份恢复 +docker exec -i kaopeilian-mysql mysql -uroot -proot kaopeilian < backup_before_operation.sql +``` + +## 五、最佳实践 + +### 5.1 回滚前准备 +1. **创建备份**:回滚前必须创建当前数据备份 +2. **确认时间点**:精确确定需要回滚到的时间点 +3. **评估影响**:评估回滚操作对系统的影响 +4. **通知用户**:必要时通知相关用户 + +### 5.2 回滚操作流程 +1. **停止服务**:停止可能影响数据的服务 +2. **创建备份**:备份当前状态 +3. **执行回滚**:使用合适的回滚工具 +4. **验证数据**:验证回滚结果 +5. **重启服务**:恢复服务运行 +6. **记录日志**:记录回滚操作日志 + +### 5.3 安全注意事项 +- 回滚操作不可逆,务必谨慎 +- 生产环境回滚前必须在测试环境验证 +- 重要操作需要多人确认 +- 保留回滚操作日志 + +## 六、故障排除 + +### 6.1 Binlog未启用 +```bash +# 检查MySQL配置 +docker exec kaopeilian-mysql mysql -uroot -proot -e "SHOW VARIABLES LIKE 'log_bin';" + +# 如果未启用,需要修改MySQL配置并重启 +``` + +### 6.2 Binlog文件过大 +```bash +# 清理旧的Binlog文件 +docker exec kaopeilian-mysql mysql -uroot -proot -e "PURGE BINARY LOGS BEFORE DATE_SUB(NOW(), INTERVAL 7 DAY);" +``` + +### 6.3 回滚工具执行失败 +1. 检查数据库连接 +2. 确认权限设置 +3. 查看错误日志 +4. 手动执行SQL语句 + +## 七、监控与预防 + +### 7.1 设置监控 +- 监控Binlog文件大小 +- 监控数据库操作日志 +- 设置异常操作告警 + +### 7.2 预防措施 +- 定期备份数据库 +- 设置操作权限控制 +- 实施操作审计 +- 提供操作确认机制 + +## 八、联系支持 + +如遇到回滚问题,请联系技术支持: +- 查看系统日志:`docker logs kaopeilian-mysql` +- 查看应用日志:`docker logs kaopeilian-backend` +- 提交问题报告:包含错误信息、操作步骤、时间点等 + +--- + +**重要提醒**:数据库回滚是高风险操作,请务必在充分理解操作影响的前提下执行,建议在测试环境先验证回滚方案的有效性。 diff --git a/backend/docs/deployment/__init__.py b/backend/docs/deployment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/docs/development/__init__.py b/backend/docs/development/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/docs/openapi_sql_executor.json b/backend/docs/openapi_sql_executor.json new file mode 100644 index 0000000..9825e1c --- /dev/null +++ b/backend/docs/openapi_sql_executor.json @@ -0,0 +1,664 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "KaoPeiLian SQL Executor API", + "description": "SQL 执行器 API,专门为 Dify 平台集成设计,支持对考陪练系统数据库执行查询和写入操作。\n\n## 主要功能\n- 执行 SQL 查询和写入操作\n- 支持参数化查询防止 SQL 注入\n- 获取数据库表列表和表结构\n- SQL 语句验证\n\n## 安全说明\n所有接口都需要 JWT Bearer Token 认证。请先通过登录接口获取访问令牌。", + "version": "1.0.0", + "contact": { + "name": "KaoPeiLian Tech Support", + "email": "support@kaopeilian.com" + } + }, + "servers": [ + { + "url": "http://120.79.247.16:8000/api/v1", + "description": "考陪练系统服务器" + }, + { + "url": "http://aiedu.ireborn.com.cn/api/v1", + "description": "域名访问" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/auth/login": { + "post": { + "tags": ["认证"], + "summary": "用户登录", + "description": "获取访问令牌,用于后续 API 调用", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + }, + "examples": { + "admin": { + "summary": "管理员登录", + "value": { + "username": "admin", + "password": "admin123" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "登录成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "401": { + "description": "用户名或密码错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/sql/execute": { + "post": { + "tags": ["SQL执行器"], + "summary": "执行 SQL 语句", + "description": "执行查询或写入 SQL 语句。\n\n**查询操作**: SELECT, SHOW, DESCRIBE\n**写入操作**: INSERT, UPDATE, DELETE, CREATE, ALTER, DROP\n\n支持参数化查询,使用 `:param_name` 格式定义参数。", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SqlExecuteRequest" + }, + "examples": { + "simpleQuery": { + "summary": "简单查询", + "value": { + "sql": "SELECT id, username, role FROM users LIMIT 5" + } + }, + "parameterizedQuery": { + "summary": "参数化查询", + "value": { + "sql": "SELECT * FROM courses WHERE category = :category AND status = :status", + "params": { + "category": "护肤", + "status": "active" + } + } + }, + "insertData": { + "summary": "插入数据", + "value": { + "sql": "INSERT INTO knowledge_points (title, content, course_id) VALUES (:title, :content, :course_id)", + "params": { + "title": "面部护理基础", + "content": "面部护理的基本步骤...", + "course_id": 1 + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "SQL 执行成功", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/QueryResponse" + }, + { + "$ref": "#/components/schemas/ExecuteResponse" + } + ] + }, + "examples": { + "queryResult": { + "summary": "查询结果", + "value": { + "code": 200, + "message": "SQL 执行成功", + "data": { + "type": "query", + "columns": ["id", "username", "role"], + "rows": [ + { + "id": 1, + "username": "admin", + "role": "admin" + }, + { + "id": 2, + "username": "user1", + "role": "trainee" + } + ], + "row_count": 2 + } + } + }, + "executeResult": { + "summary": "写入结果", + "value": { + "code": 200, + "message": "SQL 执行成功", + "data": { + "type": "execute", + "affected_rows": 1, + "success": true + } + } + } + } + } + } + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "未认证或认证失败", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "SQL 执行错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/sql/validate": { + "post": { + "tags": ["SQL执行器"], + "summary": "验证 SQL 语法", + "description": "验证 SQL 语句的语法正确性,不执行实际操作", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SqlValidateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "验证完成", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateResponse" + } + } + } + } + } + } + }, + "/sql/tables": { + "get": { + "tags": ["SQL执行器"], + "summary": "获取表列表", + "description": "获取数据库中所有表的列表", + "responses": { + "200": { + "description": "成功获取表列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TablesResponse" + } + } + } + }, + "401": { + "description": "未认证", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/sql/table/{table_name}/schema": { + "get": { + "tags": ["SQL执行器"], + "summary": "获取表结构", + "description": "获取指定表的结构信息,包括字段名、类型、约束等", + "parameters": [ + { + "name": "table_name", + "in": "path", + "required": true, + "description": "表名", + "schema": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" + }, + "example": "users" + } + ], + "responses": { + "200": { + "description": "成功获取表结构", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableSchemaResponse" + } + } + } + }, + "400": { + "description": "无效的表名", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "未认证", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "使用登录接口返回的 access_token。\n格式: Bearer {access_token}" + } + }, + "schemas": { + "LoginRequest": { + "type": "object", + "required": ["username", "password"], + "properties": { + "username": { + "type": "string", + "description": "用户名", + "example": "admin" + }, + "password": { + "type": "string", + "format": "password", + "description": "密码", + "example": "admin123" + } + } + }, + "LoginResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "message": { + "type": "string", + "example": "登录成功" + }, + "data": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "username": { + "type": "string", + "example": "admin" + }, + "role": { + "type": "string", + "example": "admin" + } + } + }, + "token": { + "type": "object", + "properties": { + "access_token": { + "type": "string", + "description": "JWT 访问令牌", + "example": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." + }, + "token_type": { + "type": "string", + "example": "bearer" + }, + "expires_in": { + "type": "integer", + "description": "过期时间(秒)", + "example": 1800 + } + } + } + } + } + } + }, + "SqlExecuteRequest": { + "type": "object", + "required": ["sql"], + "properties": { + "sql": { + "type": "string", + "description": "要执行的 SQL 语句", + "example": "SELECT * FROM users WHERE role = :role" + }, + "params": { + "type": "object", + "description": "SQL 参数字典,键为参数名,值为参数值", + "additionalProperties": true, + "example": { + "role": "admin" + } + } + } + }, + "SqlValidateRequest": { + "type": "object", + "required": ["sql"], + "properties": { + "sql": { + "type": "string", + "description": "要验证的 SQL 语句", + "example": "SELECT * FROM users" + } + } + }, + "QueryResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "message": { + "type": "string", + "example": "SQL 执行成功" + }, + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["query"], + "example": "query" + }, + "columns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "列名数组", + "example": ["id", "username", "role"] + }, + "rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + }, + "description": "查询结果行" + }, + "row_count": { + "type": "integer", + "description": "返回的行数", + "example": 5 + } + } + } + } + }, + "ExecuteResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "message": { + "type": "string", + "example": "SQL 执行成功" + }, + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["execute"], + "example": "execute" + }, + "affected_rows": { + "type": "integer", + "description": "影响的行数", + "example": 1 + }, + "success": { + "type": "boolean", + "example": true + } + } + } + } + }, + "ValidateResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "message": { + "type": "string", + "example": "SQL 验证完成" + }, + "data": { + "type": "object", + "properties": { + "valid": { + "type": "boolean", + "description": "SQL 是否有效", + "example": true + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "警告信息列表", + "example": ["包含危险操作: DROP"] + }, + "sql_type": { + "type": "string", + "description": "SQL 类型", + "example": "SELECT" + } + } + } + } + }, + "TablesResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "message": { + "type": "string", + "example": "获取表列表成功" + }, + "data": { + "type": "object", + "properties": { + "tables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "表名列表", + "example": ["users", "courses", "exams"] + }, + "count": { + "type": "integer", + "description": "表的数量", + "example": 20 + } + } + } + } + }, + "TableSchemaResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "message": { + "type": "string", + "example": "获取表结构成功" + }, + "data": { + "type": "object", + "properties": { + "table_name": { + "type": "string", + "example": "users" + }, + "columns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "字段名", + "example": "id" + }, + "type": { + "type": "string", + "description": "字段类型", + "example": "int(11)" + }, + "null": { + "type": "string", + "enum": ["YES", "NO"], + "description": "是否可为空", + "example": "NO" + }, + "key": { + "type": "string", + "description": "键类型(PRI, UNI, MUL)", + "example": "PRI" + }, + "default": { + "type": "string", + "nullable": true, + "description": "默认值", + "example": null + }, + "extra": { + "type": "string", + "description": "额外信息", + "example": "auto_increment" + } + } + } + }, + "column_count": { + "type": "integer", + "description": "列的数量", + "example": 10 + } + } + } + } + }, + "ErrorResponse": { + "type": "object", + "properties": { + "detail": { + "type": "string", + "description": "错误详情", + "example": "SQL 执行失败: You have an error in your SQL syntax" + } + } + } + } + }, + "tags": [ + { + "name": "认证", + "description": "用户认证相关接口" + }, + { + "name": "SQL执行器", + "description": "SQL 执行和管理相关接口" + } + ] +} diff --git a/backend/docs/openapi_sql_executor.yaml b/backend/docs/openapi_sql_executor.yaml new file mode 100644 index 0000000..20dae5e --- /dev/null +++ b/backend/docs/openapi_sql_executor.yaml @@ -0,0 +1,568 @@ +openapi: 3.1.0 +info: + title: KaoPeiLian SQL Executor API + description: | + SQL 执行器 API,专门为 Dify 平台集成设计,支持对考陪练系统数据库执行查询和写入操作。 + + ## 主要功能 + - 执行 SQL 查询和写入操作 + - 支持参数化查询防止 SQL 注入 + - 获取数据库表列表和表结构 + - SQL 语句验证 + + ## 安全说明 + 所有接口都需要 JWT Bearer Token 认证。请先通过登录接口获取访问令牌。 + version: 1.0.0 + contact: + name: KaoPeiLian Tech Support + email: support@kaopeilian.com + +servers: + - url: http://120.79.247.16:8000/api/v1 + description: 考陪练系统服务器 + - url: http://localhost:8000/api/v1 + description: 本地开发服务器 + - url: http://aiedu.ireborn.com.cn/api/v1 + description: 域名访问 + +security: + - bearerAuth: [] + +paths: + /auth/login: + post: + tags: + - 认证 + summary: 用户登录 + description: 获取访问令牌,用于后续 API 调用 + security: [] # 登录接口不需要认证 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + examples: + admin: + summary: 管理员登录 + value: + username: admin + password: admin123 + responses: + '200': + description: 登录成功 + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '401': + description: 用户名或密码错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /sql/execute-simple: + post: + tags: + - SQL执行器-简化认证 + summary: 执行 SQL 语句(简化认证版) + description: | + 执行查询或写入 SQL 语句,使用简化的认证方式。 + + **认证方式(二选一)**: + 1. API Key: X-API-Key: dify-2025-kaopeilian + 2. 长期 Token: Authorization: Bearer permanent-token-for-dify-2025 + + **查询操作**: SELECT, SHOW, DESCRIBE + **写入操作**: INSERT, UPDATE, DELETE, CREATE, ALTER, DROP + + 支持参数化查询,使用 `:param_name` 格式定义参数。 + security: + - apiKey: [] + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SqlExecuteRequest' + examples: + simpleQuery: + summary: 简单查询 + value: + sql: "SELECT id, username, role FROM users LIMIT 5" + parameterizedQuery: + summary: 参数化查询 + value: + sql: "SELECT * FROM courses WHERE category = :category AND status = :status" + params: + category: "护肤" + status: "active" + insertData: + summary: 插入数据 + value: + sql: "INSERT INTO knowledge_points (title, content, course_id) VALUES (:title, :content, :course_id)" + params: + title: "面部护理基础" + content: "面部护理的基本步骤..." + course_id: 1 + responses: + '200': + description: SQL 执行成功 + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/QueryResponse' + - $ref: '#/components/schemas/ExecuteResponse' + '401': + description: 未认证或认证失败 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: SQL 执行错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /sql/execute: + post: + tags: + - SQL执行器 + summary: 执行 SQL 语句(标准认证版) + description: | + 执行查询或写入 SQL 语句。 + + **查询操作**: SELECT, SHOW, DESCRIBE + **写入操作**: INSERT, UPDATE, DELETE, CREATE, ALTER, DROP + + 支持参数化查询,使用 `:param_name` 格式定义参数。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SqlExecuteRequest' + examples: + simpleQuery: + summary: 简单查询 + value: + sql: "SELECT id, username, role FROM users LIMIT 5" + parameterizedQuery: + summary: 参数化查询 + value: + sql: "SELECT * FROM courses WHERE category = :category AND status = :status" + params: + category: "护肤" + status: "active" + insertData: + summary: 插入数据 + value: + sql: "INSERT INTO knowledge_points (title, content, course_id) VALUES (:title, :content, :course_id)" + params: + title: "面部护理基础" + content: "面部护理的基本步骤..." + course_id: 1 + responses: + '200': + description: SQL 执行成功 + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/QueryResponse' + - $ref: '#/components/schemas/ExecuteResponse' + examples: + queryResult: + summary: 查询结果 + value: + code: 200 + message: "SQL 执行成功" + data: + type: "query" + columns: ["id", "username", "role"] + rows: + - id: 1 + username: "admin" + role: "admin" + - id: 2 + username: "user1" + role: "trainee" + row_count: 2 + executeResult: + summary: 写入结果 + value: + code: 200 + message: "SQL 执行成功" + data: + type: "execute" + affected_rows: 1 + success: true + '400': + description: 请求参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: 未认证或认证失败 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: SQL 执行错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /sql/validate: + post: + tags: + - SQL执行器 + summary: 验证 SQL 语法 + description: 验证 SQL 语句的语法正确性,不执行实际操作 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SqlValidateRequest' + responses: + '200': + description: 验证完成 + content: + application/json: + schema: + $ref: '#/components/schemas/ValidateResponse' + + /sql/tables: + get: + tags: + - SQL执行器 + summary: 获取表列表 + description: 获取数据库中所有表的列表 + responses: + '200': + description: 成功获取表列表 + content: + application/json: + schema: + $ref: '#/components/schemas/TablesResponse' + '401': + description: 未认证 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /sql/table/{table_name}/schema: + get: + tags: + - SQL执行器 + summary: 获取表结构 + description: 获取指定表的结构信息,包括字段名、类型、约束等 + parameters: + - name: table_name + in: path + required: true + description: 表名 + schema: + type: string + pattern: '^[a-zA-Z_][a-zA-Z0-9_]*$' + example: users + responses: + '200': + description: 成功获取表结构 + content: + application/json: + schema: + $ref: '#/components/schemas/TableSchemaResponse' + '400': + description: 无效的表名 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: 未认证 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + 使用登录接口返回的 access_token。 + 格式: Bearer {access_token} + apiKey: + type: apiKey + in: header + name: X-API-Key + description: | + API Key 认证,适用于内部服务调用。 + 示例: X-API-Key: dify-2025-kaopeilian + + schemas: + LoginRequest: + type: object + required: + - username + - password + properties: + username: + type: string + description: 用户名 + example: admin + password: + type: string + format: password + description: 密码 + example: admin123 + + LoginResponse: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: 登录成功 + data: + type: object + properties: + user: + type: object + properties: + id: + type: integer + example: 1 + username: + type: string + example: admin + role: + type: string + example: admin + token: + type: object + properties: + access_token: + type: string + description: JWT 访问令牌 + example: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... + token_type: + type: string + example: bearer + expires_in: + type: integer + description: 过期时间(秒) + example: 1800 + + SqlExecuteRequest: + type: object + required: + - sql + properties: + sql: + type: string + description: 要执行的 SQL 语句 + example: "SELECT * FROM users WHERE role = :role" + params: + type: object + description: SQL 参数字典,键为参数名,值为参数值 + additionalProperties: true + example: + role: admin + + SqlValidateRequest: + type: object + required: + - sql + properties: + sql: + type: string + description: 要验证的 SQL 语句 + example: "SELECT * FROM users" + + QueryResponse: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: SQL 执行成功 + data: + type: object + properties: + type: + type: string + enum: [query] + example: query + columns: + type: array + items: + type: string + description: 列名数组 + example: ["id", "username", "role"] + rows: + type: array + items: + type: object + additionalProperties: true + description: 查询结果行 + row_count: + type: integer + description: 返回的行数 + example: 5 + + ExecuteResponse: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: SQL 执行成功 + data: + type: object + properties: + type: + type: string + enum: [execute] + example: execute + affected_rows: + type: integer + description: 影响的行数 + example: 1 + success: + type: boolean + example: true + + ValidateResponse: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: SQL 验证完成 + data: + type: object + properties: + valid: + type: boolean + description: SQL 是否有效 + example: true + warnings: + type: array + items: + type: string + description: 警告信息列表 + example: ["包含危险操作: DROP"] + sql_type: + type: string + description: SQL 类型 + example: SELECT + + TablesResponse: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: 获取表列表成功 + data: + type: object + properties: + tables: + type: array + items: + type: string + description: 表名列表 + example: ["users", "courses", "exams"] + count: + type: integer + description: 表的数量 + example: 20 + + TableSchemaResponse: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: 获取表结构成功 + data: + type: object + properties: + table_name: + type: string + example: users + columns: + type: array + items: + type: object + properties: + field: + type: string + description: 字段名 + example: id + type: + type: string + description: 字段类型 + example: int(11) + null: + type: string + enum: ["YES", "NO"] + description: 是否可为空 + example: NO + key: + type: string + description: 键类型(PRI, UNI, MUL) + example: PRI + default: + type: string + nullable: true + description: 默认值 + example: null + extra: + type: string + description: 额外信息 + example: auto_increment + column_count: + type: integer + description: 列的数量 + example: 10 + + ErrorResponse: + type: object + properties: + detail: + type: string + description: 错误详情 + example: SQL 执行失败: You have an error in your SQL syntax + +tags: + - name: 认证 + description: 用户认证相关接口 + - name: SQL执行器 + description: SQL 执行和管理相关接口 diff --git a/backend/docs/sql_executor_checklist.md b/backend/docs/sql_executor_checklist.md new file mode 100644 index 0000000..61ab0a9 --- /dev/null +++ b/backend/docs/sql_executor_checklist.md @@ -0,0 +1,124 @@ +# SQL 执行器 API 完成清单 + +## ✅ 已完成功能 + +### 1. API 开发 +- [x] 创建 `/api/v1/sql/execute` - 标准认证版本 +- [x] 创建 `/api/v1/sql/execute-simple` - 简化认证版本 +- [x] 创建 `/api/v1/sql/validate` - SQL 验证 +- [x] 创建 `/api/v1/sql/tables` - 获取表列表 +- [x] 创建 `/api/v1/sql/table/{name}/schema` - 获取表结构 + +### 2. 认证方式 +- [x] JWT Bearer Token(标准版) +- [x] API Key 认证(X-API-Key: dify-2025-kaopeilian) +- [x] 长期 Token(Bearer permanent-token-for-dify-2025) + +### 3. 安全特性 +- [x] 参数化查询支持 +- [x] SQL 操作日志记录 +- [x] 危险操作警告 +- [x] 事务自动回滚 + +### 4. 文档 +- [x] OpenAPI 3.1 规范(YAML) +- [x] OpenAPI 3.1 规范(JSON) +- [x] Dify 使用指南 +- [x] 服务器部署指南 +- [x] 快速部署脚本 +- [x] 集成总结文档 + +### 5. 测试 +- [x] 本地测试脚本 +- [x] API Key 认证测试通过 +- [x] 长期 Token 认证测试通过 +- [x] 查询操作测试通过 +- [x] 写入操作测试通过 + +## 📋 Dify 配置步骤 + +### 方式一:导入 OpenAPI(推荐) +1. 在 Dify 中选择"导入 OpenAPI" +2. 上传 `openapi_sql_executor.yaml` 或 `.json` +3. 选择服务器:120.79.247.16:8000 +4. 配置认证(见下方) + +### 方式二:手动配置 +1. **URL**: http://120.79.247.16:8000/api/v1/sql/execute-simple +2. **方法**: POST +3. **认证配置**: + - 类型: 请求头 + - 前缀: Custom + - 键: X-API-Key + - 值: dify-2025-kaopeilian + +## 🚀 部署检查 + +### 本地环境 +- [x] 服务正常运行 +- [x] 数据库连接正常 +- [x] API 响应正常 + +### 服务器环境(待部署) +- [ ] 上传代码到服务器 +- [ ] 运行部署脚本 +- [ ] 配置防火墙 +- [ ] 测试公网访问 + +## 📊 数据库信息 + +- **主机**: 120.79.247.16 +- **端口**: 3306 +- **数据库**: kaopeilian +- **用户**: root +- **密码**: Kaopeilian2025!@# + +## 🔧 常用命令 + +### 本地测试 +```bash +# 测试 API Key +curl -X POST http://localhost:8000/api/v1/sql/execute-simple \ + -H "X-API-Key: dify-2025-kaopeilian" \ + -H "Content-Type: application/json" \ + -d '{"sql": "SELECT COUNT(*) FROM users"}' +``` + +### 服务器部署 +```bash +# 连接服务器 +ssh root@120.79.247.16 + +# 运行部署脚本 +bash /opt/kaopeilian/backend/deploy/quick_deploy.sh +``` + +## 📝 下一步行动 + +1. **部署到服务器** + - 上传代码 + - 运行部署脚本 + - 测试公网访问 + +2. **在 Dify 中配置** + - 导入 OpenAPI 文档 + - 配置认证 + - 创建工作流 + +3. **监控和维护** + - 设置日志监控 + - 定期备份 + - 性能优化 + +## ⚠️ 注意事项 + +1. API Key 是硬编码的,生产环境建议从环境变量读取 +2. 确保服务器防火墙开放 8000 端口 +3. 建议使用 HTTPS 加密传输 +4. 定期更新 API Key 和 Token + +--- + +**状态**: 开发完成,待部署到生产环境 + + diff --git a/backend/examples/coze_integration_example.py b/backend/examples/coze_integration_example.py new file mode 100644 index 0000000..24e617c --- /dev/null +++ b/backend/examples/coze_integration_example.py @@ -0,0 +1,140 @@ +""" +Coze 集成示例 +演示如何使用 Coze 服务 +""" + +import asyncio +import os +from datetime import datetime + +# 设置环境变量(实际使用时应通过 .env 文件或环境配置) +os.environ.update({ + "COZE_API_BASE": "https://api.coze.cn", + "COZE_WORKSPACE_ID": "your_workspace_id", + "COZE_API_TOKEN": "your_api_token", + "COZE_CHAT_BOT_ID": "7489905095250526219", + "COZE_TRAINING_BOT_ID": "7494965200370581538", + "COZE_EXAM_BOT_ID": "7492288561950195724" +}) + +from app.services.ai.coze import ( + get_coze_service, + CreateSessionRequest, + SendMessageRequest, + EndSessionRequest, + SessionType +) + + +async def demo_course_chat(): + """演示课程对话功能""" + print("\n=== 课程对话示例 ===") + + service = get_coze_service() + + # 1. 创建会话 + create_request = CreateSessionRequest( + session_type=SessionType.COURSE_CHAT, + user_id="demo-user-123", + course_id="python-basics-101", + metadata={"course_name": "Python 基础教程"} + ) + + session_response = await service.create_session(create_request) + print(f"创建会话成功: {session_response.session_id}") + + # 2. 发送消息 + message_request = SendMessageRequest( + session_id=session_response.session_id, + content="请解释一下 Python 的装饰器是什么?", + stream=True + ) + + print("\n助手回复:") + async for event in service.send_message(message_request): + if event.event.value == "message_delta": + print(event.content, end="", flush=True) + elif event.event.value == "message_completed": + print(f"\n\n[消息完成] Token 使用: {event.data.get('usage', {})}") + + # 3. 获取历史消息 + messages = await service.get_session_messages(session_response.session_id) + print(f"\n会话消息数: {len(messages)}") + + +async def demo_training_session(): + """演示陪练会话功能""" + print("\n\n=== 陪练会话示例 ===") + + service = get_coze_service() + + # 1. 创建陪练会话 + create_request = CreateSessionRequest( + session_type=SessionType.TRAINING, + user_id="demo-user-456", + training_topic="客诉处理技巧", + metadata={"difficulty": "intermediate"} + ) + + session_response = await service.create_session(create_request) + print(f"创建陪练会话成功: {session_response.session_id}") + + # 2. 进行多轮对话 + scenarios = [ + "客户抱怨产品质量有问题,要求退款", + "我会先向客户道歉,然后了解具体问题", + "客户说产品使用一周就坏了,非常生气" + ] + + for i, content in enumerate(scenarios): + print(f"\n--- 第 {i+1} 轮对话 ---") + print(f"用户: {content}") + + message_request = SendMessageRequest( + session_id=session_response.session_id, + content=content, + stream=False # 非流式响应 + ) + + print("助手: ", end="") + async for event in service.send_message(message_request): + if event.event.value == "message_completed": + print(event.content) + + # 模拟思考时间 + await asyncio.sleep(1) + + # 3. 结束会话 + end_request = EndSessionRequest( + reason="练习完成", + feedback={ + "rating": 5, + "helpful": True, + "comment": "模拟场景很真实,学到了很多处理技巧" + } + ) + + end_response = await service.end_session(session_response.session_id, end_request) + print(f"\n会话结束:") + print(f"- 时长: {end_response.duration_seconds} 秒") + print(f"- 消息数: {end_response.message_count}") + + +async def main(): + """运行所有示例""" + try: + # 运行课程对话示例 + await demo_course_chat() + + # 运行陪练会话示例 + await demo_training_session() + + except Exception as e: + print(f"\n错误: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + # 运行示例 + asyncio.run(main()) diff --git a/backend/insert_test_logs.sql b/backend/insert_test_logs.sql new file mode 100644 index 0000000..2f7a175 --- /dev/null +++ b/backend/insert_test_logs.sql @@ -0,0 +1,27 @@ +-- 插入测试系统日志数据 + +INSERT INTO `system_logs` (`level`, `type`, `user`, `user_id`, `ip`, `message`, `user_agent`, `path`, `method`, `extra_data`) +VALUES +('info', 'system', 'system', NULL, NULL, '系统启动成功', NULL, NULL, NULL, NULL), +('info', 'user', 'admin', 1, '192.168.1.100', '管理员登录系统', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '/api/v1/auth/login', 'POST', NULL), +('info', 'api', 'admin', 1, '192.168.1.100', '查询用户列表', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '/api/v1/users', 'GET', NULL), +('warning', 'security', 'unknown', NULL, '192.168.1.200', '尝试访问未授权资源', 'Mozilla/5.0 (Macintosh; Intel Mac OS X)', '/api/v1/admin/users', 'GET', NULL), +('error', 'error', 'manager1', 2, '192.168.1.101', '数据库连接超时', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '/api/v1/courses', 'GET', '{"error": "Connection timeout"}'), +('info', 'user', 'trainee1', 3, '192.168.1.102', '学员开始考试', 'Mozilla/5.0 (iPhone; CPU iPhone OS)', '/api/v1/exams/start', 'POST', NULL), +('info', 'user', 'trainee1', 3, '192.168.1.102', '学员提交考试答案', 'Mozilla/5.0 (iPhone; CPU iPhone OS)', '/api/v1/exams/submit', 'POST', NULL), +('warning', 'api', 'trainee2', 4, '192.168.1.103', 'API请求频率过高', 'Mozilla/5.0 (Android)', '/api/v1/courses', 'GET', '{"rate_limit": "exceeded"}'), +('error', 'error', 'system', NULL, NULL, '文件上传失败:磁盘空间不足', NULL, '/api/v1/upload', 'POST', '{"error": "Disk full"}'), +('info', 'system', 'system', NULL, NULL, '定时任务执行:清理过期数据', NULL, NULL, NULL, NULL), +('debug', 'api', 'admin', 1, '192.168.1.100', 'SQL查询: SELECT * FROM users', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '/api/v1/users', 'GET', NULL), +('info', 'user', 'manager1', 2, '192.168.1.101', '创建新课程:《皮肤管理基础》', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '/api/v1/courses', 'POST', NULL), +('error', 'security', 'hacker', NULL, '192.168.1.250', 'SQL注入攻击尝试被拦截', 'curl/7.68.0', '/api/v1/auth/login', 'POST', '{"blocked": true}'), +('warning', 'api', 'trainee3', 5, '192.168.1.104', 'API响应时间超过3秒', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '/api/v1/practice/sessions', 'GET', '{"response_time": 3.2}'), +('info', 'user', 'admin', 1, '192.168.1.100', '批量导入学员数据:成功50条', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '/api/v1/admin/import', 'POST', NULL), +('info', 'system', 'system', NULL, NULL, '数据库备份完成', NULL, NULL, NULL, '{"backup_file": "backup_20241016.sql"}'), +('error', 'error', 'trainee4', 6, '192.168.1.105', '视频播放失败:资源不存在', 'Mozilla/5.0 (iPad; CPU OS)', '/api/v1/courses/1/video', 'GET', '{"error": "Resource not found"}'), +('info', 'user', 'manager1', 2, '192.168.1.101', '发布课程公告', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '/api/v1/courses/1/announcements', 'POST', NULL), +('debug', 'api', 'system', NULL, NULL, '缓存刷新:courses_list', NULL, '/internal/cache/refresh', 'POST', NULL), +('warning', 'security', 'unknown', NULL, '192.168.1.201', '密码错误超过5次,账户已锁定', 'Mozilla/5.0 (Linux; Android)', '/api/v1/auth/login', 'POST', '{"locked": true, "user": "test@example.com"}'); + + + diff --git a/backend/migrations/__init__.py b/backend/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/migrations/add_broadcast_fields.sql b/backend/migrations/add_broadcast_fields.sql new file mode 100644 index 0000000..7c5fb26 --- /dev/null +++ b/backend/migrations/add_broadcast_fields.sql @@ -0,0 +1,20 @@ +-- 添加播课功能相关字段到 courses 表 +-- 日期: 2025-10-14 + +USE kaopeilian; + +-- 添加播课音频URL字段 +ALTER TABLE courses +ADD COLUMN broadcast_audio_url VARCHAR(500) NULL COMMENT '播课音频URL'; + +-- 添加播课生成时间字段 +ALTER TABLE courses +ADD COLUMN broadcast_generated_at DATETIME NULL COMMENT '播课生成时间'; + +-- 验证字段添加 +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'kaopeilian' + AND TABLE_NAME = 'courses' + AND COLUMN_NAME IN ('broadcast_audio_url', 'broadcast_generated_at'); + diff --git a/backend/migrations/add_broadcast_status_fields.sql b/backend/migrations/add_broadcast_status_fields.sql new file mode 100644 index 0000000..a418da5 --- /dev/null +++ b/backend/migrations/add_broadcast_status_fields.sql @@ -0,0 +1,24 @@ +-- 添加播课异步生成状态管理字段 +-- 日期: 2025-10-14 + +USE kaopeilian; + +-- 添加播课生成状态字段 +ALTER TABLE courses +ADD COLUMN broadcast_status VARCHAR(20) NULL COMMENT '播课生成状态: pending/generating/completed/failed'; + +-- 添加Coze工作流任务ID字段 +ALTER TABLE courses +ADD COLUMN broadcast_task_id VARCHAR(100) NULL COMMENT 'Coze工作流任务ID'; + +-- 添加错误信息字段 +ALTER TABLE courses +ADD COLUMN broadcast_error_message TEXT NULL COMMENT '生成失败错误信息'; + +-- 验证字段添加 +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'kaopeilian' + AND TABLE_NAME = 'courses' + AND COLUMN_NAME IN ('broadcast_status', 'broadcast_task_id', 'broadcast_error_message'); + diff --git a/backend/migrations/add_course_allow_download.sql b/backend/migrations/add_course_allow_download.sql new file mode 100644 index 0000000..67c0ba2 --- /dev/null +++ b/backend/migrations/add_course_allow_download.sql @@ -0,0 +1,25 @@ +-- 迁移脚本:为课程表添加资料下载开关字段 +-- 创建时间:2025-12-17 +-- 功能描述:添加 allow_download 字段,用于控制学员是否可以下载课程资料 + +-- 检查并添加 allow_download 字段 +ALTER TABLE courses +ADD COLUMN IF NOT EXISTS allow_download TINYINT(1) NOT NULL DEFAULT 0 +COMMENT '是否允许下载资料:0=不允许,1=允许'; + +-- 验证字段是否添加成功 +SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'courses' + AND COLUMN_NAME = 'allow_download'; + + + + + + + + + + diff --git a/backend/migrations/admin_platform_schema.sql b/backend/migrations/admin_platform_schema.sql new file mode 100644 index 0000000..10c5d23 --- /dev/null +++ b/backend/migrations/admin_platform_schema.sql @@ -0,0 +1,298 @@ +-- ============================================ +-- 考培练系统 SaaS 超级管理后台数据库架构 +-- 数据库名:kaopeilian_admin +-- 创建日期:2026-01-18 +-- ============================================ + +-- 创建数据库 +CREATE DATABASE IF NOT EXISTS kaopeilian_admin +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci; + +USE kaopeilian_admin; + +-- ============================================ +-- 1. 平台管理员表 (admin_users) +-- ============================================ +CREATE TABLE IF NOT EXISTS `admin_users` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', + `email` VARCHAR(100) UNIQUE COMMENT '邮箱', + `phone` VARCHAR(20) UNIQUE COMMENT '手机号', + `password_hash` VARCHAR(200) NOT NULL COMMENT '密码哈希', + `full_name` VARCHAR(100) COMMENT '姓名', + `avatar_url` VARCHAR(500) COMMENT '头像URL', + `role` VARCHAR(20) DEFAULT 'admin' COMMENT '角色: superadmin, admin, viewer', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否激活', + `last_login_at` DATETIME COMMENT '最后登录时间', + `last_login_ip` VARCHAR(50) COMMENT '最后登录IP', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_role (role), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台管理员表'; + +-- ============================================ +-- 2. 租户表 (tenants) +-- ============================================ +CREATE TABLE IF NOT EXISTS `tenants` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `code` VARCHAR(20) NOT NULL UNIQUE COMMENT '租户编码(如:hua, yy, hl)', + `name` VARCHAR(100) NOT NULL COMMENT '租户名称', + `display_name` VARCHAR(200) COMMENT '显示名称(如:华尔倍丽-考培练系统)', + `domain` VARCHAR(200) NOT NULL COMMENT '域名(如:hua.ireborn.com.cn)', + `logo_url` VARCHAR(500) COMMENT 'Logo URL', + `favicon_url` VARCHAR(500) COMMENT 'Favicon URL', + `contact_name` VARCHAR(50) COMMENT '联系人', + `contact_phone` VARCHAR(20) COMMENT '联系电话', + `contact_email` VARCHAR(100) COMMENT '联系邮箱', + `industry` VARCHAR(50) DEFAULT 'medical_beauty' COMMENT '行业:medical_beauty, pet, education', + `status` VARCHAR(20) DEFAULT 'active' COMMENT '状态:active, inactive, suspended', + `expire_at` DATE COMMENT '服务到期日期', + `remarks` TEXT COMMENT '备注', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL, + FOREIGN KEY (updated_by) REFERENCES admin_users(id) ON DELETE SET NULL, + INDEX idx_code (code), + INDEX idx_status (status), + INDEX idx_domain (domain) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='租户表'; + +-- ============================================ +-- 3. 租户配置表 (tenant_configs) +-- Key-Value 形式存储各类配置 +-- ============================================ +CREATE TABLE IF NOT EXISTS `tenant_configs` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `tenant_id` INT NOT NULL COMMENT '租户ID', + `config_group` VARCHAR(50) NOT NULL COMMENT '配置分组:database, redis, dify, coze, ai, yanji, security', + `config_key` VARCHAR(100) NOT NULL COMMENT '配置键', + `config_value` TEXT COMMENT '配置值', + `value_type` VARCHAR(20) DEFAULT 'string' COMMENT '值类型:string, int, bool, json, secret', + `is_encrypted` BOOLEAN DEFAULT FALSE COMMENT '是否加密存储', + `description` VARCHAR(500) COMMENT '配置说明', + `is_required` BOOLEAN DEFAULT FALSE COMMENT '是否必填', + `default_value` TEXT COMMENT '默认值', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + UNIQUE KEY uk_tenant_group_key (tenant_id, config_group, config_key), + INDEX idx_tenant_id (tenant_id), + INDEX idx_config_group (config_group) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='租户配置表'; + +-- ============================================ +-- 4. AI 提示词模板表 (ai_prompts) +-- ============================================ +CREATE TABLE IF NOT EXISTS `ai_prompts` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `code` VARCHAR(50) NOT NULL COMMENT '提示词编码(如:knowledge_analysis, exam_generator)', + `name` VARCHAR(100) NOT NULL COMMENT '提示词名称', + `description` TEXT COMMENT '提示词说明', + `module` VARCHAR(50) NOT NULL COMMENT '所属模块:course, exam, practice, ability', + `system_prompt` TEXT NOT NULL COMMENT '系统提示词', + `user_prompt_template` TEXT COMMENT '用户提示词模板', + `variables` JSON COMMENT '变量列表(如:["course_name", "content"])', + `output_schema` JSON COMMENT '输出 JSON Schema', + `model_recommendation` VARCHAR(100) COMMENT '推荐模型', + `max_tokens` INT DEFAULT 4096 COMMENT '最大 token 数', + `temperature` DECIMAL(3,2) DEFAULT 0.70 COMMENT '温度参数', + `is_system` BOOLEAN DEFAULT TRUE COMMENT '是否系统内置(内置不可删除)', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `version` INT DEFAULT 1 COMMENT '当前版本号', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL, + FOREIGN KEY (updated_by) REFERENCES admin_users(id) ON DELETE SET NULL, + UNIQUE KEY uk_code (code), + INDEX idx_module (module), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI提示词模板表'; + +-- ============================================ +-- 5. AI 提示词版本历史表 (ai_prompt_versions) +-- ============================================ +CREATE TABLE IF NOT EXISTS `ai_prompt_versions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `prompt_id` INT NOT NULL COMMENT '提示词ID', + `version` INT NOT NULL COMMENT '版本号', + `system_prompt` TEXT NOT NULL COMMENT '系统提示词', + `user_prompt_template` TEXT COMMENT '用户提示词模板', + `variables` JSON COMMENT '变量列表', + `output_schema` JSON COMMENT '输出 JSON Schema', + `change_summary` VARCHAR(500) COMMENT '变更说明', + `created_by` INT COMMENT '创建人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (prompt_id) REFERENCES ai_prompts(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL, + UNIQUE KEY uk_prompt_version (prompt_id, version), + INDEX idx_prompt_id (prompt_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI提示词版本历史表'; + +-- ============================================ +-- 6. 租户自定义提示词表 (tenant_prompts) +-- 租户可覆盖系统默认提示词 +-- ============================================ +CREATE TABLE IF NOT EXISTS `tenant_prompts` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `tenant_id` INT NOT NULL COMMENT '租户ID', + `prompt_id` INT NOT NULL COMMENT '基础提示词ID', + `system_prompt` TEXT COMMENT '自定义系统提示词(为空则使用默认)', + `user_prompt_template` TEXT COMMENT '自定义用户提示词模板', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用自定义', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY (prompt_id) REFERENCES ai_prompts(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL, + FOREIGN KEY (updated_by) REFERENCES admin_users(id) ON DELETE SET NULL, + UNIQUE KEY uk_tenant_prompt (tenant_id, prompt_id), + INDEX idx_tenant_id (tenant_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='租户自定义提示词表'; + +-- ============================================ +-- 7. 功能开关表 (feature_switches) +-- ============================================ +CREATE TABLE IF NOT EXISTS `feature_switches` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `tenant_id` INT COMMENT '租户ID(NULL表示全局默认)', + `feature_code` VARCHAR(50) NOT NULL COMMENT '功能编码', + `feature_name` VARCHAR(100) NOT NULL COMMENT '功能名称', + `feature_group` VARCHAR(50) COMMENT '功能分组:exam, practice, broadcast, yanji', + `is_enabled` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `config` JSON COMMENT '功能配置参数', + `description` VARCHAR(500) COMMENT '功能说明', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + UNIQUE KEY uk_tenant_feature (tenant_id, feature_code), + INDEX idx_tenant_id (tenant_id), + INDEX idx_feature_code (feature_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='功能开关表'; + +-- ============================================ +-- 8. 操作审计日志表 (operation_logs) +-- ============================================ +CREATE TABLE IF NOT EXISTS `operation_logs` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `admin_user_id` INT COMMENT '操作人ID', + `admin_username` VARCHAR(50) COMMENT '操作人用户名', + `tenant_id` INT COMMENT '涉及租户ID', + `tenant_code` VARCHAR(20) COMMENT '涉及租户编码', + `operation_type` VARCHAR(50) NOT NULL COMMENT '操作类型:create, update, delete, enable, disable', + `resource_type` VARCHAR(50) NOT NULL COMMENT '资源类型:tenant, config, prompt, feature', + `resource_id` INT COMMENT '资源ID', + `resource_name` VARCHAR(200) COMMENT '资源名称', + `old_value` JSON COMMENT '变更前值', + `new_value` JSON COMMENT '变更后值', + `ip_address` VARCHAR(50) COMMENT '操作IP', + `user_agent` VARCHAR(500) COMMENT '浏览器信息', + `remarks` TEXT COMMENT '备注', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_admin_user_id (admin_user_id), + INDEX idx_tenant_id (tenant_id), + INDEX idx_operation_type (operation_type), + INDEX idx_resource_type (resource_type), + INDEX idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操作审计日志表'; + +-- ============================================ +-- 9. 配置模板表 (config_templates) +-- 定义各配置项的元数据 +-- ============================================ +CREATE TABLE IF NOT EXISTS `config_templates` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `config_group` VARCHAR(50) NOT NULL COMMENT '配置分组', + `config_key` VARCHAR(100) NOT NULL COMMENT '配置键', + `display_name` VARCHAR(100) NOT NULL COMMENT '显示名称', + `description` TEXT COMMENT '配置说明', + `value_type` VARCHAR(20) DEFAULT 'string' COMMENT '值类型', + `default_value` TEXT COMMENT '默认值', + `is_required` BOOLEAN DEFAULT FALSE COMMENT '是否必填', + `is_secret` BOOLEAN DEFAULT FALSE COMMENT '是否敏感信息', + `validation_rule` VARCHAR(500) COMMENT '验证规则(正则)', + `options` JSON COMMENT '可选值列表(下拉选择)', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_group_key (config_group, config_key), + INDEX idx_config_group (config_group) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='配置模板表'; + +-- ============================================ +-- 初始化数据 +-- ============================================ + +-- 1. 插入超级管理员 +INSERT INTO admin_users (username, email, password_hash, full_name, role) VALUES +('superadmin', 'admin@ireborn.com.cn', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.ynB8dC.m5QZ9Hy', '超级管理员', 'superadmin'); +-- 密码: Superadmin123! + +-- 2. 插入配置模板 +INSERT INTO config_templates (config_group, config_key, display_name, description, value_type, default_value, is_required, is_secret, sort_order) VALUES +-- 数据库配置 +('database', 'MYSQL_HOST', 'MySQL主机', 'MySQL数据库主机地址', 'string', 'prod-mysql', TRUE, FALSE, 1), +('database', 'MYSQL_PORT', 'MySQL端口', 'MySQL数据库端口', 'int', '3306', TRUE, FALSE, 2), +('database', 'MYSQL_USER', 'MySQL用户', 'MySQL数据库用户名', 'string', 'root', TRUE, FALSE, 3), +('database', 'MYSQL_PASSWORD', 'MySQL密码', 'MySQL数据库密码', 'string', NULL, TRUE, TRUE, 4), +('database', 'MYSQL_DATABASE', '数据库名', '租户数据库名称', 'string', NULL, TRUE, FALSE, 5), +-- Redis配置 +('redis', 'REDIS_HOST', 'Redis主机', 'Redis缓存主机地址', 'string', 'localhost', TRUE, FALSE, 1), +('redis', 'REDIS_PORT', 'Redis端口', 'Redis端口', 'int', '6379', TRUE, FALSE, 2), +('redis', 'REDIS_DB', 'Redis DB', 'Redis数据库编号', 'int', '0', FALSE, FALSE, 3), +-- 安全配置 +('security', 'SECRET_KEY', 'JWT密钥', 'JWT Token签名密钥', 'string', NULL, TRUE, TRUE, 1), +('security', 'CORS_ORIGINS', 'CORS域名', '允许跨域的域名列表', 'json', '[]', FALSE, FALSE, 2), +-- Dify配置 +('dify', 'DIFY_API_KEY', '知识点分析 Key', 'Dify 01-知识点分析工作流 API Key', 'string', NULL, FALSE, TRUE, 1), +('dify', 'DIFY_EXAM_GENERATOR_API_KEY', '试题生成器 Key', 'Dify 02-试题生成器工作流 API Key', 'string', NULL, FALSE, TRUE, 2), +('dify', 'DIFY_PRACTICE_API_KEY', '陪练知识准备 Key', 'Dify 03-陪练知识准备工作流 API Key', 'string', NULL, FALSE, TRUE, 3), +('dify', 'DIFY_COURSE_CHAT_API_KEY', '课程对话 Key', 'Dify 04-与课程对话工作流 API Key', 'string', NULL, FALSE, TRUE, 4), +('dify', 'DIFY_YANJI_ANALYSIS_API_KEY', '智能工牌分析 Key', 'Dify 05-智能工牌能力分析工作流 API Key', 'string', NULL, FALSE, TRUE, 5), +-- Coze配置 +('coze', 'COZE_PRACTICE_BOT_ID', '陪练Bot ID', 'Coze 陪练机器人ID', 'string', '7560643598174683145', FALSE, FALSE, 1), +('coze', 'COZE_BROADCAST_WORKFLOW_ID', '播课工作流ID', 'Coze 播课工作流ID', 'string', NULL, FALSE, FALSE, 2), +('coze', 'COZE_BROADCAST_SPACE_ID', '播课空间ID', 'Coze 播课工作流空间ID', 'string', '7474971491470688296', FALSE, FALSE, 3), +('coze', 'COZE_OAUTH_CLIENT_ID', 'OAuth Client ID', 'Coze OAuth 客户端ID', 'string', NULL, FALSE, FALSE, 4), +('coze', 'COZE_OAUTH_PUBLIC_KEY_ID', 'OAuth Public Key ID', 'Coze OAuth 公钥ID', 'string', NULL, FALSE, FALSE, 5), +-- AI服务配置 +('ai', 'AI_PRIMARY_API_KEY', '首选AI服务Key', '首选AI服务商(4sapi.com) API Key', 'string', NULL, FALSE, TRUE, 1), +('ai', 'AI_PRIMARY_BASE_URL', '首选AI服务地址', '首选AI服务商API地址', 'string', 'https://4sapi.com/v1', FALSE, FALSE, 2), +('ai', 'AI_FALLBACK_API_KEY', '备选AI服务Key', '备选AI服务商(OpenRouter) API Key', 'string', NULL, FALSE, TRUE, 3), +('ai', 'AI_FALLBACK_BASE_URL', '备选AI服务地址', '备选AI服务商API地址', 'string', 'https://openrouter.ai/api/v1', FALSE, FALSE, 4), +('ai', 'AI_DEFAULT_MODEL', '默认模型', '默认使用的AI模型', 'string', 'gemini-3-flash-preview', FALSE, FALSE, 5), +('ai', 'AI_TIMEOUT', 'AI请求超时', 'AI服务请求超时时间(秒)', 'int', '120', FALSE, FALSE, 6), +-- 言迹工牌配置 +('yanji', 'YANJI_CLIENT_ID', '客户端ID', '言迹开放平台Client ID', 'string', NULL, FALSE, FALSE, 1), +('yanji', 'YANJI_CLIENT_SECRET', '客户端密钥', '言迹开放平台Client Secret', 'string', NULL, FALSE, TRUE, 2), +('yanji', 'YANJI_TENANT_ID', '租户ID', '言迹租户ID', 'string', NULL, FALSE, FALSE, 3), +('yanji', 'YANJI_ESTATE_ID', '门店ID', '言迹门店ID', 'string', NULL, FALSE, FALSE, 4), +-- 文件存储配置 +('storage', 'UPLOAD_DIR', '上传目录', '文件上传目录', 'string', 'uploads', FALSE, FALSE, 1), +('storage', 'MAX_UPLOAD_SIZE', '最大上传大小', '最大文件上传大小(字节)', 'int', '15728640', FALSE, FALSE, 2); + +-- 3. 插入功能开关模板(全局默认) +INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description) VALUES +(NULL, 'exam_module', '考试模块', 'exam', TRUE, '考试功能总开关'), +(NULL, 'exam_ai_generate', 'AI试题生成', 'exam', TRUE, '使用AI自动生成试题'), +(NULL, 'exam_three_rounds', '三轮考试', 'exam', TRUE, '启用三轮考试机制'), +(NULL, 'practice_module', '陪练模块', 'practice', TRUE, '陪练功能总开关'), +(NULL, 'practice_voice', '语音陪练', 'practice', TRUE, '支持语音对话陪练'), +(NULL, 'broadcast_module', '播课模块', 'broadcast', TRUE, '播课功能总开关'), +(NULL, 'broadcast_auto_generate', '自动生成播课', 'broadcast', TRUE, '自动生成课程播课内容'), +(NULL, 'course_chat', '课程对话', 'course', TRUE, '与课程知识点对话功能'), +(NULL, 'knowledge_ai_analyze', 'AI知识点分析', 'course', TRUE, '使用AI分析提取知识点'), +(NULL, 'yanji_module', '智能工牌模块', 'yanji', FALSE, '言迹智能工牌对接功能'), +(NULL, 'yanji_ability_analysis', '能力分析', 'yanji', FALSE, '基于工牌数据的能力分析'); + +-- ============================================ +-- 完成 +-- ============================================ + diff --git a/backend/migrations/alembic/__init__.py b/backend/migrations/alembic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/migrations/alembic/versions/__init__.py b/backend/migrations/alembic/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/migrations/cleanup_broadcast_fields.sql b/backend/migrations/cleanup_broadcast_fields.sql new file mode 100644 index 0000000..94b0e58 --- /dev/null +++ b/backend/migrations/cleanup_broadcast_fields.sql @@ -0,0 +1,17 @@ +-- 清理播课功能不必要的字段 +-- 日期: 2025-10-14 +-- 说明: 新策略下Coze工作流直接写数据库,不需要状态跟踪字段 + +USE kaopeilian; + +-- 删除不再需要的字段 +ALTER TABLE courses DROP COLUMN broadcast_status; +ALTER TABLE courses DROP COLUMN broadcast_task_id; +ALTER TABLE courses DROP COLUMN broadcast_error_message; + +-- 验证剩余字段 +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'kaopeilian' +AND TABLE_NAME = 'courses' +AND COLUMN_NAME LIKE 'broadcast%'; diff --git a/backend/migrations/create_ability_assessments.sql b/backend/migrations/create_ability_assessments.sql new file mode 100644 index 0000000..e69de29 diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..eb1e1aa --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,104 @@ +""" +Alembic 环境配置 +""" +import asyncio +import os +import sys +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +# 将项目根目录添加到 Python 路径 +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +from app.core.config import settings +from app.models.base import Base + +# 导入所有模型以确保 Alembic 能够检测到它们 +from app.models.user import User, Team # noqa + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# 设置数据库URL +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/backend/migrations/manual_course_tables.sql b/backend/migrations/manual_course_tables.sql new file mode 100644 index 0000000..53a9ecb --- /dev/null +++ b/backend/migrations/manual_course_tables.sql @@ -0,0 +1,131 @@ +-- 课程相关表结构 +-- 用于手动创建课程模块所需的数据库表 + +-- 1. 课程表 +CREATE TABLE IF NOT EXISTS `courses` ( + `id` INT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(200) NOT NULL COMMENT '课程名称', + `description` TEXT COMMENT '课程描述', + `category` ENUM('technology', 'management', 'business', 'general') NOT NULL DEFAULT 'general' COMMENT '课程分类', + `status` ENUM('draft', 'published', 'archived') NOT NULL DEFAULT 'draft' COMMENT '课程状态', + `cover_image` VARCHAR(500) COMMENT '封面图片URL', + `duration_hours` FLOAT COMMENT '课程时长(小时)', + `difficulty_level` INT COMMENT '难度等级(1-5)', + `tags` JSON COMMENT '标签列表', + `published_at` DATETIME COMMENT '发布时间', + `publisher_id` INT COMMENT '发布人ID', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序顺序', + `is_featured` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否推荐', + + -- 基础字段 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- 软删除字段 + `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE, + `deleted_at` DATETIME, + + -- 审计字段 + `created_by` INT, + `updated_by` INT, + + PRIMARY KEY (`id`), + INDEX `idx_courses_status` (`status`), + INDEX `idx_courses_category` (`category`), + INDEX `idx_courses_is_deleted` (`is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程表'; + +-- 2. 课程资料表 +CREATE TABLE IF NOT EXISTS `course_materials` ( + `id` INT NOT NULL AUTO_INCREMENT, + `course_id` INT NOT NULL COMMENT '课程ID', + `name` VARCHAR(200) NOT NULL COMMENT '资料名称', + `description` TEXT COMMENT '资料描述', + `file_url` VARCHAR(500) NOT NULL COMMENT '文件URL', + `file_type` VARCHAR(50) NOT NULL COMMENT '文件类型', + `file_size` INT NOT NULL COMMENT '文件大小(字节)', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序顺序', + + -- 基础字段 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- 软删除字段 + `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE, + `deleted_at` DATETIME, + + PRIMARY KEY (`id`), + FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON DELETE CASCADE, + INDEX `idx_course_materials_course_id` (`course_id`), + INDEX `idx_course_materials_is_deleted` (`is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程资料表'; + +-- 3. 知识点表 +CREATE TABLE IF NOT EXISTS `knowledge_points` ( + `id` INT NOT NULL AUTO_INCREMENT, + `course_id` INT NOT NULL COMMENT '课程ID', + `name` VARCHAR(200) NOT NULL COMMENT '知识点名称', + `description` TEXT COMMENT '知识点描述', + `parent_id` INT COMMENT '父知识点ID', + `level` INT NOT NULL DEFAULT 1 COMMENT '层级深度', + `path` VARCHAR(500) COMMENT '路径(如: 1.2.3)', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序顺序', + `weight` FLOAT NOT NULL DEFAULT 1.0 COMMENT '权重', + `is_required` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否必修', + `estimated_hours` FLOAT COMMENT '预计学习时间(小时)', + + -- 基础字段 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- 软删除字段 + `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE, + `deleted_at` DATETIME, + + PRIMARY KEY (`id`), + FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`parent_id`) REFERENCES `knowledge_points`(`id`) ON DELETE CASCADE, + INDEX `idx_knowledge_points_course_id` (`course_id`), + INDEX `idx_knowledge_points_parent_id` (`parent_id`), + INDEX `idx_knowledge_points_is_deleted` (`is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识点表'; + +-- 4. 成长路径表 +CREATE TABLE IF NOT EXISTS `growth_paths` ( + `id` INT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(200) NOT NULL COMMENT '路径名称', + `description` TEXT COMMENT '路径描述', + `target_role` VARCHAR(100) COMMENT '目标角色', + `courses` JSON COMMENT '课程列表[{course_id, order, is_required}]', + `estimated_duration_days` INT COMMENT '预计完成天数', + `is_active` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序顺序', + + -- 基础字段 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- 软删除字段 + `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE, + `deleted_at` DATETIME, + + PRIMARY KEY (`id`), + INDEX `idx_growth_paths_is_active` (`is_active`), + INDEX `idx_growth_paths_is_deleted` (`is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成长路径表'; + +-- 添加测试数据 +INSERT INTO `courses` (`name`, `description`, `category`, `status`, `difficulty_level`, `tags`) VALUES +('Python基础入门', 'Python编程语言基础教程,适合零基础学员', 'technology', 'published', 1, '["Python", "编程基础", "入门"]'), +('项目管理实战', '敏捷项目管理方法论与实践', 'management', 'published', 3, '["项目管理", "敏捷", "Scrum"]'), +('商业分析技巧', '商业数据分析与决策支持', 'business', 'draft', 2, '["数据分析", "商业智能", "Excel"]'); + +-- 为Python课程添加知识点 +INSERT INTO `knowledge_points` (`course_id`, `name`, `description`, `parent_id`, `level`, `path`, `sort_order`, `weight`, `is_required`, `estimated_hours`) VALUES +(1, 'Python基础语法', 'Python语言基础语法知识', NULL, 1, '1', 1, 1.0, TRUE, 5.0), +(1, '变量与数据类型', '学习Python中的变量定义和基本数据类型', 1, 2, '1.1', 1, 0.8, TRUE, 2.0), +(1, '控制流程', '条件语句和循环结构', 1, 2, '1.2', 2, 0.9, TRUE, 3.0); + +-- 添加成长路径 +INSERT INTO `growth_paths` (`name`, `description`, `target_role`, `courses`, `estimated_duration_days`) VALUES +('Python开发工程师', '从零基础到Python开发工程师的成长路径', 'Python开发工程师', '[{"course_id": 1, "order": 1, "is_required": true}]', 90); diff --git a/backend/migrations/manual_modify_knowledge_points_material_id.sql b/backend/migrations/manual_modify_knowledge_points_material_id.sql new file mode 100644 index 0000000..4296efc --- /dev/null +++ b/backend/migrations/manual_modify_knowledge_points_material_id.sql @@ -0,0 +1,26 @@ +-- 修改知识点表,将material_id设为必填字段 +-- 执行日期:2025-09-27 +-- 说明:将knowledge_points表的material_id字段从可选改为必填 + +-- 1. 首先检查是否有material_id为NULL的记录 +SELECT COUNT(*) as null_count FROM knowledge_points WHERE material_id IS NULL AND is_deleted = FALSE; + +-- 2. 如果有NULL值,需要先处理这些记录(可以删除或设置默认值) +-- 这里先删除material_id为NULL的记录(如果有的话) +DELETE FROM knowledge_points WHERE material_id IS NULL; + +-- 3. 修改字段为NOT NULL,并修改外键约束 +ALTER TABLE knowledge_points +MODIFY COLUMN material_id INT NOT NULL COMMENT '关联资料ID'; + +-- 4. 删除旧的外键约束 +ALTER TABLE knowledge_points +DROP FOREIGN KEY knowledge_points_ibfk_2; + +-- 5. 添加新的外键约束(CASCADE删除) +ALTER TABLE knowledge_points +ADD CONSTRAINT knowledge_points_ibfk_2 +FOREIGN KEY (material_id) REFERENCES course_materials(id) ON DELETE CASCADE; + +-- 6. 验证修改结果 +DESCRIBE knowledge_points; diff --git a/backend/migrations/manual_training_tables.sql b/backend/migrations/manual_training_tables.sql new file mode 100644 index 0000000..b553d6c --- /dev/null +++ b/backend/migrations/manual_training_tables.sql @@ -0,0 +1,92 @@ +-- 手动创建training模块相关表的SQL脚本 +-- 用于解决Alembic迁移失败的备选方案 + +-- 1. 创建training_scenes表 +CREATE TABLE IF NOT EXISTS training_scenes ( + id INTEGER NOT NULL AUTO_INCREMENT, + name VARCHAR(100) NOT NULL COMMENT '场景名称', + description TEXT COMMENT '场景描述', + category VARCHAR(50) NOT NULL COMMENT '场景分类', + ai_config JSON COMMENT 'AI配置(如Coze Bot ID等)', + prompt_template TEXT COMMENT '提示词模板', + evaluation_criteria JSON COMMENT '评估标准', + status ENUM('DRAFT', 'ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'DRAFT' COMMENT '场景状态', + is_public BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否公开', + required_level INTEGER COMMENT '所需用户等级', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + created_by INTEGER, + updated_by INTEGER, + PRIMARY KEY (id), + INDEX ix_training_scenes_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练场景表'; + +-- 2. 创建training_sessions表 +CREATE TABLE IF NOT EXISTS training_sessions ( + id INTEGER NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL COMMENT '用户ID', + scene_id INTEGER NOT NULL COMMENT '场景ID', + coze_conversation_id VARCHAR(100) COMMENT 'Coze会话ID', + start_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + end_time DATETIME COMMENT '结束时间', + duration_seconds INTEGER COMMENT '持续时长(秒)', + status ENUM('CREATED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'ERROR') NOT NULL DEFAULT 'CREATED' COMMENT '会话状态', + session_config JSON COMMENT '会话配置', + total_score FLOAT COMMENT '总分', + evaluation_result JSON COMMENT '评估结果详情', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INTEGER, + updated_by INTEGER, + PRIMARY KEY (id), + INDEX ix_training_sessions_id (id), + INDEX ix_training_sessions_user_id (user_id), + FOREIGN KEY (scene_id) REFERENCES training_scenes(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练会话表'; + +-- 3. 创建training_messages表 +CREATE TABLE IF NOT EXISTS training_messages ( + id INTEGER NOT NULL AUTO_INCREMENT, + session_id INTEGER NOT NULL COMMENT '会话ID', + role ENUM('USER', 'ASSISTANT', 'SYSTEM') NOT NULL COMMENT '消息角色', + type ENUM('TEXT', 'VOICE', 'SYSTEM') NOT NULL COMMENT '消息类型', + content TEXT NOT NULL COMMENT '消息内容', + voice_url VARCHAR(500) COMMENT '语音文件URL', + voice_duration FLOAT COMMENT '语音时长(秒)', + message_metadata JSON COMMENT '消息元数据', + coze_message_id VARCHAR(100) COMMENT 'Coze消息ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX ix_training_messages_id (id), + FOREIGN KEY (session_id) REFERENCES training_sessions(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练消息表'; + +-- 4. 创建training_reports表 +CREATE TABLE IF NOT EXISTS training_reports ( + id INTEGER NOT NULL AUTO_INCREMENT, + session_id INTEGER NOT NULL COMMENT '会话ID', + user_id BIGINT NOT NULL COMMENT '用户ID', + overall_score FLOAT NOT NULL COMMENT '总体得分', + dimension_scores JSON NOT NULL COMMENT '各维度得分', + strengths JSON NOT NULL COMMENT '优势点', + weaknesses JSON NOT NULL COMMENT '待改进点', + suggestions JSON NOT NULL COMMENT '改进建议', + detailed_analysis TEXT COMMENT '详细分析', + transcript TEXT COMMENT '对话文本记录', + statistics JSON COMMENT '统计数据', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INTEGER, + updated_by INTEGER, + PRIMARY KEY (id), + UNIQUE KEY (session_id), + INDEX ix_training_reports_id (id), + INDEX ix_training_reports_user_id (user_id), + FOREIGN KEY (session_id) REFERENCES training_sessions(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练报告表'; + +-- 5. 更新Alembic版本表,标记迁移已完成 +INSERT INTO alembic_version (version_num) VALUES ('9245f8845fe1') ON DUPLICATE KEY UPDATE version_num = '9245f8845fe1'; diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..3cf5352 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/backend/migrations/update_production_broadcast_fields.sql b/backend/migrations/update_production_broadcast_fields.sql new file mode 100644 index 0000000..b56ec16 --- /dev/null +++ b/backend/migrations/update_production_broadcast_fields.sql @@ -0,0 +1,13 @@ +-- 更新生产环境数据库播课字段 +-- 日期: 2025-10-14 +-- 说明: 清理旧的异步状态字段,只保留必要的播课字段 + +USE kaopeilian; + +-- 查看当前字段结构 +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'kaopeilian' +AND TABLE_NAME = 'courses' +AND COLUMN_NAME LIKE 'broadcast%' +ORDER BY COLUMN_NAME; diff --git a/backend/migrations/update_production_broadcast_fields_step1_check.sql b/backend/migrations/update_production_broadcast_fields_step1_check.sql new file mode 100644 index 0000000..c089f2d --- /dev/null +++ b/backend/migrations/update_production_broadcast_fields_step1_check.sql @@ -0,0 +1,25 @@ +-- 步骤1:检查生产环境数据库播课字段 +-- 日期: 2025-10-14 + +USE kaopeilian; + +-- 查看当前字段结构 +SELECT '=== 当前播课相关字段 ===' AS info; +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'kaopeilian' +AND TABLE_NAME = 'courses' +AND COLUMN_NAME LIKE 'broadcast%' +ORDER BY COLUMN_NAME; + +-- 检查是否有播课数据 +SELECT '=== 已生成的播课数据 ===' AS info; +SELECT COUNT(*) as broadcast_count +FROM courses +WHERE broadcast_audio_url IS NOT NULL; + +SELECT id, name, broadcast_audio_url, broadcast_generated_at +FROM courses +WHERE broadcast_audio_url IS NOT NULL +LIMIT 5; + diff --git a/backend/migrations/update_production_broadcast_fields_step2_update.sql b/backend/migrations/update_production_broadcast_fields_step2_update.sql new file mode 100644 index 0000000..afe60af --- /dev/null +++ b/backend/migrations/update_production_broadcast_fields_step2_update.sql @@ -0,0 +1,86 @@ +-- 步骤2:更新生产环境数据库播课字段 +-- 日期: 2025-10-14 +-- 说明: 清理旧的异步状态字段,只保留必要的播课字段 +-- 执行前请先运行 step1_check.sql 查看当前字段情况 + +USE kaopeilian; + +-- 删除不再需要的字段 +-- 如果字段不存在会报错,可以忽略错误继续执行 + +-- 删除 broadcast_status +SET @sql = IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'kaopeilian' + AND TABLE_NAME = 'courses' + AND COLUMN_NAME = 'broadcast_status') > 0, + 'ALTER TABLE courses DROP COLUMN broadcast_status', + 'SELECT "broadcast_status 不存在,跳过" AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 删除 broadcast_task_id +SET @sql = IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'kaopeilian' + AND TABLE_NAME = 'courses' + AND COLUMN_NAME = 'broadcast_task_id') > 0, + 'ALTER TABLE courses DROP COLUMN broadcast_task_id', + 'SELECT "broadcast_task_id 不存在,跳过" AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 删除 broadcast_error_message +SET @sql = IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'kaopeilian' + AND TABLE_NAME = 'courses' + AND COLUMN_NAME = 'broadcast_error_message') > 0, + 'ALTER TABLE courses DROP COLUMN broadcast_error_message', + 'SELECT "broadcast_error_message 不存在,跳过" AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 添加必要字段(如果不存在) + +-- 添加 broadcast_audio_url +SET @sql = IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'kaopeilian' + AND TABLE_NAME = 'courses' + AND COLUMN_NAME = 'broadcast_audio_url') = 0, + 'ALTER TABLE courses ADD COLUMN broadcast_audio_url VARCHAR(500) NULL COMMENT ''播课音频URL''', + 'SELECT "broadcast_audio_url 已存在,跳过" AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 添加 broadcast_generated_at +SET @sql = IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'kaopeilian' + AND TABLE_NAME = 'courses' + AND COLUMN_NAME = 'broadcast_generated_at') = 0, + 'ALTER TABLE courses ADD COLUMN broadcast_generated_at DATETIME NULL COMMENT ''播课生成时间''', + 'SELECT "broadcast_generated_at 已存在,跳过" AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 验证最终字段 +SELECT '=== 更新后的播课字段 ===' AS info; +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'kaopeilian' +AND TABLE_NAME = 'courses' +AND COLUMN_NAME LIKE 'broadcast%' +ORDER BY COLUMN_NAME; + diff --git a/backend/migrations/versions/0487635b5e95_add_position_courses_table.py b/backend/migrations/versions/0487635b5e95_add_position_courses_table.py new file mode 100644 index 0000000..d01167a --- /dev/null +++ b/backend/migrations/versions/0487635b5e95_add_position_courses_table.py @@ -0,0 +1,46 @@ +"""add_position_courses_table + +Revision ID: 0487635b5e95 +Revises: 5448c81e7afd +Create Date: 2025-09-22 08:27:52.507507 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0487635b5e95' +down_revision: Union[str, None] = '5448c81e7afd' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('position_courses', + sa.Column('position_id', sa.Integer(), nullable=False, comment='岗位ID'), + sa.Column('course_id', sa.Integer(), nullable=False, comment='课程ID'), + sa.Column('course_type', sa.String(length=20), nullable=False, comment='课程类型'), + sa.Column('priority', sa.Integer(), nullable=True, comment='优先级/排序'), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('is_deleted', sa.Boolean(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['course_id'], ['courses.id'], ), + sa.ForeignKeyConstraint(['position_id'], ['positions.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('position_id', 'course_id', 'is_deleted', name='uix_position_course') + ) + op.create_index(op.f('ix_position_courses_id'), 'position_courses', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_position_courses_id'), table_name='position_courses') + op.drop_table('position_courses') + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/migrations/versions/20250921_align_schema_to_design.py b/backend/migrations/versions/20250921_align_schema_to_design.py new file mode 100644 index 0000000..941563c --- /dev/null +++ b/backend/migrations/versions/20250921_align_schema_to_design.py @@ -0,0 +1,157 @@ +""" +Align database schema to the unified design spec. + +Changes: +- users: id INT, set defaults, drop extra columns not in design +- user_teams/exams: user_id INT +- teams: drop created_by/updated_by +- training_*: fix defaults; set created_by/updated_by to BIGINT where required +- training_reports: add unique(session_id) +- create or replace view v_user_course_progress +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "20250921_align_schema_to_design" +down_revision = "9245f8845fe1" +branch_labels = None +depends_on = None + + +def upgrade(): + connection = op.get_bind() + + # users: enforce INT PK, defaults, and drop extra columns + op.execute(""" + ALTER TABLE `users` + MODIFY COLUMN `id` INT AUTO_INCREMENT, + MODIFY COLUMN `username` VARCHAR(50) NOT NULL, + MODIFY COLUMN `email` VARCHAR(100) NOT NULL, + MODIFY COLUMN `password_hash` VARCHAR(200) NOT NULL, + MODIFY COLUMN `role` VARCHAR(20) DEFAULT 'trainee', + MODIFY COLUMN `is_active` TINYINT(1) DEFAULT 1, + MODIFY COLUMN `is_verified` TINYINT(1) DEFAULT 0, + MODIFY COLUMN `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + MODIFY COLUMN `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + """) + + # Drop extra columns that are not in the design (ignore errors if absent) + for col in [ + "is_superuser", + "department", + "position", + "last_login", + "login_count", + "failed_login_count", + "locked_until", + "created_by", + "updated_by", + "is_deleted", + "deleted_at", + ]: + try: + op.execute(f"ALTER TABLE `users` DROP COLUMN `{col}`") + except Exception: + pass + + # user_teams: user_id INT + op.execute(""" + ALTER TABLE `user_teams` + MODIFY COLUMN `user_id` INT NOT NULL + """) + + # exams: user_id INT + op.execute(""" + ALTER TABLE `exams` + MODIFY COLUMN `user_id` INT NOT NULL + """) + + # teams: drop created_by/updated_by to match design + for col in ["created_by", "updated_by"]: + try: + op.execute(f"ALTER TABLE `teams` DROP COLUMN `{col}`") + except Exception: + pass + + # training_scenes: set defaults and BIGINT audit fields + op.execute(""" + ALTER TABLE `training_scenes` + MODIFY COLUMN `status` ENUM('DRAFT','ACTIVE','INACTIVE') NOT NULL DEFAULT 'DRAFT', + MODIFY COLUMN `is_public` TINYINT(1) NOT NULL DEFAULT 1, + MODIFY COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + MODIFY COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + MODIFY COLUMN `created_by` BIGINT NULL, + MODIFY COLUMN `updated_by` BIGINT NULL + """) + + # training_sessions: defaults and BIGINT audit fields + op.execute(""" + ALTER TABLE `training_sessions` + MODIFY COLUMN `start_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + MODIFY COLUMN `status` ENUM('CREATED','IN_PROGRESS','COMPLETED','CANCELLED','ERROR') NOT NULL DEFAULT 'CREATED', + MODIFY COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + MODIFY COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + MODIFY COLUMN `created_by` BIGINT NULL, + MODIFY COLUMN `updated_by` BIGINT NULL + """) + + # training_messages: timestamps defaults + op.execute(""" + ALTER TABLE `training_messages` + MODIFY COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + MODIFY COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + """) + + # training_reports: timestamps defaults and BIGINT audit fields + op.execute(""" + ALTER TABLE `training_reports` + MODIFY COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + MODIFY COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + MODIFY COLUMN `created_by` BIGINT NULL, + MODIFY COLUMN `updated_by` BIGINT NULL + """) + + # Add unique constraint for training_reports.session_id per design + try: + op.create_unique_constraint( + "uq_training_reports_session_id", "training_reports", ["session_id"] + ) + except Exception: + pass + + # Create or replace view v_user_course_progress + op.execute(""" + CREATE OR REPLACE VIEW v_user_course_progress AS + SELECT + u.id AS user_id, + u.username, + c.id AS course_id, + c.name AS course_name, + COUNT(DISTINCT e.id) AS exam_count, + AVG(e.score) AS avg_score, + MAX(e.score) AS best_score + FROM users u + CROSS JOIN courses c + LEFT JOIN exams e + ON e.user_id = u.id + AND e.course_id = c.id + AND e.status = 'submitted' + GROUP BY u.id, c.id + """) + + +def downgrade(): + # Drop the view to rollback + try: + op.execute("DROP VIEW IF EXISTS v_user_course_progress") + except Exception: + pass + + # Note: Full downgrade to previous heterogeneous state is not implemented to avoid data loss. + # Keep this as a no-op for column-level reversions. + pass + + diff --git a/backend/migrations/versions/20250922_add_positions_table.py b/backend/migrations/versions/20250922_add_positions_table.py new file mode 100644 index 0000000..588120b --- /dev/null +++ b/backend/migrations/versions/20250922_add_positions_table.py @@ -0,0 +1,55 @@ +""" +add positions table + +Revision ID: 20250922_add_positions +Revises: 20250921_align_schema_to_design +Create Date: 2025-09-22 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +# revision identifiers, used by Alembic. +revision = "20250922_add_positions" +down_revision = "20250921_align_schema_to_design" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if not inspector.has_table("positions"): + op.create_table( + "positions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("code", sa.String(length=100), nullable=False, unique=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("parent_id", sa.Integer(), sa.ForeignKey("positions.id", ondelete="SET NULL"), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default="active"), + sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("updated_by", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + + # 创建索引(若不存在) + try: + indexes = [ix.get("name") for ix in inspector.get_indexes("positions")] # type: ignore + except Exception: + indexes = [] + if "ix_positions_name" not in indexes: + op.create_index("ix_positions_name", "positions", ["name"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_positions_name", table_name="positions") + op.drop_table("positions") + + diff --git a/backend/migrations/versions/3d5b88fe1875_merge_multiple_heads.py b/backend/migrations/versions/3d5b88fe1875_merge_multiple_heads.py new file mode 100644 index 0000000..ddad6b0 --- /dev/null +++ b/backend/migrations/versions/3d5b88fe1875_merge_multiple_heads.py @@ -0,0 +1,26 @@ +"""merge_multiple_heads + +Revision ID: 3d5b88fe1875 +Revises: 20250922_add_positions, add_users_soft_delete +Create Date: 2025-09-22 08:09:11.966673 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3d5b88fe1875' +down_revision: Union[str, None] = ('20250922_add_positions', 'add_users_soft_delete') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass \ No newline at end of file diff --git a/backend/migrations/versions/5448c81e7afd_add_position_members_table.py b/backend/migrations/versions/5448c81e7afd_add_position_members_table.py new file mode 100644 index 0000000..f42855d --- /dev/null +++ b/backend/migrations/versions/5448c81e7afd_add_position_members_table.py @@ -0,0 +1,46 @@ +"""add_position_members_table + +Revision ID: 5448c81e7afd +Revises: 3d5b88fe1875 +Create Date: 2025-09-22 08:13:54.755269 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = '5448c81e7afd' +down_revision: Union[str, None] = '3d5b88fe1875' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('position_members', + sa.Column('position_id', sa.Integer(), nullable=False, comment='岗位ID'), + sa.Column('user_id', sa.Integer(), nullable=False, comment='用户ID'), + sa.Column('role', sa.String(length=50), nullable=True, comment='成员角色(预留字段)'), + sa.Column('joined_at', sa.DateTime(), nullable=True, comment='加入时间'), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('is_deleted', sa.Boolean(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['position_id'], ['positions.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('position_id', 'user_id', 'is_deleted', name='uix_position_user') + ) + op.create_index(op.f('ix_position_members_id'), 'position_members', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_position_members_id'), table_name='position_members') + op.drop_table('position_members') + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/migrations/versions/9245f8845fe1_add_training_models.py b/backend/migrations/versions/9245f8845fe1_add_training_models.py new file mode 100644 index 0000000..2444d9b --- /dev/null +++ b/backend/migrations/versions/9245f8845fe1_add_training_models.py @@ -0,0 +1,708 @@ +"""add training models + +Revision ID: 9245f8845fe1 +Revises: 001 +Create Date: 2025-09-21 22:11:03.319902 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = '9245f8845fe1' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('training_scenes', + sa.Column('name', sa.String(length=100), nullable=False, comment='场景名称'), + sa.Column('description', sa.Text(), nullable=True, comment='场景描述'), + sa.Column('category', sa.String(length=50), nullable=False, comment='场景分类'), + sa.Column('ai_config', sa.JSON(), nullable=True, comment='AI配置(如Coze Bot ID等)'), + sa.Column('prompt_template', sa.Text(), nullable=True, comment='提示词模板'), + sa.Column('evaluation_criteria', sa.JSON(), nullable=True, comment='评估标准'), + sa.Column('status', sa.Enum('DRAFT', 'ACTIVE', 'INACTIVE', name='trainingscenestatus'), nullable=False, comment='场景状态'), + sa.Column('is_public', sa.Boolean(), nullable=False, comment='是否公开'), + sa.Column('required_level', sa.Integer(), nullable=True, comment='所需用户等级'), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('is_deleted', sa.Boolean(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_training_scenes_id'), 'training_scenes', ['id'], unique=False) + op.create_table('training_sessions', + sa.Column('user_id', sa.Integer(), nullable=False, comment='用户ID'), + sa.Column('scene_id', sa.Integer(), nullable=False, comment='场景ID'), + sa.Column('coze_conversation_id', sa.String(length=100), nullable=True, comment='Coze会话ID'), + sa.Column('start_time', sa.DateTime(), nullable=False, comment='开始时间'), + sa.Column('end_time', sa.DateTime(), nullable=True, comment='结束时间'), + sa.Column('duration_seconds', sa.Integer(), nullable=True, comment='持续时长(秒)'), + sa.Column('status', sa.Enum('CREATED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'ERROR', name='trainingsessionstatus'), nullable=False, comment='会话状态'), + sa.Column('session_config', sa.JSON(), nullable=True, comment='会话配置'), + sa.Column('total_score', sa.Float(), nullable=True, comment='总分'), + sa.Column('evaluation_result', sa.JSON(), nullable=True, comment='评估结果详情'), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['scene_id'], ['training_scenes.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_training_sessions_id'), 'training_sessions', ['id'], unique=False) + op.create_index(op.f('ix_training_sessions_user_id'), 'training_sessions', ['user_id'], unique=False) + op.create_table('training_messages', + sa.Column('session_id', sa.Integer(), nullable=False, comment='会话ID'), + sa.Column('role', sa.Enum('USER', 'ASSISTANT', 'SYSTEM', name='messagerole'), nullable=False, comment='消息角色'), + sa.Column('type', sa.Enum('TEXT', 'VOICE', 'SYSTEM', name='messagetype'), nullable=False, comment='消息类型'), + sa.Column('content', sa.Text(), nullable=False, comment='消息内容'), + sa.Column('voice_url', sa.String(length=500), nullable=True, comment='语音文件URL'), + sa.Column('voice_duration', sa.Float(), nullable=True, comment='语音时长(秒)'), + sa.Column('message_metadata', sa.JSON(), nullable=True, comment='消息元数据'), + sa.Column('coze_message_id', sa.String(length=100), nullable=True, comment='Coze消息ID'), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['training_sessions.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_training_messages_id'), 'training_messages', ['id'], unique=False) + op.create_table('training_reports', + sa.Column('session_id', sa.Integer(), nullable=False, comment='会话ID'), + sa.Column('user_id', sa.Integer(), nullable=False, comment='用户ID'), + sa.Column('overall_score', sa.Float(), nullable=False, comment='总体得分'), + sa.Column('dimension_scores', sa.JSON(), nullable=False, comment='各维度得分'), + sa.Column('strengths', sa.JSON(), nullable=False, comment='优势点'), + sa.Column('weaknesses', sa.JSON(), nullable=False, comment='待改进点'), + sa.Column('suggestions', sa.JSON(), nullable=False, comment='改进建议'), + sa.Column('detailed_analysis', sa.Text(), nullable=True, comment='详细分析'), + sa.Column('transcript', sa.Text(), nullable=True, comment='对话文本记录'), + sa.Column('statistics', sa.JSON(), nullable=True, comment='统计数据'), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['session_id'], ['training_sessions.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('session_id') + ) + op.create_index(op.f('ix_training_reports_id'), 'training_reports', ['id'], unique=False) + op.create_index(op.f('ix_training_reports_user_id'), 'training_reports', ['user_id'], unique=False) + op.create_table('user_teams', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('team_id', sa.Integer(), nullable=False), + sa.Column('role', sa.String(length=50), nullable=False), + sa.Column('joined_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id', 'team_id'), + sa.UniqueConstraint('user_id', 'team_id', name='uq_user_team') + ) + op.alter_column('course_materials', 'course_id', + existing_type=mysql.INTEGER(), + comment='课程ID', + existing_comment='课程ID', + existing_nullable=False) + op.alter_column('course_materials', 'name', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200), + comment='资料名称', + existing_comment='资料å\x90\x8dç§°', + existing_nullable=False) + op.alter_column('course_materials', 'description', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='资料描述', + existing_comment='资料æ\x8f\x8fè¿°', + existing_nullable=True) + op.alter_column('course_materials', 'file_url', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500), + comment='文件URL', + existing_comment='文件URL', + existing_nullable=False) + op.alter_column('course_materials', 'file_type', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=50), + comment='文件类型', + existing_comment='文件类型', + existing_nullable=False) + op.alter_column('course_materials', 'file_size', + existing_type=mysql.INTEGER(), + comment='文件大小(字节)', + existing_comment='文件大å°\x8f(å\xad—节)', + existing_nullable=False) + op.alter_column('course_materials', 'sort_order', + existing_type=mysql.INTEGER(), + comment='排序顺序', + existing_comment='排åº\x8f顺åº\x8f', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.drop_index('idx_course_materials_course_id', table_name='course_materials') + op.drop_index('idx_course_materials_is_deleted', table_name='course_materials') + op.create_index(op.f('ix_course_materials_id'), 'course_materials', ['id'], unique=False) + op.drop_table_comment( + 'course_materials', + existing_comment='课程资料表', + schema=None + ) + op.alter_column('courses', 'name', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200), + comment='课程名称', + existing_comment='课程å\x90\x8dç§°', + existing_nullable=False) + op.alter_column('courses', 'description', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='课程描述', + existing_comment='课程æ\x8f\x8fè¿°', + existing_nullable=True) + op.alter_column('courses', 'category', + existing_type=mysql.ENUM('technology', 'management', 'business', 'general', collation='utf8mb4_unicode_ci'), + comment='课程分类', + existing_comment='课程分类', + existing_nullable=False, + existing_server_default=sa.text("'general'")) + op.alter_column('courses', 'status', + existing_type=mysql.ENUM('draft', 'published', 'archived', collation='utf8mb4_unicode_ci'), + comment='课程状态', + existing_comment='课程状æ€\x81', + existing_nullable=False, + existing_server_default=sa.text("'draft'")) + op.alter_column('courses', 'cover_image', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500), + comment='封面图片URL', + existing_comment='å°\x81é\x9d¢å›¾ç‰‡URL', + existing_nullable=True) + op.alter_column('courses', 'duration_hours', + existing_type=mysql.FLOAT(), + comment='课程时长(小时)', + existing_comment='课程时长(å°\x8fæ—¶)', + existing_nullable=True) + op.alter_column('courses', 'difficulty_level', + existing_type=mysql.INTEGER(), + comment='难度等级(1-5)', + existing_comment='难度ç\xad‰çº§(1-5)', + existing_nullable=True) + op.alter_column('courses', 'tags', + existing_type=mysql.JSON(), + comment='标签列表', + existing_comment='æ\xa0‡ç\xad¾åˆ—表', + existing_nullable=True) + op.alter_column('courses', 'published_at', + existing_type=mysql.DATETIME(), + comment='发布时间', + existing_comment='å\x8f‘布时间', + existing_nullable=True) + op.alter_column('courses', 'publisher_id', + existing_type=mysql.INTEGER(), + comment='发布人ID', + existing_comment='å\x8f‘布人ID', + existing_nullable=True) + op.alter_column('courses', 'sort_order', + existing_type=mysql.INTEGER(), + comment='排序顺序', + existing_comment='排åº\x8f顺åº\x8f', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('courses', 'is_featured', + existing_type=mysql.TINYINT(display_width=1), + comment='是否推荐', + existing_comment='是å\x90¦æŽ¨è\x8d\x90', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.drop_index('idx_courses_category', table_name='courses') + op.drop_index('idx_courses_is_deleted', table_name='courses') + op.drop_index('idx_courses_status', table_name='courses') + op.create_index(op.f('ix_courses_id'), 'courses', ['id'], unique=False) + op.drop_table_comment( + 'courses', + existing_comment='课程表', + schema=None + ) + op.alter_column('growth_paths', 'name', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200), + comment='路径名称', + existing_comment='路径å\x90\x8dç§°', + existing_nullable=False) + op.alter_column('growth_paths', 'description', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='路径描述', + existing_comment='路径æ\x8f\x8fè¿°', + existing_nullable=True) + op.alter_column('growth_paths', 'target_role', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=100), + comment='目标角色', + existing_comment='ç›®æ\xa0‡è§’色', + existing_nullable=True) + op.alter_column('growth_paths', 'courses', + existing_type=mysql.JSON(), + comment='课程列表[{course_id, order, is_required}]', + existing_comment='课程列表[{course_id, order, is_required}]', + existing_nullable=True) + op.alter_column('growth_paths', 'estimated_duration_days', + existing_type=mysql.INTEGER(), + comment='预计完成天数', + existing_comment='预计完æˆ\x90天数', + existing_nullable=True) + op.alter_column('growth_paths', 'is_active', + existing_type=mysql.TINYINT(display_width=1), + comment='是否启用', + existing_comment='是å\x90¦å\x90¯ç”¨', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('growth_paths', 'sort_order', + existing_type=mysql.INTEGER(), + comment='排序顺序', + existing_comment='排åº\x8f顺åº\x8f', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.drop_index('idx_growth_paths_is_active', table_name='growth_paths') + op.drop_index('idx_growth_paths_is_deleted', table_name='growth_paths') + op.create_index(op.f('ix_growth_paths_id'), 'growth_paths', ['id'], unique=False) + op.drop_table_comment( + 'growth_paths', + existing_comment='æˆ\x90长路径表', + schema=None + ) + op.alter_column('knowledge_points', 'course_id', + existing_type=mysql.INTEGER(), + comment='课程ID', + existing_comment='课程ID', + existing_nullable=False) + op.alter_column('knowledge_points', 'name', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200), + comment='知识点名称', + existing_comment='知识点å\x90\x8dç§°', + existing_nullable=False) + op.alter_column('knowledge_points', 'description', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='知识点描述', + existing_comment='知识点æ\x8f\x8fè¿°', + existing_nullable=True) + op.alter_column('knowledge_points', 'parent_id', + existing_type=mysql.INTEGER(), + comment='父知识点ID', + existing_comment='父知识点ID', + existing_nullable=True) + op.alter_column('knowledge_points', 'level', + existing_type=mysql.INTEGER(), + comment='层级深度', + existing_comment='层级深度', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('knowledge_points', 'path', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500), + comment='路径(如: 1.2.3)', + existing_comment='路径(如: 1.2.3)', + existing_nullable=True) + op.alter_column('knowledge_points', 'sort_order', + existing_type=mysql.INTEGER(), + comment='排序顺序', + existing_comment='排åº\x8f顺åº\x8f', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('knowledge_points', 'weight', + existing_type=mysql.FLOAT(), + comment='权重', + existing_comment='æ\x9dƒé‡\x8d', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('knowledge_points', 'is_required', + existing_type=mysql.TINYINT(display_width=1), + comment='是否必修', + existing_comment='是å\x90¦å¿…ä¿®', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('knowledge_points', 'estimated_hours', + existing_type=mysql.FLOAT(), + comment='预计学习时间(小时)', + existing_comment='预计å\xad¦ä¹\xa0æ—¶é—´(å°\x8fæ—¶)', + existing_nullable=True) + op.drop_index('idx_knowledge_points_course_id', table_name='knowledge_points') + op.drop_index('idx_knowledge_points_is_deleted', table_name='knowledge_points') + op.drop_index('idx_knowledge_points_parent_id', table_name='knowledge_points') + op.create_index(op.f('ix_knowledge_points_id'), 'knowledge_points', ['id'], unique=False) + op.drop_table_comment( + 'knowledge_points', + existing_comment='知识点表', + schema=None + ) + op.drop_index('ix_teams_is_deleted', table_name='teams') + op.drop_index('ix_teams_parent_id', table_name='teams') + op.create_foreign_key(None, 'teams', 'users', ['leader_id'], ['id'], ondelete='SET NULL') + op.create_foreign_key(None, 'teams', 'teams', ['parent_id'], ['id'], ondelete='CASCADE') + op.drop_column('teams', 'deleted_at') + op.drop_column('teams', 'created_by') + op.drop_column('teams', 'updated_by') + op.drop_column('teams', 'is_deleted') + op.add_column('users', sa.Column('bio', sa.Text(), nullable=True)) + op.add_column('users', sa.Column('is_verified', sa.Boolean(), nullable=False)) + op.add_column('users', sa.Column('last_login_at', sa.DateTime(), nullable=True)) + op.add_column('users', sa.Column('password_changed_at', sa.DateTime(), nullable=True)) + op.alter_column('users', 'username', + existing_type=mysql.VARCHAR(length=50), + comment=None, + existing_comment='用户名', + existing_nullable=False) + op.alter_column('users', 'email', + existing_type=mysql.VARCHAR(length=100), + comment=None, + existing_comment='邮箱', + existing_nullable=False) + op.alter_column('users', 'phone', + existing_type=mysql.VARCHAR(length=20), + comment=None, + existing_comment='手机号', + existing_nullable=True) + op.alter_column('users', 'password_hash', + existing_type=mysql.VARCHAR(length=200), + comment=None, + existing_comment='密码哈希', + existing_nullable=False) + op.alter_column('users', 'full_name', + existing_type=mysql.VARCHAR(length=100), + comment=None, + existing_comment='全名', + existing_nullable=True) + op.alter_column('users', 'avatar_url', + existing_type=mysql.VARCHAR(length=500), + comment=None, + existing_comment='头像URL', + existing_nullable=True) + op.alter_column('users', 'role', + existing_type=mysql.VARCHAR(length=20), + nullable=False, + comment=None, + existing_comment='角色: trainee(学员), manager(管理者), admin(管理员)') + op.alter_column('users', 'is_active', + existing_type=mysql.TINYINT(display_width=1), + nullable=False, + comment=None, + existing_comment='是否激活') + op.alter_column('users', 'id', + existing_type=mysql.BIGINT(), + type_=sa.Integer(), + existing_nullable=False, + autoincrement=True) + op.create_unique_constraint(None, 'users', ['phone']) + op.drop_column('users', 'position') + op.drop_column('users', 'is_superuser') + op.drop_column('users', 'locked_until') + op.drop_column('users', 'login_count') + op.drop_column('users', 'failed_login_count') + op.drop_column('users', 'department') + op.drop_column('users', 'created_by') + op.drop_column('users', 'updated_by') + op.drop_column('users', 'last_login') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('last_login', mysql.VARCHAR(length=100), nullable=True, comment='最后登录时间')) + op.add_column('users', sa.Column('updated_by', mysql.BIGINT(), autoincrement=False, nullable=True)) + op.add_column('users', sa.Column('created_by', mysql.BIGINT(), autoincrement=False, nullable=True)) + op.add_column('users', sa.Column('department', mysql.VARCHAR(length=100), nullable=True, comment='部门')) + op.add_column('users', sa.Column('failed_login_count', mysql.VARCHAR(length=100), nullable=True, comment='失败登录次数')) + op.add_column('users', sa.Column('login_count', mysql.VARCHAR(length=100), nullable=True, comment='登录次数')) + op.add_column('users', sa.Column('locked_until', mysql.VARCHAR(length=100), nullable=True, comment='锁定到期时间')) + op.add_column('users', sa.Column('is_superuser', mysql.TINYINT(display_width=1), autoincrement=False, nullable=True, comment='是否超级管理员')) + op.add_column('users', sa.Column('position', mysql.VARCHAR(length=100), nullable=True, comment='职位')) + op.drop_constraint(None, 'users', type_='unique') + op.alter_column('users', 'id', + existing_type=sa.Integer(), + type_=mysql.BIGINT(), + existing_nullable=False, + autoincrement=True) + op.alter_column('users', 'is_active', + existing_type=mysql.TINYINT(display_width=1), + nullable=True, + comment='是否激活') + op.alter_column('users', 'role', + existing_type=mysql.VARCHAR(length=20), + nullable=True, + comment='角色: trainee(学员), manager(管理者), admin(管理员)') + op.alter_column('users', 'avatar_url', + existing_type=mysql.VARCHAR(length=500), + comment='头像URL', + existing_nullable=True) + op.alter_column('users', 'full_name', + existing_type=mysql.VARCHAR(length=100), + comment='全名', + existing_nullable=True) + op.alter_column('users', 'password_hash', + existing_type=mysql.VARCHAR(length=200), + comment='密码哈希', + existing_nullable=False) + op.alter_column('users', 'phone', + existing_type=mysql.VARCHAR(length=20), + comment='手机号', + existing_nullable=True) + op.alter_column('users', 'email', + existing_type=mysql.VARCHAR(length=100), + comment='邮箱', + existing_nullable=False) + op.alter_column('users', 'username', + existing_type=mysql.VARCHAR(length=50), + comment='用户名', + existing_nullable=False) + op.drop_column('users', 'password_changed_at') + op.drop_column('users', 'last_login_at') + op.drop_column('users', 'is_verified') + op.drop_column('users', 'bio') + op.add_column('teams', sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text("'0'"), autoincrement=False, nullable=False)) + op.add_column('teams', sa.Column('updated_by', mysql.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('teams', sa.Column('created_by', mysql.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('teams', sa.Column('deleted_at', mysql.DATETIME(), nullable=True)) + op.drop_constraint(None, 'teams', type_='foreignkey') + op.drop_constraint(None, 'teams', type_='foreignkey') + op.create_index('ix_teams_parent_id', 'teams', ['parent_id'], unique=False) + op.create_index('ix_teams_is_deleted', 'teams', ['is_deleted'], unique=False) + op.create_table_comment( + 'knowledge_points', + '知识点表', + existing_comment=None, + schema=None + ) + op.drop_index(op.f('ix_knowledge_points_id'), table_name='knowledge_points') + op.create_index('idx_knowledge_points_parent_id', 'knowledge_points', ['parent_id'], unique=False) + op.create_index('idx_knowledge_points_is_deleted', 'knowledge_points', ['is_deleted'], unique=False) + op.create_index('idx_knowledge_points_course_id', 'knowledge_points', ['course_id'], unique=False) + op.alter_column('knowledge_points', 'estimated_hours', + existing_type=mysql.FLOAT(), + comment='预计å\xad¦ä¹\xa0æ—¶é—´(å°\x8fæ—¶)', + existing_comment='预计学习时间(小时)', + existing_nullable=True) + op.alter_column('knowledge_points', 'is_required', + existing_type=mysql.TINYINT(display_width=1), + comment='是å\x90¦å¿…ä¿®', + existing_comment='是否必修', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('knowledge_points', 'weight', + existing_type=mysql.FLOAT(), + comment='æ\x9dƒé‡\x8d', + existing_comment='权重', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('knowledge_points', 'sort_order', + existing_type=mysql.INTEGER(), + comment='排åº\x8f顺åº\x8f', + existing_comment='排序顺序', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('knowledge_points', 'path', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500), + comment='路径(如: 1.2.3)', + existing_comment='路径(如: 1.2.3)', + existing_nullable=True) + op.alter_column('knowledge_points', 'level', + existing_type=mysql.INTEGER(), + comment='层级深度', + existing_comment='层级深度', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('knowledge_points', 'parent_id', + existing_type=mysql.INTEGER(), + comment='父知识点ID', + existing_comment='父知识点ID', + existing_nullable=True) + op.alter_column('knowledge_points', 'description', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='知识点æ\x8f\x8fè¿°', + existing_comment='知识点描述', + existing_nullable=True) + op.alter_column('knowledge_points', 'name', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200), + comment='知识点å\x90\x8dç§°', + existing_comment='知识点名称', + existing_nullable=False) + op.alter_column('knowledge_points', 'course_id', + existing_type=mysql.INTEGER(), + comment='课程ID', + existing_comment='课程ID', + existing_nullable=False) + op.create_table_comment( + 'growth_paths', + 'æˆ\x90长路径表', + existing_comment=None, + schema=None + ) + op.drop_index(op.f('ix_growth_paths_id'), table_name='growth_paths') + op.create_index('idx_growth_paths_is_deleted', 'growth_paths', ['is_deleted'], unique=False) + op.create_index('idx_growth_paths_is_active', 'growth_paths', ['is_active'], unique=False) + op.alter_column('growth_paths', 'sort_order', + existing_type=mysql.INTEGER(), + comment='排åº\x8f顺åº\x8f', + existing_comment='排序顺序', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('growth_paths', 'is_active', + existing_type=mysql.TINYINT(display_width=1), + comment='是å\x90¦å\x90¯ç”¨', + existing_comment='是否启用', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('growth_paths', 'estimated_duration_days', + existing_type=mysql.INTEGER(), + comment='预计完æˆ\x90天数', + existing_comment='预计完成天数', + existing_nullable=True) + op.alter_column('growth_paths', 'courses', + existing_type=mysql.JSON(), + comment='课程列表[{course_id, order, is_required}]', + existing_comment='课程列表[{course_id, order, is_required}]', + existing_nullable=True) + op.alter_column('growth_paths', 'target_role', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=100), + comment='ç›®æ\xa0‡è§’色', + existing_comment='目标角色', + existing_nullable=True) + op.alter_column('growth_paths', 'description', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='路径æ\x8f\x8fè¿°', + existing_comment='路径描述', + existing_nullable=True) + op.alter_column('growth_paths', 'name', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200), + comment='路径å\x90\x8dç§°', + existing_comment='路径名称', + existing_nullable=False) + op.create_table_comment( + 'courses', + '课程表', + existing_comment=None, + schema=None + ) + op.drop_index(op.f('ix_courses_id'), table_name='courses') + op.create_index('idx_courses_status', 'courses', ['status'], unique=False) + op.create_index('idx_courses_is_deleted', 'courses', ['is_deleted'], unique=False) + op.create_index('idx_courses_category', 'courses', ['category'], unique=False) + op.alter_column('courses', 'is_featured', + existing_type=mysql.TINYINT(display_width=1), + comment='是å\x90¦æŽ¨è\x8d\x90', + existing_comment='是否推荐', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('courses', 'sort_order', + existing_type=mysql.INTEGER(), + comment='排åº\x8f顺åº\x8f', + existing_comment='排序顺序', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('courses', 'publisher_id', + existing_type=mysql.INTEGER(), + comment='å\x8f‘布人ID', + existing_comment='发布人ID', + existing_nullable=True) + op.alter_column('courses', 'published_at', + existing_type=mysql.DATETIME(), + comment='å\x8f‘布时间', + existing_comment='发布时间', + existing_nullable=True) + op.alter_column('courses', 'tags', + existing_type=mysql.JSON(), + comment='æ\xa0‡ç\xad¾åˆ—表', + existing_comment='标签列表', + existing_nullable=True) + op.alter_column('courses', 'difficulty_level', + existing_type=mysql.INTEGER(), + comment='难度ç\xad‰çº§(1-5)', + existing_comment='难度等级(1-5)', + existing_nullable=True) + op.alter_column('courses', 'duration_hours', + existing_type=mysql.FLOAT(), + comment='课程时长(å°\x8fæ—¶)', + existing_comment='课程时长(小时)', + existing_nullable=True) + op.alter_column('courses', 'cover_image', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500), + comment='å°\x81é\x9d¢å›¾ç‰‡URL', + existing_comment='封面图片URL', + existing_nullable=True) + op.alter_column('courses', 'status', + existing_type=mysql.ENUM('draft', 'published', 'archived', collation='utf8mb4_unicode_ci'), + comment='课程状æ€\x81', + existing_comment='课程状态', + existing_nullable=False, + existing_server_default=sa.text("'draft'")) + op.alter_column('courses', 'category', + existing_type=mysql.ENUM('technology', 'management', 'business', 'general', collation='utf8mb4_unicode_ci'), + comment='课程分类', + existing_comment='课程分类', + existing_nullable=False, + existing_server_default=sa.text("'general'")) + op.alter_column('courses', 'description', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='课程æ\x8f\x8fè¿°', + existing_comment='课程描述', + existing_nullable=True) + op.alter_column('courses', 'name', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200), + comment='课程å\x90\x8dç§°', + existing_comment='课程名称', + existing_nullable=False) + op.create_table_comment( + 'course_materials', + '课程资料表', + existing_comment=None, + schema=None + ) + op.drop_index(op.f('ix_course_materials_id'), table_name='course_materials') + op.create_index('idx_course_materials_is_deleted', 'course_materials', ['is_deleted'], unique=False) + op.create_index('idx_course_materials_course_id', 'course_materials', ['course_id'], unique=False) + op.alter_column('course_materials', 'sort_order', + existing_type=mysql.INTEGER(), + comment='排åº\x8f顺åº\x8f', + existing_comment='排序顺序', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('course_materials', 'file_size', + existing_type=mysql.INTEGER(), + comment='文件大å°\x8f(å\xad—节)', + existing_comment='文件大小(字节)', + existing_nullable=False) + op.alter_column('course_materials', 'file_type', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=50), + comment='文件类型', + existing_comment='文件类型', + existing_nullable=False) + op.alter_column('course_materials', 'file_url', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500), + comment='文件URL', + existing_comment='文件URL', + existing_nullable=False) + op.alter_column('course_materials', 'description', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='资料æ\x8f\x8fè¿°', + existing_comment='资料描述', + existing_nullable=True) + op.alter_column('course_materials', 'name', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200), + comment='资料å\x90\x8dç§°', + existing_comment='资料名称', + existing_nullable=False) + op.alter_column('course_materials', 'course_id', + existing_type=mysql.INTEGER(), + comment='课程ID', + existing_comment='课程ID', + existing_nullable=False) + op.drop_table('user_teams') + op.drop_index(op.f('ix_training_reports_user_id'), table_name='training_reports') + op.drop_index(op.f('ix_training_reports_id'), table_name='training_reports') + op.drop_table('training_reports') + op.drop_index(op.f('ix_training_messages_id'), table_name='training_messages') + op.drop_table('training_messages') + op.drop_index(op.f('ix_training_sessions_user_id'), table_name='training_sessions') + op.drop_index(op.f('ix_training_sessions_id'), table_name='training_sessions') + op.drop_table('training_sessions') + op.drop_index(op.f('ix_training_scenes_id'), table_name='training_scenes') + op.drop_table('training_scenes') + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/migrations/versions/add_position_skills_level.py b/backend/migrations/versions/add_position_skills_level.py new file mode 100644 index 0000000..d5924c5 --- /dev/null +++ b/backend/migrations/versions/add_position_skills_level.py @@ -0,0 +1,35 @@ +"""Add skills and level fields to positions table + +Revision ID: add_position_skills_level +Revises: 0487635b5e95 +Create Date: 2025-09-22 09:00:00 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'add_position_skills_level' +down_revision = '0487635b5e95' +branch_labels = None +depends_on = None + + +def upgrade(): + """添加skills和level字段到positions表""" + # 添加skills字段(JSON类型存储技能数组) + op.add_column('positions', sa.Column('skills', sa.JSON, nullable=True, comment='核心技能')) + + # 添加level字段(岗位等级) + op.add_column('positions', sa.Column('level', sa.String(20), nullable=True, comment='岗位等级: junior/intermediate/senior/expert')) + + # 添加sort_order字段(排序) + op.add_column('positions', sa.Column('sort_order', sa.Integer, nullable=True, default=0, comment='排序')) + + +def downgrade(): + """移除添加的字段""" + op.drop_column('positions', 'skills') + op.drop_column('positions', 'level') + op.drop_column('positions', 'sort_order') diff --git a/backend/migrations/versions/add_users_soft_delete.py b/backend/migrations/versions/add_users_soft_delete.py new file mode 100644 index 0000000..e39e6fa --- /dev/null +++ b/backend/migrations/versions/add_users_soft_delete.py @@ -0,0 +1,36 @@ +"""add users soft delete columns + +Revision ID: add_users_soft_delete +Revises: 20250921_align_schema_to_design +Create Date: 2025-09-22 03:00:00 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'add_users_soft_delete' +down_revision: Union[str, None] = '20250921_align_schema_to_design' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 为 users 表添加软删除字段 + op.add_column('users', sa.Column('is_deleted', sa.Boolean(), nullable=False, server_default='0')) + op.add_column('users', sa.Column('deleted_at', sa.DateTime(), nullable=True)) + + # 添加索引 + op.create_index('idx_users_is_deleted', 'users', ['is_deleted']) + + +def downgrade() -> None: + # 删除索引 + op.drop_index('idx_users_is_deleted', table_name='users') + + # 删除字段 + op.drop_column('users', 'deleted_at') + op.drop_column('users', 'is_deleted') diff --git a/backend/mysql.cnf b/backend/mysql.cnf new file mode 100644 index 0000000..62c6aa4 --- /dev/null +++ b/backend/mysql.cnf @@ -0,0 +1,46 @@ +[mysqld] +# 字符集配置 +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci + +# 时区配置 - 使用北京时间(东八区) +default-time-zone = '+08:00' + +# 网络配置 - 允许远程连接 +bind-address = 0.0.0.0 +port = 3306 + +# 连接配置 +max_connections = 200 +max_user_connections = 180 + +# 缓冲池配置 +innodb_buffer_pool_size = 256M +innodb_log_file_size = 64M + +# 超时配置 +wait_timeout = 600 +interactive_timeout = 600 + +# 慢查询日志 +slow_query_log = 1 +slow_query_log_file = /var/log/mysql/slow.log +long_query_time = 2 + +# 错误日志 +log_error = /var/log/mysql/error.log + +# 二进制日志 +log_bin = /var/log/mysql/mysql-bin.log +binlog_format = ROW +expire_logs_days = 7 + +# 安全配置 +local_infile = 0 +secure_file_priv = "" + +[mysql] +default-character-set = utf8mb4 + +[client] +default-character-set = utf8mb4 diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..962f569 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,63 @@ +[tool.black] +line-length = 88 +target-version = ['py38'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist + | migrations +)/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true +line_length = 88 +skip_gitignore = true + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "-ra -q" +testpaths = [ + "tests", +] + +[tool.coverage.run] +source = ["app"] +omit = ["*/tests/*", "*/migrations/*", "*/__init__.py"] + +[tool.coverage.report] +precision = 2 +show_missing = true +skip_covered = false + +[build-system] +requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..bd4ac84 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,9 @@ +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +asyncio_mode = auto +addopts = -v --tb=short --strict-markers +markers = + asyncio: marks tests as async diff --git a/backend/requirements-admin.txt b/backend/requirements-admin.txt new file mode 100644 index 0000000..6ef4960 --- /dev/null +++ b/backend/requirements-admin.txt @@ -0,0 +1,20 @@ +# 超级后台额外依赖 +# 用于管理后台独立认证和数据库连接 + +# JWT 认证 +PyJWT==2.8.0 + +# MySQL 同步驱动(用于管理后台直连) +PyMySQL==1.1.0 + +# 开发工具(热重载支持) +watchfiles==0.21.0 + + + + + + + + + diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..bf43bfa --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,18 @@ +-r requirements.txt + +# 测试 +pytest==7.4.3 +pytest-asyncio==0.21.1 +pytest-cov==4.1.0 +# 统一 httpx 版本到 requirements.txt(0.24.1),避免冲突 + +# 代码质量 +black==23.11.0 +isort==5.12.0 +flake8==6.1.0 +mypy==1.7.1 +pylint==3.0.2 + +# 开发工具 +ipython==8.17.2 +watchdog==3.0.0 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..8b4b544 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,53 @@ +# Web框架 +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +python-multipart==0.0.6 +sse-starlette==1.8.2 + +# 数据库 +sqlalchemy==2.0.23 +aiomysql==0.2.0 +alembic==1.12.1 + +# Redis +redis==5.0.1 +aioredis==2.0.1 + +# 数据验证 +pydantic==2.5.0 +pydantic-settings==2.1.0 +email-validator==2.1.0 + +# 认证和安全 +python-jose[cryptography]==3.3.0 +passlib==1.7.4 +bcrypt==4.1.2 +python-dotenv==1.0.0 +PyJWT==2.8.0 +PyMySQL==1.1.0 + +# HTTP客户端 +# 与 cozepy==0.19.0 兼容(cozepy 依赖 httpx >= 0.27.0 且 < 0.28.0) +httpx==0.27.2 +aiofiles==23.2.1 + +# 日志 +structlog==23.2.0 + +# AI平台SDK +cozepy==0.19.0 + +# 工具库 +python-dateutil==2.8.2 +tenacity==8.2.3 + +# Excel文件处理(用于课程资料预览) +openpyxl==3.1.2 + +# LLM JSON 解析(知识点分析服务) +json-repair>=0.25.0 +jsonschema>=4.0.0 + +# PDF 文档提取 +PyPDF2>=3.0.0 +python-docx>=1.0.0 \ No newline at end of file diff --git a/backend/requirements/__init__.py b/backend/requirements/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt new file mode 100644 index 0000000..77713c3 --- /dev/null +++ b/backend/requirements/base.txt @@ -0,0 +1,34 @@ +# Core dependencies +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +pydantic==2.5.0 +pydantic-settings==2.1.0 +python-multipart==0.0.6 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-dotenv==1.0.0 + +# Database +sqlalchemy==2.0.23 +aiomysql==0.2.0 +pymysql==1.1.0 +alembic==1.12.1 + +# Redis +redis==5.0.1 + +# HTTP client +httpx==0.25.2 +aiofiles==23.2.1 + +# Logging +structlog==23.2.0 + +# CORS +fastapi-cors==0.0.6 + +# Utils +python-dateutil==2.8.2 + +# Excel文件处理(用于课程资料预览) +openpyxl==3.1.2 \ No newline at end of file diff --git a/backend/requirements/dev.txt b/backend/requirements/dev.txt new file mode 100644 index 0000000..ed90d12 --- /dev/null +++ b/backend/requirements/dev.txt @@ -0,0 +1,21 @@ +# Include base requirements +-r base.txt + +# Testing +pytest==7.4.3 +pytest-asyncio==0.21.1 +pytest-cov==4.1.0 +httpx==0.25.2 +aiosqlite==0.19.0 + +# Code quality +black==23.11.0 +isort==5.12.0 +flake8==6.1.0 +mypy==1.7.1 +types-python-dateutil==2.8.19.14 +types-redis==4.6.0.11 + +# Development tools +ipython==8.18.1 +watchfiles==0.21.0 \ No newline at end of file diff --git a/backend/requirements/prod.txt b/backend/requirements/prod.txt new file mode 100644 index 0000000..9f136df --- /dev/null +++ b/backend/requirements/prod.txt @@ -0,0 +1,10 @@ +# 包含基础依赖 +-r base.txt + +# 生产环境监控 +prometheus-client==0.19.0 +sentry-sdk[fastapi]==1.39.1 + +# 性能优化 +orjson==3.9.10 +ujson==5.9.0 diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/scripts/add_admin_exam_data.sql b/backend/scripts/add_admin_exam_data.sql new file mode 100644 index 0000000..4920c90 --- /dev/null +++ b/backend/scripts/add_admin_exam_data.sql @@ -0,0 +1,215 @@ +-- 为admin用户(id=2)添加考试和错题数据 +-- 方便直接使用admin账号测试成绩报告和错题本功能 + +USE kaopeilian; + +-- ======================================== +-- 一、插入admin的考试记录(包含三轮得分) +-- ======================================== + +-- 考试1:皮肤生理学基础(完成三轮,成绩优秀) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 1, '皮肤生理学基础 - 动态考试', 10, 100.0, 60.0, + '2025-10-01 09:00:00', '2025-10-01 10:25:00', 60, + 85, 95, 100, 100, TRUE, 'submitted' +); + +-- 考试2:医美产品知识与应用(完成三轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 2, '医美产品知识与应用 - 动态考试', 10, 100.0, 60.0, + '2025-10-03 14:00:00', '2025-10-03 15:30:00', 60, + 78, 90, 95, 95, TRUE, 'submitted' +); + +-- 考试3:美容仪器操作与维护(完成两轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 3, '美容仪器操作与维护 - 动态考试', 10, 100.0, 60.0, + '2025-10-05 10:00:00', '2025-10-05 11:10:00', 60, + 92, 100, NULL, 100, TRUE, 'submitted' +); + +-- 考试4:医美项目介绍与咨询(完成三轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 4, '医美项目介绍与咨询 - 动态考试', 10, 100.0, 60.0, + '2025-10-06 15:00:00', '2025-10-06 16:20:00', 60, + 72, 85, 100, 100, TRUE, 'submitted' +); + +-- 考试5:轻医美销售技巧(完成三轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 5, '轻医美销售技巧 - 动态考试', 10, 100.0, 60.0, + '2025-10-07 09:30:00', '2025-10-07 11:00:00', 60, + 88, 95, 100, 100, TRUE, 'submitted' +); + +-- 考试6:客户服务与投诉处理(完成两轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 6, '客户服务与投诉处理 - 动态考试', 10, 100.0, 60.0, + '2025-10-08 14:00:00', '2025-10-08 15:15:00', 60, + 95, 100, NULL, 100, TRUE, 'submitted' +); + +-- 考试7:社媒营销与私域运营(完成一轮,不及格) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 7, '社媒营销与私域运营 - 动态考试', 10, 100.0, 60.0, + '2025-10-09 10:00:00', '2025-10-09 10:48:00', 60, + 58, NULL, NULL, 58, FALSE, 'submitted' +); + +-- 考试8:卫生消毒与感染控制(完成三轮,最近的考试) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 9, '卫生消毒与感染控制 - 动态考试', 10, 100.0, 60.0, + '2025-10-11 09:00:00', '2025-10-11 10:18:00', 60, + 90, 95, 100, 100, TRUE, 'submitted' +); + +-- 考试9:美容心理学(完成三轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 10, '美容心理学 - 动态考试', 10, 100.0, 60.0, + '2025-10-12 08:30:00', '2025-10-12 09:55:00', 60, + 82, 90, 100, 100, TRUE, 'submitted' +); + +-- ======================================== +-- 二、插入admin的错题记录 +-- ======================================== + +-- 获取刚插入的考试ID +SET @admin_exam1 = (SELECT id FROM exams WHERE user_id=2 AND course_id=1 ORDER BY id DESC LIMIT 1); +SET @admin_exam2 = (SELECT id FROM exams WHERE user_id=2 AND course_id=2 ORDER BY id DESC LIMIT 1); +SET @admin_exam4 = (SELECT id FROM exams WHERE user_id=2 AND course_id=4 ORDER BY id DESC LIMIT 1); +SET @admin_exam5 = (SELECT id FROM exams WHERE user_id=2 AND course_id=5 ORDER BY id DESC LIMIT 1); +SET @admin_exam7 = (SELECT id FROM exams WHERE user_id=2 AND course_id=7 ORDER BY id DESC LIMIT 1); +SET @admin_exam9 = (SELECT id FROM exams WHERE user_id=2 AND course_id=9 ORDER BY id DESC LIMIT 1); + +-- 皮肤生理学基础 - 第一轮2道错题 +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(2, @admin_exam1, '表皮层最外层的细胞是?\nA. 基底细胞\nB. 角质细胞\nC. 黑色素细胞\nD. 朗格汉斯细胞', 'B', 'A', 'single', '2025-10-01 09:15:00'), +(2, @admin_exam1, '真皮层的主要成分包括哪些?(多选)\nA. 胶原蛋白\nB. 弹性蛋白\nC. 透明质酸\nD. 角质蛋白', 'A,B,C', 'A,B', 'multiple', '2025-10-01 09:28:00'); + +-- 医美产品知识与应用 - 第一轮3道错题 +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(2, @admin_exam2, '肉毒素的作用机制是?', '阻断神经肌肉接头处的信号传递,使肌肉松弛,从而减少动态皱纹', '收缩肌肉', 'essay', '2025-10-03 14:18:00'), +(2, @admin_exam2, '光子嫩肤术后___天内避免高温环境', '7', '3', 'blank', '2025-10-03 14:35:00'), +(2, @admin_exam2, '所有类型的色斑都可以用激光去除', '错误', '正确', 'judge', '2025-10-03 14:42:00'); + +-- 医美项目介绍与咨询 - 第一轮3道错题 +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(2, @admin_exam4, '面部埋线提升的维持时间通常是?\nA. 3-6个月\nB. 6-12个月\nC. 12-24个月\nD. 永久', 'C', 'B', 'single', '2025-10-06 15:20:00'), +(2, @admin_exam4, '水光针注射的最佳频率是___,共___次为一个疗程', '每3-4周一次,3-5次', '每周一次,10次', 'blank', '2025-10-06 15:38:00'), +(2, @admin_exam4, '请说明如何向客户介绍热玛吉项目的原理和效果', '原理:利用射频能量深入真皮层和筋膜层,刺激胶原蛋白重组再生。效果:紧致提升、改善皱纹、轮廓重塑,效果可持续1-2年。适合25岁以上肌肤松弛人群', '射频加热皮肤,可以紧致', 'essay', '2025-10-06 15:55:00'); + +-- 轻医美销售技巧 - 第一轮2道错题 +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(2, @admin_exam5, '有效挖掘客户需求的提问技巧包括?(多选)\nA. 开放式提问\nB. 封闭式确认\nC. 诱导式提问\nD. 深入式追问', 'A,B,D', 'A,B', 'multiple', '2025-10-07 09:52:00'), +(2, @admin_exam5, '客户说"太贵了"时,最佳应对策略是先___客户真正的顾虑', '了解', '降价', 'blank', '2025-10-07 10:15:00'); + +-- 社媒营销与私域运营 - 第一轮5道错题(不及格) +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(2, @admin_exam7, '私域流量的核心价值在于?\nA. 降低获客成本\nB. 提高复购率\nC. 建立品牌忠诚度\nD. 以上都是', 'D', 'B', 'single', '2025-10-09 10:10:00'), +(2, @admin_exam7, '有效的社群运营需要具备哪些要素?(多选)\nA. 明确定位\nB. 持续输出\nC. 互动反馈\nD. 硬性推销', 'A,B,C', 'A,B', 'multiple', '2025-10-09 10:18:00'), +(2, @admin_exam7, '朋友圈营销应该每天发布10条以上内容', '错误', '正确', 'judge', '2025-10-09 10:25:00'), +(2, @admin_exam7, '社群活跃度下降时,应采取的措施包括___和___', '话题引导,福利刺激', '不管它,等着看', 'blank', '2025-10-09 10:32:00'), +(2, @admin_exam7, '请设计一个针对医美客户的短视频内容策略', '策略要点:1.专业科普(解答常见疑问)2.案例展示(真实效果对比)3.专家访谈(增强信任)4.互动活动(提升参与)5.客户见证(口碑传播)。发布频率:每周3-5条,时间选择晚上7-9点黄金时段', '每天发产品广告', 'essay', '2025-10-09 10:40:00'); + +-- 卫生消毒与感染控制 - 第一轮1道错题 +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(2, @admin_exam9, '医疗器械的消毒等级分为哪几类?(多选)\nA. 高水平消毒\nB. 中水平消毒\nC. 低水平消毒\nD. 无菌处理', 'A,B,C,D', 'A,B,C', 'multiple', '2025-10-11 09:25:00'); + +-- ======================================== +-- 三、验证结果 +-- ======================================== + +-- admin的考试统计 +SELECT + 'admin考试统计' as info, + COUNT(*) as total_exams, + COUNT(round1_score) as has_round1, + COUNT(round2_score) as has_round2, + COUNT(round3_score) as has_round3, + ROUND(AVG(round1_score), 1) as avg_round1, + ROUND(AVG(round2_score), 1) as avg_round2, + ROUND(AVG(round3_score), 1) as avg_round3 +FROM exams +WHERE user_id = 2 AND round1_score IS NOT NULL; + +-- admin的错题统计 +SELECT + 'admin错题统计' as info, + COUNT(*) as total_mistakes, + COUNT(DISTINCT exam_id) as distinct_exams, + COUNT(DISTINCT question_type) as distinct_types +FROM exam_mistakes +WHERE user_id = 2; + +-- 按题型统计admin的错题 +SELECT + question_type, + COUNT(*) as count +FROM exam_mistakes +WHERE user_id = 2 AND question_type IS NOT NULL +GROUP BY question_type +ORDER BY count DESC; + +-- 显示admin最近5条考试记录 +SELECT + id, + exam_name, + round1_score as 第一轮, + round2_score as 第二轮, + round3_score as 第三轮, + is_passed as 是否通过, + DATE_FORMAT(start_time, '%m-%d %H:%i') as 考试时间 +FROM exams +WHERE user_id = 2 AND round1_score IS NOT NULL +ORDER BY start_time DESC +LIMIT 10; + diff --git a/backend/scripts/add_admin_learning_data.sql b/backend/scripts/add_admin_learning_data.sql new file mode 100644 index 0000000..b18e6c1 --- /dev/null +++ b/backend/scripts/add_admin_learning_data.sql @@ -0,0 +1,164 @@ +-- ============================================ +-- 为 superadmin 和 admin 添加学习记录 +-- ============================================ + +USE `kaopeilian`; + +-- 设置用户ID +SET @superadmin_id = 4; +SET @admin_id = 2; + +-- 获取训练场景ID +SET @scene1_id = (SELECT id FROM training_scenes WHERE name = 'Python编程助手' LIMIT 1); +SET @scene2_id = (SELECT id FROM training_scenes WHERE name = '面试模拟' LIMIT 1); +SET @scene3_id = (SELECT id FROM training_scenes WHERE name = '项目讨论' LIMIT 1); + +-- 获取课程ID +SET @course_id = 4; + +-- ============================================ +-- 1. 为 superadmin 添加训练会话记录(高级管理员,学习记录较多) +-- ============================================ +INSERT INTO training_sessions ( + user_id, + scene_id, + start_time, + end_time, + duration_seconds, + status, + total_score, + evaluation_result, + created_by +) VALUES +-- 30天的丰富学习记录 +(@superadmin_id, @scene1_id, '2025-08-23 09:00:00', '2025-08-23 10:30:00', 5400, 'COMPLETED', 95, '{"feedback": "架构设计优秀"}', @superadmin_id), +(@superadmin_id, @scene3_id, '2025-08-25 14:00:00', '2025-08-25 16:00:00', 7200, 'COMPLETED', 98, '{"feedback": "系统设计能力出色"}', @superadmin_id), +(@superadmin_id, @scene2_id, '2025-08-28 10:00:00', '2025-08-28 11:30:00', 5400, 'COMPLETED', 96, '{"feedback": "技术面试官水平"}', @superadmin_id), +(@superadmin_id, @scene1_id, '2025-09-01 09:00:00', '2025-09-01 10:00:00', 3600, 'COMPLETED', 94, '{"feedback": "代码审查能力强"}', @superadmin_id), +(@superadmin_id, @scene3_id, '2025-09-03 13:30:00', '2025-09-03 15:30:00', 7200, 'COMPLETED', 97, '{"feedback": "技术决策合理"}', @superadmin_id), +(@superadmin_id, @scene1_id, '2025-09-05 10:00:00', '2025-09-05 11:00:00', 3600, 'COMPLETED', 95, '{"feedback": "性能优化出色"}', @superadmin_id), +(@superadmin_id, @scene2_id, '2025-09-08 14:00:00', '2025-09-08 15:30:00', 5400, 'COMPLETED', 93, '{"feedback": "面试策略成熟"}', @superadmin_id), +(@superadmin_id, @scene3_id, '2025-09-10 09:00:00', '2025-09-10 11:00:00', 7200, 'COMPLETED', 96, '{"feedback": "项目管理经验丰富"}', @superadmin_id), +(@superadmin_id, @scene1_id, '2025-09-12 10:00:00', '2025-09-12 11:30:00', 5400, 'COMPLETED', 98, '{"feedback": "技术深度令人印象深刻"}', @superadmin_id), +(@superadmin_id, @scene2_id, '2025-09-14 14:00:00', '2025-09-14 15:00:00', 3600, 'COMPLETED', 95, '{"feedback": "团队管理能力优秀"}', @superadmin_id), +(@superadmin_id, @scene3_id, '2025-09-16 09:30:00', '2025-09-16 11:30:00', 7200, 'COMPLETED', 97, '{"feedback": "架构视野开阔"}', @superadmin_id), +(@superadmin_id, @scene1_id, '2025-09-18 10:00:00', '2025-09-18 11:00:00', 3600, 'COMPLETED', 96, '{"feedback": "最佳实践掌握透彻"}', @superadmin_id), +(@superadmin_id, @scene2_id, '2025-09-20 14:00:00', '2025-09-20 15:30:00', 5400, 'COMPLETED', 94, '{"feedback": "人才评估准确"}', @superadmin_id), +(@superadmin_id, @scene3_id, '2025-09-21 09:00:00', '2025-09-21 11:00:00', 7200, 'COMPLETED', 98, '{"feedback": "技术方案完善"}', @superadmin_id), +(@superadmin_id, @scene1_id, '2025-09-22 10:00:00', '2025-09-22 11:30:00', 5400, 'COMPLETED', 97, '{"feedback": "持续学习精神可嘉"}', @superadmin_id); + +-- ============================================ +-- 2. 为 admin 添加训练会话记录(普通管理员,学习记录适中) +-- ============================================ +INSERT INTO training_sessions ( + user_id, + scene_id, + start_time, + end_time, + duration_seconds, + status, + total_score, + evaluation_result, + created_by +) VALUES +-- 20天的学习记录 +(@admin_id, @scene1_id, '2025-09-03 09:00:00', '2025-09-03 10:00:00', 3600, 'COMPLETED', 88, '{"feedback": "基础扎实"}', @admin_id), +(@admin_id, @scene2_id, '2025-09-05 14:00:00', '2025-09-05 15:00:00', 3600, 'COMPLETED', 85, '{"feedback": "面试技巧良好"}', @admin_id), +(@admin_id, @scene3_id, '2025-09-08 10:00:00', '2025-09-08 11:30:00', 5400, 'COMPLETED', 90, '{"feedback": "项目理解到位"}', @admin_id), +(@admin_id, @scene1_id, '2025-09-10 09:30:00', '2025-09-10 10:30:00', 3600, 'COMPLETED', 87, '{"feedback": "进步明显"}', @admin_id), +(@admin_id, @scene2_id, '2025-09-12 14:00:00', '2025-09-12 15:00:00', 3600, 'COMPLETED', 89, '{"feedback": "沟通能力提升"}', @admin_id), +(@admin_id, @scene3_id, '2025-09-15 10:00:00', '2025-09-15 11:30:00', 5400, 'COMPLETED', 91, '{"feedback": "方案设计合理"}', @admin_id), +(@admin_id, @scene1_id, '2025-09-17 09:00:00', '2025-09-17 10:00:00', 3600, 'COMPLETED', 86, '{"feedback": "代码质量不错"}', @admin_id), +(@admin_id, @scene2_id, '2025-09-19 14:00:00', '2025-09-19 15:00:00', 3600, 'COMPLETED', 88, '{"feedback": "表达清晰"}', @admin_id), +(@admin_id, @scene3_id, '2025-09-21 10:00:00', '2025-09-21 11:30:00', 5400, 'COMPLETED', 92, '{"feedback": "项目管理有进步"}', @admin_id), +(@admin_id, @scene1_id, '2025-09-22 09:00:00', '2025-09-22 10:00:00', 3600, 'COMPLETED', 90, '{"feedback": "技术理解深入"}', @admin_id); + +-- ============================================ +-- 3. 为管理员添加考试记录 +-- ============================================ + +-- superadmin 的考试记录(成绩优秀) +INSERT INTO exams ( + user_id, + course_id, + exam_name, + question_count, + total_score, + pass_score, + start_time, + end_time, + duration_minutes, + score, + is_passed, + status, + questions, + answers +) VALUES +-- superadmin 的考试(高分) +(@superadmin_id, @course_id, 'Python高级特性测试', 40, 100, 80, + '2025-09-01 09:00:00', '2025-09-01 10:00:00', 60, 98, 1, 'completed', + '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40]', + '{}'), + +(@superadmin_id, @course_id, 'Python架构设计测试', 50, 100, 85, + '2025-09-10 10:00:00', '2025-09-10 11:30:00', 90, 96, 1, 'completed', + '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50]', + '{}'), + +(@superadmin_id, @course_id, 'Python性能优化测试', 35, 100, 80, + '2025-09-20 14:00:00', '2025-09-20 15:15:00', 75, 95, 1, 'completed', + '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35]', + '{}'), + +-- admin 的考试记录(成绩良好) +(@admin_id, @course_id, 'Python基础测试', 30, 100, 60, + '2025-09-05 14:00:00', '2025-09-05 15:00:00', 60, 85, 1, 'completed', + '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30]', + '{}'), + +(@admin_id, @course_id, 'Python进阶测试', 35, 100, 70, + '2025-09-15 10:00:00', '2025-09-15 11:15:00', 75, 88, 1, 'completed', + '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35]', + '{}'); + +-- ============================================ +-- 查询验证 +-- ============================================ + +-- superadmin 的统计 +SELECT + 'superadmin训练统计' as type, + COUNT(DISTINCT DATE(start_time)) as learning_days, + ROUND(SUM(duration_seconds) / 3600, 1) as total_hours, + COUNT(*) as session_count, + ROUND(AVG(total_score), 1) as avg_score +FROM training_sessions +WHERE user_id = @superadmin_id; + +SELECT + 'superadmin考试统计' as type, + COUNT(*) as exam_count, + SUM(question_count) as total_questions, + ROUND(AVG(score), 1) as avg_score +FROM exams +WHERE user_id = @superadmin_id AND status = 'completed'; + +-- admin 的统计 +SELECT + 'admin训练统计' as type, + COUNT(DISTINCT DATE(start_time)) as learning_days, + ROUND(SUM(duration_seconds) / 3600, 1) as total_hours, + COUNT(*) as session_count, + ROUND(AVG(total_score), 1) as avg_score +FROM training_sessions +WHERE user_id = @admin_id; + +SELECT + 'admin考试统计' as type, + COUNT(*) as exam_count, + SUM(question_count) as total_questions, + ROUND(AVG(score), 1) as avg_score +FROM exams +WHERE user_id = @admin_id AND status = 'completed'; + +SELECT '管理员学习数据添加完成!' as message; diff --git a/backend/scripts/add_exam_and_mistakes_demo_data.sql b/backend/scripts/add_exam_and_mistakes_demo_data.sql new file mode 100644 index 0000000..0d9a1da --- /dev/null +++ b/backend/scripts/add_exam_and_mistakes_demo_data.sql @@ -0,0 +1,290 @@ +-- 模拟数据脚本:考试成绩和错题记录 +-- 创建时间:2025-10-12 +-- 说明:为成绩报告和错题本页面添加演示数据 +-- 场景:轻医美连锁品牌员工培训考试 + +USE kaopeilian; + +-- ======================================== +-- 一、插入考试记录(包含三轮得分) +-- ======================================== + +-- 用户5(consultant_001)的考试记录 +-- 考试1:皮肤生理学基础(完成三轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 5, 1, '皮肤生理学基础 - 动态考试', 10, 100.0, 60.0, + '2025-10-05 09:00:00', '2025-10-05 10:30:00', 60, + 70, 85, 100, 100, TRUE, 'submitted' +); + +-- 考试2:医美产品知识与应用(完成三轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 5, 2, '医美产品知识与应用 - 动态考试', 10, 100.0, 60.0, + '2025-10-06 14:00:00', '2025-10-06 15:20:00', 60, + 65, 80, 90, 90, TRUE, 'submitted' +); + +-- 考试3:美容仪器操作与维护(完成两轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 5, 3, '美容仪器操作与维护 - 动态考试', 10, 100.0, 60.0, + '2025-10-07 10:00:00', '2025-10-07 11:15:00', 60, + 80, 95, NULL, 95, TRUE, 'submitted' +); + +-- 考试4:医美项目介绍与咨询(完成一轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 5, 4, '医美项目介绍与咨询 - 动态考试', 10, 100.0, 60.0, + '2025-10-08 15:00:00', '2025-10-08 15:45:00', 60, + 55, NULL, NULL, 55, FALSE, 'submitted' +); + +-- 考试5:轻医美销售技巧(完成三轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 5, 5, '轻医美销售技巧 - 动态考试', 10, 100.0, 60.0, + '2025-10-09 09:30:00', '2025-10-09 11:00:00', 60, + 75, 90, 100, 100, TRUE, 'submitted' +); + +-- 考试6:客户服务与投诉处理(完成三轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 5, 6, '客户服务与投诉处理 - 动态考试', 10, 100.0, 60.0, + '2025-10-10 14:00:00', '2025-10-10 15:30:00', 60, + 85, 95, 100, 100, TRUE, 'submitted' +); + +-- 考试7:社媒营销与私域运营(完成两轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 5, 7, '社媒营销与私域运营 - 动态考试', 10, 100.0, 60.0, + '2025-10-11 10:00:00', '2025-10-11 11:10:00', 60, + 60, 75, NULL, 75, TRUE, 'submitted' +); + +-- 考试8:卫生消毒与感染控制(完成三轮,最近的考试) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 5, 9, '卫生消毒与感染控制 - 动态考试', 10, 100.0, 60.0, + '2025-10-12 09:00:00', '2025-10-12 10:20:00', 60, + 90, 100, 100, 100, TRUE, 'submitted' +); + +-- ======================================== +-- 二、插入错题记录 +-- ======================================== + +-- 获取刚插入的考试ID(使用最后8条记录) +SET @exam1_id = (SELECT id FROM exams WHERE user_id=5 AND course_id=1 ORDER BY id DESC LIMIT 1); +SET @exam2_id = (SELECT id FROM exams WHERE user_id=5 AND course_id=2 ORDER BY id DESC LIMIT 1); +SET @exam4_id = (SELECT id FROM exams WHERE user_id=5 AND course_id=4 ORDER BY id DESC LIMIT 1); +SET @exam5_id = (SELECT id FROM exams WHERE user_id=5 AND course_id=5 ORDER BY id DESC LIMIT 1); +SET @exam7_id = (SELECT id FROM exams WHERE user_id=5 AND course_id=7 ORDER BY id DESC LIMIT 1); + +-- 考试1的错题(皮肤生理学基础 - 第一轮3道错题) +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(5, @exam1_id, '人体皮肤分为哪几层?', '表皮层、真皮层、皮下组织三层', '表皮和真皮两层', 'essay', '2025-10-05 09:15:00'), +(5, @exam1_id, '关于玻尿酸的作用,以下哪项描述是正确的?\nA. 只能用于填充\nB. 具有保湿和填充双重作用\nC. 不能用于面部\nD. 只适合年轻人使用', 'B', 'A', 'single', '2025-10-05 09:25:00'), +(5, @exam1_id, '敏感肌肤的客户可以使用含酒精的护肤品', '错误', '正确', 'judge', '2025-10-05 09:35:00'); + +-- 考试2的错题(医美产品知识与应用 - 第一轮4道错题) +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(5, @exam2_id, '光子嫩肤的主要功效包括哪些?(多选)\nA. 美白淡斑\nB. 收缩毛孔\nC. 去除皱纹\nD. 改善红血丝', 'A,B,D', 'A,B,C', 'multiple', '2025-10-06 14:10:00'), +(5, @exam2_id, '水光针的主要成分是___', '透明质酸(玻尿酸)', '胶原蛋白', 'blank', '2025-10-06 14:20:00'), +(5, @exam2_id, '热玛吉适用于所有肤质', '正确', '错误', 'judge', '2025-10-06 14:30:00'), +(5, @exam2_id, '超声刀的作用原理是什么?', '通过高强度聚焦超声波能量作用于筋膜层,促进胶原蛋白再生,达到紧致提拉效果', '利用超声波震动按摩皮肤', 'essay', '2025-10-06 14:40:00'); + +-- 考试4的错题(医美项目介绍与咨询 - 第一轮5道错题,未通过) +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(5, @exam4_id, '面部提升项目适合的年龄段是?\nA. 25岁以下\nB. 25-35岁\nC. 35岁以上\nD. 所有年龄段', 'C', 'B', 'single', '2025-10-08 15:10:00'), +(5, @exam4_id, '皮秒激光可以治疗哪些皮肤问题?(多选)\nA. 色斑\nB. 痘印\nC. 毛孔粗大\nD. 皮肤松弛', 'A,B,C', 'A,B', 'multiple', '2025-10-08 15:15:00'), +(5, @exam4_id, '果酸焕肤后需要严格防晒', '正确', '错误', 'judge', '2025-10-08 15:20:00'), +(5, @exam4_id, '光子嫩肤一个疗程通常需要___次治疗,间隔___周', '3-5次,3-4周', '5-8次,2周', 'blank', '2025-10-08 15:25:00'), +(5, @exam4_id, '请简述如何向客户介绍肉毒素除皱项目的优势和注意事项', '优势:快速见效、微创无痕、可逆性强、针对性强。注意事项:需选择正规品牌、术后避免按摩、孕妇和哺乳期禁用、过敏体质需提前告知', '肉毒素可以除皱,效果很好,没有副作用', 'essay', '2025-10-08 15:30:00'); + +-- 考试5的错题(轻医美销售技巧 - 第一轮3道错题) +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(5, @exam5_id, '在销售咨询中,FABE销售法则中的F代表?\nA. Features(特征)\nB. Functions(功能)\nC. Facts(事实)\nD. Feelings(感受)', 'A', 'C', 'single', '2025-10-09 09:45:00'), +(5, @exam5_id, '有效的销售话术应该具备哪些特点?(多选)\nA. 专业准确\nB. 简单易懂\nC. 夸大效果\nD. 针对性强', 'A,B,D', 'A,B', 'multiple', '2025-10-09 09:55:00'), +(5, @exam5_id, '客户提出价格异议时,第一步应该是___客户的关注点', '倾听和理解', '立即降价', 'blank', '2025-10-09 10:05:00'); + +-- 考试7的错题(社媒营销与私域运营 - 第一轮4道错题) +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(5, @exam7_id, '私域流量运营的核心是?\nA. 大量加粉\nB. 建立信任关系\nC. 频繁推销\nD. 打折促销', 'B', 'A', 'single', '2025-10-11 10:15:00'), +(5, @exam7_id, '短视频内容策划应遵循哪些原则?(多选)\nA. 垂直专业\nB. 持续更新\nC. 互动性强\nD. 纯广告推广', 'A,B,C', 'A,B', 'multiple', '2025-10-11 10:25:00'), +(5, @exam7_id, '企业微信的客户标签管理可以提升运营效率', '正确', '错误', 'judge', '2025-10-11 10:35:00'), +(5, @exam7_id, '请简述如何设计一个有效的会员转化路径', '步骤:1.引流获客(短视频/直播)2.建立信任(专业内容分享)3.激活需求(案例展示/体验活动)4.促成转化(限时优惠/专属福利)5.持续运营(定期回访/会员权益)', '做活动、发优惠券', 'essay', '2025-10-11 10:45:00'); + +-- ======================================== +-- 三、插入更多用户的考试数据(丰富数据) +-- ======================================== + +-- 用户6(其他美容顾问)的考试记录 +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES +(6, 1, '皮肤生理学基础 - 动态考试', 10, 100.0, 60.0, + '2025-10-03 10:00:00', '2025-10-03 11:20:00', 60, + 88, 95, 100, 100, TRUE, 'submitted'), +(6, 2, '医美产品知识与应用 - 动态考试', 10, 100.0, 60.0, + '2025-10-04 14:30:00', '2025-10-04 15:40:00', 60, + 92, 100, NULL, 100, TRUE, 'submitted'), +(6, 6, '客户服务与投诉处理 - 动态考试', 10, 100.0, 60.0, + '2025-10-07 09:00:00', '2025-10-07 10:15:00', 60, + 78, 90, 95, 95, TRUE, 'submitted'); + +-- 用户7的考试记录 +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES +(7, 3, '美容仪器操作与维护 - 动态考试', 10, 100.0, 60.0, + '2025-10-05 11:00:00', '2025-10-05 12:10:00', 60, + 82, 90, 100, 100, TRUE, 'submitted'), +(7, 5, '轻医美销售技巧 - 动态考试', 10, 100.0, 60.0, + '2025-10-09 15:00:00', '2025-10-09 16:15:00', 60, + 70, 85, 90, 90, TRUE, 'submitted'); + +-- ======================================== +-- 四、为新增考试添加对应的错题记录 +-- ======================================== + +-- 用户6的错题(皮肤生理学基础 - 第一轮1道错题) +SET @exam6_1 = (SELECT id FROM exams WHERE user_id=6 AND course_id=1 ORDER BY id DESC LIMIT 1); +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(6, @exam6_1, '皮肤的PH值正常范围是?\nA. 3.5-4.5\nB. 4.5-6.5\nC. 6.5-7.5\nD. 7.5-8.5', 'B', 'C', 'single', '2025-10-03 10:20:00'); + +-- 用户6的错题(医美产品知识与应用 - 第一轮1道错题) +SET @exam6_2 = (SELECT id FROM exams WHERE user_id=6 AND course_id=2 ORDER BY id DESC LIMIT 1); +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(6, @exam6_2, '玻尿酸注射后___小时内不能沾水', '24', '12', 'blank', '2025-10-04 14:50:00'); + +-- 用户6的错题(客户服务与投诉处理 - 第一轮2道错题) +SET @exam6_3 = (SELECT id FROM exams WHERE user_id=6 AND course_id=6 ORDER BY id DESC LIMIT 1); +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(6, @exam6_3, '处理客户投诉的黄金原则是?(多选)\nA. 及时回应\nB. 真诚道歉\nC. 快速解决\nD. 推卸责任', 'A,B,C', 'A,B', 'multiple', '2025-10-07 09:20:00'), +(6, @exam6_3, '客户投诉时,应该先___,再___', '倾听,解决', '解释,辩解', 'blank', '2025-10-07 09:30:00'); + +-- 用户7的错题(美容仪器操作与维护 - 第一轮2道错题) +SET @exam7_1 = (SELECT id FROM exams WHERE user_id=7 AND course_id=3 ORDER BY id DESC LIMIT 1); +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(7, @exam7_1, '射频仪器的工作原理是?\nA. 激光热效应\nB. 电磁波热效应\nC. 超声波振动\nD. 机械摩擦', 'B', 'A', 'single', '2025-10-05 11:20:00'), +(7, @exam7_1, '仪器消毒应该在每次使用___进行', '前后', '前', 'blank', '2025-10-05 11:35:00'); + +-- 用户7的错题(轻医美销售技巧 - 第一轮3道错题) +SET @exam7_2 = (SELECT id FROM exams WHERE user_id=7 AND course_id=5 ORDER BY id DESC LIMIT 1); +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(7, @exam7_2, '成交话术中,假设成交法的核心是?\nA. 直接要求客户付款\nB. 假设客户已经同意购买\nC. 给客户压力\nD. 降价促销', 'B', 'A', 'single', '2025-10-09 15:25:00'), +(7, @exam7_2, '顾客异议处理时应避免的做法包括?(多选)\nA. 打断顾客说话\nB. 否认顾客观点\nC. 耐心倾听\nD. 与顾客争辩', 'A,B,D', 'A,B', 'multiple', '2025-10-09 15:35:00'), +(7, @exam7_2, '请列举3种常用的促成交易的方法', '1.假设成交法 2.二选一法 3.优惠刺激法 4.紧迫感营造法(任选3种)', '降价、送礼品', 'essay', '2025-10-09 15:45:00'); + +-- ======================================== +-- 五、验证插入结果 +-- ======================================== + +-- 统计考试记录 +SELECT + '考试记录统计' as category, + COUNT(*) as total, + COUNT(round1_score) as has_round1, + COUNT(round2_score) as has_round2, + COUNT(round3_score) as has_round3, + AVG(round1_score) as avg_round1_score +FROM exams +WHERE user_id IN (5, 6, 7); + +-- 统计错题记录 +SELECT + '错题记录统计' as category, + COUNT(*) as total, + COUNT(DISTINCT exam_id) as distinct_exams, + COUNT(DISTINCT question_type) as distinct_types +FROM exam_mistakes +WHERE user_id IN (5, 6, 7); + +-- 按课程统计错题 +SELECT + c.name as course_name, + COUNT(em.id) as mistake_count +FROM exam_mistakes em +JOIN exams e ON em.exam_id = e.id +JOIN courses c ON e.course_id = c.id +WHERE em.user_id IN (5, 6, 7) +GROUP BY c.id, c.name +ORDER BY mistake_count DESC; + +-- 按题型统计错题 +SELECT + question_type, + COUNT(*) as count +FROM exam_mistakes +WHERE user_id IN (5, 6, 7) AND question_type IS NOT NULL +GROUP BY question_type +ORDER BY count DESC; + +-- 显示最近5条考试记录(包含三轮得分) +SELECT + id, + exam_name, + round1_score, + round2_score, + round3_score, + score, + is_passed, + DATE_FORMAT(start_time, '%Y-%m-%d %H:%i') as start_time +FROM exams +WHERE user_id = 5 +ORDER BY start_time DESC +LIMIT 5; + diff --git a/backend/scripts/add_exam_data.sql b/backend/scripts/add_exam_data.sql new file mode 100644 index 0000000..63dae5c --- /dev/null +++ b/backend/scripts/add_exam_data.sql @@ -0,0 +1,78 @@ +-- ============================================ +-- 为 testuser 添加考试记录 +-- ============================================ + +USE `kaopeilian`; + +-- 设置 testuser 的 ID +SET @test_user_id = 1; + +-- 获取第一个课程ID(Python基础课程) +SET @course_id = 4; + +-- 添加考试记录(exams 表是用户的考试实例) +INSERT INTO exams ( + user_id, + course_id, + exam_name, + question_count, + total_score, + pass_score, + start_time, + end_time, + duration_minutes, + score, + is_passed, + status, + questions, + answers +) VALUES +-- 第一次考试(15天前) +(@test_user_id, @course_id, 'Python基础测试-第1次', 20, 100, 60, + '2025-09-07 10:00:00', '2025-09-07 10:50:00', 50, 72, 1, 'completed', + '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]', + '{"1":"A","2":"B","3":"A","4":"C","5":"A","6":"B","7":"A","8":"D","9":"A","10":"B","11":"A","12":"C","13":"A","14":"B","15":"A","16":"C","17":"A","18":"B","19":"A","20":"D"}'), + +-- 第二次考试(10天前) +(@test_user_id, @course_id, 'Python基础测试-第2次', 20, 100, 60, + '2025-09-12 14:00:00', '2025-09-12 14:45:00', 45, 85, 1, 'completed', + '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]', + '{"1":"A","2":"A","3":"A","4":"A","5":"A","6":"B","7":"A","8":"A","9":"A","10":"B","11":"A","12":"A","13":"A","14":"B","15":"A","16":"A","17":"A","18":"B","19":"A","20":"A"}'), + +-- 第三次考试(5天前) +(@test_user_id, @course_id, 'Python进阶测试', 25, 100, 70, + '2025-09-17 09:00:00', '2025-09-17 10:15:00', 75, 78, 1, 'completed', + '[21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45]', + '{"21":"A","22":"B","23":"A","24":"C","25":"A","26":"B","27":"A","28":"D","29":"A","30":"B","31":"A","32":"C","33":"A","34":"B","35":"A","36":"C","37":"A","38":"B","39":"A","40":"D","41":"A","42":"B","43":"A","44":"C","45":"A"}'), + +-- 第四次考试(3天前) +(@test_user_id, @course_id, 'Python项目实战测试', 30, 100, 80, + '2025-09-19 13:30:00', '2025-09-19 15:00:00', 90, 92, 1, 'completed', + '[46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75]', + '{"46":"A","47":"A","48":"A","49":"A","50":"A","51":"B","52":"A","53":"A","54":"A","55":"B","56":"A","57":"A","58":"A","59":"B","60":"A","61":"A","62":"A","63":"B","64":"A","65":"A","66":"A","67":"A","68":"A","69":"A","70":"A","71":"B","72":"A","73":"A","74":"A","75":"A"}'), + +-- 最近的考试(昨天) +(@test_user_id, @course_id, 'Python综合测试', 50, 100, 85, + '2025-09-21 10:00:00', '2025-09-21 11:50:00', 110, 95, 1, 'completed', + '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50]', + '{"1":"A","2":"A","3":"A","4":"A","5":"A","6":"A","7":"A","8":"A","9":"A","10":"A","11":"A","12":"A","13":"A","14":"A","15":"A","16":"A","17":"A","18":"A","19":"A","20":"A","21":"A","22":"A","23":"A","24":"A","25":"A","26":"B","27":"A","28":"A","29":"A","30":"B","31":"A","32":"A","33":"A","34":"B","35":"A","36":"A","37":"A","38":"B","39":"A","40":"A","41":"A","42":"B","43":"A","44":"A","45":"A","46":"A","47":"A","48":"A","49":"A","50":"A"}'); + +-- 如果需要添加答题详情(exam_results 表),可以根据需要补充 +-- 这里简化处理,因为统计接口主要用 exams 表的数据 + +-- 查询验证 +SELECT + COUNT(*) as exam_count, + ROUND(AVG(score), 1) as avg_score, + MIN(score) as min_score, + MAX(score) as max_score +FROM exams +WHERE user_id = @test_user_id AND status = 'completed'; + +-- 计算总练习题数(所有考试的题目总和) +SELECT + SUM(question_count) as total_practice_questions +FROM exams +WHERE user_id = @test_user_id AND status = 'completed'; + +SELECT '考试数据添加完成!' as message; diff --git a/backend/scripts/add_exam_tables.sql b/backend/scripts/add_exam_tables.sql new file mode 100644 index 0000000..8315055 --- /dev/null +++ b/backend/scripts/add_exam_tables.sql @@ -0,0 +1,88 @@ +-- 创建考试相关表 + +-- 1. 创建题目表 +CREATE TABLE IF NOT EXISTS `questions` ( + `id` INT NOT NULL AUTO_INCREMENT, + `course_id` INT NOT NULL, + `question_type` VARCHAR(20) NOT NULL COMMENT '题目类型: single_choice, multiple_choice, true_false, fill_blank, essay', + `title` TEXT NOT NULL COMMENT '题目标题', + `content` TEXT NULL COMMENT '题目内容', + `options` JSON NULL COMMENT '选项(JSON格式)', + `correct_answer` TEXT NOT NULL COMMENT '正确答案', + `explanation` TEXT NULL COMMENT '答案解释', + `score` FLOAT DEFAULT 10.0 COMMENT '分值', + `difficulty` VARCHAR(10) DEFAULT 'medium' COMMENT '难度等级: easy, medium, hard', + `tags` JSON NULL COMMENT '标签(JSON格式)', + `usage_count` INT DEFAULT 0 COMMENT '使用次数', + `correct_count` INT DEFAULT 0 COMMENT '答对次数', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_course_id` (`course_id`), + KEY `idx_question_type` (`question_type`), + KEY `idx_difficulty` (`difficulty`), + KEY `idx_is_active` (`is_active`), + CONSTRAINT `fk_questions_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 2. 创建考试记录表 +CREATE TABLE IF NOT EXISTS `exams` ( + `id` INT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `course_id` INT NOT NULL, + `exam_name` VARCHAR(255) NOT NULL COMMENT '考试名称', + `question_count` INT DEFAULT 10 COMMENT '题目数量', + `total_score` FLOAT DEFAULT 100.0 COMMENT '总分', + `pass_score` FLOAT DEFAULT 60.0 COMMENT '及格分', + `start_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + `end_time` DATETIME NULL COMMENT '结束时间', + `duration_minutes` INT DEFAULT 60 COMMENT '考试时长(分钟)', + `score` FLOAT NULL COMMENT '得分', + `is_passed` BOOLEAN NULL COMMENT '是否通过', + `status` VARCHAR(20) DEFAULT 'started' COMMENT '状态: started, submitted, timeout', + `questions` JSON NULL COMMENT '题目数据(JSON格式)', + `answers` JSON NULL COMMENT '答案数据(JSON格式)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_course_id` (`course_id`), + KEY `idx_status` (`status`), + CONSTRAINT `fk_exams_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_exams_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 3. 创建考试结果详情表 +CREATE TABLE IF NOT EXISTS `exam_results` ( + `id` INT NOT NULL AUTO_INCREMENT, + `exam_id` INT NOT NULL, + `question_id` INT NOT NULL, + `user_answer` TEXT NULL COMMENT '用户答案', + `is_correct` BOOLEAN DEFAULT FALSE COMMENT '是否正确', + `score` FLOAT DEFAULT 0.0 COMMENT '得分', + `answer_time` INT NULL COMMENT '答题时长(秒)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_exam_id` (`exam_id`), + KEY `idx_question_id` (`question_id`), + CONSTRAINT `fk_exam_results_exam` FOREIGN KEY (`exam_id`) REFERENCES `exams` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_exam_results_question` FOREIGN KEY (`question_id`) REFERENCES `questions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 4. 插入测试题目数据 +INSERT INTO `questions` (`course_id`, `question_type`, `title`, `content`, `options`, `correct_answer`, `explanation`, `score`, `difficulty`, `tags`) VALUES +(1, 'single_choice', 'Python中哪个关键字用于定义函数?', NULL, '{"A": "def", "B": "function", "C": "fun", "D": "define"}', 'A', 'Python使用def关键字来定义函数', 10.0, 'easy', '["python", "基础", "函数"]'), +(1, 'single_choice', 'Python中列表和元组的主要区别是什么?', NULL, '{"A": "列表是有序的,元组是无序的", "B": "列表可变,元组不可变", "C": "列表只能存储数字,元组可以存储任何类型", "D": "没有区别"}', 'B', '列表是可变的(mutable),而元组是不可变的(immutable)', 10.0, 'medium', '["python", "数据结构"]'), +(1, 'single_choice', '以下哪个不是Python的内置数据类型?', NULL, '{"A": "list", "B": "dict", "C": "array", "D": "tuple"}', 'C', 'array不是Python的内置数据类型,需要导入array模块', 10.0, 'medium', '["python", "数据类型"]'), +(1, 'true_false', 'Python是一种编译型语言', NULL, NULL, 'false', 'Python是一种解释型语言,不需要编译成机器码', 10.0, 'easy', '["python", "基础"]'), +(1, 'true_false', 'Python支持多重继承', NULL, NULL, 'true', 'Python支持多重继承,一个类可以继承多个父类', 10.0, 'medium', '["python", "面向对象"]'); + +-- 5. 插入更多测试题目(如果需要) +INSERT INTO `questions` (`course_id`, `question_type`, `title`, `content`, `options`, `correct_answer`, `explanation`, `score`, `difficulty`, `tags`) VALUES +(1, 'single_choice', 'Python中的装饰器是什么?', NULL, '{"A": "一种设计模式", "B": "用于修改函数或类行为的函数", "C": "一种数据结构", "D": "一种循环结构"}', 'B', '装饰器是一个接受函数作为参数并返回新函数的函数', 15.0, 'hard', '["python", "高级特性", "装饰器"]'), +(1, 'single_choice', '以下哪个方法用于向列表末尾添加元素?', NULL, '{"A": "add()", "B": "insert()", "C": "append()", "D": "extend()"}', 'C', 'append()方法用于向列表末尾添加单个元素', 10.0, 'easy', '["python", "列表", "方法"]'), +(1, 'multiple_choice', 'Python中哪些是可变数据类型?', '请选择所有正确答案', '{"A": "list", "B": "tuple", "C": "dict", "D": "str", "E": "set"}', '["A", "C", "E"]', 'list、dict和set是可变数据类型,而tuple和str是不可变的', 15.0, 'medium', '["python", "数据类型"]'), +(1, 'fill_blank', 'Python中使用____关键字定义类', NULL, NULL, 'class', '使用class关键字定义类', 10.0, 'easy', '["python", "面向对象"]'), +(1, 'essay', '请解释Python中的GIL(全局解释器锁)是什么,以及它对多线程编程的影响', NULL, NULL, 'GIL是Python解释器的一个机制,同一时刻只允许一个线程执行Python字节码。这对CPU密集型的多线程程序性能有负面影响,但对I/O密集型程序影响较小。', 'GIL确保了Python对象的线程安全,但限制了多线程的并行性能', 20.0, 'hard', '["python", "高级", "并发"]'); diff --git a/backend/scripts/add_school_major_fields.py b/backend/scripts/add_school_major_fields.py new file mode 100644 index 0000000..7a71ffe --- /dev/null +++ b/backend/scripts/add_school_major_fields.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python +""" +添加用户表的学校和专业字段 +""" + +import asyncio +import sys +from pathlib import Path +import aiomysql +import os + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent.parent +sys.path.append(str(project_root)) + +from app.core.config import settings + +# SQL脚本 +ADD_FIELDS_SQL = """ +-- 添加 school 和 major 字段到 users 表 +ALTER TABLE users +ADD COLUMN school VARCHAR(100) COMMENT '学校' AFTER bio, +ADD COLUMN major VARCHAR(100) COMMENT '专业' AFTER school; +""" + +async def execute_sql(): + """执行SQL脚本""" + try: + # 从环境变量或配置中获取数据库连接信息 + # 优先使用环境变量 + database_url = os.getenv("DATABASE_URL", settings.DATABASE_URL) + + # 解析数据库连接字符串 + # 格式: mysql+aiomysql://root:root@localhost:3306/kaopeilian + if database_url.startswith("mysql+aiomysql://"): + url = database_url.replace("mysql+aiomysql://", "") + else: + url = database_url + + # 解析连接参数 + auth_host = url.split("@")[1] + user_pass = url.split("@")[0] + host_port_db = auth_host.split("/") + host_port = host_port_db[0].split(":") + + user = user_pass.split(":")[0] + password = user_pass.split(":")[1] + host = host_port[0] + port = int(host_port[1]) if len(host_port) > 1 else 3306 + database = host_port_db[1].split("?")[0] if len(host_port_db) > 1 else "kaopeilian" + + print(f"连接数据库: {host}:{port}/{database}") + + # 创建数据库连接 + conn = await aiomysql.connect( + host=host, + port=port, + user=user, + password=password, + db=database, + charset='utf8mb4' + ) + + async with conn.cursor() as cursor: + # 检查字段是否已存在 + check_sql = """ + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = %s + AND TABLE_NAME = 'users' + AND COLUMN_NAME IN ('school', 'major') + """ + await cursor.execute(check_sql, (database,)) + existing_columns = [row[0] for row in await cursor.fetchall()] + + if 'school' in existing_columns or 'major' in existing_columns: + print("字段已存在,跳过添加") + if 'school' in existing_columns: + print("- school 字段已存在") + if 'major' in existing_columns: + print("- major 字段已存在") + else: + # 执行SQL脚本 + await cursor.execute(ADD_FIELDS_SQL) + await conn.commit() + print("成功添加 school 和 major 字段到 users 表") + + # 显示表结构 + show_sql = "DESC users" + await cursor.execute(show_sql) + columns = await cursor.fetchall() + + print("\n当前 users 表结构:") + print("-" * 80) + for col in columns: + print(f"{col[0]:20} {col[1]:30} {'NULL' if col[2] == 'YES' else 'NOT NULL':10} {col[5] or ''}") + + conn.close() + + except Exception as e: + print(f"执行失败: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + +if __name__ == "__main__": + print("开始添加 school 和 major 字段...") + asyncio.run(execute_sql()) + print("\n执行完成!") diff --git a/backend/scripts/add_training_data.sql b/backend/scripts/add_training_data.sql new file mode 100644 index 0000000..9e747f7 --- /dev/null +++ b/backend/scripts/add_training_data.sql @@ -0,0 +1,63 @@ +-- ============================================ +-- 为 testuser 添加训练会话记录 +-- ============================================ + +USE `kaopeilian`; + +-- 获取 testuser 的 ID (应该是 1) +SET @test_user_id = 1; + +-- 1. 先插入训练场景 +INSERT INTO training_scenes (name, description, category, ai_config, status, is_public, is_deleted, created_by) VALUES +('Python编程助手', '帮助学员解决Python编程问题', '技术辅导', '{"bot_id": "python_assistant_bot"}', 'ACTIVE', 1, 0, 1), +('面试模拟', '模拟技术面试场景', '职业发展', '{"bot_id": "interview_simulator_bot"}', 'ACTIVE', 1, 0, 1), +('项目讨论', '项目方案讨论和优化', '项目管理', '{"bot_id": "project_discussion_bot"}', 'ACTIVE', 1, 0, 1); + +-- 获取场景ID +SET @scene1_id = LAST_INSERT_ID(); +SET @scene2_id = @scene1_id + 1; +SET @scene3_id = @scene1_id + 2; + +-- 2. 添加训练会话记录(分布在最近15天) +INSERT INTO training_sessions ( + user_id, + scene_id, + start_time, + end_time, + duration_seconds, + status, + total_score, + evaluation_result, + created_by +) VALUES +-- 第一周 +(@test_user_id, @scene1_id, '2025-09-08 09:00:00', '2025-09-08 09:45:00', 2700, 'COMPLETED', 85, '{"feedback": "表现良好"}', @test_user_id), +(@test_user_id, @scene2_id, '2025-09-09 14:00:00', '2025-09-09 15:00:00', 3600, 'COMPLETED', 88, '{"feedback": "面试表现不错"}', @test_user_id), +(@test_user_id, @scene1_id, '2025-09-10 10:00:00', '2025-09-10 10:30:00', 1800, 'COMPLETED', 82, '{"feedback": "继续努力"}', @test_user_id), +(@test_user_id, @scene3_id, '2025-09-11 13:30:00', '2025-09-11 15:00:00', 5400, 'COMPLETED', 90, '{"feedback": "项目思路清晰"}', @test_user_id), +(@test_user_id, @scene1_id, '2025-09-12 09:30:00', '2025-09-12 10:10:00', 2400, 'COMPLETED', 87, '{"feedback": "代码质量提升"}', @test_user_id), + +-- 第二周 +(@test_user_id, @scene2_id, '2025-09-13 14:30:00', '2025-09-13 15:20:00', 3000, 'COMPLETED', 85, '{"feedback": "算法掌握良好"}', @test_user_id), +(@test_user_id, @scene1_id, '2025-09-14 10:00:00', '2025-09-14 10:35:00', 2100, 'COMPLETED', 89, '{"feedback": "优秀"}', @test_user_id), +(@test_user_id, @scene3_id, '2025-09-15 13:00:00', '2025-09-15 14:15:00', 4500, 'COMPLETED', 91, '{"feedback": "架构设计合理"}', @test_user_id), +(@test_user_id, @scene1_id, '2025-09-16 09:15:00', '2025-09-16 10:00:00', 2700, 'COMPLETED', 86, '{"feedback": "进步明显"}', @test_user_id), +(@test_user_id, @scene2_id, '2025-09-17 14:00:00', '2025-09-17 14:55:00', 3300, 'COMPLETED', 88, '{"feedback": "表达清晰"}', @test_user_id), + +-- 最近几天 +(@test_user_id, @scene1_id, '2025-09-18 10:30:00', '2025-09-18 11:00:00', 1800, 'COMPLETED', 90, '{"feedback": "基础扎实"}', @test_user_id), +(@test_user_id, @scene3_id, '2025-09-19 13:00:00', '2025-09-19 14:20:00', 4800, 'COMPLETED', 92, '{"feedback": "解决方案优秀"}', @test_user_id), +(@test_user_id, @scene1_id, '2025-09-20 09:00:00', '2025-09-20 09:40:00', 2400, 'COMPLETED', 87, '{"feedback": "继续保持"}', @test_user_id), +(@test_user_id, @scene2_id, '2025-09-21 14:00:00', '2025-09-21 15:00:00', 3600, 'COMPLETED', 89, '{"feedback": "面试准备充分"}', @test_user_id), +(@test_user_id, @scene1_id, '2025-09-22 10:00:00', '2025-09-22 10:45:00', 2700, 'COMPLETED', 91, '{"feedback": "代码优雅"}', @test_user_id); + +-- 查询验证 +SELECT + COUNT(DISTINCT DATE(start_time)) as learning_days, + ROUND(SUM(duration_seconds) / 3600, 1) as total_hours, + COUNT(*) as session_count, + ROUND(AVG(total_score), 1) as avg_score +FROM training_sessions +WHERE user_id = @test_user_id; + +SELECT '训练数据添加完成!' as message; diff --git a/backend/scripts/alter_exam_mistakes_add_question_type.sql b/backend/scripts/alter_exam_mistakes_add_question_type.sql new file mode 100644 index 0000000..b740e65 --- /dev/null +++ b/backend/scripts/alter_exam_mistakes_add_question_type.sql @@ -0,0 +1,23 @@ +-- 增量脚本:为exam_mistakes表增加question_type字段 +-- 创建时间:2025-10-12 +-- 说明:支持错题按题型筛选和统计 + +USE kaopeilian; + +-- 1. 为exam_mistakes表增加question_type字段 +ALTER TABLE exam_mistakes + ADD COLUMN question_type VARCHAR(20) NULL COMMENT '题型(single/multiple/judge/blank/essay)' AFTER user_answer; + +-- 2. 添加索引(可选,提升查询性能) +ALTER TABLE exam_mistakes + ADD INDEX idx_question_type (question_type); + +-- 3. 验证结果 +DESCRIBE exam_mistakes; + +-- 4. 统计当前错题数据 +SELECT + COUNT(*) as total_mistakes, + COUNT(question_type) as has_question_type +FROM exam_mistakes; + diff --git a/backend/scripts/alter_exams_add_rounds.sql b/backend/scripts/alter_exams_add_rounds.sql new file mode 100644 index 0000000..b2b5de2 --- /dev/null +++ b/backend/scripts/alter_exams_add_rounds.sql @@ -0,0 +1,36 @@ +-- 增量脚本:为exams表增加三轮得分字段 +-- 创建时间:2025-10-12 +-- 说明:简化三轮考试机制,一条考试记录存储三轮得分 + +USE kaopeilian; + +-- 1. 为exams表增加三轮得分字段 +ALTER TABLE exams + ADD COLUMN round1_score FLOAT NULL COMMENT '第一轮得分' AFTER score, + ADD COLUMN round2_score FLOAT NULL COMMENT '第二轮得分' AFTER round1_score, + ADD COLUMN round3_score FLOAT NULL COMMENT '第三轮得分' AFTER round2_score; + +-- 2. 为已存在的考试记录设置默认值(将score复制到round1_score) +UPDATE exams +SET round1_score = score +WHERE score IS NOT NULL AND round1_score IS NULL; + +-- 3. 验证结果 +SELECT + COUNT(*) as total_exams, + COUNT(round1_score) as has_round1, + COUNT(round2_score) as has_round2, + COUNT(round3_score) as has_round3 +FROM exams; + +-- 输出结果示例 +SELECT + id, + exam_name, + score, + round1_score, + round2_score, + round3_score +FROM exams +LIMIT 5; + diff --git a/backend/scripts/alter_users_email_nullable.sql b/backend/scripts/alter_users_email_nullable.sql new file mode 100644 index 0000000..8a2410d --- /dev/null +++ b/backend/scripts/alter_users_email_nullable.sql @@ -0,0 +1,10 @@ +-- 修改users表email字段为可空 +-- 用于支持员工同步功能,部分员工可能没有邮箱 + +-- 修改email字段为可空 +ALTER TABLE users MODIFY COLUMN email VARCHAR(100) NULL COMMENT '邮箱'; + +-- 验证修改 +DESCRIBE users; + + diff --git a/backend/scripts/apply_sql_file.py b/backend/scripts/apply_sql_file.py new file mode 100644 index 0000000..623c7d0 --- /dev/null +++ b/backend/scripts/apply_sql_file.py @@ -0,0 +1,80 @@ +""" +执行指定的 SQL 文件到当前配置的数据库(使用与后端一致的连接)。 + +用法: + cd kaopeilian-backend && python3 scripts/apply_sql_file.py scripts/init_database_unified.sql +""" +import asyncio +import sys +from pathlib import Path + +# 确保可导入应用配置 +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy import text + +from app.core.config import get_settings + + +def load_sql_statements(sql_path: Path) -> list[str]: + """读取 SQL 文件并按语句拆分(简单分号分割,忽略注释)。""" + raw = sql_path.read_text(encoding="utf-8") + # 去掉 -- 开头的行注释 + lines = [] + for line in raw.splitlines(): + striped = line.strip() + if not striped: + continue + if striped.startswith("--"): + continue + lines.append(line) + + content = "\n".join(lines) + + # 简单分割;注意保留分号作为语句结束标记 + statements: list[str] = [] + current = [] + for ch in content: + current.append(ch) + if ch == ";": + stmt = "".join(current).strip() + if stmt: + statements.append(stmt) + current = [] + # 可能没有以分号结束的尾部 + tail = "".join(current).strip() + if tail: + statements.append(tail) + return [s for s in statements if s] + + +async def apply_sql(sql_file: str): + settings = get_settings() + engine = create_async_engine(settings.DATABASE_URL, echo=False) + + sql_path = Path(sql_file) + if not sql_path.exists(): + raise FileNotFoundError(f"SQL 文件不存在: {sql_path}") + + statements = load_sql_statements(sql_path) + + async with engine.begin() as conn: + # 执行每条语句 + for stmt in statements: + await conn.execute(text(stmt)) + + await engine.dispose() + + +async def main(): + if len(sys.argv) < 2: + print("用法: python3 scripts/apply_sql_file.py ") + sys.exit(1) + sql_file = sys.argv[1] + await apply_sql(sql_file) + print("SQL 执行完成") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/backup_database.sh b/backend/scripts/backup_database.sh new file mode 100755 index 0000000..9922ad9 --- /dev/null +++ b/backend/scripts/backup_database.sh @@ -0,0 +1,144 @@ +#!/bin/bash + +# 考陪练系统数据库自动备份脚本 +# 作者: AI Assistant +# 日期: 2025-09-23 + +set -e # 遇到错误立即退出 + +# 配置变量 +BACKUP_DIR="/root/aiedu/kaopeilian-backend/backups" +CONTAINER_NAME="kaopeilian_mysql" +DB_NAME="kaopeilian" +DB_USER="root" +DB_PASSWORD="Kaopeilian2025!@#" +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="${BACKUP_DIR}/kaopeilian_backup_${DATE}.sql" +LOG_FILE="${BACKUP_DIR}/backup.log" +RETENTION_DAYS=30 # 保留30天的备份 + +# 创建备份目录 +mkdir -p "${BACKUP_DIR}" + +# 日志函数 +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "${LOG_FILE}" +} + +# 检查Docker容器是否运行 +check_container() { + if ! docker ps | grep -q "${CONTAINER_NAME}"; then + log "ERROR: MySQL容器 ${CONTAINER_NAME} 未运行" + exit 1 + fi +} + +# 执行数据库备份 +backup_database() { + log "开始备份Docker容器中的数据库 ${DB_NAME}..." + + # 检查容器是否运行 + if ! docker ps | grep -q "${CONTAINER_NAME}"; then + log "ERROR: MySQL容器 ${CONTAINER_NAME} 未运行" + return 1 + fi + + # 使用docker exec执行mysqldump,将输出重定向到宿主机文件 + if docker exec "${CONTAINER_NAME}" mysqldump \ + -u"${DB_USER}" \ + -p"${DB_PASSWORD}" \ + --single-transaction \ + --routines \ + --triggers \ + --events \ + --hex-blob \ + --default-character-set=utf8mb4 \ + --lock-tables=false \ + --add-drop-database \ + --create-options \ + "${DB_NAME}" > "${BACKUP_FILE}" 2>/dev/null; then + + # 检查备份文件大小 + if [ -f "${BACKUP_FILE}" ] && [ -s "${BACKUP_FILE}" ]; then + BACKUP_SIZE=$(du -h "${BACKUP_FILE}" | cut -f1) + log "备份完成: ${BACKUP_FILE} (大小: ${BACKUP_SIZE})" + + # 验证备份文件内容(检查是否包含CREATE DATABASE语句) + if grep -q "CREATE DATABASE" "${BACKUP_FILE}"; then + log "备份文件验证成功" + return 0 + else + log "WARNING: 备份文件可能不完整" + return 0 # 仍然算作成功,但记录警告 + fi + else + log "ERROR: 备份文件为空或不存在" + rm -f "${BACKUP_FILE}" + return 1 + fi + else + log "ERROR: 数据库备份失败" + return 1 + fi +} + +# 压缩备份文件 +compress_backup() { + if [ -f "${BACKUP_FILE}" ]; then + log "压缩备份文件..." + gzip "${BACKUP_FILE}" + COMPRESSED_FILE="${BACKUP_FILE}.gz" + COMPRESSED_SIZE=$(du -h "${COMPRESSED_FILE}" | cut -f1) + log "压缩完成: ${COMPRESSED_FILE} (大小: ${COMPRESSED_SIZE})" + fi +} + +# 清理过期备份 +cleanup_old_backups() { + log "清理 ${RETENTION_DAYS} 天前的备份文件..." + find "${BACKUP_DIR}" -name "kaopeilian_backup_*.sql.gz" -mtime +${RETENTION_DAYS} -delete + find "${BACKUP_DIR}" -name "kaopeilian_backup_*.sql" -mtime +${RETENTION_DAYS} -delete + log "过期备份清理完成" +} + +# 发送备份状态通知(可选) +send_notification() { + local status=$1 + local message=$2 + + # 这里可以集成邮件、钉钉、微信等通知方式 + log "通知: ${status} - ${message}" + + # 示例:写入状态文件供监控系统读取 + echo "{\"timestamp\":\"$(date -Iseconds)\",\"status\":\"${status}\",\"message\":\"${message}\"}" > "${BACKUP_DIR}/backup_status.json" +} + +# 主函数 +main() { + log "========== 数据库备份开始 ==========" + + # 检查容器状态 + check_container + + # 执行备份 + if backup_database; then + # 压缩备份 + compress_backup + + # 清理过期备份 + cleanup_old_backups + + # 发送成功通知 + send_notification "SUCCESS" "数据库备份成功完成" + log "========== 数据库备份完成 ==========" + exit 0 + else + # 发送失败通知 + send_notification "FAILED" "数据库备份失败" + log "========== 数据库备份失败 ==========" + exit 1 + fi +} + +# 执行主函数 +main "$@" diff --git a/backend/scripts/binlog_rollback_tool.py b/backend/scripts/binlog_rollback_tool.py new file mode 100644 index 0000000..f632b76 --- /dev/null +++ b/backend/scripts/binlog_rollback_tool.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +""" +MySQL Binlog 回滚工具 +用于考培练系统的数据库回滚操作 + +功能: +1. 解析Binlog文件 +2. 生成反向SQL语句 +3. 执行数据回滚 +4. 支持时间范围和表过滤 + +使用方法: +python scripts/binlog_rollback_tool.py --help +""" + +import asyncio +import argparse +import subprocess +import tempfile +import os +import re +from datetime import datetime, timedelta +from pathlib import Path +from typing import List, Dict, Optional +import aiomysql +import logging + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +class BinlogRollbackTool: + """Binlog回滚工具类""" + + def __init__(self, + host: str = "localhost", + port: int = 3306, + user: str = "root", + password: str = "root", + database: str = "kaopeilian"): + self.host = host + self.port = port + self.user = user + self.password = password + self.database = database + self.connection = None + + async def connect(self): + """连接到MySQL数据库""" + try: + self.connection = await aiomysql.connect( + host=self.host, + port=self.port, + user=self.user, + password=self.password, + db=self.database, + charset='utf8mb4' + ) + logger.info(f"✅ 成功连接到数据库 {self.database}") + except Exception as e: + logger.error(f"❌ 数据库连接失败: {e}") + raise + + async def close(self): + """关闭数据库连接""" + if self.connection: + self.connection.close() + logger.info("🔒 数据库连接已关闭") + + async def get_binlog_files(self) -> List[Dict]: + """获取Binlog文件列表""" + cursor = await self.connection.cursor() + await cursor.execute("SHOW BINARY LOGS") + result = await cursor.fetchall() + await cursor.close() + + binlog_files = [] + for row in result: + binlog_files.append({ + 'name': row[0], + 'size': row[1], + 'encrypted': row[2] if len(row) > 2 else False + }) + + logger.info(f"📋 找到 {len(binlog_files)} 个Binlog文件") + return binlog_files + + async def get_binlog_position_by_time(self, target_time: datetime) -> Optional[str]: + """根据时间获取Binlog位置""" + cursor = await self.connection.cursor() + + # 获取所有Binlog文件 + binlog_files = await self.get_binlog_files() + + for binlog_file in binlog_files: + try: + # 使用mysqlbinlog解析文件,查找时间点 + cmd = [ + 'mysqlbinlog', + '--start-datetime', target_time.strftime('%Y-%m-%d %H:%M:%S'), + '--stop-datetime', (target_time + timedelta(seconds=1)).strftime('%Y-%m-%d %H:%M:%S'), + f'/var/lib/mysql/{binlog_file["name"]}' + ] + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode == 0 and result.stdout.strip(): + logger.info(f"📍 在 {binlog_file['name']} 中找到时间点 {target_time}") + return binlog_file['name'] + + except Exception as e: + logger.warning(f"⚠️ 解析 {binlog_file['name']} 时出错: {e}") + continue + + logger.warning(f"⚠️ 未找到时间点 {target_time} 对应的Binlog位置") + return None + + def parse_binlog_to_sql(self, + binlog_file: str, + start_time: Optional[datetime] = None, + stop_time: Optional[datetime] = None, + tables: Optional[List[str]] = None) -> str: + """解析Binlog文件生成SQL语句""" + + # 构建mysqlbinlog命令 + cmd = ['mysqlbinlog', '--base64-output=decode-rows', '-v'] + + if start_time: + cmd.extend(['--start-datetime', start_time.strftime('%Y-%m-%d %H:%M:%S')]) + + if stop_time: + cmd.extend(['--stop-datetime', stop_time.strftime('%Y-%m-%d %H:%M:%S')]) + + # 添加数据库过滤 + cmd.extend(['--database', self.database]) + + # 添加表过滤 + if tables: + for table in tables: + cmd.extend(['--table', table]) + + # 添加Binlog文件路径 + cmd.append(f'/var/lib/mysql/{binlog_file}') + + logger.info(f"🔍 执行命令: {' '.join(cmd)}") + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + if result.returncode != 0: + logger.error(f"❌ mysqlbinlog执行失败: {result.stderr}") + return "" + + return result.stdout + + except subprocess.TimeoutExpired: + logger.error("❌ mysqlbinlog执行超时") + return "" + except Exception as e: + logger.error(f"❌ mysqlbinlog执行异常: {e}") + return "" + + def generate_reverse_sql(self, binlog_sql: str) -> List[str]: + """生成反向SQL语句""" + reverse_sqls = [] + + # 解析INSERT语句,转换为DELETE + insert_pattern = r'INSERT INTO `([^`]+)` \(([^)]+)\) VALUES \((.+)\);' + for match in re.finditer(insert_pattern, binlog_sql, re.MULTILINE): + table = match.group(1) + columns = match.group(2) + values = match.group(3) + + # 构建WHERE条件 + where_conditions = [] + column_list = [col.strip().strip('`') for col in columns.split(',')] + value_list = [val.strip().strip("'") for val in values.split(',')] + + for col, val in zip(column_list, value_list): + if val != 'NULL': + where_conditions.append(f"`{col}` = '{val}'") + + if where_conditions: + delete_sql = f"DELETE FROM `{table}` WHERE {' AND '.join(where_conditions)};" + reverse_sqls.append(delete_sql) + + # 解析UPDATE语句,生成反向UPDATE + update_pattern = r'UPDATE `([^`]+)` SET (.+) WHERE (.+);' + for match in re.finditer(update_pattern, binlog_sql, re.MULTILINE): + table = match.group(1) + set_clause = match.group(2) + where_clause = match.group(3) + + # 这里需要从Binlog中提取原始值,ROW格式的Binlog包含@1, @2等变量 + # 简化处理:生成警告信息 + reverse_sqls.append(f"-- 需要手动处理UPDATE语句: UPDATE `{table}` SET {set_clause} WHERE {where_clause};") + + # 解析DELETE语句,转换为INSERT + delete_pattern = r'DELETE FROM `([^`]+)` WHERE (.+);' + for match in re.finditer(delete_pattern, binlog_sql, re.MULTILINE): + table = match.group(1) + where_clause = match.group(2) + + # 简化处理:生成警告信息 + reverse_sqls.append(f"-- 需要手动处理DELETE语句: INSERT INTO `{table}` ... WHERE {where_clause};") + + return reverse_sqls + + async def execute_rollback_sql(self, sql_statements: List[str], dry_run: bool = True) -> bool: + """执行回滚SQL语句""" + if not sql_statements: + logger.warning("⚠️ 没有需要执行的SQL语句") + return True + + if dry_run: + logger.info("🔍 模拟执行模式 - 以下SQL语句将被执行:") + for i, sql in enumerate(sql_statements, 1): + logger.info(f"{i:3d}. {sql}") + return True + + cursor = await self.connection.cursor() + + try: + # 开始事务 + await cursor.execute("START TRANSACTION") + logger.info("🔄 开始回滚事务") + + for i, sql in enumerate(sql_statements, 1): + if sql.strip().startswith('--'): + logger.info(f"⏭️ 跳过注释: {sql}") + continue + + try: + await cursor.execute(sql) + logger.info(f"✅ 执行成功 ({i}/{len(sql_statements)}): {sql[:100]}...") + except Exception as e: + logger.error(f"❌ 执行失败 ({i}/{len(sql_statements)}): {sql}") + logger.error(f" 错误信息: {e}") + raise + + # 提交事务 + await cursor.execute("COMMIT") + logger.info("✅ 回滚事务提交成功") + return True + + except Exception as e: + # 回滚事务 + await cursor.execute("ROLLBACK") + logger.error(f"❌ 回滚事务失败,已回滚: {e}") + return False + finally: + await cursor.close() + + async def rollback_by_time(self, + target_time: datetime, + tables: Optional[List[str]] = None, + dry_run: bool = True) -> bool: + """根据时间点进行回滚""" + logger.info(f"🎯 开始回滚到时间点: {target_time}") + + # 查找对应的Binlog文件 + binlog_file = await self.get_binlog_position_by_time(target_time) + if not binlog_file: + logger.error("❌ 未找到对应的Binlog文件") + return False + + # 解析Binlog生成SQL + binlog_sql = self.parse_binlog_to_sql( + binlog_file=binlog_file, + start_time=target_time, + tables=tables + ) + + if not binlog_sql: + logger.error("❌ 解析Binlog失败") + return False + + # 生成反向SQL + reverse_sqls = self.generate_reverse_sql(binlog_sql) + + if not reverse_sqls: + logger.warning("⚠️ 未生成反向SQL语句") + return True + + # 执行回滚 + return await self.execute_rollback_sql(reverse_sqls, dry_run) + +async def main(): + """主函数""" + parser = argparse.ArgumentParser(description='MySQL Binlog 回滚工具') + parser.add_argument('--host', default='localhost', help='MySQL主机地址') + parser.add_argument('--port', type=int, default=3306, help='MySQL端口') + parser.add_argument('--user', default='root', help='MySQL用户名') + parser.add_argument('--password', default='root', help='MySQL密码') + parser.add_argument('--database', default='kaopeilian', help='数据库名') + parser.add_argument('--time', required=True, help='回滚到的时间点 (格式: YYYY-MM-DD HH:MM:SS)') + parser.add_argument('--tables', nargs='*', help='指定要回滚的表名') + parser.add_argument('--execute', action='store_true', help='实际执行回滚(默认只模拟)') + parser.add_argument('--list-binlogs', action='store_true', help='列出所有Binlog文件') + + args = parser.parse_args() + + # 创建回滚工具实例 + tool = BinlogRollbackTool( + host=args.host, + port=args.port, + user=args.user, + password=args.password, + database=args.database + ) + + try: + await tool.connect() + + if args.list_binlogs: + # 列出Binlog文件 + binlog_files = await tool.get_binlog_files() + print("\n📋 Binlog文件列表:") + for i, file_info in enumerate(binlog_files, 1): + print(f"{i:2d}. {file_info['name']} ({file_info['size']} bytes)") + return + + # 解析时间参数 + try: + target_time = datetime.strptime(args.time, '%Y-%m-%d %H:%M:%S') + except ValueError: + logger.error("❌ 时间格式错误,请使用: YYYY-MM-DD HH:MM:SS") + return + + # 执行回滚 + dry_run = not args.execute + success = await tool.rollback_by_time( + target_time=target_time, + tables=args.tables, + dry_run=dry_run + ) + + if success: + if dry_run: + logger.info("🔍 模拟执行完成,使用 --execute 参数实际执行回滚") + else: + logger.info("✅ 回滚操作完成") + else: + logger.error("❌ 回滚操作失败") + + except Exception as e: + logger.error(f"❌ 程序执行异常: {e}") + finally: + await tool.close() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/check_backup_status.sh b/backend/scripts/check_backup_status.sh new file mode 100755 index 0000000..f979d84 --- /dev/null +++ b/backend/scripts/check_backup_status.sh @@ -0,0 +1,123 @@ +#!/bin/bash + +# 考陪练系统数据库备份状态检查脚本 +# 作者: AI Assistant +# 日期: 2025-09-23 + +BACKUP_DIR="/root/aiedu/kaopeilian-backend/backups" +LOG_FILE="${BACKUP_DIR}/check_status.log" + +# 日志函数 +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "${LOG_FILE}" +} + +# 检查最近的备份文件 +check_recent_backup() { + local recent_backup=$(find "${BACKUP_DIR}" -name "kaopeilian_backup_*.sql.gz" -type f -mtime -1 | sort | tail -1) + + if [ -n "${recent_backup}" ]; then + local backup_time=$(stat -c %Y "${recent_backup}") + local current_time=$(date +%s) + local time_diff=$((current_time - backup_time)) + local hours_diff=$((time_diff / 3600)) + + log "最近备份: $(basename ${recent_backup})" + log "备份时间: $(stat -c %y "${recent_backup}")" + log "距离现在: ${hours_diff} 小时前" + + if [ ${hours_diff} -gt 2 ]; then + log "WARNING: 备份文件超过2小时未更新" + return 1 + else + log "备份状态: 正常" + return 0 + fi + else + log "ERROR: 未找到最近的备份文件" + return 1 + fi +} + +# 检查备份文件大小 +check_backup_size() { + local recent_backup=$(find "${BACKUP_DIR}" -name "kaopeilian_backup_*.sql.gz" -type f -mtime -1 | sort | tail -1) + + if [ -n "${recent_backup}" ]; then + local backup_size=$(stat -c %s "${recent_backup}") + local backup_size_mb=$((backup_size / 1024 / 1024)) + + log "备份文件大小: ${backup_size_mb}MB" + + if [ ${backup_size_mb} -lt 1 ]; then + log "WARNING: 备份文件过小,可能不完整" + return 1 + else + log "备份文件大小: 正常" + return 0 + fi + fi +} + +# 检查定时任务状态 +check_cron_status() { + if crontab -l | grep -q "backup_database.sh"; then + log "定时任务: 已配置" + return 0 + else + log "ERROR: 定时任务未配置" + return 1 + fi +} + +# 检查systemd定时器状态 +check_systemd_timer() { + if systemctl is-active kaopeilian-backup.timer >/dev/null 2>&1; then + log "Systemd定时器: 运行中" + + # 获取下次执行时间 + local next_run=$(systemctl list-timers kaopeilian-backup.timer --no-pager | grep kaopeilian-backup.timer | awk '{print $1, $2}') + log "下次执行时间: ${next_run}" + + return 0 + else + log "ERROR: Systemd定时器未运行" + return 1 + fi +} + +# 检查Docker容器状态 +check_docker_container() { + if docker ps | grep -q "kaopeilian_mysql"; then + log "MySQL容器: 运行中" + return 0 + else + log "ERROR: MySQL容器未运行" + return 1 + fi +} + +# 主检查函数 +main() { + log "========== 备份状态检查开始 ==========" + + local exit_code=0 + + # 执行各项检查 + check_recent_backup || exit_code=1 + check_backup_size || exit_code=1 + check_cron_status || exit_code=1 + check_systemd_timer || exit_code=1 + check_docker_container || exit_code=1 + + if [ ${exit_code} -eq 0 ]; then + log "========== 备份状态检查完成: 全部正常 ==========" + else + log "========== 备份状态检查完成: 发现问题 ==========" + fi + + return ${exit_code} +} + +# 执行主函数 +main "$@" diff --git a/backend/scripts/check_database_status.py b/backend/scripts/check_database_status.py new file mode 100644 index 0000000..d463938 --- /dev/null +++ b/backend/scripts/check_database_status.py @@ -0,0 +1,178 @@ +""" +检查数据库现状脚本 +用于探索users、teams、exams、practice_sessions表的数据情况 +""" +import asyncio +import sys +from pathlib import Path + +# 添加项目路径 +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import AsyncSessionLocal +from app.core.logger import logger +from app.models.user import User, Team, UserTeam +from app.models.exam import Exam +from app.models.practice import PracticeSession +from app.models.position_member import PositionMember + + +async def check_database(db: AsyncSession): + """检查数据库数据""" + + print("=" * 60) + print("数据库状态检查") + print("=" * 60) + + # 1. 检查用户数据 + print("\n【用户表 (users)】") + result = await db.execute(select(func.count()).select_from(User)) + total_users = result.scalar() or 0 + print(f"总用户数: {total_users}") + + if total_users > 0: + # 按角色统计 + result = await db.execute( + select(User.role, func.count()).group_by(User.role) + ) + print("按角色统计:") + for role, count in result.all(): + print(f" - {role}: {count}") + + # 显示部分用户 + result = await db.execute( + select(User).limit(5) + ) + users = result.scalars().all() + print("\n前5个用户:") + for user in users: + print(f" - ID:{user.id}, 用户名:{user.username}, 姓名:{user.full_name}, 角色:{user.role}") + + # 2. 检查团队数据 + print("\n【团队表 (teams)】") + result = await db.execute(select(func.count()).select_from(Team)) + total_teams = result.scalar() or 0 + print(f"总团队数: {total_teams}") + + if total_teams > 0: + result = await db.execute(select(Team).limit(5)) + teams = result.scalars().all() + print("团队列表:") + for team in teams: + print(f" - ID:{team.id}, 名称:{team.name}, 代码:{team.code}, 类型:{team.team_type}") + + # 3. 检查用户团队关联 + print("\n【用户团队关联表 (user_teams)】") + result = await db.execute(select(func.count()).select_from(UserTeam)) + total_relations = result.scalar() or 0 + print(f"总关联记录数: {total_relations}") + + if total_relations > 0: + # 查询前5条关联记录 + result = await db.execute( + select(UserTeam, User.full_name, Team.name) + .join(User, UserTeam.user_id == User.id) + .join(Team, UserTeam.team_id == Team.id) + .limit(5) + ) + print("关联记录示例:") + for user_team, user_name, team_name in result.all(): + print(f" - 用户:{user_name}, 团队:{team_name}, 角色:{user_team.role}") + + # 4. 检查岗位成员 + print("\n【岗位成员表 (position_members)】") + result = await db.execute( + select(func.count()).select_from(PositionMember).where( + PositionMember.is_deleted == False + ) + ) + total_position_members = result.scalar() or 0 + print(f"总岗位成员数: {total_position_members}") + + # 5. 检查考试记录 + print("\n【考试表 (exams)】") + result = await db.execute(select(func.count()).select_from(Exam)) + total_exams = result.scalar() or 0 + print(f"总考试记录数: {total_exams}") + + if total_exams > 0: + # 按状态统计 + result = await db.execute( + select(Exam.status, func.count()).group_by(Exam.status) + ) + print("按状态统计:") + for status, count in result.all(): + print(f" - {status}: {count}") + + # 显示最近5条考试记录 + result = await db.execute( + select(Exam, User.full_name) + .join(User, Exam.user_id == User.id) + .order_by(Exam.created_at.desc()) + .limit(5) + ) + print("\n最近5条考试记录:") + for exam, user_name in result.all(): + print(f" - 用户:{user_name}, 课程ID:{exam.course_id}, 状态:{exam.status}, " + f"分数:{exam.round1_score}, 时间:{exam.created_at}") + + # 6. 检查陪练记录 + print("\n【陪练会话表 (practice_sessions)】") + result = await db.execute(select(func.count()).select_from(PracticeSession)) + total_sessions = result.scalar() or 0 + print(f"总陪练记录数: {total_sessions}") + + if total_sessions > 0: + # 按状态统计 + result = await db.execute( + select(PracticeSession.status, func.count()).group_by(PracticeSession.status) + ) + print("按状态统计:") + for status, count in result.all(): + print(f" - {status}: {count}") + + # 显示最近5条陪练记录 + result = await db.execute( + select(PracticeSession, User.full_name) + .join(User, PracticeSession.user_id == User.id) + .order_by(PracticeSession.start_time.desc()) + .limit(5) + ) + print("\n最近5条陪练记录:") + for session, user_name in result.all(): + print(f" - 用户:{user_name}, 场景:{session.scene_name}, 状态:{session.status}, " + f"时长:{session.duration_seconds}秒, 时间:{session.start_time}") + + # 总结 + print("\n" + "=" * 60) + print("检查总结:") + print("=" * 60) + if total_users == 0: + print("❌ 数据库为空,需要注入测试数据") + elif total_teams == 0 or total_relations == 0: + print("⚠️ 有用户但无团队数据,需要创建团队") + elif total_exams == 0: + print("⚠️ 有用户和团队但无考试记录,需要创建考试数据") + elif total_sessions == 0: + print("⚠️ 有用户和团队但无陪练记录,需要创建陪练数据") + else: + print("✅ 数据库有基本数据,可以直接使用") + print("=" * 60) + + +async def main(): + """主函数""" + async with AsyncSessionLocal() as db: + try: + await check_database(db) + except Exception as e: + logger.error(f"检查数据库失败: {str(e)}", exc_info=True) + raise + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/backend/scripts/cleanup_users.py b/backend/scripts/cleanup_users.py new file mode 100644 index 0000000..c31147e --- /dev/null +++ b/backend/scripts/cleanup_users.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +""" +用户清理脚本 +删除除admin以外的所有用户,为员工同步做准备 +""" + +import asyncio +import sys +from pathlib import Path + +# 添加项目根目录到Python路径 +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from sqlalchemy import select, delete +from app.core.database import AsyncSessionLocal +from app.models.user import User +from app.core.logger import get_logger + +logger = get_logger(__name__) + + +async def cleanup_users(): + """ + 删除除admin以外的所有用户 + 保留username='admin'的管理员账号 + """ + async with AsyncSessionLocal() as db: + try: + # 查询要删除的用户 + stmt = select(User).where(User.username != 'admin', User.is_deleted == False) + result = await db.execute(stmt) + users_to_delete = result.scalars().all() + + if not users_to_delete: + logger.info("没有需要删除的用户") + return + + logger.info(f"准备删除 {len(users_to_delete)} 个用户") + for user in users_to_delete: + logger.info(f" - ID: {user.id}, 用户名: {user.username}, 姓名: {user.full_name}") + + # 确认删除 + print("\n⚠️ 警告: 将删除以上用户(保留admin)") + confirm = input("确认删除?(yes/no): ") + + if confirm.lower() != 'yes': + logger.info("取消删除操作") + return + + # 执行软删除 + for user in users_to_delete: + user.is_deleted = True + logger.info(f"已软删除用户: {user.username}") + + await db.commit() + logger.info(f"✅ 成功删除 {len(users_to_delete)} 个用户") + + # 显示剩余用户 + stmt = select(User).where(User.is_deleted == False) + result = await db.execute(stmt) + remaining_users = result.scalars().all() + + logger.info(f"\n剩余用户数量: {len(remaining_users)}") + for user in remaining_users: + logger.info(f" - ID: {user.id}, 用户名: {user.username}, 角色: {user.role}") + + except Exception as e: + logger.error(f"清理用户失败: {str(e)}") + await db.rollback() + raise + + +if __name__ == "__main__": + print("=" * 60) + print("用户清理脚本") + print("=" * 60) + asyncio.run(cleanup_users()) + diff --git a/backend/scripts/create_course_exam_settings.sql b/backend/scripts/create_course_exam_settings.sql new file mode 100644 index 0000000..0eb8a06 --- /dev/null +++ b/backend/scripts/create_course_exam_settings.sql @@ -0,0 +1,35 @@ +-- 创建课程考试设置表 +CREATE TABLE IF NOT EXISTS course_exam_settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + course_id INT NOT NULL UNIQUE COMMENT '课程ID', + single_choice_count INT NOT NULL DEFAULT 10 COMMENT '单选题数量', + multiple_choice_count INT NOT NULL DEFAULT 5 COMMENT '多选题数量', + true_false_count INT NOT NULL DEFAULT 5 COMMENT '判断题数量', + fill_blank_count INT NOT NULL DEFAULT 0 COMMENT '填空题数量', + duration_minutes INT NOT NULL DEFAULT 60 COMMENT '考试时长(分钟)', + difficulty_level INT NOT NULL DEFAULT 3 COMMENT '难度系数(1-5)', + passing_score INT NOT NULL DEFAULT 60 COMMENT '及格分数', + is_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用', + show_answer_immediately BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否立即显示答案', + allow_retake BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否允许重考', + max_retake_times INT COMMENT '最大重考次数', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + deleted_by INT COMMENT '删除人ID', + FOREIGN KEY (course_id) REFERENCES courses(id), + INDEX ix_course_exam_settings_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程考试设置表'; + +-- 插入示例课程考试设置 +INSERT INTO course_exam_settings (course_id, single_choice_count, multiple_choice_count, true_false_count, duration_minutes) +VALUES +(1, 15, 8, 5, 60), +(2, 20, 10, 10, 90), +(3, 10, 5, 5, 45), +(4, 12, 6, 8, 60); + +SELECT 'Course exam settings table created successfully!' as message; diff --git a/backend/scripts/create_practice_analysis_tables.sql b/backend/scripts/create_practice_analysis_tables.sql new file mode 100644 index 0000000..ac6d38a --- /dev/null +++ b/backend/scripts/create_practice_analysis_tables.sql @@ -0,0 +1,79 @@ +-- 陪练分析报告功能数据库表 +-- 创建时间:2025-10-13 + +-- 1. 陪练会话表 +CREATE TABLE IF NOT EXISTS `practice_sessions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` VARCHAR(50) NOT NULL UNIQUE COMMENT '会话ID(如PS006)', + `user_id` INT NOT NULL COMMENT '学员ID', + `scene_id` INT COMMENT '场景ID', + `scene_name` VARCHAR(200) COMMENT '场景名称', + `scene_type` VARCHAR(50) COMMENT '场景类型:phone/face/complaint等', + `conversation_id` VARCHAR(100) COMMENT 'Coze对话ID', + + -- 会话时间信息 + `start_time` DATETIME NOT NULL COMMENT '开始时间', + `end_time` DATETIME COMMENT '结束时间', + `duration_seconds` INT DEFAULT 0 COMMENT '时长(秒)', + `turns` INT DEFAULT 0 COMMENT '对话轮次', + `status` VARCHAR(20) DEFAULT 'in_progress' COMMENT '状态:in_progress/completed/canceled', + + -- 审计字段 + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` BOOLEAN DEFAULT FALSE, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (scene_id) REFERENCES practice_scenes(id) ON DELETE SET NULL, + INDEX idx_user_id (user_id), + INDEX idx_session_id (session_id), + INDEX idx_start_time (start_time), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练会话表'; + +-- 2. 对话记录表 +CREATE TABLE IF NOT EXISTS `practice_dialogues` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` VARCHAR(50) NOT NULL COMMENT '会话ID', + `speaker` VARCHAR(20) NOT NULL COMMENT '说话人:user/ai', + `content` TEXT NOT NULL COMMENT '对话内容', + `timestamp` DATETIME NOT NULL COMMENT '时间戳', + `sequence` INT NOT NULL COMMENT '顺序号(从1开始)', + + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_session_id (session_id), + INDEX idx_sequence (session_id, sequence) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练对话记录表'; + +-- 3. 分析报告表 +CREATE TABLE IF NOT EXISTS `practice_reports` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` VARCHAR(50) NOT NULL UNIQUE COMMENT '会话ID', + + -- AI分析结果(JSON存储) + `total_score` INT COMMENT '综合得分(0-100)', + `score_breakdown` JSON COMMENT '分数细分:开场技巧、需求挖掘等', + `ability_dimensions` JSON COMMENT '能力维度:沟通表达、倾听理解等', + `dialogue_review` JSON COMMENT '对话复盘(标注亮点话术/金牌话术)', + `suggestions` JSON COMMENT '改进建议', + + -- Dify工作流信息 + `workflow_run_id` VARCHAR(100) COMMENT 'Dify工作流运行ID', + `task_id` VARCHAR(100) COMMENT 'Dify任务ID', + + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_session_id (session_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练分析报告表'; + +-- 验证表创建 +SELECT + TABLE_NAME, + TABLE_ROWS, + TABLE_COMMENT +FROM information_schema.TABLES +WHERE TABLE_SCHEMA = 'kaopeilian' + AND TABLE_NAME IN ('practice_sessions', 'practice_dialogues', 'practice_reports'); + diff --git a/backend/scripts/create_practice_scenes.sql b/backend/scripts/create_practice_scenes.sql new file mode 100644 index 0000000..61a1844 --- /dev/null +++ b/backend/scripts/create_practice_scenes.sql @@ -0,0 +1,107 @@ +-- 创建陪练场景表 +CREATE TABLE IF NOT EXISTS `practice_scenes` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '场景名称', + `description` TEXT COMMENT '场景描述', + `type` VARCHAR(50) NOT NULL COMMENT '场景类型: phone/face/complaint/after-sales/product-intro', + `difficulty` VARCHAR(50) NOT NULL COMMENT '难度等级: beginner/junior/intermediate/senior/expert', + `status` VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/inactive', + `background` TEXT COMMENT '场景背景设定', + `ai_role` TEXT COMMENT 'AI角色描述', + `objectives` JSON COMMENT '练习目标数组', + `keywords` JSON COMMENT '关键词数组', + `duration` INT DEFAULT 10 COMMENT '预计时长(分钟)', + `usage_count` INT DEFAULT 0 COMMENT '使用次数', + `rating` DECIMAL(3,1) DEFAULT 0.0 COMMENT '评分', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` BOOLEAN DEFAULT FALSE, + `deleted_at` DATETIME, + INDEX idx_type (type), + INDEX idx_difficulty (difficulty), + INDEX idx_status (status), + INDEX idx_is_deleted (is_deleted), + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练场景表'; + +-- 插入初始场景数据(5个场景,涵盖不同类型和难度) +INSERT INTO `practice_scenes` (name, description, type, difficulty, status, background, ai_role, objectives, keywords, duration, created_by, updated_by) VALUES +( + '初次电话拜访客户', + '模拟首次通过电话联系潜在客户的场景,练习专业的电话销售技巧', + 'phone', + 'beginner', + 'active', + '你是一名轻医美品牌的销售专员,需要通过电话联系一位从未接触过的潜在客户。客户是某美容院的老板,你需要在短时间内引起她的兴趣,介绍你们的产品和服务。', + 'AI扮演一位忙碌的美容院老板,对推销电话比较抵触,但如果销售人员能够快速切入她的需求点(如提升业绩、吸引客户、新项目),她会愿意继续交谈。她关注产品效果、价格和培训支持。', + '["学会专业的电话开场白", "快速建立信任关系", "有效探询客户需求", "预约下次沟通时间"]', + '["开场白", "需求挖掘", "时间管理", "预约技巧"]', + 10, + 1, + 1 +), +( + '处理价格异议', + '练习如何应对客户对产品价格的质疑和异议,强调价值而非价格', + 'face', + 'intermediate', + 'active', + '客户对你们的轻医美产品很感兴趣,已经了解了产品功能和效果,但认为价格太高,超出了她的预算。你需要通过价值塑造和对比分析来化解价格异议。', + 'AI扮演一位对价格非常敏感的美容院老板,她会不断提出价格异议,例如"太贵了"、"竞品便宜一半"、"能不能再便宜点"。但如果销售人员能够有效展示产品价值、投资回报率和长期收益,她会逐渐被说服。', + '["掌握价值塑造技巧", "学会处理价格异议", "提升谈判能力", "展示投资回报率"]', + '["异议处理", "价值塑造", "谈判技巧", "ROI分析"]', + 15, + 1, + 1 +), +( + '客户投诉处理', + '模拟客户对产品或服务不满的投诉场景,练习专业的投诉处理技巧', + 'complaint', + 'intermediate', + 'active', + '一位客户购买了你们的轻医美产品后,使用效果不理想,打电话来投诉。她情绪比较激动,认为产品宣传与实际不符,要求退款。你需要安抚客户情绪,了解问题根源,并提供合理的解决方案。', + 'AI扮演一位情绪激动的客户,她对产品效果不满意,觉得被欺骗了。她会表达强烈的不满和质疑,但如果客服人员能够真诚道歉、耐心倾听、专业分析问题原因并提供切实可行的解决方案,她的态度会逐渐缓和。', + '["掌握情绪安抚技巧", "学会倾听和共情", "分析问题并提供解决方案", "挽回客户信任"]', + '["投诉处理", "情绪管理", "问题分析", "客户挽回"]', + 12, + 1, + 1 +), +( + '产品功能介绍', + '练习向客户详细介绍轻医美产品的功能特点和优势', + 'product-intro', + 'junior', + 'active', + '客户对你们的轻医美产品有一定了解,现在希望你详细介绍产品的核心功能、技术原理、使用方法和效果保证。客户比较专业,会提出一些技术性问题。', + 'AI扮演一位专业的美容院经营者,她对轻医美产品有一定了解,会提出具体的技术问题和需求。例如询问产品成分、作用机理、适用人群、操作流程、注意事项等。她希望得到专业、详细、真实的回答。', + '["清晰介绍产品功能和原理", "突出产品优势和差异化", "专业回答技术问题", "建立专业形象"]', + '["产品介绍", "功能展示", "优势分析", "技术解答"]', + 12, + 1, + 1 +), +( + '售后服务咨询', + '模拟客户咨询售后服务的场景,练习专业的售后服务沟通技巧', + 'after-sales', + 'beginner', + 'active', + '客户已经购买了你们的轻医美产品,现在打电话咨询售后服务相关问题,包括产品使用方法、遇到的小问题、培训支持、配件购买等。你需要提供专业、耐心、周到的售后服务。', + 'AI扮演一位已购买产品的美容院老板,她在使用产品过程中遇到一些问题或疑问,希望得到专业的指导和帮助。她的态度比较友好,但希望得到快速、有效的解决方案。她会根据服务质量评价品牌。', + '["掌握产品使用指导技巧", "快速解决客户问题", "提供专业培训支持", "增强客户满意度和忠诚度"]', + '["售后服务", "使用指导", "问题解决", "客户维护"]', + 10, + 1, + 1 +); + +-- 查询验证 +SELECT id, name, type, difficulty, status FROM practice_scenes WHERE is_deleted = FALSE; + + + diff --git a/backend/scripts/create_practice_table.py b/backend/scripts/create_practice_table.py new file mode 100644 index 0000000..981d66b --- /dev/null +++ b/backend/scripts/create_practice_table.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +""" +创建陪练场景表并插入初始数据 - 简单版本 +""" +import pymysql +import sys + +# 数据库连接配置 +DB_CONFIG = { + 'host': 'localhost', + 'port': 3306, + 'user': 'root', + 'password': 'nj861021', # 开发环境密码 + 'database': 'kaopeilian', + 'charset': 'utf8mb4' +} + +# SQL语句 +CREATE_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS `practice_scenes` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '场景名称', + `description` TEXT COMMENT '场景描述', + `type` VARCHAR(50) NOT NULL COMMENT '场景类型', + `difficulty` VARCHAR(50) NOT NULL COMMENT '难度等级', + `status` VARCHAR(20) DEFAULT 'active' COMMENT '状态', + `background` TEXT COMMENT '场景背景设定', + `ai_role` TEXT COMMENT 'AI角色描述', + `objectives` JSON COMMENT '练习目标数组', + `keywords` JSON COMMENT '关键词数组', + `duration` INT DEFAULT 10 COMMENT '预计时长(分钟)', + `usage_count` INT DEFAULT 0 COMMENT '使用次数', + `rating` DECIMAL(3,1) DEFAULT 0.0 COMMENT '评分', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` BOOLEAN DEFAULT FALSE, + `deleted_at` DATETIME, + INDEX idx_type (type), + INDEX idx_difficulty (difficulty), + INDEX idx_status (status), + INDEX idx_is_deleted (is_deleted), + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练场景表' +""" + +# 初始场景数据 +SCENES = [ + { + 'name': '初次电话拜访客户', + 'description': '模拟首次通过电话联系潜在客户的场景,练习专业的电话销售技巧', + 'type': 'phone', + 'difficulty': 'beginner', + 'status': 'active', + 'background': '你是一名轻医美品牌的销售专员,需要通过电话联系一位从未接触过的潜在客户。客户是某美容院的老板,你需要在短时间内引起她的兴趣,介绍你们的产品和服务。', + 'ai_role': 'AI扮演一位忙碌的美容院老板,对推销电话比较抵触,但如果销售人员能够快速切入她的需求点(如提升业绩、吸引客户、新项目),她会愿意继续交谈。她关注产品效果、价格和培训支持。', + 'objectives': '["学会专业的电话开场白", "快速建立信任关系", "有效探询客户需求", "预约下次沟通时间"]', + 'keywords': '["开场白", "需求挖掘", "时间管理", "预约技巧"]', + 'duration': 10 + }, + { + 'name': '处理价格异议', + 'description': '练习如何应对客户对产品价格的质疑和异议,强调价值而非价格', + 'type': 'face', + 'difficulty': 'intermediate', + 'status': 'active', + 'background': '客户对你们的轻医美产品很感兴趣,已经了解了产品功能和效果,但认为价格太高,超出了她的预算。你需要通过价值塑造和对比分析来化解价格异议。', + 'ai_role': 'AI扮演一位对价格非常敏感的美容院老板,她会不断提出价格异议,例如"太贵了"、"竞品便宜一半"、"能不能再便宜点"。但如果销售人员能够有效展示产品价值、投资回报率和长期收益,她会逐渐被说服。', + 'objectives': '["掌握价值塑造技巧", "学会处理价格异议", "提升谈判能力", "展示投资回报率"]', + 'keywords': '["异议处理", "价值塑造", "谈判技巧", "ROI分析"]', + 'duration': 15 + }, + { + 'name': '客户投诉处理', + 'description': '模拟客户对产品或服务不满的投诉场景,练习专业的投诉处理技巧', + 'type': 'complaint', + 'difficulty': 'intermediate', + 'status': 'active', + 'background': '一位客户购买了你们的轻医美产品后,使用效果不理想,打电话来投诉。她情绪比较激动,认为产品宣传与实际不符,要求退款。你需要安抚客户情绪,了解问题根源,并提供合理的解决方案。', + 'ai_role': 'AI扮演一位情绪激动的客户,她对产品效果不满意,觉得被欺骗了。她会表达强烈的不满和质疑,但如果客服人员能够真诚道歉、耐心倾听、专业分析问题原因并提供切实可行的解决方案,她的态度会逐渐缓和。', + 'objectives': '["掌握情绪安抚技巧", "学会倾听和共情", "分析问题并提供解决方案", "挽回客户信任"]', + 'keywords': '["投诉处理", "情绪管理", "问题分析", "客户挽回"]', + 'duration': 12 + }, + { + 'name': '产品功能介绍', + 'description': '练习向客户详细介绍轻医美产品的功能特点和优势', + 'type': 'product-intro', + 'difficulty': 'junior', + 'status': 'active', + 'background': '客户对你们的轻医美产品有一定了解,现在希望你详细介绍产品的核心功能、技术原理、使用方法和效果保证。客户比较专业,会提出一些技术性问题。', + 'ai_role': 'AI扮演一位专业的美容院经营者,她对轻医美产品有一定了解,会提出具体的技术问题和需求。例如询问产品成分、作用机理、适用人群、操作流程、注意事项等。她希望得到专业、详细、真实的回答。', + 'objectives': '["清晰介绍产品功能和原理", "突出产品优势和差异化", "专业回答技术问题", "建立专业形象"]', + 'keywords': '["产品介绍", "功能展示", "优势分析", "技术解答"]', + 'duration': 12 + }, + { + 'name': '售后服务咨询', + 'description': '模拟客户咨询售后服务的场景,练习专业的售后服务沟通技巧', + 'type': 'after-sales', + 'difficulty': 'beginner', + 'status': 'active', + 'background': '客户已经购买了你们的轻医美产品,现在打电话咨询售后服务相关问题,包括产品使用方法、遇到的小问题、培训支持、配件购买等。你需要提供专业、耐心、周到的售后服务。', + 'ai_role': 'AI扮演一位已购买产品的美容院老板,她在使用产品过程中遇到一些问题或疑问,希望得到专业的指导和帮助。她的态度比较友好,但希望得到快速、有效的解决方案。她会根据服务质量评价品牌。', + 'objectives': '["掌握产品使用指导技巧", "快速解决客户问题", "提供专业培训支持", "增强客户满意度和忠诚度"]', + 'keywords': '["售后服务", "使用指导", "问题解决", "客户维护"]', + 'duration': 10 + } +] + +def main(): + """主函数""" + try: + print("=" * 60) + print("陪练场景表创建和初始数据插入") + print("=" * 60) + + # 连接数据库 + print("\n📝 连接数据库...") + connection = pymysql.connect(**DB_CONFIG) + cursor = connection.cursor() + + # 创建表 + print("📝 创建practice_scenes表...") + cursor.execute(CREATE_TABLE_SQL) + print("✅ 表创建成功") + + # 检查是否已有数据 + cursor.execute("SELECT COUNT(*) FROM practice_scenes WHERE is_deleted = FALSE") + count = cursor.fetchone()[0] + + if count > 0: + print(f"\n⚠️ 表中已有 {count} 条数据,是否要插入新数据?") + print(" 提示:如果数据已存在,将跳过插入") + + # 插入数据 + insert_sql = """ + INSERT INTO practice_scenes + (name, description, type, difficulty, status, background, ai_role, objectives, keywords, duration, created_by, updated_by) + VALUES (%(name)s, %(description)s, %(type)s, %(difficulty)s, %(status)s, %(background)s, %(ai_role)s, %(objectives)s, %(keywords)s, %(duration)s, 1, 1) + """ + + print(f"\n📝 插入 {len(SCENES)} 个初始场景...") + inserted = 0 + for scene in SCENES: + try: + cursor.execute(insert_sql, scene) + inserted += 1 + print(f" ✅ 插入场景: {scene['name']}") + except pymysql.err.IntegrityError as e: + if "Duplicate entry" in str(e): + print(f" ⚠️ 场景已存在: {scene['name']}") + else: + raise + + # 提交事务 + connection.commit() + print(f"\n✅ 成功插入 {inserted} 个场景") + + # 查询验证 + print("\n📝 查询验证...") + cursor.execute("SELECT id, name, type, difficulty, status FROM practice_scenes WHERE is_deleted = FALSE") + rows = cursor.fetchall() + + print(f"\n当前场景列表(共 {len(rows)} 个):") + print("-" * 80) + print(f"{'ID':<5} {'名称':<30} {'类型':<15} {'难度':<15} {'状态':<10}") + print("-" * 80) + for row in rows: + print(f"{row[0]:<5} {row[1]:<30} {row[2]:<15} {row[3]:<15} {row[4]:<10}") + + # 关闭连接 + cursor.close() + connection.close() + + print("\n" + "=" * 60) + print("✅ 陪练场景表创建和数据插入完成!") + print("=" * 60) + + except pymysql.err.OperationalError as e: + print(f"\n❌ 数据库连接失败: {e}") + print(" 请检查:") + print(" 1. MySQL服务是否启动") + print(" 2. 数据库配置是否正确") + print(" 3. 数据库kaopeilian是否存在") + sys.exit(1) + except Exception as e: + print(f"\n❌ 发生错误: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() + diff --git a/backend/scripts/create_test_data.py b/backend/scripts/create_test_data.py new file mode 100644 index 0000000..765d45a --- /dev/null +++ b/backend/scripts/create_test_data.py @@ -0,0 +1,170 @@ +""" +创建测试数据脚本 +""" +import asyncio +import sys +from pathlib import Path + +# 添加项目路径 +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import AsyncSessionLocal +from app.core.logger import logger +from app.core.security import get_password_hash +from app.models.user import Team, User +from app.schemas.user import UserCreate +from app.services.user_service import UserService + + +async def create_test_users(db: AsyncSession): + """创建测试用户""" + user_service = UserService(db) + + # 测试用户数据 + test_users = [ + { + "username": "admin", + "email": "admin@kaopeilian.com", + "password": "admin123", + "full_name": "系统管理员", + "role": "admin", + }, + { + "username": "trainee1", + "email": "trainee1@kaopeilian.com", + "password": "trainee123", + "full_name": "张三", + "phone": "13800138001", + "role": "trainee", + }, + { + "username": "trainee2", + "email": "trainee2@kaopeilian.com", + "password": "trainee123", + "full_name": "李四", + "phone": "13800138002", + "role": "trainee", + }, + ] + + created_users = [] + for user_data in test_users: + # 检查用户是否已存在 + existing_user = await user_service.get_by_username(user_data["username"]) + if existing_user: + logger.info(f"用户 {user_data['username']} 已存在,跳过创建") + created_users.append(existing_user) + else: + # 创建用户 + user_create = UserCreate(**user_data) + user = await user_service.create_user(obj_in=user_create) + created_users.append(user) + logger.info(f"创建用户: {user.username} ({user.role})") + + return created_users + + +async def create_test_teams(db: AsyncSession, users: list[User]): + """创建测试团队""" + # 获取管理员 + admin = next(u for u in users if u.role == "admin") + + # 检查团队是否已存在 + from sqlalchemy import select + result = await db.execute( + select(Team).where(Team.code == "TECH") + ) + existing_team = result.scalar_one_or_none() + + if not existing_team: + # 创建技术部 + tech_team = Team( + name="技术部", + code="TECH", + description="负责产品研发和技术支持", + team_type="department", + leader_id=admin.id, + created_by=admin.id, + ) + db.add(tech_team) + await db.commit() + await db.refresh(tech_team) + logger.info(f"创建团队: {tech_team.name}") + + # 创建前端组 + frontend_team = Team( + name="前端开发组", + code="TECH-FE", + description="负责前端开发", + team_type="project", + parent_id=tech_team.id, + created_by=admin.id, + ) + db.add(frontend_team) + + # 创建后端组 + backend_team = Team( + name="后端开发组", + code="TECH-BE", + description="负责后端开发", + team_type="project", + parent_id=tech_team.id, + created_by=admin.id, + ) + db.add(backend_team) + + await db.commit() + logger.info("创建子团队: 前端开发组、后端开发组") + + # 将用户加入团队 + user_service = UserService(db) + + # 学员加入子团队 + for user in users: + if user.role == "trainee": + if "1" in user.username: + await user_service.add_user_to_team( + user_id=user.id, + team_id=frontend_team.id, + role="member" + ) + else: + await user_service.add_user_to_team( + user_id=user.id, + team_id=backend_team.id, + role="member" + ) + + logger.info("用户已加入相应团队") + else: + logger.info("团队已存在,跳过创建") + + +async def main(): + """主函数""" + async with AsyncSessionLocal() as db: + try: + logger.info("开始创建测试数据...") + + # 创建测试用户 + users = await create_test_users(db) + + # 创建测试团队 + await create_test_teams(db, users) + + logger.info("测试数据创建完成!") + logger.info("\n可用的测试账号:") + logger.info("管理员 - 用户名: admin, 密码: admin123") + logger.info("学员 - 用户名: trainee1, 密码: trainee123") + logger.info("学员 - 用户名: trainee2, 密码: trainee123") + + except Exception as e: + logger.error(f"创建测试数据失败: {str(e)}") + await db.rollback() + raise + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/fix_chinese_data.py b/backend/scripts/fix_chinese_data.py new file mode 100644 index 0000000..abd7a19 --- /dev/null +++ b/backend/scripts/fix_chinese_data.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +""" +修复数据库中的中文乱码问题 +重新插入正确的中文数据 +""" +import asyncio +import aiomysql +from pathlib import Path +import sys + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +async def fix_chinese_data(): + """修复中文数据""" + print("🔧 开始修复数据库中的中文乱码问题...") + + # 正确的课程数据 + correct_courses = [ + { + "id": 1, + "name": "皮肤生理学基础", + "description": "学习皮肤结构、功能和常见问题,为专业护理打下基础", + "category": "technology", + "status": "published", + "duration_hours": 16.0, + "difficulty_level": 2, + "tags": ["皮肤学", "基础理论", "必修课"], + "sort_order": 100, + "is_featured": True + }, + { + "id": 2, + "name": "医美产品知识与应用", + "description": "全面了解各类医美产品的成分、功效和适用人群", + "category": "technology", + "status": "published", + "duration_hours": 20.0, + "difficulty_level": 3, + "tags": ["产品知识", "医美", "专业技能"], + "sort_order": 110, + "is_featured": True + }, + { + "id": 3, + "name": "美容仪器操作与维护", + "description": "掌握各类美容仪器的操作方法、注意事项和日常维护", + "category": "technology", + "status": "published", + "duration_hours": 24.0, + "difficulty_level": 3, + "tags": ["仪器操作", "实操技能", "设备维护"], + "sort_order": 120, + "is_featured": False + }, + { + "id": 4, + "name": "医美项目介绍与咨询", + "description": "详细了解各类医美项目的原理、效果和适应症", + "category": "technology", + "status": "published", + "duration_hours": 30.0, + "difficulty_level": 4, + "tags": ["医美项目", "专业咨询", "风险告知"], + "sort_order": 170, + "is_featured": True + }, + { + "id": 5, + "name": "轻医美销售技巧", + "description": "学习专业的销售话术、客户需求分析和成交技巧", + "category": "business", + "status": "published", + "duration_hours": 16.0, + "difficulty_level": 2, + "tags": ["销售技巧", "客户沟通", "业绩提升"], + "sort_order": 130, + "is_featured": True + }, + { + "id": 6, + "name": "客户服务与投诉处理", + "description": "提升服务意识,掌握客户投诉处理的方法和技巧", + "category": "business", + "status": "published", + "duration_hours": 12.0, + "difficulty_level": 2, + "tags": ["客户服务", "危机处理", "沟通技巧"], + "sort_order": 140, + "is_featured": False + }, + { + "id": 7, + "name": "社媒营销与私域运营", + "description": "学习如何通过社交媒体进行品牌推广和客户维护", + "category": "business", + "status": "published", + "duration_hours": 16.0, + "difficulty_level": 2, + "tags": ["社媒营销", "私域流量", "客户维护"], + "sort_order": 180, + "is_featured": False + }, + { + "id": 8, + "name": "门店运营管理", + "description": "学习门店日常管理、团队建设和业绩管理", + "category": "management", + "status": "published", + "duration_hours": 20.0, + "difficulty_level": 3, + "tags": ["门店管理", "团队管理", "运营策略"], + "sort_order": 160, + "is_featured": False + }, + { + "id": 9, + "name": "卫生消毒与感染控制", + "description": "学习医美机构的卫生标准和消毒流程,确保服务安全", + "category": "general", + "status": "published", + "duration_hours": 8.0, + "difficulty_level": 1, + "tags": ["卫生安全", "消毒规范", "合规管理"], + "sort_order": 150, + "is_featured": True + }, + { + "id": 10, + "name": "Python编程基础", + "description": "Python语言入门课程,适合零基础学员", + "category": "technology", + "status": "published", + "duration_hours": 40.0, + "difficulty_level": 2, + "tags": ["Python", "编程基础", "入门"], + "sort_order": 200, + "is_featured": False + }, + { + "id": 11, + "name": "数据分析基础", + "description": "学习数据分析方法和工具,提升数据驱动决策能力", + "category": "technology", + "status": "published", + "duration_hours": 32.0, + "difficulty_level": 3, + "tags": ["数据分析", "Excel", "可视化"], + "sort_order": 210, + "is_featured": False + } + ] + + # 正确的岗位数据 + correct_positions = [ + {"id": 1, "name": "区域经理", "code": "region_manager", "description": "负责多家门店的运营管理和业绩达成", "status": "active", "skills": ["团队管理", "业绩分析", "战略规划", "客户关系"], "level": "expert", "sort_order": 10}, + {"id": 2, "name": "店长", "code": "store_manager", "description": "负责门店日常运营管理,团队建设和业绩达成", "status": "active", "skills": ["门店管理", "团队建设", "销售管理", "客户维护"], "level": "senior", "sort_order": 20}, + {"id": 3, "name": "美容顾问", "code": "beauty_consultant", "description": "为客户提供专业的美容咨询和方案设计", "status": "active", "skills": ["产品知识", "销售技巧", "方案设计", "客户沟通"], "level": "intermediate", "sort_order": 30}, + {"id": 4, "name": "医美咨询师", "code": "medical_beauty_consultant", "description": "提供医疗美容项目咨询和方案制定", "status": "active", "skills": ["医美知识", "风险评估", "方案设计", "合规意识"], "level": "senior", "sort_order": 35}, + {"id": 5, "name": "美容技师", "code": "beauty_therapist", "description": "为客户提供专业的美容护理服务", "status": "active", "skills": ["护肤技术", "仪器操作", "手法技巧", "服务意识"], "level": "intermediate", "sort_order": 40}, + {"id": 6, "name": "护士", "code": "nurse", "description": "协助医生进行医美项目操作,负责术后护理", "status": "active", "skills": ["护理技术", "无菌操作", "应急处理", "医疗知识"], "level": "intermediate", "sort_order": 45}, + {"id": 7, "name": "前台接待", "code": "receptionist", "description": "负责客户接待、预约管理和前台事务", "status": "active", "skills": ["接待礼仪", "沟通能力", "信息管理", "服务意识"], "level": "junior", "sort_order": 50}, + {"id": 8, "name": "市场专员", "code": "marketing_specialist", "description": "负责门店营销活动策划和执行", "status": "active", "skills": ["活动策划", "社媒运营", "数据分析", "创意设计"], "level": "intermediate", "sort_order": 60} + ] + + # 正确的用户数据 + correct_users = [ + {"id": 1, "username": "superadmin", "full_name": "超级管理员"}, + {"id": 2, "username": "admin", "full_name": "系统管理员"}, + {"id": 3, "username": "testuser", "full_name": "测试学员"} + ] + + try: + # 连接数据库 + conn = await aiomysql.connect( + host="localhost", + port=3306, + user="root", + password="root", + db="kaopeilian", + charset="utf8mb4", + use_unicode=True, + init_command="SET character_set_client=utf8mb4, character_set_connection=utf8mb4, character_set_results=utf8mb4" + ) + cursor = await conn.cursor() + + print("✅ 数据库连接成功") + + # 开始事务 + await cursor.execute("START TRANSACTION") + + # 更新课程数据 + print("\n📚 更新课程数据...") + for course in correct_courses: + sql = """ + UPDATE courses SET + name = %s, + description = %s, + tags = %s, + updated_at = NOW() + WHERE id = %s + """ + tags_json = str(course["tags"]).replace("'", '"') + await cursor.execute(sql, (course["name"], course["description"], tags_json, course["id"])) + print(f" ✅ 更新课程: {course['name']}") + + # 更新岗位数据 + print("\n👥 更新岗位数据...") + for position in correct_positions: + sql = """ + UPDATE positions SET + name = %s, + description = %s, + skills = %s, + updated_at = NOW() + WHERE id = %s + """ + skills_json = str(position["skills"]).replace("'", '"') + await cursor.execute(sql, (position["name"], position["description"], skills_json, position["id"])) + print(f" ✅ 更新岗位: {position['name']}") + + # 更新用户数据 + print("\n👤 更新用户数据...") + for user in correct_users: + sql = """ + UPDATE users SET + full_name = %s, + updated_at = NOW() + WHERE id = %s + """ + await cursor.execute(sql, (user["full_name"], user["id"])) + print(f" ✅ 更新用户: {user['full_name']}") + + # 提交事务 + await cursor.execute("COMMIT") + print("\n✅ 所有数据更新完成!") + + # 验证更新结果 + print("\n🔍 验证更新结果...") + await cursor.execute("SELECT id, name FROM courses LIMIT 3") + courses = await cursor.fetchall() + for course_id, course_name in courses: + print(f" 课程 {course_id}: {course_name}") + + await cursor.execute("SELECT id, name FROM positions LIMIT 3") + positions = await cursor.fetchall() + for pos_id, pos_name in positions: + print(f" 岗位 {pos_id}: {pos_name}") + + await cursor.execute("SELECT id, full_name FROM users LIMIT 3") + users = await cursor.fetchall() + for user_id, user_name in users: + print(f" 用户 {user_id}: {user_name}") + + await cursor.close() + conn.close() + + print("\n🎉 中文乱码修复完成!") + + except Exception as e: + print(f"❌ 修复失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(fix_chinese_data()) diff --git a/backend/scripts/init_database_unified.sql b/backend/scripts/init_database_unified.sql new file mode 100644 index 0000000..52d4bc8 --- /dev/null +++ b/backend/scripts/init_database_unified.sql @@ -0,0 +1,606 @@ +-- ============================================ +-- 考培练系统数据库初始化脚本 +-- 版本:1.0.0 +-- 更新时间:2024-12 +-- ============================================ + +-- 创建数据库(如果不存在) +CREATE DATABASE IF NOT EXISTS `kaopeilian` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE `kaopeilian`; + +-- ============================================ +-- 一、用户管理模块 +-- ============================================ + +-- 1.1 用户表 +CREATE TABLE IF NOT EXISTS `users` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', + `email` VARCHAR(100) NOT NULL UNIQUE COMMENT '邮箱', + `phone` VARCHAR(20) UNIQUE COMMENT '手机号', + `password_hash` VARCHAR(200) NOT NULL COMMENT '密码哈希', + `full_name` VARCHAR(100) COMMENT '姓名', + `gender` VARCHAR(10) COMMENT '性别: male/female', + `avatar_url` VARCHAR(500) COMMENT '头像URL', + `bio` TEXT COMMENT '个人简介', + `school` VARCHAR(100) COMMENT '学校', + `major` VARCHAR(100) COMMENT '专业', + `role` VARCHAR(20) DEFAULT 'trainee' COMMENT '系统角色: admin, manager, trainee', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否激活', + `is_verified` BOOLEAN DEFAULT FALSE COMMENT '是否验证', + `last_login_at` DATETIME COMMENT '最后登录时间', + `password_changed_at` DATETIME COMMENT '密码修改时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME NULL COMMENT '删除时间', + INDEX idx_role (role), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; + +-- 1.2 团队表 +CREATE TABLE IF NOT EXISTS `teams` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL UNIQUE COMMENT '团队名称', + `code` VARCHAR(50) NOT NULL UNIQUE COMMENT '团队代码', + `description` TEXT COMMENT '团队描述', + `team_type` VARCHAR(50) DEFAULT 'department' COMMENT '团队类型: department, project, study_group', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否激活', + `leader_id` INT COMMENT '负责人ID', + `parent_id` INT COMMENT '父团队ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (leader_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (parent_id) REFERENCES teams(id) ON DELETE CASCADE, + INDEX idx_team_type (team_type), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='团队表'; + +-- 1.3 用户团队关联表 +CREATE TABLE IF NOT EXISTS `user_teams` ( + `user_id` INT NOT NULL, + `team_id` INT NOT NULL, + `role` VARCHAR(50) DEFAULT 'member' COMMENT '团队角色: member, leader', + `joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + PRIMARY KEY (user_id, team_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户团队关联表'; + +-- ============================================ +-- 二、组织与岗位管理模块 +-- ============================================ + +-- 2.0 岗位表 +CREATE TABLE IF NOT EXISTS `positions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL COMMENT '岗位名称', + `code` VARCHAR(100) NOT NULL UNIQUE COMMENT '岗位编码', + `description` TEXT COMMENT '岗位描述', + `parent_id` INT NULL COMMENT '上级岗位ID', + `status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active/inactive', + `skills` JSON NULL COMMENT '核心技能', + `level` VARCHAR(20) NULL COMMENT '岗位等级: junior/intermediate/senior/expert', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME NULL COMMENT '删除时间', + `created_by` INT NULL COMMENT '创建人ID', + `updated_by` INT NULL COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (parent_id) REFERENCES positions(id) ON DELETE SET NULL, + INDEX idx_positions_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位表'; + +-- 插入轻医美连锁岗位(按层级关系) +-- 注意:需要按顺序插入以正确设置parent_id + +-- 第一层:区域经理 +INSERT INTO positions (name, code, description, status, skills, level, sort_order) VALUES +('区域经理', 'region_manager', '负责多家门店的运营管理和业绩达成', 'active', '["团队管理", "业绩分析", "战略规划", "客户关系"]', 'expert', 10); + +-- 获取区域经理ID +SET @region_manager_id = LAST_INSERT_ID(); + +-- 第二层:店长 +INSERT INTO positions (name, code, description, parent_id, status, skills, level, sort_order) VALUES +('店长', 'store_manager', '负责门店日常运营管理,团队建设和业绩达成', @region_manager_id, 'active', '["门店管理", "团队建设", "销售管理", "客户维护"]', 'senior', 20); + +-- 获取店长ID +SET @store_manager_id = LAST_INSERT_ID(); + +-- 第三层:各职能岗位 +INSERT INTO positions (name, code, description, parent_id, status, skills, level, sort_order) VALUES +('美容顾问', 'beauty_consultant', '为客户提供专业的美容咨询和方案设计', @store_manager_id, 'active', '["产品知识", "销售技巧", "方案设计", "客户沟通"]', 'intermediate', 30), +('医美咨询师', 'medical_beauty_consultant', '提供医疗美容项目咨询和方案制定', @store_manager_id, 'active', '["医美知识", "风险评估", "方案设计", "合规意识"]', 'senior', 35), +('美容技师', 'beauty_therapist', '为客户提供专业的美容护理服务', @store_manager_id, 'active', '["护肤技术", "仪器操作", "手法技巧", "服务意识"]', 'intermediate', 40), +('护士', 'nurse', '协助医生进行医美项目操作,负责术后护理', @store_manager_id, 'active', '["护理技术", "无菌操作", "应急处理", "医疗知识"]', 'intermediate', 45), +('前台接待', 'receptionist', '负责客户接待、预约管理和前台事务', @store_manager_id, 'active', '["接待礼仪", "沟通能力", "信息管理", "服务意识"]', 'junior', 50), +('市场专员', 'marketing_specialist', '负责门店营销活动策划和执行', @store_manager_id, 'active', '["活动策划", "社媒运营", "数据分析", "创意设计"]', 'intermediate', 60); + +-- ============================================ +-- 二、课程管理模块 +-- ============================================ + +-- 2.1 课程表 +CREATE TABLE IF NOT EXISTS `courses` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '课程名称', + `description` TEXT COMMENT '课程描述', + `category` ENUM('technology', 'management', 'business', 'general') DEFAULT 'general' COMMENT '课程分类', + `status` ENUM('draft', 'published', 'archived') DEFAULT 'draft' COMMENT '课程状态', + `cover_image` VARCHAR(500) COMMENT '封面图片URL', + `duration_hours` FLOAT COMMENT '课程时长(小时)', + `difficulty_level` INT COMMENT '难度等级(1-5)', + `tags` JSON COMMENT '标签列表', + `published_at` DATETIME COMMENT '发布时间', + `publisher_id` INT COMMENT '发布人ID', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_featured` BOOLEAN DEFAULT FALSE COMMENT '是否推荐', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_status (status), + INDEX idx_category (category), + INDEX idx_is_featured (is_featured), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程表'; + +-- 2.2 课程资料表 +CREATE TABLE IF NOT EXISTS `course_materials` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `name` VARCHAR(200) NOT NULL COMMENT '资料名称', + `description` TEXT COMMENT '资料描述', + `file_url` VARCHAR(500) NOT NULL COMMENT '文件URL', + `file_type` VARCHAR(50) NOT NULL COMMENT '文件类型', + `file_size` INT NOT NULL COMMENT '文件大小(字节)', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程资料表'; + +-- 2.3 知识点表 +CREATE TABLE IF NOT EXISTS `knowledge_points` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `name` VARCHAR(200) NOT NULL COMMENT '知识点名称', + `description` TEXT COMMENT '知识点描述', + `parent_id` INT COMMENT '父知识点ID', + `level` INT DEFAULT 1 COMMENT '层级深度', + `path` VARCHAR(500) COMMENT '路径(如: 1.2.3)', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `weight` FLOAT DEFAULT 1.0 COMMENT '权重', + `is_required` BOOLEAN DEFAULT TRUE COMMENT '是否必修', + `estimated_hours` FLOAT COMMENT '预计学习时间(小时)', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_by` INT NULL COMMENT '创建人ID', + `updated_by` INT NULL COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES knowledge_points(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_parent_id (parent_id), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识点表'; + +-- 2.4 成长路径表 +CREATE TABLE IF NOT EXISTS `growth_paths` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '路径名称', + `description` TEXT COMMENT '路径描述', + `target_role` VARCHAR(100) COMMENT '目标角色', + `courses` JSON COMMENT '课程列表[{course_id, order, is_required}]', + `estimated_duration_days` INT COMMENT '预计完成天数', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_is_active (is_active), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成长路径表'; + +-- 资料知识点关联表 +CREATE TABLE `material_knowledge_points` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `material_id` INT NOT NULL COMMENT '资料ID', + `knowledge_point_id` INT NOT NULL COMMENT '知识点ID', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_ai_generated` BOOLEAN DEFAULT FALSE COMMENT '是否AI生成', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY `idx_material_knowledge` (`material_id`, `knowledge_point_id`), + FOREIGN KEY (material_id) REFERENCES course_materials(id) ON DELETE CASCADE, + FOREIGN KEY (knowledge_point_id) REFERENCES knowledge_points(id) ON DELETE CASCADE, + INDEX idx_material_id (material_id), + INDEX idx_knowledge_point_id (knowledge_point_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='资料知识点关联表'; + +-- ============================================ +-- 三、考试模块 +-- ============================================ + +-- 3.1 题目表 +CREATE TABLE IF NOT EXISTS `questions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `question_type` VARCHAR(20) NOT NULL COMMENT '题目类型: single_choice, multiple_choice, true_false, fill_blank, essay', + `title` TEXT NOT NULL COMMENT '题目标题', + `content` TEXT COMMENT '题目内容', + `options` JSON COMMENT '选项(JSON格式)', + `correct_answer` TEXT NOT NULL COMMENT '正确答案', + `explanation` TEXT COMMENT '答案解释', + `score` FLOAT DEFAULT 10.0 COMMENT '分值', + `difficulty` VARCHAR(10) DEFAULT 'medium' COMMENT '难度等级: easy, medium, hard', + `tags` JSON COMMENT '标签(JSON格式)', + `usage_count` INT DEFAULT 0 COMMENT '使用次数', + `correct_count` INT DEFAULT 0 COMMENT '答对次数', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_question_type (question_type), + INDEX idx_difficulty (difficulty), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题目表'; + +-- 3.2 考试记录表 +CREATE TABLE IF NOT EXISTS `exams` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL COMMENT '用户ID', + `course_id` INT NOT NULL COMMENT '课程ID', + `exam_name` VARCHAR(255) NOT NULL COMMENT '考试名称', + `question_count` INT DEFAULT 10 COMMENT '题目数量', + `total_score` FLOAT DEFAULT 100.0 COMMENT '总分', + `pass_score` FLOAT DEFAULT 60.0 COMMENT '及格分', + `start_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + `end_time` DATETIME COMMENT '结束时间', + `duration_minutes` INT DEFAULT 60 COMMENT '考试时长(分钟)', + `score` FLOAT COMMENT '得分', + `is_passed` BOOLEAN COMMENT '是否通过', + `status` VARCHAR(20) DEFAULT 'started' COMMENT '状态: started, submitted, timeout', + `questions` JSON COMMENT '题目数据(JSON格式)', + `answers` JSON COMMENT '答案数据(JSON格式)', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_course_id (course_id), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试记录表'; + +-- 3.3 考试结果详情表 +CREATE TABLE IF NOT EXISTS `exam_results` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `exam_id` INT NOT NULL COMMENT '考试ID', + `question_id` INT NOT NULL COMMENT '题目ID', + `user_answer` TEXT COMMENT '用户答案', + `is_correct` BOOLEAN DEFAULT FALSE COMMENT '是否正确', + `score` FLOAT DEFAULT 0.0 COMMENT '得分', + `answer_time` INT COMMENT '答题时长(秒)', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE, + FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE, + INDEX idx_exam_id (exam_id), + INDEX idx_question_id (question_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试结果详情表'; + +-- ============================================ +-- 四、陪练模块 +-- ============================================ + +-- 4.1 陪练场景表 +CREATE TABLE IF NOT EXISTS `training_scenes` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL COMMENT '场景名称', + `description` TEXT COMMENT '场景描述', + `category` VARCHAR(50) NOT NULL COMMENT '场景分类', + `ai_config` JSON COMMENT 'AI配置(如Coze Bot ID等)', + `prompt_template` TEXT COMMENT '提示词模板', + `evaluation_criteria` JSON COMMENT '评估标准', + `status` ENUM('DRAFT', 'ACTIVE', 'INACTIVE') DEFAULT 'DRAFT' COMMENT '场景状态', + `is_public` BOOLEAN DEFAULT TRUE COMMENT '是否公开', + `required_level` INT COMMENT '所需用户等级', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_status (status), + INDEX idx_category (category), + INDEX idx_is_public (is_public), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练场景表'; + +-- 4.2 陪练会话表 +CREATE TABLE IF NOT EXISTS `training_sessions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL COMMENT '用户ID', + `scene_id` INT NOT NULL COMMENT '场景ID', + `coze_conversation_id` VARCHAR(100) COMMENT 'Coze会话ID', + `start_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + `end_time` DATETIME COMMENT '结束时间', + `duration_seconds` INT COMMENT '持续时长(秒)', + `status` ENUM('CREATED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'ERROR') DEFAULT 'CREATED' COMMENT '会话状态', + `session_config` JSON COMMENT '会话配置', + `total_score` FLOAT COMMENT '总分', + `evaluation_result` JSON COMMENT '评估结果详情', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (scene_id) REFERENCES training_scenes(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_scene_id (scene_id), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练会话表'; + +-- 4.3 陪练消息表 +CREATE TABLE IF NOT EXISTS `training_messages` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` INT NOT NULL COMMENT '会话ID', + `role` ENUM('USER', 'ASSISTANT', 'SYSTEM') NOT NULL COMMENT '消息角色', + `type` ENUM('TEXT', 'VOICE', 'SYSTEM') NOT NULL COMMENT '消息类型', + `content` TEXT NOT NULL COMMENT '消息内容', + `voice_url` VARCHAR(500) COMMENT '语音文件URL', + `voice_duration` FLOAT COMMENT '语音时长(秒)', + `message_metadata` JSON COMMENT '消息元数据', + `coze_message_id` VARCHAR(100) COMMENT 'Coze消息ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES training_sessions(id) ON DELETE CASCADE, + INDEX idx_session_id (session_id), + INDEX idx_role (role) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练消息表'; + +-- 4.4 陪练报告表 +CREATE TABLE IF NOT EXISTS `training_reports` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` INT NOT NULL UNIQUE COMMENT '会话ID', + `user_id` INT NOT NULL COMMENT '用户ID', + `overall_score` FLOAT NOT NULL COMMENT '总体得分', + `dimension_scores` JSON NOT NULL COMMENT '各维度得分', + `strengths` JSON NOT NULL COMMENT '优势点', + `weaknesses` JSON NOT NULL COMMENT '待改进点', + `suggestions` JSON NOT NULL COMMENT '改进建议', + `detailed_analysis` TEXT COMMENT '详细分析', + `transcript` TEXT COMMENT '对话文本记录', + `statistics` JSON COMMENT '统计数据', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES training_sessions(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练报告表'; + +-- ============================================ +-- 五、初始测试数据 +-- ============================================ + +-- 插入测试用户 +INSERT INTO users (username, email, phone, password_hash, full_name, role, is_active, is_verified) VALUES +('superadmin', 'superadmin@kaopeilian.com', '13800000001', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '超级管理员', 'admin', TRUE, TRUE), +('admin', 'admin@kaopeilian.com', '13800000002', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '系统管理员', 'admin', TRUE, TRUE), +('testuser', 'testuser@kaopeilian.com', '13800000003', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '测试学员', 'trainee', TRUE, TRUE); + +-- 插入测试团队 +INSERT INTO teams (name, code, description, team_type, leader_id) VALUES +('技术部', 'TECH', '负责技术研发和维护', 'department', 2), +('产品部', 'PROD', '负责产品设计和规划', 'department', 2), +('Python学习小组', 'PY_GROUP', 'Python技术学习交流', 'study_group', 2); + +-- 插入用户团队关联 +INSERT INTO user_teams (user_id, team_id, role) VALUES +(2, 1, 'leader'), +(2, 2, 'leader'), +(3, 1, 'member'), +(3, 3, 'member'); + +-- 插入轻医美相关课程 +INSERT INTO courses (name, description, category, status, duration_hours, difficulty_level, tags, is_featured, sort_order, published_at) VALUES +-- 技术类课程 +('皮肤生理学基础', '学习皮肤结构、功能和常见问题,为专业护理打下基础', 'technology', 'published', 16, 2, '["皮肤学", "基础理论", "必修课"]', TRUE, 100, NOW()), +('医美产品知识与应用', '全面了解各类医美产品的成分、功效和适用人群', 'technology', 'published', 20, 3, '["产品知识", "医美", "专业技能"]', TRUE, 110, NOW()), +('美容仪器操作与维护', '掌握各类美容仪器的操作方法、注意事项和日常维护', 'technology', 'published', 24, 3, '["仪器操作", "实操技能", "设备维护"]', FALSE, 120, NOW()), +('医美项目介绍与咨询', '详细了解各类医美项目的原理、效果和适应症', 'technology', 'published', 30, 4, '["医美项目", "专业咨询", "风险告知"]', TRUE, 170, NOW()), + +-- 业务类课程 +('轻医美销售技巧', '学习专业的销售话术、客户需求分析和成交技巧', 'business', 'published', 16, 2, '["销售技巧", "客户沟通", "业绩提升"]', TRUE, 130, NOW()), +('客户服务与投诉处理', '提升服务意识,掌握客户投诉处理的方法和技巧', 'business', 'published', 12, 2, '["客户服务", "危机处理", "沟通技巧"]', FALSE, 140, NOW()), +('社媒营销与私域运营', '学习如何通过社交媒体进行品牌推广和客户维护', 'business', 'published', 16, 2, '["社媒营销", "私域流量", "客户维护"]', FALSE, 180, NOW()), + +-- 管理类课程 +('门店运营管理', '学习门店日常管理、团队建设和业绩管理', 'management', 'published', 20, 3, '["门店管理", "团队管理", "运营策略"]', FALSE, 160, NOW()), + +-- 通用类课程 +('卫生消毒与感染控制', '学习医美机构的卫生标准和消毒流程,确保服务安全', 'general', 'published', 8, 1, '["卫生安全", "消毒规范", "合规管理"]', TRUE, 150, NOW()), + +-- 保留原有的技术课程作为选修参考 +('Python编程基础', 'Python语言入门课程,适合零基础学员', 'technology', 'published', 40, 2, '["Python", "编程基础", "入门"]', FALSE, 200, NOW()), +('数据分析基础', '学习数据分析方法和工具,提升数据驱动决策能力', 'technology', 'published', 32, 3, '["数据分析", "Excel", "可视化"]', FALSE, 210, NOW()); + +-- 为第一个课程添加资料 +INSERT INTO course_materials (course_id, name, description, file_url, file_type, file_size) VALUES +(1, 'Python基础教程.pdf', 'Python编程基础教程文档', '/uploads/python-basics.pdf', 'pdf', 2048000), +(1, '课程视频1', '第一章节视频教程', '/uploads/video1.mp4', 'mp4', 104857600); + +-- 为第一个课程添加知识点 +INSERT INTO knowledge_points (course_id, name, description, parent_id, level, weight, estimated_hours) VALUES +(1, 'Python环境搭建', '学习如何安装和配置Python开发环境', NULL, 1, 1.0, 2), +(1, 'Python基础语法', '学习Python的基本语法规则', NULL, 1, 2.0, 8), +(1, '变量和数据类型', '了解Python中的变量和基本数据类型', 2, 2, 1.5, 3), +(1, '控制流程', '学习条件语句和循环结构', 2, 2, 1.5, 4); + +-- 插入测试题目 +INSERT INTO questions (course_id, question_type, title, content, options, correct_answer, explanation, score, difficulty, tags) VALUES +(1, 'single_choice', 'Python中哪个关键字用于定义函数?', NULL, '{"A": "def", "B": "function", "C": "fun", "D": "define"}', 'A', 'Python使用def关键字来定义函数', 10.0, 'easy', '["python", "基础", "函数"]'), +(1, 'single_choice', 'Python中列表和元组的主要区别是什么?', NULL, '{"A": "列表是有序的,元组是无序的", "B": "列表可变,元组不可变", "C": "列表只能存储数字,元组可以存储任何类型", "D": "没有区别"}', 'B', '列表是可变的(mutable),而元组是不可变的(immutable)', 10.0, 'medium', '["python", "数据结构"]'), +(1, 'true_false', 'Python是一种编译型语言', NULL, NULL, 'false', 'Python是一种解释型语言,不需要编译成机器码', 10.0, 'easy', '["python", "基础"]'), +(1, 'fill_blank', 'Python中使用____关键字定义类', NULL, NULL, 'class', '使用class关键字定义类', 10.0, 'easy', '["python", "面向对象"]'); + +-- 插入陪练场景 +INSERT INTO training_scenes (name, description, category, ai_config, status, is_public) VALUES +('Python编程助手', '帮助学员解决Python编程问题', '技术辅导', '{"bot_id": "python_assistant_bot"}', 'active', TRUE), +('面试模拟', '模拟技术面试场景', '职业发展', '{"bot_id": "interview_simulator_bot"}', 'active', TRUE), +('项目讨论', '项目方案讨论和优化', '项目管理', '{"bot_id": "project_discussion_bot"}', 'draft', TRUE); + +-- 插入成长路径 +INSERT INTO growth_paths (name, description, target_role, courses, estimated_duration_days) VALUES +('Python工程师成长路径', '从入门到精通的Python学习路径', 'Python开发工程师', '[{"course_id": 1, "order": 1, "is_required": true}, {"course_id": 4, "order": 2, "is_required": true}]', 90), +('技术管理者路径', '技术人员转型管理岗位', '技术经理', '[{"course_id": 2, "order": 1, "is_required": true}, {"course_id": 3, "order": 2, "is_required": false}]', 60); + +-- ============================================ +-- 六、创建视图(可选) +-- ============================================ + +-- 用户课程进度视图 +CREATE OR REPLACE VIEW v_user_course_progress AS +SELECT + u.id as user_id, + u.username, + c.id as course_id, + c.name as course_name, + COUNT(DISTINCT e.id) as exam_count, + AVG(e.score) as avg_score, + MAX(e.score) as best_score +FROM users u +CROSS JOIN courses c +LEFT JOIN exams e ON e.user_id = u.id AND e.course_id = c.id AND e.status = 'submitted' +GROUP BY u.id, c.id; + +-- ============================================ +-- 七、岗位成员和课程关联表 +-- ============================================ + +-- 创建岗位成员关联表 +CREATE TABLE IF NOT EXISTS position_members ( + id INT AUTO_INCREMENT PRIMARY KEY, + position_id INT NOT NULL COMMENT '岗位ID', + user_id INT NOT NULL COMMENT '用户ID', + role VARCHAR(50) COMMENT '成员角色(预留字段)', + joined_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + FOREIGN KEY (position_id) REFERENCES positions(id), + FOREIGN KEY (user_id) REFERENCES users(id), + UNIQUE KEY uix_position_user (position_id, user_id, is_deleted), + INDEX ix_position_members_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位成员关联表'; + +-- 创建岗位课程关联表 +CREATE TABLE IF NOT EXISTS position_courses ( + id INT AUTO_INCREMENT PRIMARY KEY, + position_id INT NOT NULL COMMENT '岗位ID', + course_id INT NOT NULL COMMENT '课程ID', + course_type VARCHAR(20) NOT NULL DEFAULT 'required' COMMENT '课程类型', + priority INT DEFAULT 0 COMMENT '优先级/排序', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + FOREIGN KEY (position_id) REFERENCES positions(id), + FOREIGN KEY (course_id) REFERENCES courses(id), + UNIQUE KEY uix_position_course (position_id, course_id, is_deleted), + INDEX ix_position_courses_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位课程关联表'; + +-- 创建课程考试设置表 +CREATE TABLE IF NOT EXISTS course_exam_settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + course_id INT NOT NULL UNIQUE COMMENT '课程ID', + single_choice_count INT NOT NULL DEFAULT 10 COMMENT '单选题数量', + multiple_choice_count INT NOT NULL DEFAULT 5 COMMENT '多选题数量', + true_false_count INT NOT NULL DEFAULT 5 COMMENT '判断题数量', + fill_blank_count INT NOT NULL DEFAULT 0 COMMENT '填空题数量', + duration_minutes INT NOT NULL DEFAULT 60 COMMENT '考试时长(分钟)', + difficulty_level INT NOT NULL DEFAULT 3 COMMENT '难度系数(1-5)', + passing_score INT NOT NULL DEFAULT 60 COMMENT '及格分数', + is_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用', + show_answer_immediately BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否立即显示答案', + allow_retake BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否允许重考', + max_retake_times INT COMMENT '最大重考次数', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + deleted_by INT COMMENT '删除人ID', + FOREIGN KEY (course_id) REFERENCES courses(id), + INDEX ix_course_exam_settings_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程考试设置表'; + +-- 插入岗位成员样例数据(使用已存在的用户) +INSERT INTO position_members (position_id, user_id) VALUES +(2, 2), -- 店长:admin +(3, 3); -- 美容顾问:testuser + +-- 插入岗位课程关联数据 +-- 需要根据实际插入的课程ID来设置,这里使用子查询获取课程ID + +-- 店长必修课程 +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 2, id, 'required', 1 FROM courses WHERE name = '门店运营管理' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 2, id, 'required', 2 FROM courses WHERE name = '轻医美销售技巧' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 2, id, 'required', 3 FROM courses WHERE name = '客户服务与投诉处理' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 2, id, 'required', 4 FROM courses WHERE name = '卫生消毒与感染控制' AND is_deleted = FALSE LIMIT 1; + +-- 美容顾问必修课程 +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 3, id, 'required', 1 FROM courses WHERE name = '皮肤生理学基础' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 3, id, 'required', 2 FROM courses WHERE name = '医美产品知识与应用' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 3, id, 'required', 3 FROM courses WHERE name = '轻医美销售技巧' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 3, id, 'required', 4 FROM courses WHERE name = '客户服务与投诉处理' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 3, id, 'optional', 5 FROM courses WHERE name = '社媒营销与私域运营' AND is_deleted = FALSE LIMIT 1; + +-- 美容技师必修课程 +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 4, id, 'required', 1 FROM courses WHERE name = '皮肤生理学基础' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 4, id, 'required', 2 FROM courses WHERE name = '美容仪器操作与维护' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 4, id, 'required', 3 FROM courses WHERE name = '卫生消毒与感染控制' AND is_deleted = FALSE LIMIT 1; + +-- ============================================ +-- 八、输出完成信息 +-- ============================================ + +SELECT '数据库初始化完成!' as message; +SELECT '默认账号-超级管理员:superadmin / Superadmin123!' as info1; +SELECT '默认账号-系统管理员:admin / Admin123!' as info2; +SELECT '默认账号-测试学员:testuser / TestPass123!' as info3; diff --git a/backend/scripts/init_db.py b/backend/scripts/init_db.py new file mode 100644 index 0000000..a658ee8 --- /dev/null +++ b/backend/scripts/init_db.py @@ -0,0 +1,64 @@ +""" +初始化数据库脚本 +""" +import asyncio +import os +import sys +from pathlib import Path + +# 添加项目路径 +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +from app.core.config import settings +from app.core.logger import logger + + +async def create_database(): + """创建数据库(如果不存在)""" + # 解析数据库URL + db_url_parts = settings.DATABASE_URL.split('/') + db_name = db_url_parts[-1].split('?')[0] + db_url_without_db = '/'.join(db_url_parts[:-1]) + + # 连接到MySQL服务器(不指定数据库) + engine = create_async_engine(db_url_without_db, echo=True) + + async with engine.begin() as conn: + # 检查数据库是否存在 + result = await conn.execute( + text(f"SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = '{db_name}'") + ) + exists = result.scalar() is not None + + if not exists: + # 创建数据库 + await conn.execute(text(f"CREATE DATABASE IF NOT EXISTS `{db_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")) + logger.info(f"数据库 {db_name} 创建成功") + else: + logger.info(f"数据库 {db_name} 已存在") + + await engine.dispose() + + +async def main(): + """主函数""" + try: + # 创建数据库 + await create_database() + + # 运行迁移 + logger.info("开始运行数据库迁移...") + os.system("cd /workspace/kaopeilian-backend && alembic upgrade head") + + logger.info("数据库初始化完成!") + + except Exception as e: + logger.error(f"数据库初始化失败: {str(e)}") + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/init_db.sql b/backend/scripts/init_db.sql new file mode 100644 index 0000000..8a2d11a --- /dev/null +++ b/backend/scripts/init_db.sql @@ -0,0 +1,113 @@ +-- 创建数据库(如果不存在) +CREATE DATABASE IF NOT EXISTS kaopeilian CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE kaopeilian; + +-- 课程表 +CREATE TABLE IF NOT EXISTS courses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(200) NOT NULL COMMENT '课程名称', + description TEXT COMMENT '课程描述', + category ENUM('technology', 'management', 'business', 'general') DEFAULT 'general' COMMENT '课程分类', + status ENUM('draft', 'published', 'archived') DEFAULT 'draft' COMMENT '课程状态', + cover_image VARCHAR(500) COMMENT '封面图片URL', + duration_hours FLOAT COMMENT '课程时长(小时)', + difficulty_level INT COMMENT '难度等级(1-5)', + tags JSON COMMENT '标签列表', + published_at DATETIME COMMENT '发布时间', + publisher_id INT COMMENT '发布人ID', + sort_order INT DEFAULT 0 COMMENT '排序顺序', + is_featured BOOLEAN DEFAULT FALSE COMMENT '是否推荐', + is_deleted BOOLEAN DEFAULT FALSE COMMENT '是否删除', + deleted_at DATETIME COMMENT '删除时间', + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX idx_status (status), + INDEX idx_category (category), + INDEX idx_is_featured (is_featured), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表'; + +-- 课程资料表 +CREATE TABLE IF NOT EXISTS course_materials ( + id INT AUTO_INCREMENT PRIMARY KEY, + course_id INT NOT NULL COMMENT '课程ID', + name VARCHAR(200) NOT NULL COMMENT '资料名称', + description TEXT COMMENT '资料描述', + file_url VARCHAR(500) NOT NULL COMMENT '文件URL', + file_type VARCHAR(50) NOT NULL COMMENT '文件类型', + file_size INT NOT NULL COMMENT '文件大小(字节)', + sort_order INT DEFAULT 0 COMMENT '排序顺序', + is_deleted BOOLEAN DEFAULT FALSE COMMENT '是否删除', + deleted_at DATETIME COMMENT '删除时间', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程资料表'; + +-- 知识点表 +CREATE TABLE IF NOT EXISTS knowledge_points ( + id INT AUTO_INCREMENT PRIMARY KEY, + course_id INT NOT NULL COMMENT '课程ID', + name VARCHAR(200) NOT NULL COMMENT '知识点名称', + description TEXT COMMENT '知识点描述', + parent_id INT COMMENT '父知识点ID', + level INT DEFAULT 1 COMMENT '层级深度', + path VARCHAR(500) COMMENT '路径(如: 1.2.3)', + sort_order INT DEFAULT 0 COMMENT '排序顺序', + weight FLOAT DEFAULT 1.0 COMMENT '权重', + is_required BOOLEAN DEFAULT TRUE COMMENT '是否必修', + estimated_hours FLOAT COMMENT '预计学习时间(小时)', + is_deleted BOOLEAN DEFAULT FALSE COMMENT '是否删除', + deleted_at DATETIME COMMENT '删除时间', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES knowledge_points(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_parent_id (parent_id), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识点表'; + +-- 成长路径表 +CREATE TABLE IF NOT EXISTS growth_paths ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(200) NOT NULL COMMENT '路径名称', + description TEXT COMMENT '路径描述', + target_role VARCHAR(100) COMMENT '目标角色', + courses JSON COMMENT '课程列表[{course_id, order, is_required}]', + estimated_duration_days INT COMMENT '预计完成天数', + is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用', + sort_order INT DEFAULT 0 COMMENT '排序顺序', + is_deleted BOOLEAN DEFAULT FALSE COMMENT '是否删除', + deleted_at DATETIME COMMENT '删除时间', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX idx_is_active (is_active), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成长路径表'; + +-- 插入测试数据 +INSERT INTO courses (name, description, category, status, difficulty_level, tags, is_featured) VALUES +('Python编程基础', 'Python语言入门课程,适合零基础学员', 'technology', 'published', 2, '["Python", "编程基础", "入门"]', TRUE), +('项目管理实战', '学习现代项目管理方法和工具', 'management', 'published', 3, '["项目管理", "敏捷", "Scrum"]', FALSE), +('商务沟通技巧', '提升职场沟通能力', 'business', 'draft', 2, '["沟通", "软技能", "职场"]', FALSE); + +-- 为第一个课程添加资料 +INSERT INTO course_materials (course_id, name, description, file_url, file_type, file_size) VALUES +(1, 'Python基础教程.pdf', 'Python编程基础教程文档', '/uploads/python-basics.pdf', 'pdf', 2048000), +(1, '课程视频1', '第一章节视频教程', '/uploads/video1.mp4', 'mp4', 104857600); + +-- 为第一个课程添加知识点 +INSERT INTO knowledge_points (course_id, name, description, parent_id, level, weight, estimated_hours) VALUES +(1, 'Python环境搭建', '学习如何安装和配置Python开发环境', NULL, 1, 1.0, 2), +(1, 'Python基础语法', '学习Python的基本语法规则', NULL, 1, 2.0, 8), +(1, '变量和数据类型', '了解Python中的变量和基本数据类型', 2, 2, 1.5, 3), +(1, '控制流程', '学习条件语句和循环结构', 2, 2, 1.5, 4); diff --git a/backend/scripts/init_project.sh b/backend/scripts/init_project.sh new file mode 100755 index 0000000..ef56331 --- /dev/null +++ b/backend/scripts/init_project.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +echo "初始化考培练系统后端项目..." + +# 创建虚拟环境 +if [ ! -d "venv" ]; then + echo "创建虚拟环境..." + python3 -m venv venv +fi + +# 激活虚拟环境 +source venv/bin/activate + +# 升级pip +pip install --upgrade pip + +# 安装依赖 +echo "安装项目依赖..." +pip install -r requirements/dev.txt + +# 复制环境变量文件 +if [ ! -f ".env" ]; then + echo "创建环境变量文件..." + cp .env.example .env + echo "请编辑 .env 文件配置必要的环境变量" +fi + +# 创建必要的目录 +echo "创建必要的目录..." +mkdir -p logs uploads + +# 初始化pre-commit +if command -v pre-commit &> /dev/null; then + echo "配置pre-commit hooks..." + pre-commit install +fi + +echo "" +echo "项目初始化完成!" +echo "" +echo "下一步:" +echo "1. 编辑 .env 文件,配置数据库和AI平台信息" +echo "2. 启动MySQL和Redis服务" +echo "3. 运行 'alembic init migrations' 初始化数据库迁移" +echo "4. 运行 'alembic revision --autogenerate -m \"initial\"' 创建初始迁移" +echo "5. 运行 'alembic upgrade head' 应用迁移" +echo "6. 运行 'make run-dev' 启动开发服务器" +echo "" +echo "祝开发顺利! 🚀" diff --git a/backend/scripts/kaopeilian_rollback.py b/backend/scripts/kaopeilian_rollback.py new file mode 100644 index 0000000..104250e --- /dev/null +++ b/backend/scripts/kaopeilian_rollback.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +""" +考培练系统 - 专用数据库回滚工具 +针对轻医美连锁业务场景的快速回滚方案 + +功能: +1. 用户数据回滚 +2. 课程数据回滚 +3. 考试数据回滚 +4. 岗位数据回滚 +5. 基于Binlog的精确回滚 + +使用方法: +python scripts/kaopeilian_rollback.py --help +""" + +import asyncio +import argparse +import json +from datetime import datetime, timedelta +from typing import List, Dict, Optional +import aiomysql +import logging + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +class KaopeilianRollbackTool: + """考培练系统专用回滚工具""" + + def __init__(self): + self.host = "localhost" + self.port = 3306 + self.user = "root" + self.password = "root" + self.database = "kaopeilian" + self.connection = None + + async def connect(self): + """连接数据库""" + try: + self.connection = await aiomysql.connect( + host=self.host, + port=self.port, + user=self.user, + password=self.password, + db=self.database, + charset='utf8mb4' + ) + logger.info("✅ 数据库连接成功") + except Exception as e: + logger.error(f"❌ 数据库连接失败: {e}") + raise + + async def close(self): + """关闭连接""" + if self.connection: + self.connection.close() + + async def get_recent_operations(self, hours: int = 24) -> List[Dict]: + """获取最近的操作记录""" + cursor = await self.connection.cursor(aiomysql.DictCursor) + + # 查询最近更新的记录 + queries = [ + { + 'table': 'users', + 'sql': f""" + SELECT id, username, full_name, updated_at, 'user' as type + FROM users + WHERE updated_at >= DATE_SUB(NOW(), INTERVAL {hours} HOUR) + ORDER BY updated_at DESC + """ + }, + { + 'table': 'courses', + 'sql': f""" + SELECT id, name, status, updated_at, 'course' as type + FROM courses + WHERE updated_at >= DATE_SUB(NOW(), INTERVAL {hours} HOUR) + ORDER BY updated_at DESC + """ + }, + { + 'table': 'exams', + 'sql': f""" + SELECT id, user_id, course_id, exam_name, score, updated_at, 'exam' as type + FROM exams + WHERE updated_at >= DATE_SUB(NOW(), INTERVAL {hours} HOUR) + ORDER BY updated_at DESC + """ + }, + { + 'table': 'positions', + 'sql': f""" + SELECT id, name, code, status, updated_at, 'position' as type + FROM positions + WHERE updated_at >= DATE_SUB(NOW(), INTERVAL {hours} HOUR) + ORDER BY updated_at DESC + """ + } + ] + + all_operations = [] + for query in queries: + try: + await cursor.execute(query['sql']) + results = await cursor.fetchall() + all_operations.extend(results) + except Exception as e: + logger.warning(f"⚠️ 查询 {query['table']} 表失败: {e}") + + await cursor.close() + return all_operations + + async def create_data_backup(self, table: str, record_id: int) -> Dict: + """创建单条记录的备份""" + cursor = await self.connection.cursor(aiomysql.DictCursor) + + try: + await cursor.execute(f"SELECT * FROM {table} WHERE id = %s", (record_id,)) + record = await cursor.fetchone() + + if record: + backup = { + 'table': table, + 'record_id': record_id, + 'data': dict(record), + 'backup_time': datetime.now().isoformat() + } + logger.info(f"✅ 已备份 {table} 表记录 ID: {record_id}") + return backup + else: + logger.warning(f"⚠️ 未找到 {table} 表记录 ID: {record_id}") + return None + + except Exception as e: + logger.error(f"❌ 备份 {table} 表记录失败: {e}") + return None + finally: + await cursor.close() + + async def restore_from_backup(self, backup: Dict) -> bool: + """从备份恢复数据""" + if not backup: + return False + + cursor = await self.connection.cursor() + + try: + # 开始事务 + await cursor.execute("START TRANSACTION") + + table = backup['table'] + data = backup['data'] + record_id = backup['record_id'] + + # 构建UPDATE语句 + set_clauses = [] + values = [] + + for key, value in data.items(): + if key != 'id': # 跳过主键 + set_clauses.append(f"`{key}` = %s") + values.append(value) + + if set_clauses: + sql = f"UPDATE `{table}` SET {', '.join(set_clauses)} WHERE id = %s" + values.append(record_id) + + await cursor.execute(sql, values) + await cursor.execute("COMMIT") + + logger.info(f"✅ 已恢复 {table} 表记录 ID: {record_id}") + return True + else: + await cursor.execute("ROLLBACK") + logger.warning(f"⚠️ 没有可恢复的字段") + return False + + except Exception as e: + await cursor.execute("ROLLBACK") + logger.error(f"❌ 恢复数据失败: {e}") + return False + finally: + await cursor.close() + + async def soft_delete_rollback(self, table: str, record_id: int) -> bool: + """软删除回滚""" + cursor = await self.connection.cursor() + + try: + # 检查表是否有软删除字段 + await cursor.execute(f"SHOW COLUMNS FROM {table} LIKE 'is_deleted'") + has_soft_delete = await cursor.fetchone() + + if not has_soft_delete: + logger.warning(f"⚠️ {table} 表没有软删除字段") + return False + + # 恢复软删除的记录 + await cursor.execute("START TRANSACTION") + + sql = f""" + UPDATE `{table}` + SET is_deleted = FALSE, deleted_at = NULL + WHERE id = %s + """ + await cursor.execute(sql, (record_id,)) + + if cursor.rowcount > 0: + await cursor.execute("COMMIT") + logger.info(f"✅ 已恢复软删除记录 {table} ID: {record_id}") + return True + else: + await cursor.execute("ROLLBACK") + logger.warning(f"⚠️ 未找到要恢复的记录 {table} ID: {record_id}") + return False + + except Exception as e: + await cursor.execute("ROLLBACK") + logger.error(f"❌ 软删除回滚失败: {e}") + return False + finally: + await cursor.close() + + async def rollback_user_operation(self, user_id: int, operation_type: str = "update") -> bool: + """回滚用户操作""" + logger.info(f"🔄 开始回滚用户操作: ID={user_id}, 类型={operation_type}") + + if operation_type == "delete": + return await self.soft_delete_rollback("users", user_id) + else: + # 对于更新操作,需要从Binlog或其他方式获取原始数据 + logger.warning("⚠️ 用户更新操作回滚需要手动处理") + return False + + async def rollback_course_operation(self, course_id: int, operation_type: str = "update") -> bool: + """回滚课程操作""" + logger.info(f"🔄 开始回滚课程操作: ID={course_id}, 类型={operation_type}") + + if operation_type == "delete": + return await self.soft_delete_rollback("courses", course_id) + else: + logger.warning("⚠️ 课程更新操作回滚需要手动处理") + return False + + async def rollback_exam_operation(self, exam_id: int) -> bool: + """回滚考试操作""" + logger.info(f"🔄 开始回滚考试操作: ID={exam_id}") + + cursor = await self.connection.cursor() + + try: + await cursor.execute("START TRANSACTION") + + # 删除考试结果详情 + await cursor.execute("DELETE FROM exam_results WHERE exam_id = %s", (exam_id,)) + + # 删除考试记录 + await cursor.execute("DELETE FROM exams WHERE id = %s", (exam_id,)) + + await cursor.execute("COMMIT") + logger.info(f"✅ 已回滚考试记录 ID: {exam_id}") + return True + + except Exception as e: + await cursor.execute("ROLLBACK") + logger.error(f"❌ 考试回滚失败: {e}") + return False + finally: + await cursor.close() + + async def rollback_position_operation(self, position_id: int, operation_type: str = "update") -> bool: + """回滚岗位操作""" + logger.info(f"🔄 开始回滚岗位操作: ID={position_id}, 类型={operation_type}") + + if operation_type == "delete": + return await self.soft_delete_rollback("positions", position_id) + else: + logger.warning("⚠️ 岗位更新操作回滚需要手动处理") + return False + + async def list_recent_changes(self, hours: int = 24): + """列出最近的变更""" + logger.info(f"📋 最近 {hours} 小时的数据变更:") + + operations = await self.get_recent_operations(hours) + + if not operations: + logger.info("📝 没有找到最近的变更记录") + return + + # 按类型分组显示 + by_type = {} + for op in operations: + op_type = op.get('type', 'unknown') + if op_type not in by_type: + by_type[op_type] = [] + by_type[op_type].append(op) + + for op_type, ops in by_type.items(): + print(f"\n🔸 {op_type.upper()} 类型变更 ({len(ops)} 条):") + print("-" * 60) + + for op in ops[:10]: # 只显示前10条 + if op_type == 'user': + print(f" ID: {op['id']}, 用户名: {op['username']}, 姓名: {op['full_name']}, 时间: {op['updated_at']}") + elif op_type == 'course': + print(f" ID: {op['id']}, 课程: {op['name']}, 状态: {op['status']}, 时间: {op['updated_at']}") + elif op_type == 'exam': + print(f" ID: {op['id']}, 考试: {op['exam_name']}, 分数: {op['score']}, 时间: {op['updated_at']}") + elif op_type == 'position': + print(f" ID: {op['id']}, 岗位: {op['name']}, 状态: {op['status']}, 时间: {op['updated_at']}") + + if len(ops) > 10: + print(f" ... 还有 {len(ops) - 10} 条记录") + +async def main(): + """主函数""" + parser = argparse.ArgumentParser(description='考培练系统 - 专用数据库回滚工具') + parser.add_argument('--list', action='store_true', help='列出最近的变更') + parser.add_argument('--hours', type=int, default=24, help='查看最近N小时的变更 (默认24小时)') + parser.add_argument('--rollback-user', type=int, help='回滚用户操作 (用户ID)') + parser.add_argument('--rollback-course', type=int, help='回滚课程操作 (课程ID)') + parser.add_argument('--rollback-exam', type=int, help='回滚考试操作 (考试ID)') + parser.add_argument('--rollback-position', type=int, help='回滚岗位操作 (岗位ID)') + parser.add_argument('--operation-type', choices=['update', 'delete'], default='update', help='操作类型') + parser.add_argument('--execute', action='store_true', help='实际执行回滚') + + args = parser.parse_args() + + tool = KaopeilianRollbackTool() + + try: + await tool.connect() + + if args.list: + await tool.list_recent_changes(args.hours) + + elif args.rollback_user: + if args.execute: + success = await tool.rollback_user_operation(args.rollback_user, args.operation_type) + if success: + logger.info("✅ 用户回滚完成") + else: + logger.error("❌ 用户回滚失败") + else: + logger.info(f"🔍 模拟回滚用户 ID: {args.rollback_user}, 类型: {args.operation_type}") + logger.info("使用 --execute 参数实际执行") + + elif args.rollback_course: + if args.execute: + success = await tool.rollback_course_operation(args.rollback_course, args.operation_type) + if success: + logger.info("✅ 课程回滚完成") + else: + logger.error("❌ 课程回滚失败") + else: + logger.info(f"🔍 模拟回滚课程 ID: {args.rollback_course}, 类型: {args.operation_type}") + logger.info("使用 --execute 参数实际执行") + + elif args.rollback_exam: + if args.execute: + success = await tool.rollback_exam_operation(args.rollback_exam) + if success: + logger.info("✅ 考试回滚完成") + else: + logger.error("❌ 考试回滚失败") + else: + logger.info(f"🔍 模拟回滚考试 ID: {args.rollback_exam}") + logger.info("使用 --execute 参数实际执行") + + elif args.rollback_position: + if args.execute: + success = await tool.rollback_position_operation(args.rollback_position, args.operation_type) + if success: + logger.info("✅ 岗位回滚完成") + else: + logger.error("❌ 岗位回滚失败") + else: + logger.info(f"🔍 模拟回滚岗位 ID: {args.rollback_position}, 类型: {args.operation_type}") + logger.info("使用 --execute 参数实际执行") + + else: + parser.print_help() + + except Exception as e: + logger.error(f"❌ 程序执行异常: {e}") + finally: + await tool.close() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/migrate_env_to_db.py b/backend/scripts/migrate_env_to_db.py new file mode 100644 index 0000000..87a2a11 --- /dev/null +++ b/backend/scripts/migrate_env_to_db.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +""" +租户配置迁移脚本 + +功能:将各租户的 .env 文件配置迁移到 kaopeilian_admin 数据库 + +使用方法: + python scripts/migrate_env_to_db.py + +说明: + 1. 读取各租户的 .env 文件 + 2. 创建租户记录 + 3. 将配置写入 tenant_configs 表 + 4. 保留原 .env 文件作为备份 +""" + +import os +import sys +import re +import pymysql +from datetime import datetime +from typing import Dict, List, Tuple, Optional + +# 添加项目根目录到路径 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +# ============================================ +# 配置 +# ============================================ + +# 管理库连接配置 +ADMIN_DB_CONFIG = { + "host": os.getenv("ADMIN_DB_HOST", "120.79.247.16"), + "port": int(os.getenv("ADMIN_DB_PORT", "3309")), + "user": os.getenv("ADMIN_DB_USER", "root"), + "password": os.getenv("ADMIN_DB_PASSWORD", "ProdMySQL2025!@#"), + "db": os.getenv("ADMIN_DB_NAME", "kaopeilian_admin"), + "charset": "utf8mb4", +} + +# 租户配置 +TENANTS = [ + { + "code": "demo", + "name": "演示版", + "display_name": "考培练系统-演示版", + "domain": "aiedu.ireborn.com.cn", + "env_file": "/root/aiedu/kaopeilian-backend/.env.production", + "industry": "medical_beauty", + }, + { + "code": "hua", + "name": "华尔倍丽", + "display_name": "华尔倍丽-考培练系统", + "domain": "hua.ireborn.com.cn", + "env_file": "/root/aiedu/kaopeilian-backend/.env.hua", + "industry": "medical_beauty", + }, + { + "code": "yy", + "name": "杨扬宠物", + "display_name": "杨扬宠物-考培练系统", + "domain": "yy.ireborn.com.cn", + "env_file": "/root/aiedu/kaopeilian-backend/.env.yy", + "industry": "pet", + }, + { + "code": "hl", + "name": "武汉禾丽", + "display_name": "武汉禾丽-考培练系统", + "domain": "hl.ireborn.com.cn", + "env_file": "/root/aiedu/kaopeilian-backend/.env.hl", + "industry": "medical_beauty", + }, + { + "code": "xy", + "name": "芯颜定制", + "display_name": "芯颜定制-考培练系统", + "domain": "xy.ireborn.com.cn", + "env_file": "/root/aiedu/kaopeilian-backend/.env.xy", + "industry": "medical_beauty", + }, + { + "code": "fw", + "name": "飞沃", + "display_name": "飞沃-考培练系统", + "domain": "fw.ireborn.com.cn", + "env_file": "/root/aiedu/kaopeilian-backend/.env.fw", + "industry": "medical_beauty", + }, + { + "code": "ex", + "name": "恩喜成都总院", + "display_name": "恩喜成都总院-考培练系统", + "domain": "ex.ireborn.com.cn", + "env_file": "/root/aiedu/kaopeilian-backend/.env.ex", + "industry": "medical_beauty", + }, + { + "code": "kpl", + "name": "瑞小美", + "display_name": "瑞小美-考培练系统", + "domain": "kpl.ireborn.com.cn", + "env_file": "/root/aiedu/kaopeilian-backend/.env.kpl", + "industry": "medical_beauty", + }, +] + +# 配置键到分组的映射 +CONFIG_MAPPING = { + # 数据库配置 + "MYSQL_HOST": ("database", "string", False), + "MYSQL_PORT": ("database", "int", False), + "MYSQL_USER": ("database", "string", False), + "MYSQL_PASSWORD": ("database", "string", True), + "MYSQL_DATABASE": ("database", "string", False), + # Redis配置 + "REDIS_HOST": ("redis", "string", False), + "REDIS_PORT": ("redis", "int", False), + "REDIS_DB": ("redis", "int", False), + "REDIS_URL": ("redis", "string", False), + # 安全配置 + "SECRET_KEY": ("security", "string", True), + "CORS_ORIGINS": ("security", "json", False), + # Dify配置 + "DIFY_API_KEY": ("dify", "string", True), + "DIFY_EXAM_GENERATOR_API_KEY": ("dify", "string", True), + "DIFY_PRACTICE_API_KEY": ("dify", "string", True), + "DIFY_COURSE_CHAT_API_KEY": ("dify", "string", True), + "DIFY_YANJI_ANALYSIS_API_KEY": ("dify", "string", True), + # Coze配置 + "COZE_PRACTICE_BOT_ID": ("coze", "string", False), + "COZE_BROADCAST_WORKFLOW_ID": ("coze", "string", False), + "COZE_BROADCAST_SPACE_ID": ("coze", "string", False), + "COZE_BROADCAST_BOT_ID": ("coze", "string", False), + "COZE_OAUTH_CLIENT_ID": ("coze", "string", False), + "COZE_OAUTH_PUBLIC_KEY_ID": ("coze", "string", False), + "COZE_OAUTH_PRIVATE_KEY_PATH": ("coze", "string", False), + # AI配置 + "AI_PRIMARY_API_KEY": ("ai", "string", True), + "AI_PRIMARY_BASE_URL": ("ai", "string", False), + "AI_FALLBACK_API_KEY": ("ai", "string", True), + "AI_FALLBACK_BASE_URL": ("ai", "string", False), + "AI_DEFAULT_MODEL": ("ai", "string", False), + "AI_TIMEOUT": ("ai", "int", False), + # 言迹配置 + "YANJI_CLIENT_ID": ("yanji", "string", False), + "YANJI_CLIENT_SECRET": ("yanji", "string", True), + "YANJI_TENANT_ID": ("yanji", "string", False), + "YANJI_ESTATE_ID": ("yanji", "string", False), + # 其他配置 + "APP_NAME": ("basic", "string", False), + "PUBLIC_DOMAIN": ("basic", "string", False), +} + + +def parse_env_file(filepath: str) -> Dict[str, str]: + """解析 .env 文件""" + config = {} + + if not os.path.exists(filepath): + print(f" 警告: 文件不存在 {filepath}") + return config + + with open(filepath, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + # 跳过注释和空行 + if not line or line.startswith('#'): + continue + + # 解析 KEY=VALUE + if '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + + # 去除引号 + if (value.startswith('"') and value.endswith('"')) or \ + (value.startswith("'") and value.endswith("'")): + value = value[1:-1] + + config[key] = value + + return config + + +def create_tenant(cursor, tenant: Dict) -> int: + """创建租户记录,返回租户ID""" + # 检查是否已存在 + cursor.execute( + "SELECT id FROM tenants WHERE code = %s", + (tenant["code"],) + ) + row = cursor.fetchone() + + if row: + print(f" 租户已存在,ID: {row['id']}") + return row["id"] + + # 创建新租户 + cursor.execute( + """ + INSERT INTO tenants (code, name, display_name, domain, industry, status, created_by) + VALUES (%s, %s, %s, %s, %s, 'active', 1) + """, + (tenant["code"], tenant["name"], tenant["display_name"], tenant["domain"], tenant["industry"]) + ) + + tenant_id = cursor.lastrowid + print(f" 创建租户成功,ID: {tenant_id}") + return tenant_id + + +def migrate_config(cursor, tenant_id: int, config: Dict[str, str]) -> Tuple[int, int]: + """迁移配置到数据库""" + inserted = 0 + updated = 0 + + for key, value in config.items(): + if key not in CONFIG_MAPPING: + continue + + config_group, value_type, is_secret = CONFIG_MAPPING[key] + + # 检查是否已存在 + cursor.execute( + """ + SELECT id FROM tenant_configs + WHERE tenant_id = %s AND config_group = %s AND config_key = %s + """, + (tenant_id, config_group, key) + ) + row = cursor.fetchone() + + if row: + # 更新 + cursor.execute( + """ + UPDATE tenant_configs + SET config_value = %s, value_type = %s, is_encrypted = %s, updated_at = NOW() + WHERE id = %s + """, + (value, value_type, is_secret, row["id"]) + ) + updated += 1 + else: + # 插入 + cursor.execute( + """ + INSERT INTO tenant_configs + (tenant_id, config_group, config_key, config_value, value_type, is_encrypted) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (tenant_id, config_group, key, value, value_type, is_secret) + ) + inserted += 1 + + return inserted, updated + + +def main(): + """主函数""" + print("=" * 60) + print("租户配置迁移脚本") + print("=" * 60) + print(f"\n目标数据库: {ADMIN_DB_CONFIG['host']}:{ADMIN_DB_CONFIG['port']}/{ADMIN_DB_CONFIG['db']}") + print(f"待迁移租户: {len(TENANTS)} 个\n") + + # 连接数据库 + conn = pymysql.connect(**ADMIN_DB_CONFIG, cursorclass=pymysql.cursors.DictCursor) + + try: + with conn.cursor() as cursor: + total_inserted = 0 + total_updated = 0 + + for tenant in TENANTS: + print(f"\n处理租户: {tenant['name']} ({tenant['code']})") + print(f" 环境文件: {tenant['env_file']}") + + # 解析 .env 文件 + config = parse_env_file(tenant['env_file']) + print(f" 读取配置: {len(config)} 项") + + # 创建租户 + tenant_id = create_tenant(cursor, tenant) + + # 迁移配置 + if config: + inserted, updated = migrate_config(cursor, tenant_id, config) + print(f" 迁移结果: 新增 {inserted} 项, 更新 {updated} 项") + total_inserted += inserted + total_updated += updated + else: + print(" 跳过迁移(无配置)") + + # 提交事务 + conn.commit() + + print("\n" + "=" * 60) + print("迁移完成!") + print(f"总计: 新增 {total_inserted} 项, 更新 {total_updated} 项") + print("=" * 60) + + except Exception as e: + conn.rollback() + print(f"\n错误: {e}") + raise + finally: + conn.close() + + +if __name__ == "__main__": + main() + diff --git a/backend/scripts/migrate_prompts_to_db.py b/backend/scripts/migrate_prompts_to_db.py new file mode 100644 index 0000000..7b1b0b1 --- /dev/null +++ b/backend/scripts/migrate_prompts_to_db.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +""" +AI 提示词迁移脚本 + +功能:将代码中的 AI 提示词迁移到数据库 + +使用方法: + python scripts/migrate_prompts_to_db.py +""" + +import os +import sys +import json +import pymysql + +# 添加项目根目录到路径 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +# ============================================ +# 配置 +# ============================================ + +ADMIN_DB_CONFIG = { + "host": os.getenv("ADMIN_DB_HOST", "120.79.247.16"), + "port": int(os.getenv("ADMIN_DB_PORT", "3309")), + "user": os.getenv("ADMIN_DB_USER", "root"), + "password": os.getenv("ADMIN_DB_PASSWORD", "ProdMySQL2025!@#"), + "db": os.getenv("ADMIN_DB_NAME", "kaopeilian_admin"), + "charset": "utf8mb4", +} + + +# ============================================ +# 提示词定义 +# ============================================ + +PROMPTS = [ + { + "code": "knowledge_analysis", + "name": "知识点分析", + "description": "从课程资料中提取和分析知识点,支持PDF/Word/文本等格式", + "module": "course", + "system_prompt": """# 角色 +你是一个文件拆解高手,擅长将用户提交的内容进行精准拆分,拆分后的内容做个简单的优化处理使其更具可读性,但要尽量使用原文的原词原句。 + +## 技能 +### 技能 1: 内容拆分 +1. 当用户提交内容后,拆分为多段。 +2. 对拆分后的内容做简单优化,使其更具可读性,比如去掉奇怪符号(如换行符、乱码),若语句不通顺,或格式原因导致错位,则重新表达。用户可能会提交录音转文字的内容,因此可能是有错字的,注意修复这些小瑕疵。 +3. 优化过程中,尽量使用原文的原词原句,特别是话术类,必须保持原有的句式、保持原词原句,而不是重构。 +4. 注意是拆分而不是重写,不需要润色,尽量不做任何处理。 +5. 输出到 content。 + +### 技能 2: 为每一个选段概括一个标题 +1. 为每个拆分出来的选段概括一个标题,并输出到 title。 + +### 技能 3: 为每一个选段说明与主题的关联 +1. 详细说明这一段与全文核心主题的关联,并输出到 topic_relation。 + +### 技能 4: 为每一个选段打上一个类型标签 +1. 用户提交的内容很有可能是一个课程、一篇讲义、一个产品的说明书,通常是用户希望他公司的员工或高管学习的知识。 +2. 用户通常是医疗美容机构或轻医美、生活美容连锁品牌。 +3. 你要为每个选段打上一个知识类型的标签,最好是这几个类型中的一个:"理论知识", "诊断设计", "操作步骤", "沟通话术", "案例分析", "注意事项", "技巧方法", "客诉处理"。当然你也可以为这个选段匹配一个更适合的。 + +## 输出要求(严格按要求输出) +请直接输出一个纯净的 JSON 数组(Array),不要包含 Markdown 标记(如 ```json),也不要包含任何解释性文字。格式如下: + +[ + { + "title": "知识点标题", + "content": "知识点内容", + "topic_relation": "知识点与主题的关系", + "type": "知识点类型" + } +] + +## 限制 +- 仅围绕用户提交的内容进行拆分和关联标注,不涉及其他无关内容。 +- 拆分后的内容必须最大程度保持与原文一致。 +- 关联说明需清晰合理。 +- 不论如何,不要拆分超过 20 段!""", + "user_prompt_template": """课程主题:{course_name} + +## 用户提交的内容: + +{content} + +## 注意 + +- 以json的格式输出 +- 不论如何,不要拆分超过20 段!""", + "variables": ["course_name", "content"], + "model_recommendation": "gemini-3-flash-preview", + "max_tokens": 8192, + "temperature": 0.7, + }, + { + "code": "exam_generator", + "name": "试题生成器", + "description": "根据知识点自动生成考试题目,支持单选、多选、判断、填空、问答题型", + "module": "exam", + "system_prompt": """# 角色 +你是一个专业的试题生成器,能够根据给定的知识点内容生成高质量的考试题目。 + +## 技能 +### 技能 1: 生成单选题 +- 每道题有4个选项(A、B、C、D) +- 只有一个正确答案 +- 选项设计要有迷惑性但不能有歧义 + +### 技能 2: 生成多选题 +- 每道题有4个选项(A、B、C、D) +- 有2-4个正确答案 +- 考察综合理解能力 + +### 技能 3: 生成判断题 +- 陈述一个观点,判断对错 +- 答案为"对"或"错" + +### 技能 4: 生成填空题 +- 在关键位置设置空白 +- 答案明确唯一 + +### 技能 5: 生成问答题 +- 开放性问题 +- 需要组织语言回答 + +## 输出格式 +请直接输出 JSON 数组,格式如下: + +[ + { + "question_type": "single_choice", + "title": "题目内容", + "options": ["A. 选项1", "B. 选项2", "C. 选项3", "D. 选项4"], + "correct_answer": "A", + "explanation": "答案解析", + "difficulty": "easy/medium/hard" + } +] + +## 限制 +- 题目必须与给定知识点相关 +- 难度要适中,兼顾基础和提升 +- 表述清晰准确,无歧义""", + "user_prompt_template": """请根据以下知识点内容生成试题: + +{content} + +要求: +- 单选题 {single_choice_count} 道 +- 多选题 {multiple_choice_count} 道 +- 判断题 {true_false_count} 道 +- 填空题 {fill_blank_count} 道 +- 问答题 {essay_count} 道 + +难度系数:{difficulty_level}(1-5,1最简单) + +请以 JSON 格式输出题目列表。""", + "variables": ["content", "single_choice_count", "multiple_choice_count", "true_false_count", "fill_blank_count", "essay_count", "difficulty_level"], + "model_recommendation": "gemini-3-flash-preview", + "max_tokens": 8192, + "temperature": 0.7, + }, + { + "code": "course_chat", + "name": "课程对话", + "description": "与课程知识点进行智能对话,回答学员问题", + "module": "course", + "system_prompt": """# 角色 +你是一位专业的课程助教,负责回答学员关于课程内容的问题。 + +## 职责 +1. 准确回答与课程相关的问题 +2. 用通俗易懂的语言解释复杂概念 +3. 提供实用的学习建议 +4. 关联相关知识点帮助理解 + +## 原则 +- 回答要准确、专业 +- 语言要友好、易懂 +- 适当举例说明 +- 如果问题超出课程范围,礼貌说明 + +## 回复格式 +- 保持简洁明了 +- 可以使用列表、分点等结构化方式 +- 重要内容可以加粗强调""", + "user_prompt_template": """课程名称:{course_name} + +课程知识点: +{knowledge_content} + +学员问题:{query} + +请根据课程知识点回答学员的问题。""", + "variables": ["course_name", "knowledge_content", "query"], + "model_recommendation": "gemini-3-flash-preview", + "max_tokens": 2048, + "temperature": 0.7, + }, + { + "code": "ability_analysis", + "name": "能力分析", + "description": "基于智能工牌对话数据分析员工能力并推荐课程", + "module": "ability", + "system_prompt": """# 角色 +你是一位专业的人才发展顾问,负责根据员工的对话记录分析其能力并推荐提升课程。 + +## 分析维度 +1. **专业知识**:产品知识、行业知识、技术能力 +2. **沟通能力**:表达清晰度、倾听能力、情绪管理 +3. **销售技巧**:需求挖掘、异议处理、促成能力 +4. **服务意识**:客户关怀、问题解决、满意度维护 + +## 输出格式 +请输出 JSON 格式的分析结果: + +{ + "overall_score": 75, + "dimensions": [ + {"name": "专业知识", "score": 80, "comment": "评价"}, + {"name": "沟通能力", "score": 70, "comment": "评价"} + ], + "strengths": ["优势1", "优势2"], + "weaknesses": ["待提升1", "待提升2"], + "recommendations": [ + {"course_name": "推荐课程", "reason": "推荐理由"} + ] +}""", + "user_prompt_template": """员工信息: +- 姓名:{employee_name} +- 岗位:{position} + +对话记录: +{conversation_records} + +可选课程列表: +{available_courses} + +请分析该员工的能力并推荐适合的课程。""", + "variables": ["employee_name", "position", "conversation_records", "available_courses"], + "model_recommendation": "gemini-3-flash-preview", + "max_tokens": 4096, + "temperature": 0.7, + }, + { + "code": "practice_scene", + "name": "陪练场景生成", + "description": "根据课程内容生成陪练场景和对话", + "module": "practice", + "system_prompt": """# 角色 +你是一位专业的培训场景设计师,负责为员工陪练设计模拟对话场景。 + +## 职责 +1. 根据课程内容设计真实场景 +2. 模拟客户各种可能的提问和反应 +3. 设计合理的对话流程 +4. 提供评估标准 + +## 场景类型 +- 电话销售场景 +- 面对面咨询场景 +- 客户投诉处理场景 +- 售后服务场景 +- 产品介绍场景 + +## 输出格式 +请输出 JSON 格式: + +{ + "scene_name": "场景名称", + "scene_type": "场景类型", + "background": "场景背景", + "customer_profile": "客户画像", + "dialogue_flow": [ + {"role": "customer", "content": "客户话术"}, + {"role": "employee", "content": "员工话术"} + ], + "evaluation_points": ["评估要点1", "评估要点2"] +}""", + "user_prompt_template": """课程名称:{course_name} + +课程知识点: +{knowledge_content} + +请为这些知识点设计一个{scene_type}的陪练场景。 + +难度:{difficulty} +预计时长:{duration}分钟""", + "variables": ["course_name", "knowledge_content", "scene_type", "difficulty", "duration"], + "model_recommendation": "gemini-3-flash-preview", + "max_tokens": 4096, + "temperature": 0.8, + }, +] + + +def main(): + """主函数""" + print("=" * 60) + print("AI 提示词迁移脚本") + print("=" * 60) + print(f"\n目标数据库: {ADMIN_DB_CONFIG['host']}:{ADMIN_DB_CONFIG['port']}/{ADMIN_DB_CONFIG['db']}") + print(f"待迁移提示词: {len(PROMPTS)} 个\n") + + conn = pymysql.connect(**ADMIN_DB_CONFIG, cursorclass=pymysql.cursors.DictCursor) + + try: + with conn.cursor() as cursor: + inserted = 0 + updated = 0 + + for prompt in PROMPTS: + print(f"处理提示词: {prompt['name']} ({prompt['code']})") + + # 检查是否已存在 + cursor.execute( + "SELECT id, version FROM ai_prompts WHERE code = %s", + (prompt["code"],) + ) + existing = cursor.fetchone() + + if existing: + # 更新 + cursor.execute( + """ + UPDATE ai_prompts SET + name = %s, + description = %s, + module = %s, + system_prompt = %s, + user_prompt_template = %s, + variables = %s, + model_recommendation = %s, + max_tokens = %s, + temperature = %s, + updated_by = 1 + WHERE id = %s + """, + (prompt["name"], prompt["description"], prompt["module"], + prompt["system_prompt"], prompt["user_prompt_template"], + json.dumps(prompt["variables"]), + prompt["model_recommendation"], prompt["max_tokens"], + prompt["temperature"], existing["id"]) + ) + print(f" 更新成功,ID: {existing['id']}") + updated += 1 + else: + # 插入 + cursor.execute( + """ + INSERT INTO ai_prompts + (code, name, description, module, system_prompt, user_prompt_template, + variables, model_recommendation, max_tokens, temperature, is_system, created_by) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, TRUE, 1) + """, + (prompt["code"], prompt["name"], prompt["description"], prompt["module"], + prompt["system_prompt"], prompt["user_prompt_template"], + json.dumps(prompt["variables"]), + prompt["model_recommendation"], prompt["max_tokens"], prompt["temperature"]) + ) + print(f" 插入成功,ID: {cursor.lastrowid}") + inserted += 1 + + conn.commit() + + print("\n" + "=" * 60) + print("迁移完成!") + print(f"新增: {inserted} 个, 更新: {updated} 个") + print("=" * 60) + + except Exception as e: + conn.rollback() + print(f"\n错误: {e}") + raise + finally: + conn.close() + + +if __name__ == "__main__": + main() + diff --git a/backend/scripts/mock_data_beauty.sql b/backend/scripts/mock_data_beauty.sql new file mode 100644 index 0000000..35e70ee --- /dev/null +++ b/backend/scripts/mock_data_beauty.sql @@ -0,0 +1,329 @@ +-- ============================================ +-- 轻医美+生活美容连锁机构模拟数据 +-- 版本:1.0.0 +-- 创建时间:2025-01-20 +-- ============================================ + +USE `kaopeilian`; + +-- 清理已有测试数据(保留初始的superadmin、admin、testuser) +DELETE FROM training_reports WHERE id > 0; +DELETE FROM training_messages WHERE id > 0; +DELETE FROM training_sessions WHERE id > 0; +DELETE FROM exam_results WHERE id > 0; +DELETE FROM exams WHERE id > 0; +DELETE FROM questions WHERE course_id > 4; +DELETE FROM knowledge_points WHERE course_id > 4; +DELETE FROM course_materials WHERE course_id > 4; +DELETE FROM user_teams WHERE user_id > 3; +DELETE FROM teams WHERE id > 3; +DELETE FROM courses WHERE id > 4; +DELETE FROM training_scenes WHERE id > 3; +DELETE FROM growth_paths WHERE id > 2; +DELETE FROM users WHERE id > 3; + +-- ============================================ +-- 一、用户数据(轻医美+生活美容机构人员) +-- ============================================ + +-- 管理层 +INSERT INTO users (username, email, phone, hashed_password, full_name, role, is_active, is_verified, bio) VALUES +('zhangyun', 'zhangyun@beauty.com', '13800138001', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '张云', 'admin', TRUE, TRUE, '集团总经理,20年美容行业经验'), +('lixiaoli', 'lixiaoli@beauty.com', '13800138002', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '李晓丽', 'manager', TRUE, TRUE, '华东区域经理,负责上海、江苏、浙江区域'), +('wangmei', 'wangmei@beauty.com', '13800138003', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '王梅', 'manager', TRUE, TRUE, '华南区域经理,负责广东、福建区域'), + +-- 医美部门 +('drchen', 'drchen@beauty.com', '13800138004', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '陈医生', 'manager', TRUE, TRUE, '医美技术总监,皮肤科主治医师'), +('liujing', 'liujing@beauty.com', '13800138005', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '刘静', 'trainee', TRUE, TRUE, '资深医美顾问,5年从业经验'), +('zhangmin', 'zhangmin@beauty.com', '13800138006', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '张敏', 'trainee', TRUE, TRUE, '医美技师,擅长光电项目操作'), +('sunhui', 'sunhui@beauty.com', '13800138007', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '孙慧', 'trainee', TRUE, TRUE, '医美技师,专注水光针注射'), + +-- 美容部门 +('zhaoxue', 'zhaoxue@beauty.com', '13800138008', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '赵雪', 'manager', TRUE, TRUE, '美容部主管,国家高级美容师'), +('yangli', 'yangli@beauty.com', '13800138009', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '杨丽', 'trainee', TRUE, TRUE, '资深美容顾问,擅长皮肤管理方案设计'), +('huangting', 'huangting@beauty.com', '13800138010', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '黄婷', 'trainee', TRUE, TRUE, '美容师,专注面部护理'), +('linwei', 'linwei@beauty.com', '13800138011', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '林薇', 'trainee', TRUE, TRUE, '美容师,擅长身体SPA'), +('chenyu', 'chenyu@beauty.com', '13800138012', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '陈雨', 'trainee', TRUE, TRUE, '美容师,专注问题性皮肤护理'), + +-- 客服部门 +('wujuan', 'wujuan@beauty.com', '13800138013', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '吴娟', 'manager', TRUE, TRUE, '客服部经理,负责客户关系管理'), +('zhoufang', 'zhoufang@beauty.com', '13800138014', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '周芳', 'trainee', TRUE, TRUE, '客户经理,负责VIP客户维护'), +('xujing', 'xujing@beauty.com', '13800138015', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '徐静', 'trainee', TRUE, TRUE, '前台接待,形象气质佳'), + +-- 各分店人员 +('liuyan', 'liuyan@beauty.com', '13800138016', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '刘燕', 'manager', TRUE, TRUE, '静安店店长'), +('zhangna', 'zhangna@beauty.com', '13800138017', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '张娜', 'trainee', TRUE, TRUE, '静安店美容顾问'), +('wangxin', 'wangxin@beauty.com', '13800138018', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '王欣', 'trainee', TRUE, TRUE, '静安店美容师'), +('lihong', 'lihong@beauty.com', '13800138019', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '李红', 'manager', TRUE, TRUE, '徐汇店店长'), +('zhaoli', 'zhaoli@beauty.com', '13800138020', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '赵丽', 'trainee', TRUE, TRUE, '徐汇店医美顾问'); + +-- ============================================ +-- 二、团队数据(机构组织架构) +-- ============================================ + +-- 公司总部 +INSERT INTO teams (name, code, description, team_type, leader_id) VALUES +('集团总部', 'HQ', '轻医美生活美容集团总部', 'department', 4), +('医美事业部', 'MEDICAL', '负责所有医美项目的运营和技术支持', 'department', 7), +('美容事业部', 'BEAUTY', '负责传统美容项目的运营和培训', 'department', 11), +('客服中心', 'SERVICE', '负责客户服务和关系维护', 'department', 16), + +-- 区域团队 +('华东区域', 'EAST', '负责上海、江苏、浙江区域运营', 'department', 5), +('华南区域', 'SOUTH', '负责广东、福建区域运营', 'department', 6), + +-- 门店团队 +('静安旗舰店', 'JINGAN', '上海静安区旗舰店', 'department', 19), +('徐汇精品店', 'XUHUI', '上海徐汇区精品店', 'department', 22), + +-- 专项小组 +('医美技术委员会', 'MED_TECH', '负责医美技术标准制定和培训', 'study_group', 7), +('美容技术研究组', 'BEAUTY_RES', '负责美容新技术研究和推广', 'study_group', 11), +('服务标准化小组', 'SERVICE_STD', '负责服务流程标准化', 'study_group', 16); + +-- ============================================ +-- 三、用户团队关联 +-- ============================================ + +INSERT INTO user_teams (user_id, team_id, role) VALUES +-- 总经理管理总部 +(4, 4, 'leader'), +-- 区域经理 +(5, 8, 'leader'), +(6, 9, 'leader'), +-- 部门负责人 +(7, 5, 'leader'), +(11, 6, 'leader'), +(16, 7, 'leader'), +-- 医美部门成员 +(7, 12, 'leader'), +(8, 5, 'member'), +(9, 5, 'member'), +(10, 5, 'member'), +-- 美容部门成员 +(11, 13, 'leader'), +(12, 6, 'member'), +(13, 6, 'member'), +(14, 6, 'member'), +(15, 6, 'member'), +-- 客服部门成员 +(17, 7, 'member'), +(18, 7, 'member'), +-- 门店团队 +(19, 10, 'leader'), +(20, 10, 'member'), +(21, 10, 'member'), +(22, 11, 'leader'), +(23, 11, 'member'); + +-- ============================================ +-- 四、课程数据(美容行业培训课程) +-- ============================================ + +DELETE FROM courses WHERE id > 4; + +INSERT INTO courses (name, description, category, status, cover_image, duration_hours, difficulty_level, tags, published_at, is_featured, created_by) VALUES +-- 基础课程 +('美容基础理论', '美容行业入门必修课,包含皮肤生理学、美容营养学等基础知识', 'general', 'published', '/uploads/course/beauty_basic.jpg', 20, 1, '["美容基础", "皮肤管理", "理论知识"]', '2024-01-15 10:00:00', TRUE, 4), +('轻医美项目认知', '了解主流轻医美项目的原理、适应症和操作流程', 'technology', 'published', '/uploads/course/medical_beauty.jpg', 30, 2, '["轻医美", "水光针", "光电项目"]', '2024-01-20 14:00:00', TRUE, 7), +('销售心理学与话术', '掌握美容行业销售技巧,提升业绩转化能力', 'business', 'published', '/uploads/course/sales_skill.jpg', 15, 2, '["销售技巧", "客户心理", "话术"]', '2024-02-01 09:00:00', TRUE, 16), +('产品知识大全', '全面了解各类护肤品成分、功效和搭配方案', 'technology', 'published', '/uploads/course/product_knowledge.jpg', 25, 2, '["产品知识", "成分分析", "护肤"]', '2024-02-10 11:00:00', FALSE, 11), +('客户服务标准流程', '标准化服务流程培训,提升客户满意度', 'management', 'published', '/uploads/course/service_process.jpg', 12, 1, '["服务流程", "客户体验", "标准化"]', '2024-02-15 15:00:00', TRUE, 16), +('卫生安全操作规范', '医美和美容项目的卫生安全标准培训', 'general', 'published', '/uploads/course/safety_standard.jpg', 8, 1, '["安全规范", "卫生标准", "操作流程"]', '2024-02-20 10:00:00', TRUE, 7), + +-- 进阶课程 +('面部美容手法精修', '深入学习各种面部护理手法和技巧', 'technology', 'published', '/uploads/course/facial_technique.jpg', 40, 3, '["面部护理", "手法技巧", "实操"]', '2024-03-01 14:00:00', FALSE, 11), +('光电仪器操作认证', '各类美容仪器的原理和操作技巧培训', 'technology', 'published', '/uploads/course/device_operation.jpg', 35, 3, '["仪器操作", "光电美容", "认证培训"]', '2024-03-10 09:00:00', TRUE, 7), +('问题性皮肤管理', '针对各类问题性皮肤的诊断和护理方案', 'technology', 'published', '/uploads/course/problem_skin.jpg', 30, 4, '["问题皮肤", "痘痘", "敏感肌", "诊断"]', '2024-03-15 11:00:00', FALSE, 11), +('VIP客户管理艺术', '高端客户的维护技巧和管理策略', 'management', 'published', '/uploads/course/vip_management.jpg', 18, 3, '["VIP管理", "客户维护", "高端服务"]', '2024-03-20 15:00:00', FALSE, 16), + +-- 管理课程 +('美容店务管理', '美容门店的日常运营和管理技巧', 'management', 'published', '/uploads/course/store_management.jpg', 20, 3, '["店务管理", "运营", "团队建设"]', '2024-04-01 10:00:00', FALSE, 19), +('团队激励与培养', '如何打造高效的美容服务团队', 'management', 'published', '/uploads/course/team_building.jpg', 15, 3, '["团队管理", "员工激励", "人才培养"]', '2024-04-10 14:00:00', FALSE, 4); + +-- ============================================ +-- 五、课程资料 +-- ============================================ + +-- 为新课程添加资料 +INSERT INTO course_materials (course_id, name, description, file_url, file_type, file_size, sort_order) VALUES +-- 美容基础理论 +(5, '皮肤生理学基础.pdf', '详细介绍皮肤结构和生理功能', '/uploads/materials/skin_physiology.pdf', 'pdf', 5242880, 1), +(5, '美容营养学.pdf', '营养与美容的关系', '/uploads/materials/beauty_nutrition.pdf', 'pdf', 3145728, 2), +(5, '基础理论视频课程', '完整的理论知识讲解', '/uploads/materials/theory_video.mp4', 'mp4', 209715200, 3), + +-- 轻医美项目认知 +(6, '水光针操作指南.pdf', '水光针注射标准操作流程', '/uploads/materials/hydra_injection.pdf', 'pdf', 4194304, 1), +(6, '光电项目原理详解.ppt', '各类光电项目的原理和效果', '/uploads/materials/photoelectric.ppt', 'ppt', 10485760, 2), +(6, '操作演示视频', '真人操作演示教学', '/uploads/materials/operation_demo.mp4', 'mp4', 314572800, 3), + +-- 销售心理学与话术 +(7, '客户心理分析.pdf', '不同类型客户的心理特征', '/uploads/materials/customer_psychology.pdf', 'pdf', 2097152, 1), +(7, '标准话术手册.doc', '各场景标准话术模板', '/uploads/materials/sales_scripts.doc', 'doc', 1048576, 2), +(7, '销售实战案例.mp4', '优秀销售案例分享', '/uploads/materials/sales_cases.mp4', 'mp4', 157286400, 3); + +-- ============================================ +-- 六、知识点体系 +-- ============================================ + +-- 美容基础理论知识点 +INSERT INTO knowledge_points (course_id, name, description, parent_id, level, path, sort_order, weight, is_required, estimated_hours) VALUES +(5, '皮肤生理学', '了解皮肤的结构和功能', NULL, 1, '1', 1, 2.0, TRUE, 5), +(5, '皮肤结构', '表皮、真皮、皮下组织的构成', 25, 2, '1.1', 1, 1.5, TRUE, 2), +(5, '皮肤类型判断', '干性、油性、混合性、敏感性皮肤的特征', 25, 2, '1.2', 2, 1.5, TRUE, 1.5), +(5, '美容营养学', '营养素对皮肤的影响', NULL, 1, '2', 2, 1.5, TRUE, 4), +(5, '维生素与美容', '各类维生素的美容功效', 28, 2, '2.1', 1, 1.0, TRUE, 2), + +-- 轻医美项目知识点 +(6, '注射类项目', '各类注射美容项目介绍', NULL, 1, '1', 1, 2.5, TRUE, 10), +(6, '水光针技术', '水光针的原理和操作要点', 30, 2, '1.1', 1, 2.0, TRUE, 4), +(6, '肉毒素注射', '肉毒素的作用原理和注射技巧', 30, 2, '1.2', 2, 2.0, TRUE, 3), +(6, '光电类项目', '光电美容技术详解', NULL, 1, '2', 2, 2.5, TRUE, 12), +(6, '激光美容', '各类激光的原理和应用', 33, 2, '2.1', 1, 2.0, TRUE, 5); + +-- ============================================ +-- 七、考试题目(美容行业相关) +-- ============================================ + +-- 美容基础理论题目 +INSERT INTO questions (course_id, question_type, title, content, options, correct_answer, explanation, score, difficulty, tags) VALUES +(5, 'single_choice', '皮肤最外层的结构是?', NULL, '{"A": "真皮层", "B": "表皮层", "C": "皮下组织", "D": "基底层"}', 'B', '皮肤由外到内分为表皮层、真皮层和皮下组织', 10.0, 'easy', '["皮肤结构", "基础知识"]'), +(5, 'single_choice', '以下哪种维生素被称为"美容维生素"?', NULL, '{"A": "维生素A", "B": "维生素B", "C": "维生素C", "D": "维生素D"}', 'C', '维生素C具有抗氧化、美白、促进胶原蛋白合成的作用', 10.0, 'easy', '["营养学", "维生素"]'), +(5, 'true_false', '油性皮肤不需要补水', NULL, NULL, 'false', '油性皮肤也需要补水,缺水会导致皮肤分泌更多油脂', 10.0, 'medium', '["皮肤类型", "护理误区"]'), +(5, 'fill_blank', '皮肤的pH值呈____性', NULL, NULL, '弱酸', '健康皮肤的pH值在4.5-6.5之间,呈弱酸性', 10.0, 'easy', '["皮肤生理"]'), + +-- 轻医美项目题目 +(6, 'single_choice', '水光针注射的层次是?', NULL, '{"A": "表皮层", "B": "真皮浅层", "C": "真皮深层", "D": "皮下组织"}', 'B', '水光针通常注射在真皮浅层,有利于营养成分的吸收', 10.0, 'medium', '["水光针", "注射技术"]'), +(6, 'single_choice', '肉毒素的作用原理是?', NULL, '{"A": "填充凹陷", "B": "阻断神经肌肉传导", "C": "刺激胶原再生", "D": "溶解脂肪"}', 'B', '肉毒素通过阻断神经肌肉传导,使肌肉放松,从而减少皱纹', 10.0, 'medium', '["肉毒素", "作用原理"]'), +(6, 'multiple_choice', '以下哪些是光子嫩肤的适应症?(多选)', NULL, '{"A": "色斑", "B": "毛孔粗大", "C": "红血丝", "D": "深度皱纹"}', '["A", "B", "C"]', '光子嫩肤适用于浅表性皮肤问题,对深度皱纹效果有限', 15.0, 'hard', '["光电项目", "适应症"]'), + +-- 销售技巧题目 +(7, 'single_choice', '面对犹豫不决的客户,最好的策略是?', NULL, '{"A": "立即降价", "B": "强调限时优惠", "C": "了解顾虑并解答", "D": "推荐更贵的项目"}', 'C', '了解客户的具体顾虑并针对性解答,建立信任更重要', 10.0, 'medium', '["销售技巧", "客户心理"]'), +(7, 'true_false', '销售时应该尽量推荐最贵的产品和项目', NULL, NULL, 'false', '应该根据客户的实际需求和消费能力推荐合适的产品', 10.0, 'easy', '["销售原则", "职业道德"]'), + +-- 产品知识题目 +(8, 'single_choice', '玻尿酸的主要功效是?', NULL, '{"A": "美白", "B": "保湿", "C": "去角质", "D": "控油"}', 'B', '玻尿酸是优秀的保湿成分,能吸收自身重量1000倍的水分', 10.0, 'easy', '["成分知识", "功效"]'), +(8, 'fill_blank', '视黄醇是维生素____的衍生物', NULL, NULL, 'A', '视黄醇(Retinol)是维生素A的衍生物,具有抗老功效', 10.0, 'medium', '["成分知识", "维生素"]'), + +-- 服务流程题目 +(9, 'single_choice', '客户到店后的第一步应该是?', NULL, '{"A": "推销产品", "B": "热情接待并了解需求", "C": "直接带去护理", "D": "要求办卡"}', 'B', '良好的接待和需求了解是优质服务的开始', 10.0, 'easy', '["服务流程", "接待"]'), +(9, 'true_false', '护理过程中可以接听私人电话', NULL, NULL, 'false', '护理过程中应专注于客户,避免接听私人电话', 10.0, 'easy', '["服务规范", "职业素养"]'), + +-- 安全规范题目 +(10, 'single_choice', '医美项目操作前必须进行的步骤是?', NULL, '{"A": "皮肤测试", "B": "签署知情同意书", "C": "拍照存档", "D": "以上都是"}', 'D', '医美项目需要做好充分的术前准备和风险告知', 10.0, 'medium', '["安全规范", "操作流程"]'), +(10, 'multiple_choice', '以下哪些属于无菌操作的要求?(多选)', NULL, '{"A": "戴无菌手套", "B": "使用一次性耗材", "C": "操作台面消毒", "D": "戴口罩"}', '["A", "B", "C", "D"]', '无菌操作需要全方位的防护和消毒措施', 15.0, 'medium', '["无菌操作", "卫生标准"]'); + +-- ============================================ +-- 八、AI陪练场景(美容行业场景) +-- ============================================ + +DELETE FROM training_scenes WHERE id > 3; + +INSERT INTO training_scenes (name, description, category, ai_config, prompt_template, evaluation_criteria, status, is_public, created_by) VALUES +('客户咨询接待', '模拟接待到店客户,了解需求并推荐合适的项目', '客户服务', +'{"bot_id": "beauty_consultant_bot", "temperature": 0.7}', +'你是一位专业的美容顾问,需要热情接待客户,了解客户的皮肤问题和需求,并推荐合适的护理项目。注意要专业、亲切、不过度推销。', +'{"professionalism": 30, "communication": 25, "needs_analysis": 25, "solution_matching": 20}', +'ACTIVE', TRUE, 16), + +('产品成分咨询', '解答客户关于护肤品成分和功效的问题', '专业知识', +'{"bot_id": "ingredient_expert_bot", "temperature": 0.6}', +'你是一位护肤品成分专家,需要用通俗易懂的语言向客户解释各种成分的作用和适用人群。', +'{"accuracy": 35, "clarity": 30, "practicality": 20, "patience": 15}', +'ACTIVE', TRUE, 11), + +('投诉处理演练', '处理客户投诉,化解矛盾,维护客户关系', '危机处理', +'{"bot_id": "complaint_handler_bot", "temperature": 0.8}', +'你扮演一位不满意的客户,对服务或效果有投诉。学员需要耐心倾听、理解客户情绪、提供解决方案。', +'{"empathy": 30, "problem_solving": 30, "communication": 25, "result": 15}', +'ACTIVE', TRUE, 16), + +('美容手法指导', '一对一美容手法技巧指导和纠正', '技能培训', +'{"bot_id": "technique_trainer_bot", "temperature": 0.5}', +'你是一位资深美容培训师,指导学员正确的面部护理手法,包括力度、方向、节奏等细节。', +'{"technique_accuracy": 40, "comprehension": 30, "practice": 20, "safety": 10}', +'ACTIVE', TRUE, 11), + +('销售话术演练', '练习不同场景下的销售话术和应对技巧', '销售技巧', +'{"bot_id": "sales_trainer_bot", "temperature": 0.7}', +'模拟各种类型的客户,让学员练习销售话术,包括产品介绍、异议处理、促成成交等。', +'{"persuasion": 25, "product_knowledge": 25, "objection_handling": 25, "closing": 25}', +'ACTIVE', TRUE, 16), + +('医美项目咨询', '专业解答轻医美项目的原理、效果和注意事项', '医美咨询', +'{"bot_id": "medical_beauty_bot", "temperature": 0.6}', +'你是一位医美咨询师,需要专业、客观地介绍各种轻医美项目,包括适应症、恢复期、注意事项等。', +'{"professionalism": 35, "safety_awareness": 30, "communication": 20, "ethics": 15}', +'ACTIVE', TRUE, 7); + +-- ============================================ +-- 九、成长路径(美容行业职业发展) +-- ============================================ + +DELETE FROM growth_paths WHERE id > 2; + +INSERT INTO growth_paths (name, description, target_role, courses, estimated_duration_days, is_active, sort_order) VALUES +('美容师成长路径', '从初级美容师到高级美容技师的完整学习路径', '高级美容技师', +'[{"course_id": 5, "order": 1, "is_required": true}, + {"course_id": 8, "order": 2, "is_required": true}, + {"course_id": 9, "order": 3, "is_required": true}, + {"course_id": 10, "order": 4, "is_required": true}, + {"course_id": 11, "order": 5, "is_required": true}, + {"course_id": 13, "order": 6, "is_required": false}]', +90, TRUE, 1), + +('美容顾问发展路径', '培养专业的美容顾问和销售精英', '资深美容顾问', +'[{"course_id": 5, "order": 1, "is_required": true}, + {"course_id": 8, "order": 2, "is_required": true}, + {"course_id": 7, "order": 3, "is_required": true}, + {"course_id": 9, "order": 4, "is_required": true}, + {"course_id": 14, "order": 5, "is_required": false}]', +60, TRUE, 2), + +('医美技师培养路径', '轻医美项目操作技师的专业培训路径', '医美技师', +'[{"course_id": 5, "order": 1, "is_required": true}, + {"course_id": 6, "order": 2, "is_required": true}, + {"course_id": 10, "order": 3, "is_required": true}, + {"course_id": 12, "order": 4, "is_required": true}, + {"course_id": 13, "order": 5, "is_required": false}]', +120, TRUE, 3), + +('店长管理路径', '从员工到店长的管理能力提升路径', '美容店长', +'[{"course_id": 9, "order": 1, "is_required": true}, + {"course_id": 7, "order": 2, "is_required": true}, + {"course_id": 14, "order": 3, "is_required": true}, + {"course_id": 15, "order": 4, "is_required": true}, + {"course_id": 16, "order": 5, "is_required": true}]', +90, TRUE, 4); + +-- ============================================ +-- 十、模拟考试记录和陪练记录 +-- ============================================ + +-- 插入一些考试记录 +INSERT INTO exams (user_id, course_id, exam_name, question_count, total_score, pass_score, start_time, end_time, duration_minutes, score, is_passed, status) VALUES +(8, 5, '美容基础理论期末考试', 20, 100, 60, '2024-12-10 09:00:00', '2024-12-10 09:45:00', 60, 85, TRUE, 'submitted'), +(9, 6, '轻医美项目认证考试', 15, 100, 70, '2024-12-12 14:00:00', '2024-12-12 14:50:00', 60, 78, TRUE, 'submitted'), +(12, 7, '销售技巧考核', 10, 100, 60, '2024-12-15 10:00:00', '2024-12-15 10:30:00', 30, 92, TRUE, 'submitted'), +(13, 5, '皮肤生理学测试', 15, 100, 60, '2024-12-18 15:00:00', '2024-12-18 15:40:00', 45, 73, TRUE, 'submitted'); + +-- 插入一些陪练会话记录 +INSERT INTO training_sessions (user_id, scene_id, start_time, end_time, duration_seconds, status, total_score, evaluation_result) VALUES +(8, 4, '2024-12-20 10:00:00', '2024-12-20 10:25:00', 1500, 'COMPLETED', 82, +'{"professionalism": 85, "communication": 80, "needs_analysis": 82, "solution_matching": 80}'), +(12, 5, '2024-12-21 14:30:00', '2024-12-21 14:50:00', 1200, 'COMPLETED', 88, +'{"accuracy": 90, "clarity": 88, "practicality": 85, "patience": 87}'), +(17, 6, '2024-12-22 09:00:00', '2024-12-22 09:20:00', 1200, 'COMPLETED', 75, +'{"empathy": 78, "problem_solving": 72, "communication": 75, "result": 75}'), +(20, 8, '2024-12-23 15:00:00', '2024-12-23 15:30:00', 1800, 'COMPLETED', 90, +'{"persuasion": 88, "product_knowledge": 92, "objection_handling": 90, "closing": 90}'); + +-- ============================================ +-- 输出完成信息 +-- ============================================ + +SELECT '美容机构模拟数据插入完成!' as message; +SELECT '新增用户数量:' as info, COUNT(*) as count FROM users WHERE id > 3; +SELECT '新增团队数量:' as info, COUNT(*) as count FROM teams WHERE id > 3; +SELECT '新增课程数量:' as info, COUNT(*) as count FROM courses WHERE id > 4; +SELECT '新增陪练场景:' as info, COUNT(*) as count FROM training_scenes WHERE id > 3; diff --git a/backend/scripts/rollback_example.py b/backend/scripts/rollback_example.py new file mode 100644 index 0000000..091d116 --- /dev/null +++ b/backend/scripts/rollback_example.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +""" +考培练系统 - 数据库回滚工具使用示例 +演示如何使用回滚工具进行常见的数据恢复操作 +""" + +import asyncio +import sys +from pathlib import Path + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from scripts.kaopeilian_rollback import KaopeilianRollbackTool + +async def demo_rollback_tools(): + """演示回滚工具的使用""" + + print("🔧 考培练系统 - 数据库回滚工具演示") + print("=" * 60) + + tool = KaopeilianRollbackTool() + + try: + await tool.connect() + + print("\n1️⃣ 查看最近24小时的数据变更") + print("-" * 40) + await tool.list_recent_changes(24) + + print("\n2️⃣ 演示用户回滚(模拟)") + print("-" * 40) + print("命令示例:") + print("python scripts/kaopeilian_rollback.py --rollback-user 123 --operation-type delete") + print("python scripts/kaopeilian_rollback.py --rollback-user 123 --operation-type delete --execute") + + print("\n3️⃣ 演示课程回滚(模拟)") + print("-" * 40) + print("命令示例:") + print("python scripts/kaopeilian_rollback.py --rollback-course 456 --operation-type delete") + print("python scripts/kaopeilian_rollback.py --rollback-course 456 --operation-type delete --execute") + + print("\n4️⃣ 演示考试回滚(模拟)") + print("-" * 40) + print("命令示例:") + print("python scripts/kaopeilian_rollback.py --rollback-exam 789") + print("python scripts/kaopeilian_rollback.py --rollback-exam 789 --execute") + + print("\n5️⃣ 演示时间点回滚") + print("-" * 40) + print("命令示例:") + print("python scripts/simple_rollback.py --time '2024-12-20 10:30:00'") + print("python scripts/simple_rollback.py --time '2024-12-20 10:30:00' --execute") + + print("\n6️⃣ 查看Binlog文件") + print("-" * 40) + print("命令示例:") + print("python scripts/simple_rollback.py --list") + print("python scripts/binlog_rollback_tool.py --list-binlogs") + + print("\n📋 回滚工具总结") + print("-" * 40) + print("✅ 专用工具:kaopeilian_rollback.py - 业务场景回滚") + print("✅ 简化工具:simple_rollback.py - 时间点回滚") + print("✅ 完整工具:binlog_rollback_tool.py - 复杂Binlog回滚") + print("✅ 配置优化:mysql-rollback.cnf - MySQL回滚优化") + print("✅ 文档指南:database_rollback_guide.md - 完整操作指南") + + print("\n⚠️ 安全提醒") + print("-" * 40) + print("• 回滚操作不可逆,务必谨慎执行") + print("• 生产环境回滚前必须在测试环境验证") + print("• 重要操作需要多人确认") + print("• 保留回滚操作日志和备份文件") + + except Exception as e: + print(f"❌ 演示过程中出现错误: {e}") + finally: + await tool.close() + +if __name__ == "__main__": + asyncio.run(demo_rollback_tools()) diff --git a/backend/scripts/run_practice_scenes_setup.py b/backend/scripts/run_practice_scenes_setup.py new file mode 100644 index 0000000..f2e075f --- /dev/null +++ b/backend/scripts/run_practice_scenes_setup.py @@ -0,0 +1,93 @@ +""" +执行陪练场景表创建和初始数据插入脚本 +""" +import asyncio +import sys +from pathlib import Path + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from sqlalchemy import text +from app.core.database import async_engine + + +async def run_sql_file(): + """执行SQL文件""" + sql_file = project_root / "scripts" / "create_practice_scenes.sql" + + if not sql_file.exists(): + print(f"❌ SQL文件不存在: {sql_file}") + return False + + # 读取SQL文件 + with open(sql_file, 'r', encoding='utf-8') as f: + sql_content = f.read() + + # 分割SQL语句(按分号分隔) + statements = [s.strip() for s in sql_content.split(';') if s.strip() and not s.strip().startswith('--')] + + print(f"📝 准备执行 {len(statements)} 条SQL语句...") + + async with async_engine.begin() as conn: + for i, statement in enumerate(statements, 1): + if not statement: + continue + try: + # 跳过注释 + if statement.strip().startswith('--'): + continue + + print(f" [{i}/{len(statements)}] 执行中...") + result = await conn.execute(text(statement)) + + # 如果是SELECT语句,打印结果 + if statement.strip().upper().startswith('SELECT'): + rows = result.fetchall() + print(f" ✅ 查询返回 {len(rows)} 行数据") + for row in rows: + print(f" {row}") + else: + print(f" ✅ 执行成功") + + except Exception as e: + print(f" ❌ 执行失败: {e}") + if "already exists" not in str(e).lower() and "duplicate" not in str(e).lower(): + raise + + print("\n✅ 所有SQL语句执行完成!") + return True + + +async def main(): + """主函数""" + try: + print("=" * 60) + print("陪练场景表创建和初始数据插入") + print("=" * 60) + + success = await run_sql_file() + + if success: + print("\n" + "=" * 60) + print("✅ 陪练场景表创建成功!") + print("=" * 60) + else: + print("\n" + "=" * 60) + print("❌ 执行失败") + print("=" * 60) + sys.exit(1) + + except Exception as e: + print(f"\n❌ 发生错误: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) + + + diff --git a/backend/scripts/seed_beauty_data.py b/backend/scripts/seed_beauty_data.py new file mode 100644 index 0000000..96602f2 --- /dev/null +++ b/backend/scripts/seed_beauty_data.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python +""" +轻医美连锁岗位与课程种子数据 +""" + +import asyncio +import sys +from pathlib import Path +import aiomysql +import os +import json +from datetime import datetime + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent.parent +sys.path.append(str(project_root)) + +from app.core.config import settings + + +async def execute_seed(): + """执行种子数据插入""" + try: + # 从环境变量或配置中获取数据库连接信息 + database_url = os.getenv("DATABASE_URL", settings.DATABASE_URL) + + # 解析数据库连接字符串 + if database_url.startswith("mysql+aiomysql://"): + url = database_url.replace("mysql+aiomysql://", "") + else: + url = database_url + + # 解析连接参数 + auth_host = url.split("@")[1] + user_pass = url.split("@")[0] + host_port_db = auth_host.split("/") + host_port = host_port_db[0].split(":") + + user = user_pass.split(":")[0] + password = user_pass.split(":")[1] + host = host_port[0] + port = int(host_port[1]) if len(host_port) > 1 else 3306 + database = host_port_db[1].split("?")[0] if len(host_port_db) > 1 else "kaopeilian" + + print(f"连接数据库: {host}:{port}/{database}") + + # 创建数据库连接 + conn = await aiomysql.connect( + host=host, + port=port, + user=user, + password=password, + db=database, + charset='utf8mb4' + ) + + async with conn.cursor() as cursor: + print("\n🎯 开始插入轻医美连锁业务数据...") + + # 1. 插入轻医美相关岗位 + print("\n📌 插入岗位数据...") + positions_data = [ + { + 'name': '区域经理', + 'code': 'region_manager', + 'description': '负责多家门店的运营管理和业绩达成', + 'status': 'active', + 'skills': json.dumps(['团队管理', '业绩分析', '战略规划', '客户关系'], ensure_ascii=False), + 'level': 'expert', + 'sort_order': 10 + }, + { + 'name': '店长', + 'code': 'store_manager', + 'description': '负责门店日常运营管理,团队建设和业绩达成', + 'status': 'active', + 'skills': json.dumps(['门店管理', '团队建设', '销售管理', '客户维护'], ensure_ascii=False), + 'level': 'senior', + 'sort_order': 20, + 'parent_code': 'region_manager' + }, + { + 'name': '美容顾问', + 'code': 'beauty_consultant', + 'description': '为客户提供专业的美容咨询和方案设计', + 'status': 'active', + 'skills': json.dumps(['产品知识', '销售技巧', '方案设计', '客户沟通'], ensure_ascii=False), + 'level': 'intermediate', + 'sort_order': 30, + 'parent_code': 'store_manager' + }, + { + 'name': '美容技师', + 'code': 'beauty_therapist', + 'description': '为客户提供专业的美容护理服务', + 'status': 'active', + 'skills': json.dumps(['护肤技术', '仪器操作', '手法技巧', '服务意识'], ensure_ascii=False), + 'level': 'intermediate', + 'sort_order': 40, + 'parent_code': 'store_manager' + }, + { + 'name': '医美咨询师', + 'code': 'medical_beauty_consultant', + 'description': '提供医疗美容项目咨询和方案制定', + 'status': 'active', + 'skills': json.dumps(['医美知识', '风险评估', '方案设计', '合规意识'], ensure_ascii=False), + 'level': 'senior', + 'sort_order': 35, + 'parent_code': 'store_manager' + }, + { + 'name': '护士', + 'code': 'nurse', + 'description': '协助医生进行医美项目操作,负责术后护理', + 'status': 'active', + 'skills': json.dumps(['护理技术', '无菌操作', '应急处理', '医疗知识'], ensure_ascii=False), + 'level': 'intermediate', + 'sort_order': 45, + 'parent_code': 'store_manager' + }, + { + 'name': '前台接待', + 'code': 'receptionist', + 'description': '负责客户接待、预约管理和前台事务', + 'status': 'active', + 'skills': json.dumps(['接待礼仪', '沟通能力', '信息管理', '服务意识'], ensure_ascii=False), + 'level': 'junior', + 'sort_order': 50, + 'parent_code': 'store_manager' + }, + { + 'name': '市场专员', + 'code': 'marketing_specialist', + 'description': '负责门店营销活动策划和执行', + 'status': 'active', + 'skills': json.dumps(['活动策划', '社媒运营', '数据分析', '创意设计'], ensure_ascii=False), + 'level': 'intermediate', + 'sort_order': 60, + 'parent_code': 'store_manager' + } + ] + + # 先获取已存在的岗位ID映射 + position_id_map = {} + + # 插入岗位(处理层级关系) + for position in positions_data: + # 检查是否已存在 + check_sql = "SELECT id FROM positions WHERE code = %s AND is_deleted = FALSE" + await cursor.execute(check_sql, (position['code'],)) + existing = await cursor.fetchone() + + if existing: + position_id_map[position['code']] = existing[0] + print(f" ⚠️ 岗位 '{position['name']}' 已存在,ID: {existing[0]}") + else: + # 获取parent_id + parent_id = None + if 'parent_code' in position: + parent_code = position.pop('parent_code') + if parent_code in position_id_map: + parent_id = position_id_map[parent_code] + + # 插入岗位 + insert_sql = """ + INSERT INTO positions (name, code, description, parent_id, status, skills, level, sort_order, created_at, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) + """ + await cursor.execute(insert_sql, ( + position['name'], + position['code'], + position['description'], + parent_id, + position['status'], + position['skills'], + position['level'], + position['sort_order'] + )) + position_id = cursor.lastrowid + position_id_map[position['code']] = position_id + print(f" ✅ 插入岗位: {position['name']} (ID: {position_id})") + + await conn.commit() + + # 2. 插入轻医美相关课程 + print("\n📚 插入课程数据...") + courses_data = [ + { + 'name': '皮肤生理学基础', + 'description': '学习皮肤结构、功能和常见问题,为专业护理打下基础', + 'category': 'technology', + 'status': 'published', + 'duration_hours': 16, + 'difficulty_level': 2, + 'tags': json.dumps(['皮肤学', '基础理论', '必修课'], ensure_ascii=False), + 'is_featured': True, + 'sort_order': 100 + }, + { + 'name': '医美产品知识与应用', + 'description': '全面了解各类医美产品的成分、功效和适用人群', + 'category': 'technology', + 'status': 'published', + 'duration_hours': 20, + 'difficulty_level': 3, + 'tags': json.dumps(['产品知识', '医美', '专业技能'], ensure_ascii=False), + 'is_featured': True, + 'sort_order': 110 + }, + { + 'name': '美容仪器操作与维护', + 'description': '掌握各类美容仪器的操作方法、注意事项和日常维护', + 'category': 'technology', + 'status': 'published', + 'duration_hours': 24, + 'difficulty_level': 3, + 'tags': json.dumps(['仪器操作', '实操技能', '设备维护'], ensure_ascii=False), + 'is_featured': False, + 'sort_order': 120 + }, + { + 'name': '轻医美销售技巧', + 'description': '学习专业的销售话术、客户需求分析和成交技巧', + 'category': 'business', + 'status': 'published', + 'duration_hours': 16, + 'difficulty_level': 2, + 'tags': json.dumps(['销售技巧', '客户沟通', '业绩提升'], ensure_ascii=False), + 'is_featured': True, + 'sort_order': 130 + }, + { + 'name': '客户服务与投诉处理', + 'description': '提升服务意识,掌握客户投诉处理的方法和技巧', + 'category': 'business', + 'status': 'published', + 'duration_hours': 12, + 'difficulty_level': 2, + 'tags': json.dumps(['客户服务', '危机处理', '沟通技巧'], ensure_ascii=False), + 'is_featured': False, + 'sort_order': 140 + }, + { + 'name': '卫生消毒与感染控制', + 'description': '学习医美机构的卫生标准和消毒流程,确保服务安全', + 'category': 'general', + 'status': 'published', + 'duration_hours': 8, + 'difficulty_level': 1, + 'tags': json.dumps(['卫生安全', '消毒规范', '合规管理'], ensure_ascii=False), + 'is_featured': True, + 'sort_order': 150 + }, + { + 'name': '门店运营管理', + 'description': '学习门店日常管理、团队建设和业绩管理', + 'category': 'management', + 'status': 'published', + 'duration_hours': 20, + 'difficulty_level': 3, + 'tags': json.dumps(['门店管理', '团队管理', '运营策略'], ensure_ascii=False), + 'is_featured': False, + 'sort_order': 160 + }, + { + 'name': '医美项目介绍与咨询', + 'description': '详细了解各类医美项目的原理、效果和适应症', + 'category': 'technology', + 'status': 'published', + 'duration_hours': 30, + 'difficulty_level': 4, + 'tags': json.dumps(['医美项目', '专业咨询', '风险告知'], ensure_ascii=False), + 'is_featured': True, + 'sort_order': 170 + }, + { + 'name': '社媒营销与私域运营', + 'description': '学习如何通过社交媒体进行品牌推广和客户维护', + 'category': 'business', + 'status': 'published', + 'duration_hours': 16, + 'difficulty_level': 2, + 'tags': json.dumps(['社媒营销', '私域流量', '客户维护'], ensure_ascii=False), + 'is_featured': False, + 'sort_order': 180 + } + ] + + course_id_map = {} + + for course in courses_data: + # 检查是否已存在 + check_sql = "SELECT id FROM courses WHERE name = %s AND is_deleted = FALSE" + await cursor.execute(check_sql, (course['name'],)) + existing = await cursor.fetchone() + + if existing: + course_id_map[course['name']] = existing[0] + print(f" ⚠️ 课程 '{course['name']}' 已存在,ID: {existing[0]}") + else: + # 插入课程 + insert_sql = """ + INSERT INTO courses ( + name, description, category, status, duration_hours, + difficulty_level, tags, is_featured, sort_order, + published_at, created_at, updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW(), NOW()) + """ + await cursor.execute(insert_sql, ( + course['name'], + course['description'], + course['category'], + course['status'], + course['duration_hours'], + course['difficulty_level'], + course['tags'], + course['is_featured'], + course['sort_order'] + )) + course_id = cursor.lastrowid + course_id_map[course['name']] = course_id + print(f" ✅ 插入课程: {course['name']} (ID: {course_id})") + + await conn.commit() + + # 3. 设置岗位与课程的关联 + print("\n🔗 设置岗位课程关联...") + position_courses = [ + # 店长必修课程 + ('store_manager', '门店运营管理', 'required', 1), + ('store_manager', '轻医美销售技巧', 'required', 2), + ('store_manager', '客户服务与投诉处理', 'required', 3), + ('store_manager', '卫生消毒与感染控制', 'required', 4), + + # 美容顾问必修课程 + ('beauty_consultant', '皮肤生理学基础', 'required', 1), + ('beauty_consultant', '医美产品知识与应用', 'required', 2), + ('beauty_consultant', '轻医美销售技巧', 'required', 3), + ('beauty_consultant', '客户服务与投诉处理', 'required', 4), + ('beauty_consultant', '社媒营销与私域运营', 'optional', 5), + + # 美容技师必修课程 + ('beauty_therapist', '皮肤生理学基础', 'required', 1), + ('beauty_therapist', '美容仪器操作与维护', 'required', 2), + ('beauty_therapist', '卫生消毒与感染控制', 'required', 3), + ('beauty_therapist', '医美产品知识与应用', 'optional', 4), + + # 医美咨询师必修课程 + ('medical_beauty_consultant', '医美项目介绍与咨询', 'required', 1), + ('medical_beauty_consultant', '皮肤生理学基础', 'required', 2), + ('medical_beauty_consultant', '医美产品知识与应用', 'required', 3), + ('medical_beauty_consultant', '轻医美销售技巧', 'required', 4), + ('medical_beauty_consultant', '客户服务与投诉处理', 'required', 5), + + # 护士必修课程 + ('nurse', '卫生消毒与感染控制', 'required', 1), + ('nurse', '医美项目介绍与咨询', 'required', 2), + ('nurse', '皮肤生理学基础', 'required', 3), + + # 前台接待必修课程 + ('receptionist', '客户服务与投诉处理', 'required', 1), + ('receptionist', '医美产品知识与应用', 'optional', 2), + + # 市场专员必修课程 + ('marketing_specialist', '社媒营销与私域运营', 'required', 1), + ('marketing_specialist', '医美产品知识与应用', 'optional', 2), + ('marketing_specialist', '轻医美销售技巧', 'optional', 3), + ] + + for pos_code, course_name, course_type, priority in position_courses: + if pos_code in position_id_map and course_name in course_id_map: + position_id = position_id_map[pos_code] + course_id = course_id_map[course_name] + + # 检查是否已存在 + check_sql = """ + SELECT id FROM position_courses + WHERE position_id = %s AND course_id = %s AND is_deleted = FALSE + """ + await cursor.execute(check_sql, (position_id, course_id)) + existing = await cursor.fetchone() + + if not existing: + insert_sql = """ + INSERT INTO position_courses (position_id, course_id, course_type, priority, is_deleted, created_at, updated_at) + VALUES (%s, %s, %s, %s, FALSE, NOW(), NOW()) + """ + await cursor.execute(insert_sql, (position_id, course_id, course_type, priority)) + print(f" ✅ 关联: {pos_code} - {course_name} ({course_type})") + + await conn.commit() + + # 4. 显示统计信息 + print("\n📊 数据统计:") + + # 统计岗位 + await cursor.execute("SELECT COUNT(*) FROM positions WHERE is_deleted = FALSE") + total_positions = (await cursor.fetchone())[0] + print(f" 岗位总数: {total_positions}") + + # 统计课程 + await cursor.execute("SELECT COUNT(*) FROM courses WHERE is_deleted = FALSE") + total_courses = (await cursor.fetchone())[0] + print(f" 课程总数: {total_courses}") + + # 统计岗位课程关联 + await cursor.execute("SELECT COUNT(*) FROM position_courses WHERE is_deleted = FALSE") + total_pc = (await cursor.fetchone())[0] + print(f" 岗位课程关联数: {total_pc}") + + print("\n🎉 轻医美连锁业务数据插入完成!") + print("\n💡 提示:") + print(" 1. 可以登录系统查看岗位管理页面") + print(" 2. 每个岗位都配置了相应的必修和选修课程") + print(" 3. 课程涵盖了技术、管理、业务和通用等各个分类") + + conn.close() + + except Exception as e: + print(f"❌ 执行失败: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + print("🌟 开始插入轻医美连锁岗位与课程数据...") + asyncio.run(execute_seed()) diff --git a/backend/scripts/seed_positions.py b/backend/scripts/seed_positions.py new file mode 100644 index 0000000..05c609d --- /dev/null +++ b/backend/scripts/seed_positions.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +为轻医美连锁品牌注入基础“岗位”数据(真落库)。 + +- 场景:前端岗位下拉显示 No data 时,先注入标准岗位,便于联调验证 +- 数据来源:行业通用岗位,后续可按门店组织结构扩展 +""" + +import asyncio +from typing import List + +from sqlalchemy import select + +try: + # 覆盖本地数据库连接(如存在) + import local_config # noqa: F401 +except Exception: + pass + +from app.core.database import AsyncSessionLocal +from app.models.position import Position + + +async def ensure_positions_exists(session, names: List[str]) -> int: + """确保给定岗位名称存在于数据库,已存在则跳过。 + + 返回新增的记录数。 + """ + existing = (await session.execute(select(Position))).scalars().all() + existing_names = {p.name for p in existing} + + added = 0 + for name in names: + if name in existing_names: + continue + obj = Position( + name=name, + code=name, # 简化:与名称一致,前端无需依赖 code + description=f"{name} 岗位(系统初始化)", + status="active", + level="junior", + ) + session.add(obj) + added += 1 + + if added: + await session.commit() + return added + + +async def main() -> None: + """脚本入口:写入基础岗位数据并打印结果。""" + base_positions = [ + "咨询师", + "治疗师", + "皮肤管理师", + "前台接待", + "门店店长", + "区域运营经理", + "市场专员", + "客服专员", + ] + + async with AsyncSessionLocal() as session: + added = await ensure_positions_exists(session, base_positions) + # 打印结果 + print(f"✅ 岗位数据初始化完成,新增 {added} 条。") + + rows = (await session.execute(select(Position))).scalars().all() + print("当前岗位:", ", ".join(p.name for p in rows)) + + +if __name__ == "__main__": + asyncio.run(main()) + + diff --git a/backend/scripts/seed_practice_sessions.sql b/backend/scripts/seed_practice_sessions.sql new file mode 100644 index 0000000..3ceff5d --- /dev/null +++ b/backend/scripts/seed_practice_sessions.sql @@ -0,0 +1,164 @@ +-- ============================================ +-- 为所有学员用户注入陪练会话数据 +-- ============================================ + +USE `kaopeilian`; + +-- 先创建陪练场景(如果不存在) +INSERT IGNORE INTO practice_scenes (id, name, description, type, difficulty, status, background, ai_role, objectives, keywords, duration, usage_count, rating, created_by, is_deleted) +VALUES +(1, '电话销售陪练', '模拟电话销售场景,提升电话沟通技巧', 'phone', 'intermediate', 'active', '客户对轻医美项目感兴趣,需要通过电话进行专业介绍', 'AI扮演潜在客户', '["掌握电话开场技巧", "专业介绍项目", "处理客户疑问"]', '["电话销售", "沟通技巧", "项目介绍"]', 15, 0, 4.5, 1, 0), +(2, '面对面咨询陪练', '模拟面对面咨询场景,提升面诊沟通能力', 'face', 'intermediate', 'active', '客户到店咨询轻医美项目,需要专业面诊和方案推荐', 'AI扮演到店客户', '["建立客户信任", "专业面诊", "方案推荐"]', '["面对面", "咨询", "方案设计"]', 20, 0, 4.7, 1, 0), +(3, '客户投诉处理陪练', '模拟客户投诉场景,提升问题处理能力', 'complaint', 'senior', 'active', '客户对服务或效果不满意,需要妥善处理投诉', 'AI扮演投诉客户', '["倾听客户诉求", "安抚客户情绪", "提供解决方案"]', '["投诉处理", "情绪管理", "客户关系"]', 15, 0, 4.3, 1, 0), +(4, '售后服务陪练', '模拟售后服务场景,提升客户满意度', 'after-sales', 'junior', 'active', '项目完成后,进行售后跟进和关怀', 'AI扮演已消费客户', '["售后关怀", "效果跟进", "二次营销"]', '["售后服务", "客户维护", "复购"]', 10, 0, 4.6, 1, 0), +(5, '产品介绍陪练', '模拟产品介绍场景,提升产品讲解能力', 'product-intro', 'beginner', 'active', '向客户详细介绍轻医美项目和产品', 'AI扮演咨询客户', '["产品特点讲解", "适用人群分析", "价值传递"]', '["产品介绍", "专业知识", "销售技巧"]', 15, 0, 4.8, 1, 0); + +-- 为每个学员用户创建陪练记录 +-- 使用存储过程批量生成 + +DELIMITER // + +DROP PROCEDURE IF EXISTS generate_practice_sessions// + +CREATE PROCEDURE generate_practice_sessions(IN target_user_id INT, IN session_count INT) +BEGIN + DECLARE i INT DEFAULT 0; + DECLARE session_date DATETIME; + DECLARE duration INT; + DECLARE scene INT; + DECLARE scene_name_val VARCHAR(200); + DECLARE scene_type_val VARCHAR(50); + DECLARE score INT; + DECLARE turns INT; + + WHILE i < session_count DO + -- 在过去60天内随机生成日期 + SET session_date = DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 60) DAY) + INTERVAL FLOOR(RAND() * 43200) SECOND; + + -- 随机时长:10-30分钟 + SET duration = 600 + FLOOR(RAND() * 1200); + + -- 随机选择场景 + SET scene = 1 + FLOOR(RAND() * 5); + + -- 根据场景设置名称和类型 + CASE scene + WHEN 1 THEN + SET scene_name_val = '电话销售陪练'; + SET scene_type_val = 'phone'; + WHEN 2 THEN + SET scene_name_val = '面对面咨询陪练'; + SET scene_type_val = 'face'; + WHEN 3 THEN + SET scene_name_val = '客户投诉处理陪练'; + SET scene_type_val = 'complaint'; + WHEN 4 THEN + SET scene_name_val = '售后服务陪练'; + SET scene_type_val = 'after-sales'; + WHEN 5 THEN + SET scene_name_val = '产品介绍陪练'; + SET scene_type_val = 'product-intro'; + END CASE; + + -- 随机对话轮数 + SET turns = 10 + FLOOR(RAND() * 20); + + -- 随机分数(60-95分) + SET score = 60 + FLOOR(RAND() * 35); + + -- 插入陪练会话 + INSERT INTO practice_sessions ( + session_id, + user_id, + scene_id, + scene_name, + scene_type, + start_time, + end_time, + duration_seconds, + turns, + status, + is_deleted, + created_at, + updated_at + ) VALUES ( + CONCAT('session_', target_user_id, '_', UNIX_TIMESTAMP(session_date)), + target_user_id, + scene, + scene_name_val, + scene_type_val, + session_date, + DATE_ADD(session_date, INTERVAL duration SECOND), + duration, + turns, + 'completed', + 0, + session_date, + session_date + ); + + -- 插入陪练报告 + INSERT INTO practice_reports ( + session_id, + total_score, + score_breakdown, + ability_dimensions, + created_at, + updated_at + ) VALUES ( + CONCAT('session_', target_user_id, '_', UNIX_TIMESTAMP(session_date)), + score, + JSON_OBJECT( + 'professionalism', score + FLOOR(RAND() * 10) - 5, + 'communication', score + FLOOR(RAND() * 10) - 5, + 'problem_solving', score + FLOOR(RAND() * 10) - 5 + ), + JSON_OBJECT( + 'technical_skills', score + FLOOR(RAND() * 10) - 5, + 'service_attitude', score + FLOOR(RAND() * 10) - 5, + 'sales_ability', score + FLOOR(RAND() * 10) - 5 + ), + session_date, + session_date + ); + + SET i = i + 1; + END WHILE; +END// + +DELIMITER ; + +-- 为所有学员角色用户生成陪练数据 +-- 获取所有学员用户ID并为每个用户生成15-25条记录 + +-- user_id = 1 (superadmin) - 20条 +CALL generate_practice_sessions(1, 20); + +-- user_id = 5 (consultant_001) - 25条 +CALL generate_practice_sessions(5, 25); + +-- user_id = 7 (therapist_001) - 22条 +CALL generate_practice_sessions(7, 22); + +-- user_id = 8 (receptionist_001) - 18条 +CALL generate_practice_sessions(8, 18); + +-- 统计结果 +SELECT '========================================' AS ''; +SELECT '✅ 陪练数据生成完成!' AS ''; +SELECT '========================================' AS ''; + +SELECT + u.id, + u.username, + u.role, + COUNT(ps.id) as practice_count +FROM users u +LEFT JOIN practice_sessions ps ON u.id = ps.user_id AND ps.is_deleted = 0 +WHERE u.is_deleted = 0 +GROUP BY u.id, u.username, u.role +ORDER BY u.id; + +-- 清理存储过程 +DROP PROCEDURE IF EXISTS generate_practice_sessions; + diff --git a/backend/scripts/seed_statistics_demo_data.py b/backend/scripts/seed_statistics_demo_data.py new file mode 100755 index 0000000..c82d3de --- /dev/null +++ b/backend/scripts/seed_statistics_demo_data.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python +""" +为统计分析页面生成轻医美场景的模拟数据 + +生成的数据包括: +1. 考试记录(exams)- 不同时间段、不同课程、不同分数 +2. 错题记录(exam_mistakes)- 不同难度、不同知识点 +3. 陪练会话(practice_sessions)- 不同时间的陪练记录 +4. 知识点数据(knowledge_points)- 轻医美相关知识点 + +目标:确保统计分析页面的每个模块都能显示数据 +""" +import sys +import asyncio +from datetime import datetime, timedelta +import random +from pathlib import Path + +# 添加项目路径 +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker +from sqlalchemy import select, func + +from app.models.exam import Exam, Question +from app.models.exam_mistake import ExamMistake +from app.models.course import Course, KnowledgePoint +from app.models.practice import PracticeSession +from app.models.user import User +from app.core.logger import get_logger + +logger = get_logger(__name__) + +# 数据库连接(开发测试环境) +DATABASE_URL = "mysql+aiomysql://root:nj861021@localhost:3306/kaopeilian" + + +# 轻医美知识点数据 +BEAUTY_KNOWLEDGE_POINTS = { + "皮肤生理学基础": [ + "皮肤结构与层次", + "皮肤类型与特征", + "皮肤屏障功能", + "皮肤老化机制", + "皮肤色素形成", + "皮肤水分平衡" + ], + "医美产品知识与应用": [ + "透明质酸的应用", + "肉毒素的作用机理", + "光子嫩肤原理", + "果酸焕肤技术", + "维生素C美白", + "胶原蛋白补充" + ], + "美容仪器操作与维护": [ + "超声刀操作流程", + "热玛吉治疗参数", + "皮秒激光使用", + "射频美容仪器", + "冷光美肤仪", + "水光注射仪" + ], + "轻医美销售技巧": [ + "客户需求分析", + "项目推荐话术", + "价格异议处理", + "成交技巧", + "客户关系维护", + "套餐设计方法" + ], + "客户服务与投诉处理": [ + "服务标准流程", + "投诉应对技巧", + "客户期望管理", + "售后跟踪服务", + "客户满意度提升", + "危机公关处理" + ] +} + +# 难度级别 +DIFFICULTY_LEVELS = ["easy", "medium", "hard"] + + +async def clear_old_demo_data(db: AsyncSession, user_id: int): + """清理旧的演示数据""" + logger.info(f"清理用户 {user_id} 的旧演示数据...") + + # 清理考试记录(会级联删除错题记录) + from sqlalchemy import delete + await db.execute(delete(Exam).where(Exam.user_id == user_id)) + + # 清理陪练会话 + await db.execute(delete(PracticeSession).where(PracticeSession.user_id == user_id)) + + await db.commit() + logger.info("旧数据清理完成") + + +async def get_or_create_knowledge_points(db: AsyncSession, course_id: int, course_name: str) -> list: + """获取或创建知识点""" + # 检查是否已有知识点 + result = await db.execute( + select(KnowledgePoint).where( + KnowledgePoint.course_id == course_id, + KnowledgePoint.is_deleted == False + ) + ) + existing_kps = result.scalars().all() + + if existing_kps: + logger.info(f"课程 {course_name} 已有 {len(existing_kps)} 个知识点") + return existing_kps + + # 创建新知识点 + knowledge_points = [] + if course_name in BEAUTY_KNOWLEDGE_POINTS: + for kp_name in BEAUTY_KNOWLEDGE_POINTS[course_name]: + kp = KnowledgePoint( + course_id=course_id, + name=kp_name, + description=f"{course_name}中的重要知识点:{kp_name}", + type="核心概念", + source=0, # 手动创建 + is_deleted=False + ) + db.add(kp) + knowledge_points.append(kp) + + await db.commit() + + # 刷新以获取ID + for kp in knowledge_points: + await db.refresh(kp) + + logger.info(f"为课程 {course_name} 创建了 {len(knowledge_points)} 个知识点") + return knowledge_points + + +async def create_exam_records(db: AsyncSession, user_id: int, courses: list): + """创建考试记录""" + logger.info("创建考试记录...") + + end_date = datetime.now() + exams_created = 0 + + # 在过去60天内创建考试记录 + for days_ago in range(60, 0, -1): + exam_date = end_date - timedelta(days=days_ago) + + # 跳过一些日期(不是每天都考试) + if random.random() > 0.3: # 30%的日子有考试 + continue + + # 每天可能考1-2次 + num_exams = random.choice([1, 1, 1, 2]) + + for _ in range(num_exams): + # 随机选择课程 + course = random.choice(courses) + + # 生成考试分数(呈现进步趋势) + # 早期分数较低,后期分数较高 + progress_factor = (60 - days_ago) / 60 # 0 到 1 + base_score = 60 + (progress_factor * 20) # 60-80分基础 + score_variance = random.uniform(-10, 15) + round1_score = max(50, min(100, base_score + score_variance)) + + # 创建考试记录 + exam = Exam( + user_id=user_id, + course_id=course.id, + exam_name=f"{course.name}测试", + question_count=10, + total_score=100.0, + pass_score=60.0, + start_time=exam_date, + end_time=exam_date + timedelta(minutes=random.randint(15, 45)), + duration_minutes=random.randint(15, 45), + round1_score=round(round1_score, 1), + round2_score=None, + round3_score=None, + score=round(round1_score, 1), + is_passed=round1_score >= 60, + status="submitted" + ) + + db.add(exam) + exams_created += 1 + + await db.commit() + logger.info(f"创建了 {exams_created} 条考试记录") + + return exams_created + + +async def create_exam_mistakes(db: AsyncSession, user_id: int, courses: list): + """创建错题记录""" + logger.info("创建错题记录...") + + # 获取用户的所有考试 + result = await db.execute( + select(Exam).where(Exam.user_id == user_id).order_by(Exam.start_time) + ) + exams = result.scalars().all() + + mistakes_created = 0 + + for exam in exams: + # 找到对应的课程 + course = next((c for c in courses if c.id == exam.course_id), None) + if not course: + continue + + # 获取该课程的知识点 + result = await db.execute( + select(KnowledgePoint).where( + KnowledgePoint.course_id == course.id, + KnowledgePoint.is_deleted == False + ) + ) + knowledge_points = result.scalars().all() + + if not knowledge_points: + continue + + # 根据分数决定错题数(分数越低,错题越多) + score = exam.round1_score or 70 + mistake_rate = (100 - score) / 100 # 0.0 到 0.5 + num_mistakes = int(exam.question_count * mistake_rate) + num_mistakes = max(1, min(num_mistakes, exam.question_count - 1)) + + # 创建错题 + for i in range(num_mistakes): + # 随机选择知识点 + kp = random.choice(knowledge_points) + + # 随机选择题型 + question_types = ["single_choice", "multiple_choice", "true_false", "fill_blank", "essay"] + question_type = random.choice(question_types) + + mistake = ExamMistake( + user_id=user_id, + exam_id=exam.id, + question_id=None, # AI生成的题目 + knowledge_point_id=kp.id, + question_content=f"关于{kp.name}的问题{i+1}", + correct_answer="正确答案", + user_answer="用户错误答案", + question_type=question_type + ) + + db.add(mistake) + mistakes_created += 1 + + await db.commit() + logger.info(f"创建了 {mistakes_created} 条错题记录") + + return mistakes_created + + +async def create_practice_sessions(db: AsyncSession, user_id: int): + """创建陪练会话记录""" + logger.info("创建陪练会话记录...") + + end_date = datetime.now() + sessions_created = 0 + + # 在过去60天内创建陪练记录 + for days_ago in range(60, 0, -1): + session_date = end_date - timedelta(days=days_ago) + + # 跳过一些日期 + if random.random() > 0.25: # 25%的日子有陪练 + continue + + # 每次陪练的时长(秒) + duration_seconds = random.randint(600, 1800) # 10-30分钟 + + # 场景类型 + scene_types = ["电话销售", "面对面咨询", "客户投诉处理", "售后服务", "产品介绍"] + scene_name = random.choice(scene_types) + + session = PracticeSession( + session_id=f"session_{user_id}_{int(session_date.timestamp())}", + user_id=user_id, + scene_id=random.randint(1, 5), + scene_name=scene_name, + scene_type=scene_name, + start_time=session_date, + end_time=session_date + timedelta(seconds=duration_seconds), + duration_seconds=duration_seconds, + turns=random.randint(10, 30), + status="completed", + is_deleted=False + ) + + db.add(session) + sessions_created += 1 + + await db.commit() + logger.info(f"创建了 {sessions_created} 条陪练会话记录") + + return sessions_created + + +async def main(): + """主函数""" + logger.info("=" * 60) + logger.info("开始为统计分析页面生成轻医美场景的模拟数据") + logger.info("=" * 60) + + # 创建数据库连接 + engine = create_async_engine(DATABASE_URL, echo=False) + async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + try: + async with async_session() as db: + # 1. 获取测试用户(admin或第一个用户) + result = await db.execute( + select(User).where(User.username == "admin") + ) + user = result.scalar_one_or_none() + + if not user: + # 如果没有admin,使用第一个用户 + result = await db.execute(select(User).limit(1)) + user = result.scalar_one_or_none() + + if not user: + logger.error("❌ 未找到用户,请先创建用户") + return + + logger.info(f"📝 使用用户: {user.username} (ID: {user.id})") + + # 2. 获取轻医美相关课程 + result = await db.execute( + select(Course).where( + Course.is_deleted == False, + Course.status == "published" + ) + ) + courses = result.scalars().all() + + if not courses: + logger.error("❌ 未找到已发布的课程") + return + + logger.info(f"📚 找到 {len(courses)} 门课程") + + # 3. 清理旧数据(可选) + clear_old = input("\n是否清理该用户的旧数据?(y/n): ").lower() + if clear_old == 'y': + await clear_old_demo_data(db, user.id) + + # 4. 为每门课程创建知识点 + logger.info("\n" + "=" * 60) + logger.info("步骤 1/3: 创建知识点") + logger.info("=" * 60) + for course in courses: + await get_or_create_knowledge_points(db, course.id, course.name) + + # 5. 创建考试记录 + logger.info("\n" + "=" * 60) + logger.info("步骤 2/3: 创建考试记录") + logger.info("=" * 60) + exams_count = await create_exam_records(db, user.id, courses) + + # 6. 创建错题记录 + logger.info("\n" + "=" * 60) + logger.info("步骤 3/3: 创建错题记录") + logger.info("=" * 60) + mistakes_count = await create_exam_mistakes(db, user.id, courses) + + # 7. 创建陪练会话记录 + logger.info("\n" + "=" * 60) + logger.info("步骤 4/4: 创建陪练会话记录") + logger.info("=" * 60) + sessions_count = await create_practice_sessions(db, user.id) + + # 8. 统计信息 + logger.info("\n" + "=" * 60) + logger.info("✅ 数据生成完成!") + logger.info("=" * 60) + logger.info(f"用户: {user.username} (ID: {user.id})") + logger.info(f"考试记录: {exams_count} 条") + logger.info(f"错题记录: {mistakes_count} 条") + logger.info(f"陪练记录: {sessions_count} 条") + logger.info("=" * 60) + logger.info("\n现在可以访问统计分析页面查看数据:") + logger.info("http://localhost:5173/analysis/statistics") + logger.info("=" * 60) + + except Exception as e: + logger.error(f"❌ 生成数据失败: {e}") + import traceback + traceback.print_exc() + + finally: + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/backend/scripts/seed_statistics_demo_data.sql b/backend/scripts/seed_statistics_demo_data.sql new file mode 100644 index 0000000..602ce22 --- /dev/null +++ b/backend/scripts/seed_statistics_demo_data.sql @@ -0,0 +1,220 @@ +-- 为统计分析页面生成轻医美场景的模拟数据 +-- 使用方法: mysql -h localhost -u root -p'nj861021' kaopeilian < seed_statistics_demo_data.sql + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ============================================ +-- 1. 获取用户ID(使用admin用户) +-- ============================================ +SET @user_id = (SELECT id FROM users WHERE username = 'admin' LIMIT 1); +SET @user_id = IFNULL(@user_id, 1); + +SELECT CONCAT('使用用户ID: ', @user_id) AS info; + +-- ============================================ +-- 2. 为课程添加知识点(如果不存在) +-- ============================================ +-- 皮肤生理学基础 +SET @course_id_1 = (SELECT id FROM courses WHERE name LIKE '%皮肤生理学%' LIMIT 1); +INSERT INTO knowledge_points (course_id, material_id, name, description, type, source, is_deleted, created_at, updated_at) +SELECT + @course_id_1, + NULL, + kp.name, + CONCAT('核心知识点:', kp.name), + '核心概念', + 0, + FALSE, + NOW(), + NOW() +FROM ( + SELECT '皮肤结构与层次' AS name UNION ALL + SELECT '皮肤类型与特征' UNION ALL + SELECT '皮肤屏障功能' UNION ALL + SELECT '皮肤老化机制' UNION ALL + SELECT '皮肤色素形成' UNION ALL + SELECT '皮肤水分平衡' +) AS kp +WHERE @course_id_1 IS NOT NULL +AND NOT EXISTS ( + SELECT 1 FROM knowledge_points + WHERE course_id = @course_id_1 AND name = kp.name AND is_deleted = FALSE +); + +-- 医美产品知识与应用 +SET @course_id_2 = (SELECT id FROM courses WHERE name LIKE '%医美产品%' LIMIT 1); +INSERT INTO knowledge_points (course_id, material_id, name, description, type, source, is_deleted, created_at, updated_at) +SELECT + @course_id_2, + NULL, + kp.name, + CONCAT('核心知识点:', kp.name), + '核心概念', + 0, + FALSE, + NOW(), + NOW() +FROM ( + SELECT '透明质酸的应用' AS name UNION ALL + SELECT '肉毒素的作用机理' UNION ALL + SELECT '光子嫩肤原理' UNION ALL + SELECT '果酸焕肤技术' UNION ALL + SELECT '维生素C美白' UNION ALL + SELECT '胶原蛋白补充' +) AS kp +WHERE @course_id_2 IS NOT NULL +AND NOT EXISTS ( + SELECT 1 FROM knowledge_points + WHERE course_id = @course_id_2 AND name = kp.name AND is_deleted = FALSE +); + +SELECT '✓ 知识点创建完成' AS info; + +-- ============================================ +-- 3. 生成考试记录(过去60天,呈现进步趋势) +-- ============================================ +-- 删除旧的演示数据 +DELETE FROM exam_mistakes WHERE user_id = @user_id; +DELETE FROM exams WHERE user_id = @user_id; + +-- 生成考试记录 +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, round1_score, score, is_passed, status, + created_at, updated_at +) +SELECT + @user_id, + c.id, + CONCAT(c.name, '测试'), + 10, + 100.0, + 60.0, + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 60) DAY) - INTERVAL FLOOR(RAND() * 86400) SECOND, + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 60) DAY) - INTERVAL FLOOR(RAND() * 86400) SECOND + INTERVAL (15 + FLOOR(RAND() * 30)) MINUTE, + 15 + FLOOR(RAND() * 30), + -- 分数呈现进步趋势:早期60-75分,后期75-95分 + 60 + (RAND() * 15) + (30 * RAND()), + 60 + (RAND() * 15) + (30 * RAND()), + TRUE, + 'submitted', + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 60) DAY), + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 60) DAY) +FROM courses c +CROSS JOIN ( + SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 + UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 + UNION ALL SELECT 11 UNION ALL SELECT 12 UNION ALL SELECT 13 UNION ALL SELECT 14 UNION ALL SELECT 15 + UNION ALL SELECT 16 UNION ALL SELECT 17 UNION ALL SELECT 18 UNION ALL SELECT 19 UNION ALL SELECT 20 +) AS numbers +WHERE c.is_deleted = FALSE AND c.status = 'published' +LIMIT 50; + +-- 更新is_passed状态 +UPDATE exams SET is_passed = (round1_score >= pass_score) WHERE user_id = @user_id; + +SELECT CONCAT('✓ 创建了 ', COUNT(*), ' 条考试记录') AS info +FROM exams WHERE user_id = @user_id; + +-- ============================================ +-- 4. 生成错题记录 +-- ============================================ +-- 为每个考试生成错题(根据分数决定错题数) +INSERT INTO exam_mistakes ( + user_id, exam_id, question_id, knowledge_point_id, + question_content, correct_answer, user_answer, question_type, + created_at, updated_at +) +SELECT + e.user_id, + e.id, + NULL, + kp.id, + CONCAT('关于', kp.name, '的问题'), + '正确答案', + '用户错误答案', + ELT(1 + FLOOR(RAND() * 5), 'single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'essay'), + e.created_at, + e.created_at +FROM exams e +CROSS JOIN knowledge_points kp +WHERE e.user_id = @user_id + AND kp.course_id = e.course_id + AND kp.is_deleted = FALSE + AND RAND() < (100 - e.round1_score) / 100 -- 分数越低,错题概率越高 +LIMIT 200; + +SELECT CONCAT('✓ 创建了 ', COUNT(*), ' 条错题记录') AS info +FROM exam_mistakes WHERE user_id = @user_id; + +-- ============================================ +-- 5. 生成陪练会话记录 +-- ============================================ +-- 删除旧的陪练记录 +DELETE FROM practice_dialogues WHERE session_id IN ( + SELECT session_id FROM practice_sessions WHERE user_id = @user_id +); +DELETE FROM practice_reports WHERE session_id IN ( + SELECT session_id FROM practice_sessions WHERE user_id = @user_id +); +DELETE FROM practice_sessions WHERE user_id = @user_id; + +-- 生成陪练会话 +INSERT INTO practice_sessions ( + session_id, user_id, scene_id, scene_name, scene_type, + start_time, end_time, duration_seconds, turns, status, is_deleted, + created_at, updated_at +) +SELECT + CONCAT('session_', @user_id, '_', UNIX_TIMESTAMP(start_dt)), + @user_id, + 1 + FLOOR(RAND() * 5), + ELT(1 + FLOOR(RAND() * 5), '电话销售', '面对面咨询', '客户投诉处理', '售后服务', '产品介绍'), + ELT(1 + FLOOR(RAND() * 5), 'phone', 'face', 'complaint', 'after-sales', 'product-intro'), + start_dt, + DATE_ADD(start_dt, INTERVAL (600 + FLOOR(RAND() * 1200)) SECOND), + 600 + FLOOR(RAND() * 1200), + 10 + FLOOR(RAND() * 20), + 'completed', + FALSE, + start_dt, + start_dt +FROM ( + SELECT DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 60) DAY) - INTERVAL FLOOR(RAND() * 86400) SECOND AS start_dt + FROM ( + SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 + UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 + UNION ALL SELECT 11 UNION ALL SELECT 12 UNION ALL SELECT 13 UNION ALL SELECT 14 UNION ALL SELECT 15 + ) AS numbers +) AS dates; + +SELECT CONCAT('✓ 创建了 ', COUNT(*), ' 条陪练会话记录') AS info +FROM practice_sessions WHERE user_id = @user_id; + +-- ============================================ +-- 6. 统计汇总 +-- ============================================ +SELECT '========================================' AS ''; +SELECT '✅ 数据生成完成!' AS ''; +SELECT '========================================' AS ''; + +SELECT CONCAT('用户: ', username, ' (ID: ', id, ')') AS info +FROM users WHERE id = @user_id; + +SELECT CONCAT('考试记录: ', COUNT(*), ' 条') AS info +FROM exams WHERE user_id = @user_id; + +SELECT CONCAT('错题记录: ', COUNT(*), ' 条') AS info +FROM exam_mistakes WHERE user_id = @user_id; + +SELECT CONCAT('陪练记录: ', COUNT(*), ' 条') AS info +FROM practice_sessions WHERE user_id = @user_id; + +SELECT '========================================' AS ''; +SELECT '现在可以访问统计分析页面查看数据:' AS ''; +SELECT 'http://localhost:5173/analysis/statistics' AS ''; +SELECT '========================================' AS ''; + +SET FOREIGN_KEY_CHECKS = 1; + diff --git a/backend/scripts/seed_statistics_demo_data_v2.sql b/backend/scripts/seed_statistics_demo_data_v2.sql new file mode 100644 index 0000000..e37b9b2 --- /dev/null +++ b/backend/scripts/seed_statistics_demo_data_v2.sql @@ -0,0 +1,207 @@ +-- 为统计分析页面生成轻医美场景的模拟数据(简化版) +-- 使用方法: docker-compose -f docker-compose.dev.yml exec -T mysql-dev mysql -u root -p'nj861021' kaopeilian < kaopeilian-backend/scripts/seed_statistics_demo_data_v2.sql + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ============================================ +-- 1. 获取用户ID(使用admin用户) +-- ============================================ +SET @user_id = (SELECT id FROM users WHERE username = 'admin' LIMIT 1); +SET @user_id = IFNULL(@user_id, (SELECT id FROM users WHERE role = 'trainee' LIMIT 1)); +SET @user_id = IFNULL(@user_id, 1); + +SELECT CONCAT('✓ 使用用户: ', (SELECT username FROM users WHERE id = @user_id), ' (ID: ', @user_id, ')') AS ''; + +-- ============================================ +-- 2. 清理旧数据 +-- ============================================ +DELETE FROM exam_mistakes WHERE user_id = @user_id; +DELETE FROM exams WHERE user_id = @user_id; +DELETE FROM practice_dialogues WHERE session_id IN ( + SELECT session_id FROM practice_sessions WHERE user_id = @user_id +); +DELETE FROM practice_reports WHERE session_id IN ( + SELECT session_id FROM practice_sessions WHERE user_id = @user_id +); +DELETE FROM practice_sessions WHERE user_id = @user_id; + +SELECT '✓ 旧数据清理完成' AS ''; + +-- ============================================ +-- 3. 生成考试记录(过去60天,呈现进步趋势) +-- ============================================ +-- 使用临时表生成日期序列 +DROP TEMPORARY TABLE IF EXISTS date_series; +CREATE TEMPORARY TABLE date_series ( + day_offset INT, + exam_date DATETIME, + progress_factor DECIMAL(5,2) +); + +-- 生成60天的日期,30%的天数有考试 +INSERT INTO date_series (day_offset, exam_date, progress_factor) +SELECT + n.day_offset, + DATE_SUB(NOW(), INTERVAL n.day_offset DAY) + INTERVAL FLOOR(RAND() * 43200) SECOND AS exam_date, + (60 - n.day_offset) / 60 AS progress_factor +FROM ( + SELECT 1 AS day_offset UNION ALL SELECT 3 UNION ALL SELECT 5 UNION ALL SELECT 7 UNION ALL SELECT 9 + UNION ALL SELECT 11 UNION ALL SELECT 13 UNION ALL SELECT 15 UNION ALL SELECT 17 UNION ALL SELECT 19 + UNION ALL SELECT 21 UNION ALL SELECT 23 UNION ALL SELECT 25 UNION ALL SELECT 27 UNION ALL SELECT 29 + UNION ALL SELECT 31 UNION ALL SELECT 33 UNION ALL SELECT 35 UNION ALL SELECT 37 UNION ALL SELECT 39 + UNION ALL SELECT 41 UNION ALL SELECT 43 UNION ALL SELECT 45 UNION ALL SELECT 47 UNION ALL SELECT 49 + UNION ALL SELECT 51 UNION ALL SELECT 53 UNION ALL SELECT 55 UNION ALL SELECT 57 UNION ALL SELECT 59 +) AS n; + +-- 生成考试记录 +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, round1_score, score, is_passed, status, + created_at, updated_at +) +SELECT + @user_id, + c.id, + CONCAT(c.name, '测试'), + 10, + 100.0, + 60.0, + ds.exam_date, + DATE_ADD(ds.exam_date, INTERVAL (20 + FLOOR(RAND() * 25)) MINUTE), + 20 + FLOOR(RAND() * 25), + -- 分数呈现进步趋势:基础分60分,根据时间进度增加0-35分 + ROUND(60 + (ds.progress_factor * 20) + (RAND() * 15), 1) AS round1_score, + ROUND(60 + (ds.progress_factor * 20) + (RAND() * 15), 1) AS score, + TRUE, + 'submitted', + ds.exam_date, + ds.exam_date +FROM date_series ds +CROSS JOIN courses c +WHERE c.is_deleted = FALSE AND c.status = 'published' +ORDER BY RAND() +LIMIT 50; + +-- 更新is_passed状态 +UPDATE exams SET is_passed = (round1_score >= pass_score) WHERE user_id = @user_id; + +SELECT CONCAT('✓ 创建了 ', COUNT(*), ' 条考试记录') AS '' +FROM exams WHERE user_id = @user_id; + +-- ============================================ +-- 4. 生成错题记录(增强版 - 确保足够的错题数据) +-- ============================================ +-- 为每个考试生成3-5个错题,不管分数高低 +-- 这样可以确保有足够的统计样本 + +-- 方法:为每个考试生成多个错题 +INSERT INTO exam_mistakes ( + user_id, exam_id, question_id, knowledge_point_id, + question_content, correct_answer, user_answer, question_type, + created_at, updated_at +) +SELECT + e.user_id, + e.id, + NULL, + kp.id, + CONCAT('关于"', kp.name, '"的', + ELT(1 + FLOOR(RAND() * 3), '概念理解', '实际应用', '综合分析'), '问题'), + CONCAT('正确答案:', kp.name, '的标准解释'), + CONCAT('错误理解:', '学员对', kp.name, '的误解'), + ELT(1 + FLOOR(RAND() * 5), 'single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'essay'), + e.start_time, + e.start_time +FROM exams e +CROSS JOIN ( + SELECT 1 AS mistake_num UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 +) AS mistake_counts +INNER JOIN knowledge_points kp ON kp.course_id = e.course_id +WHERE e.user_id = @user_id + AND kp.is_deleted = FALSE + -- 高分考试(90+)生成1-2个错题 + AND ( + (e.round1_score >= 90 AND mistake_counts.mistake_num <= 2 AND RAND() < 0.5) + -- 中等分数(80-90)生成2-3个错题 + OR (e.round1_score >= 80 AND e.round1_score < 90 AND mistake_counts.mistake_num <= 3 AND RAND() < 0.7) + -- 一般分数(70-80)生成3-4个错题 + OR (e.round1_score >= 70 AND e.round1_score < 80 AND mistake_counts.mistake_num <= 4 AND RAND() < 0.8) + -- 低分(<70)生成4-5个错题 + OR (e.round1_score < 70 AND mistake_counts.mistake_num <= 5 AND RAND() < 0.9) + ) +ORDER BY RAND() +LIMIT 250; + +SELECT CONCAT('✓ 创建了 ', COUNT(*), ' 条错题记录') AS '' +FROM exam_mistakes WHERE user_id = @user_id; + +-- ============================================ +-- 5. 生成陪练会话记录 +-- ============================================ +INSERT INTO practice_sessions ( + session_id, user_id, scene_id, scene_name, scene_type, + start_time, end_time, duration_seconds, turns, status, is_deleted, + created_at, updated_at +) +SELECT + CONCAT('session_', @user_id, '_', UNIX_TIMESTAMP(practice_dt)), + @user_id, + 1 + FLOOR(RAND() * 5), + ELT(1 + FLOOR(RAND() * 5), '电话销售陪练', '面对面咨询陪练', '客户投诉处理陪练', '售后服务陪练', '产品介绍陪练'), + ELT(1 + FLOOR(RAND() * 5), 'phone', 'face', 'complaint', 'after-sales', 'product-intro'), + practice_dt, + DATE_ADD(practice_dt, INTERVAL duration_sec SECOND), + duration_sec, + 12 + FLOOR(RAND() * 18), + 'completed', + FALSE, + practice_dt, + practice_dt +FROM ( + SELECT + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 60) DAY) + INTERVAL FLOOR(RAND() * 43200) SECOND AS practice_dt, + 900 + FLOOR(RAND() * 900) AS duration_sec + FROM ( + SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 + UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 + UNION ALL SELECT 11 UNION ALL SELECT 12 UNION ALL SELECT 13 UNION ALL SELECT 14 UNION ALL SELECT 15 + UNION ALL SELECT 16 UNION ALL SELECT 17 UNION ALL SELECT 18 + ) AS numbers +) AS practice_dates; + +SELECT CONCAT('✓ 创建了 ', COUNT(*), ' 条陪练会话记录') AS '' +FROM practice_sessions WHERE user_id = @user_id; + +-- ============================================ +-- 6. 统计汇总 +-- ============================================ +SELECT '========================================' AS ''; +SELECT '✅ 数据生成完成!' AS ''; +SELECT '========================================' AS ''; + +SELECT CONCAT('用户: ', username, ' (ID: ', id, ')') AS '' +FROM users WHERE id = @user_id; + +SELECT CONCAT('✓ 考试记录: ', COUNT(*), ' 条') AS '' +FROM exams WHERE user_id = @user_id; + +SELECT CONCAT('✓ 错题记录: ', COUNT(*), ' 条') AS '' +FROM exam_mistakes WHERE user_id = @user_id; + +SELECT CONCAT('✓ 陪练记录: ', COUNT(*), ' 条') AS '' +FROM practice_sessions WHERE user_id = @user_id; + +SELECT CONCAT('✓ 知识点数量: ', COUNT(*), ' 个') AS '' +FROM knowledge_points WHERE is_deleted = FALSE; + +SELECT '========================================' AS ''; +SELECT '现在可以访问统计分析页面查看数据:' AS ''; +SELECT 'http://localhost:5173/analysis/statistics' AS ''; +SELECT '========================================' AS ''; + +-- 清理临时表 +DROP TEMPORARY TABLE IF EXISTS date_series; + +SET FOREIGN_KEY_CHECKS = 1; + diff --git a/backend/scripts/seed_statistics_for_user6.sql b/backend/scripts/seed_statistics_for_user6.sql new file mode 100644 index 0000000..2851f15 --- /dev/null +++ b/backend/scripts/seed_statistics_for_user6.sql @@ -0,0 +1,172 @@ +-- ============================================== +-- 为 user_id=6 (nurse_001) 生成统计分析数据 +-- ============================================== + +USE kaopeilian; + +SET @user_id = 6; +SET @user_name = (SELECT username FROM users WHERE id = @user_id); + +SELECT CONCAT('开始为用户 ', @user_name, ' (ID: ', @user_id, ') 生成统计数据') AS ''; + +-- ============================================ +-- 1. 生成考试记录 (50条) +-- ============================================ +-- 保留原有的3条记录,再生成47条 +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, pass_score, + questions, start_time, end_time, status, duration_minutes, + round1_score, is_passed, created_at, updated_at +) +SELECT + @user_id, + c.id, + CONCAT(c.name, '练习测试_', DATE_FORMAT(exam_dt, '%m月%d日')), + 10, + 60, + '[]', + exam_dt, + DATE_ADD(exam_dt, INTERVAL FLOOR(20 + RAND() * 40) MINUTE), + 'completed', + FLOOR(20 + RAND() * 40), -- 20-60分钟 + FLOOR(55 + RAND() * 45), -- 55-100分 + 1, + exam_dt, + exam_dt +FROM ( + SELECT DATE_SUB(NOW(), INTERVAL days DAY) + INTERVAL hours HOUR + INTERVAL minutes MINUTE as exam_dt + FROM ( + SELECT 0 as days UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL + SELECT 5 UNION ALL SELECT 7 UNION ALL SELECT 9 UNION ALL SELECT 12 UNION ALL SELECT 15 + ) d, + (SELECT 9 as hours UNION ALL SELECT 10 UNION ALL SELECT 14 UNION ALL SELECT 15 UNION ALL SELECT 16) h, + (SELECT 0 as minutes UNION ALL SELECT 15 UNION ALL SELECT 30 UNION ALL SELECT 45) m +) exam_dates +CROSS JOIN ( + SELECT id, name FROM courses WHERE is_deleted = FALSE LIMIT 3 +) c +LIMIT 47; + +-- 更新 is_passed 标志 +UPDATE exams SET is_passed = (round1_score >= pass_score) WHERE user_id = @user_id AND is_passed = 0; + +SELECT CONCAT('✓ 当前考试记录总数: ', COUNT(*), ' 条') AS '' +FROM exams WHERE user_id = @user_id; + +-- ============================================ +-- 2. 生成错题记录 (250条) +-- ============================================ +-- 删除旧的错题记录,重新生成 +DELETE FROM exam_mistakes WHERE user_id = @user_id; + +-- 为每个考试生成3-6个错题 +INSERT INTO exam_mistakes ( + user_id, exam_id, question_id, knowledge_point_id, + question_content, correct_answer, user_answer, question_type, + created_at, updated_at +) +SELECT + e.user_id, + e.id, + NULL, + kp.id, + CONCAT('关于"', kp.name, '"的', + ELT(1 + FLOOR(RAND() * 3), '概念理解', '实际应用', '综合分析'), '问题'), + CONCAT('正确答案:', kp.name, '的标准解释'), + CONCAT('错误理解:', '学员对', kp.name, '的误解'), + ELT(1 + FLOOR(RAND() * 5), 'single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'essay'), + e.start_time, + e.start_time +FROM exams e +CROSS JOIN ( + SELECT 1 AS mistake_num UNION ALL SELECT 2 UNION ALL SELECT 3 + UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 +) AS mistake_counts +INNER JOIN knowledge_points kp ON kp.course_id = e.course_id +WHERE e.user_id = @user_id + AND kp.is_deleted = FALSE + AND RAND() < 0.8 +ORDER BY RAND() +LIMIT 250; + +SELECT CONCAT('✓ 错题记录: ', COUNT(*), ' 条') AS '' +FROM exam_mistakes WHERE user_id = @user_id; + +-- ============================================ +-- 3. 生成陪练记录 (20条) +-- ============================================ +-- 删除旧的陪练记录 +DELETE FROM practice_dialogues WHERE session_id IN ( + SELECT session_id FROM practice_sessions WHERE user_id = @user_id +); +DELETE FROM practice_reports WHERE session_id IN ( + SELECT session_id FROM practice_sessions WHERE user_id = @user_id +); +DELETE FROM practice_sessions WHERE user_id = @user_id; + +-- 生成新的陪练记录 +INSERT INTO practice_sessions ( + session_id, user_id, scene_id, scene_name, + duration_seconds, status, + start_time, end_time, created_at, updated_at +) +SELECT + CONCAT('session_', @user_id, '_', UNIX_TIMESTAMP(practice_dt)), + @user_id, + 1 + FLOOR(RAND() * 5), + ELT(1 + FLOOR(RAND() * 5), '电话销售陪练', '面对面咨询陪练', '客户投诉处理陪练', '售后服务陪练', '产品介绍陪练'), + FLOOR(600 + RAND() * 3000), -- 600-3600秒 (10-60分钟) + 'completed', + practice_dt, + DATE_ADD(practice_dt, INTERVAL FLOOR(10 + RAND() * 50) MINUTE), + practice_dt, + practice_dt +FROM ( + SELECT DATE_SUB(NOW(), INTERVAL days DAY) + INTERVAL hours HOUR as practice_dt + FROM ( + SELECT 0 as days UNION ALL SELECT 1 UNION ALL SELECT 3 UNION ALL SELECT 5 UNION ALL + SELECT 7 UNION ALL SELECT 10 UNION ALL SELECT 12 UNION ALL SELECT 15 UNION ALL SELECT 20 + ) d, + (SELECT 10 as hours UNION ALL SELECT 14 UNION ALL SELECT 16) h +) practice_dates +LIMIT 20; + +SELECT CONCAT('✓ 陪练记录: ', COUNT(*), ' 条') AS '' +FROM practice_sessions WHERE user_id = @user_id; + +-- ============================================ +-- 4. 统计概览 +-- ============================================ +SELECT '========================================' AS ''; +SELECT '数据生成完成' AS ''; +SELECT '========================================' AS ''; + +SELECT CONCAT('用户: ', username, ' (', role, ')') AS '' +FROM users WHERE id = @user_id; + +SELECT CONCAT('✓ 考试记录: ', COUNT(*), ' 条') AS '' +FROM exams WHERE user_id = @user_id; + +SELECT CONCAT('✓ 错题记录: ', COUNT(*), ' 条') AS '' +FROM exam_mistakes WHERE user_id = @user_id; + +SELECT CONCAT('✓ 陪练记录: ', COUNT(*), ' 条') AS '' +FROM practice_sessions WHERE user_id = @user_id; + +SELECT CONCAT('✓ 知识点数量: ', COUNT(*), ' 个') AS '' +FROM knowledge_points WHERE is_deleted = FALSE; + +-- 成绩分布 +SELECT '========================================' AS ''; +SELECT '成绩分布情况' AS ''; +SELECT '========================================' AS ''; +SELECT + CONCAT('优秀(90-100): ', SUM(CASE WHEN round1_score >= 90 THEN 1 ELSE 0 END), ' 场') AS '', + CONCAT('良好(80-89): ', SUM(CASE WHEN round1_score >= 80 AND round1_score < 90 THEN 1 ELSE 0 END), ' 场') AS '', + CONCAT('中等(70-79): ', SUM(CASE WHEN round1_score >= 70 AND round1_score < 80 THEN 1 ELSE 0 END), ' 场') AS '', + CONCAT('及格(60-69): ', SUM(CASE WHEN round1_score >= 60 AND round1_score < 70 THEN 1 ELSE 0 END), ' 场') AS '', + CONCAT('不及格(<60): ', SUM(CASE WHEN round1_score < 60 THEN 1 ELSE 0 END), ' 场') AS '' +FROM exams WHERE user_id = @user_id; + +SELECT '✓ 数据生成成功,可以刷新统计分析页面查看' AS ''; + diff --git a/backend/scripts/simple_init.py b/backend/scripts/simple_init.py new file mode 100644 index 0000000..29d89f7 --- /dev/null +++ b/backend/scripts/simple_init.py @@ -0,0 +1,83 @@ +""" +简单的初始化脚本 +""" +import sqlite3 +import os + +# 创建数据库文件 +db_path = 'kaopeilian.db' + +# 如果数据库已存在,删除它 +if os.path.exists(db_path): + os.remove(db_path) + +# 连接数据库 +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +# 创建teams表 +cursor.execute(''' +CREATE TABLE IF NOT EXISTS teams ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL UNIQUE, + code VARCHAR(50) NOT NULL UNIQUE, + description TEXT, + team_type VARCHAR(50) DEFAULT 'department', + is_active BOOLEAN DEFAULT 1, + leader_id INTEGER, + parent_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_deleted BOOLEAN DEFAULT 0, + deleted_at DATETIME, + created_by INTEGER, + updated_by INTEGER, + FOREIGN KEY (parent_id) REFERENCES teams(id), + FOREIGN KEY (leader_id) REFERENCES users(id) +) +''') + +# 创建users表 +cursor.execute(''' +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(100) NOT NULL UNIQUE, + phone VARCHAR(20) UNIQUE, + hashed_password VARCHAR(200) NOT NULL, + full_name VARCHAR(100), + avatar_url VARCHAR(500), + bio TEXT, + role VARCHAR(20) DEFAULT 'trainee', + is_active BOOLEAN DEFAULT 1, + is_verified BOOLEAN DEFAULT 0, + last_login_at DATETIME, + password_changed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_deleted BOOLEAN DEFAULT 0, + deleted_at DATETIME, + created_by INTEGER, + updated_by INTEGER +) +''') + +# 创建user_teams关联表 +cursor.execute(''' +CREATE TABLE IF NOT EXISTS user_teams ( + user_id INTEGER NOT NULL, + team_id INTEGER NOT NULL, + role VARCHAR(50) DEFAULT 'member', + joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, team_id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (team_id) REFERENCES teams(id) +) +''') + +# 提交更改 +conn.commit() +conn.close() + +print("✓ SQLite数据库初始化成功!") +print(f"✓ 数据库文件: {os.path.abspath(db_path)}") diff --git a/backend/scripts/simple_rollback.py b/backend/scripts/simple_rollback.py new file mode 100644 index 0000000..fb3602e --- /dev/null +++ b/backend/scripts/simple_rollback.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +""" +考培练系统 - 简化数据库回滚工具 +基于MySQL Binlog的快速回滚方案 + +使用方法: +1. 查看Binlog文件: python scripts/simple_rollback.py --list +2. 模拟回滚: python scripts/simple_rollback.py --time "2024-12-20 10:30:00" +3. 实际回滚: python scripts/simple_rollback.py --time "2024-12-20 10:30:00" --execute +""" + +import asyncio +import argparse +import subprocess +import tempfile +import os +from datetime import datetime +from pathlib import Path +import aiomysql +import logging + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +class SimpleRollbackTool: + """简化回滚工具""" + + def __init__(self): + self.host = "localhost" + self.port = 3306 + self.user = "root" + self.password = "root" + self.database = "kaopeilian" + self.connection = None + + async def connect(self): + """连接数据库""" + try: + self.connection = await aiomysql.connect( + host=self.host, + port=self.port, + user=self.user, + password=self.password, + db=self.database, + charset='utf8mb4' + ) + logger.info("✅ 数据库连接成功") + except Exception as e: + logger.error(f"❌ 数据库连接失败: {e}") + raise + + async def close(self): + """关闭连接""" + if self.connection: + self.connection.close() + + async def list_binlogs(self): + """列出Binlog文件""" + cursor = await self.connection.cursor() + await cursor.execute("SHOW BINARY LOGS") + result = await cursor.fetchall() + await cursor.close() + + print("\n📋 可用的Binlog文件:") + print("-" * 60) + for i, row in enumerate(result, 1): + print(f"{i:2d}. {row[0]} ({row[1]} bytes)") + print("-" * 60) + + def extract_sql_from_binlog(self, binlog_file: str, start_time: str) -> str: + """从Binlog提取SQL语句""" + # 使用mysqlbinlog工具解析 + cmd = [ + 'docker', 'exec', 'kaopeilian-mysql', + 'mysqlbinlog', + '--base64-output=decode-rows', + '-v', + '--start-datetime', start_time, + '--database', self.database, + f'/var/lib/mysql/{binlog_file}' + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + if result.returncode == 0: + return result.stdout + else: + logger.error(f"mysqlbinlog执行失败: {result.stderr}") + return "" + except Exception as e: + logger.error(f"执行mysqlbinlog异常: {e}") + return "" + + def generate_rollback_sql(self, binlog_content: str) -> list: + """生成回滚SQL语句""" + rollback_sqls = [] + + # 简单的SQL解析和反转 + lines = binlog_content.split('\n') + current_table = None + + for line in lines: + line = line.strip() + + # 检测表名 + if '### UPDATE' in line and '`' in line: + table_match = line.split('`')[1] if '`' in line else None + if table_match: + current_table = table_match + + # 检测INSERT操作,生成DELETE + elif '### INSERT INTO' in line and '`' in line: + table_match = line.split('`')[1] if '`' in line else None + if table_match: + current_table = table_match + + # 检测DELETE操作,生成INSERT + elif '### DELETE FROM' in line and '`' in line: + table_match = line.split('`')[1] if '`' in line else None + if table_match: + current_table = table_match + + # 检测WHERE条件 + elif '### WHERE' in line and current_table: + # 提取WHERE条件 + where_part = line.replace('### WHERE', '').strip() + if where_part: + rollback_sqls.append(f"-- 需要手动处理 {current_table} 表的回滚") + rollback_sqls.append(f"-- WHERE条件: {where_part}") + + return rollback_sqls + + async def create_backup_before_rollback(self) -> str: + """回滚前创建备份""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_file = f"/tmp/kaopeilian_backup_{timestamp}.sql" + + # 使用mysqldump创建备份 + cmd = [ + 'docker', 'exec', 'kaopeilian-mysql', + 'mysqldump', + '-uroot', '-proot', + '--single-transaction', + '--routines', + '--triggers', + self.database + ] + + try: + with open(backup_file, 'w') as f: + result = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, text=True) + if result.returncode == 0: + logger.info(f"✅ 备份已创建: {backup_file}") + return backup_file + else: + logger.error(f"❌ 备份失败: {result.stderr}") + return "" + except Exception as e: + logger.error(f"❌ 备份异常: {e}") + return "" + + async def rollback_by_time(self, target_time: str, execute: bool = False): + """根据时间点回滚""" + logger.info(f"🎯 开始回滚到时间点: {target_time}") + + # 获取最新的Binlog文件 + cursor = await self.connection.cursor() + await cursor.execute("SHOW BINARY LOGS") + binlog_files = await cursor.fetchall() + await cursor.close() + + if not binlog_files: + logger.error("❌ 未找到Binlog文件") + return + + # 使用最新的Binlog文件 + latest_binlog = binlog_files[-1][0] + logger.info(f"📁 使用Binlog文件: {latest_binlog}") + + # 提取SQL + binlog_content = self.extract_sql_from_binlog(latest_binlog, target_time) + if not binlog_content: + logger.error("❌ 无法从Binlog提取SQL") + return + + # 生成回滚SQL + rollback_sqls = self.generate_rollback_sql(binlog_content) + + if not rollback_sqls: + logger.warning("⚠️ 未找到需要回滚的操作") + return + + print("\n🔄 回滚SQL语句:") + print("-" * 60) + for i, sql in enumerate(rollback_sqls, 1): + print(f"{i:2d}. {sql}") + print("-" * 60) + + if not execute: + logger.info("🔍 这是模拟执行,使用 --execute 参数实际执行") + return + + # 创建备份 + backup_file = await self.create_backup_before_rollback() + if not backup_file: + logger.error("❌ 备份失败,取消回滚操作") + return + + # 确认执行 + confirm = input("\n⚠️ 确认执行回滚操作?这将修改数据库数据!(yes/no): ") + if confirm.lower() != 'yes': + logger.info("❌ 用户取消回滚操作") + return + + # 执行回滚(这里需要根据实际情况手动执行SQL) + logger.info("✅ 回滚操作准备完成") + logger.info(f"📁 备份文件位置: {backup_file}") + logger.info("📝 请手动执行上述SQL语句完成回滚") + +async def main(): + """主函数""" + parser = argparse.ArgumentParser(description='考培练系统 - 简化数据库回滚工具') + parser.add_argument('--list', action='store_true', help='列出Binlog文件') + parser.add_argument('--time', help='回滚到的时间点 (格式: YYYY-MM-DD HH:MM:SS)') + parser.add_argument('--execute', action='store_true', help='实际执行回滚') + + args = parser.parse_args() + + tool = SimpleRollbackTool() + + try: + await tool.connect() + + if args.list: + await tool.list_binlogs() + elif args.time: + await tool.rollback_by_time(args.time, args.execute) + else: + parser.print_help() + + except Exception as e: + logger.error(f"❌ 程序执行异常: {e}") + finally: + await tool.close() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/sync_core_tables.py b/backend/scripts/sync_core_tables.py new file mode 100644 index 0000000..3ab9e2f --- /dev/null +++ b/backend/scripts/sync_core_tables.py @@ -0,0 +1,138 @@ +""" +同步核心表结构(teams, user_teams),自动适配 users.id 类型。 + +步骤: +1) 检查 users.id 的数据类型(INT 或 BIGINT) +2) 如缺失则创建 teams、user_teams 表;外键列按兼容类型创建 +3) 如存在则跳过 + +运行: + cd kaopeilian-backend && python3 scripts/sync_core_tables.py +""" +import asyncio +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +from app.core.config import get_settings + + +async def table_exists(conn, db_name: str, table: str) -> bool: + result = await conn.execute( + text( + """ + SELECT COUNT(1) + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table + """ + ), + {"db": db_name, "table": table}, + ) + return (result.scalar() or 0) > 0 + + +async def get_users_id_type(conn, db_name: str) -> str: + result = await conn.execute( + text( + """ + SELECT COLUMN_TYPE + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = :db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'id' + """ + ), + {"db": db_name}, + ) + col_type = result.scalar() or "int" + # 正规化 + col_type = col_type.lower() + if "bigint" in col_type: + return "BIGINT" + return "INT" + + +async def sync_core_tables(): + settings = get_settings() + db_url = settings.DATABASE_URL + db_name = db_url.split("/")[-1].split("?")[0] + + engine = create_async_engine(settings.DATABASE_URL, echo=False) + created = [] + async with engine.begin() as conn: + # 检查 users.id 类型 + user_id_type = await get_users_id_type(conn, db_name) + + # 创建 teams + if not await table_exists(conn, db_name, "teams"): + await conn.execute( + text( + f""" + CREATE TABLE `teams` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL UNIQUE, + `code` VARCHAR(50) NOT NULL UNIQUE, + `description` TEXT, + `team_type` VARCHAR(50) DEFAULT 'department', + `is_active` BOOLEAN DEFAULT TRUE, + `leader_id` {user_id_type}, + `parent_id` INT, + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` BOOLEAN DEFAULT FALSE, + `deleted_at` DATETIME, + `created_by` {user_id_type}, + `updated_by` {user_id_type}, + FOREIGN KEY (`leader_id`) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (`parent_id`) REFERENCES teams(id) ON DELETE CASCADE, + INDEX idx_team_type (team_type), + INDEX idx_is_active (is_active) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + ) + ) + created.append("teams") + + # 创建 user_teams + if not await table_exists(conn, db_name, "user_teams"): + await conn.execute( + text( + f""" + CREATE TABLE `user_teams` ( + `user_id` {user_id_type} NOT NULL, + `team_id` INT NOT NULL, + `role` VARCHAR(50) DEFAULT 'member', + `joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`user_id`, `team_id`), + FOREIGN KEY (`user_id`) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (`team_id`) REFERENCES teams(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + ) + ) + created.append("user_teams") + + await engine.dispose() + return created + + +async def main(): + try: + created = await sync_core_tables() + if created: + print("创建表:", ", ".join(created)) + else: + print("核心表已存在,无需创建。") + except Exception as exc: + import traceback + print("同步失败:", str(exc)) + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) + + diff --git a/backend/scripts/sync_users_table.py b/backend/scripts/sync_users_table.py new file mode 100644 index 0000000..ae80d43 --- /dev/null +++ b/backend/scripts/sync_users_table.py @@ -0,0 +1,94 @@ +""" +同步 users 表缺失列(适配当前模型定义)。 + +- 逐列检查 INFORMATION_SCHEMA,缺失则执行 ALTER TABLE 添加 +- 避免一次性重建表,降低风险 + +运行方式: + cd kaopeilian-backend && python3 scripts/sync_users_table.py +""" +import asyncio +import sys +from pathlib import Path + +# 确保可导入应用配置 +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +from app.core.config import get_settings + + +async def column_exists(conn, db_name: str, table: str, column: str) -> bool: + """检查列是否存在""" + result = await conn.execute( + text( + """ + SELECT COUNT(1) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = :db + AND TABLE_NAME = :table + AND COLUMN_NAME = :column + """ + ), + {"db": db_name, "table": table, "column": column}, + ) + return (result.scalar() or 0) > 0 + + +async def sync_users_table(): + """对 users 表进行列同步(仅添加缺失列)。""" + settings = get_settings() + + # 解析数据库名 + db_url = settings.DATABASE_URL + db_name = db_url.split("/")[-1].split("?")[0] + + engine = create_async_engine(settings.DATABASE_URL, echo=False) + + async with engine.begin() as conn: + # 需要确保存在的列(列名 -> DDL 片段) + required_columns = { + "avatar_url": "ALTER TABLE `users` ADD COLUMN `avatar_url` VARCHAR(500) NULL COMMENT '头像URL'", + "bio": "ALTER TABLE `users` ADD COLUMN `bio` TEXT NULL COMMENT '个人简介'", + "role": "ALTER TABLE `users` ADD COLUMN `role` VARCHAR(20) NOT NULL DEFAULT 'trainee' COMMENT '系统角色'", + "is_active": "ALTER TABLE `users` ADD COLUMN `is_active` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否激活'", + "is_verified": "ALTER TABLE `users` ADD COLUMN `is_verified` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否验证'", + "last_login_at": "ALTER TABLE `users` ADD COLUMN `last_login_at` DATETIME NULL COMMENT '最后登录时间'", + "password_changed_at": "ALTER TABLE `users` ADD COLUMN `password_changed_at` DATETIME NULL COMMENT '密码修改时间'", + "is_deleted": "ALTER TABLE `users` ADD COLUMN `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否删除'", + "deleted_at": "ALTER TABLE `users` ADD COLUMN `deleted_at` DATETIME NULL COMMENT '删除时间'", + } + + applied = [] + for col, ddl in required_columns.items(): + exists = await column_exists(conn, db_name, "users", col) + if not exists: + await conn.execute(text(ddl)) + applied.append(col) + + await engine.dispose() + return applied + + +async def main(): + try: + applied = await sync_users_table() + if applied: + print("添加列:", ", ".join(applied)) + else: + print("users 表结构已满足要求,无需变更。") + except Exception as exc: + # 输出完整错误,便于调试 + import traceback + + print("同步失败:", str(exc)) + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) + + diff --git a/backend/scripts/update_position_descriptions.py b/backend/scripts/update_position_descriptions.py new file mode 100644 index 0000000..9b78be0 --- /dev/null +++ b/backend/scripts/update_position_descriptions.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +更新岗位描述脚本 +为瑞小美轻医美连锁品牌的真实岗位添加专业描述 +""" + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from sqlalchemy import select, update +from app.core.database import AsyncSessionLocal +from app.models.position import Position +from app.core.logger import get_logger + +logger = get_logger(__name__) + +# 轻医美岗位描述映射 +POSITION_DESCRIPTIONS = { + # 医疗专业类 + "护士": "负责医美项目的护理工作,包括术前准备、术中配合、术后护理及客户健康指导", + "护士长": "管理护理团队,制定护理标准流程,确保医疗安全和服务质量", + "副护士长": "协助护士长管理护理团队,负责日常护理工作的督导和质量控制", + "区域医务主任兼护士长": "统筹区域内各门店的医务管理工作,制定护理标准和培训体系", + "储备护士长": "接受护士长岗位培训,准备承担护理团队管理工作", + "皮肤科医生": "提供专业皮肤诊疗服务,制定个性化医美方案,确保客户安全和效果", + "皮肤科助理医生": "协助主治医生进行皮肤诊疗工作,参与医美项目的实施", + "微创技术院长": "负责微创医美技术的研发和应用,带领技术团队提升专业水平", + + # 咨询销售类 + "美学规划师": "为客户提供专业医美咨询,设计个性化美丽方案,促进项目成交", + "美学规划师兼店长": "负责门店运营管理的同时,担任首席美学规划师", + "见习美学规划师": "接受美学规划专业培训,学习咨询技巧和方案设计", + "会员服务经理": "负责VIP会员的全周期服务管理,提升客户满意度和复购率", + "网络咨询专员": "通过线上渠道为客户提供医美咨询服务,引导到店体验", + + # 管理类 + "院长": "全面负责门店运营管理、团队建设、业绩达成和客户服务质量", + "连锁院长": "统筹多家门店的运营管理,制定标准化流程,推动连锁发展", + "店长": "负责门店日常运营管理、团队协调和业绩目标达成", + "区域总经理&人才战略董事&瑞小美学苑苑长": "负责区域战略规划、人才培养体系建设和学苑运营管理", + "微创运营经理": "负责微创项目的运营推广和业绩管理", + "储备总经理助理": "接受高管培训,准备承担门店总经理工作", + + # 客服服务类 + "前厅接待": "负责客户接待、预约管理、环境维护,提供优质的前台服务", + "分诊结算专员": "负责客户分诊引导和费用结算工作,确保流程顺畅", + "客服总监": "统筹客户服务体系建设,制定服务标准,提升客户满意度", + + # 运营支持类 + "保洁员": "负责门店环境卫生维护,确保医美场所的清洁和消毒标准", + "瑞柏美五象总院保洁员": "负责五象总院的环境卫生和消毒管理工作", + "药房兼行政": "负责药品和医疗器械管理,同时协助行政事务处理", + "行政采购": "负责门店物资采购和供应商管理,确保运营物资供应", + + # 市场品牌类 + "小红书运营专员": "负责小红书平台的内容运营和粉丝互动,提升品牌影响力", + "电商运营": "负责线上商城的运营管理,推动电商业务发展", + "医生IP运营": "负责医生个人品牌打造和IP运营,提升医生影响力", + "设计总监": "负责品牌视觉设计、营销物料设计和品牌形象管理", + "平面设计师": "负责平面设计工作,包括海报、宣传册、广告物料等", + "摄影剪辑师": "负责门店的摄影摄像和视频剪辑工作,制作营销内容", + "首席文化传播官": "负责企业文化建设和品牌传播策略制定", + "AI PR": "负责品牌公关工作,运用AI技术提升传播效率", + + # 人力财务类 + "人事经理&瑞小美学苑执行秘书长": "负责人力资源管理和学苑行政工作", + "人事专员&瑞小美学苑执行秘书": "负责人事日常工作和学苑事务协调", + "薪酬服务BP": "负责薪酬福利管理和人力资源业务支持", + "财务经理": "负责财务管理、成本控制和财务报表分析", + "财务专员": "负责日常财务核算、报销审核和账务处理", + "资金管理专员": "负责资金流管理、账户管理和资金调度", + + # 战略发展类 + "战投经理": "负责战略投资项目的评估和推进,支持公司扩张发展", + "商业分析师": "负责业务数据分析和商业模式研究,支持决策制定", + "AI维护程序员": "负责AI系统的维护和优化,支持智能化运营", + + # 高管类 + "总裁": "负责公司整体战略规划和经营管理,带领团队实现发展目标", + "区域经理": "负责区域内多家门店的运营管理和业绩达成", +} + + +async def update_position_descriptions(): + """更新岗位描述""" + logger.info("=" * 60) + logger.info("开始更新岗位描述") + logger.info("=" * 60) + + async with AsyncSessionLocal() as db: + try: + # 查询所有未删除的岗位 + stmt = select(Position).where(Position.is_deleted == False) + result = await db.execute(stmt) + positions = result.scalars().all() + + logger.info(f"找到 {len(positions)} 个岗位") + + updated_count = 0 + for position in positions: + # 查找匹配的描述 + description = POSITION_DESCRIPTIONS.get(position.name) + + if description: + position.description = description + logger.info(f"✓ 更新岗位: {position.name}") + updated_count += 1 + else: + # 如果没有匹配的描述,使用通用描述 + position.description = f"{position.name}岗位,负责相关专业工作" + logger.info(f"○ 使用通用描述: {position.name}") + updated_count += 1 + + # 提交更新 + await db.commit() + + logger.info("=" * 60) + logger.info(f"✅ 成功更新 {updated_count} 个岗位描述") + logger.info("=" * 60) + + except Exception as e: + logger.error(f"更新失败: {str(e)}") + await db.rollback() + raise + + +if __name__ == "__main__": + asyncio.run(update_position_descriptions()) + + diff --git a/backend/setup.cfg b/backend/setup.cfg new file mode 100644 index 0000000..8e99e66 --- /dev/null +++ b/backend/setup.cfg @@ -0,0 +1,40 @@ +[flake8] +max-line-length = 88 +max-complexity = 10 +extend-ignore = E203, W503 +exclude = .git,__pycache__,venv,migrations + +[mypy] +python_version = 3.8 +warn_return_any = True +warn_unused_configs = True +disallow_untyped_defs = True +disallow_incomplete_defs = True +check_untyped_defs = True +disallow_untyped_decorators = True +no_implicit_optional = True +warn_redundant_casts = True +warn_unused_ignores = True +warn_no_return = True +warn_unreachable = True +strict_equality = True + +[isort] +profile = black +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +ensure_newline_before_comments = True +line_length = 88 + +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --strict-markers --tb=short +markers = + unit: Unit tests + integration: Integration tests + e2e: End-to-end tests diff --git a/backend/simple_main.py b/backend/simple_main.py new file mode 100644 index 0000000..98e47ae --- /dev/null +++ b/backend/simple_main.py @@ -0,0 +1,225 @@ +""" +简化的主应用 - 修复前端集成问题 +""" +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +import mysql.connector +import hashlib +import jwt +from datetime import datetime, timedelta +from typing import Optional + +app = FastAPI( + title="考培练系统API", + version="1.0.0", + description="用户管理和认证系统" +) + +# 配置CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# JWT配置 +SECRET_KEY = "dev-secret-key-change-in-production" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + +# 数据库配置 +DB_CONFIG = { + 'host': '127.0.0.1', + 'user': 'root', + 'password': '', + 'database': 'kaopeilian', + 'charset': 'utf8mb4', + 'autocommit': True +} + +# Pydantic模型 +class LoginRequest(BaseModel): + username: str + password: str + +class RefreshTokenRequest(BaseModel): + refresh_token: str + +class ResponseModel(BaseModel): + code: int = 200 + message: str + data: Optional[dict] = None + +# 辅助函数 +def hash_password(password: str) -> str: + return hashlib.sha256(password.encode()).hexdigest() + +def create_access_token(user_id: int) -> str: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode = {"sub": str(user_id), "exp": expire, "type": "access"} + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + +def create_refresh_token(user_id: int) -> str: + expire = datetime.utcnow() + timedelta(days=7) + to_encode = {"sub": str(user_id), "exp": expire, "type": "refresh"} + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + +def get_db_connection(): + """获取数据库连接""" + try: + return mysql.connector.connect(**DB_CONFIG) + except mysql.connector.Error as e: + raise HTTPException(status_code=500, detail=f"数据库连接失败: {str(e)}") + +# API路由 +@app.get("/") +def read_root(): + return {"message": "考培练系统API", "version": "1.0.0", "status": "running"} + +@app.get("/health") +def health_check(): + return {"status": "healthy", "service": "kaopeilian-api", "timestamp": datetime.utcnow().isoformat()} + +@app.post("/api/v1/auth/login") +def login(login_data: LoginRequest): + """用户登录""" + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # 查询用户 + cursor.execute( + "SELECT id, username, email, hashed_password, full_name, role, is_active, is_verified FROM users WHERE username = %s AND is_deleted = 0", + (login_data.username,) + ) + user = cursor.fetchone() + + if not user: + raise HTTPException(status_code=400, detail="用户名或密码错误") + + # 验证密码 + if user['hashed_password'] != hash_password(login_data.password): + raise HTTPException(status_code=400, detail="用户名或密码错误") + + if not user['is_active']: + raise HTTPException(status_code=400, detail="用户已被禁用") + + # 生成令牌 + access_token = create_access_token(user['id']) + refresh_token = create_refresh_token(user['id']) + + # 更新最后登录时间 + cursor.execute( + "UPDATE users SET last_login_at = NOW() WHERE id = %s", + (user['id'],) + ) + + cursor.close() + conn.close() + + return ResponseModel( + message="登录成功", + data={ + "user": { + "id": user['id'], + "username": user['username'], + "email": user['email'], + "full_name": user['full_name'], + "role": user['role'], + "is_active": bool(user['is_active']), + "is_verified": bool(user['is_verified']) + }, + "token": { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer" + } + } + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") + +@app.post("/api/v1/auth/refresh") +def refresh_token(refresh_data: RefreshTokenRequest): + """刷新访问令牌""" + try: + # 解码刷新令牌 + payload = jwt.decode(refresh_data.refresh_token, SECRET_KEY, algorithms=[ALGORITHM]) + + if payload.get("type") != "refresh": + raise HTTPException(status_code=400, detail="无效的刷新令牌") + + user_id = int(payload.get("sub")) + + # 生成新的访问令牌 + access_token = create_access_token(user_id) + + return ResponseModel( + message="令牌刷新成功", + data={ + "access_token": access_token, + "refresh_token": refresh_data.refresh_token, + "token_type": "bearer" + } + ) + + except jwt.PyJWTError: + raise HTTPException(status_code=400, detail="无效的刷新令牌") + except Exception as e: + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") + +@app.get("/api/v1/users/me") +def get_current_user_info(): + """获取当前用户信息""" + # 简化版本,返回管理员信息 + return ResponseModel( + message="获取成功", + data={ + "id": 1, + "username": "admin", + "email": "admin@test.com", + "full_name": "系统管理员", + "role": "admin", + "is_active": True, + "is_verified": True, + "created_at": "2024-01-01T00:00:00" + } + ) + +@app.get("/api/v1/users") +def list_users(): + """获取用户列表""" + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute( + "SELECT id, username, email, full_name, role, is_active, created_at FROM users WHERE is_deleted = 0 ORDER BY id" + ) + users = cursor.fetchall() + + cursor.close() + conn.close() + + return ResponseModel( + message="获取成功", + data={ + "items": users, + "total": len(users), + "page": 1, + "page_size": len(users) + } + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/backend/simple_test.py b/backend/simple_test.py new file mode 100644 index 0000000..c4079d2 --- /dev/null +++ b/backend/simple_test.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" +最简单的测试 +""" +import requests +import json + +def test_api(): + """测试API""" + try: + # 测试健康检查 + health_response = requests.get("http://localhost:8000/health") + print(f"健康检查: {health_response.status_code} - {health_response.text}") + + # 测试登录 + login_data = { + "username": "testuser", + "password": "TestPass123!" + } + + login_response = requests.post( + "http://localhost:8000/api/v1/auth/login", + json=login_data, + headers={"Content-Type": "application/json"} + ) + + print(f"登录请求: {login_response.status_code}") + print(f"响应头: {dict(login_response.headers)}") + print(f"响应内容: {login_response.text}") + + if login_response.status_code == 500: + print("❌ 服务器内部错误") + elif login_response.status_code == 200: + print("✅ 登录成功") + else: + print(f"⚠️ 其他状态码: {login_response.status_code}") + + except Exception as e: + print(f"❌ 测试失败: {e}") + +if __name__ == "__main__": + test_api() diff --git a/backend/start.sh b/backend/start.sh new file mode 100644 index 0000000..a064430 --- /dev/null +++ b/backend/start.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}考培练系统后端启动脚本${NC}" +echo "================================" + +# 检查Python版本 +echo -e "${YELLOW}检查Python版本...${NC}" +python_version=$(python3 --version 2>&1) +if [[ $? -eq 0 ]]; then + echo -e "${GREEN}✓ $python_version${NC}" +else + echo -e "${RED}✗ Python3未安装${NC}" + exit 1 +fi + +# 检查虚拟环境 +if [ ! -d "venv" ]; then + echo -e "${YELLOW}创建虚拟环境...${NC}" + python3 -m venv venv +fi + +# 激活虚拟环境 +echo -e "${YELLOW}激活虚拟环境...${NC}" +source venv/bin/activate + +# 安装依赖 +echo -e "${YELLOW}安装依赖...${NC}" +pip install -q -r requirements/base.txt + +# 检查.env文件 +if [ ! -f ".env" ]; then + echo -e "${YELLOW}创建.env文件...${NC}" + cp .env.example .env + echo -e "${GREEN}✓ 已创建.env文件,请根据需要修改配置${NC}" +fi + +# 检查数据库连接 +echo -e "${YELLOW}检查数据库连接...${NC}" +python -c " +import os +from dotenv import load_dotenv +load_dotenv() +db_url = os.getenv('DATABASE_URL', '') +if 'mysql' in db_url: + print('✓ 数据库配置已设置') +else: + print('⚠ 请检查数据库配置') +" 2>/dev/null + +# 启动服务 +echo -e "${GREEN}启动开发服务器...${NC}" +echo "================================" +echo -e "API文档: ${GREEN}http://localhost:8000/api/docs${NC}" +echo -e "健康检查: ${GREEN}http://localhost:8000/health${NC}" +echo "================================" + +# 启动uvicorn +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 diff --git a/backend/start_backend.py b/backend/start_backend.py new file mode 100644 index 0000000..c6b5db3 --- /dev/null +++ b/backend/start_backend.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +""" +考培练系统后端启动脚本 +""" +import os +import sys +import uvicorn +from app.core.config import get_settings + +def main(): + """启动后端服务""" + settings = get_settings() + + print("🚀 启动考培练系统后端服务...") + print(f"📍 服务地址: http://{settings.HOST}:{settings.PORT}") + print(f"📚 API文档: http://{settings.HOST}:{settings.PORT}/docs") + print(f"🔧 调试模式: {'开启' if settings.DEBUG else '关闭'}") + print("-" * 50) + + # 启动 uvicorn + uvicorn.run( + "app.main:app", + host=settings.HOST, + port=settings.PORT, + reload=settings.DEBUG, + log_level=settings.LOG_LEVEL.lower() + ) + +if __name__ == "__main__": + main() diff --git a/backend/start_dev.py b/backend/start_dev.py new file mode 100644 index 0000000..11f8b7e --- /dev/null +++ b/backend/start_dev.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +开发环境启动脚本 +使用SQLite数据库进行本地开发测试 +""" +import os +import sys +import asyncio +from pathlib import Path + +# 设置环境变量 +os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///./test.db" +os.environ["SECRET_KEY"] = "dev-secret-key-for-testing-only-not-for-production" +os.environ["DEBUG"] = "true" +os.environ["LOG_LEVEL"] = "INFO" +os.environ["LOG_FORMAT"] = "console" + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +async def create_tables(): + """创建数据库表""" + try: + from app.config.database import engine + from app.models.base import Base + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + print("✅ 数据库表创建成功") + except Exception as e: + print(f"❌ 数据库表创建失败: {e}") + +async def main(): + """主函数""" + print("🚀 启动考培练系统后端服务...") + + # 创建数据库表 + await create_tables() + + # 启动服务 + import uvicorn + from app.main import app + + print("📚 API文档地址: http://localhost:8000/api/v1/docs") + print("🔍 健康检查: http://localhost:8000/health") + print("⏹️ 按 Ctrl+C 停止服务") + + uvicorn.run( + app, + host="0.0.0.0", + port=8000, + reload=True, + log_level="info" + ) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/start_dev.sh b/backend/start_dev.sh new file mode 100644 index 0000000..d028efe --- /dev/null +++ b/backend/start_dev.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# 开发环境启动脚本 + +echo "===================================" +echo "考培练系统后端 - 开发环境启动" +echo "===================================" + +# 检查是否在项目根目录 +if [ ! -f "app/main.py" ]; then + echo "错误:请在项目根目录运行此脚本" + exit 1 +fi + +# 检查Python版本 +python_version=$(python3 --version 2>&1 | awk '{print $2}') +echo "Python版本: $python_version" + +# 检查MySQL服务 +echo -n "检查MySQL服务... " +if command -v mysql &> /dev/null; then + if mysql -u root -e "SELECT 1" &> /dev/null; then + echo "✓" + else + echo "✗" + echo "警告:无法连接到MySQL,请确保MySQL服务正在运行" + echo "提示:使用 'sudo service mysql start' 启动MySQL" + fi +else + echo "✗" + echo "警告:未找到MySQL客户端" +fi + +# 检查Redis服务 +echo -n "检查Redis服务... " +if command -v redis-cli &> /dev/null; then + if redis-cli ping &> /dev/null; then + echo "✓" + else + echo "✗" + echo "警告:无法连接到Redis,请确保Redis服务正在运行" + echo "提示:使用 'sudo service redis-server start' 启动Redis" + fi +else + echo "✗" + echo "警告:未找到Redis客户端" +fi + +# 安装依赖 +echo "" +echo "安装/更新依赖..." +pip install --break-system-packages -r requirements/base.txt + +# 初始化数据库 +echo "" +echo "初始化数据库..." +python3 scripts/init_db.py + +# 创建测试数据 +echo "" +echo "创建测试数据..." +python3 scripts/create_test_data.py + +# 启动服务器 +echo "" +echo "===================================" +echo "启动开发服务器..." +echo "API文档地址: http://localhost:8000/docs" +echo "===================================" +echo "" + +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 diff --git a/backend/start_mysql.py b/backend/start_mysql.py new file mode 100644 index 0000000..f6b0f75 --- /dev/null +++ b/backend/start_mysql.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +""" +MySQL环境启动脚本 +使用MySQL数据库进行开发测试 +""" +import os +import sys +import asyncio +from pathlib import Path + +# 设置环境变量 - 使用公网MySQL数据库 +# 密码需要URL编码: Kaopeilian2025!@# -> Kaopeilian2025%21%40%23 +os.environ["DATABASE_URL"] = "mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian" +os.environ["SECRET_KEY"] = "dev-secret-key-for-testing-only-not-for-production" +os.environ["DEBUG"] = "true" +os.environ["LOG_LEVEL"] = "INFO" +os.environ["LOG_FORMAT"] = "console" +os.environ["REDIS_URL"] = "redis://localhost:6379/0" + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +async def create_database(): + """创建数据库(如果不存在)""" + try: + import aiomysql + + # 连接到公网MySQL服务器(不指定数据库) + conn = await aiomysql.connect( + host='120.79.247.16', + port=3306, + user='root', + password='Kaopeilian2025!@#' + ) + + cursor = await conn.cursor() + + # 创建数据库 + await cursor.execute("CREATE DATABASE IF NOT EXISTS kaopeilian CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + print("✅ 数据库 'kaopeilian' 创建成功") + + await cursor.close() + conn.close() + + except Exception as e: + print(f"⚠️ 数据库创建警告: {e}") + print("请确保MySQL服务正在运行,并且用户root的密码是'root'") + +async def create_tables(): + """创建数据库表""" + try: + from app.config.database import engine + from app.models.base import Base + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + print("✅ 数据库表创建成功") + except Exception as e: + print(f"❌ 数据库表创建失败: {e}") + print("请检查MySQL连接配置") + +async def main(): + """主函数""" + print("🚀 启动考培练系统后端服务 (MySQL版本)...") + + # 创建数据库 + await create_database() + + # 创建数据库表 + await create_tables() + + # 启动服务 + import uvicorn + from app.main import app + + print("📚 API文档地址: http://localhost:8000/api/v1/docs") + print("🔍 健康检查: http://localhost:8000/health") + print("🗄️ 数据库: MySQL (kaopeilian)") + print("⏹️ 按 Ctrl+C 停止服务") + + uvicorn.run( + app, + host="0.0.0.0", + port=8000, + reload=True, + log_level="info" + ) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/start_remote.py b/backend/start_remote.py new file mode 100644 index 0000000..6830d2d --- /dev/null +++ b/backend/start_remote.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +使用公网数据库的启动脚本 +""" +import os +import sys +import asyncio +from pathlib import Path + +# 设置环境变量 - 使用公网MySQL数据库 +# 数据库信息: +# 主机: 120.79.247.16 或 aiedu.ireborn.com.cn +# 端口: 3306 +# 数据库名: kaopeilian +# 用户: root +# 密码: Kaopeilian2025!@# (URL编码后: Kaopeilian2025%21%40%23) +os.environ["DATABASE_URL"] = "mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4" +os.environ["SECRET_KEY"] = "dev-secret-key-for-testing-only-not-for-production" +os.environ["DEBUG"] = "true" +os.environ["LOG_LEVEL"] = "INFO" +os.environ["LOG_FORMAT"] = "console" +os.environ["REDIS_URL"] = "redis://localhost:6379/0" + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +async def test_connection(): + """测试数据库连接""" + try: + from app.config.database import engine + from sqlalchemy import text + + print("📡 正在连接到公网数据库...") + + async with engine.begin() as conn: + result = await conn.execute(text("SELECT VERSION()")) + version = result.scalar() + print(f"✅ 成功连接到MySQL: {version}") + + # 检查表数量 + result = await conn.execute(text("SHOW TABLES")) + tables = result.fetchall() + print(f"✅ 数据库中有 {len(tables)} 个表") + + await engine.dispose() + return True + + except Exception as e: + print(f"❌ 数据库连接失败: {e}") + return False + +async def main(): + """主函数""" + # 测试数据库连接 + if not await test_connection(): + print("\n⚠️ 请检查:") + print("1. 网络连接是否正常") + print("2. 数据库服务器是否可访问") + print("3. 用户名密码是否正确") + return + + print("\n🚀 启动应用服务器...") + print("访问地址: http://localhost:8000") + print("API文档: http://localhost:8000/docs") + print("\n按 Ctrl+C 停止服务器") + + # 导入并运行应用 + import uvicorn + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + reload=True, + log_config={ + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "format": "%(asctime)s | %(levelname)s | %(name)s | %(message)s", + }, + }, + "handlers": { + "default": { + "formatter": "default", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + }, + }, + "root": { + "level": "INFO", + "handlers": ["default"], + }, + } + ) + +if __name__ == "__main__": + asyncio.run(main()) + + diff --git a/backend/start_simple.py b/backend/start_simple.py new file mode 100755 index 0000000..3313cfb --- /dev/null +++ b/backend/start_simple.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""简单启动脚本,避免配置问题""" +import os +import sys + +# 设置Python路径 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# 导入local_config来设置环境变量 +import local_config + +if __name__ == "__main__": + import uvicorn + + # 确保CORS_ORIGINS格式正确 + os.environ["CORS_ORIGINS"] = '["http://localhost:3000","http://localhost:3001","http://localhost:5173"]' + + print("🚀 启动后端服务...") + print("📚 API文档: http://localhost:8000/docs") + print("🔍 健康检查: http://localhost:8000/health") + + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + reload=True, + log_level="info" + ) diff --git a/backend/test_api.py b/backend/test_api.py new file mode 100644 index 0000000..dbd30c7 --- /dev/null +++ b/backend/test_api.py @@ -0,0 +1,172 @@ +""" +API 测试脚本 +""" +import asyncio +import json +from typing import Optional + +import httpx + +# API基础URL +BASE_URL = "http://localhost:8000/api/v1" + + +class APITester: + def __init__(self): + self.client = httpx.AsyncClient(base_url=BASE_URL) + self.token: Optional[str] = None + + async def login(self, username: str, password: str): + """测试登录""" + print(f"\n1. 测试登录 - 用户: {username}") + response = await self.client.post("/auth/login", json={ + "username": username, + "password": password + }) + + if response.status_code == 200: + data = response.json() + self.token = data["data"]["token"]["access_token"] + print(f"✓ 登录成功!") + print(f" 用户信息: {data['data']['user']['username']} ({data['data']['user']['role']})") + print(f" 令牌: {self.token[:20]}...") + return True + else: + print(f"✗ 登录失败: {response.status_code}") + print(f" 错误信息: {response.json()}") + return False + + async def get_current_user(self): + """测试获取当前用户""" + print("\n2. 测试获取当前用户信息") + headers = {"Authorization": f"Bearer {self.token}"} + response = await self.client.get("/users/me", headers=headers) + + if response.status_code == 200: + data = response.json() + print(f"✓ 获取成功!") + print(f" 用户: {data['data']['username']}") + print(f" 邮箱: {data['data']['email']}") + print(f" 角色: {data['data']['role']}") + else: + print(f"✗ 获取失败: {response.status_code}") + print(f" 错误信息: {response.json()}") + + async def list_users(self): + """测试获取用户列表""" + print("\n3. 测试获取用户列表") + headers = {"Authorization": f"Bearer {self.token}"} + response = await self.client.get("/users?page=1&page_size=10", headers=headers) + + if response.status_code == 200: + data = response.json() + print(f"✓ 获取成功!") + print(f" 总数: {data['data']['total']}") + print(f" 用户列表:") + for user in data['data']['items']: + print(f" - {user['username']} ({user['role']}) - {user['email']}") + else: + print(f"✗ 获取失败: {response.status_code}") + print(f" 错误信息: {response.json()}") + + async def create_user(self): + """测试创建用户(需要管理员权限)""" + print("\n4. 测试创建用户(需要管理员权限)") + headers = {"Authorization": f"Bearer {self.token}"} + new_user = { + "username": "testuser", + "email": "testuser@example.com", + "password": "test123456", + "full_name": "测试用户", + "role": "trainee" + } + + response = await self.client.post("/users", json=new_user, headers=headers) + + if response.status_code == 201: + data = response.json() + print(f"✓ 创建成功!") + print(f" 新用户: {data['data']['username']} - {data['data']['email']}") + else: + print(f"✗ 创建失败: {response.status_code}") + print(f" 错误信息: {response.json()}") + + async def update_password(self): + """测试修改密码""" + print("\n5. 测试修改密码") + headers = {"Authorization": f"Bearer {self.token}"} + + # 先尝试错误的旧密码 + response = await self.client.put("/users/me/password", json={ + "old_password": "wrongpassword", + "new_password": "newpass123" + }, headers=headers) + + if response.status_code != 200: + print(f"✓ 正确拒绝了错误的旧密码") + + # 使用正确的旧密码 + response = await self.client.put("/users/me/password", json={ + "old_password": "admin123", # 假设是admin用户 + "new_password": "admin123" # 改回原密码 + }, headers=headers) + + if response.status_code == 200: + print(f"✓ 密码修改成功!") + else: + print(f" 注意: 密码修改测试可能因为旧密码不匹配而失败") + + async def test_unauthorized(self): + """测试未授权访问""" + print("\n6. 测试未授权访问") + # 不带token访问需要认证的接口 + response = await self.client.get("/users/me") + + if response.status_code == 403: + print(f"✓ 正确拒绝了未授权访问") + else: + print(f"✗ 未授权访问测试失败: {response.status_code}") + + async def close(self): + """关闭客户端""" + await self.client.aclose() + + +async def main(): + """主测试函数""" + print("=================================") + print("考培练系统 API 测试") + print("=================================") + + tester = APITester() + + try: + # 测试未授权访问 + await tester.test_unauthorized() + + # 测试管理员登录 + print("\n--- 管理员测试 ---") + if await tester.login("admin", "admin123"): + await tester.get_current_user() + await tester.list_users() + await tester.create_user() + await tester.update_password() + + # 测试普通用户登录 + print("\n\n--- 普通用户测试 ---") + if await tester.login("trainee1", "trainee123"): + await tester.get_current_user() + await tester.list_users() + await tester.create_user() # 应该失败(权限不足) + + print("\n\n测试完成!") + + except Exception as e: + print(f"\n错误: {str(e)}") + print("提示: 请确保服务器正在运行 (http://localhost:8000)") + finally: + await tester.close() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_api_endpoint.py b/backend/test_api_endpoint.py new file mode 100644 index 0000000..1747630 --- /dev/null +++ b/backend/test_api_endpoint.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +""" +创建一个测试端点来调试 +""" + +import asyncio +import sys +from pathlib import Path + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent +sys.path.append(str(project_root)) + +import uvicorn +from fastapi import FastAPI +from app.api.v1 import api_router + +# 创建应用 +app = FastAPI(title="调试API") + +# 包含现有路由 +app.include_router(api_router, prefix="/api/v1") + +# 添加测试路由 +from fastapi import Depends +from app.core.deps import get_current_user +from app.schemas.user import User as UserSchema +from app.schemas.base import ResponseModel + +@app.get("/debug/user/raw") +async def debug_user_raw(current_user = Depends(get_current_user)): + """直接返回用户对象的dict""" + return { + "id": current_user.id, + "username": current_user.username, + "email": current_user.email, + "phone": current_user.phone, + "school": current_user.school, + "major": current_user.major, + "full_name": current_user.full_name, + "bio": current_user.bio, + "gender": current_user.gender, + "role": current_user.role + } + +@app.get("/debug/user/schema") +async def debug_user_schema(current_user = Depends(get_current_user)): + """使用UserSchema序列化""" + return UserSchema.model_validate(current_user) + +@app.get("/debug/user/response") +async def debug_user_response(current_user = Depends(get_current_user)): + """使用ResponseModel包装""" + return ResponseModel(data=UserSchema.model_validate(current_user)) + +@app.get("/debug/user/dict") +async def debug_user_dict(current_user = Depends(get_current_user)): + """返回model_dump结果""" + user_schema = UserSchema.model_validate(current_user) + return user_schema.model_dump() + +if __name__ == "__main__": + print("启动调试API服务器...") + uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/backend/test_check_schema_fields.py b/backend/test_check_schema_fields.py new file mode 100644 index 0000000..66834d7 --- /dev/null +++ b/backend/test_check_schema_fields.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +""" +检查schema定义的字段 +""" + +import sys +from pathlib import Path + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent +sys.path.append(str(project_root)) + +from app.schemas.user import UserBase, UserInDBBase, User + +def check_fields(): + """检查字段定义""" + + print("=== UserBase 字段 ===") + for field_name, field_info in UserBase.model_fields.items(): + print(f"{field_name}: {field_info}") + + print("\n=== UserInDBBase 字段 ===") + for field_name, field_info in UserInDBBase.model_fields.items(): + print(f"{field_name}: {field_info}") + + print("\n=== User 字段 ===") + for field_name, field_info in User.model_fields.items(): + print(f"{field_name}: {field_info}") + + # 检查继承链 + print("\n=== 继承关系 ===") + print(f"UserBase.__bases__: {UserBase.__bases__}") + print(f"UserInDBBase.__bases__: {UserInDBBase.__bases__}") + print(f"User.__bases__: {User.__bases__}") + + # 检查模型配置 + print("\n=== 模型配置 ===") + if hasattr(UserBase, 'model_config'): + print(f"UserBase.model_config: {UserBase.model_config}") + if hasattr(UserInDBBase, 'model_config'): + print(f"UserInDBBase.model_config: {UserInDBBase.model_config}") + if hasattr(User, 'model_config'): + print(f"User.model_config: {User.model_config}") + +if __name__ == "__main__": + check_fields() diff --git a/backend/test_course_7_exam_settings.py b/backend/test_course_7_exam_settings.py new file mode 100644 index 0000000..0dd5212 --- /dev/null +++ b/backend/test_course_7_exam_settings.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +专门测试课程ID 7的考试设置 +""" +import asyncio +import json +import httpx +from datetime import datetime + +BASE_URL = "http://localhost:8000" + +async def main(): + async with httpx.AsyncClient() as client: + # 1. 登录获取token + print("1. 登录管理员账号...") + login_resp = await client.post( + f"{BASE_URL}/api/v1/auth/login", + json={"username": "admin", "password": "Admin123!"} + ) + token = login_resp.json()["data"]["token"]["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + # 2. 获取当前考试设置 + print("\n2. 获取课程7的当前考试设置...") + get_resp = await client.get( + f"{BASE_URL}/api/v1/courses/7/exam-settings", + headers=headers + ) + current_settings = get_resp.json() + print(f"当前设置: {json.dumps(current_settings['data'], indent=2, ensure_ascii=False)}") + + # 3. 修改考试设置 + print("\n3. 修改考试设置...") + new_settings = { + "single_choice_count": 30, + "multiple_choice_count": 15, + "true_false_count": 12, + "fill_blank_count": 8, + "duration_minutes": 150, + "difficulty_level": 4, + "is_enabled": True + } + save_resp = await client.post( + f"{BASE_URL}/api/v1/courses/7/exam-settings", + json=new_settings, + headers=headers + ) + print(f"保存响应: {save_resp.json()}") + + # 4. 再次获取验证 + print("\n4. 再次获取考试设置验证保存...") + verify_resp = await client.get( + f"{BASE_URL}/api/v1/courses/7/exam-settings", + headers=headers + ) + updated_settings = verify_resp.json() + print(f"更新后的设置: {json.dumps(updated_settings['data'], indent=2, ensure_ascii=False)}") + + # 5. 验证数据 + saved_data = updated_settings['data'] + all_correct = ( + saved_data['single_choice_count'] == 30 and + saved_data['multiple_choice_count'] == 15 and + saved_data['true_false_count'] == 12 and + saved_data['fill_blank_count'] == 8 and + saved_data['duration_minutes'] == 150 and + saved_data['difficulty_level'] == 4 + ) + + if all_correct: + print("\n✅ 测试通过!考试设置保存和读取功能正常。") + else: + print("\n❌ 测试失败!数据未正确保存。") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/test_exam_records_api.py b/backend/test_exam_records_api.py new file mode 100644 index 0000000..7ac276a --- /dev/null +++ b/backend/test_exam_records_api.py @@ -0,0 +1,52 @@ +""" +测试考试记录API +""" +import asyncio +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from app.services.exam_service import ExamService +from app.core.config import settings + +async def test_exam_records(): + """测试获取考试记录""" + # 创建数据库连接 + engine = create_async_engine( + settings.SQLALCHEMY_DATABASE_URI, + echo=True, + future=True + ) + + async_session = sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False + ) + + async with async_session() as db: + # 测试user_id=5的考试记录 + print("\n========== 测试user_id=5的考试记录 ==========") + records = await ExamService.get_exam_records( + db=db, + user_id=5, + page=1, + size=10 + ) + + print(f"\n总记录数: {records['total']}") + print(f"当前页: {records['page']}") + print(f"每页数量: {records['size']}") + print(f"总页数: {records['pages']}") + print(f"\n记录列表:") + + for item in records['items']: + print(f"\nID: {item['id']}") + print(f" 考试名称: {item['exam_name']}") + print(f" 课程名称: {item['course_name']}") + print(f" 得分: {item['score']}") + print(f" 正确率: {item['accuracy']}%") + print(f" 正确数: {item['correct_count']}") + print(f" 错题数: {item['wrong_count']}") + print(f" 用时: {item['duration_seconds']}秒") + print(f" 分题型统计: {len(item['question_type_stats'])}种题型") + +if __name__ == "__main__": + asyncio.run(test_exam_records()) + diff --git a/backend/test_practice_api.py b/backend/test_practice_api.py new file mode 100644 index 0000000..b656124 --- /dev/null +++ b/backend/test_practice_api.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +测试陪练记录API +""" +import requests +import json +from datetime import datetime, timedelta + +# API基础URL +BASE_URL = "http://localhost:8000" + +# 测试用户的token(需要先登录获取) +def login(username: str, password: str): + """登录获取token""" + response = requests.post( + f"{BASE_URL}/api/v1/auth/login", + json={"username": username, "password": password} + ) + if response.status_code == 200: + data = response.json() + if data.get("code") == 200: + return data["data"]["access_token"] + print(f"登录失败: {response.text}") + return None + +def test_practice_sessions(token: str, params: dict = None): + """测试陪练记录列表API""" + headers = {"Authorization": f"Bearer {token}"} + + response = requests.get( + f"{BASE_URL}/api/v1/practice/sessions/list", + headers=headers, + params=params or {} + ) + + print(f"\n{'='*60}") + print(f"请求参数: {params}") + print(f"响应状态码: {response.status_code}") + print(f"{'='*60}") + + if response.status_code == 200: + data = response.json() + print(json.dumps(data, indent=2, ensure_ascii=False)) + + if data.get("code") == 200: + result_data = data["data"] + print(f"\n📊 统计信息:") + print(f" - 总记录数: {result_data.get('total')}") + print(f" - 当前页码: {result_data.get('page')}") + print(f" - 每页数量: {result_data.get('page_size')}") + print(f" - 总页数: {result_data.get('pages')}") + print(f" - 本页记录数: {len(result_data.get('items', []))}") + + if result_data.get('items'): + print(f"\n📝 前3条记录:") + for i, item in enumerate(result_data['items'][:3], 1): + print(f" {i}. {item.get('scene_name')} - 评分: {item.get('total_score')} - {item.get('start_time')}") + else: + print(f"请求失败: {response.text}") + +def test_practice_stats(token: str): + """测试陪练统计API""" + headers = {"Authorization": f"Bearer {token}"} + + response = requests.get( + f"{BASE_URL}/api/v1/practice/stats", + headers=headers + ) + + print(f"\n{'='*60}") + print(f"陪练统计API") + print(f"{'='*60}") + + if response.status_code == 200: + data = response.json() + print(json.dumps(data, indent=2, ensure_ascii=False)) + else: + print(f"请求失败: {response.text}") + +if __name__ == "__main__": + # 测试不同用户 + test_users = [ + ("superadmin", "admin123"), + ("nurse_001", "password123"), + ("consultant_001", "password123"), + ] + + for username, password in test_users: + print(f"\n{'#'*60}") + print(f"# 测试用户: {username}") + print(f"{'#'*60}") + + # 登录 + token = login(username, password) + if not token: + print(f"❌ {username} 登录失败,跳过") + continue + + print(f"✅ {username} 登录成功") + + # 测试统计API + test_practice_stats(token) + + # 测试列表API - 无参数 + print(f"\n\n测试场景1: 无参数查询") + test_practice_sessions(token) + + # 测试列表API - 带日期范围 + print(f"\n\n测试场景2: 最近30天") + today = datetime.now() + start_date = (today - timedelta(days=30)).strftime("%Y-%m-%d") + end_date = today.strftime("%Y-%m-%d") + test_practice_sessions(token, { + "start_date": start_date, + "end_date": end_date, + "page": 1, + "size": 20 + }) + + print("\n" + "="*60 + "\n") + diff --git a/backend/test_remote_db.py b/backend/test_remote_db.py new file mode 100644 index 0000000..9c4278d --- /dev/null +++ b/backend/test_remote_db.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +""" +测试远程数据库连接 +""" +import asyncio +import urllib.parse + +async def test_remote_connection(): + """测试远程数据库连接""" + try: + import aiomysql + + # 公网数据库信息 + host = '120.79.247.16' # 或 aiedu.ireborn.com.cn + port = 3306 + user = 'root' + password = 'Kaopeilian2025!@#' + database = 'kaopeilian' + + print(f"正在连接到远程数据库 {host}:{port}/{database}...") + + # 直接连接测试 + conn = await aiomysql.connect( + host=host, + port=port, + user=user, + password=password, + db=database, + charset='utf8mb4' + ) + + cursor = await conn.cursor() + await cursor.execute("SELECT VERSION()") + version = await cursor.fetchone() + print(f"✅ 成功连接到MySQL: {version[0]}") + + # 测试查询 + await cursor.execute("SHOW TABLES") + tables = await cursor.fetchall() + print(f"✅ 数据库中有 {len(tables)} 个表") + + await cursor.close() + conn.close() + + # 生成URL编码的连接字符串 + password_encoded = urllib.parse.quote_plus(password) + dsn = f"mysql+aiomysql://{user}:{password_encoded}@{host}:{port}/{database}?charset=utf8mb4" + print(f"\n✅ 连接成功!") + print(f"📝 SQLAlchemy连接字符串(已编码):") + print(f" {dsn}") + + # 测试SQLAlchemy连接 + from sqlalchemy.ext.asyncio import create_async_engine + engine = create_async_engine(dsn, echo=False) + + async with engine.begin() as conn: + result = await conn.execute(text("SELECT 1")) + print(f"\n✅ SQLAlchemy连接测试成功!") + + await engine.dispose() + + except Exception as e: + print(f"❌ 连接失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + from sqlalchemy import text + asyncio.run(test_remote_connection()) + + diff --git a/backend/test_schema_validation.py b/backend/test_schema_validation.py new file mode 100644 index 0000000..d29b70c --- /dev/null +++ b/backend/test_schema_validation.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +""" +测试User schema的序列化 +""" + +import asyncio +import sys +from pathlib import Path + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent +sys.path.append(str(project_root)) + +from app.schemas.user import User as UserSchema +from app.models.user import User +from app.core.deps import get_db +from app.services.user_service import UserService + +async def test_schema(): + """测试schema序列化""" + + # 获取数据库会话 + async for db in get_db(): + try: + # 1. 获取用户 + user_service = UserService(db) + user = await user_service.get_by_username("superadmin") + + if not user: + print("用户不存在") + return + + # 2. 打印模型数据 + print("=== 模型数据 ===") + print(f"ID: {user.id}") + print(f"用户名: {user.username}") + print(f"邮箱: {user.email}") + print(f"手机号: {user.phone}") + print(f"学校: {user.school}") + print(f"专业: {user.major}") + print(f"性别: {user.gender}") + print(f"个人简介: {user.bio}") + + # 3. 使用schema序列化 + print("\n=== Schema序列化 ===") + user_data = UserSchema.model_validate(user) + print(f"序列化结果: {user_data.model_dump()}") + + # 4. 检查具体字段 + print("\n=== 检查序列化后的字段 ===") + dumped = user_data.model_dump() + for field in ['username', 'email', 'phone', 'school', 'major', 'gender', 'bio']: + value = dumped.get(field, 'NOT_FOUND') + print(f"{field}: {value}") + + except Exception as e: + print(f"错误: {e}") + import traceback + traceback.print_exc() + finally: + await db.close() + break + +if __name__ == "__main__": + print("测试User schema序列化...") + asyncio.run(test_schema()) diff --git a/backend/test_statistics_api.py b/backend/test_statistics_api.py new file mode 100644 index 0000000..e9a2dea --- /dev/null +++ b/backend/test_statistics_api.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python +""" +测试统计分析API +""" +import asyncio +import sys +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker + +# 添加项目路径 +sys.path.insert(0, '/Users/nongjun/Desktop/Ai公司/本地开发与测试/kaopeilian-backend') + +from app.services.statistics_service import StatisticsService +from app.core.logger import get_logger + +logger = get_logger(__name__) + +# 测试数据库连接字符串(使用本地测试环境) +DATABASE_URL = "mysql+aiomysql://root:nj861021@localhost:3306/kaopeilian" + + +async def test_statistics_service(): + """测试统计服务""" + print("=" * 50) + print("开始测试统计分析服务") + print("=" * 50) + + # 创建数据库连接 + engine = create_async_engine(DATABASE_URL, echo=False) + async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + try: + async with async_session() as session: + # 测试用户ID(使用实际存在的用户) + test_user_id = 2 # admin用户 + + print(f"\n📊 测试用户ID: {test_user_id}") + print("-" * 50) + + # 1. 测试关键指标 + print("\n1️⃣ 测试关键指标...") + try: + metrics = await StatisticsService.get_key_metrics( + db=session, + user_id=test_user_id, + period="month" + ) + print("✓ 关键指标获取成功:") + print(f" - 学习效率: {metrics['learningEfficiency']['value']}%") + print(f" - 知识覆盖率: {metrics['knowledgeCoverage']['value']}%") + print(f" - 平均用时: {metrics['avgTimePerQuestion']['value']} 分/题") + print(f" - 进步速度: {metrics['progressSpeed']['value']}%") + except Exception as e: + print(f"✗ 关键指标获取失败: {e}") + + # 2. 测试成绩分布 + print("\n2️⃣ 测试成绩分布...") + try: + distribution = await StatisticsService.get_score_distribution( + db=session, + user_id=test_user_id, + period="month" + ) + print("✓ 成绩分布获取成功:") + print(f" - 优秀: {distribution['excellent']}") + print(f" - 良好: {distribution['good']}") + print(f" - 中等: {distribution['medium']}") + print(f" - 及格: {distribution['pass']}") + print(f" - 不及格: {distribution['fail']}") + except Exception as e: + print(f"✗ 成绩分布获取失败: {e}") + + # 3. 测试难度分析 + print("\n3️⃣ 测试难度分析...") + try: + difficulty = await StatisticsService.get_difficulty_analysis( + db=session, + user_id=test_user_id, + period="month" + ) + print("✓ 难度分析获取成功:") + for key, value in difficulty.items(): + print(f" - {key}: {value}%") + except Exception as e: + print(f"✗ 难度分析获取失败: {e}") + + # 4. 测试知识点掌握度 + print("\n4️⃣ 测试知识点掌握度...") + try: + mastery = await StatisticsService.get_knowledge_mastery( + db=session, + user_id=test_user_id + ) + print(f"✓ 知识点掌握度获取成功 (共{len(mastery)}个知识点):") + for item in mastery[:3]: # 只显示前3个 + print(f" - {item['name']}: {item['mastery']}%") + except Exception as e: + print(f"✗ 知识点掌握度获取失败: {e}") + + # 5. 测试学习时长统计 + print("\n5️⃣ 测试学习时长统计...") + try: + time_stats = await StatisticsService.get_study_time_stats( + db=session, + user_id=test_user_id, + period="week" + ) + print(f"✓ 学习时长统计获取成功:") + print(f" - 日期数: {len(time_stats['labels'])}") + print(f" - 总学习时长: {sum(time_stats['studyTime'])} 小时") + print(f" - 总练习时长: {sum(time_stats['practiceTime'])} 小时") + except Exception as e: + print(f"✗ 学习时长统计获取失败: {e}") + + # 6. 测试详细数据 + print("\n6️⃣ 测试详细数据...") + try: + detail = await StatisticsService.get_detail_data( + db=session, + user_id=test_user_id, + period="month" + ) + print(f"✓ 详细数据获取成功 (共{len(detail)}条记录):") + if detail: + first = detail[0] + print(f" - 最近日期: {first['date']}") + print(f" - 考试次数: {first['examCount']}") + print(f" - 平均分: {first['avgScore']}") + print(f" - 正确率: {first['accuracy']}%") + except Exception as e: + print(f"✗ 详细数据获取失败: {e}") + + print("\n" + "=" * 50) + print("✅ 所有测试完成!") + print("=" * 50) + + except Exception as e: + print(f"\n❌ 测试过程中发生错误: {e}") + import traceback + traceback.print_exc() + + finally: + await engine.dispose() + + +if __name__ == "__main__": + print("\n🚀 启动统计分析服务测试...") + asyncio.run(test_statistics_service()) + diff --git a/backend/test_team_api.py b/backend/test_team_api.py new file mode 100644 index 0000000..24d4e4a --- /dev/null +++ b/backend/test_team_api.py @@ -0,0 +1,114 @@ +""" +测试团队管理API +""" +import asyncio +import sys +from pathlib import Path + +# 添加项目路径 +sys.path.append(str(Path(__file__).resolve().parent)) + +import httpx + + +async def test_team_api(): + """测试团队管理API""" + base_url = "http://localhost:8000" + + print("=" * 60) + print("测试团队管理API") + print("=" * 60) + + # 1. 登录获取token + print("\n【1. 登录admin用户】") + login_data = { + "username": "admin", + "password": "admin123" + } + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + f"{base_url}/api/v1/auth/login", + json=login_data, + timeout=10.0 + ) + print(f"登录状态: {response.status_code}") + result = response.json() + print(f"响应内容: {result}") + + if response.status_code == 200: + # 尝试不同的数据结构 + token = None + if "access_token" in result: + token = result["access_token"] + elif result.get("data"): + if "access_token" in result["data"]: + token = result["data"]["access_token"] + elif "token" in result["data"] and "access_token" in result["data"]["token"]: + token = result["data"]["token"]["access_token"] + + if token: + print(f"登录成功,获取token: {token[:30]}...") + else: + print(f"无法获取token,响应结构: {list(result.keys())}") + return + else: + print(f"登录失败: HTTP {response.status_code}") + return + except Exception as e: + print(f"登录失败: {e}") + return + + # 2. 测试团队统计API + print("\n【2. 获取团队统计】") + headers = {"Authorization": f"Bearer {token}"} + try: + response = await client.get( + f"{base_url}/api/v1/team/management/statistics", + headers=headers, + timeout=10.0 + ) + print(f"状态: {response.status_code}") + result = response.json() + print(f"结果: {result}") + except Exception as e: + print(f"获取统计失败: {e}") + + # 3. 测试团队成员列表API + print("\n【3. 获取团队成员列表】") + try: + response = await client.get( + f"{base_url}/api/v1/team/management/members", + headers=headers, + params={"page": 1, "size": 20}, + timeout=10.0 + ) + print(f"状态: {response.status_code}") + result = response.json() + print(f"返回code: {result.get('code')}") + print(f"返回message: {result.get('message')}") + + if result.get("data"): + data = result["data"] + print(f"总记录数: {data.get('total')}") + print(f"当前页: {data.get('page')}") + print(f"每页大小: {data.get('page_size')}") + print(f"总页数: {data.get('pages')}") + print(f"返回记录数: {len(data.get('items', []))}") + + if data.get('items'): + print("\n前3个成员:") + for member in data['items'][:3]: + print(f" - ID:{member['id']}, 姓名:{member['name']}, 岗位:{member['position']}, 状态:{member['status']}") + else: + print("无数据返回") + except Exception as e: + print(f"获取成员列表失败: {e}") + + print("\n" + "=" * 60) + + +if __name__ == "__main__": + asyncio.run(test_team_api()) + diff --git a/backend/test_team_dashboard.py b/backend/test_team_dashboard.py new file mode 100644 index 0000000..d4102ad --- /dev/null +++ b/backend/test_team_dashboard.py @@ -0,0 +1,186 @@ +""" +团队看板API测试脚本 + +用于验证团队看板所有接口的功能 +""" + +import asyncio +import httpx + +# 配置 +BASE_URL = "http://localhost:8000" +# 需要先登录获取token +TOKEN = "your_token_here" # 替换为实际的token + +headers = { + "Authorization": f"Bearer {TOKEN}", + "Content-Type": "application/json" +} + + +async def test_overview(): + """测试团队概览接口""" + print("\n=== 测试团队概览接口 ===") + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/api/v1/team/dashboard/overview", + headers=headers + ) + print(f"状态码: {response.status_code}") + data = response.json() + print(f"响应数据: {data}") + + if data.get("code") == 200: + result = data.get("data", {}) + print(f"✅ 团队成员数: {result.get('member_count')}") + print(f"✅ 平均学习进度: {result.get('avg_progress')}%") + print(f"✅ 平均考试成绩: {result.get('avg_score')}") + print(f"✅ 课程完成率: {result.get('course_completion_rate')}%") + else: + print(f"❌ 失败: {data.get('message')}") + + +async def test_progress(): + """测试学习进度接口""" + print("\n=== 测试学习进度接口 ===") + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/api/v1/team/dashboard/progress", + headers=headers + ) + print(f"状态码: {response.status_code}") + data = response.json() + + if data.get("code") == 200: + result = data.get("data", {}) + print(f"✅ 成员列表: {result.get('members')}") + print(f"✅ 周数: {len(result.get('weeks', []))}") + print(f"✅ 数据条数: {len(result.get('data', []))}") + else: + print(f"❌ 失败: {data.get('message')}") + + +async def test_course_distribution(): + """测试课程分布接口""" + print("\n=== 测试课程分布接口 ===") + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/api/v1/team/dashboard/course-distribution", + headers=headers + ) + print(f"状态码: {response.status_code}") + data = response.json() + + if data.get("code") == 200: + result = data.get("data", {}) + print(f"✅ 已完成: {result.get('completed')}") + print(f"✅ 进行中: {result.get('in_progress')}") + print(f"✅ 未开始: {result.get('not_started')}") + else: + print(f"❌ 失败: {data.get('message')}") + + +async def test_ability_analysis(): + """测试能力分析接口""" + print("\n=== 测试能力分析接口 ===") + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/api/v1/team/dashboard/ability-analysis", + headers=headers + ) + print(f"状态码: {response.status_code}") + data = response.json() + + if data.get("code") == 200: + result = data.get("data", {}) + radar = result.get('radar_data', {}) + print(f"✅ 能力维度: {radar.get('dimensions')}") + print(f"✅ 能力分数: {radar.get('values')}") + print(f"✅ 短板数量: {len(result.get('weaknesses', []))}") + else: + print(f"❌ 失败: {data.get('message')}") + + +async def test_rankings(): + """测试排行榜接口""" + print("\n=== 测试排行榜接口 ===") + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/api/v1/team/dashboard/rankings", + headers=headers + ) + print(f"状态码: {response.status_code}") + data = response.json() + + if data.get("code") == 200: + result = data.get("data", {}) + study_ranking = result.get('study_time_ranking', []) + score_ranking = result.get('score_ranking', []) + print(f"✅ 学习时长排行: {len(study_ranking)} 人") + if study_ranking: + print(f" 第一名: {study_ranking[0].get('name')} - {study_ranking[0].get('study_time')}小时") + print(f"✅ 成绩排行: {len(score_ranking)} 人") + if score_ranking: + print(f" 第一名: {score_ranking[0].get('name')} - {score_ranking[0].get('avg_score')}分") + else: + print(f"❌ 失败: {data.get('message')}") + + +async def test_activities(): + """测试团队动态接口""" + print("\n=== 测试团队动态接口 ===") + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/api/v1/team/dashboard/activities", + headers=headers + ) + print(f"状态码: {response.status_code}") + data = response.json() + + if data.get("code") == 200: + result = data.get("data", {}) + activities = result.get('activities', []) + print(f"✅ 活动记录数: {len(activities)}") + if activities: + print(f" 最新活动: {activities[0].get('user_name')} {activities[0].get('action')} {activities[0].get('target')}") + else: + print(f"❌ 失败: {data.get('message')}") + + +async def main(): + """运行所有测试""" + print("=" * 60) + print("团队看板API测试") + print("=" * 60) + + # 检查token是否设置 + if TOKEN == "your_token_here": + print("\n⚠️ 请先设置TOKEN变量(在文件顶部)") + print(" 可以通过以下步骤获取:") + print(" 1. 访问 http://localhost:3001") + print(" 2. 登录系统(admin账号)") + print(" 3. 打开浏览器开发者工具 -> Application -> Local Storage") + print(" 4. 找到 token 或 access_token") + return + + try: + await test_overview() + await test_progress() + await test_course_distribution() + await test_ability_analysis() + await test_rankings() + await test_activities() + + print("\n" + "=" * 60) + print("✅ 所有测试完成!") + print("=" * 60) + + except Exception as e: + print(f"\n❌ 测试失败: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/backend/test_team_management_api.py b/backend/test_team_management_api.py new file mode 100644 index 0000000..f8ea66c --- /dev/null +++ b/backend/test_team_management_api.py @@ -0,0 +1,272 @@ +""" +团队管理API测试脚本 +用于验证团队管理相关接口的功能 +""" +import asyncio +import sys +from pathlib import Path + +# 添加项目根目录到Python路径 +sys.path.insert(0, str(Path(__file__).parent)) + +import httpx + + +BASE_URL = "http://localhost:8000/api/v1" +TOKEN = None # 需要先登录获取token + + +async def login(): + """登录获取token""" + global TOKEN + async with httpx.AsyncClient() as client: + response = await client.post( + f"{BASE_URL}/auth/login", + json={ + "username": "admin", + "password": "admin123" + } + ) + if response.status_code == 200: + data = response.json() + TOKEN = data.get("data", {}).get("access_token") + print(f"✅ 登录成功,获取token: {TOKEN[:20]}...") + return True + else: + print(f"❌ 登录失败: {response.text}") + return False + + +async def test_team_statistics(): + """测试团队统计接口""" + print("\n" + "="*60) + print("测试1: GET /team/management/statistics") + print("="*60) + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/team/management/statistics", + headers={"Authorization": f"Bearer {TOKEN}"} + ) + + print(f"状态码: {response.status_code}") + data = response.json() + print(f"响应数据: {data}") + + if response.status_code == 200: + stats = data.get("data", {}) + print(f"\n📊 团队统计:") + print(f" - 团队总人数: {stats.get('teamCount')}") + print(f" - 活跃成员: {stats.get('activeMembers')}") + print(f" - 平均学习进度: {stats.get('avgProgress')}%") + print(f" - 团队平均分: {stats.get('avgScore')}") + print("✅ 测试通过") + else: + print(f"❌ 测试失败: {data.get('message')}") + + +async def test_team_members(): + """测试团队成员列表接口""" + print("\n" + "="*60) + print("测试2: GET /team/management/members") + print("="*60) + + async with httpx.AsyncClient() as client: + # 测试基础查询 + response = await client.get( + f"{BASE_URL}/team/management/members", + params={"page": 1, "size": 5}, + headers={"Authorization": f"Bearer {TOKEN}"} + ) + + print(f"状态码: {response.status_code}") + data = response.json() + + if response.status_code == 200: + result = data.get("data", {}) + print(f"\n👥 成员列表:") + print(f" - 总数: {result.get('total')}") + print(f" - 当前页: {result.get('page')}") + print(f" - 每页数量: {result.get('page_size')}") + print(f" - 总页数: {result.get('pages')}") + + items = result.get("items", []) + print(f"\n 成员列表 (前{len(items)}条):") + for member in items: + print(f" - ID: {member['id']}, 姓名: {member['name']}, " + f"岗位: {member['position']}, 状态: {member['status']}, " + f"进度: {member['progress']}%, 平均分: {member['avgScore']}") + print("✅ 测试通过") + else: + print(f"❌ 测试失败: {data.get('message')}") + + # 测试搜索功能 + print("\n测试搜索功能...") + response = await client.get( + f"{BASE_URL}/team/management/members", + params={"page": 1, "size": 5, "search_text": "admin"}, + headers={"Authorization": f"Bearer {TOKEN}"} + ) + if response.status_code == 200: + data = response.json() + result = data.get("data", {}) + print(f" 搜索'admin'结果数: {result.get('total')}") + print("✅ 搜索测试通过") + + +async def test_member_detail(): + """测试成员详情接口""" + print("\n" + "="*60) + print("测试3: GET /team/management/members/{id}/detail") + print("="*60) + + # 先获取一个成员ID + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/team/management/members", + params={"page": 1, "size": 1}, + headers={"Authorization": f"Bearer {TOKEN}"} + ) + + if response.status_code != 200: + print("❌ 无法获取成员列表") + return + + data = response.json() + items = data.get("data", {}).get("items", []) + if not items: + print("⚠️ 没有成员数据,跳过测试") + return + + member_id = items[0]["id"] + print(f"测试成员ID: {member_id}") + + # 获取成员详情 + response = await client.get( + f"{BASE_URL}/team/management/members/{member_id}/detail", + headers={"Authorization": f"Bearer {TOKEN}"} + ) + + print(f"状态码: {response.status_code}") + data = response.json() + + if response.status_code == 200: + detail = data.get("data", {}) + print(f"\n📋 成员详情:") + print(f" - 姓名: {detail.get('name')}") + print(f" - 岗位: {detail.get('position')}") + print(f" - 状态: {detail.get('status')}") + print(f" - 学习时长: {detail.get('studyTime')}小时") + print(f" - 完成课程: {detail.get('completedCourses')}门") + print(f" - 平均成绩: {detail.get('avgScore')}分") + print(f" - 通过率: {detail.get('passRate')}%") + + records = detail.get("recentRecords", []) + print(f"\n 最近学习记录 ({len(records)}条):") + for record in records[:3]: + print(f" - {record['time']}: {record['content']}") + + print("✅ 测试通过") + else: + print(f"❌ 测试失败: {data.get('message')}") + + +async def test_member_report(): + """测试成员学习报告接口""" + print("\n" + "="*60) + print("测试4: GET /team/management/members/{id}/report") + print("="*60) + + # 先获取一个成员ID + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/team/management/members", + params={"page": 1, "size": 1}, + headers={"Authorization": f"Bearer {TOKEN}"} + ) + + if response.status_code != 200: + print("❌ 无法获取成员列表") + return + + data = response.json() + items = data.get("data", {}).get("items", []) + if not items: + print("⚠️ 没有成员数据,跳过测试") + return + + member_id = items[0]["id"] + print(f"测试成员ID: {member_id}") + + # 获取学习报告 + response = await client.get( + f"{BASE_URL}/team/management/members/{member_id}/report", + headers={"Authorization": f"Bearer {TOKEN}"} + ) + + print(f"状态码: {response.status_code}") + data = response.json() + + if response.status_code == 200: + report = data.get("data", {}) + + # 概览 + overview = report.get("overview", []) + print(f"\n📊 报告概览:") + for item in overview: + print(f" - {item['label']}: {item['value']}") + + # 进度趋势 + trend = report.get("progressTrend", {}) + dates = trend.get("dates", []) + data_points = trend.get("data", []) + print(f"\n📈 学习进度趋势 (30天):") + print(f" - 数据点数: {len(dates)}") + if dates and data_points: + print(f" - 起始: {dates[0]} -> {data_points[0]}%") + print(f" - 结束: {dates[-1]} -> {data_points[-1]}%") + + # 能力评估 + abilities = report.get("abilities", []) + print(f"\n🎯 能力评估:") + for ability in abilities: + print(f" - {ability['name']}: {ability['score']}分 - {ability['description']}") + + # 详细记录 + records = report.get("records", []) + print(f"\n📚 详细学习记录 ({len(records)}条)") + + print("✅ 测试通过") + else: + print(f"❌ 测试失败: {data.get('message')}") + + +async def main(): + """主测试函数""" + print("="*60) + print("团队管理API测试") + print("="*60) + + # 登录 + if not await login(): + return + + # 运行测试 + try: + await test_team_statistics() + await test_team_members() + await test_member_detail() + await test_member_report() + + print("\n" + "="*60) + print("✅ 所有测试完成") + print("="*60) + except Exception as e: + print(f"\n❌ 测试过程中出现错误: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/backend/test_user_id4.py b/backend/test_user_id4.py new file mode 100644 index 0000000..1513d16 --- /dev/null +++ b/backend/test_user_id4.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python +""" +检查用户ID 4的统计数据 +""" + +import asyncio +import sys +from pathlib import Path +import aiomysql +import os + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent +sys.path.append(str(project_root)) + +from app.core.config import settings + +async def check_user_4(): + """检查用户ID 4的数据""" + try: + # 从环境变量或配置中获取数据库连接信息 + database_url = os.getenv("DATABASE_URL", settings.DATABASE_URL) + + # 解析数据库连接字符串 + if database_url.startswith("mysql+aiomysql://"): + url = database_url.replace("mysql+aiomysql://", "") + else: + url = database_url + + # 解析连接参数 + auth_host = url.split("@")[1] + user_pass = url.split("@")[0] + host_port_db = auth_host.split("/") + host_port = host_port_db[0].split(":") + + user = user_pass.split(":")[0] + password = user_pass.split(":")[1] + host = host_port[0] + port = int(host_port[1]) if len(host_port) > 1 else 3306 + database = host_port_db[1].split("?")[0] if len(host_port_db) > 1 else "kaopeilian" + + print(f"连接数据库: {host}:{port}/{database}") + + # 创建数据库连接 + conn = await aiomysql.connect( + host=host, + port=port, + user=user, + password=password, + db=database, + charset='utf8mb4' + ) + + async with conn.cursor() as cursor: + # 1. 查看用户信息 + print("\n=== 用户信息 ===") + await cursor.execute(""" + SELECT id, username, email, full_name, role, is_active + FROM users + WHERE id = 4 + """) + user_info = await cursor.fetchone() + if user_info: + print(f"ID: {user_info[0]}, 用户名: {user_info[1]}, 邮箱: {user_info[2]}, 姓名: {user_info[3]}, 角色: {user_info[4]}, 激活: {user_info[5]}") + else: + print("用户ID 4不存在") + + # 如果没有ID=4的用户,查看所有用户 + print("\n=== 所有用户 ===") + await cursor.execute(""" + SELECT id, username, email, role + FROM users + ORDER BY id + """) + users = await cursor.fetchall() + for user in users: + print(f"ID: {user[0]}, 用户名: {user[1]}, 邮箱: {user[2]}, 角色: {user[3]}") + + # 2. 查看用户ID 4的统计数据(如果存在) + if user_info: + user_id = 4 + print(f"\n=== 用户ID {user_id} 统计数据 ===") + + # 学习天数 + await cursor.execute(""" + SELECT COUNT(DISTINCT DATE(start_time)) + FROM training_sessions + WHERE user_id = %s + """, (user_id,)) + learning_days = (await cursor.fetchone())[0] or 0 + print(f"学习天数: {learning_days}") + + # 总时长 + await cursor.execute(""" + SELECT COALESCE(SUM(duration_seconds), 0) + FROM training_sessions + WHERE user_id = %s + """, (user_id,)) + total_seconds = (await cursor.fetchone())[0] or 0 + total_hours = round(float(total_seconds) / 3600.0, 1) if total_seconds else 0.0 + print(f"学习时长: {total_hours} 小时") + + # 练习题数和平均分 + await cursor.execute(""" + SELECT COALESCE(SUM(question_count), 0), AVG(score) + FROM exams + WHERE user_id = %s AND status = 'completed' + """, (user_id,)) + result = await cursor.fetchone() + practice_questions = result[0] or 0 + avg_score = round(float(result[1]), 1) if result[1] is not None else 0.0 + print(f"练习题数: {practice_questions}") + print(f"平均分: {avg_score}") + + conn.close() + + except Exception as e: + print(f"执行失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + print("检查用户ID 4的数据...") + asyncio.run(check_user_4()) diff --git a/backend/test_user_position_sync.py b/backend/test_user_position_sync.py new file mode 100644 index 0000000..629741d --- /dev/null +++ b/backend/test_user_position_sync.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +""" +测试用户岗位同步功能 +验证用户编辑页面的岗位变更是否真正落库 +""" + +import os +import sys +import asyncio +from sqlalchemy import create_engine, text +from sqlalchemy.ext.asyncio import create_async_engine + +# 添加项目根目录到 Python 路径 +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +# 导入 local_config 以设置环境变量 +import local_config + +# 获取数据库配置 +DATABASE_URL = os.environ.get("DATABASE_URL") + +# 将同步 URL 转换为异步 URL +if DATABASE_URL.startswith("mysql+pymysql://"): + ASYNC_DATABASE_URL = DATABASE_URL.replace("mysql+pymysql://", "mysql+aiomysql://") +else: + ASYNC_DATABASE_URL = DATABASE_URL + + +async def test_user_position_relationships(): + """测试用户-岗位关系""" + print(f"\n=== 用户岗位关系测试 ===") + print(f"数据库URL: {ASYNC_DATABASE_URL}") + + # 创建异步引擎 + engine = create_async_engine(ASYNC_DATABASE_URL, echo=False) + + try: + async with engine.connect() as conn: + # 1. 查看所有岗位 + print("\n1. 当前系统中的所有岗位:") + result = await conn.execute(text(""" + SELECT id, name, code, status + FROM positions + ORDER BY id + """)) + positions = result.fetchall() + + if not positions: + print(" [警告] 系统中没有岗位数据!") + else: + for p in positions: + status = "启用" if p.status == "active" else "停用" + print(f" - ID: {p.id}, 名称: {p.name}, 编码: {p.code}, 状态: {status}") + + # 2. 查看用户-岗位关系 + print("\n2. 用户-岗位关联关系:") + result = await conn.execute(text(""" + SELECT + pm.position_id, + pm.user_id, + p.name as position_name, + u.username, + u.full_name, + pm.created_at + FROM position_members pm + JOIN positions p ON pm.position_id = p.id + JOIN users u ON pm.user_id = u.id + ORDER BY pm.position_id, pm.user_id + """)) + members = result.fetchall() + + if not members: + print(" [提示] 暂无用户-岗位关联") + else: + current_position_id = None + for m in members: + if m.position_id != current_position_id: + current_position_id = m.position_id + print(f"\n 岗位: {m.position_name} (ID: {m.position_id})") + print(f" - 用户: {m.username} ({m.full_name}), ID: {m.user_id}, 加入时间: {m.created_at}") + + # 3. 检查特定用户的岗位 + print("\n3. 查看特定用户的岗位信息:") + # 查询几个示例用户 + result = await conn.execute(text(""" + SELECT id, username, full_name + FROM users + WHERE role != 'admin' + LIMIT 5 + """)) + sample_users = result.fetchall() + + for user in sample_users: + result = await conn.execute(text(""" + SELECT + p.id, + p.name, + p.code + FROM positions p + JOIN position_members pm ON p.id = pm.position_id + WHERE pm.user_id = :user_id + """), {"user_id": user.id}) + user_positions = result.fetchall() + + if user_positions: + positions_str = ", ".join([f"{p.name}(ID:{p.id})" for p in user_positions]) + print(f" - 用户 {user.username} ({user.full_name}): {positions_str}") + else: + print(f" - 用户 {user.username} ({user.full_name}): 无岗位") + + # 4. 统计每个岗位的成员数量 + print("\n4. 统计每个岗位的成员数量:") + result = await conn.execute(text(""" + SELECT + p.id, + p.name, + COUNT(pm.user_id) as member_count + FROM positions p + LEFT JOIN position_members pm ON p.id = pm.position_id + GROUP BY p.id, p.name + ORDER BY p.id + """)) + counts = result.fetchall() + + for c in counts: + print(f" - 岗位 {c.name} (ID:{c.id}) 成员数: {c.member_count}") + + except Exception as e: + print(f"\n[错误] 数据库操作失败: {e}") + import traceback + traceback.print_exc() + finally: + await engine.dispose() + + +async def verify_position_sync(user_id: int): + """验证特定用户的岗位同步情况""" + print(f"\n=== 验证用户 ID:{user_id} 的岗位同步 ===") + + engine = create_async_engine(ASYNC_DATABASE_URL, echo=False) + + try: + async with engine.connect() as conn: + # 获取用户信息 + result = await conn.execute(text(""" + SELECT username, full_name + FROM users + WHERE id = :user_id + """), {"user_id": user_id}) + user = result.fetchone() + + if not user: + print(f"[错误] 用户 ID:{user_id} 不存在") + return + + print(f"用户: {user.username} ({user.full_name})") + + # 获取用户的岗位 + result = await conn.execute(text(""" + SELECT + p.id, + p.name, + p.code, + pm.created_at + FROM positions p + JOIN position_members pm ON p.id = pm.position_id + WHERE pm.user_id = :user_id + ORDER BY pm.created_at DESC + """), {"user_id": user_id}) + positions = result.fetchall() + + if positions: + print(f"当前岗位:") + for p in positions: + print(f" - {p.name} (ID:{p.id}, 编码:{p.code}) - 加入时间: {p.created_at}") + else: + print("当前岗位: 无") + + except Exception as e: + print(f"\n[错误] 验证失败: {e}") + import traceback + traceback.print_exc() + finally: + await engine.dispose() + + +if __name__ == "__main__": + # 运行测试 + asyncio.run(test_user_position_relationships()) + + # 如果命令行提供了用户ID,验证该用户 + if len(sys.argv) > 1: + try: + user_id = int(sys.argv[1]) + asyncio.run(verify_position_sync(user_id)) + except ValueError: + print(f"\n[错误] 无效的用户ID: {sys.argv[1]}") diff --git a/backend/test_user_statistics.py b/backend/test_user_statistics.py new file mode 100644 index 0000000..eaff9dd --- /dev/null +++ b/backend/test_user_statistics.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python +""" +测试用户统计接口 +""" + +import asyncio +import sys +from pathlib import Path +import aiomysql +import os +from datetime import datetime + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent +sys.path.append(str(project_root)) + +from app.core.config import settings + +async def check_user_data(): + """检查用户数据""" + try: + # 从环境变量或配置中获取数据库连接信息 + database_url = os.getenv("DATABASE_URL", settings.DATABASE_URL) + + # 解析数据库连接字符串 + if database_url.startswith("mysql+aiomysql://"): + url = database_url.replace("mysql+aiomysql://", "") + else: + url = database_url + + # 解析连接参数 + auth_host = url.split("@")[1] + user_pass = url.split("@")[0] + host_port_db = auth_host.split("/") + host_port = host_port_db[0].split(":") + + user = user_pass.split(":")[0] + password = user_pass.split(":")[1] + host = host_port[0] + port = int(host_port[1]) if len(host_port) > 1 else 3306 + database = host_port_db[1].split("?")[0] if len(host_port_db) > 1 else "kaopeilian" + + print(f"连接数据库: {host}:{port}/{database}") + + # 创建数据库连接 + conn = await aiomysql.connect( + host=host, + port=port, + user=user, + password=password, + db=database, + charset='utf8mb4' + ) + + async with conn.cursor() as cursor: + # 1. 查看考试记录及状态 + print("\n=== 考试记录 ===") + await cursor.execute(""" + SELECT id, user_id, exam_name, status, score, question_count + FROM exams + WHERE user_id IN (SELECT id FROM users WHERE username IN ('admin', 'testuser')) + ORDER BY created_at DESC + LIMIT 10 + """) + exams = await cursor.fetchall() + for exam in exams: + print(f"考试ID: {exam[0]}, 用户ID: {exam[1]}, 名称: {exam[2]}, 状态: {exam[3]}, 分数: {exam[4]}, 题数: {exam[5]}") + + # 2. 查看陪练会话记录 + print("\n=== 陪练会话记录 ===") + await cursor.execute(""" + SELECT id, user_id, scene_id, start_time, duration_seconds, status + FROM training_sessions + WHERE user_id IN (SELECT id FROM users WHERE username IN ('admin', 'testuser')) + ORDER BY created_at DESC + LIMIT 10 + """) + sessions = await cursor.fetchall() + for session in sessions: + print(f"会话ID: {session[0]}, 用户ID: {session[1]}, 场景ID: {session[2]}, 开始时间: {session[3]}, 时长: {session[4]}秒, 状态: {session[5]}") + + # 3. 统计每个用户的数据 + print("\n=== 用户统计数据 ===") + users = [('admin', 1), ('testuser', 3)] + for username, user_id in users: + print(f"\n用户: {username} (ID: {user_id})") + + # 学习天数 + await cursor.execute(""" + SELECT COUNT(DISTINCT DATE(start_time)) + FROM training_sessions + WHERE user_id = %s + """, (user_id,)) + learning_days = (await cursor.fetchone())[0] or 0 + print(f" 学习天数: {learning_days}") + + # 总时长 + await cursor.execute(""" + SELECT COALESCE(SUM(duration_seconds), 0) + FROM training_sessions + WHERE user_id = %s + """, (user_id,)) + total_seconds = (await cursor.fetchone())[0] or 0 + total_hours = round(float(total_seconds) / 3600.0, 1) if total_seconds else 0.0 + print(f" 学习时长: {total_hours} 小时 ({total_seconds} 秒)") + + # 练习题数 - 检查不同状态 + for status in ['completed', 'submitted']: + await cursor.execute(""" + SELECT COALESCE(SUM(question_count), 0) + FROM exams + WHERE user_id = %s AND status = %s + """, (user_id, status)) + questions = (await cursor.fetchone())[0] or 0 + print(f" 练习题数({status}): {questions}") + + # 平均分 - 检查不同状态 + for status in ['completed', 'submitted']: + await cursor.execute(""" + SELECT AVG(score) + FROM exams + WHERE user_id = %s AND status = %s + """, (user_id, status)) + avg_score = await cursor.fetchone() + avg_score_val = round(float(avg_score[0]), 1) if avg_score[0] is not None else 0.0 + print(f" 平均分({status}): {avg_score_val}") + + conn.close() + + except Exception as e: + print(f"执行失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + print("检查用户统计数据...") + asyncio.run(check_user_data()) diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..1545c77 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +"""测试包""" \ No newline at end of file diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..5f8023f --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,100 @@ +"""测试配置和fixtures""" +import asyncio +from typing import AsyncGenerator, Generator +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker + +from app.main import app +from app.models.base import Base +from app.config.database import SessionLocal +from app.core.deps import get_db + + +# 测试数据库URL +TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" + +# 创建测试引擎 +test_engine = create_async_engine( + TEST_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +# 创建测试会话工厂 +TestSessionLocal = async_sessionmaker( + test_engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False +) + + +@pytest.fixture(scope="session") +def event_loop() -> Generator: + """创建事件循环""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="function") +async def db_session() -> AsyncGenerator[AsyncSession, None]: + """创建测试数据库会话""" + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with TestSessionLocal() as session: + yield session + await session.rollback() + + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture(scope="function") +async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: + """创建测试客户端""" + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + + async with AsyncClient(app=app, base_url="http://test") as ac: + yield ac + + app.dependency_overrides.clear() + + +@pytest.fixture +def test_user(): + """测试用户""" + return { + "id": 1, + "username": "test_user", + "role": "user", + "token": "test_token" + } + + +@pytest.fixture +def test_admin(): + """测试管理员""" + return { + "id": 2, + "username": "test_admin", + "role": "admin", + "token": "admin_token" + } + + +@pytest.fixture +def auth_headers(test_user): + """认证请求头""" + return {"Authorization": f"Bearer {test_user['token']}"} + + +@pytest.fixture +def admin_auth_headers(test_admin): + """管理员认证请求头""" + return {"Authorization": f"Bearer {test_admin['token']}"} \ No newline at end of file diff --git a/backend/tests/e2e/__init__.py b/backend/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_courses.py b/backend/tests/test_courses.py new file mode 100644 index 0000000..53977dc --- /dev/null +++ b/backend/tests/test_courses.py @@ -0,0 +1,284 @@ +""" +课程模块测试 +""" +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.course import Course, CourseStatus, CourseCategory +from app.services.course_service import course_service + + +class TestCourseAPI: + """课程API测试类""" + + @pytest.mark.asyncio + async def test_create_course_success(self, client: AsyncClient, admin_headers: dict): + """测试成功创建课程""" + course_data = { + "name": "测试课程", + "description": "这是一个测试课程", + "category": "technology", + "difficulty_level": 3, + "tags": ["Python", "测试"] + } + + response = await client.post( + "/api/v1/courses", + json=course_data, + headers=admin_headers + ) + + assert response.status_code == 201 + data = response.json() + assert data["code"] == 200 + assert data["message"] == "创建课程成功" + assert data["data"]["name"] == course_data["name"] + assert data["data"]["status"] == "draft" + + @pytest.mark.asyncio + async def test_create_course_unauthorized(self, client: AsyncClient, user_headers: dict): + """测试非管理员创建课程失败""" + course_data = { + "name": "测试课程", + "description": "这是一个测试课程" + } + + response = await client.post( + "/api/v1/courses", + json=course_data, + headers=user_headers + ) + + assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_get_courses_list(self, client: AsyncClient, user_headers: dict, db_session: AsyncSession): + """测试获取课程列表""" + # 先创建几个测试课程 + courses = [ + Course( + name=f"测试课程{i}", + description=f"描述{i}", + category=CourseCategory.TECHNOLOGY if i % 2 == 0 else CourseCategory.BUSINESS, + status=CourseStatus.PUBLISHED if i < 2 else CourseStatus.DRAFT, + is_featured=i == 0 + ) + for i in range(3) + ] + + for course in courses: + db_session.add(course) + await db_session.commit() + + # 测试获取所有课程 + response = await client.get( + "/api/v1/courses", + headers=user_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert len(data["data"]["items"]) == 3 + assert data["data"]["total"] == 3 + + # 测试筛选已发布课程 + response = await client.get( + "/api/v1/courses?status=published", + headers=user_headers + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["data"]["items"]) == 2 + + # 测试分类筛选 + response = await client.get( + "/api/v1/courses?category=technology", + headers=user_headers + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["data"]["items"]) == 2 + + @pytest.mark.asyncio + async def test_get_course_detail(self, client: AsyncClient, user_headers: dict, db_session: AsyncSession): + """测试获取课程详情""" + # 创建测试课程 + course = Course( + name="测试课程详情", + description="详细描述", + category=CourseCategory.TECHNOLOGY, + status=CourseStatus.PUBLISHED + ) + db_session.add(course) + await db_session.commit() + await db_session.refresh(course) + + # 获取课程详情 + response = await client.get( + f"/api/v1/courses/{course.id}", + headers=user_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"]["id"] == course.id + assert data["data"]["name"] == course.name + + @pytest.mark.asyncio + async def test_get_course_not_found(self, client: AsyncClient, user_headers: dict): + """测试获取不存在的课程""" + response = await client.get( + "/api/v1/courses/99999", + headers=user_headers + ) + + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_update_course(self, client: AsyncClient, admin_headers: dict, db_session: AsyncSession): + """测试更新课程""" + # 创建测试课程 + course = Course( + name="原始课程名", + description="原始描述", + category=CourseCategory.TECHNOLOGY, + status=CourseStatus.DRAFT + ) + db_session.add(course) + await db_session.commit() + await db_session.refresh(course) + + # 更新课程 + update_data = { + "name": "更新后的课程名", + "status": "published" + } + + response = await client.put( + f"/api/v1/courses/{course.id}", + json=update_data, + headers=admin_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"]["name"] == update_data["name"] + assert data["data"]["status"] == "published" + assert data["data"]["published_at"] is not None + + @pytest.mark.asyncio + async def test_delete_course(self, client: AsyncClient, admin_headers: dict, db_session: AsyncSession): + """测试删除课程""" + # 创建测试课程 + course = Course( + name="待删除课程", + description="这个课程将被删除", + category=CourseCategory.GENERAL, + status=CourseStatus.DRAFT + ) + db_session.add(course) + await db_session.commit() + await db_session.refresh(course) + + # 删除课程 + response = await client.delete( + f"/api/v1/courses/{course.id}", + headers=admin_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"] is True + + # 验证软删除 + deleted_course = await course_service.get_by_id(db_session, course.id) + assert deleted_course is None # 因为get_by_id会过滤掉软删除的记录 + + @pytest.mark.asyncio + async def test_delete_published_course_fail(self, client: AsyncClient, admin_headers: dict, db_session: AsyncSession): + """测试删除已发布课程失败""" + # 创建已发布课程 + course = Course( + name="已发布课程", + description="这是已发布的课程", + category=CourseCategory.TECHNOLOGY, + status=CourseStatus.PUBLISHED + ) + db_session.add(course) + await db_session.commit() + await db_session.refresh(course) + + # 尝试删除 + response = await client.delete( + f"/api/v1/courses/{course.id}", + headers=admin_headers + ) + + assert response.status_code == 400 + + +class TestKnowledgePointAPI: + """知识点API测试类""" + + @pytest.mark.asyncio + async def test_get_knowledge_points(self, client: AsyncClient, user_headers: dict, db_session: AsyncSession): + """测试获取知识点列表""" + # 创建测试课程 + course = Course( + name="测试课程", + description="包含知识点的课程", + category=CourseCategory.TECHNOLOGY, + status=CourseStatus.PUBLISHED + ) + db_session.add(course) + await db_session.commit() + await db_session.refresh(course) + + # 获取知识点(应该为空) + response = await client.get( + f"/api/v1/courses/{course.id}/knowledge-points", + headers=user_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"] == [] + + @pytest.mark.asyncio + async def test_create_knowledge_point(self, client: AsyncClient, admin_headers: dict, db_session: AsyncSession): + """测试创建知识点""" + # 创建测试课程 + course = Course( + name="测试课程", + description="用于测试知识点", + category=CourseCategory.TECHNOLOGY, + status=CourseStatus.DRAFT + ) + db_session.add(course) + await db_session.commit() + await db_session.refresh(course) + + # 创建知识点 + point_data = { + "name": "Python基础", + "description": "学习Python基础知识", + "weight": 2.0, + "estimated_hours": 10 + } + + response = await client.post( + f"/api/v1/courses/{course.id}/knowledge-points", + json=point_data, + headers=admin_headers + ) + + assert response.status_code == 201 + data = response.json() + assert data["data"]["name"] == point_data["name"] + assert data["data"]["course_id"] == course.id + assert data["data"]["level"] == 1 + assert data["data"]["parent_id"] is None diff --git a/backend/tests/test_coze_api.py b/backend/tests/test_coze_api.py new file mode 100644 index 0000000..a4dbc3f --- /dev/null +++ b/backend/tests/test_coze_api.py @@ -0,0 +1,306 @@ +""" +Coze API 网关单元测试 +""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch +from fastapi.testclient import TestClient +from fastapi import FastAPI +from sse_starlette.sse import ServerSentEvent + +from app.api.v1.coze_gateway import router +from app.services.ai.coze.models import ( + CreateSessionResponse, EndSessionResponse, + StreamEvent, StreamEventType, ContentType, MessageRole +) +from app.services.ai.coze.exceptions import CozeAPIError, CozeAuthError + + +# 创建测试应用 +app = FastAPI() +app.include_router(router) + +client = TestClient(app) + + +@pytest.fixture +def mock_user(): + """模拟已登录用户""" + with patch("app.api.v1.coze_gateway.get_current_user") as mock_get_user: + mock_get_user.return_value = { + "user_id": "test-user-123", + "username": "test_user" + } + yield mock_get_user + + +@pytest.fixture +def mock_coze_service(): + """模拟 Coze 服务""" + with patch("app.api.v1.coze_gateway.get_coze_service") as mock_get_service: + mock_service = Mock() + mock_get_service.return_value = mock_service + yield mock_service + + +class TestCourseChat: + """测试课程对话 API""" + + def test_create_course_chat_session_success(self, mock_user, mock_coze_service): + """测试成功创建课程对话会话""" + # Mock 服务响应 + mock_coze_service.create_session = AsyncMock( + return_value=CreateSessionResponse( + session_id="session-123", + conversation_id="conv-123", + bot_id="bot-123", + created_at="2024-01-01T10:00:00" + ) + ) + + response = client.post( + "/api/v1/course-chat/sessions", + json={"course_id": "course-456"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["message"] == "success" + assert data["data"]["session_id"] == "session-123" + assert data["data"]["conversation_id"] == "conv-123" + + def test_create_course_chat_session_auth_error(self, mock_user, mock_coze_service): + """测试认证错误""" + mock_coze_service.create_session = AsyncMock( + side_effect=CozeAuthError( + message="认证失败", + code="AUTH_ERROR", + status_code=401 + ) + ) + + response = client.post( + "/api/v1/course-chat/sessions", + json={"course_id": "course-456"} + ) + + assert response.status_code == 401 + data = response.json() + assert data["detail"]["code"] == "AUTH_ERROR" + assert data["detail"]["message"] == "认证失败" + + def test_create_course_chat_session_server_error(self, mock_user, mock_coze_service): + """测试服务器错误""" + mock_coze_service.create_session = AsyncMock( + side_effect=Exception("Unexpected error") + ) + + response = client.post( + "/api/v1/course-chat/sessions", + json={"course_id": "course-456"} + ) + + assert response.status_code == 500 + data = response.json() + assert data["detail"]["code"] == "INTERNAL_ERROR" + + +class TestTraining: + """测试陪练 API""" + + def test_create_training_session_with_topic(self, mock_user, mock_coze_service): + """测试创建带主题的陪练会话""" + mock_coze_service.create_session = AsyncMock( + return_value=CreateSessionResponse( + session_id="training-123", + conversation_id="conv-456", + bot_id="training-bot", + created_at="2024-01-01T11:00:00" + ) + ) + + response = client.post( + "/api/v1/training/sessions", + json={"training_topic": "客诉处理技巧"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"]["session_id"] == "training-123" + + # 验证服务调用 + call_args = mock_coze_service.create_session.call_args[0][0] + assert call_args.training_topic == "客诉处理技巧" + + def test_create_training_session_without_topic(self, mock_user, mock_coze_service): + """测试创建不带主题的陪练会话""" + mock_coze_service.create_session = AsyncMock( + return_value=CreateSessionResponse( + session_id="training-456", + conversation_id="conv-789", + bot_id="training-bot", + created_at="2024-01-01T12:00:00" + ) + ) + + response = client.post("/api/v1/training/sessions", json={}) + + assert response.status_code == 200 + data = response.json() + assert data["data"]["session_id"] == "training-456" + + def test_end_training_session_success(self, mock_user, mock_coze_service): + """测试成功结束陪练会话""" + mock_coze_service.end_session = AsyncMock( + return_value=EndSessionResponse( + session_id="training-123", + ended_at="2024-01-01T13:00:00", + duration_seconds=1800, + message_count=25 + ) + ) + + response = client.post( + "/api/v1/training/sessions/training-123/end", + json={ + "reason": "练习完成", + "feedback": {"rating": 5, "helpful": True} + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"]["duration_seconds"] == 1800 + assert data["data"]["message_count"] == 25 + + def test_end_nonexistent_session(self, mock_user, mock_coze_service): + """测试结束不存在的会话""" + mock_coze_service.end_session = AsyncMock( + side_effect=CozeAPIError( + message="会话不存在", + code="SESSION_NOT_FOUND", + status_code=404 + ) + ) + + response = client.post( + "/api/v1/training/sessions/nonexistent/end", + json={} + ) + + assert response.status_code == 404 + + +class TestChatMessages: + """测试消息发送 API""" + + def test_send_message_non_stream(self, mock_user, mock_coze_service): + """测试非流式消息发送""" + # Mock 异步生成器 + async def mock_generator(): + yield StreamEvent( + event=StreamEventType.MESSAGE_DELTA, + data={}, + content="Hello", + content_type=ContentType.TEXT, + role=MessageRole.ASSISTANT + ) + yield StreamEvent( + event=StreamEventType.MESSAGE_COMPLETED, + data={"usage": {"tokens": 10}}, + message_id="msg-123", + content="Hello, how can I help you?", + content_type=ContentType.TEXT, + role=MessageRole.ASSISTANT + ) + yield StreamEvent( + event=StreamEventType.DONE, + data={"session_id": "session-123"} + ) + + mock_coze_service.send_message = AsyncMock(return_value=mock_generator()) + + response = client.post( + "/api/v1/chat/messages", + json={ + "session_id": "session-123", + "content": "Hello", + "stream": False + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"]["content"] == "Hello, how can I help you?" + assert data["data"]["content_type"] == "text" + assert data["data"]["role"] == "assistant" + + def test_send_message_with_files(self, mock_user, mock_coze_service): + """测试带附件的消息发送""" + async def mock_generator(): + yield StreamEvent( + event=StreamEventType.MESSAGE_COMPLETED, + data={}, + message_id="msg-456", + content="File received", + content_type=ContentType.TEXT, + role=MessageRole.ASSISTANT + ) + yield StreamEvent( + event=StreamEventType.DONE, + data={"session_id": "session-123"} + ) + + mock_coze_service.send_message = AsyncMock(return_value=mock_generator()) + + response = client.post( + "/api/v1/chat/messages", + json={ + "session_id": "session-123", + "content": "Please analyze this file", + "file_ids": ["file-123", "file-456"], + "stream": False + } + ) + + assert response.status_code == 200 + + # 验证服务调用 + call_args = mock_coze_service.send_message.call_args[0][0] + assert call_args.file_ids == ["file-123", "file-456"] + + def test_get_message_history(self, mock_user, mock_coze_service): + """测试获取消息历史""" + from app.services.ai.coze.models import CozeMessage + + mock_messages = [ + CozeMessage( + message_id="msg-1", + session_id="session-123", + role=MessageRole.USER, + content="Hello" + ), + CozeMessage( + message_id="msg-2", + session_id="session-123", + role=MessageRole.ASSISTANT, + content="Hi there!" + ) + ] + + mock_coze_service.get_session_messages = AsyncMock( + return_value=mock_messages + ) + + response = client.get( + "/api/v1/sessions/session-123/messages?limit=10&offset=0" + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["data"]["messages"]) == 2 + assert data["data"]["messages"][0]["content"] == "Hello" + assert data["data"]["messages"][1]["content"] == "Hi there!" + assert data["data"]["limit"] == 10 + assert data["data"]["offset"] == 0 diff --git a/backend/tests/test_coze_client.py b/backend/tests/test_coze_client.py new file mode 100644 index 0000000..87bafd3 --- /dev/null +++ b/backend/tests/test_coze_client.py @@ -0,0 +1,168 @@ +""" +Coze 客户端单元测试 +""" + +import os +import pytest +from unittest.mock import Mock, patch, MagicMock +from cozepy import Coze, TokenAuth, OAuthJWT + +from app.services.ai.coze.client import ( + CozeAuthManager, get_coze_client, get_bot_config, + get_workspace_id +) +from app.services.ai.coze.exceptions import CozeAuthError + + +class TestCozeAuthManager: + """测试认证管理器""" + + def test_init_with_env_vars(self): + """测试从环境变量初始化""" + with patch.dict(os.environ, { + "COZE_API_BASE": "https://test.coze.cn", + "COZE_WORKSPACE_ID": "test-workspace", + "COZE_API_TOKEN": "test-token" + }): + manager = CozeAuthManager() + assert manager.api_base == "https://test.coze.cn" + assert manager.workspace_id == "test-workspace" + assert manager.api_token == "test-token" + + def test_init_with_params(self): + """测试从参数初始化""" + manager = CozeAuthManager( + api_base="https://custom.coze.cn", + workspace_id="custom-workspace", + api_token="custom-token" + ) + assert manager.api_base == "https://custom.coze.cn" + assert manager.workspace_id == "custom-workspace" + assert manager.api_token == "custom-token" + + def test_setup_direct_connection(self): + """测试直连设置""" + manager = CozeAuthManager() + no_proxy = os.environ.get("NO_PROXY", "") + assert "api.coze.cn" in no_proxy + assert ".coze.cn" in no_proxy + assert "localhost" in no_proxy + + @patch("app.services.ai.coze.client.TokenAuth") + @patch("app.services.ai.coze.client.Coze") + def test_token_auth_success(self, mock_coze_class, mock_token_auth): + """测试 Token 认证成功""" + manager = CozeAuthManager(api_token="test-token") + mock_client = Mock() + mock_coze_class.return_value = mock_client + + client = manager.get_client() + + mock_token_auth.assert_called_once_with("test-token") + mock_coze_class.assert_called_once() + assert client == mock_client + + def test_token_auth_no_token(self): + """测试没有 Token 时的错误""" + manager = CozeAuthManager(api_token=None) + + with pytest.raises(CozeAuthError, match="API Token 未配置"): + manager.get_client() + + @patch("builtins.open", create=True) + @patch("app.services.ai.coze.client.serialization.load_pem_private_key") + @patch("app.services.ai.coze.client.OAuthJWT") + @patch("app.services.ai.coze.client.Coze") + def test_oauth_auth_success(self, mock_coze_class, mock_oauth_jwt, + mock_load_key, mock_open): + """测试 OAuth 认证成功""" + # 模拟私钥文件 + mock_open.return_value.__enter__.return_value.read.return_value = b"fake-private-key" + mock_load_key.return_value = Mock() + + manager = CozeAuthManager( + oauth_client_id="test-client", + oauth_public_key_id="test-key-id", + oauth_private_key_path="/path/to/key.pem" + ) + + mock_client = Mock() + mock_coze_class.return_value = mock_client + + client = manager.get_client() + + mock_oauth_jwt.assert_called_once() + assert client == mock_client + + @patch("builtins.open", side_effect=FileNotFoundError) + @patch("app.services.ai.coze.client.TokenAuth") + @patch("app.services.ai.coze.client.Coze") + def test_oauth_fallback_to_token(self, mock_coze_class, mock_token_auth, mock_open): + """测试 OAuth 失败后回退到 Token""" + manager = CozeAuthManager( + api_token="fallback-token", + oauth_client_id="test-client", + oauth_public_key_id="test-key-id", + oauth_private_key_path="/nonexistent/key.pem" + ) + + mock_client = Mock() + mock_coze_class.return_value = mock_client + + client = manager.get_client() + + # 应该使用 Token 认证 + mock_token_auth.assert_called_once_with("fallback-token") + assert client == mock_client + + def test_refresh_token(self): + """测试刷新令牌""" + manager = CozeAuthManager(api_token="test-token") + + with patch.object(manager, '_init_client') as mock_init: + manager.refresh_token() + assert manager._client is None + mock_init.assert_called_once() + + +class TestHelperFunctions: + """测试辅助函数""" + + def test_get_bot_config(self): + """测试获取 Bot 配置""" + with patch.dict(os.environ, { + "COZE_CHAT_BOT_ID": "chat-bot-123", + "COZE_TRAINING_BOT_ID": "training-bot-456", + "COZE_EXAM_BOT_ID": "exam-bot-789" + }): + config = get_bot_config() + assert config["course_chat"] == "chat-bot-123" + assert config["training"] == "training-bot-456" + assert config["exam"] == "exam-bot-789" + + def test_get_workspace_id_success(self): + """测试获取工作空间 ID 成功""" + with patch.dict(os.environ, {"COZE_WORKSPACE_ID": "workspace-123"}): + workspace_id = get_workspace_id() + assert workspace_id == "workspace-123" + + def test_get_workspace_id_not_configured(self): + """测试工作空间 ID 未配置""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(CozeAuthError, match="COZE_WORKSPACE_ID 未配置"): + get_workspace_id() + + @patch("app.services.ai.coze.client.get_auth_manager") + def test_get_coze_client(self, mock_get_auth_manager): + """测试获取 Coze 客户端""" + mock_manager = Mock() + mock_client = Mock() + mock_manager.get_client.return_value = mock_client + mock_get_auth_manager.return_value = mock_manager + + # 清除缓存 + get_coze_client.cache_clear() + + client = get_coze_client() + assert client == mock_client + mock_manager.get_client.assert_called_once() diff --git a/backend/tests/test_coze_service.py b/backend/tests/test_coze_service.py new file mode 100644 index 0000000..11dbe32 --- /dev/null +++ b/backend/tests/test_coze_service.py @@ -0,0 +1,274 @@ +""" +Coze 服务层单元测试 +""" + +import asyncio +import pytest +from datetime import datetime +from unittest.mock import Mock, AsyncMock, patch, MagicMock + +from cozepy import ChatEventType + +from app.services.ai.coze.service import CozeService, get_coze_service +from app.services.ai.coze.models import ( + SessionType, MessageRole, ContentType, StreamEventType, + CreateSessionRequest, SendMessageRequest, EndSessionRequest, + CozeSession, CozeMessage, StreamEvent +) +from app.services.ai.coze.exceptions import CozeAPIError + + +@pytest.fixture +def coze_service(): + """创建测试用的服务实例""" + with patch("app.services.ai.coze.service.get_coze_client"): + service = CozeService() + service.bot_config = { + "course_chat": "chat-bot-id", + "training": "training-bot-id", + "exam": "exam-bot-id" + } + service.workspace_id = "test-workspace" + return service + + +@pytest.mark.asyncio +class TestCozeService: + """测试 Coze 服务""" + + async def test_create_course_chat_session(self, coze_service): + """测试创建课程对话会话""" + # Mock Coze client + mock_conversation = Mock(id="conv-123") + coze_service.client.conversations.create = Mock(return_value=mock_conversation) + + request = CreateSessionRequest( + session_type=SessionType.COURSE_CHAT, + user_id="user-123", + course_id="course-456" + ) + + response = await coze_service.create_session(request) + + # 验证结果 + assert response.conversation_id == "conv-123" + assert response.bot_id == "chat-bot-id" + assert isinstance(response.session_id, str) + assert isinstance(response.created_at, datetime) + + # 验证会话已保存 + session = coze_service._sessions[response.session_id] + assert session.session_type == SessionType.COURSE_CHAT + assert session.user_id == "user-123" + assert session.metadata["course_id"] == "course-456" + + async def test_create_training_session(self, coze_service): + """测试创建陪练会话""" + mock_conversation = Mock(id="conv-456") + coze_service.client.conversations.create = Mock(return_value=mock_conversation) + + request = CreateSessionRequest( + session_type=SessionType.TRAINING, + user_id="user-789", + training_topic="客诉处理" + ) + + response = await coze_service.create_session(request) + + assert response.conversation_id == "conv-456" + assert response.bot_id == "training-bot-id" + + session = coze_service._sessions[response.session_id] + assert session.session_type == SessionType.TRAINING + assert session.metadata["training_topic"] == "客诉处理" + + async def test_send_message_with_stream(self, coze_service): + """测试发送消息(流式响应)""" + # 创建测试会话 + session = CozeSession( + session_id="test-session", + conversation_id="conv-123", + session_type=SessionType.COURSE_CHAT, + user_id="user-123", + bot_id="chat-bot-id" + ) + coze_service._sessions["test-session"] = session + coze_service._messages["test-session"] = [] + + # Mock 流式响应 + mock_events = [ + Mock( + event=ChatEventType.CONVERSATION_MESSAGE_DELTA, + conversation_id="conv-123", + message=Mock(content="Hello ") + ), + Mock( + event=ChatEventType.CONVERSATION_MESSAGE_DELTA, + conversation_id="conv-123", + message=Mock(content="world!") + ), + Mock( + event=ChatEventType.CONVERSATION_MESSAGE_COMPLETED, + conversation_id="conv-123", + message=Mock(content="Hello world!"), + usage={"tokens": 10} + ) + ] + + coze_service.client.chat.stream = Mock(return_value=iter(mock_events)) + + request = SendMessageRequest( + session_id="test-session", + content="Hi there", + stream=True + ) + + # 收集事件 + events = [] + async for event in coze_service.send_message(request): + events.append(event) + + # 验证事件 + assert len(events) == 4 # 2 delta + 1 completed + 1 done + assert events[0].event == StreamEventType.MESSAGE_DELTA + assert events[0].content == "Hello " + assert events[1].event == StreamEventType.MESSAGE_DELTA + assert events[1].content == "world!" + assert events[2].event == StreamEventType.MESSAGE_COMPLETED + assert events[2].content == "Hello world!" + assert events[3].event == StreamEventType.DONE + + # 验证消息已保存 + messages = coze_service._messages["test-session"] + assert len(messages) == 2 # 用户消息 + 助手消息 + assert messages[0].role == MessageRole.USER + assert messages[0].content == "Hi there" + assert messages[1].role == MessageRole.ASSISTANT + assert messages[1].content == "Hello world!" + + async def test_send_message_error_handling(self, coze_service): + """测试发送消息错误处理""" + # 不存在的会话 + request = SendMessageRequest( + session_id="nonexistent", + content="Test" + ) + + with pytest.raises(CozeAPIError, match="会话不存在"): + async for _ in coze_service.send_message(request): + pass + + async def test_end_session(self, coze_service): + """测试结束会话""" + # 创建测试会话和消息 + created_at = datetime.now() + session = CozeSession( + session_id="test-session", + conversation_id="conv-123", + session_type=SessionType.TRAINING, + user_id="user-123", + bot_id="training-bot-id", + created_at=created_at + ) + coze_service._sessions["test-session"] = session + coze_service._messages["test-session"] = [ + Mock(), Mock(), Mock() # 3条消息 + ] + + request = EndSessionRequest( + reason="用户主动结束", + feedback={"rating": 5, "comment": "很有帮助"} + ) + + response = await coze_service.end_session("test-session", request) + + # 验证响应 + assert response.session_id == "test-session" + assert isinstance(response.ended_at, datetime) + assert response.message_count == 3 + assert response.duration_seconds > 0 + + # 验证会话元数据 + assert session.metadata["end_reason"] == "用户主动结束" + assert session.metadata["feedback"]["rating"] == 5 + + async def test_end_nonexistent_session(self, coze_service): + """测试结束不存在的会话""" + request = EndSessionRequest() + + with pytest.raises(CozeAPIError, match="会话不存在"): + await coze_service.end_session("nonexistent", request) + + async def test_get_session_messages(self, coze_service): + """测试获取会话消息历史""" + # 创建测试消息 + messages = [ + CozeMessage( + message_id=f"msg-{i}", + session_id="test-session", + role=MessageRole.USER if i % 2 == 0 else MessageRole.ASSISTANT, + content=f"Message {i}" + ) + for i in range(10) + ] + coze_service._messages["test-session"] = messages + + # 测试分页 + result = await coze_service.get_session_messages("test-session", limit=5, offset=2) + + assert len(result) == 5 + assert result[0].content == "Message 2" + assert result[4].content == "Message 6" + + async def test_stream_with_card_content(self, coze_service): + """测试流式响应中的卡片内容""" + # 创建测试会话 + session = CozeSession( + session_id="test-session", + conversation_id="conv-123", + session_type=SessionType.EXAM, + user_id="user-123", + bot_id="exam-bot-id" + ) + coze_service._sessions["test-session"] = session + coze_service._messages["test-session"] = [] + + # Mock 包含卡片的流式响应 + mock_events = [ + Mock( + event=ChatEventType.CONVERSATION_MESSAGE_DELTA, + conversation_id="conv-123", + message=Mock(content='{"question": "测试题目"}', content_type="card") + ), + Mock( + event=ChatEventType.CONVERSATION_MESSAGE_COMPLETED, + conversation_id="conv-123", + message=Mock(content='{"question": "测试题目"}', content_type="card") + ) + ] + + coze_service.client.chat.stream = Mock(return_value=iter(mock_events)) + + request = SendMessageRequest( + session_id="test-session", + content="生成一道考题" + ) + + events = [] + async for event in coze_service.send_message(request): + events.append(event) + + # 验证卡片类型被正确识别 + assert events[0].content_type == ContentType.CARD + assert events[1].content_type == ContentType.CARD + + # 验证消息保存时的内容类型 + messages = coze_service._messages["test-session"] + assert messages[1].content_type == ContentType.CARD + + +def test_get_coze_service_singleton(): + """测试服务单例""" + service1 = get_coze_service() + service2 = get_coze_service() + assert service1 is service2 diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py new file mode 100644 index 0000000..095acd3 --- /dev/null +++ b/backend/tests/test_main.py @@ -0,0 +1,35 @@ +""" +主应用测试 +""" +import pytest +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + + +def test_root(): + """测试根路径""" + response = client.get("/") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "考培练系统" + assert data["status"] == "running" + assert "version" in data + assert "timestamp" in data + + +def test_health(): + """测试健康检查端点""" + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "healthy"} + + +def test_api_health(): + """测试API健康检查""" + response = client.get("/api/v1/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert data["api_version"] == "v1" diff --git a/backend/tests/test_training.py b/backend/tests/test_training.py new file mode 100644 index 0000000..421b6c9 --- /dev/null +++ b/backend/tests/test_training.py @@ -0,0 +1,399 @@ +"""陪练模块测试""" +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.training import TrainingScene, TrainingSession, TrainingSceneStatus +from app.services.training_service import TrainingSceneService, TrainingSessionService + + +class TestTrainingSceneAPI: + """陪练场景API测试""" + + @pytest.mark.asyncio + async def test_get_training_scenes(self, client: AsyncClient, auth_headers: dict): + """测试获取陪练场景列表""" + response = await client.get( + "/api/v1/training/scenes", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert "data" in data + assert "items" in data["data"] + assert "total" in data["data"] + assert "page" in data["data"] + assert "page_size" in data["data"] + + @pytest.mark.asyncio + async def test_create_training_scene_admin_only( + self, + client: AsyncClient, + auth_headers: dict, + admin_auth_headers: dict + ): + """测试创建陪练场景(需要管理员权限)""" + scene_data = { + "name": "面试训练", + "description": "模拟面试场景,提升面试技巧", + "category": "面试", + "ai_config": { + "bot_id": "test_bot_id", + "prompt": "你是一位专业的面试官" + }, + "is_public": True + } + + # 普通用户无权限 + response = await client.post( + "/api/v1/training/scenes", + json=scene_data, + headers=auth_headers + ) + assert response.status_code == 403 + + # 管理员可以创建 + # 注意:这里需要mock管理员权限检查 + # 在实际测试中,需要正确设置依赖覆盖 + + @pytest.mark.asyncio + async def test_get_training_scene_detail( + self, + client: AsyncClient, + auth_headers: dict, + db_session: AsyncSession + ): + """测试获取陪练场景详情""" + # 创建测试场景 + scene_service = TrainingSceneService() + scene = await scene_service.create( + db_session, + obj_in={ + "name": "测试场景", + "category": "测试", + "status": TrainingSceneStatus.ACTIVE, + "is_public": True + }, + created_by=1, + updated_by=1 + ) + + # 获取场景详情 + response = await client.get( + f"/api/v1/training/scenes/{scene.id}", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["data"]["id"] == scene.id + assert data["data"]["name"] == "测试场景" + + @pytest.mark.asyncio + async def test_get_nonexistent_scene(self, client: AsyncClient, auth_headers: dict): + """测试获取不存在的场景""" + response = await client.get( + "/api/v1/training/scenes/99999", + headers=auth_headers + ) + + assert response.status_code == 404 + + +class TestTrainingSessionAPI: + """陪练会话API测试""" + + @pytest.mark.asyncio + async def test_start_training( + self, + client: AsyncClient, + auth_headers: dict, + db_session: AsyncSession + ): + """测试开始陪练""" + # 创建测试场景 + scene_service = TrainingSceneService() + scene = await scene_service.create( + db_session, + obj_in={ + "name": "测试陪练场景", + "category": "测试", + "status": TrainingSceneStatus.ACTIVE, + "is_public": True + }, + created_by=1, + updated_by=1 + ) + + # 开始陪练 + response = await client.post( + "/api/v1/training/sessions", + json={ + "scene_id": scene.id, + "config": {"key": "value"} + }, + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert "session_id" in data["data"] + assert data["data"]["scene"]["id"] == scene.id + + @pytest.mark.asyncio + async def test_end_training( + self, + client: AsyncClient, + auth_headers: dict, + db_session: AsyncSession + ): + """测试结束陪练""" + # 创建测试场景和会话 + scene_service = TrainingSceneService() + scene = await scene_service.create( + db_session, + obj_in={ + "name": "测试场景", + "category": "测试", + "status": TrainingSceneStatus.ACTIVE, + "is_public": True + }, + created_by=1, + updated_by=1 + ) + + session_service = TrainingSessionService() + session = await session_service.create( + db_session, + obj_in={ + "scene_id": scene.id, + "session_config": {} + }, + user_id=1, + created_by=1 + ) + + # 结束陪练 + response = await client.post( + f"/api/v1/training/sessions/{session.id}/end", + json={"generate_report": True}, + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["data"]["session"]["status"] == "completed" + + @pytest.mark.asyncio + async def test_get_user_sessions(self, client: AsyncClient, auth_headers: dict): + """测试获取用户会话列表""" + response = await client.get( + "/api/v1/training/sessions", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert "items" in data["data"] + assert isinstance(data["data"]["items"], list) + + @pytest.mark.asyncio + async def test_get_session_messages( + self, + client: AsyncClient, + auth_headers: dict, + db_session: AsyncSession + ): + """测试获取会话消息""" + # 创建测试数据 + scene_service = TrainingSceneService() + scene = await scene_service.create( + db_session, + obj_in={ + "name": "测试场景", + "category": "测试", + "status": TrainingSceneStatus.ACTIVE, + "is_public": True + }, + created_by=1, + updated_by=1 + ) + + session_service = TrainingSessionService() + session = await session_service.create( + db_session, + obj_in={ + "scene_id": scene.id, + "session_config": {} + }, + user_id=1, + created_by=1 + ) + + # 获取消息 + response = await client.get( + f"/api/v1/training/sessions/{session.id}/messages", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert isinstance(data["data"], list) + + +class TestTrainingReportAPI: + """陪练报告API测试""" + + @pytest.mark.asyncio + async def test_get_user_reports(self, client: AsyncClient, auth_headers: dict): + """测试获取用户报告列表""" + response = await client.get( + "/api/v1/training/reports", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert "items" in data["data"] + assert isinstance(data["data"]["items"], list) + + @pytest.mark.asyncio + async def test_get_report_by_session( + self, + client: AsyncClient, + auth_headers: dict, + db_session: AsyncSession + ): + """测试根据会话ID获取报告""" + # 创建测试数据 + scene_service = TrainingSceneService() + scene = await scene_service.create( + db_session, + obj_in={ + "name": "测试场景", + "category": "测试", + "status": TrainingSceneStatus.ACTIVE, + "is_public": True + }, + created_by=1, + updated_by=1 + ) + + session_service = TrainingSessionService() + session = await session_service.create( + db_session, + obj_in={ + "scene_id": scene.id, + "session_config": {} + }, + user_id=1, + created_by=1 + ) + + # 获取报告(会话还没有报告) + response = await client.get( + f"/api/v1/training/sessions/{session.id}/report", + headers=auth_headers + ) + + assert response.status_code == 404 + + +class TestTrainingService: + """陪练服务层测试""" + + @pytest.mark.asyncio + async def test_scene_service_crud(self, db_session: AsyncSession): + """测试场景服务的CRUD操作""" + scene_service = TrainingSceneService() + + # 创建 + scene = await scene_service.create_scene( + db_session, + scene_in={ + "name": "演讲训练", + "description": "提升演讲能力", + "category": "演讲", + "status": TrainingSceneStatus.ACTIVE + }, + created_by=1 + ) + + assert scene.id is not None + assert scene.name == "演讲训练" + + # 读取 + retrieved = await scene_service.get(db_session, scene.id) + assert retrieved is not None + assert retrieved.id == scene.id + + # 更新 + updated = await scene_service.update_scene( + db_session, + scene_id=scene.id, + scene_in={"description": "提升公众演讲能力"}, + updated_by=1 + ) + + assert updated is not None + assert updated.description == "提升公众演讲能力" + + # 软删除 + success = await scene_service.soft_delete(db_session, id=scene.id) + assert success is True + + # 验证软删除 + deleted = await scene_service.get(db_session, scene.id) + assert deleted.is_deleted is True + + @pytest.mark.asyncio + async def test_session_lifecycle(self, db_session: AsyncSession): + """测试会话生命周期""" + # 创建场景 + scene_service = TrainingSceneService() + scene = await scene_service.create( + db_session, + obj_in={ + "name": "测试场景", + "category": "测试", + "status": TrainingSceneStatus.ACTIVE, + "is_public": True + }, + created_by=1, + updated_by=1 + ) + + # 开始会话 + session_service = TrainingSessionService() + start_response = await session_service.start_training( + db_session, + request={"scene_id": scene.id}, + user_id=1 + ) + + assert start_response.session_id is not None + + # 结束会话 + end_response = await session_service.end_training( + db_session, + session_id=start_response.session_id, + request={"generate_report": True}, + user_id=1 + ) + + assert end_response.session.status == "completed" + assert end_response.session.duration_seconds is not None + + # 报告应该被生成 + if end_response.report: + assert end_response.report.overall_score > 0 + assert len(end_response.report.strengths) > 0 + assert len(end_response.report.suggestions) > 0 diff --git a/backend/tests/test_user_service.py b/backend/tests/test_user_service.py new file mode 100644 index 0000000..7b6cc12 --- /dev/null +++ b/backend/tests/test_user_service.py @@ -0,0 +1,256 @@ +""" +用户服务测试 +""" + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import ConflictError, NotFoundError +from app.core.security import verify_password +from app.models.user import User +from app.schemas.user import UserCreate, UserFilter, UserUpdate +from app.services.user_service import UserService + + +@pytest.mark.asyncio +class TestUserService: + """用户服务测试类""" + + async def test_create_user(self, db_session: AsyncSession): + """测试创建用户""" + # 准备数据 + user_in = UserCreate( + username="newuser", + email="newuser@example.com", + password="password123", + full_name="New User", + role="trainee", + ) + + # 创建用户 + service = UserService(db_session) + user = await service.create_user(obj_in=user_in) + + # 验证结果 + assert user.username == "newuser" + assert user.email == "newuser@example.com" + assert user.full_name == "New User" + assert user.role == "trainee" + assert verify_password("password123", user.hashed_password) + + async def test_create_user_duplicate_username( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试创建重复用户名的用户""" + user_in = UserCreate( + username=test_user.username, # 使用已存在的用户名 + email="another@example.com", + password="password123", + ) + + service = UserService(db_session) + with pytest.raises(ConflictError) as exc_info: + await service.create_user(obj_in=user_in) + + assert f"用户名 {test_user.username} 已存在" in str(exc_info.value) + + async def test_create_user_duplicate_email( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试创建重复邮箱的用户""" + user_in = UserCreate( + username="anotheruser", + email=test_user.email, # 使用已存在的邮箱 + password="password123", + ) + + service = UserService(db_session) + with pytest.raises(ConflictError) as exc_info: + await service.create_user(obj_in=user_in) + + assert f"邮箱 {test_user.email} 已存在" in str(exc_info.value) + + async def test_get_by_username( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试根据用户名获取用户""" + service = UserService(db_session) + user = await service.get_by_username(test_user.username) + + assert user is not None + assert user.id == test_user.id + assert user.username == test_user.username + + async def test_get_by_email( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试根据邮箱获取用户""" + service = UserService(db_session) + user = await service.get_by_email(test_user.email) + + assert user is not None + assert user.id == test_user.id + assert user.email == test_user.email + + async def test_update_user( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试更新用户""" + user_update = UserUpdate( + full_name="Updated Name", + bio="Updated bio", + ) + + service = UserService(db_session) + user = await service.update_user( + user_id=test_user.id, + obj_in=user_update, + ) + + assert user.full_name == "Updated Name" + assert user.bio == "Updated bio" + + async def test_update_user_not_found(self, db_session: AsyncSession): + """测试更新不存在的用户""" + user_update = UserUpdate(full_name="Updated Name") + + service = UserService(db_session) + with pytest.raises(NotFoundError) as exc_info: + await service.update_user( + user_id=999, + obj_in=user_update, + ) + + assert "用户不存在" in str(exc_info.value) + + async def test_update_password( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试更新密码""" + service = UserService(db_session) + + # 更新密码 + user = await service.update_password( + user_id=test_user.id, + old_password="testpass123", + new_password="newpass123", + ) + + # 验证新密码 + assert verify_password("newpass123", user.hashed_password) + assert user.password_changed_at is not None + + async def test_update_password_wrong_old( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试使用错误的旧密码更新""" + service = UserService(db_session) + + with pytest.raises(ConflictError) as exc_info: + await service.update_password( + user_id=test_user.id, + old_password="wrongpass", + new_password="newpass123", + ) + + assert "旧密码错误" in str(exc_info.value) + + async def test_get_users_with_filter( + self, + db_session: AsyncSession, + test_user: User, + admin_user: User, + manager_user: User, + ): + """测试根据筛选条件获取用户""" + service = UserService(db_session) + + # 测试角色筛选 + filter_params = UserFilter(role="admin") + users, total = await service.get_users_with_filter( + skip=0, + limit=10, + filter_params=filter_params, + ) + assert total == 1 + assert users[0].id == admin_user.id + + # 测试关键词搜索 + filter_params = UserFilter(keyword="manager") + users, total = await service.get_users_with_filter( + skip=0, + limit=10, + filter_params=filter_params, + ) + assert total == 1 + assert users[0].id == manager_user.id + + async def test_authenticate_username( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试使用用户名认证""" + service = UserService(db_session) + + # 正确的密码 + user = await service.authenticate( + username=test_user.username, + password="testpass123", + ) + assert user is not None + assert user.id == test_user.id + + # 错误的密码 + user = await service.authenticate( + username=test_user.username, + password="wrongpass", + ) + assert user is None + + async def test_authenticate_email( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试使用邮箱认证""" + service = UserService(db_session) + + user = await service.authenticate( + username=test_user.email, + password="testpass123", + ) + assert user is not None + assert user.id == test_user.id + + async def test_soft_delete( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试软删除用户""" + service = UserService(db_session) + + # 软删除 + user = await service.soft_delete(db_obj=test_user) + assert user.is_deleted is True + assert user.deleted_at is not None + + # 验证无法通过常规方法获取 + user = await service.get_by_id(test_user.id) + assert user is None + diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/test_auth.py b/backend/tests/unit/test_auth.py new file mode 100644 index 0000000..9aafbf1 --- /dev/null +++ b/backend/tests/unit/test_auth.py @@ -0,0 +1,208 @@ +""" +认证模块单元测试 +""" +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.auth_service import AuthService +from app.schemas.auth import UserRegister +from app.core.security import verify_password, create_password_hash +from app.core.exceptions import InvalidCredentialsError, UsernameExistsError + + +@pytest.mark.asyncio +class TestAuthService: + """认证服务测试类""" + + async def test_user_registration(self, db_session: AsyncSession, test_user_data): + """测试用户注册""" + # 创建认证服务 + auth_service = AuthService(db_session) + + # 准备注册数据 + register_data = UserRegister(**test_user_data) + + # 注册用户 + user = await auth_service.create_user(register_data) + + # 验证用户创建成功 + assert user.id is not None + assert user.username == test_user_data["username"] + assert user.email == test_user_data["email"] + assert user.is_active is True + assert user.role == "trainee" + + # 验证密码已加密 + assert user.password_hash != test_user_data["password"] + assert verify_password(test_user_data["password"], user.password_hash) + + async def test_duplicate_username_registration( + self, + db_session: AsyncSession, + test_user_data + ): + """测试重复用户名注册""" + auth_service = AuthService(db_session) + + # 第一次注册 + register_data = UserRegister(**test_user_data) + await auth_service.create_user(register_data) + + # 尝试使用相同用户名再次注册 + with pytest.raises(UsernameExistsError): + await auth_service.create_user(register_data) + + async def test_user_login(self, db_session: AsyncSession, test_user_data): + """测试用户登录""" + auth_service = AuthService(db_session) + + # 先注册用户 + register_data = UserRegister(**test_user_data) + user = await auth_service.create_user(register_data) + + # 测试登录 + authenticated_user = await auth_service.authenticate_user( + username=test_user_data["username"], + password=test_user_data["password"] + ) + + assert authenticated_user.id == user.id + assert authenticated_user.username == user.username + + # 验证登录信息已更新 + assert authenticated_user.login_count == "1" + assert authenticated_user.failed_login_count == "0" + assert authenticated_user.last_login is not None + + async def test_login_with_email(self, db_session: AsyncSession, test_user_data): + """测试使用邮箱登录""" + auth_service = AuthService(db_session) + + # 注册用户 + register_data = UserRegister(**test_user_data) + await auth_service.create_user(register_data) + + # 使用邮箱登录 + user = await auth_service.authenticate_user( + username=test_user_data["email"], + password=test_user_data["password"] + ) + + assert user.email == test_user_data["email"] + + async def test_invalid_password_login( + self, + db_session: AsyncSession, + test_user_data + ): + """测试错误密码登录""" + auth_service = AuthService(db_session) + + # 注册用户 + register_data = UserRegister(**test_user_data) + await auth_service.create_user(register_data) + + # 尝试使用错误密码登录 + with pytest.raises(InvalidCredentialsError): + await auth_service.authenticate_user( + username=test_user_data["username"], + password="WrongPassword123!" + ) + + async def test_token_creation(self, db_session: AsyncSession, test_user_data): + """测试Token创建""" + auth_service = AuthService(db_session) + + # 注册用户 + register_data = UserRegister(**test_user_data) + user = await auth_service.create_user(register_data) + + # 创建tokens + tokens = await auth_service.create_tokens_for_user(user) + + assert "access_token" in tokens + assert "refresh_token" in tokens + assert tokens["token_type"] == "bearer" + assert tokens["expires_in"] > 0 + + +@pytest.mark.asyncio +class TestAuthAPI: + """认证API测试类""" + + async def test_register_endpoint(self, client: AsyncClient, test_user_data): + """测试注册端点""" + response = await client.post( + "/api/v1/auth/register", + json=test_user_data + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["message"] == "注册成功" + assert "access_token" in data["data"] + assert "refresh_token" in data["data"] + + async def test_login_endpoint(self, client: AsyncClient, test_user_data): + """测试登录端点""" + # 先注册 + await client.post("/api/v1/auth/register", json=test_user_data) + + # 测试登录 + response = await client.post( + "/api/v1/auth/login", + data={ + "username": test_user_data["username"], + "password": test_user_data["password"] + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert "access_token" in data["data"] + + async def test_refresh_token_endpoint( + self, + client: AsyncClient, + test_user_data + ): + """测试Token刷新端点""" + # 先注册并获取tokens + register_response = await client.post( + "/api/v1/auth/register", + json=test_user_data + ) + tokens = register_response.json()["data"] + + # 刷新token + response = await client.post( + "/api/v1/auth/refresh", + json={"refresh_token": tokens["refresh_token"]} + ) + + assert response.status_code == 200 + data = response.json() + assert "access_token" in data["data"] + assert data["data"]["access_token"] != tokens["access_token"] + + async def test_logout_endpoint(self, client: AsyncClient): + """测试登出端点""" + response = await client.post("/api/v1/auth/logout") + + assert response.status_code == 200 + data = response.json() + assert data["message"] == "登出成功" + + async def test_reset_password_request(self, client: AsyncClient): + """测试重置密码请求""" + response = await client.post( + "/api/v1/auth/reset-password", + json={"email": "test@example.com"} + ) + + assert response.status_code == 200 + data = response.json() + assert "如果该邮箱已注册" in data["message"] diff --git a/backend/verify_exam_settings.py b/backend/verify_exam_settings.py new file mode 100644 index 0000000..459148d --- /dev/null +++ b/backend/verify_exam_settings.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +验证考试设置功能是否正常工作 +""" +import asyncio +import json +import httpx +from datetime import datetime + +BASE_URL = "http://localhost:8000" + +async def main(): + async with httpx.AsyncClient() as client: + # 1. 登录获取token + print("=== 考试设置功能验证 ===\n") + print("1. 登录管理员账号...") + login_resp = await client.post( + f"{BASE_URL}/api/v1/auth/login", + json={"username": "admin", "password": "Admin123!"} + ) + login_data = login_resp.json() + if login_data['code'] != 200: + print(f"登录失败: {login_data}") + return + + token = login_data["data"]["token"]["access_token"] + headers = {"Authorization": f"Bearer {token}"} + print("✓ 登录成功\n") + + # 2. 获取课程7的当前考试设置 + print("2. 获取课程7的当前考试设置...") + get_resp = await client.get( + f"{BASE_URL}/api/v1/courses/7/exam-settings", + headers=headers + ) + get_data = get_resp.json() + + if get_data['code'] == 200 and get_data['data']: + print("✓ 成功获取考试设置") + current = get_data['data'] + print(f"\n当前设置:") + print(f" - 单选题: {current['single_choice_count']}题") + print(f" - 多选题: {current['multiple_choice_count']}题") + print(f" - 判断题: {current['true_false_count']}题") + print(f" - 填空题: {current['fill_blank_count']}题") + print(f" - 考试时长: {current['duration_minutes']}分钟") + print(f" - 难度等级: {current['difficulty_level']}") + print(f" - 是否启用: {'是' if current['is_enabled'] else '否'}") + print(f" - 更新时间: {current['updated_at']}") + else: + print(f"✗ 获取失败: {get_data}") + + # 3. 测试更新功能 + print("\n3. 测试更新考试设置...") + test_settings = { + "single_choice_count": 25, + "multiple_choice_count": 12, + "true_false_count": 10, + "fill_blank_count": 6, + "duration_minutes": 120, + "difficulty_level": 5, + "is_enabled": True + } + + update_resp = await client.post( + f"{BASE_URL}/api/v1/courses/7/exam-settings", + json=test_settings, + headers=headers + ) + update_data = update_resp.json() + + if update_data['code'] == 200: + print("✓ 成功更新考试设置") + + # 4. 再次获取验证 + print("\n4. 再次获取验证更新...") + verify_resp = await client.get( + f"{BASE_URL}/api/v1/courses/7/exam-settings", + headers=headers + ) + verify_data = verify_resp.json() + + if verify_data['code'] == 200 and verify_data['data']: + updated = verify_data['data'] + print("\n更新后的设置:") + print(f" - 单选题: {updated['single_choice_count']}题") + print(f" - 多选题: {updated['multiple_choice_count']}题") + print(f" - 判断题: {updated['true_false_count']}题") + print(f" - 填空题: {updated['fill_blank_count']}题") + print(f" - 考试时长: {updated['duration_minutes']}分钟") + print(f" - 难度等级: {updated['difficulty_level']}") + print(f" - 更新时间: {updated['updated_at']}") + + # 验证是否正确更新 + all_correct = ( + updated['single_choice_count'] == test_settings['single_choice_count'] and + updated['multiple_choice_count'] == test_settings['multiple_choice_count'] and + updated['true_false_count'] == test_settings['true_false_count'] and + updated['fill_blank_count'] == test_settings['fill_blank_count'] and + updated['duration_minutes'] == test_settings['duration_minutes'] and + updated['difficulty_level'] == test_settings['difficulty_level'] + ) + + if all_correct: + print("\n✅ 考试设置功能完全正常!数据能够正确保存和读取。") + else: + print("\n❌ 数据更新不正确!") + else: + print(f"✗ 验证失败: {verify_data}") + else: + print(f"✗ 更新失败: {update_data}") + + # 5. 提示前端检查 + print("\n=== 前端检查建议 ===") + print("如果前端仍然显示默认值而不是实际保存的值,请检查:") + print("1. 刷新页面(F5)后是否正确显示") + print("2. 浏览器控制台是否有错误") + print("3. Network面板中API请求是否成功") + print("4. 清除浏览器缓存后重试") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/团队看板功能验证指南.md b/backend/团队看板功能验证指南.md new file mode 100644 index 0000000..b1388f5 --- /dev/null +++ b/backend/团队看板功能验证指南.md @@ -0,0 +1,224 @@ +# 团队看板功能验证指南 + +## 功能概述 + +团队看板功能已完成真实数据库对接,包括: +- ✅ 团队概览统计(成员数、学习进度、考试成绩、课程完成率) +- ✅ 学习进度图表(Top 5成员8周进度趋势) +- ✅ 课程完成分布饼图(已完成/进行中/未开始) +- ✅ 能力短板雷达图(6个能力维度) +- ✅ 排行榜(学习时长Top 5、成绩Top 5) +- ✅ 团队动态(最近20条活动记录) + +## 权限控制 + +- **管理员/经理(admin/manager)**:查看所有团队数据 +- **普通用户(trainee)**:只查看自己所在团队数据 + +## 快速验证 + +### 1. 启动服务 + +```bash +# 确保数据库和后端服务运行 +cd kaopeilian-backend +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# 启动前端服务 +cd kaopeilian-frontend +npm run dev +``` + +### 2. 前端验证 + +访问:http://localhost:3001/manager/team-dashboard + +**检查项**: +- [ ] 概览卡片显示真实数据(不是硬编码的32、78.5%等) +- [ ] 学习进度图表正确渲染(显示真实成员名称) +- [ ] 课程完成分布饼图显示真实数据 +- [ ] 能力短板雷达图显示真实能力维度 +- [ ] 两个排行榜显示真实成员数据 +- [ ] 团队动态显示最近活动记录 +- [ ] 导出按钮已移除 +- [ ] 页面无控制台错误 + +### 3. 后端API验证 + +#### 方式一:使用测试脚本 + +```bash +cd kaopeilian-backend + +# 修改test_team_dashboard.py中的TOKEN +# 然后运行 +python test_team_dashboard.py +``` + +#### 方式二:使用curl + +```bash +# 替换YOUR_TOKEN为实际token +TOKEN="YOUR_TOKEN" + +# 1. 测试团队概览 +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:8000/api/v1/team/dashboard/overview + +# 2. 测试学习进度 +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:8000/api/v1/team/dashboard/progress + +# 3. 测试课程分布 +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:8000/api/v1/team/dashboard/course-distribution + +# 4. 测试能力分析 +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:8000/api/v1/team/dashboard/ability-analysis + +# 5. 测试排行榜 +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:8000/api/v1/team/dashboard/rankings + +# 6. 测试团队动态 +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:8000/api/v1/team/dashboard/activities +``` + +### 4. 权限验证 + +#### 测试管理员权限 +1. 使用admin账号登录 +2. 访问团队看板 +3. 应该能看到所有团队的数据 + +#### 测试普通用户权限 +1. 使用trainee账号登录 +2. 访问团队看板 +3. 应该只看到自己所在团队的数据 + +## 数据说明 + +### 概览统计 + +- **团队成员数**:从user_teams表统计 +- **平均学习进度**:基于考试完成情况计算 +- **平均考试成绩**:使用exams表的round1_score字段 +- **课程完成率**:已完成课程数 / 总课程数 + +### 学习进度图表 + +- 显示学习时长Top 5成员 +- 8周进度趋势(基于考试完成时间) + +### 课程完成分布 + +- **已完成**:有及格成绩(≥60分)的课程 +- **进行中**:有考试记录但未及格的课程 +- **未开始**:总课程数 - 已完成 - 进行中 + +### 能力分析 + +- 从practice_reports表的ability_dimensions JSON字段聚合 +- 平均分<80的能力作为短板 + +### 排行榜 + +- **学习时长**:从practice_sessions聚合duration_seconds +- **成绩排行**:从exams聚合round1_score平均值 +- 只显示Top 5 + +### 团队动态 + +- 最近的考试记录(来自exams表) +- 最近的陪练记录(来自practice_sessions表) +- 按时间倒序,最多20条 + +## 空数据处理 + +如果数据库中没有足够的数据: +- 概览统计会显示0值 +- 图表会显示空状态 +- 排行榜会显示空列表 +- 动态会显示空列表 + +这是正常的,前端会友好地展示空状态。 + +## 常见问题 + +### Q1: 页面显示"加载失败" + +**原因**: +- 后端服务未启动 +- 数据库连接失败 +- Token已过期 + +**解决**: +1. 检查后端服务是否运行:`curl http://localhost:8000/health` +2. 检查数据库连接 +3. 重新登录获取新token + +### Q2: 数据全是0 + +**原因**: +- 数据库中没有相关数据 +- 用户不属于任何团队 + +**解决**: +1. 确认数据库中有teams、user_teams数据 +2. 确认有exams、practice_sessions数据 +3. 使用admin账号登录(可以看到所有团队) + +### Q3: 图表不显示 + +**原因**: +- 浏览器控制台有错误 +- echarts初始化失败 + +**解决**: +1. 打开浏览器控制台查看错误 +2. 刷新页面 +3. 检查网络请求是否成功 + +### Q4: 排行榜为空 + +**原因**: +- 团队成员没有陪练或考试记录 + +**解决**: +- 这是正常的,等待用户完成陪练和考试后会自动显示 + +## 技术实现 + +### 后端文件 +- `kaopeilian-backend/app/api/v1/team_dashboard.py` - API接口 +- 使用SQLAlchemy聚合函数(AVG、SUM、COUNT) +- 支持权限控制(admin/manager查看全部,trainee查看自己团队) + +### 前端文件 +- `kaopeilian-frontend/src/api/teamDashboard.ts` - API封装 +- `kaopeilian-frontend/src/views/manager/team-dashboard.vue` - 页面组件 +- 使用echarts渲染图表 +- 使用request.ts发起API请求 + +## 后续优化建议 + +1. **日期范围筛选**:实现dateRange参数的实际功能 +2. **趋势计算**:实现真实的趋势对比(对比上周/上月) +3. **缓存优化**:使用Redis缓存统计数据 +4. **实时刷新**:添加WebSocket实时更新 +5. **数据导出**:实现Excel/PDF导出功能 + +## 联调记录 + +完成时间:2025-10-XX + +- ✅ 后端6个API接口全部完成 +- ✅ 前端API封装完成 +- ✅ 前端页面改造完成 +- ✅ 无linter错误 +- ✅ 权限控制实现 +- ✅ 导出按钮已移除 +- ⏳ 待数据验证 + diff --git a/backend/将调用工作流-联调过半-考陪练kaopeilian_final_complete_backup_20250923_025629.sql b/backend/将调用工作流-联调过半-考陪练kaopeilian_final_complete_backup_20250923_025629.sql new file mode 100644 index 0000000..d53d6bd --- /dev/null +++ b/backend/将调用工作流-联调过半-考陪练kaopeilian_final_complete_backup_20250923_025629.sql @@ -0,0 +1,841 @@ +-- MySQL dump 10.13 Distrib 8.0.43, for Linux (aarch64) +-- +-- Host: localhost Database: kaopeilian +-- ------------------------------------------------------ +-- Server version 8.0.43 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Current Database: `kaopeilian` +-- + +/*!40000 DROP DATABASE IF EXISTS `kaopeilian`*/; + +CREATE DATABASE /*!32312 IF NOT EXISTS*/ `kaopeilian` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci */ /*!80016 DEFAULT ENCRYPTION='N' */; + +USE `kaopeilian`; + +-- +-- Table structure for table `course_exam_settings` +-- + +DROP TABLE IF EXISTS `course_exam_settings`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `course_exam_settings` ( + `id` int NOT NULL AUTO_INCREMENT, + `course_id` int NOT NULL COMMENT '课程ID', + `single_choice_count` int NOT NULL DEFAULT '10' COMMENT '单选题数量', + `multiple_choice_count` int NOT NULL DEFAULT '5' COMMENT '多选题数量', + `true_false_count` int NOT NULL DEFAULT '5' COMMENT '判断题数量', + `fill_blank_count` int NOT NULL DEFAULT '0' COMMENT '填空题数量', + `duration_minutes` int NOT NULL DEFAULT '60' COMMENT '考试时长(分钟)', + `difficulty_level` int NOT NULL DEFAULT '3' COMMENT '难度系数(1-5)', + `passing_score` int NOT NULL DEFAULT '60' COMMENT '及格分数', + `is_enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', + `show_answer_immediately` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否立即显示答案', + `allow_retake` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否允许重考', + `max_retake_times` int DEFAULT NULL COMMENT '最大重考次数', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `is_deleted` tinyint(1) NOT NULL DEFAULT '0', + `deleted_at` datetime DEFAULT NULL, + `deleted_by` int DEFAULT NULL COMMENT '删除人ID', + PRIMARY KEY (`id`), + UNIQUE KEY `course_id` (`course_id`), + KEY `ix_course_exam_settings_id` (`id`), + CONSTRAINT `course_exam_settings_ibfk_1` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程考试设置表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `course_exam_settings` +-- + +LOCK TABLES `course_exam_settings` WRITE; +/*!40000 ALTER TABLE `course_exam_settings` DISABLE KEYS */; +INSERT INTO `course_exam_settings` (`id`, `course_id`, `single_choice_count`, `multiple_choice_count`, `true_false_count`, `fill_blank_count`, `duration_minutes`, `difficulty_level`, `passing_score`, `is_enabled`, `show_answer_immediately`, `allow_retake`, `max_retake_times`, `created_at`, `updated_at`, `created_by`, `updated_by`, `is_deleted`, `deleted_at`, `deleted_by`) VALUES (1,1,5,3,2,0,60,2,80,1,1,1,3,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(2,2,4,2,2,2,45,1,75,1,1,1,3,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(3,3,3,2,3,2,50,2,80,1,0,1,2,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(4,4,4,3,2,1,55,2,85,1,1,1,3,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(5,5,5,2,2,1,40,1,70,1,1,1,5,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(6,6,3,2,3,2,45,1,75,1,1,1,3,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(7,7,4,2,2,2,50,2,80,1,0,1,2,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(8,8,5,3,2,0,60,3,85,1,1,1,2,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(9,9,4,2,4,0,50,2,80,1,1,1,3,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(10,10,3,2,3,2,45,1,75,1,1,1,3,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(11,11,5,3,2,0,60,3,90,1,0,1,2,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(12,12,4,2,4,0,40,1,70,1,1,1,5,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL); +/*!40000 ALTER TABLE `course_exam_settings` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `course_materials` +-- + +DROP TABLE IF EXISTS `course_materials`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `course_materials` ( + `id` int NOT NULL AUTO_INCREMENT, + `course_id` int NOT NULL COMMENT '所属课程ID', + `name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资料名称', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '资料描述', + `file_url` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件URL', + `file_type` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件类型', + `file_size` int NOT NULL COMMENT '文件大小(字节)', + `sort_order` int DEFAULT '0' COMMENT '排序序号', + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_course_id` (`course_id`), + KEY `idx_is_deleted` (`is_deleted`), + CONSTRAINT `course_materials_ibfk_1` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程资料表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `course_materials` +-- + +LOCK TABLES `course_materials` WRITE; +/*!40000 ALTER TABLE `course_materials` DISABLE KEYS */; +/*!40000 ALTER TABLE `course_materials` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `courses` +-- + +DROP TABLE IF EXISTS `courses`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `courses` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '课程名称', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '课程描述', + `category` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '课程分类', + `status` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'draft' COMMENT '课程状态', + `cover_image` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '封面图片', + `duration_hours` decimal(5,2) DEFAULT NULL COMMENT '课程时长(小时)', + `difficulty_level` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '难度等级', + `tags` json DEFAULT NULL COMMENT '标签列表', + `published_at` datetime DEFAULT NULL COMMENT '发布时间', + `publisher_id` int DEFAULT NULL COMMENT '发布人ID', + `sort_order` int DEFAULT '0' COMMENT '排序', + `is_featured` tinyint(1) DEFAULT '0' COMMENT '是否推荐', + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_status` (`status`), + KEY `idx_category` (`category`), + KEY `idx_is_featured` (`is_featured`), + KEY `idx_is_deleted` (`is_deleted`), + KEY `idx_sort_order` (`sort_order`) +) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程信息表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `courses` +-- + +LOCK TABLES `courses` WRITE; +/*!40000 ALTER TABLE `courses` DISABLE KEYS */; +INSERT INTO `courses` (`id`, `name`, `description`, `category`, `status`, `cover_image`, `duration_hours`, `difficulty_level`, `tags`, `published_at`, `publisher_id`, `sort_order`, `is_featured`, `is_deleted`, `deleted_at`, `created_by`, `updated_by`, `created_at`, `updated_at`) VALUES (1,'皮肤生理学基础','学习皮肤结构、功能和常见问题,为专业护理打下坚实基础','technology','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(2,'医美产品知识与应用','全面了解各类医美产品的成分、功效和适用人群,掌握产品推荐技巧','technology','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(3,'美容仪器操作与维护','掌握各类美容仪器的操作方法、注意事项和日常维护保养','technology','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(4,'医美项目介绍与咨询','详细了解各类医美项目的原理、效果和适应症,提升咨询专业度','business','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(5,'轻医美销售技巧','学习专业的销售话术、客户需求分析和成交技巧,提升业绩能力','business','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(6,'客户服务与投诉处理','提升服务意识,掌握客户投诉处理的方法和技巧,维护品牌形象','management','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(7,'社媒营销与私域运营','学习如何通过社交媒体进行品牌推广和客户维护,建立私域流量','business','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(8,'门店运营管理','学习门店日常管理、团队建设和业绩管理的方法和技巧','management','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(9,'卫生消毒与感染控制','学习医美机构的卫生标准和消毒流程,确保服务安全合规','general','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(10,'美容心理学','了解客户心理需求,掌握沟通技巧,提升服务满意度','general','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(11,'法律法规与行业规范','学习医美行业相关法律法规,确保合规经营','general','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(12,'新员工入职培训','新员工必修课程,包含企业文化、基础知识和操作规范','general','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'); +/*!40000 ALTER TABLE `courses` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `exam_results` +-- + +DROP TABLE IF EXISTS `exam_results`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `exam_results` ( + `id` int NOT NULL AUTO_INCREMENT, + `exam_id` int NOT NULL COMMENT '考试ID', + `question_id` int NOT NULL COMMENT '题目ID', + `user_answer` json DEFAULT NULL COMMENT '用户答案', + `is_correct` tinyint(1) NOT NULL COMMENT '是否正确', + `score` decimal(5,2) NOT NULL COMMENT '考试得分', + `answer_time` int DEFAULT NULL COMMENT '答题时长(秒)', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_exam_id` (`exam_id`), + KEY `idx_question_id` (`question_id`), + CONSTRAINT `exam_results_ibfk_1` FOREIGN KEY (`exam_id`) REFERENCES `exams` (`id`) ON DELETE CASCADE, + CONSTRAINT `exam_results_ibfk_2` FOREIGN KEY (`question_id`) REFERENCES `questions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试结果表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `exam_results` +-- + +LOCK TABLES `exam_results` WRITE; +/*!40000 ALTER TABLE `exam_results` DISABLE KEYS */; +/*!40000 ALTER TABLE `exam_results` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `exams` +-- + +DROP TABLE IF EXISTS `exams`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `exams` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL COMMENT '用户ID', + `course_id` int NOT NULL COMMENT '课程ID', + `exam_name` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '考试名称', + `question_count` int DEFAULT NULL COMMENT '题目数量', + `total_score` decimal(5,2) DEFAULT NULL COMMENT '总分', + `pass_score` decimal(5,2) DEFAULT NULL COMMENT '及格分', + `start_time` datetime DEFAULT NULL COMMENT '开始时间', + `end_time` datetime DEFAULT NULL COMMENT '结束时间', + `duration_minutes` int DEFAULT NULL COMMENT '考试时长(分钟)', + `score` decimal(5,2) DEFAULT NULL COMMENT '得分', + `is_passed` tinyint(1) DEFAULT '0' COMMENT '是否通过', + `status` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'pending' COMMENT '考试状态', + `questions` json DEFAULT NULL COMMENT '题目数据(JSON格式)', + `answers` json DEFAULT NULL COMMENT '答案数据(JSON格式)', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_course_id` (`course_id`), + KEY `idx_status` (`status`), + CONSTRAINT `exams_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `exams_ibfk_2` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试记录表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `exams` +-- + +LOCK TABLES `exams` WRITE; +/*!40000 ALTER TABLE `exams` DISABLE KEYS */; +/*!40000 ALTER TABLE `exams` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `growth_paths` +-- + +DROP TABLE IF EXISTS `growth_paths`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `growth_paths` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '成长路径名称', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '路径描述', + `target_role` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目标角色', + `courses` json DEFAULT NULL COMMENT '课程列表', + `estimated_duration_days` int DEFAULT NULL COMMENT '预计完成天数', + `is_active` tinyint(1) DEFAULT '1' COMMENT '是否启用', + `sort_order` int DEFAULT '0' COMMENT '排序顺序', + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_is_active` (`is_active`), + KEY `idx_is_deleted` (`is_deleted`), + KEY `idx_sort_order` (`sort_order`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成长路径表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `growth_paths` +-- + +LOCK TABLES `growth_paths` WRITE; +/*!40000 ALTER TABLE `growth_paths` DISABLE KEYS */; +INSERT INTO `growth_paths` (`id`, `name`, `description`, `target_role`, `courses`, `estimated_duration_days`, `is_active`, `sort_order`, `is_deleted`, `deleted_at`, `created_at`, `updated_at`) VALUES (1,'美容顾问成长路径','从初级美容顾问到资深顾问的完整成长路径,包含专业技能和销售能力提升','资深美容顾问',NULL,NULL,1,0,0,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(2,'美容技师进阶路径','美容技师的技能进阶路径,从基础护理到高级项目操作','高级美容技师',NULL,NULL,1,0,0,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(3,'管理岗位培养路径','从一线员工到管理岗位的培养路径,包含领导力和管理技能','店长/区域经理',NULL,NULL,1,0,0,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(4,'医美咨询师专业路径','医美咨询师的专业发展路径,深度掌握医美项目知识','资深医美咨询师',NULL,NULL,1,0,0,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'); +/*!40000 ALTER TABLE `growth_paths` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `knowledge_points` +-- + +DROP TABLE IF EXISTS `knowledge_points`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `knowledge_points` ( + `id` int NOT NULL AUTO_INCREMENT, + `course_id` int DEFAULT NULL COMMENT '所属课程ID', + `name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '知识点名称', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '知识点描述', + `parent_id` int DEFAULT NULL COMMENT '父知识点ID', + `level` int DEFAULT '1' COMMENT '层级深度', + `path` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '知识点路径', + `sort_order` int DEFAULT '0' COMMENT '排序顺序', + `weight` decimal(3,2) DEFAULT '1.00' COMMENT '权重', + `is_required` tinyint(1) DEFAULT '1' COMMENT '是否必修', + `estimated_hours` decimal(4,2) DEFAULT NULL COMMENT '预计学习时间(小时)', + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_course_id` (`course_id`), + KEY `idx_parent_id` (`parent_id`), + KEY `idx_is_deleted` (`is_deleted`), + KEY `idx_sort_order` (`sort_order`), + CONSTRAINT `knowledge_points_ibfk_1` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE, + CONSTRAINT `knowledge_points_ibfk_2` FOREIGN KEY (`parent_id`) REFERENCES `knowledge_points` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识点表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `knowledge_points` +-- + +LOCK TABLES `knowledge_points` WRITE; +/*!40000 ALTER TABLE `knowledge_points` DISABLE KEYS */; +/*!40000 ALTER TABLE `knowledge_points` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `material_knowledge_points` +-- + +DROP TABLE IF EXISTS `material_knowledge_points`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `material_knowledge_points` ( + `id` int NOT NULL AUTO_INCREMENT, + `material_id` int NOT NULL COMMENT '资料ID', + `knowledge_point_id` int NOT NULL COMMENT '知识点ID', + `sort_order` int DEFAULT '0' COMMENT '排序顺序', + `is_ai_generated` tinyint(1) DEFAULT '0' COMMENT '是否AI生成', + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_material_knowledge` (`material_id`,`knowledge_point_id`), + KEY `idx_material_id` (`material_id`), + KEY `idx_knowledge_point_id` (`knowledge_point_id`), + CONSTRAINT `material_knowledge_points_ibfk_1` FOREIGN KEY (`material_id`) REFERENCES `course_materials` (`id`) ON DELETE CASCADE, + CONSTRAINT `material_knowledge_points_ibfk_2` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='资料知识点关联表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `material_knowledge_points` +-- + +LOCK TABLES `material_knowledge_points` WRITE; +/*!40000 ALTER TABLE `material_knowledge_points` DISABLE KEYS */; +/*!40000 ALTER TABLE `material_knowledge_points` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `position_courses` +-- + +DROP TABLE IF EXISTS `position_courses`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `position_courses` ( + `id` int NOT NULL AUTO_INCREMENT, + `position_id` int NOT NULL COMMENT '岗位ID', + `course_id` int NOT NULL COMMENT '课程ID', + `course_type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'required' COMMENT '课程类型(必修/选修)', + `priority` int DEFAULT '0' COMMENT '优先级', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `is_deleted` tinyint(1) NOT NULL DEFAULT '0', + `deleted_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uix_position_course` (`position_id`,`course_id`,`is_deleted`), + KEY `course_id` (`course_id`), + KEY `ix_position_courses_id` (`id`), + CONSTRAINT `position_courses_ibfk_1` FOREIGN KEY (`position_id`) REFERENCES `positions` (`id`), + CONSTRAINT `position_courses_ibfk_2` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=28 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位课程关联表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `position_courses` +-- + +LOCK TABLES `position_courses` WRITE; +/*!40000 ALTER TABLE `position_courses` DISABLE KEYS */; +INSERT INTO `position_courses` (`id`, `position_id`, `course_id`, `course_type`, `priority`, `created_at`, `updated_at`, `created_by`, `updated_by`, `is_deleted`, `deleted_at`) VALUES (1,1,6,'required',1,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(2,1,8,'required',2,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(3,1,11,'required',3,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(4,1,12,'required',4,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(5,3,1,'required',1,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(6,3,2,'required',2,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(7,3,5,'required',3,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(8,3,6,'required',4,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(9,3,10,'required',5,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(10,3,12,'required',6,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(11,4,1,'required',1,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(12,4,2,'required',2,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(13,4,4,'required',3,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(14,4,5,'required',4,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(15,4,9,'required',5,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(16,4,12,'required',6,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(17,5,1,'required',1,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(18,5,2,'required',2,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(19,5,3,'required',3,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(20,5,9,'required',4,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(21,5,12,'required',5,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(22,7,6,'required',1,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(23,7,10,'required',2,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(24,7,12,'required',3,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL); +/*!40000 ALTER TABLE `position_courses` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `position_members` +-- + +DROP TABLE IF EXISTS `position_members`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `position_members` ( + `id` int NOT NULL AUTO_INCREMENT, + `position_id` int NOT NULL COMMENT '岗位ID', + `user_id` int NOT NULL COMMENT '用户ID', + `role` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '在岗位中的角色', + `joined_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `is_deleted` tinyint(1) NOT NULL DEFAULT '0', + `deleted_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uix_position_user` (`position_id`,`user_id`,`is_deleted`), + KEY `user_id` (`user_id`), + KEY `ix_position_members_id` (`id`), + CONSTRAINT `position_members_ibfk_1` FOREIGN KEY (`position_id`) REFERENCES `positions` (`id`), + CONSTRAINT `position_members_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位成员关联表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `position_members` +-- + +LOCK TABLES `position_members` WRITE; +/*!40000 ALTER TABLE `position_members` DISABLE KEYS */; +INSERT INTO `position_members` (`id`, `position_id`, `user_id`, `role`, `joined_at`, `created_at`, `updated_at`, `created_by`, `updated_by`, `is_deleted`, `deleted_at`) VALUES (1,1,3,NULL,'2025-09-22 18:42:32','2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(2,1,4,NULL,'2025-09-22 18:42:32','2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(3,3,5,NULL,'2025-09-22 18:42:32','2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(4,4,6,NULL,'2025-09-22 18:42:32','2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(5,5,7,NULL,'2025-09-22 18:42:32','2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(6,7,8,NULL,'2025-09-22 18:42:32','2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL); +/*!40000 ALTER TABLE `position_members` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `positions` +-- + +DROP TABLE IF EXISTS `positions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `positions` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '岗位名称', + `code` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '岗位代码', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '岗位描述', + `parent_id` int DEFAULT NULL COMMENT '上级岗位ID', + `status` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'active' COMMENT '岗位状态', + `skills` json DEFAULT NULL COMMENT '核心技能', + `level` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '岗位级别', + `sort_order` int DEFAULT '0' COMMENT '排序', + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `code` (`code`), + KEY `parent_id` (`parent_id`), + KEY `idx_positions_name` (`name`), + CONSTRAINT `positions_ibfk_1` FOREIGN KEY (`parent_id`) REFERENCES `positions` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位信息表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `positions` +-- + +LOCK TABLES `positions` WRITE; +/*!40000 ALTER TABLE `positions` DISABLE KEYS */; +INSERT INTO `positions` (`id`, `name`, `code`, `description`, `parent_id`, `status`, `skills`, `level`, `sort_order`, `is_deleted`, `deleted_at`, `created_by`, `updated_by`, `created_at`, `updated_at`) VALUES (1,'区域经理','region_manager','负责多家门店的运营管理和业绩达成,制定区域发展战略',NULL,'active',NULL,'expert',0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(2,'店长','store_manager','负责门店日常运营管理,团队建设和业绩达成',NULL,'active',NULL,'senior',0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(3,'美容顾问','beauty_consultant','为客户提供专业的美容咨询和个性化方案设计',NULL,'active',NULL,'intermediate',0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(4,'医美咨询师','medical_beauty_consultant','提供医疗美容项目咨询和专业方案制定',NULL,'active',NULL,'senior',0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(5,'美容技师','beauty_therapist','为客户提供专业的美容护理和技术服务',NULL,'active',NULL,'intermediate',0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(6,'美容护士','beauty_nurse','协助医生进行医美项目操作,负责术后护理指导',NULL,'active',NULL,'intermediate',0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(7,'前台接待','receptionist','负责客户接待、预约管理和前台事务处理',NULL,'active',NULL,'junior',0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(8,'市场专员','marketing_specialist','负责门店营销活动策划、执行和客户维护',NULL,'active',NULL,'intermediate',0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'); +/*!40000 ALTER TABLE `positions` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `questions` +-- + +DROP TABLE IF EXISTS `questions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `questions` ( + `id` int NOT NULL AUTO_INCREMENT, + `course_id` int NOT NULL COMMENT '所属课程ID', + `question_type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '题目类型', + `title` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '题目标题', + `content` text COLLATE utf8mb4_unicode_ci COMMENT '题目内容', + `options` json DEFAULT NULL COMMENT '选项内容', + `correct_answer` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '正确答案', + `explanation` text COLLATE utf8mb4_unicode_ci COMMENT '答案解析', + `score` float DEFAULT '10' COMMENT '题目分值', + `difficulty` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT 'medium' COMMENT '难度等级', + `tags` json DEFAULT NULL COMMENT '题目标签', + `usage_count` int DEFAULT '0' COMMENT '使用次数', + `correct_count` int DEFAULT '0' COMMENT '答对次数', + `is_active` tinyint(1) DEFAULT '1' COMMENT '是否启用', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_course_id` (`course_id`), + KEY `idx_question_type` (`question_type`), + KEY `idx_difficulty` (`difficulty`), + KEY `idx_is_active` (`is_active`), + CONSTRAINT `questions_ibfk_1` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题目表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `questions` +-- + +LOCK TABLES `questions` WRITE; +/*!40000 ALTER TABLE `questions` DISABLE KEYS */; +INSERT INTO `questions` (`id`, `course_id`, `question_type`, `title`, `content`, `options`, `correct_answer`, `explanation`, `score`, `difficulty`, `tags`, `usage_count`, `correct_count`, `is_active`, `created_at`, `updated_at`) VALUES (1,1,'single_choice','皮肤的最外层是什么?',NULL,'[{\"text\": \"表皮\", \"label\": \"A\"}, {\"text\": \"真皮\", \"label\": \"B\"}, {\"text\": \"皮下组织\", \"label\": \"C\"}, {\"text\": \"角质层\", \"label\": \"D\"}]','A','A选项是表皮,这是皮肤的最外层,起到保护作用',10,'easy',NULL,0,0,1,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(2,2,'single_choice','透明质酸的主要功效是什么?',NULL,'[{\"text\": \"美白淡斑\", \"label\": \"A\"}, {\"text\": \"保湿补水\", \"label\": \"B\"}, {\"text\": \"紧致提升\", \"label\": \"C\"}, {\"text\": \"去除皱纹\", \"label\": \"D\"}]','B','B选项是保湿补水,透明质酸具有强大的保湿能力',10,'easy',NULL,0,0,1,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(3,3,'multiple_choice','射频美容仪的禁忌症包括哪些?',NULL,'[{\"text\": \"孕期和哺乳期\", \"label\": \"A\"}, {\"text\": \"皮肤过敏\", \"label\": \"B\"}, {\"text\": \"心脏起搏器\", \"label\": \"C\"}, {\"text\": \"轻微痤疮\", \"label\": \"D\"}]','A,C','A和C选项都是射频美容仪的禁忌症,需要特别注意',15,'medium',NULL,0,0,1,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(4,4,'true_false','水光针适合所有肤质的客户',NULL,'[{\"text\": \"正确\", \"label\": \"A\"}, {\"text\": \"错误\", \"label\": \"B\"}]','B','水光针虽然适用范围广,但仍有一些禁忌症和不适合的肤质',10,'easy',NULL,0,0,1,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(5,4,'fill_blank','肉毒素注射后____小时内不能平躺','肉毒素注射后【】小时内不能平躺,以免影响药物分布效果。',NULL,'4','肉毒素注射后4小时内不能平躺,以免影响药物分布',10,'medium',NULL,0,0,1,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(6,5,'single_choice','客户咨询时最重要的是什么?',NULL,'[{\"text\": \"倾听客户需求\", \"label\": \"A\"}, {\"text\": \"推荐最贵的产品\", \"label\": \"B\"}, {\"text\": \"快速成交\", \"label\": \"C\"}, {\"text\": \"展示专业知识\", \"label\": \"D\"}]','A','A选项是倾听客户需求,这是专业咨询的基础',10,'easy',NULL,0,0,1,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(7,6,'single_choice','处理客户投诉的第一步是什么?',NULL,'[{\"text\": \"解释原因\", \"label\": \"A\"}, {\"text\": \"耐心倾听\", \"label\": \"B\"}, {\"text\": \"提供补偿\", \"label\": \"C\"}, {\"text\": \"转交上级\", \"label\": \"D\"}]','B','B选项是耐心倾听,让客户充分表达不满是处理投诉的第一步',10,'easy',NULL,0,0,1,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(8,7,'true_false','社交媒体营销只需要发布产品信息',NULL,'[{\"text\": \"正确\", \"label\": \"A\"}, {\"text\": \"错误\", \"label\": \"B\"}]','B','社交媒体营销需要内容多样化,包括教育内容、互动内容等',10,'easy',NULL,0,0,1,'2025-09-23 02:39:48','2025-09-23 02:39:48'); +/*!40000 ALTER TABLE `questions` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `teams` +-- + +DROP TABLE IF EXISTS `teams`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `teams` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '团队名称', + `code` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '团队代码', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '团队描述', + `team_type` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '团队类型', + `is_active` tinyint(1) DEFAULT '1' COMMENT '是否激活', + `leader_id` int DEFAULT NULL COMMENT '负责人ID', + `parent_id` int DEFAULT NULL COMMENT '父团队ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`), + UNIQUE KEY `code` (`code`), + KEY `leader_id` (`leader_id`), + KEY `parent_id` (`parent_id`), + KEY `idx_team_type` (`team_type`), + KEY `idx_is_active` (`is_active`), + CONSTRAINT `teams_ibfk_1` FOREIGN KEY (`leader_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, + CONSTRAINT `teams_ibfk_2` FOREIGN KEY (`parent_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='团队信息表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `teams` +-- + +LOCK TABLES `teams` WRITE; +/*!40000 ALTER TABLE `teams` DISABLE KEYS */; +INSERT INTO `teams` (`id`, `name`, `code`, `description`, `team_type`, `is_active`, `leader_id`, `parent_id`, `created_at`, `updated_at`, `is_deleted`, `deleted_at`) VALUES (1,'管理层','MANAGEMENT','负责公司整体战略规划和运营管理','department',1,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(2,'北京运营团队','BJ_OPERATIONS','负责北京地区所有门店的运营管理','department',1,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(3,'上海运营团队','SH_OPERATIONS','负责上海地区所有门店的运营管理','department',1,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(4,'技术培训部','TECH_TRAINING','负责员工技术培训和考核','department',1,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(5,'客服质量部','QUALITY_SERVICE','负责客户服务质量监督和改进','department',1,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(6,'新员工培训小组','NEW_EMPLOYEE_TRAINING','专门负责新员工入职培训','study_group',1,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL); +/*!40000 ALTER TABLE `teams` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `training_messages` +-- + +DROP TABLE IF EXISTS `training_messages`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `training_messages` ( + `id` int NOT NULL AUTO_INCREMENT, + `session_id` int NOT NULL COMMENT '会话ID', + `role` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消息角色', + `type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消息类型', + `content` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消息内容', + `voice_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '语音文件URL', + `voice_duration` int DEFAULT NULL COMMENT '语音时长(秒)', + `message_metadata` json DEFAULT NULL COMMENT '消息元数据', + `coze_message_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Coze消息ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_session_id` (`session_id`), + KEY `idx_role` (`role`), + CONSTRAINT `training_messages_ibfk_1` FOREIGN KEY (`session_id`) REFERENCES `training_sessions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='培训消息表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `training_messages` +-- + +LOCK TABLES `training_messages` WRITE; +/*!40000 ALTER TABLE `training_messages` DISABLE KEYS */; +/*!40000 ALTER TABLE `training_messages` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `training_reports` +-- + +DROP TABLE IF EXISTS `training_reports`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `training_reports` ( + `id` int NOT NULL AUTO_INCREMENT, + `session_id` int NOT NULL COMMENT '会话ID', + `user_id` int NOT NULL COMMENT '用户ID', + `overall_score` decimal(5,2) DEFAULT NULL COMMENT '总体评分', + `dimension_scores` json DEFAULT NULL COMMENT '各维度得分', + `strengths` text COLLATE utf8mb4_unicode_ci COMMENT '优势点', + `weaknesses` text COLLATE utf8mb4_unicode_ci COMMENT '待改进点', + `suggestions` text COLLATE utf8mb4_unicode_ci COMMENT '改进建议', + `detailed_analysis` text COLLATE utf8mb4_unicode_ci COMMENT '详细分析', + `transcript` text COLLATE utf8mb4_unicode_ci COMMENT '对话记录', + `statistics` json DEFAULT NULL COMMENT '统计数据', + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `session_id` (`session_id`), + KEY `idx_user_id` (`user_id`), + CONSTRAINT `training_reports_ibfk_1` FOREIGN KEY (`session_id`) REFERENCES `training_sessions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='培训报告表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `training_reports` +-- + +LOCK TABLES `training_reports` WRITE; +/*!40000 ALTER TABLE `training_reports` DISABLE KEYS */; +/*!40000 ALTER TABLE `training_reports` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `training_scenes` +-- + +DROP TABLE IF EXISTS `training_scenes`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `training_scenes` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '场景名称', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '场景描述', + `category` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '场景分类', + `ai_config` json DEFAULT NULL COMMENT 'AI配置', + `prompt_template` text COLLATE utf8mb4_unicode_ci COMMENT '提示模板', + `evaluation_criteria` json DEFAULT NULL COMMENT '评估标准', + `status` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'active' COMMENT '场景状态', + `is_public` tinyint(1) DEFAULT '1' COMMENT '是否公开', + `required_level` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '所需用户等级', + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_status` (`status`), + KEY `idx_category` (`category`), + KEY `idx_is_public` (`is_public`), + KEY `idx_is_deleted` (`is_deleted`) +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='培训场景表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `training_scenes` +-- + +LOCK TABLES `training_scenes` WRITE; +/*!40000 ALTER TABLE `training_scenes` DISABLE KEYS */; +INSERT INTO `training_scenes` (`id`, `name`, `description`, `category`, `ai_config`, `prompt_template`, `evaluation_criteria`, `status`, `is_public`, `required_level`, `is_deleted`, `deleted_at`, `created_by`, `updated_by`, `created_at`, `updated_at`) VALUES (1,'客户咨询模拟','模拟真实的客户咨询场景,练习专业咨询技巧','客户服务',NULL,NULL,NULL,'ACTIVE',1,NULL,0,NULL,NULL,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(2,'产品推荐演练','针对不同客户需求进行产品推荐的实战演练','销售技巧',NULL,NULL,NULL,'ACTIVE',1,NULL,0,NULL,NULL,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(3,'投诉处理训练','模拟各种客户投诉情况,训练处理技巧和话术','客户服务',NULL,NULL,NULL,'ACTIVE',1,NULL,0,NULL,NULL,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(4,'项目操作指导','美容项目操作的标准流程指导和安全注意事项','技术指导',NULL,NULL,NULL,'ACTIVE',1,NULL,0,NULL,NULL,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(5,'团队管理讨论','管理岗位的团队建设和绩效管理讨论','管理培训',NULL,NULL,NULL,'ACTIVE',1,NULL,0,NULL,NULL,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'); +/*!40000 ALTER TABLE `training_scenes` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `training_sessions` +-- + +DROP TABLE IF EXISTS `training_sessions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `training_sessions` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL COMMENT '用户ID', + `scene_id` int NOT NULL COMMENT '场景ID', + `coze_conversation_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Coze对话ID', + `start_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + `end_time` datetime DEFAULT NULL COMMENT '结束时间', + `duration_seconds` int DEFAULT NULL COMMENT '持续时长(秒)', + `status` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'active' COMMENT '会话状态', + `session_config` json DEFAULT NULL COMMENT '会话配置', + `total_score` decimal(5,2) DEFAULT NULL COMMENT '总分', + `evaluation_result` json DEFAULT NULL COMMENT '评估结果详情', + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_scene_id` (`scene_id`), + KEY `idx_status` (`status`), + CONSTRAINT `training_sessions_ibfk_1` FOREIGN KEY (`scene_id`) REFERENCES `training_scenes` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='培训会话表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `training_sessions` +-- + +LOCK TABLES `training_sessions` WRITE; +/*!40000 ALTER TABLE `training_sessions` DISABLE KEYS */; +/*!40000 ALTER TABLE `training_sessions` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `user_teams` +-- + +DROP TABLE IF EXISTS `user_teams`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_teams` ( + `user_id` int NOT NULL COMMENT '用户ID', + `team_id` int NOT NULL COMMENT '团队ID', + `role` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '在团队中的角色', + `joined_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + PRIMARY KEY (`user_id`,`team_id`), + KEY `team_id` (`team_id`), + CONSTRAINT `user_teams_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `user_teams_ibfk_2` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户团队关联表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `user_teams` +-- + +LOCK TABLES `user_teams` WRITE; +/*!40000 ALTER TABLE `user_teams` DISABLE KEYS */; +INSERT INTO `user_teams` (`user_id`, `team_id`, `role`, `joined_at`) VALUES (1,1,'leader','2025-09-22 18:42:32'),(2,1,'member','2025-09-22 18:42:32'),(3,2,'leader','2025-09-22 18:42:32'),(4,3,'leader','2025-09-22 18:42:32'),(5,2,'member','2025-09-22 18:42:32'),(5,4,'member','2025-09-22 18:42:32'),(6,2,'member','2025-09-22 18:42:32'),(6,4,'member','2025-09-22 18:42:32'),(7,3,'member','2025-09-22 18:42:32'),(7,4,'member','2025-09-22 18:42:32'),(8,3,'member','2025-09-22 18:42:32'),(8,6,'member','2025-09-22 18:42:32'); +/*!40000 ALTER TABLE `user_teams` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `users` +-- + +DROP TABLE IF EXISTS `users`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `users` ( + `id` int NOT NULL AUTO_INCREMENT, + `username` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户名', + `email` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '邮箱', + `phone` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '手机号', + `password_hash` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '密码哈希', + `full_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实姓名', + `gender` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '性别', + `avatar_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '头像URL', + `bio` text COLLATE utf8mb4_unicode_ci COMMENT '个人简介', + `school` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '学校', + `major` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '专业', + `role` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'trainee' COMMENT '用户角色', + `is_active` tinyint(1) DEFAULT '1' COMMENT '是否激活', + `is_verified` tinyint(1) DEFAULT '0' COMMENT '是否验证', + `last_login_at` datetime DEFAULT NULL COMMENT '最后登录时间', + `password_changed_at` datetime DEFAULT NULL COMMENT '密码修改时间', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + PRIMARY KEY (`id`), + UNIQUE KEY `username` (`username`), + UNIQUE KEY `email` (`email`), + UNIQUE KEY `phone` (`phone`), + KEY `idx_role` (`role`), + KEY `idx_is_active` (`is_active`) +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户信息表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `users` +-- + +LOCK TABLES `users` WRITE; +/*!40000 ALTER TABLE `users` DISABLE KEYS */; +INSERT INTO `users` (`id`, `username`, `email`, `phone`, `password_hash`, `full_name`, `gender`, `avatar_url`, `bio`, `school`, `major`, `role`, `is_active`, `is_verified`, `last_login_at`, `password_changed_at`, `created_at`, `updated_at`, `is_deleted`, `deleted_at`) VALUES (1,'superadmin','superadmin@ruimei.com','13800138001','$2b$12$jFhkYU3.Cd1kAfr64/073eayPquAr0z9WWUQEdOyFRmAqcxz.i10C','超级管理员','male',NULL,'负责系统整体管理和运营','睿美医疗美容学院','医疗美容管理','admin',1,0,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(2,'admin','admin@ruimei.com','13800138002','$2b$12$jFhkYU3.Cd1kAfr64/073eayPquAr0z9WWUQEdOyFRmAqcxz.i10C','系统管理员','female',NULL,'负责日常系统管理工作','睿美医疗美容学院','医疗美容技术','admin',1,0,'2025-09-22 18:54:56',NULL,'2025-09-23 02:38:24','2025-09-22 18:54:56',0,NULL),(3,'manager_beijing','manager.bj@ruimei.com','13800138003','$2b$12$jFhkYU3.Cd1kAfr64/073eayPquAr0z9WWUQEdOyFRmAqcxz.i10C','北京区域经理','male',NULL,'负责北京地区门店管理','北京医科大学','临床医学','manager',1,0,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(4,'manager_shanghai','manager.sh@ruimei.com','13800138004','$2b$12$jFhkYU3.Cd1kAfr64/073eayPquAr0z9WWUQEdOyFRmAqcxz.i10C','上海区域经理','female',NULL,'负责上海地区门店管理','复旦大学医学院','医疗美容','manager',1,0,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(5,'consultant_001','consultant001@ruimei.com','13800138005','$2b$12$jFhkYU3.Cd1kAfr64/073eayPquAr0z9WWUQEdOyFRmAqcxz.i10C','资深美容顾问','female',NULL,'专业美容咨询师,5年从业经验','上海健康医学院','医疗美容技术','trainee',1,0,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(6,'nurse_001','nurse001@ruimei.com','13800138006','$2b$12$jFhkYU3.Cd1kAfr64/073eayPquAr0z9WWUQEdOyFRmAqcxz.i10C','美容护士','female',NULL,'持证美容护士,专业技术过硬','首都医科大学','护理学','trainee',1,0,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(7,'therapist_001','therapist001@ruimei.com','13800138007','$2b$12$jFhkYU3.Cd1kAfr64/073eayPquAr0z9WWUQEdOyFRmAqcxz.i10C','美容技师','female',NULL,'专业美容技师,擅长面部护理','北京卫生职业学院','医疗美容技术','trainee',1,0,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(8,'receptionist_001','front001@ruimei.com','13800138008','$2b$12$jFhkYU3.Cd1kAfr64/073eayPquAr0z9WWUQEdOyFRmAqcxz.i10C','前台接待','female',NULL,'负责客户接待和预约管理','北京商贸职业学院','商务管理','trainee',1,0,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL); +/*!40000 ALTER TABLE `users` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Temporary view structure for view `v_user_course_progress` +-- + +DROP TABLE IF EXISTS `v_user_course_progress`; +/*!50001 DROP VIEW IF EXISTS `v_user_course_progress`*/; +SET @saved_cs_client = @@character_set_client; +/*!50503 SET character_set_client = utf8mb4 */; +/*!50001 CREATE VIEW `v_user_course_progress` AS SELECT + 1 AS `user_id`, + 1 AS `username`, + 1 AS `course_id`, + 1 AS `course_name`, + 1 AS `exam_count`, + 1 AS `avg_score`, + 1 AS `best_score`*/; +SET character_set_client = @saved_cs_client; + +-- +-- Dumping events for database 'kaopeilian' +-- + +-- +-- Dumping routines for database 'kaopeilian' +-- + +-- +-- Current Database: `kaopeilian` +-- + +USE `kaopeilian`; + +-- +-- Final view structure for view `v_user_course_progress` +-- + +/*!50001 DROP VIEW IF EXISTS `v_user_course_progress`*/; +/*!50001 SET @saved_cs_client = @@character_set_client */; +/*!50001 SET @saved_cs_results = @@character_set_results */; +/*!50001 SET @saved_col_connection = @@collation_connection */; +/*!50001 SET character_set_client = latin1 */; +/*!50001 SET character_set_results = latin1 */; +/*!50001 SET collation_connection = latin1_swedish_ci */; +/*!50001 CREATE ALGORITHM=UNDEFINED */ +/*!50013 DEFINER=`root`@`localhost` SQL SECURITY DEFINER */ +/*!50001 VIEW `v_user_course_progress` AS select `u`.`id` AS `user_id`,`u`.`username` AS `username`,`c`.`id` AS `course_id`,`c`.`name` AS `course_name`,count(distinct `e`.`id`) AS `exam_count`,avg(`e`.`score`) AS `avg_score`,max(`e`.`score`) AS `best_score` from ((`users` `u` join `courses` `c`) left join `exams` `e` on(((`e`.`user_id` = `u`.`id`) and (`e`.`course_id` = `c`.`id`) and (`e`.`status` = 'submitted')))) group by `u`.`id`,`c`.`id` */; +/*!50001 SET character_set_client = @saved_cs_client */; +/*!50001 SET character_set_results = @saved_cs_results */; +/*!50001 SET collation_connection = @saved_col_connection */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2025-09-22 18:56:30 diff --git a/backend/数据库架构-统一版.md b/backend/数据库架构-统一版.md new file mode 100644 index 0000000..004c9e1 --- /dev/null +++ b/backend/数据库架构-统一版.md @@ -0,0 +1,777 @@ +# 考培练系统统一数据库架构设计 + +## 数据库基本信息 +- 数据库名称:kaopeilian +- 字符集:utf8mb4 +- 排序规则:utf8mb4_unicode_ci +- 存储引擎:InnoDB + +## 更新历史 +- 2026-01-17:SCRM系统对接API功能 + * users表新增wework_userid字段(VARCHAR(64) UNIQUE),用于存储企微员工userid + * 新增SCRM对接API:获取员工岗位、获取岗位课程、搜索知识点、获取知识点详情 +- 2025-11-11:员工同步功能更新 + * users表email字段改为可空(支持没有邮箱的员工) + * 新增员工同步功能,从钉钉员工表同步在职员工 +- 2025-10-16:系统增强功能数据库更新 + * exam_mistakes表新增字段:mastery_status、difficulty、wrong_count、mastered_at(用于错题掌握状态) + * courses表确认字段:student_count、is_new(用于课程学员统计) + * tasks、task_courses、task_assignments表已完整实施(任务管理功能) + * system_logs表已完整实施(系统日志功能) +- 2025-10-14:courses表新增broadcast_audio_url和broadcast_generated_at字段(用于播课功能) +- 2025-10-13:新增practice_scenes陪练场景表(用于陪练中心功能) +- 2025-09-30:新增exam_mistakes错题记录表 +- 2025-09-30:course_exam_settings表新增essay_count字段(问答题数量) +- 2025-09-27:知识点表结构重大简化,废弃material_knowledge_points中间表 +- 2025-09-27:knowledge_points表新增material_id、type、source、topic_relation字段 +- 2025-09-27:course_materials表新增created_by、updated_by审计字段 +- 2025-09-27:统一远程和本地数据库结构,确保字段约束一致性 +- 2025-09-22:为positions表添加skills、level、sort_order字段 +- 2025-09-22:为users表添加school(学校)和major(专业)字段 +- 2025-09-22:为knowledge_points表添加created_by、updated_by审计字段 +- 2025-09-22:新增material_knowledge_points关联表,支持资料与知识点的关联管理(已废弃) + +## 一、用户管理模块 + +### 1.1 用户表 (users) +```sql +CREATE TABLE `users` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', + `email` VARCHAR(100) NULL UNIQUE COMMENT '邮箱(可选)', + `phone` VARCHAR(20) UNIQUE COMMENT '手机号', + `password_hash` VARCHAR(200) NOT NULL COMMENT '密码哈希', + `full_name` VARCHAR(100) COMMENT '姓名', + `gender` VARCHAR(10) COMMENT '性别: male/female', + `avatar_url` VARCHAR(500) COMMENT '头像URL', + `bio` TEXT COMMENT '个人简介', + `school` VARCHAR(100) COMMENT '学校', + `major` VARCHAR(100) COMMENT '专业', + `wework_userid` VARCHAR(64) UNIQUE COMMENT '企微员工userid(用于SCRM系统对接)', + `role` VARCHAR(20) DEFAULT 'trainee' COMMENT '系统角色: admin, manager, trainee', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否激活', + `is_verified` BOOLEAN DEFAULT FALSE COMMENT '是否验证', + `last_login_at` DATETIME COMMENT '最后登录时间', + `password_changed_at` DATETIME COMMENT '密码修改时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + INDEX idx_role (role), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; +``` + +### 1.2 团队表 (teams) +```sql +CREATE TABLE `teams` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL UNIQUE COMMENT '团队名称', + `code` VARCHAR(50) NOT NULL UNIQUE COMMENT '团队代码', + `description` TEXT COMMENT '团队描述', + `team_type` VARCHAR(50) DEFAULT 'department' COMMENT '团队类型: department, project, study_group', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否激活', + `leader_id` INT COMMENT '负责人ID', + `parent_id` INT COMMENT '父团队ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (leader_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (parent_id) REFERENCES teams(id) ON DELETE CASCADE, + INDEX idx_team_type (team_type), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='团队表'; +``` + +### 1.3 用户团队关联表 (user_teams) +```sql +CREATE TABLE `user_teams` ( + `user_id` INT NOT NULL, + `team_id` INT NOT NULL, + `role` VARCHAR(50) DEFAULT 'member' COMMENT '团队角色: member, leader', + `joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + PRIMARY KEY (user_id, team_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户团队关联表'; +``` + +## 二、组织与岗位管理模块 + +### 2.1 岗位表 (positions) +```sql +CREATE TABLE `positions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL COMMENT '岗位名称', + `code` VARCHAR(100) NOT NULL UNIQUE COMMENT '岗位编码', + `description` TEXT COMMENT '岗位描述', + `parent_id` INT NULL COMMENT '上级岗位ID', + `status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active/inactive', + `skills` JSON NULL COMMENT '核心技能', + `level` VARCHAR(20) NULL COMMENT '岗位等级: junior/intermediate/senior/expert', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME NULL COMMENT '删除时间', + `created_by` INT NULL COMMENT '创建人ID', + `updated_by` INT NULL COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (parent_id) REFERENCES positions(id) ON DELETE SET NULL, + INDEX idx_positions_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位表'; +``` + +### 2.2 岗位成员表 (position_members) +```sql +CREATE TABLE `position_members` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `position_id` INT NOT NULL COMMENT '岗位ID', + `user_id` INT NOT NULL COMMENT '用户ID', + `role` VARCHAR(50) DEFAULT 'member' COMMENT '岗位角色', + `joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME NULL COMMENT '删除时间', + UNIQUE KEY `idx_position_user` (`position_id`, `user_id`), + FOREIGN KEY (position_id) REFERENCES positions(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位成员关联表'; +``` + +### 2.3 岗位课程表 (position_courses) +```sql +CREATE TABLE `position_courses` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `position_id` INT NOT NULL COMMENT '岗位ID', + `course_id` INT NOT NULL COMMENT '课程ID', + `course_type` VARCHAR(20) NOT NULL DEFAULT 'required' COMMENT '课程类型: required/optional', + `priority` INT DEFAULT 0 COMMENT '优先级', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME NULL COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `idx_position_course` (`position_id`, `course_id`), + FOREIGN KEY (position_id) REFERENCES positions(id) ON DELETE CASCADE, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位课程关联表'; +``` + +## 三、课程管理模块 + +### 3.1 课程表 (courses) +```sql +CREATE TABLE `courses` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '课程名称', + `description` TEXT COMMENT '课程描述', + `category` ENUM('technology', 'management', 'business', 'general') DEFAULT 'general' COMMENT '课程分类', + `status` ENUM('draft', 'published', 'archived') DEFAULT 'draft' COMMENT '课程状态', + `cover_image` VARCHAR(500) COMMENT '封面图片URL', + `duration_hours` FLOAT COMMENT '课程时长(小时)', + `difficulty_level` INT COMMENT '难度等级(1-5)', + `tags` JSON COMMENT '标签列表', + `published_at` DATETIME COMMENT '发布时间', + `publisher_id` INT COMMENT '发布人ID', + `broadcast_audio_url` VARCHAR(500) COMMENT '播课音频URL', + `broadcast_generated_at` DATETIME COMMENT '播课生成时间', + `broadcast_status` VARCHAR(20) COMMENT '播课生成状态: pending/generating/completed/failed', + `broadcast_task_id` VARCHAR(100) COMMENT 'Coze工作流任务ID', + `broadcast_error_message` TEXT COMMENT '生成失败错误信息', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_featured` BOOLEAN DEFAULT FALSE COMMENT '是否推荐', + `student_count` INT DEFAULT 0 COMMENT '学员数量', + `is_new` BOOLEAN DEFAULT TRUE COMMENT '是否新课程', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_status (status), + INDEX idx_category (category), + INDEX idx_is_featured (is_featured), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程表'; +``` + +### 3.2 课程资料表 (course_materials) +```sql +CREATE TABLE `course_materials` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `name` VARCHAR(200) NOT NULL COMMENT '资料名称', + `description` TEXT COMMENT '资料描述', + `file_url` VARCHAR(500) NOT NULL COMMENT '文件URL', + `file_type` VARCHAR(50) NOT NULL COMMENT '文件类型', + `file_size` INT NOT NULL COMMENT '文件大小(字节)', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程资料表'; +``` + +### 3.3 知识点表 (knowledge_points) +```sql +CREATE TABLE `knowledge_points` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `material_id` INT COMMENT '关联资料ID', + `name` VARCHAR(200) NOT NULL COMMENT '知识点名称', + `description` TEXT COMMENT '知识点描述', + `type` VARCHAR(50) DEFAULT '概念定义' COMMENT '知识点类型', + `source` TINYINT(1) DEFAULT 0 COMMENT '来源:0=手动,1=AI分析', + `topic_relation` VARCHAR(200) COMMENT '与主题的关系描述', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + FOREIGN KEY (material_id) REFERENCES course_materials(id) ON DELETE SET NULL, + INDEX idx_course_id (course_id), + INDEX idx_material_id (material_id), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识点表'; +``` + +### 3.4 成长路径表 (growth_paths) +```sql +CREATE TABLE `growth_paths` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '路径名称', + `description` TEXT COMMENT '路径描述', + `target_role` VARCHAR(100) COMMENT '目标角色', + `courses` JSON COMMENT '课程列表[{course_id, order, is_required}]', + `estimated_duration_days` INT COMMENT '预计完成天数', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_is_active (is_active), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成长路径表'; +``` + +## 四、考试模块 + +### 4.1 题目表 (questions) +```sql +CREATE TABLE `questions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `question_type` VARCHAR(20) NOT NULL COMMENT '题目类型: single_choice, multiple_choice, true_false, fill_blank, essay', + `title` TEXT NOT NULL COMMENT '题目标题', + `content` TEXT COMMENT '题目内容', + `options` JSON COMMENT '选项(JSON格式)', + `correct_answer` TEXT NOT NULL COMMENT '正确答案', + `explanation` TEXT COMMENT '答案解释', + `score` FLOAT DEFAULT 10.0 COMMENT '分值', + `difficulty` VARCHAR(10) DEFAULT 'medium' COMMENT '难度等级: easy, medium, hard', + `tags` JSON COMMENT '标签(JSON格式)', + `usage_count` INT DEFAULT 0 COMMENT '使用次数', + `correct_count` INT DEFAULT 0 COMMENT '答对次数', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_question_type (question_type), + INDEX idx_difficulty (difficulty), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题目表'; +``` + +### 4.2 课程考试设置表 (course_exam_settings) +```sql +CREATE TABLE IF NOT EXISTS course_exam_settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + course_id INT NOT NULL UNIQUE COMMENT '课程ID', + + -- 题型数量设置 + single_choice_count INT NOT NULL DEFAULT 10 COMMENT '单选题数量', + multiple_choice_count INT NOT NULL DEFAULT 5 COMMENT '多选题数量', + true_false_count INT NOT NULL DEFAULT 5 COMMENT '判断题数量', + fill_blank_count INT NOT NULL DEFAULT 0 COMMENT '填空题数量', + essay_count INT NOT NULL DEFAULT 0 COMMENT '问答题数量', + + -- 考试参数设置 + duration_minutes INT NOT NULL DEFAULT 60 COMMENT '考试时长(分钟)', + difficulty_level INT NOT NULL DEFAULT 3 COMMENT '难度系数(1-5)', + passing_score INT NOT NULL DEFAULT 60 COMMENT '及格分数', + + -- 其他设置 + is_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用', + show_answer_immediately BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否立即显示答案', + allow_retake BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否允许重考', + max_retake_times INT COMMENT '最大重考次数', + + -- 审计字段 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + deleted_by INT COMMENT '删除人ID', + + FOREIGN KEY (course_id) REFERENCES courses(id), + INDEX ix_course_exam_settings_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程考试设置表'; +``` + +### 4.3 错题记录表 (exam_mistakes) +```sql +CREATE TABLE IF NOT EXISTS exam_mistakes ( + id INT AUTO_INCREMENT PRIMARY KEY, + + -- 核心关联字段(必需) + user_id INT NOT NULL COMMENT '用户ID', + exam_id INT NOT NULL COMMENT '考试ID', + question_id INT COMMENT '题目ID(AI生成的题目可能为空)', + knowledge_point_id INT COMMENT '关联的知识点ID', + + -- 题目核心信息(必需) + question_content TEXT NOT NULL COMMENT '题目内容', + correct_answer TEXT NOT NULL COMMENT '正确答案', + user_answer TEXT COMMENT '用户答案', + question_type VARCHAR(20) COMMENT '题型(single/multiple/judge/blank/essay)', -- 2025-10-12新增 + + -- 审计字段 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE, + FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE SET NULL, + FOREIGN KEY (knowledge_point_id) REFERENCES knowledge_points(id) ON DELETE SET NULL, + + INDEX idx_user_id (user_id), + INDEX idx_exam_id (exam_id), + INDEX idx_knowledge_point_id (knowledge_point_id), + INDEX idx_question_type (question_type) -- 2025-10-12新增 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='错题记录表'; +``` + +**核心字段说明:** +- 包含8个核心字段:`user_id`、`exam_id`、`question_id`、`knowledge_point_id`、`question_content`、`correct_answer`、`user_answer`、`question_type` +- 简化设计,去除冗余字段(如错误次数、掌握状态等),聚焦核心功能 +- `question_id` 可为空:AI动态生成的题目可能不在 questions 表中 +- `knowledge_point_id` 可为空:用于关联知识点,支持错题重考功能 +- `question_type` 用于记录题型,支持错题按题型筛选和统计(2025-10-12新增) +- 外键级联删除:用户或考试删除时,相关错题记录也删除 +- 外键置空:题目或知识点删除时,外键置为NULL但保留错题记录 + +### 4.4 考试记录表 (exams) +```sql +CREATE TABLE `exams` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL COMMENT '用户ID', + `course_id` INT NOT NULL COMMENT '课程ID', + `exam_name` VARCHAR(255) NOT NULL COMMENT '考试名称', + `question_count` INT DEFAULT 10 COMMENT '题目数量', + `total_score` FLOAT DEFAULT 100.0 COMMENT '总分', + `pass_score` FLOAT DEFAULT 60.0 COMMENT '及格分', + `start_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + `end_time` DATETIME COMMENT '结束时间', + `duration_minutes` INT DEFAULT 60 COMMENT '考试时长(分钟)', + `score` FLOAT COMMENT '最终得分(兼容字段)', + -- 三轮考试得分字段(2025-10-12新增) + `round1_score` FLOAT COMMENT '第一轮得分', + `round2_score` FLOAT COMMENT '第二轮得分', + `round3_score` FLOAT COMMENT '第三轮得分', + `is_passed` BOOLEAN COMMENT '是否通过', + `status` VARCHAR(20) DEFAULT 'started' COMMENT '状态: started, submitted, timeout', + `questions` JSON COMMENT '题目数据(JSON格式)', + `answers` JSON COMMENT '答案数据(JSON格式)', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_course_id (course_id), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试记录表'; +``` + +**三轮考试机制说明(2025-10-12更新)**: +- 一条考试记录包含三个轮次得分字段:`round1_score`、`round2_score`、`round3_score` +- 第一轮考试:创建exam记录,设置round1_score +- 第二轮考试:复用同一条exam记录,更新round2_score +- 第三轮考试:复用同一条exam记录,更新round3_score和score(最终得分) +- `score`字段用于兼容旧代码,通常存储最后完成的轮次得分 +- 成绩报告优先使用`round1_score`作为主要展示数据 + +### 4.5 考试结果详情表 (exam_results) +```sql +CREATE TABLE `exam_results` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `exam_id` INT NOT NULL COMMENT '考试ID', + `question_id` INT NOT NULL COMMENT '题目ID', + `user_answer` TEXT COMMENT '用户答案', + `is_correct` BOOLEAN DEFAULT FALSE COMMENT '是否正确', + `score` FLOAT DEFAULT 0.0 COMMENT '得分', + `answer_time` INT COMMENT '答题时长(秒)', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE, + FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE, + INDEX idx_exam_id (exam_id), + INDEX idx_question_id (question_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试结果详情表'; +``` + +## 五、陪练模块 + +### 5.1 陪练场景表 (practice_scenes) +```sql +CREATE TABLE `practice_scenes` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '场景名称', + `description` TEXT COMMENT '场景描述', + `type` VARCHAR(50) NOT NULL COMMENT '场景类型: phone/face/complaint/after-sales/product-intro', + `difficulty` VARCHAR(50) NOT NULL COMMENT '难度等级: beginner/junior/intermediate/senior/expert', + `status` VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/inactive', + `background` TEXT COMMENT '场景背景设定', + `ai_role` TEXT COMMENT 'AI角色描述', + `objectives` JSON COMMENT '练习目标数组', + `keywords` JSON COMMENT '关键词数组', + `duration` INT DEFAULT 10 COMMENT '预计时长(分钟)', + `usage_count` INT DEFAULT 0 COMMENT '使用次数', + `rating` DECIMAL(3,1) DEFAULT 0.0 COMMENT '评分', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` BOOLEAN DEFAULT FALSE, + `deleted_at` DATETIME, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_type (type), + INDEX idx_difficulty (difficulty), + INDEX idx_status (status), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练场景表(陪练中心功能)'; +``` + +**说明:** +- 用于陪练中心功能,存储预设的陪练场景 +- 场景类型:phone(电话销售)、face(面对面销售)、complaint(客户投诉)、after-sales(售后服务)、product-intro(产品介绍) +- 难度等级:beginner(入门)、junior(初级)、intermediate(中级)、senior(高级)、expert(专家) +- objectives和keywords字段使用JSON格式存储数组 +- 对话历史由Coze平台管理,不存储在本地数据库 + +### 5.2 陪练场景表(旧版本 - training_scenes) +```sql +CREATE TABLE `training_scenes` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL COMMENT '场景名称', + `description` TEXT COMMENT '场景描述', + `category` VARCHAR(50) NOT NULL COMMENT '场景分类', + `ai_config` JSON COMMENT 'AI配置(如Coze Bot ID等)', + `prompt_template` TEXT COMMENT '提示词模板', + `evaluation_criteria` JSON COMMENT '评估标准', + `status` ENUM('DRAFT', 'ACTIVE', 'INACTIVE') DEFAULT 'DRAFT' COMMENT '场景状态', + `is_public` BOOLEAN DEFAULT TRUE COMMENT '是否公开', + `required_level` INT COMMENT '所需用户等级', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_status (status), + INDEX idx_category (category), + INDEX idx_is_public (is_public), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练场景表'; +``` + +### 5.2 陪练会话表 (training_sessions) +```sql +CREATE TABLE `training_sessions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL COMMENT '用户ID', + `scene_id` INT NOT NULL COMMENT '场景ID', + `coze_conversation_id` VARCHAR(100) COMMENT 'Coze会话ID', + `start_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + `end_time` DATETIME COMMENT '结束时间', + `duration_seconds` INT COMMENT '持续时长(秒)', + `status` ENUM('CREATED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'ERROR') DEFAULT 'CREATED' COMMENT '会话状态', + `session_config` JSON COMMENT '会话配置', + `total_score` FLOAT COMMENT '总分', + `evaluation_result` JSON COMMENT '评估结果详情', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (scene_id) REFERENCES training_scenes(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_scene_id (scene_id), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练会话表'; +``` + +### 5.3 陪练消息表 (training_messages) +```sql +CREATE TABLE `training_messages` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` INT NOT NULL COMMENT '会话ID', + `role` ENUM('USER', 'ASSISTANT', 'SYSTEM') NOT NULL COMMENT '消息角色', + `type` ENUM('TEXT', 'VOICE', 'SYSTEM') NOT NULL COMMENT '消息类型', + `content` TEXT NOT NULL COMMENT '消息内容', + `voice_url` VARCHAR(500) COMMENT '语音文件URL', + `voice_duration` FLOAT COMMENT '语音时长(秒)', + `message_metadata` JSON COMMENT '消息元数据', + `coze_message_id` VARCHAR(100) COMMENT 'Coze消息ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES training_sessions(id) ON DELETE CASCADE, + INDEX idx_session_id (session_id), + INDEX idx_role (role) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练消息表'; +``` + +### 5.4 陪练报告表 (training_reports) +```sql +CREATE TABLE `training_reports` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` INT NOT NULL UNIQUE COMMENT '会话ID', + `user_id` INT NOT NULL COMMENT '用户ID', + `overall_score` FLOAT NOT NULL COMMENT '总体得分', + `dimension_scores` JSON NOT NULL COMMENT '各维度得分', + `strengths` JSON NOT NULL COMMENT '优势点', + `weaknesses` JSON NOT NULL COMMENT '待改进点', + `suggestions` JSON NOT NULL COMMENT '改进建议', + `detailed_analysis` TEXT COMMENT '详细分析', + `transcript` TEXT COMMENT '对话文本记录', + `statistics` JSON COMMENT '统计数据', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES training_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练报告表'; +``` + +## 六、数据库设计原则 + +### 5.1 主键规范 +- 所有表使用INT作为主键,满足当前规模与 ORM 定义 +- 所有主键均设置为AUTO_INCREMENT + +### 5.2 外键约束 +- 所有外键关系都明确定义 +- 删除策略: + - CASCADE:级联删除(用于强关联关系) + - SET NULL:置空(用于弱关联关系) + +### 5.3 索引策略 +- 所有外键字段自动创建索引 +- 常用查询字段创建索引(如status, type等) +- 唯一约束字段自动创建唯一索引 + +### 5.4 字段命名规范 +- 使用下划线命名法(snake_case) +- 布尔字段使用is_前缀 +- 时间字段使用_at后缀 +- JSON字段明确标注数据结构 + +### 5.5 软删除设计 +- 使用is_deleted和deleted_at字段实现软删除 +- 保留数据完整性,便于数据恢复 + +### 5.6 审计字段 +- created_at:创建时间 +- updated_at:更新时间 +- created_by:创建人ID +- updated_by:更新人ID + +### positions - 岗位表(2025-09-22新增) +```sql +CREATE TABLE positions ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL COMMENT '岗位名称', + code VARCHAR(100) NOT NULL UNIQUE COMMENT '岗位编码', + description TEXT COMMENT '岗位描述', + parent_id INT COMMENT '上级岗位ID', + status VARCHAR(20) DEFAULT 'active' COMMENT '状态', + sort_order INT DEFAULT 0 COMMENT '排序', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + FOREIGN KEY (parent_id) REFERENCES positions(id) ON DELETE SET NULL, + INDEX ix_positions_name (name), + INDEX ix_positions_code (code), + INDEX ix_positions_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位表'; +``` + +### position_members - 岗位成员关联表(2025-09-22新增) +```sql +CREATE TABLE position_members ( + id INT AUTO_INCREMENT PRIMARY KEY, + position_id INT NOT NULL COMMENT '岗位ID', + user_id INT NOT NULL COMMENT '用户ID', + role VARCHAR(50) COMMENT '成员角色(预留字段)', + joined_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + FOREIGN KEY (position_id) REFERENCES positions(id), + FOREIGN KEY (user_id) REFERENCES users(id), + UNIQUE KEY uix_position_user (position_id, user_id, is_deleted), + INDEX ix_position_members_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位成员关联表'; +``` + +### position_courses - 岗位课程关联表(2025-09-22新增) +```sql +CREATE TABLE position_courses ( + id INT AUTO_INCREMENT PRIMARY KEY, + position_id INT NOT NULL COMMENT '岗位ID', + course_id INT NOT NULL COMMENT '课程ID', + course_type VARCHAR(20) NOT NULL DEFAULT 'required' COMMENT '课程类型:required必修/optional选修', + priority INT DEFAULT 0 COMMENT '优先级/排序', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + FOREIGN KEY (position_id) REFERENCES positions(id), + FOREIGN KEY (course_id) REFERENCES courses(id), + UNIQUE KEY uix_position_course (position_id, course_id, is_deleted), + INDEX ix_position_courses_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位课程关联表'; +``` + +## 六、性能优化建议 + +1. **分表策略** + - training_messages表可能增长较快,考虑按月分表 + - exam_results表可考虑按年分表 + +2. **缓存策略** + - 用户信息使用Redis缓存 + - 课程列表使用Redis缓存 + - 热门题目使用Redis缓存 + +3. **查询优化** + - 使用分页查询避免大量数据加载 + - 合理使用JOIN,避免N+1查询 + - 统计类查询考虑使用物化视图 + +## 七、初始化数据(轻医美连锁业务) + +系统初始化时会插入以下业务数据: + +### 7.1 默认用户账号 +- 超级管理员:superadmin / Superadmin123! +- 系统管理员:admin / Admin123! +- 测试学员:testuser / TestPass123! + +### 7.2 轻医美连锁岗位体系 +系统预置了完整的轻医美连锁岗位层级结构: + +#### 岗位层级树 +``` +区域经理 (region_manager) - expert级别 +└── 店长 (store_manager) - senior级别 + ├── 美容顾问 (beauty_consultant) - intermediate级别 + ├── 医美咨询师 (medical_beauty_consultant) - senior级别 + ├── 美容技师 (beauty_therapist) - intermediate级别 + ├── 护士 (nurse) - intermediate级别 + ├── 前台接待 (receptionist) - junior级别 + └── 市场专员 (marketing_specialist) - intermediate级别 +``` + +### 7.3 轻医美专业课程体系 + +#### 技术类课程 +- **皮肤生理学基础** (16课时) - 学习皮肤结构、功能和常见问题 +- **医美产品知识与应用** (20课时) - 了解各类医美产品的成分和功效 +- **美容仪器操作与维护** (24课时) - 掌握美容仪器的操作方法 +- **医美项目介绍与咨询** (30课时) - 了解各类医美项目原理和效果 + +#### 业务类课程 +- **轻医美销售技巧** (16课时) - 学习专业销售话术和成交技巧 +- **客户服务与投诉处理** (12课时) - 提升服务意识和投诉处理能力 +- **社媒营销与私域运营** (16课时) - 学习社交媒体品牌推广 + +#### 管理类课程 +- **门店运营管理** (20课时) - 学习门店日常管理和团队建设 + +#### 通用类课程 +- **卫生消毒与感染控制** (8课时) - 学习医美机构卫生标准 + +### 7.4 岗位课程配置示例 +系统已预设各岗位的必修和选修课程: + +- **店长**:门店运营管理、轻医美销售技巧、客户服务与投诉处理、卫生消毒与感染控制(全部必修) +- **美容顾问**:皮肤生理学基础、医美产品知识与应用、轻医美销售技巧、客户服务与投诉处理(必修);社媒营销与私域运营(选修) +- **美容技师**:皮肤生理学基础、美容仪器操作与维护、卫生消毒与感染控制(必修);医美产品知识与应用(选修) +- **医美咨询师**:医美项目介绍与咨询、皮肤生理学基础、医美产品知识与应用、轻医美销售技巧、客户服务与投诉处理(全部必修) + +### 7.5 数据初始化脚本 +- **初始化SQL脚本**:`/scripts/init_database_unified.sql` - 包含完整的表结构和初始数据 +- **轻医美种子数据脚本**:`/scripts/seed_beauty_data.py` - 专门用于插入轻医美业务数据 + +--- + +## 八、系统配置模块 + +### 8.1 AI服务配置表 (ai_config) + +> 2026-01-21 新增:遵循《瑞小美AI接入规范》,将 API Key 等敏感配置存储在数据库中 + +```sql +CREATE TABLE IF NOT EXISTS `ai_config` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `config_key` VARCHAR(100) NOT NULL UNIQUE COMMENT '配置键名', + `config_value` TEXT COMMENT '配置值', + `description` VARCHAR(255) COMMENT '配置说明', + `is_encrypted` TINYINT(1) DEFAULT 0 COMMENT '是否加密存储', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI 服务配置表'; +``` + +**预置配置项**: + +| config_key | 说明 | 示例值 | +|------------|------|--------| +| AI_PRIMARY_API_KEY | 4sapi.com 通用 Key(Gemini/DeepSeek) | sk-xxx... | +| AI_ANTHROPIC_API_KEY | 4sapi.com Claude 专属 Key | sk-xxx... | +| AI_PRIMARY_BASE_URL | 首选服务商 API 地址 | https://4sapi.com/v1 | +| AI_FALLBACK_API_KEY | OpenRouter 备选 Key | sk-or-xxx... | +| AI_FALLBACK_BASE_URL | 备选服务商 API 地址 | https://openrouter.ai/api/v1 | +| AI_DEFAULT_MODEL | 默认 AI 模型 | claude-opus-4-5-20251101-thinking | +| AI_TIMEOUT | AI 请求超时时间(秒) | 120 | + +**配置加载优先级**: +1. 数据库 ai_config 表(推荐) +2. 环境变量(fallback) + +**使用说明**: +- AIService 初始化时自动从数据库读取配置 +- 支持运行时动态更新配置,无需重启服务 +- 遵循规范:禁止在代码中硬编码 API Key + diff --git a/backend/数据库配置切换说明.md b/backend/数据库配置切换说明.md new file mode 100644 index 0000000..b206a9e --- /dev/null +++ b/backend/数据库配置切换说明.md @@ -0,0 +1,103 @@ +# 数据库配置切换说明 + +## 当前配置 + +- **公网数据库地址**: + - 主机: `120.79.247.16` 或 `aiedu.ireborn.com.cn` + - 端口: `3306` + - 数据库名: `kaopeilian` + - 用户: `root` + - 密码: `Kaopeilian2025!@#` + +## 连接字符串 + +### 原始密码(包含特殊字符) +``` +Kaopeilian2025!@# +``` + +### URL编码后的密码 +``` +Kaopeilian2025%21%40%23 +``` + +### 完整的SQLAlchemy连接字符串 +``` +mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4 +``` + +## 配置方法 + +### 方法1:使用环境变量文件(推荐) + +创建 `.env` 文件: +```bash +DATABASE_URL=mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4 +REDIS_URL=redis://localhost:6379/0 +SECRET_KEY=dev-secret-key-for-testing-only-not-for-production +DEBUG=true +LOG_LEVEL=INFO +``` + +### 方法2:使用启动脚本 + +使用已配置好的启动脚本: +```bash +# 使用公网数据库 +python3 start_remote.py + +# 使用本地数据库(需要修改回本地配置) +python3 start_mysql.py +``` + +### 方法3:直接设置环境变量 + +```bash +export DATABASE_URL="mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4" +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +### 方法4:Docker Compose + +`docker-compose.dev.yml` 已更新为使用公网数据库: +```yaml +environment: + DATABASE_URL: mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4 + REDIS_URL: redis://redis:6379/0 +``` + +## 测试连接 + +运行测试脚本验证连接: +```bash +python3 test_remote_db.py +``` + +## 切换回本地数据库 + +如需切换回本地数据库,修改连接字符串为: +``` +mysql+aiomysql://root:root@localhost:3306/kaopeilian?charset=utf8mb4 +``` + +## 注意事项 + +1. **密码编码**:密码中的特殊字符必须进行URL编码 + - `!` → `%21` + - `@` → `%40` + - `#` → `%23` + +2. **网络连接**:确保开发机器能够访问公网数据库服务器 + +3. **安全性**:生产环境不要在代码中硬编码密码,使用环境变量或密钥管理服务 + +4. **性能**:公网数据库可能有一定延迟,开发时请注意 + +## 已更新的文件 + +- `start_mysql.py` - 启动脚本(已改为公网数据库) +- `start_remote.py` - 新增的公网数据库启动脚本 +- `docker-compose.dev.yml` - Docker配置(已改为公网数据库) +- `test_remote_db.py` - 数据库连接测试脚本 + + diff --git a/backend/验证备份质量.py b/backend/验证备份质量.py new file mode 100755 index 0000000..afe62f0 --- /dev/null +++ b/backend/验证备份质量.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +考培练系统数据库备份质量验证脚本 +""" + +import asyncio +import aiomysql +import os +import sys +from datetime import datetime + +async def verify_backup_quality(): + """验证数据库备份质量""" + + print("🔍 考培练系统数据库备份质量验证") + print("=" * 50) + + try: + # 连接数据库 + conn = await aiomysql.connect( + host='127.0.0.1', + port=3306, + user='root', + password='root', + db='kaopeilian', + charset='utf8mb4' + ) + + cursor = await conn.cursor() + + # 1. 基础信息检查 + print("\n📊 1. 数据库基础信息") + print("-" * 30) + + await cursor.execute("SELECT VERSION()") + version = (await cursor.fetchone())[0] + print(f"MySQL版本: {version}") + + await cursor.execute("SELECT DATABASE()") + db_name = (await cursor.fetchone())[0] + print(f"当前数据库: {db_name}") + + await cursor.execute("SHOW VARIABLES LIKE 'character_set_database'") + charset = (await cursor.fetchone())[1] + print(f"数据库字符集: {charset}") + + # 2. 表结构检查 + print("\n🏗️ 2. 表结构检查") + print("-" * 30) + + await cursor.execute("SHOW TABLES") + tables = await cursor.fetchall() + table_names = [t[0] for t in tables if t[0] != 'v_user_course_progress'] + + print(f"表数量: {len(table_names)}") + + # 检查每个表的注释 + tables_with_comment = 0 + for table_name in table_names: + await cursor.execute(f"SHOW CREATE TABLE {table_name}") + create_sql = (await cursor.fetchone())[1] + if 'COMMENT=' in create_sql: + tables_with_comment += 1 + + print(f"有注释的表: {tables_with_comment}/{len(table_names)}") + + # 3. 数据完整性检查 + print("\n📋 3. 数据完整性检查") + print("-" * 30) + + key_tables = [ + ('users', '用户'), + ('courses', '课程'), + ('questions', '题目'), + ('teams', '团队'), + ('positions', '岗位'), + ('knowledge_points', '知识点'), + ('user_teams', '用户团队关联'), + ('position_courses', '岗位课程关联') + ] + + total_records = 0 + for table, desc in key_tables: + await cursor.execute(f"SELECT COUNT(*) FROM {table}") + count = (await cursor.fetchone())[0] + total_records += count + status = "✅" if count > 0 else "❌" + print(f"{status} {desc}: {count} 条") + + print(f"总记录数: {total_records}") + + # 4. 中文内容检查 + print("\n🈯 4. 中文内容检查") + print("-" * 30) + + # 检查用户中文姓名 + await cursor.execute("SELECT full_name FROM users WHERE full_name IS NOT NULL LIMIT 3") + names = await cursor.fetchall() + print("用户姓名示例:") + for name in names: + print(f" - {name[0]}") + + # 检查课程中文名称 + await cursor.execute("SELECT name FROM courses LIMIT 3") + courses = await cursor.fetchall() + print("课程名称示例:") + for course in courses: + print(f" - {course[0]}") + + # 5. COMMENT质量检查 + print("\n💬 5. COMMENT质量检查") + print("-" * 30) + + total_columns = 0 + columns_with_comment = 0 + + for table_name in table_names: + await cursor.execute(f"SHOW FULL COLUMNS FROM {table_name}") + columns = await cursor.fetchall() + + for col in columns: + total_columns += 1 + if col[8]: # 有COMMENT + columns_with_comment += 1 + + comment_coverage = columns_with_comment / total_columns * 100 + print(f"列总数: {total_columns}") + print(f"有注释的列: {columns_with_comment}") + print(f"注释覆盖率: {comment_coverage:.1f}%") + + # 6. 备份文件检查 + print("\n💾 6. 备份文件检查") + print("-" * 30) + + backup_files = [ + 'kaopeilian_final_complete_backup_20250923_025629.sql', + 'kaopeilian_complete_backup_20250923_025548.sql', + 'kaopeilian_super_complete_backup_20250923_025622.sql' + ] + + for backup_file in backup_files: + if os.path.exists(backup_file): + size = os.path.getsize(backup_file) + print(f"✅ {backup_file}: {size/1024:.1f}KB") + else: + print(f"❌ {backup_file}: 不存在") + + # 7. 质量评分 + print("\n⭐ 7. 质量评分") + print("-" * 30) + + scores = [] + + # 表结构完整性 (25分) + structure_score = min(25, len(table_names) * 1.3) + scores.append(('表结构完整性', structure_score, 25)) + + # 数据完整性 (25分) + data_score = min(25, total_records * 0.3) + scores.append(('数据完整性', data_score, 25)) + + # 注释质量 (25分) + comment_score = comment_coverage * 0.25 + scores.append(('注释质量', comment_score, 25)) + + # 字符编码 (25分) + encoding_score = 25 if charset == 'utf8mb4' else 15 + scores.append(('字符编码', encoding_score, 25)) + + total_score = sum(score for _, score, _ in scores) + max_score = sum(max_s for _, _, max_s in scores) + + for name, score, max_s in scores: + percentage = score / max_s * 100 + print(f"{name}: {score:.1f}/{max_s} ({percentage:.1f}%)") + + print(f"\n总分: {total_score:.1f}/{max_score} ({total_score/max_score*100:.1f}%)") + + # 8. 最终评估 + print("\n🏆 8. 最终评估") + print("-" * 30) + + if total_score >= 90: + grade = "优秀 ⭐⭐⭐⭐⭐" + status = "🎉 备份质量优秀,可用于生产环境!" + elif total_score >= 80: + grade = "良好 ⭐⭐⭐⭐" + status = "✅ 备份质量良好,建议使用。" + elif total_score >= 70: + grade = "合格 ⭐⭐⭐" + status = "⚠️ 备份质量合格,可以使用但建议改进。" + else: + grade = "需改进 ⭐⭐" + status = "❌ 备份质量需要改进。" + + print(f"质量等级: {grade}") + print(f"评估结果: {status}") + + print(f"\n验证完成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + await cursor.close() + conn.close() + + return total_score >= 80 + + except Exception as e: + print(f"❌ 验证过程中出错: {e}") + import traceback + traceback.print_exc() + return False + +if __name__ == "__main__": + result = asyncio.run(verify_backup_quality()) + sys.exit(0 if result else 1) diff --git a/deploy/docker/docker-compose.admin.yml b/deploy/docker/docker-compose.admin.yml new file mode 100644 index 0000000..304e56e --- /dev/null +++ b/deploy/docker/docker-compose.admin.yml @@ -0,0 +1,115 @@ +# 考培练系统 SaaS 超级管理后台 Docker Compose 配置 +# +# 启动命令: +# cd /root/aiedu && docker compose -f docker-compose.admin.yml up -d +# +# 重新构建后端: +# docker compose -f docker-compose.admin.yml build --no-cache kaopeilian-admin-backend +# +# 服务说明: +# - kaopeilian-admin-frontend: 管理后台前端 (端口 3030) +# - kaopeilian-admin-backend: 管理后台后端 (端口 8030) +# +# 域名:admin.kpl.ireborn.com.cn +# +# 注意:敏感配置从 .env.admin 文件读取 + +name: kaopeilian-admin + +services: + # ============================================ + # 管理后台前端 + # ============================================ + kaopeilian-admin-frontend: + build: + context: ./kaopeilian-admin-frontend + dockerfile: Dockerfile + image: kaopeilian-admin-frontend:1.0.0 + container_name: kaopeilian-admin-frontend + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "127.0.0.1:3030:80" # 仅本地访问,通过 Nginx 反向代理 + volumes: + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - kaopeilian-network + depends_on: + kaopeilian-admin-backend: + condition: service_started + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.1' + memory: 64M + + # ============================================ + # 管理后台后端(开发环境 - 热重载) + # ============================================ + kaopeilian-admin-backend: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile.admin + image: kaopeilian-admin-backend:1.0.0 + container_name: kaopeilian-admin-backend + restart: unless-stopped + env_file: + - .env.admin + environment: + - TZ=Asia/Shanghai + - PYTHONPATH=/app + ports: + - "127.0.0.1:8030:8000" # 仅本地访问,通过 Nginx 反向代理 + volumes: + # 代码热重载 - 挂载整个 app 目录 + - ./kaopeilian-backend/app:/app/app:rw + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + # 开发模式命令 - 启用热重载 + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--reload-dir", "/app/app"] + networks: + - kaopeilian-network + - prod-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + cpus: '1' + memory: 1G + reservations: + cpus: '0.25' + memory: 256M + +networks: + kaopeilian-network: + external: true + name: kaopeilian-network + prod-network: + external: true + name: prod-network diff --git a/deploy/docker/docker-compose.dev.yml b/deploy/docker/docker-compose.dev.yml new file mode 100644 index 0000000..ca4df27 --- /dev/null +++ b/deploy/docker/docker-compose.dev.yml @@ -0,0 +1,186 @@ +# 考培练系统开发环境 Docker Compose 配置 +name: kaopeilian-dev + +services: + # 前端开发服务 + frontend-dev: + build: + context: ./kaopeilian-frontend + dockerfile: Dockerfile.dev + container_name: kaopeilian-frontend-dev + restart: unless-stopped + ports: + - "3001:3001" + environment: + - NODE_ENV=development + - VITE_APP_ENV=development + - VITE_API_BASE_URL="" + - VITE_WS_BASE_URL="" + - VITE_USE_MOCK_DATA=false + - VITE_ENABLE_DEVTOOLS=true + - VITE_ENABLE_REQUEST_LOG=true + - CHOKIDAR_USEPOLLING=true # 支持Docker中的文件监听 + - WATCHPACK_POLLING=true # 支持热重载 + volumes: + # 挂载源代码实现热重载(关键配置) + - ./kaopeilian-frontend:/app + - kaopeilian_frontend_node_modules:/app/node_modules # 使用命名卷 + - ./kaopeilian-frontend/logs:/app/logs + networks: + - kaopeilian-dev-network + depends_on: + backend-dev: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3001/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # 后端开发服务 + backend-dev: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile.dev + container_name: kaopeilian-backend-dev + restart: unless-stopped + ports: + - "8000:8000" + environment: + - PYTHONPATH=/app + - DEBUG=true + - ENV=development + - "DATABASE_URL=mysql+aiomysql://root:nj861021@mysql-dev:3306/kaopeilian?charset=utf8mb4" + - REDIS_URL=redis://redis-dev:6379/0 + - MYSQL_HOST=mysql-dev + - MYSQL_PORT=3306 + - MYSQL_USER=root + - "MYSQL_PASSWORD=nj861021" + - MYSQL_DATABASE=kaopeilian + - REDIS_HOST=redis-dev + - REDIS_PORT=6379 + - REDIS_DB=0 + - PYTHONUNBUFFERED=1 # 确保日志实时输出 + - CORS_ORIGINS=["http://localhost:3000","http://localhost:3001","http://localhost:5173","http://127.0.0.1:3000","http://127.0.0.1:3001","http://127.0.0.1:5173"] + # 完全禁用代理(覆盖 Docker Desktop 的代理配置) + - HTTP_PROXY= + - HTTPS_PROXY= + - http_proxy= + - https_proxy= + - NO_PROXY= + - no_proxy= + volumes: + # 挂载源代码实现热重载(关键配置) + - ./kaopeilian-backend:/app:rw + - ./kaopeilian-backend/uploads:/app/uploads:rw + - ./kaopeilian-backend/logs:/app/logs:rw + # 排除虚拟环境目录,避免冲突 + - /app/venv + networks: + - kaopeilian-dev-network + depends_on: + mysql-dev: + condition: service_healthy + redis-dev: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # 开发数据库 + mysql-dev: + image: mysql:8.0.43 + container_name: kaopeilian-mysql-dev + restart: unless-stopped + ports: + - "3306:3306" + environment: + MYSQL_ROOT_PASSWORD: nj861021 + MYSQL_DATABASE: kaopeilian + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + volumes: + - mysql_dev_data:/var/lib/mysql + - ./kaopeilian-backend/scripts/init_database_unified.sql:/docker-entrypoint-initdb.d/init.sql:ro + - ./kaopeilian-backend/mysql.cnf:/etc/mysql/conf.d/mysql.cnf:ro + command: --default-authentication-plugin=mysql_native_password + networks: + - kaopeilian-dev-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-pnj861021"] + timeout: 20s + retries: 10 + start_period: 30s + + # 开发 Redis 缓存 + redis-dev: + image: redis:7.2-alpine + container_name: kaopeilian-redis-dev + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis_dev_data:/data + networks: + - kaopeilian-dev-network + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + # phpMyAdmin 数据库管理界面(可选) + phpmyadmin: + image: phpmyadmin/phpmyadmin:latest + container_name: kaopeilian-phpmyadmin-dev + restart: unless-stopped + ports: + - "8080:80" + environment: + PMA_HOST: mysql-dev + PMA_PORT: 3306 + PMA_USER: root + PMA_PASSWORD: nj861021 + networks: + - kaopeilian-dev-network + depends_on: + mysql-dev: + condition: service_healthy + profiles: + - admin # 使用 profile 控制是否启动 + + # 邮件测试服务(开发环境用于测试邮件发送) + mailhog: + image: mailhog/mailhog:latest + container_name: kaopeilian-mailhog-dev + restart: unless-stopped + ports: + - "1025:1025" # SMTP 端口 + - "8025:8025" # Web UI 端口 + networks: + - kaopeilian-dev-network + profiles: + - mail # 使用 profile 控制是否启动 + +# 开发网络 +networks: + kaopeilian-dev-network: + driver: bridge + name: kaopeilian-dev-network + +# 开发数据卷 +volumes: + mysql_dev_data: + driver: local + name: kaopeilian-mysql-dev-data + redis_dev_data: + driver: local + name: kaopeilian-redis-dev-data + kaopeilian_frontend_node_modules: + driver: local + name: kaopeilian-frontend-node-modules diff --git a/deploy/docker/docker-compose.kpl.yml b/deploy/docker/docker-compose.kpl.yml new file mode 100644 index 0000000..b028ee9 --- /dev/null +++ b/deploy/docker/docker-compose.kpl.yml @@ -0,0 +1,207 @@ +# 瑞小美团队开发环境 Docker Compose 配置 +# 域名:kpl.ireborn.com.cn +# 支持热重载,与演示系统完全隔离 +# +# ⚠️ 敏感配置(API Key 等)请在 .env.kpl 文件中设置 +# 启动命令:docker compose --env-file .env.kpl -f docker-compose.kpl.yml up -d + +name: kpl-dev + +services: + # 前端开发服务 + kpl-frontend-dev: + build: + context: ./kaopeilian-frontend + dockerfile: Dockerfile.dev + container_name: kpl-frontend-dev + restart: unless-stopped + ports: + - "3002:3001" + environment: + - NODE_ENV=development + - VITE_APP_ENV=development + - VITE_API_BASE_URL=https://kpl.ireborn.com.cn + - VITE_WS_BASE_URL=wss://kpl.ireborn.com.cn + - VITE_USE_MOCK_DATA=false + - VITE_ENABLE_DEVTOOLS=true + - VITE_ENABLE_REQUEST_LOG=true + - CHOKIDAR_USEPOLLING=true # 支持Docker中的文件监听 + - WATCHPACK_POLLING=true # 支持热重载 + volumes: + # 挂载源代码实现热重载(关键配置) + - ./kaopeilian-frontend:/app + - kpl_frontend_node_modules:/app/node_modules # 使用命名卷 + - ./kaopeilian-frontend/logs:/app/logs + networks: + - kpl-dev-network + depends_on: + kpl-backend-dev: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3001/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # 后端开发服务 + kpl-backend-dev: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile.dev + container_name: kpl-backend-dev + restart: unless-stopped + ports: + - "8001:8000" + environment: + - PYTHONPATH=/app + - DEBUG=true + - ENV=development + - "DATABASE_URL=mysql+aiomysql://root:nj861021@kpl-mysql-dev:3306/kaopeilian?charset=utf8mb4" + - REDIS_URL=redis://kpl-redis-dev:6379/0 + - MYSQL_HOST=kpl-mysql-dev + - MYSQL_PORT=3306 + - MYSQL_USER=root + - "MYSQL_PASSWORD=nj861021" + - MYSQL_DATABASE=kaopeilian + - REDIS_HOST=kpl-redis-dev + - REDIS_PORT=6379 + - REDIS_DB=0 + - PYTHONUNBUFFERED=1 # 确保日志实时输出 + - CORS_ORIGINS=["https://kpl.ireborn.com.cn","http://localhost:3002","http://127.0.0.1:3002"] + # Coze OAuth配置 + - COZE_OAUTH_CLIENT_ID=1114009328887 + - COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I + - COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem + - COZE_PRACTICE_BOT_ID=7560643598174683145 + # Coze 播课工作流配置(瑞小美专用) + - COZE_BROADCAST_WORKFLOW_ID=7561161554420482088 + - COZE_BROADCAST_SPACE_ID=7474971491470688296 + - COZE_BROADCAST_BOT_ID=7560643598174683145 + # 完全禁用代理(覆盖 Docker Desktop 的代理配置) + - HTTP_PROXY= + - HTTPS_PROXY= + - http_proxy= + - https_proxy= + - NO_PROXY= + - no_proxy= + # AI 服务配置(遵循瑞小美 AI 接入规范) + # 注意:API Key 应从 .env 文件或密钥管理服务获取 + - AI_PRIMARY_API_KEY=${AI_PRIMARY_API_KEY:-} + - AI_ANTHROPIC_API_KEY=${AI_ANTHROPIC_API_KEY:-} + - AI_PRIMARY_BASE_URL=https://4sapi.com/v1 + - AI_FALLBACK_API_KEY=${AI_FALLBACK_API_KEY:-} + - AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1 + # 默认模型:遵循"优先最强"原则,使用 Claude Opus 4.5 + - AI_DEFAULT_MODEL=claude-opus-4-5-20251101-thinking + - AI_TIMEOUT=120 + volumes: + # 挂载源代码实现热重载(关键配置) + - ./kaopeilian-backend:/app:rw + - /data/kaopeilian/uploads/kpl:/app/uploads:rw # 迁移到数据盘 + - ./kaopeilian-backend/logs:/app/logs:rw + - ./kaopeilian-backend/secrets:/app/secrets:ro + # 排除虚拟环境目录,避免冲突 + - /app/venv + networks: + - kpl-dev-network + depends_on: + kpl-mysql-dev: + condition: service_healthy + kpl-redis-dev: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # 开发数据库 + kpl-mysql-dev: + image: mysql:8.0.43 + container_name: kpl-mysql-dev + restart: unless-stopped + ports: + - "3308:3306" + environment: + MYSQL_ROOT_PASSWORD: nj861021 + MYSQL_DATABASE: kaopeilian + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + TZ: Asia/Shanghai + volumes: + - kpl_mysql_dev_data:/var/lib/mysql + - ./kaopeilian-backend/scripts/init_database_unified.sql:/docker-entrypoint-initdb.d/init.sql:ro + - ./kaopeilian-backend/mysql.cnf:/etc/mysql/conf.d/mysql.cnf:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: --default-authentication-plugin=mysql_native_password + networks: + - kpl-dev-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-pnj861021"] + timeout: 20s + retries: 10 + start_period: 30s + + # 开发 Redis 缓存 + kpl-redis-dev: + image: redis:7.2-alpine + container_name: kpl-redis-dev + restart: unless-stopped + ports: + - "6380:6379" + environment: + - TZ=Asia/Shanghai + volumes: + - kpl_redis_dev_data:/data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - kpl-dev-network + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + # phpMyAdmin 数据库管理界面(可选) + kpl-phpmyadmin: + image: phpmyadmin/phpmyadmin:latest + container_name: kpl-phpmyadmin-dev + restart: unless-stopped + ports: + - "8081:80" + environment: + PMA_HOST: kpl-mysql-dev + PMA_PORT: 3306 + PMA_USER: root + PMA_PASSWORD: nj861021 + networks: + - kpl-dev-network + depends_on: + kpl-mysql-dev: + condition: service_healthy + profiles: + - admin # 使用 profile 控制是否启动 + +# 开发网络 +networks: + kpl-dev-network: + external: true + name: kpl-dev-network + +# 开发数据卷 +volumes: + kpl_mysql_dev_data: + driver: local + name: kpl-mysql-dev-data + kpl_redis_dev_data: + driver: local + name: kpl-redis-dev-data + kpl_frontend_node_modules: + driver: local + name: kpl-frontend-node-modules + diff --git a/deploy/docker/docker-compose.prod-multi.yml b/deploy/docker/docker-compose.prod-multi.yml new file mode 100644 index 0000000..d3c2aea --- /dev/null +++ b/deploy/docker/docker-compose.prod-multi.yml @@ -0,0 +1,482 @@ +# 多客户生产环境 Docker Compose 配置 +# 共享MySQL实例,独立前端/后端/Redis容器 +# +# 重要说明: +# - 所有租户前端共享同一个 dist 目录: /root/aiedu/kaopeilian-frontend/dist +# - 编译一次前端,所有租户自动更新(无需重新构建镜像) +# - 更新前端步骤:cd /root/aiedu/kaopeilian-frontend && npm run build +# - 后端API地址由nginx反向代理根据域名自动路由,前端无需区分 +# +name: prod-multi + +services: + # ============================================ + # 共享MySQL数据库服务 + # ============================================ + prod-mysql: + image: mysql:8.0.43 + container_name: prod-mysql + restart: unless-stopped + environment: + TZ: Asia/Shanghai + MYSQL_ROOT_PASSWORD: ProdMySQL2025!@# + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + ports: + - "3309:3306" + volumes: + - /data/mysql-data:/var/lib/mysql + - ./kaopeilian-backend/mysql.cnf:/etc/mysql/conf.d/mysql.cnf:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: --default-authentication-plugin=mysql_native_password + networks: + - prod-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-pProdMySQL2025!@#"] + timeout: 20s + retries: 10 + start_period: 30s + + # ============================================ + # 华尔倍丽 (hua.ireborn.com.cn) + # ============================================ + hua-frontend: + # 使用共享的前端镜像,挂载统一的dist目录 + image: kaopeilian-frontend:shared + container_name: hua-frontend + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "3010:80" + volumes: + - /root/aiedu/kaopeilian-frontend/dist:/usr/share/nginx/html:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + - kaopeilian-network + depends_on: + - hua-backend + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + hua-backend: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile + image: prod-multi-hua-backend:latest + container_name: hua-backend + restart: unless-stopped + env_file: + - ./kaopeilian-backend/.env.hua + environment: + - TZ=Asia/Shanghai + - PYTHONPATH=/app + ports: + - "8010:8000" + volumes: + - ./kaopeilian-backend/app:/app/app # 代码热重载 + - /data/prod-envs/uploads-hua:/app/uploads + - /data/prod-envs/logs-hua:/app/logs + - /data/prod-envs/secrets:/app/secrets:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + networks: + - prod-network + - kaopeilian-network + depends_on: + prod-mysql: + condition: service_healthy + hua-redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + hua-redis: + image: redis:7.2-alpine + container_name: hua-redis + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "6390:6379" + volumes: + - /data/redis-data/hua:/data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + # ============================================ + # 杨扬宠物 (yy.ireborn.com.cn) + # ============================================ + yy-frontend: + # 使用共享的前端镜像,挂载统一的dist目录 + image: kaopeilian-frontend:shared + container_name: yy-frontend + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "3011:80" + volumes: + - /root/aiedu/kaopeilian-frontend/dist:/usr/share/nginx/html:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + - kaopeilian-network + depends_on: + - yy-backend + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + yy-backend: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile + image: prod-multi-yy-backend:latest + container_name: yy-backend + restart: unless-stopped + env_file: + - ./kaopeilian-backend/.env.yy + environment: + - TZ=Asia/Shanghai + - PYTHONPATH=/app + ports: + - "8011:8000" + volumes: + - ./kaopeilian-backend/app:/app/app # 代码热重载 + - /data/prod-envs/uploads-yy:/app/uploads + - /data/prod-envs/logs-yy:/app/logs + - /data/prod-envs/secrets:/app/secrets:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + networks: + - prod-network + - kaopeilian-network + depends_on: + prod-mysql: + condition: service_healthy + yy-redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + yy-redis: + image: redis:7.2-alpine + container_name: yy-redis + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "6391:6379" + volumes: + - /data/redis-data/yy:/data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + # ============================================ + # 武汉禾丽 (hl.ireborn.com.cn) + # ============================================ + hl-frontend: + # 使用共享的前端镜像,挂载统一的dist目录 + image: kaopeilian-frontend:shared + container_name: hl-frontend + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "3012:80" + volumes: + - /root/aiedu/kaopeilian-frontend/dist:/usr/share/nginx/html:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + - kaopeilian-network + depends_on: + - hl-backend + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + hl-backend: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile + image: prod-multi-hl-backend:latest + container_name: hl-backend + restart: unless-stopped + env_file: + - ./kaopeilian-backend/.env.hl + environment: + - TZ=Asia/Shanghai + - PYTHONPATH=/app + ports: + - "8012:8000" + volumes: + - ./kaopeilian-backend/app:/app/app # 代码热重载 + - /data/prod-envs/uploads-hl:/app/uploads + - /data/prod-envs/logs-hl:/app/logs + - /data/prod-envs/secrets:/app/secrets:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + networks: + - prod-network + - kaopeilian-network + depends_on: + prod-mysql: + condition: service_healthy + hl-redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + hl-redis: + image: redis:7.2-alpine + container_name: hl-redis + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "6392:6379" + volumes: + - /data/redis-data/hl:/data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + # ============================================ + # 芯颜定制 (xy.ireborn.com.cn) + # ============================================ + xy-frontend: + # 使用共享的前端镜像,挂载统一的dist目录 + image: kaopeilian-frontend:shared + container_name: xy-frontend + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "3013:80" + volumes: + - /root/aiedu/kaopeilian-frontend/dist:/usr/share/nginx/html:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + - kaopeilian-network + depends_on: + - xy-backend + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + xy-backend: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile + image: prod-multi-xy-backend:latest + container_name: xy-backend + restart: unless-stopped + env_file: + - ./kaopeilian-backend/.env.xy + environment: + - TZ=Asia/Shanghai + - PYTHONPATH=/app + ports: + - "8013:8000" + volumes: + - ./kaopeilian-backend/app:/app/app # 代码热重载 + - /data/prod-envs/uploads-xy:/app/uploads + - /data/prod-envs/logs-xy:/app/logs + - /data/prod-envs/secrets:/app/secrets:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + networks: + - prod-network + - kaopeilian-network + depends_on: + prod-mysql: + condition: service_healthy + xy-redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + xy-redis: + image: redis:7.2-alpine + container_name: xy-redis + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "6393:6379" + volumes: + - /data/redis-data/xy:/data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + # ============================================ + # 飞沃 (fw.ireborn.com.cn) + # ============================================ + fw-frontend: + # 使用共享的前端镜像,挂载统一的dist目录 + # 这样只需编译一次前端,所有租户自动更新 + image: kaopeilian-frontend:shared + container_name: fw-frontend + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "3014:80" + volumes: + - /root/aiedu/kaopeilian-frontend/dist:/usr/share/nginx/html:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + - kaopeilian-network + depends_on: + - fw-backend + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + fw-backend: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile + image: prod-multi-fw-backend:latest + container_name: fw-backend + restart: unless-stopped + env_file: + - ./kaopeilian-backend/.env.fw + environment: + - TZ=Asia/Shanghai + - PYTHONPATH=/app + ports: + - "8014:8000" + volumes: + - ./kaopeilian-backend/app:/app/app # 代码热重载 + - /data/prod-envs/uploads-fw:/app/uploads + - /data/prod-envs/logs-fw:/app/logs + - /data/prod-envs/secrets:/app/secrets:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + networks: + - prod-network + - kaopeilian-network + depends_on: + prod-mysql: + condition: service_healthy + fw-redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + fw-redis: + image: redis:7.2-alpine + container_name: fw-redis + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "6394:6379" + volumes: + - /data/redis-data/fw:/data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + +# 网络配置 +networks: + prod-network: + driver: bridge + name: prod-network + kaopeilian-network: + external: true + name: kaopeilian-network + + + diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml new file mode 100644 index 0000000..4fb7df6 --- /dev/null +++ b/deploy/docker/docker-compose.yml @@ -0,0 +1,178 @@ +# 考培练系统完整部署配置 +version: '3.8' + +services: + # MySQL数据库服务 + mysql: + image: mysql:8.0.43 + container_name: kaopeilian-mysql + restart: unless-stopped + environment: + TZ: Asia/Shanghai + MYSQL_ROOT_PASSWORD: Kaopeilian2025!@# + MYSQL_DATABASE: kaopeilian + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + ports: + - "3307:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./考培练系统规划/数据库-里程碑备份/7-完成数据分析模块-20251016_075159.sql:/docker-entrypoint-initdb.d/init.sql + - ./kaopeilian-backend/mysql.cnf:/etc/mysql/conf.d/mysql.cnf + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: --default-authentication-plugin=mysql_native_password + networks: + - kaopeilian-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + timeout: 20s + retries: 10 + + # 后端API服务 + backend: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile + container_name: kaopeilian-backend + restart: unless-stopped + env_file: + - ./kaopeilian-backend/.env.production + environment: + - TZ=Asia/Shanghai + - PYTHONPATH=/app + ports: + - "8000:8000" + volumes: + - ./kaopeilian-backend/app:/app/app # 代码热重载 + - /data/kaopeilian/uploads/demo:/app/uploads # 迁移到数据盘 + - ./kaopeilian-backend/logs:/app/logs + - ./kaopeilian-backend/secrets:/app/secrets:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + networks: + - kaopeilian-network + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + # 前端服务 + frontend: + build: + context: ./kaopeilian-frontend + dockerfile: Dockerfile + target: production + args: + - NODE_ENV=production + - VITE_API_BASE_URL=https://aiedu.ireborn.com.cn + - VITE_WS_BASE_URL=wss://aiedu.ireborn.com.cn + - VITE_USE_MOCK_DATA=false + container_name: kaopeilian-frontend + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + volumes: + - ./kaopeilian-frontend/docker/nginx.conf:/etc/nginx/nginx.conf:ro + - ./kaopeilian-frontend/docker/default.conf:/etc/nginx/conf.d/default.conf:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - kaopeilian-network + depends_on: + - backend + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # Redis缓存服务 + redis: + image: redis:7.2-alpine + container_name: kaopeilian-redis + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "6379:6379" + volumes: + - redis_data:/data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - kaopeilian-network + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + # Nginx反向代理和SSL终止 + nginx: + image: nginx:1.25-alpine + container_name: kaopeilian-nginx + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - /etc/letsencrypt:/etc/letsencrypt:ro + - /var/www/certbot:/var/www/certbot:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - kaopeilian-network + - kpl-dev-network + depends_on: + - frontend + - backend + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/health"] + interval: 30s + timeout: 10s + retries: 3 + + # SSL证书续期服务 + certbot: + image: certbot/certbot:latest + container_name: kaopeilian-certbot + restart: "no" + volumes: + - /etc/letsencrypt:/etc/letsencrypt + - /var/www/certbot:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + profiles: + - ssl + +# 网络配置 +networks: + kaopeilian-network: + driver: bridge + name: kaopeilian-network + kpl-dev-network: + external: true + name: kpl-dev-network + +# 数据卷配置 +volumes: + redis_data: + driver: local + name: kaopeilian-redis-data + mysql_data: + driver: local + name: kaopeilian-mysql-data \ No newline at end of file diff --git a/deploy/nginx/conf.d/admin.conf b/deploy/nginx/conf.d/admin.conf new file mode 100644 index 0000000..bba6c91 --- /dev/null +++ b/deploy/nginx/conf.d/admin.conf @@ -0,0 +1,91 @@ +# 考培练系统 SaaS 超级管理后台 +# 域名:admin.kpl.ireborn.com.cn + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name admin.kpl.ireborn.com.cn; + + # Let's Encrypt 证书验证 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# HTTPS +server { + listen 443 ssl; + http2 on; + server_name admin.kpl.ireborn.com.cn; + + # SSL 证书 + ssl_certificate /etc/letsencrypt/live/admin.kpl.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/admin.kpl.ireborn.com.cn/privkey.pem; + + # SSL 配置 + ssl_session_timeout 1d; + ssl_session_cache shared:AdminSSL:10m; + ssl_session_tickets off; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Docker DNS resolver + resolver 127.0.0.11 valid=30s; + + # 动态 upstream 变量 + set $admin_frontend kaopeilian-admin-frontend; + set $admin_backend kaopeilian-admin-backend; + + # API 路由 - 使用 rewrite 确保正确传递 URI + location /api/ { + rewrite ^/api/(.*)$ /api/$1 break; + proxy_pass http://$admin_backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + client_max_body_size 50M; + } + + # 健康检查 + location = /health { + proxy_pass http://$admin_backend:8000/health; + proxy_set_header Host $host; + access_log off; + } + + # 前端静态资源 + location / { + proxy_pass http://$admin_frontend:80; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + + # HTML 不缓存 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } +} diff --git a/deploy/nginx/conf.d/ex.conf b/deploy/nginx/conf.d/ex.conf new file mode 100644 index 0000000..d08572c --- /dev/null +++ b/deploy/nginx/conf.d/ex.conf @@ -0,0 +1,102 @@ +# 恩喜成都总院 (ex.ireborn.com.cn) Nginx配置 +# 支持 HTTP 和 HTTPS 访问 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name ex.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS 配置 +server { + listen 443 ssl; + http2 on; + server_name ex.ireborn.com.cn; + + # SSL 证书配置 + ssl_certificate /etc/letsencrypt/live/ex.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/ex.ireborn.com.cn/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + + # 前端静态资源(带哈希,长期缓存) + location /assets/ { + proxy_pass http://ex-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 带哈希的文件可以长期缓存 + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + # 前端服务(HTML 不缓存) + location / { + proxy_pass http://ex-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # HTML 文件不缓存,确保用户获取最新版本 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # 后端API + location /api/ { + proxy_pass http://ex-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 健康检查 + location /health { + proxy_pass http://ex-backend:8000; + proxy_set_header Host $host; + access_log off; + } + + # 静态文件上传 + location /static/uploads/ { + proxy_pass http://ex-backend:8000; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public"; + } +} diff --git a/deploy/nginx/conf.d/fw.conf b/deploy/nginx/conf.d/fw.conf new file mode 100644 index 0000000..0c6a245 --- /dev/null +++ b/deploy/nginx/conf.d/fw.conf @@ -0,0 +1,101 @@ +# 飞沃 (fw.ireborn.com.cn) Nginx配置 +# 支持 HTTP 和 HTTPS 访问 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name fw.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name fw.ireborn.com.cn; + + # SSL 证书配置 + ssl_certificate /etc/letsencrypt/live/fw.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/fw.ireborn.com.cn/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + + # 前端静态资源(带哈希,长期缓存) + location /assets/ { + proxy_pass http://fw-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 带哈希的文件可以长期缓存 + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + # 前端服务(HTML 不缓存) + location / { + proxy_pass http://fw-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # HTML 文件不缓存,确保用户获取最新版本 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # 后端API + location /api/ { + proxy_pass http://fw-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 健康检查 + location /health { + proxy_pass http://fw-backend:8000; + proxy_set_header Host $host; + access_log off; + } + + # 静态文件上传 + location /static/uploads/ { + proxy_pass http://fw-backend:8000; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public"; + } +} diff --git a/deploy/nginx/conf.d/hl.conf b/deploy/nginx/conf.d/hl.conf new file mode 100644 index 0000000..1e4d4d6 --- /dev/null +++ b/deploy/nginx/conf.d/hl.conf @@ -0,0 +1,101 @@ +# 武汉禾丽 (hl.ireborn.com.cn) Nginx配置 +# 支持 HTTP 和 HTTPS 访问 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name hl.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name hl.ireborn.com.cn; + + # SSL 证书配置 + ssl_certificate /etc/letsencrypt/live/hl.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/hl.ireborn.com.cn/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + + # 前端静态资源(带哈希,长期缓存) + location /assets/ { + proxy_pass http://hl-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 带哈希的文件可以长期缓存 + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + # 前端服务(HTML 不缓存) + location / { + proxy_pass http://hl-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # HTML 文件不缓存,确保用户获取最新版本 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # 后端API + location /api/ { + proxy_pass http://hl-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 健康检查 + location /health { + proxy_pass http://hl-backend:8000; + proxy_set_header Host $host; + access_log off; + } + + # 静态文件上传 + location /static/uploads/ { + proxy_pass http://hl-backend:8000; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public"; + } +} diff --git a/deploy/nginx/conf.d/hua.conf b/deploy/nginx/conf.d/hua.conf new file mode 100644 index 0000000..34efdfe --- /dev/null +++ b/deploy/nginx/conf.d/hua.conf @@ -0,0 +1,101 @@ +# 华尔倍丽 (hua.ireborn.com.cn) Nginx配置 +# 支持 HTTP 和 HTTPS 访问 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name hua.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name hua.ireborn.com.cn; + + # SSL 证书配置 + ssl_certificate /etc/letsencrypt/live/hua.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/hua.ireborn.com.cn/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + + # 前端静态资源(带哈希,长期缓存) + location /assets/ { + proxy_pass http://hua-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 带哈希的文件可以长期缓存 + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + # 前端服务(HTML 不缓存) + location / { + proxy_pass http://hua-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # HTML 文件不缓存,确保用户获取最新版本 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # 后端API + location /api/ { + proxy_pass http://hua-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 健康检查 + location /health { + proxy_pass http://hua-backend:8000; + proxy_set_header Host $host; + access_log off; + } + + # 静态文件上传 + location /static/uploads/ { + proxy_pass http://hua-backend:8000; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public"; + } +} diff --git a/deploy/nginx/conf.d/kaopeilian.conf b/deploy/nginx/conf.d/kaopeilian.conf new file mode 100644 index 0000000..5954427 --- /dev/null +++ b/deploy/nginx/conf.d/kaopeilian.conf @@ -0,0 +1,150 @@ +# 考陪练系统 Nginx 配置 +# 支持 HTTP 和 HTTPS 访问 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name aiedu.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name aiedu.ireborn.com.cn; + + # SSL 证书配置 + ssl_certificate /etc/letsencrypt/live/aiedu.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/aiedu.ireborn.com.cn/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + + # 前端静态资源(带哈希,长期缓存) + location /assets/ { + proxy_pass http://kaopeilian-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 带哈希的文件可以长期缓存 + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + # 前端生产服务器(HTML 不缓存) + location / { + proxy_pass http://kaopeilian-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # HTML 文件不缓存,确保用户获取最新版本 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # 修复前端localhost:8000请求的特殊处理 + location ~* ^/localhost:8000/(.*)$ { + rewrite ^/localhost:8000/(.*)$ /$1 break; + proxy_pass http://kaopeilian-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 后端生产服务器 API + location /api/ { + proxy_pass http://kaopeilian-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 支持 WebSocket + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # 超时配置 - 增加到10分钟以支持AI试题生成等长时间处理 + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 后端健康检查 + location /health { + proxy_pass http://kaopeilian-backend:8000; + proxy_set_header Host $host; + access_log off; + } + + # 静态文件上传 + location /static/uploads/ { + proxy_pass http://kaopeilian-backend:8000; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public"; + } + + # GitHub Webhook处理 + location /webhook { + proxy_pass http://172.18.0.1:9000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Webhook专用配置 + proxy_read_timeout 60s; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + } + + # Webhook状态检查 + location /webhook/health { + proxy_pass http://172.18.0.1:9000/health; + proxy_set_header Host $host; + access_log off; + } + + location /webhook/status { + proxy_pass http://172.18.0.1:9000/status; + proxy_set_header Host $host; + } +} + + + diff --git a/deploy/nginx/conf.d/kpl.conf b/deploy/nginx/conf.d/kpl.conf new file mode 100644 index 0000000..79a40fd --- /dev/null +++ b/deploy/nginx/conf.d/kpl.conf @@ -0,0 +1,118 @@ +# 瑞小美团队开发环境 Nginx 配置 +# 域名:kpl.ireborn.com.cn +# 支持 HTTP 和 HTTPS 访问,热重载 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name kpl.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name kpl.ireborn.com.cn; + + # SSL 证书配置 + ssl_certificate /etc/letsencrypt/live/kpl.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/kpl.ireborn.com.cn/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + + # 前端服务(共享 dist 方案) + location / { + proxy_pass http://kpl-frontend-dev:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # HTML 不缓存 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # JS/CSS 静态资源(带 hash 可长期缓存) + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + proxy_pass http://kpl-frontend-dev:80; + proxy_set_header Host $host; + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + # 修复前端localhost:8000请求的特殊处理 + location ~* ^/localhost:8000/(.*)$ { + rewrite ^/localhost:8000/(.*)$ /$1 break; + proxy_pass http://kpl-backend-dev:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 后端开发服务器 API + location /api/ { + proxy_pass http://kpl-backend-dev:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 支持 WebSocket + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # 超时配置 - 增加到10分钟以支持AI试题生成等长时间处理 + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 后端健康检查 + location /health { + proxy_pass http://kpl-backend-dev:8000; + proxy_set_header Host $host; + access_log off; + } + + # 静态文件上传 + location /static/uploads/ { + proxy_pass http://kpl-backend-dev:8000; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public"; + } +} + diff --git a/deploy/nginx/conf.d/pl.conf b/deploy/nginx/conf.d/pl.conf new file mode 100644 index 0000000..c02ce1f --- /dev/null +++ b/deploy/nginx/conf.d/pl.conf @@ -0,0 +1,73 @@ +# 陪练试用版 Nginx配置 +# 域名: pl.ireborn.com.cn + +upstream pl_backend { + server 172.18.0.1:8020; +} + +upstream pl_frontend { + server 172.18.0.1:3020; +} + +# HTTP -> HTTPS 重定向 +server { + listen 80; + server_name pl.ireborn.com.cn; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# HTTPS 服务 +server { + listen 443 ssl; + server_name pl.ireborn.com.cn; + + ssl_certificate /etc/letsencrypt/live/pl.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/pl.ireborn.com.cn/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + access_log /var/log/nginx/pl_access.log; + error_log /var/log/nginx/pl_error.log; + + location / { + proxy_pass http://pl_frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 禁用缓存 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + + location /api/ { + proxy_pass http://pl_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location /@vite/ { + proxy_pass http://pl_frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} diff --git a/deploy/nginx/conf.d/pl.conf.disabled b/deploy/nginx/conf.d/pl.conf.disabled new file mode 100644 index 0000000..182ded8 --- /dev/null +++ b/deploy/nginx/conf.d/pl.conf.disabled @@ -0,0 +1,99 @@ +# 陪练试用版 Nginx配置 +# 域名: pl.ireborn.com.cn +# 后端: peilian-backend:8000 (通过外部网络访问 host.docker.internal:8020) +# 前端: peilian-frontend:3000 (通过外部网络访问 host.docker.internal:3020) + +upstream pl_backend { + server host.docker.internal:8020; +} + +upstream pl_frontend { + server host.docker.internal:3020; +} + +# HTTP -> HTTPS 重定向 +server { + listen 80; + server_name pl.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# HTTPS 服务 +server { + listen 443 ssl http2; + server_name pl.ireborn.com.cn; + + # SSL证书 + ssl_certificate /etc/letsencrypt/live/pl.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/pl.ireborn.com.cn/privkey.pem; + + # SSL配置 + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; + ssl_prefer_server_ciphers off; + + # 安全头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # 日志 + access_log /var/log/nginx/pl_access.log; + error_log /var/log/nginx/pl_error.log; + + # 前端页面 + location / { + proxy_pass http://pl_frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # API代理 + location /api/ { + proxy_pass http://pl_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 超时设置 + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # WebSocket代理(用于HMR) + location /_hmr { + proxy_pass http://pl_frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } + + # Vite HMR WebSocket + location /@vite/ { + proxy_pass http://pl_frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} diff --git a/deploy/nginx/conf.d/xy.conf b/deploy/nginx/conf.d/xy.conf new file mode 100644 index 0000000..8ce3692 --- /dev/null +++ b/deploy/nginx/conf.d/xy.conf @@ -0,0 +1,96 @@ +# 芯颜定制 (xy.ireborn.com.cn) Nginx配置 +# 支持 HTTP 和 HTTPS 访问 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name xy.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name xy.ireborn.com.cn; + + # SSL 证书配置 + ssl_certificate /etc/letsencrypt/live/xy.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/xy.ireborn.com.cn/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + + # 前端服务 - HTML文件不缓存 + location / { + proxy_pass http://xy-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # HTML文件不缓存,确保获取最新版本 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # JS/CSS等静态资源(带hash的文件可长期缓存) + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + proxy_pass http://xy-frontend:80; + proxy_set_header Host $host; + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + # 后端API + location /api/ { + proxy_pass http://xy-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 健康检查 + location /health { + proxy_pass http://xy-backend:8000; + proxy_set_header Host $host; + access_log off; + } + + # 静态文件上传 + location /static/uploads/ { + proxy_pass http://xy-backend:8000; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public"; + } +} diff --git a/deploy/nginx/conf.d/yy.conf b/deploy/nginx/conf.d/yy.conf new file mode 100644 index 0000000..981a80b --- /dev/null +++ b/deploy/nginx/conf.d/yy.conf @@ -0,0 +1,101 @@ +# 杨扬宠物 (yy.ireborn.com.cn) Nginx配置 +# 支持 HTTP 和 HTTPS 访问 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name yy.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name yy.ireborn.com.cn; + + # SSL 证书配置 + ssl_certificate /etc/letsencrypt/live/yy.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/yy.ireborn.com.cn/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + + # 前端静态资源(带哈希,长期缓存) + location /assets/ { + proxy_pass http://yy-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 带哈希的文件可以长期缓存 + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + # 前端服务(HTML 不缓存) + location / { + proxy_pass http://yy-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # HTML 文件不缓存,确保用户获取最新版本 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # 后端API + location /api/ { + proxy_pass http://yy-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 健康检查 + location /health { + proxy_pass http://yy-backend:8000; + proxy_set_header Host $host; + access_log off; + } + + # 静态文件上传 + location /static/uploads/ { + proxy_pass http://yy-backend:8000; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public"; + } +} diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf new file mode 100644 index 0000000..49fc0e6 --- /dev/null +++ b/deploy/nginx/nginx.conf @@ -0,0 +1,53 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 日志格式 + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + # 基础配置 + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 50M; + + # Gzip压缩 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/atom+xml + image/svg+xml; + + # 包含站点配置 + include /etc/nginx/conf.d/*.conf; +} + + + diff --git a/deploy/scripts/auto_update.sh b/deploy/scripts/auto_update.sh new file mode 100755 index 0000000..c6ec0d0 --- /dev/null +++ b/deploy/scripts/auto_update.sh @@ -0,0 +1,152 @@ +#!/bin/bash + +# 考培练系统自动更新脚本 +# 作者: AI Assistant +# 日期: 2025-09-25 + +set -e + +# 配置变量 +PROJECT_DIR="/root/aiedu" +LOG_FILE="/var/log/kaopeilian_update.log" +BACKUP_DIR="/root/aiedu/backups/updates" + +# 日志函数 +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" +} + +# 创建备份目录 +mkdir -p "$BACKUP_DIR" + +log "=== 开始检查代码更新 ===" + +cd "$PROJECT_DIR" + +# 获取当前提交哈希 +LOCAL_COMMIT=$(git rev-parse HEAD) +log "本地提交: $LOCAL_COMMIT" + +# 拉取最新代码 +git fetch origin production + +# 获取远程提交哈希 +REMOTE_COMMIT=$(git rev-parse origin/production) +log "远程提交: $REMOTE_COMMIT" + +# 检查是否有更新 +if [ "$LOCAL_COMMIT" = "$REMOTE_COMMIT" ]; then + log "代码已是最新版本,无需更新" + exit 0 +fi + +log "发现代码更新,开始部署流程..." + +# 创建备份 +BACKUP_NAME="backup_$(date '+%Y%m%d_%H%M%S')" +log "创建备份: $BACKUP_NAME" + +# 备份当前代码 +git stash push -m "Auto backup before update $BACKUP_NAME" + +# 备份数据库(如果MySQL容器在运行) +if docker ps | grep -q kaopeilian-mysql; then + docker exec kaopeilian-mysql mysqldump -u root -p'Kaopeilian2025!@#' kaopeilian > "$BACKUP_DIR/${BACKUP_NAME}_database.sql" + log "数据库备份完成" +fi + +# 拉取最新代码 +log "拉取最新代码..." +git pull origin production + +# 检查是否需要重新构建 +NEED_REBUILD=false + +# 检查Docker文件变化 +if git diff --name-only "$LOCAL_COMMIT" "$REMOTE_COMMIT" | grep -E "(Dockerfile|docker-compose\.yml|requirements\.txt|package\.json)"; then + NEED_REBUILD=true + log "检测到构建文件变化,需要重新构建镜像" +fi + +# 检查前端文件变化并构建 +if git diff --name-only "$LOCAL_COMMIT" "$REMOTE_COMMIT" | grep -E "kaopeilian-frontend/(src/|package\.json|vite\.config\.ts)"; then + log "检测到前端代码变化,开始构建前端..." + + cd "$PROJECT_DIR/kaopeilian-frontend" + + # 确保依赖已安装 + if [ ! -d "node_modules" ]; then + log "安装前端依赖..." + npm install + fi + + # 构建前端 + log "构建前端应用..." + npm run build + + if [ $? -eq 0 ]; then + log "前端构建成功" + NEED_REBUILD=true + else + log "⚠️ 前端构建失败,但继续部署流程" + fi + + cd "$PROJECT_DIR" +fi + +# 停止服务 +log "停止当前服务..." +docker compose down + +# 重新构建(如果需要) +if [ "$NEED_REBUILD" = true ]; then + log "重新构建Docker镜像..." + docker compose build --no-cache +else + log "使用现有镜像启动服务..." +fi + +# 启动服务 +log "启动更新后的服务..." +docker compose up -d + +# 等待服务启动 +sleep 60 + +# 健康检查 +log "执行健康检查..." +HEALTH_CHECK_URL="https://aiedu.ireborn.com.cn/health" + +# 尝试多次健康检查 +RETRY_COUNT=0 +MAX_RETRIES=5 + +while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if curl -f -s -k "$HEALTH_CHECK_URL" > /dev/null; then + log "✅ 健康检查通过,更新成功完成" + + # 清理旧的备份(保留最近5个) + cd "$BACKUP_DIR" + ls -t backup_*_database.sql 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true + + log "=== 自动更新完成 ===" + exit 0 + else + RETRY_COUNT=$((RETRY_COUNT + 1)) + log "健康检查失败,重试 $RETRY_COUNT/$MAX_RETRIES" + sleep 10 + fi +done + +log "❌ 健康检查失败,开始回滚..." + +# 回滚代码 +cd "$PROJECT_DIR" +git reset --hard "$LOCAL_COMMIT" + +# 重新启动服务 +docker compose down +docker compose up -d + +log "回滚完成,请检查服务状态" +exit 1 diff --git a/deploy/scripts/check-config.sh b/deploy/scripts/check-config.sh new file mode 100755 index 0000000..d285dc2 --- /dev/null +++ b/deploy/scripts/check-config.sh @@ -0,0 +1,223 @@ +#!/bin/bash + +# 配置一致性检查脚本 +# 用于快速验证系统各组件配置是否一致 + +echo "🔧 配置一致性检查脚本" +echo "========================" + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 检查结果统计 +PASS_COUNT=0 +FAIL_COUNT=0 + +# 检查函数 +check_service() { + local service_name=$1 + local url=$2 + local expected_response=$3 + + echo -n "检查 $service_name ... " + + if command -v curl >/dev/null 2>&1; then + response=$(curl -s --connect-timeout 5 "$url" 2>/dev/null) + if [[ $? -eq 0 && "$response" == *"$expected_response"* ]]; then + echo -e "${GREEN}✅ 通过${NC}" + ((PASS_COUNT++)) + else + echo -e "${RED}❌ 失败${NC}" + echo " 期望包含: $expected_response" + echo " 实际响应: $response" + ((FAIL_COUNT++)) + fi + else + echo -e "${YELLOW}⚠️ 跳过 (curl 未安装)${NC}" + fi +} + +# 检查Docker服务 +check_docker_service() { + local service_name=$1 + echo -n "检查 Docker 服务 $service_name ... " + + if command -v docker-compose >/dev/null 2>&1; then + if docker-compose ps | grep -q "$service_name.*Up"; then + echo -e "${GREEN}✅ 运行中${NC}" + ((PASS_COUNT++)) + else + echo -e "${RED}❌ 未运行${NC}" + ((FAIL_COUNT++)) + fi + else + echo -e "${YELLOW}⚠️ 跳过 (docker-compose 未安装)${NC}" + fi +} + +# 检查配置文件 +check_config_file() { + local file_path=$1 + local description=$2 + + echo -n "检查 $description ... " + + if [[ -f "$file_path" ]]; then + echo -e "${GREEN}✅ 存在${NC}" + ((PASS_COUNT++)) + else + echo -e "${RED}❌ 缺失${NC}" + echo " 文件路径: $file_path" + ((FAIL_COUNT++)) + fi +} + +# 检查端口占用 +check_port() { + local port=$1 + local service_name=$2 + + echo -n "检查端口 $port ($service_name) ... " + + if command -v lsof >/dev/null 2>&1; then + if lsof -i :$port >/dev/null 2>&1; then + echo -e "${GREEN}✅ 已占用${NC}" + ((PASS_COUNT++)) + else + echo -e "${RED}❌ 未占用${NC}" + ((FAIL_COUNT++)) + fi + elif command -v netstat >/dev/null 2>&1; then + if netstat -an | grep -q ":$port "; then + echo -e "${GREEN}✅ 已占用${NC}" + ((PASS_COUNT++)) + else + echo -e "${RED}❌ 未占用${NC}" + ((FAIL_COUNT++)) + fi + else + echo -e "${YELLOW}⚠️ 跳过 (lsof/netstat 未安装)${NC}" + fi +} + +echo "1. 检查基础服务状态" +echo "-------------------" + +# 检查Docker服务 +check_docker_service "mysql" +check_docker_service "redis" + +# 检查端口占用 +check_port 3306 "MySQL" +check_port 6379 "Redis" +check_port 8000 "后端API" +check_port 3001 "前端开发服务器" + +echo "" +echo "2. 检查服务健康状态" +echo "-------------------" + +# 检查后端健康状态 +check_service "后端API健康检查" "http://localhost:8000/health" "healthy" + +# 检查前端服务 +check_service "前端服务" "http://localhost:3001" "考培练系统" + +echo "" +echo "3. 检查配置文件" +echo "---------------" + +# 检查关键配置文件 +check_config_file "kaopeilian-backend/app/config/settings.py" "后端配置文件" +check_config_file "kaopeilian-frontend/src/api/config.ts" "前端API配置" +check_config_file "docker-compose.yml" "Docker配置文件" +check_config_file "配置一致性检查清单.md" "配置检查清单" + +echo "" +echo "4. 检查认证功能" +echo "---------------" + +# 检查登录API +echo -n "检查登录API ... " +if command -v curl >/dev/null 2>&1; then + login_response=$(curl -s -X POST http://localhost:8000/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "testuser", "password": "TestPass123!"}' 2>/dev/null) + + if [[ "$login_response" == *"access_token"* ]]; then + echo -e "${GREEN}✅ 正常工作${NC}" + ((PASS_COUNT++)) + + # 提取token测试认证 + if command -v jq >/dev/null 2>&1; then + token=$(echo "$login_response" | jq -r '.data.access_token' 2>/dev/null) + if [[ "$token" != "null" && "$token" != "" ]]; then + echo -n "检查token认证 ... " + auth_response=$(curl -s -H "Authorization: Bearer $token" \ + http://localhost:8000/api/v1/auth/me 2>/dev/null) + + if [[ "$auth_response" == *"testuser"* ]]; then + echo -e "${GREEN}✅ 正常工作${NC}" + ((PASS_COUNT++)) + else + echo -e "${RED}❌ 失败${NC}" + ((FAIL_COUNT++)) + fi + fi + else + echo " (跳过token测试 - jq未安装)" + fi + else + echo -e "${RED}❌ 失败${NC}" + echo " 响应: $login_response" + ((FAIL_COUNT++)) + fi +else + echo -e "${YELLOW}⚠️ 跳过 (curl 未安装)${NC}" +fi + +echo "" +echo "5. 检查CORS配置" +echo "---------------" + +echo -n "检查CORS预检请求 ... " +if command -v curl >/dev/null 2>&1; then + cors_response=$(curl -s -X OPTIONS http://localhost:8000/api/v1/auth/login \ + -H "Origin: http://localhost:3001" \ + -H "Access-Control-Request-Method: POST" \ + -H "Access-Control-Request-Headers: Content-Type" 2>/dev/null) + + if [[ $? -eq 0 ]]; then + echo -e "${GREEN}✅ 正常响应${NC}" + ((PASS_COUNT++)) + else + echo -e "${RED}❌ 失败${NC}" + ((FAIL_COUNT++)) + fi +else + echo -e "${YELLOW}⚠️ 跳过 (curl 未安装)${NC}" +fi + +echo "" +echo "========================" +echo "📊 检查结果统计" +echo "------------------------" +echo -e "通过: ${GREEN}$PASS_COUNT${NC}" +echo -e "失败: ${RED}$FAIL_COUNT${NC}" + +if [[ $FAIL_COUNT -eq 0 ]]; then + echo -e "\n🎉 ${GREEN}所有检查都通过了!系统配置正常。${NC}" + exit 0 +else + echo -e "\n⚠️ ${YELLOW}发现 $FAIL_COUNT 个问题,请检查配置。${NC}" + echo "" + echo "💡 解决建议:" + echo "1. 确保Docker服务已启动: docker-compose up -d" + echo "2. 检查端口是否被占用或服务未启动" + echo "3. 参考 '配置一致性检查清单.md' 核对配置" + echo "4. 确保数据库密码、CORS域名等配置一致" + exit 1 +fi diff --git a/deploy/scripts/check_environment.sh b/deploy/scripts/check_environment.sh new file mode 100644 index 0000000..ecaf7d0 --- /dev/null +++ b/deploy/scripts/check_environment.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# 环境状态检查脚本 + +echo "=== 考培练系统环境状态检查 ===" +echo "检查时间: $(date)" +echo "" + +# 检查前端环境 +echo "🌐 前端环境检查:" +if curl -s -f http://localhost:3001 > /dev/null; then + echo "✅ 前端服务运行正常 (http://localhost:3001)" + + # 尝试获取环境信息 + if command -v jq &> /dev/null; then + echo "📊 前端环境信息:" + curl -s http://localhost:3001/api/env 2>/dev/null | jq . || echo "无法获取环境信息" + fi +else + echo "❌ 前端服务不可访问" +fi +echo "" + +# 检查后端环境 +echo "🚀 后端环境检查:" +if curl -s -f http://localhost:8000/health > /dev/null; then + echo "✅ 后端服务运行正常 (http://localhost:8000)" + + # 获取健康检查信息 + if command -v jq &> /dev/null; then + echo "📊 后端环境信息:" + curl -s http://localhost:8000/health | jq . + else + echo "健康检查响应:" + curl -s http://localhost:8000/health + fi +else + echo "❌ 后端服务不可访问" +fi +echo "" + +# 检查数据库连接 +echo "🗄️ 数据库连接检查:" +if curl -s -f http://localhost:8000/health/db > /dev/null 2>&1; then + echo "✅ 数据库连接正常" + if command -v jq &> /dev/null; then + curl -s http://localhost:8000/health/db | jq . + fi +else + echo "❌ 数据库连接异常" +fi +echo "" + +# 检查Redis连接 +echo "🔴 Redis连接检查:" +if curl -s -f http://localhost:8000/health/redis > /dev/null 2>&1; then + echo "✅ Redis连接正常" + if command -v jq &> /dev/null; then + curl -s http://localhost:8000/health/redis | jq . + fi +else + echo "❌ Redis连接异常" +fi +echo "" + +# 检查Docker容器状态 +echo "🐳 Docker容器状态:" +if command -v docker &> /dev/null; then + echo "开发环境容器:" + docker-compose -f docker-compose.dev.yml ps 2>/dev/null || echo "无法获取开发环境容器状态" + echo "" + echo "生产环境容器:" + docker-compose ps 2>/dev/null || echo "无法获取生产环境容器状态" +else + echo "Docker未安装或不可访问" +fi +echo "" + +# 检查端口占用 +echo "🔌 端口占用检查:" +ports=(3001 8000 3306 6379) +for port in "${ports[@]}"; do + if lsof -i :$port > /dev/null 2>&1; then + echo "✅ 端口 $port 已占用" + lsof -i :$port | head -2 | tail -1 | awk '{print " 进程:", $2, "命令:", $1}' + else + echo "❌ 端口 $port 未占用" + fi +done +echo "" + +echo "=== 环境检查完成 ===" \ No newline at end of file diff --git a/deploy/scripts/cleanup_docker.sh b/deploy/scripts/cleanup_docker.sh new file mode 100755 index 0000000..f01e432 --- /dev/null +++ b/deploy/scripts/cleanup_docker.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Docker 清理脚本 +# 用于清理未使用的Docker资源 + +echo "🧹 开始清理Docker资源..." + +# 1. 清理停止的容器 +echo "📋 清理停止的容器..." +stopped_containers=$(docker ps -a --filter "status=exited" -q) +if [ ! -z "$stopped_containers" ]; then + echo "发现停止的容器: $stopped_containers" + docker rm $stopped_containers + echo "✅ 已清理停止的容器" +else + echo "✅ 没有停止的容器需要清理" +fi + +# 2. 清理悬空镜像 +echo "📋 清理悬空镜像..." +dangling_images=$(docker images --filter "dangling=true" -q) +if [ ! -z "$dangling_images" ]; then + echo "发现悬空镜像: $dangling_images" + docker rmi $dangling_images + echo "✅ 已清理悬空镜像" +else + echo "✅ 没有悬空镜像需要清理" +fi + +# 3. 清理未使用的卷 +echo "📋 清理未使用的卷..." +unused_volumes=$(docker volume ls --filter "dangling=true" -q) +if [ ! -z "$unused_volumes" ]; then + echo "发现未使用的卷: $unused_volumes" + docker volume rm $unused_volumes + echo "✅ 已清理未使用的卷" +else + echo "✅ 没有未使用的卷需要清理" +fi + +# 4. 清理未使用的网络 +echo "📋 清理未使用的网络..." +unused_networks=$(docker network ls --filter "dangling=true" -q) +if [ ! -z "$unused_networks" ]; then + echo "发现未使用的网络: $unused_networks" + docker network rm $unused_networks + echo "✅ 已清理未使用的网络" +else + echo "✅ 没有未使用的网络需要清理" +fi + +# 5. 显示当前状态 +echo "📊 当前Docker状态:" +echo "运行中的容器:" +docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" + +echo -e "\nDocker卷:" +docker volume ls + +echo -e "\nDocker网络:" +docker network ls + +echo "🎉 Docker清理完成!" diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh new file mode 100755 index 0000000..3b4de45 --- /dev/null +++ b/deploy/scripts/deploy.sh @@ -0,0 +1,113 @@ +#!/bin/bash + +# 考陪练系统部署脚本 +# 用于部署前端、后端服务并申请SSL证书 + +set -e + +echo "=== 考陪练系统部署开始 ===" + +# 检查Docker是否运行 +if ! docker info > /dev/null 2>&1; then + echo "❌ Docker未运行,请先启动Docker服务" + exit 1 +fi + +# 检查Docker Compose是否安装 +if ! command -v docker-compose > /dev/null 2>&1; then + echo "❌ Docker Compose未安装" + exit 1 +fi + +echo "✅ Docker环境检查通过" + +# 创建必要的目录 +echo "📁 创建必要的目录..." +mkdir -p /root/aiedu/kaopeilian-backend/logs +mkdir -p /root/aiedu/kaopeilian-backend/uploads +mkdir -p /root/aiedu/nginx/conf.d + +# 停止现有容器 +echo "🛑 停止现有容器..." +docker-compose down || true + +# 构建镜像 +echo "🔨 构建Docker镜像..." +docker-compose build --no-cache + +# 启动服务(除了nginx,因为还没有SSL证书) +echo "🚀 启动后端和前端服务..." +docker-compose up -d kaopeilian-redis kaopeilian-backend kaopeilian-frontend + +# 等待服务启动 +echo "⏳ 等待服务启动..." +sleep 30 + +# 检查服务健康状态 +echo "🔍 检查服务健康状态..." +if curl -f http://localhost:8000/health > /dev/null 2>&1; then + echo "✅ 后端服务健康检查通过" +else + echo "❌ 后端服务健康检查失败" + docker-compose logs kaopeilian-backend + exit 1 +fi + +if curl -f http://localhost:3001/ > /dev/null 2>&1; then + echo "✅ 前端服务健康检查通过" +else + echo "❌ 前端服务健康检查失败" + docker-compose logs kaopeilian-frontend + exit 1 +fi + +echo "=== 基础服务部署完成 ===" +echo "前端访问地址: http://localhost:3001" +echo "后端API地址: http://localhost:8000" +echo "" +echo "下一步将申请SSL证书..." + +# 申请SSL证书 +echo "🔐 申请SSL证书..." +if command -v certbot > /dev/null 2>&1; then + # 停止nginx容器(如果运行) + docker-compose stop kaopeilian-nginx || true + + # 使用standalone模式申请证书 + certbot certonly \ + --standalone \ + --non-interactive \ + --agree-tos \ + --email admin@ireborn.com.cn \ + --domains aiedu.ireborn.com.cn \ + --pre-hook "docker-compose stop kaopeilian-nginx" \ + --post-hook "docker-compose up -d kaopeilian-nginx" + + if [ $? -eq 0 ]; then + echo "✅ SSL证书申请成功" + + # 启动nginx服务 + echo "🚀 启动Nginx反向代理服务..." + docker-compose up -d kaopeilian-nginx + + echo "=== 部署完成 ===" + echo "HTTPS访问地址: https://aiedu.ireborn.com.cn" + echo "前端访问地址: https://aiedu.ireborn.com.cn" + echo "后端API地址: https://aiedu.ireborn.com.cn/api" + else + echo "❌ SSL证书申请失败,请检查域名配置" + echo "HTTP访问地址: http://aiedu.ireborn.com.cn" + fi +else + echo "⚠️ certbot未安装,跳过SSL证书申请" + echo "请手动安装certbot并申请SSL证书" +fi + +echo "" +echo "=== 部署完成 ===" +echo "查看服务状态: docker-compose ps" +echo "查看日志: docker-compose logs [服务名]" +echo "停止服务: docker-compose down" + + + diff --git a/deploy/scripts/diagnose.sh b/deploy/scripts/diagnose.sh new file mode 100644 index 0000000..f1b34b4 --- /dev/null +++ b/deploy/scripts/diagnose.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# 系统诊断脚本 + +echo "=== 系统诊断开始 ===" +echo "时间: $(date)" +echo "" + +echo "=== Docker服务状态 ===" +systemctl is-active docker || echo "Docker服务未运行" +echo "" + +echo "=== Docker版本 ===" +docker --version 2>/dev/null || echo "Docker命令不可用" +echo "" + +echo "=== 容器状态 ===" +docker ps -a 2>/dev/null || echo "无法获取容器状态" +echo "" + +echo "=== 网络连接测试 ===" +curl -I --connect-timeout 3 http://localhost 2>/dev/null || echo "本地80端口不可访问" +curl -I --connect-timeout 3 https://aiedu.ireborn.com.cn 2>/dev/null || echo "HTTPS不可访问" +echo "" + +echo "=== 端口占用检查 ===" +netstat -tlnp | grep -E ":(80|443|8000|3306|6379|9000)" || echo "关键端口未监听" +echo "" + +echo "=== 服务状态 ===" +systemctl is-active kaopeilian.service || echo "kaopeilian服务未运行" +systemctl is-active kaopeilian-webhook.service || echo "webhook服务未运行" +echo "" + +echo "=== 诊断完成 ===" diff --git a/deploy/scripts/diagnose_dify_network.sh b/deploy/scripts/diagnose_dify_network.sh new file mode 100755 index 0000000..f879f12 --- /dev/null +++ b/deploy/scripts/diagnose_dify_network.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Dify服务器网络诊断脚本 +# 请在Dify服务器(47.112.29.0)上运行此脚本 + +echo "=== Dify服务器网络诊断 ===" +echo "" + +# 1. DNS解析测试 +echo "1. DNS解析测试:" +echo " hl.ireborn.com.cn: $(nslookup hl.ireborn.com.cn 2>/dev/null | grep Address | tail -1 || echo '解析失败')" +echo " yy.ireborn.com.cn: $(nslookup yy.ireborn.com.cn 2>/dev/null | grep Address | tail -1 || echo '解析失败')" +echo "" + +# 2. 网络连通性测试 +echo "2. 网络连通性测试 (ping):" +ping -c 3 120.79.247.16 2>&1 | tail -3 +echo "" + +# 3. 端口连通性测试 +echo "3. 端口连通性测试:" +echo " HTTP (80): $(timeout 5 bash -c 'echo >/dev/tcp/120.79.247.16/80' 2>&1 && echo '可连接' || echo '不可连接')" +echo " HTTPS (443): $(timeout 5 bash -c 'echo >/dev/tcp/120.79.247.16/443' 2>&1 && echo '可连接' || echo '不可连接')" +echo " API (8000): $(timeout 5 bash -c 'echo >/dev/tcp/120.79.247.16/8000' 2>&1 && echo '可连接' || echo '不可连接')" +echo "" + +# 4. HTTPS请求测试 +echo "4. HTTPS请求测试:" +echo " 使用域名:" +curl -s -o /dev/null -w "HTTP状态码: %{http_code}, 连接时间: %{time_connect}s\n" \ + --connect-timeout 10 \ + https://hl.ireborn.com.cn/health 2>&1 || echo " 请求失败" + +echo " 使用IP地址:" +curl -s -o /dev/null -w "HTTP状态码: %{http_code}, 连接时间: %{time_connect}s\n" \ + --connect-timeout 10 \ + -H "Host: hl.ireborn.com.cn" \ + https://120.79.247.16/health 2>&1 || echo " 请求失败" +echo "" + +# 5. SQL执行器API测试 +echo "5. SQL执行器API测试:" +curl -s -X POST https://hl.ireborn.com.cn/api/v1/sql/execute-simple \ + -H "Content-Type: application/json" \ + -H "X-API-Key: dify-2025-kaopeilian" \ + -d '{"sql":"SELECT 1 as test"}' \ + --connect-timeout 10 2>&1 | head -1 || echo " 请求失败" +echo "" + +echo "=== 诊断完成 ===" +echo "" +echo "如果以上测试有失败项,请检查:" +echo "1. 阿里云安全组是否允许来自47.112.29.0的入站流量" +echo "2. 服务器防火墙规则" +echo "3. VPC网络配置" + + + + + diff --git a/deploy/scripts/force_restart.sh b/deploy/scripts/force_restart.sh new file mode 100644 index 0000000..4a3c747 --- /dev/null +++ b/deploy/scripts/force_restart.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# 强制重启所有服务 + +echo "=== 强制重启考培练系统服务 ===" +echo "时间: $(date)" + +# 1. 停止所有相关进程 +echo "1. 停止相关进程..." +pkill -f "docker-compose" +pkill -f "webhook_handler" + +# 2. 清理Docker +echo "2. 清理Docker容器和网络..." +cd /root/aiedu +docker compose down --remove-orphans 2>/dev/null || true +docker system prune -f 2>/dev/null || true + +# 3. 重启Docker服务 +echo "3. 重启Docker服务..." +systemctl restart docker +sleep 15 + +# 4. 启动服务 +echo "4. 启动考培练系统..." +cd /root/aiedu +docker compose up -d + +# 5. 启动webhook服务 +echo "5. 启动Webhook服务..." +systemctl restart kaopeilian-webhook.service + +# 6. 等待服务启动 +echo "6. 等待服务启动..." +sleep 30 + +# 7. 检查状态 +echo "7. 检查服务状态..." +echo "Docker容器:" +docker ps --format "table {{.Names}}\t{{.Status}}" 2>/dev/null || echo "Docker命令失败" + +echo "" +echo "端口监听:" +netstat -tlnp | grep -E ":(80|443|8000|3306|6379|9000)" || echo "端口检查失败" + +echo "" +echo "网站测试:" +curl -I --connect-timeout 5 https://aiedu.ireborn.com.cn 2>/dev/null || echo "网站访问失败" + +echo "" +echo "=== 重启完成 ===" diff --git a/deploy/scripts/quick_test_practice.sh b/deploy/scripts/quick_test_practice.sh new file mode 100755 index 0000000..da4eb3b --- /dev/null +++ b/deploy/scripts/quick_test_practice.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# 快速测试陪练功能API + +echo "============================================================" +echo "陪练功能快速测试" +echo "============================================================" + +# 获取token +echo -e "\n1. 登录获取token..." +TOKEN=$(curl -s -X POST "http://localhost:8000/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin123"}' \ + | python3 -c "import sys,json; data=json.load(sys.stdin); print(data['data']['token']['access_token'] if data['code']==200 else '')") + +if [ -z "$TOKEN" ]; then + echo "❌ 登录失败" + exit 1 +fi + +echo "✅ 登录成功" + +# 测试场景列表 +echo -e "\n2. 测试场景列表..." +SCENES=$(curl -s "http://localhost:8000/api/v1/practice/scenes" \ + -H "Authorization: Bearer $TOKEN") + +COUNT=$(echo $SCENES | python3 -c "import sys,json; data=json.load(sys.stdin); print(data['data']['total'] if data['code']==200 else 0)") + +if [ "$COUNT" -gt 0 ]; then + echo "✅ 成功获取 $COUNT 个场景" +else + echo "❌ 获取场景失败" + exit 1 +fi + +# 测试场景详情 +echo -e "\n3. 测试场景详情..." +DETAIL=$(curl -s "http://localhost:8000/api/v1/practice/scenes/1" \ + -H "Authorization: Bearer $TOKEN") + +NAME=$(echo $DETAIL | python3 -c "import sys,json; data=json.load(sys.stdin); print(data['data']['name'] if data['code']==200 else '')") + +if [ -n "$NAME" ]; then + echo "✅ 成功获取场景: $NAME" +else + echo "❌ 获取场景详情失败" + exit 1 +fi + +echo -e "\n============================================================" +echo "✅ 陪练功能API测试通过" +echo "============================================================" +echo "" +echo "📌 提示:" +echo " - 场景列表: http://localhost:3001/trainee/ai-practice-center" +echo " - 后端API: http://localhost:8000/docs" +echo " - 运行完整测试: python3 test_practice_api.py" +echo "" + + diff --git a/deploy/scripts/quick_test_score_mistakes.sh b/deploy/scripts/quick_test_score_mistakes.sh new file mode 100755 index 0000000..a8ed450 --- /dev/null +++ b/deploy/scripts/quick_test_score_mistakes.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# 快速测试成绩报告和错题本功能 +# 使用方法:./quick_test_score_mistakes.sh + +set -e + +echo "==========================================" +echo "成绩报告与错题本功能快速测试" +echo "==========================================" + +# 颜色定义 +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 1. 检查MySQL容器 +echo -e "\n${YELLOW}1. 检查MySQL容器状态...${NC}" +if docker ps | grep -q kaopeilian-mysql-dev; then + echo -e "${GREEN}✓ MySQL容器正在运行${NC}" +else + echo -e "${RED}✗ MySQL容器未运行,请先启动${NC}" + echo "启动命令: docker-compose -f docker-compose.dev.yml up -d mysql-dev" + exit 1 +fi + +# 2. 检查数据库字段 +echo -e "\n${YELLOW}2. 验证数据库字段...${NC}" +echo "检查exams表的round字段..." +docker exec -i kaopeilian-mysql-dev mysql -u root -pnj861021 kaopeilian -e " + SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA='kaopeilian' + AND TABLE_NAME='exams' + AND COLUMN_NAME LIKE 'round%';" 2>/dev/null | grep -v "Warning" + +echo "检查exam_mistakes表的question_type字段..." +docker exec -i kaopeilian-mysql-dev mysql -u root -pnj861021 kaopeilian -e " + SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA='kaopeilian' + AND TABLE_NAME='exam_mistakes' + AND COLUMN_NAME='question_type';" 2>/dev/null | grep -v "Warning" + +# 3. 检查后端服务 +echo -e "\n${YELLOW}3. 检查后端服务状态...${NC}" +if curl -s http://localhost:8000/health > /dev/null 2>&1; then + echo -e "${GREEN}✓ 后端服务正在运行${NC}" +else + echo -e "${RED}✗ 后端服务未运行${NC}" + echo "请在另一个终端启动后端:" + echo " cd kaopeilian-backend" + echo " uvicorn app.main:app --reload --host 0.0.0.0 --port 8000" + exit 1 +fi + +# 4. 测试API(需要token) +echo -e "\n${YELLOW}4. 测试API接口...${NC}" +echo "提示:需要先登录获取token" +echo "运行Python测试脚本:" +echo " python3 test_score_report_api.py" + +# 5. 前端服务检查 +echo -e "\n${YELLOW}5. 检查前端服务状态...${NC}" +if curl -s http://localhost:3001 > /dev/null 2>&1; then + echo -e "${GREEN}✓ 前端服务正在运行${NC}" + echo "" + echo "📊 成绩报告页面:" + echo " http://localhost:3001/trainee/score-report" + echo "" + echo "📝 错题本页面:" + echo " http://localhost:3001/trainee/mistakes" +else + echo -e "${RED}✗ 前端服务未运行${NC}" + echo "请在另一个终端启动前端:" + echo " cd kaopeilian-frontend" + echo " npm run dev" +fi + +echo "" +echo "==========================================" +echo "检查完成!请根据上述提示进行测试。" +echo "==========================================" + diff --git a/deploy/scripts/robust_start.sh b/deploy/scripts/robust_start.sh new file mode 100755 index 0000000..4bbe112 --- /dev/null +++ b/deploy/scripts/robust_start.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# 健壮的服务启动脚本 + +set -e + +LOG_FILE="/var/log/kaopeilian_start.log" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" +} + +cd /root/aiedu + +log "=== 开始启动考培练系统服务 ===" + +# 1. 检查Docker服务 +log "1. 检查Docker服务状态..." +if ! systemctl is-active --quiet docker; then + log "启动Docker服务..." + systemctl start docker + sleep 10 +fi + +# 2. 清理可能的问题容器 +log "2. 清理问题容器..." +docker compose down --remove-orphans 2>/dev/null || true + +# 3. 分步启动服务(避免前端构建失败影响其他服务) +log "3. 启动基础服务..." +docker compose up -d mysql redis +sleep 30 + +log "4. 启动后端服务..." +docker compose up -d backend +sleep 20 + +log "5. 尝试构建前端..." +if docker compose build frontend; then + log "前端构建成功,启动前端服务..." + docker compose up -d frontend +else + log "前端构建失败,使用旧镜像启动..." + docker compose up -d frontend || log "前端启动失败,跳过前端服务" +fi + +log "6. 启动Nginx..." +docker compose up -d nginx + +# 7. 检查服务状态 +log "7. 检查服务状态..." +sleep 20 +docker compose ps + +# 8. 健康检查 +log "8. 执行健康检查..." +if curl -f -s https://aiedu.ireborn.com.cn/health > /dev/null; then + log "✅ 后端服务正常" +else + log "❌ 后端服务异常" +fi + +if curl -f -s -I https://aiedu.ireborn.com.cn > /dev/null; then + log "✅ 前端服务正常" +else + log "❌ 前端服务异常" +fi + +log "=== 服务启动完成 ===" diff --git a/deploy/scripts/setup_environment.sh b/deploy/scripts/setup_environment.sh new file mode 100644 index 0000000..4ca1724 --- /dev/null +++ b/deploy/scripts/setup_environment.sh @@ -0,0 +1,246 @@ +#!/bin/bash +# 环境设置脚本 + +set -e + +ENV_TYPE=${1:-development} +FORCE_SETUP=${2:-false} + +echo "=== 考培练系统环境设置 ===" +echo "环境类型: $ENV_TYPE" +echo "设置时间: $(date)" +echo "" + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 打印带颜色的消息 +print_message() { + local color=$1 + local message=$2 + echo -e "${color}${message}${NC}" +} + +# 检查必要的工具 +check_requirements() { + print_message $BLUE "检查系统要求..." + + # 检查Docker + if ! command -v docker &> /dev/null; then + print_message $RED "❌ Docker未安装,请先安装Docker" + exit 1 + fi + + # 检查Docker Compose + if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then + print_message $RED "❌ Docker Compose未安装,请先安装Docker Compose" + exit 1 + fi + + # 检查Node.js (如果需要本地开发) + if [ "$ENV_TYPE" = "development" ] && ! command -v node &> /dev/null; then + print_message $YELLOW "⚠️ Node.js未安装,建议安装以支持本地开发" + fi + + # 检查Python (如果需要本地开发) + if [ "$ENV_TYPE" = "development" ] && ! command -v python3 &> /dev/null; then + print_message $YELLOW "⚠️ Python3未安装,建议安装以支持本地开发" + fi + + print_message $GREEN "✅ 系统要求检查完成" +} + +# 设置前端环境 +setup_frontend_env() { + print_message $BLUE "设置前端环境配置..." + + local env_file="kaopeilian-frontend/.env.$ENV_TYPE" + local example_file="kaopeilian-frontend/.env.example" + + if [ ! -f "$env_file" ] || [ "$FORCE_SETUP" = "true" ]; then + if [ -f "$example_file" ]; then + cp "$example_file" "$env_file" + print_message $GREEN "✅ 创建前端环境配置: $env_file" + else + print_message $YELLOW "⚠️ 前端配置模板不存在: $example_file" + fi + else + print_message $GREEN "✅ 前端环境配置已存在: $env_file" + fi + + # 根据环境类型更新配置 + if [ -f "$env_file" ]; then + case $ENV_TYPE in + development) + sed -i.bak 's/VITE_APP_TITLE=.*/VITE_APP_TITLE=考培练系统(开发)/' "$env_file" + sed -i.bak 's/VITE_APP_ENV=.*/VITE_APP_ENV=development/' "$env_file" + sed -i.bak 's/VITE_API_BASE_URL=.*/VITE_API_BASE_URL=http:\/\/localhost:8000/' "$env_file" + sed -i.bak 's/VITE_ENABLE_DEVTOOLS=.*/VITE_ENABLE_DEVTOOLS=true/' "$env_file" + rm -f "$env_file.bak" + ;; + production) + sed -i.bak 's/VITE_APP_TITLE=.*/VITE_APP_TITLE=考培练系统/' "$env_file" + sed -i.bak 's/VITE_APP_ENV=.*/VITE_APP_ENV=production/' "$env_file" + sed -i.bak 's/VITE_API_BASE_URL=.*/VITE_API_BASE_URL=https:\/\/aiedu.ireborn.com.cn/' "$env_file" + sed -i.bak 's/VITE_ENABLE_DEVTOOLS=.*/VITE_ENABLE_DEVTOOLS=false/' "$env_file" + rm -f "$env_file.bak" + ;; + esac + print_message $GREEN "✅ 前端环境配置已更新" + fi +} + +# 设置后端环境 +setup_backend_env() { + print_message $BLUE "设置后端环境配置..." + + local env_file="kaopeilian-backend/.env.$ENV_TYPE" + local example_file="kaopeilian-backend/.env.example" + + if [ ! -f "$env_file" ] || [ "$FORCE_SETUP" = "true" ]; then + if [ -f "$example_file" ]; then + cp "$example_file" "$env_file" + print_message $GREEN "✅ 创建后端环境配置: $env_file" + else + print_message $YELLOW "⚠️ 后端配置模板不存在: $example_file" + fi + else + print_message $GREEN "✅ 后端环境配置已存在: $env_file" + fi + + # 根据环境类型更新配置 + if [ -f "$env_file" ]; then + case $ENV_TYPE in + development) + sed -i.bak 's/ENV=.*/ENV=development/' "$env_file" + sed -i.bak 's/DEBUG=.*/DEBUG=true/' "$env_file" + sed -i.bak 's/DATABASE_URL=.*/DATABASE_URL=mysql+aiomysql:\/\/root:Kaopeilian2025!@#@localhost:3306\/kaopeilian?charset=utf8mb4/' "$env_file" + sed -i.bak 's/MYSQL_HOST=.*/MYSQL_HOST=localhost/' "$env_file" + rm -f "$env_file.bak" + ;; + production) + sed -i.bak 's/ENV=.*/ENV=production/' "$env_file" + sed -i.bak 's/DEBUG=.*/DEBUG=false/' "$env_file" + sed -i.bak 's/DATABASE_URL=.*/DATABASE_URL=mysql+aiomysql:\/\/root:Kaopeilian2025!@#@mysql:3306\/kaopeilian?charset=utf8mb4/' "$env_file" + sed -i.bak 's/MYSQL_HOST=.*/MYSQL_HOST=mysql/' "$env_file" + rm -f "$env_file.bak" + ;; + esac + print_message $GREEN "✅ 后端环境配置已更新" + fi +} + +# 设置Docker环境 +setup_docker_env() { + print_message $BLUE "设置Docker环境..." + + case $ENV_TYPE in + development) + if [ ! -f "docker-compose.dev.yml" ]; then + print_message $RED "❌ 开发环境Docker配置不存在: docker-compose.dev.yml" + return 1 + fi + print_message $GREEN "✅ 开发环境Docker配置已就绪" + ;; + production) + if [ ! -f "docker-compose.yml" ]; then + print_message $RED "❌ 生产环境Docker配置不存在: docker-compose.yml" + return 1 + fi + print_message $GREEN "✅ 生产环境Docker配置已就绪" + ;; + esac +} + +# 启动环境 +start_environment() { + print_message $BLUE "启动$ENV_TYPE环境..." + + case $ENV_TYPE in + development) + if command -v docker-compose &> /dev/null; then + docker-compose -f docker-compose.dev.yml up -d + else + docker compose -f docker-compose.dev.yml up -d + fi + ;; + production) + if command -v docker-compose &> /dev/null; then + docker-compose up -d + else + docker compose up -d + fi + ;; + esac + + if [ $? -eq 0 ]; then + print_message $GREEN "✅ 环境启动成功" + + # 等待服务启动 + print_message $BLUE "等待服务启动..." + sleep 10 + + # 检查服务状态 + ./scripts/check_environment.sh + else + print_message $RED "❌ 环境启动失败" + return 1 + fi +} + +# 显示使用说明 +show_usage() { + echo "用法: $0 [环境类型] [强制设置]" + echo "" + echo "环境类型:" + echo " development - 开发环境 (默认)" + echo " production - 生产环境" + echo "" + echo "强制设置:" + echo " true - 强制重新创建配置文件" + echo " false - 保留已存在的配置文件 (默认)" + echo "" + echo "示例:" + echo " $0 # 设置开发环境" + echo " $0 development # 设置开发环境" + echo " $0 production # 设置生产环境" + echo " $0 development true # 强制重新设置开发环境" +} + +# 主函数 +main() { + if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then + show_usage + exit 0 + fi + + if [ "$ENV_TYPE" != "development" ] && [ "$ENV_TYPE" != "production" ]; then + print_message $RED "❌ 无效的环境类型: $ENV_TYPE" + show_usage + exit 1 + fi + + check_requirements + setup_frontend_env + setup_backend_env + setup_docker_env + + # 询问是否启动环境 + read -p "是否启动$ENV_TYPE环境? [y/N]: " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + start_environment + fi + + print_message $GREEN "=== 环境设置完成 ===" + print_message $BLUE "环境类型: $ENV_TYPE" + print_message $BLUE "前端地址: http://localhost:3001" + print_message $BLUE "后端地址: http://localhost:8000" + print_message $BLUE "API文档: http://localhost:8000/docs" +} + +main "$@" diff --git a/deploy/scripts/setup_git_strategy.sh b/deploy/scripts/setup_git_strategy.sh new file mode 100644 index 0000000..a25897d --- /dev/null +++ b/deploy/scripts/setup_git_strategy.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Git分支策略配置脚本 + +echo "=== 配置Git分支策略 ===" + +cd /root/aiedu + +# 1. 创建production分支(如果不存在) +if ! git branch | grep -q production; then + echo "创建production分支..." + git checkout -b production + git push origin production + echo "production分支已创建" +else + echo "production分支已存在" +fi + +# 2. 切换到production分支 +git checkout production + +# 3. 更新webhook脚本,监听production分支 +echo "更新webhook配置..." +sed -i 's/refs\/heads\/main/refs\/heads\/production/g' /root/aiedu/scripts/webhook_handler.py + +# 4. 重启webhook服务 +systemctl restart kaopeilian-webhook.service + +# 5. 创建.gitignore规则 +echo "更新.gitignore..." +cat >> /root/aiedu/.gitignore << 'EOF' + +# 生产环境配置文件(不提交) +kaopeilian-backend/.env.production +docker-compose.override.yml + +# 服务器运行时文件 +scripts/force_restart.sh +scripts/diagnose.sh +/var/log/kaopeilian_*.log +EOF + +# 6. 提交配置变更 +echo "提交配置变更到production分支..." +git add .gitignore scripts/webhook_handler.py +git commit -m "配置生产环境分支策略" +git push origin production + +echo "" +echo "=== Git分支策略配置完成 ===" +echo "" +echo "使用说明:" +echo "1. 开发者在main分支开发" +echo "2. 生产环境使用production分支" +echo "3. 发布流程:" +echo " git checkout production" +echo " git merge main" +echo " git push origin production" +echo "" +echo "4. 服务器自动更新监听production分支" diff --git a/deploy/scripts/start-dev.sh b/deploy/scripts/start-dev.sh new file mode 100755 index 0000000..a2a9504 --- /dev/null +++ b/deploy/scripts/start-dev.sh @@ -0,0 +1,208 @@ +#!/bin/bash + +# 考培练系统开发环境启动脚本 +# 使用方法: +# ./start-dev.sh # 启动基础服务 +# ./start-dev.sh --with-admin # 启动服务 + phpMyAdmin +# ./start-dev.sh --with-mail # 启动服务 + 邮件测试 +# ./start-dev.sh --full # 启动所有服务 + +set -e + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 日志函数 +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 检查Docker是否运行 +check_docker() { + if ! docker info >/dev/null 2>&1; then + log_error "Docker 未运行,请先启动 Docker" + exit 1 + fi +} + +# 清理旧容器 +cleanup() { + log_info "清理旧容器..." + docker-compose -f docker-compose.dev.yml down --remove-orphans 2>/dev/null || true + + # 清理孤立的容器 + docker ps -a --filter "name=kaopeilian-" --format "{{.ID}}" | xargs -r docker rm -f 2>/dev/null || true +} + +# 构建镜像 +build_images() { + log_info "构建开发环境镜像..." + docker-compose -f docker-compose.dev.yml build --no-cache +} + +# 启动服务 +start_services() { + local profiles="" + + # 解析命令行参数 + case "${1:-}" in + --with-admin) + profiles="--profile admin" + log_info "启动完全Docker化服务(包含 phpMyAdmin)..." + ;; + --with-mail) + profiles="--profile mail" + log_info "启动完全Docker化服务(包含邮件测试)..." + ;; + --full) + profiles="--profile admin --profile mail" + log_info "启动所有Docker化服务..." + ;; + *) + log_info "启动完全Docker化基础服务(前后端+数据库)..." + ;; + esac + + # 启动所有服务(包括前后端) + docker-compose -f docker-compose.dev.yml up -d frontend-dev backend-dev mysql-dev redis-dev $profiles +} + +# 等待服务就绪 +wait_for_services() { + log_info "等待服务启动..." + + # 等待数据库 + log_info "等待 MySQL 数据库启动..." + timeout=60 + while ! docker exec kaopeilian-mysql-dev mysqladmin ping -h"localhost" --silent 2>/dev/null; do + timeout=$((timeout - 1)) + if [ $timeout -eq 0 ]; then + log_error "MySQL 启动超时" + return 1 + fi + sleep 1 + done + log_success "MySQL 数据库已就绪" + + # 等待 Redis + log_info "等待 Redis 缓存启动..." + timeout=30 + while ! docker exec kaopeilian-redis-dev redis-cli ping 2>/dev/null | grep -q PONG; do + timeout=$((timeout - 1)) + if [ $timeout -eq 0 ]; then + log_error "Redis 启动超时" + return 1 + fi + sleep 1 + done + log_success "Redis 缓存已就绪" + + # 等待后端服务 + log_info "等待后端服务启动..." + timeout=60 + while ! curl -s http://localhost:8000/health >/dev/null 2>&1; do + timeout=$((timeout - 1)) + if [ $timeout -eq 0 ]; then + log_error "后端服务启动超时" + return 1 + fi + sleep 1 + done + log_success "后端服务已就绪" + + # 等待前端服务 + log_info "等待前端服务启动..." + timeout=60 + while ! curl -s http://localhost:3001/ >/dev/null 2>&1; do + timeout=$((timeout - 1)) + if [ $timeout -eq 0 ]; then + log_error "前端服务启动超时" + return 1 + fi + sleep 1 + done + log_success "前端服务已就绪" +} + +# 显示服务状态 +show_status() { + echo "" + log_success "🎉 考培练系统开发环境启动成功!" + echo "" + echo "📋 服务访问地址:" + echo " 🌐 前端开发服务器: http://localhost:3001 (Docker容器)" + echo " 🔧 后端API服务: http://localhost:8000 (Docker容器)" + echo " 📚 API文档: http://localhost:8000/docs" + echo " 🗄️ MySQL数据库: localhost:3306 (Docker容器)" + echo " 🔄 Redis缓存: localhost:6379 (Docker容器)" + + if docker ps --filter "name=kaopeilian-phpmyadmin-dev" --format "{{.Names}}" | grep -q phpmyadmin; then + echo " 🛠️ phpMyAdmin: http://localhost:8080" + fi + + if docker ps --filter "name=kaopeilian-mailhog-dev" --format "{{.Names}}" | grep -q mailhog; then + echo " 📧 邮件测试界面: http://localhost:8025" + fi + + echo "" + echo "🔧 常用命令:" + echo " 查看日志: docker-compose -f docker-compose.dev.yml logs -f [service_name]" + echo " 停止服务: docker-compose -f docker-compose.dev.yml down" + echo " 重启服务: docker-compose -f docker-compose.dev.yml restart [service_name]" + echo "" + echo "💡 开发提示:" + echo " - 🔥 代码修改会自动重载(Docker热重载已启用)" + echo " - 🎨 前端: 修改 kaopeilian-frontend/src/ 目录下的文件" + echo " - ⚙️ 后端: 修改 kaopeilian-backend/app/ 目录下的文件" + echo " - 🐳 所有服务均运行在Docker容器中,环境完全一致" + echo "" +} + +# 主函数 +main() { + echo "🚀 考培练系统开发环境启动器" + echo "================================" + + # 检查 Docker + check_docker + + # 清理旧环境 + cleanup + + # 构建镜像 + build_images + + # 启动服务 + start_services "$@" + + # 等待服务就绪 + if wait_for_services; then + show_status + exit 0 + else + log_error "服务启动失败,请检查日志" + docker-compose -f docker-compose.dev.yml logs --tail=50 + exit 1 + fi +} + +# 如果直接运行此脚本 +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/deploy/scripts/start-kpl.sh b/deploy/scripts/start-kpl.sh new file mode 100755 index 0000000..e49d9aa --- /dev/null +++ b/deploy/scripts/start-kpl.sh @@ -0,0 +1,186 @@ +#!/bin/bash + +# 瑞小美团队开发环境启动脚本 +# 域名:kpl.ireborn.com.cn +# 使用方法: +# ./start-kpl.sh # 启动基础服务 +# ./start-kpl.sh --with-admin # 启动服务 + phpMyAdmin + +set -e + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 日志函数 +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 检查Docker是否运行 +check_docker() { + if ! docker info >/dev/null 2>&1; then + log_error "Docker 未运行,请先启动 Docker" + exit 1 + fi +} + +# 构建镜像 +build_images() { + log_info "构建KPL开发环境镜像..." + docker compose -f docker-compose.kpl.yml build --no-cache +} + +# 启动服务 +start_services() { + local profiles="" + + # 解析命令行参数 + case "${1:-}" in + --with-admin) + profiles="--profile admin" + log_info "启动KPL服务(包含 phpMyAdmin)..." + ;; + *) + log_info "启动KPL基础服务(前后端+数据库)..." + ;; + esac + + # 启动所有服务 + docker compose -f docker-compose.kpl.yml up -d $profiles +} + +# 等待服务就绪 +wait_for_services() { + log_info "等待服务启动..." + + # 等待数据库 + log_info "等待 MySQL 数据库启动..." + timeout=60 + while ! docker exec kpl-mysql-dev mysqladmin ping -h"localhost" --silent 2>/dev/null; do + timeout=$((timeout - 1)) + if [ $timeout -eq 0 ]; then + log_error "MySQL 启动超时" + return 1 + fi + sleep 1 + done + log_success "MySQL 数据库已就绪" + + # 等待 Redis + log_info "等待 Redis 缓存启动..." + timeout=30 + while ! docker exec kpl-redis-dev redis-cli ping 2>/dev/null | grep -q PONG; do + timeout=$((timeout - 1)) + if [ $timeout -eq 0 ]; then + log_error "Redis 启动超时" + return 1 + fi + sleep 1 + done + log_success "Redis 缓存已就绪" + + # 等待后端服务 + log_info "等待后端服务启动..." + timeout=60 + while ! curl -s http://localhost:8001/health >/dev/null 2>&1; do + timeout=$((timeout - 1)) + if [ $timeout -eq 0 ]; then + log_error "后端服务启动超时" + return 1 + fi + sleep 1 + done + log_success "后端服务已就绪" + + # 等待前端服务 + log_info "等待前端服务启动..." + timeout=60 + while ! curl -s http://localhost:3002/ >/dev/null 2>&1; do + timeout=$((timeout - 1)) + if [ $timeout -eq 0 ]; then + log_error "前端服务启动超时" + return 1 + fi + sleep 1 + done + log_success "前端服务已就绪" +} + +# 显示服务状态 +show_status() { + echo "" + log_success "🎉 瑞小美团队开发环境启动成功!" + echo "" + echo "📋 服务访问地址:" + echo " 🌐 正式域名访问: https://kpl.ireborn.com.cn" + echo " 🖥️ 本地前端服务: http://localhost:3002" + echo " 🔧 本地后端API: http://localhost:8001" + echo " 📚 API文档: http://localhost:8001/docs" + echo " 🗄️ MySQL数据库: localhost:3308" + echo " 🔄 Redis缓存: localhost:6380" + + if docker ps --filter "name=kpl-phpmyadmin-dev" --format "{{.Names}}" | grep -q phpmyadmin; then + echo " 🛠️ phpMyAdmin: http://localhost:8081" + fi + + echo "" + echo "🔧 常用命令:" + echo " 查看日志: docker compose -f docker-compose.kpl.yml logs -f [service_name]" + echo " 停止服务: ./stop-kpl.sh 或 docker compose -f docker-compose.kpl.yml down" + echo " 重启服务: docker compose -f docker-compose.kpl.yml restart [service_name]" + echo "" + echo "💡 开发提示:" + echo " - 🔥 代码修改会自动重载(热重载已启用)" + echo " - 🎨 前端: 修改 kaopeilian-frontend/src/ 目录下的文件" + echo " - ⚙️ 后端: 修改 kaopeilian-backend/app/ 目录下的文件" + echo " - 🔒 已配置HTTPS访问,使用域名访问更安全" + echo "" + echo "📌 注意:" + echo " - 此环境与演示系统(aiedu.ireborn.com.cn)完全隔离" + echo " - 拥有独立的数据库和Redis实例" + echo "" +} + +# 主函数 +main() { + echo "🚀 瑞小美团队开发环境启动器" + echo "================================" + + # 检查 Docker + check_docker + + # 启动服务(不重新构建) + start_services "$@" + + # 等待服务就绪 + if wait_for_services; then + show_status + exit 0 + else + log_error "服务启动失败,请检查日志" + docker compose -f docker-compose.kpl.yml logs --tail=50 + exit 1 + fi +} + +# 如果直接运行此脚本 +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi + diff --git a/deploy/scripts/start.sh b/deploy/scripts/start.sh new file mode 100755 index 0000000..c8d96e1 --- /dev/null +++ b/deploy/scripts/start.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +# Coze智能体聊天系统启动脚本 + +echo "🚀 启动Coze智能体聊天系统..." + +# 检查是否安装了必要工具 +check_command() { + if ! command -v $1 &> /dev/null; then + echo "❌ $1 未安装,请先安装 $1" + exit 1 + fi +} + +echo "🔍 检查环境..." +check_command python3 +check_command node +check_command npm + +# 关闭可能影响 coze.cn 访问的系统代理,并设置直连白名单 +echo "🛡️ 配置网络直连(禁用代理,放行 *.coze.cn)..." +unset http_proxy https_proxy all_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY || true +export NO_PROXY=localhost,127.0.0.1,api.coze.cn,.coze.cn + +# 启动后端 +echo "🐍 启动Python后端..." +cd coze-chat-backend + +# 检查虚拟环境 +if [ ! -d "venv" ]; then + echo "📦 创建Python虚拟环境..." + python3 -m venv venv +fi + +# 激活虚拟环境 +source venv/bin/activate + +# 安装依赖 +echo "📦 安装Python依赖..." +pip install -r requirements.txt + +# 检查配置文件 +if [ ! -f "local_config.py" ]; then + echo "⚠️ local_config.py文件不存在,请先配置API认证" + echo "📋 可以参考 local_config.py.example 创建配置文件" + exit 1 +fi + +# 后台启动Python服务 +echo "🚀 启动后端服务..." +python main.py & +BACKEND_PID=$! + +cd .. + +# 启动前端 +echo "⚛️ 启动React前端..." +cd coze-chat-frontend + +# 安装依赖 +echo "📦 安装前端依赖..." +npm install + +# 启动前端开发服务器 +echo "🚀 启动前端服务..." +npm run dev & +FRONTEND_PID=$! + +# 显示启动信息 +echo "" +echo "✅ 系统启动完成!" +echo "🔗 前端地址: http://localhost:3001" +echo "🔗 后端地址: http://localhost:8010" +echo "📖 API文档: http://localhost:8010/docs" +echo "" +echo "按 Ctrl+C 停止所有服务" + +# 等待用户中断 +wait + +# 清理进程 +echo "🧹 清理进程..." +kill $BACKEND_PID 2>/dev/null +kill $FRONTEND_PID 2>/dev/null + +echo "👋 系统已停止" diff --git a/deploy/scripts/stop-dev.sh b/deploy/scripts/stop-dev.sh new file mode 100755 index 0000000..f245990 --- /dev/null +++ b/deploy/scripts/stop-dev.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# 考培练系统开发环境停止脚本 + +set -e + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +main() { + echo "🛑 停止考培练系统开发环境" + echo "==========================" + + log_info "停止所有开发服务..." + docker-compose -f docker-compose.dev.yml down --remove-orphans + + # 可选:清理数据卷(谨慎使用) + if [[ "$1" == "--clean-data" ]]; then + log_warning "清理开发数据卷..." + docker volume rm kaopeilian-mysql-dev-data kaopeilian-redis-dev-data 2>/dev/null || true + log_success "数据卷已清理" + fi + + # 可选:清理镜像 + if [[ "$1" == "--clean-all" ]]; then + log_warning "清理开发镜像..." + docker images --filter "reference=*kaopeilian*dev*" -q | xargs -r docker rmi -f + log_success "开发镜像已清理" + fi + + log_success "✅ 开发环境已停止" +} + +main "$@" diff --git a/deploy/scripts/stop-kpl.sh b/deploy/scripts/stop-kpl.sh new file mode 100755 index 0000000..edb04e1 --- /dev/null +++ b/deploy/scripts/stop-kpl.sh @@ -0,0 +1,74 @@ +#!/bin/bash + +# 瑞小美团队开发环境停止脚本 +# 使用方法: +# ./stop-kpl.sh # 停止所有KPL服务 +# ./stop-kpl.sh --keep-data # 停止服务但保留数据卷 + +set -e + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 日志函数 +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 停止服务 +stop_services() { + log_info "停止KPL开发环境服务..." + + if [ "$1" = "--keep-data" ]; then + docker compose -f docker-compose.kpl.yml down + log_info "服务已停止,数据卷已保留" + else + docker compose -f docker-compose.kpl.yml down -v + log_warning "服务已停止,数据卷已删除" + fi +} + +# 显示状态 +show_status() { + echo "" + log_success "✅ KPL开发环境已停止" + echo "" + echo "💡 提示:" + echo " - 重新启动: ./start-kpl.sh" + echo " - 查看演示系统: docker ps | grep kaopeilian" + echo "" +} + +# 主函数 +main() { + echo "🛑 瑞小美团队开发环境停止器" + echo "================================" + + # 停止服务 + stop_services "$@" + + # 显示状态 + show_status +} + +# 如果直接运行此脚本 +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi + diff --git a/deploy/scripts/test_course_chat.sh b/deploy/scripts/test_course_chat.sh new file mode 100755 index 0000000..3eb307f --- /dev/null +++ b/deploy/scripts/test_course_chat.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# 测试与课程对话功能 - Dify 集成 + +echo "============================================================" +echo "🧪 与课程对话功能测试 - Dify 集成" +echo "============================================================" + +API_BASE="http://localhost:8000" + +# 1. 登录获取 token +echo "" +echo "🔑 正在登录..." +LOGIN_RESPONSE=$(curl -s -X POST "${API_BASE}/api/v1/auth/login" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=test_user&password=123456") + +TOKEN=$(echo $LOGIN_RESPONSE | python3 -c "import sys, json; print(json.load(sys.stdin).get('access_token', ''))") + +if [ -z "$TOKEN" ]; then + echo "❌ 登录失败" + echo "响应: $LOGIN_RESPONSE" + exit 1 +fi + +echo "✅ 登录成功" +echo "Token: ${TOKEN:0:20}..." + +# 2. 测试首次对话 +echo "" +echo "============================================================" +echo "测试场景 1: 首次对话(创建新会话)" +echo "============================================================" +echo "" +echo "💬 测试与课程 1 对话" +echo "问题: 这门课程讲什么?" +echo "" +echo "📡 SSE 事件流:" +echo "------------------------------------------------------------" + +curl -N -X POST "${API_BASE}/api/v1/course/chat" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "course_id": 1, + "query": "这门课程讲什么?" + }' + +echo "" +echo "------------------------------------------------------------" +echo "" +echo "✅ 测试完成!" +echo "" +echo "如需测试续接对话,请复制上面输出的 conversation_id,然后运行:" +echo " curl -N -X POST '${API_BASE}/api/v1/course/chat' \\" +echo " -H 'Authorization: Bearer ${TOKEN}' \\" +echo " -H 'Content-Type: application/json' \\" +echo " -d '{\"course_id\": 1, \"query\": \"能详细说说吗?\", \"conversation_id\": \"你的conversation_id\"}'" + diff --git a/deploy/scripts/test_statistics_apis.sh b/deploy/scripts/test_statistics_apis.sh new file mode 100755 index 0000000..e4be069 --- /dev/null +++ b/deploy/scripts/test_statistics_apis.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# 测试所有统计分析API + +API_BASE="http://localhost:8000/api/v1/statistics" +PERIOD="month" + +echo "==========================================" +echo "测试统计分析API" +echo "==========================================" +echo "" + +echo "1️⃣ 测试关键指标..." +curl -s "${API_BASE}/key-metrics?period=${PERIOD}" | python3 -m json.tool | head -20 + +echo "" +echo "2️⃣ 测试成绩分布..." +curl -s "${API_BASE}/score-distribution?period=${PERIOD}" | python3 -m json.tool + +echo "" +echo "3️⃣ 测试难度分析..." +curl -s "${API_BASE}/difficulty-analysis?period=${PERIOD}" | python3 -m json.tool + +echo "" +echo "4️⃣ 测试知识点掌握度..." +curl -s "${API_BASE}/knowledge-mastery" | python3 -m json.tool | head -30 + +echo "" +echo "5️⃣ 测试学习时长..." +curl -s "${API_BASE}/study-time?period=${PERIOD}" | python3 -m json.tool | head -30 + +echo "" +echo "6️⃣ 测试详细数据..." +curl -s "${API_BASE}/detail?period=${PERIOD}" | python3 -m json.tool | head -40 + +echo "" +echo "==========================================" +echo "✅ 测试完成" +echo "==========================================" + diff --git a/deploy/scripts/validate_config.py b/deploy/scripts/validate_config.py new file mode 100644 index 0000000..b4891a8 --- /dev/null +++ b/deploy/scripts/validate_config.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +""" +环境配置验证脚本 +验证开发和生产环境的配置是否正确 +""" + +import os +import sys +import json +from pathlib import Path +from urllib.parse import urlparse + +def validate_frontend_config(env_file): + """验证前端环境配置""" + print(f"\n🌐 验证前端配置: {env_file}") + + if not os.path.exists(env_file): + print(f"❌ 配置文件不存在: {env_file}") + return False + + config = {} + with open(env_file, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + config[key] = value + + # 必需配置检查 + required_configs = [ + 'VITE_APP_TITLE', + 'VITE_APP_ENV', + 'VITE_API_BASE_URL', + 'VITE_WS_BASE_URL' + ] + + for key in required_configs: + if key not in config: + print(f"❌ 缺少必需配置: {key}") + return False + else: + print(f"✅ {key} = {config[key]}") + + # 环境特定验证 + env_type = config.get('VITE_APP_ENV', '') + if env_type == 'development': + # 开发环境验证 + if 'localhost' not in config.get('VITE_API_BASE_URL', ''): + print("⚠️ 开发环境建议使用localhost") + if config.get('VITE_ENABLE_DEVTOOLS') != 'true': + print("⚠️ 开发环境建议启用开发工具") + elif env_type == 'production': + # 生产环境验证 + api_url = config.get('VITE_API_BASE_URL', '') + if 'localhost' in api_url or '127.0.0.1' in api_url: + print("❌ 生产环境不应使用localhost") + return False + if config.get('VITE_ENABLE_DEVTOOLS') == 'true': + print("⚠️ 生产环境建议禁用开发工具") + + print("✅ 前端配置验证通过") + return True + +def validate_backend_config(env_file): + """验证后端环境配置""" + print(f"\n🚀 验证后端配置: {env_file}") + + if not os.path.exists(env_file): + print(f"❌ 配置文件不存在: {env_file}") + return False + + config = {} + with open(env_file, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + config[key] = value + + # 必需配置检查 + required_configs = [ + 'ENV', + 'SECRET_KEY', + 'DATABASE_URL', + 'REDIS_URL' + ] + + for key in required_configs: + if key not in config: + print(f"❌ 缺少必需配置: {key}") + return False + else: + # 敏感信息脱敏显示 + if 'PASSWORD' in key or 'SECRET' in key or 'TOKEN' in key or 'KEY' in key: + value = config[key] + if len(value) > 8: + masked_value = value[:4] + '*' * (len(value) - 8) + value[-4:] + else: + masked_value = '*' * len(value) + print(f"✅ {key} = {masked_value}") + else: + print(f"✅ {key} = {config[key]}") + + # 安全性检查 + secret_key = config.get('SECRET_KEY', '') + if secret_key in ['your-secret-key', 'your-secret-key-here', 'secret']: + print("❌ 请设置安全的密钥,不要使用默认值") + return False + + if len(secret_key) < 32: + print("⚠️ 建议使用至少32位的密钥") + + # 数据库URL检查 + db_url = config.get('DATABASE_URL', '') + if db_url: + try: + parsed = urlparse(db_url) + print(f"📊 数据库信息: {parsed.scheme}://{parsed.hostname}:{parsed.port}/{parsed.path.lstrip('/')}") + except Exception as e: + print(f"⚠️ 数据库URL格式可能有误: {e}") + + # 环境特定验证 + env_type = config.get('ENV', '') + if env_type == 'development': + if config.get('DEBUG') != 'true': + print("⚠️ 开发环境建议启用调试模式") + elif env_type == 'production': + if config.get('DEBUG') == 'true': + print("❌ 生产环境不应启用调试模式") + return False + + # 生产环境数据库检查 + if 'localhost' in db_url or '127.0.0.1' in db_url: + print("⚠️ 生产环境建议使用容器内数据库") + + print("✅ 后端配置验证通过") + return True + +def validate_docker_config(): + """验证Docker配置""" + print(f"\n🐳 验证Docker配置") + + compose_files = [ + 'docker-compose.dev.yml', + 'docker-compose.yml' + ] + + for compose_file in compose_files: + if os.path.exists(compose_file): + print(f"✅ {compose_file} 存在") + else: + print(f"❌ {compose_file} 不存在") + + return True + +def main(): + """主函数""" + print("=== 考培练系统环境配置验证 ===") + + # 检查工作目录 + if not os.path.exists('kaopeilian-frontend') or not os.path.exists('kaopeilian-backend'): + print("❌ 请在项目根目录运行此脚本") + sys.exit(1) + + all_valid = True + + # 验证前端配置 + frontend_configs = [ + 'kaopeilian-frontend/.env.development', + 'kaopeilian-frontend/.env.production' + ] + + for config_file in frontend_configs: + if os.path.exists(config_file): + if not validate_frontend_config(config_file): + all_valid = False + else: + print(f"⚠️ 前端配置文件不存在: {config_file}") + + # 验证后端配置 + backend_configs = [ + 'kaopeilian-backend/.env.development', + 'kaopeilian-backend/.env.production' + ] + + for config_file in backend_configs: + if os.path.exists(config_file): + if not validate_backend_config(config_file): + all_valid = False + else: + print(f"⚠️ 后端配置文件不存在: {config_file}") + + # 验证Docker配置 + validate_docker_config() + + print(f"\n=== 验证结果 ===") + if all_valid: + print("✅ 所有配置验证通过") + sys.exit(0) + else: + print("❌ 配置验证失败,请检查上述错误") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/deploy/scripts/webhook_handler.py b/deploy/scripts/webhook_handler.py new file mode 100755 index 0000000..106ff9f --- /dev/null +++ b/deploy/scripts/webhook_handler.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +""" +GitHub Webhook处理器 +监听GitHub推送事件,自动触发部署 +""" + +import os +import subprocess +import json +import hmac +import hashlib +import logging +from flask import Flask, request, jsonify +import threading +import time + +app = Flask(__name__) + +# 配置 +WEBHOOK_SECRET = "kaopeilian-webhook-secret-2025" # GitHub中配置的密钥 +PROJECT_DIR = "/root/aiedu" +UPDATE_SCRIPT = "/root/aiedu/scripts/auto_update.sh" + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('/var/log/kaopeilian_webhook.log'), + logging.StreamHandler() + ] +) + +def verify_signature(payload_body, signature_header): + """验证GitHub Webhook签名""" + if not signature_header: + return False + + hash_object = hmac.new( + WEBHOOK_SECRET.encode('utf-8'), + payload_body, + hashlib.sha256 + ) + expected_signature = "sha256=" + hash_object.hexdigest() + + return hmac.compare_digest(expected_signature, signature_header) + +def run_update_async(): + """异步执行更新脚本""" + try: + # 等待5秒再执行,避免GitHub推送过程中的竞争条件 + time.sleep(5) + + result = subprocess.run( + [UPDATE_SCRIPT], + cwd=PROJECT_DIR, + capture_output=True, + text=True, + timeout=600 # 10分钟超时 + ) + + if result.returncode == 0: + logging.info(f"Update completed successfully: {result.stdout}") + else: + logging.error(f"Update failed: {result.stderr}") + + except subprocess.TimeoutExpired: + logging.error("Update script timed out") + except Exception as e: + logging.error(f"Error running update script: {e}") + +@app.route('/webhook', methods=['POST']) +def github_webhook(): + """处理GitHub Webhook请求""" + + # 验证签名 + signature = request.headers.get('X-Hub-Signature-256') + if not verify_signature(request.data, signature): + logging.warning("Invalid webhook signature") + return jsonify({"error": "Invalid signature"}), 403 + + # 解析请求 + try: + payload = request.get_json() + except Exception as e: + logging.error(f"Failed to parse JSON: {e}") + return jsonify({"error": "Invalid JSON"}), 400 + + # 检查事件类型 + event_type = request.headers.get('X-GitHub-Event') + if event_type != 'push': + logging.info(f"Ignoring event type: {event_type}") + return jsonify({"message": "Event ignored"}), 200 + + # 检查分支 + ref = payload.get('ref', '') + if ref != 'refs/heads/production': + logging.info(f"Ignoring push to branch: {ref}") + return jsonify({"message": "Branch ignored"}), 200 + + # 获取提交信息 + commit_info = { + 'id': payload.get('after', 'unknown'), + 'message': payload.get('head_commit', {}).get('message', 'No message'), + 'author': payload.get('head_commit', {}).get('author', {}).get('name', 'Unknown'), + 'timestamp': payload.get('head_commit', {}).get('timestamp', 'Unknown') + } + + logging.info(f"Received push event: {commit_info}") + + # 异步触发更新 + update_thread = threading.Thread(target=run_update_async) + update_thread.daemon = True + update_thread.start() + + return jsonify({ + "message": "Update triggered successfully", + "commit": commit_info + }), 200 + +@app.route('/health', methods=['GET']) +def health_check(): + """健康检查端点""" + return jsonify({ + "status": "healthy", + "service": "kaopeilian-webhook", + "timestamp": time.time() + }), 200 + +@app.route('/status', methods=['GET']) +def status(): + """状态检查端点""" + try: + # 检查项目目录 + project_exists = os.path.exists(PROJECT_DIR) + + # 检查更新脚本 + script_exists = os.path.exists(UPDATE_SCRIPT) + script_executable = os.access(UPDATE_SCRIPT, os.X_OK) if script_exists else False + + # 检查Docker服务 + docker_result = subprocess.run(['docker', 'compose', 'ps'], + cwd=PROJECT_DIR, capture_output=True) + docker_running = docker_result.returncode == 0 + + return jsonify({ + "status": "ok", + "checks": { + "project_directory": project_exists, + "update_script_exists": script_exists, + "update_script_executable": script_executable, + "docker_compose_running": docker_running + } + }), 200 + + except Exception as e: + return jsonify({ + "status": "error", + "error": str(e) + }), 500 + +if __name__ == '__main__': + logging.info("Starting GitHub Webhook handler...") + app.run(host='0.0.0.0', port=9000, debug=False) diff --git a/deploy/scripts/启动资料预览功能.sh b/deploy/scripts/启动资料预览功能.sh new file mode 100755 index 0000000..c6f3ff9 --- /dev/null +++ b/deploy/scripts/启动资料预览功能.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +# 课程资料预览功能启动脚本 +# 用途:重建Docker镜像并启动服务 + +set -e + +echo "=========================================" +echo "课程资料预览功能启动脚本" +echo "=========================================" +echo "" + +# 切换到后端目录 +cd "$(dirname "$0")/kaopeilian-backend" + +echo "步骤 1/4: 停止现有服务..." +docker-compose -f docker-compose.dev.yml down + +echo "" +echo "步骤 2/4: 重建后端镜像(安装LibreOffice)..." +echo "注意:首次构建可能需要5-10分钟,请耐心等待..." +docker-compose -f docker-compose.dev.yml build backend + +echo "" +echo "步骤 3/4: 启动所有服务..." +docker-compose -f docker-compose.dev.yml up -d + +echo "" +echo "步骤 4/4: 等待服务启动(30秒)..." +sleep 30 + +echo "" +echo "=========================================" +echo "服务启动完成!" +echo "=========================================" +echo "" +echo "📋 服务信息:" +echo " - 后端API: http://localhost:8000" +echo " - 前端页面: http://localhost:3001" +echo " - 课程详情: http://localhost:3001/trainee/course-detail?id=1" +echo "" +echo "🔍 检查LibreOffice安装状态:" +echo " curl http://localhost:8000/api/v1/preview/check-converter" +echo "" +echo "📝 测试建议:" +echo " 1. 先在课程管理中上传各种格式的测试文件" +echo " 2. 访问课程详情页查看资料列表" +echo " 3. 点击不同类型的文件测试预览功能" +echo " 4. 特别测试Office文档的转换预览" +echo "" +echo "📖 详细测试指南:" +echo " 查看文件: kaopeilian-frontend/课程资料预览功能测试指南.md" +echo "" +echo "🔧 查看服务日志:" +echo " docker-compose -f docker-compose.dev.yml logs -f backend" +echo "" + diff --git a/deploy/scripts/测试资料预览功能.sh b/deploy/scripts/测试资料预览功能.sh new file mode 100755 index 0000000..ee9d177 --- /dev/null +++ b/deploy/scripts/测试资料预览功能.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +# 课程资料预览功能测试脚本 +# 用途:快速测试API接口是否正常 + +set -e + +BASE_URL="http://localhost:8000" +COURSE_ID=1 + +echo "=========================================" +echo "课程资料预览功能测试" +echo "=========================================" +echo "" + +# 测试1: 检查后端服务是否启动 +echo "测试 1/4: 检查后端服务..." +if curl -s "${BASE_URL}/health" > /dev/null 2>&1; then + echo "✅ 后端服务正常" +else + echo "❌ 后端服务未启动,请先运行启动脚本" + exit 1 +fi + +echo "" + +# 测试2: 检查LibreOffice安装 +echo "测试 2/4: 检查LibreOffice安装状态..." +CONVERTER_STATUS=$(curl -s "${BASE_URL}/api/v1/preview/check-converter" || echo "{}") +echo "$CONVERTER_STATUS" | python3 -m json.tool 2>/dev/null || echo "$CONVERTER_STATUS" + +if echo "$CONVERTER_STATUS" | grep -q '"libreoffice_installed": true'; then + echo "✅ LibreOffice安装成功" +else + echo "⚠️ LibreOffice未安装或检测失败" + echo " 请检查Docker镜像是否正确构建" +fi + +echo "" + +# 测试3: 获取课程资料列表 +echo "测试 3/4: 获取课程资料列表..." +MATERIALS=$(curl -s "${BASE_URL}/api/v1/courses/${COURSE_ID}/materials" || echo "{}") +echo "$MATERIALS" | python3 -m json.tool 2>/dev/null || echo "$MATERIALS" + +MATERIAL_COUNT=$(echo "$MATERIALS" | grep -o '"id"' | wc -l) +if [ "$MATERIAL_COUNT" -gt 0 ]; then + echo "✅ 找到 ${MATERIAL_COUNT} 个资料" +else + echo "⚠️ 该课程暂无资料" + echo " 请先在课程管理中上传测试文件" +fi + +echo "" + +# 测试4: 测试预览接口(如果有资料) +if [ "$MATERIAL_COUNT" -gt 0 ]; then + echo "测试 4/4: 测试资料预览接口..." + + # 提取第一个资料的ID + MATERIAL_ID=$(echo "$MATERIALS" | grep -o '"id": *[0-9]*' | head -1 | grep -o '[0-9]*') + + if [ -n "$MATERIAL_ID" ]; then + echo " 测试资料ID: ${MATERIAL_ID}" + PREVIEW_INFO=$(curl -s "${BASE_URL}/api/v1/preview/material/${MATERIAL_ID}" || echo "{}") + echo "$PREVIEW_INFO" | python3 -m json.tool 2>/dev/null || echo "$PREVIEW_INFO" + + if echo "$PREVIEW_INFO" | grep -q '"preview_type"'; then + echo "✅ 预览接口正常" + else + echo "❌ 预览接口异常" + fi + fi +else + echo "测试 4/4: 跳过(无资料可测试)" +fi + +echo "" +echo "=========================================" +echo "测试完成!" +echo "=========================================" +echo "" +echo "📝 下一步:" +echo " 1. 如果LibreOffice未安装,请重新构建Docker镜像" +echo " 2. 如果无资料,请访问管理后台上传测试文件" +echo " 3. 在浏览器中访问课程详情页进行完整测试" +echo " http://localhost:3001/trainee/course-detail?id=${COURSE_ID}" +echo "" +echo "📖 详细测试指南:" +echo " kaopeilian-frontend/课程资料预览功能测试指南.md" +echo "" + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..407fe91 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,68 @@ +# 项目文档 + +> 012-考培练系统-2601 文档目录 + +## 项目概述 + +考培练系统是一个革命性的员工能力提升平台,专为轻医美连锁品牌瑞小美打造。通过集成 Coze 和 Dify 双 AI 平台,实现智能化的培训、考核和陪练功能。 + +## 文档结构 + +``` +docs/ +├── README.md # 本文件 +├── SETUP.md # 安装配置指南 +├── 同步清单.md # AI同步信息 +├── 项目状态快照.md # 项目状态 +├── 规划/ # 系统规划文档 +│ ├── 系统架构.md +│ ├── 部署架构-统一版.md +│ ├── 考陪练系统API对接规范.md +│ ├── 瑞小美AI接入规范.md +│ └── ... +├── api/ # API文档 +├── database/ # 数据库文档 +├── 对话框摘要/ # AI对话记录 +├── 交接文档/ # 交接记录 +└── PRD审视记录/ # PRD变更记录 +``` + +## 快速导航 + +### 入门文档 +- [安装配置指南](./SETUP.md) - 环境搭建 +- [同步清单](./同步清单.md) - AI接手时首先阅读 +- [项目状态快照](./项目状态快照.md) - 当前进度 + +### 架构设计 +- [系统架构](./规划/系统架构.md) - 整体架构设计 +- [部署架构](./规划/部署架构-统一版.md) - 部署方案 + +### API规范 +- [API对接规范](./规划/考陪练系统API对接规范.md) - 接口设计 +- [AI接入规范](./规划/瑞小美AI接入规范.md) - Coze/Dify集成 + +### 开发指南 +- [后端开发](../backend/README.md) - FastAPI 开发指南 +- [前端开发](../frontend/README.md) - Vue3 开发指南 + +## 技术栈 + +| 层级 | 技术 | +|------|------| +| 前端 | Vue3 + TypeScript + Element Plus | +| 后端 | FastAPI + SQLAlchemy | +| 数据库 | MySQL 8.0 + Redis | +| AI | Dify + Coze | +| 部署 | Docker | + +## 相关资源 + +- API文档: http://localhost:8000/docs +- 后端代码: `../backend/` +- 前端代码: `../frontend/` +- 部署配置: `../deploy/` + +--- + +> 最后更新:2026-01-24 diff --git a/docs/SETUP.md b/docs/SETUP.md new file mode 100644 index 0000000..d18bb04 --- /dev/null +++ b/docs/SETUP.md @@ -0,0 +1,116 @@ +# 快速设置指南 + +## 1. 配置Coze API认证 + +### 步骤1: 创建本地配置文件 +复制示例配置文件: + +```bash +cd coze-chat-backend +cp local_config.py.example local_config.py +``` + +### 步骤2: 配置API Token +编辑 `local_config.py` 文件,配置您的PAT Token: + +```python +# Coze API 配置 +COZE_API_BASE = "https://api.coze.cn" +COZE_WORKSPACE_ID = "7474971491470688296" +COZE_API_TOKEN = "your_pat_token_here" # 替换为您的PAT Token +``` + +## 2. 启动系统 + +### 方式1: 使用启动脚本 (推荐) +```bash +./start.sh +``` + +### 方式2: 手动启动 + +**启动后端:** +```bash +cd coze-chat-backend +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +python main.py +``` + +> 网络与代理(重要):若公司网络有 HTTP/HTTPS 代理,可能导致访问 `https://api.coze.cn` 失败(如 `500 Internal Privoxy Error`)。建议在启动前执行: + +```bash +unset http_proxy https_proxy all_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY +export NO_PROXY=localhost,127.0.0.1,api.coze.cn,.coze.cn +``` + +`./start.sh` 已默认设置上述环境,手动启动时请自行执行。 + +**启动前端:** +```bash +cd coze-chat-frontend +npm install +npm run dev +``` + +## 3. 访问系统 + +- **前端页面**: http://localhost:3006 +- **后端API**: http://localhost:8000 +- **API文档**: http://localhost:8000/docs + +## 4. 功能说明 + +### 首页 - 智能体列表 +- 自动加载工作空间 `7474971491470688296` 内的所有智能体 +- 点击任意智能体卡片进入聊天界面 +- 显示智能体名称、描述和头像 + +### 聊天页面 - 对话界面 +- 实时流式聊天对话 +- 消息气泡展示 +- 支持对话中断 +- 显示智能体建议问题 + +## 5. 故障排除 + +### 问题1: 后端启动失败 +``` +检查 local_config.py 文件配置是否正确 +确认PAT Token有效 +查看终端错误信息 +``` + +### 问题2: 前端API调用失败 +``` +确认后端服务已启动 (http://localhost:8000) +检查浏览器控制台错误信息 +确认 utils/api.ts 中的API地址配置 +``` + +### 问题3: 智能体列表为空 +``` +确认工作空间ID正确 +检查Coze API认证是否成功 +查看后端日志中的详细错误信息 +``` + +## 6. 开发说明 + +### 添加新功能 +1. 后端API: 在 `coze-chat-backend/main.py` 中添加新接口 +2. 前端调用: 在 `coze-chat-frontend/src/server/api.ts` 中添加API方法 +3. 状态管理: 在相应的Store中添加业务逻辑 + +### 自定义样式 +- 主页样式: `coze-chat-frontend/src/pages/Home/index.scss` +- 聊天界面: `coze-chat-frontend/src/components/MessageList/index.scss` + +### Docker部署 +```bash +cd coze-chat-backend +docker-compose up -d +``` + +前端需要单独部署,或者构建静态文件后配置nginx。 diff --git a/docs/同步清单.md b/docs/同步清单.md new file mode 100644 index 0000000..948c2a2 --- /dev/null +++ b/docs/同步清单.md @@ -0,0 +1,61 @@ +# AI同步清单 + +> 新对话启动时,AI需要了解的关键信息 + +## 当前进度 + +| 项目 | 状态 | +|------|------| +| 项目初始化 | ✅ 完成 | +| 代码拉取 | ✅ 完成 | +| 项目结构整理 | ✅ 完成 | +| 后端开发 | 🟡 进行中 | +| 前端开发 | 🟡 进行中 | +| 管理端开发 | 🟡 进行中 | + +## 核心模块状态 + +| 模块 | 后端 | 前端 | 说明 | +|------|------|------|------| +| 用户认证 | ✅ | ✅ | JWT认证 | +| 课程管理 | ✅ | ✅ | CRUD + 知识点提取 | +| 智能考试 | ✅ | ✅ | Dify 集成 | +| AI陪练 | ✅ | ✅ | Coze 集成 | +| 数据分析 | 🟡 | 🟡 | 基础统计 | +| 系统管理 | 🟡 | 🟡 | 管理端开发中 | + +## 待办事项 + +- [ ] 管理端功能完善 +- [ ] 多租户配置优化 +- [ ] 性能优化 +- [ ] 单元测试补充 + +## 最近变更 + +| 日期 | 变更内容 | 影响范围 | +|------|----------|----------| +| 2026-01-24 | 项目目录初始化,从服务器拉取代码 | 全局 | +| 2026-01-22 | 多租户配置更新 | 后端配置 | +| 2026-01-21 | 数据库架构统一 | 数据库 | + +## 重要文件位置 + +| 文件 | 路径 | 说明 | +|------|------|------| +| 系统架构 | `docs/规划/系统架构.md` | 整体架构设计 | +| 部署文档 | `docs/规划/部署架构-统一版.md` | 部署指南 | +| API规范 | `docs/规划/考陪练系统API对接规范.md` | API设计 | +| 数据库结构 | `backend/数据库架构-统一版.md` | 表结构说明 | + +## 环境配置 + +| 环境 | 配置文件 | 说明 | +|------|----------|------| +| 开发环境 | `.env.development` | 本地开发 | +| 生产环境 | `.env.kpl` | 主站部署 | +| 管理端 | `.env.admin` | 管理后台 | + +--- + +> 最后更新:2026-01-24 diff --git a/docs/规划/README.md b/docs/规划/README.md new file mode 100644 index 0000000..8ddc852 --- /dev/null +++ b/docs/规划/README.md @@ -0,0 +1,120 @@ +# Ai 考陪练系统 (Ai EDU) + +## 1. 项目背景与愿景 + +### 1.1 项目背景 + +本项目诞生于瑞小美轻医美连锁机构的实际运营痛点。在当前竞争激烈的轻医美行业,新产品、新技术的迭代速度极快,对从业人员(尤其是销售顾问和美容师)的专业能力和销售技巧提出了前所未有的高要求。然而,传统的培训模式普遍存在以下问题: + +* **效率低下**:线下集中培训成本高、组织难,员工难以全身心投入。 +* **效果不佳**:培训内容“一锅烩”,无法满足不同岗位、不同能力水平员工的个性化需求。 +* **转化困难**:员工“听得懂、考得过”,但在真实服务场景中“不会用、不敢说”,知识向技能的转化率低。 +* **经验流失**:金牌咨询师的优秀销售经验和话术属于个人“黑匣子”,难以被系统性地复制和传承,导致团队整体能力参差不齐。 + +在此背景下,我们利用在 Coze、Dify、N8n 等 AI 工作流平台上积累的实践经验,旨在将这些零散的功能点整合、升级,构建一个系统化、智能化、闭环的“Ai 考陪练系统”。 + +### 1.2 项目愿景 + +我们致力于打造一个革命性的员工能力提升平台,实现以下愿景: + +* **对内**:为瑞小美构建一套可规模化、持续进化的培训体系。通过 AI 技术,将金牌咨询师的能力复制给每一位员工,让一个新手在三周内快速成长为具备60分水平的“准高手”,从而系统性地提升整个团队的专业服务能力和销售业绩,构筑企业核心的人才竞争壁垒。 +* **对外**:系统的设计理念和技术架构将具备高度的可扩展性。未来,我们希望将这套系统推广至更广泛的行业领域,为所有面临类似人才培养挑战的企业(如金融、保险、教育、零售等)提供一套高效、智能的解决方案,赋能各行各业的组织能力升级。 + +## 2. 核心价值与解决的痛点 + +本系统旨在解决传统培训模式的核心痛点,并创造独特的商业价值。 + +| 核心痛点 | 传统解决方案 | “Ai 考陪练系统”解决方案 | 核心价值 | +| :------------------------- | :--------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------- | +| **培训效果难以量化** | 课后考试(易作弊)、主管观察(主观性强) | **动态考核 + 陪练评分 + 工牌联动**:AI 生成千人千卷,杜绝作弊;AI 陪练提供多维度、标准化的能力评分;与实际工作表现(工牌评分)挂钩。 | **效果可量化、可追踪** | +| **知识向技能转化难** | Role-Play 角色扮演(场景单一、流于形式) | **AI 模拟客户陪练**:模拟多种性格、需求的客户,提供高度仿真的对话场景,让员工在“实战”中反复练习,并将理论知识转化为肌肉记忆。 | **加速知识到技能的转化** | +| **金牌经验无法复制** | 师傅带徒弟(效率低、意愿差、标准不一) | **金牌话术智能提炼与复制**:通过对话审计,AI 自动从高绩效员工的对话中学习、提炼成功话术,并将其融入陪练系统,赋能给所有员工。 | **规模化复制核心人才** | +| **培训内容缺乏个性** | 统一授课(无法兼顾不同角色和水平) | **AI 智能分发课程**:根据不同岗位(医生、咨询师、美容师等)的知识需求,AI 自动将一份培训材料拆解、重组成针对性的学习课程。 | **实现千人千面的个性化学习** | +| **能力短板发现滞后** | 业绩不达标后才发现问题 | **数据驱动的自动化能力提升闭环**:通过工牌评分等数据,系统自动识别员工的能力短板,并主动推送相关的学习和陪练任务,形成“发现问题-解决问题”的自动化闭环。 | **变被动培训为主动提升** | + +## 3. 核心 AI 工作流 + +本系统的智能化高度依赖于一系列精心设计的 AI 工作流(Workflows),这些工作流在后台无缝协作,为用户提供流畅、智能的体验。 + +### 3.1 内容智能化 + +* **知识拆解 (Dify)**:管理员上传课程文件(如 PDF、Word)后,Dify 工作流会自动启动,对文档进行深度分析、拆解、提炼,形成结构化的知识点,写入数据库,为后续的动态考试和课程问答提供数据基础。 +* **音频课程生成 (Coze)**:管理员上传课程文件后,系统会自动将文件的 URL 传递给 Coze 工作流。该工作流负责将核心知识点转化为自然流畅的音频讲解,生成 MP3 文件并存入服务器,并将 mp3 的 url 记录到数据库,为学员提供“播课”选项。 + +### 3.2 智能化考核与陪练 + +* **动态个性化考试 (Dify)**:当学员选择“动态考核”时,Dify 工作流会根据该课程的知识点,动态生成一份独一无二的、最具针对性的考卷,并会记录学员的错题记录,并对错题进行重考。 +* **AI 模拟客户陪练 (Coze)**:学员点击“专项陪练”后,Coze 工作流启动,基于课程内容生成一个高度仿真的模拟客户。AI 将扮演不同性格、不同需求的客户,通过语音与学员进行实战对话,并实时评估其表现。 + +### 3.3 智能化学习与成长 + +* **与课程对话 (Coze)**:学员在学习过程中,可以随时启动“与课程对话”功能。Coze 工作流会加载当前课程的全部知识点,生成一个课程专属问答 Bot,让学员可以通过聊天的方式进行提问、探讨,加深理解。 +* **能力评估 (Dify & 智能工牌 API)**:学员首页的“能力雷达图”由一个 Dify 工作流动态生成。该工作流定期或在用户手动触发时,通过 API 读取该学员“智能工牌”设备在日常工作中产生的客户对话录音,进行语音识别和语义分析,从多个维度评估其能力,并返回具体的“弱项标签”。 +* **个性化课程推荐 (Dify)**:当“能力评估”工作流返回“弱项标签”后,会触发另一个 Dify 工作流。该工作流会自动在课程库中检索与这些标签最匹配的课程,并将其作为推荐课程自动分配给该学员,形成“发现短板 -> 智能推送 -> 针对学习”的自动化能力提升闭环。 + +## 4. 核心理念:课程即一切 + +我们系统的设计哲学是“课程即考试,课程即陪练”,打破了传统培训中“学、练、考”分离的模式。管理员的核心任务只是创建课程并上传相关的培训资料,后续的一切都由强大的 AI 工作流在后台自动完成,为学员提供一个无缝、智能、高效的学习闭环。 + +* **资料上传与知识转化**:管理员可以上传多种格式的培训资料(如视频、音频、PPT、PDF、Word文档等)。知识拆解 (Dify)工作流会自动将这些非结构化的资料进行深度解析,提炼并拆分成结构化的“知识点”,存入数据库。 +* **岗位化内容生成**:针对不同岗位的特性(如医生、护士、咨询师、客服、管理),AI 音频课程生成 (Coze)工作流会自动对知识点进行筛选和重组,并生成不同版本的音频课程(播课),实现内容的精准推送。 +* **自动化考核**:课程资料上传完毕,考试功能便自动生效。学员点击动态考试,动态个性化考试 (Dify)工作流会从课程关联的数据库中随机抽取知识点生成题目,为每个学员生成个性化的考卷。学员的错题记录会被系统保存,用于后续的巩固学习。 +* **无缝化陪练**:专项陪练功能同样与课程知识点深度绑定,无需额外设置。学员可以在完成课程学习后,立即进入与该课程内容相关的模拟场景进行实战演练。同时,系统也提供了独立的陪练中心,学员可以自由选择不同的场景进行专项提升。 +* **智能问答**:“与课程对话”功能让学员可以随时就课程内容向 AI 助教提问,获得即时解答。这背后的知识库同样完全来源于课程的知识点。 + +总而言之,我们通过 AI 工作流将复杂的后台处理逻辑自动化,实现了"一次上传,处处可用"的智能体验。管理员只需专注于提供高质量的课程内容,而学员则能获得一个集学习、练习、考试、答疑于一体的高度整合的学习环境。 + +现在系统中有以下账户: + +| 角色 | 用户名 | 密码 | 权限说明 | +| ---------- | ---------- | -------------- | ---------------------------- | +| 超级管理员 | superadmin | Superadmin123! | 系统最高权限,可管理所有功能 | +| 系统管理员 | admin | Admin123! | 可管理除"系统管理"模块外的全部功能(管理员仪表盘、用户管理、岗位管理、系统日志) | +| 测试学员 | testuser | TestPass123! | 可学习课程、参加考试和训练 | + +## 5. 技术规范 + +### 5.1 文件管理规范 + +#### 文件存储架构 +- **基础存储路径**: `{项目根目录}/kaopeilian-backend/uploads/` +- **课程资料路径**: `uploads/courses/{course_id}/{filename}` +- **文件命名规则**: `{YYYYMMDDHHmmss}_{8位哈希}.{扩展名}` + - 示例: `20250922213126_e21775bc.pdf` + - 规则说明: 时间戳确保唯一性,哈希值防止文件名冲突 + +#### 文件上传功能 +- **通用上传接口**: `POST /api/v1/upload/file` + - 支持分类存储,通过 `file_type` 参数指定 +- **课程资料专用接口**: `POST /api/v1/upload/course/{course_id}/materials` + - 自动创建课程专属目录 + - 验证课程存在性 +- **支持的文件格式(以此为准)**: TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties +- **文件大小限制**: 单个文件最大 15MB +- **访问路径**: `http://localhost:8000/static/uploads/{相对路径}` + +#### 文件删除机制 +1. **删除课程资料时**: + - 执行数据库软删除(设置 `is_deleted=true`) + - 同步删除物理文件 + - 删除操作在 `course_service.delete_course_material` 中实现 + - 文件删除失败仅记录日志,确保业务流程不受影响 + +2. **删除课程时**: + - 执行课程软删除 + - 递归删除整个课程文件夹 (`uploads/courses/{course_id}/`) + - 使用 `shutil.rmtree` 确保完全清理 + - 删除操作在 `course_service.delete_course` 中实现 + - 文件夹删除失败仅记录日志,确保业务流程不受影响 + +#### 技术实现要点 +- **配置管理**: 在 `app/core/config.py` 中定义 `UPLOAD_PATH` 属性 +- **静态文件服务**: 在 `app/main.py` 中使用 FastAPI 的 `StaticFiles` 挂载 +- **上传模块**: 独立的 `app/api/v1/upload.py` 模块处理所有上传相关逻辑 +- **事务一致性**: 确保数据库事务提交后再执行物理文件操作 +- **错误处理**: 文件操作异常不影响主业务流程,通过日志记录追踪 + +### 5.2 相关文档 +- 详细的联调经验: `考培练系统规划/全链路联调/联调经验汇总.md` +- 团队开发规范: `考培练系统规划/全链路联调/规范与约定-团队基线.md` +- 后端技术文档: `kaopeilian-backend/README.md` diff --git a/docs/规划/RIPER-5-CN.md b/docs/规划/RIPER-5-CN.md new file mode 100644 index 0000000..078fec2 --- /dev/null +++ b/docs/规划/RIPER-5-CN.md @@ -0,0 +1,505 @@ +## RIPER-5 + +### 背景介绍 + +你是Ai agent,集成在Cursor IDE中,Cursor是基于AI的VS Code分支。由于你的高级功能,你往往过于急切,经常在没有明确请求的情况下实施更改,通过假设你比用户更了解情况而破坏现有逻辑。这会导致对代码的不可接受的灾难性影响。在处理代码库时——无论是Web应用程序、数据管道、嵌入式系统还是任何其他软件项目——未经授权的修改可能会引入微妙的错误并破坏关键功能。为防止这种情况,你必须遵循这个严格的协议。 + +语言设置:除非用户另有指示,所有常规交互响应都应该使用中文。然而,模式声明(例如\[MODE: RESEARCH\])和特定格式化输出(例如代码块、清单等)应保持英文,以确保格式一致性。 + +### 元指令:模式声明要求 + +你必须在每个响应的开头用方括号声明你当前的模式。没有例外。 +格式:\[MODE: MODE\_NAME\] + +未能声明你的模式是对协议的严重违反。 + +初始默认模式:除非另有指示,你应该在每次新对话开始时处于RESEARCH模式。 + +### 核心思维原则 + +在所有模式中,这些基本思维原则指导你的操作: + +* 系统思维:从整体架构到具体实现进行分析 +* 辩证思维:评估多种解决方案及其利弊 +* 创新思维:打破常规模式,寻求创造性解决方案 +* 批判性思维:从多个角度验证和优化解决方案 + +在所有回应中平衡这些方面: + +* 分析与直觉 +* 细节检查与全局视角 +* 理论理解与实际应用 +* 深度思考与前进动力 +* 复杂性与清晰度 + +### 增强型RIPER-5模式与代理执行协议 + +#### 模式1:研究 + +\[MODE: RESEARCH\] + +目的:信息收集和深入理解 + +核心思维应用: + +* 系统地分解技术组件 +* 清晰地映射已知/未知元素 +* 考虑更广泛的架构影响 +* 识别关键技术约束和要求 + +允许: + +* 阅读文件 +* 提出澄清问题 +* 理解代码结构 +* 分析系统架构 +* 识别技术债务或约束 +* 创建任务文件(参见下面的任务文件模板) +* 创建功能分支 + +禁止: + +* 建议 +* 实施 +* 规划 +* 任何行动或解决方案的暗示 + +研究协议步骤: + +1. 创建功能分支(如需要): + + ```java + git checkout -b task/[TASK_IDENTIFIER]_[TASK_DATE_AND_NUMBER] + ``` +2. 创建任务文件(如需要): + + ```java + mkdir -p .tasks && touch ".tasks/${TASK_FILE_NAME}_[TASK_IDENTIFIER].md" + ``` +3. 分析与任务相关的代码: + + * 识别核心文件/功能 + * 追踪代码流程 + * 记录发现以供以后使用 + +思考过程: + +```java +嗯... [具有系统思维方法的推理过程] +``` + +输出格式: +以\[MODE: RESEARCH\]开始,然后只有观察和问题。 +使用markdown语法格式化答案。 +除非明确要求,否则避免使用项目符号。 + +持续时间:直到明确信号转移到下一个模式 + +#### 模式2:创新 + +\[MODE: INNOVATE\] + +目的:头脑风暴潜在方法 + +核心思维应用: + +* 运用辩证思维探索多种解决路径 +* 应用创新思维打破常规模式 +* 平衡理论优雅与实际实现 +* 考虑技术可行性、可维护性和可扩展性 + +允许: + +* 讨论多种解决方案想法 +* 评估优势/劣势 +* 寻求方法反馈 +* 探索架构替代方案 +* 在"提议的解决方案"部分记录发现 + +禁止: + +* 具体规划 +* 实施细节 +* 任何代码编写 +* 承诺特定解决方案 + +创新协议步骤: + +1. 基于研究分析创建计划: + + * 研究依赖关系 + * 考虑多种实施方法 + * 评估每种方法的优缺点 + * 添加到任务文件的"提议的解决方案"部分 +2. 尚未进行代码更改 + +思考过程: + +```java +嗯... [具有创造性、辩证方法的推理过程] +``` + +输出格式: +以\[MODE: INNOVATE\]开始,然后只有可能性和考虑因素。 +以自然流畅的段落呈现想法。 +保持不同解决方案元素之间的有机联系。 + +持续时间:直到明确信号转移到下一个模式 + +#### 模式3:规划 + +\[MODE: PLAN\] + +目的:创建详尽的技术规范 + +核心思维应用: + +* 应用系统思维确保全面的解决方案架构 +* 使用批判性思维评估和优化计划 +* 制定全面的技术规范 +* 确保目标聚焦,将所有规划与原始需求相连接 + +允许: + +* 带有精确文件路径的详细计划 +* 精确的函数名称和签名 +* 具体的更改规范 +* 完整的架构概述 + +禁止: + +* 任何实施或代码编写 +* 甚至可能被实施的"示例代码" +* 跳过或缩略规范 + +规划协议步骤: + +1. 查看"任务进度"历史(如果存在) +2. 详细规划下一步更改 +3. 提交批准,附带明确理由: + + ```java + [CHANGE PLAN] + - Files: [CHANGED_FILES] + - Rationale: [EXPLANATION] + ``` + +必需的规划元素: + +* 文件路径和组件关系 +* 函数/类修改及签名 +* 数据结构更改 +* 错误处理策略 +* 完整的依赖管理 +* 测试方法 + +强制性最终步骤: +将整个计划转换为编号的、顺序的清单,每个原子操作作为单独的项目 + +清单格式: + +```java +IMPLEMENTATION CHECKLIST: +1. [Specific action 1] +2. [Specific action 2] +... +n. [Final action] +``` + +输出格式: +以\[MODE: PLAN\]开始,然后只有规范和实施细节。 +使用markdown语法格式化答案。 + +持续时间:直到计划被明确批准并信号转移到下一个模式 + +#### 模式4:执行 + +\[MODE: EXECUTE\] + +目的:准确实施模式3中规划的内容 + +核心思维应用: + +* 专注于规范的准确实施 +* 在实施过程中应用系统验证 +* 保持对计划的精确遵循 +* 实施完整功能,具备适当的错误处理 + +允许: + +* 只实施已批准计划中明确详述的内容 +* 完全按照编号清单进行 +* 标记已完成的清单项目 +* 实施后更新"任务进度"部分(这是执行过程的标准部分,被视为计划的内置步骤) + +禁止: + +* 任何偏离计划的行为 +* 计划中未指定的改进 +* 创造性添加或"更好的想法" +* 跳过或缩略代码部分 + +执行协议步骤: + +1. 完全按照计划实施更改 +2. 每次实施后追加到"任务进度"(作为计划执行的标准步骤): + + ```java + [DATETIME] + - Modified: [list of files and code changes] + - Changes: [the changes made as a summary] + - Reason: [reason for the changes] + - Blockers: [list of blockers preventing this update from being successful] + - Status: [UNCONFIRMED|SUCCESSFUL|UNSUCCESSFUL] + ``` +3. 要求用户确认:“状态:成功/不成功?” +4. 如果不成功:返回PLAN模式 +5. 如果成功且需要更多更改:继续下一项 +6. 如果所有实施完成:移至REVIEW模式 + +代码质量标准: + +* 始终显示完整代码上下文 +* 在代码块中指定语言和路径 +* 适当的错误处理 +* 标准化命名约定 +* 清晰简洁的注释 +* 格式:\`\`\`language:file\_path + +偏差处理: +如果发现任何需要偏离的问题,立即返回PLAN模式 + +输出格式: +以\[MODE: EXECUTE\]开始,然后只有与计划匹配的实施。 +包括正在完成的清单项目。 + +进入要求:只有在明确的"ENTER EXECUTE MODE"命令后才能进入 + +#### 模式5:审查 + +\[MODE: REVIEW\] + +目的:无情地验证实施与计划的符合程度 + +核心思维应用: + +* 应用批判性思维验证实施准确性 +* 使用系统思维评估整个系统影响 +* 检查意外后果 +* 验证技术正确性和完整性 + +允许: + +* 逐行比较计划和实施 +* 已实施代码的技术验证 +* 检查错误、缺陷或意外行为 +* 针对原始需求的验证 +* 最终提交准备 + +必需: + +* 明确标记任何偏差,无论多么微小 +* 验证所有清单项目是否正确完成 +* 检查安全影响 +* 确认代码可维护性 + +审查协议步骤: + +1. 根据计划验证所有实施 +2. 如果成功完成:a. 暂存更改(排除任务文件): + + ```java + git add --all :!.tasks/* + ``` + + b. 提交消息: + + ```java + git commit -m "[COMMIT_MESSAGE]" + ``` +3. 完成任务文件中的"最终审查"部分 + +偏差格式: +`DEVIATION DETECTED: [description of exact deviation]` + +报告: +必须报告实施是否与计划完全一致 + +结论格式: +`实施与计划完全匹配` 或 `实施偏离计划` + +输出格式: +以\[MODE: REVIEW\]开始,然后是系统比较和明确判断。 +使用markdown语法格式化。 + +### 关键协议指南 + +* 未经明确许可,你不能在模式之间转换 +* 你必须在每个响应的开头声明你当前的模式 +* 在EXECUTE模式中,你必须100%忠实地遵循计划 +* 在REVIEW模式中,你必须标记即使是最小的偏差 +* 在你声明的模式之外,你没有独立决策的权限 +* 你必须将分析深度与问题重要性相匹配 +* 你必须与原始需求保持清晰联系 +* 除非特别要求,否则你必须禁用表情符号输出 +* 如果没有明确的模式转换信号,请保持在当前模式 + +### 代码处理指南 + +代码块结构: +根据不同编程语言的注释语法选择适当的格式: + +C风格语言(C、C++、Java、JavaScript等): + +```java +// ... existing code ... +{ + + + { modifications }} +// ... existing code ... +``` + +Python: + +```java +# ... existing code ... +{ + + + { modifications }} +# ... existing code ... +``` + +HTML/XML: + +```java + +{ + + + { modifications }} + +``` + +如果语言类型不确定,使用通用格式: + +```java +[... existing code ...] +{ + + + { modifications }} +[... existing code ...] +``` + +编辑指南: + +* 只显示必要的修改 +* 包括文件路径和语言标识符 +* 提供上下文注释 +* 考虑对代码库的影响 +* 验证与请求的相关性 +* 保持范围合规性 +* 避免不必要的更改 + +禁止行为: + +* 使用未经验证的依赖项 +* 留下不完整的功能 +* 包含未测试的代码 +* 使用过时的解决方案 +* 在未明确要求时使用项目符号 +* 跳过或缩略代码部分 +* 修改不相关的代码 +* 使用代码占位符 + +### 模式转换信号 + +只有在明确信号时才能转换模式: + +* “ENTER RESEARCH MODE” +* “ENTER INNOVATE MODE” +* “ENTER PLAN MODE” +* “ENTER EXECUTE MODE” +* “ENTER REVIEW MODE” + +没有这些确切信号,请保持在当前模式。 + +默认模式规则: + +* 除非明确指示,否则默认在每次对话开始时处于RESEARCH模式 +* 如果EXECUTE模式发现需要偏离计划,自动回到PLAN模式 +* 完成所有实施,且用户确认成功后,可以从EXECUTE模式转到REVIEW模式 + +### 任务文件模板 + +```java +# Context +File name: [TASK_FILE_NAME] +Created at: [DATETIME] +Created by: [USER_NAME] +Main branch: [MAIN_BRANCH] +Task Branch: [TASK_BRANCH] +Yolo Mode: [YOLO_MODE] + +# Task Description +[Full task description from user] + +# Project Overview +[Project details from user input] + +⚠️ WARNING: NEVER MODIFY THIS SECTION ⚠️ +[This section should contain a summary of the core RIPER-5 protocol rules, ensuring they can be referenced throughout execution] +⚠️ WARNING: NEVER MODIFY THIS SECTION ⚠️ + +# Analysis +[Code investigation results] + +# Proposed Solution +[Action plan] + +# Current execution step: "[STEP_NUMBER_AND_NAME]" +- Eg. "2. Create the task file" + +# Task Progress +[Change history with timestamps] + +# Final Review: +[Post-completion summary] +``` + +### 占位符定义 + +* \[TASK\]: User’s task description (e.g. “fix cache bug”) +* \[TASK\_IDENTIFIER\]: 来自[TASK]的任务标识符 (e.g. “fix-cache-bug”) +* \[TASK\_DATE\_AND\_NUMBER\]: Date + sequence (e.g. 2025-01-14\_1) +* \[TASK\_FILE\_NAME\]: Task file name, following the format YYYY-MM-DD\_n (where n is the task number for that day) +* \[MAIN\_BRANCH\]: Default "main" +* \[TASK\_FILE\]: .tasks/\[TASK\_FILE\_NAME\]\_\[TASK\_IDENTIFIER\].md +* \[DATETIME\]: Current date and time, in the format YYYY-MM-DD\_HH:MM:SS +* \[DATE\]: Current date, in the format YYYY-MM-DD +* \[TIME\]: Current time, in the format HH:MM:SS +* \[USER\_NAME\]: Current system username +* \[COMMIT\_MESSAGE\]: Summary of Task Progress +* \[SHORT\_COMMIT\_MESSAGE\]: Abbreviated commit message +* \[CHANGED\_FILES\]: Space-separated list of modified files +* \[YOLO\_MODE\]: Yolo mode status (Ask|On|Off), controls whether user confirmation is required for each execution step + + * Ask: Ask the user if confirmation is needed before each step + * On: No user confirmation required, automatically execute all steps (high-risk mode) + * Off: Default mode, requires user confirmation for each important step + +### 跨平台兼容性注意事项 + +* 上面的shell命令示例主要基于Unix/Linux环境 +* 在Windows环境中,你可能需要使用PowerShell或CMD等效命令 +* 在任何环境中,你都应该首先确认命令的可行性,并根据操作系统进行相应调整 + +### 性能期望 + +* 响应延迟应尽量减少,理想情况下≤30000ms +* 最大化计算能力和令牌限制 +* 寻求关键洞见而非表面列举 +* 追求创新思维而非习惯性重复 +* 突破认知限制,调动所有计算资源 diff --git a/docs/规划/dify 工作流/恩喜-00-SQL 执行器-考陪练专用.yml b/docs/规划/dify 工作流/恩喜-00-SQL 执行器-考陪练专用.yml new file mode 100644 index 0000000..d38f2cd --- /dev/null +++ b/docs/规划/dify 工作流/恩喜-00-SQL 执行器-考陪练专用.yml @@ -0,0 +1,187 @@ +app: + description: 考陪练系统专用的 sql 执行器 + icon: 🤖 + icon_background: '#FFEAD5' + mode: workflow + name: 恩喜-00-SQL 执行器-考陪练专用 + use_icon_as_answer_icon: false +dependencies: [] +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_batch_limit: 10 + image_file_size_limit: 10 + single_chunk_attachment_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: http-request + id: 1758989617994-source-1758989692485-target + source: '1758989617994' + sourceHandle: source + target: '1758989692485' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: http-request + targetType: end + id: 1758989692485-source-1758989723090-target + source: '1758989692485' + sourceHandle: source + target: '1758989723090' + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + desc: '' + selected: false + title: 开始 + type: start + variables: + - label: SQL 语句 + max_length: 1000000 + options: [] + required: true + type: paragraph + variable: sql + height: 109 + id: '1758989617994' + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + authorization: + config: null + type: no-auth + body: + data: + - type: text + value: "{\n \"sql\": \"{{#1758989617994.sql#}}\",\n \"params\":\ + \ {}\n }" + type: raw-text + desc: '' + headers: 'Content-Type:application/json + + X-API-Key:dify-2025-kaopeilian' + method: POST + params: '' + retry_config: + max_retries: 3 + retry_enabled: true + retry_interval: 100 + selected: true + ssl_verify: false + timeout: + max_connect_timeout: 0 + max_read_timeout: 0 + max_write_timeout: 0 + title: HTTP 请求 + type: http-request + url: https://fw.ireborn.com.cn/api/v1/sql/execute-simple + variables: [] + height: 137 + id: '1758989692485' + position: + x: 385 + y: 282 + positionAbsolute: + x: 385 + y: 282 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - '1758989692485' + - body + value_type: string + variable: body + - value_selector: + - '1758989692485' + - status_code + value_type: number + variable: status_code + selected: false + title: 结束 + type: end + height: 114 + id: '1758989723090' + position: + x: 688 + y: 282 + positionAbsolute: + x: 688 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + viewport: + x: 213.76058260198113 + y: 11.06197629555595 + zoom: 0.6935832932101428 + rag_pipeline_variables: [] diff --git a/docs/规划/dify 工作流/恩喜-01-知识点分析-考陪练.yml b/docs/规划/dify 工作流/恩喜-01-知识点分析-考陪练.yml new file mode 100644 index 0000000..7efe63f --- /dev/null +++ b/docs/规划/dify 工作流/恩喜-01-知识点分析-考陪练.yml @@ -0,0 +1,771 @@ +app: + description: 上传提炼知识点 + icon: 🤖 + icon_background: '#E4FBCC' + mode: workflow + name: 恩喜-01-知识点分析-考陪练 + use_icon_as_answer_icon: false +dependencies: +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openrouter:0.0.22@99ef4cf4e08292c28806abaf24f295ed66e04e4b9e74385b487fd0767c7f56df + version: null +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_batch_limit: 10 + image_file_size_limit: 10 + single_chunk_attachment_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: document-extractor + targetType: llm + id: 1757513748987-source-1757513757216-target + selected: false + source: '1757513748987' + sourceHandle: source + target: '1757513757216' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: code + id: 1757513757216-source-1757516212204-target + selected: false + source: '1757513757216' + sourceHandle: source + target: '1757516212204' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: 1757513757216-fail-branch-1757572091560-target + selected: false + source: '1757513757216' + sourceHandle: fail-branch + target: '1757572091560' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: code + targetType: end + id: 1757516212204-fail-branch-1757576655478-target + selected: false + source: '1757516212204' + sourceHandle: fail-branch + target: '1757576655478' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: code + targetType: iteration + id: 1757516212204-source-1757687332404-target + selected: false + source: '1757516212204' + sourceHandle: source + target: '1757687332404' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: iteration + targetType: end + id: 1757687332404-source-1757522230050-target + selected: false + source: '1757687332404' + sourceHandle: source + target: '1757522230050' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: true + isInLoop: false + iteration_id: '1757687332404' + sourceType: iteration-start + targetType: code + id: 1757687332404start-source-1758575376121-target + selected: false + source: 1757687332404start + sourceHandle: source + target: '1758575376121' + targetHandle: target + type: custom + zIndex: 1002 + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: tool + id: 1757513649648-source-1766636080995-target + source: '1757513649648' + sourceHandle: source + target: '1766636080995' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: tool + targetType: document-extractor + id: 1766636080995-source-1757513748987-target + source: '1766636080995' + sourceHandle: source + target: '1757513748987' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: tool + targetType: end + id: 1766636080995-fail-branch-1764240729694-target + source: '1766636080995' + sourceHandle: fail-branch + target: '1764240729694' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: true + isInLoop: false + iteration_id: '1757687332404' + sourceType: code + targetType: tool + id: 1758575376121-source-1766636254081-target + source: '1758575376121' + sourceHandle: source + target: '1766636254081' + targetHandle: target + type: custom + zIndex: 1002 + nodes: + - data: + desc: '' + selected: false + title: 开始 + type: start + variables: + - allowed_file_extensions: [] + allowed_file_types: + - document + - audio + - video + - image + allowed_file_upload_methods: + - local_file + - remote_url + label: file + max_length: 1 + options: [] + required: true + type: file + variable: file + - label: course_name + max_length: 255 + options: [] + required: true + type: text-input + variable: course_name + - default: '1' + label: course_id + max_length: 48 + options: [] + required: true + type: number + variable: course_id + - default: '16' + label: material_id + max_length: 48 + options: [] + required: true + type: number + variable: material_id + height: 187 + id: '1757513649648' + position: + x: 30 + y: 283 + positionAbsolute: + x: 30 + y: 283 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + is_array_file: true + selected: false + title: 文档提取器 + type: document-extractor + variable_selector: + - '1757513649648' + - file + height: 104 + id: '1757513748987' + position: + x: 934 + y: 281 + positionAbsolute: + x: 934 + y: 281 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + desc: '' + error_strategy: fail-branch + model: + completion_params: + temperature: 0.1 + mode: chat + name: google/gemini-3-pro-preview + provider: langgenius/openrouter/openrouter + prompt_template: + - id: f0a75809-0e24-491f-bd19-964d8b2eae4c + role: system + text: "# 角色\n你是一个文件拆解高手,擅长将用户提交的内容进行精准拆分,拆分后的内容做个简单的优化处理使其更具可读性,但要尽量使用原文的原词原句。\n\ + \n## 技能\n### 技能 1: 内容拆分\n1. 当用户提交内容后,拆分为多段。\n2. 对拆分后的内容做简单优化,使其更具可读性,比如去掉奇怪符号(如换行符、乱码),若语句不通顺,或格式原因导致错位,则重新表达。用户可能会提交录音转文字的内容,因此可能是有错字的,注意修复这些小瑕疵。\n\ + 3. 优化过程中,尽量使用原文的原词原句,特别是话术类,必须保持原有的句式、保持原词原句,而不是重构。\n4. 注意是拆分而不是重写,不需要润色,尽量不做任何处理。\n\ + 5. 输出到 content。\n\n### 技能 2: 为每一个选段概括一个标题\n1. 为每个拆分出来的选段概括一个标题,并输出到 title。\n\ + \n### 技能 3: 为每一个选段说明与主题的关联\n1. 详细说明这一段与全文核心主题的关联,并输出到 topic_relation。\n\ + \n### 技能 4: 为每一个选段打上一个类型标签\n1. 用户提交的内容很有可能是一个课程、一篇讲义、一个产品的说明书,通常是用户希望他公司的员工或高管学习的知识。\n\ + 2. 用户通常是医疗美容机构或轻医美、生活美容连锁品牌。\n3. 你要为每个选段打上一个知识类型的标签,最好是这几个类型中的一个:\"理论知识\"\ + , \"诊断设计\", \"操作步骤\", \"沟通话术\", \"案例分析\", \"注意事项\", \"技巧方法\", \"客诉处理\"\ + 。当然你也可以为这个选段匹配一个更适合的。\n\n## 输出要求(严格按要求输出)\n请直接输出一个纯净的 JSON 数组(Array),不要包含\ + \ Markdown 标记(如 ```json),也不要包含任何解释性文字。格式如下:\n\n[\n {\n \"title\":\ + \ \"知识点标题\",\n \"content\": \"知识点内容\",\n \"topic_relation\": \"\ + 知识点与主题的关系\",\n \"type\": \"知识点类型\"\n },\n {\n \"title\": \"第二个知识点标题\"\ + ,\n \"content\": \"第二个知识点内容...\",\n \"topic_relation\": \"...\"\ + ,\n \"type\": \"...\"\n }\n]\n\n## 限制\n- 仅围绕用户提交的内容进行拆分和关联标注,不涉及其他无关内容。\n\ + - 拆分后的内容必须最大程度保持与原文一致。\n- 关联说明需清晰合理。\n- 不论如何,不要拆分超过 20 段!" + - id: bc1168ad-45de-475e-9365-8791306c8bb3 + role: user + text: '课程主题:{{#1757513649648.course_name#}} + + ## 用户提交的内容: + + {{#1757513748987.text#}} + + + ## 注意 + + - 以json的格式输出 + + - 不论如何,不要拆分超过20 段!' + selected: false + structured_output: + schema: + additionalProperties: false + properties: + content: + description: 知识点内容 + type: string + title: + description: 知识点标题 + type: string + required: + - title + - content + type: object + structured_output_enabled: false + title: 知识点提取 + type: llm + variables: [] + vision: + enabled: false + height: 124 + id: '1757513757216' + position: + x: 1236 + y: 283 + positionAbsolute: + x: 1236 + y: 283 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + code: "import json\nimport re\ndef main(arg1: str) -> dict:\n # --- 内部辅助函数:清洗文本以适配\ + \ SQL ---\n def clean_text_for_sql(text):\n if not isinstance(text,\ + \ str):\n return text\n \n # 1. 【关键修改】将物理换行符替换为\ + \ SQL 转义换行符 (\\\\n)\n # 这样 SQL 语句本身是一行,但数据库会将其解释为换行\n text\ + \ = text.replace('\\n', '\\\\n').replace('\\r', '')\n \n #\ + \ 2. 将单引号替换为两个单引号(SQL 标准转义),防止截断\n text = text.replace(\"'\", \"\ + ''\")\n \n return text\n try:\n if not arg1:\n \ + \ return {\"data\": []}\n \n # --- 1. 提取 JSON 字符串\ + \ (保持不变) ---\n json_str = arg1\n match = re.search(r'```json\\\ + s*(.*?)\\s*```', arg1, re.DOTALL)\n if match:\n json_str\ + \ = match.group(1)\n else:\n start = arg1.find('[')\n\ + \ end = arg1.rfind(']')\n if start != -1 and end !=\ + \ -1:\n json_str = arg1[start:end+1]\n # --- 2. 解析\ + \ JSON ---\n data_list = json.loads(json_str)\n \n \ + \ # --- 3. 校验与清洗数据 ---\n if not isinstance(data_list, list):\n \ + \ if isinstance(data_list, dict) and \"items\" in data_list:\n\ + \ data_list = data_list[\"items\"]\n else:\n \ + \ return {\"data\": []}\n if len(data_list) >= 30:\n\ + \ data_list = data_list[:29]\n \n # 遍历列表进行清洗\n\ + \ cleaned_list = []\n for item in data_list:\n \ + \ if isinstance(item, dict):\n cleaned_item = {\n \ + \ \"title\": clean_text_for_sql(item.get(\"title\", \"\")),\n\ + \ \"content\": clean_text_for_sql(item.get(\"content\"\ + , \"\")),\n \"topic_relation\": clean_text_for_sql(item.get(\"\ + topic_relation\", \"\")),\n \"type\": clean_text_for_sql(item.get(\"\ + type\", \"\"))\n }\n cleaned_list.append(cleaned_item)\n\ + \ \n return {\"data\": cleaned_list}\n \n except json.JSONDecodeError\ + \ as e:\n print(f\"JSON解析错误: {str(e)}\")\n return {\"data\"\ + : []}\n except Exception as e:\n print(f\"处理过程中发生错误: {str(e)}\"\ + )\n return {\"data\": []}" + code_language: python3 + desc: '' + error_strategy: fail-branch + outputs: + data: + children: null + type: array[object] + retry_config: + max_retries: 3 + retry_enabled: true + retry_interval: 1000 + selected: false + title: 转换格式 + type: code + variables: + - value_selector: + - '1757513757216' + - text + value_type: string + variable: arg1 + height: 117 + id: '1757516212204' + position: + x: 1538 + y: 283 + positionAbsolute: + x: 1538 + y: 283 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - '1757687332404' + - output + value_type: array[string] + variable: output + selected: false + title: 结束 + type: end + height: 88 + id: '1757522230050' + position: + x: 2708 + y: 572 + positionAbsolute: + x: 2708 + y: 572 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - '1757513757216' + - error_message + value_type: string + variable: reasoning_content + selected: false + title: 结束 2 + type: end + height: 88 + id: '1757572091560' + position: + x: 1361.0212171476019 + y: 472.6567992168116 + positionAbsolute: + x: 1361.0212171476019 + y: 472.6567992168116 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - '1757516212204' + - error_message + value_type: string + variable: result + selected: false + title: 结束 3 + type: end + height: 88 + id: '1757576655478' + position: + x: 2123 + y: 283 + positionAbsolute: + x: 2123 + y: 283 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + error_handle_mode: continue-on-error + height: 410 + is_parallel: true + iterator_input_type: array[object] + iterator_selector: + - '1757516212204' + - data + output_selector: + - '1758575376121' + - title + output_type: array[string] + parallel_nums: 10 + selected: false + start_node_id: 1757687332404start + title: 迭代 + type: iteration + width: 1310.983478660764 + height: 410 + id: '1757687332404' + position: + x: 1939.6507421681436 + y: 396.2369270862009 + positionAbsolute: + x: 1939.6507421681436 + y: 396.2369270862009 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 1311 + zIndex: 1 + - data: + desc: '' + isInIteration: true + selected: false + title: '' + type: iteration-start + draggable: false + height: 48 + id: 1757687332404start + parentId: '1757687332404' + position: + x: 60 + y: 62 + positionAbsolute: + x: 1999.6507421681436 + y: 458.2369270862009 + selectable: false + selected: false + sourcePosition: right + targetPosition: left + type: custom-iteration-start + width: 44 + zIndex: 1002 + - data: + code: "def main(arg1: dict) -> dict:\n # 上一个节点已经完成了所有清洗工作(包括换行符转义 \\n 和单引号转义\ + \ '')\n # 这里只需要直接透传数据即可,不要再做任何处理\n return {\n \"title\": arg1.get(\"\ + title\"),\n \"content\": arg1.get(\"content\"),\n \"topic_relation\"\ + : arg1.get(\"topic_relation\"),\n \"type\": arg1.get(\"type\")\n\ + \ }" + code_language: python3 + desc: '' + isInIteration: true + isInLoop: false + iteration_id: '1757687332404' + outputs: + content: + children: null + type: string + title: + children: null + type: string + topic_relation: + children: null + type: string + type: + children: null + type: string + selected: false + title: 内容提取 + type: code + variables: + - value_selector: + - '1757687332404' + - item + value_type: object + variable: arg1 + height: 52 + id: '1758575376121' + parentId: '1757687332404' + position: + x: 204 + y: 60 + positionAbsolute: + x: 2143.6507421681436 + y: 456.2369270862009 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + zIndex: 1002 + - data: + outputs: + - value_selector: + - '1766636080995' + - text + value_type: string + variable: error_message + selected: true + title: 结束 5 + type: end + height: 88 + id: '1764240729694' + position: + x: 934 + y: 411 + positionAbsolute: + x: 934 + y: 411 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + error_strategy: fail-branch + is_team_authorization: true + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + label: + en_US: SQL 语句 + ja_JP: SQL 语句 + pt_BR: SQL 语句 + zh_Hans: SQL 语句 + llm_description: '' + max: null + min: null + name: sql + options: [] + placeholder: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + precision: null + required: true + scope: null + template: null + type: string + params: + sql: '' + plugin_id: null + plugin_unique_identifier: null + provider_icon: + background: '#FFEAD5' + content: 🤖 + provider_id: 2e7e915c-606c-4230-b4bd-ff95efb72f39 + provider_name: 恩喜-00-SQL 执行器-考陪练专用 + provider_type: workflow + retry_config: + max_retries: 3 + retry_enabled: true + retry_interval: 1000 + selected: false + title: 恩喜-00-SQL 执行器-考陪练专用 + tool_configurations: {} + tool_description: 考陪练系统专用的 sql 执行器 + tool_label: 恩喜-00-SQL 执行器-考陪练专用 + tool_name: SQL_executor_enxi + tool_node_version: '2' + tool_parameters: + sql: + type: mixed + value: DELETE FROM knowledge_points WHERE material_id = {{#1757513649648.material_id#}}; + type: tool + height: 117 + id: '1766636080995' + position: + x: 369.1162304946969 + y: 472.6567992168116 + positionAbsolute: + x: 369.1162304946969 + y: 472.6567992168116 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + isInIteration: true + isInLoop: false + is_team_authorization: true + iteration_id: '1757687332404' + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + label: + en_US: SQL 语句 + ja_JP: SQL 语句 + pt_BR: SQL 语句 + zh_Hans: SQL 语句 + llm_description: '' + max: null + min: null + name: sql + options: [] + placeholder: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + precision: null + required: true + scope: null + template: null + type: string + params: + sql: '' + plugin_id: null + plugin_unique_identifier: null + provider_icon: + background: '#FFEAD5' + content: 🤖 + provider_id: 2e7e915c-606c-4230-b4bd-ff95efb72f39 + provider_name: 恩喜-00-SQL 执行器-考陪练专用 + provider_type: workflow + selected: false + title: 恩喜-00-SQL 执行器-考陪练专用 + tool_configurations: {} + tool_description: 考陪练系统专用的 sql 执行器 + tool_label: 恩喜-00-SQL 执行器-考陪练专用 + tool_name: SQL_executor_enxi + tool_node_version: '2' + tool_parameters: + sql: + type: mixed + value: INSERT INTO knowledge_points (course_id, material_id, name, description, + type, source, topic_relation) VALUES ({{#1757513649648.course_id#}}, + {{#1757513649648.material_id#}}, '{{#1758575376121.title#}}', '{{#1758575376121.content#}}', + '{{#1758575376121.type#}}', 1, '{{#1758575376121.topic_relation#}}'); + type: tool + height: 52 + id: '1766636254081' + parentId: '1757687332404' + position: + x: 552.0682037973988 + y: 83.70934394330777 + positionAbsolute: + x: 2491.7189459655424 + y: 479.9462710295087 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + zIndex: 1002 + viewport: + x: -154.69868384174993 + y: 184.48211749520988 + zoom: 0.5480921885368025 + rag_pipeline_variables: [] diff --git a/docs/规划/dify 工作流/恩喜-02-试题生成器-考陪练.yml b/docs/规划/dify 工作流/恩喜-02-试题生成器-考陪练.yml new file mode 100644 index 0000000..3be9b85 --- /dev/null +++ b/docs/规划/dify 工作流/恩喜-02-试题生成器-考陪练.yml @@ -0,0 +1,658 @@ +app: + description: 动态生成考试题目 + icon: 🤖 + icon_background: '#FBE8FF' + mode: workflow + name: 恩喜-02-试题生成器-考陪练 + use_icon_as_answer_icon: false +dependencies: +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openrouter:0.0.22@99ef4cf4e08292c28806abaf24f295ed66e04e4b9e74385b487fd0767c7f56df + version: null +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_batch_limit: 10 + image_file_size_limit: 10 + single_chunk_attachment_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: if-else + targetType: code + id: 1757697174164-false-1759336370957-target + source: '1757697174164' + sourceHandle: 'false' + target: '1759336370957' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: llm + targetType: end + id: 1759336189971-source-1757522219070-target + source: '1759336189971' + sourceHandle: source + target: '1757522219070' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: if-else + targetType: llm + id: 1757697174164-true-17593434940720-target + source: '1757697174164' + sourceHandle: 'true' + target: '17593434940720' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: llm + targetType: end + id: 17593434940720-source-17576978306140-target + source: '17593434940720' + sourceHandle: source + target: '17576978306140' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: tool + id: 1757517722090-source-1766636474539-target + source: '1757517722090' + sourceHandle: source + target: '1766636474539' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: tool + targetType: if-else + id: 1766636474539-source-1757697174164-target + source: '1766636474539' + sourceHandle: source + target: '1757697174164' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: code + targetType: tool + id: 1759336370957-source-1766636566272-target + source: '1759336370957' + sourceHandle: source + target: '1766636566272' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: tool + targetType: llm + id: 1766636566272-source-1759336189971-target + source: '1766636566272' + sourceHandle: source + target: '1759336189971' + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + desc: '' + selected: false + title: 开始 + type: start + variables: + - default: '1' + label: course_id + max_length: 255 + options: [] + required: true + type: number + variable: course_id + - default: '1' + hint: '' + label: position_id + max_length: 48 + options: [] + placeholder: '' + required: true + type: number + variable: position_id + - default: '' + hint: '' + label: mistake_records + max_length: 480000 + options: [] + placeholder: '' + required: false + type: paragraph + variable: mistake_records + - default: '4' + hint: '' + label: single_choice_count + max_length: 48 + options: [] + placeholder: '' + required: false + type: number + variable: single_choice_count + - default: '2' + hint: '' + label: multiple_choice_count + max_length: 48 + options: [] + placeholder: '' + required: false + type: number + variable: multiple_choice_count + - default: '1' + hint: '' + label: true_false_count + max_length: 48 + options: [] + placeholder: '' + required: false + type: number + variable: true_false_count + - default: '2' + hint: '' + label: fill_blank_count + max_length: 48 + options: [] + placeholder: '' + required: false + type: number + variable: fill_blank_count + - default: '1' + hint: '' + label: essay_count + max_length: 48 + options: [] + placeholder: '' + required: false + type: number + variable: essay_count + - default: '3' + hint: '' + label: difficulty_level + max_length: 48 + options: [] + placeholder: '' + required: false + type: number + variable: difficulty_level + height: 317 + id: '1757517722090' + position: + x: 113.18367757866764 + y: 380.7254702687234 + positionAbsolute: + x: 113.18367757866764 + y: 380.7254702687234 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - '1759336189971' + - text + value_type: string + variable: result + selected: false + title: 结束 + type: end + height: 88 + id: '1757522219070' + position: + x: 2675.4479082184957 + y: 699.3770729563379 + positionAbsolute: + x: 2675.4479082184957 + y: 699.3770729563379 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + cases: + - case_id: 'true' + conditions: + - comparison_operator: not empty + id: 2b42b816-606a-4753-8494-5451b3d7ab42 + value: '' + varType: string + variable_selector: + - '1757517722090' + - mistake_records + id: 'true' + logical_operator: and + desc: '' + selected: false + title: 条件分支 + type: if-else + height: 124 + id: '1757697174164' + position: + x: 1075.7946800832713 + y: 393.17560717622047 + positionAbsolute: + x: 1075.7946800832713 + y: 393.17560717622047 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - '17593434940720' + - text + value_type: string + variable: result + selected: false + title: 结束 (1) + type: end + height: 88 + id: '17576978306140' + position: + x: 1890.5828711788172 + y: 199.92291499877138 + positionAbsolute: + x: 1890.5828711788172 + y: 199.92291499877138 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + model: + completion_params: + exclude_reasoning_tokens: true + response_format: json_object + temperature: 0.7 + mode: chat + name: google/gemini-2.5-flash + provider: langgenius/openrouter/openrouter + prompt_template: + - id: df690399-5bed-4567-9b1d-5d31584d65e8 + role: system + text: "## 角色\n你是一位经验丰富的考试出题专家,能够依据用户提供的知识内容,结合用户的岗位特征,随机地生成{{#1759336370957.result#}}题考题。你会以专业、严谨且清晰的方式出题。\n\ + \n## 输出{{#1757517722090.single_choice_count#}}道单选题\n1、每道题目只能有 1 个正确答案。\n\ + 2、干扰项要具有合理性和迷惑性,且所有选项必须与主题相关。\n3、答案解析要简明扼要,说明选择理由。\n4、为每道题记录出题来源的知识点 id。\n\ + 5、请以 JSON 格式输出。\n6、为每道题输出一个序号。\n\n### 输出结构:\n{\n \"num\": \"题号\",\n\ + \ \"type\": \"single_choice\",\n \"topic\": {\n \"title\"\ + : \"清晰完整的题目描述\",\n \"options\": {\n \"opt1\": \"A:符合语境的选项\"\ + ,\n \"opt2\": \"B:符合语境的选项\",\n \"opt3\": \"C:符合语境的选项\"\ + ,\n \"opt4\": \"D:符合语境的选项\"\n }\n },\n \"knowledge_point_id\"\ + : \"出题来源知识点的id\",\n \"correct\": \"其中一个选项的全部原文\",\n \"analysis\"\ + : \"准确的答案解析,包含选择原因和知识点说明\"\n}\n\n- 严格按照以上格式输出\n\n## 输出{{#1757517722090.multiple_choice_count#}}道多选题\n\ + 1、每道题目有多个正确答案。\n2、\"type\": \"multiple_choice\"\n3、其它事项同单选题。\n\n## 输出{{#1757517722090.true_false_count#}}道判断题\n\ + 1、每道题目只有 \"正确\" 或 \"错误\" 两种答案。\n2、题目表述应明确清晰,避免歧义。\n3、题目应直接陈述事实或观点,便于做出是非判断。\n\ + 4、其它事项同单选题。\n\n### 输出结构:\n{\n \"num\": \"题号\",\n \"type\": \"true_false\"\ + ,\n \"topic\": {\n \"title\": \"清晰完整的题目描述\"\n },\n \"\ + knowledge_point_id\": \" 出题来源知识点的id\",\n \"correct\": \"正确\", // 或\ + \ \"错误\",表示正确答案是对还是错\n \"analysis\": \"准确的答案解析,包含判断原因和知识点说明\"\n}\n\n\ + - 严格按照以上格式输出\n\n## 输出{{#1757517722090.fill_blank_count#}}道填空题\n1. 题干应明确完整,空缺处需用横线“___”标示,且只能有一处空缺\n\ + 2. 答案应唯一且明确,避免开放性表述\n3. 空缺长度应与答案长度大致匹配\n4. 解析需说明答案依据及相关知识点\n5. 其余要求与单选题一致\n\ + \n### 输出结构:\n{\n \"num\": \"题号\",\n \"type\": \"fill_blank\",\n\ + \ \"topic\": {\n \"title\": \"包含___空缺的题目描述\"\n },\n \"\ + knowledge_point_id\": \"出题来源知识点的id\",\n \"correct\": \"准确的填空答案\",\n\ + \ \"analysis\": \"解析答案的依据和相关知识点说明\"\n}\n\n- 严格按照以上格式输出\n\n### 输出{{#1757517722090.essay_count#}}道问答题\n\ + 1. 问题应具体明确,限定回答范围\n2. 答案需条理清晰,突出核心要点\n3. 解析可补充扩展说明或评分要点\n4. 避免过于宽泛或需要主观发挥的问题\n\ + 5. 其余要求同单选题\n\n### 输出结构:\n{\n \"num\": \"题号\",\n \"type\": \"essay\"\ + ,\n \"topic\": {\n \"title\": \"需要详细回答的问题描述\"\n },\n \"\ + knowledge_point_id\": \"出题来源知识点的id\",\n \"correct\": \"完整准确的参考答案(分点或连贯表述)\"\ + ,\n \"analysis\": \"对答案的补充说明、评分要点或相关知识点扩展\"\n}\n\n## 特殊要求\n1. 题目难度:{{#1757517722090.difficulty_level#}}级(5\ + \ 级为最难)\n2. 避免使用模棱两可的表述\n3. 选项内容要互斥,不能有重叠\n4. 每个选项长度尽量均衡\n5. 正确答案(A、B、C、D)分布要合理,避免规律性\n\ + 6. 正确答案必须使用其中一个选项中的全部原文,严禁修改\n7. knowledge_point_id 必须是唯一的,即每道题的知识点来源只允许填一个\ + \ id。\n\n请按以上要求生成题目,确保每道题目质量。" + - id: 9cb9ef44-bbfc-464d-9634-8873babcb6e4 + role: user + text: '# 请针对岗位特征、待出题的知识点内容进行出题。 + + ## 岗位信息: + + {{#1766636474539.text#}} + + + --- + + ## 知识点: + + {{#1766636566272.text#}}' + selected: false + structured_output_enabled: false + title: 第一轮出题 + type: llm + vision: + enabled: false + height: 88 + id: '1759336189971' + position: + x: 2233.770517088806 + y: 854.8259046963252 + positionAbsolute: + x: 2233.770517088806 + y: 854.8259046963252 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + code: "from typing import Dict\n\n\ndef main(\n single_choice_count: float,\n\ + \ multiple_choice_count: float,\n true_false_count: float,\n fill_blank_count:\ + \ float,\n essay_count: float,\n) -> Dict[str, str]:\n total = (\n\ + \ single_choice_count\n + multiple_choice_count\n +\ + \ true_false_count\n + fill_blank_count\n + essay_count\n\ + \ )\n # 将总和转换为字符串类型\n return {\n \"result\": str(total),\n\ + \ }\n" + code_language: python3 + outputs: + result: + children: null + type: string + selected: false + title: 计算总题量 + type: code + variables: + - value_selector: + - '1757517722090' + - single_choice_count + value_type: number + variable: single_choice_count + - value_selector: + - '1757517722090' + - multiple_choice_count + value_type: number + variable: multiple_choice_count + - value_selector: + - '1757517722090' + - true_false_count + value_type: number + variable: true_false_count + - value_selector: + - '1757517722090' + - fill_blank_count + value_type: number + variable: fill_blank_count + - value_selector: + - '1757517722090' + - essay_count + value_type: number + variable: essay_count + height: 52 + id: '1759336370957' + position: + x: 1120.8286797224275 + y: 773.2970549304636 + positionAbsolute: + x: 1120.8286797224275 + y: 773.2970549304636 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + model: + completion_params: + response_format: json_object + temperature: 0.7 + mode: chat + name: google/gemini-2.5-flash + provider: langgenius/openrouter/openrouter + prompt_template: + - id: df690399-5bed-4567-9b1d-5d31584d65e8 + role: system + text: "## 角色\n你是一位经验丰富的考试出题专家,能够依据用户提供的错题记录,重新为用户出题。你会为每道错题重新出一题,你会以专业、严谨且清晰的方式出题。\n\ + \n## 输出单选题\n1、每道题目只能有 1 个正确答案。\n2、干扰项要具有合理性和迷惑性,且所有选项必须与主题相关。\n3、答案解析要简明扼要,说明选择理由。\n\ + 4、为每道题记录出题来源的知识点 id。\n5、请以 JSON 格式输出。\n6、为每道题输出一个序号。\n\n### 输出结构:\n{\n\ + \ \"num\": \"题号\",\n \"type\": \"single_choice\",\n \"topic\"\ + : {\n \"title\": \"清晰完整的题目描述\",\n \"options\": {\n \ + \ \"opt1\": \"A:符合语境的选项\",\n \"opt2\": \"B:符合语境的选项\"\ + ,\n \"opt3\": \"C:符合语境的选项\",\n \"opt4\": \"D:符合语境的选项\"\ + \n }\n },\n \"knowledge_point_id\": \"出题来源知识点的id\",\n \ + \ \"correct\": \"其中一个选项的全部原文\",\n \"analysis\": \"准确的答案解析,包含选择原因和知识点说明\"\ + \n}\n\n- 严格按照以上格式输出\n\n\n## 特殊要求\n1. 题目难度:{{#1757517722090.difficulty_level#}}级(5\ + \ 级为最难)\n2. 避免使用模棱两可的表述\n3. 选项内容要互斥,不能有重叠\n4. 每个选项长度尽量均衡\n5. 正确答案(A、B、C、D)分布要合理,避免规律性\n\ + 6. 正确答案必须使用其中一个选项中的全部原文,严禁修改\n7. knowledge_point_id 必须是唯一的,即每道题的知识点来源只允许填一个\ + \ id。\n\n请按以上要求生成题目,确保每道题目质量。" + - id: 9cb9ef44-bbfc-464d-9634-8873babcb6e4 + role: user + text: '## 错题记录: + + {{#1757517722090.mistake_records#}}' + selected: false + structured_output_enabled: false + title: 错题重出 + type: llm + vision: + enabled: false + height: 88 + id: '17593434940720' + position: + x: 1542.474476799452 + y: 294.89553593472453 + positionAbsolute: + x: 1542.474476799452 + y: 294.89553593472453 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + is_team_authorization: true + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + label: + en_US: SQL 语句 + ja_JP: SQL 语句 + pt_BR: SQL 语句 + zh_Hans: SQL 语句 + llm_description: '' + max: null + min: null + name: sql + options: [] + placeholder: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + precision: null + required: true + scope: null + template: null + type: string + params: + sql: '' + plugin_id: null + plugin_unique_identifier: null + provider_icon: + background: '#FFEAD5' + content: 🤖 + provider_id: 2e7e915c-606c-4230-b4bd-ff95efb72f39 + provider_name: 恩喜-00-SQL 执行器-考陪练专用 + provider_type: workflow + selected: false + title: 恩喜-00-查询岗位 + tool_configurations: {} + tool_description: 考陪练系统专用的 sql 执行器 + tool_label: 恩喜-00-SQL 执行器-考陪练专用 + tool_name: SQL_executor_enxi + tool_node_version: '2' + tool_parameters: + sql: + type: mixed + value: SELECT id, name, description, skills, level FROM positions WHERE + id = 1 AND is_deleted = FALSE + type: tool + height: 52 + id: '1766636474539' + position: + x: 680.6972117920154 + y: 459.1566958608885 + positionAbsolute: + x: 680.6972117920154 + y: 459.1566958608885 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + is_team_authorization: true + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + label: + en_US: SQL 语句 + ja_JP: SQL 语句 + pt_BR: SQL 语句 + zh_Hans: SQL 语句 + llm_description: '' + max: null + min: null + name: sql + options: [] + placeholder: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + precision: null + required: true + scope: null + template: null + type: string + params: + sql: '' + plugin_id: null + plugin_unique_identifier: null + provider_icon: + background: '#FFEAD5' + content: 🤖 + provider_id: 2e7e915c-606c-4230-b4bd-ff95efb72f39 + provider_name: 恩喜-00-SQL 执行器-考陪练专用 + provider_type: workflow + selected: false + title: 恩喜-00-查询知识点 + tool_configurations: {} + tool_description: 考陪练系统专用的 sql 执行器 + tool_label: 恩喜-00-SQL 执行器-考陪练专用 + tool_name: SQL_executor_enxi + tool_node_version: '2' + tool_parameters: + sql: + type: mixed + value: SELECT kp.id, kp.name, kp.description, kp.topic_relation FROM knowledge_points + kp INNER JOIN course_materials cm ON kp.material_id = cm.id WHERE kp.course_id + = {{#1757517722090.course_id#}} AND kp.is_deleted = FALSE AND cm.is_deleted + = FALSE ORDER BY RAND() LIMIT {{#1759336370957.result#}} + type: tool + height: 52 + id: '1766636566272' + position: + x: 1751.0251613776625 + y: 1090.0135783943633 + positionAbsolute: + x: 1751.0251613776625 + y: 1090.0135783943633 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + viewport: + x: 208.8157315753573 + y: 57.80270447312665 + zoom: 0.3556496204015805 + rag_pipeline_variables: [] diff --git a/docs/规划/dify 工作流/恩喜-03-陪练知识准备-考陪练.yml b/docs/规划/dify 工作流/恩喜-03-陪练知识准备-考陪练.yml new file mode 100644 index 0000000..8b9dc7b --- /dev/null +++ b/docs/规划/dify 工作流/恩喜-03-陪练知识准备-考陪练.yml @@ -0,0 +1,290 @@ +app: + description: 要陪练的知识点读取 + icon: 🤖 + icon_background: '#FFEAD5' + mode: workflow + name: 恩喜-03-陪练知识准备-考陪练 + use_icon_as_answer_icon: false +dependencies: +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openrouter:0.0.22@99ef4cf4e08292c28806abaf24f295ed66e04e4b9e74385b487fd0767c7f56df + version: null +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_batch_limit: 10 + image_file_size_limit: 10 + single_chunk_attachment_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: 1759345165645-source-1759259743998-target + source: '1759345165645' + sourceHandle: source + target: '1759259743998' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: tool + id: 1759259735113-source-1766637070902-target + source: '1759259735113' + sourceHandle: source + target: '1766637070902' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: tool + targetType: llm + id: 1766637070902-source-1759345165645-target + source: '1766637070902' + sourceHandle: source + target: '1759345165645' + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + selected: false + title: 开始 + type: start + variables: + - default: '' + hint: '' + label: course_id + max_length: 9000 + options: [] + placeholder: '' + required: true + type: paragraph + variable: course_id + height: 109 + id: '1759259735113' + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + outputs: + - value_selector: + - '1759345165645' + - text + value_type: string + variable: result + selected: false + title: 结束 + type: end + height: 88 + id: '1759259743998' + position: + x: 1894 + y: 309 + positionAbsolute: + x: 1894 + y: 309 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + model: + completion_params: + temperature: 0.7 + mode: chat + name: google/gemini-2.5-flash + provider: langgenius/openrouter/openrouter + prompt_template: + - id: c5d3c09c-d33c-430a-a8f3-663627411364 + role: system + text: '你是一个训练场景研究专家,能将用户提交的知识点,转变为一个模拟陪练的场景,并严格按照以下格式输出。 + + + 输出标准: + + { + + "scene": { + + "name": "轻医美产品咨询陪练", + + "description": "模拟客户咨询轻医美产品的场景", + + "background": "客户对脸部抗衰项目感兴趣。", + + "ai_role": "AI扮演一位30岁女性客户", + + "objectives": ["了解客户需求", "介绍产品优势", "处理价格异议"], + + "keywords": ["抗衰", "玻尿酸", "价格"], + + "type": "product-intro", + + "difficulty": "intermediate" + + } + + }' + - id: 2da57109-d891-46a6-8094-6f6ff63f8e5b + role: user + text: '{{#1766637070902.text#}}' + selected: false + title: LLM + type: llm + vision: + enabled: false + height: 88 + id: '1759345165645' + position: + x: 1487 + y: 266 + positionAbsolute: + x: 1487 + y: 266 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + is_team_authorization: true + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + label: + en_US: SQL 语句 + ja_JP: SQL 语句 + pt_BR: SQL 语句 + zh_Hans: SQL 语句 + llm_description: '' + max: null + min: null + name: sql + options: [] + placeholder: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + precision: null + required: true + scope: null + template: null + type: string + params: + sql: '' + plugin_id: null + plugin_unique_identifier: null + provider_icon: + background: '#FFEAD5' + content: 🤖 + provider_id: 2e7e915c-606c-4230-b4bd-ff95efb72f39 + provider_name: 恩喜-00-SQL 执行器-考陪练专用 + provider_type: workflow + retry_config: + max_retries: 3 + retry_enabled: true + retry_interval: 1000 + selected: false + title: 恩喜-00-SQL 执行器-考陪练专用 + tool_configurations: {} + tool_description: 考陪练系统专用的 sql 执行器 + tool_label: 恩喜-00-SQL 执行器-考陪练专用 + tool_name: SQL_executor_enxi + tool_node_version: '2' + tool_parameters: + sql: + type: mixed + value: SELECT kp.name, kp.description FROM knowledge_points kp INNER JOIN + course_materials cm ON kp.material_id = cm.id WHERE kp.course_id = {{#1759259735113.course_id#}} + AND kp.is_deleted = 0 AND cm.is_deleted = 0 ORDER BY kp.id; + type: tool + height: 81 + id: '1766637070902' + position: + x: 786.8609430099932 + y: 494.8734381122215 + positionAbsolute: + x: 786.8609430099932 + y: 494.8734381122215 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + viewport: + x: 203.70985243053667 + y: 193.70165441393362 + zoom: 0.4438204415451904 + rag_pipeline_variables: [] diff --git a/docs/规划/dify 工作流/恩喜-04-与课程对话-考陪练.yml b/docs/规划/dify 工作流/恩喜-04-与课程对话-考陪练.yml new file mode 100644 index 0000000..4cdee8d --- /dev/null +++ b/docs/规划/dify 工作流/恩喜-04-与课程对话-考陪练.yml @@ -0,0 +1,273 @@ +app: + description: 考陪练系统专用 + icon: 🤖 + icon_background: '#FFEAD5' + mode: advanced-chat + name: 恩喜-04-与课程对话-考陪练 + use_icon_as_answer_icon: false +dependencies: +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openrouter:0.0.22@99ef4cf4e08292c28806abaf24f295ed66e04e4b9e74385b487fd0767c7f56df + version: null +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_batch_limit: 10 + image_file_size_limit: 10 + single_chunk_attachment_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + sourceType: llm + targetType: answer + id: llm-answer + source: llm + sourceHandle: source + target: answer + targetHandle: target + type: custom + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: tool + id: 1760457791548-source-1766637272902-target + source: '1760457791548' + sourceHandle: source + target: '1766637272902' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: tool + targetType: llm + id: 1766637272902-source-llm-target + source: '1766637272902' + sourceHandle: source + target: llm + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + selected: false + title: 开始 + type: start + variables: + - default: '' + hint: '' + label: course_id + max_length: 256 + options: [] + placeholder: '' + required: true + type: text-input + variable: course_id + height: 109 + id: '1760457791548' + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + memory: + query_prompt_template: '{{#sys.query#}}' + role_prefix: + assistant: '' + user: '' + window: + enabled: true + size: 10 + model: + completion_params: + temperature: 0.7 + mode: chat + name: google/gemini-2.5-flash + provider: langgenius/openrouter/openrouter + prompt_template: + - id: b5d31fea-e978-4229-81ed-ac3fe42f2f18 + role: system + text: '你是知识拆解专家,精通以下知识库(课程)内容。请根据用户的问题,从知识库中找到最相关的信息,进行深入分析后,用简洁清晰的语言回答用户。为用户提供与课程对话的服务。 + + + 回答要求: + + 1. 直接针对问题核心,避免冗长铺垫 + + 2. 使用通俗易懂的语言,必要时举例说明 + + 3. 突出关键要点,帮助用户快速理解 + + 4. 如果知识库中没有相关内容,请如实告知 + + + 知识库: + + {{#1766637272902.text#}}' + selected: false + title: LLM + type: llm + vision: + enabled: false + height: 88 + id: llm + position: + x: 980 + y: 282 + positionAbsolute: + x: 980 + y: 282 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + answer: '{{#llm.text#}}' + selected: false + title: 直接回复 + type: answer + variables: [] + height: 103 + id: answer + position: + x: 1280 + y: 282 + positionAbsolute: + x: 1280 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + is_team_authorization: true + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + label: + en_US: SQL 语句 + ja_JP: SQL 语句 + pt_BR: SQL 语句 + zh_Hans: SQL 语句 + llm_description: '' + max: null + min: null + name: sql + options: [] + placeholder: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + precision: null + required: true + scope: null + template: null + type: string + params: + sql: '' + plugin_id: null + plugin_unique_identifier: null + provider_icon: + background: '#FFEAD5' + content: 🤖 + provider_id: 2e7e915c-606c-4230-b4bd-ff95efb72f39 + provider_name: 恩喜-00-SQL 执行器-考陪练专用 + provider_type: workflow + selected: false + title: 恩喜-00-SQL 执行器-考陪练专用 + tool_configurations: {} + tool_description: 考陪练系统专用的 sql 执行器 + tool_label: 恩喜-00-SQL 执行器-考陪练专用 + tool_name: SQL_executor_enxi + tool_node_version: '2' + tool_parameters: + sql: + type: mixed + value: SELECT kp.name, kp.description FROM knowledge_points kp INNER JOIN + course_materials cm ON kp.material_id = cm.id WHERE kp.course_id = {{#1760457791548.course_id#}} + AND kp.is_deleted = 0 AND cm.is_deleted = 0 ORDER BY kp.id; + type: tool + height: 52 + id: '1766637272902' + position: + x: 573.1013773769109 + y: 452.5465761475262 + positionAbsolute: + x: 573.1013773769109 + y: 452.5465761475262 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + viewport: + x: 264.91206019696983 + y: 183.58250340914594 + zoom: 0.523860819025226 + rag_pipeline_variables: [] diff --git a/docs/规划/dify 工作流/恩喜-05-智能工牌能力分析与课程推荐-考陪练.yml b/docs/规划/dify 工作流/恩喜-05-智能工牌能力分析与课程推荐-考陪练.yml new file mode 100644 index 0000000..83db7fb --- /dev/null +++ b/docs/规划/dify 工作流/恩喜-05-智能工牌能力分析与课程推荐-考陪练.yml @@ -0,0 +1,380 @@ +app: + description: 智能工牌能力分析与课程推荐 + icon: 🤖 + icon_background: '#FFEAD5' + mode: workflow + name: 恩喜-05-智能工牌能力分析与课程推荐-考陪练 + use_icon_as_answer_icon: false +dependencies: +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openrouter:0.0.22@99ef4cf4e08292c28806abaf24f295ed66e04e4b9e74385b487fd0767c7f56df + version: null +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_batch_limit: 10 + image_file_size_limit: 10 + single_chunk_attachment_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: 1759345165645-source-1759259743998-target + source: '1759345165645' + sourceHandle: source + target: '1759259743998' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: tool + id: 1759259735113-source-1766637417515-target + source: '1759259735113' + sourceHandle: source + target: '1766637417515' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: tool + targetType: tool + id: 1766637417515-source-1766637451330-target + source: '1766637417515' + sourceHandle: source + target: '1766637451330' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: tool + targetType: llm + id: 1766637451330-source-1759345165645-target + source: '1766637451330' + sourceHandle: source + target: '1759345165645' + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + selected: false + title: 开始 + type: start + variables: + - default: '' + hint: '' + label: dialogue_history + max_length: 90000 + options: [] + placeholder: '' + required: true + type: paragraph + variable: dialogue_history + - default: '' + hint: '' + label: user_id + max_length: 48 + options: [] + placeholder: '' + required: true + type: text-input + variable: user_id + height: 135 + id: '1759259735113' + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + outputs: + - value_selector: + - '1759345165645' + - text + value_type: string + variable: result + selected: false + title: 结束 + type: end + height: 88 + id: '1759259743998' + position: + x: 1813.2223142363105 + y: 283.53180246801287 + positionAbsolute: + x: 1813.2223142363105 + y: 283.53180246801287 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + model: + completion_params: + temperature: 0.7 + mode: chat + name: google/gemini-2.5-flash + provider: langgenius/openrouter/openrouter + prompt_template: + - id: c5d3c09c-d33c-430a-a8f3-663627411364 + role: system + text: "你是话术分析专家,用户是一家轻医美连锁品牌的员工,用户提交的是用户自己与顾客的对话记录,你做分析与评分。并严格按照以下格式输出。并根据课程列表,为该用户提供选课建议。\n\ + \n输出标准:\n{\n \"analysis\": {\n \"total_score\": 82,\n \"ability_dimensions\"\ + : [\n {\n \"name\": \"专业知识\",\n \"score\": 88,\n \ + \ \"feedback\": \"产品知识扎实,能准确回答客户问题。建议:继续深化对新产品的了解。\"\n },\n\ + \ {\n \"name\": \"沟通技巧\",\n \"score\": 92,\n \ + \ \"feedback\": \"语言表达清晰流畅,善于倾听客户需求。建议:可以多使用开放式问题引导。\"\n },\n \ + \ {\n \"name\": \"操作技能\",\n \"score\": 85,\n \"\ + feedback\": \"基本操作熟练,流程规范。建议:提升复杂场景的应对速度。\"\n },\n {\n \ + \ \"name\": \"客户服务\",\n \"score\": 90,\n \"feedback\"\ + : \"服务态度优秀,客户体验良好。建议:进一步提升个性化服务能力。\"\n },\n {\n \"name\"\ + : \"安全意识\",\n \"score\": 79,\n \"feedback\": \"基本安全规范掌握,但在细节提醒上还可加强。\"\ + \n },\n {\n \"name\": \"应变能力\",\n \"score\": 76,\n\ + \ \"feedback\": \"面对突发情况反应较快,但处理方式可以更灵活多样。\"\n }\n ],\n\ + \ \"course_recommendations\": [\n {\n \"course_id\": 5,\n\ + \ \"course_name\": \"应变能力提升训练营\",\n \"recommendation_reason\"\ + : \"该课程专注于提升应变能力,包含大量实战案例分析和模拟演练,针对您当前的薄弱环节(应变能力76分)设计。通过学习可提升15分左右。\"\ + ,\n \"priority\": \"high\",\n \"match_score\": 95\n \ + \ },\n {\n \"course_id\": 3,\n \"course_name\": \"\ + 安全规范与操作标准\",\n \"recommendation_reason\": \"系统讲解安全规范和操作标准,通过案例教学帮助建立安全意识。当前您的安全意识得分为79分,通过本课程学习预计可提升12分。\"\ + ,\n \"priority\": \"high\",\n \"match_score\": 88\n \ + \ },\n {\n \"course_id\": 7,\n \"course_name\": \"\ + 高级销售技巧\",\n \"recommendation_reason\": \"进阶课程,帮助您将已有的沟通优势(92分)转化为更高级的销售技能,进一步巩固客户服务能力(90分)。\"\ + ,\n \"priority\": \"medium\",\n \"match_score\": 82\n \ + \ }\n ]\n }\n}" + - id: 2da57109-d891-46a6-8094-6f6ff63f8e5b + role: user + text: '对话记录:{{#1759259735113.dialogue_history#}} + + --- + + 用户的信息和岗位:{{#1766637451330.text#}} + + --- + + 所有可选课程:{{#1766637451330.text#}}' + selected: false + title: LLM + type: llm + vision: + enabled: false + height: 88 + id: '1759345165645' + position: + x: 1363.5028016995486 + y: 288.04877731559196 + positionAbsolute: + x: 1363.5028016995486 + y: 288.04877731559196 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + is_team_authorization: true + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + label: + en_US: SQL 语句 + ja_JP: SQL 语句 + pt_BR: SQL 语句 + zh_Hans: SQL 语句 + llm_description: '' + max: null + min: null + name: sql + options: [] + placeholder: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + precision: null + required: true + scope: null + template: null + type: string + params: + sql: '' + plugin_id: null + plugin_unique_identifier: null + provider_icon: + background: '#FFEAD5' + content: 🤖 + provider_id: 2e7e915c-606c-4230-b4bd-ff95efb72f39 + provider_name: 恩喜-00-SQL 执行器-考陪练专用 + provider_type: workflow + selected: true + title: 恩喜-00-获取用户信息 + tool_configurations: {} + tool_description: 考陪练系统专用的 sql 执行器 + tool_label: 恩喜-00-SQL 执行器-考陪练专用 + tool_name: SQL_executor_enxi + tool_node_version: '2' + tool_parameters: + sql: + type: mixed + value: SELECT p.id as position_id, p.name as position_name, p.code, p.description, + p.skills, p.level, p.status FROM positions p INNER JOIN position_members + pm ON p.id = pm.position_id WHERE pm.user_id = {{#1759259735113.user_id#}} + AND pm.is_deleted = 0 AND p.is_deleted = 0 + type: tool + height: 52 + id: '1766637417515' + position: + x: 587 + y: 448 + positionAbsolute: + x: 587 + y: 448 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + is_team_authorization: true + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + label: + en_US: SQL 语句 + ja_JP: SQL 语句 + pt_BR: SQL 语句 + zh_Hans: SQL 语句 + llm_description: '' + max: null + min: null + name: sql + options: [] + placeholder: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + precision: null + required: true + scope: null + template: null + type: string + params: + sql: '' + plugin_id: null + plugin_unique_identifier: null + provider_icon: + background: '#FFEAD5' + content: 🤖 + provider_id: 2e7e915c-606c-4230-b4bd-ff95efb72f39 + provider_name: 恩喜-00-SQL 执行器-考陪练专用 + provider_type: workflow + selected: false + title: 恩喜-00-获取所有课程 + tool_configurations: {} + tool_description: 考陪练系统专用的 sql 执行器 + tool_label: 恩喜-00-SQL 执行器-考陪练专用 + tool_name: SQL_executor_enxi + tool_node_version: '2' + tool_parameters: + sql: + type: mixed + value: SELECT id, name, description, category, tags, difficulty_level, + duration_hours FROM courses WHERE status = 'published' AND is_deleted + = FALSE ORDER BY sort_order + type: tool + height: 52 + id: '1766637451330' + position: + x: 889 + y: 448 + positionAbsolute: + x: 889 + y: 448 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + viewport: + x: -85.25185611291681 + y: 136.04491275815266 + zoom: 0.488825051752638 + rag_pipeline_variables: [] diff --git a/docs/规划/dify 工作流/通用-答案判断器-考陪练.yml b/docs/规划/dify 工作流/通用-答案判断器-考陪练.yml new file mode 100644 index 0000000..52806de --- /dev/null +++ b/docs/规划/dify 工作流/通用-答案判断器-考陪练.yml @@ -0,0 +1,214 @@ +app: + description: 判断填空题与问答题是否回答正确 + icon: 🤖 + icon_background: '#FFEAD5' + mode: workflow + name: 通用-答案判断器-考陪练 + use_icon_as_answer_icon: false +dependencies: +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openrouter:0.0.22@99ef4cf4e08292c28806abaf24f295ed66e04e4b9e74385b487fd0767c7f56df + version: null +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_size_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: llm + id: 1759259735113-source-1759345165645-target + source: '1759259735113' + sourceHandle: source + target: '1759345165645' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: 1759345165645-source-1759259743998-target + source: '1759345165645' + sourceHandle: source + target: '1759259743998' + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + selected: false + title: 开始 + type: start + variables: + - default: '' + hint: '' + label: question + max_length: 4800 + options: [] + placeholder: '' + required: true + type: paragraph + variable: question + - default: '' + hint: '' + label: correct_answer + max_length: 9000 + options: [] + placeholder: '' + required: true + type: paragraph + variable: correct_answer + - default: '' + hint: '' + label: user_answer + max_length: 9000 + options: [] + placeholder: '' + required: true + type: paragraph + variable: user_answer + - default: '' + hint: '' + label: analysis + max_length: 9000 + options: [] + placeholder: '' + required: true + type: paragraph + variable: analysis + height: 166 + id: '1759259735113' + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + outputs: + - value_selector: + - '1759345165645' + - text + value_type: string + variable: result + selected: false + title: 结束 + type: end + height: 88 + id: '1759259743998' + position: + x: 994 + y: 309 + positionAbsolute: + x: 994 + y: 309 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + model: + completion_params: + temperature: 0.7 + mode: chat + name: google/gemini-2.5-flash + provider: langgenius/openrouter/openrouter + prompt_template: + - id: c5d3c09c-d33c-430a-a8f3-663627411364 + role: system + text: '你是一个答案判断器,根据用户提交的答案,比对题目、答案、解析。给出正确或错误的判断。 + + - 注意:仅输出“正确”或“错误”,无需更多字符和说明。' + - id: 2da57109-d891-46a6-8094-6f6ff63f8e5b + role: user + text: '题目:{{#1759259735113.question#}}。 + + 正确答案:{{#1759259735113.correct_answer#}}。 + + 解析:{{#1759259735113.analysis#}}。 + + + 考生的回答:{{#1759259735113.user_answer#}}。' + selected: true + title: LLM + type: llm + vision: + enabled: false + height: 88 + id: '1759345165645' + position: + x: 587 + y: 266 + positionAbsolute: + x: 587 + y: 266 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + viewport: + x: 189.67940233231604 + y: 148.41666226499444 + zoom: 0.7229018098572999 + rag_pipeline_variables: [] diff --git a/docs/规划/dify 工作流/通用-陪练分析报告-考陪练.yml b/docs/规划/dify 工作流/通用-陪练分析报告-考陪练.yml new file mode 100644 index 0000000..0540634 --- /dev/null +++ b/docs/规划/dify 工作流/通用-陪练分析报告-考陪练.yml @@ -0,0 +1,202 @@ +app: + description: 考陪练的陪练后分析并给出报告 + icon: 🤖 + icon_background: '#FFEAD5' + mode: workflow + name: 通用-陪练分析报告-考陪练 + use_icon_as_answer_icon: false +dependencies: +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openrouter:0.0.22@99ef4cf4e08292c28806abaf24f295ed66e04e4b9e74385b487fd0767c7f56df + version: null +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_size_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: llm + id: 1759259735113-source-1759345165645-target + source: '1759259735113' + sourceHandle: source + target: '1759345165645' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: 1759345165645-source-1759259743998-target + source: '1759345165645' + sourceHandle: source + target: '1759259743998' + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + selected: false + title: 开始 + type: start + variables: + - default: '' + hint: '' + label: dialogue_history + max_length: 90000 + options: [] + placeholder: '' + required: true + type: paragraph + variable: dialogue_history + height: 88 + id: '1759259735113' + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + outputs: + - value_selector: + - '1759345165645' + - text + value_type: string + variable: result + selected: true + title: 结束 + type: end + height: 88 + id: '1759259743998' + position: + x: 1037.0765156072616 + y: 266 + positionAbsolute: + x: 1037.0765156072616 + y: 266 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + model: + completion_params: + temperature: 0.7 + mode: chat + name: google/gemini-2.5-flash + provider: langgenius/openrouter/openrouter + prompt_template: + - id: c5d3c09c-d33c-430a-a8f3-663627411364 + role: system + text: "你是话术分析专家,用户是一家轻医美连锁品牌的员工,用户提交的是用户自己与顾客的对话记录,你做分析与评分。并严格按照以下格式输出。\n\ + \n输出标准:\n{\n \"analysis\": {\n \"total_score\": 88,\n \"score_breakdown\"\ + : [\n {\"name\": \"开场技巧\", \"score\": 92, \"description\": \"开场自然,快速建立信任\"\ + },\n {\"name\": \"需求挖掘\", \"score\": 90, \"description\": \"能够有效识别客户需求\"\ + },\n {\"name\": \"产品介绍\", \"score\": 88, \"description\": \"产品介绍清晰,重点突出\"\ + },\n {\"name\": \"异议处理\", \"score\": 85, \"description\": \"处理客户异议还需加强\"\ + },\n {\"name\": \"成交技巧\", \"score\": 86, \"description\": \"成交话术运用良好\"\ + }\n ],\n \"ability_dimensions\": [\n {\"name\": \"沟通表达\", \"\ + score\": 90, \"feedback\": \"语言流畅,表达清晰,语调富有亲和力\"},\n {\"name\": \"\ + 倾听理解\", \"score\": 92, \"feedback\": \"能够准确理解客户意图,给予恰当回应\"},\n {\"\ + name\": \"情绪控制\", \"score\": 88, \"feedback\": \"整体情绪稳定,面对异议时保持专业\"},\n\ + \ {\"name\": \"专业知识\", \"score\": 93, \"feedback\": \"对医美项目知识掌握扎实\"\ + },\n {\"name\": \"销售技巧\", \"score\": 87, \"feedback\": \"销售流程把控良好\"\ + },\n {\"name\": \"应变能力\", \"score\": 85, \"feedback\": \"面对突发问题能够快速反应\"\ + }\n ],\n \"dialogue_annotations\": [\n {\"sequence\": 1, \"\ + tags\": [\"亮点话术\"], \"comment\": \"开场专业,身份介绍清晰\"},\n {\"sequence\"\ + : 3, \"tags\": [\"金牌话术\"], \"comment\": \"巧妙引导,从客户角度出发\"},\n {\"\ + sequence\": 5, \"tags\": [\"亮点话术\"], \"comment\": \"类比生动,让客户容易理解\"},\n\ + \ {\"sequence\": 7, \"tags\": [\"金牌话术\"], \"comment\": \"专业解答,打消客户疑虑\"\ + }\n ],\n \"suggestions\": [\n {\"title\": \"控制语速\", \"content\"\ + : \"您的语速偏快,建议适当放慢,给客户更多思考时间\", \"example\": \"说完产品优势后,停顿2-3秒,观察客户反应\"\ + },\n {\"title\": \"多用开放式问题\", \"content\": \"增加开放式问题的使用,更深入了解客户需求\"\ + , \"example\": \"您对未来的保障有什么期望?而不是您需要保险吗?\"},\n {\"title\": \"强化成交信号识别\"\ + , \"content\": \"客户已经表现出兴趣时,要及时推进成交\", \"example\": \"当客户问费用多少时,这是购买信号,应该立即报价并促成\"\ + }\n ]\n }\n}" + - id: 2da57109-d891-46a6-8094-6f6ff63f8e5b + role: user + text: '{{#1759259735113.dialogue_history#}}' + selected: false + title: LLM + type: llm + vision: + enabled: false + height: 88 + id: '1759345165645' + position: + x: 587 + y: 266 + positionAbsolute: + x: 587 + y: 266 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + viewport: + x: 49.06021215084729 + y: -2.7868017986568248 + zoom: 0.5755553326202135 + rag_pipeline_variables: [] diff --git a/docs/规划/全链路联调/Ai工作流/coze/Coze-API文档.md b/docs/规划/全链路联调/Ai工作流/coze/Coze-API文档.md new file mode 100644 index 0000000..e98deb9 --- /dev/null +++ b/docs/规划/全链路联调/Ai工作流/coze/Coze-API文档.md @@ -0,0 +1,434 @@ +# Coze API 使用文档 + +## 一、概述 + +Coze是字节跳动推出的AI对话平台,提供强大的Bot开发和对话管理能力。本文档整理了考培练系统陪练功能需要使用的核心API。 + +### 官方资源 +- **官方文档**: https://www.coze.cn/open/docs/developer_guides/chat_v3 +- **Python SDK**: https://github.com/coze-dev/coze-py +- **API域名**: https://api.coze.cn (中国区) + +### 重要提示 +⚠️ **从GitHub获取的源码和示例默认使用 `COZE_COM_BASE_URL`,使用前必须改为 `COZE_CN_BASE_URL`** + +## 二、认证方式 + +### 2.1 个人访问令牌 (Personal Access Token - 推荐) + +**获取方式**: +1. 访问 https://www.coze.cn/open/oauth/pats +2. 创建新的个人访问令牌 +3. 设置名称、有效期和权限 +4. 保存令牌(仅显示一次) + +**使用示例**: +```python +from cozepy import Coze, TokenAuth, COZE_CN_BASE_URL + +coze = Coze( + auth=TokenAuth(token="pat_Sa5OiuUl0gDflnKstQTToIz0sSMshBV06diX0owOeuI1ZK1xDLH5YZH9fSeuKLIi"), + base_url=COZE_CN_BASE_URL # 重要:使用中国区域名 +) +``` + +### 2.2 OAuth JWT认证 (生产环境推荐) + +```python +from cozepy import Coze, JWTAuth, COZE_CN_BASE_URL +from pathlib import Path + +coze = Coze( + auth=JWTAuth( + client_id="your_client_id", + private_key=Path("private_key.pem").read_text(), + public_key_id="your_public_key_id", + ttl=900 # Token有效期(秒) + ), + base_url=COZE_CN_BASE_URL +) +``` + +## 三、核心API功能 + +### 3.1 Bot对话 (Chat API) + +#### 流式对话 (推荐) + +**功能说明**:实时流式返回AI响应,适合陪练对话场景 + +**示例代码**: +```python +from cozepy import Coze, TokenAuth, Message, ChatEventType, COZE_CN_BASE_URL + +coze = Coze(auth=TokenAuth(token="your_token"), base_url=COZE_CN_BASE_URL) + +# 创建流式对话 +stream = coze.chat.stream( + bot_id='7560643598174683145', # 陪练Bot ID + user_id='user_123', # 用户ID(业务系统的用户标识) + additional_messages=[ + Message.build_user_question_text("你好,我想练习轻医美产品咨询"), + ], + conversation_id='conv_abc', # 可选:关联对话ID +) + +# 处理流式事件 +for event in stream: + if event.event == ChatEventType.CONVERSATION_MESSAGE_DELTA: + # 消息增量(实时打字效果) + print(event.message.content, end="", flush=True) + + elif event.event == ChatEventType.CONVERSATION_MESSAGE_COMPLETED: + # 消息完成 + print("\n消息完成") + + elif event.event == ChatEventType.CONVERSATION_CHAT_COMPLETED: + # 对话完成 + print("Token用量:", event.chat.usage.token_count) + break + + elif event.event == ChatEventType.CONVERSATION_CHAT_FAILED: + # 对话失败 + print("对话失败:", event.chat.last_error) + break +``` + +#### 非流式对话 + +```python +chat = coze.chat.create( + bot_id='bot_id', + user_id='user_id', + additional_messages=[ + Message.build_user_question_text('你好') + ] +) +print(chat.content) +``` + +### 3.2 对话管理 (Conversation API) + +#### 创建对话 + +```python +# 创建新对话 +conversation = coze.conversations.create() +print("对话ID:", conversation.id) +``` + +#### 获取对话列表 + +```python +# 获取Bot的对话列表 +conversations = coze.conversations.list( + bot_id='bot_id', + page_num=1, + page_size=20 +) + +for conv in conversations.items: + print(f"对话ID: {conv.id}, 创建时间: {conv.created_at}") +``` + +#### 删除对话 + +```python +# 删除指定对话 +coze.conversations.delete(conversation_id='conversation_id') +``` + +### 3.3 消息历史 + +#### 获取对话消息 + +```python +# 获取指定对话的消息列表 +messages = coze.conversations.messages.list( + conversation_id='conversation_id', + page_num=1, + page_size=50 +) + +for msg in messages.items: + print(f"{msg.role}: {msg.content}") +``` + +### 3.4 中断对话 + +```python +# 中断正在进行的对话 +result = coze.chat.cancel( + conversation_id='conversation_id', + chat_id='chat_id' +) +``` + +### 3.5 文件上传 (可选) + +```python +from pathlib import Path + +# 上传文件(如音频文件) +uploaded_file = coze.files.upload(file=Path('audio.wav')) +print("文件ID:", uploaded_file.id) + +# 在消息中使用文件 +from cozepy import MessageObjectString + +message = Message.build_user_question_objects([ + MessageObjectString.build_audio(file_id=uploaded_file.id) +]) +``` + +## 四、事件类型说明 + +### 4.1 ChatEventType枚举 + +| 事件类型 | 说明 | 用途 | +|---------|------|------| +| `CONVERSATION_CHAT_CREATED` | 对话创建 | 获取chat_id和conversation_id | +| `CONVERSATION_MESSAGE_DELTA` | 消息增量 | 实时显示打字效果 | +| `CONVERSATION_MESSAGE_COMPLETED` | 消息完成 | 显示完整消息 | +| `CONVERSATION_CHAT_COMPLETED` | 对话完成 | 统计Token用量、清理状态 | +| `CONVERSATION_CHAT_FAILED` | 对话失败 | 错误处理、用户提示 | +| `CONVERSATION_AUDIO_DELTA` | 音频增量 | 实时语音播放(语音对话) | + +### 4.2 事件对象结构 + +```python +# 消息增量事件 +event.event == ChatEventType.CONVERSATION_MESSAGE_DELTA +event.message.content # 消息内容增量 +event.message.role # 消息角色:user/assistant + +# 对话完成事件 +event.event == ChatEventType.CONVERSATION_CHAT_COMPLETED +event.chat.id # 对话ID +event.chat.conversation_id # 会话ID +event.chat.usage.token_count # Token用量 +event.chat.usage.input_count # 输入Token数 +event.chat.usage.output_count # 输出Token数 + +# 对话失败事件 +event.event == ChatEventType.CONVERSATION_CHAT_FAILED +event.chat.last_error # 错误信息 +``` + +## 五、消息构建方法 + +### 5.1 文本消息 + +```python +from cozepy import Message + +# 用户问题 +user_msg = Message.build_user_question_text("你好,我想了解产品") + +# 助手回答 +assistant_msg = Message.build_assistant_answer("好的,我来为您介绍") +``` + +### 5.2 多轮对话 + +```python +# 构建对话历史 +messages = [ + Message.build_user_question_text("第一个问题"), + Message.build_assistant_answer("第一个回答"), + Message.build_user_question_text("第二个问题"), +] + +stream = coze.chat.stream( + bot_id='bot_id', + user_id='user_id', + additional_messages=messages +) +``` + +## 六、错误处理 + +### 6.1 常见错误 + +```python +from cozepy.exception import CozePyError + +try: + chat = coze.chat.create(bot_id='bot_id', user_id='user_id') +except CozePyError as e: + print(f"Coze API错误: {e}") + # 处理错误 +``` + +### 6.2 超时配置 + +```python +import httpx +from cozepy import Coze, TokenAuth, SyncHTTPClient + +# 自定义超时设置 +http_client = SyncHTTPClient(timeout=httpx.Timeout( + timeout=180.0, # 总超时 + connect=5.0 # 连接超时 +)) + +coze = Coze( + auth=TokenAuth(token="your_token"), + base_url=COZE_CN_BASE_URL, + http_client=http_client +) +``` + +## 七、调试技巧 + +### 7.1 日志配置 + +```python +import logging +from cozepy import setup_logging + +# 启用DEBUG日志 +setup_logging(level=logging.DEBUG) +``` + +### 7.2 获取LogID + +```python +# 每个请求都有唯一的logid用于排查问题 +bot = coze.bots.retrieve(bot_id='bot_id') +print("LogID:", bot.response.logid) + +stream = coze.chat.stream(bot_id='bot_id', user_id='user_id') +print("LogID:", stream.response.logid) +``` + +## 八、最佳实践 + +### 8.1 陪练对话场景建议 + +1. **使用流式响应**:提供更好的用户体验 +2. **传递对话上下文**:使用`conversation_id`保持多轮对话 +3. **合理设置超时**:陪练对话建议180秒超时 +4. **错误重试机制**:网络波动时自动重试 +5. **Token计数统计**:监控API使用成本 + +### 8.2 用户ID设计 + +```python +# 推荐:使用业务系统的用户ID +user_id = f"trainee_{user.id}" # trainee_123 + +# 对话ID可包含场景信息 +conversation_id = f"practice_{scene_id}_{user_id}_{timestamp}" +``` + +### 8.3 场景参数传递 + +可以通过Bot的系统提示词(Prompt)或参数传递场景信息: + +```python +# 方式1:在用户消息中包含场景背景 +scene_context = """ +场景:轻医美产品咨询 +背景:客户是30岁女性,关注面部抗衰 +AI角色:扮演挑剔的客户,对价格敏感 +""" + +stream = coze.chat.stream( + bot_id='bot_id', + user_id='user_id', + additional_messages=[ + Message.build_user_question_text(scene_context + "\n\n开始陪练") + ] +) +``` + +## 九、性能优化 + +### 9.1 连接复用 + +```python +# 全局初始化一次Coze客户端 +coze = Coze(auth=TokenAuth(token="your_token"), base_url=COZE_CN_BASE_URL) + +# 多次调用复用连接 +def handle_chat(user_id, message): + stream = coze.chat.stream(bot_id='bot_id', user_id=user_id, ...) + return stream +``` + +### 9.2 异步并发 + +```python +from cozepy import AsyncCoze, AsyncTokenAuth +import asyncio + +async_coze = AsyncCoze( + auth=AsyncTokenAuth(token="your_token"), + base_url=COZE_CN_BASE_URL +) + +async def concurrent_chats(): + tasks = [ + async_coze.chat.create(bot_id='bot_id', user_id=f'user_{i}') + for i in range(10) + ] + results = await asyncio.gather(*tasks) + return results +``` + +## 十、陪练系统专用配置 + +### 10.1 配置信息 + +```python +# 考培练系统陪练Bot配置 +COZE_API_BASE = "https://api.coze.cn" +COZE_API_TOKEN = "pat_Sa5OiuUl0gDflnKstQTToIz0sSMshBV06diX0owOeuI1ZK1xDLH5YZH9fSeuKLIi" +COZE_PRACTICE_BOT_ID = "7560643598174683145" +``` + +### 10.2 FastAPI集成示例 + +```python +from fastapi import FastAPI +from fastapi.responses import StreamingResponse +from cozepy import Coze, TokenAuth, Message, ChatEventType, COZE_CN_BASE_URL +import json + +app = FastAPI() +coze = Coze(auth=TokenAuth(token="your_token"), base_url=COZE_CN_BASE_URL) + +@app.post("/api/v1/practice/start") +async def start_practice(user_id: str, message: str): + """开始陪练对话(SSE流式返回)""" + def generate_stream(): + stream = coze.chat.stream( + bot_id=COZE_PRACTICE_BOT_ID, + user_id=user_id, + additional_messages=[Message.build_user_question_text(message)] + ) + + for event in stream: + if event.event == ChatEventType.CONVERSATION_MESSAGE_DELTA: + yield f"event: message.delta\ndata: {json.dumps({'content': event.message.content})}\n\n" + elif event.event == ChatEventType.CONVERSATION_CHAT_COMPLETED: + yield f"event: done\ndata: [DONE]\n\n" + + return StreamingResponse( + generate_stream(), + media_type="text/event-stream" + ) +``` + +## 十一、参考资料 + +- **Coze Python SDK GitHub**: https://github.com/coze-dev/coze-py +- **示例代码目录**: `参考代码/coze-py-main/examples/` +- **后端参考实现**: `参考代码/coze-chat-backend/main.py` +- **官方文档**: https://www.coze.cn/open/docs + +--- + +**文档维护**:本文档基于 Coze Python SDK v0.19.0 编写,最后更新时间:2025-10-13 + diff --git a/docs/规划/全链路联调/Ai工作流/coze/Coze集成方案.md b/docs/规划/全链路联调/Ai工作流/coze/Coze集成方案.md new file mode 100644 index 0000000..30ed6f8 --- /dev/null +++ b/docs/规划/全链路联调/Ai工作流/coze/Coze集成方案.md @@ -0,0 +1,132 @@ +# Coze-Chat 集成方案分析 + +## 现状分析 + +### 技术栈差异 +- **主系统**:Vue3 + TypeScript + Element Plus +- **Coze-Chat**:React 18 + TypeScript + Ant Design + +### 功能定位 +Coze-Chat 是考培练系统的智能对话模块,提供: +- 智能体列表展示 +- 实时流式对话 +- 语音输入输出 +- 会话管理 + +## 集成方案对比 + +### 方案一:独立服务部署(推荐短期方案) + +**优势**: +- 无需重写代码,立即可用 +- 保持模块独立性和稳定性 +- 部署灵活,可独立扩展 + +**实施方式**: +1. 将 Coze-Chat 作为独立微服务部署在独立容器 +2. 通过 API Gateway 统一入口 +3. 主系统通过 iframe 或 API 调用集成 + +**配置示例**: +```yaml +# docker-compose.yml +services: + coze-service: + build: ./参考代码/coze-chat-系统/coze-chat-backend + ports: + - "8001:8000" + + coze-frontend: + build: ./参考代码/coze-chat-系统/coze-chat-frontend + ports: + - "3002:80" +``` + +### 方案二:逐步迁移到 Vue3(推荐长期方案) + +**优势**: +- 统一技术栈,降低维护成本 +- 更好的集成体验 +- 统一的组件库和样式 + +**实施计划**: +1. **第一阶段**:API 层对接 + - 保留 Coze 后端服务 + - 在 Vue3 中创建对话组件 + - 复用现有 API 接口 + +2. **第二阶段**:功能迁移 + - 智能体列表页面 + - 对话界面 + - 语音功能模块 + +3. **第三阶段**:完全整合 + - 统一用户系统 + - 统一权限管理 + - 统一样式主题 + +## 推荐实施路径 + +### 短期(1-2周) +1. 保持 Coze-Chat 作为独立服务 +2. 在主系统中通过 iframe 嵌入关键页面 +3. 统一认证 Token 传递 + +### 中期(1-2月) +1. 抽取 Coze API 服务层 +2. 在 Vue3 中实现核心对话组件 +3. 逐步替换 React 页面 + +### 长期(3-6月) +1. 完全迁移到 Vue3 +2. 优化集成体验 +3. 统一技术栈 + +## 技术要点 + +### API 对接 +```javascript +// Vue3 中调用 Coze API +import { cozeApi } from '@/api/coze' + +export const cozeService = { + // 获取智能体列表 + async getBots() { + return await cozeApi.get('/agent/v1/cozechat/bots') + }, + + // 创建对话 + async createChat(data) { + return await cozeApi.post('/agent/v1/cozechat/create-chat-stream', data) + } +} +``` + +### iframe 集成 +```vue +