cacode commited on
Commit
a30f196
·
verified ·
1 Parent(s): 774585c

Update Space: schedule, MySQL persistence, registration codes, registration flow

Browse files
README.md CHANGED
@@ -1,222 +1,222 @@
1
- ---
2
- title: Advanced SCU Course Catcher
3
- emoji: 🐳
4
- colorFrom: green
5
- colorTo: yellow
6
- sdk: docker
7
- app_port: 7860
8
- pinned: false
9
- ---
10
-
11
- # Advanced SCU Course Catcher
12
-
13
- 这是一个面向 Hugging Face Space 的四川大学选课系统 Web 版抢课面板。当前版本已经从单机脚本改造成了可部署、可并行、可多用户管理的 Flask + Selenium 服务,支持普通用户端和管理员端分离运行。
14
-
15
- ## 当前能力
16
-
17
- - 普通用户入口:`/login`
18
- 用户使用 `学号 + 密码` 登录,只能看到自己的课程、任务状态和实时日志。
19
- - 管理员入口:`/admin`
20
- 管理员使用 `账号 + 密码` 登录。此入口不会在用户登录页展示。
21
- - 多管理员体系
22
- 支持多位普通管理员,支持 1 位超级管理员。超级管理员账号和密码由环境变量 `ADMIN`、`PASSWORD` 提供。
23
- - 多用户管理
24
- 管理员可以手动录入用户账号、重置密码、启用/禁用用户、查看所有课程目标。
25
- - 课程录入
26
- 用户自己填写 `课程号` 和 `课序号`,管理员可见全部内容,也可以代为录入和修改。
27
- - 实时日志
28
- 用户和管理员都可以实时看到程序日志,前端通过 SSE 持续推送。
29
- - 并行调度
30
- 多用户任务由后台任务调度器并行执行,管理员可以在后台动态设置并行数。
31
- - Hugging Face Space 适配
32
- 使用 Docker Space 运行,内置 Chromium、chromedriver、中文字体和无头浏览器配置。
33
- - 登录验证码与提交验证码 OCR
34
- 登录阶段和提交选课阶段都支持验证码 OCR 自动识别。
35
- - Selenium 自恢复
36
- 单个 Selenium 会话连续错误达到 5 次时,会自动重建浏览器;连续重建 5 个会话仍失败时,任务才会终止。
37
-
38
- ## 运行结构
39
-
40
- 当前实际运行入口和核心模块如下:
41
-
42
- - `app.py`
43
- Hugging Face / gunicorn 使用的 WSGI 入口。
44
- - `space_app.py`
45
- Flask 路由、页面渲染、登录鉴权、SSE 日志流。
46
- - `core/config.py`
47
- 运行配置与内部密钥管理。
48
- - `core/db.py`
49
- SQLite 数据层。
50
- - `core/task_manager.py`
51
- 并行调度、任务生命周期管理。
52
- - `core/course_bot.py`
53
- Selenium 抢课核心逻辑、验证码识别、重试与重建策略。
54
- - `webdriver_utils.py`
55
- Chromium / chromedriver 初始化。
56
- - `templates/` + `static/`
57
- 用户端、管理员和响应式前端页面。
58
-
59
- ## 环境变量
60
-
61
- 为了减少 Space 配置复杂度,当前只保留少量必要环境变量。
62
-
63
- ### 必填
64
-
65
- - `ADMIN`
66
- 超级管理员账号。
67
- - `PASSWORD`
68
- 超级管理员密码。
69
-
70
- ### 可选
71
-
72
- - `DATA_DIR`
73
- 数据目录,默认会使用仓库下的 `data/`。在 Hugging Face Space 中建议保持为 `/data`。
74
- - `CHROME_BIN`
75
- 自定义 Chromium 路径。默认会自动尝试 `/usr/bin/chromium`。
76
- - `CHROMEDRIVER_PATH`
77
- 自定义 chromedriver 路径。默认会自动尝试 `/usr/bin/chromedriver`。
78
-
79
- ### 不需要再手动配置的内容
80
-
81
- 以下内容已经改为自动生成或写入程序默认值,不需要再手工配置环境变量:
82
-
83
- - Flask session secret
84
- - 用户密码加密密钥
85
- - 默认并行数
86
- - 登录重试次数
87
- - Selenium 会话错误阈值
88
- - Selenium 会话重建阈值
89
- - 提交验证码重试次数
90
- - 页面超时时间
91
- - 日志页容量
92
-
93
- 程序会在 `DATA_DIR/.app_secrets.json` 中自动持久化内部密钥。这样即使以后修改 `ADMIN` 或 `PASSWORD`,也不会导致历史用户密码全部失效。
94
-
95
- ## Hugging Face Space 部署
96
-
97
- ### 1. 创建 Space
98
-
99
- 在 Hugging Face 上创建一个新的 Space:
100
-
101
- - SDK 选择 `Docker`
102
- - 端口使用 `7860`
103
- - 仓库内容直接推送本项目即可
104
-
105
- 本仓库根目录已经提供好 `README.md` 的 Space metadata 和 `Dockerfile`。
106
-
107
- ### 2. 设置 Space Secrets
108
-
109
- 进入 `Settings -> Variables and secrets`,至少配置:
110
-
111
- - `ADMIN=你的超级管理员账号`
112
- - `PASSWORD=你的超级管理员密码`
113
-
114
- 通常不需要再设置其他环境变量。
115
-
116
- 如果你想显式指定数据目录,也可以增加:
117
-
118
- - `DATA_DIR=/data`
119
-
120
- ### 3. 开启持久化存储
121
-
122
- 如果你希望以下数据在 Space 重启后仍然保留,建议在 Space 设置里开启 Persistent Storage:
123
-
124
- - 用户账号
125
- - 管理员账号
126
- - 课程目标
127
- - 任务历史
128
- - 运行日志
129
- - 自动生成的内部密钥文件
130
-
131
- 当前 Dockerfile 已按 `/data` 目录进行适配,并预先处理了目录权限,方便在 Space 中直接持久化。
132
-
133
- ### 4. 推送部署
134
-
135
- 如果你使用 git 推送到 Hugging Face Space,可以参考:
136
-
137
- ```bash
138
- git remote add space https://huggingface.co/spaces/<your-name>/<your-space>
139
- git push space main
140
- ```
141
-
142
- ### 5. 启动方式
143
-
144
- 容器启动命令已经写入 `Dockerfile`:
145
-
146
- ```bash
147
- gunicorn --bind 0.0.0.0:${PORT:-7860} --workers 1 --threads 8 --timeout 180 app:app
148
- ```
149
-
150
- 这里使用 `1` 个 gunicorn worker,是为了避免多进程情况下每个进程都各自启动一套本地任务调度器。并发能力由应用内部的线程调度和管理员可配置的任务并行数控制。
151
-
152
- ## Space 端建议配置
153
-
154
- 推荐的初始运行策略:
155
-
156
- - 后台并行数先设置为 `1` 或 `2`
157
- - 只有在 Space CPU / 内存足够稳定时,再逐步提升
158
- - 在真实高峰前,先做一次小规模联调
159
-
160
- 对于 CPU 较小的 Space,同时拉起过多 Chromium 会明显增加失败率,因此管理员后台提供了并行数动态调整功能。
161
-
162
- ## 管理员联调清单
163
-
164
- 部署到 Space 后,建议按以下顺序联调:
165
-
166
- 1. 访问 `/admin`,使用 `ADMIN` / `PASSWORD` 登录超级管理员。
167
- 2. 在管理员后台创建 1 个普通管理员、2 个测试用户。
168
- 3. 为两个测试用户分别录入不同课程号和课序号。
169
- 4. 将并行数设置为 `2`。
170
- 5. 分别触发两个用户任务,确认两条任务都能进入队列,并按并行数启动。
171
- 6. 在管理员日志面板确认日志持续刷新,且可以看到不同学号的执行记录。
172
- 7. 使用用户账号访问 `/login`,确认用户只能看到自己的课程和日志。
173
- 8. 在任务运行过程中测试停止任务,确认状态能变为“停止中”并最终收敛。
174
- 9. 如果学校页面出现提交验证码,确认日志中能看到提交阶段验证码识别提示。
175
-
176
- ## 自动化测试
177
-
178
- 仓库当前已补充以下自动化测试:
179
-
180
- - `tests/test_config.py`
181
- 验证内部密钥在超级管理员账号变更后依然保持稳定。
182
- - `tests/test_course_bot.py`
183
- 验证 Selenium 会话错误累计后会触发浏览器重建,并验证提交阶段验证码分支。
184
- - `tests/test_task_manager.py`
185
- 验证任务调度器会严格遵守管理员设置的并行上限。
186
- - `.github/workflows/linux-tests.yml`
187
- 在 `ubuntu-latest` 上自动运行上述测试,避免关键逻辑只在 Windows 本地验证。
188
-
189
- 本地运行测试:
190
-
191
- ```bash
192
- python -m unittest discover -s tests -v
193
- ```
194
-
195
- ## 本地运行
196
-
197
- ### 直接运行
198
-
199
- ```bash
200
- pip install -r requirements.txt
201
- set ADMIN=admin
202
- set PASSWORD=change-me
203
- python main.py
204
- ```
205
-
206
- ### Docker 运行
207
-
208
- ```bash
209
- docker build -t advanced-scu-course-catcher .
210
- docker run --rm -p 7860:7860 \
211
- -e ADMIN=admin \
212
- -e PASSWORD=change-me \
213
- advanced-scu-course-catcher
214
- ```
215
-
216
- ## 重要说明
217
-
218
- - 本项目当前的任务型是“持续轮询直到成功、手动停止或达到错误上限”。
219
- - 用户密码会以应用内部密钥加密后保存在数据库中,用于后台自动登录教务系统。
220
- - 管理员可以看到全部用户、课程和日志,请在实际使用前明确权限边界。
221
- - 如果学校选课页面 DOM 结构变化较大,需要同步更新 `core/course_bot.py` 中的选择器以及 `javascript/` 下的辅助脚本。
222
  - `course_catcher/` 目录是仓库中保留的旧实现,当前运行链路以 `space_app.py + core/*` 为准。
 
1
+ ---
2
+ title: Advanced SCU Course Catcher
3
+ emoji: 🐳
4
+ colorFrom: green
5
+ colorTo: yellow
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Advanced SCU Course Catcher
12
+
13
+ 这是一个面向 Hugging Face Space 的四川大学选课系统 Web 版抢课面板。当前版本已经从单机脚本改造成了可部署、可并行、可多用户管理的 Flask + Selenium 服务,支持普通用户端和管理员端分离运行。
14
+
15
+ ## 当前能力
16
+
17
+ - 普通用户入口:`/login`
18
+ 用户使用 `学号 + 密码` 登录,只能看到自己的课程、任务状态和实时日志。
19
+ - 管理员入口:`/admin`
20
+ 管理员使用 `账号 + 密码` 登录。此入口不会在用户登录页展示。
21
+ - 多管理员体系
22
+ 支持多位普通管理员,支持 1 位超级管理员。超级管理员账号和密码由环境变量 `ADMIN`、`PASSWORD` 提供。
23
+ - 多用户管理
24
+ 管理员可以手动录入用户账号、重置密码、启用/禁用用户、查看所有课程目标。
25
+ - 课程录入
26
+ 用户自己填写 `课程号` 和 `课序号`,管理员可见全部内容,也可以代为录入和修改。
27
+ - 实时日志
28
+ 用户和管理员都可以实时看到程序日志,前端通过 SSE 持续推送。
29
+ - 并行调度
30
+ 多用户任务由后台任务调度器并行执行,管理员可以在后台动态设置并行数。
31
+ - Hugging Face Space 适配
32
+ 使用 Docker Space 运行,内置 Chromium、chromedriver、中文字体和无头浏览器配置。
33
+ - 登录验证码与提交验证码 OCR
34
+ 登录阶段和提交选课阶段都支持验证码 OCR 自动识别。
35
+ - Selenium 自恢复
36
+ 单个 Selenium 会话连续错误达到 5 次时,会自动重建浏览器;连续重建 5 个会话仍失败时,任务才会终止。
37
+
38
+ ## 运行结构
39
+
40
+ 当前实际运行入口和核心模块如下:
41
+
42
+ - `app.py`
43
+ Hugging Face / gunicorn 使用的 WSGI 入口。
44
+ - `space_app.py`
45
+ Flask 路由、页面渲染、登录鉴权、SSE 日志流。
46
+ - `core/config.py`
47
+ 运行配置与内部密钥管理。
48
+ - `core/db.py`
49
+ SQLite 数据层。
50
+ - `core/task_manager.py`
51
+ 并行调度、任务生命周期管理。
52
+ - `core/course_bot.py`
53
+ Selenium 抢课核心逻辑、验证码识别、重试与重建策略。
54
+ - `webdriver_utils.py`
55
+ Chromium / chromedriver 初始化。
56
+ - `templates/` + `static/`
57
+ 用户端、管理员���和响应式前端页面。
58
+
59
+ ## 环境变量
60
+
61
+ 为了减少 Space 配置复杂度,当前只保留少量必要环境变量。
62
+
63
+ ### 必填
64
+
65
+ - `ADMIN`
66
+ 超级管理员账号。
67
+ - `PASSWORD`
68
+ 超级管理员密码。
69
+
70
+ ### 可选
71
+
72
+ - `DATA_DIR`
73
+ 数据目录,默认会使用仓库下的 `data/`。在 Hugging Face Space 中建议保持为 `/data`。
74
+ - `CHROME_BIN`
75
+ 自定义 Chromium 路径。默认会自动尝试 `/usr/bin/chromium`。
76
+ - `CHROMEDRIVER_PATH`
77
+ 自定义 chromedriver 路径。默认会自动尝试 `/usr/bin/chromedriver`。
78
+
79
+ ### 不需要再手动配置的内容
80
+
81
+ 以下内容已经改为自动生成或写入程序默认值,不需要再手工配置环境变量:
82
+
83
+ - Flask session secret
84
+ - 用户密码加密密钥
85
+ - 默认并行数
86
+ - 登录重试次数
87
+ - Selenium 会话错误阈值
88
+ - Selenium 会话重建阈值
89
+ - 提交验证码重试次数
90
+ - 页面超时时间
91
+ - 日志页容量
92
+
93
+ 程序会在 `DATA_DIR/.app_secrets.json` 中自动持久化内部密钥。这样即使以后修改 `ADMIN` 或 `PASSWORD`,也不会导致历史用户密码全部失效。
94
+
95
+ ## Hugging Face Space 部署
96
+
97
+ ### 1. 创建 Space
98
+
99
+ 在 Hugging Face 上创建一个新的 Space:
100
+
101
+ - SDK 选择 `Docker`
102
+ - 端口使用 `7860`
103
+ - 仓库内容直接推送本项目即可
104
+
105
+ 本仓库根目录已经提供好 `README.md` 的 Space metadata 和 `Dockerfile`。
106
+
107
+ ### 2. 设置 Space Secrets
108
+
109
+ 进入 `Settings -> Variables and secrets`,至少配置:
110
+
111
+ - `ADMIN=你的超级管理员账号`
112
+ - `PASSWORD=你的超级管理员密码`
113
+
114
+ 通常不需要再设置其他环境变量。
115
+
116
+ 如果你想显式指定数据目录,也可以增加:
117
+
118
+ - `DATA_DIR=/data`
119
+
120
+ ### 3. 开启持久化存储
121
+
122
+ 如果你希望以下数据在 Space 重启后仍然保留,建议在 Space 设置里开启 Persistent Storage:
123
+
124
+ - 用户账号
125
+ - 管理员账号
126
+ - 课程目标
127
+ - 任务历史
128
+ - 运行日志
129
+ - 自动生成的内部密钥文件
130
+
131
+ 当前 Dockerfile 已按 `/data` 目录进行适配,并预先处理了目录权限,方便在 Space 中直接持久化。
132
+
133
+ ### 4. 推送部署
134
+
135
+ 如果你使用 git 推送到 Hugging Face Space,可以参考:
136
+
137
+ ```bash
138
+ git remote add space https://huggingface.co/spaces/<your-name>/<your-space>
139
+ git push space main
140
+ ```
141
+
142
+ ### 5. 启动方式
143
+
144
+ 容器启动命令已经写入 `Dockerfile`:
145
+
146
+ ```bash
147
+ gunicorn --bind 0.0.0.0:${PORT:-7860} --workers 1 --threads 8 --timeout 180 app:app
148
+ ```
149
+
150
+ 这里使用 `1` 个 gunicorn worker,是为了避免多进程情况下每个进程都各自启动一套本地任务调度器。并发能力由应用内部的线程调度和管理员可配置的任务并行数控制。
151
+
152
+ ## Space 端建议配置
153
+
154
+ 推荐的初始运行策略:
155
+
156
+ - 后台并行数先设置为 `1` 或 `2`
157
+ - 只有在 Space CPU / 内存足够稳定时,再逐步提升
158
+ - 在真实高峰前,先做一次小规模联调
159
+
160
+ 对于 CPU 较小的 Space,同时拉起过多 Chromium 会明显增加失败率,因此管理员后台提供了并行数动态调整功能。
161
+
162
+ ## 管理员联调清单
163
+
164
+ 部署到 Space 后,建议按以下顺序联调:
165
+
166
+ 1. 访问 `/admin`,使用 `ADMIN` / `PASSWORD` 登录超级管理员。
167
+ 2. 在管理员后台创建 1 个普通管理员、2 个测试用户。
168
+ 3. 为两个测试用户分别录入不同课程号和课序号。
169
+ 4. 将并行数设置为 `2`。
170
+ 5. 分别触发两个用户任务,确认两条任务都能进入队列,并按并行数启动。
171
+ 6. 在管理员日志面板确认日志持续刷新,且可以看到不同学号的执行记录。
172
+ 7. 使用用户账号访问 `/login`,确认用户只能看到自己的课程和日志。
173
+ 8. 在任务运行过程中测试停止任务,确认状态能变为“停止中”并最终收敛。
174
+ 9. 如果学校页面出现提交验证码,确认日志中能看到提交阶段验证码识别提示。
175
+
176
+ ## 自动化测试
177
+
178
+ 仓库当前已补充以下自动化测试:
179
+
180
+ - `tests/test_config.py`
181
+ 验证内部密钥在超级管理员账号变更后依然保持稳定。
182
+ - `tests/test_course_bot.py`
183
+ 验证 Selenium 会话错误累计后会触发浏览器重建,并验证提交阶段验证码分支。
184
+ - `tests/test_task_manager.py`
185
+ 验证任务调度器会严格遵守管理员设置的并行上限。
186
+ - `.github/workflows/linux-tests.yml`
187
+ 在 `ubuntu-latest` 上自动运行上述测试,避免关键逻辑只在 Windows 本地验证。
188
+
189
+ 本地运行测试:
190
+
191
+ ```bash
192
+ python -m unittest discover -s tests -v
193
+ ```
194
+
195
+ ## 本地运行
196
+
197
+ ### 直接运行
198
+
199
+ ```bash
200
+ pip install -r requirements.txt
201
+ set ADMIN=admin
202
+ set PASSWORD=change-me
203
+ python main.py
204
+ ```
205
+
206
+ ### Docker 运行
207
+
208
+ ```bash
209
+ docker build -t advanced-scu-course-catcher .
210
+ docker run --rm -p 7860:7860 \
211
+ -e ADMIN=admin \
212
+ -e PASSWORD=change-me \
213
+ advanced-scu-course-catcher
214
+ ```
215
+
216
+ ## 重要说明
217
+
218
+ - 本项目当前的任务���型是“持续轮询直到成功、手动停止或达到错误上限”。
219
+ - 用户密码会以应用内部密钥加密后保存在数据库中,用于后台自动登录教务系统。
220
+ - 管理员可以看到全部用户、课程和日志,请在实际使用前明确权限边界。
221
+ - 如果学校选课页面 DOM 结构变化较大,需要同步更新 `core/course_bot.py` 中的选择器以及 `javascript/` 下的辅助脚本。
222
  - `course_catcher/` 目录是仓库中保留的旧实现,当前运行链路以 `space_app.py + core/*` 为准。
