Compare commits

..

1 Commits

Author SHA1 Message Date
Yu Yon
53c78e8e3c feat: 添加安全模块 + Dockerfile添加curl支持健康检查
主要更新:
- 新增 security/ 安全模块 (风险评估、威胁检测、蜜罐等)
- Dockerfile 添加 curl 以支持 Docker 健康检查
- 前端页面更新 (管理后台、用户端)
- 数据库迁移和 schema 更新
- 新增 kdocs 上传服务
- 添加安全相关测试用例

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 17:48:33 +08:00
247 changed files with 15162 additions and 16953 deletions

View File

@@ -13,36 +13,11 @@ FLASK_DEBUG=false
# Session配置
SESSION_LIFETIME_HOURS=24
SESSION_COOKIE_SECURE=true # 生产环境HTTPS必须为true本地HTTP调试可临时设为false
HTTPS_ENABLED=true
# 是否信任 X-Forwarded-* 代理头(默认关闭,建议仅在可信反代后开启)
TRUST_PROXY_HEADERS=false
# TRUST_PROXY_HEADERS=true 时生效,按需配置你的反向代理网段
TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
# 可选:首次启动时指定默认管理员密码(避免控制台输出明文密码)
# DEFAULT_ADMIN_PASSWORD=your-strong-admin-password
SESSION_COOKIE_SECURE=false # 使用HTTPS时设为true
# ==================== 数据库配置 ====================
DB_FILE=data/app_data.db
DB_POOL_SIZE=5
DB_CONNECT_TIMEOUT_SECONDS=10
DB_BUSY_TIMEOUT_MS=10000
DB_CACHE_SIZE_KB=8192
DB_WAL_AUTOCHECKPOINT_PAGES=1000
DB_MMAP_SIZE_MB=256
DB_LOCK_RETRY_COUNT=3
DB_LOCK_RETRY_BASE_MS=50
DB_SLOW_QUERY_MS=120
DB_SLOW_QUERY_SQL_MAX_LEN=240
DB_SLOW_SQL_WINDOW_SECONDS=86400
DB_SLOW_SQL_TOP_LIMIT=12
DB_SLOW_SQL_RECENT_LIMIT=50
DB_SLOW_SQL_MAX_EVENTS=20000
DB_PRAGMA_OPTIMIZE_INTERVAL_SECONDS=21600
DB_ANALYZE_INTERVAL_SECONDS=86400
DB_WAL_CHECKPOINT_INTERVAL_SECONDS=43200
DB_WAL_CHECKPOINT_MODE=PASSIVE
SYSTEM_CONFIG_CACHE_TTL_SECONDS=30
# ==================== 并发控制配置 ====================
MAX_CONCURRENT_GLOBAL=2

183
.gitignore vendored
View File

@@ -1,152 +1,75 @@
# Python
# 浏览器二进制文件
playwright/
ms-playwright/
# 数据库文件(敏感数据)
data/*.db
data/*.db-shm
data/*.db-wal
data/*.backup*
data/secret_key.txt
data/update/
# Cookies敏感用户凭据
data/cookies/
# 日志文件
logs/
*.log
# 截图文件
截图/
# Python缓存
__pycache__/
*.py[cod]
*$py.class
*.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Test and tool directories
tests/
tools/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
.ruff_cache/
.mypy_cache/
.coverage
coverage.xml
htmlcov/
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# 环境变量文件(包含敏感信息)
.env
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Project specific
data/
logs/
screenshots/
截图/
ruff_cache/
*.png
*.jpg
*.jpeg
*.gif
*.bmp
*.ico
*.pdf
qr_code_*.png
# Development files
test_*.py
start_*.bat
temp_*.py
kdocs_*test*.py
simple_test.py
tools/
*.sh
# Docker volumes
volumes/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
# 系统文件
.DS_Store
Thumbs.db
# Temporary files
# 临时文件
*.tmp
*.temp
*.bak
*.backup
# Allow committed test cases
!tests/
!tests/**/*.py
# 部署脚本(含服务器信息)
deploy_*.sh
verify_*.sh
deploy.sh
# 内部文档
docs/
# 前端依赖(体积大,不应入库)
node_modules/
app-frontend/node_modules/
admin-frontend/node_modules/
# Local data
data/
docker-compose.yml.bak.*

297
README.md
View File

@@ -1,58 +1,31 @@
# 知识管理平台自动化工具 - Docker部署版
这是一个基于 Docker 的知识管理平台自动化工具支持多用户、定时任务、代理IP、VIP管理、金山文档集成等功能。
---
## 近期更新2026-02
- Socket.IO 运行模式已切换为 `eventlet`(生产优先)。
- 管理端前端增加请求缓存/去重,降低报表页重复请求压力。
- 默认 Docker 端口映射更新为 `51232 -> 51233`
- 已清理仓库中的历史清理报告与明显冗余文件。
这是一个基于 Docker 的知识管理平台自动化工具支持多用户、定时任务、代理IP、VIP管理等功能。
---
## 项目简介
本项目是一个 **Docker 容器化应用**,使用 Flask + Vue 3 + Requests + wkhtmltoimage + SQLite 构建,提供:
本项目是一个 **Docker 容器化应用**,使用 Flask + Requests + wkhtmltopdf + SQLite 构建,提供:
### 核心功能
- 多用户注册登录系统(支持邮箱绑定与验证
- 自动化浏览任务(纯 HTTP API 模拟,速度快)
- 智能截图系统wkhtmltoimage支持线程池
- 用户自定义定时任务(支持随机延迟)
- VIP 用户管理(账号数量限制、优先队列)
### 集成功能
- **金山文档集成** - 自动上传截图到在线表格,支持姓名搜索匹配
- **邮件通知** - 任务完成通知、密码重置、邮箱验证
- **代理IP支持** - 动态代理API集成
### 安全功能
- 威胁检测引擎JNDI/SQL注入/XSS/命令注入检测)
- IP/用户风险评分系统
- 自动黑名单机制
- 登录设备指纹追踪
### 管理功能
- 现代化 Vue 3 SPA 后台管理界面
- 公告系统(支持图片)
- Bug 反馈系统
- 任务日志与统计
- 多用户注册登录系统
- 自动化任务HTTP 模拟
- 定时任务调度
- 截图管理
- VIP用户管理
- 代理IP支持
- 后台管理系统
---
## 技术栈
- **后端**: Python 3.10+, Flask, Flask-SocketIO
- **前端**: Vue 3 + Vite + Element Plus (SPA)
- **数据库**: SQLite + 连接池
- **自动化**: Requests + BeautifulSoup (浏览)
- **截图**: wkhtmltoimage
- **金山文档**: Playwright (表格操作/上传)
- **后端**: Python 3.8+, Flask
- **数据库**: SQLite
- **自动化**: Requests + BeautifulSoup
- **截图**: wkhtmltopdf / wkhtmltoimage
- **容器化**: Docker + Docker Compose
- **实时通信**: Socket.IO (WebSocket)
- **前端**: HTML + JavaScript + Socket.IO
---
@@ -62,46 +35,30 @@
zsglpt/
├── app.py # 启动/装配入口
├── routes/ # 路由层Blueprint
│ ├── api_*.py # API 路由
│ ├── admin_api/ # 管理后台 API
│ └── pages.py # 页面路由
├── services/ # 业务服务层
│ ├── tasks.py # 任务调度器
│ ├── screenshots.py # 截图服务
│ ├── kdocs_uploader.py # 金山文档上传服务
│ └── schedule_*.py # 定时任务相关
├── security/ # 安全防护模块
│ ├── threat_detector.py # 威胁检测引擎
│ ├── risk_scorer.py # 风险评分
│ ├── blacklist.py # 黑名单管理
│ └── middleware.py # 安全中间件
├── realtime/ # SocketIO 事件与推送
├── database.py # 数据库稳定门面(对外 API
├── db/ # DB 分域实现 + schema/migrations
├── db_pool.py # 数据库连接池
├── api_browser.py # Requests 自动化(主浏览流程)
├── browser_pool_worker.py # wkhtmltoimage 截图线程池
├── browser_pool_worker.py # 截图 WorkerPool
├── app_config.py # 配置管理
├── app_logger.py # 日志系统
├── app_security.py # 安全工具函数
├── password_utils.py # 密码哈希工具
├── app_security.py # 安全模块
├── password_utils.py # 密码工具
├── crypto_utils.py # 加解密工具
├── email_service.py # 邮件服务SMTP
├── email_service.py # 邮件服务
├── requirements.txt # Python依赖
├── requirements-dev.txt # 开发依赖(不进生产镜像)
├── pyproject.toml # ruff/pytest 配置
├── pyproject.toml # ruff/black/pytest 配置
├── Dockerfile # Docker镜像构建文件
├── docker-compose.yml # Docker编排文件
├── templates/ # HTML模板SPA 入口
├── app.html # 用户端 SPA 入口
├── admin.html # 管理端 SPA 入口
│ └── email/ # 邮件模板
├── app-frontend/ # 用户端 Vue 源码
── admin-frontend/ # 管理端 Vue 源码
├── static/ # 前端构建产物
│ ├── app/ # 用户端 SPA 资源
│ └── admin/ # 管理端 SPA 资源
└── scripts/ # 维护脚本(例如健康监控)
├── templates/ # HTML模板SPA fallback
├── app-frontend/ # 用户端前端源码(可选保留)
├── admin-frontend/ # 后台前端源码(可选保留)
└── static/ # 前端构建产物(运行时使用)
├── app/ # 用户端 SPA
── admin/ # 后台 SPA
```
---
@@ -134,56 +91,20 @@ ssh -i /path/to/key root@your-server-ip
---
### 3. 配置加密密钥(重要!)
系统使用 Fernet 对称加密保护用户账号密码。**首次部署或迁移时必须正确配置加密密钥!**
#### 方式一:使用 .env 文件(推荐)
在项目根目录创建 `.env` 文件:
```bash
cd /www/wwwroot/zsglpt
# 生成随机密钥
python3 -c "from cryptography.fernet import Fernet; print(f'ENCRYPTION_KEY_RAW={Fernet.generate_key().decode()}')" > .env
# 设置权限(仅 root 可读)
chmod 600 .env
```
#### 方式二:已有密钥迁移
如果从其他服务器迁移,需要复制原有的密钥:
```bash
# 从旧服务器复制 .env 文件
scp root@old-server:/www/wwwroot/zsglpt/.env /www/wwwroot/zsglpt/
```
#### ⚠️ 重要警告
- **密钥丢失 = 所有加密密码无法解密**,必须重新录入所有账号密码
- `.env` 文件已在 `.gitignore` 中,不会被提交到 Git
- 建议将密钥备份到安全的地方(如密码管理器)
- 系统启动时会检测密钥,如果密钥丢失但存在加密数据,将拒绝启动并报错
---
## 快速部署
### 步骤1: 上传项目文件
将整个 `zsglpt` 文件夹上传到服务器的 `/www/wwwroot/` 目录:
将整个 `zsgpt2` 文件夹上传到服务器的 `/www/wwwroot/` 目录:
```bash
# 在本地执行Windows PowerShell 或 Git Bash
scp -r C:\Users\Administrator\Desktop\zsglpt root@your-server-ip:/www/wwwroot/
scp -r C:\Users\Administrator\Desktop\zsgpt2 root@your-server-ip:/www/wwwroot/
# 或者使用 FileZilla、WinSCP 等工具上传
```
上传后,服务器上的路径应该是:`/www/wwwroot/zsglpt/`
上传后,服务器上的路径应该是:`/www/wwwroot/zsgpt2/`
### 步骤2: SSH登录服务器
@@ -194,19 +115,16 @@ ssh root@your-server-ip
### 步骤3: 进入项目目录
```bash
cd /www/wwwroot/zsglpt
cd /www/wwwroot/zsgpt2
```
### 步骤4: 创建必要的目录
```bash
mkdir -p data logs 截图
chown -R 1000:1000 data logs 截图
chmod 750 data logs 截图
chmod 777 data logs 截图
```
> 说明:避免使用 `chmod 777`。如容器内运行用户不是 `1000:1000`,请改为实际 UID/GID。
### 步骤5: 构建并启动Docker容器
```bash
@@ -214,7 +132,7 @@ chmod 750 data logs 截图
docker build -t knowledge-automation .
# 启动容器
docker compose up -d
docker-compose up -d
# 查看容器状态
docker ps | grep knowledge-automation
@@ -229,8 +147,8 @@ docker logs -f knowledge-automation-multiuser
如果看到以下信息,说明启动成功:
```
服务器启动中...
用户访问地址: http://0.0.0.0:51233
后台管理地址: http://0.0.0.0:51233/yuyx
用户访问地址: http://0.0.0.0:5000
后台管理地址: http://0.0.0.0:5000/yuyx
```
---
@@ -264,7 +182,7 @@ server {
# 反向代理
location / {
proxy_pass http://127.0.0.1:51232;
proxy_pass http://127.0.0.1:5001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -306,15 +224,15 @@ certbot renew --dry-run
### 用户端
- **HTTP**: `http://your-server-ip:51232`
- **HTTP**: `http://your-server-ip:5001`
- **域名**: `http://your-domain.com` (配置Nginx后)
- **HTTPS**: `https://your-domain.com` (配置SSL后)
### 后台管理
- **后台地址**: `/yuyx`
- **管理员账号**: 以数据库现有账号为准(首次运行默认创建 `admin`
- **管理员密码**: 首次运行随机生成,请查看容器启动日志
- **路径**: `/yuyx`
- **默认账号**: `admin`
- **默认密码**: `admin`
**首次登录后请立即修改密码!**
@@ -372,7 +290,7 @@ docker logs -f knowledge-automation-multiuser
docker logs --tail 100 knowledge-automation-multiuser
# 查看应用日志文件
tail -f /www/wwwroot/zsglpt/logs/app.log
tail -f /www/wwwroot/zsgpt2/logs/app.log
```
### 进入容器
@@ -390,14 +308,14 @@ docker exec knowledge-automation-multiuser python -c "print('Hello')"
如果修改了代码,需要重新构建:
```bash
cd /www/wwwroot/zsglpt
cd /www/wwwroot/zsgpt2
# 停止并删除旧容器
docker compose down
docker-compose down
# 重新构建并启动
docker compose build
docker compose up -d
docker-compose build
docker-compose up -d
```
---
@@ -410,13 +328,13 @@ docker compose up -d
cd /www/wwwroot
# 备份整个项目
tar -czf zsglpt_backup_$(date +%Y%m%d).tar.gz zsglpt/
tar -czf zsgpt2_backup_$(date +%Y%m%d).tar.gz zsgpt2/
# 仅备份数据库
cp /www/wwwroot/zsglpt/data/app_data.db /backup/app_data_$(date +%Y%m%d).db
cp /www/wwwroot/zsgpt2/data/app_data.db /backup/app_data_$(date +%Y%m%d).db
# 备份截图
tar -czf screenshots_$(date +%Y%m%d).tar.gz /www/wwwroot/zsglpt/截图/
tar -czf screenshots_$(date +%Y%m%d).tar.gz /www/wwwroot/zsgpt2/截图/
```
### 2. 恢复数据
@@ -427,10 +345,10 @@ docker stop knowledge-automation-multiuser
# 恢复整个项目
cd /www/wwwroot
tar -xzf zsglpt_backup_20251027.tar.gz
tar -xzf zsgpt2_backup_20251027.tar.gz
# 恢复数据库
cp /backup/app_data_20251027.db /www/wwwroot/zsglpt/data/app_data.db
cp /backup/app_data_20251027.db /www/wwwroot/zsgpt2/data/app_data.db
# 重启容器
docker start knowledge-automation-multiuser
@@ -448,7 +366,7 @@ crontab -e
```bash
# 每天凌晨3点备份
0 3 * * * tar -czf /backup/zsglpt_$(date +\%Y\%m\%d).tar.gz /www/wwwroot/zsglpt/data
0 3 * * * tar -czf /backup/zsgpt2_$(date +\%Y\%m\%d).tar.gz /www/wwwroot/zsgpt2/data
```
---
@@ -457,19 +375,19 @@ crontab -e
### 1. 容器启动失败
**问题**: `docker compose up -d` 失败
**问题**: `docker-compose up -d` 失败
**解决方案**:
```bash
# 查看详细错误
docker compose logs
docker-compose logs
# 检查端口占用
netstat -tlnp | grep 51232
netstat -tlnp | grep 5001
# 重新构建
docker compose build --no-cache
docker compose up -d
docker-compose build --no-cache
docker-compose up -d
```
### 2. 502 Bad Gateway
@@ -482,10 +400,10 @@ docker compose up -d
docker ps | grep knowledge-automation
# 检查端口是否监听
netstat -tlnp | grep 51232
netstat -tlnp | grep 5001
# 测试直接访问
curl http://127.0.0.1:51232
curl http://127.0.0.1:5001
# 检查Nginx配置
nginx -t
@@ -501,7 +419,7 @@ nginx -t
docker restart knowledge-automation-multiuser
# 如果问题持续,优化数据库
cd /www/wwwroot/zsglpt
cd /www/wwwroot/zsgpt2
cp data/app_data.db data/app_data.db.backup
sqlite3 data/app_data.db "VACUUM;"
```
@@ -524,8 +442,8 @@ services:
然后重启:
```bash
docker compose down
docker compose up -d
docker-compose down
docker-compose up -d
```
### 5. 截图工具未安装
@@ -567,13 +485,13 @@ wkhtmltoimage --version
```bash
# 清理7天前的截图
find /www/wwwroot/zsglpt/截图 -name "*.jpg" -mtime +7 -delete
find /www/wwwroot/zsgpt2/截图 -name "*.jpg" -mtime +7 -delete
# 清理旧日志
find /www/wwwroot/zsglpt/logs -name "*.log" -mtime +30 -delete
find /www/wwwroot/zsgpt2/logs -name "*.log" -mtime +30 -delete
# 优化数据库
sqlite3 /www/wwwroot/zsglpt/data/app_data.db "VACUUM;"
sqlite3 /www/wwwroot/zsgpt2/data/app_data.db "VACUUM;"
```
---
@@ -594,9 +512,9 @@ firewall-cmd --permanent --add-port=80/tcp
firewall-cmd --permanent --add-port=443/tcp
firewall-cmd --reload
# 禁止直接访问51232端口仅Nginx可访问
iptables -A INPUT -p tcp --dport 51232 -s 127.0.0.1 -j ACCEPT
iptables -A INPUT -p tcp --dport 51232 -j DROP
# 禁止直接访问5001端口仅Nginx可访问
iptables -A INPUT -p tcp --dport 5001 -s 127.0.0.1 -j ACCEPT
iptables -A INPUT -p tcp --dport 5001 -j DROP
```
### 3. 启用HTTPS
@@ -637,13 +555,13 @@ systemctl restart sshd
```bash
# 统计今日任务数
grep "浏览完成" /www/wwwroot/zsglpt/logs/app.log | grep $(date +%Y-%m-%d) | wc -l
grep "浏览完成" /www/wwwroot/zsgpt2/logs/app.log | grep $(date +%Y-%m-%d) | wc -l
# 查看错误日志
grep "ERROR" /www/wwwroot/zsglpt/logs/app.log | tail -20
grep "ERROR" /www/wwwroot/zsgpt2/logs/app.log | tail -20
# 查看最近的登录
grep "登录成功" /www/wwwroot/zsglpt/logs/app.log | tail -10
grep "登录成功" /www/wwwroot/zsgpt2/logs/app.log | tail -10
```
### 3. 数据库维护
@@ -667,7 +585,7 @@ EOF
```bash
# 停止容器
docker compose down
docker-compose down
# 备份数据
cp -r data data.backup
@@ -677,8 +595,8 @@ cp -r 截图 截图.backup
# 使用 scp 或 FTP 工具上传
# 重新构建并启动
docker compose build
docker compose up -d
docker-compose build
docker-compose up -d
```
### 2. 数据库迁移
@@ -697,8 +615,8 @@ docker logs knowledge-automation-multiuser | grep "数据库"
| 端口 | 说明 | 映射 |
|------|------|------|
| 51233 | 容器内应用端口 | - |
| 51232 | 主机映射端口 | 容器51233 → 主机51232 |
| 5000 | 容器内应用端口 | - |
| 5001 | 主机映射端口 | 容器5000 → 主机5001 |
| 80 | HTTP端口 | Nginx |
| 443 | HTTPS端口 | Nginx |
@@ -710,8 +628,6 @@ docker logs knowledge-automation-multiuser | grep "数据库"
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| ENCRYPTION_KEY_RAW | 加密密钥Fernet格式优先级最高 | 从 .env 文件读取 |
| ENCRYPTION_KEY | 加密密钥会通过PBKDF2派生 | - |
| TZ | 时区 | Asia/Shanghai |
| PYTHONUNBUFFERED | Python输出缓冲 | 1 |
| WKHTMLTOIMAGE_PATH | wkhtmltoimage 可执行文件路径 | 自动探测 |
@@ -749,7 +665,7 @@ docker logs knowledge-automation-multiuser | grep "数据库"
遇到问题时,请按以下顺序检查:
1. **容器日志**: `docker logs knowledge-automation-multiuser`
2. **应用日志**: `cat /www/wwwroot/zsglpt/logs/app.log`
2. **应用日志**: `cat /www/wwwroot/zsgpt2/logs/app.log`
3. **Nginx日志**: `cat /var/log/nginx/zsgpt_error.log`
4. **系统资源**: `docker stats`, `htop`, `df -h`
@@ -761,9 +677,9 @@ docker logs knowledge-automation-multiuser | grep "数据库"
---
**文档版本**: v2.1
**更新日期**: 2026-02-07
**适用版本**: Docker多用户版 + Vue SPA
**文档版本**: v1.0
**更新日期**: 2025-10-29
**适用版本**: Docker多用户版
---
@@ -771,73 +687,26 @@ docker logs knowledge-automation-multiuser | grep "数据库"
```bash
# 1. 上传文件
scp -r zsglpt root@your-ip:/www/wwwroot/
scp -r zsgpt2 root@your-ip:/www/wwwroot/
# 2. SSH登录
ssh root@your-ip
# 3. 进入目录并创建必要目录
cd /www/wwwroot/zsglpt
cd /www/wwwroot/zsgpt2
mkdir -p data logs 截图
chmod 777 data logs 截图
# 4. 启动容器
docker compose up -d
docker-compose up -d
# 5. 查看日志
docker logs -f knowledge-automation-multiuser
# 6. 访问系统
# 浏览器打开: http://your-ip:51232
# 后台管理: http://your-ip:51232/yuyx
# 首次管理员密码会写入 data/default_admin_credentials.txt权限600
# 登录后请立即修改密码并删除该文件
# 浏览器打开: http://your-ip:5001
# 后台管理: http://your-ip:5001/yuyx
# 默认账号: admin / admin
```
完成!🎉
---
## 更新日志
### v2.0 (2026-01-08)
#### 新功能
- **金山文档集成**: 自动上传截图到金山文档表格
- 支持姓名搜索匹配单元格
- 支持配置有效行范围
- 支持覆盖已有图片
- 离线状态监控与邮件通知
- **Vue 3 SPA 前端**: 用户端和管理端全面升级为现代化单页应用
- Element Plus UI 组件库
- 实时任务状态更新
- 响应式设计
- **用户自定义定时任务**: 用户可创建自己的定时任务
- 支持多时间段配置
- 支持随机延迟
- 支持选择指定账号
- **安全防护系统**:
- 威胁检测引擎JNDI/SQL注入/XSS/命令注入)
- IP/用户风险评分
- 自动黑名单机制
- **邮件通知系统**:
- 任务完成通知
- 密码重置邮件
- 邮箱验证
- **公告系统**: 支持图片的系统公告
- **Bug反馈系统**: 用户可提交问题反馈
#### 优化
- **截图线程池**: wkhtmltoimage 截图支持多线程并发
- 线程池管理,按需启动
- 空闲自动释放资源
- **二次登录机制**: 刷新"上次登录时间"显示
- **API 预热**: 启动时预热连接,减少首次请求延迟
- **数据库连接池**: 提高并发性能
### v1.0 (2025-10-29)
- 初始版本
- 多用户系统
- 基础自动化任务
- 定时任务调度
- 代理IP支持

5
admin-frontend/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

View File

@@ -5,13 +5,8 @@ export async function updateAdminUsername(newUsername) {
return data
}
export async function updateAdminPassword(payload = {}) {
const currentPassword = String(payload.currentPassword || '')
const newPassword = String(payload.newPassword || '')
const { data } = await api.put('/admin/password', {
current_password: currentPassword,
new_password: newPassword,
})
export async function updateAdminPassword(newPassword) {
const { data } = await api.put('/admin/password', { new_password: newPassword })
return data
}
@@ -20,27 +15,3 @@ export async function logout() {
return data
}
export async function fetchAdminPasskeys() {
const { data } = await api.get('/admin/passkeys')
return data
}
export async function createAdminPasskeyOptions(payload = {}) {
const { data } = await api.post('/admin/passkeys/register/options', payload)
return data
}
export async function createAdminPasskeyVerify(payload = {}) {
const { data } = await api.post('/admin/passkeys/register/verify', payload)
return data
}
export async function deleteAdminPasskey(passkeyId) {
const { data } = await api.delete(`/admin/passkeys/${passkeyId}`)
return data
}
export async function reportAdminPasskeyClientError(payload = {}) {
const { data } = await api.post('/admin/passkeys/client-error', payload)
return data
}

View File

@@ -1,11 +1,7 @@
import { api } from './client'
import { createCachedGetter } from './cache'
const browserPoolStatsGetter = createCachedGetter(async () => {
export async function fetchBrowserPoolStats() {
const { data } = await api.get('/browser_pool/stats')
return data
}, 4_000)
export async function fetchBrowserPoolStats(options = {}) {
return browserPoolStatsGetter.run(options)
}

View File

@@ -1,46 +0,0 @@
export function createCachedGetter(fetcher, ttlMs = 0) {
let hasValue = false
let cachedValue = null
let expiresAt = 0
let inflight = null
async function run(options = {}) {
const force = Boolean(options?.force)
const now = Date.now()
if (!force && hasValue && now < expiresAt) {
return cachedValue
}
if (!force && inflight) {
return inflight
}
inflight = Promise.resolve()
.then(() => fetcher())
.then((data) => {
cachedValue = data
hasValue = true
const ttl = Math.max(0, Number(ttlMs) || 0)
expiresAt = Date.now() + ttl
return data
})
.finally(() => {
inflight = null
})
return inflight
}
function clear() {
hasValue = false
cachedValue = null
expiresAt = 0
inflight = null
}
return {
run,
clear,
}
}

View File

@@ -4,10 +4,6 @@ import { ElMessage, ElMessageBox } from 'element-plus'
let lastToastKey = ''
let lastToastAt = 0
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504])
const MAX_RETRY_COUNT = 1
const RETRY_BASE_DELAY_MS = 300
function toastErrorOnce(key, message, minIntervalMs = 1500) {
const now = Date.now()
if (key === lastToastKey && now - lastToastAt < minIntervalMs) return
@@ -22,41 +18,6 @@ function getCookie(name) {
return match ? decodeURIComponent(match[1]) : ''
}
function isIdempotentMethod(method) {
return ['GET', 'HEAD', 'OPTIONS'].includes(String(method || 'GET').toUpperCase())
}
function shouldRetryRequest(error) {
const config = error?.config
if (!config || config.__no_retry) return false
if (!isIdempotentMethod(config.method)) return false
const retried = Number(config.__retry_count || 0)
if (retried >= MAX_RETRY_COUNT) return false
const code = String(error?.code || '')
if (code === 'ECONNABORTED' || code === 'ERR_NETWORK') return true
const status = Number(error?.response?.status || 0)
return RETRYABLE_STATUS.has(status)
}
function delay(ms) {
return new Promise((resolve) => {
window.setTimeout(resolve, Math.max(0, Number(ms || 0)))
})
}
async function retryRequestOnce(error, client) {
const config = error?.config || {}
const retried = Number(config.__retry_count || 0)
config.__retry_count = retried + 1
const backoffMs = RETRY_BASE_DELAY_MS * (retried + 1)
await delay(backoffMs)
return client.request(config)
}
export const api = axios.create({
baseURL: '/yuyx/api',
timeout: 30_000,
@@ -104,7 +65,6 @@ api.interceptors.response.use(
const status = error?.response?.status
const payload = error?.response?.data
const message = payload?.error || payload?.message || error?.message || '请求失败'
const silent = Boolean(error?.config?.__silent)
if (payload?.code === 'reauth_required' && error?.config && !error.config.__reauth_retry) {
try {
@@ -116,32 +76,18 @@ api.interceptors.response.use(
}
}
if (shouldRetryRequest(error)) {
return retryRequestOnce(error, api)
}
if (status === 401) {
if (!silent) {
toastErrorOnce('401', message || '登录已过期,请重新登录', 3000)
}
toastErrorOnce('401', message || '登录已过期,请重新登录', 3000)
const pathname = window.location?.pathname || ''
if (!pathname.startsWith('/yuyx')) window.location.href = '/yuyx'
} else if (status === 403) {
if (!silent) {
toastErrorOnce('403', message || '需要管理员权限', 5000)
}
toastErrorOnce('403', message || '需要管理员权限', 5000)
} else if (status) {
if (!silent) {
toastErrorOnce(`http:${status}:${message}`, message)
}
toastErrorOnce(`http:${status}:${message}`, message)
} else if (error?.code === 'ECONNABORTED') {
if (!silent) {
toastErrorOnce('timeout', '请求超时', 3000)
}
toastErrorOnce('timeout', '请求超时', 3000)
} else {
if (!silent) {
toastErrorOnce(`net:${message}`, message, 3000)
}
toastErrorOnce(`net:${message}`, message, 3000)
}
return Promise.reject(error)

View File

@@ -1,10 +1,4 @@
import { api } from './client'
import { createCachedGetter } from './cache'
const emailStatsGetter = createCachedGetter(async () => {
const { data } = await api.get('/email/stats')
return data
}, 10_000)
export async function fetchEmailSettings() {
const { data } = await api.get('/email/settings')
@@ -13,12 +7,12 @@ export async function fetchEmailSettings() {
export async function updateEmailSettings(payload) {
const { data } = await api.post('/email/settings', payload)
emailStatsGetter.clear()
return data
}
export async function fetchEmailStats(options = {}) {
return emailStatsGetter.run(options)
export async function fetchEmailStats() {
const { data } = await api.get('/email/stats')
return data
}
export async function fetchEmailLogs(params) {
@@ -28,6 +22,6 @@ export async function fetchEmailLogs(params) {
export async function cleanupEmailLogs(days) {
const { data } = await api.post('/email/logs/cleanup', { days })
emailStatsGetter.clear()
return data
}

View File

@@ -1,40 +1,26 @@
import { api } from './client'
import { createCachedGetter } from './cache'
const FEEDBACK_STATS_TTL_MS = 10_000
const feedbackStatsGetter = createCachedGetter(async () => {
const { data } = await api.get('/feedbacks', { params: { limit: 1, offset: 0 } })
return data?.stats
}, FEEDBACK_STATS_TTL_MS)
export async function fetchFeedbacks(status = '') {
const { data } = await api.get('/feedbacks', { params: status ? { status } : {} })
return data
}
export async function fetchFeedbackStats(options = {}) {
return feedbackStatsGetter.run(options)
}
export function clearFeedbackStatsCache() {
feedbackStatsGetter.clear()
export async function fetchFeedbackStats() {
const { data } = await api.get('/feedbacks', { params: { limit: 1, offset: 0 } })
return data?.stats
}
export async function replyFeedback(feedbackId, reply) {
const { data } = await api.post(`/feedbacks/${feedbackId}/reply`, { reply })
clearFeedbackStatsCache()
return data
}
export async function closeFeedback(feedbackId) {
const { data } = await api.post(`/feedbacks/${feedbackId}/close`)
clearFeedbackStatsCache()
return data
}
export async function deleteFeedback(feedbackId) {
const { data } = await api.delete(`/feedbacks/${feedbackId}`)
clearFeedbackStatsCache()
return data
}

View File

@@ -1,7 +1,7 @@
import { api } from './client'
export async function fetchKdocsStatus(params = {}, requestConfig = {}) {
const { data } = await api.get('/kdocs/status', { params, ...requestConfig })
export async function fetchKdocsStatus(params = {}) {
const { data } = await api.get('/kdocs/status', { params })
return data
}

View File

@@ -0,0 +1,17 @@
import { api } from './client'
export async function fetchPasswordResets() {
const { data } = await api.get('/password_resets')
return data
}
export async function approvePasswordReset(requestId) {
const { data } = await api.post(`/password_resets/${requestId}/approve`)
return data
}
export async function rejectPasswordReset(requestId) {
const { data } = await api.post(`/password_resets/${requestId}/reject`)
return data
}

View File

@@ -1,17 +1,7 @@
import { api } from './client'
import { createCachedGetter } from './cache'
const SYSTEM_STATS_TTL_MS = 15_000
const systemStatsGetter = createCachedGetter(async () => {
export async function fetchSystemStats() {
const { data } = await api.get('/stats')
return data
}, SYSTEM_STATS_TTL_MS)
export async function fetchSystemStats(options = {}) {
return systemStatsGetter.run(options)
}
export function clearSystemStatsCache() {
systemStatsGetter.clear()
}

View File

@@ -1,18 +1,12 @@
import { api } from './client'
import { createCachedGetter } from './cache'
const systemConfigGetter = createCachedGetter(async () => {
export async function fetchSystemConfig() {
const { data } = await api.get('/system/config')
return data
}, 15_000)
export async function fetchSystemConfig(options = {}) {
return systemConfigGetter.run(options)
}
export async function updateSystemConfig(payload) {
const { data } = await api.post('/system/config', payload)
systemConfigGetter.clear()
return data
}
@@ -20,3 +14,4 @@ export async function executeScheduleNow() {
const { data } = await api.post('/schedule/execute', {})
return data
}

View File

@@ -1,58 +1,23 @@
import { api } from './client'
import { createCachedGetter } from './cache'
const serverInfoGetter = createCachedGetter(async () => {
export async function fetchServerInfo() {
const { data } = await api.get('/server/info')
return data
}, 30_000)
}
const dockerStatsGetter = createCachedGetter(async () => {
export async function fetchDockerStats() {
const { data } = await api.get('/docker_stats')
return data
}, 8_000)
}
const requestMetricsGetter = createCachedGetter(async () => {
const { data } = await api.get('/request_metrics')
return data
}, 10_000)
const slowSqlMetricsGetter = createCachedGetter(async () => {
const { data } = await api.get('/slow_sql_metrics')
return data
}, 10_000)
const taskStatsGetter = createCachedGetter(async () => {
export async function fetchTaskStats() {
const { data } = await api.get('/task/stats')
return data
}, 4_000)
}
const runningTasksGetter = createCachedGetter(async () => {
export async function fetchRunningTasks() {
const { data } = await api.get('/task/running')
return data
}, 2_000)
export async function fetchServerInfo(options = {}) {
return serverInfoGetter.run(options)
}
export async function fetchDockerStats(options = {}) {
return dockerStatsGetter.run(options)
}
export async function fetchRequestMetrics(options = {}) {
return requestMetricsGetter.run(options)
}
export async function fetchSlowSqlMetrics(options = {}) {
return slowSqlMetricsGetter.run(options)
}
export async function fetchTaskStats(options = {}) {
return taskStatsGetter.run(options)
}
export async function fetchRunningTasks(options = {}) {
return runningTasksGetter.run(options)
}
export async function fetchTaskLogs(params) {
@@ -62,7 +27,6 @@ export async function fetchTaskLogs(params) {
export async function clearOldTaskLogs(days) {
const { data } = await api.post('/task/logs/clear', { days })
taskStatsGetter.clear()
runningTasksGetter.clear()
return data
}

View File

@@ -0,0 +1,26 @@
import { api } from './client'
export async function fetchUpdateStatus() {
const { data } = await api.get('/update/status')
return data
}
export async function fetchUpdateResult() {
const { data } = await api.get('/update/result')
return data
}
export async function fetchUpdateLog(params = {}) {
const { data } = await api.get('/update/log', { params })
return data
}
export async function requestUpdateCheck() {
const { data } = await api.post('/update/check', {})
return data
}
export async function requestUpdateRun(payload = {}) {
const { data } = await api.post('/update/run', payload)
return data
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -1,164 +0,0 @@
<script setup>
const props = defineProps({
items: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
minWidth: {
type: Number,
default: 180,
},
})
</script>
<template>
<div class="metric-grid" :style="{ '--metric-min': `${minWidth}px` }">
<div
v-for="item in items"
:key="item?.key || item?.label"
class="metric-card"
:class="`metric-tone--${item?.tone || 'blue'}`"
>
<div class="metric-top">
<div v-if="item?.icon" class="metric-icon">
<el-icon><component :is="item.icon" /></el-icon>
</div>
<div class="metric-label">{{ item?.label || '-' }}</div>
</div>
<div class="metric-value">
<el-skeleton v-if="loading" :rows="1" animated />
<template v-else>{{ item?.value ?? 0 }}</template>
</div>
<div v-if="item?.hint || item?.sub" class="metric-hint app-muted">{{ item?.hint || item?.sub }}</div>
</div>
</div>
</template>
<style scoped>
.metric-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--metric-min), 1fr));
gap: 12px;
}
.metric-card {
position: relative;
overflow: hidden;
border-radius: 14px;
border: 1px solid var(--app-border);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(250, 252, 255, 0.9));
box-shadow: var(--app-shadow-soft);
padding: 13px 14px;
min-height: 104px;
}
.metric-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--metric-top, #3b82f6);
}
.metric-top {
display: flex;
align-items: center;
gap: 8px;
}
.metric-icon {
width: 26px;
height: 26px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: var(--metric-icon-bg, rgba(59, 130, 246, 0.12));
color: var(--metric-icon-color, #1d4ed8);
}
.metric-label {
font-size: 12px;
color: #475569;
font-weight: 700;
line-height: 1.4;
}
.metric-value {
margin-top: 10px;
font-size: 26px;
line-height: 1.05;
font-weight: 900;
color: #0f172a;
}
.metric-hint {
margin-top: 8px;
font-size: 12px;
line-height: 1.4;
}
.metric-tone--blue {
--metric-top: linear-gradient(90deg, #3b82f6, #06b6d4);
--metric-icon-bg: rgba(59, 130, 246, 0.14);
--metric-icon-color: #1d4ed8;
}
.metric-tone--green {
--metric-top: linear-gradient(90deg, #10b981, #22c55e);
--metric-icon-bg: rgba(16, 185, 129, 0.14);
--metric-icon-color: #047857;
}
.metric-tone--purple {
--metric-top: linear-gradient(90deg, #8b5cf6, #ec4899);
--metric-icon-bg: rgba(139, 92, 246, 0.14);
--metric-icon-color: #6d28d9;
}
.metric-tone--orange {
--metric-top: linear-gradient(90deg, #f59e0b, #f97316);
--metric-icon-bg: rgba(245, 158, 11, 0.14);
--metric-icon-color: #b45309;
}
.metric-tone--red {
--metric-top: linear-gradient(90deg, #ef4444, #f43f5e);
--metric-icon-bg: rgba(239, 68, 68, 0.14);
--metric-icon-color: #b91c1c;
}
.metric-tone--cyan {
--metric-top: linear-gradient(90deg, #06b6d4, #3b82f6);
--metric-icon-bg: rgba(6, 182, 212, 0.14);
--metric-icon-color: #0e7490;
}
@media (max-width: 768px) {
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-card {
min-height: 96px;
}
.metric-value {
font-size: 22px;
}
}
@media (max-width: 480px) {
.metric-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,51 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
stats: { type: Object, required: true },
loading: { type: Boolean, default: false },
})
const items = computed(() => [
{ key: 'total_users', label: '总用户数' },
{ key: 'new_users_today', label: '今日注册' },
{ key: 'new_users_7d', label: '近7天注册' },
{ key: 'total_accounts', label: '总账号数' },
{ key: 'vip_users', label: 'VIP用户' },
])
</script>
<template>
<el-row :gutter="12" class="stats-row">
<el-col v-for="it in items" :key="it.key" :xs="12" :sm="8" :md="6" :lg="4" :xl="4">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value">
<el-skeleton v-if="loading" :rows="1" animated />
<template v-else>{{ stats?.[it.key] ?? 0 }}</template>
</div>
<div class="stat-label">{{ it.label }}</div>
</el-card>
</el-col>
</el-row>
</template>
<style scoped>
.stats-row {
margin-bottom: 14px;
}
.stat-card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
box-shadow: var(--app-shadow);
}
.stat-value {
font-size: 22px;
font-weight: 800;
line-height: 1.1;
}
.stat-label {
margin-top: 6px;
font-size: 12px;
color: var(--app-muted);
}
</style>

View File

@@ -17,7 +17,6 @@ import {
import { api } from '../api/client'
import { fetchFeedbackStats } from '../api/feedbacks'
import { fetchSystemStats } from '../api/stats'
import { clearCachedKdocsStatus, preloadKdocsStatus } from '../utils/kdocsStatusCache'
const route = useRoute()
const router = useRouter()
@@ -26,17 +25,16 @@ const stats = ref({})
const adminUsername = computed(() => stats.value?.admin_username || '')
async function refreshStats(options = {}) {
stats.value = await fetchSystemStats(options)
async function refreshStats() {
try {
stats.value = await fetchSystemStats()
} finally {
}
}
const loadingBadges = ref(false)
const pendingFeedbackCount = ref(0)
const BADGE_POLL_ACTIVE_MS = 60_000
const BADGE_POLL_HIDDEN_MS = 180_000
let badgeTimer = null
let badgeTimer
async function refreshNavBadges(partial = null) {
if (partial && typeof partial === 'object') {
@@ -57,34 +55,6 @@ async function refreshNavBadges(partial = null) {
}
}
function isPageHidden() {
if (typeof document === 'undefined') return false
return document.visibilityState === 'hidden'
}
function currentBadgePollDelay() {
return isPageHidden() ? BADGE_POLL_HIDDEN_MS : BADGE_POLL_ACTIVE_MS
}
function stopBadgePolling() {
if (!badgeTimer) return
window.clearTimeout(badgeTimer)
badgeTimer = null
}
function scheduleBadgePolling() {
stopBadgePolling()
badgeTimer = window.setTimeout(async () => {
badgeTimer = null
await refreshNavBadges().catch(() => {})
scheduleBadgePolling()
}, currentBadgePollDelay())
}
function onVisibilityChange() {
scheduleBadgePolling()
}
provide('refreshStats', refreshStats)
provide('adminStats', stats)
provide('refreshNavBadges', refreshNavBadges)
@@ -103,19 +73,14 @@ onMounted(async () => {
mediaQuery.addEventListener?.('change', syncIsMobile)
syncIsMobile()
// 后台登录后预加载金山文档登录状态,系统配置页可直接复用缓存。
void preloadKdocsStatus({ maxAgeMs: 60_000, silent: true }).catch(() => {})
await refreshStats()
await refreshNavBadges()
scheduleBadgePolling()
window.addEventListener('visibilitychange', onVisibilityChange)
badgeTimer = window.setInterval(refreshNavBadges, 60_000)
})
onBeforeUnmount(() => {
mediaQuery?.removeEventListener?.('change', syncIsMobile)
stopBadgePolling()
window.removeEventListener('visibilitychange', onVisibilityChange)
window.clearInterval(badgeTimer)
})
const menuItems = [
@@ -141,30 +106,19 @@ function badgeFor(item) {
}
async function logout() {
let confirmed = false
try {
await ElMessageBox.confirm('确定退出管理员登录吗?', '退出登录', {
confirmButtonText: '退出',
cancelButtonText: '取消',
type: 'warning',
})
confirmed = true
} catch (error) {
const reason = String(error || '').toLowerCase()
if (reason === 'cancel' || reason === 'close') return
try {
confirmed = window.confirm('确定退出管理员登录吗?')
} catch {
confirmed = false
}
} catch {
return
}
if (!confirmed) return
try {
await api.post('/logout')
} finally {
clearCachedKdocsStatus()
window.location.href = '/yuyx'
}
}
@@ -208,27 +162,25 @@ async function go(path) {
<span class="app-muted">管理员</span>
<strong>{{ adminUsername || '-' }}</strong>
</div>
<el-button type="primary" plain class="logout-btn" @click="logout">退出</el-button>
<el-button type="primary" plain @click="logout">退出</el-button>
</div>
</el-header>
<el-main class="layout-main">
<div class="main-shell">
<Suspense>
<template #default>
<RouterView />
</template>
<template #fallback>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="fallback-card">
<el-skeleton :rows="5" animated />
</el-card>
</template>
</Suspense>
</div>
<Suspense>
<template #default>
<RouterView />
</template>
<template #fallback>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="fallback-card">
<el-skeleton :rows="5" animated />
</el-card>
</template>
</Suspense>
</el-main>
</el-container>
<el-drawer v-model="drawerOpen" size="min(82vw, 280px)" direction="ltr" :with-header="false">
<el-drawer v-model="drawerOpen" size="240px" :with-header="false">
<div class="drawer-brand">
<div class="brand-title">后台管理</div>
<div class="brand-sub app-muted">知识管理平台</div>
@@ -252,58 +204,31 @@ async function go(path) {
}
.layout-aside {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94));
background: #ffffff;
border-right: 1px solid var(--app-border);
box-shadow: 4px 0 16px rgba(15, 23, 42, 0.04);
}
.brand,
.drawer-brand {
padding: 18px 16px 14px;
}
.brand {
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
padding: 18px 16px 10px;
}
.drawer-brand {
padding: 18px 16px 10px;
}
.brand-title {
font-size: 16px;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.2px;
}
.brand-sub {
margin-top: 4px;
margin-top: 2px;
font-size: 12px;
}
.aside-menu {
border-right: none;
padding: 8px;
background: transparent;
}
.aside-menu :deep(.el-menu-item) {
height: 42px;
line-height: 42px;
margin: 3px 0;
border-radius: 10px;
color: #334155;
font-weight: 600;
}
.aside-menu :deep(.el-menu-item .el-icon) {
margin-right: 10px;
}
.aside-menu :deep(.el-menu-item:hover) {
background: rgba(59, 130, 246, 0.08);
color: #1d4ed8;
}
.aside-menu :deep(.el-menu-item.is-active) {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.12), rgba(124, 58, 237, 0.1));
color: #1e40af;
}
.menu-label {
@@ -318,22 +243,16 @@ async function go(path) {
}
.fallback-card {
min-height: 160px;
border-radius: var(--app-radius-lg);
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.layout-header {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
height: 58px;
padding: 0 18px;
background: rgba(255, 255, 255, 0.78);
background: rgba(246, 247, 251, 0.6);
backdrop-filter: saturate(180%) blur(10px);
border-bottom: 1px solid var(--app-border);
}
@@ -346,7 +265,7 @@ async function go(path) {
}
.header-title {
font-size: 15px;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
@@ -369,33 +288,18 @@ async function go(path) {
align-items: baseline;
gap: 8px;
font-size: 13px;
color: #334155;
}
.admin-name strong {
color: #0f172a;
font-weight: 800;
}
.logout-btn {
min-width: 74px;
}
.layout-main {
padding: 18px;
}
.main-shell {
width: 100%;
max-width: 1600px;
margin: 0 auto;
padding: 16px;
}
@media (max-width: 768px) {
.layout-header {
flex-wrap: wrap;
height: auto;
padding: 10px 12px;
padding-top: 10px;
padding-bottom: 10px;
}
.header-right {
@@ -407,10 +311,6 @@ async function go(path) {
display: none;
}
.admin-name strong {
display: none;
}
.layout-main {
padding: 12px;
}

View File

@@ -193,6 +193,9 @@ onMounted(load)
<div class="page-stack">
<div class="app-page-title">
<h2>公告管理</h2>
<div>
<el-button @click="load">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
@@ -288,22 +291,18 @@ onMounted(load)
.page-stack {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
gap: 12px;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
background: var(--app-card-bg);
box-shadow: var(--app-shadow-soft);
}
.section-title {
margin: 0 0 12px;
font-size: 15px;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.2px;
}
.help {
@@ -365,9 +364,6 @@ onMounted(load)
.table-wrap {
overflow-x: auto;
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
.ellipsis {

View File

@@ -12,7 +12,6 @@ import {
testSmtpConfig,
updateSmtpConfig,
} from '../api/smtp'
import MetricGrid from '../components/MetricGrid.vue'
// ========== 全局设置 ==========
const emailSettingsLoading = ref(false)
@@ -488,21 +487,6 @@ function emailLogUserLabel(row) {
return '系统'
}
const emailSummaryCards = computed(() => [
{ key: 'total_sent', label: '总发送', value: emailStats.value?.total_sent || 0, tone: 'blue' },
{ key: 'total_success', label: '成功', value: emailStats.value?.total_success || 0, tone: 'green' },
{ key: 'total_failed', label: '失败', value: emailStats.value?.total_failed || 0, tone: 'red' },
{ key: 'success_rate', label: '成功率', value: `${emailStats.value?.success_rate || 0}%`, tone: 'purple' },
])
const emailTypeCards = computed(() => [
{ key: 'register_sent', label: '注册验证', value: emailStats.value?.register_sent || 0, tone: 'cyan' },
{ key: 'reset_sent', label: '密码重置', value: emailStats.value?.reset_sent || 0, tone: 'orange' },
{ key: 'bind_sent', label: '邮箱绑定', value: emailStats.value?.bind_sent || 0, tone: 'purple' },
{ key: 'task_complete_sent', label: '任务完成', value: emailStats.value?.task_complete_sent || 0, tone: 'green' },
])
async function loadEmailStats() {
emailStatsLoading.value = true
try {
@@ -590,6 +574,9 @@ onMounted(refreshAll)
<div class="page-stack">
<div class="app-page-title">
<h2>邮件配置</h2>
<div class="toolbar">
<el-button @click="refreshAll">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card" v-loading="emailSettingsLoading">
@@ -681,10 +668,38 @@ onMounted(refreshAll)
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card" v-loading="emailStatsLoading">
<h3 class="section-title">邮件发送统计</h3>
<MetricGrid :items="emailSummaryCards" :loading="emailStatsLoading" :min-width="160" />
<el-row :gutter="12">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value">{{ emailStats.total_sent || 0 }}</div>
<div class="stat-label">总发送</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value ok">{{ emailStats.total_success || 0 }}</div>
<div class="stat-label">成功</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value err">{{ emailStats.total_failed || 0 }}</div>
<div class="stat-label">失败</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value">{{ emailStats.success_rate || 0 }}%</div>
<div class="stat-label">成功率</div>
</el-card>
</el-col>
</el-row>
<div class="sub-stats">
<MetricGrid :items="emailTypeCards" :loading="emailStatsLoading" :min-width="150" />
<el-tag effect="light">注册验证 {{ emailStats.register_sent || 0 }}</el-tag>
<el-tag effect="light">密码重置 {{ emailStats.reset_sent || 0 }}</el-tag>
<el-tag effect="light">邮箱绑定 {{ emailStats.bind_sent || 0 }}</el-tag>
<el-tag effect="light">任务完成 {{ emailStats.task_complete_sent || 0 }}</el-tag>
</div>
<div class="help app-muted">最后更新{{ emailStats.last_updated || '-' }}</div>
@@ -838,8 +853,7 @@ onMounted(refreshAll)
.page-stack {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
gap: 12px;
}
.toolbar {
@@ -852,8 +866,6 @@ onMounted(refreshAll)
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
background: var(--app-card-bg);
box-shadow: var(--app-shadow-soft);
}
.section-head {
@@ -867,9 +879,8 @@ onMounted(refreshAll)
.section-title {
margin: 0;
font-size: 15px;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.2px;
}
.help {
@@ -880,13 +891,37 @@ onMounted(refreshAll)
.table-wrap {
overflow-x: auto;
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
.stat-card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.stat-value {
font-size: 20px;
font-weight: 900;
line-height: 1.1;
}
.stat-label {
margin-top: 6px;
font-size: 12px;
color: var(--app-muted);
}
.ok {
color: #047857;
}
.err {
color: #b91c1c;
}
.sub-stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}

View File

@@ -1,9 +1,8 @@
<script setup>
import { computed, inject, onMounted, ref } from 'vue'
import { inject, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { closeFeedback, deleteFeedback, fetchFeedbacks, replyFeedback } from '../api/feedbacks'
import MetricGrid from '../components/MetricGrid.vue'
const refreshNavBadges = inject('refreshNavBadges', null)
@@ -19,13 +18,6 @@ const statusOptions = [
{ label: '已关闭', value: 'closed' },
]
const metricItems = computed(() => [
{ key: 'total', label: '总反馈', value: stats.value.total || 0, tone: 'blue' },
{ key: 'pending', label: '待处理', value: stats.value.pending || 0, tone: 'orange' },
{ key: 'replied', label: '已回复', value: stats.value.replied || 0, tone: 'green' },
{ key: 'closed', label: '已关闭', value: stats.value.closed || 0, tone: 'purple' },
])
function statusMeta(status) {
if (status === 'pending') return { label: '待处理', type: 'warning' }
if (status === 'replied') return { label: '已回复', type: 'success' }
@@ -125,17 +117,38 @@ onMounted(load)
<el-select v-model="statusFilter" style="width: 160px" @change="load">
<el-option v-for="o in statusOptions" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
<el-button @click="load">刷新</el-button>
</div>
</div>
<MetricGrid :items="metricItems" :loading="loading" :min-width="165" />
<el-row :gutter="12">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value">{{ stats.total || 0 }}</div>
<div class="stat-label">总计</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value warn">{{ stats.pending || 0 }}</div>
<div class="stat-label">待处理</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value ok">{{ stats.replied || 0 }}</div>
<div class="stat-label">已回复</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value">{{ stats.closed || 0 }}</div>
<div class="stat-label">已关闭</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<div class="section-head">
<h3 class="section-title">反馈列表</h3>
<div class="app-muted"> {{ list.length }} 当前筛选</div>
</div>
<div class="table-wrap">
<el-table :data="list" v-loading="loading" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
@@ -191,44 +204,43 @@ onMounted(load)
.page-stack {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
gap: 12px;
}
.toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.card {
.card,
.stat-card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
background: var(--app-card-bg);
box-shadow: var(--app-shadow-soft);
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.section-title {
margin: 0;
font-size: 15px;
.stat-value {
font-size: 20px;
font-weight: 800;
line-height: 1.1;
}
.stat-label {
margin-top: 6px;
font-size: 12px;
color: var(--app-muted);
}
.warn {
color: #b45309;
}
.ok {
color: #047857;
}
.table-wrap {
overflow-x: auto;
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
.ellipsis {

View File

@@ -143,6 +143,9 @@ onMounted(async () => {
<div class="page-stack">
<div class="app-page-title">
<h2>任务日志</h2>
<div class="toolbar">
<el-button @click="load">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
@@ -246,15 +249,12 @@ onMounted(async () => {
.page-stack {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
gap: 12px;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
background: var(--app-card-bg);
box-shadow: var(--app-shadow-soft);
}
.filters {
@@ -266,9 +266,6 @@ onMounted(async () => {
.table-wrap {
overflow-x: auto;
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
.ellipsis {

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,6 @@ import {
unbanIp,
unbanUser,
} from '../api/security'
import MetricGrid from '../components/MetricGrid.vue'
const pageSize = 20
@@ -120,27 +119,9 @@ const threatTypeOptions = computed(() => {
const dashboardCards = computed(() => {
const d = dashboard.value || {}
return [
{
key: 'threat_events_24h',
label: '最近24小时威胁事件',
value: normalizeCount(d.threat_events_24h),
tone: 'red',
hint: '用于衡量当前攻击面活跃度',
},
{
key: 'banned_ip_count',
label: '当前封禁 IP 数',
value: normalizeCount(d.banned_ip_count),
tone: 'orange',
hint: '自动与人工封禁总量',
},
{
key: 'banned_user_count',
label: '当前封禁用户数',
value: normalizeCount(d.banned_user_count),
tone: 'purple',
hint: '高风险账户拦截情况',
},
{ key: 'threat_events_24h', label: '最近24小时威胁事件', value: normalizeCount(d.threat_events_24h) },
{ key: 'banned_ip_count', label: '当前封禁IP数', value: normalizeCount(d.banned_ip_count) },
{ key: 'banned_user_count', label: '当前封禁用户数', value: normalizeCount(d.banned_user_count) },
]
})
@@ -465,12 +446,23 @@ onMounted(async () => {
<div class="app-page-title">
<h2>安全防护</h2>
<div class="toolbar">
<el-button @click="refreshAll">刷新</el-button>
<el-button type="warning" plain :loading="cleanupLoading" @click="onCleanup">清理过期记录</el-button>
<el-button type="primary" @click="openBanDialog()">手动封禁</el-button>
</div>
</div>
<MetricGrid :items="dashboardCards" :loading="dashboardLoading" :min-width="220" />
<el-row :gutter="12" class="stats-row">
<el-col v-for="it in dashboardCards" :key="it.key" :xs="24" :sm="8" :md="8" :lg="8" :xl="8">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value">
<el-skeleton v-if="dashboardLoading" :rows="1" animated />
<template v-else>{{ it.value }}</template>
</div>
<div class="stat-label">{{ it.label }}</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<el-tabs v-model="activeTab">
@@ -567,6 +559,7 @@ onMounted(async () => {
<el-tab-pane label="封禁管理" name="bans">
<div class="toolbar">
<el-button @click="loadBans">刷新封禁列表</el-button>
<el-button type="primary" @click="openBanDialog()">手动封禁</el-button>
</div>
@@ -738,8 +731,7 @@ onMounted(async () => {
.page-stack {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
gap: 12px;
}
.toolbar {
@@ -749,12 +741,13 @@ onMounted(async () => {
flex-wrap: wrap;
}
.stats-row {
margin-bottom: 2px;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
background: var(--app-card-bg);
box-shadow: var(--app-shadow-soft);
}
.sub-card {
@@ -763,6 +756,23 @@ onMounted(async () => {
border: 1px solid var(--app-border);
}
.stat-card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
box-shadow: var(--app-shadow);
}
.stat-value {
font-size: 22px;
font-weight: 800;
line-height: 1.1;
}
.stat-label {
margin-top: 6px;
font-size: 12px;
color: var(--app-muted);
}
.filters {
display: flex;
@@ -774,9 +784,6 @@ onMounted(async () => {
.table-wrap {
overflow-x: auto;
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
.ellipsis {

View File

@@ -1,31 +1,12 @@
<script setup>
import { onMounted, ref } from 'vue'
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createAdminPasskeyOptions,
createAdminPasskeyVerify,
deleteAdminPasskey,
fetchAdminPasskeys,
logout,
reportAdminPasskeyClientError,
updateAdminPassword,
updateAdminUsername,
} from '../api/admin'
import { createPasskey, getPasskeyClientErrorMessage, isPasskeyAvailable } from '../utils/passkey'
import { logout, updateAdminPassword, updateAdminUsername } from '../api/admin'
const username = ref('')
const currentPassword = ref('')
const password = ref('')
const confirmPassword = ref('')
const submitting = ref(false)
const passkeyLoading = ref(false)
const passkeyAddLoading = ref(false)
const passkeyDeviceName = ref('')
const passkeyItems = ref([])
const passkeyRegisterOptions = ref(null)
const passkeyRegisterOptionsAt = ref(0)
const PASSKEY_OPTIONS_PREFETCH_MAX_AGE_MS = 240000
function validateStrongPassword(value) {
const text = String(value || '')
@@ -76,31 +57,17 @@ async function saveUsername() {
}
async function savePassword() {
const currentValue = currentPassword.value
const value = password.value
const confirmValue = confirmPassword.value
if (!currentValue) {
ElMessage.error('请输入当前密码')
return
}
if (!value) {
ElMessage.error('请输入新密码')
return
}
const check = validateStrongPassword(value)
if (!check.ok) {
ElMessage.error(check.message)
return
}
if (value !== confirmValue) {
ElMessage.error('两次输入的新密码不一致')
return
}
try {
await ElMessageBox.confirm('确定修改管理员密码吗?修改后需要重新登录。', '修改密码', {
confirmButtonText: '确认修改',
@@ -113,11 +80,9 @@ async function savePassword() {
submitting.value = true
try {
await updateAdminPassword({ currentPassword: currentValue, newPassword: value })
await updateAdminPassword(value)
ElMessage.success('密码修改成功,请重新登录')
currentPassword.value = ''
password.value = ''
confirmPassword.value = ''
setTimeout(relogin, 1200)
} catch {
// handled by interceptor
@@ -125,117 +90,6 @@ async function savePassword() {
submitting.value = false
}
}
async function loadPasskeys() {
passkeyLoading.value = true
try {
const data = await fetchAdminPasskeys()
passkeyItems.value = Array.isArray(data?.items) ? data.items : []
if (passkeyItems.value.length < 3) {
await prefetchPasskeyRegisterOptions()
} else {
passkeyRegisterOptions.value = null
passkeyRegisterOptionsAt.value = 0
}
} catch {
passkeyItems.value = []
passkeyRegisterOptions.value = null
passkeyRegisterOptionsAt.value = 0
} finally {
passkeyLoading.value = false
}
}
function getCachedPasskeyRegisterOptions() {
if (!passkeyRegisterOptions.value) return null
if (Date.now() - Number(passkeyRegisterOptionsAt.value || 0) > PASSKEY_OPTIONS_PREFETCH_MAX_AGE_MS) return null
return passkeyRegisterOptions.value
}
async function prefetchPasskeyRegisterOptions() {
try {
const res = await createAdminPasskeyOptions({})
passkeyRegisterOptions.value = res
passkeyRegisterOptionsAt.value = Date.now()
} catch {
passkeyRegisterOptions.value = null
passkeyRegisterOptionsAt.value = 0
}
}
async function addPasskey() {
if (!isPasskeyAvailable()) {
ElMessage.error('当前浏览器或环境不支持Passkey需 HTTPS')
return
}
if (passkeyItems.value.length >= 3) {
ElMessage.error('最多可绑定3台设备')
return
}
passkeyAddLoading.value = true
try {
let optionsRes = getCachedPasskeyRegisterOptions()
if (!optionsRes) {
optionsRes = await createAdminPasskeyOptions({})
}
const credential = await createPasskey(optionsRes?.publicKey || {})
await createAdminPasskeyVerify({ credential, device_name: passkeyDeviceName.value.trim() })
passkeyRegisterOptions.value = null
passkeyRegisterOptionsAt.value = 0
passkeyDeviceName.value = ''
ElMessage.success('Passkey设备添加成功')
await loadPasskeys()
} catch (e) {
try {
await reportAdminPasskeyClientError({
stage: 'register',
source: 'admin-settings',
name: e?.name || '',
message: e?.message || '',
code: e?.code || '',
user_agent: navigator.userAgent || '',
})
} catch {
// ignore report failure
}
passkeyRegisterOptions.value = null
passkeyRegisterOptionsAt.value = 0
await prefetchPasskeyRegisterOptions()
const data = e?.response?.data
const message =
data?.error ||
getPasskeyClientErrorMessage(e, 'Passkey注册')
ElMessage.error(message)
} finally {
passkeyAddLoading.value = false
}
}
async function removePasskey(item) {
try {
await ElMessageBox.confirm(`确定删除设备「${item?.device_name || '未命名设备'}」吗?`, '删除Passkey设备', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await deleteAdminPasskey(item.id)
ElMessage.success('设备已删除')
await loadPasskeys()
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '删除失败')
}
}
onMounted(() => {
loadPasskeys()
})
</script>
<template>
@@ -258,16 +112,6 @@ onMounted(() => {
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">修改管理员密码</h3>
<el-form label-width="120px">
<el-form-item label="当前密码">
<el-input
v-model="currentPassword"
type="password"
show-password
placeholder="输入当前密码"
:disabled="submitting"
/>
</el-form-item>
<el-form-item label="新密码">
<el-input
v-model="password"
@@ -277,60 +121,10 @@ onMounted(() => {
:disabled="submitting"
/>
</el-form-item>
<el-form-item label="确认新密码">
<el-input
v-model="confirmPassword"
type="password"
show-password
placeholder="再次输入新密码"
:disabled="submitting"
/>
</el-form-item>
</el-form>
<el-button type="primary" :loading="submitting" @click="savePassword">保存密码</el-button>
<div class="help">建议使用更强密码至少8位且包含字母与数字</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">Passkey设备</h3>
<el-alert
type="info"
:closable="false"
title="最多可绑定3台设备可用于管理员无密码登录。"
show-icon
class="help-alert"
/>
<el-form inline>
<el-form-item label="设备备注">
<el-input
v-model="passkeyDeviceName"
placeholder="例如值班iPhone / 办公Mac"
maxlength="40"
show-word-limit
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="passkeyAddLoading" @click="addPasskey">添加Passkey设备</el-button>
</el-form-item>
</el-form>
<div v-loading="passkeyLoading">
<el-empty v-if="passkeyItems.length === 0" description="暂无Passkey设备" />
<el-table v-else :data="passkeyItems" size="small" style="width: 100%">
<el-table-column prop="device_name" label="设备备注" min-width="160" />
<el-table-column prop="credential_id_preview" label="凭据ID" min-width="180" />
<el-table-column prop="last_used_at" label="最近使用" min-width="140" />
<el-table-column prop="created_at" label="创建时间" min-width="140" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="danger" text @click="removePasskey(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
@@ -338,22 +132,18 @@ onMounted(() => {
.page-stack {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
gap: 12px;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
background: var(--app-card-bg);
box-shadow: var(--app-shadow-soft);
}
.section-title {
margin: 0 0 12px;
font-size: 15px;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.2px;
}
.help {
@@ -361,8 +151,4 @@ onMounted(() => {
font-size: 12px;
color: var(--app-muted);
}
.help-alert {
margin-bottom: 12px;
}
</style>

View File

@@ -2,26 +2,35 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchSystemConfig, updateSystemConfig } from '../api/system'
import { fetchSystemConfig, updateSystemConfig, executeScheduleNow } from '../api/system'
import { fetchKdocsQr, fetchKdocsStatus, clearKdocsLogin } from '../api/kdocs'
import { fetchProxyConfig, testProxy, updateProxyConfig } from '../api/proxy'
import { getCachedKdocsStatus, preloadKdocsStatus, updateCachedKdocsStatus } from '../utils/kdocsStatusCache'
const loading = ref(false)
// 并发
const maxConcurrentGlobal = ref(2)
const maxConcurrentPerAccount = ref(1)
const maxScreenshotConcurrent = ref(3)
const dbSlowQueryMs = ref(120)
// 定时
const scheduleEnabled = ref(false)
const scheduleTime = ref('02:00')
const scheduleBrowseType = ref('应读')
const scheduleWeekdays = ref(['1', '2', '3', '4', '5', '6', '7'])
const scheduleScreenshotEnabled = ref(true)
// 代理
const proxyEnabled = ref(false)
const proxyApiUrl = ref('')
const proxyExpireMinutes = ref(3)
// 自动审核
const autoApproveEnabled = ref(false)
const autoApproveHourlyLimit = ref(10)
const autoApproveVipDays = ref(7)
// 金山文档上传
const kdocsEnabled = ref(false)
const kdocsDocUrl = ref('')
const kdocsDefaultUnit = ref('')
@@ -29,47 +38,51 @@ const kdocsSheetName = ref('')
const kdocsSheetIndex = ref(0)
const kdocsUnitColumn = ref('A')
const kdocsImageColumn = ref('D')
const kdocsRowStart = ref(0)
const kdocsRowEnd = ref(0)
const kdocsAdminNotifyEnabled = ref(false)
const kdocsAdminNotifyEmail = ref('')
const initialKdocsStatus = getCachedKdocsStatus({ maxAgeMs: 10 * 60 * 1000 })
const kdocsStatus = ref(initialKdocsStatus || {})
const kdocsStatus = ref({})
const kdocsQrOpen = ref(false)
const kdocsQrImage = ref('')
const kdocsPolling = ref(false)
const kdocsStatusLoading = ref(false)
const kdocsQrLoading = ref(false)
const kdocsClearLoading = ref(false)
const kdocsSilentRefreshing = ref(!initialKdocsStatus)
const kdocsActionHint = ref('')
let kdocsPollingTimer = null
const weekdaysOptions = [
{ label: '周一', value: '1' },
{ label: '周二', value: '2' },
{ label: '周三', value: '3' },
{ label: '周四', value: '4' },
{ label: '周五', value: '5' },
{ label: '周六', value: '6' },
{ label: '周日', value: '7' },
]
const weekdayNames = {
1: '周一',
2: '周二',
3: '周三',
4: '周四',
5: '周五',
6: '周六',
7: '周日',
}
const scheduleWeekdayDisplay = computed(() =>
(scheduleWeekdays.value || [])
.map((d) => weekdayNames[Number(d)] || d)
.join('、'),
)
const kdocsActionBusy = computed(
() => kdocsStatusLoading.value || kdocsQrLoading.value || kdocsClearLoading.value,
)
const kdocsDetecting = computed(
() => kdocsSilentRefreshing.value || kdocsStatusLoading.value || kdocsPolling.value,
)
const kdocsStatusText = computed(() => {
if (kdocsDetecting.value) return '检测中'
const status = kdocsStatus.value || {}
if (status?.logged_in === true || status?.last_login_ok === true) return '已登录'
if (status?.logged_in === false || status?.last_login_ok === false || status?.login_required === true) return '未登录'
if (status?.last_error) return '异常'
return '未知'
})
const kdocsStatusClass = computed(() => {
if (kdocsDetecting.value) return 'is-checking'
if (kdocsStatusText.value === '已登录') return 'is-online'
if (kdocsStatusText.value === '未登录') return 'is-offline'
if (kdocsStatusText.value === '异常') return 'is-error'
return 'is-unknown'
})
function normalizeBrowseType(value) {
if (String(value) === '注册前未读') return '注册前未读'
return '应读'
}
function setKdocsHint(message) {
if (!message) {
@@ -83,15 +96,26 @@ function setKdocsHint(message) {
async function loadAll() {
loading.value = true
try {
const [system, proxy] = await Promise.all([
const [system, proxy, kdocsInfo] = await Promise.all([
fetchSystemConfig(),
fetchProxyConfig(),
fetchKdocsStatus().catch(() => ({})),
])
maxConcurrentGlobal.value = system.max_concurrent_global ?? 2
maxConcurrentPerAccount.value = system.max_concurrent_per_account ?? 1
maxScreenshotConcurrent.value = system.max_screenshot_concurrent ?? 3
dbSlowQueryMs.value = system.db_slow_query_ms ?? 120
scheduleEnabled.value = (system.schedule_enabled ?? 0) === 1
scheduleTime.value = system.schedule_time || '02:00'
scheduleBrowseType.value = normalizeBrowseType(system.schedule_browse_type)
const weekdays = String(system.schedule_weekdays || '1,2,3,4,5,6,7')
.split(',')
.map((x) => x.trim())
.filter(Boolean)
scheduleWeekdays.value = weekdays.length ? weekdays : ['1', '2', '3', '4', '5', '6', '7']
scheduleScreenshotEnabled.value = (system.enable_screenshot ?? 1) === 1
autoApproveEnabled.value = (system.auto_approve_enabled ?? 0) === 1
autoApproveHourlyLimit.value = system.auto_approve_hourly_limit ?? 10
@@ -108,42 +132,14 @@ async function loadAll() {
kdocsSheetIndex.value = system.kdocs_sheet_index ?? 0
kdocsUnitColumn.value = (system.kdocs_unit_column || 'A').toUpperCase()
kdocsImageColumn.value = (system.kdocs_image_column || 'D').toUpperCase()
kdocsRowStart.value = system.kdocs_row_start ?? 0
kdocsRowEnd.value = system.kdocs_row_end ?? 0
kdocsAdminNotifyEnabled.value = (system.kdocs_admin_notify_enabled ?? 0) === 1
kdocsAdminNotifyEmail.value = system.kdocs_admin_notify_email || ''
kdocsStatus.value = kdocsInfo || {}
} catch {
// handled by interceptor
} finally {
loading.value = false
}
const cachedStatus = getCachedKdocsStatus({ maxAgeMs: 10 * 60 * 1000 })
if (cachedStatus) {
kdocsStatus.value = cachedStatus
kdocsSilentRefreshing.value = false
}
// 静默刷新金山登录状态,确保状态持续更新且不阻塞首屏。
void refreshKdocsStatusSilently()
}
async function refreshKdocsStatusSilently() {
if (kdocsSilentRefreshing.value || kdocsStatusLoading.value) return
kdocsSilentRefreshing.value = true
try {
const status = await preloadKdocsStatus({
force: false,
maxAgeMs: 60_000,
silent: true,
live: 0,
})
kdocsStatus.value = status || {}
} catch {
// silent mode
} finally {
kdocsSilentRefreshing.value = false
}
}
async function saveConcurrency() {
@@ -151,12 +147,11 @@ async function saveConcurrency() {
max_concurrent_global: Number(maxConcurrentGlobal.value),
max_concurrent_per_account: Number(maxConcurrentPerAccount.value),
max_screenshot_concurrent: Number(maxScreenshotConcurrent.value),
db_slow_query_ms: Number(dbSlowQueryMs.value),
}
try {
await ElMessageBox.confirm(
`确定更新并发配置吗?\n\n全局并发数: ${payload.max_concurrent_global}\n单账号并发数: ${payload.max_concurrent_per_account}\n截图并发数: ${payload.max_screenshot_concurrent}\n慢 SQL 阈值: ${payload.db_slow_query_ms}ms`,
`确定更新并发配置吗?\n\n全局并发数: ${payload.max_concurrent_global}\n单账号并发数: ${payload.max_concurrent_per_account}\n截图并发数: ${payload.max_screenshot_concurrent}`,
'保存并发配置',
{ confirmButtonText: '保存', cancelButtonText: '取消', type: 'warning' },
)
@@ -172,6 +167,63 @@ async function saveConcurrency() {
}
}
async function saveSchedule() {
if (scheduleEnabled.value && (!scheduleWeekdays.value || scheduleWeekdays.value.length === 0)) {
ElMessage.error('请至少选择一个执行日期')
return
}
const payload = {
schedule_enabled: scheduleEnabled.value ? 1 : 0,
schedule_time: scheduleTime.value,
schedule_browse_type: scheduleBrowseType.value,
schedule_weekdays: (scheduleWeekdays.value || []).join(','),
enable_screenshot: scheduleScreenshotEnabled.value ? 1 : 0,
}
const screenshotText = scheduleScreenshotEnabled.value ? '截图' : '不截图'
const message = scheduleEnabled.value
? `确定启用定时任务吗?\n\n执行时间: 每天 ${payload.schedule_time}\n执行日期: ${scheduleWeekdayDisplay.value}\n浏览类型: ${payload.schedule_browse_type}\n截图: ${screenshotText}\n\n系统将自动执行所有账号的浏览任务`
: '确定关闭定时任务吗?'
try {
await ElMessageBox.confirm(message, '保存定时任务', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await updateSystemConfig(payload)
ElMessage.success(res?.message || (scheduleEnabled.value ? '定时任务已启用' : '定时任务已关闭'))
} catch {
// handled by interceptor
}
}
async function runScheduleNow() {
const msg = `确定要立即执行定时任务吗?\n\n这将执行所有账号的浏览任务\n浏览类型: ${scheduleBrowseType.value}\n\n注意无视定时时间和执行日期配置立即开始执行`
try {
await ElMessageBox.confirm(msg, '立即执行', {
confirmButtonText: '立即执行',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await executeScheduleNow()
ElMessage.success(res?.message || '定时任务已开始执行')
} catch {
// handled by interceptor
}
}
async function saveProxy() {
if (proxyEnabled.value && !proxyApiUrl.value.trim()) {
ElMessage.error('启用代理时API地址不能为空')
@@ -192,47 +244,6 @@ async function saveProxy() {
}
}
async function onTestProxy() {
if (!proxyApiUrl.value.trim()) {
ElMessage.error('请先输入代理API地址')
return
}
try {
const res = await testProxy({ api_url: proxyApiUrl.value.trim() })
await ElMessageBox.alert(res?.message || '测试完成', '代理测试', { confirmButtonText: '知道了' })
} catch {
// handled by interceptor
}
}
async function saveAutoApprove() {
const hourly = Number(autoApproveHourlyLimit.value)
const vipDays = Number(autoApproveVipDays.value)
if (!Number.isFinite(hourly) || hourly < 1) {
ElMessage.error('每小时注册限制必须大于0')
return
}
if (!Number.isFinite(vipDays) || vipDays < 0) {
ElMessage.error('VIP天数不能为负数')
return
}
const payload = {
auto_approve_enabled: autoApproveEnabled.value ? 1 : 0,
auto_approve_hourly_limit: hourly,
auto_approve_vip_days: vipDays,
}
try {
const res = await updateSystemConfig(payload)
ElMessage.success(res?.message || '注册设置已保存')
} catch {
// handled by interceptor
}
}
async function saveKdocsConfig() {
const payload = {
kdocs_enabled: kdocsEnabled.value ? 1 : 0,
@@ -242,8 +253,6 @@ async function saveKdocsConfig() {
kdocs_sheet_index: Number(kdocsSheetIndex.value) || 0,
kdocs_unit_column: kdocsUnitColumn.value.trim().toUpperCase(),
kdocs_image_column: kdocsImageColumn.value.trim().toUpperCase(),
kdocs_row_start: Number(kdocsRowStart.value) || 0,
kdocs_row_end: Number(kdocsRowEnd.value) || 0,
kdocs_admin_notify_enabled: kdocsAdminNotifyEnabled.value ? 1 : 0,
kdocs_admin_notify_email: kdocsAdminNotifyEmail.value.trim(),
}
@@ -261,9 +270,7 @@ async function refreshKdocsStatus() {
kdocsStatusLoading.value = true
setKdocsHint('正在刷新状态')
try {
const status = await fetchKdocsStatus({ live: 1 })
kdocsStatus.value = status || {}
updateCachedKdocsStatus(kdocsStatus.value)
kdocsStatus.value = await fetchKdocsStatus({ live: 1 })
setKdocsHint('状态已刷新')
} catch {
setKdocsHint('刷新失败,请稍后重试')
@@ -275,8 +282,7 @@ async function refreshKdocsStatus() {
async function pollKdocsStatus() {
try {
const status = await fetchKdocsStatus({ live: 1 })
kdocsStatus.value = status || {}
updateCachedKdocsStatus(kdocsStatus.value)
kdocsStatus.value = status
const loggedIn = status?.logged_in === true || status?.last_login_ok === true
if (loggedIn) {
ElMessage.success('扫码成功,已登录')
@@ -341,12 +347,6 @@ async function onClearKdocsLogin() {
await clearKdocsLogin()
kdocsQrOpen.value = false
kdocsQrImage.value = ''
kdocsStatus.value = updateCachedKdocsStatus({
...(kdocsStatus.value || {}),
logged_in: false,
last_login_ok: false,
login_required: true,
})
ElMessage.success('登录态已清除')
setKdocsHint('登录态已清除')
await refreshKdocsStatus()
@@ -369,6 +369,47 @@ onBeforeUnmount(() => {
stopKdocsPolling()
})
async function onTestProxy() {
if (!proxyApiUrl.value.trim()) {
ElMessage.error('请先输入代理API地址')
return
}
try {
const res = await testProxy({ api_url: proxyApiUrl.value.trim() })
await ElMessageBox.alert(res?.message || '测试完成', '代理测试', { confirmButtonText: '知道了' })
} catch {
// handled by interceptor
}
}
async function saveAutoApprove() {
const hourly = Number(autoApproveHourlyLimit.value)
const vipDays = Number(autoApproveVipDays.value)
if (!Number.isFinite(hourly) || hourly < 1) {
ElMessage.error('每小时注册限制必须大于0')
return
}
if (!Number.isFinite(vipDays) || vipDays < 0) {
ElMessage.error('VIP天数不能为负数')
return
}
const payload = {
auto_approve_enabled: autoApproveEnabled.value ? 1 : 0,
auto_approve_hourly_limit: hourly,
auto_approve_vip_days: vipDays,
}
try {
const res = await updateSystemConfig(payload)
ElMessage.success(res?.message || '注册设置已保存')
} catch {
// handled by interceptor
}
}
onMounted(loadAll)
</script>
@@ -376,107 +417,124 @@ onMounted(loadAll)
<div class="page-stack" v-loading="loading">
<div class="app-page-title">
<h2>系统配置</h2>
</div>
<div class="config-grid">
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card section-card">
<h3 class="section-title">并发配置</h3>
<div class="section-sub app-muted">控制任务与截图的并发资源上限</div>
<el-form label-width="122px">
<el-form-item label="全局最大并发数">
<el-input-number v-model="maxConcurrentGlobal" :min="1" :max="200" />
<div class="help">同时最多运行账号数浏览任务 API 执行资源占用较低</div>
</el-form-item>
<el-form-item label="单账号最大并发数">
<el-input-number v-model="maxConcurrentPerAccount" :min="1" :max="50" />
<div class="help">建议保持为 1避免同账号任务抢占</div>
</el-form-item>
<el-form-item label="截图最大并发数">
<el-input-number v-model="maxScreenshotConcurrent" :min="1" :max="50" />
<div class="help">截图资源占用较低可按机器性能逐步提高</div>
</el-form-item>
<el-form-item label="慢 SQL 阈值(ms)">
<el-input-number v-model="dbSlowQueryMs" :min="0" :max="60000" />
<div class="help">低于该阈值不会计入慢 SQL0 表示关闭慢 SQL 采样</div>
</el-form-item>
</el-form>
<div class="row-actions">
<el-button type="primary" @click="saveConcurrency">保存并发配置</el-button>
</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card section-card">
<h3 class="section-title">代理设置</h3>
<div class="section-sub app-muted">用于任务出网代理与连接有效期管理</div>
<el-form label-width="122px">
<el-form-item label="启用 IP 代理">
<el-switch v-model="proxyEnabled" />
<div class="help">开启后浏览任务通过代理访问失败自动重试</div>
</el-form-item>
<el-form-item label="代理 API 地址">
<el-input v-model="proxyApiUrl" placeholder="http://api.xxx/Tools/IP.ashx?..." />
<div class="help">API 应返回 `IP:PORT`123.45.67.89:8888</div>
</el-form-item>
<el-form-item label="有效期(分钟)">
<el-input-number v-model="proxyExpireMinutes" :min="1" :max="60" />
</el-form-item>
</el-form>
<div class="row-actions">
<el-button type="primary" @click="saveProxy">保存代理配置</el-button>
<el-button @click="onTestProxy">测试代理</el-button>
</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card section-card">
<h3 class="section-title">注册设置</h3>
<div class="section-sub app-muted">控制注册节流与新用户赠送 VIP</div>
<el-form label-width="122px">
<el-form-item label="注册赠送 VIP">
<el-switch v-model="autoApproveEnabled" />
<div class="help">开启后新用户注册成功自动赠送下方设定的 VIP 天数</div>
</el-form-item>
<el-form-item label="每小时注册限制">
<el-input-number v-model="autoApproveHourlyLimit" :min="1" :max="10000" />
</el-form-item>
<el-form-item label="赠送 VIP 天数">
<el-input-number v-model="autoApproveVipDays" :min="0" :max="999999" />
</el-form-item>
</el-form>
<div class="row-actions">
<el-button type="primary" @click="saveAutoApprove">保存注册设置</el-button>
</div>
</el-card>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card kdocs-card">
<div class="section-head">
<h3 class="section-title">金山文档上传</h3>
<div class="status-inline app-muted">
<span>登录状态</span>
<span class="status-chip" :class="kdocsStatusClass">
{{ kdocsStatusText }}
<span v-if="kdocsDetecting" class="status-dots" aria-hidden="true">
<i></i><i></i><i></i>
</span>
</span>
<span>· 待上传 {{ kdocsStatus.queue_size || 0 }}</span>
</div>
<div>
<el-button @click="loadAll">刷新</el-button>
</div>
</div>
<el-form label-width="118px" class="kdocs-form">
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">系统并发配置</h3>
<el-form label-width="130px">
<el-form-item label="全局最大并发数">
<el-input-number v-model="maxConcurrentGlobal" :min="1" :max="200" />
<div class="help">同时最多运行的账号数量浏览任务使用 API 方式资源占用较低</div>
</el-form-item>
<el-form-item label="单账号最大并发数">
<el-input-number v-model="maxConcurrentPerAccount" :min="1" :max="50" />
<div class="help">单个账号同时最多运行的任务数量建议设为 1</div>
</el-form-item>
<el-form-item label="截图最大并发数">
<el-input-number v-model="maxScreenshotConcurrent" :min="1" :max="50" />
<div class="help">同时进行截图的最大数量wkhtmltoimage 资源占用较低可按需提高</div>
</el-form-item>
</el-form>
<el-button type="primary" @click="saveConcurrency">保存并发配置</el-button>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">定时任务配置</h3>
<el-form label-width="130px">
<el-form-item label="启用定时任务">
<el-switch v-model="scheduleEnabled" />
<div class="help">开启后系统会按计划自动执行浏览任务</div>
</el-form-item>
<el-form-item v-if="scheduleEnabled" label="执行时间">
<el-time-picker v-model="scheduleTime" value-format="HH:mm" format="HH:mm" />
</el-form-item>
<el-form-item v-if="scheduleEnabled" label="浏览类型">
<el-select v-model="scheduleBrowseType" style="width: 220px">
<el-option label="注册前未读" value="注册前未读" />
<el-option label="应读" value="应读" />
</el-select>
</el-form-item>
<el-form-item v-if="scheduleEnabled" label="执行日期">
<el-checkbox-group v-model="scheduleWeekdays">
<el-checkbox v-for="w in weekdaysOptions" :key="w.value" :label="w.value">
{{ w.label }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item v-if="scheduleEnabled" label="定时任务截图">
<el-switch v-model="scheduleScreenshotEnabled" />
<div class="help">开启后定时任务执行时会生成截图</div>
</el-form-item>
</el-form>
<div class="row-actions">
<el-button type="primary" @click="saveSchedule">保存定时任务配置</el-button>
<el-button type="success" plain @click="runScheduleNow">立即执行</el-button>
</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">代理设置</h3>
<el-form label-width="130px">
<el-form-item label="启用IP代理">
<el-switch v-model="proxyEnabled" />
<div class="help">开启后所有浏览任务将通过代理IP访问失败自动重试3次</div>
</el-form-item>
<el-form-item label="代理API地址">
<el-input v-model="proxyApiUrl" placeholder="http://api.xxx/Tools/IP.ashx?..." />
<div class="help">API 应返回IP:PORT例如 123.45.67.89:8888</div>
</el-form-item>
<el-form-item label="代理有效期(分钟)">
<el-input-number v-model="proxyExpireMinutes" :min="1" :max="60" />
</el-form-item>
</el-form>
<div class="row-actions">
<el-button type="primary" @click="saveProxy">保存代理配置</el-button>
<el-button @click="onTestProxy">测试代理</el-button>
</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">注册设置</h3>
<el-form label-width="130px">
<el-form-item label="注册赠送VIP">
<el-switch v-model="autoApproveEnabled" />
<div class="help">开启后新用户注册成功后将赠送下方设置的VIP天数注册已默认无需审核</div>
</el-form-item>
<el-form-item label="每小时注册限制">
<el-input-number v-model="autoApproveHourlyLimit" :min="1" :max="10000" />
</el-form-item>
<el-form-item label="注册赠送VIP天数">
<el-input-number v-model="autoApproveVipDays" :min="0" :max="999999" />
</el-form-item>
</el-form>
<el-button type="primary" @click="saveAutoApprove">保存注册设置</el-button>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">金山文档上传</h3>
<el-form label-width="130px">
<el-form-item label="启用上传">
<el-switch v-model="kdocsEnabled" />
<div class="help">表格结构变化时可先关闭避免错误上传</div>
@@ -490,29 +548,21 @@ onMounted(loadAll)
<el-input v-model="kdocsDefaultUnit" placeholder="如:道县(用户可覆盖)" />
</el-form-item>
<el-form-item label="Sheet 名称">
<el-input v-model="kdocsSheetName" placeholder="留空使用第一个 Sheet" />
<el-form-item label="Sheet名称">
<el-input v-model="kdocsSheetName" placeholder="留空使用第一个Sheet" />
</el-form-item>
<el-form-item label="Sheet 序号">
<el-form-item label="Sheet序号">
<el-input-number v-model="kdocsSheetIndex" :min="0" :max="50" />
<div class="help">0 表示第一个 Sheet</div>
<div class="help">0 表示第一个Sheet</div>
</el-form-item>
<el-form-item label="列配置">
<div class="kdocs-inline">
<el-input v-model="kdocsUnitColumn" placeholder="县区列,如 A" />
<el-input v-model="kdocsImageColumn" placeholder="图片列,如 D" />
</div>
<el-form-item label="县区列">
<el-input v-model="kdocsUnitColumn" placeholder="A" style="max-width: 120px" />
</el-form-item>
<el-form-item label="有效行范围">
<div class="kdocs-range">
<el-input-number v-model="kdocsRowStart" :min="0" :max="10000" placeholder="起始行" style="width: 140px" />
<span class="app-muted"></span>
<el-input-number v-model="kdocsRowEnd" :min="0" :max="10000" placeholder="结束行" style="width: 140px" />
</div>
<div class="help">用于限制上传区间 50-1000 表示不限制</div>
<el-form-item label="图片列">
<el-input v-model="kdocsImageColumn" placeholder="D" style="max-width: 120px" />
</el-form-item>
<el-form-item label="管理员通知">
@@ -526,6 +576,13 @@ onMounted(loadAll)
<div class="row-actions">
<el-button type="primary" @click="saveKdocsConfig">保存表格配置</el-button>
<el-button
:loading="kdocsStatusLoading"
:disabled="kdocsActionBusy && !kdocsStatusLoading"
@click="refreshKdocsStatus"
>
刷新状态
</el-button>
<el-button
type="success"
plain
@@ -546,7 +603,14 @@ onMounted(loadAll)
</el-button>
</div>
<div v-if="kdocsStatus.last_error" class="help">最近错误{{ kdocsStatus.last_error }}</div>
<div class="help">
登录状态
<span v-if="kdocsStatus.last_login_ok === true">已登录</span>
<span v-else-if="kdocsStatus.login_required">需要扫码</span>
<span v-else>未知</span>
· 待上传 {{ kdocsStatus.queue_size || 0 }}
<span v-if="kdocsStatus.last_error">· 最近错误{{ kdocsStatus.last_error }}</span>
</div>
<div v-if="kdocsActionHint" class="help">操作提示{{ kdocsActionHint }}</div>
</el-card>
@@ -563,150 +627,18 @@ onMounted(loadAll)
.page-stack {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.config-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
gap: 12px;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
background: var(--app-card-bg);
box-shadow: var(--app-shadow-soft);
}
.section-card {
min-width: 0;
}
.section-title {
margin: 0;
font-size: 15px;
margin: 0 0 12px;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.2px;
}
.section-sub {
margin-top: 6px;
margin-bottom: 10px;
font-size: 12px;
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.status-inline {
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.status-chip {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
}
.status-chip.is-checking {
color: #1d4ed8;
background: #dbeafe;
border-color: #93c5fd;
}
.status-chip.is-online {
color: #065f46;
background: #d1fae5;
border-color: #6ee7b7;
}
.status-chip.is-offline {
color: #92400e;
background: #fef3c7;
border-color: #fcd34d;
}
.status-chip.is-error {
color: #991b1b;
background: #fee2e2;
border-color: #fca5a5;
}
.status-chip.is-unknown {
color: #374151;
background: #f3f4f6;
border-color: #d1d5db;
}
.status-dots {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 3px;
}
.status-dots i {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
opacity: 0.25;
animation: dotPulse 1.2s infinite ease-in-out;
}
.status-dots i:nth-child(2) {
animation-delay: 0.2s;
}
.status-dots i:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes dotPulse {
0%,
80%,
100% {
opacity: 0.25;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-1px);
}
}
.kdocs-form {
margin-top: 6px;
}
.kdocs-inline {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
width: 100%;
}
.kdocs-range {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.kdocs-qr {
@@ -736,24 +668,4 @@ onMounted(loadAll)
flex-wrap: wrap;
gap: 10px;
}
@media (max-width: 1200px) {
.config-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.config-grid {
grid-template-columns: 1fr;
}
.kdocs-inline {
grid-template-columns: 1fr;
}
.kdocs-range {
align-items: stretch;
}
}
</style>

View File

@@ -72,7 +72,7 @@ async function onEnableUser(row) {
await approveUser(row.id)
ElMessage.success('用户已启用')
await loadUsers()
await refreshStats?.({ force: true })
await refreshStats?.()
} catch {
// handled by interceptor
}
@@ -93,7 +93,7 @@ async function onDisableUser(row) {
await rejectUser(row.id)
ElMessage.success('用户已禁用')
await loadUsers()
await refreshStats?.({ force: true })
await refreshStats?.()
} catch {
// handled by interceptor
}
@@ -114,7 +114,7 @@ async function onDelete(row) {
await deleteUser(row.id)
ElMessage.success('用户已删除')
await loadUsers()
await refreshStats?.({ force: true })
await refreshStats?.()
} catch {
// handled by interceptor
}
@@ -136,7 +136,7 @@ async function onSetVip(row, days) {
const res = await setUserVip(row.id, days)
ElMessage.success(res?.message || 'VIP设置成功')
await loadUsers()
await refreshStats?.({ force: true })
await refreshStats?.()
} catch {
// handled by interceptor
}
@@ -157,7 +157,7 @@ async function onRemoveVip(row) {
const res = await removeUserVip(row.id)
ElMessage.success(res?.message || 'VIP已移除')
await loadUsers()
await refreshStats?.({ force: true })
await refreshStats?.()
} catch {
// handled by interceptor
}
@@ -210,6 +210,9 @@ onMounted(refreshAll)
<div class="page-stack">
<div class="app-page-title">
<h2>用户</h2>
<div>
<el-button @click="refreshAll">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
@@ -282,22 +285,18 @@ onMounted(refreshAll)
.page-stack {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
gap: 12px;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
background: var(--app-card-bg);
box-shadow: var(--app-shadow-soft);
}
.section-title {
margin: 0 0 12px;
font-size: 15px;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.2px;
}
.help {
@@ -307,9 +306,6 @@ onMounted(refreshAll)
.table-wrap {
overflow-x: auto;
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
.user-block {

View File

@@ -1,14 +1,10 @@
:root {
--app-bg: #f4f6fb;
--app-bg: #f6f7fb;
--app-text: #111827;
--app-muted: #6b7280;
--app-border: rgba(15, 23, 42, 0.1);
--app-border-strong: rgba(15, 23, 42, 0.14);
--app-border: rgba(17, 24, 39, 0.08);
--app-radius: 12px;
--app-radius-lg: 14px;
--app-shadow-soft: 0 8px 24px rgba(15, 23, 42, 0.05);
--app-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
--app-card-bg: rgba(255, 255, 255, 0.94);
--app-shadow: 0 8px 24px rgba(17, 24, 39, 0.06);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
@@ -24,17 +20,10 @@ body,
height: 100%;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--app-bg);
color: var(--app-text);
background:
radial-gradient(1200px 500px at -10% -10%, rgba(59, 130, 246, 0.12), transparent 55%),
radial-gradient(1000px 420px at 110% 0%, rgba(139, 92, 246, 0.1), transparent 50%),
var(--app-bg);
}
a {
@@ -47,13 +36,13 @@ a {
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 0 0 14px;
margin: 0 0 12px;
}
.app-page-title h2 {
margin: 0;
font-size: 19px;
font-weight: 800;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.2px;
}
@@ -61,72 +50,12 @@ a {
color: var(--app-muted);
}
.page-stack {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.el-card {
border-radius: var(--app-radius-lg);
border: 1px solid var(--app-border);
background: var(--app-card-bg);
box-shadow: var(--app-shadow-soft);
}
.el-button {
border-radius: 10px;
font-weight: 600;
}
.el-input__wrapper,
.el-textarea__inner,
.el-select__wrapper,
.el-input-number,
.el-picker__wrapper {
border-radius: 10px;
}
.el-table {
border-radius: 10px;
overflow: hidden;
}
.el-table th.el-table__cell {
background: #f8fafc;
color: #334155;
font-weight: 700;
}
.el-table td.el-table__cell,
.el-table th.el-table__cell {
padding-top: 11px;
padding-bottom: 11px;
}
.el-table .el-table__row:hover > td.el-table__cell {
background: #f8fbff;
}
.el-tag {
border-radius: 999px;
}
.el-dialog {
border-radius: var(--app-radius-lg);
}
@media (max-width: 768px) {
.app-page-title {
flex-wrap: wrap;
align-items: flex-start;
}
.app-page-title h2 {
font-size: 17px;
}
.el-dialog {
max-width: 92vw;
}
@@ -149,121 +78,3 @@ a {
width: 100%;
}
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.section-title {
margin: 0;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.2px;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.table-wrap {
overflow-x: auto;
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 14px;
flex-wrap: wrap;
}
.page-hint {
font-size: 12px;
}
.el-tabs__item {
font-weight: 700;
}
.el-form-item {
margin-bottom: 18px;
}
@media (max-width: 768px) {
.pagination {
justify-content: flex-start;
}
}
@media (max-width: 900px) {
.toolbar {
width: 100%;
}
.toolbar > * {
min-width: 0;
}
}
@media (max-width: 768px) {
.app-page-title > div {
width: 100%;
}
.app-page-title .toolbar {
width: 100%;
}
.toolbar > * {
flex: 1 1 calc(50% - 6px);
}
.toolbar .el-button,
.toolbar .el-select,
.toolbar .el-input,
.toolbar .el-input-number {
width: 100% !important;
}
.section-head {
align-items: flex-start;
}
.section-head > * {
width: 100%;
}
.table-wrap {
-webkit-overflow-scrolling: touch;
}
.table-wrap .el-table {
min-width: 700px;
}
.el-pagination {
width: 100%;
justify-content: flex-start;
}
}
@media (max-width: 520px) {
.toolbar > * {
flex-basis: 100%;
}
.table-wrap .el-table {
min-width: 620px;
}
}

View File

@@ -1,121 +0,0 @@
import { fetchKdocsStatus } from '../api/kdocs'
const CACHE_KEY = 'admin:kdocs:status:v1'
const DEFAULT_MAX_AGE_MS = 5 * 60 * 1000
let memoryStatus = null
let memoryUpdatedAt = 0
let inflightPromise = null
function nowTs() {
return Date.now()
}
function normalizeStatus(raw) {
if (!raw || typeof raw !== 'object') return {}
return raw
}
function readSessionCache() {
try {
const raw = window.sessionStorage.getItem(CACHE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object') return null
const updatedAt = Number(parsed.updated_at || 0)
const status = normalizeStatus(parsed.status)
if (!updatedAt) return null
return { status, updatedAt }
} catch {
return null
}
}
function writeSessionCache(status, updatedAt) {
try {
window.sessionStorage.setItem(
CACHE_KEY,
JSON.stringify({
status: normalizeStatus(status),
updated_at: Number(updatedAt || nowTs()),
}),
)
} catch {
// ignore
}
}
function hydrateFromSessionIfNeeded() {
if (memoryStatus !== null) return
const cached = readSessionCache()
if (!cached) return
memoryStatus = cached.status
memoryUpdatedAt = cached.updatedAt
}
function commitStatus(status) {
memoryStatus = normalizeStatus(status)
memoryUpdatedAt = nowTs()
writeSessionCache(memoryStatus, memoryUpdatedAt)
return memoryStatus
}
function isFresh(maxAgeMs) {
if (memoryStatus === null || !memoryUpdatedAt) return false
const ageLimit = Number(maxAgeMs)
if (!Number.isFinite(ageLimit) || ageLimit < 0) return true
return nowTs() - memoryUpdatedAt <= ageLimit
}
export function getCachedKdocsStatus(options = {}) {
hydrateFromSessionIfNeeded()
const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS
if (!isFresh(maxAgeMs)) return null
return normalizeStatus(memoryStatus)
}
export function updateCachedKdocsStatus(status) {
return commitStatus(status)
}
export function clearCachedKdocsStatus() {
memoryStatus = null
memoryUpdatedAt = 0
inflightPromise = null
try {
window.sessionStorage.removeItem(CACHE_KEY)
} catch {
// ignore
}
}
export async function preloadKdocsStatus(options = {}) {
const {
force = false,
maxAgeMs = DEFAULT_MAX_AGE_MS,
silent = true,
live = 0,
} = options
if (!force) {
const cached = getCachedKdocsStatus({ maxAgeMs })
if (cached) return cached
}
if (inflightPromise) return inflightPromise
const params = live ? { live: 1 } : {}
const requestConfig = {
__silent: Boolean(silent),
__no_retry: true,
timeout: 8000,
}
inflightPromise = fetchKdocsStatus(params, requestConfig)
.then((status) => commitStatus(status || {}))
.finally(() => {
inflightPromise = null
})
return inflightPromise
}

View File

@@ -1,130 +0,0 @@
function ensurePublicKeyOptions(options) {
if (!options || typeof options !== 'object') {
throw new Error('Passkey参数无效')
}
return options.publicKey && typeof options.publicKey === 'object' ? options.publicKey : options
}
function base64UrlToUint8Array(base64url) {
const value = String(base64url || '')
const padding = '='.repeat((4 - (value.length % 4)) % 4)
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = window.atob(base64)
const bytes = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i += 1) {
bytes[i] = raw.charCodeAt(i)
}
return bytes
}
function uint8ArrayToBase64Url(input) {
const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input || [])
let binary = ''
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i])
}
return window
.btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '')
}
function toCreationOptions(rawOptions) {
const options = ensurePublicKeyOptions(rawOptions)
const normalized = {
...options,
challenge: base64UrlToUint8Array(options.challenge),
user: {
...options.user,
id: base64UrlToUint8Array(options.user?.id),
},
}
if (Array.isArray(options.excludeCredentials)) {
normalized.excludeCredentials = options.excludeCredentials.map((item) => ({
...item,
id: base64UrlToUint8Array(item.id),
}))
}
return normalized
}
function serializeCredential(credential) {
if (!credential) return null
const response = credential.response || {}
const output = {
id: credential.id,
rawId: uint8ArrayToBase64Url(credential.rawId),
type: credential.type,
authenticatorAttachment: credential.authenticatorAttachment || undefined,
response: {},
}
if (response.clientDataJSON) {
output.response.clientDataJSON = uint8ArrayToBase64Url(response.clientDataJSON)
}
if (response.attestationObject) {
output.response.attestationObject = uint8ArrayToBase64Url(response.attestationObject)
}
if (response.authenticatorData) {
output.response.authenticatorData = uint8ArrayToBase64Url(response.authenticatorData)
}
if (response.signature) {
output.response.signature = uint8ArrayToBase64Url(response.signature)
}
if (response.userHandle) {
output.response.userHandle = uint8ArrayToBase64Url(response.userHandle)
} else {
output.response.userHandle = null
}
if (typeof response.getTransports === 'function') {
output.response.transports = response.getTransports() || []
}
return output
}
export function isPasskeyAvailable() {
return typeof window !== 'undefined' && window.isSecureContext && !!window.PublicKeyCredential && !!navigator.credentials
}
function isMiuiBrowser() {
const ua = String(window?.navigator?.userAgent || '')
return /MiuiBrowser|XiaoMi\/MiuiBrowser/i.test(ua)
}
export function getPasskeyClientErrorMessage(error, actionLabel = 'Passkey操作') {
const name = String(error?.name || '').trim()
const message = String(error?.message || '').trim()
if (name === 'NotAllowedError') {
return `${actionLabel}未完成(可能已取消、超时或设备未响应)`
}
if (name === 'NotReadableError') {
if (/credential manager/i.test(message) && isMiuiBrowser()) {
return '当前小米浏览器与系统凭据管理器兼容性较差,请改用系统 Chrome 或 Edge 后重试。'
}
if (/credential manager/i.test(message)) {
return '系统凭据管理器返回异常,请确认已设置系统锁屏并改用系统 Chrome/Edge 后重试。'
}
return message || `${actionLabel}失败(设备读取异常)`
}
if (name === 'SecurityError') {
return '当前环境安全策略不满足 Passkey 要求,请确认使用 HTTPS 且证书有效。'
}
return message || `${actionLabel}失败`
}
export async function createPasskey(rawOptions) {
const publicKey = toCreationOptions(rawOptions)
const credential = await navigator.credentials.create({ publicKey })
return serializeCredential(credential)
}

View File

@@ -8,25 +8,5 @@ export default defineConfig({
outDir: '../static/admin',
emptyOutDir: true,
manifest: true,
cssCodeSplit: true,
chunkSizeWarningLimit: 800,
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return undefined
if (id.includes('/node_modules/vue/') || id.includes('/node_modules/@vue/') || id.includes('/node_modules/vue-router/')) {
return 'vendor-vue'
}
if (id.includes('/node_modules/element-plus/') || id.includes('/node_modules/@element-plus/')) {
return 'vendor-element'
}
if (id.includes('/node_modules/axios/')) {
return 'vendor-axios'
}
return 'vendor-misc'
},
},
},
},
})

View File

@@ -15,78 +15,14 @@ import weakref
from typing import Optional, Callable
from dataclasses import dataclass
from urllib.parse import urlsplit
import threading
from app_config import get_config
import time as _time_module
_MODULE_START_TIME = _time_module.time()
_WARMUP_PERIOD_SECONDS = 60 # 启动后 60 秒内使用更长超时
_WARMUP_TIMEOUT_SECONDS = 15.0 # 预热期间的超时时间
# HTML解析缓存类
class HTMLParseCache:
"""HTML解析结果缓存"""
def __init__(self, ttl: int = 300, maxsize: int = 1000):
self.cache = {}
self.ttl = ttl
self.maxsize = maxsize
self._access_times = {}
self._lock = threading.RLock()
def _make_key(self, url: str, content_hash: str) -> str:
return f"{url}:{content_hash}"
def get(self, key: str) -> Optional[tuple]:
"""获取缓存,如果存在且未过期"""
with self._lock:
if key in self.cache:
value, timestamp = self.cache[key]
if time.time() - timestamp < self.ttl:
self._access_times[key] = time.time()
return value
else:
# 过期删除
del self.cache[key]
del self._access_times[key]
return None
def set(self, key: str, value: tuple):
"""设置缓存"""
with self._lock:
# 如果缓存已满,删除最久未访问的项
if len(self.cache) >= self.maxsize:
if self._access_times:
# 使用简单的LRU策略删除最久未访问的项
oldest_key = None
oldest_time = float("inf")
for key, access_time in self._access_times.items():
if access_time < oldest_time:
oldest_time = access_time
oldest_key = key
if oldest_key:
del self.cache[oldest_key]
del self._access_times[oldest_key]
self.cache[key] = (value, time.time())
self._access_times[key] = time.time()
def clear(self):
"""清空缓存"""
with self._lock:
self.cache.clear()
self._access_times.clear()
def get_lru_key(self) -> Optional[str]:
"""获取最久未访问的键"""
if not self._access_times:
return None
return min(self._access_times.keys(), key=lambda k: self._access_times[k])
config = get_config()
BASE_URL = getattr(config, "ZSGL_BASE_URL", "https://postoa.aidunsoft.com")
@@ -95,9 +31,7 @@ INDEX_URL_PATTERN = getattr(config, "ZSGL_INDEX_URL_PATTERN", "index.aspx")
COOKIES_DIR = getattr(config, "COOKIES_DIR", "data/cookies")
try:
_API_REQUEST_TIMEOUT_SECONDS = float(
os.environ.get("API_REQUEST_TIMEOUT_SECONDS") or os.environ.get("API_REQUEST_TIMEOUT") or "5"
)
_API_REQUEST_TIMEOUT_SECONDS = float(os.environ.get("API_REQUEST_TIMEOUT_SECONDS") or os.environ.get("API_REQUEST_TIMEOUT") or "5")
except Exception:
_API_REQUEST_TIMEOUT_SECONDS = 5.0
_API_REQUEST_TIMEOUT_SECONDS = max(3.0, _API_REQUEST_TIMEOUT_SECONDS)
@@ -117,11 +51,7 @@ def get_cookie_jar_path(username: str) -> str:
"""获取截图用的 cookies 文件路径Netscape Cookie 格式)"""
import hashlib
os.makedirs(COOKIES_DIR, mode=0o700, exist_ok=True)
try:
os.chmod(COOKIES_DIR, 0o700)
except Exception:
pass
os.makedirs(COOKIES_DIR, exist_ok=True)
filename = hashlib.sha256(username.encode()).hexdigest()[:32] + ".cookies.txt"
return os.path.join(COOKIES_DIR, filename)
@@ -136,7 +66,6 @@ def is_cookie_jar_fresh(cookie_path: str, max_age_seconds: int = _COOKIE_JAR_MAX
except Exception:
return False
_api_browser_instances: "weakref.WeakSet[APIBrowser]" = weakref.WeakSet()
@@ -155,7 +84,6 @@ atexit.register(_cleanup_api_browser_instances)
@dataclass
class APIBrowseResult:
"""API 浏览结果"""
success: bool
total_items: int = 0
total_attachments: int = 0
@@ -167,73 +95,34 @@ class APIBrowser:
def __init__(self, log_callback: Optional[Callable] = None, proxy_config: Optional[dict] = None):
self.session = requests.Session()
self.session.headers.update(
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}
)
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
})
self.logged_in = False
self.log_callback = log_callback
self.stop_flag = False
self._closed = False # 防止重复关闭
self.last_total_records = 0
# 初始化HTML解析缓存
self._parse_cache = HTMLParseCache(ttl=300, maxsize=500) # 5分钟缓存最多500条记录
# 设置代理
if proxy_config and proxy_config.get("server"):
proxy_server = proxy_config["server"]
self.session.proxies = {"http": proxy_server, "https": proxy_server}
self.session.proxies = {
"http": proxy_server,
"https": proxy_server
}
self.proxy_server = proxy_server
else:
self.proxy_server = None
_api_browser_instances.add(self)
def _calculate_adaptive_delay(self, iteration: int, consecutive_failures: int) -> float:
"""
智能延迟计算:文章处理延迟
根据迭代次数和连续失败次数动态调整延迟
"""
# 基础延迟,显著降低
base_delay = 0.03
# 如果有连续失败,增加延迟但有上限
if consecutive_failures > 0:
delay = base_delay * (1.5 ** min(consecutive_failures, 3))
return min(delay, 0.2) # 最多200ms
# 根据处理进度调整延迟,开始时较慢,后来可以更快
progress_factor = min(iteration / 100.0, 1.0) # 100个文章后达到最大优化
optimized_delay = base_delay * (1.2 - 0.4 * progress_factor) # 从120%逐渐降低到80%
return max(optimized_delay, 0.02) # 最少20ms
def _calculate_page_delay(self, current_page: int, new_articles_in_page: int) -> float:
"""
智能延迟计算:页面处理延迟
根据页面位置和新文章数量调整延迟
"""
base_delay = 0.08 # 基础延迟降低50%
# 如果当前页有大量新文章,可以稍微增加延迟
if new_articles_in_page > 10:
return base_delay * 1.2
# 如果是新页面,降低延迟(内容可能需要加载)
if current_page <= 3:
return base_delay * 1.1
# 后续页面可以更快
return base_delay * 0.8
def log(self, message: str):
"""记录日志"""
if self.log_callback:
self.log_callback(message)
def save_cookies_for_screenshot(self, username: str):
"""保存 cookies 供 wkhtmltoimage 使用Netscape Cookie 格式)"""
cookies_path = get_cookie_jar_path(username)
@@ -264,10 +153,6 @@ class APIBrowser:
with open(cookies_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
try:
os.chmod(cookies_path, 0o600)
except Exception:
pass
self.log(f"[API] Cookies已保存供截图使用")
return True
@@ -275,22 +160,24 @@ class APIBrowser:
self.log(f"[API] 保存cookies失败: {e}")
return False
def _request_with_retry(self, method, url, max_retries=3, retry_delay=1, **kwargs):
"""带重试机制的请求方法"""
# 启动后 60 秒内使用更长超时15秒之后使用配置的超时
if (_time_module.time() - _MODULE_START_TIME) < _WARMUP_PERIOD_SECONDS:
kwargs.setdefault("timeout", _WARMUP_TIMEOUT_SECONDS)
kwargs.setdefault('timeout', _WARMUP_TIMEOUT_SECONDS)
else:
kwargs.setdefault("timeout", _API_REQUEST_TIMEOUT_SECONDS)
kwargs.setdefault('timeout', _API_REQUEST_TIMEOUT_SECONDS)
last_error = None
timeout_value = kwargs.get("timeout")
diag_enabled = _API_DIAGNOSTIC_LOG
slow_ms = _API_DIAGNOSTIC_SLOW_MS
for attempt in range(1, max_retries + 1):
start_ts = _time_module.time()
try:
if method.lower() == "get":
if method.lower() == 'get':
resp = self.session.get(url, **kwargs)
else:
resp = self.session.post(url, **kwargs)
@@ -311,20 +198,19 @@ class APIBrowser:
if attempt < max_retries:
self.log(f"[API] 请求超时,{retry_delay}秒后重试 ({attempt}/{max_retries})...")
import time
time.sleep(retry_delay)
else:
self.log(f"[API] 请求失败,已重试{max_retries}次: {str(e)}")
raise last_error
def _get_aspnet_fields(self, soup):
"""获取 ASP.NET 隐藏字段"""
fields = {}
for name in ["__VIEWSTATE", "__VIEWSTATEGENERATOR", "__EVENTVALIDATION"]:
field = soup.find("input", {"name": name})
for name in ['__VIEWSTATE', '__VIEWSTATEGENERATOR', '__EVENTVALIDATION']:
field = soup.find('input', {'name': name})
if field:
fields[name] = field.get("value", "")
fields[name] = field.get('value', '')
return fields
def get_real_name(self) -> Optional[str]:
@@ -338,18 +224,18 @@ class APIBrowser:
try:
url = f"{BASE_URL}/admin/center.aspx"
resp = self._request_with_retry("get", url)
soup = BeautifulSoup(resp.text, "html.parser")
resp = self._request_with_retry('get', url)
soup = BeautifulSoup(resp.text, 'html.parser')
# 查找包含"姓名:"的元素
# 页面格式: <li><p>姓名:喻勇祥(19174616018) 人力资源编码: ...</p></li>
nlist = soup.find("div", {"class": "nlist-5"})
nlist = soup.find('div', {'class': 'nlist-5'})
if nlist:
first_li = nlist.find("li")
first_li = nlist.find('li')
if first_li:
text = first_li.get_text()
# 解析姓名:格式为 "姓名XXX(手机号)"
match = re.search(r"姓名[:]\s*([^\(]+)", text)
match = re.search(r'姓名[:]\s*([^\(]+)', text)
if match:
real_name = match.group(1).strip()
if real_name:
@@ -363,26 +249,26 @@ class APIBrowser:
self.log(f"[API] 登录: {username}")
try:
resp = self._request_with_retry("get", LOGIN_URL)
resp = self._request_with_retry('get', LOGIN_URL)
soup = BeautifulSoup(resp.text, "html.parser")
soup = BeautifulSoup(resp.text, 'html.parser')
fields = self._get_aspnet_fields(soup)
data = fields.copy()
data["txtUserName"] = username
data["txtPassword"] = password
data["btnSubmit"] = "登 录"
data['txtUserName'] = username
data['txtPassword'] = password
data['btnSubmit'] = '登 录'
resp = self._request_with_retry(
"post",
'post',
LOGIN_URL,
data=data,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Origin": BASE_URL,
"Referer": LOGIN_URL,
'Content-Type': 'application/x-www-form-urlencoded',
'Origin': BASE_URL,
'Referer': LOGIN_URL,
},
allow_redirects=True,
allow_redirects=True
)
if INDEX_URL_PATTERN in resp.url:
@@ -390,9 +276,9 @@ class APIBrowser:
self.log(f"[API] 登录成功")
return True
else:
soup = BeautifulSoup(resp.text, "html.parser")
error = soup.find(id="lblMsg")
error_msg = error.get_text().strip() if error else "未知错误"
soup = BeautifulSoup(resp.text, 'html.parser')
error = soup.find(id='lblMsg')
error_msg = error.get_text().strip() if error else '未知错误'
self.log(f"[API] 登录失败: {error_msg}")
return False
@@ -406,57 +292,55 @@ class APIBrowser:
return [], 0, None
if base_url and page > 1:
url = re.sub(r"page=\d+", f"page={page}", base_url)
url = re.sub(r'page=\d+', f'page={page}', base_url)
elif page > 1:
# 兼容兜底:若没有 next_url极少数情况下页面不提供“下一页”链接尝试直接拼 page 参数
url = f"{BASE_URL}/admin/center.aspx?bz={bz}&page={page}"
else:
url = f"{BASE_URL}/admin/center.aspx?bz={bz}"
resp = self._request_with_retry("get", url)
soup = BeautifulSoup(resp.text, "html.parser")
resp = self._request_with_retry('get', url)
soup = BeautifulSoup(resp.text, 'html.parser')
articles = []
ltable = soup.find("table", {"class": "ltable"})
ltable = soup.find('table', {'class': 'ltable'})
if ltable:
rows = ltable.find_all("tr")[1:]
rows = ltable.find_all('tr')[1:]
for row in rows:
# 检查是否是"暂无记录"
if "暂无记录" in row.get_text():
if '暂无记录' in row.get_text():
continue
link = row.find("a", href=True)
link = row.find('a', href=True)
if link:
href = link.get("href", "")
href = link.get('href', '')
title = link.get_text().strip()
match = re.search(r"id=(\d+)", href)
match = re.search(r'id=(\d+)', href)
article_id = match.group(1) if match else None
articles.append(
{
"title": title,
"href": href,
"article_id": article_id,
}
)
articles.append({
'title': title,
'href': href,
'article_id': article_id,
})
# 获取总页数
total_pages = 1
next_page_url = None
total_records = 0
page_content = soup.find(id="PageContent")
page_content = soup.find(id='PageContent')
if page_content:
text = page_content.get_text()
total_match = re.search(r"共(\d+)记录", text)
total_match = re.search(r'共(\d+)记录', text)
if total_match:
total_records = int(total_match.group(1))
total_pages = (total_records + 9) // 10
next_link = page_content.find("a", string=re.compile("下一页"))
next_link = page_content.find('a', string=re.compile('下一页'))
if next_link:
next_href = next_link.get("href", "")
next_href = next_link.get('href', '')
if next_href:
next_page_url = f"{BASE_URL}/admin/{next_href}"
@@ -467,83 +351,43 @@ class APIBrowser:
return articles, total_pages, next_page_url
def get_article_attachments(self, article_href: str):
"""获取文章的附件列表和文章信息"""
if not article_href.startswith("http"):
"""获取文章的附件列表"""
if not article_href.startswith('http'):
url = f"{BASE_URL}/admin/{article_href}"
else:
url = article_href
# 先检查缓存,避免不必要的请求
# 使用URL作为缓存键简化版本
cache_key = f"attachments_{hash(url)}"
cached_result = self._parse_cache.get(cache_key)
if cached_result:
return cached_result
resp = self._request_with_retry("get", url)
soup = BeautifulSoup(resp.text, "html.parser")
resp = self._request_with_retry('get', url)
soup = BeautifulSoup(resp.text, 'html.parser')
attachments = []
article_info = {"channel_id": None, "article_id": None}
# 从 saveread 按钮获取 channel_id 和 article_id
for elem in soup.find_all(["button", "input"]):
onclick = elem.get("onclick", "")
match = re.search(r"saveread\((\d+),(\d+)\)", onclick)
if match:
article_info["channel_id"] = match.group(1)
article_info["article_id"] = match.group(2)
break
attach_list = soup.find("div", {"class": "attach-list2"})
attach_list = soup.find('div', {'class': 'attach-list2'})
if attach_list:
items = attach_list.find_all("li")
items = attach_list.find_all('li')
for item in items:
download_links = item.find_all("a", onclick=re.compile(r"download2?\.ashx"))
download_links = item.find_all('a', onclick=re.compile(r'download\.ashx'))
for link in download_links:
onclick = link.get("onclick", "")
id_match = re.search(r"id=(\d+)", onclick)
channel_match = re.search(r"channel_id=(\d+)", onclick)
onclick = link.get('onclick', '')
id_match = re.search(r'id=(\d+)', onclick)
channel_match = re.search(r'channel_id=(\d+)', onclick)
if id_match:
attach_id = id_match.group(1)
channel_id = channel_match.group(1) if channel_match else "1"
h3 = item.find("h3")
filename = h3.get_text().strip() if h3 else f"附件{attach_id}"
attachments.append({"id": attach_id, "channel_id": channel_id, "filename": filename})
channel_id = channel_match.group(1) if channel_match else '1'
h3 = item.find('h3')
filename = h3.get_text().strip() if h3 else f'附件{attach_id}'
attachments.append({
'id': attach_id,
'channel_id': channel_id,
'filename': filename
})
break
result = (attachments, article_info)
# 存入缓存
self._parse_cache.set(cache_key, result)
return result
return attachments
def mark_article_read(self, channel_id: str, article_id: str) -> bool:
"""通过 saveread API 标记文章已读"""
if not channel_id or not article_id:
return False
import random
saveread_url = (
f"{BASE_URL}/tools/submit_ajax.ashx?action=saveread&time={random.random()}&fl={channel_id}&id={article_id}"
)
try:
resp = self._request_with_retry("post", saveread_url)
# 检查响应是否成功
if resp.status_code == 200:
try:
data = resp.json()
return data.get("status") == 1
except:
return True # 如果不是 JSON 但状态码 200也认为成功
return False
except:
return False
def mark_read(self, attach_id: str, channel_id: str = "1") -> bool:
"""通过访问预览通道标记附件已读"""
download_url = f"{BASE_URL}/tools/download2.ashx?site=main&id={attach_id}&channel_id={channel_id}"
def mark_read(self, attach_id: str, channel_id: str = '1') -> bool:
"""通过访问下载链接标记已读"""
download_url = f"{BASE_URL}/tools/download.ashx?site=main&id={attach_id}&channel_id={channel_id}"
try:
resp = self._request_with_retry("get", download_url, stream=True)
@@ -576,26 +420,28 @@ class APIBrowser:
return result
# 根据浏览类型确定 bz 参数
# 网站更新后参数: 0=应读, 1=已读(注册前未读需通过页面交互切换
# 网页实际参数: 0=注册前未读, 2=应读(历史上曾存在 1=已读,但当前逻辑不再使用
# 当前前端选项: 注册前未读、应读(默认应读)
browse_type_text = str(browse_type or "")
if "注册前" in browse_type_text:
bz = 0 # 注册前未读(暂与应读相同,网站通过页面状态区分)
if '注册前' in browse_type_text:
bz = 0 # 注册前未读
else:
bz = 0 # 应读
bz = 2 # 应读
self.log(f"[API] 开始浏览 '{browse_type}' (bz={bz})...")
try:
total_items = 0
total_attachments = 0
page = 1
base_url = None
skipped_items = 0
consecutive_failures = 0
max_consecutive_failures = 3
# 获取第一页,了解总记录数
# 获取第一页
try:
articles, total_pages, _ = self.get_article_list_page(bz, 1)
articles, total_pages, next_url = self.get_article_list_page(bz, page)
consecutive_failures = 0
except Exception as e:
result.error_message = str(e)
@@ -607,9 +453,14 @@ class APIBrowser:
result.success = True
return result
total_records = int(getattr(self, "last_total_records", 0) or 0)
self.log(f"[API] 共 {total_records} 条记录,开始处理...")
self.log(f"[API] 共 {total_pages} 页,开始处理...")
if next_url:
base_url = next_url
elif total_pages > 1:
base_url = f"{BASE_URL}/admin/center.aspx?bz={bz}&page=2"
total_records = int(getattr(self, "last_total_records", 0) or 0)
last_report_ts = 0.0
def report_progress(force: bool = False):
@@ -627,37 +478,31 @@ class APIBrowser:
report_progress(force=True)
# 循环处理:遍历所有页面,跟踪已处理文章防止重复
max_iterations = total_records + 20 # 防止无限循环
iteration = 0
processed_hrefs = set() # 跟踪已处理的文章,防止重复处理
current_page = 1
while articles and iteration < max_iterations:
iteration += 1
# 处理所有页面
while page <= total_pages:
if should_stop_callback and should_stop_callback():
self.log("[API] 收到停止信号")
break
new_articles_in_page = 0 # 本次迭代中新处理的文章数
# page==1 已取过,后续页在这里获取
if page > 1:
try:
articles, _, next_url = self.get_article_list_page(bz, page, base_url)
consecutive_failures = 0
if next_url:
base_url = next_url
except Exception as e:
self.log(f"[API] 获取第{page}页列表失败,终止本次浏览: {str(e)}")
raise
for article in articles:
if should_stop_callback and should_stop_callback():
break
article_href = article["href"]
# 跳过已处理的文章
if article_href in processed_hrefs:
continue
processed_hrefs.add(article_href)
new_articles_in_page += 1
title = article["title"][:30]
# 获取附件和文章信息(文章详情页)
title = article['title'][:30]
# 获取附件(文章详情页)
try:
attachments, article_info = self.get_article_attachments(article_href)
attachments = self.get_article_attachments(article['href'])
consecutive_failures = 0
except Exception as e:
skipped_items += 1
@@ -672,52 +517,21 @@ class APIBrowser:
total_items += 1
report_progress()
# 标记文章已读(调用 saveread API
article_marked = False
if article_info.get("channel_id") and article_info.get("article_id"):
article_marked = self.mark_article_read(article_info["channel_id"], article_info["article_id"])
# 处理附件(如果有)
if attachments:
for attach in attachments:
if self.mark_read(attach["id"], attach["channel_id"]):
if self.mark_read(attach['id'], attach['channel_id']):
total_attachments += 1
self.log(f"[API] [{total_items}] {title} - {len(attachments)}个附件")
else:
# 没有附件的文章,只记录标记状态
status = "已标记" if article_marked else "标记失败"
self.log(f"[API] [{total_items}] {title} - 无附件({status})")
# 智能延迟策略:根据连续失败次数和文章数量动态调整
time.sleep(self._calculate_adaptive_delay(total_items, consecutive_failures))
time.sleep(0.1)
time.sleep(self._calculate_page_delay(current_page, new_articles_in_page))
# 决定下一步获取哪一页
if new_articles_in_page > 0:
# 有新文章被处理重新获取第1页因为已读文章会从列表消失页面会上移
current_page = 1
else:
# 当前页没有新文章,尝试下一页
current_page += 1
if current_page > total_pages:
self.log(f"[API] 已遍历所有 {total_pages} 页,结束循环")
break
try:
articles, new_total_pages, _ = self.get_article_list_page(bz, current_page)
if new_total_pages > 0:
total_pages = new_total_pages
except Exception as e:
self.log(f"[API] 获取第{current_page}页列表失败: {str(e)}")
break
page += 1
time.sleep(0.2)
report_progress(force=True)
if skipped_items:
self.log(
f"[API] 浏览完成: {total_items} 条内容,{total_attachments} 个附件(跳过 {skipped_items} 条内容)"
)
self.log(f"[API] 浏览完成: {total_items} 条内容,{total_attachments} 个附件(跳过 {skipped_items} 条内容)")
else:
self.log(f"[API] 浏览完成: {total_items} 条内容,{total_attachments} 个附件")
@@ -774,7 +588,7 @@ def warmup_api_connection(proxy_config: Optional[dict] = None, log_callback: Opt
# 发送一个轻量级请求建立连接
resp = session.get(f"{BASE_URL}/admin/login.aspx", timeout=10, allow_redirects=False)
log(f"[OK] API 连接预热完成 (status={resp.status_code})")
log(f" API 连接预热完成 (status={resp.status_code})")
session.close()
return True
except Exception as e:

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>知识管理平台</title>
</head>
<body>
<noscript>该页面需要启用 JavaScript 才能使用。</noscript>
<div id="app"></div>
<script type="module" src="/src/login-main.js"></script>
</body>
</html>

View File

@@ -18,8 +18,6 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0",
"vite": "^7.2.4"
}
},
@@ -554,55 +552,12 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.7",
@@ -1202,19 +1157,6 @@
}
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
@@ -1260,22 +1202,6 @@
"node": ">= 0.4"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1288,13 +1214,6 @@
"node": ">= 0.8"
}
},
"node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"dev": true,
"license": "MIT"
},
"node_modules/copy-anything": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
@@ -1508,32 +1427,12 @@
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"dev": true,
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1718,31 +1617,6 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/local-pkg": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
"integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
"dev": true,
"license": "MIT",
"dependencies": {
"mlly": "^1.7.4",
"pkg-types": "^2.3.0",
"quansync": "^0.2.11"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -1819,38 +1693,6 @@
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mlly": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz",
"integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.15.0",
"pathe": "^2.0.3",
"pkg-types": "^1.3.1",
"ufo": "^1.6.1"
}
},
"node_modules/mlly/node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true,
"license": "MIT"
},
"node_modules/mlly/node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.1"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1881,24 +1723,6 @@
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause"
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@@ -1917,6 +1741,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1945,18 +1770,6 @@
}
}
},
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.2",
"exsolve": "^1.0.7",
"pathe": "^2.0.3"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -1991,37 +1804,6 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/antfu"
},
{
"type": "individual",
"url": "https://github.com/sponsors/sxzz"
}
],
"license": "MIT"
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
@@ -2070,13 +1852,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"dev": true,
"license": "MIT"
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
@@ -2123,19 +1898,6 @@
"node": ">=0.10.0"
}
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/superjson": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
@@ -2165,148 +1927,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/ufo": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"dev": true,
"license": "MIT"
},
"node_modules/unimport": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/unimport/-/unimport-5.6.0.tgz",
"integrity": "sha512-8rqAmtJV8o60x46kBAJKtHpJDJWkA2xcBqWKPI14MgUb05o1pnpnCnXSxedUXyeq7p8fR5g3pTo2BaswZ9lD9A==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.15.0",
"escape-string-regexp": "^5.0.0",
"estree-walker": "^3.0.3",
"local-pkg": "^1.1.2",
"magic-string": "^0.30.21",
"mlly": "^1.8.0",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"pkg-types": "^2.3.0",
"scule": "^1.3.0",
"strip-literal": "^3.1.0",
"tinyglobby": "^0.2.15",
"unplugin": "^2.3.11",
"unplugin-utils": "^0.3.1"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/unimport/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/unplugin": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"acorn": "^8.15.0",
"picomatch": "^4.0.3",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/unplugin-auto-import": {
"version": "21.0.0",
"resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-21.0.0.tgz",
"integrity": "sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"local-pkg": "^1.1.2",
"magic-string": "^0.30.21",
"picomatch": "^4.0.3",
"unimport": "^5.6.0",
"unplugin": "^2.3.11",
"unplugin-utils": "^0.3.1"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@nuxt/kit": "^4.0.0",
"@vueuse/core": "*"
},
"peerDependenciesMeta": {
"@nuxt/kit": {
"optional": true
},
"@vueuse/core": {
"optional": true
}
}
},
"node_modules/unplugin-utils": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
"integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
"dev": true,
"license": "MIT",
"dependencies": {
"pathe": "^2.0.3",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/unplugin-vue-components": {
"version": "31.0.0",
"resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-31.0.0.tgz",
"integrity": "sha512-4ULwfTZTLuWJ7+S9P7TrcStYLsSRkk6vy2jt/WTfgUEUb0nW9//xxmrfhyHUEVpZ2UKRRwfRb8Yy15PDbVZf+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^5.0.0",
"local-pkg": "^1.1.2",
"magic-string": "^0.30.21",
"mlly": "^1.8.0",
"obug": "^2.1.1",
"picomatch": "^4.0.3",
"tinyglobby": "^0.2.15",
"unplugin": "^2.3.11",
"unplugin-utils": "^0.3.1"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@nuxt/kit": "^3.2.2 || ^4.0.0",
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"@nuxt/kit": {
"optional": true
}
}
},
"node_modules/vite": {
"version": "7.2.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
@@ -2426,13 +2046,6 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true,
"license": "MIT"
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",

View File

@@ -19,8 +19,7 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0",
"vite": "^7.2.4"
}
}

View File

@@ -15,16 +15,6 @@ export async function login(payload) {
return data
}
export async function passkeyLoginOptions(payload) {
const { data } = await publicApi.post('/passkeys/login/options', payload)
return data
}
export async function passkeyLoginVerify(payload) {
const { data } = await publicApi.post('/passkeys/login/verify', payload)
return data
}
export async function register(payload) {
const { data } = await publicApi.post('/register', payload)
return data

View File

@@ -1,81 +1,15 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
let lastToastKey = ''
let lastToastAt = 0
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504])
const MAX_RETRY_COUNT = 1
const RETRY_BASE_DELAY_MS = 300
const TOAST_STYLE_ID = 'zsglpt-lite-toast-style'
function ensureToastStyle() {
if (typeof document === 'undefined') return
if (document.getElementById(TOAST_STYLE_ID)) return
const style = document.createElement('style')
style.id = TOAST_STYLE_ID
style.textContent = `
.zsglpt-lite-toast-wrap {
position: fixed;
right: 16px;
top: 16px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.zsglpt-lite-toast {
max-width: min(88vw, 420px);
padding: 10px 12px;
border-radius: 10px;
color: #fff;
font-size: 13px;
font-weight: 600;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.24);
opacity: 0;
transform: translateY(-6px);
transition: all .18s ease;
}
.zsglpt-lite-toast.is-visible {
opacity: 1;
transform: translateY(0);
}
.zsglpt-lite-toast.is-error {
background: linear-gradient(135deg, #ef4444, #dc2626);
}
`
document.head.appendChild(style)
}
function ensureToastWrap() {
if (typeof document === 'undefined') return null
ensureToastStyle()
let wrap = document.querySelector('.zsglpt-lite-toast-wrap')
if (wrap) return wrap
wrap = document.createElement('div')
wrap.className = 'zsglpt-lite-toast-wrap'
document.body.appendChild(wrap)
return wrap
}
function showLiteToast(message) {
const wrap = ensureToastWrap()
if (!wrap) return
const node = document.createElement('div')
node.className = 'zsglpt-lite-toast is-error'
node.textContent = String(message || '请求失败')
wrap.appendChild(node)
requestAnimationFrame(() => node.classList.add('is-visible'))
window.setTimeout(() => node.classList.remove('is-visible'), 2300)
window.setTimeout(() => node.remove(), 2600)
}
function toastErrorOnce(key, message, minIntervalMs = 1500) {
const now = Date.now()
if (key === lastToastKey && now - lastToastAt < minIntervalMs) return
lastToastKey = key
lastToastAt = now
showLiteToast(message)
ElMessage.error(message)
}
function getCookie(name) {
@@ -84,41 +18,6 @@ function getCookie(name) {
return match ? decodeURIComponent(match[1]) : ''
}
function isIdempotentMethod(method) {
return ['GET', 'HEAD', 'OPTIONS'].includes(String(method || 'GET').toUpperCase())
}
function shouldRetryRequest(error) {
const config = error?.config
if (!config || config.__no_retry) return false
if (!isIdempotentMethod(config.method)) return false
const retried = Number(config.__retry_count || 0)
if (retried >= MAX_RETRY_COUNT) return false
const code = String(error?.code || '')
if (code === 'ECONNABORTED' || code === 'ERR_NETWORK') return true
const status = Number(error?.response?.status || 0)
return RETRYABLE_STATUS.has(status)
}
function delay(ms) {
return new Promise((resolve) => {
window.setTimeout(resolve, Math.max(0, Number(ms || 0)))
})
}
async function retryRequestOnce(error, client) {
const config = error?.config || {}
const retried = Number(config.__retry_count || 0)
config.__retry_count = retried + 1
const backoffMs = RETRY_BASE_DELAY_MS * (retried + 1)
await delay(backoffMs)
return client.request(config)
}
export const publicApi = axios.create({
baseURL: '/api',
timeout: 30_000,
@@ -140,21 +39,14 @@ publicApi.interceptors.request.use((config) => {
publicApi.interceptors.response.use(
(response) => response,
(error) => {
if (shouldRetryRequest(error)) {
return retryRequestOnce(error, publicApi)
}
const status = error?.response?.status
const payload = error?.response?.data
const message = payload?.error || payload?.message || error?.message || '请求失败'
if (status === 401) {
toastErrorOnce('401', message || '登录已过期,请重新登录', 3000)
const pathname = window.location?.pathname || ''
// 登录页面不弹通知,让 LoginPage.vue 自己处理错误显示
if (!pathname.startsWith('/login')) {
toastErrorOnce('401', message || '登录已过期,请重新登录', 3000)
window.location.href = '/login'
}
if (!pathname.startsWith('/login')) window.location.href = '/login'
} else if (status === 403) {
toastErrorOnce('403', message || '无权限', 5000)
} else if (error?.code === 'ECONNABORTED') {

View File

@@ -1,7 +1,7 @@
import { publicApi } from './http'
export async function fetchSchedules(params = {}) {
const { data } = await publicApi.get('/schedules', { params })
export async function fetchSchedules() {
const { data } = await publicApi.get('/schedules')
return data
}
@@ -39,3 +39,4 @@ export async function clearScheduleLogs(scheduleId) {
const { data } = await publicApi.delete(`/schedules/${scheduleId}/logs`)
return data
}

View File

@@ -1,7 +1,7 @@
import { publicApi } from './http'
export async function fetchScreenshots(params = {}) {
const { data } = await publicApi.get('/screenshots', { params })
export async function fetchScreenshots() {
const { data } = await publicApi.get('/screenshots')
return data
}
@@ -14,3 +14,4 @@ export async function clearScreenshots() {
const { data } = await publicApi.post('/screenshots/clear', {})
return data
}

View File

@@ -39,33 +39,3 @@ export async function updateKdocsSettings(payload) {
const { data } = await publicApi.post('/user/kdocs', payload)
return data
}
export async function fetchKdocsStatus() {
const { data } = await publicApi.get('/kdocs/status')
return data
}
export async function fetchUserPasskeys() {
const { data } = await publicApi.get('/user/passkeys')
return data
}
export async function createUserPasskeyOptions(payload) {
const { data } = await publicApi.post('/user/passkeys/register/options', payload)
return data
}
export async function createUserPasskeyVerify(payload) {
const { data } = await publicApi.post('/user/passkeys/register/verify', payload)
return data
}
export async function deleteUserPasskey(passkeyId) {
const { data } = await publicApi.delete(`/user/passkeys/${passkeyId}`)
return data
}
export async function reportUserPasskeyClientError(payload) {
const { data } = await publicApi.post('/user/passkeys/client-error', payload || {})
return data
}

View File

@@ -3,28 +3,20 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Calendar, Camera, User } from '@element-plus/icons-vue'
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/message-box/style/css'
import { fetchActiveAnnouncement, dismissAnnouncement } from '../api/announcements'
import { fetchMyFeedbacks, submitFeedback } from '../api/feedback'
import {
bindEmail,
changePassword,
createUserPasskeyOptions,
createUserPasskeyVerify,
deleteUserPasskey,
fetchEmailNotify,
fetchUserPasskeys,
fetchUserEmail,
fetchKdocsSettings,
reportUserPasskeyClientError,
unbindEmail,
updateKdocsSettings,
updateEmailNotify,
} from '../api/settings'
import { useUserStore } from '../stores/user'
import { createPasskey, getPasskeyClientErrorMessage, isPasskeyAvailable } from '../utils/passkey'
import { validateStrongPassword } from '../utils/password'
const route = useRoute()
@@ -124,13 +116,6 @@ const passwordForm = reactive({
const kdocsLoading = ref(false)
const kdocsSaving = ref(false)
const kdocsUnitValue = ref('')
const passkeyLoading = ref(false)
const passkeyAddLoading = ref(false)
const passkeyDeviceName = ref('')
const passkeyItems = ref([])
const passkeyRegisterOptions = ref(null)
const passkeyRegisterOptionsAt = ref(0)
const PASSKEY_OPTIONS_PREFETCH_MAX_AGE_MS = 240000
function syncIsMobile() {
isMobile.value = Boolean(mediaQuery?.matches)
@@ -167,26 +152,16 @@ async function go(path) {
}
async function logout() {
let confirmed = false
try {
await ElMessageBox.confirm('确定退出登录吗?', '退出登录', {
confirmButtonText: '退出',
cancelButtonText: '取消',
type: 'warning',
})
confirmed = true
} catch (error) {
const reason = String(error || '').toLowerCase()
if (reason === 'cancel' || reason === 'close') return
try {
confirmed = window.confirm('确定退出登录吗?')
} catch {
confirmed = false
}
} catch {
return
}
if (!confirmed) return
await userStore.logout()
window.location.href = '/login'
}
@@ -262,7 +237,7 @@ async function openSettings() {
}
async function loadSettings() {
await Promise.all([loadEmailInfo(), loadEmailNotify(), loadKdocsSettings(), loadPasskeys()])
await Promise.all([loadEmailInfo(), loadEmailNotify(), loadKdocsSettings()])
}
async function loadEmailInfo() {
@@ -317,113 +292,6 @@ async function saveKdocsSettings() {
}
}
async function loadPasskeys() {
passkeyLoading.value = true
try {
const data = await fetchUserPasskeys()
passkeyItems.value = Array.isArray(data?.items) ? data.items : []
if (passkeyItems.value.length < 3) {
await prefetchPasskeyRegisterOptions()
} else {
passkeyRegisterOptions.value = null
passkeyRegisterOptionsAt.value = 0
}
} catch {
passkeyItems.value = []
passkeyRegisterOptions.value = null
passkeyRegisterOptionsAt.value = 0
} finally {
passkeyLoading.value = false
}
}
function getCachedPasskeyRegisterOptions() {
if (!passkeyRegisterOptions.value) return null
if (Date.now() - Number(passkeyRegisterOptionsAt.value || 0) > PASSKEY_OPTIONS_PREFETCH_MAX_AGE_MS) return null
return passkeyRegisterOptions.value
}
async function prefetchPasskeyRegisterOptions() {
try {
const res = await createUserPasskeyOptions({})
passkeyRegisterOptions.value = res
passkeyRegisterOptionsAt.value = Date.now()
} catch {
passkeyRegisterOptions.value = null
passkeyRegisterOptionsAt.value = 0
}
}
async function onAddPasskey() {
if (!isPasskeyAvailable()) {
ElMessage.error('当前浏览器或环境不支持Passkey需 HTTPS')
return
}
if (passkeyItems.value.length >= 3) {
ElMessage.error('最多可绑定3台设备')
return
}
passkeyAddLoading.value = true
try {
let optionsRes = getCachedPasskeyRegisterOptions()
if (!optionsRes) {
optionsRes = await createUserPasskeyOptions({})
}
const credential = await createPasskey(optionsRes?.publicKey || {})
await createUserPasskeyVerify({ credential, device_name: passkeyDeviceName.value.trim() })
passkeyRegisterOptions.value = null
passkeyRegisterOptionsAt.value = 0
passkeyDeviceName.value = ''
ElMessage.success('Passkey设备添加成功')
await loadPasskeys()
} catch (e) {
try {
await reportUserPasskeyClientError({
stage: 'register',
source: 'user-settings',
name: e?.name || '',
message: e?.message || '',
code: e?.code || '',
user_agent: navigator.userAgent || '',
})
} catch {
// ignore report failure
}
passkeyRegisterOptions.value = null
passkeyRegisterOptionsAt.value = 0
await prefetchPasskeyRegisterOptions()
const data = e?.response?.data
const message =
data?.error ||
getPasskeyClientErrorMessage(e, 'Passkey注册')
ElMessage.error(message)
} finally {
passkeyAddLoading.value = false
}
}
async function onDeletePasskey(item) {
try {
await ElMessageBox.confirm(`确定删除设备「${item?.device_name || '未命名设备'}」吗?`, '删除Passkey设备', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await deleteUserPasskey(item.id)
ElMessage.success('设备已删除')
await loadPasskeys()
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '删除失败')
}
}
async function onBindEmail() {
const email = bindEmailValue.value.trim().toLowerCase()
if (!email) {
@@ -797,47 +665,6 @@ async function dismissAnnouncementPermanently() {
</div>
</el-tab-pane>
<el-tab-pane label="Passkey设备" name="passkeys">
<div class="settings-section" v-loading="passkeyLoading">
<el-alert
type="info"
:closable="false"
title="最多可绑定3台设备用于无密码登录。"
show-icon
class="settings-alert"
/>
<el-form inline>
<el-form-item label="设备备注">
<el-input
v-model="passkeyDeviceName"
placeholder="例如我的iPhone / 办公Mac"
maxlength="40"
show-word-limit
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="passkeyAddLoading" @click="onAddPasskey">
添加Passkey设备
</el-button>
</el-form-item>
</el-form>
<el-empty v-if="passkeyItems.length === 0" description="暂无Passkey设备" />
<el-table v-else :data="passkeyItems" size="small" style="width: 100%">
<el-table-column prop="device_name" label="设备备注" min-width="160" />
<el-table-column prop="credential_id_preview" label="凭据ID" min-width="180" />
<el-table-column prop="last_used_at" label="最近使用" min-width="140" />
<el-table-column prop="created_at" label="创建时间" min-width="140" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="danger" text @click="onDeletePasskey(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="表格上传" name="kdocs">
<div v-loading="kdocsLoading" class="settings-section">
<el-form label-position="top">

View File

@@ -1,6 +0,0 @@
import { createApp } from 'vue'
import LoginPage from './pages/LoginPage.vue'
import './style.css'
createApp(LoginPage).mount('#app')

View File

@@ -5,6 +5,11 @@ import router from './router'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import './style.css'
createApp(App).use(createPinia()).use(router).mount('#app')
createApp(App).use(createPinia()).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')

View File

@@ -15,7 +15,7 @@ import {
updateAccount,
updateAccountRemark,
} from '../api/accounts'
import { fetchKdocsSettings, fetchKdocsStatus, updateKdocsSettings } from '../api/settings'
import { fetchKdocsSettings, updateKdocsSettings } from '../api/settings'
import { fetchRunStats } from '../api/stats'
import { useSocket } from '../composables/useSocket'
import { useUserStore } from '../stores/user'
@@ -61,14 +61,6 @@ watch(batchEnableScreenshot, (value) => {
const kdocsAutoUpload = ref(false)
const kdocsSettingsLoading = ref(false)
// KDocs 在线状态
const kdocsStatus = reactive({
enabled: false,
online: false,
message: '',
})
const kdocsStatusLoading = ref(false)
const addOpen = ref(false)
const editOpen = ref(false)
const upgradeOpen = ref(false)
@@ -147,12 +139,10 @@ function toPercent(acc) {
function statusTagType(status = '') {
const text = String(status)
if (text.includes('已完成') || text.includes('完成')) return 'success' // 绿色
if (text.includes('失败') || text.includes('错误') || text.includes('异常') || text.includes('登录失败')) return 'danger' // 红色
if (text.includes('上传截图')) return 'danger' // 上传中:红色
if (text.includes('等待上传')) return 'warning' // 等待上传:黄色
if (text.includes('排队') || text.includes('运行') || text.includes('截图')) return 'warning' // 黄色
return 'info' // 灰色
if (text.includes('已完成') || text.includes('完成')) return 'success'
if (text.includes('失败') || text.includes('错误') || text.includes('异常') || text.includes('登录失败')) return 'danger'
if (text.includes('排队') || text.includes('运行') || text.includes('截图')) return 'warning'
return 'info'
}
function showRuntimeProgress(acc) {
@@ -215,22 +205,6 @@ async function loadKdocsSettings() {
}
}
async function loadKdocsStatus() {
kdocsStatusLoading.value = true
try {
const data = await fetchKdocsStatus()
kdocsStatus.enabled = Boolean(data?.enabled)
kdocsStatus.online = Boolean(data?.online)
kdocsStatus.message = data?.message || ''
} catch {
kdocsStatus.enabled = false
kdocsStatus.online = false
kdocsStatus.message = ''
} finally {
kdocsStatusLoading.value = false
}
}
async function onToggleKdocsAutoUpload(value) {
kdocsSettingsLoading.value = true
try {
@@ -532,17 +506,6 @@ function bindSocket() {
let unbindSocket = null
let statsTimer = null
let kdocsStatusTimer = null
const STATS_POLL_ACTIVE_MS = 10_000
const STATS_POLL_HIDDEN_MS = 30_000
const KDOCS_STATUS_POLL_ACTIVE_MS = 60_000
const KDOCS_STATUS_POLL_HIDDEN_MS = 180_000
function isPageHidden() {
if (typeof document === 'undefined') return false
return document.visibilityState === 'hidden'
}
const shouldPollStats = computed(() => {
// 仅在“真正执行中”才轮询(排队中不轮询,避免空转导致页面闪烁)
@@ -554,27 +517,15 @@ const shouldPollStats = computed(() => {
})
})
function currentStatsPollDelay() {
return isPageHidden() ? STATS_POLL_HIDDEN_MS : STATS_POLL_ACTIVE_MS
}
function stopStatsPolling() {
if (!statsTimer) return
window.clearTimeout(statsTimer)
window.clearInterval(statsTimer)
statsTimer = null
}
function scheduleStatsPolling() {
if (statsTimer || !shouldPollStats.value) return
statsTimer = window.setTimeout(async () => {
statsTimer = null
await refreshStats({ silent: true }).catch(() => {})
scheduleStatsPolling()
}, currentStatsPollDelay())
}
function startStatsPolling() {
scheduleStatsPolling()
if (statsTimer) return
statsTimer = window.setInterval(() => refreshStats({ silent: true }), 10_000)
}
function syncStatsPolling(prevRunning = null) {
@@ -587,38 +538,10 @@ function syncStatsPolling(prevRunning = null) {
else stopStatsPolling()
}
watch(shouldPollStats, (_running, prevRunning) => {
watch(shouldPollStats, (running, prevRunning) => {
syncStatsPolling(prevRunning)
})
function currentKdocsStatusPollDelay() {
return isPageHidden() ? KDOCS_STATUS_POLL_HIDDEN_MS : KDOCS_STATUS_POLL_ACTIVE_MS
}
function stopKdocsStatusPolling() {
if (!kdocsStatusTimer) return
window.clearTimeout(kdocsStatusTimer)
kdocsStatusTimer = null
}
function scheduleKdocsStatusPolling() {
if (kdocsStatusTimer) return
kdocsStatusTimer = window.setTimeout(async () => {
kdocsStatusTimer = null
await loadKdocsStatus().catch(() => {})
scheduleKdocsStatusPolling()
}, currentKdocsStatusPollDelay())
}
function restartTimedPollingOnVisibilityChange() {
if (shouldPollStats.value) {
stopStatsPolling()
startStatsPolling()
}
stopKdocsStatusPolling()
scheduleKdocsStatusPolling()
}
onMounted(async () => {
if (!userStore.vipInfo) {
userStore.refreshVipInfo().catch(() => {
@@ -630,19 +553,13 @@ onMounted(async () => {
await refreshAccounts()
await loadKdocsSettings()
await loadKdocsStatus()
await refreshStats()
syncStatsPolling()
scheduleKdocsStatusPolling()
window.addEventListener('visibilitychange', restartTimedPollingOnVisibilityChange)
})
onBeforeUnmount(() => {
if (unbindSocket) unbindSocket()
stopStatsPolling()
stopKdocsStatusPolling()
window.removeEventListener('visibilitychange', restartTimedPollingOnVisibilityChange)
})
</script>
@@ -733,9 +650,6 @@ onBeforeUnmount(() => {
@change="onToggleKdocsAutoUpload"
/>
<span class="app-muted">表格(测试)</span>
<el-tag v-if="kdocsStatus.enabled" :type="kdocsStatus.online ? 'success' : 'warning'" size="small" effect="plain">
{{ kdocsStatus.online ? ' 就绪' : ' 离线' }}
</el-tag>
</div>
<div class="toolbar-right">

File diff suppressed because it is too large Load Diff

View File

@@ -19,9 +19,6 @@ const userStore = useUserStore()
const loading = ref(false)
const schedules = ref([])
const schedulePage = ref(1)
const scheduleTotal = ref(0)
const schedulePageSize = 12
const accountsLoading = ref(false)
const accountOptions = ref([])
@@ -68,7 +65,6 @@ const weekdayOptions = [
]
const canUseSchedule = computed(() => userStore.isVip)
const scheduleTotalPages = computed(() => Math.max(1, Math.ceil((scheduleTotal.value || 0) / schedulePageSize)))
function normalizeTime(value) {
const match = String(value || '').match(/^(\d{1,2}):(\d{2})$/)
@@ -98,37 +94,17 @@ async function loadAccounts() {
}
}
async function reloadSchedulesAfterMutate() {
if (schedulePage.value > 1 && schedules.value.length <= 1) {
schedulePage.value -= 1
}
await loadSchedules()
}
async function onSchedulePageChange(page) {
schedulePage.value = page
await loadSchedules()
}
async function loadSchedules() {
loading.value = true
try {
const params = {
limit: schedulePageSize,
offset: (schedulePage.value - 1) * schedulePageSize,
}
const payload = await fetchSchedules(params)
const rawItems = Array.isArray(payload) ? payload : (Array.isArray(payload?.items) ? payload.items : [])
const rawTotal = Array.isArray(payload) ? rawItems.length : Number(payload?.total ?? rawItems.length)
schedules.value = rawItems.map((s) => ({
const list = await fetchSchedules()
schedules.value = (Array.isArray(list) ? list : []).map((s) => ({
...s,
browse_type: normalizeBrowseType(s?.browse_type),
}))
scheduleTotal.value = Number.isFinite(rawTotal) ? Math.max(0, rawTotal) : rawItems.length
} catch (e) {
if (e?.response?.status === 401) window.location.href = '/login'
schedules.value = []
scheduleTotal.value = 0
} finally {
loading.value = false
}
@@ -196,7 +172,6 @@ async function saveSchedule() {
} else {
await createSchedule(payload)
ElMessage.success('创建成功')
schedulePage.value = 1
}
editorOpen.value = false
await loadSchedules()
@@ -223,7 +198,7 @@ async function onDelete(schedule) {
const res = await deleteSchedule(schedule.id)
if (res?.success) {
ElMessage.success('已删除')
await reloadSchedulesAfterMutate()
await loadSchedules()
} else {
ElMessage.error(res?.error || '删除失败')
}
@@ -400,17 +375,6 @@ onMounted(async () => {
</div>
</el-card>
</div>
<div v-if="scheduleTotal > schedulePageSize" class="pagination">
<el-pagination
v-model:current-page="schedulePage"
:page-size="schedulePageSize"
:total="scheduleTotal"
layout="prev, pager, next, jumper, ->, total"
@current-change="onSchedulePageChange"
/>
<div class="page-hint app-muted"> {{ schedulePage }} / {{ scheduleTotalPages }} </div>
</div>
</template>
</el-card>
@@ -629,19 +593,6 @@ onMounted(async () => {
flex-wrap: wrap;
}
.pagination {
margin-top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.page-hint {
font-size: 12px;
}
.logs {
display: flex;
flex-direction: column;

View File

@@ -1,15 +1,11 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { clearScreenshots, deleteScreenshot, fetchScreenshots } from '../api/screenshots'
const loading = ref(false)
const screenshots = ref([])
const currentPage = ref(1)
const total = ref(0)
const pageSize = 24
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / pageSize)))
const previewOpen = ref(false)
const previewUrl = ref('')
@@ -19,50 +15,32 @@ function buildUrl(filename) {
return `/screenshots/${encodeURIComponent(filename)}`
}
function buildThumbUrl(filename) {
return `/screenshots/thumb/${encodeURIComponent(filename)}`
}
async function load() {
loading.value = true
try {
const params = {
limit: pageSize,
offset: (currentPage.value - 1) * pageSize,
}
const payload = await fetchScreenshots(params)
const items = Array.isArray(payload) ? payload : (Array.isArray(payload?.items) ? payload.items : [])
const payloadTotal = Array.isArray(payload) ? items.length : Number(payload?.total ?? items.length)
screenshots.value = items
total.value = Number.isFinite(payloadTotal) ? Math.max(0, payloadTotal) : items.length
const data = await fetchScreenshots()
screenshots.value = Array.isArray(data) ? data : []
} catch (e) {
if (e?.response?.status === 401) window.location.href = '/login'
screenshots.value = []
total.value = 0
} finally {
loading.value = false
}
}
async function onPageChange(page) {
currentPage.value = page
await load()
}
function openPreview(item) {
previewTitle.value = item.display_name || item.filename || '截图预览'
previewUrl.value = buildUrl(item.filename)
previewOpen.value = true
}
function onThumbError(event, item) {
const imageEl = event?.target
if (!imageEl) return
if (imageEl.dataset.fullLoaded === '1') return
imageEl.dataset.fullLoaded = '1'
imageEl.src = buildUrl(item.filename)
function findRenderedShotImage(filename) {
try {
const escaped = typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(String(filename)) : String(filename)
return document.querySelector(`img[data-shot-filename="${escaped}"]`)
} catch {
return null
}
}
function canvasToPngBlob(canvas) {
@@ -118,8 +96,17 @@ async function blobToPng(blob) {
}
}
async function screenshotUrlToPngBlob(url) {
// 复制时始终拉取原图,避免复制到缩略图
async function screenshotUrlToPngBlob(url, filename) {
// 优先使用页面上已渲染完成的 <img>(避免额外请求;也更容易满足剪贴板“用户手势”限制)
const imgEl = findRenderedShotImage(filename)
if (imgEl) {
try {
return await imageElementToPngBlob(imgEl)
} catch {
// fallback to fetch
}
}
const resp = await fetch(url, { credentials: 'include', cache: 'no-store' })
if (!resp.ok) throw new Error('fetch_failed')
const blob = await resp.blob()
@@ -144,8 +131,6 @@ async function onClearAll() {
if (res?.success) {
ElMessage.success(`已清空(删除 ${res?.deleted || 0} 张)`)
screenshots.value = []
total.value = 0
currentPage.value = 1
previewOpen.value = false
return
}
@@ -170,9 +155,8 @@ async function onDelete(item) {
try {
const res = await deleteScreenshot(item.filename)
if (res?.success) {
screenshots.value = screenshots.value.filter((s) => s.filename !== item.filename)
if (previewUrl.value.includes(encodeURIComponent(item.filename))) previewOpen.value = false
if (currentPage.value > 1 && screenshots.value.length <= 1) currentPage.value -= 1
await load()
ElMessage.success('已删除')
return
}
@@ -199,15 +183,15 @@ async function copyImage(item) {
try {
await navigator.clipboard.write([
new ClipboardItem({
'image/png': screenshotUrlToPngBlob(url),
'image/png': screenshotUrlToPngBlob(url, item.filename),
}),
])
} catch {
const pngBlob = await screenshotUrlToPngBlob(url)
const pngBlob = await screenshotUrlToPngBlob(url, item.filename)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
}
ElMessage.success('图片已复制到剪贴板')
} catch {
} catch (e) {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(`${window.location.origin}${url}`)
@@ -239,22 +223,22 @@ onMounted(load)
<div class="panel-title">截图管理</div>
<div class="panel-actions">
<el-button :loading="loading" @click="load">刷新</el-button>
<el-button type="danger" plain :disabled="total === 0" @click="onClearAll">清空全部</el-button>
<el-button type="danger" plain :disabled="screenshots.length === 0" @click="onClearAll">清空全部</el-button>
</div>
</div>
<el-skeleton v-if="loading" :rows="6" animated />
<template v-else>
<el-empty v-if="total === 0" description="暂无截图" />
<el-empty v-if="screenshots.length === 0" description="暂无截图" />
<div v-else class="grid">
<el-card v-for="item in screenshots" :key="item.filename" shadow="never" class="shot-card" :body-style="{ padding: '0' }">
<img
class="shot-img"
:src="buildThumbUrl(item.filename)"
:src="buildUrl(item.filename)"
:alt="item.display_name || item.filename"
:data-shot-filename="item.filename"
loading="lazy"
@error="onThumbError($event, item)"
@click="openPreview(item)"
/>
<div class="shot-body">
@@ -268,17 +252,6 @@ onMounted(load)
</div>
</el-card>
</div>
<div v-if="total > pageSize" class="pagination">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="prev, pager, next, jumper, ->, total"
@current-change="onPageChange"
/>
<div class="page-hint app-muted"> {{ currentPage }} / {{ totalPages }} </div>
</div>
</template>
<el-dialog v-model="previewOpen" :title="previewTitle" width="min(920px, 94vw)">
@@ -326,19 +299,6 @@ onMounted(load)
align-items: start;
}
.pagination {
margin-top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.page-hint {
font-size: 12px;
}
.shot-card {
border-radius: 14px;
border: 1px solid var(--app-border);

View File

@@ -1,10 +1,11 @@
import { createRouter, createWebHistory } from 'vue-router'
import AppLayout from '../layouts/AppLayout.vue'
const LoginPage = () => import('../pages/LoginPage.vue')
const RegisterPage = () => import('../pages/RegisterPage.vue')
const ResetPasswordPage = () => import('../pages/ResetPasswordPage.vue')
const VerifyResultPage = () => import('../pages/VerifyResultPage.vue')
const AppLayout = () => import('../layouts/AppLayout.vue')
const AccountsPage = () => import('../pages/AccountsPage.vue')
const SchedulesPage = () => import('../pages/SchedulesPage.vue')

View File

@@ -1,153 +0,0 @@
function ensurePublicKeyOptions(options) {
if (!options || typeof options !== 'object') {
throw new Error('Passkey参数无效')
}
return options.publicKey && typeof options.publicKey === 'object' ? options.publicKey : options
}
function base64UrlToUint8Array(base64url) {
const value = String(base64url || '')
const padding = '='.repeat((4 - (value.length % 4)) % 4)
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = window.atob(base64)
const bytes = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i += 1) {
bytes[i] = raw.charCodeAt(i)
}
return bytes
}
function uint8ArrayToBase64Url(input) {
const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input || [])
let binary = ''
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i])
}
return window
.btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '')
}
function toCreationOptions(rawOptions) {
const options = ensurePublicKeyOptions(rawOptions)
const normalized = {
...options,
challenge: base64UrlToUint8Array(options.challenge),
user: {
...options.user,
id: base64UrlToUint8Array(options.user?.id),
},
}
if (Array.isArray(options.excludeCredentials)) {
normalized.excludeCredentials = options.excludeCredentials.map((item) => ({
...item,
id: base64UrlToUint8Array(item.id),
}))
}
return normalized
}
function toRequestOptions(rawOptions) {
const options = ensurePublicKeyOptions(rawOptions)
const normalized = {
...options,
challenge: base64UrlToUint8Array(options.challenge),
}
if (Array.isArray(options.allowCredentials)) {
normalized.allowCredentials = options.allowCredentials.map((item) => ({
...item,
id: base64UrlToUint8Array(item.id),
}))
}
return normalized
}
function serializeCredential(credential) {
if (!credential) return null
const response = credential.response || {}
const output = {
id: credential.id,
rawId: uint8ArrayToBase64Url(credential.rawId),
type: credential.type,
authenticatorAttachment: credential.authenticatorAttachment || undefined,
response: {},
}
if (response.clientDataJSON) {
output.response.clientDataJSON = uint8ArrayToBase64Url(response.clientDataJSON)
}
if (response.attestationObject) {
output.response.attestationObject = uint8ArrayToBase64Url(response.attestationObject)
}
if (response.authenticatorData) {
output.response.authenticatorData = uint8ArrayToBase64Url(response.authenticatorData)
}
if (response.signature) {
output.response.signature = uint8ArrayToBase64Url(response.signature)
}
if (response.userHandle) {
output.response.userHandle = uint8ArrayToBase64Url(response.userHandle)
} else {
output.response.userHandle = null
}
if (typeof response.getTransports === 'function') {
output.response.transports = response.getTransports() || []
}
return output
}
export function isPasskeyAvailable() {
return typeof window !== 'undefined' && window.isSecureContext && !!window.PublicKeyCredential && !!navigator.credentials
}
function isMiuiBrowser() {
const ua = String(window?.navigator?.userAgent || '')
return /MiuiBrowser|XiaoMi\/MiuiBrowser/i.test(ua)
}
export function getPasskeyClientErrorMessage(error, actionLabel = 'Passkey操作') {
const name = String(error?.name || '').trim()
const message = String(error?.message || '').trim()
if (name === 'NotAllowedError') {
return `${actionLabel}未完成(可能已取消、超时或设备未响应)`
}
if (name === 'NotReadableError') {
if (/credential manager/i.test(message) && isMiuiBrowser()) {
return '当前小米浏览器与系统凭据管理器兼容性较差,请改用系统 Chrome 或 Edge 后重试。'
}
if (/credential manager/i.test(message)) {
return '系统凭据管理器返回异常,请确认已设置系统锁屏并改用系统 Chrome/Edge 后重试。'
}
return message || `${actionLabel}失败(设备读取异常)`
}
if (name === 'SecurityError') {
return '当前环境安全策略不满足 Passkey 要求,请确认使用 HTTPS 且证书有效。'
}
return message || `${actionLabel}失败`
}
export async function createPasskey(rawOptions) {
const publicKey = toCreationOptions(rawOptions)
const credential = await navigator.credentials.create({ publicKey })
return serializeCredential(credential)
}
export async function authenticateWithPasskey(rawOptions) {
const publicKey = toRequestOptions(rawOptions)
const credential = await navigator.credentials.get({ publicKey })
return serializeCredential(credential)
}

View File

@@ -1,62 +1,13 @@
import { defineConfig } from 'vite'
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
dts: false,
}),
Components({
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
dts: false,
}),
],
plugins: [vue()],
base: './',
build: {
outDir: '../static/app',
emptyOutDir: true,
manifest: true,
cssCodeSplit: true,
chunkSizeWarningLimit: 800,
rollupOptions: {
input: {
app: fileURLToPath(new URL('./index.html', import.meta.url)),
login: fileURLToPath(new URL('./login.html', import.meta.url)),
},
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return undefined
if (
id.includes('/node_modules/vue/') ||
id.includes('/node_modules/@vue/') ||
id.includes('/node_modules/vue-router/') ||
id.includes('/node_modules/pinia/')
) {
return 'vendor-vue'
}
if (id.includes('/node_modules/axios/')) {
return 'vendor-axios'
}
if (
id.includes('/node_modules/socket.io-client/') ||
id.includes('/node_modules/engine.io-client/') ||
id.includes('/node_modules/socket.io-parser/')
) {
return 'vendor-realtime'
}
return undefined
},
},
},
},
})

419
app.py
View File

@@ -14,13 +14,11 @@ from __future__ import annotations
import atexit
import os
import re
import signal
import sys
import threading
import time
from flask import Flask, g, jsonify, redirect, request, send_from_directory, session, url_for
from flask import Flask, jsonify, redirect, request, send_from_directory, session, url_for
from flask_login import LoginManager, current_user
from flask_socketio import SocketIO
@@ -36,8 +34,7 @@ from realtime.status_push import status_push_worker
from routes import register_blueprints
from security import init_security_middleware
from services.checkpoints import init_checkpoint_manager
from services.maintenance import start_cleanup_scheduler, start_database_maintenance_scheduler, start_kdocs_monitor
from services.request_metrics import record_request_metric
from services.maintenance import start_cleanup_scheduler, start_kdocs_monitor
from services.models import User
from services.runtime import init_runtime
from services.scheduler import scheduled_task_worker
@@ -49,13 +46,12 @@ from services.tasks import get_task_scheduler
# 设置时区为中国标准时间CST, UTC+8
os.environ["TZ"] = "Asia/Shanghai"
_TZSET_ERROR = None
try:
import time as _time
_time.tzset()
except Exception as e:
_TZSET_ERROR = e
except Exception:
pass
def _sigchld_handler(signum, frame):
@@ -88,86 +84,20 @@ if not app.config.get("SECRET_KEY"):
cors_origins = os.environ.get("CORS_ALLOWED_ORIGINS", "").strip()
cors_allowed = [o.strip() for o in cors_origins.split(",") if o.strip()] if cors_origins else []
_socketio_preferred_mode = (os.environ.get("SOCKETIO_ASYNC_MODE", "eventlet") or "").strip().lower()
if _socketio_preferred_mode in {"", "auto"}:
_socketio_preferred_mode = None
_socketio_fallback_reason = None
try:
socketio = SocketIO(
app,
cors_allowed_origins=cors_allowed if cors_allowed else None,
async_mode=_socketio_preferred_mode,
ping_timeout=60,
ping_interval=25,
logger=False,
engineio_logger=False,
)
except Exception as socketio_error:
_socketio_fallback_reason = str(socketio_error)
socketio = SocketIO(
app,
cors_allowed_origins=cors_allowed if cors_allowed else None,
async_mode="threading",
ping_timeout=60,
ping_interval=25,
logger=False,
engineio_logger=False,
)
socketio = SocketIO(
app,
cors_allowed_origins=cors_allowed if cors_allowed else None,
async_mode="threading",
ping_timeout=60,
ping_interval=25,
logger=False,
engineio_logger=False,
)
init_logging(log_level=config.LOG_LEVEL, log_file=config.LOG_FILE)
logger = get_logger("app")
if _TZSET_ERROR is not None:
logger.warning(f"设置时区失败,将继续使用系统默认时区: {_TZSET_ERROR}")
if _socketio_fallback_reason:
logger.warning(f"[SocketIO] 初始化失败,已回退 threading 模式: {_socketio_fallback_reason}")
logger.info(f"[SocketIO] 当前 async_mode: {socketio.async_mode}")
init_runtime(socketio=socketio, logger=logger)
_API_DIAGNOSTIC_LOG = str(os.environ.get("API_DIAGNOSTIC_LOG", "0")).strip().lower() in {
"1",
"true",
"yes",
"on",
}
_API_DIAGNOSTIC_SLOW_MS = max(0.0, float(os.environ.get("API_DIAGNOSTIC_SLOW_MS", "0") or 0.0))
def _is_api_or_health_path(path: str) -> bool:
raw = str(path or "")
return raw.startswith("/api/") or raw.startswith("/yuyx/api/") or raw == "/health"
def _request_uses_https() -> bool:
try:
if bool(request.is_secure):
return True
except Exception as e:
logger.debug(f"检查 request.is_secure 失败: {e}")
try:
forwarded_proto = str(request.headers.get("X-Forwarded-Proto", "") or "").split(",", 1)[0].strip().lower()
if forwarded_proto == "https":
return True
except Exception as e:
logger.debug(f"检查 X-Forwarded-Proto 失败: {e}")
return False
_SECURITY_RESPONSE_HEADERS = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "SAMEORIGIN",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()",
}
_SECURITY_CSP_HEADER = str(os.environ.get("SECURITY_CONTENT_SECURITY_POLICY", "") or "").strip()
_HASHED_STATIC_ASSET_RE = re.compile(r".*-[a-z0-9_-]{8,}\.(?:js|css|woff2?|ttf|svg|png|jpe?g|webp)$", re.IGNORECASE)
# 初始化安全中间件(需在其他中间件/Blueprint 之前注册)
init_security_middleware(app)
@@ -201,90 +131,33 @@ def unauthorized():
return redirect(url_for("pages.login_page", next=request.url))
@app.before_request
def track_request_start_time():
g.request_start_perf = time.perf_counter()
@app.before_request
def enforce_csrf_protection():
if request.method in {"GET", "HEAD", "OPTIONS"}:
return
if request.path.startswith("/static/"):
return
# 登录挑战相关路由豁免 CSRF会话尚未建立前需要可用
csrf_exempt_paths = {
"/yuyx/api/login",
"/api/login",
"/api/auth/login",
"/api/generate_captcha",
"/yuyx/api/passkeys/login/options",
"/yuyx/api/passkeys/login/verify",
"/api/passkeys/login/options",
"/api/passkeys/login/verify",
}
if request.path in csrf_exempt_paths:
if not (current_user.is_authenticated or "admin_id" in session):
return
token = request.headers.get("X-CSRF-Token") or request.form.get("csrf_token")
if not token or not validate_csrf_token(token):
return jsonify({"error": "CSRF token missing or invalid"}), 403
def _record_request_metric_after_response(response) -> None:
try:
started = float(getattr(g, "request_start_perf", 0.0) or 0.0)
if started <= 0:
return
duration_ms = max(0.0, (time.perf_counter() - started) * 1000.0)
path = request.path or "/"
method = request.method or "GET"
status_code = int(getattr(response, "status_code", 0) or 0)
is_api = _is_api_or_health_path(path)
record_request_metric(
path=path,
method=method,
status_code=status_code,
duration_ms=duration_ms,
is_api=is_api,
)
if _API_DIAGNOSTIC_LOG and is_api:
is_slow = _API_DIAGNOSTIC_SLOW_MS > 0 and duration_ms >= _API_DIAGNOSTIC_SLOW_MS
is_server_error = status_code >= 500
if is_slow or is_server_error:
logger.warning(
f"[API-DIAG] {method} {path} -> {status_code} ({duration_ms:.1f}ms)"
)
except Exception as e:
logger.debug(f"记录请求指标失败: {e}")
@app.after_request
def ensure_csrf_cookie(response):
if not request.path.startswith("/static/"):
token = session.get("csrf_token")
if not token:
token = generate_csrf_token()
response.set_cookie(
"csrf_token",
token,
httponly=False,
secure=bool(config.SESSION_COOKIE_SECURE),
samesite=config.SESSION_COOKIE_SAMESITE,
)
for header_name, header_value in _SECURITY_RESPONSE_HEADERS.items():
response.headers.setdefault(header_name, header_value)
if _request_uses_https():
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
if _SECURITY_CSP_HEADER:
response.headers.setdefault("Content-Security-Policy", _SECURITY_CSP_HEADER)
_record_request_metric_after_response(response)
if request.path.startswith("/static/"):
return response
token = session.get("csrf_token")
if not token:
token = generate_csrf_token()
response.set_cookie(
"csrf_token",
token,
httponly=False,
secure=bool(config.SESSION_COOKIE_SECURE),
samesite=config.SESSION_COOKIE_SAMESITE,
)
return response
@@ -296,38 +169,10 @@ def serve_static(filename):
if not is_safe_path("static", filename):
return jsonify({"error": "非法路径"}), 403
lowered = filename.lower()
is_asset_file = "/assets/" in lowered or lowered.endswith((".js", ".css", ".woff", ".woff2", ".ttf", ".svg"))
is_hashed_asset = bool(_HASHED_STATIC_ASSET_RE.match(lowered))
cache_ttl = 3600
if is_asset_file:
cache_ttl = 604800 # 7天
if is_hashed_asset:
cache_ttl = 31536000 # 365天
if request.args.get("v"):
cache_ttl = max(cache_ttl, 604800)
response = send_from_directory("static", filename, max_age=cache_ttl, conditional=True)
# 协商缓存:确保存在 ETag并基于 If-None-Match/If-Modified-Since 返回 304
try:
response.add_etag(overwrite=False)
except Exception as e:
logger.debug(f"静态资源 ETag 设置失败({filename}): {e}")
try:
response.make_conditional(request)
except Exception as e:
logger.debug(f"静态资源协商缓存处理失败({filename}): {e}")
response.headers.setdefault("Vary", "Accept-Encoding")
if is_hashed_asset:
response.headers["Cache-Control"] = f"public, max-age={cache_ttl}, immutable"
elif is_asset_file:
response.headers["Cache-Control"] = f"public, max-age={cache_ttl}, stale-while-revalidate=60"
else:
response.headers["Cache-Control"] = f"public, max-age={cache_ttl}"
response = send_from_directory("static", filename)
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
@@ -343,35 +188,35 @@ def cleanup_on_exit():
for acc in accounts.values():
if getattr(acc, "is_running", False):
acc.should_stop = True
except Exception as e:
logger.warning(f"停止运行中任务失败: {e}")
except Exception:
pass
logger.info("- 停止任务调度器...")
try:
scheduler = get_task_scheduler()
scheduler.shutdown(timeout=5)
except Exception as e:
logger.warning(f"停止任务调度器失败: {e}")
except Exception:
pass
logger.info("- 关闭截图线程池...")
try:
shutdown_browser_worker_pool()
except Exception as e:
logger.warning(f"关闭截图线程池失败: {e}")
except Exception:
pass
logger.info("- 关闭邮件队列...")
try:
email_service.shutdown_email_queue()
except Exception as e:
logger.warning(f"关闭邮件队列失败: {e}")
except Exception:
pass
logger.info("- 关闭数据库连接池...")
try:
db_pool._pool.close_all() if db_pool._pool else None
except Exception as e:
logger.warning(f"关闭数据库连接池失败: {e}")
except Exception:
pass
logger.info("[OK] 资源清理完成")
logger.info(" 资源清理完成")
# ==================== 启动入口(保持 python app.py 可用) ====================
@@ -383,93 +228,6 @@ def _signal_handler(sig, frame):
sys.exit(0)
def _cleanup_stale_task_state() -> None:
logger.info("清理遗留任务状态...")
try:
from services.state import safe_get_active_task_ids, safe_remove_task, safe_remove_task_status
for _, accounts in safe_iter_user_accounts_items():
for acc in accounts.values():
if not getattr(acc, "is_running", False):
continue
acc.is_running = False
acc.should_stop = False
acc.status = "未开始"
for account_id in list(safe_get_active_task_ids()):
safe_remove_task(account_id)
safe_remove_task_status(account_id)
logger.info("[OK] 遗留任务状态已清理")
except Exception as e:
logger.warning(f"清理遗留任务状态失败: {e}")
def _init_optional_email_service() -> None:
try:
email_service.init_email_service()
logger.info("[OK] 邮件服务已初始化")
except Exception as e:
logger.warning(f"警告: 邮件服务初始化失败: {e}")
def _load_and_apply_scheduler_limits() -> None:
try:
system_config = database.get_system_config() or {}
max_concurrent_global = int(system_config.get("max_concurrent_global", config.MAX_CONCURRENT_GLOBAL))
max_concurrent_per_account = int(system_config.get("max_concurrent_per_account", config.MAX_CONCURRENT_PER_ACCOUNT))
get_task_scheduler().update_limits(max_global=max_concurrent_global, max_per_user=max_concurrent_per_account)
logger.info(f"[OK] 已加载并发配置: 全局={max_concurrent_global}, 单账号={max_concurrent_per_account}")
except Exception as e:
logger.warning(f"警告: 加载并发配置失败,使用默认值: {e}")
def _start_background_workers() -> None:
logger.info("启动定时任务调度器...")
threading.Thread(target=scheduled_task_worker, daemon=True, name="scheduled-task-worker").start()
logger.info("[OK] 定时任务调度器已启动")
logger.info("[OK] 状态推送线程已启动默认2秒/次)")
threading.Thread(target=status_push_worker, daemon=True, name="status-push-worker").start()
def _init_screenshot_worker_pool() -> None:
try:
pool_size = int((database.get_system_config() or {}).get("max_screenshot_concurrent", 3))
except Exception:
pool_size = 3
try:
logger.info(f"初始化截图线程池({pool_size}个worker按需启动执行环境空闲5分钟后自动释放...")
init_browser_worker_pool(pool_size=pool_size)
logger.info("[OK] 截图线程池初始化完成")
except Exception as e:
logger.warning(f"警告: 截图线程池初始化失败: {e}")
def _warmup_api_connection() -> None:
logger.info("预热 API 连接...")
try:
from api_browser import warmup_api_connection
threading.Thread(
target=warmup_api_connection,
kwargs={"log_callback": lambda msg: logger.info(msg)},
daemon=True,
name="api-warmup",
).start()
except Exception as e:
logger.warning(f"API 预热失败: {e}")
def _log_startup_urls() -> None:
logger.info("服务器启动中...")
logger.info(f"用户访问地址: http://{config.SERVER_HOST}:{config.SERVER_PORT}")
logger.info(f"后台管理地址: http://{config.SERVER_HOST}:{config.SERVER_PORT}/yuyx")
logger.info("默认管理员: admin (首次运行密码写入 data/default_admin_credentials.txt)")
logger.info("=" * 60)
if __name__ == "__main__":
atexit.register(cleanup_on_exit)
signal.signal(signal.SIGINT, _signal_handler)
@@ -481,27 +239,88 @@ if __name__ == "__main__":
database.init_database()
init_checkpoint_manager()
logger.info("[OK] 任务断点管理器已初始化")
logger.info(" 任务断点管理器已初始化")
_cleanup_stale_task_state()
_init_optional_email_service()
# 【新增】容器重启时清理遗留的任务状态
logger.info("清理遗留任务状态...")
try:
from services.state import safe_remove_task, safe_get_active_task_ids, safe_remove_task_status
# 重置所有账号的运行状态
for _, accounts in safe_iter_user_accounts_items():
for acc in accounts.values():
if getattr(acc, "is_running", False):
acc.is_running = False
acc.should_stop = False
acc.status = "未开始"
# 清理活跃任务句柄
for account_id in list(safe_get_active_task_ids()):
safe_remove_task(account_id)
safe_remove_task_status(account_id)
logger.info("✓ 遗留任务状态已清理")
except Exception as e:
logger.warning(f"清理遗留任务状态失败: {e}")
try:
email_service.init_email_service()
logger.info("✓ 邮件服务已初始化")
except Exception as e:
logger.warning(f"警告: 邮件服务初始化失败: {e}")
start_cleanup_scheduler()
start_database_maintenance_scheduler()
start_kdocs_monitor()
_load_and_apply_scheduler_limits()
_start_background_workers()
_log_startup_urls()
_init_screenshot_worker_pool()
_warmup_api_connection()
try:
system_config = database.get_system_config() or {}
max_concurrent_global = int(system_config.get("max_concurrent_global", config.MAX_CONCURRENT_GLOBAL))
max_concurrent_per_account = int(system_config.get("max_concurrent_per_account", config.MAX_CONCURRENT_PER_ACCOUNT))
get_task_scheduler().update_limits(max_global=max_concurrent_global, max_per_user=max_concurrent_per_account)
logger.info(f"✓ 已加载并发配置: 全局={max_concurrent_global}, 单账号={max_concurrent_per_account}")
except Exception as e:
logger.warning(f"警告: 加载并发配置失败,使用默认值: {e}")
run_kwargs = {
"host": config.SERVER_HOST,
"port": config.SERVER_PORT,
"debug": config.DEBUG,
}
if str(socketio.async_mode) == "threading":
run_kwargs["allow_unsafe_werkzeug"] = True
logger.info("启动定时任务调度器...")
threading.Thread(target=scheduled_task_worker, daemon=True, name="scheduled-task-worker").start()
logger.info("✓ 定时任务调度器已启动")
socketio.run(app, **run_kwargs)
logger.info("✓ 状态推送线程已启动默认2秒/次)")
threading.Thread(target=status_push_worker, daemon=True, name="status-push-worker").start()
logger.info("服务器启动中...")
logger.info(f"用户访问地址: http://{config.SERVER_HOST}:{config.SERVER_PORT}")
logger.info(f"后台管理地址: http://{config.SERVER_HOST}:{config.SERVER_PORT}/yuyx")
logger.info("默认管理员: admin (首次运行随机密码见日志)")
logger.info("=" * 60)
try:
pool_size = int((database.get_system_config() or {}).get("max_screenshot_concurrent", 3))
except Exception:
pool_size = 3
try:
logger.info(f"初始化截图线程池({pool_size}个worker按需启动执行环境空闲5分钟后自动释放...")
init_browser_worker_pool(pool_size=pool_size)
logger.info("✓ 截图线程池初始化完成")
except Exception as e:
logger.warning(f"警告: 截图线程池初始化失败: {e}")
# 预热 API 连接(后台进行,不阻塞启动)
logger.info("预热 API 连接...")
try:
from api_browser import warmup_api_connection
import threading
threading.Thread(
target=warmup_api_connection,
kwargs={"log_callback": lambda msg: logger.info(msg)},
daemon=True,
name="api-warmup",
).start()
except Exception as e:
logger.warning(f"API 预热失败: {e}")
socketio.run(
app,
host=config.SERVER_HOST,
port=config.SERVER_PORT,
debug=config.DEBUG,
allow_unsafe_werkzeug=True,
)

View File

@@ -14,62 +14,38 @@ from urllib.parse import urlsplit, urlunsplit
# Bug fix: 添加警告日志,避免静默失败
try:
from dotenv import load_dotenv
env_path = Path(__file__).parent / ".env"
env_path = Path(__file__).parent / '.env'
if env_path.exists():
load_dotenv(dotenv_path=env_path)
print(f"[OK] 已加载环境变量文件: {env_path}")
print(f" 已加载环境变量文件: {env_path}")
except ImportError:
# python-dotenv未安装记录警告
import sys
print(
"⚠ 警告: python-dotenv未安装将不会加载.env文件。如需使用.env文件请运行: pip install python-dotenv",
file=sys.stderr,
)
print("⚠ 警告: python-dotenv未安装将不会加载.env文件。如需使用.env文件请运行: pip install python-dotenv", file=sys.stderr)
# 常量定义
SECRET_KEY_FILE = "data/secret_key.txt"
def _ensure_private_dir(path: str) -> None:
if not path:
return
os.makedirs(path, mode=0o700, exist_ok=True)
try:
os.chmod(path, 0o700)
except Exception:
pass
def _ensure_private_file(path: str) -> None:
try:
os.chmod(path, 0o600)
except Exception:
pass
SECRET_KEY_FILE = 'data/secret_key.txt'
def get_secret_key():
"""获取SECRET_KEY优先环境变量"""
# 优先从环境变量读取
secret_key = os.environ.get("SECRET_KEY")
secret_key = os.environ.get('SECRET_KEY')
if secret_key:
return secret_key
# 从文件读取
if os.path.exists(SECRET_KEY_FILE):
_ensure_private_file(SECRET_KEY_FILE)
with open(SECRET_KEY_FILE, "r") as f:
with open(SECRET_KEY_FILE, 'r') as f:
return f.read().strip()
# 生成新的
new_key = os.urandom(24).hex()
_ensure_private_dir("data")
with open(SECRET_KEY_FILE, "w") as f:
os.makedirs('data', exist_ok=True)
with open(SECRET_KEY_FILE, 'w') as f:
f.write(new_key)
_ensure_private_file(SECRET_KEY_FILE)
print(f"[OK] 已生成新的SECRET_KEY并保存到 {SECRET_KEY_FILE}")
print(f"✓ 已生成新的SECRET_KEY并保存到 {SECRET_KEY_FILE}")
return new_key
@@ -109,30 +85,27 @@ class Config:
# ==================== 会话安全配置 ====================
# 安全修复: 根据环境自动选择安全配置
# 生产环境(FLASK_ENV=production)时自动启用更严格的安全设置
_is_production = os.environ.get("FLASK_ENV", "production") == "production"
_force_secure = os.environ.get("SESSION_COOKIE_SECURE", "").lower() == "true"
SESSION_COOKIE_SECURE = _force_secure or (
_is_production and os.environ.get("HTTPS_ENABLED", "false").lower() == "true"
)
_is_production = os.environ.get('FLASK_ENV', 'production') == 'production'
_force_secure = os.environ.get('SESSION_COOKIE_SECURE', '').lower() == 'true'
SESSION_COOKIE_SECURE = _force_secure or (_is_production and os.environ.get('HTTPS_ENABLED', 'false').lower() == 'true')
SESSION_COOKIE_HTTPONLY = True # 防止XSS攻击
# SameSite配置HTTPS环境使用NoneHTTP环境使用Lax
SESSION_COOKIE_SAMESITE = "None" if SESSION_COOKIE_SECURE else "Lax"
SESSION_COOKIE_SAMESITE = 'None' if SESSION_COOKIE_SECURE else 'Lax'
# 自定义cookie名称避免与其他应用冲突
SESSION_COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "zsglpt_session")
SESSION_COOKIE_NAME = os.environ.get('SESSION_COOKIE_NAME', 'zsglpt_session')
# Cookie路径确保整个应用都能访问
SESSION_COOKIE_PATH = "/"
PERMANENT_SESSION_LIFETIME = timedelta(hours=int(os.environ.get("SESSION_LIFETIME_HOURS", "24")))
SESSION_COOKIE_PATH = '/'
PERMANENT_SESSION_LIFETIME = timedelta(hours=int(os.environ.get('SESSION_LIFETIME_HOURS', '24')))
# 安全警告检查
@classmethod
def check_security_warnings(cls):
"""检查安全配置,输出警告"""
import sys
warnings = []
env = os.environ.get("FLASK_ENV", "production")
env = os.environ.get('FLASK_ENV', 'production')
if env == "production":
if env == 'production':
if not cls.SESSION_COOKIE_SECURE:
warnings.append("SESSION_COOKIE_SECURE=False: 生产环境建议启用HTTPS并设置SESSION_COOKIE_SECURE=true")
@@ -143,125 +116,106 @@ class Config:
print("", file=sys.stderr)
# ==================== 数据库配置 ====================
DB_FILE = os.environ.get("DB_FILE", "data/app_data.db")
DB_POOL_SIZE = int(os.environ.get("DB_POOL_SIZE", "5"))
DB_CONNECT_TIMEOUT_SECONDS = int(os.environ.get("DB_CONNECT_TIMEOUT_SECONDS", "10"))
DB_BUSY_TIMEOUT_MS = int(os.environ.get("DB_BUSY_TIMEOUT_MS", "10000"))
DB_CACHE_SIZE_KB = int(os.environ.get("DB_CACHE_SIZE_KB", "8192"))
DB_WAL_AUTOCHECKPOINT_PAGES = int(os.environ.get("DB_WAL_AUTOCHECKPOINT_PAGES", "1000"))
DB_MMAP_SIZE_MB = int(os.environ.get("DB_MMAP_SIZE_MB", "256"))
DB_LOCK_RETRY_COUNT = int(os.environ.get("DB_LOCK_RETRY_COUNT", "3"))
DB_LOCK_RETRY_BASE_MS = int(os.environ.get("DB_LOCK_RETRY_BASE_MS", "50"))
DB_SLOW_QUERY_MS = int(os.environ.get("DB_SLOW_QUERY_MS", "120"))
DB_SLOW_QUERY_SQL_MAX_LEN = int(os.environ.get("DB_SLOW_QUERY_SQL_MAX_LEN", "240"))
DB_SLOW_SQL_WINDOW_SECONDS = int(os.environ.get("DB_SLOW_SQL_WINDOW_SECONDS", "86400"))
DB_SLOW_SQL_TOP_LIMIT = int(os.environ.get("DB_SLOW_SQL_TOP_LIMIT", "12"))
DB_SLOW_SQL_RECENT_LIMIT = int(os.environ.get("DB_SLOW_SQL_RECENT_LIMIT", "50"))
DB_SLOW_SQL_MAX_EVENTS = int(os.environ.get("DB_SLOW_SQL_MAX_EVENTS", "20000"))
DB_PRAGMA_OPTIMIZE_INTERVAL_SECONDS = int(os.environ.get("DB_PRAGMA_OPTIMIZE_INTERVAL_SECONDS", "21600"))
DB_ANALYZE_INTERVAL_SECONDS = int(os.environ.get("DB_ANALYZE_INTERVAL_SECONDS", "86400"))
DB_WAL_CHECKPOINT_INTERVAL_SECONDS = int(os.environ.get("DB_WAL_CHECKPOINT_INTERVAL_SECONDS", "43200"))
DB_WAL_CHECKPOINT_MODE = os.environ.get("DB_WAL_CHECKPOINT_MODE", "PASSIVE")
DB_FILE = os.environ.get('DB_FILE', 'data/app_data.db')
DB_POOL_SIZE = int(os.environ.get('DB_POOL_SIZE', '5'))
# ==================== 浏览器配置 ====================
SCREENSHOTS_DIR = os.environ.get("SCREENSHOTS_DIR", "截图")
COOKIES_DIR = os.environ.get("COOKIES_DIR", "data/cookies")
KDOCS_LOGIN_STATE_FILE = os.environ.get("KDOCS_LOGIN_STATE_FILE", "data/kdocs_login_state.json")
SCREENSHOTS_DIR = os.environ.get('SCREENSHOTS_DIR', '截图')
COOKIES_DIR = os.environ.get('COOKIES_DIR', 'data/cookies')
KDOCS_LOGIN_STATE_FILE = os.environ.get('KDOCS_LOGIN_STATE_FILE', 'data/kdocs_login_state.json')
# ==================== 公告图片上传配置 ====================
ANNOUNCEMENT_IMAGE_DIR = os.environ.get("ANNOUNCEMENT_IMAGE_DIR", "static/announcements")
ALLOWED_ANNOUNCEMENT_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
MAX_ANNOUNCEMENT_IMAGE_SIZE = int(os.environ.get("MAX_ANNOUNCEMENT_IMAGE_SIZE", "5242880")) # 5MB
ANNOUNCEMENT_IMAGE_DIR = os.environ.get('ANNOUNCEMENT_IMAGE_DIR', 'static/announcements')
ALLOWED_ANNOUNCEMENT_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
MAX_ANNOUNCEMENT_IMAGE_SIZE = int(os.environ.get('MAX_ANNOUNCEMENT_IMAGE_SIZE', '5242880')) # 5MB
# ==================== 并发控制配置 ====================
MAX_CONCURRENT_GLOBAL = int(os.environ.get("MAX_CONCURRENT_GLOBAL", "2"))
MAX_CONCURRENT_PER_ACCOUNT = int(os.environ.get("MAX_CONCURRENT_PER_ACCOUNT", "1"))
MAX_CONCURRENT_GLOBAL = int(os.environ.get('MAX_CONCURRENT_GLOBAL', '2'))
MAX_CONCURRENT_PER_ACCOUNT = int(os.environ.get('MAX_CONCURRENT_PER_ACCOUNT', '1'))
# ==================== 日志缓存配置 ====================
MAX_LOGS_PER_USER = int(os.environ.get("MAX_LOGS_PER_USER", "100"))
MAX_TOTAL_LOGS = int(os.environ.get("MAX_TOTAL_LOGS", "1000"))
MAX_LOGS_PER_USER = int(os.environ.get('MAX_LOGS_PER_USER', '100'))
MAX_TOTAL_LOGS = int(os.environ.get('MAX_TOTAL_LOGS', '1000'))
# ==================== 内存/缓存清理配置 ====================
USER_ACCOUNTS_EXPIRE_SECONDS = int(os.environ.get("USER_ACCOUNTS_EXPIRE_SECONDS", "3600"))
BATCH_TASK_EXPIRE_SECONDS = int(os.environ.get("BATCH_TASK_EXPIRE_SECONDS", "21600")) # 默认6小时
PENDING_RANDOM_EXPIRE_SECONDS = int(os.environ.get("PENDING_RANDOM_EXPIRE_SECONDS", "7200")) # 默认2小时
USER_ACCOUNTS_EXPIRE_SECONDS = int(os.environ.get('USER_ACCOUNTS_EXPIRE_SECONDS', '3600'))
BATCH_TASK_EXPIRE_SECONDS = int(os.environ.get('BATCH_TASK_EXPIRE_SECONDS', '21600')) # 默认6小时
PENDING_RANDOM_EXPIRE_SECONDS = int(os.environ.get('PENDING_RANDOM_EXPIRE_SECONDS', '7200')) # 默认2小时
# ==================== 验证码配置 ====================
MAX_CAPTCHA_ATTEMPTS = int(os.environ.get("MAX_CAPTCHA_ATTEMPTS", "5"))
CAPTCHA_EXPIRE_SECONDS = int(os.environ.get("CAPTCHA_EXPIRE_SECONDS", "300"))
MAX_CAPTCHA_ATTEMPTS = int(os.environ.get('MAX_CAPTCHA_ATTEMPTS', '5'))
CAPTCHA_EXPIRE_SECONDS = int(os.environ.get('CAPTCHA_EXPIRE_SECONDS', '300'))
# ==================== IP限流配置 ====================
MAX_IP_ATTEMPTS_PER_HOUR = int(os.environ.get("MAX_IP_ATTEMPTS_PER_HOUR", "10"))
IP_LOCK_DURATION = int(os.environ.get("IP_LOCK_DURATION", "3600")) # 秒
IP_RATE_LIMIT_LOGIN_MAX = int(os.environ.get("IP_RATE_LIMIT_LOGIN_MAX", "20"))
IP_RATE_LIMIT_LOGIN_WINDOW_SECONDS = int(os.environ.get("IP_RATE_LIMIT_LOGIN_WINDOW_SECONDS", "60"))
IP_RATE_LIMIT_REGISTER_MAX = int(os.environ.get("IP_RATE_LIMIT_REGISTER_MAX", "10"))
IP_RATE_LIMIT_REGISTER_WINDOW_SECONDS = int(os.environ.get("IP_RATE_LIMIT_REGISTER_WINDOW_SECONDS", "3600"))
IP_RATE_LIMIT_EMAIL_MAX = int(os.environ.get("IP_RATE_LIMIT_EMAIL_MAX", "20"))
IP_RATE_LIMIT_EMAIL_WINDOW_SECONDS = int(os.environ.get("IP_RATE_LIMIT_EMAIL_WINDOW_SECONDS", "3600"))
MAX_IP_ATTEMPTS_PER_HOUR = int(os.environ.get('MAX_IP_ATTEMPTS_PER_HOUR', '10'))
IP_LOCK_DURATION = int(os.environ.get('IP_LOCK_DURATION', '3600')) # 秒
IP_RATE_LIMIT_LOGIN_MAX = int(os.environ.get('IP_RATE_LIMIT_LOGIN_MAX', '20'))
IP_RATE_LIMIT_LOGIN_WINDOW_SECONDS = int(os.environ.get('IP_RATE_LIMIT_LOGIN_WINDOW_SECONDS', '60'))
IP_RATE_LIMIT_REGISTER_MAX = int(os.environ.get('IP_RATE_LIMIT_REGISTER_MAX', '10'))
IP_RATE_LIMIT_REGISTER_WINDOW_SECONDS = int(os.environ.get('IP_RATE_LIMIT_REGISTER_WINDOW_SECONDS', '3600'))
IP_RATE_LIMIT_EMAIL_MAX = int(os.environ.get('IP_RATE_LIMIT_EMAIL_MAX', '20'))
IP_RATE_LIMIT_EMAIL_WINDOW_SECONDS = int(os.environ.get('IP_RATE_LIMIT_EMAIL_WINDOW_SECONDS', '3600'))
# ==================== 超时配置 ====================
PAGE_LOAD_TIMEOUT = int(os.environ.get("PAGE_LOAD_TIMEOUT", "60000")) # 毫秒
DEFAULT_TIMEOUT = int(os.environ.get("DEFAULT_TIMEOUT", "60000")) # 毫秒
PAGE_LOAD_TIMEOUT = int(os.environ.get('PAGE_LOAD_TIMEOUT', '60000')) # 毫秒
DEFAULT_TIMEOUT = int(os.environ.get('DEFAULT_TIMEOUT', '60000')) # 毫秒
# ==================== 知识管理平台配置 ====================
ZSGL_LOGIN_URL = os.environ.get("ZSGL_LOGIN_URL", "https://postoa.aidunsoft.com/admin/login.aspx")
ZSGL_INDEX_URL_PATTERN = os.environ.get("ZSGL_INDEX_URL_PATTERN", "index.aspx")
ZSGL_BASE_URL = os.environ.get("ZSGL_BASE_URL") or _derive_base_url_from_full_url(
ZSGL_LOGIN_URL, "https://postoa.aidunsoft.com"
)
ZSGL_INDEX_URL = os.environ.get("ZSGL_INDEX_URL") or _derive_sibling_url(
ZSGL_LOGIN_URL = os.environ.get('ZSGL_LOGIN_URL', 'https://postoa.aidunsoft.com/admin/login.aspx')
ZSGL_INDEX_URL_PATTERN = os.environ.get('ZSGL_INDEX_URL_PATTERN', 'index.aspx')
ZSGL_BASE_URL = os.environ.get('ZSGL_BASE_URL') or _derive_base_url_from_full_url(ZSGL_LOGIN_URL, 'https://postoa.aidunsoft.com')
ZSGL_INDEX_URL = os.environ.get('ZSGL_INDEX_URL') or _derive_sibling_url(
ZSGL_LOGIN_URL,
ZSGL_INDEX_URL_PATTERN,
f"{ZSGL_BASE_URL}/admin/{ZSGL_INDEX_URL_PATTERN}",
)
MAX_CONCURRENT_CONTEXTS = int(os.environ.get("MAX_CONCURRENT_CONTEXTS", "100"))
MAX_CONCURRENT_CONTEXTS = int(os.environ.get('MAX_CONCURRENT_CONTEXTS', '100'))
# ==================== 服务器配置 ====================
SERVER_HOST = os.environ.get("SERVER_HOST", "0.0.0.0")
SERVER_PORT = int(os.environ.get("SERVER_PORT", "51233"))
SERVER_HOST = os.environ.get('SERVER_HOST', '0.0.0.0')
SERVER_PORT = int(os.environ.get('SERVER_PORT', '51233'))
# ==================== SocketIO配置 ====================
SOCKETIO_CORS_ALLOWED_ORIGINS = os.environ.get("SOCKETIO_CORS_ALLOWED_ORIGINS", "")
SOCKETIO_CORS_ALLOWED_ORIGINS = os.environ.get('SOCKETIO_CORS_ALLOWED_ORIGINS', '*')
# ==================== 网站基础URL配置 ====================
# 用于生成邮件中的验证链接等
BASE_URL = os.environ.get("BASE_URL", "http://localhost:51233")
BASE_URL = os.environ.get('BASE_URL', 'http://localhost:51233')
# ==================== 日志配置 ====================
# 安全修复: 生产环境默认使用INFO级别避免泄露敏感调试信息
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
LOG_FILE = os.environ.get("LOG_FILE", "logs/app.log")
LOG_MAX_BYTES = int(os.environ.get("LOG_MAX_BYTES", "10485760")) # 10MB
LOG_BACKUP_COUNT = int(os.environ.get("LOG_BACKUP_COUNT", "5"))
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
LOG_FILE = os.environ.get('LOG_FILE', 'logs/app.log')
LOG_MAX_BYTES = int(os.environ.get('LOG_MAX_BYTES', '10485760')) # 10MB
LOG_BACKUP_COUNT = int(os.environ.get('LOG_BACKUP_COUNT', '5'))
# ==================== 安全配置 ====================
DEBUG = os.environ.get("FLASK_DEBUG", "False").lower() == "true"
ALLOWED_SCREENSHOT_EXTENSIONS = {".png", ".jpg", ".jpeg"}
MAX_SCREENSHOT_SIZE = int(os.environ.get("MAX_SCREENSHOT_SIZE", "10485760")) # 10MB
LOGIN_CAPTCHA_AFTER_FAILURES = int(os.environ.get("LOGIN_CAPTCHA_AFTER_FAILURES", "3"))
LOGIN_CAPTCHA_WINDOW_SECONDS = int(os.environ.get("LOGIN_CAPTCHA_WINDOW_SECONDS", "900"))
LOGIN_RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get("LOGIN_RATE_LIMIT_WINDOW_SECONDS", "900"))
LOGIN_IP_MAX_ATTEMPTS = int(os.environ.get("LOGIN_IP_MAX_ATTEMPTS", "60"))
LOGIN_USERNAME_MAX_ATTEMPTS = int(os.environ.get("LOGIN_USERNAME_MAX_ATTEMPTS", "30"))
LOGIN_IP_USERNAME_MAX_ATTEMPTS = int(os.environ.get("LOGIN_IP_USERNAME_MAX_ATTEMPTS", "12"))
LOGIN_FAIL_DELAY_BASE_MS = int(os.environ.get("LOGIN_FAIL_DELAY_BASE_MS", "200"))
LOGIN_FAIL_DELAY_MAX_MS = int(os.environ.get("LOGIN_FAIL_DELAY_MAX_MS", "1200"))
LOGIN_ACCOUNT_LOCK_FAILURES = int(os.environ.get("LOGIN_ACCOUNT_LOCK_FAILURES", "6"))
LOGIN_ACCOUNT_LOCK_WINDOW_SECONDS = int(os.environ.get("LOGIN_ACCOUNT_LOCK_WINDOW_SECONDS", "900"))
LOGIN_ACCOUNT_LOCK_SECONDS = int(os.environ.get("LOGIN_ACCOUNT_LOCK_SECONDS", "600"))
LOGIN_SCAN_UNIQUE_USERNAME_THRESHOLD = int(os.environ.get("LOGIN_SCAN_UNIQUE_USERNAME_THRESHOLD", "8"))
LOGIN_SCAN_WINDOW_SECONDS = int(os.environ.get("LOGIN_SCAN_WINDOW_SECONDS", "600"))
LOGIN_SCAN_COOLDOWN_SECONDS = int(os.environ.get("LOGIN_SCAN_COOLDOWN_SECONDS", "600"))
EMAIL_RATE_LIMIT_MAX = int(os.environ.get("EMAIL_RATE_LIMIT_MAX", "6"))
EMAIL_RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get("EMAIL_RATE_LIMIT_WINDOW_SECONDS", "3600"))
LOGIN_ALERT_ENABLED = os.environ.get("LOGIN_ALERT_ENABLED", "true").lower() == "true"
LOGIN_ALERT_MIN_INTERVAL_SECONDS = int(os.environ.get("LOGIN_ALERT_MIN_INTERVAL_SECONDS", "3600"))
ADMIN_REAUTH_WINDOW_SECONDS = int(os.environ.get("ADMIN_REAUTH_WINDOW_SECONDS", "600"))
SECURITY_ENABLED = os.environ.get("SECURITY_ENABLED", "true").lower() == "true"
SECURITY_LOG_LEVEL = os.environ.get("SECURITY_LOG_LEVEL", "INFO")
HONEYPOT_ENABLED = os.environ.get("HONEYPOT_ENABLED", "true").lower() == "true"
AUTO_BAN_ENABLED = os.environ.get("AUTO_BAN_ENABLED", "true").lower() == "true"
DEBUG = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
ALLOWED_SCREENSHOT_EXTENSIONS = {'.png', '.jpg', '.jpeg'}
MAX_SCREENSHOT_SIZE = int(os.environ.get('MAX_SCREENSHOT_SIZE', '10485760')) # 10MB
LOGIN_CAPTCHA_AFTER_FAILURES = int(os.environ.get('LOGIN_CAPTCHA_AFTER_FAILURES', '3'))
LOGIN_CAPTCHA_WINDOW_SECONDS = int(os.environ.get('LOGIN_CAPTCHA_WINDOW_SECONDS', '900'))
LOGIN_RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get('LOGIN_RATE_LIMIT_WINDOW_SECONDS', '900'))
LOGIN_IP_MAX_ATTEMPTS = int(os.environ.get('LOGIN_IP_MAX_ATTEMPTS', '60'))
LOGIN_USERNAME_MAX_ATTEMPTS = int(os.environ.get('LOGIN_USERNAME_MAX_ATTEMPTS', '30'))
LOGIN_IP_USERNAME_MAX_ATTEMPTS = int(os.environ.get('LOGIN_IP_USERNAME_MAX_ATTEMPTS', '12'))
LOGIN_FAIL_DELAY_BASE_MS = int(os.environ.get('LOGIN_FAIL_DELAY_BASE_MS', '200'))
LOGIN_FAIL_DELAY_MAX_MS = int(os.environ.get('LOGIN_FAIL_DELAY_MAX_MS', '1200'))
LOGIN_ACCOUNT_LOCK_FAILURES = int(os.environ.get('LOGIN_ACCOUNT_LOCK_FAILURES', '6'))
LOGIN_ACCOUNT_LOCK_WINDOW_SECONDS = int(os.environ.get('LOGIN_ACCOUNT_LOCK_WINDOW_SECONDS', '900'))
LOGIN_ACCOUNT_LOCK_SECONDS = int(os.environ.get('LOGIN_ACCOUNT_LOCK_SECONDS', '600'))
LOGIN_SCAN_UNIQUE_USERNAME_THRESHOLD = int(os.environ.get('LOGIN_SCAN_UNIQUE_USERNAME_THRESHOLD', '8'))
LOGIN_SCAN_WINDOW_SECONDS = int(os.environ.get('LOGIN_SCAN_WINDOW_SECONDS', '600'))
LOGIN_SCAN_COOLDOWN_SECONDS = int(os.environ.get('LOGIN_SCAN_COOLDOWN_SECONDS', '600'))
EMAIL_RATE_LIMIT_MAX = int(os.environ.get('EMAIL_RATE_LIMIT_MAX', '6'))
EMAIL_RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get('EMAIL_RATE_LIMIT_WINDOW_SECONDS', '3600'))
LOGIN_ALERT_ENABLED = os.environ.get('LOGIN_ALERT_ENABLED', 'true').lower() == 'true'
LOGIN_ALERT_MIN_INTERVAL_SECONDS = int(os.environ.get('LOGIN_ALERT_MIN_INTERVAL_SECONDS', '3600'))
ADMIN_REAUTH_WINDOW_SECONDS = int(os.environ.get('ADMIN_REAUTH_WINDOW_SECONDS', '600'))
SECURITY_ENABLED = os.environ.get('SECURITY_ENABLED', 'true').lower() == 'true'
SECURITY_LOG_LEVEL = os.environ.get('SECURITY_LOG_LEVEL', 'INFO')
HONEYPOT_ENABLED = os.environ.get('HONEYPOT_ENABLED', 'true').lower() == 'true'
AUTO_BAN_ENABLED = os.environ.get('AUTO_BAN_ENABLED', 'true').lower() == 'true'
@classmethod
def validate(cls):
@@ -285,38 +239,12 @@ class Config:
if cls.DB_POOL_SIZE < 1:
errors.append("DB_POOL_SIZE必须大于0")
if cls.DB_CONNECT_TIMEOUT_SECONDS < 1:
errors.append("DB_CONNECT_TIMEOUT_SECONDS必须大于0")
if cls.DB_BUSY_TIMEOUT_MS < 100:
errors.append("DB_BUSY_TIMEOUT_MS必须至少100毫秒")
if cls.DB_CACHE_SIZE_KB < 1024:
errors.append("DB_CACHE_SIZE_KB建议至少1024")
if cls.DB_WAL_AUTOCHECKPOINT_PAGES < 100:
errors.append("DB_WAL_AUTOCHECKPOINT_PAGES建议至少100")
if cls.DB_MMAP_SIZE_MB < 0:
errors.append("DB_MMAP_SIZE_MB不能为负数")
if cls.DB_LOCK_RETRY_COUNT < 0:
errors.append("DB_LOCK_RETRY_COUNT不能为负数")
if cls.DB_LOCK_RETRY_BASE_MS < 10:
errors.append("DB_LOCK_RETRY_BASE_MS建议至少10毫秒")
if cls.DB_SLOW_QUERY_MS < 0:
errors.append("DB_SLOW_QUERY_MS不能为负数")
if cls.DB_SLOW_QUERY_SQL_MAX_LEN < 80:
errors.append("DB_SLOW_QUERY_SQL_MAX_LEN建议至少80")
if cls.DB_SLOW_SQL_WINDOW_SECONDS < 600:
errors.append("DB_SLOW_SQL_WINDOW_SECONDS建议至少600")
if cls.DB_SLOW_SQL_TOP_LIMIT < 5:
errors.append("DB_SLOW_SQL_TOP_LIMIT建议至少5")
if cls.DB_SLOW_SQL_RECENT_LIMIT < 10:
errors.append("DB_SLOW_SQL_RECENT_LIMIT建议至少10")
if cls.DB_SLOW_SQL_MAX_EVENTS < cls.DB_SLOW_SQL_RECENT_LIMIT:
errors.append("DB_SLOW_SQL_MAX_EVENTS必须不小于DB_SLOW_SQL_RECENT_LIMIT")
# 验证日志配置
if cls.LOG_LEVEL not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
if cls.LOG_LEVEL not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
errors.append(f"LOG_LEVEL无效: {cls.LOG_LEVEL}")
if cls.SECURITY_LOG_LEVEL not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
if cls.SECURITY_LOG_LEVEL not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
errors.append(f"SECURITY_LOG_LEVEL无效: {cls.SECURITY_LOG_LEVEL}")
return errors
@@ -342,14 +270,12 @@ class Config:
class DevelopmentConfig(Config):
"""开发环境配置"""
DEBUG = True
# 不覆盖SESSION_COOKIE_SECURE使用父类的环境变量配置
class ProductionConfig(Config):
"""生产环境配置"""
DEBUG = False
# 不覆盖SESSION_COOKIE_SECURE使用父类的环境变量配置
# 如需HTTPS请在环境变量中设置 SESSION_COOKIE_SECURE=true
@@ -357,27 +283,26 @@ class ProductionConfig(Config):
class TestingConfig(Config):
"""测试环境配置"""
DEBUG = True
TESTING = True
DB_FILE = "data/test_app_data.db"
DB_FILE = 'data/test_app_data.db'
# 根据环境变量选择配置
config_map = {
"development": DevelopmentConfig,
"production": ProductionConfig,
"testing": TestingConfig,
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
}
def get_config():
"""获取当前环境的配置"""
env = os.environ.get("FLASK_ENV", "production")
env = os.environ.get('FLASK_ENV', 'production')
return config_map.get(env, ProductionConfig)
if __name__ == "__main__":
if __name__ == '__main__':
# 配置验证测试
config = get_config()
errors = config.validate()
@@ -387,5 +312,5 @@ if __name__ == "__main__":
for error in errors:
print(f"{error}")
else:
print("[OK] 配置验证通过")
print(" 配置验证通过")
config.print_config()

View File

@@ -7,7 +7,6 @@
import logging
import os
import re
from logging.handlers import RotatingFileHandler
from datetime import datetime
import threading
@@ -46,31 +45,6 @@ class ColoredFormatter(logging.Formatter):
return result
class SensitiveDataFilter(logging.Filter):
"""对日志中的敏感字段做统一脱敏处理。"""
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
_PAIR_PATTERNS = (
(re.compile(r"(?i)\b(password|passwd|pwd)\s*[:=]\s*([^,\s]+)"), r"\1=[REDACTED]"),
(re.compile(r"(?i)\b(token|csrf_token|session|authorization)\s*[:=]\s*([^,\s]+)"), r"\1=[REDACTED]"),
(re.compile(r"(?i)\b(user_id|admin_id|token_id)\s*=\s*\d+\b"), r"\1=[MASKED]"),
)
def filter(self, record: logging.LogRecord) -> bool:
try:
message = record.getMessage()
sanitized = self._EMAIL_RE.sub("[REDACTED_EMAIL]", message)
for pattern, replacement in self._PAIR_PATTERNS:
sanitized = pattern.sub(replacement, sanitized)
if sanitized != message:
record.msg = sanitized
record.args = ()
except Exception:
# 日志过滤异常不应影响业务日志输出
pass
return True
def setup_logger(name='app', level=None, log_file=None, max_bytes=10*1024*1024, backup_count=5):
"""
设置日志记录器
@@ -100,17 +74,6 @@ def setup_logger(name='app', level=None, log_file=None, max_bytes=10*1024*1024,
# 清除已有的处理器(避免重复)
logger.handlers.clear()
logger.filters.clear()
# 全局敏感日志脱敏(默认开启,可通过 LOG_REDACT_SENSITIVE=0 关闭)
redact_enabled = str(os.environ.get("LOG_REDACT_SENSITIVE", "1")).strip().lower() in {
"1",
"true",
"yes",
"on",
}
if redact_enabled:
logger.addFilter(SensitiveDataFilter())
# 日志格式
detailed_formatter = logging.Formatter(
@@ -318,9 +281,9 @@ def init_logging(log_level='INFO', log_file='logs/app.log'):
# 创建审计日志器已在AuditLogger中创建
try:
get_logger('app').info("[OK] 日志系统初始化完成")
get_logger('app').info(" 日志系统初始化完成")
except Exception:
print("[OK] 日志系统初始化完成")
print(" 日志系统初始化完成")
if __name__ == '__main__':

View File

@@ -9,7 +9,6 @@ import os
import re
import time
import hashlib
import hmac
import secrets
import ipaddress
import socket
@@ -79,13 +78,7 @@ def sanitize_filename(filename):
class IPRateLimiter:
"""IP访问频率限制器"""
def __init__(
self,
max_attempts=10,
window_seconds=3600,
lock_duration=3600,
max_tracked_ips=20000,
):
def __init__(self, max_attempts=10, window_seconds=3600, lock_duration=3600):
"""
初始化限流器
@@ -97,7 +90,6 @@ class IPRateLimiter:
self.max_attempts = max_attempts
self.window_seconds = window_seconds
self.lock_duration = lock_duration
self.max_tracked_ips = max(1000, int(max_tracked_ips or 0))
# IP访问记录: {ip: [(timestamp, success), ...]}
self._attempts = defaultdict(list)
@@ -105,47 +97,6 @@ class IPRateLimiter:
self._locked = {}
self._lock = threading.Lock()
def _prune_if_oversized(self, now_ts: float) -> None:
"""限制内部映射大小避免在高频随机IP攻击下持续膨胀。"""
tracked = len(self._attempts) + len(self._locked)
if tracked <= self.max_tracked_ips:
return
cutoff_time = now_ts - self.window_seconds
for ip in list(self._attempts.keys()):
self._attempts[ip] = [
(ts, succ) for ts, succ in self._attempts[ip]
if ts > cutoff_time
]
if not self._attempts[ip]:
del self._attempts[ip]
for ip in list(self._locked.keys()):
if now_ts >= self._locked[ip]:
del self._locked[ip]
tracked = len(self._attempts) + len(self._locked)
if tracked <= self.max_tracked_ips:
return
# 优先按“最近访问时间最早”淘汰 attempts 中的 IP 记录。
overflow = tracked - self.max_tracked_ips
oldest = []
for ip, attempt_items in self._attempts.items():
if attempt_items:
oldest.append((attempt_items[-1][0], ip))
else:
oldest.append((0.0, ip))
oldest.sort(key=lambda item: item[0])
removed = 0
for _, ip in oldest:
self._attempts.pop(ip, None)
self._locked.pop(ip, None)
removed += 1
if removed >= overflow:
break
def is_locked(self, ip_address):
"""
检查IP是否被锁定
@@ -178,7 +129,6 @@ class IPRateLimiter:
"""
with self._lock:
now = time.time()
self._prune_if_oversized(now)
# 清理过期记录
cutoff_time = now - self.window_seconds
@@ -247,9 +197,6 @@ class IPRateLimiter:
# 全局IP限流器实例
ip_rate_limiter = IPRateLimiter()
_TRUTHY_VALUES = {"1", "true", "yes", "on"}
_TRUST_PROXY_HEADERS = str(os.environ.get("TRUST_PROXY_HEADERS", "false") or "").strip().lower() in _TRUTHY_VALUES
def require_ip_not_locked(f):
"""装饰器检查IP是否被锁定"""
@@ -407,19 +354,7 @@ def generate_csrf_token():
def validate_csrf_token(token):
"""验证CSRF令牌"""
expected = session.get("csrf_token")
if (token is None) or (expected is None):
return False
provided_text = str(token or "")
expected_text = str(expected or "")
if (not provided_text) or (not expected_text):
return False
return hmac.compare_digest(
provided_text.encode("utf-8"),
expected_text.encode("utf-8"),
)
return token == session.get('csrf_token')
# ==================== 内容安全 ====================
@@ -508,7 +443,7 @@ def get_client_ip(trust_proxy=False):
"""
# 安全说明X-Forwarded-For 可被伪造
# 仅在确认请求来自可信代理时才使用代理头
if trust_proxy and _TRUST_PROXY_HEADERS:
if trust_proxy:
if request.headers.get('X-Forwarded-For'):
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
elif request.headers.get('X-Real-IP'):
@@ -518,90 +453,30 @@ def get_client_ip(trust_proxy=False):
return request.remote_addr
def _load_trusted_proxy_networks():
"""加载可信代理 CIDR 列表。"""
default_cidrs = "127.0.0.1/32,::1/128"
raw = str(os.environ.get("TRUSTED_PROXY_CIDRS", default_cidrs) or "").strip()
if not raw:
return []
networks = []
for segment in raw.split(","):
cidr_text = str(segment or "").strip()
if not cidr_text:
continue
try:
networks.append(ipaddress.ip_network(cidr_text, strict=False))
except ValueError:
continue
return networks
_TRUSTED_PROXY_NETWORKS = _load_trusted_proxy_networks()
def _parse_ip_address(candidate: str):
try:
return ipaddress.ip_address(str(candidate or "").strip())
except ValueError:
return None
def _is_trusted_proxy_ip(ip_obj) -> bool:
if ip_obj is None:
return False
for network in _TRUSTED_PROXY_NETWORKS:
try:
if ip_obj.version != network.version:
continue
if ip_obj in network:
return True
except Exception:
continue
return False
def _extract_real_ip_from_forwarded_chain() -> str | None:
"""基于 X-Forwarded-For 链反向提取最靠近应用侧的“非代理”来源 IP。"""
forwarded = str(request.headers.get("X-Forwarded-For", "") or "")
candidates = []
for segment in forwarded.split(","):
ip_text = str(segment or "").strip()
ip_obj = _parse_ip_address(ip_text)
if ip_obj is None:
continue
candidates.append((str(ip_obj), ip_obj))
# 若存在 X-Forwarded-For按“从右到左”剥离可信代理。
if candidates:
for ip_text, ip_obj in reversed(candidates):
if _is_trusted_proxy_ip(ip_obj):
continue
return ip_text
return candidates[0][0]
real_ip_text = str(request.headers.get("X-Real-IP", "") or "").strip()
real_ip_obj = _parse_ip_address(real_ip_text)
if real_ip_obj is None:
return None
return str(real_ip_obj)
def get_rate_limit_ip() -> str:
"""在可信代理场景下取真实IP用于限流/风控。"""
remote_addr = request.remote_addr or ""
if not _TRUST_PROXY_HEADERS:
return remote_addr
try:
remote_ip = ipaddress.ip_address(remote_addr)
except ValueError:
remote_ip = None
remote_ip = _parse_ip_address(remote_addr)
if remote_ip is None:
return remote_addr
# 仅当请求来自可信代理时才信任转发头。
if _is_trusted_proxy_ip(remote_ip):
forwarded_real_ip = _extract_real_ip_from_forwarded_chain()
if forwarded_real_ip:
return forwarded_real_ip
if remote_ip and (remote_ip.is_private or remote_ip.is_loopback or remote_ip.is_link_local):
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
candidate = forwarded.split(",")[0].strip()
try:
ipaddress.ip_address(candidate)
return candidate
except ValueError:
pass
real_ip = request.headers.get("X-Real-IP", "").strip()
if real_ip:
try:
ipaddress.ip_address(real_ip)
return real_ip
except ValueError:
pass
return remote_addr

214
browser_installer.py Executable file
View File

@@ -0,0 +1,214 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
浏览器自动下载安装模块
检测本地是否有Playwright浏览器如果没有则自动下载安装
"""
import os
import sys
import shutil
import subprocess
from pathlib import Path
# 设置浏览器安装路径支持Docker和本地环境
# Docker环境: PLAYWRIGHT_BROWSERS_PATH环境变量已设置为 /ms-playwright
# 本地环境: 使用Playwright默认路径
if 'PLAYWRIGHT_BROWSERS_PATH' in os.environ:
BROWSERS_PATH = os.environ['PLAYWRIGHT_BROWSERS_PATH']
else:
# Windows: %USERPROFILE%\AppData\Local\ms-playwright
# Linux: ~/.cache/ms-playwright
if sys.platform == 'win32':
BROWSERS_PATH = str(Path.home() / "AppData" / "Local" / "ms-playwright")
else:
BROWSERS_PATH = str(Path.home() / ".cache" / "ms-playwright")
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = BROWSERS_PATH
class BrowserInstaller:
"""浏览器安装器"""
def __init__(self, log_callback=None):
"""
初始化安装器
Args:
log_callback: 日志回调函数
"""
self.log_callback = log_callback
def log(self, message):
"""输出日志"""
if self.log_callback:
self.log_callback(message)
else:
try:
print(message)
except UnicodeEncodeError:
# 如果打印Unicode字符失败替换特殊字符
safe_message = message.replace('', '[OK]').replace('', '[X]')
print(safe_message)
def check_playwright_installed(self):
"""检查Playwright是否已安装"""
try:
import playwright
self.log("✓ Playwright已安装")
return True
except ImportError:
self.log("✗ Playwright未安装")
return False
def check_chromium_installed(self):
"""检查Chromium浏览器是否已安装"""
try:
from playwright.sync_api import sync_playwright
# 尝试启动浏览器检查是否可用
with sync_playwright() as p:
try:
# 使用超时快速检查
browser = p.chromium.launch(headless=True, timeout=5000)
browser.close()
self.log("✓ Chromium浏览器已安装且可用")
return True
except Exception as e:
error_msg = str(e)
self.log(f"✗ Chromium浏览器不可用: {error_msg}")
# 检查是否是路径不存在的错误
if "Executable doesn't exist" in error_msg:
self.log("检测到浏览器文件缺失,需要重新安装")
return False
except Exception as e:
self.log(f"✗ 检查浏览器时出错: {str(e)}")
return False
def install_chromium(self):
"""安装Chromium浏览器"""
try:
self.log("正在安装 Chromium 浏览器...")
# 查找 playwright 可执行文件
playwright_cli = None
possible_paths = [
os.path.join(os.path.dirname(sys.executable), "Scripts", "playwright.exe"),
os.path.join(os.path.dirname(sys.executable), "playwright.exe"),
os.path.join(os.path.dirname(sys.executable), "Scripts", "playwright"),
os.path.join(os.path.dirname(sys.executable), "playwright"),
"playwright", # 系统PATH中
]
for path in possible_paths:
if os.path.exists(path) or shutil.which(path):
playwright_cli = path
break
# 如果找到了 playwright CLI直接调用
if playwright_cli:
self.log(f"使用 Playwright CLI: {playwright_cli}")
result = subprocess.run(
[playwright_cli, "install", "chromium"],
capture_output=True,
text=True,
timeout=300
)
else:
# 检测是否是 Nuitka 编译的程序
is_nuitka = hasattr(sys, 'frozen') or '__compiled__' in globals()
if is_nuitka:
self.log("检测到 Nuitka 编译环境")
self.log("✗ 无法找到 playwright CLI 工具")
self.log("请手动运行: playwright install chromium")
return False
else:
# 使用 python -m
result = subprocess.run(
[sys.executable, "-m", "playwright", "install", "chromium"],
capture_output=True,
text=True,
timeout=300
)
if result.returncode == 0:
self.log("✓ Chromium浏览器安装成功")
return True
else:
self.log(f"✗ 浏览器安装失败: {result.stderr}")
return False
except subprocess.TimeoutExpired:
self.log("✗ 浏览器安装超时")
return False
except Exception as e:
self.log(f"✗ 浏览器安装出错: {str(e)}")
return False
def auto_install(self):
"""
自动检测并安装所需环境
Returns:
是否成功安装或已安装
"""
self.log("=" * 60)
self.log("检查浏览器环境...")
self.log("=" * 60)
# 1. 检查Playwright是否安装
if not self.check_playwright_installed():
self.log("✗ Playwright未安装无法继续")
self.log("请确保程序包含 Playwright 库")
return False
# 2. 检查Chromium浏览器是否安装
if not self.check_chromium_installed():
self.log("\n未检测到Chromium浏览器开始自动安装...")
# 安装浏览器
if not self.install_chromium():
self.log("✗ 浏览器安装失败")
self.log("\n您可以尝试以下方法:")
self.log("1. 手动执行: playwright install chromium")
self.log("2. 检查网络连接后重试")
self.log("3. 检查防火墙设置")
return False
self.log("\n" + "=" * 60)
self.log("✓ 浏览器环境检查完成,一切就绪!")
self.log("=" * 60 + "\n")
return True
def check_and_install_browser(log_callback=None):
"""
便捷函数:检查并安装浏览器
Args:
log_callback: 日志回调函数
Returns:
是否成功
"""
installer = BrowserInstaller(log_callback)
return installer.auto_install()
# 测试代码
if __name__ == "__main__":
print("浏览器自动安装工具")
print("=" * 60)
installer = BrowserInstaller()
success = installer.auto_install()
if success:
print("\n✓ 安装成功!您现在可以运行主程序了。")
else:
print("\n✗ 安装失败,请查看上方错误信息。")
print("=" * 60)

View File

@@ -1,98 +1,20 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""截图线程池管理 - 工作线程池模式(并发执行截图任务)"""
import os
import threading
import queue
import time
from typing import Callable, Optional, Dict, Any
import os
import threading
import queue
import time
from typing import Callable, Optional, Dict, Any
# 安全修复: 将魔法数字提取为可配置常量
BROWSER_IDLE_TIMEOUT = int(os.environ.get("BROWSER_IDLE_TIMEOUT", "300")) # 空闲超时(秒)默认5分钟
TASK_QUEUE_TIMEOUT = int(os.environ.get("TASK_QUEUE_TIMEOUT", "10")) # 队列获取超时(秒)
TASK_QUEUE_MAXSIZE = int(os.environ.get("BROWSER_TASK_QUEUE_MAXSIZE", "200")) # 队列最大长度(0表示无限制)
BROWSER_MAX_USE_COUNT = int(os.environ.get("BROWSER_MAX_USE_COUNT", "0")) # 每个执行环境最大复用次数(0表示不限制)
# 新增:自适应资源配置
ADAPTIVE_CONFIG = os.environ.get("BROWSER_ADAPTIVE_CONFIG", "1").strip().lower() in ("1", "true", "yes", "on")
LOAD_HISTORY_SIZE = 50 # 负载历史记录大小
class AdaptiveResourceManager:
"""自适应资源管理器"""
def __init__(self):
self._load_history = []
self._current_load = 0
self._last_adjustment = 0
self._adjustment_cooldown = 30 # 调整冷却时间30秒
def record_task_interval(self, interval: float):
"""记录任务间隔,更新负载历史"""
if len(self._load_history) >= LOAD_HISTORY_SIZE:
self._load_history.pop(0)
self._load_history.append(interval)
# 计算当前负载
if len(self._load_history) >= 2:
recent_intervals = self._load_history[-10:] # 最近10个任务
avg_interval = sum(recent_intervals) / len(recent_intervals)
# 负载越高,间隔越短
self._current_load = 1.0 / max(avg_interval, 0.1)
def should_adjust_timeout(self) -> bool:
"""判断是否应该调整超时配置"""
if not ADAPTIVE_CONFIG:
return False
current_time = time.time()
if current_time - self._last_adjustment < self._adjustment_cooldown:
return False
return len(self._load_history) >= 10 # 至少需要10个数据点
def calculate_optimal_idle_timeout(self) -> int:
"""基于历史负载计算最优空闲超时"""
if not self._load_history:
return BROWSER_IDLE_TIMEOUT
# 计算最近任务间隔的平均值
recent_intervals = self._load_history[-20:] # 最近20个任务
if len(recent_intervals) < 2:
return BROWSER_IDLE_TIMEOUT
avg_interval = sum(recent_intervals) / len(recent_intervals)
# 根据负载动态调整超时
# 高负载时缩短超时,低负载时延长超时
if self._current_load > 2.0: # 高负载
optimal_timeout = min(avg_interval * 1.5, 600) # 最多10分钟
elif self._current_load < 0.5: # 低负载
optimal_timeout = min(avg_interval * 3.0, 1800) # 最多30分钟
else: # 正常负载
optimal_timeout = min(avg_interval * 2.0, 900) # 最多15分钟
return max(int(optimal_timeout), 60) # 最少1分钟
def get_optimal_queue_timeout(self) -> int:
"""获取最优队列超时"""
if not self._load_history:
return TASK_QUEUE_TIMEOUT
# 根据任务频率调整队列超时
if self._current_load > 2.0: # 高负载时减少等待
return max(TASK_QUEUE_TIMEOUT // 2, 3)
elif self._current_load < 0.5: # 低负载时可以增加等待
return min(TASK_QUEUE_TIMEOUT * 2, 30)
else:
return TASK_QUEUE_TIMEOUT
def record_adjustment(self):
"""记录一次调整操作"""
self._last_adjustment = time.time()
BROWSER_IDLE_TIMEOUT = int(os.environ.get('BROWSER_IDLE_TIMEOUT', '300')) # 空闲超时(秒)默认5分钟
TASK_QUEUE_TIMEOUT = int(os.environ.get('TASK_QUEUE_TIMEOUT', '10')) # 队列获取超时(秒)
TASK_QUEUE_MAXSIZE = int(os.environ.get('BROWSER_TASK_QUEUE_MAXSIZE', '200')) # 队列最大长度(0表示无限制)
BROWSER_MAX_USE_COUNT = int(os.environ.get('BROWSER_MAX_USE_COUNT', '0')) # 每个执行环境最大复用次数(0表示不限制)
class BrowserWorker(threading.Thread):
"""截图工作线程 - 每个worker维护自己的执行环境"""
@@ -114,28 +36,21 @@ class BrowserWorker(threading.Thread):
self.failed_tasks = 0
self.pre_warm = pre_warm
self.last_activity_ts = 0.0
self.task_start_time = 0.0
# 初始化自适应资源管理器
if ADAPTIVE_CONFIG:
self._adaptive_mgr = AdaptiveResourceManager()
else:
self._adaptive_mgr = None
def log(self, message: str):
"""日志输出"""
if self.log_callback:
self.log_callback(f"[Worker-{self.worker_id}] {message}")
else:
def log(self, message: str):
"""日志输出"""
if self.log_callback:
self.log_callback(f"[Worker-{self.worker_id}] {message}")
else:
print(f"[截图池][Worker-{self.worker_id}] {message}")
def _create_browser(self):
"""创建截图执行环境(逻辑占位,无需真实浏览器)"""
created_at = time.time()
self.browser_instance = {
"created_at": created_at,
"use_count": 0,
"worker_id": self.worker_id,
'created_at': created_at,
'use_count': 0,
'worker_id': self.worker_id,
}
self.last_activity_ts = created_at
self.log("截图执行环境就绪")
@@ -158,7 +73,7 @@ class BrowserWorker(threading.Thread):
self.log("执行环境不可用,尝试重新创建...")
self._close_browser()
return self._create_browser()
def run(self):
"""工作线程主循环 - 按需启动执行环境模式"""
if self.pre_warm:
@@ -179,33 +94,19 @@ class BrowserWorker(threading.Thread):
# 从队列获取任务(带超时,以便能响应停止信号和空闲检查)
self.idle = True
# 使用自适应队列超时
queue_timeout = (
self._adaptive_mgr.get_optimal_queue_timeout() if self._adaptive_mgr else TASK_QUEUE_TIMEOUT
)
try:
task = self.task_queue.get(timeout=queue_timeout)
task = self.task_queue.get(timeout=TASK_QUEUE_TIMEOUT)
except queue.Empty:
# 检查是否需要释放空闲的执行环境
if self.browser_instance and self.last_activity_ts > 0:
idle_time = time.time() - self.last_activity_ts
# 使用自适应空闲超时
optimal_timeout = (
self._adaptive_mgr.calculate_optimal_idle_timeout()
if self._adaptive_mgr
else BROWSER_IDLE_TIMEOUT
)
if idle_time > optimal_timeout:
self.log(f"空闲{int(idle_time)}秒(优化超时:{optimal_timeout}秒),释放执行环境")
if idle_time > BROWSER_IDLE_TIMEOUT:
self.log(f"空闲{int(idle_time)}秒,释放执行环境")
self._close_browser()
continue
self.idle = False
self.idle = False
if task is None: # None作为停止信号
self.log("收到停止信号")
break
@@ -245,40 +146,21 @@ class BrowserWorker(threading.Thread):
continue
# 执行任务
task_func = task.get("func")
task_args = task.get("args", ())
task_kwargs = task.get("kwargs", {})
callback = task.get("callback")
self.total_tasks += 1
# 确保browser_instance存在后再访问
if self.browser_instance is None:
self.log("执行环境不可用,任务失败")
if callable(callback):
callback(None, "执行环境不可用")
self.failed_tasks += 1
continue
self.browser_instance["use_count"] += 1
task_func = task.get('func')
task_args = task.get('args', ())
task_kwargs = task.get('kwargs', {})
callback = task.get('callback')
self.total_tasks += 1
self.browser_instance['use_count'] += 1
self.log(f"开始执行任务(第{self.browser_instance['use_count']}次执行)")
# 记录任务开始时间
task_start_time = time.time()
try:
# 将执行环境实例传递给任务函数
# 将执行环境实例传递给任务函数
result = task_func(self.browser_instance, *task_args, **task_kwargs)
callback(result, None)
self.log(f"任务执行成功")
# 记录任务完成并更新负载历史
task_end_time = time.time()
task_interval = task_end_time - task_start_time
if self._adaptive_mgr:
self._adaptive_mgr.record_task_interval(task_interval)
self.last_activity_ts = time.time()
except Exception as e:
@@ -294,23 +176,23 @@ class BrowserWorker(threading.Thread):
# 定期重启执行环境,释放可能累积的资源
if self.browser_instance and BROWSER_MAX_USE_COUNT > 0:
if self.browser_instance.get("use_count", 0) >= BROWSER_MAX_USE_COUNT:
if self.browser_instance.get('use_count', 0) >= BROWSER_MAX_USE_COUNT:
self.log(f"执行环境已复用{self.browser_instance['use_count']}次,重启释放资源")
self._close_browser()
except Exception as e:
self.log(f"Worker出错: {e}")
time.sleep(1)
# 清理资源
self._close_browser()
self.log(f"Worker停止总任务:{self.total_tasks}, 失败:{self.failed_tasks}")
def stop(self):
"""停止worker"""
self.running = False
# 清理资源
self._close_browser()
self.log(f"Worker停止总任务:{self.total_tasks}, 失败:{self.failed_tasks}")
def stop(self):
"""停止worker"""
self.running = False
class BrowserWorkerPool:
"""截图工作线程池"""
@@ -322,14 +204,14 @@ class BrowserWorkerPool:
self.workers = []
self.initialized = False
self.lock = threading.Lock()
def log(self, message: str):
"""日志输出"""
def log(self, message: str):
"""日志输出"""
if self.log_callback:
self.log_callback(message)
else:
print(f"[截图池] {message}")
def initialize(self):
"""初始化工作线程池按需模式默认预热1个执行环境"""
with self.lock:
@@ -349,7 +231,7 @@ class BrowserWorkerPool:
self.workers.append(worker)
self.initialized = True
self.log(f"[OK] 截图线程池初始化完成({self.pool_size}个worker就绪执行环境将在有任务时按需启动")
self.log(f" 截图线程池初始化完成({self.pool_size}个worker就绪执行环境将在有任务时按需启动")
# 初始化完成后默认预热1个执行环境降低容器重启后前几批任务的冷启动开销
self.warmup(1)
@@ -381,40 +263,40 @@ class BrowserWorkerPool:
time.sleep(0.1)
warmed = sum(1 for w in target_workers if w.browser_instance)
self.log(f"[OK] 截图线程池预热完成({warmed}个执行环境就绪)")
self.log(f" 截图线程池预热完成({warmed}个执行环境就绪)")
return warmed
def submit_task(self, task_func: Callable, callback: Callable, *args, **kwargs) -> bool:
"""
提交任务到队列
Args:
task_func: 任务函数,签名为 func(browser_instance, *args, **kwargs)
callback: 回调函数,签名为 callback(result, error)
*args, **kwargs: 传递给task_func的参数
Returns:
是否成功提交
"""
if not self.initialized:
self.log("警告:线程池未初始化")
return False
"""
提交任务到队列
Args:
task_func: 任务函数,签名为 func(browser_instance, *args, **kwargs)
callback: 回调函数,签名为 callback(result, error)
*args, **kwargs: 传递给task_func的参数
Returns:
是否成功提交
"""
if not self.initialized:
self.log("警告:线程池未初始化")
return False
task = {
"func": task_func,
"args": args,
"kwargs": kwargs,
"callback": callback,
"retry_count": 0,
'func': task_func,
'args': args,
'kwargs': kwargs,
'callback': callback,
'retry_count': 0,
}
try:
self.task_queue.put(task, timeout=1)
return True
except queue.Full:
self.log(f"警告任务队列已满maxsize={self.task_queue.maxsize}),拒绝提交任务")
return False
def get_stats(self) -> Dict[str, Any]:
"""获取线程池统计信息"""
workers = list(self.workers or [])
@@ -446,64 +328,64 @@ class BrowserWorkerPool:
)
return {
"pool_size": self.pool_size,
"idle_workers": idle_count,
"busy_workers": max(0, len(workers) - idle_count),
"queue_size": self.task_queue.qsize(),
"total_tasks": total_tasks,
"failed_tasks": failed_tasks,
"success_rate": f"{(total_tasks - failed_tasks) / total_tasks * 100:.1f}%" if total_tasks > 0 else "N/A",
"workers": worker_details,
"timestamp": time.time(),
'pool_size': self.pool_size,
'idle_workers': idle_count,
'busy_workers': max(0, len(workers) - idle_count),
'queue_size': self.task_queue.qsize(),
'total_tasks': total_tasks,
'failed_tasks': failed_tasks,
'success_rate': f"{(total_tasks - failed_tasks) / total_tasks * 100:.1f}%" if total_tasks > 0 else "N/A",
'workers': worker_details,
'timestamp': time.time(),
}
def wait_for_completion(self, timeout: Optional[float] = None):
"""等待所有任务完成"""
start_time = time.time()
while not self.task_queue.empty():
if timeout and (time.time() - start_time) > timeout:
self.log("等待超时")
return False
time.sleep(0.5)
# 再等待一下确保正在执行的任务完成
time.sleep(2)
return True
def shutdown(self):
"""关闭线程池"""
self.log("正在关闭工作线程池...")
# 发送停止信号
for _ in self.workers:
self.task_queue.put(None)
# 等待所有worker停止
for worker in self.workers:
worker.join(timeout=10)
self.workers.clear()
self.initialized = False
self.log("[OK] 工作线程池已关闭")
# 全局实例
_global_pool: Optional[BrowserWorkerPool] = None
_pool_lock = threading.Lock()
def wait_for_completion(self, timeout: Optional[float] = None):
"""等待所有任务完成"""
start_time = time.time()
while not self.task_queue.empty():
if timeout and (time.time() - start_time) > timeout:
self.log("等待超时")
return False
time.sleep(0.5)
# 再等待一下确保正在执行的任务完成
time.sleep(2)
return True
def shutdown(self):
"""关闭线程池"""
self.log("正在关闭工作线程池...")
# 发送停止信号
for _ in self.workers:
self.task_queue.put(None)
# 等待所有worker停止
for worker in self.workers:
worker.join(timeout=10)
self.workers.clear()
self.initialized = False
self.log(" 工作线程池已关闭")
# 全局实例
_global_pool: Optional[BrowserWorkerPool] = None
_pool_lock = threading.Lock()
def get_browser_worker_pool(pool_size: int = 3, log_callback: Optional[Callable] = None) -> BrowserWorkerPool:
"""获取全局截图工作线程池(单例)"""
global _global_pool
with _pool_lock:
if _global_pool is None:
_global_pool = BrowserWorkerPool(pool_size=pool_size, log_callback=log_callback)
_global_pool.initialize()
return _global_pool
global _global_pool
with _pool_lock:
if _global_pool is None:
_global_pool = BrowserWorkerPool(pool_size=pool_size, log_callback=log_callback)
_global_pool.initialize()
return _global_pool
def init_browser_worker_pool(pool_size: int = 3, log_callback: Optional[Callable] = None):
"""初始化全局截图工作线程池"""
get_browser_worker_pool(pool_size=pool_size, log_callback=log_callback)
@@ -546,43 +428,43 @@ def resize_browser_worker_pool(pool_size: int, log_callback: Optional[Callable]
def shutdown_browser_worker_pool():
"""关闭全局截图工作线程池"""
global _global_pool
with _pool_lock:
if _global_pool:
_global_pool.shutdown()
_global_pool = None
if __name__ == "__main__":
with _pool_lock:
if _global_pool:
_global_pool.shutdown()
_global_pool = None
if __name__ == '__main__':
# 测试代码
print("测试截图工作线程池...")
def test_task(browser_instance, url: str, task_id: int):
"""测试任务访问URL"""
print(f"[Task-{task_id}] 开始访问: {url}")
time.sleep(2) # 模拟截图耗时
return {"task_id": task_id, "url": url, "status": "success"}
def test_callback(result, error):
"""测试回调"""
if error:
print(f"任务失败: {error}")
else:
print(f"任务成功: {result}")
def test_task(browser_instance, url: str, task_id: int):
"""测试任务访问URL"""
print(f"[Task-{task_id}] 开始访问: {url}")
time.sleep(2) # 模拟截图耗时
return {'task_id': task_id, 'url': url, 'status': 'success'}
def test_callback(result, error):
"""测试回调"""
if error:
print(f"任务失败: {error}")
else:
print(f"任务成功: {result}")
# 创建线程池2个worker
pool = BrowserWorkerPool(pool_size=2)
pool.initialize()
# 提交4个任务
for i in range(4):
pool.submit_task(test_task, test_callback, f"https://example.com/{i}", i + 1)
print("\n任务已提交,等待完成...")
pool.wait_for_completion()
print("\n统计信息:", pool.get_stats())
# 关闭线程池
pool.shutdown()
print("\n测试完成!")
pool.initialize()
# 提交4个任务
for i in range(4):
pool.submit_task(test_task, test_callback, f"https://example.com/{i}", i + 1)
print("\n任务已提交,等待完成...")
pool.wait_for_completion()
print("\n统计信息:", pool.get_stats())
# 关闭线程池
pool.shutdown()
print("\n测试完成!")

View File

@@ -4,17 +4,10 @@
加密工具模块
用于加密存储敏感信息(如第三方账号密码)
使用Fernet对称加密
安全增强版本 - 2026-01-21
- 支持 ENCRYPTION_KEY_RAW 直接使用 Fernet 密钥
- 增加密钥丢失保护机制
- 增加启动时密钥验证
"""
import os
import sys
import base64
import threading
from pathlib import Path
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
@@ -28,37 +21,18 @@ ENCRYPTION_KEY_FILE = os.environ.get('ENCRYPTION_KEY_FILE', 'data/encryption_key
ENCRYPTION_SALT_FILE = os.environ.get('ENCRYPTION_SALT_FILE', 'data/encryption_salt.bin')
def _ensure_private_dir(path: Path) -> None:
if not path:
return
os.makedirs(path, mode=0o700, exist_ok=True)
try:
os.chmod(path, 0o700)
except Exception:
pass
def _ensure_private_file(path: Path) -> None:
try:
os.chmod(path, 0o600)
except Exception:
pass
def _get_or_create_salt():
"""获取或创建盐值"""
salt_path = Path(ENCRYPTION_SALT_FILE)
if salt_path.exists():
_ensure_private_file(salt_path)
with open(salt_path, 'rb') as f:
return f.read()
# 生成新的盐值
salt = os.urandom(16)
_ensure_private_dir(salt_path.parent)
os.makedirs(salt_path.parent, exist_ok=True)
with open(salt_path, 'wb') as f:
f.write(salt)
_ensure_private_file(salt_path)
return salt
@@ -73,103 +47,40 @@ def _derive_key(password: bytes, salt: bytes) -> bytes:
return base64.urlsafe_b64encode(kdf.derive(password))
def _check_existing_encrypted_data() -> bool:
"""
检查是否存在已加密的数据
用于防止在有加密数据的情况下意外生成新密钥
"""
try:
import sqlite3
db_path = os.environ.get('DB_FILE', 'data/app_data.db')
if not Path(db_path).exists():
return False
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM accounts WHERE password LIKE 'gAAAAA%'")
count = cursor.fetchone()[0]
conn.close()
return count > 0
except Exception as e:
logger.warning(f"检查加密数据时出错: {e}")
return False
def get_encryption_key():
"""
获取加密密钥
优先级:
1. ENCRYPTION_KEY_RAW - 直接使用 Fernet 密钥(推荐用于 Docker 部署)
2. ENCRYPTION_KEY - 通过 PBKDF2 派生密钥
3. 从文件读取
4. 生成新密钥(仅在无现有加密数据时)
"""
# 优先级 1: 直接使用 Fernet 密钥(推荐)
raw_key = os.environ.get('ENCRYPTION_KEY_RAW')
if raw_key:
logger.info("使用环境变量 ENCRYPTION_KEY_RAW 作为加密密钥")
return raw_key.encode() if isinstance(raw_key, str) else raw_key
# 优先级 2: 从环境变量派生密钥
"""获取加密密钥(优先环境变量,否则从文件读取或生成)"""
# 优先从环境变量读取
env_key = os.environ.get('ENCRYPTION_KEY')
if env_key:
logger.info("使用环境变量 ENCRYPTION_KEY 派生加密密钥")
# 使用环境变量中的密钥派生Fernet密钥
salt = _get_or_create_salt()
return _derive_key(env_key.encode(), salt)
# 优先级 3: 从文件读取
# 从文件读取
key_path = Path(ENCRYPTION_KEY_FILE)
if key_path.exists():
logger.info(f"从文件 {ENCRYPTION_KEY_FILE} 读取加密密钥")
_ensure_private_file(key_path)
with open(key_path, 'rb') as f:
return f.read()
# 优先级 4: 生成新密钥(带保护检查)
# 安全检查:如果已有加密数据,禁止生成新密钥
if _check_existing_encrypted_data():
error_msg = (
"\n" + "=" * 60 + "\n"
"[严重错误] 检测到数据库中存在已加密的密码数据,但加密密钥文件丢失!\n"
"\n"
"这将导致所有已加密的密码无法解密!\n"
"\n"
"解决方案:\n"
"1. 恢复 data/encryption_key.bin 文件(如有备份)\n"
"2. 或在 docker-compose.yml 中设置 ENCRYPTION_KEY_RAW 环境变量\n"
"3. 如果密钥确实丢失,需要重新录入所有账号密码\n"
"\n"
+ "=" * 60
)
logger.error(error_msg)
print(error_msg, file=sys.stderr)
raise RuntimeError("加密密钥丢失且存在已加密数据,请恢复密钥后再启动")
# 生成新的密钥
key = Fernet.generate_key()
_ensure_private_dir(key_path.parent)
os.makedirs(key_path.parent, exist_ok=True)
with open(key_path, 'wb') as f:
f.write(key)
_ensure_private_file(key_path)
logger.info(f"已生成新的加密密钥并保存到 {ENCRYPTION_KEY_FILE}")
logger.warning("请立即备份此密钥文件,并建议设置 ENCRYPTION_KEY_RAW 环境变量!")
return key
# 全局Fernet实例
_fernet = None
_fernet_lock = threading.Lock()
def _get_fernet():
"""获取Fernet加密器懒加载"""
global _fernet
if _fernet is None:
with _fernet_lock:
if _fernet is None:
key = get_encryption_key()
_fernet = Fernet(key)
key = get_encryption_key()
_fernet = Fernet(key)
return _fernet
@@ -209,10 +120,7 @@ def decrypt_password(encrypted_password: str) -> str:
decrypted = fernet.decrypt(encrypted_password.encode('utf-8'))
return decrypted.decode('utf-8')
except Exception as e:
# 解密失败,可能是旧的明文密码或密钥不匹配
if is_encrypted(encrypted_password):
logger.error(f"密码解密失败(密钥可能不匹配): {e}")
return ''
# 解密失败,可能是旧的明文密码
logger.warning(f"密码解密失败,可能是未加密的旧数据: {e}")
return encrypted_password
@@ -230,6 +138,7 @@ def is_encrypted(password: str) -> bool:
"""
if not password:
return False
# Fernet加密的数据是base64编码以'gAAAAA'开头
return password.startswith('gAAAAA')
@@ -248,39 +157,6 @@ def migrate_password(password: str) -> str:
return encrypt_password(password)
def verify_encryption_key() -> bool:
"""
验证当前密钥是否能解密现有数据
用于启动时检查
Returns:
bool: 密钥是否有效
"""
try:
import sqlite3
db_path = os.environ.get('DB_FILE', 'data/app_data.db')
if not Path(db_path).exists():
return True
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT password FROM accounts WHERE password LIKE 'gAAAAA%' LIMIT 1")
row = cursor.fetchone()
conn.close()
if not row:
return True
# 尝试解密
fernet = _get_fernet()
fernet.decrypt(row[0].encode('utf-8'))
logger.info("加密密钥验证成功")
return True
except Exception as e:
logger.error(f"加密密钥验证失败: {e}")
return False
if __name__ == '__main__':
# 测试加密解密
test_password = "test_password_123"
@@ -293,6 +169,3 @@ if __name__ == '__main__':
print(f"加密解密成功: {test_password == decrypted}")
print(f"是否已加密: {is_encrypted(encrypted)}")
print(f"明文是否加密: {is_encrypted(test_password)}")
# 验证密钥
print(f"\n密钥验证: {verify_encryption_key()}")

View File

@@ -19,16 +19,13 @@ from typing import Optional
import db_pool
from app_config import get_config
from app_logger import get_logger
from db.schema import ensure_schema
from db.migrations import migrate_database as _migrate_database
from db.admin import (
admin_reset_user_password,
clean_old_operation_logs,
get_admin_by_id,
ensure_default_admin,
get_admin_by_username,
get_hourly_registration_count,
get_system_config_raw as _get_system_config_raw,
get_system_stats,
@@ -74,15 +71,6 @@ from db.feedbacks import (
get_user_feedbacks,
reply_feedback,
)
from db.passkeys import (
count_passkeys,
create_passkey,
delete_passkey,
get_passkey_by_credential_id,
get_passkey_by_id,
list_passkeys,
update_passkey_usage,
)
from db.schedules import (
clean_old_schedule_logs,
create_schedule_execution_log,
@@ -109,7 +97,6 @@ from db.users import (
delete_user,
extend_user_vip,
get_all_users,
get_users_count,
get_pending_users,
get_user_by_id,
get_user_by_username,
@@ -128,13 +115,12 @@ from db.users import (
from db.security import record_login_context
config = get_config()
logger = get_logger(__name__)
# 数据库文件路径
DB_FILE = config.DB_FILE
# 数据库版本 (用于迁移管理)
DB_VERSION = 21
DB_VERSION = 17
# ==================== 系统配置缓存P1 / O-03 ====================
@@ -143,9 +129,9 @@ _system_config_cache_lock = threading.Lock()
_system_config_cache_value: Optional[dict] = None
_system_config_cache_loaded_at = 0.0
try:
_SYSTEM_CONFIG_CACHE_TTL_SECONDS = float(os.environ.get("SYSTEM_CONFIG_CACHE_TTL_SECONDS", "30"))
_SYSTEM_CONFIG_CACHE_TTL_SECONDS = float(os.environ.get("SYSTEM_CONFIG_CACHE_TTL_SECONDS", "3"))
except Exception:
_SYSTEM_CONFIG_CACHE_TTL_SECONDS = 30.0
_SYSTEM_CONFIG_CACHE_TTL_SECONDS = 3.0
_SYSTEM_CONFIG_CACHE_TTL_SECONDS = max(0.0, _SYSTEM_CONFIG_CACHE_TTL_SECONDS)
@@ -156,37 +142,6 @@ def invalidate_system_config_cache() -> None:
_system_config_cache_loaded_at = 0.0
def _normalize_system_config_value(value) -> dict:
try:
return dict(value or {})
except Exception:
return {}
def _is_system_config_cache_valid(now_ts: float) -> bool:
if _system_config_cache_value is None:
return False
if _SYSTEM_CONFIG_CACHE_TTL_SECONDS <= 0:
return True
return (now_ts - _system_config_cache_loaded_at) < _SYSTEM_CONFIG_CACHE_TTL_SECONDS
def _read_system_config_cache(now_ts: float, *, ignore_ttl: bool = False) -> Optional[dict]:
with _system_config_cache_lock:
if _system_config_cache_value is None:
return None
if (not ignore_ttl) and (not _is_system_config_cache_valid(now_ts)):
return None
return dict(_system_config_cache_value)
def _write_system_config_cache(value: dict, now_ts: float) -> None:
global _system_config_cache_value, _system_config_cache_loaded_at
with _system_config_cache_lock:
_system_config_cache_value = dict(value)
_system_config_cache_loaded_at = now_ts
def init_database():
"""初始化数据库表结构 + 迁移(入口统一)。"""
db_pool.init_pool(DB_FILE, pool_size=config.DB_POOL_SIZE)
@@ -197,12 +152,6 @@ def init_database():
ensure_default_admin()
try:
config_value = get_system_config()
db_pool.configure_slow_query_runtime(threshold_ms=config_value.get("db_slow_query_ms"))
except Exception as e:
logger.warning(f"初始化慢查询阈值失败,使用默认值: {e}")
def migrate_database():
"""数据库迁移(对外保留接口)。"""
@@ -216,21 +165,19 @@ def migrate_database():
def get_system_config():
"""获取系统配置(带进程内缓存)。"""
global _system_config_cache_value, _system_config_cache_loaded_at
now_ts = time.time()
with _system_config_cache_lock:
if _system_config_cache_value is not None:
if _SYSTEM_CONFIG_CACHE_TTL_SECONDS <= 0 or (now_ts - _system_config_cache_loaded_at) < _SYSTEM_CONFIG_CACHE_TTL_SECONDS:
return dict(_system_config_cache_value)
cached_value = _read_system_config_cache(now_ts)
if cached_value is not None:
return cached_value
value = _get_system_config_raw()
try:
value = _normalize_system_config_value(_get_system_config_raw())
except Exception:
fallback_value = _read_system_config_cache(now_ts, ignore_ttl=True)
if fallback_value is not None:
return fallback_value
raise
_write_system_config_cache(value, now_ts)
with _system_config_cache_lock:
_system_config_cache_value = dict(value)
_system_config_cache_loaded_at = now_ts
return dict(value)
@@ -260,7 +207,6 @@ def update_system_config(
kdocs_admin_notify_email=None,
kdocs_row_start=None,
kdocs_row_end=None,
db_slow_query_ms=None,
):
"""更新系统配置(写入后立即失效缓存)。"""
ok = _update_system_config(
@@ -289,13 +235,7 @@ def update_system_config(
kdocs_admin_notify_email=kdocs_admin_notify_email,
kdocs_row_start=kdocs_row_start,
kdocs_row_end=kdocs_row_end,
db_slow_query_ms=db_slow_query_ms,
)
if ok:
invalidate_system_config_cache()
try:
latest_config = get_system_config()
db_pool.configure_slow_query_runtime(threshold_ms=latest_config.get("db_slow_query_ms"))
except Exception as e:
logger.warning(f"更新慢查询阈值失败,保留当前配置: {e}")
return ok

View File

@@ -6,51 +6,19 @@ import db_pool
from crypto_utils import decrypt_password, encrypt_password
from db.utils import get_cst_now_str
_ACCOUNT_STATUS_QUERY_SQL = """
SELECT status, login_fail_count, last_login_error
FROM accounts
WHERE id = ?
"""
def _decode_account_password(account_dict: dict) -> dict:
account_dict["password"] = decrypt_password(account_dict.get("password", ""))
return account_dict
def _normalize_account_ids(account_ids) -> list[str]:
normalized = []
seen = set()
for account_id in account_ids or []:
if not account_id:
continue
account_key = str(account_id)
if account_key in seen:
continue
seen.add(account_key)
normalized.append(account_key)
return normalized
def create_account(user_id, account_id, username, password, remember=True, remark=""):
"""创建账号(密码加密存储)"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cst_time = get_cst_now_str()
encrypted_password = encrypt_password(password)
cursor.execute(
"""
INSERT INTO accounts (id, user_id, username, password, remember, remark, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
account_id,
user_id,
username,
encrypted_password,
1 if remember else 0,
remark,
get_cst_now_str(),
),
(account_id, user_id, username, encrypted_password, 1 if remember else 0, remark, cst_time),
)
conn.commit()
return cursor.lastrowid
@@ -61,7 +29,12 @@ def get_user_accounts(user_id):
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM accounts WHERE user_id = ? ORDER BY created_at DESC", (user_id,))
return [_decode_account_password(dict(row)) for row in cursor.fetchall()]
accounts = []
for row in cursor.fetchall():
account = dict(row)
account["password"] = decrypt_password(account.get("password", ""))
accounts.append(account)
return accounts
def get_account(account_id):
@@ -70,9 +43,11 @@ def get_account(account_id):
cursor = conn.cursor()
cursor.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
row = cursor.fetchone()
if not row:
return None
return _decode_account_password(dict(row))
if row:
account = dict(row)
account["password"] = decrypt_password(account.get("password", ""))
return account
return None
def update_account_remark(account_id, remark):
@@ -103,21 +78,33 @@ def increment_account_login_fail(account_id, error_message):
if not row:
return False
fail_count = int(row["login_fail_count"] or 0) + 1
is_suspended = fail_count >= 3
fail_count = (row["login_fail_count"] or 0) + 1
if fail_count >= 3:
cursor.execute(
"""
UPDATE accounts
SET login_fail_count = ?,
last_login_error = ?,
status = 'suspended'
WHERE id = ?
""",
(fail_count, error_message, account_id),
)
conn.commit()
return True
cursor.execute(
"""
UPDATE accounts
SET login_fail_count = ?,
last_login_error = ?,
status = CASE WHEN ? = 1 THEN 'suspended' ELSE status END
last_login_error = ?
WHERE id = ?
""",
(fail_count, error_message, 1 if is_suspended else 0, account_id),
(fail_count, error_message, account_id),
)
conn.commit()
return is_suspended
return False
def reset_account_login_status(account_id):
@@ -142,22 +129,29 @@ def get_account_status(account_id):
"""获取账号状态信息"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(_ACCOUNT_STATUS_QUERY_SQL, (account_id,))
cursor.execute(
"""
SELECT status, login_fail_count, last_login_error
FROM accounts
WHERE id = ?
""",
(account_id,),
)
return cursor.fetchone()
def get_account_status_batch(account_ids):
"""批量获取账号状态信息"""
normalized_ids = _normalize_account_ids(account_ids)
if not normalized_ids:
account_ids = [str(account_id) for account_id in (account_ids or []) if account_id]
if not account_ids:
return {}
results = {}
chunk_size = 900 # 避免触发 SQLite 绑定参数上限
with db_pool.get_db() as conn:
cursor = conn.cursor()
for idx in range(0, len(normalized_ids), chunk_size):
chunk = normalized_ids[idx : idx + chunk_size]
for idx in range(0, len(account_ids), chunk_size):
chunk = account_ids[idx : idx + chunk_size]
placeholders = ",".join("?" for _ in chunk)
cursor.execute(
f"""

View File

@@ -2,9 +2,10 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import os
import sqlite3
from pathlib import Path
from datetime import datetime, timedelta
import pytz
import db_pool
from db.utils import get_cst_now_str
@@ -15,123 +16,6 @@ from password_utils import (
verify_password_sha256,
)
_DEFAULT_SYSTEM_CONFIG = {
"max_concurrent_global": 2,
"max_concurrent_per_account": 1,
"max_screenshot_concurrent": 3,
"db_slow_query_ms": 120,
"schedule_enabled": 0,
"schedule_time": "02:00",
"schedule_browse_type": "应读",
"schedule_weekdays": "1,2,3,4,5,6,7",
"proxy_enabled": 0,
"proxy_api_url": "",
"proxy_expire_minutes": 3,
"enable_screenshot": 1,
"auto_approve_enabled": 0,
"auto_approve_hourly_limit": 10,
"auto_approve_vip_days": 7,
"kdocs_enabled": 0,
"kdocs_doc_url": "",
"kdocs_default_unit": "",
"kdocs_sheet_name": "",
"kdocs_sheet_index": 0,
"kdocs_unit_column": "A",
"kdocs_image_column": "D",
"kdocs_admin_notify_enabled": 0,
"kdocs_admin_notify_email": "",
"kdocs_row_start": 0,
"kdocs_row_end": 0,
}
_SYSTEM_CONFIG_UPDATERS = (
("max_concurrent_global", "max_concurrent"),
("schedule_enabled", "schedule_enabled"),
("schedule_time", "schedule_time"),
("schedule_browse_type", "schedule_browse_type"),
("schedule_weekdays", "schedule_weekdays"),
("max_concurrent_per_account", "max_concurrent_per_account"),
("max_screenshot_concurrent", "max_screenshot_concurrent"),
("db_slow_query_ms", "db_slow_query_ms"),
("enable_screenshot", "enable_screenshot"),
("proxy_enabled", "proxy_enabled"),
("proxy_api_url", "proxy_api_url"),
("proxy_expire_minutes", "proxy_expire_minutes"),
("auto_approve_enabled", "auto_approve_enabled"),
("auto_approve_hourly_limit", "auto_approve_hourly_limit"),
("auto_approve_vip_days", "auto_approve_vip_days"),
("kdocs_enabled", "kdocs_enabled"),
("kdocs_doc_url", "kdocs_doc_url"),
("kdocs_default_unit", "kdocs_default_unit"),
("kdocs_sheet_name", "kdocs_sheet_name"),
("kdocs_sheet_index", "kdocs_sheet_index"),
("kdocs_unit_column", "kdocs_unit_column"),
("kdocs_image_column", "kdocs_image_column"),
("kdocs_admin_notify_enabled", "kdocs_admin_notify_enabled"),
("kdocs_admin_notify_email", "kdocs_admin_notify_email"),
("kdocs_row_start", "kdocs_row_start"),
("kdocs_row_end", "kdocs_row_end"),
)
def _count_scalar(cursor, sql: str, params=()) -> int:
cursor.execute(sql, params)
row = cursor.fetchone()
if not row:
return 0
try:
if "count" in row.keys():
return int(row["count"] or 0)
except Exception:
pass
try:
return int(row[0] or 0)
except Exception:
return 0
def _table_exists(cursor, table_name: str) -> bool:
cursor.execute(
"""
SELECT name FROM sqlite_master
WHERE type='table' AND name=?
""",
(table_name,),
)
return bool(cursor.fetchone())
def _normalize_days(days, default: int = 30) -> int:
try:
value = int(days)
except Exception:
value = default
if value < 0:
return 0
return value
def _store_default_admin_credentials(username: str, password: str) -> str | None:
"""将首次管理员账号密码写入受限权限文件,避免打印到日志。"""
raw_path = str(
os.environ.get("DEFAULT_ADMIN_CREDENTIALS_FILE", "data/default_admin_credentials.txt") or ""
).strip()
if not raw_path:
return None
cred_path = Path(raw_path)
try:
cred_path.parent.mkdir(parents=True, exist_ok=True)
with open(cred_path, "w", encoding="utf-8") as f:
f.write("安全提醒:首次管理员账号已创建\n")
f.write(f"用户名: {username}\n")
f.write(f"密码: {password}\n")
f.write("请登录后立即修改密码,并删除该文件。\n")
os.chmod(cred_path, 0o600)
return str(cred_path)
except Exception:
return None
def ensure_default_admin() -> bool:
"""确保存在默认管理员账号(行为保持不变)。"""
@@ -140,12 +24,12 @@ def ensure_default_admin() -> bool:
with db_pool.get_db() as conn:
cursor = conn.cursor()
count = _count_scalar(cursor, "SELECT COUNT(*) as count FROM admins")
cursor.execute("SELECT COUNT(*) as count FROM admins")
result = cursor.fetchone()
if count == 0:
if result["count"] == 0:
alphabet = string.ascii_letters + string.digits
bootstrap_password = str(os.environ.get("DEFAULT_ADMIN_PASSWORD", "") or "").strip()
random_password = bootstrap_password or "".join(secrets.choice(alphabet) for _ in range(12))
random_password = "".join(secrets.choice(alphabet) for _ in range(12))
default_password_hash = hash_password_bcrypt(random_password)
cursor.execute(
@@ -153,16 +37,11 @@ def ensure_default_admin() -> bool:
("admin", default_password_hash, get_cst_now_str()),
)
conn.commit()
credential_file = _store_default_admin_credentials("admin", random_password)
print("=" * 60)
print("安全提醒:已创建默认管理员账号")
print("用户名: admin")
if credential_file:
print(f"初始密码已写入: {credential_file}权限600")
print("请立即登录后修改密码,并删除该文件。")
else:
print("未能写入初始密码文件。")
print("建议设置 DEFAULT_ADMIN_PASSWORD 后重建管理员账号。")
print(f"密码: {random_password}")
print("请立即登录后修改密码!")
print("=" * 60)
return True
return False
@@ -195,24 +74,6 @@ def verify_admin(username: str, password: str):
return None
def get_admin_by_username(username: str):
"""根据用户名获取管理员记录"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM admins WHERE username = ?", (username,))
row = cursor.fetchone()
return dict(row) if row else None
def get_admin_by_id(admin_id: int):
"""根据ID获取管理员记录"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM admins WHERE id = ?", (int(admin_id),))
row = cursor.fetchone()
return dict(row) if row else None
def update_admin_password(username: str, new_password: str) -> bool:
"""更新管理员密码"""
with db_pool.get_db() as conn:
@@ -240,40 +101,49 @@ def get_system_stats() -> dict:
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) as count FROM users")
total_users = cursor.fetchone()["count"]
cursor.execute("SELECT COUNT(*) as count FROM users WHERE status = 'approved'")
approved_users = cursor.fetchone()["count"]
cursor.execute(
"""
SELECT
COUNT(*) AS total_users,
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) AS approved_users,
SUM(CASE WHEN date(created_at) = date('now', 'localtime') THEN 1 ELSE 0 END) AS new_users_today,
SUM(CASE WHEN datetime(created_at) >= datetime('now', 'localtime', '-7 days') THEN 1 ELSE 0 END) AS new_users_7d,
SUM(
CASE
WHEN vip_expire_time IS NOT NULL
AND datetime(vip_expire_time) > datetime('now', 'localtime')
THEN 1 ELSE 0
END
) AS vip_users
SELECT COUNT(*) as count
FROM users
WHERE date(created_at) = date('now', 'localtime')
"""
)
user_stats = cursor.fetchone() or {}
new_users_today = cursor.fetchone()["count"]
def _to_int(key: str) -> int:
try:
return int(user_stats[key] or 0)
except Exception:
return 0
cursor.execute(
"""
SELECT COUNT(*) as count
FROM users
WHERE datetime(created_at) >= datetime('now', 'localtime', '-7 days')
"""
)
new_users_7d = cursor.fetchone()["count"]
total_accounts = _count_scalar(cursor, "SELECT COUNT(*) as count FROM accounts")
cursor.execute("SELECT COUNT(*) as count FROM accounts")
total_accounts = cursor.fetchone()["count"]
cursor.execute(
"""
SELECT COUNT(*) as count FROM users
WHERE vip_expire_time IS NOT NULL
AND datetime(vip_expire_time) > datetime('now', 'localtime')
"""
)
vip_users = cursor.fetchone()["count"]
return {
"total_users": _to_int("total_users"),
"approved_users": _to_int("approved_users"),
"new_users_today": _to_int("new_users_today"),
"new_users_7d": _to_int("new_users_7d"),
"total_users": total_users,
"approved_users": approved_users,
"new_users_today": new_users_today,
"new_users_7d": new_users_7d,
"total_accounts": total_accounts,
"vip_users": _to_int("vip_users"),
"vip_users": vip_users,
}
@@ -283,9 +153,37 @@ def get_system_config_raw() -> dict:
cursor = conn.cursor()
cursor.execute("SELECT * FROM system_config WHERE id = 1")
row = cursor.fetchone()
if row:
return dict(row)
return dict(_DEFAULT_SYSTEM_CONFIG)
return {
"max_concurrent_global": 2,
"max_concurrent_per_account": 1,
"max_screenshot_concurrent": 3,
"schedule_enabled": 0,
"schedule_time": "02:00",
"schedule_browse_type": "应读",
"schedule_weekdays": "1,2,3,4,5,6,7",
"proxy_enabled": 0,
"proxy_api_url": "",
"proxy_expire_minutes": 3,
"enable_screenshot": 1,
"auto_approve_enabled": 0,
"auto_approve_hourly_limit": 10,
"auto_approve_vip_days": 7,
"kdocs_enabled": 0,
"kdocs_doc_url": "",
"kdocs_default_unit": "",
"kdocs_sheet_name": "",
"kdocs_sheet_index": 0,
"kdocs_unit_column": "A",
"kdocs_image_column": "D",
"kdocs_admin_notify_enabled": 0,
"kdocs_admin_notify_email": "",
"kdocs_row_start": 0,
"kdocs_row_end": 0,
}
def update_system_config(
@@ -315,55 +213,129 @@ def update_system_config(
kdocs_admin_notify_email=None,
kdocs_row_start=None,
kdocs_row_end=None,
db_slow_query_ms=None,
) -> bool:
"""更新系统配置仅更新DB不做缓存处理"""
arg_values = {
"max_concurrent": max_concurrent,
"schedule_enabled": schedule_enabled,
"schedule_time": schedule_time,
"schedule_browse_type": schedule_browse_type,
"schedule_weekdays": schedule_weekdays,
"max_concurrent_per_account": max_concurrent_per_account,
"max_screenshot_concurrent": max_screenshot_concurrent,
"enable_screenshot": enable_screenshot,
"proxy_enabled": proxy_enabled,
"proxy_api_url": proxy_api_url,
"proxy_expire_minutes": proxy_expire_minutes,
"auto_approve_enabled": auto_approve_enabled,
"auto_approve_hourly_limit": auto_approve_hourly_limit,
"auto_approve_vip_days": auto_approve_vip_days,
"kdocs_enabled": kdocs_enabled,
"kdocs_doc_url": kdocs_doc_url,
"kdocs_default_unit": kdocs_default_unit,
"kdocs_sheet_name": kdocs_sheet_name,
"kdocs_sheet_index": kdocs_sheet_index,
"kdocs_unit_column": kdocs_unit_column,
"kdocs_image_column": kdocs_image_column,
"kdocs_admin_notify_enabled": kdocs_admin_notify_enabled,
"kdocs_admin_notify_email": kdocs_admin_notify_email,
"kdocs_row_start": kdocs_row_start,
"kdocs_row_end": kdocs_row_end,
"db_slow_query_ms": db_slow_query_ms,
allowed_fields = {
"max_concurrent_global",
"schedule_enabled",
"schedule_time",
"schedule_browse_type",
"schedule_weekdays",
"max_concurrent_per_account",
"max_screenshot_concurrent",
"enable_screenshot",
"proxy_enabled",
"proxy_api_url",
"proxy_expire_minutes",
"auto_approve_enabled",
"auto_approve_hourly_limit",
"auto_approve_vip_days",
"kdocs_enabled",
"kdocs_doc_url",
"kdocs_default_unit",
"kdocs_sheet_name",
"kdocs_sheet_index",
"kdocs_unit_column",
"kdocs_image_column",
"kdocs_admin_notify_enabled",
"kdocs_admin_notify_email",
"kdocs_row_start",
"kdocs_row_end",
"updated_at",
}
updates = []
params = []
for db_field, arg_name in _SYSTEM_CONFIG_UPDATERS:
value = arg_values.get(arg_name)
if value is None:
continue
updates.append(f"{db_field} = ?")
params.append(value)
if not updates:
return False
updates.append("updated_at = ?")
params.append(get_cst_now_str())
with db_pool.get_db() as conn:
cursor = conn.cursor()
updates = []
params = []
if max_concurrent is not None:
updates.append("max_concurrent_global = ?")
params.append(max_concurrent)
if schedule_enabled is not None:
updates.append("schedule_enabled = ?")
params.append(schedule_enabled)
if schedule_time is not None:
updates.append("schedule_time = ?")
params.append(schedule_time)
if schedule_browse_type is not None:
updates.append("schedule_browse_type = ?")
params.append(schedule_browse_type)
if max_concurrent_per_account is not None:
updates.append("max_concurrent_per_account = ?")
params.append(max_concurrent_per_account)
if max_screenshot_concurrent is not None:
updates.append("max_screenshot_concurrent = ?")
params.append(max_screenshot_concurrent)
if enable_screenshot is not None:
updates.append("enable_screenshot = ?")
params.append(enable_screenshot)
if schedule_weekdays is not None:
updates.append("schedule_weekdays = ?")
params.append(schedule_weekdays)
if proxy_enabled is not None:
updates.append("proxy_enabled = ?")
params.append(proxy_enabled)
if proxy_api_url is not None:
updates.append("proxy_api_url = ?")
params.append(proxy_api_url)
if proxy_expire_minutes is not None:
updates.append("proxy_expire_minutes = ?")
params.append(proxy_expire_minutes)
if auto_approve_enabled is not None:
updates.append("auto_approve_enabled = ?")
params.append(auto_approve_enabled)
if auto_approve_hourly_limit is not None:
updates.append("auto_approve_hourly_limit = ?")
params.append(auto_approve_hourly_limit)
if auto_approve_vip_days is not None:
updates.append("auto_approve_vip_days = ?")
params.append(auto_approve_vip_days)
if kdocs_enabled is not None:
updates.append("kdocs_enabled = ?")
params.append(kdocs_enabled)
if kdocs_doc_url is not None:
updates.append("kdocs_doc_url = ?")
params.append(kdocs_doc_url)
if kdocs_default_unit is not None:
updates.append("kdocs_default_unit = ?")
params.append(kdocs_default_unit)
if kdocs_sheet_name is not None:
updates.append("kdocs_sheet_name = ?")
params.append(kdocs_sheet_name)
if kdocs_sheet_index is not None:
updates.append("kdocs_sheet_index = ?")
params.append(kdocs_sheet_index)
if kdocs_unit_column is not None:
updates.append("kdocs_unit_column = ?")
params.append(kdocs_unit_column)
if kdocs_image_column is not None:
updates.append("kdocs_image_column = ?")
params.append(kdocs_image_column)
if kdocs_admin_notify_enabled is not None:
updates.append("kdocs_admin_notify_enabled = ?")
params.append(kdocs_admin_notify_enabled)
if kdocs_admin_notify_email is not None:
updates.append("kdocs_admin_notify_email = ?")
params.append(kdocs_admin_notify_email)
if kdocs_row_start is not None:
updates.append("kdocs_row_start = ?")
params.append(kdocs_row_start)
if kdocs_row_end is not None:
updates.append("kdocs_row_end = ?")
params.append(kdocs_row_end)
if not updates:
return False
updates.append("updated_at = ?")
params.append(get_cst_now_str())
for update_clause in updates:
field_name = update_clause.split("=")[0].strip()
if field_name not in allowed_fields:
raise ValueError(f"非法字段名: {field_name}")
sql = f"UPDATE system_config SET {', '.join(updates)} WHERE id = 1"
cursor.execute(sql, params)
conn.commit()
@@ -374,13 +346,13 @@ def get_hourly_registration_count() -> int:
"""获取最近一小时内的注册用户数"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
return _count_scalar(
cursor,
cursor.execute(
"""
SELECT COUNT(*) as count FROM users
SELECT COUNT(*) FROM users
WHERE created_at >= datetime('now', 'localtime', '-1 hour')
""",
"""
)
return cursor.fetchone()[0]
# ==================== 密码重置(管理员) ====================
@@ -402,12 +374,17 @@ def admin_reset_user_password(user_id: int, new_password: str) -> bool:
def clean_old_operation_logs(days: int = 30) -> int:
"""清理指定天数前的操作日志如果存在operation_logs表"""
safe_days = _normalize_days(days, default=30)
with db_pool.get_db() as conn:
cursor = conn.cursor()
if not _table_exists(cursor, "operation_logs"):
cursor.execute(
"""
SELECT name FROM sqlite_master
WHERE type='table' AND name='operation_logs'
"""
)
if not cursor.fetchone():
return 0
try:
@@ -416,11 +393,11 @@ def clean_old_operation_logs(days: int = 30) -> int:
DELETE FROM operation_logs
WHERE created_at < datetime('now', 'localtime', '-' || ? || ' days')
""",
(safe_days,),
(days,),
)
deleted_count = cursor.rowcount
conn.commit()
print(f"已清理 {deleted_count} 条旧操作日志 (>{safe_days}天)")
print(f"已清理 {deleted_count} 条旧操作日志 (>{days}天)")
return deleted_count
except Exception as e:
print(f"清理旧操作日志失败: {e}")

View File

@@ -6,38 +6,12 @@ import db_pool
from db.utils import get_cst_now_str
def _normalize_limit(value, default: int, *, minimum: int = 1, maximum: int = 500) -> int:
try:
parsed = int(value)
except Exception:
parsed = default
parsed = max(minimum, parsed)
parsed = min(maximum, parsed)
return parsed
def _normalize_offset(value, default: int = 0) -> int:
try:
parsed = int(value)
except Exception:
parsed = default
return max(0, parsed)
def _normalize_announcement_payload(title, content, image_url):
normalized_title = str(title or "").strip()
normalized_content = str(content or "").strip()
normalized_image = str(image_url or "").strip() or None
return normalized_title, normalized_content, normalized_image
def _deactivate_all_active_announcements(cursor, cst_time: str) -> None:
cursor.execute("UPDATE announcements SET is_active = 0, updated_at = ? WHERE is_active = 1", (cst_time,))
def create_announcement(title, content, image_url=None, is_active=True):
"""创建公告(默认启用;启用时会自动停用其他公告)"""
title, content, image_url = _normalize_announcement_payload(title, content, image_url)
title = (title or "").strip()
content = (content or "").strip()
image_url = (image_url or "").strip()
image_url = image_url or None
if not title or not content:
return None
@@ -46,7 +20,7 @@ def create_announcement(title, content, image_url=None, is_active=True):
cst_time = get_cst_now_str()
if is_active:
_deactivate_all_active_announcements(cursor, cst_time)
cursor.execute("UPDATE announcements SET is_active = 0, updated_at = ? WHERE is_active = 1", (cst_time,))
cursor.execute(
"""
@@ -70,9 +44,6 @@ def get_announcement_by_id(announcement_id):
def get_announcements(limit=50, offset=0):
"""获取公告列表(管理员用)"""
safe_limit = _normalize_limit(limit, 50)
safe_offset = _normalize_offset(offset, 0)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
@@ -81,7 +52,7 @@ def get_announcements(limit=50, offset=0):
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?
""",
(safe_limit, safe_offset),
(limit, offset),
)
return [dict(row) for row in cursor.fetchall()]
@@ -93,7 +64,7 @@ def set_announcement_active(announcement_id, is_active):
cst_time = get_cst_now_str()
if is_active:
_deactivate_all_active_announcements(cursor, cst_time)
cursor.execute("UPDATE announcements SET is_active = 0, updated_at = ? WHERE is_active = 1", (cst_time,))
cursor.execute(
"""
UPDATE announcements
@@ -150,12 +121,13 @@ def dismiss_announcement_for_user(user_id, announcement_id):
"""用户永久关闭某条公告(幂等)"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cst_time = get_cst_now_str()
cursor.execute(
"""
INSERT OR IGNORE INTO announcement_dismissals (user_id, announcement_id, dismissed_at)
VALUES (?, ?, ?)
""",
(user_id, announcement_id, get_cst_now_str()),
(user_id, announcement_id, cst_time),
)
conn.commit()
return cursor.rowcount >= 0

View File

@@ -5,27 +5,6 @@ from __future__ import annotations
import db_pool
def _to_bool_with_default(value, default: bool = True) -> bool:
if value is None:
return default
try:
return bool(int(value))
except Exception:
try:
return bool(value)
except Exception:
return default
def _normalize_notify_enabled(enabled) -> int:
if isinstance(enabled, bool):
return 1 if enabled else 0
try:
return 1 if int(enabled) else 0
except Exception:
return 1
def get_user_by_email(email):
"""根据邮箱获取用户"""
with db_pool.get_db() as conn:
@@ -46,7 +25,7 @@ def update_user_email(user_id, email, verified=False):
SET email = ?, email_verified = ?
WHERE id = ?
""",
(email, 1 if verified else 0, user_id),
(email, int(verified), user_id),
)
conn.commit()
return cursor.rowcount > 0
@@ -63,7 +42,7 @@ def update_user_email_notify(user_id, enabled):
SET email_notify_enabled = ?
WHERE id = ?
""",
(_normalize_notify_enabled(enabled), user_id),
(int(enabled), user_id),
)
conn.commit()
return cursor.rowcount > 0
@@ -78,6 +57,6 @@ def get_user_email_notify(user_id):
row = cursor.fetchone()
if row is None:
return True
return _to_bool_with_default(row[0], default=True)
return bool(row[0]) if row[0] is not None else True
except Exception:
return True

View File

@@ -2,73 +2,32 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from datetime import datetime
import pytz
import db_pool
from db.utils import escape_html, get_cst_now_str
def _normalize_limit(value, default: int, *, minimum: int = 1, maximum: int = 500) -> int:
try:
parsed = int(value)
except Exception:
parsed = default
parsed = max(minimum, parsed)
parsed = min(maximum, parsed)
return parsed
def _normalize_offset(value, default: int = 0) -> int:
try:
parsed = int(value)
except Exception:
parsed = default
return max(0, parsed)
def _safe_text(value) -> str:
if value is None:
return ""
text = str(value)
return escape_html(text) if text else ""
def _build_feedback_filter_sql(status_filter=None) -> tuple[str, list]:
where_clauses = ["1=1"]
params = []
if status_filter:
where_clauses.append("status = ?")
params.append(status_filter)
return " AND ".join(where_clauses), params
def _normalize_feedback_stats_row(row) -> dict:
row_dict = dict(row) if row else {}
return {
"total": int(row_dict.get("total") or 0),
"pending": int(row_dict.get("pending") or 0),
"replied": int(row_dict.get("replied") or 0),
"closed": int(row_dict.get("closed") or 0),
}
from db.utils import escape_html
def create_bug_feedback(user_id, username, title, description, contact=""):
"""创建Bug反馈带XSS防护"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cst_tz = pytz.timezone("Asia/Shanghai")
cst_time = datetime.now(cst_tz).strftime("%Y-%m-%d %H:%M:%S")
safe_title = escape_html(title) if title else ""
safe_description = escape_html(description) if description else ""
safe_contact = escape_html(contact) if contact else ""
safe_username = escape_html(username) if username else ""
cursor.execute(
"""
INSERT INTO bug_feedbacks (user_id, username, title, description, contact, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
user_id,
_safe_text(username),
_safe_text(title),
_safe_text(description),
_safe_text(contact),
get_cst_now_str(),
),
(user_id, safe_username, safe_title, safe_description, safe_contact, cst_time),
)
conn.commit()
@@ -77,25 +36,25 @@ def create_bug_feedback(user_id, username, title, description, contact=""):
def get_bug_feedbacks(limit=100, offset=0, status_filter=None):
"""获取Bug反馈列表管理员用"""
safe_limit = _normalize_limit(limit, 100, minimum=1, maximum=1000)
safe_offset = _normalize_offset(offset, 0)
with db_pool.get_db() as conn:
cursor = conn.cursor()
where_sql, params = _build_feedback_filter_sql(status_filter=status_filter)
sql = f"""
SELECT * FROM bug_feedbacks
WHERE {where_sql}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
"""
cursor.execute(sql, params + [safe_limit, safe_offset])
sql = "SELECT * FROM bug_feedbacks WHERE 1=1"
params = []
if status_filter:
sql += " AND status = ?"
params.append(status_filter)
sql += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor.execute(sql, params)
return [dict(row) for row in cursor.fetchall()]
def get_user_feedbacks(user_id, limit=50):
"""获取用户自己的反馈列表"""
safe_limit = _normalize_limit(limit, 50, minimum=1, maximum=1000)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
@@ -105,7 +64,7 @@ def get_user_feedbacks(user_id, limit=50):
ORDER BY created_at DESC
LIMIT ?
""",
(user_id, safe_limit),
(user_id, limit),
)
return [dict(row) for row in cursor.fetchall()]
@@ -123,13 +82,18 @@ def reply_feedback(feedback_id, admin_reply):
"""管理员回复反馈带XSS防护"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cst_tz = pytz.timezone("Asia/Shanghai")
cst_time = datetime.now(cst_tz).strftime("%Y-%m-%d %H:%M:%S")
safe_reply = escape_html(admin_reply) if admin_reply else ""
cursor.execute(
"""
UPDATE bug_feedbacks
SET admin_reply = ?, status = 'replied', replied_at = ?
WHERE id = ?
""",
(_safe_text(admin_reply), get_cst_now_str(), feedback_id),
(safe_reply, cst_time, feedback_id),
)
conn.commit()
@@ -175,4 +139,6 @@ def get_feedback_stats():
FROM bug_feedbacks
"""
)
return _normalize_feedback_stats_row(cursor.fetchone())
row = cursor.fetchone()
return dict(row) if row else {"total": 0, "pending": 0, "replied": 0, "closed": 0}

View File

@@ -28,143 +28,105 @@ def set_current_version(conn, version: int) -> None:
conn.commit()
def _table_exists(cursor, table_name: str) -> bool:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (str(table_name),))
return cursor.fetchone() is not None
def _get_table_columns(cursor, table_name: str) -> set[str]:
cursor.execute(f"PRAGMA table_info({table_name})")
return {col[1] for col in cursor.fetchall()}
def _add_column_if_missing(cursor, table_name: str, columns: set[str], column_name: str, column_ddl: str, *, ok_message: str) -> bool:
if column_name in columns:
return False
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_ddl}")
columns.add(column_name)
print(ok_message)
return True
def _read_row_value(row, key: str, index: int):
if isinstance(row, sqlite3.Row):
return row[key]
return row[index]
def _get_migration_steps():
return [
(1, _migrate_to_v1),
(2, _migrate_to_v2),
(3, _migrate_to_v3),
(4, _migrate_to_v4),
(5, _migrate_to_v5),
(6, _migrate_to_v6),
(7, _migrate_to_v7),
(8, _migrate_to_v8),
(9, _migrate_to_v9),
(10, _migrate_to_v10),
(11, _migrate_to_v11),
(12, _migrate_to_v12),
(13, _migrate_to_v13),
(14, _migrate_to_v14),
(15, _migrate_to_v15),
(16, _migrate_to_v16),
(17, _migrate_to_v17),
(18, _migrate_to_v18),
(19, _migrate_to_v19),
(20, _migrate_to_v20),
(21, _migrate_to_v21),
]
def migrate_database(conn, target_version: int) -> None:
"""数据库迁移:按版本增量升级(向前兼容)。"""
cursor = conn.cursor()
cursor.execute("INSERT OR IGNORE INTO db_version (id, version, updated_at) VALUES (1, 0, ?)", (get_cst_now_str(),))
conn.commit()
target_version = int(target_version)
current_version = get_current_version(conn)
for version, migrate_fn in _get_migration_steps():
if version > target_version or current_version >= version:
continue
migrate_fn(conn)
current_version = version
if current_version < 1:
_migrate_to_v1(conn)
current_version = 1
if current_version < 2:
_migrate_to_v2(conn)
current_version = 2
if current_version < 3:
_migrate_to_v3(conn)
current_version = 3
if current_version < 4:
_migrate_to_v4(conn)
current_version = 4
if current_version < 5:
_migrate_to_v5(conn)
current_version = 5
if current_version < 6:
_migrate_to_v6(conn)
current_version = 6
if current_version < 7:
_migrate_to_v7(conn)
current_version = 7
if current_version < 8:
_migrate_to_v8(conn)
current_version = 8
if current_version < 9:
_migrate_to_v9(conn)
current_version = 9
if current_version < 10:
_migrate_to_v10(conn)
current_version = 10
if current_version < 11:
_migrate_to_v11(conn)
current_version = 11
if current_version < 12:
_migrate_to_v12(conn)
current_version = 12
if current_version < 13:
_migrate_to_v13(conn)
current_version = 13
if current_version < 14:
_migrate_to_v14(conn)
current_version = 14
if current_version < 15:
_migrate_to_v15(conn)
current_version = 15
if current_version < 16:
_migrate_to_v16(conn)
current_version = 16
if current_version < 17:
_migrate_to_v17(conn)
current_version = 17
if current_version < 18:
_migrate_to_v18(conn)
current_version = 18
stored_version = get_current_version(conn)
if stored_version != current_version:
set_current_version(conn, current_version)
if current_version != target_version:
print(f" [WARN] 目标版本 {target_version} 未完全可达,当前停留在 {current_version}")
if current_version != int(target_version):
set_current_version(conn, int(target_version))
def _migrate_to_v1(conn):
"""迁移到版本1 - 添加缺失字段"""
cursor = conn.cursor()
system_columns = _get_table_columns(cursor, "system_config")
_add_column_if_missing(
cursor,
"system_config",
system_columns,
"schedule_weekdays",
'TEXT DEFAULT "1,2,3,4,5,6,7"',
ok_message=" [OK] 添加 schedule_weekdays 字段",
)
_add_column_if_missing(
cursor,
"system_config",
system_columns,
"max_screenshot_concurrent",
"INTEGER DEFAULT 3",
ok_message=" [OK] 添加 max_screenshot_concurrent 字段",
)
_add_column_if_missing(
cursor,
"system_config",
system_columns,
"max_concurrent_per_account",
"INTEGER DEFAULT 1",
ok_message=" [OK] 添加 max_concurrent_per_account 字段",
)
_add_column_if_missing(
cursor,
"system_config",
system_columns,
"auto_approve_enabled",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 auto_approve_enabled 字段",
)
_add_column_if_missing(
cursor,
"system_config",
system_columns,
"auto_approve_hourly_limit",
"INTEGER DEFAULT 10",
ok_message=" [OK] 添加 auto_approve_hourly_limit 字段",
)
_add_column_if_missing(
cursor,
"system_config",
system_columns,
"auto_approve_vip_days",
"INTEGER DEFAULT 7",
ok_message=" [OK] 添加 auto_approve_vip_days 字段",
)
cursor.execute("PRAGMA table_info(system_config)")
columns = [col[1] for col in cursor.fetchall()]
task_log_columns = _get_table_columns(cursor, "task_logs")
_add_column_if_missing(
cursor,
"task_logs",
task_log_columns,
"duration",
"INTEGER",
ok_message=" [OK] 添加 duration 字段到 task_logs",
)
if "schedule_weekdays" not in columns:
cursor.execute('ALTER TABLE system_config ADD COLUMN schedule_weekdays TEXT DEFAULT "1,2,3,4,5,6,7"')
print(" ✓ 添加 schedule_weekdays 字段")
if "max_screenshot_concurrent" not in columns:
cursor.execute("ALTER TABLE system_config ADD COLUMN max_screenshot_concurrent INTEGER DEFAULT 3")
print(" ✓ 添加 max_screenshot_concurrent 字段")
if "max_concurrent_per_account" not in columns:
cursor.execute("ALTER TABLE system_config ADD COLUMN max_concurrent_per_account INTEGER DEFAULT 1")
print(" ✓ 添加 max_concurrent_per_account 字段")
if "auto_approve_enabled" not in columns:
cursor.execute("ALTER TABLE system_config ADD COLUMN auto_approve_enabled INTEGER DEFAULT 0")
print(" ✓ 添加 auto_approve_enabled 字段")
if "auto_approve_hourly_limit" not in columns:
cursor.execute("ALTER TABLE system_config ADD COLUMN auto_approve_hourly_limit INTEGER DEFAULT 10")
print(" ✓ 添加 auto_approve_hourly_limit 字段")
if "auto_approve_vip_days" not in columns:
cursor.execute("ALTER TABLE system_config ADD COLUMN auto_approve_vip_days INTEGER DEFAULT 7")
print(" ✓ 添加 auto_approve_vip_days 字段")
cursor.execute("PRAGMA table_info(task_logs)")
columns = [col[1] for col in cursor.fetchall()]
if "duration" not in columns:
cursor.execute("ALTER TABLE task_logs ADD COLUMN duration INTEGER")
print(" ✓ 添加 duration 字段到 task_logs")
conn.commit()
@@ -173,39 +135,24 @@ def _migrate_to_v2(conn):
"""迁移到版本2 - 添加代理配置字段"""
cursor = conn.cursor()
columns = _get_table_columns(cursor, "system_config")
_add_column_if_missing(
cursor,
"system_config",
columns,
"proxy_enabled",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 proxy_enabled 字段",
)
_add_column_if_missing(
cursor,
"system_config",
columns,
"proxy_api_url",
'TEXT DEFAULT ""',
ok_message=" [OK] 添加 proxy_api_url 字段",
)
_add_column_if_missing(
cursor,
"system_config",
columns,
"proxy_expire_minutes",
"INTEGER DEFAULT 3",
ok_message=" [OK] 添加 proxy_expire_minutes 字段",
)
_add_column_if_missing(
cursor,
"system_config",
columns,
"enable_screenshot",
"INTEGER DEFAULT 1",
ok_message=" [OK] 添加 enable_screenshot 字段",
)
cursor.execute("PRAGMA table_info(system_config)")
columns = [col[1] for col in cursor.fetchall()]
if "proxy_enabled" not in columns:
cursor.execute("ALTER TABLE system_config ADD COLUMN proxy_enabled INTEGER DEFAULT 0")
print(" ✓ 添加 proxy_enabled 字段")
if "proxy_api_url" not in columns:
cursor.execute('ALTER TABLE system_config ADD COLUMN proxy_api_url TEXT DEFAULT ""')
print(" ✓ 添加 proxy_api_url 字段")
if "proxy_expire_minutes" not in columns:
cursor.execute("ALTER TABLE system_config ADD COLUMN proxy_expire_minutes INTEGER DEFAULT 3")
print(" ✓ 添加 proxy_expire_minutes 字段")
if "enable_screenshot" not in columns:
cursor.execute("ALTER TABLE system_config ADD COLUMN enable_screenshot INTEGER DEFAULT 1")
print(" ✓ 添加 enable_screenshot 字段")
conn.commit()
@@ -214,31 +161,20 @@ def _migrate_to_v3(conn):
"""迁移到版本3 - 添加账号状态和登录失败计数字段"""
cursor = conn.cursor()
columns = _get_table_columns(cursor, "accounts")
_add_column_if_missing(
cursor,
"accounts",
columns,
"status",
'TEXT DEFAULT "active"',
ok_message=" [OK] 添加 accounts.status 字段 (账号状态)",
)
_add_column_if_missing(
cursor,
"accounts",
columns,
"login_fail_count",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 accounts.login_fail_count 字段 (登录失败计数)",
)
_add_column_if_missing(
cursor,
"accounts",
columns,
"last_login_error",
"TEXT",
ok_message=" [OK] 添加 accounts.last_login_error 字段 (最后登录错误)",
)
cursor.execute("PRAGMA table_info(accounts)")
columns = [col[1] for col in cursor.fetchall()]
if "status" not in columns:
cursor.execute('ALTER TABLE accounts ADD COLUMN status TEXT DEFAULT "active"')
print(" ✓ 添加 accounts.status 字段 (账号状态)")
if "login_fail_count" not in columns:
cursor.execute("ALTER TABLE accounts ADD COLUMN login_fail_count INTEGER DEFAULT 0")
print(" ✓ 添加 accounts.login_fail_count 字段 (登录失败计数)")
if "last_login_error" not in columns:
cursor.execute("ALTER TABLE accounts ADD COLUMN last_login_error TEXT")
print(" ✓ 添加 accounts.last_login_error 字段 (最后登录错误)")
conn.commit()
@@ -247,15 +183,12 @@ def _migrate_to_v4(conn):
"""迁移到版本4 - 添加任务来源字段"""
cursor = conn.cursor()
columns = _get_table_columns(cursor, "task_logs")
_add_column_if_missing(
cursor,
"task_logs",
columns,
"source",
'TEXT DEFAULT "manual"',
ok_message=" [OK] 添加 task_logs.source 字段 (任务来源: manual/scheduled/immediate)",
)
cursor.execute("PRAGMA table_info(task_logs)")
columns = [col[1] for col in cursor.fetchall()]
if "source" not in columns:
cursor.execute('ALTER TABLE task_logs ADD COLUMN source TEXT DEFAULT "manual"')
print(" ✓ 添加 task_logs.source 字段 (任务来源: manual/scheduled/immediate)")
conn.commit()
@@ -286,7 +219,7 @@ def _migrate_to_v5(conn):
)
"""
)
print(" [OK] 创建 user_schedules 表 (用户定时任务)")
print(" 创建 user_schedules 表 (用户定时任务)")
cursor.execute(
"""
@@ -310,12 +243,12 @@ def _migrate_to_v5(conn):
)
"""
)
print(" [OK] 创建 schedule_execution_logs 表 (定时任务执行日志)")
print(" 创建 schedule_execution_logs 表 (定时任务执行日志)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_user_id ON user_schedules(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_enabled ON user_schedules(enabled)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_next_run ON user_schedules(next_run_at)")
print(" [OK] 创建 user_schedules 表索引")
print(" 创建 user_schedules 表索引")
conn.commit()
@@ -338,10 +271,10 @@ def _migrate_to_v6(conn):
)
"""
)
print(" [OK] 创建 announcements 表 (公告)")
print(" 创建 announcements 表 (公告)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcements_active ON announcements(is_active)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at)")
print(" [OK] 创建 announcements 表索引")
print(" 创建 announcements 表索引")
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='announcement_dismissals'")
if not cursor.fetchone():
@@ -357,9 +290,9 @@ def _migrate_to_v6(conn):
)
"""
)
print(" [OK] 创建 announcement_dismissals 表 (公告永久关闭记录)")
print(" 创建 announcement_dismissals 表 (公告永久关闭记录)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcement_dismissals_user ON announcement_dismissals(user_id)")
print(" [OK] 创建 announcement_dismissals 表索引")
print(" 创建 announcement_dismissals 表索引")
conn.commit()
@@ -367,17 +300,20 @@ def _migrate_to_v6(conn):
def _migrate_to_v7(conn):
"""迁移到版本7 - 统一存储北京时间将历史UTC时间字段整体+8小时"""
cursor = conn.cursor()
columns_cache: dict[str, set[str]] = {}
def table_exists(table_name: str) -> bool:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
return cursor.fetchone() is not None
def column_exists(table_name: str, column_name: str) -> bool:
cursor.execute(f"PRAGMA table_info({table_name})")
return any(row[1] == column_name for row in cursor.fetchall())
def shift_utc_to_cst(table_name: str, column_name: str) -> None:
if not _table_exists(cursor, table_name):
if not table_exists(table_name):
return
if table_name not in columns_cache:
columns_cache[table_name] = _get_table_columns(cursor, table_name)
if column_name not in columns_cache[table_name]:
if not column_exists(table_name, column_name):
return
cursor.execute(
f"""
UPDATE {table_name}
@@ -393,6 +329,10 @@ def _migrate_to_v7(conn):
("accounts", "created_at"),
("password_reset_requests", "created_at"),
("password_reset_requests", "processed_at"),
]:
shift_utc_to_cst(table, col)
for table, col in [
("smtp_configs", "created_at"),
("smtp_configs", "updated_at"),
("smtp_configs", "last_success_at"),
@@ -400,6 +340,10 @@ def _migrate_to_v7(conn):
("email_tokens", "created_at"),
("email_logs", "created_at"),
("email_stats", "last_updated"),
]:
shift_utc_to_cst(table, col)
for table, col in [
("task_checkpoints", "created_at"),
("task_checkpoints", "updated_at"),
("task_checkpoints", "completed_at"),
@@ -407,7 +351,7 @@ def _migrate_to_v7(conn):
shift_utc_to_cst(table, col)
conn.commit()
print(" [OK] 时区迁移历史UTC时间已转换为北京时间")
print(" 时区迁移历史UTC时间已转换为北京时间")
def _migrate_to_v8(conn):
@@ -415,23 +359,15 @@ def _migrate_to_v8(conn):
cursor = conn.cursor()
# 1) 增量字段random_delay旧库可能不存在
columns = _get_table_columns(cursor, "user_schedules")
_add_column_if_missing(
cursor,
"user_schedules",
columns,
"random_delay",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 user_schedules.random_delay 字段",
)
_add_column_if_missing(
cursor,
"user_schedules",
columns,
"next_run_at",
"TIMESTAMP",
ok_message=" [OK] 添加 user_schedules.next_run_at 字段",
)
cursor.execute("PRAGMA table_info(user_schedules)")
columns = [col[1] for col in cursor.fetchall()]
if "random_delay" not in columns:
cursor.execute("ALTER TABLE user_schedules ADD COLUMN random_delay INTEGER DEFAULT 0")
print(" ✓ 添加 user_schedules.random_delay 字段")
if "next_run_at" not in columns:
cursor.execute("ALTER TABLE user_schedules ADD COLUMN next_run_at TIMESTAMP")
print(" ✓ 添加 user_schedules.next_run_at 字段")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_next_run ON user_schedules(next_run_at)")
conn.commit()
@@ -456,12 +392,12 @@ def _migrate_to_v8(conn):
fixed = 0
for row in rows:
try:
schedule_id = _read_row_value(row, "id", 0)
schedule_time = _read_row_value(row, "schedule_time", 1)
weekdays = _read_row_value(row, "weekdays", 2)
random_delay = _read_row_value(row, "random_delay", 3)
last_run_at = _read_row_value(row, "last_run_at", 4)
next_run_at = _read_row_value(row, "next_run_at", 5)
schedule_id = row["id"] if isinstance(row, sqlite3.Row) else row[0]
schedule_time = row["schedule_time"] if isinstance(row, sqlite3.Row) else row[1]
weekdays = row["weekdays"] if isinstance(row, sqlite3.Row) else row[2]
random_delay = row["random_delay"] if isinstance(row, sqlite3.Row) else row[3]
last_run_at = row["last_run_at"] if isinstance(row, sqlite3.Row) else row[4]
next_run_at = row["next_run_at"] if isinstance(row, sqlite3.Row) else row[5]
except Exception:
continue
@@ -484,7 +420,7 @@ def _migrate_to_v8(conn):
conn.commit()
if fixed:
print(f" [OK] 已为 {fixed} 条启用定时任务补算 next_run_at")
print(f" 已为 {fixed} 条启用定时任务补算 next_run_at")
except Exception as e:
# 迁移过程中不阻断主流程;上线后由 worker 兜底补算
print(f" ⚠ v8 迁移补算 next_run_at 失败: {e}")
@@ -494,46 +430,27 @@ def _migrate_to_v9(conn):
"""迁移到版本9 - 邮件设置字段迁移(清理 email_service scattered ALTER TABLE"""
cursor = conn.cursor()
if not _table_exists(cursor, "email_settings"):
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='email_settings'")
if not cursor.fetchone():
# 邮件表由 email_service.init_email_tables 创建;此处仅做增量字段迁移
return
columns = _get_table_columns(cursor, "email_settings")
cursor.execute("PRAGMA table_info(email_settings)")
columns = [col[1] for col in cursor.fetchall()]
changed = False
changed = (
_add_column_if_missing(
cursor,
"email_settings",
columns,
"register_verify_enabled",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 email_settings.register_verify_enabled 字段",
)
or changed
)
changed = (
_add_column_if_missing(
cursor,
"email_settings",
columns,
"base_url",
"TEXT DEFAULT ''",
ok_message=" [OK] 添加 email_settings.base_url 字段",
)
or changed
)
changed = (
_add_column_if_missing(
cursor,
"email_settings",
columns,
"task_notify_enabled",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 email_settings.task_notify_enabled 字段",
)
or changed
)
if "register_verify_enabled" not in columns:
cursor.execute("ALTER TABLE email_settings ADD COLUMN register_verify_enabled INTEGER DEFAULT 0")
print(" ✓ 添加 email_settings.register_verify_enabled 字段")
changed = True
if "base_url" not in columns:
cursor.execute("ALTER TABLE email_settings ADD COLUMN base_url TEXT DEFAULT ''")
print(" ✓ 添加 email_settings.base_url 字段")
changed = True
if "task_notify_enabled" not in columns:
cursor.execute("ALTER TABLE email_settings ADD COLUMN task_notify_enabled INTEGER DEFAULT 0")
print(" ✓ 添加 email_settings.task_notify_enabled 字段")
changed = True
if changed:
conn.commit()
@@ -542,31 +459,18 @@ def _migrate_to_v9(conn):
def _migrate_to_v10(conn):
"""迁移到版本10 - users 邮箱字段迁移(避免运行时 ALTER TABLE"""
cursor = conn.cursor()
columns = _get_table_columns(cursor, "users")
cursor.execute("PRAGMA table_info(users)")
columns = [col[1] for col in cursor.fetchall()]
changed = False
changed = (
_add_column_if_missing(
cursor,
"users",
columns,
"email_verified",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 users.email_verified 字段",
)
or changed
)
changed = (
_add_column_if_missing(
cursor,
"users",
columns,
"email_notify_enabled",
"INTEGER DEFAULT 1",
ok_message=" [OK] 添加 users.email_notify_enabled 字段",
)
or changed
)
if "email_verified" not in columns:
cursor.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0")
print(" ✓ 添加 users.email_verified 字段")
changed = True
if "email_notify_enabled" not in columns:
cursor.execute("ALTER TABLE users ADD COLUMN email_notify_enabled INTEGER DEFAULT 1")
print(" ✓ 添加 users.email_notify_enabled 字段")
changed = True
if changed:
conn.commit()
@@ -591,7 +495,7 @@ def _migrate_to_v11(conn):
conn.commit()
if updated:
print(f" [OK] 已将 {updated} 个 pending 用户迁移为 approved")
print(f" 已将 {updated} 个 pending 用户迁移为 approved")
except sqlite3.OperationalError as e:
print(f" ⚠️ v11 迁移跳过: {e}")
@@ -753,24 +657,19 @@ def _migrate_to_v15(conn):
"""迁移到版本15 - 邮件设置:新设备登录提醒全局开关"""
cursor = conn.cursor()
if not _table_exists(cursor, "email_settings"):
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='email_settings'")
if not cursor.fetchone():
# 邮件表由 email_service.init_email_tables 创建;此处仅做增量字段迁移
return
columns = _get_table_columns(cursor, "email_settings")
cursor.execute("PRAGMA table_info(email_settings)")
columns = [col[1] for col in cursor.fetchall()]
changed = False
changed = (
_add_column_if_missing(
cursor,
"email_settings",
columns,
"login_alert_enabled",
"INTEGER DEFAULT 1",
ok_message=" [OK] 添加 email_settings.login_alert_enabled 字段",
)
or changed
)
if "login_alert_enabled" not in columns:
cursor.execute("ALTER TABLE email_settings ADD COLUMN login_alert_enabled INTEGER DEFAULT 1")
print(" ✓ 添加 email_settings.login_alert_enabled 字段")
changed = True
try:
cursor.execute("UPDATE email_settings SET login_alert_enabled = 1 WHERE login_alert_enabled IS NULL")
@@ -787,24 +686,22 @@ def _migrate_to_v15(conn):
def _migrate_to_v16(conn):
"""迁移到版本16 - 公告支持图片字段"""
cursor = conn.cursor()
columns = _get_table_columns(cursor, "announcements")
cursor.execute("PRAGMA table_info(announcements)")
columns = [col[1] for col in cursor.fetchall()]
if _add_column_if_missing(
cursor,
"announcements",
columns,
"image_url",
"TEXT",
ok_message=" [OK] 添加 announcements.image_url 字段",
):
if "image_url" not in columns:
cursor.execute("ALTER TABLE announcements ADD COLUMN image_url TEXT")
conn.commit()
print(" ✓ 添加 announcements.image_url 字段")
def _migrate_to_v17(conn):
"""迁移到版本17 - 金山文档上传配置与用户开关"""
cursor = conn.cursor()
system_columns = _get_table_columns(cursor, "system_config")
cursor.execute("PRAGMA table_info(system_config)")
columns = [col[1] for col in cursor.fetchall()]
system_fields = [
("kdocs_enabled", "INTEGER DEFAULT 0"),
("kdocs_doc_url", "TEXT DEFAULT ''"),
@@ -817,29 +714,21 @@ def _migrate_to_v17(conn):
("kdocs_admin_notify_email", "TEXT DEFAULT ''"),
]
for field, ddl in system_fields:
_add_column_if_missing(
cursor,
"system_config",
system_columns,
field,
ddl,
ok_message=f" [OK] 添加 system_config.{field} 字段",
)
if field not in columns:
cursor.execute(f"ALTER TABLE system_config ADD COLUMN {field} {ddl}")
print(f" ✓ 添加 system_config.{field} 字段")
cursor.execute("PRAGMA table_info(users)")
columns = [col[1] for col in cursor.fetchall()]
user_columns = _get_table_columns(cursor, "users")
user_fields = [
("kdocs_unit", "TEXT DEFAULT ''"),
("kdocs_auto_upload", "INTEGER DEFAULT 0"),
]
for field, ddl in user_fields:
_add_column_if_missing(
cursor,
"users",
user_columns,
field,
ddl,
ok_message=f" [OK] 添加 users.{field} 字段",
)
if field not in columns:
cursor.execute(f"ALTER TABLE users ADD COLUMN {field} {ddl}")
print(f" ✓ 添加 users.{field} 字段")
conn.commit()
@@ -848,88 +737,15 @@ def _migrate_to_v18(conn):
"""迁移到版本18 - 金山文档上传:有效行范围配置"""
cursor = conn.cursor()
columns = _get_table_columns(cursor, "system_config")
_add_column_if_missing(
cursor,
"system_config",
columns,
"kdocs_row_start",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 system_config.kdocs_row_start 字段",
)
_add_column_if_missing(
cursor,
"system_config",
columns,
"kdocs_row_end",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 system_config.kdocs_row_end 字段",
)
conn.commit()
def _migrate_to_v19(conn):
"""迁移到版本19 - 报表与调度查询复合索引优化"""
cursor = conn.cursor()
index_statements = [
"CREATE INDEX IF NOT EXISTS idx_users_status_created_at ON users(status, created_at)",
"CREATE INDEX IF NOT EXISTS idx_task_logs_status_created_at ON task_logs(status, created_at)",
"CREATE INDEX IF NOT EXISTS idx_user_schedules_enabled_next_run ON user_schedules(enabled, next_run_at)",
"CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_status_created_at ON bug_feedbacks(status, created_at)",
"CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_user_created_at ON bug_feedbacks(user_id, created_at)",
]
for statement in index_statements:
cursor.execute(statement)
conn.commit()
def _migrate_to_v20(conn):
"""迁移到版本20 - 慢SQL阈值系统配置"""
cursor = conn.cursor()
columns = _get_table_columns(cursor, "system_config")
_add_column_if_missing(
cursor,
"system_config",
columns,
"db_slow_query_ms",
"INTEGER DEFAULT 120",
ok_message=" [OK] 添加 system_config.db_slow_query_ms 字段",
)
conn.commit()
def _migrate_to_v21(conn):
"""迁移到版本21 - Passkey 认证设备表"""
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_type TEXT NOT NULL,
owner_id INTEGER NOT NULL,
device_name TEXT NOT NULL,
credential_id TEXT UNIQUE NOT NULL,
public_key TEXT NOT NULL,
sign_count INTEGER DEFAULT 0,
transports TEXT DEFAULT '',
aaguid TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_passkeys_owner ON passkeys(owner_type, owner_id)")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_passkeys_owner_last_used ON passkeys(owner_type, owner_id, last_used_at)"
)
cursor.execute("PRAGMA table_info(system_config)")
columns = [col[1] for col in cursor.fetchall()]
if "kdocs_row_start" not in columns:
cursor.execute("ALTER TABLE system_config ADD COLUMN kdocs_row_start INTEGER DEFAULT 0")
print(" ✓ 添加 system_config.kdocs_row_start 字段")
if "kdocs_row_end" not in columns:
cursor.execute("ALTER TABLE system_config ADD COLUMN kdocs_row_end INTEGER DEFAULT 0")
print(" ✓ 添加 system_config.kdocs_row_end 字段")
conn.commit()

View File

@@ -1,173 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import sqlite3
import db_pool
from db.utils import get_cst_now_str
_OWNER_TYPES = {"user", "admin"}
def _normalize_owner_type(owner_type: str) -> str:
normalized = str(owner_type or "").strip().lower()
if normalized not in _OWNER_TYPES:
raise ValueError(f"invalid owner_type: {owner_type}")
return normalized
def list_passkeys(owner_type: str, owner_id: int) -> list[dict]:
owner = _normalize_owner_type(owner_type)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT id, owner_type, owner_id, device_name, credential_id, transports,
sign_count, aaguid, created_at, last_used_at
FROM passkeys
WHERE owner_type = ? AND owner_id = ?
ORDER BY datetime(created_at) DESC, id DESC
""",
(owner, int(owner_id)),
)
return [dict(row) for row in cursor.fetchall()]
def count_passkeys(owner_type: str, owner_id: int) -> int:
owner = _normalize_owner_type(owner_type)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT COUNT(*) AS count FROM passkeys WHERE owner_type = ? AND owner_id = ?",
(owner, int(owner_id)),
)
row = cursor.fetchone()
if not row:
return 0
try:
return int(row["count"] or 0)
except Exception:
try:
return int(row[0] or 0)
except Exception:
return 0
def get_passkey_by_credential_id(credential_id: str) -> dict | None:
credential = str(credential_id or "").strip()
if not credential:
return None
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT id, owner_type, owner_id, device_name, credential_id, public_key,
sign_count, transports, aaguid, created_at, last_used_at
FROM passkeys
WHERE credential_id = ?
""",
(credential,),
)
row = cursor.fetchone()
return dict(row) if row else None
def get_passkey_by_id(owner_type: str, owner_id: int, passkey_id: int) -> dict | None:
owner = _normalize_owner_type(owner_type)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT id, owner_type, owner_id, device_name, credential_id, public_key,
sign_count, transports, aaguid, created_at, last_used_at
FROM passkeys
WHERE id = ? AND owner_type = ? AND owner_id = ?
""",
(int(passkey_id), owner, int(owner_id)),
)
row = cursor.fetchone()
return dict(row) if row else None
def create_passkey(
owner_type: str,
owner_id: int,
*,
credential_id: str,
public_key: str,
sign_count: int,
device_name: str,
transports: str = "",
aaguid: str = "",
) -> int | None:
owner = _normalize_owner_type(owner_type)
now = get_cst_now_str()
with db_pool.get_db() as conn:
cursor = conn.cursor()
try:
cursor.execute(
"""
INSERT INTO passkeys (
owner_type,
owner_id,
device_name,
credential_id,
public_key,
sign_count,
transports,
aaguid,
created_at,
last_used_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
owner,
int(owner_id),
str(device_name or "").strip(),
str(credential_id or "").strip(),
str(public_key or "").strip(),
int(sign_count or 0),
str(transports or "").strip(),
str(aaguid or "").strip(),
now,
now,
),
)
conn.commit()
return int(cursor.lastrowid)
except sqlite3.IntegrityError:
return None
def update_passkey_usage(passkey_id: int, new_sign_count: int) -> bool:
now = get_cst_now_str()
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
UPDATE passkeys
SET sign_count = ?,
last_used_at = ?
WHERE id = ?
""",
(int(new_sign_count or 0), now, int(passkey_id)),
)
conn.commit()
return cursor.rowcount > 0
def delete_passkey(owner_type: str, owner_id: int, passkey_id: int) -> bool:
owner = _normalize_owner_type(owner_type)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"DELETE FROM passkeys WHERE id = ? AND owner_type = ? AND owner_id = ?",
(int(passkey_id), owner, int(owner_id)),
)
conn.commit()
return cursor.rowcount > 0

View File

@@ -2,93 +2,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import json
from datetime import datetime, timedelta
from datetime import datetime
import db_pool
from services.schedule_utils import compute_next_run_at, format_cst
from services.time_utils import get_beijing_now
_SCHEDULE_DEFAULT_TIME = "08:00"
_SCHEDULE_DEFAULT_WEEKDAYS = "1,2,3,4,5"
_ALLOWED_SCHEDULE_UPDATE_FIELDS = (
"name",
"enabled",
"schedule_time",
"weekdays",
"browse_type",
"enable_screenshot",
"random_delay",
"account_ids",
)
_ALLOWED_EXEC_LOG_UPDATE_FIELDS = (
"total_accounts",
"success_accounts",
"failed_accounts",
"total_items",
"total_attachments",
"total_screenshots",
"duration_seconds",
"status",
"error_message",
)
def _normalize_limit(limit, default: int, *, minimum: int = 1) -> int:
try:
parsed = int(limit)
except Exception:
parsed = default
if parsed < minimum:
return minimum
return parsed
def _to_int(value, default: int = 0) -> int:
try:
return int(value)
except Exception:
return default
def _format_optional_datetime(dt: datetime | None) -> str | None:
if dt is None:
return None
return format_cst(dt)
def _serialize_account_ids(account_ids) -> str:
return json.dumps(account_ids) if account_ids else "[]"
def _compute_schedule_next_run_str(
*,
now_dt,
schedule_time,
weekdays,
random_delay,
last_run_at,
) -> str:
next_dt = compute_next_run_at(
now=now_dt,
schedule_time=str(schedule_time or _SCHEDULE_DEFAULT_TIME),
weekdays=str(weekdays or _SCHEDULE_DEFAULT_WEEKDAYS),
random_delay=_to_int(random_delay, 0),
last_run_at=str(last_run_at or "") if last_run_at else None,
)
return format_cst(next_dt)
def _map_schedule_log_row(row) -> dict:
log = dict(row)
log["created_at"] = log.get("execute_time")
log["success_count"] = log.get("success_accounts", 0)
log["failed_count"] = log.get("failed_accounts", 0)
log["duration"] = log.get("duration_seconds", 0)
return log
def get_user_schedules(user_id):
"""获取用户的所有定时任务"""
@@ -125,10 +44,14 @@ def create_user_schedule(
account_ids=None,
):
"""创建用户定时任务"""
import json
with db_pool.get_db() as conn:
cursor = conn.cursor()
cst_time = format_cst(get_beijing_now())
account_ids_str = json.dumps(account_ids) if account_ids else "[]"
cursor.execute(
"""
INSERT INTO user_schedules (
@@ -143,8 +66,8 @@ def create_user_schedule(
weekdays,
browse_type,
enable_screenshot,
_to_int(random_delay, 0),
_serialize_account_ids(account_ids),
int(random_delay or 0),
account_ids_str,
cst_time,
cst_time,
),
@@ -156,11 +79,28 @@ def create_user_schedule(
def update_user_schedule(schedule_id, **kwargs):
"""更新用户定时任务"""
import json
with db_pool.get_db() as conn:
cursor = conn.cursor()
now_dt = get_beijing_now()
now_str = format_cst(now_dt)
updates = []
params = []
allowed_fields = [
"name",
"enabled",
"schedule_time",
"weekdays",
"browse_type",
"enable_screenshot",
"random_delay",
"account_ids",
]
# 读取旧值,用于决定是否需要重算 next_run_at
cursor.execute(
"""
SELECT enabled, schedule_time, weekdays, random_delay, last_run_at
@@ -172,11 +112,10 @@ def update_user_schedule(schedule_id, **kwargs):
current = cursor.fetchone()
if not current:
return False
current_enabled = _to_int(current[0], 0)
current_enabled = int(current[0] or 0)
current_time = current[1]
current_weekdays = current[2]
current_random_delay = _to_int(current[3], 0)
current_random_delay = int(current[3] or 0)
current_last_run_at = current[4]
will_enabled = current_enabled
@@ -184,28 +123,21 @@ def update_user_schedule(schedule_id, **kwargs):
next_weekdays = current_weekdays
next_random_delay = current_random_delay
updates = []
params = []
for field in _ALLOWED_SCHEDULE_UPDATE_FIELDS:
if field not in kwargs:
continue
value = kwargs[field]
if field == "account_ids" and isinstance(value, list):
value = json.dumps(value)
if field == "enabled":
will_enabled = 1 if value else 0
if field == "schedule_time":
next_time = value
if field == "weekdays":
next_weekdays = value
if field == "random_delay":
next_random_delay = int(value or 0)
updates.append(f"{field} = ?")
params.append(value)
for field in allowed_fields:
if field in kwargs:
value = kwargs[field]
if field == "account_ids" and isinstance(value, list):
value = json.dumps(value)
if field == "enabled":
will_enabled = 1 if value else 0
if field == "schedule_time":
next_time = value
if field == "weekdays":
next_weekdays = value
if field == "random_delay":
next_random_delay = int(value or 0)
updates.append(f"{field} = ?")
params.append(value)
if not updates:
return False
@@ -213,26 +145,30 @@ def update_user_schedule(schedule_id, **kwargs):
updates.append("updated_at = ?")
params.append(now_str)
config_changed = any(key in kwargs for key in ("schedule_time", "weekdays", "random_delay"))
# 关键字段变更后重算 next_run_at确保索引驱动不会跑偏
#
# 需求:当用户修改“执行时间/执行日期/随机±15分钟”后即使今天已经执行过也允许按新配置在今天再次触发。
# 做法:这些关键字段发生变更时,重算 next_run_at 时忽略 last_run_at 的“同日仅一次”限制。
config_changed = any(key in kwargs for key in ["schedule_time", "weekdays", "random_delay"])
enabled_toggled = "enabled" in kwargs
should_recompute_next = config_changed or (enabled_toggled and will_enabled == 1)
if should_recompute_next:
next_run_at = _compute_schedule_next_run_str(
now_dt=now_dt,
schedule_time=next_time,
weekdays=next_weekdays,
random_delay=next_random_delay,
last_run_at=None if config_changed else current_last_run_at,
next_dt = compute_next_run_at(
now=now_dt,
schedule_time=str(next_time or "08:00"),
weekdays=str(next_weekdays or "1,2,3,4,5"),
random_delay=int(next_random_delay or 0),
last_run_at=None if config_changed else (str(current_last_run_at or "") if current_last_run_at else None),
)
updates.append("next_run_at = ?")
params.append(next_run_at)
params.append(format_cst(next_dt))
# 若本次显式禁用任务,则 next_run_at 清空(与 toggle 行为保持一致)
if enabled_toggled and will_enabled == 0:
updates.append("next_run_at = ?")
params.append(None)
params.append(schedule_id)
sql = f"UPDATE user_schedules SET {', '.join(updates)} WHERE id = ?"
cursor.execute(sql, params)
conn.commit()
@@ -267,19 +203,28 @@ def toggle_user_schedule(schedule_id, enabled):
)
row = cursor.fetchone()
if row:
schedule_time, weekdays, random_delay, last_run_at, existing_next_run_at = row
existing_next_run_at = str(existing_next_run_at or "").strip() or None
schedule_time, weekdays, random_delay, last_run_at, existing_next_run_at = (
row[0],
row[1],
row[2],
row[3],
row[4],
)
existing_next_run_at = str(existing_next_run_at or "").strip() or None
# 若 next_run_at 已经被“修改配置”逻辑预先计算好且仍在未来,则优先沿用,
# 避免 last_run_at 的“同日仅一次”限制阻塞用户把任务调整到今天再次触发。
if existing_next_run_at and existing_next_run_at > now_str:
next_run_at = existing_next_run_at
else:
next_run_at = _compute_schedule_next_run_str(
now_dt=now_dt,
schedule_time=schedule_time,
weekdays=weekdays,
random_delay=random_delay,
last_run_at=last_run_at,
next_dt = compute_next_run_at(
now=now_dt,
schedule_time=str(schedule_time or "08:00"),
weekdays=str(weekdays or "1,2,3,4,5"),
random_delay=int(random_delay or 0),
last_run_at=str(last_run_at or "") if last_run_at else None,
)
next_run_at = format_cst(next_dt)
cursor.execute(
"""
@@ -327,15 +272,16 @@ def update_schedule_last_run(schedule_id):
row = cursor.fetchone()
if not row:
return False
schedule_time, weekdays, random_delay = row[0], row[1], row[2]
schedule_time, weekdays, random_delay = row
next_run_at = _compute_schedule_next_run_str(
now_dt=now_dt,
schedule_time=schedule_time,
weekdays=weekdays,
random_delay=random_delay,
next_dt = compute_next_run_at(
now=now_dt,
schedule_time=str(schedule_time or "08:00"),
weekdays=str(weekdays or "1,2,3,4,5"),
random_delay=int(random_delay or 0),
last_run_at=now_str,
)
next_run_at = format_cst(next_dt)
cursor.execute(
"""
@@ -359,11 +305,7 @@ def update_schedule_next_run(schedule_id: int, next_run_at: str) -> bool:
SET next_run_at = ?, updated_at = ?
WHERE id = ?
""",
(
str(next_run_at or "").strip() or None,
format_cst(get_beijing_now()),
int(schedule_id),
),
(str(next_run_at or "").strip() or None, format_cst(get_beijing_now()), int(schedule_id)),
)
conn.commit()
return cursor.rowcount > 0
@@ -386,15 +328,15 @@ def recompute_schedule_next_run(schedule_id: int, *, now_dt=None) -> bool:
if not row:
return False
schedule_time, weekdays, random_delay, last_run_at = row
next_run_at = _compute_schedule_next_run_str(
now_dt=now_dt,
schedule_time=schedule_time,
weekdays=weekdays,
random_delay=random_delay,
last_run_at=last_run_at,
schedule_time, weekdays, random_delay, last_run_at = row[0], row[1], row[2], row[3]
next_dt = compute_next_run_at(
now=now_dt,
schedule_time=str(schedule_time or "08:00"),
weekdays=str(weekdays or "1,2,3,4,5"),
random_delay=int(random_delay or 0),
last_run_at=str(last_run_at or "") if last_run_at else None,
)
return update_schedule_next_run(int(schedule_id), next_run_at)
return update_schedule_next_run(int(schedule_id), format_cst(next_dt))
def get_due_user_schedules(now_cst: str, limit: int = 50):
@@ -403,8 +345,6 @@ def get_due_user_schedules(now_cst: str, limit: int = 50):
if not now_cst:
now_cst = format_cst(get_beijing_now())
safe_limit = _normalize_limit(limit, 50, minimum=1)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
@@ -418,7 +358,7 @@ def get_due_user_schedules(now_cst: str, limit: int = 50):
ORDER BY us.next_run_at ASC
LIMIT ?
""",
(now_cst, safe_limit),
(now_cst, int(limit)),
)
return [dict(row) for row in cursor.fetchall()]
@@ -430,13 +370,15 @@ def create_schedule_execution_log(schedule_id, user_id, schedule_name):
"""创建定时任务执行日志"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
execute_time = format_cst(get_beijing_now())
cursor.execute(
"""
INSERT INTO schedule_execution_logs (
schedule_id, user_id, schedule_name, execute_time, status
) VALUES (?, ?, ?, ?, 'running')
""",
(schedule_id, user_id, schedule_name, format_cst(get_beijing_now())),
(schedule_id, user_id, schedule_name, execute_time),
)
conn.commit()
@@ -451,11 +393,22 @@ def update_schedule_execution_log(log_id, **kwargs):
updates = []
params = []
for field in _ALLOWED_EXEC_LOG_UPDATE_FIELDS:
if field not in kwargs:
continue
updates.append(f"{field} = ?")
params.append(kwargs[field])
allowed_fields = [
"total_accounts",
"success_accounts",
"failed_accounts",
"total_items",
"total_attachments",
"total_screenshots",
"duration_seconds",
"status",
"error_message",
]
for field in allowed_fields:
if field in kwargs:
updates.append(f"{field} = ?")
params.append(kwargs[field])
if not updates:
return False
@@ -471,7 +424,6 @@ def update_schedule_execution_log(log_id, **kwargs):
def get_schedule_execution_logs(schedule_id, limit=10):
"""获取定时任务执行日志"""
try:
safe_limit = _normalize_limit(limit, 10, minimum=1)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
@@ -481,16 +433,24 @@ def get_schedule_execution_logs(schedule_id, limit=10):
ORDER BY execute_time DESC
LIMIT ?
""",
(schedule_id, safe_limit),
(schedule_id, limit),
)
logs = []
for row in cursor.fetchall():
rows = cursor.fetchall()
for row in rows:
try:
logs.append(_map_schedule_log_row(row))
log = dict(row)
log["created_at"] = log.get("execute_time")
log["success_count"] = log.get("success_accounts", 0)
log["failed_count"] = log.get("failed_accounts", 0)
log["duration"] = log.get("duration_seconds", 0)
logs.append(log)
except Exception as e:
print(f"[数据库] 处理日志行时出错: {e}")
continue
return logs
except Exception as e:
print(f"[数据库] 查询定时任务日志时出错: {e}")
@@ -502,7 +462,6 @@ def get_schedule_execution_logs(schedule_id, limit=10):
def get_user_all_schedule_logs(user_id, limit=50):
"""获取用户所有定时任务的执行日志"""
safe_limit = _normalize_limit(limit, 50, minimum=1)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
@@ -512,7 +471,7 @@ def get_user_all_schedule_logs(user_id, limit=50):
ORDER BY execute_time DESC
LIMIT ?
""",
(user_id, safe_limit),
(user_id, limit),
)
return [dict(row) for row in cursor.fetchall()]
@@ -534,21 +493,14 @@ def delete_schedule_logs(schedule_id, user_id):
def clean_old_schedule_logs(days=30):
"""清理指定天数前的定时任务执行日志"""
safe_days = _to_int(days, 30)
if safe_days < 0:
safe_days = 0
cutoff_dt = get_beijing_now() - timedelta(days=safe_days)
cutoff_str = format_cst(cutoff_dt)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
DELETE FROM schedule_execution_logs
WHERE execute_time < ?
WHERE execute_time < datetime('now', 'localtime', '-' || ? || ' days')
""",
(cutoff_str,),
(days,),
)
conn.commit()
return cursor.rowcount

View File

@@ -74,25 +74,6 @@ def ensure_schema(conn) -> None:
"""
)
# Passkey 认证设备表(用户/管理员)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_type TEXT NOT NULL,
owner_id INTEGER NOT NULL,
device_name TEXT NOT NULL,
credential_id TEXT UNIQUE NOT NULL,
public_key TEXT NOT NULL,
sign_count INTEGER DEFAULT 0,
transports TEXT DEFAULT '',
aaguid TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# ==================== 安全防护:威胁检测相关表 ====================
# 威胁事件日志表
@@ -229,7 +210,6 @@ def ensure_schema(conn) -> None:
proxy_expire_minutes INTEGER DEFAULT 3,
max_screenshot_concurrent INTEGER DEFAULT 3,
max_concurrent_per_account INTEGER DEFAULT 1,
db_slow_query_ms INTEGER DEFAULT 120,
schedule_weekdays TEXT DEFAULT '1,2,3,4,5,6,7',
enable_screenshot INTEGER DEFAULT 1,
auto_approve_enabled INTEGER DEFAULT 0,
@@ -382,13 +362,8 @@ def ensure_schema(conn) -> None:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_status ON users(status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_vip_expire ON users(vip_expire_time)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_status_created_at ON users(status, created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_fingerprints_user ON login_fingerprints(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_ips_user ON login_ips(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_passkeys_owner ON passkeys(owner_type, owner_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_passkeys_owner_last_used ON passkeys(owner_type, owner_id, last_used_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_created_at ON threat_events(created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_ip ON threat_events(ip)")
@@ -415,17 +390,12 @@ def ensure_schema(conn) -> None:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_user_id ON task_logs(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_status ON task_logs(status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_status_created_at ON task_logs(status, created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_created_at ON task_logs(created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_source ON task_logs(source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_source_created_at ON task_logs(source, created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_user_date ON task_logs(user_id, created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_user_id ON bug_feedbacks(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_status ON bug_feedbacks(status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_created_at ON bug_feedbacks(created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_status_created_at ON bug_feedbacks(status, created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_user_created_at ON bug_feedbacks(user_id, created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcements_active ON announcements(is_active)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at)")
@@ -434,15 +404,11 @@ def ensure_schema(conn) -> None:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_user_id ON user_schedules(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_enabled ON user_schedules(enabled)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_next_run ON user_schedules(next_run_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_enabled_next_run ON user_schedules(enabled, next_run_at)")
# 复合索引优化
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_user_enabled ON user_schedules(user_id, enabled)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_execution_logs_schedule_id ON schedule_execution_logs(schedule_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_execution_logs_user_id ON schedule_execution_logs(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_execution_logs_status ON schedule_execution_logs(status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_execution_logs_execute_time ON schedule_execution_logs(execute_time)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_execution_logs_schedule_time ON schedule_execution_logs(schedule_id, execute_time)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_execution_logs_user_time ON schedule_execution_logs(user_id, execute_time)")
# 初始化VIP配置幂等
try:

View File

@@ -3,82 +3,13 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any, Dict, Optional
from typing import Any, Optional
from typing import Dict
import db_pool
from db.utils import get_cst_now, get_cst_now_str
_THREAT_EVENT_SELECT_COLUMNS = """
id,
threat_type,
score,
rule,
field_name,
matched,
value_preview,
ip,
user_id,
request_method,
request_path,
user_agent,
created_at
"""
def _normalize_page(page: int) -> int:
try:
page_i = int(page)
except Exception:
page_i = 1
return max(1, page_i)
def _normalize_per_page(per_page: int, default: int = 20) -> int:
try:
value = int(per_page)
except Exception:
value = default
return max(1, min(200, value))
def _normalize_limit(limit: int, default: int = 50) -> int:
try:
value = int(limit)
except Exception:
value = default
return max(1, min(200, value))
def _row_value(row, key: str, index: int = 0, default=None):
if row is None:
return default
try:
return row[key]
except Exception:
try:
return row[index]
except Exception:
return default
def _fetch_threat_events_history(where_clause: str, params: tuple[Any, ...], limit_i: int) -> list[dict]:
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
f"""
SELECT
{_THREAT_EVENT_SELECT_COLUMNS}
FROM threat_events
WHERE {where_clause}
ORDER BY created_at DESC, id DESC
LIMIT ?
""",
tuple(params) + (limit_i,),
)
return [dict(r) for r in cursor.fetchall()]
def record_login_context(user_id: int, ip_address: str, user_agent: str) -> Dict[str, bool]:
"""记录登录环境信息,返回是否新设备/新IP。"""
user_id = int(user_id)
@@ -105,7 +36,7 @@ def record_login_context(user_id: int, ip_address: str, user_agent: str) -> Dict
SET last_seen = ?, last_ip = ?
WHERE id = ?
""",
(now_str, ip_text, _row_value(row, "id", 0)),
(now_str, ip_text, row["id"] if isinstance(row, dict) else row[0]),
)
else:
cursor.execute(
@@ -130,7 +61,7 @@ def record_login_context(user_id: int, ip_address: str, user_agent: str) -> Dict
SET last_seen = ?
WHERE id = ?
""",
(now_str, _row_value(row, "id", 0)),
(now_str, row["id"] if isinstance(row, dict) else row[0]),
)
else:
cursor.execute(
@@ -235,8 +166,15 @@ def _build_threat_events_where_clause(filters: Optional[dict]) -> tuple[str, lis
def get_threat_events_list(page: int, per_page: int, filters: Optional[dict] = None) -> dict:
"""分页获取威胁事件。"""
page_i = _normalize_page(page)
per_page_i = _normalize_per_page(per_page, default=20)
try:
page_i = max(1, int(page))
except Exception:
page_i = 1
try:
per_page_i = int(per_page)
except Exception:
per_page_i = 20
per_page_i = max(1, min(200, per_page_i))
where_sql, params = _build_threat_events_where_clause(filters)
offset = (page_i - 1) * per_page_i
@@ -250,7 +188,19 @@ def get_threat_events_list(page: int, per_page: int, filters: Optional[dict] = N
cursor.execute(
f"""
SELECT
{_THREAT_EVENT_SELECT_COLUMNS}
id,
threat_type,
score,
rule,
field_name,
matched,
value_preview,
ip,
user_id,
request_method,
request_path,
user_agent,
created_at
FROM threat_events
{where_sql}
ORDER BY created_at DESC, id DESC
@@ -268,20 +218,75 @@ def get_ip_threat_history(ip: str, limit: int = 50) -> list[dict]:
ip_text = str(ip or "").strip()[:64]
if not ip_text:
return []
try:
limit_i = max(1, min(200, int(limit)))
except Exception:
limit_i = 50
limit_i = _normalize_limit(limit, default=50)
return _fetch_threat_events_history("ip = ?", (ip_text,), limit_i)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT
id,
threat_type,
score,
rule,
field_name,
matched,
value_preview,
ip,
user_id,
request_method,
request_path,
user_agent,
created_at
FROM threat_events
WHERE ip = ?
ORDER BY created_at DESC, id DESC
LIMIT ?
""",
(ip_text, limit_i),
)
return [dict(r) for r in cursor.fetchall()]
def get_user_threat_history(user_id: int, limit: int = 50) -> list[dict]:
"""获取用户的威胁历史最近limit条"""
if user_id is None:
return []
try:
user_id_int = int(user_id)
except Exception:
return []
try:
limit_i = max(1, min(200, int(limit)))
except Exception:
limit_i = 50
limit_i = _normalize_limit(limit, default=50)
return _fetch_threat_events_history("user_id = ?", (user_id_int,), limit_i)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT
id,
threat_type,
score,
rule,
field_name,
matched,
value_preview,
ip,
user_id,
request_method,
request_path,
user_agent,
created_at
FROM threat_events
WHERE user_id = ?
ORDER BY created_at DESC, id DESC
LIMIT ?
""",
(user_id_int, limit_i),
)
return [dict(r) for r in cursor.fetchall()]

View File

@@ -2,135 +2,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import datetime
import pytz
import db_pool
from db.utils import get_cst_now, get_cst_now_str, sanitize_sql_like_pattern
_TASK_STATS_SELECT_SQL = """
SELECT
COUNT(*) as total_tasks,
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_tasks,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_tasks,
SUM(total_items) as total_items,
SUM(total_attachments) as total_attachments
FROM task_logs
"""
_USER_RUN_STATS_SELECT_SQL = """
SELECT
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
SUM(total_items) as total_items,
SUM(total_attachments) as total_attachments
FROM task_logs
"""
def _build_day_bounds(date_filter: str) -> tuple[str | None, str | None]:
"""将 YYYY-MM-DD 转换为 [day_start, day_end) 区间。"""
try:
day_start = datetime.strptime(str(date_filter), "%Y-%m-%d")
except Exception:
return None, None
day_end = day_start + timedelta(days=1)
return day_start.strftime("%Y-%m-%d %H:%M:%S"), day_end.strftime("%Y-%m-%d %H:%M:%S")
def _normalize_int(value, default: int, *, minimum: int | None = None) -> int:
try:
parsed = int(value)
except Exception:
parsed = default
if minimum is not None and parsed < minimum:
return minimum
return parsed
def _stat_value(row, key: str) -> int:
try:
value = row[key] if row else 0
except Exception:
value = 0
return int(value or 0)
def _build_task_logs_where_sql(
*,
date_filter=None,
status_filter=None,
source_filter=None,
user_id_filter=None,
account_filter=None,
) -> tuple[str, list]:
where_clauses = ["1=1"]
params = []
if date_filter:
day_start, day_end = _build_day_bounds(date_filter)
if day_start and day_end:
where_clauses.append("tl.created_at >= ? AND tl.created_at < ?")
params.extend([day_start, day_end])
else:
where_clauses.append("date(tl.created_at) = ?")
params.append(date_filter)
if status_filter:
where_clauses.append("tl.status = ?")
params.append(status_filter)
if source_filter:
source_filter = str(source_filter or "").strip()
if source_filter == "user_scheduled":
where_clauses.append("tl.source >= ? AND tl.source < ?")
params.extend(["user_scheduled:", "user_scheduled;"])
elif source_filter.endswith("*"):
prefix = source_filter[:-1]
safe_prefix = sanitize_sql_like_pattern(prefix)
where_clauses.append("tl.source LIKE ? ESCAPE '\\\\'")
params.append(f"{safe_prefix}%")
else:
where_clauses.append("tl.source = ?")
params.append(source_filter)
if user_id_filter:
where_clauses.append("tl.user_id = ?")
params.append(user_id_filter)
if account_filter:
safe_filter = sanitize_sql_like_pattern(account_filter)
where_clauses.append("tl.username LIKE ? ESCAPE '\\\\'")
params.append(f"%{safe_filter}%")
return " AND ".join(where_clauses), params
def _fetch_task_stats_row(cursor, *, where_clause: str = "", params: tuple | list = ()) -> dict:
sql = _TASK_STATS_SELECT_SQL
if where_clause:
sql = f"{sql}\nWHERE {where_clause}"
cursor.execute(sql, params)
row = cursor.fetchone()
return {
"total_tasks": _stat_value(row, "total_tasks"),
"success_tasks": _stat_value(row, "success_tasks"),
"failed_tasks": _stat_value(row, "failed_tasks"),
"total_items": _stat_value(row, "total_items"),
"total_attachments": _stat_value(row, "total_attachments"),
}
def _fetch_user_run_stats_row(cursor, *, where_clause: str, params: tuple | list) -> dict:
sql = f"{_USER_RUN_STATS_SELECT_SQL}\nWHERE {where_clause}"
cursor.execute(sql, params)
row = cursor.fetchone()
return {
"completed": _stat_value(row, "completed"),
"failed": _stat_value(row, "failed"),
"total_items": _stat_value(row, "total_items"),
"total_attachments": _stat_value(row, "total_attachments"),
}
from db.utils import sanitize_sql_like_pattern
def create_task_log(
@@ -148,6 +25,8 @@ def create_task_log(
"""创建任务日志记录"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cst_tz = pytz.timezone("Asia/Shanghai")
cst_time = datetime.now(cst_tz).strftime("%Y-%m-%d %H:%M:%S")
cursor.execute(
"""
@@ -166,7 +45,7 @@ def create_task_log(
total_attachments,
error_message,
duration,
get_cst_now_str(),
cst_time,
source,
),
)
@@ -185,27 +64,54 @@ def get_task_logs(
account_filter=None,
):
"""获取任务日志列表(支持分页和多种筛选)"""
limit = _normalize_int(limit, 100, minimum=1)
offset = _normalize_int(offset, 0, minimum=0)
with db_pool.get_db() as conn:
cursor = conn.cursor()
where_sql, params = _build_task_logs_where_sql(
date_filter=date_filter,
status_filter=status_filter,
source_filter=source_filter,
user_id_filter=user_id_filter,
account_filter=account_filter,
)
where_clauses = ["1=1"]
params = []
if date_filter:
where_clauses.append("date(tl.created_at) = ?")
params.append(date_filter)
if status_filter:
where_clauses.append("tl.status = ?")
params.append(status_filter)
if source_filter:
source_filter = str(source_filter or "").strip()
# 兼容“虚拟来源”:用于筛选 user_scheduled:batch_xxx 这类动态值
if source_filter == "user_scheduled":
where_clauses.append("tl.source LIKE ? ESCAPE '\\\\'")
params.append("user_scheduled:%")
elif source_filter.endswith("*"):
prefix = source_filter[:-1]
safe_prefix = sanitize_sql_like_pattern(prefix)
where_clauses.append("tl.source LIKE ? ESCAPE '\\\\'")
params.append(f"{safe_prefix}%")
else:
where_clauses.append("tl.source = ?")
params.append(source_filter)
if user_id_filter:
where_clauses.append("tl.user_id = ?")
params.append(user_id_filter)
if account_filter:
safe_filter = sanitize_sql_like_pattern(account_filter)
where_clauses.append("tl.username LIKE ? ESCAPE '\\\\'")
params.append(f"%{safe_filter}%")
where_sql = " AND ".join(where_clauses)
count_sql = f"""
SELECT COUNT(*) as total
FROM task_logs tl
LEFT JOIN users u ON tl.user_id = u.id
WHERE {where_sql}
"""
cursor.execute(count_sql, params)
total = _stat_value(cursor.fetchone(), "total")
total = cursor.fetchone()["total"]
data_sql = f"""
SELECT
@@ -217,10 +123,9 @@ def get_task_logs(
ORDER BY tl.created_at DESC
LIMIT ? OFFSET ?
"""
data_params = list(params)
data_params.extend([limit, offset])
params.extend([limit, offset])
cursor.execute(data_sql, data_params)
cursor.execute(data_sql, params)
logs = [dict(row) for row in cursor.fetchall()]
return {"logs": logs, "total": total}
@@ -228,39 +133,61 @@ def get_task_logs(
def get_task_stats(date_filter=None):
"""获取任务统计信息"""
if date_filter is None:
date_filter = get_cst_now().strftime("%Y-%m-%d")
day_start, day_end = _build_day_bounds(date_filter)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cst_tz = pytz.timezone("Asia/Shanghai")
if day_start and day_end:
today_stats = _fetch_task_stats_row(
cursor,
where_clause="created_at >= ? AND created_at < ?",
params=(day_start, day_end),
)
else:
today_stats = _fetch_task_stats_row(
cursor,
where_clause="date(created_at) = ?",
params=(date_filter,),
)
if date_filter is None:
date_filter = datetime.now(cst_tz).strftime("%Y-%m-%d")
total_stats = _fetch_task_stats_row(cursor)
cursor.execute(
"""
SELECT
COUNT(*) as total_tasks,
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_tasks,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_tasks,
SUM(total_items) as total_items,
SUM(total_attachments) as total_attachments
FROM task_logs
WHERE date(created_at) = ?
""",
(date_filter,),
)
today_stats = cursor.fetchone()
return {"today": today_stats, "total": total_stats}
cursor.execute(
"""
SELECT
COUNT(*) as total_tasks,
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_tasks,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_tasks,
SUM(total_items) as total_items,
SUM(total_attachments) as total_attachments
FROM task_logs
"""
)
total_stats = cursor.fetchone()
return {
"today": {
"total_tasks": today_stats["total_tasks"] or 0,
"success_tasks": today_stats["success_tasks"] or 0,
"failed_tasks": today_stats["failed_tasks"] or 0,
"total_items": today_stats["total_items"] or 0,
"total_attachments": today_stats["total_attachments"] or 0,
},
"total": {
"total_tasks": total_stats["total_tasks"] or 0,
"success_tasks": total_stats["success_tasks"] or 0,
"failed_tasks": total_stats["failed_tasks"] or 0,
"total_items": total_stats["total_items"] or 0,
"total_attachments": total_stats["total_attachments"] or 0,
},
}
def delete_old_task_logs(days=30, batch_size=1000):
"""删除N天前的任务日志分批删除避免长时间锁表"""
days = _normalize_int(days, 30, minimum=0)
batch_size = _normalize_int(batch_size, 1000, minimum=1)
cutoff = (get_cst_now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
total_deleted = 0
while True:
with db_pool.get_db() as conn:
@@ -270,16 +197,16 @@ def delete_old_task_logs(days=30, batch_size=1000):
DELETE FROM task_logs
WHERE rowid IN (
SELECT rowid FROM task_logs
WHERE created_at < ?
WHERE created_at < datetime('now', 'localtime', '-' || ? || ' days')
LIMIT ?
)
""",
(cutoff, batch_size),
(days, batch_size),
)
deleted = cursor.rowcount
conn.commit()
if deleted <= 0:
if deleted == 0:
break
total_deleted += deleted
@@ -288,23 +215,31 @@ def delete_old_task_logs(days=30, batch_size=1000):
def get_user_run_stats(user_id, date_filter=None):
"""获取用户的运行统计信息"""
if date_filter is None:
date_filter = get_cst_now().strftime("%Y-%m-%d")
day_start, day_end = _build_day_bounds(date_filter)
with db_pool.get_db() as conn:
cst_tz = pytz.timezone("Asia/Shanghai")
cursor = conn.cursor()
if day_start and day_end:
return _fetch_user_run_stats_row(
cursor,
where_clause="user_id = ? AND created_at >= ? AND created_at < ?",
params=(user_id, day_start, day_end),
)
if date_filter is None:
date_filter = datetime.now(cst_tz).strftime("%Y-%m-%d")
return _fetch_user_run_stats_row(
cursor,
where_clause="user_id = ? AND date(created_at) = ?",
params=(user_id, date_filter),
cursor.execute(
"""
SELECT
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
SUM(total_items) as total_items,
SUM(total_attachments) as total_attachments
FROM task_logs
WHERE user_id = ? AND date(created_at) = ?
""",
(user_id, date_filter),
)
stats = cursor.fetchone()
return {
"completed": stats["completed"] or 0,
"failed": stats["failed"] or 0,
"total_items": stats["total_items"] or 0,
"total_attachments": stats["total_attachments"] or 0,
}

View File

@@ -16,62 +16,8 @@ from password_utils import (
verify_password_bcrypt,
verify_password_sha256,
)
logger = get_logger(__name__)
_CST_TZ = pytz.timezone("Asia/Shanghai")
_PERMANENT_VIP_EXPIRE = "2099-12-31 23:59:59"
_USER_LOOKUP_SQL = {
"id": "SELECT * FROM users WHERE id = ?",
"username": "SELECT * FROM users WHERE username = ?",
}
_USER_ADMIN_SAFE_COLUMNS = (
"id",
"username",
"email",
"email_verified",
"email_notify_enabled",
"kdocs_unit",
"kdocs_auto_upload",
"status",
"vip_expire_time",
"created_at",
"approved_at",
)
_USER_ADMIN_SAFE_COLUMNS_SQL = ", ".join(_USER_ADMIN_SAFE_COLUMNS)
def _row_to_dict(row):
return dict(row) if row else None
def _get_user_by_field(field_name: str, field_value):
query_sql = _USER_LOOKUP_SQL.get(str(field_name or ""))
if not query_sql:
raise ValueError(f"unsupported user lookup field: {field_name}")
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(query_sql, (field_value,))
return _row_to_dict(cursor.fetchone())
def _parse_cst_datetime(datetime_str: str | None):
if not datetime_str:
return None
try:
naive_dt = datetime.strptime(str(datetime_str), "%Y-%m-%d %H:%M:%S")
return _CST_TZ.localize(naive_dt)
except Exception:
return None
def _format_vip_expire(days: int, *, base_dt: datetime | None = None) -> str:
if int(days) == 999999:
return _PERMANENT_VIP_EXPIRE
if base_dt is None:
base_dt = datetime.now(_CST_TZ)
return (base_dt + timedelta(days=int(days))).strftime("%Y-%m-%d %H:%M:%S")
def get_vip_config():
"""获取VIP配置"""
@@ -86,12 +32,13 @@ def set_default_vip_days(days):
"""设置默认VIP天数"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cst_time = get_cst_now_str()
cursor.execute(
"""
INSERT OR REPLACE INTO vip_config (id, default_vip_days, updated_at)
VALUES (1, ?, ?)
""",
(days, get_cst_now_str()),
(days, cst_time),
)
conn.commit()
return True
@@ -100,8 +47,14 @@ def set_default_vip_days(days):
def set_user_vip(user_id, days):
"""设置用户VIP - days: 7=一周, 30=一个月, 365=一年, 999999=永久"""
with db_pool.get_db() as conn:
cst_tz = pytz.timezone("Asia/Shanghai")
cursor = conn.cursor()
expire_time = _format_vip_expire(days)
if days == 999999:
expire_time = "2099-12-31 23:59:59"
else:
expire_time = (datetime.now(cst_tz) + timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
cursor.execute("UPDATE users SET vip_expire_time = ? WHERE id = ?", (expire_time, user_id))
conn.commit()
return cursor.rowcount > 0
@@ -110,26 +63,29 @@ def set_user_vip(user_id, days):
def extend_user_vip(user_id, days):
"""延长用户VIP时间"""
user = get_user_by_id(user_id)
cst_tz = pytz.timezone("Asia/Shanghai")
if not user:
return False
current_expire = user.get("vip_expire_time")
now_dt = datetime.now(_CST_TZ)
if current_expire and current_expire != _PERMANENT_VIP_EXPIRE:
expire_time = _parse_cst_datetime(current_expire)
if expire_time is not None:
if expire_time < now_dt:
expire_time = now_dt
new_expire = _format_vip_expire(days, base_dt=expire_time)
else:
logger.warning("解析VIP过期时间失败使用当前时间")
new_expire = _format_vip_expire(days, base_dt=now_dt)
else:
new_expire = _format_vip_expire(days, base_dt=now_dt)
with db_pool.get_db() as conn:
cursor = conn.cursor()
current_expire = user.get("vip_expire_time")
if current_expire and current_expire != "2099-12-31 23:59:59":
try:
expire_time_naive = datetime.strptime(current_expire, "%Y-%m-%d %H:%M:%S")
expire_time = cst_tz.localize(expire_time_naive)
now = datetime.now(cst_tz)
if expire_time < now:
expire_time = now
new_expire = (expire_time + timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, AttributeError) as e:
logger.warning(f"解析VIP过期时间失败: {e}, 使用当前时间")
new_expire = (datetime.now(cst_tz) + timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
else:
new_expire = (datetime.now(cst_tz) + timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
cursor.execute("UPDATE users SET vip_expire_time = ? WHERE id = ?", (new_expire, user_id))
conn.commit()
return cursor.rowcount > 0
@@ -149,49 +105,45 @@ def is_user_vip(user_id):
注意数据库中存储的时间统一使用CSTAsia/Shanghai时区
"""
cst_tz = pytz.timezone("Asia/Shanghai")
user = get_user_by_id(user_id)
if not user:
if not user or not user.get("vip_expire_time"):
return False
vip_expire_time = user.get("vip_expire_time")
if not vip_expire_time:
try:
expire_time_naive = datetime.strptime(user["vip_expire_time"], "%Y-%m-%d %H:%M:%S")
expire_time = cst_tz.localize(expire_time_naive)
now = datetime.now(cst_tz)
return now < expire_time
except (ValueError, AttributeError) as e:
logger.warning(f"检查VIP状态失败 (user_id={user_id}): {e}")
return False
expire_time = _parse_cst_datetime(vip_expire_time)
if expire_time is None:
logger.warning(f"检查VIP状态失败 (user_id={user_id}): 无法解析时间")
return False
return datetime.now(_CST_TZ) < expire_time
def get_user_vip_info(user_id):
"""获取用户VIP信息"""
cst_tz = pytz.timezone("Asia/Shanghai")
user = get_user_by_id(user_id)
if not user:
return {"is_vip": False, "expire_time": None, "days_left": 0, "username": ""}
vip_expire_time = user.get("vip_expire_time")
username = user.get("username", "")
if not vip_expire_time:
return {"is_vip": False, "expire_time": None, "days_left": 0, "username": username}
return {"is_vip": False, "expire_time": None, "days_left": 0, "username": user.get("username", "")}
expire_time = _parse_cst_datetime(vip_expire_time)
if expire_time is None:
logger.warning("VIP信息获取错误: 无法解析过期时间")
return {"is_vip": False, "expire_time": None, "days_left": 0, "username": username}
try:
expire_time_naive = datetime.strptime(vip_expire_time, "%Y-%m-%d %H:%M:%S")
expire_time = cst_tz.localize(expire_time_naive)
now = datetime.now(cst_tz)
is_vip = now < expire_time
days_left = (expire_time - now).days if is_vip else 0
now_dt = datetime.now(_CST_TZ)
is_vip = now_dt < expire_time
days_left = (expire_time - now_dt).days if is_vip else 0
return {
"username": username,
"is_vip": is_vip,
"expire_time": vip_expire_time,
"days_left": max(0, days_left),
}
return {"username": user.get("username", ""), "is_vip": is_vip, "expire_time": vip_expire_time, "days_left": max(0, days_left)}
except Exception as e:
logger.warning(f"VIP信息获取错误: {e}")
return {"is_vip": False, "expire_time": None, "days_left": 0, "username": user.get("username", "")}
# ==================== 用户相关 ====================
@@ -199,6 +151,8 @@ def get_user_vip_info(user_id):
def create_user(username, password, email=""):
"""创建新用户(默认直接通过,赠送默认VIP)"""
cst_tz = pytz.timezone("Asia/Shanghai")
with db_pool.get_db() as conn:
cursor = conn.cursor()
password_hash = hash_password_bcrypt(password)
@@ -206,8 +160,12 @@ def create_user(username, password, email=""):
default_vip_days = get_vip_config()["default_vip_days"]
vip_expire_time = None
if int(default_vip_days or 0) > 0:
vip_expire_time = _format_vip_expire(int(default_vip_days))
if default_vip_days > 0:
if default_vip_days == 999999:
vip_expire_time = "2099-12-31 23:59:59"
else:
vip_expire_time = (datetime.now(cst_tz) + timedelta(days=default_vip_days)).strftime("%Y-%m-%d %H:%M:%S")
try:
cursor.execute(
@@ -252,28 +210,28 @@ def verify_user(username, password):
def get_user_by_id(user_id):
"""根据ID获取用户"""
return _get_user_by_field("id", user_id)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
user = cursor.fetchone()
return dict(user) if user else None
def get_user_kdocs_settings(user_id):
"""获取用户的金山文档配置"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT kdocs_unit, kdocs_auto_upload FROM users WHERE id = ?", (user_id,))
row = cursor.fetchone()
if not row:
return None
return {
"kdocs_unit": (row["kdocs_unit"] or "") if isinstance(row, dict) else (row[0] or ""),
"kdocs_auto_upload": 1 if ((row["kdocs_auto_upload"] if isinstance(row, dict) else row[1]) or 0) else 0,
}
user = get_user_by_id(user_id)
if not user:
return None
return {
"kdocs_unit": user.get("kdocs_unit") or "",
"kdocs_auto_upload": 1 if user.get("kdocs_auto_upload") else 0,
}
def update_user_kdocs_settings(user_id, *, kdocs_unit=None, kdocs_auto_upload=None) -> bool:
"""更新用户的金山文档配置"""
updates = []
params = []
if kdocs_unit is not None:
updates.append("kdocs_unit = ?")
params.append(kdocs_unit)
@@ -294,66 +252,26 @@ def update_user_kdocs_settings(user_id, *, kdocs_unit=None, kdocs_auto_upload=No
def get_user_by_username(username):
"""根据用户名获取用户"""
return _get_user_by_field("username", username)
def _normalize_limit_offset(limit, offset, *, max_limit: int = 500):
normalized_limit = None
if limit is not None:
try:
normalized_limit = int(limit)
except (TypeError, ValueError):
normalized_limit = 50
normalized_limit = max(1, min(normalized_limit, max_limit))
try:
normalized_offset = int(offset or 0)
except (TypeError, ValueError):
normalized_offset = 0
normalized_offset = max(0, normalized_offset)
return normalized_limit, normalized_offset
def get_users_count(*, status: str | None = None) -> int:
with db_pool.get_db() as conn:
cursor = conn.cursor()
if status:
cursor.execute("SELECT COUNT(*) AS count FROM users WHERE status = ?", (status,))
else:
cursor.execute("SELECT COUNT(*) AS count FROM users")
row = cursor.fetchone()
return int((row["count"] if row else 0) or 0)
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
user = cursor.fetchone()
return dict(user) if user else None
def get_all_users(*, limit=None, offset=0):
def get_all_users():
"""获取所有用户"""
limit, offset = _normalize_limit_offset(limit, offset)
with db_pool.get_db() as conn:
cursor = conn.cursor()
sql = f"SELECT {_USER_ADMIN_SAFE_COLUMNS_SQL} FROM users ORDER BY created_at DESC"
params = []
if limit is not None:
sql += " LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor.execute(sql, params)
cursor.execute("SELECT * FROM users ORDER BY created_at DESC")
return [dict(row) for row in cursor.fetchall()]
def get_pending_users(*, limit=None, offset=0):
def get_pending_users():
"""获取待审核用户"""
limit, offset = _normalize_limit_offset(limit, offset)
with db_pool.get_db() as conn:
cursor = conn.cursor()
sql = (
f"SELECT {_USER_ADMIN_SAFE_COLUMNS_SQL} "
"FROM users WHERE status = 'pending' ORDER BY created_at DESC"
)
params = []
if limit is not None:
sql += " LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor.execute(sql, params)
cursor.execute("SELECT * FROM users WHERE status = 'pending' ORDER BY created_at DESC")
return [dict(row) for row in cursor.fetchall()]
@@ -361,13 +279,14 @@ def approve_user(user_id):
"""审核通过用户"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cst_time = get_cst_now_str()
cursor.execute(
"""
UPDATE users
SET status = 'approved', approved_at = ?
WHERE id = ?
""",
(get_cst_now_str(), user_id),
(cst_time, user_id),
)
conn.commit()
return cursor.rowcount > 0
@@ -396,5 +315,5 @@ def get_user_stats(user_id):
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) as count FROM accounts WHERE user_id = ?", (user_id,))
row = cursor.fetchone()
return {"account_count": int((row["count"] if row else 0) or 0)}
account_count = cursor.fetchone()["count"]
return {"account_count": account_count}

View File

@@ -7,149 +7,8 @@
import sqlite3
import threading
from queue import Queue, Empty
import time
from queue import Empty, Full, Queue
from app_config import get_config
from app_logger import get_logger
logger = get_logger("database")
config = get_config()
DB_CONNECT_TIMEOUT_SECONDS = max(1, int(getattr(config, "DB_CONNECT_TIMEOUT_SECONDS", 10)))
DB_BUSY_TIMEOUT_MS = max(1000, int(getattr(config, "DB_BUSY_TIMEOUT_MS", 10000)))
DB_CACHE_SIZE_KB = max(1024, int(getattr(config, "DB_CACHE_SIZE_KB", 8192)))
DB_WAL_AUTOCHECKPOINT_PAGES = max(100, int(getattr(config, "DB_WAL_AUTOCHECKPOINT_PAGES", 1000)))
DB_MMAP_SIZE_MB = max(0, int(getattr(config, "DB_MMAP_SIZE_MB", 256)))
DB_LOCK_RETRY_COUNT = max(0, int(getattr(config, "DB_LOCK_RETRY_COUNT", 3)))
DB_LOCK_RETRY_BASE_MS = max(10, int(getattr(config, "DB_LOCK_RETRY_BASE_MS", 50)))
DB_SLOW_QUERY_MS = max(0, int(getattr(config, "DB_SLOW_QUERY_MS", 120)))
DB_SLOW_QUERY_SQL_MAX_LEN = max(80, int(getattr(config, "DB_SLOW_QUERY_SQL_MAX_LEN", 240)))
_slow_query_runtime_lock = threading.Lock()
_slow_query_runtime_threshold_ms = DB_SLOW_QUERY_MS
_slow_query_runtime_sql_max_len = DB_SLOW_QUERY_SQL_MAX_LEN
def _get_slow_query_runtime_values() -> tuple[int, int]:
with _slow_query_runtime_lock:
return int(_slow_query_runtime_threshold_ms), int(_slow_query_runtime_sql_max_len)
def get_slow_query_runtime() -> dict:
threshold_ms, sql_max_len = _get_slow_query_runtime_values()
return {"threshold_ms": threshold_ms, "sql_max_len": sql_max_len}
def configure_slow_query_runtime(*, threshold_ms=None, sql_max_len=None) -> dict:
global _slow_query_runtime_threshold_ms, _slow_query_runtime_sql_max_len
with _slow_query_runtime_lock:
if threshold_ms is not None:
_slow_query_runtime_threshold_ms = max(0, int(threshold_ms))
if sql_max_len is not None:
_slow_query_runtime_sql_max_len = max(80, int(sql_max_len))
runtime_threshold_ms = int(_slow_query_runtime_threshold_ms)
runtime_sql_max_len = int(_slow_query_runtime_sql_max_len)
try:
from services.slow_sql_metrics import configure_slow_sql_runtime
configure_slow_sql_runtime(
threshold_ms=runtime_threshold_ms,
sql_max_len=runtime_sql_max_len,
)
except Exception:
pass
return {"threshold_ms": runtime_threshold_ms, "sql_max_len": runtime_sql_max_len}
def _is_lock_conflict_error(error: sqlite3.OperationalError) -> bool:
message = str(error or "").lower()
return ("locked" in message) or ("busy" in message)
def _compact_sql(sql: str) -> str:
_, sql_max_len = _get_slow_query_runtime_values()
statement = " ".join(str(sql or "").split())
if len(statement) <= sql_max_len:
return statement
return statement[: sql_max_len - 3] + "..."
def _describe_params(parameters) -> str:
if parameters is None:
return "none"
if isinstance(parameters, dict):
return f"dict[{len(parameters)}]"
if isinstance(parameters, (list, tuple)):
return f"{type(parameters).__name__}[{len(parameters)}]"
return type(parameters).__name__
class TracedCursor:
"""带慢查询检测的游标包装器"""
def __init__(self, cursor, on_query_executed):
self._cursor = cursor
self._on_query_executed = on_query_executed
def _trace(self, sql, parameters, execute_fn):
start = time.perf_counter()
try:
execute_fn()
finally:
elapsed_ms = (time.perf_counter() - start) * 1000.0
try:
self._on_query_executed(sql, parameters, elapsed_ms)
except Exception:
pass
def execute(self, sql, parameters=None):
if parameters is None:
self._trace(sql, None, lambda: self._cursor.execute(sql))
else:
self._trace(sql, parameters, lambda: self._cursor.execute(sql, parameters))
return self
def executemany(self, sql, seq_of_parameters):
self._trace(sql, seq_of_parameters, lambda: self._cursor.executemany(sql, seq_of_parameters))
return self
def executescript(self, sql_script):
self._trace(sql_script, None, lambda: self._cursor.executescript(sql_script))
return self
def fetchone(self):
return self._cursor.fetchone()
def fetchall(self):
return self._cursor.fetchall()
def fetchmany(self, size=None):
if size is None:
return self._cursor.fetchmany()
return self._cursor.fetchmany(size)
def close(self):
return self._cursor.close()
@property
def rowcount(self):
return self._cursor.rowcount
@property
def lastrowid(self):
return self._cursor.lastrowid
def __iter__(self):
return iter(self._cursor)
def __getattr__(self, item):
return getattr(self._cursor, item)
class ConnectionPool:
@@ -183,70 +42,14 @@ class ConnectionPool:
def _create_connection(self):
"""创建新的数据库连接"""
conn = sqlite3.connect(
self.database,
check_same_thread=False,
timeout=DB_CONNECT_TIMEOUT_SECONDS,
)
conn = sqlite3.connect(self.database, check_same_thread=False)
conn.row_factory = sqlite3.Row
pragma_statements = [
"PRAGMA foreign_keys=ON",
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
f"PRAGMA busy_timeout={DB_BUSY_TIMEOUT_MS}",
"PRAGMA temp_store=MEMORY",
f"PRAGMA cache_size={-DB_CACHE_SIZE_KB}",
f"PRAGMA wal_autocheckpoint={DB_WAL_AUTOCHECKPOINT_PAGES}",
]
if DB_MMAP_SIZE_MB > 0:
pragma_statements.append(f"PRAGMA mmap_size={DB_MMAP_SIZE_MB * 1024 * 1024}")
for statement in pragma_statements:
try:
conn.execute(statement)
except sqlite3.DatabaseError as e:
logger.warning(f"设置数据库参数失败 ({statement}): {e}")
# 设置WAL模式提高并发性能
conn.execute('PRAGMA journal_mode=WAL')
# 设置合理的超时时间
conn.execute('PRAGMA busy_timeout=5000')
return conn
def _close_connection(self, conn) -> None:
if conn is None:
return
try:
conn.close()
except Exception as e:
logger.warning(f"关闭连接失败: {e}")
def _is_connection_healthy(self, conn) -> bool:
if conn is None:
return False
try:
conn.rollback()
conn.execute("SELECT 1")
return True
except sqlite3.Error as e:
logger.warning(f"连接健康检查失败(数据库错误): {e}")
except Exception as e:
logger.warning(f"连接健康检查失败(未知错误): {e}")
return False
def _replenish_pool_if_needed(self) -> None:
with self._lock:
if self._pool.qsize() >= self.pool_size:
return
new_conn = None
try:
new_conn = self._create_connection()
self._pool.put(new_conn, block=False)
self._created_connections += 1
except Full:
if new_conn:
self._close_connection(new_conn)
except Exception as e:
if new_conn:
self._close_connection(new_conn)
logger.warning(f"重建连接失败: {e}")
def get_connection(self):
"""
从连接池获取连接
@@ -267,20 +70,57 @@ class ConnectionPool:
Args:
conn: 要归还的连接
"""
import sqlite3
from queue import Full
if conn is None:
return
if self._is_connection_healthy(conn):
connection_healthy = False
try:
# 回滚任何未提交的事务
conn.rollback()
# 安全修复:验证连接是否健康,防止损坏的连接污染连接池
conn.execute("SELECT 1")
connection_healthy = True
except sqlite3.Error as e:
# 数据库相关错误,连接可能损坏
print(f"连接健康检查失败(数据库错误): {e}")
except Exception as e:
print(f"连接健康检查失败(未知错误): {e}")
if connection_healthy:
try:
self._pool.put(conn, block=False)
return
return # 成功归还
except Full:
logger.warning("连接池已满,关闭多余连接")
self._close_connection(conn)
return
# 队列已满(不应该发生,但处理它)
print(f"警告: 连接池已满,关闭多余连接")
connection_healthy = False # 标记为需要关闭
self._close_connection(conn)
self._replenish_pool_if_needed()
# 连接不健康或队列已满,关闭它
try:
conn.close()
except Exception as close_error:
print(f"关闭连接失败: {close_error}")
# 如果连接不健康,尝试创建新连接补充池
if not connection_healthy:
with self._lock:
# 双重检查:确保池确实需要补充
if self._pool.qsize() < self.pool_size:
try:
new_conn = self._create_connection()
self._created_connections += 1
self._pool.put(new_conn, block=False)
except Full:
# 在获取锁期间池被填满了,关闭新建的连接
try:
new_conn.close()
except Exception:
pass
except Exception as create_error:
print(f"重建连接失败: {create_error}")
def close_all(self):
"""关闭所有连接"""
@@ -289,15 +129,15 @@ class ConnectionPool:
conn = self._pool.get(block=False)
conn.close()
except Exception as e:
logger.warning(f"关闭连接失败: {e}")
print(f"关闭连接失败: {e}")
def get_stats(self):
"""获取连接池统计信息"""
return {
"pool_size": self.pool_size,
"available": self._pool.qsize(),
"in_use": self.pool_size - self._pool.qsize(),
"total_created": self._created_connections,
'pool_size': self.pool_size,
'available': self._pool.qsize(),
'in_use': self.pool_size - self._pool.qsize(),
'total_created': self._created_connections
}
@@ -324,60 +164,31 @@ class PooledConnection:
"""with语句结束时自动归还连接 [已修复Bug#3]"""
try:
if exc_type is not None:
# 发生异常,回滚事务
self._conn.rollback()
logger.warning(f"数据库事务已回滚: {exc_type.__name__}")
print(f"数据库事务已回滚: {exc_type.__name__}")
# 注意: 不自动commit要求用户显式调用conn.commit()
if self._cursor is not None:
if self._cursor:
self._cursor.close()
self._cursor = None
except Exception as e:
logger.warning(f"关闭游标失败: {e}")
print(f"关闭游标失败: {e}")
finally:
# 归还连接
self._pool.return_connection(self._conn)
return False
def _on_query_executed(self, sql: str, parameters, elapsed_ms: float) -> None:
slow_query_ms, _ = _get_slow_query_runtime_values()
if slow_query_ms <= 0:
return
if elapsed_ms < slow_query_ms:
return
params_info = _describe_params(parameters)
try:
from services.slow_sql_metrics import record_slow_sql
record_slow_sql(sql=sql, duration_ms=elapsed_ms, params_info=params_info)
except Exception:
pass
logger.warning(f"[慢SQL] {elapsed_ms:.1f}ms sql=\"{_compact_sql(sql)}\" params={params_info}")
return False # 不抑制异常
def cursor(self):
"""获取游标"""
if self._cursor is None:
raw_cursor = self._conn.cursor()
self._cursor = TracedCursor(raw_cursor, self._on_query_executed)
self._cursor = self._conn.cursor()
return self._cursor
def commit(self):
"""提交事务"""
for attempt in range(DB_LOCK_RETRY_COUNT + 1):
try:
self._conn.commit()
return
except sqlite3.OperationalError as e:
if (not _is_lock_conflict_error(e)) or attempt >= DB_LOCK_RETRY_COUNT:
raise
sleep_seconds = (DB_LOCK_RETRY_BASE_MS * (2**attempt)) / 1000.0
logger.warning(
f"数据库提交遇到锁冲突,{sleep_seconds:.3f}s 后重试 "
f"({attempt + 1}/{DB_LOCK_RETRY_COUNT})"
)
time.sleep(sleep_seconds)
self._conn.commit()
def rollback(self):
"""回滚事务"""
@@ -386,9 +197,9 @@ class PooledConnection:
def execute(self, sql, parameters=None):
"""执行SQL"""
cursor = self.cursor()
if parameters is None:
return cursor.execute(sql)
return cursor.execute(sql, parameters)
if parameters:
return cursor.execute(sql, parameters)
return cursor.execute(sql)
def fetchone(self):
"""获取一行"""
@@ -434,7 +245,7 @@ def init_pool(database, pool_size=5):
with _pool_lock:
if _pool is None:
_pool = ConnectionPool(database, pool_size)
logger.info(f"[OK] 数据库连接池已初始化 (大小: {pool_size})")
print(f" 数据库连接池已初始化 (大小: {pool_size})")
def get_db():

View File

@@ -7,77 +7,51 @@ services:
ports:
- "51232:51233"
volumes:
- ./data:/app/data # 数据库持久化
- ./logs:/app/logs # 日志持久化
- ./截图:/app/截图 # 截图持久化
- /etc/localtime:/etc/localtime:ro # 时区同步
- ./static:/app/static # 静态文件(实时更新)
- ./templates:/app/templates # 模板文件(实时更新)
- ./app.py:/app/app.py # 主程序(实时更新)
- ./database.py:/app/database.py # 数据库模块(实时更新)
- ./crypto_utils.py:/app/crypto_utils.py # 加密工具(实时更新)
- ./data:/app/data
- ./logs:/app/logs
- ./截图:/app/截图
- ./playwright:/ms-playwright
- /etc/localtime:/etc/localtime:ro
- ./static:/app/static
- ./templates:/app/templates
- ./app.py:/app/app.py
- ./database.py:/app/database.py
# 代码热更新
- ./services:/app/services
- ./routes:/app/routes
- ./db:/app/db
- ./security:/app/security
- ./realtime:/app/realtime
- ./api_browser.py:/app/api_browser.py
- ./app_config.py:/app/app_config.py
- ./app_logger.py:/app/app_logger.py
- ./app_security.py:/app/app_security.py
- ./browser_pool_worker.py:/app/browser_pool_worker.py
- ./crypto_utils.py:/app/crypto_utils.py
- ./db_pool.py:/app/db_pool.py
- ./email_service.py:/app/email_service.py
- ./password_utils.py:/app/password_utils.py
- ./playwright_automation.py:/app/playwright_automation.py
- ./task_checkpoint.py:/app/task_checkpoint.py
dns:
- 223.5.5.5
- 114.114.114.114
- 119.29.29.29
environment:
- TZ=Asia/Shanghai
- PYTHONUNBUFFERED=1
# Flask 配置
- PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
- FLASK_ENV=production
- FLASK_DEBUG=false
- SOCKETIO_ASYNC_MODE=eventlet
# 服务器配置
- SERVER_HOST=0.0.0.0
- SERVER_PORT=51233
# 数据库配置
- DB_FILE=data/app_data.db
- DB_POOL_SIZE=5
- SYSTEM_CONFIG_CACHE_TTL_SECONDS=30
- DB_SLOW_QUERY_MS=120
- DB_SLOW_SQL_WINDOW_SECONDS=86400
- DB_SLOW_SQL_TOP_LIMIT=12
- DB_SLOW_SQL_RECENT_LIMIT=50
- DB_SLOW_SQL_MAX_EVENTS=20000
- ADMIN_SLOW_SQL_METRICS_CACHE_TTL_SECONDS=3
# 并发控制配置
- MAX_CONCURRENT_GLOBAL=2
- MAX_CONCURRENT_PER_ACCOUNT=1
- MAX_CONCURRENT_CONTEXTS=100
# 安全配置
# 加密密钥配置(重要!防止容器重建时丢失密钥)
- ENCRYPTION_KEY_RAW=${ENCRYPTION_KEY_RAW}
- SESSION_LIFETIME_HOURS=24
- SESSION_COOKIE_SECURE=true
- HTTPS_ENABLED=true
- MAX_CAPTCHA_ATTEMPTS=5
- MAX_IP_ATTEMPTS_PER_HOUR=10
# 日志配置
- LOG_LEVEL=INFO
- SECURITY_LOG_ALLOW_STRATEGY=0
- LOG_FILE=logs/app.log
- API_DIAGNOSTIC_LOG=0
- API_DIAGNOSTIC_SLOW_MS=0
# 状态推送节流(秒)
- STATUS_PUSH_INTERVAL_SECONDS=2
# wkhtmltoimage 截图配置
- WKHTMLTOIMAGE_FULL_PAGE=0
# 知识管理平台配置
- ZSGL_LOGIN_URL=https://postoa.aidunsoft.com/admin/login.aspx
- ZSGL_INDEX_URL_PATTERN=index.aspx
- PAGE_LOAD_TIMEOUT=60000
restart: unless-stopped
shm_size: 2gb # 为Chromium分配共享内存
# 内存和CPU资源限制
mem_limit: 4g # 硬限制:最大4GB内存
mem_reservation: 2g # 软限制:预留2GB
cpus: '2.0' # 限制使用2个CPU核心
# 健康检查(可选)
shm_size: 2gb
mem_limit: 4g
mem_reservation: 2g
cpus: '2.0'
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:51233 || exit 1"]
interval: 30s
interval: 5m
timeout: 10s
retries: 3
start_period: 40s

File diff suppressed because it is too large Load Diff

1591
playwright_automation.py Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import json
import os
import time
@@ -10,40 +9,8 @@ from services.runtime import get_logger, get_socketio
from services.state import safe_get_account, safe_iter_task_status_items
def _to_int(value, default: int = 0) -> int:
try:
return int(value)
except Exception:
return int(default)
def _payload_signature(payload: dict) -> str:
try:
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
except Exception:
return repr(payload)
def _should_emit(
*,
last_sig: str | None,
last_ts: float,
new_sig: str,
now_ts: float,
min_interval: float,
force_interval: float,
) -> bool:
if last_sig is None:
return True
if (now_ts - last_ts) >= force_interval:
return True
if new_sig != last_sig and (now_ts - last_ts) >= min_interval:
return True
return False
def status_push_worker() -> None:
"""后台线程:按间隔推送排队/运行中任务状态(变更驱动+心跳兜底)。"""
"""后台线程:按间隔推送排队/运行中任务状态更新(可节流)。"""
logger = get_logger()
try:
push_interval = float(os.environ.get("STATUS_PUSH_INTERVAL_SECONDS", "1"))
@@ -51,41 +18,18 @@ def status_push_worker() -> None:
push_interval = 1.0
push_interval = max(0.5, push_interval)
try:
queue_min_interval = float(os.environ.get("STATUS_PUSH_MIN_QUEUE_INTERVAL_SECONDS", str(push_interval)))
except Exception:
queue_min_interval = push_interval
queue_min_interval = max(push_interval, queue_min_interval)
try:
progress_min_interval = float(
os.environ.get("STATUS_PUSH_MIN_PROGRESS_INTERVAL_SECONDS", str(push_interval))
)
except Exception:
progress_min_interval = push_interval
progress_min_interval = max(push_interval, progress_min_interval)
try:
force_interval = float(os.environ.get("STATUS_PUSH_FORCE_INTERVAL_SECONDS", "10"))
except Exception:
force_interval = 10.0
force_interval = max(push_interval, force_interval)
socketio = get_socketio()
from services.tasks import get_task_scheduler
scheduler = get_task_scheduler()
emitted_state: dict[str, dict] = {}
while True:
try:
now_ts = time.time()
queue_snapshot = scheduler.get_queue_state_snapshot()
pending_total = int(queue_snapshot.get("pending_total", 0) or 0)
running_total = int(queue_snapshot.get("running_total", 0) or 0)
running_by_user = queue_snapshot.get("running_by_user") or {}
positions = queue_snapshot.get("positions") or {}
active_account_ids = set()
status_items = safe_iter_task_status_items()
for account_id, status_info in status_items:
@@ -95,15 +39,11 @@ def status_push_worker() -> None:
user_id = status_info.get("user_id")
if not user_id:
continue
active_account_ids.add(str(account_id))
account = safe_get_account(user_id, account_id)
if not account:
continue
user_id_int = _to_int(user_id)
account_data = account.to_dict()
pos = positions.get(account_id) or positions.get(str(account_id)) or {}
pos = positions.get(account_id) or {}
account_data.update(
{
"queue_pending_total": pending_total,
@@ -111,23 +51,10 @@ def status_push_worker() -> None:
"queue_ahead": pos.get("queue_ahead"),
"queue_position": pos.get("queue_position"),
"queue_is_vip": pos.get("is_vip"),
"queue_running_user": _to_int(running_by_user.get(user_id_int, running_by_user.get(str(user_id_int), 0))),
"queue_running_user": int(running_by_user.get(int(user_id), 0) or 0),
}
)
cache_entry = emitted_state.setdefault(str(account_id), {})
account_sig = _payload_signature(account_data)
if _should_emit(
last_sig=cache_entry.get("account_sig"),
last_ts=float(cache_entry.get("account_ts", 0) or 0),
new_sig=account_sig,
now_ts=now_ts,
min_interval=queue_min_interval,
force_interval=force_interval,
):
socketio.emit("account_update", account_data, room=f"user_{user_id}")
cache_entry["account_sig"] = account_sig
cache_entry["account_ts"] = now_ts
socketio.emit("account_update", account_data, room=f"user_{user_id}")
if status != "运行中":
continue
@@ -147,26 +74,9 @@ def status_push_worker() -> None:
"queue_running_total": running_total,
"queue_ahead": pos.get("queue_ahead"),
"queue_position": pos.get("queue_position"),
"queue_running_user": _to_int(running_by_user.get(user_id_int, running_by_user.get(str(user_id_int), 0))),
"queue_running_user": int(running_by_user.get(int(user_id), 0) or 0),
}
progress_sig = _payload_signature(progress_data)
if _should_emit(
last_sig=cache_entry.get("progress_sig"),
last_ts=float(cache_entry.get("progress_ts", 0) or 0),
new_sig=progress_sig,
now_ts=now_ts,
min_interval=progress_min_interval,
force_interval=force_interval,
):
socketio.emit("task_progress", progress_data, room=f"user_{user_id}")
cache_entry["progress_sig"] = progress_sig
cache_entry["progress_ts"] = now_ts
if emitted_state:
stale_ids = [account_id for account_id in emitted_state.keys() if account_id not in active_account_ids]
for account_id in stale_ids:
emitted_state.pop(account_id, None)
socketio.emit("task_progress", progress_data, room=f"user_{user_id}")
time.sleep(push_interval)
except Exception as e:

View File

@@ -6,11 +6,9 @@ schedule==1.2.0
psutil==5.9.6
pytz==2024.1
bcrypt==4.0.1
requests==2.32.3
requests==2.31.0
python-dotenv==1.0.0
beautifulsoup4==4.12.2
cryptography>=41.0.0
webauthn>=2.7.1
Pillow>=10.0.0
playwright==1.42.0
eventlet==0.36.1

View File

@@ -8,15 +8,6 @@ admin_api_bp = Blueprint("admin_api", __name__, url_prefix="/yuyx/api")
# Import side effects: register routes on blueprint
from routes.admin_api import core as _core # noqa: F401
from routes.admin_api import system_config_api as _system_config_api # noqa: F401
from routes.admin_api import operations_api as _operations_api # noqa: F401
from routes.admin_api import announcements_api as _announcements_api # noqa: F401
from routes.admin_api import users_api as _users_api # noqa: F401
from routes.admin_api import account_api as _account_api # noqa: F401
from routes.admin_api import feedback_api as _feedback_api # noqa: F401
from routes.admin_api import infra_api as _infra_api # noqa: F401
from routes.admin_api import tasks_api as _tasks_api # noqa: F401
from routes.admin_api import email_api as _email_api # noqa: F401
# Export security blueprint for app registration
from routes.admin_api.security import security_bp # noqa: F401

View File

@@ -1,83 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import database
from app_security import validate_password
from flask import jsonify, request, session
from routes.admin_api import admin_api_bp
from routes.decorators import admin_required
# ==================== 密码重置 / 反馈(管理员) ====================
@admin_api_bp.route("/admin/password", methods=["PUT"])
@admin_required
def update_admin_password():
"""修改管理员密码(要求提供当前密码并校验新密码强度)"""
data = request.json or {}
current_password = (data.get("current_password") or "").strip()
new_password = (data.get("new_password") or "").strip()
if not current_password:
return jsonify({"error": "当前密码不能为空"}), 400
if not new_password:
return jsonify({"error": "新密码不能为空"}), 400
if current_password == new_password:
return jsonify({"error": "新密码不能与当前密码相同"}), 400
is_valid, error_msg = validate_password(new_password)
if not is_valid:
return jsonify({"error": error_msg}), 400
username = session.get("admin_username")
if not username:
return jsonify({"error": "未登录"}), 401
admin = database.verify_admin(username, current_password)
if not admin:
return jsonify({"error": "当前密码错误"}), 401
if database.update_admin_password(username, new_password):
session["admin_reauth_until"] = 0
session.modified = True
return jsonify({"success": True})
return jsonify({"error": "修改失败"}), 400
@admin_api_bp.route("/admin/username", methods=["PUT"])
@admin_required
def update_admin_username():
"""修改管理员用户名"""
data = request.json or {}
new_username = (data.get("new_username") or "").strip()
if not new_username:
return jsonify({"error": "用户名不能为空"}), 400
old_username = session.get("admin_username")
if database.update_admin_username(old_username, new_username):
session["admin_username"] = new_username
return jsonify({"success": True})
return jsonify({"error": "修改失败,用户名可能已存在"}), 400
@admin_api_bp.route("/users/<int:user_id>/reset_password", methods=["POST"])
@admin_required
def admin_reset_password_route(user_id):
"""管理员直接重置用户密码(无需审核)"""
data = request.json or {}
new_password = (data.get("new_password") or "").strip()
if not new_password:
return jsonify({"error": "新密码不能为空"}), 400
is_valid, error_msg = validate_password(new_password)
if not is_valid:
return jsonify({"error": error_msg}), 400
if database.admin_reset_user_password(user_id, new_password):
return jsonify({"message": "密码重置成功"})
return jsonify({"error": "重置失败,用户不存在"}), 400

View File

@@ -1,144 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import os
import posixpath
import secrets
import time
import database
from app_config import get_config
from app_logger import get_logger
from app_security import is_safe_path, sanitize_filename
from flask import current_app, jsonify, request, url_for
from routes.admin_api import admin_api_bp
from routes.decorators import admin_required
logger = get_logger("app")
config = get_config()
def _get_upload_dir():
rel_dir = getattr(config, "ANNOUNCEMENT_IMAGE_DIR", "static/announcements")
if not is_safe_path(current_app.root_path, rel_dir):
rel_dir = "static/announcements"
abs_dir = os.path.join(current_app.root_path, rel_dir)
os.makedirs(abs_dir, exist_ok=True)
return abs_dir, rel_dir
def _get_file_size(file_storage):
try:
file_storage.stream.seek(0, os.SEEK_END)
size = file_storage.stream.tell()
file_storage.stream.seek(0)
return size
except Exception:
return None
# ==================== 公告管理API管理员 ====================
@admin_api_bp.route("/announcements/upload_image", methods=["POST"])
@admin_required
def admin_upload_announcement_image():
"""上传公告图片返回可访问URL"""
file = request.files.get("file")
if not file or not file.filename:
return jsonify({"error": "请选择图片"}), 400
filename = sanitize_filename(file.filename)
ext = os.path.splitext(filename)[1].lower()
allowed_exts = getattr(config, "ALLOWED_ANNOUNCEMENT_IMAGE_EXTENSIONS", {".png", ".jpg", ".jpeg"})
if not ext or ext not in allowed_exts:
return jsonify({"error": "不支持的图片格式"}), 400
if file.mimetype and not str(file.mimetype).startswith("image/"):
return jsonify({"error": "文件类型无效"}), 400
size = _get_file_size(file)
max_size = int(getattr(config, "MAX_ANNOUNCEMENT_IMAGE_SIZE", 5 * 1024 * 1024))
if size is not None and size > max_size:
max_mb = max_size // 1024 // 1024
return jsonify({"error": f"图片大小不能超过{max_mb}MB"}), 400
abs_dir, rel_dir = _get_upload_dir()
token = secrets.token_hex(6)
name = f"announcement_{int(time.time())}_{token}{ext}"
save_path = os.path.join(abs_dir, name)
file.save(save_path)
static_root = os.path.join(current_app.root_path, "static")
rel_to_static = os.path.relpath(abs_dir, static_root)
if rel_to_static.startswith(".."):
rel_to_static = "announcements"
url_path = posixpath.join(rel_to_static.replace(os.sep, "/"), name)
return jsonify({"success": True, "url": url_for("serve_static", filename=url_path)})
@admin_api_bp.route("/announcements", methods=["GET"])
@admin_required
def admin_get_announcements():
"""获取公告列表"""
try:
limit = int(request.args.get("limit", 50))
offset = int(request.args.get("offset", 0))
except (TypeError, ValueError):
limit, offset = 50, 0
limit = max(1, min(200, limit))
offset = max(0, offset)
return jsonify(database.get_announcements(limit=limit, offset=offset))
@admin_api_bp.route("/announcements", methods=["POST"])
@admin_required
def admin_create_announcement():
"""创建公告(默认启用并替换旧公告)"""
data = request.json or {}
title = (data.get("title") or "").strip()
content = (data.get("content") or "").strip()
image_url = (data.get("image_url") or "").strip()
is_active = bool(data.get("is_active", True))
if image_url and len(image_url) > 1000:
return jsonify({"error": "图片地址过长"}), 400
announcement_id = database.create_announcement(title, content, image_url=image_url, is_active=is_active)
if not announcement_id:
return jsonify({"error": "标题和内容不能为空"}), 400
return jsonify({"success": True, "id": announcement_id})
@admin_api_bp.route("/announcements/<int:announcement_id>/activate", methods=["POST"])
@admin_required
def admin_activate_announcement(announcement_id):
"""启用公告(会自动停用其他公告)"""
if not database.get_announcement_by_id(announcement_id):
return jsonify({"error": "公告不存在"}), 404
ok = database.set_announcement_active(announcement_id, True)
return jsonify({"success": ok})
@admin_api_bp.route("/announcements/<int:announcement_id>/deactivate", methods=["POST"])
@admin_required
def admin_deactivate_announcement(announcement_id):
"""停用公告"""
if not database.get_announcement_by_id(announcement_id):
return jsonify({"error": "公告不存在"}), 404
ok = database.set_announcement_active(announcement_id, False)
return jsonify({"success": ok})
@admin_api_bp.route("/announcements/<int:announcement_id>", methods=["DELETE"])
@admin_required
def admin_delete_announcement(announcement_id):
"""删除公告"""
if not database.get_announcement_by_id(announcement_id):
return jsonify({"error": "公告不存在"}), 404
ok = database.delete_announcement(announcement_id)
return jsonify({"success": ok})

File diff suppressed because it is too large Load Diff

View File

@@ -1,214 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import email_service
from app_logger import get_logger
from app_security import validate_email
from flask import jsonify, request
from routes.admin_api import admin_api_bp
from routes.decorators import admin_required
logger = get_logger("app")
@admin_api_bp.route("/email/settings", methods=["GET"])
@admin_required
def get_email_settings_api():
"""获取全局邮件设置"""
try:
settings = email_service.get_email_settings()
return jsonify(settings)
except Exception as e:
logger.error(f"获取邮件设置失败: {e}")
return jsonify({"error": "获取邮件设置失败"}), 500
@admin_api_bp.route("/email/settings", methods=["POST"])
@admin_required
def update_email_settings_api():
"""更新全局邮件设置"""
try:
data = request.json or {}
enabled = data.get("enabled", False)
failover_enabled = data.get("failover_enabled", True)
register_verify_enabled = data.get("register_verify_enabled")
login_alert_enabled = data.get("login_alert_enabled")
base_url = data.get("base_url")
task_notify_enabled = data.get("task_notify_enabled")
email_service.update_email_settings(
enabled=enabled,
failover_enabled=failover_enabled,
register_verify_enabled=register_verify_enabled,
login_alert_enabled=login_alert_enabled,
base_url=base_url,
task_notify_enabled=task_notify_enabled,
)
return jsonify({"success": True})
except Exception as e:
logger.error(f"更新邮件设置失败: {e}")
return jsonify({"error": "更新邮件设置失败"}), 500
@admin_api_bp.route("/smtp/configs", methods=["GET"])
@admin_required
def get_smtp_configs_api():
"""获取所有SMTP配置列表"""
try:
configs = email_service.get_smtp_configs(include_password=False)
return jsonify(configs)
except Exception as e:
logger.error(f"获取SMTP配置失败: {e}")
return jsonify({"error": "获取SMTP配置失败"}), 500
@admin_api_bp.route("/smtp/configs", methods=["POST"])
@admin_required
def create_smtp_config_api():
"""创建SMTP配置"""
try:
data = request.json or {}
if not data.get("host"):
return jsonify({"error": "SMTP服务器地址不能为空"}), 400
if not data.get("username"):
return jsonify({"error": "SMTP用户名不能为空"}), 400
config_id = email_service.create_smtp_config(data)
return jsonify({"success": True, "id": config_id})
except Exception as e:
logger.error(f"创建SMTP配置失败: {e}")
return jsonify({"error": "创建SMTP配置失败"}), 500
@admin_api_bp.route("/smtp/configs/<int:config_id>", methods=["GET"])
@admin_required
def get_smtp_config_api(config_id):
"""获取单个SMTP配置详情"""
try:
config_data = email_service.get_smtp_config(config_id, include_password=False)
if not config_data:
return jsonify({"error": "配置不存在"}), 404
return jsonify(config_data)
except Exception as e:
logger.error(f"获取SMTP配置失败: {e}")
return jsonify({"error": "获取SMTP配置失败"}), 500
@admin_api_bp.route("/smtp/configs/<int:config_id>", methods=["PUT"])
@admin_required
def update_smtp_config_api(config_id):
"""更新SMTP配置"""
try:
data = request.json or {}
if email_service.update_smtp_config(config_id, data):
return jsonify({"success": True})
return jsonify({"error": "更新失败"}), 400
except Exception as e:
logger.error(f"更新SMTP配置失败: {e}")
return jsonify({"error": "更新SMTP配置失败"}), 500
@admin_api_bp.route("/smtp/configs/<int:config_id>", methods=["DELETE"])
@admin_required
def delete_smtp_config_api(config_id):
"""删除SMTP配置"""
try:
if email_service.delete_smtp_config(config_id):
return jsonify({"success": True})
return jsonify({"error": "删除失败"}), 400
except Exception as e:
logger.error(f"删除SMTP配置失败: {e}")
return jsonify({"error": "删除SMTP配置失败"}), 500
@admin_api_bp.route("/smtp/configs/<int:config_id>/test", methods=["POST"])
@admin_required
def test_smtp_config_api(config_id):
"""测试SMTP配置"""
try:
data = request.json or {}
test_email = str(data.get("email", "") or "").strip()
if not test_email:
return jsonify({"error": "请提供测试邮箱"}), 400
is_valid, error_msg = validate_email(test_email)
if not is_valid:
return jsonify({"error": error_msg}), 400
result = email_service.test_smtp_config(config_id, test_email)
return jsonify(result)
except Exception as e:
logger.error(f"测试SMTP配置失败: {e}")
return jsonify({"success": False, "error": "测试SMTP配置失败"}), 500
@admin_api_bp.route("/smtp/configs/<int:config_id>/primary", methods=["POST"])
@admin_required
def set_primary_smtp_config_api(config_id):
"""设置主SMTP配置"""
try:
if email_service.set_primary_smtp_config(config_id):
return jsonify({"success": True})
return jsonify({"error": "设置失败"}), 400
except Exception as e:
logger.error(f"设置主SMTP配置失败: {e}")
return jsonify({"error": "设置主SMTP配置失败"}), 500
@admin_api_bp.route("/smtp/configs/primary/clear", methods=["POST"])
@admin_required
def clear_primary_smtp_config_api():
"""取消主SMTP配置"""
try:
email_service.clear_primary_smtp_config()
return jsonify({"success": True})
except Exception as e:
logger.error(f"取消主SMTP配置失败: {e}")
return jsonify({"error": "取消主SMTP配置失败"}), 500
@admin_api_bp.route("/email/stats", methods=["GET"])
@admin_required
def get_email_stats_api():
"""获取邮件发送统计"""
try:
stats = email_service.get_email_stats()
return jsonify(stats)
except Exception as e:
logger.error(f"获取邮件统计失败: {e}")
return jsonify({"error": "获取邮件统计失败"}), 500
@admin_api_bp.route("/email/logs", methods=["GET"])
@admin_required
def get_email_logs_api():
"""获取邮件发送日志"""
try:
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 20, type=int)
email_type = request.args.get("type", None)
status = request.args.get("status", None)
page_size = min(max(page_size, 10), 100)
result = email_service.get_email_logs(page, page_size, email_type, status)
return jsonify(result)
except Exception as e:
logger.error(f"获取邮件日志失败: {e}")
return jsonify({"error": "获取邮件日志失败"}), 500
@admin_api_bp.route("/email/logs/cleanup", methods=["POST"])
@admin_required
def cleanup_email_logs_api():
"""清理过期邮件日志"""
try:
data = request.json or {}
days = data.get("days", 30)
days = min(max(days, 7), 365)
deleted = email_service.cleanup_email_logs(days)
return jsonify({"success": True, "deleted": deleted})
except Exception as e:
logger.error(f"清理邮件日志失败: {e}")
return jsonify({"error": "清理邮件日志失败"}), 500

View File

@@ -1,58 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import database
from flask import jsonify, request
from routes.admin_api import admin_api_bp
from routes.decorators import admin_required
@admin_api_bp.route("/feedbacks", methods=["GET"])
@admin_required
def get_all_feedbacks():
"""管理员获取所有反馈"""
status = request.args.get("status")
try:
limit = int(request.args.get("limit", 100))
offset = int(request.args.get("offset", 0))
limit = min(max(1, limit), 1000)
offset = max(0, offset)
except (ValueError, TypeError):
return jsonify({"error": "无效的分页参数"}), 400
feedbacks = database.get_bug_feedbacks(limit=limit, offset=offset, status_filter=status)
stats = database.get_feedback_stats()
return jsonify({"feedbacks": feedbacks, "stats": stats})
@admin_api_bp.route("/feedbacks/<int:feedback_id>/reply", methods=["POST"])
@admin_required
def reply_to_feedback(feedback_id):
"""管理员回复反馈"""
data = request.get_json() or {}
reply = (data.get("reply") or "").strip()
if not reply:
return jsonify({"error": "回复内容不能为空"}), 400
if database.reply_feedback(feedback_id, reply):
return jsonify({"message": "回复成功"})
return jsonify({"error": "反馈不存在"}), 404
@admin_api_bp.route("/feedbacks/<int:feedback_id>/close", methods=["POST"])
@admin_required
def close_feedback_api(feedback_id):
"""管理员关闭反馈"""
if database.close_feedback(feedback_id):
return jsonify({"message": "已关闭"})
return jsonify({"error": "反馈不存在"}), 404
@admin_api_bp.route("/feedbacks/<int:feedback_id>", methods=["DELETE"])
@admin_required
def delete_feedback_api(feedback_id):
"""管理员删除反馈"""
if database.delete_feedback(feedback_id):
return jsonify({"message": "已删除"})
return jsonify({"error": "反馈不存在"}), 404

View File

@@ -1,353 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import os
import socket
import threading
import time
from datetime import datetime
import database
from app_logger import get_logger
from flask import jsonify, session
from routes.admin_api import admin_api_bp
from routes.decorators import admin_required
from services.request_metrics import get_request_metrics_snapshot
from services.slow_sql_metrics import get_slow_sql_metrics_snapshot
from services.time_utils import BEIJING_TZ, get_beijing_now
logger = get_logger("app")
_ADMIN_STATS_CACHE_TTL = max(1.0, float(os.environ.get("ADMIN_STATS_CACHE_TTL_SECONDS", "5")))
_admin_stats_cache: dict[str, object] = {"expires_at_monotonic": 0.0, "data": None}
_admin_stats_cache_lock = threading.Lock()
_DOCKER_STATS_CACHE_TTL = max(2.0, float(os.environ.get("ADMIN_DOCKER_STATS_CACHE_TTL_SECONDS", "5")))
_docker_stats_cache: dict[str, object] = {"expires_at_monotonic": 0.0, "data": None}
_docker_stats_cache_lock = threading.Lock()
_REQUEST_METRICS_CACHE_TTL = max(1.0, float(os.environ.get("ADMIN_REQUEST_METRICS_CACHE_TTL_SECONDS", "3")))
_request_metrics_cache: dict[str, object] = {"expires_at_monotonic": 0.0, "data": None}
_request_metrics_cache_lock = threading.Lock()
_SLOW_SQL_METRICS_CACHE_TTL = max(1.0, float(os.environ.get("ADMIN_SLOW_SQL_METRICS_CACHE_TTL_SECONDS", "3")))
_slow_sql_metrics_cache: dict[str, object] = {"expires_at_monotonic": 0.0, "data": None}
_slow_sql_metrics_cache_lock = threading.Lock()
def _get_system_stats_cached() -> dict:
now = time.monotonic()
with _admin_stats_cache_lock:
expires_at = float(_admin_stats_cache.get("expires_at_monotonic") or 0.0)
cached_data = _admin_stats_cache.get("data")
if isinstance(cached_data, dict) and now < expires_at:
return dict(cached_data)
fresh_data = database.get_system_stats() or {}
with _admin_stats_cache_lock:
_admin_stats_cache["data"] = dict(fresh_data)
_admin_stats_cache["expires_at_monotonic"] = now + _ADMIN_STATS_CACHE_TTL
return dict(fresh_data)
def _get_request_metrics_cached() -> dict:
now = time.monotonic()
with _request_metrics_cache_lock:
expires_at = float(_request_metrics_cache.get("expires_at_monotonic") or 0.0)
cached_data = _request_metrics_cache.get("data")
if isinstance(cached_data, dict) and now < expires_at:
return dict(cached_data)
fresh_data = get_request_metrics_snapshot() or {}
with _request_metrics_cache_lock:
_request_metrics_cache["data"] = dict(fresh_data)
_request_metrics_cache["expires_at_monotonic"] = now + _REQUEST_METRICS_CACHE_TTL
return dict(fresh_data)
def _get_slow_sql_metrics_cached() -> dict:
now = time.monotonic()
with _slow_sql_metrics_cache_lock:
expires_at = float(_slow_sql_metrics_cache.get("expires_at_monotonic") or 0.0)
cached_data = _slow_sql_metrics_cache.get("data")
if isinstance(cached_data, dict) and now < expires_at:
return dict(cached_data)
fresh_data = get_slow_sql_metrics_snapshot() or {}
with _slow_sql_metrics_cache_lock:
_slow_sql_metrics_cache["data"] = dict(fresh_data)
_slow_sql_metrics_cache["expires_at_monotonic"] = now + _SLOW_SQL_METRICS_CACHE_TTL
return dict(fresh_data)
@admin_api_bp.route("/stats", methods=["GET"])
@admin_required
def get_system_stats():
"""获取系统统计"""
stats = _get_system_stats_cached()
stats["admin_username"] = session.get("admin_username", "admin")
return jsonify(stats)
@admin_api_bp.route("/request_metrics", methods=["GET"])
@admin_required
def get_request_metrics():
"""获取请求级监控指标"""
try:
metrics = _get_request_metrics_cached()
return jsonify(metrics)
except Exception as e:
logger.exception(f"获取请求级监控指标失败: {e}")
return jsonify({"error": "获取请求级监控指标失败"}), 500
@admin_api_bp.route("/slow_sql_metrics", methods=["GET"])
@admin_required
def get_slow_sql_metrics():
"""获取慢 SQL 监控指标"""
try:
metrics = _get_slow_sql_metrics_cached()
return jsonify(metrics)
except Exception as e:
logger.exception(f"获取慢 SQL 监控指标失败: {e}")
return jsonify({"error": "获取慢 SQL 监控指标失败"}), 500
@admin_api_bp.route("/browser_pool/stats", methods=["GET"])
@admin_required
def get_browser_pool_stats():
"""获取截图线程池状态"""
try:
from browser_pool_worker import get_browser_worker_pool
pool = get_browser_worker_pool()
stats = pool.get_stats() or {}
worker_details = []
for worker in stats.get("workers") or []:
last_ts = float(worker.get("last_active_ts") or 0)
last_active_at = None
if last_ts > 0:
try:
last_active_at = datetime.fromtimestamp(last_ts, tz=BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
last_active_at = None
created_ts = worker.get("browser_created_at")
created_at = None
if created_ts:
try:
created_at = datetime.fromtimestamp(float(created_ts), tz=BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
created_at = None
worker_details.append(
{
"worker_id": worker.get("worker_id"),
"idle": bool(worker.get("idle")),
"has_browser": bool(worker.get("has_browser")),
"total_tasks": int(worker.get("total_tasks") or 0),
"failed_tasks": int(worker.get("failed_tasks") or 0),
"browser_use_count": int(worker.get("browser_use_count") or 0),
"browser_created_at": created_at,
"browser_created_ts": created_ts,
"last_active_at": last_active_at,
"last_active_ts": last_ts,
"thread_alive": bool(worker.get("thread_alive")),
}
)
total_workers = len(worker_details) if worker_details else int(stats.get("pool_size") or 0)
return jsonify(
{
"total_workers": total_workers,
"active_workers": int(stats.get("busy_workers") or 0),
"idle_workers": int(stats.get("idle_workers") or 0),
"queue_size": int(stats.get("queue_size") or 0),
"workers": worker_details,
"summary": {
"total_tasks": int(stats.get("total_tasks") or 0),
"failed_tasks": int(stats.get("failed_tasks") or 0),
"success_rate": stats.get("success_rate"),
},
"server_time_cst": get_beijing_now().strftime("%Y-%m-%d %H:%M:%S"),
}
)
except Exception as e:
logger.exception(f"[AdminAPI] 获取截图线程池状态失败: {e}")
return jsonify({"error": "获取截图线程池状态失败"}), 500
def _format_duration(seconds: int) -> str:
total = max(0, int(seconds or 0))
days = total // 86400
hours = (total % 86400) // 3600
minutes = (total % 3600) // 60
if days > 0:
return f"{days}{hours}小时{minutes}分钟"
if hours > 0:
return f"{hours}小时{minutes}分钟"
return f"{minutes}分钟"
def _fill_host_service_stats(docker_status: dict) -> None:
import psutil
process = psutil.Process(os.getpid())
memory_info = process.memory_info()
virtual_memory = psutil.virtual_memory()
rss_bytes = float(memory_info.rss or 0)
total_bytes = float(virtual_memory.total or 0)
memory_percent = (rss_bytes / total_bytes * 100.0) if total_bytes > 0 else 0.0
docker_status.update(
{
"running": True,
"status": "Host Service",
"container_name": f"host:{socket.gethostname()}",
"uptime": _format_duration(int(time.time() - float(process.create_time() or time.time()))),
"memory_usage": f"{rss_bytes / 1024 / 1024:.2f} MB",
"memory_limit": f"{total_bytes / 1024 / 1024 / 1024:.2f} GB" if total_bytes > 0 else "N/A",
"memory_percent": f"{memory_percent:.2f}%",
"cpu_percent": f"{max(0.0, float(process.cpu_percent(interval=0.1))):.2f}%",
}
)
@admin_api_bp.route("/docker_stats", methods=["GET"])
@admin_required
def get_docker_stats():
"""获取容器运行状态(非容器部署时返回当前服务进程状态)"""
now = time.monotonic()
with _docker_stats_cache_lock:
expires_at = float(_docker_stats_cache.get("expires_at_monotonic") or 0.0)
cached_data = _docker_stats_cache.get("data")
if isinstance(cached_data, dict) and now < expires_at:
return jsonify(dict(cached_data))
docker_status = {
"running": False,
"container_name": "N/A",
"uptime": "N/A",
"memory_usage": "N/A",
"memory_limit": "N/A",
"memory_percent": "N/A",
"cpu_percent": "N/A",
"status": "Unknown",
}
try:
if os.path.exists("/.dockerenv"):
docker_status["running"] = True
try:
with open("/etc/hostname", "r", encoding="utf-8") as f:
docker_status["container_name"] = f.read().strip() or "N/A"
except Exception as e:
logger.debug(f"读取容器名称失败: {e}")
try:
if os.path.exists("/sys/fs/cgroup/memory.current"):
with open("/sys/fs/cgroup/memory.current", "r", encoding="utf-8") as f:
mem_total = int(f.read().strip())
cache = 0
if os.path.exists("/sys/fs/cgroup/memory.stat"):
with open("/sys/fs/cgroup/memory.stat", "r", encoding="utf-8") as f:
for line in f:
if line.startswith("inactive_file "):
cache = int(line.split()[1])
break
mem_bytes = max(0, mem_total - cache)
docker_status["memory_usage"] = f"{mem_bytes / 1024 / 1024:.2f} MB"
if os.path.exists("/sys/fs/cgroup/memory.max"):
with open("/sys/fs/cgroup/memory.max", "r", encoding="utf-8") as f:
limit_str = f.read().strip()
if limit_str != "max":
limit_bytes = int(limit_str)
if limit_bytes > 0:
docker_status["memory_limit"] = f"{limit_bytes / 1024 / 1024 / 1024:.2f} GB"
docker_status["memory_percent"] = f"{mem_bytes / limit_bytes * 100:.2f}%"
elif os.path.exists("/sys/fs/cgroup/memory/memory.usage_in_bytes"):
with open("/sys/fs/cgroup/memory/memory.usage_in_bytes", "r", encoding="utf-8") as f:
mem_bytes = int(f.read().strip())
docker_status["memory_usage"] = f"{mem_bytes / 1024 / 1024:.2f} MB"
with open("/sys/fs/cgroup/memory/memory.limit_in_bytes", "r", encoding="utf-8") as f:
limit_bytes = int(f.read().strip())
if 0 < limit_bytes < 1e18:
docker_status["memory_limit"] = f"{limit_bytes / 1024 / 1024 / 1024:.2f} GB"
docker_status["memory_percent"] = f"{mem_bytes / limit_bytes * 100:.2f}%"
except Exception as e:
logger.debug(f"读取容器内存信息失败: {e}")
try:
if os.path.exists("/sys/fs/cgroup/cpu.stat"):
usage1 = 0
with open("/sys/fs/cgroup/cpu.stat", "r", encoding="utf-8") as f:
for line in f:
if line.startswith("usage_usec"):
usage1 = int(line.split()[1])
break
time.sleep(0.1)
usage2 = 0
with open("/sys/fs/cgroup/cpu.stat", "r", encoding="utf-8") as f:
for line in f:
if line.startswith("usage_usec"):
usage2 = int(line.split()[1])
break
cpu_percent = (usage2 - usage1) / 0.1 / 1e6 * 100
docker_status["cpu_percent"] = f"{max(0.0, cpu_percent):.2f}%"
elif os.path.exists("/sys/fs/cgroup/cpu/cpuacct.usage"):
with open("/sys/fs/cgroup/cpu/cpuacct.usage", "r", encoding="utf-8") as f:
usage1 = int(f.read().strip())
time.sleep(0.1)
with open("/sys/fs/cgroup/cpu/cpuacct.usage", "r", encoding="utf-8") as f:
usage2 = int(f.read().strip())
cpu_percent = (usage2 - usage1) / 0.1 / 1e9 * 100
docker_status["cpu_percent"] = f"{max(0.0, cpu_percent):.2f}%"
except Exception as e:
logger.debug(f"读取容器CPU信息失败: {e}")
try:
with open("/proc/uptime", "r", encoding="utf-8") as f:
system_uptime = float(f.read().split()[0])
with open("/proc/1/stat", "r", encoding="utf-8") as f:
stat = f.read().split()
starttime_jiffies = int(stat[21])
clk_tck = os.sysconf(os.sysconf_names["SC_CLK_TCK"])
uptime_seconds = int(system_uptime - (starttime_jiffies / clk_tck))
docker_status["uptime"] = _format_duration(uptime_seconds)
except Exception as e:
logger.debug(f"获取容器运行时间失败: {e}")
docker_status["status"] = "Running"
else:
_fill_host_service_stats(docker_status)
except Exception as e:
logger.exception(f"获取容器/服务状态失败: {e}")
docker_status["status"] = f"Error: {e}"
with _docker_stats_cache_lock:
_docker_stats_cache["data"] = dict(docker_status)
_docker_stats_cache["expires_at_monotonic"] = now + _DOCKER_STATS_CACHE_TTL
return jsonify(docker_status)

View File

@@ -1,244 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import threading
import time
from datetime import datetime
import database
import requests
from app_logger import get_logger
from app_security import is_safe_outbound_url
from flask import jsonify, request
from routes.admin_api import admin_api_bp
from routes.decorators import admin_required
from services.scheduler import run_scheduled_task
from services.time_utils import BEIJING_TZ, get_beijing_now
logger = get_logger("app")
_server_cpu_percent_lock = threading.Lock()
_server_cpu_percent_last: float | None = None
_server_cpu_percent_last_ts = 0.0
def _get_server_cpu_percent() -> float:
import psutil
global _server_cpu_percent_last, _server_cpu_percent_last_ts
now = time.time()
with _server_cpu_percent_lock:
if _server_cpu_percent_last is not None and (now - _server_cpu_percent_last_ts) < 0.5:
return _server_cpu_percent_last
try:
if _server_cpu_percent_last is None:
cpu_percent = float(psutil.cpu_percent(interval=0.1))
else:
cpu_percent = float(psutil.cpu_percent(interval=None))
except Exception:
cpu_percent = float(_server_cpu_percent_last or 0.0)
if cpu_percent < 0:
cpu_percent = 0.0
_server_cpu_percent_last = cpu_percent
_server_cpu_percent_last_ts = now
return cpu_percent
@admin_api_bp.route("/kdocs/status", methods=["GET"])
@admin_required
def get_kdocs_status_api():
"""获取金山文档上传状态"""
try:
from services.kdocs_uploader import get_kdocs_uploader
uploader = get_kdocs_uploader()
status = uploader.get_status()
live = str(request.args.get("live", "")).lower() in ("1", "true", "yes")
# 仅在显式 live=1 时做实时状态校验,默认返回缓存状态,避免阻塞页面加载
should_live_check = live
if should_live_check:
live_status = uploader.refresh_login_status()
if live_status.get("success"):
logged_in = bool(live_status.get("logged_in"))
status["logged_in"] = logged_in
status["last_login_ok"] = logged_in
status["login_required"] = not logged_in
if live_status.get("error"):
status["last_error"] = live_status.get("error")
else:
status["logged_in"] = True if status.get("last_login_ok") else False if status.get("last_login_ok") is False else None
if status.get("last_login_ok") is True and status.get("last_error") == "操作超时":
status["last_error"] = None
return jsonify(status)
except Exception as e:
return jsonify({"error": f"获取状态失败: {e}"}), 500
@admin_api_bp.route("/kdocs/qr", methods=["POST"])
@admin_required
def get_kdocs_qr_api():
"""获取金山文档登录二维码"""
try:
from services.kdocs_uploader import get_kdocs_uploader
uploader = get_kdocs_uploader()
data = request.get_json(silent=True) or {}
force = bool(data.get("force"))
if not force:
force = str(request.args.get("force", "")).lower() in ("1", "true", "yes")
result = uploader.request_qr(force=force)
if not result.get("success"):
return jsonify({"error": result.get("error", "获取二维码失败")}), 400
return jsonify(result)
except Exception as e:
return jsonify({"error": f"获取二维码失败: {e}"}), 500
@admin_api_bp.route("/kdocs/clear-login", methods=["POST"])
@admin_required
def clear_kdocs_login_api():
"""清除金山文档登录态"""
try:
from services.kdocs_uploader import get_kdocs_uploader
uploader = get_kdocs_uploader()
result = uploader.clear_login()
if not result.get("success"):
return jsonify({"error": result.get("error", "清除失败")}), 400
return jsonify({"success": True})
except Exception as e:
return jsonify({"error": f"清除失败: {e}"}), 500
@admin_api_bp.route("/schedule/execute", methods=["POST"])
@admin_required
def execute_schedule_now():
"""立即执行定时任务(无视定时时间和星期限制)"""
try:
threading.Thread(target=run_scheduled_task, args=(True,), daemon=True).start()
logger.info("[立即执行定时任务] 管理员手动触发定时任务执行(跳过星期检查)")
return jsonify({"message": "定时任务已开始执行,请查看任务列表获取进度"})
except Exception as e:
logger.error(f"[立即执行定时任务] 启动失败: {str(e)}")
return jsonify({"error": f"启动失败: {str(e)}"}), 500
@admin_api_bp.route("/proxy/config", methods=["GET"])
@admin_required
def get_proxy_config_api():
"""获取代理配置"""
config_data = database.get_system_config()
return jsonify(
{
"proxy_enabled": config_data.get("proxy_enabled", 0),
"proxy_api_url": config_data.get("proxy_api_url", ""),
"proxy_expire_minutes": config_data.get("proxy_expire_minutes", 3),
}
)
@admin_api_bp.route("/proxy/config", methods=["POST"])
@admin_required
def update_proxy_config_api():
"""更新代理配置"""
data = request.json or {}
proxy_enabled = data.get("proxy_enabled")
proxy_api_url = (data.get("proxy_api_url", "") or "").strip()
proxy_expire_minutes = data.get("proxy_expire_minutes")
if proxy_enabled is not None and proxy_enabled not in [0, 1]:
return jsonify({"error": "proxy_enabled必须是0或1"}), 400
if proxy_expire_minutes is not None:
if not isinstance(proxy_expire_minutes, int) or proxy_expire_minutes < 1:
return jsonify({"error": "代理有效期必须是大于0的整数"}), 400
if database.update_system_config(
proxy_enabled=proxy_enabled,
proxy_api_url=proxy_api_url,
proxy_expire_minutes=proxy_expire_minutes,
):
return jsonify({"message": "代理配置已更新"})
return jsonify({"error": "更新失败"}), 400
@admin_api_bp.route("/proxy/test", methods=["POST"])
@admin_required
def test_proxy_api():
"""测试代理连接"""
data = request.json or {}
api_url = (data.get("api_url") or "").strip()
if not api_url:
return jsonify({"error": "请提供API地址"}), 400
if not is_safe_outbound_url(api_url):
return jsonify({"error": "API地址不可用或不安全"}), 400
try:
response = requests.get(api_url, timeout=10)
if response.status_code == 200:
ip_port = response.text.strip()
if ip_port and ":" in ip_port:
return jsonify({"success": True, "proxy": ip_port, "message": f"代理获取成功: {ip_port}"})
return jsonify({"success": False, "message": f"代理格式错误: {ip_port}"}), 400
return jsonify({"success": False, "message": f"HTTP错误: {response.status_code}"}), 400
except Exception as e:
return jsonify({"success": False, "message": f"连接失败: {str(e)}"}), 500
@admin_api_bp.route("/server/info", methods=["GET"])
@admin_required
def get_server_info_api():
"""获取服务器信息"""
import psutil
cpu_percent = _get_server_cpu_percent()
memory = psutil.virtual_memory()
memory_total = f"{memory.total / (1024**3):.1f}GB"
memory_used = f"{memory.used / (1024**3):.1f}GB"
memory_percent = memory.percent
disk = psutil.disk_usage("/")
disk_total = f"{disk.total / (1024**3):.1f}GB"
disk_used = f"{disk.used / (1024**3):.1f}GB"
disk_percent = disk.percent
try:
process = psutil.Process()
process_start_at = datetime.fromtimestamp(process.create_time(), tz=BEIJING_TZ)
uptime_delta = get_beijing_now() - process_start_at
except Exception:
boot_time = datetime.fromtimestamp(psutil.boot_time(), tz=BEIJING_TZ)
uptime_delta = get_beijing_now() - boot_time
uptime_seconds = max(0, int(uptime_delta.total_seconds()))
days = uptime_seconds // 86400
hours = (uptime_seconds % 86400) // 3600
minutes = (uptime_seconds % 3600) // 60
if days > 0:
uptime = f"{days}{hours}小时"
elif hours > 0:
uptime = f"{hours}小时{minutes}分钟"
else:
uptime = f"{minutes}分钟"
return jsonify(
{
"cpu_percent": cpu_percent,
"memory_total": memory_total,
"memory_used": memory_used,
"memory_percent": memory_percent,
"disk_total": disk_total,
"disk_used": disk_used,
"disk_percent": disk_percent,
"uptime": uptime,
}
)

View File

@@ -62,19 +62,6 @@ def _parse_bool(value: Any) -> bool:
return text in {"1", "true", "yes", "y", "on"}
def _parse_int(value: Any, *, default: int | None = None, min_value: int | None = None) -> int | None:
try:
parsed = int(value)
except Exception:
parsed = default
if parsed is None:
return None
if min_value is not None:
parsed = max(int(min_value), parsed)
return parsed
def _sanitize_threat_event(event: dict) -> dict:
return {
"id": event.get("id"),
@@ -212,7 +199,10 @@ def ban_ip():
if not reason:
return jsonify({"error": "reason不能为空"}), 400
duration_hours = _parse_int(duration_hours_raw, default=24, min_value=1) or 24
try:
duration_hours = max(1, int(duration_hours_raw))
except Exception:
duration_hours = 24
ok = blacklist.ban_ip(ip, reason, duration_hours=duration_hours, permanent=permanent)
if not ok:
@@ -245,14 +235,20 @@ def ban_user():
duration_hours_raw = data.get("duration_hours", 24)
permanent = _parse_bool(data.get("permanent", False))
user_id = _parse_int(user_id_raw)
try:
user_id = int(user_id_raw)
except Exception:
user_id = None
if user_id is None:
return jsonify({"error": "user_id不能为空"}), 400
if not reason:
return jsonify({"error": "reason不能为空"}), 400
duration_hours = _parse_int(duration_hours_raw, default=24, min_value=1) or 24
try:
duration_hours = max(1, int(duration_hours_raw))
except Exception:
duration_hours = 24
ok = blacklist._ban_user_internal(user_id, reason=reason, duration_hours=duration_hours, permanent=permanent)
if not ok:
@@ -266,7 +262,10 @@ def unban_user():
"""解除用户封禁"""
data = _parse_json()
user_id_raw = data.get("user_id")
user_id = _parse_int(user_id_raw)
try:
user_id = int(user_id_raw)
except Exception:
user_id = None
if user_id is None:
return jsonify({"error": "user_id不能为空"}), 400

View File

@@ -1,248 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import database
from app_logger import get_logger
from app_security import is_safe_outbound_url, validate_email
from flask import jsonify, request
from routes.admin_api import admin_api_bp
from routes.decorators import admin_required
from services.browse_types import BROWSE_TYPE_SHOULD_READ, validate_browse_type
from services.tasks import get_task_scheduler
logger = get_logger("app")
@admin_api_bp.route("/system/config", methods=["GET"])
@admin_required
def get_system_config_api():
"""获取系统配置"""
return jsonify(database.get_system_config())
@admin_api_bp.route("/system/config", methods=["POST"])
@admin_required
def update_system_config_api():
"""更新系统配置"""
data = request.json or {}
max_concurrent = data.get("max_concurrent_global")
schedule_enabled = data.get("schedule_enabled")
schedule_time = data.get("schedule_time")
schedule_browse_type = data.get("schedule_browse_type")
schedule_weekdays = data.get("schedule_weekdays")
new_max_concurrent_per_account = data.get("max_concurrent_per_account")
new_max_screenshot_concurrent = data.get("max_screenshot_concurrent")
db_slow_query_ms = data.get("db_slow_query_ms")
enable_screenshot = data.get("enable_screenshot")
auto_approve_enabled = data.get("auto_approve_enabled")
auto_approve_hourly_limit = data.get("auto_approve_hourly_limit")
auto_approve_vip_days = data.get("auto_approve_vip_days")
kdocs_enabled = data.get("kdocs_enabled")
kdocs_doc_url = data.get("kdocs_doc_url")
kdocs_default_unit = data.get("kdocs_default_unit")
kdocs_sheet_name = data.get("kdocs_sheet_name")
kdocs_sheet_index = data.get("kdocs_sheet_index")
kdocs_unit_column = data.get("kdocs_unit_column")
kdocs_image_column = data.get("kdocs_image_column")
kdocs_admin_notify_enabled = data.get("kdocs_admin_notify_enabled")
kdocs_admin_notify_email = data.get("kdocs_admin_notify_email")
kdocs_row_start = data.get("kdocs_row_start")
kdocs_row_end = data.get("kdocs_row_end")
if max_concurrent is not None:
if not isinstance(max_concurrent, int) or max_concurrent < 1:
return jsonify({"error": "全局并发数必须大于0建议小型服务器2-5中型5-10大型10-20"}), 400
if new_max_concurrent_per_account is not None:
if not isinstance(new_max_concurrent_per_account, int) or new_max_concurrent_per_account < 1:
return jsonify({"error": "单账号并发数必须大于0建议设为1避免同一用户任务相互影响"}), 400
if new_max_screenshot_concurrent is not None:
if not isinstance(new_max_screenshot_concurrent, int) or new_max_screenshot_concurrent < 1:
return jsonify({"error": "截图并发数必须大于0建议根据服务器配置设置wkhtmltoimage 资源占用较低)"}), 400
if db_slow_query_ms is not None:
try:
db_slow_query_ms = int(db_slow_query_ms)
except (ValueError, TypeError):
return jsonify({"error": "慢 SQL 阈值必须是数字(毫秒)"}), 400
if db_slow_query_ms < 0 or db_slow_query_ms > 60000:
return jsonify({"error": "慢 SQL 阈值范围应在 0-60000 毫秒之间"}), 400
if enable_screenshot is not None:
if isinstance(enable_screenshot, bool):
enable_screenshot = 1 if enable_screenshot else 0
if enable_screenshot not in (0, 1):
return jsonify({"error": "截图开关必须是0或1"}), 400
if schedule_time is not None:
import re
if not re.match(r"^([01]\d|2[0-3]):([0-5]\d)$", schedule_time):
return jsonify({"error": "时间格式错误,应为 HH:MM"}), 400
if schedule_browse_type is not None:
normalized = validate_browse_type(schedule_browse_type, default=BROWSE_TYPE_SHOULD_READ)
if not normalized:
return jsonify({"error": "浏览类型无效"}), 400
schedule_browse_type = normalized
if schedule_weekdays is not None:
try:
days = [int(d.strip()) for d in schedule_weekdays.split(",") if d.strip()]
if not all(1 <= d <= 7 for d in days):
return jsonify({"error": "星期数字必须在1-7之间"}), 400
except (ValueError, AttributeError):
return jsonify({"error": "星期格式错误"}), 400
if auto_approve_hourly_limit is not None:
if not isinstance(auto_approve_hourly_limit, int) or auto_approve_hourly_limit < 1:
return jsonify({"error": "每小时注册限制必须大于0"}), 400
if auto_approve_vip_days is not None:
if not isinstance(auto_approve_vip_days, int) or auto_approve_vip_days < 0:
return jsonify({"error": "注册赠送VIP天数不能为负数"}), 400
if kdocs_enabled is not None:
if isinstance(kdocs_enabled, bool):
kdocs_enabled = 1 if kdocs_enabled else 0
if kdocs_enabled not in (0, 1):
return jsonify({"error": "表格上传开关必须是0或1"}), 400
if kdocs_doc_url is not None:
kdocs_doc_url = str(kdocs_doc_url or "").strip()
if kdocs_doc_url and not is_safe_outbound_url(kdocs_doc_url):
return jsonify({"error": "文档链接格式不正确"}), 400
if kdocs_default_unit is not None:
kdocs_default_unit = str(kdocs_default_unit or "").strip()
if len(kdocs_default_unit) > 50:
return jsonify({"error": "默认县区长度不能超过50"}), 400
if kdocs_sheet_name is not None:
kdocs_sheet_name = str(kdocs_sheet_name or "").strip()
if len(kdocs_sheet_name) > 50:
return jsonify({"error": "Sheet名称长度不能超过50"}), 400
if kdocs_sheet_index is not None:
try:
kdocs_sheet_index = int(kdocs_sheet_index)
except Exception:
return jsonify({"error": "Sheet序号必须是数字"}), 400
if kdocs_sheet_index < 0:
return jsonify({"error": "Sheet序号不能为负数"}), 400
if kdocs_unit_column is not None:
kdocs_unit_column = str(kdocs_unit_column or "").strip().upper()
if not kdocs_unit_column:
return jsonify({"error": "县区列不能为空"}), 400
import re
if not re.match(r"^[A-Z]{1,3}$", kdocs_unit_column):
return jsonify({"error": "县区列格式错误"}), 400
if kdocs_image_column is not None:
kdocs_image_column = str(kdocs_image_column or "").strip().upper()
if not kdocs_image_column:
return jsonify({"error": "图片列不能为空"}), 400
import re
if not re.match(r"^[A-Z]{1,3}$", kdocs_image_column):
return jsonify({"error": "图片列格式错误"}), 400
if kdocs_admin_notify_enabled is not None:
if isinstance(kdocs_admin_notify_enabled, bool):
kdocs_admin_notify_enabled = 1 if kdocs_admin_notify_enabled else 0
if kdocs_admin_notify_enabled not in (0, 1):
return jsonify({"error": "管理员通知开关必须是0或1"}), 400
if kdocs_admin_notify_email is not None:
kdocs_admin_notify_email = str(kdocs_admin_notify_email or "").strip()
if kdocs_admin_notify_email:
is_valid, error_msg = validate_email(kdocs_admin_notify_email)
if not is_valid:
return jsonify({"error": error_msg}), 400
if kdocs_row_start is not None:
try:
kdocs_row_start = int(kdocs_row_start)
except (ValueError, TypeError):
return jsonify({"error": "起始行必须是数字"}), 400
if kdocs_row_start < 0:
return jsonify({"error": "起始行不能为负数"}), 400
if kdocs_row_end is not None:
try:
kdocs_row_end = int(kdocs_row_end)
except (ValueError, TypeError):
return jsonify({"error": "结束行必须是数字"}), 400
if kdocs_row_end < 0:
return jsonify({"error": "结束行不能为负数"}), 400
old_config = database.get_system_config() or {}
if not database.update_system_config(
max_concurrent=max_concurrent,
schedule_enabled=schedule_enabled,
schedule_time=schedule_time,
schedule_browse_type=schedule_browse_type,
schedule_weekdays=schedule_weekdays,
max_concurrent_per_account=new_max_concurrent_per_account,
max_screenshot_concurrent=new_max_screenshot_concurrent,
enable_screenshot=enable_screenshot,
auto_approve_enabled=auto_approve_enabled,
auto_approve_hourly_limit=auto_approve_hourly_limit,
auto_approve_vip_days=auto_approve_vip_days,
kdocs_enabled=kdocs_enabled,
kdocs_doc_url=kdocs_doc_url,
kdocs_default_unit=kdocs_default_unit,
kdocs_sheet_name=kdocs_sheet_name,
kdocs_sheet_index=kdocs_sheet_index,
kdocs_unit_column=kdocs_unit_column,
kdocs_image_column=kdocs_image_column,
kdocs_admin_notify_enabled=kdocs_admin_notify_enabled,
kdocs_admin_notify_email=kdocs_admin_notify_email,
kdocs_row_start=kdocs_row_start,
kdocs_row_end=kdocs_row_end,
db_slow_query_ms=db_slow_query_ms,
):
return jsonify({"error": "更新失败"}), 400
try:
new_config = database.get_system_config() or {}
scheduler = get_task_scheduler()
scheduler.update_limits(
max_global=int(new_config.get("max_concurrent_global", old_config.get("max_concurrent_global", 2))),
max_per_user=int(new_config.get("max_concurrent_per_account", old_config.get("max_concurrent_per_account", 1))),
)
try:
import db_pool
db_pool.configure_slow_query_runtime(threshold_ms=new_config.get("db_slow_query_ms"))
except Exception as slow_sql_error:
logger.warning(f"慢 SQL 运行时阈值更新失败: {slow_sql_error}")
if new_max_screenshot_concurrent is not None:
try:
from browser_pool_worker import resize_browser_worker_pool
if resize_browser_worker_pool(int(new_config.get("max_screenshot_concurrent", new_max_screenshot_concurrent))):
logger.info(f"截图线程池并发已更新为: {new_config.get('max_screenshot_concurrent')}")
except Exception as pool_error:
logger.warning(f"截图线程池并发更新失败: {pool_error}")
except Exception:
pass
if max_concurrent is not None and max_concurrent != old_config.get("max_concurrent_global"):
logger.info(f"全局并发数已更新为: {max_concurrent}")
if new_max_concurrent_per_account is not None and new_max_concurrent_per_account != old_config.get("max_concurrent_per_account"):
logger.info(f"单用户并发数已更新为: {new_max_concurrent_per_account}")
if new_max_screenshot_concurrent is not None:
logger.info(f"截图并发数已更新为: {new_max_screenshot_concurrent}")
if db_slow_query_ms is not None:
logger.info(f"慢 SQL 阈值已更新为: {db_slow_query_ms}ms")
return jsonify({"message": "系统配置已更新"})

View File

@@ -1,138 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import database
from app_logger import get_logger
from flask import jsonify, request
from routes.admin_api import admin_api_bp
from routes.decorators import admin_required
from services.state import safe_iter_task_status_items
from services.tasks import get_task_scheduler
logger = get_logger("app")
def _parse_page_int(name: str, default: int, *, minimum: int, maximum: int) -> int:
try:
value = int(request.args.get(name, default))
return max(minimum, min(value, maximum))
except (ValueError, TypeError):
return default
@admin_api_bp.route("/task/stats", methods=["GET"])
@admin_required
def get_task_stats_api():
"""获取任务统计数据"""
date_filter = request.args.get("date")
stats = database.get_task_stats(date_filter)
return jsonify(stats)
@admin_api_bp.route("/task/running", methods=["GET"])
@admin_required
def get_running_tasks_api():
"""获取当前运行中和排队中的任务"""
import time as time_mod
current_time = time_mod.time()
running = []
queuing = []
user_cache = {}
for account_id, info in safe_iter_task_status_items():
elapsed = int(current_time - info.get("start_time", current_time))
info_user_id = info.get("user_id")
if info_user_id not in user_cache:
user_cache[info_user_id] = database.get_user_by_id(info_user_id)
user = user_cache.get(info_user_id)
user_username = user["username"] if user else "N/A"
progress = info.get("progress", {"items": 0, "attachments": 0})
task_info = {
"account_id": account_id,
"user_id": info.get("user_id"),
"user_username": user_username,
"username": info.get("username"),
"browse_type": info.get("browse_type"),
"source": info.get("source", "manual"),
"detail_status": info.get("detail_status", "未知"),
"progress_items": progress.get("items", 0),
"progress_attachments": progress.get("attachments", 0),
"elapsed_seconds": elapsed,
"elapsed_display": f"{elapsed // 60}{elapsed % 60}" if elapsed >= 60 else f"{elapsed}",
}
if info.get("status") == "运行中":
running.append(task_info)
else:
queuing.append(task_info)
running.sort(key=lambda x: x["elapsed_seconds"], reverse=True)
queuing.sort(key=lambda x: x["elapsed_seconds"], reverse=True)
try:
max_concurrent = int(get_task_scheduler().max_global)
except Exception:
max_concurrent = int((database.get_system_config() or {}).get("max_concurrent_global", 2))
return jsonify(
{
"running": running,
"queuing": queuing,
"running_count": len(running),
"queuing_count": len(queuing),
"max_concurrent": max_concurrent,
}
)
@admin_api_bp.route("/task/logs", methods=["GET"])
@admin_required
def get_task_logs_api():
"""获取任务日志列表(支持分页和多种筛选)"""
limit = _parse_page_int("limit", 20, minimum=1, maximum=200)
offset = _parse_page_int("offset", 0, minimum=0, maximum=10**9)
date_filter = request.args.get("date")
status_filter = request.args.get("status")
source_filter = request.args.get("source")
user_id_filter = request.args.get("user_id")
account_filter = (request.args.get("account") or "").strip()
if user_id_filter:
try:
user_id_filter = int(user_id_filter)
except (ValueError, TypeError):
user_id_filter = None
try:
result = database.get_task_logs(
limit=limit,
offset=offset,
date_filter=date_filter,
status_filter=status_filter,
source_filter=source_filter,
user_id_filter=user_id_filter,
account_filter=account_filter if account_filter else None,
)
return jsonify(result)
except Exception as e:
logger.error(f"获取任务日志失败: {e}")
return jsonify({"logs": [], "total": 0, "error": "查询失败"})
@admin_api_bp.route("/task/logs/clear", methods=["POST"])
@admin_required
def clear_old_task_logs_api():
"""清理旧的任务日志"""
data = request.json or {}
days = data.get("days", 30)
if not isinstance(days, int) or days < 1:
return jsonify({"error": "天数必须是大于0的整数"}), 400
deleted_count = database.delete_old_task_logs(days)
return jsonify({"message": f"已删除{days}天前的{deleted_count}条日志"})

180
routes/admin_api/update.py Normal file
View File

@@ -0,0 +1,180 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import os
import uuid
from flask import jsonify, request, session
from routes.admin_api import admin_api_bp
from routes.decorators import admin_required
from services.time_utils import get_beijing_now
from services.update_files import (
ensure_update_dirs,
get_update_job_log_path,
get_update_request_path,
get_update_result_path,
get_update_status_path,
load_json_file,
sanitize_job_id,
tail_text_file,
write_json_atomic,
)
def _request_ip() -> str:
try:
return request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or request.remote_addr or ""
except Exception:
return ""
def _make_job_id(prefix: str = "upd") -> str:
now_str = get_beijing_now().strftime("%Y%m%d_%H%M%S")
rand = uuid.uuid4().hex[:8]
return f"{prefix}_{now_str}_{rand}"
def _has_pending_request() -> bool:
try:
return os.path.exists(get_update_request_path())
except Exception:
return False
def _parse_bool_field(data: dict, key: str) -> bool | None:
if not isinstance(data, dict) or key not in data:
return None
value = data.get(key)
if isinstance(value, bool):
return value
if isinstance(value, int):
if value in (0, 1):
return bool(value)
raise ValueError(f"{key} 必须是 0/1 或 true/false")
if isinstance(value, str):
text = value.strip().lower()
if text in ("1", "true", "yes", "y", "on"):
return True
if text in ("0", "false", "no", "n", "off", ""):
return False
raise ValueError(f"{key} 必须是 0/1 或 true/false")
if value is None:
return None
raise ValueError(f"{key} 必须是 0/1 或 true/false")
@admin_api_bp.route("/update/status", methods=["GET"])
@admin_required
def get_update_status_api():
"""读取宿主机 Update-Agent 写入的 update/status.json。"""
ensure_update_dirs()
status_path = get_update_status_path()
data, err = load_json_file(status_path)
if err:
return jsonify({"ok": False, "error": f"读取 status 失败: {err}", "data": data}), 200
if not data:
return jsonify({"ok": False, "error": "未发现更新状态Update-Agent 可能未运行)"}), 200
data.setdefault("update_available", False)
return jsonify({"ok": True, "data": data}), 200
@admin_api_bp.route("/update/result", methods=["GET"])
@admin_required
def get_update_result_api():
"""读取 update/result.json最近一次更新执行结果"""
ensure_update_dirs()
result_path = get_update_result_path()
data, err = load_json_file(result_path)
if err:
return jsonify({"ok": False, "error": f"读取 result 失败: {err}", "data": data}), 200
if not data:
return jsonify({"ok": True, "data": None}), 200
return jsonify({"ok": True, "data": data}), 200
@admin_api_bp.route("/update/log", methods=["GET"])
@admin_required
def get_update_log_api():
"""读取 update/jobs/<job_id>.log 的末尾内容(用于后台展示进度)。"""
ensure_update_dirs()
job_id = sanitize_job_id(request.args.get("job_id"))
if not job_id:
# 若未指定,则尝试用 result.json 的 job_id
result_data, _ = load_json_file(get_update_result_path())
job_id = sanitize_job_id(result_data.get("job_id") if isinstance(result_data, dict) else None)
if not job_id:
return jsonify({"ok": True, "job_id": None, "log": "", "truncated": False}), 200
max_bytes = request.args.get("max_bytes", "200000")
try:
max_bytes_i = int(max_bytes)
except Exception:
max_bytes_i = 200_000
max_bytes_i = max(10_000, min(2_000_000, max_bytes_i))
log_path = get_update_job_log_path(job_id)
text, truncated = tail_text_file(log_path, max_bytes=max_bytes_i)
return jsonify({"ok": True, "job_id": job_id, "log": text, "truncated": truncated}), 200
@admin_api_bp.route("/update/check", methods=["POST"])
@admin_required
def request_update_check_api():
"""请求宿主机 Update-Agent 立刻执行一次检查更新。"""
ensure_update_dirs()
if _has_pending_request():
return jsonify({"error": "已有更新请求正在处理中,请稍后再试"}), 409
job_id = _make_job_id(prefix="chk")
payload = {
"job_id": job_id,
"action": "check",
"requested_at": get_beijing_now().strftime("%Y-%m-%d %H:%M:%S"),
"requested_by": session.get("admin_username") or "",
"requested_ip": _request_ip(),
}
write_json_atomic(get_update_request_path(), payload)
return jsonify({"success": True, "job_id": job_id}), 200
@admin_api_bp.route("/update/run", methods=["POST"])
@admin_required
def request_update_run_api():
"""请求宿主机 Update-Agent 执行一键更新并重启服务。"""
ensure_update_dirs()
if _has_pending_request():
return jsonify({"error": "已有更新请求正在处理中,请稍后再试"}), 409
data = request.json or {}
try:
build_no_cache = _parse_bool_field(data, "build_no_cache")
if build_no_cache is None:
build_no_cache = _parse_bool_field(data, "no_cache")
build_pull = _parse_bool_field(data, "build_pull")
if build_pull is None:
build_pull = _parse_bool_field(data, "pull")
except ValueError as e:
return jsonify({"error": str(e)}), 400
job_id = _make_job_id(prefix="upd")
payload = {
"job_id": job_id,
"action": "update",
"requested_at": get_beijing_now().strftime("%Y-%m-%d %H:%M:%S"),
"requested_by": session.get("admin_username") or "",
"requested_ip": _request_ip(),
"build_no_cache": bool(build_no_cache) if build_no_cache is not None else False,
"build_pull": bool(build_pull) if build_pull is not None else False,
}
write_json_atomic(get_update_request_path(), payload)
return jsonify(
{
"success": True,
"job_id": job_id,
"message": "已提交更新请求服务将重启页面可能短暂不可用请等待1-2分钟后刷新",
}
), 200

View File

@@ -1,149 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import database
from flask import jsonify, request
from routes.admin_api import admin_api_bp
from routes.decorators import admin_required
from services.state import safe_clear_user_logs, safe_remove_user_accounts
# ==================== 用户管理/统计(管理员) ====================
def _parse_optional_pagination(default_limit: int = 50, max_limit: int = 500) -> tuple[int | None, int]:
limit_raw = request.args.get("limit")
offset_raw = request.args.get("offset")
if (limit_raw is None) and (offset_raw is None):
return None, 0
try:
limit = int(limit_raw if limit_raw is not None else default_limit)
except (TypeError, ValueError):
limit = default_limit
limit = max(1, min(limit, max_limit))
try:
offset = int(offset_raw if offset_raw is not None else 0)
except (TypeError, ValueError):
offset = 0
offset = max(0, offset)
return limit, offset
@admin_api_bp.route("/users", methods=["GET"])
@admin_required
def get_all_users():
"""获取所有用户"""
limit, offset = _parse_optional_pagination()
if limit is None:
users = database.get_all_users()
return jsonify(users)
users = database.get_all_users(limit=limit, offset=offset)
total = database.get_users_count()
return jsonify({"items": users, "total": total, "limit": limit, "offset": offset})
@admin_api_bp.route("/users/pending", methods=["GET"])
@admin_required
def get_pending_users():
"""获取待审核用户"""
limit, offset = _parse_optional_pagination(default_limit=30, max_limit=200)
if limit is None:
users = database.get_pending_users()
return jsonify(users)
users = database.get_pending_users(limit=limit, offset=offset)
total = database.get_users_count(status="pending")
return jsonify({"items": users, "total": total, "limit": limit, "offset": offset})
@admin_api_bp.route("/users/<int:user_id>/approve", methods=["POST"])
@admin_required
def approve_user_route(user_id):
"""审核通过用户"""
if database.approve_user(user_id):
return jsonify({"success": True})
return jsonify({"error": "审核失败"}), 400
@admin_api_bp.route("/users/<int:user_id>/reject", methods=["POST"])
@admin_required
def reject_user_route(user_id):
"""拒绝用户"""
if database.reject_user(user_id):
return jsonify({"success": True})
return jsonify({"error": "拒绝失败"}), 400
@admin_api_bp.route("/users/<int:user_id>", methods=["DELETE"])
@admin_required
def delete_user_route(user_id):
"""删除用户"""
if database.delete_user(user_id):
safe_remove_user_accounts(user_id)
safe_clear_user_logs(user_id)
return jsonify({"success": True})
return jsonify({"error": "删除失败"}), 400
# ==================== VIP 管理(管理员) ====================
@admin_api_bp.route("/vip/config", methods=["GET"])
@admin_required
def get_vip_config_api():
"""获取VIP配置"""
config = database.get_vip_config()
return jsonify(config)
@admin_api_bp.route("/vip/config", methods=["POST"])
@admin_required
def set_vip_config_api():
"""设置默认VIP天数"""
data = request.json or {}
days = data.get("default_vip_days", 0)
if not isinstance(days, int) or days < 0:
return jsonify({"error": "VIP天数必须是非负整数"}), 400
database.set_default_vip_days(days)
return jsonify({"message": "VIP配置已更新", "default_vip_days": days})
@admin_api_bp.route("/users/<int:user_id>/vip", methods=["POST"])
@admin_required
def set_user_vip_api(user_id):
"""设置用户VIP"""
data = request.json or {}
days = data.get("days", 30)
valid_days = [7, 30, 365, 999999]
if days not in valid_days:
return jsonify({"error": "VIP天数必须是 7/30/365/999999 之一"}), 400
if database.set_user_vip(user_id, days):
vip_type = {7: "一周", 30: "一个月", 365: "一年", 999999: "永久"}[days]
return jsonify({"message": f"VIP设置成功: {vip_type}"})
return jsonify({"error": "设置失败,用户不存在"}), 400
@admin_api_bp.route("/users/<int:user_id>/vip", methods=["DELETE"])
@admin_required
def remove_user_vip_api(user_id):
"""移除用户VIP"""
if database.remove_user_vip(user_id):
return jsonify({"message": "VIP已移除"})
return jsonify({"error": "移除失败"}), 400
@admin_api_bp.route("/users/<int:user_id>/vip", methods=["GET"])
@admin_required
def get_user_vip_info_api(user_id):
"""获取用户VIP信息(管理员)"""
vip_info = database.get_user_vip_info(user_id)
return jsonify(vip_info)

View File

@@ -40,48 +40,6 @@ def _emit(event: str, data: object, *, room: str | None = None) -> None:
pass
def _emit_account_update(user_id: int, account) -> None:
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
def _request_json(default=None):
if default is None:
default = {}
data = request.get_json(silent=True)
return data if isinstance(data, dict) else default
def _ensure_accounts_loaded(user_id: int) -> dict:
accounts = safe_get_user_accounts_snapshot(user_id)
if accounts:
return accounts
load_user_accounts(user_id)
return safe_get_user_accounts_snapshot(user_id)
def _get_user_account(user_id: int, account_id: str, *, refresh_if_missing: bool = False):
account = safe_get_account(user_id, account_id)
if account or (not refresh_if_missing):
return account
load_user_accounts(user_id)
return safe_get_account(user_id, account_id)
def _validate_browse_type_input(raw_browse_type, *, default=BROWSE_TYPE_SHOULD_READ):
browse_type = validate_browse_type(raw_browse_type, default=default)
if not browse_type:
return None, (jsonify({"error": "浏览类型无效"}), 400)
return browse_type, None
def _cancel_pending_account_task(user_id: int, account_id: str) -> bool:
try:
scheduler = get_task_scheduler()
return bool(scheduler.cancel_pending_task(user_id=user_id, account_id=account_id))
except Exception:
return False
@api_accounts_bp.route("/api/accounts", methods=["GET"])
@login_required
def get_accounts():
@@ -91,7 +49,8 @@ def get_accounts():
accounts = safe_get_user_accounts_snapshot(user_id)
if refresh or not accounts:
accounts = _ensure_accounts_loaded(user_id)
load_user_accounts(user_id)
accounts = safe_get_user_accounts_snapshot(user_id)
return jsonify([acc.to_dict() for acc in accounts.values()])
@@ -104,18 +63,20 @@ def add_account():
current_count = len(database.get_user_accounts(user_id))
is_vip = database.is_user_vip(user_id)
if (not is_vip) and current_count >= 3:
if not is_vip and current_count >= 3:
return jsonify({"error": "普通用户最多添加3个账号升级VIP可无限添加"}), 403
data = _request_json()
username = str(data.get("username", "")).strip()
password = str(data.get("password", "")).strip()
remark = str(data.get("remark", "")).strip()[:200]
data = request.json
username = data.get("username", "").strip()
password = data.get("password", "").strip()
remark = data.get("remark", "").strip()[:200]
if not username or not password:
return jsonify({"error": "用户名和密码不能为空"}), 400
accounts = _ensure_accounts_loaded(user_id)
accounts = safe_get_user_accounts_snapshot(user_id)
if not accounts:
load_user_accounts(user_id)
accounts = safe_get_user_accounts_snapshot(user_id)
for acc in accounts.values():
if acc.username == username:
return jsonify({"error": f"账号 '{username}' 已存在"}), 400
@@ -131,7 +92,7 @@ def add_account():
safe_set_account(user_id, account_id, account)
log_to_client(f"添加账号: {username}", user_id)
_emit_account_update(user_id, account)
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
return jsonify(account.to_dict())
@@ -142,15 +103,15 @@ def update_account(account_id):
"""更新账号信息(密码等)"""
user_id = current_user.id
account = _get_user_account(user_id, account_id)
account = safe_get_account(user_id, account_id)
if not account:
return jsonify({"error": "账号不存在"}), 404
if account.is_running:
return jsonify({"error": "账号正在运行中,请先停止"}), 400
data = _request_json()
new_password = str(data.get("password", "")).strip()
data = request.json
new_password = data.get("password", "").strip()
new_remember = data.get("remember", account.remember)
if not new_password:
@@ -164,13 +125,11 @@ def update_account(account_id):
"""
UPDATE accounts
SET password = ?, remember = ?
WHERE id = ? AND user_id = ?
WHERE id = ?
""",
(encrypted_password, new_remember, account_id, user_id),
(encrypted_password, new_remember, account_id),
)
conn.commit()
if cursor.rowcount <= 0:
return jsonify({"error": "账号不存在或无权限"}), 404
database.reset_account_login_status(account_id)
logger.info(f"[账号更新] 用户 {user_id} 修改了账号 {account.username} 的密码,已重置登录状态")
@@ -188,7 +147,7 @@ def delete_account(account_id):
"""删除账号"""
user_id = current_user.id
account = _get_user_account(user_id, account_id)
account = safe_get_account(user_id, account_id)
if not account:
return jsonify({"error": "账号不存在"}), 404
@@ -200,6 +159,7 @@ def delete_account(account_id):
username = account.username
database.delete_account(account_id)
safe_remove_account(user_id, account_id)
log_to_client(f"删除账号: {username}", user_id)
@@ -236,12 +196,12 @@ def update_remark(account_id):
"""更新备注"""
user_id = current_user.id
account = _get_user_account(user_id, account_id)
account = safe_get_account(user_id, account_id)
if not account:
return jsonify({"error": "账号不存在"}), 404
data = _request_json()
remark = str(data.get("remark", "")).strip()[:200]
data = request.json
remark = data.get("remark", "").strip()[:200]
database.update_account_remark(account_id, remark)
@@ -257,18 +217,17 @@ def start_account(account_id):
"""启动账号任务"""
user_id = current_user.id
account = _get_user_account(user_id, account_id)
account = safe_get_account(user_id, account_id)
if not account:
return jsonify({"error": "账号不存在"}), 404
if account.is_running:
return jsonify({"error": "任务已在运行中"}), 400
data = _request_json()
browse_type, browse_error = _validate_browse_type_input(data.get("browse_type"), default=BROWSE_TYPE_SHOULD_READ)
if browse_error:
return browse_error
data = request.json or {}
browse_type = validate_browse_type(data.get("browse_type"), default=BROWSE_TYPE_SHOULD_READ)
if not browse_type:
return jsonify({"error": "浏览类型无效"}), 400
enable_screenshot = data.get("enable_screenshot", True)
ok, message = submit_account_task(
user_id=user_id,
@@ -290,7 +249,7 @@ def stop_account(account_id):
"""停止账号任务"""
user_id = current_user.id
account = _get_user_account(user_id, account_id)
account = safe_get_account(user_id, account_id)
if not account:
return jsonify({"error": "账号不存在"}), 404
@@ -300,16 +259,20 @@ def stop_account(account_id):
account.should_stop = True
account.status = "正在停止"
if _cancel_pending_account_task(user_id, account_id):
account.status = "已停止"
account.is_running = False
safe_remove_task_status(account_id)
_emit_account_update(user_id, account)
log_to_client(f"任务已取消: {account.username}", user_id)
return jsonify({"success": True, "canceled": True})
try:
scheduler = get_task_scheduler()
if scheduler.cancel_pending_task(user_id=user_id, account_id=account_id):
account.status = "已停止"
account.is_running = False
safe_remove_task_status(account_id)
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
log_to_client(f"任务已取消: {account.username}", user_id)
return jsonify({"success": True, "canceled": True})
except Exception:
pass
log_to_client(f"停止任务: {account.username}", user_id)
_emit_account_update(user_id, account)
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
return jsonify({"success": True})
@@ -320,20 +283,23 @@ def manual_screenshot(account_id):
"""手动为指定账号截图"""
user_id = current_user.id
account = _get_user_account(user_id, account_id, refresh_if_missing=True)
account = safe_get_account(user_id, account_id)
if not account:
load_user_accounts(user_id)
account = safe_get_account(user_id, account_id)
if not account:
return jsonify({"error": "账号不存在"}), 404
if account.is_running:
return jsonify({"error": "任务运行中,无法截图"}), 400
data = _request_json()
data = request.json or {}
requested_browse_type = data.get("browse_type", None)
if requested_browse_type is None:
browse_type = normalize_browse_type(account.last_browse_type)
else:
browse_type, browse_error = _validate_browse_type_input(requested_browse_type, default=BROWSE_TYPE_SHOULD_READ)
if browse_error:
return browse_error
browse_type = validate_browse_type(requested_browse_type, default=BROWSE_TYPE_SHOULD_READ)
if not browse_type:
return jsonify({"error": "浏览类型无效"}), 400
account.last_browse_type = browse_type
@@ -351,16 +317,12 @@ def manual_screenshot(account_id):
def batch_start_accounts():
"""批量启动账号"""
user_id = current_user.id
data = _request_json()
data = request.json or {}
account_ids = data.get("account_ids", [])
browse_type, browse_error = _validate_browse_type_input(
data.get("browse_type", BROWSE_TYPE_SHOULD_READ),
default=BROWSE_TYPE_SHOULD_READ,
)
if browse_error:
return browse_error
browse_type = validate_browse_type(data.get("browse_type", BROWSE_TYPE_SHOULD_READ), default=BROWSE_TYPE_SHOULD_READ)
if not browse_type:
return jsonify({"error": "浏览类型无效"}), 400
enable_screenshot = data.get("enable_screenshot", True)
if not account_ids:
@@ -369,10 +331,11 @@ def batch_start_accounts():
started = []
failed = []
_ensure_accounts_loaded(user_id)
if not safe_get_user_accounts_snapshot(user_id):
load_user_accounts(user_id)
for account_id in account_ids:
account = _get_user_account(user_id, account_id)
account = safe_get_account(user_id, account_id)
if not account:
failed.append({"id": account_id, "reason": "账号不存在"})
continue
@@ -394,13 +357,7 @@ def batch_start_accounts():
failed.append({"id": account_id, "reason": msg})
return jsonify(
{
"success": True,
"started_count": len(started),
"failed_count": len(failed),
"started": started,
"failed": failed,
}
{"success": True, "started_count": len(started), "failed_count": len(failed), "started": started, "failed": failed}
)
@@ -409,29 +366,39 @@ def batch_start_accounts():
def batch_stop_accounts():
"""批量停止账号"""
user_id = current_user.id
data = _request_json()
data = request.json
account_ids = data.get("account_ids", [])
if not account_ids:
return jsonify({"error": "请选择要停止的账号"}), 400
stopped = []
_ensure_accounts_loaded(user_id)
if not safe_get_user_accounts_snapshot(user_id):
load_user_accounts(user_id)
for account_id in account_ids:
account = _get_user_account(user_id, account_id)
if (not account) or (not account.is_running):
account = safe_get_account(user_id, account_id)
if not account:
continue
if not account.is_running:
continue
account.should_stop = True
account.status = "正在停止"
stopped.append(account_id)
if _cancel_pending_account_task(user_id, account_id):
account.status = "已停止"
account.is_running = False
safe_remove_task_status(account_id)
try:
scheduler = get_task_scheduler()
if scheduler.cancel_pending_task(user_id=user_id, account_id=account_id):
account.status = "已停止"
account.is_running = False
safe_remove_task_status(account_id)
except Exception:
pass
_emit_account_update(user_id, account)
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
return jsonify({"success": True, "stopped_count": len(stopped), "stopped": stopped})

View File

@@ -2,34 +2,20 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import base64
import json
import random
import secrets
import threading
import time
import uuid
from io import BytesIO
import database
import email_service
from app_config import get_config
from app_logger import get_logger
from app_security import get_rate_limit_ip, require_ip_not_locked, validate_email, validate_password, validate_username
from flask import Blueprint, jsonify, request, session
from flask import Blueprint, jsonify, redirect, render_template, request, url_for
from flask_login import login_required, login_user, logout_user
from routes.pages import render_app_spa_or_legacy
from services.accounts_service import load_user_accounts
from services.models import User
from services.passkeys import (
encode_credential_id,
get_expected_origins,
get_rp_id,
is_challenge_valid,
make_authentication_options,
normalize_device_name,
verify_authentication,
)
from services.state import (
check_ip_request_rate,
check_email_rate_limit,
@@ -53,176 +39,12 @@ config = get_config()
api_auth_bp = Blueprint("api_auth", __name__)
_CAPTCHA_FONT_PATHS = [
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
]
_CAPTCHA_FONT = None
_CAPTCHA_FONT_LOCK = threading.Lock()
_USER_PASSKEY_LOGIN_SESSION_KEY = "user_passkey_login_state"
def _get_json_payload() -> dict:
data = request.get_json(silent=True)
return data if isinstance(data, dict) else {}
def _load_captcha_font(image_font_module):
global _CAPTCHA_FONT
if _CAPTCHA_FONT is not None:
return _CAPTCHA_FONT
with _CAPTCHA_FONT_LOCK:
if _CAPTCHA_FONT is not None:
return _CAPTCHA_FONT
for font_path in _CAPTCHA_FONT_PATHS:
try:
_CAPTCHA_FONT = image_font_module.truetype(font_path, 42)
break
except Exception:
continue
if _CAPTCHA_FONT is None:
_CAPTCHA_FONT = image_font_module.load_default()
return _CAPTCHA_FONT
def _generate_captcha_image_data_uri(code: str) -> str:
from PIL import Image, ImageDraw, ImageFont
width, height = 160, 60
image = Image.new("RGB", (width, height), color=(255, 255, 255))
draw = ImageDraw.Draw(image)
for _ in range(6):
x1 = random.randint(0, width)
y1 = random.randint(0, height)
x2 = random.randint(0, width)
y2 = random.randint(0, height)
draw.line(
[(x1, y1), (x2, y2)],
fill=(random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)),
width=1,
)
for _ in range(80):
x = random.randint(0, width)
y = random.randint(0, height)
draw.point((x, y), fill=(random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)))
font = _load_captcha_font(ImageFont)
for i, char in enumerate(code):
x = 12 + i * 35 + random.randint(-3, 3)
y = random.randint(5, 12)
color = (random.randint(0, 150), random.randint(0, 150), random.randint(0, 150))
draw.text((x, y), char, font=font, fill=color)
buffer = BytesIO()
image.save(buffer, format="PNG")
img_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
return f"data:image/png;base64,{img_base64}"
def _with_vip_suffix(message: str, auto_approve_enabled: bool, auto_approve_vip_days: int) -> str:
if auto_approve_enabled and auto_approve_vip_days > 0:
return f"{message},赠送{auto_approve_vip_days}天VIP"
return message
def _verify_common_captcha(client_ip: str, captcha_session: str, captcha_code: str):
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if success:
return True, None
is_locked = record_failed_captcha(client_ip)
if is_locked:
return False, (jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429)
return False, (jsonify({"error": message}), 400)
def _verify_login_captcha_if_needed(
*,
captcha_required: bool,
captcha_session: str,
captcha_code: str,
client_ip: str,
username_key: str,
):
if not captcha_required:
return True, None
if not captcha_session or not captcha_code:
return False, (jsonify({"error": "请填写验证码", "need_captcha": True}), 400)
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if success:
return True, None
record_login_failure(client_ip, username_key)
return False, (jsonify({"error": message, "need_captcha": True}), 400)
def _send_password_reset_email_if_possible(email: str, username: str, user_id: int) -> None:
result = email_service.send_password_reset_email(email=email, username=username, user_id=user_id)
if not result["success"]:
logger.error(f"密码重置邮件发送失败: {result['error']}")
def _send_login_security_alert_if_needed(user: dict, username: str, client_ip: str) -> None:
try:
user_agent = request.headers.get("User-Agent", "")
context = database.record_login_context(user["id"], client_ip, user_agent)
if not context or (not context.get("new_ip") and not context.get("new_device")):
return
if not config.LOGIN_ALERT_ENABLED:
return
if not should_send_login_alert(user["id"], client_ip):
return
if not email_service.get_email_settings().get("login_alert_enabled", True):
return
user_info = database.get_user_by_id(user["id"]) or {}
if (not user_info.get("email")) or (not user_info.get("email_verified")):
return
if not database.get_user_email_notify(user["id"]):
return
email_service.send_security_alert_email(
email=user_info.get("email"),
username=user_info.get("username") or username,
ip_address=client_ip,
user_agent=user_agent,
new_ip=context.get("new_ip", False),
new_device=context.get("new_device", False),
user_id=user["id"],
)
except Exception as e:
logger.warning(f"发送登录安全提醒失败: user_id={user.get('id')}, error={e}")
def _parse_credential_payload(data: dict) -> dict | None:
credential = data.get("credential")
if isinstance(credential, dict):
return credential
if isinstance(credential, str):
try:
parsed = json.loads(credential)
return parsed if isinstance(parsed, dict) else None
except Exception:
return None
return None
@api_auth_bp.route("/api/register", methods=["POST"])
@require_ip_not_locked
def register():
"""用户注册"""
data = _get_json_payload()
data = request.json or {}
username = data.get("username", "").strip()
password = data.get("password", "").strip()
email = data.get("email", "").strip().lower()
@@ -245,9 +67,12 @@ def register():
if not allowed:
return jsonify({"error": error_msg}), 429
captcha_ok, captcha_error_response = _verify_common_captcha(client_ip, captcha_session, captcha_code)
if not captcha_ok:
return captcha_error_response
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if not success:
is_locked = record_failed_captcha(client_ip)
if is_locked:
return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
return jsonify({"error": message}), 400
email_settings = email_service.get_email_settings()
email_verify_enabled = email_settings.get("register_verify_enabled", False) and email_settings.get("enabled", False)
@@ -280,22 +105,20 @@ def register():
if email_verify_enabled and email:
result = email_service.send_register_verification_email(email=email, username=username, user_id=user_id)
if result["success"]:
message = _with_vip_suffix(
"注册成功!验证邮件已发送(可直接登录,建议完成邮箱验证)",
auto_approve_enabled,
auto_approve_vip_days,
)
message = "注册成功!验证邮件已发送(可直接登录,建议完成邮箱验证)"
if auto_approve_enabled and auto_approve_vip_days > 0:
message += f",赠送{auto_approve_vip_days}天VIP"
return jsonify({"success": True, "message": message, "need_verify": True})
logger.error(f"注册验证邮件发送失败: {result['error']}")
message = _with_vip_suffix(
f"注册成功,但验证邮件发送失败({result['error']})。你仍可直接登录",
auto_approve_enabled,
auto_approve_vip_days,
)
message = f"注册成功,但验证邮件发送失败({result['error']})。你仍可直接登录"
if auto_approve_enabled and auto_approve_vip_days > 0:
message += f",赠送{auto_approve_vip_days}天VIP"
return jsonify({"success": True, "message": message, "need_verify": True})
message = _with_vip_suffix("注册成功!可直接登录", auto_approve_enabled, auto_approve_vip_days)
message = "注册成功!可直接登录"
if auto_approve_enabled and auto_approve_vip_days > 0:
message += f",赠送{auto_approve_vip_days}天VIP"
return jsonify({"success": True, "message": message})
return jsonify({"error": "用户名已存在"}), 400
@@ -303,38 +126,20 @@ def register():
@api_auth_bp.route("/api/verify-email/<token>")
def verify_email(token):
"""验证邮箱 - 用户点击邮件中的链接"""
result = email_service.verify_email_token(token, email_service.EMAIL_TYPE_REGISTER, consume=False)
result = email_service.verify_email_token(token, email_service.EMAIL_TYPE_REGISTER)
if result:
token_id = result["token_id"]
user_id = result["user_id"]
email = result["email"]
if not database.approve_user(user_id):
logger.error(f"用户邮箱验证失败: 用户审核更新失败 user_id={user_id}")
error_message = "验证处理失败,请稍后重试"
spa_initial_state = {
"page": "verify_result",
"success": False,
"title": "验证失败",
"error_message": error_message,
"primary_label": "返回登录",
"primary_url": "/login",
}
return render_app_spa_or_legacy(
"verify_failed.html",
legacy_context={"error_message": error_message},
spa_initial_state=spa_initial_state,
)
database.approve_user(user_id)
system_config = database.get_system_config()
auto_approve_vip_days = system_config.get("auto_approve_vip_days", 7)
if auto_approve_vip_days > 0:
database.set_user_vip(user_id, auto_approve_vip_days)
if not email_service.consume_email_token(token_id):
logger.warning(f"用户邮箱验证后Token消费失败: user_id={user_id}")
logger.info(f"用户邮箱验证成功: user_id={user_id}")
logger.info(f"用户邮箱验证成功: user_id={user_id}, email={email}")
spa_initial_state = {
"page": "verify_result",
"success": True,
@@ -347,7 +152,7 @@ def verify_email(token):
}
return render_app_spa_or_legacy("verify_success.html", spa_initial_state=spa_initial_state)
logger.warning("邮箱验证失败: token无效或已过期")
logger.warning(f"邮箱验证失败: token={token[:20]}...")
error_message = "验证链接无效或已过期,请重新注册或申请重发验证邮件"
spa_initial_state = {
"page": "verify_result",
@@ -370,7 +175,7 @@ def verify_email(token):
@require_ip_not_locked
def resend_verify_email():
"""重发验证邮件"""
data = _get_json_payload()
data = request.json or {}
email = data.get("email", "").strip().lower()
captcha_session = data.get("captcha_session", "")
captcha_code = data.get("captcha", "").strip()
@@ -390,9 +195,12 @@ def resend_verify_email():
if not allowed:
return jsonify({"error": error_msg}), 429
captcha_ok, captcha_error_response = _verify_common_captcha(client_ip, captcha_session, captcha_code)
if not captcha_ok:
return captcha_error_response
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if not success:
is_locked = record_failed_captcha(client_ip)
if is_locked:
return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
return jsonify({"error": message}), 400
user = database.get_user_by_email(email)
if not user:
@@ -427,7 +235,7 @@ def get_email_verify_status():
@require_ip_not_locked
def forgot_password():
"""发送密码重置邮件"""
data = _get_json_payload()
data = request.json or {}
email = data.get("email", "").strip().lower()
username = data.get("username", "").strip()
captcha_session = data.get("captcha_session", "")
@@ -455,9 +263,12 @@ def forgot_password():
if not allowed:
return jsonify({"error": error_msg}), 429
captcha_ok, captcha_error_response = _verify_common_captcha(client_ip, captcha_session, captcha_code)
if not captcha_ok:
return captcha_error_response
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if not success:
is_locked = record_failed_captcha(client_ip)
if is_locked:
return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
return jsonify({"error": message}), 400
email_settings = email_service.get_email_settings()
if not email_settings.get("enabled", False):
@@ -482,16 +293,20 @@ def forgot_password():
if not allowed:
return jsonify({"error": error_msg}), 429
_send_password_reset_email_if_possible(
result = email_service.send_password_reset_email(
email=bound_email,
username=user["username"],
user_id=user["id"],
)
if not result["success"]:
logger.error(f"密码重置邮件发送失败: {result['error']}")
return jsonify({"success": True, "message": "如果该账号已绑定邮箱,您将收到密码重置邮件"})
user = database.get_user_by_email(email)
if user and user.get("status") == "approved":
_send_password_reset_email_if_possible(email=email, username=user["username"], user_id=user["id"])
result = email_service.send_password_reset_email(email=email, username=user["username"], user_id=user["id"])
if not result["success"]:
logger.error(f"密码重置邮件发送失败: {result['error']}")
return jsonify({"success": True, "message": "如果该邮箱已注册,您将收到密码重置邮件"})
@@ -516,7 +331,7 @@ def reset_password_page(token):
@api_auth_bp.route("/api/reset-password-confirm", methods=["POST"])
def reset_password_confirm():
"""确认密码重置"""
data = _get_json_payload()
data = request.json or {}
token = data.get("token", "").strip()
new_password = data.get("new_password", "").strip()
@@ -541,191 +356,78 @@ def reset_password_confirm():
@api_auth_bp.route("/api/generate_captcha", methods=["POST"])
def generate_captcha():
"""生成4位数字验证码图片"""
client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "login")
if not allowed:
return jsonify({"error": error_msg}), 429
import base64
import uuid
from io import BytesIO
session_id = str(uuid.uuid4())
code = "".join(str(secrets.randbelow(10)) for _ in range(4))
code = "".join([str(secrets.randbelow(10)) for _ in range(4)])
safe_set_captcha(session_id, {"code": code, "expire_time": time.time() + 300, "failed_attempts": 0})
safe_cleanup_expired_captcha()
try:
captcha_image = _generate_captcha_image_data_uri(code)
return jsonify({"session_id": session_id, "captcha_image": captcha_image})
from PIL import Image, ImageDraw, ImageFont
import io
width, height = 160, 60
image = Image.new("RGB", (width, height), color=(255, 255, 255))
draw = ImageDraw.Draw(image)
for _ in range(6):
x1 = random.randint(0, width)
y1 = random.randint(0, height)
x2 = random.randint(0, width)
y2 = random.randint(0, height)
draw.line(
[(x1, y1), (x2, y2)],
fill=(random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)),
width=1,
)
for _ in range(80):
x = random.randint(0, width)
y = random.randint(0, height)
draw.point((x, y), fill=(random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)))
font = None
font_paths = [
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
]
for font_path in font_paths:
try:
font = ImageFont.truetype(font_path, 42)
break
except Exception:
continue
if font is None:
font = ImageFont.load_default()
for i, char in enumerate(code):
x = 12 + i * 35 + random.randint(-3, 3)
y = random.randint(5, 12)
color = (random.randint(0, 150), random.randint(0, 150), random.randint(0, 150))
draw.text((x, y), char, font=font, fill=color)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
img_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
return jsonify({"session_id": session_id, "captcha_image": f"data:image/png;base64,{img_base64}"})
except ImportError as e:
logger.error(f"PIL库未安装验证码功能不可用: {e}")
safe_delete_captcha(session_id)
return jsonify({"error": "验证码服务暂不可用请联系管理员安装PIL库"}), 503
@api_auth_bp.route("/api/passkeys/login/options", methods=["POST"])
@require_ip_not_locked
def user_passkey_login_options():
"""用户 Passkey 登录:获取 assertion challenge。"""
data = _get_json_payload()
username = str(data.get("username", "") or "").strip()
client_ip = get_rate_limit_ip()
mode = "named" if username else "discoverable"
username_key = f"passkey:{username}" if username else "passkey:discoverable"
is_locked, remaining = check_login_ip_user_locked(client_ip, username_key)
if is_locked:
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}"
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试"}), 429
allowed, error_msg = check_ip_request_rate(client_ip, "login")
if not allowed:
return jsonify({"error": error_msg}), 429
allowed, error_msg = check_login_rate_limits(client_ip, username_key)
if not allowed:
return jsonify({"error": error_msg}), 429
user_id = 0
allow_credential_ids = []
if mode == "named":
user = database.get_user_by_username(username)
if not user or user.get("status") != "approved":
record_login_failure(client_ip, username_key)
return jsonify({"error": "账号或Passkey不可用"}), 400
user_id = int(user["id"])
passkeys = database.list_passkeys("user", user_id)
if not passkeys:
record_login_failure(client_ip, username_key)
return jsonify({"error": "该账号尚未绑定Passkey"}), 400
allow_credential_ids = [str(item.get("credential_id") or "").strip() for item in passkeys if item.get("credential_id")]
try:
rp_id = get_rp_id(request)
expected_origins = get_expected_origins(request)
except Exception as e:
logger.warning(f"[passkey] 生成登录 challenge 失败(mode={mode}, username={username or '-'}) : {e}")
return jsonify({"error": "Passkey配置异常请联系管理员"}), 500
options = make_authentication_options(rp_id=rp_id, allow_credential_ids=allow_credential_ids)
challenge = str(options.get("challenge") or "").strip()
if not challenge:
return jsonify({"error": "生成Passkey挑战失败"}), 500
session[_USER_PASSKEY_LOGIN_SESSION_KEY] = {
"mode": mode,
"username": username,
"user_id": int(user_id),
"challenge": challenge,
"rp_id": rp_id,
"expected_origins": expected_origins,
"username_key": username_key,
"created_at": time.time(),
}
session.modified = True
return jsonify({"publicKey": options})
@api_auth_bp.route("/api/passkeys/login/verify", methods=["POST"])
@require_ip_not_locked
def user_passkey_login_verify():
"""用户 Passkey 登录:校验 assertion 并登录。"""
data = _get_json_payload()
request_username = str(data.get("username", "") or "").strip()
credential = _parse_credential_payload(data)
if not credential:
return jsonify({"error": "Passkey参数缺失"}), 400
state = session.get(_USER_PASSKEY_LOGIN_SESSION_KEY) or {}
if not state:
return jsonify({"error": "Passkey挑战不存在或已过期请重试"}), 400
if not is_challenge_valid(state.get("created_at")):
session.pop(_USER_PASSKEY_LOGIN_SESSION_KEY, None)
return jsonify({"error": "Passkey挑战已过期请重试"}), 400
mode = str(state.get("mode") or "named")
if mode not in {"named", "discoverable"}:
session.pop(_USER_PASSKEY_LOGIN_SESSION_KEY, None)
return jsonify({"error": "Passkey状态异常请重试"}), 400
expected_username = str(state.get("username") or "").strip()
username = expected_username
if mode == "named":
if not expected_username:
session.pop(_USER_PASSKEY_LOGIN_SESSION_KEY, None)
return jsonify({"error": "Passkey状态异常请重试"}), 400
if request_username and request_username != expected_username:
return jsonify({"error": "用户名与挑战不匹配,请重试"}), 400
else:
username = request_username
client_ip = get_rate_limit_ip()
username_key = str(state.get("username_key") or "").strip() or (
f"passkey:{expected_username}" if mode == "named" else "passkey:discoverable"
)
is_locked, remaining = check_login_ip_user_locked(client_ip, username_key)
if is_locked:
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}"
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试"}), 429
credential_id = str(credential.get("id") or credential.get("rawId") or "").strip()
if not credential_id:
return jsonify({"error": "Passkey参数无效"}), 400
passkey = database.get_passkey_by_credential_id(credential_id)
if not passkey:
record_login_failure(client_ip, username_key)
return jsonify({"error": "Passkey不存在或已删除"}), 401
if str(passkey.get("owner_type") or "") != "user":
record_login_failure(client_ip, username_key)
return jsonify({"error": "Passkey不属于用户账号"}), 401
if mode == "named" and int(passkey.get("owner_id") or 0) != int(state.get("user_id") or 0):
record_login_failure(client_ip, username_key)
return jsonify({"error": "Passkey与账号不匹配"}), 401
try:
parsed_credential, verified = verify_authentication(
credential=credential,
expected_challenge=str(state.get("challenge") or ""),
expected_rp_id=str(state.get("rp_id") or ""),
expected_origins=list(state.get("expected_origins") or []),
credential_public_key=str(passkey.get("public_key") or ""),
credential_current_sign_count=int(passkey.get("sign_count") or 0),
)
verified_credential_id = encode_credential_id(verified.credential_id)
if verified_credential_id != str(passkey.get("credential_id") or ""):
raise ValueError("credential_id mismatch")
except Exception as e:
logger.warning(f"[passkey] 用户登录验签失败(mode={mode}, username={expected_username or request_username or '-'}) : {e}")
record_login_failure(client_ip, username_key)
return jsonify({"error": "Passkey验证失败"}), 401
user_id = int(passkey.get("owner_id") or 0)
user = database.get_user_by_id(user_id)
if not user or user.get("status") != "approved":
return jsonify({"error": "账号不可用"}), 401
database.update_passkey_usage(int(passkey["id"]), int(verified.new_sign_count))
clear_login_failures(client_ip, username_key)
user_login_key = f"passkey:{str(user.get('username') or '').strip()}"
if user_login_key and user_login_key != username_key:
clear_login_failures(client_ip, user_login_key)
session.pop(_USER_PASSKEY_LOGIN_SESSION_KEY, None)
user_obj = User(user_id)
login_user(user_obj)
load_user_accounts(user_id)
resolved_username = str(user.get("username") or "").strip() or username or f"user-{user_id}"
_send_login_security_alert_if_needed(user=user, username=resolved_username, client_ip=client_ip)
return jsonify({"success": True, "credential_id": parsed_credential.id, "username": resolved_username})
@api_auth_bp.route("/api/login", methods=["POST"])
@require_ip_not_locked
def login():
"""用户登录"""
data = _get_json_payload()
data = request.json or {}
username = data.get("username", "").strip()
password = data.get("password", "").strip()
captcha_session = data.get("captcha_session", "")
@@ -750,15 +452,13 @@ def login():
return jsonify({"error": error_msg, "need_captcha": True}), 429
captcha_required = check_login_captcha_required(client_ip, username_key) or scan_locked or bool(need_captcha)
captcha_ok, captcha_error_response = _verify_login_captcha_if_needed(
captcha_required=captcha_required,
captcha_session=captcha_session,
captcha_code=captcha_code,
client_ip=client_ip,
username_key=username_key,
)
if not captcha_ok:
return captcha_error_response
if captcha_required:
if not captcha_session or not captcha_code:
return jsonify({"error": "请填写验证码", "need_captcha": True}), 400
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if not success:
record_login_failure(client_ip, username_key)
return jsonify({"error": message, "need_captcha": True}), 400
user = database.verify_user(username, password)
if not user:
@@ -776,7 +476,29 @@ def login():
login_user(user_obj)
load_user_accounts(user["id"])
_send_login_security_alert_if_needed(user=user, username=username, client_ip=client_ip)
try:
user_agent = request.headers.get("User-Agent", "")
context = database.record_login_context(user["id"], client_ip, user_agent)
if context and (context.get("new_ip") or context.get("new_device")):
if (
config.LOGIN_ALERT_ENABLED
and should_send_login_alert(user["id"], client_ip)
and email_service.get_email_settings().get("login_alert_enabled", True)
):
user_info = database.get_user_by_id(user["id"]) or {}
if user_info.get("email") and user_info.get("email_verified"):
if database.get_user_email_notify(user["id"]):
email_service.send_security_alert_email(
email=user_info.get("email"),
username=user_info.get("username") or username,
ip_address=client_ip,
user_agent=user_agent,
new_ip=context.get("new_ip", False),
new_device=context.get("new_device", False),
user_id=user["id"],
)
except Exception:
pass
return jsonify({"success": True})
@@ -785,7 +507,4 @@ def login():
def logout():
"""用户登出"""
logout_user()
session.pop("admin_id", None)
session.pop("admin_username", None)
session.pop("admin_reauth_until", None)
return jsonify({"success": True})

View File

@@ -2,14 +2,9 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import json
import re
import threading
import time as time_mod
import uuid
import database
from app_logger import get_logger
from flask import Blueprint, jsonify, request
from flask_login import current_user, login_required
from services.accounts_service import load_user_accounts
@@ -18,18 +13,10 @@ from services.state import safe_get_account, safe_get_user_accounts_snapshot
from services.tasks import submit_account_task
api_schedules_bp = Blueprint("api_schedules", __name__)
logger = get_logger("app")
_HHMM_RE = re.compile(r"^(\d{1,2}):(\d{2})$")
def _request_json(default=None):
if default is None:
default = {}
data = request.get_json(silent=True)
return data if isinstance(data, dict) else default
def _normalize_hhmm(value: object) -> str | None:
match = _HHMM_RE.match(str(value or "").strip())
if not match:
@@ -41,81 +28,18 @@ def _normalize_hhmm(value: object) -> str | None:
return f"{hour:02d}:{minute:02d}"
def _normalize_random_delay(value) -> tuple[int | None, str | None]:
try:
normalized = int(value or 0)
except Exception:
return None, "random_delay必须是0或1"
if normalized not in (0, 1):
return None, "random_delay必须是0或1"
return normalized, None
def _parse_schedule_account_ids(raw_value) -> list:
try:
parsed = json.loads(raw_value or "[]")
except (json.JSONDecodeError, TypeError):
return []
return parsed if isinstance(parsed, list) else []
def _get_owned_schedule_or_error(schedule_id: int):
schedule = database.get_schedule_by_id(schedule_id)
if not schedule:
return None, (jsonify({"error": "定时任务不存在"}), 404)
if schedule.get("user_id") != current_user.id:
return None, (jsonify({"error": "无权访问"}), 403)
return schedule, None
def _ensure_user_accounts_loaded(user_id: int) -> None:
if safe_get_user_accounts_snapshot(user_id):
return
load_user_accounts(user_id)
def _parse_browse_type_or_error(raw_value, *, default=BROWSE_TYPE_SHOULD_READ):
browse_type = validate_browse_type(raw_value, default=default)
if not browse_type:
return None, (jsonify({"error": "浏览类型无效"}), 400)
return browse_type, None
def _parse_optional_pagination(default_limit: int = 20, *, max_limit: int = 200) -> tuple[int | None, int | None, bool]:
limit_raw = request.args.get("limit")
offset_raw = request.args.get("offset")
if (limit_raw is None) and (offset_raw is None):
return None, None, False
try:
limit = int(limit_raw if limit_raw is not None else default_limit)
except (ValueError, TypeError):
limit = default_limit
limit = max(1, min(limit, max_limit))
try:
offset = int(offset_raw if offset_raw is not None else 0)
except (ValueError, TypeError):
offset = 0
offset = max(0, offset)
return limit, offset, True
@api_schedules_bp.route("/api/schedules", methods=["GET"])
@login_required
def get_user_schedules_api():
"""获取当前用户的所有定时任务"""
schedules = database.get_user_schedules(current_user.id)
for schedule in schedules:
schedule["account_ids"] = _parse_schedule_account_ids(schedule.get("account_ids"))
limit, offset, paged = _parse_optional_pagination(default_limit=12, max_limit=100)
if paged:
total = len(schedules)
items = schedules[offset : offset + limit]
return jsonify({"items": items, "total": total, "limit": limit, "offset": offset})
import json
for s in schedules:
try:
s["account_ids"] = json.loads(s.get("account_ids", "[]") or "[]")
except (json.JSONDecodeError, TypeError):
s["account_ids"] = []
return jsonify(schedules)
@@ -123,26 +47,23 @@ def get_user_schedules_api():
@login_required
def create_user_schedule_api():
"""创建用户定时任务"""
data = _request_json()
data = request.json or {}
name = data.get("name", "我的定时任务")
schedule_time = data.get("schedule_time", "08:00")
weekdays = data.get("weekdays", "1,2,3,4,5")
browse_type, browse_error = _parse_browse_type_or_error(data.get("browse_type", BROWSE_TYPE_SHOULD_READ))
if browse_error:
return browse_error
browse_type = validate_browse_type(data.get("browse_type", BROWSE_TYPE_SHOULD_READ), default=BROWSE_TYPE_SHOULD_READ)
if not browse_type:
return jsonify({"error": "浏览类型无效"}), 400
enable_screenshot = data.get("enable_screenshot", 1)
random_delay, delay_error = _normalize_random_delay(data.get("random_delay", 0))
if delay_error:
return jsonify({"error": delay_error}), 400
random_delay = int(data.get("random_delay", 0) or 0)
account_ids = data.get("account_ids", [])
normalized_time = _normalize_hhmm(schedule_time)
if not normalized_time:
return jsonify({"error": "时间格式不正确,应为 HH:MM"}), 400
if random_delay not in (0, 1):
return jsonify({"error": "random_delay必须是0或1"}), 400
schedule_id = database.create_user_schedule(
user_id=current_user.id,
@@ -164,11 +85,18 @@ def create_user_schedule_api():
@login_required
def get_schedule_detail_api(schedule_id):
"""获取定时任务详情"""
schedule, error_response = _get_owned_schedule_or_error(schedule_id)
if error_response:
return error_response
schedule = database.get_schedule_by_id(schedule_id)
if not schedule:
return jsonify({"error": "定时任务不存在"}), 404
if schedule["user_id"] != current_user.id:
return jsonify({"error": "无权访问"}), 403
schedule["account_ids"] = _parse_schedule_account_ids(schedule.get("account_ids"))
import json
try:
schedule["account_ids"] = json.loads(schedule.get("account_ids", "[]") or "[]")
except (json.JSONDecodeError, TypeError):
schedule["account_ids"] = []
return jsonify(schedule)
@@ -176,12 +104,14 @@ def get_schedule_detail_api(schedule_id):
@login_required
def update_schedule_api(schedule_id):
"""更新定时任务"""
_, error_response = _get_owned_schedule_or_error(schedule_id)
if error_response:
return error_response
schedule = database.get_schedule_by_id(schedule_id)
if not schedule:
return jsonify({"error": "定时任务不存在"}), 404
if schedule["user_id"] != current_user.id:
return jsonify({"error": "无权访问"}), 403
data = _request_json()
allowed_fields = {
data = request.json or {}
allowed_fields = [
"name",
"schedule_time",
"weekdays",
@@ -190,26 +120,27 @@ def update_schedule_api(schedule_id):
"random_delay",
"account_ids",
"enabled",
}
update_data = {key: value for key, value in data.items() if key in allowed_fields}
]
update_data = {k: v for k, v in data.items() if k in allowed_fields}
if "schedule_time" in update_data:
normalized_time = _normalize_hhmm(update_data["schedule_time"])
if not normalized_time:
return jsonify({"error": "时间格式不正确,应为 HH:MM"}), 400
update_data["schedule_time"] = normalized_time
if "random_delay" in update_data:
random_delay, delay_error = _normalize_random_delay(update_data.get("random_delay"))
if delay_error:
return jsonify({"error": delay_error}), 400
update_data["random_delay"] = random_delay
try:
update_data["random_delay"] = int(update_data.get("random_delay") or 0)
except Exception:
return jsonify({"error": "random_delay必须是0或1"}), 400
if update_data["random_delay"] not in (0, 1):
return jsonify({"error": "random_delay必须是0或1"}), 400
if "browse_type" in update_data:
normalized_browse_type, browse_error = _parse_browse_type_or_error(update_data.get("browse_type"))
if browse_error:
return browse_error
update_data["browse_type"] = normalized_browse_type
normalized = validate_browse_type(update_data.get("browse_type"), default=BROWSE_TYPE_SHOULD_READ)
if not normalized:
return jsonify({"error": "浏览类型无效"}), 400
update_data["browse_type"] = normalized
success = database.update_user_schedule(schedule_id, **update_data)
if success:
@@ -221,9 +152,11 @@ def update_schedule_api(schedule_id):
@login_required
def delete_schedule_api(schedule_id):
"""删除定时任务"""
_, error_response = _get_owned_schedule_or_error(schedule_id)
if error_response:
return error_response
schedule = database.get_schedule_by_id(schedule_id)
if not schedule:
return jsonify({"error": "定时任务不存在"}), 404
if schedule["user_id"] != current_user.id:
return jsonify({"error": "无权访问"}), 403
success = database.delete_user_schedule(schedule_id)
if success:
@@ -235,11 +168,13 @@ def delete_schedule_api(schedule_id):
@login_required
def toggle_schedule_api(schedule_id):
"""启用/禁用定时任务"""
schedule, error_response = _get_owned_schedule_or_error(schedule_id)
if error_response:
return error_response
schedule = database.get_schedule_by_id(schedule_id)
if not schedule:
return jsonify({"error": "定时任务不存在"}), 404
if schedule["user_id"] != current_user.id:
return jsonify({"error": "无权访问"}), 403
data = _request_json()
data = request.json
enabled = data.get("enabled", not schedule["enabled"])
success = database.toggle_user_schedule(schedule_id, enabled)
@@ -252,11 +187,22 @@ def toggle_schedule_api(schedule_id):
@login_required
def run_schedule_now_api(schedule_id):
"""立即执行定时任务"""
schedule, error_response = _get_owned_schedule_or_error(schedule_id)
if error_response:
return error_response
import json
import threading
import time as time_mod
import uuid
schedule = database.get_schedule_by_id(schedule_id)
if not schedule:
return jsonify({"error": "定时任务不存在"}), 404
if schedule["user_id"] != current_user.id:
return jsonify({"error": "无权访问"}), 403
try:
account_ids = json.loads(schedule.get("account_ids", "[]") or "[]")
except (json.JSONDecodeError, TypeError):
account_ids = []
account_ids = _parse_schedule_account_ids(schedule.get("account_ids"))
if not account_ids:
return jsonify({"error": "没有配置账号"}), 400
@@ -264,7 +210,8 @@ def run_schedule_now_api(schedule_id):
browse_type = normalize_browse_type(schedule.get("browse_type", BROWSE_TYPE_SHOULD_READ))
enable_screenshot = schedule["enable_screenshot"]
_ensure_user_accounts_loaded(user_id)
if not safe_get_user_accounts_snapshot(user_id):
load_user_accounts(user_id)
from services.state import safe_create_batch, safe_finalize_batch_after_dispatch
from services.task_batches import _send_batch_task_email_if_configured
@@ -303,7 +250,6 @@ def run_schedule_now_api(schedule_id):
if remaining["done"] or remaining["count"] > 0:
return
remaining["done"] = True
execution_duration = int(time_mod.time() - execution_start_time)
database.update_schedule_execution_log(
log_id,
@@ -314,17 +260,19 @@ def run_schedule_now_api(schedule_id):
status="completed",
)
task_source = f"user_scheduled:{batch_id}"
for account_id in account_ids:
account = safe_get_account(user_id, account_id)
if (not account) or account.is_running:
if not account:
skipped_count += 1
continue
if account.is_running:
skipped_count += 1
continue
task_source = f"user_scheduled:{batch_id}"
with completion_lock:
remaining["count"] += 1
ok, _ = submit_account_task(
ok, msg = submit_account_task(
user_id=user_id,
account_id=account_id,
browse_type=browse_type,
@@ -393,5 +341,4 @@ def delete_schedule_logs_api(schedule_id):
deleted = database.delete_schedule_logs(schedule_id, current_user.id)
return jsonify({"success": True, "deleted": deleted})
except Exception as e:
logger.warning(f"[schedules] 清空定时任务日志失败(schedule_id={schedule_id}): {e}")
return jsonify({"error": "清空日志失败,请稍后重试"}), 500
return jsonify({"error": str(e)}), 500

View File

@@ -4,178 +4,19 @@ from __future__ import annotations
import os
from datetime import datetime
from typing import Iterator
import database
from app_config import get_config
from app_logger import get_logger
from app_security import is_safe_path
from flask import Blueprint, jsonify, request, send_from_directory
from flask import Blueprint, jsonify, send_from_directory
from flask_login import current_user, login_required
from PIL import Image, ImageOps
from services.client_log import log_to_client
from services.time_utils import BEIJING_TZ
config = get_config()
SCREENSHOTS_DIR = config.SCREENSHOTS_DIR
_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg")
_THUMBNAIL_DIR = os.path.join(SCREENSHOTS_DIR, ".thumbs")
_THUMBNAIL_MAX_SIZE = (480, 270)
_THUMBNAIL_QUALITY = 80
try:
_RESAMPLE_FILTER = Image.Resampling.LANCZOS
except AttributeError: # Pillow<9 fallback
_RESAMPLE_FILTER = Image.LANCZOS
api_screenshots_bp = Blueprint("api_screenshots", __name__)
logger = get_logger("app")
def _get_user_prefix(user_id: int) -> str:
return f"u{int(user_id)}"
def _get_username(user_id: int) -> str:
user_info = database.get_user_by_id(user_id)
return str(user_info.get("username") or "") if user_info else ""
def _list_all_usernames() -> list[str]:
users = database.get_all_users()
result = []
for row in users:
username = str(row.get("username") or "").strip()
if username:
result.append(username)
return result
def _resolve_user_owned_prefix(
filename: str,
*,
user_id: int,
username: str,
all_usernames: list[str] | None = None,
) -> str | None:
lower_name = filename.lower()
if not lower_name.endswith(_IMAGE_EXTENSIONS):
return None
# 新版命名u{user_id}_...
id_prefix = _get_user_prefix(user_id)
if filename.startswith(id_prefix + "_"):
return id_prefix
# 兼容旧版命名:{username}_...
username = str(username or "").strip()
if not username:
return None
if all_usernames is None:
all_usernames = _list_all_usernames()
matched_usernames = [item for item in all_usernames if filename.startswith(item + "_")]
if not matched_usernames:
return None
# 取“最长匹配用户名”,避免 foo 越权读取 foo_bar 的截图。
max_len = max(len(item) for item in matched_usernames)
winners = [item for item in matched_usernames if len(item) == max_len]
if len(winners) != 1:
return None
if winners[0] != username:
return None
return winners[0]
def _iter_user_screenshot_entries(user_id: int, username: str, all_usernames: list[str]) -> Iterator[tuple[os.DirEntry, str]]:
if not os.path.exists(SCREENSHOTS_DIR):
return
with os.scandir(SCREENSHOTS_DIR) as entries:
for entry in entries:
if not entry.is_file():
continue
matched_prefix = _resolve_user_owned_prefix(
entry.name,
user_id=user_id,
username=username,
all_usernames=all_usernames,
)
if not matched_prefix:
continue
yield entry, matched_prefix
def _build_display_name(filename: str, owner_prefix: str) -> str:
prefix = f"{owner_prefix}_"
if filename.startswith(prefix):
return filename[len(prefix) :]
return filename
def _thumbnail_name(filename: str) -> str:
stem, _ = os.path.splitext(filename)
return f"{stem}.thumb.jpg"
def _thumbnail_path(filename: str) -> str:
return os.path.join(_THUMBNAIL_DIR, _thumbnail_name(filename))
def _ensure_thumbnail(source_path: str, thumb_path: str) -> bool:
if not os.path.exists(source_path):
return False
source_mtime = os.path.getmtime(source_path)
if os.path.exists(thumb_path) and os.path.getmtime(thumb_path) >= source_mtime:
return True
os.makedirs(_THUMBNAIL_DIR, exist_ok=True)
with Image.open(source_path) as image:
image = ImageOps.exif_transpose(image)
if image.mode != "RGB":
image = image.convert("RGB")
image.thumbnail(_THUMBNAIL_MAX_SIZE, _RESAMPLE_FILTER)
image.save(
thumb_path,
format="JPEG",
quality=_THUMBNAIL_QUALITY,
optimize=True,
progressive=True,
)
os.utime(thumb_path, (source_mtime, source_mtime))
return True
def _remove_thumbnail(filename: str) -> None:
thumb_path = _thumbnail_path(filename)
if os.path.exists(thumb_path):
os.remove(thumb_path)
def _parse_optional_pagination(default_limit: int = 24, *, max_limit: int = 100) -> tuple[int | None, int | None, bool]:
limit_raw = request.args.get("limit")
offset_raw = request.args.get("offset")
if (limit_raw is None) and (offset_raw is None):
return None, None, False
try:
limit = int(limit_raw if limit_raw is not None else default_limit)
except (ValueError, TypeError):
limit = default_limit
limit = max(1, min(limit, max_limit))
try:
offset = int(offset_raw if offset_raw is not None else 0)
except (ValueError, TypeError):
offset = 0
offset = max(0, offset)
return limit, offset, True
@api_screenshots_bp.route("/api/screenshots", methods=["GET"])
@@ -183,49 +24,46 @@ def _parse_optional_pagination(default_limit: int = 24, *, max_limit: int = 100)
def get_screenshots():
"""获取当前用户的截图列表"""
user_id = current_user.id
username = _get_username(user_id)
user_info = database.get_user_by_id(user_id)
username_prefix = user_info["username"] if user_info else f"user{user_id}"
try:
screenshots = []
all_usernames = _list_all_usernames()
for entry, matched_prefix in _iter_user_screenshot_entries(user_id, username, all_usernames):
filename = entry.name
stat = entry.stat()
created_time = datetime.fromtimestamp(stat.st_mtime, tz=BEIJING_TZ)
screenshots.append(
{
"filename": filename,
"display_name": _build_display_name(filename, matched_prefix),
"size": stat.st_size,
"created": created_time.strftime("%Y-%m-%d %H:%M:%S"),
"_created_ts": stat.st_mtime,
}
)
screenshots.sort(key=lambda item: item.get("_created_ts", 0), reverse=True)
for item in screenshots:
item.pop("_created_ts", None)
limit, offset, paged = _parse_optional_pagination(default_limit=24, max_limit=100)
if paged:
total = len(screenshots)
items = screenshots[offset : offset + limit]
return jsonify({"items": items, "total": total, "limit": limit, "offset": offset})
if os.path.exists(SCREENSHOTS_DIR):
for filename in os.listdir(SCREENSHOTS_DIR):
if filename.lower().endswith((".png", ".jpg", ".jpeg")) and filename.startswith(username_prefix + "_"):
filepath = os.path.join(SCREENSHOTS_DIR, filename)
stat = os.stat(filepath)
created_time = datetime.fromtimestamp(stat.st_mtime, tz=BEIJING_TZ)
parts = filename.rsplit(".", 1)[0].split("_", 1)
if len(parts) > 1:
display_name = parts[1] + "." + filename.rsplit(".", 1)[1]
else:
display_name = filename
screenshots.append(
{
"filename": filename,
"display_name": display_name,
"size": stat.st_size,
"created": created_time.strftime("%Y-%m-%d %H:%M:%S"),
}
)
screenshots.sort(key=lambda x: x["created"], reverse=True)
return jsonify(screenshots)
except Exception as e:
logger.warning(f"[screenshots] 获取截图列表失败(user_id={user_id}): {e}")
return jsonify({"error": "获取截图列表失败"}), 500
return jsonify({"error": str(e)}), 500
@api_screenshots_bp.route("/screenshots/<filename>")
@login_required
def serve_screenshot(filename):
"""提供图文件访问"""
"""提供图文件访问"""
user_id = current_user.id
username = _get_username(user_id)
if not _resolve_user_owned_prefix(filename, user_id=user_id, username=username):
user_info = database.get_user_by_id(user_id)
username_prefix = user_info["username"] if user_info else f"user{user_id}"
if not filename.startswith(username_prefix + "_"):
return jsonify({"error": "无权访问"}), 403
if not is_safe_path(SCREENSHOTS_DIR, filename):
@@ -234,56 +72,26 @@ def serve_screenshot(filename):
return send_from_directory(SCREENSHOTS_DIR, filename)
@api_screenshots_bp.route("/screenshots/thumb/<filename>")
@login_required
def serve_screenshot_thumbnail(filename):
"""提供缩略图访问(失败时自动回退原图)"""
user_id = current_user.id
username = _get_username(user_id)
if not _resolve_user_owned_prefix(filename, user_id=user_id, username=username):
return jsonify({"error": "无权访问"}), 403
if not is_safe_path(SCREENSHOTS_DIR, filename):
return jsonify({"error": "非法路径"}), 403
source_path = os.path.join(SCREENSHOTS_DIR, filename)
if not os.path.exists(source_path):
return jsonify({"error": "文件不存在"}), 404
thumb_path = _thumbnail_path(filename)
try:
if _ensure_thumbnail(source_path, thumb_path) and os.path.exists(thumb_path):
return send_from_directory(_THUMBNAIL_DIR, os.path.basename(thumb_path), max_age=86400, conditional=True)
except Exception:
pass
return send_from_directory(SCREENSHOTS_DIR, filename, max_age=3600, conditional=True)
@api_screenshots_bp.route("/api/screenshots/<filename>", methods=["DELETE"])
@login_required
def delete_screenshot(filename):
"""删除指定截图"""
user_id = current_user.id
username = _get_username(user_id)
if not _resolve_user_owned_prefix(filename, user_id=user_id, username=username):
return jsonify({"error": "无权删除"}), 403
user_info = database.get_user_by_id(user_id)
username_prefix = user_info["username"] if user_info else f"user{user_id}"
if not is_safe_path(SCREENSHOTS_DIR, filename):
return jsonify({"error": "非法路径"}), 403
if not filename.startswith(username_prefix + "_"):
return jsonify({"error": "无权删除"}), 403
try:
filepath = os.path.join(SCREENSHOTS_DIR, filename)
if os.path.exists(filepath):
os.remove(filepath)
_remove_thumbnail(filename)
log_to_client(f"删除截图: {filename}", user_id)
return jsonify({"success": True})
return jsonify({"error": "文件不存在"}), 404
except Exception as e:
logger.warning(f"[screenshots] 删除截图失败(user_id={user_id}, filename={filename}): {e}")
return jsonify({"error": "删除截图失败"}), 500
return jsonify({"error": str(e)}), 500
@api_screenshots_bp.route("/api/screenshots/clear", methods=["POST"])
@@ -291,18 +99,19 @@ def delete_screenshot(filename):
def clear_all_screenshots():
"""清空当前用户的所有截图"""
user_id = current_user.id
username = _get_username(user_id)
user_info = database.get_user_by_id(user_id)
username_prefix = user_info["username"] if user_info else f"user{user_id}"
try:
deleted_count = 0
all_usernames = _list_all_usernames()
for entry, _ in _iter_user_screenshot_entries(user_id, username, all_usernames):
os.remove(entry.path)
_remove_thumbnail(entry.name)
deleted_count += 1
if os.path.exists(SCREENSHOTS_DIR):
for filename in os.listdir(SCREENSHOTS_DIR):
if filename.lower().endswith((".png", ".jpg", ".jpeg")) and filename.startswith(username_prefix + "_"):
filepath = os.path.join(SCREENSHOTS_DIR, filename)
os.remove(filepath)
deleted_count += 1
log_to_client(f"清理了 {deleted_count} 个截图文件", user_id)
return jsonify({"success": True, "deleted": deleted_count})
except Exception as e:
logger.warning(f"[screenshots] 清空截图失败(user_id={user_id}): {e}")
return jsonify({"error": "清空截图失败"}), 500
return jsonify({"error": str(e)}), 500

View File

@@ -2,137 +2,18 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import json
import time
import database
import email_service
from app_logger import get_logger
from app_security import get_rate_limit_ip, require_ip_not_locked, validate_email, validate_password
from flask import Blueprint, jsonify, request, session
from flask import Blueprint, jsonify, request
from flask_login import current_user, login_required
from routes.pages import render_app_spa_or_legacy
from services.passkeys import (
MAX_PASSKEYS_PER_OWNER,
encode_credential_id,
get_credential_transports,
get_expected_origins,
get_rp_id,
is_challenge_valid,
make_registration_options,
normalize_device_name,
verify_registration,
)
from services.state import check_email_rate_limit, check_ip_request_rate, safe_iter_task_status_items
from services.tasks import get_task_scheduler
logger = get_logger("app")
api_user_bp = Blueprint("api_user", __name__)
_USER_PASSKEY_REGISTER_SESSION_KEY = "user_passkey_register_state"
def _get_current_user_record():
return database.get_user_by_id(current_user.id)
def _get_current_user_or_404():
user = _get_current_user_record()
if user:
return user, None
return None, (jsonify({"error": "用户不存在"}), 404)
def _get_current_username(*, fallback: str) -> str:
user = _get_current_user_record()
username = (user or {}).get("username", "")
return username if username else fallback
def _coerce_binary_flag(value, *, field_label: str):
if isinstance(value, bool):
value = 1 if value else 0
try:
value = int(value)
except Exception:
return None, f"{field_label}必须是0或1"
if value not in (0, 1):
return None, f"{field_label}必须是0或1"
return value, None
def _parse_credential_payload(data: dict) -> dict | None:
credential = data.get("credential")
if isinstance(credential, dict):
return credential
if isinstance(credential, str):
try:
parsed = json.loads(credential)
return parsed if isinstance(parsed, dict) else None
except Exception:
return None
return None
def _truncate_text(value, max_len: int = 300) -> str:
text = str(value or "").strip()
if len(text) > max_len:
return f"{text[:max_len]}..."
return text
def _check_bind_email_rate_limits(email: str):
client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "email")
if not allowed:
return False, error_msg, 429
allowed, error_msg = check_email_rate_limit(email, "bind_email")
if not allowed:
return False, error_msg, 429
return True, "", 200
def _render_verify_bind_failed(*, title: str, error_message: str):
spa_initial_state = {
"page": "verify_result",
"success": False,
"title": title,
"error_message": error_message,
"primary_label": "返回登录",
"primary_url": "/login",
}
return render_app_spa_or_legacy(
"verify_failed.html",
legacy_context={"error_message": error_message},
spa_initial_state=spa_initial_state,
)
def _render_verify_bind_success(email: str):
spa_initial_state = {
"page": "verify_result",
"success": True,
"title": "邮箱绑定成功",
"message": f"邮箱 {email} 已成功绑定到您的账号!",
"primary_label": "返回登录",
"primary_url": "/login",
"redirect_url": "/login",
"redirect_seconds": 5,
}
return render_app_spa_or_legacy("verify_success.html", spa_initial_state=spa_initial_state)
def _get_current_running_count(user_id: int) -> int:
try:
queue_snapshot = get_task_scheduler().get_queue_state_snapshot() or {}
running_by_user = queue_snapshot.get("running_by_user") or {}
return int(running_by_user.get(int(user_id), running_by_user.get(str(user_id), 0)) or 0)
except Exception:
current_running = 0
for _, info in safe_iter_task_status_items():
if info.get("user_id") == user_id and info.get("status") == "运行中":
current_running += 1
return current_running
@api_user_bp.route("/api/announcements/active", methods=["GET"])
@@ -196,7 +77,8 @@ def submit_feedback():
if len(description) > 2000:
return jsonify({"error": "描述不能超过2000个字符"}), 400
username = _get_current_username(fallback=f"用户{current_user.id}")
user_info = database.get_user_by_id(current_user.id)
username = user_info["username"] if user_info else f"用户{current_user.id}"
feedback_id = database.create_bug_feedback(
user_id=current_user.id,
@@ -222,7 +104,8 @@ def get_my_feedbacks():
def get_current_user_vip():
"""获取当前用户VIP信息"""
vip_info = database.get_user_vip_info(current_user.id)
vip_info["username"] = _get_current_username(fallback="Unknown")
user_info = database.get_user_by_id(current_user.id)
vip_info["username"] = user_info["username"] if user_info else "Unknown"
return jsonify(vip_info)
@@ -241,9 +124,9 @@ def change_user_password():
if not is_valid:
return jsonify({"error": error_msg}), 400
user, error_response = _get_current_user_or_404()
if error_response:
return error_response
user = database.get_user_by_id(current_user.id)
if not user:
return jsonify({"error": "用户不存在"}), 404
username = user.get("username", "")
if not username or not database.verify_user(username, current_password):
@@ -258,9 +141,9 @@ def change_user_password():
@login_required
def get_user_email():
"""获取当前用户的邮箱信息"""
user, error_response = _get_current_user_or_404()
if error_response:
return error_response
user = database.get_user_by_id(current_user.id)
if not user:
return jsonify({"error": "用户不存在"}), 404
return jsonify({"email": user.get("email", ""), "email_verified": user.get("email_verified", False)})
@@ -269,12 +152,10 @@ def get_user_email():
@login_required
def get_user_kdocs_settings():
"""获取当前用户的金山文档设置"""
settings = database.get_user_kdocs_settings(current_user.id) or {}
cfg = database.get_system_config() or {}
default_unit = (cfg.get("kdocs_default_unit") or "").strip() or "道县"
kdocs_unit = (settings.get("kdocs_unit") or "").strip() or default_unit
kdocs_auto_upload = 1 if int(settings.get("kdocs_auto_upload", 0) or 0) == 1 else 0
return jsonify({"kdocs_unit": kdocs_unit, "kdocs_auto_upload": kdocs_auto_upload})
settings = database.get_user_kdocs_settings(current_user.id)
if not settings:
return jsonify({"kdocs_unit": "", "kdocs_auto_upload": 0})
return jsonify(settings)
@api_user_bp.route("/api/user/kdocs", methods=["POST"])
@@ -291,9 +172,14 @@ def update_user_kdocs_settings():
return jsonify({"error": "县区长度不能超过50"}), 400
if kdocs_auto_upload is not None:
kdocs_auto_upload, parse_error = _coerce_binary_flag(kdocs_auto_upload, field_label="自动上传开关")
if parse_error:
return jsonify({"error": parse_error}), 400
if isinstance(kdocs_auto_upload, bool):
kdocs_auto_upload = 1 if kdocs_auto_upload else 0
try:
kdocs_auto_upload = int(kdocs_auto_upload)
except Exception:
return jsonify({"error": "自动上传开关必须是0或1"}), 400
if kdocs_auto_upload not in (0, 1):
return jsonify({"error": "自动上传开关必须是0或1"}), 400
if not database.update_user_kdocs_settings(
current_user.id,
@@ -302,14 +188,8 @@ def update_user_kdocs_settings():
):
return jsonify({"error": "更新失败"}), 400
settings = database.get_user_kdocs_settings(current_user.id) or {}
cfg = database.get_system_config() or {}
default_unit = (cfg.get("kdocs_default_unit") or "").strip() or "道县"
response_settings = {
"kdocs_unit": (settings.get("kdocs_unit") or "").strip() or default_unit,
"kdocs_auto_upload": 1 if int(settings.get("kdocs_auto_upload", 0) or 0) == 1 else 0,
}
return jsonify({"success": True, "settings": response_settings})
settings = database.get_user_kdocs_settings(current_user.id) or {"kdocs_unit": "", "kdocs_auto_upload": 0}
return jsonify({"success": True, "settings": settings})
@api_user_bp.route("/api/user/bind-email", methods=["POST"])
@@ -327,9 +207,13 @@ def bind_user_email():
if not is_valid:
return jsonify({"error": error_msg}), 400
allowed, error_msg, status_code = _check_bind_email_rate_limits(email)
client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "email")
if not allowed:
return jsonify({"error": error_msg}), status_code
return jsonify({"error": error_msg}), 429
allowed, error_msg = check_email_rate_limit(email, "bind_email")
if not allowed:
return jsonify({"error": error_msg}), 429
settings = email_service.get_email_settings()
if not settings.get("enabled", False):
@@ -339,9 +223,9 @@ def bind_user_email():
if existing_user and existing_user["id"] != current_user.id:
return jsonify({"error": "该邮箱已被其他用户绑定"}), 400
user, error_response = _get_current_user_or_404()
if error_response:
return error_response
user = database.get_user_by_id(current_user.id)
if not user:
return jsonify({"error": "用户不存在"}), 404
if user.get("email") == email and user.get("email_verified"):
return jsonify({"error": "该邮箱已绑定并验证"}), 400
@@ -356,30 +240,63 @@ def bind_user_email():
@api_user_bp.route("/api/verify-bind-email/<token>")
def verify_bind_email(token):
"""验证邮箱绑定Token"""
result = email_service.verify_bind_email_token(token, consume=False)
result = email_service.verify_bind_email_token(token)
if result:
token_id = result["token_id"]
user_id = result["user_id"]
email = result["email"]
if database.update_user_email(user_id, email, verified=True):
if not email_service.consume_email_token(token_id):
logger.warning(f"邮箱绑定成功但Token消费失败: user_id={user_id}")
return _render_verify_bind_success(email)
spa_initial_state = {
"page": "verify_result",
"success": True,
"title": "邮箱绑定成功",
"message": f"邮箱 {email} 已成功绑定到您的账号!",
"primary_label": "返回登录",
"primary_url": "/login",
"redirect_url": "/login",
"redirect_seconds": 5,
}
return render_app_spa_or_legacy("verify_success.html", spa_initial_state=spa_initial_state)
return _render_verify_bind_failed(title="绑定失败", error_message="邮箱绑定失败,请重试")
error_message = "邮箱绑定失败,请重试"
spa_initial_state = {
"page": "verify_result",
"success": False,
"title": "绑定失败",
"error_message": error_message,
"primary_label": "返回登录",
"primary_url": "/login",
}
return render_app_spa_or_legacy(
"verify_failed.html",
legacy_context={"error_message": error_message},
spa_initial_state=spa_initial_state,
)
return _render_verify_bind_failed(title="链接无效", error_message="验证链接已过期或无效,请重新发送验证邮件")
error_message = "验证链接已过期或无效,请重新发送验证邮件"
spa_initial_state = {
"page": "verify_result",
"success": False,
"title": "链接无效",
"error_message": error_message,
"primary_label": "返回登录",
"primary_url": "/login",
}
return render_app_spa_or_legacy(
"verify_failed.html",
legacy_context={"error_message": error_message},
spa_initial_state=spa_initial_state,
)
@api_user_bp.route("/api/user/unbind-email", methods=["POST"])
@login_required
def unbind_user_email():
"""解绑用户邮箱"""
user, error_response = _get_current_user_or_404()
if error_response:
return error_response
user = database.get_user_by_id(current_user.id)
if not user:
return jsonify({"error": "用户不存在"}), 404
if not user.get("email"):
return jsonify({"error": "当前未绑定邮箱"}), 400
@@ -409,176 +326,6 @@ def update_user_email_notify():
return jsonify({"error": "更新失败"}), 500
@api_user_bp.route("/api/user/passkeys", methods=["GET"])
@login_required
def list_user_passkeys():
"""获取当前用户绑定的 Passkey 设备列表。"""
rows = database.list_passkeys("user", int(current_user.id))
items = []
for row in rows:
credential_id = str(row.get("credential_id") or "")
preview = ""
if credential_id:
preview = f"{credential_id[:8]}...{credential_id[-6:]}" if len(credential_id) > 16 else credential_id
items.append(
{
"id": int(row.get("id")),
"device_name": str(row.get("device_name") or ""),
"credential_id_preview": preview,
"created_at": row.get("created_at"),
"last_used_at": row.get("last_used_at"),
"transports": str(row.get("transports") or ""),
}
)
return jsonify({"items": items, "limit": MAX_PASSKEYS_PER_OWNER})
@api_user_bp.route("/api/user/passkeys/register/options", methods=["POST"])
@login_required
def user_passkey_register_options():
"""当前登录用户创建 Passkey下发 registration challenge。"""
user, error_response = _get_current_user_or_404()
if error_response:
return error_response
count = database.count_passkeys("user", int(current_user.id))
if count >= MAX_PASSKEYS_PER_OWNER:
return jsonify({"error": f"最多可绑定{MAX_PASSKEYS_PER_OWNER}台设备"}), 400
data = request.get_json(silent=True) or {}
device_name = normalize_device_name(data.get("device_name"))
existing = database.list_passkeys("user", int(current_user.id))
exclude_credential_ids = [str(item.get("credential_id") or "").strip() for item in existing if item.get("credential_id")]
try:
rp_id = get_rp_id(request)
expected_origins = get_expected_origins(request)
except Exception as e:
logger.warning(f"[passkey] 用户注册 options 失败(user_id={current_user.id}): {e}")
return jsonify({"error": "Passkey配置异常请联系管理员"}), 500
try:
options = make_registration_options(
rp_id=rp_id,
rp_name="知识管理平台",
user_name=str(user.get("username") or f"user-{current_user.id}"),
user_display_name=str(user.get("username") or f"user-{current_user.id}"),
user_id_bytes=f"user:{int(current_user.id)}".encode("utf-8"),
exclude_credential_ids=exclude_credential_ids,
)
except Exception as e:
logger.warning(f"[passkey] 用户注册 options 构建失败(user_id={current_user.id}): {e}")
return jsonify({"error": "生成Passkey挑战失败"}), 500
challenge = str(options.get("challenge") or "").strip()
if not challenge:
return jsonify({"error": "生成Passkey挑战失败"}), 500
session[_USER_PASSKEY_REGISTER_SESSION_KEY] = {
"user_id": int(current_user.id),
"challenge": challenge,
"rp_id": rp_id,
"expected_origins": expected_origins,
"device_name": device_name,
"created_at": time.time(),
}
session.modified = True
return jsonify({"publicKey": options, "limit": MAX_PASSKEYS_PER_OWNER})
@api_user_bp.route("/api/user/passkeys/register/verify", methods=["POST"])
@login_required
def user_passkey_register_verify():
"""当前登录用户创建 Passkey校验 attestation 并落库。"""
state = session.get(_USER_PASSKEY_REGISTER_SESSION_KEY) or {}
if not state:
return jsonify({"error": "Passkey挑战不存在或已过期请重试"}), 400
if int(state.get("user_id") or 0) != int(current_user.id):
return jsonify({"error": "Passkey挑战与当前用户不匹配"}), 400
if not is_challenge_valid(state.get("created_at")):
session.pop(_USER_PASSKEY_REGISTER_SESSION_KEY, None)
return jsonify({"error": "Passkey挑战已过期请重试"}), 400
data = request.get_json(silent=True) or {}
credential = _parse_credential_payload(data)
if not credential:
return jsonify({"error": "Passkey参数缺失"}), 400
count = database.count_passkeys("user", int(current_user.id))
if count >= MAX_PASSKEYS_PER_OWNER:
session.pop(_USER_PASSKEY_REGISTER_SESSION_KEY, None)
return jsonify({"error": f"最多可绑定{MAX_PASSKEYS_PER_OWNER}台设备"}), 400
try:
verified = verify_registration(
credential=credential,
expected_challenge=str(state.get("challenge") or ""),
expected_rp_id=str(state.get("rp_id") or ""),
expected_origins=list(state.get("expected_origins") or []),
)
except Exception as e:
logger.warning(f"[passkey] 用户注册验签失败(user_id={current_user.id}): {e}")
return jsonify({"error": "Passkey验证失败请重试"}), 400
credential_id = encode_credential_id(verified.credential_id)
public_key = encode_credential_id(verified.credential_public_key)
transports = get_credential_transports(credential)
device_name = normalize_device_name(data.get("device_name") if "device_name" in data else state.get("device_name"))
aaguid = str(verified.aaguid or "")
created_id = database.create_passkey(
"user",
int(current_user.id),
credential_id=credential_id,
public_key=public_key,
sign_count=int(verified.sign_count or 0),
device_name=device_name,
transports=transports,
aaguid=aaguid,
)
if not created_id:
return jsonify({"error": "该Passkey已绑定或保存失败"}), 400
session.pop(_USER_PASSKEY_REGISTER_SESSION_KEY, None)
return jsonify({"success": True, "id": int(created_id), "device_name": device_name})
@api_user_bp.route("/api/user/passkeys/<int:passkey_id>", methods=["DELETE"])
@login_required
def delete_user_passkey(passkey_id):
"""删除当前用户绑定的 Passkey 设备。"""
ok = database.delete_passkey("user", int(current_user.id), int(passkey_id))
if ok:
return jsonify({"success": True})
return jsonify({"error": "设备不存在或已删除"}), 404
@api_user_bp.route("/api/user/passkeys/client-error", methods=["POST"])
@login_required
def report_user_passkey_client_error():
"""上报浏览器端 Passkey 失败详情,便于排查兼容性问题。"""
data = request.get_json(silent=True) or {}
error_name = _truncate_text(data.get("name"), 120)
error_message = _truncate_text(data.get("message"), 400)
error_code = _truncate_text(data.get("code"), 120)
ua = _truncate_text(data.get("user_agent") or request.headers.get("User-Agent", ""), 300)
stage = _truncate_text(data.get("stage"), 80)
source = _truncate_text(data.get("source"), 80)
logger.warning(
"[passkey][client-error][user] user_id=%s stage=%s source=%s name=%s code=%s message=%s ua=%s",
current_user.id,
stage or "-",
source or "-",
error_name or "-",
error_code or "-",
error_message or "-",
ua or "-",
)
return jsonify({"success": True})
@api_user_bp.route("/api/run_stats", methods=["GET"])
@login_required
def get_run_stats():
@@ -587,7 +334,10 @@ def get_run_stats():
stats = database.get_user_run_stats(user_id)
current_running = _get_current_running_count(user_id)
current_running = 0
for _, info in safe_iter_task_status_items():
if info.get("user_id") == user_id and info.get("status") == "运行中":
current_running += 1
return jsonify(
{
@@ -621,14 +371,6 @@ def get_kdocs_status_for_user():
login_required_flag = status.get("login_required", False)
last_login_ok = status.get("last_login_ok")
# 重启后首次查询时,状态可能还是 None这里做一次轻量实时校验
if last_login_ok is None:
live_status = kdocs.refresh_login_status()
if live_status.get("success"):
logged_in = bool(live_status.get("logged_in"))
login_required_flag = not logged_in
last_login_ok = logged_in
# 判断是否在线
is_online = not login_required_flag and last_login_ok is True

View File

@@ -31,7 +31,7 @@ def admin_required(f):
if is_api:
return jsonify({"error": "需要管理员权限"}), 403
return redirect(url_for("pages.admin_login_page"))
logger.debug(f"[admin_required] 管理员 {session.get('admin_username')} 访问 {request.path}")
logger.info(f"[admin_required] 管理员 {session.get('admin_username')} 访问 {request.path}")
return f(*args, **kwargs)
return decorated_function

View File

@@ -2,80 +2,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import os
import time
from flask import Blueprint, jsonify
import database
import db_pool
from services.request_metrics import get_request_metrics_snapshot
from services.time_utils import get_beijing_now
health_bp = Blueprint("health", __name__)
_PROCESS_START_TS = time.time()
_INCLUDE_HEALTH_METRICS = str(os.environ.get("HEALTH_INCLUDE_METRICS", "0")).strip().lower() in {
"1",
"true",
"yes",
"on",
}
_EXPOSE_HEALTH_ERRORS = str(os.environ.get("HEALTH_EXPOSE_ERRORS", "0")).strip().lower() in {
"1",
"true",
"yes",
"on",
}
def _build_runtime_metrics() -> dict:
metrics = {
"uptime_seconds": max(0, int(time.time() - _PROCESS_START_TS)),
}
try:
pool_stats = db_pool.get_pool_stats() or {}
metrics["db_pool"] = {
"pool_size": int(pool_stats.get("pool_size", 0) or 0),
"available": int(pool_stats.get("available", 0) or 0),
"in_use": int(pool_stats.get("in_use", 0) or 0),
}
except Exception:
pass
try:
import psutil
proc = psutil.Process(os.getpid())
with proc.oneshot():
mem_info = proc.memory_info()
metrics["process"] = {
"rss_mb": round(float(mem_info.rss) / 1024 / 1024, 2),
"cpu_percent": round(float(proc.cpu_percent(interval=None)), 2),
"threads": int(proc.num_threads()),
}
except Exception:
pass
try:
from services import tasks as tasks_module
scheduler = getattr(tasks_module, "_task_scheduler", None)
if scheduler is not None:
queue_snapshot = scheduler.get_queue_state_snapshot() or {}
metrics["task_queue"] = {
"pending_total": int(queue_snapshot.get("pending_total", 0) or 0),
"running_total": int(queue_snapshot.get("running_total", 0) or 0),
}
except Exception:
pass
try:
metrics["requests"] = get_request_metrics_snapshot()
except Exception:
pass
return metrics
@health_bp.route("/health", methods=["GET"])
@@ -87,10 +19,7 @@ def health_check():
database.get_system_config()
except Exception as e:
db_ok = False
if _EXPOSE_HEALTH_ERRORS:
db_error = f"{type(e).__name__}: {e}"
else:
db_error = "db_unavailable"
db_error = f"{type(e).__name__}: {e}"
payload = {
"ok": db_ok,
@@ -98,7 +27,5 @@ def health_check():
"db_ok": db_ok,
"db_error": db_error,
}
if _INCLUDE_HEALTH_METRICS:
payload["metrics"] = _build_runtime_metrics()
return jsonify(payload), (200 if db_ok else 500)

View File

@@ -6,7 +6,7 @@ import json
import os
from typing import Optional
from flask import Blueprint, current_app, redirect, render_template, session, url_for
from flask import Blueprint, current_app, redirect, render_template, request, session, url_for
from flask_login import current_user, login_required
from routes.decorators import admin_required
@@ -15,45 +15,10 @@ from services.runtime import get_logger
pages_bp = Blueprint("pages", __name__)
def _collect_entry_css_files(manifest: dict, entry_name: str) -> list[str]:
css_files: list[str] = []
seen_css: set[str] = set()
visited: set[str] = set()
def _append_css(entry_obj: dict) -> None:
for css_file in entry_obj.get("css") or []:
css_path = str(css_file or "").strip()
if not css_path or css_path in seen_css:
continue
seen_css.add(css_path)
css_files.append(css_path)
def _walk_manifest_key(manifest_key: str) -> None:
key = str(manifest_key or "").strip()
if not key or key in visited:
return
visited.add(key)
entry_obj = manifest.get(key)
if not isinstance(entry_obj, dict):
return
_append_css(entry_obj)
for imported_key in entry_obj.get("imports") or []:
_walk_manifest_key(imported_key)
entry = manifest.get(entry_name) or {}
if isinstance(entry, dict):
_append_css(entry)
for imported_key in entry.get("imports") or []:
_walk_manifest_key(imported_key)
return css_files
def render_app_spa_or_legacy(
legacy_template_name: str,
legacy_context: Optional[dict] = None,
spa_initial_state: Optional[dict] = None,
spa_entry_name: str = "index.html",
):
"""渲染前台 Vue SPA构建产物位于 static/app失败则回退旧模板。"""
logger = get_logger()
@@ -63,9 +28,9 @@ def render_app_spa_or_legacy(
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json.load(f)
entry = manifest.get(spa_entry_name) or {}
entry = manifest.get("index.html") or {}
js_file = entry.get("file")
css_files = _collect_entry_css_files(manifest, spa_entry_name)
css_files = entry.get("css") or []
if not js_file:
logger.warning(f"[app_spa] manifest缺少入口文件: {manifest_path}")
@@ -107,6 +72,13 @@ def _get_asset_build_id(static_root: str, rel_paths: list[str]) -> Optional[str]
return str(int(max(mtimes)))
def _is_legacy_admin_user_agent(user_agent: str) -> bool:
if not user_agent:
return False
ua = user_agent.lower()
return "msie" in ua or "trident/" in ua
@pages_bp.route("/")
def index():
"""主页 - 重定向到登录或应用"""
@@ -118,7 +90,7 @@ def index():
@pages_bp.route("/login")
def login_page():
"""登录页面"""
return render_app_spa_or_legacy("login.html", spa_entry_name="login.html")
return render_app_spa_or_legacy("login.html")
@pages_bp.route("/register")
@@ -153,6 +125,8 @@ def admin_login_page():
@admin_required
def admin_page():
"""后台管理页面"""
if request.args.get("legacy") == "1" or _is_legacy_admin_user_agent(request.headers.get("User-Agent", "")):
return render_template("admin_legacy.html")
logger = get_logger()
manifest_path = os.path.join(current_app.root_path, "static", "admin", ".vite", "manifest.json")
try:
@@ -164,8 +138,8 @@ def admin_page():
css_files = entry.get("css") or []
if not js_file:
logger.error(f"[admin_spa] manifest缺少入口文件: {manifest_path}")
return "后台前端资源缺失,请重新构建管理端", 503
logger.warning(f"[admin_spa] manifest缺少入口文件: {manifest_path}")
return render_template("admin_legacy.html")
admin_spa_js_file = f"admin/{js_file}"
admin_spa_css_files = [f"admin/{p}" for p in css_files]
@@ -181,8 +155,8 @@ def admin_page():
admin_spa_build_id=admin_spa_build_id,
)
except FileNotFoundError:
logger.error(f"[admin_spa] 未找到manifest: {manifest_path}")
return "后台前端资源未构建,请联系管理员", 503
logger.warning(f"[admin_spa] 未找到manifest: {manifest_path},回退旧版后台模板")
return render_template("admin_legacy.html")
except Exception as e:
logger.error(f"[admin_spa] 加载manifest失败: {e}")
return "后台页面加载失败,请稍后重试", 500
return render_template("admin_legacy.html")

View File

@@ -1,60 +0,0 @@
# 健康监控(邮件版)
本目录提供 `health_email_monitor.py`,通过调用 `/health` 接口并使用**容器内已有邮件配置**发告警邮件。
## 1) 快速试跑
```bash
cd /root/zsglpt
python3 scripts/health_email_monitor.py \
--to 你的告警邮箱@example.com \
--container knowledge-automation-multiuser \
--url http://127.0.0.1:51232/health \
--dry-run
```
去掉 `--dry-run` 即会实际发邮件。
## 2) 建议 cron每分钟
```bash
* * * * * cd /root/zsglpt && /usr/bin/python3 scripts/health_email_monitor.py \
--to 你的告警邮箱@example.com \
--container knowledge-automation-multiuser \
--url http://127.0.0.1:51232/health \
>> /root/zsglpt/logs/health_monitor.log 2>&1
```
## 3) 支持的规则
- `service_down`:健康接口请求失败(立即告警)
- `health_fail`:返回 `ok/db_ok` 异常或 HTTP 5xx立即告警
- `db_pool_exhausted`:连接池耗尽(默认连续 3 次才告警)
- `queue_backlog_high`:任务堆积过高(默认 `pending_total >= 50` 且连续 5 次)
脚本支持恢复通知(规则恢复正常会发“恢复”邮件)。
## 4) 常用参数
- `--to`:收件人(必填)
- `--container`Docker 容器名(默认 `knowledge-automation-multiuser`
- `--url`:健康地址(默认 `http://127.0.0.1:51232/health`
- `--state-file`:状态文件路径(默认 `/tmp/zsglpt_health_monitor_state.json`
- `--remind-seconds`:重复告警间隔(默认 3600 秒)
- `--queue-threshold`:队列告警阈值(默认 50
- `--queue-streak`:队列连续次数阈值(默认 5
- `--db-pool-streak`:连接池连续次数阈值(默认 3
## 5) 环境变量方式(可选)
也可不用命令行参数,改用环境变量:
- `MONITOR_EMAIL_TO`
- `MONITOR_DOCKER_CONTAINER`
- `HEALTH_URL`
- `MONITOR_STATE_FILE`
- `MONITOR_REMIND_SECONDS`
- `MONITOR_QUEUE_THRESHOLD`
- `MONITOR_QUEUE_STREAK`
- `MONITOR_DB_POOL_STREAK`

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