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="
课代表可管理所代表科目的作业缺交情况
+| 作业标题 | +科目 | +截止日期 | +描述 | +操作 | +
|---|---|---|---|---|
| 加载中... | ||||
修改当前班级的扣分规则、加减分限制和功能开关(仅班主任可修改)
+配置各角色单次加减分的上下限
+按周或按月自动重置学生操行分(需配合定时任务或手动触发)
+控制各角色的功能启用状态
+| 排名 | +学号 | +姓名 | +分值 | +
|---|---|---|---|
| 加载中... | |||
手动触发当前班级的周/月操行分重置(重置前会自动创建分数快照)
+ +暂无学生数据
'; + } else { + html += '| ' + + ' | 学号 | 姓名 |
|---|---|---|
| ' + + ' | ' + escapeHtml(s.student_no) + ' | ' + + '' + escapeHtml(s.name) + ' | ' + + '
| 学号 | 姓名 | 家长手机号 | 宿舍号 | 初始密码 | '; + html += '学号 | 姓名 | 家长账号(推荐手机号) | 宿舍号 | 初始密码 | '; html += '
|---|---|---|---|---|---|---|---|---|---|
| ${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} | - ${userRole === '班主任' ? `${student.parent_phone ? student.parent_phone.slice(0,3) + '******' + student.parent_phone.slice(-2) : '-'} | ` : ''} + ${userRole === '班主任' ? `${student.parent_account ? student.parent_account.slice(0,3) + '******' + student.parent_account.slice(-2) : '-'} | ` : ''}
${userRole === '班主任' ? `
+
| |||