ca.pem ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIERDCCAqygAwIBAgIUIZeUQD0xvEAJNu72uWDNezVfx/cwDQYJKoZIhvcNAQEM
3
+ BQAwOjE4MDYGA1UEAwwvOTYzMmFjZDktZjBhOC00NjQ4LTg3M2QtNTRkYTAxNWEz
4
+ NzllIFByb2plY3QgQ0EwHhcNMjYwMzA1MDgzMTU3WhcNMzYwMzAyMDgzMTU3WjA6
5
+ MTgwNgYDVQQDDC85NjMyYWNkOS1mMGE4LTQ2NDgtODczZC01NGRhMDE1YTM3OWUg
6
+ UHJvamVjdCBDQTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBAMSAckGu
7
+ d3mDTOTdgrL/mCfF7WkNWx6CKNGU/R5WaKdJ0Ub0Fbsu3erzQ7Qi877qcPSQg06G
8
+ qH9umxOvq6hZG2lrNXg+8AJihezMME1zmODu+OEOX96jsUn5Pr8OSa68zzRkoiOl
9
+ fXtVDYwTaIhexLJT/U6ELKqUxkBIVHTSmY/hp0SFOuEtMdra6dbJdMOXrGhI+IQK
10
+ KmbRh9H208NpfzjQBos1g27D4YHBe1p55CfihDFEso22i98Wxu2kRqv6hz2n35Qi
11
+ PNdJEi38ascZCGjx24VuuhXGcghHC1GxuIcArxWExNt880HtGztSIu6mBeSW+k9m
12
+ HQiORu9TGuaErD/Xa33wM+sLKFbjBspCjkejwWWp7Kz4T+yup1lOxBDZlqaBgUdD
13
+ MqIcIwBlG/kEUqTPHHKiTcGzg/8KUVDtTTofEAEi5MSiu/7EBQkN/jcAmEfwm4E4
14
+ eOCINwkv53IL5ZRKVg7+sPg0a3mFe2nC0jpO8SskAe00ny3glN+uVzG+2wIDAQAB
15
+ o0IwQDAdBgNVHQ4EFgQUecg1zbigo7JiWmgY17t7E4L8cFIwEgYDVR0TAQH/BAgw
16
+ BgEB/wIBADALBgNVHQ8EBAMCAQYwDQYJKoZIhvcNAQEMBQADggGBAFSdhJQ5fCO7
17
+ FC4I7ri5Gh93iBzjFplqTpbKYZft1RCRZy/ddvwSh4RMylb4MAQbDNs5D/c45E0u
18
+ CUSc449KkVmoZsrtRwQY7Z9BLGaSDlWNca8FO+cGNqrM7vhXOeMYiGADIeg2M/yU
19
+ QMmNxR+Y6nQO6wHP9r3BG1rNMBrjNcOjIXoo65qXv3PGLmNnjlLWyHwTmkfm6E4o
20
+ RIPPWQNo7zVzUnxNiGEDUOpMpeA/JxK4BY44JZTRy4TNVCoM+Qn6PoY4IYyFVjFo
21
+ CG0dBZPHdSeeJGgqJjOVhQQj2hq7BMVdgMSIv1jhUezHQeyfW8XF42C80qG09psI
22
+ CLuxu2geZC/4Y+I5NR21EecUf8nDHNcBHObAOsrPM7oOd7iPpiEMKh2s6kRa4yGt
23
+ LWOL391h5HgNnNcancVe8fXuZ4q7ul4NmQYYt8vtj9e82VpppKPwCPtIZGi4XEv5
24
+ qrYctjxBQUnw+Wp/aV+Q4MN1CD188XEJyo0re0n5Lzi/VBj8suXzcg==
25
+ -----END CERTIFICATE-----
core/config.py CHANGED
@@ -6,11 +6,12 @@ import secrets
6
  import shutil
7
  from dataclasses import dataclass
8
  from pathlib import Path
 
9
 
10
  from cryptography.fernet import Fernet
11
 
12
 
13
- DEFAULT_PARALLEL_LIMIT = 2
14
  POLL_INTERVAL_SECONDS = 10
15
  LOGIN_RETRY_LIMIT = 6
16
  TASK_BACKOFF_SECONDS = 6
@@ -19,6 +20,8 @@ LOGS_PAGE_SIZE = 180
19
  SELENIUM_ERROR_LIMIT = 5
20
  SELENIUM_RESTART_LIMIT = 5
21
  SUBMIT_CAPTCHA_RETRY_LIMIT = 5
 
 
22
 
23
 
24
  def _pick_binary(env_name: str, *fallbacks: str) -> str:
@@ -79,11 +82,38 @@ def _load_or_create_internal_secrets(data_dir: Path) -> tuple[str, str]:
79
  return session_secret, encryption_key
80
 
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  @dataclass(slots=True)
83
  class AppConfig:
84
  root_dir: Path
85
  data_dir: Path
86
- db_path: Path
 
 
87
  session_secret: str
88
  encryption_key: str
89
  super_admin_username: str
@@ -99,6 +129,7 @@ class AppConfig:
99
  submit_captcha_retry_limit: int
100
  chrome_binary: str
101
  chromedriver_path: str
 
102
 
103
  @classmethod
104
  def load(cls) -> "AppConfig":
@@ -109,11 +140,14 @@ class AppConfig:
109
  super_admin_username = os.getenv("ADMIN", "superadmin")
110
  super_admin_password = os.getenv("PASSWORD", "change-me-in-hf-space")
111
  session_secret, encryption_key = _load_or_create_internal_secrets(data_dir)
 
112
 
113
  return cls(
114
  root_dir=root_dir,
115
  data_dir=data_dir,
116
- db_path=data_dir / "course_catcher.db",
 
 
117
  session_secret=session_secret,
118
  encryption_key=encryption_key,
119
  super_admin_username=super_admin_username,
@@ -139,4 +173,5 @@ class AppConfig:
139
  "/usr/bin/chromedriver",
140
  "chromedriver",
141
  ),
 
142
  )
 
6
  import shutil
7
  from dataclasses import dataclass
8
  from pathlib import Path
9
+ from urllib.parse import quote
10
 
11
  from cryptography.fernet import Fernet
12
 
13
 
14
+ DEFAULT_PARALLEL_LIMIT = 4
15
  POLL_INTERVAL_SECONDS = 10
16
  LOGIN_RETRY_LIMIT = 6
17
  TASK_BACKOFF_SECONDS = 6
 
20
  SELENIUM_ERROR_LIMIT = 5
21
  SELENIUM_RESTART_LIMIT = 5
22
  SUBMIT_CAPTCHA_RETRY_LIMIT = 5
23
+ SCHEDULE_TIMEZONE = "Asia/Shanghai"
24
+ AIVEN_MYSQL_TEMPLATE = "mysql://avnadmin:{password}@mysql-2bace9cd-cacode.i.aivencloud.com:21260/SACC?ssl-mode=REQUIRED"
25
 
26
 
27
  def _pick_binary(env_name: str, *fallbacks: str) -> str:
 
82
  return session_secret, encryption_key
83
 
84
 
85
+ def _build_database_target(root_dir: Path, data_dir: Path) -> tuple[str | Path, str, Path | None]:
86
+ explicit_database_url = os.getenv("DATABASE_URL", "").strip()
87
+ if explicit_database_url:
88
+ normalized_url = explicit_database_url
89
+ else:
90
+ sql_password = os.getenv("SQL_PASSWORD", "").strip()
91
+ if sql_password:
92
+ normalized_url = AIVEN_MYSQL_TEMPLATE.format(password=quote(sql_password, safe=""))
93
+ else:
94
+ return (data_dir / "course_catcher.db").resolve(), "sqlite", None
95
+
96
+ if normalized_url.startswith("mysql://"):
97
+ normalized_url = f"mysql+pymysql://{normalized_url[len('mysql://') :]}"
98
+
99
+ if normalized_url.startswith("mysql+pymysql://"):
100
+ mysql_ssl_ca_path = Path(os.getenv("MYSQL_SSL_CA", str(root_dir / "ca.pem"))).resolve()
101
+ return normalized_url, "mysql", mysql_ssl_ca_path
102
+
103
+ if normalized_url.startswith("sqlite:///"):
104
+ sqlite_path = Path(normalized_url[len("sqlite:///") :]).resolve()
105
+ return sqlite_path, "sqlite", None
106
+
107
+ return Path(normalized_url).resolve(), "sqlite", None
108
+
109
+
110
  @dataclass(slots=True)
111
  class AppConfig:
112
  root_dir: Path
113
  data_dir: Path
114
+ db_path: str | Path
115
+ database_backend: str
116
+ mysql_ssl_ca_path: Path | None
117
  session_secret: str
118
  encryption_key: str
119
  super_admin_username: str
 
129
  submit_captcha_retry_limit: int
130
  chrome_binary: str
131
  chromedriver_path: str
132
+ schedule_timezone: str
133
 
134
  @classmethod
135
  def load(cls) -> "AppConfig":
 
140
  super_admin_username = os.getenv("ADMIN", "superadmin")
141
  super_admin_password = os.getenv("PASSWORD", "change-me-in-hf-space")
142
  session_secret, encryption_key = _load_or_create_internal_secrets(data_dir)
143
+ db_path, database_backend, mysql_ssl_ca_path = _build_database_target(root_dir, data_dir)
144
 
145
  return cls(
146
  root_dir=root_dir,
147
  data_dir=data_dir,
148
+ db_path=db_path,
149
+ database_backend=database_backend,
150
+ mysql_ssl_ca_path=mysql_ssl_ca_path,
151
  session_secret=session_secret,
152
  encryption_key=encryption_key,
153
  super_admin_username=super_admin_username,
 
173
  "/usr/bin/chromedriver",
174
  "chromedriver",
175
  ),
176
+ schedule_timezone=os.getenv("APP_TIMEZONE", SCHEDULE_TIMEZONE),
177
  )
core/db.py CHANGED
@@ -1,16 +1,28 @@
1
- from __future__ import annotations
2
 
 
3
  import sqlite3
 
4
  from contextlib import contextmanager
5
  from datetime import datetime, timezone
6
  from pathlib import Path
7
  from typing import Any, Iterator
 
 
 
 
 
 
 
 
8
 
9
 
10
  ACTIVE_TASK_STATUSES = {"pending", "running", "cancel_requested"}
11
  DEFAULT_REFRESH_INTERVAL_SECONDS = 10
12
  MIN_REFRESH_INTERVAL_SECONDS = 1
13
  MAX_REFRESH_INTERVAL_SECONDS = 120
 
 
14
 
15
 
16
  def utc_now() -> str:
