feat: 多班级版 v2.0 - Go后端重写 + 43轮代码审查
- 后端从 Python FastAPI 重写为 Go Gin(端口 56789) - 多班级完全隔离 - 超级管理员独立登录 - 课代表作业管理、排行榜分项排行 - 角色加减分上下限可配置 - 家长改密功能(可开关) - 周度/月度重置功能 - MySQL 5.7 兼容 - 43轮代码审查+全部修复 - Apache 2.0 许可证
This commit is contained in:
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,9 +1,14 @@
|
||||
# 环境变量
|
||||
.env
|
||||
backend/.env
|
||||
backend-go/.env
|
||||
frontend/.env
|
||||
|
||||
# Python
|
||||
# Go
|
||||
backend-go/sharedclassmanager
|
||||
backend-go/sharedclassmanager.exe
|
||||
backend-go/logs/
|
||||
|
||||
# Python(旧后端残留)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
@@ -44,6 +49,9 @@ Thumbs.db
|
||||
|
||||
# CoStrict
|
||||
.cospec/
|
||||
plans/
|
||||
.roo/
|
||||
code-review_result/
|
||||
|
||||
# PDF
|
||||
docs/guide/cadre.pdf
|
||||
@@ -53,4 +61,4 @@ docs/guide/teacher.pdf
|
||||
qrcode.png
|
||||
|
||||
# example
|
||||
example
|
||||
example/
|
||||
|
||||
376
INSTALL.md
376
INSTALL.md
@@ -1,4 +1,4 @@
|
||||
# 班级操行分管理系统 - 安装部署指南
|
||||
# 多班级版班级管理系统 - 安装部署指南
|
||||
|
||||
## 环境要求
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
### 软件依赖
|
||||
| 软件 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| Python | 3.9+ | 后端运行环境 |
|
||||
| Go | 1.21+ | 后端运行环境 |
|
||||
| MySQL | 5.7+ | 数据存储 |
|
||||
| Redis | 6.0+ | 缓存、会话 |
|
||||
| Nginx | 1.18+ | Web服务器、反向代理 |
|
||||
@@ -36,150 +36,174 @@ url=https://download.bt.cn/install/installStable.sh;if [ -f /usr/bin/curl ];then
|
||||
|
||||
| 软件名称 | 版本要求 | 用途 |
|
||||
|---------|---------|------|
|
||||
| Nginx | 1.21+ | Web服务器 |
|
||||
| Nginx | 1.18+ | Web服务器 |
|
||||
| MySQL | 5.7+ | 数据库 |
|
||||
| Redis | 6.0+ | 缓存服务 |
|
||||
| PHP | 8.0+ | 前端处理 |
|
||||
| Python项目管理器 | 最新版 | 后端部署 |
|
||||
|
||||
### 3. 创建数据库
|
||||
### 3. 安装 Go 环境
|
||||
|
||||
在服务器上安装 Go 1.21+:
|
||||
|
||||
```bash
|
||||
# 下载 Go(以 1.21.0 为例,请替换为最新稳定版)
|
||||
wget https://go.dev/dl/go1.21.0.linux-amd64.tar.gz
|
||||
|
||||
# 解压到 /usr/local
|
||||
sudo tar -C /usr/local -xzf go1.21.0.linux-amd64.tar.gz
|
||||
|
||||
# 配置环境变量
|
||||
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# 验证安装
|
||||
go version
|
||||
```
|
||||
|
||||
### 4. 创建数据库
|
||||
|
||||
在宝塔面板中:
|
||||
1. 进入"数据库"菜单
|
||||
2. 点击"添加数据库"
|
||||
3. 填写数据库信息:
|
||||
- 数据库名:`class_manager`
|
||||
- 用户名:`class_user`
|
||||
- 数据库名:`classmanagerdb`
|
||||
- 用户名:`class_admin`
|
||||
- 密码:生成强密码并保存
|
||||
4. 点击"导入",选择 `sql/init.sql` 文件导入
|
||||
|
||||
### 4. 部署后端服务
|
||||
### 5. 部署 Go 后端
|
||||
|
||||
#### 4.1 上传代码
|
||||
#### 5.1 上传代码
|
||||
|
||||
1. 进入宝塔面板"文件"菜单
|
||||
2. 进入 `/www/wwwroot/` 目录
|
||||
3. 创建项目目录 `classmanager`
|
||||
4. 上传或克隆代码到 `/www/wwwroot/classmanager`
|
||||
|
||||
#### 4.2 使用Python项目管理器部署
|
||||
|
||||
1. 进入宝塔面板"网站 -> Python项目"
|
||||
2. 点击"添加项目":
|
||||
- 项目路径:`/www/wwwroot/classmanager/backend`
|
||||
- Python版本:3.9+
|
||||
- 框架:FastAPI
|
||||
- 启动方式:`uvicorn main:app --host 127.0.0.1 --port 8000 --workers 4`
|
||||
- 项目名称:`classmanager_backend`
|
||||
|
||||
#### 4.3 配置环境变量
|
||||
|
||||
在 `/www/wwwroot/classmanager/backend/` 目录下:
|
||||
3. 上传或克隆代码到 `/www/wwwroot/SharedClassManager`
|
||||
|
||||
```bash
|
||||
# 复制环境变量示例文件
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑配置
|
||||
vim .env
|
||||
git clone https://hz-gitea.sea-studio.top/canglan/SharedClassManager.git /www/wwwroot/SharedClassManager
|
||||
```
|
||||
|
||||
根据实际环境修改以下配置:
|
||||
#### 5.2 配置环境变量
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/SharedClassManager/backend-go
|
||||
cp .env.example .env
|
||||
vim .env # 根据实际环境修改配置
|
||||
```
|
||||
|
||||
**必须修改的配置项**:
|
||||
- `DB_USER` - 数据库用户名
|
||||
- `DB_PASSWORD` - 数据库密码
|
||||
- `REDIS_PASSWORD` - Redis密码(如有)
|
||||
- `SECRET_KEY` - 应用密钥(32位以上随机字符串)
|
||||
- `JWT_SECRET_KEY` - JWT密钥(32位以上随机字符串)
|
||||
- `DEBUG_PATH` - 调试入口路径(生产环境请修改为随机字符串)
|
||||
- `PASSWORD_SALT` - 密码加密盐值
|
||||
|
||||
### 5. 部署前端
|
||||
#### 5.3 编译并运行
|
||||
|
||||
#### 5.1 上传前端代码
|
||||
```bash
|
||||
cd /www/wwwroot/SharedClassManager/backend-go
|
||||
go mod tidy
|
||||
go build -o sharedclassmanager ./cmd/server
|
||||
```
|
||||
|
||||
将代码上传或克隆到 `/www/wwwroot/classmanager`
|
||||
#### 5.4 使用 Systemd 管理服务
|
||||
|
||||
#### 5.2 创建网站
|
||||
创建 systemd 服务文件:
|
||||
|
||||
```bash
|
||||
sudo vim /etc/systemd/system/sharedclassmanager.service
|
||||
```
|
||||
|
||||
写入以下内容:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=SharedClassManager Go Backend
|
||||
After=network.target mysql.service redis.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/www/wwwroot/SharedClassManager/backend-go
|
||||
ExecStart=/www/wwwroot/SharedClassManager/backend-go/sharedclassmanager
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
启动服务:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl start sharedclassmanager
|
||||
sudo systemctl enable sharedclassmanager
|
||||
```
|
||||
|
||||
> 也可使用宝塔面板的"Python项目"管理器管理 Go 进程,将启动命令指向编译后的二进制文件即可。
|
||||
|
||||
### 6. 部署前端
|
||||
|
||||
#### 6.1 创建网站
|
||||
|
||||
1. 进入宝塔面板"网站"菜单
|
||||
2. 点击"添加站点":
|
||||
- 域名:填写您的域名
|
||||
- 根目录:`/www/wwwroot/classmanager/frontend`
|
||||
- 根目录:`/www/wwwroot/SharedClassManager/frontend`
|
||||
- PHP版本:8.0
|
||||
|
||||
#### 5.3 配置伪静态
|
||||
#### 6.2 配置 Nginx 反向代理
|
||||
|
||||
在站点设置中:
|
||||
1. 点击"伪静态"
|
||||
2. 选择"thinkphp"或添加以下规则:
|
||||
在站点设置中,点击"配置文件",替换为以下内容:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
root /www/wwwroot/SharedClassManager/frontend;
|
||||
index index.php;
|
||||
|
||||
# PHP 处理
|
||||
location / {
|
||||
if (!-e $request_filename){
|
||||
rewrite ^(.*)$ /index.php?s=$1 last;
|
||||
break;
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
include snippets/fastcgi-php.conf;
|
||||
fastcgi_pass unix:/var/run/php/php8.0-fpm.sock;
|
||||
}
|
||||
|
||||
# Go API 反向代理
|
||||
# 前后端通过 Nginx 反代同域通信,无需 CORS
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:56789/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**部署方式二:一体化部署(同域名)**
|
||||
|
||||
如果希望前后端使用同一个域名(如 `https://your-domain.com`),需要配置反向代理:
|
||||
|
||||
在站点设置中:
|
||||
1. 点击"反向代理"
|
||||
2. 添加反向代理:
|
||||
- 目标URL:`http://127.0.0.1:8000`
|
||||
- 发送域名:`$host`
|
||||
- 代理目录:`/api/`
|
||||
3. 前端 `.env` 配置:
|
||||
```
|
||||
API_BASE_URL=https://your-domain.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 5.4 配置伪静态(续)
|
||||
|
||||
在站点设置中:
|
||||
1. 点击"伪静态"
|
||||
2. 选择"thinkphp"或添加以下规则:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
if (!-e $request_filename){
|
||||
rewrite ^(.*)$ /index.php?s=$1 last;
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 配置SSL证书
|
||||
### 7. 配置 SSL 证书
|
||||
|
||||
1. 在站点设置中点击"SSL"
|
||||
2. 选择"Let's Encrypt"免费证书
|
||||
3. 勾选"强制HTTPS"
|
||||
|
||||
### 7. 初始化管理员账号
|
||||
### 8. 初始化系统管理员
|
||||
|
||||
使用调试接口创建初始管理员(仅首次部署使用):
|
||||
Go 后端首次启动时会**自动创建**超级管理员账号,登录信息从环境变量读取:
|
||||
|
||||
```bash
|
||||
# 替换 your-domain.com 为您的域名
|
||||
# 替换 /a7k9x2m4q8w1e3r5t6y7u8i9o0p1z2x3 为 .env 中配置的 DEBUG_PATH
|
||||
- **登录路径**:由 `SUPER_ADMIN_LOGIN_PATH` 配置(默认 `/super-admin/login`)
|
||||
- **默认用户名**:由 `SUPER_ADMIN_DEFAULT_USERNAME` 配置(默认 `admin`)
|
||||
- **默认密码**:由 `SUPER_ADMIN_DEFAULT_PASSWORD` 配置(默认 `Admin123`)
|
||||
|
||||
curl -X POST https://your-domain.com/a7k9x2m4q8w1e3r5t6y7u8i9o0p1z2x3 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "admin",
|
||||
"password": "Admin@123",
|
||||
"real_name": "班主任",
|
||||
"role_type": "班主任"
|
||||
}'
|
||||
```
|
||||
|
||||
**注意**:创建成功后,请立即登录系统修改密码,并在生产环境中禁用或修改 DEBUG_PATH。
|
||||
> **注意**:首次登录后请立即修改密码。
|
||||
|
||||
---
|
||||
|
||||
@@ -190,10 +214,10 @@ curl -X POST https://your-domain.com/a7k9x2m4q8w1e3r5t6y7u8i9o0p1z2x3 \
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt update
|
||||
sudo apt install -y python3 python3-pip python3-venv mysql-server redis-server nginx php8.0 php8.0-fpm php8.0-mysql
|
||||
sudo apt install -y golang-go mysql-server redis-server nginx php8.0 php8.0-fpm php8.0-mysql
|
||||
|
||||
# CentOS
|
||||
sudo yum install -y python3 python3-pip mysql-server redis nginx php php-fpm php-mysql
|
||||
sudo yum install -y golang mysql-server redis nginx php php-fpm php-mysql
|
||||
```
|
||||
|
||||
### 2. 数据库配置
|
||||
@@ -208,54 +232,53 @@ mysql -u root -p
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE DATABASE class_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'class_user'@'localhost' IDENTIFIED BY 'YourStrongPassword';
|
||||
GRANT ALL PRIVILEGES ON class_manager.* TO 'class_user'@'localhost';
|
||||
CREATE DATABASE classmanagerdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'class_admin'@'localhost' IDENTIFIED BY 'YourStrongPassword';
|
||||
GRANT ALL PRIVILEGES ON classmanagerdb.* TO 'class_admin'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
EXIT;
|
||||
```
|
||||
|
||||
导入初始化数据:
|
||||
```bash
|
||||
mysql -u class_user -p class_manager < sql/init.sql
|
||||
mysql -u class_admin -p classmanagerdb < sql/init.sql
|
||||
```
|
||||
|
||||
### 3. 后端部署
|
||||
### 3. Go 后端部署
|
||||
|
||||
```bash
|
||||
# 创建项目目录
|
||||
sudo mkdir -p /var/www/classmanager
|
||||
sudo chown -R $USER:$USER /var/www/classmanager
|
||||
sudo mkdir -p /www/wwwroot/SharedClassManager
|
||||
sudo chown -R $USER:$USER /www/wwwroot/SharedClassManager
|
||||
|
||||
# 上传代码到 /var/www/classmanager/backend/
|
||||
cd /var/www/classmanager/backend
|
||||
|
||||
# 创建虚拟环境
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
# 上传代码
|
||||
cd /www/wwwroot/SharedClassManager/backend-go
|
||||
|
||||
# 配置环境变量
|
||||
cp .env.example .env
|
||||
vim .env # 根据实际情况修改配置
|
||||
|
||||
# 编译
|
||||
go mod tidy
|
||||
go build -o sharedclassmanager ./cmd/server
|
||||
|
||||
# 使用 Systemd 管理服务
|
||||
sudo vim /etc/systemd/system/classmanager.service
|
||||
sudo vim /etc/systemd/system/sharedclassmanager.service
|
||||
```
|
||||
|
||||
Systemd 服务文件内容:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=ClassManager Backend
|
||||
After=network.target
|
||||
Description=SharedClassManager Go Backend
|
||||
After=network.target mysql.service redis.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/var/www/classmanager/backend
|
||||
Environment="PATH=/var/www/classmanager/backend/venv/bin"
|
||||
ExecStart=/var/www/classmanager/backend/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000
|
||||
WorkingDirectory=/www/wwwroot/SharedClassManager/backend-go
|
||||
ExecStart=/www/wwwroot/SharedClassManager/backend-go/sharedclassmanager
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -264,26 +287,21 @@ WantedBy=multi-user.target
|
||||
启动服务:
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl start classmanager
|
||||
sudo systemctl enable classmanager
|
||||
sudo systemctl start sharedclassmanager
|
||||
sudo systemctl enable sharedclassmanager
|
||||
```
|
||||
|
||||
### 4. 前端部署
|
||||
|
||||
```bash
|
||||
# 上传前端代码到 /var/www/classmanager/frontend/
|
||||
# 配置Nginx
|
||||
sudo vim /etc/nginx/sites-available/classmanager
|
||||
```
|
||||
|
||||
Nginx 配置示例:
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
root /var/www/classmanager/frontend;
|
||||
root /www/wwwroot/SharedClassManager/frontend;
|
||||
index index.php;
|
||||
|
||||
# PHP 处理
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
@@ -293,17 +311,20 @@ server {
|
||||
fastcgi_pass unix:/var/run/php/php8.0-fpm.sock;
|
||||
}
|
||||
|
||||
# Go API 反向代理
|
||||
# 前后端通过 Nginx 反代同域通信,无需 CORS
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8000/;
|
||||
proxy_pass http://127.0.0.1:56789/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
启用站点:
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/classmanager /etc/nginx/sites-enabled/
|
||||
sudo ln -s /etc/nginx/sites-available/sharedclassmanager /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
@@ -312,51 +333,118 @@ sudo systemctl restart nginx
|
||||
|
||||
## 环境变量说明
|
||||
|
||||
后端 `.env` 文件主要配置项:
|
||||
Go 后端 `.env` 文件全部配置项(参考 `backend-go/.env.example`):
|
||||
|
||||
### 应用配置
|
||||
| 配置项 | 说明 | 示例 |
|
||||
|-------|------|------|
|
||||
| `APP_NAME` | 应用名称 | 班级操行分管理系统 |
|
||||
| `APP_NAME` | 应用名称 | 多班级版班级管理系统 |
|
||||
| `APP_ENV` | 运行环境 | production / development |
|
||||
| `DEBUG` | 调试模式 | false(生产环境) |
|
||||
| `SECRET_KEY` | 应用密钥 | 32位以上随机字符串 |
|
||||
| `APP_PORT` | 服务端口 | 56789 |
|
||||
|
||||
### MySQL 数据库
|
||||
| 配置项 | 说明 | 示例 |
|
||||
|-------|------|------|
|
||||
| `DB_HOST` | 数据库地址 | localhost |
|
||||
| `DB_USER` | 数据库用户名 | class_user |
|
||||
| `DB_PORT` | 数据库端口 | 3306 |
|
||||
| `DB_USER` | 数据库用户名 | class_admin |
|
||||
| `DB_PASSWORD` | 数据库密码 | YourPassword |
|
||||
| `DB_NAME` | 数据库名 | class_manager |
|
||||
| `DB_NAME` | 数据库名 | classmanagerdb |
|
||||
| `DB_MAX_OPEN_CONNS` | 最大打开连接数 | 25 |
|
||||
| `DB_MAX_IDLE_CONNS` | 最大空闲连接数 | 10 |
|
||||
| `DB_CONN_MAX_LIFETIME` | 连接最大生命周期(秒) | 300 |
|
||||
|
||||
### Redis 缓存
|
||||
| 配置项 | 说明 | 示例 |
|
||||
|-------|------|------|
|
||||
| `REDIS_HOST` | Redis地址 | localhost |
|
||||
| `REDIS_PORT` | Redis端口 | 6379 |
|
||||
| `REDIS_PASSWORD` | Redis密码 | 可选 |
|
||||
| `REDIS_DB` | Redis数据库编号 | 0 |
|
||||
| `REDIS_MAX_CONNECTIONS` | 最大连接数 | 500 |
|
||||
|
||||
### JWT 认证
|
||||
| 配置项 | 说明 | 示例 |
|
||||
|-------|------|------|
|
||||
| `JWT_SECRET_KEY` | JWT密钥 | 32位以上随机字符串 |
|
||||
| `DEBUG_PATH` | 调试入口路径 | /random-string |
|
||||
| `DEDUCTION_HOMEWORK_NOT_SUBMIT` | 作业未提交扣分 | 2 |
|
||||
| `DEDUCTION_HOMEWORK_LATE` | 作业迟交扣分 | 1 |
|
||||
| `DEDUCTION_ATTENDANCE_ABSENT` | 缺勤扣分 | 5 |
|
||||
| `DEDUCTION_ATTENDANCE_LATE` | 迟到扣分 | 2 |
|
||||
| `DEDUCTION_ATTENDANCE_LEAVE` | 请假扣分 | 1 |
|
||||
| `JWT_ALGORITHM` | JWT算法 | HS256 |
|
||||
| `JWT_EXPIRE_MINUTES` | Token过期时间(分钟) | 60 |
|
||||
| `JWT_IDLE_TIMEOUT_MINUTES` | 空闲超时时间(分钟) | 10 |
|
||||
|
||||
### 密码加密
|
||||
| 配置项 | 说明 | 示例 |
|
||||
|-------|------|------|
|
||||
| `PASSWORD_SALT` | 密码加密盐值 | your-fixed-salt-string |
|
||||
|
||||
### 系统管理员
|
||||
| 配置项 | 说明 | 示例 |
|
||||
|-------|------|------|
|
||||
| `SUPER_ADMIN_LOGIN_PATH` | 超级管理员登录路径 | /super-admin/login |
|
||||
| `SUPER_ADMIN_DEFAULT_USERNAME` | 默认用户名 | admin |
|
||||
| `SUPER_ADMIN_DEFAULT_PASSWORD` | 默认密码 | Admin123 |
|
||||
|
||||
### 日志配置
|
||||
| 配置项 | 说明 | 示例 |
|
||||
|-------|------|------|
|
||||
| `LOG_LEVEL` | 日志级别 | info |
|
||||
| `LOG_FILE` | 日志文件路径 | logs/app.log |
|
||||
|
||||
> **注意**:扣分规则等班级级配置已迁移到数据库 `class_settings` 表中,班主任可在管理端"班级设置"页面修改,无需修改环境变量。
|
||||
|
||||
---
|
||||
|
||||
## 初始化系统管理员
|
||||
|
||||
Go 后端首次启动时会**自动创建**超级管理员账号,无需手动操作:
|
||||
|
||||
1. 确认 `.env` 中以下配置项已正确设置:
|
||||
- `SUPER_ADMIN_LOGIN_PATH` — 登录页面路径
|
||||
- `SUPER_ADMIN_DEFAULT_USERNAME` — 默认用户名
|
||||
- `SUPER_ADMIN_DEFAULT_PASSWORD` — 默认密码
|
||||
2. 启动 Go 后端服务
|
||||
3. 访问 `https://your-domain.com/{SUPER_ADMIN_LOGIN_PATH}` 登录
|
||||
4. 首次登录后请**立即修改密码**
|
||||
5. 创建班级,然后为班级指定班主任
|
||||
|
||||
---
|
||||
|
||||
## 多班级使用流程
|
||||
|
||||
1. 系统管理员登录 → 创建班级
|
||||
2. 为班级添加班主任(管理员管理)
|
||||
3. 班主任登录 → 导入学生 → 开始使用
|
||||
4. 班主任可在"班级设置"中自定义本班扣分规则和功能开关
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 后端启动失败
|
||||
- 检查端口8000是否被占用
|
||||
- 检查端口 56789 是否被占用:`sudo lsof -i :56789`
|
||||
- 检查数据库和 Redis 连接配置
|
||||
- 查看日志:`sudo journalctl -u classmanager -f`
|
||||
- 查看日志:`sudo journalctl -u sharedclassmanager -f`
|
||||
|
||||
### Q2: 前端页面空白或报错
|
||||
- 检查 Nginx 配置中的 root 路径
|
||||
- 检查PHP-FPM是否运行
|
||||
- 检查文件权限:`sudo chown -R www-data:www-data /var/www/classmanager`
|
||||
- 检查 PHP-FPM 是否运行:`sudo systemctl status php8.0-fpm`
|
||||
- 检查文件权限:`sudo chown -R www-data:www-data /www/wwwroot/SharedClassManager`
|
||||
|
||||
### Q3: API 请求 404
|
||||
- 检查反向代理配置
|
||||
- 确认后端服务已启动
|
||||
- 检查反向代理配置是否正确(`/api/` → `127.0.0.1:56789`)
|
||||
- 确认 Go 后端服务已启动:`sudo systemctl status sharedclassmanager`
|
||||
- 检查防火墙设置
|
||||
|
||||
### Q4: 数据库连接失败
|
||||
- 确认 MySQL 已启动
|
||||
- 检查用户名密码
|
||||
- 检查 `.env` 中的数据库用户名、密码、数据库名
|
||||
- 确认用户有数据库权限
|
||||
|
||||
### Q5: Go 编译失败
|
||||
- 确认 Go 版本 >= 1.21:`go version`
|
||||
- 执行 `go mod tidy` 拉取依赖
|
||||
- 检查网络连接(可能需要配置 Go 代理:`go env -w GOPROXY=https://goproxy.cn,direct`)
|
||||
|
||||
---
|
||||
|
||||
## 技术支持
|
||||
@@ -364,4 +452,4 @@ sudo systemctl restart nginx
|
||||
- 开发者: Canglan
|
||||
- 联系方式: admin@sea-studio.top
|
||||
- 版权归属: Sea Network Technology Studio
|
||||
- 许可证: MIT License
|
||||
- 许可证: Apache License 2.0
|
||||
|
||||
212
LICENSE
212
LICENSE
@@ -1,21 +1,199 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 CangLan
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
1. Definitions.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work.
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. Please also get an
|
||||
"Alarm or alarm" from your own alarm vendor.
|
||||
|
||||
Copyright 2025 Sea Network Technology Studio
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
430
README.md
430
README.md
@@ -1,285 +1,233 @@
|
||||
# 班级操行分管理系统
|
||||
# 多班级版班级管理系统 v1.0
|
||||
|
||||
基于 Python FastAPI 开发的班级操行分管理系统,支持学生端、管理端、家长端三端访问,实现操行分管理、作业提交跟踪、考勤记录等功能。
|
||||
基于 Go (Gin + GORM) + PHP + MySQL 5.7 + Redis 开发的多班级操行分管理系统,支持多班级完全隔离,包含系统管理员、学生、管理端、家长端四端访问。
|
||||
|
||||
## 主要功能
|
||||
## 技术栈
|
||||
|
||||
### 学生端
|
||||
- 查询个人当前操行总分
|
||||
- 查看个人加减分历史明细(时间、分数变化、原因、操作人)
|
||||
- 查看个人作业提交情况
|
||||
- 查看个人考勤记录
|
||||
- 查看历史学期归档数据(操行分、考勤统计、作业统计)
|
||||
- 修改个人登录密码(首次登录强制修改)
|
||||
| 层级 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| 后端 API | **Go (Gin + GORM)** | 高性能、编译型、并发友好,适合中小规模管理系统 |
|
||||
| 数据库 | **MySQL 5.7+** | 成熟稳定、事务支持完善、utf8mb4 全字符集支持 |
|
||||
| 缓存 | **Redis 6.0+** | Token 管理、登录限流、会话缓存 |
|
||||
| 前端 | **PHP 8.0+ + JavaScript** | 零构建依赖、部署简单、与后端 API 解耦,适合校园网络环境 |
|
||||
|
||||
### 家长端
|
||||
- 查询子女当前操行总分和班级排名
|
||||
- 查看子女操行分历史记录(加分/减分明细)
|
||||
- 查看子女考勤记录
|
||||
- 默认仅显示当前学期数据
|
||||
## 功能特性
|
||||
|
||||
### 管理端
|
||||
### 系统管理员(super_admin)
|
||||
- 独立登录入口(路径可配置)
|
||||
- 班级管理:创建/编辑/删除/启用禁用班级
|
||||
- 切换班级上下文:在不同班级间切换进行管理操作
|
||||
- 跨班级查看:查看所有班级的管理员和学生列表
|
||||
- 首次启动自动创建,无需手动初始化
|
||||
|
||||
### 管理端(班级内角色)
|
||||
|
||||
**班主任权限:**
|
||||
- 学生管理:新增/编辑/删除学生、批量导入学生(JSON)
|
||||
- 操行分管理:对学生进行加减分、撤销任何扣分记录、查看全班历史记录、导出德育分记录
|
||||
- 操行分管理:对学生进行加减分(无限制)、撤销任何扣分记录、查看全班历史记录
|
||||
- 作业管理:发布作业、查看提交情况
|
||||
- 考勤管理:按时段(早上 7:15/中午 14:00/晚修 19:30)记录考勤、自定义考勤扣分值
|
||||
- 考勤管理:按时段(早上/中午/晚修)记录考勤
|
||||
- 科目管理:动态增删学科
|
||||
- 管理员管理:添加/编辑/删除/重置密码班长/科代表/考勤委员/劳动委员/志愿委员
|
||||
- 学期管理:创建/编辑/删除学期、激活学期、归档学期(含考勤/作业统计快照)、关联历史记录、归档后可选重置分数
|
||||
- 排行榜百分比筛选:在排行榜上方输入百分比,筛选显示前N%的学生(抹零法)
|
||||
- 数据导出:导出历史记录、导出德育分记录(含加分/减分历史)
|
||||
- 管理员管理:添加/编辑/删除班干部、科任老师、课代表
|
||||
- 学期管理:创建/编辑/删除/激活/归档学期
|
||||
- 班级设置:修改扣分规则、功能开关、角色权限、加减分限制
|
||||
- 排行榜:查看分项排行(操行分、作业、考勤)
|
||||
- 数据导出:导出德育分记录、历史记录
|
||||
|
||||
**科任老师权限(需配置科目):**
|
||||
- 对所教科目学生进行加减分(±5分以内,可在班级设置中配置)
|
||||
- 查看所教科目的作业管理
|
||||
- 查看全班历史记录
|
||||
|
||||
**班长权限:**
|
||||
- 操行分管理:对学生进行加减分(±5分以内)、撤销任何人的扣分记录、查看全班历史记录
|
||||
- 对学生进行加减分(±5分以内,可在班级设置中配置)
|
||||
- 撤销任何操行分记录
|
||||
- 查看全班历史记录
|
||||
|
||||
**学习委员权限:**
|
||||
- 作业管理:更新作业提交状态、关联扣分(仅扣分,按规则)
|
||||
- 科目管理:动态增删学科
|
||||
- 历史记录:仅查看自己提交的操作记录
|
||||
- 对学生进行加减分(±5分以内,可在班级设置中配置)
|
||||
- 科目管理
|
||||
- 作业管理
|
||||
|
||||
**考勤委员权限:**
|
||||
- 考勤管理:按时段(早上 7:15/中午 14:00/晚修 19:30)记录考勤状态、关联扣分(仅扣分,按规则)
|
||||
- 历史记录:仅查看自己提交的操作记录
|
||||
- 考勤管理
|
||||
- 考勤扣分(仅扣分,上限8分)
|
||||
- 可撤销自己创建的记录
|
||||
|
||||
**劳动委员权限:**
|
||||
- 操行分管理:以卫生值日为理由进行加减分(固定 ±1 分)
|
||||
- 历史记录:仅查看自己提交的操作记录
|
||||
- 对学生进行加减分(±1分以内)
|
||||
|
||||
**志愿委员权限:**
|
||||
- 操行分管理:以服务时长为由进行加分(仅加分)
|
||||
- 历史记录:仅查看自己提交的操作记录
|
||||
- 仅可加分(上限5分)
|
||||
- 查看全班历史记录
|
||||
|
||||
## 技术栈
|
||||
## 安全特性
|
||||
**课代表权限:**
|
||||
- 管理所代表科目的作业(管理端页面)
|
||||
- 由学习委员/班主任/科任老师设定
|
||||
|
||||
- JWT Token + PHP Session 双轨制认证
|
||||
- Redis 管理登录态,支持空闲超时自动失效
|
||||
- 全链路输入校验:Pydantic Schema 层(正则/长度/范围约束)+ Service 层(业务逻辑校验)
|
||||
- 输入过滤中间件(XSS/SQL 注入防护)
|
||||
- 密码 bcrypt 加密存储
|
||||
- 操作日志记录
|
||||
### 学生端
|
||||
- 查询个人当前操行总分和班级排名
|
||||
- 查看个人加减分历史明细
|
||||
- 查看个人作业提交情况
|
||||
- 查看个人考勤记录
|
||||
- 查看历史学期归档数据
|
||||
- 修改个人登录密码
|
||||
|
||||
## 技术栈
|
||||
### 家长端
|
||||
- 查询子女当前操行总分和班级排名
|
||||
- 查看子女操行分历史记录
|
||||
- 查看子女考勤记录
|
||||
- 修改密码(受班级功能开关控制)
|
||||
|
||||
| 层级 | 技术 | 版本 |
|
||||
|------|------|------|
|
||||
| 后端 | Python | 3.13.x |
|
||||
| 后端框架 | FastAPI | 0.104+ |
|
||||
| 数据库 | MySQL | 5.7 |
|
||||
| 缓存 | Redis | 7.x |
|
||||
| 前端 | PHP | 8.0 |
|
||||
| Web服务器 | Nginx | 1.28+ |
|
||||
## 文件结构
|
||||
## 角色权限矩阵
|
||||
|
||||
| 功能 | 班主任 | 科任老师 | 班长 | 学习委员 | 考勤委员 | 劳动委员 | 志愿委员 | 课代表 |
|
||||
|------|--------|---------|------|---------|---------|---------|---------|--------|
|
||||
| 操行分管理 | ✓ 无限制 | ±5分 | ±5分 | ±5分 | 仅扣分 | ±1分 | 仅加分 | - |
|
||||
| 历史记录 | 全部(可撤销) | 自己的 | 全部(可撤销) | 自己的 | 自己的 | 自己的 | 自己的 | - |
|
||||
| 作业管理 | ✓ | 所教科目 | - | ✓ | - | - | - | 所教科目 |
|
||||
| 考勤管理 | ✓ | - | - | - | ✓ | - | - | - |
|
||||
| 科目管理 | ✓ | - | - | ✓ | - | - | - | - |
|
||||
| 学生管理 | ✓ | - | - | - | - | - | - | - |
|
||||
| 管理员管理 | ✓ | - | - | - | - | - | - | - |
|
||||
| 学期管理 | ✓ | - | - | - | - | - | - | - |
|
||||
| 班级设置 | ✓ | - | - | - | - | - | - | - |
|
||||
| 排行榜 | ✓ | - | - | - | - | - | - | - |
|
||||
|
||||
> 加减分上下限可在班级设置中由班主任自行配置。
|
||||
|
||||
## 多班级隔离机制
|
||||
|
||||
```
|
||||
classmanager/
|
||||
│
|
||||
├── backend/ # Python FastAPI 后端
|
||||
│ ├── .env.example # 后端环境变量示例
|
||||
│ ├── .gitignore # Git 忽略文件
|
||||
│ ├── config.py # 配置管理
|
||||
│ ├── main.py # FastAPI 主入口
|
||||
│ ├── requirements.txt # Python 依赖
|
||||
│ │
|
||||
│ ├── logs/ # 日志目录
|
||||
│ │ ├── access.log
|
||||
│ │ ├── app.log
|
||||
│ │ ├── error.log
|
||||
│ │ └── operation.log
|
||||
│ │
|
||||
│ ├── middleware/ # 中间件
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── auth_middleware.py # JWT 认证中间件
|
||||
│ │ ├── permission.py # 权限验证中间件
|
||||
│ │ └── sanitize.py # 输入过滤中间件
|
||||
│ │
|
||||
│ ├── models/ # 数据模型
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── admin_role.py # 管理员角色模型
|
||||
│ │ ├── attendance.py # 考勤模型
|
||||
│ │ ├── conduct.py # 操行分模型
|
||||
│ │ ├── homework.py # 作业模型
|
||||
│ │ ├── log.py # 日志模型
|
||||
│ │ ├── semester.py # 学期模型
|
||||
│ │ ├── student.py # 学生模型
|
||||
│ │ ├── subject.py # 科目模型
|
||||
│ │ └── user.py # 用户模型
|
||||
│ │
|
||||
│ ├── routes/ # API 路由
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── admin.py # 管理端接口
|
||||
│ │ ├── auth.py # 认证接口
|
||||
│ │ ├── debug.py # 调试入口
|
||||
│ │ ├── parent.py # 家长端接口
|
||||
│ │ ├── semester.py # 学期管理接口
|
||||
│ │ ├── student.py # 学生端接口
|
||||
│ │ └── subject.py # 科目管理接口
|
||||
│ │
|
||||
│ ├── schemas/ # Pydantic 模型
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── admin.py
|
||||
│ │ ├── auth.py
|
||||
│ │ ├── common.py
|
||||
│ │ ├── conduct.py
|
||||
│ │ ├── parent.py
|
||||
│ │ ├── semester.py # 学期请求模型
|
||||
│ │ ├── student.py
|
||||
│ │ └── subject.py
|
||||
│ │
|
||||
│ ├── services/ # 业务逻辑层
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── admin_service.py
|
||||
│ │ ├── attendance_service.py
|
||||
│ │ ├── auth_service.py
|
||||
│ │ ├── conduct_service.py
|
||||
│ │ ├── homework_service.py
|
||||
│ │ ├── log_service.py
|
||||
│ │ ├── parent_service.py
|
||||
│ │ ├── semester_service.py # 学期服务
|
||||
│ │ ├── student_service.py
|
||||
│ │ └── subject_service.py
|
||||
│ │
|
||||
│ └── utils/ # 工具类
|
||||
│ ├── __init__.py
|
||||
│ ├── database.py # MySQL 连接池
|
||||
│ ├── jwt_handler.py # JWT 处理
|
||||
│ ├── logger.py # 日志轮转
|
||||
│ ├── redis_client.py # Redis 客户端
|
||||
│ ├── response.py # 统一响应
|
||||
│ └── security.py # 密码加密
|
||||
│
|
||||
├── frontend/ # PHP 前端
|
||||
│ ├── .env.example # 前端环境变量示例
|
||||
│ ├── .htaccess # Apache 配置(可选)
|
||||
│ ├── config.php # 前端配置
|
||||
│ ├── index.php # 登录入口
|
||||
│ │
|
||||
│ ├── admin/ # 管理端
|
||||
│ │ ├── admins.php # 管理员管理
|
||||
│ │ ├── attendance.php # 考勤管理
|
||||
│ │ ├── conduct.php # 操行分管理
|
||||
│ │ ├── dashboard.php # 管理端首页
|
||||
│ │ ├── history.php # 历史记录
|
||||
│ │ ├── homework.php # 作业管理
|
||||
│ │ ├── password.php # 修改密码
|
||||
│ │ ├── semesters.php # 学期管理
|
||||
│ │ ├── students.php # 学生管理
|
||||
│ │ └── subjects.php # 科目管理
|
||||
│ │
|
||||
│ ├── api/ # 内部 API
|
||||
│ │ └── save_session.php # Session 保存接口
|
||||
│ │
|
||||
│ ├── assets/ # 静态资源
|
||||
│ │ ├── css/
|
||||
│ │ │ ├── admin.css # 管理端样式
|
||||
│ │ │ └── style.css # 全局样式
|
||||
│ │ ├── js/
|
||||
│ │ │ ├── admin.js # 管理端 JS
|
||||
│ │ │ ├── common.js # 公共 JS
|
||||
│ │ │ ├── parent.js # 家长端 JS
|
||||
│ │ │ └── student.js # 学生端 JS
|
||||
│ │ └── uploads/
|
||||
│ │ └── sample_import.json # 学生导入示例
|
||||
│ │
|
||||
│ ├── includes/ # 公共包含文件
|
||||
│ │ ├── footer.php # 公共底部
|
||||
│ │ ├── header.php # 公共头部
|
||||
│ │ └── nav.php # 导航栏
|
||||
│ │
|
||||
│ ├── parent/ # 家长端
|
||||
│ │ ├── attendance.php # 考勤记录
|
||||
│ │ ├── dashboard.php # 家长端首页
|
||||
│ │ └── history.php # 历史记录
|
||||
│ │
|
||||
│ └── student/ # 学生端
|
||||
│ ├── attendance.php # 考勤记录
|
||||
│ ├── conduct.php # 操行分详情
|
||||
│ ├── dashboard.php # 学生端首页
|
||||
│ ├── homework.php # 作业情况
|
||||
│ ├── password.php # 修改密码
|
||||
│ └── semester_history.php # 学期记录
|
||||
│
|
||||
├── sql/ # 数据库脚本
|
||||
│ └── init.sql # 初始化表结构
|
||||
│
|
||||
├── docs/ # 文档
|
||||
│ ├── student.md # 学生端详细文档
|
||||
│ ├── parent.md # 家长端详细文档
|
||||
│ ├── teacher.md # 班主任详细文档
|
||||
│ ├── cadre.md # 班干部详细文档
|
||||
│ └── guide/ # 快速使用说明
|
||||
│ ├── student.md
|
||||
│ ├── parent.md
|
||||
│ ├── teacher.md
|
||||
│ └── cadre.md
|
||||
│
|
||||
├── .gitignore
|
||||
├── INSTALL.md # 安装部署文档
|
||||
├── LICENSE # MIT 许可证
|
||||
└── README.md # 项目说明
|
||||
系统管理员 (super_admin)
|
||||
├── JWT 中 class_id 可变(通过 /api/class/switch 切换)
|
||||
├── 可管理所有班级
|
||||
└── 权限检查自动放行
|
||||
|
||||
班级管理员 (admin) — 班主任/班长/科任老师/课代表等
|
||||
├── admin_roles 绑定 class_id
|
||||
├── JWT 中 class_id 固定
|
||||
├── 所有查询自动过滤 class_id
|
||||
└── 严格隔离在本班内
|
||||
|
||||
学生/家长
|
||||
├── 通过 student.class_id 确定所属班级
|
||||
└── 只能看到本班数据
|
||||
```
|
||||
|
||||
## 角色权限一览表
|
||||
## 班级设置
|
||||
|
||||
| 角色 | 操行分查看 | 操行分加减 | 撤销扣分 | 历史记录查看 | 其他权限 |
|
||||
|------|-----------|-----------|---------|-------------|---------|
|
||||
| 班主任 | 全班 | 无限制 | 可撤销任何记录 | 全班所有记录 | 学生/管理员/科目管理、数据导出 |
|
||||
| 班长 | 全班 | ±5分 | 可撤销任何记录 | 全班所有记录 | - |
|
||||
| 学习委员 | 全班 | ±5分以内(加减分) | 不可撤销 | 仅自己提交的 | 作业管理、科目管理 |
|
||||
| 考勤委员 | 全班 | 仅扣分,最多扣8分 | 不可撤销 | 仅自己提交的 | 考勤管理 |
|
||||
| 劳动委员 | 全班 | ±1分以内 | 不可撤销 | 仅自己提交的 | - |
|
||||
| 志愿委员 | 全班 | 仅加分,最多+5分 | 不可撤销 | 仅自己提交的 | - |
|
||||
| 学生 | 自己 | 无 | 无 | 自己的历史 | 修改密码 |
|
||||
| 家长 | 子女总分 | 无 | 无 | 不可见详情 | - |
|
||||
每个班级可独立配置以下内容(班主任可在管理端修改):
|
||||
|
||||
## 密码要求
|
||||
### 扣分规则
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| student_initial_points | 学生初始操行分 | 60 |
|
||||
| deduction_homework_not_submit | 作业未提交扣分 | 2 |
|
||||
| deduction_homework_late | 作业迟交扣分 | 1 |
|
||||
| deduction_attendance_absent | 缺勤扣分 | 3 |
|
||||
| deduction_attendance_late | 迟到扣分 | 1 |
|
||||
| deduction_attendance_leave | 请假扣分 | 0 |
|
||||
|
||||
- 长度:6-20位
|
||||
- 复杂度:必须包含大写字母、小写字母、数字、特殊符号中的至少3种
|
||||
- 示例有效密码:`Hello1!`、`Abc123#`、`Test@99`
|
||||
### 功能开关
|
||||
| 功能标识 | 说明 | 默认 |
|
||||
|----------|------|------|
|
||||
| homework | 作业管理 | 启用 |
|
||||
| attendance | 考勤管理 | 启用 |
|
||||
| ranking | 排行榜 | 启用 |
|
||||
| dormitory | 宿舍管理 | 启用 |
|
||||
| parent_password | 家长改密功能 | 启用 |
|
||||
|
||||
## 安装部署
|
||||
### 角色开关
|
||||
班主任可在班级设置中启用或禁用各角色(班长、学习委员、考勤委员、劳动委员、志愿委员、科任老师、课代表),禁用后该角色不可被分配。
|
||||
|
||||
详见 [INSTALL.md](INSTALL.md)
|
||||
### 加减分限制
|
||||
班主任可在班级设置中配置各角色的加减分上下限,灵活控制权限范围。
|
||||
|
||||
## 使用说明
|
||||
## 排行榜分项排行
|
||||
|
||||
详细文档:
|
||||
管理端排行榜支持以下分项查看:
|
||||
- **操行分排行**:按当前操行分排名
|
||||
- **作业排行**:按作业完成情况排名
|
||||
- **考勤排行**:按出勤率排名
|
||||
|
||||
- 学生端详见 [docs/student.md](docs/student.md)
|
||||
- 家长端详见 [docs/parent.md](docs/parent.md)
|
||||
- 班主任详见 [docs/teacher.md](docs/teacher.md)
|
||||
- 班干部详见 [docs/cadre.md](docs/cadre.md)
|
||||
排行榜支持百分比筛选(如显示前 10% 的学生)。
|
||||
|
||||
快速使用指南:
|
||||
## 超级管理员独立登录
|
||||
|
||||
- [学生端](docs/guide/student.md) / [家长端](docs/guide/parent.md) / [班主任](docs/guide/teacher.md) / [班干部](docs/guide/cadre.md)
|
||||
超级管理员通过独立路径登录,与普通用户登录入口分离:
|
||||
- 登录路径通过 `.env` 中的 `SUPER_ADMIN_LOGIN_PATH` 配置
|
||||
- 默认路径:`/super-admin/login`
|
||||
- 首次启动自动创建,默认账号:`admin` / `Admin123`
|
||||
|
||||
## 版本
|
||||
## 家长登录账号
|
||||
|
||||
| 版本 | 发布日期 | 说明 |
|
||||
|------|---------|------|
|
||||
| v1.0 | 2026.4.19 | 初始版本发布,包含基础功能 |
|
||||
| v1.1 | 2026.4.20 | 更新家长端查看加减分记录功能 |
|
||||
| v1.2 | 2026.4.22 | 学期管理、env配置加减分上限、排行榜百分比筛选、撤销操作日志、调试入口开关 |
|
||||
| v1.3 | 2026.4.27 | 考勤时段系统(早上/中午/晚修三时段)、历史记录扣分类型筛选、管理员/科目信息编辑、全链路输入安全校验 |
|
||||
| v1.4 | 2026.4.28 | 全量代码审查修复:双重密码哈希bug、学生端XSS漏洞、性能优化、Pydantic schema统一、权限检查补全、考勤委员撤销权限 |
|
||||
| v1.5 | 2026.4.29 | 登录错误封禁5分钟+手动解锁、加减分回显修复、权限限制修复、按钮样式补全 |
|
||||
| v1.6 | 2026.4.29 | 权限修复:考勤委员提示遗漏、历史记录权限泄露、时间筛选失效、作业页分数限制与后端同步 |
|
||||
| v1.7 | 2026.5.21 | 全量一致性审计:前后端配置统一(.env.example/config.py/config.php)、清理废弃全局变量、角色权限表精确化 |
|
||||
| v1.8 | 2026.5.22 | 科目管理融入作业管理页、科目删除数据依赖检查、加减分记录类型区分(manual/homework/attendance)、学生端作业详情优化 |
|
||||
| v2.0.1 | 2026.5.23 | 操作列折叠优化、扣分类型大类区分、科目选择修复、改名作业扣分、记录人优化、家长端优化、学期管理优化 |
|
||||
| v2.1 | 2026.5.26 | CSS变量化统一配色方案、简化按钮系统、操作列按钮风格统一、清理内联颜色、修复科目管理面板无法展开、数据库索引优化、清理init.sql冗余迁移代码、安全审计通过 |
|
||||
| v2.2 | 2026.5.27 | 安全修复:管理员操作越权漏洞修复、新增宿舍集体加分功能、学生导入支持宿舍号、导入预览显示宿舍号列 |
|
||||
| v2.3 | 2026.5.28 | 升级系统全面重构:修复前后端通信错误处理、SQL DELIMITER解析修复、XSS防护、升级验证+自动重试+失败回滚机制、补全v1.0-v1.6增量升级脚本 |
|
||||
| v2.5 | 2026.5.28 | 历史记录页优化(文字宽度/换行/合并按钮样式)、新增状态筛选项、合并记录批量撤销/反撤销、操作菜单底部遮挡修复、删除科目报错修复、学期自动关联+当前周数计算 |
|
||||
| v2.5.1 | 2026.5.29 | 筛选学生时自动取消合并记录、合并记录选项样式修复(竖排显示)、历史记录筛选改为折叠式、科目管理调用修复、全局escapeHtml XSS转义修复 |
|
||||
| v2.6 | 2026.5.29 | 历史记录筛选面板重构(学生筛选移入面板、合并按钮始终可见)、新增科目下拉筛选、新增原因关键词搜索、科目删除后默认仅显示启用科目、筛选面板展开修复 |
|
||||
| v2.7 | 2026.5.29 | 操作菜单定位修复、历史记录显示修复、筛选面板修复、科目删除修复、学期周数列修复、escapeHtml 转义修复 |
|
||||
学生导入时,`parent_account` 字段为家长登录账号,**推荐填写手机号**。系统会自动为该账号创建家长用户,初始密码与学生相同(默认 `123456`)。
|
||||
|
||||
示例导入 JSON 格式:
|
||||
```json
|
||||
{
|
||||
"students": [
|
||||
{
|
||||
"student_no": "2025001",
|
||||
"name": "张三",
|
||||
"parent_account": "13800138001",
|
||||
"dormitory_number": "A301",
|
||||
"password": "123456"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
详细部署指南请参阅 [INSTALL.md](INSTALL.md)。
|
||||
|
||||
### 环境要求
|
||||
- Go 1.21+
|
||||
- MySQL 5.7+
|
||||
- Redis 6.0+
|
||||
- Nginx 1.18+
|
||||
- PHP 8.0+
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. 克隆项目
|
||||
```bash
|
||||
git clone https://hz-gitea.sea-studio.top/canglan/SharedClassManager.git
|
||||
cd SharedClassManager
|
||||
```
|
||||
|
||||
2. 初始化数据库
|
||||
```bash
|
||||
mysql -u root -p < sql/init.sql
|
||||
```
|
||||
|
||||
3. 配置并启动 Go 后端
|
||||
```bash
|
||||
cd backend-go
|
||||
cp .env.example .env
|
||||
vim .env # 修改配置
|
||||
go mod tidy
|
||||
go build -o sharedclassmanager ./cmd/server
|
||||
./sharedclassmanager
|
||||
```
|
||||
|
||||
4. 配置前端
|
||||
```bash
|
||||
cd frontend
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件,配置 API 地址
|
||||
```
|
||||
|
||||
5. 配置 Nginx 反向代理(参考 [INSTALL.md](INSTALL.md))
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目使用 [MIT License](LICENSE) 许可证
|
||||
本项目采用 [Apache License 2.0](LICENSE) 许可证。
|
||||
|
||||
Copyright 2025 Sea Network Technology Studio
|
||||
|
||||
## 开发者
|
||||
|
||||
Canglan — admin@sea-studio.top
|
||||
|
||||
67
backend-go/.env.example
Normal file
67
backend-go/.env.example
Normal 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
63
backend-go/Makefile
Normal 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)"
|
||||
210
backend-go/cmd/server/main.go
Normal file
210
backend-go/cmd/server/main.go
Normal 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
13
backend-go/go.mod
Normal 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
|
||||
)
|
||||
163
backend-go/internal/config/config.go
Normal file
163
backend-go/internal/config/config.go
Normal 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
|
||||
}
|
||||
602
backend-go/internal/handler/admin_handler.go
Normal file
602
backend-go/internal/handler/admin_handler.go
Normal 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, "操作成功")
|
||||
}
|
||||
131
backend-go/internal/handler/auth_handler.go
Normal file
131
backend-go/internal/handler/auth_handler.go
Normal 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
|
||||
}
|
||||
143
backend-go/internal/handler/cadre_handler.go
Normal file
143
backend-go/internal/handler/cadre_handler.go
Normal 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, "操作成功")
|
||||
}
|
||||
271
backend-go/internal/handler/class_handler.go
Normal file
271
backend-go/internal/handler/class_handler.go
Normal 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, "保存成功")
|
||||
}
|
||||
44
backend-go/internal/handler/config_handler.go
Normal file
44
backend-go/internal/handler/config_handler.go
Normal 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, "操作成功")
|
||||
}
|
||||
20
backend-go/internal/handler/handler_utils.go
Normal file
20
backend-go/internal/handler/handler_utils.go
Normal 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
|
||||
}
|
||||
115
backend-go/internal/handler/parent_handler.go
Normal file
115
backend-go/internal/handler/parent_handler.go
Normal 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, "密码修改成功")
|
||||
}
|
||||
230
backend-go/internal/handler/semester_handler.go
Normal file
230
backend-go/internal/handler/semester_handler.go
Normal 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, "操作成功")
|
||||
}
|
||||
|
||||
192
backend-go/internal/handler/student_handler.go
Normal file
192
backend-go/internal/handler/student_handler.go
Normal 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, "操作成功")
|
||||
}
|
||||
152
backend-go/internal/handler/subject_handler.go
Normal file
152
backend-go/internal/handler/subject_handler.go
Normal 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, "科目已禁用")
|
||||
}
|
||||
}
|
||||
56
backend-go/internal/handler/super_admin_handler.go
Normal file
56
backend-go/internal/handler/super_admin_handler.go
Normal 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, "登录成功")
|
||||
}
|
||||
57
backend-go/internal/middleware/access_log.go
Normal file
57
backend-go/internal/middleware/access_log.go
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
227
backend-go/internal/middleware/auth.go
Normal file
227
backend-go/internal/middleware/auth.go
Normal 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
|
||||
}
|
||||
|
||||
// 检查 role(admin_roles.role_type)
|
||||
if r, ok := role.(string); ok && roleSet[r] {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
response.Forbidden(c, "权限不足")
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserID 从上下文获取用户 ID
|
||||
func GetUserID(c *gin.Context) int {
|
||||
if v, exists := c.Get(CtxUserID); exists {
|
||||
if id, ok := v.(int); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetUsername 从上下文获取用户名
|
||||
func GetUsername(c *gin.Context) string {
|
||||
if v, exists := c.Get(CtxUsername); exists {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetUserType 从上下文获取用户类型
|
||||
func GetUserType(c *gin.Context) string {
|
||||
if v, exists := c.Get(CtxUserType); exists {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetRole 从上下文获取角色
|
||||
func GetRole(c *gin.Context) string {
|
||||
if v, exists := c.Get(CtxRole); exists {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetClassID 从上下文获取班级 ID
|
||||
func GetClassID(c *gin.Context) int {
|
||||
if v, exists := c.Get(CtxClassID); exists {
|
||||
if id, ok := v.(int); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetStudentID 从上下文获取学生 ID
|
||||
func GetStudentID(c *gin.Context) int {
|
||||
if v, exists := c.Get(CtxStudentID); exists {
|
||||
if id, ok := v.(int); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetRealName 从上下文获取真实姓名
|
||||
func GetRealName(c *gin.Context) string {
|
||||
if v, exists := c.Get(CtxRealName); exists {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
131
backend-go/internal/middleware/sanitize.go
Normal file
131
backend-go/internal/middleware/sanitize.go
Normal 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
|
||||
}
|
||||
36
backend-go/internal/model/admin_role.go
Normal file
36
backend-go/internal/model/admin_role.go
Normal 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"
|
||||
}
|
||||
53
backend-go/internal/model/assignment.go
Normal file
53
backend-go/internal/model/assignment.go
Normal 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"
|
||||
}
|
||||
38
backend-go/internal/model/attendance.go
Normal file
38
backend-go/internal/model/attendance.go
Normal 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"
|
||||
}
|
||||
60
backend-go/internal/model/class_model.go
Normal file
60
backend-go/internal/model/class_model.go
Normal 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"
|
||||
}
|
||||
44
backend-go/internal/model/conduct.go
Normal file
44
backend-go/internal/model/conduct.go
Normal 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"
|
||||
}
|
||||
50
backend-go/internal/model/log.go
Normal file
50
backend-go/internal/model/log.go
Normal 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"
|
||||
}
|
||||
88
backend-go/internal/model/semester.go
Normal file
88
backend-go/internal/model/semester.go
Normal 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"
|
||||
}
|
||||
37
backend-go/internal/model/student.go
Normal file
37
backend-go/internal/model/student.go
Normal 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"
|
||||
}
|
||||
29
backend-go/internal/model/subject.go
Normal file
29
backend-go/internal/model/subject.go
Normal 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"
|
||||
}
|
||||
32
backend-go/internal/model/super_admin.go
Normal file
32
backend-go/internal/model/super_admin.go
Normal 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"
|
||||
}
|
||||
26
backend-go/internal/model/system_setting.go
Normal file
26
backend-go/internal/model/system_setting.go
Normal 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"
|
||||
}
|
||||
34
backend-go/internal/model/user.go
Normal file
34
backend-go/internal/model/user.go
Normal 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"
|
||||
}
|
||||
112
backend-go/internal/repository/admin_role_repo.go
Normal file
112
backend-go/internal/repository/admin_role_repo.go
Normal 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
|
||||
}
|
||||
168
backend-go/internal/repository/assignment_repo.go
Normal file
168
backend-go/internal/repository/assignment_repo.go
Normal 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
|
||||
}
|
||||
184
backend-go/internal/repository/attendance_repo.go
Normal file
184
backend-go/internal/repository/attendance_repo.go
Normal 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
|
||||
}
|
||||
184
backend-go/internal/repository/class_repo.go
Normal file
184
backend-go/internal/repository/class_repo.go
Normal 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
|
||||
}
|
||||
294
backend-go/internal/repository/conduct_repo.go
Normal file
294
backend-go/internal/repository/conduct_repo.go
Normal 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
|
||||
}
|
||||
91
backend-go/internal/repository/log_repo.go
Normal file
91
backend-go/internal/repository/log_repo.go
Normal 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
|
||||
}
|
||||
291
backend-go/internal/repository/semester_repo.go
Normal file
291
backend-go/internal/repository/semester_repo.go
Normal 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
|
||||
}
|
||||
230
backend-go/internal/repository/student_repo.go
Normal file
230
backend-go/internal/repository/student_repo.go
Normal 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
|
||||
}
|
||||
104
backend-go/internal/repository/subject_repo.go
Normal file
104
backend-go/internal/repository/subject_repo.go
Normal 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
|
||||
}
|
||||
110
backend-go/internal/repository/super_admin_repo.go
Normal file
110
backend-go/internal/repository/super_admin_repo.go
Normal 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
|
||||
}
|
||||
100
backend-go/internal/repository/system_setting_repo.go
Normal file
100
backend-go/internal/repository/system_setting_repo.go
Normal 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
|
||||
}
|
||||
166
backend-go/internal/repository/user_repo.go
Normal file
166
backend-go/internal/repository/user_repo.go
Normal 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
|
||||
}
|
||||
206
backend-go/internal/router/router.go
Normal file
206
backend-go/internal/router/router.go
Normal 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
|
||||
}
|
||||
33
backend-go/internal/schema/admin.go
Normal file
33
backend-go/internal/schema/admin.go
Normal 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"`
|
||||
}
|
||||
30
backend-go/internal/schema/attendance.go
Normal file
30
backend-go/internal/schema/attendance.go
Normal 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"`
|
||||
}
|
||||
26
backend-go/internal/schema/auth.go
Normal file
26
backend-go/internal/schema/auth.go
Normal 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"`
|
||||
}
|
||||
|
||||
44
backend-go/internal/schema/class.go
Normal file
44
backend-go/internal/schema/class.go
Normal 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"`
|
||||
}
|
||||
43
backend-go/internal/schema/conduct.go
Normal file
43
backend-go/internal/schema/conduct.go
Normal 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"`
|
||||
}
|
||||
50
backend-go/internal/schema/ranking.go
Normal file
50
backend-go/internal/schema/ranking.go
Normal 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"`
|
||||
}
|
||||
38
backend-go/internal/schema/semester.go
Normal file
38
backend-go/internal/schema/semester.go
Normal 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"`
|
||||
}
|
||||
54
backend-go/internal/schema/student.go
Normal file
54
backend-go/internal/schema/student.go
Normal 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"`
|
||||
}
|
||||
27
backend-go/internal/schema/subject.go
Normal file
27
backend-go/internal/schema/subject.go
Normal 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"`
|
||||
}
|
||||
452
backend-go/internal/service/admin_service.go
Normal file
452
backend-go/internal/service/admin_service.go
Normal 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()
|
||||
}
|
||||
226
backend-go/internal/service/attendance_service.go
Normal file
226
backend-go/internal/service/attendance_service.go
Normal 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
|
||||
}
|
||||
}
|
||||
461
backend-go/internal/service/auth_service.go
Normal file
461
backend-go/internal/service/auth_service.go
Normal 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 "/"
|
||||
}
|
||||
}
|
||||
|
||||
224
backend-go/internal/service/class_service.go
Normal file
224
backend-go/internal/service/class_service.go
Normal 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
|
||||
}
|
||||
384
backend-go/internal/service/conduct_service.go
Normal file
384
backend-go/internal/service/conduct_service.go
Normal 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
|
||||
}
|
||||
49
backend-go/internal/service/config_service.go
Normal file
49
backend-go/internal/service/config_service.go
Normal 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"),
|
||||
}
|
||||
}
|
||||
70
backend-go/internal/service/log_service.go
Normal file
70
backend-go/internal/service/log_service.go
Normal 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
|
||||
}
|
||||
|
||||
80
backend-go/internal/service/ranking_service.go
Normal file
80
backend-go/internal/service/ranking_service.go
Normal 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
|
||||
}
|
||||
665
backend-go/internal/service/semester_service.go
Normal file
665
backend-go/internal/service/semester_service.go
Normal 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
|
||||
}
|
||||
}
|
||||
171
backend-go/internal/service/student_service.go
Normal file
171
backend-go/internal/service/student_service.go
Normal 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
|
||||
}
|
||||
92
backend-go/internal/service/subject_service.go
Normal file
92
backend-go/internal/service/subject_service.go
Normal 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)
|
||||
}
|
||||
158
backend-go/internal/service/super_admin_service.go
Normal file
158
backend-go/internal/service/super_admin_service.go
Normal 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
|
||||
}
|
||||
25
backend-go/internal/service/utils.go
Normal file
25
backend-go/internal/service/utils.go
Normal 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
|
||||
}
|
||||
110
backend-go/pkg/crypto/password.go
Normal file
110
backend-go/pkg/crypto/password.go
Normal 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, ""
|
||||
}
|
||||
71
backend-go/pkg/database/mysql.go
Normal file
71
backend-go/pkg/database/mysql.go
Normal 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
|
||||
}
|
||||
80
backend-go/pkg/database/redis.go
Normal file
80
backend-go/pkg/database/redis.go
Normal 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
93
backend-go/pkg/jwt/jwt.go
Normal 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
|
||||
}
|
||||
64
backend-go/pkg/logger/logger.go
Normal file
64
backend-go/pkg/logger/logger.go
Normal 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()
|
||||
}
|
||||
}
|
||||
106
backend-go/pkg/response/response.go
Normal file
106
backend-go/pkg/response/response.go
Normal 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,
|
||||
}, "操作成功")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
142
backend/main.py
142
backend/main.py
@@ -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
|
||||
)
|
||||
@@ -1,11 +0,0 @@
|
||||
# ===========================================
|
||||
# 班级操行分管理系统 - 后端服务
|
||||
#
|
||||
# 开发者: Canglan
|
||||
# 联系方式: admin@sea-studio.top
|
||||
# 版权归属: Sea Network Technology Studio
|
||||
# 许可证: MIT License
|
||||
#
|
||||
# 版权所有 © Sea Network Technology Studio
|
||||
# ===========================================
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -1,138 +0,0 @@
|
||||
# ===========================================
|
||||
# 班级操行分管理系统 - 后端服务
|
||||
#
|
||||
# 开发者: Canglan
|
||||
# 联系方式: admin@sea-studio.top
|
||||
# 版权归属: Sea Network Technology Studio
|
||||
# 许可证: MIT License
|
||||
#
|
||||
# 版权所有 © Sea Network Technology Studio
|
||||
# ===========================================
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from typing import Dict, Any
|
||||
import re
|
||||
|
||||
|
||||
class SanitizeMiddleware(BaseHTTPMiddleware):
|
||||
"""输入过滤中间件"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# 只处理POST、PUT、PATCH请求
|
||||
if request.method in ["POST", "PUT", "PATCH"]:
|
||||
# 获取请求体
|
||||
body = await request.body()
|
||||
if body:
|
||||
import json
|
||||
try:
|
||||
data = json.loads(body)
|
||||
# 清理数据
|
||||
cleaned_data = self._sanitize_data(data)
|
||||
# 替换请求体
|
||||
request._body = json.dumps(cleaned_data).encode()
|
||||
except:
|
||||
pass
|
||||
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
def _sanitize_data(self, data: Any) -> Any:
|
||||
"""递归清理数据"""
|
||||
if isinstance(data, dict):
|
||||
return {k: self._sanitize_data(v) for k, v in data.items()}
|
||||
elif isinstance(data, list):
|
||||
return [self._sanitize_data(item) for item in data]
|
||||
elif isinstance(data, str):
|
||||
return self._sanitize_string(data)
|
||||
else:
|
||||
return data
|
||||
|
||||
def _sanitize_string(self, value: str) -> str:
|
||||
"""清理字符串"""
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
# 去除首尾空格
|
||||
value = value.strip()
|
||||
|
||||
# SQL注入模式检测
|
||||
sql_patterns = [
|
||||
r'(?i)(\bunion\b\s+\bselect\b)',
|
||||
r'(?i)(\bor\b\s+\d+\s*=\s*\d+)',
|
||||
r'(?i)(\bdrop\b\s+\btable\b)',
|
||||
r'(?i)(\bdelete\b\s+\bfrom\b)',
|
||||
r'(?i)(\binsert\b\s+\binto\b)',
|
||||
r'(?i)(\bupdate\b\s+\w+\s+\bset\b)',
|
||||
]
|
||||
for pattern in sql_patterns:
|
||||
value = re.sub(pattern, '', value)
|
||||
|
||||
# 路径遍历检测
|
||||
value = value.replace('../', '').replace('..\\', '')
|
||||
|
||||
# 限制长度
|
||||
if len(value) > 1000:
|
||||
value = value[:1000]
|
||||
|
||||
# 转义HTML特殊字符
|
||||
html_chars = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/'
|
||||
}
|
||||
for char, escape in html_chars.items():
|
||||
value = value.replace(char, escape)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def sanitize_input(value: str, max_length: int = 255) -> str:
|
||||
"""清理单个输入值"""
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
value = value.strip()
|
||||
if len(value) > max_length:
|
||||
value = value[:max_length]
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def validate_points(points: int, min_val: int = -100, max_val: int = 100) -> tuple:
|
||||
"""
|
||||
验证分值
|
||||
返回: (是否有效, 错误信息)
|
||||
"""
|
||||
if points == 0:
|
||||
return False, "分值不能为0"
|
||||
if points < min_val or points > max_val:
|
||||
return False, f"分值必须在{min_val}到{max_val}之间"
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_reason(reason: str) -> tuple:
|
||||
"""
|
||||
验证原因
|
||||
返回: (是否有效, 错误信息)
|
||||
"""
|
||||
if not reason or not reason.strip():
|
||||
return False, "原因不能为空"
|
||||
# 计算可见字符长度(不含换行符),支持多行输入
|
||||
visible_length = len(reason.replace('\n', ''))
|
||||
if visible_length > 255:
|
||||
return False, "原因长度不能超过255个字符"
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_date(date_str: str) -> bool:
|
||||
"""验证日期格式 YYYY-MM-DD"""
|
||||
if not date_str:
|
||||
return False
|
||||
pattern = r'^\d{4}-\d{2}-\d{2}$'
|
||||
if not re.match(pattern, date_str):
|
||||
return False
|
||||
return True
|
||||
@@ -1,11 +0,0 @@
|
||||
# ===========================================
|
||||
# 班级操行分管理系统 - 后端服务
|
||||
#
|
||||
# 开发者: Canglan
|
||||
# 联系方式: admin@sea-studio.top
|
||||
# 版权归属: Sea Network Technology Studio
|
||||
# 许可证: MIT License
|
||||
#
|
||||
# 版权所有 © Sea Network Technology Studio
|
||||
# ===========================================
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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,))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,11 +0,0 @@
|
||||
# ===========================================
|
||||
# 班级操行分管理系统 - 后端服务
|
||||
#
|
||||
# 开发者: Canglan
|
||||
# 联系方式: admin@sea-studio.top
|
||||
# 版权归属: Sea Network Technology Studio
|
||||
# 许可证: MIT License
|
||||
#
|
||||
# 版权所有 © Sea Network Technology Studio
|
||||
# ===========================================
|
||||
|
||||
@@ -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} 的登录锁定")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -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
Reference in New Issue
Block a user