Compare commits

..

10 Commits

Author SHA1 Message Date
d6dec878bd feat: 多班级版 v2.0 - Go后端重写 + 43轮代码审查
- 后端从 Python FastAPI 重写为 Go Gin(端口 56789)
- 多班级完全隔离
- 超级管理员独立登录
- 课代表作业管理、排行榜分项排行
- 角色加减分上下限可配置
- 家长改密功能(可开关)
- 周度/月度重置功能
- MySQL 5.7 兼容
- 43轮代码审查+全部修复
- Apache 2.0 许可证
2026-06-22 10:06:10 +08:00
4084afc53c 管理端_功能优化 2026-06-02 11:34:00 +08:00
8c58c915be 管理端_功能优化 2026-06-02 11:11:01 +08:00
f565cd2d4a 管理端_考勤管理页样式优化 2026-06-02 11:05:56 +08:00
0e874f5ddf 管理端_考勤管理页样式优化 2026-06-02 10:58:04 +08:00
b286c885b3 管理端_考勤管理页按钮位置优化 2026-06-02 10:54:34 +08:00
493537ea35 管理端_考勤管理页按钮位置优化 2026-06-02 10:48:07 +08:00
ec6515598b 管理端_考勤管理页按钮位置优化 2026-06-02 10:46:09 +08:00
36d0851171 管理端_考勤管理页样式优化 2026-06-02 10:35:55 +08:00
323b3c8fbc 管理端_考勤管理页面样式优化 2026-06-01 10:36:49 +08:00
214 changed files with 12661 additions and 9744 deletions

14
.gitignore vendored
View File

@@ -1,9 +1,14 @@
# 环境变量 # 环境变量
.env .env
backend/.env backend-go/.env
frontend/.env frontend/.env
# Python # Go
backend-go/sharedclassmanager
backend-go/sharedclassmanager.exe
backend-go/logs/
# Python旧后端残留
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
@@ -44,6 +49,9 @@ Thumbs.db
# CoStrict # CoStrict
.cospec/ .cospec/
plans/
.roo/
code-review_result/
# PDF # PDF
docs/guide/cadre.pdf docs/guide/cadre.pdf
@@ -53,4 +61,4 @@ docs/guide/teacher.pdf
qrcode.png qrcode.png
# example # example
example example/

View File