@@ -25,21 +37,103 @@ def clamp_refresh_interval_seconds(value: int | str | None, default: int = DEFAU
25
  return max(MIN_REFRESH_INTERVAL_SECONDS, min(MAX_REFRESH_INTERVAL_SECONDS, normalized))
26
 
27
 
 
 
 
 
28
  class Database:
29
- def __init__(self, path: Path, default_parallel_limit: int = 2) -> None:
30
- self.path = Path(path)
 
 
 
 
 
31
  self.default_parallel_limit = default_parallel_limit
32
- self.path.parent.mkdir(parents=True, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
- def _connect(self) -> sqlite3.Connection:
35
- connection = sqlite3.connect(self.path, timeout=30)
36
- connection.row_factory = sqlite3.Row
37
- connection.execute("PRAGMA foreign_keys = ON")
38
- connection.execute("PRAGMA journal_mode = WAL")
39
- return connection
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  @contextmanager
42
- def _cursor(self) -> Iterator[tuple[sqlite3.Connection, sqlite3.Cursor]]:
43
  connection = self._connect()
44
  try:
45
  cursor = connection.cursor()
@@ -48,18 +142,38 @@ class Database:
48
  finally:
49
  connection.close()
50
 
 
 
 
51
  @staticmethod
52
- def _rows_to_dicts(rows: list[sqlite3.Row]) -> list[dict[str, Any]]:
53
  return [dict(row) for row in rows]
54
 
55
  @staticmethod
56
  def _normalize_course_identity(value: str) -> str:
57
  return str(value or "").strip().upper()
58
 
59
- @staticmethod
60
- def _column_exists(cursor: sqlite3.Cursor, table_name: str, column_name: str) -> bool:
61
- rows = cursor.execute(f"PRAGMA table_info({table_name})").fetchall()
62
- return any(str(row[1]) == column_name for row in rows)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  def _ensure_column(self, table_name: str, column_name: str, definition: str) -> None:
65
  with self._cursor() as (_connection, cursor):
@@ -67,105 +181,248 @@ class Database:
67
  return
68
  cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {definition}")
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  def init_db(self) -> None:
71
  with self._cursor() as (_connection, cursor):
72
- cursor.executescript(
73
- """
74
- CREATE TABLE IF NOT EXISTS users (
75
- id INTEGER PRIMARY KEY AUTOINCREMENT,
76
- student_id TEXT NOT NULL UNIQUE,
77
- password_encrypted TEXT NOT NULL,
78
- display_name TEXT NOT NULL DEFAULT '',
79
- is_active INTEGER NOT NULL DEFAULT 1,
80
- refresh_interval_seconds INTEGER NOT NULL DEFAULT 10,
81
- created_at TEXT NOT NULL,
82
- updated_at TEXT NOT NULL
83
- );
84
-
85
- CREATE TABLE IF NOT EXISTS course_targets (
86
- id INTEGER PRIMARY KEY AUTOINCREMENT,
87
- user_id INTEGER NOT NULL,
88
- category TEXT NOT NULL DEFAULT 'free',
89
- course_id TEXT NOT NULL,
90
- course_index TEXT NOT NULL,
91
- created_at TEXT NOT NULL,
92
- UNIQUE(user_id, category, course_id, course_index),
93
- FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
94
- );
95
-
96
- CREATE TABLE IF NOT EXISTS admins (
97
- id INTEGER PRIMARY KEY AUTOINCREMENT,
98
- username TEXT NOT NULL UNIQUE,
99
- password_hash TEXT NOT NULL,
100
- created_at TEXT NOT NULL,
101
- updated_at TEXT NOT NULL
102
- );
103
-
104
- CREATE TABLE IF NOT EXISTS app_settings (
105
- key TEXT PRIMARY KEY,
106
- value TEXT NOT NULL,
107
- updated_at TEXT NOT NULL
108
- );
109
-
110
- CREATE TABLE IF NOT EXISTS tasks (
111
- id INTEGER PRIMARY KEY AUTOINCREMENT,
112
- user_id INTEGER NOT NULL,
113
- status TEXT NOT NULL,
114
- requested_by TEXT NOT NULL,
115
- requested_by_role TEXT NOT NULL,
116
- last_error TEXT NOT NULL DEFAULT '',
117
- total_attempts INTEGER NOT NULL DEFAULT 0,
118
- total_errors INTEGER NOT NULL DEFAULT 0,
119
- created_at TEXT NOT NULL,
120
- started_at TEXT,
121
- finished_at TEXT,
122
- updated_at TEXT NOT NULL,
123
- FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
124
- );
125
-
126
- CREATE TABLE IF NOT EXISTS logs (
127
- id INTEGER PRIMARY KEY AUTOINCREMENT,
128
- task_id INTEGER,
129
- user_id INTEGER,
130
- scope TEXT NOT NULL,
131
- level TEXT NOT NULL,
132
- message TEXT NOT NULL,
133
- created_at TEXT NOT NULL,
134
- FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
135
- FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
136
- );
137
- """
138
- )
139
 
140
  self._ensure_column(
141
  "users",
142
  "refresh_interval_seconds",
143
- f"INTEGER NOT NULL DEFAULT {DEFAULT_REFRESH_INTERVAL_SECONDS}",
144
  )
145
- self._ensure_column("tasks", "total_attempts", "INTEGER NOT NULL DEFAULT 0")
146
- self._ensure_column("tasks", "total_errors", "INTEGER NOT NULL DEFAULT 0")
147
 
148
  if self.get_setting("parallel_limit") is None:
149
  self.set_setting("parallel_limit", str(self.default_parallel_limit))
150
 
151
  def get_setting(self, key: str) -> str | None:
 
152
  with self._cursor() as (_connection, cursor):
153
- row = cursor.execute("SELECT value FROM app_settings WHERE key = ?", (key,)).fetchone()
154
- return None if row is None else str(row["value"])
 
155
 
156
  def set_setting(self, key: str, value: str) -> None:
157
  now = utc_now()
158
  with self._cursor() as (_connection, cursor):
159
  cursor.execute(
160
- """
161
- INSERT INTO app_settings (key, value, updated_at)
162
- VALUES (?, ?, ?)
163
- ON CONFLICT(key) DO UPDATE SET
164
- value = excluded.value,
165
- updated_at = excluded.updated_at
166
- """,
167
- (key, value, now),
168
  )
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  def get_parallel_limit(self) -> int:
171
  raw_value = self.get_setting("parallel_limit")
@@ -191,22 +448,78 @@ class Database:
191
  refresh_interval = clamp_refresh_interval_seconds(refresh_interval_seconds)
192
  with self._cursor() as (_connection, cursor):
193
  cursor.execute(
194
- """
195
- INSERT INTO users (
196
- student_id,
197
- password_encrypted,
198
- display_name,
199
- is_active,
200
- refresh_interval_seconds,
201
- created_at,
202
- updated_at
203
- )
204
- VALUES (?, ?, ?, 1, ?, ?, ?)
205
- """,
206
- (student_id.strip(), password_encrypted, display_name.strip(), refresh_interval, now, now),
207
  )
208
  return int(cursor.lastrowid)
209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  def update_user(
211
  self,
212
  user_id: int,
@@ -217,28 +530,34 @@ class Database:
217
  refresh_interval_seconds: int | None = None,
218
  ) -> None:
219
  assignments: list[str] = []
220
- values: list[Any] = []
221
  if password_encrypted is not None:
222
- assignments.append("password_encrypted = ?")
223
- values.append(password_encrypted)
224
  if display_name is not None:
225
- assignments.append("display_name = ?")
226
- values.append(display_name.strip())
227
  if is_active is not None:
228
- assignments.append("is_active = ?")
229
- values.append(1 if is_active else 0)
230
  if refresh_interval_seconds is not None:
231
- assignments.append("refresh_interval_seconds = ?")
232
- values.append(clamp_refresh_interval_seconds(refresh_interval_seconds))
233
  if not assignments:
234
  return
 
 
 
 
 
 
235
 
236
- assignments.append("updated_at = ?")
237
- values.append(utc_now())
238
- values.append(user_id)
239
-
240
  with self._cursor() as (_connection, cursor):
241
- cursor.execute(f"UPDATE users SET {', '.join(assignments)} WHERE id = ?", tuple(values))
 
 
 
242
 
243
  def toggle_user_active(self, user_id: int) -> dict[str, Any] | None:
244
  user = self.get_user(user_id)
@@ -249,42 +568,35 @@ class Database:
249
 
250
  def get_user(self, user_id: int) -> dict[str, Any] | None:
251
  with self._cursor() as (_connection, cursor):
252
- row = cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
 
 
 
 
253
  return None if row is None else dict(row)
254
 
255
  def get_user_by_student_id(self, student_id: str) -> dict[str, Any] | None:
256
  with self._cursor() as (_connection, cursor):
257
- row = cursor.execute("SELECT * FROM users WHERE student_id = ?", (student_id.strip(),)).fetchone()
 
 
 
 
258
  return None if row is None else dict(row)
259
 
260
  def list_users(self) -> list[dict[str, Any]]:
 
 
 
 
 
 
 
 
 
261
  with self._cursor() as (_connection, cursor):
262
- rows = cursor.execute(
263
- """
264
- SELECT
265
- u.*,
266
- COUNT(c.id) AS course_count,
267
- (
268
- SELECT t.status
269
- FROM tasks t
270
- WHERE t.user_id = u.id
271
- ORDER BY t.created_at DESC
272
- LIMIT 1
273
- ) AS latest_task_status,
274
- (
275
- SELECT t.updated_at
276
- FROM tasks t
277
- WHERE t.user_id = u.id
278
- ORDER BY t.created_at DESC
279
- LIMIT 1
280
- ) AS latest_task_updated_at
281
- FROM users u
282
- LEFT JOIN course_targets c ON c.user_id = u.id
283
- GROUP BY u.id
284
- ORDER BY u.student_id ASC
285
- """
286
- ).fetchall()
287
- return self._rows_to_dicts(rows)
288
 
289
  def add_course(self, user_id: int, category: str, course_id: str, course_index: str) -> int | None:
290
  now = utc_now()
@@ -293,210 +605,199 @@ class Database:
293
  normalized_course_index = self._normalize_course_identity(course_index)
294
  with self._cursor() as (_connection, cursor):
295
  cursor.execute(
296
- """
297
- INSERT OR IGNORE INTO course_targets (user_id, category, course_id, course_index, created_at)
298
- VALUES (?, ?, ?, ?, ?)
299
- """,
300
- (user_id, normalized_category, normalized_course_id, normalized_course_index, now),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  )
302
- return int(cursor.lastrowid) if cursor.lastrowid else None
303
 
304
  def delete_course(self, course_target_id: int) -> None:
305
  with self._cursor() as (_connection, cursor):
306
- cursor.execute("DELETE FROM course_targets WHERE id = ?", (course_target_id,))
 
 
 
307
 
308
  def remove_course_by_identity(self, user_id: int, category: str, course_id: str, course_index: str) -> None:
309
  with self._cursor() as (_connection, cursor):
310
  cursor.execute(
311
- """
312
- DELETE FROM course_targets
313
- WHERE user_id = ? AND category = ? AND course_id = ? AND course_index = ?
314
- """,
315
- (
316
- user_id,
317
- category,
318
- self._normalize_course_identity(course_id),
319
- self._normalize_course_identity(course_index),
320
- ),
321
  )
322
 
323
  def list_courses_for_user(self, user_id: int) -> list[dict[str, Any]]:
324
  with self._cursor() as (_connection, cursor):
325
- rows = cursor.execute(
326
- """
327
- SELECT *
328
- FROM course_targets
329
- WHERE user_id = ?
330
- ORDER BY category ASC, course_id ASC, course_index ASC
331
- """,
332
- (user_id,),
333
- ).fetchall()
334
- return self._rows_to_dicts(rows)
335
 
336
  def create_admin(self, username: str, password_hash: str) -> int:
337
  now = utc_now()
338
  with self._cursor() as (_connection, cursor):
339
  cursor.execute(
340
- """
341
- INSERT INTO admins (username, password_hash, created_at, updated_at)
342
- VALUES (?, ?, ?, ?)
343
- """,
344
- (username.strip(), password_hash, now, now),
 
 
345
  )
346
  return int(cursor.lastrowid)
347
 
348
  def get_admin_by_username(self, username: str) -> dict[str, Any] | None:
349
  with self._cursor() as (_connection, cursor):
350
- row = cursor.execute("SELECT * FROM admins WHERE username = ?", (username.strip(),)).fetchone()
 
 
 
 
351
  return None if row is None else dict(row)
352
 
353
  def list_admins(self) -> list[dict[str, Any]]:
354
  with self._cursor() as (_connection, cursor):
355
- rows = cursor.execute(
356
- "SELECT id, username, created_at, updated_at FROM admins ORDER BY username ASC"
357
- ).fetchall()
358
- return self._rows_to_dicts(rows)
359
 
360
  def find_active_task_for_user(self, user_id: int) -> dict[str, Any] | None:
361
  with self._cursor() as (_connection, cursor):
362
- row = cursor.execute(
363
- """
364
- SELECT *
365
- FROM tasks
366
- WHERE user_id = ? AND status IN ('pending', 'running', 'cancel_requested')
367
- ORDER BY created_at DESC
368
- LIMIT 1
369
- """,
370
- (user_id,),
371
- ).fetchone()
372
  return None if row is None else dict(row)
373
 
374
  def create_task(self, user_id: int, requested_by: str, requested_by_role: str) -> int:
375
  now = utc_now()
376
  with self._cursor() as (_connection, cursor):
377
  cursor.execute(
378
- """
379
- INSERT INTO tasks (user_id, status, requested_by, requested_by_role, created_at, updated_at)
380
- VALUES (?, 'pending', ?, ?, ?, ?)
381
- """,
382
- (user_id, requested_by, requested_by_role, now, now),
 
 
 
 
 
 
 
383
  )
384
  return int(cursor.lastrowid)
385
 
386
  def get_task(self, task_id: int) -> dict[str, Any] | None:
 
 
 
 
 
 
387
  with self._cursor() as (_connection, cursor):
388
- row = cursor.execute(
389
- """
390
- SELECT
391
- t.*,
392
- u.student_id,
393
- u.display_name,
394
- u.refresh_interval_seconds
395
- FROM tasks t
396
- JOIN users u ON u.id = t.user_id
397
- WHERE t.id = ?
398
- """,
399
- (task_id,),
400
- ).fetchone()
401
  return None if row is None else dict(row)
402
 
403
  def get_latest_task_for_user(self, user_id: int) -> dict[str, Any] | None:
 
 
 
 
 
 
 
 
404
  with self._cursor() as (_connection, cursor):
405
- row = cursor.execute(
406
- """
407
- SELECT
408
- t.*,
409
- u.student_id,
410
- u.display_name,
411
- u.refresh_interval_seconds
412
- FROM tasks t
413
- JOIN users u ON u.id = t.user_id
414
- WHERE t.user_id = ?
415
- ORDER BY t.created_at DESC
416
- LIMIT 1
417
- """,
418
- (user_id,),
419
- ).fetchone()
420
  return None if row is None else dict(row)
421
 
422
  def list_pending_tasks(self, limit: int) -> list[dict[str, Any]]:
 
 
 
 
 
 
 
 
423
  with self._cursor() as (_connection, cursor):
424
- rows = cursor.execute(
425
- """
426
- SELECT
427
- t.*,
428
- u.student_id,
429
- u.display_name,
430
- u.password_encrypted,
431
- u.is_active,
432
- u.refresh_interval_seconds
433
- FROM tasks t
434
- JOIN users u ON u.id = t.user_id
435
- WHERE t.status = 'pending'
436
- ORDER BY t.created_at ASC
437
- LIMIT ?
438
- """,
439
- (limit,),
440
- ).fetchall()
441
- return self._rows_to_dicts(rows)
442
 
443
  def list_recent_tasks(self, limit: int = 20) -> list[dict[str, Any]]:
 
 
 
 
 
 
 
444
  with self._cursor() as (_connection, cursor):
445
- rows = cursor.execute(
446
- """
447
- SELECT
448
- t.*,
449
- u.student_id,
450
- u.display_name,
451
- u.refresh_interval_seconds
452
- FROM tasks t
453
- JOIN users u ON u.id = t.user_id
454
- ORDER BY t.created_at DESC
455
- LIMIT ?
456
- """,
457
- (limit,),
458
- ).fetchall()
459
- return self._rows_to_dicts(rows)
460
-
461
  def mark_task_running(self, task_id: int) -> None:
462
  now = utc_now()
463
  with self._cursor() as (_connection, cursor):
464
  cursor.execute(
465
- """
466
- UPDATE tasks
467
- SET status = 'running',
468
- started_at = COALESCE(started_at, ?),
469
- updated_at = ?,
470
- last_error = ''
471
- WHERE id = ?
472
- """,
473
- (now, now, task_id),
474
  )
475
 
476
  def finish_task(self, task_id: int, status: str, last_error: str = "") -> None:
477
  now = utc_now()
478
  with self._cursor() as (_connection, cursor):
479
  cursor.execute(
480
- """
481
- UPDATE tasks
482
- SET status = ?,
483
- finished_at = ?,
484
- updated_at = ?,
485
- last_error = ?
486
- WHERE id = ?
487
- """,
488
- (status, now, now, last_error, task_id),
489
  )
490
 
491
  def update_task_status(self, task_id: int, status: str, last_error: str = "") -> None:
492
  with self._cursor() as (_connection, cursor):
493
  cursor.execute(
494
- """
495
- UPDATE tasks
496
- SET status = ?, updated_at = ?, last_error = ?
497
- WHERE id = ?
498
- """,
499
- (status, utc_now(), last_error, task_id),
 
500
  )
501
 
502
  def increment_task_attempts(self, task_id: int, delta: int = 1) -> None:
@@ -505,13 +806,8 @@ class Database:
505
  return
506
  with self._cursor() as (_connection, cursor):
507
  cursor.execute(
508
- """
509
- UPDATE tasks
510
- SET total_attempts = COALESCE(total_attempts, 0) + ?,
511
- updated_at = ?
512
- WHERE id = ?
513
- """,
514
- (increment, utc_now(), task_id),
515
  )
516
 
517
  def increment_task_errors(self, task_id: int, delta: int = 1) -> None:
@@ -520,13 +816,8 @@ class Database:
520
  return
521
  with self._cursor() as (_connection, cursor):
522
  cursor.execute(
523
- """
524
- UPDATE tasks
525
- SET total_errors = COALESCE(total_errors, 0) + ?,
526
- updated_at = ?
527
- WHERE id = ?
528
- """,
529
- (increment, utc_now(), task_id),
530
  )
531
 
532
  def request_task_stop(self, task_id: int) -> bool:
@@ -535,12 +826,8 @@ class Database:
535
  return False
536
  with self._cursor() as (_connection, cursor):
537
  cursor.execute(
538
- """
539
- UPDATE tasks
540
- SET status = 'cancel_requested', updated_at = ?
541
- WHERE id = ?
542
- """,
543
- (utc_now(), task_id),
544
  )
545
  return True
546
 
@@ -548,102 +835,246 @@ class Database:
548
  now = utc_now()
549
  with self._cursor() as (_connection, cursor):
550
  cursor.execute(
551
- """
552
- UPDATE tasks
553
- SET status = 'stopped',
554
- finished_at = COALESCE(finished_at, ?),
555
- updated_at = ?
556
- WHERE status IN ('pending', 'running', 'cancel_requested')
557
- """,
558
- (now, now),
559
  )
560
 
561
  def add_log(self, task_id: int | None, user_id: int | None, scope: str, level: str, message: str) -> int:
562
  now = utc_now()
563
  with self._cursor() as (_connection, cursor):
564
  cursor.execute(
565
- """
566
- INSERT INTO logs (task_id, user_id, scope, level, message, created_at)
567
- VALUES (?, ?, ?, ?, ?, ?)
568
- """,
569
- (task_id, user_id, scope, level.upper(), message, now),
 
 
 
 
570
  )
571
  return int(cursor.lastrowid)
572
 
573
  def list_recent_logs(self, *, user_id: int | None = None, limit: int = 120) -> list[dict[str, Any]]:
574
  if user_id is None:
575
- query = """
576
- SELECT
577
- l.*,
578
- u.student_id,
579
- u.display_name
580
  FROM logs l
581
  LEFT JOIN users u ON u.id = l.user_id
582
  ORDER BY l.id DESC
583
- LIMIT ?
584
  """
585
- params = (limit,)
586
  else:
587
- query = """
588
- SELECT
589
- l.*,
590
- u.student_id,
591
- u.display_name
592
  FROM logs l
593
  LEFT JOIN users u ON u.id = l.user_id
594
- WHERE l.user_id = ?
595
  ORDER BY l.id DESC
596
- LIMIT ?
597
  """
598
- params = (user_id, limit)
599
-
600
  with self._cursor() as (_connection, cursor):
601
- rows = cursor.execute(query, params).fetchall()
 
602
  return list(reversed(self._rows_to_dicts(rows)))
603
 
604
  def list_logs_after(self, after_id: int, *, user_id: int | None = None, limit: int = 100) -> list[dict[str, Any]]:
605
  if user_id is None:
606
- query = """
607
- SELECT
608
- l.*,
609
- u.student_id,
610
- u.display_name
611
  FROM logs l
612
  LEFT JOIN users u ON u.id = l.user_id
613
- WHERE l.id > ?
614
  ORDER BY l.id ASC
615
- LIMIT ?
616
  """
617
- params = (after_id, limit)
618
  else:
619
- query = """
620
- SELECT
621
- l.*,
622
- u.student_id,
623
- u.display_name
624
  FROM logs l
625
  LEFT JOIN users u ON u.id = l.user_id
626
- WHERE l.id > ? AND l.user_id = ?
627
  ORDER BY l.id ASC
628
- LIMIT ?
629
  """
630
- params = (after_id, user_id, limit)
631
-
632
  with self._cursor() as (_connection, cursor):
633
- rows = cursor.execute(query, params).fetchall()
634
- return self._rows_to_dicts(rows)
635
 
636
  def get_admin_stats(self) -> dict[str, int]:
637
  with self._cursor() as (_connection, cursor):
638
- users_count = cursor.execute("SELECT COUNT(*) AS total FROM users").fetchone()["total"]
639
- courses_count = cursor.execute("SELECT COUNT(*) AS total FROM course_targets").fetchone()["total"]
640
- admins_count = cursor.execute("SELECT COUNT(*) AS total FROM admins").fetchone()["total"]
641
- running_count = cursor.execute("SELECT COUNT(*) AS total FROM tasks WHERE status = 'running'").fetchone()["total"]
642
- pending_count = cursor.execute("SELECT COUNT(*) AS total FROM tasks WHERE status = 'pending'").fetchone()["total"]
 
 
 
 
 
 
 
 
 
643
  return {
644
- "users_count": int(users_count),
645
- "courses_count": int(courses_count),
646
- "admins_count": int(admins_count) + 1,
647
- "running_count": int(running_count),
648
- "pending_count": int(pending_count),
 
 
649
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
 
3
+ import secrets
4
  import sqlite3
5
+ import string
6
  from contextlib import contextmanager
7
  from datetime import datetime, timezone
8
  from pathlib import Path
9
  from typing import Any, Iterator
10
+ from urllib.parse import parse_qs, unquote, urlparse
11
+
12
+ try:
13
+ import pymysql
14
+ from pymysql.cursors import DictCursor as MySQLDictCursor
15
+ except ImportError: # pragma: no cover - optional dependency in local SQLite mode
16
+ pymysql = None
17
+ MySQLDictCursor = None
18
 
19
 
20
  ACTIVE_TASK_STATUSES = {"pending", "running", "cancel_requested"}
21
  DEFAULT_REFRESH_INTERVAL_SECONDS = 10
22
  MIN_REFRESH_INTERVAL_SECONDS = 1
23
  MAX_REFRESH_INTERVAL_SECONDS = 120
24
+ DEFAULT_REGISTRATION_CODE_MAX_USES = 1
25
+ REGISTRATION_CODE_ALPHABET = string.ascii_uppercase + string.digits
26
 
27
 
28
  def utc_now() -> str:
 
37
  return max(MIN_REFRESH_INTERVAL_SECONDS, min(MAX_REFRESH_INTERVAL_SECONDS, normalized))
38
 
39
 
40
+ def normalize_registration_code(value: str) -> str:
41
+ return str(value or "").strip().upper()
42
+
43
+
44
  class Database:
45
+ def __init__(
46
+ self,
47
+ path: Path | str,
48
+ default_parallel_limit: int = 4,
49
+ *,
50
+ mysql_ssl_ca_path: Path | str | None = None,
51
+ ) -> None:
52
  self.default_parallel_limit = default_parallel_limit
53
+ self._mysql_ssl_ca_path = Path(mysql_ssl_ca_path).resolve() if mysql_ssl_ca_path else None
54
+ self._dialect = "sqlite"
55
+ self._sqlite_path: Path | None = None
56
+ self._mysql_options: dict[str, Any] = {}
57
+ self._mysql_database_name = ""
58
+
59
+ raw_path = str(path)
60
+ if isinstance(path, Path) or "://" not in raw_path:
61
+ self._sqlite_path = Path(path).resolve()
62
+ self._sqlite_path.parent.mkdir(parents=True, exist_ok=True)
63
+ self.path = self._sqlite_path
64
+ elif raw_path.startswith("sqlite:///"):
65
+ self._sqlite_path = Path(raw_path[len("sqlite:///") :]).resolve()
66
+ self._sqlite_path.parent.mkdir(parents=True, exist_ok=True)
67
+ self.path = self._sqlite_path
68
+ elif raw_path.startswith("mysql://") or raw_path.startswith("mysql+pymysql://"):
69
+ self._dialect = "mysql"
70
+ self._mysql_options = self._parse_mysql_options(raw_path)
71
+ self._mysql_database_name = str(self._mysql_options["database"])
72
+ self.path = self._mysql_display_label()
73
+ else:
74
+ raise ValueError(f"Unsupported database target: {raw_path}")
75
 
76
+ @property
77
+ def is_mysql(self) -> bool:
78
+ return self._dialect == "mysql"
79
+
80
+ def _mysql_display_label(self) -> str:
81
+ if not self._mysql_options:
82
+ return "mysql://unknown"
83
+ return (
84
+ f"mysql://{self._mysql_options.get('user', '')}@"
85
+ f"{self._mysql_options.get('host', '')}:{self._mysql_options.get('port', '')}/"
86
+ f"{self._mysql_options.get('database', '')}"
87
+ )
88
+
89
+ @staticmethod
90
+ def _parse_mysql_options(raw_url: str) -> dict[str, Any]:
91
+ normalized_url = raw_url.replace("mysql+pymysql://", "mysql://", 1)
92
+ parsed = urlparse(normalized_url)
93
+ return {
94
+ "host": parsed.hostname or "localhost",
95
+ "port": parsed.port or 3306,
96
+ "user": unquote(parsed.username or ""),
97
+ "password": unquote(parsed.password or ""),
98
+ "database": (parsed.path or "/").lstrip("/"),
99
+ "query": parse_qs(parsed.query),
100
+ }
101
+
102
+ def _connect(self):
103
+ if not self.is_mysql:
104
+ connection = sqlite3.connect(self._sqlite_path, timeout=30, check_same_thread=False)
105
+ connection.row_factory = sqlite3.Row
106
+ connection.execute("PRAGMA foreign_keys = ON")
107
+ connection.execute("PRAGMA journal_mode = WAL")
108
+ return connection
109
+
110
+ if pymysql is None or MySQLDictCursor is None:
111
+ raise RuntimeError("PyMySQL is required when DATABASE_URL/SQL_PASSWORD enables MySQL persistence.")
112
+
113
+ ssl_config: dict[str, str] | None = None
114
+ ssl_mode = str(self._mysql_options.get("query", {}).get("ssl-mode", [""])[0]).upper()
115
+ if ssl_mode == "REQUIRED" or self._mysql_ssl_ca_path is not None:
116
+ if self._mysql_ssl_ca_path is None or not self._mysql_ssl_ca_path.exists():
117
+ raise RuntimeError("MySQL SSL is enabled but ca.pem was not found.")
118
+ ssl_config = {"ca": str(self._mysql_ssl_ca_path)}
119
+
120
+ return pymysql.connect(
121
+ host=self._mysql_options["host"],
122
+ port=int(self._mysql_options["port"]),
123
+ user=self._mysql_options["user"],
124
+ password=self._mysql_options["password"],
125
+ database=self._mysql_options["database"],
126
+ charset="utf8mb4",
127
+ cursorclass=MySQLDictCursor,
128
+ autocommit=False,
129
+ ssl=ssl_config,
130
+ connect_timeout=10,
131
+ read_timeout=30,
132
+ write_timeout=30,
133
+ )
134
 
135
  @contextmanager
136
+ def _cursor(self) -> Iterator[tuple[Any, Any]]:
137
  connection = self._connect()
138
  try:
139
  cursor = connection.cursor()
 
142
  finally:
143
  connection.close()
144
 
145
+ def _placeholder(self, name: str) -> str:
146
+ return f":{name}" if not self.is_mysql else f"%({name})s"
147
+
148
  @staticmethod
149
+ def _rows_to_dicts(rows: list[Any]) -> list[dict[str, Any]]:
150
  return [dict(row) for row in rows]
151
 
152
  @staticmethod
153
  def _normalize_course_identity(value: str) -> str:
154
  return str(value or "").strip().upper()
155
 
156
+ def _column_exists(self, cursor: Any, table_name: str, column_name: str) -> bool:
157
+ if not self.is_mysql:
158
+ rows = cursor.execute(f"PRAGMA table_info({table_name})").fetchall()
159
+ return any(str(row[1]) == column_name for row in rows)
160
+
161
+ query = (
162
+ f"SELECT COUNT(*) AS total FROM information_schema.columns "
163
+ f"WHERE table_schema = {self._placeholder('schema_name')} "
164
+ f"AND table_name = {self._placeholder('table_name')} "
165
+ f"AND column_name = {self._placeholder('column_name')}"
166
+ )
167
+ cursor.execute(
168
+ query,
169
+ {
170
+ "schema_name": self._mysql_database_name,
171
+ "table_name": table_name,
172
+ "column_name": column_name,
173
+ },
174
+ )
175
+ row = cursor.fetchone()
176
+ return int(dict(row).get("total", 0)) > 0
177
 
178
  def _ensure_column(self, table_name: str, column_name: str, definition: str) -> None:
179
  with self._cursor() as (_connection, cursor):
 
181
  return
182
  cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {definition}")
183
 
184
+ def _create_tables_sqlite(self, cursor: Any) -> None:
185
+ cursor.executescript(
186
+ """
187
+ CREATE TABLE IF NOT EXISTS users (
188
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
189
+ student_id TEXT NOT NULL UNIQUE,
190
+ password_encrypted TEXT NOT NULL,
191
+ display_name TEXT NOT NULL DEFAULT '',
192
+ is_active INTEGER NOT NULL DEFAULT 1,
193
+ refresh_interval_seconds INTEGER NOT NULL DEFAULT 10,
194
+ created_at TEXT NOT NULL,
195
+ updated_at TEXT NOT NULL
196
+ );
197
+ CREATE TABLE IF NOT EXISTS course_targets (
198
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
199
+ user_id INTEGER NOT NULL,
200
+ category TEXT NOT NULL DEFAULT 'free',
201
+ course_id TEXT NOT NULL,
202
+ course_index TEXT NOT NULL,
203
+ created_at TEXT NOT NULL,
204
+ UNIQUE(user_id, category, course_id, course_index),
205
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
206
+ );
207
+ CREATE TABLE IF NOT EXISTS admins (
208
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
209
+ username TEXT NOT NULL UNIQUE,
210
+ password_hash TEXT NOT NULL,
211
+ created_at TEXT NOT NULL,
212
+ updated_at TEXT NOT NULL
213
+ );
214
+ CREATE TABLE IF NOT EXISTS app_settings (
215
+ `key` TEXT PRIMARY KEY,
216
+ value TEXT NOT NULL,
217
+ updated_at TEXT NOT NULL
218
+ );
219
+ CREATE TABLE IF NOT EXISTS tasks (
220
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
221
+ user_id INTEGER NOT NULL,
222
+ status TEXT NOT NULL,
223
+ requested_by TEXT NOT NULL,
224
+ requested_by_role TEXT NOT NULL,
225
+ last_error TEXT NOT NULL DEFAULT '',
226
+ total_attempts INTEGER NOT NULL DEFAULT 0,
227
+ total_errors INTEGER NOT NULL DEFAULT 0,
228
+ created_at TEXT NOT NULL,
229
+ started_at TEXT,
230
+ finished_at TEXT,
231
+ updated_at TEXT NOT NULL,
232
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
233
+ );
234
+ CREATE TABLE IF NOT EXISTS logs (
235
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
236
+ task_id INTEGER,
237
+ user_id INTEGER,
238
+ scope TEXT NOT NULL,
239
+ level TEXT NOT NULL,
240
+ message TEXT NOT NULL,
241
+ created_at TEXT NOT NULL,
242
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
243
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
244
+ );
245
+ CREATE TABLE IF NOT EXISTS user_schedules (
246
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
247
+ user_id INTEGER NOT NULL UNIQUE,
248
+ is_enabled INTEGER NOT NULL DEFAULT 0,
249
+ start_date TEXT,
250
+ end_date TEXT,
251
+ daily_start_time TEXT,
252
+ daily_stop_time TEXT,
253
+ last_auto_start_on TEXT NOT NULL DEFAULT '',
254
+ last_auto_stop_on TEXT NOT NULL DEFAULT '',
255
+ created_at TEXT NOT NULL,
256
+ updated_at TEXT NOT NULL,
257
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
258
+ );
259
+ CREATE TABLE IF NOT EXISTS registration_codes (
260
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
261
+ code TEXT NOT NULL UNIQUE,
262
+ note TEXT NOT NULL DEFAULT '',
263
+ is_active INTEGER NOT NULL DEFAULT 1,
264
+ max_uses INTEGER NOT NULL DEFAULT 1,
265
+ used_count INTEGER NOT NULL DEFAULT 0,
266
+ created_by TEXT NOT NULL DEFAULT '',
267
+ used_by_user_id INTEGER,
268
+ used_at TEXT,
269
+ created_at TEXT NOT NULL,
270
+ updated_at TEXT NOT NULL,
271
+ FOREIGN KEY(used_by_user_id) REFERENCES users(id) ON DELETE SET NULL
272
+ );
273
+ """
274
+ )
275
+ def _create_tables_mysql(self, cursor: Any) -> None:
276
+ statements = [
277
+ """
278
+ CREATE TABLE IF NOT EXISTS users (
279
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
280
+ student_id VARCHAR(32) NOT NULL UNIQUE,
281
+ password_encrypted TEXT NOT NULL,
282
+ display_name VARCHAR(255) NOT NULL DEFAULT '',
283
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
284
+ refresh_interval_seconds INT NOT NULL DEFAULT 10,
285
+ created_at VARCHAR(32) NOT NULL,
286
+ updated_at VARCHAR(32) NOT NULL
287
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
288
+ """,
289
+ """
290
+ CREATE TABLE IF NOT EXISTS course_targets (
291
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
292
+ user_id BIGINT NOT NULL,
293
+ category VARCHAR(32) NOT NULL DEFAULT 'free',
294
+ course_id VARCHAR(64) NOT NULL,
295
+ course_index VARCHAR(32) NOT NULL,
296
+ created_at VARCHAR(32) NOT NULL,
297
+ UNIQUE KEY uq_course_targets_identity (user_id, category, course_id, course_index),
298
+ CONSTRAINT fk_course_targets_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
299
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
300
+ """,
301
+ """
302
+ CREATE TABLE IF NOT EXISTS admins (
303
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
304
+ username VARCHAR(255) NOT NULL UNIQUE,
305
+ password_hash TEXT NOT NULL,
306
+ created_at VARCHAR(32) NOT NULL,
307
+ updated_at VARCHAR(32) NOT NULL
308
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
309
+ """,
310
+ """
311
+ CREATE TABLE IF NOT EXISTS app_settings (
312
+ `key` VARCHAR(191) PRIMARY KEY,
313
+ value TEXT NOT NULL,
314
+ updated_at VARCHAR(32) NOT NULL
315
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
316
+ """,
317
+ """
318
+ CREATE TABLE IF NOT EXISTS tasks (
319
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
320
+ user_id BIGINT NOT NULL,
321
+ status VARCHAR(32) NOT NULL,
322
+ requested_by VARCHAR(255) NOT NULL,
323
+ requested_by_role VARCHAR(32) NOT NULL,
324
+ last_error TEXT NOT NULL,
325
+ total_attempts INT NOT NULL DEFAULT 0,
326
+ total_errors INT NOT NULL DEFAULT 0,
327
+ created_at VARCHAR(32) NOT NULL,
328
+ started_at VARCHAR(32),
329
+ finished_at VARCHAR(32),
330
+ updated_at VARCHAR(32) NOT NULL,
331
+ CONSTRAINT fk_tasks_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
332
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
333
+ """,
334
+ """
335
+ CREATE TABLE IF NOT EXISTS logs (
336
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
337
+ task_id BIGINT NULL,
338
+ user_id BIGINT NULL,
339
+ scope VARCHAR(64) NOT NULL,
340
+ level VARCHAR(32) NOT NULL,
341
+ message TEXT NOT NULL,
342
+ created_at VARCHAR(32) NOT NULL,
343
+ CONSTRAINT fk_logs_task FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
344
+ CONSTRAINT fk_logs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
345
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
346
+ """,
347
+ """
348
+ CREATE TABLE IF NOT EXISTS user_schedules (
349
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
350
+ user_id BIGINT NOT NULL UNIQUE,
351
+ is_enabled TINYINT(1) NOT NULL DEFAULT 0,
352
+ start_date VARCHAR(10) NULL,
353
+ end_date VARCHAR(10) NULL,
354
+ daily_start_time VARCHAR(5) NULL,
355
+ daily_stop_time VARCHAR(5) NULL,
356
+ last_auto_start_on VARCHAR(10) NOT NULL DEFAULT '',
357
+ last_auto_stop_on VARCHAR(10) NOT NULL DEFAULT '',
358
+ created_at VARCHAR(32) NOT NULL,
359
+ updated_at VARCHAR(32) NOT NULL,
360
+ CONSTRAINT fk_user_schedules_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
361
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
362
+ """,
363
+ """
364
+ CREATE TABLE IF NOT EXISTS registration_codes (
365
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
366
+ code VARCHAR(64) NOT NULL UNIQUE,
367
+ note TEXT NOT NULL,
368
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
369
+ max_uses INT NOT NULL DEFAULT 1,
370
+ used_count INT NOT NULL DEFAULT 0,
371
+ created_by VARCHAR(255) NOT NULL DEFAULT '',
372
+ used_by_user_id BIGINT NULL,
373
+ used_at VARCHAR(32) NULL,
374
+ created_at VARCHAR(32) NOT NULL,
375
+ updated_at VARCHAR(32) NOT NULL,
376
+ CONSTRAINT fk_registration_codes_user FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE SET NULL
377
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
378
+ """,
379
+ ]
380
+ for statement in statements:
381
+ cursor.execute(statement)
382
+
383
  def init_db(self) -> None:
384
  with self._cursor() as (_connection, cursor):
385
+ if self.is_mysql:
386
+ self._create_tables_mysql(cursor)
387
+ else:
388
+ self._create_tables_sqlite(cursor)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
 
390
  self._ensure_column(
391
  "users",
392
  "refresh_interval_seconds",
393
+ f"INT NOT NULL DEFAULT {DEFAULT_REFRESH_INTERVAL_SECONDS}",
394
  )
395
+ self._ensure_column("tasks", "total_attempts", "INT NOT NULL DEFAULT 0")
396
+ self._ensure_column("tasks", "total_errors", "INT NOT NULL DEFAULT 0")
397
 
398
  if self.get_setting("parallel_limit") is None:
399
  self.set_setting("parallel_limit", str(self.default_parallel_limit))
400
 
401
  def get_setting(self, key: str) -> str | None:
402
+ query = f"SELECT value FROM app_settings WHERE `key` = {self._placeholder('key')}"
403
  with self._cursor() as (_connection, cursor):
404
+ cursor.execute(query, {"key": key})
405
+ row = cursor.fetchone()
406
+ return None if row is None else str(dict(row)["value"])
407
 
408
  def set_setting(self, key: str, value: str) -> None:
409
  now = utc_now()
410
  with self._cursor() as (_connection, cursor):
411
  cursor.execute(
412
+ f"SELECT value FROM app_settings WHERE `key` = {self._placeholder('key')}",
413
+ {"key": key},
 
 
 
 
 
 
414
  )
415
+ existing = cursor.fetchone()
416
+ if existing is None:
417
+ cursor.execute(
418
+ f"INSERT INTO app_settings (`key`, value, updated_at) VALUES ({self._placeholder('key')}, {self._placeholder('value')}, {self._placeholder('updated_at')})",
419
+ {"key": key, "value": value, "updated_at": now},
420
+ )
421
+ else:
422
+ cursor.execute(
423
+ f"UPDATE app_settings SET value = {self._placeholder('value')}, updated_at = {self._placeholder('updated_at')} WHERE `key` = {self._placeholder('key')}",
424
+ {"key": key, "value": value, "updated_at": now},
425
+ )
426
 
427
  def get_parallel_limit(self) -> int:
428
  raw_value = self.get_setting("parallel_limit")
 
448
  refresh_interval = clamp_refresh_interval_seconds(refresh_interval_seconds)
449
  with self._cursor() as (_connection, cursor):
450
  cursor.execute(
451
+ f"INSERT INTO users (student_id, password_encrypted, display_name, is_active, refresh_interval_seconds, created_at, updated_at) VALUES ({self._placeholder('student_id')}, {self._placeholder('password_encrypted')}, {self._placeholder('display_name')}, {self._placeholder('is_active')}, {self._placeholder('refresh_interval_seconds')}, {self._placeholder('created_at')}, {self._placeholder('updated_at')})",
452
+ {
453
+ "student_id": student_id.strip(),
454
+ "password_encrypted": password_encrypted,
455
+ "display_name": display_name.strip(),
456
+ "is_active": 1,
457
+ "refresh_interval_seconds": refresh_interval,
458
+ "created_at": now,
459
+ "updated_at": now,
460
+ },
 
 
 
461
  )
462
  return int(cursor.lastrowid)
463
 
464
+ def register_user_with_code(
465
+ self,
466
+ registration_code: str,
467
+ student_id: str,
468
+ password_encrypted: str,
469
+ display_name: str = "",
470
+ *,
471
+ refresh_interval_seconds: int = DEFAULT_REFRESH_INTERVAL_SECONDS,
472
+ ) -> int:
473
+ now = utc_now()
474
+ normalized_code = normalize_registration_code(registration_code)
475
+ normalized_student_id = str(student_id or "").strip()
476
+ refresh_interval = clamp_refresh_interval_seconds(refresh_interval_seconds)
477
+ with self._cursor() as (_connection, cursor):
478
+ cursor.execute(
479
+ f"SELECT * FROM registration_codes WHERE code = {self._placeholder('code')}",
480
+ {"code": normalized_code},
481
+ )
482
+ code_row = cursor.fetchone()
483
+ if code_row is None:
484
+ raise ValueError("注册码不存在。")
485
+ code_payload = dict(code_row)
486
+ if not bool(code_payload.get("is_active", 0)):
487
+ raise ValueError("注册码已停用。")
488
+ if int(code_payload.get("used_count", 0)) >= int(code_payload.get("max_uses", 1)):
489
+ raise ValueError("注册码已使用完毕。")
490
+ cursor.execute(
491
+ f"SELECT id FROM users WHERE student_id = {self._placeholder('student_id')}",
492
+ {"student_id": normalized_student_id},
493
+ )
494
+ if cursor.fetchone() is not None:
495
+ raise ValueError("该学号已经注册。")
496
+ cursor.execute(
497
+ f"INSERT INTO users (student_id, password_encrypted, display_name, is_active, refresh_interval_seconds, created_at, updated_at) VALUES ({self._placeholder('student_id')}, {self._placeholder('password_encrypted')}, {self._placeholder('display_name')}, {self._placeholder('is_active')}, {self._placeholder('refresh_interval_seconds')}, {self._placeholder('created_at')}, {self._placeholder('updated_at')})",
498
+ {
499
+ "student_id": normalized_student_id,
500
+ "password_encrypted": password_encrypted,
501
+ "display_name": display_name.strip(),
502
+ "is_active": 1,
503
+ "refresh_interval_seconds": refresh_interval,
504
+ "created_at": now,
505
+ "updated_at": now,
506
+ },
507
+ )
508
+ user_id = int(cursor.lastrowid)
509
+ used_count = int(code_payload.get("used_count", 0)) + 1
510
+ max_uses = int(code_payload.get("max_uses", 1))
511
+ cursor.execute(
512
+ f"UPDATE registration_codes SET used_count = {self._placeholder('used_count')}, used_by_user_id = {self._placeholder('used_by_user_id')}, used_at = {self._placeholder('used_at')}, is_active = {self._placeholder('is_active')}, updated_at = {self._placeholder('updated_at')} WHERE id = {self._placeholder('id')}",
513
+ {
514
+ "id": int(code_payload["id"]),
515
+ "used_count": used_count,
516
+ "used_by_user_id": user_id,
517
+ "used_at": now,
518
+ "is_active": 0 if used_count >= max_uses else 1,
519
+ "updated_at": now,
520
+ },
521
+ )
522
+ return user_id
523
  def update_user(
524
  self,
525
  user_id: int,
 
530
  refresh_interval_seconds: int | None = None,
531
  ) -> None:
532
  assignments: list[str] = []
533
+ values: dict[str, Any] = {"user_id": user_id, "updated_at": utc_now()}
534
  if password_encrypted is not None:
535
+ assignments.append(f"password_encrypted = {self._placeholder('password_encrypted')}")
536
+ values["password_encrypted"] = password_encrypted
537
  if display_name is not None:
538
+ assignments.append(f"display_name = {self._placeholder('display_name')}")
539
+ values["display_name"] = display_name.strip()
540
  if is_active is not None:
541
+ assignments.append(f"is_active = {self._placeholder('is_active')}")
542
+ values["is_active"] = 1 if is_active else 0
543
  if refresh_interval_seconds is not None:
544
+ assignments.append(f"refresh_interval_seconds = {self._placeholder('refresh_interval_seconds')}")
545
+ values["refresh_interval_seconds"] = clamp_refresh_interval_seconds(refresh_interval_seconds)
546
  if not assignments:
547
  return
548
+ assignments.append(f"updated_at = {self._placeholder('updated_at')}")
549
+ with self._cursor() as (_connection, cursor):
550
+ cursor.execute(
551
+ f"UPDATE users SET {', '.join(assignments)} WHERE id = {self._placeholder('user_id')}",
552
+ values,
553
+ )
554
 
555
+ def delete_user(self, user_id: int) -> None:
 
 
 
556
  with self._cursor() as (_connection, cursor):
557
+ cursor.execute(
558
+ f"DELETE FROM users WHERE id = {self._placeholder('user_id')}",
559
+ {"user_id": user_id},
560
+ )
561
 
562
  def toggle_user_active(self, user_id: int) -> dict[str, Any] | None:
563
  user = self.get_user(user_id)
 
568
 
569
  def get_user(self, user_id: int) -> dict[str, Any] | None:
570
  with self._cursor() as (_connection, cursor):
571
+ cursor.execute(
572
+ f"SELECT * FROM users WHERE id = {self._placeholder('user_id')}",
573
+ {"user_id": user_id},
574
+ )
575
+ row = cursor.fetchone()
576
  return None if row is None else dict(row)
577
 
578
  def get_user_by_student_id(self, student_id: str) -> dict[str, Any] | None:
579
  with self._cursor() as (_connection, cursor):
580
+ cursor.execute(
581
+ f"SELECT * FROM users WHERE student_id = {self._placeholder('student_id')}",
582
+ {"student_id": student_id.strip()},
583
+ )
584
+ row = cursor.fetchone()
585
  return None if row is None else dict(row)
586
 
587
  def list_users(self) -> list[dict[str, Any]]:
588
+ query = f"""
589
+ SELECT
590
+ u.*,
591
+ (SELECT COUNT(*) FROM course_targets c WHERE c.user_id = u.id) AS course_count,
592
+ (SELECT t.status FROM tasks t WHERE t.user_id = u.id ORDER BY t.created_at DESC LIMIT 1) AS latest_task_status,
593
+ (SELECT t.updated_at FROM tasks t WHERE t.user_id = u.id ORDER BY t.created_at DESC LIMIT 1) AS latest_task_updated_at
594
+ FROM users u
595
+ ORDER BY u.student_id ASC
596
+ """
597
  with self._cursor() as (_connection, cursor):
598
+ cursor.execute(query)
599
+ return self._rows_to_dicts(cursor.fetchall())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
600
 
601
  def add_course(self, user_id: int, category: str, course_id: str, course_index: str) -> int | None:
602
  now = utc_now()
 
605
  normalized_course_index = self._normalize_course_identity(course_index)
606
  with self._cursor() as (_connection, cursor):
607
  cursor.execute(
608
+ f"SELECT id FROM course_targets WHERE user_id = {self._placeholder('user_id')} AND category = {self._placeholder('category')} AND course_id = {self._placeholder('course_id')} AND course_index = {self._placeholder('course_index')}",
609
+ {
610
+ "user_id": user_id,
611
+ "category": normalized_category,
612
+ "course_id": normalized_course_id,
613
+ "course_index": normalized_course_index,
614
+ },
615
+ )
616
+ if cursor.fetchone() is not None:
617
+ return None
618
+ cursor.execute(
619
+ f"INSERT INTO course_targets (user_id, category, course_id, course_index, created_at) VALUES ({self._placeholder('user_id')}, {self._placeholder('category')}, {self._placeholder('course_id')}, {self._placeholder('course_index')}, {self._placeholder('created_at')})",
620
+ {
621
+ "user_id": user_id,
622
+ "category": normalized_category,
623
+ "course_id": normalized_course_id,
624
+ "course_index": normalized_course_index,
625
+ "created_at": now,
626
+ },
627
  )
628
+ return int(cursor.lastrowid)
629
 
630
  def delete_course(self, course_target_id: int) -> None:
631
  with self._cursor() as (_connection, cursor):
632
+ cursor.execute(
633
+ f"DELETE FROM course_targets WHERE id = {self._placeholder('course_target_id')}",
634
+ {"course_target_id": course_target_id},
635
+ )
636
 
637
  def remove_course_by_identity(self, user_id: int, category: str, course_id: str, course_index: str) -> None:
638
  with self._cursor() as (_connection, cursor):
639
  cursor.execute(
640
+ f"DELETE FROM course_targets WHERE user_id = {self._placeholder('user_id')} AND category = {self._placeholder('category')} AND course_id = {self._placeholder('course_id')} AND course_index = {self._placeholder('course_index')}",
641
+ {
642
+ "user_id": user_id,
643
+ "category": category,
644
+ "course_id": self._normalize_course_identity(course_id),
645
+ "course_index": self._normalize_course_identity(course_index),
646
+ },
 
 
 
647
  )
648
 
649
  def list_courses_for_user(self, user_id: int) -> list[dict[str, Any]]:
650
  with self._cursor() as (_connection, cursor):
651
+ cursor.execute(
652
+ f"SELECT * FROM course_targets WHERE user_id = {self._placeholder('user_id')} ORDER BY category ASC, course_id ASC, course_index ASC",
653
+ {"user_id": user_id},
654
+ )
655
+ return self._rows_to_dicts(cursor.fetchall())
 
 
 
 
 
656
 
657
  def create_admin(self, username: str, password_hash: str) -> int:
658
  now = utc_now()
659
  with self._cursor() as (_connection, cursor):
660
  cursor.execute(
661
+ f"INSERT INTO admins (username, password_hash, created_at, updated_at) VALUES ({self._placeholder('username')}, {self._placeholder('password_hash')}, {self._placeholder('created_at')}, {self._placeholder('updated_at')})",
662
+ {
663
+ "username": username.strip(),
664
+ "password_hash": password_hash,
665
+ "created_at": now,
666
+ "updated_at": now,
667
+ },
668
  )
669
  return int(cursor.lastrowid)
670
 
671
  def get_admin_by_username(self, username: str) -> dict[str, Any] | None:
672
  with self._cursor() as (_connection, cursor):
673
+ cursor.execute(
674
+ f"SELECT * FROM admins WHERE username = {self._placeholder('username')}",
675
+ {"username": username.strip()},
676
+ )
677
+ row = cursor.fetchone()
678
  return None if row is None else dict(row)
679
 
680
  def list_admins(self) -> list[dict[str, Any]]:
681
  with self._cursor() as (_connection, cursor):
682
+ cursor.execute("SELECT id, username, created_at, updated_at FROM admins ORDER BY username ASC")
683
+ return self._rows_to_dicts(cursor.fetchall())
 
 
684
 
685
  def find_active_task_for_user(self, user_id: int) -> dict[str, Any] | None:
686
  with self._cursor() as (_connection, cursor):
687
+ cursor.execute(
688
+ f"SELECT * FROM tasks WHERE user_id = {self._placeholder('user_id')} AND status IN ('pending', 'running', 'cancel_requested') ORDER BY created_at DESC LIMIT 1",
689
+ {"user_id": user_id},
690
+ )
691
+ row = cursor.fetchone()
 
 
 
 
 
692
  return None if row is None else dict(row)
693
 
694
  def create_task(self, user_id: int, requested_by: str, requested_by_role: str) -> int:
695
  now = utc_now()
696
  with self._cursor() as (_connection, cursor):
697
  cursor.execute(
698
+ f"INSERT INTO tasks (user_id, status, requested_by, requested_by_role, last_error, total_attempts, total_errors, created_at, updated_at) VALUES ({self._placeholder('user_id')}, {self._placeholder('status')}, {self._placeholder('requested_by')}, {self._placeholder('requested_by_role')}, {self._placeholder('last_error')}, {self._placeholder('total_attempts')}, {self._placeholder('total_errors')}, {self._placeholder('created_at')}, {self._placeholder('updated_at')})",
699
+ {
700
+ "user_id": user_id,
701
+ "status": "pending",
702
+ "requested_by": requested_by,
703
+ "requested_by_role": requested_by_role,
704
+ "last_error": "",
705
+ "total_attempts": 0,
706
+ "total_errors": 0,
707
+ "created_at": now,
708
+ "updated_at": now,
709
+ },
710
  )
711
  return int(cursor.lastrowid)
712
 
713
  def get_task(self, task_id: int) -> dict[str, Any] | None:
714
+ query = f"""
715
+ SELECT t.*, u.student_id, u.display_name, u.refresh_interval_seconds
716
+ FROM tasks t
717
+ JOIN users u ON u.id = t.user_id
718
+ WHERE t.id = {self._placeholder('task_id')}
719
+ """
720
  with self._cursor() as (_connection, cursor):
721
+ cursor.execute(query, {"task_id": task_id})
722
+ row = cursor.fetchone()
 
 
 
 
 
 
 
 
 
 
 
723
  return None if row is None else dict(row)
724
 
725
  def get_latest_task_for_user(self, user_id: int) -> dict[str, Any] | None:
726
+ query = f"""
727
+ SELECT t.*, u.student_id, u.display_name, u.refresh_interval_seconds
728
+ FROM tasks t
729
+ JOIN users u ON u.id = t.user_id
730
+ WHERE t.user_id = {self._placeholder('user_id')}
731
+ ORDER BY t.created_at DESC
732
+ LIMIT 1
733
+ """
734
  with self._cursor() as (_connection, cursor):
735
+ cursor.execute(query, {"user_id": user_id})
736
+ row = cursor.fetchone()
 
 
 
 
 
 
 
 
 
 
 
 
 
737
  return None if row is None else dict(row)
738
 
739
  def list_pending_tasks(self, limit: int) -> list[dict[str, Any]]:
740
+ query = f"""
741
+ SELECT t.*, u.student_id, u.display_name, u.password_encrypted, u.is_active, u.refresh_interval_seconds
742
+ FROM tasks t
743
+ JOIN users u ON u.id = t.user_id
744
+ WHERE t.status = 'pending'
745
+ ORDER BY t.created_at ASC
746
+ LIMIT {self._placeholder('limit')}
747
+ """
748
  with self._cursor() as (_connection, cursor):
749
+ cursor.execute(query, {"limit": int(limit)})
750
+ return self._rows_to_dicts(cursor.fetchall())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751
 
752
  def list_recent_tasks(self, limit: int = 20) -> list[dict[str, Any]]:
753
+ query = f"""
754
+ SELECT t.*, u.student_id, u.display_name, u.refresh_interval_seconds
755
+ FROM tasks t
756
+ JOIN users u ON u.id = t.user_id
757
+ ORDER BY t.created_at DESC
758
+ LIMIT {self._placeholder('limit')}
759
+ """
760
  with self._cursor() as (_connection, cursor):
761
+ cursor.execute(query, {"limit": int(limit)})
762
+ return self._rows_to_dicts(cursor.fetchall())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
763
  def mark_task_running(self, task_id: int) -> None:
764
  now = utc_now()
765
  with self._cursor() as (_connection, cursor):
766
  cursor.execute(
767
+ f"UPDATE tasks SET status = {self._placeholder('status')}, started_at = COALESCE(started_at, {self._placeholder('started_at')}), updated_at = {self._placeholder('updated_at')}, last_error = {self._placeholder('last_error')} WHERE id = {self._placeholder('task_id')}",
768
+ {
769
+ "status": "running",
770
+ "started_at": now,
771
+ "updated_at": now,
772
+ "last_error": "",
773
+ "task_id": task_id,
774
+ },
 
775
  )
776
 
777
  def finish_task(self, task_id: int, status: str, last_error: str = "") -> None:
778
  now = utc_now()
779
  with self._cursor() as (_connection, cursor):
780
  cursor.execute(
781
+ f"UPDATE tasks SET status = {self._placeholder('status')}, finished_at = {self._placeholder('finished_at')}, updated_at = {self._placeholder('updated_at')}, last_error = {self._placeholder('last_error')} WHERE id = {self._placeholder('task_id')}",
782
+ {
783
+ "status": status,
784
+ "finished_at": now,
785
+ "updated_at": now,
786
+ "last_error": last_error,
787
+ "task_id": task_id,
788
+ },
 
789
  )
790
 
791
  def update_task_status(self, task_id: int, status: str, last_error: str = "") -> None:
792
  with self._cursor() as (_connection, cursor):
793
  cursor.execute(
794
+ f"UPDATE tasks SET status = {self._placeholder('status')}, updated_at = {self._placeholder('updated_at')}, last_error = {self._placeholder('last_error')} WHERE id = {self._placeholder('task_id')}",
795
+ {
796
+ "status": status,
797
+ "updated_at": utc_now(),
798
+ "last_error": last_error,
799
+ "task_id": task_id,
800
+ },
801
  )
802
 
803
  def increment_task_attempts(self, task_id: int, delta: int = 1) -> None:
 
806
  return
807
  with self._cursor() as (_connection, cursor):
808
  cursor.execute(
809
+ f"UPDATE tasks SET total_attempts = total_attempts + {self._placeholder('delta')}, updated_at = {self._placeholder('updated_at')} WHERE id = {self._placeholder('task_id')}",
810
+ {"delta": increment, "updated_at": utc_now(), "task_id": task_id},
 
 
 
 
 
811
  )
812
 
813
  def increment_task_errors(self, task_id: int, delta: int = 1) -> None:
 
816
  return
817
  with self._cursor() as (_connection, cursor):
818
  cursor.execute(
819
+ f"UPDATE tasks SET total_errors = total_errors + {self._placeholder('delta')}, updated_at = {self._placeholder('updated_at')} WHERE id = {self._placeholder('task_id')}",
820
+ {"delta": increment, "updated_at": utc_now(), "task_id": task_id},
 
 
 
 
 
821
  )
822
 
823
  def request_task_stop(self, task_id: int) -> bool:
 
826
  return False
827
  with self._cursor() as (_connection, cursor):
828
  cursor.execute(
829
+ f"UPDATE tasks SET status = {self._placeholder('status')}, updated_at = {self._placeholder('updated_at')} WHERE id = {self._placeholder('task_id')}",
830
+ {"status": "cancel_requested", "updated_at": utc_now(), "task_id": task_id},
 
 
 
 
831
  )
832
  return True
833
 
 
835
  now = utc_now()
836
  with self._cursor() as (_connection, cursor):
837
  cursor.execute(
838
+ f"UPDATE tasks SET status = {self._placeholder('status')}, finished_at = COALESCE(finished_at, {self._placeholder('finished_at')}), updated_at = {self._placeholder('updated_at')} WHERE status IN ('pending', 'running', 'cancel_requested')",
839
+ {"status": "stopped", "finished_at": now, "updated_at": now},
 
 
 
 
 
 
840
  )
841
 
842
  def add_log(self, task_id: int | None, user_id: int | None, scope: str, level: str, message: str) -> int:
843
  now = utc_now()
844
  with self._cursor() as (_connection, cursor):
845
  cursor.execute(
846
+ f"INSERT INTO logs (task_id, user_id, scope, level, message, created_at) VALUES ({self._placeholder('task_id')}, {self._placeholder('user_id')}, {self._placeholder('scope')}, {self._placeholder('level')}, {self._placeholder('message')}, {self._placeholder('created_at')})",
847
+ {
848
+ "task_id": task_id,
849
+ "user_id": user_id,
850
+ "scope": scope,
851
+ "level": level.upper(),
852
+ "message": message,
853
+ "created_at": now,
854
+ },
855
  )
856
  return int(cursor.lastrowid)
857
 
858
  def list_recent_logs(self, *, user_id: int | None = None, limit: int = 120) -> list[dict[str, Any]]:
859
  if user_id is None:
860
+ query = f"""
861
+ SELECT l.*, u.student_id, u.display_name
 
 
 
862
  FROM logs l
863
  LEFT JOIN users u ON u.id = l.user_id
864
  ORDER BY l.id DESC
865
+ LIMIT {self._placeholder('limit')}
866
  """
867
+ params = {"limit": int(limit)}
868
  else:
869
+ query = f"""
870
+ SELECT l.*, u.student_id, u.display_name
 
 
 
871
  FROM logs l
872
  LEFT JOIN users u ON u.id = l.user_id
873
+ WHERE l.user_id = {self._placeholder('user_id')}
874
  ORDER BY l.id DESC
875
+ LIMIT {self._placeholder('limit')}
876
  """
877
+ params = {"user_id": user_id, "limit": int(limit)}
 
878
  with self._cursor() as (_connection, cursor):
879
+ cursor.execute(query, params)
880
+ rows = cursor.fetchall()
881
  return list(reversed(self._rows_to_dicts(rows)))
882
 
883
  def list_logs_after(self, after_id: int, *, user_id: int | None = None, limit: int = 100) -> list[dict[str, Any]]:
884
  if user_id is None:
885
+ query = f"""
886
+ SELECT l.*, u.student_id, u.display_name
 
 
 
887
  FROM logs l
888
  LEFT JOIN users u ON u.id = l.user_id
889
+ WHERE l.id > {self._placeholder('after_id')}
890
  ORDER BY l.id ASC
891
+ LIMIT {self._placeholder('limit')}
892
  """
893
+ params = {"after_id": after_id, "limit": int(limit)}
894
  else:
895
+ query = f"""
896
+ SELECT l.*, u.student_id, u.display_name
 
 
 
897
  FROM logs l
898
  LEFT JOIN users u ON u.id = l.user_id
899
+ WHERE l.id > {self._placeholder('after_id')} AND l.user_id = {self._placeholder('user_id')}
900
  ORDER BY l.id ASC
901
+ LIMIT {self._placeholder('limit')}
902
  """
903
+ params = {"after_id": after_id, "user_id": user_id, "limit": int(limit)}
 
904
  with self._cursor() as (_connection, cursor):
905
+ cursor.execute(query, params)
906
+ return self._rows_to_dicts(cursor.fetchall())
907
 
908
  def get_admin_stats(self) -> dict[str, int]:
909
  with self._cursor() as (_connection, cursor):
910
+ cursor.execute("SELECT COUNT(*) AS total FROM users")
911
+ users_count = int(dict(cursor.fetchone())["total"])
912
+ cursor.execute("SELECT COUNT(*) AS total FROM course_targets")
913
+ courses_count = int(dict(cursor.fetchone())["total"])
914
+ cursor.execute("SELECT COUNT(*) AS total FROM admins")
915
+ admins_count = int(dict(cursor.fetchone())["total"])
916
+ cursor.execute("SELECT COUNT(*) AS total FROM tasks WHERE status = 'running'")
917
+ running_count = int(dict(cursor.fetchone())["total"])
918
+ cursor.execute("SELECT COUNT(*) AS total FROM tasks WHERE status = 'pending'")
919
+ pending_count = int(dict(cursor.fetchone())["total"])
920
+ cursor.execute("SELECT COUNT(*) AS total FROM registration_codes")
921
+ registration_code_count = int(dict(cursor.fetchone())["total"])
922
+ cursor.execute("SELECT COUNT(*) AS total FROM user_schedules WHERE is_enabled = 1")
923
+ active_schedule_count = int(dict(cursor.fetchone())["total"])
924
  return {
925
+ "users_count": users_count,
926
+ "courses_count": courses_count,
927
+ "admins_count": admins_count + 1,
928
+ "running_count": running_count,
929
+ "pending_count": pending_count,
930
+ "registration_code_count": registration_code_count,
931
+ "active_schedule_count": active_schedule_count,
932
  }
933
+
934
+ def get_user_schedule(self, user_id: int) -> dict[str, Any] | None:
935
+ with self._cursor() as (_connection, cursor):
936
+ cursor.execute(
937
+ f"SELECT * FROM user_schedules WHERE user_id = {self._placeholder('user_id')}",
938
+ {"user_id": user_id},
939
+ )
940
+ row = cursor.fetchone()
941
+ return None if row is None else dict(row)
942
+
943
+ def upsert_user_schedule(
944
+ self,
945
+ user_id: int,
946
+ *,
947
+ is_enabled: bool,
948
+ start_date: str | None,
949
+ end_date: str | None,
950
+ daily_start_time: str | None,
951
+ daily_stop_time: str | None,
952
+ ) -> None:
953
+ now = utc_now()
954
+ current = self.get_user_schedule(user_id)
955
+ payload = {
956
+ "user_id": user_id,
957
+ "is_enabled": 1 if is_enabled else 0,
958
+ "start_date": start_date,
959
+ "end_date": end_date,
960
+ "daily_start_time": daily_start_time,
961
+ "daily_stop_time": daily_stop_time,
962
+ "last_auto_start_on": "",
963
+ "last_auto_stop_on": "",
964
+ "created_at": now,
965
+ "updated_at": now,
966
+ "id": int(current["id"]) if current else None,
967
+ }
968
+ with self._cursor() as (_connection, cursor):
969
+ if current is None:
970
+ cursor.execute(
971
+ f"INSERT INTO user_schedules (user_id, is_enabled, start_date, end_date, daily_start_time, daily_stop_time, last_auto_start_on, last_auto_stop_on, created_at, updated_at) VALUES ({self._placeholder('user_id')}, {self._placeholder('is_enabled')}, {self._placeholder('start_date')}, {self._placeholder('end_date')}, {self._placeholder('daily_start_time')}, {self._placeholder('daily_stop_time')}, {self._placeholder('last_auto_start_on')}, {self._placeholder('last_auto_stop_on')}, {self._placeholder('created_at')}, {self._placeholder('updated_at')})",
972
+ payload,
973
+ )
974
+ else:
975
+ cursor.execute(
976
+ f"UPDATE user_schedules SET is_enabled = {self._placeholder('is_enabled')}, start_date = {self._placeholder('start_date')}, end_date = {self._placeholder('end_date')}, daily_start_time = {self._placeholder('daily_start_time')}, daily_stop_time = {self._placeholder('daily_stop_time')}, last_auto_start_on = {self._placeholder('last_auto_start_on')}, last_auto_stop_on = {self._placeholder('last_auto_stop_on')}, updated_at = {self._placeholder('updated_at')} WHERE id = {self._placeholder('id')}",
977
+ payload,
978
+ )
979
+
980
+ def list_enabled_user_schedules(self) -> list[dict[str, Any]]:
981
+ query = """
982
+ SELECT s.*, u.student_id, u.display_name, u.is_active AS user_is_active
983
+ FROM user_schedules s
984
+ JOIN users u ON u.id = s.user_id
985
+ WHERE s.is_enabled = 1
986
+ ORDER BY s.user_id ASC
987
+ """
988
+ with self._cursor() as (_connection, cursor):
989
+ cursor.execute(query)
990
+ return self._rows_to_dicts(cursor.fetchall())
991
+
992
+ def mark_schedule_auto_start(self, user_id: int, day_text: str) -> None:
993
+ with self._cursor() as (_connection, cursor):
994
+ cursor.execute(
995
+ f"UPDATE user_schedules SET last_auto_start_on = {self._placeholder('day_text')}, updated_at = {self._placeholder('updated_at')} WHERE user_id = {self._placeholder('user_id')}",
996
+ {"day_text": day_text, "updated_at": utc_now(), "user_id": user_id},
997
+ )
998
+
999
+ def mark_schedule_auto_stop(self, user_id: int, day_text: str) -> None:
1000
+ with self._cursor() as (_connection, cursor):
1001
+ cursor.execute(
1002
+ f"UPDATE user_schedules SET last_auto_stop_on = {self._placeholder('day_text')}, updated_at = {self._placeholder('updated_at')} WHERE user_id = {self._placeholder('user_id')}",
1003
+ {"day_text": day_text, "updated_at": utc_now(), "user_id": user_id},
1004
+ )
1005
+ def create_registration_code(
1006
+ self,
1007
+ *,
1008
+ created_by: str,
1009
+ note: str = "",
1010
+ max_uses: int = DEFAULT_REGISTRATION_CODE_MAX_USES,
1011
+ ) -> dict[str, Any]:
1012
+ normalized_max_uses = max(1, int(max_uses or DEFAULT_REGISTRATION_CODE_MAX_USES))
1013
+ now = utc_now()
1014
+ for _ in range(24):
1015
+ code = "SACC-" + "".join(secrets.choice(REGISTRATION_CODE_ALPHABET) for _ in range(8))
1016
+ if self.get_registration_code_by_code(code) is not None:
1017
+ continue
1018
+ with self._cursor() as (_connection, cursor):
1019
+ cursor.execute(
1020
+ f"INSERT INTO registration_codes (code, note, is_active, max_uses, used_count, created_by, used_by_user_id, used_at, created_at, updated_at) VALUES ({self._placeholder('code')}, {self._placeholder('note')}, {self._placeholder('is_active')}, {self._placeholder('max_uses')}, {self._placeholder('used_count')}, {self._placeholder('created_by')}, {self._placeholder('used_by_user_id')}, {self._placeholder('used_at')}, {self._placeholder('created_at')}, {self._placeholder('updated_at')})",
1021
+ {
1022
+ "code": code,
1023
+ "note": note.strip(),
1024
+ "is_active": 1,
1025
+ "max_uses": normalized_max_uses,
1026
+ "used_count": 0,
1027
+ "created_by": created_by,
1028
+ "used_by_user_id": None,
1029
+ "used_at": None,
1030
+ "created_at": now,
1031
+ "updated_at": now,
1032
+ },
1033
+ )
1034
+ return self.get_registration_code_by_id(int(cursor.lastrowid)) or {"code": code}
1035
+ raise RuntimeError("生成注册码失败,请重试。")
1036
+
1037
+ def get_registration_code_by_code(self, code: str) -> dict[str, Any] | None:
1038
+ with self._cursor() as (_connection, cursor):
1039
+ cursor.execute(
1040
+ f"SELECT * FROM registration_codes WHERE code = {self._placeholder('code')}",
1041
+ {"code": normalize_registration_code(code)},
1042
+ )
1043
+ row = cursor.fetchone()
1044
+ return None if row is None else dict(row)
1045
+
1046
+ def get_registration_code_by_id(self, registration_code_id: int) -> dict[str, Any] | None:
1047
+ with self._cursor() as (_connection, cursor):
1048
+ cursor.execute(
1049
+ f"SELECT * FROM registration_codes WHERE id = {self._placeholder('registration_code_id')}",
1050
+ {"registration_code_id": registration_code_id},
1051
+ )
1052
+ row = cursor.fetchone()
1053
+ return None if row is None else dict(row)
1054
+
1055
+ def list_registration_codes(self, limit: int = 100) -> list[dict[str, Any]]:
1056
+ query = f"""
1057
+ SELECT rc.*, u.student_id AS used_by_student_id, u.display_name AS used_by_display_name
1058
+ FROM registration_codes rc
1059
+ LEFT JOIN users u ON u.id = rc.used_by_user_id
1060
+ ORDER BY rc.created_at DESC
1061
+ LIMIT {self._placeholder('limit')}
1062
+ """
1063
+ with self._cursor() as (_connection, cursor):
1064
+ cursor.execute(query, {"limit": int(limit)})
1065
+ return self._rows_to_dicts(cursor.fetchall())
1066
+
1067
+ def toggle_registration_code_active(self, registration_code_id: int) -> dict[str, Any] | None:
1068
+ code = self.get_registration_code_by_id(registration_code_id)
1069
+ if code is None:
1070
+ return None
1071
+ with self._cursor() as (_connection, cursor):
1072
+ cursor.execute(
1073
+ f"UPDATE registration_codes SET is_active = {self._placeholder('is_active')}, updated_at = {self._placeholder('updated_at')} WHERE id = {self._placeholder('registration_code_id')}",
1074
+ {
1075
+ "is_active": 0 if bool(code.get('is_active')) else 1,
1076
+ "updated_at": utc_now(),
1077
+ "registration_code_id": registration_code_id,
1078
+ },
1079
+ )
1080
+ return self.get_registration_code_by_id(registration_code_id)
core/task_manager.py CHANGED
@@ -4,6 +4,8 @@ import logging
4
  import threading
5
  import time
6
  from dataclasses import dataclass
 
 
7
 
8
  from core.course_bot import CourseBot, TaskResult
9
  from core.db import Database
@@ -32,6 +34,7 @@ class TaskManager:
32
  self._dispatcher_thread: threading.Thread | None = None
33
  self._running: dict[int, RunningTask] = {}
34
  self._last_dispatch_snapshot: tuple[int, int, int] | None = None
 
35
 
36
  def start(self) -> None:
37
  with self._startup_lock:
@@ -39,9 +42,10 @@ class TaskManager:
39
  LOGGER.info("Task manager start skipped because it is already running")
40
  return
41
  LOGGER.info(
42
- "Task manager starting | db_path=%s default_parallel_limit=%s",
43
  self.store.path,
44
  getattr(self.config, "default_parallel_limit", "-"),
 
45
  )
46
  self.store.reset_inflight_tasks()
47
  self._dispatcher_thread = threading.Thread(target=self._dispatch_loop, name="task-dispatcher", daemon=True)
@@ -92,10 +96,71 @@ class TaskManager:
92
  running_task.stop_event.set()
93
  return True
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  def _dispatch_loop(self) -> None:
96
  LOGGER.info("Dispatcher loop started")
97
  while not self._shutdown_event.is_set():
98
  self._cleanup_running_registry()
 
99
  parallel_limit = self.store.get_parallel_limit()
100
  running_count = self._running_count()
101
  available_slots = max(0, parallel_limit - running_count)
@@ -232,4 +297,4 @@ class TaskManager:
232
  with self._running_lock:
233
  removed = self._running.pop(task_id, None)
234
  if removed is not None:
235
- LOGGER.info("Task removed from running registry | task_id=%s", task_id)
 
4
  import threading
5
  import time
6
  from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from zoneinfo import ZoneInfo
9
 
10
  from core.course_bot import CourseBot, TaskResult
11
  from core.db import Database
 
34
  self._dispatcher_thread: threading.Thread | None = None
35
  self._running: dict[int, RunningTask] = {}
36
  self._last_dispatch_snapshot: tuple[int, int, int] | None = None
37
+ self._schedule_timezone = ZoneInfo(getattr(self.config, "schedule_timezone", "Asia/Shanghai"))
38
 
39
  def start(self) -> None:
40
  with self._startup_lock:
 
42
  LOGGER.info("Task manager start skipped because it is already running")
43
  return
44
  LOGGER.info(
45
+ "Task manager starting | db_path=%s default_parallel_limit=%s schedule_timezone=%s",
46
  self.store.path,
47
  getattr(self.config, "default_parallel_limit", "-"),
48
+ getattr(self.config, "schedule_timezone", "Asia/Shanghai"),
49
  )
50
  self.store.reset_inflight_tasks()
51
  self._dispatcher_thread = threading.Thread(target=self._dispatch_loop, name="task-dispatcher", daemon=True)
 
96
  running_task.stop_event.set()
97
  return True
98
 
99
+ def _current_schedule_now(self) -> datetime:
100
+ return datetime.now(self._schedule_timezone)
101
+
102
+ def _within_schedule_date_window(self, schedule: dict, today_text: str) -> bool:
103
+ start_date = str(schedule.get("start_date") or "").strip()
104
+ end_date = str(schedule.get("end_date") or "").strip()
105
+ if start_date and today_text < start_date:
106
+ return False
107
+ if end_date and today_text > end_date:
108
+ return False
109
+ return True
110
+
111
+ def _apply_user_schedules(self) -> None:
112
+ now = self._current_schedule_now()
113
+ today_text = now.date().isoformat()
114
+ current_time_text = now.strftime("%H:%M")
115
+
116
+ for schedule in self.store.list_enabled_user_schedules():
117
+ if not bool(schedule.get("user_is_active", 0)):
118
+ continue
119
+ if not self._within_schedule_date_window(schedule, today_text):
120
+ continue
121
+
122
+ start_time = str(schedule.get("daily_start_time") or "").strip()
123
+ stop_time = str(schedule.get("daily_stop_time") or "").strip()
124
+ if not start_time or not stop_time:
125
+ continue
126
+
127
+ user_id = int(schedule["user_id"])
128
+ active_task = self.store.find_active_task_for_user(user_id)
129
+
130
+ if current_time_text >= stop_time and str(schedule.get("last_auto_stop_on") or "") != today_text:
131
+ if active_task and self.stop_task(int(active_task["id"])):
132
+ self._log(int(active_task["id"]), user_id, "SYSTEM", "INFO", "已按管理员定时设置发送停止请求。")
133
+ else:
134
+ self._log(None, user_id, "SYSTEM", "INFO", "已到定时停止时间,当前没有运行中的任务。")
135
+ self.store.mark_schedule_auto_stop(user_id, today_text)
136
+ continue
137
+
138
+ if not (start_time <= current_time_text < stop_time):
139
+ continue
140
+ if str(schedule.get("last_auto_start_on") or "") == today_text:
141
+ continue
142
+
143
+ user = self.store.get_user(user_id)
144
+ if user is None:
145
+ self.store.mark_schedule_auto_start(user_id, today_text)
146
+ continue
147
+ if not self.store.list_courses_for_user(user_id):
148
+ self._log(None, user_id, "SYSTEM", "INFO", "已到定时启动时间,但当前没有课程目标,今天不自动启动。")
149
+ self.store.mark_schedule_auto_start(user_id, today_text)
150
+ continue
151
+
152
+ task, created = self.queue_task(user_id, requested_by="scheduler", requested_by_role="system")
153
+ if created:
154
+ self._log(task["id"], user_id, "SYSTEM", "INFO", "已按管理员定时设置自动加入任务队列。")
155
+ else:
156
+ self._log(task["id"], user_id, "SYSTEM", "INFO", "已到定时启动时间,但当前已有任务在运行或排队。")
157
+ self.store.mark_schedule_auto_start(user_id, today_text)
158
+
159
  def _dispatch_loop(self) -> None:
160
  LOGGER.info("Dispatcher loop started")
161
  while not self._shutdown_event.is_set():
162
  self._cleanup_running_registry()
163
+ self._apply_user_schedules()
164
  parallel_limit = self.store.get_parallel_limit()
165
  running_count = self._running_count()
166
  available_slots = max(0, parallel_limit - running_count)
 
297
  with self._running_lock:
298
  removed = self._running.pop(task_id, None)
299
  if removed is not None:
300
+ LOGGER.info("Task removed from running registry | task_id=%s", task_id)
requirements.txt CHANGED
@@ -1,7 +1,8 @@
1
- Flask>=3.0.0
2
- gunicorn>=23.0.0
3
- selenium>=4.35.0
4
- onnxruntime>=1.18.0
5
- numpy>=1.24.0
6
- Pillow>=10.0.0
7
- cryptography>=43.0.0
 
 
1
+ Flask>=3.0.0
2
+ gunicorn>=23.0.0
3
+ selenium>=4.35.0
4
+ onnxruntime>=1.18.0
5
+ numpy>=1.24.0
6
+ Pillow>=10.0.0
7
+ cryptography>=43.0.0
8
+ PyMySQL>=1.1.1
space_app.py CHANGED
@@ -1,26 +1,37 @@
1
- from __future__ import annotations
2
 
3
  import atexit
4
  import json
5
  import re
6
  import time
 
7
  from functools import wraps
8
  from typing import Callable
9
 
10
  from flask import Flask, Response, flash, g, jsonify, redirect, render_template, request, session, stream_with_context, url_for
11
 
12
  from core.config import AppConfig
13
- from core.db import Database, MAX_REFRESH_INTERVAL_SECONDS, MIN_REFRESH_INTERVAL_SECONDS
 
 
 
 
 
 
 
14
  from core.security import SecretBox, hash_password, verify_password
15
  from core.task_manager import TaskManager
16
- from core.runtime_logging import configure_logging, get_logger
17
 
18
 
19
  configure_logging()
20
  APP_LOGGER = get_logger("sacc.web")
21
 
22
  config = AppConfig.load()
23
- store = Database(config.db_path, default_parallel_limit=config.default_parallel_limit)
 
 
 
 
24
  store.init_db()
25
  secret_box = SecretBox(config.encryption_key)
26
  task_manager = TaskManager(config=config, store=store, secret_box=secret_box)
@@ -61,12 +72,14 @@ app = Flask(__name__, template_folder="templates", static_folder="static")
61
  app.secret_key = config.session_secret
62
  app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax")
63
  APP_LOGGER.info(
64
- "Application bootstrap complete | data_dir=%s db_path=%s chrome_binary=%s chromedriver_path=%s default_parallel_limit=%s",
65
  config.data_dir,
66
  config.db_path,
 
67
  config.chrome_binary,
68
  config.chromedriver_path,
69
  config.default_parallel_limit,
 
70
  )
71
 
72
  CATEGORY_LABELS = {
@@ -82,7 +95,6 @@ TASK_LABELS = {
82
  "failed": "失败",
83
  }
84
 
85
-
86
  SKIPPED_REQUEST_LOG_PREFIXES = (
87
  "/static/",
88
  "/api/",
@@ -90,6 +102,9 @@ SKIPPED_REQUEST_LOG_PREFIXES = (
90
  SKIPPED_REQUEST_LOG_PATHS = {
91
  "/favicon.ico",
92
  }
 
 
 
93
 
94
 
95
  def _current_actor_label() -> str:
@@ -137,6 +152,7 @@ def inject_globals() -> dict:
137
  "refresh_interval_min": MIN_REFRESH_INTERVAL_SECONDS,
138
  "refresh_interval_max": MAX_REFRESH_INTERVAL_SECONDS,
139
  "default_refresh_interval_seconds": config.poll_interval_seconds,
 
140
  }
141
 
142
 
@@ -172,10 +188,6 @@ def _get_admin_identity() -> dict:
172
  }
173
 
174
 
175
- COURSE_ID_PATTERN = re.compile(r"^[0-9A-Za-z]{1,32}$")
176
- COURSE_INDEX_PATTERN = re.compile(r"^[0-9A-Za-z]{1,8}$")
177
-
178
-
179
  def _normalize_course_token(raw_value: str) -> str:
180
  return re.sub(r"\s+", "", str(raw_value or "")).upper()
181
 
@@ -203,6 +215,72 @@ def _parse_refresh_interval(raw_value: str | None, *, default: int) -> int:
203
  return interval
204
 
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  def _user_owns_course(user_id: int, course_target_id: int) -> bool:
207
  return any(course["id"] == course_target_id for course in store.list_courses_for_user(user_id))
208
 
@@ -212,6 +290,7 @@ def _build_user_dashboard_context(user: dict) -> dict:
212
  "current_user": user,
213
  "courses": store.list_courses_for_user(user["id"]),
214
  "task": store.get_latest_task_for_user(user["id"]),
 
215
  "recent_logs": store.list_recent_logs(user_id=user["id"], limit=config.logs_page_size),
216
  }
217
 
@@ -221,10 +300,12 @@ def _build_admin_dashboard_context() -> dict:
221
  for user in users:
222
  user["courses"] = store.list_courses_for_user(user["id"])
223
  user["latest_task"] = store.get_latest_task_for_user(user["id"])
 
224
  admin_identity = _get_admin_identity()
225
  return {
226
  "users": users,
227
  "admins": store.list_admins(),
 
228
  "stats": store.get_admin_stats(),
229
  "recent_tasks": store.list_recent_tasks(limit=18),
230
  "recent_logs": store.list_recent_logs(limit=config.logs_page_size),
@@ -261,7 +342,7 @@ def login():
261
  password = request.form.get("password", "")
262
  user = store.get_user_by_student_id(student_id)
263
  if user is None:
264
- flash("没有找到该学号对应的账号,请联系管理员录入。", "danger")
265
  return render_template("login.html")
266
  if not user["is_active"]:
267
  flash("该账号已被管理员禁用。", "danger")
@@ -283,6 +364,39 @@ def login():
283
  return render_template("login.html")
284
 
285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  @app.post("/logout")
287
  def logout():
288
  session.clear()
@@ -313,8 +427,7 @@ def admin_login():
313
  @app.post("/admin/logout")
314
  def admin_logout():
315
  session.clear()
316
- return redirect(url_for("admin_login"))
317
-
318
 
319
  @app.get("/dashboard")
320
  @_login_required("user")
@@ -441,7 +554,7 @@ def admin_dashboard():
441
  @_login_required("admin")
442
  def update_parallel_limit():
443
  try:
444
- parallel_limit = max(1, min(8, int(request.form.get("parallel_limit", "2"))))
445
  except ValueError:
446
  flash("并行数必须是 1 到 8 的整数。", "danger")
447
  return redirect(url_for("admin_dashboard"))
@@ -521,6 +634,38 @@ def toggle_user(user_id: int):
521
  return redirect(url_for("admin_dashboard"))
522
 
523
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  @app.post("/admin/users/<int:user_id>/courses")
525
  @_login_required("admin")
526
  def admin_add_course(user_id: int):
@@ -591,6 +736,31 @@ def create_admin():
591
  return redirect(url_for("admin_dashboard"))
592
 
593
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
594
  @app.get("/api/user/status")
595
  @_login_required("user")
596
  def user_status():
@@ -671,4 +841,4 @@ def stream_admin_logs():
671
  response = Response(generate(), mimetype="text/event-stream")
672
  response.headers["Cache-Control"] = "no-cache"
673
  response.headers["X-Accel-Buffering"] = "no"
674
- return response
 
1
+ from __future__ import annotations
2
 
3
  import atexit
4
  import json
5
  import re
6
  import time
7
+ from datetime import date as date_cls, time as time_cls
8
  from functools import wraps
9
  from typing import Callable
10
 
11
  from flask import Flask, Response, flash, g, jsonify, redirect, render_template, request, session, stream_with_context, url_for
12
 
13
  from core.config import AppConfig
14
+ from core.db import (
15
+ DEFAULT_REGISTRATION_CODE_MAX_USES,
16
+ Database,
17
+ MAX_REFRESH_INTERVAL_SECONDS,
18
+ MIN_REFRESH_INTERVAL_SECONDS,
19
+ normalize_registration_code,
20
+ )
21
+ from core.runtime_logging import configure_logging, get_logger
22
  from core.security import SecretBox, hash_password, verify_password
23
  from core.task_manager import TaskManager
 
24
 
25
 
26
  configure_logging()
27
  APP_LOGGER = get_logger("sacc.web")
28
 
29
  config = AppConfig.load()
30
+ store = Database(
31
+ config.db_path,
32
+ default_parallel_limit=config.default_parallel_limit,
33
+ mysql_ssl_ca_path=config.mysql_ssl_ca_path,
34
+ )
35
  store.init_db()
36
  secret_box = SecretBox(config.encryption_key)
37
  task_manager = TaskManager(config=config, store=store, secret_box=secret_box)
 
72
  app.secret_key = config.session_secret
73
  app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax")
74
  APP_LOGGER.info(
75
+ "Application bootstrap complete | data_dir=%s db_path=%s backend=%s chrome_binary=%s chromedriver_path=%s default_parallel_limit=%s schedule_timezone=%s",
76
  config.data_dir,
77
  config.db_path,
78
+ config.database_backend,
79
  config.chrome_binary,
80
  config.chromedriver_path,
81
  config.default_parallel_limit,
82
+ config.schedule_timezone,
83
  )
84
 
85
  CATEGORY_LABELS = {
 
95
  "failed": "失败",
96
  }
97
 
 
98
  SKIPPED_REQUEST_LOG_PREFIXES = (
99
  "/static/",
100
  "/api/",
 
102
  SKIPPED_REQUEST_LOG_PATHS = {
103
  "/favicon.ico",
104
  }
105
+ COURSE_ID_PATTERN = re.compile(r"^[0-9A-Za-z]{1,32}$")
106
+ COURSE_INDEX_PATTERN = re.compile(r"^[0-9A-Za-z]{1,8}$")
107
+ REGISTRATION_CODE_PATTERN = re.compile(r"^[A-Z0-9-]{6,64}$")
108
 
109
 
110
  def _current_actor_label() -> str:
 
152
  "refresh_interval_min": MIN_REFRESH_INTERVAL_SECONDS,
153
  "refresh_interval_max": MAX_REFRESH_INTERVAL_SECONDS,
154
  "default_refresh_interval_seconds": config.poll_interval_seconds,
155
+ "default_registration_code_max_uses": DEFAULT_REGISTRATION_CODE_MAX_USES,
156
  }
157
 
158
 
 
188
  }
189
 
190
 
 
 
 
 
191
  def _normalize_course_token(raw_value: str) -> str:
192
  return re.sub(r"\s+", "", str(raw_value or "")).upper()
193
 
 
215
  return interval
216
 
217
 
218
+ def _parse_registration_code_max_uses(raw_value: str | None) -> int:
219
+ raw_text = str(raw_value or "").strip()
220
+ if not raw_text:
221
+ return DEFAULT_REGISTRATION_CODE_MAX_USES
222
+ try:
223
+ value = int(raw_text)
224
+ except ValueError as exc:
225
+ raise ValueError("注册码可用次数必须是 1 到 99 之间的整数。") from exc
226
+ if value < 1 or value > 99:
227
+ raise ValueError("注册码可用次数必须在 1 到 99 之间。")
228
+ return value
229
+
230
+
231
+ def _parse_iso_date(raw_value: str | None, label: str) -> str:
232
+ raw_text = str(raw_value or "").strip()
233
+ if not raw_text:
234
+ raise ValueError(f"{label}不能为空。")
235
+ try:
236
+ return date_cls.fromisoformat(raw_text).isoformat()
237
+ except ValueError as exc:
238
+ raise ValueError(f"{label}格式无效,请使用 YYYY-MM-DD。") from exc
239
+
240
+
241
+ def _parse_clock_time(raw_value: str | None, label: str) -> str:
242
+ raw_text = str(raw_value or "").strip()
243
+ if not raw_text:
244
+ raise ValueError(f"{label}不能为空。")
245
+ try:
246
+ return time_cls.fromisoformat(raw_text).strftime("%H:%M")
247
+ except ValueError as exc:
248
+ raise ValueError(f"{label}格式无效,请使用 HH:MM。") from exc
249
+
250
+
251
+ def _parse_schedule_form(form) -> dict:
252
+ enabled = str(form.get("schedule_enabled", "")).lower() in {"1", "true", "on", "yes"}
253
+ start_date_raw = form.get("start_date", "")
254
+ end_date_raw = form.get("end_date", "")
255
+ daily_start_time_raw = form.get("daily_start_time", "")
256
+ daily_stop_time_raw = form.get("daily_stop_time", "")
257
+ has_any_value = enabled or any(str(value or "").strip() for value in (start_date_raw, end_date_raw, daily_start_time_raw, daily_stop_time_raw))
258
+ if not has_any_value:
259
+ return {
260
+ "is_enabled": False,
261
+ "start_date": None,
262
+ "end_date": None,
263
+ "daily_start_time": None,
264
+ "daily_stop_time": None,
265
+ }
266
+
267
+ start_date = _parse_iso_date(start_date_raw, "开始日期")
268
+ end_date = _parse_iso_date(end_date_raw, "结束日期")
269
+ daily_start_time = _parse_clock_time(daily_start_time_raw, "每日启动时间")
270
+ daily_stop_time = _parse_clock_time(daily_stop_time_raw, "每日停止时间")
271
+ if end_date < start_date:
272
+ raise ValueError("结束日期不能早于开始日期。")
273
+ if daily_stop_time <= daily_start_time:
274
+ raise ValueError("每日停止时间必须晚于每日启动时间。")
275
+ return {
276
+ "is_enabled": enabled,
277
+ "start_date": start_date,
278
+ "end_date": end_date,
279
+ "daily_start_time": daily_start_time,
280
+ "daily_stop_time": daily_stop_time,
281
+ }
282
+
283
+
284
  def _user_owns_course(user_id: int, course_target_id: int) -> bool:
285
  return any(course["id"] == course_target_id for course in store.list_courses_for_user(user_id))
286
 
 
290
  "current_user": user,
291
  "courses": store.list_courses_for_user(user["id"]),
292
  "task": store.get_latest_task_for_user(user["id"]),
293
+ "schedule": store.get_user_schedule(user["id"]),
294
  "recent_logs": store.list_recent_logs(user_id=user["id"], limit=config.logs_page_size),
295
  }
296
 
 
300
  for user in users:
301
  user["courses"] = store.list_courses_for_user(user["id"])
302
  user["latest_task"] = store.get_latest_task_for_user(user["id"])
303
+ user["schedule"] = store.get_user_schedule(user["id"])
304
  admin_identity = _get_admin_identity()
305
  return {
306
  "users": users,
307
  "admins": store.list_admins(),
308
+ "registration_codes": store.list_registration_codes(limit=60),
309
  "stats": store.get_admin_stats(),
310
  "recent_tasks": store.list_recent_tasks(limit=18),
311
  "recent_logs": store.list_recent_logs(limit=config.logs_page_size),
 
342
  password = request.form.get("password", "")
343
  user = store.get_user_by_student_id(student_id)
344
  if user is None:
345
+ flash("没有找到该学号对应的账号。如果你有注册码,请先完成注册。", "danger")
346
  return render_template("login.html")
347
  if not user["is_active"]:
348
  flash("该账号已被管理员禁用。", "danger")
 
364
  return render_template("login.html")
365
 
366
 
367
+ @app.route("/register", methods=["GET", "POST"])
368
+ def register():
369
+ if request.method == "POST":
370
+ registration_code = normalize_registration_code(request.form.get("registration_code", ""))
371
+ student_id = request.form.get("student_id", "").strip()
372
+ password = request.form.get("password", "").strip()
373
+ display_name = request.form.get("display_name", "").strip()
374
+
375
+ if not REGISTRATION_CODE_PATTERN.fullmatch(registration_code):
376
+ flash("请输入有效的注册码。", "danger")
377
+ return render_template("register.html")
378
+ if not student_id.isdigit() or not password:
379
+ flash("请填写学号和教务处密码。", "danger")
380
+ return render_template("register.html")
381
+
382
+ try:
383
+ store.register_user_with_code(
384
+ registration_code,
385
+ student_id,
386
+ secret_box.encrypt(password),
387
+ display_name,
388
+ refresh_interval_seconds=config.poll_interval_seconds,
389
+ )
390
+ except ValueError as exc:
391
+ flash(str(exc), "danger")
392
+ return render_template("register.html")
393
+
394
+ flash("注册成功,请使用学号和教务处密码登录。", "success")
395
+ return redirect(url_for("login"))
396
+
397
+ return render_template("register.html")
398
+
399
+
400
  @app.post("/logout")
401
  def logout():
402
  session.clear()
 
427
  @app.post("/admin/logout")
428
  def admin_logout():
429
  session.clear()
430
+ return redirect(url_for("admin_login"))
 
431
 
432
  @app.get("/dashboard")
433
  @_login_required("user")
 
554
  @_login_required("admin")
555
  def update_parallel_limit():
556
  try:
557
+ parallel_limit = max(1, min(8, int(request.form.get("parallel_limit", str(config.default_parallel_limit)))))
558
  except ValueError:
559
  flash("并行数必须是 1 到 8 的整数。", "danger")
560
  return redirect(url_for("admin_dashboard"))
 
634
  return redirect(url_for("admin_dashboard"))
635
 
636
 
637
+ @app.post("/admin/users/<int:user_id>/delete")
638
+ @_login_required("admin")
639
+ def delete_user_by_admin(user_id: int):
640
+ user = store.get_user(user_id)
641
+ if user is None:
642
+ flash("用户不存在。", "danger")
643
+ return redirect(url_for("admin_dashboard"))
644
+ active_task = store.find_active_task_for_user(user_id)
645
+ if active_task is not None:
646
+ flash("请先停止该用户当前任务,再删除用户。", "danger")
647
+ return redirect(url_for("admin_dashboard"))
648
+ store.delete_user(user_id)
649
+ flash("用户及其课程、日志、定时设置已删除。", "success")
650
+ return redirect(url_for("admin_dashboard"))
651
+
652
+
653
+ @app.post("/admin/users/<int:user_id>/schedule")
654
+ @_login_required("admin")
655
+ def update_user_schedule(user_id: int):
656
+ if store.get_user(user_id) is None:
657
+ flash("用户不存在。", "danger")
658
+ return redirect(url_for("admin_dashboard"))
659
+ try:
660
+ schedule_payload = _parse_schedule_form(request.form)
661
+ except ValueError as exc:
662
+ flash(str(exc), "danger")
663
+ return redirect(url_for("admin_dashboard"))
664
+ store.upsert_user_schedule(user_id, **schedule_payload)
665
+ flash("定时启动终止设置已更新。", "success")
666
+ return redirect(url_for("admin_dashboard"))
667
+
668
+
669
  @app.post("/admin/users/<int:user_id>/courses")
670
  @_login_required("admin")
671
  def admin_add_course(user_id: int):
 
736
  return redirect(url_for("admin_dashboard"))
737
 
738
 
739
+ @app.post("/admin/registration-codes")
740
+ @_login_required("admin")
741
+ def create_registration_code():
742
+ note = request.form.get("note", "").strip()
743
+ try:
744
+ max_uses = _parse_registration_code_max_uses(request.form.get("max_uses"))
745
+ except ValueError as exc:
746
+ flash(str(exc), "danger")
747
+ return redirect(url_for("admin_dashboard"))
748
+ admin_identity = _get_admin_identity()
749
+ created = store.create_registration_code(created_by=admin_identity["username"], note=note, max_uses=max_uses)
750
+ flash(f"注册码已创建:{created['code']}", "success")
751
+ return redirect(url_for("admin_dashboard"))
752
+
753
+
754
+ @app.post("/admin/registration-codes/<int:registration_code_id>/toggle")
755
+ @_login_required("admin")
756
+ def toggle_registration_code(registration_code_id: int):
757
+ updated = store.toggle_registration_code_active(registration_code_id)
758
+ if updated is None:
759
+ flash("注册码不存在。", "danger")
760
+ else:
761
+ flash("注册码状态已更新。", "success")
762
+ return redirect(url_for("admin_dashboard"))
763
+
764
  @app.get("/api/user/status")
765
  @_login_required("user")
766
  def user_status():
 
841
  response = Response(generate(), mimetype="text/event-stream")
842
  response.headers["Cache-Control"] = "no-cache"
843
  response.headers["X-Accel-Buffering"] = "no"
844
+ return response
templates/admin_dashboard.html CHANGED
@@ -1,279 +1,377 @@
1
- {% extends "base.html" %}
2
- {% block title %}管理后台 | SCU 选课控制台{% endblock %}
3
- {% block body_class %}admin-theme{% endblock %}
4
- {% block content %}
5
- <section class="dashboard-shell admin-dashboard" data-log-stream-url="{{ url_for('stream_admin_logs', last_id=recent_logs[-1].id if recent_logs else 0) }}" data-status-url="{{ url_for('admin_status') }}">
6
- <header class="topbar reveal-up">
7
- <div>
8
- <span class="eyebrow">Admin Console</span>
9
- <h1>管理员后台</h1>
10
- <p>当前管理员:{{ admin_identity.username }}{% if is_super_admin %} · 超级管理员{% endif %}</p>
11
- </div>
12
- <form method="post" action="{{ url_for('admin_logout') }}">
13
- <button type="submit" class="btn btn-ghost">退出后台</button>
14
- </form>
15
- </header>
16
-
17
- <section class="metric-grid reveal-up delay-1">
18
- <article class="metric-card">
19
- <span>用户数</span>
20
- <strong id="stat-users">{{ stats.users_count }}</strong>
21
- <small>已录入的学生账号</small>
22
- </article>
23
- <article class="metric-card">
24
- <span>运行中任务</span>
25
- <strong id="stat-running">{{ stats.running_count }}</strong>
26
- <small>排队中:<span id="stat-pending">{{ stats.pending_count }}</span></small>
27
- </article>
28
- <article class="metric-card">
29
- <span>总课程目标</span>
30
- <strong>{{ stats.courses_count }}</strong>
31
- <small>管理员可见全部课程号与课序号</small>
32
- </article>
33
- <article class="metric-card">
34
- <span>管理员总数</span>
35
- <strong>{{ stats.admins_count }}</strong>
36
- <small>包含 1 位超级管理员</small>
37
- </article>
38
- </section>
39
-
40
- <section class="content-grid admin-grid">
41
- <article class="card reveal-up delay-2">
42
- <div class="card-head">
43
- <span class="kicker">调度设置</span>
44
- <h2>并行数</h2>
45
- <p>建议根据 Hugging Face Space 的 CPU 与内存情况控制在较低范围。</p>
46
- </div>
47
- <form method="post" action="{{ url_for('update_parallel_limit') }}" class="form-grid form-grid-compact">
48
- <label class="field">
49
- <span>当前并行数</span>
50
- <input type="number" id="parallel-limit-input" name="parallel_limit" min="1" max="8" value="{{ parallel_limit }}" required>
51
- </label>
52
- <button type="submit" class="btn btn-primary">更新并行数</button>
53
- </form>
54
- </article>
55
-
56
- <article class="card reveal-up delay-2">
57
- <div class="card-head">
58
- <span class="kicker">新增用户</span>
59
- <h2>手动录入用户信息</h2>
60
- <p>管理员可以直接创建学生账号,普通用户随后即可用学号和密码登录。</p>
61
- </div>
62
- <form method="post" action="{{ url_for('create_user') }}" class="form-grid form-grid-compact">
63
- <label class="field">
64
- <span>学号</span>
65
- <input type="text" name="student_id" inputmode="numeric" placeholder="13 位学号" required>
66
- </label>
67
- <label class="field">
68
- <span>显示名称</span>
69
- <input type="text" name="display_name" placeholder="可选备注">
70
- </label>
71
- <label class="field">
72
- <span>刷新间隔</span>
73
- <input type="number" name="refresh_interval_seconds" min="{{ refresh_interval_min }}" max="{{ refresh_interval_max }}" value="{{ default_refresh_interval_seconds }}" required>
74
- </label>
75
- <label class="field span-2">
76
- <span>密码</span>
77
- <input type="password" name="password" placeholder="教务系统密码" required>
78
- </label>
79
- <button type="submit" class="btn btn-secondary">创建用户</button>
80
- </form>
81
- </article>
82
-
83
- {% if is_super_admin %}
84
- <article class="card reveal-up delay-2">
85
- <div class="card-head">
86
- <span class="kicker">管理员管理</span>
87
- <h2>新增管理员</h2>
88
- <p>只有超级管理员可以继续创建普通管理员。</p>
89
- </div>
90
- <form method="post" action="{{ url_for('create_admin') }}" class="form-grid form-grid-compact">
91
- <label class="field">
92
- <span>管理员账号</span>
93
- <input type="text" name="username" placeholder="输入管理员账号" required>
94
- </label>
95
- <label class="field">
96
- <span>管理员密码</span>
97
- <input type="password" name="password" placeholder="输入管理员密码" required>
98
- </label>
99
- <button type="submit" class="btn btn-ghost">创建管理员</button>
100
- </form>
101
- <div class="chip-row">
102
- <span class="chip highlight">超级管理员:{{ admin_identity.username }}</span>
103
- {% for admin in admins %}
104
- <span class="chip">{{ admin.username }}</span>
105
- {% endfor %}
106
- </div>
107
- </article>
108
- {% endif %}
109
-
110
- <article class="card reveal-up delay-3 span-2">
111
- <div class="card-head split">
112
- <div>
113
- <span class="kicker">任务总览</span>
114
- <h2>最近任务</h2>
115
- <p>用于快速确认任务是否正在排队、执行、停止或失败。</p>
116
- </div>
117
- <span class="status-pill status-running">实时刷新</span>
118
- </div>
119
- <div class="course-table-wrap">
120
- <table class="data-table">
121
- <thead>
122
- <tr>
123
- <th>任务</th>
124
- <th>学号</th>
125
- <th>状态</th>
126
- <th>尝试</th>
127
- <th>错误</th>
128
- <th>刷新间隔</th>
129
- <th>触发者</th>
130
- <th>更新时间</th>
131
- </tr>
132
- </thead>
133
- <tbody>
134
- {% if recent_tasks %}
135
- {% for task in recent_tasks %}
136
- <tr>
137
- <td>#{{ task.id }}</td>
138
- <td>{{ task.student_id }}</td>
139
- <td><span class="status-pill status-{{ task.status }}">{{ task_labels.get(task.status, task.status) }}</span></td>
140
- <td>{{ task.total_attempts }}</td>
141
- <td>{{ task.total_errors }}</td>
142
- <td>{{ task.refresh_interval_seconds or default_refresh_interval_seconds }} 秒</td>
143
- <td>{{ task.requested_by_role }}:{{ task.requested_by }}</td>
144
- <td>{{ task.updated_at }}</td>
145
- </tr>
146
- {% endfor %}
147
- {% else %}
148
- <tr>
149
- <td colspan="8" class="empty-cell">还没有任务记录。</td>
150
- </tr>
151
- {% endif %}
152
- </tbody>
153
- </table>
154
- </div>
155
- </article>
156
-
157
- <article class="card reveal-up delay-3 span-2">
158
- <div class="card-head split">
159
- <div>
160
- <span class="kicker">全局日志</span>
161
- <h2>所有用户的运行日志</h2>
162
- <p>日志会持续流入,便于管理员确认浏览器登录、查课、提交结果与错误信息。</p>
163
- </div>
164
- <span class="live-dot">LIVE</span>
165
- </div>
166
- <div class="log-console" id="log-console">
167
- {% if recent_logs %}
168
- {% for log in recent_logs %}
169
- <div class="log-line level-{{ log.level|lower }}">
170
- <span class="log-meta">{{ log.created_at }} · {{ log.student_id or 'system' }} · {{ log.scope }} · {{ log.level }}</span>
171
- <span>{{ log.message }}</span>
172
- </div>
173
- {% endfor %}
174
- {% else %}
175
- <div class="log-line level-info muted">暂无日志,用户启动任务后这里会自动刷新。</div>
176
- {% endif %}
177
- </div>
178
- </article>
179
-
180
- <article class="card reveal-up delay-4 span-2">
181
- <div class="card-head">
182
- <span class="kicker">用户清单</span>
183
- <h2>所有用户与课程详情</h2>
184
- <p>可以直接修改用户信息、增减课程,或代替用户启动和停止任务。</p>
185
- </div>
186
- <div class="user-card-grid">
187
- {% for user in users %}
188
- <section class="user-card">
189
- <div class="user-card-head">
190
- <div>
191
- <h3>{{ user.display_name or user.student_id }}</h3>
192
- <p>{{ user.student_id }}</p>
193
- </div>
194
- <span class="status-pill status-{{ user.latest_task.status if user.latest_task else 'idle' }}">
195
- {{ task_labels.get(user.latest_task.status, '未��动') if user.latest_task else '未启动' }}
196
- </span>
197
- </div>
198
-
199
- <div class="chip-row tight">
200
- <span class="chip {% if user.is_active %}highlight{% endif %}">{{ '启用中' if user.is_active else '已禁用' }}</span>
201
- <span class="chip">课程 {{ user.course_count }}</span>
202
- <span class="chip">最近任务 {{ user.latest_task.id if user.latest_task else '--' }}</span>
203
- <span class="chip">刷新 {{ user.refresh_interval_seconds or default_refresh_interval_seconds }} 秒</span>
204
- <span class="chip">尝试 {{ user.latest_task.total_attempts if user.latest_task else 0 }}</span>
205
- <span class="chip">错误 {{ user.latest_task.total_errors if user.latest_task else 0 }}</span>
206
- </div>
207
-
208
- <form method="post" action="{{ url_for('update_user', user_id=user.id) }}" class="form-grid form-grid-compact slim-form">
209
- <label class="field span-2">
210
- <span>显示名称</span>
211
- <input type="text" name="display_name" value="{{ user.display_name }}" placeholder="备注名称">
212
- </label>
213
- <label class="field">
214
- <span>刷新间隔</span>
215
- <input type="number" name="refresh_interval_seconds" min="{{ refresh_interval_min }}" max="{{ refresh_interval_max }}" value="{{ user.refresh_interval_seconds or default_refresh_interval_seconds }}" required>
216
- </label>
217
- <label class="field span-2">
218
- <span>重置密码</span>
219
- <input type="password" name="password" placeholder="留空表示不修改">
220
- </label>
221
- <button type="submit" class="btn btn-ghost">保存用户</button>
222
- </form>
223
-
224
- <div class="button-row wrap-row">
225
- <form method="post" action="{{ url_for('toggle_user', user_id=user.id) }}">
226
- <button type="submit" class="btn btn-ghost {% if not user.is_active %}danger{% endif %}">{{ '禁用' if user.is_active else '启用' }}</button>
227
- </form>
228
- <form method="post" action="{{ url_for('admin_start_user_task', user_id=user.id) }}">
229
- <button type="submit" class="btn btn-primary">代启动任务</button>
230
- </form>
231
- <form method="post" action="{{ url_for('admin_stop_user_task', user_id=user.id) }}">
232
- <button type="submit" class="btn btn-ghost danger">代停任务</button>
233
- </form>
234
- </div>
235
-
236
- <form method="post" action="{{ url_for('admin_add_course', user_id=user.id) }}" class="form-grid form-grid-compact slim-form">
237
- <label class="field">
238
- <span>类型</span>
239
- <select name="category">
240
- <option value="free">自由选课</option>
241
- <option value="plan">方案选课</option>
242
- </select>
243
- </label>
244
- <label class="field">
245
- <span>课程号</span>
246
- <input type="text" name="course_id" placeholder="例如 888005010A59" autocapitalize="characters" required>
247
- </label>
248
- <label class="field">
249
- <span>课序号</span>
250
- <input type="text" name="course_index" placeholder="例如 01 或 666" autocapitalize="characters" required>
251
- </label>
252
- <button type="submit" class="btn btn-secondary">为该用户加课</button>
253
- </form>
254
-
255
- <div class="course-list">
256
- {% if user.courses %}
257
- {% for course in user.courses %}
258
- <div class="course-chip-row">
259
- <span>{{ category_labels.get(course.category, course.category) }} · {{ course.course_id }}_{{ course.course_index }}</span>
260
- <form method="post" action="{{ url_for('admin_delete_course', course_target_id=course.id) }}">
261
- <button type="submit" class="inline-action">删除</button>
262
- </form>
263
- </div>
264
- {% endfor %}
265
- {% else %}
266
- <div class="empty-mini">当前没有课程目标。</div>
267
- {% endif %}
268
- </div>
269
- </section>
270
- {% else %}
271
- <div class="empty-state-card">
272
- 还没有录入何用户,请先通过上方表单创建。
273
- </div>
274
- {% endfor %}
275
- </div>
276
- </article>
277
- </section>
278
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  {% endblock %}
 
1
+ {% extends "base.html" %}
2
+ {% block title %}管理后台 | SCU 选课控制台{% endblock %}
3
+ {% block body_class %}admin-theme{% endblock %}
4
+ {% block content %}
5
+ <section class="dashboard-shell admin-dashboard" data-log-stream-url="{{ url_for('stream_admin_logs', last_id=recent_logs[-1].id if recent_logs else 0) }}" data-status-url="{{ url_for('admin_status') }}">
6
+ <header class="topbar reveal-up">
7
+ <div>
8
+ <span class="eyebrow">Admin Console</span>
9
+ <h1>管理员后台</h1>
10
+ <p>当前管理员:{{ admin_identity.username }}{% if is_super_admin %} · 超级管理员{% endif %}</p>
11
+ </div>
12
+ <form method="post" action="{{ url_for('admin_logout') }}">
13
+ <button type="submit" class="btn btn-ghost">退出后台</button>
14
+ </form>
15
+ </header>
16
+
17
+ <section class="metric-grid reveal-up delay-1">
18
+ <article class="metric-card">
19
+ <span>用户数</span>
20
+ <strong id="stat-users">{{ stats.users_count }}</strong>
21
+ <small>已录入的学生账号</small>
22
+ </article>
23
+ <article class="metric-card">
24
+ <span>运行中任务</span>
25
+ <strong id="stat-running">{{ stats.running_count }}</strong>
26
+ <small>排队中:<span id="stat-pending">{{ stats.pending_count }}</span></small>
27
+ </article>
28
+ <article class="metric-card">
29
+ <span>总课程目标</span>
30
+ <strong>{{ stats.courses_count }}</strong>
31
+ <small>管理员可见全部课程号与课序号</small>
32
+ </article>
33
+ <article class="metric-card">
34
+ <span>有效定时任务</span>
35
+ <strong>{{ stats.active_schedule_count }}</strong>
36
+ <small>管理员配置的每日自动启动与停止</small>
37
+ </article>
38
+ <article class="metric-card">
39
+ <span>注册码总数</span>
40
+ <strong>{{ stats.registration_code_count }}</strong>
41
+ <small>支持用户按注册码自助注册</small>
42
+ </article>
43
+ </section>
44
+
45
+ <section class="content-grid admin-grid">
46
+ <article class="card reveal-up delay-2">
47
+ <div class="card-head">
48
+ <span class="kicker">调度设置</span>
49
+ <h2>并行数</h2>
50
+ <p>默认并行数已调整为 4,建议根据 Hugging Face Space 的资源情况适当调节。</p>
51
+ </div>
52
+ <form method="post" action="{{ url_for('update_parallel_limit') }}" class="form-grid form-grid-compact">
53
+ <label class="field">
54
+ <span>当前并行数</span>
55
+ <input type="number" id="parallel-limit-input" name="parallel_limit" min="1" max="8" value="{{ parallel_limit }}" required>
56
+ </label>
57
+ <button type="submit" class="btn btn-primary">更新并行数</button>
58
+ </form>
59
+ </article>
60
+
61
+ <article class="card reveal-up delay-2">
62
+ <div class="card-head">
63
+ <span class="kicker">新增用户</span>
64
+ <h2>手动录入用户信息</h2>
65
+ <p>管理员可以直接录入生账,也可以只发注册码让学生自行注册。</p>
66
+ </div>
67
+ <form method="post" action="{{ url_for('create_user') }}" class="form-grid form-grid-compact">
68
+ <label class="field">
69
+ <span>学号</span>
70
+ <input type="text" name="student_id" inputmode="numeric" placeholder="13 位学号" required>
71
+ </label>
72
+ <label class="field">
73
+ <span>显示名称</span>
74
+ <input type="text" name="display_name" placeholder="可选备注">
75
+ </label>
76
+ <label class="field">
77
+ <span>刷新间隔</span>
78
+ <input type="number" name="refresh_interval_seconds" min="{{ refresh_interval_min }}" max="{{ refresh_interval_max }}" value="{{ default_refresh_interval_seconds }}" required>
79
+ </label>
80
+ <label class="field span-2">
81
+ <span>密码</span>
82
+ <input type="password" name="password" placeholder="教务处密码" required>
83
+ </label>
84
+ <button type="submit" class="btn btn-secondary">创建用户</button>
85
+ </form>
86
+ </article>
87
+
88
+ <article class="card reveal-up delay-2">
89
+ <div class="card-head">
90
+ <span class="kicker">注册码</span>
91
+ <h2>创建注册码</h2>
92
+ <p>学生拿到注册码后即可在 <code>/register</code> 页面使用学和教务处密码完成注册。</p>
93
+ </div>
94
+ <form method="post" action="{{ url_for('create_registration_code') }}" class="form-grid form-grid-compact">
95
+ <label class="field span-2">
96
+ <span>备注</span>
97
+ <input type="text" name="note" placeholder="例如 2025 春季新用户批次">
98
+ </label>
99
+ <label class="field">
100
+ <span>可用次数</span>
101
+ <input type="number" name="max_uses" min="1" max="99" value="{{ default_registration_code_max_uses }}" required>
102
+ </label>
103
+ <button type="submit" class="btn btn-secondary">生成注册码</button>
104
+ </form>
105
+ </article>
106
+
107
+ {% if is_super_admin %}
108
+ <article class="card reveal-up delay-2">
109
+ <div class="card-head">
110
+ <span class="kicker">管理员管理</span>
111
+ <h2>新增管理员</h2>
112
+ <p>只有超级管理员可以继续创建普通管理员。</p>
113
+ </div>
114
+ <form method="post" action="{{ url_for('create_admin') }}" class="form-grid form-grid-compact">
115
+ <label class="field">
116
+ <span>管理员账号</span>
117
+ <input type="text" name="username" placeholder="输入管理员账号" required>
118
+ </label>
119
+ <label class="field">
120
+ <span>管理员密码</span>
121
+ <input type="password" name="password" placeholder="输入管理员密码" required>
122
+ </label>
123
+ <button type="submit" class="btn btn-ghost">创建管理员</button>
124
+ </form>
125
+ <div class="chip-row">
126
+ <span class="chip highlight">超级管理员:{{ admin_identity.username }}</span>
127
+ {% for admin in admins %}
128
+ <span class="chip">{{ admin.username }}</span>
129
+ {% endfor %}
130
+ </div>
131
+ </article>
132
+ {% endif %}
133
+
134
+ <article class="card reveal-up delay-3 span-2">
135
+ <div class="card-head split">
136
+ <div>
137
+ <span class="kicker">任务总览</span>
138
+ <h2>最近任务</h2>
139
+ <p>用于快速确认任务是否正在排队、执行、停止或失败。</p>
140
+ </div>
141
+ <span class="status-pill status-running">实时刷新</span>
142
+ </div>
143
+ <div class="course-table-wrap">
144
+ <table class="data-table">
145
+ <thead>
146
+ <tr>
147
+ <th>任务</th>
148
+ <th>学号</th>
149
+ <th>状态</th>
150
+ <th>尝试</th>
151
+ <th>错误</th>
152
+ <th>刷新间隔</th>
153
+ <th>触发者</th>
154
+ <th>更新时间</th>
155
+ </tr>
156
+ </thead>
157
+ <tbody>
158
+ {% if recent_tasks %}
159
+ {% for task in recent_tasks %}
160
+ <tr>
161
+ <td>#{{ task.id }}</td>
162
+ <td>{{ task.student_id }}</td>
163
+ <td><span class="status-pill status-{{ task.status }}">{{ task_labels.get(task.status, task.status) }}</span></td>
164
+ <td>{{ task.total_attempts }}</td>
165
+ <td>{{ task.total_errors }}</td>
166
+ <td>{{ task.refresh_interval_seconds or default_refresh_interval_seconds }} 秒</td>
167
+ <td>{{ task.requested_by_role }}:{{ task.requested_by }}</td>
168
+ <td>{{ task.updated_at }}</td>
169
+ </tr>
170
+ {% endfor %}
171
+ {% else %}
172
+ <tr>
173
+ <td colspan="8" class="empty-cell">还没有任务记录。</td>
174
+ </tr>
175
+ {% endif %}
176
+ </tbody>
177
+ </table>
178
+ </div>
179
+ </article>
180
+
181
+ <article class="card reveal-up delay-3 span-2">
182
+ <div class="card-head split">
183
+ <div>
184
+ <span class="kicker">注册码清单</span>
185
+ <h2>注册码状态</h2>
186
+ <p>可以查看注册码是否启用、可用次数、已用次数以及最近一次使用情况。</p>
187
+ </div>
188
+ </div>
189
+ <div class="course-table-wrap">
190
+ <table class="data-table">
191
+ <thead>
192
+ <tr>
193
+ <th>注册码</th>
194
+ <th>备注</th>
195
+ <th>状态</th>
196
+ <th>使用</th>
197
+ <th>最近使用者</th>
198
+ <th>操作</th>
199
+ </tr>
200
+ </thead>
201
+ <tbody>
202
+ {% if registration_codes %}
203
+ {% for code in registration_codes %}
204
+ <tr>
205
+ <td><code>{{ code.code }}</code></td>
206
+ <td>{{ code.note or '无' }}</td>
207
+ <td>{{ '启用' if code.is_active else '停用' }}</td>
208
+ <td>{{ code.used_count }}/{{ code.max_uses }}</td>
209
+ <td>{{ code.used_by_student_id or '暂无' }}</td>
210
+ <td>
211
+ <form method="post" action="{{ url_for('toggle_registration_code', registration_code_id=code.id) }}">
212
+ <button type="submit" class="inline-action">{{ '停用' if code.is_active else '启用' }}</button>
213
+ </form>
214
+ </td>
215
+ </tr>
216
+ {% endfor %}
217
+ {% else %}
218
+ <tr>
219
+ <td colspan="6" class="empty-cell">还没有创建注册码。</td>
220
+ </tr>
221
+ {% endif %}
222
+ </tbody>
223
+ </table>
224
+ </div>
225
+ </article>
226
+
227
+ <article class="card reveal-up delay-3 span-2">
228
+ <div class="card-head split">
229
+ <div>
230
+ <span class="kicker">全局日志</span>
231
+ <h2>所有用户的运行日志</h2>
232
+ <p>日志会持续流入,便于管理员确认登录、查课、提交结果、定时启动终与错误信息。</p>
233
+ </div>
234
+ <span class="live-dot">LIVE</span>
235
+ </div>
236
+ <div class="log-console" id="log-console">
237
+ {% if recent_logs %}
238
+ {% for log in recent_logs %}
239
+ <div class="log-line level-{{ log.level|lower }}">
240
+ <span class="log-meta">{{ log.created_at }} · {{ log.student_id or 'system' }} · {{ log.scope }} · {{ log.level }}</span>
241
+ <span>{{ log.message }}</span>
242
+ </div>
243
+ {% endfor %}
244
+ {% else %}
245
+ <div class="log-line level-info muted">暂无日志,用户启动任务后这里会自动刷新。</div>
246
+ {% endif %}
247
+ </div>
248
+ </article>
249
+
250
+ <article class="card reveal-up delay-4 span-2">
251
+ <div class="card-head">
252
+ <span class="kicker">用户管理</span>
253
+ <h2>所有用户与课程详情</h2>
254
+ <p>可以直接修改用户信息、配置定时任务、增减课程,或代替用户启动和停止任务。</p>
255
+ </div>
256
+ <div class="user-card-grid">
257
+ {% for user in users %}
258
+ <section class="user-card">
259
+ <div class="user-card-head">
260
+ <div>
261
+ <h3>{{ user.display_name or user.student_id }}</h3>
262
+ <p>{{ user.student_id }}</p>
263
+ </div>
264
+ <span class="status-pill status-{{ user.latest_task.status if user.latest_task else 'idle' }}">
265
+ {{ task_labels.get(user.latest_task.status, '未启动') if user.latest_task else '未启动' }}
266
+ </span>
267
+ </div>
268
+
269
+ <div class="chip-row tight">
270
+ <span class="chip {% if user.is_active %}highlight{% endif %}">{{ '启用中' if user.is_active else '已禁用' }}</span>
271
+ <span class="chip">课程 {{ user.course_count }}</span>
272
+ <span class="chip">最近务 {{ user.latest_task.id if user.latest_task else '--' }}</span>
273
+ <span class="chip">刷新 {{ user.refresh_interval_seconds or default_refresh_interval_seconds }} 秒</span>
274
+ <span class="chip">尝试 {{ user.latest_task.total_attempts if user.latest_task else 0 }}</span>
275
+ <span class="chip">错误 {{ user.latest_task.total_errors if user.latest_task else 0 }}</span>
276
+ <span class="chip">定时 {{ '开启' if user.schedule and user.schedule.is_enabled else '关闭' }}</span>
277
+ </div>
278
+
279
+ <form method="post" action="{{ url_for('update_user', user_id=user.id) }}" class="form-grid form-grid-compact slim-form">
280
+ <label class="field span-2">
281
+ <span>显示名称</span>
282
+ <input type="text" name="display_name" value="{{ user.display_name }}" placeholder="备注名称">
283
+ </label>
284
+ <label class="field">
285
+ <span>刷新间隔</span>
286
+ <input type="number" name="refresh_interval_seconds" min="{{ refresh_interval_min }}" max="{{ refresh_interval_max }}" value="{{ user.refresh_interval_seconds or default_refresh_interval_seconds }}" required>
287
+ </label>
288
+ <label class="field span-2">
289
+ <span>重置密码</span>
290
+ <input type="password" name="password" placeholder="留空表示不修改">
291
+ </label>
292
+ <button type="submit" class="btn btn-ghost">保存用户</button>
293
+ </form>
294
+
295
+ <form method="post" action="{{ url_for('update_user_schedule', user_id=user.id) }}" class="form-grid form-grid-compact slim-form">
296
+ <label class="field">
297
+ <span>启用定时</span>
298
+ <input type="checkbox" name="schedule_enabled" value="1" {% if user.schedule and user.schedule.is_enabled %}checked{% endif %}>
299
+ </label>
300
+ <label class="field">
301
+ <span>开始日期</span>
302
+ <input type="date" name="start_date" value="{{ user.schedule.start_date if user.schedule else '' }}">
303
+ </label>
304
+ <label class="field">
305
+ <span>结束日期</span>
306
+ <input type="date" name="end_date" value="{{ user.schedule.end_date if user.schedule else '' }}">
307
+ </label>
308
+ <label class="field">
309
+ <span>每日启动</span>
310
+ <input type="time" name="daily_start_time" value="{{ user.schedule.daily_start_time if user.schedule else '' }}">
311
+ </label>
312
+ <label class="field">
313
+ <span>每日停止</span>
314
+ <input type="time" name="daily_stop_time" value="{{ user.schedule.daily_stop_time if user.schedule else '' }}">
315
+ </label>
316
+ <button type="submit" class="btn btn-secondary">保存定时设置</button>
317
+ </form>
318
+
319
+ <div class="button-row wrap-row">
320
+ <form method="post" action="{{ url_for('toggle_user', user_id=user.id) }}">
321
+ <button type="submit" class="btn btn-ghost {% if not user.is_active %}danger{% endif %}">{{ '禁用' if user.is_active else '启用' }}</button>
322
+ </form>
323
+ <form method="post" action="{{ url_for('admin_start_user_task', user_id=user.id) }}">
324
+ <button type="submit" class="btn btn-primary">代启动任务</button>
325
+ </form>
326
+ <form method="post" action="{{ url_for('admin_stop_user_task', user_id=user.id) }}">
327
+ <button type="submit" class="btn btn-ghost danger">代停止任务</button>
328
+ </form>
329
+ <form method="post" action="{{ url_for('delete_user_by_admin', user_id=user.id) }}">
330
+ <button type="submit" class="btn btn-ghost danger">删除用户</button>
331
+ </form>
332
+ </div>
333
+
334
+ <form method="post" action="{{ url_for('admin_add_course', user_id=user.id) }}" class="form-grid form-grid-compact slim-form">
335
+ <label class="field">
336
+ <span>类型</span>
337
+ <select name="category">
338
+ <option value="free">自由选课</option>
339
+ <option value="plan">方案选课</option>
340
+ </select>
341
+ </label>
342
+ <label class="field">
343
+ <span>课程号</span>
344
+ <input type="text" name="course_id" placeholder="例如 888005010A59" autocapitalize="characters" required>
345
+ </label>
346
+ <label class="field">
347
+ <span>课序号</span>
348
+ <input type="text" name="course_index" placeholder="例如 01 或 666" autocapitalize="characters" required>
349
+ </label>
350
+ <button type="submit" class="btn btn-secondary">为该用户加课</button>
351
+ </form>
352
+
353
+ <div class="course-list">
354
+ {% if user.courses %}
355
+ {% for course in user.courses %}
356
+ <div class="course-chip-row">
357
+ <span>{{ category_labels.get(course.category, course.category) }} · {{ course.course_id }}_{{ course.course_index }}</span>
358
+ <form method="post" action="{{ url_for('admin_delete_course', course_target_id=course.id) }}">
359
+ <button type="submit" class="inline-action">删除</button>
360
+ </form>
361
+ </div>
362
+ {% endfor %}
363
+ {% else %}
364
+ <div class="empty-mini">当前没有课程目标。</div>
365
+ {% endif %}
366
+ </div>
367
+ </section>
368
+ {% else %}
369
+ <div class="empty-state-card">
370
+ 还没有录入任何用户,请先通过上方表单创建或发放注册码。
371
+ </div>
372
+ {% endfor %}
373
+ </div>
374
+ </article>
375
+ </section>
376
+ </section>
377
  {% endblock %}
templates/login.html CHANGED
@@ -15,12 +15,12 @@
15
  <span>每位用户独立队列与日志流</span>
16
  </article>
17
  <article>
18
- <strong>可见</strong>
19
- <span>浏览器执行志会持续推送</span>
20
  </article>
21
  <article>
22
- <strong>HF Space 友好</strong>
23
- <span>无头浏览器任务可直接部署</span>
24
  </article>
25
  </div>
26
  </div>
@@ -34,16 +34,16 @@
34
  <form method="post" class="form-grid">
35
  <label class="field">
36
  <span>学号</span>
37
- <input type="text" name="student_id" inputmode="numeric" autocomplete="username" placeholder="例如 2023XXXXXXXXX" required>
38
  </label>
39
  <label class="field">
40
  <span>密码</span>
41
- <input type="password" name="password" autocomplete="current-password" placeholder="输入教务系统密码" required>
42
  </label>
43
  <button type="submit" class="btn btn-primary btn-lg">进入用户控制台</button>
44
  </form>
45
  <div class="auth-footnote">
46
- 管理员可以在后台手动录入用户信息普通户登录后只会看到自己的课程数据与日志
47
  </div>
48
  </div>
49
  </section>
 
15
  <span>每位用户独立队列与日志流</span>
16
  </article>
17
  <article>
18
+ <strong>启动终止</strong>
19
+ <span>管理员可配置每自动开始与停止</span>
20
  </article>
21
  <article>
22
+ <strong>远程持久化</strong>
23
+ <span>用户、课程与定时配置会保存在数据库中</span>
24
  </article>
25
  </div>
26
  </div>
 
34
  <form method="post" class="form-grid">
35
  <label class="field">
36
  <span>学号</span>
37
+ <input type="text" name="student_id" inputmode="numeric" autocomplete="username" placeholder="例如 2025XXXXXXXXX" required>
38
  </label>
39
  <label class="field">
40
  <span>密码</span>
41
+ <input type="password" name="password" autocomplete="current-password" placeholder="输入教务密码" required>
42
  </label>
43
  <button type="submit" class="btn btn-primary btn-lg">进入用户控制台</button>
44
  </form>
45
  <div class="auth-footnote">
46
+ 如果你已经拿到注册码,请先前往 <a href="{{ url_for('register') }}">用户注册</a>注册时请使学号和教务处密码
47
  </div>
48
  </div>
49
  </section>
templates/register.html ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}用户注册 | SCU 选课控制台{% endblock %}
3
+ {% block body_class %}auth-body{% endblock %}
4
+ {% block content %}
5
+ <section class="auth-layout">
6
+ <div class="hero-panel reveal-up">
7
+ <span class="eyebrow">Registration</span>
8
+ <h1>使用注册码创建你的抢课账号。</h1>
9
+ <p>
10
+ 注册成功后,后续就可以直接使用学号和教务处密码登录,不需要重复输入注册码。
11
+ </p>
12
+ <div class="hero-metrics">
13
+ <article>
14
+ <strong>注册码校验</strong>
15
+ <span>注册码由管理员在后台创建和管理</span>
16
+ </article>
17
+ <article>
18
+ <strong>使用学号注册</strong>
19
+ <span>请填写你的学号和教务处密码</span>
20
+ </article>
21
+ <article>
22
+ <strong>自动进入用户体系</strong>
23
+ <span>注册后即可管理课程目标与查看日志</span>
24
+ </article>
25
+ </div>
26
+ </div>
27
+
28
+ <div class="auth-card reveal-up delay-1">
29
+ <div class="card-head compact">
30
+ <span class="kicker">学生注册</span>
31
+ <h2>注册新账号</h2>
32
+ <p>请使用管理员发放的注册码,并填写学号与教务处密码。</p>
33
+ </div>
34
+ <form method="post" class="form-grid">
35
+ <label class="field">
36
+ <span>注册码</span>
37
+ <input type="text" name="registration_code" placeholder="例如 SACC-AB12CD34" autocapitalize="characters" required>
38
+ </label>
39
+ <label class="field">
40
+ <span>显示名称</span>
41
+ <input type="text" name="display_name" placeholder="可选昵称或备注">
42
+ </label>
43
+ <label class="field">
44
+ <span>学号</span>
45
+ <input type="text" name="student_id" inputmode="numeric" placeholder="请输入学号" required>
46
+ </label>
47
+ <label class="field">
48
+ <span>教务处密码</span>
49
+ <input type="password" name="password" placeholder="请输入教务处密码" required>
50
+ </label>
51
+ <button type="submit" class="btn btn-primary btn-lg">完成注册</button>
52
+ </form>
53
+ <div class="auth-footnote">
54
+ 已有账号?直接返回 <a href="{{ url_for('login') }}">用户登录</a>。
55
+ </div>
56
+ </div>
57
+ </section>
58
+ {% endblock %}