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
109 changed files with 5164 additions and 2266 deletions

181
.gitignore vendored
View File

@@ -1,148 +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
# 部署脚本(含服务器信息)
deploy_*.sh
verify_*.sh
deploy.sh
# 内部文档
docs/
# 前端依赖(体积大,不应入库)
node_modules/
app-frontend/node_modules/
admin-frontend/node_modules/
# Local data
data/
docker-compose.yml.bak.*

View File

@@ -1,181 +0,0 @@
# 最终仓库清理完成报告
## 🎯 用户反馈
用户指出:"TESTING_GUIDE.md 这类的md文件 应该也不需要了吧 一般就是要个redeme吧"
这个反馈非常准确!我们进行了最终的清理。
---
## ✅ 最终清理结果
### 删除的非必要文档7个文件-1,797行
| 文件名 | 删除原因 |
|--------|----------|
| `BUG_REPORT.md` | 开发过程文档,对用户无用 |
| `CLEANUP_SUMMARY.md` | 开发者内部记录 |
| `DATABASE_UPGRADE_COMPATIBILITY.md` | 临时技术文档 |
| `GIT_PUSH_SUCCESS.md` | 开发者内部报告 |
| `LINUX_DEPLOYMENT_ANALYSIS.md` | 临时分析文档 |
| `PERFORMANCE_ANALYSIS_REPORT.md` | 临时性能报告 |
| `SCREENSHOT_FIX_SUCCESS.md` | 过时的问题解决记录 |
### 保留的核心文档
| 文件 | 保留原因 |
|------|----------|
| `README.md` | 项目主要文档,包含完整使用说明 |
| `admin-frontend/README.md` | 管理前端文档 |
| `app-frontend/README.md` | 用户前端文档 |
---
## 📊 清理效果对比
### 清理前
- 📁 **文档文件**: 15个.md文件包含大量开发文档
- 📁 **测试文件**: 25个开发测试文件
- 📁 **临时文件**: 各种临时脚本和图片
- 📁 **总文件**: 过度臃肿,仓库混乱
### 清理后
- 📁 **文档文件**: 3个README.md文件专业简洁
- 📁 **核心代码**: 纯生产环境代码
- 📁 **配置文件**: Docker、依赖、部署配置
- 📁 **总文件**: 精简专业,生产就绪
---
## 🛡️ 保护机制
### 更新.gitignore
```gitignore
# ... 其他忽略规则 ...
# Development files
test_*.py
start_*.bat
temp_*.py
kdocs_*test*.py
simple_test.py
tools/
*.sh
# Documentation
*.md
!README.md
```
### 规则说明
-**允许**: 根目录的README.md
-**禁止**: 根目录的其他.md文件
-**允许**: 子目录的README.md
-**禁止**: 所有测试和临时文件
---
## 🎯 最终状态
### ✅ 仓库现在包含
#### 核心应用文件
- `app.py` - Flask应用主文件
- `database.py` - 数据库操作
- `api_browser.py` - API浏览器
- `browser_pool_worker.py` - 截图线程池
- `services/` - 业务逻辑
- `routes/` - API路由
- `db/` - 数据库相关
#### 配置文件
- `Dockerfile` - Docker构建配置
- `docker-compose.yml` - 编排文件
- `requirements.txt` - Python依赖
- `pyproject.toml` - 项目配置
- `.env.example` - 环境变量模板
#### 文档
- `README.md` - 唯一的主要文档
### ❌ 仓库不再包含
- ❌ 测试文件test_*.py等
- ❌ 启动脚本start_*.bat等
- ❌ 临时文件temp_*.py等
- ❌ 开发文档(各种-*.md文件
- ❌ 运行时文件(截图、日志等)
---
## 📈 质量提升
| 指标 | 清理前 | 清理后 | 改善程度 |
|------|--------|--------|----------|
| **文档数量** | 15个.md | 3个README | ⭐⭐⭐⭐⭐ |
| **专业度** | 开发版感觉 | 生产级质量 | ⭐⭐⭐⭐⭐ |
| **可维护性** | 混乱复杂 | 简洁清晰 | ⭐⭐⭐⭐⭐ |
| **部署友好性** | 需手动清理 | 开箱即用 | ⭐⭐⭐⭐⭐ |
---
## 💡 经验教训
### ✅ 正确的做法
1. **README.md为王** - 只需要一个主要的README文档
2. **保护.gitignore** - 从一开始就设置好忽略规则
3. **分离开发/生产** - 明确区分开发文件和生产代码
4. **定期清理** - 保持仓库健康
### ❌ 避免的错误
1. **推送开发文档** - 这些文档应该放在Wiki或内部文档中
2. **混合测试代码** - 测试文件应该单独管理
3. **推送临时文件** - 运行时生成的文件不应该版本控制
---
## 🎉 最终状态
### 仓库地址
`https://git.workyai.cn/237899745/zsglpt`
### 最新提交
`00597fb` - 删除本地文档文件的最终提交
### 状态
**生产环境就绪**
**专业简洁**
**易于维护**
---
## 📝 给用户的建议
### ✅ 现在可以安全使用
```bash
git clone https://git.workyai.cn/237899745/zsglpt.git
cd zsglpt
docker-compose up -d
```
### ✅ 部署特点
- 🚀 **一键部署** - Docker + docker-compose
- 📚 **文档完整** - README.md包含所有必要信息
- 🔧 **配置简单** - 环境变量模板
- 🛡️ **安全可靠** - 纯生产代码
### ✅ 维护友好
- 📖 **文档清晰** - 只有必要的README
- 🧹 **仓库整洁** - 无临时文件
- 🔄 **版本管理** - 清晰的提交历史
---
**感谢你的提醒!仓库现在非常专业和简洁!**
---
*报告生成时间: 2026-01-16*
*清理操作: 用户指导完成*
*最终状态: 生产环境就绪*

174
README.md
View File

@@ -1,49 +1,31 @@
# 知识管理平台自动化工具 - Docker部署版
这是一个基于 Docker 的知识管理平台自动化工具支持多用户、定时任务、代理IP、VIP管理、金山文档集成等功能。
这是一个基于 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.11+, 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
---
@@ -53,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 资源
└── tests/ # 测试用例
├── templates/ # HTML模板SPA fallback
├── app-frontend/ # 用户端前端源码(可选保留)
├── admin-frontend/ # 后台前端源码(可选保留)
└── static/ # 前端构建产物(运行时使用)
├── app/ # 用户端 SPA
── admin/ # 后台 SPA
```
---
@@ -125,42 +91,6 @@ ssh -i /path/to/key root@your-server-ip
---
### 3. 配置加密密钥(重要!)
系统使用 Fernet 对称加密保护用户账号密码。**首次部署或迁移时必须正确配置加密密钥!**
#### 方式一:使用 .env 文件(推荐)
在项目根目录创建 `.env` 文件:
```bash
cd /www/wwwroot/zsgpt2
# 生成随机密钥
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/zsgpt2/.env /www/wwwroot/zsgpt2/
```
#### ⚠️ 重要警告
- **密钥丢失 = 所有加密密码无法解密**,必须重新录入所有账号密码
- `.env` 文件已在 `.gitignore` 中,不会被提交到 Git
- 建议将密钥备份到安全的地方(如密码管理器)
- 系统启动时会检测密钥,如果密钥丢失但存在加密数据,将拒绝启动并报错
---
## 快速部署
### 步骤1: 上传项目文件
@@ -698,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,9 +677,9 @@ docker logs knowledge-automation-multiuser | grep "数据库"
---
**文档版本**: v2.0
**更新日期**: 2026-01-08
**适用版本**: Docker多用户版 + Vue SPA
**文档版本**: v1.0
**更新日期**: 2025-10-29
**适用版本**: Docker多用户版
---
@@ -782,49 +710,3 @@ docker logs -f knowledge-automation-multiuser
```
完成!🎉
---
## 更新日志
### v2.0 (2026-01-08)
#### 新功能
- **金山文档集成**: 自动上传截图到金山文档表格
- 支持姓名搜索匹配单元格
- 支持配置有效行范围
- 支持覆盖已有图片
- 离线状态监控与邮件通知
- **Vue 3 SPA 前端**: 用户端和管理端全面升级为现代化单页应用
- Element Plus UI 组件库
- 实时任务状态更新
- 响应式设计
- **用户自定义定时任务**: 用户可创建自己的定时任务
- 支持多时间段配置
- 支持随机延迟
- 支持选择指定账号
- **安全防护系统**:
- 威胁检测引擎JNDI/SQL注入/XSS/命令注入)
- IP/用户风险评分
- 自动黑名单机制
- **邮件通知系统**:
- 任务完成通知
- 密码重置邮件
- 邮箱验证
- **公告系统**: 支持图片的系统公告
- **Bug反馈系统**: 用户可提交问题反馈
#### 优化
- **截图线程池**: wkhtmltoimage 截图支持多线程并发
- 线程池管理,按需启动
- 空闲自动释放资源
- **二次登录机制**: 刷新"上次登录时间"显示
- **API 预热**: 启动时预热连接,减少首次请求延迟
- **数据库连接池**: 提高并发性能
### v1.0 (2025-10-29)
- 初始版本
- 多用户系统
- 基础自动化任务
- 定时任务调度
- 代理IP支持

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

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

@@ -38,8 +38,6 @@ 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 kdocsStatus = ref({})
@@ -134,8 +132,6 @@ 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 || {}
@@ -257,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(),
}
@@ -571,15 +565,6 @@ onMounted(loadAll)
<el-input v-model="kdocsImageColumn" placeholder="D" style="max-width: 120px" />
</el-form-item>
<el-form-item label="有效行范围">
<div style="display: flex; align-items: center; gap: 8px;">
<el-input-number v-model="kdocsRowStart" :min="0" :max="10000" placeholder="起始行" style="width: 120px" />
<span></span>
<el-input-number v-model="kdocsRowEnd" :min="0" :max="10000" placeholder="结束行" style="width: 120px" />
</div>
<div class="help">限制上传的行范围 50-1000 表示不限制用于防止重名导致误传到其他县区</div>
</el-form-item>
<el-form-item label="管理员通知">
<el-switch v-model="kdocsAdminNotifyEnabled" />
</el-form-item>

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)
@@ -132,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()
@@ -151,7 +84,6 @@ atexit.register(_cleanup_api_browser_instances)
@dataclass
class APIBrowseResult:
"""API 浏览结果"""
success: bool
total_items: int = 0
total_attachments: int = 0
@@ -163,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)
@@ -267,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)
@@ -303,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]:
@@ -330,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:
@@ -355,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:
@@ -382,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
@@ -398,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}"
@@ -459,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)
@@ -568,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)
@@ -599,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):
@@ -619,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
@@ -664,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} 个附件")
@@ -766,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

@@ -44,12 +44,9 @@ publicApi.interceptors.response.use(
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

@@ -39,8 +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
}

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 {
@@ -568,8 +542,6 @@ watch(shouldPollStats, (running, prevRunning) => {
syncStatsPolling(prevRunning)
})
let kdocsStatusTimer = null
onMounted(async () => {
if (!userStore.vipInfo) {
userStore.refreshVipInfo().catch(() => {
@@ -581,18 +553,13 @@ onMounted(async () => {
await refreshAccounts()
await loadKdocsSettings()
await loadKdocsStatus()
await refreshStats()
syncStatsPolling()
// 每60秒刷新 KDocs 状态
kdocsStatusTimer = window.setInterval(() => loadKdocsStatus(), 60_000)
})
onBeforeUnmount(() => {
if (unbindSocket) unbindSocket()
stopStatsPolling()
if (kdocsStatusTimer) window.clearInterval(kdocsStatusTimer)
})
</script>
@@ -683,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">

20
app.py
View File

@@ -137,10 +137,6 @@ def enforce_csrf_protection():
return
if request.path.startswith("/static/"):
return
# 登录相关路由豁免 CSRF 检查(登录本身就是建立 session 的过程)
csrf_exempt_paths = {"/yuyx/api/login", "/api/login", "/api/auth/login"}
if request.path in csrf_exempt_paths:
return
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")
@@ -220,7 +216,7 @@ def cleanup_on_exit():
except Exception:
pass
logger.info("[OK] 资源清理完成")
logger.info(" 资源清理完成")
# ==================== 启动入口(保持 python app.py 可用) ====================
@@ -243,7 +239,7 @@ if __name__ == "__main__":
database.init_database()
init_checkpoint_manager()
logger.info("[OK] 任务断点管理器已初始化")
logger.info(" 任务断点管理器已初始化")
# 【新增】容器重启时清理遗留的任务状态
logger.info("清理遗留任务状态...")
@@ -260,13 +256,13 @@ if __name__ == "__main__":
for account_id in list(safe_get_active_task_ids()):
safe_remove_task(account_id)
safe_remove_task_status(account_id)
logger.info("[OK] 遗留任务状态已清理")
logger.info(" 遗留任务状态已清理")
except Exception as e:
logger.warning(f"清理遗留任务状态失败: {e}")
try:
email_service.init_email_service()
logger.info("[OK] 邮件服务已初始化")
logger.info(" 邮件服务已初始化")
except Exception as e:
logger.warning(f"警告: 邮件服务初始化失败: {e}")
@@ -278,15 +274,15 @@ if __name__ == "__main__":
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}")
logger.info(f" 已加载并发配置: 全局={max_concurrent_global}, 单账号={max_concurrent_per_account}")
except Exception as e:
logger.warning(f"警告: 加载并发配置失败,使用默认值: {e}")
logger.info("启动定时任务调度器...")
threading.Thread(target=scheduled_task_worker, daemon=True, name="scheduled-task-worker").start()
logger.info("[OK] 定时任务调度器已启动")
logger.info(" 定时任务调度器已启动")
logger.info("[OK] 状态推送线程已启动默认2秒/次)")
logger.info(" 状态推送线程已启动默认2秒/次)")
threading.Thread(target=status_push_worker, daemon=True, name="status-push-worker").start()
logger.info("服务器启动中...")
@@ -302,7 +298,7 @@ if __name__ == "__main__":
try:
logger.info(f"初始化截图线程池({pool_size}个worker按需启动执行环境空闲5分钟后自动释放...")
init_browser_worker_pool(pool_size=pool_size)
logger.info("[OK] 截图线程池初始化完成")
logger.info(" 截图线程池初始化完成")
except Exception as e:
logger.warning(f"警告: 截图线程池初始化失败: {e}")

View File

@@ -14,43 +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"
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):
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()
os.makedirs("data", exist_ok=True)
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)
print(f"[OK] 已生成新的SECRET_KEY并保存到 {SECRET_KEY_FILE}")
print(f" 已生成新的SECRET_KEY并保存到 {SECRET_KEY_FILE}")
return new_key
@@ -90,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")
@@ -124,108 +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_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):
@@ -251,10 +241,10 @@ class Config:
errors.append("DB_POOL_SIZE必须大于0")
# 验证日志配置
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
@@ -280,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
@@ -295,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()
@@ -325,5 +312,5 @@ if __name__ == "__main__":
for error in errors:
print(f"{error}")
else:
print("[OK] 配置验证通过")
print(" 配置验证通过")
config.print_config()

View File

@@ -281,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__':

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,15 +4,9 @@
加密工具模块
用于加密存储敏感信息(如第三方账号密码)
使用Fernet对称加密
安全增强版本 - 2026-01-21
- 支持 ENCRYPTION_KEY_RAW 直接使用 Fernet 密钥
- 增加密钥丢失保护机制
- 增加启动时密钥验证
"""
import os
import sys
import base64
from pathlib import Path
from cryptography.fernet import Fernet
@@ -53,89 +47,27 @@ 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} 读取加密密钥")
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"
"设置 ALLOW_NEW_KEY=true 环境变量可强制生成新密钥(不推荐)\n"
+ "=" * 60
)
logger.error(error_msg)
# 检查是否强制允许生成新密钥
if os.environ.get('ALLOW_NEW_KEY', '').lower() != 'true':
print(error_msg, file=sys.stderr)
raise RuntimeError("加密密钥丢失且存在已加密数据,请检查配置")
# 生成新的密钥
key = Fernet.generate_key()
os.makedirs(key_path.parent, exist_ok=True)
with open(key_path, 'wb') as f:
f.write(key)
logger.info(f"已生成新的加密密钥并保存到 {ENCRYPTION_KEY_FILE}")
logger.warning("请立即备份此密钥文件,并建议设置 ENCRYPTION_KEY_RAW 环境变量!")
return key
@@ -188,11 +120,8 @@ 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}")
else:
logger.warning(f"密码解密失败,可能是未加密的旧数据: {e}")
# 解密失败,可能是旧的明文密码
logger.warning(f"密码解密失败,可能是未加密的旧数据: {e}")
return encrypted_password
@@ -209,6 +138,7 @@ def is_encrypted(password: str) -> bool:
"""
if not password:
return False
# Fernet加密的数据是base64编码以'gAAAAA'开头
return password.startswith('gAAAAA')
@@ -227,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"
@@ -272,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

@@ -1,4 +0,0 @@
# Netscape HTTP Cookie File
# This file was generated by zsglpt
postoa.aidunsoft.com FALSE / FALSE 0 ASP.NET_SessionId xtjioeuz4yvk4bx3xqyt0pyp
postoa.aidunsoft.com FALSE / FALSE 1800092244 UserInfo userName=13974663700&Pwd=9B8DC766B11550651353D98805B4995B

View File

@@ -1 +0,0 @@
_S5Vpk71XaK9bm5U8jHJe-x2ASm38YWNweVlmCcIauM=

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
4abccefe523ed05bdbb717d1153e202d25ade95458c4d78e

View File

@@ -104,29 +104,29 @@ def _migrate_to_v1(conn):
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(" [OK] 添加 schedule_weekdays 字段")
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(" [OK] 添加 max_screenshot_concurrent 字段")
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(" [OK] 添加 max_concurrent_per_account 字段")
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(" [OK] 添加 auto_approve_enabled 字段")
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(" [OK] 添加 auto_approve_hourly_limit 字段")
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(" [OK] 添加 auto_approve_vip_days 字段")
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(" [OK] 添加 duration 字段到 task_logs")
print(" 添加 duration 字段到 task_logs")
conn.commit()
@@ -140,19 +140,19 @@ def _migrate_to_v2(conn):
if "proxy_enabled" not in columns:
cursor.execute("ALTER TABLE system_config ADD COLUMN proxy_enabled INTEGER DEFAULT 0")
print(" [OK] 添加 proxy_enabled 字段")
print(" 添加 proxy_enabled 字段")
if "proxy_api_url" not in columns:
cursor.execute('ALTER TABLE system_config ADD COLUMN proxy_api_url TEXT DEFAULT ""')
print(" [OK] 添加 proxy_api_url 字段")
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(" [OK] 添加 proxy_expire_minutes 字段")
print(" 添加 proxy_expire_minutes 字段")
if "enable_screenshot" not in columns:
cursor.execute("ALTER TABLE system_config ADD COLUMN enable_screenshot INTEGER DEFAULT 1")
print(" [OK] 添加 enable_screenshot 字段")
print(" 添加 enable_screenshot 字段")
conn.commit()
@@ -166,15 +166,15 @@ def _migrate_to_v3(conn):
if "status" not in columns:
cursor.execute('ALTER TABLE accounts ADD COLUMN status TEXT DEFAULT "active"')
print(" [OK] 添加 accounts.status 字段 (账号状态)")
print(" 添加 accounts.status 字段 (账号状态)")
if "login_fail_count" not in columns:
cursor.execute("ALTER TABLE accounts ADD COLUMN login_fail_count INTEGER DEFAULT 0")
print(" [OK] 添加 accounts.login_fail_count 字段 (登录失败计数)")
print(" 添加 accounts.login_fail_count 字段 (登录失败计数)")
if "last_login_error" not in columns:
cursor.execute("ALTER TABLE accounts ADD COLUMN last_login_error TEXT")
print(" [OK] 添加 accounts.last_login_error 字段 (最后登录错误)")
print(" 添加 accounts.last_login_error 字段 (最后登录错误)")
conn.commit()
@@ -188,7 +188,7 @@ def _migrate_to_v4(conn):
if "source" not in columns:
cursor.execute('ALTER TABLE task_logs ADD COLUMN source TEXT DEFAULT "manual"')
print(" [OK] 添加 task_logs.source 字段 (任务来源: manual/scheduled/immediate)")
print(" 添加 task_logs.source 字段 (任务来源: manual/scheduled/immediate)")
conn.commit()
@@ -219,7 +219,7 @@ def _migrate_to_v5(conn):
)
"""
)
print(" [OK] 创建 user_schedules 表 (用户定时任务)")
print(" 创建 user_schedules 表 (用户定时任务)")
cursor.execute(
"""
@@ -243,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()
@@ -271,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():
@@ -290,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()
@@ -351,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):
@@ -363,11 +363,11 @@ def _migrate_to_v8(conn):
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(" [OK] 添加 user_schedules.random_delay 字段")
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(" [OK] 添加 user_schedules.next_run_at 字段")
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()
@@ -420,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}")
@@ -441,15 +441,15 @@ def _migrate_to_v9(conn):
changed = False
if "register_verify_enabled" not in columns:
cursor.execute("ALTER TABLE email_settings ADD COLUMN register_verify_enabled INTEGER DEFAULT 0")
print(" [OK] 添加 email_settings.register_verify_enabled 字段")
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(" [OK] 添加 email_settings.base_url 字段")
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(" [OK] 添加 email_settings.task_notify_enabled 字段")
print(" 添加 email_settings.task_notify_enabled 字段")
changed = True
if changed:
@@ -465,11 +465,11 @@ def _migrate_to_v10(conn):
changed = False
if "email_verified" not in columns:
cursor.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0")
print(" [OK] 添加 users.email_verified 字段")
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(" [OK] 添加 users.email_notify_enabled 字段")
print(" 添加 users.email_notify_enabled 字段")
changed = True
if changed:
@@ -495,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}")
@@ -668,7 +668,7 @@ def _migrate_to_v15(conn):
changed = False
if "login_alert_enabled" not in columns:
cursor.execute("ALTER TABLE email_settings ADD COLUMN login_alert_enabled INTEGER DEFAULT 1")
print(" [OK] 添加 email_settings.login_alert_enabled 字段")
print(" 添加 email_settings.login_alert_enabled 字段")
changed = True
try:
@@ -692,7 +692,7 @@ def _migrate_to_v16(conn):
if "image_url" not in columns:
cursor.execute("ALTER TABLE announcements ADD COLUMN image_url TEXT")
conn.commit()
print(" [OK] 添加 announcements.image_url 字段")
print(" 添加 announcements.image_url 字段")
def _migrate_to_v17(conn):
@@ -716,7 +716,7 @@ def _migrate_to_v17(conn):
for field, ddl in system_fields:
if field not in columns:
cursor.execute(f"ALTER TABLE system_config ADD COLUMN {field} {ddl}")
print(f" [OK] 添加 system_config.{field} 字段")
print(f" 添加 system_config.{field} 字段")
cursor.execute("PRAGMA table_info(users)")
columns = [col[1] for col in cursor.fetchall()]
@@ -728,7 +728,7 @@ def _migrate_to_v17(conn):
for field, ddl in user_fields:
if field not in columns:
cursor.execute(f"ALTER TABLE users ADD COLUMN {field} {ddl}")
print(f" [OK] 添加 users.{field} 字段")
print(f" 添加 users.{field} 字段")
conn.commit()
@@ -742,10 +742,10 @@ def _migrate_to_v18(conn):
if "kdocs_row_start" not in columns:
cursor.execute("ALTER TABLE system_config ADD COLUMN kdocs_row_start INTEGER DEFAULT 0")
print(" [OK] 添加 system_config.kdocs_row_start 字段")
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(" [OK] 添加 system_config.kdocs_row_end 字段")
print(" 添加 system_config.kdocs_row_end 字段")
conn.commit()