@@ -1,4 +1,4 @@
# 班级操行分管理系统 - 安装部署指南 # 多班级版班级管理系统 - 安装部署指南
## 环境要求 ## 环境要求
@@ -11,7 +11,7 @@
### 软件依赖 ### 软件依赖
| 软件 | 版本 | 用途 | | 软件 | 版本 | 用途 |
|------|------|------| |------|------|------|
| Python | 3.9+ | 后端运行环境 | | Go | 1.21+ | 后端运行环境 |
| MySQL | 5.7+ | 数据存储 | | MySQL | 5.7+ | 数据存储 |
| Redis | 6.0+ | 缓存、会话 | | Redis | 6.0+ | 缓存、会话 |
| Nginx | 1.18+ | Web服务器、反向代理 | | 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+ | 数据库 | | MySQL | 5.7+ | 数据库 |
| Redis | 6.0+ | 缓存服务 | | Redis | 6.0+ | 缓存服务 |
| PHP | 8.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. 进入"数据库"菜单 1. 进入"数据库"菜单
2. 点击"添加数据库" 2. 点击"添加数据库"
3. 填写数据库信息: 3. 填写数据库信息:
- 数据库名:`class_manager` - 数据库名:`classmanagerdb`
- 用户名:`class_user` - 用户名:`class_admin`
- 密码:生成强密码并保存 - 密码:生成强密码并保存
4. 点击"导入",选择 `sql/init.sql` 文件导入 4. 点击"导入",选择 `sql/init.sql` 文件导入
### 4. 部署后端服务 ### 5. 部署 Go 后端
#### 4.1 上传代码 #### 5.1 上传代码
1. 进入宝塔面板"文件"菜单 1. 进入宝塔面板"文件"菜单
2. 进入 `/www/wwwroot/` 目录 2. 进入 `/www/wwwroot/` 目录
3. 创建项目目录 `classmanager` 3. 上传或克隆代码到 `/www/wwwroot/SharedClassManager`
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/` 目录下:
```bash ```bash
# 复制环境变量示例文件 git clone https://hz-gitea.sea-studio.top/canglan/SharedClassManager.git /www/wwwroot/SharedClassManager
cp .env.example .env
# 编辑配置
vim .env
``` ```
根据实际环境修改以下配置: #### 5.2 配置环境变量
```bash
cd /www/wwwroot/SharedClassManager/backend-go
cp .env.example .env
vim .env # 根据实际环境修改配置
```
**必须修改的配置项**
- `DB_USER` - 数据库用户名 - `DB_USER` - 数据库用户名
- `DB_PASSWORD` - 数据库密码 - `DB_PASSWORD` - 数据库密码
- `REDIS_PASSWORD` - Redis密码如有
- `SECRET_KEY` - 应用密钥32位以上随机字符串
- `JWT_SECRET_KEY` - JWT密钥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. 进入宝塔面板"网站"菜单 1. 进入宝塔面板"网站"菜单
2. 点击"添加站点" 2. 点击"添加站点"
- 域名:填写您的域名 - 域名:填写您的域名
- 根目录:`/www/wwwroot/classmanager/frontend` - 根目录:`/www/wwwroot/SharedClassManager/frontend`
- PHP版本8.0 - PHP版本8.0
#### 5.3 配置伪静态 #### 6.2 配置 Nginx 反向代理
在站点设置中: 在站点设置中,点击"配置文件",替换为以下内容
1. 点击"伪静态"
2. 选择"thinkphp"或添加以下规则:
```nginx ```nginx
server {
listen 80;
server_name your-domain.com;
root /www/wwwroot/SharedClassManager/frontend;
index index.php;
# PHP 处理
location / { location / {
if (!-e $request_filename){ try_files $uri $uri/ /index.php?$query_string;
rewrite ^(.*)$ /index.php?s=$1 last; }
break;
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` 配置: 3. 前端 `.env` 配置:
``` ```
API_BASE_URL=https://your-domain.com API_BASE_URL=https://your-domain.com
``` ```
--- ### 7. 配置 SSL 证书
#### 5.4 配置伪静态(续)
在站点设置中:
1. 点击"伪静态"
2. 选择"thinkphp"或添加以下规则:
```nginx
location / {
if (!-e $request_filename){
rewrite ^(.*)$ /index.php?s=$1 last;
break;
}
}
```
### 6. 配置SSL证书
1. 在站点设置中点击"SSL" 1. 在站点设置中点击"SSL"
2. 选择"Let's Encrypt"免费证书 2. 选择"Let's Encrypt"免费证书
3. 勾选"强制HTTPS" 3. 勾选"强制HTTPS"
### 7. 初始化管理员账号 ### 8. 初始化系统管理员
使用调试接口创建初始管理员(仅首次部署使用) Go 后端首次启动时会**自动创建**超级管理员账号,登录信息从环境变量读取
```bash - **登录路径**:由 `SUPER_ADMIN_LOGIN_PATH` 配置(默认 `/super-admin/login`
# 替换 your-domain.com 为您的域名 - **默认用户名**:由 `SUPER_ADMIN_DEFAULT_USERNAME` 配置(默认 `admin`
# 替换 /a7k9x2m4q8w1e3r5t6y7u8i9o0p1z2x3 为 .env 中配置的 DEBUG_PATH - **默认密码**:由 `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 ```bash
# Ubuntu/Debian # Ubuntu/Debian
sudo apt update 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 # 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. 数据库配置 ### 2. 数据库配置
@@ -208,54 +232,53 @@ mysql -u root -p
``` ```
```sql ```sql
CREATE DATABASE class_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE DATABASE classmanagerdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'class_user'@'localhost' IDENTIFIED BY 'YourStrongPassword'; CREATE USER 'class_admin'@'localhost' IDENTIFIED BY 'YourStrongPassword';
GRANT ALL PRIVILEGES ON class_manager.* TO 'class_user'@'localhost'; GRANT ALL PRIVILEGES ON classmanagerdb.* TO 'class_admin'@'localhost';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
EXIT; EXIT;
``` ```
导入初始化数据: 导入初始化数据:
```bash ```bash
mysql -u class_user -p class_manager < sql/init.sql mysql -u class_admin -p classmanagerdb < sql/init.sql
``` ```
### 3. 后端部署 ### 3. Go 后端部署
```bash ```bash
# 创建项目目录 # 创建项目目录
sudo mkdir -p /var/www/classmanager sudo mkdir -p /www/wwwroot/SharedClassManager
sudo chown -R $USER:$USER /var/www/classmanager sudo chown -R $USER:$USER /www/wwwroot/SharedClassManager
# 上传代码到 /var/www/classmanager/backend/ # 上传代码
cd /var/www/classmanager/backend cd /www/wwwroot/SharedClassManager/backend-go
# 创建虚拟环境
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# 配置环境变量 # 配置环境变量
cp .env.example .env cp .env.example .env
vim .env # 根据实际情况修改配置 vim .env # 根据实际情况修改配置
# 编译
go mod tidy
go build -o sharedclassmanager ./cmd/server
# 使用 Systemd 管理服务 # 使用 Systemd 管理服务
sudo vim /etc/systemd/system/classmanager.service sudo vim /etc/systemd/system/sharedclassmanager.service
``` ```
Systemd 服务文件内容: Systemd 服务文件内容:
```ini ```ini
[Unit] [Unit]
Description=ClassManager Backend Description=SharedClassManager Go Backend
After=network.target After=network.target mysql.service redis.service
[Service] [Service]
Type=simple Type=simple
User=www-data User=www-data
WorkingDirectory=/var/www/classmanager/backend WorkingDirectory=/www/wwwroot/SharedClassManager/backend-go
Environment="PATH=/var/www/classmanager/backend/venv/bin" ExecStart=/www/wwwroot/SharedClassManager/backend-go/sharedclassmanager
ExecStart=/var/www/classmanager/backend/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000
Restart=always Restart=always
RestartSec=5
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
@@ -264,26 +287,21 @@ WantedBy=multi-user.target
启动服务: 启动服务:
```bash ```bash
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl start classmanager sudo systemctl start sharedclassmanager
sudo systemctl enable classmanager sudo systemctl enable sharedclassmanager
``` ```
### 4. 前端部署 ### 4. 前端部署
```bash
# 上传前端代码到 /var/www/classmanager/frontend/
# 配置Nginx
sudo vim /etc/nginx/sites-available/classmanager
```
Nginx 配置示例: Nginx 配置示例:
```nginx ```nginx
server { server {
listen 80; listen 80;
server_name your-domain.com; server_name your-domain.com;
root /var/www/classmanager/frontend; root /www/wwwroot/SharedClassManager/frontend;
index index.php; index index.php;
# PHP 处理
location / { location / {
try_files $uri $uri/ /index.php?$query_string; try_files $uri $uri/ /index.php?$query_string;
} }
@@ -293,17 +311,20 @@ server {
fastcgi_pass unix:/var/run/php/php8.0-fpm.sock; fastcgi_pass unix:/var/run/php/php8.0-fpm.sock;
} }
# Go API 反向代理
# 前后端通过 Nginx 反代同域通信,无需 CORS
location /api/ { 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
} }
``` ```
启用站点: 启用站点:
```bash ```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 nginx -t
sudo systemctl restart nginx 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生产环境 | | `DEBUG` | 调试模式 | false生产环境 |
| `SECRET_KEY` | 应用密钥 | 32位以上随机字符串 | | `APP_PORT` | 服务端口 | 56789 |
### MySQL 数据库
| 配置项 | 说明 | 示例 |
|-------|------|------|
| `DB_HOST` | 数据库地址 | localhost | | `DB_HOST` | 数据库地址 | localhost |
| `DB_USER` | 数据库用户名 | class_user | | `DB_PORT` | 数据库端口 | 3306 |
| `DB_USER` | 数据库用户名 | class_admin |
| `DB_PASSWORD` | 数据库密码 | YourPassword | | `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_HOST` | Redis地址 | localhost |
| `REDIS_PORT` | Redis端口 | 6379 |
| `REDIS_PASSWORD` | Redis密码 | 可选 | | `REDIS_PASSWORD` | Redis密码 | 可选 |
| `REDIS_DB` | Redis数据库编号 | 0 |
| `REDIS_MAX_CONNECTIONS` | 最大连接数 | 500 |
### JWT 认证
| 配置项 | 说明 | 示例 |
|-------|------|------|
| `JWT_SECRET_KEY` | JWT密钥 | 32位以上随机字符串 | | `JWT_SECRET_KEY` | JWT密钥 | 32位以上随机字符串 |
| `DEBUG_PATH` | 调试入口路径 | /random-string | | `JWT_ALGORITHM` | JWT算法 | HS256 |
| `DEDUCTION_HOMEWORK_NOT_SUBMIT` | 作业未提交扣分 | 2 | | `JWT_EXPIRE_MINUTES` | Token过期时间(分钟) | 60 |
| `DEDUCTION_HOMEWORK_LATE` | 作业迟交扣分 | 1 | | `JWT_IDLE_TIMEOUT_MINUTES` | 空闲超时时间(分钟) | 10 |
| `DEDUCTION_ATTENDANCE_ABSENT` | 缺勤扣分 | 5 |
| `DEDUCTION_ATTENDANCE_LATE` | 迟到扣分 | 2 | ### 密码加密
| `DEDUCTION_ATTENDANCE_LEAVE` | 请假扣分 | 1 | | 配置项 | 说明 | 示例 |
|-------|------|------|
| `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: 后端启动失败 ### Q1: 后端启动失败
- 检查端口8000是否被占用 - 检查端口 56789 是否被占用:`sudo lsof -i :56789`
- 检查数据库和 Redis 连接配置 - 检查数据库和 Redis 连接配置
- 查看日志:`sudo journalctl -u classmanager -f` - 查看日志:`sudo journalctl -u sharedclassmanager -f`
### Q2: 前端页面空白或报错 ### Q2: 前端页面空白或报错
- 检查 Nginx 配置中的 root 路径 - 检查 Nginx 配置中的 root 路径
- 检查PHP-FPM是否运行 - 检查 PHP-FPM 是否运行`sudo systemctl status php8.0-fpm`
- 检查文件权限:`sudo chown -R www-data:www-data /var/www/classmanager` - 检查文件权限:`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: 数据库连接失败 ### 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 - 开发者: Canglan
- 联系方式: admin@sea-studio.top - 联系方式: admin@sea-studio.top
- 版权归属: Sea Network Technology Studio - 版权归属: Sea Network Technology Studio
- 许可证: MIT License - 许可证: Apache License 2.0

212
LICENSE
View File

@@ -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 TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
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:
The above copyright notice and this permission notice shall be included in all 1. Definitions.
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR "License" shall mean the terms and conditions for use, reproduction,
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, and distribution as defined by Sections 1 through 9 of this document.
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER "Licensor" shall mean the copyright owner or entity authorized by
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, the copyright owner that is granting the License.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. "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.

430
README.md
View File

@@ -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 - 学生管理:新增/编辑/删除学生、批量导入学生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 | | 操行分管理 | ✓ 无限制 | ±5分 | ±5分 | ±5分 | 仅扣分 | ±1分 | 仅加分 | - |
| 缓存 | Redis | 7.x | | 历史记录 | 全部(可撤销) | 自己的 | 全部(可撤销) | 自己的 | 自己的 | 自己的 | 自己的 | - |
| 前端 | PHP | 8.0 | | 作业管理 | ✓ | 所教科目 | - | ✓ | - | - | - | 所教科目 |
| Web服务器 | Nginx | 1.28+ | | 考勤管理 | ✓ | - | - | - | ✓ | - | - | - |
## 文件结构 | 科目管理 | ✓ | - | - | ✓ | - | - | - | - |
| 学生管理 | ✓ | - | - | - | - | - | - | - |
| 管理员管理 | ✓ | - | - | - | - | - | - | - |
| 学期管理 | ✓ | - | - | - | - | - | - | - |
| 班级设置 | ✓ | - | - | - | - | - | - | - |
| 排行榜 | ✓ | - | - | - | - | - | - | - |
> 加减分上下限可在班级设置中由班主任自行配置。
## 多班级隔离机制
``` ```
classmanager/ 系统管理员 (super_admin)
├── JWT 中 class_id 可变(通过 /api/class/switch 切换)
├── 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 # 项目说明
班级管理员 (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) 排行榜支持百分比筛选(如显示前 10% 的学生)。
- 家长端详见 [docs/parent.md](docs/parent.md)
- 班主任详见 [docs/teacher.md](docs/teacher.md)
- 班干部详见 [docs/cadre.md](docs/cadre.md)
快速使用指南: ## 超级管理员独立登录
- [学生端](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`
## 版本 ## 家长登录账号
| 版本 | 发布日期 | 说明 | 学生导入时,`parent_account` 字段为家长登录账号,**推荐填写手机号**。系统会自动为该账号创建家长用户,初始密码与学生相同(默认 `123456`)。
|------|---------|------|
| v1.0 | 2026.4.19 | 初始版本发布,包含基础功能 | 示例导入 JSON 格式:
| v1.1 | 2026.4.20 | 更新家长端查看加减分记录功能 | ```json
| v1.2 | 2026.4.22 | 学期管理、env配置加减分上限、排行榜百分比筛选、撤销操作日志、调试入口开关 | {
| v1.3 | 2026.4.27 | 考勤时段系统(早上/中午/晚修三时段)、历史记录扣分类型筛选、管理员/科目信息编辑、全链路输入安全校验 | "students": [
| v1.4 | 2026.4.28 | 全量代码审查修复双重密码哈希bug、学生端XSS漏洞、性能优化、Pydantic schema统一、权限检查补全、考勤委员撤销权限 | {
| v1.5 | 2026.4.29 | 登录错误封禁5分钟+手动解锁、加减分回显修复、权限限制修复、按钮样式补全 | "student_no": "2025001",
| v1.6 | 2026.4.29 | 权限修复:考勤委员提示遗漏、历史记录权限泄露、时间筛选失效、作业页分数限制与后端同步 | "name": "张三",
| v1.7 | 2026.5.21 | 全量一致性审计:前后端配置统一(.env.example/config.py/config.php、清理废弃全局变量、角色权限表精确化 | "parent_account": "13800138001",
| v1.8 | 2026.5.22 | 科目管理融入作业管理页、科目删除数据依赖检查、加减分记录类型区分manual/homework/attendance、学生端作业详情优化 | "dormitory_number": "A301",
| v2.0.1 | 2026.5.23 | 操作列折叠优化、扣分类型大类区分、科目选择修复、改名作业扣分、记录人优化、家长端优化、学期管理优化 | "password": "123456"
| 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 转义修复 |
详细部署指南请参阅 [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) 许可证 本项目用 [Apache License 2.0](LICENSE) 许可证
Copyright 2025 Sea Network Technology Studio
## 开发者
Canglan — admin@sea-studio.top

View File

@@ -1 +1 @@
2.7 1.0

67
backend-go/.env.example Normal file
View File

@@ -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

63
backend-go/Makefile Normal file
View File

@@ -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"

View File

@@ -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("服务已安全停止")
}

13
backend-go/go.mod Normal file
View File

@@ -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
)

View File

@@ -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
}

View File

@@ -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, "操作成功")
}

View File

@@ -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
}

View File

@@ -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, "操作成功")
}

View File

@@ -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, "保存成功")
}

View File

@@ -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, "操作成功")
}

View File

@@ -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
}

View File

@@ -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, "密码修改成功")
}

View File

@@ -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, "操作成功")
}

View File

@@ -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, "操作成功")
}

View File

@@ -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, "科目已禁用")
}
}

View File

@@ -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, "登录成功")
}

View File

@@ -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,
)
}
}

View File

@@ -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
}
// 检查 roleadmin_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 ""
}

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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 "/"
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"),
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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, ""
}

View File

@@ -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
}

View File

@@ -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()
}

93
backend-go/pkg/jwt/jwt.go Normal file
View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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,
}, "操作成功")
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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
)

View File

@@ -1,11 +0,0 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================

View File

@@ -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
)

View File

@@ -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

View File

@@ -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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;'
}
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

View File

@@ -1,11 +0,0 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -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,))

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,11 +0,0 @@
# ===========================================
# 班级操行分管理系统 - 后端服务
#
# 开发者: Canglan
# 联系方式: admin@sea-studio.top
# 版权归属: Sea Network Technology Studio
# 许可证: MIT License
#
# 版权所有 © Sea Network Technology Studio
# ===========================================

View File

@@ -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} 的登录锁定")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"])

View File

@@ -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)

Some files were not shown because too many files have changed in this diff Show More