From d6dec878bd64bb3e79bdc08953db60f24246de8c Mon Sep 17 00:00:00 2001 From: canglan Date: Mon, 22 Jun 2026 10:06:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=9A=E7=8F=AD=E7=BA=A7=E7=89=88=20?= =?UTF-8?q?v2.0=20-=20Go=E5=90=8E=E7=AB=AF=E9=87=8D=E5=86=99=20+=2043?= =?UTF-8?q?=E8=BD=AE=E4=BB=A3=E7=A0=81=E5=AE=A1=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端从 Python FastAPI 重写为 Go Gin(端口 56789) - 多班级完全隔离 - 超级管理员独立登录 - 课代表作业管理、排行榜分项排行 - 角色加减分上下限可配置 - 家长改密功能(可开关) - 周度/月度重置功能 - MySQL 5.7 兼容 - 43轮代码审查+全部修复 - Apache 2.0 许可证 --- .gitignore | 14 +- INSTALL.md | 392 +++++++---- LICENSE | 212 +++++- README.md | 430 +++++------ VERSION | 2 +- backend-go/.env.example | 67 ++ backend-go/Makefile | 63 ++ backend-go/cmd/server/main.go | 210 ++++++ backend-go/go.mod | 13 + backend-go/internal/config/config.go | 163 +++++ backend-go/internal/handler/admin_handler.go | 602 ++++++++++++++++ backend-go/internal/handler/auth_handler.go | 131 ++++ backend-go/internal/handler/cadre_handler.go | 143 ++++ backend-go/internal/handler/class_handler.go | 271 +++++++ backend-go/internal/handler/config_handler.go | 44 ++ backend-go/internal/handler/handler_utils.go | 20 + backend-go/internal/handler/parent_handler.go | 115 +++ .../internal/handler/semester_handler.go | 230 ++++++ .../internal/handler/student_handler.go | 192 +++++ .../internal/handler/subject_handler.go | 152 ++++ .../internal/handler/super_admin_handler.go | 56 ++ backend-go/internal/middleware/access_log.go | 57 ++ backend-go/internal/middleware/auth.go | 227 ++++++ backend-go/internal/middleware/sanitize.go | 131 ++++ backend-go/internal/model/admin_role.go | 36 + backend-go/internal/model/assignment.go | 53 ++ backend-go/internal/model/attendance.go | 38 + backend-go/internal/model/class_model.go | 60 ++ backend-go/internal/model/conduct.go | 44 ++ backend-go/internal/model/log.go | 50 ++ backend-go/internal/model/semester.go | 88 +++ backend-go/internal/model/student.go | 37 + backend-go/internal/model/subject.go | 29 + backend-go/internal/model/super_admin.go | 32 + backend-go/internal/model/system_setting.go | 26 + backend-go/internal/model/user.go | 34 + .../internal/repository/admin_role_repo.go | 112 +++ .../internal/repository/assignment_repo.go | 168 +++++ .../internal/repository/attendance_repo.go | 184 +++++ backend-go/internal/repository/class_repo.go | 184 +++++ .../internal/repository/conduct_repo.go | 294 ++++++++ backend-go/internal/repository/log_repo.go | 91 +++ .../internal/repository/semester_repo.go | 291 ++++++++ .../internal/repository/student_repo.go | 230 ++++++ .../internal/repository/subject_repo.go | 104 +++ .../internal/repository/super_admin_repo.go | 110 +++ .../repository/system_setting_repo.go | 100 +++ backend-go/internal/repository/user_repo.go | 166 +++++ backend-go/internal/router/router.go | 206 ++++++ backend-go/internal/schema/admin.go | 33 + backend-go/internal/schema/attendance.go | 30 + backend-go/internal/schema/auth.go | 26 + backend-go/internal/schema/class.go | 44 ++ backend-go/internal/schema/conduct.go | 43 ++ backend-go/internal/schema/ranking.go | 50 ++ backend-go/internal/schema/semester.go | 38 + backend-go/internal/schema/student.go | 54 ++ backend-go/internal/schema/subject.go | 27 + backend-go/internal/service/admin_service.go | 452 ++++++++++++ .../internal/service/attendance_service.go | 226 ++++++ backend-go/internal/service/auth_service.go | 461 ++++++++++++ backend-go/internal/service/class_service.go | 224 ++++++ .../internal/service/conduct_service.go | 384 ++++++++++ backend-go/internal/service/config_service.go | 49 ++ backend-go/internal/service/log_service.go | 70 ++ .../internal/service/ranking_service.go | 80 +++ .../internal/service/semester_service.go | 665 ++++++++++++++++++ .../internal/service/student_service.go | 171 +++++ .../internal/service/subject_service.go | 92 +++ .../internal/service/super_admin_service.go | 158 +++++ backend-go/internal/service/utils.go | 25 + backend-go/pkg/crypto/password.go | 110 +++ backend-go/pkg/database/mysql.go | 71 ++ backend-go/pkg/database/redis.go | 80 +++ backend-go/pkg/jwt/jwt.go | 93 +++ backend-go/pkg/logger/logger.go | 64 ++ backend-go/pkg/response/response.go | 106 +++ backend/.env.example | 150 ---- backend/config.py | 91 --- backend/main.py | 142 ---- backend/middleware/__init__.py | 11 - backend/middleware/auth_middleware.py | 144 ---- backend/middleware/permission.py | 214 ------ backend/middleware/sanitize.py | 138 ---- backend/models/__init__.py | 11 - backend/models/admin_role.py | 62 -- backend/models/attendance.py | 103 --- backend/models/conduct.py | 392 ----------- backend/models/log.py | 64 -- backend/models/semester.py | 297 -------- backend/models/student.py | 205 ------ backend/models/subject.py | 93 --- backend/models/user.py | 115 --- backend/requirements.txt | 11 - backend/routes/__init__.py | 11 - backend/routes/admin.py | 650 ----------------- backend/routes/auth.py | 107 --- backend/routes/config.py | 29 - backend/routes/debug.py | 73 -- backend/routes/parent.py | 95 --- backend/routes/semester.py | 254 ------- backend/routes/student.py | 138 ---- backend/routes/subject.py | 55 -- backend/routes/upgrade.py | 356 ---------- backend/schemas/__init__.py | 11 - backend/schemas/admin.py | 111 --- backend/schemas/auth.py | 55 -- backend/schemas/config.py | 22 - backend/schemas/semester.py | 27 - backend/schemas/student.py | 65 -- backend/schemas/subject.py | 43 -- backend/services/__init__.py | 11 - backend/services/admin_service.py | 344 --------- backend/services/attendance_service.py | 145 ---- backend/services/auth_service.py | 186 ----- backend/services/conduct_service.py | 352 --------- backend/services/log_service.py | 57 -- backend/services/parent_service.py | 131 ---- backend/services/semester_service.py | 456 ------------ backend/services/student_service.py | 140 ---- backend/services/subject_service.py | 82 --- backend/utils/__init__.py | 11 - backend/utils/database.py | 150 ---- backend/utils/jwt_handler.py | 87 --- backend/utils/logger.py | 102 --- backend/utils/redis_client.py | 140 ---- backend/utils/response.py | 106 --- backend/utils/security.py | 173 ----- docs/cadre.md | 167 ----- docs/guide/cadre.md | 49 -- docs/guide/parent.md | 67 -- docs/guide/student.md | 72 -- docs/guide/teacher.md | 47 -- docs/parent.md | 149 ---- docs/student.md | 199 ------ docs/teacher.md | 295 -------- frontend/.env.example | 13 +- frontend/admin/admins.php | 4 +- frontend/admin/attendance.php | 4 +- frontend/admin/cadre_homework.php | 125 ++++ frontend/admin/class_settings.php | 439 ++++++++++++ frontend/admin/classes.php | 214 ++++++ frontend/admin/conduct.php | 6 +- frontend/admin/dashboard.php | 4 +- frontend/admin/history.php | 11 +- frontend/admin/homework.php | 4 +- frontend/admin/password.php | 4 +- frontend/admin/rankings.php | 91 +++ frontend/admin/semesters.php | 66 +- frontend/admin/students.php | 10 +- frontend/admin/subjects.php | 4 +- frontend/api/check_upgrade.php | 4 +- frontend/api/clear_session.php | 6 +- frontend/api/execute_upgrade.php | 18 +- frontend/api/save_session.php | 66 +- frontend/assets/css/admin.css | 4 +- frontend/assets/css/style.css | 8 +- frontend/assets/js/admins.js | 2 +- frontend/assets/js/attendance-manage.js | 2 +- frontend/assets/js/cadre-homework.js | 159 +++++ frontend/assets/js/common.js | 26 +- frontend/assets/js/conduct.js | 51 +- frontend/assets/js/dashboard.js | 2 +- frontend/assets/js/history.js | 142 ++-- frontend/assets/js/homework-manage.js | 4 +- frontend/assets/js/modules/admin-mgmt.js | 4 +- frontend/assets/js/modules/modal-utils.js | 4 +- frontend/assets/js/modules/points-mgmt.js | 4 +- frontend/assets/js/modules/student-mgmt.js | 12 +- frontend/assets/js/modules/subject-mgmt.js | 4 +- frontend/assets/js/modules/utils.js | 4 +- frontend/assets/js/parent.js | 4 +- frontend/assets/js/rankings.js | 59 ++ frontend/assets/js/semesters.js | 83 ++- frontend/assets/js/student-homework.js | 2 +- frontend/assets/js/student.js | 4 +- frontend/assets/js/students-manage.js | 6 +- frontend/assets/uploads/sample_import.json | 70 +- frontend/config.php | 34 +- frontend/includes/footer.php | 21 +- frontend/includes/header.php | 77 +- frontend/includes/nav.php | 22 +- frontend/index.php | 23 +- frontend/parent/attendance.php | 5 +- frontend/parent/dashboard.php | 5 +- frontend/parent/history.php | 5 +- frontend/parent/password.php | 106 +++ frontend/student/attendance.php | 4 +- frontend/student/dashboard.php | 21 +- frontend/student/homework.php | 4 +- frontend/student/password.php | 4 +- frontend/student/semester_history.php | 4 +- frontend/super-admin/login.php | 134 ++++ sql/init.sql | 262 ++++++- sql/upgrades/v1.0.sql | 340 ++++++--- sql/upgrades/v1.1.sql | 97 --- sql/upgrades/v1.2.sql | 41 -- sql/upgrades/v1.3.sql | 22 - sql/upgrades/v1.4.sql | 19 - sql/upgrades/v1.5.sql | 30 - sql/upgrades/v1.6.sql | 63 -- sql/upgrades/v1.7.sql | 31 - sql/upgrades/v1.8.sql | 131 ---- sql/upgrades/v2.0.1.sql | 6 - sql/upgrades/v2.0.sql | 145 ---- sql/upgrades/v2.1.sql | 167 ++--- sql/upgrades/v2.2.sql | 11 - sql/upgrades/v2.3.sql | 11 - sql/upgrades/v2.4.sql | 11 - sql/upgrades/v2.5.1.sql | 11 - sql/upgrades/v2.5.sql | 12 - sql/upgrades/v2.6.sql | 12 - sql/upgrades/v2.7.sql | 2 - upgrade.php | 8 +- 214 files changed, 12622 insertions(+), 9725 deletions(-) create mode 100644 backend-go/.env.example create mode 100644 backend-go/Makefile create mode 100644 backend-go/cmd/server/main.go create mode 100644 backend-go/go.mod create mode 100644 backend-go/internal/config/config.go create mode 100644 backend-go/internal/handler/admin_handler.go create mode 100644 backend-go/internal/handler/auth_handler.go create mode 100644 backend-go/internal/handler/cadre_handler.go create mode 100644 backend-go/internal/handler/class_handler.go create mode 100644 backend-go/internal/handler/config_handler.go create mode 100644 backend-go/internal/handler/handler_utils.go create mode 100644 backend-go/internal/handler/parent_handler.go create mode 100644 backend-go/internal/handler/semester_handler.go create mode 100644 backend-go/internal/handler/student_handler.go create mode 100644 backend-go/internal/handler/subject_handler.go create mode 100644 backend-go/internal/handler/super_admin_handler.go create mode 100644 backend-go/internal/middleware/access_log.go create mode 100644 backend-go/internal/middleware/auth.go create mode 100644 backend-go/internal/middleware/sanitize.go create mode 100644 backend-go/internal/model/admin_role.go create mode 100644 backend-go/internal/model/assignment.go create mode 100644 backend-go/internal/model/attendance.go create mode 100644 backend-go/internal/model/class_model.go create mode 100644 backend-go/internal/model/conduct.go create mode 100644 backend-go/internal/model/log.go create mode 100644 backend-go/internal/model/semester.go create mode 100644 backend-go/internal/model/student.go create mode 100644 backend-go/internal/model/subject.go create mode 100644 backend-go/internal/model/super_admin.go create mode 100644 backend-go/internal/model/system_setting.go create mode 100644 backend-go/internal/model/user.go create mode 100644 backend-go/internal/repository/admin_role_repo.go create mode 100644 backend-go/internal/repository/assignment_repo.go create mode 100644 backend-go/internal/repository/attendance_repo.go create mode 100644 backend-go/internal/repository/class_repo.go create mode 100644 backend-go/internal/repository/conduct_repo.go create mode 100644 backend-go/internal/repository/log_repo.go create mode 100644 backend-go/internal/repository/semester_repo.go create mode 100644 backend-go/internal/repository/student_repo.go create mode 100644 backend-go/internal/repository/subject_repo.go create mode 100644 backend-go/internal/repository/super_admin_repo.go create mode 100644 backend-go/internal/repository/system_setting_repo.go create mode 100644 backend-go/internal/repository/user_repo.go create mode 100644 backend-go/internal/router/router.go create mode 100644 backend-go/internal/schema/admin.go create mode 100644 backend-go/internal/schema/attendance.go create mode 100644 backend-go/internal/schema/auth.go create mode 100644 backend-go/internal/schema/class.go create mode 100644 backend-go/internal/schema/conduct.go create mode 100644 backend-go/internal/schema/ranking.go create mode 100644 backend-go/internal/schema/semester.go create mode 100644 backend-go/internal/schema/student.go create mode 100644 backend-go/internal/schema/subject.go create mode 100644 backend-go/internal/service/admin_service.go create mode 100644 backend-go/internal/service/attendance_service.go create mode 100644 backend-go/internal/service/auth_service.go create mode 100644 backend-go/internal/service/class_service.go create mode 100644 backend-go/internal/service/conduct_service.go create mode 100644 backend-go/internal/service/config_service.go create mode 100644 backend-go/internal/service/log_service.go create mode 100644 backend-go/internal/service/ranking_service.go create mode 100644 backend-go/internal/service/semester_service.go create mode 100644 backend-go/internal/service/student_service.go create mode 100644 backend-go/internal/service/subject_service.go create mode 100644 backend-go/internal/service/super_admin_service.go create mode 100644 backend-go/internal/service/utils.go create mode 100644 backend-go/pkg/crypto/password.go create mode 100644 backend-go/pkg/database/mysql.go create mode 100644 backend-go/pkg/database/redis.go create mode 100644 backend-go/pkg/jwt/jwt.go create mode 100644 backend-go/pkg/logger/logger.go create mode 100644 backend-go/pkg/response/response.go delete mode 100644 backend/.env.example delete mode 100644 backend/config.py delete mode 100644 backend/main.py delete mode 100644 backend/middleware/__init__.py delete mode 100644 backend/middleware/auth_middleware.py delete mode 100644 backend/middleware/permission.py delete mode 100644 backend/middleware/sanitize.py delete mode 100644 backend/models/__init__.py delete mode 100644 backend/models/admin_role.py delete mode 100644 backend/models/attendance.py delete mode 100644 backend/models/conduct.py delete mode 100644 backend/models/log.py delete mode 100644 backend/models/semester.py delete mode 100644 backend/models/student.py delete mode 100644 backend/models/subject.py delete mode 100644 backend/models/user.py delete mode 100644 backend/requirements.txt delete mode 100644 backend/routes/__init__.py delete mode 100644 backend/routes/admin.py delete mode 100644 backend/routes/auth.py delete mode 100644 backend/routes/config.py delete mode 100644 backend/routes/debug.py delete mode 100644 backend/routes/parent.py delete mode 100644 backend/routes/semester.py delete mode 100644 backend/routes/student.py delete mode 100644 backend/routes/subject.py delete mode 100644 backend/routes/upgrade.py delete mode 100644 backend/schemas/__init__.py delete mode 100644 backend/schemas/admin.py delete mode 100644 backend/schemas/auth.py delete mode 100644 backend/schemas/config.py delete mode 100644 backend/schemas/semester.py delete mode 100644 backend/schemas/student.py delete mode 100644 backend/schemas/subject.py delete mode 100644 backend/services/__init__.py delete mode 100644 backend/services/admin_service.py delete mode 100644 backend/services/attendance_service.py delete mode 100644 backend/services/auth_service.py delete mode 100644 backend/services/conduct_service.py delete mode 100644 backend/services/log_service.py delete mode 100644 backend/services/parent_service.py delete mode 100644 backend/services/semester_service.py delete mode 100644 backend/services/student_service.py delete mode 100644 backend/services/subject_service.py delete mode 100644 backend/utils/__init__.py delete mode 100644 backend/utils/database.py delete mode 100644 backend/utils/jwt_handler.py delete mode 100644 backend/utils/logger.py delete mode 100644 backend/utils/redis_client.py delete mode 100644 backend/utils/response.py delete mode 100644 backend/utils/security.py delete mode 100644 docs/cadre.md delete mode 100644 docs/guide/cadre.md delete mode 100644 docs/guide/parent.md delete mode 100644 docs/guide/student.md delete mode 100644 docs/guide/teacher.md delete mode 100644 docs/parent.md delete mode 100644 docs/student.md delete mode 100644 docs/teacher.md create mode 100644 frontend/admin/cadre_homework.php create mode 100644 frontend/admin/class_settings.php create mode 100644 frontend/admin/classes.php create mode 100644 frontend/admin/rankings.php create mode 100644 frontend/assets/js/cadre-homework.js create mode 100644 frontend/assets/js/rankings.js create mode 100644 frontend/parent/password.php create mode 100644 frontend/super-admin/login.php delete mode 100644 sql/upgrades/v1.1.sql delete mode 100644 sql/upgrades/v1.2.sql delete mode 100644 sql/upgrades/v1.3.sql delete mode 100644 sql/upgrades/v1.4.sql delete mode 100644 sql/upgrades/v1.5.sql delete mode 100644 sql/upgrades/v1.6.sql delete mode 100644 sql/upgrades/v1.7.sql delete mode 100644 sql/upgrades/v1.8.sql delete mode 100644 sql/upgrades/v2.0.1.sql delete mode 100644 sql/upgrades/v2.0.sql delete mode 100644 sql/upgrades/v2.2.sql delete mode 100644 sql/upgrades/v2.3.sql delete mode 100644 sql/upgrades/v2.4.sql delete mode 100644 sql/upgrades/v2.5.1.sql delete mode 100644 sql/upgrades/v2.5.sql delete mode 100644 sql/upgrades/v2.6.sql delete mode 100644 sql/upgrades/v2.7.sql diff --git a/.gitignore b/.gitignore index eca4a9a..6ccded7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,14 @@ # 环境变量 .env -backend/.env +backend-go/.env frontend/.env -# Python +# Go +backend-go/sharedclassmanager +backend-go/sharedclassmanager.exe +backend-go/logs/ + +# Python(旧后端残留) __pycache__/ *.py[cod] *$py.class @@ -44,6 +49,9 @@ Thumbs.db # CoStrict .cospec/ +plans/ +.roo/ +code-review_result/ # PDF docs/guide/cadre.pdf @@ -53,4 +61,4 @@ docs/guide/teacher.pdf qrcode.png # example -example \ No newline at end of file +example/ diff --git a/INSTALL.md b/INSTALL.md index acd14bb..dc5dadd 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,4 +1,4 @@ -# 班级操行分管理系统 - 安装部署指南 +# 多班级版班级管理系统 - 安装部署指南 ## 环境要求 @@ -11,7 +11,7 @@ ### 软件依赖 | 软件 | 版本 | 用途 | |------|------|------| -| Python | 3.9+ | 后端运行环境 | +| Go | 1.21+ | 后端运行环境 | | MySQL | 5.7+ | 数据存储 | | Redis | 6.0+ | 缓存、会话 | | Nginx | 1.18+ | Web服务器、反向代理 | @@ -36,150 +36,174 @@ url=https://download.bt.cn/install/installStable.sh;if [ -f /usr/bin/curl ];then | 软件名称 | 版本要求 | 用途 | |---------|---------|------| -| Nginx | 1.21+ | Web服务器 | +| Nginx | 1.18+ | Web服务器 | | MySQL | 5.7+ | 数据库 | | Redis | 6.0+ | 缓存服务 | | PHP | 8.0+ | 前端处理 | -| Python项目管理器 | 最新版 | 后端部署 | -### 3. 创建数据库 +### 3. 安装 Go 环境 + +在服务器上安装 Go 1.21+: + +```bash +# 下载 Go(以 1.21.0 为例,请替换为最新稳定版) +wget https://go.dev/dl/go1.21.0.linux-amd64.tar.gz + +# 解压到 /usr/local +sudo tar -C /usr/local -xzf go1.21.0.linux-amd64.tar.gz + +# 配置环境变量 +echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc +source ~/.bashrc + +# 验证安装 +go version +``` + +### 4. 创建数据库 在宝塔面板中: 1. 进入"数据库"菜单 2. 点击"添加数据库" 3. 填写数据库信息: - - 数据库名:`class_manager` - - 用户名:`class_user` + - 数据库名:`classmanagerdb` + - 用户名:`class_admin` - 密码:生成强密码并保存 4. 点击"导入",选择 `sql/init.sql` 文件导入 -### 4. 部署后端服务 +### 5. 部署 Go 后端 -#### 4.1 上传代码 +#### 5.1 上传代码 1. 进入宝塔面板"文件"菜单 2. 进入 `/www/wwwroot/` 目录 -3. 创建项目目录 `classmanager` -4. 上传或克隆代码到 `/www/wwwroot/classmanager` - -#### 4.2 使用Python项目管理器部署 - -1. 进入宝塔面板"网站 -> Python项目" -2. 点击"添加项目": - - 项目路径:`/www/wwwroot/classmanager/backend` - - Python版本:3.9+ - - 框架:FastAPI - - 启动方式:`uvicorn main:app --host 127.0.0.1 --port 8000 --workers 4` - - 项目名称:`classmanager_backend` - -#### 4.3 配置环境变量 - -在 `/www/wwwroot/classmanager/backend/` 目录下: +3. 上传或克隆代码到 `/www/wwwroot/SharedClassManager` ```bash -# 复制环境变量示例文件 -cp .env.example .env - -# 编辑配置 -vim .env +git clone https://hz-gitea.sea-studio.top/canglan/SharedClassManager.git /www/wwwroot/SharedClassManager ``` -根据实际环境修改以下配置: +#### 5.2 配置环境变量 + +```bash +cd /www/wwwroot/SharedClassManager/backend-go +cp .env.example .env +vim .env # 根据实际环境修改配置 +``` + +**必须修改的配置项**: - `DB_USER` - 数据库用户名 - `DB_PASSWORD` - 数据库密码 -- `REDIS_PASSWORD` - Redis密码(如有) -- `SECRET_KEY` - 应用密钥(32位以上随机字符串) - `JWT_SECRET_KEY` - JWT密钥(32位以上随机字符串) -- `DEBUG_PATH` - 调试入口路径(生产环境请修改为随机字符串) +- `PASSWORD_SALT` - 密码加密盐值 -### 5. 部署前端 +#### 5.3 编译并运行 -#### 5.1 上传前端代码 +```bash +cd /www/wwwroot/SharedClassManager/backend-go +go mod tidy +go build -o sharedclassmanager ./cmd/server +``` -将代码上传或克隆到 `/www/wwwroot/classmanager` +#### 5.4 使用 Systemd 管理服务 -#### 5.2 创建网站 +创建 systemd 服务文件: + +```bash +sudo vim /etc/systemd/system/sharedclassmanager.service +``` + +写入以下内容: + +```ini +[Unit] +Description=SharedClassManager Go Backend +After=network.target mysql.service redis.service + +[Service] +Type=simple +User=www-data +WorkingDirectory=/www/wwwroot/SharedClassManager/backend-go +ExecStart=/www/wwwroot/SharedClassManager/backend-go/sharedclassmanager +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +启动服务: + +```bash +sudo systemctl daemon-reload +sudo systemctl start sharedclassmanager +sudo systemctl enable sharedclassmanager +``` + +> 也可使用宝塔面板的"Python项目"管理器管理 Go 进程,将启动命令指向编译后的二进制文件即可。 + +### 6. 部署前端 + +#### 6.1 创建网站 1. 进入宝塔面板"网站"菜单 2. 点击"添加站点": - 域名:填写您的域名 - - 根目录:`/www/wwwroot/classmanager/frontend` + - 根目录:`/www/wwwroot/SharedClassManager/frontend` - PHP版本:8.0 -#### 5.3 配置伪静态 +#### 6.2 配置 Nginx 反向代理 -在站点设置中: -1. 点击"伪静态" -2. 选择"thinkphp"或添加以下规则: +在站点设置中,点击"配置文件",替换为以下内容: ```nginx -location / { - if (!-e $request_filename){ - rewrite ^(.*)$ /index.php?s=$1 last; - break; +server { + listen 80; + server_name your-domain.com; + root /www/wwwroot/SharedClassManager/frontend; + index index.php; + + # PHP 处理 + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + include snippets/fastcgi-php.conf; + fastcgi_pass unix:/var/run/php/php8.0-fpm.sock; + } + + # Go API 反向代理 + # 前后端通过 Nginx 反代同域通信,无需 CORS + location /api/ { + proxy_pass http://127.0.0.1:56789/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } ``` ---- - -**部署方式二:一体化部署(同域名)** - -如果希望前后端使用同一个域名(如 `https://your-domain.com`),需要配置反向代理: - -在站点设置中: -1. 点击"反向代理" -2. 添加反向代理: - - 目标URL:`http://127.0.0.1:8000` - - 发送域名:`$host` - - 代理目录:`/api/` 3. 前端 `.env` 配置: ``` API_BASE_URL=https://your-domain.com ``` ---- - -#### 5.4 配置伪静态(续) - -在站点设置中: -1. 点击"伪静态" -2. 选择"thinkphp"或添加以下规则: - -```nginx -location / { - if (!-e $request_filename){ - rewrite ^(.*)$ /index.php?s=$1 last; - break; - } -} -``` - -### 6. 配置SSL证书 +### 7. 配置 SSL 证书 1. 在站点设置中点击"SSL" 2. 选择"Let's Encrypt"免费证书 3. 勾选"强制HTTPS" -### 7. 初始化管理员账号 +### 8. 初始化系统管理员 -使用调试接口创建初始管理员(仅首次部署使用): +Go 后端首次启动时会**自动创建**超级管理员账号,登录信息从环境变量读取: -```bash -# 替换 your-domain.com 为您的域名 -# 替换 /a7k9x2m4q8w1e3r5t6y7u8i9o0p1z2x3 为 .env 中配置的 DEBUG_PATH +- **登录路径**:由 `SUPER_ADMIN_LOGIN_PATH` 配置(默认 `/super-admin/login`) +- **默认用户名**:由 `SUPER_ADMIN_DEFAULT_USERNAME` 配置(默认 `admin`) +- **默认密码**:由 `SUPER_ADMIN_DEFAULT_PASSWORD` 配置(默认 `Admin123`) -curl -X POST https://your-domain.com/a7k9x2m4q8w1e3r5t6y7u8i9o0p1z2x3 \ - -H "Content-Type: application/json" \ - -d '{ - "username": "admin", - "password": "Admin@123", - "real_name": "班主任", - "role_type": "班主任" - }' -``` - -**注意**:创建成功后,请立即登录系统修改密码,并在生产环境中禁用或修改 DEBUG_PATH。 +> **注意**:首次登录后请立即修改密码。 --- @@ -190,10 +214,10 @@ curl -X POST https://your-domain.com/a7k9x2m4q8w1e3r5t6y7u8i9o0p1z2x3 \ ```bash # Ubuntu/Debian sudo apt update -sudo apt install -y python3 python3-pip python3-venv mysql-server redis-server nginx php8.0 php8.0-fpm php8.0-mysql +sudo apt install -y golang-go mysql-server redis-server nginx php8.0 php8.0-fpm php8.0-mysql # CentOS -sudo yum install -y python3 python3-pip mysql-server redis nginx php php-fpm php-mysql +sudo yum install -y golang mysql-server redis nginx php php-fpm php-mysql ``` ### 2. 数据库配置 @@ -208,54 +232,53 @@ mysql -u root -p ``` ```sql -CREATE DATABASE class_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -CREATE USER 'class_user'@'localhost' IDENTIFIED BY 'YourStrongPassword'; -GRANT ALL PRIVILEGES ON class_manager.* TO 'class_user'@'localhost'; +CREATE DATABASE classmanagerdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'class_admin'@'localhost' IDENTIFIED BY 'YourStrongPassword'; +GRANT ALL PRIVILEGES ON classmanagerdb.* TO 'class_admin'@'localhost'; FLUSH PRIVILEGES; EXIT; ``` 导入初始化数据: ```bash -mysql -u class_user -p class_manager < sql/init.sql +mysql -u class_admin -p classmanagerdb < sql/init.sql ``` -### 3. 后端部署 +### 3. Go 后端部署 ```bash # 创建项目目录 -sudo mkdir -p /var/www/classmanager -sudo chown -R $USER:$USER /var/www/classmanager +sudo mkdir -p /www/wwwroot/SharedClassManager +sudo chown -R $USER:$USER /www/wwwroot/SharedClassManager -# 上传代码到 /var/www/classmanager/backend/ -cd /var/www/classmanager/backend - -# 创建虚拟环境 -python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt +# 上传代码 +cd /www/wwwroot/SharedClassManager/backend-go # 配置环境变量 cp .env.example .env vim .env # 根据实际情况修改配置 -# 使用Systemd管理服务 -sudo vim /etc/systemd/system/classmanager.service +# 编译 +go mod tidy +go build -o sharedclassmanager ./cmd/server + +# 使用 Systemd 管理服务 +sudo vim /etc/systemd/system/sharedclassmanager.service ``` -Systemd服务文件内容: +Systemd 服务文件内容: ```ini [Unit] -Description=ClassManager Backend -After=network.target +Description=SharedClassManager Go Backend +After=network.target mysql.service redis.service [Service] Type=simple User=www-data -WorkingDirectory=/var/www/classmanager/backend -Environment="PATH=/var/www/classmanager/backend/venv/bin" -ExecStart=/var/www/classmanager/backend/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 +WorkingDirectory=/www/wwwroot/SharedClassManager/backend-go +ExecStart=/www/wwwroot/SharedClassManager/backend-go/sharedclassmanager Restart=always +RestartSec=5 [Install] WantedBy=multi-user.target @@ -264,26 +287,21 @@ WantedBy=multi-user.target 启动服务: ```bash sudo systemctl daemon-reload -sudo systemctl start classmanager -sudo systemctl enable classmanager +sudo systemctl start sharedclassmanager +sudo systemctl enable sharedclassmanager ``` ### 4. 前端部署 -```bash -# 上传前端代码到 /var/www/classmanager/frontend/ -# 配置Nginx -sudo vim /etc/nginx/sites-available/classmanager -``` - -Nginx配置示例: +Nginx 配置示例: ```nginx server { listen 80; server_name your-domain.com; - root /var/www/classmanager/frontend; + root /www/wwwroot/SharedClassManager/frontend; index index.php; + # PHP 处理 location / { try_files $uri $uri/ /index.php?$query_string; } @@ -293,17 +311,20 @@ server { fastcgi_pass unix:/var/run/php/php8.0-fpm.sock; } + # Go API 反向代理 + # 前后端通过 Nginx 反代同域通信,无需 CORS location /api/ { - proxy_pass http://127.0.0.1:8000/; + proxy_pass http://127.0.0.1:56789/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } ``` 启用站点: ```bash -sudo ln -s /etc/nginx/sites-available/classmanager /etc/nginx/sites-enabled/ +sudo ln -s /etc/nginx/sites-available/sharedclassmanager /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl restart nginx ``` @@ -312,51 +333,118 @@ sudo systemctl restart nginx ## 环境变量说明 -后端 `.env` 文件主要配置项: +Go 后端 `.env` 文件全部配置项(参考 `backend-go/.env.example`): +### 应用配置 | 配置项 | 说明 | 示例 | |-------|------|------| -| `APP_NAME` | 应用名称 | 班级操行分管理系统 | +| `APP_NAME` | 应用名称 | 多班级版班级管理系统 | +| `APP_ENV` | 运行环境 | production / development | | `DEBUG` | 调试模式 | false(生产环境) | -| `SECRET_KEY` | 应用密钥 | 32位以上随机字符串 | +| `APP_PORT` | 服务端口 | 56789 | + +### MySQL 数据库 +| 配置项 | 说明 | 示例 | +|-------|------|------| | `DB_HOST` | 数据库地址 | localhost | -| `DB_USER` | 数据库用户名 | class_user | +| `DB_PORT` | 数据库端口 | 3306 | +| `DB_USER` | 数据库用户名 | class_admin | | `DB_PASSWORD` | 数据库密码 | YourPassword | -| `DB_NAME` | 数据库名 | class_manager | +| `DB_NAME` | 数据库名 | classmanagerdb | +| `DB_MAX_OPEN_CONNS` | 最大打开连接数 | 25 | +| `DB_MAX_IDLE_CONNS` | 最大空闲连接数 | 10 | +| `DB_CONN_MAX_LIFETIME` | 连接最大生命周期(秒) | 300 | + +### Redis 缓存 +| 配置项 | 说明 | 示例 | +|-------|------|------| | `REDIS_HOST` | Redis地址 | localhost | +| `REDIS_PORT` | Redis端口 | 6379 | | `REDIS_PASSWORD` | Redis密码 | 可选 | +| `REDIS_DB` | Redis数据库编号 | 0 | +| `REDIS_MAX_CONNECTIONS` | 最大连接数 | 500 | + +### JWT 认证 +| 配置项 | 说明 | 示例 | +|-------|------|------| | `JWT_SECRET_KEY` | JWT密钥 | 32位以上随机字符串 | -| `DEBUG_PATH` | 调试入口路径 | /random-string | -| `DEDUCTION_HOMEWORK_NOT_SUBMIT` | 作业未提交扣分 | 2 | -| `DEDUCTION_HOMEWORK_LATE` | 作业迟交扣分 | 1 | -| `DEDUCTION_ATTENDANCE_ABSENT` | 缺勤扣分 | 5 | -| `DEDUCTION_ATTENDANCE_LATE` | 迟到扣分 | 2 | -| `DEDUCTION_ATTENDANCE_LEAVE` | 请假扣分 | 1 | +| `JWT_ALGORITHM` | JWT算法 | HS256 | +| `JWT_EXPIRE_MINUTES` | Token过期时间(分钟) | 60 | +| `JWT_IDLE_TIMEOUT_MINUTES` | 空闲超时时间(分钟) | 10 | + +### 密码加密 +| 配置项 | 说明 | 示例 | +|-------|------|------| +| `PASSWORD_SALT` | 密码加密盐值 | your-fixed-salt-string | + +### 系统管理员 +| 配置项 | 说明 | 示例 | +|-------|------|------| +| `SUPER_ADMIN_LOGIN_PATH` | 超级管理员登录路径 | /super-admin/login | +| `SUPER_ADMIN_DEFAULT_USERNAME` | 默认用户名 | admin | +| `SUPER_ADMIN_DEFAULT_PASSWORD` | 默认密码 | Admin123 | + +### 日志配置 +| 配置项 | 说明 | 示例 | +|-------|------|------| +| `LOG_LEVEL` | 日志级别 | info | +| `LOG_FILE` | 日志文件路径 | logs/app.log | + +> **注意**:扣分规则等班级级配置已迁移到数据库 `class_settings` 表中,班主任可在管理端"班级设置"页面修改,无需修改环境变量。 + +--- + +## 初始化系统管理员 + +Go 后端首次启动时会**自动创建**超级管理员账号,无需手动操作: + +1. 确认 `.env` 中以下配置项已正确设置: + - `SUPER_ADMIN_LOGIN_PATH` — 登录页面路径 + - `SUPER_ADMIN_DEFAULT_USERNAME` — 默认用户名 + - `SUPER_ADMIN_DEFAULT_PASSWORD` — 默认密码 +2. 启动 Go 后端服务 +3. 访问 `https://your-domain.com/{SUPER_ADMIN_LOGIN_PATH}` 登录 +4. 首次登录后请**立即修改密码** +5. 创建班级,然后为班级指定班主任 + +--- + +## 多班级使用流程 + +1. 系统管理员登录 → 创建班级 +2. 为班级添加班主任(管理员管理) +3. 班主任登录 → 导入学生 → 开始使用 +4. 班主任可在"班级设置"中自定义本班扣分规则和功能开关 --- ## 常见问题 ### Q1: 后端启动失败 -- 检查端口8000是否被占用 -- 检查数据库和Redis连接配置 -- 查看日志:`sudo journalctl -u classmanager -f` +- 检查端口 56789 是否被占用:`sudo lsof -i :56789` +- 检查数据库和 Redis 连接配置 +- 查看日志:`sudo journalctl -u sharedclassmanager -f` ### Q2: 前端页面空白或报错 -- 检查Nginx配置中的root路径 -- 检查PHP-FPM是否运行 -- 检查文件权限:`sudo chown -R www-data:www-data /var/www/classmanager` +- 检查 Nginx 配置中的 root 路径 +- 检查 PHP-FPM 是否运行:`sudo systemctl status php8.0-fpm` +- 检查文件权限:`sudo chown -R www-data:www-data /www/wwwroot/SharedClassManager` -### Q3: API请求404 -- 检查反向代理配置 -- 确认后端服务已启动 +### Q3: API 请求 404 +- 检查反向代理配置是否正确(`/api/` → `127.0.0.1:56789`) +- 确认 Go 后端服务已启动:`sudo systemctl status sharedclassmanager` - 检查防火墙设置 ### Q4: 数据库连接失败 -- 确认MySQL已启动 -- 检查用户名密码 +- 确认 MySQL 已启动 +- 检查 `.env` 中的数据库用户名、密码、数据库名 - 确认用户有数据库权限 +### Q5: Go 编译失败 +- 确认 Go 版本 >= 1.21:`go version` +- 执行 `go mod tidy` 拉取依赖 +- 检查网络连接(可能需要配置 Go 代理:`go env -w GOPROXY=https://goproxy.cn,direct`) + --- ## 技术支持 @@ -364,4 +452,4 @@ sudo systemctl restart nginx - 开发者: Canglan - 联系方式: admin@sea-studio.top - 版权归属: Sea Network Technology Studio -- 许可证: MIT License +- 许可证: Apache License 2.0 diff --git a/LICENSE b/LICENSE index f863cf0..6cc5745 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,199 @@ -MIT License -Copyright (c) 2026 CangLan + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + 1. Definitions. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work. + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. Please also get an + "Alarm or alarm" from your own alarm vendor. + + Copyright 2025 Sea Network Technology Studio + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 0a2c283..36857df 100644 --- a/README.md +++ b/README.md @@ -1,285 +1,233 @@ -# 班级操行分管理系统 +# 多班级版班级管理系统 v1.0 -基于 Python FastAPI 开发的班级操行分管理系统,支持学生端、管理端、家长端三端访问,实现操行分管理、作业提交跟踪、考勤记录等功能。 +基于 Go (Gin + GORM) + PHP + MySQL 5.7 + Redis 开发的多班级操行分管理系统,支持多班级完全隔离,包含系统管理员、学生、管理端、家长端四端访问。 -## 主要功能 +## 技术栈 -### 学生端 -- 查询个人当前操行总分 -- 查看个人加减分历史明细(时间、分数变化、原因、操作人) -- 查看个人作业提交情况 -- 查看个人考勤记录 -- 查看历史学期归档数据(操行分、考勤统计、作业统计) -- 修改个人登录密码(首次登录强制修改) +| 层级 | 技术 | 说明 | +|------|------|------| +| 后端 API | **Go (Gin + GORM)** | 高性能、编译型、并发友好,适合中小规模管理系统 | +| 数据库 | **MySQL 5.7+** | 成熟稳定、事务支持完善、utf8mb4 全字符集支持 | +| 缓存 | **Redis 6.0+** | Token 管理、登录限流、会话缓存 | +| 前端 | **PHP 8.0+ + JavaScript** | 零构建依赖、部署简单、与后端 API 解耦,适合校园网络环境 | -### 家长端 -- 查询子女当前操行总分和班级排名 -- 查看子女操行分历史记录(加分/减分明细) -- 查看子女考勤记录 -- 默认仅显示当前学期数据 +## 功能特性 -### 管理端 +### 系统管理员(super_admin) +- 独立登录入口(路径可配置) +- 班级管理:创建/编辑/删除/启用禁用班级 +- 切换班级上下文:在不同班级间切换进行管理操作 +- 跨班级查看:查看所有班级的管理员和学生列表 +- 首次启动自动创建,无需手动初始化 + +### 管理端(班级内角色) **班主任权限:** - 学生管理:新增/编辑/删除学生、批量导入学生(JSON) -- 操行分管理:对学生进行加减分、撤销任何扣分记录、查看全班历史记录、导出德育分记录 +- 操行分管理:对学生进行加减分(无限制)、撤销任何扣分记录、查看全班历史记录 - 作业管理:发布作业、查看提交情况 -- 考勤管理:按时段(早上 7:15/中午 14:00/晚修 19:30)记录考勤、自定义考勤扣分值 +- 考勤管理:按时段(早上/中午/晚修)记录考勤 - 科目管理:动态增删学科 -- 管理员管理:添加/编辑/删除/重置密码班长/科代表/考勤委员/劳动委员/志愿委员 -- 学期管理:创建/编辑/删除学期、激活学期、归档学期(含考勤/作业统计快照)、关联历史记录、归档后可选重置分数 -- 排行榜百分比筛选:在排行榜上方输入百分比,筛选显示前N%的学生(抹零法) -- 数据导出:导出历史记录、导出德育分记录(含加分/减分历史) +- 管理员管理:添加/编辑/删除班干部、科任老师、课代表 +- 学期管理:创建/编辑/删除/激活/归档学期 +- 班级设置:修改扣分规则、功能开关、角色权限、加减分限制 +- 排行榜:查看分项排行(操行分、作业、考勤) +- 数据导出:导出德育分记录、历史记录 + +**科任老师权限(需配置科目):** +- 对所教科目学生进行加减分(±5分以内,可在班级设置中配置) +- 查看所教科目的作业管理 +- 查看全班历史记录 **班长权限:** -- 操行分管理:对学生进行加减分(±5分以内)、撤销任何人的扣分记录、查看全班历史记录 +- 对学生进行加减分(±5分以内,可在班级设置中配置) +- 撤销任何操行分记录 +- 查看全班历史记录 **学习委员权限:** -- 作业管理:更新作业提交状态、关联扣分(仅扣分,按规则) -- 科目管理:动态增删学科 -- 历史记录:仅查看自己提交的操作记录 +- 对学生进行加减分(±5分以内,可在班级设置中配置) +- 科目管理 +- 作业管理 **考勤委员权限:** -- 考勤管理:按时段(早上 7:15/中午 14:00/晚修 19:30)记录考勤状态、关联扣分(仅扣分,按规则) -- 历史记录:仅查看自己提交的操作记录 +- 考勤管理 +- 考勤扣分(仅扣分,上限8分) +- 可撤销自己创建的记录 **劳动委员权限:** -- 操行分管理:以卫生值日为理由进行加减分(固定 ±1 分) -- 历史记录:仅查看自己提交的操作记录 +- 对学生进行加减分(±1分以内) **志愿委员权限:** -- 操行分管理:以服务时长为由进行加分(仅加分) -- 历史记录:仅查看自己提交的操作记录 +- 仅可加分(上限5分) +- 查看全班历史记录 -## 技术栈 -## 安全特性 +**课代表权限:** +- 管理所代表科目的作业(管理端页面) +- 由学习委员/班主任/科任老师设定 -- JWT Token + PHP Session 双轨制认证 -- Redis 管理登录态,支持空闲超时自动失效 -- 全链路输入校验:Pydantic Schema 层(正则/长度/范围约束)+ Service 层(业务逻辑校验) -- 输入过滤中间件(XSS/SQL 注入防护) -- 密码 bcrypt 加密存储 -- 操作日志记录 +### 学生端 +- 查询个人当前操行总分和班级排名 +- 查看个人加减分历史明细 +- 查看个人作业提交情况 +- 查看个人考勤记录 +- 查看历史学期归档数据 +- 修改个人登录密码 -## 技术栈 +### 家长端 +- 查询子女当前操行总分和班级排名 +- 查看子女操行分历史记录 +- 查看子女考勤记录 +- 修改密码(受班级功能开关控制) -| 层级 | 技术 | 版本 | -|------|------|------| -| 后端 | Python | 3.13.x | -| 后端框架 | FastAPI | 0.104+ | -| 数据库 | MySQL | 5.7 | -| 缓存 | Redis | 7.x | -| 前端 | PHP | 8.0 | -| Web服务器 | Nginx | 1.28+ | -## 文件结构 +## 角色权限矩阵 + +| 功能 | 班主任 | 科任老师 | 班长 | 学习委员 | 考勤委员 | 劳动委员 | 志愿委员 | 课代表 | +|------|--------|---------|------|---------|---------|---------|---------|--------| +| 操行分管理 | ✓ 无限制 | ±5分 | ±5分 | ±5分 | 仅扣分 | ±1分 | 仅加分 | - | +| 历史记录 | 全部(可撤销) | 自己的 | 全部(可撤销) | 自己的 | 自己的 | 自己的 | 自己的 | - | +| 作业管理 | ✓ | 所教科目 | - | ✓ | - | - | - | 所教科目 | +| 考勤管理 | ✓ | - | - | - | ✓ | - | - | - | +| 科目管理 | ✓ | - | - | ✓ | - | - | - | - | +| 学生管理 | ✓ | - | - | - | - | - | - | - | +| 管理员管理 | ✓ | - | - | - | - | - | - | - | +| 学期管理 | ✓ | - | - | - | - | - | - | - | +| 班级设置 | ✓ | - | - | - | - | - | - | - | +| 排行榜 | ✓ | - | - | - | - | - | - | - | + +> 加减分上下限可在班级设置中由班主任自行配置。 + +## 多班级隔离机制 ``` -classmanager/ -│ -├── backend/ # Python FastAPI 后端 -│ ├── .env.example # 后端环境变量示例 -│ ├── .gitignore # Git 忽略文件 -│ ├── config.py # 配置管理 -│ ├── main.py # FastAPI 主入口 -│ ├── requirements.txt # Python 依赖 -│ │ -│ ├── logs/ # 日志目录 -│ │ ├── access.log -│ │ ├── app.log -│ │ ├── error.log -│ │ └── operation.log -│ │ -│ ├── middleware/ # 中间件 -│ │ ├── __init__.py -│ │ ├── auth_middleware.py # JWT 认证中间件 -│ │ ├── permission.py # 权限验证中间件 -│ │ └── sanitize.py # 输入过滤中间件 -│ │ -│ ├── models/ # 数据模型 -│ │ ├── __init__.py -│ │ ├── admin_role.py # 管理员角色模型 -│ │ ├── attendance.py # 考勤模型 -│ │ ├── conduct.py # 操行分模型 -│ │ ├── homework.py # 作业模型 -│ │ ├── log.py # 日志模型 -│ │ ├── semester.py # 学期模型 -│ │ ├── student.py # 学生模型 -│ │ ├── subject.py # 科目模型 -│ │ └── user.py # 用户模型 -│ │ -│ ├── routes/ # API 路由 -│ │ ├── __init__.py -│ │ ├── admin.py # 管理端接口 -│ │ ├── auth.py # 认证接口 -│ │ ├── debug.py # 调试入口 -│ │ ├── parent.py # 家长端接口 -│ │ ├── semester.py # 学期管理接口 -│ │ ├── student.py # 学生端接口 -│ │ └── subject.py # 科目管理接口 -│ │ -│ ├── schemas/ # Pydantic 模型 -│ │ ├── __init__.py -│ │ ├── admin.py -│ │ ├── auth.py -│ │ ├── common.py -│ │ ├── conduct.py -│ │ ├── parent.py -│ │ ├── semester.py # 学期请求模型 -│ │ ├── student.py -│ │ └── subject.py -│ │ -│ ├── services/ # 业务逻辑层 -│ │ ├── __init__.py -│ │ ├── admin_service.py -│ │ ├── attendance_service.py -│ │ ├── auth_service.py -│ │ ├── conduct_service.py -│ │ ├── homework_service.py -│ │ ├── log_service.py -│ │ ├── parent_service.py -│ │ ├── semester_service.py # 学期服务 -│ │ ├── student_service.py -│ │ └── subject_service.py -│ │ -│ └── utils/ # 工具类 -│ ├── __init__.py -│ ├── database.py # MySQL 连接池 -│ ├── jwt_handler.py # JWT 处理 -│ ├── logger.py # 日志轮转 -│ ├── redis_client.py # Redis 客户端 -│ ├── response.py # 统一响应 -│ └── security.py # 密码加密 -│ -├── frontend/ # PHP 前端 -│ ├── .env.example # 前端环境变量示例 -│ ├── .htaccess # Apache 配置(可选) -│ ├── config.php # 前端配置 -│ ├── index.php # 登录入口 -│ │ -│ ├── admin/ # 管理端 -│ │ ├── admins.php # 管理员管理 -│ │ ├── attendance.php # 考勤管理 -│ │ ├── conduct.php # 操行分管理 -│ │ ├── dashboard.php # 管理端首页 -│ │ ├── history.php # 历史记录 -│ │ ├── homework.php # 作业管理 -│ │ ├── password.php # 修改密码 -│ │ ├── semesters.php # 学期管理 -│ │ ├── students.php # 学生管理 -│ │ └── subjects.php # 科目管理 -│ │ -│ ├── api/ # 内部 API -│ │ └── save_session.php # Session 保存接口 -│ │ -│ ├── assets/ # 静态资源 -│ │ ├── css/ -│ │ │ ├── admin.css # 管理端样式 -│ │ │ └── style.css # 全局样式 -│ │ ├── js/ -│ │ │ ├── admin.js # 管理端 JS -│ │ │ ├── common.js # 公共 JS -│ │ │ ├── parent.js # 家长端 JS -│ │ │ └── student.js # 学生端 JS -│ │ └── uploads/ -│ │ └── sample_import.json # 学生导入示例 -│ │ -│ ├── includes/ # 公共包含文件 -│ │ ├── footer.php # 公共底部 -│ │ ├── header.php # 公共头部 -│ │ └── nav.php # 导航栏 -│ │ -│ ├── parent/ # 家长端 -│ │ ├── attendance.php # 考勤记录 -│ │ ├── dashboard.php # 家长端首页 -│ │ └── history.php # 历史记录 -│ │ -│ └── student/ # 学生端 -│ ├── attendance.php # 考勤记录 -│ ├── conduct.php # 操行分详情 -│ ├── dashboard.php # 学生端首页 -│ ├── homework.php # 作业情况 -│ ├── password.php # 修改密码 -│ └── semester_history.php # 学期记录 -│ -├── sql/ # 数据库脚本 -│ └── init.sql # 初始化表结构 -│ -├── docs/ # 文档 -│ ├── student.md # 学生端详细文档 -│ ├── parent.md # 家长端详细文档 -│ ├── teacher.md # 班主任详细文档 -│ ├── cadre.md # 班干部详细文档 -│ └── guide/ # 快速使用说明 -│ ├── student.md -│ ├── parent.md -│ ├── teacher.md -│ └── cadre.md -│ -├── .gitignore -├── INSTALL.md # 安装部署文档 -├── LICENSE # MIT 许可证 -└── README.md # 项目说明 +系统管理员 (super_admin) +├── JWT 中 class_id 可变(通过 /api/class/switch 切换) +├── 可管理所有班级 +└── 权限检查自动放行 +班级管理员 (admin) — 班主任/班长/科任老师/课代表等 +├── admin_roles 绑定 class_id +├── JWT 中 class_id 固定 +├── 所有查询自动过滤 class_id +└── 严格隔离在本班内 + +学生/家长 +├── 通过 student.class_id 确定所属班级 +└── 只能看到本班数据 ``` -## 角色权限一览表 +## 班级设置 -| 角色 | 操行分查看 | 操行分加减 | 撤销扣分 | 历史记录查看 | 其他权限 | -|------|-----------|-----------|---------|-------------|---------| -| 班主任 | 全班 | 无限制 | 可撤销任何记录 | 全班所有记录 | 学生/管理员/科目管理、数据导出 | -| 班长 | 全班 | ±5分 | 可撤销任何记录 | 全班所有记录 | - | -| 学习委员 | 全班 | ±5分以内(加减分) | 不可撤销 | 仅自己提交的 | 作业管理、科目管理 | -| 考勤委员 | 全班 | 仅扣分,最多扣8分 | 不可撤销 | 仅自己提交的 | 考勤管理 | -| 劳动委员 | 全班 | ±1分以内 | 不可撤销 | 仅自己提交的 | - | -| 志愿委员 | 全班 | 仅加分,最多+5分 | 不可撤销 | 仅自己提交的 | - | -| 学生 | 自己 | 无 | 无 | 自己的历史 | 修改密码 | -| 家长 | 子女总分 | 无 | 无 | 不可见详情 | - | +每个班级可独立配置以下内容(班主任可在管理端修改): -## 密码要求 +### 扣分规则 +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| student_initial_points | 学生初始操行分 | 60 | +| deduction_homework_not_submit | 作业未提交扣分 | 2 | +| deduction_homework_late | 作业迟交扣分 | 1 | +| deduction_attendance_absent | 缺勤扣分 | 3 | +| deduction_attendance_late | 迟到扣分 | 1 | +| deduction_attendance_leave | 请假扣分 | 0 | -- 长度:6-20位 -- 复杂度:必须包含大写字母、小写字母、数字、特殊符号中的至少3种 -- 示例有效密码:`Hello1!`、`Abc123#`、`Test@99` +### 功能开关 +| 功能标识 | 说明 | 默认 | +|----------|------|------| +| homework | 作业管理 | 启用 | +| attendance | 考勤管理 | 启用 | +| ranking | 排行榜 | 启用 | +| dormitory | 宿舍管理 | 启用 | +| parent_password | 家长改密功能 | 启用 | -## 安装部署 +### 角色开关 +班主任可在班级设置中启用或禁用各角色(班长、学习委员、考勤委员、劳动委员、志愿委员、科任老师、课代表),禁用后该角色不可被分配。 -详见 [INSTALL.md](INSTALL.md) +### 加减分限制 +班主任可在班级设置中配置各角色的加减分上下限,灵活控制权限范围。 -## 使用说明 +## 排行榜分项排行 -详细文档: +管理端排行榜支持以下分项查看: +- **操行分排行**:按当前操行分排名 +- **作业排行**:按作业完成情况排名 +- **考勤排行**:按出勤率排名 -- 学生端详见 [docs/student.md](docs/student.md) -- 家长端详见 [docs/parent.md](docs/parent.md) -- 班主任详见 [docs/teacher.md](docs/teacher.md) -- 班干部详见 [docs/cadre.md](docs/cadre.md) +排行榜支持百分比筛选(如显示前 10% 的学生)。 -快速使用指南: +## 超级管理员独立登录 -- [学生端](docs/guide/student.md) / [家长端](docs/guide/parent.md) / [班主任](docs/guide/teacher.md) / [班干部](docs/guide/cadre.md) +超级管理员通过独立路径登录,与普通用户登录入口分离: +- 登录路径通过 `.env` 中的 `SUPER_ADMIN_LOGIN_PATH` 配置 +- 默认路径:`/super-admin/login` +- 首次启动自动创建,默认账号:`admin` / `Admin123` -## 版本 +## 家长登录账号 -| 版本 | 发布日期 | 说明 | -|------|---------|------| -| v1.0 | 2026.4.19 | 初始版本发布,包含基础功能 | -| v1.1 | 2026.4.20 | 更新家长端查看加减分记录功能 | -| v1.2 | 2026.4.22 | 学期管理、env配置加减分上限、排行榜百分比筛选、撤销操作日志、调试入口开关 | -| v1.3 | 2026.4.27 | 考勤时段系统(早上/中午/晚修三时段)、历史记录扣分类型筛选、管理员/科目信息编辑、全链路输入安全校验 | -| v1.4 | 2026.4.28 | 全量代码审查修复:双重密码哈希bug、学生端XSS漏洞、性能优化、Pydantic schema统一、权限检查补全、考勤委员撤销权限 | -| v1.5 | 2026.4.29 | 登录错误封禁5分钟+手动解锁、加减分回显修复、权限限制修复、按钮样式补全 | -| v1.6 | 2026.4.29 | 权限修复:考勤委员提示遗漏、历史记录权限泄露、时间筛选失效、作业页分数限制与后端同步 | -| v1.7 | 2026.5.21 | 全量一致性审计:前后端配置统一(.env.example/config.py/config.php)、清理废弃全局变量、角色权限表精确化 | -| v1.8 | 2026.5.22 | 科目管理融入作业管理页、科目删除数据依赖检查、加减分记录类型区分(manual/homework/attendance)、学生端作业详情优化 | -| v2.0.1 | 2026.5.23 | 操作列折叠优化、扣分类型大类区分、科目选择修复、改名作业扣分、记录人优化、家长端优化、学期管理优化 | -| v2.1 | 2026.5.26 | CSS变量化统一配色方案、简化按钮系统、操作列按钮风格统一、清理内联颜色、修复科目管理面板无法展开、数据库索引优化、清理init.sql冗余迁移代码、安全审计通过 | -| v2.2 | 2026.5.27 | 安全修复:管理员操作越权漏洞修复、新增宿舍集体加分功能、学生导入支持宿舍号、导入预览显示宿舍号列 | -| v2.3 | 2026.5.28 | 升级系统全面重构:修复前后端通信错误处理、SQL DELIMITER解析修复、XSS防护、升级验证+自动重试+失败回滚机制、补全v1.0-v1.6增量升级脚本 | -| v2.5 | 2026.5.28 | 历史记录页优化(文字宽度/换行/合并按钮样式)、新增状态筛选项、合并记录批量撤销/反撤销、操作菜单底部遮挡修复、删除科目报错修复、学期自动关联+当前周数计算 | -| v2.5.1 | 2026.5.29 | 筛选学生时自动取消合并记录、合并记录选项样式修复(竖排显示)、历史记录筛选改为折叠式、科目管理调用修复、全局escapeHtml XSS转义修复 | -| v2.6 | 2026.5.29 | 历史记录筛选面板重构(学生筛选移入面板、合并按钮始终可见)、新增科目下拉筛选、新增原因关键词搜索、科目删除后默认仅显示启用科目、筛选面板展开修复 | -| v2.7 | 2026.5.29 | 操作菜单定位修复、历史记录显示修复、筛选面板修复、科目删除修复、学期周数列修复、escapeHtml 转义修复 | +学生导入时,`parent_account` 字段为家长登录账号,**推荐填写手机号**。系统会自动为该账号创建家长用户,初始密码与学生相同(默认 `123456`)。 + +示例导入 JSON 格式: +```json +{ + "students": [ + { + "student_no": "2025001", + "name": "张三", + "parent_account": "13800138001", + "dormitory_number": "A301", + "password": "123456" + } + ] +} +``` + +## 快速开始 + +详细部署指南请参阅 [INSTALL.md](INSTALL.md)。 + +### 环境要求 +- Go 1.21+ +- MySQL 5.7+ +- Redis 6.0+ +- Nginx 1.18+ +- PHP 8.0+ + +### 安装步骤 + +1. 克隆项目 +```bash +git clone https://hz-gitea.sea-studio.top/canglan/SharedClassManager.git +cd SharedClassManager +``` + +2. 初始化数据库 +```bash +mysql -u root -p < sql/init.sql +``` + +3. 配置并启动 Go 后端 +```bash +cd backend-go +cp .env.example .env +vim .env # 修改配置 +go mod tidy +go build -o sharedclassmanager ./cmd/server +./sharedclassmanager +``` + +4. 配置前端 +```bash +cd frontend +cp .env.example .env +# 编辑 .env 文件,配置 API 地址 +``` + +5. 配置 Nginx 反向代理(参考 [INSTALL.md](INSTALL.md)) ## 许可证 -本项目使用 [MIT License](LICENSE) 许可证 \ No newline at end of file +本项目采用 [Apache License 2.0](LICENSE) 许可证。 + +Copyright 2025 Sea Network Technology Studio + +## 开发者 + +Canglan — admin@sea-studio.top diff --git a/VERSION b/VERSION index 1effb00..d3827e7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.7 +1.0 diff --git a/backend-go/.env.example b/backend-go/.env.example new file mode 100644 index 0000000..9184370 --- /dev/null +++ b/backend-go/.env.example @@ -0,0 +1,67 @@ +# =========================================== +# 多班级版班级管理系统 - Go 后端配置 +# =========================================== + +# 应用名称 +APP_NAME=多班级版班级管理系统 +# 运行环境: production / development +APP_ENV=production +# 调试模式 +DEBUG=false +# 服务端口 +APP_PORT=56789 + +# =========================================== +# MySQL 数据库配置 +# =========================================== + +DB_HOST=localhost +DB_PORT=3306 +DB_USER=class_admin +DB_PASSWORD=YourPassword +DB_NAME=classmanagerdb +DB_MAX_OPEN_CONNS=25 +DB_MAX_IDLE_CONNS=10 +DB_CONN_MAX_LIFETIME=300 + +# =========================================== +# Redis 缓存配置 +# =========================================== + +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 +REDIS_MAX_CONNECTIONS=500 + +# =========================================== +# JWT 认证配置 +# =========================================== + +JWT_SECRET_KEY=your-32-char-secret-key +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=60 +JWT_IDLE_TIMEOUT_MINUTES=10 + +# =========================================== +# 密码加密配置(与 Python 版兼容) +# 算法: MD5(SHA1(password) + SALT) +# =========================================== + +PASSWORD_SALT=your-fixed-salt-string + +# =========================================== +# 系统管理员配置 +# =========================================== + +SUPER_ADMIN_LOGIN_PATH=/super-admin +SUPER_ADMIN_DEFAULT_USERNAME=admin +# ⚠️ 部署时必须修改为强密码,否则存在安全风险 +SUPER_ADMIN_DEFAULT_PASSWORD=Admin123 + +# =========================================== +# 日志配置 +# =========================================== + +LOG_LEVEL=info +LOG_FILE=logs/app.log diff --git a/backend-go/Makefile b/backend-go/Makefile new file mode 100644 index 0000000..3d25edf --- /dev/null +++ b/backend-go/Makefile @@ -0,0 +1,63 @@ +.PHONY: build run clean test lint fmt vet tidy + +# 应用名称 +APP_NAME=scm-server +# 入口目录 +CMD_DIR=./cmd/server +# 输出目录 +BUILD_DIR=./build + +# 默认目标 +all: build + +# 编译 +build: + @echo "==> 编译 $(APP_NAME)..." + @mkdir -p $(BUILD_DIR) + go build -o $(BUILD_DIR)/$(APP_NAME) $(CMD_DIR) + +# 运行 +run: + go run $(CMD_DIR)/main.go + +# 清理 +clean: + @echo "==> 清理构建产物..." + @rm -rf $(BUILD_DIR) + +# 测试 +test: + go test -v -count=1 ./... + +# 代码检查 +lint: fmt vet + +# 格式化 +fmt: + go fmt ./... + +# 静态分析 +vet: + go vet ./... + +# 整理依赖 +tidy: + go mod tidy + +# 开发模式(热重载需要安装 air) +dev: + @which air > /dev/null 2>&1 || (echo "请先安装 air: go install github.com/air-verse/air@latest" && exit 1) + air + +# 帮助 +help: + @echo "可用命令:" + @echo " make build - 编译项目" + @echo " make run - 直接运行" + @echo " make clean - 清理构建产物" + @echo " make test - 运行测试" + @echo " make lint - 代码检查 (fmt + vet)" + @echo " make fmt - 格式化代码" + @echo " make vet - 静态分析" + @echo " make tidy - 整理依赖" + @echo " make dev - 开发模式(需要 air)" diff --git a/backend-go/cmd/server/main.go b/backend-go/cmd/server/main.go new file mode 100644 index 0000000..693eb88 --- /dev/null +++ b/backend-go/cmd/server/main.go @@ -0,0 +1,210 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/handler" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/router" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +func main() { + // ========== 1. 加载配置 ========== + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err) + os.Exit(1) + } + + // ========== 2. 初始化日志 ========== + logger.Init(cfg.LogLevel, cfg.IsProduction()) + defer logger.Sync() + + logger.Sugared.Infof("应用启动: %s (env=%s, port=%s)", cfg.AppName, cfg.AppEnv, cfg.AppPort) + + // ========== 3. 初始化 MySQL ========== + mysqlDB, err := database.InitMySQL(cfg) + if err != nil { + logger.Sugared.Fatalf("初始化 MySQL 失败: %v", err) + } + logger.Sugared.Info("MySQL 连接成功") + + sqlDB, err := mysqlDB.DB() + if err != nil { + logger.Sugared.Fatalf("获取 sql.DB 失败: %v", err) + } + defer sqlDB.Close() + + // ========== 4. 初始化 Redis ========== + redisClient, err := database.InitRedis(cfg) + if err != nil { + logger.Sugared.Fatalf("初始化 Redis 失败: %v", err) + } + logger.Sugared.Info("Redis 连接成功") + defer redisClient.Close() + + // ========== 5. 初始化 Repository 层 ========== + userRepo := repository.NewUserRepo(mysqlDB) + studentRepo := repository.NewStudentRepo(mysqlDB) + adminRoleRepo := repository.NewAdminRoleRepo(mysqlDB) + classRepo := repository.NewClassRepo(mysqlDB) + conductRepo := repository.NewConductRepo(mysqlDB) + attendanceRepo := repository.NewAttendanceRepo(mysqlDB) + semesterRepo := repository.NewSemesterRepo(mysqlDB) + subjectRepo := repository.NewSubjectRepo(mysqlDB) + assignmentRepo := repository.NewAssignmentRepo(mysqlDB) + logRepo := repository.NewLogRepo(mysqlDB) + superAdminRepo := repository.NewSuperAdminRepo(mysqlDB) + settingRepo := repository.NewSystemSettingRepo(mysqlDB) + + // ========== 6. 初始化 Service 层 ========== + logService := service.NewLogService(logRepo) + + authService := service.NewAuthService( + userRepo, studentRepo, adminRoleRepo, classRepo, logService, + ) + adminService := service.NewAdminService( + userRepo, studentRepo, adminRoleRepo, classRepo, + ) + conductService := service.NewConductService( + conductRepo, studentRepo, adminRoleRepo, semesterRepo, classRepo, + ) + attendanceService := service.NewAttendanceService( + attendanceRepo, studentRepo, userRepo, conductRepo, semesterRepo, settingRepo, classRepo, + ) + semesterService := service.NewSemesterService( + semesterRepo, studentRepo, classRepo, attendanceRepo, assignmentRepo, logService, + ) + classService := service.NewClassService( + classRepo, userRepo, adminRoleRepo, + ) + subjectService := service.NewSubjectService(subjectRepo) + studentService := service.NewStudentService( + studentRepo, conductRepo, attendanceRepo, semesterRepo, + ) + parentService := service.NewParentService( + userRepo, studentRepo, conductRepo, attendanceRepo, + ) + rankingService := service.NewRankingService( + studentRepo, conductRepo, + ) + superAdminService := service.NewSuperAdminService(superAdminRepo, logService) + configService := service.NewConfigService(classRepo) + + // 确保默认超级管理员存在 + if err := superAdminService.EnsureDefaultAdmin(); err != nil { + logger.Sugared.Errorf("初始化默认超级管理员失败: %v", err) + } + + // ========== 7. 初始化 Handler 层 ========== + handlers := &router.Handlers{ + Auth: handler.NewAuthHandler(authService, superAdminService), + Admin: handler.NewAdminHandler(adminService, conductService, attendanceService, rankingService, logService), + Student: handler.NewStudentHandler(studentService, classRepo), + Parent: handler.NewParentHandler(parentService, authService, classService), + Subject: handler.NewSubjectHandler(subjectService), + Semester: handler.NewSemesterHandler(semesterService), + Class: handler.NewClassHandler(classService), + Config: handler.NewConfigHandler(configService), + SuperAdmin: handler.NewSuperAdminHandler(superAdminService), + Cadre: handler.NewCadreHandler(assignmentRepo, conductService, adminRoleRepo), + } + + // ========== 8. 初始化路由 ========== + r := router.SetupRouter(cfg, handlers) + + // ========== 9. 启动 HTTP 服务 ========== + addr := fmt.Sprintf(":%s", cfg.AppPort) + srv := &http.Server{ + Addr: addr, + Handler: r, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + } + + // 优雅关闭 + go func() { + logger.Sugared.Infof("HTTP 服务启动: http://0.0.0.0%s", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Sugared.Fatalf("HTTP 服务异常: %v", err) + } + }() + + // ========== 10. 等待中断信号 ========== + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + // ========== 自动周期重置定时任务 ========== + // 每天凌晨 1:00 检查是否有班级需要执行周/月重置 + // 使用独立 done 通道避免与 quit 通道的竞态条件 + timerDone := make(chan struct{}) + go func() { + runAutoPeriodReset := func() { + defer func() { + if r := recover(); r != nil { + logger.Sugared.Errorf("自动周期重置 panic: %v", r) + } + }() + semesterService.AutoPeriodReset() + } + + // 计算距离下一个凌晨 1:00 的等待时间 + waitUntilNext1AM := func() time.Duration { + now := time.Now() + next := time.Date(now.Year(), now.Month(), now.Day(), 1, 0, 0, 0, now.Location()) + if now.After(next) { + next = next.Add(24 * time.Hour) + } + return next.Sub(now) + } + + timer := time.NewTimer(waitUntilNext1AM()) + defer timer.Stop() + + for { + select { + case <-timerDone: + logger.Sugared.Info("定时任务收到退出信号,停止") + return + case <-timer.C: + runAutoPeriodReset() + timer.Reset(24 * time.Hour) + } + } + }() + + sig := <-quit + close(timerDone) + logger.Sugared.Infof("收到信号 %v,正在关闭服务...", sig) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + logger.Sugared.Errorf("服务关闭异常: %v", err) + } + + logger.Sugared.Info("服务已安全停止") +} diff --git a/backend-go/go.mod b/backend-go/go.mod new file mode 100644 index 0000000..d748f33 --- /dev/null +++ b/backend-go/go.mod @@ -0,0 +1,13 @@ +module hz-gitea.sea-studio.top/canglan/SharedClassManager + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/joho/godotenv v1.5.1 + github.com/redis/go-redis/v9 v9.7.0 + go.uber.org/zap v1.27.0 + gorm.io/driver/mysql v1.5.7 + gorm.io/gorm v1.25.12 +) diff --git a/backend-go/internal/config/config.go b/backend-go/internal/config/config.go new file mode 100644 index 0000000..b2ddcc2 --- /dev/null +++ b/backend-go/internal/config/config.go @@ -0,0 +1,163 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/joho/godotenv" +) + +// Config 应用全局配置结构体 +type Config struct { + // 应用基础配置 + AppName string + AppEnv string + Debug bool + AppPort string + + // MySQL 数据库配置 + DBHost string + DBPort int + DBUser string + DBPassword string + DBName string + DBMaxOpenConns int + DBMaxIdleConns int + DBConnMaxLife int // 秒 + + // Redis 配置 + RedisHost string + RedisPort int + RedisPassword string + RedisDB int + RedisMaxConns int + + // JWT 配置 + JWTSecretKey string + JWTAlgorithm string + JWTExpireMinutes int + JWTIdleTimeoutMinutes int + + // 密码加密(兼容 Python 版) + PasswordSalt string + + // 系统管理员配置 + SuperAdminLoginPath string + SuperAdminDefaultUser string + SuperAdminDefaultPass string + + // 日志 + LogLevel string + LogFile string +} + +// AppConfig 全局配置实例 +var AppConfig *Config + +// Load 加载配置:先尝试加载 .env 文件,然后读取环境变量 +func Load() (*Config, error) { + // 尝试加载 .env 文件(不存在不报错) + _ = godotenv.Load() + + cfg := &Config{ + AppName: getEnv("APP_NAME", "多班级版班级管理系统"), + AppEnv: getEnv("APP_ENV", "production"), + Debug: getEnvBool("DEBUG", false), + AppPort: getEnv("APP_PORT", "56789"), + + DBHost: getEnv("DB_HOST", "localhost"), + DBPort: getEnvInt("DB_PORT", 3306), + DBUser: getEnv("DB_USER", "class_admin"), + DBPassword: getEnv("DB_PASSWORD", ""), + DBName: getEnv("DB_NAME", "classmanagerdb"), + DBMaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 25), + DBMaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 10), + DBConnMaxLife: getEnvInt("DB_CONN_MAX_LIFETIME", 300), + + RedisHost: getEnv("REDIS_HOST", "localhost"), + RedisPort: getEnvInt("REDIS_PORT", 6379), + RedisPassword: getEnv("REDIS_PASSWORD", ""), + RedisDB: getEnvInt("REDIS_DB", 0), + RedisMaxConns: getEnvInt("REDIS_MAX_CONNECTIONS", 500), + + JWTSecretKey: getEnv("JWT_SECRET_KEY", ""), + JWTAlgorithm: getEnv("JWT_ALGORITHM", "HS256"), + JWTExpireMinutes: getEnvInt("JWT_EXPIRE_MINUTES", 60), + JWTIdleTimeoutMinutes: getEnvInt("JWT_IDLE_TIMEOUT_MINUTES", 10), + + PasswordSalt: getEnv("PASSWORD_SALT", ""), + + SuperAdminLoginPath: getEnv("SUPER_ADMIN_LOGIN_PATH", "/super-admin"), + SuperAdminDefaultUser: getEnv("SUPER_ADMIN_DEFAULT_USERNAME", "admin"), + // 安全警告:默认密码仅用于首次部署初始化,上线前必须在 .env 中修改 SUPER_ADMIN_DEFAULT_PASSWORD。 + // EnsureDefaultAdmin 通过 need_change_password=1 强制首次登录改密作为缓解措施。 + SuperAdminDefaultPass: getEnv("SUPER_ADMIN_DEFAULT_PASSWORD", "Admin123"), + + LogLevel: getEnv("LOG_LEVEL", "info"), + LogFile: getEnv("LOG_FILE", "logs/app.log"), + } + + // 校验必填项 + if cfg.JWTSecretKey == "" { + return nil, fmt.Errorf("配置 JWT_SECRET_KEY 不能为空") + } + if cfg.PasswordSalt == "" { + return nil, fmt.Errorf("配置 PASSWORD_SALT 不能为空") + } + + AppConfig = cfg + return cfg, nil +} + +// DSN 返回 MySQL 连接字符串 +func (c *Config) DSN() string { + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", + c.DBUser, c.DBPassword, c.DBHost, c.DBPort, c.DBName) +} + +// RedisAddr 返回 Redis 地址 +func (c *Config) RedisAddr() string { + return fmt.Sprintf("%s:%d", c.RedisHost, c.RedisPort) +} + +// IsProduction 判断是否为生产环境 +func (c *Config) IsProduction() bool { + return c.AppEnv == "production" +} + +// --- 辅助函数 --- + +func getEnv(key, fallback string) string { + if val, ok := os.LookupEnv(key); ok { + return val + } + return fallback +} + +func getEnvInt(key string, fallback int) int { + if val, ok := os.LookupEnv(key); ok { + if i, err := strconv.Atoi(val); err == nil { + return i + } + } + return fallback +} +func getEnvBool(key string, fallback bool) bool { + if val, ok := os.LookupEnv(key); ok { + return strings.ToLower(val) == "true" + } + return fallback +} diff --git a/backend-go/internal/handler/admin_handler.go b/backend-go/internal/handler/admin_handler.go new file mode 100644 index 0000000..391ac83 --- /dev/null +++ b/backend-go/internal/handler/admin_handler.go @@ -0,0 +1,602 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "encoding/json" + "io" + "strconv" + + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// AdminHandler 管理端处理器 +type AdminHandler struct { + adminService *service.AdminService + conductService *service.ConductService + attendanceSvc *service.AttendanceService + rankingService *service.RankingService + logService *service.LogService +} + +// NewAdminHandler 创建管理端处理器 +func NewAdminHandler( + adminService *service.AdminService, + conductService *service.ConductService, + attendanceSvc *service.AttendanceService, + rankingService *service.RankingService, + logService *service.LogService, +) *AdminHandler { + return &AdminHandler{ + adminService: adminService, + conductService: conductService, + attendanceSvc: attendanceSvc, + rankingService: rankingService, + logService: logService, + } +} + +// ========== 学生管理 ========== + +// GetDormitories 获取宿舍号列表 +func (h *AdminHandler) GetDormitories(c *gin.Context) { + classID := middleware.GetClassID(c) + if classID == 0 { + response.BadRequest(c, "请先选择班级") + return + } + dormitories, err := h.adminService.GetDormitories(classID) + if err != nil { + response.InternalError(c, "获取宿舍号列表失败") + return + } + response.Success(c, gin.H{"dormitories": dormitories}, "操作成功") +} + +// StudentList 获取学生列表 +func (h *AdminHandler) StudentList(c *gin.Context) { + classID := middleware.GetClassID(c) + if classID == 0 { + response.BadRequest(c, "请先选择班级") + return + } + + var query schema.StudentListQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.adminService.GetStudents(classID, query.Page, query.PageSize, query.Search, query.DormitoryNumber) + if err != nil { + response.InternalError(c, "获取学生列表失败") + return + } + response.Success(c, result, "操作成功") +} + +// StudentImport 批量导入学生 +func (h *AdminHandler) StudentImport(c *gin.Context) { + classID := middleware.GetClassID(c) + if classID == 0 { + response.BadRequest(c, "请先选择班级") + return + } + + file, _, err := c.Request.FormFile("file") + if err != nil { + response.BadRequest(c, "请上传文件") + return + } + defer file.Close() + + limitedReader := io.LimitReader(file, 5*1024*1024) + content, err := io.ReadAll(limitedReader) + if err != nil { + response.BadRequest(c, "读取文件失败") + return + } + + var data struct { + Students []map[string]interface{} `json:"students"` + } + if err := json.Unmarshal(content, &data); err != nil { + response.BadRequest(c, "JSON格式错误") + return + } + if len(data.Students) == 0 { + response.BadRequest(c, "文件中没有学生数据") + return + } + + result, err := h.adminService.ImportStudents(data.Students, classID) + if err != nil { + response.InternalError(c, "导入失败") + return + } + + response.Success(c, result, "操作成功") +} + +// StudentCreate 新增学生 +func (h *AdminHandler) StudentCreate(c *gin.Context) { + classID := middleware.GetClassID(c) + if classID == 0 { + response.BadRequest(c, "请先选择班级") + return + } + + var req schema.StudentCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.adminService.AddStudent(req.StudentNo, req.Name, req.ParentAccount, classID, req.DormitoryNumber) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + msg, _ := result["message"].(string) + if msg == "" { + msg = "操作失败" + } + response.BadRequest(c, msg) + return + } + + response.Success(c, result, "学生添加成功") +} + +// StudentUpdate 编辑学生 +func (h *AdminHandler) StudentUpdate(c *gin.Context) { + studentID, ok := parseID(c, "student_id") + if !ok { + return + } + + classID := middleware.GetClassID(c) + + var req schema.StudentUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if err := h.adminService.UpdateStudent(studentID, req.Name, req.ParentAccount, req.DormitoryNumber, classID); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "更新成功") +} + +// StudentDelete 删除学生 +func (h *AdminHandler) StudentDelete(c *gin.Context) { + studentID, ok := parseID(c, "student_id") + if !ok { + return + } + + classID := middleware.GetClassID(c) + + if err := h.adminService.DeleteStudent(studentID, classID); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "删除成功") +} + +// ResetStudentPassword 重置学生密码 +func (h *AdminHandler) ResetStudentPassword(c *gin.Context) { + studentID, ok := parseID(c, "student_id") + if !ok { + return + } + + var req schema.ResetPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if err := h.adminService.ResetStudentPassword(studentID, req.NewPassword); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "密码重置成功") +} + +// ========== 操行分管理 ========== + +// AddConductPoints 批量加减分 +func (h *AdminHandler) AddConductPoints(c *gin.Context) { + var req schema.ConductAddRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + classID := middleware.GetClassID(c) + userID := middleware.GetUserID(c) + realName := middleware.GetRealName(c) + + result, err := h.conductService.AddPoints( + req.StudentIDs, req.PointsChange, req.Reason, + userID, realName, classID, req.RelatedType, + ) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + msg, _ := result["message"].(string) + if msg == "" { + msg = "操作失败" + } + response.BadRequest(c, msg) + return + } + response.Success(c, result, "操作成功") +} + +// RevokeConductRecord 撤销记录 +func (h *AdminHandler) RevokeConductRecord(c *gin.Context) { + var req schema.RevokeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + classID := middleware.GetClassID(c) + result, err := h.conductService.RevokeRecord(req.RecordID, userID, classID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + msg, _ := result["message"].(string) + if msg == "" { + msg = "操作失败" + } + response.BadRequest(c, msg) + return + } + response.SuccessWithMessage(c, "撤销成功") +} + +// RestoreConductRecord 反撤销记录 +func (h *AdminHandler) RestoreConductRecord(c *gin.Context) { + var req schema.RevokeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + classID := middleware.GetClassID(c) + result, err := h.conductService.RestoreRecord(req.RecordID, userID, classID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + msg, _ := result["message"].(string) + if msg == "" { + msg = "操作失败" + } + response.BadRequest(c, msg) + return + } + response.SuccessWithMessage(c, "反撤销成功") +} + +// GetConductHistory 操行分历史 +func (h *AdminHandler) GetConductHistory(c *gin.Context) { + classID := middleware.GetClassID(c) + + var query schema.ConductHistoryQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.conductService.GetHistory( + classID, query.StudentID, query.Page, query.PageSize, + query.StartDate, query.EndDate, query.RelatedType, + query.ReasonPrefix, query.IsRevoked, query.ReasonSearch, + ) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// BatchRevokeConductRecords 批量撤销 +func (h *AdminHandler) BatchRevokeConductRecords(c *gin.Context) { + var req schema.BatchRevokeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + classID := middleware.GetClassID(c) + successCount := 0 + failCount := 0 + var errors []map[string]interface{} + + for _, recordID := range req.RecordIDs { + result, _ := h.conductService.RevokeRecord(recordID, userID, classID) + if result != nil { + if success, _ := result["success"].(bool); success { + successCount++ + } else { + failCount++ + msg, _ := result["message"].(string) + errors = append(errors, map[string]interface{}{"record_id": recordID, "error": msg}) + } + } else { + failCount++ + errors = append(errors, map[string]interface{}{"record_id": recordID, "error": "撤销失败"}) + } + } + + response.Success(c, gin.H{ + "success_count": successCount, + "fail_count": failCount, + "errors": errors, + }, "批量撤销完成") +} + +// BatchRestoreConductRecords 批量反撤销 +func (h *AdminHandler) BatchRestoreConductRecords(c *gin.Context) { + var req schema.BatchRevokeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + classID := middleware.GetClassID(c) + successCount := 0 + failCount := 0 + var errors []map[string]interface{} + + for _, recordID := range req.RecordIDs { + result, _ := h.conductService.RestoreRecord(recordID, userID, classID) + if result != nil { + if success, _ := result["success"].(bool); success { + successCount++ + } else { + failCount++ + msg, _ := result["message"].(string) + errors = append(errors, map[string]interface{}{"record_id": recordID, "error": msg}) + } + } else { + failCount++ + errors = append(errors, map[string]interface{}{"record_id": recordID, "error": "反撤销失败"}) + } + } + + response.Success(c, gin.H{ + "success_count": successCount, + "fail_count": failCount, + "errors": errors, + }, "批量反撤销完成") +} + +// ========== 考勤管理 ========== + +// CreateAttendanceRecord 添加考勤 +func (h *AdminHandler) CreateAttendanceRecord(c *gin.Context) { + var req schema.AttendanceCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + classID := middleware.GetClassID(c) + result, err := h.attendanceSvc.CreateRecord( + req.StudentID, req.Date, req.Slot, req.Status, + &req.Reason, req.ApplyDeduction, req.CustomDeduction, userID, classID, + ) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + msg, _ := result["message"].(string) + if msg == "" { + msg = "操作失败" + } + response.BadRequest(c, msg) + return + } + msg, _ := result["message"].(string) + if msg == "" { + msg = "操作成功" + } + response.SuccessWithMessage(c, msg) +} + +// GetAttendanceRecords 获取考勤记录 +func (h *AdminHandler) GetAttendanceRecords(c *gin.Context) { + classID := middleware.GetClassID(c) + + var query schema.AttendanceQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.attendanceSvc.GetRecords(classID, query.Date, query.StudentID, query.Slot) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// ========== 管理员管理 ========== + +// AdminList 管理员列表 +func (h *AdminHandler) AdminList(c *gin.Context) { + classID := middleware.GetClassID(c) + result, err := h.adminService.GetAdmins(classID) + if err != nil { + response.InternalError(c, "获取管理员列表失败") + return + } + response.Success(c, result, "操作成功") +} + +// AdminCreate 添加管理员 +func (h *AdminHandler) AdminCreate(c *gin.Context) { + classID := middleware.GetClassID(c) + if classID == 0 { + response.BadRequest(c, "请先选择班级") + return + } + + var req schema.AdminCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.adminService.AddAdmin(req.Username, req.RealName, req.Password, req.RoleType, classID, req.SubjectID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + msg, _ := result["message"].(string) + if msg == "" { + msg = "操作失败" + } + response.BadRequest(c, msg) + return + } + response.Success(c, result, "管理员添加成功") +} + +// AdminUpdate 更新管理员 +func (h *AdminHandler) AdminUpdate(c *gin.Context) { + userID, ok := parseID(c, "user_id") + if !ok { + return + } + + classID := middleware.GetClassID(c) + var req schema.AdminUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if err := h.adminService.UpdateAdmin(userID, req.RealName, req.RoleType, classID, req.SubjectID); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "更新成功") +} + +// AdminDelete 删除管理员 +func (h *AdminHandler) AdminDelete(c *gin.Context) { + userID, ok := parseID(c, "user_id") + if !ok { + return + } + + classID := middleware.GetClassID(c) + if err := h.adminService.DeleteAdmin(userID, classID); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "删除成功") +} + +// AdminResetPassword 重置管理员密码 +func (h *AdminHandler) AdminResetPassword(c *gin.Context) { + userID, ok := parseID(c, "user_id") + if !ok { + return + } + + var req schema.ResetPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if err := h.adminService.ResetAdminPassword(userID, req.NewPassword); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "密码重置成功") +} + +// UnlockAccount 解除登录锁定 +func (h *AdminHandler) UnlockAccount(c *gin.Context) { + var req schema.UnlockUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if err := h.adminService.UnlockAccount(req.Username, c.ClientIP()); err != nil { + response.InternalError(c, "解锁失败") + return + } + response.SuccessWithMessage(c, "解锁成功") +} + + +// GetRankings 分项排行榜 +func (h *AdminHandler) GetRankings(c *gin.Context) { + classID := middleware.GetClassID(c) + if classID == 0 { + response.BadRequest(c, "请先选择班级") + return + } + + rankType := c.DefaultQuery("type", "all") + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + if limit <= 0 { + limit = 50 + } + if limit > 500 { + limit = 500 + } + + result, err := h.rankingService.GetRankings(classID, rankType, limit) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} diff --git a/backend-go/internal/handler/auth_handler.go b/backend-go/internal/handler/auth_handler.go new file mode 100644 index 0000000..5c42109 --- /dev/null +++ b/backend-go/internal/handler/auth_handler.go @@ -0,0 +1,131 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// AuthHandler 认证处理器 +type AuthHandler struct { + authService *service.AuthService + superAdminService *service.SuperAdminService +} + +// NewAuthHandler 创建认证处理器 +func NewAuthHandler(authService *service.AuthService, superAdminService *service.SuperAdminService) *AuthHandler { + return &AuthHandler{authService: authService, superAdminService: superAdminService} +} + +// Login 用户登录 +func (h *AuthHandler) Login(c *gin.Context) { + var req schema.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + ip := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + + result := h.authService.Login(req.Username, req.Password, ip, userAgent) + if !result.Success { + response.Unauthorized(c, result.Message) + return + } + + response.Success(c, result, "登录成功") +} + +// Logout 用户登出 +func (h *AuthHandler) Logout(c *gin.Context) { + userID := middleware.GetUserID(c) + if err := h.authService.Logout(userID); err != nil { + response.InternalError(c, "登出失败") + return + } + response.SuccessWithMessage(c, "登出成功") +} + +// ChangePassword 修改密码(超级管理员操作 super_admins 表,普通用户操作 users 表) +func (h *AuthHandler) ChangePassword(c *gin.Context) { + var req schema.ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + userType := middleware.GetUserType(c) + + // force 参数仅在用户确实需要强制改密时才允许使用 + if req.Force { + if userType == "super_admin" { + // 超级管理员的 need_change_password 由 super_admin_service 处理 + // force 改密时直接允许(登录时已验证 need_change_password 标记) + } else { + userInfo, err := h.authService.GetUserInfo(userID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + needChange, _ := userInfo["need_change_password"].(bool) + if !needChange { + response.BadRequest(c, "当前状态不允许强制修改密码") + return + } + } + } + + if userType == "super_admin" { + if err := h.superAdminService.ChangePassword(userID, req.OldPassword, req.NewPassword, req.Force); err != nil { + response.BadRequest(c, err.Error()) + return + } + } else { + if err := h.authService.ChangePassword(userID, req.OldPassword, req.NewPassword, req.Force); err != nil { + response.BadRequest(c, err.Error()) + return + } + } + + response.SuccessWithMessage(c, "密码修改成功,请重新登录") +} + +// GetUserInfo 获取当前用户信息 +func (h *AuthHandler) GetUserInfo(c *gin.Context) { + userID := middleware.GetUserID(c) + userInfo, err := h.authService.GetUserInfo(userID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, userInfo, "操作成功") +} + +// parseID 解析路径参数中的 ID +func parseID(c *gin.Context, key string) (int, bool) { + idStr := c.Param(key) + id, err := strconv.Atoi(idStr) + if err != nil { + response.BadRequest(c, "无效的ID参数") + return 0, false + } + return id, true +} diff --git a/backend-go/internal/handler/cadre_handler.go b/backend-go/internal/handler/cadre_handler.go new file mode 100644 index 0000000..78e5584 --- /dev/null +++ b/backend-go/internal/handler/cadre_handler.go @@ -0,0 +1,143 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "time" + + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// CadreHandler 课代表处理器 +type CadreHandler struct { + assignmentRepo *repository.AssignmentRepo + conductService *service.ConductService + adminRoleRepo *repository.AdminRoleRepo +} + +// NewCadreHandler 创建课代表处理器 +func NewCadreHandler(assignmentRepo *repository.AssignmentRepo, conductService *service.ConductService, adminRoleRepo *repository.AdminRoleRepo) *CadreHandler { + return &CadreHandler{assignmentRepo: assignmentRepo, conductService: conductService, adminRoleRepo: adminRoleRepo} +} + +// HomeworkList 课代表查看作业列表 +func (h *CadreHandler) HomeworkList(c *gin.Context) { + classID := middleware.GetClassID(c) + + var query schema.CadreHomeworkQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, "参数错误") + return + } + + subjectID := 0 + if query.SubjectID != nil { + subjectID = *query.SubjectID + } + + assignments, total, err := h.assignmentRepo.GetAssignmentsByClass(classID, subjectID, query.Page, query.PageSize) + if err != nil { + response.InternalError(c, "获取作业列表失败") + return + } + + response.Paginated(c, assignments, total, query.Page, query.PageSize) +} + +// HomeworkSubmit 课代表发布作业 +func (h *CadreHandler) HomeworkSubmit(c *gin.Context) { + var req schema.CadreHomeworkSubmitRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + classID := middleware.GetClassID(c) + userID := middleware.GetUserID(c) + + // 从管理员角色中获取课代表关联的科目 ID + adminRole, err := h.adminRoleRepo.GetByUserID(userID) + if err != nil || adminRole == nil || adminRole.SubjectID == nil { + response.BadRequest(c, "无法获取课代表关联的科目信息") + return + } + + deadline, err := time.Parse("2006-01-02", req.Deadline) + if err != nil { + response.BadRequest(c, "日期格式错误") + return + } + + assignment := &model.Assignment{ + ClassID: classID, + SubjectID: *adminRole.SubjectID, + Title: req.Title, + Description: &req.Description, + Deadline: deadline, + CreatedBy: userID, + } + + assignmentID, err := h.assignmentRepo.CreateAssignment(assignment) + if err != nil { + response.InternalError(c, "发布作业失败") + return + } + + response.Success(c, gin.H{ + "assignment_id": assignmentID, + }, "发布成功") +} + +// AddConductPoints 课代表登记缺交(仅允许作业相关的扣分操作) +func (h *CadreHandler) AddConductPoints(c *gin.Context) { + var req schema.ConductAddRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + // 课代表只允许扣分操作 + if req.PointsChange >= 0 { + response.BadRequest(c, "课代表只能进行扣分操作") + return + } + + classID := middleware.GetClassID(c) + userID := middleware.GetUserID(c) + realName := middleware.GetRealName(c) + + result, err := h.conductService.CadreAddPoints( + req.StudentIDs, req.PointsChange, req.Reason, + userID, realName, classID, "homework", + ) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + msg, _ := result["message"].(string) + if msg == "" { + msg = "操作失败" + } + response.BadRequest(c, msg) + return + } + response.Success(c, result, "操作成功") +} diff --git a/backend-go/internal/handler/class_handler.go b/backend-go/internal/handler/class_handler.go new file mode 100644 index 0000000..82ae5c5 --- /dev/null +++ b/backend-go/internal/handler/class_handler.go @@ -0,0 +1,271 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// ClassHandler 班级管理处理器 +type ClassHandler struct { + classService *service.ClassService +} + +// NewClassHandler 创建班级管理处理器 +func NewClassHandler(classService *service.ClassService) *ClassHandler { + return &ClassHandler{classService: classService} +} + +// ClassList 班级列表 +func (h *ClassHandler) ClassList(c *gin.Context) { + includeDisabled := c.Query("include_disabled") == "true" + result, err := h.classService.ListClasses(includeDisabled) + if err != nil { + response.InternalError(c, "获取班级列表失败") + return + } + response.Success(c, result, "操作成功") +} + +// ClassDetail 班级详情 +func (h *ClassHandler) ClassDetail(c *gin.Context) { + classID, ok := parseID(c, "class_id") + if !ok { + return + } + + result, err := h.classService.GetClassDetail(classID) + if err != nil { + response.NotFound(c, "班级不存在") + return + } + response.Success(c, result, "操作成功") +} + +// ClassCreate 创建班级 +func (h *ClassHandler) ClassCreate(c *gin.Context) { + var req schema.ClassCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.classService.CreateClass(req.ClassName, req.Grade, req.Description) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + response.BadRequest(c, result["message"].(string)) + return + } + response.Success(c, result, "班级创建成功") +} + +// ClassUpdate 更新班级 +func (h *ClassHandler) ClassUpdate(c *gin.Context) { + classID, ok := parseID(c, "class_id") + if !ok { + return + } + + var req schema.ClassUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if err := h.classService.UpdateClass(classID, req.ClassName, req.Grade, req.Description, req.Status); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "更新成功") +} + +// ClassDelete 删除班级 +func (h *ClassHandler) ClassDelete(c *gin.Context) { + classID, ok := parseID(c, "class_id") + if !ok { + return + } + + if err := h.classService.DeleteClass(classID); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "删除成功") +} + +// SwitchClass 切换班级上下文 +func (h *ClassHandler) SwitchClass(c *gin.Context) { + var req schema.SwitchClassRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + result, err := h.classService.SwitchClass(userID, req.ClassID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "切换成功") +} + +// GetSettings 获取班级设置 +func (h *ClassHandler) GetSettings(c *gin.Context) { + classID := middleware.GetClassID(c) + result, err := h.classService.GetSettings(classID) + if err != nil { + response.InternalError(c, "获取设置失败") + return + } + response.Success(c, result, "操作成功") +} + +// allowedSettingKeys 允许通过 SaveSetting 端点写入的配置键白名单 +var allowedSettingKeys = map[string]bool{ + "initial_password": true, + "initial_points": true, + "deduction_attendance_absent": true, + "deduction_attendance_late": true, + "deduction_attendance_leave": true, + "deduction_homework_not_submit": true, + "deduction_homework_late": true, + "reset_frequency": true, + "reset_day_of_week": true, + "reset_day_of_month": true, +} + +// SaveSetting 保存班级设置 +func (h *ClassHandler) SaveSetting(c *gin.Context) { + classID := middleware.GetClassID(c) + + var req schema.SettingRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if !allowedSettingKeys[req.SettingKey] { + response.BadRequest(c, "不允许的配置项: "+req.SettingKey) + return + } + + if err := h.classService.SaveSetting(classID, req.SettingKey, req.SettingValue); err != nil { + response.InternalError(c, "保存设置失败") + return + } + response.SuccessWithMessage(c, "保存成功") +} + +// GetPointLimits 获取角色加减分配置 +func (h *ClassHandler) GetPointLimits(c *gin.Context) { + classID := middleware.GetClassID(c) + result, err := h.classService.GetSettings(classID) + if err != nil { + response.InternalError(c, "获取配置失败") + return + } + response.Success(c, result, "操作成功") +} + +// allowedPointLimitKeys 允许的操行分限制配置键白名单(与 conduct_service 读取 key 一致) +var allowedPointLimitKeys = map[string]bool{ + "point_limit_班长_max": true, + "point_limit_班长_min": true, + "point_limit_学习委员_max": true, + "point_limit_学习委员_min": true, + "point_limit_考勤委员_max": true, + "point_limit_考勤委员_min": true, + "point_limit_劳动委员_max": true, + "point_limit_劳动委员_min": true, + "point_limit_志愿委员_max": true, + "point_limit_志愿委员_min": true, + "point_limit_科任老师_max": true, + "point_limit_科任老师_min": true, +} + +// SavePointLimits 保存角色加减分配置 +func (h *ClassHandler) SavePointLimits(c *gin.Context) { + classID := middleware.GetClassID(c) + + var req map[string]string + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + for key, value := range req { + if !allowedPointLimitKeys[key] { + response.BadRequest(c, "不允许的配置项: "+key) + return + } + if err := h.classService.SaveSetting(classID, key, value); err != nil { + response.InternalError(c, "保存配置失败") + return + } + } + response.SuccessWithMessage(c, "保存成功") +} + +// GetFeatures 获取功能开关 +func (h *ClassHandler) GetFeatures(c *gin.Context) { + classID := middleware.GetClassID(c) + result, err := h.classService.GetFeatures(classID) + if err != nil { + response.InternalError(c, "获取功能开关失败") + return + } + response.Success(c, result, "操作成功") +} + +// allowedFeatureKeys 允许的功能开关键白名单 +var allowedFeatureKeys = map[string]bool{ + "parent_account_enabled": true, + "parent_password_change_enabled": true, + "parent_view_attendance": true, + "parent_view_ranking": true, + "student_view_ranking": true, + "homework_management": true, + "attendance_management": true, + "cadre_homework": true, +} + +// SaveFeature 保存功能开关 +func (h *ClassHandler) SaveFeature(c *gin.Context) { + classID := middleware.GetClassID(c) + + var req schema.FeatureToggleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if !allowedFeatureKeys[req.FeatureKey] { + response.BadRequest(c, "不允许的功能开关: "+req.FeatureKey) + return + } + + if err := h.classService.SaveFeature(classID, req.FeatureKey, req.Enabled); err != nil { + response.InternalError(c, "保存功能开关失败") + return + } + response.SuccessWithMessage(c, "保存成功") +} diff --git a/backend-go/internal/handler/config_handler.go b/backend-go/internal/handler/config_handler.go new file mode 100644 index 0000000..5b6bceb --- /dev/null +++ b/backend-go/internal/handler/config_handler.go @@ -0,0 +1,44 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// ConfigHandler 配置处理器 +type ConfigHandler struct { + configService *service.ConfigService +} + +// NewConfigHandler 创建配置处理器 +func NewConfigHandler(configService *service.ConfigService) *ConfigHandler { + return &ConfigHandler{configService: configService} +} + +// GetDeductionRules 获取扣分规则(优先从 class_settings 读取班级级配置) +func (h *ConfigHandler) GetDeductionRules(c *gin.Context) { + classID := 0 + if classIDStr := c.Query("class_id"); classIDStr != "" { + if id, err := strconv.Atoi(classIDStr); err == nil { + classID = id + } + } + + rules := h.configService.GetDeductionRules(classID) + response.Success(c, rules, "操作成功") +} diff --git a/backend-go/internal/handler/handler_utils.go b/backend-go/internal/handler/handler_utils.go new file mode 100644 index 0000000..4832166 --- /dev/null +++ b/backend-go/internal/handler/handler_utils.go @@ -0,0 +1,20 @@ +package handler + +import ( + "strconv" + + "github.com/gin-gonic/gin" +) + +// parseQueryParamInt 解析查询参数为 int +func parseQueryParamInt(c *gin.Context, key string, defaultVal int) int { + val := c.Query(key) + if val == "" { + return defaultVal + } + i, err := strconv.Atoi(val) + if err != nil { + return defaultVal + } + return i +} diff --git a/backend-go/internal/handler/parent_handler.go b/backend-go/internal/handler/parent_handler.go new file mode 100644 index 0000000..5d2bf92 --- /dev/null +++ b/backend-go/internal/handler/parent_handler.go @@ -0,0 +1,115 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// ParentHandler 家长端处理器 +type ParentHandler struct { + parentService *service.ParentService + authService *service.AuthService + classService *service.ClassService +} + +// NewParentHandler 创建家长端处理器 +func NewParentHandler( + parentService *service.ParentService, + authService *service.AuthService, + classService *service.ClassService, +) *ParentHandler { + return &ParentHandler{ + parentService: parentService, + authService: authService, + classService: classService, + } +} + +// Dashboard 子女操行分(家长仪表盘) +func (h *ParentHandler) Dashboard(c *gin.Context) { + userID := middleware.GetUserID(c) + result, err := h.parentService.GetChildConduct(userID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// History 子女历史记录 +func (h *ParentHandler) History(c *gin.Context) { + var query schema.ParentHistoryQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + result, err := h.parentService.GetChildHistory(userID, query.Page, query.PageSize) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// Attendance 子女考勤 +func (h *ParentHandler) Attendance(c *gin.Context) { + userID := middleware.GetUserID(c) + result, err := h.parentService.GetChildAttendance(userID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// Ranking 子女排名 +func (h *ParentHandler) Ranking(c *gin.Context) { + userID := middleware.GetUserID(c) + result, err := h.parentService.GetChildRanking(userID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// ChangePassword 家长修改密码(受功能开关控制) +func (h *ParentHandler) ChangePassword(c *gin.Context) { + classID := middleware.GetClassID(c) + + // 检查功能开关 + if !h.classService.IsFeatureEnabled(classID, "parent_password_change_enabled") { + response.Forbidden(c, "该功能暂未开放") + return + } + + var req schema.ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + if err := h.authService.ChangePassword(userID, req.OldPassword, req.NewPassword, false); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.SuccessWithMessage(c, "密码修改成功") +} diff --git a/backend-go/internal/handler/semester_handler.go b/backend-go/internal/handler/semester_handler.go new file mode 100644 index 0000000..26fe88e --- /dev/null +++ b/backend-go/internal/handler/semester_handler.go @@ -0,0 +1,230 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// SemesterHandler 学期管理处理器 +type SemesterHandler struct { + semesterService *service.SemesterService +} + +// NewSemesterHandler 创建学期管理处理器 +func NewSemesterHandler(semesterService *service.SemesterService) *SemesterHandler { + return &SemesterHandler{semesterService: semesterService} +} + +// SemesterList 学期列表 +func (h *SemesterHandler) SemesterList(c *gin.Context) { + result, err := h.semesterService.ListSemesters() + if err != nil { + response.InternalError(c, "获取学期列表失败") + return + } + response.Success(c, result, "操作成功") +} + +// ActiveSemester 当前学期 +func (h *SemesterHandler) ActiveSemester(c *gin.Context) { + semester, err := h.semesterService.GetActiveSemester() + if err != nil { + response.Success(c, nil, "无活跃学期") + return + } + response.Success(c, semester, "操作成功") +} + +// SemesterCreate 创建学期 +func (h *SemesterHandler) SemesterCreate(c *gin.Context) { + var req schema.SemesterCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.semesterService.CreateSemester(req.SemesterName, req.StartDate, req.EndDate) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + response.BadRequest(c, result["message"].(string)) + return + } + response.Success(c, result, "操作成功") +} + +// ActivateSemester 激活学期 +func (h *SemesterHandler) ActivateSemester(c *gin.Context) { + semesterID, ok := parseID(c, "semester_id") + if !ok { + return + } + + if err := h.semesterService.ActivateSemester(semesterID); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.SuccessWithMessage(c, "已设为当前学期") +} + +// SemesterUpdate 编辑学期 +func (h *SemesterHandler) SemesterUpdate(c *gin.Context) { + semesterID, ok := parseID(c, "semester_id") + if !ok { + return + } + + var req schema.SemesterUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if err := h.semesterService.UpdateSemester(semesterID, req.SemesterName, req.StartDate, req.EndDate); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.SuccessWithMessage(c, "更新成功") +} + +// SemesterDelete 删除学期 +func (h *SemesterHandler) SemesterDelete(c *gin.Context) { + semesterID, ok := parseID(c, "semester_id") + if !ok { + return + } + + if err := h.semesterService.DeleteSemester(semesterID); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.SuccessWithMessage(c, "删除成功") +} + +// AssociateRecords 关联记录 +func (h *SemesterHandler) AssociateRecords(c *gin.Context) { + semesterID, ok := parseID(c, "semester_id") + if !ok { + return + } + + result, err := h.semesterService.AssociateRecords(semesterID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + response.BadRequest(c, result["message"].(string)) + return + } + response.Success(c, result, "操作成功") +} + +// ArchiveSemester 归档学期 +func (h *SemesterHandler) ArchiveSemester(c *gin.Context) { + semesterID, ok := parseID(c, "semester_id") + if !ok { + return + } + + classID := parseQueryParamInt(c, "class_id", 0) + resetScores := c.Query("reset_scores") == "true" + + result, err := h.semesterService.ArchiveSemester(semesterID, classID, resetScores) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + response.BadRequest(c, result["message"].(string)) + return + } + response.Success(c, result, "操作成功") +} + +// GetArchiveData 归档数据 +func (h *SemesterHandler) GetArchiveData(c *gin.Context) { + semesterID, ok := parseID(c, "semester_id") + if !ok { + return + } + + classID := parseQueryParamInt(c, "class_id", 0) + page := parseQueryParamInt(c, "page", 1) + pageSize := parseQueryParamInt(c, "page_size", 20) + + result, err := h.semesterService.GetArchiveRecords(semesterID, classID, page, pageSize) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// PeriodReset 手动触发周/月重置 +func (h *SemesterHandler) PeriodReset(c *gin.Context) { + var req schema.PeriodResetRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + classID := middleware.GetClassID(c) + if classID == 0 { + response.BadRequest(c, "未指定班级") + return + } + + userID := middleware.GetUserID(c) + realName := middleware.GetRealName(c) + ip := c.ClientIP() + + if err := h.semesterService.PeriodReset(classID, req.Period, userID, realName, ip); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, service.PeriodLabelCN(req.Period)+"重置成功") +} + +// GetPeriodArchives 查看周期归档数据 +func (h *SemesterHandler) GetPeriodArchives(c *gin.Context) { + var req schema.PeriodArchiveQuery + if err := c.ShouldBindQuery(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + classID := middleware.GetClassID(c) + if classID == 0 { + response.BadRequest(c, "未指定班级") + return + } + + result, err := h.semesterService.GetPeriodArchives(classID, req.Period, req.Page, req.PageSize) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + diff --git a/backend-go/internal/handler/student_handler.go b/backend-go/internal/handler/student_handler.go new file mode 100644 index 0000000..eaf5d16 --- /dev/null +++ b/backend-go/internal/handler/student_handler.go @@ -0,0 +1,192 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// StudentHandler 学生端处理器 +type StudentHandler struct { + studentService *service.StudentService + classRepo *repository.ClassRepo +} + +// NewStudentHandler 创建学生端处理器 +func NewStudentHandler(studentService *service.StudentService, classRepo *repository.ClassRepo) *StudentHandler { + return &StudentHandler{studentService: studentService, classRepo: classRepo} +} + +// Dashboard 学生个人信息(仪表盘) +func (h *StudentHandler) Dashboard(c *gin.Context) { + studentID := middleware.GetStudentID(c) + if studentID == 0 { + response.BadRequest(c, "非学生用户") + return + } + + result, err := h.studentService.GetStudentInfo(studentID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// resolveStudentID 校验学生归属:学生只能查看自己的数据,家长只能查看关联子女,管理员可查看指定学生 +func (h *StudentHandler) resolveStudentID(c *gin.Context) (int, bool) { + userType := middleware.GetUserType(c) + if userType == "student" { + // 学生只能查看自己的数据,忽略 URL 参数中的 student_id + studentID := middleware.GetStudentID(c) + if studentID == 0 { + response.BadRequest(c, "非学生用户") + return 0, false + } + return studentID, true + } + + requestedID, ok := parseID(c, "student_id") + if !ok { + return 0, false + } + + // 家长只能查看自己关联的子女数据 + if userType == "parent" { + parentStudentID := middleware.GetStudentID(c) + if parentStudentID == 0 || parentStudentID != requestedID { + response.Forbidden(c, "无权访问该学生数据") + return 0, false + } + return requestedID, true + } + + // 管理员/超级管理员允许查看(角色权限由路由中间件 RequireRole 控制) + return requestedID, true +} + +// ConductHistory 学生操行分历史 +func (h *StudentHandler) ConductHistory(c *gin.Context) { + studentID, ok := h.resolveStudentID(c) + if !ok { + return + } + + var query schema.StudentConductQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.studentService.GetConductHistory(studentID, query.Limit, query.Offset) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// Homework 学生作业情况 +func (h *StudentHandler) Homework(c *gin.Context) { + studentID, ok := h.resolveStudentID(c) + if !ok { + return + } + + result, err := h.studentService.GetHomeworkStatus(studentID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// Attendance 学生考勤记录 +func (h *StudentHandler) Attendance(c *gin.Context) { + studentID, ok := h.resolveStudentID(c) + if !ok { + return + } + + month := c.Query("month") + result, err := h.studentService.GetAttendanceRecords(studentID, month) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// Ranking 操行分排行 +func (h *StudentHandler) Ranking(c *gin.Context) { + classID := middleware.GetClassID(c) + + // 检查班级功能开关:学生查看排行榜 + feature, err := h.classRepo.GetFeature(classID, "student_view_ranking") + if err == nil && feature != nil && feature.Enabled == 0 { + response.Forbidden(c, "该功能暂未开放") + return + } + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + if limit <= 0 { + limit = 50 + } + if limit > 500 { + limit = 500 + } + + result, err := h.studentService.GetRanking(classID, limit) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// MyInfo 学生个人信息 +func (h *StudentHandler) MyInfo(c *gin.Context) { + studentID := middleware.GetStudentID(c) + if studentID == 0 { + response.BadRequest(c, "非学生用户") + return + } + + result, err := h.studentService.GetStudentInfo(studentID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// SemesterRecords 学期归档记录 +func (h *StudentHandler) SemesterRecords(c *gin.Context) { + studentID := middleware.GetStudentID(c) + if studentID <= 0 { + response.BadRequest(c, "非学生用户") + return + } + result, err := h.studentService.GetSemesterRecords(studentID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} diff --git a/backend-go/internal/handler/subject_handler.go b/backend-go/internal/handler/subject_handler.go new file mode 100644 index 0000000..5d7485f --- /dev/null +++ b/backend-go/internal/handler/subject_handler.go @@ -0,0 +1,152 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// SubjectHandler 科目管理处理器 +type SubjectHandler struct { + subjectService *service.SubjectService +} + +// NewSubjectHandler 创建科目管理处理器 +func NewSubjectHandler(subjectService *service.SubjectService) *SubjectHandler { + return &SubjectHandler{subjectService: subjectService} +} + +// SubjectList 科目列表 +func (h *SubjectHandler) SubjectList(c *gin.Context) { + var isActive *bool + if v := c.Query("is_active"); v == "true" { + b := true + isActive = &b + } else if v == "false" { + b := false + isActive = &b + } + + result, err := h.subjectService.GetSubjects(isActive) + if err != nil { + response.InternalError(c, "获取科目列表失败") + return + } + response.Success(c, result, "操作成功") +} + +// SubjectCreate 创建科目 +func (h *SubjectHandler) SubjectCreate(c *gin.Context) { + var req schema.SubjectCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.subjectService.CreateSubject(req.SubjectName, req.SubjectCode, req.SortOrder) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + response.BadRequest(c, result["message"].(string)) + return + } + response.Success(c, result, "操作成功") +} + +// SubjectUpdate 更新科目 +func (h *SubjectHandler) SubjectUpdate(c *gin.Context) { + subjectID, ok := parseID(c, "subject_id") + if !ok { + return + } + + var req schema.SubjectUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + updates := make(map[string]interface{}) + if req.SubjectName != nil { + updates["subject_name"] = *req.SubjectName + } + if req.SubjectCode != nil { + updates["subject_code"] = *req.SubjectCode + } + if req.IsActive != nil { + updates["is_active"] = *req.IsActive + } + if req.SortOrder != nil { + updates["sort_order"] = *req.SortOrder + } + + if err := h.subjectService.UpdateSubject(subjectID, updates); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "更新成功") +} + +// SubjectDelete 删除科目 +func (h *SubjectHandler) SubjectDelete(c *gin.Context) { + subjectID, ok := parseID(c, "subject_id") + if !ok { + return + } + + if err := h.subjectService.DeleteSubject(subjectID); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "删除成功") +} + +// SubjectToggle 切换科目启用/禁用状态 +func (h *SubjectHandler) SubjectToggle(c *gin.Context) { + subjectID, ok := parseID(c, "subject_id") + if !ok { + return + } + + var req struct { + IsActive bool `json:"is_active"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + var err error + if req.IsActive { + err = h.subjectService.EnableSubject(subjectID) + } else { + err = h.subjectService.DisableSubject(subjectID) + } + + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if req.IsActive { + response.SuccessWithMessage(c, "科目已启用") + } else { + response.SuccessWithMessage(c, "科目已禁用") + } +} diff --git a/backend-go/internal/handler/super_admin_handler.go b/backend-go/internal/handler/super_admin_handler.go new file mode 100644 index 0000000..95e32e1 --- /dev/null +++ b/backend-go/internal/handler/super_admin_handler.go @@ -0,0 +1,56 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// SuperAdminHandler 超级管理员处理器 +type SuperAdminHandler struct { + superAdminService *service.SuperAdminService +} + +// NewSuperAdminHandler 创建超级管理员处理器 +func NewSuperAdminHandler(superAdminService *service.SuperAdminService) *SuperAdminHandler { + return &SuperAdminHandler{superAdminService: superAdminService} +} + +// Login 超级管理员登录 +func (h *SuperAdminHandler) Login(c *gin.Context) { + var req schema.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + ip := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + result, err := h.superAdminService.Login(req.Username, req.Password, ip, userAgent) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + success, ok := result["success"].(bool) + if !ok || !success { + msg, _ := result["message"].(string) + response.Unauthorized(c, msg) + return + } + + response.Success(c, result, "登录成功") +} diff --git a/backend-go/internal/middleware/access_log.go b/backend-go/internal/middleware/access_log.go new file mode 100644 index 0000000..bf1b5bf --- /dev/null +++ b/backend-go/internal/middleware/access_log.go @@ -0,0 +1,57 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// AccessLog 访问日志中间件 +func AccessLog() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + + // 处理请求 + c.Next() + + latency := time.Since(start) + status := c.Writer.Status() + clientIP := c.ClientIP() + method := c.Request.Method + userAgent := c.Request.UserAgent() + + if query != "" { + path = path + "?" + query + } + + // 获取用户信息(如已认证) + userID, _ := c.Get(CtxUserID) + username, _ := c.Get(CtxUsername) + + logger.Sugared.Infow("请求日志", + "status", status, + "method", method, + "path", path, + "ip", clientIP, + "latency", latency.String(), + "user_agent", userAgent, + "user_id", userID, + "username", username, + ) + } +} diff --git a/backend-go/internal/middleware/auth.go b/backend-go/internal/middleware/auth.go new file mode 100644 index 0000000..04f424c --- /dev/null +++ b/backend-go/internal/middleware/auth.go @@ -0,0 +1,227 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package middleware + +import ( + "context" + "strings" + + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database" + appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// 上下文 Key 常量 +const ( + CtxUserID = "user_id" + CtxUsername = "username" + CtxUserType = "user_type" + CtxStudentID = "student_id" + CtxRole = "role" + CtxRealName = "real_name" + CtxClassID = "class_id" +) + +// 公开路径(不需要认证) +var publicPaths = map[string]bool{ + "/": true, + "/health": true, + "/api/auth/login": true, +} + +// RegisterPublicPath 注册额外的公开路径(需在路由初始化阶段调用) +func RegisterPublicPath(path string) { + publicPaths[path] = true +} + +// AuthRequired JWT 认证中间件 +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + path := c.Request.URL.Path + + // 公开路径跳过 + if publicPaths[path] { + c.Next() + return + } + + cfg := config.AppConfig + + // 获取 Authorization header + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + response.Unauthorized(c, "缺少认证令牌") + c.Abort() + return + } + + // 解析 Bearer Token + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + response.Unauthorized(c, "认证格式错误") + c.Abort() + return + } + tokenStr := parts[1] + + // 验证 JWT + claims, err := appJwt.VerifyToken(tokenStr) + if err != nil { + logger.Sugared.Warnf("JWT 验证失败: path=%s, err=%v", path, err) + response.Unauthorized(c, "令牌无效或已过期") + c.Abort() + return + } + + // 验证 Redis 中的 Token + ctx := context.Background() + storedToken, err := database.GetUserToken(ctx, claims.UserID) + if err != nil || storedToken != tokenStr { + logger.Sugared.Warnf("Redis Token 不匹配: path=%s, user_id=%d", path, claims.UserID) + // 主动清理 Redis 中的旧 Token,避免残留 + if err == nil && storedToken != "" && storedToken != tokenStr { + _ = database.DeleteUserToken(ctx, claims.UserID) + } + response.Unauthorized(c, "令牌已失效,请重新登录") + c.Abort() + return + } + // 刷新 Token 过期时间(空闲超时) + _ = database.ExpireToken(ctx, claims.UserID, cfg.JWTIdleTimeoutMinutes) + + // 将用户信息写入 Gin 上下文 + c.Set(CtxUserID, claims.UserID) + c.Set(CtxUsername, claims.Username) + c.Set(CtxUserType, claims.UserType) + c.Set(CtxRealName, claims.RealName) + if claims.StudentID != nil { + c.Set(CtxStudentID, *claims.StudentID) + } + c.Set(CtxRole, claims.Role) + if claims.ClassID != nil { + c.Set(CtxClassID, *claims.ClassID) + } + + logger.Sugared.Debugf("认证成功: %s %s, user_id=%d, username=%s", + c.Request.Method, path, claims.UserID, claims.Username) + + c.Next() + } +} + +// RequireRole 角色权限中间件 +func RequireRole(roles ...string) gin.HandlerFunc { + roleSet := make(map[string]bool, len(roles)) + for _, r := range roles { + roleSet[r] = true + } + + return func(c *gin.Context) { + userType, _ := c.Get(CtxUserType) + role, _ := c.Get(CtxRole) + + // 超级管理员直接通过 + if userType == "super_admin" { + c.Next() + return + } + + // 检查 user_type + if ut, ok := userType.(string); ok && roleSet[ut] { + c.Next() + return + } + + // 检查 role(admin_roles.role_type) + if r, ok := role.(string); ok && roleSet[r] { + c.Next() + return + } + + response.Forbidden(c, "权限不足") + c.Abort() + } +} + +// GetUserID 从上下文获取用户 ID +func GetUserID(c *gin.Context) int { + if v, exists := c.Get(CtxUserID); exists { + if id, ok := v.(int); ok { + return id + } + } + return 0 +} + +// GetUsername 从上下文获取用户名 +func GetUsername(c *gin.Context) string { + if v, exists := c.Get(CtxUsername); exists { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// GetUserType 从上下文获取用户类型 +func GetUserType(c *gin.Context) string { + if v, exists := c.Get(CtxUserType); exists { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// GetRole 从上下文获取角色 +func GetRole(c *gin.Context) string { + if v, exists := c.Get(CtxRole); exists { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// GetClassID 从上下文获取班级 ID +func GetClassID(c *gin.Context) int { + if v, exists := c.Get(CtxClassID); exists { + if id, ok := v.(int); ok { + return id + } + } + return 0 +} + +// GetStudentID 从上下文获取学生 ID +func GetStudentID(c *gin.Context) int { + if v, exists := c.Get(CtxStudentID); exists { + if id, ok := v.(int); ok { + return id + } + } + return 0 +} + +// GetRealName 从上下文获取真实姓名 +func GetRealName(c *gin.Context) string { + if v, exists := c.Get(CtxRealName); exists { + if s, ok := v.(string); ok { + return s + } + } + return "" +} diff --git a/backend-go/internal/middleware/sanitize.go b/backend-go/internal/middleware/sanitize.go new file mode 100644 index 0000000..681e620 --- /dev/null +++ b/backend-go/internal/middleware/sanitize.go @@ -0,0 +1,131 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package middleware + +import ( + "bytes" + "encoding/json" + "io" + "net/url" + "strings" + + "github.com/gin-gonic/gin" +) + +// Sanitize 输入清理中间件(路径遍历防护 + 长度限制) +func Sanitize() gin.HandlerFunc { + return func(c *gin.Context) { + // 处理 POST、PUT、PATCH 请求体 + if c.Request.Method == "POST" || c.Request.Method == "PUT" || c.Request.Method == "PATCH" { + body, err := io.ReadAll(c.Request.Body) + if err == nil && len(body) > 0 { + var data interface{} + if json.Unmarshal(body, &data) == nil { + cleaned := sanitizeData(data) + newBody, _ := json.Marshal(cleaned) + c.Request.Body = io.NopCloser(bytes.NewBuffer(newBody)) + c.Request.ContentLength = int64(len(newBody)) + } else { + // 非 JSON 请求体,恢复原始 body + c.Request.Body = io.NopCloser(bytes.NewBuffer(body)) + } + } + } + + // 清理查询参数(GET 等请求的 URL query string) + if c.Request.URL.RawQuery != "" { + params := c.Request.URL.Query() + dirty := false + for key, values := range params { + for i, v := range values { + cleaned := sanitizeString(v) + if cleaned != v { + values[i] = cleaned + dirty = true + } + } + params[key] = values + } + if dirty { + c.Request.URL.RawQuery = params.Encode() + } + } + + c.Next() + } +} + +// sanitizeData 递归清理数据 +func sanitizeData(data interface{}) interface{} { + switch v := data.(type) { + case map[string]interface{}: + result := make(map[string]interface{}, len(v)) + for key, val := range v { + result[key] = sanitizeData(val) + } + return result + case []interface{}: + result := make([]interface{}, len(v)) + for i, val := range v { + result[i] = sanitizeData(val) + } + return result + case string: + return sanitizeString(v) + default: + return v + } +} + +// sanitizeString 清理字符串 +func sanitizeString(value string) string { + if value == "" { + return "" + } + + value = strings.TrimSpace(value) + + // 路径遍历防护(循环解码直到稳定,防止多层编码绕过) + for { + decoded, err := url.PathUnescape(value) + if err != nil || decoded == value { + break + } + value = decoded + } + // 大小写无关的路径遍历模式清理(循环移除直到无匹配) + lower := strings.ToLower(value) + for strings.Contains(lower, "../") || strings.Contains(lower, "..\\") { + replaced := false + for _, pattern := range []string{"../", "..\\"} { + if idx := strings.Index(lower, pattern); idx >= 0 { + value = value[:idx] + value[idx+len(pattern):] + lower = lower[:idx] + lower[idx+len(pattern):] + replaced = true + break + } + } + if !replaced { + break + } + } + + // 限制长度(按 rune 截断,避免切断多字节 UTF-8 字符) + runes := []rune(value) + if len(runes) > 1000 { + value = string(runes[:1000]) + } + + // SQL 注入由 GORM 参数化查询防护,无需正则替换(避免破坏合法输入) + + return value +} diff --git a/backend-go/internal/model/admin_role.go b/backend-go/internal/model/admin_role.go new file mode 100644 index 0000000..120bf9a --- /dev/null +++ b/backend-go/internal/model/admin_role.go @@ -0,0 +1,36 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// AdminRole 管理员角色模型,对应 admin_roles 表 +type AdminRole struct { + AdminRoleID int `gorm:"column:admin_role_id;primaryKey;autoIncrement" json:"admin_role_id"` + UserID int `gorm:"column:user_id;not null;uniqueIndex:uk_user_class" json:"user_id"` + ClassID int `gorm:"column:class_id;not null;uniqueIndex:uk_user_class;index:idx_admin_role_class" json:"class_id"` + RoleType string `gorm:"column:role_type;type:enum('班主任','班长','学习委员','考勤委员','劳动委员','志愿委员','科任老师','课代表');not null" json:"role_type"` + SubjectID *int `gorm:"column:subject_id" json:"subject_id"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + + // 虚拟字段(JOIN 查询时使用) + RealName *string `gorm:"-" json:"real_name,omitempty"` + Username *string `gorm:"-" json:"username,omitempty"` + UserStatus *int8 `gorm:"-" json:"user_status,omitempty"` + SubjectName *string `gorm:"-" json:"subject_name,omitempty"` + ClassName *string `gorm:"-" json:"class_name,omitempty"` +} + +// TableName 指定表名 +func (AdminRole) TableName() string { + return "admin_roles" +} diff --git a/backend-go/internal/model/assignment.go b/backend-go/internal/model/assignment.go new file mode 100644 index 0000000..012fdcf --- /dev/null +++ b/backend-go/internal/model/assignment.go @@ -0,0 +1,53 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// Assignment 作业模型,对应 assignments 表 +type Assignment struct { + AssignmentID int `gorm:"column:assignment_id;primaryKey;autoIncrement" json:"assignment_id"` + ClassID int `gorm:"column:class_id;not null;index:idx_assignment_class" json:"class_id"` + SubjectID int `gorm:"column:subject_id;not null;index:idx_assignment_subject" json:"subject_id"` + Title string `gorm:"column:title;type:varchar(100);not null" json:"title"` + Description *string `gorm:"column:description;type:text" json:"description"` + Deadline time.Time `gorm:"column:deadline;type:date;not null" json:"deadline"` + CreatedBy int `gorm:"column:created_by;not null" json:"created_by"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + + // 虚拟字段 + SubjectName *string `gorm:"-" json:"subject_name,omitempty"` +} + +// TableName 指定表名 +func (Assignment) TableName() string { + return "assignments" +} + +// AssignmentSubmission 作业提交记录模型,对应 homework_submissions 表 +type AssignmentSubmission struct { + SubmissionID int `gorm:"column:submission_id;primaryKey;autoIncrement" json:"submission_id"` + AssignmentID int `gorm:"column:assignment_id;not null;uniqueIndex:uk_assignment_student" json:"assignment_id"` + StudentID int `gorm:"column:student_id;not null;uniqueIndex:uk_assignment_student" json:"student_id"` + Status string `gorm:"column:status;type:enum('submitted','not_submitted','late');default:'not_submitted'" json:"status"` + SubmitTime *time.Time `gorm:"column:submit_time" json:"submit_time"` + Comments *string `gorm:"column:comments;type:text" json:"comments"` + DeductionApplied int8 `gorm:"column:deduction_applied;default:0" json:"deduction_applied"` + DeductionRecordID *int64 `gorm:"column:deduction_record_id" json:"deduction_record_id"` + UpdatedBy *int `gorm:"column:updated_by" json:"updated_by"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定表名 +func (AssignmentSubmission) TableName() string { + return "homework_submissions" +} diff --git a/backend-go/internal/model/attendance.go b/backend-go/internal/model/attendance.go new file mode 100644 index 0000000..1cc12e4 --- /dev/null +++ b/backend-go/internal/model/attendance.go @@ -0,0 +1,38 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// AttendanceRecord 考勤记录模型,对应 attendance_records 表 +type AttendanceRecord struct { + AttendanceID int `gorm:"column:attendance_id;primaryKey;autoIncrement" json:"attendance_id"` + StudentID int `gorm:"column:student_id;not null" json:"student_id"` + Date time.Time `gorm:"column:date;type:date;not null;index:idx_date" json:"date"` + Slot string `gorm:"column:slot;type:enum('morning','afternoon','evening');default:'morning'" json:"slot"` + Status string `gorm:"column:status;type:enum('present','absent','late','leave');default:'present'" json:"status"` + Reason *string `gorm:"column:reason;type:varchar(255)" json:"reason"` + RecorderID int `gorm:"column:recorder_id;not null" json:"recorder_id"` + DeductionApplied int8 `gorm:"column:deduction_applied;default:0" json:"deduction_applied"` + DeductionRecordID *int64 `gorm:"column:deduction_record_id" json:"deduction_record_id"` + SemesterID *int `gorm:"column:semester_id;index:idx_attendance_semester" json:"semester_id"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + + // 虚拟字段(JOIN 查询时使用) + StudentName *string `gorm:"-" json:"student_name,omitempty"` + StudentNo *string `gorm:"-" json:"student_no,omitempty"` +} + +// TableName 指定表名 +func (AttendanceRecord) TableName() string { + return "attendance_records" +} diff --git a/backend-go/internal/model/class_model.go b/backend-go/internal/model/class_model.go new file mode 100644 index 0000000..3f00dcc --- /dev/null +++ b/backend-go/internal/model/class_model.go @@ -0,0 +1,60 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// Class 班级模型,对应 classes 表 +type Class struct { + ClassID int `gorm:"column:class_id;primaryKey;autoIncrement" json:"class_id"` + ClassName string `gorm:"column:class_name;type:varchar(100);uniqueIndex:uk_class_name;not null" json:"class_name"` + Grade *string `gorm:"column:grade;type:varchar(50)" json:"grade"` + Description *string `gorm:"column:description;type:varchar(255)" json:"description"` + Status int8 `gorm:"column:status;default:1" json:"status"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + + // 虚拟字段 + StudentCount int64 `gorm:"-" json:"student_count,omitempty"` +} + +// TableName 指定表名 +func (Class) TableName() string { + return "classes" +} + +// ClassSetting 班级设置模型,对应 class_settings 表 +type ClassSetting struct { + SettingID int `gorm:"column:setting_id;primaryKey;autoIncrement" json:"setting_id"` + ClassID int `gorm:"column:class_id;not null;uniqueIndex:uk_class_setting" json:"class_id"` + SettingKey string `gorm:"column:setting_key;type:varchar(50);not null;uniqueIndex:uk_class_setting" json:"setting_key"` + SettingValue string `gorm:"column:setting_value;type:varchar(255);not null" json:"setting_value"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定表名 +func (ClassSetting) TableName() string { + return "class_settings" +} + +// ClassFeature 班级功能开关模型,对应 class_features 表 +type ClassFeature struct { + FeatureID int `gorm:"column:feature_id;primaryKey;autoIncrement" json:"feature_id"` + ClassID int `gorm:"column:class_id;not null;uniqueIndex:uk_class_feature" json:"class_id"` + FeatureKey string `gorm:"column:feature_key;type:varchar(50);not null;uniqueIndex:uk_class_feature" json:"feature_key"` + Enabled int8 `gorm:"column:enabled;default:1" json:"enabled"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定表名 +func (ClassFeature) TableName() string { + return "class_features" +} diff --git a/backend-go/internal/model/conduct.go b/backend-go/internal/model/conduct.go new file mode 100644 index 0000000..a6fb7e3 --- /dev/null +++ b/backend-go/internal/model/conduct.go @@ -0,0 +1,44 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// ConductRecord 操行分记录模型,对应 conduct_records 表 +type ConductRecord struct { + RecordID int64 `gorm:"column:record_id;primaryKey;autoIncrement" json:"record_id"` + StudentID int `gorm:"column:student_id;not null;index:idx_conduct_student;index:idx_student_created" json:"student_id"` + PointsChange int `gorm:"column:points_change;not null" json:"points_change"` + Reason string `gorm:"column:reason;type:varchar(255);not null" json:"reason"` + RecorderID int `gorm:"column:recorder_id;not null;index:idx_recorder_id" json:"recorder_id"` + RecorderName *string `gorm:"column:recorder_name;type:varchar(50)" json:"recorder_name"` + RelatedType string `gorm:"column:related_type;type:enum('manual','homework','attendance');default:'manual'" json:"related_type"` + RelatedID *int `gorm:"column:related_id" json:"related_id"` + IsRevoked int8 `gorm:"column:is_revoked;default:0" json:"is_revoked"` + RevokedBy *int `gorm:"column:revoked_by" json:"revoked_by"` + RevokedAt *time.Time `gorm:"column:revoked_at" json:"revoked_at"` + SemesterID *int `gorm:"column:semester_id;index:idx_conduct_semester;index:idx_conduct_type_semester" json:"semester_id"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_student_created" json:"created_at"` + + // 虚拟字段(JOIN 查询时使用) + StudentName *string `gorm:"-" json:"student_name,omitempty"` + StudentNo *string `gorm:"-" json:"student_no,omitempty"` + RecorderReal *string `gorm:"-" json:"recorder_real,omitempty"` + RevokerName *string `gorm:"-" json:"revoker_name,omitempty"` + TotalPoints *int `gorm:"-" json:"total_points,omitempty"` + ClassID *int `gorm:"-" json:"class_id,omitempty"` +} + +// TableName 指定表名 +func (ConductRecord) TableName() string { + return "conduct_records" +} diff --git a/backend-go/internal/model/log.go b/backend-go/internal/model/log.go new file mode 100644 index 0000000..0506002 --- /dev/null +++ b/backend-go/internal/model/log.go @@ -0,0 +1,50 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// OperationLog 操作日志模型,对应 operation_logs 表 +type OperationLog struct { + LogID int64 `gorm:"column:log_id;primaryKey;autoIncrement" json:"log_id"` + OperatorID int `gorm:"column:operator_id;not null;index:idx_operator_created" json:"operator_id"` + OperatorName *string `gorm:"column:operator_name;type:varchar(50)" json:"operator_name"` + OperatorRole *string `gorm:"column:operator_role;type:varchar(50)" json:"operator_role"` + ClassID *int `gorm:"column:class_id;index:idx_operation_class" json:"class_id"` + OperationType string `gorm:"column:operation_type;type:varchar(50);not null" json:"operation_type"` + TargetType *string `gorm:"column:target_type;type:varchar(50)" json:"target_type"` + TargetID *int `gorm:"column:target_id" json:"target_id"` + Details *string `gorm:"column:details;type:text" json:"details"` + IPAddress *string `gorm:"column:ip_address;type:varchar(45)" json:"ip_address"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_operator_created" json:"created_at"` +} + +// TableName 指定表名 +func (OperationLog) TableName() string { + return "operation_logs" +} + +// LoginLog 登录日志模型,对应 login_logs 表 +type LoginLog struct { + LogID int64 `gorm:"column:log_id;primaryKey;autoIncrement" json:"log_id"` + Username string `gorm:"column:username;type:varchar(50);not null;index:idx_username_created" json:"username"` + LoginResult int8 `gorm:"column:login_result;not null" json:"login_result"` + FailReason *string `gorm:"column:fail_reason;type:varchar(100)" json:"fail_reason"` + IPAddress *string `gorm:"column:ip_address;type:varchar(45)" json:"ip_address"` + UserAgent *string `gorm:"column:user_agent;type:varchar(255)" json:"user_agent"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_username_created" json:"created_at"` +} + +// TableName 指定表名 +func (LoginLog) TableName() string { + return "login_logs" +} diff --git a/backend-go/internal/model/semester.go b/backend-go/internal/model/semester.go new file mode 100644 index 0000000..2f92988 --- /dev/null +++ b/backend-go/internal/model/semester.go @@ -0,0 +1,88 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// Semester 学期模型,对应 semesters 表 +type Semester struct { + SemesterID int `gorm:"column:semester_id;primaryKey;autoIncrement" json:"semester_id"` + SemesterName string `gorm:"column:semester_name;type:varchar(100);not null" json:"semester_name"` + StartDate *time.Time `gorm:"column:start_date;type:date" json:"start_date"` + EndDate *time.Time `gorm:"column:end_date;type:date" json:"end_date"` + IsActive int8 `gorm:"column:is_active;default:0" json:"is_active"` + IsArchived int8 `gorm:"column:is_archived;default:0" json:"is_archived"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + + // 虚拟字段 + ConductCount int64 `gorm:"-" json:"conduct_count,omitempty"` + AttendanceCount int64 `gorm:"-" json:"attendance_count,omitempty"` + CurrentWeek *int `gorm:"-" json:"current_week,omitempty"` +} + +// TableName 指定表名 +func (Semester) TableName() string { + return "semesters" +} + +// SemesterArchive 学期归档快照模型,对应 semester_archives 表 +type SemesterArchive struct { + ArchiveID int `gorm:"column:archive_id;primaryKey;autoIncrement" json:"archive_id"` + SemesterID int `gorm:"column:semester_id;not null;index:idx_semester_id" json:"semester_id"` + ClassID int `gorm:"column:class_id;not null;index:idx_archive_class" json:"class_id"` + StudentID int `gorm:"column:student_id;not null" json:"student_id"` + StudentNo string `gorm:"column:student_no;type:varchar(20);not null" json:"student_no"` + StudentName string `gorm:"column:student_name;type:varchar(50);not null" json:"student_name"` + FinalPoints int `gorm:"column:final_points;not null" json:"final_points"` + RankPosition *int `gorm:"column:rank_position" json:"rank_position"` + TotalStudents *int `gorm:"column:total_students" json:"total_students"` + AttendancePresent int `gorm:"column:attendance_present;default:0" json:"attendance_present"` + AttendanceAbsent int `gorm:"column:attendance_absent;default:0" json:"attendance_absent"` + AttendanceLate int `gorm:"column:attendance_late;default:0" json:"attendance_late"` + AttendanceLeave int `gorm:"column:attendance_leave;default:0" json:"attendance_leave"` + HomeworkSubmitted int `gorm:"column:homework_submitted;default:0" json:"homework_submitted"` + HomeworkNotSubmitted int `gorm:"column:homework_not_submitted;default:0" json:"homework_not_submitted"` + HomeworkLate int `gorm:"column:homework_late;default:0" json:"homework_late"` + ArchivedAt time.Time `gorm:"column:archived_at;autoCreateTime" json:"archived_at"` + + // 虚拟字段 + SemesterName *string `gorm:"-" json:"semester_name,omitempty"` + SStartDate *time.Time `gorm:"-" json:"start_date,omitempty"` + SEndDate *time.Time `gorm:"-" json:"end_date,omitempty"` +} + +// TableName 指定表名 +func (SemesterArchive) TableName() string { + return "semester_archives" +} + +// PeriodArchive 周期归档快照模型,对应 period_archives 表 +type PeriodArchive struct { + ArchiveID int `gorm:"column:archive_id;primaryKey;autoIncrement" json:"archive_id"` + ClassID int `gorm:"column:class_id;not null;index:idx_period_archive_class" json:"class_id"` + PeriodType string `gorm:"column:period_type;type:enum('weekly','monthly');not null;index:idx_period_archive_type" json:"period_type"` + PeriodLabel string `gorm:"column:period_label;type:varchar(50);not null;index:idx_period_archive_type" json:"period_label"` + StudentID int `gorm:"column:student_id;not null" json:"student_id"` + StudentNo string `gorm:"column:student_no;type:varchar(20);not null" json:"student_no"` + StudentName string `gorm:"column:student_name;type:varchar(50);not null" json:"student_name"` + FinalPoints int `gorm:"column:final_points;not null" json:"final_points"` + RankPosition *int `gorm:"column:rank_position" json:"rank_position"` + TotalStudents *int `gorm:"column:total_students" json:"total_students"` + ArchivedAt time.Time `gorm:"column:archived_at;autoCreateTime" json:"archived_at"` + ResetBy string `gorm:"column:reset_by;type:varchar(20);default:auto" json:"reset_by"` + OperatorID *int `gorm:"column:operator_id" json:"operator_id"` +} + +// TableName 指定表名 +func (PeriodArchive) TableName() string { + return "period_archives" +} diff --git a/backend-go/internal/model/student.go b/backend-go/internal/model/student.go new file mode 100644 index 0000000..b93068e --- /dev/null +++ b/backend-go/internal/model/student.go @@ -0,0 +1,37 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// Student 学生模型,对应 students 表 +type Student struct { + StudentID int `gorm:"column:student_id;primaryKey;autoIncrement" json:"student_id"` + StudentNo string `gorm:"column:student_no;type:varchar(20);not null" json:"student_no"` + ClassID int `gorm:"column:class_id;not null;index:idx_student_class" json:"class_id"` + Name string `gorm:"column:name;type:varchar(50);not null" json:"name"` + TotalPoints int `gorm:"column:total_points;default:60" json:"total_points"` + ParentAccount *string `gorm:"column:parent_account;type:varchar(50)" json:"parent_account"` + DormitoryNumber *string `gorm:"column:dormitory_number;type:varchar(20)" json:"dormitory_number"` // 格式:南0-000 + Status int8 `gorm:"column:status;default:1" json:"status"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + PointsUpdatedAt time.Time `gorm:"column:points_updated_at;autoCreateTime" json:"points_updated_at"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` + + // 虚拟字段(JOIN 查询时使用,不映射到数据库) + ClassName *string `gorm:"-" json:"class_name,omitempty"` +} + +// TableName 指定表名 +func (Student) TableName() string { + return "students" +} diff --git a/backend-go/internal/model/subject.go b/backend-go/internal/model/subject.go new file mode 100644 index 0000000..ba47061 --- /dev/null +++ b/backend-go/internal/model/subject.go @@ -0,0 +1,29 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// Subject 科目模型,对应 subjects 表 +type Subject struct { + SubjectID int `gorm:"column:subject_id;primaryKey;autoIncrement" json:"subject_id"` + SubjectName string `gorm:"column:subject_name;type:varchar(50);uniqueIndex:uk_subject_name;not null" json:"subject_name"` + SubjectCode *string `gorm:"column:subject_code;type:varchar(20)" json:"subject_code"` + IsActive int8 `gorm:"column:is_active;default:1" json:"is_active"` + SortOrder int `gorm:"column:sort_order;default:0" json:"sort_order"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` +} + +// TableName 指定表名 +func (Subject) TableName() string { + return "subjects" +} diff --git a/backend-go/internal/model/super_admin.go b/backend-go/internal/model/super_admin.go new file mode 100644 index 0000000..ec7c4b0 --- /dev/null +++ b/backend-go/internal/model/super_admin.go @@ -0,0 +1,32 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// SuperAdmin 超级管理员模型,对应 super_admins 表 +type SuperAdmin struct { + ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Username string `gorm:"column:username;type:varchar(50);uniqueIndex;not null" json:"username"` + PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` + Salt string `gorm:"column:salt;type:varchar(64);not null" json:"-"` + RealName string `gorm:"column:real_name;type:varchar(50);not null" json:"real_name"` + Status int8 `gorm:"column:status;default:1" json:"status"` + NeedChangePassword int8 `gorm:"column:need_change_password;default:1" json:"need_change_password"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定表名 +func (SuperAdmin) TableName() string { + return "super_admins" +} diff --git a/backend-go/internal/model/system_setting.go b/backend-go/internal/model/system_setting.go new file mode 100644 index 0000000..a0959ff --- /dev/null +++ b/backend-go/internal/model/system_setting.go @@ -0,0 +1,26 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// SystemSetting 系统设置模型,对应 system_settings 表 +type SystemSetting struct { + SettingKey string `gorm:"column:setting_key;type:varchar(50);primaryKey;not null" json:"setting_key"` + SettingValue string `gorm:"column:setting_value;type:varchar(255);not null" json:"setting_value"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定表名 +func (SystemSetting) TableName() string { + return "system_settings" +} diff --git a/backend-go/internal/model/user.go b/backend-go/internal/model/user.go new file mode 100644 index 0000000..7a28061 --- /dev/null +++ b/backend-go/internal/model/user.go @@ -0,0 +1,34 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// User 用户模型,对应 users 表 +type User struct { + UserID int `gorm:"column:user_id;primaryKey;autoIncrement" json:"user_id"` + Username string `gorm:"column:username;type:varchar(50);uniqueIndex;not null" json:"username"` + PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` + RealName string `gorm:"column:real_name;type:varchar(50);not null" json:"real_name"` + UserType string `gorm:"column:user_type;type:enum('student','parent','admin','super_admin');not null" json:"user_type"` + StudentID *int `gorm:"column:student_id" json:"student_id"` + Status int8 `gorm:"column:status;default:1" json:"status"` + NeedChangePassword int8 `gorm:"column:need_change_password;default:1" json:"need_change_password"` + LastLoginTime *time.Time `gorm:"column:last_login_time" json:"last_login_time"` + LastLoginIP *string `gorm:"column:last_login_ip;type:varchar(45)" json:"last_login_ip"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` +} + +// TableName 指定表名 +func (User) TableName() string { + return "users" +} diff --git a/backend-go/internal/repository/admin_role_repo.go b/backend-go/internal/repository/admin_role_repo.go new file mode 100644 index 0000000..6a33039 --- /dev/null +++ b/backend-go/internal/repository/admin_role_repo.go @@ -0,0 +1,112 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// AdminRoleRepo 管理员角色数据访问层 +type AdminRoleRepo struct { + db *gorm.DB +} + +// NewAdminRoleRepo 创建管理员角色 Repository +func NewAdminRoleRepo(db *gorm.DB) *AdminRoleRepo { + return &AdminRoleRepo{db: db} +} + +// GetByUserID 获取用户的管理员角色(取第一个,含科目名称) +func (r *AdminRoleRepo) GetByUserID(userID int) (*model.AdminRole, error) { + var role model.AdminRole + if err := r.db.Table("admin_roles ar"). + Select("ar.*, s.subject_name"). + Joins("LEFT JOIN subjects s ON ar.subject_id = s.subject_id"). + Where("ar.user_id = ?", userID). + Order("ar.admin_role_id ASC"). + Limit(1). + First(&role).Error; err != nil { + return nil, err + } + return &role, nil +} + +// GetByUserIDAndClass 获取用户在指定班级的管理员角色 +func (r *AdminRoleRepo) GetByUserIDAndClass(userID int, classID int) (*model.AdminRole, error) { + var role model.AdminRole + if err := r.db.Table("admin_roles ar"). + Select("ar.*, s.subject_name"). + Joins("LEFT JOIN subjects s ON ar.subject_id = s.subject_id"). + Where("ar.user_id = ? AND ar.class_id = ?", userID, classID). + Limit(1). + First(&role).Error; err != nil { + return nil, err + } + return &role, nil +} + +// GetAllByClass 获取指定班级的所有管理员列表(含用户和科目信息) +func (r *AdminRoleRepo) GetAllByClass(classID int) ([]model.AdminRole, error) { + var roles []model.AdminRole + if err := r.db.Table("admin_roles ar"). + Select("ar.*, u.real_name, u.username, u.status as user_status, s.subject_name"). + Joins("JOIN users u ON ar.user_id = u.user_id AND u.status = 1"). + Joins("LEFT JOIN subjects s ON ar.subject_id = s.subject_id"). + Where("ar.class_id = ?", classID). + Order("ar.role_type"). + Find(&roles).Error; err != nil { + return nil, err + } + return roles, nil +} + +// Create 创建管理员角色 +func (r *AdminRoleRepo) Create(role *model.AdminRole) (int, error) { + if err := r.db.Create(role).Error; err != nil { + return 0, err + } + return role.AdminRoleID, nil +} + +// Delete 删除管理员角色(可指定班级) +func (r *AdminRoleRepo) Delete(userID int, classID int) error { + query := r.db.Where("user_id = ?", userID) + if classID > 0 { + query = query.Where("class_id = ?", classID) + } + return query.Delete(&model.AdminRole{}).Error +} + +// UpdateRole 更新管理员角色类型和关联科目 +func (r *AdminRoleRepo) UpdateRole(userID int, roleType string, classID int, subjectID *int) error { + query := r.db.Model(&model.AdminRole{}).Where("user_id = ?", userID) + if classID > 0 { + query = query.Where("class_id = ?", classID) + } + return query.Updates(map[string]interface{}{ + "role_type": roleType, + "subject_id": subjectID, + }).Error +} + +// GetUserRoleAndClassID 获取用户的角色类型和所属班级ID +func (r *AdminRoleRepo) GetUserRoleAndClassID(userID int) (string, int, error) { + var role model.AdminRole + if err := r.db.Where("user_id = ?", userID). + Limit(1). + First(&role).Error; err != nil { + return "", 0, err + } + return role.RoleType, role.ClassID, nil +} diff --git a/backend-go/internal/repository/assignment_repo.go b/backend-go/internal/repository/assignment_repo.go new file mode 100644 index 0000000..ac2db18 --- /dev/null +++ b/backend-go/internal/repository/assignment_repo.go @@ -0,0 +1,168 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "time" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// AssignmentRepo 作业数据访问层 +type AssignmentRepo struct { + db *gorm.DB +} + +// NewAssignmentRepo 创建作业 Repository +func NewAssignmentRepo(db *gorm.DB) *AssignmentRepo { + return &AssignmentRepo{db: db} +} + +// ========== Assignment 操作 ========== + +// CreateAssignment 创建作业 +func (r *AssignmentRepo) CreateAssignment(assignment *model.Assignment) (int, error) { + if err := r.db.Create(assignment).Error; err != nil { + return 0, err + } + return assignment.AssignmentID, nil +} + +// GetAssignmentByID 根据ID获取作业 +func (r *AssignmentRepo) GetAssignmentByID(assignmentID int) (*model.Assignment, error) { + var assignment model.Assignment + if err := r.db.Where("assignment_id = ?", assignmentID).First(&assignment).Error; err != nil { + return nil, err + } + return &assignment, nil +} + +// GetAssignmentsByClass 获取班级作业列表 +func (r *AssignmentRepo) GetAssignmentsByClass(classID int, subjectID int, page, pageSize int) ([]model.Assignment, int64, error) { + var assignments []model.Assignment + var total int64 + + query := r.db.Model(&model.Assignment{}).Where("class_id = ?", classID) + if subjectID > 0 { + query = query.Where("subject_id = ?", subjectID) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC"). + Limit(pageSize). + Offset(offset). + Find(&assignments).Error; err != nil { + return nil, 0, err + } + + return assignments, total, nil +} + +// GetAssignmentsBySubject 获取科目关联的作业列表 +func (r *AssignmentRepo) GetAssignmentsBySubject(subjectID int) ([]model.Assignment, error) { + var assignments []model.Assignment + if err := r.db.Where("subject_id = ?", subjectID). + Order("created_at DESC"). + Find(&assignments).Error; err != nil { + return nil, err + } + return assignments, nil +} + +// DeleteAssignment 删除作业 +func (r *AssignmentRepo) DeleteAssignment(assignmentID int) error { + return r.db.Where("assignment_id = ?", assignmentID).Delete(&model.Assignment{}).Error +} + +// GetHomeworkStatsByDateRange 通过作业截止日期范围查询学生作业提交统计 +func (r *AssignmentRepo) GetHomeworkStatsByDateRange(startDate, endDate time.Time) ([]struct { + StudentID int + Status string + Count int64 +}, error) { + var stats []struct { + StudentID int + Status string + Count int64 + } + err := r.db.Table("homework_submissions hs"). + Select("hs.student_id, hs.status, COUNT(*) as count"). + Joins("JOIN assignments a ON hs.assignment_id = a.assignment_id"). + Where("a.deadline BETWEEN ? AND ?", startDate, endDate). + Group("hs.student_id, hs.status"). + Find(&stats).Error + if err != nil { + return nil, err + } + return stats, nil +} + +// ========== AssignmentSubmission 操作 ========== + +// CreateSubmission 创建作业提交记录 +func (r *AssignmentRepo) CreateSubmission(submission *model.AssignmentSubmission) (int, error) { + if err := r.db.Create(submission).Error; err != nil { + return 0, err + } + return submission.SubmissionID, nil +} + +// GetSubmissionByAssignmentAndStudent 获取指定作业和学生的提交记录 +func (r *AssignmentRepo) GetSubmissionByAssignmentAndStudent(assignmentID, studentID int) (*model.AssignmentSubmission, error) { + var submission model.AssignmentSubmission + if err := r.db.Where("assignment_id = ? AND student_id = ?", assignmentID, studentID). + First(&submission).Error; err != nil { + return nil, err + } + return &submission, nil +} + +// GetSubmissionsByAssignment 获取作业的所有提交记录 +func (r *AssignmentRepo) GetSubmissionsByAssignment(assignmentID int) ([]model.AssignmentSubmission, error) { + var submissions []model.AssignmentSubmission + if err := r.db.Where("assignment_id = ?", assignmentID). + Find(&submissions).Error; err != nil { + return nil, err + } + return submissions, nil +} + +// GetSubmissionsByStudent 获取学生的所有提交记录 +func (r *AssignmentRepo) GetSubmissionsByStudent(studentID int) ([]model.AssignmentSubmission, error) { + var submissions []model.AssignmentSubmission + if err := r.db.Where("student_id = ?", studentID). + Find(&submissions).Error; err != nil { + return nil, err + } + return submissions, nil +} + +// UpdateSubmission 更新提交记录 +func (r *AssignmentRepo) UpdateSubmission(submissionID int, updates map[string]interface{}) error { + return r.db.Model(&model.AssignmentSubmission{}). + Where("submission_id = ?", submissionID). + Updates(updates).Error +} + +// BatchCreateSubmissions 批量创建提交记录 +func (r *AssignmentRepo) BatchCreateSubmissions(submissions []model.AssignmentSubmission) error { + if len(submissions) == 0 { + return nil + } + return r.db.Create(&submissions).Error +} diff --git a/backend-go/internal/repository/attendance_repo.go b/backend-go/internal/repository/attendance_repo.go new file mode 100644 index 0000000..dfc0185 --- /dev/null +++ b/backend-go/internal/repository/attendance_repo.go @@ -0,0 +1,184 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "time" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// AttendanceRepo 考勤数据访问层 +type AttendanceRepo struct { + db *gorm.DB +} + +// NewAttendanceRepo 创建考勤 Repository +func NewAttendanceRepo(db *gorm.DB) *AttendanceRepo { + return &AttendanceRepo{db: db} +} + +// GetStudentRecords 获取学生考勤记录 +func (r *AttendanceRepo) GetStudentRecords(studentID int, month string) ([]model.AttendanceRecord, error) { + var records []model.AttendanceRecord + query := r.db.Where("student_id = ?", studentID) + + if month != "" { + query = query.Where("DATE_FORMAT(date, '%Y-%m') = ?", month) + } + + if err := query.Order("date DESC").Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +// GetClassRecords 获取班级考勤记录(支持多种过滤条件) +func (r *AttendanceRepo) GetClassRecords(classID int, date string, studentID int, slot string) ([]model.AttendanceRecord, error) { + var records []model.AttendanceRecord + query := r.db.Table("attendance_records ar"). + Select("ar.*, s.name as student_name, s.student_no"). + Joins("JOIN students s ON ar.student_id = s.student_id"). + Where("1 = 1") + + if classID > 0 { + query = query.Where("s.class_id = ?", classID) + } + if date != "" { + query = query.Where("ar.date = ?", date) + } + if studentID > 0 { + query = query.Where("ar.student_id = ?", studentID) + } + if slot != "" { + query = query.Where("ar.slot = ?", slot) + } + + if err := query.Order("ar.date DESC, s.student_no").Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +// CreateRecordResult 创建或更新考勤记录的结果 +type CreateRecordResult struct { + AttendanceID int + IsUpdate bool + OldDeductionApplied int8 + OldDeductionRecordID *int64 +} + +// CreateRecord 创建或更新考勤记录(存在则更新),使用事务+行锁防止并发竞态 +func (r *AttendanceRepo) CreateRecord(record *model.AttendanceRecord) (*CreateRecordResult, error) { + var result CreateRecordResult + err := r.db.Transaction(func(tx *gorm.DB) error { + // 使用 SELECT ... FOR UPDATE 锁定记录,防止并发请求同时判定为"不存在" + var existing model.AttendanceRecord + findErr := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("student_id = ? AND date = ? AND slot = ?", + record.StudentID, record.Date, record.Slot). + First(&existing).Error + + if findErr == nil { + // 更新已有记录 + if updateErr := tx.Model(&existing).Updates(map[string]interface{}{ + "status": record.Status, + "reason": record.Reason, + "recorder_id": record.RecorderID, + }).Error; updateErr != nil { + return updateErr + } + result = CreateRecordResult{ + AttendanceID: existing.AttendanceID, + IsUpdate: true, + OldDeductionApplied: existing.DeductionApplied, + OldDeductionRecordID: existing.DeductionRecordID, + } + return nil + } + + if findErr != gorm.ErrRecordNotFound { + return findErr + } + + // 插入新记录 + if createErr := tx.Create(record).Error; createErr != nil { + return createErr + } + result = CreateRecordResult{ + AttendanceID: record.AttendanceID, + IsUpdate: false, + } + return nil + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// GetAttendanceStatsBySemester 批量查询学期内所有学生的考勤统计 +func (r *AttendanceRepo) GetAttendanceStatsBySemester(semesterID int, startDate, endDate string) ([]struct { + StudentID int + Status string + Count int64 +}, error) { + var stats []struct { + StudentID int + Status string + Count int64 + } + err := r.db.Model(&model.AttendanceRecord{}). + Select("student_id, status, COUNT(*) as count"). + Where("semester_id = ? OR (semester_id IS NULL AND `date` BETWEEN ? AND ?)", semesterID, startDate, endDate). + Group("student_id, status"). + Find(&stats).Error + if err != nil { + return nil, err + } + return stats, nil +} + +// GetAttendanceStatsByDateRange 通过日期范围查询学生考勤统计 +func (r *AttendanceRepo) GetAttendanceStatsByDateRange(startDate, endDate time.Time, classID int) ([]struct { + StudentID int + Status string + Count int64 +}, error) { + var stats []struct { + StudentID int + Status string + Count int64 + } + query := r.db.Model(&model.AttendanceRecord{}). + Select("student_id, status, COUNT(*) as count"). + Where("date BETWEEN ? AND ?", startDate, endDate) + + if classID > 0 { + query = query.Where("student_id IN (SELECT student_id FROM students WHERE class_id = ?)", classID) + } + + err := query.Group("student_id, status").Find(&stats).Error + if err != nil { + return nil, err + } + return stats, nil +} + +// AssociateSemester 将考勤记录关联到学期 +func (r *AttendanceRepo) AssociateSemester(attendanceID int, semesterID int) error { + return r.db.Model(&model.AttendanceRecord{}). + Where("attendance_id = ? AND semester_id IS NULL", attendanceID). + Update("semester_id", semesterID).Error +} diff --git a/backend-go/internal/repository/class_repo.go b/backend-go/internal/repository/class_repo.go new file mode 100644 index 0000000..1fec2d9 --- /dev/null +++ b/backend-go/internal/repository/class_repo.go @@ -0,0 +1,184 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// ClassRepo 班级数据访问层 +type ClassRepo struct { + db *gorm.DB +} + +// NewClassRepo 创建班级 Repository +func NewClassRepo(db *gorm.DB) *ClassRepo { + return &ClassRepo{db: db} +} + +// GetDB 获取底层数据库连接 +func (r *ClassRepo) GetDB() *gorm.DB { + return r.db +} + +// GetByID 根据ID获取班级信息 +func (r *ClassRepo) GetByID(classID int) (*model.Class, error) { + var class model.Class + if err := r.db.Where("class_id = ?", classID).First(&class).Error; err != nil { + return nil, err + } + return &class, nil +} + +// GetAll 获取所有班级列表 +func (r *ClassRepo) GetAll(includeDisabled bool) ([]model.Class, error) { + var classes []model.Class + query := r.db.Where("1 = 1") + if !includeDisabled { + query = query.Where("status = 1") + } + if err := query.Order("class_id").Find(&classes).Error; err != nil { + return nil, err + } + return classes, nil +} + +// GetByName 根据班级名称获取班级 +func (r *ClassRepo) GetByName(className string) (*model.Class, error) { + var class model.Class + if err := r.db.Where("class_name = ?", className).First(&class).Error; err != nil { + return nil, err + } + return &class, nil +} + +// Create 创建班级 +func (r *ClassRepo) Create(class *model.Class) (int, error) { + if err := r.db.Create(class).Error; err != nil { + return 0, err + } + return class.ClassID, nil +} + +// Update 更新班级信息(仅更新非零值字段) +func (r *ClassRepo) Update(classID int, updates map[string]interface{}) error { + if len(updates) == 0 { + return nil + } + return r.db.Model(&model.Class{}). + Where("class_id = ?", classID). + Updates(updates).Error +} + +// Delete 删除班级(硬删除,需先确认无学生) +func (r *ClassRepo) Delete(classID int) error { + return r.db.Where("class_id = ?", classID).Delete(&model.Class{}).Error +} + +// GetStudentCount 获取班级活跃学生数量 +func (r *ClassRepo) GetStudentCount(classID int) (int64, error) { + var count int64 + if err := r.db.Model(&model.Student{}). + Where("class_id = ? AND status = 1", classID). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// HasActiveStudents 检查班级是否有活跃学生 +func (r *ClassRepo) HasActiveStudents(classID int) (bool, error) { + count, err := r.GetStudentCount(classID) + if err != nil { + return false, err + } + return count > 0, nil +} + +// ========== 班级设置操作 ========== + +// GetSettings 获取班级的所有设置 +func (r *ClassRepo) GetSettings(classID int) ([]model.ClassSetting, error) { + var settings []model.ClassSetting + if err := r.db.Where("class_id = ?", classID).Find(&settings).Error; err != nil { + return nil, err + } + return settings, nil +} + +// GetSetting 获取班级单个设置项 +func (r *ClassRepo) GetSetting(classID int, key string) (*model.ClassSetting, error) { + var setting model.ClassSetting + if err := r.db.Where("class_id = ? AND setting_key = ?", classID, key).First(&setting).Error; err != nil { + return nil, err + } + return &setting, nil +} + +// SaveSetting 保存班级设置项(upsert) +func (r *ClassRepo) SaveSetting(classID int, key, value string) error { + setting := model.ClassSetting{ + ClassID: classID, + SettingKey: key, + SettingValue: value, + } + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "class_id"}, {Name: "setting_key"}}, + DoUpdates: clause.AssignmentColumns([]string{"setting_value"}), + }).Create(&setting).Error +} + +// BatchSaveSettings 批量保存班级设置项 +func (r *ClassRepo) BatchSaveSettings(classID int, settings map[string]string) error { + for key, value := range settings { + if err := r.SaveSetting(classID, key, value); err != nil { + return err + } + } + return nil +} + +// ========== 班级功能开关操作 ========== + +// GetFeatures 获取班级的所有功能开关 +func (r *ClassRepo) GetFeatures(classID int) ([]model.ClassFeature, error) { + var features []model.ClassFeature + if err := r.db.Where("class_id = ?", classID).Find(&features).Error; err != nil { + return nil, err + } + return features, nil +} + +// GetFeature 获取班级单个功能开关 +func (r *ClassRepo) GetFeature(classID int, featureKey string) (*model.ClassFeature, error) { + var feature model.ClassFeature + if err := r.db.Where("class_id = ? AND feature_key = ?", classID, featureKey).First(&feature).Error; err != nil { + return nil, err + } + return &feature, nil +} + +// SaveFeature 保存班级功能开关(upsert) +func (r *ClassRepo) SaveFeature(classID int, featureKey string, enabled int8) error { + feature := model.ClassFeature{ + ClassID: classID, + FeatureKey: featureKey, + Enabled: enabled, + } + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "class_id"}, {Name: "feature_key"}}, + DoUpdates: clause.AssignmentColumns([]string{"enabled"}), + }).Create(&feature).Error +} diff --git a/backend-go/internal/repository/conduct_repo.go b/backend-go/internal/repository/conduct_repo.go new file mode 100644 index 0000000..53a1380 --- /dev/null +++ b/backend-go/internal/repository/conduct_repo.go @@ -0,0 +1,294 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "fmt" + "strings" + "time" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// ConductRepo 操行分记录数据访问层 +type ConductRepo struct { + db *gorm.DB +} + +// NewConductRepo 创建操行分 Repository +func NewConductRepo(db *gorm.DB) *ConductRepo { + return &ConductRepo{db: db} +} + +// CreateRecord 创建操行分记录 +func (r *ConductRepo) CreateRecord(record *model.ConductRecord) (int64, error) { + if err := r.db.Create(record).Error; err != nil { + return 0, err + } + return record.RecordID, nil +} + +// GetRecordByID 根据ID获取记录(含学生信息) +func (r *ConductRepo) GetRecordByID(recordID int64) (*model.ConductRecord, error) { + var record model.ConductRecord + if err := r.db.Table("conduct_records cr"). + Select("cr.*, s.name as student_name, s.total_points"). + Joins("JOIN students s ON cr.student_id = s.student_id"). + Where("cr.record_id = ?", recordID). + First(&record).Error; err != nil { + return nil, err + } + return &record, nil +} + +// CountStudentRecords 统计学生操行分记录总数 +func (r *ConductRepo) CountStudentRecords(studentID int, includeRevoked bool, startDate, endDate string, recorderID int) (int64, error) { + var count int64 + query := r.db.Model(&model.ConductRecord{}).Where("student_id = ?", studentID) + + if !includeRevoked { + query = query.Where("is_revoked = 0") + } + if startDate != "" { + query = query.Where("DATE(created_at) >= ?", startDate) + } + if endDate != "" { + query = query.Where("DATE(created_at) <= ?", endDate) + } + if recorderID > 0 { + query = query.Where("recorder_id = ?", recorderID) + } + + if err := query.Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// GetStudentRecords 获取学生操行分记录 +func (r *ConductRepo) GetStudentRecords(studentID int, limit, offset int, includeRevoked bool, startDate, endDate string, recorderID int) ([]model.ConductRecord, error) { + var records []model.ConductRecord + query := r.db.Table("conduct_records cr"). + Select("cr.*, u.real_name as recorder_real"). + Joins("LEFT JOIN users u ON cr.recorder_id = u.user_id"). + Where("cr.student_id = ?", studentID) + + if !includeRevoked { + query = query.Where("cr.is_revoked = 0") + } + if startDate != "" { + query = query.Where("DATE(cr.created_at) >= ?", startDate) + } + if endDate != "" { + query = query.Where("DATE(cr.created_at) <= ?", endDate) + } + if recorderID > 0 { + query = query.Where("cr.recorder_id = ?", recorderID) + } + + if err := query.Order("cr.created_at DESC"). + Limit(limit). + Offset(offset). + Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +// GetAllRecords 获取所有记录(管理员用,支持多种过滤条件) +func (r *ConductRepo) GetAllRecords(classID int, limit, offset int, startDate, endDate string, + studentID int, includeRevoked bool, relatedType, reasonPrefix string, + isRevoked *int, reasonSearch string) ([]model.ConductRecord, error) { + + var records []model.ConductRecord + query := r.db.Table("conduct_records cr"). + Select("cr.*, s.name as student_name, s.student_no, s.class_id, u.real_name as recorder_real, ru.real_name as revoker_name"). + Joins("JOIN students s ON cr.student_id = s.student_id"). + Joins("JOIN users u ON cr.recorder_id = u.user_id"). + Joins("LEFT JOIN users ru ON cr.revoked_by = ru.user_id"). + Where("1 = 1") + + if !includeRevoked { + query = query.Where("cr.is_revoked = 0") + } + if classID > 0 { + query = query.Where("s.class_id = ?", classID) + } + if studentID > 0 { + query = query.Where("cr.student_id = ?", studentID) + } + if startDate != "" { + query = query.Where("DATE(cr.created_at) >= ?", startDate) + } + if endDate != "" { + query = query.Where("DATE(cr.created_at) <= ?", endDate) + } + if relatedType != "" { + query = query.Where("cr.related_type = ?", relatedType) + } + if reasonPrefix != "" { + query = query.Where("cr.reason LIKE ?", fmt.Sprintf("%s%%", reasonPrefix)) + } + if reasonSearch != "" { + escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(reasonSearch) + query = query.Where("cr.reason LIKE ?", fmt.Sprintf("%%%s%%", escaped)) + } + if isRevoked != nil { + query = query.Where("cr.is_revoked = ?", *isRevoked) + } + + if err := query.Order("cr.created_at DESC"). + Limit(limit). + Offset(offset). + Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +// CountAllRecords 统计记录总数(与 GetAllRecords 使用相同过滤条件) +func (r *ConductRepo) CountAllRecords(classID int, startDate, endDate string, + studentID int, includeRevoked bool, relatedType, reasonPrefix string, + isRevoked *int, reasonSearch string) (int64, error) { + + var count int64 + query := r.db.Table("conduct_records cr"). + Joins("JOIN students s ON cr.student_id = s.student_id"). + Where("1 = 1") + + if !includeRevoked { + query = query.Where("cr.is_revoked = 0") + } + if classID > 0 { + query = query.Where("s.class_id = ?", classID) + } + if studentID > 0 { + query = query.Where("cr.student_id = ?", studentID) + } + if startDate != "" { + query = query.Where("DATE(cr.created_at) >= ?", startDate) + } + if endDate != "" { + query = query.Where("DATE(cr.created_at) <= ?", endDate) + } + if relatedType != "" { + query = query.Where("cr.related_type = ?", relatedType) + } + if reasonPrefix != "" { + query = query.Where("cr.reason LIKE ?", fmt.Sprintf("%s%%", reasonPrefix)) + } + if reasonSearch != "" { + escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(reasonSearch) + query = query.Where("cr.reason LIKE ?", fmt.Sprintf("%%%s%%", escaped)) + } + if isRevoked != nil { + query = query.Where("cr.is_revoked = ?", *isRevoked) + } + + if err := query.Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// RevokeRecord 撤销单条操行分记录 +func (r *ConductRepo) RevokeRecord(recordID int64, revokerID int) error { + return r.db.Model(&model.ConductRecord{}). + Where("record_id = ? AND is_revoked = 0", recordID). + Updates(map[string]interface{}{ + "is_revoked": 1, + "revoked_by": revokerID, + }).Error +} + +// BatchRevokeRecords 批量撤销记录 +func (r *ConductRepo) BatchRevokeRecords(recordIDs []int64, revokerID int) (int64, error) { + result := r.db.Model(&model.ConductRecord{}). + Where("record_id IN ? AND is_revoked = 0", recordIDs). + Updates(map[string]interface{}{ + "is_revoked": 1, + "revoked_by": revokerID, + "revoked_at": time.Now(), + }) + if result.Error != nil { + return 0, result.Error + } + return result.RowsAffected, nil +} + +// BatchRestoreRecords 批量反撤销记录 +func (r *ConductRepo) BatchRestoreRecords(recordIDs []int64) (int64, error) { + result := r.db.Model(&model.ConductRecord{}). + Where("record_id IN ? AND is_revoked = 1", recordIDs). + Updates(map[string]interface{}{ + "is_revoked": 0, + "revoked_by": nil, + "revoked_at": nil, + }) + if result.Error != nil { + return 0, result.Error + } + return result.RowsAffected, nil +} + +// AssociateSemester 将记录关联到学期 +func (r *ConductRepo) AssociateSemester(recordID int64, semesterID int) error { + return r.db.Model(&model.ConductRecord{}). + Where("record_id = ? AND semester_id IS NULL", recordID). + Update("semester_id", semesterID).Error +} + +// GetHomeworkRecords 获取学生作业相关的操行分记录 +func (r *ConductRepo) GetHomeworkRecords(studentID int) ([]model.ConductRecord, error) { + var records []model.ConductRecord + if err := r.db.Where("student_id = ? AND related_type = 'homework' AND is_revoked = 0", studentID). + Order("created_at DESC"). + Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +// GetStudentPointsByType 按 related_type 在 SQL 层聚合学生分数(避免全量加载),支持 limit 限制返回数量 +func (r *ConductRepo) GetStudentPointsByType(classID int, relatedType string, limit int) ([]struct { + StudentID int + StudentNo string + Name string + TotalPoints int +}, error) { + var results []struct { + StudentID int + StudentNo string + Name string + TotalPoints int + } + err := r.db.Table("conduct_records cr"). + Select("cr.student_id, s.student_no, s.name, SUM(cr.points_change) as total_points"). + Joins("JOIN students s ON cr.student_id = s.student_id"). + Where("s.class_id = ? AND s.status = 1 AND cr.related_type = ? AND cr.is_revoked = 0", classID, relatedType). + Group("cr.student_id, s.student_no, s.name"). + Order("total_points DESC"). + Limit(limit). + Find(&results).Error + return results, err +} + +// GetStudentTotalPoints 获取学生当前总分 +func (r *ConductRepo) GetStudentTotalPoints(studentID int) (int, error) { + var student model.Student + if err := r.db.Where("student_id = ?", studentID).First(&student).Error; err != nil { + return 0, err + } + return student.TotalPoints, nil +} diff --git a/backend-go/internal/repository/log_repo.go b/backend-go/internal/repository/log_repo.go new file mode 100644 index 0000000..267813d --- /dev/null +++ b/backend-go/internal/repository/log_repo.go @@ -0,0 +1,91 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// LogRepo 日志数据访问层 +type LogRepo struct { + db *gorm.DB +} + +// NewLogRepo 创建日志 Repository +func NewLogRepo(db *gorm.DB) *LogRepo { + return &LogRepo{db: db} +} + +// ========== 操作日志 ========== + +// CreateOperationLog 写入操作日志 +func (r *LogRepo) CreateOperationLog(log *model.OperationLog) (int64, error) { + if err := r.db.Create(log).Error; err != nil { + return 0, err + } + return log.LogID, nil +} + +// GetOperationLogs 查询操作日志(支持按操作者和班级过滤) +func (r *LogRepo) GetOperationLogs(operatorID int, classID int, operationType string, page, pageSize int) ([]model.OperationLog, int64, error) { + var logs []model.OperationLog + var total int64 + + query := r.db.Model(&model.OperationLog{}).Where("1 = 1") + + if operatorID > 0 { + query = query.Where("operator_id = ?", operatorID) + } + if classID > 0 { + query = query.Where("class_id = ?", classID) + } + if operationType != "" { + query = query.Where("operation_type = ?", operationType) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC"). + Limit(pageSize). + Offset(offset). + Find(&logs).Error; err != nil { + return nil, 0, err + } + + return logs, total, nil +} + +// ========== 登录日志 ========== + +// CreateLoginLog 写入登录日志 +func (r *LogRepo) CreateLoginLog(log *model.LoginLog) (int64, error) { + if err := r.db.Create(log).Error; err != nil { + return 0, err + } + return log.LogID, nil +} + +// GetRecentLoginFailCount 获取最近 5 分钟内的登录失败次数 +func (r *LogRepo) GetRecentLoginFailCount(username string) (int64, error) { + var count int64 + if err := r.db.Model(&model.LoginLog{}). + Where("username = ? AND login_result = 0 AND created_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE)", username). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} diff --git a/backend-go/internal/repository/semester_repo.go b/backend-go/internal/repository/semester_repo.go new file mode 100644 index 0000000..f4eec43 --- /dev/null +++ b/backend-go/internal/repository/semester_repo.go @@ -0,0 +1,291 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "fmt" + "time" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// SemesterRepo 学期数据访问层 +type SemesterRepo struct { + db *gorm.DB +} + +// NewSemesterRepo 创建学期 Repository +func NewSemesterRepo(db *gorm.DB) *SemesterRepo { + return &SemesterRepo{db: db} +} + +// GetDB 获取底层数据库连接(用于事务操作) +func (r *SemesterRepo) GetDB() *gorm.DB { + return r.db +} + +// Create 创建学期 +func (r *SemesterRepo) Create(semester *model.Semester) (int, error) { + if err := r.db.Create(semester).Error; err != nil { + return 0, err + } + return semester.SemesterID, nil +} + +// GetByID 根据ID获取学期信息 +func (r *SemesterRepo) GetByID(semesterID int) (*model.Semester, error) { + var semester model.Semester + if err := r.db.Where("semester_id = ?", semesterID).First(&semester).Error; err != nil { + return nil, err + } + return &semester, nil +} + +// GetAll 获取所有学期列表 +func (r *SemesterRepo) GetAll() ([]model.Semester, error) { + var semesters []model.Semester + if err := r.db.Order("created_at DESC").Find(&semesters).Error; err != nil { + return nil, err + } + return semesters, nil +} + +// GetActive 获取当前活跃学期(优先 is_active 标记,降级为日期范围匹配) +func (r *SemesterRepo) GetActive() (*model.Semester, error) { + var semester model.Semester + + // 第一优先级:is_active 标记 + if err := r.db.Where("is_active = 1 AND is_archived = 0"). + Limit(1).First(&semester).Error; err == nil { + return &semester, nil + } + + // 第二优先级:日期范围匹配 + today := time.Now().Format("2006-01-02") + if err := r.db.Where("is_archived = 0 AND start_date <= ? AND (end_date IS NULL OR end_date >= ?)", today, today). + Limit(1).First(&semester).Error; err != nil { + return nil, err + } + return &semester, nil +} + +// DeactivateAll 将所有学期设为非活跃 +func (r *SemesterRepo) DeactivateAll() error { + return r.db.Model(&model.Semester{}). + Where("is_active = 1"). + Update("is_active", 0).Error +} + +// Activate 设为当前活跃学期 +func (r *SemesterRepo) Activate(semesterID int) error { + return r.db.Model(&model.Semester{}). + Where("semester_id = ? AND is_archived = 0", semesterID). + Update("is_active", 1).Error +} + +// Archive 归档学期 +func (r *SemesterRepo) Archive(semesterID int) error { + return r.db.Model(&model.Semester{}). + Where("semester_id = ? AND is_archived = 0", semesterID). + Updates(map[string]interface{}{ + "is_archived": 1, + "is_active": 0, + }).Error +} + +// Update 编辑学期信息(仅未归档) +func (r *SemesterRepo) Update(semesterID int, updates map[string]interface{}) error { + if len(updates) == 0 { + return nil + } + return r.db.Model(&model.Semester{}). + Where("semester_id = ? AND is_archived = 0", semesterID). + Updates(updates).Error +} + +// Delete 删除学期 +func (r *SemesterRepo) Delete(semesterID int) error { + return r.db.Where("semester_id = ?", semesterID).Delete(&model.Semester{}).Error +} + +// CountArchives 统计学期归档数据数量 +func (r *SemesterRepo) CountArchives(semesterID int) (int64, error) { + var count int64 + if err := r.db.Model(&model.SemesterArchive{}). + Where("semester_id = ?", semesterID). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// CountRecordsBySemester 统计学期关联的记录数 +func (r *SemesterRepo) CountRecordsBySemester(semesterID int) (conductCount, attendanceCount int64, err error) { + if err = r.db.Model(&model.ConductRecord{}). + Where("semester_id = ?", semesterID). + Count(&conductCount).Error; err != nil { + return 0, 0, err + } + if err = r.db.Model(&model.AttendanceRecord{}). + Where("semester_id = ?", semesterID). + Count(&attendanceCount).Error; err != nil { + return 0, 0, err + } + return conductCount, attendanceCount, nil +} + +// AssociateRecordsByDateRange 按日期范围关联记录到学期 +func (r *SemesterRepo) AssociateRecordsByDateRange(semesterID int, startDate, endDate string) (conductCount, attendanceCount int64, err error) { + if startDate == "" || endDate == "" { + return 0, 0, fmt.Errorf("日期范围不能为空") + } + + // 关联操行分记录 + result := r.db.Model(&model.ConductRecord{}). + Where("semester_id IS NULL AND created_at BETWEEN ? AND CONCAT(?, ' 23:59:59')", startDate, endDate). + Update("semester_id", semesterID) + if result.Error != nil { + return 0, 0, result.Error + } + conductCount = result.RowsAffected + + // 关联考勤记录 + result = r.db.Model(&model.AttendanceRecord{}). + Where("semester_id IS NULL AND `date` BETWEEN ? AND ?", startDate, endDate). + Update("semester_id", semesterID) + if result.Error != nil { + return conductCount, 0, result.Error + } + attendanceCount = result.RowsAffected + + return conductCount, attendanceCount, nil +} + +// GetConductRecordSemesterID 获取操行分记录所属的学期ID +func (r *SemesterRepo) GetConductRecordSemesterID(recordID int64) (*int, error) { + var record model.ConductRecord + if err := r.db.Where("record_id = ?", recordID).First(&record).Error; err != nil { + return nil, err + } + return record.SemesterID, nil +} + +// ========== 学期归档操作 ========== + +// BatchCreateArchives 批量创建归档快照 +func (r *SemesterRepo) BatchCreateArchives(archives []model.SemesterArchive) error { + if len(archives) == 0 { + return nil + } + return r.db.Create(&archives).Error +} + +// DeleteArchivesBySemester 删除指定学期的所有归档数据 +func (r *SemesterRepo) DeleteArchivesBySemester(semesterID int) error { + return r.db.Where("semester_id = ?", semesterID).Delete(&model.SemesterArchive{}).Error +} + +// GetArchivesBySemester 获取学期的归档数据 +func (r *SemesterRepo) GetArchivesBySemester(semesterID int, classID int, page, pageSize int) ([]model.SemesterArchive, int64, error) { + var archives []model.SemesterArchive + var total int64 + + query := r.db.Model(&model.SemesterArchive{}).Where("semester_id = ?", semesterID) + if classID > 0 { + query = query.Where("class_id = ?", classID) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if err := query.Order("rank_position ASC"). + Limit(pageSize). + Offset(offset). + Find(&archives).Error; err != nil { + return nil, 0, err + } + + return archives, total, nil +} + +// GetArchivesByStudent 获取学生在所有已归档学期的数据 +func (r *SemesterRepo) GetArchivesByStudent(studentID int) ([]model.SemesterArchive, error) { + var archives []model.SemesterArchive + if err := r.db.Table("semester_archives sa"). + Select("sa.archive_id, sa.semester_id, sa.student_id, sa.student_no, "+ + "sa.student_name, sa.final_points, sa.rank_position, "+ + "sa.total_students, sa.attendance_present, sa.attendance_absent, "+ + "sa.attendance_late, sa.attendance_leave, "+ + "sa.homework_submitted, sa.homework_not_submitted, sa.homework_late, "+ + "sa.archived_at, s.semester_name, s.start_date, s.end_date"). + Joins("JOIN semesters s ON sa.semester_id = s.semester_id"). + Where("sa.student_id = ?", studentID). + Order("sa.archived_at DESC"). + Find(&archives).Error; err != nil { + return nil, err + } + return archives, nil +} + +// ========== 周期归档操作 ========== + +// GetPeriodArchives 获取周期归档列表 +func (r *SemesterRepo) GetPeriodArchives(classID int, periodType string, page, pageSize int) ([]model.PeriodArchive, int64, error) { + var archives []model.PeriodArchive + var total int64 + + query := r.db.Model(&model.PeriodArchive{}). + Where("class_id = ? AND period_type = ?", classID, periodType) + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if err := query.Order("archived_at DESC, period_label DESC, rank_position ASC"). + Limit(pageSize). + Offset(offset). + Find(&archives).Error; err != nil { + return nil, 0, err + } + + return archives, total, nil +} + +// GetPeriodArchiveLabels 获取班级的所有周期归档标签(按时间倒序去重) +func (r *SemesterRepo) GetPeriodArchiveLabels(classID int, periodType string) ([]string, error) { + var labels []string + if err := r.db.Model(&model.PeriodArchive{}). + Where("class_id = ? AND period_type = ?", classID, periodType). + Distinct("period_label"). + Order("period_label DESC"). + Pluck("period_label", &labels).Error; err != nil { + return nil, err + } + return labels, nil +} + +// GetLatestPeriodArchiveLabel 获取指定班级最近一次周期归档的标签 +func (r *SemesterRepo) GetLatestPeriodArchiveLabel(classID int, periodType string) (string, error) { + var archive model.PeriodArchive + if err := r.db.Where("class_id = ? AND period_type = ?", classID, periodType). + Order("archived_at DESC"). + Limit(1). + First(&archive).Error; err != nil { + return "", err + } + return archive.PeriodLabel, nil +} diff --git a/backend-go/internal/repository/student_repo.go b/backend-go/internal/repository/student_repo.go new file mode 100644 index 0000000..7ae5a98 --- /dev/null +++ b/backend-go/internal/repository/student_repo.go @@ -0,0 +1,230 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "fmt" + "strings" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// StudentRepo 学生数据访问层 +type StudentRepo struct { + db *gorm.DB +} + +// NewStudentRepo 创建学生 Repository +func NewStudentRepo(db *gorm.DB) *StudentRepo { + return &StudentRepo{db: db} +} + +// GetByID 根据ID获取学生信息(含班级名称) +func (r *StudentRepo) GetByID(studentID int) (*model.Student, error) { + var student model.Student + if err := r.db.Table("students s"). + Select("s.*, c.class_name"). + Joins("LEFT JOIN classes c ON s.class_id = c.class_id"). + Where("s.student_id = ?", studentID). + First(&student).Error; err != nil { + return nil, err + } + return &student, nil +} + +// GetByStudentNo 根据学号获取学生(可指定班级) +func (r *StudentRepo) GetByStudentNo(studentNo string, classID int) (*model.Student, error) { + var student model.Student + query := r.db.Where("student_no = ?", studentNo) + if classID > 0 { + query = query.Where("class_id = ?", classID) + } + if err := query.First(&student).Error; err != nil { + return nil, err + } + return &student, nil +} + +// GetAll 获取指定班级的学生列表 +func (r *StudentRepo) GetAll(classID int, includeDisabled bool) ([]model.Student, error) { + var students []model.Student + query := r.db.Where("class_id = ?", classID) + if !includeDisabled { + query = query.Where("status = 1") + } + if err := query.Order("student_no").Find(&students).Error; err != nil { + return nil, err + } + return students, nil +} + +// GetDormitoryList 获取班级内所有不重复的宿舍号列表 +func (r *StudentRepo) GetDormitoryList(classID int) ([]string, error) { + var dormitories []string + err := r.db.Model(&model.Student{}). + Where("class_id = ? AND status = 1 AND dormitory_number IS NOT NULL AND dormitory_number != ''", classID). + Distinct("dormitory_number"). + Order("dormitory_number"). + Pluck("dormitory_number", &dormitories).Error + if err != nil { + return nil, err + } + return dormitories, nil +} + +// Create 创建学生记录 +func (r *StudentRepo) Create(student *model.Student) (int, error) { + if err := r.db.Create(student).Error; err != nil { + return 0, err + } + return student.StudentID, nil +} + +// Update 更新学生信息(仅更新非零值字段) +func (r *StudentRepo) Update(studentID int, updates map[string]interface{}) error { + if len(updates) == 0 { + return nil + } + return r.db.Model(&model.Student{}). + Where("student_id = ?", studentID). + Updates(updates).Error +} + +// SoftDelete 软删除学生 +func (r *StudentRepo) SoftDelete(studentID int) error { + return r.db.Model(&model.Student{}). + Where("student_id = ?", studentID). + Update("status", 0).Error +} + +// UpdateTotalPoints 更新学生总分(增量更新,下限保护为 0) +func (r *StudentRepo) UpdateTotalPoints(studentID int, pointsChange int) error { + return r.db.Model(&model.Student{}). + Where("student_id = ?", studentID). + Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", pointsChange)).Error +} + +// GetRanking 获取班级内学生排行 +func (r *StudentRepo) GetRanking(classID int, limit int) ([]model.Student, error) { + var students []model.Student + if err := r.db.Where("status = 1 AND class_id = ?", classID). + Order("total_points DESC, student_id ASC"). + Limit(limit). + Find(&students).Error; err != nil { + return nil, err + } + return students, nil +} + +// GetTotalCount 获取班级内活跃学生总数 +func (r *StudentRepo) GetTotalCount(classID int) (int64, error) { + var count int64 + if err := r.db.Model(&model.Student{}). + Where("status = 1 AND class_id = ?", classID). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// ListByClass 分页获取班级学生列表(支持搜索和宿舍号过滤) +func (r *StudentRepo) ListByClass(classID int, page, pageSize int, search, dormitoryNumber string) ([]model.Student, int64, error) { + var students []model.Student + var total int64 + + query := r.db.Model(&model.Student{}).Where("status = 1 AND class_id = ?", classID) + + if search != "" { + escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(search) + searchPattern := fmt.Sprintf("%%%s%%", escaped) + query = query.Where("student_no LIKE ? OR name LIKE ?", searchPattern, searchPattern) + } + + if dormitoryNumber != "" { + query = query.Where("dormitory_number = ?", dormitoryNumber) + } + + // 获取总数 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 分页查询 + offset := (page - 1) * pageSize + if err := query.Order("student_no"). + Limit(pageSize). + Offset(offset). + Find(&students).Error; err != nil { + return nil, 0, err + } + + return students, total, nil +} + +// BatchCreate 批量创建学生 +func (r *StudentRepo) BatchCreate(students []model.Student) error { + return r.db.Create(&students).Error +} + +// GetStudentNosByClass 获取指定班级所有学生学号(用于批量导入去重) +func (r *StudentRepo) GetStudentNosByClass(classID int) ([]string, error) { + var studentNos []string + if err := r.db.Model(&model.Student{}). + Where("class_id = ?", classID). + Pluck("student_no", &studentNos).Error; err != nil { + return nil, err + } + return studentNos, nil +} + +// ResetPoints 重置班级内所有学生的操行分为初始值 +func (r *StudentRepo) ResetPoints(classID int, initialPoints int) error { + return r.db.Model(&model.Student{}). + Where("class_id = ? AND status = 1", classID). + Update("total_points", initialPoints).Error +} + +// GetByParentAccount 根据家长账号查找学生 +func (r *StudentRepo) GetByParentAccount(parentAccount string) (*model.Student, error) { + var student model.Student + if err := r.db.Where("parent_account = ? AND status = 1", parentAccount).First(&student).Error; err != nil { + return nil, err + } + return &student, nil +} + +// GetRankByStudentID 使用密集排名(dense rank)计算学生排名:相同分数同名次,后续名次不跳过 +func (r *StudentRepo) GetRankByStudentID(classID, studentID int) (int, error) { + var student model.Student + if err := r.db.Select("total_points").Where("student_id = ?", studentID).First(&student).Error; err != nil { + return 0, err + } + var distinctHigherCount int64 + if err := r.db.Raw("SELECT COUNT(DISTINCT total_points) FROM students WHERE status = 1 AND class_id = ? AND total_points > ?", + classID, student.TotalPoints).Scan(&distinctHigherCount).Error; err != nil { + return 0, err + } + return int(distinctHigherCount) + 1, nil +} + +// GetStudentsByClassID 获取班级内所有活跃学生(用于归档等批量操作) +func (r *StudentRepo) GetStudentsByClassID(classID int) ([]model.Student, error) { + var students []model.Student + if err := r.db.Where("class_id = ? AND status = 1", classID). + Order("total_points DESC, student_id ASC"). + Find(&students).Error; err != nil { + return nil, err + } + return students, nil +} diff --git a/backend-go/internal/repository/subject_repo.go b/backend-go/internal/repository/subject_repo.go new file mode 100644 index 0000000..6d3129d --- /dev/null +++ b/backend-go/internal/repository/subject_repo.go @@ -0,0 +1,104 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// SubjectRepo 科目数据访问层 +type SubjectRepo struct { + db *gorm.DB +} + +// NewSubjectRepo 创建科目 Repository +func NewSubjectRepo(db *gorm.DB) *SubjectRepo { + return &SubjectRepo{db: db} +} + +// GetAll 获取所有科目列表 +func (r *SubjectRepo) GetAll(isActive *bool) ([]model.Subject, error) { + var subjects []model.Subject + query := r.db.Where("1 = 1") + if isActive != nil { + if *isActive { + query = query.Where("is_active = 1") + } else { + query = query.Where("is_active = 0") + } + } + if err := query.Order("sort_order, subject_id").Find(&subjects).Error; err != nil { + return nil, err + } + return subjects, nil +} + +// GetByID 根据ID获取科目 +func (r *SubjectRepo) GetByID(subjectID int) (*model.Subject, error) { + var subject model.Subject + if err := r.db.Where("subject_id = ?", subjectID).First(&subject).Error; err != nil { + return nil, err + } + return &subject, nil +} + +// GetByName 根据科目名称获取科目 +func (r *SubjectRepo) GetByName(subjectName string) (*model.Subject, error) { + var subject model.Subject + if err := r.db.Where("subject_name = ?", subjectName).First(&subject).Error; err != nil { + return nil, err + } + return &subject, nil +} + +// Create 创建科目 +func (r *SubjectRepo) Create(subject *model.Subject) (int, error) { + if err := r.db.Create(subject).Error; err != nil { + return 0, err + } + return subject.SubjectID, nil +} + +// Update 更新科目信息 +func (r *SubjectRepo) Update(subjectID int, updates map[string]interface{}) error { + if len(updates) == 0 { + return nil + } + return r.db.Model(&model.Subject{}). + Where("subject_id = ?", subjectID). + Updates(updates).Error +} + +// Delete 删除科目 +func (r *SubjectRepo) Delete(subjectID int) error { + return r.db.Where("subject_id = ?", subjectID).Delete(&model.Subject{}).Error +} + +// HasRelatedData 检查科目是否有关联的作业数据 +func (r *SubjectRepo) HasRelatedData(subjectID int) (bool, error) { + var count int64 + if err := r.db.Model(&model.Assignment{}). + Where("subject_id = ?", subjectID). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +// Activate 激活科目 +func (r *SubjectRepo) Activate(subjectID int) error { + return r.db.Model(&model.Subject{}). + Where("subject_id = ?", subjectID). + Update("is_active", 1).Error +} diff --git a/backend-go/internal/repository/super_admin_repo.go b/backend-go/internal/repository/super_admin_repo.go new file mode 100644 index 0000000..67e3855 --- /dev/null +++ b/backend-go/internal/repository/super_admin_repo.go @@ -0,0 +1,110 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// SuperAdminRepo 超级管理员数据访问层 +type SuperAdminRepo struct { + db *gorm.DB +} + +// NewSuperAdminRepo 创建超级管理员 Repository +func NewSuperAdminRepo(db *gorm.DB) *SuperAdminRepo { + return &SuperAdminRepo{db: db} +} + +// GetByUsername 根据用户名获取超级管理员 +func (r *SuperAdminRepo) GetByUsername(username string) (*model.SuperAdmin, error) { + var admin model.SuperAdmin + if err := r.db.Where("username = ? AND status = 1", username).First(&admin).Error; err != nil { + return nil, err + } + return &admin, nil +} + +// GetByID 根据ID获取超级管理员 +func (r *SuperAdminRepo) GetByID(id int) (*model.SuperAdmin, error) { + var admin model.SuperAdmin + if err := r.db.Where("id = ?", id).First(&admin).Error; err != nil { + return nil, err + } + return &admin, nil +} + +// Create 创建超级管理员 +func (r *SuperAdminRepo) Create(admin *model.SuperAdmin) (int, error) { + if err := r.db.Create(admin).Error; err != nil { + return 0, err + } + return admin.ID, nil +} + +// UpdatePassword 更新超级管理员密码 +func (r *SuperAdminRepo) UpdatePassword(id int, passwordHash string) error { + return r.db.Model(&model.SuperAdmin{}). + Where("id = ?", id). + Update("password_hash", passwordHash).Error +} + +// UpdatePasswordWithSalt 更新超级管理员密码和盐值,并清除强制改密标记 +func (r *SuperAdminRepo) UpdatePasswordWithSalt(id int, passwordHash, salt string) error { + return r.db.Model(&model.SuperAdmin{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "password_hash": passwordHash, + "salt": salt, + "need_change_password": 0, + }).Error +} + +// CheckUsernameExists 检查用户名是否存在 +func (r *SuperAdminRepo) CheckUsernameExists(username string) (bool, error) { + var count int64 + if err := r.db.Model(&model.SuperAdmin{}).Where("username = ?", username).Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +// List 获取所有超级管理员 +func (r *SuperAdminRepo) List() ([]model.SuperAdmin, error) { + var admins []model.SuperAdmin + if err := r.db.Order("id").Find(&admins).Error; err != nil { + return nil, err + } + return admins, nil +} + +// UpdateStatus 更新超级管理员状态 +func (r *SuperAdminRepo) UpdateStatus(id int, status int8) error { + return r.db.Model(&model.SuperAdmin{}). + Where("id = ?", id). + Update("status", status).Error +} + +// EnsureDefaultAdmin 确保默认超级管理员存在(使用 INSERT IGNORE 避免并发竞态) +func (r *SuperAdminRepo) EnsureDefaultAdmin(username, passwordHash, salt, realName string) error { + admin := model.SuperAdmin{ + Username: username, + PasswordHash: passwordHash, + Salt: salt, + RealName: realName, + Status: 1, + } + return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&admin).Error +} diff --git a/backend-go/internal/repository/system_setting_repo.go b/backend-go/internal/repository/system_setting_repo.go new file mode 100644 index 0000000..1191494 --- /dev/null +++ b/backend-go/internal/repository/system_setting_repo.go @@ -0,0 +1,100 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// SystemSettingRepo 系统设置数据访问层 +type SystemSettingRepo struct { + db *gorm.DB +} + +// NewSystemSettingRepo 创建系统设置 Repository +func NewSystemSettingRepo(db *gorm.DB) *SystemSettingRepo { + return &SystemSettingRepo{db: db} +} + +// GetByKey 根据键名获取系统设置 +func (r *SystemSettingRepo) GetByKey(key string) (*model.SystemSetting, error) { + var setting model.SystemSetting + if err := r.db.Where("setting_key = ?", key).First(&setting).Error; err != nil { + return nil, err + } + return &setting, nil +} + +// GetAll 获取所有系统设置 +func (r *SystemSettingRepo) GetAll() ([]model.SystemSetting, error) { + var settings []model.SystemSetting + if err := r.db.Find(&settings).Error; err != nil { + return nil, err + } + return settings, nil +} + +// GetByKeyMap 获取所有系统设置并转为 map +func (r *SystemSettingRepo) GetByKeyMap() (map[string]string, error) { + settings, err := r.GetAll() + if err != nil { + return nil, err + } + result := make(map[string]string, len(settings)) + for _, s := range settings { + result[s.SettingKey] = s.SettingValue + } + return result, nil +} + +// Save 保存系统设置(upsert) +func (r *SystemSettingRepo) Save(key, value string) error { + setting := model.SystemSetting{ + SettingKey: key, + SettingValue: value, + } + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "setting_key"}}, + DoUpdates: clause.AssignmentColumns([]string{"setting_value"}), + }).Create(&setting).Error +} + +// BatchSave 批量保存系统设置 +func (r *SystemSettingRepo) BatchSave(settings map[string]string) error { + for key, value := range settings { + if err := r.Save(key, value); err != nil { + return err + } + } + return nil +} + +// GetValue 根据键名获取设置值 +func (r *SystemSettingRepo) GetValue(key string) (string, error) { + setting, err := r.GetByKey(key) + if err != nil { + return "", err + } + return setting.SettingValue, nil +} + +// GetValueWithDefault 根据键名获取设置值,不存在则返回默认值 +func (r *SystemSettingRepo) GetValueWithDefault(key, defaultValue string) string { + setting, err := r.GetByKey(key) + if err != nil { + return defaultValue + } + return setting.SettingValue +} diff --git a/backend-go/internal/repository/user_repo.go b/backend-go/internal/repository/user_repo.go new file mode 100644 index 0000000..5ea3636 --- /dev/null +++ b/backend-go/internal/repository/user_repo.go @@ -0,0 +1,166 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "time" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// UserRepo 用户数据访问层 +type UserRepo struct { + db *gorm.DB +} + +// NewUserRepo 创建用户 Repository +func NewUserRepo(db *gorm.DB) *UserRepo { + return &UserRepo{db: db} +} + +// GetByUsername 根据用户名获取用户(含状态过滤) +func (r *UserRepo) GetByUsername(username string) (*model.User, error) { + var user model.User + if err := r.db.Where("username = ? AND status = 1", username).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +// GetByUserID 根据用户ID获取用户 +func (r *UserRepo) GetByUserID(userID int) (*model.User, error) { + var user model.User + if err := r.db.Where("user_id = ?", userID).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +// CreateStudent 创建学生账号 +func (r *UserRepo) CreateStudent(username, passwordHash, realName string, studentID int) (int, error) { + user := model.User{ + Username: username, + PasswordHash: passwordHash, + RealName: realName, + UserType: "student", + StudentID: &studentID, + Status: 1, + NeedChangePassword: 1, + } + if err := r.db.Create(&user).Error; err != nil { + return 0, err + } + return user.UserID, nil +} + +// CreateParent 创建家长账号 +func (r *UserRepo) CreateParent(username, passwordHash, realName string, studentID int) (int, error) { + user := model.User{ + Username: username, + PasswordHash: passwordHash, + RealName: realName, + UserType: "parent", + StudentID: &studentID, + Status: 1, + NeedChangePassword: 0, + } + if err := r.db.Create(&user).Error; err != nil { + return 0, err + } + return user.UserID, nil +} + +// CreateAdmin 创建管理员账号 +func (r *UserRepo) CreateAdmin(username, passwordHash, realName string) (int, error) { + user := model.User{ + Username: username, + PasswordHash: passwordHash, + RealName: realName, + UserType: "admin", + Status: 1, + NeedChangePassword: 1, + } + if err := r.db.Create(&user).Error; err != nil { + return 0, err + } + return user.UserID, nil +} + +// UpdatePassword 更新密码并清除强制改密标记 +func (r *UserRepo) UpdatePassword(userID int, passwordHash string) error { + return r.db.Model(&model.User{}). + Where("user_id = ?", userID). + Updates(map[string]interface{}{ + "password_hash": passwordHash, + "need_change_password": 0, + }).Error +} + +// UpdateLastLogin 更新最后登录信息 +func (r *UserRepo) UpdateLastLogin(userID int, ip string) error { + return r.db.Model(&model.User{}). + Where("user_id = ?", userID). + Updates(map[string]interface{}{ + "last_login_time": time.Now(), + "last_login_ip": ip, + }).Error +} + +// CheckUsernameExists 检查用户名是否存在 +func (r *UserRepo) CheckUsernameExists(username string) (bool, error) { + var count int64 + if err := r.db.Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +// UpdateStatus 更新用户状态 +func (r *UserRepo) UpdateStatus(userID int, status int8) error { + return r.db.Model(&model.User{}). + Where("user_id = ?", userID). + Update("status", status).Error +} + +// UpdateRealName 更新用户真实姓名 +func (r *UserRepo) UpdateRealName(userID int, realName string) error { + return r.db.Model(&model.User{}). + Where("user_id = ?", userID). + Update("real_name", realName).Error +} + +// GetByStudentID 根据学生ID获取关联的用户账号 +func (r *UserRepo) GetByStudentID(studentID int) (*model.User, error) { + var user model.User + if err := r.db.Where("student_id = ? AND status = 1", studentID).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +// DeleteUser 硬删除用户记录 +func (r *UserRepo) DeleteUser(userID int) error { + return r.db.Unscoped().Where("user_id = ?", userID).Delete(&model.User{}).Error +} + +// GetActiveUsernames 获取所有活跃用户的用户名列表(用于批量导入去重) +func (r *UserRepo) GetActiveUsernames() ([]string, error) { + var usernames []string + if err := r.db.Model(&model.User{}). + Where("status = 1"). + Pluck("username", &usernames).Error; err != nil { + return nil, err + } + return usernames, nil +} diff --git a/backend-go/internal/router/router.go b/backend-go/internal/router/router.go new file mode 100644 index 0000000..3002e0a --- /dev/null +++ b/backend-go/internal/router/router.go @@ -0,0 +1,206 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package router + +import ( + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/handler" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// Handlers 聚合所有 HTTP 处理器 +type Handlers struct { + Auth *handler.AuthHandler + Admin *handler.AdminHandler + Student *handler.StudentHandler + Parent *handler.ParentHandler + Subject *handler.SubjectHandler + Semester *handler.SemesterHandler + Class *handler.ClassHandler + Config *handler.ConfigHandler + SuperAdmin *handler.SuperAdminHandler + Cadre *handler.CadreHandler +} + +// SetupRouter 注册所有路由,返回 Gin 引擎 +func SetupRouter(cfg *config.Config, h *Handlers) *gin.Engine { + if cfg.IsProduction() { + gin.SetMode(gin.ReleaseMode) + } + + r := gin.New() + + // ========== 全局中间件 ========== + // CORS 说明:生产环境通过 Nginx 反代实现同源策略,API 与前端同域,无需额外 CORS 配置。 + // 若需要直接访问 API(绕过 Nginx),需在此添加 CORS 中间件。 + r.Use(middleware.AccessLog()) + r.Use(gin.Recovery()) + r.Use(middleware.Sanitize()) + + // ========== 公开路由组(不需要认证) ========== + public := r.Group("/api") + { + public.POST("/auth/login", h.Auth.Login) + } + + // ========== 超级管理员独立登录(路径可配置) ========== + superAdminPath := "/api" + cfg.SuperAdminLoginPath + middleware.RegisterPublicPath(superAdminPath + "/login") + superAdmin := r.Group(superAdminPath) + { + superAdmin.POST("/login", h.SuperAdmin.Login) + } + + // ========== 需认证的路由组 ========== + authRequired := r.Group("/api") + authRequired.Use(middleware.AuthRequired()) + { + // 扣分规则(需认证) + authRequired.GET("/config/deduction-rules", h.Config.GetDeductionRules) + + // 认证相关 + authRequired.POST("/auth/logout", h.Auth.Logout) + authRequired.POST("/auth/change-password", h.Auth.ChangePassword) + authRequired.GET("/auth/me", h.Auth.GetUserInfo) + + // 学生端 + student := authRequired.Group("/student") + { + student.GET("/conduct/:student_id", h.Student.ConductHistory) + student.GET("/homework/:student_id", h.Student.Homework) + student.GET("/attendance/:student_id", h.Student.Attendance) + student.GET("/ranking", h.Student.Ranking) + student.GET("/my-info", h.Student.MyInfo) + student.GET("/semester-records", h.Student.SemesterRecords) + } + + // 家长端 + parent := authRequired.Group("/parent") + { + parent.GET("/child/conduct", h.Parent.Dashboard) + parent.GET("/child/attendance", h.Parent.Attendance) + parent.GET("/child/ranking", h.Parent.Ranking) + parent.GET("/child/history", h.Parent.History) + parent.POST("/password", h.Parent.ChangePassword) + } + + // 管理端 + admin := authRequired.Group("/admin") + admin.Use(middleware.RequireRole("admin", "super_admin")) + { + // 学生管理 + admin.GET("/students/dormitories", h.Admin.GetDormitories) + admin.GET("/students", h.Admin.StudentList) + admin.POST("/students/import", h.Admin.StudentImport) + admin.POST("/students", h.Admin.StudentCreate) + admin.PUT("/students/:student_id", h.Admin.StudentUpdate) + admin.DELETE("/students/:student_id", h.Admin.StudentDelete) + admin.POST("/students/reset-password/:student_id", h.Admin.ResetStudentPassword) + + // 操行分管理 + admin.POST("/conduct/add", h.Admin.AddConductPoints) + admin.POST("/conduct/revoke", h.Admin.RevokeConductRecord) + admin.POST("/conduct/restore", h.Admin.RestoreConductRecord) + admin.GET("/conduct/history", h.Admin.GetConductHistory) + admin.POST("/conduct/batch-revoke", h.Admin.BatchRevokeConductRecords) + admin.POST("/conduct/batch-restore", h.Admin.BatchRestoreConductRecords) + + // 考勤管理 + admin.POST("/attendance", h.Admin.CreateAttendanceRecord) + admin.GET("/attendance/records", h.Admin.GetAttendanceRecords) + + // 管理员管理 + admin.POST("/add", h.Admin.AdminCreate) + admin.GET("/list", h.Admin.AdminList) + admin.PUT("/update/:user_id", h.Admin.AdminUpdate) + admin.DELETE("/delete/:user_id", h.Admin.AdminDelete) + admin.POST("/reset-password/:user_id", h.Admin.AdminResetPassword) + admin.POST("/unlock-user", h.Admin.UnlockAccount) + + // 排行榜分项(新增) + admin.GET("/rankings", h.Admin.GetRankings) + } + + // 科目管理 + subject := authRequired.Group("/subject") + subject.Use(middleware.RequireRole("admin", "super_admin")) + { + subject.GET("/list", h.Subject.SubjectList) + subject.POST("/create", h.Subject.SubjectCreate) + subject.PUT("/update/:subject_id", h.Subject.SubjectUpdate) + subject.PUT("/toggle/:subject_id", h.Subject.SubjectToggle) + subject.DELETE("/delete/:subject_id", h.Subject.SubjectDelete) + } + + // 学期管理 + semester := authRequired.Group("/semester") + semester.Use(middleware.RequireRole("admin", "super_admin")) + { + semester.GET("/list", h.Semester.SemesterList) + semester.GET("/active", h.Semester.ActiveSemester) + semester.POST("/create", h.Semester.SemesterCreate) + semester.PUT("/activate/:semester_id", h.Semester.ActivateSemester) + semester.PUT("/update/:semester_id", h.Semester.SemesterUpdate) + semester.DELETE("/delete/:semester_id", h.Semester.SemesterDelete) + semester.POST("/:semester_id/associate", h.Semester.AssociateRecords) + semester.POST("/archive/:semester_id", h.Semester.ArchiveSemester) + semester.GET("/archive/:semester_id/records", h.Semester.GetArchiveData) + semester.POST("/period-reset", h.Semester.PeriodReset) + semester.GET("/period-archives", h.Semester.GetPeriodArchives) + } + + // 班级管理 + classGroup := authRequired.Group("/class") + classGroup.Use(middleware.RequireRole("admin", "super_admin")) + { + classGroup.GET("/list", h.Class.ClassList) + classGroup.GET("/:class_id", h.Class.ClassDetail) + classGroup.POST("/create", h.Class.ClassCreate) + classGroup.PUT("/update/:class_id", h.Class.ClassUpdate) + classGroup.DELETE("/delete/:class_id", h.Class.ClassDelete) + classGroup.POST("/switch", h.Class.SwitchClass) + classGroup.POST("/settings", h.Class.SaveSetting) + classGroup.GET("/settings", h.Class.GetSettings) + classGroup.GET("/point-limits", h.Class.GetPointLimits) + classGroup.POST("/point-limits", h.Class.SavePointLimits) + classGroup.GET("/features", h.Class.GetFeatures) + classGroup.POST("/features", h.Class.SaveFeature) + } + + // 课代表路由(新增) + cadre := authRequired.Group("/cadre") + cadre.Use(middleware.RequireRole("课代表")) + { + cadre.GET("/homework", h.Cadre.HomeworkList) + cadre.POST("/homework", h.Cadre.HomeworkSubmit) + cadre.POST("/conduct/add", h.Cadre.AddConductPoints) + } + } + + // ========== 系统路由 ========== + r.GET("/", func(c *gin.Context) { + response.Success(c, gin.H{ + "app": cfg.AppName, + "version": "2.0", + "status": "running", + }, "服务运行中") + }) + + r.GET("/health", func(c *gin.Context) { + response.Success(c, gin.H{"status": "ok"}, "健康检查通过") + }) + + return r +} diff --git a/backend-go/internal/schema/admin.go b/backend-go/internal/schema/admin.go new file mode 100644 index 0000000..d1ff6cb --- /dev/null +++ b/backend-go/internal/schema/admin.go @@ -0,0 +1,33 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// AdminCreateRequest 添加管理员请求 +type AdminCreateRequest struct { + Username string `json:"username" binding:"required"` + RealName string `json:"real_name" binding:"required"` + Password string `json:"password"` + RoleType string `json:"role_type" binding:"required"` + SubjectID *int `json:"subject_id"` +} + +// AdminUpdateRequest 更新管理员请求 +type AdminUpdateRequest struct { + RealName string `json:"real_name" binding:"required"` + RoleType string `json:"role_type" binding:"required"` + SubjectID *int `json:"subject_id"` +} + +// UnlockUserRequest 解锁用户请求 +type UnlockUserRequest struct { + Username string `json:"username" binding:"required"` +} diff --git a/backend-go/internal/schema/attendance.go b/backend-go/internal/schema/attendance.go new file mode 100644 index 0000000..0d45175 --- /dev/null +++ b/backend-go/internal/schema/attendance.go @@ -0,0 +1,30 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// AttendanceCreateRequest 创建考勤记录请求 +type AttendanceCreateRequest struct { + StudentID int `json:"student_id" binding:"required"` + Date string `json:"date" binding:"required"` + Slot string `json:"slot" binding:"required,oneof=morning afternoon evening"` + Status string `json:"status" binding:"required,oneof=present absent late leave"` + Reason string `json:"reason"` + ApplyDeduction bool `json:"apply_deduction"` + CustomDeduction *int `json:"custom_deduction"` +} + +// AttendanceQuery 考勤查询参数 +type AttendanceQuery struct { + Date string `form:"date"` + StudentID *int `form:"student_id"` + Slot string `form:"slot"` +} diff --git a/backend-go/internal/schema/auth.go b/backend-go/internal/schema/auth.go new file mode 100644 index 0000000..fdcf3d4 --- /dev/null +++ b/backend-go/internal/schema/auth.go @@ -0,0 +1,26 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// LoginRequest 登录请求 +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// ChangePasswordRequest 修改密码请求 +type ChangePasswordRequest struct { + OldPassword string `json:"old_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required"` + Force bool `json:"force"` +} + diff --git a/backend-go/internal/schema/class.go b/backend-go/internal/schema/class.go new file mode 100644 index 0000000..24bfdb1 --- /dev/null +++ b/backend-go/internal/schema/class.go @@ -0,0 +1,44 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// ClassCreateRequest 创建班级请求 +type ClassCreateRequest struct { + ClassName string `json:"class_name" binding:"required"` + Grade *string `json:"grade"` + Description *string `json:"description"` +} + +// ClassUpdateRequest 更新班级请求 +type ClassUpdateRequest struct { + ClassName *string `json:"class_name"` + Grade *string `json:"grade"` + Description *string `json:"description"` + Status *int8 `json:"status"` +} + +// SwitchClassRequest 切换班级上下文请求 +type SwitchClassRequest struct { + ClassID int `json:"class_id" binding:"required"` +} + +// SettingRequest 保存班级设置请求 +type SettingRequest struct { + SettingKey string `json:"setting_key" binding:"required"` + SettingValue string `json:"setting_value" binding:"required"` +} + +// FeatureToggleRequest 功能开关请求 +type FeatureToggleRequest struct { + FeatureKey string `json:"feature_key" binding:"required"` + Enabled int8 `json:"enabled" binding:"oneof=0 1"` +} diff --git a/backend-go/internal/schema/conduct.go b/backend-go/internal/schema/conduct.go new file mode 100644 index 0000000..8ed1ef6 --- /dev/null +++ b/backend-go/internal/schema/conduct.go @@ -0,0 +1,43 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// ConductAddRequest 批量加减分请求 +type ConductAddRequest struct { + StudentIDs []int `json:"student_ids" binding:"required,min=1"` + PointsChange int `json:"points_change" binding:"required,ne=0"` + Reason string `json:"reason" binding:"required"` + RelatedType string `json:"related_type"` +} + +// RevokeRequest 撤销/反撤销请求 +type RevokeRequest struct { + RecordID int64 `json:"record_id" binding:"required"` +} + +// BatchRevokeRequest 批量撤销/反撤销请求 +type BatchRevokeRequest struct { + RecordIDs []int64 `json:"record_ids" binding:"required,min=1,max=100"` +} + +// ConductHistoryQuery 操行分历史查询参数 +type ConductHistoryQuery struct { + StudentID *int `form:"student_id"` + Page int `form:"page,default=1" binding:"min=1"` + PageSize int `form:"page_size,default=20" binding:"min=1,max=1000"` + StartDate string `form:"start_date"` + EndDate string `form:"end_date"` + RelatedType string `form:"related_type"` + ReasonPrefix string `form:"reason_prefix"` + IsRevoked *int `form:"is_revoked"` + ReasonSearch string `form:"reason_search"` +} diff --git a/backend-go/internal/schema/ranking.go b/backend-go/internal/schema/ranking.go new file mode 100644 index 0000000..39c7cff --- /dev/null +++ b/backend-go/internal/schema/ranking.go @@ -0,0 +1,50 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// RankingQuery 排行榜查询参数 +type RankingQuery struct { + Type string `form:"type" binding:"omitempty,oneof=attendance homework conduct all"` + Limit int `form:"limit,default=50" binding:"min=1,max=1000"` +} + +// ParentHistoryQuery 家长历史记录查询参数 +type ParentHistoryQuery struct { + Page int `form:"page,default=1" binding:"min=1"` + PageSize int `form:"page_size,default=20" binding:"min=1,max=100"` +} + +// StudentConductQuery 学生操行分查询参数 +type StudentConductQuery struct { + Limit int `form:"limit,default=50" binding:"min=1"` + Offset int `form:"offset,default=0" binding:"min=0"` +} + +// StudentAttendanceQuery 学生考勤查询参数 +type StudentAttendanceQuery struct { + Month string `form:"month"` +} + +// CadreHomeworkQuery 课代表作业查询参数 +type CadreHomeworkQuery struct { + SubjectID *int `form:"subject_id"` + Page int `form:"page,default=1" binding:"min=1"` + PageSize int `form:"page_size,default=20" binding:"min=1,max=100"` +} + +// CadreHomeworkSubmitRequest 课代表发布作业请求 +// SubjectID 由后端从管理员角色中自动获取,无需前端传递 +type CadreHomeworkSubmitRequest struct { + Title string `json:"title" binding:"required"` + Description string `json:"description"` + Deadline string `json:"deadline" binding:"required"` +} diff --git a/backend-go/internal/schema/semester.go b/backend-go/internal/schema/semester.go new file mode 100644 index 0000000..b378feb --- /dev/null +++ b/backend-go/internal/schema/semester.go @@ -0,0 +1,38 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// SemesterCreateRequest 创建学期请求 +type SemesterCreateRequest struct { + SemesterName string `json:"semester_name" binding:"required"` + StartDate *string `json:"start_date"` + EndDate *string `json:"end_date"` +} + +// SemesterUpdateRequest 更新学期请求 +type SemesterUpdateRequest struct { + SemesterName *string `json:"semester_name"` + StartDate *string `json:"start_date"` + EndDate *string `json:"end_date"` +} + +// PeriodResetRequest 周期重置请求 +type PeriodResetRequest struct { + Period string `json:"period" binding:"required,oneof=weekly monthly"` +} + +// PeriodArchiveQuery 周期归档查询参数 +type PeriodArchiveQuery struct { + Period string `form:"period" binding:"required,oneof=weekly monthly"` + Page int `form:"page,default=1"` + PageSize int `form:"page_size,default=20"` +} diff --git a/backend-go/internal/schema/student.go b/backend-go/internal/schema/student.go new file mode 100644 index 0000000..79e2809 --- /dev/null +++ b/backend-go/internal/schema/student.go @@ -0,0 +1,54 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// StudentCreateRequest 新增学生请求 +type StudentCreateRequest struct { + StudentNo string `json:"student_no" binding:"required"` + Name string `json:"name" binding:"required"` + ParentAccount *string `json:"parent_account"` + DormitoryNumber *string `json:"dormitory_number"` +} + +// StudentImportSingle 导入的单个学生数据 +type StudentImportSingle struct { + StudentNo string `json:"student_no"` + Name string `json:"name"` + ParentAccount string `json:"parent_account"` + DormitoryNumber string `json:"dormitory_number"` + Password string `json:"password"` +} + +// StudentImportRequest 批量导入学生请求 +type StudentImportRequest struct { + Students []StudentImportSingle `json:"students" binding:"required"` +} + +// StudentUpdateRequest 编辑学生请求 +type StudentUpdateRequest struct { + Name *string `json:"name"` + ParentAccount *string `json:"parent_account"` + DormitoryNumber *string `json:"dormitory_number"` +} + +// StudentListQuery 学生列表查询参数 +type StudentListQuery struct { + Page int `form:"page,default=1" binding:"min=1"` + PageSize int `form:"page_size,default=20" binding:"min=1,max=1000"` + Search string `form:"search"` + DormitoryNumber string `form:"dormitory_number"` +} + +// ResetPasswordRequest 重置密码请求 +type ResetPasswordRequest struct { + NewPassword string `json:"new_password" binding:"required"` +} diff --git a/backend-go/internal/schema/subject.go b/backend-go/internal/schema/subject.go new file mode 100644 index 0000000..a7da3de --- /dev/null +++ b/backend-go/internal/schema/subject.go @@ -0,0 +1,27 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// SubjectCreateRequest 创建科目请求 +type SubjectCreateRequest struct { + SubjectName string `json:"subject_name" binding:"required"` + SubjectCode *string `json:"subject_code"` + SortOrder int `json:"sort_order"` +} + +// SubjectUpdateRequest 更新科目请求 +type SubjectUpdateRequest struct { + SubjectName *string `json:"subject_name"` + SubjectCode *string `json:"subject_code"` + IsActive *int8 `json:"is_active"` + SortOrder *int `json:"sort_order"` +} diff --git a/backend-go/internal/service/admin_service.go b/backend-go/internal/service/admin_service.go new file mode 100644 index 0000000..eb385c0 --- /dev/null +++ b/backend-go/internal/service/admin_service.go @@ -0,0 +1,452 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "context" + "fmt" + "regexp" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/crypto" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// AdminService 管理员服务 +type AdminService struct { + userRepo *repository.UserRepo + studentRepo *repository.StudentRepo + adminRoleRepo *repository.AdminRoleRepo + classRepo *repository.ClassRepo +} + +// NewAdminService 创建管理员服务 +func NewAdminService( + userRepo *repository.UserRepo, + studentRepo *repository.StudentRepo, + adminRoleRepo *repository.AdminRoleRepo, + classRepo *repository.ClassRepo, +) *AdminService { + return &AdminService{ + userRepo: userRepo, + studentRepo: studentRepo, + adminRoleRepo: adminRoleRepo, + classRepo: classRepo, + } +} + +// dormitoryRegex 宿舍号格式校验:东南北西 + 1-2位楼号 - 3位房号 +var dormitoryRegex = regexp.MustCompile(`^[东南北西]\d{1,2}-\d{3}$`) + +// validateDormitoryNumber 校验宿舍号格式(允许空值或合法格式) +func validateDormitoryNumber(dn *string) bool { + if dn == nil || *dn == "" { + return true + } + return dormitoryRegex.MatchString(*dn) +} + +// GetStudents 获取指定班级的学生列表 +func (s *AdminService) GetStudents(classID int, page, pageSize int, search, dormitoryNumber string) (map[string]interface{}, error) { + students, total, err := s.studentRepo.ListByClass(classID, page, pageSize, search, dormitoryNumber) + if err != nil { + return nil, err + } + + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + + return map[string]interface{}{ + "students": students, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": totalPages, + }, nil +} + +// GetDormitories 获取宿舍号列表 +func (s *AdminService) GetDormitories(classID int) ([]string, error) { + return s.studentRepo.GetDormitoryList(classID) +} + +// getInitialPassword 从 class_settings 读取初始密码,若无则使用随机密码 +func (s *AdminService) getInitialPassword(classID int) (string, error) { + if s.classRepo != nil { + setting, err := s.classRepo.GetSetting(classID, "initial_password") + if err == nil && setting != nil && setting.SettingValue != "" { + return setting.SettingValue, nil + } + } + pwd, err := crypto.GenerateRandomPassword(8) + if err != nil { + logger.Sugared.Errorf("生成随机密码失败: %v", err) + return "", fmt.Errorf("生成随机密码失败: %w", err) + } + return pwd, nil +} + +// AddStudent 新增学生 +func (s *AdminService) AddStudent(studentNo, name string, parentAccount *string, classID int, dormitoryNumber *string) (map[string]interface{}, error) { + cfg := config.AppConfig + + // 校验宿舍号格式 + if !validateDormitoryNumber(dormitoryNumber) { + return map[string]interface{}{"success": false, "message": "宿舍号格式不正确,应为如 东1-101 的格式"}, nil + } + + // 检查学号是否已存在 + existing, err := s.studentRepo.GetByStudentNo(studentNo, classID) + if err == nil && existing != nil { + return map[string]interface{}{"success": false, "message": "该班级中已存在此学号"}, nil + } + + // 创建学生记录 + student := &model.Student{ + StudentNo: studentNo, + ClassID: classID, + Name: name, + TotalPoints: 60, + ParentAccount: parentAccount, + DormitoryNumber: dormitoryNumber, + Status: 1, + } + studentID, err := s.studentRepo.Create(student) + if err != nil { + return nil, err + } + // 创建学生登录账号(从 class_settings 读取初始密码或使用随机密码) + defaultPassword, err := s.getInitialPassword(classID) + if err != nil { + return nil, err + } + passwordHash := crypto.HashPassword(defaultPassword, cfg.PasswordSalt) + _, err = s.userRepo.CreateStudent(studentNo, passwordHash, name, studentID) + if err != nil { + logger.Sugared.Errorf("创建学生登录账号失败: student_no=%s, student_id=%d, err=%v", studentNo, studentID, err) + // 回滚学生记录,避免存在无账号的孤儿学生 + _ = s.studentRepo.SoftDelete(studentID) + return nil, fmt.Errorf("创建学生登录账号失败") + } + + // 创建家长账号(失败时不回滚学生记录,仅记录日志;管理员可手动补建家长账号) + if parentAccount != nil && *parentAccount != "" { + exists, _ := s.userRepo.CheckUsernameExists(*parentAccount) + if !exists { + parentHash := crypto.HashPassword(defaultPassword, cfg.PasswordSalt) + parentRealName := fmt.Sprintf("%s家长", name) + if _, err := s.userRepo.CreateParent(*parentAccount, parentHash, parentRealName, studentID); err != nil { + logger.Sugared.Warnf("创建家长账号失败(学生记录已保留): parent_account=%s, student_id=%d, err=%v", *parentAccount, studentID, err) + } + } + } + + return map[string]interface{}{ + "success": true, + "student_id": studentID, + }, nil +} + +// ImportStudents 批量导入学生 +// 注意:当前实现为逐条创建,单条失败时回滚该条记录(SoftDelete),不影响其他记录。 +// 这是设计上有意为之——允许部分成功,避免一条失败导致整个导入作废。 +// 未使用数据库事务的原因:Repository 层未暴露事务接口,全量事务包裹需要较大重构; +// 且批量导入场景下允许部分成功是合理的业务权衡(用户可修正失败记录后重新导入)。 +func (s *AdminService) ImportStudents(students []map[string]interface{}, classID int) (map[string]interface{}, error) { + cfg := config.AppConfig + successCount := 0 + failedCount := 0 + var details []map[string]interface{} + + // 预查重 + existingNos, _ := s.studentRepo.GetStudentNosByClass(classID) + existingSet := make(map[string]bool, len(existingNos)) + for _, no := range existingNos { + existingSet[no] = true + } + + existingUsernames, _ := s.userRepo.GetActiveUsernames() + usernameSet := make(map[string]bool, len(existingUsernames)) + for _, u := range existingUsernames { + usernameSet[u] = true + } + + for _, stu := range students { + studentNo, _ := stu["student_no"].(string) + name, _ := stu["name"].(string) + + if studentNo == "" || name == "" { + failedCount++ + details = append(details, map[string]interface{}{ + "student_no": studentNo, "success": false, "error": "学号或姓名不能为空", + }) + continue + } + + if existingSet[studentNo] { + failedCount++ + details = append(details, map[string]interface{}{ + "student_no": studentNo, "success": false, "error": "学号已存在", + }) + continue + } + + var parentAccount *string + if pa, ok := stu["parent_account"].(string); ok && pa != "" { + parentAccount = &pa + } + var dormitoryNumber *string + if dn, ok := stu["dormitory_number"].(string); ok && dn != "" { + dormitoryNumber = &dn + } + // 校验宿舍号格式 + if !validateDormitoryNumber(dormitoryNumber) { + failedCount++ + details = append(details, map[string]interface{}{ + "student_no": studentNo, "success": false, "error": "宿舍号格式不正确,应为如 东1-101 的格式", + }) + continue + } + + password, pwdErr := s.getInitialPassword(classID) + if pwdErr != nil { + failedCount++ + details = append(details, map[string]interface{}{ + "student_no": studentNo, "success": false, "error": "生成初始密码失败", + }) + continue + } + if pw, ok := stu["password"].(string); ok && pw != "" { + if valid, msg := crypto.ValidatePasswordStrength(pw); !valid { + failedCount++ + details = append(details, map[string]interface{}{ + "student_no": studentNo, "success": false, "error": msg, + }) + continue + } + password = pw + } + + // 创建学生记录 + student := &model.Student{ + StudentNo: studentNo, + ClassID: classID, + Name: name, + TotalPoints: 60, + ParentAccount: parentAccount, + DormitoryNumber: dormitoryNumber, + Status: 1, + } + studentID, err := s.studentRepo.Create(student) + if err != nil { + failedCount++ + details = append(details, map[string]interface{}{ + "student_no": studentNo, "success": false, "error": err.Error(), + }) + continue + } + existingSet[studentNo] = true + + // 创建学生登录账号 + passwordHash := crypto.HashPassword(password, cfg.PasswordSalt) + if _, err := s.userRepo.CreateStudent(studentNo, passwordHash, name, studentID); err != nil { + logger.Sugared.Errorf("批量导入-创建学生登录账号失败: student_no=%s, student_id=%d, err=%v", studentNo, studentID, err) + // 回滚学生记录 + _ = s.studentRepo.SoftDelete(studentID) + failedCount++ + details = append(details, map[string]interface{}{ + "student_no": studentNo, "success": false, "error": "创建登录账号失败", + }) + continue + } + usernameSet[studentNo] = true + + // 创建家长账号 + if parentAccount != nil && *parentAccount != "" && !usernameSet[*parentAccount] { + parentHash := crypto.HashPassword(password, cfg.PasswordSalt) + parentRealName := fmt.Sprintf("%s家长", name) + if _, err := s.userRepo.CreateParent(*parentAccount, parentHash, parentRealName, studentID); err != nil { + logger.Sugared.Errorf("批量导入-创建家长账号失败: parent_account=%s, student_id=%d, err=%v", *parentAccount, studentID, err) + } + usernameSet[*parentAccount] = true + } + + successCount++ + details = append(details, map[string]interface{}{ + "student_no": studentNo, "success": true, "student_id": studentID, + }) + } + + return map[string]interface{}{ + "success": true, + "total": len(students), + "success_count": successCount, + "failed_count": failedCount, + "details": details, + }, nil +} + +// UpdateStudent 编辑学生信息 +func (s *AdminService) UpdateStudent(studentID int, name, parentAccount, dormitoryNumber *string, classID int) error { + // 校验学生是否属于当前班级 + student, err := s.studentRepo.GetByID(studentID) + if err != nil || student == nil { + return fmt.Errorf("学生不存在") + } + if student.ClassID != classID { + return fmt.Errorf("无权操作该学生") + } + // 校验宿舍号格式 + if !validateDormitoryNumber(dormitoryNumber) { + return fmt.Errorf("宿舍号格式不正确,应为如 东1-101 的格式") + } + updates := make(map[string]interface{}) + if name != nil { + updates["name"] = *name + } + if parentAccount != nil { + updates["parent_account"] = *parentAccount + } + if dormitoryNumber != nil { + updates["dormitory_number"] = *dormitoryNumber + } + return s.studentRepo.Update(studentID, updates) +} + +// DeleteStudent 删除学生 +func (s *AdminService) DeleteStudent(studentID int, classID int) error { + // 校验学生是否属于当前班级 + student, err := s.studentRepo.GetByID(studentID) + if err != nil || student == nil { + return fmt.Errorf("学生不存在") + } + if student.ClassID != classID { + return fmt.Errorf("无权操作该学生") + } + return s.studentRepo.SoftDelete(studentID) +} + +// ResetStudentPassword 重置学生密码 +func (s *AdminService) ResetStudentPassword(studentID int, newPassword string) error { + // 验证新密码强度(#11) + if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid { + return fmt.Errorf("%s", msg) + } + cfg := config.AppConfig + student, err := s.studentRepo.GetByID(studentID) + if err != nil { + return fmt.Errorf("学生不存在") + } + // 通过学号查找关联的用户账号 + user, err := s.userRepo.GetByUsername(student.StudentNo) + if err != nil { + return fmt.Errorf("学生登录账号不存在") + } + passwordHash := crypto.HashPassword(newPassword, cfg.PasswordSalt) + return s.userRepo.UpdatePassword(user.UserID, passwordHash) +} + +// AddAdmin 添加管理员 +func (s *AdminService) AddAdmin(username, realName, password, roleType string, classID int, subjectID *int) (map[string]interface{}, error) { + cfg := config.AppConfig + + exists, _ := s.userRepo.CheckUsernameExists(username) + if exists { + return map[string]interface{}{"success": false, "message": "用户名已存在"}, nil + } + + if password == "" { + pwd, err := crypto.GenerateRandomPassword(8) + if err != nil { + return nil, fmt.Errorf("生成随机密码失败: %w", err) + } + password = pwd + } + + passwordHash := crypto.HashPassword(password, cfg.PasswordSalt) + userID, err := s.userRepo.CreateAdmin(username, passwordHash, realName) + if err != nil { + return nil, err + } + + role := &model.AdminRole{ + UserID: userID, + ClassID: classID, + RoleType: roleType, + SubjectID: subjectID, + } + _, err = s.adminRoleRepo.Create(role) + if err != nil { + // 角色创建失败,回滚用户记录,避免孤儿数据 + _ = s.userRepo.DeleteUser(userID) + return nil, fmt.Errorf("创建管理员角色失败,已回滚用户记录: %w", err) + } + + return map[string]interface{}{ + "success": true, + "user_id": userID, + "username": username, + "role_type": roleType, + }, nil +} + +// GetAdmins 获取管理员列表 +func (s *AdminService) GetAdmins(classID int) (map[string]interface{}, error) { + admins, err := s.adminRoleRepo.GetAllByClass(classID) + if err != nil { + return nil, err + } + return map[string]interface{}{"admins": admins}, nil +} + +// UpdateAdmin 更新管理员 +func (s *AdminService) UpdateAdmin(userID int, realName, roleType string, classID int, subjectID *int) error { + if err := s.userRepo.UpdateRealName(userID, realName); err != nil { + return err + } + return s.adminRoleRepo.UpdateRole(userID, roleType, classID, subjectID) +} + +// DeleteAdmin 硬删除管理员(同时删除 users 和 admin_roles 记录) +func (s *AdminService) DeleteAdmin(userID int, classID int) error { + // 先删除关联的 admin_roles 记录 + if err := s.adminRoleRepo.Delete(userID, classID); err != nil { + return err + } + // 硬删除 users 表记录 + return s.userRepo.DeleteUser(userID) +} + +// ResetAdminPassword 重置管理员密码 +func (s *AdminService) ResetAdminPassword(userID int, newPassword string) error { + if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid { + return fmt.Errorf("%s", msg) + } + cfg := config.AppConfig + passwordHash := crypto.HashPassword(newPassword, cfg.PasswordSalt) + return s.userRepo.UpdatePassword(userID, passwordHash) +} + +// UnlockAccount 解锁账号(清除用户名级 + IP 级登录失败计数) +func (s *AdminService) UnlockAccount(username, ip string) error { + ctx := context.Background() + keys := []string{fmt.Sprintf("login_attempts:%s", username)} + if ip != "" { + keys = append(keys, fmt.Sprintf("login_attempts:ip:%s", ip)) + } + return database.RDB.Del(ctx, keys...).Err() +} diff --git a/backend-go/internal/service/attendance_service.go b/backend-go/internal/service/attendance_service.go new file mode 100644 index 0000000..aab6f8f --- /dev/null +++ b/backend-go/internal/service/attendance_service.go @@ -0,0 +1,226 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "fmt" + "strconv" + "time" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// AttendanceService 考勤服务 +type AttendanceService struct { + attendanceRepo *repository.AttendanceRepo + studentRepo *repository.StudentRepo + userRepo *repository.UserRepo + conductRepo *repository.ConductRepo + semesterRepo *repository.SemesterRepo + settingRepo *repository.SystemSettingRepo + classRepo *repository.ClassRepo +} + +// NewAttendanceService 创建考勤服务 +func NewAttendanceService( + attendanceRepo *repository.AttendanceRepo, + studentRepo *repository.StudentRepo, + userRepo *repository.UserRepo, + conductRepo *repository.ConductRepo, + semesterRepo *repository.SemesterRepo, + settingRepo *repository.SystemSettingRepo, + classRepo *repository.ClassRepo, +) *AttendanceService { + return &AttendanceService{ + attendanceRepo: attendanceRepo, + studentRepo: studentRepo, + userRepo: userRepo, + conductRepo: conductRepo, + semesterRepo: semesterRepo, + settingRepo: settingRepo, + classRepo: classRepo, + } +} + +// CreateRecord 创建考勤记录 +func (s *AttendanceService) CreateRecord(studentID int, dateStr, slot, status string, reason *string, + applyDeduction bool, customDeduction *int, recorderID int, classID int) (map[string]interface{}, error) { + + // 校验学生是否属于当前班级(#7) + student, err := s.studentRepo.GetByID(studentID) + if err != nil || student == nil || student.ClassID != classID { + return map[string]interface{}{"success": false, "message": "学生不属于当前班级"}, nil + } + + // 解析日期 + parsedDate, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return map[string]interface{}{"success": false, "message": "日期格式错误"}, nil + } + + // 获取活跃学期 + var semesterID *int + activeSemester, _ := s.semesterRepo.GetActive() + if activeSemester != nil { + semesterID = &activeSemester.SemesterID + } + + record := &model.AttendanceRecord{ + StudentID: studentID, + Date: parsedDate, + Slot: slot, + Status: status, + Reason: reason, + RecorderID: recorderID, + SemesterID: semesterID, + } + + createResult, err := s.attendanceRepo.CreateRecord(record) + if err != nil { + return map[string]interface{}{"success": false, "message": "添加考勤记录失败"}, nil + } + attendanceID := createResult.AttendanceID + + // 更新已有记录时,先撤销旧扣分再应用新扣分 + if createResult.IsUpdate && createResult.OldDeductionApplied == 1 && createResult.OldDeductionRecordID != nil { + if err := s.conductRepo.RevokeRecord(*createResult.OldDeductionRecordID, recorderID); err != nil { + logger.Sugared.Errorf("撤销旧考勤扣分失败: attendance_id=%d, old_record_id=%d, err=%v", + attendanceID, *createResult.OldDeductionRecordID, err) + return nil, fmt.Errorf("撤销旧扣分失败,操作已中止以避免双重扣分: %w", err) + } + } + + // 应用扣分(事务保护,避免数据不一致) + if applyDeduction && (status == "absent" || status == "late" || status == "leave") { + // 校验自定义扣分值必须为非负数 + if customDeduction != nil && *customDeduction < 0 { + return map[string]interface{}{"success": false, "message": "自定义扣分值不能为负数"}, nil + } + + var pointsChange int + if customDeduction != nil { + pointsChange = -*customDeduction + } else { + pointsChange = s.getDeductionPoints(classID, status) + } + + if pointsChange == 0 { + return map[string]interface{}{"success": true, "message": "考勤记录添加成功(不扣分)"}, nil + } + + // 获取操作人姓名 + recorderName := "班主任" + user, err := s.userRepo.GetByUserID(recorderID) + if err == nil && user != nil { + recorderName = user.RealName + } + + statusText := map[string]string{ + "absent": "缺勤", "late": "迟到", "leave": "请假", + }[status] + + // 使用事务确保操行分记录创建、总分更新、考勤标记的原子性 + db := s.semesterRepo.GetDB() + txErr := db.Transaction(func(tx *gorm.DB) error { + conductRecord := &model.ConductRecord{ + StudentID: studentID, + PointsChange: pointsChange, + Reason: fmt.Sprintf("考勤:%s", statusText), + RecorderID: recorderID, + RecorderName: &recorderName, + RelatedType: "attendance", + RelatedID: &attendanceID, + SemesterID: semesterID, + } + if err := tx.Create(conductRecord).Error; err != nil { + return err + } + if err := tx.Model(&model.Student{}). + Where("student_id = ?", studentID). + Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", pointsChange)).Error; err != nil { + return err + } + if err := tx.Model(&model.AttendanceRecord{}). + Where("attendance_id = ?", attendanceID). + Updates(map[string]interface{}{ + "deduction_applied": 1, + "deduction_record_id": conductRecord.RecordID, + }).Error; err != nil { + return err + } + return nil + }) + if txErr != nil { + logger.Sugared.Errorf("考勤扣分事务失败: attendance_id=%d, student_id=%d, err=%v", attendanceID, studentID, txErr) + return map[string]interface{}{ + "success": false, + "message": "考勤记录添加成功,但扣分失败,请手动处理", + "attendance_id": attendanceID, + "deduction_failed": true, + }, nil + } + + logger.Sugared.Infof("用户[%d] 添加考勤记录[%d] -> %s (扣%d分)", recorderID, attendanceID, status, -pointsChange) + } + + return map[string]interface{}{"success": true, "message": "考勤记录添加成功"}, nil +} + +// GetRecords 获取考勤记录 +func (s *AttendanceService) GetRecords(classID int, date string, studentID *int, slot string) (map[string]interface{}, error) { + records, err := s.attendanceRepo.GetClassRecords(classID, date, derefInt(studentID), slot) + if err != nil { + return nil, err + } + return map[string]interface{}{"records": records}, nil +} + +// getClassSettingValue 从 class_settings 读取设置值,若无则返回默认值 +func (s *AttendanceService) getClassSettingValue(classID int, key, defaultVal string) string { + if classID > 0 && s.classRepo != nil { + setting, err := s.classRepo.GetSetting(classID, key) + if err == nil && setting != nil && setting.SettingValue != "" { + return setting.SettingValue + } + } + return defaultVal +} + +// getDeductionPoints 获取考勤扣分数值(优先从 class_settings 读取班级级配置) +func (s *AttendanceService) getDeductionPoints(classID int, status string) int { + switch status { + case "absent": + val := s.getClassSettingValue(classID, "deduction_attendance_absent", "3") + if v, err := strconv.Atoi(val); err == nil { + return -v + } + return -3 + case "late": + val := s.getClassSettingValue(classID, "deduction_attendance_late", "1") + if v, err := strconv.Atoi(val); err == nil { + return -v + } + return -1 + case "leave": + val := s.getClassSettingValue(classID, "deduction_attendance_leave", "0") + if v, err := strconv.Atoi(val); err == nil { + return -v + } + return 0 + default: + return 0 + } +} diff --git a/backend-go/internal/service/auth_service.go b/backend-go/internal/service/auth_service.go new file mode 100644 index 0000000..8d4a69a --- /dev/null +++ b/backend-go/internal/service/auth_service.go @@ -0,0 +1,461 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "context" + "fmt" + + "github.com/redis/go-redis/v9" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/crypto" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database" + appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// AuthService 认证服务 +type AuthService struct { + userRepo *repository.UserRepo + studentRepo *repository.StudentRepo + adminRoleRepo *repository.AdminRoleRepo + classRepo *repository.ClassRepo + logService *LogService +} + +// NewAuthService 创建认证服务 +func NewAuthService( + userRepo *repository.UserRepo, + studentRepo *repository.StudentRepo, + adminRoleRepo *repository.AdminRoleRepo, + classRepo *repository.ClassRepo, + logService *LogService, +) *AuthService { + return &AuthService{ + userRepo: userRepo, + studentRepo: studentRepo, + adminRoleRepo: adminRoleRepo, + classRepo: classRepo, + logService: logService, + } +} + +// LoginResult 登录结果 +type LoginResult struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Token string `json:"token,omitempty"` + UserID int `json:"user_id,omitempty"` + Username string `json:"username,omitempty"` + RealName string `json:"real_name,omitempty"` + UserType string `json:"user_type,omitempty"` + StudentID *int `json:"student_id,omitempty"` + Role *string `json:"role,omitempty"` + ClassID *int `json:"class_id,omitempty"` + ClassName *string `json:"class_name,omitempty"` + NeedChangePassword bool `json:"need_change_password,omitempty"` + Redirect string `json:"redirect,omitempty"` +} + +// incrWithExpireAtomic 原子递增并在首次设置过期时间(Lua 脚本保证原子性) +func incrWithExpireAtomic(ctx context.Context, key string, ttlSeconds int) (int64, error) { + script := redis.NewScript(` + local current = redis.call('INCR', KEYS[1]) + if current == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) + end + return current + `) + result, err := script.Run(ctx, database.RDB, []string{key}, ttlSeconds).Int64() + return result, err +} + +// Login 用户登录 +func (s *AuthService) Login(username, password, ip, userAgent string) *LoginResult { + ctx := context.Background() + cfg := config.AppConfig + + // 检查登录失败次数(用户名级 + IP 级双重限流,使用原子 Incr 防止 TOCTOU 竞态) + attemptsKey := fmt.Sprintf("login_attempts:%s", username) + ipAttemptsKey := fmt.Sprintf("login_attempts:ip:%s", ip) + + // 用户名级限流:原子递增后检查 + userCount, err := incrWithExpireAtomic(ctx, attemptsKey, 300) + if err != nil { + logger.Sugared.Errorf("Redis 限流检查失败 (用户名级): %v", err) + return &LoginResult{Success: false, Message: "系统繁忙,请稍后重试"} + } + if userCount > 5 { + s.logService.WriteLoginLog(username, 0, ip, userAgent, "登录失败次数过多") + return &LoginResult{Success: false, Message: "登录失败次数过多,请5分钟后重试"} + } + // IP 级限流:原子递增后检查 + ipCount, err := incrWithExpireAtomic(ctx, ipAttemptsKey, 300) + if err != nil { + logger.Sugared.Errorf("Redis 限流检查失败 (IP级): %v", err) + return &LoginResult{Success: false, Message: "系统繁忙,请稍后重试"} + } + if ipCount > 20 { + s.logService.WriteLoginLog(username, 0, ip, userAgent, "IP登录失败次数过多") + return &LoginResult{Success: false, Message: "登录失败次数过多,请5分钟后重试"} + } + + // 获取用户 + user, err := s.userRepo.GetByUsername(username) + if err != nil { + // 尝试学生登录:username 匹配 student_no + student, stuErr := s.studentRepo.GetByStudentNo(username, 0) + if stuErr == nil && student != nil { + return s.loginAsStudent(student, password, ip, userAgent, cfg, attemptsKey, ipAttemptsKey) + } + // 尝试家长登录:username 匹配 parent_account + return s.tryParentLogin(username, password, ip, userAgent, cfg, attemptsKey, ipAttemptsKey) + } + + // 验证密码(使用全局 PASSWORD_SALT,与 Python 版兼容。 + // 已知设计局限:全局共享盐值,若泄露则所有普通用户密码面临风险。 + // 后续迁移计划:为每个用户生成独立盐值,存储在 users 表中。) + if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) { + s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误") + return &LoginResult{Success: false, Message: "用户名或密码错误"} + } + + // 检查账号状态 + if user.Status != 1 { + s.logService.WriteLoginLog(username, 0, ip, userAgent, "账号已被禁用") + return &LoginResult{Success: false, Message: "账号已被禁用"} + } + + // 清除用户名级登录失败记录,IP 级计数由 TTL 自然过期(防止同 IP 其他用户限流被重置) + database.RDB.Del(ctx, attemptsKey) + + // 更新最后登录信息 + _ = s.userRepo.UpdateLastLogin(user.UserID, ip) + + // 获取角色和班级信息 + var role *string + var classID *int + var className *string + + if user.UserType == "admin" { + adminRole, err := s.adminRoleRepo.GetByUserID(user.UserID) + if err == nil && adminRole != nil { + role = &adminRole.RoleType + classID = &adminRole.ClassID + } + } else if user.UserType == "super_admin" { + r := "系统管理员" + role = &r + } else if user.StudentID != nil { + student, err := s.studentRepo.GetByID(*user.StudentID) + if err == nil && student != nil { + cid := student.ClassID + classID = &cid + } + } + + // 获取班级名称 + if classID != nil { + cls, err := s.classRepo.GetByID(*classID) + if err == nil && cls != nil { + className = &cls.ClassName + } + } + + // 生成 Token + token, err := appJwt.CreateToken( + user.UserID, user.Username, user.UserType, + user.StudentID, derefStr(role), user.RealName, classID, + user.NeedChangePassword == 1, + ) + if err != nil { + return &LoginResult{Success: false, Message: "生成令牌失败"} + } + + // 存储 Token 到 Redis(使用 IdleTimeout 与中间件空闲超时一致,避免 Token 在 Redis 中残留过久) + _ = database.SetUserToken(ctx, user.UserID, token, cfg.JWTIdleTimeoutMinutes) + // 确定跳转路径 + redirect := getRedirectPath(user.UserType, role) + + // 需要强制改密时,跳转到密码修改页面 + needChangePassword := user.NeedChangePassword == 1 + if needChangePassword { + redirect = getPasswordChangePath(user.UserType) + } + + s.logService.WriteLoginLog(username, 1, ip, userAgent, "") + + return &LoginResult{ + Success: true, + Token: token, + UserID: user.UserID, + Username: user.Username, + RealName: user.RealName, + UserType: user.UserType, + StudentID: user.StudentID, + Role: role, + ClassID: classID, + ClassName: className, + NeedChangePassword: needChangePassword, + Redirect: redirect, + } +} + +// loginAsStudent 学生登录(通过学号) +func (s *AuthService) loginAsStudent(student *model.Student, password, ip, userAgent string, cfg *config.Config, attemptsKey, ipAttemptsKey string) *LoginResult { + ctx := context.Background() + + user, err := s.userRepo.GetByUsername(student.StudentNo) + if err != nil { + return &LoginResult{Success: false, Message: "用户名或密码错误"} + } + + if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) { + return &LoginResult{Success: false, Message: "用户名或密码错误"} + } + + if user.Status != 1 { + return &LoginResult{Success: false, Message: "账号已被禁用"} + } + + // 清除用户名级登录失败记录 + database.RDB.Del(ctx, attemptsKey) + _ = s.userRepo.UpdateLastLogin(user.UserID, ip) + + classID := student.ClassID + var className *string + cls, err := s.classRepo.GetByID(classID) + if err == nil && cls != nil { + className = &cls.ClassName + } + + token, err := appJwt.CreateToken(user.UserID, user.Username, user.UserType, user.StudentID, "", user.RealName, &classID, user.NeedChangePassword == 1) + if err != nil { + return &LoginResult{Success: false, Message: "生成令牌失败"} + } + + _ = database.SetUserToken(ctx, user.UserID, token, cfg.JWTIdleTimeoutMinutes) + s.logService.WriteLoginLog(user.Username, 1, ip, userAgent, "") + + needChangePassword := user.NeedChangePassword == 1 + redirect := "/student/dashboard.php" + if needChangePassword { + redirect = "/student/password.php" + } + + return &LoginResult{ + Success: true, + Token: token, + UserID: user.UserID, + Username: user.Username, + RealName: user.RealName, + UserType: user.UserType, + StudentID: user.StudentID, + ClassID: &classID, + ClassName: className, + NeedChangePassword: needChangePassword, + Redirect: redirect, + } +} + +// tryParentLogin 尝试家长登录(通过 parent_account 查找学生,再获取关联的家长用户) +func (s *AuthService) tryParentLogin(username, password, ip, userAgent string, cfg *config.Config, attemptsKey, ipAttemptsKey string) *LoginResult { + ctx := context.Background() + + // 根据 parent_account 字段查找学生 + student, err := s.studentRepo.GetByParentAccount(username) + if err != nil || student == nil { + s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误") + return &LoginResult{Success: false, Message: "用户名或密码错误"} + } + + // 根据学生ID获取关联的家长用户账号 + user, err := s.userRepo.GetByStudentID(student.StudentID) + if err != nil || user == nil || user.UserType != "parent" { + s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误") + return &LoginResult{Success: false, Message: "用户名或密码错误"} + } + + if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) { + return &LoginResult{Success: false, Message: "用户名或密码错误"} + } + + // 清除用户名级登录失败记录 + database.RDB.Del(ctx, attemptsKey) + _ = s.userRepo.UpdateLastLogin(user.UserID, ip) + + classID := student.ClassID + var className *string + cls, err := s.classRepo.GetByID(classID) + if err == nil && cls != nil { + className = &cls.ClassName + } + + token, err := appJwt.CreateToken(user.UserID, user.Username, user.UserType, user.StudentID, "", user.RealName, &classID, user.NeedChangePassword == 1) + if err != nil { + return &LoginResult{Success: false, Message: "生成令牌失败"} + } + + _ = database.SetUserToken(ctx, user.UserID, token, cfg.JWTIdleTimeoutMinutes) + s.logService.WriteLoginLog(username, 1, ip, userAgent, "") + + needChangePassword := user.NeedChangePassword == 1 + redirect := "/parent/dashboard.php" + if needChangePassword { + redirect = "/parent/password.php" + } + + return &LoginResult{ + Success: true, + Token: token, + UserID: user.UserID, + Username: user.Username, + RealName: user.RealName, + UserType: user.UserType, + StudentID: user.StudentID, + ClassID: &classID, + ClassName: className, + NeedChangePassword: needChangePassword, + Redirect: redirect, + } +} + +// Logout 用户登出 +func (s *AuthService) Logout(userID int) error { + ctx := context.Background() + return database.DeleteUserToken(ctx, userID) +} + +// ChangePassword 修改密码 +func (s *AuthService) ChangePassword(userID int, oldPassword, newPassword string, force bool) error { + cfg := config.AppConfig + + user, err := s.userRepo.GetByUserID(userID) + if err != nil { + return fmt.Errorf("用户不存在") + } + + // 验证原密码(强制改密时跳过) + if !force { + if !crypto.VerifyPassword(oldPassword, user.PasswordHash, cfg.PasswordSalt) { + return fmt.Errorf("原密码错误") + } + } + + // 验证新密码强度 + if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid { + return fmt.Errorf("%s", msg) + } + + // 更新密码 + newHash := crypto.HashPassword(newPassword, cfg.PasswordSalt) + if err := s.userRepo.UpdatePassword(userID, newHash); err != nil { + return fmt.Errorf("密码修改失败") + } + + // 清除 Token + ctx := context.Background() + _ = database.DeleteUserToken(ctx, userID) + return nil +} + +// GetUserInfo 获取用户信息 +func (s *AuthService) GetUserInfo(userID int) (map[string]interface{}, error) { + user, err := s.userRepo.GetByUserID(userID) + if err != nil { + return nil, fmt.Errorf("用户不存在") + } + + result := map[string]interface{}{ + "user_id": user.UserID, + "username": user.Username, + "real_name": user.RealName, + "user_type": user.UserType, + "need_change_password": user.NeedChangePassword == 1, + } + + var classID int + + if user.StudentID != nil { + student, err := s.studentRepo.GetByID(*user.StudentID) + if err == nil && student != nil { + result["student_no"] = student.StudentNo + result["student_name"] = student.Name + result["total_points"] = student.TotalPoints + classID = student.ClassID + } + } + + if user.UserType == "admin" { + adminRole, err := s.adminRoleRepo.GetByUserID(userID) + if err == nil && adminRole != nil { + result["role"] = adminRole.RoleType + classID = adminRole.ClassID + } + } + + if classID > 0 { + result["class_id"] = classID + cls, err := s.classRepo.GetByID(classID) + if err == nil && cls != nil { + result["class_name"] = cls.ClassName + } + } + + return result, nil +} + +// UnlockAccount 解锁账号(清除用户名级 + IP 级登录失败计数) +func (s *AuthService) UnlockAccount(username, ip string) error { + ctx := context.Background() + keys := []string{fmt.Sprintf("login_attempts:%s", username)} + if ip != "" { + keys = append(keys, fmt.Sprintf("login_attempts:ip:%s", ip)) + } + return database.RDB.Del(ctx, keys...).Err() +} + +// getRedirectPath 根据用户类型和角色确定跳转路径 +func getRedirectPath(userType string, role *string) string { + switch userType { + case "super_admin": + return "/admin/dashboard.php" + case "admin": + return "/admin/dashboard.php" + case "student": + return "/student/dashboard.php" + case "parent": + return "/parent/dashboard.php" + default: + return "/" + } +} + +// getPasswordChangePath 根据用户类型返回密码修改页面路径 +func getPasswordChangePath(userType string) string { + switch userType { + case "super_admin": + return "/admin/password.php" + case "admin": + return "/admin/password.php" + case "student": + return "/student/password.php" + case "parent": + return "/parent/password.php" + default: + return "/" + } +} + diff --git a/backend-go/internal/service/class_service.go b/backend-go/internal/service/class_service.go new file mode 100644 index 0000000..9e7a981 --- /dev/null +++ b/backend-go/internal/service/class_service.go @@ -0,0 +1,224 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "context" + "fmt" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database" +) + +// ClassService 班级服务 +type ClassService struct { + classRepo *repository.ClassRepo + userRepo *repository.UserRepo + adminRoleRepo *repository.AdminRoleRepo +} + +// NewClassService 创建班级服务 +func NewClassService( + classRepo *repository.ClassRepo, + userRepo *repository.UserRepo, + adminRoleRepo *repository.AdminRoleRepo, +) *ClassService { + return &ClassService{ + classRepo: classRepo, + userRepo: userRepo, + adminRoleRepo: adminRoleRepo, + } +} + +// ListClasses 获取班级列表 +func (s *ClassService) ListClasses(includeDisabled bool) (map[string]interface{}, error) { + classes, err := s.classRepo.GetAll(includeDisabled) + if err != nil { + return nil, err + } + + for i := range classes { + count, _ := s.classRepo.GetStudentCount(classes[i].ClassID) + classes[i].StudentCount = count + } + + return map[string]interface{}{ + "classes": classes, + "total": len(classes), + }, nil +} + +// GetClassDetail 获取班级详情 +func (s *ClassService) GetClassDetail(classID int) (map[string]interface{}, error) { + cls, err := s.classRepo.GetByID(classID) + if err != nil { + return nil, err + } + cls.StudentCount, _ = s.classRepo.GetStudentCount(classID) + return map[string]interface{}{"class": cls}, nil +} + +// CreateClass 创建班级 +func (s *ClassService) CreateClass(className string, grade, description *string) (map[string]interface{}, error) { + existing, _ := s.classRepo.GetByName(className) + if existing != nil { + return map[string]interface{}{"success": false, "message": "班级名称已存在"}, nil + } + + cls := &model.Class{ + ClassName: className, + Grade: grade, + Description: description, + Status: 1, + } + classID, err := s.classRepo.Create(cls) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "success": true, + "class_id": classID, + "message": "班级创建成功", + }, nil +} + +// UpdateClass 更新班级 +func (s *ClassService) UpdateClass(classID int, className, grade, description *string, status *int8) error { + existing, err := s.classRepo.GetByID(classID) + if err != nil { + return fmt.Errorf("班级不存在") + } + + updates := make(map[string]interface{}) + if className != nil && *className != existing.ClassName { + nameExists, _ := s.classRepo.GetByName(*className) + if nameExists != nil { + return fmt.Errorf("班级名称已存在") + } + updates["class_name"] = *className + } + if grade != nil { + updates["grade"] = *grade + } + if description != nil { + updates["description"] = *description + } + if status != nil { + updates["status"] = *status + } + + return s.classRepo.Update(classID, updates) +} + +// DeleteClass 删除班级 +func (s *ClassService) DeleteClass(classID int) error { + hasStudents, _ := s.classRepo.HasActiveStudents(classID) + if hasStudents { + return fmt.Errorf("该班级下还有学生,无法删除") + } + return s.classRepo.Delete(classID) +} + +// SwitchClass 切换班级上下文(超级管理员) +func (s *ClassService) SwitchClass(userID int, classID int) (map[string]interface{}, error) { + cfg := config.AppConfig + cls, err := s.classRepo.GetByID(classID) + if err != nil { + return nil, fmt.Errorf("班级不存在") + } + + user, err := s.userRepo.GetByUserID(userID) + if err != nil { + return nil, fmt.Errorf("用户不存在") + } + + // 查询目标班级中该用户的角色 + var role string + if user.UserType == "super_admin" { + role = "系统管理员" + } else { + adminRole, _ := s.adminRoleRepo.GetByUserIDAndClass(userID, classID) + if adminRole != nil { + role = adminRole.RoleType + } + } + + // 生成新 Token,更新 class_id + token, err := appJwt.CreateToken( + user.UserID, user.Username, user.UserType, + user.StudentID, role, user.RealName, &classID, + user.NeedChangePassword == 1, + ) + if err != nil { + return nil, fmt.Errorf("生成令牌失败") + } + + ctx := context.Background() + _ = database.SetUserToken(ctx, userID, token, cfg.JWTIdleTimeoutMinutes) + + return map[string]interface{}{ + "token": token, + "class_id": classID, + "class_name": cls.ClassName, + }, nil +} + +// GetSettings 获取班级设置 +func (s *ClassService) GetSettings(classID int) (map[string]interface{}, error) { + settings, err := s.classRepo.GetSettings(classID) + if err != nil { + return nil, err + } + + result := make(map[string]string) + for _, setting := range settings { + result[setting.SettingKey] = setting.SettingValue + } + return map[string]interface{}{"settings": result}, nil +} + +// SaveSetting 保存班级设置 +func (s *ClassService) SaveSetting(classID int, key, value string) error { + return s.classRepo.SaveSetting(classID, key, value) +} + +// GetFeatures 获取班级功能开关 +func (s *ClassService) GetFeatures(classID int) (map[string]interface{}, error) { + features, err := s.classRepo.GetFeatures(classID) + if err != nil { + return nil, err + } + + result := make(map[string]int8) + for _, f := range features { + result[f.FeatureKey] = f.Enabled + } + return map[string]interface{}{"features": result}, nil +} + +// SaveFeature 保存班级功能开关 +func (s *ClassService) SaveFeature(classID int, featureKey string, enabled int8) error { + return s.classRepo.SaveFeature(classID, featureKey, enabled) +} + +// IsFeatureEnabled 检查功能开关是否启用 +func (s *ClassService) IsFeatureEnabled(classID int, featureKey string) bool { + feature, err := s.classRepo.GetFeature(classID, featureKey) + if err != nil || feature == nil { + return true // 默认启用 + } + return feature.Enabled == 1 +} diff --git a/backend-go/internal/service/conduct_service.go b/backend-go/internal/service/conduct_service.go new file mode 100644 index 0000000..25ee71b --- /dev/null +++ b/backend-go/internal/service/conduct_service.go @@ -0,0 +1,384 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "fmt" + "strconv" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// ConductService 操行分服务 +type ConductService struct { + conductRepo *repository.ConductRepo + studentRepo *repository.StudentRepo + adminRoleRepo *repository.AdminRoleRepo + semesterRepo *repository.SemesterRepo + classRepo *repository.ClassRepo +} + +// NewConductService 创建操行分服务 +func NewConductService( + conductRepo *repository.ConductRepo, + studentRepo *repository.StudentRepo, + adminRoleRepo *repository.AdminRoleRepo, + semesterRepo *repository.SemesterRepo, + classRepo *repository.ClassRepo, +) *ConductService { + return &ConductService{ + conductRepo: conductRepo, + studentRepo: studentRepo, + adminRoleRepo: adminRoleRepo, + semesterRepo: semesterRepo, + classRepo: classRepo, + } +} + +// AddPoints 批量加减分 +func (s *ConductService) AddPoints(studentIDs []int, pointsChange int, reason string, + recorderID int, recorderName string, classID int, relatedType string) (map[string]interface{}, error) { + + // 输入校验 + if len(studentIDs) == 0 || len(studentIDs) > 200 { + return map[string]interface{}{"success": false, "message": "学生数量需在1-200之间"}, nil + } + if reason == "" || len(reason) > 255 { + return map[string]interface{}{"success": false, "message": "原因不能为空且不超过255字符"}, nil + } + if pointsChange == 0 || absInt(pointsChange) > 100 { + return map[string]interface{}{"success": false, "message": "分值无效"}, nil + } + + // 获取操作人角色 + role, _, err := s.adminRoleRepo.GetUserRoleAndClassID(recorderID) + if err != nil { + return map[string]interface{}{"success": false, "message": "获取操作人角色失败"}, nil + } + + // 权限验证(从 class_settings 读取限制,这里使用默认值) + if err := s.validatePointsPermission(role, pointsChange, classID); err != nil { + return map[string]interface{}{"success": false, "message": err.Error()}, nil + } + + return s.addPointsInternal(studentIDs, pointsChange, reason, recorderID, recorderName, classID, relatedType) +} + +// CadreAddPoints 课代表专用加减分(跳过角色权限验证,仅限作业相关扣分) +func (s *ConductService) CadreAddPoints(studentIDs []int, pointsChange int, reason string, + recorderID int, recorderName string, classID int, relatedType string) (map[string]interface{}, error) { + + // 输入校验 + if len(studentIDs) == 0 || len(studentIDs) > 200 { + return map[string]interface{}{"success": false, "message": "学生数量需在1-200之间"}, nil + } + if reason == "" || len(reason) > 255 { + return map[string]interface{}{"success": false, "message": "原因不能为空且不超过255字符"}, nil + } + if pointsChange >= 0 || absInt(pointsChange) > 100 { + return map[string]interface{}{"success": false, "message": "课代表只能进行扣分操作"}, nil + } + + // 强制设置为作业类型 + relatedType = "homework" + + return s.addPointsInternal(studentIDs, pointsChange, reason, recorderID, recorderName, classID, relatedType) +} + +// addPointsInternal 批量加减分内部实现 +func (s *ConductService) addPointsInternal(studentIDs []int, pointsChange int, reason string, + recorderID int, recorderName string, classID int, relatedType string) (map[string]interface{}, error) { + + // 自动获取当前活跃学期 + activeSemester, semErr := s.semesterRepo.GetActive() + if semErr != nil { + logger.Sugared.Warnf("获取活跃学期失败,操行分将不关联学期: %v", semErr) + } + var semesterID *int + if activeSemester != nil { + semesterID = &activeSemester.SemesterID + } + + if relatedType == "" { + relatedType = "manual" + } + + successCount := 0 + failCount := 0 + var details []map[string]interface{} + db := s.semesterRepo.GetDB() + + for _, studentID := range studentIDs { + // 检查学生是否存在 + student, err := s.studentRepo.GetByID(studentID) + if err != nil || student == nil { + failCount++ + details = append(details, map[string]interface{}{"student_id": studentID, "error": "学生不存在"}) + continue + } + + // 校验学生是否属于当前班级 + if student.ClassID != classID { + failCount++ + details = append(details, map[string]interface{}{"student_id": studentID, "error": "学生不属于当前班级"}) + continue + } + + // 使用事务确保记录创建和总分更新的原子性(#3) + recordID, txErr := func() (int64, error) { + var rid int64 + txErr := db.Transaction(func(tx *gorm.DB) error { + record := &model.ConductRecord{ + StudentID: studentID, + PointsChange: pointsChange, + Reason: reason, + RecorderID: recorderID, + RecorderName: &recorderName, + RelatedType: relatedType, + SemesterID: semesterID, + } + if err := tx.Create(record).Error; err != nil { + return err + } + rid = record.RecordID + if err := tx.Model(&model.Student{}). + Where("student_id = ?", studentID). + Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", pointsChange)).Error; err != nil { + return err + } + return nil + }) + return rid, txErr + }() + + if txErr != nil { + failCount++ + details = append(details, map[string]interface{}{"student_id": studentID, "error": txErr.Error()}) + continue + } + + successCount++ + details = append(details, map[string]interface{}{"student_id": studentID, "success": true, "record_id": recordID}) + logger.Sugared.Infof("用户[%d] 对学生[%d] 进行 %d 分操作", recorderID, studentID, pointsChange) + } + + return map[string]interface{}{ + "success": failCount == 0, + "success_count": successCount, + "fail_count": failCount, + "details": details, + }, nil +} + +// RevokeRecord 撤销记录(事务保护,避免并发重复撤销) +func (s *ConductService) RevokeRecord(recordID int64, revokerID int, classID int) (map[string]interface{}, error) { + record, err := s.conductRepo.GetRecordByID(recordID) + if err != nil || record == nil { + return map[string]interface{}{"success": false, "message": "记录不存在"}, nil + } + + // 校验记录所属学生是否在当前操作者的班级中 + student, _ := s.studentRepo.GetByID(record.StudentID) + if student == nil || student.ClassID != classID { + return map[string]interface{}{"success": false, "message": "无权操作该记录"}, nil + } + + if record.IsRevoked == 1 { + return map[string]interface{}{"success": false, "message": "该记录已被撤销"}, nil + } + + db := s.semesterRepo.GetDB() + txErr := db.Transaction(func(tx *gorm.DB) error { + // 撤销记录 + if err := tx.Model(&model.ConductRecord{}). + Where("record_id = ? AND is_revoked = 0", recordID). + Updates(map[string]interface{}{ + "is_revoked": 1, + "revoked_by": revokerID, + }).Error; err != nil { + return err + } + // 反向恢复学生总分(下限保护) + return tx.Model(&model.Student{}). + Where("student_id = ?", record.StudentID). + Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", -record.PointsChange)).Error + }) + if txErr != nil { + return map[string]interface{}{"success": false, "message": "撤销失败"}, nil + } + + return map[string]interface{}{ + "success": true, + "message": "撤销成功", + "record": map[string]interface{}{ + "student_id": record.StudentID, + "recorder_name": derefStr(record.RecorderName), + "points_change": record.PointsChange, + }, + }, nil +} + +// RestoreRecord 反撤销记录(事务保护,避免并发重复恢复) +func (s *ConductService) RestoreRecord(recordID int64, restorerID int, classID int) (map[string]interface{}, error) { + record, err := s.conductRepo.GetRecordByID(recordID) + if err != nil || record == nil { + return map[string]interface{}{"success": false, "message": "记录不存在"}, nil + } + + // 校验记录所属学生是否在当前操作者的班级中 + student, _ := s.studentRepo.GetByID(record.StudentID) + if student == nil || student.ClassID != classID { + return map[string]interface{}{"success": false, "message": "无权操作该记录"}, nil + } + + if record.IsRevoked == 0 { + return map[string]interface{}{"success": false, "message": "该记录未被撤销,无需恢复"}, nil + } + + db := s.semesterRepo.GetDB() + txErr := db.Transaction(func(tx *gorm.DB) error { + // 反撤销 + if err := tx.Model(&model.ConductRecord{}). + Where("record_id = ? AND is_revoked = 1", recordID). + Updates(map[string]interface{}{ + "is_revoked": 0, + "revoked_by": nil, + "revoked_at": nil, + }).Error; err != nil { + return err + } + // 恢复学生总分(下限保护) + return tx.Model(&model.Student{}). + Where("student_id = ?", record.StudentID). + Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", record.PointsChange)).Error + }) + if txErr != nil { + return map[string]interface{}{"success": false, "message": "反撤销失败"}, nil + } + + return map[string]interface{}{ + "success": true, + "message": "反撤销成功", + }, nil +} + +// GetHistory 获取操行分历史记录 +func (s *ConductService) GetHistory(classID int, studentID *int, page, pageSize int, + startDate, endDate, relatedType, reasonPrefix string, isRevoked *int, reasonSearch string) (map[string]interface{}, error) { + + includeRevoked := false + if isRevoked != nil && *isRevoked == 1 { + includeRevoked = true + } + + offset := (page - 1) * pageSize + + records, err := s.conductRepo.GetAllRecords(classID, pageSize, offset, startDate, endDate, + derefInt(studentID), includeRevoked, relatedType, reasonPrefix, isRevoked, reasonSearch) + if err != nil { + return nil, err + } + + total, err := s.conductRepo.CountAllRecords(classID, startDate, endDate, + derefInt(studentID), includeRevoked, relatedType, reasonPrefix, isRevoked, reasonSearch) + if err != nil { + return nil, err + } + + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + + return map[string]interface{}{ + "records": records, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": totalPages, + }, nil +} + +// validatePointsPermission 验证角色加减分权限 +func (s *ConductService) validatePointsPermission(role string, pointsChange, classID int) error { + // 从 class_settings 读取配置,若无则使用默认值 + maxPoints := func(key string, defaultVal int) int { + if classID > 0 { + setting, err := s.classRepo.GetSetting(classID, key) + if err == nil && setting != nil { + if v, e := strconv.Atoi(setting.SettingValue); e == nil { + return v + } + } + } + return defaultVal + } + + switch role { + case "班主任": + return nil // 无限制 + case "班长": + maxAdd := maxPoints("point_limit_班长_max", 5) + maxSub := maxPoints("point_limit_班长_min", -5) + if pointsChange > maxAdd || pointsChange < maxSub { + return fmt.Errorf("班长单次只能加%d至减%d分以内", maxAdd, absInt(maxSub)) + } + case "学习委员": + limit := maxPoints("point_limit_学习委员_max", 5) + if absInt(pointsChange) > limit { + return fmt.Errorf("学习委员单次只能加减%d分以内", limit) + } + case "科任老师": + limit := maxPoints("point_limit_科任老师_max", 5) + if absInt(pointsChange) > limit { + return fmt.Errorf("科任老师单次只能加减%d分以内", limit) + } + case "考勤委员": + if pointsChange > 0 { + return fmt.Errorf("考勤委员只能进行扣分操作") + } + limit := maxPoints("point_limit_考勤委员_max", 8) + if absInt(pointsChange) > limit { + return fmt.Errorf("考勤委员单次最多扣%d分", limit) + } + case "劳动委员": + limit := maxPoints("point_limit_劳动委员_max", 1) + if absInt(pointsChange) > limit { + return fmt.Errorf("劳动委员单次只能加减%d分以内", limit) + } + case "志愿委员": + if pointsChange < 0 { + return fmt.Errorf("志愿委员只能加分") + } + limit := maxPoints("point_limit_志愿委员_max", 5) + if pointsChange > limit { + return fmt.Errorf("志愿委员单次最多加%d分", limit) + } + case "课代表": + return fmt.Errorf("课代表无权进行此操作") + default: + return fmt.Errorf("无权进行此操作") + } + return nil +} + +// absInt 取绝对值 +func absInt(x int) int { + if x < 0 { + return -x + } + return x +} diff --git a/backend-go/internal/service/config_service.go b/backend-go/internal/service/config_service.go new file mode 100644 index 0000000..5c0daca --- /dev/null +++ b/backend-go/internal/service/config_service.go @@ -0,0 +1,49 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" +) + +// ConfigService 配置服务 +type ConfigService struct { + classRepo *repository.ClassRepo +} + +// NewConfigService 创建配置服务 +func NewConfigService(classRepo *repository.ClassRepo) *ConfigService { + return &ConfigService{classRepo: classRepo} +} + +// GetClassSettingValue 从 class_settings 读取设置值,若无则返回默认值 +func (s *ConfigService) GetClassSettingValue(classID int, key, defaultVal string) string { + if classID > 0 && s.classRepo != nil { + setting, err := s.classRepo.GetSetting(classID, key) + if err == nil && setting != nil && setting.SettingValue != "" { + return setting.SettingValue + } + } + return defaultVal +} + +// GetDeductionRules 获取扣分规则(优先从 class_settings 读取班级级配置) +func (s *ConfigService) GetDeductionRules(classID int) map[string]string { + return map[string]string{ + "DEDUCTION_ATTENDANCE_ABSENT": s.GetClassSettingValue(classID, "deduction_attendance_absent", "3"), + "DEDUCTION_ATTENDANCE_LATE": s.GetClassSettingValue(classID, "deduction_attendance_late", "1"), + "DEDUCTION_ATTENDANCE_LEAVE": s.GetClassSettingValue(classID, "deduction_attendance_leave", "0"), + "STUDENT_INITIAL_POINTS": s.GetClassSettingValue(classID, "initial_points", "60"), + "DEDUCTION_HOMEWORK_NOT_SUBMIT": s.GetClassSettingValue(classID, "deduction_homework_not_submit", "2"), + "DEDUCTION_HOMEWORK_LATE": s.GetClassSettingValue(classID, "deduction_homework_late", "1"), + } +} diff --git a/backend-go/internal/service/log_service.go b/backend-go/internal/service/log_service.go new file mode 100644 index 0000000..6924200 --- /dev/null +++ b/backend-go/internal/service/log_service.go @@ -0,0 +1,70 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// LogService 日志服务 +type LogService struct { + logRepo *repository.LogRepo +} + +// NewLogService 创建日志服务 +func NewLogService(logRepo *repository.LogRepo) *LogService { + return &LogService{logRepo: logRepo} +} + +// WriteLoginLog 写入登录日志 +func (s *LogService) WriteLoginLog(username string, loginResult int8, ip, userAgent, failReason string) { + log := &model.LoginLog{ + Username: username, + LoginResult: loginResult, + IPAddress: stringPtr(ip), + UserAgent: stringPtr(userAgent), + FailReason: stringPtr(failReason), + } + if _, err := s.logRepo.CreateLoginLog(log); err != nil { + logger.Sugared.Errorf("写入登录日志失败: %v", err) + } +} + +// WriteOperationLog 写入操作日志 +func (s *LogService) WriteOperationLog(operatorID int, operatorName, operatorRole, operationType string, + targetType *string, targetID *int, details *string, ip *string, classID *int) { + log := &model.OperationLog{ + OperatorID: operatorID, + OperatorName: stringPtr(operatorName), + OperatorRole: stringPtr(operatorRole), + OperationType: operationType, + TargetType: targetType, + TargetID: targetID, + Details: details, + IPAddress: ip, + ClassID: classID, + } + if _, err := s.logRepo.CreateOperationLog(log); err != nil { + logger.Sugared.Errorf("写入操作日志失败: %v", err) + } +} + +// stringPtr 辅助函数:字符串转指针(空字符串返回 nil) +func stringPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + diff --git a/backend-go/internal/service/ranking_service.go b/backend-go/internal/service/ranking_service.go new file mode 100644 index 0000000..b1fbb13 --- /dev/null +++ b/backend-go/internal/service/ranking_service.go @@ -0,0 +1,80 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" +) + +// RankingService 排行榜服务 +type RankingService struct { + studentRepo *repository.StudentRepo + conductRepo *repository.ConductRepo +} + +// NewRankingService 创建排行榜服务 +func NewRankingService( + studentRepo *repository.StudentRepo, + conductRepo *repository.ConductRepo, +) *RankingService { + return &RankingService{ + studentRepo: studentRepo, + conductRepo: conductRepo, + } +} + +// GetRankings 获取排行榜 +func (s *RankingService) GetRankings(classID int, rankType string, limit int) (map[string]interface{}, error) { + switch rankType { + case "attendance", "homework", "conduct": + return s.getTypedRanking(classID, rankType, limit) + default: + // 默认按操行分总分排行 + ranking, err := s.studentRepo.GetRanking(classID, limit) + if err != nil { + return nil, err + } + totalStudents, _ := s.studentRepo.GetTotalCount(classID) + return map[string]interface{}{ + "ranking": ranking, + "total_students": totalStudents, + "type": "all", + }, nil + } +} + +// getTypedRanking 获取分项排行榜(使用 SQL 层聚合,避免全量加载) +func (s *RankingService) getTypedRanking(classID int, relatedType string, limit int) (map[string]interface{}, error) { + dbType := relatedType + if relatedType == "conduct" { + dbType = "manual" + } + results, err := s.conductRepo.GetStudentPointsByType(classID, dbType, limit) + if err != nil { + return nil, err + } + + var rankings []map[string]interface{} + for _, r := range results { + rankings = append(rankings, map[string]interface{}{ + "student_id": r.StudentID, + "student_no": r.StudentNo, + "name": r.Name, + "points": r.TotalPoints, + }) + } + + return map[string]interface{}{ + "ranking": rankings, + "type": relatedType, + }, nil +} diff --git a/backend-go/internal/service/semester_service.go b/backend-go/internal/service/semester_service.go new file mode 100644 index 0000000..e3edb4a --- /dev/null +++ b/backend-go/internal/service/semester_service.go @@ -0,0 +1,665 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "fmt" + "strconv" + "time" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// SemesterService 学期服务 +type SemesterService struct { + semesterRepo *repository.SemesterRepo + studentRepo *repository.StudentRepo + classRepo *repository.ClassRepo + attendanceRepo *repository.AttendanceRepo + assignmentRepo *repository.AssignmentRepo + logService *LogService +} + +// NewSemesterService 创建学期服务 +func NewSemesterService( + semesterRepo *repository.SemesterRepo, + studentRepo *repository.StudentRepo, + classRepo *repository.ClassRepo, + attendanceRepo *repository.AttendanceRepo, + assignmentRepo *repository.AssignmentRepo, + logService *LogService, +) *SemesterService { + return &SemesterService{ + semesterRepo: semesterRepo, + studentRepo: studentRepo, + classRepo: classRepo, + attendanceRepo: attendanceRepo, + assignmentRepo: assignmentRepo, + logService: logService, + } +} + +// ListSemesters 获取学期列表 +func (s *SemesterService) ListSemesters() (map[string]interface{}, error) { + semesters, err := s.semesterRepo.GetAll() + if err != nil { + return nil, err + } + + today := time.Now() + for i := range semesters { + conductCount, attendanceCount, _ := s.semesterRepo.CountRecordsBySemester(semesters[i].SemesterID) + semesters[i].ConductCount = conductCount + semesters[i].AttendanceCount = attendanceCount + + // 计算当前周数 + if semesters[i].IsActive == 1 && semesters[i].StartDate != nil { + delta := today.Sub(*semesters[i].StartDate).Hours() / (24 * 7) + if delta >= 0 { + week := int(delta) + 1 + semesters[i].CurrentWeek = &week + } + } + } + + return map[string]interface{}{ + "semesters": semesters, + }, nil +} + +// GetActiveSemester 获取当前活跃学期 +func (s *SemesterService) GetActiveSemester() (*model.Semester, error) { + return s.semesterRepo.GetActive() +} + +// CreateSemester 创建学期 +func (s *SemesterService) CreateSemester(semesterName string, startDate, endDate *string) (map[string]interface{}, error) { + semester := &model.Semester{ + SemesterName: semesterName, + IsActive: 0, + IsArchived: 0, + } + + if startDate != nil && *startDate != "" { + t, err := time.Parse("2006-01-02", *startDate) + if err == nil { + semester.StartDate = &t + } + } + if endDate != nil && *endDate != "" { + t, err := time.Parse("2006-01-02", *endDate) + if err == nil { + semester.EndDate = &t + } + } + + semesterID, err := s.semesterRepo.Create(semester) + if err != nil { + return map[string]interface{}{"success": false, "message": "创建学期失败"}, nil + } + + // 如果日期范围包含今天,自动激活 + if semester.StartDate != nil { + today := time.Now() + if semester.StartDate.Before(today) || sameDay(*semester.StartDate, today) { + if semester.EndDate == nil || semester.EndDate.After(today) || sameDay(*semester.EndDate, today) { + _ = s.semesterRepo.DeactivateAll() + _ = s.semesterRepo.Activate(semesterID) + } + } + } + + return map[string]interface{}{ + "success": true, + "message": "学期创建成功", + "semester_id": semesterID, + }, nil +} + +// ActivateSemester 激活学期 +func (s *SemesterService) ActivateSemester(semesterID int) error { + semester, err := s.semesterRepo.GetByID(semesterID) + if err != nil || semester == nil { + return fmt.Errorf("学期不存在") + } + if semester.IsArchived == 1 { + return fmt.Errorf("已归档的学期不能设为当前学期") + } + + _ = s.semesterRepo.DeactivateAll() + return s.semesterRepo.Activate(semesterID) +} + +// UpdateSemester 更新学期 +func (s *SemesterService) UpdateSemester(semesterID int, semesterName, startDate, endDate *string) error { + semester, err := s.semesterRepo.GetByID(semesterID) + if err != nil || semester == nil { + return fmt.Errorf("学期不存在") + } + if semester.IsArchived == 1 { + return fmt.Errorf("已归档的学期不能编辑") + } + + updates := make(map[string]interface{}) + if semesterName != nil { + updates["semester_name"] = *semesterName + } + if startDate != nil { + t, err := time.Parse("2006-01-02", *startDate) + if err == nil { + updates["start_date"] = t + } + } + if endDate != nil { + t, err := time.Parse("2006-01-02", *endDate) + if err == nil { + updates["end_date"] = t + } + } + + return s.semesterRepo.Update(semesterID, updates) +} + +// DeleteSemester 删除学期 +func (s *SemesterService) DeleteSemester(semesterID int) error { + archiveCount, err := s.semesterRepo.CountArchives(semesterID) + if err != nil { + return err + } + if archiveCount > 0 { + return fmt.Errorf("该学期有 %d 条归档数据,无法删除", archiveCount) + } + return s.semesterRepo.Delete(semesterID) +} + +// AssociateRecords 关联记录到学期 +func (s *SemesterService) AssociateRecords(semesterID int) (map[string]interface{}, error) { + semester, err := s.semesterRepo.GetByID(semesterID) + if err != nil || semester == nil { + return map[string]interface{}{"success": false, "message": "学期不存在"}, nil + } + if semester.IsArchived == 1 { + return map[string]interface{}{"success": false, "message": "已归档的学期不能关联数据"}, nil + } + if semester.StartDate == nil { + return map[string]interface{}{"success": false, "message": "学期未设置开始日期,无法关联数据"}, nil + } + + startDate := semester.StartDate.Format("2006-01-02") + endDate := time.Now().Format("2006-01-02") + if semester.EndDate != nil { + endDate = semester.EndDate.Format("2006-01-02") + } + + conductCount, attendanceCount, err := s.semesterRepo.AssociateRecordsByDateRange(semesterID, startDate, endDate) + if err != nil { + return map[string]interface{}{"success": false, "message": "关联记录失败"}, nil + } + + return map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("关联完成:操行分 %d 条,考勤 %d 条", conductCount, attendanceCount), + "data": map[string]interface{}{ + "conduct": conductCount, + "attendance": attendanceCount, + }, + }, nil +} + +// ArchiveSemester 归档学期 +func (s *SemesterService) ArchiveSemester(semesterID, classID int, resetScores bool) (map[string]interface{}, error) { + semester, err := s.semesterRepo.GetByID(semesterID) + if err != nil || semester == nil { + return map[string]interface{}{"success": false, "message": "学期不存在"}, nil + } + if semester.IsArchived == 1 { + return map[string]interface{}{"success": false, "message": "该学期已归档"}, nil + } + if semester.StartDate == nil { + return map[string]interface{}{"success": false, "message": "学期未设置开始日期,无法进行归档"}, nil + } + if classID == 0 { + return map[string]interface{}{"success": false, "message": "未指定班级"}, nil + } + + // 获取班级活跃学生 + students, err := s.studentRepo.GetStudentsByClassID(classID) + if err != nil || len(students) == 0 { + return map[string]interface{}{"success": false, "message": "没有可归档的学生数据"}, nil + } + totalStudents := len(students) + + // 查询考勤统计 + startDate := semester.StartDate.Format("2006-01-02") + endDate := time.Now().Format("2006-01-02") + if semester.EndDate != nil { + endDate = semester.EndDate.Format("2006-01-02") + } + attendanceStats, _ := s.attendanceRepo.GetAttendanceStatsBySemester(semesterID, startDate, endDate) + attendanceMap := make(map[int]map[string]int64) + for _, stat := range attendanceStats { + if attendanceMap[stat.StudentID] == nil { + attendanceMap[stat.StudentID] = make(map[string]int64) + } + attendanceMap[stat.StudentID][stat.Status] = stat.Count + } + + // 查询作业统计 + homeworkStats, err := s.assignmentRepo.GetHomeworkStatsByDateRange(*semester.StartDate, time.Now()) + if err != nil { + logger.Sugared.Warnf("查询作业统计失败,归档快照中作业数据可能不完整: %v", err) + } + homeworkMap := make(map[int]map[string]int64) + for _, stat := range homeworkStats { + if homeworkMap[stat.StudentID] == nil { + homeworkMap[stat.StudentID] = make(map[string]int64) + } + homeworkMap[stat.StudentID][stat.Status] = stat.Count + } + + // 使用事务确保归档操作的原子性,并通过行锁防止并发归档 + db := s.semesterRepo.GetDB() + txErr := db.Transaction(func(tx *gorm.DB) error { + // 使用 SELECT ... FOR UPDATE 锁定学期记录,防止并发归档 + var lockedSemester model.Semester + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("semester_id = ?", semesterID).First(&lockedSemester).Error; err != nil { + return fmt.Errorf("锁定学期记录失败: %w", err) + } + if lockedSemester.IsArchived == 1 { + return fmt.Errorf("该学期已被其他操作归档") + } + + // 删除旧的归档数据 + if err := tx.Where("semester_id = ?", semesterID).Delete(&model.SemesterArchive{}).Error; err != nil { + return fmt.Errorf("删除旧归档数据失败: %w", err) + } + + // 创建归档快照(填充考勤和作业统计) + var archives []model.SemesterArchive + for rank, stu := range students { + stuAttendance := attendanceMap[stu.StudentID] + stuHomework := homeworkMap[stu.StudentID] + archive := model.SemesterArchive{ + SemesterID: semesterID, + ClassID: classID, + StudentID: stu.StudentID, + StudentNo: stu.StudentNo, + StudentName: stu.Name, + FinalPoints: stu.TotalPoints, + RankPosition: intPtr(rank + 1), + TotalStudents: &totalStudents, + AttendancePresent: int(stuAttendance["present"]), + AttendanceAbsent: int(stuAttendance["absent"]), + AttendanceLate: int(stuAttendance["late"]), + AttendanceLeave: int(stuAttendance["leave"]), + HomeworkSubmitted: int(stuHomework["submitted"]), + HomeworkNotSubmitted: int(stuHomework["not_submitted"]), + HomeworkLate: int(stuHomework["late"]), + } + archives = append(archives, archive) + } + + if len(archives) > 0 { + if err := tx.Create(&archives).Error; err != nil { + return fmt.Errorf("创建归档快照失败: %w", err) + } + } + + // 归档学期 + if err := tx.Model(&model.Semester{}). + Where("semester_id = ? AND is_archived = 0", semesterID). + Updates(map[string]interface{}{"is_archived": 1, "is_active": 0}).Error; err != nil { + return fmt.Errorf("归档学期失败: %w", err) + } + + // 重置分数(从 class_settings 读取初始分,若无则默认 60) + if resetScores { + initialPoints := 60 + var setting model.ClassSetting + if err := tx.Where("class_id = ? AND setting_key = ?", classID, "initial_points").First(&setting).Error; err == nil { + if v, e := strconv.Atoi(setting.SettingValue); e == nil { + initialPoints = v + } + } + if err := tx.Model(&model.Student{}). + Where("class_id = ? AND status = 1", classID). + Update("total_points", initialPoints).Error; err != nil { + return fmt.Errorf("重置分数失败: %w", err) + } + } + + return nil + }) + + if txErr != nil { + logger.Sugared.Errorf("归档事务失败: %v", txErr) + return map[string]interface{}{"success": false, "message": "归档失败: " + txErr.Error()}, nil + } + + return map[string]interface{}{ + "success": true, + "message": "归档成功", + }, nil +} + +// GetArchiveRecords 获取归档数据 +func (s *SemesterService) GetArchiveRecords(semesterID, classID, page, pageSize int) (map[string]interface{}, error) { + archives, total, err := s.semesterRepo.GetArchivesBySemester(semesterID, classID, page, pageSize) + if err != nil { + return nil, err + } + + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + + return map[string]interface{}{ + "items": archives, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": totalPages, + }, nil +} + +// sameDay 判断两个时间是否同一天 +func sameDay(a, b time.Time) bool { + return a.Year() == b.Year() && a.YearDay() == b.YearDay() +} + +// ========== 周期重置功能 ========== + +// PeriodReset 周度/月度重置 +// 1. 创建当前操行分快照 +// 2. 将所有学生操行分重置为 class_settings.initial_points +// 3. 记录操作日志 +func (s *SemesterService) PeriodReset(classID int, period string, operatorID int, operatorName string, ip string) error { + periodLabel := generatePeriodLabel(period, time.Now()) + + // 读取初始分 + initialPoints := 60 + var setting model.ClassSetting + if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", classID, "initial_points").First(&setting).Error; err == nil { + if v, e := strconv.Atoi(setting.SettingValue); e == nil { + initialPoints = v + } + } + + // 获取班级活跃学生 + students, err := s.studentRepo.GetStudentsByClassID(classID) + if err != nil || len(students) == 0 { + return fmt.Errorf("没有可重置的学生数据") + } + + totalStudents := len(students) + var archives []model.PeriodArchive + for rank, stu := range students { + archive := model.PeriodArchive{ + ClassID: classID, + PeriodType: period, + PeriodLabel: periodLabel, + StudentID: stu.StudentID, + StudentNo: stu.StudentNo, + StudentName: stu.Name, + FinalPoints: stu.TotalPoints, + RankPosition: intPtr(rank + 1), + TotalStudents: &totalStudents, + ResetBy: "manual", + OperatorID: &operatorID, + } + archives = append(archives, archive) + } + + // 使用事务确保原子性,并将存在检查移入事务内防止竞态条件 + db := s.semesterRepo.GetDB() + txErr := db.Transaction(func(tx *gorm.DB) error { + // 在事务内检查本期是否已有归档数据(防并发重复重置) + var existCount int64 + if err := tx.Model(&model.PeriodArchive{}). + Where("class_id = ? AND period_type = ? AND period_label = ?", classID, period, periodLabel). + Count(&existCount).Error; err != nil { + return fmt.Errorf("检查归档数据失败: %w", err) + } + if existCount > 0 { + return fmt.Errorf("当前周期(%s)已有归档数据,请勿重复重置", periodLabel) + } + + // 创建归档快照 + if len(archives) > 0 { + if err := tx.Create(&archives).Error; err != nil { + return fmt.Errorf("创建周期归档快照失败: %w", err) + } + } + + // 重置分数 + if err := tx.Model(&model.Student{}). + Where("class_id = ? AND status = 1", classID). + Update("total_points", initialPoints).Error; err != nil { + return fmt.Errorf("重置分数失败: %w", err) + } + + return nil + }) + + if txErr != nil { + logger.Sugared.Errorf("周期重置事务失败: %v", txErr) + return txErr + } + + // 记录操作日志 + details := fmt.Sprintf("手动执行%s重置,周期标签: %s,影响学生数: %d", periodCN(period), periodLabel, totalStudents) + s.logService.WriteOperationLog( + operatorID, operatorName, "班主任", "period_reset", + nil, nil, &details, &ip, &classID, + ) + + return nil +} + +// AutoPeriodReset 自动周期重置检查(由定时任务调用) +func (s *SemesterService) AutoPeriodReset() { + logger.Sugared.Info("开始检查自动周期重置...") + + // 获取所有启用的班级 + classes, err := s.classRepo.GetAll(false) + if err != nil { + logger.Sugared.Errorf("获取班级列表失败: %v", err) + return + } + + now := time.Now() + for _, cls := range classes { + // 读取 reset_frequency + var freqSetting model.ClassSetting + if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", cls.ClassID, "reset_frequency").First(&freqSetting).Error; err != nil { + continue // 无配置,跳过 + } + freq := freqSetting.SettingValue + if freq == "none" || freq == "" { + continue + } + + shouldReset := false + switch freq { + case "weekly": + // 读取 reset_day_of_week(默认1=周一) + resetDay := 1 + var daySetting model.ClassSetting + if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", cls.ClassID, "reset_day_of_week").First(&daySetting).Error; err == nil { + if v, e := strconv.Atoi(daySetting.SettingValue); e == nil && v >= 1 && v <= 7 { + resetDay = v + } + } + // Go的Weekday: 0=Sunday, 1=Monday, ..., 6=Saturday + // 映射: 1=周一(1) ... 6=周六(6), 7=周日(0) + var targetWeekday time.Weekday + if resetDay == 7 { + targetWeekday = time.Sunday + } else { + targetWeekday = time.Weekday(resetDay) + } + if now.Weekday() == targetWeekday { + shouldReset = true + } + case "monthly": + // 读取 reset_day_of_month(默认1) + resetDay := 1 + var daySetting model.ClassSetting + if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", cls.ClassID, "reset_day_of_month").First(&daySetting).Error; err == nil { + if v, e := strconv.Atoi(daySetting.SettingValue); e == nil && v >= 1 && v <= 28 { + resetDay = v + } + } + if now.Day() == resetDay { + shouldReset = true + } + } + + if !shouldReset { + continue + } + + // 检查今天是否已经重置过 + periodLabel := generatePeriodLabel(freq, now) + var existCount int64 + if err := s.classRepo.GetDB().Model(&model.PeriodArchive{}). + Where("class_id = ? AND period_type = ? AND period_label = ? AND reset_by = ?", + cls.ClassID, freq, periodLabel, "auto"). + Count(&existCount).Error; err != nil { + logger.Sugared.Errorf("检查班级 %d 周期归档失败: %v", cls.ClassID, err) + continue + } + if existCount > 0 { + logger.Sugared.Infof("班级 %d 本期(%s)已自动重置,跳过", cls.ClassID, periodLabel) + continue + } + + // 执行自动重置 + logger.Sugared.Infof("自动重置班级 %d (%s, %s)", cls.ClassID, cls.ClassName, periodLabel) + if err := s.autoPeriodResetClass(cls.ClassID, freq, periodLabel); err != nil { + logger.Sugared.Errorf("自动重置班级 %d 失败: %v", cls.ClassID, err) + } + } +} + +// autoPeriodResetClass 单个班级的自动周期重置 +func (s *SemesterService) autoPeriodResetClass(classID int, period, periodLabel string) error { + initialPoints := 60 + var setting model.ClassSetting + if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", classID, "initial_points").First(&setting).Error; err == nil { + if v, e := strconv.Atoi(setting.SettingValue); e == nil { + initialPoints = v + } + } + + students, err := s.studentRepo.GetStudentsByClassID(classID) + if err != nil || len(students) == 0 { + return fmt.Errorf("没有可重置的学生数据") + } + + totalStudents := len(students) + var archives []model.PeriodArchive + for rank, stu := range students { + archive := model.PeriodArchive{ + ClassID: classID, + PeriodType: period, + PeriodLabel: periodLabel, + StudentID: stu.StudentID, + StudentNo: stu.StudentNo, + StudentName: stu.Name, + FinalPoints: stu.TotalPoints, + RankPosition: intPtr(rank + 1), + TotalStudents: &totalStudents, + ResetBy: "auto", + } + archives = append(archives, archive) + } + + db := s.semesterRepo.GetDB() + return db.Transaction(func(tx *gorm.DB) error { + if len(archives) > 0 { + if err := tx.Create(&archives).Error; err != nil { + return fmt.Errorf("创建周期归档快照失败: %w", err) + } + } + if err := tx.Model(&model.Student{}). + Where("class_id = ? AND status = 1", classID). + Update("total_points", initialPoints).Error; err != nil { + return fmt.Errorf("重置分数失败: %w", err) + } + return nil + }) +} + +// GetPeriodArchives 获取周期归档列表 +func (s *SemesterService) GetPeriodArchives(classID int, period string, page, pageSize int) (map[string]interface{}, error) { + archives, total, err := s.semesterRepo.GetPeriodArchives(classID, period, page, pageSize) + if err != nil { + return nil, err + } + + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + + return map[string]interface{}{ + "items": archives, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": totalPages, + }, nil +} + +// generatePeriodLabel 生成周期标签 +func generatePeriodLabel(period string, t time.Time) string { + switch period { + case "weekly": + year, week := t.ISOWeek() + return fmt.Sprintf("%d-W%02d", year, week) + case "monthly": + return t.Format("2006-01") + default: + return t.Format("2006-01-02") + } +} + +// periodCN 周期类型的中文描述 +func periodCN(period string) string { + switch period { + case "weekly": + return "每周" + case "monthly": + return "每月" + default: + return period + } +} + +// PeriodLabelCN 周期类型的中文标签(当前周期) +func PeriodLabelCN(period string) string { + switch period { + case "weekly": + return "本周" + case "monthly": + return "本月" + default: + return period + } +} diff --git a/backend-go/internal/service/student_service.go b/backend-go/internal/service/student_service.go new file mode 100644 index 0000000..e43793d --- /dev/null +++ b/backend-go/internal/service/student_service.go @@ -0,0 +1,171 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" +) + +// StudentService 学生端服务 +type StudentService struct { + studentRepo *repository.StudentRepo + conductRepo *repository.ConductRepo + attendanceRepo *repository.AttendanceRepo + semesterRepo *repository.SemesterRepo +} + +// NewStudentService 创建学生端服务 +func NewStudentService( + studentRepo *repository.StudentRepo, + conductRepo *repository.ConductRepo, + attendanceRepo *repository.AttendanceRepo, + semesterRepo *repository.SemesterRepo, +) *StudentService { + return &StudentService{ + studentRepo: studentRepo, + conductRepo: conductRepo, + attendanceRepo: attendanceRepo, + semesterRepo: semesterRepo, + } +} + +// GetStudentInfo 获取学生个人信息 +func (s *StudentService) GetStudentInfo(studentID int) (map[string]interface{}, error) { + student, err := s.studentRepo.GetByID(studentID) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "student": student, + }, nil +} + +// GetConductHistory 获取学生操行分历史 +func (s *StudentService) GetConductHistory(studentID int, limit, offset int) (map[string]interface{}, error) { + student, err := s.studentRepo.GetByID(studentID) + if err != nil { + return nil, err + } + + records, err := s.conductRepo.GetStudentRecords(studentID, limit, offset, false, "", "", 0) + if err != nil { + return nil, err + } + + // 扣分项的操作人统一显示为"班主任" + for i := range records { + if records[i].PointsChange < 0 { + name := "班主任" + records[i].RecorderReal = &name + } + } + + return map[string]interface{}{ + "student_id": studentID, + "student_name": student.Name, + "total_points": student.TotalPoints, + "records": records, + }, nil +} + +// GetHomeworkStatus 获取学生作业情况 +func (s *StudentService) GetHomeworkStatus(studentID int) (map[string]interface{}, error) { + student, err := s.studentRepo.GetByID(studentID) + if err != nil { + return nil, err + } + + records, err := s.conductRepo.GetStudentRecords(studentID, 1000, 0, false, "", "", 0) + if err != nil { + return nil, err + } + + // 过滤出作业相关记录 + var homeworkRecords []interface{} + for _, r := range records { + if r.RelatedType == "homework" { + homeworkRecords = append(homeworkRecords, r) + } + } + + return map[string]interface{}{ + "student_id": studentID, + "student_name": student.Name, + "homework": homeworkRecords, + }, nil +} + +// GetAttendanceRecords 获取学生考勤记录 +func (s *StudentService) GetAttendanceRecords(studentID int, month string) (map[string]interface{}, error) { + student, err := s.studentRepo.GetByID(studentID) + if err != nil { + return nil, err + } + + records, err := s.attendanceRepo.GetStudentRecords(studentID, month) + if err != nil { + return nil, err + } + + // 统计 + present, absent, late, leave := 0, 0, 0, 0 + for _, r := range records { + switch r.Status { + case "present": + present++ + case "absent": + absent++ + case "late": + late++ + case "leave": + leave++ + } + } + + return map[string]interface{}{ + "student_id": studentID, + "student_name": student.Name, + "statistics": map[string]interface{}{ + "present": present, + "absent": absent, + "late": late, + "leave": leave, + "total": len(records), + }, + "records": records, + }, nil +} + +// GetRanking 获取排行榜 +func (s *StudentService) GetRanking(classID int, limit int) (map[string]interface{}, error) { + ranking, err := s.studentRepo.GetRanking(classID, limit) + if err != nil { + return nil, err + } + totalStudents, _ := s.studentRepo.GetTotalCount(classID) + + return map[string]interface{}{ + "ranking": ranking, + "total_students": totalStudents, + }, nil +} + +// GetSemesterRecords 获取学生学期归档记录 +func (s *StudentService) GetSemesterRecords(studentID int) (map[string]interface{}, error) { + archives, err := s.semesterRepo.GetArchivesByStudent(studentID) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "records": archives, + }, nil +} diff --git a/backend-go/internal/service/subject_service.go b/backend-go/internal/service/subject_service.go new file mode 100644 index 0000000..a1ff115 --- /dev/null +++ b/backend-go/internal/service/subject_service.go @@ -0,0 +1,92 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "fmt" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// SubjectService 科目服务 +type SubjectService struct { + subjectRepo *repository.SubjectRepo +} + +// NewSubjectService 创建科目服务 +func NewSubjectService(subjectRepo *repository.SubjectRepo) *SubjectService { + return &SubjectService{subjectRepo: subjectRepo} +} + +// GetSubjects 获取科目列表 +func (s *SubjectService) GetSubjects(isActive *bool) (map[string]interface{}, error) { + subjects, err := s.subjectRepo.GetAll(isActive) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "subjects": subjects, + "total": len(subjects), + }, nil +} + +// CreateSubject 创建科目 +func (s *SubjectService) CreateSubject(subjectName string, subjectCode *string, sortOrder int) (map[string]interface{}, error) { + existing, _ := s.subjectRepo.GetByName(subjectName) + if existing != nil { + return map[string]interface{}{"success": false, "message": "科目名称已存在"}, nil + } + + subject := &model.Subject{ + SubjectName: subjectName, + SubjectCode: subjectCode, + SortOrder: sortOrder, + IsActive: 1, + } + + subjectID, err := s.subjectRepo.Create(subject) + if err != nil { + return nil, err + } + + logger.Sugared.Infof("创建科目: %s", subjectName) + return map[string]interface{}{ + "success": true, + "subject_id": subjectID, + }, nil +} + +// UpdateSubject 更新科目 +func (s *SubjectService) UpdateSubject(subjectID int, updates map[string]interface{}) error { + return s.subjectRepo.Update(subjectID, updates) +} + +// DisableSubject 禁用科目(将 is_active 设为 0,保留数据) +func (s *SubjectService) DisableSubject(subjectID int) error { + return s.subjectRepo.Update(subjectID, map[string]interface{}{"is_active": 0}) +} + +// EnableSubject 启用科目(将 is_active 设为 1) +func (s *SubjectService) EnableSubject(subjectID int) error { + return s.subjectRepo.Update(subjectID, map[string]interface{}{"is_active": 1}) +} + +// DeleteSubject 物理删除科目(需先检查关联数据) +func (s *SubjectService) DeleteSubject(subjectID int) error { + hasData, _ := s.subjectRepo.HasRelatedData(subjectID) + if hasData { + return fmt.Errorf("该科目下已有作业数据,无法删除") + } + return s.subjectRepo.Delete(subjectID) +} diff --git a/backend-go/internal/service/super_admin_service.go b/backend-go/internal/service/super_admin_service.go new file mode 100644 index 0000000..aed8c2b --- /dev/null +++ b/backend-go/internal/service/super_admin_service.go @@ -0,0 +1,158 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "context" + "fmt" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/crypto" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database" + appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// SuperAdminService 超级管理员服务 +type SuperAdminService struct { + superAdminRepo *repository.SuperAdminRepo + logService *LogService +} + +// NewSuperAdminService 创建超级管理员服务 +func NewSuperAdminService(superAdminRepo *repository.SuperAdminRepo, logService *LogService) *SuperAdminService { + return &SuperAdminService{superAdminRepo: superAdminRepo, logService: logService} +} + +// EnsureDefaultAdmin 确保默认超级管理员存在 +func (s *SuperAdminService) EnsureDefaultAdmin() error { + cfg := config.AppConfig + + logger.Sugared.Warnf("⚠️ 当前使用默认超级管理员密码,部署环境请务必修改 SUPER_ADMIN_DEFAULT_PASSWORD 并重启服务") + + // 为超级管理员生成独立的随机 Salt + salt, err := crypto.GenerateRandomPassword(16) + if err != nil { + return fmt.Errorf("生成随机盐值失败: %w", err) + } + passwordHash := crypto.HashPassword(cfg.SuperAdminDefaultPass, salt) + if err := s.superAdminRepo.EnsureDefaultAdmin( + cfg.SuperAdminDefaultUser, + passwordHash, + salt, + "系统管理员", + ); err != nil { + return fmt.Errorf("创建默认超级管理员失败: %w", err) + } + return nil +} + +// Login 超级管理员登录 +func (s *SuperAdminService) Login(username, password, ip, userAgent string) (map[string]interface{}, error) { + ctx := context.Background() + cfg := config.AppConfig + + // 检查登录失败次数(用户名级 + IP 级双重限流,使用原子 Incr 防止 TOCTOU 竞态) + attemptsKey := fmt.Sprintf("login_attempts:sa:%s", username) + ipAttemptsKey := fmt.Sprintf("login_attempts:ip:super_admin:%s", ip) + + count, _ := incrWithExpireAtomic(ctx, attemptsKey, 300) + if count > 5 { + return map[string]interface{}{"success": false, "message": "登录失败次数过多,请5分钟后重试"}, nil + } + // IP 级限流 + ipCount, _ := incrWithExpireAtomic(ctx, ipAttemptsKey, 300) + if ipCount > 20 { + return map[string]interface{}{"success": false, "message": "登录失败次数过多,请5分钟后重试"}, nil + } + + admin, err := s.superAdminRepo.GetByUsername(username) + if err != nil { + s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误") + return map[string]interface{}{"success": false, "message": "用户名或密码错误"}, nil + } + + if !crypto.VerifyPassword(password, admin.PasswordHash, admin.Salt) { + s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误") + return map[string]interface{}{"success": false, "message": "用户名或密码错误"}, nil + } + + // 清除用户名级登录失败记录,IP 级计数由 TTL 自然过期(与普通用户策略一致,防止同 IP 其他用户限流被重置) + database.RDB.Del(ctx, attemptsKey) + s.logService.WriteLoginLog(username, 1, ip, userAgent, "") + + // 生成 Token + token, err := appJwt.CreateToken( + admin.ID, admin.Username, "super_admin", + nil, "系统管理员", admin.RealName, nil, false, + ) + if err != nil { + return map[string]interface{}{"success": false, "message": "生成令牌失败"}, nil + } + + _ = database.SetUserToken(ctx, admin.ID, token, cfg.JWTIdleTimeoutMinutes) + + needChangePassword := admin.NeedChangePassword == 1 + redirect := "/admin/dashboard.php" + if needChangePassword { + redirect = "/admin/password.php" + } + + return map[string]interface{}{ + "success": true, + "token": token, + "user_id": admin.ID, + "username": admin.Username, + "real_name": admin.RealName, + "user_type": "super_admin", + "need_change_password": needChangePassword, + "redirect": redirect, + }, nil +} + +// ChangePassword 超级管理员修改密码(操作 super_admins 表,使用独立 salt) +func (s *SuperAdminService) ChangePassword(adminID int, oldPassword, newPassword string, force bool) error { + admin, err := s.superAdminRepo.GetByID(adminID) + if err != nil { + return fmt.Errorf("超级管理员不存在") + } + + // 验证原密码(强制改密时跳过) + if !force { + if !crypto.VerifyPassword(oldPassword, admin.PasswordHash, admin.Salt) { + return fmt.Errorf("原密码错误") + } + } + + // 验证新密码强度 + if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid { + return fmt.Errorf("%s", msg) + } + + // 生成新的独立 salt + newSalt, err := crypto.GenerateRandomPassword(16) + if err != nil { + return fmt.Errorf("生成随机盐值失败: %w", err) + } + newHash := crypto.HashPassword(newPassword, newSalt) + + if err := s.superAdminRepo.UpdatePasswordWithSalt(adminID, newHash, newSalt); err != nil { + return fmt.Errorf("密码修改失败") + } + + // 清除旧 Token,强制重新登录 + ctx := context.Background() + _ = database.DeleteUserToken(ctx, adminID) + + return nil +} diff --git a/backend-go/internal/service/utils.go b/backend-go/internal/service/utils.go new file mode 100644 index 0000000..a4a3352 --- /dev/null +++ b/backend-go/internal/service/utils.go @@ -0,0 +1,25 @@ +package service + +// derefInt 安全解引用 int 指针 +func derefInt(i *int) int { + if i == nil { + return 0 + } + return *i +} + +// derefStr 安全解引用字符串指针 +func derefStr(s *string) string { + if s == nil { + return "" + } + return *s +} + +// intPtr 辅助函数:int 转指针(0 返回 nil) +func intPtr(i int) *int { + if i == 0 { + return nil + } + return &i +} diff --git a/backend-go/pkg/crypto/password.go b/backend-go/pkg/crypto/password.go new file mode 100644 index 0000000..ce05356 --- /dev/null +++ b/backend-go/pkg/crypto/password.go @@ -0,0 +1,110 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package crypto + +import ( + "crypto/md5" + "crypto/rand" + "crypto/sha1" + "crypto/subtle" + "encoding/hex" + "fmt" + "math/big" +) + +// HashPassword 密码哈希(与 Python 版完全兼容) +// 算法: MD5(SHA1(password) + salt) +// Python 参考: backend/utils/security.py -> sha1_md5_password() +// 已知弱算法:MD5 和 SHA1 均不适合密码哈希场景,保留此实现仅为兼容 Python 版数据。 +// 后续迁移计划:迁移到 bcrypt/scrypt/argon2,并提供兼容层逐步过渡。 +func HashPassword(password string, salt string) string { + // 第一层: SHA1(password) + sha1Hash := sha1.Sum([]byte(password)) + sha1Hex := hex.EncodeToString(sha1Hash[:]) + + // 加盐: SHA1_hex + salt + salted := sha1Hex + salt + + // 第二层: MD5(salted) + md5Hash := md5.Sum([]byte(salted)) + return hex.EncodeToString(md5Hash[:]) +} + +// VerifyPassword 验证密码(使用常量时间比较,防止时序攻击) +func VerifyPassword(plainPassword, hashedPassword, salt string) bool { + computed := HashPassword(plainPassword, salt) + return subtle.ConstantTimeCompare([]byte(computed), []byte(hashedPassword)) == 1 +} + +// GenerateRandomPassword 生成随机密码 +// 与 Python 版 SecurityUtils.generate_random_password() 兼容 +func GenerateRandomPassword(length int) (string, error) { + alphabet := "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + result := make([]byte, length) + for i := range result { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) + if err != nil { + return "", fmt.Errorf("生成随机密码失败: %w", err) + } + result[i] = alphabet[n.Int64()] + } + return string(result), nil +} + +// ValidatePasswordStrength 验证密码强度 +// 要求: 大小写字母、数字、特殊符号至少包含 3 种,长度 6-20 +func ValidatePasswordStrength(password string) (bool, string) { + if len(password) < 6 { + return false, "密码长度至少6位" + } + if len(password) > 20 { + return false, "密码长度不能超过20位" + } + + hasUpper := false + hasLower := false + hasDigit := false + hasSpecial := false + + for _, c := range password { + switch { + case c >= 'A' && c <= 'Z': + hasUpper = true + case c >= 'a' && c <= 'z': + hasLower = true + case c >= '0' && c <= '9': + hasDigit = true + default: + hasSpecial = true + } + } + + charTypes := 0 + if hasUpper { + charTypes++ + } + if hasLower { + charTypes++ + } + if hasDigit { + charTypes++ + } + if hasSpecial { + charTypes++ + } + + if charTypes < 3 { + return false, "密码必须包含大写字母、小写字母、数字、特殊符号中的至少3种" + } + + return true, "" +} diff --git a/backend-go/pkg/database/mysql.go b/backend-go/pkg/database/mysql.go new file mode 100644 index 0000000..3bceff9 --- /dev/null +++ b/backend-go/pkg/database/mysql.go @@ -0,0 +1,71 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package database + +import ( + "fmt" + "strings" + "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" +) + +// DB 全局数据库实例 +var DB *gorm.DB + +// InitMySQL 初始化 MySQL 连接池 +func InitMySQL(cfg *config.Config) (*gorm.DB, error) { + dsn := cfg.DSN() + + // 根据 LogLevel 配置设置 GORM 日志级别 + gormLogLevel := logger.Info + switch strings.ToLower(cfg.LogLevel) { + case "silent": + gormLogLevel = logger.Silent + case "error": + gormLogLevel = logger.Error + case "warn", "warning": + gormLogLevel = logger.Warn + default: + gormLogLevel = logger.Info + } + gormCfg := &gorm.Config{ + Logger: logger.Default.LogMode(gormLogLevel), + } + + db, err := gorm.Open(mysql.Open(dsn), gormCfg) + if err != nil { + return nil, fmt.Errorf("连接数据库失败: %w", err) + } + + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("获取底层 sql.DB 失败: %w", err) + } + + // 连接池配置 + sqlDB.SetMaxOpenConns(cfg.DBMaxOpenConns) + sqlDB.SetMaxIdleConns(cfg.DBMaxIdleConns) + sqlDB.SetConnMaxLifetime(time.Duration(cfg.DBConnMaxLife) * time.Second) + + // 测试连接 + if err := sqlDB.Ping(); err != nil { + return nil, fmt.Errorf("数据库 Ping 失败: %w", err) + } + + DB = db + return db, nil +} diff --git a/backend-go/pkg/database/redis.go b/backend-go/pkg/database/redis.go new file mode 100644 index 0000000..94160f3 --- /dev/null +++ b/backend-go/pkg/database/redis.go @@ -0,0 +1,80 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package database + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" +) + +// RDB 全局 Redis 客户端实例 +var RDB *redis.Client + +// InitRedis 初始化 Redis 连接 +func InitRedis(cfg *config.Config) (*redis.Client, error) { + rdb := redis.NewClient(&redis.Options{ + Addr: cfg.RedisAddr(), + Password: cfg.RedisPassword, + DB: cfg.RedisDB, + PoolSize: cfg.RedisMaxConns, + MinIdleConns: 5, + DialTimeout: 5 * time.Second, + ReadTimeout: 3 * time.Second, + WriteTimeout: 3 * time.Second, + }) + + // 测试连接 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := rdb.Ping(ctx).Err(); err != nil { + return nil, fmt.Errorf("连接 Redis 失败: %w", err) + } + + RDB = rdb + return rdb, nil +} + +// --- Token 存储操作(兼容 Python 版 Redis Token 管理) --- + +const ( + tokenKeyPrefix = "user_token:" +) + +// SetUserToken 存储用户 Token +func SetUserToken(ctx context.Context, userID int, token string, expireMinutes int) error { + key := fmt.Sprintf("%s%d", tokenKeyPrefix, userID) + return RDB.Set(ctx, key, token, time.Duration(expireMinutes)*time.Minute).Err() +} + +// GetUserToken 获取用户 Token +func GetUserToken(ctx context.Context, userID int) (string, error) { + key := fmt.Sprintf("%s%d", tokenKeyPrefix, userID) + return RDB.Get(ctx, key).Result() +} + +// DeleteUserToken 删除用户 Token +func DeleteUserToken(ctx context.Context, userID int) error { + key := fmt.Sprintf("%s%d", tokenKeyPrefix, userID) + return RDB.Del(ctx, key).Err() +} + +// ExpireToken 刷新 Token 过期时间(参数单位:分钟) +func ExpireToken(ctx context.Context, userID int, expireMinutes int) error { + key := fmt.Sprintf("%s%d", tokenKeyPrefix, userID) + return RDB.Expire(ctx, key, time.Duration(expireMinutes)*time.Minute).Err() +} diff --git a/backend-go/pkg/jwt/jwt.go b/backend-go/pkg/jwt/jwt.go new file mode 100644 index 0000000..2058c77 --- /dev/null +++ b/backend-go/pkg/jwt/jwt.go @@ -0,0 +1,93 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package jwt + +import ( + "fmt" + "time" + + goJwt "github.com/golang-jwt/jwt/v5" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" +) + +// getSigningMethod 根据配置返回对应的签名算法 +func getSigningMethod(algorithm string) goJwt.SigningMethod { + switch algorithm { + case "HS384": + return goJwt.SigningMethodHS384 + case "HS512": + return goJwt.SigningMethodHS512 + default: + return goJwt.SigningMethodHS256 + } +} + +// Claims JWT 载荷结构(与 Python 版完全兼容) +type Claims struct { + UserID int `json:"user_id"` + Username string `json:"username"` + UserType string `json:"user_type"` + StudentID *int `json:"student_id"` + Role string `json:"role"` + RealName string `json:"real_name"` + ClassID *int `json:"class_id"` + NeedChangePassword bool `json:"need_change_password"` + goJwt.RegisteredClaims +} + +// CreateToken 创建 JWT Token +func CreateToken(userID int, username, userType string, studentID *int, role, realName string, classID *int, needChangePassword bool) (string, error) { + now := time.Now() + cfg := config.AppConfig + + claims := Claims{ + UserID: userID, + Username: username, + UserType: userType, + StudentID: studentID, + Role: role, + RealName: realName, + ClassID: classID, + NeedChangePassword: needChangePassword, + RegisteredClaims: goJwt.RegisteredClaims{ + ExpiresAt: goJwt.NewNumericDate(now.Add(time.Duration(cfg.JWTExpireMinutes) * time.Minute)), + IssuedAt: goJwt.NewNumericDate(now), + Issuer: cfg.AppName, + }, + } + + token := goJwt.NewWithClaims(getSigningMethod(cfg.JWTAlgorithm), claims) + return token.SignedString([]byte(cfg.JWTSecretKey)) +} + +// VerifyToken 验证 JWT Token,返回解析后的载荷 +func VerifyToken(tokenStr string) (*Claims, error) { + cfg := config.AppConfig + + token, err := goJwt.ParseWithClaims(tokenStr, &Claims{}, func(t *goJwt.Token) (interface{}, error) { + if _, ok := t.Method.(*goJwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("不支持的签名算法: %v", t.Header["alg"]) + } + return []byte(cfg.JWTSecretKey), nil + }) + if err != nil { + return nil, fmt.Errorf("token 验证失败: %w", err) + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, fmt.Errorf("token 无效") + } + + return claims, nil +} diff --git a/backend-go/pkg/logger/logger.go b/backend-go/pkg/logger/logger.go new file mode 100644 index 0000000..2693cee --- /dev/null +++ b/backend-go/pkg/logger/logger.go @@ -0,0 +1,64 @@ +package logger + +import ( + "os" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// Log 全局日志实例 +var Log *zap.Logger + +// Sugared 全局 SugaredLogger(便捷方法) +var Sugared *zap.SugaredLogger + +// Init 初始化日志 +func Init(level string, isProduction bool) { + var zapLevel zapcore.Level + switch level { + case "debug": + zapLevel = zapcore.DebugLevel + case "info": + zapLevel = zapcore.InfoLevel + case "warn": + zapLevel = zapcore.WarnLevel + case "error": + zapLevel = zapcore.ErrorLevel + default: + zapLevel = zapcore.InfoLevel + } + + encoderCfg := zap.NewProductionEncoderConfig() + encoderCfg.TimeKey = "time" + encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder + encoderCfg.EncodeLevel = zapcore.CapitalLevelEncoder + + var core zapcore.Core + if isProduction { + // 生产环境:JSON 格式输出到 stdout + core = zapcore.NewCore( + zapcore.NewJSONEncoder(encoderCfg), + zapcore.Lock(os.Stdout), + zapLevel, + ) + } else { + // 开发环境:Console 格式输出 + encoderCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder + core = zapcore.NewCore( + zapcore.NewConsoleEncoder(encoderCfg), + zapcore.Lock(os.Stdout), + zapLevel, + ) + } + + Log = zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)) + Sugared = Log.Sugar() +} + +// Sync 刷新日志缓冲区 +func Sync() { + if Log != nil { + _ = Log.Sync() + } +} diff --git a/backend-go/pkg/response/response.go b/backend-go/pkg/response/response.go new file mode 100644 index 0000000..eab0828 --- /dev/null +++ b/backend-go/pkg/response/response.go @@ -0,0 +1,106 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package response + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Response 统一响应结构体 +type Response struct { + Success bool `json:"success"` + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data"` +} + +// PageData 分页响应数据 +type PageData struct { + Items interface{} `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` +} + +// JSON 统一 JSON 响应 +func JSON(c *gin.Context, httpCode int, success bool, code int, message string, data interface{}) { + c.JSON(httpCode, Response{ + Success: success, + Code: code, + Message: message, + Data: data, + }) +} + +// Success 成功响应 (200) +func Success(c *gin.Context, data interface{}, message string) { + JSON(c, http.StatusOK, true, 200, message, data) +} + +// SuccessWithMessage 成功响应(仅消息) +func SuccessWithMessage(c *gin.Context, message string) { + JSON(c, http.StatusOK, true, 200, message, nil) +} + +// Created 创建成功响应 (201) +func Created(c *gin.Context, data interface{}, message string) { + JSON(c, http.StatusCreated, true, 201, message, data) +} + +// BadRequest 参数错误 (400) +func BadRequest(c *gin.Context, message string) { + JSON(c, http.StatusBadRequest, false, 400, message, nil) +} + +// Unauthorized 未授权 (401) +func Unauthorized(c *gin.Context, message string) { + JSON(c, http.StatusUnauthorized, false, 401, message, nil) +} + +// Forbidden 禁止访问 (403) +func Forbidden(c *gin.Context, message string) { + JSON(c, http.StatusForbidden, false, 403, message, nil) +} + +// NotFound 资源不存在 (404) +func NotFound(c *gin.Context, message string) { + JSON(c, http.StatusNotFound, false, 404, message, nil) +} + +// Conflict 冲突 (409) +func Conflict(c *gin.Context, message string) { + JSON(c, http.StatusConflict, false, 409, message, nil) +} + +// InternalError 服务器内部错误 (500) +func InternalError(c *gin.Context, message string) { + JSON(c, http.StatusInternalServerError, false, 500, message, nil) +} + +// Paginated 分页成功响应 +func Paginated(c *gin.Context, items interface{}, total int64, page, pageSize int) { + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + + Success(c, PageData{ + Items: items, + Total: total, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + }, "操作成功") +} diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index 28b49af..0000000 --- a/backend/.env.example +++ /dev/null @@ -1,150 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 前端配置 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -# =========================================== -# FastAPI 应用配置 -# =========================================== - -# 应用名称 -APP_NAME=班级操行分管理系统 -# 运行环境 - production / development / testing -APP_ENV=production -# 调试模式 - true开启详细错误信息,生产环境必须为false -DEBUG=False -# 应用密钥 - 必须32位以上随机字符串 -SECRET_KEY=your-super-secret-key-min-32-characters-long -# API版本号 -API_VERSION=v1 - - -# =========================================== -# MySQL 数据库配置 -# =========================================== - -DB_HOST=127.0.0.1 -DB_PORT=3306 -DB_USER=class_admin -DB_PASSWORD=your-strong-db-password -DB_NAME=classmanagerdb -DB_POOL_SIZE=10 -DB_MAX_OVERFLOW=20 - -# =========================================== -# Redis 缓存配置 -# =========================================== - -REDIS_HOST=127.0.0.1 -REDIS_PORT=6379 -REDIS_PASSWORD=your-redis-password -REDIS_DB=0 -REDIS_MAX_CONNECTIONS=50 - -# =========================================== -# JWT 认证配置 -# =========================================== - -JWT_SECRET_KEY=your-jwt-secret-key-min-32-chars -JWT_ALGORITHM=HS256 -JWT_EXPIRE_MINUTES=30 -# JWT空闲超时时间(分钟)- 无操作超过此时间需重新登录 -JWT_IDLE_TIMEOUT_MINUTES=10 - -# =========================================== -# 密码加密配置 -# =========================================== - -PASSWORD_SALT=your-fixed-salt-string-for-password-hash - -# =========================================== -# 调试入口配置 -# =========================================== - -# 调试功能开关 - 设为 true 启用调试路由,生产环境必须为 false -DEBUG_ENABLED=false -# 调试入口路径 - 自定义随机路径增强安全性 -DEBUG_PATH=/a7k9x2m4q8w1e3r5t6y7u8i9o0p1z2x3 - -# =========================================== -# 扣分规则配置 -# =========================================== - -# 作业未提交扣分 - 学生未按时提交作业时扣除的操行分 -DEDUCTION_HOMEWORK_NOT_SUBMIT=2 - -# 作业迟交扣分 - 学生迟交作业时扣除的操行分 -DEDUCTION_HOMEWORK_LATE=1 - -# 缺勤扣分 - 学生无故缺勤时扣除的操行分 -DEDUCTION_ATTENDANCE_ABSENT=3 - -# 迟到扣分 - 学生迟到时扣除的操行分 -DEDUCTION_ATTENDANCE_LATE=1 - -# 请假扣分 - 学生请假时扣除的操行分(设为0表示不扣分) -DEDUCTION_ATTENDANCE_LEAVE=0 - -# =========================================== -# 劳动委员固定分值配置 -# =========================================== - -LABOR_POINTS_ADD=1 -LABOR_POINTS_SUBTRACT=-1 - -# =========================================== -# 各角色加减分限制配置 -# =========================================== - -# 班长单次加分上限 -MONITOR_MAX_ADD=5 -# 班长单次扣分上限(负数) -MONITOR_MAX_SUBTRACT=-5 - -# 学习委员单次加减分上限(绝对值) -STUDY_COMMISSIONER_MAX_POINTS=5 - -# 考勤委员单次扣分上限(绝对值) -ATTENDANCE_REP_MAX_POINTS=8 - -# 劳动委员单次加减分上限(绝对值) -LABOR_REP_MAX_POINTS=1 - -# 志愿委员单次加分上限 -VOLUNTEER_REP_MAX_POINTS=5 - -# =========================================== -# 日志配置 -# =========================================== - -LOG_LEVEL=INFO -LOG_MAX_BYTES=104857600 -LOG_BACKUP_COUNT=30 -LOG_RETENTION_DAYS=365 - -# =========================================== -# CORS 跨域配置 -# =========================================== - -# 允许的跨域域名 - 多个域名用英文逗号分隔 -# 示例: https://example.com,https://api.example.com -CORS_ORIGINS=https://your-frontend-domain.com,https://your-api-domain.com - -# =========================================== -# 上传文件配置 -# =========================================== - -MAX_UPLOAD_SIZE=5242880 -ALLOWED_EXTENSIONS=json - -# =========================================== -# 学生初始配置 -# =========================================== - -STUDENT_INITIAL_POINTS=60 \ No newline at end of file diff --git a/backend/config.py b/backend/config.py deleted file mode 100644 index 5c834bb..0000000 --- a/backend/config.py +++ /dev/null @@ -1,91 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 配置管理 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -import os -from dotenv import load_dotenv -from typing import List - -load_dotenv() - -class Settings: - APP_NAME: str = os.getenv("APP_NAME", "班级操行分管理系统") - APP_ENV: str = os.getenv("APP_ENV", "production") - DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true" - SECRET_KEY: str = os.getenv("SECRET_KEY", "") - API_VERSION: str = os.getenv("API_VERSION", "v1") - - DB_HOST: str = os.getenv("DB_HOST", "127.0.0.1") - DB_PORT: int = int(os.getenv("DB_PORT", "3306")) - DB_USER: str = os.getenv("DB_USER", "root") - DB_PASSWORD: str = os.getenv("DB_PASSWORD", "") - DB_NAME: str = os.getenv("DB_NAME", "classmanagerdb") - DB_POOL_SIZE: int = int(os.getenv("DB_POOL_SIZE", "10")) - DB_MAX_OVERFLOW: int = int(os.getenv("DB_MAX_OVERFLOW", "20")) - - REDIS_HOST: str = os.getenv("REDIS_HOST", "127.0.0.1") - REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379")) - REDIS_PASSWORD: str = os.getenv("REDIS_PASSWORD", "") - REDIS_DB: int = int(os.getenv("REDIS_DB", "0")) - REDIS_MAX_CONNECTIONS: int = int(os.getenv("REDIS_MAX_CONNECTIONS", "50")) - - @property - def REDIS_URL(self) -> str: - if self.REDIS_PASSWORD: - return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" - return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" - - JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "") - JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256") - JWT_EXPIRE_MINUTES: int = int(os.getenv("JWT_EXPIRE_MINUTES", "60")) - JWT_IDLE_TIMEOUT_MINUTES: int = int(os.getenv("JWT_IDLE_TIMEOUT_MINUTES", "10")) - - PASSWORD_SALT: str = os.getenv("PASSWORD_SALT", "") - DEBUG_ENABLED: bool = os.getenv("DEBUG_ENABLED", "False").lower() == "true" - DEBUG_PATH: str = os.getenv("DEBUG_PATH", "/debug_add_admin") - - DEDUCTION_HOMEWORK_NOT_SUBMIT: int = int(os.getenv("DEDUCTION_HOMEWORK_NOT_SUBMIT", "2")) - DEDUCTION_HOMEWORK_LATE: int = int(os.getenv("DEDUCTION_HOMEWORK_LATE", "1")) - DEDUCTION_ATTENDANCE_ABSENT: int = int(os.getenv("DEDUCTION_ATTENDANCE_ABSENT", "3")) - DEDUCTION_ATTENDANCE_LATE: int = int(os.getenv("DEDUCTION_ATTENDANCE_LATE", "1")) - DEDUCTION_ATTENDANCE_LEAVE: int = int(os.getenv("DEDUCTION_ATTENDANCE_LEAVE", "0")) - - LABOR_POINTS_ADD: int = int(os.getenv("LABOR_POINTS_ADD", "1")) - LABOR_POINTS_SUBTRACT: int = int(os.getenv("LABOR_POINTS_SUBTRACT", "-1")) - - MONITOR_MAX_ADD: int = int(os.getenv("MONITOR_MAX_ADD", "5")) - MONITOR_MAX_SUBTRACT: int = int(os.getenv("MONITOR_MAX_SUBTRACT", "-5")) - - STUDY_COMMISSIONER_MAX_POINTS: int = int(os.getenv("STUDY_COMMISSIONER_MAX_POINTS", "5")) - ATTENDANCE_REP_MAX_POINTS: int = int(os.getenv("ATTENDANCE_REP_MAX_POINTS", "8")) - LABOR_REP_MAX_POINTS: int = int(os.getenv("LABOR_REP_MAX_POINTS", "1")) - VOLUNTEER_REP_MAX_POINTS: int = int(os.getenv("VOLUNTEER_REP_MAX_POINTS", "5")) - - LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") - LOG_MAX_BYTES: int = int(os.getenv("LOG_MAX_BYTES", "104857600")) - LOG_BACKUP_COUNT: int = int(os.getenv("LOG_BACKUP_COUNT", "30")) - LOG_RETENTION_DAYS: int = int(os.getenv("LOG_RETENTION_DAYS", "365")) - - @property - def CORS_ORIGINS(self) -> List[str]: - origins = os.getenv("CORS_ORIGINS", "") - return [origin.strip() for origin in origins.split(",") if origin.strip()] - - MAX_UPLOAD_SIZE: int = int(os.getenv("MAX_UPLOAD_SIZE", "5242880")) - ALLOWED_EXTENSIONS: set = set(os.getenv("ALLOWED_EXTENSIONS", "json").split(",")) - STUDENT_INITIAL_POINTS: int = int(os.getenv("STUDENT_INITIAL_POINTS", "60")) - - def validate(self) -> None: - required = ["SECRET_KEY", "JWT_SECRET_KEY", "PASSWORD_SALT"] - for name in required: - if not getattr(self, name): - raise ValueError(f"配置 {name} 不能为空") - -settings = Settings() \ No newline at end of file diff --git a/backend/main.py b/backend/main.py deleted file mode 100644 index 2b7f9bc..0000000 --- a/backend/main.py +++ /dev/null @@ -1,142 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 主入口 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse -from contextlib import asynccontextmanager -import traceback -import uvicorn - -from config import settings -from utils.logger import setup_logger, log_access -from utils.database import init_db_pool, close_db_pool -from utils.redis_client import init_redis_pool, close_redis_pool -from middleware.auth_middleware import AuthMiddleware -from routes import auth, student, parent, admin, subject, semester, debug, upgrade -from routes.config import router as config_router - - -# 设置日志 -logger = setup_logger() - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """应用生命周期管理""" - logger.info("正在启动应用...") - await init_db_pool() - await init_redis_pool() - logger.info(f"CORS 允许域名: {settings.CORS_ORIGINS}") - logger.info(f"{settings.APP_NAME} 启动完成") - - yield - - logger.info("正在关闭应用...") - await close_db_pool() - await close_redis_pool() - logger.info("应用已关闭") - - -# 创建FastAPI应用 -app = FastAPI( - title=settings.APP_NAME, - version=settings.API_VERSION, - debug=settings.DEBUG, - lifespan=lifespan -) - - -# 访问日志中间件 -@app.middleware("http") -async def access_log_middleware(request: Request, call_next): - log_access(request) - response = await call_next(request) - return response - - -# 认证中间件(先注册,后执行) -app.add_middleware(AuthMiddleware) - -# CORS中间件(后注册,先执行)- 从环境变量读取允许的域名 -cors_origins = settings.CORS_ORIGINS -if not cors_origins: - logger.warning("CORS_ORIGINS 未配置或为空,跨域请求将被拒绝!请检查 .env 文件中的 CORS_ORIGINS 配置") - -app.add_middleware( - CORSMiddleware, - allow_origins=cors_origins, - allow_credentials=True, - allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], - allow_headers=["*"], - expose_headers=["*"], -) - - -# 全局异常处理器 -@app.exception_handler(Exception) -async def global_exception_handler(request: Request, exc: Exception): - """全局异常处理器 - 捕获所有未处理异常""" - logger.error(f"未处理异常: {exc}", exc_info=True) - - # 获取origin用于CORS头 - origin = request.headers.get("origin", "") - allowed_origins = settings.CORS_ORIGINS or [] - - # 使用HTTP 200 + 业务错误码返回,避免CORS头丢失问题 - # (FastAPI exception_handler返回的500响应可能不经过CORS中间件,导致跨域读取失败) - headers = {} - if origin in allowed_origins: - headers["access-control-allow-origin"] = origin - headers["access-control-allow-credentials"] = "true" - headers["access-control-expose-headers"] = "*" - - return JSONResponse( - status_code=200, - content={ - "success": False, - "code": 500, - "message": f"服务器内部错误: {str(exc)}", - "detail": traceback.format_exc() if settings.DEBUG else None - }, - headers=headers - ) - - -# 注册路由 -app.include_router(auth.router, prefix="/api/auth", tags=["认证"]) -app.include_router(student.router, prefix="/api/student", tags=["学生端"]) -app.include_router(parent.router, prefix="/api/parent", tags=["家长端"]) -app.include_router(admin.router, prefix="/api/admin", tags=["管理端"]) -app.include_router(subject.router, prefix="/api/subject", tags=["科目管理"]) -app.include_router(semester.router, prefix="/api/semester", tags=["学期管理"]) -app.include_router(config_router, prefix="/api/config", tags=["配置"]) -app.include_router(upgrade.router, prefix="/api/upgrade", tags=["升级管理"]) -app.include_router(debug.router, tags=["调试"]) - - -@app.get("/") -async def root(): - return {"status": "ok", "message": f"{settings.APP_NAME} API 运行中"} - - -@app.get("/health") -async def health_check(): - return {"status": "healthy"} - - -if __name__ == "__main__": - uvicorn.run( - "main:app", - host="0.0.0.0", - port=8000, - reload=settings.DEBUG - ) \ No newline at end of file diff --git a/backend/middleware/__init__.py b/backend/middleware/__init__.py deleted file mode 100644 index 638547a..0000000 --- a/backend/middleware/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - diff --git a/backend/middleware/auth_middleware.py b/backend/middleware/auth_middleware.py deleted file mode 100644 index 5e3cce7..0000000 --- a/backend/middleware/auth_middleware.py +++ /dev/null @@ -1,144 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.requests import Request -from starlette.responses import Response -from fastapi.responses import JSONResponse -from typing import Optional, Dict, Any -import re - -from config import settings -from utils.jwt_handler import jwt_handler -from utils.redis_client import RedisClient -from utils.response import unauthorized_response -from utils.logger import get_logger - -logger = get_logger(__name__) - -# 不需要认证的路由 -PUBLIC_PATHS = [ - r'^/$', - r'^/health$', - r'^/api/auth/login$', - r'^/api/auth/logout$', - r'^/api/config/deduction-rules$', -] -def is_public_path(path: str) -> bool: - """检查是否为公开路径""" - for pattern in PUBLIC_PATHS: - if re.match(pattern, path): - return True - # 动态匹配调试入口路径(需同时启用调试功能) - if settings.DEBUG_ENABLED and settings.DEBUG_PATH and path == settings.DEBUG_PATH: - return True - return False - - -class AuthMiddleware(BaseHTTPMiddleware): - """JWT认证中间件""" - - async def dispatch(self, request: Request, call_next): - path = request.url.path - - # OPTIONS 预检请求跳过认证 - if request.method == "OPTIONS": - logger.debug(f"[Auth] OPTIONS {path} - 跳过认证") - return await call_next(request) - - # 公开路径跳过认证 - if is_public_path(path): - logger.debug(f"[Auth] {request.method} {path} - 公开路径,跳过认证") - return await call_next(request) - - logger.info(f"[Auth] {request.method} {path} - 开始认证") - - try: - # 获取Authorization头 - auth_header = request.headers.get("Authorization") - - if not auth_header: - logger.warning(f"[Auth] {path} - 缺少Authorization header") - return self._cors_response(request, 401, "缺少认证令牌") - - # 解析Bearer Token - try: - scheme, token = auth_header.split() - if scheme.lower() != "bearer": - logger.warning(f"[Auth] {path} - Authorization header格式错误") - return self._cors_response(request, 401, "认证格式错误") - except ValueError: - logger.warning(f"[Auth] {path} - Authorization header格式错误") - return self._cors_response(request, 401, "认证格式错误") - - # 验证Token - payload = jwt_handler.verify_token(token) - if not payload: - logger.warning(f"[Auth] {path} - JWT验证失败") - return self._cors_response(request, 401, "令牌无效或已过期") - - # 验证Redis中的Token - user_id = payload.get("user_id") - stored_token = await RedisClient.get_user_token(user_id) - - if not stored_token or stored_token != token: - logger.warning(f"[Auth] {path} - Redis Token不匹配, user_id={user_id}, stored={'有' if stored_token else '无'}") - return self._cors_response(request, 401, "令牌已失效,请重新登录") - - # 将用户信息存储到request.state - request.state.user_id = payload.get("user_id") - request.state.username = payload.get("username") - request.state.real_name = payload.get("real_name") or payload.get("username") - request.state.user_type = payload.get("user_type") - request.state.student_id = payload.get("student_id") - request.state.role = payload.get("role") - # 刷新Token过期时间(空闲超时:10分钟无操作则需重新登录) - await RedisClient.expire(f"user_token:{user_id}", settings.JWT_IDLE_TIMEOUT_MINUTES * 60) - - logger.debug(f"[Auth] {path} - 认证成功, user_id={user_id}, username={payload.get('username')}") - - except Exception as e: - logger.error(f"认证中间件异常: {e}", exc_info=True) - return self._cors_response(request, 401, "认证服务异常,请稍后重试") - - try: - response = await call_next(request) - # 为所有响应确保CORS头存在(防止路由层异常导致CORS头丢失) - origin = request.headers.get("origin", "") - allowed_origins = settings.CORS_ORIGINS or [] - if origin in allowed_origins and not response.headers.get("access-control-allow-origin"): - response.headers["access-control-allow-origin"] = origin - response.headers["access-control-allow-credentials"] = "true" - return response - except Exception as e: - logger.error(f"[Auth] call_next异常: {e}", exc_info=True) - return self._cors_response(request, 500, "服务器内部错误") - - def _cors_response(self, request: Request, status_code: int, message: str) -> JSONResponse: - """创建带CORS头的响应""" - origin = request.headers.get("origin", "") - allowed_origins = settings.CORS_ORIGINS or [] - - headers = {} - if origin in allowed_origins: - headers["Access-Control-Allow-Origin"] = origin - headers["Access-Control-Allow-Credentials"] = "true" - - return JSONResponse( - status_code=status_code, - content={ - "success": False, - "code": status_code, - "message": message, - "data": None - }, - headers=headers - ) diff --git a/backend/middleware/permission.py b/backend/middleware/permission.py deleted file mode 100644 index 733a671..0000000 --- a/backend/middleware/permission.py +++ /dev/null @@ -1,214 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 权限验证中间件 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from fastapi import Request -from typing import List, Optional, Callable, Dict, Any -from functools import wraps - -from utils.response import forbidden_response -from utils.database import execute_one -from utils.logger import get_logger -from models.admin_role import AdminRoleModel - -logger = get_logger(__name__) - - -async def get_current_user(request: Request) -> Dict[str, Any]: - """获取当前登录用户信息""" - return { - "user_id": getattr(request.state, 'user_id', None), - "username": getattr(request.state, 'username', None), - "real_name": getattr(request.state, 'real_name', None), - "user_type": getattr(request.state, 'user_type', None), - "student_id": getattr(request.state, 'student_id', None), - "role": getattr(request.state, 'role', None) - } - - -async def get_current_user_id(request: Request) -> int: - """获取当前用户ID""" - return getattr(request.state, 'user_id', None) - - -class PermissionChecker: - """权限检查器""" - - @staticmethod - async def get_user_role(user_id: int) -> Optional[str]: - """获取用户的管理员角色""" - sql = "SELECT role_type FROM admin_roles WHERE user_id = %s LIMIT 1" - result = await execute_one(sql, (user_id,)) - return result["role_type"] if result else None - - @staticmethod - async def check_is_teacher(user_id: int) -> bool: - """检查是否为班主任""" - role = await PermissionChecker.get_user_role(user_id) - return role == "班主任" - - @staticmethod - async def check_is_monitor(user_id: int) -> bool: - """检查是否为班长""" - role = await PermissionChecker.get_user_role(user_id) - return role == "班长" - - @staticmethod - async def check_is_study_commissioner(user_id: int) -> bool: - """检查是否为学习委员""" - role = await PermissionChecker.get_user_role(user_id) - return role == "学习委员" - - @staticmethod - async def check_is_attendance_rep(user_id: int) -> bool: - """检查是否为考勤委员""" - role = await PermissionChecker.get_user_role(user_id) - return role == "考勤委员" - - @staticmethod - async def check_is_labor_rep(user_id: int) -> bool: - """检查是否为劳动委员""" - role = await PermissionChecker.get_user_role(user_id) - return role == "劳动委员" - - @staticmethod - async def check_is_volunteer_rep(user_id: int) -> bool: - """检查是否为志愿委员""" - role = await PermissionChecker.get_user_role(user_id) - return role == "志愿委员" - - @staticmethod - async def check_can_manage_subjects(user_id: int) -> bool: - """检查是否可以管理科目(班主任或学习委员)""" - role = await PermissionChecker.get_user_role(user_id) - return role in ["班主任", "学习委员"] - - @staticmethod - async def get_user_class_id(user_id: int) -> Optional[int]: - """ - 获取用户关联的班级ID - 单班级系统,固定返回1 - """ - # 本系统为单班级设计,class_id 固定为 1 - return 1 - - @staticmethod - async def get_user_subject_ids(user_id: int) -> List[int]: - """获取用户管理的科目ID列表""" - admin_role = await AdminRoleModel.get_by_user_id(user_id) - if not admin_role: - return [] - # 班主任可以管理所有科目 - if admin_role["role_type"] == "班主任": - from models.subject import SubjectModel - subjects = await SubjectModel.get_all(is_active=True) - return [s["subject_id"] for s in subjects] - # 其他角色返回关联的科目 - if admin_role.get("subject_id"): - return [admin_role["subject_id"]] - return [] - - @staticmethod - async def check_can_manage_student(user_id: int, student_id: int) -> bool: - """检查是否可以管理指定学生(管理员默认可管理所有学生)""" - role = await PermissionChecker.get_user_role(user_id) - return role is not None - - @staticmethod - async def check_can_revoke(user_id: int, record_id: int) -> bool: - """ - 检查是否可以撤销扣分记录 - 班主任:可以撤销/反撤销任何记录 - 班长:可以撤销/反撤销任何记录 - 考勤委员:可以撤销自己创建的记录 - 其他角色:无撤销权限 - """ - record = await execute_one( - "SELECT record_id, recorder_id FROM conduct_records WHERE record_id = %s", - (record_id,) - ) - if not record: - return False - role = await PermissionChecker.get_user_role(user_id) - if role in ["班主任", "班长"]: - return True - if role == "考勤委员" and record.get("recorder_id") == user_id: - return True - return False - - -def require_auth(func: Callable): - """需要认证的装饰器""" - @wraps(func) - async def wrapper(*args, **kwargs): - request = kwargs.get('request') - if not request or not hasattr(request.state, 'user_id'): - return forbidden_response("请先登录") - return await func(*args, **kwargs) - return wrapper - - -def require_role(roles: List[str]): - """需要特定角色的装饰器""" - def decorator(func: Callable): - @wraps(func) - async def wrapper(*args, **kwargs): - request = kwargs.get('request') - if not request or not hasattr(request.state, 'user_id'): - return forbidden_response("请先登录") - user_id = request.state.user_id - user_role = await PermissionChecker.get_user_role(user_id) - if user_role not in roles: - return forbidden_response(f"需要{','.join(roles)}权限") - return await func(*args, **kwargs) - return wrapper - return decorator - - -def require_teacher(func: Callable): - """需要班主任权限的装饰器""" - @wraps(func) - async def wrapper(*args, **kwargs): - request = kwargs.get('request') - if not request or not hasattr(request.state, 'user_id'): - return forbidden_response("请先登录") - is_teacher = await PermissionChecker.check_is_teacher(request.state.user_id) - if not is_teacher: - return forbidden_response("需要班主任权限") - return await func(*args, **kwargs) - return wrapper - - -def require_monitor(func: Callable): - """需要班长权限的装饰器""" - @wraps(func) - async def wrapper(*args, **kwargs): - request = kwargs.get('request') - if not request or not hasattr(request.state, 'user_id'): - return forbidden_response("请先登录") - is_monitor = await PermissionChecker.check_is_monitor(request.state.user_id) - if not is_monitor: - return forbidden_response("需要班长权限") - return await func(*args, **kwargs) - return wrapper - - -def require_study_commissioner(func: Callable): - """需要学习委员权限的装饰器""" - @wraps(func) - async def wrapper(*args, **kwargs): - request = kwargs.get('request') - if not request or not hasattr(request.state, 'user_id'): - return forbidden_response("请先登录") - is_study = await PermissionChecker.check_is_study_commissioner(request.state.user_id) - if not is_study: - return forbidden_response("需要学习委员权限") - return await func(*args, **kwargs) - return wrapper \ No newline at end of file diff --git a/backend/middleware/sanitize.py b/backend/middleware/sanitize.py deleted file mode 100644 index 108372b..0000000 --- a/backend/middleware/sanitize.py +++ /dev/null @@ -1,138 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from fastapi import Request -from starlette.middleware.base import BaseHTTPMiddleware -from typing import Dict, Any -import re - - -class SanitizeMiddleware(BaseHTTPMiddleware): - """输入过滤中间件""" - - async def dispatch(self, request: Request, call_next): - # 只处理POST、PUT、PATCH请求 - if request.method in ["POST", "PUT", "PATCH"]: - # 获取请求体 - body = await request.body() - if body: - import json - try: - data = json.loads(body) - # 清理数据 - cleaned_data = self._sanitize_data(data) - # 替换请求体 - request._body = json.dumps(cleaned_data).encode() - except: - pass - - response = await call_next(request) - return response - - def _sanitize_data(self, data: Any) -> Any: - """递归清理数据""" - if isinstance(data, dict): - return {k: self._sanitize_data(v) for k, v in data.items()} - elif isinstance(data, list): - return [self._sanitize_data(item) for item in data] - elif isinstance(data, str): - return self._sanitize_string(data) - else: - return data - - def _sanitize_string(self, value: str) -> str: - """清理字符串""" - if not value: - return "" - - # 去除首尾空格 - value = value.strip() - - # SQL注入模式检测 - sql_patterns = [ - r'(?i)(\bunion\b\s+\bselect\b)', - r'(?i)(\bor\b\s+\d+\s*=\s*\d+)', - r'(?i)(\bdrop\b\s+\btable\b)', - r'(?i)(\bdelete\b\s+\bfrom\b)', - r'(?i)(\binsert\b\s+\binto\b)', - r'(?i)(\bupdate\b\s+\w+\s+\bset\b)', - ] - for pattern in sql_patterns: - value = re.sub(pattern, '', value) - - # 路径遍历检测 - value = value.replace('../', '').replace('..\\', '') - - # 限制长度 - if len(value) > 1000: - value = value[:1000] - - # 转义HTML特殊字符 - html_chars = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - '/': '/' - } - for char, escape in html_chars.items(): - value = value.replace(char, escape) - - return value - - -def sanitize_input(value: str, max_length: int = 255) -> str: - """清理单个输入值""" - if not value: - return "" - - value = value.strip() - if len(value) > max_length: - value = value[:max_length] - - return value - - -def validate_points(points: int, min_val: int = -100, max_val: int = 100) -> tuple: - """ - 验证分值 - 返回: (是否有效, 错误信息) - """ - if points == 0: - return False, "分值不能为0" - if points < min_val or points > max_val: - return False, f"分值必须在{min_val}到{max_val}之间" - return True, "" - - -def validate_reason(reason: str) -> tuple: - """ - 验证原因 - 返回: (是否有效, 错误信息) - """ - if not reason or not reason.strip(): - return False, "原因不能为空" - # 计算可见字符长度(不含换行符),支持多行输入 - visible_length = len(reason.replace('\n', '')) - if visible_length > 255: - return False, "原因长度不能超过255个字符" - return True, "" - - -def validate_date(date_str: str) -> bool: - """验证日期格式 YYYY-MM-DD""" - if not date_str: - return False - pattern = r'^\d{4}-\d{2}-\d{2}$' - if not re.match(pattern, date_str): - return False - return True \ No newline at end of file diff --git a/backend/models/__init__.py b/backend/models/__init__.py deleted file mode 100644 index 638547a..0000000 --- a/backend/models/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - diff --git a/backend/models/admin_role.py b/backend/models/admin_role.py deleted file mode 100644 index c40de9b..0000000 --- a/backend/models/admin_role.py +++ /dev/null @@ -1,62 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 管理员角色模型 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from typing import Optional, Dict, Any, List -from utils.database import execute_one, execute_query, execute_insert, execute_update - - -class AdminRoleModel: - """管理员角色数据模型""" - - @staticmethod - async def get_by_user_id(user_id: int) -> Optional[Dict[str, Any]]: - sql = """ - SELECT ar.*, s.subject_name - FROM admin_roles ar - LEFT JOIN subjects s ON ar.subject_id = s.subject_id - WHERE ar.user_id = %s - LIMIT 1 - """ - return await execute_one(sql, (user_id,)) - - @staticmethod - async def get_all() -> List[Dict[str, Any]]: - sql = """ - SELECT ar.*, u.real_name, u.username, s.subject_name - FROM admin_roles ar - JOIN users u ON ar.user_id = u.user_id AND u.status = 1 - LEFT JOIN subjects s ON ar.subject_id = s.subject_id - ORDER BY ar.role_type - """ - return await execute_query(sql) - @staticmethod - async def create(user_id: int, role_type: str, subject_id: int = None) -> int: - sql = """ - INSERT INTO admin_roles (user_id, role_type, subject_id) - VALUES (%s, %s, %s) - """ - return await execute_insert(sql, (user_id, role_type, subject_id)) - - @staticmethod - async def delete(user_id: int) -> bool: - sql = "DELETE FROM admin_roles WHERE user_id = %s" - result = await execute_update(sql, (user_id,)) - return result > 0 - - @staticmethod - async def update_role(user_id: int, role_type: str, subject_id: int = None) -> bool: - sql = """ - UPDATE admin_roles - SET role_type = %s, subject_id = %s - WHERE user_id = %s - """ - result = await execute_update(sql, (role_type, subject_id, user_id)) - return result > 0 \ No newline at end of file diff --git a/backend/models/attendance.py b/backend/models/attendance.py deleted file mode 100644 index 76c4c59..0000000 --- a/backend/models/attendance.py +++ /dev/null @@ -1,103 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from typing import Optional, Dict, Any, List -from datetime import datetime -from utils.database import execute_one, execute_query, execute_insert, execute_update - - -class AttendanceModel: - """考勤数据模型""" - - @staticmethod - async def get_student_records(student_id: int, month: str = None) -> List[Dict[str, Any]]: - sql = """ - SELECT attendance_id, date, slot, status, reason, deduction_applied, created_at - FROM attendance_records - WHERE student_id = %s - """ - params = [student_id] - - if month: - sql += " AND DATE_FORMAT(date, '%%Y-%%m') = %s" - params.append(month) - - sql += " ORDER BY date DESC" - - return await execute_query(sql, tuple(params)) - - @staticmethod - async def get_class_records( - date: str = None, - student_id: int = None, - slot: str = None - ) -> List[Dict[str, Any]]: - sql = """ - SELECT ar.*, s.name as student_name, s.student_no - FROM attendance_records ar - JOIN students s ON ar.student_id = s.student_id - WHERE 1=1 - """ - params = [] - - if date: - sql += " AND ar.date = %s" - params.append(date) - - if student_id: - sql += " AND ar.student_id = %s" - params.append(student_id) - - if slot: - sql += " AND ar.slot = %s" - params.append(slot) - - sql += " ORDER BY ar.date DESC, s.student_no" - - return await execute_query(sql, tuple(params)) - - @staticmethod - async def create_record( - student_id: int, - date: str, - status: str, - reason: str = None, - recorder_id: int = None, - slot: str = 'morning' - ) -> int: - # 检查是否已存在当天同时段记录 - existing = await execute_one( - "SELECT attendance_id FROM attendance_records WHERE student_id = %s AND date = %s AND slot = %s", - (student_id, date, slot) - ) - - if existing: - # 更新已有记录 - sql = """ - UPDATE attendance_records - SET status = %s, reason = %s, recorder_id = %s - WHERE student_id = %s AND date = %s AND slot = %s - """ - await execute_update(sql, (status, reason, recorder_id, student_id, date, slot)) - return existing["attendance_id"] - else: - # 插入新记录 - sql = """ - INSERT INTO attendance_records (student_id, date, slot, status, reason, recorder_id) - VALUES (%s, %s, %s, %s, %s, %s) - """ - return await execute_insert(sql, (student_id, date, slot, status, reason, recorder_id)) - - @staticmethod - async def mark_deduction_applied(attendance_id: int) -> bool: - sql = "UPDATE attendance_records SET deduction_applied = 1 WHERE attendance_id = %s" - result = await execute_update(sql, (attendance_id,)) - return result > 0 \ No newline at end of file diff --git a/backend/models/conduct.py b/backend/models/conduct.py deleted file mode 100644 index 2cefb0c..0000000 --- a/backend/models/conduct.py +++ /dev/null @@ -1,392 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from typing import Optional, List, Dict, Any -from datetime import datetime -from utils.database import execute_one, execute_query, execute_insert, execute_update -from utils.logger import get_logger - -logger = get_logger(__name__) - - -class ConductModel: - """操行分数据模型""" - - @staticmethod - async def create_record( - student_id: int, - points_change: int, - reason: str, - recorder_id: int, - recorder_name: str = None, - related_type: str = 'manual', - related_id: int = None - ) -> int: - """创建操行分记录""" - sql = """ - INSERT INTO conduct_records - (student_id, points_change, reason, recorder_id, recorder_name, related_type, related_id) - VALUES (%s, %s, %s, %s, %s, %s, %s) - """ - return await execute_insert(sql, ( - student_id, points_change, reason, recorder_id, recorder_name, related_type, related_id - )) - - @staticmethod - async def count_student_records( - student_id: int, - include_revoked: bool = False, - start_date: str = None, - end_date: str = None, - recorder_id: int = None - ) -> int: - """统计学生操行分记录总数""" - conditions = ["student_id = %s"] - params = [student_id] - if not include_revoked: - conditions.append("is_revoked = 0") - if start_date: - conditions.append("DATE(created_at) >= %s") - params.append(start_date) - if end_date: - conditions.append("DATE(created_at) <= %s") - params.append(end_date) - if recorder_id: - conditions.append("recorder_id = %s") - params.append(recorder_id) - where = " AND ".join(conditions) - sql = f"SELECT COUNT(*) as total FROM conduct_records WHERE {where}" - result = await execute_one(sql, tuple(params)) - return result["total"] if result else 0 - - @staticmethod - async def count_records_by_recorder( - recorder_id: int, - start_date: str = None, - end_date: str = None - ) -> int: - """统计记录人提交的操行分记录总数""" - conditions = ["recorder_id = %s"] - params = [recorder_id] - if start_date: - conditions.append("DATE(created_at) >= %s") - params.append(start_date) - if end_date: - conditions.append("DATE(created_at) <= %s") - params.append(end_date) - where = " AND ".join(conditions) - sql = f"SELECT COUNT(*) as total FROM conduct_records WHERE {where}" - result = await execute_one(sql, tuple(params)) - return result["total"] if result else 0 - - @staticmethod - async def get_student_records( - student_id: int, - limit: int = 50, - offset: int = 0, - include_revoked: bool = False, - start_date: str = None, - end_date: str = None, - recorder_id: int = None - ) -> List[Dict[str, Any]]: - """获取学生操行分记录""" - conditions = ["cr.student_id = %s"] - params = [student_id] - if not include_revoked: - conditions.append("cr.is_revoked = 0") - if start_date: - conditions.append("DATE(cr.created_at) >= %s") - params.append(start_date) - if end_date: - conditions.append("DATE(cr.created_at) <= %s") - params.append(end_date) - if recorder_id: - conditions.append("cr.recorder_id = %s") - params.append(recorder_id) - where = " AND ".join(conditions) - sql = f""" - SELECT cr.*, u.real_name as recorder_name - FROM conduct_records cr - LEFT JOIN users u ON cr.recorder_id = u.user_id - WHERE {where} - ORDER BY cr.created_at DESC - LIMIT %s OFFSET %s - """ - params.extend([limit, offset]) - return await execute_query(sql, tuple(params)) - - @staticmethod - async def get_records_by_recorder( - recorder_id: int, - limit: int = 50, - offset: int = 0, - start_date: str = None, - end_date: str = None - ) -> List[Dict[str, Any]]: - """获取操作人提交的记录""" - conditions = ["cr.recorder_id = %s", "cr.is_revoked = 0"] - params = [recorder_id] - if start_date: - conditions.append("DATE(cr.created_at) >= %s") - params.append(start_date) - if end_date: - conditions.append("DATE(cr.created_at) <= %s") - params.append(end_date) - where = " AND ".join(conditions) - sql = f""" - SELECT cr.*, s.name as student_name - FROM conduct_records cr - JOIN students s ON cr.student_id = s.student_id - WHERE {where} - ORDER BY cr.created_at DESC - LIMIT %s OFFSET %s - """ - params.extend([limit, offset]) - return await execute_query(sql, tuple(params)) - - @staticmethod - @staticmethod - async def get_all_records( - limit: int = 100, - offset: int = 0, - start_date: str = None, - end_date: str = None, - student_id: int = None, - include_revoked: bool = True, - related_type: str = None, - reason_prefix: str = None, - is_revoked: int = None, - reason_search: str = None - ) -> List[Dict[str, Any]]: - """获取所有记录(班主任/班长专用)""" - # 空字符串转为None - if start_date == "": - start_date = None - if end_date == "": - end_date = None - if related_type == "": - related_type = None - if reason_prefix == "": - reason_prefix = None - if reason_search == "": - reason_search = None - sql = """ - SELECT cr.*, s.name as student_name, s.student_no, u.real_name as recorder_name, - ru.real_name as revoker_name - FROM conduct_records cr - JOIN students s ON cr.student_id = s.student_id - JOIN users u ON cr.recorder_id = u.user_id - LEFT JOIN users ru ON cr.revoked_by = ru.user_id - WHERE 1=1 - """ - if not include_revoked: - sql += " AND cr.is_revoked = 0" - params = [] - - if student_id: - sql += " AND cr.student_id = %s" - params.append(student_id) - - if start_date: - sql += " AND DATE(cr.created_at) >= %s" - params.append(start_date) - - if end_date: - sql += " AND DATE(cr.created_at) <= %s" - params.append(end_date) - - if related_type: - sql += " AND cr.related_type = %s" - params.append(related_type) - - if reason_prefix: - sql += " AND cr.reason LIKE %s" - params.append(f"{reason_prefix}%") - - if reason_search: - sql += " AND cr.reason LIKE %s" - params.append(f"%{reason_search}%") - - if is_revoked is not None: - sql += " AND cr.is_revoked = %s" - params.append(1 if is_revoked else 0) - - sql += " ORDER BY cr.created_at DESC LIMIT %s OFFSET %s" - params.extend([limit, offset]) - - return await execute_query(sql, tuple(params)) - - @staticmethod - async def get_grouped_records( - student_id: int = None, - start_date: str = None, - end_date: str = None, - related_type: str = None, - reason_prefix: str = None, - page: int = 1, - page_size: int = 20, - is_revoked: int = None, - reason_search: str = None - ) -> Dict[str, Any]: - """获取分组后的操行分记录(同批次合并)""" - if start_date == "": - start_date = None - if end_date == "": - end_date = None - if related_type == "": - related_type = None - if reason_prefix == "": - reason_prefix = None - if reason_search == "": - reason_search = None - - conditions = ["1=1"] - params = [] - - if is_revoked is not None: - conditions.append("cr.is_revoked = %s") - params.append(1 if is_revoked else 0) - else: - conditions.append("cr.is_revoked = 0") - - if student_id: - conditions.append("cr.student_id = %s") - params.append(student_id) - if start_date: - conditions.append("cr.created_at >= %s") - params.append(start_date) - if end_date: - conditions.append("cr.created_at <= %s") - params.append(end_date + ' 23:59:59') - if related_type: - conditions.append("cr.related_type = %s") - params.append(related_type) - if reason_prefix: - conditions.append("cr.reason LIKE %s") - params.append(f"{reason_prefix}%") - if reason_search: - conditions.append("cr.reason LIKE %s") - params.append(f"%{reason_search}%") - - where_clause = " AND ".join(conditions) - - count_sql = f""" - SELECT COUNT(DISTINCT CONCAT(cr.points_change, '|', cr.reason, '|', cr.recorder_id, '|', DATE_FORMAT(cr.created_at, '%%Y-%%m-%%d %%H:%%i:%%s'))) as total - FROM conduct_records cr - WHERE {where_clause} - """ - - data_sql = f""" - SELECT - cr.points_change, - cr.reason, - cr.recorder_name, - DATE_FORMAT(MIN(cr.created_at), '%%Y-%%m-%%d %%H:%%i:%%s') as created_at, - GROUP_CONCAT(s.name ORDER BY s.student_id SEPARATOR ', ') as student_names, - COUNT(*) as student_count, - MAX(cr.is_revoked) as all_revoked - FROM conduct_records cr - JOIN students s ON cr.student_id = s.student_id - WHERE {where_clause} - GROUP BY cr.points_change, cr.reason, cr.recorder_id, DATE_FORMAT(cr.created_at, '%%Y-%%m-%%d %%H:%%i:%%s') - ORDER BY MIN(cr.created_at) DESC - LIMIT %s OFFSET %s - """ - - params_for_count = list(params) - params_for_data = list(params) + [page_size, (page - 1) * page_size] - - total_result = await execute_one(count_sql, tuple(params_for_count)) - total = total_result['total'] if total_result else 0 - - records = await execute_query(data_sql, tuple(params_for_data)) - - return { - "records": records, - "total": total, - "page": page, - "page_size": page_size, - "total_pages": (total + page_size - 1) // page_size - } - - @staticmethod - async def get_record_by_id(record_id: int) -> Optional[Dict[str, Any]]: - """根据ID获取记录""" - sql = """ - SELECT cr.*, s.name as student_name, s.total_points - FROM conduct_records cr - JOIN students s ON cr.student_id = s.student_id - WHERE cr.record_id = %s - """ - return await execute_one(sql, (record_id,)) - - @staticmethod - async def revoke_record(record_id: int, revoker_id: int) -> bool: - """撤销记录""" - try: - sql = """ - UPDATE conduct_records - SET is_revoked = 1, revoked_by = %s, revoked_at = NOW() - WHERE record_id = %s AND is_revoked = 0 - """ - result = await execute_update(sql, (revoker_id, record_id)) - return result > 0 - except Exception as e: - logger.error(f"撤销记录失败: {e}") - return False - - @staticmethod - async def restore_record(record_id: int, restorer_id: int) -> bool: - """反撤销(恢复)已撤销的记录""" - try: - sql = """ - UPDATE conduct_records - SET is_revoked = 0, revoked_by = NULL, revoked_at = NULL - WHERE record_id = %s AND is_revoked = 1 - """ - result = await execute_update(sql, (record_id,)) - return result > 0 - except Exception as e: - logger.error(f"恢复记录失败: {e}") - return False - - @staticmethod - async def batch_create_records(records_data: List[Dict]) -> List[Dict]: - """批量创建操行分记录""" - results = [] - for record in records_data: - try: - record_id = await ConductModel.create_record( - student_id=record.get('student_id'), - points_change=record.get('points_change'), - reason=record.get('reason'), - recorder_id=record.get('recorder_id'), - recorder_name=record.get('recorder_name') - ) - results.append({ - 'student_id': record.get('student_id'), - 'success': True, - 'record_id': record_id - }) - except Exception as e: - results.append({ - 'student_id': record.get('student_id'), - 'success': False, - 'error': str(e) - }) - return results - - @staticmethod - async def get_student_total_points(student_id: int) -> int: - """获取学生当前总分""" - sql = "SELECT total_points FROM students WHERE student_id = %s" - result = await execute_one(sql, (student_id,)) - return result['total_points'] if result else 100 \ No newline at end of file diff --git a/backend/models/log.py b/backend/models/log.py deleted file mode 100644 index 064428d..0000000 --- a/backend/models/log.py +++ /dev/null @@ -1,64 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from utils.database import execute_insert -from utils.logger import get_logger - -logger = get_logger(__name__) - - -class LoginLogModel: - """登录日志数据模型""" - - @staticmethod - async def create(username: str, login_result: int, ip_address: str, user_agent: str = None, fail_reason: str = None) -> int: - """ - 写入登录日志 - :param username: 用户名 - :param login_result: 登录结果 (1=成功, 0=失败) - :param ip_address: IP地址 - :param user_agent: 浏览器UA - :param fail_reason: 失败原因 - :return: log_id - """ - sql = """ - INSERT INTO login_logs (username, login_result, fail_reason, ip_address, user_agent) - VALUES (%s, %s, %s, %s, %s) - """ - return await execute_insert(sql, (username, login_result, fail_reason, ip_address, user_agent)) - - -class OperationLogModel: - """操作日志数据模型""" - - @staticmethod - async def create(operator_id: int, operator_name: str, operator_role: str, - operation_type: str, target_type: str = None, target_id: int = None, - details: str = None, ip_address: str = None) -> int: - """ - 写入操作日志 - :param operator_id: 操作者用户ID - :param operator_name: 操作者用户名 - :param operator_role: 操作者角色 - :param operation_type: 操作类型 - :param target_type: 目标类型 - :param target_id: 目标ID - :param details: 详细信息 - :param ip_address: IP地址 - :return: log_id - """ - sql = """ - INSERT INTO operation_logs (operator_id, operator_name, operator_role, - operation_type, target_type, target_id, details, ip_address) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) - """ - return await execute_insert(sql, (operator_id, operator_name, operator_role, - operation_type, target_type, target_id, details, ip_address)) diff --git a/backend/models/semester.py b/backend/models/semester.py deleted file mode 100644 index 0feff9d..0000000 --- a/backend/models/semester.py +++ /dev/null @@ -1,297 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 学期数据模型 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from typing import Optional, List, Dict, Any -from utils.database import execute_one, execute_query, execute_insert, execute_update, execute_many -from utils.logger import get_logger - -logger = get_logger(__name__) - - -class SemesterModel: - """学期数据模型""" - - @staticmethod - async def create( - semester_name: str, - start_date: str = None, - end_date: str = None - ) -> int: - """创建学期""" - sql = """ - INSERT INTO semesters (semester_name, start_date, end_date) - VALUES (%s, %s, %s) - """ - return await execute_insert(sql, (semester_name, start_date, end_date)) - - @staticmethod - async def get_by_id(semester_id: int) -> Optional[Dict[str, Any]]: - """根据ID获取学期信息""" - sql = "SELECT * FROM semesters WHERE semester_id = %s" - return await execute_one(sql, (semester_id,)) - - @staticmethod - async def get_all() -> List[Dict[str, Any]]: - """获取所有学期列表""" - sql = """ - SELECT semester_id, semester_name, start_date, end_date, - is_active, is_archived, created_at - FROM semesters - ORDER BY created_at DESC - """ - return await execute_query(sql) - - @staticmethod - async def get_active() -> Optional[Dict[str, Any]]: - """获取当前活跃学期(优先 is_active 标记,降级为日期范围匹配)""" - fields = "semester_id, semester_name, start_date, end_date, is_active, is_archived, created_at" - # 第一优先级:is_active 标记 - sql = f""" - SELECT {fields} - FROM semesters - WHERE is_active = 1 AND is_archived = 0 - LIMIT 1 - """ - result = await execute_one(sql) - if result: - return result - # 第二优先级:日期范围匹配 - # 注:无日期的学期不会自动匹配为活跃学期(需手动激活) - sql = f""" - SELECT {fields} - FROM semesters - WHERE is_archived = 0 AND start_date <= CURDATE() AND (end_date IS NULL OR end_date >= CURDATE()) - LIMIT 1 - """ - return await execute_one(sql) - - @staticmethod - async def deactivate_all() -> int: - """将所有学期设为非活跃""" - sql = "UPDATE semesters SET is_active = 0 WHERE is_active = 1" - return await execute_update(sql) - - @staticmethod - async def activate(semester_id: int) -> bool: - """设为当前活跃学期""" - sql = """ - UPDATE semesters SET is_active = 1 - WHERE semester_id = %s AND is_archived = 0 - """ - result = await execute_update(sql, (semester_id,)) - return result > 0 - - @staticmethod - async def archive(semester_id: int) -> bool: - """归档学期""" - sql = """ - UPDATE semesters SET is_archived = 1, is_active = 0 - WHERE semester_id = %s AND is_archived = 0 - """ - result = await execute_update(sql, (semester_id,)) - return result > 0 - - @staticmethod - async def is_archived(semester_id: int) -> bool: - """检查学期是否已归档""" - sql = "SELECT is_archived FROM semesters WHERE semester_id = %s" - result = await execute_one(sql, (semester_id,)) - if not result: - return False - return bool(result['is_archived']) - - @staticmethod - async def get_record_semester_id(record_id: int) -> Optional[int]: - """获取操行分记录所属的学期ID""" - sql = "SELECT semester_id FROM conduct_records WHERE record_id = %s" - result = await execute_one(sql, (record_id,)) - return result['semester_id'] if result else None - - @staticmethod - async def get_attendance_stats_by_semester(semester_id: int, start_date: str, end_date: str) -> List[Dict]: - """批量查询学期内所有学生的考勤统计""" - sql = """ - SELECT student_id, status, COUNT(*) as cnt - FROM attendance_records - WHERE (semester_id = %s OR (semester_id IS NULL AND `date` BETWEEN %s AND %s)) - GROUP BY student_id, status - """ - return await execute_query(sql, (semester_id, start_date, end_date)) - - @staticmethod - async def count_records_by_semester(semester_id: int) -> Dict[str, int]: - """统计学期关联的记录数""" - conduct_sql = "SELECT COUNT(*) as cnt FROM conduct_records WHERE semester_id = %s" - attendance_sql = "SELECT COUNT(*) as cnt FROM attendance_records WHERE semester_id = %s" - conduct_result = await execute_one(conduct_sql, (semester_id,)) - attendance_result = await execute_one(attendance_sql, (semester_id,)) - return { - "conduct_count": conduct_result['cnt'] if conduct_result else 0, - "attendance_count": attendance_result['cnt'] if attendance_result else 0 - } - - @staticmethod - async def get_homework_stats_by_date_range(start_date: str, end_date: str) -> List[Dict]: - """通过作业截止日期范围查询所有学生的作业提交统计""" - sql = """ - SELECT hs.student_id, hs.status, COUNT(*) as cnt - FROM homework_submissions hs - JOIN assignments a ON hs.assignment_id = a.assignment_id - WHERE a.deadline BETWEEN %s AND %s - GROUP BY hs.student_id, hs.status - """ - return await execute_query(sql, (start_date, end_date)) - - @staticmethod - async def update( - semester_id: int, - semester_name: str = None, - start_date: str = None, - end_date: str = None - ) -> bool: - """编辑学期信息(仅未归档)""" - sets = [] - params = [] - if semester_name is not None: - sets.append("semester_name = %s") - params.append(semester_name) - if start_date is not None: - sets.append("start_date = %s") - params.append(start_date) - if end_date is not None: - sets.append("end_date = %s") - params.append(end_date) - if not sets: - return False - params.append(semester_id) - sql = f"UPDATE semesters SET {', '.join(sets)} WHERE semester_id = %s AND is_archived = 0" - result = await execute_update(sql, tuple(params)) - return result > 0 - - @staticmethod - async def delete(semester_id: int) -> bool: - """删除学期""" - sql = "DELETE FROM semesters WHERE semester_id = %s" - result = await execute_update(sql, (semester_id,)) - return result > 0 - - @staticmethod - async def count_archives(semester_id: int) -> int: - """统计学期的归档数据数量""" - sql = "SELECT COUNT(*) as cnt FROM semester_archives WHERE semester_id = %s" - result = await execute_one(sql, (semester_id,)) - return result['cnt'] if result else 0 - - @staticmethod - async def associate_records_by_date_range( - semester_id: int, - start_date: str, - end_date: str - ) -> Dict[str, int]: - """按日期范围关联记录到学期""" - # 关联操行分记录(created_at 为 TIMESTAMP,需包含 end_date 当天) - conduct_sql = """ - UPDATE conduct_records - SET semester_id = %s - WHERE semester_id IS NULL - AND created_at BETWEEN %s AND CONCAT(%s, ' 23:59:59') - """ - conduct_count = await execute_update(conduct_sql, (semester_id, start_date, end_date)) - - # 关联考勤记录 - attendance_sql = """ - UPDATE attendance_records - SET semester_id = %s - WHERE semester_id IS NULL - AND `date` BETWEEN %s AND %s - """ - attendance_count = await execute_update(attendance_sql, (semester_id, start_date, end_date)) - - return {"conduct": conduct_count, "attendance": attendance_count} - - -class SemesterArchiveModel: - """学期归档快照数据模型""" - - @staticmethod - async def batch_create(archives_data: List[Dict]) -> int: - """批量创建归档快照""" - if not archives_data: - return 0 - sql = """ - INSERT INTO semester_archives - (semester_id, student_id, student_no, student_name, final_points, rank_position, total_students, - attendance_present, attendance_absent, attendance_late, attendance_leave, - homework_submitted, homework_not_submitted, homework_late) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """ - params_list = [ - ( - a['semester_id'], a['student_id'], a['student_no'], - a['student_name'], a['final_points'], - a.get('rank_position', 0), a.get('total_students', 0), - a.get('attendance_present', 0), a.get('attendance_absent', 0), - a.get('attendance_late', 0), a.get('attendance_leave', 0), - a.get('homework_submitted', 0), a.get('homework_not_submitted', 0), - a.get('homework_late', 0) - ) - for a in archives_data - ] - return await execute_many(sql, params_list) - - @staticmethod - async def delete_by_semester(semester_id: int) -> int: - """删除指定学期的所有归档数据(用于归档操作的幂等性)""" - sql = "DELETE FROM semester_archives WHERE semester_id = %s" - return await execute_update(sql, (semester_id,)) - - @staticmethod - async def get_by_semester(semester_id: int) -> List[Dict[str, Any]]: - """获取学期的归档数据""" - sql = """ - SELECT archive_id, semester_id, student_id, student_no, - student_name, final_points, rank_position, total_students, - attendance_present, attendance_absent, attendance_late, attendance_leave, - homework_submitted, homework_not_submitted, homework_late, archived_at - FROM semester_archives - WHERE semester_id = %s - ORDER BY rank_position ASC - """ - return await execute_query(sql, (semester_id,)) - - @staticmethod - async def get_by_semester_and_student(semester_id: int, student_id: int) -> Optional[Dict[str, Any]]: - """获取指定学期指定学生的归档数据""" - sql = """ - SELECT archive_id, semester_id, student_id, student_no, - student_name, final_points, rank_position, total_students, archived_at - FROM semester_archives - WHERE semester_id = %s AND student_id = %s - """ - return await execute_one(sql, (semester_id, student_id)) - - @staticmethod - async def get_by_student(student_id: int) -> List[Dict[str, Any]]: - """获取学生在所有已归档学期的数据""" - sql = """ - SELECT sa.archive_id, sa.semester_id, sa.student_id, sa.student_no, - sa.student_name, sa.final_points, sa.rank_position, - sa.total_students, sa.attendance_present, sa.attendance_absent, - sa.attendance_late, sa.attendance_leave, - sa.homework_submitted, sa.homework_not_submitted, sa.homework_late, - sa.archived_at, - s.semester_name, s.start_date, s.end_date - FROM semester_archives sa - JOIN semesters s ON sa.semester_id = s.semester_id - WHERE sa.student_id = %s - ORDER BY sa.archived_at DESC - """ - return await execute_query(sql, (student_id,)) diff --git a/backend/models/student.py b/backend/models/student.py deleted file mode 100644 index 4f320b1..0000000 --- a/backend/models/student.py +++ /dev/null @@ -1,205 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 学生数据模型 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from typing import Optional, List, Dict, Any -from utils.database import execute_one, execute_query, execute_insert, execute_update, execute_many -from utils.security import security -from utils.logger import get_logger - -logger = get_logger(__name__) - - -class StudentModel: - """学生数据模型""" - - @staticmethod - async def get_by_id(student_id: int) -> Optional[Dict[str, Any]]: - """根据ID获取学生信息""" - sql = """ - SELECT s.* - FROM students s - WHERE s.student_id = %s - """ - return await execute_one(sql, (student_id,)) - - @staticmethod - async def get_by_student_no(student_no: str) -> Optional[Dict[str, Any]]: - """根据学号获取学生信息""" - sql = """ - SELECT s.* - FROM students s - WHERE s.student_no = %s - """ - return await execute_one(sql, (student_no,)) - - @staticmethod - async def get_all(include_disabled: bool = False) -> List[Dict[str, Any]]: - """获取所有学生列表(单班级)""" - sql = """ - SELECT student_id, student_no, name, total_points, parent_phone, dormitory_number, status - FROM students - WHERE 1=1 - """ - if not include_disabled: - sql += " AND status = 1" - sql += " ORDER BY student_no" - return await execute_query(sql) - - @staticmethod - async def get_dormitory_list() -> List[str]: - """获取所有不重复的宿舍号列表""" - try: - sql = """ - SELECT DISTINCT dormitory_number - FROM students - WHERE status = 1 AND dormitory_number IS NOT NULL AND dormitory_number != '' - ORDER BY dormitory_number - """ - rows = await execute_query(sql) - return [row["dormitory_number"] for row in rows] - except Exception as e: - logger.warning(f"dormitory_number 列不存在,返回空列表: {e}") - return [] - - @staticmethod - async def create( - student_no: str, - name: str, - parent_phone: str = None, - dormitory_number: str = None, - initial_points: int = 60 - ) -> int: - """创建学生(初始操行分默认60分)""" - if dormitory_number is not None: - sql = """ - INSERT INTO students (student_no, name, parent_phone, dormitory_number, total_points) - VALUES (%s, %s, %s, %s, %s) - """ - return await execute_insert(sql, (student_no, name, parent_phone, dormitory_number, initial_points)) - else: - sql = """ - INSERT INTO students (student_no, name, parent_phone, total_points) - VALUES (%s, %s, %s, %s) - """ - return await execute_insert(sql, (student_no, name, parent_phone, initial_points)) - - @staticmethod - async def update(student_id: int, name: str = None, parent_phone: str = None, dormitory_number: str = None, status: int = None) -> bool: - """更新学生信息""" - updates = [] - params = [] - has_dormitory = False - - if name is not None: - updates.append("name = %s") - params.append(name) - if parent_phone is not None: - updates.append("parent_phone = %s") - params.append(parent_phone) - if dormitory_number is not None: - updates.append("dormitory_number = %s") - params.append(dormitory_number) - has_dormitory = True - if status is not None: - updates.append("status = %s") - params.append(status) - - if not updates: - return True - - params.append(student_id) - sql = f"UPDATE students SET {', '.join(updates)} WHERE student_id = %s" - try: - result = await execute_update(sql, tuple(params)) - return result > 0 - except Exception as e: - if has_dormitory: - logger.warning(f"dormitory_number 列不存在,尝试不含该字段重试: {e}") - retry_updates = [] - retry_params = [] - if name is not None: - retry_updates.append("name = %s") - retry_params.append(name) - if parent_phone is not None: - retry_updates.append("parent_phone = %s") - retry_params.append(parent_phone) - if status is not None: - retry_updates.append("status = %s") - retry_params.append(status) - if not retry_updates: - return True - retry_params.append(student_id) - sql = f"UPDATE students SET {', '.join(retry_updates)} WHERE student_id = %s" - result = await execute_update(sql, tuple(retry_params)) - return result > 0 - raise - - @staticmethod - async def delete(student_id: int) -> bool: - """删除学生(软删除)""" - sql = "UPDATE students SET status = 0 WHERE student_id = %s" - result = await execute_update(sql, (student_id,)) - return result > 0 - - @staticmethod - async def update_total_points(student_id: int, points_change: int) -> bool: - """更新学生总分""" - sql = "UPDATE students SET total_points = total_points + %s WHERE student_id = %s" - result = await execute_update(sql, (points_change, student_id)) - return result > 0 - - @staticmethod - async def get_ranking(limit: int = 50) -> List[Dict[str, Any]]: - """获取学生排行(单班级)""" - sql = """ - SELECT student_id, student_no, name, total_points - FROM students - WHERE status = 1 - ORDER BY total_points DESC, student_id ASC - LIMIT %s - """ - results = await execute_query(sql, (limit,)) - for i, row in enumerate(results): - row['rank'] = i + 1 - return results - - @staticmethod - async def get_total_count() -> int: - """获取活跃学生总数""" - sql = "SELECT COUNT(*) as total FROM students WHERE status = 1" - result = await execute_one(sql) - return result["total"] if result else 0 - - @staticmethod - async def batch_create(students_data: List[Dict], initial_points: int = 60) -> List[Dict]: - """批量创建学生""" - results = [] - for student in students_data: - try: - student_id = await StudentModel.create( - student_no=student.get('student_no'), - name=student.get('name'), - parent_phone=student.get('parent_phone'), - dormitory_number=student.get('dormitory_number'), - initial_points=initial_points - ) - results.append({ - 'student_no': student.get('student_no'), - 'success': True, - 'student_id': student_id - }) - except Exception as e: - results.append({ - 'student_no': student.get('student_no'), - 'success': False, - 'error': str(e) - }) - return results \ No newline at end of file diff --git a/backend/models/subject.py b/backend/models/subject.py deleted file mode 100644 index 62a3b47..0000000 --- a/backend/models/subject.py +++ /dev/null @@ -1,93 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from typing import Optional, Dict, Any, List -from utils.database import execute_one, execute_query, execute_insert, execute_update - - -class SubjectModel: - """科目数据模型""" - - @staticmethod - async def get_all(is_active: bool = None) -> List[Dict[str, Any]]: - if is_active is not None: - sql = "SELECT * FROM subjects WHERE is_active = %s ORDER BY sort_order, subject_id" - return await execute_query(sql, (1 if is_active else 0,)) - else: - sql = "SELECT * FROM subjects ORDER BY sort_order, subject_id" - return await execute_query(sql) - - @staticmethod - async def get_by_id(subject_id: int) -> Optional[Dict[str, Any]]: - sql = "SELECT * FROM subjects WHERE subject_id = %s" - return await execute_one(sql, (subject_id,)) - - @staticmethod - async def get_by_name(subject_name: str) -> Optional[Dict[str, Any]]: - sql = "SELECT * FROM subjects WHERE subject_name = %s" - return await execute_one(sql, (subject_name,)) - - @staticmethod - async def create(subject_name: str, subject_code: str = None, sort_order: int = 0) -> int: - sql = """ - INSERT INTO subjects (subject_name, subject_code, sort_order) - VALUES (%s, %s, %s) - """ - return await execute_insert(sql, (subject_name, subject_code, sort_order)) - - @staticmethod - async def update(subject_id: int, **kwargs) -> bool: - updates = [] - params = [] - - if "subject_name" in kwargs: - updates.append("subject_name = %s") - params.append(kwargs["subject_name"]) - if "subject_code" in kwargs: - updates.append("subject_code = %s") - params.append(kwargs["subject_code"]) - if "is_active" in kwargs: - updates.append("is_active = %s") - params.append(1 if kwargs["is_active"] else 0) - if "sort_order" in kwargs: - updates.append("sort_order = %s") - params.append(kwargs["sort_order"]) - - if not updates: - return True - - params.append(subject_id) - sql = f"UPDATE subjects SET {', '.join(updates)} WHERE subject_id = %s" - result = await execute_update(sql, tuple(params)) - return result > 0 - - @staticmethod - async def has_related_data(subject_id: int) -> bool: - """检查科目是否有关联的作业数据""" - sql = "SELECT COUNT(*) AS cnt FROM assignments WHERE subject_id = %s" - result = await execute_one(sql, (subject_id,)) - return result and result.get("cnt", 0) > 0 - - @staticmethod - async def delete(subject_id: int) -> bool: - """真正删除科目记录""" - subject = await SubjectModel.get_by_id(subject_id) - if not subject: - return False - sql = "DELETE FROM subjects WHERE subject_id = %s" - result = await execute_update(sql, (subject_id,)) - return result > 0 - - @staticmethod - async def activate(subject_id: int) -> bool: - sql = "UPDATE subjects SET is_active = 1 WHERE subject_id = %s" - result = await execute_update(sql, (subject_id,)) - return result > 0 \ No newline at end of file diff --git a/backend/models/user.py b/backend/models/user.py deleted file mode 100644 index a20b718..0000000 --- a/backend/models/user.py +++ /dev/null @@ -1,115 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from utils.database import execute_one, execute_insert, execute_update -from utils.security import security -from utils.logger import get_logger - -logger = get_logger(__name__) - - -class UserModel: - """用户数据模型""" - - @staticmethod - async def get_by_username(username: str) -> dict: - """根据用户名获取用户""" - sql = """ - SELECT user_id, username, password_hash, real_name, user_type, - student_id, status, need_change_password, last_login_time, last_login_ip - FROM users - WHERE username = %s AND status = 1 - """ - return await execute_one(sql, (username,)) - - @staticmethod - async def get_by_user_id(user_id: int) -> dict: - """根据用户ID获取用户""" - sql = """ - SELECT user_id, username, password_hash, real_name, user_type, student_id, - need_change_password, status - FROM users - WHERE user_id = %s - """ - return await execute_one(sql, (user_id,)) - - @staticmethod - async def create_student(username: str, password: str, real_name: str, student_id: int) -> int: - """创建学生账号""" - password_hash = security.bcrypt_password(password) - sql = """ - INSERT INTO users (username, password_hash, real_name, user_type, student_id, need_change_password) - VALUES (%s, %s, %s, 'student', %s, 1) - """ - return await execute_insert(sql, (username, password_hash, real_name, student_id)) - - @staticmethod - async def create_parent(username: str, password: str, real_name: str, student_id: int) -> int: - """创建家长账号""" - password_hash = security.bcrypt_password(password) - sql = """ - INSERT INTO users (username, password_hash, real_name, user_type, student_id, need_change_password) - VALUES (%s, %s, %s, 'parent', %s, 0) - """ - return await execute_insert(sql, (username, password_hash, real_name, student_id)) - - @staticmethod - async def create_admin(username: str, password: str, real_name: str) -> int: - """创建管理员账号""" - password_hash = security.bcrypt_password(password) - sql = """ - INSERT INTO users (username, password_hash, real_name, user_type, need_change_password) - VALUES (%s, %s, %s, 'admin', 1) - """ - return await execute_insert(sql, (username, password_hash, real_name)) - - @staticmethod - async def update_password(user_id: int, new_password: str) -> bool: - """更新密码""" - password_hash = security.bcrypt_password(new_password) - sql = """ - UPDATE users - SET password_hash = %s, need_change_password = 0 - WHERE user_id = %s - """ - result = await execute_update(sql, (password_hash, user_id)) - return result > 0 - - @staticmethod - async def update_last_login(user_id: int, ip: str) -> None: - """更新最后登录信息""" - sql = """ - UPDATE users - SET last_login_time = NOW(), last_login_ip = %s - WHERE user_id = %s - """ - await execute_update(sql, (ip, user_id)) - - @staticmethod - async def check_username_exists(username: str) -> bool: - """检查用户名是否存在""" - sql = "SELECT 1 FROM users WHERE username = %s" - result = await execute_one(sql, (username,)) - return result is not None - - @staticmethod - async def update_status(user_id: int, status: int) -> bool: - """更新用户状态(0=禁用,1=启用)""" - sql = "UPDATE users SET status = %s WHERE user_id = %s" - result = await execute_update(sql, (status, user_id)) - return result > 0 - - @staticmethod - async def update_real_name(user_id: int, real_name: str) -> bool: - """更新用户真实姓名""" - sql = "UPDATE users SET real_name = %s WHERE user_id = %s" - result = await execute_update(sql, (real_name, user_id)) - return result > 0 \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index 4bb448e..0000000 --- a/backend/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -fastapi==0.104.1 -uvicorn[standard]==0.24.0 -python-dotenv==1.0.0 -aiomysql==0.2.0 -redis==5.0.1 -python-jose[cryptography]==3.3.0 -passlib[bcrypt]==1.7.4 -pydantic==2.5.0 -pydantic-settings==2.1.0 -python-multipart==0.0.6 -loguru==0.7.2 \ No newline at end of file diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py deleted file mode 100644 index 638547a..0000000 --- a/backend/routes/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - diff --git a/backend/routes/admin.py b/backend/routes/admin.py deleted file mode 100644 index 8e4e7b0..0000000 --- a/backend/routes/admin.py +++ /dev/null @@ -1,650 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 管理端路由 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from fastapi import APIRouter, Request, Query, UploadFile, File -from typing import Optional, List -import json - -from middleware.permission import ( - get_current_user, - require_teacher, - PermissionChecker -) -from services.admin_service import AdminService -from services.conduct_service import ConductService -from services.attendance_service import AttendanceService -from services.log_service import LogService -from utils.redis_client import RedisClient -from schemas.admin import ( - AddPointsRequest, RevokeRequest, AddAdminRequest, - AddStudentRequest, UpdateStudentRequest, - AddAttendanceRequest, - UpdateAdminRequest, DeleteAdminRequest, ResetPasswordRequest, - UnlockUserRequest -) -from utils.response import success_response, error_response -from utils.logger import get_logger -from config import settings - -router = APIRouter() -logger = get_logger(__name__) - - -# ========== 学生管理 ========== - -@router.get("/students/dormitories") -async def get_dormitory_list(request: Request): - """获取宿舍号列表""" - user = await get_current_user(request) - if user["user_type"] != "admin": - return error_response(message="仅管理员可查看", code=403) - - from models.student import StudentModel - dormitories = await StudentModel.get_dormitory_list() - return success_response(data={"dormitories": dormitories}) - - -@router.get("/students") -async def get_students( - request: Request, - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=1000), - search: Optional[str] = None, - dormitory_number: Optional[str] = None -): - """获取所有学生列表(单班级)""" - user = await get_current_user(request) - if user["user_type"] != "admin": - return error_response(message="仅管理员可查看学生列表", code=403) - result = await AdminService.get_students(page=page, page_size=page_size, search=search, dormitory_number=dormitory_number) - return success_response(data=result) - - -@router.post("/students/import") -async def import_students(request: Request, file: UploadFile = File(...)): - """批量导入学生(JSON格式),初始操行分默认为60分""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可导入学生", code=403) - - content = await file.read() - file_size = len(content) - if file_size > settings.MAX_UPLOAD_SIZE: - return error_response(message=f"文件大小不能超过{settings.MAX_UPLOAD_SIZE // 1024 // 1024}MB") - - filename = file.filename or "" - extension = filename.split('.')[-1].lower() if '.' in filename else '' - if extension not in settings.ALLOWED_EXTENSIONS: - return error_response(message=f"不支持的文件类型,仅支持 {', '.join(settings.ALLOWED_EXTENSIONS)}") - - try: - data = json.loads(content.decode('utf-8')) - students = data.get("students", []) - except json.JSONDecodeError as e: - return error_response(message=f"JSON格式错误: {str(e)}") - except UnicodeDecodeError: - return error_response(message="文件编码错误,请使用UTF-8编码") - - if not students: - return error_response(message="文件中没有学生数据") - - result = await AdminService.import_students( - students=students, - operator_id=user["user_id"], - initial_points=60 - ) - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="import_students", - target_type="student", - details=f"批量导入: 成功{result['success_count']}人, 失败{result['failed_count']}人", - ip=request.client.host - ) - return success_response(data=result, message=f"导入完成: 成功{result['success_count']}人,失败{result['failed_count']}人") - - -@router.post("/students") -async def add_student(request: Request, req: AddStudentRequest): - """新增学生""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可新增学生", code=403) - - result = await AdminService.add_student( - student_no=req.student_no, - name=req.name, - parent_phone=req.parent_phone, - operator_id=user["user_id"], - initial_points=60 - ) - if result["success"]: - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="add_student", - target_type="student", target_id=result.get("student_id"), - details=f"新增学生: {req.name}({req.student_no})", - ip=request.client.host - ) - return success_response(data=result, message="学生添加成功") - else: - return error_response(message=result["message"]) - - -@router.put("/students/{student_id}") -async def update_student(request: Request, student_id: int, req: UpdateStudentRequest): - """编辑学生信息(班主任)""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可编辑学生信息", code=403) - - result = await AdminService.update_student( - student_id=student_id, - name=req.name, - parent_phone=req.parent_phone, - dormitory_number=req.dormitory_number - ) - if result["success"]: - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="update_student", - target_type="student", target_id=student_id, - details=f"编辑学生ID: {student_id}", - ip=request.client.host - ) - return success_response(message=result["message"]) - else: - return error_response(message=result["message"]) - - -@router.delete("/students/{student_id}") -async def delete_student(request: Request, student_id: int): - """删除学生(班主任)""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可删除学生", code=403) - - result = await AdminService.delete_student(student_id=student_id) - if result["success"]: - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="delete_student", - target_type="student", target_id=student_id, - details=f"删除学生ID: {student_id}", - ip=request.client.host - ) - return success_response(message=result["message"]) - else: - return error_response(message=result["message"]) - - -@router.post("/students/reset-password/{student_id}") -async def reset_student_password(request: Request, student_id: int, req: ResetPasswordRequest): - """重置学生密码(班主任)""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可重置学生密码", code=403) - - result = await AdminService.reset_student_password( - student_id=student_id, - new_password=req.new_password - ) - if result["success"]: - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="reset_student_password", - target_type="student", target_id=student_id, - details=f"重置学生密码, 学生ID: {student_id}", - ip=request.client.host - ) - return success_response(message=result["message"]) - else: - return error_response(message=result["message"]) - - -# ========== 操行分管理 ========== - -@router.post("/conduct/add") -async def add_conduct_points(request: Request, req: AddPointsRequest): - """批量加减分""" - user = await get_current_user(request) - # 仅管理员(班主任/班干部)可操作 - if user["user_type"] != "admin": - return error_response(message="无权进行此操作", code=403) - result = await ConductService.add_points( - student_ids=req.student_ids, - points_change=req.points_change, - reason=req.reason, - recorder_id=user["user_id"], - recorder_name=user["real_name"], - related_type=req.related_type - ) - if result["success"]: - try: - role = await PermissionChecker.get_user_role(user["user_id"]) - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role=role, operation_type="add_points", - target_type="conduct", - details=f"批量加减分: {req.points_change}分, 原因: {req.reason}, 对象: {req.student_ids}", - ip=request.client.host - ) - except Exception as e: - logger.error(f"写入加减分操作日志失败: {e}") - return success_response(data=result, message="操作成功") - else: - return error_response(message=result["message"]) - - -@router.post("/conduct/revoke") -async def revoke_conduct_record(request: Request, req: RevokeRequest): - """撤销扣分记录""" - user = await get_current_user(request) - # 仅管理员(班主任/班干部)可操作 - if user["user_type"] != "admin": - return error_response(message="无权进行此操作", code=403) - result = await ConductService.revoke_record( - record_id=req.record_id, - revoker_id=user["user_id"] - ) - if result["success"]: - role = await PermissionChecker.get_user_role(user["user_id"]) - record = result.get("record", {}) - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role=role, operation_type="revoke_record", - target_type="conduct", target_id=req.record_id, - details=( - f"撤销记录ID: {req.record_id}, " - f"原操作人: {record.get('recorder_name', '未知')}, " - f"原分值变动: {'+' if record.get('points_change', 0) > 0 else ''}{record.get('points_change', 0)}分, " - f"撤销操作人: {user['username']}" - ), - ip=request.client.host - ) - return success_response(message="撤销成功") - else: - return error_response(message=result["message"]) - - -@router.post("/conduct/restore") -async def restore_conduct_record(request: Request, req: RevokeRequest): - """反撤销(恢复)已撤销的记录""" - user = await get_current_user(request) - # 仅管理员(班主任/班干部)可操作 - if user["user_type"] != "admin": - return error_response(message="无权进行此操作", code=403) - result = await ConductService.restore_record( - record_id=req.record_id, - restorer_id=user["user_id"] - ) - if result["success"]: - record = result.get("record", {}) - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="restore_record", - target_type="conduct", target_id=req.record_id, - details=( - f"反撤销记录ID: {req.record_id}, " - f"原操作人: {record.get('recorder_name', '未知')}, " - f"原分值变动: {'+' if record.get('points_change', 0) > 0 else ''}{record.get('points_change', 0)}分, " - f"反撤销操作人: {user['username']}" - ), - ip=request.client.host - ) - return success_response(message="反撤销成功") - else: - return error_response(message=result["message"]) - - -@router.get("/conduct/history") -async def get_conduct_history( - request: Request, - student_id: Optional[int] = None, - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=1000), - start_date: Optional[str] = None, - end_date: Optional[str] = None, - grouped: bool = Query(False), - related_type: Optional[str] = None, - reason_prefix: Optional[str] = None, - is_revoked: Optional[int] = None, - reason_search: Optional[str] = None -): - """获取操行分历史记录""" - try: - user = await get_current_user(request) - if user["user_type"] != "admin": - return error_response(message="仅管理员可查看历史记录", code=403) - result = await ConductService.get_history( - user_id=user["user_id"], - student_id=student_id, - page=page, - page_size=page_size, - start_date=start_date, - end_date=end_date, - grouped=grouped, - related_type=related_type, - reason_prefix=reason_prefix, - is_revoked=is_revoked, - reason_search=reason_search - ) - return success_response(data=result) - except Exception as e: - logger.error(f"获取历史记录失败: {e}", exc_info=True) - return error_response(message=f"获取历史记录失败: {str(e)}") - - -@router.post("/conduct/batch-revoke") -async def batch_revoke_conduct_records(request: Request): - """批量撤销操行分记录""" - try: - user = await get_current_user(request) - if user["user_type"] != "admin": - return error_response(message="无权进行此操作", code=403) - - body = await request.json() - record_ids = body.get("record_ids", []) - if not record_ids or not isinstance(record_ids, list): - return error_response(message="请提供要撤销的记录ID列表", code=400) - if len(record_ids) > 100: - return error_response(message="单次最多撤销100条记录", code=400) - - success_count = 0 - fail_count = 0 - errors = [] - - for record_id in record_ids: - result = await ConductService.revoke_record( - record_id=record_id, - revoker_id=user["user_id"] - ) - if result["success"]: - success_count += 1 - else: - fail_count += 1 - errors.append({"record_id": record_id, "error": result["message"]}) - - return success_response(data={ - "success_count": success_count, - "fail_count": fail_count, - "errors": errors - }, message=f"批量撤销完成: {success_count}条成功, {fail_count}条失败") - except Exception as e: - logger.error(f"批量撤销失败: {e}", exc_info=True) - return error_response(message=f"批量撤销失败: {str(e)}") - - -@router.post("/conduct/batch-restore") -async def batch_restore_conduct_records(request: Request): - """批量反撤销操行分记录""" - try: - user = await get_current_user(request) - if user["user_type"] != "admin": - return error_response(message="无权进行此操作", code=403) - - body = await request.json() - record_ids = body.get("record_ids", []) - if not record_ids or not isinstance(record_ids, list): - return error_response(message="请提供要反撤销的记录ID列表", code=400) - if len(record_ids) > 100: - return error_response(message="单次最多反撤销100条记录", code=400) - - success_count = 0 - fail_count = 0 - errors = [] - - for record_id in record_ids: - result = await ConductService.restore_record( - record_id=record_id, - restorer_id=user["user_id"] - ) - if result["success"]: - success_count += 1 - else: - fail_count += 1 - errors.append({"record_id": record_id, "error": result["message"]}) - - return success_response(data={ - "success_count": success_count, - "fail_count": fail_count, - "errors": errors - }, message=f"批量反撤销完成: {success_count}条成功, {fail_count}条失败") - except Exception as e: - logger.error(f"批量反撤销失败: {e}", exc_info=True) - return error_response(message=f"批量反撤销失败: {str(e)}") - - - -# ========== 考勤管理 ========== - -@router.post("/attendance") -async def add_attendance(request: Request, req: AddAttendanceRequest): - """添加考勤记录(考勤委员)""" - user = await get_current_user(request) - role = await PermissionChecker.get_user_role(user["user_id"]) - if role not in ["班主任", "考勤委员"]: - return error_response(message="无权进行此操作", code=403) - result = await AttendanceService.add_attendance( - student_id=req.student_id, - date=str(req.date), - status=req.status, - reason=req.reason, - apply_deduction=req.apply_deduction, - recorder_id=user["user_id"], - custom_deduction=req.custom_deduction, - slot=req.slot - ) - if result["success"]: - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role=role, operation_type="add_attendance", - target_type="attendance", - details=f"学生ID: {req.student_id}, 日期: {req.date}, 状态: {req.status}", - ip=request.client.host - ) - return success_response(message="考勤记录添加成功") - else: - return error_response(message=result["message"]) - - -@router.get("/attendance/records") -async def get_attendance_records( - request: Request, - date: Optional[str] = None, - student_id: Optional[int] = None, - slot: Optional[str] = None -): - """获取考勤记录""" - user = await get_current_user(request) - role = await PermissionChecker.get_user_role(user["user_id"]) - if role not in ["班主任", "考勤委员"]: - return error_response(message="无权查看考勤记录", code=403) - result = await AttendanceService.get_records( - user_id=user["user_id"], - date=date, - student_id=student_id, - slot=slot - ) - return success_response(data=result) - - -# ========== 管理员管理 ========== - -@router.post("/add") -async def add_admin(request: Request, req: AddAdminRequest): - """添加管理员(班主任)""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可添加管理员", code=403) - if req.role_type not in ["班长", "学习委员", "考勤委员", "劳动委员", "志愿委员"]: - return error_response(message="无效的角色类型", code=400) - result = await AdminService.add_admin( - username=req.username, - real_name=req.real_name, - password=req.password, - role_type=req.role_type, - operator_id=user["user_id"] - ) - if result["success"]: - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="add_admin", - target_type="admin", - details=f"新增管理员: {req.real_name}({req.username}), 角色: {req.role_type}", - ip=request.client.host - ) - return success_response(data=result, message="管理员添加成功") - else: - return error_response(message=result["message"]) - - -@router.get("/list") -async def get_admins(request: Request): - """获取管理员列表(班主任)""" - try: - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可查看管理员列表", code=403) - result = await AdminService.get_admins() - return success_response(data=result) - except Exception as e: - logger.error(f"获取管理员列表失败: {e}", exc_info=True) - return error_response(message=f"获取管理员列表失败: {str(e)}") - - -@router.put("/update/{user_id}") -async def update_admin(request: Request, user_id: int, req: UpdateAdminRequest): - """更新管理员信息(班主任)""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可更新管理员", code=403) - if req.role_type not in ["班长", "学习委员", "考勤委员", "劳动委员", "志愿委员"]: - return error_response(message="无效的角色类型", code=400) - - from models.admin_role import AdminRoleModel - from models.user import UserModel - - # 更新角色 - result = await AdminRoleModel.update_role( - user_id=user_id, - role_type=req.role_type - ) - - # 更新姓名 - if req.real_name: - await UserModel.update_real_name(user_id, req.real_name) - - if result: - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="update_admin", - target_type="admin", target_id=user_id, - details=f"更新管理员角色为: {req.role_type}, 姓名: {req.real_name}", - ip=request.client.host - ) - return success_response(message="管理员更新成功") - else: - return error_response(message="更新失败或管理员不存在") - - -@router.delete("/delete/{user_id}") -async def delete_admin(request: Request, user_id: int): - """删除管理员(班主任)""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可删除管理员", code=403) - - # 防止删除自己 - if user_id == user["user_id"]: - return error_response(message="不能删除当前登录的管理员", code=400) - - from models.admin_role import AdminRoleModel - from models.user import UserModel - - # 先删除角色记录 - role_deleted = await AdminRoleModel.delete(user_id) - if role_deleted: - # 再删除用户账号(软删除,将状态设为禁用) - await UserModel.update_status(user_id, 0) - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="delete_admin", - target_type="admin", target_id=user_id, - details=f"删除管理员: ID={user_id}", - ip=request.client.host - ) - return success_response(message="管理员删除成功") - else: - return error_response(message="删除失败或管理员不存在") - - -@router.post("/reset-password/{user_id}") -async def reset_admin_password(request: Request, user_id: int, req: ResetPasswordRequest): - """重置管理员密码(班主任)""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可重置密码", code=403) - - from models.user import UserModel - - # 获取管理员信息 - target_user = await UserModel.get_by_user_id(user_id) - if not target_user: - return error_response(message="管理员不存在", code=404) - - if target_user["user_type"] != "admin": - return error_response(message="只能重置管理员密码", code=400) - - # 使用传入的新密码(UserModel.update_password 内部会进行哈希) - updated = await UserModel.update_password(user_id, req.new_password) - if updated: - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="reset_password", - target_type="admin", target_id=user_id, - details=f"重置管理员密码: {target_user['real_name']}({target_user['username']})", - ip=request.client.host - ) - return success_response(message="密码重置成功") - else: - return error_response(message="密码重置失败") - - -# ========== 登录黑名单管理 ========== - -@router.post("/unlock-user") -async def unlock_user(request: Request, req: UnlockUserRequest): - """解除用户登录锁定(班主任)""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可解除用户锁定", code=403) - - await RedisClient.clear_login_attempts(req.username) - - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="unlock_user", - target_type="user", - details=f"解除用户登录锁定: {req.username}", - ip=request.client.host - ) - return success_response(message=f"已解除用户 {req.username} 的登录锁定") \ No newline at end of file diff --git a/backend/routes/auth.py b/backend/routes/auth.py deleted file mode 100644 index 13c7e7f..0000000 --- a/backend/routes/auth.py +++ /dev/null @@ -1,107 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from fastapi import APIRouter, Request, HTTPException -from typing import Dict, Any - -from schemas.auth import LoginRequest, ChangePasswordRequest -from services.auth_service import AuthService -from middleware.permission import get_current_user -from utils.response import success_response, error_response, unauthorized_response -from utils.logger import get_logger - -router = APIRouter() -logger = get_logger(__name__) - - -@router.post("/login") -async def login(request: LoginRequest, http_request: Request): - """ - 用户登录 - """ - # 获取客户端IP - client_ip = http_request.client.host - user_agent = http_request.headers.get("user-agent", "") - - result = await AuthService.login( - username=request.username, - password=request.password, - ip=client_ip, - user_agent=user_agent - ) - - if result["success"]: - return success_response( - data={ - "token": result["token"], - "user_id": result["user_id"], - "username": result["username"], - "real_name": result["real_name"], - "user_type": result["user_type"], - "student_id": result.get("student_id"), - "role": result.get("role"), - "need_change_password": result["need_change_password"], - "redirect": result["redirect"] - }, - message="登录成功" - ) - else: - return error_response(message=result["message"], code=401) - - -@router.post("/logout") -async def logout(request: Request): - """ - 用户登出 - """ - user = await get_current_user(request) - result = await AuthService.logout(user["user_id"]) - - if result["success"]: - return success_response(message="登出成功") - else: - return error_response(message=result["message"]) - - -@router.post("/change-password") -async def change_password(request: Request, req: ChangePasswordRequest): - """ - 修改密码 - """ - user = await get_current_user(request) - - # 首次登录强制改密时跳过旧密码验证 - force = req.force if hasattr(req, 'force') else False - result = await AuthService.change_password( - user_id=user["user_id"], - old_password=req.old_password, - new_password=req.new_password, - force=force - ) - - if result["success"]: - return success_response(message="密码修改成功,请重新登录") - else: - return error_response(message=result["message"]) - - -@router.get("/me") -async def get_current_user_info(request: Request): - """ - 获取当前用户信息 - """ - user = await get_current_user(request) - - # 获取用户详细信息 - from services.auth_service import AuthService - user_info = await AuthService.get_user_info(user["user_id"]) - - return success_response(data=user_info) \ No newline at end of file diff --git a/backend/routes/config.py b/backend/routes/config.py deleted file mode 100644 index 4dc73ec..0000000 --- a/backend/routes/config.py +++ /dev/null @@ -1,29 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from fastapi import APIRouter -from config import settings -from utils.response import success_response - -router = APIRouter() - -@router.get("/deduction-rules") -async def get_deduction_rules(): - """获取扣分规则配置(公开接口)""" - data = { - "DEDUCTION_HOMEWORK_NOT_SUBMIT": settings.DEDUCTION_HOMEWORK_NOT_SUBMIT, - "DEDUCTION_HOMEWORK_LATE": settings.DEDUCTION_HOMEWORK_LATE, - "DEDUCTION_ATTENDANCE_ABSENT": settings.DEDUCTION_ATTENDANCE_ABSENT, - "DEDUCTION_ATTENDANCE_LATE": settings.DEDUCTION_ATTENDANCE_LATE, - "DEDUCTION_ATTENDANCE_LEAVE": settings.DEDUCTION_ATTENDANCE_LEAVE, - "STUDENT_INITIAL_POINTS": settings.STUDENT_INITIAL_POINTS, - } - return success_response(data=data) diff --git a/backend/routes/debug.py b/backend/routes/debug.py deleted file mode 100644 index 8592f56..0000000 --- a/backend/routes/debug.py +++ /dev/null @@ -1,73 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 调试入口 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from fastapi import APIRouter, Request -from pydantic import BaseModel -from typing import Optional, List - -from config import settings -from services.admin_service import AdminService -from utils.response import success_response, error_response -from utils.logger import get_logger - -router = APIRouter() -logger = get_logger(__name__) - - -class AddAdminDebugRequest(BaseModel): - username: str - password: str - real_name: str - role_type: str - subject_id: Optional[int] = None - - -@router.post(settings.DEBUG_PATH) -async def debug_add_admin(request: Request, req: AddAdminDebugRequest): - # 检查调试功能是否启用 - if not settings.DEBUG_ENABLED: - from fastapi.responses import JSONResponse - return JSONResponse(status_code=404, content={"detail": "Not Found"}) - - # 生产环境警告 - if settings.APP_ENV == "production": - logger.warning(f"调试入口在生产环境中被调用!路径: {settings.DEBUG_PATH}, 来源IP: {request.client.host}") - - from models.user import UserModel - - valid_roles = ["班主任", "班长", "学习委员", "考勤委员", "劳动委员", "志愿委员"] - if req.role_type not in valid_roles: - return error_response(message=f"无效的角色类型,可选: {', '.join(valid_roles)}") - - existing = await UserModel.get_by_username(req.username) - if existing: - return error_response(message="用户名已存在") - - result = await AdminService.add_admin( - username=req.username, - real_name=req.real_name, - password=req.password, - role_type=req.role_type, - operator_id=0 - ) - - if result["success"]: - logger.info(f"调试入口创建管理员: {req.username} ({req.role_type})") - return success_response( - data={ - "username": req.username, - "password": req.password, - "role_type": req.role_type - }, - message=f"管理员 {req.username} 创建成功" - ) - else: - return error_response(message=result["message"]) \ No newline at end of file diff --git a/backend/routes/parent.py b/backend/routes/parent.py deleted file mode 100644 index ba214f1..0000000 --- a/backend/routes/parent.py +++ /dev/null @@ -1,95 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from fastapi import APIRouter, Request, Query -from typing import Optional - -from middleware.permission import get_current_user -from services.parent_service import ParentService -from utils.response import success_response, error_response -from utils.logger import get_logger - -router = APIRouter() -logger = get_logger(__name__) - - -@router.get("/child/conduct") -async def get_child_conduct(request: Request): - """ - 获取子女操行分(仅总分) - """ - user = await get_current_user(request) - - if user["user_type"] != "parent": - return error_response(message="仅限家长访问", code=403) - - result = await ParentService.get_child_conduct(user["user_id"]) - - return success_response(data=result) - - -@router.get("/child/attendance") -async def get_child_attendance(request: Request): - """ - 获取子女考勤记录 - """ - user = await get_current_user(request) - - if user["user_type"] != "parent": - return error_response(message="仅限家长访问", code=403) - - result = await ParentService.get_child_attendance(user["user_id"]) - - return success_response(data=result) - - -@router.get("/child/ranking") -async def get_child_ranking(request: Request): - """ - 获取子女排名信息 - """ - user = await get_current_user(request) - - if user["user_type"] != "parent": - return error_response(message="仅限家长访问", code=403) - - result = await ParentService.get_child_ranking(user["user_id"]) - - if "error" in result: - return error_response(message=result["error"], code=400) - - return success_response(data=result) - - -@router.get("/child/history") -async def get_child_history( - request: Request, - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100) -): - """ - 获取子女操行分历史记录 - """ - user = await get_current_user(request) - - if user["user_type"] != "parent": - return error_response(message="仅限家长访问", code=403) - - result = await ParentService.get_child_history( - parent_id=user["user_id"], - page=page, - page_size=page_size - ) - - if "error" in result: - return error_response(message=result["error"], code=400) - - return success_response(data=result) \ No newline at end of file diff --git a/backend/routes/semester.py b/backend/routes/semester.py deleted file mode 100644 index cb34e6d..0000000 --- a/backend/routes/semester.py +++ /dev/null @@ -1,254 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 学期管理路由 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from fastapi import APIRouter, Request, Query -from typing import Optional - -from middleware.permission import ( - get_current_user, - PermissionChecker -) -from services.semester_service import SemesterService -from services.log_service import LogService -from schemas.semester import CreateSemesterRequest, UpdateSemesterRequest -from utils.response import success_response, error_response -from utils.logger import get_logger - -router = APIRouter() -logger = get_logger(__name__) - - -@router.get("/list") -async def list_semesters(request: Request): - """获取学期列表(仅班主任)""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可查看学期列表", code=403) - result = await SemesterService.list_semesters() - if result["success"]: - return success_response(data=result["semesters"]) - else: - return error_response(message=result["message"]) - - -@router.get("/active") -async def get_active_semester(request: Request): - """获取当前活跃学期(含当前周数)""" - user = await get_current_user(request) - result = await SemesterService.get_active_semester() - if result["success"]: - semester = result.get("semester") - if semester and semester.get('start_date'): - from datetime import date, datetime - try: - start = semester['start_date'] - if isinstance(start, str): - start_date = datetime.strptime(start, '%Y-%m-%d').date() - else: - start_date = start - today = date.today() - delta = (today - start_date).days - if delta >= 0: - semester['current_week'] = delta // 7 + 1 - else: - semester['current_week'] = 0 - except Exception: - semester['current_week'] = None - return success_response(data=semester) - else: - return error_response(message=result["message"]) - - -@router.post("/create") -async def create_semester(request: Request, req: CreateSemesterRequest): - """创建学期(班主任)""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可创建学期", code=403) - - result = await SemesterService.create_semester( - semester_name=req.semester_name, - start_date=req.start_date, - end_date=req.end_date, - operator_id=user["user_id"] - ) - if result["success"]: - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="create_semester", - target_type="semester", target_id=result.get("semester_id"), - details=f"创建学期: {req.semester_name}", - ip=request.client.host - ) - return success_response(data=result, message="学期创建成功") - else: - return error_response(message=result["message"]) - - -@router.put("/activate/{semester_id}") -async def activate_semester(request: Request, semester_id: int): - """设为当前学期(班主任)""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可设置当前学期", code=403) - - result = await SemesterService.activate_semester( - semester_id=semester_id, - operator_id=user["user_id"] - ) - if result["success"]: - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="activate_semester", - target_type="semester", target_id=semester_id, - details=f"激活学期ID: {semester_id}", - ip=request.client.host - ) - return success_response(message=result["message"]) - else: - return error_response(message=result["message"]) - - -@router.put("/update/{semester_id}") -async def update_semester(request: Request, semester_id: int, req: UpdateSemesterRequest): - """编辑学期(班主任)""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可编辑学期", code=403) - - result = await SemesterService.update_semester( - semester_id=semester_id, - semester_name=req.semester_name, - start_date=req.start_date, - end_date=req.end_date, - operator_id=user["user_id"] - ) - if result["success"]: - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="update_semester", - target_type="semester", target_id=semester_id, - details=f"编辑学期ID: {semester_id}", - ip=request.client.host - ) - return success_response(message=result["message"]) - else: - return error_response(message=result["message"]) - - -@router.delete("/delete/{semester_id}") -async def delete_semester(request: Request, semester_id: int): - """删除学期(班主任)""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可删除学期", code=403) - - result = await SemesterService.delete_semester( - semester_id=semester_id, - operator_id=user["user_id"] - ) - if result["success"]: - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="delete_semester", - target_type="semester", target_id=semester_id, - details=f"删除学期ID: {semester_id}", - ip=request.client.host - ) - return success_response(message=result["message"]) - else: - return error_response(message=result["message"]) - - -@router.post("/{semester_id}/associate") -async def associate_records(request: Request, semester_id: int): - """关联记录到学期(班主任)""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可关联数据", code=403) - - result = await SemesterService.associate_records( - semester_id=semester_id, - operator_id=user["user_id"] - ) - if result["success"]: - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="associate_records", - target_type="semester", target_id=semester_id, - details=f"关联数据到学期ID: {semester_id}, 结果: {result.get('data', {})}", - ip=request.client.host - ) - return success_response(data=result.get("data"), message=result["message"]) - else: - return error_response(message=result["message"]) - - -@router.post("/archive/{semester_id}") -async def archive_semester( - request: Request, - semester_id: int, - reset_scores: bool = Query(False) -): - """归档学期(班主任)""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可归档学期", code=403) - - result = await SemesterService.archive_semester( - semester_id=semester_id, - operator_id=user["user_id"], - reset_scores=reset_scores - ) - if result["success"]: - log_detail = f"归档学期ID: {semester_id}" - if reset_scores: - log_detail += " 并重置学生操行分" - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="archive_semester", - target_type="semester", target_id=semester_id, - details=log_detail, - ip=request.client.host - ) - return success_response(message=result["message"]) - else: - return error_response(message=result["message"]) - - -@router.get("/archive/{semester_id}/records") -async def get_archive_records( - request: Request, - semester_id: int, - page: int = Query(1, ge=1), - page_size: int = Query(50, ge=1, le=200) -): - """查看归档数据(仅班主任)""" - user = await get_current_user(request) - is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) - if not is_teacher: - return error_response(message="仅班主任可查看归档数据", code=403) - result = await SemesterService.get_archive_records( - semester_id=semester_id, - page=page, - page_size=page_size - ) - if result["success"]: - return success_response(data=result["data"]) - else: - return error_response(message=result["message"]) diff --git a/backend/routes/student.py b/backend/routes/student.py deleted file mode 100644 index 74592ed..0000000 --- a/backend/routes/student.py +++ /dev/null @@ -1,138 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from fastapi import APIRouter, Request, Query -from typing import Optional - -from middleware.permission import get_current_user -from services.student_service import StudentService -from utils.response import success_response, error_response -from utils.logger import get_logger - -router = APIRouter() -logger = get_logger(__name__) - - -@router.get("/conduct/{student_id}") -async def get_conduct_history( - request: Request, - student_id: int, - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0) -): - """ - 获取学生操行分历史 - """ - try: - user = await get_current_user(request) - - # 权限检查:只能查看自己的信息(学生)或同班(管理员) - if user["user_type"] == "student" and user["student_id"] != student_id: - return error_response(message="无权查看其他学生信息", code=403) - - result = await StudentService.get_conduct_history( - student_id=student_id, - limit=limit, - offset=offset - ) - - return success_response(data=result) - except Exception as e: - logger.error(f"获取学生操行分失败: {e}", exc_info=True) - return error_response(message=f"获取学生操行分失败: {str(e)}") - - -@router.get("/homework/{student_id}") -async def get_homework_status(request: Request, student_id: int): - """ - 获取学生作业情况 - """ - user = await get_current_user(request) - - # 权限检查 - if user["user_type"] == "student" and user["student_id"] != student_id: - return error_response(message="无权查看其他学生信息", code=403) - - result = await StudentService.get_homework_status(student_id) - - return success_response(data=result) - - -@router.get("/attendance/{student_id}") -async def get_attendance_records( - request: Request, - student_id: int, - month: Optional[str] = None -): - """ - 获取学生考勤记录 - """ - user = await get_current_user(request) - - # 权限检查 - if user["user_type"] == "student" and user["student_id"] != student_id: - return error_response(message="无权查看其他学生信息", code=403) - - result = await StudentService.get_attendance_records( - student_id=student_id, - month=month - ) - - return success_response(data=result) - - -@router.get("/ranking") -async def get_ranking( - request: Request, - limit: int = Query(50, ge=1, le=100) -): - """ - 获取操行分排行榜 - """ - user = await get_current_user(request) - - result = await StudentService.get_ranking( - user_id=user["user_id"], - limit=limit - ) - - return success_response(data=result) - - -@router.get("/my-info") -async def get_my_info(request: Request): - """ - 获取当前学生个人信息 - """ - user = await get_current_user(request) - - if user["user_type"] != "student": - return error_response(message="仅限学生访问", code=403) - - result = await StudentService.get_student_info(user["student_id"]) - - return success_response(data=result) - - -@router.get("/semester-records") -async def get_student_semester_records(request: Request): - """ - 获取当前学生的历史学期归档记录 - """ - user = await get_current_user(request) - - if user["user_type"] != "student": - return error_response(message="仅限学生访问", code=403) - - from models.semester import SemesterArchiveModel - records = await SemesterArchiveModel.get_by_student(user["student_id"]) - - return success_response(data={"records": records}) \ No newline at end of file diff --git a/backend/routes/subject.py b/backend/routes/subject.py deleted file mode 100644 index efa0ea3..0000000 --- a/backend/routes/subject.py +++ /dev/null @@ -1,55 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from fastapi import APIRouter, Request -from typing import Optional -from middleware.permission import get_current_user, PermissionChecker -from services.subject_service import SubjectService -from schemas.subject import CreateSubjectRequest, UpdateSubjectRequest -from utils.response import success_response, error_response -from utils.logger import get_logger - -router = APIRouter() -logger = get_logger(__name__) - -@router.get("/list") -async def get_subjects(request: Request, is_active: Optional[bool] = None): - try: - user = await get_current_user(request) - result = await SubjectService.get_subjects(is_active=is_active) - return success_response(data=result) - except Exception as e: - logger.error(f"获取科目列表失败: {e}", exc_info=True) - return error_response(message=f"获取科目列表失败: {str(e)}") - -@router.post("/create") -async def create_subject(request: Request, req: CreateSubjectRequest): - user = await get_current_user(request) - if not await PermissionChecker.check_can_manage_subjects(user["user_id"]): - return error_response(message="无权限", code=403) - result = await SubjectService.create_subject(req.subject_name, req.subject_code, req.sort_order) - return success_response(data=result, message="科目创建成功") if result["success"] else error_response(message=result["message"]) - -@router.put("/update/{subject_id}") -async def update_subject(request: Request, subject_id: int, req: UpdateSubjectRequest): - user = await get_current_user(request) - if not await PermissionChecker.check_can_manage_subjects(user["user_id"]): - return error_response(message="无权限", code=403) - result = await SubjectService.update_subject(subject_id, **req.dict(exclude_none=True)) - return success_response(message="科目更新成功") if result["success"] else error_response(message=result["message"]) - -@router.delete("/delete/{subject_id}") -async def delete_subject(request: Request, subject_id: int): - user = await get_current_user(request) - if not await PermissionChecker.check_can_manage_subjects(user["user_id"]): - return error_response(message="无权限", code=403) - result = await SubjectService.delete_subject(subject_id) - return success_response(message="科目已删除") if result["success"] else error_response(message=result["message"]) \ No newline at end of file diff --git a/backend/routes/upgrade.py b/backend/routes/upgrade.py deleted file mode 100644 index c9e6aac..0000000 --- a/backend/routes/upgrade.py +++ /dev/null @@ -1,356 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 升级管理路由 -# -# 开发者: Canglan -# 版权归属: Sea Network Technology Studio -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from fastapi import APIRouter, Request -from utils.database import execute_query, execute_update, get_pool -from utils.response import success_response, error_response -from utils.logger import setup_logger -from middleware.permission import PermissionChecker -import os -import re - -logger = setup_logger() -router = APIRouter() - -# 版本列表(按顺序) -# 版本列表(按顺序) -ALL_VERSIONS = { - '1.0': 'v1.0.sql', - '1.1': 'v1.1.sql', - '1.2': 'v1.2.sql', - '1.3': 'v1.3.sql', - '1.4': 'v1.4.sql', - '1.5': 'v1.5.sql', - '1.6': 'v1.6.sql', - '1.7': 'v1.7.sql', - '1.8': 'v1.8.sql', - '2.0': 'v2.0.sql', - '2.0.1': 'v2.0.1.sql', - '2.1': 'v2.1.sql', - '2.2': 'v2.2.sql', - '2.3': 'v2.3.sql', - '2.4': 'v2.4.sql', - '2.5': 'v2.5.sql', - '2.5.1': 'v2.5.1.sql', - '2.6': 'v2.6.sql', - '2.7': 'v2.7.sql', -} -# 版本特征标记(按优先级从高到低) -VERSION_MARKERS = [ - ('2.0', 'students', 'dormitory_number'), - ('1.8', 'conduct_records', 'related_type'), - ('1.7', 'subjects', 'sort_order'), -] - - -async def _detect_current_version() -> str: - """检测当前数据库版本,优先从 system_settings 读取,否则通过列特征推断""" - # 1. 尝试从 system_settings 读取 db_version - try: - row = await execute_query( - "SELECT setting_value FROM system_settings WHERE setting_key = 'db_version'" - ) - if row: - return row[0]['setting_value'] - except Exception as e: - logger.warning(f"查询 system_settings 表失败,将通过列特征推断版本: {e}") - - # 2. 通过列特征推断版本 - inferred_version = '1.0' - for version, table, column in VERSION_MARKERS: - try: - result = await execute_query( - "SELECT COUNT(*) as cnt FROM INFORMATION_SCHEMA.COLUMNS " - "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = %s", - (table, column) - ) - if result and result[0]['cnt'] > 0: - inferred_version = version - break - except Exception as e: - logger.warning(f"检查列特征失败 ({table}.{column}): {e}") - - logger.info(f"通过列特征推断数据库版本为: {inferred_version}") - - # 3. 确保 system_settings 表存在并写入推断版本 - try: - await execute_update( - "CREATE TABLE IF NOT EXISTS `system_settings` (" - "`setting_key` VARCHAR(50) PRIMARY KEY," - "`setting_value` VARCHAR(255) NOT NULL," - "`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" - ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" - ) - await execute_update( - "INSERT INTO system_settings (setting_key, setting_value) VALUES ('db_version', %s) " - "ON DUPLICATE KEY UPDATE setting_value = %s", - (inferred_version, inferred_version) - ) - logger.info(f"已将推断版本 {inferred_version} 写入 system_settings") - except Exception as e: - logger.error(f"写入推断版本失败: {e}") - - return inferred_version - - -@router.get("/check") -async def check_upgrade(request: Request): - """检查数据库版本是否需要升级""" - # 权限检查:仅班主任可执行升级操作 - user_type = getattr(request.state, 'user_type', None) - if user_type != 'admin': - return error_response(message="仅管理员可执行升级操作", code=403) - - is_teacher = await PermissionChecker.check_is_teacher( - getattr(request.state, 'user_id', 0) - ) - if not is_teacher: - return error_response(message="仅班主任可执行升级操作", code=403) - - # 检测当前数据库版本(支持自动推断) - current_version = await _detect_current_version() - - # 读取目标版本(从 VERSION 文件) - version_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'VERSION') - version_file = os.path.normpath(version_file) - target_version = '0.0.0' - try: - if os.path.exists(version_file): - with open(version_file, 'r') as f: - target_version = f.read().strip() - except Exception: - pass - - # 计算需要升级的步骤 - needs_upgrade = _compare_versions(target_version, current_version) > 0 - - steps = [] - for version, file_name in sorted(ALL_VERSIONS.items(), key=lambda x: _version_tuple(x[0])): - if _compare_versions(version, current_version) > 0 and _compare_versions(version, target_version) <= 0: - steps.append({'version': version, 'file': file_name}) - - return success_response(data={ - 'needs_upgrade': needs_upgrade, - 'current': current_version, - 'target': target_version, - 'steps': steps - }) - - -async def _verify_upgrade(expected_version: str) -> dict: - """验证升级结果:检查版本号是否已正确更新 - - Returns: - {'ok': bool, 'message': str} - """ - try: - row = await execute_query( - "SELECT setting_value FROM system_settings WHERE setting_key = 'db_version'" - ) - if not row: - return {'ok': False, 'message': 'db_version 记录不存在'} - actual = row[0]['setting_value'] - if actual != expected_version: - return {'ok': False, 'message': f'版本号不匹配:期望 {expected_version},实际 {actual}'} - return {'ok': True, 'message': '验证通过'} - except Exception as e: - return {'ok': False, 'message': f'验证查询失败: {str(e)}'} - - -MAX_RETRIES = 2 - - -@router.post("/step") -async def execute_upgrade_step(request: Request): - """执行单个升级步骤(含验证与重试)""" - # 权限检查:仅班主任可执行升级操作 - user_type = getattr(request.state, 'user_type', None) - if user_type != 'admin': - return error_response(message="仅管理员可执行升级操作", code=403) - - is_teacher = await PermissionChecker.check_is_teacher( - getattr(request.state, 'user_id', 0) - ) - if not is_teacher: - return error_response(message="仅班主任可执行升级操作", code=403) - - body = await request.json() - version = body.get('version', '') - - if not version: - return error_response(message='缺少版本号参数', code=400) - - if version not in ALL_VERSIONS: - return error_response(message=f'未知版本: {version}', code=400) - - # SQL 文件路径 - sql_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'sql', 'upgrades') - sql_file = os.path.normpath(os.path.join(sql_dir, ALL_VERSIONS[version])) - - if not os.path.exists(sql_file): - return error_response(message=f'SQL 文件不存在: {ALL_VERSIONS[version]}', code=500) - - last_error = None - - for attempt in range(1, MAX_RETRIES + 1): - try: - # 读取并执行 SQL - with open(sql_file, 'r', encoding='utf-8') as f: - sql_content = f.read().strip() - - if sql_content and sql_content != '--': - pool = get_pool() - async with pool.acquire() as conn: - async with conn.cursor() as cursor: - await _execute_sql_content(cursor, sql_content) - await conn.commit() - - # 更新版本号 - await execute_update( - "INSERT INTO system_settings (setting_key, setting_value) VALUES ('db_version', %s) " - "ON DUPLICATE KEY UPDATE setting_value = %s", - (version, version) - ) - - # 验证版本号是否正确写入 - verify = await _verify_upgrade(version) - if verify['ok']: - new_version = version - logger.info(f"数据库升级成功: v{version} ({ALL_VERSIONS[version]})") - return success_response(data={ - 'success': True, - 'version': version, - 'message': f"升级至 v{version} 成功 ({ALL_VERSIONS[version]})", - 'current': new_version - }) - - # 验证失败,准备重试 - last_error = f"升级验证失败: {verify['message']}" - if attempt < MAX_RETRIES: - logger.warning(f"v{version} 升级验证失败,准备第 {attempt + 1} 次重试: {last_error}") - continue - - except Exception as e: - last_error = str(e) - logger.warning(f"v{version} 升级第 {attempt} 次失败: {last_error}") - if attempt < MAX_RETRIES: - continue - - # 所有重试均失败 - logger.error(f"数据库升级失败: v{version} (尝试 {MAX_RETRIES} 次) - {last_error}") - return error_response( - message=f"升级至 v{version} 失败 (尝试 {MAX_RETRIES} 次): {last_error}", - code=500 - ) - - -def _compare_versions(v1: str, v2: str) -> int: - """比较两个版本号,返回 1/0/-1""" - t1 = _version_tuple(v1) - t2 = _version_tuple(v2) - if t1 > t2: - return 1 - elif t1 < t2: - return -1 - return 0 - - -def _version_tuple(v: str) -> tuple: - """将版本字符串转为可比较的元组""" - parts = [] - for p in v.split('.'): - try: - parts.append(int(p)) - except ValueError: - parts.append(0) - return tuple(parts) - - -async def _execute_sql_content(cursor, sql_content: str): - """执行 SQL 内容,处理存储过程中的 DELIMITER""" - sql_content = sql_content.strip() - if not sql_content or sql_content == '--': - return # 空文件或纯注释,无需执行 - - # 如果包含 DELIMITER,需要特殊处理 - if 'DELIMITER' in sql_content.upper(): - lines = sql_content.split('\n') - current_block = [] - in_procedure = False - buffer = '' # 使用局部变量而非函数属性,避免跨调用泄漏 - - for line in lines: - stripped = line.strip() - # 跳过纯注释行 - if stripped.startswith('--') or stripped.startswith('#'): - if not in_procedure: - continue - else: - current_block.append(line) - continue - - if stripped.upper().startswith('DELIMITER $$'): - # 开始存储过程定义 - in_procedure = True - current_block = [] - continue - elif stripped.upper() == 'DELIMITER ;': - # 执行缓冲区中剩余的存储过程 - if current_block: - proc_sql = '\n'.join(current_block).strip() - if proc_sql: - proc_sql = re.sub(r'\$\$\s*$', '', proc_sql) - if proc_sql: - await cursor.execute(proc_sql) - in_procedure = False - current_block = [] - continue - elif stripped.upper().startswith('DELIMITER'): - # 其他 DELIMITER 指令,跳过 - continue - - if in_procedure: - current_block.append(line) - # 遇到 $$ 结尾的行,说明一个存储过程定义结束,立即执行 - if stripped.endswith('$$'): - proc_sql = '\n'.join(current_block).strip() - if proc_sql: - # 移除结尾的 $$ 定界符 - proc_sql = re.sub(r'\$\$\s*$', '', proc_sql) - if proc_sql: - await cursor.execute(proc_sql) - current_block = [] - else: - # 普通SQL,按完整语句分割(以分号结尾) - if stripped: - # 累积多行直到遇到分号 - if buffer: - buffer += ' ' + stripped - else: - buffer = stripped - - # 如果以分号结尾,执行并清空缓冲区 - if buffer.rstrip().endswith(';'): - stmt = buffer.rstrip(';').strip() - if stmt: - await cursor.execute(stmt) - buffer = '' - - # 处理缓冲区中剩余的语句 - if buffer: - stmt = buffer.rstrip(';').strip() - if stmt: - await cursor.execute(stmt) - else: - # 无 DELIMITER,按分号+换行分割语句 - statements = re.split(r';\s*\n', sql_content) - for stmt in statements: - stmt = stmt.strip() - if stmt and stmt != '--': - await cursor.execute(stmt) diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py deleted file mode 100644 index 638547a..0000000 --- a/backend/schemas/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - diff --git a/backend/schemas/admin.py b/backend/schemas/admin.py deleted file mode 100644 index a50029a..0000000 --- a/backend/schemas/admin.py +++ /dev/null @@ -1,111 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from pydantic import BaseModel, Field -from typing import Optional, List -from datetime import date, datetime - - -class AddPointsRequest(BaseModel): - """加减分请求""" - student_ids: List[int] = Field(..., min_length=1, max_length=200, description="学生ID列表") - points_change: int = Field(..., gt=-100, lt=100, description="分数变动") - reason: str = Field(..., min_length=1, max_length=255, description="原因") - related_type: Optional[str] = Field(default='manual', pattern=r'^(manual|homework|attendance)$', description="关联类型: manual/homework/attendance") - - -class AddPointsResponse(BaseModel): - """加减分响应""" - success_count: int - fail_count: int - details: List[dict] - - -class RevokeRequest(BaseModel): - """撤销请求""" - record_id: int = Field(..., description="记录ID") - - -class ImportStudentsRequest(BaseModel): - """导入学生请求""" - students: List[dict] = Field(..., description="学生列表") - - -class ImportResult(BaseModel): - """导入结果""" - total: int - success: int - failed: int - errors: List[str] - - -class AddAdminRequest(BaseModel): - """添加管理员请求""" - username: str = Field(..., min_length=2, max_length=50, pattern=r'^[a-zA-Z0-9_\u4e00-\u9fa5]+$', description="登录账号") - real_name: str = Field(..., min_length=1, max_length=50, description="真实姓名") - password: Optional[str] = Field(None, min_length=6, max_length=50, description="密码(不填则自动生成)") - role_type: str = Field(..., pattern=r'^(班长|学习委员|考勤委员|劳动委员|志愿委员)$', description="角色类型") - subject_id: Optional[int] = Field(None, gt=0, description="科目ID(科代表需要)") - - -class AddAdminResponse(BaseModel): - """添加管理员响应""" - success: bool - username: str - password: Optional[str] = None - message: str - - -class AddStudentRequest(BaseModel): - """新增学生请求""" - student_no: str = Field(..., min_length=1, max_length=20, pattern=r'^[a-zA-Z0-9]+$', description="学号") - name: str = Field(..., min_length=1, max_length=50, description="姓名") - parent_phone: Optional[str] = Field(None, max_length=11, pattern=r'^\d{0,11}$', description="家长手机号") - dormitory_number: Optional[str] = Field(None, max_length=20, description="宿舍号") - - -class AddAttendanceRequest(BaseModel): - """添加考勤请求""" - student_id: int = Field(..., gt=0, description="学生ID") - date: date - slot: str = Field(default="morning", pattern=r'^(morning|afternoon|evening)$', description="时段") - status: str = Field(..., pattern=r'^(present|absent|late|leave)$', description="考勤状态") - reason: Optional[str] = Field(None, max_length=255, description="原因") - apply_deduction: bool = True - custom_deduction: Optional[int] = Field(default=None, gt=0, le=20, description="自定义扣分值") - - -class UpdateAdminRequest(BaseModel): - """更新管理员请求""" - real_name: str = Field(..., min_length=1, max_length=50, description="真实姓名") - role_type: str = Field(..., pattern=r'^(班长|学习委员|考勤委员|劳动委员|志愿委员)$', description="角色类型") - - -class DeleteAdminRequest(BaseModel): - """删除管理员请求""" - user_id: int = Field(..., description="用户ID") - - -class ResetPasswordRequest(BaseModel): - """重置密码请求""" - new_password: str = Field(..., min_length=6, max_length=50, description="新密码") - - -class UpdateStudentRequest(BaseModel): - """更新学生请求""" - name: Optional[str] = Field(None, min_length=1, max_length=50, description="姓名") - parent_phone: Optional[str] = Field(None, max_length=11, pattern=r'^\d{0,11}$', description="家长手机号") - dormitory_number: Optional[str] = Field(None, max_length=20, description="宿舍号") - - -class UnlockUserRequest(BaseModel): - """解除用户登录锁定请求""" - username: str = Field(..., min_length=1, max_length=50, description="用户名") \ No newline at end of file diff --git a/backend/schemas/auth.py b/backend/schemas/auth.py deleted file mode 100644 index 6501753..0000000 --- a/backend/schemas/auth.py +++ /dev/null @@ -1,55 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from pydantic import BaseModel, Field -from typing import Optional - - -class LoginRequest(BaseModel): - """登录请求""" - username: str = Field(..., min_length=1, max_length=50, description="用户名") - password: str = Field(..., min_length=1, max_length=50, description="密码") - - -class LoginResponse(BaseModel): - """登录响应""" - success: bool - token: str - user_id: int - username: str - real_name: str - user_type: str - need_change_password: bool - redirect: str - - -class ChangePasswordRequest(BaseModel): - """修改密码请求""" - old_password: str = Field(default="", max_length=50, description="原密码") - new_password: str = Field(..., min_length=6, max_length=20, description="新密码") - force: bool = Field(default=False, description="是否强制修改(首次登录)") - - -class ChangePasswordResponse(BaseModel): - """修改密码响应""" - success: bool - message: str - - -class UserInfo(BaseModel): - """用户信息""" - user_id: int - username: str - real_name: str - user_type: str - student_id: Optional[int] = None - role: Optional[str] = None - need_change_password: bool \ No newline at end of file diff --git a/backend/schemas/config.py b/backend/schemas/config.py deleted file mode 100644 index fddc68e..0000000 --- a/backend/schemas/config.py +++ /dev/null @@ -1,22 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from pydantic import BaseModel -from typing import Optional - -class DeductionConfigResponse(BaseModel): - """扣分规则配置响应""" - DEDUCTION_HOMEWORK_NOT_SUBMIT: int - DEDUCTION_HOMEWORK_LATE: int - DEDUCTION_ATTENDANCE_ABSENT: int - DEDUCTION_ATTENDANCE_LATE: int - DEDUCTION_ATTENDANCE_LEAVE: int - STUDENT_INITIAL_POINTS: int diff --git a/backend/schemas/semester.py b/backend/schemas/semester.py deleted file mode 100644 index cfb2b51..0000000 --- a/backend/schemas/semester.py +++ /dev/null @@ -1,27 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 学期请求模型 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from pydantic import BaseModel, Field -from typing import Optional - - -class CreateSemesterRequest(BaseModel): - """创建学期请求""" - semester_name: str = Field(..., min_length=1, max_length=100, description="学期名称") - start_date: Optional[str] = Field(None, description="学期开始日期 (YYYY-MM-DD)") - end_date: Optional[str] = Field(None, description="学期结束日期 (YYYY-MM-DD)") - - -class UpdateSemesterRequest(BaseModel): - """编辑学期请求""" - semester_name: Optional[str] = Field(None, min_length=1, max_length=100, description="学期名称") - start_date: Optional[str] = Field(None, description="学期开始日期 (YYYY-MM-DD)") - end_date: Optional[str] = Field(None, description="学期结束日期 (YYYY-MM-DD)") diff --git a/backend/schemas/student.py b/backend/schemas/student.py deleted file mode 100644 index ff4adb4..0000000 --- a/backend/schemas/student.py +++ /dev/null @@ -1,65 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from pydantic import BaseModel, Field -from typing import Optional, List -from datetime import date, datetime - - -class StudentInfo(BaseModel): - """学生信息""" - student_id: int - student_no: str - name: str - total_points: int - parent_phone: Optional[str] = None - dormitory_number: Optional[str] = None - status: int - - -class ConductRecord(BaseModel): - """操行分记录""" - record_id: int - student_id: int - student_name: Optional[str] = None - points_change: int - reason: str - recorder_id: int - recorder_name: str - related_type: str - is_revoked: bool - created_at: datetime - - -class ConductHistoryResponse(BaseModel): - """操行分历史响应""" - student_id: int - student_name: str - total_points: int - records: List[ConductRecord] - - -class AttendanceRecord(BaseModel): - """考勤记录""" - attendance_id: int - date: date - status: str - reason: Optional[str] = None - deduction_applied: bool - - -class StudentRanking(BaseModel): - """学生排行""" - student_id: int - student_no: str - name: str - total_points: int - rank_in_class: int \ No newline at end of file diff --git a/backend/schemas/subject.py b/backend/schemas/subject.py deleted file mode 100644 index 8e9ad06..0000000 --- a/backend/schemas/subject.py +++ /dev/null @@ -1,43 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from pydantic import BaseModel, Field -from typing import Optional, List - - -class SubjectInfo(BaseModel): - """科目信息""" - subject_id: int - subject_name: str - subject_code: Optional[str] = None - is_active: bool - sort_order: int - - -class CreateSubjectRequest(BaseModel): - """创建科目请求""" - subject_name: str = Field(..., min_length=1, max_length=50, description="科目名称") - subject_code: Optional[str] = Field(None, max_length=20, description="科目代码") - sort_order: int = Field(0, description="排序序号") - - -class UpdateSubjectRequest(BaseModel): - """更新科目请求""" - subject_name: Optional[str] = Field(None, max_length=50, description="科目名称") - subject_code: Optional[str] = Field(None, max_length=20, description="科目代码") - is_active: Optional[bool] = Field(None, description="是否启用") - sort_order: Optional[int] = Field(None, description="排序序号") - - -class SubjectListResponse(BaseModel): - """科目列表响应""" - subjects: List[SubjectInfo] - total: int \ No newline at end of file diff --git a/backend/services/__init__.py b/backend/services/__init__.py deleted file mode 100644 index 638547a..0000000 --- a/backend/services/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - diff --git a/backend/services/admin_service.py b/backend/services/admin_service.py deleted file mode 100644 index 29f6df5..0000000 --- a/backend/services/admin_service.py +++ /dev/null @@ -1,344 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 管理员服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from typing import Dict, Any, List, Optional -from utils.database import execute_query, execute_one, execute_update -from models.user import UserModel -from models.student import StudentModel -from models.admin_role import AdminRoleModel -from utils.security import security -from utils.logger import get_logger - -logger = get_logger(__name__) - - -class AdminService: - """管理员服务""" - - @staticmethod - async def get_students( - page: int = 1, - page_size: int = 20, - search: str = None, - dormitory_number: str = None - ) -> Dict[str, Any]: - """获取所有学生列表""" - offset = (page - 1) * page_size - - try: - sql = """ - SELECT student_id, student_no, name, total_points, parent_phone, dormitory_number, status - FROM students - WHERE status = 1 - """ - params = [] - - if search: - sql += " AND (student_no LIKE %s OR name LIKE %s)" - params.extend([f"%{search}%", f"%{search}%"]) - - if dormitory_number: - sql += " AND dormitory_number = %s" - params.append(dormitory_number) - - sql += " ORDER BY student_no LIMIT %s OFFSET %s" - params.extend([page_size, offset]) - - students = await execute_query(sql, tuple(params)) - has_dormitory = True - except Exception as e: - logger.warning(f"dormitory_number 列不存在,使用不含该字段的查询: {e}") - sql = """ - SELECT student_id, student_no, name, total_points, parent_phone, status - FROM students - WHERE status = 1 - """ - params = [] - - if search: - sql += " AND (student_no LIKE %s OR name LIKE %s)" - params.extend([f"%{search}%", f"%{search}%"]) - - sql += " ORDER BY student_no LIMIT %s OFFSET %s" - params.extend([page_size, offset]) - - students = await execute_query(sql, tuple(params)) - has_dormitory = False - - # 获取总数 - count_sql = "SELECT COUNT(*) as total FROM students WHERE status = 1" - count_params = [] - if search: - count_sql += " AND (student_no LIKE %s OR name LIKE %s)" - count_params.extend([f"%{search}%", f"%{search}%"]) - if dormitory_number and has_dormitory: - count_sql += " AND dormitory_number = %s" - count_params.append(dormitory_number) - if count_params: - total_result = await execute_one(count_sql, tuple(count_params)) - else: - total_result = await execute_one(count_sql) - total = total_result["total"] if total_result else 0 - - return { - "students": students, - "total": total, - "page": page, - "page_size": page_size, - "total_pages": (total + page_size - 1) // page_size - } - - @staticmethod - async def import_students( - students: List[Dict], - operator_id: int, - initial_points: int = 60 - ) -> Dict[str, Any]: - """批量导入学生(优化版:预查重 + 批量操作)""" - results = [] - success_count = 0 - - # 预查重:一次性获取所有已存在的学号和手机号 - existing_students = await StudentModel.get_all() - existing_student_nos = {s["student_no"] for s in existing_students} - - all_users = await execute_query("SELECT username FROM users WHERE status = 1") - existing_usernames = {u["username"] for u in all_users} - - for student in students: - try: - student_no = student.get("student_no", "").strip() - name = student.get("name", "").strip() - parent_phone = student.get("parent_phone", "").strip() - dormitory_number = student.get("dormitory_number", "").strip() if student.get("dormitory_number") else None - password = student.get("password", "").strip() - - if not student_no or not name: - results.append({"student_no": student_no, "success": False, "error": "学号或姓名不能为空"}) - continue - - if not security.validate_student_no(student_no): - results.append({"student_no": student_no, "success": False, "error": "学号格式错误"}) - continue - - if parent_phone and not security.validate_phone(parent_phone): - results.append({"student_no": student_no, "success": False, "error": "手机号格式错误"}) - continue - - if student_no in existing_student_nos: - results.append({"student_no": student_no, "success": False, "error": "学号已存在"}) - continue - - init_password = password if password else "123456" - - # 创建学生记录 - student_id = await StudentModel.create( - student_no=student_no, - name=name, - parent_phone=parent_phone if parent_phone else None, - dormitory_number=dormitory_number, - initial_points=initial_points - ) - existing_student_nos.add(student_no) - - # 创建学生登录账号 - await UserModel.create_student( - username=student_no, - password=init_password, - real_name=name, - student_id=student_id - ) - existing_usernames.add(student_no) - - # 创建家长账号(如果手机号存在且未被注册) - if parent_phone and parent_phone not in existing_usernames: - await UserModel.create_parent( - username=parent_phone, - password=init_password, - real_name=f"{name}家长", - student_id=student_id - ) - existing_usernames.add(parent_phone) - - results.append({"student_no": student_no, "success": True, "student_id": student_id}) - success_count += 1 - logger.info(f"用户[{operator_id}] 导入学生: {student_no} - {name}") - - except Exception as e: - logger.error(f"导入学生失败: {student.get('student_no', '?')} - {str(e)}") - results.append({ - "student_no": student.get("student_no", ""), - "success": False, - "error": f"导入异常: {str(e)}" - }) - - return { - "success": True, - "total": len(students), - "success_count": success_count, - "failed_count": len(students) - success_count, - "results": results - } - - @staticmethod - async def add_student( - student_no: str, - name: str, - parent_phone: Optional[str], - operator_id: int, - initial_points: int = 60, - dormitory_number: Optional[str] = None - ) -> Dict[str, Any]: - """新增学生""" - if not security.validate_student_no(student_no): - return {"success": False, "message": "学号格式错误"} - - if parent_phone and not security.validate_phone(parent_phone): - return {"success": False, "message": "手机号格式错误"} - - existing = await StudentModel.get_by_student_no(student_no) - if existing: - return {"success": False, "message": "学号已存在"} - - student_id = await StudentModel.create( - student_no=student_no, - name=name, - parent_phone=parent_phone if parent_phone else None, - dormitory_number=dormitory_number, - initial_points=initial_points - ) - - await UserModel.create_student( - username=student_no, - password="123456", - real_name=name, - student_id=student_id - ) - - if parent_phone: - parent_exists = await UserModel.get_by_username(parent_phone) - if not parent_exists: - await UserModel.create_parent( - username=parent_phone, - password="123456", - real_name=f"{name}家长", - student_id=student_id - ) - - logger.info(f"用户[{operator_id}] 新增学生: {student_no} - {name}") - - return {"success": True, "student_id": student_id} - - @staticmethod - async def add_admin( - username: str, - real_name: str, - password: Optional[str], - role_type: str, - operator_id: int - ) -> Dict[str, Any]: - """添加管理员""" - existing = await UserModel.get_by_username(username) - if existing: - return {"success": False, "message": "用户名已存在"} - - if not password: - password = security.generate_random_password() - - user_id = await UserModel.create_admin( - username=username, - password=password, - real_name=real_name - ) - - await AdminRoleModel.create( - user_id=user_id, - role_type=role_type, - subject_id=None - ) - - logger.info(f"用户[{operator_id}] 添加管理员: {username} ({role_type})") - - return { - "success": True, - "user_id": user_id, - "username": username, - "password": password, - "role_type": role_type - } - - @staticmethod - async def get_admins() -> Dict[str, Any]: - """获取管理员列表""" - admins = await AdminRoleModel.get_all() - return {"admins": admins} - - @staticmethod - async def update_student(student_id: int, name: str = None, parent_phone: str = None, dormitory_number: str = None) -> Dict[str, Any]: - """编辑学生信息""" - try: - student = await StudentModel.get_by_id(student_id) - if not student: - return {"success": False, "message": "学生不存在"} - - result = await StudentModel.update(student_id, name=name, parent_phone=parent_phone, dormitory_number=dormitory_number) - if result: - return {"success": True, "message": "学生信息更新成功"} - return {"success": False, "message": "更新失败"} - except Exception as e: - logger.error(f"更新学生信息失败: {e}") - return {"success": False, "message": f"更新失败: {str(e)}"} - - @staticmethod - async def delete_student(student_id: int) -> Dict[str, Any]: - """删除学生(软删除)""" - try: - student = await StudentModel.get_by_id(student_id) - if not student: - return {"success": False, "message": "学生不存在"} - - result = await StudentModel.delete(student_id) - if result: - user = await execute_one( - "SELECT user_id FROM users WHERE student_id = %s AND user_type = 'student'", - (student_id,) - ) - if user: - await UserModel.update_status(user['user_id'], 0) - return {"success": True, "message": "学生删除成功"} - return {"success": False, "message": "删除失败"} - except Exception as e: - logger.error(f"删除学生失败: {e}") - return {"success": False, "message": f"删除失败: {str(e)}"} - - @staticmethod - async def reset_student_password(student_id: int, new_password: str) -> Dict[str, Any]: - """重置学生密码""" - try: - user = await execute_one( - "SELECT user_id FROM users WHERE student_id = %s AND user_type = 'student'", - (student_id,) - ) - if not user: - return {"success": False, "message": "未找到对应的用户账号"} - - # UserModel.update_password 内部会进行哈希,无需预先哈希 - result = await UserModel.update_password(user['user_id'], new_password) - if result: - await execute_update( - "UPDATE users SET need_change_password = 1 WHERE user_id = %s", - (user['user_id'],) - ) - return {"success": True, "message": "密码重置成功"} - return {"success": False, "message": "密码重置失败"} - except Exception as e: - logger.error(f"重置学生密码失败: {e}") - return {"success": False, "message": f"重置失败: {str(e)}"} \ No newline at end of file diff --git a/backend/services/attendance_service.py b/backend/services/attendance_service.py deleted file mode 100644 index 3e03385..0000000 --- a/backend/services/attendance_service.py +++ /dev/null @@ -1,145 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from typing import Dict, Any, Optional -from datetime import datetime - -from models.attendance import AttendanceModel -from models.student import StudentModel -from models.conduct import ConductModel -from models.user import UserModel -from middleware.permission import PermissionChecker -from config import settings -from utils.logger import get_logger - -logger = get_logger(__name__) - -# 考勤状态中文映射 -ATTENDANCE_STATUS_MAP = { - "absent": "缺勤", - "late": "迟到", - "leave": "请假" -} - - -class AttendanceService: - """考勤服务""" - - @staticmethod - async def add_attendance( - student_id: int, - date: str, - status: str, - reason: Optional[str], - apply_deduction: bool, - recorder_id: int, - custom_deduction: Optional[int] = None, - slot: str = 'morning' - ) -> Dict[str, Any]: - """添加考勤记录""" - # 校验时段 - if slot not in ('morning', 'afternoon', 'evening'): - return {"success": False, "message": "无效的考勤时段"} - # 校验状态 - if status not in ('present', 'absent', 'late', 'leave'): - return {"success": False, "message": "无效的考勤状态"} - # 校验自定义扣分范围 - if custom_deduction is not None and (custom_deduction < 1 or custom_deduction > 20): - return {"success": False, "message": "自定义扣分必须在1-20之间"} - - # 检查权限 - role = await PermissionChecker.get_user_role(recorder_id) - if role not in ["班主任", "考勤委员"]: - return {"success": False, "message": "无权进行此操作"} - - # 考勤委员扣分上限 - if role == "考勤委员" and apply_deduction and status in ["absent", "late"]: - if custom_deduction is not None and custom_deduction > settings.ATTENDANCE_REP_MAX_POINTS: - return {"success": False, "message": f"考勤委员单次扣分上限为{settings.ATTENDANCE_REP_MAX_POINTS}分"} - - # 添加考勤记录 - attendance_id = await AttendanceModel.create_record( - student_id=student_id, - date=date, - status=status, - reason=reason, - recorder_id=recorder_id, - slot=slot - ) - - if not attendance_id: - return {"success": False, "message": "添加考勤记录失败"} - - # 应用扣分 - if apply_deduction and status in ["absent", "late", "leave"]: - # 确定扣分数值(优先使用自定义扣分) - if custom_deduction is not None: - points_change = -custom_deduction - elif status == "absent": - points_change = -settings.DEDUCTION_ATTENDANCE_ABSENT - elif status == "late": - points_change = -settings.DEDUCTION_ATTENDANCE_LATE - else: - points_change = -settings.DEDUCTION_ATTENDANCE_LEAVE - - # 扣分为0时跳过(如请假不扣分) - if points_change == 0: - logger.info(f"用户[{recorder_id}] 添加考勤记录[{attendance_id}] -> {status} (不扣分)") - return {"success": True, "message": "考勤记录添加成功(不扣分)"} - student = await StudentModel.get_by_id(student_id) - if student: - # 获取操作人姓名 - user = await UserModel.get_by_user_id(recorder_id) - recorder_name = user.get("real_name", "班主任") if user else "班主任" - # 使用中文状态 - status_text = ATTENDANCE_STATUS_MAP.get(status, status) - await ConductModel.create_record( - student_id=student_id, - points_change=points_change, - reason=f"考勤:{status_text}", - recorder_id=recorder_id, - recorder_name=recorder_name, - related_type="attendance", - related_id=attendance_id - ) - - # 更新学生总分 - await StudentModel.update_total_points(student_id, points_change) - - # 标记已应用扣分 - await AttendanceModel.mark_deduction_applied(attendance_id) - logger.info(f"用户[{recorder_id}] 添加考勤记录[{attendance_id}] -> {status}") - - return {"success": True, "message": "考勤记录添加成功"} - - @staticmethod - async def get_records( - user_id: int, - date: Optional[str] = None, - student_id: Optional[int] = None, - slot: Optional[str] = None - ) -> Dict[str, Any]: - """获取考勤记录""" - role = await PermissionChecker.get_user_role(user_id) - - if role in ["班主任", "考勤委员"]: - records = await AttendanceModel.get_class_records( - date=date, - student_id=student_id, - slot=slot - ) - elif student_id: - # 管理员可查看指定学生 - records = await AttendanceModel.get_student_records(student_id) - else: - records = [] - - return {"records": records} \ No newline at end of file diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py deleted file mode 100644 index 109bfc4..0000000 --- a/backend/services/auth_service.py +++ /dev/null @@ -1,186 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from typing import Dict, Any, Optional -from datetime import datetime - -from models.user import UserModel -from models.student import StudentModel -from models.admin_role import AdminRoleModel -from services.log_service import LogService -from utils.security import security -from utils.jwt_handler import jwt_handler -from utils.redis_client import RedisClient -from utils.database import execute_update -from utils.logger import get_logger - -logger = get_logger(__name__) - - -class AuthService: - """认证服务""" - - @staticmethod - async def login(username: str, password: str, ip: str, user_agent: str = None) -> Dict[str, Any]: - """ - 用户登录 - """ - # 检查登录失败次数 - attempts = await RedisClient.get(f"login_attempts:{username}") - if attempts and int(attempts) >= 5: - await LogService.write_login_log(username, 0, ip, user_agent, "登录失败次数过多") - return {"success": False, "message": "登录失败次数过多,请5分钟后重试"} - - # 获取用户信息 - user = await UserModel.get_by_username(username) - - if not user: - await RedisClient.set_login_attempts(username) - await LogService.write_login_log(username, 0, ip, user_agent, "用户名或密码错误") - return {"success": False, "message": "用户名或密码错误"} - - # 验证密码 - is_valid, needs_upgrade = security.verify_password_v2(password, user["password_hash"]) - if not is_valid: - await RedisClient.set_login_attempts(username) - await LogService.write_login_log(username, 0, ip, user_agent, "用户名或密码错误") - return {"success": False, "message": "用户名或密码错误"} - - # 自动升级旧哈希密码 - if needs_upgrade: - try: - await UserModel.update_password(user["user_id"], password) - except Exception: - pass - - # 检查账号状态 - if user["status"] != 1: - await LogService.write_login_log(username, 0, ip, user_agent, "账号已被禁用") - return {"success": False, "message": "账号已被禁用"} - - # 清除登录失败记录 - await RedisClient.clear_login_attempts(username) - - # 更新最后登录信息 - await UserModel.update_last_login(user["user_id"], ip) - - # 获取用户角色(如果是管理员) - role = None - if user["user_type"] == "admin": - admin_role = await AdminRoleModel.get_by_user_id(user["user_id"]) - role = admin_role["role_type"] if admin_role else None - - # 生成Token - token = jwt_handler.create_token( - user_id=user["user_id"], - username=user["username"], - user_type=user["user_type"], - student_id=user["student_id"], - role=role, - real_name=user["real_name"] - ) - - # 存储Token到Redis - await RedisClient.set_user_token(user["user_id"], token) - - # 确定跳转路径 - redirect = AuthService._get_redirect_path(user["user_type"], role) - - await LogService.write_login_log(username, 1, ip, user_agent) - - return { - "success": True, - "token": token, - "user_id": user["user_id"], - "username": user["username"], - "real_name": user["real_name"], - "user_type": user["user_type"], - "student_id": user["student_id"], - "role": role, - "need_change_password": user["need_change_password"] == 1, - "redirect": redirect - } - - @staticmethod - async def logout(user_id: int) -> Dict[str, Any]: - """用户登出""" - await RedisClient.delete_user_token(user_id) - return {"success": True, "message": "登出成功"} - - @staticmethod - async def change_password(user_id: int, old_password: str, new_password: str, force: bool = False) -> Dict[str, Any]: - """修改密码""" - # 获取用户信息 - user = await UserModel.get_by_user_id(user_id) - if not user: - return {"success": False, "message": "用户不存在"} - - # 验证原密码(强制改密时跳过) - if not force: - is_valid, _ = security.verify_password_v2(old_password, user["password_hash"]) - if not is_valid: - return {"success": False, "message": "原密码错误"} - - # 验证新密码强度 - is_valid, msg = security.validate_password_strength(new_password) - if not is_valid: - return {"success": False, "message": msg} - - # 更新密码 - result = await UserModel.update_password(user_id, new_password) - - if result: - # 清除所有Token - await RedisClient.delete_user_token(user_id) - return {"success": True, "message": "密码修改成功"} - else: - return {"success": False, "message": "密码修改失败"} - - @staticmethod - async def get_user_info(user_id: int) -> Optional[Dict[str, Any]]: - """获取用户信息""" - user = await UserModel.get_by_user_id(user_id) - if not user: - return None - - result = { - "user_id": user["user_id"], - "username": user["username"], - "real_name": user["real_name"], - "user_type": user["user_type"], - "need_change_password": user["need_change_password"] == 1 - } - - # 获取学生信息 - if user["student_id"]: - student = await StudentModel.get_by_id(user["student_id"]) - if student: - result["student_no"] = student["student_no"] - result["student_name"] = student["name"] - result["total_points"] = student["total_points"] - - # 获取管理员角色 - if user["user_type"] == "admin": - admin_role = await AdminRoleModel.get_by_user_id(user_id) - if admin_role: - result["role"] = admin_role["role_type"] - - return result - - @staticmethod - def _get_redirect_path(user_type: str, role: str = None) -> str: - """获取跳转路径""" - if user_type == "student": - return "/student/dashboard.php" - elif user_type == "parent": - return "/parent/dashboard.php" - else: - return "/admin/dashboard.php" \ No newline at end of file diff --git a/backend/services/conduct_service.py b/backend/services/conduct_service.py deleted file mode 100644 index 0ecb8cd..0000000 --- a/backend/services/conduct_service.py +++ /dev/null @@ -1,352 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from typing import Dict, Any, List, Optional -from datetime import datetime - -from models.student import StudentModel -from models.conduct import ConductModel -from models.user import UserModel -from models.semester import SemesterModel -from middleware.permission import PermissionChecker -from config import settings -from utils.logger import get_logger - -logger = get_logger(__name__) - - -class ConductService: - """操行分服务""" - - @staticmethod - async def add_points( - student_ids: List[int], - points_change: int, - reason: str, - recorder_id: int, - recorder_name: str, - related_type: str = 'manual' - ) -> Dict[str, Any]: - """批量加减分""" - # 输入校验 - if not student_ids or len(student_ids) > 200: - return {"success": False, "message": "学生数量需在1-200之间"} - if not reason or not reason.strip() or len(reason) > 255: - return {"success": False, "message": "原因不能为空且不超过255字符"} - - # 验证分值 - if points_change == 0: - return {"success": False, "message": "分值不能为0"} - if abs(points_change) > 100: - return {"success": False, "message": "单次加减分不能超过100分"} - - # 获取操作人角色 - role = await PermissionChecker.get_user_role(recorder_id) - - # 权限验证 - if role == "班主任": - # 班主任无限制 - pass - elif role == "班长": - # 班长限制 ±5分 - if points_change > settings.MONITOR_MAX_ADD or points_change < settings.MONITOR_MAX_SUBTRACT: - return {"success": False, "message": f"班长单次只能加减{settings.MONITOR_MAX_ADD}分以内"} - elif role == "劳动委员": - # 劳动委员可加减分,±LABOR_REP_MAX_POINTS以内 - if abs(points_change) > settings.LABOR_REP_MAX_POINTS: - return {"success": False, "message": f"劳动委员单次只能加减{settings.LABOR_REP_MAX_POINTS}分以内"} - elif role == "志愿委员": - # 志愿委员只能加分,上限VOLUNTEER_REP_MAX_POINTS - if points_change < 0: - return {"success": False, "message": "志愿委员只能加分"} - if points_change > settings.VOLUNTEER_REP_MAX_POINTS: - return {"success": False, "message": f"志愿委员单次最多加{settings.VOLUNTEER_REP_MAX_POINTS}分"} - elif role == "学习委员": - # 学习委员可加减分,±STUDY_COMMISSIONER_MAX_POINTS以内 - if abs(points_change) > settings.STUDY_COMMISSIONER_MAX_POINTS: - return {"success": False, "message": f"学习委员单次只能加减{settings.STUDY_COMMISSIONER_MAX_POINTS}分以内"} - elif role == "考勤委员": - # 考勤委员只能扣分,上限ATTENDANCE_REP_MAX_POINTS - if points_change > 0: - return {"success": False, "message": "考勤委员只能进行扣分操作"} - if abs(points_change) > settings.ATTENDANCE_REP_MAX_POINTS: - return {"success": False, "message": f"考勤委员单次最多扣{settings.ATTENDANCE_REP_MAX_POINTS}分"} - else: - return {"success": False, "message": "无权进行此操作"} - - # 批量处理 - success_count = 0 - fail_count = 0 - details = [] - - # 自动获取当前活跃学期 - active_semester = await SemesterModel.get_active() - semester_id = active_semester['semester_id'] if active_semester else None - - for student_id in student_ids: - try: - # 检查学生是否存在 - student = await StudentModel.get_by_id(student_id) - if not student: - details.append({"student_id": student_id, "error": "学生不存在"}) - fail_count += 1 - continue - - record_id = await ConductModel.create_record( - student_id=student_id, - points_change=points_change, - reason=reason, - recorder_id=recorder_id, - recorder_name=recorder_name, - related_type=related_type - ) - - # 自动关联到当前学期 - if semester_id and record_id: - try: - from utils.database import execute_update as _exec_update - await _exec_update( - "UPDATE conduct_records SET semester_id = %s WHERE record_id = %s AND semester_id IS NULL", - (semester_id, record_id) - ) - except Exception: - pass # 关联失败不影响主流程 - - # 更新学生总分 - await StudentModel.update_total_points(student_id, points_change) - - details.append({"student_id": student_id, "success": True, "record_id": record_id}) - success_count += 1 - - logger.info(f"用户[{recorder_id}] 对学生[{student_id}] 进行 {points_change} 分操作") - - except Exception as e: - details.append({"student_id": student_id, "error": str(e)}) - fail_count += 1 - - message = "操作成功" if fail_count == 0 else f"{success_count}人成功,{fail_count}人失败" - return { - "success": fail_count == 0, - "message": message, - "success_count": success_count, - "fail_count": fail_count, - "details": details - } - - @staticmethod - async def revoke_record(record_id: int, revoker_id: int) -> Dict[str, Any]: - """撤销扣分记录""" - if not record_id or record_id <= 0: - return {"success": False, "message": "无效的记录ID"} - - # 检查权限 - can_revoke = await PermissionChecker.check_can_revoke(revoker_id, record_id) - if not can_revoke: - return {"success": False, "message": "无权撤销此记录"} - - # 先获取原记录信息(用于恢复分数) - record = await ConductModel.get_record_by_id(record_id) - if not record: - return {"success": False, "message": "记录不存在"} - - # 归档后班主任仍可撤销/修改记录(任务需求#8) - # 归档操作本身不可逆,但归档数据可由班主任修改 - - # 撤销记录 - result = await ConductModel.revoke_record(record_id, revoker_id) - - if result: - # 反向恢复学生总分 - await StudentModel.update_total_points(record["student_id"], -record["points_change"]) - logger.info(f"用户[{revoker_id}] 撤销了记录[{record_id}]") - return { - "success": True, - "message": "撤销成功", - "record": { - "student_id": record["student_id"], - "recorder_name": record.get("recorder_name", "未知"), - "points_change": record["points_change"], - "reason": record.get("reason", "") - } - } - else: - return {"success": False, "message": "撤销失败"} - - @staticmethod - async def restore_record(record_id: int, restorer_id: int) -> Dict[str, Any]: - """反撤销(恢复)已撤销的记录""" - if not record_id or record_id <= 0: - return {"success": False, "message": "无效的记录ID"} - - # 检查权限:只有班主任可以反撤销 - role = await PermissionChecker.get_user_role(restorer_id) - if role != "班主任": - return {"success": False, "message": "仅班主任可反撤销记录"} - - # 获取原记录信息 - record = await ConductModel.get_record_by_id(record_id) - if not record: - return {"success": False, "message": "记录不存在"} - - if not record.get("is_revoked"): - return {"success": False, "message": "该记录未被撤销,无需恢复"} - - # 恢复记录 - result = await ConductModel.restore_record(record_id, restorer_id) - - if result: - # 恢复学生总分(重新加上原来的分数变动) - await StudentModel.update_total_points(record["student_id"], record["points_change"]) - logger.info(f"用户[{restorer_id}] 反撤销了记录[{record_id}]") - return { - "success": True, - "message": "反撤销成功", - "record": { - "student_id": record["student_id"], - "recorder_name": record.get("recorder_name", "未知"), - "points_change": record["points_change"], - "reason": record.get("reason", "") - } - } - else: - return {"success": False, "message": "反撤销失败"} - - @staticmethod - async def get_history( - user_id: int, - student_id: Optional[int] = None, - page: int = 1, - page_size: int = 20, - start_date: Optional[str] = None, - end_date: Optional[str] = None, - grouped: bool = False, - related_type: Optional[str] = None, - reason_prefix: Optional[str] = None, - is_revoked: Optional[int] = None, - reason_search: Optional[str] = None - ) -> Dict[str, Any]: - """获取历史记录""" - # 空字符串转为None - if start_date == "": - start_date = None - if end_date == "": - end_date = None - if related_type == "": - related_type = None - if reason_prefix == "": - reason_prefix = None - if reason_search == "": - reason_search = None - if related_type and related_type not in ('manual', 'homework', 'attendance'): - return {"records": [], "page": page, "page_size": page_size, "total": 0, "total_pages": 0} - - role = await PermissionChecker.get_user_role(user_id) - offset = (page - 1) * page_size - - # 班主任/班长/志愿委员可查看全班 - if role in ["班主任", "班长", "志愿委员"]: - if grouped: - return await ConductModel.get_grouped_records( - student_id=student_id, - start_date=start_date, - end_date=end_date, - related_type=related_type, - reason_prefix=reason_prefix, - page=page, - page_size=page_size, - is_revoked=is_revoked, - reason_search=reason_search - ) - - records = await ConductModel.get_all_records( - limit=page_size, - offset=offset, - start_date=start_date, - end_date=end_date, - student_id=student_id, - related_type=related_type, - reason_prefix=reason_prefix, - is_revoked=is_revoked, - reason_search=reason_search - ) - - # 获取总数 - from utils.database import execute_one - count_conditions = ["1=1"] - count_params = [] - if student_id: - count_conditions.append("cr.student_id = %s") - count_params.append(student_id) - if start_date: - count_conditions.append("DATE(cr.created_at) >= %s") - count_params.append(start_date) - if end_date: - count_conditions.append("DATE(cr.created_at) <= %s") - count_params.append(end_date) - if related_type: - count_conditions.append("cr.related_type = %s") - count_params.append(related_type) - if reason_prefix: - count_conditions.append("cr.reason LIKE %s") - count_params.append(f"{reason_prefix}%") - if reason_search: - count_conditions.append("cr.reason LIKE %s") - count_params.append(f"%{reason_search}%") - if is_revoked is not None: - count_conditions.append("cr.is_revoked = %s") - count_params.append(1 if is_revoked else 0) - count_where = " AND ".join(count_conditions) - count_sql = f""" - SELECT COUNT(*) as total FROM conduct_records cr - JOIN students s ON cr.student_id = s.student_id - WHERE {count_where} - """ - total_result = await execute_one(count_sql, tuple(count_params)) - total = total_result["total"] if total_result else 0 - - elif student_id: - # 普通管理员查看指定学生(仅返回自己操作的记录) - records = await ConductModel.get_student_records( - student_id=student_id, - limit=page_size, - offset=offset, - start_date=start_date, - end_date=end_date, - recorder_id=user_id - ) - total = await ConductModel.count_student_records( - student_id=student_id, - start_date=start_date, - end_date=end_date, - recorder_id=user_id - ) - else: - # 查看自己提交的记录 - records = await ConductModel.get_records_by_recorder( - recorder_id=user_id, - limit=page_size, - offset=offset, - start_date=start_date, - end_date=end_date - ) - total = await ConductModel.count_records_by_recorder( - recorder_id=user_id, - start_date=start_date, - end_date=end_date - ) - - return { - "records": records, - "page": page, - "page_size": page_size, - "total": total, - "total_pages": (total + page_size - 1) // page_size - } \ No newline at end of file diff --git a/backend/services/log_service.py b/backend/services/log_service.py deleted file mode 100644 index ae59876..0000000 --- a/backend/services/log_service.py +++ /dev/null @@ -1,57 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from models.log import LoginLogModel, OperationLogModel -from middleware.permission import PermissionChecker -from utils.logger import get_logger - -logger = get_logger(__name__) - - -class LogService: - """日志服务""" - - @staticmethod - async def write_login_log(username: str, login_result: int, ip: str, user_agent: str = None, fail_reason: str = None): - """ - 写入登录日志(异步,不阻塞主流程) - """ - try: - await LoginLogModel.create( - username=username, - login_result=login_result, - ip_address=ip, - user_agent=user_agent, - fail_reason=fail_reason - ) - except Exception as e: - logger.error(f"写入登录日志失败: {e}") - - @staticmethod - async def write_operation_log(operator_id: int, operator_name: str, operator_role: str, - operation_type: str, target_type: str = None, - target_id: int = None, details: str = None, ip: str = None): - """ - 写入操作日志(异步,不阻塞主流程) - """ - try: - await OperationLogModel.create( - operator_id=operator_id, - operator_name=operator_name, - operator_role=operator_role, - operation_type=operation_type, - target_type=target_type, - target_id=target_id, - details=details, - ip_address=ip - ) - except Exception as e: - logger.error(f"写入操作日志失败: {e}") diff --git a/backend/services/parent_service.py b/backend/services/parent_service.py deleted file mode 100644 index 9a17ccc..0000000 --- a/backend/services/parent_service.py +++ /dev/null @@ -1,131 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -import math -from typing import Dict, Any, Optional, List - -from models.user import UserModel -from models.student import StudentModel -from models.conduct import ConductModel -from models.attendance import AttendanceModel -from utils.logger import get_logger - -logger = get_logger(__name__) - - -class ParentService: - """家长服务""" - - @staticmethod - async def get_child_conduct(parent_id: int) -> Dict[str, Any]: - """获取子女操行分(仅总分,家长端不显示详细记录)""" - # 获取家长关联的学生 - user = await UserModel.get_by_user_id(parent_id) - if not user or not user["student_id"]: - return {"error": "未关联学生"} - - student = await StudentModel.get_by_id(user["student_id"]) - if not student: - return {"error": "学生不存在"} - - return { - "student_id": student["student_id"], - "student_name": student["name"], - "student_no": student["student_no"], - "total_points": student["total_points"], - "dormitory_number": student.get("dormitory_number") - } - - @staticmethod - async def get_child_attendance(parent_id: int) -> Dict[str, Any]: - """获取子女考勤记录""" - user = await UserModel.get_by_user_id(parent_id) - if not user or not user["student_id"]: - return {"error": "未关联学生"} - - student = await StudentModel.get_by_id(user["student_id"]) - if not student: - return {"error": "学生不存在"} - - records = await AttendanceModel.get_student_records(user["student_id"]) - - return { - "student_id": student["student_id"], - "student_name": student["name"], - "records": records - } - - @staticmethod - async def get_child_ranking(parent_id: int) -> Dict[str, Any]: - """获取子女排名信息""" - user = await UserModel.get_by_user_id(parent_id) - if not user or not user["student_id"]: - return {"error": "未关联学生"} - - student = await StudentModel.get_by_id(user["student_id"]) - if not student: - return {"error": "学生不存在"} - - # 获取全班排名 - ranking = await StudentModel.get_ranking(limit=1000) - total_students = await StudentModel.get_total_count() - - # 查找当前学生排名 - student_rank = None - for r in ranking: - if r["student_id"] == user["student_id"]: - student_rank = r["rank"] - - # 计算百分比排名 - percentile = None - if student_rank and total_students and total_students > 0: - percentile = math.ceil(student_rank / total_students * 100) - - return { - "student_id": student["student_id"], - "student_name": student["name"], - "student_no": student["student_no"], - "total_points": student["total_points"], - "rank": student_rank, - "total_students": total_students, - "percentile": percentile - } - - @staticmethod - async def get_child_history(parent_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]: - """获取子女操行分历史记录""" - user = await UserModel.get_by_user_id(parent_id) - if not user or not user["student_id"]: - return {"error": "未关联学生"} - - student = await StudentModel.get_by_id(user["student_id"]) - if not student: - return {"error": "学生不存在"} - - offset = (page - 1) * page_size - records = await ConductModel.get_student_records( - student_id=user["student_id"], - limit=page_size, - offset=offset - ) - - # 使用 COUNT 查询获取总数(避免获取全部记录) - total = await ConductModel.count_student_records(user["student_id"]) - - return { - "student_id": student["student_id"], - "student_name": student["name"], - "total_points": student["total_points"], - "records": records, - "total": total, - "page": page, - "page_size": page_size - } \ No newline at end of file diff --git a/backend/services/semester_service.py b/backend/services/semester_service.py deleted file mode 100644 index 63e6448..0000000 --- a/backend/services/semester_service.py +++ /dev/null @@ -1,456 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 学期服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -import datetime -from typing import Dict, Any, List, Optional - -from models.semester import SemesterModel, SemesterArchiveModel -from models.student import StudentModel -from middleware.permission import PermissionChecker -from config import settings -from utils.logger import get_logger -from utils.database import execute_query - -logger = get_logger(__name__) - - -class SemesterService: - """学期管理服务""" - - @staticmethod - async def list_semesters() -> Dict[str, Any]: - """获取学期列表""" - try: - semesters = await SemesterModel.get_all() - today = datetime.date.today() - for sem in semesters: - counts = await SemesterModel.count_records_by_semester(sem['semester_id']) - sem['conduct_count'] = counts['conduct_count'] - sem['attendance_count'] = counts['attendance_count'] - # 计算当前周数(仅活跃学期且有开始日期时) - sem['current_week'] = None - if sem.get('is_active') and sem.get('start_date'): - try: - s_date = sem['start_date'] - if isinstance(s_date, str): - s_date = datetime.datetime.strptime(s_date, '%Y-%m-%d').date() - delta = (today - s_date).days - if delta >= 0: - sem['current_week'] = delta // 7 + 1 - except (ValueError, TypeError): - pass - return { - "success": True, - "semesters": semesters - } - except Exception as e: - logger.error(f"获取学期列表失败: {e}") - return {"success": False, "message": f"获取学期列表失败: {str(e)}"} - - @staticmethod - async def create_semester( - semester_name: str, - start_date: str = None, - end_date: str = None, - operator_id: int = None - ) -> Dict[str, Any]: - """创建新学期""" - if not semester_name or not semester_name.strip(): - return {"success": False, "message": "学期名称不能为空"} - - try: - # 创建学期(不预先 deactivate_all) - semester_id = await SemesterModel.create( - semester_name=semester_name.strip(), - start_date=start_date, - end_date=end_date - ) - - # 判断新学期的日期范围是否包含今天,决定是否自动激活 - should_activate = False - if start_date is not None: - try: - today = datetime.date.today() - s_date = datetime.datetime.strptime(start_date, "%Y-%m-%d").date() - e_date = ( - datetime.datetime.strptime(end_date, "%Y-%m-%d").date() - if end_date is not None - else None - ) - if s_date <= today and (e_date is None or e_date >= today): - should_activate = True - except (ValueError, TypeError): - should_activate = False - - if should_activate: - # 日期范围包含今天,自动激活 - await SemesterModel.deactivate_all() - await SemesterModel.activate(semester_id) - logger.info( - f"用户[{operator_id}] 创建并激活新学期: {semester_name}" - ) - else: - # 补录历史学期或未来学期,不激活 - logger.info( - f"用户[{operator_id}] 创建补录学期(未激活): {semester_name}" - ) - - return { - "success": True, - "message": "学期创建成功", - "semester_id": semester_id - } - except Exception as e: - logger.error(f"创建学期失败: {e}") - return {"success": False, "message": f"创建学期失败: {str(e)}"} - - @staticmethod - async def activate_semester( - semester_id: int, - operator_id: int = None - ) -> Dict[str, Any]: - """设为当前活跃学期""" - try: - # 检查学期是否存在 - semester = await SemesterModel.get_by_id(semester_id) - if not semester: - return {"success": False, "message": "学期不存在"} - - # 已归档的学期不能激活 - if semester['is_archived']: - return {"success": False, "message": "已归档的学期不能设为当前学期"} - - # 自动将之前的活跃学期设为非活跃 - await SemesterModel.deactivate_all() - - # 设为活跃 - result = await SemesterModel.activate(semester_id) - - if result: - logger.info(f"用户[{operator_id}] 激活了学期: {semester['semester_name']}") - return {"success": True, "message": "已设为当前学期"} - else: - return {"success": False, "message": "激活失败"} - except Exception as e: - logger.error(f"激活学期失败: {e}") - return {"success": False, "message": f"激活学期失败: {str(e)}"} - - @staticmethod - async def update_semester( - semester_id: int, - semester_name: str = None, - start_date: str = None, - end_date: str = None, - operator_id: int = None - ) -> Dict[str, Any]: - """编辑学期信息""" - try: - semester = await SemesterModel.get_by_id(semester_id) - if not semester: - return {"success": False, "message": "学期不存在"} - - if semester['is_archived']: - return {"success": False, "message": "已归档的学期不能编辑"} - - result = await SemesterModel.update( - semester_id=semester_id, - semester_name=semester_name, - start_date=start_date, - end_date=end_date - ) - - if result: - logger.info(f"用户[{operator_id}] 编辑了学期: {semester['semester_name']}") - return {"success": True, "message": "学期信息已更新"} - else: - return {"success": False, "message": "更新失败,请检查参数"} - except Exception as e: - logger.error(f"编辑学期失败: {e}") - return {"success": False, "message": f"编辑学期失败: {str(e)}"} - - @staticmethod - async def delete_semester( - semester_id: int, - operator_id: int = None - ) -> Dict[str, Any]: - """删除学期""" - try: - semester = await SemesterModel.get_by_id(semester_id) - if not semester: - return {"success": False, "message": "学期不存在"} - - # 检查是否有关联归档数据 - archive_count = await SemesterModel.count_archives(semester_id) - if archive_count > 0: - return {"success": False, "message": f"该学期有 {archive_count} 条归档数据,无法删除"} - - result = await SemesterModel.delete(semester_id) - - if result: - logger.info(f"用户[{operator_id}] 删除了学期: {semester['semester_name']}") - return {"success": True, "message": "学期已删除"} - else: - return {"success": False, "message": "删除失败"} - except Exception as e: - logger.error(f"删除学期失败: {e}") - return {"success": False, "message": f"删除学期失败: {str(e)}"} - - @staticmethod - async def associate_records( - semester_id: int, - operator_id: int = None - ) -> Dict[str, Any]: - """关联记录到学期""" - try: - semester = await SemesterModel.get_by_id(semester_id) - if not semester: - return {"success": False, "message": "学期不存在"} - - if semester['is_archived']: - return {"success": False, "message": "已归档的学期不能关联数据"} - - start_date = semester.get('start_date') - if not start_date: - return {"success": False, "message": "学期未设置开始日期,无法关联数据"} - - end_date = semester.get('end_date') or datetime.date.today().isoformat() - - counts = await SemesterModel.associate_records_by_date_range( - semester_id=semester_id, - start_date=start_date, - end_date=end_date - ) - - logger.info( - f"用户[{operator_id}] 关联数据到学期: {semester['semester_name']}, " - f"操行分 {counts['conduct']} 条, 考勤 {counts['attendance']} 条" - ) - - return { - "success": True, - "message": f"关联完成:操行分 {counts['conduct']} 条,考勤 {counts['attendance']} 条", - "data": counts - } - except Exception as e: - logger.error(f"关联记录失败: {e}") - return {"success": False, "message": f"关联记录失败: {str(e)}"} - - @staticmethod - async def archive_semester( - semester_id: int, - operator_id: int = None, - reset_scores: bool = False - ) -> Dict[str, Any]: - """归档学期""" - try: - # 检查学期是否存在 - semester = await SemesterModel.get_by_id(semester_id) - if not semester: - return {"success": False, "message": "学期不存在"} - - # 已归档的不能重复归档 - if semester['is_archived']: - return {"success": False, "message": "该学期已归档"} - - # 校验开始日期:无 start_date 时作业统计会全部归零 - start_date = semester.get('start_date') - if not start_date: - return {"success": False, "message": "学期未设置开始日期,无法进行归档"} - - # 获取所有活跃学生及其当前分数 - students = await StudentModel.get_all(include_disabled=False) - if not students: - return {"success": False, "message": "没有可归档的学生数据"} - - total_students = len(students) - - # 获取学期的日期范围,用于查询考勤和作业统计 - end_date = semester.get('end_date') or datetime.date.today().isoformat() - - # 批量查询考勤和作业统计 - attendance_stats = await SemesterModel.get_attendance_stats_by_semester( - semester_id, start_date, end_date - ) - homework_stats = await SemesterModel.get_homework_stats_by_date_range( - start_date, end_date - ) - - # 构建 attendance_map: {student_id: {status_field: cnt, ...}} - attendance_map = {} - for stat in attendance_stats: - sid = stat['student_id'] - if sid not in attendance_map: - attendance_map[sid] = { - 'attendance_present': 0, 'attendance_absent': 0, - 'attendance_late': 0, 'attendance_leave': 0 - } - status_key = { - 'present': 'attendance_present', - 'absent': 'attendance_absent', - 'late': 'attendance_late', - 'leave': 'attendance_leave' - }.get(stat['status']) - if status_key: - attendance_map[sid][status_key] = stat['cnt'] - - # 构建 homework_map: {student_id: {status_field: cnt, ...}} - homework_map = {} - for stat in homework_stats: - sid = stat['student_id'] - if sid not in homework_map: - homework_map[sid] = { - 'homework_submitted': 0, 'homework_not_submitted': 0, - 'homework_late': 0 - } - status_key = { - 'submitted': 'homework_submitted', - 'not_submitted': 'homework_not_submitted', - 'late': 'homework_late' - }.get(stat['status']) - if status_key: - homework_map[sid][status_key] = stat['cnt'] - - # 按分数降序排列以计算排名 - sorted_students = sorted(students, key=lambda s: s['total_points'], reverse=True) - - # 构建归档快照数据 - archives_data = [] - for rank, student in enumerate(sorted_students, 1): - att = attendance_map.get(student['student_id'], {}) - hw = homework_map.get(student['student_id'], {}) - archives_data.append({ - 'semester_id': semester_id, - 'student_id': student['student_id'], - 'student_no': student['student_no'], - 'student_name': student['name'], - 'final_points': student['total_points'], - 'rank_position': rank, - 'total_students': total_students, - 'attendance_present': att.get('attendance_present', 0), - 'attendance_absent': att.get('attendance_absent', 0), - 'attendance_late': att.get('attendance_late', 0), - 'attendance_leave': att.get('attendance_leave', 0), - 'homework_submitted': hw.get('homework_submitted', 0), - 'homework_not_submitted': hw.get('homework_not_submitted', 0), - 'homework_late': hw.get('homework_late', 0), - }) - # 删除已有的归档数据以保证幂等性,再保存归档快照 - await SemesterArchiveModel.delete_by_semester(semester_id) - await SemesterArchiveModel.batch_create(archives_data) - - # 标记学期为已归档 - await SemesterModel.archive(semester_id) - - # 归档成功后按需重置学生操行分 - if reset_scores: - reset_result = await SemesterService.reset_student_points() - logger.info( - f"用户[{operator_id}] 归档学期: {semester['semester_name']} 并重置学生操行分, " - f"共 {total_students} 名学生" - ) - return { - "success": True, - "message": f"学期归档成功,共归档 {total_students} 名学生数据,已重置学生操行分" - } - - logger.info( - f"用户[{operator_id}] 归档了学期: {semester['semester_name']}, " - f"共 {total_students} 名学生" - ) - - return { - "success": True, - "message": f"学期归档成功,共归档 {total_students} 名学生数据" - } - except Exception as e: - logger.error(f"归档学期失败: {e}") - return {"success": False, "message": f"归档学期失败: {str(e)}"} - - @staticmethod - async def get_archive_records( - semester_id: int, - page: int = 1, - page_size: int = 50 - ) -> Dict[str, Any]: - """获取归档数据(只读)""" - try: - # 检查学期是否存在 - semester = await SemesterModel.get_by_id(semester_id) - if not semester: - return {"success": False, "message": "学期不存在"} - - archives = await SemesterArchiveModel.get_by_semester(semester_id) - - total = len(archives) - offset = (page - 1) * page_size - paged_archives = archives[offset:offset + page_size] - - return { - "success": True, - "data": { - "semester": { - "semester_id": semester['semester_id'], - "semester_name": semester['semester_name'], - "start_date": semester.get('start_date'), - "end_date": semester.get('end_date'), - "is_archived": bool(semester['is_archived']) - }, - "archives": paged_archives, - "total": total, - "page": page, - "page_size": page_size, - "total_pages": (total + page_size - 1) // page_size - } - } - except Exception as e: - logger.error(f"获取归档数据失败: {e}") - return {"success": False, "message": f"获取归档数据失败: {str(e)}"} - - @staticmethod - async def reset_student_points(initial_points: int = None) -> Dict[str, Any]: - """重置所有学生的操行分为初始分""" - if initial_points is None: - initial_points = settings.STUDENT_INITIAL_POINTS - - try: - from utils.database import execute_update - sql = "UPDATE students SET total_points = %s WHERE status = 1" - affected = await execute_update(sql, (initial_points,)) - - logger.info(f"已重置 {affected} 名学生的操行分为 {initial_points}") - return { - "success": True, - "message": f"已重置 {affected} 名学生的操行分为 {initial_points} 分", - "affected": affected - } - except Exception as e: - logger.error(f"重置学生分数失败: {e}") - return {"success": False, "message": f"重置学生分数失败: {str(e)}"} - - @staticmethod - async def get_active_semester() -> Dict[str, Any]: - """获取当前活跃学期""" - try: - active = await SemesterModel.get_active() - if active: - return { - "success": True, - "semester": active - } - else: - return { - "success": True, - "semester": None, - "message": "当前没有活跃学期" - } - except Exception as e: - logger.error(f"获取活跃学期失败: {e}") - return {"success": False, "message": f"获取活跃学期失败: {str(e)}"} diff --git a/backend/services/student_service.py b/backend/services/student_service.py deleted file mode 100644 index 403f354..0000000 --- a/backend/services/student_service.py +++ /dev/null @@ -1,140 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from typing import Dict, Any, List, Optional -from datetime import datetime, timedelta - -from models.student import StudentModel -from models.conduct import ConductModel -from models.attendance import AttendanceModel -from middleware.permission import PermissionChecker -from utils.database import execute_query -from utils.logger import get_logger - -logger = get_logger(__name__) - - -class StudentService: - """学生服务""" - - @staticmethod - async def get_conduct_history( - student_id: int, - limit: int = 50, - offset: int = 0 - ) -> Dict[str, Any]: - """获取学生操行分历史(学生端显示,扣分项操作人显示为班主任)""" - student = await StudentModel.get_by_id(student_id) - if not student: - return {"error": "学生不存在"} - - records = await ConductModel.get_student_records( - student_id=student_id, - limit=limit, - offset=offset - ) - - # 处理记录:扣分项的操作人统一显示为"班主任" - for record in records: - if record["points_change"] < 0: # 扣分项 - record["recorder_name"] = "班主任" - # 加分项保持原操作人不变 - - return { - "student_id": student_id, - "student_name": student["name"], - "total_points": student["total_points"], - "records": records - } - - @staticmethod - async def get_homework_status(student_id: int) -> Dict[str, Any]: - """获取学生作业扣分记录""" - student = await StudentModel.get_by_id(student_id) - if not student: - return {"error": "学生不存在"} - - # 查询作业相关的操行分记录 - sql = """ - SELECT cr.record_id, cr.points_change, cr.reason, cr.created_at, - cr.related_type, cr.recorder_name - FROM conduct_records cr - WHERE cr.student_id = %s AND cr.related_type = 'homework' AND cr.is_revoked = 0 - ORDER BY cr.created_at DESC - """ - records = await execute_query(sql, (student_id,)) - - # 统计 - total = len(records) - deductions = sum(1 for r in records if r["points_change"] < 0) - - return { - "student_id": student_id, - "student_name": student["name"], - "statistics": { - "total": total, - "deductions": deductions - }, - "homework": records - } - - @staticmethod - async def get_attendance_records( - student_id: int, - month: Optional[str] = None - ) -> Dict[str, Any]: - """获取学生考勤记录""" - student = await StudentModel.get_by_id(student_id) - if not student: - return {"error": "学生不存在"} - - records = await AttendanceModel.get_student_records( - student_id=student_id, - month=month - ) - - # 统计 - present = sum(1 for r in records if r["status"] == "present") - absent = sum(1 for r in records if r["status"] == "absent") - late = sum(1 for r in records if r["status"] == "late") - leave = sum(1 for r in records if r["status"] == "leave") - - return { - "student_id": student_id, - "student_name": student["name"], - "statistics": { - "present": present, - "absent": absent, - "late": late, - "leave": leave, - "total": len(records) - }, - "records": records - } - - @staticmethod - async def get_ranking( - user_id: int, - limit: int = 50 - ) -> Dict[str, Any]: - """获取排行榜(单班级系统)""" - ranking = await StudentModel.get_ranking(limit=limit) - total_students = await StudentModel.get_total_count() - - return { - "ranking": ranking, - "total_students": total_students - } - - @staticmethod - async def get_student_info(student_id: int) -> Optional[Dict[str, Any]]: - """获取学生个人信息""" - return await StudentModel.get_by_id(student_id) \ No newline at end of file diff --git a/backend/services/subject_service.py b/backend/services/subject_service.py deleted file mode 100644 index 7743050..0000000 --- a/backend/services/subject_service.py +++ /dev/null @@ -1,82 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from typing import Dict, Any, List, Optional - -from models.subject import SubjectModel -from utils.logger import get_logger - -logger = get_logger(__name__) - - -class SubjectService: - """科目服务""" - - @staticmethod - async def get_subjects(is_active: Optional[bool] = None) -> Dict[str, Any]: - """获取科目列表""" - subjects = await SubjectModel.get_all(is_active=is_active) - - return { - "subjects": subjects, - "total": len(subjects) - } - - @staticmethod - async def create_subject( - subject_name: str, - subject_code: Optional[str], - sort_order: int = 0 - ) -> Dict[str, Any]: - """创建科目""" - # 检查是否已存在 - existing = await SubjectModel.get_by_name(subject_name) - if existing: - return {"success": False, "message": "科目名称已存在"} - - subject_id = await SubjectModel.create( - subject_name=subject_name, - subject_code=subject_code, - sort_order=sort_order - ) - - if subject_id: - logger.info(f"创建科目: {subject_name}") - return {"success": True, "subject_id": subject_id} - else: - return {"success": False, "message": "创建科目失败"} - - @staticmethod - async def update_subject(subject_id: int, **kwargs) -> Dict[str, Any]: - """更新科目""" - result = await SubjectModel.update(subject_id, **kwargs) - - if result: - logger.info(f"更新科目: {subject_id}") - return {"success": True} - else: - return {"success": False, "message": "更新科目失败"} - - @staticmethod - async def delete_subject(subject_id: int) -> Dict[str, Any]: - """删除科目(真正删除记录)""" - # 检查科目是否有关联数据 - has_data = await SubjectModel.has_related_data(subject_id) - if has_data: - return {"success": False, "message": "该科目下已有作业数据,无法删除"} - - result = await SubjectModel.delete(subject_id) - - if result: - logger.info(f"删除科目: {subject_id}") - return {"success": True} - else: - return {"success": False, "message": "删除科目失败"} \ No newline at end of file diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py deleted file mode 100644 index 638547a..0000000 --- a/backend/utils/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - diff --git a/backend/utils/database.py b/backend/utils/database.py deleted file mode 100644 index cc18082..0000000 --- a/backend/utils/database.py +++ /dev/null @@ -1,150 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 数据库连接池 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -import aiomysql -from typing import Optional, Dict, Any, List -from contextlib import asynccontextmanager -from datetime import datetime, date - -def _convert_datetime(obj: Any) -> Any: - '''递归转换datetime对象为字符串''' - if isinstance(obj, datetime): - return obj.strftime('%Y-%m-%d %H:%M:%S') - elif isinstance(obj, date): - return obj.strftime('%Y-%m-%d') - elif isinstance(obj, dict): - return {k: _convert_datetime(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [_convert_datetime(item) for item in obj] - return obj - -from config import settings -from utils.logger import get_logger - -logger = get_logger(__name__) - -# 连接池实例 -_pool: Optional[aiomysql.Pool] = None - - -async def init_db_pool() -> None: - """初始化数据库连接池""" - global _pool - try: - _pool = await aiomysql.create_pool( - host=settings.DB_HOST, - port=settings.DB_PORT, - user=settings.DB_USER, - password=settings.DB_PASSWORD, - db=settings.DB_NAME, - minsize=1, - maxsize=settings.DB_POOL_SIZE, - autocommit=False, - charset='utf8mb4', - cursorclass=aiomysql.DictCursor - ) - logger.info("数据库连接池初始化成功") - except Exception as e: - logger.error(f"数据库连接池初始化失败: {e}") - raise - - -async def close_db_pool() -> None: - """关闭数据库连接池""" - global _pool - if _pool: - _pool.close() - await _pool.wait_closed() - logger.info("数据库连接池已关闭") - - -def get_pool() -> aiomysql.Pool: - """获取连接池实例""" - if _pool is None: - raise RuntimeError("数据库连接池未初始化") - return _pool - - -@asynccontextmanager -async def get_connection(): - """获取数据库连接(上下文管理器)""" - pool = get_pool() - async with pool.acquire() as conn: - async with conn.cursor() as cursor: - yield cursor - await conn.commit() - - -@asynccontextmanager -async def get_transaction(): - """获取事务连接""" - pool = get_pool() - async with pool.acquire() as conn: - async with conn.cursor() as cursor: - try: - yield cursor - await conn.commit() - except Exception: - await conn.rollback() - raise - - -async def execute_query(sql: str, params: tuple = None) -> List[Dict[str, Any]]: - """执行查询SQL""" - async with get_connection() as cursor: - await cursor.execute(sql, params) - result = await cursor.fetchall() - return _convert_datetime(result) - - -async def execute_one(sql: str, params: tuple = None) -> Optional[Dict[str, Any]]: - """执行查询SQL(单条)""" - async with get_connection() as cursor: - await cursor.execute(sql, params) - result = await cursor.fetchone() - return _convert_datetime(result) - - -async def execute_insert(sql: str, params: tuple = None) -> int: - """执行插入SQL,返回自增ID""" - async with get_connection() as cursor: - await cursor.execute(sql, params) - return cursor.lastrowid - - -async def execute_update(sql: str, params: tuple = None) -> int: - """执行更新SQL,返回影响行数""" - async with get_connection() as cursor: - result = await cursor.execute(sql, params) - return result - - -async def execute_many(sql: str, params_list: list) -> int: - """批量执行SQL""" - async with get_connection() as cursor: - await cursor.executemany(sql, params_list) - return cursor.rowcount - - -async def call_procedure(proc_name: str, args: tuple = None) -> List[Dict[str, Any]]: - """调用存储过程""" - async with get_connection() as cursor: - if args: - await cursor.callproc(proc_name, args) - else: - await cursor.callproc(proc_name) - - # 获取结果 - result = [] - for result_set in cursor.fetchall(): - if result_set: - result.extend(result_set) - return result \ No newline at end of file diff --git a/backend/utils/jwt_handler.py b/backend/utils/jwt_handler.py deleted file mode 100644 index 1862fbe..0000000 --- a/backend/utils/jwt_handler.py +++ /dev/null @@ -1,87 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from jose import jwt, JWTError -from datetime import datetime, timedelta -from typing import Optional, Dict, Any - -from config import settings -from utils.logger import get_logger - -logger = get_logger(__name__) - - -class JWTHandler: - """JWT Token处理类""" - - @staticmethod - def create_token(user_id: int, username: str, user_type: str, student_id: int = None, role: str = None, real_name: str = None) -> str: - """ - 创建JWT Token - """ - payload = { - 'user_id': user_id, - 'username': username, - 'user_type': user_type, - 'student_id': student_id, - 'role': role, - 'real_name': real_name, - 'exp': datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRE_MINUTES), - 'iat': datetime.utcnow(), - 'iss': settings.APP_NAME - } - - token = jwt.encode( - payload, - settings.JWT_SECRET_KEY, - algorithm=settings.JWT_ALGORITHM - ) - return token - - @staticmethod - def verify_token(token: str) -> Optional[Dict[str, Any]]: - """ - 验证JWT Token - 返回: 解码后的payload,失败返回None - """ - try: - payload = jwt.decode( - token, - settings.JWT_SECRET_KEY, - algorithms=[settings.JWT_ALGORITHM], - options={'verify_exp': True} - ) - return payload - except jwt.ExpiredSignatureError: - logger.warning("JWT Token已过期") - return None - except jwt.JWTError as e: - logger.warning(f"JWT Token验证失败: {e}") - return None - - @staticmethod - def get_user_id_from_token(token: str) -> Optional[int]: - """从Token中获取用户ID""" - payload = JWTHandler.verify_token(token) - if payload: - return payload.get('user_id') - return None - - @staticmethod - def get_user_type_from_token(token: str) -> Optional[str]: - """从Token中获取用户类型""" - payload = JWTHandler.verify_token(token) - if payload: - return payload.get('user_type') - return None - - -jwt_handler = JWTHandler() \ No newline at end of file diff --git a/backend/utils/logger.py b/backend/utils/logger.py deleted file mode 100644 index ff36b03..0000000 --- a/backend/utils/logger.py +++ /dev/null @@ -1,102 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -import sys -from loguru import logger -from pathlib import Path -from fastapi import Request - -from config import settings - - -# 日志目录 -LOG_DIR = Path(__file__).parent.parent / "logs" -LOG_DIR.mkdir(exist_ok=True) - - -def setup_logger(): - """配置日志系统""" - - # 移除默认处理器 - logger.remove() - - # 控制台输出(仅INFO及以上) - logger.add( - sys.stdout, - format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name} - {message}", - level=settings.LOG_LEVEL, - colorize=True - ) - - # 应用日志(轮转) - logger.add( - LOG_DIR / "app.log", - format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name} | {message}", - rotation=settings.LOG_MAX_BYTES, - retention=settings.LOG_RETENTION_DAYS, - compression="gz", - encoding="utf-8", - level="DEBUG" - ) - - # 错误日志(单独记录) - logger.add( - LOG_DIR / "error.log", - format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name} | {message}", - rotation=settings.LOG_MAX_BYTES, - retention=settings.LOG_RETENTION_DAYS * 2, - compression="gz", - encoding="utf-8", - level="ERROR" - ) - - # 访问日志 - logger.add( - LOG_DIR / "access.log", - format="{time:YYYY-MM-DD HH:mm:ss} | {message}", - rotation="1 day", - retention="90 days", - compression="gz", - encoding="utf-8", - filter=lambda record: record["extra"].get("type") == "access" - ) - - # 操作日志 - logger.add( - LOG_DIR / "operation.log", - format="{time:YYYY-MM-DD HH:mm:ss} | {message}", - rotation=settings.LOG_MAX_BYTES, - retention=settings.LOG_RETENTION_DAYS, - compression="gz", - encoding="utf-8", - filter=lambda record: record["extra"].get("type") == "operation" - ) - - return logger - - -def get_logger(name: str): - """获取日志记录器""" - return logger.bind(name=name) - - -def log_access(request: Request): - """记录访问日志""" - logger.bind(type="access").info(f"{request.method} {request.url.path} - {request.client.host}") - - -def log_operation(operator_id: int, operator_name: str, action: str, details: str = ""): - """记录操作日志""" - logger.bind(type="operation").info(f"用户[{operator_id}:{operator_name}] 执行 {action} - {details}") - - -# 导出logger -__all__ = ["setup_logger", "get_logger", "log_access", "log_operation", "logger"] \ No newline at end of file diff --git a/backend/utils/redis_client.py b/backend/utils/redis_client.py deleted file mode 100644 index 3cd376b..0000000 --- a/backend/utils/redis_client.py +++ /dev/null @@ -1,140 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -import redis.asyncio as redis -from typing import Optional, Any -import json - -from config import settings -from utils.logger import get_logger - -logger = get_logger(__name__) - -# Redis客户端实例 -_redis_client: Optional[redis.Redis] = None - - -async def init_redis_pool() -> None: - """初始化Redis连接池""" - global _redis_client - try: - _redis_client = redis.from_url( - settings.REDIS_URL, - max_connections=settings.REDIS_MAX_CONNECTIONS, - decode_responses=True - ) - # 测试连接 - await _redis_client.ping() - logger.info("Redis连接池初始化成功") - except Exception as e: - logger.error(f"Redis连接池初始化失败: {e}") - raise - - -async def close_redis_pool() -> None: - """关闭Redis连接池""" - global _redis_client - if _redis_client: - await _redis_client.close() - logger.info("Redis连接池已关闭") - - -def get_redis() -> redis.Redis: - """获取Redis客户端""" - if _redis_client is None: - raise RuntimeError("Redis客户端未初始化") - return _redis_client - - -class RedisClient: - """Redis操作封装类""" - - @staticmethod - async def set(key: str, value: Any, expire: int = None) -> bool: - """设置缓存""" - client = get_redis() - if isinstance(value, (dict, list)): - value = json.dumps(value, ensure_ascii=False) - else: - value = str(value) - - if expire: - return await client.setex(key, expire, value) - return await client.set(key, value) - - @staticmethod - async def get(key: str) -> Optional[str]: - """获取缓存""" - client = get_redis() - return await client.get(key) - - @staticmethod - async def get_json(key: str) -> Optional[Any]: - """获取JSON格式缓存""" - value = await RedisClient.get(key) - if value: - try: - return json.loads(value) - except json.JSONDecodeError: - return value - return None - - @staticmethod - async def delete(key: str) -> int: - """删除缓存""" - client = get_redis() - return await client.delete(key) - - @staticmethod - async def exists(key: str) -> bool: - """检查key是否存在""" - client = get_redis() - return await client.exists(key) > 0 - - @staticmethod - async def expire(key: str, seconds: int) -> bool: - """设置过期时间""" - client = get_redis() - return await client.expire(key, seconds) - - @staticmethod - async def set_user_token(user_id: int, token: str, expire: int = None) -> bool: - """设置用户Token缓存""" - key = f"user_token:{user_id}" - expire = expire or settings.JWT_EXPIRE_MINUTES * 60 - return await RedisClient.set(key, token, expire) - - @staticmethod - async def get_user_token(user_id: int) -> Optional[str]: - """获取用户Token""" - key = f"user_token:{user_id}" - return await RedisClient.get(key) - - @staticmethod - async def delete_user_token(user_id: int) -> int: - """删除用户Token""" - key = f"user_token:{user_id}" - return await RedisClient.delete(key) - - @staticmethod - async def set_login_attempts(username: str) -> int: - """记录登录失败次数""" - key = f"login_attempts:{username}" - attempts = await RedisClient.get(key) - attempts = int(attempts) + 1 if attempts else 1 - await RedisClient.set(key, attempts, 300) # 5分钟锁定 - return attempts - - @staticmethod - async def clear_login_attempts(username: str) -> None: - """清除登录失败记录""" - key = f"login_attempts:{username}" - await RedisClient.delete(key) \ No newline at end of file diff --git a/backend/utils/response.py b/backend/utils/response.py deleted file mode 100644 index d994066..0000000 --- a/backend/utils/response.py +++ /dev/null @@ -1,106 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -from typing import Any, Optional, Dict, List -from fastapi.responses import JSONResponse - - -class ResponseCode: - """响应状态码""" - SUCCESS = 200 - CREATED = 201 - BAD_REQUEST = 400 - UNAUTHORIZED = 401 - FORBIDDEN = 403 - NOT_FOUND = 404 - CONFLICT = 409 - UNPROCESSABLE = 422 - INTERNAL_ERROR = 500 - - -def success_response(data: Any = None, message: str = "操作成功") -> JSONResponse: - """成功响应""" - return JSONResponse( - status_code=ResponseCode.SUCCESS, - content={ - "success": True, - "code": ResponseCode.SUCCESS, - "message": message, - "data": data - } - ) - - -def error_response( - message: str = "操作失败", - code: int = ResponseCode.BAD_REQUEST, - data: Any = None -) -> JSONResponse: - """错误响应""" - return JSONResponse( - status_code=code, - content={ - "success": False, - "code": code, - "message": message, - "data": data - } - ) - - -def unauthorized_response(message: str = "未授权,请重新登录") -> JSONResponse: - """未授权响应""" - return JSONResponse( - status_code=ResponseCode.UNAUTHORIZED, - content={ - "success": False, - "code": ResponseCode.UNAUTHORIZED, - "message": message, - "data": None - } - ) - - -def forbidden_response(message: str = "权限不足") -> JSONResponse: - """禁止访问响应""" - return JSONResponse( - status_code=ResponseCode.FORBIDDEN, - content={ - "success": False, - "code": ResponseCode.FORBIDDEN, - "message": message, - "data": None - } - ) - - -def not_found_response(message: str = "资源不存在") -> JSONResponse: - """资源不存在响应""" - return JSONResponse( - status_code=ResponseCode.NOT_FOUND, - content={ - "success": False, - "code": ResponseCode.NOT_FOUND, - "message": message, - "data": None - } - ) - - -def paginated_response(items: List[Any], total: int, page: int, page_size: int) -> Dict: - """分页响应数据""" - return { - "items": items, - "total": total, - "page": page, - "page_size": page_size, - "total_pages": (total + page_size - 1) // page_size - } \ No newline at end of file diff --git a/backend/utils/security.py b/backend/utils/security.py deleted file mode 100644 index 00b5ee0..0000000 --- a/backend/utils/security.py +++ /dev/null @@ -1,173 +0,0 @@ -# =========================================== -# 班级操行分管理系统 - 后端服务 -# -# 开发者: Canglan -# 联系方式: admin@sea-studio.top -# 版权归属: Sea Network Technology Studio -# 许可证: MIT License -# -# 版权所有 © Sea Network Technology Studio -# =========================================== - -import hashlib -import secrets -import re -from passlib.hash import bcrypt as bcrypt_hash -from config import settings - - -class SecurityUtils: - """安全工具类""" - - @staticmethod - def sha1_md5_password(password: str) -> str: - """ - 双重加密:sha1 + md5 - 流程:原始密码 -> sha1 -> 加盐 -> md5 - """ - # 第一层:SHA1 - sha1_hash = hashlib.sha1(password.encode('utf-8')).hexdigest() - # 加盐 - salted = sha1_hash + settings.PASSWORD_SALT - # 第二层:MD5 - md5_hash = hashlib.md5(salted.encode('utf-8')).hexdigest() - return md5_hash - - @staticmethod - def bcrypt_password(password: str) -> str: - """使用bcrypt加密密码""" - return bcrypt_hash.using(rounds=12).hash(password) - - @staticmethod - def verify_password_v2(plain_password: str, hashed_password: str) -> tuple: - """ - 验证密码(支持bcrypt和旧哈希) - 返回: (是否验证成功, 是否需要升级哈希) - """ - try: - if bcrypt_hash.verify(plain_password, hashed_password): - return True, False - except Exception: - pass - # 回退到旧的sha1_md5验证 - if SecurityUtils.sha1_md5_password(plain_password) == hashed_password: - return True, True - return False, False - - @staticmethod - def verify_password(plain_password: str, hashed_password: str) -> bool: - """验证密码""" - return SecurityUtils.sha1_md5_password(plain_password) == hashed_password - - @staticmethod - def generate_random_password(length: int = 8) -> str: - """生成随机密码""" - alphabet = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789' - return ''.join(secrets.choice(alphabet) for _ in range(length)) - - @staticmethod - def validate_password_strength(password: str) -> tuple: - """ - 验证密码强度 - 要求:大小写字母、数字、特殊符号 至少包含其中3种 - 返回: (是否有效, 错误信息) - """ - if len(password) < 6: - return False, "密码长度至少6位" - if len(password) > 20: - return False, "密码长度不能超过20位" - - # 检查四种字符类型 - has_upper = any(c.isupper() for c in password) # 大写字母 - has_lower = any(c.islower() for c in password) # 小写字母 - has_digit = any(c.isdigit() for c in password) # 数字 - has_special = any(not c.isalnum() for c in password) # 特殊符号 - - # 统计满足的字符类型数量 - char_types = sum([has_upper, has_lower, has_digit, has_special]) - - if char_types < 3: - return False, "密码必须包含大写字母、小写字母、数字、特殊符号中的至少3种" - - return True, "" - - @staticmethod - def sanitize_string(value: str, max_length: int = 255) -> str: - """ - 清理字符串输入 - - 去除首尾空格 - - 限制长度 - - 转义特殊字符 - """ - if not value: - return "" - - # 去除首尾空格 - value = value.strip() - - # SQL注入模式检测 - sql_patterns = [ - r'(?i)(\bunion\b\s+\bselect\b)', - r'(?i)(\bor\b\s+\d+\s*=\s*\d+)', - r'(?i)(\bdrop\b\s+\btable\b)', - r'(?i)(\bdelete\b\s+\bfrom\b)', - r'(?i)(\binsert\b\s+\binto\b)', - r'(?i)(\bupdate\b\s+\w+\s+\bset\b)', - ] - for pattern in sql_patterns: - value = re.sub(pattern, '', value) - - # 路径遍历检测 - value = value.replace('../', '').replace('..\\', '') - - # 限制长度 - if len(value) > max_length: - value = value[:max_length] - - # 转义HTML特殊字符(防止XSS) - html_chars = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - '/': '/' - } - for char, escape in html_chars.items(): - value = value.replace(char, escape) - - return value - - @staticmethod - def validate_student_no(student_no: str) -> bool: - """验证学号格式(数字+字母,长度4-20)""" - if not student_no: - return False - if len(student_no) < 4 or len(student_no) > 20: - return False - # 字母数字组合 - return student_no.isalnum() - - @staticmethod - def validate_phone(phone: str) -> bool: - """验证手机号格式(中国手机号)""" - if not phone: - return False - pattern = r'^1[3-9]\d{9}$' - return bool(re.match(pattern, phone)) - - @staticmethod - def validate_points_change(points: int, max_abs: int = 100) -> tuple: - """ - 验证分值变动 - 返回: (是否有效, 错误信息) - """ - if points == 0: - return False, "分值不能为0" - if abs(points) > max_abs: - return False, f"单次分值变动不能超过{max_abs}分" - return True, "" - - -# 单例导出 -security = SecurityUtils() \ No newline at end of file diff --git a/docs/cadre.md b/docs/cadre.md deleted file mode 100644 index 378bb6f..0000000 --- a/docs/cadre.md +++ /dev/null @@ -1,167 +0,0 @@ -# 班干部使用文档 - -## 登录 - -1. 打开系统网址,进入登录页面 -2. 输入**用户名**和**密码** -3. 点击"登录"按钮 -4. 首次登录系统会强制要求修改密码 - -> **密码要求**:长度6-20位,必须包含大写字母、小写字母、数字、特殊符号中的至少3种。 - ---- - -## 角色权限一览 - -| 角色 | 操行分管理 | 历史记录 | 作业扣分 | 考勤管理 | 科目管理 | -|------|-----------|---------|---------|---------|---------| -| 班长 | ±5分以内 | 全部(可撤销) | - | - | - | -| 学习委员 | ±5分以内 | 自己的 | ✓ | - | ✓ | -| 考勤委员 | - | 自己的 | - | ✓ | - | -| 劳动委员 | ±1分(卫生值日) | 自己的 | - | - | - | -| 志愿委员 | 仅加分 | 自己的 | - | - | - | - ---- - -## 功能说明 - -### 班长 - -#### 操行分管理 (conduct.php) - -**加减分操作**: -1. 在学生列表中勾选目标学生 -2. 点击"批量加减分"按钮 -3. 填写分数变动(±5分以内)和原因 -4. 点击"确认提交" - -**加减分限制**:每次操作不超过±5分。 - -#### 历史记录 (history.php) - -- 可查看全班所有操行分变动记录 -- 可按时间范围和学生筛选 -- **可撤销**任何人的扣分记录 - ---- - -### 学习委员 - -#### 作业扣分 (homework.php) - -**批量扣分**: -1. 在学生列表中勾选目标学生 -2. 点击"批量加减分"按钮 -3. 选择扣分类型: - - **未交作业**:按配置扣分(默认2分) - - **迟交作业**:按配置扣分(默认1分) - - **自定义**:手动输入扣分值 -4. 填写原因(自动填充或手动修改) -5. 可选择是否关联扣分 -6. 点击"确认提交" - -> 扣分限制:每次加减分不超过3分。 - -#### 操行分管理 (conduct.php) - -**加减分操作**: -1. 在学生列表中勾选目标学生 -2. 点击"批量加减分"按钮 -3. 填写分数变动(±5分以内)和原因 -4. 点击"确认提交" - -> 加减分限制:每次操作不超过±5分(上限可在系统环境变量中配置)。 - -#### 科目管理 (subjects.php) - -- 查看科目列表 -- 添加/编辑/删除/启用/禁用科目 - -#### 历史记录 - -- 仅可查看自己提交的操作记录 - ---- - -### 考勤委员 - -#### 考勤管理 (attendance.php) - -考勤按**时段**管理,每日分三个时段独立记录:早上(7:15)、中午(14:00)、晚修(19:30)。 - -**添加考勤记录**: -1. 选择**日期**和**时段**(早上/中午/晚修) -2. 在状态按钮组中选择考勤状态:缺勤 / 迟到 / 请假 -3. 可选填写自定义扣分值(留空使用默认值) -4. 可选填写原因 -5. 在学生方格中**点击选择**有考勤异常的学生 -6. 点击"提交考勤"批量提交 - -**默认扣分规则**: - -| 考勤状态 | 默认扣分 | -|---------|---------| -| 缺勤 | 3分 | -| 迟到 | 1分 | -| 请假 | 0分 | - -#### 历史记录 - -- 仅可查看自己提交的操作记录 - ---- - -### 劳动委员 - -#### 操行分管理 (conduct.php) - -**加减分操作**: -1. 在学生列表中勾选目标学生 -2. 点击"批量加减分"按钮 -3. 以卫生值日为由进行 ±1 分操作 -4. 点击"确认提交" - -> 限制:固定 ±1 分。 - -#### 历史记录 - -- 仅可查看自己提交的操作记录 - ---- - -### 志愿委员 - -#### 操行分管理 (conduct.php) - -**加分操作**: -1. 在学生列表中勾选目标学生 -2. 点击"批量加减分"按钮 -3. 以服务时长为由进行加分 -4. 点击"确认提交" - -> 限制:仅限加分操作。 - -#### 历史记录 - -- 仅可查看自己提交的操作记录 - ---- - -## 修改密码 (password.php) - -- 输入原密码和新密码 -- 新密码需符合密码强度要求 -- 修改成功后需重新登录 - ---- - -## 常见问题 - -### Q: 忘记密码怎么办? -请联系班主任重置密码。 - -### Q: 为什么只能看到自己的记录? -班干部角色默认仅查看自己提交的操作记录(班长可查看全部)。 - -### Q: 考勤扣分规则可以修改吗? -考勤扣分规则在系统配置中设置,如需调整请联系班主任。 diff --git a/docs/guide/cadre.md b/docs/guide/cadre.md deleted file mode 100644 index 88d7bdf..0000000 --- a/docs/guide/cadre.md +++ /dev/null @@ -1,49 +0,0 @@ -# 班干部使用说明 - -## 登录网址 - -![二维码](./qrcode.png) - -### 或访问https://class.sea-studio.top/ - -## 登录 - -输入**用户名**和**密码**登录。首次登录需强制修改密码。 - -> 密码要求:6-20位,包含大写字母、小写字母、数字、特殊符号中的至少3种。 - -## 角色权限 - -| 角色 | 操行分管理 | 历史记录 | 作业扣分 | 考勤管理 | 科目管理 | -|------|-----------|---------|---------|---------|---------| -| 班长 | ±5分以内 | 全部(可撤销) | - | - | - | -| 学习委员 | ±5分以内 | 自己的 | ✓ | - | ✓ | -| 考勤委员 | - | 自己的 | - | ✓ | - | -| 劳动委员 | ±1分(卫生值日) | 自己的 | - | - | - | -| 志愿委员 | 仅加分 | 自己的 | - | - | - | - -## 常用操作 - -### 班长 -- **加减分**:勾选学生 → 批量加减分 → 填写分值(±5分以内)和原因 -- **撤销记录**:历史记录页 → 点击撤销按钮 - -### 学习委员 -- **作业扣分**:勾选学生 → 批量加减分 → 选择未交/迟交/自定义 → 填写原因 -- **加减分**:勾选学生 → 批量加减分 → 填写分值(±5分以内)和原因 -- **科目管理**:添加/编辑/删除科目 - -### 考勤委员 -- **添加考勤**:选择日期和时段(早上7:15/中午14:00/晚修19:30)→ 选择状态 → 点击选择异常学生 → 提交考勤 -- 默认扣分:缺勤3分、迟到1分、请假0分 - -### 劳动委员 -- **加减分**:以卫生值日为由进行 ±1 分操作 - -### 志愿委员 -- **加分**:以服务时长为由进行加分操作 - -## 常见问题 - -- **忘记密码**:联系班主任重置。 -- **只能看到自己的记录**:班干部角色默认仅查看自己提交的操作记录(班长可查看全部)。 diff --git a/docs/guide/parent.md b/docs/guide/parent.md deleted file mode 100644 index fe5e67a..0000000 --- a/docs/guide/parent.md +++ /dev/null @@ -1,67 +0,0 @@ -# 家长端使用说明 - -## 登录网址 - -![二维码](./qrcode.png) - -### 或访问 https://class.sea-studio.top/ - -## 登录 - -1. 输入**登记的手机号**和**密码**登录 -2. 账号由班主任创建,与子女信息自动关联 - -> 初始密码默认为 `123456`,如有疑问请联系班主任。 - ---- - -## 功能概览 - -| 页面 | 说明 | 如何进入 | -|------|------|---------| -| 首页 | 查看子女操行分、班级排名、初始分提示 | 登录后默认进入 | -| 历史记录 | 查看子女加减分明细(时间、类型、原因、分值、记录人),支持分页 | 点击导航栏「历史记录」 | -| 考勤记录 | 查看子女考勤统计(出勤/缺勤/迟到/请假) + 记录明细 | 点击导航栏「考勤记录」 | - ---- - -## 各页面操作说明 - -### 📊 首页 -- 紫色卡片区域显示子女姓名和学号 -- 两个统计卡片:**当前操行分**和**班级排名** -- 页面底部显示初始分提示(默认60分) -- 操行分高于60分表示表现良好,低于60分建议查看历史记录了解扣分原因 - -### 📋 历史记录 -- 表格列出所有操行分变动记录 -- **类型说明**: - - **手动** — 班主任或班干部手动操作 - - **作业** — 作业未交或迟交自动扣分 - - **考勤** — 缺勤或迟到自动扣分 -- 加分显示绿色 `+N`,扣分显示红色 `-N` -- 底部可翻页浏览 - -### 📅 考勤记录 -- 四个统计卡片:出勤、缺勤、迟到、请假 -- 下方列表显示每条考勤记录 -- 考勤按时段记录:早上(7:15)、中午(14:00)、晚修(19:30) - -> **注意**:家长端默认仅显示当前学期数据。 - ---- - -## 常见问题 - -- **忘记密码**:联系班主任重置。 -- **初始操行分**:默认60分,首页底部有提示。 -- **看不到子女信息**:请确认使用正确的手机号登录(家长账号是请假系统登记时的手机号)。 -- **操行分或考勤有异议**:请联系班主任核实处理。 -- **账号被锁定**:连续输错5次密码会锁定5分钟,请联系班主任处理。 -- **记录类型含义**:「手动」为人工操作、「作业」为作业扣分、「考勤」为考勤扣分。 -- **其他任何问题**:请联系班主任咨询。 - ---- - -## 声明 -有关扣分的问题可以先联系学生询问,不要着急,可能是登记时出错,不用担心。可以联系班主任或学生联系对应的班干核实信息。 diff --git a/docs/guide/student.md b/docs/guide/student.md deleted file mode 100644 index 54d8b69..0000000 --- a/docs/guide/student.md +++ /dev/null @@ -1,72 +0,0 @@ -# 学生端使用说明 - -## 登录网址 - -![二维码](./qrcode.png) - -### 或访问 https://class.sea-studio.top/ - -## 登录 - -1. 输入**长学号**和**密码**登录 -2. **首次登录需强制修改密码**(新密码需6-20位,包含大写字母、小写字母、数字、特殊符号中的至少3种) -3. 修改成功后自动进入首页 - -> 初始密码默认为 `123456`,首次登录后请立即修改。 - ---- - -## 功能概览 - -| 页面 | 说明 | 如何进入 | -|------|------|---------| -| 首页 | 查看操行分、班级排名、作业完成率、出勤率、最近5条记录 | 登录后默认进入 | -| 操行分详情 | 查看完整加减分历史(时间、分值、原因、操作人),支持分页 | 点击导航栏「操行分详情」 | -| 作业情况 | 查看作业列表(科目、标题、截止日期、提交状态、备注) | 点击导航栏「作业情况」 | -| 考勤记录 | 查看出勤/缺勤/迟到/请假统计 + 记录明细 | 点击导航栏「考勤记录」 | -| 学期记录 | 查看历史学期归档数据(操行分排名、考勤统计、作业统计) | 点击导航栏「学期记录」 | -| 修改密码 | 修改登录密码(需输入原密码) | 点击导航栏「修改密码」 | - ---- - -## 各页面操作说明 - -### 📊 首页 -- 四个统计卡片显示关键数据 -- 下方「最新操行分记录」显示最近5条变动 -- 点击「查看更多」跳转到操行分详情页 - -### 📋 操行分详情 -- 顶部大字显示当前操行分 -- 下方表格列出所有历史记录 -- 页面底部可翻页浏览(每页20条) - -### 📝 作业情况 -- 作业状态有三种:🟢已提交 / 🔴未提交 / 🟡迟交 -- 未提交和迟交会自动扣减操行分 - -### 📅 考勤记录 -- 每天分三个时段:早上(7:15)、中午(14:00)、晚修(19:30) -- 统计卡片显示出勤/缺勤/迟到/请假次数 -- 下方列表显示每条记录的日期和状态 - -### 📚 学期记录 -- 以卡片形式展示每个已归档学期 -- 包含最终操行分、排名、考勤和作业统计 - -### 🔑 修改密码 -1. 输入原密码 -2. 输入新密码(6-20位,至少3种字符类型) -3. 确认新密码 -4. 点击「确认修改」→ 需重新登录 - ---- - -## 常见问题 - -- **忘记密码**:联系开发人员重置。 -- **操行分有误**:联系班主任或班长核实调整。 -- **考勤异议**:向班主任反映,由班主任核实后修正。 -- **作业状态有误**:联系学习委员或班主任核实。 -- **账号被锁定**:连续输错5次密码会锁定5分钟,急需使用请联系开发人员解锁。 -- **系统异常**:页面打不开、功能报错等问题请联系开发人员反馈(建议提供截图和操作步骤)。 diff --git a/docs/guide/teacher.md b/docs/guide/teacher.md deleted file mode 100644 index 8bb36e0..0000000 --- a/docs/guide/teacher.md +++ /dev/null @@ -1,47 +0,0 @@ -# 班主任使用说明 - -## 登录网址 - -![二维码](./qrcode.png) - -### 或访问https://class.sea-studio.top/ - -## 登录 - -输入**用户名**和**密码**登录。首次登录需强制修改密码。 - -> 密码要求:6-20位,包含大写字母、小写字母、数字、特殊符号中的至少3种。 - -## 功能概览 - -班主任拥有管理端全部权限: - -| 页面 | 功能 | -|------|------| -| 首页 | 查看学生总数、排行榜、快捷入口 | -| 操行分管理 | 对学生加减分(无限制)、导出德育分记录 | -| 历史记录 | 查看/导出/撤销全班记录,支持按扣分类型筛选 | -| 作业扣分 | 发布缺交作业记录、关联扣分 | -| 考勤管理 | 按时段(早上/中午/晚修)记录考勤、自定义扣分值 | -| 学生管理 | 新增/编辑/删除/批量导入学生 | -| 科目管理 | 增删改科目信息 | -| 管理员管理 | 添加/编辑姓名和角色/删除/重置密码班干部账号 | -| 学期管理 | 创建/编辑/删除/归档学期、关联历史记录、归档重置分数 | -| 修改密码 | 修改登录密码 | - -## 常用操作 - -- **加减分**:勾选学生 → 批量加减分 → 填写分值和原因 -- **导出德育分**:操行分管理页 → 导出德育分记录(CSV) -- **导出历史记录**:历史记录页 → 导出历史记录(CSV,可按日期/学生筛选) -- **撤销记录**:历史记录页 → 点击撤销按钮 -- **导入学生**:学生管理页 → 导入学生(JSON格式,需包含 `student_no` 和 `name`) -- **添加班干部**:管理员管理页 → 添加管理员 → 填写账号信息和角色 -- **重置班干部密码**:管理员管理页 → 重置密码 → 输入新密码 -- **学期管理**:学期管理页 → 创建学期(可快捷填充日期)→ 关联历史数据 → 归档(可选重置分数) -- **排行榜筛选**:首页 → 输入百分比 → 点击筛选(抹零法,如15%显示前15%的学生) - -## 常见问题 - -- **班主任忘记密码**:联系系统管理员通过调试接口重置。 -- **考勤扣分规则**:在系统配置中设置,添加考勤时也可自定义扣分值。 diff --git a/docs/parent.md b/docs/parent.md deleted file mode 100644 index 8314797..0000000 --- a/docs/parent.md +++ /dev/null @@ -1,149 +0,0 @@ -# 家长端使用文档 - -## 登录 - -### 账号说明 - -家长账号由班主任在系统中创建,与学生信息自动关联。每个学生对应一个家长账号。 - -- **用户名**:登记时填写的手机号 -- **初始密码**:与学生初始密码相同(默认为 `123456`,具体以班主任通知为准) - -### 登录步骤 - -1. 打开系统网址,进入登录页面 -2. 输入**手机号**和**密码** -3. 点击「登录」按钮即可进入家长端首页 - -> 如果连续输错密码5次,账号将被锁定5分钟。如忘记密码请联系班主任重置。 - ---- - -## 功能说明 - -### 1. 首页 - -登录后自动进入家长端首页,页面由以下部分组成: - -**顶部导航栏**:显示系统名称和「退出登录」按钮。 - -**子女信息卡片**(紫色渐变区域): -- 子女姓名 -- 学号 - -**统计卡片**(2个): - -| 卡片 | 说明 | -|------|------| -| 当前操行分 | 显示子女当前的总操行分(初始分为60分) | -| 班级排名 | 显示子女在全班中的排名 | - -**初始分提示**:页面底部显示"初始操行分为 60 分"的提示文字。 - -> 操行分高于初始分说明总体表现良好,低于初始分说明有扣分记录,建议关注历史记录了解详情。 - ---- - -### 2. 历史记录 - -**进入方式**:点击底部导航栏的「历史记录」 - -**页面内容**: - -以表格形式展示子女的操行分变动明细: - -| 列 | 说明 | -|----|------| -| 日期 | 操作的日期和时间 | -| 类型 | 记录类型,包含以下几种: | -| | **手动** — 班主任或班干部手动加减分 | -| | **作业** — 作业未交或迟交导致的扣分 | -| | **考勤** — 缺勤或迟到导致的扣分 | -| 原因 | 本次加减分的详细原因 | -| 分值 | 加分显示为绿色 `+N`,扣分显示为红色 `-N` | -| 记录人 | 执行操作的人员姓名 | - -**分页浏览**:当记录较多时,页面底部提供翻页功能: -- 点击「上一页」/「下一页」按钮切换 -- 显示当前页码和总页数 - -> 家长端默认仅显示当前学期的数据。历史学期归档数据请在管理端或学生端查看。 - ---- - -### 3. 考勤记录 - -**进入方式**:点击底部导航栏的「考勤记录」 - -**页面内容**: - -1. **统计卡片**(4个): - -| 卡片 | 说明 | -|------|------| -| 出勤 | 正常出勤次数 | -| 缺勤 | 无故缺勤次数 | -| 迟到 | 迟到次数 | -| 请假 | 请假次数 | - -2. **考勤记录明细**: - -| 列 | 说明 | -|----|------| -| 日期 | 考勤日期 | -| 状态 | 考勤状态,包含以下几种: | -| | 🟢 **正常** — 按时出勤 | -| | 🔴 **缺勤** — 无故缺勤 | -| | 🟡 **迟到** — 迟到 | -| | 🔵 **请假** — 已请假 | -| 原因 | 如有备注则显示原因 | - -> **考勤时段**:每天分为三个时段——早上(7:15)、中午(14:00)、晚修(19:30),每个时段独立记录考勤。 - ---- - -## 导航栏说明 - -底部导航栏包含以下选项: - -| 导航项 | 说明 | -|-------|------| -| 首页 | 子女信息、操行分和排名概览 | -| 历史记录 | 子女操行分变动历史明细(支持分页) | -| 考勤记录 | 子女考勤统计和记录明细 | - -> 当前所在页面的导航项会高亮显示。 - ---- - -## 常见问题 - -### Q: 忘记密码怎么办? -请联系班主任重置密码。 - -### Q: 初始操行分是多少? -学生初始操行分默认为60分,首页底部会显示当前系统的初始分设定值。 - -### Q: 看不到子女的信息怎么办? -请确认以下情况: -是否使用了正确的手机号登录(家长账号是请假系统登记时的手机号) - -### Q: 子女的操行分有误怎么办? -请联系班主任核实,班主任可以查看和调整学生的操行分记录。 - -### Q: 考勤记录有异议怎么办? -考勤记录由班级考勤委员录入,如有异议请联系班主任核实处理。 - -### Q: 登录时提示"账号已锁定"怎么办? -连续输错密码5次会导致账号锁定5分钟,请等待5分钟后重试。如果急需使用,请联系班主任联系开发人员解锁。 - -### Q: 为什么有些记录显示"作业"或"考勤"类型? -系统会自动将作业未交/迟交、缺勤/迟到等产生的扣分标记为对应类型,方便家长了解扣分原因。 - -### Q: 想了解更多关于系统的问题怎么办? -有关系统功能、账号等任何问题,请直接联系班主任咨询。 - ---- - -## 声明 -有关扣分的问题可以先联系学生询问,不要着急,可能是登记时出错,不用担心。可以联系班主任或学生联系对应的班干合适信息。 diff --git a/docs/student.md b/docs/student.md deleted file mode 100644 index 3006bd6..0000000 --- a/docs/student.md +++ /dev/null @@ -1,199 +0,0 @@ -# 学生端使用文档 - -## 登录 - -### 首次登录 - -1. 打开系统网址,进入登录页面 -2. 输入**长学号**作为用户名 -3. 输入初始密码(默认为 `123456`,具体以班主任通知为准) -4. 点击「登录」按钮 -5. **首次登录系统会强制弹出密码修改窗口**,请设置一个安全的新密码 -6. 修改成功后自动进入学生端首页 - -> **密码要求**:长度6-20位,必须包含大写字母、小写字母、数字、特殊符号中的至少3种。 -> -> **示例有效密码**:`Hello1!`、`Abc123#`、`Test@99` - -### 日常登录 - -1. 打开系统网址 -2. 输入学号和密码 -3. 点击「登录」按钮即可进入首页 - -> 如果连续输错密码5次,账号将被锁定5分钟。如忘记密码请联系开发人员重置。 - ---- - -## 功能说明 - -### 1. 首页 - -登录后自动进入学生端首页,页面由以下部分组成: - -**顶部导航栏**:显示系统名称、你的姓名和角色,以及「退出登录」按钮。 - -**统计卡片**(4个): - -| 卡片 | 说明 | -|------|------| -| 当前操行分 | 显示你当前的总操行分(初始分为60分) | -| 班级排名 | 显示你在全班中的排名 | -| 作业完成率 | 已提交作业数 ÷ 总作业数 × 100% | -| 本月出勤率 | 本月正常出勤天数 ÷ 应出勤天数 × 100% | - -**最新操行分记录**: -- 显示最近5条操行分变动记录 -- 每条记录显示:分值变动(绿色为加分,红色为扣分)、变动原因、日期 -- 点击「查看更多」可跳转到操行分详情页 - ---- - -### 2. 操行分详情 - -**进入方式**:点击底部导航栏的「操行分详情」 - -**页面内容**: - -1. **当前操行分**:页面顶部以大字显示你的当前总分 -2. **历史记录列表**:以表格形式展示完整的加减分记录 - -| 列 | 说明 | -|----|------| -| 时间 | 操作的日期和时间 | -| 分数变动 | 绿色 `+N` 表示加分,红色 `-N` 表示扣分 | -| 原因 | 本次加减分的原因说明 | -| 操作人 | 执行加减分操作的人员姓名 | - -3. **分页浏览**:页面底部提供翻页功能,每页显示20条记录 - ---- - -### 3. 作业情况 - -**进入方式**:点击底部导航栏的「作业情况」 - -**页面内容**: - -以表格形式展示你的所有作业记录: - -| 列 | 说明 | -|----|------| -| 科目 | 作业所属科目名称 | -| 作业标题 | 作业的标题或描述 | -| 截止日期 | 作业的提交截止时间 | -| 状态 | 作业提交状态,包含以下几种: | -| | 🟢 **已提交** — 按时提交 | -| | 🔴 **未提交** — 未提交(会自动扣分) | -| | 🟡 **迟交** — 超过截止时间后提交(会自动扣分) | -| 备注 | 老师或学习委员填写的备注信息 | - -> **扣分说明**:未交作业和迟交作业会自动扣减操行分,具体扣分分值由系统配置决定。 - ---- - -### 4. 考勤记录 - -**进入方式**:点击底部导航栏的「考勤记录」 - -**页面内容**: - -1. **统计卡片**(4个): - -| 卡片 | 说明 | -|------|------| -| 出勤 | 正常出勤次数 | -| 缺勤 | 缺勤次数 | -| 迟到 | 迟到次数 | -| 请假 | 请假次数 | - -2. **考勤记录明细**: - -| 列 | 说明 | -|----|------| -| 日期 | 考勤日期 | -| 状态 | 考勤状态(正常/缺勤/迟到/请假) | -| 原因 | 如有备注则显示原因 | - -> **考勤时段**:每天分为三个时段进行考勤——早上(7:15)、中午(14:00)、晚修(19:30)。每个时段独立记录。 - ---- - -### 5. 学期记录 - -**进入方式**:点击底部导航栏的「学期记录」 - -**页面内容**: - -展示所有已归档学期的历史数据,每个学期以卡片形式展示: - -- **学期名称和日期范围** -- **最终操行分和排名** -- **考勤统计**:出勤、缺勤、迟到、请假次数 -- **作业统计**:已交、未交、迟交数量 - -> 学期记录为只读数据,归档后不可修改。如对数据有疑问请联系班主任。 - ---- - -### 6. 修改密码 - -**进入方式**:点击底部导航栏的「修改密码」 - -**操作步骤**: - -1. 在「原密码」输入框中输入当前密码 -2. 在「新密码」输入框中输入新密码(6-20位,需包含大写字母、小写字母、数字、特殊符号中的至少3种) -3. 在「确认新密码」输入框中再次输入新密码 -4. 点击「确认修改」按钮 -5. 修改成功后需要重新登录 - -> **密码安全建议**:不要使用生日、学号等容易猜测的密码,不要将密码告诉他人。 - ---- - -## 导航栏说明 - -底部导航栏包含以下选项: - -| 导航项 | 图标/文字 | 说明 | -|-------|----------|------| -| 首页 | 首页 | 概览信息和最近记录 | -| 操行分详情 | 操行分详情 | 完整加减分历史(支持分页) | -| 作业情况 | 作业情况 | 作业缺交记录 | -| 考勤记录 | 考勤记录 | 考勤记录明细 | -| 学期记录 | 学期记录 | 查看历史学期归档数据 | -| 修改密码 | 修改密码 | 修改登录密码 | - -> 当前所在页面的导航项会高亮显示(蓝色加粗)。 - ---- - -## 常见问题 - -### Q: 忘记密码怎么办? -请联系开发人员重置密码。 - -### Q: 操行分有误怎么办? -请联系班主任或班长核实并调整。 - -### Q: 页面打不开或显示异常怎么办? -请尝试以下步骤: -1. 清除浏览器缓存后重新打开 -2. 更换浏览器(推荐使用 Chrome、Edge) -3. 如问题持续,请联系开发人员反馈 - -### Q: 考勤记录与实际不符怎么办? -考勤记录由考勤委员录入,如有异议请先向班主任反映,由班主任核实后修正。 - -### Q: 作业状态显示"未提交"但我已经交了怎么办? -请联系学习委员或班主任核实作业提交状态,确认后可以更正。 - -### Q: 登录时提示"账号已锁定"怎么办? -连续输错密码5次会导致账号锁定5分钟,请等待5分钟后重试。如果急需使用,请联系开发人员解锁。 - -### Q: 如何反馈系统问题? -系统相关问题(页面错误、功能异常等)请联系开发人员反馈,并提供: -- 使用的设备和浏览器 -- 出现问题的页面和操作步骤 -- 截图或错误提示信息 diff --git a/docs/teacher.md b/docs/teacher.md deleted file mode 100644 index a2e49a0..0000000 --- a/docs/teacher.md +++ /dev/null @@ -1,295 +0,0 @@ -# 班主任使用文档 - -## 登录 - -1. 打开系统网址,进入登录页面 -2. 输入**用户名**和**密码** -3. 点击"登录"按钮 -4. 首次登录系统会强制要求修改密码 - -> **密码要求**:长度6-20位,必须包含大写字母、小写字母、数字、特殊符号中的至少3种。 - ---- - -## 功能说明 - -班主任拥有管理端**全部权限**。 - -### 1. 首页 (dashboard.php) - -管理端首页展示以下内容: - -- **统计数据**:学生总数 -- **快捷操作**: - - 操行分管理 - - 导入学生 - - 导出德育分记录 -- **操行分排行榜**:展示前100名学生的排名、学号、姓名和操行分。排行榜上方提供百分比筛选框,可输入1-100的数字筛选显示前N%的学生(抹零法:如49人×15%=7.35,显示前7人)。 - ---- - -### 2. 操行分管理 (conduct.php) - -#### 查看学生列表 -- 展示所有学生的学号、姓名、当前操行分 -- 支持按学生选择 - -#### 批量加减分 -1. 在学生列表中勾选目标学生(可点击"全选") -2. 点击"批量加减分"按钮 -3. 填写以下信息: - - **分数变动**:正数为加分,负数为扣分 - - **原因**:填写加减分原因(必填) -4. 点击"确认提交" - -> 班主任加减分无限制。 - -#### 导出德育分记录 -1. 点击"导出德育分记录"按钮 -2. 系统自动生成CSV文件并下载 -3. 文件格式:`学号,姓名,分数,加分历史,减分记录` -4. 历史记录以分号分隔,包含原因和分值 - ---- - -### 3. 历史记录 (history.php) - -#### 查看历史记录 -- 展示所有操行分变动记录 -- 支持筛选条件: - - **开始日期** / **结束日期**:按时间范围筛选 - - **学生**:按学生筛选 - - **扣分类型**:按来源筛选(手动加减分 / 作业 / 考勤) -- 点击"查询"按钮刷新列表 -- 已撤销的记录会显示"由 XXX 撤销"标记 - -#### 导出历史记录 -1. 设置筛选条件(可选) -2. 点击"导出历史记录"按钮 -3. 系统自动生成CSV文件并下载 -4. 文件格式:`时间,学号,姓名,分数变动,原因,操作人` - -#### 撤销记录 -1. 在历史记录列表中找到目标记录 -2. 点击右侧"撤销"按钮 -3. 确认撤销操作 - ---- - -### 4. 作业扣分 (homework.php) - -本模块用于管理学生扣分记录。 - -#### 查看学生列表 -- 展示所有学生的学号、姓名、当前操行分 -- 可勾选学生进行批量操作 - -#### 批量扣分 -1. 在学生列表中勾选目标学生(可点击"全选") -2. 点击"批量加减分"按钮 -3. 选择扣分类型: - - **未交作业**:按配置扣分(默认2分) - - **迟交作业**:按配置扣分(默认1分) - - **自定义**:手动输入扣分值 -4. 填写原因(自动填充或手动修改) -5. 可选择是否关联扣分 -6. 点击"确认提交" - -> 扣分限制:每次加减分不超过3分。 - ---- - -### 5. 考勤管理 (attendance.php) - -考勤按**时段**管理,每日分三个时段独立记录: - -| 时段 | 标识 | 时间点 | -|------|------|--------| -| 早上 | morning | 7:15 | -| 中午 | afternoon | 14:00 | -| 晚修 | evening | 19:30 | - -每个学生每天每个时段最多一条考勤记录。 - -#### 添加考勤记录 -1. 选择**日期**和**时段**(早上/中午/晚修) -2. 在状态按钮组中选择考勤状态:缺勤 / 迟到 / 请假 -3. 可选填写自定义扣分值(留空使用默认值) -4. 可选填写原因 -5. 在学生方格中**点击选择**有考勤异常的学生(已记录的时段会显示为虚线框) -6. 点击"全选"可快速选中所有未记录的学生 -7. 点击"提交考勤"批量提交 - -#### 查看考勤记录 -- 按日期筛选考勤记录 -- 每条记录显示:学号、姓名、状态、原因、记录人、扣分情况 - -**默认扣分规则**: - -| 考勤状态 | 默认扣分 | -|---------|---------| -| 缺勤 | 3分 | -| 迟到 | 1分 | -| 请假 | 0分 | - ---- - -### 6. 学生管理 (students.php) - -#### 查看学生列表 -- 展示所有学生的学号、姓名、家长手机号、状态等信息 -- 支持搜索 - -#### 新增学生 -1. 点击"新增学生"按钮 -2. 填写学号、姓名 -3. 可选填家长手机号(填写后系统自动创建家长账号) -4. 点击"确认" - -#### 批量导入学生 -1. 点击"导入学生"按钮 -2. 下载导入模板 -3. 上传JSON格式的学生数据文件 -4. 系统自动解析并导入 -5. 导入结果展示成功/失败数量 - -> 导入的学生初始操行分为60分,系统自动创建学生登录账号和密码。 - ---- - -### 7. 科目管理 (subjects.php) - -#### 查看科目列表 -- 展示所有科目及其状态 - -#### 添加科目 -1. 点击"添加科目"按钮 -2. 填写科目名称 -3. 可选填科目编码和排序 -4. 点击"确认" - -#### 管理科目 -- 编辑:修改科目信息 -- 删除:删除科目(已有作业关联的科目不可删除) -- 启用/禁用:控制科目状态 - ---- - -### 8. 管理员管理 (admins.php) - -#### 添加管理员 -1. 点击"添加管理员"按钮 -2. 填写以下信息: - - **用户名**:登录账号 - - **姓名**:真实姓名 - - **密码**:留空则自动生成8位随机密码 - - **角色**:选择角色类型 -3. 点击"添加" -4. 添加成功后会显示管理员信息和初始密码(请妥善保存) - -#### 编辑管理员 -- 点击"编辑"按钮可修改**姓名**和**角色类型** - -#### 重置密码 -1. 点击"重置密码"按钮 -2. 输入新密码 -3. 点击"确认重置" - -#### 删除管理员 -- 点击"删除"按钮确认删除(不可恢复) - ---- - -### 9. 学期管理 (semesters.php) - -#### 创建学期 -1. 点击"创建新学期"按钮 -2. 填写学期名称(如"2025秋季学期") -3. 可使用快捷按钮自动填充日期: - - **上学期**:9月1日 - 次年2月末(自动处理闰年) - - **下学期**:3月1日 - 7月15日 -4. 结束日期为可选,不确定可不填 -5. 点击"创建学期" -6. 系统自动根据日期判断是否为当前活跃学期 - -> 创建学期的日期范围包含今天时,自动设为当前活跃学期;否则(如补录上学期)不会自动激活。 - -#### 编辑学期 -1. 在学期列表中点击"编辑"按钮 -2. 修改学期名称或日期 -3. 点击"保存修改" -4. 已归档的学期不可编辑 - -#### 删除学期 -1. 在编辑模态框中点击"删除学期"按钮 -2. 确认删除操作 -3. 已有归档数据的学期不可删除 - -#### 激活学期 -- 点击"激活"按钮将学期设为当前活跃学期 -- 当前活跃学期标记为"当前学期"(绿色标签) - -#### 关联历史数据 -1. 点击"关联数据"按钮 -2. 系统将自动把该学期日期范围内未分配学期的操行分记录和考勤记录关联到该学期 -3. 关联完成后显示关联的记录数量 - -> 用于补录上学期数据:创建上学期的学期记录后,通过"关联数据"将已有的历史记录关联到该学期。 - -#### 归档学期 -1. 点击"归档"按钮 -2. 确认归档操作: - - 系统将保存所有学生的操行分快照、考勤统计和作业统计 - - 可选勾选"归档后重置所有学生操行分为初始值(60分)" -3. 点击"确认归档" -4. 归档成功后该学期变为只读 - -> **归档说明**:归档仅创建数据快照,不会删除或修改任何原始数据。如勾选重置,归档完成后将所有学生操行分恢复为初始值,所有历史记录和操作日志完整保留。 - -#### 查看归档数据 -- 已归档学期显示"查看归档"按钮 -- 展示归档时的学生排名、操行分、考勤统计、作业统计 - ---- - -### 10. 修改密码 (password.php) - -- 输入原密码和新密码 -- 新密码需符合密码强度要求 -- 修改成功后需重新登录 - ---- - -## 常见问题 - -### Q: 忘记密码怎么办? -请联系系统管理员通过调试接口重置。 - -### Q: 如何导出全班德育分记录? -在"操行分管理"页面点击"导出德育分记录"按钮,系统会生成包含所有学生当前分数及加减分历史的CSV文件。 - -### Q: 如何撤销错误的加减分? -在"历史记录"页面找到对应记录,点击"撤销"按钮。 - -### Q: 导入学生时格式错误怎么办? -请确保上传的文件是JSON格式,包含 `students` 数组,每个学生对象至少包含 `student_no`(学号)和 `name`(姓名)字段。 - -### Q: 考勤扣分规则可以修改吗? -考勤扣分规则在系统配置中设置。班主任在添加考勤记录时可以自定义扣分值。 - ---- - -## 数据导出说明 - -### 德育分记录导出 -- **位置**:操行分管理页面 -- **格式**:CSV(UTF-8编码) -- **字段**:学号、姓名、分数、加分历史、减分记录 -- **文件名**:`德育分记录_日期.csv` - -### 历史记录导出 -- **位置**:历史记录页面 -- **格式**:CSV(UTF-8编码) -- **字段**:时间、学号、姓名、分数变动、原因、操作人 -- **支持筛选**:可按时间范围和学生筛选后导出 -- **文件名**:`历史记录_日期.csv` diff --git a/frontend/.env.example b/frontend/.env.example index e17b84c..8b115b7 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,15 +1,16 @@ # =========================================== -# 班级操行分管理系统 - 前端配置 +# 多班级版班级管理系统 - 前端配置 # # 开发者: Canglan # 联系方式: admin@sea-studio.top # 版权归属: Sea Network Technology Studio -# 许可证: MIT License +# 许可证: Apache License 2.0 # # 版权所有 © Sea Network Technology Studio # =========================================== -# 后端API地址,修改为实际地址 +# 后端API地址(Go 后端默认端口 56789,通过 Nginx 反代后可直接使用域名) +# 如果直接访问 Go 后端,格式为 http://your-server-ip:56789 API_BASE_URL=https://your-api-domain.com # API超时时间(秒) @@ -22,7 +23,7 @@ JWT_STORAGE_KEY=class_system_token USER_STORAGE_KEY=class_system_user # 站点名称 -SITE_NAME=班级操行分管理系统 +SITE_NAME=多班级版班级管理系统 # 会话超时时间(分钟) SESSION_TIMEOUT=30 @@ -32,4 +33,8 @@ SESSION_TIMEOUT=30 ICP_ENABLED=false # ICP备案号 ICP_NUMBER=京ICP备1234567890号-x + +# 超级管理员独立登录路径(不含 /api 前缀,代码会自动拼接) +SUPER_ADMIN_LOGIN_PATH=/super-admin + STUDENT_INITIAL_POINTS=60 \ No newline at end of file diff --git a/frontend/admin/admins.php b/frontend/admin/admins.php index 6588f68..db4bfed 100644 --- a/frontend/admin/admins.php +++ b/frontend/admin/admins.php @@ -1,11 +1,11 @@ + +
+ + +
+
+
+ +
+
+
+ + + + + + + + + + + + + +
作业标题科目截止日期描述操作
加载中...
+
+ +
+
+ + + + + + + + + + + + diff --git a/frontend/admin/class_settings.php b/frontend/admin/class_settings.php new file mode 100644 index 0000000..e6687e0 --- /dev/null +++ b/frontend/admin/class_settings.php @@ -0,0 +1,439 @@ + + +
+ + + +
+

扣分规则

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+

角色加减分限制

+

配置各角色单次加减分的上下限

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+

周期重置

+

按周或按月自动重置学生操行分(需配合定时任务或手动触发)

+
+
+ + +
+ + + +
+
+ + +
+

功能开关

+

控制各角色的功能启用状态

+
+
+ + + + + + + + +
+ +
+
+
+ + + + + + diff --git a/frontend/admin/classes.php b/frontend/admin/classes.php new file mode 100644 index 0000000..f9dfe6b --- /dev/null +++ b/frontend/admin/classes.php @@ -0,0 +1,214 @@ + + +
+ + +
+
加载中...
+
+
+ + + + + + + diff --git a/frontend/admin/conduct.php b/frontend/admin/conduct.php index 9a03edd..6778782 100644 --- a/frontend/admin/conduct.php +++ b/frontend/admin/conduct.php @@ -1,11 +1,11 @@ - 时间 - 学生 - 分数变动 + 类型 + 分值 原因 + 学生 操作人 + 时间 操作 diff --git a/frontend/admin/homework.php b/frontend/admin/homework.php index 2a75e1a..1833575 100644 --- a/frontend/admin/homework.php +++ b/frontend/admin/homework.php @@ -1,11 +1,11 @@ + +
+ + +
+ + + +
+ +
+
+ + + + + + + + + + + + +
排名学号姓名分值
加载中...
+
+
+
+ + + + + + diff --git a/frontend/admin/semesters.php b/frontend/admin/semesters.php index 9a24842..b15bf24 100644 --- a/frontend/admin/semesters.php +++ b/frontend/admin/semesters.php @@ -1,18 +1,18 @@
+ +
+

周期重置

+

手动触发当前班级的周/月操行分重置(重置前会自动创建分数快照)

+
+ + + + +
+
+
@@ -198,6 +210,54 @@ include __DIR__ . '/../includes/header.php';
+ + + + + + diff --git a/frontend/admin/students.php b/frontend/admin/students.php index 62c6830..a8cffe9 100644 --- a/frontend/admin/students.php +++ b/frontend/admin/students.php @@ -1,11 +1,11 @@ 姓名 宿舍号 操行分 - 家长手机号 + 家长账号(推荐手机号) 操作 @@ -99,7 +99,7 @@ include __DIR__ . '/../includes/header.php';
- + 填写后将自动创建家长账号(密码同学生初始密码123456)
@@ -133,7 +133,7 @@ include __DIR__ . '/../includes/header.php';
- +
diff --git a/frontend/admin/subjects.php b/frontend/admin/subjects.php index 6fe47c4..1158e24 100644 --- a/frontend/admin/subjects.php +++ b/frontend/admin/subjects.php @@ -1,11 +1,11 @@ false, - CURLOPT_SSL_VERIFYHOST => 0 + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2 ]); $apiResponse = curl_exec($ch); diff --git a/frontend/api/clear_session.php b/frontend/api/clear_session.php index 13c84ed..e0c7eb3 100644 --- a/frontend/api/clear_session.php +++ b/frontend/api/clear_session.php @@ -1,11 +1,11 @@ false, 'error' => '未授权']); exit(); } +$userType = $_SESSION['user_type']; $role = $_SESSION['role'] ?? ''; -if ($role !== '班主任') { +if ($userType === 'admin' && $role !== '班主任') { http_response_code(403); echo json_encode(['success' => false, 'error' => '权限不足']); exit(); @@ -27,7 +28,8 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { exit(); } -$stepVersion = $_GET['version'] ?? ''; +$input = json_decode(file_get_contents('php://input'), true); +$stepVersion = $input['version'] ?? ''; if (empty($stepVersion)) { http_response_code(400); echo json_encode(['success' => false, 'error' => '缺少版本号参数']); @@ -56,8 +58,8 @@ curl_setopt_array($ch, [ 'Authorization: Bearer ' . $token, 'Content-Type: application/json' ], - CURLOPT_SSL_VERIFYPEER => false, - CURLOPT_SSL_VERIFYHOST => 0 + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2 ]); $apiResponse = curl_exec($ch); diff --git a/frontend/api/save_session.php b/frontend/api/save_session.php index 6d0b4f0..91c829b 100644 --- a/frontend/api/save_session.php +++ b/frontend/api/save_session.php @@ -1,11 +1,11 @@ false, + 'message' => '跨域请求被拒绝' + ]); + exit(); + } +} elseif (!empty($referer)) { + $parsedReferer = parse_url($referer, PHP_URL_HOST); + if ($parsedReferer !== $host && $parsedReferer !== $serverName) { + http_response_code(403); + echo json_encode([ + 'success' => false, + 'message' => '跨域请求被拒绝' + ]); + exit(); + } +} + // 获取原始输入 $input = file_get_contents('php://input'); @@ -82,7 +109,7 @@ if (!empty($missingFields)) { } // 验证 user_type 是否合法 -$validUserTypes = ['student', 'parent', 'admin']; +$validUserTypes = ['student', 'parent', 'admin', 'super_admin']; if (!in_array($data['user_type'], $validUserTypes)) { http_response_code(400); echo json_encode([ @@ -115,8 +142,8 @@ curl_setopt_array($ch, [ 'Authorization: Bearer ' . $token, 'Content-Type: application/json' ], - CURLOPT_SSL_VERIFYPEER => false, - CURLOPT_SSL_VERIFYHOST => 0 + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2 ]); $apiResponse = curl_exec($ch); @@ -153,18 +180,23 @@ if ($tokenUserId === null || intval($tokenUserId) !== intval($data['user_id'])) exit(); } -// 设置 Session 变量 -$_SESSION['user_id'] = $data['user_id']; -$_SESSION['user_type'] = $data['user_type']; -$_SESSION['username'] = $data['username']; -$_SESSION['real_name'] = $data['real_name'] ?? ''; -$_SESSION['role'] = $data['role'] ?? ''; +// 从后端 JWT 解析权威数据(不信任客户端传入的 user_type/role) +$tokenData_user = $tokenData['data']; +// 登录成功后重新生成 Session ID,防止 Session 固定攻击 +session_regenerate_id(true); +$_SESSION['user_id'] = intval($tokenData_user['user_id']); +$_SESSION['user_type'] = $tokenData_user['user_type']; +$_SESSION['username'] = $tokenData_user['username']; +$_SESSION['real_name'] = $tokenData_user['real_name'] ?? ''; +$_SESSION['role'] = $tokenData_user['role'] ?? ''; +$_SESSION['class_id'] = $tokenData_user['class_id'] ?? null; +$_SESSION['class_name'] = $tokenData_user['class_name'] ?? ''; $_SESSION['login_time'] = time(); $_SESSION['jwt_token'] = $token; - -// 如果是学生,额外设置 student_id -if ($data['user_type'] === 'student') { - if (empty($data['student_id'])) { +// 如果是学生,额外设置 student_id(仅从 JWT 解析,不信任客户端传入值) +if ($_SESSION['user_type'] === 'student') { + $studentId = $tokenData_user['student_id'] ?? null; + if (empty($studentId)) { http_response_code(400); echo json_encode([ 'success' => false, @@ -172,7 +204,7 @@ if ($data['user_type'] === 'student') { ]); exit(); } - $_SESSION['student_id'] = $data['student_id']; + $_SESSION['student_id'] = $studentId; } // 保存 Session diff --git a/frontend/assets/css/admin.css b/frontend/assets/css/admin.css index 871595a..497d5e8 100644 --- a/frontend/assets/css/admin.css +++ b/frontend/assets/css/admin.css @@ -1,10 +1,10 @@ /** - * 班级操行分管理系统 - 管理端样式 + * 多班级版班级管理系统 - 管理端样式 * * 开发者: Canglan * 联系方式: admin@sea-studio.top * 版权归属: Sea Network Technology Studio - * 许可证: MIT License + * 许可证: Apache License 2.0 * * 版权所有 © Sea Network Technology Studio */ diff --git a/frontend/assets/css/style.css b/frontend/assets/css/style.css index 86bcf54..624a044 100644 --- a/frontend/assets/css/style.css +++ b/frontend/assets/css/style.css @@ -1,10 +1,10 @@ /** - * 班级操行分管理系统 - 全局样式 + * 多班级版班级管理系统 - 全局样式 * * 开发者: Canglan * 联系方式: admin@sea-studio.top * 版权归属: Sea Network Technology Studio - * 许可证: MIT License + * 许可证: Apache License 2.0 * * 版权所有 © Sea Network Technology Studio */ @@ -691,6 +691,10 @@ tr:hover { background: #ed8936; } +.toast-info { + background: var(--color-primary); +} + @keyframes fadeInUp { from { opacity: 0; diff --git a/frontend/assets/js/admins.js b/frontend/assets/js/admins.js index c4e2f06..d943d6d 100644 --- a/frontend/assets/js/admins.js +++ b/frontend/assets/js/admins.js @@ -1,5 +1,5 @@ /** - * 班级操行分管理系统 - 管理员管理页JS + * 多班级版班级管理系统 - 管理员管理页JS * * 开发者: Canglan * 版权归属: Sea Network Technology Studio diff --git a/frontend/assets/js/attendance-manage.js b/frontend/assets/js/attendance-manage.js index ec79f9a..6302f49 100644 --- a/frontend/assets/js/attendance-manage.js +++ b/frontend/assets/js/attendance-manage.js @@ -1,5 +1,5 @@ /** - * 班级操行分管理系统 - 考勤管理页JS + * 多班级版班级管理系统 - 考勤管理页JS * * 开发者: Canglan * 版权归属: Sea Network Technology Studio diff --git a/frontend/assets/js/cadre-homework.js b/frontend/assets/js/cadre-homework.js new file mode 100644 index 0000000..688b5ee --- /dev/null +++ b/frontend/assets/js/cadre-homework.js @@ -0,0 +1,159 @@ +/** + * 多班级版班级管理系统 - 课代表作业管理JS + * + * 开发者: Canglan + * 版权归属: Sea Network Technology Studio + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { +'use strict'; + +var currentPage = 1; +var pageSize = 20; +var currentAssignmentId = null; + +async function loadHomework(page) { + var res = await apiGet('/api/cadre/homework', { page: page, page_size: pageSize }); + if (res && res.success && res.data) { + var items = res.data.items || res.data.records || []; + var total = res.data.total || 0; + var html = ''; + if (items.length === 0) { + html = '暂无作业记录'; + } else { + items.forEach(function(item) { + html += '' + + '' + escapeHtml(item.title || '-') + '' + + '' + escapeHtml(item.subject_name || '-') + '' + + '' + formatDate(item.deadline) + '' + + '' + escapeHtml(item.description || '-') + '' + + '' + + ''; + }); + } + document.getElementById('homeworkList').innerHTML = html; + + var totalPages = Math.ceil(total / pageSize); + if (totalPages > 1) { + document.getElementById('pagination').style.display = 'flex'; + document.getElementById('pageInfo').textContent = page + ' / ' + totalPages; + document.getElementById('prevBtn').disabled = page <= 1; + document.getElementById('nextBtn').disabled = page >= totalPages; + } else { + document.getElementById('pagination').style.display = 'none'; + } + } +} + +window.changePage = function(delta) { + currentPage += delta; + loadHomework(currentPage); +}; + +window.showPublishModal = function() { + document.getElementById('publishForm').reset(); + document.getElementById('hwDeadline').value = new Date().toISOString().split('T')[0]; + document.getElementById('publishModal').style.display = 'flex'; +}; + +window.submitHomework = async function() { + var title = document.getElementById('hwTitle').value.trim(); + var deadline = document.getElementById('hwDeadline').value; + var description = document.getElementById('hwDescription').value.trim(); + + if (!title) { + showToast('请填写作业标题', 'error'); + return; + } + if (!deadline) { + showToast('请选择截止日期', 'error'); + return; + } + + var res = await apiPost('/api/cadre/homework', { + title: title, + deadline: deadline, + description: description + }); + + if (res && res.success) { + showToast('作业发布成功'); + closeModal('publishModal'); + loadHomework(currentPage); + } else { + showToast(res && res.message ? res.message : '发布失败', 'error'); + } +}; + +window.showAbsentModal = async function(assignmentId) { + currentAssignmentId = assignmentId; + var res = await apiGet('/api/admin/students', { page_size: 1000 }); + if (res && res.success && res.data) { + var students = res.data.students || res.data.items || []; + var html = '
'; + if (students.length === 0) { + html += '

暂无学生数据

'; + } else { + html += '
' + + '' + + ''; + students.forEach(function(s) { + html += '' + + '' + + '' + + '' + + ''; + }); + html += '
学号姓名
' + escapeHtml(s.student_no) + '' + escapeHtml(s.name) + '
'; + } + document.getElementById('absentStudentList').innerHTML = html; + document.getElementById('absentModal').style.display = 'flex'; + } else { + showToast('获取学生列表失败', 'error'); + } +}; + +window.toggleAllAbsent = function(el) { + var checkboxes = document.querySelectorAll('.absent-checkbox'); + checkboxes.forEach(function(cb) { cb.checked = el.checked; }); +}; + +window.submitAbsent = async function() { + var checkboxes = document.querySelectorAll('.absent-checkbox:checked'); + if (checkboxes.length === 0) { + showToast('请选择至少一名缺交学生', 'error'); + return; + } + + var studentIds = []; + checkboxes.forEach(function(cb) { + studentIds.push(parseInt(cb.getAttribute('data-id'))); + }); + + var hwDeduct = window.DEDUCTION_HOMEWORK_NOT_SUBMIT || 2; + var res = await apiPost('/api/cadre/conduct/add', { + student_ids: studentIds, + points_change: -hwDeduct, + reason: '作业未提交', + related_type: 'homework' + }); + + if (res && res.success) { + showToast('已登记 ' + studentIds.length + ' 名学生缺交'); + closeModal('absentModal'); + } else { + showToast(res && res.message ? res.message : '提交失败', 'error'); + } +}; + +window.closeModal = function(id) { + document.getElementById(id).style.display = 'none'; +}; + +document.addEventListener('DOMContentLoaded', function() { + loadHomework(currentPage); +}); + +})(); diff --git a/frontend/assets/js/common.js b/frontend/assets/js/common.js index 7c693a7..1f61775 100644 --- a/frontend/assets/js/common.js +++ b/frontend/assets/js/common.js @@ -1,10 +1,10 @@ /** - * 班级操行分管理系统 - 公共JS + * 多班级版班级管理系统 - 公共JS * * 开发者: Canglan * 联系方式: admin@sea-studio.top * 版权归属: Sea Network Technology Studio - * 许可证: MIT License + * 许可证: Apache License 2.0 * * 版权所有 © Sea Network Technology Studio */ @@ -18,7 +18,7 @@ function getUserInfo() { if (!userStr) return null; try { return JSON.parse(userStr); - } catch { + } catch (e) { return null; } } @@ -191,11 +191,13 @@ async function logout() { function escapeHtml(str) { if (!str) return ''; return String(str) - .replace(/&/g, '\x26amp;') - .replace(//g, '\x26gt;') - .replace(/"/g, '\x26quot;') - .replace(/'/g, '\x26#x27;'); + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/`/g, '`') + .replace(/\//g, '/'); } /** @@ -383,21 +385,19 @@ document.addEventListener('click', function(e) { } }); -// 全局textarea键盘事件:Enter提交表单,Ctrl+Enter换行 +// 全局textarea键盘事件:Ctrl+Enter提交表单,Enter换行(默认行为) document.addEventListener('keydown', function(e) { if (e.target.tagName !== 'TEXTAREA') return; - if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey && !e.metaKey) { - // Enter键提交表单 + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + // Ctrl+Enter / Cmd+Enter 提交表单 e.preventDefault(); var form = e.target.closest('form'); if (form) { - // 触发form的submit事件 var submitEvent = new Event('submit', { cancelable: true, bubbles: true }); form.dispatchEvent(submitEvent); } } - // Ctrl+Enter和Shift+Enter保持默认换行行为(不拦截) }); window.selectDeductionType = function(points, reason) { diff --git a/frontend/assets/js/conduct.js b/frontend/assets/js/conduct.js index 78d4bc5..5457493 100644 --- a/frontend/assets/js/conduct.js +++ b/frontend/assets/js/conduct.js @@ -1,5 +1,5 @@ /** - * 班级操行分管理系统 - 操行分管理页JS + * 多班级版班级管理系统 - 操行分管理页JS * * 开发者: Canglan * 版权归属: Sea Network Technology Studio @@ -20,7 +20,7 @@ async function loadStudents() { ${escapeHtml(student.student_no)} ${escapeHtml(student.name)} ${student.total_points} - + `; }); if (res.data.students.length === 0) { @@ -39,7 +39,7 @@ function showSinglePointsModal(studentId, studentName) { } async function exportMoralityRecords() { - showToast('正在导出德育分记录...', 'info'); + showToast('正在导出操行分记录...', 'info'); try { const studentsRes = await apiGet('/api/admin/students', { page_size: 1000 }); @@ -54,13 +54,20 @@ async function exportMoralityRecords() { return; } - const historyRes = await apiGet('/api/admin/conduct/history', { page: 1, page_size: 1000 }); - if (!historyRes || !historyRes.success) { - showToast('获取历史记录失败', 'error'); - return; - } - - const allRecords = historyRes.data.records || []; + const allRecords = []; + let page = 1; + let totalPages = 1; + do { + const historyRes = await apiGet('/api/admin/conduct/history', { page: page, page_size: 500 }); + if (!historyRes || !historyRes.success) { + showToast('获取历史记录失败', 'error'); + return; + } + const records = historyRes.data.records || []; + allRecords.push(...records); + totalPages = historyRes.data.total_pages || 1; + page++; + } while (page <= totalPages); const recordsByStudent = {}; allRecords.forEach(record => { @@ -90,7 +97,7 @@ async function exportMoralityRecords() { if (field === null || field === undefined) return ''; let str = String(field).replace(/[\r\n]+/g, ' '); str = str.replace(/"/g, '""'); - if (/[\,\;\"\s]/.test(str)) { + if (/[\,\"\s]/.test(str)) { str = '"' + str + '"'; } return str; @@ -106,7 +113,7 @@ async function exportMoralityRecords() { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = `德育分记录_${new Date().toISOString().slice(0,10)}.csv`; + link.download = `操行分记录_${new Date().toISOString().slice(0,10)}.csv`; document.body.appendChild(link); link.click(); document.body.removeChild(link); @@ -119,7 +126,7 @@ async function exportMoralityRecords() { } } // 宿舍集体加分相关 -var dormitoryStudentIds = []; +let dormitoryStudentIds = []; async function showDormitoryPointsModal() { dormitoryStudentIds = []; @@ -196,6 +203,11 @@ async function submitDormitoryPoints() { return; } + if (Math.abs(pointsChange) > 100) { + showToast('分值绝对值不能超过100', 'error'); + return; + } + if (!reason.trim()) { showToast('请填写原因', 'error'); return; @@ -204,7 +216,8 @@ async function submitDormitoryPoints() { const data = { student_ids: dormitoryStudentIds, points_change: pointsChange, - reason: reason + reason: reason, + related_type: 'manual' }; const res = await apiPost('/api/admin/conduct/add', data); @@ -220,6 +233,16 @@ async function submitDormitoryPoints() { loadStudents(); +document.getElementById('studentList').addEventListener('click', function(e) { + const btn = e.target.closest('.js-single-points'); + if (btn) { + showSinglePointsModal( + parseInt(btn.dataset.studentId, 10), + btn.dataset.studentName + ); + } +}); + window.loadStudents = loadStudents; window.showSinglePointsModal = showSinglePointsModal; window.exportMoralityRecords = exportMoralityRecords; diff --git a/frontend/assets/js/dashboard.js b/frontend/assets/js/dashboard.js index 3a948f9..5c7b375 100644 --- a/frontend/assets/js/dashboard.js +++ b/frontend/assets/js/dashboard.js @@ -1,5 +1,5 @@ /** - * 班级操行分管理系统 - 管理端首页JS + * 多班级版班级管理系统 - 管理端首页JS * * 开发者: Canglan * 版权归属: Sea Network Technology Studio diff --git a/frontend/assets/js/history.js b/frontend/assets/js/history.js index 766cf2a..2a13bb9 100644 --- a/frontend/assets/js/history.js +++ b/frontend/assets/js/history.js @@ -1,5 +1,5 @@ /** - * 班级操行分管理系统 - 历史记录页JS + * 多班级版班级管理系统 - 历史记录页JS * * 开发者: Canglan * 版权归属: Sea Network Technology Studio @@ -15,11 +15,14 @@ const currentUserId = window.PAGE_CONFIG.userId; let currentHistoryPage = 1; let totalHistoryPages = 1; -function escapeHtml(str) { - if (!str) return ''; - var el = document.createElement('span'); - el.appendChild(document.createTextNode(str)); - return el.innerHTML; +function getTypeLabel(relatedType) { + if (!relatedType) return '操行'; + switch (relatedType) { + case 'conduct': return '操行'; + case 'homework': return '作业'; + case 'attendance': return '考勤'; + default: return relatedType; + } } async function loadStudentsForSelect() { @@ -35,9 +38,9 @@ async function loadStudentsForSelect() { // 加载科目下拉列表 async function loadSubjectsForFilter() { - var subjectSelect = document.getElementById('historySubjectFilter'); + let subjectSelect = document.getElementById('historySubjectFilter'); if (!subjectSelect) return; - var res = await apiGet('/api/subject/list', { is_active: true }); + let res = await apiGet('/api/subject/list', { is_active: true }); if (res && res.success && res.data && res.data.subjects) { let html = ''; res.data.subjects.forEach(s => { @@ -49,16 +52,16 @@ async function loadSubjectsForFilter() { // 筛选学生时自动取消合并记录 function onStudentFilterChange() { - var studentId = document.getElementById('historyStudentId').value; + let studentId = document.getElementById('historyStudentId').value; if (studentId) { - var grouped = document.getElementById('historyGrouped'); + let grouped = document.getElementById('historyGrouped'); if (grouped) grouped.checked = false; } } // 科目筛选变化时,取消扣分类型筛选(互斥) function onSubjectFilterChange() { - var subjectVal = document.getElementById('historySubjectFilter').value; + let subjectVal = document.getElementById('historySubjectFilter').value; if (subjectVal) { document.getElementById('historyReasonFilter').value = ''; } @@ -66,8 +69,8 @@ function onSubjectFilterChange() { // 折叠/展开筛选面板 function toggleFilterPanel() { - var panel = document.getElementById('advancedFilters'); - var btn = document.getElementById('filterToggleBtn'); + let panel = document.getElementById('advancedFilters'); + let btn = document.getElementById('filterToggleBtn'); if (!panel || !btn) return; if (panel.style.display === 'none') { panel.style.display = 'block'; @@ -81,19 +84,19 @@ function toggleFilterPanel() { async function loadHistory(page) { page = page || 1; currentHistoryPage = page; - var startDate = document.getElementById('historyStartDate').value; - var endDate = document.getElementById('historyEndDate').value; - var studentId = document.getElementById('historyStudentId').value; - var reasonFilter = document.getElementById('historyReasonFilter').value; - var subjectFilter = document.getElementById('historySubjectFilter').value; - var reasonSearch = document.getElementById('historyReasonSearch').value.trim(); - var isGrouped = document.getElementById('historyGrouped').checked; - var statusFilter = document.getElementById('historyStatusFilter') ? document.getElementById('historyStatusFilter').value : ''; + let startDate = document.getElementById('historyStartDate').value; + let endDate = document.getElementById('historyEndDate').value; + let studentId = document.getElementById('historyStudentId').value; + let reasonFilter = document.getElementById('historyReasonFilter').value; + let subjectFilter = document.getElementById('historySubjectFilter').value; + let reasonSearch = document.getElementById('historyReasonSearch').value.trim(); + let isGrouped = document.getElementById('historyGrouped').checked; + let statusFilter = document.getElementById('historyStatusFilter') ? document.getElementById('historyStatusFilter').value : ''; // 筛选学生时强制取消合并 if (studentId) isGrouped = false; - var params = { + let params = { page: page, page_size: 20, start_date: startDate, end_date: endDate @@ -111,37 +114,38 @@ async function loadHistory(page) { if (isGrouped) params.grouped = true; if (statusFilter !== '') params.is_revoked = parseInt(statusFilter); - var res = await apiGet('/api/admin/conduct/history', params); + let res = await apiGet('/api/admin/conduct/history', params); if (res && res.success) { - var nowrapStyle = ' style="white-space:nowrap;min-width:80px;"'; - var headHtml = ''; + let nowrapStyle = ' style="white-space:nowrap;min-width:80px;"'; + let headHtml = ''; if (isGrouped) { - headHtml = '时间原因分值操作人涉及学生'; + headHtml = '类型分值原因学生名单操作人时间'; if (role === '班主任' || role === '班长') { headHtml += '操作'; } } else { - headHtml = '时间学生分数变动原因操作人'; + headHtml = '类型分值原因学生操作人时间'; if (role === '班主任' || role === '班长' || role === '考勤委员') { headHtml += '操作'; } } document.getElementById('historyTableHead').innerHTML = headHtml; - var html = ''; + let html = ''; if (isGrouped) { res.data.records.forEach(function(record) { - var pointsClass = record.points_change > 0 ? 'plus' : 'minus'; - var names = record.student_names || ''; - var allRevoked = record.all_revoked; - var revokedStyle = allRevoked ? ' style="opacity:0.5;text-decoration:line-through;"' : ''; + let pointsClass = record.points_change > 0 ? 'plus' : 'minus'; + let names = record.student_names || ''; + let allRevoked = record.all_revoked; + let revokedStyle = allRevoked ? ' style="opacity:0.5;text-decoration:line-through;"' : ''; html += '' + - '' + formatDateTime(record.created_at) + '' + - '' + escapeHtml(record.reason) + '' + + '' + escapeHtml(getTypeLabel(record.related_type)) + '' + '' + (record.points_change > 0 ? '+' : '') + record.points_change + '×' + record.student_count + '' + + '' + escapeHtml(record.reason) + '' + + '' + escapeHtml(names) + '' + '' + escapeHtml(record.recorder_name || '') + '' + - '' + escapeHtml(names) + ''; + '' + formatDateTime(record.created_at) + ''; if (role === '班主任' || role === '班长') { if (allRevoked) { html += '已撤销'; @@ -152,29 +156,30 @@ async function loadHistory(page) { html += ''; }); if (res.data.records.length === 0) { - var colSpan = (role === '班主任' || role === '班长') ? 6 : 5; + let colSpan = (role === '班主任' || role === '班长') ? 7 : 6; html = '暂无记录'; } } else { res.data.records.forEach(function(record) { - var pointsClass = record.points_change > 0 ? 'plus' : 'minus'; - var revokedStyle = record.is_revoked == 1 ? ' style="opacity:0.5;text-decoration:line-through;"' : ''; + let pointsClass = record.points_change > 0 ? 'plus' : 'minus'; + let revokedStyle = record.is_revoked == 1 ? ' style="opacity:0.5;text-decoration:line-through;"' : ''; html += '' + - '' + formatDateTime(record.created_at) + '' + - '' + escapeHtml(record.student_name) + '' + + '' + escapeHtml(getTypeLabel(record.related_type)) + '' + '' + (record.points_change > 0 ? '+' : '') + record.points_change + '' + '' + escapeHtml(record.reason) + '' + - '' + escapeHtml(record.recorder_name) + ''; + '' + escapeHtml(record.student_name) + '' + + '' + escapeHtml(record.recorder_name) + '' + + '' + formatDateTime(record.created_at) + ''; if (role === '班主任') { if (record.is_revoked == 1) { - var revokerInfo = record.revoker_name ? '由 ' + escapeHtml(record.revoker_name) + ' 撤销' : '已撤销'; + let revokerInfo = record.revoker_name ? '由 ' + escapeHtml(record.revoker_name) + ' 撤销' : '已撤销'; html += '' + revokerInfo + ''; } else { html += ''; } } else if (role === '班长') { if (record.is_revoked == 1) { - var revokerInfo = record.revoker_name ? '由 ' + escapeHtml(record.revoker_name) + ' 撤销' : '已撤销'; + let revokerInfo = record.revoker_name ? '由 ' + escapeHtml(record.revoker_name) + ' 撤销' : '已撤销'; html += '' + revokerInfo + ''; } else { html += ''; @@ -192,7 +197,7 @@ async function loadHistory(page) { }); if (res.data.records.length === 0) { - var colSpan = (role === '班主任' || role === '班长' || role === '考勤委员') ? 6 : 5; + let colSpan = (role === '班主任' || role === '班长' || role === '考勤委员') ? 7 : 6; html = '暂无记录'; } } @@ -211,17 +216,17 @@ function renderHistoryPagination() { } async function exportHistoryRecords() { - var startDate = document.getElementById('historyStartDate').value; - var endDate = document.getElementById('historyEndDate').value; - var studentId = document.getElementById('historyStudentId').value; + let startDate = document.getElementById('historyStartDate').value; + let endDate = document.getElementById('historyEndDate').value; + let studentId = document.getElementById('historyStudentId').value; showToast('正在导出历史记录...', 'info'); try { - var reasonFilter = document.getElementById('historyReasonFilter').value; - var subjectFilter = document.getElementById('historySubjectFilter').value; - var reasonSearch = document.getElementById('historyReasonSearch').value.trim(); - var params = { page: 1, page_size: 1000 }; + let reasonFilter = document.getElementById('historyReasonFilter').value; + let subjectFilter = document.getElementById('historySubjectFilter').value; + let reasonSearch = document.getElementById('historyReasonSearch').value.trim(); + let params = { page: 1, page_size: 1000 }; if (startDate) params.start_date = startDate; if (endDate) params.end_date = endDate; if (studentId) params.student_id = studentId; @@ -232,24 +237,31 @@ async function exportHistoryRecords() { } if (reasonSearch) params.reason_search = reasonSearch; - var res = await apiGet('/api/admin/conduct/history', params); + let res = await apiGet('/api/admin/conduct/history', params); if (res && res.success && res.data.records) { - var records = res.data.records; + let records = res.data.records; if (records.length === 0) { showToast('没有找到记录', 'warning'); return; } - var csv = '\uFEFF'; + function csvField(val) { + let s = String(val == null ? '' : val); + if (s.indexOf(',') >= 0 || s.indexOf('"') >= 0 || s.indexOf('\n') >= 0) { + return '"' + s.replace(/"/g, '""') + '"'; + } + return s; + } + let csv = '\uFEFF'; csv += '时间,学号,姓名,分数变动,原因,操作人\n'; records.forEach(function(r) { if (r.is_revoked == 1) return; - csv += (r.created_at || '') + ',' + (r.student_no || '') + ',' + (r.student_name || '') + ',' + (r.points_change > 0 ? '+' : '') + r.points_change + ',' + (r.reason || '').replace(/,/g, ';') + ',' + (r.recorder_name || '') + '\n'; + csv += csvField(r.created_at) + ',' + csvField(r.student_no) + ',' + csvField(r.student_name) + ',' + csvField((r.points_change > 0 ? '+' : '') + r.points_change) + ',' + csvField(r.reason) + ',' + csvField(r.recorder_name) + '\n'; }); - var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); - var url = URL.createObjectURL(blob); - var link = document.createElement('a'); + let blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + let url = URL.createObjectURL(blob); + let link = document.createElement('a'); link.href = url; link.download = '历史记录_' + new Date().toISOString().slice(0,10) + '.csv'; document.body.appendChild(link); @@ -273,21 +285,21 @@ async function batchRevokeGrouped(reason, pointsChange, recorderName, createdAt) showToast('正在批量撤销...', 'info'); try { - var params = { + let params = { page: 1, page_size: 1000, start_date: document.getElementById('historyStartDate').value, end_date: document.getElementById('historyEndDate').value, - reason_prefix: reason.substring(0, 4), + reason_prefix: reason, grouped: false }; - var res = await apiGet('/api/admin/conduct/history', params); + let res = await apiGet('/api/admin/conduct/history', params); if (!res || !res.success || !res.data.records) { showToast('查询记录失败', 'error'); return; } - var matchedIds = []; + let matchedIds = []; res.data.records.forEach(function(r) { if (r.reason === reason && r.points_change === pointsChange && r.is_revoked == 0) { matchedIds.push(r.record_id); @@ -299,7 +311,7 @@ async function batchRevokeGrouped(reason, pointsChange, recorderName, createdAt) return; } - var revokeRes = await apiPost('/api/admin/conduct/batch-revoke', { record_ids: matchedIds }); + let revokeRes = await apiPost('/api/admin/conduct/batch-revoke', { record_ids: matchedIds }); if (revokeRes && revokeRes.success) { showToast('批量撤销完成: ' + (revokeRes.data ? revokeRes.data.success_count : 0) + '条成功'); loadHistory(currentHistoryPage); @@ -313,8 +325,8 @@ async function batchRevokeGrouped(reason, pointsChange, recorderName, createdAt) // 初始化:并行加载学生和科目列表,然后加载历史记录 Promise.all([loadStudentsForSelect(), loadSubjectsForFilter()]).then(function() { - var urlParams = new URLSearchParams(window.location.search); - var preStudentId = urlParams.get('student_id'); + let urlParams = new URLSearchParams(window.location.search); + let preStudentId = urlParams.get('student_id'); if (preStudentId) { document.getElementById('historyStudentId').value = preStudentId; onStudentFilterChange(); diff --git a/frontend/assets/js/homework-manage.js b/frontend/assets/js/homework-manage.js index e4256ad..a7f6897 100644 --- a/frontend/assets/js/homework-manage.js +++ b/frontend/assets/js/homework-manage.js @@ -1,5 +1,5 @@ /** - * 班级操行分管理系统 - 作业扣分页JS + * 多班级版班级管理系统 - 作业扣分页JS * * 开发者: Canglan * 版权归属: Sea Network Technology Studio @@ -187,7 +187,7 @@ async function submitAddSubject() { } async function toggleSubjectStatus(subjectId, enable) { - const res = await apiPut(`/api/subject/update/${subjectId}`, { is_active: enable }); + const res = await apiPut(`/api/subject/toggle/${subjectId}`, { is_active: enable }); if (res && res.success) { showToast(enable ? '科目已启用' : '科目已禁用'); loadSubjectList(); diff --git a/frontend/assets/js/modules/admin-mgmt.js b/frontend/assets/js/modules/admin-mgmt.js index 3f709a1..a416837 100644 --- a/frontend/assets/js/modules/admin-mgmt.js +++ b/frontend/assets/js/modules/admin-mgmt.js @@ -1,10 +1,10 @@ /** - * 班级操行分管理系统 - 管理员管理函数 + * 多班级版班级管理系统 - 管理员管理函数 * * 开发者: Canglan * 联系方式: admin@sea-studio.top * 版权归属: Sea Network Technology Studio - * 许可证: MIT License + * 许可证: Apache License 2.0 * * 版权所有 © Sea Network Technology Studio */ diff --git a/frontend/assets/js/modules/modal-utils.js b/frontend/assets/js/modules/modal-utils.js index 3d0cf1f..eeeb47f 100644 --- a/frontend/assets/js/modules/modal-utils.js +++ b/frontend/assets/js/modules/modal-utils.js @@ -1,10 +1,10 @@ /** - * 班级操行分管理系统 - 模态框工具函数 + * 多班级版班级管理系统 - 模态框工具函数 * * 开发者: Canglan * 联系方式: admin@sea-studio.top * 版权归属: Sea Network Technology Studio - * 许可证: MIT License + * 许可证: Apache License 2.0 * * 版权所有 © Sea Network Technology Studio */ diff --git a/frontend/assets/js/modules/points-mgmt.js b/frontend/assets/js/modules/points-mgmt.js index 4451f27..be5ccdb 100644 --- a/frontend/assets/js/modules/points-mgmt.js +++ b/frontend/assets/js/modules/points-mgmt.js @@ -1,10 +1,10 @@ /** - * 班级操行分管理系统 - 加减分管理函数 + * 多班级版班级管理系统 - 加减分管理函数 * * 开发者: Canglan * 联系方式: admin@sea-studio.top * 版权归属: Sea Network Technology Studio - * 许可证: MIT License + * 许可证: Apache License 2.0 * * 版权所有 © Sea Network Technology Studio */ diff --git a/frontend/assets/js/modules/student-mgmt.js b/frontend/assets/js/modules/student-mgmt.js index 91df684..8b6f174 100644 --- a/frontend/assets/js/modules/student-mgmt.js +++ b/frontend/assets/js/modules/student-mgmt.js @@ -1,10 +1,10 @@ /** - * 班级操行分管理系统 - 学生管理函数 + * 多班级版班级管理系统 - 学生管理函数 * * 开发者: Canglan * 联系方式: admin@sea-studio.top * 版权归属: Sea Network Technology Studio - * 许可证: MIT License + * 许可证: Apache License 2.0 * * 版权所有 © Sea Network Technology Studio */ @@ -32,7 +32,7 @@ const res = await apiPost('/api/admin/students', { student_no: studentNo, name: name, - parent_phone: parentPhone, + parent_account: parentPhone, dormitory_number: document.getElementById('addDormitoryNumber').value.trim() }); @@ -68,7 +68,7 @@ const res = await apiPut(`/api/admin/students/${studentId}`, { name: name, - parent_phone: phone || null, + parent_account: phone || null, dormitory_number: document.getElementById('editDormitoryNumber').value.trim() }); @@ -146,14 +146,14 @@ const students = data.students || []; let html = '

预览数据

'; - html += ''; + html += ''; html += ''; students.forEach(s => { html += ` - + `; diff --git a/frontend/assets/js/modules/subject-mgmt.js b/frontend/assets/js/modules/subject-mgmt.js index 9c55142..a0b30b5 100644 --- a/frontend/assets/js/modules/subject-mgmt.js +++ b/frontend/assets/js/modules/subject-mgmt.js @@ -1,10 +1,10 @@ /** - * 班级操行分管理系统 - 科目管理函数 + * 多班级版班级管理系统 - 科目管理函数 * * 开发者: Canglan * 联系方式: admin@sea-studio.top * 版权归属: Sea Network Technology Studio - * 许可证: MIT License + * 许可证: Apache License 2.0 * * 版权所有 © Sea Network Technology Studio */ diff --git a/frontend/assets/js/modules/utils.js b/frontend/assets/js/modules/utils.js index 97aa8e2..b50fedd 100644 --- a/frontend/assets/js/modules/utils.js +++ b/frontend/assets/js/modules/utils.js @@ -1,10 +1,10 @@ /** - * 班级操行分管理系统 - 通用工具函数 + * 多班级版班级管理系统 - 通用工具函数 * * 开发者: Canglan * 联系方式: admin@sea-studio.top * 版权归属: Sea Network Technology Studio - * 许可证: MIT License + * 许可证: Apache License 2.0 * * 版权所有 © Sea Network Technology Studio */ diff --git a/frontend/assets/js/parent.js b/frontend/assets/js/parent.js index a6db5b3..4b225a1 100644 --- a/frontend/assets/js/parent.js +++ b/frontend/assets/js/parent.js @@ -1,10 +1,10 @@ /** - * 班级操行分管理系统 - 家长端JS + * 多班级版班级管理系统 - 家长端JS * * 开发者: Canglan * 联系方式: admin@sea-studio.top * 版权归属: Sea Network Technology Studio - * 许可证: MIT License + * 许可证: Apache License 2.0 * * 版权所有 © Sea Network Technology Studio */ diff --git a/frontend/assets/js/rankings.js b/frontend/assets/js/rankings.js new file mode 100644 index 0000000..00ffcdc --- /dev/null +++ b/frontend/assets/js/rankings.js @@ -0,0 +1,59 @@ +/** + * 多班级版班级管理系统 - 排行榜JS + * + * 开发者: Canglan + * 版权归属: Sea Network Technology Studio + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { +'use strict'; + +let currentType = 'conduct'; + +async function loadRankings(type) { + const res = await apiGet('/api/admin/rankings', { type: type, limit: 50 }); + if (res && res.success && res.data) { + const rankings = res.data.ranking || []; + let html = ''; + if (rankings.length === 0) { + html = ''; + } else { + rankings.forEach(function(item, index) { + let rankClass = ''; + if (index === 0) rankClass = 'rank-gold'; + else if (index === 1) rankClass = 'rank-silver'; + else if (index === 2) rankClass = 'rank-bronze'; + + let pointsText = Number(item.points !== undefined ? item.points : (item.total_points || 0)); + if (pointsText > 0) { + pointsText = '+' + pointsText; + } + + html += '' + + '' + + '' + + '' + + '' + + ''; + }); + } + document.getElementById('rankingList').innerHTML = html; + } else { + document.getElementById('rankingList').innerHTML = ''; + } +} + +window.switchTab = function(type, btn) { + currentType = type; + document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); }); + btn.classList.add('active'); + loadRankings(type); +}; + +document.addEventListener('DOMContentLoaded', function() { + loadRankings(currentType); +}); + +})(); diff --git a/frontend/assets/js/semesters.js b/frontend/assets/js/semesters.js index 960fa5a..05ddca2 100644 --- a/frontend/assets/js/semesters.js +++ b/frontend/assets/js/semesters.js @@ -1,5 +1,5 @@ /** - * 班级操行分管理系统 - 学期管理页JS + * 多班级版班级管理系统 - 学期管理页JS * * 开发者: Canglan * 版权归属: Sea Network Technology Studio @@ -41,7 +41,7 @@ async function loadSemesters() { const res = await apiGet('/api/semester/list'); if (res && res.success) { let html = ''; - const semesters = res.data || []; + const semesters = res.data.semesters || []; semesters.forEach(sem => { let statusText = ''; let statusClass = ''; @@ -252,7 +252,7 @@ async function viewArchiveData(semesterId, semesterName, page) { if (res && res.success) { const data = res.data || {}; - const archives = data.archives || []; + const archives = data.items || []; let html = ''; archives.forEach(a => { html += ` @@ -288,6 +288,80 @@ function renderArchivePagination(semesterId, semesterName) { }); } +// ========== 周期重置功能 ========== + +let pendingPeriodType = null; +let periodArchivesType = null; +let periodArchivesPage = 1; +let periodArchivesTotalPages = 1; + +function confirmPeriodReset(periodType) { + pendingPeriodType = periodType; + const label = periodType === 'weekly' ? '本周' : '本月'; + document.getElementById('periodResetText').innerHTML = + `确定要执行 ${label}重置 吗?
将保存当前所有学生的操行分快照,然后将所有学生操行分重置为初始值。`; + document.getElementById('periodResetModal').style.display = 'flex'; +} + +async function executePeriodReset() { + if (!pendingPeriodType) return; + + const res = await apiPost('/api/semester/period-reset', { period: pendingPeriodType }); + if (res && res.success) { + showToast(res.message || '重置成功'); + closeModal('periodResetModal'); + pendingPeriodType = null; + } else { + showToast(res?.message || '重置失败', 'error'); + } +} + +async function showPeriodArchives(type, page) { + var periodType = type || periodArchivesType; + page = page || 1; + periodArchivesType = periodType; + periodArchivesPage = page; + + const label = periodType === 'weekly' ? '周' : '月'; + document.getElementById('periodArchivesTitle').textContent = label + '归档数据'; + + const res = await apiGet('/api/semester/period-archives', { + period: periodType, + page: page, + page_size: 50 + }); + + if (res && res.success) { + const data = res.data || {}; + const archives = data.items || []; + let html = ''; + archives.forEach(function(a) { + const resetByLabel = a.reset_by === 'auto' ? '自动' : '手动'; + html += '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + if (archives.length === 0) { + html = ''; + } + document.getElementById('periodArchivesList').innerHTML = html; + + periodArchivesTotalPages = data.total_pages || 1; + renderSmartPagination('periodArchivePagination', periodArchivesPage, periodArchivesTotalPages, function(p) { + showPeriodArchives(periodArchivesType, p); + }); + document.getElementById('periodArchivesModal').style.display = 'flex'; + } else { + showToast(res?.message || '获取归档数据失败', 'error'); + } +} + loadSemesters(); window.fillSemesterDates = fillSemesterDates; @@ -302,5 +376,8 @@ window.confirmAssociate = confirmAssociate; window.showArchiveConfirm = showArchiveConfirm; window.confirmArchive = confirmArchive; window.viewArchiveData = viewArchiveData; +window.confirmPeriodReset = confirmPeriodReset; +window.executePeriodReset = executePeriodReset; +window.showPeriodArchives = showPeriodArchives; })(); diff --git a/frontend/assets/js/student-homework.js b/frontend/assets/js/student-homework.js index 4ff9d5e..626a5a3 100644 --- a/frontend/assets/js/student-homework.js +++ b/frontend/assets/js/student-homework.js @@ -1,5 +1,5 @@ /** - * 班级操行分管理系统 - 学生端作业情况JS + * 多班级版班级管理系统 - 学生端作业情况JS * * 开发者: Canglan * 版权归属: Sea Network Technology Studio diff --git a/frontend/assets/js/student.js b/frontend/assets/js/student.js index eb00687..43b676f 100644 --- a/frontend/assets/js/student.js +++ b/frontend/assets/js/student.js @@ -1,10 +1,10 @@ /** - * 班级操行分管理系统 - 学生端JS + * 多班级版班级管理系统 - 学生端JS * * 开发者: Canglan * 联系方式: admin@sea-studio.top * 版权归属: Sea Network Technology Studio - * 许可证: MIT License + * 许可证: Apache License 2.0 * * 版权所有 © Sea Network Technology Studio */ diff --git a/frontend/assets/js/students-manage.js b/frontend/assets/js/students-manage.js index 4cf17ce..8bb0c7d 100644 --- a/frontend/assets/js/students-manage.js +++ b/frontend/assets/js/students-manage.js @@ -1,5 +1,5 @@ /** - * 班级操行分管理系统 - 学生管理页JS + * 多班级版班级管理系统 - 学生管理页JS * * 开发者: Canglan * 版权归属: Sea Network Technology Studio @@ -28,13 +28,13 @@ async function loadStudents(page = 1) { - ${userRole === '班主任' ? `` : ''} + ${userRole === '班主任' ? `` : ''}
学号姓名家长手机号宿舍号初始密码学号姓名家长账号(推荐手机号)宿舍号初始密码
${escapeHtml(s.student_no || '')} ${escapeHtml(s.name || '')}${escapeHtml(s.parent_phone || '')}${escapeHtml(s.parent_account || '')} ${escapeHtml(s.dormitory_number || '-')} ${escapeHtml(s.password || '123456')}
暂无排行数据
' + (index + 1) + '' + escapeHtml(item.student_no || '-') + '' + escapeHtml(item.name || '-') + '' + pointsText + '
加载失败
' + escapeHtml(a.period_label) + '' + (a.rank_position || '-') + '' + escapeHtml(a.student_no) + '' + escapeHtml(a.student_name) + '' + a.final_points + '' + resetByLabel + '' + formatDateTime(a.archived_at) + '
暂无归档数据
${escapeHtml(student.name)} ${escapeHtml(student.dormitory_number || '-')} ${student.total_points}${student.parent_phone ? student.parent_phone.slice(0,3) + '******' + student.parent_phone.slice(-2) : '-'}${student.parent_account ? student.parent_account.slice(0,3) + '******' + student.parent_account.slice(-2) : '-'}
${userRole === '班主任' ? `
- 编辑 + 编辑 重置密码 解锁 删除 diff --git a/frontend/assets/uploads/sample_import.json b/frontend/assets/uploads/sample_import.json index 279cf25..5fa9b76 100644 --- a/frontend/assets/uploads/sample_import.json +++ b/frontend/assets/uploads/sample_import.json @@ -1,49 +1,23 @@ { - "_comment1": "================================================", - "_comment2": "班级操行分管理系统 - 学生批量导入模板", - "_comment3": "开发者: Canglan | 版权: Sea Network Technology Studio", - "_comment4": "================================================", - "_comment5": "字段说明:", - "_comment6": " student_no - 必填,学生学号,唯一标识", - "_comment7": " name - 必填,学生姓名", - "_comment8": " parent_phone - 可选,家长手机号(11位手机号)", - "_comment9": " dormitory_number - 可选,宿舍号(支持字母数字组合,如 301-A)", - "_comment10": " password - 可选,初始密码,不填则默认 123456", - "_comment11": "================================================", - "_comment12": "导入规则:", - "_comment13": " 1. 学生操行分初始值 = 60分", - "_comment14": " 2. 学生账号 = 学号,密码 = 指定的password或123456", - "_comment15": " 3. 家长账号 = 手机号(若parent_phone有值),密码 = 指定的password或123456", - "_comment16": " 4. 家长姓名默认显示为 '学生姓名家长'", - "_comment17": "================================================", - "students": [ - { - "student_no": "20240001", - "name": "张三", - "parent_phone": "13800138001", - "dormitory_number": "301-A", - "password": "123456" - }, - { - "student_no": "20240002", - "name": "李四", - "parent_phone": "13800138002", - "dormitory_number": "205", - "password": "123456" - }, - { - "student_no": "20240003", - "name": "王五", - "parent_phone": "", - "dormitory_number": "", - "password": "" - }, - { - "student_no": "20240004", - "name": "赵六", - "parent_phone": "13800138004", - "dormitory_number": "102-B", - "password": "" - } - ] -} \ No newline at end of file + "students": [ + { + "student_no": "2025001", + "name": "张三", + "parent_account": "13800138001", + "dormitory_number": "A301", + "password": "123456" + }, + { + "student_no": "2025002", + "name": "李四", + "parent_account": "13800138002", + "dormitory_number": "A302" + }, + { + "student_no": "2025003", + "name": "王五", + "parent_account": "", + "dormitory_number": "B101" + } + ] +} diff --git a/frontend/config.php b/frontend/config.php index c77a01a..dba5510 100644 --- a/frontend/config.php +++ b/frontend/config.php @@ -1,11 +1,11 @@ SESSION_TIMEOUT)) { + session_unset(); + session_destroy(); + if (strpos($_SERVER['REQUEST_URI'] ?? '', '/api/') === false) { + header('Location: /index.php'); + exit(); + } + http_response_code(401); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['success' => false, 'message' => '会话已过期,请重新登录']); + exit(); +} + // 时区设置 date_default_timezone_set('Asia/Shanghai'); -// 生产环境关闭错误显示 -error_reporting(0); -ini_set('display_errors', 0); \ No newline at end of file +// 生产环境关闭错误显示(保留错误日志记录,仅隐藏页面输出) +error_reporting(E_ALL & ~E_NOTICE & ~E_DEPRECATED); +ini_set('display_errors', 0); +ini_set('log_errors', 1); \ No newline at end of file diff --git a/frontend/includes/footer.php b/frontend/includes/footer.php index cc46a9e..a605725 100644 --- a/frontend/includes/footer.php +++ b/frontend/includes/footer.php @@ -1,17 +1,6 @@ - - +
+
+

© · Powered by Canglan / Sea Network Technology Studio

+
- \ No newline at end of file + diff --git a/frontend/includes/header.php b/frontend/includes/header.php index 3defb43..7bdd891 100644 --- a/frontend/includes/header.php +++ b/frontend/includes/header.php @@ -1,11 +1,11 @@ @@ -24,7 +26,7 @@ $page_title = $page_title ?? '首页'; - <?php echo SITE_NAME; ?> - <?php echo $page_title; ?> + <?php echo htmlspecialchars(SITE_NAME, ENT_QUOTES, 'UTF-8'); ?> - <?php echo htmlspecialchars($page_title); ?> @@ -32,8 +34,11 @@ $page_title = $page_title ?? '首页';
-

+

+ + + () @@ -42,38 +47,46 @@ $page_title = $page_title ?? '首页';
- \ No newline at end of file + +
\ No newline at end of file diff --git a/frontend/includes/nav.php b/frontend/includes/nav.php index 0635bda..f22fdd1 100644 --- a/frontend/includes/nav.php +++ b/frontend/includes/nav.php @@ -1,20 +1,30 @@