View File

@@ -45,9 +45,9 @@ class ConnectionPool:
conn = sqlite3.connect(self.database, check_same_thread=False)
conn.row_factory = sqlite3.Row
# 设置WAL模式提高并发性能
conn.execute("PRAGMA journal_mode=WAL")
conn.execute('PRAGMA journal_mode=WAL')
# 设置合理的超时时间
conn.execute("PRAGMA busy_timeout=5000")
conn.execute('PRAGMA busy_timeout=5000')
return conn
def get_connection(self):
@@ -109,26 +109,17 @@ class ConnectionPool:
with self._lock:
# 双重检查:确保池确实需要补充
if self._pool.qsize() < self.pool_size:
new_conn = None
try:
new_conn = self._create_connection()
self._pool.put(new_conn, block=False)
# 只有成功放入池后才增加计数
self._created_connections += 1
self._pool.put(new_conn, block=False)
except Full:
# 在获取锁期间池被填满了,关闭新建的连接
if new_conn:
try:
new_conn.close()
except Exception:
pass
try:
new_conn.close()
except Exception:
pass
except Exception as create_error:
# 创建连接失败,确保关闭已创建的连接
if new_conn:
try:
new_conn.close()
except Exception:
pass
print(f"重建连接失败: {create_error}")
def close_all(self):
@@ -143,10 +134,10 @@ class ConnectionPool:
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
}
@@ -254,7 +245,7 @@ def init_pool(database, pool_size=5):
with _pool_lock:
if _pool is None:
_pool = ConnectionPool(database, pool_size)
print(f"[OK] 数据库连接池已初始化 (大小: {pool_size})")
print(f" 数据库连接池已初始化 (大小: {pool_size})")
def get_db():

View File

@@ -7,65 +7,48 @@ 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
# 服务器配置
- 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
# 并发控制配置
- 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=false
- MAX_CAPTCHA_ATTEMPTS=5
- MAX_IP_ATTEMPTS_PER_HOUR=10
# 日志配置
- LOG_LEVEL=INFO
- 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: 5m

