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