1591
playwright_automation.py Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -570,34 +570,10 @@ def get_docker_stats():
logger.debug(f"读取CPU信息失败: {e}")
try:
# 读取系统运行时间
with open('/proc/uptime', 'r') as f:
system_uptime = float(f.read().split()[0])
# 读取 PID 1 的启动时间 (jiffies)
with open('/proc/1/stat', 'r') as f:
stat = f.read().split()
starttime_jiffies = int(stat[21])
# 获取 CLK_TCK (通常是 100)
clk_tck = os.sysconf(os.sysconf_names['SC_CLK_TCK'])
# 计算容器运行时长(秒)
container_uptime_seconds = system_uptime - (starttime_jiffies / clk_tck)
# 格式化为可读字符串
days = int(container_uptime_seconds // 86400)
hours = int((container_uptime_seconds % 86400) // 3600)
minutes = int((container_uptime_seconds % 3600) // 60)
if days > 0:
docker_status["uptime"] = f"{days}{hours}小时{minutes}分钟"
elif hours > 0:
docker_status["uptime"] = f"{hours}小时{minutes}分钟"
else:
docker_status["uptime"] = f"{minutes}分钟"
result = subprocess.check_output(["uptime", "-p"]).decode("utf-8").strip()
docker_status["uptime"] = result.replace("up ", "")
except Exception as e:
logger.debug(f"获取容器运行时间失败: {e}")
logger.debug(f"获取运行时间失败: {e}")
docker_status["status"] = "Running"

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

112
services/browser_manager.py Normal file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import threading
import time
from typing import Optional
from app_logger import get_logger
from browser_installer import check_and_install_browser
from playwright_automation import PlaywrightBrowserManager
logger = get_logger("browser_manager")
_browser_manager: Optional[PlaywrightBrowserManager] = None
_lock = threading.Lock()
_cond = threading.Condition(_lock)
_init_in_progress = False
_init_error: Optional[str] = None
_init_thread: Optional[threading.Thread] = None
def get_browser_manager() -> Optional[PlaywrightBrowserManager]:
return _browser_manager
def is_browser_manager_ready() -> bool:
return _browser_manager is not None
def get_browser_manager_init_error() -> Optional[str]:
return _init_error
def init_browser_manager(*, block: bool = True, timeout: Optional[float] = None) -> bool:
global _browser_manager
global _init_in_progress, _init_error
deadline = time.monotonic() + float(timeout) if timeout is not None else None
with _cond:
if _browser_manager is not None:
return True
if _init_in_progress:
if not block:
return False
while _init_in_progress:
if deadline is None:
_cond.wait(timeout=0.5)
continue
remaining = deadline - time.monotonic()
if remaining <= 0:
break
_cond.wait(timeout=min(0.5, remaining))
return _browser_manager is not None
_init_in_progress = True
_init_error = None
ok = False
error: Optional[str] = None
manager: Optional[PlaywrightBrowserManager] = None
try:
logger.info("正在初始化Playwright浏览器管理器...")
if not check_and_install_browser(log_callback=lambda msg, account_id=None: logger.info(str(msg))):
error = "浏览器环境检查失败"
logger.error("浏览器环境检查失败!")
ok = False
else:
manager = PlaywrightBrowserManager(
headless=True,
log_callback=lambda msg, account_id=None: logger.info(str(msg)),
)
ok = True
logger.info("Playwright浏览器管理器创建成功")
except Exception as exc:
error = f"{type(exc).__name__}: {exc}"
logger.exception("初始化Playwright浏览器管理器时发生异常")
ok = False
with _cond:
if ok and manager is not None:
_browser_manager = manager
else:
_init_error = error or "初始化失败"
_init_in_progress = False
_cond.notify_all()
return ok
def init_browser_manager_async() -> None:
"""异步初始化浏览器环境,避免阻塞 Web 请求/服务启动。"""
global _init_thread
def _worker():
try:
init_browser_manager(block=True)
except Exception:
logger.exception("异步初始化浏览器管理器失败")
with _cond:
if _browser_manager is not None:
return
if _init_thread and _init_thread.is_alive():
return
if _init_in_progress:
return
_init_thread = threading.Thread(target=_worker, daemon=True, name="browser-manager-init")
_init_thread.start()

View File

@@ -1,15 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
KDocs Uploader with Auto-Recovery Mechanism
自动恢复机制:当检测到上传线程卡住时,自动重启线程
优化记录 (2026-01-21):
- 删除无效的二分搜索相关代码 (_binary_search_person, _name_matches, _name_less_than, _get_cell_value_fast)
- 优化 sleep 等待时间,减少约 30% 的等待
- 添加缓存过期机制 (5分钟 TTL)
- 优化日志级别,减少调试日志噪音
"""
from __future__ import annotations
import base64
@@ -19,7 +9,7 @@ import re
import threading
import time
from io import BytesIO
from typing import Any, Dict, Optional, Tuple
from typing import Any, Dict, Optional
from urllib.parse import urlparse
import database
@@ -41,19 +31,11 @@ except Exception: # pragma: no cover - 运行环境缺少 playwright 时降级
logger = get_logger()
config = get_config()
# 看门狗配置
WATCHDOG_CHECK_INTERVAL = 60 # 每60秒检查一次
WATCHDOG_TIMEOUT = 300 # 如果5分钟没有活动且队列有任务认为线程卡住
# 缓存配置
CACHE_TTL_SECONDS = 300 # 缓存过期时间: 5分钟
class KDocsUploader:
def __init__(self) -> None:
self._queue: queue.Queue = queue.Queue(maxsize=int(os.environ.get("KDOCS_QUEUE_MAXSIZE", "200")))
self._thread: Optional[threading.Thread] = None
self._thread_id = 0 # 线程ID用于追踪重启次数
self._thread = threading.Thread(target=self._run, name="kdocs-uploader", daemon=True)
self._running = False
self._last_error: Optional[str] = None
self._last_success_at: Optional[float] = None
@@ -67,111 +49,17 @@ class KDocsUploader:
self._last_login_ok: Optional[bool] = None
self._doc_url: Optional[str] = None
# 自动恢复机制相关
self._last_activity: float = time.time() # 最后活动时间
self._watchdog_thread: Optional[threading.Thread] = None
self._watchdog_running = False
self._restart_count = 0 # 重启次数统计
self._lock = threading.Lock() # 线程安全锁
# 人员位置缓存: {cache_key: (row_num, timestamp)}
self._person_cache: Dict[str, Tuple[int, float]] = {}
def start(self) -> None:
with self._lock:
if self._running:
return
self._running = True
self._thread_id += 1
self._thread = threading.Thread(
target=self._run,
name=f"kdocs-uploader-{self._thread_id}",
daemon=True
)
self._thread.start()
self._last_activity = time.time()
# 启动看门狗线程
if not self._watchdog_running:
self._watchdog_running = True
self._watchdog_thread = threading.Thread(
target=self._watchdog_run,
name="kdocs-watchdog",
daemon=True
)
self._watchdog_thread.start()
logger.info("[KDocs] 看门狗线程已启动")
if self._running:
return
self._running = True
self._thread.start()
def stop(self) -> None:
with self._lock:
if not self._running:
return
self._running = False
self._watchdog_running = False
self._queue.put({"action": "shutdown"})
def _watchdog_run(self) -> None:
"""看门狗线程:监控上传线程健康状态"""
logger.info("[KDocs] 看门狗开始监控")
while self._watchdog_running:
try:
time.sleep(WATCHDOG_CHECK_INTERVAL)
if not self._running:
continue
# 检查线程是否存活
if self._thread is None or not self._thread.is_alive():
logger.warning("[KDocs] 检测到上传线程已停止,正在重启...")
self._restart_thread()
continue
# 检查是否有任务堆积且长时间无活动
queue_size = self._queue.qsize()
time_since_activity = time.time() - self._last_activity
if queue_size > 0 and time_since_activity > WATCHDOG_TIMEOUT:
logger.warning(
f"[KDocs] 检测到上传线程可能卡住: "
f"队列={queue_size}, 无活动时间={time_since_activity:.0f}"
)
self._restart_thread()
except Exception as e:
logger.warning(f"[KDocs] 看门狗检查异常: {e}")
def _restart_thread(self) -> None:
"""重启上传线程"""
with self._lock:
self._restart_count += 1
logger.warning(f"[KDocs] 正在重启上传线程 (第{self._restart_count}次重启)")
# 清理浏览器资源
try:
self._cleanup_browser()
except Exception as e:
logger.warning(f"[KDocs] 清理浏览器时出错: {e}")
# 停止旧线程(如果还在运行)
old_running = self._running
self._running = False
# 等待一小段时间让旧线程有机会退出
time.sleep(1)
# 启动新线程
self._running = True
self._thread_id += 1
self._thread = threading.Thread(
target=self._run,
name=f"kdocs-uploader-{self._thread_id}",
daemon=True
)
self._thread.start()
self._last_activity = time.time()
self._last_error = f"线程已自动恢复 (第{self._restart_count}次)"
logger.info(f"[KDocs] 上传线程已重启 (ID={self._thread_id})")
if not self._running:
return
self._running = False
self._queue.put({"action": "shutdown"})
def get_status(self) -> Dict[str, Any]:
return {
@@ -180,8 +68,6 @@ class KDocsUploader:
"last_error": self._last_error,
"last_success_at": self._last_success_at,
"last_login_ok": self._last_login_ok,
"restart_count": self._restart_count,
"thread_alive": self._thread.is_alive() if self._thread else False,
}
def enqueue_upload(
@@ -204,15 +90,6 @@ class KDocsUploader:
"image_path": image_path,
}
try:
# 入队前设置状态为等待上传
try:
account = safe_get_account(user_id, account_id)
if account and self._should_mark_upload(account):
account.status = "等待上传"
self._emit_account_update(user_id, account)
except Exception:
pass
self._queue.put({"action": "upload", "payload": payload}, timeout=1)
return True
except queue.Full:
@@ -244,57 +121,28 @@ class KDocsUploader:
return {"success": False, "error": "操作超时"}
def _run(self) -> None:
thread_id = self._thread_id
logger.info(f"[KDocs] 上传线程启动 (ID={thread_id})")
while self._running:
while True:
task = self._queue.get()
if not task:
continue
action = task.get("action")
if action == "shutdown":
break
try:
# 使用超时获取任务,以便定期检查 _running 状态
try:
task = self._queue.get(timeout=5)
except queue.Empty:
continue
if not task:
continue
# 更新最后活动时间
self._last_activity = time.time()
action = task.get("action")
if action == "shutdown":
break
try:
if action == "upload":
self._handle_upload(task.get("payload") or {})
elif action == "qr":
result = self._handle_qr(task.get("payload") or {})
task.get("response").put(result)
elif action == "clear_login":
result = self._handle_clear_login()
task.get("response").put(result)
elif action == "status":
result = self._handle_status_check()
task.get("response").put(result)
# 任务处理完成后更新活动时间
self._last_activity = time.time()
except Exception as e:
logger.warning(f"[KDocs] 处理任务失败: {e}")
# 如果有响应队列,返回错误
if "response" in task and task.get("response"):
try:
task["response"].put({"success": False, "error": str(e)})
except Exception:
pass
if action == "upload":
self._handle_upload(task.get("payload") or {})
elif action == "qr":
result = self._handle_qr(task.get("payload") or {})
task.get("response").put(result)
elif action == "clear_login":
result = self._handle_clear_login()
task.get("response").put(result)
elif action == "status":
result = self._handle_status_check()
task.get("response").put(result)
except Exception as e:
logger.warning(f"[KDocs] 线程主循环异常: {e}")
time.sleep(1) # 避免异常时的紧密循环
logger.warning(f"[KDocs] 处理任务失败: {e}")
logger.info(f"[KDocs] 上传线程退出 (ID={thread_id})")
self._cleanup_browser()
def _load_system_config(self) -> Dict[str, Any]:
@@ -323,7 +171,6 @@ class KDocsUploader:
except Exception as e:
self._last_error = f"浏览器启动失败: {e}"
self._cleanup_browser()
return False
def _cleanup_browser(self) -> None:
@@ -377,7 +224,7 @@ class KDocsUploader:
fast_timeout = int(os.environ.get("KDOCS_FAST_GOTO_TIMEOUT_MS", "15000"))
goto_kwargs = {"wait_until": "domcontentloaded", "timeout": fast_timeout}
self._page.goto(doc_url, **goto_kwargs)
time.sleep(0.5) # 优化: 0.6 -> 0.5
time.sleep(0.6)
doc_pages = self._find_doc_pages(doc_url)
if doc_pages and doc_pages[0] is not self._page:
self._page = doc_pages[0]
@@ -532,7 +379,7 @@ class KDocsUploader:
clicked = True
break
if clicked:
time.sleep(1.2) # 优化: 1.5 -> 1.2
time.sleep(1.5)
pages = self._iter_pages()
for page in pages:
if self._try_click_names(
@@ -568,12 +415,10 @@ class KDocsUploader:
pages.extend(self._context.pages)
if self._page and self._page not in pages:
pages.insert(0, self._page)
def rank(p) -> int:
url = (getattr(p, "url", "") or "").lower()
keywords = ("login", "account", "passport", "wechat", "qr")
return 0 if any(k in url for k in keywords) else 1
pages.sort(key=rank)
return pages
@@ -667,7 +512,7 @@ class KDocsUploader:
el = page.get_by_role(role, name=name)
if el.is_visible(timeout=timeout):
el.click()
time.sleep(0.8) # 优化: 1 -> 0.8
time.sleep(1)
return True
except Exception:
return False
@@ -692,7 +537,7 @@ class KDocsUploader:
el = page.get_by_text(name, exact=True)
if el.is_visible(timeout=timeout_ms):
el.click()
time.sleep(0.8) # 优化: 1 -> 0.8
time.sleep(1)
return True
except Exception:
pass
@@ -701,7 +546,7 @@ class KDocsUploader:
el = page.get_by_text(name, exact=False)
if el.is_visible(timeout=timeout_ms):
el.click()
time.sleep(0.8) # 优化: 1 -> 0.8
time.sleep(1)
return True
except Exception:
pass
@@ -712,7 +557,7 @@ class KDocsUploader:
el = frame.get_by_role("button", name=name)
if el.is_visible(timeout=frame_timeout_ms):
el.click()
time.sleep(0.8) # 优化: 1 -> 0.8
time.sleep(1)
return True
except Exception:
pass
@@ -720,7 +565,7 @@ class KDocsUploader:
el = frame.get_by_text(name, exact=True)
if el.is_visible(timeout=frame_timeout_ms):
el.click()
time.sleep(0.8) # 优化: 1 -> 0.8
time.sleep(1)
return True
except Exception:
pass
@@ -729,7 +574,7 @@ class KDocsUploader:
el = frame.get_by_text(name, exact=False)
if el.is_visible(timeout=frame_timeout_ms):
el.click()
time.sleep(0.8) # 优化: 1 -> 0.8
time.sleep(1)
return True
except Exception:
pass
@@ -870,7 +715,7 @@ class KDocsUploader:
break
if candidate:
invalid_qr = candidate
time.sleep(0.8) # 优化: 1 -> 0.8
time.sleep(1)
if not qr_image:
self._last_error = "二维码识别异常" if invalid_qr else "二维码获取失败"
try:
@@ -928,7 +773,6 @@ class KDocsUploader:
self._login_required = False
self._last_login_ok = None
self._cleanup_browser()
return {"success": True}
def _handle_status_check(self) -> Dict[str, Any]:
@@ -1067,7 +911,10 @@ class KDocsUploader:
if not settings.get("enabled", False):
return
subject = "金山文档上传失败提醒"
body = f"上传失败\n\n人员: {unit}-{name}\n图片: {image_path}\n错误: {error}\n\n请检查登录状态或表格配置。"
body = (
f"上传失败\n\n人员: {unit}-{name}\n图片: {image_path}\n错误: {error}\n\n"
"请检查登录状态或表格配置。"
)
try:
email_service.send_email_async(
to_email=to_email,
@@ -1090,12 +937,9 @@ class KDocsUploader:
return
if getattr(account, "is_running", False):
return
current_status = getattr(account, "status", "")
# 只处理上传相关的状态
if current_status not in ("上传截图", "等待上传"):
if getattr(account, "status", "") != "上传截图":
return
# 上传完成后恢复为未开始,而不是恢复到之前的等待上传状态
account.status = "未开始"
account.status = prev_status or "未开始"
self._emit_account_update(user_id, account)
def _select_sheet(self, sheet_name: str, sheet_index: int) -> None:
@@ -1110,7 +954,7 @@ class KDocsUploader:
if locator.count() < 1:
continue
locator.first.click()
time.sleep(0.4) # 优化: 0.5 -> 0.4
time.sleep(0.5)
return
except Exception:
continue
@@ -1127,14 +971,17 @@ class KDocsUploader:
if locator.count() <= idx:
continue
locator.nth(idx).click()
time.sleep(0.4) # 优化: 0.5 -> 0.4
time.sleep(0.5)
return
except Exception:
continue
def _get_current_cell_address(self) -> str:
"""获取当前选中的单元格地址(如 A1, C66 等)"""
# 优化: 移除顶部的固定 sleep改用更短的重试间隔
import re
# 等待一小段时间让名称框稳定
time.sleep(0.1)
for attempt in range(3):
try:
name_box = self._page.locator("input.edit-box").first
@@ -1154,10 +1001,10 @@ class KDocsUploader:
pass
# 等待一下再重试
time.sleep(0.15) # 优化: 0.2 -> 0.15
time.sleep(0.2)
# 如果无法获取有效地址,返回空字符串
logger.debug("[KDocs] 无法获取有效的单元格地址") # 优化: warning -> debug
logger.warning("[KDocs调试] 无法获取有效的单元格地址")
return ""
def _navigate_to_cell(self, cell_address: str) -> None:
@@ -1171,7 +1018,7 @@ class KDocsUploader:
name_box.click()
name_box.fill(cell_address)
name_box.press("Enter")
time.sleep(0.25) # 优化: 0.3 -> 0.25
time.sleep(0.3)
def _focus_grid(self) -> None:
try:
@@ -1193,7 +1040,7 @@ class KDocsUploader:
)
if info and info.get("x") and info.get("y"):
self._page.mouse.click(info["x"], info["y"])
time.sleep(0.08) # 优化: 0.1 -> 0.08
time.sleep(0.1)
except Exception:
pass
@@ -1205,7 +1052,7 @@ class KDocsUploader:
def _get_cell_value(self, cell_address: str) -> str:
self._navigate_to_cell(cell_address)
time.sleep(0.25) # 优化: 0.3 -> 0.25
time.sleep(0.3)
try:
self._page.evaluate("() => navigator.clipboard.writeText('')")
except Exception:
@@ -1214,6 +1061,7 @@ class KDocsUploader:
# 尝试方法1: 读取金山文档编辑栏/公式栏的内容
try:
# 金山文档的编辑栏选择器(可能需要调整)
formula_bar_selectors = [
".formula-bar-input",
".cell-editor-input",
@@ -1226,9 +1074,9 @@ class KDocsUploader:
try:
el = self._page.query_selector(selector)
if el:
value = el.input_value() if hasattr(el, "input_value") else el.inner_text()
value = el.input_value() if hasattr(el, 'input_value') else el.inner_text()
if value and not value.startswith("=DISPIMG"):
logger.debug(f"[KDocs] 从编辑栏读取到: '{value[:50]}...'") # 优化: info -> debug
logger.info(f"[KDocs调试] 从编辑栏读取到: '{value[:50]}...' (selector={selector})")
return value.strip()
except Exception:
pass
@@ -1238,13 +1086,13 @@ class KDocsUploader:
# 尝试方法2: F2进入编辑模式全选复制
try:
self._page.keyboard.press("F2")
time.sleep(0.15) # 优化: 0.2 -> 0.15
time.sleep(0.2)
self._page.keyboard.press("Control+a")
time.sleep(0.08) # 优化: 0.1 -> 0.08
time.sleep(0.1)
self._page.keyboard.press("Control+c")
time.sleep(0.15) # 优化: 0.2 -> 0.15
time.sleep(0.2)
self._page.keyboard.press("Escape")
time.sleep(0.08) # 优化: 0.1 -> 0.08
time.sleep(0.1)
value = self._read_clipboard_text()
if value and not value.startswith("=DISPIMG"):
return value.strip()
@@ -1254,7 +1102,7 @@ class KDocsUploader:
# 尝试方法3: 直接复制单元格(备选)
try:
self._page.keyboard.press("Control+c")
time.sleep(0.15) # 优化: 0.2 -> 0.15
time.sleep(0.2)
value = self._read_clipboard_text()
if value:
return value.strip()
@@ -1295,7 +1143,7 @@ class KDocsUploader:
def _search_person(self, name: str) -> None:
self._focus_grid()
self._page.keyboard.press("Control+f")
time.sleep(0.25) # 优化: 0.3 -> 0.25
time.sleep(0.3)
search_input = None
selectors = [
"input[placeholder*='查找']",
@@ -1325,7 +1173,7 @@ class KDocsUploader:
self._page.keyboard.type(name)
except Exception:
pass
time.sleep(0.15) # 优化: 0.2 -> 0.15
time.sleep(0.2)
try:
find_btn = self._page.get_by_role("button", name="查找").nth(2)
find_btn.click()
@@ -1337,7 +1185,7 @@ class KDocsUploader:
self._page.keyboard.press("Enter")
except Exception:
pass
time.sleep(0.25) # 优化: 0.3 -> 0.25
time.sleep(0.3)
def _find_next(self) -> None:
try:
@@ -1351,71 +1199,147 @@ class KDocsUploader:
self._page.keyboard.press("Enter")
except Exception:
pass
time.sleep(0.25) # 优化: 0.3 -> 0.25
time.sleep(0.3)
def _close_search(self) -> None:
self._page.keyboard.press("Escape")
time.sleep(0.15) # 优化: 0.2 -> 0.15
time.sleep(0.2)
def _extract_row_number(self, cell_address: str) -> int:
import re
match = re.search(r"(\d+)$", cell_address)
if match:
return int(match.group(1))
return -1
def _get_cached_person(self, cache_key: str) -> Optional[int]:
"""获取缓存的人员位置(带过期检查)"""
if cache_key not in self._person_cache:
return None
row_num, timestamp = self._person_cache[cache_key]
if time.time() - timestamp > CACHE_TTL_SECONDS:
# 缓存已过期,删除并返回 None
del self._person_cache[cache_key]
logger.debug(f"[KDocs] 缓存已过期: {cache_key}")
return None
return row_num
def _verify_unit_by_navigation(self, row_num: int, unit: str, unit_col: str) -> bool:
"""验证县区 - 从目标行开始搜索县区"""
logger.info(f"[KDocs调试] 验证县区: 期望行={row_num}, 期望值='{unit}'")
def _set_cached_person(self, cache_key: str, row_num: int) -> None:
"""设置人员位置缓存"""
self._person_cache[cache_key] = (row_num, time.time())
# 方法: 先导航到目标行的A列然后从那里搜索县区
try:
# 1. 先导航到目标行的 A 列
start_cell = f"{unit_col}{row_num}"
self._navigate_to_cell(start_cell)
time.sleep(0.3)
logger.info(f"[KDocs调试] 已导航到 {start_cell}")
def _find_person_with_unit(
self, unit: str, name: str, unit_col: str, max_attempts: int = 10, row_start: int = 0, row_end: int = 0
) -> int:
# 2. 从当前位置搜索县区
self._page.keyboard.press("Control+f")
time.sleep(0.3)
# 找到搜索框并输入
try:
search_input = self._page.locator("input[placeholder*='查找'], input[placeholder*='搜索'], input[type='text']").first
search_input.fill(unit)
time.sleep(0.2)
self._page.keyboard.press("Enter")
time.sleep(0.5)
except Exception as e:
logger.warning(f"[KDocs调试] 填写搜索框失败: {e}")
self._page.keyboard.press("Escape")
return False
# 3. 关闭搜索框,检查当前位置
self._page.keyboard.press("Escape")
time.sleep(0.3)
current_address = self._get_current_cell_address()
found_row = self._extract_row_number(current_address)
logger.info(f"[KDocs调试] 搜索'{unit}'后: 当前单元格={current_address}, 行号={found_row}")
# 4. 检查是否在同一行(允许在目标行或之后的几行内,因为搜索可能从当前位置向下)
if found_row == row_num:
logger.info(f"[KDocs调试] ✓ 验证成功! 县区'{unit}'在第{row_num}")
return True
else:
logger.info(f"[KDocs调试] 验证失败: 期望行{row_num}, 实际找到行{found_row}")
return False
except Exception as e:
logger.warning(f"[KDocs调试] 验证异常: {e}")
return False
def _debug_dump_page_elements(self) -> None:
"""调试: 输出页面上可能包含单元格值的元素"""
logger.info("[KDocs调试] ========== 页面元素分析 ==========")
try:
# 查找可能的编辑栏元素
selectors_to_check = [
"input", "textarea",
"[class*='formula']", "[class*='Formula']",
"[class*='editor']", "[class*='Editor']",
"[class*='cell']", "[class*='Cell']",
"[class*='input']", "[class*='Input']",
]
for selector in selectors_to_check:
try:
elements = self._page.query_selector_all(selector)
for i, el in enumerate(elements[:3]): # 只看前3个
try:
class_name = el.get_attribute("class") or ""
value = ""
try:
value = el.input_value()
except:
try:
value = el.inner_text()
except:
pass
if value:
logger.info(f"[KDocs调试] 元素 {selector}[{i}] class='{class_name[:50]}' value='{value[:30]}'")
except:
pass
except:
pass
except Exception as e:
logger.warning(f"[KDocs调试] 页面元素分析失败: {e}")
logger.info("[KDocs调试] ====================================")
def _debug_dump_table_structure(self, target_row: int = 66) -> None:
"""调试: 输出表格结构"""
self._debug_dump_page_elements() # 先分析页面元素
logger.info("[KDocs调试] ========== 表格结构分析 ==========")
cols = ['A', 'B', 'C', 'D', 'E']
for row in [1, 2, 3, target_row]:
row_data = []
for col in cols:
val = self._get_cell_value(f"{col}{row}")
# 截断太长的值
if len(val) > 30:
val = val[:30] + "..."
row_data.append(f"{col}{row}='{val}'")
logger.info(f"[KDocs调试] 第{row}行: {' | '.join(row_data)}")
logger.info("[KDocs调试] ====================================")
def _find_person_with_unit(self, unit: str, name: str, unit_col: str, max_attempts: int = 50,
row_start: int = 0, row_end: int = 0) -> int:
"""
查找人员所在行号。
策略只搜索姓名找到姓名列C列的匹配项
注意:组合搜索会匹配到图片列的错误位置,已放弃该方案
:param row_start: 有效行范围起始0表示不限制
:param row_end: 有效行范围结束0表示不限制
"""
logger.debug(f"[KDocs] 开始搜索人员: name='{name}', unit='{unit}'") # 优化: info -> debug
logger.info(f"[KDocs调试] 开始搜索人员: name='{name}', unit='{unit}'")
if row_start > 0 or row_end > 0:
logger.debug(f"[KDocs] 有效行范围: {row_start}-{row_end}") # 优化: info -> debug
logger.info(f"[KDocs调试] 有效行范围: {row_start}-{row_end}")
# 带过期检查的缓存
cache_key = f"{name}_{unit}_{unit_col}"
cached_row = self._get_cached_person(cache_key)
if cached_row is not None:
logger.debug(f"[KDocs] 使用缓存找到人员: name='{name}', row={cached_row}") # 优化: info -> debug
return cached_row
# 使用线性搜索Ctrl+F 方式)
row_num = self._search_and_get_row(
name, max_attempts=max_attempts, expected_col="C", row_start=row_start, row_end=row_end
)
# 只搜索姓名 - 这是目前唯一可靠的方式
logger.info(f"[KDocs调试] 搜索姓名: '{name}'")
row_num = self._search_and_get_row(name, max_attempts=max_attempts, expected_col='C',
row_start=row_start, row_end=row_end)
if row_num > 0:
logger.info(f"[KDocs] 找到人员: name='{name}', row={row_num}")
# 缓存结果(带时间戳)
self._set_cached_person(cache_key, row_num)
logger.info(f"[KDocs调试] ✓ 姓名搜索成功! 找到行号={row_num}")
return row_num
logger.warning(f"[KDocs] 搜索失败,未找到人员 '{name}'")
logger.warning(f"[KDocs调试] 搜索失败,未找到人员 '{name}'")
return -1
def _search_and_get_row(
self, search_text: str, max_attempts: int = 10, expected_col: str = None, row_start: int = 0, row_end: int = 0
) -> int:
def _search_and_get_row(self, search_text: str, max_attempts: int = 10, expected_col: str = None,
row_start: int = 0, row_end: int = 0) -> int:
"""
执行搜索并获取找到的行号
:param search_text: 要搜索的文本
@@ -1430,39 +1354,35 @@ class KDocsUploader:
for attempt in range(max_attempts):
self._close_search()
time.sleep(0.2) # 优化: 0.3 -> 0.2
time.sleep(0.3) # 等待名称框更新
current_address = self._get_current_cell_address()
if not current_address:
logger.debug(f"[KDocs] 第{attempt + 1}次: 无法获取单元格地址") # 优化: warning -> debug
logger.warning(f"[KDocs调试] 第{attempt+1}次: 无法获取单元格地址")
# 继续尝试下一个
self._page.keyboard.press("Control+f")
time.sleep(0.15) # 优化: 0.2 -> 0.15
time.sleep(0.2)
self._find_next()
continue
row_num = self._extract_row_number(current_address)
# 提取列字母A, B, C, D 等)
col_letter = "".join(c for c in current_address if c.isalpha()).upper()
col_letter = ''.join(c for c in current_address if c.isalpha()).upper()
logger.debug(
f"[KDocs] 第{attempt + 1}次搜索'{search_text}': 单元格={current_address}, 列={col_letter}, 行号={row_num}"
) # 优化: info -> debug
logger.info(f"[KDocs调试] 第{attempt+1}次搜索'{search_text}': 单元格={current_address}, 列={col_letter}, 行号={row_num}")
if row_num <= 0:
logger.debug(f"[KDocs] 无法提取行号,搜索可能没有结果") # 优化: warning -> debug
logger.warning(f"[KDocs调试] 无法提取行号,搜索可能没有结果")
return -1
# 检查是否已经访问过这个位置
position_key = f"{col_letter}{row_num}"
if position_key in found_positions:
logger.debug(f"[KDocs] 位置{position_key}已搜索过,循环结束") # 优化: info -> debug
logger.info(f"[KDocs调试] 位置{position_key}已搜索过,循环结束")
# 检查是否有任何有效结果
valid_results = [
pos
for pos in found_positions
if (not expected_col or pos.startswith(expected_col)) and self._extract_row_number(pos) > 2
]
valid_results = [pos for pos in found_positions
if (not expected_col or pos.startswith(expected_col))
and self._extract_row_number(pos) > 2]
if valid_results:
# 返回第一个有效结果的行号
return self._extract_row_number(valid_results[0])
@@ -1472,93 +1392,94 @@ class KDocsUploader:
# 跳过标题行和表头行通常是第1-2行
if row_num <= 2:
logger.debug(f"[KDocs] 跳过标题/表头行: {row_num}") # 优化: info -> debug
logger.info(f"[KDocs调试] 跳过标题/表头行: {row_num}")
self._page.keyboard.press("Control+f")
time.sleep(0.15) # 优化: 0.2 -> 0.15
time.sleep(0.2)
self._find_next()
continue
# 如果指定了期望的列,检查是否匹配
if expected_col and col_letter != expected_col.upper():
logger.debug(f"[KDocs] 列不匹配: 期望={expected_col}, 实际={col_letter}") # 优化: info -> debug
logger.info(f"[KDocs调试] 列不匹配: 期望={expected_col}, 实际={col_letter},继续搜索下一个")
self._page.keyboard.press("Control+f")
time.sleep(0.15) # 优化: 0.2 -> 0.15
time.sleep(0.2)
self._find_next()
continue
# 检查行号是否在有效范围内
if row_start > 0 and row_num < row_start:
logger.debug(f"[KDocs] 行号{row_num}小于起始行{row_start}") # 优化: info -> debug
logger.info(f"[KDocs调试] 行号{row_num}小于起始行{row_start},继续搜索下一个")
self._page.keyboard.press("Control+f")
time.sleep(0.15) # 优化: 0.2 -> 0.15
time.sleep(0.2)
self._find_next()
continue
if row_end > 0 and row_num > row_end:
logger.debug(f"[KDocs] 行号{row_num}大于结束行{row_end}") # 优化: info -> debug
logger.info(f"[KDocs调试] 行号{row_num}大于结束行{row_end},继续搜索下一个")
self._page.keyboard.press("Control+f")
time.sleep(0.15) # 优化: 0.2 -> 0.15
time.sleep(0.2)
self._find_next()
continue
# 找到有效的数据行,列匹配且在行范围内
logger.debug(f"[KDocs] 找到有效位置: {current_address}") # 优化: info -> debug
logger.info(f"[KDocs调试] ✓ 找到有效位置: {current_address} (在有效范围内)")
return row_num
self._close_search()
logger.debug(f"[KDocs] 达到最大尝试次数{max_attempts},未找到有效结果") # 优化: warning -> debug
logger.warning(f"[KDocs调试] 达到最大尝试次数{max_attempts},未找到有效结果")
return -1
def _upload_image_to_cell(self, row_num: int, image_path: str, image_col: str) -> bool:
cell_address = f"{image_col}{row_num}"
self._navigate_to_cell(cell_address)
time.sleep(0.3)
# 清除单元格现有内容
try:
# 1. 导航到单元格
# 1. 导航到单元格(名称框输入地址+Enter会跳转并可能进入编辑模式
self._navigate_to_cell(cell_address)
time.sleep(0.2) # 优化: 0.3 -> 0.2
time.sleep(0.3)
# 2. 按 Escape 退出可能的编辑模式,回到选中状态
self._page.keyboard.press("Escape")
time.sleep(0.2) # 优化: 0.3 -> 0.2
time.sleep(0.3)
# 3. 按 Delete 删除选中单元格的内容
self._page.keyboard.press("Delete")
time.sleep(0.4) # 优化: 0.5 -> 0.4
logger.debug(f"[KDocs] 已删除 {cell_address} 的内容") # 优化: info -> debug
time.sleep(0.5)
logger.info(f"[KDocs] 已删除 {cell_address} 的内容")
except Exception as e:
logger.warning(f"[KDocs] 清除单元格内容时出错: {e}")
logger.info(f"[KDocs] 上传图片到 {cell_address}")
logger.info(f"[KDocs] 准备上传图片到 {cell_address},已清除旧内容")
try:
insert_btn = self._page.get_by_role("button", name="插入")
insert_btn.click()
time.sleep(0.25) # 优化: 0.3 -> 0.25
time.sleep(0.3)
except Exception as e:
raise RuntimeError(f"打开插入菜单失败: {e}")
try:
image_btn = self._page.get_by_role("button", name="图片")
image_btn.click()
time.sleep(0.25) # 优化: 0.3 -> 0.25
time.sleep(0.3)
cell_image_option = self._page.get_by_role("option", name="单元格图片")
cell_image_option.click()
time.sleep(0.15) # 优化: 0.2 -> 0.15
time.sleep(0.2)
except Exception as e:
raise RuntimeError(f"选择单元格图片失败: {e}")
try:
local_option = self._page.get_by_role("option", name="本地")
# 添加超时防止无限阻塞
with self._page.expect_file_chooser(timeout=15000) as fc_info:
with self._page.expect_file_chooser() as fc_info:
local_option.click()
file_chooser = fc_info.value
file_chooser.set_files(image_path)
except Exception as e:
raise RuntimeError(f"上传文件失败: {e}")
time.sleep(1.5) # 优化: 2 -> 1.5
time.sleep(2)
return True

View File

@@ -213,9 +213,7 @@ def take_screenshot_for_account(
# 标记账号正在截图(防止重复提交截图任务)
account.is_running = True
def screenshot_task(
browser_instance, user_id, account_id, account, browse_type, source, task_start_time, browse_result
):
def screenshot_task(browser_instance, user_id, account_id, account, browse_type, source, task_start_time, browse_result):
"""在worker线程中执行的截图任务"""
# ✅ 获得worker后立即更新状态为"截图中"
acc = safe_get_account(user_id, account_id)
@@ -250,10 +248,7 @@ def take_screenshot_for_account(
def custom_log(message: str):
log_to_client(message, user_id, account_id)
# 智能登录状态检查:只在必要时才刷新登录
should_refresh_login = not is_cookie_jar_fresh(cookie_path)
if should_refresh_login and attempt > 1:
# 重试时刷新登录attempt > 1 表示第2次及以后的尝试
if not is_cookie_jar_fresh(cookie_path) or attempt > 1:
log_to_client("正在刷新登录态...", user_id, account_id)
if not _ensure_login_cookies(account, proxy_config, custom_log):
log_to_client("截图登录失败", user_id, account_id)
@@ -263,12 +258,6 @@ def take_screenshot_for_account(
continue
log_to_client("❌ 截图失败: 登录失败", user_id, account_id)
return {"success": False, "error": "登录失败"}
elif should_refresh_login:
# 首次尝试时快速检查登录状态
log_to_client("正在刷新登录态...", user_id, account_id)
if not _ensure_login_cookies(account, proxy_config, custom_log):
log_to_client("❌ 截图失败: 登录失败", user_id, account_id)
return {"success": False, "error": "登录失败"}
log_to_client(f"导航到 '{browse_type}' 页面...", user_id, account_id)
@@ -279,7 +268,7 @@ def take_screenshot_for_account(
if "注册前" in str(browse_type):
bz = 0
else:
bz = 0 # 应读(网站更新后 bz=0 为应读)
bz = 2 # 应读
target_url = f"{base}/admin/center.aspx?bz={bz}"
index_url = config.ZSGL_INDEX_URL or f"{base}/admin/index.aspx"
run_script = (
@@ -338,7 +327,7 @@ def take_screenshot_for_account(
log_callback=custom_log,
):
if os.path.exists(screenshot_path) and os.path.getsize(screenshot_path) > 1000:
log_to_client(f"[OK] 截图成功: {screenshot_filename}", user_id, account_id)
log_to_client(f" 截图成功: {screenshot_filename}", user_id, account_id)
return {"success": True, "filename": screenshot_filename}
log_to_client("截图文件异常,将重试", user_id, account_id)
if os.path.exists(screenshot_path):
@@ -407,13 +396,10 @@ def take_screenshot_for_account(
if doc_url:
user_cfg = database.get_user_kdocs_settings(user_id) or {}
if int(user_cfg.get("kdocs_auto_upload", 0) or 0) == 1:
unit = (
user_cfg.get("kdocs_unit") or cfg.get("kdocs_default_unit") or ""
).strip()
unit = (user_cfg.get("kdocs_unit") or cfg.get("kdocs_default_unit") or "").strip()
name = (account.remark or "").strip()
if unit and name:
from services.kdocs_uploader import get_kdocs_uploader
ok = get_kdocs_uploader().enqueue_upload(
user_id=user_id,
account_id=account_id,

View File

@@ -86,6 +86,7 @@ class TaskScheduler:
self._executor_max_workers = self.max_global
self._executor = ThreadPoolExecutor(max_workers=self._executor_max_workers, thread_name_prefix="TaskWorker")
self._old_executors = []
self._futures_lock = threading.Lock()
self._active_futures = set()
@@ -137,6 +138,12 @@ class TaskScheduler:
except Exception:
pass
for ex in self._old_executors:
try:
ex.shutdown(wait=False)
except Exception:
pass
# 最后兜底:清理本调度器提交过的 active_task避免测试/重启时被“任务已在运行中”误拦截
try:
with self._cond:
@@ -161,18 +168,15 @@ class TaskScheduler:
new_max_global = max(1, int(max_global))
self.max_global = new_max_global
if new_max_global > self._executor_max_workers:
# 立即关闭旧线程池,防止资源泄漏
old_executor = self._executor
self._old_executors.append(self._executor)
self._executor_max_workers = new_max_global
self._executor = ThreadPoolExecutor(
max_workers=self._executor_max_workers, thread_name_prefix="TaskWorker"
)
# 立即关闭旧线程池
try:
old_executor.shutdown(wait=False)
logger.info(f"线程池已扩容:{old_executor._max_workers} -> {self._executor_max_workers}")
except Exception as e:
logger.warning(f"关闭旧线程池失败: {e}")
self._old_executors[-1].shutdown(wait=False)
except Exception:
pass
self._cond.notify_all()
@@ -327,8 +331,7 @@ class TaskScheduler:
except Exception:
with self._cond:
self._running_global = max(0, self._running_global - 1)
# 使用默认值 0 与增加时保持一致
self._running_by_user[task.user_id] = max(0, self._running_by_user.get(task.user_id, 0) - 1)
self._running_by_user[task.user_id] = max(0, self._running_by_user.get(task.user_id, 1) - 1)
if self._running_by_user.get(task.user_id) == 0:
self._running_by_user.pop(task.user_id, None)
self._cond.notify_all()
@@ -386,8 +389,7 @@ class TaskScheduler:
safe_remove_task(task.account_id)
with self._cond:
self._running_global = max(0, self._running_global - 1)
# 使用默认值 0 与增加时保持一致
self._running_by_user[task.user_id] = max(0, self._running_by_user.get(task.user_id, 0) - 1)
self._running_by_user[task.user_id] = max(0, self._running_by_user.get(task.user_id, 1) - 1)
if self._running_by_user.get(task.user_id) == 0:
self._running_by_user.pop(task.user_id, None)
self._cond.notify_all()
@@ -535,9 +537,7 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True, source="m
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
account.last_browse_type = browse_type
safe_update_task_status(
account_id, {"status": "运行中", "detail_status": "初始化", "start_time": task_start_time}
)
safe_update_task_status(account_id, {"status": "运行中", "detail_status": "初始化", "start_time": task_start_time})
max_attempts = 3
@@ -555,7 +555,7 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True, source="m
proxy_server = get_proxy_from_api(proxy_api_url, max_retries=3)
if proxy_server:
proxy_config = {"server": proxy_server}
log_to_client(f"[OK] 将使用代理: {proxy_server}", user_id, account_id)
log_to_client(f" 将使用代理: {proxy_server}", user_id, account_id)
account.proxy_config = proxy_config # 保存代理配置供截图使用
else:
log_to_client("✗ 代理获取失败,将不使用代理继续", user_id, account_id)
@@ -573,12 +573,12 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True, source="m
with APIBrowser(log_callback=custom_log, proxy_config=proxy_config) as api_browser:
if api_browser.login(account.username, account.password):
log_to_client("[OK] 首次登录成功,刷新登录时间...", user_id, account_id)
log_to_client(" 首次登录成功,刷新登录时间...", user_id, account_id)
# 二次登录:让"上次登录时间"变成刚才首次登录的时间
# 这样截图时显示的"上次登录时间"就是几秒前而不是昨天
if api_browser.login(account.username, account.password):
log_to_client("[OK] 二次登录成功!", user_id, account_id)
log_to_client(" 二次登录成功!", user_id, account_id)
else:
log_to_client("⚠ 二次登录失败,继续使用首次登录状态", user_id, account_id)
@@ -610,9 +610,7 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True, source="m
browsed_items = int(progress.get("browsed_items") or 0)
if total_items > 0:
account.total_items = total_items
safe_update_task_status(
account_id, {"progress": {"items": browsed_items, "attachments": 0}}
)
safe_update_task_status(account_id, {"progress": {"items": browsed_items, "attachments": 0}})
except Exception:
pass
@@ -657,9 +655,7 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True, source="m
if result.success:
log_to_client(
f"浏览完成! 共 {result.total_items} 条内容,{result.total_attachments} 个附件",
user_id,
account_id,
f"浏览完成! 共 {result.total_items} 条内容,{result.total_attachments} 个附件", user_id, account_id
)
safe_update_task_status(
account_id,
@@ -729,9 +725,7 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True, source="m
account.automation = None
if attempt < max_attempts:
log_to_client(
f"⚠ 代理可能速度过慢将换新IP重试 ({attempt}/{max_attempts})", user_id, account_id
)
log_to_client(f"⚠ 代理可能速度过慢将换新IP重试 ({attempt}/{max_attempts})", user_id, account_id)
time_module.sleep(2)
continue
log_to_client(f"❌ 已达到最大重试次数({max_attempts}),任务失败", user_id, account_id)
@@ -871,10 +865,7 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True, source="m
},
},
)
browse_result_dict = {
"total_items": result.total_items,
"total_attachments": result.total_attachments,
}
browse_result_dict = {"total_items": result.total_items, "total_attachments": result.total_attachments}
screenshot_submitted = True
threading.Thread(
target=take_screenshot_for_account,
@@ -897,13 +888,7 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True, source="m
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
def delayed_retry_submit():
# 重新获取最新的账户对象,避免使用闭包中的旧对象
fresh_account = safe_get_account(user_id, account_id)
if not fresh_account:
log_to_client("自动重试取消: 账户不存在", user_id, account_id)
return
if fresh_account.should_stop:
log_to_client("自动重试取消: 任务已被停止", user_id, account_id)
if account.should_stop:
return
log_to_client(f"🔄 开始第 {retry_count + 1} 次自动重试...", user_id, account_id)
ok, msg = submit_account_task(

View File

@@ -1,34 +1,34 @@
{
"_email-C4xyG93p.js": {
"file": "assets/email-C4xyG93p.js",
"_email-BsKBHU5S.js": {
"file": "assets/email-BsKBHU5S.js",
"name": "email",
"imports": [
"index.html"
]
},
"_system-C6kBIFhi.js": {
"file": "assets/system-C6kBIFhi.js",
"name": "system",
"imports": [
"index.html"
]
},
"_tasks-dxahzB_w.js": {
"file": "assets/tasks-dxahzB_w.js",
"_tasks-DpslJtm_.js": {
"file": "assets/tasks-DpslJtm_.js",
"name": "tasks",
"imports": [
"index.html"
]
},
"_users-ecMaaAFD.js": {
"file": "assets/users-ecMaaAFD.js",
"_update-DcFD-YxU.js": {
"file": "assets/update-DcFD-YxU.js",
"name": "update",
"imports": [
"index.html"
]
},
"_users-CC9BckjT.js": {
"file": "assets/users-CC9BckjT.js",
"name": "users",
"imports": [
"index.html"
]
},
"index.html": {
"file": "assets/index-DKH_HvPt.js",
"file": "assets/index-CdjS44Uj.js",
"name": "index",
"src": "index.html",
"isEntry": true,
@@ -39,16 +39,15 @@
"src/pages/LogsPage.vue",
"src/pages/AnnouncementsPage.vue",
"src/pages/EmailPage.vue",
"src/pages/SecurityPage.vue",
"src/pages/SystemPage.vue",
"src/pages/SettingsPage.vue"
],
"css": [
"assets/index-_5Ec1Hmd.css"
"assets/index-EWm4DZW8.css"
]
},
"src/pages/AnnouncementsPage.vue": {
"file": "assets/AnnouncementsPage-kpoSCxEP.js",
"file": "assets/AnnouncementsPage-Djmq3Wb7.js",
"name": "AnnouncementsPage",
"src": "src/pages/AnnouncementsPage.vue",
"isDynamicEntry": true,
@@ -56,24 +55,24 @@
"index.html"
],
"css": [
"assets/AnnouncementsPage-BhIwmMSX.css"
"assets/AnnouncementsPage-CjcC-aWD.css"
]
},
"src/pages/EmailPage.vue": {
"file": "assets/EmailPage-CEtsoP5P.js",
"file": "assets/EmailPage-q6nJlTue.js",
"name": "EmailPage",
"src": "src/pages/EmailPage.vue",
"isDynamicEntry": true,
"imports": [
"_email-C4xyG93p.js",
"_email-BsKBHU5S.js",
"index.html"
],
"css": [
"assets/EmailPage-BH6ksrcc.css"
"assets/EmailPage-BxzHc6tN.css"
]
},
"src/pages/FeedbacksPage.vue": {
"file": "assets/FeedbacksPage-ByHln3Ce.js",
"file": "assets/FeedbacksPage-Drw6uvSR.js",
"name": "FeedbacksPage",
"src": "src/pages/FeedbacksPage.vue",
"isDynamicEntry": true,
@@ -85,13 +84,13 @@
]
},
"src/pages/LogsPage.vue": {
"file": "assets/LogsPage-vZFAwgb-.js",
"file": "assets/LogsPage-DQd9IS3I.js",
"name": "LogsPage",
"src": "src/pages/LogsPage.vue",
"isDynamicEntry": true,
"imports": [
"_users-ecMaaAFD.js",
"_tasks-dxahzB_w.js",
"_users-CC9BckjT.js",
"_tasks-DpslJtm_.js",
"index.html"
],
"css": [
@@ -99,34 +98,22 @@
]
},
"src/pages/ReportPage.vue": {
"file": "assets/ReportPage--ClMBhif.js",
"file": "assets/ReportPage-Dnk3wsl3.js",
"name": "ReportPage",
"src": "src/pages/ReportPage.vue",
"isDynamicEntry": true,
"imports": [
"index.html",
"_email-C4xyG93p.js",
"_tasks-dxahzB_w.js",
"_system-C6kBIFhi.js"
"_email-BsKBHU5S.js",
"_tasks-DpslJtm_.js",
"_update-DcFD-YxU.js"
],
"css": [
"assets/ReportPage-Q8rCsG8A.css"
]
},
"src/pages/SecurityPage.vue": {
"file": "assets/SecurityPage-DBhX0IuO.js",
"name": "SecurityPage",
"src": "src/pages/SecurityPage.vue",
"isDynamicEntry": true,
"imports": [
"index.html"
],
"css": [
"assets/SecurityPage-Dv9jYTtC.css"
"assets/ReportPage-TpqQWWvU.css"
]
},
"src/pages/SettingsPage.vue": {
"file": "assets/SettingsPage-D91FOriC.js",
"file": "assets/SettingsPage-YOW1Apwk.js",
"name": "SettingsPage",
"src": "src/pages/SettingsPage.vue",
"isDynamicEntry": true,
@@ -134,33 +121,33 @@
"index.html"
],
"css": [
"assets/SettingsPage-DKTq8S2K.css"
"assets/SettingsPage-DGdwb4W2.css"
]
},
"src/pages/SystemPage.vue": {
"file": "assets/SystemPage-DVj-4Lnp.js",
"file": "assets/SystemPage-DCcH_SAQ.js",
"name": "SystemPage",
"src": "src/pages/SystemPage.vue",
"isDynamicEntry": true,
"imports": [
"_system-C6kBIFhi.js",
"_update-DcFD-YxU.js",
"index.html"
],
"css": [
"assets/SystemPage-C8GQyKcD.css"
"assets/SystemPage-BjTkcmTG.css"
]
},
"src/pages/UsersPage.vue": {
"file": "assets/UsersPage-C_vL5-r3.js",
"file": "assets/UsersPage-DhTO_5zp.js",
"name": "UsersPage",
"src": "src/pages/UsersPage.vue",
"isDynamicEntry": true,
"imports": [
"_users-ecMaaAFD.js",
"_users-CC9BckjT.js",
"index.html"
],
"css": [
"assets/UsersPage-CC4Unpwt.css"
"assets/UsersPage-CbiPbpuj.css"
]
}
}

View File

@@ -1 +0,0 @@
.page-stack[data-v-cad97d6b]{display:flex;flex-direction:column;gap:12px}.card[data-v-cad97d6b]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-title[data-v-cad97d6b]{margin:0 0 12px;font-size:14px;font-weight:800}.help[data-v-cad97d6b]{margin-top:10px;font-size:12px;color:var(--app-muted)}.image-preview[data-v-cad97d6b]{margin:6px 0 2px;display:flex;justify-content:flex-start}.image-preview img[data-v-cad97d6b]{max-width:280px;max-height:160px;border-radius:8px;border:1px solid var(--app-border);object-fit:contain}.image-upload-row[data-v-cad97d6b]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.image-input[data-v-cad97d6b]{display:none}.image-url[data-v-cad97d6b]{font-size:12px;color:var(--app-muted);word-break:break-all}.announcement-view[data-v-cad97d6b]{display:flex;flex-direction:column;gap:12px}.announcement-view-text[data-v-cad97d6b]{white-space:pre-wrap;line-height:1.6;font-size:14px}.announcement-view-image[data-v-cad97d6b]{max-width:100%;max-height:320px;border-radius:10px;border:1px solid var(--app-border);object-fit:contain}.table-wrap[data-v-cad97d6b]{overflow-x:auto}.ellipsis[data-v-cad97d6b]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.actions[data-v-cad97d6b]{display:flex;flex-wrap:wrap;gap:8px}

View File

@@ -0,0 +1 @@
.page-stack[data-v-a7b3418e]{display:flex;flex-direction:column;gap:12px}.card[data-v-a7b3418e]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-title[data-v-a7b3418e]{margin:0 0 12px;font-size:14px;font-weight:800}.help[data-v-a7b3418e]{margin-top:10px;font-size:12px;color:var(--app-muted)}.table-wrap[data-v-a7b3418e]{overflow-x:auto}.ellipsis[data-v-a7b3418e]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.actions[data-v-a7b3418e]{display:flex;flex-wrap:wrap;gap:8px}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.page-stack[data-v-7a7e1e9d]{display:flex;flex-direction:column;gap:12px}.toolbar[data-v-7a7e1e9d]{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.card[data-v-7a7e1e9d]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-head[data-v-7a7e1e9d]{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px;flex-wrap:wrap}.section-title[data-v-7a7e1e9d]{margin:0;font-size:14px;font-weight:800}.help[data-v-7a7e1e9d]{margin-top:8px;font-size:12px;color:var(--app-muted)}.table-wrap[data-v-7a7e1e9d]{overflow-x:auto}.stat-card[data-v-7a7e1e9d]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.stat-value[data-v-7a7e1e9d]{font-size:20px;font-weight:900;line-height:1.1}.stat-label[data-v-7a7e1e9d]{margin-top:6px;font-size:12px;color:var(--app-muted)}.ok[data-v-7a7e1e9d]{color:#047857}.err[data-v-7a7e1e9d]{color:#b91c1c}.sub-stats[data-v-7a7e1e9d]{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.ellipsis[data-v-7a7e1e9d]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pagination[data-v-7a7e1e9d]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px;flex-wrap:wrap}.page-hint[data-v-7a7e1e9d]{font-size:12px}.dialog-actions[data-v-7a7e1e9d]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.spacer[data-v-7a7e1e9d]{flex:1}

View File

@@ -0,0 +1 @@
.page-stack[data-v-ff849557]{display:flex;flex-direction:column;gap:12px}.toolbar[data-v-ff849557]{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.card[data-v-ff849557]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-head[data-v-ff849557]{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px;flex-wrap:wrap}.section-title[data-v-ff849557]{margin:0;font-size:14px;font-weight:800}.help[data-v-ff849557]{margin-top:8px;font-size:12px;color:var(--app-muted)}.table-wrap[data-v-ff849557]{overflow-x:auto}.stat-card[data-v-ff849557]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.stat-value[data-v-ff849557]{font-size:20px;font-weight:900;line-height:1.1}.stat-label[data-v-ff849557]{margin-top:6px;font-size:12px;color:var(--app-muted)}.ok[data-v-ff849557]{color:#047857}.err[data-v-ff849557]{color:#b91c1c}.sub-stats[data-v-ff849557]{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.ellipsis[data-v-ff849557]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pagination[data-v-ff849557]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px;flex-wrap:wrap}.page-hint[data-v-ff849557]{font-size:12px}.dialog-actions[data-v-ff849557]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.spacer[data-v-ff849557]{flex:1}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.page-stack[data-v-22d57053]{display:flex;flex-direction:column;gap:12px}.toolbar[data-v-22d57053]{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.stats-row[data-v-22d57053]{margin-bottom:2px}.card[data-v-22d57053]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.sub-card[data-v-22d57053]{margin-top:12px;border-radius:var(--app-radius);border:1px solid var(--app-border)}.stat-card[data-v-22d57053]{border-radius:var(--app-radius);border:1px solid var(--app-border);box-shadow:var(--app-shadow)}.stat-value[data-v-22d57053]{font-size:22px;font-weight:800;line-height:1.1}.stat-label[data-v-22d57053]{margin-top:6px;font-size:12px;color:var(--app-muted)}.filters[data-v-22d57053]{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:12px}.table-wrap[data-v-22d57053]{overflow-x:auto}.ellipsis[data-v-22d57053]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mono[data-v-22d57053]{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.pagination[data-v-22d57053]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px;flex-wrap:wrap}.page-hint[data-v-22d57053]{font-size:12px}.inner-tabs[data-v-22d57053]{margin-top:6px}.risk-head[data-v-22d57053]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:12px;flex-wrap:wrap}.risk-title[data-v-22d57053]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.dialog-actions[data-v-22d57053]{display:flex;align-items:center;gap:10px}.spacer[data-v-22d57053]{flex:1}

View File

@@ -1 +0,0 @@
import{a as m,_ as B,r as p,f as u,g as T,h as P,j as r,m as a,w as l,q as x,L as i,K as b}from"./index-DKH_HvPt.js";async function C(o){const{data:s}=await m.put("/admin/username",{new_username:o});return s}async function S(o){const{data:s}=await m.put("/admin/password",{new_password:o});return s}async function U(){const{data:o}=await m.post("/logout");return o}const A={class:"page-stack"},E={__name:"SettingsPage",setup(o){const s=p(""),d=p(""),n=p(!1);function k(t){const e=String(t||"");return e.length<8?{ok:!1,message:"密码长度至少8位"}:e.length>128?{ok:!1,message:"密码长度不能超过128个字符"}:!/[a-zA-Z]/.test(e)||!/\d/.test(e)?{ok:!1,message:"密码必须包含字母和数字"}:{ok:!0,message:""}}async function f(){try{await U()}catch{}finally{window.location.href="/yuyx"}}async function V(){const t=s.value.trim();if(!t){i.error("请输入新用户名");return}try{await b.confirm(`确定将管理员用户名修改为「${t}」吗?修改后需要重新登录。`,"修改用户名",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await C(t),i.success("用户名修改成功,请重新登录"),s.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}async function h(){const t=d.value;if(!t){i.error("请输入新密码");return}const e=k(t);if(!e.ok){i.error(e.message);return}try{await b.confirm("确定修改管理员密码吗?修改后需要重新登录。","修改密码",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await S(t),i.success("密码修改成功,请重新登录"),d.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}return(t,e)=>{const g=u("el-input"),w=u("el-form-item"),v=u("el-form"),y=u("el-button"),_=u("el-card");return P(),T("div",A,[e[7]||(e[7]=r("div",{class:"app-page-title"},[r("h2",null,"设置"),r("span",{class:"app-muted"},"管理员账号设置")],-1)),a(_,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:l(()=>[e[3]||(e[3]=r("h3",{class:"section-title"},"修改管理员用户名",-1)),a(v,{"label-width":"120px"},{default:l(()=>[a(w,{label:"新用户名"},{default:l(()=>[a(g,{modelValue:s.value,"onUpdate:modelValue":e[0]||(e[0]=c=>s.value=c),placeholder:"输入新用户名",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(y,{type:"primary",loading:n.value,onClick:V},{default:l(()=>[...e[2]||(e[2]=[x("保存用户名",-1)])]),_:1},8,["loading"])]),_:1}),a(_,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:l(()=>[e[5]||(e[5]=r("h3",{class:"section-title"},"修改管理员密码",-1)),a(v,{"label-width":"120px"},{default:l(()=>[a(w,{label:"新密码"},{default:l(()=>[a(g,{modelValue:d.value,"onUpdate:modelValue":e[1]||(e[1]=c=>d.value=c),type:"password","show-password":"",placeholder:"输入新密码",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(y,{type:"primary",loading:n.value,onClick:h},{default:l(()=>[...e[4]||(e[4]=[x("保存密码",-1)])]),_:1},8,["loading"]),e[6]||(e[6]=r("div",{class:"help"},"建议使用更强密码至少8位且包含字母与数字。",-1))]),_:1})])}}},M=B(E,[["__scopeId","data-v-12a26d11"]]);export{M as default};

View File

@@ -0,0 +1 @@
.page-stack[data-v-2f4b840f]{display:flex;flex-direction:column;gap:12px}.card[data-v-2f4b840f]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-title[data-v-2f4b840f]{margin:0 0 12px;font-size:14px;font-weight:800}.help[data-v-2f4b840f]{margin-top:10px;font-size:12px;color:var(--app-muted)}

View File

@@ -1 +0,0 @@
.page-stack[data-v-12a26d11]{display:flex;flex-direction:column;gap:12px}.card[data-v-12a26d11]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-title[data-v-12a26d11]{margin:0 0 12px;font-size:14px;font-weight:800}.help[data-v-12a26d11]{margin-top:10px;font-size:12px;color:var(--app-muted)}

View File

@@ -0,0 +1 @@
import{S as m,_ as T,r as p,e as u,f as h,g as k,h as r,j as a,w as s,p as x,L as i,K as b}from"./index-CdjS44Uj.js";async function C(o){const{data:t}=await m.put("/admin/username",{new_username:o});return t}async function P(o){const{data:t}=await m.put("/admin/password",{new_password:o});return t}async function U(){const{data:o}=await m.post("/logout");return o}const E={class:"page-stack"},N={__name:"SettingsPage",setup(o){const t=p(""),d=p(""),n=p(!1);async function f(){try{await U()}catch{}finally{window.location.href="/yuyx"}}async function V(){const l=t.value.trim();if(!l){i.error("请输入新用户名");return}try{await b.confirm(`确定将管理员用户名修改为「${l}」吗?修改后需要重新登录。`,"修改用户名",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await C(l),i.success("用户名修改成功,请重新登录"),t.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}async function B(){const l=d.value;if(!l){i.error("请输入新密码");return}if(l.length<6){i.error("密码至少6个字符");return}try{await b.confirm("确定修改管理员密码吗?修改后需要重新登录。","修改密码",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await P(l),i.success("密码修改成功,请重新登录"),d.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}return(l,e)=>{const w=u("el-input"),v=u("el-form-item"),y=u("el-form"),_=u("el-button"),g=u("el-card");return k(),h("div",E,[e[7]||(e[7]=r("div",{class:"app-page-title"},[r("h2",null,"设置"),r("span",{class:"app-muted"},"管理员账号设置")],-1)),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[3]||(e[3]=r("h3",{class:"section-title"},"修改管理员用户名",-1)),a(y,{"label-width":"120px"},{default:s(()=>[a(v,{label:"新用户名"},{default:s(()=>[a(w,{modelValue:t.value,"onUpdate:modelValue":e[0]||(e[0]=c=>t.value=c),placeholder:"输入新用户名",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:V},{default:s(()=>[...e[2]||(e[2]=[x("保存用户名",-1)])]),_:1},8,["loading"])]),_:1}),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[5]||(e[5]=r("h3",{class:"section-title"},"修改管理员密码",-1)),a(y,{"label-width":"120px"},{default:s(()=>[a(v,{label:"新密码"},{default:s(()=>[a(w,{modelValue:d.value,"onUpdate:modelValue":e[1]||(e[1]=c=>d.value=c),type:"password","show-password":"",placeholder:"输入新密码",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:B},{default:s(()=>[...e[4]||(e[4]=[x("保存密码",-1)])]),_:1},8,["loading"]),e[6]||(e[6]=r("div",{class:"help"},"建议使用更强密码至少8位且包含字母与数字。",-1))]),_:1})])}}},A=T(N,[["__scopeId","data-v-2f4b840f"]]);export{A as default};

View File

@@ -0,0 +1 @@
.page-stack[data-v-d88590f1]{display:flex;flex-direction:column;gap:12px}.card[data-v-d88590f1]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-title[data-v-d88590f1]{margin:0 0 12px;font-size:14px;font-weight:800}.help[data-v-d88590f1]{margin-top:6px;font-size:12px;color:var(--app-muted)}.row-actions[data-v-d88590f1]{display:flex;flex-wrap:wrap;gap:10px}

View File

@@ -1 +0,0 @@
.page-stack[data-v-b359577d]{display:flex;flex-direction:column;gap:12px}.card[data-v-b359577d]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-title[data-v-b359577d]{margin:0 0 12px;font-size:14px;font-weight:800}.kdocs-qr[data-v-b359577d]{display:flex;flex-direction:column;align-items:center;gap:12px}.kdocs-qr img[data-v-b359577d]{width:260px;max-width:100%;border:1px solid var(--app-border);border-radius:8px;padding:8px;background:#fff}.help[data-v-b359577d]{margin-top:6px;font-size:12px;color:var(--app-muted)}.row-actions[data-v-b359577d]{display:flex;flex-wrap:wrap;gap:10px}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.page-stack[data-v-d73d2b82]{display:flex;flex-direction:column;gap:12px}.card[data-v-d73d2b82]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-title[data-v-d73d2b82]{margin:0 0 12px;font-size:14px;font-weight:800}.help[data-v-d73d2b82]{margin-top:10px;font-size:12px}.table-wrap[data-v-d73d2b82]{overflow-x:auto}.user-block[data-v-d73d2b82]{display:flex;flex-direction:column;gap:2px}.user-main[data-v-d73d2b82]{display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap}.user-sub[data-v-d73d2b82]{font-size:12px}.vip-sub[data-v-d73d2b82]{font-size:12px;color:#7c3aed}.actions[data-v-d73d2b82]{display:flex;flex-wrap:wrap;gap:8px}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.page-stack[data-v-84b2f73a]{display:flex;flex-direction:column;gap:12px}.card[data-v-84b2f73a]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-title[data-v-84b2f73a]{margin:0 0 12px;font-size:14px;font-weight:800}.help[data-v-84b2f73a]{margin-top:10px;font-size:12px}.table-wrap[data-v-84b2f73a]{overflow-x:auto}.user-block[data-v-84b2f73a]{display:flex;flex-direction:column;gap:2px}.user-main[data-v-84b2f73a]{display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap}.user-sub[data-v-84b2f73a]{font-size:12px}.vip-sub[data-v-84b2f73a]{font-size:12px;color:#7c3aed}.actions[data-v-84b2f73a]{display:flex;flex-wrap:wrap;gap:8px}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{a as n}from"./index-DKH_HvPt.js";async function i(){const{data:a}=await n.get("/email/settings");return a}async function e(a){const{data:t}=await n.post("/email/settings",a);return t}async function c(){const{data:a}=await n.get("/email/stats");return a}async function o(a){const{data:t}=await n.get("/email/logs",{params:a});return t}async function l(a){const{data:t}=await n.post("/email/logs/cleanup",{days:a});return t}export{o as a,i as b,l as c,c as f,e as u};
import{S as n}from"./index-CdjS44Uj.js";async function i(){const{data:a}=await n.get("/email/settings");return a}async function e(a){const{data:t}=await n.post("/email/settings",a);return t}async function c(){const{data:a}=await n.get("/email/stats");return a}async function o(a){const{data:t}=await n.get("/email/logs",{params:a});return t}async function l(a){const{data:t}=await n.post("/email/logs/cleanup",{days:a});return t}export{o as a,i as b,l as c,c as f,e as u};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{a}from"./index-DKH_HvPt.js";async function s(){const{data:t}=await a.get("/system/config");return t}async function c(t){const{data:e}=await a.post("/system/config",t);return e}async function o(){const{data:t}=await a.post("/schedule/execute",{});return t}export{o as e,s as f,c as u};

View File

@@ -0,0 +1 @@
import{S as a}from"./index-CdjS44Uj.js";async function c(){const{data:t}=await a.get("/server/info");return t}async function e(){const{data:t}=await a.get("/docker_stats");return t}async function o(){const{data:t}=await a.get("/task/stats");return t}async function r(){const{data:t}=await a.get("/task/running");return t}async function i(t){const{data:s}=await a.get("/task/logs",{params:t});return s}async function f(t){const{data:s}=await a.post("/task/logs/clear",{days:t});return s}export{r as a,c as b,e as c,i as d,f as e,o as f};

View File

@@ -1 +0,0 @@
import{a}from"./index-DKH_HvPt.js";async function c(){const{data:t}=await a.get("/server/info");return t}async function e(){const{data:t}=await a.get("/docker_stats");return t}async function o(){const{data:t}=await a.get("/task/stats");return t}async function r(){const{data:t}=await a.get("/task/running");return t}async function i(t){const{data:s}=await a.get("/task/logs",{params:t});return s}async function f(t){const{data:s}=await a.post("/task/logs/clear",{days:t});return s}export{r as a,c as b,e as c,i as d,f as e,o as f};

View File

@@ -0,0 +1 @@
import{S as a}from"./index-CdjS44Uj.js";async function s(){const{data:t}=await a.get("/system/config");return t}async function c(t){const{data:e}=await a.post("/system/config",t);return e}async function u(){const{data:t}=await a.post("/schedule/execute",{});return t}async function o(){const{data:t}=await a.get("/update/status");return t}async function r(){const{data:t}=await a.get("/update/result");return t}async function d(t={}){const{data:e}=await a.get("/update/log",{params:t});return e}async function i(){const{data:t}=await a.post("/update/check",{});return t}async function f(t={}){const{data:e}=await a.post("/update/run",t);return e}export{o as a,r as b,d as c,f as d,u as e,s as f,i as r,c as u};

View File

@@ -1 +1 @@
import{a as t}from"./index-DKH_HvPt.js";async function n(){const{data:s}=await t.get("/users");return s}async function o(s){const{data:a}=await t.post(`/users/${s}/approve`);return a}async function c(s){const{data:a}=await t.post(`/users/${s}/reject`);return a}async function i(s){const{data:a}=await t.delete(`/users/${s}`);return a}async function u(s,a){const{data:e}=await t.post(`/users/${s}/vip`,{days:a});return e}async function p(s){const{data:a}=await t.delete(`/users/${s}/vip`);return a}async function d(s,a){const{data:e}=await t.post(`/users/${s}/reset_password`,{new_password:a});return e}export{o as a,p as b,d as c,i as d,n as f,c as r,u as s};
import{S as t}from"./index-CdjS44Uj.js";async function n(){const{data:s}=await t.get("/users");return s}async function o(s){const{data:a}=await t.post(`/users/${s}/approve`);return a}async function c(s){const{data:a}=await t.post(`/users/${s}/reject`);return a}async function i(s){const{data:a}=await t.delete(`/users/${s}`);return a}async function u(s,a){const{data:e}=await t.post(`/users/${s}/vip`,{days:a});return e}async function p(s){const{data:a}=await t.delete(`/users/${s}/vip`);return a}async function d(s,a){const{data:e}=await t.post(`/users/${s}/reset_password`,{new_password:a});return e}export{o as a,p as b,d as c,i as d,n as f,c as r,u as s};

View File

@@ -1,20 +1,24 @@
{
"_accounts-Bta9cdL5.js": {
"file": "assets/accounts-Bta9cdL5.js",
"_accounts-BXD0We06.js": {
"file": "assets/accounts-BXD0We06.js",
"name": "accounts",
"imports": [
"index.html"
]
},
"_auth--ytvFYf6.js": {
"file": "assets/auth--ytvFYf6.js",
"_auth-cf7b3Gq2.js": {
"file": "assets/auth-cf7b3Gq2.js",
"name": "auth",
"imports": [
"index.html"
]
},
"_password-7ryi82gE.js": {
"file": "assets/password-7ryi82gE.js",
"name": "password"
},
"index.html": {
"file": "assets/index-CPwwGffH.js",
"file": "assets/index-DhsLPY8p.js",
"name": "index",
"src": "index.html",
"isEntry": true,
@@ -28,68 +32,70 @@
"src/pages/ScreenshotsPage.vue"
],
"css": [
"assets/index-BVjJVlht.css"
"assets/index-CD3NfpmF.css"
]
},
"src/pages/AccountsPage.vue": {
"file": "assets/AccountsPage-D3MJyXUD.js",
"file": "assets/AccountsPage-38dq1Ex4.js",
"name": "AccountsPage",
"src": "src/pages/AccountsPage.vue",
"isDynamicEntry": true,
"imports": [
"_accounts-Bta9cdL5.js",
"_accounts-BXD0We06.js",
"index.html"
],
"css": [
"assets/AccountsPage-tARhOk5s.css"
"assets/AccountsPage-CkDdMK5Q.css"
]
},
"src/pages/LoginPage.vue": {
"file": "assets/LoginPage-Cz6slTnR.js",
"file": "assets/LoginPage-B_fgHOTT.js",
"name": "LoginPage",
"src": "src/pages/LoginPage.vue",
"isDynamicEntry": true,
"imports": [
"index.html",
"_auth--ytvFYf6.js"
"_auth-cf7b3Gq2.js",
"_password-7ryi82gE.js"
],
"css": [
"assets/LoginPage-CnwOLKJz.css"
"assets/LoginPage-8DI6Rf67.css"
]
},
"src/pages/RegisterPage.vue": {
"file": "assets/RegisterPage-D46uldFj.js",
"file": "assets/RegisterPage-B_Z92PVI.js",
"name": "RegisterPage",
"src": "src/pages/RegisterPage.vue",
"isDynamicEntry": true,
"imports": [
"index.html",
"_auth--ytvFYf6.js"
"_auth-cf7b3Gq2.js"
],
"css": [
"assets/RegisterPage-BOcNcW5D.css"
"assets/RegisterPage-yylt2w7b.css"
]
},
"src/pages/ResetPasswordPage.vue": {
"file": "assets/ResetPasswordPage-CO1hZug-.js",
"file": "assets/ResetPasswordPage-2f8v-5j9.js",
"name": "ResetPasswordPage",
"src": "src/pages/ResetPasswordPage.vue",
"isDynamicEntry": true,
"imports": [
"index.html",
"_auth--ytvFYf6.js"
"_auth-cf7b3Gq2.js",
"_password-7ryi82gE.js"
],
"css": [
"assets/ResetPasswordPage-DybfLMAw.css"
]
},
"src/pages/SchedulesPage.vue": {
"file": "assets/SchedulesPage-CliP1bMU.js",
"file": "assets/SchedulesPage-VLwHd9Sa.js",
"name": "SchedulesPage",
"src": "src/pages/SchedulesPage.vue",
"isDynamicEntry": true,
"imports": [
"_accounts-Bta9cdL5.js",
"_accounts-BXD0We06.js",
"index.html"
],
"css": [
@@ -97,7 +103,7 @@
]
},
"src/pages/ScreenshotsPage.vue": {
"file": "assets/ScreenshotsPage-CqETBpbn.js",
"file": "assets/ScreenshotsPage-Dtd_MXUX.js",
"name": "ScreenshotsPage",
"src": "src/pages/ScreenshotsPage.vue",
"isDynamicEntry": true,
@@ -109,7 +115,7 @@
]
},
"src/pages/VerifyResultPage.vue": {
"file": "assets/VerifyResultPage-XFuV1ie5.js",
"file": "assets/VerifyResultPage-8_v-5_kc.js",
"name": "VerifyResultPage",
"src": "src/pages/VerifyResultPage.vue",
"isDynamicEntry": true,

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.page[data-v-b4e85160]{display:flex;flex-direction:column;gap:12px}.stat-card[data-v-b4e85160],.panel[data-v-b4e85160]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.stat-label[data-v-b4e85160]{font-size:12px}.stat-value[data-v-b4e85160]{margin-top:6px;font-size:22px;font-weight:900;letter-spacing:.2px}.stat-suffix[data-v-b4e85160]{margin-left:6px;font-size:12px;font-weight:600}.upgrade-banner[data-v-b4e85160]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.upgrade-actions[data-v-b4e85160]{margin-top:10px}.panel-head[data-v-b4e85160]{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;flex-wrap:wrap}.panel-title[data-v-b4e85160]{font-size:16px;font-weight:900}.panel-actions[data-v-b4e85160]{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.toolbar[data-v-b4e85160]{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding:10px;border:1px dashed rgba(17,24,39,.14);border-radius:12px;background:#f6f7fb99}.toolbar-left[data-v-b4e85160],.toolbar-middle[data-v-b4e85160],.toolbar-right[data-v-b4e85160]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.toolbar-right[data-v-b4e85160]{margin-left:auto;justify-content:flex-end}.grid[data-v-b4e85160]{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px;align-items:start}.account-card[data-v-b4e85160]{border-radius:14px;border:1px solid var(--app-border)}.card-top[data-v-b4e85160]{display:flex;gap:10px}.card-check[data-v-b4e85160]{padding-top:2px}.card-main[data-v-b4e85160]{min-width:0;flex:1}.card-title[data-v-b4e85160]{display:flex;align-items:center;justify-content:space-between;gap:10px}.card-name[data-v-b4e85160]{font-size:14px;font-weight:900;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.card-sub[data-v-b4e85160]{margin-top:6px;font-size:12px;line-height:1.4;word-break:break-word}.progress[data-v-b4e85160]{margin-top:12px}.progress-meta[data-v-b4e85160]{margin-top:6px;display:flex;justify-content:space-between;gap:10px;font-size:12px}.card-controls[data-v-b4e85160]{margin-top:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.card-buttons[data-v-b4e85160]{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end}.vip-body[data-v-b4e85160]{padding:12px 0 0}.vip-tip[data-v-b4e85160]{margin-top:10px;font-size:13px;line-height:1.6}@media(max-width:480px){.grid[data-v-b4e85160]{grid-template-columns:1fr}}@media(max-width:768px){.panel-actions[data-v-b4e85160]{width:100%;justify-content:flex-end}.toolbar-left[data-v-b4e85160],.toolbar-middle[data-v-b4e85160],.toolbar-right[data-v-b4e85160]{width:100%}.toolbar-right[data-v-b4e85160]{margin-left:0;justify-content:flex-end}}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.page[data-v-961c6960]{display:flex;flex-direction:column;gap:12px}.stat-card[data-v-961c6960],.panel[data-v-961c6960]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.stat-label[data-v-961c6960]{font-size:12px}.stat-value[data-v-961c6960]{margin-top:6px;font-size:22px;font-weight:900;letter-spacing:.2px}.stat-suffix[data-v-961c6960]{margin-left:6px;font-size:12px;font-weight:600}.upgrade-banner[data-v-961c6960]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.upgrade-actions[data-v-961c6960]{margin-top:10px}.panel-head[data-v-961c6960]{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;flex-wrap:wrap}.panel-title[data-v-961c6960]{font-size:16px;font-weight:900}.panel-actions[data-v-961c6960]{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.toolbar[data-v-961c6960]{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding:10px;border:1px dashed rgba(17,24,39,.14);border-radius:12px;background:#f6f7fb99}.toolbar-left[data-v-961c6960],.toolbar-middle[data-v-961c6960],.toolbar-right[data-v-961c6960]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.toolbar-right[data-v-961c6960]{margin-left:auto;justify-content:flex-end}.grid[data-v-961c6960]{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px;align-items:start}.account-card[data-v-961c6960]{border-radius:14px;border:1px solid var(--app-border)}.card-top[data-v-961c6960]{display:flex;gap:10px}.card-check[data-v-961c6960]{padding-top:2px}.card-main[data-v-961c6960]{min-width:0;flex:1}.card-title[data-v-961c6960]{display:flex;align-items:center;justify-content:space-between;gap:10px}.card-name[data-v-961c6960]{font-size:14px;font-weight:900;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.card-sub[data-v-961c6960]{margin-top:6px;font-size:12px;line-height:1.4;word-break:break-word}.progress[data-v-961c6960]{margin-top:12px}.progress-meta[data-v-961c6960]{margin-top:6px;display:flex;justify-content:space-between;gap:10px;font-size:12px}.card-controls[data-v-961c6960]{margin-top:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.card-buttons[data-v-961c6960]{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end}.vip-body[data-v-961c6960]{padding:12px 0 0}.vip-tip[data-v-961c6960]{margin-top:10px;font-size:13px;line-height:1.6}@media(max-width:480px){.grid[data-v-961c6960]{grid-template-columns:1fr}}@media(max-width:768px){.panel-actions[data-v-961c6960]{width:100%;justify-content:flex-end}.toolbar-left[data-v-961c6960],.toolbar-middle[data-v-961c6960],.toolbar-right[data-v-961c6960]{width:100%}.toolbar-right[data-v-961c6960]{margin-left:0;justify-content:flex-end}}

View File

@@ -0,0 +1 @@
.auth-wrap[data-v-50df591d]{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}.auth-card[data-v-50df591d]{width:100%;max-width:420px;border-radius:var(--app-radius);border:1px solid var(--app-border);box-shadow:var(--app-shadow)}.brand[data-v-50df591d]{margin-bottom:14px}.brand-title[data-v-50df591d]{font-size:18px;font-weight:900}.brand-sub[data-v-50df591d]{margin-top:4px;font-size:12px}.links[data-v-50df591d]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:2px 0 10px;flex-wrap:wrap}.submit-btn[data-v-50df591d]{width:100%}.foot[data-v-50df591d]{margin-top:14px;display:flex;align-items:center;justify-content:center;gap:6px}.dialog-form[data-v-50df591d]{margin-top:10px}.captcha-row[data-v-50df591d]{display:flex;align-items:center;gap:10px;width:100%}.captcha-img[data-v-50df591d]{height:40px;border:1px solid var(--app-border);border-radius:8px;cursor:pointer;-webkit-user-select:none;user-select:none}@media(max-width:480px){.captcha-img[data-v-50df591d]{height:38px}}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.auth-wrap[data-v-9eb557e5]{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}.auth-card[data-v-9eb557e5]{width:100%;max-width:420px;border-radius:var(--app-radius);border:1px solid var(--app-border);box-shadow:var(--app-shadow)}.brand[data-v-9eb557e5]{margin-bottom:14px}.brand-title[data-v-9eb557e5]{font-size:18px;font-weight:900}.brand-sub[data-v-9eb557e5]{margin-top:4px;font-size:12px}.links[data-v-9eb557e5]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:2px 0 10px;flex-wrap:wrap}.submit-btn[data-v-9eb557e5]{width:100%}.foot[data-v-9eb557e5]{margin-top:14px;display:flex;align-items:center;justify-content:center;gap:6px}.dialog-form[data-v-9eb557e5]{margin-top:10px}.captcha-row[data-v-9eb557e5]{display:flex;align-items:center;gap:10px;width:100%}.captcha-img[data-v-9eb557e5]{height:40px;border:1px solid var(--app-border);border-radius:8px;cursor:pointer;-webkit-user-select:none;user-select:none}@media(max-width:480px){.captcha-img[data-v-9eb557e5]{height:38px}}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.auth-wrap[data-v-a9d7804f]{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}.auth-card[data-v-a9d7804f]{width:100%;max-width:420px;border-radius:var(--app-radius);border:1px solid var(--app-border);box-shadow:var(--app-shadow)}.brand[data-v-a9d7804f]{margin-bottom:14px}.brand-title[data-v-a9d7804f]{font-size:18px;font-weight:900}.brand-sub[data-v-a9d7804f]{margin-top:4px;font-size:12px}.alert[data-v-a9d7804f]{margin-bottom:12px}.hint[data-v-a9d7804f]{margin-top:6px;font-size:12px}.captcha-row[data-v-a9d7804f]{display:flex;align-items:center;gap:10px;width:100%}.captcha-img[data-v-a9d7804f]{height:40px;border:1px solid var(--app-border);border-radius:8px;cursor:pointer;-webkit-user-select:none;user-select:none}.submit-btn[data-v-a9d7804f]{width:100%;margin-top:4px}.actions[data-v-a9d7804f]{margin-top:14px;display:flex;align-items:center;justify-content:center;gap:6px}

View File

@@ -0,0 +1 @@
import{_ as M,r as j,a as p,c as B,o as A,b as S,d as t,w as o,e as m,u as H,f as g,g as n,h as U,i as x,j as N,t as q,k as E,E as d}from"./index-DhsLPY8p.js";import{g as z,f as F,c as G}from"./auth-cf7b3Gq2.js";const J={class:"auth-wrap"},O={class:"hint app-muted"},Q={class:"captcha-row"},W=["src"],X={class:"actions"},Y={__name:"RegisterPage",setup(Z){const T=H(),a=j({username:"",password:"",confirm_password:"",email:"",captcha:""}),v=p(!1),f=p(""),h=p(""),b=p(!1),l=p(""),_=p(""),V=p(""),K=B(()=>v.value?"邮箱 *":"邮箱(可选)"),P=B(()=>v.value?"必填,用于账号验证":"选填,用于找回密码和接收通知");async function w(){try{const u=await z();h.value=u?.session_id||"",f.value=u?.captcha_image||"",a.captcha=""}catch{h.value="",f.value=""}}async function R(){try{const u=await F();v.value=!!u?.register_verify_enabled}catch{v.value=!1}}function D(){l.value="",_.value="",V.value=""}async function k(){D();const u=a.username.trim(),e=a.password,y=a.confirm_password,s=a.email.trim(),i=a.captcha.trim();if(u.length<3){l.value="用户名至少3个字符",d.error(l.value);return}if(e.length<6){l.value="密码至少6个字符",d.error(l.value);return}if(e!==y){l.value="两次输入的密码不一致",d.error(l.value);return}if(v.value&&!s){l.value="请填写邮箱地址用于账号验证",d.error(l.value);return}if(s&&!s.includes("@")){l.value="邮箱格式不正确",d.error(l.value);return}if(!i){l.value="请输入验证码",d.error(l.value);return}b.value=!0;try{const c=await G({username:u,password:e,email:s,captcha_session:h.value,captcha:i});_.value=c?.message||"注册成功",V.value=c?.need_verify?"请检查您的邮箱(包括垃圾邮件文件夹)":"",d.success("注册成功"),a.username="",a.password="",a.confirm_password="",a.email="",a.captcha="",setTimeout(()=>{window.location.href="/login"},3e3)}catch(c){const C=c?.response?.data;l.value=C?.error||"注册失败",d.error(l.value),await w()}finally{b.value=!1}}function I(){T.push("/login")}return A(async()=>{await w(),await R()}),(u,e)=>{const y=m("el-alert"),s=m("el-input"),i=m("el-form-item"),c=m("el-button"),C=m("el-form"),L=m("el-card");return g(),S("div",J,[t(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:o(()=>[e[11]||(e[11]=n("div",{class:"brand"},[n("div",{class:"brand-title"},"知识管理平台"),n("div",{class:"brand-sub app-muted"},"用户注册")],-1)),l.value?(g(),U(y,{key:0,type:"error",closable:!1,title:l.value,"show-icon":"",class:"alert"},null,8,["title"])):x("",!0),_.value?(g(),U(y,{key:1,type:"success",closable:!1,title:_.value,description:V.value,"show-icon":"",class:"alert"},null,8,["title","description"])):x("",!0),t(C,{"label-position":"top"},{default:o(()=>[t(i,{label:"用户名 *"},{default:o(()=>[t(s,{modelValue:a.username,"onUpdate:modelValue":e[0]||(e[0]=r=>a.username=r),placeholder:"至少3个字符",autocomplete:"username"},null,8,["modelValue"]),e[5]||(e[5]=n("div",{class:"hint app-muted"},"至少3个字符",-1))]),_:1}),t(i,{label:"密码 *"},{default:o(()=>[t(s,{modelValue:a.password,"onUpdate:modelValue":e[1]||(e[1]=r=>a.password=r),type:"password","show-password":"",placeholder:"至少6个字符",autocomplete:"new-password"},null,8,["modelValue"]),e[6]||(e[6]=n("div",{class:"hint app-muted"},"至少6个字符",-1))]),_:1}),t(i,{label:"确认密码 *"},{default:o(()=>[t(s,{modelValue:a.confirm_password,"onUpdate:modelValue":e[2]||(e[2]=r=>a.confirm_password=r),type:"password","show-password":"",placeholder:"请再次输入密码",autocomplete:"new-password",onKeyup:N(k,["enter"])},null,8,["modelValue"])]),_:1}),t(i,{label:K.value},{default:o(()=>[t(s,{modelValue:a.email,"onUpdate:modelValue":e[3]||(e[3]=r=>a.email=r),placeholder:"name@example.com",autocomplete:"email"},null,8,["modelValue"]),n("div",O,q(P.value),1)]),_:1},8,["label"]),t(i,{label:"验证码 *"},{default:o(()=>[n("div",Q,[t(s,{modelValue:a.captcha,"onUpdate:modelValue":e[4]||(e[4]=r=>a.captcha=r),placeholder:"请输入验证码",onKeyup:N(k,["enter"])},null,8,["modelValue"]),f.value?(g(),S("img",{key:0,class:"captcha-img",src:f.value,alt:"验证码",title:"点击刷新",onClick:w},null,8,W)):x("",!0),t(c,{onClick:w},{default:o(()=>[...e[7]||(e[7]=[E("刷新",-1)])]),_:1})])]),_:1})]),_:1}),t(c,{type:"primary",class:"submit-btn",loading:b.value,onClick:k},{default:o(()=>[...e[8]||(e[8]=[E("注册",-1)])]),_:1},8,["loading"]),n("div",X,[e[10]||(e[10]=n("span",{class:"app-muted"},"已有账号?",-1)),t(c,{link:"",type:"primary",onClick:I},{default:o(()=>[...e[9]||(e[9]=[E("立即登录",-1)])]),_:1})])]),_:1})])}}},ae=M(Y,[["__scopeId","data-v-75731a6d"]]);export{ae as default};

View File

@@ -1 +0,0 @@
import{_ as M,r as j,a as d,c as B,o as A,b as U,d as l,w as o,e as v,u as H,f as b,g as n,h as N,i as E,j as P,t as q,k as S,E as c,v as z}from"./index-CPwwGffH.js";import{g as F,f as G,b as J}from"./auth--ytvFYf6.js";const O={class:"auth-wrap"},Q={class:"hint app-muted"},W={class:"captcha-row"},X=["src"],Y={class:"actions"},Z={__name:"RegisterPage",setup($){const T=H(),a=j({username:"",password:"",confirm_password:"",email:"",captcha:""}),f=d(!1),w=d(""),h=d(""),V=d(!1),t=d(""),_=d(""),k=d(""),K=B(()=>f.value?"邮箱 *":"邮箱(可选)"),R=B(()=>f.value?"必填,用于账号验证":"选填,用于找回密码和接收通知");async function y(){try{const u=await F();h.value=u?.session_id||"",w.value=u?.captcha_image||"",a.captcha=""}catch{h.value="",w.value=""}}async function D(){try{const u=await G();f.value=!!u?.register_verify_enabled}catch{f.value=!1}}function I(){t.value="",_.value="",k.value=""}async function C(){I();const u=a.username.trim(),e=a.password,g=a.confirm_password,s=a.email.trim(),i=a.captcha.trim();if(u.length<3){t.value="用户名至少3个字符",c.error(t.value);return}const p=z(e);if(!p.ok){t.value=p.message||"密码格式不正确",c.error(t.value);return}if(e!==g){t.value="两次输入的密码不一致",c.error(t.value);return}if(f.value&&!s){t.value="请填写邮箱地址用于账号验证",c.error(t.value);return}if(s&&!s.includes("@")){t.value="邮箱格式不正确",c.error(t.value);return}if(!i){t.value="请输入验证码",c.error(t.value);return}V.value=!0;try{const m=await J({username:u,password:e,email:s,captcha_session:h.value,captcha:i});_.value=m?.message||"注册成功",k.value=m?.need_verify?"请检查您的邮箱(包括垃圾邮件文件夹)":"",c.success("注册成功"),a.username="",a.password="",a.confirm_password="",a.email="",a.captcha="",setTimeout(()=>{window.location.href="/login"},3e3)}catch(m){const x=m?.response?.data;t.value=x?.error||"注册失败",c.error(t.value),await y()}finally{V.value=!1}}function L(){T.push("/login")}return A(async()=>{await y(),await D()}),(u,e)=>{const g=v("el-alert"),s=v("el-input"),i=v("el-form-item"),p=v("el-button"),m=v("el-form"),x=v("el-card");return b(),U("div",O,[l(x,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:o(()=>[e[11]||(e[11]=n("div",{class:"brand"},[n("div",{class:"brand-title"},"知识管理平台"),n("div",{class:"brand-sub app-muted"},"用户注册")],-1)),t.value?(b(),N(g,{key:0,type:"error",closable:!1,title:t.value,"show-icon":"",class:"alert"},null,8,["title"])):E("",!0),_.value?(b(),N(g,{key:1,type:"success",closable:!1,title:_.value,description:k.value,"show-icon":"",class:"alert"},null,8,["title","description"])):E("",!0),l(m,{"label-position":"top"},{default:o(()=>[l(i,{label:"用户名 *"},{default:o(()=>[l(s,{modelValue:a.username,"onUpdate:modelValue":e[0]||(e[0]=r=>a.username=r),placeholder:"至少3个字符",autocomplete:"username"},null,8,["modelValue"]),e[5]||(e[5]=n("div",{class:"hint app-muted"},"至少3个字符",-1))]),_:1}),l(i,{label:"密码 *"},{default:o(()=>[l(s,{modelValue:a.password,"onUpdate:modelValue":e[1]||(e[1]=r=>a.password=r),type:"password","show-password":"",placeholder:"至少8位且包含字母和数字",autocomplete:"new-password"},null,8,["modelValue"]),e[6]||(e[6]=n("div",{class:"hint app-muted"},"至少8位且包含字母和数字",-1))]),_:1}),l(i,{label:"确认密码 *"},{default:o(()=>[l(s,{modelValue:a.confirm_password,"onUpdate:modelValue":e[2]||(e[2]=r=>a.confirm_password=r),type:"password","show-password":"",placeholder:"请再次输入密码",autocomplete:"new-password",onKeyup:P(C,["enter"])},null,8,["modelValue"])]),_:1}),l(i,{label:K.value},{default:o(()=>[l(s,{modelValue:a.email,"onUpdate:modelValue":e[3]||(e[3]=r=>a.email=r),placeholder:"name@example.com",autocomplete:"email"},null,8,["modelValue"]),n("div",Q,q(R.value),1)]),_:1},8,["label"]),l(i,{label:"验证码 *"},{default:o(()=>[n("div",W,[l(s,{modelValue:a.captcha,"onUpdate:modelValue":e[4]||(e[4]=r=>a.captcha=r),placeholder:"请输入验证码",onKeyup:P(C,["enter"])},null,8,["modelValue"]),w.value?(b(),U("img",{key:0,class:"captcha-img",src:w.value,alt:"验证码",title:"点击刷新",onClick:y},null,8,X)):E("",!0),l(p,{onClick:y},{default:o(()=>[...e[7]||(e[7]=[S("刷新",-1)])]),_:1})])]),_:1})]),_:1}),l(p,{type:"primary",class:"submit-btn",loading:V.value,onClick:C},{default:o(()=>[...e[8]||(e[8]=[S("注册",-1)])]),_:1},8,["loading"]),n("div",Y,[e[10]||(e[10]=n("span",{class:"app-muted"},"已有账号?",-1)),l(p,{link:"",type:"primary",onClick:L},{default:o(()=>[...e[9]||(e[9]=[S("立即登录",-1)])]),_:1})])]),_:1})])}}},te=M(Z,[["__scopeId","data-v-a9d7804f"]]);export{te as default};

View File

@@ -0,0 +1 @@
.auth-wrap[data-v-75731a6d]{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}.auth-card[data-v-75731a6d]{width:100%;max-width:420px;border-radius:var(--app-radius);border:1px solid var(--app-border);box-shadow:var(--app-shadow)}.brand[data-v-75731a6d]{margin-bottom:14px}.brand-title[data-v-75731a6d]{font-size:18px;font-weight:900}.brand-sub[data-v-75731a6d]{margin-top:4px;font-size:12px}.alert[data-v-75731a6d]{margin-bottom:12px}.hint[data-v-75731a6d]{margin-top:6px;font-size:12px}.captcha-row[data-v-75731a6d]{display:flex;align-items:center;gap:10px;width:100%}.captcha-img[data-v-75731a6d]{height:40px;border:1px solid var(--app-border);border-radius:8px;cursor:pointer;-webkit-user-select:none;user-select:none}.submit-btn[data-v-75731a6d]{width:100%;margin-top:4px}.actions[data-v-75731a6d]{margin-top:14px;display:flex;align-items:center;justify-content:center;gap:6px}

View File

@@ -0,0 +1 @@
import{_ as L,a as n,l as M,r as U,c as j,o as F,m as K,b as v,d as s,w as a,e as l,u as D,f as m,g as w,F as T,k,h as q,i as x,j as z,t as G,E as y}from"./index-DhsLPY8p.js";import{d as H}from"./auth-cf7b3Gq2.js";import{v as J}from"./password-7ryi82gE.js";const O={class:"auth-wrap"},Q={class:"actions"},W={class:"actions"},X={key:0,class:"app-muted"},Y={__name:"ResetPasswordPage",setup(Z){const B=M(),A=D(),r=n(String(B.params.token||"")),i=n(!0),b=n(""),t=U({newPassword:"",confirmPassword:""}),g=n(!1),f=n(""),d=n(0);let u=null;function C(){if(typeof window>"u")return null;const o=window.__APP_INITIAL_STATE__;return!o||typeof o!="object"?null:(window.__APP_INITIAL_STATE__=null,o)}const I=j(()=>!!(i.value&&r.value&&!f.value));function S(){A.push("/login")}function N(){d.value=3,u=window.setInterval(()=>{d.value-=1,d.value<=0&&(window.clearInterval(u),u=null,window.location.href="/login")},1e3)}async function V(){if(!I.value)return;const o=t.newPassword,e=t.confirmPassword,c=J(o);if(!c.ok){y.error(c.message);return}if(o!==e){y.error("两次输入的密码不一致");return}g.value=!0;try{await H({token:r.value,new_password:o}),f.value="密码重置成功3秒后跳转到登录页面...",y.success("密码重置成功"),N()}catch(p){const _=p?.response?.data;y.error(_?.error||"重置失败")}finally{g.value=!1}}return F(()=>{const o=C();o?.page==="reset_password"?(r.value=String(o?.token||r.value||""),i.value=!!o?.valid,b.value=o?.error_message||(i.value?"":"重置链接无效或已过期,请重新申请密码重置")):r.value||(i.value=!1,b.value="重置链接无效或已过期,请重新申请密码重置")}),K(()=>{u&&window.clearInterval(u)}),(o,e)=>{const c=l("el-alert"),p=l("el-button"),_=l("el-input"),h=l("el-form-item"),R=l("el-form"),E=l("el-card");return m(),v("div",O,[s(E,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:a(()=>[e[5]||(e[5]=w("div",{class:"brand"},[w("div",{class:"brand-title"},"知识管理平台"),w("div",{class:"brand-sub app-muted"},"重置密码")],-1)),i.value?(m(),v(T,{key:1},[f.value?(m(),q(c,{key:0,type:"success",closable:!1,title:"重置成功",description:f.value,"show-icon":"",class:"alert"},null,8,["description"])):x("",!0),s(R,{"label-position":"top"},{default:a(()=>[s(h,{label:"新密码至少8位且包含字母和数字"},{default:a(()=>[s(_,{modelValue:t.newPassword,"onUpdate:modelValue":e[0]||(e[0]=P=>t.newPassword=P),type:"password","show-password":"",placeholder:"请输入新密码",autocomplete:"new-password"},null,8,["modelValue"])]),_:1}),s(h,{label:"确认密码"},{default:a(()=>[s(_,{modelValue:t.confirmPassword,"onUpdate:modelValue":e[1]||(e[1]=P=>t.confirmPassword=P),type:"password","show-password":"",placeholder:"请再次输入新密码",autocomplete:"new-password",onKeyup:z(V,["enter"])},null,8,["modelValue"])]),_:1})]),_:1}),s(p,{type:"primary",class:"submit-btn",loading:g.value,disabled:!I.value,onClick:V},{default:a(()=>[...e[3]||(e[3]=[k(" 确认重置 ",-1)])]),_:1},8,["loading","disabled"]),w("div",W,[s(p,{link:"",type:"primary",onClick:S},{default:a(()=>[...e[4]||(e[4]=[k("返回登录",-1)])]),_:1}),d.value>0?(m(),v("span",X,G(d.value)+" 秒后自动跳转…",1)):x("",!0)])],64)):(m(),v(T,{key:0},[s(c,{type:"error",closable:!1,title:"链接已失效",description:b.value,"show-icon":""},null,8,["description"]),w("div",Q,[s(p,{type:"primary",onClick:S},{default:a(()=>[...e[2]||(e[2]=[k("返回登录",-1)])]),_:1})])],64))]),_:1})])}}},se=L(Y,[["__scopeId","data-v-0bbb511c"]]);export{se as default};

View File

@@ -1 +0,0 @@
import{_ as L,a as n,l as M,r as U,c as j,o as F,m as K,b as v,d as s,w as a,e as l,u as D,f as w,g as m,F as T,k,h as q,i as x,j as z,t as G,v as H,E as y}from"./index-CPwwGffH.js";import{c as J}from"./auth--ytvFYf6.js";const O={class:"auth-wrap"},Q={class:"actions"},W={class:"actions"},X={key:0,class:"app-muted"},Y={__name:"ResetPasswordPage",setup(Z){const B=M(),A=D(),r=n(String(B.params.token||"")),i=n(!0),b=n(""),t=U({newPassword:"",confirmPassword:""}),g=n(!1),_=n(""),d=n(0);let u=null;function C(){if(typeof window>"u")return null;const o=window.__APP_INITIAL_STATE__;return!o||typeof o!="object"?null:(window.__APP_INITIAL_STATE__=null,o)}const I=j(()=>!!(i.value&&r.value&&!_.value));function S(){A.push("/login")}function N(){d.value=3,u=window.setInterval(()=>{d.value-=1,d.value<=0&&(window.clearInterval(u),u=null,window.location.href="/login")},1e3)}async function V(){if(!I.value)return;const o=t.newPassword,e=t.confirmPassword,c=H(o);if(!c.ok){y.error(c.message);return}if(o!==e){y.error("两次输入的密码不一致");return}g.value=!0;try{await J({token:r.value,new_password:o}),_.value="密码重置成功3秒后跳转到登录页面...",y.success("密码重置成功"),N()}catch(p){const f=p?.response?.data;y.error(f?.error||"重置失败")}finally{g.value=!1}}return F(()=>{const o=C();o?.page==="reset_password"?(r.value=String(o?.token||r.value||""),i.value=!!o?.valid,b.value=o?.error_message||(i.value?"":"重置链接无效或已过期,请重新申请密码重置")):r.value||(i.value=!1,b.value="重置链接无效或已过期,请重新申请密码重置")}),K(()=>{u&&window.clearInterval(u)}),(o,e)=>{const c=l("el-alert"),p=l("el-button"),f=l("el-input"),h=l("el-form-item"),R=l("el-form"),E=l("el-card");return w(),v("div",O,[s(E,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:a(()=>[e[5]||(e[5]=m("div",{class:"brand"},[m("div",{class:"brand-title"},"知识管理平台"),m("div",{class:"brand-sub app-muted"},"重置密码")],-1)),i.value?(w(),v(T,{key:1},[_.value?(w(),q(c,{key:0,type:"success",closable:!1,title:"重置成功",description:_.value,"show-icon":"",class:"alert"},null,8,["description"])):x("",!0),s(R,{"label-position":"top"},{default:a(()=>[s(h,{label:"新密码至少8位且包含字母和数字"},{default:a(()=>[s(f,{modelValue:t.newPassword,"onUpdate:modelValue":e[0]||(e[0]=P=>t.newPassword=P),type:"password","show-password":"",placeholder:"请输入新密码",autocomplete:"new-password"},null,8,["modelValue"])]),_:1}),s(h,{label:"确认密码"},{default:a(()=>[s(f,{modelValue:t.confirmPassword,"onUpdate:modelValue":e[1]||(e[1]=P=>t.confirmPassword=P),type:"password","show-password":"",placeholder:"请再次输入新密码",autocomplete:"new-password",onKeyup:z(V,["enter"])},null,8,["modelValue"])]),_:1})]),_:1}),s(p,{type:"primary",class:"submit-btn",loading:g.value,disabled:!I.value,onClick:V},{default:a(()=>[...e[3]||(e[3]=[k(" 确认重置 ",-1)])]),_:1},8,["loading","disabled"]),m("div",W,[s(p,{link:"",type:"primary",onClick:S},{default:a(()=>[...e[4]||(e[4]=[k("返回登录",-1)])]),_:1}),d.value>0?(w(),v("span",X,G(d.value)+" 秒后自动跳转…",1)):x("",!0)])],64)):(w(),v(T,{key:0},[s(c,{type:"error",closable:!1,title:"链接已失效",description:b.value,"show-icon":""},null,8,["description"]),m("div",Q,[s(p,{type:"primary",onClick:S},{default:a(()=>[...e[2]||(e[2]=[k("返回登录",-1)])]),_:1})])],64))]),_:1})])}}},oe=L(Y,[["__scopeId","data-v-0bbb511c"]]);export{oe as default};

View File

@@ -1 +1 @@
import{_ as U,a as o,c as I,o as E,m as R,b as k,d as i,w as s,e as d,u as W,f as _,g as l,i as B,h as $,k as T,t as v}from"./index-CPwwGffH.js";const j={class:"auth-wrap"},z={class:"actions"},D={key:0,class:"countdown app-muted"},M={__name:"VerifyResultPage",setup(q){const x=W(),p=o(!1),f=o(""),m=o(""),w=o(""),y=o(""),r=o(""),u=o(""),c=o(""),n=o(0);let a=null;function C(){if(typeof window>"u")return null;const e=window.__APP_INITIAL_STATE__;return!e||typeof e!="object"?null:(window.__APP_INITIAL_STATE__=null,e)}function N(e){const t=!!e?.success;p.value=t,f.value=e?.title||(t?"验证成功":"验证失败"),m.value=e?.message||e?.error_message||(t?"操作已完成,现在可以继续使用系统。":"操作失败,请稍后重试。"),w.value=e?.primary_label||(t?"立即登录":"重新注册"),y.value=e?.primary_url||(t?"/login":"/register"),r.value=e?.secondary_label||(t?"":"返回登录"),u.value=e?.secondary_url||(t?"":"/login"),c.value=e?.redirect_url||(t?"/login":""),n.value=Number(e?.redirect_seconds||(t?5:0))||0}const A=I(()=>!!(r.value&&u.value)),b=I(()=>!!(c.value&&n.value>0));async function g(e){if(e){if(e.startsWith("http://")||e.startsWith("https://")){window.location.href=e;return}await x.push(e)}}function P(){b.value&&(a=window.setInterval(()=>{n.value-=1,n.value<=0&&(window.clearInterval(a),a=null,window.location.href=c.value)},1e3))}return E(()=>{const e=C();N(e),P()}),R(()=>{a&&window.clearInterval(a)}),(e,t)=>{const h=d("el-button"),V=d("el-result"),L=d("el-card");return _(),k("div",j,[i(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:s(()=>[t[2]||(t[2]=l("div",{class:"brand"},[l("div",{class:"brand-title"},"知识管理平台"),l("div",{class:"brand-sub app-muted"},"验证结果")],-1)),i(V,{icon:p.value?"success":"error",title:f.value,"sub-title":m.value,class:"result"},{extra:s(()=>[l("div",z,[i(h,{type:"primary",onClick:t[0]||(t[0]=S=>g(y.value))},{default:s(()=>[T(v(w.value),1)]),_:1}),A.value?(_(),$(h,{key:0,onClick:t[1]||(t[1]=S=>g(u.value))},{default:s(()=>[T(v(r.value),1)]),_:1})):B("",!0)]),b.value?(_(),k("div",D,v(n.value)+" 秒后自动跳转... ",1)):B("",!0)]),_:1},8,["icon","title","sub-title"])]),_:1})])}}},G=U(M,[["__scopeId","data-v-1fc6b081"]]);export{G as default};
import{_ as U,a as o,c as I,o as E,m as R,b as k,d as i,w as s,e as d,u as W,f as _,g as l,i as B,h as $,k as T,t as v}from"./index-DhsLPY8p.js";const j={class:"auth-wrap"},z={class:"actions"},D={key:0,class:"countdown app-muted"},M={__name:"VerifyResultPage",setup(q){const x=W(),p=o(!1),f=o(""),m=o(""),w=o(""),y=o(""),r=o(""),u=o(""),c=o(""),n=o(0);let a=null;function C(){if(typeof window>"u")return null;const e=window.__APP_INITIAL_STATE__;return!e||typeof e!="object"?null:(window.__APP_INITIAL_STATE__=null,e)}function N(e){const t=!!e?.success;p.value=t,f.value=e?.title||(t?"验证成功":"验证失败"),m.value=e?.message||e?.error_message||(t?"操作已完成,现在可以继续使用系统。":"操作失败,请稍后重试。"),w.value=e?.primary_label||(t?"立即登录":"重新注册"),y.value=e?.primary_url||(t?"/login":"/register"),r.value=e?.secondary_label||(t?"":"返回登录"),u.value=e?.secondary_url||(t?"":"/login"),c.value=e?.redirect_url||(t?"/login":""),n.value=Number(e?.redirect_seconds||(t?5:0))||0}const A=I(()=>!!(r.value&&u.value)),b=I(()=>!!(c.value&&n.value>0));async function g(e){if(e){if(e.startsWith("http://")||e.startsWith("https://")){window.location.href=e;return}await x.push(e)}}function P(){b.value&&(a=window.setInterval(()=>{n.value-=1,n.value<=0&&(window.clearInterval(a),a=null,window.location.href=c.value)},1e3))}return E(()=>{const e=C();N(e),P()}),R(()=>{a&&window.clearInterval(a)}),(e,t)=>{const h=d("el-button"),V=d("el-result"),L=d("el-card");return _(),k("div",j,[i(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:s(()=>[t[2]||(t[2]=l("div",{class:"brand"},[l("div",{class:"brand-title"},"知识管理平台"),l("div",{class:"brand-sub app-muted"},"验证结果")],-1)),i(V,{icon:p.value?"success":"error",title:f.value,"sub-title":m.value,class:"result"},{extra:s(()=>[l("div",z,[i(h,{type:"primary",onClick:t[0]||(t[0]=S=>g(y.value))},{default:s(()=>[T(v(w.value),1)]),_:1}),A.value?(_(),$(h,{key:0,onClick:t[1]||(t[1]=S=>g(u.value))},{default:s(()=>[T(v(r.value),1)]),_:1})):B("",!0)]),b.value?(_(),k("div",D,v(n.value)+" 秒后自动跳转... ",1)):B("",!0)]),_:1},8,["icon","title","sub-title"])]),_:1})])}}},G=U(M,[["__scopeId","data-v-1fc6b081"]]);export{G as default};

View File

@@ -1 +1 @@
import{p as c}from"./index-CPwwGffH.js";async function o(t={}){const{data:a}=await c.get("/accounts",{params:t});return a}async function u(t){const{data:a}=await c.post("/accounts",t);return a}async function r(t,a){const{data:n}=await c.put(`/accounts/${t}`,a);return n}async function e(t){const{data:a}=await c.delete(`/accounts/${t}`);return a}async function i(t,a){const{data:n}=await c.put(`/accounts/${t}/remark`,a);return n}async function p(t,a){const{data:n}=await c.post(`/accounts/${t}/start`,a);return n}async function d(t){const{data:a}=await c.post(`/accounts/${t}/stop`,{});return a}async function f(t){const{data:a}=await c.post("/accounts/batch/start",t);return a}async function w(t){const{data:a}=await c.post("/accounts/batch/stop",t);return a}async function y(){const{data:t}=await c.post("/accounts/clear",{});return t}async function A(t,a={}){const{data:n}=await c.post(`/accounts/${t}/screenshot`,a);return n}export{w as a,f as b,y as c,d,e,o as f,u as g,i as h,p as s,A as t,r as u};
import{p as c}from"./index-DhsLPY8p.js";async function o(t={}){const{data:a}=await c.get("/accounts",{params:t});return a}async function u(t){const{data:a}=await c.post("/accounts",t);return a}async function r(t,a){const{data:n}=await c.put(`/accounts/${t}`,a);return n}async function e(t){const{data:a}=await c.delete(`/accounts/${t}`);return a}async function i(t,a){const{data:n}=await c.put(`/accounts/${t}/remark`,a);return n}async function p(t,a){const{data:n}=await c.post(`/accounts/${t}/start`,a);return n}async function d(t){const{data:a}=await c.post(`/accounts/${t}/stop`,{});return a}async function f(t){const{data:a}=await c.post("/accounts/batch/start",t);return a}async function w(t){const{data:a}=await c.post("/accounts/batch/stop",t);return a}async function y(){const{data:t}=await c.post("/accounts/clear",{});return t}async function A(t,a={}){const{data:n}=await c.post(`/accounts/${t}/screenshot`,a);return n}export{w as a,f as b,y as c,d,e,o as f,u as g,i as h,p as s,A as t,r as u};

View File

@@ -1 +0,0 @@
import{p as s}from"./index-CPwwGffH.js";async function r(){const{data:a}=await s.get("/email/verify-status");return a}async function o(){const{data:a}=await s.post("/generate_captcha",{});return a}async function e(a){const{data:t}=await s.post("/login",a);return t}async function i(a){const{data:t}=await s.post("/register",a);return t}async function c(a){const{data:t}=await s.post("/resend-verify-email",a);return t}async function f(a){const{data:t}=await s.post("/forgot-password",a);return t}async function u(a){const{data:t}=await s.post("/reset-password-confirm",a);return t}export{f as a,i as b,u as c,r as f,o as g,e as l,c as r};

View File

@@ -0,0 +1 @@
import{p as s}from"./index-DhsLPY8p.js";async function r(){const{data:t}=await s.get("/email/verify-status");return t}async function e(){const{data:t}=await s.post("/generate_captcha",{});return t}async function o(t){const{data:a}=await s.post("/login",t);return a}async function i(t){const{data:a}=await s.post("/register",t);return a}async function c(t){const{data:a}=await s.post("/resend-verify-email",t);return a}async function u(t){const{data:a}=await s.post("/forgot-password",t);return a}async function f(t){const{data:a}=await s.post("/reset_password_request",t);return a}async function d(t){const{data:a}=await s.post("/reset-password-confirm",t);return a}export{u as a,c as b,i as c,d,r as f,e as g,o as l,f as r};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
function s(t){const e=String(t||"");return e.length<8?{ok:!1,message:"密码长度至少8位"}:!/[a-zA-Z]/.test(e)||!/\d/.test(e)?{ok:!1,message:"密码必须包含字母和数字"}:{ok:!0,message:""}}export{s as v};

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>知识管理平台</title>
<script type="module" crossorigin src="./assets/index-CPwwGffH.js"></script>
<script type="module" crossorigin src="./assets/index-7hTgh8K-.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BVjJVlht.css">
</head>
<body>

View File

@@ -1,12 +1,12 @@
"""
任务断点续传模块
功能:
1. 记录任务执行进度(每个步骤的状态)
2. 任务异常时自动保存断点
3. 重启后自动恢复未完成任务
4. 智能重试机制
"""
"""
任务断点续传模块
功能:
1. 记录任务执行进度(每个步骤的状态)
2. 任务异常时自动保存断点
3. 重启后自动恢复未完成任务
4. 智能重试机制
"""
import time
import json
from datetime import datetime
@@ -19,97 +19,97 @@ CST_TZ = pytz.timezone("Asia/Shanghai")
def get_cst_now_str():
return datetime.now(CST_TZ).strftime('%Y-%m-%d %H:%M:%S')
class TaskStage(Enum):
"""任务执行阶段"""
QUEUED = 'queued' # 排队中
STARTING = 'starting' # 启动浏览器
LOGGING_IN = 'logging_in' # 登录中
BROWSING = 'browsing' # 浏览中
DOWNLOADING = 'downloading' # 下载中
COMPLETING = 'completing' # 完成中
COMPLETED = 'completed' # 已完成
FAILED = 'failed' # 失败
PAUSED = 'paused' # 暂停(等待恢复)
class TaskCheckpoint:
"""任务断点管理器"""
def __init__(self):
"""初始化(使用全局连接池)"""
self._init_table()
def _safe_json_loads(self, data):
"""安全的JSON解析处理损坏或无效的数据
Args:
data: JSON字符串或None
Returns:
解析后的对象或None
"""
if not data:
return None
try:
return json.loads(data)
except (json.JSONDecodeError, TypeError, ValueError) as e:
print(f"[警告] JSON解析失败: {e}, 数据: {data[:100] if isinstance(data, str) else data}")
return None
def _init_table(self):
"""初始化任务进度表"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS task_checkpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT UNIQUE NOT NULL, -- 任务唯一ID (user_id:account_id:timestamp)
user_id INTEGER NOT NULL,
account_id TEXT NOT NULL,
username TEXT NOT NULL,
browse_type TEXT NOT NULL,
-- 任务状态
stage TEXT NOT NULL, -- 当前阶段
status TEXT NOT NULL, -- running/paused/completed/failed
progress_percent INTEGER DEFAULT 0, -- 进度百分比
-- 进度详情
current_page INTEGER DEFAULT 0, -- 当前浏览到第几页
total_pages INTEGER DEFAULT 0, -- 总页数(如果已知)
processed_items INTEGER DEFAULT 0, -- 已处理条目数
downloaded_files INTEGER DEFAULT 0, -- 已下载文件数
-- 错误处理
retry_count INTEGER DEFAULT 0, -- 重试次数
max_retries INTEGER DEFAULT 3, -- 最大重试次数
last_error TEXT, -- 最后一次错误信息
error_count INTEGER DEFAULT 0, -- 累计错误次数
-- 断点数据(JSON格式存储上下文)
checkpoint_data TEXT, -- 断点上下文数据
-- 时间戳
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
""")
# 创建索引加速查询
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_task_status
ON task_checkpoints(status, stage)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_task_user
ON task_checkpoints(user_id, account_id)
""")
conn.commit()
class TaskStage(Enum):
"""任务执行阶段"""
QUEUED = 'queued' # 排队中
STARTING = 'starting' # 启动浏览器
LOGGING_IN = 'logging_in' # 登录中
BROWSING = 'browsing' # 浏览中
DOWNLOADING = 'downloading' # 下载中
COMPLETING = 'completing' # 完成中
COMPLETED = 'completed' # 已完成
FAILED = 'failed' # 失败
PAUSED = 'paused' # 暂停(等待恢复)
class TaskCheckpoint:
"""任务断点管理器"""
def __init__(self):
"""初始化(使用全局连接池)"""
self._init_table()
def _safe_json_loads(self, data):
"""安全的JSON解析处理损坏或无效的数据
Args:
data: JSON字符串或None
Returns:
解析后的对象或None
"""
if not data:
return None
try:
return json.loads(data)
except (json.JSONDecodeError, TypeError, ValueError) as e:
print(f"[警告] JSON解析失败: {e}, 数据: {data[:100] if isinstance(data, str) else data}")
return None
def _init_table(self):
"""初始化任务进度表"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS task_checkpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT UNIQUE NOT NULL, -- 任务唯一ID (user_id:account_id:timestamp)
user_id INTEGER NOT NULL,
account_id TEXT NOT NULL,
username TEXT NOT NULL,
browse_type TEXT NOT NULL,
-- 任务状态
stage TEXT NOT NULL, -- 当前阶段
status TEXT NOT NULL, -- running/paused/completed/failed
progress_percent INTEGER DEFAULT 0, -- 进度百分比
-- 进度详情
current_page INTEGER DEFAULT 0, -- 当前浏览到第几页
total_pages INTEGER DEFAULT 0, -- 总页数(如果已知)
processed_items INTEGER DEFAULT 0, -- 已处理条目数
downloaded_files INTEGER DEFAULT 0, -- 已下载文件数
-- 错误处理
retry_count INTEGER DEFAULT 0, -- 重试次数
max_retries INTEGER DEFAULT 3, -- 最大重试次数
last_error TEXT, -- 最后一次错误信息
error_count INTEGER DEFAULT 0, -- 累计错误次数
-- 断点数据(JSON格式存储上下文)
checkpoint_data TEXT, -- 断点上下文数据
-- 时间戳
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
""")
# 创建索引加速查询
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_task_status
ON task_checkpoints(status, stage)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_task_user
ON task_checkpoints(user_id, account_id)
""")
conn.commit()
def create_checkpoint(self, user_id, account_id, username, browse_type):
"""创建新的任务断点"""
task_id = f"{user_id}:{account_id}:{int(time.time())}"
@@ -124,90 +124,90 @@ class TaskCheckpoint:
TaskStage.QUEUED.value, 'running', cst_time, cst_time))
conn.commit()
return task_id
def update_stage(self, task_id, stage, progress_percent=None, checkpoint_data=None):
"""更新任务阶段"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
def update_stage(self, task_id, stage, progress_percent=None, checkpoint_data=None):
"""更新任务阶段"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
updates = ['stage = ?', 'updated_at = ?']
params = [stage.value if isinstance(stage, TaskStage) else stage, get_cst_now_str()]
if progress_percent is not None:
updates.append('progress_percent = ?')
params.append(progress_percent)
if checkpoint_data is not None:
updates.append('checkpoint_data = ?')
params.append(json.dumps(checkpoint_data, ensure_ascii=False))
params.append(task_id)
cursor.execute(f"""
UPDATE task_checkpoints
SET {', '.join(updates)}
WHERE task_id = ?
""", params)
conn.commit()
def update_progress(self, task_id, **kwargs):
"""更新任务进度
Args:
task_id: 任务ID
current_page: 当前页码
total_pages: 总页数
processed_items: 已处理条目数
downloaded_files: 已下载文件数
"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
if progress_percent is not None:
updates.append('progress_percent = ?')
params.append(progress_percent)
if checkpoint_data is not None:
updates.append('checkpoint_data = ?')
params.append(json.dumps(checkpoint_data, ensure_ascii=False))
params.append(task_id)
cursor.execute(f"""
UPDATE task_checkpoints
SET {', '.join(updates)}
WHERE task_id = ?
""", params)
conn.commit()
def update_progress(self, task_id, **kwargs):
"""更新任务进度
Args:
task_id: 任务ID
current_page: 当前页码
total_pages: 总页数
processed_items: 已处理条目数
downloaded_files: 已下载文件数
"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
updates = ['updated_at = ?']
params = [get_cst_now_str()]
for key in ['current_page', 'total_pages', 'processed_items', 'downloaded_files']:
if key in kwargs:
updates.append(f'{key} = ?')
params.append(kwargs[key])
# 自动计算进度百分比
if 'current_page' in kwargs and 'total_pages' in kwargs and kwargs['total_pages'] > 0:
progress = int((kwargs['current_page'] / kwargs['total_pages']) * 100)
updates.append('progress_percent = ?')
params.append(min(progress, 100))
params.append(task_id)
cursor.execute(f"""
UPDATE task_checkpoints
SET {', '.join(updates)}
WHERE task_id = ?
""", params)
conn.commit()
for key in ['current_page', 'total_pages', 'processed_items', 'downloaded_files']:
if key in kwargs:
updates.append(f'{key} = ?')
params.append(kwargs[key])
# 自动计算进度百分比
if 'current_page' in kwargs and 'total_pages' in kwargs and kwargs['total_pages'] > 0:
progress = int((kwargs['current_page'] / kwargs['total_pages']) * 100)
updates.append('progress_percent = ?')
params.append(min(progress, 100))
params.append(task_id)
cursor.execute(f"""
UPDATE task_checkpoints
SET {', '.join(updates)}
WHERE task_id = ?
""", params)
conn.commit()
def record_error(self, task_id, error_message, pause=False):
"""记录错误并决定是否暂停任务"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cst_time = get_cst_now_str()
# 获取当前重试次数和最大重试次数
cursor.execute("""
SELECT retry_count, max_retries, error_count
FROM task_checkpoints
WHERE task_id = ?
""", (task_id,))
result = cursor.fetchone()
if result:
retry_count, max_retries, error_count = result
retry_count += 1
error_count += 1
# 判断是否超过最大重试次数
if retry_count >= max_retries or pause:
# 超过重试次数,暂停任务等待人工处理
# 获取当前重试次数和最大重试次数
cursor.execute("""
SELECT retry_count, max_retries, error_count
FROM task_checkpoints
WHERE task_id = ?
""", (task_id,))
result = cursor.fetchone()
if result:
retry_count, max_retries, error_count = result
retry_count += 1
error_count += 1
# 判断是否超过最大重试次数
if retry_count >= max_retries or pause:
# 超过重试次数,暂停任务等待人工处理
cursor.execute("""
UPDATE task_checkpoints
SET status = 'paused',
@@ -233,9 +233,9 @@ class TaskCheckpoint:
""", (retry_count, error_count, error_message, cst_time, task_id))
conn.commit()
return 'retry'
return 'unknown'
return 'unknown'
def complete_task(self, task_id, success=True):
"""完成任务"""
with db_pool.get_db() as conn:
@@ -253,86 +253,86 @@ class TaskCheckpoint:
TaskStage.COMPLETED.value if success else TaskStage.FAILED.value,
cst_time, cst_time, task_id))
conn.commit()
def get_checkpoint(self, task_id):
"""获取任务断点信息"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT task_id, user_id, account_id, username, browse_type,
stage, status, progress_percent,
current_page, total_pages, processed_items, downloaded_files,
retry_count, max_retries, last_error, error_count,
checkpoint_data, created_at, updated_at, completed_at
FROM task_checkpoints
WHERE task_id = ?
""", (task_id,))
row = cursor.fetchone()
if row:
return {
'task_id': row[0],
'user_id': row[1],
'account_id': row[2],
'username': row[3],
'browse_type': row[4],
'stage': row[5],
'status': row[6],
'progress_percent': row[7],
'current_page': row[8],
'total_pages': row[9],
'processed_items': row[10],
'downloaded_files': row[11],
'retry_count': row[12],
'max_retries': row[13],
'last_error': row[14],
'error_count': row[15],
'checkpoint_data': self._safe_json_loads(row[16]),
'created_at': row[17],
'updated_at': row[18],
'completed_at': row[19]
}
return None
def get_paused_tasks(self, user_id=None):
"""获取所有暂停的任务(可恢复的任务)"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
if user_id:
cursor.execute("""
SELECT task_id, user_id, account_id, username, browse_type,
stage, progress_percent, last_error, retry_count,
updated_at
FROM task_checkpoints
WHERE status = 'paused' AND user_id = ?
ORDER BY updated_at DESC
""", (user_id,))
else:
cursor.execute("""
SELECT task_id, user_id, account_id, username, browse_type,
stage, progress_percent, last_error, retry_count,
updated_at
FROM task_checkpoints
WHERE status = 'paused'
ORDER BY updated_at DESC
""")
tasks = []
for row in cursor.fetchall():
tasks.append({
'task_id': row[0],
'user_id': row[1],
'account_id': row[2],
'username': row[3],
'browse_type': row[4],
'stage': row[5],
'progress_percent': row[6],
'last_error': row[7],
'retry_count': row[8],
'updated_at': row[9]
})
return tasks
def get_checkpoint(self, task_id):
"""获取任务断点信息"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT task_id, user_id, account_id, username, browse_type,
stage, status, progress_percent,
current_page, total_pages, processed_items, downloaded_files,
retry_count, max_retries, last_error, error_count,
checkpoint_data, created_at, updated_at, completed_at
FROM task_checkpoints
WHERE task_id = ?
""", (task_id,))
row = cursor.fetchone()
if row:
return {
'task_id': row[0],
'user_id': row[1],
'account_id': row[2],
'username': row[3],
'browse_type': row[4],
'stage': row[5],
'status': row[6],
'progress_percent': row[7],
'current_page': row[8],
'total_pages': row[9],
'processed_items': row[10],
'downloaded_files': row[11],
'retry_count': row[12],
'max_retries': row[13],
'last_error': row[14],
'error_count': row[15],
'checkpoint_data': self._safe_json_loads(row[16]),
'created_at': row[17],
'updated_at': row[18],
'completed_at': row[19]
}
return None
def get_paused_tasks(self, user_id=None):
"""获取所有暂停的任务(可恢复的任务)"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
if user_id:
cursor.execute("""
SELECT task_id, user_id, account_id, username, browse_type,
stage, progress_percent, last_error, retry_count,
updated_at
FROM task_checkpoints
WHERE status = 'paused' AND user_id = ?
ORDER BY updated_at DESC
""", (user_id,))
else:
cursor.execute("""
SELECT task_id, user_id, account_id, username, browse_type,
stage, progress_percent, last_error, retry_count,
updated_at
FROM task_checkpoints
WHERE status = 'paused'
ORDER BY updated_at DESC
""")
tasks = []
for row in cursor.fetchall():
tasks.append({
'task_id': row[0],
'user_id': row[1],
'account_id': row[2],
'username': row[3],
'browse_type': row[4],
'stage': row[5],
'progress_percent': row[6],
'last_error': row[7],
'retry_count': row[8],
'updated_at': row[9]
})
return tasks
def resume_task(self, task_id):
"""恢复暂停的任务"""
with db_pool.get_db() as conn:
@@ -347,7 +347,7 @@ class TaskCheckpoint:
""", (cst_time, task_id))
conn.commit()
return cursor.rowcount > 0
def abandon_task(self, task_id):
"""放弃暂停的任务"""
with db_pool.get_db() as conn:
@@ -363,7 +363,7 @@ class TaskCheckpoint:
""", (TaskStage.FAILED.value, cst_time, cst_time, task_id))
conn.commit()
return cursor.rowcount > 0
def cleanup_old_checkpoints(self, days=7):
"""清理旧的断点数据(保留最近N天)"""
with db_pool.get_db() as conn:
@@ -376,14 +376,14 @@ class TaskCheckpoint:
deleted = cursor.rowcount
conn.commit()
return deleted
# 全局单例
_checkpoint_manager = None
def get_checkpoint_manager():
"""获取全局断点管理器实例"""
global _checkpoint_manager
if _checkpoint_manager is None:
_checkpoint_manager = TaskCheckpoint()
return _checkpoint_manager
# 全局单例
_checkpoint_manager = None
def get_checkpoint_manager():
"""获取全局断点管理器实例"""
global _checkpoint_manager
if _checkpoint_manager is None:
_checkpoint_manager = TaskCheckpoint()
return _checkpoint_manager

7
tests/conftest.py Normal file
View File

@@ -0,0 +1,7 @@
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))

View File

@@ -0,0 +1,249 @@
from __future__ import annotations
from datetime import timedelta
import pytest
from flask import Flask
import db_pool
from db.schema import ensure_schema
from db.utils import get_cst_now
from security.blacklist import BlacklistManager
from security.risk_scorer import RiskScorer
@pytest.fixture()
def _test_db(tmp_path):
db_file = tmp_path / "admin_security_api_test.db"
old_pool = getattr(db_pool, "_pool", None)
try:
if old_pool is not None:
try:
old_pool.close_all()
except Exception:
pass
db_pool._pool = None
db_pool.init_pool(str(db_file), pool_size=1)
with db_pool.get_db() as conn:
ensure_schema(conn)
yield db_file
finally:
try:
if getattr(db_pool, "_pool", None) is not None:
db_pool._pool.close_all()
except Exception:
pass
db_pool._pool = old_pool
def _make_app() -> Flask:
from routes.admin_api.security import security_bp
app = Flask(__name__)
app.config.update(SECRET_KEY="test-secret", TESTING=True)
app.register_blueprint(security_bp)
return app
def _login_admin(client) -> None:
with client.session_transaction() as sess:
sess["admin_id"] = 1
sess["admin_username"] = "admin"
def _insert_threat_event(*, threat_type: str, score: int, ip: str, user_id: int | None, created_at: str, payload: str):
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO threat_events (threat_type, score, ip, user_id, request_path, value_preview, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(threat_type, int(score), ip, user_id, "/api/test", payload, created_at),
)
conn.commit()
def test_dashboard_requires_admin(_test_db):
app = _make_app()
client = app.test_client()
resp = client.get("/api/admin/security/dashboard")
assert resp.status_code == 403
assert resp.get_json() == {"error": "需要管理员权限"}
def test_dashboard_counts_and_payload_truncation(_test_db):
app = _make_app()
client = app.test_client()
_login_admin(client)
now = get_cst_now()
within_24h = now.strftime("%Y-%m-%d %H:%M:%S")
within_24h_2 = (now - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
older = (now - timedelta(hours=25)).strftime("%Y-%m-%d %H:%M:%S")
long_payload = "x" * 300
_insert_threat_event(
threat_type="sql_injection",
score=90,
ip="1.2.3.4",
user_id=10,
created_at=within_24h,
payload=long_payload,
)
_insert_threat_event(
threat_type="xss",
score=70,
ip="2.3.4.5",
user_id=11,
created_at=within_24h_2,
payload="short",
)
_insert_threat_event(
threat_type="path_traversal",
score=60,
ip="9.9.9.9",
user_id=None,
created_at=older,
payload="old",
)
manager = BlacklistManager()
manager.ban_ip("8.8.8.8", reason="manual", duration_hours=1, permanent=False)
manager._ban_user_internal(123, reason="manual", duration_hours=1, permanent=False)
resp = client.get("/api/admin/security/dashboard")
assert resp.status_code == 200
data = resp.get_json()
assert data["threat_events_24h"] == 2
assert data["banned_ip_count"] == 1
assert data["banned_user_count"] == 1
recent = data["recent_threat_events"]
assert isinstance(recent, list)
assert len(recent) == 3
payload_preview = recent[0]["value_preview"]
assert isinstance(payload_preview, str)
assert len(payload_preview) <= 200
assert payload_preview.endswith("...")
def test_threats_pagination_and_filters(_test_db):
app = _make_app()
client = app.test_client()
_login_admin(client)
now = get_cst_now()
t1 = (now - timedelta(minutes=1)).strftime("%Y-%m-%d %H:%M:%S")
t2 = (now - timedelta(minutes=2)).strftime("%Y-%m-%d %H:%M:%S")
t3 = (now - timedelta(minutes=3)).strftime("%Y-%m-%d %H:%M:%S")
_insert_threat_event(threat_type="sql_injection", score=90, ip="1.1.1.1", user_id=1, created_at=t1, payload="a")
_insert_threat_event(threat_type="xss", score=70, ip="2.2.2.2", user_id=2, created_at=t2, payload="b")
_insert_threat_event(threat_type="nested_expression", score=80, ip="3.3.3.3", user_id=3, created_at=t3, payload="c")
resp = client.get("/api/admin/security/threats?page=1&per_page=2")
assert resp.status_code == 200
data = resp.get_json()
assert data["total"] == 3
assert len(data["items"]) == 2
resp2 = client.get("/api/admin/security/threats?page=2&per_page=2")
assert resp2.status_code == 200
data2 = resp2.get_json()
assert data2["total"] == 3
assert len(data2["items"]) == 1
resp3 = client.get("/api/admin/security/threats?event_type=sql_injection")
assert resp3.status_code == 200
data3 = resp3.get_json()
assert data3["total"] == 1
assert data3["items"][0]["threat_type"] == "sql_injection"
resp4 = client.get("/api/admin/security/threats?severity=high")
assert resp4.status_code == 200
data4 = resp4.get_json()
assert data4["total"] == 2
assert {item["threat_type"] for item in data4["items"]} == {"sql_injection", "nested_expression"}
def test_ban_and_unban_ip(_test_db):
app = _make_app()
client = app.test_client()
_login_admin(client)
resp = client.post("/api/admin/security/ban-ip", json={"ip": "7.7.7.7", "reason": "test", "duration_hours": 1})
assert resp.status_code == 200
assert resp.get_json()["success"] is True
list_resp = client.get("/api/admin/security/banned-ips")
assert list_resp.status_code == 200
payload = list_resp.get_json()
assert payload["count"] == 1
assert payload["items"][0]["ip"] == "7.7.7.7"
resp2 = client.post("/api/admin/security/unban-ip", json={"ip": "7.7.7.7"})
assert resp2.status_code == 200
assert resp2.get_json()["success"] is True
list_resp2 = client.get("/api/admin/security/banned-ips")
assert list_resp2.status_code == 200
assert list_resp2.get_json()["count"] == 0
def test_risk_endpoints_and_cleanup(_test_db):
app = _make_app()
client = app.test_client()
_login_admin(client)
scorer = RiskScorer(auto_ban_enabled=False)
scorer.record_threat("4.4.4.4", 44, threat_type="xss", score=20, request_path="/", payload="<script>")
ip_resp = client.get("/api/admin/security/ip-risk/4.4.4.4")
assert ip_resp.status_code == 200
ip_data = ip_resp.get_json()
assert ip_data["risk_score"] == 20
assert len(ip_data["threat_history"]) >= 1
user_resp = client.get("/api/admin/security/user-risk/44")
assert user_resp.status_code == 200
user_data = user_resp.get_json()
assert user_data["risk_score"] == 20
assert len(user_data["threat_history"]) >= 1
# Prepare decaying scores and expired ban
old_ts = (get_cst_now() - timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S")
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO ip_risk_scores (ip, risk_score, last_seen, created_at, updated_at)
VALUES (?, 100, ?, ?, ?)
""",
("5.5.5.5", old_ts, old_ts, old_ts),
)
cursor.execute(
"""
INSERT INTO ip_blacklist (ip, reason, is_active, added_at, expires_at)
VALUES (?, ?, 1, ?, ?)
""",
("6.6.6.6", "expired", old_ts, old_ts),
)
conn.commit()
manager = BlacklistManager()
assert manager.is_ip_banned("6.6.6.6") is False # expired already
cleanup_resp = client.post("/api/admin/security/cleanup", json={})
assert cleanup_resp.status_code == 200
assert cleanup_resp.get_json()["success"] is True
# Score decayed by cleanup
assert RiskScorer().get_ip_score("5.5.5.5") == 81

View File

@@ -0,0 +1,74 @@
from __future__ import annotations
import queue
from browser_pool_worker import BrowserWorker
class _AlwaysFailEnsureWorker(BrowserWorker):
def __init__(self, *, worker_id: int, task_queue: queue.Queue):
super().__init__(worker_id=worker_id, task_queue=task_queue, pre_warm=False)
self.ensure_calls = 0
def _ensure_browser(self) -> bool: # noqa: D401 - matching base naming
self.ensure_calls += 1
if self.ensure_calls >= 2:
self.running = False
return False
def _close_browser(self):
self.browser_instance = None
def test_requeue_task_when_browser_unavailable():
task_queue: queue.Queue = queue.Queue()
callback_calls: list[tuple[object, object]] = []
def callback(result, error):
callback_calls.append((result, error))
task = {
"func": lambda *_args, **_kwargs: None,
"args": (),
"kwargs": {},
"callback": callback,
"retry_count": 0,
}
worker = _AlwaysFailEnsureWorker(worker_id=1, task_queue=task_queue)
worker.start()
task_queue.put(task)
worker.join(timeout=5)
assert worker.is_alive() is False
assert worker.ensure_calls == 2 # 本地最多尝试2次创建执行环境
assert callback_calls == [] # 第一次失败会重新入队,不应立即回调失败
requeued = task_queue.get_nowait()
assert requeued["retry_count"] == 1
def test_fail_task_after_second_assignment():
task_queue: queue.Queue = queue.Queue()
callback_calls: list[tuple[object, object]] = []
def callback(result, error):
callback_calls.append((result, error))
task = {
"func": lambda *_args, **_kwargs: None,
"args": (),
"kwargs": {},
"callback": callback,
"retry_count": 1, # 已重新分配过1次
}
worker = _AlwaysFailEnsureWorker(worker_id=1, task_queue=task_queue)
worker.start()
task_queue.put(task)
worker.join(timeout=5)
assert worker.is_alive() is False
assert callback_calls == [(None, "执行环境不可用")]
assert worker.total_tasks == 1
assert worker.failed_tasks == 1

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