diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..0fb7671
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,59 @@
+# 环境变量配置示例
+# 复制此文件为 .env 并根据实际情况修改
+
+# ==================== Flask核心配置 ====================
+# Flask运行环境: development, production, testing
+FLASK_ENV=production
+# 是否开启DEBUG模式
+FLASK_DEBUG=false
+
+# ==================== 安全配置 ====================
+# Session密钥(生产环境务必修改为随机字符串)
+# SECRET_KEY=your-secret-key-here
+
+# Session配置
+SESSION_LIFETIME_HOURS=24
+SESSION_COOKIE_SECURE=false # 使用HTTPS时设为true
+
+# ==================== 数据库配置 ====================
+DB_FILE=data/app_data.db
+DB_POOL_SIZE=5
+
+# ==================== 并发控制配置 ====================
+MAX_CONCURRENT_GLOBAL=2
+MAX_CONCURRENT_PER_ACCOUNT=1
+MAX_CONCURRENT_CONTEXTS=100
+
+# ==================== 日志配置 ====================
+LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR, CRITICAL
+LOG_FILE=logs/app.log
+LOG_MAX_BYTES=10485760 # 10MB
+LOG_BACKUP_COUNT=5
+
+# ==================== 验证码配置 ====================
+MAX_CAPTCHA_ATTEMPTS=5
+CAPTCHA_EXPIRE_SECONDS=300 # 5分钟
+MAX_IP_ATTEMPTS_PER_HOUR=10
+IP_LOCK_DURATION=3600 # 1小时
+
+# ==================== 知识管理平台配置 ====================
+# 登录URL(根据实际部署修改)
+ZSGL_LOGIN_URL=https://postoa.aidunsoft.com/admin/login.aspx
+ZSGL_INDEX_URL_PATTERN=index.aspx
+
+# ==================== 浏览器配置 ====================
+SCREENSHOTS_DIR=截图
+PAGE_LOAD_TIMEOUT=60000 # 毫秒
+DEFAULT_TIMEOUT=60000 # 毫秒
+
+# ==================== 服务器配置 ====================
+# 服务器监听地址和端口
+SERVER_HOST=0.0.0.0
+SERVER_PORT=51233
+
+# ==================== 其他配置 ====================
+# 截图文件大小限制
+MAX_SCREENSHOT_SIZE=10485760 # 10MB
+
+# SocketIO CORS配置
+SOCKETIO_CORS_ALLOWED_ORIGINS=*
diff --git a/.gitignore b/.gitignore
index 2cadcb5..70e8139 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,9 @@ env/
venv/
ENV/
+# 环境变量文件(包含敏感信息)
+.env
+
# Docker volumes
volumes/
diff --git a/BUG修复报告_20251210.md b/BUG修复报告_20251210.md
new file mode 100644
index 0000000..6ff5c56
--- /dev/null
+++ b/BUG修复报告_20251210.md
@@ -0,0 +1,180 @@
+# zsglpt项目 Bug修复报告
+
+**修复时间**: 2025年12月10日
+**服务器**: 118.145.177.79
+**项目路径**: /www/wwwroot/zsglpt
+
+---
+
+## 修复内容总结
+
+### ✅ 1. 修复添加账号按钮无反应问题
+
+**问题原因**:
+- 后端API中变量未定义,导致添加账号失败
+- 前端缺少备注输入字段
+
+**修复方案**:
+- 在第1190行添加变量定义:
+ ```python
+ remember = data.get('remember', True)
+ ```
+- 在前端添加账号表单中添加备注输入框
+- 修改JavaScript添加remark参数传递
+
+**影响文件**:
+-
+-
+
+---
+
+### ✅ 2. 为账号添加备注功能(可选,不需要占位符)
+
+**实现方案**:
+- 后端API已支持remark字段
+- 前端添加账号表单新增备注输入框(可选填写)
+- 账号列表显示备注信息
+
+**特性**:
+- ✓ 备注字段可选,不强制填写
+- ✓ 无默认占位字符
+- ✓ 限制200字符
+
+---
+
+### ✅ 3. 账号卡片添加设置按钮支持修改密码和备注
+
+**实现方案**:
+- 在账号卡片操作区域添加⚙️设置按钮
+- 新增编辑账号弹窗()
+- 支持修改密码(留空则不修改)
+- 支持修改备注
+
+**功能说明**:
+- ✓ 可以只修改密码
+- ✓ 可以只修改备注
+- ✓ 可以同时修改密码和备注
+- ✓ 账号运行中时设置按钮禁用
+
+**影响文件**:
+-
+ - 添加编辑账号弹窗HTML
+ - 添加函数
+ - 添加函数
+ - 添加函数
+
+---
+
+### ✅ 4. 修复用户反馈功能问题
+
+**问题1**: 提交反馈后显示提交失败
+- **原因**: 前端JavaScript检查,但后端返回的是
+- **修复**: 修改成功判断条件为
+
+**问题2**: 用户看不到反馈历史
+- **原因**: API路径错误,前端调用,实际应为
+- **修复**: 修正API路径
+
+**影响文件**:
+-
+ - 修改函数
+ - 修改函数中的API路径
+
+---
+
+### ✅ 5. 为定时任务添加执行日志功能
+
+**实现方案**:
+- 在定时任务卡片添加日志按钮
+- 新增日志查看弹窗()
+- 调用后端API 获取执行记录
+
+**日志信息包含**:
+- ✓ 执行时间
+- ✓ 执行状态(成功/失败/进行中)
+- ✓ 账号数量
+- ✓ 成功/失败账号数
+- ✓ 执行耗时
+- ✓ 错误信息(如有)
+
+**影响文件**:
+-
+ - 添加日志查看弹窗HTML
+ - 添加函数
+ - 添加函数
+
+---
+
+### ✅ 6. 定时任务不执行问题
+
+**问题原因**:
+- 数据库缺少和表
+
+**修复方案**:
+- 重启Docker容器,触发数据库初始化
+- 数据库初始化代码已包含表创建逻辑
+
+**操作命令**:
+```bash
+cd /www/wwwroot/zsglpt
+docker-compose down
+docker-compose up -d
+```
+
+---
+
+## 测试验证
+
+### 添加账号功能测试
+1. ✓ 点击添加账号按钮能正常打开弹窗
+2. ✓ 填写账号、密码、备注后能成功添加
+3. ✓ 备注字段可选,不填写也能成功添加
+
+### 账号设置功能测试
+1. ✓ 账号卡片显示设置按钮(⚙️)
+2. ✓ 点击设置按钮打开编辑弹窗
+3. ✓ 可以只修改密码
+4. ✓ 可以只修改备注
+5. ✓ 留空密码则不修改密码
+
+### 反馈功能测试
+1. ✓ 提交反馈后显示反馈已提交,感谢!
+2. ✓ 点击我的反馈能查看历史反馈记录
+3. ✓ 后台能看到用户提交的反馈
+
+### 定时任务日志测试
+1. ✓ 定时任务卡片显示日志按钮
+2. ✓ 点击日志按钮能查看执行历史
+3. ✓ 日志显示完整的执行信息
+
+---
+
+## 注意事项
+
+1. **已备份文件**:
+ -
+ -
+
+2. **Docker容器已重启**:
+ - 所有修改已生效
+ - 数据库已初始化完成
+
+3. **浏览器缓存**:
+ - 建议用户清除浏览器缓存或强制刷新(Ctrl+F5)
+ - 确保加载最新的前端代码
+
+---
+
+## 修复总结
+
+| 序号 | 问题描述 | 状态 |
+|-----|---------|-----|
+| 1 | 添加账号按钮无反应 | ✅ 已修复 |
+| 2 | 添加账号时缺少备注字段 | ✅ 已添加 |
+| 3 | 账号卡片缺少设置按钮 | ✅ 已添加 |
+| 4 | 反馈提交后显示失败 | ✅ 已修复 |
+| 5 | 用户看不到反馈历史 | ✅ 已修复 |
+| 6 | 定时任务不执行 | ✅ 已修复 |
+| 7 | 定时任务缺少日志功能 | ✅ 已添加 |
+
+**所有bug已修复完成!** 🎉
diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md
new file mode 100644
index 0000000..75f4905
--- /dev/null
+++ b/DEPLOYMENT_GUIDE.md
@@ -0,0 +1,267 @@
+# 生产环境部署指南
+
+## 服务器信息
+- **IP地址**: 118.145.177.79
+- **域名**: zsglpt.workyai.cn
+- **SSH用户**: root
+- **项目路径**: /root/zsglpt
+
+---
+
+## 部署前检查清单
+
+### 1. 本地环境检查
+- [x] 代码已测试无误
+- [x] 数据库迁移脚本已准备
+- [x] Docker配置文件已更新
+- [x] 静态文件已优化(SourceMap已修复)
+
+### 2. 服务器连接测试
+```bash
+# 测试SSH连接
+ssh root@118.145.177.79 "echo 'SSH连接成功'"
+```
+
+### 3. 确认服务器环境
+```bash
+# 检查Docker是否运行
+ssh root@118.145.177.79 "docker ps"
+
+# 检查磁盘空间
+ssh root@118.145.177.79 "df -h"
+
+# 检查内存
+ssh root@118.145.177.79 "free -h"
+```
+
+---
+
+## 快速部署步骤
+
+### 方法1: 使用自动化脚本(推荐)
+
+```bash
+cd /home/yuyx/aaaaaa/zsglpt
+./deploy_to_production.sh
+```
+
+**脚本会自动完成:**
+1. ✓ 测试SSH连接
+2. ✓ 备份生产服务器数据库
+3. ✓ 压缩并上传项目文件
+4. ✓ 停止旧容器
+5. ✓ 重新构建Docker镜像
+6. ✓ 启动新容器
+7. ✓ 显示运行状态
+
+### 方法2: 手动部署步骤
+
+如果自动脚本失败,可以手动执行以下步骤:
+
+#### 步骤1: 备份生产数据
+```bash
+ssh root@118.145.177.79 "
+ cd /root/zsglpt
+ mkdir -p backups/backup_$(date +%Y%m%d_%H%M%S)
+ cp -r data backups/backup_$(date +%Y%m%d_%H%M%S)/
+"
+```
+
+#### 步骤2: 压缩并上传项目
+```bash
+cd /home/yuyx/aaaaaa/zsglpt
+tar -czf /tmp/zsglpt_deploy.tar.gz \
+ --exclude='data' \
+ --exclude='logs' \
+ --exclude='截图' \
+ --exclude='playwright' \
+ --exclude='backups' \
+ .
+
+scp /tmp/zsglpt_deploy.tar.gz root@118.145.177.79:/tmp/
+```
+
+#### 步骤3: 在服务器上解压
+```bash
+ssh root@118.145.177.79 "
+ cd /root/zsglpt
+ tar -xzf /tmp/zsglpt_deploy.tar.gz
+ rm /tmp/zsglpt_deploy.tar.gz
+"
+```
+
+#### 步骤4: 重启服务
+```bash
+ssh root@118.145.177.79 "
+ cd /root/zsglpt
+ docker-compose down
+ docker-compose build --no-cache
+ docker-compose up -d
+"
+```
+
+---
+
+## 部署后验证
+
+### 1. 检查容器状态
+```bash
+ssh root@118.145.177.79 "docker ps | grep knowledge-automation"
+```
+
+**预期输出**: 状态为 `Up` 且包含 `(healthy)`
+
+### 2. 检查服务日志
+```bash
+ssh root@118.145.177.79 "docker logs --tail 50 knowledge-automation-multiuser"
+```
+
+**预期输出**:
+- ✓ 数据库连接池已初始化
+- ✓ 浏览器环境检查完成
+- ✓ 服务器启动成功
+
+### 3. 测试HTTP访问
+```bash
+curl -I https://zsglpt.workyai.cn
+```
+
+**预期输出**: HTTP/1.1 200 或 302
+
+### 4. 测试功能
+- [ ] 访问前台页面: https://zsglpt.workyai.cn
+- [ ] 测试登录功能
+- [ ] 测试任务执行
+- [ ] 检查WebSocket连接
+- [ ] 验证数据库数据完整
+
+---
+
+## 重要修复项说明
+
+### 修复1: 执行用时计算
+**位置**: `app.py:1221`
+**说明**: 执行用时现在只计算任务实际运行时间,不包含排队等待时间
+
+### 修复2: SourceMap错误
+**文件**: `static/js/socket.io.min.js`
+**说明**: 已添加内联空SourceMap,浏览器不会再报404错误
+
+### 修复3: 静态文件缓存
+**位置**: `app.py:1747-1749`
+**说明**: 添加了Cache-Control响应头,禁用静态文件缓存
+
+### 修复4: Docker挂载
+**文件**: `docker-compose.yml`
+**说明**: 新增了static、templates、app.py的挂载,方便热更新
+
+---
+
+## 回滚步骤
+
+如果部署后出现问题,可以快速回滚到上一个版本:
+
+```bash
+ssh root@118.145.177.79 "
+ cd /root/zsglpt
+
+ # 找到最新的备份目录
+ LATEST_BACKUP=\$(ls -t backups/ | head -1)
+ echo \"回滚到备份: \$LATEST_BACKUP\"
+
+ # 停止当前容器
+ docker-compose down
+
+ # 恢复代码文件
+ cp -r backups/\$LATEST_BACKUP/code/* .
+
+ # 恢复数据库(如果需要)
+ # cp -r backups/\$LATEST_BACKUP/data/* data/
+
+ # 重启容器
+ docker-compose up -d --build
+"
+```
+
+---
+
+## 常见问题排查
+
+### 问题1: 容器无法启动
+```bash
+# 查看详细错误日志
+ssh root@118.145.177.79 "docker logs knowledge-automation-multiuser"
+
+# 检查端口占用
+ssh root@118.145.177.79 "netstat -tlnp | grep 51233"
+```
+
+### 问题2: 数据库连接失败
+```bash
+# 检查数据库文件权限
+ssh root@118.145.177.79 "ls -lh /root/zsglpt/data/"
+
+# 检查数据库文件是否存在
+ssh root@118.145.177.79 "docker exec knowledge-automation-multiuser ls -lh /app/data/"
+```
+
+### 问题3: 域名无法访问
+```bash
+# 检查Nginx配置
+ssh root@118.145.177.79 "docker exec zsgpt2-nginx nginx -t"
+
+# 查看Nginx日志
+ssh root@118.145.177.79 "docker logs zsgpt2-nginx"
+```
+
+### 问题4: WebSocket连接失败
+检查Nginx配置是否包含WebSocket代理设置:
+```nginx
+location /socket.io/ {
+ proxy_pass http://knowledge-automation:51233;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+}
+```
+
+---
+
+## 监控命令
+
+### 实时查看日志
+```bash
+ssh root@118.145.177.79 "docker logs -f knowledge-automation-multiuser"
+```
+
+### 查看容器资源使用
+```bash
+ssh root@118.145.177.79 "docker stats knowledge-automation-multiuser"
+```
+
+### 查看运行任务
+```bash
+ssh root@118.145.177.79 "docker exec knowledge-automation-multiuser ps aux | grep python"
+```
+
+---
+
+## 紧急联系信息
+
+- **部署时间**: 2025-11-20
+- **部署版本**: v2.0 (修复执行用时+SourceMap)
+- **备份位置**: /root/zsglpt/backups/
+
+---
+
+## 维护建议
+
+1. **定期备份**: 建议每天自动备份数据库
+2. **日志清理**: 定期清理旧日志文件,避免磁盘占满
+3. **监控磁盘**: 关注磁盘使用率,特别是截图目录
+4. **更新依赖**: 定期更新Python依赖包和Docker镜像
+5. **安全审计**: 定期检查系统安全日志
+
+---
+
+**部署完成后请在GitHub/文档中更新部署记录!**
diff --git a/DEPLOYMENT_SUMMARY.md b/DEPLOYMENT_SUMMARY.md
new file mode 100644
index 0000000..07276d5
--- /dev/null
+++ b/DEPLOYMENT_SUMMARY.md
@@ -0,0 +1,131 @@
+# 🚀 生产环境部署摘要
+
+## 快速开始
+
+```bash
+cd /home/yuyx/aaaaaa/zsglpt
+./deploy_to_production.sh
+```
+
+部署完成后验证:
+```bash
+./verify_deployment.sh
+```
+
+---
+
+## 📋 部署清单
+
+### 已创建的文件
+1. ✅ `deploy_to_production.sh` - 自动化部署脚本
+2. ✅ `verify_deployment.sh` - 部署验证脚本
+3. ✅ `DEPLOYMENT_GUIDE.md` - 详细部署指南
+
+### 服务器信息
+- **IP**: 118.145.177.79
+- **用户**: root
+- **路径**: /root/zsglpt
+- **域名**: zsglpt.workyai.cn
+
+---
+
+## 🔧 本次更新内容
+
+### 1. Bug修复
+- ✅ 执行用时计算:不再包含排队等待时间 (app.py:1221)
+- ✅ SourceMap 404错误:添加内联空SourceMap
+- ✅ 静态文件缓存:添加Cache-Control响应头
+
+### 2. 配置优化
+- ✅ Docker挂载:新增static/templates/app.py挂载
+- ✅ 性能优化:优化数据库连接池配置
+- ✅ 安全加固:Session配置优化
+
+### 3. 新增功能
+- ✅ 自动化部署脚本
+- ✅ 部署验证脚本
+- ✅ 完整的回滚方案
+
+---
+
+## ⚡ 部署步骤
+
+### 1. 执行部署脚本
+```bash
+./deploy_to_production.sh
+```
+
+脚本会自动:
+- ✓ 测试SSH连接
+- ✓ 备份数据库
+- ✓ 上传文件
+- ✓ 停止旧容器
+- ✓ 构建新镜像
+- ✓ 启动新容器
+
+### 2. 验证部署
+```bash
+./verify_deployment.sh
+```
+
+### 3. 访问测试
+- 前台: https://zsglpt.workyai.cn
+- 后台: https://zsglpt.workyai.cn/yuyx
+
+---
+
+## 🔍 验证检查项
+
+- [ ] 容器状态为 `Up (healthy)`
+- [ ] 无错误日志
+- [ ] 数据库文件存在
+- [ ] HTTP访问返回200/302
+- [ ] WebSocket连接正常
+- [ ] 登录功能正常
+- [ ] 任务执行正常
+
+---
+
+## 🆘 如果出现问题
+
+### 查看日志
+```bash
+ssh root@118.145.177.79 "docker logs -f knowledge-automation-multiuser"
+```
+
+### 立即回滚
+```bash
+ssh root@118.145.177.79 "
+ cd /root/zsglpt
+ LATEST_BACKUP=\$(ls -t backups/ | head -1)
+ docker-compose down
+ cp -r backups/\$LATEST_BACKUP/code/* .
+ docker-compose up -d --build
+"
+```
+
+### 联系支持
+如遇到无法解决的问题,请提供:
+1. 错误日志截图
+2. 容器状态输出
+3. 详细操作步骤
+
+---
+
+## 📊 部署记录
+
+| 日期 | 版本 | 主要更新 | 状态 |
+|------|------|----------|------|
+| 2025-11-20 | v2.0 | 修复执行用时+SourceMap | 待部署 |
+
+---
+
+## 📞 支持信息
+
+- **文档位置**: /home/yuyx/aaaaaa/zsglpt/DEPLOYMENT_GUIDE.md
+- **备份位置**: /root/zsglpt/backups/
+- **日志位置**: /root/zsglpt/logs/
+
+---
+
+**祝部署顺利!** 🎉
diff --git a/Dockerfile b/Dockerfile
index 70b0bd6..43b199d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,8 +23,13 @@ COPY app.py .
COPY database.py .
COPY db_pool.py .
COPY playwright_automation.py .
+COPY api_browser.py .
+COPY browser_pool.py .
+COPY browser_pool_worker.py .
+COPY screenshot_worker.py .
COPY browser_installer.py .
COPY password_utils.py .
+COPY task_checkpoint.py .
# 复制新的优化模块
COPY app_config.py .
@@ -39,8 +44,8 @@ COPY static/ ./static/
# 创建必要的目录
RUN mkdir -p data logs 截图
-# 暴露端口
-EXPOSE 5000
+# 暴露端口(容器内端口,与 app_config.py 中 SERVER_PORT 默认值一致)
+EXPOSE 51233
# 启动命令
CMD ["python", "app.py"]
diff --git a/admin.html b/admin.html
new file mode 100644
index 0000000..0120f9d
--- /dev/null
+++ b/admin.html
@@ -0,0 +1,2213 @@
+
+
+
+
+
+ 后台管理 v2.0 - 知识管理平台
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
用户注册审核
+
+
+
密码重置审核
+
+
+
+
+
+
+
+
+
+
用户反馈管理
+
+
+
+
+
+
+ 总计: 0
+ 待处理: 0
+ 已回复: 0
+ 已关闭: 0
+
+
+
+
+
+
+
系统并发配置
+
+
+
+
+
+
+
+
定时任务配置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🌐 代理设置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
服务器信息
+
+
+
+
+
Docker容器状态
+
+
+
+
+
实时任务监控 ● 实时更新中
+
+
+
+
+
+
+
+
+
+
当日任务统计
+
+
+
+
历史累计统计
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 时间 |
+ 来源 |
+ 用户 |
+ 账号 |
+ 浏览类型 |
+ 状态 |
+ 内容/附件 |
+ 用时 |
+ 失败原因 |
+
+
+
+ | 加载中... |
+
+
+
+
+
+
+
+
+
+
+
管理员账号设置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/api_browser.py b/api_browser.py
new file mode 100755
index 0000000..e3ff2f9
--- /dev/null
+++ b/api_browser.py
@@ -0,0 +1,388 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+API 浏览器 - 用纯 HTTP 请求实现浏览功能
+比 Playwright 快 30-60 倍
+"""
+
+import requests
+from bs4 import BeautifulSoup
+import re
+import time
+from typing import Optional, Callable
+from dataclasses import dataclass
+
+
+BASE_URL = "https://postoa.aidunsoft.com"
+
+
+@dataclass
+class APIBrowseResult:
+ """API 浏览结果"""
+ success: bool
+ total_items: int = 0
+ total_attachments: int = 0
+ error_message: str = ""
+
+
+class APIBrowser:
+ """API 浏览器 - 使用纯 HTTP 请求实现浏览"""
+
+ 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.logged_in = False
+ self.log_callback = log_callback
+ self.stop_flag = False
+ # 设置代理
+ if proxy_config and proxy_config.get("server"):
+ proxy_server = proxy_config["server"]
+ self.session.proxies = {
+ "http": proxy_server,
+ "https": proxy_server
+ }
+ self.proxy_server = proxy_server
+ else:
+ self.proxy_server = None
+
+ def log(self, message: str):
+ """记录日志"""
+ if self.log_callback:
+ self.log_callback(message)
+ def save_cookies_for_playwright(self, username: str):
+ """保存cookies供Playwright使用"""
+ import os
+ import json
+ import hashlib
+
+ cookies_dir = '/app/data/cookies'
+ os.makedirs(cookies_dir, exist_ok=True)
+
+ # 用用户名的hash作为文件名
+ filename = hashlib.md5(username.encode()).hexdigest() + '.json'
+ cookies_path = os.path.join(cookies_dir, filename)
+
+ try:
+ # 获取requests session的cookies
+ cookies_list = []
+ for cookie in self.session.cookies:
+ cookies_list.append({
+ 'name': cookie.name,
+ 'value': cookie.value,
+ 'domain': cookie.domain or 'postoa.aidunsoft.com',
+ 'path': cookie.path or '/',
+ })
+
+ # Playwright storage_state 格式
+ storage_state = {
+ 'cookies': cookies_list,
+ 'origins': []
+ }
+
+ with open(cookies_path, 'w', encoding='utf-8') as f:
+ json.dump(storage_state, f)
+
+ self.log(f"[API] Cookies已保存供截图使用")
+ return True
+ except Exception as e:
+ self.log(f"[API] 保存cookies失败: {e}")
+ return False
+
+
+
+ def _request_with_retry(self, method, url, max_retries=3, retry_delay=1, **kwargs):
+ """带重试机制的请求方法"""
+ kwargs.setdefault('timeout', 10)
+ last_error = None
+
+ for attempt in range(1, max_retries + 1):
+ try:
+ if method.lower() == 'get':
+ resp = self.session.get(url, **kwargs)
+ else:
+ resp = self.session.post(url, **kwargs)
+ return resp
+ except Exception as e:
+ last_error = e
+ 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})
+ if field:
+ fields[name] = field.get('value', '')
+ return fields
+
+ def login(self, username: str, password: str) -> bool:
+ """登录"""
+ self.log(f"[API] 登录: {username}")
+
+ try:
+ login_url = f"{BASE_URL}/admin/login.aspx"
+ resp = self._request_with_retry('get', login_url)
+
+ soup = BeautifulSoup(resp.text, 'html.parser')
+ fields = self._get_aspnet_fields(soup)
+
+ data = fields.copy()
+ data['txtUserName'] = username
+ data['txtPassword'] = password
+ data['btnSubmit'] = '登 录'
+
+ resp = self._request_with_retry(
+ 'post',
+ login_url,
+ data=data,
+ headers={
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Origin': BASE_URL,
+ 'Referer': login_url,
+ },
+ allow_redirects=True
+ )
+
+ if 'index.aspx' in resp.url:
+ self.logged_in = True
+ 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 '未知错误'
+ self.log(f"[API] 登录失败: {error_msg}")
+ return False
+
+ except Exception as e:
+ self.log(f"[API] 登录异常: {str(e)}")
+ return False
+
+ def get_article_list_page(self, bz: int = 2, page: int = 1, base_url: str = None):
+ """获取单页文章列表"""
+ if not self.logged_in:
+ return [], 0, None
+
+ try:
+ if base_url and page > 1:
+ url = re.sub(r'page=\d+', f'page={page}', base_url)
+ else:
+ url = f"{BASE_URL}/admin/center.aspx?bz={bz}"
+
+ resp = self._request_with_retry('get', url)
+ soup = BeautifulSoup(resp.text, 'html.parser')
+ articles = []
+
+ ltable = soup.find('table', {'class': 'ltable'})
+ if ltable:
+ rows = ltable.find_all('tr')[1:]
+ for row in rows:
+ # 检查是否是"暂无记录"
+ if '暂无记录' in row.get_text():
+ continue
+
+ link = row.find('a', href=True)
+ if link:
+ href = link.get('href', '')
+ title = link.get_text().strip()
+
+ 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,
+ })
+
+ # 获取总页数
+ total_pages = 1
+ next_page_url = None
+
+ page_content = soup.find(id='PageContent')
+ if page_content:
+ text = page_content.get_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('下一页'))
+ if next_link:
+ next_href = next_link.get('href', '')
+ if next_href:
+ next_page_url = f"{BASE_URL}/admin/{next_href}"
+
+ return articles, total_pages, next_page_url
+
+ except Exception as e:
+ self.log(f"[API] 获取列表失败: {str(e)}")
+ return [], 0, None
+
+ def get_article_attachments(self, article_href: str):
+ """获取文章的附件列表"""
+ try:
+ if not article_href.startswith('http'):
+ url = f"{BASE_URL}/admin/{article_href}"
+ else:
+ url = article_href
+
+ resp = self._request_with_retry('get', url)
+ soup = BeautifulSoup(resp.text, 'html.parser')
+
+ attachments = []
+
+ attach_list = soup.find('div', {'class': 'attach-list2'})
+ if attach_list:
+ items = attach_list.find_all('li')
+ for item in items:
+ 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)
+ 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
+ })
+ break
+
+ return attachments
+
+ except Exception as e:
+ return []
+
+ 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)
+ resp.close()
+ return resp.status_code == 200
+ except:
+ return False
+
+ def browse_content(self, browse_type: str,
+ should_stop_callback: Optional[Callable] = None) -> APIBrowseResult:
+ """
+ 浏览内容并标记已读
+
+ Args:
+ browse_type: 浏览类型 (应读/注册前未读)
+ should_stop_callback: 检查是否应该停止的回调函数
+
+ Returns:
+ 浏览结果
+ """
+ result = APIBrowseResult(success=False)
+
+ if not self.logged_in:
+ result.error_message = "未登录"
+ return result
+
+ # 根据浏览类型确定 bz 参数
+ # 网页实际选项: 0=注册前未读, 1=已读, 2=应读
+ # 前端选项: 注册前未读, 应读, 未读, 已读
+ if '注册前' in browse_type:
+ bz = 0 # 注册前未读
+ elif browse_type == '已读':
+ bz = 1 # 已读
+ else:
+ bz = 2 # 应读、未读 都映射到 bz=2
+
+ self.log(f"[API] 开始浏览 '{browse_type}' (bz={bz})...")
+
+ try:
+ total_items = 0
+ total_attachments = 0
+ page = 1
+ base_url = None
+
+ # 获取第一页
+ articles, total_pages, next_url = self.get_article_list_page(bz, page)
+
+ if not articles:
+ self.log(f"[API] '{browse_type}' 没有待处理内容")
+ result.success = True
+ return result
+
+ self.log(f"[API] 共 {total_pages} 页,开始处理...")
+
+ if next_url:
+ base_url = next_url
+
+ # 处理所有页面
+ while True:
+ if should_stop_callback and should_stop_callback():
+ self.log("[API] 收到停止信号")
+ break
+
+ for article in articles:
+ if should_stop_callback and should_stop_callback():
+ break
+
+ title = article['title'][:30]
+ total_items += 1
+
+ # 获取附件
+ attachments = self.get_article_attachments(article['href'])
+
+ if attachments:
+ for attach in attachments:
+ if self.mark_read(attach['id'], attach['channel_id']):
+ total_attachments += 1
+
+ self.log(f"[API] [{total_items}] {title} - {len(attachments)}个附件")
+
+ time.sleep(0.1)
+
+ # 下一页
+ page += 1
+ if page > total_pages:
+ break
+
+ articles, _, next_url = self.get_article_list_page(bz, page, base_url)
+ if not articles:
+ break
+
+ if next_url:
+ base_url = next_url
+
+ time.sleep(0.2)
+
+ self.log(f"[API] 浏览完成: {total_items} 条内容,{total_attachments} 个附件")
+
+ result.success = True
+ result.total_items = total_items
+ result.total_attachments = total_attachments
+ return result
+
+ except Exception as e:
+ result.error_message = str(e)
+ self.log(f"[API] 浏览出错: {str(e)}")
+ return result
+
+ def close(self):
+ """关闭会话"""
+ try:
+ self.session.close()
+ except:
+ pass
diff --git a/app.py b/app.py
index 394d7c7..8cf8cb7 100755
--- a/app.py
+++ b/app.py
@@ -29,7 +29,10 @@ from functools import wraps
# 导入数据库模块和核心模块
import database
import requests
+from browser_pool import get_browser_pool, init_browser_pool
+from browser_pool_worker import get_browser_worker_pool, init_browser_worker_pool, shutdown_browser_worker_pool
from playwright_automation import PlaywrightBrowserManager, PlaywrightAutomation, BrowseResult
+from api_browser import APIBrowser, APIBrowseResult
from browser_installer import check_and_install_browser
# ========== 优化模块导入 ==========
from app_config import get_config
@@ -39,25 +42,26 @@ from app_security import (
validate_username, validate_password, validate_email,
is_safe_path, sanitize_filename, get_client_ip
)
+from app_utils import verify_and_consume_captcha
# ========== 初始化配置 ==========
config = get_config()
app = Flask(__name__)
-# SECRET_KEY持久化,避免重启后所有用户登出
-SECRET_KEY_FILE = 'data/secret_key.txt'
-if os.path.exists(SECRET_KEY_FILE):
- with open(SECRET_KEY_FILE, 'r') as f:
- SECRET_KEY = f.read().strip()
-else:
- SECRET_KEY = os.urandom(24).hex()
- os.makedirs('data', exist_ok=True)
- with open(SECRET_KEY_FILE, 'w') as f:
- f.write(SECRET_KEY)
- print(f"✓ 已生成新的SECRET_KEY并保存")
app.config.from_object(config)
-socketio = SocketIO(app, cors_allowed_origins="*")
+# 确保SECRET_KEY已设置
+if not app.config.get('SECRET_KEY'):
+ raise RuntimeError("SECRET_KEY未配置,请检查app_config.py")
+socketio = SocketIO(
+ app,
+ cors_allowed_origins="*",
+ async_mode='threading', # 明确指定async模式
+ ping_timeout=60, # ping超时60秒
+ ping_interval=25, # 每25秒ping一次
+ logger=False, # 禁用socketio debug日志
+ engineio_logger=False
+)
# ========== 初始化日志系统 ==========
init_logging(log_level=config.LOG_LEVEL, log_file=config.LOG_FILE)
@@ -65,6 +69,11 @@ logger = get_logger('app')
logger.info("="*60)
logger.info("知识管理平台自动化工具 - 多用户版")
logger.info("="*60)
+logger.info(f"Session配置: COOKIE_NAME={app.config.get('SESSION_COOKIE_NAME', 'session')}, "
+ f"SAMESITE={app.config.get('SESSION_COOKIE_SAMESITE', 'None')}, "
+ f"HTTPONLY={app.config.get('SESSION_COOKIE_HTTPONLY', 'None')}, "
+ f"SECURE={app.config.get('SESSION_COOKIE_SECURE', 'None')}, "
+ f"PATH={app.config.get('SESSION_COOKIE_PATH', 'None')}")
# Flask-Login 配置
@@ -72,6 +81,13 @@ login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'login_page'
+@login_manager.unauthorized_handler
+def unauthorized():
+ """处理未授权访问 - API请求返回JSON,页面请求重定向"""
+ if request.path.startswith('/api/') or request.path.startswith('/yuyx/api/'):
+ return jsonify({"error": "请先登录", "code": "unauthorized"}), 401
+ return redirect(url_for('login_page', next=request.url))
+
# 截图目录
SCREENSHOTS_DIR = config.SCREENSHOTS_DIR
os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
@@ -80,6 +96,12 @@ os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
browser_manager = None
user_accounts = {} # {user_id: {account_id: Account对象}}
active_tasks = {} # {account_id: Thread对象}
+task_status = {} # {account_id: {"user_id": x, "username": y, "status": "排队中/运行中", "detail_status": "具体状态", "browse_type": z, "start_time": t, "source": s, "progress": {...}, "is_vip": bool}}
+
+# VIP优先级队列
+vip_task_queue = [] # VIP用户任务队列
+normal_task_queue = [] # 普通用户任务队列
+task_queue_lock = threading.Lock()
log_cache = {} # {user_id: [logs]} 每个用户独立的日志缓存
log_cache_total_count = 0 # 全局日志总数,防止无限增长
@@ -94,18 +116,30 @@ captcha_storage = {}
# IP限流存储:{ip: {"attempts": count, "lock_until": timestamp, "first_attempt": timestamp}}
ip_rate_limit = {}
-# 限流配置
-MAX_CAPTCHA_ATTEMPTS = 5 # 每个验证码最多尝试次数
-MAX_IP_ATTEMPTS_PER_HOUR = 10 # 每小时每个IP最多验证码错误次数
-IP_LOCK_DURATION = 3600 # IP锁定时长(秒) - 1小时
-# 全局限制:整个系统同时最多运行2个账号(线程本地架构,每个线程独立浏览器,内存占用约200MB/浏览器)
-max_concurrent_per_account = 1 # 每个用户最多1个
-max_concurrent_global = 2 # 全局最多2个(线程本地架构内存需求更高)
+# 限流配置 - 从 config 读取,避免硬编码
+MAX_CAPTCHA_ATTEMPTS = config.MAX_CAPTCHA_ATTEMPTS
+MAX_IP_ATTEMPTS_PER_HOUR = config.MAX_IP_ATTEMPTS_PER_HOUR
+IP_LOCK_DURATION = config.IP_LOCK_DURATION
+# 全局限制:整个系统同时最多运行N个账号(线程本地架构,每个线程独立浏览器,内存占用约200MB/浏览器)
+max_concurrent_per_account = config.MAX_CONCURRENT_PER_ACCOUNT
+max_concurrent_global = config.MAX_CONCURRENT_GLOBAL
user_semaphores = {} # {user_id: Semaphore}
global_semaphore = threading.Semaphore(max_concurrent_global)
# 截图专用信号量:限制同时进行的截图任务数量为1(避免资源竞争)
-screenshot_semaphore = threading.Semaphore(1)
+# ���图信号量将在首次使用时初始化
+screenshot_semaphore = None
+screenshot_semaphore_lock = threading.Lock()
+
+def get_screenshot_semaphore():
+ """获取截图信号量(懒加载,根据配置动态创建)"""
+ global screenshot_semaphore
+ with screenshot_semaphore_lock:
+ config = database.get_system_config()
+ max_concurrent = config.get('max_screenshot_concurrent', 3)
+ if screenshot_semaphore is None:
+ screenshot_semaphore = threading.Semaphore(max_concurrent)
+ return screenshot_semaphore, max_concurrent
class User(UserMixin):
@@ -140,7 +174,7 @@ class Account:
self.proxy_config = None # 保存代理配置,浏览和截图共用
def to_dict(self):
- return {
+ result = {
"id": self.id,
"username": self.username,
"status": self.status,
@@ -149,6 +183,35 @@ class Account:
"total_attachments": self.total_attachments,
"is_running": self.is_running
}
+ # 添加详细进度信息(如果有)
+ if self.id in task_status:
+ ts = task_status[self.id]
+ progress = ts.get('progress', {})
+ result['detail_status'] = ts.get('detail_status', '')
+ result['progress_items'] = progress.get('items', 0)
+ result['progress_attachments'] = progress.get('attachments', 0)
+ result['start_time'] = ts.get('start_time', 0)
+ # 计算运行时长
+ if ts.get('start_time'):
+ import time
+ elapsed = int(time.time() - ts['start_time'])
+ result['elapsed_seconds'] = elapsed
+ mins, secs = divmod(elapsed, 60)
+ result['elapsed_display'] = f"{mins}分{secs}秒"
+ else:
+ # 非运行状态下,根据status设置detail_status
+ status_map = {
+ '已完成': '任务完成',
+ '截图中': '正在截图',
+ '浏览完成': '浏览完成',
+ '登录失败': '登录失败',
+ '已暂停': '任务已暂停'
+ }
+ for key, val in status_map.items():
+ if key in self.status:
+ result['detail_status'] = val
+ break
+ return result
@login_manager.user_loader
@@ -164,8 +227,12 @@ def admin_required(f):
"""管理员权限装饰器"""
@wraps(f)
def decorated_function(*args, **kwargs):
+ logger.debug(f"[admin_required] Session内容: {dict(session)}")
+ logger.debug(f"[admin_required] Cookies: {request.cookies}")
if 'admin_id' not in session:
+ logger.warning(f"[admin_required] 拒绝访问 {request.path} - session中无admin_id")
return jsonify({"error": "需要管理员权限"}), 403
+ logger.info(f"[admin_required] 管理员 {session.get('admin_username')} 访问 {request.path}")
return f(*args, **kwargs)
return decorated_function
@@ -214,7 +281,13 @@ def log_to_client(message, user_id=None, account_id=None):
# 发送到该用户的room
socketio.emit('log', log_data, room=f'user_{user_id}')
- print(f"[{timestamp}] User:{user_id} {message}")
+ # 控制台日志:添加账号短标识便于区分
+ if account_id:
+ # 显示账号ID前4位作为标识
+ short_id = account_id[:4] if len(account_id) >= 4 else account_id
+ print(f"[{timestamp}] U{user_id}:{short_id} | {message}")
+ else:
+ print(f"[{timestamp}] U{user_id} | {message}")
@@ -228,17 +301,43 @@ def get_proxy_from_api(api_url, max_retries=3):
Returns:
代理服务器地址(格式: http://IP:PORT)或 None
"""
+ import re
+ # IP:PORT 格式正则
+ ip_port_pattern = re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{1,5}$')
+
for attempt in range(max_retries):
try:
response = requests.get(api_url, timeout=10)
if response.status_code == 200:
- ip_port = response.text.strip()
- if ip_port and ':' in ip_port:
- proxy_server = f"http://{ip_port}"
+ text = response.text.strip()
+
+ # 尝试解析JSON响应
+ try:
+ import json
+ data = json.loads(text)
+ # 检查是否是错误响应
+ if isinstance(data, dict):
+ if data.get('status') != 200 and data.get('status') != 0:
+ error_msg = data.get('msg', data.get('message', '未知错误'))
+ print(f"✗ 代理API返回错误: {error_msg} (尝试 {attempt + 1}/{max_retries})")
+ if attempt < max_retries - 1:
+ time.sleep(1)
+ continue
+ # 尝试从JSON中获取IP
+ ip_port = data.get('ip') or data.get('proxy') or data.get('data')
+ if ip_port:
+ text = str(ip_port).strip()
+ except (json.JSONDecodeError, ValueError):
+ # 不是JSON,继续使用原始文本
+ pass
+
+ # 验证IP:PORT格式
+ if ip_port_pattern.match(text):
+ proxy_server = f"http://{text}"
print(f"✓ 获取代理成功: {proxy_server} (尝试 {attempt + 1}/{max_retries})")
return proxy_server
else:
- print(f"✗ 代理格式错误: {ip_port} (尝试 {attempt + 1}/{max_retries})")
+ print(f"✗ 代理格式无效: {text[:50]} (尝试 {attempt + 1}/{max_retries})")
else:
print(f"✗ 获取代理失败: HTTP {response.status_code} (尝试 {attempt + 1}/{max_retries})")
except Exception as e:
@@ -344,44 +443,22 @@ def register():
if not username or not password:
return jsonify({"error": "用户名和密码不能为空"}), 400
- # 验证验证码
- if not captcha_session or captcha_session not in captcha_storage:
- return jsonify({"error": "验证码已过期,请重新获取"}), 400
-
- captcha_data = captcha_storage[captcha_session]
- if captcha_data["expire_time"] < time.time():
- del captcha_storage[captcha_session]
- return jsonify({"error": "验证码已过期,请重新获取"}), 400
-
- # 获取客户端IP
- client_ip = request.headers.get('X-Forwarded-For', request.headers.get('X-Real-IP', request.remote_addr))
- if client_ip and ',' in client_ip:
- client_ip = client_ip.split(',')[0].strip()
+ # 获取客户端IP(用于IP限流检查)
+ client_ip = get_client_ip()
# 检查IP限流
allowed, error_msg = check_ip_rate_limit(client_ip)
if not allowed:
return jsonify({"error": error_msg}), 429
- # 检查验证码尝试次数
- if captcha_data.get("failed_attempts", 0) >= MAX_CAPTCHA_ATTEMPTS:
- del captcha_storage[captcha_session]
- return jsonify({"error": "验证码尝试次数过多,请重新获取"}), 400
-
- if captcha_data["code"] != captcha_code:
- # 记录失败次数
- captcha_data["failed_attempts"] = captcha_data.get("failed_attempts", 0) + 1
-
- # 记录IP失败尝试
+ # 验证验证码
+ success, message = verify_and_consume_captcha(captcha_session, captcha_code, captcha_storage, MAX_CAPTCHA_ATTEMPTS)
+ if not success:
+ # 验证失败,记录IP失败尝试(注册特有的IP限流逻辑)
is_locked = record_failed_captcha(client_ip)
if is_locked:
return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
-
- return jsonify({"error": "验证码错误(剩余{}次机会)".format(
- MAX_CAPTCHA_ATTEMPTS - captcha_data["failed_attempts"])}), 400
-
- # 验证成功,删除已使用的验证码
- del captcha_storage[captcha_session]
+ return jsonify({"error": message}), 400
user_id = database.create_user(username, password, email)
if user_id:
@@ -392,7 +469,9 @@ def register():
# ==================== 验证码API ====================
import random
+from task_checkpoint import get_checkpoint_manager, TaskStage
+checkpoint_mgr = None # 任务断点管理器
def check_ip_rate_limit(ip_address):
"""检查IP是否被限流"""
@@ -481,19 +560,9 @@ def login():
# 如果需要验证码,验证验证码
if need_captcha:
- if not captcha_session or captcha_session not in captcha_storage:
- return jsonify({"error": "验证码已过期,请重新获取"}), 400
-
- captcha_data = captcha_storage[captcha_session]
- if captcha_data["expire_time"] < time.time():
- del captcha_storage[captcha_session]
- return jsonify({"error": "验证码已过期,请重新获取"}), 400
-
- if captcha_data["code"] != captcha_code:
- return jsonify({"error": "验证码错误"}), 400
-
- # 验证成功,删除已使用的验证码
- del captcha_storage[captcha_session]
+ success, message = verify_and_consume_captcha(captcha_session, captcha_code, captcha_storage)
+ if not success:
+ return jsonify({"error": message}), 400
# 先检查用户是否存在
user_exists = database.get_user_by_username(username)
@@ -527,11 +596,34 @@ def logout():
# ==================== 管理员认证API ====================
+@app.route('/yuyx/api/debug-config', methods=['GET'])
+def debug_config():
+ """调试配置信息"""
+ return jsonify({
+ "secret_key_set": bool(app.secret_key),
+ "secret_key_length": len(app.secret_key) if app.secret_key else 0,
+ "session_config": {
+ "SESSION_COOKIE_NAME": app.config.get('SESSION_COOKIE_NAME'),
+ "SESSION_COOKIE_SECURE": app.config.get('SESSION_COOKIE_SECURE'),
+ "SESSION_COOKIE_HTTPONLY": app.config.get('SESSION_COOKIE_HTTPONLY'),
+ "SESSION_COOKIE_SAMESITE": app.config.get('SESSION_COOKIE_SAMESITE'),
+ "PERMANENT_SESSION_LIFETIME": str(app.config.get('PERMANENT_SESSION_LIFETIME')),
+ },
+ "current_session": dict(session),
+ "cookies_received": list(request.cookies.keys())
+ })
+
+
@app.route('/yuyx/api/login', methods=['POST'])
@require_ip_not_locked # IP限流保护
def admin_login():
- """管理员登录"""
- data = request.json
+ """管理员登录(支持JSON和form-data两种格式)"""
+ # 兼容JSON和form-data两种提交方式
+ if request.is_json:
+ data = request.json
+ else:
+ data = request.form
+
username = data.get('username', '').strip()
password = data.get('password', '').strip()
captcha_session = data.get('captcha_session', '')
@@ -540,27 +632,42 @@ def admin_login():
# 如果需要验证码,验证验证码
if need_captcha:
- if not captcha_session or captcha_session not in captcha_storage:
- return jsonify({"error": "验证码已过期,请重新获取"}), 400
-
- captcha_data = captcha_storage[captcha_session]
- if captcha_data["expire_time"] < time.time():
- del captcha_storage[captcha_session]
- return jsonify({"error": "验证码已过期,请重新获取"}), 400
-
- if captcha_data["code"] != captcha_code:
- return jsonify({"error": "验证码错误"}), 400
-
- # 验证成功,删除已使用的验证码
- del captcha_storage[captcha_session]
+ success, message = verify_and_consume_captcha(captcha_session, captcha_code, captcha_storage)
+ if not success:
+ if request.is_json:
+ return jsonify({"error": message}), 400
+ else:
+ return redirect(url_for('admin_login_page'))
admin = database.verify_admin(username, password)
if admin:
+ # 清除旧session,确保干净的状态
+ session.clear()
+ # 设置管理员session
session['admin_id'] = admin['id']
session['admin_username'] = admin['username']
- return jsonify({"success": True})
+ session.permanent = True # 设置为永久会话(使用PERMANENT_SESSION_LIFETIME配置)
+ session.modified = True # 强制标记session为已修改,确保保存
+
+ logger.info(f"[admin_login] 管理员 {username} 登录成功, session已设置: admin_id={admin['id']}")
+ logger.debug(f"[admin_login] Session内容: {dict(session)}")
+ logger.debug(f"[admin_login] Cookie将被设置: name={app.config.get('SESSION_COOKIE_NAME', 'session')}")
+
+ # 根据请求类型返回不同响应
+ if request.is_json:
+ # JSON请求:返回JSON响应(给JavaScript使用)
+ response = jsonify({"success": True, "redirect": "/yuyx/admin"})
+ return response
+ else:
+ # form-data请求:直接重定向到后台页面
+ return redirect(url_for('admin_page'))
else:
- return jsonify({"error": "管理员用户名或密码错误", "need_captcha": True}), 401
+ logger.warning(f"[admin_login] 管理员 {username} 登录失败 - 用户名或密码错误")
+ if request.is_json:
+ return jsonify({"error": "管理员用户名或密码错误", "need_captcha": True}), 401
+ else:
+ # form提交失败,重定向回登录页(TODO: 可以添加flash消息)
+ return redirect(url_for('admin_login_page'))
@app.route('/yuyx/api/logout', methods=['POST'])
@@ -665,8 +772,8 @@ def get_docker_stats():
try:
with open('/etc/hostname', 'r') as f:
docker_status['container_name'] = f.read().strip()
- except:
- pass
+ except Exception as e:
+ logger.debug(f"读取容器名称失败: {e}")
# 获取内存使用情况 (cgroup v2)
try:
@@ -771,8 +878,8 @@ def get_docker_stats():
docker_status['uptime'] = "{}小时 {}分钟".format(hours, minutes)
else:
docker_status['uptime'] = "{}分钟".format(minutes)
- except:
- pass
+ except Exception as e:
+ logger.debug(f"读取容器运行时间失败: {e}")
docker_status['status'] = 'Running'
else:
@@ -801,6 +908,22 @@ def update_admin_password():
@app.route('/yuyx/api/admin/username', methods=['PUT'])
@admin_required
+def update_admin_username():
+ """修改管理员用户名"""
+ data = request.json
+ new_username = data.get('new_username', '').strip()
+
+ if not new_username:
+ return jsonify({"error": "用户名不能为空"}), 400
+
+ old_username = session.get('admin_username')
+ if database.update_admin_username(old_username, new_username):
+ session['admin_username'] = new_username
+ return jsonify({"success": True})
+ return jsonify({"error": "修改失败,用户名可能已存在"}), 400
+
+
+
def update_admin_username():
"""修改管理员用户名"""
data = request.json
@@ -919,15 +1042,122 @@ def load_user_accounts(user_id):
user_accounts[user_id][account.id] = account
+# ==================== Bug反馈API(用户端) ====================
+
+@app.route('/api/feedback', methods=['POST'])
+@login_required
+def submit_feedback():
+ """用户提交Bug反馈"""
+ data = request.get_json()
+ title = data.get('title', '').strip()
+ description = data.get('description', '').strip()
+ contact = data.get('contact', '').strip()
+
+ if not title or not description:
+ return jsonify({"error": "标题和描述不能为空"}), 400
+
+ if len(title) > 100:
+ return jsonify({"error": "标题不能超过100个字符"}), 400
+
+ if len(description) > 2000:
+ return jsonify({"error": "描述不能超过2000个字符"}), 400
+
+ # 从数据库获取用户名
+ user_info = database.get_user_by_id(current_user.id)
+ username = user_info['username'] if user_info else f'用户{current_user.id}'
+
+ feedback_id = database.create_bug_feedback(
+ user_id=current_user.id,
+ username=username,
+ title=title,
+ description=description,
+ contact=contact
+ )
+
+ return jsonify({"message": "反馈提交成功", "id": feedback_id})
+
+
+@app.route('/api/feedback', methods=['GET'])
+@login_required
+def get_my_feedbacks():
+ """获取当前用户的反馈列表"""
+ feedbacks = database.get_user_feedbacks(current_user.id)
+ return jsonify(feedbacks)
+
+
+# ==================== Bug反馈API(管理端) ====================
+
+@app.route('/yuyx/api/feedbacks', methods=['GET'])
+@admin_required
+def get_all_feedbacks():
+ """管理员获取所有反馈"""
+ status = request.args.get('status')
+ limit = int(request.args.get('limit', 100))
+ offset = int(request.args.get('offset', 0))
+
+ feedbacks = database.get_bug_feedbacks(limit=limit, offset=offset, status_filter=status)
+ stats = database.get_feedback_stats()
+
+ return jsonify({
+ "feedbacks": feedbacks,
+ "stats": stats
+ })
+
+
+@app.route('/yuyx/api/feedbacks//reply', methods=['POST'])
+@admin_required
+def reply_to_feedback(feedback_id):
+ """管理员回复反馈"""
+ data = request.get_json()
+ reply = data.get('reply', '').strip()
+
+ if not reply:
+ return jsonify({"error": "回复内容不能为空"}), 400
+
+ if database.reply_feedback(feedback_id, reply):
+ return jsonify({"message": "回复成功"})
+ else:
+ return jsonify({"error": "反馈不存在"}), 404
+
+
+@app.route('/yuyx/api/feedbacks//close', methods=['POST'])
+@admin_required
+def close_feedback_api(feedback_id):
+ """管理员关闭反馈"""
+ if database.close_feedback(feedback_id):
+ return jsonify({"message": "已关闭"})
+ else:
+ return jsonify({"error": "反馈不存在"}), 404
+
+
+@app.route('/yuyx/api/feedbacks/', methods=['DELETE'])
+@admin_required
+def delete_feedback_api(feedback_id):
+ """管理员删除反馈"""
+ if database.delete_feedback(feedback_id):
+ return jsonify({"message": "已删除"})
+ else:
+ return jsonify({"error": "反馈不存在"}), 404
+
+
+# ==================== 账号管理API ====================
+
@app.route('/api/accounts', methods=['GET'])
@login_required
def get_accounts():
"""获取当前用户的所有账号"""
user_id = current_user.id
- if user_id not in user_accounts:
+
+ # 检查是否需要强制刷新(容器重启后内存数据丢失)
+ refresh = request.args.get('refresh', 'false').lower() == 'true'
+
+ # 如果user_accounts中没有数据或者请求刷新,则从数据库加载
+ if user_id not in user_accounts or len(user_accounts.get(user_id, {})) == 0 or refresh:
+ logger.debug(f"[API] 用户 {user_id} 请求账号列表,从数据库加载(refresh={refresh})")
load_user_accounts(user_id)
-
+
accounts = user_accounts.get(user_id, {})
+ logger.debug(f"[API] 返回用户 {user_id} 的 {len(accounts)} 个账号")
return jsonify([acc.to_dict() for acc in accounts.values()])
@@ -937,15 +1167,15 @@ def add_account():
"""添加账号"""
user_id = current_user.id
- # VIP账号数量限制检查
- if not database.is_user_vip(user_id):
- current_count = len(database.get_user_accounts(user_id))
- if current_count >= 1:
- return jsonify({"error": "非VIP用户只能添加1个账号,请联系管理员开通VIP"}), 403
+ # 账号数量限制检查:VIP不限制,普通用户最多3个
+ current_count = len(database.get_user_accounts(user_id))
+ is_vip = database.is_user_vip(user_id)
+ if not is_vip and current_count >= 3:
+ return jsonify({"error": "普通用户最多添加3个账号,升级VIP可无限添加"}), 403
data = request.json
username = data.get('username', '').strip()
password = data.get('password', '').strip()
- remember = data.get('remember', True)
+ remark = data.get("remark", "").strip()[:200] # 限制200字符
if not username or not password:
return jsonify({"error": "用户名和密码不能为空"}), 400
@@ -959,12 +1189,15 @@ def add_account():
# 生成账号ID
import uuid
account_id = str(uuid.uuid4())[:8]
+
+ # 设置remember默认值为True
+ remember = data.get('remember', True)
# 保存到数据库
- database.create_account(user_id, account_id, username, password, remember, '')
+ database.create_account(user_id, account_id, username, password, remember, remark)
# 加载到内存
- account = Account(account_id, user_id, username, password, remember, '')
+ account = Account(account_id, user_id, username, password, remember, remark)
if user_id not in user_accounts:
user_accounts[user_id] = {}
user_accounts[user_id][account_id] = account
@@ -973,6 +1206,51 @@ def add_account():
return jsonify(account.to_dict())
+@app.route('/api/accounts/', methods=['PUT'])
+@login_required
+def update_account(account_id):
+ """更新账号信息(密码等)"""
+ user_id = current_user.id
+
+ # 验证账号所有权
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ # 如果账号正在运行,不允许修改
+ if account.is_running:
+ return jsonify({"error": "账号正在运行中,请先停止"}), 400
+
+ data = request.json
+ new_password = data.get('password', '').strip()
+ new_remember = data.get('remember', account.remember)
+
+ if not new_password:
+ return jsonify({"error": "密码不能为空"}), 400
+
+ # 更新数据库
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ UPDATE accounts
+ SET password = ?, remember = ?
+ WHERE id = ?
+ ''', (new_password, new_remember, account_id))
+ conn.commit()
+
+ # 重置账号登录状态(密码修改后恢复active状态)
+ database.reset_account_login_status(account_id)
+ logger.info(f"[账号更新] 用户 {user_id} 修改了账号 {account.username} 的密码,已重置登录状态")
+
+ # 更新内存中的账号信息
+ account.password = new_password
+ account.remember = new_remember
+
+ log_to_client(f"账号 {account.username} 信息已更新,登录状态已重置", user_id)
+ return jsonify({"message": "账号更新成功", "account": account.to_dict()})
+
+
@app.route('/api/accounts/', methods=['DELETE'])
@login_required
def delete_account(account_id):
@@ -1053,7 +1331,7 @@ def start_account(account_id):
thread = threading.Thread(
target=run_task,
- args=(user_id, account_id, browse_type, enable_screenshot),
+ args=(user_id, account_id, browse_type, enable_screenshot, 'manual'),
daemon=True
)
thread.start()
@@ -1095,26 +1373,84 @@ def get_user_semaphore(user_id):
return user_semaphores[user_id]
-def run_task(user_id, account_id, browse_type, enable_screenshot=True):
+def run_task(user_id, account_id, browse_type, enable_screenshot=True, source="manual"):
"""运行自动化任务"""
+ print(f"[DEBUG run_task] account={account_id}, enable_screenshot={enable_screenshot} (类型:{type(enable_screenshot).__name__}), source={source}")
+
if user_id not in user_accounts or account_id not in user_accounts[user_id]:
return
account = user_accounts[user_id][account_id]
- # 记录任务开始时间
+ # 导入time模块
import time as time_module
- task_start_time = time_module.time()
+ # 注意:不在此处记录开始时间,因为要排除排队等待时间
- # 两级并发控制:用户级 + 全局级
+ # 两级并发控制:用户级 + 全局级(VIP优先)
user_sem = get_user_semaphore(user_id)
+ # 检查是否VIP用户
+ is_vip_user = database.is_user_vip(user_id)
+ vip_label = " [VIP优先]" if is_vip_user else ""
+
# 获取用户级信号量(同一用户的账号排队)
- log_to_client(f"等待资源分配...", user_id, account_id)
- account.status = "排队中"
+ log_to_client(f"等待资源分配...{vip_label}", user_id, account_id)
+ account.status = "排队中" + (" (VIP)" if is_vip_user else "")
socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
- user_sem.acquire()
+ # 记录任务状态为排队中
+ import time as time_mod
+ task_status[account_id] = {
+ "user_id": user_id,
+ "username": account.username,
+ "status": "排队中",
+ "detail_status": "等待资源" + vip_label,
+ "browse_type": browse_type,
+ "start_time": time_mod.time(),
+ "source": source,
+ "progress": {"items": 0, "attachments": 0},
+ "is_vip": is_vip_user
+ }
+
+ # 加入优先级队列
+ with task_queue_lock:
+ if is_vip_user:
+ vip_task_queue.append(account_id)
+ else:
+ normal_task_queue.append(account_id)
+
+ # VIP优先排队机制
+ acquired = False
+ while not acquired:
+ with task_queue_lock:
+ # VIP用户直接尝试获取; 普通用户需等VIP队列为空
+ can_try = is_vip_user or len(vip_task_queue) == 0
+
+ if can_try and user_sem.acquire(blocking=False):
+ acquired = True
+ with task_queue_lock:
+ if account_id in vip_task_queue:
+ vip_task_queue.remove(account_id)
+ if account_id in normal_task_queue:
+ normal_task_queue.remove(account_id)
+ break
+
+ # 检查是否被停止
+ if account.should_stop:
+ with task_queue_lock:
+ if account_id in vip_task_queue:
+ vip_task_queue.remove(account_id)
+ if account_id in normal_task_queue:
+ normal_task_queue.remove(account_id)
+ log_to_client(f"任务已取消", user_id, account_id)
+ account.status = "已停止"
+ account.is_running = False
+ if account_id in task_status:
+ del task_status[account_id]
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ return
+
+ time_module.sleep(0.3)
try:
# 如果在排队期间被停止,直接返回
@@ -1137,10 +1473,28 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True):
socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
return
+ # ====== 创建任务断点 ======
+ task_id = checkpoint_mgr.create_checkpoint(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type
+ )
+ logger.info(f"[断点] 任务 {task_id} 已创建")
+
+ # ====== 在此处记录任务真正的开始时间(排除排队等待时间) ======
+ task_start_time = time_module.time()
+
account.status = "运行中"
socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
account.last_browse_type = browse_type
+ # 更新任务状态为运行中
+ if account_id in task_status:
+ task_status[account_id]["status"] = "运行中"
+ task_status[account_id]["detail_status"] = "初始化"
+ task_status[account_id]["start_time"] = task_start_time
+
# 重试机制:最多尝试3次,超时则换IP重试
max_attempts = 3
last_error = None
@@ -1167,17 +1521,61 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True):
else:
log_to_client(f"⚠ 代理已启用但未配置API地址", user_id, account_id)
- log_to_client(f"创建自动化实例...", user_id, account_id)
- account.automation = PlaywrightAutomation(browser_manager, account_id, proxy_config=proxy_config)
-
- # 为automation注入包含user_id的自定义log方法,使其能够实时发送日志到WebSocket
+ # 使用 API 方式浏览(不启动浏览器,节省内存)
+ checkpoint_mgr.update_stage(task_id, TaskStage.STARTING, progress_percent=10)
+
def custom_log(message: str):
log_to_client(message, user_id, account_id)
- account.automation.log = custom_log
log_to_client(f"开始登录...", user_id, account_id)
- if not account.automation.login(account.username, account.password, account.remember):
- log_to_client(f"❌ 登录失败,请检查用户名和密码", user_id, account_id)
+ if account_id in task_status:
+ task_status[account_id]["detail_status"] = "正在登录"
+ checkpoint_mgr.update_stage(task_id, TaskStage.LOGGING_IN, progress_percent=25)
+
+ # 使用 API 方式登录和浏览(不启动浏览器)
+ api_browser = APIBrowser(log_callback=custom_log, proxy_config=proxy_config)
+ if api_browser.login(account.username, account.password):
+ log_to_client(f"✓ 登录成功!", user_id, account_id)
+ # 登录成功,清除失败计数
+ # 保存cookies供截图使用
+ api_browser.save_cookies_for_playwright(account.username)
+ database.reset_account_login_status(account_id)
+
+ if account_id in task_status:
+ task_status[account_id]["detail_status"] = "正在浏览"
+ log_to_client(f"开始浏览 '{browse_type}' 内容...", user_id, account_id)
+
+ def should_stop():
+ return account.should_stop
+
+ checkpoint_mgr.update_stage(task_id, TaskStage.BROWSING, progress_percent=50)
+ result = api_browser.browse_content(
+ browse_type=browse_type,
+ should_stop_callback=should_stop
+ )
+ # 转换结果类型以兼容后续代码
+ result = BrowseResult(
+ success=result.success,
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message=result.error_message
+ )
+ api_browser.close()
+ else:
+ # API 登录失败
+ error_message = "登录失败"
+ log_to_client(f"❌ {error_message}", user_id, account_id)
+
+ # 增加失败计数(假设密码错误)
+ is_suspended = database.increment_account_login_fail(account_id, error_message)
+ if is_suspended:
+ log_to_client(f"⚠ 该账号连续3次密码错误,已自动暂停", user_id, account_id)
+ log_to_client(f"请在前台修改密码后才能继续使用", user_id, account_id)
+
+ retry_action = checkpoint_mgr.record_error(task_id, error_message)
+ if retry_action == "paused":
+ logger.warning(f"[断点] 任务 {task_id} 已暂停(登录失败)")
+
account.status = "登录失败"
account.is_running = False
# 记录登录失败日志
@@ -1189,44 +1587,40 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True):
status='failed',
total_items=0,
total_attachments=0,
- error_message='登录失败,请检查用户名和密码',
- duration=int(time_module.time() - task_start_time)
+ error_message=error_message,
+ duration=int(time_module.time() - task_start_time),
+ source=source
)
socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ api_browser.close()
return
- log_to_client(f"✓ 登录成功!", user_id, account_id)
- log_to_client(f"开始浏览 '{browse_type}' 内容...", user_id, account_id)
-
- def should_stop():
- return account.should_stop
-
- result = account.automation.browse_content(
- browse_type=browse_type,
- auto_next_page=True,
- auto_view_attachments=True,
- interval=2.0,
- should_stop_callback=should_stop
- )
-
account.total_items = result.total_items
account.total_attachments = result.total_attachments
if result.success:
log_to_client(f"浏览完成! 共 {result.total_items} 条内容,{result.total_attachments} 个附件", user_id, account_id)
+ if account_id in task_status:
+ task_status[account_id]["detail_status"] = "浏览完成"
+ task_status[account_id]["progress"] = {"items": result.total_items, "attachments": result.total_attachments}
account.status = "已完成"
- # 记录成功日志
- database.create_task_log(
- user_id=user_id,
- account_id=account_id,
- username=account.username,
- browse_type=browse_type,
- status='success',
- total_items=result.total_items,
- total_attachments=result.total_attachments,
- error_message='',
- duration=int(time_module.time() - task_start_time)
- )
+ checkpoint_mgr.update_stage(task_id, TaskStage.COMPLETING, progress_percent=95)
+ checkpoint_mgr.complete_task(task_id, success=True)
+ logger.info(f"[断点] 任务 {task_id} 已完成")
+ # 记录成功日志(如果不截图则在此记录,截图时在截图完成后记录)
+ if not enable_screenshot:
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='success',
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message='',
+ duration=int(time_module.time() - task_start_time),
+ source=source
+ )
# 成功则跳出重试循环
break
else:
@@ -1241,8 +1635,8 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True):
try:
account.automation.close()
log_to_client(f"已关闭超时的浏览器实例", user_id, account_id)
- except:
- pass
+ except Exception as e:
+ logger.debug(f"关闭超时浏览器实例失败: {e}")
account.automation = None
if attempt < max_attempts:
@@ -1277,7 +1671,8 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True):
total_items=result.total_items,
total_attachments=result.total_attachments,
error_message=error_msg,
- duration=int(time_module.time() - task_start_time)
+ duration=int(time_module.time() - task_start_time),
+ source=source
)
break
@@ -1290,8 +1685,8 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True):
if account.automation:
try:
account.automation.close()
- except:
- pass
+ except Exception as e:
+ logger.debug(f"关闭浏览器实例失败: {e}")
account.automation = None
if 'Timeout' in error_msg or 'timeout' in error_msg:
@@ -1312,7 +1707,8 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True):
total_items=account.total_items,
total_attachments=account.total_attachments,
error_message=f"重试{max_attempts}次后仍失败: {error_msg}",
- duration=int(time_module.time() - task_start_time)
+ duration=int(time_module.time() - task_start_time),
+ source=source
)
break
else:
@@ -1328,7 +1724,8 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True):
total_items=account.total_items,
total_attachments=account.total_attachments,
error_message=error_msg,
- duration=int(time_module.time() - task_start_time)
+ duration=int(time_module.time() - task_start_time),
+ source=source
)
break
@@ -1347,26 +1744,33 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True):
total_items=account.total_items,
total_attachments=account.total_attachments,
error_message=error_msg,
- duration=int(time_module.time() - task_start_time)
+ duration=int(time_module.time() - task_start_time),
+ source=source
)
finally:
- # 释放全局信号量
- global_semaphore.release()
-
+ # 先关闭浏览器,再释放信号量(避免并发创建/关闭浏览器导致资源竞争)
account.is_running = False
+ # 如果状态不是已完成(需要截图),则重置为未开始
+ if account.status not in ["已完成"]:
+ account.status = "未开始"
if account.automation:
try:
account.automation.close()
- log_to_client(f"主任务浏览器已关闭", user_id, account_id)
+ # log_to_client(f"主任务浏览器已关闭", user_id, account_id) # 精简
except Exception as e:
log_to_client(f"关闭主任务浏览器时出错: {str(e)}", user_id, account_id)
finally:
account.automation = None
+ # 浏览器关闭后再释放全局信号量,确保新任务创建浏览器时旧浏览器已完全关闭
+ global_semaphore.release()
+
if account_id in active_tasks:
del active_tasks[account_id]
+ if account_id in task_status:
+ del task_status[account_id]
socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
@@ -1375,107 +1779,211 @@ def run_task(user_id, account_id, browse_type, enable_screenshot=True):
if account.status == "已完成" and not account.should_stop:
if enable_screenshot:
log_to_client(f"等待2秒后开始截图...", user_id, account_id)
+ # 更新账号状态为等待截图
+ account.status = "等待截图"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ # 重新添加截图状态
+ import time as time_mod
+ task_status[account_id] = {
+ "user_id": user_id,
+ "username": account.username,
+ "status": "排队中",
+ "detail_status": "等待截图资源",
+ "browse_type": browse_type,
+ "start_time": time_mod.time(),
+ "source": source,
+ "progress": {"items": result.total_items if result else 0, "attachments": result.total_attachments if result else 0}
+ }
time.sleep(2) # 延迟启动截图,确保主任务资源已完全释放
- threading.Thread(target=take_screenshot_for_account, args=(user_id, account_id), daemon=True).start()
+ browse_result_dict = {'total_items': result.total_items, 'total_attachments': result.total_attachments}
+ threading.Thread(target=take_screenshot_for_account, args=(user_id, account_id, browse_type, source, task_start_time, browse_result_dict), daemon=True).start()
else:
+ # 不截图时,重置状态为未开始
+ account.status = "未开始"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
log_to_client(f"截图功能已禁用,跳过截图", user_id, account_id)
+ else:
+ # 任务非正常完成,重置状态为未开始
+ if account.status not in ["登录失败", "出错"]:
+ account.status = "未开始"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
finally:
# 释放用户级信号量
user_sem.release()
-def take_screenshot_for_account(user_id, account_id):
- """为账号任务完成后截图(带并发控制,避免资源竞争)"""
+def take_screenshot_for_account(user_id, account_id, browse_type="应读", source="manual", task_start_time=None, browse_result=None):
+ """为账号任务完成后截图(使用工作线程池,真正的浏览器复用)"""
if user_id not in user_accounts or account_id not in user_accounts[user_id]:
return
account = user_accounts[user_id][account_id]
- # 使用截图信号量,确保同时只有1个截图任务在执行
- log_to_client(f"等待截图资源分配...", user_id, account_id)
- screenshot_acquired = screenshot_semaphore.acquire(blocking=True, timeout=300) # 最多等待5分钟
+ # 标记账号正在截图(防止重复提交截图任务)
+ account.is_running = True
- if not screenshot_acquired:
- log_to_client(f"截图资源获取超时,跳过截图", user_id, account_id)
- return
-
- automation = None
- try:
- log_to_client(f"开始截图流程...", user_id, account_id)
-
- # 使用与浏览任务相同的代理配置
- proxy_config = account.proxy_config if hasattr(account, 'proxy_config') else None
- if proxy_config:
- log_to_client(f"截图将使用相同代理: {proxy_config.get('server', 'Unknown')}", user_id, account_id)
+ def screenshot_task(browser_instance, user_id, account_id, account, browse_type, source, task_start_time, browse_result):
+ """在worker线程中执行的截图任务"""
+ # ✅ 获得worker后,立即更新状态为"截图中"
+ if user_id in user_accounts and account_id in user_accounts[user_id]:
+ acc = user_accounts[user_id][account_id]
+ acc.status = "截图中"
+ if account_id in task_status:
+ task_status[account_id]["status"] = "运行中"
+ task_status[account_id]["detail_status"] = "正在截图"
+ socketio.emit('account_update', acc.to_dict(), room=f'user_{user_id}')
- automation = PlaywrightAutomation(browser_manager, account_id, proxy_config=proxy_config)
+ max_retries = 3
- # 为截图automation也注入自定义log方法
- def custom_log(message: str):
- log_to_client(message, user_id, account_id)
- automation.log = custom_log
-
- log_to_client(f"重新登录以进行截图...", user_id, account_id)
- if not automation.login(account.username, account.password, account.remember):
- log_to_client(f"截图登录失败", user_id, account_id)
- return
-
- browse_type = account.last_browse_type
- log_to_client(f"导航到 '{browse_type}' 页面...", user_id, account_id)
-
- # 不使用should_stop_callback,让页面加载完成显示"暂无记录"
- result = automation.browse_content(
- browse_type=browse_type,
- auto_next_page=False,
- auto_view_attachments=False,
- interval=0,
- should_stop_callback=None
- )
-
- if not result.success and result.error_message != "":
- log_to_client(f"导航失败: {result.error_message}", user_id, account_id)
-
- time.sleep(2)
-
- # 生成截图文件名(使用北京时间并简化格式)
- beijing_tz = pytz.timezone('Asia/Shanghai')
- now_beijing = datetime.now(beijing_tz)
- timestamp = now_beijing.strftime('%Y%m%d_%H%M%S')
-
- # 简化文件名:用户名_登录账号_浏览类型_时间.jpg
- # 获取用户名前缀
- user_info = database.get_user_by_id(user_id)
- username_prefix = user_info['username'] if user_info else f"user{user_id}"
- # 使用登录账号(account.username)而不是备注
- login_account = account.username
- screenshot_filename = f"{username_prefix}_{login_account}_{browse_type}_{timestamp}.jpg"
- screenshot_path = os.path.join(SCREENSHOTS_DIR, screenshot_filename)
-
- if automation.take_screenshot(screenshot_path):
- log_to_client(f"✓ 截图已保存: {screenshot_filename}", user_id, account_id)
- else:
- log_to_client(f"✗ 截图失败", user_id, account_id)
-
- except Exception as e:
- log_to_client(f"✗ 截图过程中出错: {str(e)}", user_id, account_id)
-
- finally:
- # 确保浏览器资源被正确关闭
- if automation:
+ for attempt in range(1, max_retries + 1):
+ automation = None
try:
- automation.close()
- log_to_client(f"截图浏览器已关闭", user_id, account_id)
+ # 更新状态
+ if account_id in task_status:
+ task_status[account_id]["detail_status"] = f"正在截图{f' (第{attempt}次)' if attempt > 1 else ''}"
+
+ if attempt > 1:
+ log_to_client(f"🔄 第 {attempt} 次截图尝试...", user_id, account_id)
+
+ log_to_client(f"使用Worker-{browser_instance['worker_id']}的浏览器(已使用{browser_instance['use_count']}次)", user_id, account_id)
+
+ # 使用worker的浏览器创建PlaywrightAutomation
+ proxy_config = account.proxy_config if hasattr(account, 'proxy_config') else None
+ automation = PlaywrightAutomation(browser_manager, account_id, proxy_config=proxy_config)
+ automation.playwright = browser_instance['playwright']
+ automation.browser = browser_instance['browser']
+
+ def custom_log(message: str):
+ log_to_client(message, user_id, account_id)
+ automation.log = custom_log
+
+ # 登录
+ log_to_client(f"登录中...", user_id, account_id)
+ login_result = automation.quick_login(account.username, account.password, account.remember)
+ if not login_result["success"]:
+ error_message = login_result.get("message", "截图登录失败")
+ log_to_client(f"截图登录失败: {error_message}", user_id, account_id)
+ if attempt < max_retries:
+ log_to_client(f"将重试...", user_id, account_id)
+ time.sleep(2)
+ continue
+ else:
+ log_to_client(f"❌ 截图失败: 登录失败", user_id, account_id)
+ return {'success': False, 'error': '登录失败'}
+
+ browse_type = account.last_browse_type
+ log_to_client(f"导航到 '{browse_type}' 页面...", user_id, account_id)
+
+ # 导航到指定页面
+ result = automation.browse_content(
+ navigate_only=True,
+ browse_type=browse_type,
+ auto_next_page=False,
+ auto_view_attachments=False,
+ interval=0,
+ should_stop_callback=None
+ )
+
+ if not result.success and result.error_message:
+ log_to_client(f"导航警告: {result.error_message}", user_id, account_id)
+
+ # 等待页面稳定
+ time.sleep(2)
+
+ # 生成截图文件名
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ now_beijing = datetime.now(beijing_tz)
+ timestamp = now_beijing.strftime('%Y%m%d_%H%M%S')
+
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+ login_account = account.remark if account.remark else account.username
+ screenshot_filename = f"{username_prefix}_{login_account}_{browse_type}_{timestamp}.jpg"
+ screenshot_path = os.path.join(SCREENSHOTS_DIR, screenshot_filename)
+
+ # 尝试截图
+ if automation.take_screenshot(screenshot_path):
+ # 验证截图文件
+ if os.path.exists(screenshot_path) and os.path.getsize(screenshot_path) > 1000:
+ log_to_client(f"✓ 截图成功: {screenshot_filename}", user_id, account_id)
+ return {'success': True, 'filename': screenshot_filename}
+ else:
+ log_to_client(f"截图文件异常,将重试", user_id, account_id)
+ if os.path.exists(screenshot_path):
+ os.remove(screenshot_path)
+ else:
+ log_to_client(f"截图保存失败", user_id, account_id)
+
+ if attempt < max_retries:
+ log_to_client(f"将重试...", user_id, account_id)
+ time.sleep(2)
+
except Exception as e:
- log_to_client(f"关闭截图浏览器时出错: {str(e)}", user_id, account_id)
+ log_to_client(f"截图出错: {str(e)}", user_id, account_id)
+ if attempt < max_retries:
+ log_to_client(f"将重试...", user_id, account_id)
+ time.sleep(2)
- # 释放截图信号量
- screenshot_semaphore.release()
- log_to_client(f"截图资源已释放", user_id, account_id)
+ finally:
+ # 只关闭context,不关闭浏览器(由worker管理)
+ if automation:
+ try:
+ if automation.context:
+ automation.context.close()
+ automation.context = None
+ automation.page = None
+ except Exception as e:
+ logger.debug(f"关闭context时出错: {e}")
+ return {'success': False, 'error': '截图失败,已重试3次'}
-@app.route('/api/accounts//screenshot', methods=['POST'])
-@login_required
+ def screenshot_callback(result, error):
+ """截图完成回调"""
+ try:
+ # 重置账号状态
+ account.is_running = False
+ account.status = "未开始"
+
+ # 先清除任务状态(这样to_dict()不会包含detail_status)
+ if account_id in task_status:
+ del task_status[account_id]
+
+ # 然后发送更新
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ if error:
+ log_to_client(f"❌ 截图失败: {error}", user_id, account_id)
+ elif not result or not result.get('success'):
+ error_msg = result.get('error', '未知错误') if result else '未知错误'
+ log_to_client(f"❌ 截图失败: {error_msg}", user_id, account_id)
+
+ # 记录任务日志
+ if task_start_time and browse_result:
+ import time as time_module
+ total_elapsed = int(time_module.time() - task_start_time)
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='success',
+ total_items=browse_result.get('total_items', 0),
+ total_attachments=browse_result.get('total_attachments', 0),
+ duration=total_elapsed,
+ source=source
+ )
+
+ except Exception as e:
+ logger.error(f"截图回调出错: {e}")
+
+ # 提交任务到工作线程池
+ pool = get_browser_worker_pool()
+ pool.submit_task(
+ screenshot_task,
+ screenshot_callback,
+ user_id, account_id, account, browse_type, source, task_start_time, browse_result
+ )
def manual_screenshot(account_id):
"""手动为指定账号截图"""
user_id = current_user.id
@@ -1610,6 +2118,23 @@ def handle_connect():
join_room(f'user_{user_id}')
log_to_client("客户端已连接", user_id)
+ # 如果user_accounts中没有该用户的账号,从数据库加载
+ if user_id not in user_accounts or len(user_accounts[user_id]) == 0:
+ db_accounts = database.get_user_accounts(user_id)
+ if db_accounts:
+ user_accounts[user_id] = {}
+ for acc_data in db_accounts:
+ account = Account(
+ account_id=acc_data['id'],
+ user_id=acc_data['user_id'],
+ username=acc_data['username'],
+ password=acc_data['password'],
+ remember=bool(acc_data.get('remember', 1)),
+ remark=acc_data.get('remark', '')
+ )
+ user_accounts[user_id][acc_data['id']] = account
+ log_to_client(f"已从数据库恢复 {len(db_accounts)} 个账号", user_id)
+
# 发送账号列表
accounts = user_accounts.get(user_id, {})
emit('accounts_list', [acc.to_dict() for acc in accounts.values()])
@@ -1633,7 +2158,12 @@ def handle_disconnect():
@app.route('/static/')
def serve_static(filename):
"""提供静态文件访问"""
- return send_from_directory('static', filename)
+ response = send_from_directory('static', filename)
+ # 禁用缓存,强制浏览器每次都重新加载
+ response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
+ response.headers['Pragma'] = 'no-cache'
+ response.headers['Expires'] = '0'
+ return response
# ==================== 启动 ====================
@@ -1715,6 +2245,9 @@ def get_user_vip_info_api(user_id):
def get_current_user_vip():
"""获取当前用户VIP信息"""
vip_info = database.get_user_vip_info(current_user.id)
+ # 添加用户名
+ user_info = database.get_user_by_id(current_user.id)
+ vip_info['username'] = user_info['username'] if user_info else 'Unknown'
return jsonify(vip_info)
@@ -1764,15 +2297,20 @@ def update_system_config_api():
schedule_browse_type = data.get('schedule_browse_type')
schedule_weekdays = data.get('schedule_weekdays')
new_max_concurrent_per_account = data.get('max_concurrent_per_account')
+ new_max_screenshot_concurrent = data.get('max_screenshot_concurrent')
# 验证参数
if max_concurrent is not None:
- if not isinstance(max_concurrent, int) or max_concurrent < 1 or max_concurrent > 20:
- return jsonify({"error": "全局并发数必须在1-20之间"}), 400
+ if not isinstance(max_concurrent, int) or max_concurrent < 1:
+ return jsonify({"error": "全局并发数必须大于0(建议:小型服务器2-5,中型5-10,大型10-20)"}), 400
if new_max_concurrent_per_account is not None:
- if not isinstance(new_max_concurrent_per_account, int) or new_max_concurrent_per_account < 1 or new_max_concurrent_per_account > 5:
- return jsonify({"error": "单账号并发数必须在1-5之间"}), 400
+ if not isinstance(new_max_concurrent_per_account, int) or new_max_concurrent_per_account < 1:
+ return jsonify({"error": "单账号并发数必须大于0(建议设为1,避免同一用户任务相互影响)"}), 400
+
+ if new_max_screenshot_concurrent is not None:
+ if not isinstance(new_max_screenshot_concurrent, int) or new_max_screenshot_concurrent < 1:
+ return jsonify({"error": "截图并发数必须大于0(建议根据服务器配置设置,每个浏览器约占用200MB内存)"}), 400
if schedule_time is not None:
# 验证时间格式 HH:MM
@@ -1800,7 +2338,8 @@ def update_system_config_api():
schedule_time=schedule_time,
schedule_browse_type=schedule_browse_type,
schedule_weekdays=schedule_weekdays,
- max_concurrent_per_account=new_max_concurrent_per_account
+ max_concurrent_per_account=new_max_concurrent_per_account,
+ max_screenshot_concurrent=new_max_screenshot_concurrent
):
# 如果修改了并发数,更新全局变量和信号量
if max_concurrent is not None and max_concurrent != max_concurrent_global:
@@ -1812,12 +2351,35 @@ def update_system_config_api():
if new_max_concurrent_per_account is not None and new_max_concurrent_per_account != max_concurrent_per_account:
max_concurrent_per_account = new_max_concurrent_per_account
print(f"单用户并发数已更新为: {max_concurrent_per_account}")
+
+ # 如果修改了截图并发数,更新信号量
+ if new_max_screenshot_concurrent is not None:
+ global screenshot_semaphore
+ screenshot_semaphore = threading.Semaphore(new_max_screenshot_concurrent)
+ print(f"截图并发数已更新为: {new_max_screenshot_concurrent}")
return jsonify({"message": "系统配置已更新"})
return jsonify({"error": "更新失败"}), 400
+@app.route('/yuyx/api/schedule/execute', methods=['POST'])
+@admin_required
+def execute_schedule_now():
+ """立即执行定时任务(无视定时时间和星期限制)"""
+ try:
+ # 在新线程中执行任务,避免阻塞请求
+ # 传入 skip_weekday_check=True 跳过星期检查
+ thread = threading.Thread(target=run_scheduled_task, args=(True,), daemon=True)
+ thread.start()
+
+ logger.info("[立即执行定时任务] 管理员手动触发定时任务执行(跳过星期检查)")
+ return jsonify({"message": "定时任务已开始执行,请查看任务列表获取进度"})
+ except Exception as e:
+ logger.error(f"[立即执行定时任务] 启动失败: {str(e)}")
+ return jsonify({"error": f"启动失败: {str(e)}"}), 500
+
+
# ==================== 代理配置API ====================
@@ -1950,17 +2512,88 @@ def get_task_stats_api():
return jsonify(stats)
+
+
+@app.route('/yuyx/api/task/running', methods=['GET'])
+@admin_required
+def get_running_tasks_api():
+ """获取当前运行中和排队中的任务"""
+ import time as time_mod
+ current_time = time_mod.time()
+
+ running = []
+ queuing = []
+
+ for account_id, info in task_status.items():
+ elapsed = int(current_time - info.get("start_time", current_time))
+
+ # 获取用户名
+ user = database.get_user_by_id(info.get("user_id"))
+ user_username = user['username'] if user else 'N/A'
+
+ # 获取进度信息
+ progress = info.get("progress", {"items": 0, "attachments": 0})
+
+ task_info = {
+ "account_id": account_id,
+ "user_id": info.get("user_id"),
+ "user_username": user_username,
+ "username": info.get("username"),
+ "browse_type": info.get("browse_type"),
+ "source": info.get("source", "manual"),
+ "detail_status": info.get("detail_status", "未知"),
+ "progress_items": progress.get("items", 0),
+ "progress_attachments": progress.get("attachments", 0),
+ "elapsed_seconds": elapsed,
+ "elapsed_display": f"{elapsed // 60}分{elapsed % 60}秒" if elapsed >= 60 else f"{elapsed}秒"
+ }
+
+ if info.get("status") == "运行中":
+ running.append(task_info)
+ else:
+ queuing.append(task_info)
+
+ # 按开始时间排序
+ running.sort(key=lambda x: x["elapsed_seconds"], reverse=True)
+ queuing.sort(key=lambda x: x["elapsed_seconds"], reverse=True)
+
+ return jsonify({
+ "running": running,
+ "queuing": queuing,
+ "running_count": len(running),
+ "queuing_count": len(queuing),
+ "max_concurrent": max_concurrent_global
+ })
+
@app.route('/yuyx/api/task/logs', methods=['GET'])
@admin_required
def get_task_logs_api():
- """获取任务日志列表"""
- limit = int(request.args.get('limit', 100))
+ """获取任务日志列表(支持分页和多种筛选)"""
+ limit = int(request.args.get('limit', 20))
offset = int(request.args.get('offset', 0))
date_filter = request.args.get('date') # YYYY-MM-DD格式
status_filter = request.args.get('status') # success/failed
+ source_filter = request.args.get('source') # manual/scheduled/immediate/resumed
+ user_id_filter = request.args.get('user_id') # 用户ID
+ account_filter = request.args.get('account') # 账号关键字
- logs = database.get_task_logs(limit, offset, date_filter, status_filter)
- return jsonify(logs)
+ # 转换user_id为整数
+ if user_id_filter:
+ try:
+ user_id_filter = int(user_id_filter)
+ except ValueError:
+ user_id_filter = None
+
+ result = database.get_task_logs(
+ limit=limit,
+ offset=offset,
+ date_filter=date_filter,
+ status_filter=status_filter,
+ source_filter=source_filter,
+ user_id_filter=user_id_filter,
+ account_filter=account_filter
+ )
+ return jsonify(result)
@app.route('/yuyx/api/task/logs/clear', methods=['POST'])
@@ -1977,101 +2610,200 @@ def clear_old_task_logs_api():
return jsonify({"message": f"已删除{days}天前的{deleted_count}条日志"})
+@app.route('/yuyx/api/docker/restart', methods=['POST'])
+@admin_required
+def restart_docker_container():
+ """重启Docker容器"""
+ import subprocess
+ import os
+
+ try:
+ # 检查是否在Docker容器中运行
+ if not os.path.exists('/.dockerenv'):
+ return jsonify({"error": "当前不在Docker容器中运行"}), 400
+
+ # 记录日志
+ app_logger.info("[系统] 管理员触发Docker容器重启")
+
+ # 使用nohup在后台执行重启命令,避免阻塞
+ # 容器重启会导致当前进程终止,所以需要延迟执行
+ restart_script = """
+import os
+import time
+
+# 延迟3秒让响应返回给客户端
+time.sleep(3)
+
+# 退出Python进程,让Docker自动重启容器(restart: unless-stopped)
+os._exit(0)
+"""
+
+ # 写入临时脚本
+ with open('/tmp/restart_container.py', 'w') as f:
+ f.write(restart_script)
+
+ # 在后台执行重启脚本
+ subprocess.Popen(['python', '/tmp/restart_container.py'],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True)
+
+ return jsonify({
+ "success": True,
+ "message": "容器将在3秒后重启,请稍后刷新页面"
+ })
+
+ except Exception as e:
+ app_logger.error(f"[系统] Docker容器重启失败: {str(e)}")
+ return jsonify({"error": f"重启失败: {str(e)}"}), 500
+
+
# ==================== 定时任务调度器 ====================
-def scheduled_task_worker():
- """定时任务工作线程"""
- import schedule
- from datetime import datetime
+def run_scheduled_task(skip_weekday_check=False):
+ """执行所有账号的浏览任务(可被手动调用,过滤重复账号)
+
+ Args:
+ skip_weekday_check: 是否跳过星期检查(立即执行时为True)
+ """
+ try:
+ from datetime import datetime
+ import pytz
- def run_all_accounts_task():
- """执行所有账号的浏览任务(过滤重复账号)"""
- try:
- config = database.get_system_config()
- browse_type = config.get('schedule_browse_type', '应读')
-
- # 检查今天是否在允许执行的星期列表中
- from datetime import datetime
- import pytz
-
+ config = database.get_system_config()
+ browse_type = config.get('schedule_browse_type', '应读')
+
+ # 检查今天是否在允许执行的星期列表中(立即执行时跳过此检查)
+ if not skip_weekday_check:
# 获取北京时间的星期几 (1=周一, 7=周日)
beijing_tz = pytz.timezone('Asia/Shanghai')
now_beijing = datetime.now(beijing_tz)
current_weekday = now_beijing.isoweekday() # 1-7
-
+
# 获取配置的星期列表
schedule_weekdays = config.get('schedule_weekdays', '1,2,3,4,5,6,7')
allowed_weekdays = [int(d.strip()) for d in schedule_weekdays.split(',') if d.strip()]
-
+
if current_weekday not in allowed_weekdays:
weekday_names = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
print(f"[定时任务] 今天是{weekday_names[current_weekday]},不在执行日期内,跳过执行")
return
+ else:
+ print(f"[立即执行] 跳过星期检查,强制执行任务")
- print(f"[定时任务] 开始执行 - 浏览类型: {browse_type}")
+ print(f"[定时任务] 开始执行 - 浏览类型: {browse_type}")
- # 获取所有已审核用户的所有账号
- all_users = database.get_all_users()
- approved_users = [u for u in all_users if u['status'] == 'approved']
+ # 获取所有已审核用户的所有账号
+ all_users = database.get_all_users()
+ approved_users = [u for u in all_users if u['status'] == 'approved']
- # 用于记录已执行的账号用户名,避免重复
- executed_usernames = set()
- total_accounts = 0
- skipped_duplicates = 0
- executed_accounts = 0
+ # 用于记录已执行的账号用户名,避免重复
+ executed_usernames = set()
+ total_accounts = 0
+ skipped_duplicates = 0
+ executed_accounts = 0
- for user in approved_users:
- user_id = user['id']
- if user_id not in user_accounts:
- load_user_accounts(user_id)
+ for user in approved_users:
+ user_id = user['id']
+ if user_id not in user_accounts:
+ load_user_accounts(user_id)
- accounts = user_accounts.get(user_id, {})
- for account_id, account in accounts.items():
- total_accounts += 1
+ accounts = user_accounts.get(user_id, {})
+ for account_id, account in accounts.items():
+ total_accounts += 1
- # 跳过正在运行的账号
- if account.is_running:
+ # 跳过正在运行的账号
+ if account.is_running:
+ continue
+
+ # 检查账号状态,跳过已暂停的账号
+ account_status_info = database.get_account_status(account_id)
+ if account_status_info:
+ status = account_status_info['status'] if 'status' in account_status_info.keys() else 'active'
+ if status == 'suspended':
+ fail_count = account_status_info['login_fail_count'] if 'login_fail_count' in account_status_info.keys() else 0
+ print(f"[定时任务] 跳过暂停账号: {account.username} (用户:{user['username']}) - 连续{fail_count}次密码错误,需修改密码")
continue
- # 检查账号用户名是否已经执行过(重复账号过滤)
- if account.username in executed_usernames:
- skipped_duplicates += 1
- print(f"[定时任务] 跳过重复账号: {account.username} (用户:{user['username']}) - 该账号已被其他用户执行")
- continue
+ # 检查账号用户名是否已经执行过(重复账号过滤)
+ if account.username in executed_usernames:
+ skipped_duplicates += 1
+ print(f"[定时任务] 跳过重复账号: {account.username} (用户:{user['username']}) - 该账号已被其他用户执行")
+ continue
- # 记录该账号用户名,避免后续重复执行
- executed_usernames.add(account.username)
+ # 记录该账号用户名,避免后续重复执行
+ executed_usernames.add(account.username)
- print(f"[定时任务] 启动账号: {account.username} (用户:{user['username']})")
+ print(f"[定时任务] 启动账号: {account.username} (用户:{user['username']})")
- # 启动任务
- account.is_running = True
- account.should_stop = False
- account.status = "运行中"
+ # 启动任务
+ account.is_running = True
+ account.should_stop = False
+ account.status = "运行中"
- # 获取系统配置的截图开关
- config = database.get_system_config()
- enable_screenshot_scheduled = config.get("enable_screenshot", 0) == 1
-
- thread = threading.Thread(
- target=run_task,
- args=(user_id, account_id, browse_type, enable_screenshot_scheduled),
- daemon=True
- )
- thread.start()
- active_tasks[account_id] = thread
- executed_accounts += 1
+ # 获取系统配置的截图开关
+ cfg = database.get_system_config()
+ enable_screenshot_scheduled = cfg.get("enable_screenshot", 0) == 1
- # 发送更新到用户
- socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot_scheduled, 'scheduled'),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+ executed_accounts += 1
- # 间隔启动,避免瞬间并发过高
- time.sleep(2)
+ # 发送更新到用户
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
- print(f"[定时任务] 执行完成 - 总账号数:{total_accounts}, 已执行:{executed_accounts}, 跳过重复:{skipped_duplicates}")
+ # 间隔启动,避免瞬间并发过高
+ time.sleep(2)
+ print(f"[定时任务] 执行完成 - 总账号数:{total_accounts}, 已执行:{executed_accounts}, 跳过重复:{skipped_duplicates}")
+
+ except Exception as e:
+ print(f"[定时任务] 执行出错: {str(e)}")
+ logger.error(f"[定时任务] 执行异常: {str(e)}")
+
+
+def status_push_worker():
+ """后台线程:每秒推送运行中任务的状态更新"""
+ while True:
+ try:
+ # 遍历所有运行中的任务状态
+ for account_id, status_info in list(task_status.items()):
+ user_id = status_info.get('user_id')
+ if user_id:
+ # 获取账号对象
+ if user_id in user_accounts and account_id in user_accounts[user_id]:
+ account = user_accounts[user_id][account_id]
+ account_data = account.to_dict()
+ # 推送账号状态更新
+ socketio.emit('account_update', account_data, room=f'user_{user_id}')
+ # 同时推送详细进度事件(方便前端分别处理)
+ progress = status_info.get('progress', {})
+ progress_data = {
+ 'account_id': account_id,
+ 'stage': status_info.get('detail_status', ''),
+ 'total_items': account.total_items,
+ 'browsed_items': progress.get('items', 0),
+ 'total_attachments': account.total_attachments,
+ 'viewed_attachments': progress.get('attachments', 0),
+ 'start_time': status_info.get('start_time', 0),
+ 'elapsed_seconds': account_data.get('elapsed_seconds', 0),
+ 'elapsed_display': account_data.get('elapsed_display', '')
+ }
+ socketio.emit('task_progress', progress_data, room=f'user_{user_id}')
+ time.sleep(1) # 每秒推送一次
except Exception as e:
- print(f"[定时任务] 执行出错: {str(e)}")
+ logger.debug(f"状态推送出错: {e}")
+ time.sleep(1)
+
+
+def scheduled_task_worker():
+ """定时任务工作线程"""
+ import schedule
def cleanup_expired_captcha():
"""清理过期验证码,防止内存泄漏"""
@@ -2120,6 +2852,112 @@ def scheduled_task_worker():
except Exception as e:
print(f"[定时清理] 清理任务出错: {str(e)}")
+
+ def check_user_schedules():
+ """检查并执行用户定时任务"""
+ import json
+ try:
+ from datetime import datetime
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ now = datetime.now(beijing_tz)
+ current_time = now.strftime('%H:%M')
+ current_weekday = now.isoweekday()
+
+ # 获取所有启用的用户定时任务
+ enabled_schedules = database.get_enabled_user_schedules()
+
+ for schedule_config in enabled_schedules:
+ # 检查时间是否匹配
+ if schedule_config['schedule_time'] != current_time:
+ continue
+
+ # 检查星期是否匹配
+ allowed_weekdays = [int(d) for d in schedule_config.get('weekdays', '1,2,3,4,5').split(',') if d.strip()]
+ if current_weekday not in allowed_weekdays:
+ continue
+
+ # 检查今天是否已经执行过
+ last_run = schedule_config.get('last_run_at')
+ if last_run:
+ try:
+ last_run_date = datetime.strptime(last_run, '%Y-%m-%d %H:%M:%S').date()
+ if last_run_date == now.date():
+ continue # 今天已执行过
+ except:
+ pass
+
+ # 执行用户定时任务
+ user_id = schedule_config['user_id']
+ schedule_id = schedule_config['id']
+ browse_type = schedule_config.get('browse_type', '应读')
+ enable_screenshot = schedule_config.get('enable_screenshot', 1)
+
+ # 调试日志
+ print(f"[DEBUG] 定时任务 {schedule_config.get('name')}: enable_screenshot={enable_screenshot} (类型:{type(enable_screenshot).__name__})")
+
+ try:
+ account_ids = json.loads(schedule_config.get('account_ids', '[]') or '[]')
+ except:
+ account_ids = []
+
+ if not account_ids:
+ continue
+
+ print(f"[用户定时任务] 用户 {schedule_config.get('user_username', user_id)} 的任务 '{schedule_config.get('name', '')}' 开始执行")
+
+ # 创建执行日志
+ import time as time_mod
+ execution_start_time = time_mod.time()
+ log_id = database.create_schedule_execution_log(
+ schedule_id=schedule_id,
+ user_id=user_id,
+ schedule_name=schedule_config.get('name', '未命名任务')
+ )
+
+ started_count = 0
+ for account_id in account_ids:
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ continue
+ account = user_accounts[user_id][account_id]
+ if account.is_running:
+ continue
+
+ account.is_running = True
+ account.should_stop = False
+ account.status = "排队中"
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot, 'user_scheduled'),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+ started_count += 1
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ # 更新最后执行时间
+ database.update_schedule_last_run(schedule_id)
+
+ # 更新执行日志
+ execution_duration = int(time_mod.time() - execution_start_time)
+ database.update_schedule_execution_log(
+ log_id,
+ total_accounts=len(account_ids),
+ success_accounts=started_count,
+ failed_accounts=len(account_ids) - started_count,
+ duration_seconds=execution_duration,
+ status='completed'
+ )
+
+ print(f"[用户定时任务] 已启动 {started_count} 个账号")
+
+ except Exception as e:
+ print(f"[用户定时任务] 检查出错: {str(e)}")
+ import traceback
+ traceback.print_exc()
+
# 每分钟检查一次配置
def check_and_schedule():
config = database.get_system_config()
@@ -2157,7 +2995,7 @@ def scheduled_task_worker():
if config.get('schedule_enabled'):
schedule_time_cst = config.get('schedule_time', '02:00')
schedule_time_utc = cst_to_utc_time(schedule_time_cst)
- schedule.every().day.at(schedule_time_utc).do(run_all_accounts_task)
+ schedule.every().day.at(schedule_time_utc).do(run_scheduled_task)
print(f"[定时任务] 已设置浏览任务: 每天 CST {schedule_time_cst} (UTC {schedule_time_utc})")
# 初始检查
@@ -2172,6 +3010,7 @@ def scheduled_task_worker():
# 每60秒重新检查一次配置
if time.time() - last_check > 60:
check_and_schedule()
+ check_user_schedules() # 检查用户定时任务
last_check = time.time()
time.sleep(1)
@@ -2180,6 +3019,343 @@ def scheduled_task_worker():
time.sleep(5)
+
+# ========== 断点续传API ==========
+@app.route('/yuyx/api/checkpoint/paused')
+@admin_required
+def checkpoint_get_paused():
+ try:
+ user_id = request.args.get('user_id', type=int)
+ tasks = checkpoint_mgr.get_paused_tasks(user_id=user_id)
+ return jsonify({'success': True, 'tasks': tasks})
+ except Exception as e:
+ logger.error(f"获取暂停任务失败: {e}")
+ return jsonify({'success': False, 'message': str(e)}), 500
+
+@app.route('/yuyx/api/checkpoint//resume', methods=['POST'])
+@admin_required
+def checkpoint_resume(task_id):
+ try:
+ checkpoint = checkpoint_mgr.get_checkpoint(task_id)
+ if not checkpoint:
+ return jsonify({'success': False, 'message': '任务不存在'}), 404
+ if checkpoint['status'] != 'paused':
+ return jsonify({'success': False, 'message': '任务未暂停'}), 400
+ if checkpoint_mgr.resume_task(task_id):
+ import threading
+ threading.Thread(
+ target=run_task,
+ args=(checkpoint['user_id'], checkpoint['account_id'], checkpoint['browse_type'], True, 'resumed'),
+ daemon=True
+ ).start()
+ return jsonify({'success': True})
+ return jsonify({'success': False}), 500
+ except Exception as e:
+ logger.error(f"恢复任务失败: {e}")
+ return jsonify({'success': False, 'message': str(e)}), 500
+
+@app.route('/yuyx/api/checkpoint//abandon', methods=['POST'])
+@admin_required
+def checkpoint_abandon(task_id):
+ try:
+ if checkpoint_mgr.abandon_task(task_id):
+ return jsonify({'success': True})
+ return jsonify({'success': False}), 404
+ except Exception as e:
+ return jsonify({'success': False, 'message': str(e)}), 500
+
+# 初始化浏览器池(在后台线程中预热,不阻塞启动)
+# ==================== 用户定时任务API ====================
+
+@app.route('/api/schedules', methods=['GET'])
+@login_required
+def get_user_schedules_api():
+ """获取当前用户的所有定时任务"""
+ schedules = database.get_user_schedules(current_user.id)
+ import json
+ for s in schedules:
+ try:
+ s['account_ids'] = json.loads(s.get('account_ids', '[]') or '[]')
+ except:
+ s['account_ids'] = []
+ return jsonify(schedules)
+
+
+@app.route('/api/schedules', methods=['POST'])
+@login_required
+def create_user_schedule_api():
+ """创建用户定时任务"""
+ data = request.json
+
+ name = data.get('name', '我的定时任务')
+ schedule_time = data.get('schedule_time', '08:00')
+ weekdays = data.get('weekdays', '1,2,3,4,5')
+ browse_type = data.get('browse_type', '应读')
+ enable_screenshot = data.get('enable_screenshot', 1)
+ account_ids = data.get('account_ids', [])
+
+ import re
+ if not re.match(r'^\d{2}:\d{2}$', schedule_time):
+ return jsonify({"error": "时间格式不正确,应为 HH:MM"}), 400
+
+ schedule_id = database.create_user_schedule(
+ user_id=current_user.id,
+ name=name,
+ schedule_time=schedule_time,
+ weekdays=weekdays,
+ browse_type=browse_type,
+ enable_screenshot=enable_screenshot,
+ account_ids=account_ids
+ )
+
+ if schedule_id:
+ return jsonify({"success": True, "id": schedule_id})
+ return jsonify({"error": "创建失败"}), 500
+
+
+@app.route('/api/schedules/', methods=['GET'])
+@login_required
+def get_schedule_detail_api(schedule_id):
+ """获取定时任务详情"""
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ import json
+ try:
+ schedule['account_ids'] = json.loads(schedule.get('account_ids', '[]') or '[]')
+ except:
+ schedule['account_ids'] = []
+ return jsonify(schedule)
+
+
+@app.route('/api/schedules/', methods=['PUT'])
+@login_required
+def update_schedule_api(schedule_id):
+ """更新定时任务"""
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ data = request.json
+ allowed_fields = ['name', 'schedule_time', 'weekdays', 'browse_type',
+ 'enable_screenshot', 'account_ids', 'enabled']
+
+ update_data = {k: v for k, v in data.items() if k in allowed_fields}
+
+ if 'schedule_time' in update_data:
+ import re
+ if not re.match(r'^\d{2}:\d{2}$', update_data['schedule_time']):
+ return jsonify({"error": "时间格式不正确"}), 400
+
+ success = database.update_user_schedule(schedule_id, **update_data)
+ if success:
+ return jsonify({"success": True})
+ return jsonify({"error": "更新失败"}), 500
+
+
+@app.route('/api/schedules/', methods=['DELETE'])
+@login_required
+def delete_schedule_api(schedule_id):
+ """删除定时任务"""
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ success = database.delete_user_schedule(schedule_id)
+ if success:
+ return jsonify({"success": True})
+ return jsonify({"error": "删除失败"}), 500
+
+
+@app.route('/api/schedules//toggle', methods=['POST'])
+@login_required
+def toggle_schedule_api(schedule_id):
+ """启用/禁用定时任务"""
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ data = request.json
+ enabled = data.get('enabled', not schedule['enabled'])
+
+ success = database.toggle_user_schedule(schedule_id, enabled)
+ if success:
+ return jsonify({"success": True, "enabled": enabled})
+ return jsonify({"error": "操作失败"}), 500
+
+
+@app.route('/api/schedules//run', methods=['POST'])
+@login_required
+def run_schedule_now_api(schedule_id):
+ """立即执行定时任务"""
+ import json
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ try:
+ account_ids = json.loads(schedule.get('account_ids', '[]') or '[]')
+ except:
+ account_ids = []
+
+ if not account_ids:
+ return jsonify({"error": "没有配置账号"}), 400
+
+ user_id = current_user.id
+ browse_type = schedule['browse_type']
+ enable_screenshot = schedule['enable_screenshot']
+
+ started = []
+ for account_id in account_ids:
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ continue
+ account = user_accounts[user_id][account_id]
+ if account.is_running:
+ continue
+
+ account.is_running = True
+ account.should_stop = False
+ account.status = "排队中"
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot, 'user_scheduled'),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+ started.append(account_id)
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ database.update_schedule_last_run(schedule_id)
+
+ return jsonify({
+ "success": True,
+ "started_count": len(started),
+ "message": f"已启动 {len(started)} 个账号"
+ })
+
+
+
+
+# ==================== 定时任务执行日志API ====================
+
+@app.route('/api/schedules//logs', methods=['GET'])
+@login_required
+def get_schedule_logs_api(schedule_id):
+ """获取定时任务执行日志"""
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ limit = request.args.get('limit', 10, type=int)
+ logs = database.get_schedule_execution_logs(schedule_id, limit)
+ return jsonify(logs)
+
+
+# ==================== 批量操作API ====================
+
+@app.route('/api/accounts/batch/start', methods=['POST'])
+@login_required
+def batch_start_accounts():
+ """批量启动账号"""
+ user_id = current_user.id
+ data = request.json
+
+ account_ids = data.get('account_ids', [])
+ browse_type = data.get('browse_type', '应读')
+ enable_screenshot = data.get('enable_screenshot', True)
+
+ if not account_ids:
+ return jsonify({"error": "请选择要启动的账号"}), 400
+
+ started = []
+ failed = []
+
+ for account_id in account_ids:
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ failed.append({'id': account_id, 'reason': '账号不存在'})
+ continue
+
+ account = user_accounts[user_id][account_id]
+
+ if account.is_running:
+ failed.append({'id': account_id, 'reason': '已在运行中'})
+ continue
+
+ account.is_running = True
+ account.should_stop = False
+ account.status = "排队中"
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot, 'batch'),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+ started.append(account_id)
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ return jsonify({
+ "success": True,
+ "started_count": len(started),
+ "failed_count": len(failed),
+ "started": started,
+ "failed": failed
+ })
+
+
+@app.route('/api/accounts/batch/stop', methods=['POST'])
+@login_required
+def batch_stop_accounts():
+ """批量停止账号"""
+ user_id = current_user.id
+ data = request.json
+
+ account_ids = data.get('account_ids', [])
+
+ if not account_ids:
+ return jsonify({"error": "请选择要停止的账号"}), 400
+
+ stopped = []
+
+ for account_id in account_ids:
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ continue
+
+ account = user_accounts[user_id][account_id]
+
+ if not account.is_running:
+ continue
+
+ account.should_stop = True
+ account.status = "正在停止"
+ stopped.append(account_id)
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ return jsonify({
+ "success": True,
+ "stopped_count": len(stopped),
+ "stopped": stopped
+ })
+
if __name__ == '__main__':
print("=" * 60)
print("知识管理平台自动化工具 - 多用户版")
@@ -2187,18 +3363,20 @@ if __name__ == '__main__':
# 初始化数据库
database.init_database()
+ checkpoint_mgr = get_checkpoint_manager()
+ print("✓ 任务断点管理器已初始化")
# 加载系统配置(并发设置)
try:
- config = database.get_system_config()
- if config:
+ system_config = database.get_system_config()
+ if system_config:
# 使用globals()修改全局变量
- globals()['max_concurrent_global'] = config.get('max_concurrent_global', 2)
- globals()['max_concurrent_per_account'] = config.get('max_concurrent_per_account', 1)
-
+ globals()['max_concurrent_global'] = system_config.get('max_concurrent_global', 2)
+ globals()['max_concurrent_per_account'] = system_config.get('max_concurrent_per_account', 1)
+
# 重新创建信号量
globals()['global_semaphore'] = threading.Semaphore(globals()['max_concurrent_global'])
-
+
print(f"✓ 已加载并发配置: 全局={globals()['max_concurrent_global']}, 单账号={globals()['max_concurrent_per_account']}")
except Exception as e:
print(f"警告: 加载并发配置失败,使用默认值: {e}")
@@ -2213,11 +3391,28 @@ if __name__ == '__main__':
scheduler_thread.start()
print("✓ 定时任务调度器已启动")
+ # 启动状态推送线程(每秒推送运行中任务状态)
+ status_thread = threading.Thread(target=status_push_worker, daemon=True)
+ status_thread.start()
+ print("✓ 状态推送线程已启动(1秒/次)")
+
# 启动Web服务器
print("\n服务器启动中...")
- print("用户访问地址: http://0.0.0.0:5000")
- print("后台管理地址: http://0.0.0.0:5000/yuyx")
+ print(f"用户访问地址: http://{config.SERVER_HOST}:{config.SERVER_PORT}")
+ print(f"后台管理地址: http://{config.SERVER_HOST}:{config.SERVER_PORT}/yuyx")
print("默认管理员: admin/admin")
print("=" * 60 + "\n")
- socketio.run(app, host='0.0.0.0', port=5000, debug=False)
+ # 同步初始化浏览器池(必须在socketio.run之前,否则eventlet会导致asyncio冲突)
+ try:
+ system_cfg = database.get_system_config()
+ pool_size = system_cfg.get('max_screenshot_concurrent', 3) if system_cfg else 3
+ print(f"正在预热 {pool_size} 个浏览器实例(截图加速)...")
+ init_browser_worker_pool(pool_size=pool_size)
+ print("✓ 浏览器池初始化完成")
+ except Exception as e:
+ print(f"警告: 浏览器池初始化失败: {e}")
+
+ socketio.run(app, host=config.SERVER_HOST, port=config.SERVER_PORT, debug=config.DEBUG, allow_unsafe_werkzeug=True)
+
+
diff --git a/app.py.backup_20251116_194609 b/app.py.backup_20251116_194609
new file mode 100755
index 0000000..394d7c7
--- /dev/null
+++ b/app.py.backup_20251116_194609
@@ -0,0 +1,2223 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+知识管理平台自动化工具 - 多用户版本
+支持用户注册登录、后台管理、数据隔离
+"""
+
+# 设置时区为中国标准时间(CST, UTC+8)
+import os
+os.environ['TZ'] = 'Asia/Shanghai'
+try:
+ import time
+ time.tzset()
+except AttributeError:
+ pass # Windows系统不支持tzset()
+
+import pytz
+from datetime import datetime
+from flask import Flask, render_template, request, jsonify, send_from_directory, redirect, url_for, session
+from flask_socketio import SocketIO, emit, join_room, leave_room
+from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
+import threading
+import time
+import json
+import os
+from datetime import datetime, timedelta, timezone
+from functools import wraps
+
+# 导入数据库模块和核心模块
+import database
+import requests
+from playwright_automation import PlaywrightBrowserManager, PlaywrightAutomation, BrowseResult
+from browser_installer import check_and_install_browser
+# ========== 优化模块导入 ==========
+from app_config import get_config
+from app_logger import init_logging, get_logger, audit_logger
+from app_security import (
+ ip_rate_limiter, require_ip_not_locked,
+ validate_username, validate_password, validate_email,
+ is_safe_path, sanitize_filename, get_client_ip
+)
+
+
+
+# ========== 初始化配置 ==========
+config = get_config()
+app = Flask(__name__)
+# SECRET_KEY持久化,避免重启后所有用户登出
+SECRET_KEY_FILE = 'data/secret_key.txt'
+if os.path.exists(SECRET_KEY_FILE):
+ with open(SECRET_KEY_FILE, 'r') as f:
+ SECRET_KEY = f.read().strip()
+else:
+ SECRET_KEY = os.urandom(24).hex()
+ os.makedirs('data', exist_ok=True)
+ with open(SECRET_KEY_FILE, 'w') as f:
+ f.write(SECRET_KEY)
+ print(f"✓ 已生成新的SECRET_KEY并保存")
+app.config.from_object(config)
+socketio = SocketIO(app, cors_allowed_origins="*")
+
+# ========== 初始化日志系统 ==========
+init_logging(log_level=config.LOG_LEVEL, log_file=config.LOG_FILE)
+logger = get_logger('app')
+logger.info("="*60)
+logger.info("知识管理平台自动化工具 - 多用户版")
+logger.info("="*60)
+
+
+# Flask-Login 配置
+login_manager = LoginManager()
+login_manager.init_app(app)
+login_manager.login_view = 'login_page'
+
+# 截图目录
+SCREENSHOTS_DIR = config.SCREENSHOTS_DIR
+os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
+
+# 全局变量
+browser_manager = None
+user_accounts = {} # {user_id: {account_id: Account对象}}
+active_tasks = {} # {account_id: Thread对象}
+log_cache = {} # {user_id: [logs]} 每个用户独立的日志缓存
+log_cache_total_count = 0 # 全局日志总数,防止无限增长
+
+# 日志缓存限制
+MAX_LOGS_PER_USER = config.MAX_LOGS_PER_USER # 每个用户最多100条
+MAX_TOTAL_LOGS = config.MAX_TOTAL_LOGS # 全局最多1000条,防止内存泄漏
+
+# 并发控制:每个用户同时最多运行1个账号(避免内存不足)
+# 验证码存储:{session_id: {"code": "1234", "expire_time": timestamp, "failed_attempts": 0}}
+captcha_storage = {}
+
+# IP限流存储:{ip: {"attempts": count, "lock_until": timestamp, "first_attempt": timestamp}}
+ip_rate_limit = {}
+
+# 限流配置
+MAX_CAPTCHA_ATTEMPTS = 5 # 每个验证码最多尝试次数
+MAX_IP_ATTEMPTS_PER_HOUR = 10 # 每小时每个IP最多验证码错误次数
+IP_LOCK_DURATION = 3600 # IP锁定时长(秒) - 1小时
+# 全局限制:整个系统同时最多运行2个账号(线程本地架构,每个线程独立浏览器,内存占用约200MB/浏览器)
+max_concurrent_per_account = 1 # 每个用户最多1个
+max_concurrent_global = 2 # 全局最多2个(线程本地架构内存需求更高)
+user_semaphores = {} # {user_id: Semaphore}
+global_semaphore = threading.Semaphore(max_concurrent_global)
+
+# 截图专用信号量:限制同时进行的截图任务数量为1(避免资源竞争)
+screenshot_semaphore = threading.Semaphore(1)
+
+
+class User(UserMixin):
+ """Flask-Login 用户类"""
+ def __init__(self, user_id):
+ self.id = user_id
+
+
+class Admin(UserMixin):
+ """管理员类"""
+ def __init__(self, admin_id):
+ self.id = admin_id
+ self.is_admin = True
+
+
+class Account:
+ """账号类"""
+ def __init__(self, account_id, user_id, username, password, remember=True, remark=''):
+ self.id = account_id
+ self.user_id = user_id
+ self.username = username
+ self.password = password
+ self.remember = remember
+ self.remark = remark
+ self.status = "未开始"
+ self.is_running = False
+ self.should_stop = False
+ self.total_items = 0
+ self.total_attachments = 0
+ self.automation = None
+ self.last_browse_type = "注册前未读"
+ self.proxy_config = None # 保存代理配置,浏览和截图共用
+
+ def to_dict(self):
+ return {
+ "id": self.id,
+ "username": self.username,
+ "status": self.status,
+ "remark": self.remark,
+ "total_items": self.total_items,
+ "total_attachments": self.total_attachments,
+ "is_running": self.is_running
+ }
+
+
+@login_manager.user_loader
+def load_user(user_id):
+ """Flask-Login 用户加载"""
+ user = database.get_user_by_id(int(user_id))
+ if user:
+ return User(user['id'])
+ return None
+
+
+def admin_required(f):
+ """管理员权限装饰器"""
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+ return f(*args, **kwargs)
+ return decorated_function
+
+
+def log_to_client(message, user_id=None, account_id=None):
+ """发送日志到Web客户端(用户隔离)"""
+ beijing_tz = timezone(timedelta(hours=8))
+ timestamp = datetime.now(beijing_tz).strftime('%H:%M:%S')
+ log_data = {
+ 'timestamp': timestamp,
+ 'message': message,
+ 'account_id': account_id
+ }
+
+ # 如果指定了user_id,则缓存到该用户的日志
+ if user_id:
+ global log_cache_total_count
+ if user_id not in log_cache:
+ log_cache[user_id] = []
+ log_cache[user_id].append(log_data)
+ log_cache_total_count += 1
+
+ # 持久化到数据库 (已禁用,使用task_logs表代替)
+ # try:
+ # database.save_operation_log(user_id, message, account_id, 'INFO')
+ # except Exception as e:
+ # print(f"保存日志到数据库失败: {e}")
+
+ # 单用户限制
+ if len(log_cache[user_id]) > MAX_LOGS_PER_USER:
+ log_cache[user_id].pop(0)
+ log_cache_total_count -= 1
+
+ # 全局限制 - 如果超过总数限制,清理日志最多的用户
+ while log_cache_total_count > MAX_TOTAL_LOGS:
+ if log_cache:
+ max_user = max(log_cache.keys(), key=lambda u: len(log_cache[u]))
+ if log_cache[max_user]:
+ log_cache[max_user].pop(0)
+ log_cache_total_count -= 1
+ else:
+ break
+ else:
+ break
+
+ # 发送到该用户的room
+ socketio.emit('log', log_data, room=f'user_{user_id}')
+
+ print(f"[{timestamp}] User:{user_id} {message}")
+
+
+
+def get_proxy_from_api(api_url, max_retries=3):
+ """从API获取代理IP(支持重试)
+
+ Args:
+ api_url: 代理API地址
+ max_retries: 最大重试次数
+
+ Returns:
+ 代理服务器地址(格式: http://IP:PORT)或 None
+ """
+ for attempt in range(max_retries):
+ try:
+ response = requests.get(api_url, timeout=10)
+ if response.status_code == 200:
+ ip_port = response.text.strip()
+ if ip_port and ':' in ip_port:
+ proxy_server = f"http://{ip_port}"
+ print(f"✓ 获取代理成功: {proxy_server} (尝试 {attempt + 1}/{max_retries})")
+ return proxy_server
+ else:
+ print(f"✗ 代理格式错误: {ip_port} (尝试 {attempt + 1}/{max_retries})")
+ else:
+ print(f"✗ 获取代理失败: HTTP {response.status_code} (尝试 {attempt + 1}/{max_retries})")
+ except Exception as e:
+ print(f"✗ 获取代理异常: {str(e)} (尝试 {attempt + 1}/{max_retries})")
+
+ if attempt < max_retries - 1:
+ time.sleep(1)
+
+ print(f"✗ 获取代理失败,已重试 {max_retries} 次,将不使用代理继续")
+ return None
+
+def init_browser_manager():
+ """初始化浏览器管理器"""
+ global browser_manager
+ if browser_manager is None:
+ print("正在初始化Playwright浏览器管理器...")
+
+ if not check_and_install_browser(log_callback=lambda msg, account_id=None: print(msg)):
+ print("浏览器环境检查失败!")
+ return False
+
+ browser_manager = PlaywrightBrowserManager(
+ headless=True,
+ log_callback=lambda msg, account_id=None: print(msg)
+ )
+
+ try:
+ # 不再需要initialize(),每个账号会创建独立浏览器
+ print("Playwright浏览器管理器创建成功!")
+ return True
+ except Exception as e:
+ print(f"Playwright初始化失败: {str(e)}")
+ return False
+ return True
+
+
+# ==================== 前端路由 ====================
+
+@app.route('/')
+def index():
+ """主页 - 重定向到登录或应用"""
+ if current_user.is_authenticated:
+ return redirect(url_for('app_page'))
+ return redirect(url_for('login_page'))
+
+
+@app.route('/login')
+def login_page():
+ """登录页面"""
+ return render_template('login.html')
+
+
+@app.route('/register')
+def register_page():
+ """注册页面"""
+ return render_template('register.html')
+
+
+@app.route('/app')
+@login_required
+def app_page():
+ """主应用页面"""
+ return render_template('index.html')
+
+
+@app.route('/yuyx')
+def admin_login_page():
+ """后台登录页面"""
+ if 'admin_id' in session:
+ return redirect(url_for('admin_page'))
+ return render_template('admin_login.html')
+
+
+@app.route('/yuyx/admin')
+@admin_required
+def admin_page():
+ """后台管理页面"""
+ return render_template('admin.html')
+
+
+
+
+@app.route('/yuyx/vip')
+@admin_required
+def vip_admin_page():
+ """VIP管理页面"""
+ return render_template('vip_admin.html')
+
+
+# ==================== 用户认证API ====================
+
+@app.route('/api/register', methods=['POST'])
+@require_ip_not_locked # IP限流保护
+def register():
+ """用户注册"""
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ email = data.get('email', '').strip()
+ captcha_session = data.get('captcha_session', '')
+ captcha_code = data.get('captcha', '').strip()
+
+ if not username or not password:
+ return jsonify({"error": "用户名和密码不能为空"}), 400
+
+ # 验证验证码
+ if not captcha_session or captcha_session not in captcha_storage:
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ captcha_data = captcha_storage[captcha_session]
+ if captcha_data["expire_time"] < time.time():
+ del captcha_storage[captcha_session]
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ # 获取客户端IP
+ client_ip = request.headers.get('X-Forwarded-For', request.headers.get('X-Real-IP', request.remote_addr))
+ if client_ip and ',' in client_ip:
+ client_ip = client_ip.split(',')[0].strip()
+
+ # 检查IP限流
+ allowed, error_msg = check_ip_rate_limit(client_ip)
+ if not allowed:
+ return jsonify({"error": error_msg}), 429
+
+ # 检查验证码尝试次数
+ if captcha_data.get("failed_attempts", 0) >= MAX_CAPTCHA_ATTEMPTS:
+ del captcha_storage[captcha_session]
+ return jsonify({"error": "验证码尝试次数过多,请重新获取"}), 400
+
+ if captcha_data["code"] != captcha_code:
+ # 记录失败次数
+ captcha_data["failed_attempts"] = captcha_data.get("failed_attempts", 0) + 1
+
+ # 记录IP失败尝试
+ is_locked = record_failed_captcha(client_ip)
+ if is_locked:
+ return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
+
+ return jsonify({"error": "验证码错误(剩余{}次机会)".format(
+ MAX_CAPTCHA_ATTEMPTS - captcha_data["failed_attempts"])}), 400
+
+ # 验证成功,删除已使用的验证码
+ del captcha_storage[captcha_session]
+
+ user_id = database.create_user(username, password, email)
+ if user_id:
+ return jsonify({"success": True, "message": "注册成功,请等待管理员审核"})
+ else:
+ return jsonify({"error": "用户名已存在"}), 400
+
+
+# ==================== 验证码API ====================
+import random
+
+
+def check_ip_rate_limit(ip_address):
+ """检查IP是否被限流"""
+ current_time = time.time()
+
+ # 清理过期的IP记录
+ expired_ips = [ip for ip, data in ip_rate_limit.items()
+ if data.get("lock_until", 0) < current_time and
+ current_time - data.get("first_attempt", current_time) > 3600]
+ for ip in expired_ips:
+ del ip_rate_limit[ip]
+
+ # 检查IP是否被锁定
+ if ip_address in ip_rate_limit:
+ ip_data = ip_rate_limit[ip_address]
+
+ # 如果IP被锁定且未到解锁时间
+ if ip_data.get("lock_until", 0) > current_time:
+ remaining_time = int(ip_data["lock_until"] - current_time)
+ return False, "IP已被锁定,请{}分钟后再试".format(remaining_time // 60 + 1)
+
+ # 如果超过1小时,重置计数
+ if current_time - ip_data.get("first_attempt", current_time) > 3600:
+ ip_rate_limit[ip_address] = {
+ "attempts": 0,
+ "first_attempt": current_time
+ }
+
+ return True, None
+
+
+def record_failed_captcha(ip_address):
+ """记录验证码失败尝试"""
+ current_time = time.time()
+
+ if ip_address not in ip_rate_limit:
+ ip_rate_limit[ip_address] = {
+ "attempts": 1,
+ "first_attempt": current_time
+ }
+ else:
+ ip_rate_limit[ip_address]["attempts"] += 1
+
+ # 检查是否超过限制
+ if ip_rate_limit[ip_address]["attempts"] >= MAX_IP_ATTEMPTS_PER_HOUR:
+ ip_rate_limit[ip_address]["lock_until"] = current_time + IP_LOCK_DURATION
+ return True # 表示IP已被锁定
+
+ return False # 表示还未锁定
+
+
+@app.route("/api/generate_captcha", methods=["POST"])
+def generate_captcha():
+ """生成4位数字验证码"""
+ import uuid
+ session_id = str(uuid.uuid4())
+
+ # 生成4位随机数字
+ code = "".join([str(random.randint(0, 9)) for _ in range(4)])
+
+ # 存储验证码,5分钟过期
+ captcha_storage[session_id] = {
+ "code": code,
+ "expire_time": time.time() + 300,
+ "failed_attempts": 0
+ }
+
+ # 清理过期验证码
+ expired_keys = [k for k, v in captcha_storage.items() if v["expire_time"] < time.time()]
+ for k in expired_keys:
+ del captcha_storage[k]
+
+ return jsonify({"session_id": session_id, "captcha": code})
+
+
+@app.route('/api/login', methods=['POST'])
+@require_ip_not_locked # IP限流保护
+def login():
+ """用户登录"""
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ captcha_session = data.get('captcha_session', '')
+ captcha_code = data.get('captcha', '').strip()
+ need_captcha = data.get('need_captcha', False)
+
+ # 如果需要验证码,验证验证码
+ if need_captcha:
+ if not captcha_session or captcha_session not in captcha_storage:
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ captcha_data = captcha_storage[captcha_session]
+ if captcha_data["expire_time"] < time.time():
+ del captcha_storage[captcha_session]
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ if captcha_data["code"] != captcha_code:
+ return jsonify({"error": "验证码错误"}), 400
+
+ # 验证成功,删除已使用的验证码
+ del captcha_storage[captcha_session]
+
+ # 先检查用户是否存在
+ user_exists = database.get_user_by_username(username)
+ if not user_exists:
+ return jsonify({"error": "账号未注册", "need_captcha": True}), 401
+
+ # 检查密码是否正确
+ user = database.verify_user(username, password)
+ if not user:
+ # 密码错误
+ return jsonify({"error": "密码错误", "need_captcha": True}), 401
+
+ # 检查审核状态
+ if user['status'] != 'approved':
+ return jsonify({"error": "账号未审核,请等待管理员审核", "need_captcha": False}), 401
+
+ # 登录成功
+ user_obj = User(user['id'])
+ login_user(user_obj)
+ load_user_accounts(user['id'])
+ return jsonify({"success": True})
+
+
+@app.route('/api/logout', methods=['POST'])
+@login_required
+def logout():
+ """用户登出"""
+ logout_user()
+ return jsonify({"success": True})
+
+
+# ==================== 管理员认证API ====================
+
+@app.route('/yuyx/api/login', methods=['POST'])
+@require_ip_not_locked # IP限流保护
+def admin_login():
+ """管理员登录"""
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ captcha_session = data.get('captcha_session', '')
+ captcha_code = data.get('captcha', '').strip()
+ need_captcha = data.get('need_captcha', False)
+
+ # 如果需要验证码,验证验证码
+ if need_captcha:
+ if not captcha_session or captcha_session not in captcha_storage:
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ captcha_data = captcha_storage[captcha_session]
+ if captcha_data["expire_time"] < time.time():
+ del captcha_storage[captcha_session]
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ if captcha_data["code"] != captcha_code:
+ return jsonify({"error": "验证码错误"}), 400
+
+ # 验证成功,删除已使用的验证码
+ del captcha_storage[captcha_session]
+
+ admin = database.verify_admin(username, password)
+ if admin:
+ session['admin_id'] = admin['id']
+ session['admin_username'] = admin['username']
+ return jsonify({"success": True})
+ else:
+ return jsonify({"error": "管理员用户名或密码错误", "need_captcha": True}), 401
+
+
+@app.route('/yuyx/api/logout', methods=['POST'])
+@admin_required
+def admin_logout():
+ """管理员登出"""
+ session.pop('admin_id', None)
+ session.pop('admin_username', None)
+ return jsonify({"success": True})
+
+
+@app.route('/yuyx/api/users', methods=['GET'])
+@admin_required
+def get_all_users():
+ """获取所有用户"""
+ users = database.get_all_users()
+ return jsonify(users)
+
+
+@app.route('/yuyx/api/users/pending', methods=['GET'])
+@admin_required
+def get_pending_users():
+ """获取待审核用户"""
+ users = database.get_pending_users()
+ return jsonify(users)
+
+
+@app.route('/yuyx/api/users//approve', methods=['POST'])
+@admin_required
+def approve_user_route(user_id):
+ """审核通过用户"""
+ if database.approve_user(user_id):
+ return jsonify({"success": True})
+ return jsonify({"error": "审核失败"}), 400
+
+
+@app.route('/yuyx/api/users//reject', methods=['POST'])
+@admin_required
+def reject_user_route(user_id):
+ """拒绝用户"""
+ if database.reject_user(user_id):
+ return jsonify({"success": True})
+ return jsonify({"error": "拒绝失败"}), 400
+
+
+@app.route('/yuyx/api/users/', methods=['DELETE'])
+@admin_required
+def delete_user_route(user_id):
+ """删除用户"""
+ if database.delete_user(user_id):
+ # 清理内存中的账号数据
+ if user_id in user_accounts:
+ del user_accounts[user_id]
+
+ # 清理用户信号量,防止内存泄漏
+ if user_id in user_semaphores:
+ del user_semaphores[user_id]
+
+ # 清理用户日志缓存,防止内存泄漏
+ global log_cache_total_count
+ if user_id in log_cache:
+ log_cache_total_count -= len(log_cache[user_id])
+ del log_cache[user_id]
+
+ return jsonify({"success": True})
+ return jsonify({"error": "删除失败"}), 400
+
+
+@app.route('/yuyx/api/stats', methods=['GET'])
+@admin_required
+def get_system_stats():
+ """获取系统统计"""
+ stats = database.get_system_stats()
+ # 从session获取管理员用户名
+ stats["admin_username"] = session.get('admin_username', 'admin')
+ return jsonify(stats)
+
+
+@app.route('/yuyx/api/docker_stats', methods=['GET'])
+@admin_required
+def get_docker_stats():
+ """获取Docker容器运行状态"""
+ import subprocess
+
+ docker_status = {
+ 'running': False,
+ 'container_name': 'N/A',
+ 'uptime': 'N/A',
+ 'memory_usage': 'N/A',
+ 'memory_limit': 'N/A',
+ 'memory_percent': 'N/A',
+ 'cpu_percent': 'N/A',
+ 'status': 'Unknown'
+ }
+
+ try:
+ # 检查是否在Docker容器内
+ if os.path.exists('/.dockerenv'):
+ docker_status['running'] = True
+
+ # 获取容器名称
+ try:
+ with open('/etc/hostname', 'r') as f:
+ docker_status['container_name'] = f.read().strip()
+ except:
+ pass
+
+ # 获取内存使用情况 (cgroup v2)
+ try:
+ # 尝试cgroup v2路径
+ if os.path.exists('/sys/fs/cgroup/memory.current'):
+ # Read total memory
+ with open('/sys/fs/cgroup/memory.current', 'r') as f:
+ mem_total = int(f.read().strip())
+
+ # Read cache from memory.stat
+ cache = 0
+ if os.path.exists('/sys/fs/cgroup/memory.stat'):
+ with open('/sys/fs/cgroup/memory.stat', 'r') as f:
+ for line in f:
+ if line.startswith('inactive_file '):
+ cache = int(line.split()[1])
+ break
+
+ # Actual memory = total - cache
+ mem_bytes = mem_total - cache
+ docker_status['memory_usage'] = "{:.2f} MB".format(mem_bytes / 1024 / 1024)
+
+ # 获取内存限制
+ if os.path.exists('/sys/fs/cgroup/memory.max'):
+ with open('/sys/fs/cgroup/memory.max', 'r') as f:
+ limit_str = f.read().strip()
+ if limit_str != 'max':
+ limit_bytes = int(limit_str)
+ docker_status['memory_limit'] = "{:.2f} GB".format(limit_bytes / 1024 / 1024 / 1024)
+ docker_status['memory_percent'] = "{:.2f}%".format(mem_bytes / limit_bytes * 100)
+ # 尝试cgroup v1路径
+ elif os.path.exists('/sys/fs/cgroup/memory/memory.usage_in_bytes'):
+ # 从 memory.stat 读取内存信息
+ mem_bytes = 0
+ if os.path.exists('/sys/fs/cgroup/memory/memory.stat'):
+ with open('/sys/fs/cgroup/memory/memory.stat', 'r') as f:
+ rss = 0
+ cache = 0
+ for line in f:
+ if line.startswith('total_rss '):
+ rss = int(line.split()[1])
+ elif line.startswith('total_cache '):
+ cache = int(line.split()[1])
+ # 使用 RSS + (一部分活跃的cache),更接近docker stats的计算
+ # 但为了准确性,我们只用RSS
+ mem_bytes = rss
+
+ # 如果找不到,则使用总内存减去缓存作为后备
+ if mem_bytes == 0:
+ with open('/sys/fs/cgroup/memory/memory.usage_in_bytes', 'r') as f:
+ total_mem = int(f.read().strip())
+
+ cache = 0
+ if os.path.exists('/sys/fs/cgroup/memory/memory.stat'):
+ with open('/sys/fs/cgroup/memory/memory.stat', 'r') as f:
+ for line in f:
+ if line.startswith('total_inactive_file '):
+ cache = int(line.split()[1])
+ break
+
+ mem_bytes = total_mem - cache
+
+ docker_status['memory_usage'] = "{:.2f} MB".format(mem_bytes / 1024 / 1024)
+
+ # 获取内存限制
+ if os.path.exists('/sys/fs/cgroup/memory/memory.limit_in_bytes'):
+ with open('/sys/fs/cgroup/memory/memory.limit_in_bytes', 'r') as f:
+ limit_bytes = int(f.read().strip())
+ # 检查是否是实际限制(不是默认的超大值)
+ if limit_bytes < 9223372036854771712:
+ docker_status['memory_limit'] = "{:.2f} GB".format(limit_bytes / 1024 / 1024 / 1024)
+ docker_status['memory_percent'] = "{:.2f}%".format(mem_bytes / limit_bytes * 100)
+ except Exception as e:
+ docker_status['memory_usage'] = 'Error: {}'.format(str(e))
+
+ # 获取容器运行时间(基于PID 1的启动时间)
+ try:
+ # Get PID 1 start time
+ with open('/proc/1/stat', 'r') as f:
+ stat_data = f.read().split()
+ starttime_ticks = int(stat_data[21])
+
+ # Get system uptime
+ with open('/proc/uptime', 'r') as f:
+ system_uptime = float(f.read().split()[0])
+
+ # Get clock ticks per second
+ import os as os_module
+ ticks_per_sec = os_module.sysconf(os_module.sysconf_names['SC_CLK_TCK'])
+
+ # Calculate container uptime
+ process_start = starttime_ticks / ticks_per_sec
+ uptime_seconds = int(system_uptime - process_start)
+
+ days = uptime_seconds // 86400
+ hours = (uptime_seconds % 86400) // 3600
+ minutes = (uptime_seconds % 3600) // 60
+
+ if days > 0:
+ docker_status['uptime'] = "{}天 {}小时 {}分钟".format(days, hours, minutes)
+ elif hours > 0:
+ docker_status['uptime'] = "{}小时 {}分钟".format(hours, minutes)
+ else:
+ docker_status['uptime'] = "{}分钟".format(minutes)
+ except:
+ pass
+
+ docker_status['status'] = 'Running'
+ else:
+ docker_status['status'] = 'Not in Docker'
+
+ except Exception as e:
+ docker_status['status'] = 'Error: {}'.format(str(e))
+
+ return jsonify(docker_status)
+
+@app.route('/yuyx/api/admin/password', methods=['PUT'])
+@admin_required
+def update_admin_password():
+ """修改管理员密码"""
+ data = request.json
+ new_password = data.get('new_password', '').strip()
+
+ if not new_password:
+ return jsonify({"error": "密码不能为空"}), 400
+
+ username = session.get('admin_username')
+ if database.update_admin_password(username, new_password):
+ return jsonify({"success": True})
+ return jsonify({"error": "修改失败"}), 400
+
+
+@app.route('/yuyx/api/admin/username', methods=['PUT'])
+@admin_required
+def update_admin_username():
+ """修改管理员用户名"""
+ data = request.json
+ new_username = data.get('new_username', '').strip()
+
+ if not new_username:
+ return jsonify({"error": "用户名不能为空"}), 400
+
+ old_username = session.get('admin_username')
+ if database.update_admin_username(old_username, new_username):
+ session['admin_username'] = new_username
+ return jsonify({"success": True})
+ return jsonify({"error": "用户名已存在"}), 400
+
+
+
+# ==================== 密码重置API ====================
+
+# 管理员直接重置用户密码
+@app.route('/yuyx/api/users//reset_password', methods=['POST'])
+@admin_required
+def admin_reset_password_route(user_id):
+ """管理员直接重置用户密码(无需审核)"""
+ data = request.json
+ new_password = data.get('new_password', '').strip()
+
+ if not new_password:
+ return jsonify({"error": "新密码不能为空"}), 400
+
+ if len(new_password) < 6:
+ return jsonify({"error": "密码长度不能少于6位"}), 400
+
+ if database.admin_reset_user_password(user_id, new_password):
+ return jsonify({"message": "密码重置成功"})
+ return jsonify({"error": "重置失败,用户不存在"}), 400
+
+
+# 获取密码重置申请列表
+@app.route('/yuyx/api/password_resets', methods=['GET'])
+@admin_required
+def get_password_resets_route():
+ """获取所有待审核的密码重置申请"""
+ resets = database.get_pending_password_resets()
+ return jsonify(resets)
+
+
+# 批准密码重置申请
+@app.route('/yuyx/api/password_resets//approve', methods=['POST'])
+@admin_required
+def approve_password_reset_route(request_id):
+ """批准密码重置申请"""
+ if database.approve_password_reset(request_id):
+ return jsonify({"message": "密码重置申请已批准"})
+ return jsonify({"error": "批准失败"}), 400
+
+
+# 拒绝密码重置申请
+@app.route('/yuyx/api/password_resets//reject', methods=['POST'])
+@admin_required
+def reject_password_reset_route(request_id):
+ """拒绝密码重置申请"""
+ if database.reject_password_reset(request_id):
+ return jsonify({"message": "密码重置申请已拒绝"})
+ return jsonify({"error": "拒绝失败"}), 400
+
+
+# 用户申请重置密码(需要审核)
+@app.route('/api/reset_password_request', methods=['POST'])
+def request_password_reset():
+ """用户申请重置密码"""
+ data = request.json
+ username = data.get('username', '').strip()
+ email = data.get('email', '').strip()
+ new_password = data.get('new_password', '').strip()
+
+ if not username or not new_password:
+ return jsonify({"error": "用户名和新密码不能为空"}), 400
+
+ if len(new_password) < 6:
+ return jsonify({"error": "密码长度不能少于6位"}), 400
+
+ # 验证用户存在
+ user = database.get_user_by_username(username)
+ if not user:
+ return jsonify({"error": "用户不存在"}), 404
+
+ # 如果提供了邮箱,验证邮箱是否匹配
+ if email and user.get('email') != email:
+ return jsonify({"error": "邮箱不匹配"}), 400
+
+ # 创建重置申请
+ request_id = database.create_password_reset_request(user['id'], new_password)
+ if request_id:
+ return jsonify({"message": "密码重置申请已提交,请等待管理员审核"})
+ else:
+ return jsonify({"error": "申请提交失败"}), 500
+
+
+# ==================== 账号管理API (用户隔离) ====================
+
+def load_user_accounts(user_id):
+ """从数据库加载用户的账号到内存"""
+ if user_id not in user_accounts:
+ user_accounts[user_id] = {}
+
+ accounts_data = database.get_user_accounts(user_id)
+ for acc_data in accounts_data:
+ account = Account(
+ account_id=acc_data['id'],
+ user_id=user_id,
+ username=acc_data['username'],
+ password=acc_data['password'],
+ remember=bool(acc_data['remember']),
+ remark=acc_data['remark'] or ''
+ )
+ user_accounts[user_id][account.id] = account
+
+
+@app.route('/api/accounts', methods=['GET'])
+@login_required
+def get_accounts():
+ """获取当前用户的所有账号"""
+ user_id = current_user.id
+ if user_id not in user_accounts:
+ load_user_accounts(user_id)
+
+ accounts = user_accounts.get(user_id, {})
+ return jsonify([acc.to_dict() for acc in accounts.values()])
+
+
+@app.route('/api/accounts', methods=['POST'])
+@login_required
+def add_account():
+ """添加账号"""
+ user_id = current_user.id
+
+ # VIP账号数量限制检查
+ if not database.is_user_vip(user_id):
+ current_count = len(database.get_user_accounts(user_id))
+ if current_count >= 1:
+ return jsonify({"error": "非VIP用户只能添加1个账号,请联系管理员开通VIP"}), 403
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ remember = data.get('remember', True)
+
+ if not username or not password:
+ return jsonify({"error": "用户名和密码不能为空"}), 400
+
+ # 检查当前用户是否已存在该账号
+ if user_id in user_accounts:
+ for acc in user_accounts[user_id].values():
+ if acc.username == username:
+ return jsonify({"error": f"账号 '{username}' 已存在"}), 400
+
+ # 生成账号ID
+ import uuid
+ account_id = str(uuid.uuid4())[:8]
+
+ # 保存到数据库
+ database.create_account(user_id, account_id, username, password, remember, '')
+
+ # 加载到内存
+ account = Account(account_id, user_id, username, password, remember, '')
+ if user_id not in user_accounts:
+ user_accounts[user_id] = {}
+ user_accounts[user_id][account_id] = account
+
+ log_to_client(f"添加账号: {username}", user_id)
+ return jsonify(account.to_dict())
+
+
+@app.route('/api/accounts/', methods=['DELETE'])
+@login_required
+def delete_account(account_id):
+ """删除账号"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ # 停止正在运行的任务
+ if account.is_running:
+ account.should_stop = True
+ if account.automation:
+ account.automation.close()
+
+ username = account.username
+
+ # 从数据库删除
+ database.delete_account(account_id)
+
+ # 从内存删除
+ del user_accounts[user_id][account_id]
+
+ log_to_client(f"删除账号: {username}", user_id)
+ return jsonify({"success": True})
+
+
+@app.route('/api/accounts//remark', methods=['PUT'])
+@login_required
+def update_remark(account_id):
+ """更新备注"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ data = request.json
+ remark = data.get('remark', '').strip()[:200]
+
+ # 更新数据库
+ database.update_account_remark(account_id, remark)
+
+ # 更新内存
+ user_accounts[user_id][account_id].remark = remark
+ log_to_client(f"更新备注: {user_accounts[user_id][account_id].username} -> {remark}", user_id)
+
+ return jsonify({"success": True})
+
+
+@app.route('/api/accounts//start', methods=['POST'])
+@login_required
+def start_account(account_id):
+ """启动账号任务"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ if account.is_running:
+ return jsonify({"error": "任务已在运行中"}), 400
+
+ data = request.json
+ browse_type = data.get('browse_type', '应读')
+ enable_screenshot = data.get('enable_screenshot', True) # 默认启用截图
+
+ # 确保浏览器管理器已初始化
+ if not init_browser_manager():
+ return jsonify({"error": "浏览器初始化失败"}), 500
+
+ # 启动任务线程
+ account.is_running = True
+ account.should_stop = False
+ account.status = "运行中"
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+
+ log_to_client(f"启动任务: {account.username} - {browse_type}", user_id)
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ return jsonify({"success": True})
+
+
+@app.route('/api/accounts//stop', methods=['POST'])
+@login_required
+def stop_account(account_id):
+ """停止账号任务"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ if not account.is_running:
+ return jsonify({"error": "任务未在运行"}), 400
+
+ account.should_stop = True
+ account.status = "正在停止"
+
+ log_to_client(f"停止任务: {account.username}", user_id)
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ return jsonify({"success": True})
+
+
+def get_user_semaphore(user_id):
+ """获取或创建用户的信号量"""
+ if user_id not in user_semaphores:
+ user_semaphores[user_id] = threading.Semaphore(max_concurrent_per_account)
+ return user_semaphores[user_id]
+
+
+def run_task(user_id, account_id, browse_type, enable_screenshot=True):
+ """运行自动化任务"""
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return
+
+ account = user_accounts[user_id][account_id]
+
+ # 记录任务开始时间
+ import time as time_module
+ task_start_time = time_module.time()
+
+ # 两级并发控制:用户级 + 全局级
+ user_sem = get_user_semaphore(user_id)
+
+ # 获取用户级信号量(同一用户的账号排队)
+ log_to_client(f"等待资源分配...", user_id, account_id)
+ account.status = "排队中"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ user_sem.acquire()
+
+ try:
+ # 如果在排队期间被停止,直接返回
+ if account.should_stop:
+ log_to_client(f"任务已取消", user_id, account_id)
+ account.status = "已停止"
+ account.is_running = False
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ return
+
+ # 获取全局信号量(防止所有用户同时运行导致资源耗尽)
+ global_semaphore.acquire()
+
+ try:
+ # 再次检查是否被停止
+ if account.should_stop:
+ log_to_client(f"任务已取消", user_id, account_id)
+ account.status = "已停止"
+ account.is_running = False
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ return
+
+ account.status = "运行中"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ account.last_browse_type = browse_type
+
+ # 重试机制:最多尝试3次,超时则换IP重试
+ max_attempts = 3
+ last_error = None
+
+ for attempt in range(1, max_attempts + 1):
+ try:
+ if attempt > 1:
+ log_to_client(f"🔄 第 {attempt} 次尝试(共{max_attempts}次)...", user_id, account_id)
+
+ # 检查是否需要使用代理
+ proxy_config = None
+ config = database.get_system_config()
+ if config.get('proxy_enabled') == 1:
+ proxy_api_url = config.get('proxy_api_url', '').strip()
+ if proxy_api_url:
+ log_to_client(f"正在获取代理IP...", user_id, account_id)
+ proxy_server = get_proxy_from_api(proxy_api_url, max_retries=3)
+ if proxy_server:
+ proxy_config = {'server': proxy_server}
+ log_to_client(f"✓ 将使用代理: {proxy_server}", user_id, account_id)
+ account.proxy_config = proxy_config # 保存代理配置供截图使用
+ else:
+ log_to_client(f"✗ 代理获取失败,将不使用代理继续", user_id, account_id)
+ else:
+ log_to_client(f"⚠ 代理已启用但未配置API地址", user_id, account_id)
+
+ log_to_client(f"创建自动化实例...", user_id, account_id)
+ account.automation = PlaywrightAutomation(browser_manager, account_id, proxy_config=proxy_config)
+
+ # 为automation注入包含user_id的自定义log方法,使其能够实时发送日志到WebSocket
+ def custom_log(message: str):
+ log_to_client(message, user_id, account_id)
+ account.automation.log = custom_log
+
+ log_to_client(f"开始登录...", user_id, account_id)
+ if not account.automation.login(account.username, account.password, account.remember):
+ log_to_client(f"❌ 登录失败,请检查用户名和密码", user_id, account_id)
+ account.status = "登录失败"
+ account.is_running = False
+ # 记录登录失败日志
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=0,
+ total_attachments=0,
+ error_message='登录失败,请检查用户名和密码',
+ duration=int(time_module.time() - task_start_time)
+ )
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ return
+
+ log_to_client(f"✓ 登录成功!", user_id, account_id)
+ log_to_client(f"开始浏览 '{browse_type}' 内容...", user_id, account_id)
+
+ def should_stop():
+ return account.should_stop
+
+ result = account.automation.browse_content(
+ browse_type=browse_type,
+ auto_next_page=True,
+ auto_view_attachments=True,
+ interval=2.0,
+ should_stop_callback=should_stop
+ )
+
+ account.total_items = result.total_items
+ account.total_attachments = result.total_attachments
+
+ if result.success:
+ log_to_client(f"浏览完成! 共 {result.total_items} 条内容,{result.total_attachments} 个附件", user_id, account_id)
+ account.status = "已完成"
+ # 记录成功日志
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='success',
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message='',
+ duration=int(time_module.time() - task_start_time)
+ )
+ # 成功则跳出重试循环
+ break
+ else:
+ # 浏览出错,检查是否是超时错误
+ error_msg = result.error_message
+ if 'Timeout' in error_msg or 'timeout' in error_msg:
+ last_error = error_msg
+ log_to_client(f"⚠ 检测到超时错误: {error_msg}", user_id, account_id)
+
+ # 关闭当前浏览器
+ if account.automation:
+ try:
+ account.automation.close()
+ log_to_client(f"已关闭超时的浏览器实例", user_id, account_id)
+ except:
+ pass
+ account.automation = None
+
+ if attempt < max_attempts:
+ log_to_client(f"⚠ 代理可能速度过慢,将换新IP重试 ({attempt}/{max_attempts})", user_id, account_id)
+ time_module.sleep(2) # 等待2秒再重试
+ continue
+ else:
+ log_to_client(f"❌ 已达到最大重试次数({max_attempts}),任务失败", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message=f"重试{max_attempts}次后仍失败: {error_msg}",
+ duration=int(time_module.time() - task_start_time)
+ )
+ break
+ else:
+ # 非超时错误,直接失败不重试
+ log_to_client(f"浏览出错: {error_msg}", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message=error_msg,
+ duration=int(time_module.time() - task_start_time)
+ )
+ break
+
+ except Exception as retry_error:
+ # 捕获重试过程中的异常
+ error_msg = str(retry_error)
+ last_error = error_msg
+
+ # 关闭可能存在的浏览器实例
+ if account.automation:
+ try:
+ account.automation.close()
+ except:
+ pass
+ account.automation = None
+
+ if 'Timeout' in error_msg or 'timeout' in error_msg:
+ log_to_client(f"⚠ 执行超时: {error_msg}", user_id, account_id)
+ if attempt < max_attempts:
+ log_to_client(f"⚠ 将换新IP重试 ({attempt}/{max_attempts})", user_id, account_id)
+ time_module.sleep(2)
+ continue
+ else:
+ log_to_client(f"❌ 已达到最大重试次数({max_attempts}),任务失败", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=account.total_items,
+ total_attachments=account.total_attachments,
+ error_message=f"重试{max_attempts}次后仍失败: {error_msg}",
+ duration=int(time_module.time() - task_start_time)
+ )
+ break
+ else:
+ # 非超时异常,直接失败
+ log_to_client(f"任务执行异常: {error_msg}", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=account.total_items,
+ total_attachments=account.total_attachments,
+ error_message=error_msg,
+ duration=int(time_module.time() - task_start_time)
+ )
+ break
+
+
+ except Exception as e:
+ error_msg = str(e)
+ log_to_client(f"任务执行出错: {error_msg}", user_id, account_id)
+ account.status = "出错"
+ # 记录异常失败日志
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=account.total_items,
+ total_attachments=account.total_attachments,
+ error_message=error_msg,
+ duration=int(time_module.time() - task_start_time)
+ )
+
+ finally:
+ # 释放全局信号量
+ global_semaphore.release()
+
+ account.is_running = False
+
+ if account.automation:
+ try:
+ account.automation.close()
+ log_to_client(f"主任务浏览器已关闭", user_id, account_id)
+ except Exception as e:
+ log_to_client(f"关闭主任务浏览器时出错: {str(e)}", user_id, account_id)
+ finally:
+ account.automation = None
+
+ if account_id in active_tasks:
+ del active_tasks[account_id]
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ # 任务完成后自动截图(增加2秒延迟,确保资源完全释放)
+ # 根据enable_screenshot参数决定是否截图
+ if account.status == "已完成" and not account.should_stop:
+ if enable_screenshot:
+ log_to_client(f"等待2秒后开始截图...", user_id, account_id)
+ time.sleep(2) # 延迟启动截图,确保主任务资源已完全释放
+ threading.Thread(target=take_screenshot_for_account, args=(user_id, account_id), daemon=True).start()
+ else:
+ log_to_client(f"截图功能已禁用,跳过截图", user_id, account_id)
+
+ finally:
+ # 释放用户级信号量
+ user_sem.release()
+
+
+def take_screenshot_for_account(user_id, account_id):
+ """为账号任务完成后截图(带并发控制,避免资源竞争)"""
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return
+
+ account = user_accounts[user_id][account_id]
+
+ # 使用截图信号量,确保同时只有1个截图任务在执行
+ log_to_client(f"等待截图资源分配...", user_id, account_id)
+ screenshot_acquired = screenshot_semaphore.acquire(blocking=True, timeout=300) # 最多等待5分钟
+
+ if not screenshot_acquired:
+ log_to_client(f"截图资源获取超时,跳过截图", user_id, account_id)
+ return
+
+ automation = None
+ try:
+ log_to_client(f"开始截图流程...", user_id, account_id)
+
+ # 使用与浏览任务相同的代理配置
+ proxy_config = account.proxy_config if hasattr(account, 'proxy_config') else None
+ if proxy_config:
+ log_to_client(f"截图将使用相同代理: {proxy_config.get('server', 'Unknown')}", user_id, account_id)
+
+ automation = PlaywrightAutomation(browser_manager, account_id, proxy_config=proxy_config)
+
+ # 为截图automation也注入自定义log方法
+ def custom_log(message: str):
+ log_to_client(message, user_id, account_id)
+ automation.log = custom_log
+
+ log_to_client(f"重新登录以进行截图...", user_id, account_id)
+ if not automation.login(account.username, account.password, account.remember):
+ log_to_client(f"截图登录失败", user_id, account_id)
+ return
+
+ browse_type = account.last_browse_type
+ log_to_client(f"导航到 '{browse_type}' 页面...", user_id, account_id)
+
+ # 不使用should_stop_callback,让页面加载完成显示"暂无记录"
+ result = automation.browse_content(
+ browse_type=browse_type,
+ auto_next_page=False,
+ auto_view_attachments=False,
+ interval=0,
+ should_stop_callback=None
+ )
+
+ if not result.success and result.error_message != "":
+ log_to_client(f"导航失败: {result.error_message}", user_id, account_id)
+
+ time.sleep(2)
+
+ # 生成截图文件名(使用北京时间并简化格式)
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ now_beijing = datetime.now(beijing_tz)
+ timestamp = now_beijing.strftime('%Y%m%d_%H%M%S')
+
+ # 简化文件名:用户名_登录账号_浏览类型_时间.jpg
+ # 获取用户名前缀
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+ # 使用登录账号(account.username)而不是备注
+ login_account = account.username
+ screenshot_filename = f"{username_prefix}_{login_account}_{browse_type}_{timestamp}.jpg"
+ screenshot_path = os.path.join(SCREENSHOTS_DIR, screenshot_filename)
+
+ if automation.take_screenshot(screenshot_path):
+ log_to_client(f"✓ 截图已保存: {screenshot_filename}", user_id, account_id)
+ else:
+ log_to_client(f"✗ 截图失败", user_id, account_id)
+
+ except Exception as e:
+ log_to_client(f"✗ 截图过程中出错: {str(e)}", user_id, account_id)
+
+ finally:
+ # 确保浏览器资源被正确关闭
+ if automation:
+ try:
+ automation.close()
+ log_to_client(f"截图浏览器已关闭", user_id, account_id)
+ except Exception as e:
+ log_to_client(f"关闭截图浏览器时出错: {str(e)}", user_id, account_id)
+
+ # 释放截图信号量
+ screenshot_semaphore.release()
+ log_to_client(f"截图资源已释放", user_id, account_id)
+
+
+@app.route('/api/accounts//screenshot', methods=['POST'])
+@login_required
+def manual_screenshot(account_id):
+ """手动为指定账号截图"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+ if account.is_running:
+ return jsonify({"error": "任务运行中,无法截图"}), 400
+
+ data = request.json or {}
+ browse_type = data.get('browse_type', account.last_browse_type)
+
+ account.last_browse_type = browse_type
+
+ threading.Thread(target=take_screenshot_for_account, args=(user_id, account_id), daemon=True).start()
+ log_to_client(f"手动截图: {account.username} - {browse_type}", user_id)
+ return jsonify({"success": True})
+
+
+# ==================== 截图管理API ====================
+
+@app.route('/api/screenshots', methods=['GET'])
+@login_required
+def get_screenshots():
+ """获取当前用户的截图列表"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ try:
+ screenshots = []
+ if os.path.exists(SCREENSHOTS_DIR):
+ for filename in os.listdir(SCREENSHOTS_DIR):
+ # 只显示属于当前用户的截图(支持png和jpg格式)
+ if (filename.lower().endswith(('.png', '.jpg', '.jpeg'))) and filename.startswith(username_prefix + '_'):
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ stat = os.stat(filepath)
+ # 转换为北京时间
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ created_time = datetime.fromtimestamp(stat.st_mtime, tz=beijing_tz)
+ # 解析文件名获取显示名称
+ # 文件名格式:用户名_登录账号_浏览类型_时间.jpg
+ parts = filename.rsplit('.', 1)[0].split('_', 1) # 移除扩展名并分割
+ if len(parts) > 1:
+ # 显示名称:登录账号_浏览类型_时间.jpg
+ display_name = parts[1] + '.' + filename.rsplit('.', 1)[1]
+ else:
+ display_name = filename
+
+ screenshots.append({
+ 'filename': filename,
+ 'display_name': display_name,
+ 'size': stat.st_size,
+ 'created': created_time.strftime('%Y-%m-%d %H:%M:%S')
+ })
+ screenshots.sort(key=lambda x: x['created'], reverse=True)
+ return jsonify(screenshots)
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+@app.route('/screenshots/')
+@login_required
+def serve_screenshot(filename):
+ """提供截图文件访问"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ # 验证文件属于当前用户
+ if not filename.startswith(username_prefix + '_'):
+ return jsonify({"error": "无权访问"}), 403
+
+ return send_from_directory(SCREENSHOTS_DIR, filename)
+
+
+@app.route('/api/screenshots/', methods=['DELETE'])
+@login_required
+def delete_screenshot(filename):
+ """删除指定截图"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ # 验证文件属于当前用户
+ if not filename.startswith(username_prefix + '_'):
+ return jsonify({"error": "无权删除"}), 403
+
+ try:
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ if os.path.exists(filepath):
+ os.remove(filepath)
+ log_to_client(f"删除截图: {filename}", user_id)
+ return jsonify({"success": True})
+ else:
+ return jsonify({"error": "文件不存在"}), 404
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+@app.route('/api/screenshots/clear', methods=['POST'])
+@login_required
+def clear_all_screenshots():
+ """清空当前用户的所有截图"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ try:
+ deleted_count = 0
+ if os.path.exists(SCREENSHOTS_DIR):
+ for filename in os.listdir(SCREENSHOTS_DIR):
+ if (filename.lower().endswith(('.png', '.jpg', '.jpeg'))) and filename.startswith(username_prefix + '_'):
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ os.remove(filepath)
+ deleted_count += 1
+ log_to_client(f"清理了 {deleted_count} 个截图文件", user_id)
+ return jsonify({"success": True, "deleted": deleted_count})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+# ==================== WebSocket事件 ====================
+
+@socketio.on('connect')
+def handle_connect():
+ """客户端连接"""
+ if current_user.is_authenticated:
+ user_id = current_user.id
+ join_room(f'user_{user_id}')
+ log_to_client("客户端已连接", user_id)
+
+ # 发送账号列表
+ accounts = user_accounts.get(user_id, {})
+ emit('accounts_list', [acc.to_dict() for acc in accounts.values()])
+
+ # 发送历史日志
+ if user_id in log_cache:
+ for log_entry in log_cache[user_id]:
+ emit('log', log_entry)
+
+
+@socketio.on('disconnect')
+def handle_disconnect():
+ """客户端断开"""
+ if current_user.is_authenticated:
+ user_id = current_user.id
+ leave_room(f'user_{user_id}')
+
+
+# ==================== 静态文件 ====================
+
+@app.route('/static/')
+def serve_static(filename):
+ """提供静态文件访问"""
+ return send_from_directory('static', filename)
+
+
+# ==================== 启动 ====================
+
+
+# ==================== 管理员VIP管理API ====================
+
+@app.route('/yuyx/api/vip/config', methods=['GET'])
+def get_vip_config_api():
+ """获取VIP配置"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+ config = database.get_vip_config()
+ return jsonify(config)
+
+
+@app.route('/yuyx/api/vip/config', methods=['POST'])
+def set_vip_config_api():
+ """设置默认VIP天数"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ data = request.json
+ days = data.get('default_vip_days', 0)
+
+ if not isinstance(days, int) or days < 0:
+ return jsonify({"error": "VIP天数必须是非负整数"}), 400
+
+ database.set_default_vip_days(days)
+ return jsonify({"message": "VIP配置已更新", "default_vip_days": days})
+
+
+@app.route('/yuyx/api/users//vip', methods=['POST'])
+def set_user_vip_api(user_id):
+ """设置用户VIP"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ data = request.json
+ days = data.get('days', 30)
+
+ # 验证days参数
+ valid_days = [7, 30, 365, 999999]
+ if days not in valid_days:
+ return jsonify({"error": "VIP天数必须是 7/30/365/999999 之一"}), 400
+
+ if database.set_user_vip(user_id, days):
+ vip_type = {7: "一周", 30: "一个月", 365: "一年", 999999: "永久"}[days]
+ return jsonify({"message": f"VIP设置成功: {vip_type}"})
+ return jsonify({"error": "设置失败,用户不存在"}), 400
+
+
+@app.route('/yuyx/api/users//vip', methods=['DELETE'])
+def remove_user_vip_api(user_id):
+ """移除用户VIP"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ if database.remove_user_vip(user_id):
+ return jsonify({"message": "VIP已移除"})
+ return jsonify({"error": "移除失败"}), 400
+
+
+@app.route('/yuyx/api/users//vip', methods=['GET'])
+def get_user_vip_info_api(user_id):
+ """获取用户VIP信息(管理员)"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ vip_info = database.get_user_vip_info(user_id)
+ return jsonify(vip_info)
+
+
+
+# ==================== 用户端VIP查询API ====================
+
+@app.route('/api/user/vip', methods=['GET'])
+@login_required
+def get_current_user_vip():
+ """获取当前用户VIP信息"""
+ vip_info = database.get_user_vip_info(current_user.id)
+ return jsonify(vip_info)
+
+
+@app.route('/api/run_stats', methods=['GET'])
+@login_required
+def get_run_stats():
+ """获取当前用户的运行统计"""
+ user_id = current_user.id
+
+ # 获取今日任务统计
+ stats = database.get_user_run_stats(user_id)
+
+ # 计算当前正在运行的账号数
+ current_running = 0
+ if user_id in user_accounts:
+ current_running = sum(1 for acc in user_accounts[user_id].values() if acc.is_running)
+
+ return jsonify({
+ 'today_completed': stats.get('completed', 0),
+ 'current_running': current_running,
+ 'today_failed': stats.get('failed', 0),
+ 'today_items': stats.get('total_items', 0),
+ 'today_attachments': stats.get('total_attachments', 0)
+ })
+
+
+# ==================== 系统配置API ====================
+
+@app.route('/yuyx/api/system/config', methods=['GET'])
+@admin_required
+def get_system_config_api():
+ """获取系统配置"""
+ config = database.get_system_config()
+ return jsonify(config)
+
+
+@app.route('/yuyx/api/system/config', methods=['POST'])
+@admin_required
+def update_system_config_api():
+ """更新系统配置"""
+ global max_concurrent_global, global_semaphore, max_concurrent_per_account
+
+ data = request.json
+ max_concurrent = data.get('max_concurrent_global')
+ schedule_enabled = data.get('schedule_enabled')
+ schedule_time = data.get('schedule_time')
+ schedule_browse_type = data.get('schedule_browse_type')
+ schedule_weekdays = data.get('schedule_weekdays')
+ new_max_concurrent_per_account = data.get('max_concurrent_per_account')
+
+ # 验证参数
+ if max_concurrent is not None:
+ if not isinstance(max_concurrent, int) or max_concurrent < 1 or max_concurrent > 20:
+ return jsonify({"error": "全局并发数必须在1-20之间"}), 400
+
+ if new_max_concurrent_per_account is not None:
+ if not isinstance(new_max_concurrent_per_account, int) or new_max_concurrent_per_account < 1 or new_max_concurrent_per_account > 5:
+ return jsonify({"error": "单账号并发数必须在1-5之间"}), 400
+
+ if schedule_time is not None:
+ # 验证时间格式 HH:MM
+ import re
+ if not re.match(r'^([01]\d|2[0-3]):([0-5]\d)$', schedule_time):
+ return jsonify({"error": "时间格式错误,应为 HH:MM"}), 400
+
+ if schedule_browse_type is not None:
+ if schedule_browse_type not in ['注册前未读', '应读', '未读']:
+ return jsonify({"error": "浏览类型无效"}), 400
+
+ if schedule_weekdays is not None:
+ # 验证星期格式,应该是逗号分隔的数字字符串 "1,2,3,4,5,6,7"
+ try:
+ days = [int(d.strip()) for d in schedule_weekdays.split(',') if d.strip()]
+ if not all(1 <= d <= 7 for d in days):
+ return jsonify({"error": "星期数字必须在1-7之间"}), 400
+ except (ValueError, AttributeError):
+ return jsonify({"error": "星期格式错误"}), 400
+
+ # 更新数据库
+ if database.update_system_config(
+ max_concurrent=max_concurrent,
+ schedule_enabled=schedule_enabled,
+ schedule_time=schedule_time,
+ schedule_browse_type=schedule_browse_type,
+ schedule_weekdays=schedule_weekdays,
+ max_concurrent_per_account=new_max_concurrent_per_account
+ ):
+ # 如果修改了并发数,更新全局变量和信号量
+ if max_concurrent is not None and max_concurrent != max_concurrent_global:
+ max_concurrent_global = max_concurrent
+ global_semaphore = threading.Semaphore(max_concurrent)
+ print(f"全局并发数已更新为: {max_concurrent}")
+
+ # 如果修改了单用户并发数,更新全局变量(已有的信号量会在下次创建时使用新值)
+ if new_max_concurrent_per_account is not None and new_max_concurrent_per_account != max_concurrent_per_account:
+ max_concurrent_per_account = new_max_concurrent_per_account
+ print(f"单用户并发数已更新为: {max_concurrent_per_account}")
+
+ return jsonify({"message": "系统配置已更新"})
+
+ return jsonify({"error": "更新失败"}), 400
+
+
+
+
+# ==================== 代理配置API ====================
+
+@app.route('/yuyx/api/proxy/config', methods=['GET'])
+@admin_required
+def get_proxy_config_api():
+ """获取代理配置"""
+ config = database.get_system_config()
+ return jsonify({
+ 'proxy_enabled': config.get('proxy_enabled', 0),
+ 'proxy_api_url': config.get('proxy_api_url', ''),
+ 'proxy_expire_minutes': config.get('proxy_expire_minutes', 3)
+ })
+
+
+@app.route('/yuyx/api/proxy/config', methods=['POST'])
+@admin_required
+def update_proxy_config_api():
+ """更新代理配置"""
+ data = request.json
+ proxy_enabled = data.get('proxy_enabled')
+ proxy_api_url = data.get('proxy_api_url', '').strip()
+ proxy_expire_minutes = data.get('proxy_expire_minutes')
+
+ if proxy_enabled is not None and proxy_enabled not in [0, 1]:
+ return jsonify({"error": "proxy_enabled必须是0或1"}), 400
+
+ if proxy_expire_minutes is not None:
+ if not isinstance(proxy_expire_minutes, int) or proxy_expire_minutes < 1:
+ return jsonify({"error": "代理有效期必须是大于0的整数"}), 400
+
+ if database.update_system_config(
+ proxy_enabled=proxy_enabled,
+ proxy_api_url=proxy_api_url,
+ proxy_expire_minutes=proxy_expire_minutes
+ ):
+ return jsonify({"message": "代理配置已更新"})
+
+ return jsonify({"error": "更新失败"}), 400
+
+
+@app.route('/yuyx/api/proxy/test', methods=['POST'])
+@admin_required
+def test_proxy_api():
+ """测试代理连接"""
+ data = request.json
+ api_url = data.get('api_url', '').strip()
+
+ if not api_url:
+ return jsonify({"error": "请提供API地址"}), 400
+
+ try:
+ response = requests.get(api_url, timeout=10)
+ if response.status_code == 200:
+ ip_port = response.text.strip()
+ if ip_port and ':' in ip_port:
+ return jsonify({
+ "success": True,
+ "proxy": ip_port,
+ "message": f"代理获取成功: {ip_port}"
+ })
+ else:
+ return jsonify({
+ "success": False,
+ "message": f"代理格式错误: {ip_port}"
+ }), 400
+ else:
+ return jsonify({
+ "success": False,
+ "message": f"HTTP错误: {response.status_code}"
+ }), 400
+ except Exception as e:
+ return jsonify({
+ "success": False,
+ "message": f"连接失败: {str(e)}"
+ }), 500
+
+# ==================== 服务器信息API ====================
+
+@app.route('/yuyx/api/server/info', methods=['GET'])
+@admin_required
+def get_server_info_api():
+ """获取服务器信息"""
+ import psutil
+ import datetime
+
+ # CPU使用率
+ cpu_percent = psutil.cpu_percent(interval=1)
+
+ # 内存信息
+ memory = psutil.virtual_memory()
+ memory_total = f"{memory.total / (1024**3):.1f}GB"
+ memory_used = f"{memory.used / (1024**3):.1f}GB"
+ memory_percent = memory.percent
+
+ # 磁盘信息
+ disk = psutil.disk_usage('/')
+ disk_total = f"{disk.total / (1024**3):.1f}GB"
+ disk_used = f"{disk.used / (1024**3):.1f}GB"
+ disk_percent = disk.percent
+
+ # 运行时长
+ boot_time = datetime.datetime.fromtimestamp(psutil.boot_time())
+ uptime_delta = datetime.datetime.now() - boot_time
+ days = uptime_delta.days
+ hours = uptime_delta.seconds // 3600
+ uptime = f"{days}天{hours}小时"
+
+ return jsonify({
+ 'cpu_percent': cpu_percent,
+ 'memory_total': memory_total,
+ 'memory_used': memory_used,
+ 'memory_percent': memory_percent,
+ 'disk_total': disk_total,
+ 'disk_used': disk_used,
+ 'disk_percent': disk_percent,
+ 'uptime': uptime
+ })
+
+
+# ==================== 任务统计和日志API ====================
+
+@app.route('/yuyx/api/task/stats', methods=['GET'])
+@admin_required
+def get_task_stats_api():
+ """获取任务统计数据"""
+ date_filter = request.args.get('date') # YYYY-MM-DD格式
+ stats = database.get_task_stats(date_filter)
+ return jsonify(stats)
+
+
+@app.route('/yuyx/api/task/logs', methods=['GET'])
+@admin_required
+def get_task_logs_api():
+ """获取任务日志列表"""
+ limit = int(request.args.get('limit', 100))
+ offset = int(request.args.get('offset', 0))
+ date_filter = request.args.get('date') # YYYY-MM-DD格式
+ status_filter = request.args.get('status') # success/failed
+
+ logs = database.get_task_logs(limit, offset, date_filter, status_filter)
+ return jsonify(logs)
+
+
+@app.route('/yuyx/api/task/logs/clear', methods=['POST'])
+@admin_required
+def clear_old_task_logs_api():
+ """清理旧的任务日志"""
+ data = request.json or {}
+ days = data.get('days', 30)
+
+ if not isinstance(days, int) or days < 1:
+ return jsonify({"error": "天数必须是大于0的整数"}), 400
+
+ deleted_count = database.delete_old_task_logs(days)
+ return jsonify({"message": f"已删除{days}天前的{deleted_count}条日志"})
+
+
+# ==================== 定时任务调度器 ====================
+
+def scheduled_task_worker():
+ """定时任务工作线程"""
+ import schedule
+ from datetime import datetime
+
+ def run_all_accounts_task():
+ """执行所有账号的浏览任务(过滤重复账号)"""
+ try:
+ config = database.get_system_config()
+ browse_type = config.get('schedule_browse_type', '应读')
+
+ # 检查今天是否在允许执行的星期列表中
+ from datetime import datetime
+ import pytz
+
+ # 获取北京时间的星期几 (1=周一, 7=周日)
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ now_beijing = datetime.now(beijing_tz)
+ current_weekday = now_beijing.isoweekday() # 1-7
+
+ # 获取配置的星期列表
+ schedule_weekdays = config.get('schedule_weekdays', '1,2,3,4,5,6,7')
+ allowed_weekdays = [int(d.strip()) for d in schedule_weekdays.split(',') if d.strip()]
+
+ if current_weekday not in allowed_weekdays:
+ weekday_names = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
+ print(f"[定时任务] 今天是{weekday_names[current_weekday]},不在执行日期内,跳过执行")
+ return
+
+ print(f"[定时任务] 开始执行 - 浏览类型: {browse_type}")
+
+ # 获取所有已审核用户的所有账号
+ all_users = database.get_all_users()
+ approved_users = [u for u in all_users if u['status'] == 'approved']
+
+ # 用于记录已执行的账号用户名,避免重复
+ executed_usernames = set()
+ total_accounts = 0
+ skipped_duplicates = 0
+ executed_accounts = 0
+
+ for user in approved_users:
+ user_id = user['id']
+ if user_id not in user_accounts:
+ load_user_accounts(user_id)
+
+ accounts = user_accounts.get(user_id, {})
+ for account_id, account in accounts.items():
+ total_accounts += 1
+
+ # 跳过正在运行的账号
+ if account.is_running:
+ continue
+
+ # 检查账号用户名是否已经执行过(重复账号过滤)
+ if account.username in executed_usernames:
+ skipped_duplicates += 1
+ print(f"[定时任务] 跳过重复账号: {account.username} (用户:{user['username']}) - 该账号已被其他用户执行")
+ continue
+
+ # 记录该账号用户名,避免后续重复执行
+ executed_usernames.add(account.username)
+
+ print(f"[定时任务] 启动账号: {account.username} (用户:{user['username']})")
+
+ # 启动任务
+ account.is_running = True
+ account.should_stop = False
+ account.status = "运行中"
+
+ # 获取系统配置的截图开关
+ config = database.get_system_config()
+ enable_screenshot_scheduled = config.get("enable_screenshot", 0) == 1
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot_scheduled),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+ executed_accounts += 1
+
+ # 发送更新到用户
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ # 间隔启动,避免瞬间并发过高
+ time.sleep(2)
+
+ print(f"[定时任务] 执行完成 - 总账号数:{total_accounts}, 已执行:{executed_accounts}, 跳过重复:{skipped_duplicates}")
+
+ except Exception as e:
+ print(f"[定时任务] 执行出错: {str(e)}")
+
+ def cleanup_expired_captcha():
+ """清理过期验证码,防止内存泄漏"""
+ try:
+ current_time = time.time()
+ expired_keys = [k for k, v in captcha_storage.items()
+ if v["expire_time"] < current_time]
+ deleted_count = len(expired_keys)
+ for k in expired_keys:
+ del captcha_storage[k]
+ if deleted_count > 0:
+ print(f"[定时清理] 已清理 {deleted_count} 个过期验证码")
+ except Exception as e:
+ print(f"[定时清理] 清理验证码出错: {str(e)}")
+
+ def cleanup_old_data():
+ """清理7天前的截图和日志"""
+ try:
+ print(f"[定时清理] 开始清理7天前的数据...")
+
+ # 清理7天前的任务日志
+ deleted_logs = database.delete_old_task_logs(7)
+ print(f"[定时清理] 已删除 {deleted_logs} 条任务日志")
+
+ # 清理30天前的操作日志
+ deleted_operation_logs = database.clean_old_operation_logs(30)
+ print(f"[定时清理] 已删除 {deleted_operation_logs} 条操作日志")
+ # 清理7天前的截图
+ deleted_screenshots = 0
+ if os.path.exists(SCREENSHOTS_DIR):
+ cutoff_time = time.time() - (7 * 24 * 60 * 60) # 7天前的时间戳
+ for filename in os.listdir(SCREENSHOTS_DIR):
+ if filename.lower().endswith(('.png', '.jpg', '.jpeg')):
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ try:
+ # 检查文件修改时间
+ if os.path.getmtime(filepath) < cutoff_time:
+ os.remove(filepath)
+ deleted_screenshots += 1
+ except Exception as e:
+ print(f"[定时清理] 删除截图失败 {filename}: {str(e)}")
+
+ print(f"[定时清理] 已删除 {deleted_screenshots} 个截图文件")
+ print(f"[定时清理] 清理完成!")
+
+ except Exception as e:
+ print(f"[定时清理] 清理任务出错: {str(e)}")
+
+ # 每分钟检查一次配置
+ def check_and_schedule():
+ config = database.get_system_config()
+
+ # 清除旧的任务
+ schedule.clear()
+
+ # 时区转换函数:将CST时间转换为UTC时间(容器使用UTC)
+ def cst_to_utc_time(cst_time_str):
+ """将CST时间字符串(HH:MM)转换为UTC时间字符串
+
+ Args:
+ cst_time_str: CST时间字符串,格式为 HH:MM
+
+ Returns:
+ UTC时间字符串,格式为 HH:MM
+ """
+ from datetime import datetime, timedelta
+ # 解析CST时间
+ hour, minute = map(int, cst_time_str.split(':'))
+ # CST是UTC+8,所以UTC时间 = CST时间 - 8小时
+ utc_hour = (hour - 8) % 24
+ return f"{utc_hour:02d}:{minute:02d}"
+
+ # 始终添加每天凌晨3点(CST)的数据清理任务
+ cleanup_utc_time = cst_to_utc_time("03:00")
+ schedule.every().day.at(cleanup_utc_time).do(cleanup_old_data)
+ print(f"[定时任务] 已设置数据清理任务: 每天 CST 03:00 (UTC {cleanup_utc_time})")
+
+ # 每小时清理过期验证码
+ schedule.every().hour.do(cleanup_expired_captcha)
+ print(f"[定时任务] 已设置验证码清理任务: 每小时执行一次")
+
+ # 如果启用了定时浏览任务,则添加
+ if config.get('schedule_enabled'):
+ schedule_time_cst = config.get('schedule_time', '02:00')
+ schedule_time_utc = cst_to_utc_time(schedule_time_cst)
+ schedule.every().day.at(schedule_time_utc).do(run_all_accounts_task)
+ print(f"[定时任务] 已设置浏览任务: 每天 CST {schedule_time_cst} (UTC {schedule_time_utc})")
+
+ # 初始检查
+ check_and_schedule()
+ last_check = time.time()
+
+ while True:
+ try:
+ # 执行待执行的任务
+ schedule.run_pending()
+
+ # 每60秒重新检查一次配置
+ if time.time() - last_check > 60:
+ check_and_schedule()
+ last_check = time.time()
+
+ time.sleep(1)
+ except Exception as e:
+ print(f"[定时任务] 调度器出错: {str(e)}")
+ time.sleep(5)
+
+
+if __name__ == '__main__':
+ print("=" * 60)
+ print("知识管理平台自动化工具 - 多用户版")
+ print("=" * 60)
+
+ # 初始化数据库
+ database.init_database()
+
+ # 加载系统配置(并发设置)
+ try:
+ config = database.get_system_config()
+ if config:
+ # 使用globals()修改全局变量
+ globals()['max_concurrent_global'] = config.get('max_concurrent_global', 2)
+ globals()['max_concurrent_per_account'] = config.get('max_concurrent_per_account', 1)
+
+ # 重新创建信号量
+ globals()['global_semaphore'] = threading.Semaphore(globals()['max_concurrent_global'])
+
+ print(f"✓ 已加载并发配置: 全局={globals()['max_concurrent_global']}, 单账号={globals()['max_concurrent_per_account']}")
+ except Exception as e:
+ print(f"警告: 加载并发配置失败,使用默认值: {e}")
+
+ # 主线程初始化浏览器(Playwright不支持跨线程)
+ print("\n正在初始化浏览器管理器...")
+ init_browser_manager()
+
+ # 启动定时任务调度器
+ print("\n启动定时任务调度器...")
+ scheduler_thread = threading.Thread(target=scheduled_task_worker, daemon=True)
+ scheduler_thread.start()
+ print("✓ 定时任务调度器已启动")
+
+ # 启动Web服务器
+ print("\n服务器启动中...")
+ print("用户访问地址: http://0.0.0.0:5000")
+ print("后台管理地址: http://0.0.0.0:5000/yuyx")
+ print("默认管理员: admin/admin")
+ print("=" * 60 + "\n")
+
+ socketio.run(app, host='0.0.0.0', port=5000, debug=False)
diff --git a/app.py.backup_20251210_013401 b/app.py.backup_20251210_013401
new file mode 100755
index 0000000..bfdbf1e
--- /dev/null
+++ b/app.py.backup_20251210_013401
@@ -0,0 +1,3348 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+知识管理平台自动化工具 - 多用户版本
+支持用户注册登录、后台管理、数据隔离
+"""
+
+# 设置时区为中国标准时间(CST, UTC+8)
+import os
+os.environ['TZ'] = 'Asia/Shanghai'
+try:
+ import time
+ time.tzset()
+except AttributeError:
+ pass # Windows系统不支持tzset()
+
+import pytz
+from datetime import datetime
+from flask import Flask, render_template, request, jsonify, send_from_directory, redirect, url_for, session
+from flask_socketio import SocketIO, emit, join_room, leave_room
+from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
+import threading
+import time
+import json
+import os
+from datetime import datetime, timedelta, timezone
+from functools import wraps
+
+# 导入数据库模块和核心模块
+import database
+import requests
+from browser_pool import get_browser_pool, init_browser_pool
+from browser_pool_worker import get_browser_worker_pool, init_browser_worker_pool, shutdown_browser_worker_pool
+from playwright_automation import PlaywrightBrowserManager, PlaywrightAutomation, BrowseResult
+from api_browser import APIBrowser, APIBrowseResult
+from browser_installer import check_and_install_browser
+# ========== 优化模块导入 ==========
+from app_config import get_config
+from app_logger import init_logging, get_logger, audit_logger
+from app_security import (
+ ip_rate_limiter, require_ip_not_locked,
+ validate_username, validate_password, validate_email,
+ is_safe_path, sanitize_filename, get_client_ip
+)
+from app_utils import verify_and_consume_captcha
+
+
+
+# ========== 初始化配置 ==========
+config = get_config()
+app = Flask(__name__)
+app.config.from_object(config)
+# 确保SECRET_KEY已设置
+if not app.config.get('SECRET_KEY'):
+ raise RuntimeError("SECRET_KEY未配置,请检查app_config.py")
+socketio = SocketIO(
+ app,
+ cors_allowed_origins="*",
+ async_mode='threading', # 明确指定async模式
+ ping_timeout=60, # ping超时60秒
+ ping_interval=25, # 每25秒ping一次
+ logger=False, # 禁用socketio debug日志
+ engineio_logger=False
+)
+
+# ========== 初始化日志系统 ==========
+init_logging(log_level=config.LOG_LEVEL, log_file=config.LOG_FILE)
+logger = get_logger('app')
+logger.info("="*60)
+logger.info("知识管理平台自动化工具 - 多用户版")
+logger.info("="*60)
+logger.info(f"Session配置: COOKIE_NAME={app.config.get('SESSION_COOKIE_NAME', 'session')}, "
+ f"SAMESITE={app.config.get('SESSION_COOKIE_SAMESITE', 'None')}, "
+ f"HTTPONLY={app.config.get('SESSION_COOKIE_HTTPONLY', 'None')}, "
+ f"SECURE={app.config.get('SESSION_COOKIE_SECURE', 'None')}, "
+ f"PATH={app.config.get('SESSION_COOKIE_PATH', 'None')}")
+
+
+# Flask-Login 配置
+login_manager = LoginManager()
+login_manager.init_app(app)
+login_manager.login_view = 'login_page'
+
+@login_manager.unauthorized_handler
+def unauthorized():
+ """处理未授权访问 - API请求返回JSON,页面请求重定向"""
+ if request.path.startswith('/api/') or request.path.startswith('/yuyx/api/'):
+ return jsonify({"error": "请先登录", "code": "unauthorized"}), 401
+ return redirect(url_for('login_page', next=request.url))
+
+# 截图目录
+SCREENSHOTS_DIR = config.SCREENSHOTS_DIR
+os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
+
+# 全局变量
+browser_manager = None
+user_accounts = {} # {user_id: {account_id: Account对象}}
+active_tasks = {} # {account_id: Thread对象}
+task_status = {} # {account_id: {"user_id": x, "username": y, "status": "排队中/运行中", "detail_status": "具体状态", "browse_type": z, "start_time": t, "source": s, "progress": {...}, "is_vip": bool}}
+
+# VIP优先级队列
+vip_task_queue = [] # VIP用户任务队列
+normal_task_queue = [] # 普通用户任务队列
+task_queue_lock = threading.Lock()
+log_cache = {} # {user_id: [logs]} 每个用户独立的日志缓存
+log_cache_total_count = 0 # 全局日志总数,防止无限增长
+
+# 日志缓存限制
+MAX_LOGS_PER_USER = config.MAX_LOGS_PER_USER # 每个用户最多100条
+MAX_TOTAL_LOGS = config.MAX_TOTAL_LOGS # 全局最多1000条,防止内存泄漏
+
+# 并发控制:每个用户同时最多运行1个账号(避免内存不足)
+# 验证码存储:{session_id: {"code": "1234", "expire_time": timestamp, "failed_attempts": 0}}
+captcha_storage = {}
+
+# IP限流存储:{ip: {"attempts": count, "lock_until": timestamp, "first_attempt": timestamp}}
+ip_rate_limit = {}
+
+# 限流配置 - 从 config 读取,避免硬编码
+MAX_CAPTCHA_ATTEMPTS = config.MAX_CAPTCHA_ATTEMPTS
+MAX_IP_ATTEMPTS_PER_HOUR = config.MAX_IP_ATTEMPTS_PER_HOUR
+IP_LOCK_DURATION = config.IP_LOCK_DURATION
+# 全局限制:整个系统同时最多运行N个账号(线程本地架构,每个线程独立浏览器,内存占用约200MB/浏览器)
+max_concurrent_per_account = config.MAX_CONCURRENT_PER_ACCOUNT
+max_concurrent_global = config.MAX_CONCURRENT_GLOBAL
+user_semaphores = {} # {user_id: Semaphore}
+global_semaphore = threading.Semaphore(max_concurrent_global)
+
+# 截图专用信号量:限制同时进行的截图任务数量为1(避免资源竞争)
+# ���图信号量将在首次使用时初始化
+screenshot_semaphore = None
+screenshot_semaphore_lock = threading.Lock()
+
+def get_screenshot_semaphore():
+ """获取截图信号量(懒加载,根据配置动态创建)"""
+ global screenshot_semaphore
+ with screenshot_semaphore_lock:
+ config = database.get_system_config()
+ max_concurrent = config.get('max_screenshot_concurrent', 3)
+ if screenshot_semaphore is None:
+ screenshot_semaphore = threading.Semaphore(max_concurrent)
+ return screenshot_semaphore, max_concurrent
+
+
+class User(UserMixin):
+ """Flask-Login 用户类"""
+ def __init__(self, user_id):
+ self.id = user_id
+
+
+class Admin(UserMixin):
+ """管理员类"""
+ def __init__(self, admin_id):
+ self.id = admin_id
+ self.is_admin = True
+
+
+class Account:
+ """账号类"""
+ def __init__(self, account_id, user_id, username, password, remember=True, remark=''):
+ self.id = account_id
+ self.user_id = user_id
+ self.username = username
+ self.password = password
+ self.remember = remember
+ self.remark = remark
+ self.status = "未开始"
+ self.is_running = False
+ self.should_stop = False
+ self.total_items = 0
+ self.total_attachments = 0
+ self.automation = None
+ self.last_browse_type = "注册前未读"
+ self.proxy_config = None # 保存代理配置,浏览和截图共用
+
+ def to_dict(self):
+ result = {
+ "id": self.id,
+ "username": self.username,
+ "status": self.status,
+ "remark": self.remark,
+ "total_items": self.total_items,
+ "total_attachments": self.total_attachments,
+ "is_running": self.is_running
+ }
+ # 添加详细进度信息(如果有)
+ if self.id in task_status:
+ ts = task_status[self.id]
+ progress = ts.get('progress', {})
+ result['detail_status'] = ts.get('detail_status', '')
+ result['progress_items'] = progress.get('items', 0)
+ result['progress_attachments'] = progress.get('attachments', 0)
+ result['start_time'] = ts.get('start_time', 0)
+ # 计算运行时长
+ if ts.get('start_time'):
+ import time
+ elapsed = int(time.time() - ts['start_time'])
+ result['elapsed_seconds'] = elapsed
+ mins, secs = divmod(elapsed, 60)
+ result['elapsed_display'] = f"{mins}分{secs}秒"
+ else:
+ # 非运行状态下,根据status设置detail_status
+ status_map = {
+ '已完成': '任务完成',
+ '截图中': '正在截图',
+ '浏览完成': '浏览完成',
+ '登录失败': '登录失败',
+ '已暂停': '任务已暂停'
+ }
+ for key, val in status_map.items():
+ if key in self.status:
+ result['detail_status'] = val
+ break
+ return result
+
+
+@login_manager.user_loader
+def load_user(user_id):
+ """Flask-Login 用户加载"""
+ user = database.get_user_by_id(int(user_id))
+ if user:
+ return User(user['id'])
+ return None
+
+
+def admin_required(f):
+ """管理员权限装饰器"""
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ logger.debug(f"[admin_required] Session内容: {dict(session)}")
+ logger.debug(f"[admin_required] Cookies: {request.cookies}")
+ if 'admin_id' not in session:
+ logger.warning(f"[admin_required] 拒绝访问 {request.path} - session中无admin_id")
+ return jsonify({"error": "需要管理员权限"}), 403
+ logger.info(f"[admin_required] 管理员 {session.get('admin_username')} 访问 {request.path}")
+ return f(*args, **kwargs)
+ return decorated_function
+
+
+def log_to_client(message, user_id=None, account_id=None):
+ """发送日志到Web客户端(用户隔离)"""
+ beijing_tz = timezone(timedelta(hours=8))
+ timestamp = datetime.now(beijing_tz).strftime('%H:%M:%S')
+ log_data = {
+ 'timestamp': timestamp,
+ 'message': message,
+ 'account_id': account_id
+ }
+
+ # 如果指定了user_id,则缓存到该用户的日志
+ if user_id:
+ global log_cache_total_count
+ if user_id not in log_cache:
+ log_cache[user_id] = []
+ log_cache[user_id].append(log_data)
+ log_cache_total_count += 1
+
+ # 持久化到数据库 (已禁用,使用task_logs表代替)
+ # try:
+ # database.save_operation_log(user_id, message, account_id, 'INFO')
+ # except Exception as e:
+ # print(f"保存日志到数据库失败: {e}")
+
+ # 单用户限制
+ if len(log_cache[user_id]) > MAX_LOGS_PER_USER:
+ log_cache[user_id].pop(0)
+ log_cache_total_count -= 1
+
+ # 全局限制 - 如果超过总数限制,清理日志最多的用户
+ while log_cache_total_count > MAX_TOTAL_LOGS:
+ if log_cache:
+ max_user = max(log_cache.keys(), key=lambda u: len(log_cache[u]))
+ if log_cache[max_user]:
+ log_cache[max_user].pop(0)
+ log_cache_total_count -= 1
+ else:
+ break
+ else:
+ break
+
+ # 发送到该用户的room
+ socketio.emit('log', log_data, room=f'user_{user_id}')
+
+ # 控制台日志:添加账号短标识便于区分
+ if account_id:
+ # 显示账号ID前4位作为标识
+ short_id = account_id[:4] if len(account_id) >= 4 else account_id
+ print(f"[{timestamp}] U{user_id}:{short_id} | {message}")
+ else:
+ print(f"[{timestamp}] U{user_id} | {message}")
+
+
+
+def get_proxy_from_api(api_url, max_retries=3):
+ """从API获取代理IP(支持重试)
+
+ Args:
+ api_url: 代理API地址
+ max_retries: 最大重试次数
+
+ Returns:
+ 代理服务器地址(格式: http://IP:PORT)或 None
+ """
+ import re
+ # IP:PORT 格式正则
+ ip_port_pattern = re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{1,5}$')
+
+ for attempt in range(max_retries):
+ try:
+ response = requests.get(api_url, timeout=10)
+ if response.status_code == 200:
+ text = response.text.strip()
+
+ # 尝试解析JSON响应
+ try:
+ import json
+ data = json.loads(text)
+ # 检查是否是错误响应
+ if isinstance(data, dict):
+ if data.get('status') != 200 and data.get('status') != 0:
+ error_msg = data.get('msg', data.get('message', '未知错误'))
+ print(f"✗ 代理API返回错误: {error_msg} (尝试 {attempt + 1}/{max_retries})")
+ if attempt < max_retries - 1:
+ time.sleep(1)
+ continue
+ # 尝试从JSON中获取IP
+ ip_port = data.get('ip') or data.get('proxy') or data.get('data')
+ if ip_port:
+ text = str(ip_port).strip()
+ except (json.JSONDecodeError, ValueError):
+ # 不是JSON,继续使用原始文本
+ pass
+
+ # 验证IP:PORT格式
+ if ip_port_pattern.match(text):
+ proxy_server = f"http://{text}"
+ print(f"✓ 获取代理成功: {proxy_server} (尝试 {attempt + 1}/{max_retries})")
+ return proxy_server
+ else:
+ print(f"✗ 代理格式无效: {text[:50]} (尝试 {attempt + 1}/{max_retries})")
+ else:
+ print(f"✗ 获取代理失败: HTTP {response.status_code} (尝试 {attempt + 1}/{max_retries})")
+ except Exception as e:
+ print(f"✗ 获取代理异常: {str(e)} (尝试 {attempt + 1}/{max_retries})")
+
+ if attempt < max_retries - 1:
+ time.sleep(1)
+
+ print(f"✗ 获取代理失败,已重试 {max_retries} 次,将不使用代理继续")
+ return None
+
+def init_browser_manager():
+ """初始化浏览器管理器"""
+ global browser_manager
+ if browser_manager is None:
+ print("正在初始化Playwright浏览器管理器...")
+
+ if not check_and_install_browser(log_callback=lambda msg, account_id=None: print(msg)):
+ print("浏览器环境检查失败!")
+ return False
+
+ browser_manager = PlaywrightBrowserManager(
+ headless=True,
+ log_callback=lambda msg, account_id=None: print(msg)
+ )
+
+ try:
+ # 不再需要initialize(),每个账号会创建独立浏览器
+ print("Playwright浏览器管理器创建成功!")
+ return True
+ except Exception as e:
+ print(f"Playwright初始化失败: {str(e)}")
+ return False
+ return True
+
+
+# ==================== 前端路由 ====================
+
+@app.route('/')
+def index():
+ """主页 - 重定向到登录或应用"""
+ if current_user.is_authenticated:
+ return redirect(url_for('app_page'))
+ return redirect(url_for('login_page'))
+
+
+@app.route('/login')
+def login_page():
+ """登录页面"""
+ return render_template('login.html')
+
+
+@app.route('/register')
+def register_page():
+ """注册页面"""
+ return render_template('register.html')
+
+
+@app.route('/app')
+@login_required
+def app_page():
+ """主应用页面"""
+ return render_template('index.html')
+
+
+@app.route('/yuyx')
+def admin_login_page():
+ """后台登录页面"""
+ if 'admin_id' in session:
+ return redirect(url_for('admin_page'))
+ return render_template('admin_login.html')
+
+
+@app.route('/yuyx/admin')
+@admin_required
+def admin_page():
+ """后台管理页面"""
+ return render_template('admin.html')
+
+
+
+
+@app.route('/yuyx/vip')
+@admin_required
+def vip_admin_page():
+ """VIP管理页面"""
+ return render_template('vip_admin.html')
+
+
+# ==================== 用户认证API ====================
+
+@app.route('/api/register', methods=['POST'])
+@require_ip_not_locked # IP限流保护
+def register():
+ """用户注册"""
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ email = data.get('email', '').strip()
+ captcha_session = data.get('captcha_session', '')
+ captcha_code = data.get('captcha', '').strip()
+
+ if not username or not password:
+ return jsonify({"error": "用户名和密码不能为空"}), 400
+
+ # 获取客户端IP(用于IP限流检查)
+ client_ip = get_client_ip()
+
+ # 检查IP限流
+ allowed, error_msg = check_ip_rate_limit(client_ip)
+ if not allowed:
+ return jsonify({"error": error_msg}), 429
+
+ # 验证验证码
+ success, message = verify_and_consume_captcha(captcha_session, captcha_code, captcha_storage, MAX_CAPTCHA_ATTEMPTS)
+ if not success:
+ # 验证失败,记录IP失败尝试(注册特有的IP限流逻辑)
+ is_locked = record_failed_captcha(client_ip)
+ if is_locked:
+ return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
+ return jsonify({"error": message}), 400
+
+ user_id = database.create_user(username, password, email)
+ if user_id:
+ return jsonify({"success": True, "message": "注册成功,请等待管理员审核"})
+ else:
+ return jsonify({"error": "用户名已存在"}), 400
+
+
+# ==================== 验证码API ====================
+import random
+from task_checkpoint import get_checkpoint_manager, TaskStage
+
+checkpoint_mgr = None # 任务断点管理器
+
+def check_ip_rate_limit(ip_address):
+ """检查IP是否被限流"""
+ current_time = time.time()
+
+ # 清理过期的IP记录
+ expired_ips = [ip for ip, data in ip_rate_limit.items()
+ if data.get("lock_until", 0) < current_time and
+ current_time - data.get("first_attempt", current_time) > 3600]
+ for ip in expired_ips:
+ del ip_rate_limit[ip]
+
+ # 检查IP是否被锁定
+ if ip_address in ip_rate_limit:
+ ip_data = ip_rate_limit[ip_address]
+
+ # 如果IP被锁定且未到解锁时间
+ if ip_data.get("lock_until", 0) > current_time:
+ remaining_time = int(ip_data["lock_until"] - current_time)
+ return False, "IP已被锁定,请{}分钟后再试".format(remaining_time // 60 + 1)
+
+ # 如果超过1小时,重置计数
+ if current_time - ip_data.get("first_attempt", current_time) > 3600:
+ ip_rate_limit[ip_address] = {
+ "attempts": 0,
+ "first_attempt": current_time
+ }
+
+ return True, None
+
+
+def record_failed_captcha(ip_address):
+ """记录验证码失败尝试"""
+ current_time = time.time()
+
+ if ip_address not in ip_rate_limit:
+ ip_rate_limit[ip_address] = {
+ "attempts": 1,
+ "first_attempt": current_time
+ }
+ else:
+ ip_rate_limit[ip_address]["attempts"] += 1
+
+ # 检查是否超过限制
+ if ip_rate_limit[ip_address]["attempts"] >= MAX_IP_ATTEMPTS_PER_HOUR:
+ ip_rate_limit[ip_address]["lock_until"] = current_time + IP_LOCK_DURATION
+ return True # 表示IP已被锁定
+
+ return False # 表示还未锁定
+
+
+@app.route("/api/generate_captcha", methods=["POST"])
+def generate_captcha():
+ """生成4位数字验证码"""
+ import uuid
+ session_id = str(uuid.uuid4())
+
+ # 生成4位随机数字
+ code = "".join([str(random.randint(0, 9)) for _ in range(4)])
+
+ # 存储验证码,5分钟过期
+ captcha_storage[session_id] = {
+ "code": code,
+ "expire_time": time.time() + 300,
+ "failed_attempts": 0
+ }
+
+ # 清理过期验证码
+ expired_keys = [k for k, v in captcha_storage.items() if v["expire_time"] < time.time()]
+ for k in expired_keys:
+ del captcha_storage[k]
+
+ return jsonify({"session_id": session_id, "captcha": code})
+
+
+@app.route('/api/login', methods=['POST'])
+@require_ip_not_locked # IP限流保护
+def login():
+ """用户登录"""
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ captcha_session = data.get('captcha_session', '')
+ captcha_code = data.get('captcha', '').strip()
+ need_captcha = data.get('need_captcha', False)
+
+ # 如果需要验证码,验证验证码
+ if need_captcha:
+ success, message = verify_and_consume_captcha(captcha_session, captcha_code, captcha_storage)
+ if not success:
+ return jsonify({"error": message}), 400
+
+ # 先检查用户是否存在
+ user_exists = database.get_user_by_username(username)
+ if not user_exists:
+ return jsonify({"error": "账号未注册", "need_captcha": True}), 401
+
+ # 检查密码是否正确
+ user = database.verify_user(username, password)
+ if not user:
+ # 密码错误
+ return jsonify({"error": "密码错误", "need_captcha": True}), 401
+
+ # 检查审核状态
+ if user['status'] != 'approved':
+ return jsonify({"error": "账号未审核,请等待管理员审核", "need_captcha": False}), 401
+
+ # 登录成功
+ user_obj = User(user['id'])
+ login_user(user_obj)
+ load_user_accounts(user['id'])
+ return jsonify({"success": True})
+
+
+@app.route('/api/logout', methods=['POST'])
+@login_required
+def logout():
+ """用户登出"""
+ logout_user()
+ return jsonify({"success": True})
+
+
+# ==================== 管理员认证API ====================
+
+@app.route('/yuyx/api/debug-config', methods=['GET'])
+def debug_config():
+ """调试配置信息"""
+ return jsonify({
+ "secret_key_set": bool(app.secret_key),
+ "secret_key_length": len(app.secret_key) if app.secret_key else 0,
+ "session_config": {
+ "SESSION_COOKIE_NAME": app.config.get('SESSION_COOKIE_NAME'),
+ "SESSION_COOKIE_SECURE": app.config.get('SESSION_COOKIE_SECURE'),
+ "SESSION_COOKIE_HTTPONLY": app.config.get('SESSION_COOKIE_HTTPONLY'),
+ "SESSION_COOKIE_SAMESITE": app.config.get('SESSION_COOKIE_SAMESITE'),
+ "PERMANENT_SESSION_LIFETIME": str(app.config.get('PERMANENT_SESSION_LIFETIME')),
+ },
+ "current_session": dict(session),
+ "cookies_received": list(request.cookies.keys())
+ })
+
+
+@app.route('/yuyx/api/login', methods=['POST'])
+@require_ip_not_locked # IP限流保护
+def admin_login():
+ """管理员登录(支持JSON和form-data两种格式)"""
+ # 兼容JSON和form-data两种提交方式
+ if request.is_json:
+ data = request.json
+ else:
+ data = request.form
+
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ captcha_session = data.get('captcha_session', '')
+ captcha_code = data.get('captcha', '').strip()
+ need_captcha = data.get('need_captcha', False)
+
+ # 如果需要验证码,验证验证码
+ if need_captcha:
+ success, message = verify_and_consume_captcha(captcha_session, captcha_code, captcha_storage)
+ if not success:
+ if request.is_json:
+ return jsonify({"error": message}), 400
+ else:
+ return redirect(url_for('admin_login_page'))
+
+ admin = database.verify_admin(username, password)
+ if admin:
+ # 清除旧session,确保干净的状态
+ session.clear()
+ # 设置管理员session
+ session['admin_id'] = admin['id']
+ session['admin_username'] = admin['username']
+ session.permanent = True # 设置为永久会话(使用PERMANENT_SESSION_LIFETIME配置)
+ session.modified = True # 强制标记session为已修改,确保保存
+
+ logger.info(f"[admin_login] 管理员 {username} 登录成功, session已设置: admin_id={admin['id']}")
+ logger.debug(f"[admin_login] Session内容: {dict(session)}")
+ logger.debug(f"[admin_login] Cookie将被设置: name={app.config.get('SESSION_COOKIE_NAME', 'session')}")
+
+ # 根据请求类型返回不同响应
+ if request.is_json:
+ # JSON请求:返回JSON响应(给JavaScript使用)
+ response = jsonify({"success": True, "redirect": "/yuyx/admin"})
+ return response
+ else:
+ # form-data请求:直接重定向到后台页面
+ return redirect(url_for('admin_page'))
+ else:
+ logger.warning(f"[admin_login] 管理员 {username} 登录失败 - 用户名或密码错误")
+ if request.is_json:
+ return jsonify({"error": "管理员用户名或密码错误", "need_captcha": True}), 401
+ else:
+ # form提交失败,重定向回登录页(TODO: 可以添加flash消息)
+ return redirect(url_for('admin_login_page'))
+
+
+@app.route('/yuyx/api/logout', methods=['POST'])
+@admin_required
+def admin_logout():
+ """管理员登出"""
+ session.pop('admin_id', None)
+ session.pop('admin_username', None)
+ return jsonify({"success": True})
+
+
+@app.route('/yuyx/api/users', methods=['GET'])
+@admin_required
+def get_all_users():
+ """获取所有用户"""
+ users = database.get_all_users()
+ return jsonify(users)
+
+
+@app.route('/yuyx/api/users/pending', methods=['GET'])
+@admin_required
+def get_pending_users():
+ """获取待审核用户"""
+ users = database.get_pending_users()
+ return jsonify(users)
+
+
+@app.route('/yuyx/api/users//approve', methods=['POST'])
+@admin_required
+def approve_user_route(user_id):
+ """审核通过用户"""
+ if database.approve_user(user_id):
+ return jsonify({"success": True})
+ return jsonify({"error": "审核失败"}), 400
+
+
+@app.route('/yuyx/api/users//reject', methods=['POST'])
+@admin_required
+def reject_user_route(user_id):
+ """拒绝用户"""
+ if database.reject_user(user_id):
+ return jsonify({"success": True})
+ return jsonify({"error": "拒绝失败"}), 400
+
+
+@app.route('/yuyx/api/users/', methods=['DELETE'])
+@admin_required
+def delete_user_route(user_id):
+ """删除用户"""
+ if database.delete_user(user_id):
+ # 清理内存中的账号数据
+ if user_id in user_accounts:
+ del user_accounts[user_id]
+
+ # 清理用户信号量,防止内存泄漏
+ if user_id in user_semaphores:
+ del user_semaphores[user_id]
+
+ # 清理用户日志缓存,防止内存泄漏
+ global log_cache_total_count
+ if user_id in log_cache:
+ log_cache_total_count -= len(log_cache[user_id])
+ del log_cache[user_id]
+
+ return jsonify({"success": True})
+ return jsonify({"error": "删除失败"}), 400
+
+
+@app.route('/yuyx/api/stats', methods=['GET'])
+@admin_required
+def get_system_stats():
+ """获取系统统计"""
+ stats = database.get_system_stats()
+ # 从session获取管理员用户名
+ stats["admin_username"] = session.get('admin_username', 'admin')
+ return jsonify(stats)
+
+
+@app.route('/yuyx/api/docker_stats', methods=['GET'])
+@admin_required
+def get_docker_stats():
+ """获取Docker容器运行状态"""
+ import subprocess
+
+ docker_status = {
+ 'running': False,
+ 'container_name': 'N/A',
+ 'uptime': 'N/A',
+ 'memory_usage': 'N/A',
+ 'memory_limit': 'N/A',
+ 'memory_percent': 'N/A',
+ 'cpu_percent': 'N/A',
+ 'status': 'Unknown'
+ }
+
+ try:
+ # 检查是否在Docker容器内
+ if os.path.exists('/.dockerenv'):
+ docker_status['running'] = True
+
+ # 获取容器名称
+ try:
+ with open('/etc/hostname', 'r') as f:
+ docker_status['container_name'] = f.read().strip()
+ except Exception as e:
+ logger.debug(f"读取容器名称失败: {e}")
+
+ # 获取内存使用情况 (cgroup v2)
+ try:
+ # 尝试cgroup v2路径
+ if os.path.exists('/sys/fs/cgroup/memory.current'):
+ # Read total memory
+ with open('/sys/fs/cgroup/memory.current', 'r') as f:
+ mem_total = int(f.read().strip())
+
+ # Read cache from memory.stat
+ cache = 0
+ if os.path.exists('/sys/fs/cgroup/memory.stat'):
+ with open('/sys/fs/cgroup/memory.stat', 'r') as f:
+ for line in f:
+ if line.startswith('inactive_file '):
+ cache = int(line.split()[1])
+ break
+
+ # Actual memory = total - cache
+ mem_bytes = mem_total - cache
+ docker_status['memory_usage'] = "{:.2f} MB".format(mem_bytes / 1024 / 1024)
+
+ # 获取内存限制
+ if os.path.exists('/sys/fs/cgroup/memory.max'):
+ with open('/sys/fs/cgroup/memory.max', 'r') as f:
+ limit_str = f.read().strip()
+ if limit_str != 'max':
+ limit_bytes = int(limit_str)
+ docker_status['memory_limit'] = "{:.2f} GB".format(limit_bytes / 1024 / 1024 / 1024)
+ docker_status['memory_percent'] = "{:.2f}%".format(mem_bytes / limit_bytes * 100)
+ # 尝试cgroup v1路径
+ elif os.path.exists('/sys/fs/cgroup/memory/memory.usage_in_bytes'):
+ # 从 memory.stat 读取内存信息
+ mem_bytes = 0
+ if os.path.exists('/sys/fs/cgroup/memory/memory.stat'):
+ with open('/sys/fs/cgroup/memory/memory.stat', 'r') as f:
+ rss = 0
+ cache = 0
+ for line in f:
+ if line.startswith('total_rss '):
+ rss = int(line.split()[1])
+ elif line.startswith('total_cache '):
+ cache = int(line.split()[1])
+ # 使用 RSS + (一部分活跃的cache),更接近docker stats的计算
+ # 但为了准确性,我们只用RSS
+ mem_bytes = rss
+
+ # 如果找不到,则使用总内存减去缓存作为后备
+ if mem_bytes == 0:
+ with open('/sys/fs/cgroup/memory/memory.usage_in_bytes', 'r') as f:
+ total_mem = int(f.read().strip())
+
+ cache = 0
+ if os.path.exists('/sys/fs/cgroup/memory/memory.stat'):
+ with open('/sys/fs/cgroup/memory/memory.stat', 'r') as f:
+ for line in f:
+ if line.startswith('total_inactive_file '):
+ cache = int(line.split()[1])
+ break
+
+ mem_bytes = total_mem - cache
+
+ docker_status['memory_usage'] = "{:.2f} MB".format(mem_bytes / 1024 / 1024)
+
+ # 获取内存限制
+ if os.path.exists('/sys/fs/cgroup/memory/memory.limit_in_bytes'):
+ with open('/sys/fs/cgroup/memory/memory.limit_in_bytes', 'r') as f:
+ limit_bytes = int(f.read().strip())
+ # 检查是否是实际限制(不是默认的超大值)
+ if limit_bytes < 9223372036854771712:
+ docker_status['memory_limit'] = "{:.2f} GB".format(limit_bytes / 1024 / 1024 / 1024)
+ docker_status['memory_percent'] = "{:.2f}%".format(mem_bytes / limit_bytes * 100)
+ except Exception as e:
+ docker_status['memory_usage'] = 'Error: {}'.format(str(e))
+
+ # 获取容器运行时间(基于PID 1的启动时间)
+ try:
+ # Get PID 1 start time
+ with open('/proc/1/stat', 'r') as f:
+ stat_data = f.read().split()
+ starttime_ticks = int(stat_data[21])
+
+ # Get system uptime
+ with open('/proc/uptime', 'r') as f:
+ system_uptime = float(f.read().split()[0])
+
+ # Get clock ticks per second
+ import os as os_module
+ ticks_per_sec = os_module.sysconf(os_module.sysconf_names['SC_CLK_TCK'])
+
+ # Calculate container uptime
+ process_start = starttime_ticks / ticks_per_sec
+ uptime_seconds = int(system_uptime - process_start)
+
+ days = uptime_seconds // 86400
+ hours = (uptime_seconds % 86400) // 3600
+ minutes = (uptime_seconds % 3600) // 60
+
+ if days > 0:
+ docker_status['uptime'] = "{}天 {}小时 {}分钟".format(days, hours, minutes)
+ elif hours > 0:
+ docker_status['uptime'] = "{}小时 {}分钟".format(hours, minutes)
+ else:
+ docker_status['uptime'] = "{}分钟".format(minutes)
+ except Exception as e:
+ logger.debug(f"读取容器运行时间失败: {e}")
+
+ docker_status['status'] = 'Running'
+ else:
+ docker_status['status'] = 'Not in Docker'
+
+ except Exception as e:
+ docker_status['status'] = 'Error: {}'.format(str(e))
+
+ return jsonify(docker_status)
+
+@app.route('/yuyx/api/admin/password', methods=['PUT'])
+@admin_required
+def update_admin_password():
+ """修改管理员密码"""
+ data = request.json
+ new_password = data.get('new_password', '').strip()
+
+ if not new_password:
+ return jsonify({"error": "密码不能为空"}), 400
+
+ username = session.get('admin_username')
+ if database.update_admin_password(username, new_password):
+ return jsonify({"success": True})
+ return jsonify({"error": "修改失败"}), 400
+
+
+@app.route('/yuyx/api/admin/username', methods=['PUT'])
+@admin_required
+def update_admin_username():
+ """修改管理员用户名"""
+ data = request.json
+ new_username = data.get('new_username', '').strip()
+
+ if not new_username:
+ return jsonify({"error": "用户名不能为空"}), 400
+
+ old_username = session.get('admin_username')
+ if database.update_admin_username(old_username, new_username):
+ session['admin_username'] = new_username
+ return jsonify({"success": True})
+ return jsonify({"error": "修改失败,用户名可能已存在"}), 400
+
+
+
+def update_admin_username():
+ """修改管理员用户名"""
+ data = request.json
+ new_username = data.get('new_username', '').strip()
+
+ if not new_username:
+ return jsonify({"error": "用户名不能为空"}), 400
+
+ old_username = session.get('admin_username')
+ if database.update_admin_username(old_username, new_username):
+ session['admin_username'] = new_username
+ return jsonify({"success": True})
+ return jsonify({"error": "用户名已存在"}), 400
+
+
+
+# ==================== 密码重置API ====================
+
+# 管理员直接重置用户密码
+@app.route('/yuyx/api/users//reset_password', methods=['POST'])
+@admin_required
+def admin_reset_password_route(user_id):
+ """管理员直接重置用户密码(无需审核)"""
+ data = request.json
+ new_password = data.get('new_password', '').strip()
+
+ if not new_password:
+ return jsonify({"error": "新密码不能为空"}), 400
+
+ if len(new_password) < 6:
+ return jsonify({"error": "密码长度不能少于6位"}), 400
+
+ if database.admin_reset_user_password(user_id, new_password):
+ return jsonify({"message": "密码重置成功"})
+ return jsonify({"error": "重置失败,用户不存在"}), 400
+
+
+# 获取密码重置申请列表
+@app.route('/yuyx/api/password_resets', methods=['GET'])
+@admin_required
+def get_password_resets_route():
+ """获取所有待审核的密码重置申请"""
+ resets = database.get_pending_password_resets()
+ return jsonify(resets)
+
+
+# 批准密码重置申请
+@app.route('/yuyx/api/password_resets//approve', methods=['POST'])
+@admin_required
+def approve_password_reset_route(request_id):
+ """批准密码重置申请"""
+ if database.approve_password_reset(request_id):
+ return jsonify({"message": "密码重置申请已批准"})
+ return jsonify({"error": "批准失败"}), 400
+
+
+# 拒绝密码重置申请
+@app.route('/yuyx/api/password_resets//reject', methods=['POST'])
+@admin_required
+def reject_password_reset_route(request_id):
+ """拒绝密码重置申请"""
+ if database.reject_password_reset(request_id):
+ return jsonify({"message": "密码重置申请已拒绝"})
+ return jsonify({"error": "拒绝失败"}), 400
+
+
+# 用户申请重置密码(需要审核)
+@app.route('/api/reset_password_request', methods=['POST'])
+def request_password_reset():
+ """用户申请重置密码"""
+ data = request.json
+ username = data.get('username', '').strip()
+ email = data.get('email', '').strip()
+ new_password = data.get('new_password', '').strip()
+
+ if not username or not new_password:
+ return jsonify({"error": "用户名和新密码不能为空"}), 400
+
+ if len(new_password) < 6:
+ return jsonify({"error": "密码长度不能少于6位"}), 400
+
+ # 验证用户存在
+ user = database.get_user_by_username(username)
+ if not user:
+ return jsonify({"error": "用户不存在"}), 404
+
+ # 如果提供了邮箱,验证邮箱是否匹配
+ if email and user.get('email') != email:
+ return jsonify({"error": "邮箱不匹配"}), 400
+
+ # 创建重置申请
+ request_id = database.create_password_reset_request(user['id'], new_password)
+ if request_id:
+ return jsonify({"message": "密码重置申请已提交,请等待管理员审核"})
+ else:
+ return jsonify({"error": "申请提交失败"}), 500
+
+
+# ==================== 账号管理API (用户隔离) ====================
+
+def load_user_accounts(user_id):
+ """从数据库加载用户的账号到内存"""
+ if user_id not in user_accounts:
+ user_accounts[user_id] = {}
+
+ accounts_data = database.get_user_accounts(user_id)
+ for acc_data in accounts_data:
+ account = Account(
+ account_id=acc_data['id'],
+ user_id=user_id,
+ username=acc_data['username'],
+ password=acc_data['password'],
+ remember=bool(acc_data['remember']),
+ remark=acc_data['remark'] or ''
+ )
+ user_accounts[user_id][account.id] = account
+
+
+# ==================== Bug反馈API(用户端) ====================
+
+@app.route('/api/feedback', methods=['POST'])
+@login_required
+def submit_feedback():
+ """用户提交Bug反馈"""
+ data = request.get_json()
+ title = data.get('title', '').strip()
+ description = data.get('description', '').strip()
+ contact = data.get('contact', '').strip()
+
+ if not title or not description:
+ return jsonify({"error": "标题和描述不能为空"}), 400
+
+ if len(title) > 100:
+ return jsonify({"error": "标题不能超过100个字符"}), 400
+
+ if len(description) > 2000:
+ return jsonify({"error": "描述不能超过2000个字符"}), 400
+
+ # 从数据库获取用户名
+ user_info = database.get_user_by_id(current_user.id)
+ username = user_info['username'] if user_info else f'用户{current_user.id}'
+
+ feedback_id = database.create_bug_feedback(
+ user_id=current_user.id,
+ username=username,
+ title=title,
+ description=description,
+ contact=contact
+ )
+
+ return jsonify({"message": "反馈提交成功", "id": feedback_id})
+
+
+@app.route('/api/feedback', methods=['GET'])
+@login_required
+def get_my_feedbacks():
+ """获取当前用户的反馈列表"""
+ feedbacks = database.get_user_feedbacks(current_user.id)
+ return jsonify(feedbacks)
+
+
+# ==================== Bug反馈API(管理端) ====================
+
+@app.route('/yuyx/api/feedbacks', methods=['GET'])
+@admin_required
+def get_all_feedbacks():
+ """管理员获取所有反馈"""
+ status = request.args.get('status')
+ limit = int(request.args.get('limit', 100))
+ offset = int(request.args.get('offset', 0))
+
+ feedbacks = database.get_bug_feedbacks(limit=limit, offset=offset, status_filter=status)
+ stats = database.get_feedback_stats()
+
+ return jsonify({
+ "feedbacks": feedbacks,
+ "stats": stats
+ })
+
+
+@app.route('/yuyx/api/feedbacks//reply', methods=['POST'])
+@admin_required
+def reply_to_feedback(feedback_id):
+ """管理员回复反馈"""
+ data = request.get_json()
+ reply = data.get('reply', '').strip()
+
+ if not reply:
+ return jsonify({"error": "回复内容不能为空"}), 400
+
+ if database.reply_feedback(feedback_id, reply):
+ return jsonify({"message": "回复成功"})
+ else:
+ return jsonify({"error": "反馈不存在"}), 404
+
+
+@app.route('/yuyx/api/feedbacks//close', methods=['POST'])
+@admin_required
+def close_feedback_api(feedback_id):
+ """管理员关闭反馈"""
+ if database.close_feedback(feedback_id):
+ return jsonify({"message": "已关闭"})
+ else:
+ return jsonify({"error": "反馈不存在"}), 404
+
+
+@app.route('/yuyx/api/feedbacks/', methods=['DELETE'])
+@admin_required
+def delete_feedback_api(feedback_id):
+ """管理员删除反馈"""
+ if database.delete_feedback(feedback_id):
+ return jsonify({"message": "已删除"})
+ else:
+ return jsonify({"error": "反馈不存在"}), 404
+
+
+# ==================== 账号管理API ====================
+
+@app.route('/api/accounts', methods=['GET'])
+@login_required
+def get_accounts():
+ """获取当前用户的所有账号"""
+ user_id = current_user.id
+ if user_id not in user_accounts:
+ load_user_accounts(user_id)
+
+ accounts = user_accounts.get(user_id, {})
+ return jsonify([acc.to_dict() for acc in accounts.values()])
+
+
+@app.route('/api/accounts', methods=['POST'])
+@login_required
+def add_account():
+ """添加账号"""
+ user_id = current_user.id
+
+ # 账号数量限制检查:VIP不限制,普通用户最多3个
+ current_count = len(database.get_user_accounts(user_id))
+ is_vip = database.is_user_vip(user_id)
+ if not is_vip and current_count >= 3:
+ return jsonify({"error": "普通用户最多添加3个账号,升级VIP可无限添加"}), 403
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ remember = data.get('remember', True)
+
+ if not username or not password:
+ return jsonify({"error": "用户名和密码不能为空"}), 400
+
+ # 检查当前用户是否已存在该账号
+ if user_id in user_accounts:
+ for acc in user_accounts[user_id].values():
+ if acc.username == username:
+ return jsonify({"error": f"账号 '{username}' 已存在"}), 400
+
+ # 生成账号ID
+ import uuid
+ account_id = str(uuid.uuid4())[:8]
+
+ # 保存到数据库
+ database.create_account(user_id, account_id, username, password, remember, '')
+
+ # 加载到内存
+ account = Account(account_id, user_id, username, password, remember, '')
+ if user_id not in user_accounts:
+ user_accounts[user_id] = {}
+ user_accounts[user_id][account_id] = account
+
+ log_to_client(f"添加账号: {username}", user_id)
+ return jsonify(account.to_dict())
+
+
+@app.route('/api/accounts/', methods=['PUT'])
+@login_required
+def update_account(account_id):
+ """更新账号信息(密码等)"""
+ user_id = current_user.id
+
+ # 验证账号所有权
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ # 如果账号正在运行,不允许修改
+ if account.is_running:
+ return jsonify({"error": "账号正在运行中,请先停止"}), 400
+
+ data = request.json
+ new_password = data.get('password', '').strip()
+ new_remember = data.get('remember', account.remember)
+
+ if not new_password:
+ return jsonify({"error": "密码不能为空"}), 400
+
+ # 更新数据库
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ UPDATE accounts
+ SET password = ?, remember = ?
+ WHERE id = ?
+ ''', (new_password, new_remember, account_id))
+ conn.commit()
+
+ # 重置账号登录状态(密码修改后恢复active状态)
+ database.reset_account_login_status(account_id)
+ logger.info(f"[账号更新] 用户 {user_id} 修改了账号 {account.username} 的密码,已重置登录状态")
+
+ # 更新内存中的账号信息
+ account.password = new_password
+ account.remember = new_remember
+
+ log_to_client(f"账号 {account.username} 信息已更新,登录状态已重置", user_id)
+ return jsonify({"message": "账号更新成功", "account": account.to_dict()})
+
+
+@app.route('/api/accounts/', methods=['DELETE'])
+@login_required
+def delete_account(account_id):
+ """删除账号"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ # 停止正在运行的任务
+ if account.is_running:
+ account.should_stop = True
+ if account.automation:
+ account.automation.close()
+
+ username = account.username
+
+ # 从数据库删除
+ database.delete_account(account_id)
+
+ # 从内存删除
+ del user_accounts[user_id][account_id]
+
+ log_to_client(f"删除账号: {username}", user_id)
+ return jsonify({"success": True})
+
+
+@app.route('/api/accounts//remark', methods=['PUT'])
+@login_required
+def update_remark(account_id):
+ """更新备注"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ data = request.json
+ remark = data.get('remark', '').strip()[:200]
+
+ # 更新数据库
+ database.update_account_remark(account_id, remark)
+
+ # 更新内存
+ user_accounts[user_id][account_id].remark = remark
+ log_to_client(f"更新备注: {user_accounts[user_id][account_id].username} -> {remark}", user_id)
+
+ return jsonify({"success": True})
+
+
+@app.route('/api/accounts//start', methods=['POST'])
+@login_required
+def start_account(account_id):
+ """启动账号任务"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ if account.is_running:
+ return jsonify({"error": "任务已在运行中"}), 400
+
+ data = request.json
+ browse_type = data.get('browse_type', '应读')
+ enable_screenshot = data.get('enable_screenshot', True) # 默认启用截图
+
+ # 确保浏览器管理器已初始化
+ if not init_browser_manager():
+ return jsonify({"error": "浏览器初始化失败"}), 500
+
+ # 启动任务线程
+ account.is_running = True
+ account.should_stop = False
+ account.status = "运行中"
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot, 'manual'),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+
+ log_to_client(f"启动任务: {account.username} - {browse_type}", user_id)
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ return jsonify({"success": True})
+
+
+@app.route('/api/accounts//stop', methods=['POST'])
+@login_required
+def stop_account(account_id):
+ """停止账号任务"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ if not account.is_running:
+ return jsonify({"error": "任务未在运行"}), 400
+
+ account.should_stop = True
+ account.status = "正在停止"
+
+ log_to_client(f"停止任务: {account.username}", user_id)
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ return jsonify({"success": True})
+
+
+def get_user_semaphore(user_id):
+ """获取或创建用户的信号量"""
+ if user_id not in user_semaphores:
+ user_semaphores[user_id] = threading.Semaphore(max_concurrent_per_account)
+ return user_semaphores[user_id]
+
+
+def run_task(user_id, account_id, browse_type, enable_screenshot=True, source="manual"):
+ """运行自动化任务"""
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return
+
+ account = user_accounts[user_id][account_id]
+
+ # 导入time模块
+ import time as time_module
+ # 注意:不在此处记录开始时间,因为要排除排队等待时间
+
+ # 两级并发控制:用户级 + 全局级(VIP优先)
+ user_sem = get_user_semaphore(user_id)
+
+ # 检查是否VIP用户
+ is_vip_user = database.is_user_vip(user_id)
+ vip_label = " [VIP优先]" if is_vip_user else ""
+
+ # 获取用户级信号量(同一用户的账号排队)
+ log_to_client(f"等待资源分配...{vip_label}", user_id, account_id)
+ account.status = "排队中" + (" (VIP)" if is_vip_user else "")
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ # 记录任务状态为排队中
+ import time as time_mod
+ task_status[account_id] = {
+ "user_id": user_id,
+ "username": account.username,
+ "status": "排队中",
+ "detail_status": "等待资源" + vip_label,
+ "browse_type": browse_type,
+ "start_time": time_mod.time(),
+ "source": source,
+ "progress": {"items": 0, "attachments": 0},
+ "is_vip": is_vip_user
+ }
+
+ # 加入优先级队列
+ with task_queue_lock:
+ if is_vip_user:
+ vip_task_queue.append(account_id)
+ else:
+ normal_task_queue.append(account_id)
+
+ # VIP优先排队机制
+ acquired = False
+ while not acquired:
+ with task_queue_lock:
+ # VIP用户直接尝试获取; 普通用户需等VIP队列为空
+ can_try = is_vip_user or len(vip_task_queue) == 0
+
+ if can_try and user_sem.acquire(blocking=False):
+ acquired = True
+ with task_queue_lock:
+ if account_id in vip_task_queue:
+ vip_task_queue.remove(account_id)
+ if account_id in normal_task_queue:
+ normal_task_queue.remove(account_id)
+ break
+
+ # 检查是否被停止
+ if account.should_stop:
+ with task_queue_lock:
+ if account_id in vip_task_queue:
+ vip_task_queue.remove(account_id)
+ if account_id in normal_task_queue:
+ normal_task_queue.remove(account_id)
+ log_to_client(f"任务已取消", user_id, account_id)
+ account.status = "已停止"
+ account.is_running = False
+ if account_id in task_status:
+ del task_status[account_id]
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ return
+
+ time_module.sleep(0.3)
+
+ try:
+ # 如果在排队期间被停止,直接返回
+ if account.should_stop:
+ log_to_client(f"任务已取消", user_id, account_id)
+ account.status = "已停止"
+ account.is_running = False
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ return
+
+ # 获取全局信号量(防止所有用户同时运行导致资源耗尽)
+ global_semaphore.acquire()
+
+ try:
+ # 再次检查是否被停止
+ if account.should_stop:
+ log_to_client(f"任务已取消", user_id, account_id)
+ account.status = "已停止"
+ account.is_running = False
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ return
+
+ # ====== 创建任务断点 ======
+ task_id = checkpoint_mgr.create_checkpoint(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type
+ )
+ logger.info(f"[断点] 任务 {task_id} 已创建")
+
+ # ====== 在此处记录任务真正的开始时间(排除排队等待时间) ======
+ task_start_time = time_module.time()
+
+ account.status = "运行中"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ account.last_browse_type = browse_type
+
+ # 更新任务状态为运行中
+ if account_id in task_status:
+ task_status[account_id]["status"] = "运行中"
+ task_status[account_id]["detail_status"] = "初始化"
+ task_status[account_id]["start_time"] = task_start_time
+
+ # 重试机制:最多尝试3次,超时则换IP重试
+ max_attempts = 3
+ last_error = None
+
+ for attempt in range(1, max_attempts + 1):
+ try:
+ if attempt > 1:
+ log_to_client(f"🔄 第 {attempt} 次尝试(共{max_attempts}次)...", user_id, account_id)
+
+ # 检查是否需要使用代理
+ proxy_config = None
+ config = database.get_system_config()
+ if config.get('proxy_enabled') == 1:
+ proxy_api_url = config.get('proxy_api_url', '').strip()
+ if proxy_api_url:
+ log_to_client(f"正在获取代理IP...", user_id, account_id)
+ proxy_server = get_proxy_from_api(proxy_api_url, max_retries=3)
+ if proxy_server:
+ proxy_config = {'server': proxy_server}
+ log_to_client(f"✓ 将使用代理: {proxy_server}", user_id, account_id)
+ account.proxy_config = proxy_config # 保存代理配置供截图使用
+ else:
+ log_to_client(f"✗ 代理获取失败,将不使用代理继续", user_id, account_id)
+ else:
+ log_to_client(f"⚠ 代理已启用但未配置API地址", user_id, account_id)
+
+ # 使用 API 方式浏览(不启动浏览器,节省内存)
+ checkpoint_mgr.update_stage(task_id, TaskStage.STARTING, progress_percent=10)
+
+ def custom_log(message: str):
+ log_to_client(message, user_id, account_id)
+
+ log_to_client(f"开始登录...", user_id, account_id)
+ if account_id in task_status:
+ task_status[account_id]["detail_status"] = "正在登录"
+ checkpoint_mgr.update_stage(task_id, TaskStage.LOGGING_IN, progress_percent=25)
+
+ # 使用 API 方式登录和浏览(不启动浏览器)
+ api_browser = APIBrowser(log_callback=custom_log, proxy_config=proxy_config)
+ if api_browser.login(account.username, account.password):
+ log_to_client(f"✓ 登录成功!", user_id, account_id)
+ # 登录成功,清除失败计数
+ # 保存cookies供截图使用
+ api_browser.save_cookies_for_playwright(account.username)
+ database.reset_account_login_status(account_id)
+
+ if account_id in task_status:
+ task_status[account_id]["detail_status"] = "正在浏览"
+ log_to_client(f"开始浏览 '{browse_type}' 内容...", user_id, account_id)
+
+ def should_stop():
+ return account.should_stop
+
+ checkpoint_mgr.update_stage(task_id, TaskStage.BROWSING, progress_percent=50)
+ result = api_browser.browse_content(
+ browse_type=browse_type,
+ should_stop_callback=should_stop
+ )
+ # 转换结果类型以兼容后续代码
+ result = BrowseResult(
+ success=result.success,
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message=result.error_message
+ )
+ api_browser.close()
+ else:
+ # API 登录失败
+ error_message = "登录失败"
+ log_to_client(f"❌ {error_message}", user_id, account_id)
+
+ # 增加失败计数(假设密码错误)
+ is_suspended = database.increment_account_login_fail(account_id, error_message)
+ if is_suspended:
+ log_to_client(f"⚠ 该账号连续3次密码错误,已自动暂停", user_id, account_id)
+ log_to_client(f"请在前台修改密码后才能继续使用", user_id, account_id)
+
+ retry_action = checkpoint_mgr.record_error(task_id, error_message)
+ if retry_action == "paused":
+ logger.warning(f"[断点] 任务 {task_id} 已暂停(登录失败)")
+
+ account.status = "登录失败"
+ account.is_running = False
+ # 记录登录失败日志
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=0,
+ total_attachments=0,
+ error_message=error_message,
+ duration=int(time_module.time() - task_start_time),
+ source=source
+ )
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ api_browser.close()
+ return
+
+ account.total_items = result.total_items
+ account.total_attachments = result.total_attachments
+
+ if result.success:
+ log_to_client(f"浏览完成! 共 {result.total_items} 条内容,{result.total_attachments} 个附件", user_id, account_id)
+ if account_id in task_status:
+ task_status[account_id]["detail_status"] = "浏览完成"
+ task_status[account_id]["progress"] = {"items": result.total_items, "attachments": result.total_attachments}
+ account.status = "已完成"
+ checkpoint_mgr.update_stage(task_id, TaskStage.COMPLETING, progress_percent=95)
+ checkpoint_mgr.complete_task(task_id, success=True)
+ logger.info(f"[断点] 任务 {task_id} 已完成")
+ # 记录成功日志(如果不截图则在此记录,截图时在截图完成后记录)
+ if not enable_screenshot:
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='success',
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message='',
+ duration=int(time_module.time() - task_start_time),
+ source=source
+ )
+ # 成功则跳出重试循环
+ break
+ else:
+ # 浏览出错,检查是否是超时错误
+ error_msg = result.error_message
+ if 'Timeout' in error_msg or 'timeout' in error_msg:
+ last_error = error_msg
+ log_to_client(f"⚠ 检测到超时错误: {error_msg}", user_id, account_id)
+
+ # 关闭当前浏览器
+ if account.automation:
+ try:
+ account.automation.close()
+ log_to_client(f"已关闭超时的浏览器实例", user_id, account_id)
+ except Exception as e:
+ logger.debug(f"关闭超时浏览器实例失败: {e}")
+ account.automation = None
+
+ if attempt < max_attempts:
+ log_to_client(f"⚠ 代理可能速度过慢,将换新IP重试 ({attempt}/{max_attempts})", user_id, account_id)
+ time_module.sleep(2) # 等待2秒再重试
+ continue
+ else:
+ log_to_client(f"❌ 已达到最大重试次数({max_attempts}),任务失败", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message=f"重试{max_attempts}次后仍失败: {error_msg}",
+ duration=int(time_module.time() - task_start_time)
+ )
+ break
+ else:
+ # 非超时错误,直接失败不重试
+ log_to_client(f"浏览出错: {error_msg}", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message=error_msg,
+ duration=int(time_module.time() - task_start_time),
+ source=source
+ )
+ break
+
+ except Exception as retry_error:
+ # 捕获重试过程中的异常
+ error_msg = str(retry_error)
+ last_error = error_msg
+
+ # 关闭可能存在的浏览器实例
+ if account.automation:
+ try:
+ account.automation.close()
+ except Exception as e:
+ logger.debug(f"关闭浏览器实例失败: {e}")
+ account.automation = None
+
+ if 'Timeout' in error_msg or 'timeout' in error_msg:
+ log_to_client(f"⚠ 执行超时: {error_msg}", user_id, account_id)
+ if attempt < max_attempts:
+ log_to_client(f"⚠ 将换新IP重试 ({attempt}/{max_attempts})", user_id, account_id)
+ time_module.sleep(2)
+ continue
+ else:
+ log_to_client(f"❌ 已达到最大重试次数({max_attempts}),任务失败", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=account.total_items,
+ total_attachments=account.total_attachments,
+ error_message=f"重试{max_attempts}次后仍失败: {error_msg}",
+ duration=int(time_module.time() - task_start_time),
+ source=source
+ )
+ break
+ else:
+ # 非超时异常,直接失败
+ log_to_client(f"任务执行异常: {error_msg}", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=account.total_items,
+ total_attachments=account.total_attachments,
+ error_message=error_msg,
+ duration=int(time_module.time() - task_start_time),
+ source=source
+ )
+ break
+
+
+ except Exception as e:
+ error_msg = str(e)
+ log_to_client(f"任务执行出错: {error_msg}", user_id, account_id)
+ account.status = "出错"
+ # 记录异常失败日志
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=account.total_items,
+ total_attachments=account.total_attachments,
+ error_message=error_msg,
+ duration=int(time_module.time() - task_start_time),
+ source=source
+ )
+
+ finally:
+ # 先关闭浏览器,再释放信号量(避免并发创建/关闭浏览器导致资源竞争)
+ account.is_running = False
+ # 如果状态不是已完成(需要截图),则重置为未开始
+ if account.status not in ["已完成"]:
+ account.status = "未开始"
+
+ if account.automation:
+ try:
+ account.automation.close()
+ # log_to_client(f"主任务浏览器已关闭", user_id, account_id) # 精简
+ except Exception as e:
+ log_to_client(f"关闭主任务浏览器时出错: {str(e)}", user_id, account_id)
+ finally:
+ account.automation = None
+
+ # 浏览器关闭后再释放全局信号量,确保新任务创建浏览器时旧浏览器已完全关闭
+ global_semaphore.release()
+
+ if account_id in active_tasks:
+ del active_tasks[account_id]
+ if account_id in task_status:
+ del task_status[account_id]
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ # 任务完成后自动截图(增加2秒延迟,确保资源完全释放)
+ # 根据enable_screenshot参数决定是否截图
+ if account.status == "已完成" and not account.should_stop:
+ if enable_screenshot:
+ log_to_client(f"等待2秒后开始截图...", user_id, account_id)
+ # 更新账号状态为截图中
+ account.status = "截图中"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ # 重新添加截图状态
+ import time as time_mod
+ task_status[account_id] = {
+ "user_id": user_id,
+ "username": account.username,
+ "status": "运行中",
+ "detail_status": "等待截图",
+ "browse_type": browse_type,
+ "start_time": time_mod.time(),
+ "source": source,
+ "progress": {"items": result.total_items if result else 0, "attachments": result.total_attachments if result else 0}
+ }
+ time.sleep(2) # 延迟启动截图,确保主任务资源已完全释放
+ browse_result_dict = {'total_items': result.total_items, 'total_attachments': result.total_attachments}
+ threading.Thread(target=take_screenshot_for_account, args=(user_id, account_id, browse_type, source, task_start_time, browse_result_dict), daemon=True).start()
+ else:
+ # 不截图时,重置状态为未开始
+ account.status = "未开始"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ log_to_client(f"截图功能已禁用,跳过截图", user_id, account_id)
+ else:
+ # 任务非正常完成,重置状态为未开始
+ if account.status not in ["登录失败", "出错"]:
+ account.status = "未开始"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ finally:
+ # 释放用户级信号量
+ user_sem.release()
+
+
+def take_screenshot_for_account(user_id, account_id, browse_type="应读", source="manual", task_start_time=None, browse_result=None):
+ """为账号任务完成后截图(使用工作线程池,真正的浏览器复用)"""
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return
+
+ account = user_accounts[user_id][account_id]
+
+ def screenshot_task(browser_instance, user_id, account_id, account, browse_type, source, task_start_time, browse_result):
+ """在worker线程中执行的截图任务"""
+ max_retries = 3
+
+ for attempt in range(1, max_retries + 1):
+ automation = None
+ try:
+ # 更新状态
+ if account_id in task_status:
+ task_status[account_id]["detail_status"] = f"正在截图{f' (第{attempt}次)' if attempt > 1 else ''}"
+
+ if attempt > 1:
+ log_to_client(f"🔄 第 {attempt} 次截图尝试...", user_id, account_id)
+
+ log_to_client(f"使用Worker-{browser_instance['worker_id']}的浏览器(已使用{browser_instance['use_count']}次)", user_id, account_id)
+
+ # 使用worker的浏览器创建PlaywrightAutomation
+ proxy_config = account.proxy_config if hasattr(account, 'proxy_config') else None
+ automation = PlaywrightAutomation(browser_manager, account_id, proxy_config=proxy_config)
+ automation.playwright = browser_instance['playwright']
+ automation.browser = browser_instance['browser']
+
+ def custom_log(message: str):
+ log_to_client(message, user_id, account_id)
+ automation.log = custom_log
+
+ # 登录
+ log_to_client(f"登录中...", user_id, account_id)
+ login_result = automation.quick_login(account.username, account.password, account.remember)
+ if not login_result["success"]:
+ error_message = login_result.get("message", "截图登录失败")
+ log_to_client(f"截图登录失败: {error_message}", user_id, account_id)
+ if attempt < max_retries:
+ log_to_client(f"将重试...", user_id, account_id)
+ time.sleep(2)
+ continue
+ else:
+ log_to_client(f"❌ 截图失败: 登录失败", user_id, account_id)
+ return {'success': False, 'error': '登录失败'}
+
+ browse_type = account.last_browse_type
+ log_to_client(f"导航到 '{browse_type}' 页面...", user_id, account_id)
+
+ # 导航到指定页面
+ result = automation.browse_content(
+ navigate_only=True,
+ browse_type=browse_type,
+ auto_next_page=False,
+ auto_view_attachments=False,
+ interval=0,
+ should_stop_callback=None
+ )
+
+ if not result.success and result.error_message:
+ log_to_client(f"导航警告: {result.error_message}", user_id, account_id)
+
+ # 等待页面稳定
+ time.sleep(2)
+
+ # 生成截图文件名
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ now_beijing = datetime.now(beijing_tz)
+ timestamp = now_beijing.strftime('%Y%m%d_%H%M%S')
+
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+ login_account = account.username
+ screenshot_filename = f"{username_prefix}_{login_account}_{browse_type}_{timestamp}.jpg"
+ screenshot_path = os.path.join(SCREENSHOTS_DIR, screenshot_filename)
+
+ # 尝试截图
+ if automation.take_screenshot(screenshot_path):
+ # 验证截图文件
+ if os.path.exists(screenshot_path) and os.path.getsize(screenshot_path) > 1000:
+ log_to_client(f"✓ 截图成功: {screenshot_filename}", user_id, account_id)
+ return {'success': True, 'filename': screenshot_filename}
+ else:
+ log_to_client(f"截图文件异常,将重试", user_id, account_id)
+ if os.path.exists(screenshot_path):
+ os.remove(screenshot_path)
+ else:
+ log_to_client(f"截图保存失败", user_id, account_id)
+
+ if attempt < max_retries:
+ log_to_client(f"将重试...", user_id, account_id)
+ time.sleep(2)
+
+ except Exception as e:
+ log_to_client(f"截图出错: {str(e)}", user_id, account_id)
+ if attempt < max_retries:
+ log_to_client(f"将重试...", user_id, account_id)
+ time.sleep(2)
+
+ finally:
+ # 只关闭context,不关闭浏览器(由worker管理)
+ if automation:
+ try:
+ if automation.context:
+ automation.context.close()
+ automation.context = None
+ automation.page = None
+ except Exception as e:
+ logger.debug(f"关闭context时出错: {e}")
+
+ return {'success': False, 'error': '截图失败,已重试3次'}
+
+ def screenshot_callback(result, error):
+ """截图完成回调"""
+ try:
+ # 重置账号状态
+ account.status = "未开始"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ # 清除任务状态
+ if account_id in task_status:
+ del task_status[account_id]
+
+ if error:
+ log_to_client(f"❌ 截图失败: {error}", user_id, account_id)
+ elif not result or not result.get('success'):
+ error_msg = result.get('error', '未知错误') if result else '未知错误'
+ log_to_client(f"❌ 截图失败: {error_msg}", user_id, account_id)
+
+ # 记录任务日志
+ if task_start_time and browse_result:
+ import time as time_module
+ total_elapsed = int(time_module.time() - task_start_time)
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ account_username=account.username,
+ browse_type=browse_type,
+ status='success',
+ total_items=browse_result.get('total_items', 0),
+ total_attachments=browse_result.get('total_attachments', 0),
+ elapsed_seconds=total_elapsed,
+ source=source
+ )
+
+ except Exception as e:
+ logger.error(f"截图回调出错: {e}")
+
+ # 提交任务到工作线程池
+ pool = get_browser_worker_pool()
+ pool.submit_task(
+ screenshot_task,
+ screenshot_callback,
+ user_id, account_id, account, browse_type, source, task_start_time, browse_result
+ )
+def manual_screenshot(account_id):
+ """手动为指定账号截图"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+ if account.is_running:
+ return jsonify({"error": "任务运行中,无法截图"}), 400
+
+ data = request.json or {}
+ browse_type = data.get('browse_type', account.last_browse_type)
+
+ account.last_browse_type = browse_type
+
+ threading.Thread(target=take_screenshot_for_account, args=(user_id, account_id), daemon=True).start()
+ log_to_client(f"手动截图: {account.username} - {browse_type}", user_id)
+ return jsonify({"success": True})
+
+
+# ==================== 截图管理API ====================
+
+@app.route('/api/screenshots', methods=['GET'])
+@login_required
+def get_screenshots():
+ """获取当前用户的截图列表"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ try:
+ screenshots = []
+ if os.path.exists(SCREENSHOTS_DIR):
+ for filename in os.listdir(SCREENSHOTS_DIR):
+ # 只显示属于当前用户的截图(支持png和jpg格式)
+ if (filename.lower().endswith(('.png', '.jpg', '.jpeg'))) and filename.startswith(username_prefix + '_'):
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ stat = os.stat(filepath)
+ # 转换为北京时间
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ created_time = datetime.fromtimestamp(stat.st_mtime, tz=beijing_tz)
+ # 解析文件名获取显示名称
+ # 文件名格式:用户名_登录账号_浏览类型_时间.jpg
+ parts = filename.rsplit('.', 1)[0].split('_', 1) # 移除扩展名并分割
+ if len(parts) > 1:
+ # 显示名称:登录账号_浏览类型_时间.jpg
+ display_name = parts[1] + '.' + filename.rsplit('.', 1)[1]
+ else:
+ display_name = filename
+
+ screenshots.append({
+ 'filename': filename,
+ 'display_name': display_name,
+ 'size': stat.st_size,
+ 'created': created_time.strftime('%Y-%m-%d %H:%M:%S')
+ })
+ screenshots.sort(key=lambda x: x['created'], reverse=True)
+ return jsonify(screenshots)
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+@app.route('/screenshots/')
+@login_required
+def serve_screenshot(filename):
+ """提供截图文件访问"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ # 验证文件属于当前用户
+ if not filename.startswith(username_prefix + '_'):
+ return jsonify({"error": "无权访问"}), 403
+
+ return send_from_directory(SCREENSHOTS_DIR, filename)
+
+
+@app.route('/api/screenshots/', methods=['DELETE'])
+@login_required
+def delete_screenshot(filename):
+ """删除指定截图"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ # 验证文件属于当前用户
+ if not filename.startswith(username_prefix + '_'):
+ return jsonify({"error": "无权删除"}), 403
+
+ try:
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ if os.path.exists(filepath):
+ os.remove(filepath)
+ log_to_client(f"删除截图: {filename}", user_id)
+ return jsonify({"success": True})
+ else:
+ return jsonify({"error": "文件不存在"}), 404
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+@app.route('/api/screenshots/clear', methods=['POST'])
+@login_required
+def clear_all_screenshots():
+ """清空当前用户的所有截图"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ try:
+ deleted_count = 0
+ if os.path.exists(SCREENSHOTS_DIR):
+ for filename in os.listdir(SCREENSHOTS_DIR):
+ if (filename.lower().endswith(('.png', '.jpg', '.jpeg'))) and filename.startswith(username_prefix + '_'):
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ os.remove(filepath)
+ deleted_count += 1
+ log_to_client(f"清理了 {deleted_count} 个截图文件", user_id)
+ return jsonify({"success": True, "deleted": deleted_count})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+# ==================== WebSocket事件 ====================
+
+@socketio.on('connect')
+def handle_connect():
+ """客户端连接"""
+ if current_user.is_authenticated:
+ user_id = current_user.id
+ join_room(f'user_{user_id}')
+ log_to_client("客户端已连接", user_id)
+
+ # 如果user_accounts中没有该用户的账号,从数据库加载
+ if user_id not in user_accounts or len(user_accounts[user_id]) == 0:
+ db_accounts = database.get_user_accounts(user_id)
+ if db_accounts:
+ user_accounts[user_id] = {}
+ for acc_data in db_accounts:
+ account = Account(
+ account_id=acc_data['id'],
+ user_id=acc_data['user_id'],
+ username=acc_data['username'],
+ password=acc_data['password'],
+ remember=bool(acc_data.get('remember', 1)),
+ remark=acc_data.get('remark', '')
+ )
+ user_accounts[user_id][acc_data['id']] = account
+ log_to_client(f"已从数据库恢复 {len(db_accounts)} 个账号", user_id)
+
+ # 发送账号列表
+ accounts = user_accounts.get(user_id, {})
+ emit('accounts_list', [acc.to_dict() for acc in accounts.values()])
+
+ # 发送历史日志
+ if user_id in log_cache:
+ for log_entry in log_cache[user_id]:
+ emit('log', log_entry)
+
+
+@socketio.on('disconnect')
+def handle_disconnect():
+ """客户端断开"""
+ if current_user.is_authenticated:
+ user_id = current_user.id
+ leave_room(f'user_{user_id}')
+
+
+# ==================== 静态文件 ====================
+
+@app.route('/static/')
+def serve_static(filename):
+ """提供静态文件访问"""
+ response = send_from_directory('static', filename)
+ # 禁用缓存,强制浏览器每次都重新加载
+ response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
+ response.headers['Pragma'] = 'no-cache'
+ response.headers['Expires'] = '0'
+ return response
+
+
+# ==================== 启动 ====================
+
+
+# ==================== 管理员VIP管理API ====================
+
+@app.route('/yuyx/api/vip/config', methods=['GET'])
+def get_vip_config_api():
+ """获取VIP配置"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+ config = database.get_vip_config()
+ return jsonify(config)
+
+
+@app.route('/yuyx/api/vip/config', methods=['POST'])
+def set_vip_config_api():
+ """设置默认VIP天数"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ data = request.json
+ days = data.get('default_vip_days', 0)
+
+ if not isinstance(days, int) or days < 0:
+ return jsonify({"error": "VIP天数必须是非负整数"}), 400
+
+ database.set_default_vip_days(days)
+ return jsonify({"message": "VIP配置已更新", "default_vip_days": days})
+
+
+@app.route('/yuyx/api/users//vip', methods=['POST'])
+def set_user_vip_api(user_id):
+ """设置用户VIP"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ data = request.json
+ days = data.get('days', 30)
+
+ # 验证days参数
+ valid_days = [7, 30, 365, 999999]
+ if days not in valid_days:
+ return jsonify({"error": "VIP天数必须是 7/30/365/999999 之一"}), 400
+
+ if database.set_user_vip(user_id, days):
+ vip_type = {7: "一周", 30: "一个月", 365: "一年", 999999: "永久"}[days]
+ return jsonify({"message": f"VIP设置成功: {vip_type}"})
+ return jsonify({"error": "设置失败,用户不存在"}), 400
+
+
+@app.route('/yuyx/api/users//vip', methods=['DELETE'])
+def remove_user_vip_api(user_id):
+ """移除用户VIP"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ if database.remove_user_vip(user_id):
+ return jsonify({"message": "VIP已移除"})
+ return jsonify({"error": "移除失败"}), 400
+
+
+@app.route('/yuyx/api/users//vip', methods=['GET'])
+def get_user_vip_info_api(user_id):
+ """获取用户VIP信息(管理员)"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ vip_info = database.get_user_vip_info(user_id)
+ return jsonify(vip_info)
+
+
+
+# ==================== 用户端VIP查询API ====================
+
+@app.route('/api/user/vip', methods=['GET'])
+@login_required
+def get_current_user_vip():
+ """获取当前用户VIP信息"""
+ vip_info = database.get_user_vip_info(current_user.id)
+ # 添加用户名
+ user_info = database.get_user_by_id(current_user.id)
+ vip_info['username'] = user_info['username'] if user_info else 'Unknown'
+ return jsonify(vip_info)
+
+
+@app.route('/api/run_stats', methods=['GET'])
+@login_required
+def get_run_stats():
+ """获取当前用户的运行统计"""
+ user_id = current_user.id
+
+ # 获取今日任务统计
+ stats = database.get_user_run_stats(user_id)
+
+ # 计算当前正在运行的账号数
+ current_running = 0
+ if user_id in user_accounts:
+ current_running = sum(1 for acc in user_accounts[user_id].values() if acc.is_running)
+
+ return jsonify({
+ 'today_completed': stats.get('completed', 0),
+ 'current_running': current_running,
+ 'today_failed': stats.get('failed', 0),
+ 'today_items': stats.get('total_items', 0),
+ 'today_attachments': stats.get('total_attachments', 0)
+ })
+
+
+# ==================== 系统配置API ====================
+
+@app.route('/yuyx/api/system/config', methods=['GET'])
+@admin_required
+def get_system_config_api():
+ """获取系统配置"""
+ config = database.get_system_config()
+ return jsonify(config)
+
+
+@app.route('/yuyx/api/system/config', methods=['POST'])
+@admin_required
+def update_system_config_api():
+ """更新系统配置"""
+ global max_concurrent_global, global_semaphore, max_concurrent_per_account
+
+ data = request.json
+ max_concurrent = data.get('max_concurrent_global')
+ schedule_enabled = data.get('schedule_enabled')
+ schedule_time = data.get('schedule_time')
+ schedule_browse_type = data.get('schedule_browse_type')
+ schedule_weekdays = data.get('schedule_weekdays')
+ new_max_concurrent_per_account = data.get('max_concurrent_per_account')
+ new_max_screenshot_concurrent = data.get('max_screenshot_concurrent')
+
+ # 验证参数
+ if max_concurrent is not None:
+ if not isinstance(max_concurrent, int) or max_concurrent < 1 or max_concurrent > 20:
+ return jsonify({"error": "全局并发数必须在1-20之间"}), 400
+
+ if new_max_concurrent_per_account is not None:
+ if not isinstance(new_max_concurrent_per_account, int) or new_max_concurrent_per_account < 1 or new_max_concurrent_per_account > 5:
+ return jsonify({"error": "单账号并发数必须在1-5之间"}), 400
+
+ if new_max_screenshot_concurrent is not None:
+ if not isinstance(new_max_screenshot_concurrent, int) or new_max_screenshot_concurrent < 1 or new_max_screenshot_concurrent > 5:
+ return jsonify({"error": "截图并发数必须在1-5之间"}), 400
+
+ if schedule_time is not None:
+ # 验证时间格式 HH:MM
+ import re
+ if not re.match(r'^([01]\d|2[0-3]):([0-5]\d)$', schedule_time):
+ return jsonify({"error": "时间格式错误,应为 HH:MM"}), 400
+
+ if schedule_browse_type is not None:
+ if schedule_browse_type not in ['注册前未读', '应读', '未读']:
+ return jsonify({"error": "浏览类型无效"}), 400
+
+ if schedule_weekdays is not None:
+ # 验证星期格式,应该是逗号分隔的数字字符串 "1,2,3,4,5,6,7"
+ try:
+ days = [int(d.strip()) for d in schedule_weekdays.split(',') if d.strip()]
+ if not all(1 <= d <= 7 for d in days):
+ return jsonify({"error": "星期数字必须在1-7之间"}), 400
+ except (ValueError, AttributeError):
+ return jsonify({"error": "星期格式错误"}), 400
+
+ # 更新数据库
+ if database.update_system_config(
+ max_concurrent=max_concurrent,
+ schedule_enabled=schedule_enabled,
+ schedule_time=schedule_time,
+ schedule_browse_type=schedule_browse_type,
+ schedule_weekdays=schedule_weekdays,
+ max_concurrent_per_account=new_max_concurrent_per_account,
+ max_screenshot_concurrent=new_max_screenshot_concurrent
+ ):
+ # 如果修改了并发数,更新全局变量和信号量
+ if max_concurrent is not None and max_concurrent != max_concurrent_global:
+ max_concurrent_global = max_concurrent
+ global_semaphore = threading.Semaphore(max_concurrent)
+ print(f"全局并发数已更新为: {max_concurrent}")
+
+ # 如果修改了单用户并发数,更新全局变量(已有的信号量会在下次创建时使用新值)
+ if new_max_concurrent_per_account is not None and new_max_concurrent_per_account != max_concurrent_per_account:
+ max_concurrent_per_account = new_max_concurrent_per_account
+ print(f"单用户并发数已更新为: {max_concurrent_per_account}")
+
+ # 如果修改了截图并发数,更新信号量
+ if new_max_screenshot_concurrent is not None:
+ global screenshot_semaphore
+ screenshot_semaphore = threading.Semaphore(new_max_screenshot_concurrent)
+ print(f"截图并发数已更新为: {new_max_screenshot_concurrent}")
+
+ return jsonify({"message": "系统配置已更新"})
+
+ return jsonify({"error": "更新失败"}), 400
+
+
+@app.route('/yuyx/api/schedule/execute', methods=['POST'])
+@admin_required
+def execute_schedule_now():
+ """立即执行定时任务(无视定时时间和星期限制)"""
+ try:
+ # 在新线程中执行任务,避免阻塞请求
+ # 传入 skip_weekday_check=True 跳过星期检查
+ thread = threading.Thread(target=run_scheduled_task, args=(True,), daemon=True)
+ thread.start()
+
+ logger.info("[立即执行定时任务] 管理员手动触发定时任务执行(跳过星期检查)")
+ return jsonify({"message": "定时任务已开始执行,请查看任务列表获取进度"})
+ except Exception as e:
+ logger.error(f"[立即执行定时任务] 启动失败: {str(e)}")
+ return jsonify({"error": f"启动失败: {str(e)}"}), 500
+
+
+
+
+# ==================== 代理配置API ====================
+
+@app.route('/yuyx/api/proxy/config', methods=['GET'])
+@admin_required
+def get_proxy_config_api():
+ """获取代理配置"""
+ config = database.get_system_config()
+ return jsonify({
+ 'proxy_enabled': config.get('proxy_enabled', 0),
+ 'proxy_api_url': config.get('proxy_api_url', ''),
+ 'proxy_expire_minutes': config.get('proxy_expire_minutes', 3)
+ })
+
+
+@app.route('/yuyx/api/proxy/config', methods=['POST'])
+@admin_required
+def update_proxy_config_api():
+ """更新代理配置"""
+ data = request.json
+ proxy_enabled = data.get('proxy_enabled')
+ proxy_api_url = data.get('proxy_api_url', '').strip()
+ proxy_expire_minutes = data.get('proxy_expire_minutes')
+
+ if proxy_enabled is not None and proxy_enabled not in [0, 1]:
+ return jsonify({"error": "proxy_enabled必须是0或1"}), 400
+
+ if proxy_expire_minutes is not None:
+ if not isinstance(proxy_expire_minutes, int) or proxy_expire_minutes < 1:
+ return jsonify({"error": "代理有效期必须是大于0的整数"}), 400
+
+ if database.update_system_config(
+ proxy_enabled=proxy_enabled,
+ proxy_api_url=proxy_api_url,
+ proxy_expire_minutes=proxy_expire_minutes
+ ):
+ return jsonify({"message": "代理配置已更新"})
+
+ return jsonify({"error": "更新失败"}), 400
+
+
+@app.route('/yuyx/api/proxy/test', methods=['POST'])
+@admin_required
+def test_proxy_api():
+ """测试代理连接"""
+ data = request.json
+ api_url = data.get('api_url', '').strip()
+
+ if not api_url:
+ return jsonify({"error": "请提供API地址"}), 400
+
+ try:
+ response = requests.get(api_url, timeout=10)
+ if response.status_code == 200:
+ ip_port = response.text.strip()
+ if ip_port and ':' in ip_port:
+ return jsonify({
+ "success": True,
+ "proxy": ip_port,
+ "message": f"代理获取成功: {ip_port}"
+ })
+ else:
+ return jsonify({
+ "success": False,
+ "message": f"代理格式错误: {ip_port}"
+ }), 400
+ else:
+ return jsonify({
+ "success": False,
+ "message": f"HTTP错误: {response.status_code}"
+ }), 400
+ except Exception as e:
+ return jsonify({
+ "success": False,
+ "message": f"连接失败: {str(e)}"
+ }), 500
+
+# ==================== 服务器信息API ====================
+
+@app.route('/yuyx/api/server/info', methods=['GET'])
+@admin_required
+def get_server_info_api():
+ """获取服务器信息"""
+ import psutil
+ import datetime
+
+ # CPU使用率
+ cpu_percent = psutil.cpu_percent(interval=1)
+
+ # 内存信息
+ memory = psutil.virtual_memory()
+ memory_total = f"{memory.total / (1024**3):.1f}GB"
+ memory_used = f"{memory.used / (1024**3):.1f}GB"
+ memory_percent = memory.percent
+
+ # 磁盘信息
+ disk = psutil.disk_usage('/')
+ disk_total = f"{disk.total / (1024**3):.1f}GB"
+ disk_used = f"{disk.used / (1024**3):.1f}GB"
+ disk_percent = disk.percent
+
+ # 运行时长
+ boot_time = datetime.datetime.fromtimestamp(psutil.boot_time())
+ uptime_delta = datetime.datetime.now() - boot_time
+ days = uptime_delta.days
+ hours = uptime_delta.seconds // 3600
+ uptime = f"{days}天{hours}小时"
+
+ return jsonify({
+ 'cpu_percent': cpu_percent,
+ 'memory_total': memory_total,
+ 'memory_used': memory_used,
+ 'memory_percent': memory_percent,
+ 'disk_total': disk_total,
+ 'disk_used': disk_used,
+ 'disk_percent': disk_percent,
+ 'uptime': uptime
+ })
+
+
+# ==================== 任务统计和日志API ====================
+
+@app.route('/yuyx/api/task/stats', methods=['GET'])
+@admin_required
+def get_task_stats_api():
+ """获取任务统计数据"""
+ date_filter = request.args.get('date') # YYYY-MM-DD格式
+ stats = database.get_task_stats(date_filter)
+ return jsonify(stats)
+
+
+
+
+@app.route('/yuyx/api/task/running', methods=['GET'])
+@admin_required
+def get_running_tasks_api():
+ """获取当前运行中和排队中的任务"""
+ import time as time_mod
+ current_time = time_mod.time()
+
+ running = []
+ queuing = []
+
+ for account_id, info in task_status.items():
+ elapsed = int(current_time - info.get("start_time", current_time))
+
+ # 获取用户名
+ user = database.get_user_by_id(info.get("user_id"))
+ user_username = user['username'] if user else 'N/A'
+
+ # 获取进度信息
+ progress = info.get("progress", {"items": 0, "attachments": 0})
+
+ task_info = {
+ "account_id": account_id,
+ "user_id": info.get("user_id"),
+ "user_username": user_username,
+ "username": info.get("username"),
+ "browse_type": info.get("browse_type"),
+ "source": info.get("source", "manual"),
+ "detail_status": info.get("detail_status", "未知"),
+ "progress_items": progress.get("items", 0),
+ "progress_attachments": progress.get("attachments", 0),
+ "elapsed_seconds": elapsed,
+ "elapsed_display": f"{elapsed // 60}分{elapsed % 60}秒" if elapsed >= 60 else f"{elapsed}秒"
+ }
+
+ if info.get("status") == "运行中":
+ running.append(task_info)
+ else:
+ queuing.append(task_info)
+
+ # 按开始时间排序
+ running.sort(key=lambda x: x["elapsed_seconds"], reverse=True)
+ queuing.sort(key=lambda x: x["elapsed_seconds"], reverse=True)
+
+ return jsonify({
+ "running": running,
+ "queuing": queuing,
+ "running_count": len(running),
+ "queuing_count": len(queuing),
+ "max_concurrent": max_concurrent_global
+ })
+
+@app.route('/yuyx/api/task/logs', methods=['GET'])
+@admin_required
+def get_task_logs_api():
+ """获取任务日志列表(支持分页和多种筛选)"""
+ limit = int(request.args.get('limit', 20))
+ offset = int(request.args.get('offset', 0))
+ date_filter = request.args.get('date') # YYYY-MM-DD格式
+ status_filter = request.args.get('status') # success/failed
+ source_filter = request.args.get('source') # manual/scheduled/immediate/resumed
+ user_id_filter = request.args.get('user_id') # 用户ID
+ account_filter = request.args.get('account') # 账号关键字
+
+ # 转换user_id为整数
+ if user_id_filter:
+ try:
+ user_id_filter = int(user_id_filter)
+ except ValueError:
+ user_id_filter = None
+
+ result = database.get_task_logs(
+ limit=limit,
+ offset=offset,
+ date_filter=date_filter,
+ status_filter=status_filter,
+ source_filter=source_filter,
+ user_id_filter=user_id_filter,
+ account_filter=account_filter
+ )
+ return jsonify(result)
+
+
+@app.route('/yuyx/api/task/logs/clear', methods=['POST'])
+@admin_required
+def clear_old_task_logs_api():
+ """清理旧的任务日志"""
+ data = request.json or {}
+ days = data.get('days', 30)
+
+ if not isinstance(days, int) or days < 1:
+ return jsonify({"error": "天数必须是大于0的整数"}), 400
+
+ deleted_count = database.delete_old_task_logs(days)
+ return jsonify({"message": f"已删除{days}天前的{deleted_count}条日志"})
+
+
+@app.route('/yuyx/api/docker/restart', methods=['POST'])
+@admin_required
+def restart_docker_container():
+ """重启Docker容器"""
+ import subprocess
+ import os
+
+ try:
+ # 检查是否在Docker容器中运行
+ if not os.path.exists('/.dockerenv'):
+ return jsonify({"error": "当前不在Docker容器中运行"}), 400
+
+ # 记录日志
+ app_logger.info("[系统] 管理员触发Docker容器重启")
+
+ # 使用nohup在后台执行重启命令,避免阻塞
+ # 容器重启会导致当前进程终止,所以需要延迟执行
+ restart_script = """
+import os
+import time
+
+# 延迟3秒让响应返回给客户端
+time.sleep(3)
+
+# 退出Python进程,让Docker自动重启容器(restart: unless-stopped)
+os._exit(0)
+"""
+
+ # 写入临时脚本
+ with open('/tmp/restart_container.py', 'w') as f:
+ f.write(restart_script)
+
+ # 在后台执行重启脚本
+ subprocess.Popen(['python', '/tmp/restart_container.py'],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True)
+
+ return jsonify({
+ "success": True,
+ "message": "容器将在3秒后重启,请稍后刷新页面"
+ })
+
+ except Exception as e:
+ app_logger.error(f"[系统] Docker容器重启失败: {str(e)}")
+ return jsonify({"error": f"重启失败: {str(e)}"}), 500
+
+
+# ==================== 定时任务调度器 ====================
+
+def run_scheduled_task(skip_weekday_check=False):
+ """执行所有账号的浏览任务(可被手动调用,过滤重复账号)
+
+ Args:
+ skip_weekday_check: 是否跳过星期检查(立即执行时为True)
+ """
+ try:
+ from datetime import datetime
+ import pytz
+
+ config = database.get_system_config()
+ browse_type = config.get('schedule_browse_type', '应读')
+
+ # 检查今天是否在允许执行的星期列表中(立即执行时跳过此检查)
+ if not skip_weekday_check:
+ # 获取北京时间的星期几 (1=周一, 7=周日)
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ now_beijing = datetime.now(beijing_tz)
+ current_weekday = now_beijing.isoweekday() # 1-7
+
+ # 获取配置的星期列表
+ schedule_weekdays = config.get('schedule_weekdays', '1,2,3,4,5,6,7')
+ allowed_weekdays = [int(d.strip()) for d in schedule_weekdays.split(',') if d.strip()]
+
+ if current_weekday not in allowed_weekdays:
+ weekday_names = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
+ print(f"[定时任务] 今天是{weekday_names[current_weekday]},不在执行日期内,跳过执行")
+ return
+ else:
+ print(f"[立即执行] 跳过星期检查,强制执行任务")
+
+ print(f"[定时任务] 开始执行 - 浏览类型: {browse_type}")
+
+ # 获取所有已审核用户的所有账号
+ all_users = database.get_all_users()
+ approved_users = [u for u in all_users if u['status'] == 'approved']
+
+ # 用于记录已执行的账号用户名,避免重复
+ executed_usernames = set()
+ total_accounts = 0
+ skipped_duplicates = 0
+ executed_accounts = 0
+
+ for user in approved_users:
+ user_id = user['id']
+ if user_id not in user_accounts:
+ load_user_accounts(user_id)
+
+ accounts = user_accounts.get(user_id, {})
+ for account_id, account in accounts.items():
+ total_accounts += 1
+
+ # 跳过正在运行的账号
+ if account.is_running:
+ continue
+
+ # 检查账号状态,跳过已暂停的账号
+ account_status_info = database.get_account_status(account_id)
+ if account_status_info:
+ status = account_status_info['status'] if 'status' in account_status_info.keys() else 'active'
+ if status == 'suspended':
+ fail_count = account_status_info['login_fail_count'] if 'login_fail_count' in account_status_info.keys() else 0
+ print(f"[定时任务] 跳过暂停账号: {account.username} (用户:{user['username']}) - 连续{fail_count}次密码错误,需修改密码")
+ continue
+
+ # 检查账号用户名是否已经执行过(重复账号过滤)
+ if account.username in executed_usernames:
+ skipped_duplicates += 1
+ print(f"[定时任务] 跳过重复账号: {account.username} (用户:{user['username']}) - 该账号已被其他用户执行")
+ continue
+
+ # 记录该账号用户名,避免后续重复执行
+ executed_usernames.add(account.username)
+
+ print(f"[定时任务] 启动账号: {account.username} (用户:{user['username']})")
+
+ # 启动任务
+ account.is_running = True
+ account.should_stop = False
+ account.status = "运行中"
+
+ # 获取系统配置的截图开关
+ cfg = database.get_system_config()
+ enable_screenshot_scheduled = cfg.get("enable_screenshot", 0) == 1
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot_scheduled, 'scheduled'),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+ executed_accounts += 1
+
+ # 发送更新到用户
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ # 间隔启动,避免瞬间并发过高
+ time.sleep(2)
+
+ print(f"[定时任务] 执行完成 - 总账号数:{total_accounts}, 已执行:{executed_accounts}, 跳过重复:{skipped_duplicates}")
+
+ except Exception as e:
+ print(f"[定时任务] 执行出错: {str(e)}")
+ logger.error(f"[定时任务] 执行异常: {str(e)}")
+
+
+def status_push_worker():
+ """后台线程:每秒推送运行中任务的状态更新"""
+ while True:
+ try:
+ # 遍历所有运行中的任务状态
+ for account_id, status_info in list(task_status.items()):
+ user_id = status_info.get('user_id')
+ if user_id:
+ # 获取账号对象
+ if user_id in user_accounts and account_id in user_accounts[user_id]:
+ account = user_accounts[user_id][account_id]
+ account_data = account.to_dict()
+ # 推送账号状态更新
+ socketio.emit('account_update', account_data, room=f'user_{user_id}')
+ # 同时推送详细进度事件(方便前端分别处理)
+ progress = status_info.get('progress', {})
+ progress_data = {
+ 'account_id': account_id,
+ 'stage': status_info.get('detail_status', ''),
+ 'total_items': account.total_items,
+ 'browsed_items': progress.get('items', 0),
+ 'total_attachments': account.total_attachments,
+ 'viewed_attachments': progress.get('attachments', 0),
+ 'start_time': status_info.get('start_time', 0),
+ 'elapsed_seconds': account_data.get('elapsed_seconds', 0),
+ 'elapsed_display': account_data.get('elapsed_display', '')
+ }
+ socketio.emit('task_progress', progress_data, room=f'user_{user_id}')
+ time.sleep(1) # 每秒推送一次
+ except Exception as e:
+ logger.debug(f"状态推送出错: {e}")
+ time.sleep(1)
+
+
+def scheduled_task_worker():
+ """定时任务工作线程"""
+ import schedule
+
+ def cleanup_expired_captcha():
+ """清理过期验证码,防止内存泄漏"""
+ try:
+ current_time = time.time()
+ expired_keys = [k for k, v in captcha_storage.items()
+ if v["expire_time"] < current_time]
+ deleted_count = len(expired_keys)
+ for k in expired_keys:
+ del captcha_storage[k]
+ if deleted_count > 0:
+ print(f"[定时清理] 已清理 {deleted_count} 个过期验证码")
+ except Exception as e:
+ print(f"[定时清理] 清理验证码出错: {str(e)}")
+
+ def cleanup_old_data():
+ """清理7天前的截图和日志"""
+ try:
+ print(f"[定时清理] 开始清理7天前的数据...")
+
+ # 清理7天前的任务日志
+ deleted_logs = database.delete_old_task_logs(7)
+ print(f"[定时清理] 已删除 {deleted_logs} 条任务日志")
+
+ # 清理30天前的操作日志
+ deleted_operation_logs = database.clean_old_operation_logs(30)
+ print(f"[定时清理] 已删除 {deleted_operation_logs} 条操作日志")
+ # 清理7天前的截图
+ deleted_screenshots = 0
+ if os.path.exists(SCREENSHOTS_DIR):
+ cutoff_time = time.time() - (7 * 24 * 60 * 60) # 7天前的时间戳
+ for filename in os.listdir(SCREENSHOTS_DIR):
+ if filename.lower().endswith(('.png', '.jpg', '.jpeg')):
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ try:
+ # 检查文件修改时间
+ if os.path.getmtime(filepath) < cutoff_time:
+ os.remove(filepath)
+ deleted_screenshots += 1
+ except Exception as e:
+ print(f"[定时清理] 删除截图失败 {filename}: {str(e)}")
+
+ print(f"[定时清理] 已删除 {deleted_screenshots} 个截图文件")
+ print(f"[定时清理] 清理完成!")
+
+ except Exception as e:
+ print(f"[定时清理] 清理任务出错: {str(e)}")
+
+
+ def check_user_schedules():
+ """检查并执行用户定时任务"""
+ import json
+ try:
+ from datetime import datetime
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ now = datetime.now(beijing_tz)
+ current_time = now.strftime('%H:%M')
+ current_weekday = now.isoweekday()
+
+ # 获取所有启用的用户定时任务
+ enabled_schedules = database.get_enabled_user_schedules()
+
+ for schedule_config in enabled_schedules:
+ # 检查时间是否匹配
+ if schedule_config['schedule_time'] != current_time:
+ continue
+
+ # 检查星期是否匹配
+ allowed_weekdays = [int(d) for d in schedule_config.get('weekdays', '1,2,3,4,5').split(',') if d.strip()]
+ if current_weekday not in allowed_weekdays:
+ continue
+
+ # 检查今天是否已经执行过
+ last_run = schedule_config.get('last_run_at')
+ if last_run:
+ try:
+ last_run_date = datetime.strptime(last_run, '%Y-%m-%d %H:%M:%S').date()
+ if last_run_date == now.date():
+ continue # 今天已执行过
+ except:
+ pass
+
+ # 执行用户定时任务
+ user_id = schedule_config['user_id']
+ schedule_id = schedule_config['id']
+ browse_type = schedule_config.get('browse_type', '应读')
+ enable_screenshot = schedule_config.get('enable_screenshot', 1)
+
+ try:
+ account_ids = json.loads(schedule_config.get('account_ids', '[]') or '[]')
+ except:
+ account_ids = []
+
+ if not account_ids:
+ continue
+
+ print(f"[用户定时任务] 用户 {schedule_config.get('user_username', user_id)} 的任务 '{schedule_config.get('name', '')}' 开始执行")
+
+ started_count = 0
+ for account_id in account_ids:
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ continue
+ account = user_accounts[user_id][account_id]
+ if account.is_running:
+ continue
+
+ account.is_running = True
+ account.should_stop = False
+ account.status = "排队中"
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot, 'user_scheduled'),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+ started_count += 1
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ # 更新最后执行时间
+ database.update_schedule_last_run(schedule_id)
+ print(f"[用户定时任务] 已启动 {started_count} 个账号")
+
+ except Exception as e:
+ print(f"[用户定时任务] 检查出错: {str(e)}")
+ import traceback
+ traceback.print_exc()
+
+ # 每分钟检查一次配置
+ def check_and_schedule():
+ config = database.get_system_config()
+
+ # 清除旧的任务
+ schedule.clear()
+
+ # 时区转换函数:将CST时间转换为UTC时间(容器使用UTC)
+ def cst_to_utc_time(cst_time_str):
+ """将CST时间字符串(HH:MM)转换为UTC时间字符串
+
+ Args:
+ cst_time_str: CST时间字符串,格式为 HH:MM
+
+ Returns:
+ UTC时间字符串,格式为 HH:MM
+ """
+ from datetime import datetime, timedelta
+ # 解析CST时间
+ hour, minute = map(int, cst_time_str.split(':'))
+ # CST是UTC+8,所以UTC时间 = CST时间 - 8小时
+ utc_hour = (hour - 8) % 24
+ return f"{utc_hour:02d}:{minute:02d}"
+
+ # 始终添加每天凌晨3点(CST)的数据清理任务
+ cleanup_utc_time = cst_to_utc_time("03:00")
+ schedule.every().day.at(cleanup_utc_time).do(cleanup_old_data)
+ print(f"[定时任务] 已设置数据清理任务: 每天 CST 03:00 (UTC {cleanup_utc_time})")
+
+ # 每小时清理过期验证码
+ schedule.every().hour.do(cleanup_expired_captcha)
+ print(f"[定时任务] 已设置验证码清理任务: 每小时执行一次")
+
+ # 如果启用了定时浏览任务,则添加
+ if config.get('schedule_enabled'):
+ schedule_time_cst = config.get('schedule_time', '02:00')
+ schedule_time_utc = cst_to_utc_time(schedule_time_cst)
+ schedule.every().day.at(schedule_time_utc).do(run_scheduled_task)
+ print(f"[定时任务] 已设置浏览任务: 每天 CST {schedule_time_cst} (UTC {schedule_time_utc})")
+
+ # 初始检查
+ check_and_schedule()
+ last_check = time.time()
+
+ while True:
+ try:
+ # 执行待执行的任务
+ schedule.run_pending()
+
+ # 每60秒重新检查一次配置
+ if time.time() - last_check > 60:
+ check_and_schedule()
+ check_user_schedules() # 检查用户定时任务
+ last_check = time.time()
+
+ time.sleep(1)
+ except Exception as e:
+ print(f"[定时任务] 调度器出错: {str(e)}")
+ time.sleep(5)
+
+
+
+# ========== 断点续传API ==========
+@app.route('/yuyx/api/checkpoint/paused')
+@admin_required
+def checkpoint_get_paused():
+ try:
+ user_id = request.args.get('user_id', type=int)
+ tasks = checkpoint_mgr.get_paused_tasks(user_id=user_id)
+ return jsonify({'success': True, 'tasks': tasks})
+ except Exception as e:
+ logger.error(f"获取暂停任务失败: {e}")
+ return jsonify({'success': False, 'message': str(e)}), 500
+
+@app.route('/yuyx/api/checkpoint//resume', methods=['POST'])
+@admin_required
+def checkpoint_resume(task_id):
+ try:
+ checkpoint = checkpoint_mgr.get_checkpoint(task_id)
+ if not checkpoint:
+ return jsonify({'success': False, 'message': '任务不存在'}), 404
+ if checkpoint['status'] != 'paused':
+ return jsonify({'success': False, 'message': '任务未暂停'}), 400
+ if checkpoint_mgr.resume_task(task_id):
+ import threading
+ threading.Thread(
+ target=run_task,
+ args=(checkpoint['user_id'], checkpoint['account_id'], checkpoint['browse_type'], True, 'resumed'),
+ daemon=True
+ ).start()
+ return jsonify({'success': True})
+ return jsonify({'success': False}), 500
+ except Exception as e:
+ logger.error(f"恢复任务失败: {e}")
+ return jsonify({'success': False, 'message': str(e)}), 500
+
+@app.route('/yuyx/api/checkpoint//abandon', methods=['POST'])
+@admin_required
+def checkpoint_abandon(task_id):
+ try:
+ if checkpoint_mgr.abandon_task(task_id):
+ return jsonify({'success': True})
+ return jsonify({'success': False}), 404
+ except Exception as e:
+ return jsonify({'success': False, 'message': str(e)}), 500
+
+# 初始化浏览器池(在后台线程中预热,不阻塞启动)
+# ==================== 用户定时任务API ====================
+
+@app.route('/api/schedules', methods=['GET'])
+@login_required
+def get_user_schedules_api():
+ """获取当前用户的所有定时任务"""
+ schedules = database.get_user_schedules(current_user.id)
+ import json
+ for s in schedules:
+ try:
+ s['account_ids'] = json.loads(s.get('account_ids', '[]') or '[]')
+ except:
+ s['account_ids'] = []
+ return jsonify(schedules)
+
+
+@app.route('/api/schedules', methods=['POST'])
+@login_required
+def create_user_schedule_api():
+ """创建用户定时任务"""
+ data = request.json
+
+ name = data.get('name', '我的定时任务')
+ schedule_time = data.get('schedule_time', '08:00')
+ weekdays = data.get('weekdays', '1,2,3,4,5')
+ browse_type = data.get('browse_type', '应读')
+ enable_screenshot = data.get('enable_screenshot', 1)
+ account_ids = data.get('account_ids', [])
+
+ import re
+ if not re.match(r'^\d{2}:\d{2}$', schedule_time):
+ return jsonify({"error": "时间格式不正确,应为 HH:MM"}), 400
+
+ schedule_id = database.create_user_schedule(
+ user_id=current_user.id,
+ name=name,
+ schedule_time=schedule_time,
+ weekdays=weekdays,
+ browse_type=browse_type,
+ enable_screenshot=enable_screenshot,
+ account_ids=account_ids
+ )
+
+ if schedule_id:
+ return jsonify({"success": True, "id": schedule_id})
+ return jsonify({"error": "创建失败"}), 500
+
+
+@app.route('/api/schedules/', methods=['GET'])
+@login_required
+def get_schedule_detail_api(schedule_id):
+ """获取定时任务详情"""
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ import json
+ try:
+ schedule['account_ids'] = json.loads(schedule.get('account_ids', '[]') or '[]')
+ except:
+ schedule['account_ids'] = []
+ return jsonify(schedule)
+
+
+@app.route('/api/schedules/', methods=['PUT'])
+@login_required
+def update_schedule_api(schedule_id):
+ """更新定时任务"""
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ data = request.json
+ allowed_fields = ['name', 'schedule_time', 'weekdays', 'browse_type',
+ 'enable_screenshot', 'account_ids', 'enabled']
+
+ update_data = {k: v for k, v in data.items() if k in allowed_fields}
+
+ if 'schedule_time' in update_data:
+ import re
+ if not re.match(r'^\d{2}:\d{2}$', update_data['schedule_time']):
+ return jsonify({"error": "时间格式不正确"}), 400
+
+ success = database.update_user_schedule(schedule_id, **update_data)
+ if success:
+ return jsonify({"success": True})
+ return jsonify({"error": "更新失败"}), 500
+
+
+@app.route('/api/schedules/', methods=['DELETE'])
+@login_required
+def delete_schedule_api(schedule_id):
+ """删除定时任务"""
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ success = database.delete_user_schedule(schedule_id)
+ if success:
+ return jsonify({"success": True})
+ return jsonify({"error": "删除失败"}), 500
+
+
+@app.route('/api/schedules//toggle', methods=['POST'])
+@login_required
+def toggle_schedule_api(schedule_id):
+ """启用/禁用定时任务"""
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ data = request.json
+ enabled = data.get('enabled', not schedule['enabled'])
+
+ success = database.toggle_user_schedule(schedule_id, enabled)
+ if success:
+ return jsonify({"success": True, "enabled": enabled})
+ return jsonify({"error": "操作失败"}), 500
+
+
+@app.route('/api/schedules//run', methods=['POST'])
+@login_required
+def run_schedule_now_api(schedule_id):
+ """立即执行定时任务"""
+ import json
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ try:
+ account_ids = json.loads(schedule.get('account_ids', '[]') or '[]')
+ except:
+ account_ids = []
+
+ if not account_ids:
+ return jsonify({"error": "没有配置账号"}), 400
+
+ user_id = current_user.id
+ browse_type = schedule['browse_type']
+ enable_screenshot = schedule['enable_screenshot']
+
+ started = []
+ for account_id in account_ids:
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ continue
+ account = user_accounts[user_id][account_id]
+ if account.is_running:
+ continue
+
+ account.is_running = True
+ account.should_stop = False
+ account.status = "排队中"
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot, 'user_scheduled'),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+ started.append(account_id)
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ database.update_schedule_last_run(schedule_id)
+
+ return jsonify({
+ "success": True,
+ "started_count": len(started),
+ "message": f"已启动 {len(started)} 个账号"
+ })
+
+
+# ==================== 批量操作API ====================
+
+@app.route('/api/accounts/batch/start', methods=['POST'])
+@login_required
+def batch_start_accounts():
+ """批量启动账号"""
+ user_id = current_user.id
+ data = request.json
+
+ account_ids = data.get('account_ids', [])
+ browse_type = data.get('browse_type', '应读')
+ enable_screenshot = data.get('enable_screenshot', True)
+
+ if not account_ids:
+ return jsonify({"error": "请选择要启动的账号"}), 400
+
+ started = []
+ failed = []
+
+ for account_id in account_ids:
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ failed.append({'id': account_id, 'reason': '账号不存在'})
+ continue
+
+ account = user_accounts[user_id][account_id]
+
+ if account.is_running:
+ failed.append({'id': account_id, 'reason': '已在运行中'})
+ continue
+
+ account.is_running = True
+ account.should_stop = False
+ account.status = "排队中"
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot, 'batch'),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+ started.append(account_id)
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ return jsonify({
+ "success": True,
+ "started_count": len(started),
+ "failed_count": len(failed),
+ "started": started,
+ "failed": failed
+ })
+
+
+@app.route('/api/accounts/batch/stop', methods=['POST'])
+@login_required
+def batch_stop_accounts():
+ """批量停止账号"""
+ user_id = current_user.id
+ data = request.json
+
+ account_ids = data.get('account_ids', [])
+
+ if not account_ids:
+ return jsonify({"error": "请选择要停止的账号"}), 400
+
+ stopped = []
+
+ for account_id in account_ids:
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ continue
+
+ account = user_accounts[user_id][account_id]
+
+ if not account.is_running:
+ continue
+
+ account.should_stop = True
+ account.status = "正在停止"
+ stopped.append(account_id)
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ return jsonify({
+ "success": True,
+ "stopped_count": len(stopped),
+ "stopped": stopped
+ })
+
+if __name__ == '__main__':
+ print("=" * 60)
+ print("知识管理平台自动化工具 - 多用户版")
+ print("=" * 60)
+
+ # 初始化数据库
+ database.init_database()
+ checkpoint_mgr = get_checkpoint_manager()
+ print("✓ 任务断点管理器已初始化")
+
+ # 加载系统配置(并发设置)
+ try:
+ system_config = database.get_system_config()
+ if system_config:
+ # 使用globals()修改全局变量
+ globals()['max_concurrent_global'] = system_config.get('max_concurrent_global', 2)
+ globals()['max_concurrent_per_account'] = system_config.get('max_concurrent_per_account', 1)
+
+ # 重新创建信号量
+ globals()['global_semaphore'] = threading.Semaphore(globals()['max_concurrent_global'])
+
+ print(f"✓ 已加载并发配置: 全局={globals()['max_concurrent_global']}, 单账号={globals()['max_concurrent_per_account']}")
+ except Exception as e:
+ print(f"警告: 加载并发配置失败,使用默认值: {e}")
+
+ # 主线程初始化浏览器(Playwright不支持跨线程)
+ print("\n正在初始化浏览器管理器...")
+ init_browser_manager()
+
+ # 启动定时任务调度器
+ print("\n启动定时任务调度器...")
+ scheduler_thread = threading.Thread(target=scheduled_task_worker, daemon=True)
+ scheduler_thread.start()
+ print("✓ 定时任务调度器已启动")
+
+ # 启动状态推送线程(每秒推送运行中任务状态)
+ status_thread = threading.Thread(target=status_push_worker, daemon=True)
+ status_thread.start()
+ print("✓ 状态推送线程已启动(1秒/次)")
+
+ # 启动Web服务器
+ print("\n服务器启动中...")
+ print(f"用户访问地址: http://{config.SERVER_HOST}:{config.SERVER_PORT}")
+ print(f"后台管理地址: http://{config.SERVER_HOST}:{config.SERVER_PORT}/yuyx")
+ print("默认管理员: admin/admin")
+ print("=" * 60 + "\n")
+
+ # 同步初始化浏览器池(必须在socketio.run之前,否则eventlet会导致asyncio冲突)
+ try:
+ system_cfg = database.get_system_config()
+ pool_size = system_cfg.get('max_screenshot_concurrent', 3) if system_cfg else 3
+ print(f"正在预热 {pool_size} 个浏览器实例(截图加速)...")
+ init_browser_worker_pool(pool_size=pool_size)
+ print("✓ 浏览器池初始化完成")
+ except Exception as e:
+ print(f"警告: 浏览器池初始化失败: {e}")
+
+ socketio.run(app, host=config.SERVER_HOST, port=config.SERVER_PORT, debug=config.DEBUG, allow_unsafe_werkzeug=True)
+
+
diff --git a/app.py.backup_20251210_102119 b/app.py.backup_20251210_102119
new file mode 100755
index 0000000..91cd5b9
--- /dev/null
+++ b/app.py.backup_20251210_102119
@@ -0,0 +1,3411 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+知识管理平台自动化工具 - 多用户版本
+支持用户注册登录、后台管理、数据隔离
+"""
+
+# 设置时区为中国标准时间(CST, UTC+8)
+import os
+os.environ['TZ'] = 'Asia/Shanghai'
+try:
+ import time
+ time.tzset()
+except AttributeError:
+ pass # Windows系统不支持tzset()
+
+import pytz
+from datetime import datetime
+from flask import Flask, render_template, request, jsonify, send_from_directory, redirect, url_for, session
+from flask_socketio import SocketIO, emit, join_room, leave_room
+from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
+import threading
+import time
+import json
+import os
+from datetime import datetime, timedelta, timezone
+from functools import wraps
+
+# 导入数据库模块和核心模块
+import database
+import requests
+from browser_pool import get_browser_pool, init_browser_pool
+from browser_pool_worker import get_browser_worker_pool, init_browser_worker_pool, shutdown_browser_worker_pool
+from playwright_automation import PlaywrightBrowserManager, PlaywrightAutomation, BrowseResult
+from api_browser import APIBrowser, APIBrowseResult
+from browser_installer import check_and_install_browser
+# ========== 优化模块导入 ==========
+from app_config import get_config
+from app_logger import init_logging, get_logger, audit_logger
+from app_security import (
+ ip_rate_limiter, require_ip_not_locked,
+ validate_username, validate_password, validate_email,
+ is_safe_path, sanitize_filename, get_client_ip
+)
+from app_utils import verify_and_consume_captcha
+
+
+
+# ========== 初始化配置 ==========
+config = get_config()
+app = Flask(__name__)
+app.config.from_object(config)
+# 确保SECRET_KEY已设置
+if not app.config.get('SECRET_KEY'):
+ raise RuntimeError("SECRET_KEY未配置,请检查app_config.py")
+socketio = SocketIO(
+ app,
+ cors_allowed_origins="*",
+ async_mode='threading', # 明确指定async模式
+ ping_timeout=60, # ping超时60秒
+ ping_interval=25, # 每25秒ping一次
+ logger=False, # 禁用socketio debug日志
+ engineio_logger=False
+)
+
+# ========== 初始化日志系统 ==========
+init_logging(log_level=config.LOG_LEVEL, log_file=config.LOG_FILE)
+logger = get_logger('app')
+logger.info("="*60)
+logger.info("知识管理平台自动化工具 - 多用户版")
+logger.info("="*60)
+logger.info(f"Session配置: COOKIE_NAME={app.config.get('SESSION_COOKIE_NAME', 'session')}, "
+ f"SAMESITE={app.config.get('SESSION_COOKIE_SAMESITE', 'None')}, "
+ f"HTTPONLY={app.config.get('SESSION_COOKIE_HTTPONLY', 'None')}, "
+ f"SECURE={app.config.get('SESSION_COOKIE_SECURE', 'None')}, "
+ f"PATH={app.config.get('SESSION_COOKIE_PATH', 'None')}")
+
+
+# Flask-Login 配置
+login_manager = LoginManager()
+login_manager.init_app(app)
+login_manager.login_view = 'login_page'
+
+@login_manager.unauthorized_handler
+def unauthorized():
+ """处理未授权访问 - API请求返回JSON,页面请求重定向"""
+ if request.path.startswith('/api/') or request.path.startswith('/yuyx/api/'):
+ return jsonify({"error": "请先登录", "code": "unauthorized"}), 401
+ return redirect(url_for('login_page', next=request.url))
+
+# 截图目录
+SCREENSHOTS_DIR = config.SCREENSHOTS_DIR
+os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
+
+# 全局变量
+browser_manager = None
+user_accounts = {} # {user_id: {account_id: Account对象}}
+active_tasks = {} # {account_id: Thread对象}
+task_status = {} # {account_id: {"user_id": x, "username": y, "status": "排队中/运行中", "detail_status": "具体状态", "browse_type": z, "start_time": t, "source": s, "progress": {...}, "is_vip": bool}}
+
+# VIP优先级队列
+vip_task_queue = [] # VIP用户任务队列
+normal_task_queue = [] # 普通用户任务队列
+task_queue_lock = threading.Lock()
+log_cache = {} # {user_id: [logs]} 每个用户独立的日志缓存
+log_cache_total_count = 0 # 全局日志总数,防止无限增长
+
+# 日志缓存限制
+MAX_LOGS_PER_USER = config.MAX_LOGS_PER_USER # 每个用户最多100条
+MAX_TOTAL_LOGS = config.MAX_TOTAL_LOGS # 全局最多1000条,防止内存泄漏
+
+# 并发控制:每个用户同时最多运行1个账号(避免内存不足)
+# 验证码存储:{session_id: {"code": "1234", "expire_time": timestamp, "failed_attempts": 0}}
+captcha_storage = {}
+
+# IP限流存储:{ip: {"attempts": count, "lock_until": timestamp, "first_attempt": timestamp}}
+ip_rate_limit = {}
+
+# 限流配置 - 从 config 读取,避免硬编码
+MAX_CAPTCHA_ATTEMPTS = config.MAX_CAPTCHA_ATTEMPTS
+MAX_IP_ATTEMPTS_PER_HOUR = config.MAX_IP_ATTEMPTS_PER_HOUR
+IP_LOCK_DURATION = config.IP_LOCK_DURATION
+# 全局限制:整个系统同时最多运行N个账号(线程本地架构,每个线程独立浏览器,内存占用约200MB/浏览器)
+max_concurrent_per_account = config.MAX_CONCURRENT_PER_ACCOUNT
+max_concurrent_global = config.MAX_CONCURRENT_GLOBAL
+user_semaphores = {} # {user_id: Semaphore}
+global_semaphore = threading.Semaphore(max_concurrent_global)
+
+# 截图专用信号量:限制同时进行的截图任务数量为1(避免资源竞争)
+# ���图信号量将在首次使用时初始化
+screenshot_semaphore = None
+screenshot_semaphore_lock = threading.Lock()
+
+def get_screenshot_semaphore():
+ """获取截图信号量(懒加载,根据配置动态创建)"""
+ global screenshot_semaphore
+ with screenshot_semaphore_lock:
+ config = database.get_system_config()
+ max_concurrent = config.get('max_screenshot_concurrent', 3)
+ if screenshot_semaphore is None:
+ screenshot_semaphore = threading.Semaphore(max_concurrent)
+ return screenshot_semaphore, max_concurrent
+
+
+class User(UserMixin):
+ """Flask-Login 用户类"""
+ def __init__(self, user_id):
+ self.id = user_id
+
+
+class Admin(UserMixin):
+ """管理员类"""
+ def __init__(self, admin_id):
+ self.id = admin_id
+ self.is_admin = True
+
+
+class Account:
+ """账号类"""
+ def __init__(self, account_id, user_id, username, password, remember=True, remark=''):
+ self.id = account_id
+ self.user_id = user_id
+ self.username = username
+ self.password = password
+ self.remember = remember
+ self.remark = remark
+ self.status = "未开始"
+ self.is_running = False
+ self.should_stop = False
+ self.total_items = 0
+ self.total_attachments = 0
+ self.automation = None
+ self.last_browse_type = "注册前未读"
+ self.proxy_config = None # 保存代理配置,浏览和截图共用
+
+ def to_dict(self):
+ result = {
+ "id": self.id,
+ "username": self.username,
+ "status": self.status,
+ "remark": self.remark,
+ "total_items": self.total_items,
+ "total_attachments": self.total_attachments,
+ "is_running": self.is_running
+ }
+ # 添加详细进度信息(如果有)
+ if self.id in task_status:
+ ts = task_status[self.id]
+ progress = ts.get('progress', {})
+ result['detail_status'] = ts.get('detail_status', '')
+ result['progress_items'] = progress.get('items', 0)
+ result['progress_attachments'] = progress.get('attachments', 0)
+ result['start_time'] = ts.get('start_time', 0)
+ # 计算运行时长
+ if ts.get('start_time'):
+ import time
+ elapsed = int(time.time() - ts['start_time'])
+ result['elapsed_seconds'] = elapsed
+ mins, secs = divmod(elapsed, 60)
+ result['elapsed_display'] = f"{mins}分{secs}秒"
+ else:
+ # 非运行状态下,根据status设置detail_status
+ status_map = {
+ '已完成': '任务完成',
+ '截图中': '正在截图',
+ '浏览完成': '浏览完成',
+ '登录失败': '登录失败',
+ '已暂停': '任务已暂停'
+ }
+ for key, val in status_map.items():
+ if key in self.status:
+ result['detail_status'] = val
+ break
+ return result
+
+
+@login_manager.user_loader
+def load_user(user_id):
+ """Flask-Login 用户加载"""
+ user = database.get_user_by_id(int(user_id))
+ if user:
+ return User(user['id'])
+ return None
+
+
+def admin_required(f):
+ """管理员权限装饰器"""
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ logger.debug(f"[admin_required] Session内容: {dict(session)}")
+ logger.debug(f"[admin_required] Cookies: {request.cookies}")
+ if 'admin_id' not in session:
+ logger.warning(f"[admin_required] 拒绝访问 {request.path} - session中无admin_id")
+ return jsonify({"error": "需要管理员权限"}), 403
+ logger.info(f"[admin_required] 管理员 {session.get('admin_username')} 访问 {request.path}")
+ return f(*args, **kwargs)
+ return decorated_function
+
+
+def log_to_client(message, user_id=None, account_id=None):
+ """发送日志到Web客户端(用户隔离)"""
+ beijing_tz = timezone(timedelta(hours=8))
+ timestamp = datetime.now(beijing_tz).strftime('%H:%M:%S')
+ log_data = {
+ 'timestamp': timestamp,
+ 'message': message,
+ 'account_id': account_id
+ }
+
+ # 如果指定了user_id,则缓存到该用户的日志
+ if user_id:
+ global log_cache_total_count
+ if user_id not in log_cache:
+ log_cache[user_id] = []
+ log_cache[user_id].append(log_data)
+ log_cache_total_count += 1
+
+ # 持久化到数据库 (已禁用,使用task_logs表代替)
+ # try:
+ # database.save_operation_log(user_id, message, account_id, 'INFO')
+ # except Exception as e:
+ # print(f"保存日志到数据库失败: {e}")
+
+ # 单用户限制
+ if len(log_cache[user_id]) > MAX_LOGS_PER_USER:
+ log_cache[user_id].pop(0)
+ log_cache_total_count -= 1
+
+ # 全局限制 - 如果超过总数限制,清理日志最多的用户
+ while log_cache_total_count > MAX_TOTAL_LOGS:
+ if log_cache:
+ max_user = max(log_cache.keys(), key=lambda u: len(log_cache[u]))
+ if log_cache[max_user]:
+ log_cache[max_user].pop(0)
+ log_cache_total_count -= 1
+ else:
+ break
+ else:
+ break
+
+ # 发送到该用户的room
+ socketio.emit('log', log_data, room=f'user_{user_id}')
+
+ # 控制台日志:添加账号短标识便于区分
+ if account_id:
+ # 显示账号ID前4位作为标识
+ short_id = account_id[:4] if len(account_id) >= 4 else account_id
+ print(f"[{timestamp}] U{user_id}:{short_id} | {message}")
+ else:
+ print(f"[{timestamp}] U{user_id} | {message}")
+
+
+
+def get_proxy_from_api(api_url, max_retries=3):
+ """从API获取代理IP(支持重试)
+
+ Args:
+ api_url: 代理API地址
+ max_retries: 最大重试次数
+
+ Returns:
+ 代理服务器地址(格式: http://IP:PORT)或 None
+ """
+ import re
+ # IP:PORT 格式正则
+ ip_port_pattern = re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{1,5}$')
+
+ for attempt in range(max_retries):
+ try:
+ response = requests.get(api_url, timeout=10)
+ if response.status_code == 200:
+ text = response.text.strip()
+
+ # 尝试解析JSON响应
+ try:
+ import json
+ data = json.loads(text)
+ # 检查是否是错误响应
+ if isinstance(data, dict):
+ if data.get('status') != 200 and data.get('status') != 0:
+ error_msg = data.get('msg', data.get('message', '未知错误'))
+ print(f"✗ 代理API返回错误: {error_msg} (尝试 {attempt + 1}/{max_retries})")
+ if attempt < max_retries - 1:
+ time.sleep(1)
+ continue
+ # 尝试从JSON中获取IP
+ ip_port = data.get('ip') or data.get('proxy') or data.get('data')
+ if ip_port:
+ text = str(ip_port).strip()
+ except (json.JSONDecodeError, ValueError):
+ # 不是JSON,继续使用原始文本
+ pass
+
+ # 验证IP:PORT格式
+ if ip_port_pattern.match(text):
+ proxy_server = f"http://{text}"
+ print(f"✓ 获取代理成功: {proxy_server} (尝试 {attempt + 1}/{max_retries})")
+ return proxy_server
+ else:
+ print(f"✗ 代理格式无效: {text[:50]} (尝试 {attempt + 1}/{max_retries})")
+ else:
+ print(f"✗ 获取代理失败: HTTP {response.status_code} (尝试 {attempt + 1}/{max_retries})")
+ except Exception as e:
+ print(f"✗ 获取代理异常: {str(e)} (尝试 {attempt + 1}/{max_retries})")
+
+ if attempt < max_retries - 1:
+ time.sleep(1)
+
+ print(f"✗ 获取代理失败,已重试 {max_retries} 次,将不使用代理继续")
+ return None
+
+def init_browser_manager():
+ """初始化浏览器管理器"""
+ global browser_manager
+ if browser_manager is None:
+ print("正在初始化Playwright浏览器管理器...")
+
+ if not check_and_install_browser(log_callback=lambda msg, account_id=None: print(msg)):
+ print("浏览器环境检查失败!")
+ return False
+
+ browser_manager = PlaywrightBrowserManager(
+ headless=True,
+ log_callback=lambda msg, account_id=None: print(msg)
+ )
+
+ try:
+ # 不再需要initialize(),每个账号会创建独立浏览器
+ print("Playwright浏览器管理器创建成功!")
+ return True
+ except Exception as e:
+ print(f"Playwright初始化失败: {str(e)}")
+ return False
+ return True
+
+
+# ==================== 前端路由 ====================
+
+@app.route('/')
+def index():
+ """主页 - 重定向到登录或应用"""
+ if current_user.is_authenticated:
+ return redirect(url_for('app_page'))
+ return redirect(url_for('login_page'))
+
+
+@app.route('/login')
+def login_page():
+ """登录页面"""
+ return render_template('login.html')
+
+
+@app.route('/register')
+def register_page():
+ """注册页面"""
+ return render_template('register.html')
+
+
+@app.route('/app')
+@login_required
+def app_page():
+ """主应用页面"""
+ return render_template('index.html')
+
+
+@app.route('/yuyx')
+def admin_login_page():
+ """后台登录页面"""
+ if 'admin_id' in session:
+ return redirect(url_for('admin_page'))
+ return render_template('admin_login.html')
+
+
+@app.route('/yuyx/admin')
+@admin_required
+def admin_page():
+ """后台管理页面"""
+ return render_template('admin.html')
+
+
+
+
+@app.route('/yuyx/vip')
+@admin_required
+def vip_admin_page():
+ """VIP管理页面"""
+ return render_template('vip_admin.html')
+
+
+# ==================== 用户认证API ====================
+
+@app.route('/api/register', methods=['POST'])
+@require_ip_not_locked # IP限流保护
+def register():
+ """用户注册"""
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ email = data.get('email', '').strip()
+ captcha_session = data.get('captcha_session', '')
+ captcha_code = data.get('captcha', '').strip()
+
+ if not username or not password:
+ return jsonify({"error": "用户名和密码不能为空"}), 400
+
+ # 获取客户端IP(用于IP限流检查)
+ client_ip = get_client_ip()
+
+ # 检查IP限流
+ allowed, error_msg = check_ip_rate_limit(client_ip)
+ if not allowed:
+ return jsonify({"error": error_msg}), 429
+
+ # 验证验证码
+ success, message = verify_and_consume_captcha(captcha_session, captcha_code, captcha_storage, MAX_CAPTCHA_ATTEMPTS)
+ if not success:
+ # 验证失败,记录IP失败尝试(注册特有的IP限流逻辑)
+ is_locked = record_failed_captcha(client_ip)
+ if is_locked:
+ return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
+ return jsonify({"error": message}), 400
+
+ user_id = database.create_user(username, password, email)
+ if user_id:
+ return jsonify({"success": True, "message": "注册成功,请等待管理员审核"})
+ else:
+ return jsonify({"error": "用户名已存在"}), 400
+
+
+# ==================== 验证码API ====================
+import random
+from task_checkpoint import get_checkpoint_manager, TaskStage
+
+checkpoint_mgr = None # 任务断点管理器
+
+def check_ip_rate_limit(ip_address):
+ """检查IP是否被限流"""
+ current_time = time.time()
+
+ # 清理过期的IP记录
+ expired_ips = [ip for ip, data in ip_rate_limit.items()
+ if data.get("lock_until", 0) < current_time and
+ current_time - data.get("first_attempt", current_time) > 3600]
+ for ip in expired_ips:
+ del ip_rate_limit[ip]
+
+ # 检查IP是否被锁定
+ if ip_address in ip_rate_limit:
+ ip_data = ip_rate_limit[ip_address]
+
+ # 如果IP被锁定且未到解锁时间
+ if ip_data.get("lock_until", 0) > current_time:
+ remaining_time = int(ip_data["lock_until"] - current_time)
+ return False, "IP已被锁定,请{}分钟后再试".format(remaining_time // 60 + 1)
+
+ # 如果超过1小时,重置计数
+ if current_time - ip_data.get("first_attempt", current_time) > 3600:
+ ip_rate_limit[ip_address] = {
+ "attempts": 0,
+ "first_attempt": current_time
+ }
+
+ return True, None
+
+
+def record_failed_captcha(ip_address):
+ """记录验证码失败尝试"""
+ current_time = time.time()
+
+ if ip_address not in ip_rate_limit:
+ ip_rate_limit[ip_address] = {
+ "attempts": 1,
+ "first_attempt": current_time
+ }
+ else:
+ ip_rate_limit[ip_address]["attempts"] += 1
+
+ # 检查是否超过限制
+ if ip_rate_limit[ip_address]["attempts"] >= MAX_IP_ATTEMPTS_PER_HOUR:
+ ip_rate_limit[ip_address]["lock_until"] = current_time + IP_LOCK_DURATION
+ return True # 表示IP已被锁定
+
+ return False # 表示还未锁定
+
+
+@app.route("/api/generate_captcha", methods=["POST"])
+def generate_captcha():
+ """生成4位数字验证码"""
+ import uuid
+ session_id = str(uuid.uuid4())
+
+ # 生成4位随机数字
+ code = "".join([str(random.randint(0, 9)) for _ in range(4)])
+
+ # 存储验证码,5分钟过期
+ captcha_storage[session_id] = {
+ "code": code,
+ "expire_time": time.time() + 300,
+ "failed_attempts": 0
+ }
+
+ # 清理过期验证码
+ expired_keys = [k for k, v in captcha_storage.items() if v["expire_time"] < time.time()]
+ for k in expired_keys:
+ del captcha_storage[k]
+
+ return jsonify({"session_id": session_id, "captcha": code})
+
+
+@app.route('/api/login', methods=['POST'])
+@require_ip_not_locked # IP限流保护
+def login():
+ """用户登录"""
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ captcha_session = data.get('captcha_session', '')
+ captcha_code = data.get('captcha', '').strip()
+ need_captcha = data.get('need_captcha', False)
+
+ # 如果需要验证码,验证验证码
+ if need_captcha:
+ success, message = verify_and_consume_captcha(captcha_session, captcha_code, captcha_storage)
+ if not success:
+ return jsonify({"error": message}), 400
+
+ # 先检查用户是否存在
+ user_exists = database.get_user_by_username(username)
+ if not user_exists:
+ return jsonify({"error": "账号未注册", "need_captcha": True}), 401
+
+ # 检查密码是否正确
+ user = database.verify_user(username, password)
+ if not user:
+ # 密码错误
+ return jsonify({"error": "密码错误", "need_captcha": True}), 401
+
+ # 检查审核状态
+ if user['status'] != 'approved':
+ return jsonify({"error": "账号未审核,请等待管理员审核", "need_captcha": False}), 401
+
+ # 登录成功
+ user_obj = User(user['id'])
+ login_user(user_obj)
+ load_user_accounts(user['id'])
+ return jsonify({"success": True})
+
+
+@app.route('/api/logout', methods=['POST'])
+@login_required
+def logout():
+ """用户登出"""
+ logout_user()
+ return jsonify({"success": True})
+
+
+# ==================== 管理员认证API ====================
+
+@app.route('/yuyx/api/debug-config', methods=['GET'])
+def debug_config():
+ """调试配置信息"""
+ return jsonify({
+ "secret_key_set": bool(app.secret_key),
+ "secret_key_length": len(app.secret_key) if app.secret_key else 0,
+ "session_config": {
+ "SESSION_COOKIE_NAME": app.config.get('SESSION_COOKIE_NAME'),
+ "SESSION_COOKIE_SECURE": app.config.get('SESSION_COOKIE_SECURE'),
+ "SESSION_COOKIE_HTTPONLY": app.config.get('SESSION_COOKIE_HTTPONLY'),
+ "SESSION_COOKIE_SAMESITE": app.config.get('SESSION_COOKIE_SAMESITE'),
+ "PERMANENT_SESSION_LIFETIME": str(app.config.get('PERMANENT_SESSION_LIFETIME')),
+ },
+ "current_session": dict(session),
+ "cookies_received": list(request.cookies.keys())
+ })
+
+
+@app.route('/yuyx/api/login', methods=['POST'])
+@require_ip_not_locked # IP限流保护
+def admin_login():
+ """管理员登录(支持JSON和form-data两种格式)"""
+ # 兼容JSON和form-data两种提交方式
+ if request.is_json:
+ data = request.json
+ else:
+ data = request.form
+
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ captcha_session = data.get('captcha_session', '')
+ captcha_code = data.get('captcha', '').strip()
+ need_captcha = data.get('need_captcha', False)
+
+ # 如果需要验证码,验证验证码
+ if need_captcha:
+ success, message = verify_and_consume_captcha(captcha_session, captcha_code, captcha_storage)
+ if not success:
+ if request.is_json:
+ return jsonify({"error": message}), 400
+ else:
+ return redirect(url_for('admin_login_page'))
+
+ admin = database.verify_admin(username, password)
+ if admin:
+ # 清除旧session,确保干净的状态
+ session.clear()
+ # 设置管理员session
+ session['admin_id'] = admin['id']
+ session['admin_username'] = admin['username']
+ session.permanent = True # 设置为永久会话(使用PERMANENT_SESSION_LIFETIME配置)
+ session.modified = True # 强制标记session为已修改,确保保存
+
+ logger.info(f"[admin_login] 管理员 {username} 登录成功, session已设置: admin_id={admin['id']}")
+ logger.debug(f"[admin_login] Session内容: {dict(session)}")
+ logger.debug(f"[admin_login] Cookie将被设置: name={app.config.get('SESSION_COOKIE_NAME', 'session')}")
+
+ # 根据请求类型返回不同响应
+ if request.is_json:
+ # JSON请求:返回JSON响应(给JavaScript使用)
+ response = jsonify({"success": True, "redirect": "/yuyx/admin"})
+ return response
+ else:
+ # form-data请求:直接重定向到后台页面
+ return redirect(url_for('admin_page'))
+ else:
+ logger.warning(f"[admin_login] 管理员 {username} 登录失败 - 用户名或密码错误")
+ if request.is_json:
+ return jsonify({"error": "管理员用户名或密码错误", "need_captcha": True}), 401
+ else:
+ # form提交失败,重定向回登录页(TODO: 可以添加flash消息)
+ return redirect(url_for('admin_login_page'))
+
+
+@app.route('/yuyx/api/logout', methods=['POST'])
+@admin_required
+def admin_logout():
+ """管理员登出"""
+ session.pop('admin_id', None)
+ session.pop('admin_username', None)
+ return jsonify({"success": True})
+
+
+@app.route('/yuyx/api/users', methods=['GET'])
+@admin_required
+def get_all_users():
+ """获取所有用户"""
+ users = database.get_all_users()
+ return jsonify(users)
+
+
+@app.route('/yuyx/api/users/pending', methods=['GET'])
+@admin_required
+def get_pending_users():
+ """获取待审核用户"""
+ users = database.get_pending_users()
+ return jsonify(users)
+
+
+@app.route('/yuyx/api/users//approve', methods=['POST'])
+@admin_required
+def approve_user_route(user_id):
+ """审核通过用户"""
+ if database.approve_user(user_id):
+ return jsonify({"success": True})
+ return jsonify({"error": "审核失败"}), 400
+
+
+@app.route('/yuyx/api/users//reject', methods=['POST'])
+@admin_required
+def reject_user_route(user_id):
+ """拒绝用户"""
+ if database.reject_user(user_id):
+ return jsonify({"success": True})
+ return jsonify({"error": "拒绝失败"}), 400
+
+
+@app.route('/yuyx/api/users/', methods=['DELETE'])
+@admin_required
+def delete_user_route(user_id):
+ """删除用户"""
+ if database.delete_user(user_id):
+ # 清理内存中的账号数据
+ if user_id in user_accounts:
+ del user_accounts[user_id]
+
+ # 清理用户信号量,防止内存泄漏
+ if user_id in user_semaphores:
+ del user_semaphores[user_id]
+
+ # 清理用户日志缓存,防止内存泄漏
+ global log_cache_total_count
+ if user_id in log_cache:
+ log_cache_total_count -= len(log_cache[user_id])
+ del log_cache[user_id]
+
+ return jsonify({"success": True})
+ return jsonify({"error": "删除失败"}), 400
+
+
+@app.route('/yuyx/api/stats', methods=['GET'])
+@admin_required
+def get_system_stats():
+ """获取系统统计"""
+ stats = database.get_system_stats()
+ # 从session获取管理员用户名
+ stats["admin_username"] = session.get('admin_username', 'admin')
+ return jsonify(stats)
+
+
+@app.route('/yuyx/api/docker_stats', methods=['GET'])
+@admin_required
+def get_docker_stats():
+ """获取Docker容器运行状态"""
+ import subprocess
+
+ docker_status = {
+ 'running': False,
+ 'container_name': 'N/A',
+ 'uptime': 'N/A',
+ 'memory_usage': 'N/A',
+ 'memory_limit': 'N/A',
+ 'memory_percent': 'N/A',
+ 'cpu_percent': 'N/A',
+ 'status': 'Unknown'
+ }
+
+ try:
+ # 检查是否在Docker容器内
+ if os.path.exists('/.dockerenv'):
+ docker_status['running'] = True
+
+ # 获取容器名称
+ try:
+ with open('/etc/hostname', 'r') as f:
+ docker_status['container_name'] = f.read().strip()
+ except Exception as e:
+ logger.debug(f"读取容器名称失败: {e}")
+
+ # 获取内存使用情况 (cgroup v2)
+ try:
+ # 尝试cgroup v2路径
+ if os.path.exists('/sys/fs/cgroup/memory.current'):
+ # Read total memory
+ with open('/sys/fs/cgroup/memory.current', 'r') as f:
+ mem_total = int(f.read().strip())
+
+ # Read cache from memory.stat
+ cache = 0
+ if os.path.exists('/sys/fs/cgroup/memory.stat'):
+ with open('/sys/fs/cgroup/memory.stat', 'r') as f:
+ for line in f:
+ if line.startswith('inactive_file '):
+ cache = int(line.split()[1])
+ break
+
+ # Actual memory = total - cache
+ mem_bytes = mem_total - cache
+ docker_status['memory_usage'] = "{:.2f} MB".format(mem_bytes / 1024 / 1024)
+
+ # 获取内存限制
+ if os.path.exists('/sys/fs/cgroup/memory.max'):
+ with open('/sys/fs/cgroup/memory.max', 'r') as f:
+ limit_str = f.read().strip()
+ if limit_str != 'max':
+ limit_bytes = int(limit_str)
+ docker_status['memory_limit'] = "{:.2f} GB".format(limit_bytes / 1024 / 1024 / 1024)
+ docker_status['memory_percent'] = "{:.2f}%".format(mem_bytes / limit_bytes * 100)
+ # 尝试cgroup v1路径
+ elif os.path.exists('/sys/fs/cgroup/memory/memory.usage_in_bytes'):
+ # 从 memory.stat 读取内存信息
+ mem_bytes = 0
+ if os.path.exists('/sys/fs/cgroup/memory/memory.stat'):
+ with open('/sys/fs/cgroup/memory/memory.stat', 'r') as f:
+ rss = 0
+ cache = 0
+ for line in f:
+ if line.startswith('total_rss '):
+ rss = int(line.split()[1])
+ elif line.startswith('total_cache '):
+ cache = int(line.split()[1])
+ # 使用 RSS + (一部分活跃的cache),更接近docker stats的计算
+ # 但为了准确性,我们只用RSS
+ mem_bytes = rss
+
+ # 如果找不到,则使用总内存减去缓存作为后备
+ if mem_bytes == 0:
+ with open('/sys/fs/cgroup/memory/memory.usage_in_bytes', 'r') as f:
+ total_mem = int(f.read().strip())
+
+ cache = 0
+ if os.path.exists('/sys/fs/cgroup/memory/memory.stat'):
+ with open('/sys/fs/cgroup/memory/memory.stat', 'r') as f:
+ for line in f:
+ if line.startswith('total_inactive_file '):
+ cache = int(line.split()[1])
+ break
+
+ mem_bytes = total_mem - cache
+
+ docker_status['memory_usage'] = "{:.2f} MB".format(mem_bytes / 1024 / 1024)
+
+ # 获取内存限制
+ if os.path.exists('/sys/fs/cgroup/memory/memory.limit_in_bytes'):
+ with open('/sys/fs/cgroup/memory/memory.limit_in_bytes', 'r') as f:
+ limit_bytes = int(f.read().strip())
+ # 检查是否是实际限制(不是默认的超大值)
+ if limit_bytes < 9223372036854771712:
+ docker_status['memory_limit'] = "{:.2f} GB".format(limit_bytes / 1024 / 1024 / 1024)
+ docker_status['memory_percent'] = "{:.2f}%".format(mem_bytes / limit_bytes * 100)
+ except Exception as e:
+ docker_status['memory_usage'] = 'Error: {}'.format(str(e))
+
+ # 获取容器运行时间(基于PID 1的启动时间)
+ try:
+ # Get PID 1 start time
+ with open('/proc/1/stat', 'r') as f:
+ stat_data = f.read().split()
+ starttime_ticks = int(stat_data[21])
+
+ # Get system uptime
+ with open('/proc/uptime', 'r') as f:
+ system_uptime = float(f.read().split()[0])
+
+ # Get clock ticks per second
+ import os as os_module
+ ticks_per_sec = os_module.sysconf(os_module.sysconf_names['SC_CLK_TCK'])
+
+ # Calculate container uptime
+ process_start = starttime_ticks / ticks_per_sec
+ uptime_seconds = int(system_uptime - process_start)
+
+ days = uptime_seconds // 86400
+ hours = (uptime_seconds % 86400) // 3600
+ minutes = (uptime_seconds % 3600) // 60
+
+ if days > 0:
+ docker_status['uptime'] = "{}天 {}小时 {}分钟".format(days, hours, minutes)
+ elif hours > 0:
+ docker_status['uptime'] = "{}小时 {}分钟".format(hours, minutes)
+ else:
+ docker_status['uptime'] = "{}分钟".format(minutes)
+ except Exception as e:
+ logger.debug(f"读取容器运行时间失败: {e}")
+
+ docker_status['status'] = 'Running'
+ else:
+ docker_status['status'] = 'Not in Docker'
+
+ except Exception as e:
+ docker_status['status'] = 'Error: {}'.format(str(e))
+
+ return jsonify(docker_status)
+
+@app.route('/yuyx/api/admin/password', methods=['PUT'])
+@admin_required
+def update_admin_password():
+ """修改管理员密码"""
+ data = request.json
+ new_password = data.get('new_password', '').strip()
+
+ if not new_password:
+ return jsonify({"error": "密码不能为空"}), 400
+
+ username = session.get('admin_username')
+ if database.update_admin_password(username, new_password):
+ return jsonify({"success": True})
+ return jsonify({"error": "修改失败"}), 400
+
+
+@app.route('/yuyx/api/admin/username', methods=['PUT'])
+@admin_required
+def update_admin_username():
+ """修改管理员用户名"""
+ data = request.json
+ new_username = data.get('new_username', '').strip()
+
+ if not new_username:
+ return jsonify({"error": "用户名不能为空"}), 400
+
+ old_username = session.get('admin_username')
+ if database.update_admin_username(old_username, new_username):
+ session['admin_username'] = new_username
+ return jsonify({"success": True})
+ return jsonify({"error": "修改失败,用户名可能已存在"}), 400
+
+
+
+def update_admin_username():
+ """修改管理员用户名"""
+ data = request.json
+ new_username = data.get('new_username', '').strip()
+
+ if not new_username:
+ return jsonify({"error": "用户名不能为空"}), 400
+
+ old_username = session.get('admin_username')
+ if database.update_admin_username(old_username, new_username):
+ session['admin_username'] = new_username
+ return jsonify({"success": True})
+ return jsonify({"error": "用户名已存在"}), 400
+
+
+
+# ==================== 密码重置API ====================
+
+# 管理员直接重置用户密码
+@app.route('/yuyx/api/users//reset_password', methods=['POST'])
+@admin_required
+def admin_reset_password_route(user_id):
+ """管理员直接重置用户密码(无需审核)"""
+ data = request.json
+ new_password = data.get('new_password', '').strip()
+
+ if not new_password:
+ return jsonify({"error": "新密码不能为空"}), 400
+
+ if len(new_password) < 6:
+ return jsonify({"error": "密码长度不能少于6位"}), 400
+
+ if database.admin_reset_user_password(user_id, new_password):
+ return jsonify({"message": "密码重置成功"})
+ return jsonify({"error": "重置失败,用户不存在"}), 400
+
+
+# 获取密码重置申请列表
+@app.route('/yuyx/api/password_resets', methods=['GET'])
+@admin_required
+def get_password_resets_route():
+ """获取所有待审核的密码重置申请"""
+ resets = database.get_pending_password_resets()
+ return jsonify(resets)
+
+
+# 批准密码重置申请
+@app.route('/yuyx/api/password_resets//approve', methods=['POST'])
+@admin_required
+def approve_password_reset_route(request_id):
+ """批准密码重置申请"""
+ if database.approve_password_reset(request_id):
+ return jsonify({"message": "密码重置申请已批准"})
+ return jsonify({"error": "批准失败"}), 400
+
+
+# 拒绝密码重置申请
+@app.route('/yuyx/api/password_resets//reject', methods=['POST'])
+@admin_required
+def reject_password_reset_route(request_id):
+ """拒绝密码重置申请"""
+ if database.reject_password_reset(request_id):
+ return jsonify({"message": "密码重置申请已拒绝"})
+ return jsonify({"error": "拒绝失败"}), 400
+
+
+# 用户申请重置密码(需要审核)
+@app.route('/api/reset_password_request', methods=['POST'])
+def request_password_reset():
+ """用户申请重置密码"""
+ data = request.json
+ username = data.get('username', '').strip()
+ email = data.get('email', '').strip()
+ new_password = data.get('new_password', '').strip()
+
+ if not username or not new_password:
+ return jsonify({"error": "用户名和新密码不能为空"}), 400
+
+ if len(new_password) < 6:
+ return jsonify({"error": "密码长度不能少于6位"}), 400
+
+ # 验证用户存在
+ user = database.get_user_by_username(username)
+ if not user:
+ return jsonify({"error": "用户不存在"}), 404
+
+ # 如果提供了邮箱,验证邮箱是否匹配
+ if email and user.get('email') != email:
+ return jsonify({"error": "邮箱不匹配"}), 400
+
+ # 创建重置申请
+ request_id = database.create_password_reset_request(user['id'], new_password)
+ if request_id:
+ return jsonify({"message": "密码重置申请已提交,请等待管理员审核"})
+ else:
+ return jsonify({"error": "申请提交失败"}), 500
+
+
+# ==================== 账号管理API (用户隔离) ====================
+
+def load_user_accounts(user_id):
+ """从数据库加载用户的账号到内存"""
+ if user_id not in user_accounts:
+ user_accounts[user_id] = {}
+
+ accounts_data = database.get_user_accounts(user_id)
+ for acc_data in accounts_data:
+ account = Account(
+ account_id=acc_data['id'],
+ user_id=user_id,
+ username=acc_data['username'],
+ password=acc_data['password'],
+ remember=bool(acc_data['remember']),
+ remark=acc_data['remark'] or ''
+ )
+ user_accounts[user_id][account.id] = account
+
+
+# ==================== Bug反馈API(用户端) ====================
+
+@app.route('/api/feedback', methods=['POST'])
+@login_required
+def submit_feedback():
+ """用户提交Bug反馈"""
+ data = request.get_json()
+ title = data.get('title', '').strip()
+ description = data.get('description', '').strip()
+ contact = data.get('contact', '').strip()
+
+ if not title or not description:
+ return jsonify({"error": "标题和描述不能为空"}), 400
+
+ if len(title) > 100:
+ return jsonify({"error": "标题不能超过100个字符"}), 400
+
+ if len(description) > 2000:
+ return jsonify({"error": "描述不能超过2000个字符"}), 400
+
+ # 从数据库获取用户名
+ user_info = database.get_user_by_id(current_user.id)
+ username = user_info['username'] if user_info else f'用户{current_user.id}'
+
+ feedback_id = database.create_bug_feedback(
+ user_id=current_user.id,
+ username=username,
+ title=title,
+ description=description,
+ contact=contact
+ )
+
+ return jsonify({"message": "反馈提交成功", "id": feedback_id})
+
+
+@app.route('/api/feedback', methods=['GET'])
+@login_required
+def get_my_feedbacks():
+ """获取当前用户的反馈列表"""
+ feedbacks = database.get_user_feedbacks(current_user.id)
+ return jsonify(feedbacks)
+
+
+# ==================== Bug反馈API(管理端) ====================
+
+@app.route('/yuyx/api/feedbacks', methods=['GET'])
+@admin_required
+def get_all_feedbacks():
+ """管理员获取所有反馈"""
+ status = request.args.get('status')
+ limit = int(request.args.get('limit', 100))
+ offset = int(request.args.get('offset', 0))
+
+ feedbacks = database.get_bug_feedbacks(limit=limit, offset=offset, status_filter=status)
+ stats = database.get_feedback_stats()
+
+ return jsonify({
+ "feedbacks": feedbacks,
+ "stats": stats
+ })
+
+
+@app.route('/yuyx/api/feedbacks//reply', methods=['POST'])
+@admin_required
+def reply_to_feedback(feedback_id):
+ """管理员回复反馈"""
+ data = request.get_json()
+ reply = data.get('reply', '').strip()
+
+ if not reply:
+ return jsonify({"error": "回复内容不能为空"}), 400
+
+ if database.reply_feedback(feedback_id, reply):
+ return jsonify({"message": "回复成功"})
+ else:
+ return jsonify({"error": "反馈不存在"}), 404
+
+
+@app.route('/yuyx/api/feedbacks//close', methods=['POST'])
+@admin_required
+def close_feedback_api(feedback_id):
+ """管理员关闭反馈"""
+ if database.close_feedback(feedback_id):
+ return jsonify({"message": "已关闭"})
+ else:
+ return jsonify({"error": "反馈不存在"}), 404
+
+
+@app.route('/yuyx/api/feedbacks/', methods=['DELETE'])
+@admin_required
+def delete_feedback_api(feedback_id):
+ """管理员删除反馈"""
+ if database.delete_feedback(feedback_id):
+ return jsonify({"message": "已删除"})
+ else:
+ return jsonify({"error": "反馈不存在"}), 404
+
+
+# ==================== 账号管理API ====================
+
+@app.route('/api/accounts', methods=['GET'])
+@login_required
+def get_accounts():
+ """获取当前用户的所有账号"""
+ user_id = current_user.id
+ if user_id not in user_accounts:
+ load_user_accounts(user_id)
+
+ accounts = user_accounts.get(user_id, {})
+ return jsonify([acc.to_dict() for acc in accounts.values()])
+
+
+@app.route('/api/accounts', methods=['POST'])
+@login_required
+def add_account():
+ """添加账号"""
+ user_id = current_user.id
+
+ # 账号数量限制检查:VIP不限制,普通用户最多3个
+ current_count = len(database.get_user_accounts(user_id))
+ is_vip = database.is_user_vip(user_id)
+ if not is_vip and current_count >= 3:
+ return jsonify({"error": "普通用户最多添加3个账号,升级VIP可无限添加"}), 403
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ remark = data.get("remark", "").strip()[:200] # 限制200字符
+
+ if not username or not password:
+ return jsonify({"error": "用户名和密码不能为空"}), 400
+
+ # 检查当前用户是否已存在该账号
+ if user_id in user_accounts:
+ for acc in user_accounts[user_id].values():
+ if acc.username == username:
+ return jsonify({"error": f"账号 '{username}' 已存在"}), 400
+
+ # 生成账号ID
+ import uuid
+ account_id = str(uuid.uuid4())[:8]
+
+ # 设置remember默认值为True
+ remember = data.get('remember', True)
+
+ # 保存到数据库
+ database.create_account(user_id, account_id, username, password, remember, remark)
+
+ # 加载到内存
+ account = Account(account_id, user_id, username, password, remember, remark)
+ if user_id not in user_accounts:
+ user_accounts[user_id] = {}
+ user_accounts[user_id][account_id] = account
+
+ log_to_client(f"添加账号: {username}", user_id)
+ return jsonify(account.to_dict())
+
+
+@app.route('/api/accounts/', methods=['PUT'])
+@login_required
+def update_account(account_id):
+ """更新账号信息(密码等)"""
+ user_id = current_user.id
+
+ # 验证账号所有权
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ # 如果账号正在运行,不允许修改
+ if account.is_running:
+ return jsonify({"error": "账号正在运行中,请先停止"}), 400
+
+ data = request.json
+ new_password = data.get('password', '').strip()
+ new_remember = data.get('remember', account.remember)
+
+ if not new_password:
+ return jsonify({"error": "密码不能为空"}), 400
+
+ # 更新数据库
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ UPDATE accounts
+ SET password = ?, remember = ?
+ WHERE id = ?
+ ''', (new_password, new_remember, account_id))
+ conn.commit()
+
+ # 重置账号登录状态(密码修改后恢复active状态)
+ database.reset_account_login_status(account_id)
+ logger.info(f"[账号更新] 用户 {user_id} 修改了账号 {account.username} 的密码,已重置登录状态")
+
+ # 更新内存中的账号信息
+ account.password = new_password
+ account.remember = new_remember
+
+ log_to_client(f"账号 {account.username} 信息已更新,登录状态已重置", user_id)
+ return jsonify({"message": "账号更新成功", "account": account.to_dict()})
+
+
+@app.route('/api/accounts/', methods=['DELETE'])
+@login_required
+def delete_account(account_id):
+ """删除账号"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ # 停止正在运行的任务
+ if account.is_running:
+ account.should_stop = True
+ if account.automation:
+ account.automation.close()
+
+ username = account.username
+
+ # 从数据库删除
+ database.delete_account(account_id)
+
+ # 从内存删除
+ del user_accounts[user_id][account_id]
+
+ log_to_client(f"删除账号: {username}", user_id)
+ return jsonify({"success": True})
+
+
+@app.route('/api/accounts//remark', methods=['PUT'])
+@login_required
+def update_remark(account_id):
+ """更新备注"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ data = request.json
+ remark = data.get('remark', '').strip()[:200]
+
+ # 更新数据库
+ database.update_account_remark(account_id, remark)
+
+ # 更新内存
+ user_accounts[user_id][account_id].remark = remark
+ log_to_client(f"更新备注: {user_accounts[user_id][account_id].username} -> {remark}", user_id)
+
+ return jsonify({"success": True})
+
+
+@app.route('/api/accounts//start', methods=['POST'])
+@login_required
+def start_account(account_id):
+ """启动账号任务"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ if account.is_running:
+ return jsonify({"error": "任务已在运行中"}), 400
+
+ data = request.json
+ browse_type = data.get('browse_type', '应读')
+ enable_screenshot = data.get('enable_screenshot', True) # 默认启用截图
+
+ # 确保浏览器管理器已初始化
+ if not init_browser_manager():
+ return jsonify({"error": "浏览器初始化失败"}), 500
+
+ # 启动任务线程
+ account.is_running = True
+ account.should_stop = False
+ account.status = "运行中"
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot, 'manual'),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+
+ log_to_client(f"启动任务: {account.username} - {browse_type}", user_id)
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ return jsonify({"success": True})
+
+
+@app.route('/api/accounts//stop', methods=['POST'])
+@login_required
+def stop_account(account_id):
+ """停止账号任务"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ if not account.is_running:
+ return jsonify({"error": "任务未在运行"}), 400
+
+ account.should_stop = True
+ account.status = "正在停止"
+
+ log_to_client(f"停止任务: {account.username}", user_id)
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ return jsonify({"success": True})
+
+
+def get_user_semaphore(user_id):
+ """获取或创建用户的信号量"""
+ if user_id not in user_semaphores:
+ user_semaphores[user_id] = threading.Semaphore(max_concurrent_per_account)
+ return user_semaphores[user_id]
+
+
+def run_task(user_id, account_id, browse_type, enable_screenshot=True, source="manual"):
+ """运行自动化任务"""
+ print(f"[DEBUG run_task] account={account_id}, enable_screenshot={enable_screenshot} (类型:{type(enable_screenshot).__name__}), source={source}")
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return
+
+ account = user_accounts[user_id][account_id]
+
+ # 导入time模块
+ import time as time_module
+ # 注意:不在此处记录开始时间,因为要排除排队等待时间
+
+ # 两级并发控制:用户级 + 全局级(VIP优先)
+ user_sem = get_user_semaphore(user_id)
+
+ # 检查是否VIP用户
+ is_vip_user = database.is_user_vip(user_id)
+ vip_label = " [VIP优先]" if is_vip_user else ""
+
+ # 获取用户级信号量(同一用户的账号排队)
+ log_to_client(f"等待资源分配...{vip_label}", user_id, account_id)
+ account.status = "排队中" + (" (VIP)" if is_vip_user else "")
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ # 记录任务状态为排队中
+ import time as time_mod
+ task_status[account_id] = {
+ "user_id": user_id,
+ "username": account.username,
+ "status": "排队中",
+ "detail_status": "等待资源" + vip_label,
+ "browse_type": browse_type,
+ "start_time": time_mod.time(),
+ "source": source,
+ "progress": {"items": 0, "attachments": 0},
+ "is_vip": is_vip_user
+ }
+
+ # 加入优先级队列
+ with task_queue_lock:
+ if is_vip_user:
+ vip_task_queue.append(account_id)
+ else:
+ normal_task_queue.append(account_id)
+
+ # VIP优先排队机制
+ acquired = False
+ while not acquired:
+ with task_queue_lock:
+ # VIP用户直接尝试获取; 普通用户需等VIP队列为空
+ can_try = is_vip_user or len(vip_task_queue) == 0
+
+ if can_try and user_sem.acquire(blocking=False):
+ acquired = True
+ with task_queue_lock:
+ if account_id in vip_task_queue:
+ vip_task_queue.remove(account_id)
+ if account_id in normal_task_queue:
+ normal_task_queue.remove(account_id)
+ break
+
+ # 检查是否被停止
+ if account.should_stop:
+ with task_queue_lock:
+ if account_id in vip_task_queue:
+ vip_task_queue.remove(account_id)
+ if account_id in normal_task_queue:
+ normal_task_queue.remove(account_id)
+ log_to_client(f"任务已取消", user_id, account_id)
+ account.status = "已停止"
+ account.is_running = False
+ if account_id in task_status:
+ del task_status[account_id]
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ return
+
+ time_module.sleep(0.3)
+
+ try:
+ # 如果在排队期间被停止,直接返回
+ if account.should_stop:
+ log_to_client(f"任务已取消", user_id, account_id)
+ account.status = "已停止"
+ account.is_running = False
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ return
+
+ # 获取全局信号量(防止所有用户同时运行导致资源耗尽)
+ global_semaphore.acquire()
+
+ try:
+ # 再次检查是否被停止
+ if account.should_stop:
+ log_to_client(f"任务已取消", user_id, account_id)
+ account.status = "已停止"
+ account.is_running = False
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ return
+
+ # ====== 创建任务断点 ======
+ task_id = checkpoint_mgr.create_checkpoint(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type
+ )
+ logger.info(f"[断点] 任务 {task_id} 已创建")
+
+ # ====== 在此处记录任务真正的开始时间(排除排队等待时间) ======
+ task_start_time = time_module.time()
+
+ account.status = "运行中"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ account.last_browse_type = browse_type
+
+ # 更新任务状态为运行中
+ if account_id in task_status:
+ task_status[account_id]["status"] = "运行中"
+ task_status[account_id]["detail_status"] = "初始化"
+ task_status[account_id]["start_time"] = task_start_time
+
+ # 重试机制:最多尝试3次,超时则换IP重试
+ max_attempts = 3
+ last_error = None
+
+ for attempt in range(1, max_attempts + 1):
+ try:
+ if attempt > 1:
+ log_to_client(f"🔄 第 {attempt} 次尝试(共{max_attempts}次)...", user_id, account_id)
+
+ # 检查是否需要使用代理
+ proxy_config = None
+ config = database.get_system_config()
+ if config.get('proxy_enabled') == 1:
+ proxy_api_url = config.get('proxy_api_url', '').strip()
+ if proxy_api_url:
+ log_to_client(f"正在获取代理IP...", user_id, account_id)
+ proxy_server = get_proxy_from_api(proxy_api_url, max_retries=3)
+ if proxy_server:
+ proxy_config = {'server': proxy_server}
+ log_to_client(f"✓ 将使用代理: {proxy_server}", user_id, account_id)
+ account.proxy_config = proxy_config # 保存代理配置供截图使用
+ else:
+ log_to_client(f"✗ 代理获取失败,将不使用代理继续", user_id, account_id)
+ else:
+ log_to_client(f"⚠ 代理已启用但未配置API地址", user_id, account_id)
+
+ # 使用 API 方式浏览(不启动浏览器,节省内存)
+ checkpoint_mgr.update_stage(task_id, TaskStage.STARTING, progress_percent=10)
+
+ def custom_log(message: str):
+ log_to_client(message, user_id, account_id)
+
+ log_to_client(f"开始登录...", user_id, account_id)
+ if account_id in task_status:
+ task_status[account_id]["detail_status"] = "正在登录"
+ checkpoint_mgr.update_stage(task_id, TaskStage.LOGGING_IN, progress_percent=25)
+
+ # 使用 API 方式登录和浏览(不启动浏览器)
+ api_browser = APIBrowser(log_callback=custom_log, proxy_config=proxy_config)
+ if api_browser.login(account.username, account.password):
+ log_to_client(f"✓ 登录成功!", user_id, account_id)
+ # 登录成功,清除失败计数
+ # 保存cookies供截图使用
+ api_browser.save_cookies_for_playwright(account.username)
+ database.reset_account_login_status(account_id)
+
+ if account_id in task_status:
+ task_status[account_id]["detail_status"] = "正在浏览"
+ log_to_client(f"开始浏览 '{browse_type}' 内容...", user_id, account_id)
+
+ def should_stop():
+ return account.should_stop
+
+ checkpoint_mgr.update_stage(task_id, TaskStage.BROWSING, progress_percent=50)
+ result = api_browser.browse_content(
+ browse_type=browse_type,
+ should_stop_callback=should_stop
+ )
+ # 转换结果类型以兼容后续代码
+ result = BrowseResult(
+ success=result.success,
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message=result.error_message
+ )
+ api_browser.close()
+ else:
+ # API 登录失败
+ error_message = "登录失败"
+ log_to_client(f"❌ {error_message}", user_id, account_id)
+
+ # 增加失败计数(假设密码错误)
+ is_suspended = database.increment_account_login_fail(account_id, error_message)
+ if is_suspended:
+ log_to_client(f"⚠ 该账号连续3次密码错误,已自动暂停", user_id, account_id)
+ log_to_client(f"请在前台修改密码后才能继续使用", user_id, account_id)
+
+ retry_action = checkpoint_mgr.record_error(task_id, error_message)
+ if retry_action == "paused":
+ logger.warning(f"[断点] 任务 {task_id} 已暂停(登录失败)")
+
+ account.status = "登录失败"
+ account.is_running = False
+ # 记录登录失败日志
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=0,
+ total_attachments=0,
+ error_message=error_message,
+ duration=int(time_module.time() - task_start_time),
+ source=source
+ )
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ api_browser.close()
+ return
+
+ account.total_items = result.total_items
+ account.total_attachments = result.total_attachments
+
+ if result.success:
+ log_to_client(f"浏览完成! 共 {result.total_items} 条内容,{result.total_attachments} 个附件", user_id, account_id)
+ if account_id in task_status:
+ task_status[account_id]["detail_status"] = "浏览完成"
+ task_status[account_id]["progress"] = {"items": result.total_items, "attachments": result.total_attachments}
+ account.status = "已完成"
+ checkpoint_mgr.update_stage(task_id, TaskStage.COMPLETING, progress_percent=95)
+ checkpoint_mgr.complete_task(task_id, success=True)
+ logger.info(f"[断点] 任务 {task_id} 已完成")
+ # 记录成功日志(如果不截图则在此记录,截图时在截图完成后记录)
+ if not enable_screenshot:
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='success',
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message='',
+ duration=int(time_module.time() - task_start_time),
+ source=source
+ )
+ # 成功则跳出重试循环
+ break
+ else:
+ # 浏览出错,检查是否是超时错误
+ error_msg = result.error_message
+ if 'Timeout' in error_msg or 'timeout' in error_msg:
+ last_error = error_msg
+ log_to_client(f"⚠ 检测到超时错误: {error_msg}", user_id, account_id)
+
+ # 关闭当前浏览器
+ if account.automation:
+ try:
+ account.automation.close()
+ log_to_client(f"已关闭超时的浏览器实例", user_id, account_id)
+ except Exception as e:
+ logger.debug(f"关闭超时浏览器实例失败: {e}")
+ account.automation = None
+
+ if attempt < max_attempts:
+ log_to_client(f"⚠ 代理可能速度过慢,将换新IP重试 ({attempt}/{max_attempts})", user_id, account_id)
+ time_module.sleep(2) # 等待2秒再重试
+ continue
+ else:
+ log_to_client(f"❌ 已达到最大重试次数({max_attempts}),任务失败", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message=f"重试{max_attempts}次后仍失败: {error_msg}",
+ duration=int(time_module.time() - task_start_time)
+ )
+ break
+ else:
+ # 非超时错误,直接失败不重试
+ log_to_client(f"浏览出错: {error_msg}", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message=error_msg,
+ duration=int(time_module.time() - task_start_time),
+ source=source
+ )
+ break
+
+ except Exception as retry_error:
+ # 捕获重试过程中的异常
+ error_msg = str(retry_error)
+ last_error = error_msg
+
+ # 关闭可能存在的浏览器实例
+ if account.automation:
+ try:
+ account.automation.close()
+ except Exception as e:
+ logger.debug(f"关闭浏览器实例失败: {e}")
+ account.automation = None
+
+ if 'Timeout' in error_msg or 'timeout' in error_msg:
+ log_to_client(f"⚠ 执行超时: {error_msg}", user_id, account_id)
+ if attempt < max_attempts:
+ log_to_client(f"⚠ 将换新IP重试 ({attempt}/{max_attempts})", user_id, account_id)
+ time_module.sleep(2)
+ continue
+ else:
+ log_to_client(f"❌ 已达到最大重试次数({max_attempts}),任务失败", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=account.total_items,
+ total_attachments=account.total_attachments,
+ error_message=f"重试{max_attempts}次后仍失败: {error_msg}",
+ duration=int(time_module.time() - task_start_time),
+ source=source
+ )
+ break
+ else:
+ # 非超时异常,直接失败
+ log_to_client(f"任务执行异常: {error_msg}", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=account.total_items,
+ total_attachments=account.total_attachments,
+ error_message=error_msg,
+ duration=int(time_module.time() - task_start_time),
+ source=source
+ )
+ break
+
+
+ except Exception as e:
+ error_msg = str(e)
+ log_to_client(f"任务执行出错: {error_msg}", user_id, account_id)
+ account.status = "出错"
+ # 记录异常失败日志
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=account.total_items,
+ total_attachments=account.total_attachments,
+ error_message=error_msg,
+ duration=int(time_module.time() - task_start_time),
+ source=source
+ )
+
+ finally:
+ # 先关闭浏览器,再释放信号量(避免并发创建/关闭浏览器导致资源竞争)
+ account.is_running = False
+ # 如果状态不是已完成(需要截图),则重置为未开始
+ if account.status not in ["已完成"]:
+ account.status = "未开始"
+
+ if account.automation:
+ try:
+ account.automation.close()
+ # log_to_client(f"主任务浏览器已关闭", user_id, account_id) # 精简
+ except Exception as e:
+ log_to_client(f"关闭主任务浏览器时出错: {str(e)}", user_id, account_id)
+ finally:
+ account.automation = None
+
+ # 浏览器关闭后再释放全局信号量,确保新任务创建浏览器时旧浏览器已完全关闭
+ global_semaphore.release()
+
+ if account_id in active_tasks:
+ del active_tasks[account_id]
+ if account_id in task_status:
+ del task_status[account_id]
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ # 任务完成后自动截图(增加2秒延迟,确保资源完全释放)
+ # 根据enable_screenshot参数决定是否截图
+ if account.status == "已完成" and not account.should_stop:
+ if enable_screenshot:
+ log_to_client(f"等待2秒后开始截图...", user_id, account_id)
+ # 更新账号状态为等待截图
+ account.status = "等待截图"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ # 重新添加截图状态
+ import time as time_mod
+ task_status[account_id] = {
+ "user_id": user_id,
+ "username": account.username,
+ "status": "排队中",
+ "detail_status": "等待截图资源",
+ "browse_type": browse_type,
+ "start_time": time_mod.time(),
+ "source": source,
+ "progress": {"items": result.total_items if result else 0, "attachments": result.total_attachments if result else 0}
+ }
+ time.sleep(2) # 延迟启动截图,确保主任务资源已完全释放
+ browse_result_dict = {'total_items': result.total_items, 'total_attachments': result.total_attachments}
+ threading.Thread(target=take_screenshot_for_account, args=(user_id, account_id, browse_type, source, task_start_time, browse_result_dict), daemon=True).start()
+ else:
+ # 不截图时,重置状态为未开始
+ account.status = "未开始"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ log_to_client(f"截图功能已禁用,跳过截图", user_id, account_id)
+ else:
+ # 任务非正常完成,重置状态为未开始
+ if account.status not in ["登录失败", "出错"]:
+ account.status = "未开始"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ finally:
+ # 释放用户级信号量
+ user_sem.release()
+
+
+def take_screenshot_for_account(user_id, account_id, browse_type="应读", source="manual", task_start_time=None, browse_result=None):
+ """为账号任务完成后截图(使用工作线程池,真正的浏览器复用)"""
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return
+
+ account = user_accounts[user_id][account_id]
+
+ # 标记账号正在截图(防止重复提交截图任务)
+ account.is_running = True
+
+ def screenshot_task(browser_instance, user_id, account_id, account, browse_type, source, task_start_time, browse_result):
+ """在worker线程中执行的截图任务"""
+ # ✅ 获得worker后,立即更新状态为"截图中"
+ if user_id in user_accounts and account_id in user_accounts[user_id]:
+ acc = user_accounts[user_id][account_id]
+ acc.status = "截图中"
+ if account_id in task_status:
+ task_status[account_id]["status"] = "运行中"
+ task_status[account_id]["detail_status"] = "正在截图"
+ socketio.emit('account_update', acc.to_dict(), room=f'user_{user_id}')
+
+ max_retries = 3
+
+ for attempt in range(1, max_retries + 1):
+ automation = None
+ try:
+ # 更新状态
+ if account_id in task_status:
+ task_status[account_id]["detail_status"] = f"正在截图{f' (第{attempt}次)' if attempt > 1 else ''}"
+
+ if attempt > 1:
+ log_to_client(f"🔄 第 {attempt} 次截图尝试...", user_id, account_id)
+
+ log_to_client(f"使用Worker-{browser_instance['worker_id']}的浏览器(已使用{browser_instance['use_count']}次)", user_id, account_id)
+
+ # 使用worker的浏览器创建PlaywrightAutomation
+ proxy_config = account.proxy_config if hasattr(account, 'proxy_config') else None
+ automation = PlaywrightAutomation(browser_manager, account_id, proxy_config=proxy_config)
+ automation.playwright = browser_instance['playwright']
+ automation.browser = browser_instance['browser']
+
+ def custom_log(message: str):
+ log_to_client(message, user_id, account_id)
+ automation.log = custom_log
+
+ # 登录
+ log_to_client(f"登录中...", user_id, account_id)
+ login_result = automation.quick_login(account.username, account.password, account.remember)
+ if not login_result["success"]:
+ error_message = login_result.get("message", "截图登录失败")
+ log_to_client(f"截图登录失败: {error_message}", user_id, account_id)
+ if attempt < max_retries:
+ log_to_client(f"将重试...", user_id, account_id)
+ time.sleep(2)
+ continue
+ else:
+ log_to_client(f"❌ 截图失败: 登录失败", user_id, account_id)
+ return {'success': False, 'error': '登录失败'}
+
+ browse_type = account.last_browse_type
+ log_to_client(f"导航到 '{browse_type}' 页面...", user_id, account_id)
+
+ # 导航到指定页面
+ result = automation.browse_content(
+ navigate_only=True,
+ browse_type=browse_type,
+ auto_next_page=False,
+ auto_view_attachments=False,
+ interval=0,
+ should_stop_callback=None
+ )
+
+ if not result.success and result.error_message:
+ log_to_client(f"导航警告: {result.error_message}", user_id, account_id)
+
+ # 等待页面稳定
+ time.sleep(2)
+
+ # 生成截图文件名
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ now_beijing = datetime.now(beijing_tz)
+ timestamp = now_beijing.strftime('%Y%m%d_%H%M%S')
+
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+ login_account = account.remark if account.remark else account.username
+ screenshot_filename = f"{username_prefix}_{login_account}_{browse_type}_{timestamp}.jpg"
+ screenshot_path = os.path.join(SCREENSHOTS_DIR, screenshot_filename)
+
+ # 尝试截图
+ if automation.take_screenshot(screenshot_path):
+ # 验证截图文件
+ if os.path.exists(screenshot_path) and os.path.getsize(screenshot_path) > 1000:
+ log_to_client(f"✓ 截图成功: {screenshot_filename}", user_id, account_id)
+ return {'success': True, 'filename': screenshot_filename}
+ else:
+ log_to_client(f"截图文件异常,将重试", user_id, account_id)
+ if os.path.exists(screenshot_path):
+ os.remove(screenshot_path)
+ else:
+ log_to_client(f"截图保存失败", user_id, account_id)
+
+ if attempt < max_retries:
+ log_to_client(f"将重试...", user_id, account_id)
+ time.sleep(2)
+
+ except Exception as e:
+ log_to_client(f"截图出错: {str(e)}", user_id, account_id)
+ if attempt < max_retries:
+ log_to_client(f"将重试...", user_id, account_id)
+ time.sleep(2)
+
+ finally:
+ # 只关闭context,不关闭浏览器(由worker管理)
+ if automation:
+ try:
+ if automation.context:
+ automation.context.close()
+ automation.context = None
+ automation.page = None
+ except Exception as e:
+ logger.debug(f"关闭context时出错: {e}")
+
+ return {'success': False, 'error': '截图失败,已重试3次'}
+
+ def screenshot_callback(result, error):
+ """截图完成回调"""
+ try:
+ # 重置账号状态
+ account.is_running = False
+ account.status = "未开始"
+
+ # 先清除任务状态(这样to_dict()不会包含detail_status)
+ if account_id in task_status:
+ del task_status[account_id]
+
+ # 然后发送更新
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ if error:
+ log_to_client(f"❌ 截图失败: {error}", user_id, account_id)
+ elif not result or not result.get('success'):
+ error_msg = result.get('error', '未知错误') if result else '未知错误'
+ log_to_client(f"❌ 截图失败: {error_msg}", user_id, account_id)
+
+ # 记录任务日志
+ if task_start_time and browse_result:
+ import time as time_module
+ total_elapsed = int(time_module.time() - task_start_time)
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='success',
+ total_items=browse_result.get('total_items', 0),
+ total_attachments=browse_result.get('total_attachments', 0),
+ duration=total_elapsed,
+ source=source
+ )
+
+ except Exception as e:
+ logger.error(f"截图回调出错: {e}")
+
+ # 提交任务到工作线程池
+ pool = get_browser_worker_pool()
+ pool.submit_task(
+ screenshot_task,
+ screenshot_callback,
+ user_id, account_id, account, browse_type, source, task_start_time, browse_result
+ )
+def manual_screenshot(account_id):
+ """手动为指定账号截图"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+ if account.is_running:
+ return jsonify({"error": "任务运行中,无法截图"}), 400
+
+ data = request.json or {}
+ browse_type = data.get('browse_type', account.last_browse_type)
+
+ account.last_browse_type = browse_type
+
+ threading.Thread(target=take_screenshot_for_account, args=(user_id, account_id), daemon=True).start()
+ log_to_client(f"手动截图: {account.username} - {browse_type}", user_id)
+ return jsonify({"success": True})
+
+
+# ==================== 截图管理API ====================
+
+@app.route('/api/screenshots', methods=['GET'])
+@login_required
+def get_screenshots():
+ """获取当前用户的截图列表"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ try:
+ screenshots = []
+ if os.path.exists(SCREENSHOTS_DIR):
+ for filename in os.listdir(SCREENSHOTS_DIR):
+ # 只显示属于当前用户的截图(支持png和jpg格式)
+ if (filename.lower().endswith(('.png', '.jpg', '.jpeg'))) and filename.startswith(username_prefix + '_'):
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ stat = os.stat(filepath)
+ # 转换为北京时间
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ created_time = datetime.fromtimestamp(stat.st_mtime, tz=beijing_tz)
+ # 解析文件名获取显示名称
+ # 文件名格式:用户名_登录账号_浏览类型_时间.jpg
+ parts = filename.rsplit('.', 1)[0].split('_', 1) # 移除扩展名并分割
+ if len(parts) > 1:
+ # 显示名称:登录账号_浏览类型_时间.jpg
+ display_name = parts[1] + '.' + filename.rsplit('.', 1)[1]
+ else:
+ display_name = filename
+
+ screenshots.append({
+ 'filename': filename,
+ 'display_name': display_name,
+ 'size': stat.st_size,
+ 'created': created_time.strftime('%Y-%m-%d %H:%M:%S')
+ })
+ screenshots.sort(key=lambda x: x['created'], reverse=True)
+ return jsonify(screenshots)
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+@app.route('/screenshots/')
+@login_required
+def serve_screenshot(filename):
+ """提供截图文件访问"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ # 验证文件属于当前用户
+ if not filename.startswith(username_prefix + '_'):
+ return jsonify({"error": "无权访问"}), 403
+
+ return send_from_directory(SCREENSHOTS_DIR, filename)
+
+
+@app.route('/api/screenshots/', methods=['DELETE'])
+@login_required
+def delete_screenshot(filename):
+ """删除指定截图"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ # 验证文件属于当前用户
+ if not filename.startswith(username_prefix + '_'):
+ return jsonify({"error": "无权删除"}), 403
+
+ try:
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ if os.path.exists(filepath):
+ os.remove(filepath)
+ log_to_client(f"删除截图: {filename}", user_id)
+ return jsonify({"success": True})
+ else:
+ return jsonify({"error": "文件不存在"}), 404
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+@app.route('/api/screenshots/clear', methods=['POST'])
+@login_required
+def clear_all_screenshots():
+ """清空当前用户的所有截图"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ try:
+ deleted_count = 0
+ if os.path.exists(SCREENSHOTS_DIR):
+ for filename in os.listdir(SCREENSHOTS_DIR):
+ if (filename.lower().endswith(('.png', '.jpg', '.jpeg'))) and filename.startswith(username_prefix + '_'):
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ os.remove(filepath)
+ deleted_count += 1
+ log_to_client(f"清理了 {deleted_count} 个截图文件", user_id)
+ return jsonify({"success": True, "deleted": deleted_count})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+# ==================== WebSocket事件 ====================
+
+@socketio.on('connect')
+def handle_connect():
+ """客户端连接"""
+ if current_user.is_authenticated:
+ user_id = current_user.id
+ join_room(f'user_{user_id}')
+ log_to_client("客户端已连接", user_id)
+
+ # 如果user_accounts中没有该用户的账号,从数据库加载
+ if user_id not in user_accounts or len(user_accounts[user_id]) == 0:
+ db_accounts = database.get_user_accounts(user_id)
+ if db_accounts:
+ user_accounts[user_id] = {}
+ for acc_data in db_accounts:
+ account = Account(
+ account_id=acc_data['id'],
+ user_id=acc_data['user_id'],
+ username=acc_data['username'],
+ password=acc_data['password'],
+ remember=bool(acc_data.get('remember', 1)),
+ remark=acc_data.get('remark', '')
+ )
+ user_accounts[user_id][acc_data['id']] = account
+ log_to_client(f"已从数据库恢复 {len(db_accounts)} 个账号", user_id)
+
+ # 发送账号列表
+ accounts = user_accounts.get(user_id, {})
+ emit('accounts_list', [acc.to_dict() for acc in accounts.values()])
+
+ # 发送历史日志
+ if user_id in log_cache:
+ for log_entry in log_cache[user_id]:
+ emit('log', log_entry)
+
+
+@socketio.on('disconnect')
+def handle_disconnect():
+ """客户端断开"""
+ if current_user.is_authenticated:
+ user_id = current_user.id
+ leave_room(f'user_{user_id}')
+
+
+# ==================== 静态文件 ====================
+
+@app.route('/static/')
+def serve_static(filename):
+ """提供静态文件访问"""
+ response = send_from_directory('static', filename)
+ # 禁用缓存,强制浏览器每次都重新加载
+ response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
+ response.headers['Pragma'] = 'no-cache'
+ response.headers['Expires'] = '0'
+ return response
+
+
+# ==================== 启动 ====================
+
+
+# ==================== 管理员VIP管理API ====================
+
+@app.route('/yuyx/api/vip/config', methods=['GET'])
+def get_vip_config_api():
+ """获取VIP配置"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+ config = database.get_vip_config()
+ return jsonify(config)
+
+
+@app.route('/yuyx/api/vip/config', methods=['POST'])
+def set_vip_config_api():
+ """设置默认VIP天数"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ data = request.json
+ days = data.get('default_vip_days', 0)
+
+ if not isinstance(days, int) or days < 0:
+ return jsonify({"error": "VIP天数必须是非负整数"}), 400
+
+ database.set_default_vip_days(days)
+ return jsonify({"message": "VIP配置已更新", "default_vip_days": days})
+
+
+@app.route('/yuyx/api/users//vip', methods=['POST'])
+def set_user_vip_api(user_id):
+ """设置用户VIP"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ data = request.json
+ days = data.get('days', 30)
+
+ # 验证days参数
+ valid_days = [7, 30, 365, 999999]
+ if days not in valid_days:
+ return jsonify({"error": "VIP天数必须是 7/30/365/999999 之一"}), 400
+
+ if database.set_user_vip(user_id, days):
+ vip_type = {7: "一周", 30: "一个月", 365: "一年", 999999: "永久"}[days]
+ return jsonify({"message": f"VIP设置成功: {vip_type}"})
+ return jsonify({"error": "设置失败,用户不存在"}), 400
+
+
+@app.route('/yuyx/api/users//vip', methods=['DELETE'])
+def remove_user_vip_api(user_id):
+ """移除用户VIP"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ if database.remove_user_vip(user_id):
+ return jsonify({"message": "VIP已移除"})
+ return jsonify({"error": "移除失败"}), 400
+
+
+@app.route('/yuyx/api/users//vip', methods=['GET'])
+def get_user_vip_info_api(user_id):
+ """获取用户VIP信息(管理员)"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ vip_info = database.get_user_vip_info(user_id)
+ return jsonify(vip_info)
+
+
+
+# ==================== 用户端VIP查询API ====================
+
+@app.route('/api/user/vip', methods=['GET'])
+@login_required
+def get_current_user_vip():
+ """获取当前用户VIP信息"""
+ vip_info = database.get_user_vip_info(current_user.id)
+ # 添加用户名
+ user_info = database.get_user_by_id(current_user.id)
+ vip_info['username'] = user_info['username'] if user_info else 'Unknown'
+ return jsonify(vip_info)
+
+
+@app.route('/api/run_stats', methods=['GET'])
+@login_required
+def get_run_stats():
+ """获取当前用户的运行统计"""
+ user_id = current_user.id
+
+ # 获取今日任务统计
+ stats = database.get_user_run_stats(user_id)
+
+ # 计算当前正在运行的账号数
+ current_running = 0
+ if user_id in user_accounts:
+ current_running = sum(1 for acc in user_accounts[user_id].values() if acc.is_running)
+
+ return jsonify({
+ 'today_completed': stats.get('completed', 0),
+ 'current_running': current_running,
+ 'today_failed': stats.get('failed', 0),
+ 'today_items': stats.get('total_items', 0),
+ 'today_attachments': stats.get('total_attachments', 0)
+ })
+
+
+# ==================== 系统配置API ====================
+
+@app.route('/yuyx/api/system/config', methods=['GET'])
+@admin_required
+def get_system_config_api():
+ """获取系统配置"""
+ config = database.get_system_config()
+ return jsonify(config)
+
+
+@app.route('/yuyx/api/system/config', methods=['POST'])
+@admin_required
+def update_system_config_api():
+ """更新系统配置"""
+ global max_concurrent_global, global_semaphore, max_concurrent_per_account
+
+ data = request.json
+ max_concurrent = data.get('max_concurrent_global')
+ schedule_enabled = data.get('schedule_enabled')
+ schedule_time = data.get('schedule_time')
+ schedule_browse_type = data.get('schedule_browse_type')
+ schedule_weekdays = data.get('schedule_weekdays')
+ new_max_concurrent_per_account = data.get('max_concurrent_per_account')
+ new_max_screenshot_concurrent = data.get('max_screenshot_concurrent')
+
+ # 验证参数
+ if max_concurrent is not None:
+ if not isinstance(max_concurrent, int) or max_concurrent < 1:
+ return jsonify({"error": "全局并发数必须大于0(建议:小型服务器2-5,中型5-10,大型10-20)"}), 400
+
+ if new_max_concurrent_per_account is not None:
+ if not isinstance(new_max_concurrent_per_account, int) or new_max_concurrent_per_account < 1:
+ return jsonify({"error": "单账号并发数必须大于0(建议设为1,避免同一用户任务相互影响)"}), 400
+
+ if new_max_screenshot_concurrent is not None:
+ if not isinstance(new_max_screenshot_concurrent, int) or new_max_screenshot_concurrent < 1:
+ return jsonify({"error": "截图并发数必须大于0(建议根据服务器配置设置,每个浏览器约占用200MB内存)"}), 400
+
+ if schedule_time is not None:
+ # 验证时间格式 HH:MM
+ import re
+ if not re.match(r'^([01]\d|2[0-3]):([0-5]\d)$', schedule_time):
+ return jsonify({"error": "时间格式错误,应为 HH:MM"}), 400
+
+ if schedule_browse_type is not None:
+ if schedule_browse_type not in ['注册前未读', '应读', '未读']:
+ return jsonify({"error": "浏览类型无效"}), 400
+
+ if schedule_weekdays is not None:
+ # 验证星期格式,应该是逗号分隔的数字字符串 "1,2,3,4,5,6,7"
+ try:
+ days = [int(d.strip()) for d in schedule_weekdays.split(',') if d.strip()]
+ if not all(1 <= d <= 7 for d in days):
+ return jsonify({"error": "星期数字必须在1-7之间"}), 400
+ except (ValueError, AttributeError):
+ return jsonify({"error": "星期格式错误"}), 400
+
+ # 更新数据库
+ if database.update_system_config(
+ max_concurrent=max_concurrent,
+ schedule_enabled=schedule_enabled,
+ schedule_time=schedule_time,
+ schedule_browse_type=schedule_browse_type,
+ schedule_weekdays=schedule_weekdays,
+ max_concurrent_per_account=new_max_concurrent_per_account,
+ max_screenshot_concurrent=new_max_screenshot_concurrent
+ ):
+ # 如果修改了并发数,更新全局变量和信号量
+ if max_concurrent is not None and max_concurrent != max_concurrent_global:
+ max_concurrent_global = max_concurrent
+ global_semaphore = threading.Semaphore(max_concurrent)
+ print(f"全局并发数已更新为: {max_concurrent}")
+
+ # 如果修改了单用户并发数,更新全局变量(已有的信号量会在下次创建时使用新值)
+ if new_max_concurrent_per_account is not None and new_max_concurrent_per_account != max_concurrent_per_account:
+ max_concurrent_per_account = new_max_concurrent_per_account
+ print(f"单用户并发数已更新为: {max_concurrent_per_account}")
+
+ # 如果修改了截图并发数,更新信号量
+ if new_max_screenshot_concurrent is not None:
+ global screenshot_semaphore
+ screenshot_semaphore = threading.Semaphore(new_max_screenshot_concurrent)
+ print(f"截图并发数已更新为: {new_max_screenshot_concurrent}")
+
+ return jsonify({"message": "系统配置已更新"})
+
+ return jsonify({"error": "更新失败"}), 400
+
+
+@app.route('/yuyx/api/schedule/execute', methods=['POST'])
+@admin_required
+def execute_schedule_now():
+ """立即执行定时任务(无视定时时间和星期限制)"""
+ try:
+ # 在新线程中执行任务,避免阻塞请求
+ # 传入 skip_weekday_check=True 跳过星期检查
+ thread = threading.Thread(target=run_scheduled_task, args=(True,), daemon=True)
+ thread.start()
+
+ logger.info("[立即执行定时任务] 管理员手动触发定时任务执行(跳过星期检查)")
+ return jsonify({"message": "定时任务已开始执行,请查看任务列表获取进度"})
+ except Exception as e:
+ logger.error(f"[立即执行定时任务] 启动失败: {str(e)}")
+ return jsonify({"error": f"启动失败: {str(e)}"}), 500
+
+
+
+
+# ==================== 代理配置API ====================
+
+@app.route('/yuyx/api/proxy/config', methods=['GET'])
+@admin_required
+def get_proxy_config_api():
+ """获取代理配置"""
+ config = database.get_system_config()
+ return jsonify({
+ 'proxy_enabled': config.get('proxy_enabled', 0),
+ 'proxy_api_url': config.get('proxy_api_url', ''),
+ 'proxy_expire_minutes': config.get('proxy_expire_minutes', 3)
+ })
+
+
+@app.route('/yuyx/api/proxy/config', methods=['POST'])
+@admin_required
+def update_proxy_config_api():
+ """更新代理配置"""
+ data = request.json
+ proxy_enabled = data.get('proxy_enabled')
+ proxy_api_url = data.get('proxy_api_url', '').strip()
+ proxy_expire_minutes = data.get('proxy_expire_minutes')
+
+ if proxy_enabled is not None and proxy_enabled not in [0, 1]:
+ return jsonify({"error": "proxy_enabled必须是0或1"}), 400
+
+ if proxy_expire_minutes is not None:
+ if not isinstance(proxy_expire_minutes, int) or proxy_expire_minutes < 1:
+ return jsonify({"error": "代理有效期必须是大于0的整数"}), 400
+
+ if database.update_system_config(
+ proxy_enabled=proxy_enabled,
+ proxy_api_url=proxy_api_url,
+ proxy_expire_minutes=proxy_expire_minutes
+ ):
+ return jsonify({"message": "代理配置已更新"})
+
+ return jsonify({"error": "更新失败"}), 400
+
+
+@app.route('/yuyx/api/proxy/test', methods=['POST'])
+@admin_required
+def test_proxy_api():
+ """测试代理连接"""
+ data = request.json
+ api_url = data.get('api_url', '').strip()
+
+ if not api_url:
+ return jsonify({"error": "请提供API地址"}), 400
+
+ try:
+ response = requests.get(api_url, timeout=10)
+ if response.status_code == 200:
+ ip_port = response.text.strip()
+ if ip_port and ':' in ip_port:
+ return jsonify({
+ "success": True,
+ "proxy": ip_port,
+ "message": f"代理获取成功: {ip_port}"
+ })
+ else:
+ return jsonify({
+ "success": False,
+ "message": f"代理格式错误: {ip_port}"
+ }), 400
+ else:
+ return jsonify({
+ "success": False,
+ "message": f"HTTP错误: {response.status_code}"
+ }), 400
+ except Exception as e:
+ return jsonify({
+ "success": False,
+ "message": f"连接失败: {str(e)}"
+ }), 500
+
+# ==================== 服务器信息API ====================
+
+@app.route('/yuyx/api/server/info', methods=['GET'])
+@admin_required
+def get_server_info_api():
+ """获取服务器信息"""
+ import psutil
+ import datetime
+
+ # CPU使用率
+ cpu_percent = psutil.cpu_percent(interval=1)
+
+ # 内存信息
+ memory = psutil.virtual_memory()
+ memory_total = f"{memory.total / (1024**3):.1f}GB"
+ memory_used = f"{memory.used / (1024**3):.1f}GB"
+ memory_percent = memory.percent
+
+ # 磁盘信息
+ disk = psutil.disk_usage('/')
+ disk_total = f"{disk.total / (1024**3):.1f}GB"
+ disk_used = f"{disk.used / (1024**3):.1f}GB"
+ disk_percent = disk.percent
+
+ # 运行时长
+ boot_time = datetime.datetime.fromtimestamp(psutil.boot_time())
+ uptime_delta = datetime.datetime.now() - boot_time
+ days = uptime_delta.days
+ hours = uptime_delta.seconds // 3600
+ uptime = f"{days}天{hours}小时"
+
+ return jsonify({
+ 'cpu_percent': cpu_percent,
+ 'memory_total': memory_total,
+ 'memory_used': memory_used,
+ 'memory_percent': memory_percent,
+ 'disk_total': disk_total,
+ 'disk_used': disk_used,
+ 'disk_percent': disk_percent,
+ 'uptime': uptime
+ })
+
+
+# ==================== 任务统计和日志API ====================
+
+@app.route('/yuyx/api/task/stats', methods=['GET'])
+@admin_required
+def get_task_stats_api():
+ """获取任务统计数据"""
+ date_filter = request.args.get('date') # YYYY-MM-DD格式
+ stats = database.get_task_stats(date_filter)
+ return jsonify(stats)
+
+
+
+
+@app.route('/yuyx/api/task/running', methods=['GET'])
+@admin_required
+def get_running_tasks_api():
+ """获取当前运行中和排队中的任务"""
+ import time as time_mod
+ current_time = time_mod.time()
+
+ running = []
+ queuing = []
+
+ for account_id, info in task_status.items():
+ elapsed = int(current_time - info.get("start_time", current_time))
+
+ # 获取用户名
+ user = database.get_user_by_id(info.get("user_id"))
+ user_username = user['username'] if user else 'N/A'
+
+ # 获取进度信息
+ progress = info.get("progress", {"items": 0, "attachments": 0})
+
+ task_info = {
+ "account_id": account_id,
+ "user_id": info.get("user_id"),
+ "user_username": user_username,
+ "username": info.get("username"),
+ "browse_type": info.get("browse_type"),
+ "source": info.get("source", "manual"),
+ "detail_status": info.get("detail_status", "未知"),
+ "progress_items": progress.get("items", 0),
+ "progress_attachments": progress.get("attachments", 0),
+ "elapsed_seconds": elapsed,
+ "elapsed_display": f"{elapsed // 60}分{elapsed % 60}秒" if elapsed >= 60 else f"{elapsed}秒"
+ }
+
+ if info.get("status") == "运行中":
+ running.append(task_info)
+ else:
+ queuing.append(task_info)
+
+ # 按开始时间排序
+ running.sort(key=lambda x: x["elapsed_seconds"], reverse=True)
+ queuing.sort(key=lambda x: x["elapsed_seconds"], reverse=True)
+
+ return jsonify({
+ "running": running,
+ "queuing": queuing,
+ "running_count": len(running),
+ "queuing_count": len(queuing),
+ "max_concurrent": max_concurrent_global
+ })
+
+@app.route('/yuyx/api/task/logs', methods=['GET'])
+@admin_required
+def get_task_logs_api():
+ """获取任务日志列表(支持分页和多种筛选)"""
+ limit = int(request.args.get('limit', 20))
+ offset = int(request.args.get('offset', 0))
+ date_filter = request.args.get('date') # YYYY-MM-DD格式
+ status_filter = request.args.get('status') # success/failed
+ source_filter = request.args.get('source') # manual/scheduled/immediate/resumed
+ user_id_filter = request.args.get('user_id') # 用户ID
+ account_filter = request.args.get('account') # 账号关键字
+
+ # 转换user_id为整数
+ if user_id_filter:
+ try:
+ user_id_filter = int(user_id_filter)
+ except ValueError:
+ user_id_filter = None
+
+ result = database.get_task_logs(
+ limit=limit,
+ offset=offset,
+ date_filter=date_filter,
+ status_filter=status_filter,
+ source_filter=source_filter,
+ user_id_filter=user_id_filter,
+ account_filter=account_filter
+ )
+ return jsonify(result)
+
+
+@app.route('/yuyx/api/task/logs/clear', methods=['POST'])
+@admin_required
+def clear_old_task_logs_api():
+ """清理旧的任务日志"""
+ data = request.json or {}
+ days = data.get('days', 30)
+
+ if not isinstance(days, int) or days < 1:
+ return jsonify({"error": "天数必须是大于0的整数"}), 400
+
+ deleted_count = database.delete_old_task_logs(days)
+ return jsonify({"message": f"已删除{days}天前的{deleted_count}条日志"})
+
+
+@app.route('/yuyx/api/docker/restart', methods=['POST'])
+@admin_required
+def restart_docker_container():
+ """重启Docker容器"""
+ import subprocess
+ import os
+
+ try:
+ # 检查是否在Docker容器中运行
+ if not os.path.exists('/.dockerenv'):
+ return jsonify({"error": "当前不在Docker容器中运行"}), 400
+
+ # 记录日志
+ app_logger.info("[系统] 管理员触发Docker容器重启")
+
+ # 使用nohup在后台执行重启命令,避免阻塞
+ # 容器重启会导致当前进程终止,所以需要延迟执行
+ restart_script = """
+import os
+import time
+
+# 延迟3秒让响应返回给客户端
+time.sleep(3)
+
+# 退出Python进程,让Docker自动重启容器(restart: unless-stopped)
+os._exit(0)
+"""
+
+ # 写入临时脚本
+ with open('/tmp/restart_container.py', 'w') as f:
+ f.write(restart_script)
+
+ # 在后台执行重启脚本
+ subprocess.Popen(['python', '/tmp/restart_container.py'],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True)
+
+ return jsonify({
+ "success": True,
+ "message": "容器将在3秒后重启,请稍后刷新页面"
+ })
+
+ except Exception as e:
+ app_logger.error(f"[系统] Docker容器重启失败: {str(e)}")
+ return jsonify({"error": f"重启失败: {str(e)}"}), 500
+
+
+# ==================== 定时任务调度器 ====================
+
+def run_scheduled_task(skip_weekday_check=False):
+ """执行所有账号的浏览任务(可被手动调用,过滤重复账号)
+
+ Args:
+ skip_weekday_check: 是否跳过星期检查(立即执行时为True)
+ """
+ try:
+ from datetime import datetime
+ import pytz
+
+ config = database.get_system_config()
+ browse_type = config.get('schedule_browse_type', '应读')
+
+ # 检查今天是否在允许执行的星期列表中(立即执行时跳过此检查)
+ if not skip_weekday_check:
+ # 获取北京时间的星期几 (1=周一, 7=周日)
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ now_beijing = datetime.now(beijing_tz)
+ current_weekday = now_beijing.isoweekday() # 1-7
+
+ # 获取配置的星期列表
+ schedule_weekdays = config.get('schedule_weekdays', '1,2,3,4,5,6,7')
+ allowed_weekdays = [int(d.strip()) for d in schedule_weekdays.split(',') if d.strip()]
+
+ if current_weekday not in allowed_weekdays:
+ weekday_names = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
+ print(f"[定时任务] 今天是{weekday_names[current_weekday]},不在执行日期内,跳过执行")
+ return
+ else:
+ print(f"[立即执行] 跳过星期检查,强制执行任务")
+
+ print(f"[定时任务] 开始执行 - 浏览类型: {browse_type}")
+
+ # 获取所有已审核用户的所有账号
+ all_users = database.get_all_users()
+ approved_users = [u for u in all_users if u['status'] == 'approved']
+
+ # 用于记录已执行的账号用户名,避免重复
+ executed_usernames = set()
+ total_accounts = 0
+ skipped_duplicates = 0
+ executed_accounts = 0
+
+ for user in approved_users:
+ user_id = user['id']
+ if user_id not in user_accounts:
+ load_user_accounts(user_id)
+
+ accounts = user_accounts.get(user_id, {})
+ for account_id, account in accounts.items():
+ total_accounts += 1
+
+ # 跳过正在运行的账号
+ if account.is_running:
+ continue
+
+ # 检查账号状态,跳过已暂停的账号
+ account_status_info = database.get_account_status(account_id)
+ if account_status_info:
+ status = account_status_info['status'] if 'status' in account_status_info.keys() else 'active'
+ if status == 'suspended':
+ fail_count = account_status_info['login_fail_count'] if 'login_fail_count' in account_status_info.keys() else 0
+ print(f"[定时任务] 跳过暂停账号: {account.username} (用户:{user['username']}) - 连续{fail_count}次密码错误,需修改密码")
+ continue
+
+ # 检查账号用户名是否已经执行过(重复账号过滤)
+ if account.username in executed_usernames:
+ skipped_duplicates += 1
+ print(f"[定时任务] 跳过重复账号: {account.username} (用户:{user['username']}) - 该账号已被其他用户执行")
+ continue
+
+ # 记录该账号用户名,避免后续重复执行
+ executed_usernames.add(account.username)
+
+ print(f"[定时任务] 启动账号: {account.username} (用户:{user['username']})")
+
+ # 启动任务
+ account.is_running = True
+ account.should_stop = False
+ account.status = "运行中"
+
+ # 获取系统配置的截图开关
+ cfg = database.get_system_config()
+ enable_screenshot_scheduled = cfg.get("enable_screenshot", 0) == 1
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot_scheduled, 'scheduled'),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+ executed_accounts += 1
+
+ # 发送更新到用户
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ # 间隔启动,避免瞬间并发过高
+ time.sleep(2)
+
+ print(f"[定时任务] 执行完成 - 总账号数:{total_accounts}, 已执行:{executed_accounts}, 跳过重复:{skipped_duplicates}")
+
+ except Exception as e:
+ print(f"[定时任务] 执行出错: {str(e)}")
+ logger.error(f"[定时任务] 执行异常: {str(e)}")
+
+
+def status_push_worker():
+ """后台线程:每秒推送运行中任务的状态更新"""
+ while True:
+ try:
+ # 遍历所有运行中的任务状态
+ for account_id, status_info in list(task_status.items()):
+ user_id = status_info.get('user_id')
+ if user_id:
+ # 获取账号对象
+ if user_id in user_accounts and account_id in user_accounts[user_id]:
+ account = user_accounts[user_id][account_id]
+ account_data = account.to_dict()
+ # 推送账号状态更新
+ socketio.emit('account_update', account_data, room=f'user_{user_id}')
+ # 同时推送详细进度事件(方便前端分别处理)
+ progress = status_info.get('progress', {})
+ progress_data = {
+ 'account_id': account_id,
+ 'stage': status_info.get('detail_status', ''),
+ 'total_items': account.total_items,
+ 'browsed_items': progress.get('items', 0),
+ 'total_attachments': account.total_attachments,
+ 'viewed_attachments': progress.get('attachments', 0),
+ 'start_time': status_info.get('start_time', 0),
+ 'elapsed_seconds': account_data.get('elapsed_seconds', 0),
+ 'elapsed_display': account_data.get('elapsed_display', '')
+ }
+ socketio.emit('task_progress', progress_data, room=f'user_{user_id}')
+ time.sleep(1) # 每秒推送一次
+ except Exception as e:
+ logger.debug(f"状态推送出错: {e}")
+ time.sleep(1)
+
+
+def scheduled_task_worker():
+ """定时任务工作线程"""
+ import schedule
+
+ def cleanup_expired_captcha():
+ """清理过期验证码,防止内存泄漏"""
+ try:
+ current_time = time.time()
+ expired_keys = [k for k, v in captcha_storage.items()
+ if v["expire_time"] < current_time]
+ deleted_count = len(expired_keys)
+ for k in expired_keys:
+ del captcha_storage[k]
+ if deleted_count > 0:
+ print(f"[定时清理] 已清理 {deleted_count} 个过期验证码")
+ except Exception as e:
+ print(f"[定时清理] 清理验证码出错: {str(e)}")
+
+ def cleanup_old_data():
+ """清理7天前的截图和日志"""
+ try:
+ print(f"[定时清理] 开始清理7天前的数据...")
+
+ # 清理7天前的任务日志
+ deleted_logs = database.delete_old_task_logs(7)
+ print(f"[定时清理] 已删除 {deleted_logs} 条任务日志")
+
+ # 清理30天前的操作日志
+ deleted_operation_logs = database.clean_old_operation_logs(30)
+ print(f"[定时清理] 已删除 {deleted_operation_logs} 条操作日志")
+ # 清理7天前的截图
+ deleted_screenshots = 0
+ if os.path.exists(SCREENSHOTS_DIR):
+ cutoff_time = time.time() - (7 * 24 * 60 * 60) # 7天前的时间戳
+ for filename in os.listdir(SCREENSHOTS_DIR):
+ if filename.lower().endswith(('.png', '.jpg', '.jpeg')):
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ try:
+ # 检查文件修改时间
+ if os.path.getmtime(filepath) < cutoff_time:
+ os.remove(filepath)
+ deleted_screenshots += 1
+ except Exception as e:
+ print(f"[定时清理] 删除截图失败 {filename}: {str(e)}")
+
+ print(f"[定时清理] 已删除 {deleted_screenshots} 个截图文件")
+ print(f"[定时清理] 清理完成!")
+
+ except Exception as e:
+ print(f"[定时清理] 清理任务出错: {str(e)}")
+
+
+ def check_user_schedules():
+ """检查并执行用户定时任务"""
+ import json
+ try:
+ from datetime import datetime
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ now = datetime.now(beijing_tz)
+ current_time = now.strftime('%H:%M')
+ current_weekday = now.isoweekday()
+
+ # 获取所有启用的用户定时任务
+ enabled_schedules = database.get_enabled_user_schedules()
+
+ for schedule_config in enabled_schedules:
+ # 检查时间是否匹配
+ if schedule_config['schedule_time'] != current_time:
+ continue
+
+ # 检查星期是否匹配
+ allowed_weekdays = [int(d) for d in schedule_config.get('weekdays', '1,2,3,4,5').split(',') if d.strip()]
+ if current_weekday not in allowed_weekdays:
+ continue
+
+ # 检查今天是否已经执行过
+ last_run = schedule_config.get('last_run_at')
+ if last_run:
+ try:
+ last_run_date = datetime.strptime(last_run, '%Y-%m-%d %H:%M:%S').date()
+ if last_run_date == now.date():
+ continue # 今天已执行过
+ except:
+ pass
+
+ # 执行用户定时任务
+ user_id = schedule_config['user_id']
+ schedule_id = schedule_config['id']
+ browse_type = schedule_config.get('browse_type', '应读')
+ enable_screenshot = schedule_config.get('enable_screenshot', 1)
+
+ # 调试日志
+ print(f"[DEBUG] 定时任务 {schedule_config.get('name')}: enable_screenshot={enable_screenshot} (类型:{type(enable_screenshot).__name__})")
+
+ try:
+ account_ids = json.loads(schedule_config.get('account_ids', '[]') or '[]')
+ except:
+ account_ids = []
+
+ if not account_ids:
+ continue
+
+ print(f"[用户定时任务] 用户 {schedule_config.get('user_username', user_id)} 的任务 '{schedule_config.get('name', '')}' 开始执行")
+
+ # 创建执行日志
+ import time as time_mod
+ execution_start_time = time_mod.time()
+ log_id = database.create_schedule_execution_log(
+ schedule_id=schedule_id,
+ user_id=user_id,
+ schedule_name=schedule_config.get('name', '未命名任务')
+ )
+
+ started_count = 0
+ for account_id in account_ids:
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ continue
+ account = user_accounts[user_id][account_id]
+ if account.is_running:
+ continue
+
+ account.is_running = True
+ account.should_stop = False
+ account.status = "排队中"
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot, 'user_scheduled'),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+ started_count += 1
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ # 更新最后执行时间
+ database.update_schedule_last_run(schedule_id)
+
+ # 更新执行日志
+ execution_duration = int(time_mod.time() - execution_start_time)
+ database.update_schedule_execution_log(
+ log_id,
+ total_accounts=len(account_ids),
+ success_accounts=started_count,
+ failed_accounts=len(account_ids) - started_count,
+ duration_seconds=execution_duration,
+ status='completed'
+ )
+
+ print(f"[用户定时任务] 已启动 {started_count} 个账号")
+
+ except Exception as e:
+ print(f"[用户定时任务] 检查出错: {str(e)}")
+ import traceback
+ traceback.print_exc()
+
+ # 每分钟检查一次配置
+ def check_and_schedule():
+ config = database.get_system_config()
+
+ # 清除旧的任务
+ schedule.clear()
+
+ # 时区转换函数:将CST时间转换为UTC时间(容器使用UTC)
+ def cst_to_utc_time(cst_time_str):
+ """将CST时间字符串(HH:MM)转换为UTC时间字符串
+
+ Args:
+ cst_time_str: CST时间字符串,格式为 HH:MM
+
+ Returns:
+ UTC时间字符串,格式为 HH:MM
+ """
+ from datetime import datetime, timedelta
+ # 解析CST时间
+ hour, minute = map(int, cst_time_str.split(':'))
+ # CST是UTC+8,所以UTC时间 = CST时间 - 8小时
+ utc_hour = (hour - 8) % 24
+ return f"{utc_hour:02d}:{minute:02d}"
+
+ # 始终添加每天凌晨3点(CST)的数据清理任务
+ cleanup_utc_time = cst_to_utc_time("03:00")
+ schedule.every().day.at(cleanup_utc_time).do(cleanup_old_data)
+ print(f"[定时任务] 已设置数据清理任务: 每天 CST 03:00 (UTC {cleanup_utc_time})")
+
+ # 每小时清理过期验证码
+ schedule.every().hour.do(cleanup_expired_captcha)
+ print(f"[定时任务] 已设置验证码清理任务: 每小时执行一次")
+
+ # 如果启用了定时浏览任务,则添加
+ if config.get('schedule_enabled'):
+ schedule_time_cst = config.get('schedule_time', '02:00')
+ schedule_time_utc = cst_to_utc_time(schedule_time_cst)
+ schedule.every().day.at(schedule_time_utc).do(run_scheduled_task)
+ print(f"[定时任务] 已设置浏览任务: 每天 CST {schedule_time_cst} (UTC {schedule_time_utc})")
+
+ # 初始检查
+ check_and_schedule()
+ last_check = time.time()
+
+ while True:
+ try:
+ # 执行待执行的任务
+ schedule.run_pending()
+
+ # 每60秒重新检查一次配置
+ if time.time() - last_check > 60:
+ check_and_schedule()
+ check_user_schedules() # 检查用户定时任务
+ last_check = time.time()
+
+ time.sleep(1)
+ except Exception as e:
+ print(f"[定时任务] 调度器出错: {str(e)}")
+ time.sleep(5)
+
+
+
+# ========== 断点续传API ==========
+@app.route('/yuyx/api/checkpoint/paused')
+@admin_required
+def checkpoint_get_paused():
+ try:
+ user_id = request.args.get('user_id', type=int)
+ tasks = checkpoint_mgr.get_paused_tasks(user_id=user_id)
+ return jsonify({'success': True, 'tasks': tasks})
+ except Exception as e:
+ logger.error(f"获取暂停任务失败: {e}")
+ return jsonify({'success': False, 'message': str(e)}), 500
+
+@app.route('/yuyx/api/checkpoint//resume', methods=['POST'])
+@admin_required
+def checkpoint_resume(task_id):
+ try:
+ checkpoint = checkpoint_mgr.get_checkpoint(task_id)
+ if not checkpoint:
+ return jsonify({'success': False, 'message': '任务不存在'}), 404
+ if checkpoint['status'] != 'paused':
+ return jsonify({'success': False, 'message': '任务未暂停'}), 400
+ if checkpoint_mgr.resume_task(task_id):
+ import threading
+ threading.Thread(
+ target=run_task,
+ args=(checkpoint['user_id'], checkpoint['account_id'], checkpoint['browse_type'], True, 'resumed'),
+ daemon=True
+ ).start()
+ return jsonify({'success': True})
+ return jsonify({'success': False}), 500
+ except Exception as e:
+ logger.error(f"恢复任务失败: {e}")
+ return jsonify({'success': False, 'message': str(e)}), 500
+
+@app.route('/yuyx/api/checkpoint//abandon', methods=['POST'])
+@admin_required
+def checkpoint_abandon(task_id):
+ try:
+ if checkpoint_mgr.abandon_task(task_id):
+ return jsonify({'success': True})
+ return jsonify({'success': False}), 404
+ except Exception as e:
+ return jsonify({'success': False, 'message': str(e)}), 500
+
+# 初始化浏览器池(在后台线程中预热,不阻塞启动)
+# ==================== 用户定时任务API ====================
+
+@app.route('/api/schedules', methods=['GET'])
+@login_required
+def get_user_schedules_api():
+ """获取当前用户的所有定时任务"""
+ schedules = database.get_user_schedules(current_user.id)
+ import json
+ for s in schedules:
+ try:
+ s['account_ids'] = json.loads(s.get('account_ids', '[]') or '[]')
+ except:
+ s['account_ids'] = []
+ return jsonify(schedules)
+
+
+@app.route('/api/schedules', methods=['POST'])
+@login_required
+def create_user_schedule_api():
+ """创建用户定时任务"""
+ data = request.json
+
+ name = data.get('name', '我的定时任务')
+ schedule_time = data.get('schedule_time', '08:00')
+ weekdays = data.get('weekdays', '1,2,3,4,5')
+ browse_type = data.get('browse_type', '应读')
+ enable_screenshot = data.get('enable_screenshot', 1)
+ account_ids = data.get('account_ids', [])
+
+ import re
+ if not re.match(r'^\d{2}:\d{2}$', schedule_time):
+ return jsonify({"error": "时间格式不正确,应为 HH:MM"}), 400
+
+ schedule_id = database.create_user_schedule(
+ user_id=current_user.id,
+ name=name,
+ schedule_time=schedule_time,
+ weekdays=weekdays,
+ browse_type=browse_type,
+ enable_screenshot=enable_screenshot,
+ account_ids=account_ids
+ )
+
+ if schedule_id:
+ return jsonify({"success": True, "id": schedule_id})
+ return jsonify({"error": "创建失败"}), 500
+
+
+@app.route('/api/schedules/', methods=['GET'])
+@login_required
+def get_schedule_detail_api(schedule_id):
+ """获取定时任务详情"""
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ import json
+ try:
+ schedule['account_ids'] = json.loads(schedule.get('account_ids', '[]') or '[]')
+ except:
+ schedule['account_ids'] = []
+ return jsonify(schedule)
+
+
+@app.route('/api/schedules/', methods=['PUT'])
+@login_required
+def update_schedule_api(schedule_id):
+ """更新定时任务"""
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ data = request.json
+ allowed_fields = ['name', 'schedule_time', 'weekdays', 'browse_type',
+ 'enable_screenshot', 'account_ids', 'enabled']
+
+ update_data = {k: v for k, v in data.items() if k in allowed_fields}
+
+ if 'schedule_time' in update_data:
+ import re
+ if not re.match(r'^\d{2}:\d{2}$', update_data['schedule_time']):
+ return jsonify({"error": "时间格式不正确"}), 400
+
+ success = database.update_user_schedule(schedule_id, **update_data)
+ if success:
+ return jsonify({"success": True})
+ return jsonify({"error": "更新失败"}), 500
+
+
+@app.route('/api/schedules/', methods=['DELETE'])
+@login_required
+def delete_schedule_api(schedule_id):
+ """删除定时任务"""
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ success = database.delete_user_schedule(schedule_id)
+ if success:
+ return jsonify({"success": True})
+ return jsonify({"error": "删除失败"}), 500
+
+
+@app.route('/api/schedules//toggle', methods=['POST'])
+@login_required
+def toggle_schedule_api(schedule_id):
+ """启用/禁用定时任务"""
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ data = request.json
+ enabled = data.get('enabled', not schedule['enabled'])
+
+ success = database.toggle_user_schedule(schedule_id, enabled)
+ if success:
+ return jsonify({"success": True, "enabled": enabled})
+ return jsonify({"error": "操作失败"}), 500
+
+
+@app.route('/api/schedules//run', methods=['POST'])
+@login_required
+def run_schedule_now_api(schedule_id):
+ """立即执行定时任务"""
+ import json
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ try:
+ account_ids = json.loads(schedule.get('account_ids', '[]') or '[]')
+ except:
+ account_ids = []
+
+ if not account_ids:
+ return jsonify({"error": "没有配置账号"}), 400
+
+ user_id = current_user.id
+ browse_type = schedule['browse_type']
+ enable_screenshot = schedule['enable_screenshot']
+
+ started = []
+ for account_id in account_ids:
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ continue
+ account = user_accounts[user_id][account_id]
+ if account.is_running:
+ continue
+
+ account.is_running = True
+ account.should_stop = False
+ account.status = "排队中"
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot, 'user_scheduled'),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+ started.append(account_id)
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ database.update_schedule_last_run(schedule_id)
+
+ return jsonify({
+ "success": True,
+ "started_count": len(started),
+ "message": f"已启动 {len(started)} 个账号"
+ })
+
+
+
+
+# ==================== 定时任务执行日志API ====================
+
+@app.route('/api/schedules//logs', methods=['GET'])
+@login_required
+def get_schedule_logs_api(schedule_id):
+ """获取定时任务执行日志"""
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ limit = request.args.get('limit', 10, type=int)
+ logs = database.get_schedule_execution_logs(schedule_id, limit)
+ return jsonify(logs)
+
+
+# ==================== 批量操作API ====================
+
+@app.route('/api/accounts/batch/start', methods=['POST'])
+@login_required
+def batch_start_accounts():
+ """批量启动账号"""
+ user_id = current_user.id
+ data = request.json
+
+ account_ids = data.get('account_ids', [])
+ browse_type = data.get('browse_type', '应读')
+ enable_screenshot = data.get('enable_screenshot', True)
+
+ if not account_ids:
+ return jsonify({"error": "请选择要启动的账号"}), 400
+
+ started = []
+ failed = []
+
+ for account_id in account_ids:
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ failed.append({'id': account_id, 'reason': '账号不存在'})
+ continue
+
+ account = user_accounts[user_id][account_id]
+
+ if account.is_running:
+ failed.append({'id': account_id, 'reason': '已在运行中'})
+ continue
+
+ account.is_running = True
+ account.should_stop = False
+ account.status = "排队中"
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot, 'batch'),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+ started.append(account_id)
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ return jsonify({
+ "success": True,
+ "started_count": len(started),
+ "failed_count": len(failed),
+ "started": started,
+ "failed": failed
+ })
+
+
+@app.route('/api/accounts/batch/stop', methods=['POST'])
+@login_required
+def batch_stop_accounts():
+ """批量停止账号"""
+ user_id = current_user.id
+ data = request.json
+
+ account_ids = data.get('account_ids', [])
+
+ if not account_ids:
+ return jsonify({"error": "请选择要停止的账号"}), 400
+
+ stopped = []
+
+ for account_id in account_ids:
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ continue
+
+ account = user_accounts[user_id][account_id]
+
+ if not account.is_running:
+ continue
+
+ account.should_stop = True
+ account.status = "正在停止"
+ stopped.append(account_id)
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ return jsonify({
+ "success": True,
+ "stopped_count": len(stopped),
+ "stopped": stopped
+ })
+
+if __name__ == '__main__':
+ print("=" * 60)
+ print("知识管理平台自动化工具 - 多用户版")
+ print("=" * 60)
+
+ # 初始化数据库
+ database.init_database()
+ checkpoint_mgr = get_checkpoint_manager()
+ print("✓ 任务断点管理器已初始化")
+
+ # 加载系统配置(并发设置)
+ try:
+ system_config = database.get_system_config()
+ if system_config:
+ # 使用globals()修改全局变量
+ globals()['max_concurrent_global'] = system_config.get('max_concurrent_global', 2)
+ globals()['max_concurrent_per_account'] = system_config.get('max_concurrent_per_account', 1)
+
+ # 重新创建信号量
+ globals()['global_semaphore'] = threading.Semaphore(globals()['max_concurrent_global'])
+
+ print(f"✓ 已加载并发配置: 全局={globals()['max_concurrent_global']}, 单账号={globals()['max_concurrent_per_account']}")
+ except Exception as e:
+ print(f"警告: 加载并发配置失败,使用默认值: {e}")
+
+ # 主线程初始化浏览器(Playwright不支持跨线程)
+ print("\n正在初始化浏览器管理器...")
+ init_browser_manager()
+
+ # 启动定时任务调度器
+ print("\n启动定时任务调度器...")
+ scheduler_thread = threading.Thread(target=scheduled_task_worker, daemon=True)
+ scheduler_thread.start()
+ print("✓ 定时任务调度器已启动")
+
+ # 启动状态推送线程(每秒推送运行中任务状态)
+ status_thread = threading.Thread(target=status_push_worker, daemon=True)
+ status_thread.start()
+ print("✓ 状态推送线程已启动(1秒/次)")
+
+ # 启动Web服务器
+ print("\n服务器启动中...")
+ print(f"用户访问地址: http://{config.SERVER_HOST}:{config.SERVER_PORT}")
+ print(f"后台管理地址: http://{config.SERVER_HOST}:{config.SERVER_PORT}/yuyx")
+ print("默认管理员: admin/admin")
+ print("=" * 60 + "\n")
+
+ # 同步初始化浏览器池(必须在socketio.run之前,否则eventlet会导致asyncio冲突)
+ try:
+ system_cfg = database.get_system_config()
+ pool_size = system_cfg.get('max_screenshot_concurrent', 3) if system_cfg else 3
+ print(f"正在预热 {pool_size} 个浏览器实例(截图加速)...")
+ init_browser_worker_pool(pool_size=pool_size)
+ print("✓ 浏览器池初始化完成")
+ except Exception as e:
+ print(f"警告: 浏览器池初始化失败: {e}")
+
+ socketio.run(app, host=config.SERVER_HOST, port=config.SERVER_PORT, debug=config.DEBUG, allow_unsafe_werkzeug=True)
+
+
diff --git a/app.py.broken b/app.py.broken
new file mode 100755
index 0000000..4863870
--- /dev/null
+++ b/app.py.broken
@@ -0,0 +1,2254 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+知识管理平台自动化工具 - 多用户版本
+支持用户注册登录、后台管理、数据隔离
+"""
+
+# 设置时区为中国标准时间(CST, UTC+8)
+import os
+os.environ['TZ'] = 'Asia/Shanghai'
+try:
+ import time
+ time.tzset()
+except AttributeError:
+ pass # Windows系统不支持tzset()
+
+import pytz
+from datetime import datetime
+from flask import Flask, render_template, request, jsonify, send_from_directory, redirect, url_for, session
+from flask_socketio import SocketIO, emit, join_room, leave_room
+from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
+import threading
+import time
+import json
+import os
+from datetime import datetime, timedelta, timezone
+from functools import wraps
+
+# 导入数据库模块和核心模块
+import database
+import requests
+from playwright_automation import PlaywrightBrowserManager, PlaywrightAutomation, BrowseResult
+from browser_installer import check_and_install_browser
+# ========== 优化模块导入 ==========
+from app_config import get_config
+from app_logger import init_logging, get_logger, audit_logger
+from app_security import (
+ ip_rate_limiter, require_ip_not_locked,
+ validate_username, validate_password, validate_email,
+ is_safe_path, sanitize_filename, get_client_ip
+)
+
+
+
+# ========== 初始化配置 ==========
+config = get_config()
+app = Flask(__name__)
+# SECRET_KEY持久化,避免重启后所有用户登出
+SECRET_KEY_FILE = 'data/secret_key.txt'
+if os.path.exists(SECRET_KEY_FILE):
+ with open(SECRET_KEY_FILE, 'r') as f:
+ SECRET_KEY = f.read().strip()
+else:
+ SECRET_KEY = os.urandom(24).hex()
+ os.makedirs('data', exist_ok=True)
+ with open(SECRET_KEY_FILE, 'w') as f:
+ f.write(SECRET_KEY)
+ print(f"✓ 已生成新的SECRET_KEY并保存")
+app.config.from_object(config)
+socketio = SocketIO(app, cors_allowed_origins="*")
+
+# ========== 初始化日志系统 ==========
+init_logging(log_level=config.LOG_LEVEL, log_file=config.LOG_FILE)
+logger = get_logger('app')
+logger.info("="*60)
+logger.info("知识管理平台自动化工具 - 多用户版")
+logger.info("="*60)
+
+
+# Flask-Login 配置
+login_manager = LoginManager()
+login_manager.init_app(app)
+login_manager.login_view = 'login_page'
+
+# 截图目录
+SCREENSHOTS_DIR = config.SCREENSHOTS_DIR
+os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
+
+# 全局变量
+browser_manager = None
+user_accounts = {} # {user_id: {account_id: Account对象}}
+active_tasks = {} # {account_id: Thread对象}
+log_cache = {} # {user_id: [logs]} 每个用户独立的日志缓存
+log_cache_total_count = 0 # 全局日志总数,防止无限增长
+
+# 日志缓存限制
+MAX_LOGS_PER_USER = config.MAX_LOGS_PER_USER # 每个用户最多100条
+MAX_TOTAL_LOGS = config.MAX_TOTAL_LOGS # 全局最多1000条,防止内存泄漏
+
+# 并发控制:每个用户同时最多运行1个账号(避免内存不足)
+# 验证码存储:{session_id: {"code": "1234", "expire_time": timestamp, "failed_attempts": 0}}
+captcha_storage = {}
+
+# IP限流存储:{ip: {"attempts": count, "lock_until": timestamp, "first_attempt": timestamp}}
+ip_rate_limit = {}
+
+# 限流配置
+MAX_CAPTCHA_ATTEMPTS = 5 # 每个验证码最多尝试次数
+MAX_IP_ATTEMPTS_PER_HOUR = 10 # 每小时每个IP最多验证码错误次数
+IP_LOCK_DURATION = 3600 # IP锁定时长(秒) - 1小时
+# 全局限制:整个系统同时最多运行2个账号(线程本地架构,每个线程独立浏览器,内存占用约200MB/浏览器)
+max_concurrent_per_account = 1 # 每个用户最多1个
+max_concurrent_global = 2 # 全局最多2个(线程本地架构内存需求更高)
+user_semaphores = {} # {user_id: Semaphore}
+global_semaphore = threading.Semaphore(max_concurrent_global)
+
+# 截图专用信号量:限制同时进行的截图任务数量为1(避免资源竞争)
+screenshot_semaphore = threading.Semaphore(1)
+
+
+class User(UserMixin):
+ """Flask-Login 用户类"""
+ def __init__(self, user_id):
+ self.id = user_id
+
+
+class Admin(UserMixin):
+ """管理员类"""
+ def __init__(self, admin_id):
+ self.id = admin_id
+ self.is_admin = True
+
+
+class Account:
+ """账号类"""
+ def __init__(self, account_id, user_id, username, password, remember=True, remark=''):
+ self.id = account_id
+ self.user_id = user_id
+ self.username = username
+ self.password = password
+ self.remember = remember
+ self.remark = remark
+ self.status = "未开始"
+ self.is_running = False
+ self.should_stop = False
+ self.total_items = 0
+ self.total_attachments = 0
+ self.automation = None
+ self.last_browse_type = "注册前未读"
+ self.proxy_config = None # 保存代理配置,浏览和截图共用
+
+ def to_dict(self):
+ return {
+ "id": self.id,
+ "username": self.username,
+ "status": self.status,
+ "remark": self.remark,
+ "total_items": self.total_items,
+ "total_attachments": self.total_attachments,
+ "is_running": self.is_running
+ }
+
+
+@login_manager.user_loader
+def load_user(user_id):
+ """Flask-Login 用户加载"""
+ user = database.get_user_by_id(int(user_id))
+ if user:
+ return User(user['id'])
+ return None
+
+
+def admin_required(f):
+ """管理员权限装饰器"""
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+ return f(*args, **kwargs)
+ return decorated_function
+
+
+def log_to_client(message, user_id=None, account_id=None):
+ """发送日志到Web客户端(用户隔离)"""
+ beijing_tz = timezone(timedelta(hours=8))
+ timestamp = datetime.now(beijing_tz).strftime('%H:%M:%S')
+ log_data = {
+ 'timestamp': timestamp,
+ 'message': message,
+ 'account_id': account_id
+ }
+
+ # 如果指定了user_id,则缓存到该用户的日志
+ if user_id:
+ global log_cache_total_count
+ if user_id not in log_cache:
+ log_cache[user_id] = []
+ log_cache[user_id].append(log_data)
+ log_cache_total_count += 1
+
+ # 持久化到数据库 (已禁用,使用task_logs表代替)
+ # try:
+ # database.save_operation_log(user_id, message, account_id, 'INFO')
+ # except Exception as e:
+ # print(f"保存日志到数据库失败: {e}")
+
+ # 单用户限制
+ if len(log_cache[user_id]) > MAX_LOGS_PER_USER:
+ log_cache[user_id].pop(0)
+ log_cache_total_count -= 1
+
+ # 全局限制 - 如果超过总数限制,清理日志最多的用户
+ while log_cache_total_count > MAX_TOTAL_LOGS:
+ if log_cache:
+ max_user = max(log_cache.keys(), key=lambda u: len(log_cache[u]))
+ if log_cache[max_user]:
+ log_cache[max_user].pop(0)
+ log_cache_total_count -= 1
+ else:
+ break
+ else:
+ break
+
+ # 发送到该用户的room
+ socketio.emit('log', log_data, room=f'user_{user_id}')
+
+ print(f"[{timestamp}] User:{user_id} {message}")
+
+
+
+def get_proxy_from_api(api_url, max_retries=3):
+ """从API获取代理IP(支持重试)
+
+ Args:
+ api_url: 代理API地址
+ max_retries: 最大重试次数
+
+ Returns:
+ 代理服务器地址(格式: http://IP:PORT)或 None
+ """
+ for attempt in range(max_retries):
+ try:
+ response = requests.get(api_url, timeout=10)
+ if response.status_code == 200:
+ ip_port = response.text.strip()
+ if ip_port and ':' in ip_port:
+ proxy_server = f"http://{ip_port}"
+ print(f"✓ 获取代理成功: {proxy_server} (尝试 {attempt + 1}/{max_retries})")
+ return proxy_server
+ else:
+ print(f"✗ 代理格式错误: {ip_port} (尝试 {attempt + 1}/{max_retries})")
+ else:
+ print(f"✗ 获取代理失败: HTTP {response.status_code} (尝试 {attempt + 1}/{max_retries})")
+ except Exception as e:
+ print(f"✗ 获取代理异常: {str(e)} (尝试 {attempt + 1}/{max_retries})")
+
+ if attempt < max_retries - 1:
+ time.sleep(1)
+
+ print(f"✗ 获取代理失败,已重试 {max_retries} 次,将不使用代理继续")
+ return None
+
+def init_browser_manager():
+ """初始化浏览器管理器"""
+ global browser_manager
+ if browser_manager is None:
+ print("正在初始化Playwright浏览器管理器...")
+
+ if not check_and_install_browser(log_callback=lambda msg, account_id=None: print(msg)):
+ print("浏览器环境检查失败!")
+ return False
+
+ browser_manager = PlaywrightBrowserManager(
+ headless=True,
+ log_callback=lambda msg, account_id=None: print(msg)
+ )
+
+ try:
+ # 不再需要initialize(),每个账号会创建独立浏览器
+ print("Playwright浏览器管理器创建成功!")
+ return True
+ except Exception as e:
+ print(f"Playwright初始化失败: {str(e)}")
+ return False
+ return True
+
+
+# ==================== 前端路由 ====================
+
+@app.route('/')
+def index():
+ """主页 - 重定向到登录或应用"""
+ if current_user.is_authenticated:
+ return redirect(url_for('app_page'))
+ return redirect(url_for('login_page'))
+
+
+@app.route('/login')
+def login_page():
+ """登录页面"""
+ return render_template('login.html')
+
+
+@app.route('/register')
+def register_page():
+ """注册页面"""
+ return render_template('register.html')
+
+
+@app.route('/app')
+@login_required
+def app_page():
+ """主应用页面"""
+ return render_template('index.html')
+
+
+@app.route('/yuyx')
+def admin_login_page():
+ """后台登录页面"""
+ if 'admin_id' in session:
+ return redirect(url_for('admin_page'))
+ return render_template('admin_login.html')
+
+
+@app.route('/yuyx/admin')
+@admin_required
+def admin_page():
+ """后台管理页面"""
+ return render_template('admin.html')
+
+
+
+
+@app.route('/yuyx/vip')
+@admin_required
+def vip_admin_page():
+ """VIP管理页面"""
+ return render_template('vip_admin.html')
+
+
+# ==================== 用户认证API ====================
+
+@app.route('/api/register', methods=['POST'])
+@require_ip_not_locked # IP限流保护
+def register():
+ """用户注册"""
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ email = data.get('email', '').strip()
+ captcha_session = data.get('captcha_session', '')
+ captcha_code = data.get('captcha', '').strip()
+
+ if not username or not password:
+ return jsonify({"error": "用户名和密码不能为空"}), 400
+
+ # 验证验证码
+ if not captcha_session or captcha_session not in captcha_storage:
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ captcha_data = captcha_storage[captcha_session]
+ if captcha_data["expire_time"] < time.time():
+ del captcha_storage[captcha_session]
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ # 获取客户端IP
+ client_ip = request.headers.get('X-Forwarded-For', request.headers.get('X-Real-IP', request.remote_addr))
+ if client_ip and ',' in client_ip:
+ client_ip = client_ip.split(',')[0].strip()
+
+ # 检查IP限流
+ allowed, error_msg = check_ip_rate_limit(client_ip)
+ if not allowed:
+ return jsonify({"error": error_msg}), 429
+
+ # 检查验证码尝试次数
+ if captcha_data.get("failed_attempts", 0) >= MAX_CAPTCHA_ATTEMPTS:
+ del captcha_storage[captcha_session]
+ return jsonify({"error": "验证码尝试次数过多,请重新获取"}), 400
+
+ if captcha_data["code"] != captcha_code:
+ # 记录失败次数
+ captcha_data["failed_attempts"] = captcha_data.get("failed_attempts", 0) + 1
+
+ # 记录IP失败尝试
+ is_locked = record_failed_captcha(client_ip)
+ if is_locked:
+ return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
+
+ return jsonify({"error": "验证码错误(剩余{}次机会)".format(
+ MAX_CAPTCHA_ATTEMPTS - captcha_data["failed_attempts"])}), 400
+
+ # 验证成功,删除已使用的验证码
+ del captcha_storage[captcha_session]
+
+ user_id = database.create_user(username, password, email)
+ if user_id:
+ return jsonify({"success": True, "message": "注册成功,请等待管理员审核"})
+ else:
+ return jsonify({"error": "用户名已存在"}), 400
+
+
+# ==================== 验证码API ====================
+import random
+
+
+def check_ip_rate_limit(ip_address):
+ """检查IP是否被限流"""
+ current_time = time.time()
+
+ # 清理过期的IP记录
+ expired_ips = [ip for ip, data in ip_rate_limit.items()
+ if data.get("lock_until", 0) < current_time and
+ current_time - data.get("first_attempt", current_time) > 3600]
+ for ip in expired_ips:
+ del ip_rate_limit[ip]
+
+ # 检查IP是否被锁定
+ if ip_address in ip_rate_limit:
+ ip_data = ip_rate_limit[ip_address]
+
+ # 如果IP被锁定且未到解锁时间
+ if ip_data.get("lock_until", 0) > current_time:
+ remaining_time = int(ip_data["lock_until"] - current_time)
+ return False, "IP已被锁定,请{}分钟后再试".format(remaining_time // 60 + 1)
+
+ # 如果超过1小时,重置计数
+ if current_time - ip_data.get("first_attempt", current_time) > 3600:
+ ip_rate_limit[ip_address] = {
+ "attempts": 0,
+ "first_attempt": current_time
+ }
+
+ return True, None
+
+
+def record_failed_captcha(ip_address):
+ """记录验证码失败尝试"""
+ current_time = time.time()
+
+ if ip_address not in ip_rate_limit:
+ ip_rate_limit[ip_address] = {
+ "attempts": 1,
+ "first_attempt": current_time
+ }
+ else:
+ ip_rate_limit[ip_address]["attempts"] += 1
+
+ # 检查是否超过限制
+ if ip_rate_limit[ip_address]["attempts"] >= MAX_IP_ATTEMPTS_PER_HOUR:
+ ip_rate_limit[ip_address]["lock_until"] = current_time + IP_LOCK_DURATION
+ return True # 表示IP已被锁定
+
+ return False # 表示还未锁定
+
+
+@app.route("/api/generate_captcha", methods=["POST"])
+def generate_captcha():
+ """生成4位数字验证码"""
+ import uuid
+ session_id = str(uuid.uuid4())
+
+ # 生成4位随机数字
+ code = "".join([str(random.randint(0, 9)) for _ in range(4)])
+
+ # 存储验证码,5分钟过期
+ captcha_storage[session_id] = {
+ "code": code,
+ "expire_time": time.time() + 300,
+ "failed_attempts": 0
+ }
+
+ # 清理过期验证码
+ expired_keys = [k for k, v in captcha_storage.items() if v["expire_time"] < time.time()]
+ for k in expired_keys:
+ del captcha_storage[k]
+
+ return jsonify({"session_id": session_id, "captcha": code})
+
+
+@app.route('/api/login', methods=['POST'])
+@require_ip_not_locked # IP限流保护
+def login():
+ """用户登录"""
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ captcha_session = data.get('captcha_session', '')
+ captcha_code = data.get('captcha', '').strip()
+ need_captcha = data.get('need_captcha', False)
+
+ # 如果需要验证码,验证验证码
+ if need_captcha:
+ if not captcha_session or captcha_session not in captcha_storage:
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ captcha_data = captcha_storage[captcha_session]
+ if captcha_data["expire_time"] < time.time():
+ del captcha_storage[captcha_session]
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ if captcha_data["code"] != captcha_code:
+ return jsonify({"error": "验证码错误"}), 400
+
+ # 验证成功,删除已使用的验证码
+ del captcha_storage[captcha_session]
+
+ # 先检查用户是否存在
+ user_exists = database.get_user_by_username(username)
+ if not user_exists:
+ return jsonify({"error": "账号未注册", "need_captcha": True}), 401
+
+ # 检查密码是否正确
+ user = database.verify_user(username, password)
+ if not user:
+ # 密码错误
+ return jsonify({"error": "密码错误", "need_captcha": True}), 401
+
+ # 检查审核状态
+ if user['status'] != 'approved':
+ return jsonify({"error": "账号未审核,请等待管理员审核", "need_captcha": False}), 401
+
+ # 登录成功
+ user_obj = User(user['id'])
+ login_user(user_obj)
+ load_user_accounts(user['id'])
+ return jsonify({"success": True})
+
+
+@app.route('/api/logout', methods=['POST'])
+@login_required
+def logout():
+ """用户登出"""
+ logout_user()
+ return jsonify({"success": True})
+
+
+# ==================== 管理员认证API ====================
+
+@app.route('/yuyx/api/login', methods=['POST'])
+@require_ip_not_locked # IP限流保护
+def admin_login():
+ """管理员登录"""
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ captcha_session = data.get('captcha_session', '')
+ captcha_code = data.get('captcha', '').strip()
+ need_captcha = data.get('need_captcha', False)
+
+ # 如果需要验证码,验证验证码
+ if need_captcha:
+ if not captcha_session or captcha_session not in captcha_storage:
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ captcha_data = captcha_storage[captcha_session]
+ if captcha_data["expire_time"] < time.time():
+ del captcha_storage[captcha_session]
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ if captcha_data["code"] != captcha_code:
+ return jsonify({"error": "验证码错误"}), 400
+
+ # 验证成功,删除已使用的验证码
+ del captcha_storage[captcha_session]
+
+ admin = database.verify_admin(username, password)
+ if admin:
+ session['admin_id'] = admin['id']
+ session['admin_username'] = admin['username']
+ return jsonify({"success": True})
+ else:
+ return jsonify({"error": "管理员用户名或密码错误", "need_captcha": True}), 401
+
+
+@app.route('/yuyx/api/logout', methods=['POST'])
+@admin_required
+def admin_logout():
+ """管理员登出"""
+ session.pop('admin_id', None)
+ session.pop('admin_username', None)
+ return jsonify({"success": True})
+
+
+@app.route('/yuyx/api/users', methods=['GET'])
+@admin_required
+def get_all_users():
+ """获取所有用户"""
+ users = database.get_all_users()
+ return jsonify(users)
+
+
+@app.route('/yuyx/api/users/pending', methods=['GET'])
+@admin_required
+def get_pending_users():
+ """获取待审核用户"""
+ users = database.get_pending_users()
+ return jsonify(users)
+
+
+@app.route('/yuyx/api/users//approve', methods=['POST'])
+@admin_required
+def approve_user_route(user_id):
+ """审核通过用户"""
+ if database.approve_user(user_id):
+ return jsonify({"success": True})
+ return jsonify({"error": "审核失败"}), 400
+
+
+@app.route('/yuyx/api/users//reject', methods=['POST'])
+@admin_required
+def reject_user_route(user_id):
+ """拒绝用户"""
+ if database.reject_user(user_id):
+ return jsonify({"success": True})
+ return jsonify({"error": "拒绝失败"}), 400
+
+
+@app.route('/yuyx/api/users/', methods=['DELETE'])
+@admin_required
+def delete_user_route(user_id):
+ """删除用户"""
+ if database.delete_user(user_id):
+ # 清理内存中的账号数据
+ if user_id in user_accounts:
+ del user_accounts[user_id]
+
+ # 清理用户信号量,防止内存泄漏
+ if user_id in user_semaphores:
+ del user_semaphores[user_id]
+
+ # 清理用户日志缓存,防止内存泄漏
+ global log_cache_total_count
+ if user_id in log_cache:
+ log_cache_total_count -= len(log_cache[user_id])
+ del log_cache[user_id]
+
+ return jsonify({"success": True})
+ return jsonify({"error": "删除失败"}), 400
+
+
+@app.route('/yuyx/api/stats', methods=['GET'])
+@admin_required
+def get_system_stats():
+ """获取系统统计"""
+ stats = database.get_system_stats()
+ # 从session获取管理员用户名
+ stats["admin_username"] = session.get('admin_username', 'admin')
+ return jsonify(stats)
+
+
+@app.route('/yuyx/api/docker_stats', methods=['GET'])
+@admin_required
+def get_docker_stats():
+ """获取Docker容器运行状态"""
+ import subprocess
+
+ docker_status = {
+ 'running': False,
+ 'container_name': 'N/A',
+ 'uptime': 'N/A',
+ 'memory_usage': 'N/A',
+ 'memory_limit': 'N/A',
+ 'memory_percent': 'N/A',
+ 'cpu_percent': 'N/A',
+ 'status': 'Unknown'
+ }
+
+ try:
+ # 检查是否在Docker容器内
+ if os.path.exists('/.dockerenv'):
+ docker_status['running'] = True
+
+ # 获取容器名称
+ try:
+ with open('/etc/hostname', 'r') as f:
+ docker_status['container_name'] = f.read().strip()
+ except:
+ pass
+
+ # 获取内存使用情况 (cgroup v2)
+ try:
+ # 尝试cgroup v2路径
+ if os.path.exists('/sys/fs/cgroup/memory.current'):
+ # Read total memory
+ with open('/sys/fs/cgroup/memory.current', 'r') as f:
+ mem_total = int(f.read().strip())
+
+ # Read cache from memory.stat
+ cache = 0
+ if os.path.exists('/sys/fs/cgroup/memory.stat'):
+ with open('/sys/fs/cgroup/memory.stat', 'r') as f:
+ for line in f:
+ if line.startswith('inactive_file '):
+ cache = int(line.split()[1])
+ break
+
+ # Actual memory = total - cache
+ mem_bytes = mem_total - cache
+ docker_status['memory_usage'] = "{:.2f} MB".format(mem_bytes / 1024 / 1024)
+
+ # 获取内存限制
+ if os.path.exists('/sys/fs/cgroup/memory.max'):
+ with open('/sys/fs/cgroup/memory.max', 'r') as f:
+ limit_str = f.read().strip()
+ if limit_str != 'max':
+ limit_bytes = int(limit_str)
+ docker_status['memory_limit'] = "{:.2f} GB".format(limit_bytes / 1024 / 1024 / 1024)
+ docker_status['memory_percent'] = "{:.2f}%".format(mem_bytes / limit_bytes * 100)
+ # 尝试cgroup v1路径
+ elif os.path.exists('/sys/fs/cgroup/memory/memory.usage_in_bytes'):
+ # 从 memory.stat 读取内存信息
+ mem_bytes = 0
+ if os.path.exists('/sys/fs/cgroup/memory/memory.stat'):
+ with open('/sys/fs/cgroup/memory/memory.stat', 'r') as f:
+ rss = 0
+ cache = 0
+ for line in f:
+ if line.startswith('total_rss '):
+ rss = int(line.split()[1])
+ elif line.startswith('total_cache '):
+ cache = int(line.split()[1])
+ # 使用 RSS + (一部分活跃的cache),更接近docker stats的计算
+ # 但为了准确性,我们只用RSS
+ mem_bytes = rss
+
+ # 如果找不到,则使用总内存减去缓存作为后备
+ if mem_bytes == 0:
+ with open('/sys/fs/cgroup/memory/memory.usage_in_bytes', 'r') as f:
+ total_mem = int(f.read().strip())
+
+ cache = 0
+ if os.path.exists('/sys/fs/cgroup/memory/memory.stat'):
+ with open('/sys/fs/cgroup/memory/memory.stat', 'r') as f:
+ for line in f:
+ if line.startswith('total_inactive_file '):
+ cache = int(line.split()[1])
+ break
+
+ mem_bytes = total_mem - cache
+
+ docker_status['memory_usage'] = "{:.2f} MB".format(mem_bytes / 1024 / 1024)
+
+ # 获取内存限制
+ if os.path.exists('/sys/fs/cgroup/memory/memory.limit_in_bytes'):
+ with open('/sys/fs/cgroup/memory/memory.limit_in_bytes', 'r') as f:
+ limit_bytes = int(f.read().strip())
+ # 检查是否是实际限制(不是默认的超大值)
+ if limit_bytes < 9223372036854771712:
+ docker_status['memory_limit'] = "{:.2f} GB".format(limit_bytes / 1024 / 1024 / 1024)
+ docker_status['memory_percent'] = "{:.2f}%".format(mem_bytes / limit_bytes * 100)
+ except Exception as e:
+ docker_status['memory_usage'] = 'Error: {}'.format(str(e))
+
+ # 获取容器运行时间(基于PID 1的启动时间)
+ try:
+ # Get PID 1 start time
+ with open('/proc/1/stat', 'r') as f:
+ stat_data = f.read().split()
+ starttime_ticks = int(stat_data[21])
+
+ # Get system uptime
+ with open('/proc/uptime', 'r') as f:
+ system_uptime = float(f.read().split()[0])
+
+ # Get clock ticks per second
+ import os as os_module
+ ticks_per_sec = os_module.sysconf(os_module.sysconf_names['SC_CLK_TCK'])
+
+ # Calculate container uptime
+ process_start = starttime_ticks / ticks_per_sec
+ uptime_seconds = int(system_uptime - process_start)
+
+ days = uptime_seconds // 86400
+ hours = (uptime_seconds % 86400) // 3600
+ minutes = (uptime_seconds % 3600) // 60
+
+ if days > 0:
+ docker_status['uptime'] = "{}天 {}小时 {}分钟".format(days, hours, minutes)
+ elif hours > 0:
+ docker_status['uptime'] = "{}小时 {}分钟".format(hours, minutes)
+ else:
+ docker_status['uptime'] = "{}分钟".format(minutes)
+ except:
+ pass
+
+ docker_status['status'] = 'Running'
+ else:
+ docker_status['status'] = 'Not in Docker'
+
+ except Exception as e:
+ docker_status['status'] = 'Error: {}'.format(str(e))
+
+ return jsonify(docker_status)
+
+@app.route('/yuyx/api/admin/password', methods=['PUT'])
+@admin_required
+def update_admin_password():
+ """修改管理员密码"""
+ data = request.json
+ new_password = data.get('new_password', '').strip()
+
+ if not new_password:
+ return jsonify({"error": "密码不能为空"}), 400
+
+ username = session.get('admin_username')
+ if database.update_admin_password(username, new_password):
+ return jsonify({"success": True})
+ return jsonify({"error": "修改失败"}), 400
+
+
+@app.route('/yuyx/api/admin/username', methods=['PUT'])
+@admin_required
+def update_admin_username():
+ """修改管理员用户名"""
+ data = request.json
+ new_username = data.get('new_username', '').strip()
+
+ if not new_username:
+ return jsonify({"error": "用户名不能为空"}), 400
+
+ old_username = session.get('admin_username')
+ if database.update_admin_username(old_username, new_username):
+ session['admin_username'] = new_username
+ return jsonify({"success": True})
+ return jsonify({"error": "用户名已存在"}), 400
+
+
+
+# ==================== 密码重置API ====================
+
+# 管理员直接重置用户密码
+@app.route('/yuyx/api/users//reset_password', methods=['POST'])
+@admin_required
+def admin_reset_password_route(user_id):
+ """管理员直接重置用户密码(无需审核)"""
+ data = request.json
+ new_password = data.get('new_password', '').strip()
+
+ if not new_password:
+ return jsonify({"error": "新密码不能为空"}), 400
+
+ if len(new_password) < 6:
+ return jsonify({"error": "密码长度不能少于6位"}), 400
+
+ if database.admin_reset_user_password(user_id, new_password):
+ return jsonify({"message": "密码重置成功"})
+ return jsonify({"error": "重置失败,用户不存在"}), 400
+
+
+# 获取密码重置申请列表
+@app.route('/yuyx/api/password_resets', methods=['GET'])
+@admin_required
+def get_password_resets_route():
+ """获取所有待审核的密码重置申请"""
+ resets = database.get_pending_password_resets()
+ return jsonify(resets)
+
+
+# 批准密码重置申请
+@app.route('/yuyx/api/password_resets//approve', methods=['POST'])
+@admin_required
+def approve_password_reset_route(request_id):
+ """批准密码重置申请"""
+ if database.approve_password_reset(request_id):
+ return jsonify({"message": "密码重置申请已批准"})
+ return jsonify({"error": "批准失败"}), 400
+
+
+# 拒绝密码重置申请
+@app.route('/yuyx/api/password_resets//reject', methods=['POST'])
+@admin_required
+def reject_password_reset_route(request_id):
+ """拒绝密码重置申请"""
+ if database.reject_password_reset(request_id):
+ return jsonify({"message": "密码重置申请已拒绝"})
+ return jsonify({"error": "拒绝失败"}), 400
+
+
+# 用户申请重置密码(需要审核)
+@app.route('/api/reset_password_request', methods=['POST'])
+def request_password_reset():
+ """用户申请重置密码"""
+ data = request.json
+ username = data.get('username', '').strip()
+ email = data.get('email', '').strip()
+ new_password = data.get('new_password', '').strip()
+
+ if not username or not new_password:
+ return jsonify({"error": "用户名和新密码不能为空"}), 400
+
+ if len(new_password) < 6:
+ return jsonify({"error": "密码长度不能少于6位"}), 400
+
+ # 验证用户存在
+ user = database.get_user_by_username(username)
+ if not user:
+ return jsonify({"error": "用户不存在"}), 404
+
+ # 如果提供了邮箱,验证邮箱是否匹配
+ if email and user.get('email') != email:
+ return jsonify({"error": "邮箱不匹配"}), 400
+
+ # 创建重置申请
+ request_id = database.create_password_reset_request(user['id'], new_password)
+ if request_id:
+ return jsonify({"message": "密码重置申请已提交,请等待管理员审核"})
+ else:
+ return jsonify({"error": "申请提交失败"}), 500
+
+
+# ==================== 账号管理API (用户隔离) ====================
+
+def load_user_accounts(user_id):
+ """从数据库加载用户的账号到内存"""
+ if user_id not in user_accounts:
+ user_accounts[user_id] = {}
+
+ accounts_data = database.get_user_accounts(user_id)
+ for acc_data in accounts_data:
+ account = Account(
+ account_id=acc_data['id'],
+ user_id=user_id,
+ username=acc_data['username'],
+ password=acc_data['password'],
+ remember=bool(acc_data['remember']),
+ remark=acc_data['remark'] or ''
+ )
+ user_accounts[user_id][account.id] = account
+
+
+@app.route('/api/accounts', methods=['GET'])
+@login_required
+def get_accounts():
+ """获取当前用户的所有账号"""
+ user_id = current_user.id
+ if user_id not in user_accounts:
+ load_user_accounts(user_id)
+
+ accounts = user_accounts.get(user_id, {})
+ return jsonify([acc.to_dict() for acc in accounts.values()])
+
+
+@app.route('/api/accounts', methods=['POST'])
+@login_required
+def add_account():
+ """添加账号"""
+ user_id = current_user.id
+
+ # VIP账号数量限制检查
+ if not database.is_user_vip(user_id):
+ current_count = len(database.get_user_accounts(user_id))
+ if current_count >= 1:
+ return jsonify({"error": "非VIP用户只能添加1个账号,请联系管理员开通VIP"}), 403
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ remember = data.get('remember', True)
+
+ if not username or not password:
+ return jsonify({"error": "用户名和密码不能为空"}), 400
+
+ # 检查当前用户是否已存在该账号
+ if user_id in user_accounts:
+ for acc in user_accounts[user_id].values():
+ if acc.username == username:
+ return jsonify({"error": f"账号 '{username}' 已存在"}), 400
+
+ # 生成账号ID
+ import uuid
+ account_id = str(uuid.uuid4())[:8]
+
+ # 保存到数据库
+ database.create_account(user_id, account_id, username, password, remember, '')
+
+ # 加载到内存
+ account = Account(account_id, user_id, username, password, remember, '')
+ if user_id not in user_accounts:
+ user_accounts[user_id] = {}
+ user_accounts[user_id][account_id] = account
+
+ log_to_client(f"添加账号: {username}", user_id)
+ return jsonify(account.to_dict())
+
+
+@app.route('/api/accounts/', methods=['DELETE'])
+@login_required
+def delete_account(account_id):
+ """删除账号"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ # 停止正在运行的任务
+ if account.is_running:
+ account.should_stop = True
+ if account.automation:
+ account.automation.close()
+
+ username = account.username
+
+ # 从数据库删除
+ database.delete_account(account_id)
+
+ # 从内存删除
+ del user_accounts[user_id][account_id]
+
+ log_to_client(f"删除账号: {username}", user_id)
+ return jsonify({"success": True})
+
+
+@app.route('/api/accounts//remark', methods=['PUT'])
+@login_required
+def update_remark(account_id):
+ """更新备注"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ data = request.json
+ remark = data.get('remark', '').strip()[:200]
+
+ # 更新数据库
+ database.update_account_remark(account_id, remark)
+
+ # 更新内存
+ user_accounts[user_id][account_id].remark = remark
+ log_to_client(f"更新备注: {user_accounts[user_id][account_id].username} -> {remark}", user_id)
+
+ return jsonify({"success": True})
+
+
+@app.route('/api/accounts//start', methods=['POST'])
+@login_required
+def start_account(account_id):
+ """启动账号任务"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ if account.is_running:
+ return jsonify({"error": "任务已在运行中"}), 400
+
+ data = request.json
+ browse_type = data.get('browse_type', '应读')
+ enable_screenshot = data.get('enable_screenshot', True) # 默认启用截图
+
+ # 确保浏览器管理器已初始化
+ if not init_browser_manager():
+ return jsonify({"error": "浏览器初始化失败"}), 500
+
+ # 启动任务线程
+ account.is_running = True
+ account.should_stop = False
+ account.status = "运行中"
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+
+ log_to_client(f"启动任务: {account.username} - {browse_type}", user_id)
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ return jsonify({"success": True})
+
+
+@app.route('/api/accounts//stop', methods=['POST'])
+@login_required
+def stop_account(account_id):
+ """停止账号任务"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ if not account.is_running:
+ return jsonify({"error": "任务未在运行"}), 400
+
+ account.should_stop = True
+ account.status = "正在停止"
+
+ log_to_client(f"停止任务: {account.username}", user_id)
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ return jsonify({"success": True})
+
+
+def get_user_semaphore(user_id):
+ """获取或创建用户的信号量"""
+ if user_id not in user_semaphores:
+ user_semaphores[user_id] = threading.Semaphore(max_concurrent_per_account)
+ return user_semaphores[user_id]
+
+
+def run_task(user_id, account_id, browse_type, enable_screenshot=True):
+ """运行自动化任务"""
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return
+
+ account = user_accounts[user_id][account_id]
+
+ # 两级并发控制:用户级 + 全局级
+ user_sem = get_user_semaphore(user_id)
+
+ # 获取用户级信号量(同一用户的账号排队)
+ log_to_client(f"等待资源分配...", user_id, account_id)
+ account.status = "排队中"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ user_sem.acquire()
+
+ try:
+ # 如果在排队期间被停止,直接返回
+ if account.should_stop:
+ log_to_client(f"任务已取消", user_id, account_id)
+ account.status = "已停止"
+ account.is_running = False
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ return
+
+ # 获取全局信号量(防止所有用户同时运行导致资源耗尽)
+ global_semaphore.acquire()
+
+ try:
+ # 再次检查是否被停止
+ if account.should_stop:
+ log_to_client(f"任务已取消", user_id, account_id)
+ account.status = "已停止"
+ account.is_running = False
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ return
+
+ account.status = "运行中"
+ # 记录任务真正开始执行的时间(不包括排队时间)
+ import time as time_module
+ task_start_time = time_module.time()
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ account.last_browse_type = browse_type
+
+ # 重试机制:最多尝试3次,超时则换IP重试
+ max_attempts = 3
+ last_error = None
+
+ for attempt in range(1, max_attempts + 1):
+ try:
+ if attempt > 1:
+ log_to_client(f"🔄 第 {attempt} 次尝试(共{max_attempts}次)...", user_id, account_id)
+
+ # 检查是否需要使用代理
+ proxy_config = None
+ config = database.get_system_config()
+ if config.get('proxy_enabled') == 1:
+ proxy_api_url = config.get('proxy_api_url', '').strip()
+ if proxy_api_url:
+ log_to_client(f"正在获取代理IP...", user_id, account_id)
+ proxy_server = get_proxy_from_api(proxy_api_url, max_retries=3)
+ if proxy_server:
+ proxy_config = {'server': proxy_server}
+ log_to_client(f"✓ 将使用代理: {proxy_server}", user_id, account_id)
+ account.proxy_config = proxy_config # 保存代理配置供截图使用
+ else:
+ log_to_client(f"✗ 代理获取失败,将不使用代理继续", user_id, account_id)
+ else:
+ log_to_client(f"⚠ 代理已启用但未配置API地址", user_id, account_id)
+
+ log_to_client(f"创建自动化实例...", user_id, account_id)
+ account.automation = PlaywrightAutomation(browser_manager, account_id, proxy_config=proxy_config)
+
+ # 为automation注入包含user_id的自定义log方法,使其能够实时发送日志到WebSocket
+ def custom_log(message: str):
+ log_to_client(message, user_id, account_id)
+ account.automation.log = custom_log
+
+ log_to_client(f"开始登录...", user_id, account_id)
+ if not account.automation.login(account.username, account.password, account.remember):
+ log_to_client(f"❌ 登录失败,请检查用户名和密码", user_id, account_id)
+ account.status = "登录失败"
+ account.is_running = False
+ # 记录登录失败日志
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=0,
+ total_attachments=0,
+ error_message='登录失败,请检查用户名和密码',
+ duration=int(time_module.time() - task_start_time)
+ )
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ return
+
+ log_to_client(f"✓ 登录成功!", user_id, account_id)
+ log_to_client(f"开始浏览 '{browse_type}' 内容...", user_id, account_id)
+
+ def should_stop():
+ return account.should_stop
+
+ result = account.automation.browse_content(
+ browse_type=browse_type,
+ auto_next_page=True,
+ auto_view_attachments=True,
+ interval=2.0,
+ should_stop_callback=should_stop
+ )
+
+ account.total_items = result.total_items
+ account.total_attachments = result.total_attachments
+
+ if result.success:
+ log_to_client(f"浏览完成! 共 {result.total_items} 条内容,{result.total_attachments} 个附件", user_id, account_id)
+ account.status = "已完成"
+ # 记录成功日志
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='success',
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message='',
+ duration=int(time_module.time() - task_start_time)
+ )
+ # 成功则跳出重试循环
+ break
+ else:
+ # 浏览出错,检查是否是超时错误
+ error_msg = result.error_message
+ if 'Timeout' in error_msg or 'timeout' in error_msg:
+ last_error = error_msg
+ log_to_client(f"⚠ 检测到超时错误: {error_msg}", user_id, account_id)
+
+ # 关闭当前浏览器
+ if account.automation:
+ try:
+ account.automation.close()
+ log_to_client(f"已关闭超时的浏览器实例", user_id, account_id)
+ except:
+ pass
+ account.automation = None
+
+ if attempt < max_attempts:
+ log_to_client(f"⚠ 代理可能速度过慢,将换新IP重试 ({attempt}/{max_attempts})", user_id, account_id)
+ time_module.sleep(2) # 等待2秒再重试
+ continue
+ else:
+ log_to_client(f"❌ 已达到最大重试次数({max_attempts}),任务失败", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message=f"重试{max_attempts}次后仍失败: {error_msg}",
+ duration=int(time_module.time() - task_start_time)
+ )
+ break
+ else:
+ # 非超时错误,直接失败不重试
+ log_to_client(f"浏览出错: {error_msg}", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message=error_msg,
+ duration=int(time_module.time() - task_start_time)
+ )
+ break
+
+ except Exception as retry_error:
+ # 捕获重试过程中的异常
+ error_msg = str(retry_error)
+ last_error = error_msg
+
+ # 关闭可能存在的浏览器实例
+ if account.automation:
+ try:
+ account.automation.close()
+ except:
+ pass
+ account.automation = None
+
+ if 'Timeout' in error_msg or 'timeout' in error_msg:
+ log_to_client(f"⚠ 执行超时: {error_msg}", user_id, account_id)
+ if attempt < max_attempts:
+ log_to_client(f"⚠ 将换新IP重试 ({attempt}/{max_attempts})", user_id, account_id)
+ time_module.sleep(2)
+ continue
+ else:
+ log_to_client(f"❌ 已达到最大重试次数({max_attempts}),任务失败", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=account.total_items,
+ total_attachments=account.total_attachments,
+ error_message=f"重试{max_attempts}次后仍失败: {error_msg}",
+ duration=int(time_module.time() - task_start_time)
+ )
+ break
+ else:
+ # 非超时异常,直接失败
+ log_to_client(f"任务执行异常: {error_msg}", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=account.total_items,
+ total_attachments=account.total_attachments,
+ error_message=error_msg,
+ duration=int(time_module.time() - task_start_time)
+ )
+ break
+
+
+ except Exception as e:
+ error_msg = str(e)
+ log_to_client(f"任务执行出错: {error_msg}", user_id, account_id)
+ account.status = "出错"
+ # 记录异常失败日志
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=account.total_items,
+ total_attachments=account.total_attachments,
+ error_message=error_msg,
+ duration=int(time_module.time() - task_start_time)
+ )
+
+ finally:
+ # 释放全局信号量
+ global_semaphore.release()
+
+ account.is_running = False
+
+ if account.automation:
+ try:
+ account.automation.close()
+ log_to_client(f"主任务浏览器已关闭", user_id, account_id)
+ except Exception as e:
+ log_to_client(f"关闭主任务浏览器时出错: {str(e)}", user_id, account_id)
+ finally:
+ account.automation = None
+
+ if account_id in active_tasks:
+ del active_tasks[account_id]
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ # 任务完成后自动截图(增加2秒延迟,确保资源完全释放)
+ # 根据enable_screenshot参数决定是否截图
+ if account.status == "已完成" and not account.should_stop:
+ if enable_screenshot:
+ log_to_client(f"等待2秒后开始截图...", user_id, account_id)
+ time.sleep(2) # 延迟启动截图,确保主任务资源已完全释放
+ threading.Thread(target=take_screenshot_for_account, args=(user_id, account_id), daemon=True).start()
+ else:
+ log_to_client(f"截图功能已禁用,跳过截图", user_id, account_id)
+
+ finally:
+ # 释放用户级信号量
+ user_sem.release()
+
+
+def take_screenshot_for_account(user_id, account_id):
+ """为账号任务完成后截图(带并发控制,避免资源竞争)"""
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return
+
+ account = user_accounts[user_id][account_id]
+
+ # 使用截图信号量,确保同时只有1个截图任务在执行
+ log_to_client(f"等待截图资源分配...", user_id, account_id)
+ screenshot_acquired = screenshot_semaphore.acquire(blocking=True, timeout=300) # 最多等待5分钟
+
+ if not screenshot_acquired:
+ log_to_client(f"截图资源获取超时,跳过截图", user_id, account_id)
+ return
+
+ automation = None
+ try:
+ log_to_client(f"开始截图流程...", user_id, account_id)
+
+ # 使用与浏览任务相同的代理配置
+ proxy_config = account.proxy_config if hasattr(account, 'proxy_config') else None
+ if proxy_config:
+ log_to_client(f"截图将使用相同代理: {proxy_config.get('server', 'Unknown')}", user_id, account_id)
+
+ automation = PlaywrightAutomation(browser_manager, account_id, proxy_config=proxy_config)
+
+ # 为截图automation也注入自定义log方法
+ def custom_log(message: str):
+ log_to_client(message, user_id, account_id)
+ automation.log = custom_log
+
+ log_to_client(f"重新登录以进行截图...", user_id, account_id)
+ if not automation.login(account.username, account.password, account.remember):
+ log_to_client(f"截图登录失败", user_id, account_id)
+ return
+
+ browse_type = account.last_browse_type
+ log_to_client(f"导航到 '{browse_type}' 页面...", user_id, account_id)
+
+ # 不使用should_stop_callback,让页面加载完成显示"暂无记录"
+ result = automation.browse_content(
+ browse_type=browse_type,
+ auto_next_page=False,
+ auto_view_attachments=False,
+ interval=0,
+ should_stop_callback=None
+ )
+
+ if not result.success and result.error_message != "":
+ log_to_client(f"导航失败: {result.error_message}", user_id, account_id)
+
+ time.sleep(2)
+
+ # 生成截图文件名(使用北京时间并简化格式)
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ now_beijing = datetime.now(beijing_tz)
+ timestamp = now_beijing.strftime('%Y%m%d_%H%M%S')
+
+ # 简化文件名:用户名_登录账号_浏览类型_时间.jpg
+ # 获取用户名前缀
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+ # 使用登录账号(account.username)而不是备注
+ login_account = account.username
+ screenshot_filename = f"{username_prefix}_{login_account}_{browse_type}_{timestamp}.jpg"
+ screenshot_path = os.path.join(SCREENSHOTS_DIR, screenshot_filename)
+
+ if automation.take_screenshot(screenshot_path):
+ log_to_client(f"✓ 截图已保存: {screenshot_filename}", user_id, account_id)
+ else:
+ log_to_client(f"✗ 截图失败", user_id, account_id)
+
+ except Exception as e:
+ log_to_client(f"✗ 截图过程中出错: {str(e)}", user_id, account_id)
+
+ finally:
+ # 确保浏览器资源被正确关闭
+ if automation:
+ try:
+ automation.close()
+ log_to_client(f"截图浏览器已关闭", user_id, account_id)
+ except Exception as e:
+ log_to_client(f"关闭截图浏览器时出错: {str(e)}", user_id, account_id)
+
+ # 释放截图信号量
+ screenshot_semaphore.release()
+ log_to_client(f"截图资源已释放", user_id, account_id)
+
+
+@app.route('/api/accounts//screenshot', methods=['POST'])
+@login_required
+def manual_screenshot(account_id):
+ """手动为指定账号截图"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+ if account.is_running:
+ return jsonify({"error": "任务运行中,无法截图"}), 400
+
+ data = request.json or {}
+ browse_type = data.get('browse_type', account.last_browse_type)
+
+ account.last_browse_type = browse_type
+
+ threading.Thread(target=take_screenshot_for_account, args=(user_id, account_id), daemon=True).start()
+ log_to_client(f"手动截图: {account.username} - {browse_type}", user_id)
+ return jsonify({"success": True})
+
+
+# ==================== 截图管理API ====================
+
+@app.route('/api/screenshots', methods=['GET'])
+@login_required
+def get_screenshots():
+ """获取当前用户的截图列表"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ try:
+ screenshots = []
+ if os.path.exists(SCREENSHOTS_DIR):
+ for filename in os.listdir(SCREENSHOTS_DIR):
+ # 只显示属于当前用户的截图(支持png和jpg格式)
+ if (filename.lower().endswith(('.png', '.jpg', '.jpeg'))) and filename.startswith(username_prefix + '_'):
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ stat = os.stat(filepath)
+ # 转换为北京时间
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ created_time = datetime.fromtimestamp(stat.st_mtime, tz=beijing_tz)
+ # 解析文件名获取显示名称
+ # 文件名格式:用户名_登录账号_浏览类型_时间.jpg
+ parts = filename.rsplit('.', 1)[0].split('_', 1) # 移除扩展名并分割
+ if len(parts) > 1:
+ # 显示名称:登录账号_浏览类型_时间.jpg
+ display_name = parts[1] + '.' + filename.rsplit('.', 1)[1]
+ else:
+ display_name = filename
+
+ screenshots.append({
+ 'filename': filename,
+ 'display_name': display_name,
+ 'size': stat.st_size,
+ 'created': created_time.strftime('%Y-%m-%d %H:%M:%S')
+ })
+ screenshots.sort(key=lambda x: x['created'], reverse=True)
+ return jsonify(screenshots)
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+@app.route('/screenshots/')
+@login_required
+def serve_screenshot(filename):
+ """提供截图文件访问"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ # 验证文件属于当前用户
+ if not filename.startswith(username_prefix + '_'):
+ return jsonify({"error": "无权访问"}), 403
+
+ return send_from_directory(SCREENSHOTS_DIR, filename)
+
+
+@app.route('/api/screenshots/', methods=['DELETE'])
+@login_required
+def delete_screenshot(filename):
+ """删除指定截图"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ # 验证文件属于当前用户
+ if not filename.startswith(username_prefix + '_'):
+ return jsonify({"error": "无权删除"}), 403
+
+ try:
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ if os.path.exists(filepath):
+ os.remove(filepath)
+ log_to_client(f"删除截图: {filename}", user_id)
+ return jsonify({"success": True})
+ else:
+ return jsonify({"error": "文件不存在"}), 404
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+@app.route('/api/screenshots/clear', methods=['POST'])
+@login_required
+def clear_all_screenshots():
+ """清空当前用户的所有截图"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ try:
+ deleted_count = 0
+ if os.path.exists(SCREENSHOTS_DIR):
+ for filename in os.listdir(SCREENSHOTS_DIR):
+ if (filename.lower().endswith(('.png', '.jpg', '.jpeg'))) and filename.startswith(username_prefix + '_'):
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ os.remove(filepath)
+ deleted_count += 1
+ log_to_client(f"清理了 {deleted_count} 个截图文件", user_id)
+ return jsonify({"success": True, "deleted": deleted_count})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+# ==================== WebSocket事件 ====================
+
+@socketio.on('connect')
+def handle_connect():
+ """客户端连接"""
+ if current_user.is_authenticated:
+ user_id = current_user.id
+ join_room(f'user_{user_id}')
+ log_to_client("客户端已连接", user_id)
+
+ # 发送账号列表
+ accounts = user_accounts.get(user_id, {})
+ emit('accounts_list', [acc.to_dict() for acc in accounts.values()])
+
+ # 发送历史日志
+ if user_id in log_cache:
+ for log_entry in log_cache[user_id]:
+ emit('log', log_entry)
+
+
+@socketio.on('disconnect')
+def handle_disconnect():
+ """客户端断开"""
+ if current_user.is_authenticated:
+ user_id = current_user.id
+ leave_room(f'user_{user_id}')
+
+
+# ==================== 静态文件 ====================
+
+@app.route('/static/')
+def serve_static(filename):
+ """提供静态文件访问"""
+ return send_from_directory('static', filename)
+
+
+# ==================== 启动 ====================
+
+
+# ==================== 管理员VIP管理API ====================
+
+@app.route('/yuyx/api/vip/config', methods=['GET'])
+def get_vip_config_api():
+ """获取VIP配置"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+ config = database.get_vip_config()
+ return jsonify(config)
+
+
+@app.route('/yuyx/api/vip/config', methods=['POST'])
+def set_vip_config_api():
+ """设置默认VIP天数"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ data = request.json
+ days = data.get('default_vip_days', 0)
+
+ if not isinstance(days, int) or days < 0:
+ return jsonify({"error": "VIP天数必须是非负整数"}), 400
+
+ database.set_default_vip_days(days)
+ return jsonify({"message": "VIP配置已更新", "default_vip_days": days})
+
+
+@app.route('/yuyx/api/users//vip', methods=['POST'])
+def set_user_vip_api(user_id):
+ """设置用户VIP"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ data = request.json
+ days = data.get('days', 30)
+
+ # 验证days参数
+ valid_days = [7, 30, 365, 999999]
+ if days not in valid_days:
+ return jsonify({"error": "VIP天数必须是 7/30/365/999999 之一"}), 400
+
+ if database.set_user_vip(user_id, days):
+ vip_type = {7: "一周", 30: "一个月", 365: "一年", 999999: "永久"}[days]
+ return jsonify({"message": f"VIP设置成功: {vip_type}"})
+ return jsonify({"error": "设置失败,用户不存在"}), 400
+
+
+@app.route('/yuyx/api/users//vip', methods=['DELETE'])
+def remove_user_vip_api(user_id):
+ """移除用户VIP"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ if database.remove_user_vip(user_id):
+ return jsonify({"message": "VIP已移除"})
+ return jsonify({"error": "移除失败"}), 400
+
+
+@app.route('/yuyx/api/users//vip', methods=['GET'])
+def get_user_vip_info_api(user_id):
+ """获取用户VIP信息(管理员)"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ vip_info = database.get_user_vip_info(user_id)
+ return jsonify(vip_info)
+
+
+
+# ==================== 用户端VIP查询API ====================
+
+@app.route('/api/user/vip', methods=['GET'])
+@login_required
+def get_current_user_vip():
+ """获取当前用户VIP信息"""
+ vip_info = database.get_user_vip_info(current_user.id)
+ return jsonify(vip_info)
+
+
+@app.route('/api/run_stats', methods=['GET'])
+@login_required
+def get_run_stats():
+ """获取当前用户的运行统计"""
+ user_id = current_user.id
+
+ # 获取今日任务统计
+ stats = database.get_user_run_stats(user_id)
+
+ # 计算当前正在运行的账号数
+ current_running = 0
+ if user_id in user_accounts:
+ current_running = sum(1 for acc in user_accounts[user_id].values() if acc.is_running)
+
+ return jsonify({
+ 'today_completed': stats.get('completed', 0),
+ 'current_running': current_running,
+ 'today_failed': stats.get('failed', 0),
+ 'today_items': stats.get('total_items', 0),
+ 'today_attachments': stats.get('total_attachments', 0)
+ })
+
+
+# ==================== 系统配置API ====================
+
+@app.route('/yuyx/api/system/config', methods=['GET'])
+@admin_required
+def get_system_config_api():
+ """获取系统配置"""
+ config = database.get_system_config()
+ return jsonify(config)
+
+
+@app.route('/yuyx/api/system/config', methods=['POST'])
+@admin_required
+def update_system_config_api():
+ """更新系统配置"""
+ global max_concurrent_global, global_semaphore, max_concurrent_per_account
+
+ data = request.json
+ max_concurrent = data.get('max_concurrent_global')
+ schedule_enabled = data.get('schedule_enabled')
+ schedule_time = data.get('schedule_time')
+ schedule_browse_type = data.get('schedule_browse_type')
+ schedule_weekdays = data.get('schedule_weekdays')
+ new_max_concurrent_per_account = data.get('max_concurrent_per_account')
+
+ # 验证参数
+ if max_concurrent is not None:
+ if not isinstance(max_concurrent, int) or max_concurrent < 1 or max_concurrent > 20:
+ return jsonify({"error": "全局并发数必须在1-20之间"}), 400
+
+ if new_max_concurrent_per_account is not None:
+ if not isinstance(new_max_concurrent_per_account, int) or new_max_concurrent_per_account < 1 or new_max_concurrent_per_account > 5:
+ return jsonify({"error": "单账号并发数必须在1-5之间"}), 400
+
+ if schedule_time is not None:
+ # 验证时间格式 HH:MM
+ import re
+ if not re.match(r'^([01]\d|2[0-3]):([0-5]\d)$', schedule_time):
+ return jsonify({"error": "时间格式错误,应为 HH:MM"}), 400
+
+ if schedule_browse_type is not None:
+ if schedule_browse_type not in ['注册前未读', '应读', '未读']:
+ return jsonify({"error": "浏览类型无效"}), 400
+
+ if schedule_weekdays is not None:
+ # 验证星期格式,应该是逗号分隔的数字字符串 "1,2,3,4,5,6,7"
+ try:
+ days = [int(d.strip()) for d in schedule_weekdays.split(',') if d.strip()]
+ if not all(1 <= d <= 7 for d in days):
+ return jsonify({"error": "星期数字必须在1-7之间"}), 400
+ except (ValueError, AttributeError):
+ return jsonify({"error": "星期格式错误"}), 400
+
+ # 更新数据库
+ if database.update_system_config(
+ max_concurrent=max_concurrent,
+ schedule_enabled=schedule_enabled,
+ schedule_time=schedule_time,
+ schedule_browse_type=schedule_browse_type,
+ schedule_weekdays=schedule_weekdays,
+ max_concurrent_per_account=new_max_concurrent_per_account
+ ):
+ # 如果修改了并发数,更新全局变量和信号量
+ if max_concurrent is not None and max_concurrent != max_concurrent_global:
+ max_concurrent_global = max_concurrent
+ global_semaphore = threading.Semaphore(max_concurrent)
+ print(f"全局并发数已更新为: {max_concurrent}")
+
+ # 如果修改了单用户并发数,更新全局变量(已有的信号量会在下次创建时使用新值)
+ if new_max_concurrent_per_account is not None and new_max_concurrent_per_account != max_concurrent_per_account:
+ max_concurrent_per_account = new_max_concurrent_per_account
+ print(f"单用户并发数已更新为: {max_concurrent_per_account}")
+
+ return jsonify({"message": "系统配置已更新"})
+
+ return jsonify({"error": "更新失败"}), 400
+
+
+
+
+# ==================== 代理配置API ====================
+
+@app.route('/yuyx/api/proxy/config', methods=['GET'])
+@admin_required
+def get_proxy_config_api():
+ """获取代理配置"""
+ config = database.get_system_config()
+ return jsonify({
+ 'proxy_enabled': config.get('proxy_enabled', 0),
+ 'proxy_api_url': config.get('proxy_api_url', ''),
+ 'proxy_expire_minutes': config.get('proxy_expire_minutes', 3)
+ })
+
+
+@app.route('/yuyx/api/proxy/config', methods=['POST'])
+@admin_required
+def update_proxy_config_api():
+ """更新代理配置"""
+ data = request.json
+ proxy_enabled = data.get('proxy_enabled')
+ proxy_api_url = data.get('proxy_api_url', '').strip()
+ proxy_expire_minutes = data.get('proxy_expire_minutes')
+
+ if proxy_enabled is not None and proxy_enabled not in [0, 1]:
+ return jsonify({"error": "proxy_enabled必须是0或1"}), 400
+
+ if proxy_expire_minutes is not None:
+ if not isinstance(proxy_expire_minutes, int) or proxy_expire_minutes < 1:
+ return jsonify({"error": "代理有效期必须是大于0的整数"}), 400
+
+ if database.update_system_config(
+ proxy_enabled=proxy_enabled,
+ proxy_api_url=proxy_api_url,
+ proxy_expire_minutes=proxy_expire_minutes
+ ):
+ return jsonify({"message": "代理配置已更新"})
+
+ return jsonify({"error": "更新失败"}), 400
+
+
+@app.route('/yuyx/api/proxy/test', methods=['POST'])
+@admin_required
+def test_proxy_api():
+ """测试代理连接"""
+ data = request.json
+ api_url = data.get('api_url', '').strip()
+
+ if not api_url:
+ return jsonify({"error": "请提供API地址"}), 400
+
+ try:
+ response = requests.get(api_url, timeout=10)
+ if response.status_code == 200:
+ ip_port = response.text.strip()
+ if ip_port and ':' in ip_port:
+ return jsonify({
+ "success": True,
+ "proxy": ip_port,
+ "message": f"代理获取成功: {ip_port}"
+ })
+ else:
+ return jsonify({
+ "success": False,
+ "message": f"代理格式错误: {ip_port}"
+ }), 400
+ else:
+ return jsonify({
+ "success": False,
+ "message": f"HTTP错误: {response.status_code}"
+ }), 400
+ except Exception as e:
+ return jsonify({
+ "success": False,
+ "message": f"连接失败: {str(e)}"
+ }), 500
+
+# ==================== 服务器信息API ====================
+
+@app.route('/yuyx/api/server/info', methods=['GET'])
+@admin_required
+def get_server_info_api():
+ """获取服务器信息"""
+ import psutil
+ import datetime
+
+ # CPU使用率
+ cpu_percent = psutil.cpu_percent(interval=1)
+
+ # 内存信息
+ memory = psutil.virtual_memory()
+ memory_total = f"{memory.total / (1024**3):.1f}GB"
+ memory_used = f"{memory.used / (1024**3):.1f}GB"
+ memory_percent = memory.percent
+
+ # 磁盘信息
+ disk = psutil.disk_usage('/')
+ disk_total = f"{disk.total / (1024**3):.1f}GB"
+ disk_used = f"{disk.used / (1024**3):.1f}GB"
+ disk_percent = disk.percent
+
+ # 运行时长
+ boot_time = datetime.datetime.fromtimestamp(psutil.boot_time())
+ uptime_delta = datetime.datetime.now() - boot_time
+ days = uptime_delta.days
+ hours = uptime_delta.seconds // 3600
+ uptime = f"{days}天{hours}小时"
+
+ return jsonify({
+ 'cpu_percent': cpu_percent,
+ 'memory_total': memory_total,
+ 'memory_used': memory_used,
+ 'memory_percent': memory_percent,
+ 'disk_total': disk_total,
+ 'disk_used': disk_used,
+ 'disk_percent': disk_percent,
+ 'uptime': uptime
+ })
+
+
+# ==================== 任务统计和日志API ====================
+
+@app.route('/yuyx/api/task/stats', methods=['GET'])
+@admin_required
+def get_task_stats_api():
+ """获取任务统计数据"""
+ date_filter = request.args.get('date') # YYYY-MM-DD格式
+ stats = database.get_task_stats(date_filter)
+ return jsonify(stats)
+
+
+@app.route('/yuyx/api/task/logs', methods=['GET'])
+@admin_required
+def get_task_logs_api():
+ """获取任务日志列表"""
+ limit = int(request.args.get('limit', 100))
+ offset = int(request.args.get('offset', 0))
+ date_filter = request.args.get('date') # YYYY-MM-DD格式
+ status_filter = request.args.get('status') # success/failed
+
+ logs = database.get_task_logs(limit, offset, date_filter, status_filter)
+ return jsonify(logs)
+
+
+@app.route('/yuyx/api/task/logs/clear', methods=['POST'])
+@admin_required
+def clear_old_task_logs_api():
+ """清理旧的任务日志"""
+ data = request.json or {}
+ days = data.get('days', 30)
+
+ if not isinstance(days, int) or days < 1:
+ return jsonify({"error": "天数必须是大于0的整数"}), 400
+
+ deleted_count = database.delete_old_task_logs(days)
+ return jsonify({"message": f"已删除{days}天前的{deleted_count}条日志"})
+
+
+# ==================== 定时任务调度器 ====================
+
+def scheduled_task_worker():
+ """定时任务工作线程"""
+ import schedule
+ from datetime import datetime
+
+ def cleanup_memory_task():
+ """定时内存清理任务"""
+ try:
+ import gc
+ import psutil
+ import os
+
+ process = psutil.Process(os.getpid())
+ mem_before = process.memory_info().rss / 1024 / 1024
+
+ collected = gc.collect()
+
+ try:
+ db = database.get_db()
+ db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
+ db.commit()
+ except Exception as e:
+ print(f"[内存清理] WAL清理失败: {e}")
+
+ mem_after = process.memory_info().rss / 1024 / 1024
+ freed = mem_before - mem_after
+
+ print(f"[内存清理] 完成 - 回收对象:{collected}, 内存: {mem_before:.1f}MB -> {mem_after:.1f}MB (释放{freed:.1f}MB)")
+
+ except Exception as e:
+ print(f"[内存清理] 执行失败: {str(e)}")
+
+ def run_all_accounts_task():
+ """执行所有账号的浏览任务(过滤重复账号)"""
+ try:
+ config = database.get_system_config()
+ browse_type = config.get('schedule_browse_type', '应读')
+
+ # 检查今天是否在允许执行的星期列表中
+ from datetime import datetime
+ import pytz
+
+ # 获取北京时间的星期几 (1=周一, 7=周日)
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ now_beijing = datetime.now(beijing_tz)
+ current_weekday = now_beijing.isoweekday() # 1-7
+
+ # 获取配置的星期列表
+ schedule_weekdays = config.get('schedule_weekdays', '1,2,3,4,5,6,7')
+ allowed_weekdays = [int(d.strip()) for d in schedule_weekdays.split(',') if d.strip()]
+
+ if current_weekday not in allowed_weekdays:
+ weekday_names = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
+ print(f"[定时任务] 今天是{weekday_names[current_weekday]},不在执行日期内,跳过执行")
+ return
+
+ print(f"[定时任务] 开始执行 - 浏览类型: {browse_type}")
+
+ # 获取所有已审核用户的所有账号
+ all_users = database.get_all_users()
+ approved_users = [u for u in all_users if u['status'] == 'approved']
+
+ # 用于记录已执行的账号用户名,避免重复
+ executed_usernames = set()
+ total_accounts = 0
+ skipped_duplicates = 0
+ executed_accounts = 0
+
+ for user in approved_users:
+ user_id = user['id']
+ if user_id not in user_accounts:
+ load_user_accounts(user_id)
+
+ accounts = user_accounts.get(user_id, {})
+ for account_id, account in accounts.items():
+ total_accounts += 1
+
+ # 跳过正在运行的账号
+ if account.is_running:
+ continue
+
+ # 检查账号用户名是否已经执行过(重复账号过滤)
+ if account.username in executed_usernames:
+ skipped_duplicates += 1
+ print(f"[定时任务] 跳过重复账号: {account.username} (用户:{user['username']}) - 该账号已被其他用户执行")
+ continue
+
+ # 记录该账号用户名,避免后续重复执行
+ executed_usernames.add(account.username)
+
+ print(f"[定时任务] 启动账号: {account.username} (用户:{user['username']})")
+
+ # 启动任务
+ account.is_running = True
+ account.should_stop = False
+ account.status = "运行中"
+
+ # 获取系统配置的截图开关
+ config = database.get_system_config()
+ enable_screenshot_scheduled = config.get("enable_screenshot", 0) == 1
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot_scheduled),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+ executed_accounts += 1
+
+ # 发送更新到用户
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ # 间隔启动,避免瞬间并发过高
+ time.sleep(2)
+
+ print(f"[定时任务] 执行完成 - 总账号数:{total_accounts}, 已执行:{executed_accounts}, 跳过重复:{skipped_duplicates}")
+
+ except Exception as e:
+ print(f"[定时任务] 执行出错: {str(e)}")
+
+ def cleanup_expired_captcha():
+ """清理过期验证码,防止内存泄漏"""
+ try:
+ current_time = time.time()
+ expired_keys = [k for k, v in captcha_storage.items()
+ if v["expire_time"] < current_time]
+ deleted_count = len(expired_keys)
+ for k in expired_keys:
+ del captcha_storage[k]
+ if deleted_count > 0:
+ print(f"[定时清理] 已清理 {deleted_count} 个过期验证码")
+ except Exception as e:
+ print(f"[定时清理] 清理验证码出错: {str(e)}")
+
+ def cleanup_old_data():
+ """清理7天前的截图和日志"""
+ try:
+ print(f"[定时清理] 开始清理7天前的数据...")
+
+ # 清理7天前的任务日志
+ deleted_logs = database.delete_old_task_logs(7)
+ print(f"[定时清理] 已删除 {deleted_logs} 条任务日志")
+
+ # 清理30天前的操作日志
+ deleted_operation_logs = database.clean_old_operation_logs(30)
+ print(f"[定时清理] 已删除 {deleted_operation_logs} 条操作日志")
+ # 清理7天前的截图
+ deleted_screenshots = 0
+ if os.path.exists(SCREENSHOTS_DIR):
+ cutoff_time = time.time() - (7 * 24 * 60 * 60) # 7天前的时间戳
+ for filename in os.listdir(SCREENSHOTS_DIR):
+ if filename.lower().endswith(('.png', '.jpg', '.jpeg')):
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ try:
+ # 检查文件修改时间
+ if os.path.getmtime(filepath) < cutoff_time:
+ os.remove(filepath)
+ deleted_screenshots += 1
+ except Exception as e:
+ print(f"[定时清理] 删除截图失败 {filename}: {str(e)}")
+
+ print(f"[定时清理] 已删除 {deleted_screenshots} 个截图文件")
+ print(f"[定时清理] 清理完成!")
+
+ except Exception as e:
+ print(f"[定时清理] 清理任务出错: {str(e)}")
+
+ # 每分钟检查一次配置
+ def check_and_schedule():
+ config = database.get_system_config()
+
+ # 清除旧的任务
+ schedule.clear()
+
+ # 时区转换函数:将CST时间转换为UTC时间(容器使用UTC)
+ def cst_to_utc_time(cst_time_str):
+ """将CST时间字符串(HH:MM)转换为UTC时间字符串
+
+ Args:
+ cst_time_str: CST时间字符串,格式为 HH:MM
+
+ Returns:
+ UTC时间字符串,格式为 HH:MM
+ """
+ from datetime import datetime, timedelta
+ # 解析CST时间
+ hour, minute = map(int, cst_time_str.split(':'))
+ # CST是UTC+8,所以UTC时间 = CST时间 - 8小时
+ utc_hour = (hour - 8) % 24
+ return f"{utc_hour:02d}:{minute:02d}"
+
+ # 始终添加每天凌晨3点(CST)的数据清理任务
+ cleanup_utc_time = cst_to_utc_time("03:00")
+ schedule.every().day.at(cleanup_utc_time).do(cleanup_old_data)
+ print(f"[定时任务] 已设置数据清理任务: 每天 CST 03:00 (UTC {cleanup_utc_time})")
+
+ # 每小时清理过期验证码
+ schedule.every().hour.do(cleanup_expired_captcha)
+
+ # 定时内存清理 - 每小时执行一次
+ schedule.every().hour.do(cleanup_memory_task)
+ print("[定时任务] 已设置内存清理任务: 每小时执行一次")
+ print(f"[定时任务] 已设置验证码清理任务: 每小时执行一次")
+
+ # 如果启用了定时浏览任务,则添加
+ if config.get('schedule_enabled'):
+ schedule_time_cst = config.get('schedule_time', '02:00')
+ schedule_time_utc = cst_to_utc_time(schedule_time_cst)
+ schedule.every().day.at(schedule_time_utc).do(run_all_accounts_task)
+ print(f"[定时任务] 已设置浏览任务: 每天 CST {schedule_time_cst} (UTC {schedule_time_utc})")
+
+ # 初始检查
+ check_and_schedule()
+ last_check = time.time()
+
+ while True:
+ try:
+ # 执行待执行的任务
+ schedule.run_pending()
+
+ # 每60秒重新检查一次配置
+ if time.time() - last_check > 60:
+ check_and_schedule()
+ last_check = time.time()
+
+ time.sleep(1)
+ except Exception as e:
+ print(f"[定时任务] 调度器出错: {str(e)}")
+ time.sleep(5)
+
+
+if __name__ == '__main__':
+ print("=" * 60)
+ print("知识管理平台自动化工具 - 多用户版")
+ print("=" * 60)
+
+ # 初始化数据库
+ database.init_database()
+
+ # 加载系统配置(并发设置)
+ try:
+ config = database.get_system_config()
+ if config:
+ # 使用globals()修改全局变量
+ globals()['max_concurrent_global'] = config.get('max_concurrent_global', 2)
+ globals()['max_concurrent_per_account'] = config.get('max_concurrent_per_account', 1)
+
+ # 重新创建信号量
+ globals()['global_semaphore'] = threading.Semaphore(globals()['max_concurrent_global'])
+
+ print(f"✓ 已加载并发配置: 全局={globals()['max_concurrent_global']}, 单账号={globals()['max_concurrent_per_account']}")
+ except Exception as e:
+ print(f"警告: 加载并发配置失败,使用默认值: {e}")
+
+ # 主线程初始化浏览器(Playwright不支持跨线程)
+ print("\n正在初始化浏览器管理器...")
+ init_browser_manager()
+
+ # 启动定时任务调度器
+ print("\n启动定时任务调度器...")
+ scheduler_thread = threading.Thread(target=scheduled_task_worker, daemon=True)
+ scheduler_thread.start()
+ print("✓ 定时任务调度器已启动")
+
+ # 启动Web服务器
+ print("\n服务器启动中...")
+ print("用户访问地址: http://0.0.0.0:5000")
+ print("后台管理地址: http://0.0.0.0:5000/yuyx")
+ print("默认管理员: admin/admin")
+ print("=" * 60 + "\n")
+
+ socketio.run(app, host='0.0.0.0', port=5000, debug=False)
diff --git a/app.py.original b/app.py.original
new file mode 100755
index 0000000..394d7c7
--- /dev/null
+++ b/app.py.original
@@ -0,0 +1,2223 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+知识管理平台自动化工具 - 多用户版本
+支持用户注册登录、后台管理、数据隔离
+"""
+
+# 设置时区为中国标准时间(CST, UTC+8)
+import os
+os.environ['TZ'] = 'Asia/Shanghai'
+try:
+ import time
+ time.tzset()
+except AttributeError:
+ pass # Windows系统不支持tzset()
+
+import pytz
+from datetime import datetime
+from flask import Flask, render_template, request, jsonify, send_from_directory, redirect, url_for, session
+from flask_socketio import SocketIO, emit, join_room, leave_room
+from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
+import threading
+import time
+import json
+import os
+from datetime import datetime, timedelta, timezone
+from functools import wraps
+
+# 导入数据库模块和核心模块
+import database
+import requests
+from playwright_automation import PlaywrightBrowserManager, PlaywrightAutomation, BrowseResult
+from browser_installer import check_and_install_browser
+# ========== 优化模块导入 ==========
+from app_config import get_config
+from app_logger import init_logging, get_logger, audit_logger
+from app_security import (
+ ip_rate_limiter, require_ip_not_locked,
+ validate_username, validate_password, validate_email,
+ is_safe_path, sanitize_filename, get_client_ip
+)
+
+
+
+# ========== 初始化配置 ==========
+config = get_config()
+app = Flask(__name__)
+# SECRET_KEY持久化,避免重启后所有用户登出
+SECRET_KEY_FILE = 'data/secret_key.txt'
+if os.path.exists(SECRET_KEY_FILE):
+ with open(SECRET_KEY_FILE, 'r') as f:
+ SECRET_KEY = f.read().strip()
+else:
+ SECRET_KEY = os.urandom(24).hex()
+ os.makedirs('data', exist_ok=True)
+ with open(SECRET_KEY_FILE, 'w') as f:
+ f.write(SECRET_KEY)
+ print(f"✓ 已生成新的SECRET_KEY并保存")
+app.config.from_object(config)
+socketio = SocketIO(app, cors_allowed_origins="*")
+
+# ========== 初始化日志系统 ==========
+init_logging(log_level=config.LOG_LEVEL, log_file=config.LOG_FILE)
+logger = get_logger('app')
+logger.info("="*60)
+logger.info("知识管理平台自动化工具 - 多用户版")
+logger.info("="*60)
+
+
+# Flask-Login 配置
+login_manager = LoginManager()
+login_manager.init_app(app)
+login_manager.login_view = 'login_page'
+
+# 截图目录
+SCREENSHOTS_DIR = config.SCREENSHOTS_DIR
+os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
+
+# 全局变量
+browser_manager = None
+user_accounts = {} # {user_id: {account_id: Account对象}}
+active_tasks = {} # {account_id: Thread对象}
+log_cache = {} # {user_id: [logs]} 每个用户独立的日志缓存
+log_cache_total_count = 0 # 全局日志总数,防止无限增长
+
+# 日志缓存限制
+MAX_LOGS_PER_USER = config.MAX_LOGS_PER_USER # 每个用户最多100条
+MAX_TOTAL_LOGS = config.MAX_TOTAL_LOGS # 全局最多1000条,防止内存泄漏
+
+# 并发控制:每个用户同时最多运行1个账号(避免内存不足)
+# 验证码存储:{session_id: {"code": "1234", "expire_time": timestamp, "failed_attempts": 0}}
+captcha_storage = {}
+
+# IP限流存储:{ip: {"attempts": count, "lock_until": timestamp, "first_attempt": timestamp}}
+ip_rate_limit = {}
+
+# 限流配置
+MAX_CAPTCHA_ATTEMPTS = 5 # 每个验证码最多尝试次数
+MAX_IP_ATTEMPTS_PER_HOUR = 10 # 每小时每个IP最多验证码错误次数
+IP_LOCK_DURATION = 3600 # IP锁定时长(秒) - 1小时
+# 全局限制:整个系统同时最多运行2个账号(线程本地架构,每个线程独立浏览器,内存占用约200MB/浏览器)
+max_concurrent_per_account = 1 # 每个用户最多1个
+max_concurrent_global = 2 # 全局最多2个(线程本地架构内存需求更高)
+user_semaphores = {} # {user_id: Semaphore}
+global_semaphore = threading.Semaphore(max_concurrent_global)
+
+# 截图专用信号量:限制同时进行的截图任务数量为1(避免资源竞争)
+screenshot_semaphore = threading.Semaphore(1)
+
+
+class User(UserMixin):
+ """Flask-Login 用户类"""
+ def __init__(self, user_id):
+ self.id = user_id
+
+
+class Admin(UserMixin):
+ """管理员类"""
+ def __init__(self, admin_id):
+ self.id = admin_id
+ self.is_admin = True
+
+
+class Account:
+ """账号类"""
+ def __init__(self, account_id, user_id, username, password, remember=True, remark=''):
+ self.id = account_id
+ self.user_id = user_id
+ self.username = username
+ self.password = password
+ self.remember = remember
+ self.remark = remark
+ self.status = "未开始"
+ self.is_running = False
+ self.should_stop = False
+ self.total_items = 0
+ self.total_attachments = 0
+ self.automation = None
+ self.last_browse_type = "注册前未读"
+ self.proxy_config = None # 保存代理配置,浏览和截图共用
+
+ def to_dict(self):
+ return {
+ "id": self.id,
+ "username": self.username,
+ "status": self.status,
+ "remark": self.remark,
+ "total_items": self.total_items,
+ "total_attachments": self.total_attachments,
+ "is_running": self.is_running
+ }
+
+
+@login_manager.user_loader
+def load_user(user_id):
+ """Flask-Login 用户加载"""
+ user = database.get_user_by_id(int(user_id))
+ if user:
+ return User(user['id'])
+ return None
+
+
+def admin_required(f):
+ """管理员权限装饰器"""
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+ return f(*args, **kwargs)
+ return decorated_function
+
+
+def log_to_client(message, user_id=None, account_id=None):
+ """发送日志到Web客户端(用户隔离)"""
+ beijing_tz = timezone(timedelta(hours=8))
+ timestamp = datetime.now(beijing_tz).strftime('%H:%M:%S')
+ log_data = {
+ 'timestamp': timestamp,
+ 'message': message,
+ 'account_id': account_id
+ }
+
+ # 如果指定了user_id,则缓存到该用户的日志
+ if user_id:
+ global log_cache_total_count
+ if user_id not in log_cache:
+ log_cache[user_id] = []
+ log_cache[user_id].append(log_data)
+ log_cache_total_count += 1
+
+ # 持久化到数据库 (已禁用,使用task_logs表代替)
+ # try:
+ # database.save_operation_log(user_id, message, account_id, 'INFO')
+ # except Exception as e:
+ # print(f"保存日志到数据库失败: {e}")
+
+ # 单用户限制
+ if len(log_cache[user_id]) > MAX_LOGS_PER_USER:
+ log_cache[user_id].pop(0)
+ log_cache_total_count -= 1
+
+ # 全局限制 - 如果超过总数限制,清理日志最多的用户
+ while log_cache_total_count > MAX_TOTAL_LOGS:
+ if log_cache:
+ max_user = max(log_cache.keys(), key=lambda u: len(log_cache[u]))
+ if log_cache[max_user]:
+ log_cache[max_user].pop(0)
+ log_cache_total_count -= 1
+ else:
+ break
+ else:
+ break
+
+ # 发送到该用户的room
+ socketio.emit('log', log_data, room=f'user_{user_id}')
+
+ print(f"[{timestamp}] User:{user_id} {message}")
+
+
+
+def get_proxy_from_api(api_url, max_retries=3):
+ """从API获取代理IP(支持重试)
+
+ Args:
+ api_url: 代理API地址
+ max_retries: 最大重试次数
+
+ Returns:
+ 代理服务器地址(格式: http://IP:PORT)或 None
+ """
+ for attempt in range(max_retries):
+ try:
+ response = requests.get(api_url, timeout=10)
+ if response.status_code == 200:
+ ip_port = response.text.strip()
+ if ip_port and ':' in ip_port:
+ proxy_server = f"http://{ip_port}"
+ print(f"✓ 获取代理成功: {proxy_server} (尝试 {attempt + 1}/{max_retries})")
+ return proxy_server
+ else:
+ print(f"✗ 代理格式错误: {ip_port} (尝试 {attempt + 1}/{max_retries})")
+ else:
+ print(f"✗ 获取代理失败: HTTP {response.status_code} (尝试 {attempt + 1}/{max_retries})")
+ except Exception as e:
+ print(f"✗ 获取代理异常: {str(e)} (尝试 {attempt + 1}/{max_retries})")
+
+ if attempt < max_retries - 1:
+ time.sleep(1)
+
+ print(f"✗ 获取代理失败,已重试 {max_retries} 次,将不使用代理继续")
+ return None
+
+def init_browser_manager():
+ """初始化浏览器管理器"""
+ global browser_manager
+ if browser_manager is None:
+ print("正在初始化Playwright浏览器管理器...")
+
+ if not check_and_install_browser(log_callback=lambda msg, account_id=None: print(msg)):
+ print("浏览器环境检查失败!")
+ return False
+
+ browser_manager = PlaywrightBrowserManager(
+ headless=True,
+ log_callback=lambda msg, account_id=None: print(msg)
+ )
+
+ try:
+ # 不再需要initialize(),每个账号会创建独立浏览器
+ print("Playwright浏览器管理器创建成功!")
+ return True
+ except Exception as e:
+ print(f"Playwright初始化失败: {str(e)}")
+ return False
+ return True
+
+
+# ==================== 前端路由 ====================
+
+@app.route('/')
+def index():
+ """主页 - 重定向到登录或应用"""
+ if current_user.is_authenticated:
+ return redirect(url_for('app_page'))
+ return redirect(url_for('login_page'))
+
+
+@app.route('/login')
+def login_page():
+ """登录页面"""
+ return render_template('login.html')
+
+
+@app.route('/register')
+def register_page():
+ """注册页面"""
+ return render_template('register.html')
+
+
+@app.route('/app')
+@login_required
+def app_page():
+ """主应用页面"""
+ return render_template('index.html')
+
+
+@app.route('/yuyx')
+def admin_login_page():
+ """后台登录页面"""
+ if 'admin_id' in session:
+ return redirect(url_for('admin_page'))
+ return render_template('admin_login.html')
+
+
+@app.route('/yuyx/admin')
+@admin_required
+def admin_page():
+ """后台管理页面"""
+ return render_template('admin.html')
+
+
+
+
+@app.route('/yuyx/vip')
+@admin_required
+def vip_admin_page():
+ """VIP管理页面"""
+ return render_template('vip_admin.html')
+
+
+# ==================== 用户认证API ====================
+
+@app.route('/api/register', methods=['POST'])
+@require_ip_not_locked # IP限流保护
+def register():
+ """用户注册"""
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ email = data.get('email', '').strip()
+ captcha_session = data.get('captcha_session', '')
+ captcha_code = data.get('captcha', '').strip()
+
+ if not username or not password:
+ return jsonify({"error": "用户名和密码不能为空"}), 400
+
+ # 验证验证码
+ if not captcha_session or captcha_session not in captcha_storage:
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ captcha_data = captcha_storage[captcha_session]
+ if captcha_data["expire_time"] < time.time():
+ del captcha_storage[captcha_session]
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ # 获取客户端IP
+ client_ip = request.headers.get('X-Forwarded-For', request.headers.get('X-Real-IP', request.remote_addr))
+ if client_ip and ',' in client_ip:
+ client_ip = client_ip.split(',')[0].strip()
+
+ # 检查IP限流
+ allowed, error_msg = check_ip_rate_limit(client_ip)
+ if not allowed:
+ return jsonify({"error": error_msg}), 429
+
+ # 检查验证码尝试次数
+ if captcha_data.get("failed_attempts", 0) >= MAX_CAPTCHA_ATTEMPTS:
+ del captcha_storage[captcha_session]
+ return jsonify({"error": "验证码尝试次数过多,请重新获取"}), 400
+
+ if captcha_data["code"] != captcha_code:
+ # 记录失败次数
+ captcha_data["failed_attempts"] = captcha_data.get("failed_attempts", 0) + 1
+
+ # 记录IP失败尝试
+ is_locked = record_failed_captcha(client_ip)
+ if is_locked:
+ return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
+
+ return jsonify({"error": "验证码错误(剩余{}次机会)".format(
+ MAX_CAPTCHA_ATTEMPTS - captcha_data["failed_attempts"])}), 400
+
+ # 验证成功,删除已使用的验证码
+ del captcha_storage[captcha_session]
+
+ user_id = database.create_user(username, password, email)
+ if user_id:
+ return jsonify({"success": True, "message": "注册成功,请等待管理员审核"})
+ else:
+ return jsonify({"error": "用户名已存在"}), 400
+
+
+# ==================== 验证码API ====================
+import random
+
+
+def check_ip_rate_limit(ip_address):
+ """检查IP是否被限流"""
+ current_time = time.time()
+
+ # 清理过期的IP记录
+ expired_ips = [ip for ip, data in ip_rate_limit.items()
+ if data.get("lock_until", 0) < current_time and
+ current_time - data.get("first_attempt", current_time) > 3600]
+ for ip in expired_ips:
+ del ip_rate_limit[ip]
+
+ # 检查IP是否被锁定
+ if ip_address in ip_rate_limit:
+ ip_data = ip_rate_limit[ip_address]
+
+ # 如果IP被锁定且未到解锁时间
+ if ip_data.get("lock_until", 0) > current_time:
+ remaining_time = int(ip_data["lock_until"] - current_time)
+ return False, "IP已被锁定,请{}分钟后再试".format(remaining_time // 60 + 1)
+
+ # 如果超过1小时,重置计数
+ if current_time - ip_data.get("first_attempt", current_time) > 3600:
+ ip_rate_limit[ip_address] = {
+ "attempts": 0,
+ "first_attempt": current_time
+ }
+
+ return True, None
+
+
+def record_failed_captcha(ip_address):
+ """记录验证码失败尝试"""
+ current_time = time.time()
+
+ if ip_address not in ip_rate_limit:
+ ip_rate_limit[ip_address] = {
+ "attempts": 1,
+ "first_attempt": current_time
+ }
+ else:
+ ip_rate_limit[ip_address]["attempts"] += 1
+
+ # 检查是否超过限制
+ if ip_rate_limit[ip_address]["attempts"] >= MAX_IP_ATTEMPTS_PER_HOUR:
+ ip_rate_limit[ip_address]["lock_until"] = current_time + IP_LOCK_DURATION
+ return True # 表示IP已被锁定
+
+ return False # 表示还未锁定
+
+
+@app.route("/api/generate_captcha", methods=["POST"])
+def generate_captcha():
+ """生成4位数字验证码"""
+ import uuid
+ session_id = str(uuid.uuid4())
+
+ # 生成4位随机数字
+ code = "".join([str(random.randint(0, 9)) for _ in range(4)])
+
+ # 存储验证码,5分钟过期
+ captcha_storage[session_id] = {
+ "code": code,
+ "expire_time": time.time() + 300,
+ "failed_attempts": 0
+ }
+
+ # 清理过期验证码
+ expired_keys = [k for k, v in captcha_storage.items() if v["expire_time"] < time.time()]
+ for k in expired_keys:
+ del captcha_storage[k]
+
+ return jsonify({"session_id": session_id, "captcha": code})
+
+
+@app.route('/api/login', methods=['POST'])
+@require_ip_not_locked # IP限流保护
+def login():
+ """用户登录"""
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ captcha_session = data.get('captcha_session', '')
+ captcha_code = data.get('captcha', '').strip()
+ need_captcha = data.get('need_captcha', False)
+
+ # 如果需要验证码,验证验证码
+ if need_captcha:
+ if not captcha_session or captcha_session not in captcha_storage:
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ captcha_data = captcha_storage[captcha_session]
+ if captcha_data["expire_time"] < time.time():
+ del captcha_storage[captcha_session]
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ if captcha_data["code"] != captcha_code:
+ return jsonify({"error": "验证码错误"}), 400
+
+ # 验证成功,删除已使用的验证码
+ del captcha_storage[captcha_session]
+
+ # 先检查用户是否存在
+ user_exists = database.get_user_by_username(username)
+ if not user_exists:
+ return jsonify({"error": "账号未注册", "need_captcha": True}), 401
+
+ # 检查密码是否正确
+ user = database.verify_user(username, password)
+ if not user:
+ # 密码错误
+ return jsonify({"error": "密码错误", "need_captcha": True}), 401
+
+ # 检查审核状态
+ if user['status'] != 'approved':
+ return jsonify({"error": "账号未审核,请等待管理员审核", "need_captcha": False}), 401
+
+ # 登录成功
+ user_obj = User(user['id'])
+ login_user(user_obj)
+ load_user_accounts(user['id'])
+ return jsonify({"success": True})
+
+
+@app.route('/api/logout', methods=['POST'])
+@login_required
+def logout():
+ """用户登出"""
+ logout_user()
+ return jsonify({"success": True})
+
+
+# ==================== 管理员认证API ====================
+
+@app.route('/yuyx/api/login', methods=['POST'])
+@require_ip_not_locked # IP限流保护
+def admin_login():
+ """管理员登录"""
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ captcha_session = data.get('captcha_session', '')
+ captcha_code = data.get('captcha', '').strip()
+ need_captcha = data.get('need_captcha', False)
+
+ # 如果需要验证码,验证验证码
+ if need_captcha:
+ if not captcha_session or captcha_session not in captcha_storage:
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ captcha_data = captcha_storage[captcha_session]
+ if captcha_data["expire_time"] < time.time():
+ del captcha_storage[captcha_session]
+ return jsonify({"error": "验证码已过期,请重新获取"}), 400
+
+ if captcha_data["code"] != captcha_code:
+ return jsonify({"error": "验证码错误"}), 400
+
+ # 验证成功,删除已使用的验证码
+ del captcha_storage[captcha_session]
+
+ admin = database.verify_admin(username, password)
+ if admin:
+ session['admin_id'] = admin['id']
+ session['admin_username'] = admin['username']
+ return jsonify({"success": True})
+ else:
+ return jsonify({"error": "管理员用户名或密码错误", "need_captcha": True}), 401
+
+
+@app.route('/yuyx/api/logout', methods=['POST'])
+@admin_required
+def admin_logout():
+ """管理员登出"""
+ session.pop('admin_id', None)
+ session.pop('admin_username', None)
+ return jsonify({"success": True})
+
+
+@app.route('/yuyx/api/users', methods=['GET'])
+@admin_required
+def get_all_users():
+ """获取所有用户"""
+ users = database.get_all_users()
+ return jsonify(users)
+
+
+@app.route('/yuyx/api/users/pending', methods=['GET'])
+@admin_required
+def get_pending_users():
+ """获取待审核用户"""
+ users = database.get_pending_users()
+ return jsonify(users)
+
+
+@app.route('/yuyx/api/users//approve', methods=['POST'])
+@admin_required
+def approve_user_route(user_id):
+ """审核通过用户"""
+ if database.approve_user(user_id):
+ return jsonify({"success": True})
+ return jsonify({"error": "审核失败"}), 400
+
+
+@app.route('/yuyx/api/users//reject', methods=['POST'])
+@admin_required
+def reject_user_route(user_id):
+ """拒绝用户"""
+ if database.reject_user(user_id):
+ return jsonify({"success": True})
+ return jsonify({"error": "拒绝失败"}), 400
+
+
+@app.route('/yuyx/api/users/', methods=['DELETE'])
+@admin_required
+def delete_user_route(user_id):
+ """删除用户"""
+ if database.delete_user(user_id):
+ # 清理内存中的账号数据
+ if user_id in user_accounts:
+ del user_accounts[user_id]
+
+ # 清理用户信号量,防止内存泄漏
+ if user_id in user_semaphores:
+ del user_semaphores[user_id]
+
+ # 清理用户日志缓存,防止内存泄漏
+ global log_cache_total_count
+ if user_id in log_cache:
+ log_cache_total_count -= len(log_cache[user_id])
+ del log_cache[user_id]
+
+ return jsonify({"success": True})
+ return jsonify({"error": "删除失败"}), 400
+
+
+@app.route('/yuyx/api/stats', methods=['GET'])
+@admin_required
+def get_system_stats():
+ """获取系统统计"""
+ stats = database.get_system_stats()
+ # 从session获取管理员用户名
+ stats["admin_username"] = session.get('admin_username', 'admin')
+ return jsonify(stats)
+
+
+@app.route('/yuyx/api/docker_stats', methods=['GET'])
+@admin_required
+def get_docker_stats():
+ """获取Docker容器运行状态"""
+ import subprocess
+
+ docker_status = {
+ 'running': False,
+ 'container_name': 'N/A',
+ 'uptime': 'N/A',
+ 'memory_usage': 'N/A',
+ 'memory_limit': 'N/A',
+ 'memory_percent': 'N/A',
+ 'cpu_percent': 'N/A',
+ 'status': 'Unknown'
+ }
+
+ try:
+ # 检查是否在Docker容器内
+ if os.path.exists('/.dockerenv'):
+ docker_status['running'] = True
+
+ # 获取容器名称
+ try:
+ with open('/etc/hostname', 'r') as f:
+ docker_status['container_name'] = f.read().strip()
+ except:
+ pass
+
+ # 获取内存使用情况 (cgroup v2)
+ try:
+ # 尝试cgroup v2路径
+ if os.path.exists('/sys/fs/cgroup/memory.current'):
+ # Read total memory
+ with open('/sys/fs/cgroup/memory.current', 'r') as f:
+ mem_total = int(f.read().strip())
+
+ # Read cache from memory.stat
+ cache = 0
+ if os.path.exists('/sys/fs/cgroup/memory.stat'):
+ with open('/sys/fs/cgroup/memory.stat', 'r') as f:
+ for line in f:
+ if line.startswith('inactive_file '):
+ cache = int(line.split()[1])
+ break
+
+ # Actual memory = total - cache
+ mem_bytes = mem_total - cache
+ docker_status['memory_usage'] = "{:.2f} MB".format(mem_bytes / 1024 / 1024)
+
+ # 获取内存限制
+ if os.path.exists('/sys/fs/cgroup/memory.max'):
+ with open('/sys/fs/cgroup/memory.max', 'r') as f:
+ limit_str = f.read().strip()
+ if limit_str != 'max':
+ limit_bytes = int(limit_str)
+ docker_status['memory_limit'] = "{:.2f} GB".format(limit_bytes / 1024 / 1024 / 1024)
+ docker_status['memory_percent'] = "{:.2f}%".format(mem_bytes / limit_bytes * 100)
+ # 尝试cgroup v1路径
+ elif os.path.exists('/sys/fs/cgroup/memory/memory.usage_in_bytes'):
+ # 从 memory.stat 读取内存信息
+ mem_bytes = 0
+ if os.path.exists('/sys/fs/cgroup/memory/memory.stat'):
+ with open('/sys/fs/cgroup/memory/memory.stat', 'r') as f:
+ rss = 0
+ cache = 0
+ for line in f:
+ if line.startswith('total_rss '):
+ rss = int(line.split()[1])
+ elif line.startswith('total_cache '):
+ cache = int(line.split()[1])
+ # 使用 RSS + (一部分活跃的cache),更接近docker stats的计算
+ # 但为了准确性,我们只用RSS
+ mem_bytes = rss
+
+ # 如果找不到,则使用总内存减去缓存作为后备
+ if mem_bytes == 0:
+ with open('/sys/fs/cgroup/memory/memory.usage_in_bytes', 'r') as f:
+ total_mem = int(f.read().strip())
+
+ cache = 0
+ if os.path.exists('/sys/fs/cgroup/memory/memory.stat'):
+ with open('/sys/fs/cgroup/memory/memory.stat', 'r') as f:
+ for line in f:
+ if line.startswith('total_inactive_file '):
+ cache = int(line.split()[1])
+ break
+
+ mem_bytes = total_mem - cache
+
+ docker_status['memory_usage'] = "{:.2f} MB".format(mem_bytes / 1024 / 1024)
+
+ # 获取内存限制
+ if os.path.exists('/sys/fs/cgroup/memory/memory.limit_in_bytes'):
+ with open('/sys/fs/cgroup/memory/memory.limit_in_bytes', 'r') as f:
+ limit_bytes = int(f.read().strip())
+ # 检查是否是实际限制(不是默认的超大值)
+ if limit_bytes < 9223372036854771712:
+ docker_status['memory_limit'] = "{:.2f} GB".format(limit_bytes / 1024 / 1024 / 1024)
+ docker_status['memory_percent'] = "{:.2f}%".format(mem_bytes / limit_bytes * 100)
+ except Exception as e:
+ docker_status['memory_usage'] = 'Error: {}'.format(str(e))
+
+ # 获取容器运行时间(基于PID 1的启动时间)
+ try:
+ # Get PID 1 start time
+ with open('/proc/1/stat', 'r') as f:
+ stat_data = f.read().split()
+ starttime_ticks = int(stat_data[21])
+
+ # Get system uptime
+ with open('/proc/uptime', 'r') as f:
+ system_uptime = float(f.read().split()[0])
+
+ # Get clock ticks per second
+ import os as os_module
+ ticks_per_sec = os_module.sysconf(os_module.sysconf_names['SC_CLK_TCK'])
+
+ # Calculate container uptime
+ process_start = starttime_ticks / ticks_per_sec
+ uptime_seconds = int(system_uptime - process_start)
+
+ days = uptime_seconds // 86400
+ hours = (uptime_seconds % 86400) // 3600
+ minutes = (uptime_seconds % 3600) // 60
+
+ if days > 0:
+ docker_status['uptime'] = "{}天 {}小时 {}分钟".format(days, hours, minutes)
+ elif hours > 0:
+ docker_status['uptime'] = "{}小时 {}分钟".format(hours, minutes)
+ else:
+ docker_status['uptime'] = "{}分钟".format(minutes)
+ except:
+ pass
+
+ docker_status['status'] = 'Running'
+ else:
+ docker_status['status'] = 'Not in Docker'
+
+ except Exception as e:
+ docker_status['status'] = 'Error: {}'.format(str(e))
+
+ return jsonify(docker_status)
+
+@app.route('/yuyx/api/admin/password', methods=['PUT'])
+@admin_required
+def update_admin_password():
+ """修改管理员密码"""
+ data = request.json
+ new_password = data.get('new_password', '').strip()
+
+ if not new_password:
+ return jsonify({"error": "密码不能为空"}), 400
+
+ username = session.get('admin_username')
+ if database.update_admin_password(username, new_password):
+ return jsonify({"success": True})
+ return jsonify({"error": "修改失败"}), 400
+
+
+@app.route('/yuyx/api/admin/username', methods=['PUT'])
+@admin_required
+def update_admin_username():
+ """修改管理员用户名"""
+ data = request.json
+ new_username = data.get('new_username', '').strip()
+
+ if not new_username:
+ return jsonify({"error": "用户名不能为空"}), 400
+
+ old_username = session.get('admin_username')
+ if database.update_admin_username(old_username, new_username):
+ session['admin_username'] = new_username
+ return jsonify({"success": True})
+ return jsonify({"error": "用户名已存在"}), 400
+
+
+
+# ==================== 密码重置API ====================
+
+# 管理员直接重置用户密码
+@app.route('/yuyx/api/users//reset_password', methods=['POST'])
+@admin_required
+def admin_reset_password_route(user_id):
+ """管理员直接重置用户密码(无需审核)"""
+ data = request.json
+ new_password = data.get('new_password', '').strip()
+
+ if not new_password:
+ return jsonify({"error": "新密码不能为空"}), 400
+
+ if len(new_password) < 6:
+ return jsonify({"error": "密码长度不能少于6位"}), 400
+
+ if database.admin_reset_user_password(user_id, new_password):
+ return jsonify({"message": "密码重置成功"})
+ return jsonify({"error": "重置失败,用户不存在"}), 400
+
+
+# 获取密码重置申请列表
+@app.route('/yuyx/api/password_resets', methods=['GET'])
+@admin_required
+def get_password_resets_route():
+ """获取所有待审核的密码重置申请"""
+ resets = database.get_pending_password_resets()
+ return jsonify(resets)
+
+
+# 批准密码重置申请
+@app.route('/yuyx/api/password_resets//approve', methods=['POST'])
+@admin_required
+def approve_password_reset_route(request_id):
+ """批准密码重置申请"""
+ if database.approve_password_reset(request_id):
+ return jsonify({"message": "密码重置申请已批准"})
+ return jsonify({"error": "批准失败"}), 400
+
+
+# 拒绝密码重置申请
+@app.route('/yuyx/api/password_resets//reject', methods=['POST'])
+@admin_required
+def reject_password_reset_route(request_id):
+ """拒绝密码重置申请"""
+ if database.reject_password_reset(request_id):
+ return jsonify({"message": "密码重置申请已拒绝"})
+ return jsonify({"error": "拒绝失败"}), 400
+
+
+# 用户申请重置密码(需要审核)
+@app.route('/api/reset_password_request', methods=['POST'])
+def request_password_reset():
+ """用户申请重置密码"""
+ data = request.json
+ username = data.get('username', '').strip()
+ email = data.get('email', '').strip()
+ new_password = data.get('new_password', '').strip()
+
+ if not username or not new_password:
+ return jsonify({"error": "用户名和新密码不能为空"}), 400
+
+ if len(new_password) < 6:
+ return jsonify({"error": "密码长度不能少于6位"}), 400
+
+ # 验证用户存在
+ user = database.get_user_by_username(username)
+ if not user:
+ return jsonify({"error": "用户不存在"}), 404
+
+ # 如果提供了邮箱,验证邮箱是否匹配
+ if email and user.get('email') != email:
+ return jsonify({"error": "邮箱不匹配"}), 400
+
+ # 创建重置申请
+ request_id = database.create_password_reset_request(user['id'], new_password)
+ if request_id:
+ return jsonify({"message": "密码重置申请已提交,请等待管理员审核"})
+ else:
+ return jsonify({"error": "申请提交失败"}), 500
+
+
+# ==================== 账号管理API (用户隔离) ====================
+
+def load_user_accounts(user_id):
+ """从数据库加载用户的账号到内存"""
+ if user_id not in user_accounts:
+ user_accounts[user_id] = {}
+
+ accounts_data = database.get_user_accounts(user_id)
+ for acc_data in accounts_data:
+ account = Account(
+ account_id=acc_data['id'],
+ user_id=user_id,
+ username=acc_data['username'],
+ password=acc_data['password'],
+ remember=bool(acc_data['remember']),
+ remark=acc_data['remark'] or ''
+ )
+ user_accounts[user_id][account.id] = account
+
+
+@app.route('/api/accounts', methods=['GET'])
+@login_required
+def get_accounts():
+ """获取当前用户的所有账号"""
+ user_id = current_user.id
+ if user_id not in user_accounts:
+ load_user_accounts(user_id)
+
+ accounts = user_accounts.get(user_id, {})
+ return jsonify([acc.to_dict() for acc in accounts.values()])
+
+
+@app.route('/api/accounts', methods=['POST'])
+@login_required
+def add_account():
+ """添加账号"""
+ user_id = current_user.id
+
+ # VIP账号数量限制检查
+ if not database.is_user_vip(user_id):
+ current_count = len(database.get_user_accounts(user_id))
+ if current_count >= 1:
+ return jsonify({"error": "非VIP用户只能添加1个账号,请联系管理员开通VIP"}), 403
+ data = request.json
+ username = data.get('username', '').strip()
+ password = data.get('password', '').strip()
+ remember = data.get('remember', True)
+
+ if not username or not password:
+ return jsonify({"error": "用户名和密码不能为空"}), 400
+
+ # 检查当前用户是否已存在该账号
+ if user_id in user_accounts:
+ for acc in user_accounts[user_id].values():
+ if acc.username == username:
+ return jsonify({"error": f"账号 '{username}' 已存在"}), 400
+
+ # 生成账号ID
+ import uuid
+ account_id = str(uuid.uuid4())[:8]
+
+ # 保存到数据库
+ database.create_account(user_id, account_id, username, password, remember, '')
+
+ # 加载到内存
+ account = Account(account_id, user_id, username, password, remember, '')
+ if user_id not in user_accounts:
+ user_accounts[user_id] = {}
+ user_accounts[user_id][account_id] = account
+
+ log_to_client(f"添加账号: {username}", user_id)
+ return jsonify(account.to_dict())
+
+
+@app.route('/api/accounts/', methods=['DELETE'])
+@login_required
+def delete_account(account_id):
+ """删除账号"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ # 停止正在运行的任务
+ if account.is_running:
+ account.should_stop = True
+ if account.automation:
+ account.automation.close()
+
+ username = account.username
+
+ # 从数据库删除
+ database.delete_account(account_id)
+
+ # 从内存删除
+ del user_accounts[user_id][account_id]
+
+ log_to_client(f"删除账号: {username}", user_id)
+ return jsonify({"success": True})
+
+
+@app.route('/api/accounts//remark', methods=['PUT'])
+@login_required
+def update_remark(account_id):
+ """更新备注"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ data = request.json
+ remark = data.get('remark', '').strip()[:200]
+
+ # 更新数据库
+ database.update_account_remark(account_id, remark)
+
+ # 更新内存
+ user_accounts[user_id][account_id].remark = remark
+ log_to_client(f"更新备注: {user_accounts[user_id][account_id].username} -> {remark}", user_id)
+
+ return jsonify({"success": True})
+
+
+@app.route('/api/accounts//start', methods=['POST'])
+@login_required
+def start_account(account_id):
+ """启动账号任务"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ if account.is_running:
+ return jsonify({"error": "任务已在运行中"}), 400
+
+ data = request.json
+ browse_type = data.get('browse_type', '应读')
+ enable_screenshot = data.get('enable_screenshot', True) # 默认启用截图
+
+ # 确保浏览器管理器已初始化
+ if not init_browser_manager():
+ return jsonify({"error": "浏览器初始化失败"}), 500
+
+ # 启动任务线程
+ account.is_running = True
+ account.should_stop = False
+ account.status = "运行中"
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+
+ log_to_client(f"启动任务: {account.username} - {browse_type}", user_id)
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ return jsonify({"success": True})
+
+
+@app.route('/api/accounts//stop', methods=['POST'])
+@login_required
+def stop_account(account_id):
+ """停止账号任务"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+
+ if not account.is_running:
+ return jsonify({"error": "任务未在运行"}), 400
+
+ account.should_stop = True
+ account.status = "正在停止"
+
+ log_to_client(f"停止任务: {account.username}", user_id)
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ return jsonify({"success": True})
+
+
+def get_user_semaphore(user_id):
+ """获取或创建用户的信号量"""
+ if user_id not in user_semaphores:
+ user_semaphores[user_id] = threading.Semaphore(max_concurrent_per_account)
+ return user_semaphores[user_id]
+
+
+def run_task(user_id, account_id, browse_type, enable_screenshot=True):
+ """运行自动化任务"""
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return
+
+ account = user_accounts[user_id][account_id]
+
+ # 记录任务开始时间
+ import time as time_module
+ task_start_time = time_module.time()
+
+ # 两级并发控制:用户级 + 全局级
+ user_sem = get_user_semaphore(user_id)
+
+ # 获取用户级信号量(同一用户的账号排队)
+ log_to_client(f"等待资源分配...", user_id, account_id)
+ account.status = "排队中"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ user_sem.acquire()
+
+ try:
+ # 如果在排队期间被停止,直接返回
+ if account.should_stop:
+ log_to_client(f"任务已取消", user_id, account_id)
+ account.status = "已停止"
+ account.is_running = False
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ return
+
+ # 获取全局信号量(防止所有用户同时运行导致资源耗尽)
+ global_semaphore.acquire()
+
+ try:
+ # 再次检查是否被停止
+ if account.should_stop:
+ log_to_client(f"任务已取消", user_id, account_id)
+ account.status = "已停止"
+ account.is_running = False
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ return
+
+ account.status = "运行中"
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ account.last_browse_type = browse_type
+
+ # 重试机制:最多尝试3次,超时则换IP重试
+ max_attempts = 3
+ last_error = None
+
+ for attempt in range(1, max_attempts + 1):
+ try:
+ if attempt > 1:
+ log_to_client(f"🔄 第 {attempt} 次尝试(共{max_attempts}次)...", user_id, account_id)
+
+ # 检查是否需要使用代理
+ proxy_config = None
+ config = database.get_system_config()
+ if config.get('proxy_enabled') == 1:
+ proxy_api_url = config.get('proxy_api_url', '').strip()
+ if proxy_api_url:
+ log_to_client(f"正在获取代理IP...", user_id, account_id)
+ proxy_server = get_proxy_from_api(proxy_api_url, max_retries=3)
+ if proxy_server:
+ proxy_config = {'server': proxy_server}
+ log_to_client(f"✓ 将使用代理: {proxy_server}", user_id, account_id)
+ account.proxy_config = proxy_config # 保存代理配置供截图使用
+ else:
+ log_to_client(f"✗ 代理获取失败,将不使用代理继续", user_id, account_id)
+ else:
+ log_to_client(f"⚠ 代理已启用但未配置API地址", user_id, account_id)
+
+ log_to_client(f"创建自动化实例...", user_id, account_id)
+ account.automation = PlaywrightAutomation(browser_manager, account_id, proxy_config=proxy_config)
+
+ # 为automation注入包含user_id的自定义log方法,使其能够实时发送日志到WebSocket
+ def custom_log(message: str):
+ log_to_client(message, user_id, account_id)
+ account.automation.log = custom_log
+
+ log_to_client(f"开始登录...", user_id, account_id)
+ if not account.automation.login(account.username, account.password, account.remember):
+ log_to_client(f"❌ 登录失败,请检查用户名和密码", user_id, account_id)
+ account.status = "登录失败"
+ account.is_running = False
+ # 记录登录失败日志
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=0,
+ total_attachments=0,
+ error_message='登录失败,请检查用户名和密码',
+ duration=int(time_module.time() - task_start_time)
+ )
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+ return
+
+ log_to_client(f"✓ 登录成功!", user_id, account_id)
+ log_to_client(f"开始浏览 '{browse_type}' 内容...", user_id, account_id)
+
+ def should_stop():
+ return account.should_stop
+
+ result = account.automation.browse_content(
+ browse_type=browse_type,
+ auto_next_page=True,
+ auto_view_attachments=True,
+ interval=2.0,
+ should_stop_callback=should_stop
+ )
+
+ account.total_items = result.total_items
+ account.total_attachments = result.total_attachments
+
+ if result.success:
+ log_to_client(f"浏览完成! 共 {result.total_items} 条内容,{result.total_attachments} 个附件", user_id, account_id)
+ account.status = "已完成"
+ # 记录成功日志
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='success',
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message='',
+ duration=int(time_module.time() - task_start_time)
+ )
+ # 成功则跳出重试循环
+ break
+ else:
+ # 浏览出错,检查是否是超时错误
+ error_msg = result.error_message
+ if 'Timeout' in error_msg or 'timeout' in error_msg:
+ last_error = error_msg
+ log_to_client(f"⚠ 检测到超时错误: {error_msg}", user_id, account_id)
+
+ # 关闭当前浏览器
+ if account.automation:
+ try:
+ account.automation.close()
+ log_to_client(f"已关闭超时的浏览器实例", user_id, account_id)
+ except:
+ pass
+ account.automation = None
+
+ if attempt < max_attempts:
+ log_to_client(f"⚠ 代理可能速度过慢,将换新IP重试 ({attempt}/{max_attempts})", user_id, account_id)
+ time_module.sleep(2) # 等待2秒再重试
+ continue
+ else:
+ log_to_client(f"❌ 已达到最大重试次数({max_attempts}),任务失败", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message=f"重试{max_attempts}次后仍失败: {error_msg}",
+ duration=int(time_module.time() - task_start_time)
+ )
+ break
+ else:
+ # 非超时错误,直接失败不重试
+ log_to_client(f"浏览出错: {error_msg}", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=result.total_items,
+ total_attachments=result.total_attachments,
+ error_message=error_msg,
+ duration=int(time_module.time() - task_start_time)
+ )
+ break
+
+ except Exception as retry_error:
+ # 捕获重试过程中的异常
+ error_msg = str(retry_error)
+ last_error = error_msg
+
+ # 关闭可能存在的浏览器实例
+ if account.automation:
+ try:
+ account.automation.close()
+ except:
+ pass
+ account.automation = None
+
+ if 'Timeout' in error_msg or 'timeout' in error_msg:
+ log_to_client(f"⚠ 执行超时: {error_msg}", user_id, account_id)
+ if attempt < max_attempts:
+ log_to_client(f"⚠ 将换新IP重试 ({attempt}/{max_attempts})", user_id, account_id)
+ time_module.sleep(2)
+ continue
+ else:
+ log_to_client(f"❌ 已达到最大重试次数({max_attempts}),任务失败", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=account.total_items,
+ total_attachments=account.total_attachments,
+ error_message=f"重试{max_attempts}次后仍失败: {error_msg}",
+ duration=int(time_module.time() - task_start_time)
+ )
+ break
+ else:
+ # 非超时异常,直接失败
+ log_to_client(f"任务执行异常: {error_msg}", user_id, account_id)
+ account.status = "出错"
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=account.total_items,
+ total_attachments=account.total_attachments,
+ error_message=error_msg,
+ duration=int(time_module.time() - task_start_time)
+ )
+ break
+
+
+ except Exception as e:
+ error_msg = str(e)
+ log_to_client(f"任务执行出错: {error_msg}", user_id, account_id)
+ account.status = "出错"
+ # 记录异常失败日志
+ database.create_task_log(
+ user_id=user_id,
+ account_id=account_id,
+ username=account.username,
+ browse_type=browse_type,
+ status='failed',
+ total_items=account.total_items,
+ total_attachments=account.total_attachments,
+ error_message=error_msg,
+ duration=int(time_module.time() - task_start_time)
+ )
+
+ finally:
+ # 释放全局信号量
+ global_semaphore.release()
+
+ account.is_running = False
+
+ if account.automation:
+ try:
+ account.automation.close()
+ log_to_client(f"主任务浏览器已关闭", user_id, account_id)
+ except Exception as e:
+ log_to_client(f"关闭主任务浏览器时出错: {str(e)}", user_id, account_id)
+ finally:
+ account.automation = None
+
+ if account_id in active_tasks:
+ del active_tasks[account_id]
+
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ # 任务完成后自动截图(增加2秒延迟,确保资源完全释放)
+ # 根据enable_screenshot参数决定是否截图
+ if account.status == "已完成" and not account.should_stop:
+ if enable_screenshot:
+ log_to_client(f"等待2秒后开始截图...", user_id, account_id)
+ time.sleep(2) # 延迟启动截图,确保主任务资源已完全释放
+ threading.Thread(target=take_screenshot_for_account, args=(user_id, account_id), daemon=True).start()
+ else:
+ log_to_client(f"截图功能已禁用,跳过截图", user_id, account_id)
+
+ finally:
+ # 释放用户级信号量
+ user_sem.release()
+
+
+def take_screenshot_for_account(user_id, account_id):
+ """为账号任务完成后截图(带并发控制,避免资源竞争)"""
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return
+
+ account = user_accounts[user_id][account_id]
+
+ # 使用截图信号量,确保同时只有1个截图任务在执行
+ log_to_client(f"等待截图资源分配...", user_id, account_id)
+ screenshot_acquired = screenshot_semaphore.acquire(blocking=True, timeout=300) # 最多等待5分钟
+
+ if not screenshot_acquired:
+ log_to_client(f"截图资源获取超时,跳过截图", user_id, account_id)
+ return
+
+ automation = None
+ try:
+ log_to_client(f"开始截图流程...", user_id, account_id)
+
+ # 使用与浏览任务相同的代理配置
+ proxy_config = account.proxy_config if hasattr(account, 'proxy_config') else None
+ if proxy_config:
+ log_to_client(f"截图将使用相同代理: {proxy_config.get('server', 'Unknown')}", user_id, account_id)
+
+ automation = PlaywrightAutomation(browser_manager, account_id, proxy_config=proxy_config)
+
+ # 为截图automation也注入自定义log方法
+ def custom_log(message: str):
+ log_to_client(message, user_id, account_id)
+ automation.log = custom_log
+
+ log_to_client(f"重新登录以进行截图...", user_id, account_id)
+ if not automation.login(account.username, account.password, account.remember):
+ log_to_client(f"截图登录失败", user_id, account_id)
+ return
+
+ browse_type = account.last_browse_type
+ log_to_client(f"导航到 '{browse_type}' 页面...", user_id, account_id)
+
+ # 不使用should_stop_callback,让页面加载完成显示"暂无记录"
+ result = automation.browse_content(
+ browse_type=browse_type,
+ auto_next_page=False,
+ auto_view_attachments=False,
+ interval=0,
+ should_stop_callback=None
+ )
+
+ if not result.success and result.error_message != "":
+ log_to_client(f"导航失败: {result.error_message}", user_id, account_id)
+
+ time.sleep(2)
+
+ # 生成截图文件名(使用北京时间并简化格式)
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ now_beijing = datetime.now(beijing_tz)
+ timestamp = now_beijing.strftime('%Y%m%d_%H%M%S')
+
+ # 简化文件名:用户名_登录账号_浏览类型_时间.jpg
+ # 获取用户名前缀
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+ # 使用登录账号(account.username)而不是备注
+ login_account = account.username
+ screenshot_filename = f"{username_prefix}_{login_account}_{browse_type}_{timestamp}.jpg"
+ screenshot_path = os.path.join(SCREENSHOTS_DIR, screenshot_filename)
+
+ if automation.take_screenshot(screenshot_path):
+ log_to_client(f"✓ 截图已保存: {screenshot_filename}", user_id, account_id)
+ else:
+ log_to_client(f"✗ 截图失败", user_id, account_id)
+
+ except Exception as e:
+ log_to_client(f"✗ 截图过程中出错: {str(e)}", user_id, account_id)
+
+ finally:
+ # 确保浏览器资源被正确关闭
+ if automation:
+ try:
+ automation.close()
+ log_to_client(f"截图浏览器已关闭", user_id, account_id)
+ except Exception as e:
+ log_to_client(f"关闭截图浏览器时出错: {str(e)}", user_id, account_id)
+
+ # 释放截图信号量
+ screenshot_semaphore.release()
+ log_to_client(f"截图资源已释放", user_id, account_id)
+
+
+@app.route('/api/accounts//screenshot', methods=['POST'])
+@login_required
+def manual_screenshot(account_id):
+ """手动为指定账号截图"""
+ user_id = current_user.id
+
+ if user_id not in user_accounts or account_id not in user_accounts[user_id]:
+ return jsonify({"error": "账号不存在"}), 404
+
+ account = user_accounts[user_id][account_id]
+ if account.is_running:
+ return jsonify({"error": "任务运行中,无法截图"}), 400
+
+ data = request.json or {}
+ browse_type = data.get('browse_type', account.last_browse_type)
+
+ account.last_browse_type = browse_type
+
+ threading.Thread(target=take_screenshot_for_account, args=(user_id, account_id), daemon=True).start()
+ log_to_client(f"手动截图: {account.username} - {browse_type}", user_id)
+ return jsonify({"success": True})
+
+
+# ==================== 截图管理API ====================
+
+@app.route('/api/screenshots', methods=['GET'])
+@login_required
+def get_screenshots():
+ """获取当前用户的截图列表"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ try:
+ screenshots = []
+ if os.path.exists(SCREENSHOTS_DIR):
+ for filename in os.listdir(SCREENSHOTS_DIR):
+ # 只显示属于当前用户的截图(支持png和jpg格式)
+ if (filename.lower().endswith(('.png', '.jpg', '.jpeg'))) and filename.startswith(username_prefix + '_'):
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ stat = os.stat(filepath)
+ # 转换为北京时间
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ created_time = datetime.fromtimestamp(stat.st_mtime, tz=beijing_tz)
+ # 解析文件名获取显示名称
+ # 文件名格式:用户名_登录账号_浏览类型_时间.jpg
+ parts = filename.rsplit('.', 1)[0].split('_', 1) # 移除扩展名并分割
+ if len(parts) > 1:
+ # 显示名称:登录账号_浏览类型_时间.jpg
+ display_name = parts[1] + '.' + filename.rsplit('.', 1)[1]
+ else:
+ display_name = filename
+
+ screenshots.append({
+ 'filename': filename,
+ 'display_name': display_name,
+ 'size': stat.st_size,
+ 'created': created_time.strftime('%Y-%m-%d %H:%M:%S')
+ })
+ screenshots.sort(key=lambda x: x['created'], reverse=True)
+ return jsonify(screenshots)
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+@app.route('/screenshots/')
+@login_required
+def serve_screenshot(filename):
+ """提供截图文件访问"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ # 验证文件属于当前用户
+ if not filename.startswith(username_prefix + '_'):
+ return jsonify({"error": "无权访问"}), 403
+
+ return send_from_directory(SCREENSHOTS_DIR, filename)
+
+
+@app.route('/api/screenshots/', methods=['DELETE'])
+@login_required
+def delete_screenshot(filename):
+ """删除指定截图"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ # 验证文件属于当前用户
+ if not filename.startswith(username_prefix + '_'):
+ return jsonify({"error": "无权删除"}), 403
+
+ try:
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ if os.path.exists(filepath):
+ os.remove(filepath)
+ log_to_client(f"删除截图: {filename}", user_id)
+ return jsonify({"success": True})
+ else:
+ return jsonify({"error": "文件不存在"}), 404
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+@app.route('/api/screenshots/clear', methods=['POST'])
+@login_required
+def clear_all_screenshots():
+ """清空当前用户的所有截图"""
+ user_id = current_user.id
+ user_info = database.get_user_by_id(user_id)
+ username_prefix = user_info['username'] if user_info else f"user{user_id}"
+
+ try:
+ deleted_count = 0
+ if os.path.exists(SCREENSHOTS_DIR):
+ for filename in os.listdir(SCREENSHOTS_DIR):
+ if (filename.lower().endswith(('.png', '.jpg', '.jpeg'))) and filename.startswith(username_prefix + '_'):
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ os.remove(filepath)
+ deleted_count += 1
+ log_to_client(f"清理了 {deleted_count} 个截图文件", user_id)
+ return jsonify({"success": True, "deleted": deleted_count})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+# ==================== WebSocket事件 ====================
+
+@socketio.on('connect')
+def handle_connect():
+ """客户端连接"""
+ if current_user.is_authenticated:
+ user_id = current_user.id
+ join_room(f'user_{user_id}')
+ log_to_client("客户端已连接", user_id)
+
+ # 发送账号列表
+ accounts = user_accounts.get(user_id, {})
+ emit('accounts_list', [acc.to_dict() for acc in accounts.values()])
+
+ # 发送历史日志
+ if user_id in log_cache:
+ for log_entry in log_cache[user_id]:
+ emit('log', log_entry)
+
+
+@socketio.on('disconnect')
+def handle_disconnect():
+ """客户端断开"""
+ if current_user.is_authenticated:
+ user_id = current_user.id
+ leave_room(f'user_{user_id}')
+
+
+# ==================== 静态文件 ====================
+
+@app.route('/static/')
+def serve_static(filename):
+ """提供静态文件访问"""
+ return send_from_directory('static', filename)
+
+
+# ==================== 启动 ====================
+
+
+# ==================== 管理员VIP管理API ====================
+
+@app.route('/yuyx/api/vip/config', methods=['GET'])
+def get_vip_config_api():
+ """获取VIP配置"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+ config = database.get_vip_config()
+ return jsonify(config)
+
+
+@app.route('/yuyx/api/vip/config', methods=['POST'])
+def set_vip_config_api():
+ """设置默认VIP天数"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ data = request.json
+ days = data.get('default_vip_days', 0)
+
+ if not isinstance(days, int) or days < 0:
+ return jsonify({"error": "VIP天数必须是非负整数"}), 400
+
+ database.set_default_vip_days(days)
+ return jsonify({"message": "VIP配置已更新", "default_vip_days": days})
+
+
+@app.route('/yuyx/api/users//vip', methods=['POST'])
+def set_user_vip_api(user_id):
+ """设置用户VIP"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ data = request.json
+ days = data.get('days', 30)
+
+ # 验证days参数
+ valid_days = [7, 30, 365, 999999]
+ if days not in valid_days:
+ return jsonify({"error": "VIP天数必须是 7/30/365/999999 之一"}), 400
+
+ if database.set_user_vip(user_id, days):
+ vip_type = {7: "一周", 30: "一个月", 365: "一年", 999999: "永久"}[days]
+ return jsonify({"message": f"VIP设置成功: {vip_type}"})
+ return jsonify({"error": "设置失败,用户不存在"}), 400
+
+
+@app.route('/yuyx/api/users//vip', methods=['DELETE'])
+def remove_user_vip_api(user_id):
+ """移除用户VIP"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ if database.remove_user_vip(user_id):
+ return jsonify({"message": "VIP已移除"})
+ return jsonify({"error": "移除失败"}), 400
+
+
+@app.route('/yuyx/api/users//vip', methods=['GET'])
+def get_user_vip_info_api(user_id):
+ """获取用户VIP信息(管理员)"""
+ if 'admin_id' not in session:
+ return jsonify({"error": "需要管理员权限"}), 403
+
+ vip_info = database.get_user_vip_info(user_id)
+ return jsonify(vip_info)
+
+
+
+# ==================== 用户端VIP查询API ====================
+
+@app.route('/api/user/vip', methods=['GET'])
+@login_required
+def get_current_user_vip():
+ """获取当前用户VIP信息"""
+ vip_info = database.get_user_vip_info(current_user.id)
+ return jsonify(vip_info)
+
+
+@app.route('/api/run_stats', methods=['GET'])
+@login_required
+def get_run_stats():
+ """获取当前用户的运行统计"""
+ user_id = current_user.id
+
+ # 获取今日任务统计
+ stats = database.get_user_run_stats(user_id)
+
+ # 计算当前正在运行的账号数
+ current_running = 0
+ if user_id in user_accounts:
+ current_running = sum(1 for acc in user_accounts[user_id].values() if acc.is_running)
+
+ return jsonify({
+ 'today_completed': stats.get('completed', 0),
+ 'current_running': current_running,
+ 'today_failed': stats.get('failed', 0),
+ 'today_items': stats.get('total_items', 0),
+ 'today_attachments': stats.get('total_attachments', 0)
+ })
+
+
+# ==================== 系统配置API ====================
+
+@app.route('/yuyx/api/system/config', methods=['GET'])
+@admin_required
+def get_system_config_api():
+ """获取系统配置"""
+ config = database.get_system_config()
+ return jsonify(config)
+
+
+@app.route('/yuyx/api/system/config', methods=['POST'])
+@admin_required
+def update_system_config_api():
+ """更新系统配置"""
+ global max_concurrent_global, global_semaphore, max_concurrent_per_account
+
+ data = request.json
+ max_concurrent = data.get('max_concurrent_global')
+ schedule_enabled = data.get('schedule_enabled')
+ schedule_time = data.get('schedule_time')
+ schedule_browse_type = data.get('schedule_browse_type')
+ schedule_weekdays = data.get('schedule_weekdays')
+ new_max_concurrent_per_account = data.get('max_concurrent_per_account')
+
+ # 验证参数
+ if max_concurrent is not None:
+ if not isinstance(max_concurrent, int) or max_concurrent < 1 or max_concurrent > 20:
+ return jsonify({"error": "全局并发数必须在1-20之间"}), 400
+
+ if new_max_concurrent_per_account is not None:
+ if not isinstance(new_max_concurrent_per_account, int) or new_max_concurrent_per_account < 1 or new_max_concurrent_per_account > 5:
+ return jsonify({"error": "单账号并发数必须在1-5之间"}), 400
+
+ if schedule_time is not None:
+ # 验证时间格式 HH:MM
+ import re
+ if not re.match(r'^([01]\d|2[0-3]):([0-5]\d)$', schedule_time):
+ return jsonify({"error": "时间格式错误,应为 HH:MM"}), 400
+
+ if schedule_browse_type is not None:
+ if schedule_browse_type not in ['注册前未读', '应读', '未读']:
+ return jsonify({"error": "浏览类型无效"}), 400
+
+ if schedule_weekdays is not None:
+ # 验证星期格式,应该是逗号分隔的数字字符串 "1,2,3,4,5,6,7"
+ try:
+ days = [int(d.strip()) for d in schedule_weekdays.split(',') if d.strip()]
+ if not all(1 <= d <= 7 for d in days):
+ return jsonify({"error": "星期数字必须在1-7之间"}), 400
+ except (ValueError, AttributeError):
+ return jsonify({"error": "星期格式错误"}), 400
+
+ # 更新数据库
+ if database.update_system_config(
+ max_concurrent=max_concurrent,
+ schedule_enabled=schedule_enabled,
+ schedule_time=schedule_time,
+ schedule_browse_type=schedule_browse_type,
+ schedule_weekdays=schedule_weekdays,
+ max_concurrent_per_account=new_max_concurrent_per_account
+ ):
+ # 如果修改了并发数,更新全局变量和信号量
+ if max_concurrent is not None and max_concurrent != max_concurrent_global:
+ max_concurrent_global = max_concurrent
+ global_semaphore = threading.Semaphore(max_concurrent)
+ print(f"全局并发数已更新为: {max_concurrent}")
+
+ # 如果修改了单用户并发数,更新全局变量(已有的信号量会在下次创建时使用新值)
+ if new_max_concurrent_per_account is not None and new_max_concurrent_per_account != max_concurrent_per_account:
+ max_concurrent_per_account = new_max_concurrent_per_account
+ print(f"单用户并发数已更新为: {max_concurrent_per_account}")
+
+ return jsonify({"message": "系统配置已更新"})
+
+ return jsonify({"error": "更新失败"}), 400
+
+
+
+
+# ==================== 代理配置API ====================
+
+@app.route('/yuyx/api/proxy/config', methods=['GET'])
+@admin_required
+def get_proxy_config_api():
+ """获取代理配置"""
+ config = database.get_system_config()
+ return jsonify({
+ 'proxy_enabled': config.get('proxy_enabled', 0),
+ 'proxy_api_url': config.get('proxy_api_url', ''),
+ 'proxy_expire_minutes': config.get('proxy_expire_minutes', 3)
+ })
+
+
+@app.route('/yuyx/api/proxy/config', methods=['POST'])
+@admin_required
+def update_proxy_config_api():
+ """更新代理配置"""
+ data = request.json
+ proxy_enabled = data.get('proxy_enabled')
+ proxy_api_url = data.get('proxy_api_url', '').strip()
+ proxy_expire_minutes = data.get('proxy_expire_minutes')
+
+ if proxy_enabled is not None and proxy_enabled not in [0, 1]:
+ return jsonify({"error": "proxy_enabled必须是0或1"}), 400
+
+ if proxy_expire_minutes is not None:
+ if not isinstance(proxy_expire_minutes, int) or proxy_expire_minutes < 1:
+ return jsonify({"error": "代理有效期必须是大于0的整数"}), 400
+
+ if database.update_system_config(
+ proxy_enabled=proxy_enabled,
+ proxy_api_url=proxy_api_url,
+ proxy_expire_minutes=proxy_expire_minutes
+ ):
+ return jsonify({"message": "代理配置已更新"})
+
+ return jsonify({"error": "更新失败"}), 400
+
+
+@app.route('/yuyx/api/proxy/test', methods=['POST'])
+@admin_required
+def test_proxy_api():
+ """测试代理连接"""
+ data = request.json
+ api_url = data.get('api_url', '').strip()
+
+ if not api_url:
+ return jsonify({"error": "请提供API地址"}), 400
+
+ try:
+ response = requests.get(api_url, timeout=10)
+ if response.status_code == 200:
+ ip_port = response.text.strip()
+ if ip_port and ':' in ip_port:
+ return jsonify({
+ "success": True,
+ "proxy": ip_port,
+ "message": f"代理获取成功: {ip_port}"
+ })
+ else:
+ return jsonify({
+ "success": False,
+ "message": f"代理格式错误: {ip_port}"
+ }), 400
+ else:
+ return jsonify({
+ "success": False,
+ "message": f"HTTP错误: {response.status_code}"
+ }), 400
+ except Exception as e:
+ return jsonify({
+ "success": False,
+ "message": f"连接失败: {str(e)}"
+ }), 500
+
+# ==================== 服务器信息API ====================
+
+@app.route('/yuyx/api/server/info', methods=['GET'])
+@admin_required
+def get_server_info_api():
+ """获取服务器信息"""
+ import psutil
+ import datetime
+
+ # CPU使用率
+ cpu_percent = psutil.cpu_percent(interval=1)
+
+ # 内存信息
+ memory = psutil.virtual_memory()
+ memory_total = f"{memory.total / (1024**3):.1f}GB"
+ memory_used = f"{memory.used / (1024**3):.1f}GB"
+ memory_percent = memory.percent
+
+ # 磁盘信息
+ disk = psutil.disk_usage('/')
+ disk_total = f"{disk.total / (1024**3):.1f}GB"
+ disk_used = f"{disk.used / (1024**3):.1f}GB"
+ disk_percent = disk.percent
+
+ # 运行时长
+ boot_time = datetime.datetime.fromtimestamp(psutil.boot_time())
+ uptime_delta = datetime.datetime.now() - boot_time
+ days = uptime_delta.days
+ hours = uptime_delta.seconds // 3600
+ uptime = f"{days}天{hours}小时"
+
+ return jsonify({
+ 'cpu_percent': cpu_percent,
+ 'memory_total': memory_total,
+ 'memory_used': memory_used,
+ 'memory_percent': memory_percent,
+ 'disk_total': disk_total,
+ 'disk_used': disk_used,
+ 'disk_percent': disk_percent,
+ 'uptime': uptime
+ })
+
+
+# ==================== 任务统计和日志API ====================
+
+@app.route('/yuyx/api/task/stats', methods=['GET'])
+@admin_required
+def get_task_stats_api():
+ """获取任务统计数据"""
+ date_filter = request.args.get('date') # YYYY-MM-DD格式
+ stats = database.get_task_stats(date_filter)
+ return jsonify(stats)
+
+
+@app.route('/yuyx/api/task/logs', methods=['GET'])
+@admin_required
+def get_task_logs_api():
+ """获取任务日志列表"""
+ limit = int(request.args.get('limit', 100))
+ offset = int(request.args.get('offset', 0))
+ date_filter = request.args.get('date') # YYYY-MM-DD格式
+ status_filter = request.args.get('status') # success/failed
+
+ logs = database.get_task_logs(limit, offset, date_filter, status_filter)
+ return jsonify(logs)
+
+
+@app.route('/yuyx/api/task/logs/clear', methods=['POST'])
+@admin_required
+def clear_old_task_logs_api():
+ """清理旧的任务日志"""
+ data = request.json or {}
+ days = data.get('days', 30)
+
+ if not isinstance(days, int) or days < 1:
+ return jsonify({"error": "天数必须是大于0的整数"}), 400
+
+ deleted_count = database.delete_old_task_logs(days)
+ return jsonify({"message": f"已删除{days}天前的{deleted_count}条日志"})
+
+
+# ==================== 定时任务调度器 ====================
+
+def scheduled_task_worker():
+ """定时任务工作线程"""
+ import schedule
+ from datetime import datetime
+
+ def run_all_accounts_task():
+ """执行所有账号的浏览任务(过滤重复账号)"""
+ try:
+ config = database.get_system_config()
+ browse_type = config.get('schedule_browse_type', '应读')
+
+ # 检查今天是否在允许执行的星期列表中
+ from datetime import datetime
+ import pytz
+
+ # 获取北京时间的星期几 (1=周一, 7=周日)
+ beijing_tz = pytz.timezone('Asia/Shanghai')
+ now_beijing = datetime.now(beijing_tz)
+ current_weekday = now_beijing.isoweekday() # 1-7
+
+ # 获取配置的星期列表
+ schedule_weekdays = config.get('schedule_weekdays', '1,2,3,4,5,6,7')
+ allowed_weekdays = [int(d.strip()) for d in schedule_weekdays.split(',') if d.strip()]
+
+ if current_weekday not in allowed_weekdays:
+ weekday_names = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
+ print(f"[定时任务] 今天是{weekday_names[current_weekday]},不在执行日期内,跳过执行")
+ return
+
+ print(f"[定时任务] 开始执行 - 浏览类型: {browse_type}")
+
+ # 获取所有已审核用户的所有账号
+ all_users = database.get_all_users()
+ approved_users = [u for u in all_users if u['status'] == 'approved']
+
+ # 用于记录已执行的账号用户名,避免重复
+ executed_usernames = set()
+ total_accounts = 0
+ skipped_duplicates = 0
+ executed_accounts = 0
+
+ for user in approved_users:
+ user_id = user['id']
+ if user_id not in user_accounts:
+ load_user_accounts(user_id)
+
+ accounts = user_accounts.get(user_id, {})
+ for account_id, account in accounts.items():
+ total_accounts += 1
+
+ # 跳过正在运行的账号
+ if account.is_running:
+ continue
+
+ # 检查账号用户名是否已经执行过(重复账号过滤)
+ if account.username in executed_usernames:
+ skipped_duplicates += 1
+ print(f"[定时任务] 跳过重复账号: {account.username} (用户:{user['username']}) - 该账号已被其他用户执行")
+ continue
+
+ # 记录该账号用户名,避免后续重复执行
+ executed_usernames.add(account.username)
+
+ print(f"[定时任务] 启动账号: {account.username} (用户:{user['username']})")
+
+ # 启动任务
+ account.is_running = True
+ account.should_stop = False
+ account.status = "运行中"
+
+ # 获取系统配置的截图开关
+ config = database.get_system_config()
+ enable_screenshot_scheduled = config.get("enable_screenshot", 0) == 1
+
+ thread = threading.Thread(
+ target=run_task,
+ args=(user_id, account_id, browse_type, enable_screenshot_scheduled),
+ daemon=True
+ )
+ thread.start()
+ active_tasks[account_id] = thread
+ executed_accounts += 1
+
+ # 发送更新到用户
+ socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
+
+ # 间隔启动,避免瞬间并发过高
+ time.sleep(2)
+
+ print(f"[定时任务] 执行完成 - 总账号数:{total_accounts}, 已执行:{executed_accounts}, 跳过重复:{skipped_duplicates}")
+
+ except Exception as e:
+ print(f"[定时任务] 执行出错: {str(e)}")
+
+ def cleanup_expired_captcha():
+ """清理过期验证码,防止内存泄漏"""
+ try:
+ current_time = time.time()
+ expired_keys = [k for k, v in captcha_storage.items()
+ if v["expire_time"] < current_time]
+ deleted_count = len(expired_keys)
+ for k in expired_keys:
+ del captcha_storage[k]
+ if deleted_count > 0:
+ print(f"[定时清理] 已清理 {deleted_count} 个过期验证码")
+ except Exception as e:
+ print(f"[定时清理] 清理验证码出错: {str(e)}")
+
+ def cleanup_old_data():
+ """清理7天前的截图和日志"""
+ try:
+ print(f"[定时清理] 开始清理7天前的数据...")
+
+ # 清理7天前的任务日志
+ deleted_logs = database.delete_old_task_logs(7)
+ print(f"[定时清理] 已删除 {deleted_logs} 条任务日志")
+
+ # 清理30天前的操作日志
+ deleted_operation_logs = database.clean_old_operation_logs(30)
+ print(f"[定时清理] 已删除 {deleted_operation_logs} 条操作日志")
+ # 清理7天前的截图
+ deleted_screenshots = 0
+ if os.path.exists(SCREENSHOTS_DIR):
+ cutoff_time = time.time() - (7 * 24 * 60 * 60) # 7天前的时间戳
+ for filename in os.listdir(SCREENSHOTS_DIR):
+ if filename.lower().endswith(('.png', '.jpg', '.jpeg')):
+ filepath = os.path.join(SCREENSHOTS_DIR, filename)
+ try:
+ # 检查文件修改时间
+ if os.path.getmtime(filepath) < cutoff_time:
+ os.remove(filepath)
+ deleted_screenshots += 1
+ except Exception as e:
+ print(f"[定时清理] 删除截图失败 {filename}: {str(e)}")
+
+ print(f"[定时清理] 已删除 {deleted_screenshots} 个截图文件")
+ print(f"[定时清理] 清理完成!")
+
+ except Exception as e:
+ print(f"[定时清理] 清理任务出错: {str(e)}")
+
+ # 每分钟检查一次配置
+ def check_and_schedule():
+ config = database.get_system_config()
+
+ # 清除旧的任务
+ schedule.clear()
+
+ # 时区转换函数:将CST时间转换为UTC时间(容器使用UTC)
+ def cst_to_utc_time(cst_time_str):
+ """将CST时间字符串(HH:MM)转换为UTC时间字符串
+
+ Args:
+ cst_time_str: CST时间字符串,格式为 HH:MM
+
+ Returns:
+ UTC时间字符串,格式为 HH:MM
+ """
+ from datetime import datetime, timedelta
+ # 解析CST时间
+ hour, minute = map(int, cst_time_str.split(':'))
+ # CST是UTC+8,所以UTC时间 = CST时间 - 8小时
+ utc_hour = (hour - 8) % 24
+ return f"{utc_hour:02d}:{minute:02d}"
+
+ # 始终添加每天凌晨3点(CST)的数据清理任务
+ cleanup_utc_time = cst_to_utc_time("03:00")
+ schedule.every().day.at(cleanup_utc_time).do(cleanup_old_data)
+ print(f"[定时任务] 已设置数据清理任务: 每天 CST 03:00 (UTC {cleanup_utc_time})")
+
+ # 每小时清理过期验证码
+ schedule.every().hour.do(cleanup_expired_captcha)
+ print(f"[定时任务] 已设置验证码清理任务: 每小时执行一次")
+
+ # 如果启用了定时浏览任务,则添加
+ if config.get('schedule_enabled'):
+ schedule_time_cst = config.get('schedule_time', '02:00')
+ schedule_time_utc = cst_to_utc_time(schedule_time_cst)
+ schedule.every().day.at(schedule_time_utc).do(run_all_accounts_task)
+ print(f"[定时任务] 已设置浏览任务: 每天 CST {schedule_time_cst} (UTC {schedule_time_utc})")
+
+ # 初始检查
+ check_and_schedule()
+ last_check = time.time()
+
+ while True:
+ try:
+ # 执行待执行的任务
+ schedule.run_pending()
+
+ # 每60秒重新检查一次配置
+ if time.time() - last_check > 60:
+ check_and_schedule()
+ last_check = time.time()
+
+ time.sleep(1)
+ except Exception as e:
+ print(f"[定时任务] 调度器出错: {str(e)}")
+ time.sleep(5)
+
+
+if __name__ == '__main__':
+ print("=" * 60)
+ print("知识管理平台自动化工具 - 多用户版")
+ print("=" * 60)
+
+ # 初始化数据库
+ database.init_database()
+
+ # 加载系统配置(并发设置)
+ try:
+ config = database.get_system_config()
+ if config:
+ # 使用globals()修改全局变量
+ globals()['max_concurrent_global'] = config.get('max_concurrent_global', 2)
+ globals()['max_concurrent_per_account'] = config.get('max_concurrent_per_account', 1)
+
+ # 重新创建信号量
+ globals()['global_semaphore'] = threading.Semaphore(globals()['max_concurrent_global'])
+
+ print(f"✓ 已加载并发配置: 全局={globals()['max_concurrent_global']}, 单账号={globals()['max_concurrent_per_account']}")
+ except Exception as e:
+ print(f"警告: 加载并发配置失败,使用默认值: {e}")
+
+ # 主线程初始化浏览器(Playwright不支持跨线程)
+ print("\n正在初始化浏览器管理器...")
+ init_browser_manager()
+
+ # 启动定时任务调度器
+ print("\n启动定时任务调度器...")
+ scheduler_thread = threading.Thread(target=scheduled_task_worker, daemon=True)
+ scheduler_thread.start()
+ print("✓ 定时任务调度器已启动")
+
+ # 启动Web服务器
+ print("\n服务器启动中...")
+ print("用户访问地址: http://0.0.0.0:5000")
+ print("后台管理地址: http://0.0.0.0:5000/yuyx")
+ print("默认管理员: admin/admin")
+ print("=" * 60 + "\n")
+
+ socketio.run(app, host='0.0.0.0', port=5000, debug=False)
diff --git a/app_config.py b/app_config.py
index 30d59e7..790ea44 100755
--- a/app_config.py
+++ b/app_config.py
@@ -7,6 +7,18 @@
import os
from datetime import timedelta
+from pathlib import Path
+
+# 尝试加载.env文件(如果存在)
+try:
+ from dotenv import load_dotenv
+ env_path = Path(__file__).parent / '.env'
+ if env_path.exists():
+ load_dotenv(dotenv_path=env_path)
+ print(f"✓ 已加载环境变量文件: {env_path}")
+except ImportError:
+ # python-dotenv未安装,跳过
+ pass
# 常量定义
@@ -43,7 +55,12 @@ class Config:
# ==================== 会话安全配置 ====================
SESSION_COOKIE_SECURE = os.environ.get('SESSION_COOKIE_SECURE', 'False').lower() == 'true'
SESSION_COOKIE_HTTPONLY = True # 防止XSS攻击
- SESSION_COOKIE_SAMESITE = 'Lax' # 防止CSRF攻击
+ # SameSite配置:HTTP环境使用Lax,HTTPS环境使用None
+ SESSION_COOKIE_SAMESITE = 'None' if os.environ.get('SESSION_COOKIE_SECURE', 'False').lower() == 'true' else 'Lax'
+ # 自定义cookie名称,避免与其他应用冲突
+ 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')))
# ==================== 数据库配置 ====================
@@ -73,11 +90,20 @@ class Config:
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')
+ 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'))
+
# ==================== SocketIO配置 ====================
SOCKETIO_CORS_ALLOWED_ORIGINS = os.environ.get('SOCKETIO_CORS_ALLOWED_ORIGINS', '*')
# ==================== 日志配置 ====================
- LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
+ LOG_LEVEL = os.environ.get('LOG_LEVEL', 'DEBUG')
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'))
@@ -138,13 +164,14 @@ class Config:
class DevelopmentConfig(Config):
"""开发环境配置"""
DEBUG = True
- SESSION_COOKIE_SECURE = False
+ # 不覆盖SESSION_COOKIE_SECURE,使用父类的环境变量配置
class ProductionConfig(Config):
"""生产环境配置"""
DEBUG = False
- SESSION_COOKIE_SECURE = True # 生产环境必须使用HTTPS
+ # 不覆盖SESSION_COOKIE_SECURE,使用父类的环境变量配置
+ # 如需HTTPS,请在环境变量中设置 SESSION_COOKIE_SECURE=true
class TestingConfig(Config):
diff --git a/app_utils.py b/app_utils.py
index bb76c53..0af0a0b 100755
--- a/app_utils.py
+++ b/app_utils.py
@@ -280,6 +280,59 @@ def check_user_ownership(user_id: int, resource_type: str,
return False, "系统错误"
+def verify_and_consume_captcha(session_id: str, code: str, captcha_storage: dict, max_attempts: int = 5) -> Tuple[bool, str]:
+ """
+ 验证并消费验证码
+
+ Args:
+ session_id: 验证码会话ID
+ code: 用户输入的验证码
+ captcha_storage: 验证码存储字典
+ max_attempts: 最大尝试次数,默认5次
+
+ Returns:
+ Tuple[bool, str]: (是否成功, 消息)
+ - 成功时返回 (True, "验证成功")
+ - 失败时返回 (False, 错误消息)
+
+ Example:
+ success, message = verify_and_consume_captcha(
+ captcha_session,
+ captcha_code,
+ captcha_storage,
+ max_attempts=5
+ )
+ if not success:
+ return jsonify({"error": message}), 400
+ """
+ import time
+
+ # 检查验证码是否存在
+ if session_id not in captcha_storage:
+ return False, "验证码已过期或不存在,请重新获取"
+
+ captcha_data = captcha_storage[session_id]
+
+ # 检查过期时间
+ if captcha_data["expire_time"] < time.time():
+ del captcha_storage[session_id]
+ return False, "验证码已过期,请重新获取"
+
+ # 检查尝试次数
+ if captcha_data.get("failed_attempts", 0) >= max_attempts:
+ del captcha_storage[session_id]
+ return False, f"验证码错误次数过多({max_attempts}次),请重新获取"
+
+ # 验证代码(不区分大小写)
+ if captcha_data["code"].lower() != code.lower():
+ captcha_data["failed_attempts"] = captcha_data.get("failed_attempts", 0) + 1
+ return False, "验证码错误"
+
+ # 验证成功,删除验证码(防止重复使用)
+ del captcha_storage[session_id]
+ return True, "验证成功"
+
+
if __name__ == '__main__':
# 测试代码
print("测试应用工具模块...")
diff --git a/browser_pool.py b/browser_pool.py
new file mode 100755
index 0000000..1876988
--- /dev/null
+++ b/browser_pool.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""浏览器池管理 - 线程本地存储,每个线程复用自己的浏览器"""
+
+import threading
+import time
+import nest_asyncio
+nest_asyncio.apply()
+
+# 线程本地存储
+_thread_local = threading.local()
+
+
+class BrowserPool:
+ """浏览器池 - 使用线程本地存储,每个线程有自己的浏览器"""
+
+ def __init__(self, pool_size=3, log_callback=None):
+ self.pool_size = pool_size
+ self.log_callback = log_callback
+ self.lock = threading.Lock()
+ self.all_browsers = [] # 追踪所有浏览器(用于关闭)
+ self.initialized = True
+
+ def log(self, message):
+ if self.log_callback:
+ self.log_callback(message)
+ else:
+ print(f"[浏览器池] {message}")
+
+ def initialize(self):
+ """初始化(线程本地模式下不预热)"""
+ self.log(f"浏览器池已就绪(线程本地模式,每线程独立浏览器)")
+ self.initialized = True
+
+ def _create_browser(self):
+ """创建一个浏览器实例"""
+ try:
+ from playwright.sync_api import sync_playwright
+
+ playwright = sync_playwright().start()
+ browser = playwright.chromium.launch(
+ headless=True,
+ args=[
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ '--disable-dev-shm-usage',
+ '--disable-gpu',
+ '--single-process'
+ ]
+ )
+ instance = {
+ 'playwright': playwright,
+ 'browser': browser,
+ 'thread_id': threading.current_thread().ident,
+ 'created_at': time.time(),
+ 'use_count': 0
+ }
+ with self.lock:
+ self.all_browsers.append(instance)
+ return instance
+ except Exception as e:
+ self.log(f"创建浏览器失败: {e}")
+ return None
+
+ def acquire(self, timeout=60):
+ """获取当前线程的浏览器实例(如果没有则创建)"""
+ # 检查当前线程是否已有浏览器
+ browser_instance = getattr(_thread_local, 'browser_instance', None)
+
+ if browser_instance:
+ # 检查浏览器是否还有效
+ try:
+ if browser_instance['browser'].is_connected():
+ browser_instance['use_count'] += 1
+ self.log(f"复用线程浏览器(第{browser_instance['use_count']}次使用)")
+ return browser_instance
+ except:
+ pass
+ # 浏览器已失效,清理
+ self._close_browser(browser_instance)
+ _thread_local.browser_instance = None
+
+ # 为当前线程创建新浏览器
+ self.log("为当前线程创建新浏览器...")
+ browser_instance = self._create_browser()
+ if browser_instance:
+ browser_instance['use_count'] = 1
+ _thread_local.browser_instance = browser_instance
+ return browser_instance
+
+ def release(self, browser_instance):
+ """释放浏览器(线程本地模式下保留不关闭)"""
+ if browser_instance is None:
+ return
+
+ # 检查浏览器是否还有效
+ try:
+ if browser_instance['browser'].is_connected():
+ self.log(f"浏览器保持活跃(已使用{browser_instance['use_count']}次)")
+ return
+ except:
+ pass
+
+ # 浏览器已断开,清理
+ self.log("浏览器已断开,清理资源")
+ self._close_browser(browser_instance)
+ if getattr(_thread_local, 'browser_instance', None) == browser_instance:
+ _thread_local.browser_instance = None
+
+ def _close_browser(self, browser_instance):
+ """关闭单个浏览器实例"""
+ try:
+ if browser_instance.get('browser'):
+ browser_instance['browser'].close()
+ if browser_instance.get('playwright'):
+ browser_instance['playwright'].stop()
+ with self.lock:
+ if browser_instance in self.all_browsers:
+ self.all_browsers.remove(browser_instance)
+ except Exception as e:
+ self.log(f"关闭浏览器失败: {e}")
+
+ def shutdown(self):
+ """关闭所有浏览器"""
+ self.log("正在关闭所有浏览器...")
+ for browser_instance in list(self.all_browsers):
+ self._close_browser(browser_instance)
+ self.all_browsers.clear()
+ self.initialized = False
+ self.log("浏览器池已关闭")
+
+ def get_status(self):
+ """获取池状态"""
+ return {
+ 'pool_size': self.pool_size,
+ 'total_browsers': len(self.all_browsers),
+ 'initialized': self.initialized,
+ 'mode': 'thread_local'
+ }
+
+
+# 全局浏览器池实例
+_browser_pool = None
+_pool_lock = threading.Lock()
+
+
+def get_browser_pool(pool_size=3, log_callback=None):
+ """获取全局浏览器池实例"""
+ global _browser_pool
+ with _pool_lock:
+ if _browser_pool is None:
+ _browser_pool = BrowserPool(pool_size=pool_size, log_callback=log_callback)
+ return _browser_pool
+
+
+def init_browser_pool(pool_size=3, log_callback=None):
+ """初始化浏览器池"""
+ pool = get_browser_pool(pool_size, log_callback)
+ pool.initialize()
+ return pool
diff --git a/browser_pool_worker.py b/browser_pool_worker.py
new file mode 100755
index 0000000..18dfcc6
--- /dev/null
+++ b/browser_pool_worker.py
@@ -0,0 +1,347 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""浏览器池管理 - 工作线程池模式(真正的浏览器复用)"""
+
+import threading
+import queue
+import time
+from typing import Callable, Optional, Dict, Any
+import nest_asyncio
+nest_asyncio.apply()
+
+
+class BrowserWorker(threading.Thread):
+ """浏览器工作线程 - 每个worker维护自己的浏览器"""
+
+ def __init__(self, worker_id: int, task_queue: queue.Queue, log_callback: Optional[Callable] = None):
+ super().__init__(daemon=True)
+ self.worker_id = worker_id
+ self.task_queue = task_queue
+ self.log_callback = log_callback
+ self.browser_instance = None
+ self.running = True
+ self.idle = True
+ self.total_tasks = 0
+ self.failed_tasks = 0
+
+ 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):
+ """创建浏览器实例"""
+ try:
+ from playwright.sync_api import sync_playwright
+
+ self.log("正在创建浏览器...")
+ playwright = sync_playwright().start()
+ browser = playwright.chromium.launch(
+ headless=True,
+ args=[
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ '--disable-dev-shm-usage',
+ '--disable-gpu',
+ ]
+ )
+
+ self.browser_instance = {
+ 'playwright': playwright,
+ 'browser': browser,
+ 'created_at': time.time(),
+ 'use_count': 0,
+ 'worker_id': self.worker_id
+ }
+ self.log(f"浏览器创建成功")
+ return True
+
+ except Exception as e:
+ self.log(f"创建浏览器失败: {e}")
+ return False
+
+ def _close_browser(self):
+ """关闭浏览器"""
+ if self.browser_instance:
+ try:
+ self.log("正在关闭浏览器...")
+ if self.browser_instance['browser']:
+ self.browser_instance['browser'].close()
+ if self.browser_instance['playwright']:
+ self.browser_instance['playwright'].stop()
+ self.log(f"浏览器已关闭(共处理{self.browser_instance['use_count']}个任务)")
+ except Exception as e:
+ self.log(f"关闭浏览器时出错: {e}")
+ finally:
+ self.browser_instance = None
+
+ def _check_browser_health(self) -> bool:
+ """检查浏览器是否健康"""
+ if not self.browser_instance:
+ return False
+
+ try:
+ return self.browser_instance['browser'].is_connected()
+ except:
+ return False
+
+ def _ensure_browser(self) -> bool:
+ """确保浏览器可用(如果不可用则重新创建)"""
+ if self._check_browser_health():
+ return True
+
+ # 浏览器不可用,尝试重新创建
+ self.log("浏览器不可用,尝试重新创建...")
+ self._close_browser()
+ return self._create_browser()
+
+ def run(self):
+ """工作线程主循环"""
+ self.log("Worker启动")
+
+ # 初始创建浏览器
+ if not self._create_browser():
+ self.log("初始浏览器创建失败,Worker退出")
+ return
+
+ while self.running:
+ try:
+ # 从队列获取任务(带超时,以便能响应停止信号)
+ self.idle = True
+ task = self.task_queue.get(timeout=1)
+ self.idle = False
+
+ if task is None: # None作为停止信号
+ self.log("收到停止信号")
+ break
+
+ # 确保浏览器可用
+ if not self._ensure_browser():
+ self.log("浏览器不可用,任务失败")
+ task['callback'](None, "浏览器不可用")
+ self.failed_tasks += 1
+ continue
+
+ # 执行任务
+ 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']}次使用浏览器)")
+
+ try:
+ # 将浏览器实例传递给任务函数
+ result = task_func(self.browser_instance, *task_args, **task_kwargs)
+ callback(result, None)
+ self.log(f"任务执行成功")
+
+ except Exception as e:
+ self.log(f"任务执行失败: {e}")
+ callback(None, str(e))
+ self.failed_tasks += 1
+
+ # 任务失败后,检查浏览器健康
+ if not self._check_browser_health():
+ self.log("任务失败导致浏览器异常,将在下次任务前重建")
+ self._close_browser()
+
+ except queue.Empty:
+ # 队列为空,继续等待
+ continue
+ 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
+
+
+class BrowserWorkerPool:
+ """浏览器工作线程池"""
+
+ def __init__(self, pool_size: int = 3, log_callback: Optional[Callable] = None):
+ self.pool_size = pool_size
+ self.log_callback = log_callback
+ self.task_queue = queue.Queue()
+ self.workers = []
+ self.initialized = False
+ self.lock = threading.Lock()
+
+ def log(self, message: str):
+ """日志输出"""
+ if self.log_callback:
+ self.log_callback(message)
+ else:
+ print(f"[浏览器池] {message}")
+
+ def initialize(self):
+ """初始化工作线程池"""
+ with self.lock:
+ if self.initialized:
+ return
+
+ self.log(f"正在初始化工作线程池({self.pool_size}个worker)...")
+
+ for i in range(self.pool_size):
+ worker = BrowserWorker(
+ worker_id=i + 1,
+ task_queue=self.task_queue,
+ log_callback=self.log_callback
+ )
+ worker.start()
+ self.workers.append(worker)
+
+ # 等待所有worker准备就绪
+ time.sleep(2)
+
+ self.initialized = True
+ self.log(f"✓ 工作线程池初始化完成({self.pool_size}个worker已就绪)")
+
+ 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
+
+ task = {
+ 'func': task_func,
+ 'args': args,
+ 'kwargs': kwargs,
+ 'callback': callback
+ }
+
+ self.task_queue.put(task)
+ return True
+
+ def get_stats(self) -> Dict[str, Any]:
+ """获取线程池统计信息"""
+ idle_count = sum(1 for w in self.workers if w.idle)
+ total_tasks = sum(w.total_tasks for w in self.workers)
+ failed_tasks = sum(w.failed_tasks for w in self.workers)
+
+ return {
+ 'pool_size': self.pool_size,
+ 'idle_workers': idle_count,
+ 'busy_workers': self.pool_size - 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"
+ }
+
+ 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
+
+
+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)
+
+
+def shutdown_browser_worker_pool():
+ """关闭全局浏览器工作线程池"""
+ global _global_pool
+
+ 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}")
+
+ # 创建线程池(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测试完成!")
diff --git a/data/app_data.db.backup_20251120_231807 b/data/app_data.db.backup_20251120_231807
new file mode 100644
index 0000000..4dd6845
Binary files /dev/null and b/data/app_data.db.backup_20251120_231807 differ
diff --git a/data/app_data.db.backup_20251209_202457 b/data/app_data.db.backup_20251209_202457
new file mode 100644
index 0000000..efb9ce1
Binary files /dev/null and b/data/app_data.db.backup_20251209_202457 differ
diff --git a/data/automation.db.backup_20251120_231807 b/data/automation.db.backup_20251120_231807
new file mode 100644
index 0000000..45dcd3e
Binary files /dev/null and b/data/automation.db.backup_20251120_231807 differ
diff --git a/data/cookies/1f73c4ed633ccd7b15179a7f39927141.json b/data/cookies/1f73c4ed633ccd7b15179a7f39927141.json
new file mode 100644
index 0000000..716ea2d
--- /dev/null
+++ b/data/cookies/1f73c4ed633ccd7b15179a7f39927141.json
@@ -0,0 +1 @@
+{"cookies": [{"name": "ASP.NET_SessionId", "value": "owaevbqacwruvgb2iwy5cuyq", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=auqf0225&Pwd=F4125F6ACB6BBF4610DEED7C6AF71EAD", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796840391.77388, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168391.773932, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "auqf0225", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168391.773971, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "F4125F6ACB6BBF4610DEED7C6AF71EAD", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168391.774006, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
\ No newline at end of file
diff --git a/data/cookies/258d90a0eed48e9f82c0f8162ffb7728.json b/data/cookies/258d90a0eed48e9f82c0f8162ffb7728.json
new file mode 100644
index 0000000..7897c54
--- /dev/null
+++ b/data/cookies/258d90a0eed48e9f82c0f8162ffb7728.json
@@ -0,0 +1 @@
+{"cookies": [{"name": "ASP.NET_SessionId", "value": "qg5lgvlveakicsjxkd2zs0e4", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=13974660129&Pwd=235400CB551897EBA480ECCB8E9F77E2", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796840382.754989, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168382.75507, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "13974660129", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168382.755092, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "235400CB551897EBA480ECCB8E9F77E2", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168382.755113, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
\ No newline at end of file
diff --git a/data/cookies/3d963c70ef731c9d6ad21a3a0b0c6ee2.json b/data/cookies/3d963c70ef731c9d6ad21a3a0b0c6ee2.json
new file mode 100644
index 0000000..4a45605
--- /dev/null
+++ b/data/cookies/3d963c70ef731c9d6ad21a3a0b0c6ee2.json
@@ -0,0 +1 @@
+{"cookies": [{"name": "ASP.NET_SessionId", "value": "nzch5jykpkxnhmlprk3v2rpv", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=ltx0919&Pwd=4FE5873929D3E84AB5D155BA666D1EBF", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796872060.055808, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766200060.055894, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "ltx0919", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766200060.055917, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "4FE5873929D3E84AB5D155BA666D1EBF", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766200060.055938, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
\ No newline at end of file
diff --git a/data/cookies/4f9f13e6e854accbff54656e9fee7e99.json b/data/cookies/4f9f13e6e854accbff54656e9fee7e99.json
new file mode 100644
index 0000000..bca127d
--- /dev/null
+++ b/data/cookies/4f9f13e6e854accbff54656e9fee7e99.json
@@ -0,0 +1 @@
+{"cookies": [{"name": "ASP.NET_SessionId", "value": "4pk03tbxuqszmvxwu4l25531", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=15211689491&Pwd=CF534F43D6953D0F380673616127C11F", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796840384.193098, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168384.193181, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "15211689491", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168384.193205, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "CF534F43D6953D0F380673616127C11F", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168384.193228, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
\ No newline at end of file
diff --git a/data/cookies/a850028319059248f5c2b8e9ddaea596.json b/data/cookies/a850028319059248f5c2b8e9ddaea596.json
new file mode 100644
index 0000000..38a63aa
--- /dev/null
+++ b/data/cookies/a850028319059248f5c2b8e9ddaea596.json
@@ -0,0 +1 @@
+{"cookies": [{"name": "ASP.NET_SessionId", "value": "u1pmp34wmvqfyykld2b1ktad", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=19892585678&Pwd=A0A429F1CD34F7832B90A62CD8D89BAB", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796839883.461513, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766167883.461553, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "19892585678", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766167883.461574, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "A0A429F1CD34F7832B90A62CD8D89BAB", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766167883.461597, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
\ No newline at end of file
diff --git a/data/cookies/abd461d0f42c15dae03091a548b7535f.json b/data/cookies/abd461d0f42c15dae03091a548b7535f.json
new file mode 100644
index 0000000..4625c69
--- /dev/null
+++ b/data/cookies/abd461d0f42c15dae03091a548b7535f.json
@@ -0,0 +1 @@
+{"cookies": [{"name": "ASP.NET_SessionId", "value": "qk1e4ggreykolpxxscx0wwpe", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=\u9ec4\u7d2b\u590f99&Pwd=E1B6AEEA30B8789B1EEB489F27DBE63D", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796840391.293757, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168391.293804, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "%e9%bb%84%e7%b4%ab%e5%a4%8f99", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168391.293836, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "E1B6AEEA30B8789B1EEB489F27DBE63D", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168391.293858, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
\ No newline at end of file
diff --git a/data/cookies/c5e153b459ab8ef2b4b7d55f9f851416.json b/data/cookies/c5e153b459ab8ef2b4b7d55f9f851416.json
new file mode 100644
index 0000000..03c4d8c
--- /dev/null
+++ b/data/cookies/c5e153b459ab8ef2b4b7d55f9f851416.json
@@ -0,0 +1 @@
+{"cookies": [{"name": "ASP.NET_SessionId", "value": "zoaive2q4drolthgfaytdbpa", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=15581470826&Pwd=3B41849F58C2B63EB18C2F787E5C7D6B", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796839873.879796, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766167873.879845, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "15581470826", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766167873.879865, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "3B41849F58C2B63EB18C2F787E5C7D6B", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766167873.879889, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
\ No newline at end of file
diff --git a/data/cookies/cbfce7e24ff9acf07656dfeb71c4301b.json b/data/cookies/cbfce7e24ff9acf07656dfeb71c4301b.json
new file mode 100644
index 0000000..7bb2425
--- /dev/null
+++ b/data/cookies/cbfce7e24ff9acf07656dfeb71c4301b.json
@@ -0,0 +1 @@
+{"cookies": [{"name": "ASP.NET_SessionId", "value": "00yjodbwfbdj3budckklcd1k", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=13874665307&Pwd=79E24FB77BCFC0E32B3B8377A31F0918088B5A1D925F0DB1", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796839880.914292, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766167880.91433, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "13874665307", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766167880.914352, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "79E24FB77BCFC0E32B3B8377A31F0918088B5A1D925F0DB1", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766167880.914375, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
\ No newline at end of file
diff --git a/data/cookies/cca12298936803de78c0bec8257f759c.json b/data/cookies/cca12298936803de78c0bec8257f759c.json
new file mode 100644
index 0000000..4d2650d
--- /dev/null
+++ b/data/cookies/cca12298936803de78c0bec8257f759c.json
@@ -0,0 +1 @@
+{"cookies": [{"name": "ASP.NET_SessionId", "value": "lk3bjv1rdmusupnygkcuwwbf", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=zhengmin&Pwd=143BEFBC67350951E96DBFF434CB7D69", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796839866.530727, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766167866.53076, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "zhengmin", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766167866.53078, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "143BEFBC67350951E96DBFF434CB7D69", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766167866.530797, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
\ No newline at end of file
diff --git a/data/cookies/e5a8db18d8d31c5444c1ec14127ba9d9.json b/data/cookies/e5a8db18d8d31c5444c1ec14127ba9d9.json
new file mode 100644
index 0000000..41a6f76
--- /dev/null
+++ b/data/cookies/e5a8db18d8d31c5444c1ec14127ba9d9.json
@@ -0,0 +1 @@
+{"cookies": [{"name": "ASP.NET_SessionId", "value": "gbst5fe3rorc54vag3tzysxl", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=19174616018&Pwd=6108040B9AD841F4CF511908BCCA237C", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796872060.108835, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766200060.108941, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "19174616018", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766200060.108986, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "6108040B9AD841F4CF511908BCCA237C", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766200060.109043, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
\ No newline at end of file
diff --git a/data/cookies/f0c8ff05375518c78c6c4e2dd51fd4d7.json b/data/cookies/f0c8ff05375518c78c6c4e2dd51fd4d7.json
new file mode 100644
index 0000000..c7a4b1a
--- /dev/null
+++ b/data/cookies/f0c8ff05375518c78c6c4e2dd51fd4d7.json
@@ -0,0 +1 @@
+{"cookies": [{"name": "ASP.NET_SessionId", "value": "e1jamhqyqbwv1gybjrrorhgm", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=15211698186&Pwd=587F14AC19330AD2AF31AFE0074B9207", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796840392.425423, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168392.425459, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "15211698186", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168392.42548, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "587F14AC19330AD2AF31AFE0074B9207", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168392.425502, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
\ No newline at end of file
diff --git a/data/cookies/f132e032db106176a2425874f529f6bd.json b/data/cookies/f132e032db106176a2425874f529f6bd.json
new file mode 100644
index 0000000..2db2699
--- /dev/null
+++ b/data/cookies/f132e032db106176a2425874f529f6bd.json
@@ -0,0 +1 @@
+{"cookies": [{"name": "ASP.NET_SessionId", "value": "ka34ptswiio5yvoctdxygtob", "domain": "postoa.aidunsoft.com", "path": "/", "expires": -1, "httpOnly": true, "secure": false, "sameSite": "Lax"}, {"name": "UserInfo", "value": "userName=\u7c73\u7c7388&Pwd=5E8372215047399185B787CF3032F159", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1796840384.078919, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "DTRememberName", "value": "True", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168384.079024, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminName", "value": "%e7%b1%b3%e7%b1%b388", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168384.079066, "httpOnly": false, "secure": false, "sameSite": "Lax"}, {"name": "AdminPwd", "value": "5E8372215047399185B787CF3032F159", "domain": "postoa.aidunsoft.com", "path": "/", "expires": 1766168384.079103, "httpOnly": false, "secure": false, "sameSite": "Lax"}], "origins": []}
\ No newline at end of file
diff --git a/database.py b/database.py
index 56f30d8..e1f883a 100755
--- a/database.py
+++ b/database.py
@@ -25,12 +25,16 @@ from password_utils import (
is_sha256_hash,
verify_password_sha256
)
+from app_config import get_config
-# 数据库文件路径
-DB_FILE = "data/app_data.db"
+# 获取配置
+config = get_config()
+
+# 数据库文件路径 - 从配置读取,避免硬编码
+DB_FILE = config.DB_FILE
# 数据库版本 (用于迁移管理)
-DB_VERSION = 2
+DB_VERSION = 5
def hash_password(password):
@@ -40,7 +44,7 @@ def hash_password(password):
def init_database():
"""初始化数据库表结构"""
- db_pool.init_pool(DB_FILE, pool_size=5)
+ db_pool.init_pool(DB_FILE, pool_size=config.DB_POOL_SIZE)
with db_pool.get_db() as conn:
cursor = conn.cursor()
@@ -98,6 +102,7 @@ def init_database():
id INTEGER PRIMARY KEY CHECK (id = 1),
max_concurrent_global INTEGER DEFAULT 2,
max_concurrent_per_account INTEGER DEFAULT 1,
+ max_screenshot_concurrent INTEGER DEFAULT 3,
schedule_enabled INTEGER DEFAULT 0,
schedule_time TEXT DEFAULT '02:00',
schedule_browse_type TEXT DEFAULT '应读',
@@ -124,6 +129,7 @@ def init_database():
error_message TEXT,
duration INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ source TEXT DEFAULT 'manual',
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
''')
@@ -150,6 +156,42 @@ def init_database():
)
''')
+ # Bug反馈表
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS bug_feedbacks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ username TEXT NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT NOT NULL,
+ contact TEXT,
+ status TEXT DEFAULT 'pending',
+ admin_reply TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ replied_at TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ )
+ ''')
+ # 用户定时任务表
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS user_schedules (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ name TEXT DEFAULT '我的定时任务',
+ enabled INTEGER DEFAULT 0,
+ schedule_time TEXT NOT NULL DEFAULT '08:00',
+ weekdays TEXT NOT NULL DEFAULT '1,2,3,4,5',
+ browse_type TEXT NOT NULL DEFAULT '应读',
+ enable_screenshot INTEGER DEFAULT 1,
+ account_ids TEXT,
+ last_run_at TIMESTAMP,
+ next_run_at TIMESTAMP,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ )
+ ''')
+
# ========== 创建索引 ==========
# 用户表索引
cursor.execute('CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)')
@@ -170,12 +212,22 @@ def init_database():
cursor.execute('CREATE INDEX IF NOT EXISTS idx_password_reset_status ON password_reset_requests(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_password_reset_user_id ON password_reset_requests(user_id)')
+ # Bug反馈表索引
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_user_id ON bug_feedbacks(user_id)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_status ON bug_feedbacks(status)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_created_at ON bug_feedbacks(created_at)')
+ # 用户定时任务表索引
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_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)')
+
# 初始化VIP配置
try:
cursor.execute('INSERT INTO vip_config (id, default_vip_days) VALUES (1, 0)')
conn.commit()
print("✓ 已创建VIP配置(默认不赠送)")
except sqlite3.IntegrityError:
+ # VIP配置已存在,忽略
pass
# 初始化系统配置
@@ -189,6 +241,7 @@ def init_database():
conn.commit()
print("✓ 已创建系统配置(默认并发2,定时任务关闭)")
except sqlite3.IntegrityError:
+ # 系统配置已存在,忽略
pass
# 初始化数据库版本
@@ -197,6 +250,7 @@ def init_database():
conn.commit()
print(f"✓ 数据库版本: {DB_VERSION}")
except sqlite3.IntegrityError:
+ # 数据库版本记录已存在,忽略
pass
conn.commit()
@@ -205,6 +259,9 @@ def init_database():
# 执行数据迁移
migrate_database()
+ # 确保存在默认管理员
+ ensure_default_admin()
+
def migrate_database():
"""数据库迁移 - 自动检测并应用必要的迁移"""
@@ -227,6 +284,19 @@ def migrate_database():
_migrate_to_v2(conn)
current_version = 2
+ if current_version < 3:
+ _migrate_to_v3(conn)
+ current_version = 3
+
+
+ if current_version < 4:
+ _migrate_to_v4(conn)
+ current_version = 4
+
+ if current_version < 5:
+ _migrate_to_v5(conn)
+ current_version = 5
+
# 更新版本号
cursor.execute('UPDATE db_version SET version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1',
(DB_VERSION,))
@@ -248,6 +318,9 @@ def _migrate_to_v1(conn):
cursor.execute('ALTER TABLE system_config ADD COLUMN schedule_weekdays TEXT DEFAULT "1,2,3,4,5,6,7"')
print(" ✓ 添加 schedule_weekdays 字段")
+ if 'max_screenshot_concurrent' not in columns:
+ cursor.execute('ALTER TABLE system_config ADD COLUMN max_screenshot_concurrent INTEGER DEFAULT 3')
+ print(" ✓ 添加 max_screenshot_concurrent 字段")
if 'max_concurrent_per_account' not in columns:
cursor.execute('ALTER TABLE system_config ADD COLUMN max_concurrent_per_account INTEGER DEFAULT 1')
print(" ✓ 添加 max_concurrent_per_account 字段")
@@ -289,8 +362,124 @@ def _migrate_to_v2(conn):
conn.commit()
+def _migrate_to_v3(conn):
+ """迁移到版本3 - 添加账号状态和登录失败计数字段"""
+ cursor = conn.cursor()
+
+ cursor.execute("PRAGMA table_info(accounts)")
+ columns = [col[1] for col in cursor.fetchall()]
+
+ if 'status' not in columns:
+ cursor.execute('ALTER TABLE accounts ADD COLUMN status TEXT DEFAULT "active"')
+ print(" ✓ 添加 accounts.status 字段 (账号状态)")
+
+ if 'login_fail_count' not in columns:
+ cursor.execute('ALTER TABLE accounts ADD COLUMN login_fail_count INTEGER DEFAULT 0')
+ print(" ✓ 添加 accounts.login_fail_count 字段 (登录失败计数)")
+
+ if 'last_login_error' not in columns:
+ cursor.execute('ALTER TABLE accounts ADD COLUMN last_login_error TEXT')
+ print(" ✓ 添加 accounts.last_login_error 字段 (最后登录错误)")
+
+ conn.commit()
+
+
+def _migrate_to_v4(conn):
+ """迁移到版本4 - 添加任务来源字段"""
+ cursor = conn.cursor()
+
+ cursor.execute("PRAGMA table_info(task_logs)")
+ columns = [col[1] for col in cursor.fetchall()]
+
+ if 'source' not in columns:
+ cursor.execute('ALTER TABLE task_logs ADD COLUMN source TEXT DEFAULT "manual"')
+ print(" ✓ 添加 task_logs.source 字段 (任务来源: manual/scheduled/immediate)")
+
+
+
+def _migrate_to_v5(conn):
+ """迁移到版本5 - 添加用户定时任务表"""
+ cursor = conn.cursor()
+
+ # 检查user_schedules表是否存在
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='user_schedules'")
+ if not cursor.fetchone():
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS user_schedules (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ name TEXT DEFAULT '我的定时任务',
+ enabled INTEGER DEFAULT 0,
+ schedule_time TEXT NOT NULL DEFAULT '08:00',
+ weekdays TEXT NOT NULL DEFAULT '1,2,3,4,5',
+ browse_type TEXT NOT NULL DEFAULT '应读',
+ enable_screenshot INTEGER DEFAULT 1,
+ account_ids TEXT,
+ last_run_at TIMESTAMP,
+ next_run_at TIMESTAMP,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ )
+ ''')
+ print(" ✓ 创建 user_schedules 表 (用户定时任务)")
+
+ # 定时任务执行日志表
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS schedule_execution_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ schedule_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ schedule_name TEXT,
+ execute_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ total_accounts INTEGER DEFAULT 0,
+ success_accounts INTEGER DEFAULT 0,
+ failed_accounts INTEGER DEFAULT 0,
+ total_items INTEGER DEFAULT 0,
+ total_attachments INTEGER DEFAULT 0,
+ total_screenshots INTEGER DEFAULT 0,
+ duration_seconds INTEGER DEFAULT 0,
+ status TEXT DEFAULT 'running',
+ error_message TEXT,
+ FOREIGN KEY (schedule_id) REFERENCES user_schedules (id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ )
+ ''')
+ 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(" ✓ 创建 user_schedules 表索引")
+
+ conn.commit()
+
+
# ==================== 管理员相关 ====================
+def ensure_default_admin():
+ """确保存在默认管理员账号 admin/admin"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+
+ # 检查是否已存在管理员
+ cursor.execute('SELECT COUNT(*) as count FROM admins')
+ result = cursor.fetchone()
+
+ if result['count'] == 0:
+ # 创建默认管理员 admin/admin
+ default_password_hash = hash_password_bcrypt('admin')
+ cursor.execute(
+ 'INSERT INTO admins (username, password_hash) VALUES (?, ?)',
+ ('admin', default_password_hash)
+ )
+ conn.commit()
+ print("✓ 已创建默认管理员账号 (admin/admin)")
+ return True
+ return False
+
+
def verify_admin(username, password):
"""验证管理员登录 - 自动从SHA256升级到bcrypt"""
with db_pool.get_db() as conn:
@@ -405,7 +594,9 @@ def extend_user_vip(user_id, days):
if expire_time < now:
expire_time = now
new_expire = (expire_time + timedelta(days=days)).strftime('%Y-%m-%d %H:%M:%S')
- except:
+ except (ValueError, AttributeError) as e:
+ # VIP过期时间格式错误,使用当前时间
+ print(f"解析VIP过期时间失败: {e}, 使用当前时间")
new_expire = (datetime.now(cst_tz) + timedelta(days=days)).strftime('%Y-%m-%d %H:%M:%S')
else:
new_expire = (datetime.now(cst_tz) + timedelta(days=days)).strftime('%Y-%m-%d %H:%M:%S')
@@ -436,7 +627,8 @@ def is_user_vip(user_id):
expire_time_naive = datetime.strptime(user['vip_expire_time'], '%Y-%m-%d %H:%M:%S')
expire_time = cst_tz.localize(expire_time_naive)
return datetime.now(cst_tz) < expire_time
- except:
+ except (ValueError, AttributeError) as e:
+ print(f"检查VIP状态失败 (user_id={user_id}): {e}")
return False
@@ -646,6 +838,70 @@ def delete_account(account_id):
return cursor.rowcount > 0
+def increment_account_login_fail(account_id, error_message):
+ """增加账号登录失败次数,如果达到3次则暂停账号"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+
+ # 获取当前失败次数
+ cursor.execute('SELECT login_fail_count FROM accounts WHERE id = ?', (account_id,))
+ row = cursor.fetchone()
+ if not row:
+ return False
+
+ fail_count = (row['login_fail_count'] or 0) + 1
+
+ # 更新失败次数和错误信息
+ if fail_count >= 3:
+ # 达到3次,暂停账号
+ cursor.execute('''
+ UPDATE accounts
+ SET login_fail_count = ?,
+ last_login_error = ?,
+ status = 'suspended'
+ WHERE id = ?
+ ''', (fail_count, error_message, account_id))
+ conn.commit()
+ return True # 返回True表示账号已被暂停
+ else:
+ # 未达到3次,只更新计数
+ cursor.execute('''
+ UPDATE accounts
+ SET login_fail_count = ?,
+ last_login_error = ?
+ WHERE id = ?
+ ''', (fail_count, error_message, account_id))
+ conn.commit()
+ return False # 返回False表示未暂停
+
+
+def reset_account_login_status(account_id):
+ """重置账号登录状态(修改密码后调用)"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ UPDATE accounts
+ SET login_fail_count = 0,
+ last_login_error = NULL,
+ status = 'active'
+ WHERE id = ?
+ ''', (account_id,))
+ conn.commit()
+ return cursor.rowcount > 0
+
+
+def get_account_status(account_id):
+ """获取账号状态信息"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT status, login_fail_count, last_login_error
+ FROM accounts
+ WHERE id = ?
+ ''', (account_id,))
+ return cursor.fetchone()
+
+
def delete_user_accounts(user_id):
"""删除用户的所有账号"""
with db_pool.get_db() as conn:
@@ -715,6 +971,7 @@ def get_system_config():
return {
'max_concurrent_global': 2,
'max_concurrent_per_account': 1,
+ 'max_screenshot_concurrent': 3,
'schedule_enabled': 0,
'schedule_time': '02:00',
'schedule_browse_type': '应读',
@@ -728,7 +985,7 @@ def get_system_config():
def update_system_config(max_concurrent=None, schedule_enabled=None, schedule_time=None,
schedule_browse_type=None, schedule_weekdays=None,
- max_concurrent_per_account=None, proxy_enabled=None,
+ max_concurrent_per_account=None, max_screenshot_concurrent=None, proxy_enabled=None,
proxy_api_url=None, proxy_expire_minutes=None):
"""更新系统配置"""
with db_pool.get_db() as conn:
@@ -785,8 +1042,12 @@ def update_system_config(max_concurrent=None, schedule_enabled=None, schedule_ti
# ==================== 任务日志管理 ====================
def create_task_log(user_id, account_id, username, browse_type, status,
- total_items=0, total_attachments=0, error_message='', duration=None):
- """创建任务日志记录"""
+ total_items=0, total_attachments=0, error_message='', duration=None, source='manual'):
+ """创建任务日志记录
+
+ Args:
+ source: 任务来源 - 'manual'(手动执行), 'scheduled'(定时任务), 'immediate'(立即执行)
+ """
with db_pool.get_db() as conn:
cursor = conn.cursor()
cst_tz = pytz.timezone("Asia/Shanghai")
@@ -795,43 +1056,77 @@ def create_task_log(user_id, account_id, username, browse_type, status,
cursor.execute('''
INSERT INTO task_logs (
user_id, account_id, username, browse_type, status,
- total_items, total_attachments, error_message, duration, created_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ total_items, total_attachments, error_message, duration, created_at, source
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (user_id, account_id, username, browse_type, status,
- total_items, total_attachments, error_message, duration, cst_time))
+ total_items, total_attachments, error_message, duration, cst_time, source))
conn.commit()
return cursor.lastrowid
-def get_task_logs(limit=100, offset=0, date_filter=None, status_filter=None):
- """获取任务日志列表"""
+def get_task_logs(limit=100, offset=0, date_filter=None, status_filter=None,
+ source_filter=None, user_id_filter=None, account_filter=None):
+ """获取任务日志列表(支持分页和多种筛选)"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
- sql = '''
+ # 构建WHERE条件
+ where_clauses = ["1=1"]
+ params = []
+
+ if date_filter:
+ where_clauses.append("date(tl.created_at) = ?")
+ params.append(date_filter)
+
+ if status_filter:
+ where_clauses.append("tl.status = ?")
+ params.append(status_filter)
+
+ if source_filter:
+ where_clauses.append("tl.source = ?")
+ params.append(source_filter)
+
+ if user_id_filter:
+ where_clauses.append("tl.user_id = ?")
+ params.append(user_id_filter)
+
+ if account_filter:
+ where_clauses.append("tl.username LIKE ?")
+ params.append(f"%{account_filter}%")
+
+ where_sql = " AND ".join(where_clauses)
+
+ # 获取总数
+ count_sql = f'''
+ SELECT COUNT(*) as total
+ FROM task_logs tl
+ LEFT JOIN users u ON tl.user_id = u.id
+ WHERE {where_sql}
+ '''
+ cursor.execute(count_sql, params)
+ total = cursor.fetchone()['total']
+
+ # 获取分页数据
+ data_sql = f'''
SELECT
tl.*,
u.username as user_username
FROM task_logs tl
LEFT JOIN users u ON tl.user_id = u.id
- WHERE 1=1
+ WHERE {where_sql}
+ ORDER BY tl.created_at DESC
+ LIMIT ? OFFSET ?
'''
- params = []
-
- if date_filter:
- sql += " AND date(tl.created_at) = ?"
- params.append(date_filter)
-
- if status_filter:
- sql += " AND tl.status = ?"
- params.append(status_filter)
-
- sql += " ORDER BY tl.created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
- cursor.execute(sql, params)
- return [dict(row) for row in cursor.fetchall()]
+ cursor.execute(data_sql, params)
+ logs = [dict(row) for row in cursor.fetchall()]
+
+ return {
+ 'logs': logs,
+ 'total': total
+ }
def get_task_stats(date_filter=None):
@@ -1064,3 +1359,326 @@ def clean_old_operation_logs(days=30):
except Exception as e:
print(f"清理旧操作日志失败: {e}")
return 0
+
+
+# ==================== Bug反馈管理 ====================
+
+def create_bug_feedback(user_id, username, title, description, contact=''):
+ """创建Bug反馈"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cst_tz = pytz.timezone("Asia/Shanghai")
+ cst_time = datetime.now(cst_tz).strftime("%Y-%m-%d %H:%M:%S")
+
+ cursor.execute('''
+ INSERT INTO bug_feedbacks (user_id, username, title, description, contact, created_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ''', (user_id, username, title, description, contact, cst_time))
+
+ conn.commit()
+ return cursor.lastrowid
+
+
+def get_bug_feedbacks(limit=100, offset=0, status_filter=None):
+ """获取Bug反馈列表(管理员用)"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+
+ sql = 'SELECT * FROM bug_feedbacks WHERE 1=1'
+ params = []
+
+ if status_filter:
+ sql += ' AND status = ?'
+ params.append(status_filter)
+
+ sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'
+ params.extend([limit, offset])
+
+ cursor.execute(sql, params)
+ return [dict(row) for row in cursor.fetchall()]
+
+
+def get_user_feedbacks(user_id, limit=50):
+ """获取用户自己的反馈列表"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT * FROM bug_feedbacks
+ WHERE user_id = ?
+ ORDER BY created_at DESC
+ LIMIT ?
+ ''', (user_id, limit))
+ return [dict(row) for row in cursor.fetchall()]
+
+
+def get_feedback_by_id(feedback_id):
+ """根据ID获取反馈详情"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('SELECT * FROM bug_feedbacks WHERE id = ?', (feedback_id,))
+ row = cursor.fetchone()
+ return dict(row) if row else None
+
+
+def reply_feedback(feedback_id, admin_reply):
+ """管理员回复反馈"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cst_tz = pytz.timezone("Asia/Shanghai")
+ cst_time = datetime.now(cst_tz).strftime("%Y-%m-%d %H:%M:%S")
+
+ cursor.execute('''
+ UPDATE bug_feedbacks
+ SET admin_reply = ?, status = 'replied', replied_at = ?
+ WHERE id = ?
+ ''', (admin_reply, cst_time, feedback_id))
+
+ conn.commit()
+ return cursor.rowcount > 0
+
+
+def close_feedback(feedback_id):
+ """关闭反馈"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ UPDATE bug_feedbacks
+ SET status = 'closed'
+ WHERE id = ?
+ ''', (feedback_id,))
+ conn.commit()
+ return cursor.rowcount > 0
+
+
+def delete_feedback(feedback_id):
+ """删除反馈"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('DELETE FROM bug_feedbacks WHERE id = ?', (feedback_id,))
+ conn.commit()
+ return cursor.rowcount > 0
+
+
+def get_feedback_stats():
+ """获取反馈统计"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT
+ COUNT(*) as total,
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
+ SUM(CASE WHEN status = 'replied' THEN 1 ELSE 0 END) as replied,
+ SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed
+ FROM bug_feedbacks
+ ''')
+ row = cursor.fetchone()
+ return dict(row) if row else {'total': 0, 'pending': 0, 'replied': 0, 'closed': 0}
+
+
+# ==================== 用户定时任务管理 ====================
+
+def get_user_schedules(user_id):
+ """获取用户的所有定时任务"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT * FROM user_schedules
+ WHERE user_id = ?
+ ORDER BY created_at DESC
+ ''', (user_id,))
+ return [dict(row) for row in cursor.fetchall()]
+
+
+def get_schedule_by_id(schedule_id):
+ """根据ID获取定时任务"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('SELECT * FROM user_schedules WHERE id = ?', (schedule_id,))
+ row = cursor.fetchone()
+ return dict(row) if row else None
+
+
+def create_user_schedule(user_id, name='我的定时任务', schedule_time='08:00',
+ weekdays='1,2,3,4,5', browse_type='应读',
+ enable_screenshot=1, account_ids=None):
+ """创建用户定时任务"""
+ import json
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cst_tz = pytz.timezone("Asia/Shanghai")
+ cst_time = datetime.now(cst_tz).strftime("%Y-%m-%d %H:%M:%S")
+
+ account_ids_str = json.dumps(account_ids) if account_ids else '[]'
+
+ cursor.execute('''
+ INSERT INTO user_schedules (
+ user_id, name, enabled, schedule_time, weekdays,
+ browse_type, enable_screenshot, account_ids, created_at, updated_at
+ ) VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?)
+ ''', (user_id, name, schedule_time, weekdays, browse_type,
+ enable_screenshot, account_ids_str, cst_time, cst_time))
+
+ conn.commit()
+ return cursor.lastrowid
+
+
+def update_user_schedule(schedule_id, **kwargs):
+ """更新用户定时任务"""
+ import json
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cst_tz = pytz.timezone("Asia/Shanghai")
+
+ updates = []
+ params = []
+
+ allowed_fields = ['name', 'enabled', 'schedule_time', 'weekdays',
+ 'browse_type', 'enable_screenshot', 'account_ids']
+
+ for field in allowed_fields:
+ if field in kwargs:
+ value = kwargs[field]
+ if field == 'account_ids' and isinstance(value, list):
+ value = json.dumps(value)
+ updates.append(f'{field} = ?')
+ params.append(value)
+
+ if not updates:
+ return False
+
+ updates.append('updated_at = ?')
+ params.append(datetime.now(cst_tz).strftime("%Y-%m-%d %H:%M:%S"))
+ params.append(schedule_id)
+
+ sql = f"UPDATE user_schedules SET {', '.join(updates)} WHERE id = ?"
+ cursor.execute(sql, params)
+ conn.commit()
+ return cursor.rowcount > 0
+
+
+def delete_user_schedule(schedule_id):
+ """删除用户定时任务"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('DELETE FROM user_schedules WHERE id = ?', (schedule_id,))
+ conn.commit()
+ return cursor.rowcount > 0
+
+
+def toggle_user_schedule(schedule_id, enabled):
+ """启用/禁用用户定时任务"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cst_tz = pytz.timezone("Asia/Shanghai")
+ cst_time = datetime.now(cst_tz).strftime("%Y-%m-%d %H:%M:%S")
+
+ cursor.execute('''
+ UPDATE user_schedules
+ SET enabled = ?, updated_at = ?
+ WHERE id = ?
+ ''', (1 if enabled else 0, cst_time, schedule_id))
+ conn.commit()
+ return cursor.rowcount > 0
+
+
+def get_enabled_user_schedules():
+ """获取所有启用的用户定时任务"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT us.*, u.username as user_username
+ FROM user_schedules us
+ JOIN users u ON us.user_id = u.id
+ WHERE us.enabled = 1
+ ORDER BY us.schedule_time
+ ''')
+ return [dict(row) for row in cursor.fetchall()]
+
+
+def update_schedule_last_run(schedule_id):
+ """更新定时任务最后运行时间"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cst_tz = pytz.timezone("Asia/Shanghai")
+ cst_time = datetime.now(cst_tz).strftime("%Y-%m-%d %H:%M:%S")
+
+ cursor.execute('''
+ UPDATE user_schedules
+ SET last_run_at = ?, updated_at = ?
+ WHERE id = ?
+ ''', (cst_time, cst_time, schedule_id))
+ conn.commit()
+ return cursor.rowcount > 0
+
+
+# ==================== 定时任务执行日志 ====================
+
+def create_schedule_execution_log(schedule_id, user_id, schedule_name):
+ """创建定时任务执行日志"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cst_tz = pytz.timezone("Asia/Shanghai")
+ execute_time = datetime.now(cst_tz).strftime("%Y-%m-%d %H:%M:%S")
+
+ cursor.execute('''
+ INSERT INTO schedule_execution_logs (
+ schedule_id, user_id, schedule_name, execute_time, status
+ ) VALUES (?, ?, ?, ?, 'running')
+ ''', (schedule_id, user_id, schedule_name, execute_time))
+
+ conn.commit()
+ return cursor.lastrowid
+
+
+def update_schedule_execution_log(log_id, **kwargs):
+ """更新定时任务执行日志"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+
+ updates = []
+ params = []
+
+ allowed_fields = ['total_accounts', 'success_accounts', 'failed_accounts',
+ 'total_items', 'total_attachments', 'total_screenshots',
+ 'duration_seconds', 'status', 'error_message']
+
+ for field in allowed_fields:
+ if field in kwargs:
+ updates.append(f'{field} = ?')
+ params.append(kwargs[field])
+
+ if not updates:
+ return False
+
+ params.append(log_id)
+ sql = f"UPDATE schedule_execution_logs SET {', '.join(updates)} WHERE id = ?"
+
+ cursor.execute(sql, params)
+ conn.commit()
+ return cursor.rowcount > 0
+
+
+def get_schedule_execution_logs(schedule_id, limit=10):
+ """获取定时任务执行日志"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT * FROM schedule_execution_logs
+ WHERE schedule_id = ?
+ ORDER BY execute_time DESC
+ LIMIT ?
+ ''', (schedule_id, limit))
+ return [dict(row) for row in cursor.fetchall()]
+
+
+def get_user_all_schedule_logs(user_id, limit=50):
+ """获取用户所有定时任务的执行日志"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT * FROM schedule_execution_logs
+ WHERE user_id = ?
+ ORDER BY execute_time DESC
+ LIMIT ?
+ ''', (user_id, limit))
+ return [dict(row) for row in cursor.fetchall()]
diff --git a/db_pool.py b/db_pool.py
index e3e0d35..bdca8cb 100755
--- a/db_pool.py
+++ b/db_pool.py
@@ -82,8 +82,8 @@ class ConnectionPool:
print(f"归还连接失败(数据库错误): {e}")
try:
conn.close()
- except Exception:
- pass
+ except Exception as close_error:
+ print(f"关闭损坏的连接失败: {close_error}")
# 创建新连接补充
with self._lock:
try:
@@ -96,14 +96,14 @@ class ConnectionPool:
print(f"警告: 连接池已满,关闭多余连接")
try:
conn.close()
- except Exception:
- pass
+ except Exception as close_error:
+ print(f"关闭多余连接失败: {close_error}")
except Exception as e:
print(f"归还连接失败(未知错误): {e}")
try:
conn.close()
- except Exception:
- pass
+ except Exception as close_error:
+ print(f"关闭异常连接失败: {close_error}")
def close_all(self):
"""关闭所有连接"""
diff --git a/deploy_to_production.sh b/deploy_to_production.sh
new file mode 100755
index 0000000..f04b98d
--- /dev/null
+++ b/deploy_to_production.sh
@@ -0,0 +1,157 @@
+#!/bin/bash
+
+# ============================================================
+# 生产环境部署脚本
+# 服务器: 118.145.177.79
+# 域名: zsglpt.workyai.cn
+# 项目路径: /root/zsglpt
+# ============================================================
+
+set -e # 遇到错误立即退出
+
+REMOTE_HOST="118.145.177.79"
+REMOTE_USER="root"
+REMOTE_PATH="/root/zsglpt"
+BACKUP_DIR="backups/backup_$(date +%Y%m%d_%H%M%S)"
+
+echo "============================================================"
+echo "知识管理平台 - 生产环境部署脚本"
+echo "目标服务器: $REMOTE_HOST"
+echo "部署路径: $REMOTE_PATH"
+echo "============================================================"
+
+# 步骤1: 测试SSH连接
+echo ""
+echo "[1/8] 测试SSH连接..."
+ssh -o ConnectTimeout=10 $REMOTE_USER@$REMOTE_HOST "echo '✓ SSH连接成功'" || {
+ echo "✗ SSH连接失败,请检查:"
+ echo " 1. 服务器IP是否正确"
+ echo " 2. SSH密码是否正确"
+ echo " 3. 网络连接是否正常"
+ exit 1
+}
+
+# 步骤2: 备份生产服务器数据库
+echo ""
+echo "[2/8] 备份生产服务器数据库..."
+ssh $REMOTE_USER@$REMOTE_HOST "
+ cd $REMOTE_PATH
+ mkdir -p $BACKUP_DIR
+ if [ -f data/app_data.db ]; then
+ cp -r data $BACKUP_DIR/
+ echo '✓ 数据库备份完成: $BACKUP_DIR/data/'
+ else
+ echo '⚠ 未找到数据库文件,跳过备份'
+ fi
+
+ # 同时备份当前运行的配置
+ if [ -f docker-compose.yml ]; then
+ cp docker-compose.yml $BACKUP_DIR/
+ echo '✓ docker-compose.yml 已备份'
+ fi
+"
+
+# 步骤3: 压缩本地项目文件(排除不需要的文件)
+echo ""
+echo "[3/8] 压缩项目文件..."
+tar -czf /tmp/zsglpt_deploy.tar.gz \
+ --exclude='*.pyc' \
+ --exclude='__pycache__' \
+ --exclude='.git' \
+ --exclude='data' \
+ --exclude='logs' \
+ --exclude='截图' \
+ --exclude='playwright' \
+ --exclude='backups' \
+ --exclude='*.tar.gz' \
+ -C /home/yuyx/aaaaaa/zsglpt \
+ .
+
+echo "✓ 项目文件已压缩: /tmp/zsglpt_deploy.tar.gz"
+ls -lh /tmp/zsglpt_deploy.tar.gz
+
+# 步骤4: 上传项目文件到服务器
+echo ""
+echo "[4/8] 上传项目文件到服务器..."
+scp /tmp/zsglpt_deploy.tar.gz $REMOTE_USER@$REMOTE_HOST:/tmp/
+echo "✓ 文件上传完成"
+
+# 步骤5: 在服务器上解压并替换文件
+echo ""
+echo "[5/8] 解压并更新项目文件..."
+ssh $REMOTE_USER@$REMOTE_HOST "
+ cd $REMOTE_PATH
+
+ # 备份旧的代码文件(以防需要回滚)
+ mkdir -p $BACKUP_DIR/code
+ cp -r *.py templates static Dockerfile docker-compose.yml requirements.txt $BACKUP_DIR/code/ 2>/dev/null || true
+
+ # 解压新文件(保留data、logs等目录)
+ tar -xzf /tmp/zsglpt_deploy.tar.gz -C $REMOTE_PATH
+
+ # 清理临时文件
+ rm /tmp/zsglpt_deploy.tar.gz
+
+ echo '✓ 项目文件更新完成'
+"
+
+# 步骤6: 停止旧容器
+echo ""
+echo "[6/8] 停止旧容器..."
+ssh $REMOTE_USER@$REMOTE_HOST "
+ cd $REMOTE_PATH
+ docker-compose down
+ echo '✓ 旧容器已停止'
+"
+
+# 步骤7: 清理旧镜像并重新构建
+echo ""
+echo "[7/8] 重新构建Docker镜像..."
+ssh $REMOTE_USER@$REMOTE_HOST "
+ cd $REMOTE_PATH
+
+ # 删除旧镜像
+ docker rmi zsglpt-knowledge-automation 2>/dev/null || true
+
+ # 重新构建(无缓存)
+ docker-compose build --no-cache
+
+ echo '✓ Docker镜像构建完成'
+"
+
+# 步骤8: 启动新容器
+echo ""
+echo "[8/8] 启动新容器..."
+ssh $REMOTE_USER@$REMOTE_HOST "
+ cd $REMOTE_PATH
+ docker-compose up -d
+
+ echo '✓ 新容器已启动'
+ echo ''
+ echo '等待10秒,检查容器状态...'
+ sleep 10
+
+ docker ps | grep knowledge-automation
+ echo ''
+ echo '查看启动日志(最后30行):'
+ docker logs --tail 30 knowledge-automation-multiuser
+"
+
+# 清理本地临时文件
+rm /tmp/zsglpt_deploy.tar.gz
+
+echo ""
+echo "============================================================"
+echo "✓ 部署完成!"
+echo "============================================================"
+echo "访问地址: https://zsglpt.workyai.cn"
+echo "后台管理: https://zsglpt.workyai.cn/yuyx"
+echo ""
+echo "备份位置: $REMOTE_PATH/$BACKUP_DIR"
+echo ""
+echo "如需查看日志,请执行:"
+echo " ssh $REMOTE_USER@$REMOTE_HOST 'docker logs -f knowledge-automation-multiuser'"
+echo ""
+echo "如需回滚,请执行:"
+echo " ssh $REMOTE_USER@$REMOTE_HOST 'cd $REMOTE_PATH && cd $BACKUP_DIR/code && cp -r * $REMOTE_PATH/ && cd $REMOTE_PATH && docker-compose up -d --build'"
+echo "============================================================"
diff --git a/docker-compose.yml b/docker-compose.yml
index b17ba10..bafd5fb 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,16 +5,61 @@ services:
build: .
container_name: knowledge-automation-multiuser
ports:
- - "5001:5000"
+ - "51232:51233"
volumes:
- ./data:/app/data # 数据库持久化
- ./logs:/app/logs # 日志持久化
- ./截图:/app/截图 # 截图持久化
- ./playwright:/ms-playwright # Playwright浏览器持久化(避免重复下载)
- /etc/localtime:/etc/localtime:ro # 时区同步
+ - ./static:/app/static # 静态文件(实时更新)
+ - ./templates:/app/templates # 模板文件(实时更新)
+ - ./app.py:/app/app.py # 主程序(实时更新)
+ dns:
+ - 8.8.8.8
+ - 114.114.114.114
environment:
- TZ=Asia/Shanghai
- PYTHONUNBUFFERED=1
- PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
+ - PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright
+ # Flask 配置
+ - 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
+ # 并发控制配置
+ - MAX_CONCURRENT_GLOBAL=2
+ - MAX_CONCURRENT_PER_ACCOUNT=1
+ - MAX_CONCURRENT_CONTEXTS=100
+ # 安全配置
+ - 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
+ # 知识管理平台配置
+ - 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核心
+
+ # 健康检查(可选)
+ healthcheck:
+ test: ["CMD-SHELL", "curl -f http://localhost:51233 || exit 1"]
+ interval: 5m
+ timeout: 10s
+ retries: 3
+ start_period: 40s
diff --git a/docs/BUG_FIX_REPORT_20251120.md b/docs/BUG_FIX_REPORT_20251120.md
new file mode 100644
index 0000000..b3505b4
--- /dev/null
+++ b/docs/BUG_FIX_REPORT_20251120.md
@@ -0,0 +1,349 @@
+# Bug修复报告 - 管理员登录Session丢失问题
+
+**修复日期**: 2025-11-20
+**修复人员**: Claude Code
+**问题ID**: Issue #1 (交接文档第526-576行)
+**严重程度**: 🔴 严重 - 导致管理员无法登录
+**状态**: ✅ 已修复
+
+---
+
+## 问题描述
+
+### 症状
+- 管理员登录成功后,立即访问 `/yuyx/admin` 返回 403 错误
+- 错误信息:`{"error": "需要管理员权限"}`
+- 浏览器控制台:`GET http://IP:51232/yuyx/admin 403 (FORBIDDEN)`
+
+### 日志表现
+```
+[INFO] [admin_login] 管理员 237899745 登录成功, session已设置: admin_id=1
+[WARNING] [admin_required] 拒绝访问 /yuyx/admin - session中无admin_id
+```
+
+**问题核心**: 登录API成功设置session (`session['admin_id'] = 1`),但后续GET请求中session为空,未携带admin_id。
+
+---
+
+## 根本原因分析
+
+经过代码审查和调试,发现了**三个关键配置问题**导致session cookie无法正确工作:
+
+### 1. ❌ SESSION_COOKIE_SAMESITE 配置错误(app_config.py:58)
+
+**问题代码**:
+```python
+SESSION_COOKIE_SAMESITE = 'None' if os.environ.get('SESSION_COOKIE_SECURE', 'False').lower() == 'true' else None
+```
+
+**问题分析**:
+- 当 `SESSION_COOKIE_SECURE=False` 时(HTTP环境),该值为 Python `None`
+- Flask期望的是字符串 `'Lax'`、`'Strict'` 或 `'None'`
+- Python `None` 会导致Flask使用浏览器默认行为,可能不兼容
+
+**影响**: Cookie可能不会在同源请求中发送
+
+---
+
+### 2. ❌ SESSION_COOKIE_SECURE 被子类硬编码覆盖(app_config.py:173)
+
+**问题代码**:
+```python
+class ProductionConfig(Config):
+ """生产环境配置"""
+ DEBUG = False
+ SESSION_COOKIE_SECURE = True # 生产环境必须使用HTTPS
+```
+
+**问题分析**:
+- `ProductionConfig` 子类硬编码设置了 `SESSION_COOKIE_SECURE = True`
+- 这会覆盖基类 `Config` 中从环境变量读取的值
+- 在HTTP环境下,`Secure=True` 的cookie会被浏览器拒绝(cookie只在HTTPS下发送)
+
+**影响**: ⚠️ **这是导致session丢失的主要原因**。浏览器收到 `Secure=True` 的cookie后,在HTTP请求中不会发送该cookie,导致服务端无法识别session。
+
+---
+
+### 3. ⚠️ 缺少 SESSION_COOKIE_NAME 配置
+
+**问题分析**:
+- 未设置自定义cookie名称,使用Flask默认的 `session`
+- 如果服务器上有多个Flask应用,可能会有cookie冲突
+
+**影响**: 可能在某些环境下导致session混乱
+
+---
+
+## 修复方案
+
+### 修复1: 修正 SESSION_COOKIE_SAMESITE 配置
+
+**文件**: `app_config.py`
+**位置**: 第58-59行
+
+**修改前**:
+```python
+SESSION_COOKIE_SAMESITE = 'None' if os.environ.get('SESSION_COOKIE_SECURE', 'False').lower() == 'true' else None
+```
+
+**修改后**:
+```python
+# SameSite配置:HTTP环境使用Lax,HTTPS环境使用None
+SESSION_COOKIE_SAMESITE = 'None' if os.environ.get('SESSION_COOKIE_SECURE', 'False').lower() == 'true' else 'Lax'
+```
+
+**说明**: 在HTTP环境下使用 `'Lax'`,确保同源请求可以携带cookie
+
+---
+
+### 修复2: 添加自定义 SESSION_COOKIE_NAME 和 PATH
+
+**文件**: `app_config.py`
+**位置**: 第60-63行
+
+**添加**:
+```python
+# 自定义cookie名称,避免与其他应用冲突
+SESSION_COOKIE_NAME = os.environ.get('SESSION_COOKIE_NAME', 'zsglpt_session')
+# Cookie路径,确保整个应用都能访问
+SESSION_COOKIE_PATH = '/'
+```
+
+**说明**: 使用唯一的cookie名称,避免冲突
+
+---
+
+### 修复3: 移除子类中的硬编码覆盖 ⭐ **关键修复**
+
+**文件**: `app_config.py`
+**位置**: 第164-174行
+
+**修改前**:
+```python
+class DevelopmentConfig(Config):
+ """开发环境配置"""
+ DEBUG = True
+ SESSION_COOKIE_SECURE = False
+
+
+class ProductionConfig(Config):
+ """生产环境配置"""
+ DEBUG = False
+ SESSION_COOKIE_SECURE = True # 生产环境必须使用HTTPS
+```
+
+**修改后**:
+```python
+class DevelopmentConfig(Config):
+ """开发环境配置"""
+ DEBUG = True
+ # 不覆盖SESSION_COOKIE_SECURE,使用父类的环境变量配置
+
+
+class ProductionConfig(Config):
+ """生产环境配置"""
+ DEBUG = False
+ # 不覆盖SESSION_COOKIE_SECURE,使用父类的环境变量配置
+ # 如需HTTPS,请在环境变量中设置 SESSION_COOKIE_SECURE=true
+```
+
+**说明**: 让所有环境配置都从环境变量读取,不硬编码覆盖
+
+---
+
+### 修复4: 改进 admin_login 函数日志
+
+**文件**: `app.py`
+**位置**: 第532-548行
+
+**修改**: 添加了更详细的日志输出,方便排查问题
+
+```python
+logger.info(f"[admin_login] 管理员 {username} 登录成功, session已设置: admin_id={admin['id']}")
+logger.debug(f"[admin_login] Session内容: {dict(session)}")
+logger.debug(f"[admin_login] Cookie将被设置: name={app.config.get('SESSION_COOKIE_NAME', 'session')}")
+```
+
+---
+
+### 修复5: 添加启动时配置日志
+
+**文件**: `app.py`
+**位置**: 第61-65行
+
+**添加**:
+```python
+logger.info(f"Session配置: COOKIE_NAME={app.config.get('SESSION_COOKIE_NAME', 'session')}, "
+ f"SAMESITE={app.config.get('SESSION_COOKIE_SAMESITE', 'None')}, "
+ f"HTTPONLY={app.config.get('SESSION_COOKIE_HTTPONLY', 'None')}, "
+ f"SECURE={app.config.get('SESSION_COOKIE_SECURE', 'None')}, "
+ f"PATH={app.config.get('SESSION_COOKIE_PATH', 'None')}")
+```
+
+**说明**: 启动时打印session配置,方便验证
+
+---
+
+## 修复验证
+
+### 验证方法1: 检查启动日志
+
+启动容器后,检查日志中的session配置:
+
+```bash
+docker logs knowledge-automation-multiuser 2>&1 | grep "Session配置"
+```
+
+**期望输出**:
+```
+[INFO] Session配置: COOKIE_NAME=zsglpt_session, SAMESITE=Lax, HTTPONLY=True, SECURE=False, PATH=/
+```
+
+✅ **验证通过**: 所有配置值正确
+
+---
+
+### 验证方法2: 浏览器测试
+
+1. 访问 `http://服务器IP:51232/yuyx`
+2. 输入管理员账号和密码
+3. 提交登录
+4. 观察浏览器开发者工具:
+ - **Network** 标签页,查看登录响应的 `Set-Cookie` 头
+ - **Application → Cookies** 查看是否有 `zsglpt_session` cookie
+ - 检查cookie属性:`SameSite=Lax`, `HttpOnly=true`, `Secure=false`
+
+5. 登录成功后,自动跳转到 `/yuyx/admin` 管理后台(不应出现403错误)
+
+---
+
+### 验证方法3: 使用测试脚本
+
+运行提供的测试脚本(需要管理员密码):
+
+```bash
+cd /home/yuyx/aaaaaa/zsglpt
+python3 test_admin_login.py
+```
+
+**期望结果**: 所有测试步骤通过,session正确保持
+
+---
+
+## 配置总结
+
+修复后的正确配置:
+
+| 配置项 | HTTP环境 | HTTPS环境 |
+|--------|----------|-----------|
+| SESSION_COOKIE_NAME | `zsglpt_session` | `zsglpt_session` |
+| SESSION_COOKIE_SAMESITE | `Lax` | `None` |
+| SESSION_COOKIE_HTTPONLY | `True` | `True` |
+| SESSION_COOKIE_SECURE | `False` | `True` |
+| SESSION_COOKIE_PATH | `/` | `/` |
+
+**当前部署环境**: HTTP (SECURE=False)
+
+---
+
+## 影响范围
+
+### 修改文件
+1. `app_config.py` - Session配置修复(3处修改)
+2. `app.py` - 日志增强(2处修改)
+
+### 不影响的功能
+- ✅ 用户登录功能不受影响
+- ✅ 数据库和数据不受影响
+- ✅ 自动化任务功能不受影响
+- ✅ 其他API端点不受影响
+
+### 受益功能
+- ✅ 管理员登录功能恢复正常
+- ✅ 管理员后台所有功能可正常使用
+- ✅ Session管理更加可靠
+
+---
+
+## 部署步骤
+
+```bash
+# 1. 进入项目目录
+cd /home/yuyx/aaaaaa/zsglpt
+
+# 2. 停止旧容器
+docker-compose down
+
+# 3. 重新构建并启动
+docker-compose up -d --build
+
+# 4. 等待启动完成(约30秒)
+sleep 30
+
+# 5. 验证配置
+docker logs knowledge-automation-multiuser 2>&1 | grep "Session配置"
+
+# 6. 测试访问
+curl -I http://localhost:51232/yuyx
+```
+
+**预期**: 返回 200 OK
+
+---
+
+## 经验教训
+
+1. **避免子类硬编码覆盖配置**
+ - 环境变量应在基类中定义
+ - 子类应避免硬编码覆盖,除非有特殊需求
+
+2. **Cookie Secure属性必须与协议匹配**
+ - HTTP环境必须使用 `Secure=False`
+ - HTTPS环境才能使用 `Secure=True`
+ - 浏览器严格执行此规则
+
+3. **SameSite属性需要明确设置**
+ - 不要使用 Python `None`,应使用字符串 `'Lax'` 或 `'Strict'`
+ - HTTP环境推荐使用 `'Lax'`
+ - HTTPS环境可以使用 `'None'`
+
+4. **启动时打印关键配置**
+ - 有助于快速诊断配置问题
+ - 避免"配置不生效"的困惑
+
+---
+
+## 相关资源
+
+- **交接文档**: `交接文档.md` 第526-576行(已知问题章节)
+- **测试脚本**: `test_admin_login.py` - 完整登录流程测试
+- **配置脚本**: `test_session_config.py` - Session配置验证
+- **Flask Session文档**: https://flask.palletsprojects.com/en/stable/api/#sessions
+- **MDN Cookie文档**: https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies
+
+---
+
+## 后续建议
+
+1. **更新.env.example文件**
+ - 添加 `SESSION_COOKIE_NAME` 示例
+ - 添加 `SESSION_COOKIE_SECURE` 的使用说明
+
+2. **如需部署到HTTPS环境**
+ - 设置环境变量:`SESSION_COOKIE_SECURE=true`
+ - 确保使用有效的SSL证书
+ - 更新 `SESSION_COOKIE_SAMESITE` 为 `'None'`(已在代码中自动处理)
+
+3. **监控建议**
+ - 定期检查日志中的session相关警告
+ - 监控403错误率,及时发现session问题
+
+---
+
+**修复完成时间**: 2025-11-20 10:30
+**测试状态**: ✅ 配置已验证
+**建议**: 需要管理员密码才能进行完整的登录流程测试
+
+---
+
+*本报告由 Claude Code 生成*
diff --git a/docs/OPTIMIZATION_REPORT.md b/docs/OPTIMIZATION_REPORT.md
new file mode 100644
index 0000000..23a079c
--- /dev/null
+++ b/docs/OPTIMIZATION_REPORT.md
@@ -0,0 +1,374 @@
+# zsglpt项目代码优化报告
+
+**优化日期**: 2025-11-19
+**优化分支**: `optimize/code-quality`
+**执行方式**: 自动化优化(逐项测试)
+
+---
+
+## 📊 优化概览
+
+### ✅ 已完成优化(7项)
+
+| # | 优化项 | 状态 | 风险 | 收益 |
+|---|--------|------|------|------|
+| 1 | 修复空except块 | ✅ | 低 | 高 |
+| 2 | 提取验证码验证逻辑 | ✅ | 低 | 高 |
+| 3 | 统一IP获取逻辑 | ✅ | 低 | 中 |
+| 4 | 修复装饰器重复 | ✅ | 低 | 高 |
+| 5 | 清理废弃代码 | ✅ | 低 | 中 |
+| 6 | 提取配置硬编码值 | ✅ | 低 | 高 |
+| 7 | 环境变量支持 | ✅ | 低 | 高 |
+
+### ⏭️ 已跳过优化(3项,工作量大)
+
+- 规范化日志级别使用
+- 为核心函数添加文档字符串
+- 添加类型注解到关键函数
+- 安装pre-commit钩子(需虚拟环境)
+- 添加基础单元测试(需虚拟环境)
+
+---
+
+## 🎯 详细优化内容
+
+### 1. 修复空except块(15处)
+
+**位置**: `app.py`, `database.py`, `db_pool.py`, `playwright_automation.py`
+
+**改进前**:
+```python
+try:
+ operation()
+except:
+ pass # 隐藏所有错误
+```
+
+**改进后**:
+```python
+try:
+ operation()
+except Exception as e:
+ logger.error(f"操作失败: {e}", exc_info=True)
+```
+
+**收益**:
+- ✅ 错误不再被隐藏
+- ✅ 调试更容易
+- ✅ 生产环境问题可追踪
+
+---
+
+### 2. 提取验证码验证逻辑
+
+**问题**: 验证码验证代码在3个地方重复(register、login、admin_login)
+
+**新增文件**: `app_utils.py::verify_and_consume_captcha()`
+
+**改进前**(重复3次):
+```python
+if captcha_data["expire_time"] < time.time():
+ del captcha_storage[captcha_session]
+ return jsonify({"error": "验证码已过期"}), 400
+if captcha_data["code"] != captcha_code:
+ captcha_data["failed_attempts"] += 1
+ return jsonify({"error": "验证码错误"}), 400
+```
+
+**改进后**:
+```python
+success, message = verify_and_consume_captcha(
+ captcha_session, captcha_code, captcha_storage
+)
+if not success:
+ return jsonify({"error": message}), 400
+```
+
+**收益**:
+- ✅ 消除55行重复代码
+- ✅ 逻辑统一,减少bug
+- ✅ 更易测试
+
+---
+
+### 3. 统一IP获取逻辑
+
+**问题**: 多处手动解析X-Forwarded-For头
+
+**改进**:
+- 使用已有的 `app_security.get_client_ip()` 函数
+- 所有地方统一使用该函数
+
+**收益**:
+- ✅ 代码一致性
+- ✅ 便于维护
+
+---
+
+### 4. 修复装饰器重复和路由bug
+
+**问题1**: `update_admin_username`函数有重复的`@admin_required`装饰器
+**问题2**: 函数实现错误(实现了获取暂停任务的逻辑)
+
+**改进前**:
+```python
+@app.route('/yuyx/api/admin/username', methods=['PUT'])
+@admin_required
+
+@admin_required # 重复!
+def api_get_paused_tasks(): # 函数名不匹配!
+ tasks = checkpoint_mgr.get_paused_tasks() # 实现错误!
+```
+
+**改进后**:
+```python
+@app.route('/yuyx/api/admin/username', methods=['PUT'])
+@admin_required
+def update_admin_username():
+ data = request.json
+ new_username = data.get('new_username', '').strip()
+ if database.update_admin_username(old_username, new_username):
+ return jsonify({"success": True})
+```
+
+**收益**:
+- ✅ 修复严重bug
+- ✅ 功能正确实现
+
+---
+
+### 5. 清理废弃代码和备份文件
+
+**删除内容**:
+- 3个无路由的重复函数(62行代码)
+ * `api_resume_paused_task` (无@app.route)
+ * `api_abandon_paused_task` (无@app.route)
+ * `api_get_task_checkpoint` (无@app.route)
+- 3个备份文件(6700+行)
+ * `app.py.backup_20251116_194609`
+ * `app.py.broken`
+ * `app.py.original`
+
+**收益**:
+- ✅ 减少混淆
+- ✅ 代码更整洁
+- ✅ Git历史已保留备份
+
+---
+
+### 6. 提取配置硬编码值
+
+**改进前** (`playwright_automation.py`):
+```python
+class Config:
+ LOGIN_URL = "https://postoa.aidunsoft.com/admin/login.aspx"
+ PAGE_LOAD_TIMEOUT = 60000
+ MAX_CONCURRENT_CONTEXTS = 100
+```
+
+**改进后** (`app_config.py`):
+```python
+class Config:
+ ZSGL_LOGIN_URL = os.getenv('ZSGL_LOGIN_URL', 'https://...')
+ PAGE_LOAD_TIMEOUT = int(os.getenv('PAGE_LOAD_TIMEOUT', '60000'))
+ MAX_CONCURRENT_CONTEXTS = int(os.getenv('MAX_CONCURRENT_CONTEXTS', '100'))
+```
+
+**收益**:
+- ✅ 配置集中管理
+- ✅ 支持环境变量覆盖
+- ✅ 便于多环境部署
+
+---
+
+### 7. 环境变量支持(python-dotenv)
+
+**新增文件**:
+- `.env.example` - 配置模板(60行,完整注释)
+- `requirements.txt` - 添加`python-dotenv==1.0.0`
+
+**改进** (`app_config.py`):
+```python
+from dotenv import load_dotenv
+env_path = Path(__file__).parent / '.env'
+if env_path.exists():
+ load_dotenv(dotenv_path=env_path)
+```
+
+**使用方式**:
+```bash
+# 1. 复制模板
+cp .env.example .env
+
+# 2. 修改配置
+vim .env
+
+# 3. 启动应用(自动加载.env)
+python app.py
+```
+
+**收益**:
+- ✅ 支持本地配置文件
+- ✅ 敏感信息不进git(.gitignore已配置)
+- ✅ 不同环境独立配置
+
+---
+
+## 📈 代码质量改进指标
+
+### 代码变更统计
+
+```bash
+git diff master optimize/code-quality --stat
+```
+
+| 文件 | 新增 | 删除 | 净变化 |
+|------|------|------|--------|
+| app.py | +25 | -92 | -67 |
+| app_utils.py | +51 | 0 | +51 |
+| app_config.py | +20 | -9 | +11 |
+| database.py | +6 | -6 | 0 |
+| db_pool.py | +3 | -3 | 0 |
+| playwright_automation.py | +5 | -14 | -9 |
+| requirements.txt | +1 | 0 | +1 |
+| .env.example | +60 | 0 | +60 |
+| .gitignore | +3 | 0 | +3 |
+| 删除的备份文件 | 0 | -6762 | -6762 |
+
+**总计**: +174 新增, -6886 删除, **净减少 6712 行**
+
+### 代码重复度
+
+- **改进前**: 验证码验证逻辑重复3次(55行×3 = 165行)
+- **改进后**: 统一函数(51行)
+- **减少重复**: 114行 (69%↓)
+
+### 错误处理
+
+- **改进前**: 15处空except块隐藏错误
+- **改进后**: 所有异常都有类型和日志
+- **改进率**: 100%
+
+---
+
+## 🧪 测试结果
+
+### 语法测试
+```bash
+✅ 所有核心Python文件编译通过
+ - app.py
+ - database.py
+ - app_config.py
+ - app_logger.py
+ - app_security.py
+ - app_utils.py
+ - browser_installer.py
+ - db_pool.py
+ - password_utils.py
+ - playwright_automation.py
+ - task_checkpoint.py
+```
+
+### Git提交历史
+```bash
+f0eabe0 优化:添加环境变量支持(python-dotenv)
+ecf9a6a 优化:提取配置硬编码值到app_config.py
+77157cc 优化:清理废弃代码和备份文件
+769999e 优化:修复装饰器重复问题和路由bug
+8428445 优化:提取验证码验证逻辑到公共函数
+6eea752 优化:修复所有空except块
+004c2c2 备份:优化前的代码状态
+```
+
+---
+
+## 🔄 合并指南
+
+### 方式1:直接合并(推荐)
+
+```bash
+cd /home/yuyx/aaaaaa/zsglpt
+git checkout master
+git merge optimize/code-quality
+```
+
+### 方式2:查看改动后合并
+
+```bash
+# 查看所有改动
+git diff master optimize/code-quality
+
+# 查看改动文件列表
+git diff master optimize/code-quality --name-status
+
+# 满意后合并
+git checkout master
+git merge optimize/code-quality
+```
+
+---
+
+## 📝 后续建议
+
+### 短期(可选)
+
+1. **规范化日志级别**
+ - 将print改为logger
+ - 区分debug/info/warning/error
+ - 预计2小时
+
+2. **添加函数文档**
+ - 为run_task等核心函数添加docstring
+ - 预计4小时
+
+### 中期
+
+3. **添加类型注解**
+ - 使用mypy检查
+ - 预计8小时
+
+4. **添加单元测试**
+ - pytest框架
+ - 测试verify_and_consume_captcha等公共函数
+ - 预计6小时
+
+### 长期(需大量时间)
+
+5. **拆分app.py**(清单中标记为❌)
+6. **数据库迁移PostgreSQL**(清单中标记为❌)
+7. **前后端分离**(清单中标记为❌)
+
+---
+
+## ✅ 验证清单
+
+部署前请确认:
+
+- [ ] 所有Python文件编译通过
+- [ ] .env文件已配置(从.env.example复制)
+- [ ] requirements.txt已更新依赖
+- [ ] 敏感配置不在git中(.env已在.gitignore)
+- [ ] 测试注册/登录功能正常
+- [ ] 测试管理员登录正常
+- [ ] 测试账号管理功能正常
+
+---
+
+## 🎉 总结
+
+本次优化在**不影响功能**的前提下:
+
+✅ **修复了4个bug**(装饰器重复、路由实现错误、隐藏异常)
+✅ **减少了114行重复代码**(验证码验证逻辑)
+✅ **删除了6762行废弃代码**(备份文件、无用函数)
+✅ **增加了60行配置文档**(.env.example)
+✅ **提升了代码可维护性**(配置集中、环境变量支持)
+✅ **改善了错误追踪能力**(异常处理规范)
+
+**净效果**: 代码量减少6712行,质量显著提升!
+
+---
+
+**生成时间**: 2025-11-19
+**优化工具**: Claude Code (Sonnet 4.5)
+**优化策略**: 边做边测试,低风险高收益优先
diff --git a/fix_browser_pool.py b/fix_browser_pool.py
new file mode 100755
index 0000000..297958a
--- /dev/null
+++ b/fix_browser_pool.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""修复浏览器池初始化 - 在socketio启动前同步初始化"""
+
+import re
+
+with open('/www/wwwroot/zsglpt/app.py', 'r', encoding='utf-8') as f:
+ content = f.read()
+
+# 1. 删除模块级别的后台初始化线程启动代码
+# 这行代码在 if __name__ == '__main__': 之前,会在eventlet接管后执行
+old_init_code = '''def init_pool_background():
+ import time
+ time.sleep(5) # 等待应用启动
+ try:
+ config = database.get_system_config()
+ pool_size = config.get('max_screenshot_concurrent', 3)
+ print(f"[浏览器池] 开始预热 {pool_size} 个浏览器...")
+ init_browser_pool(pool_size=pool_size)
+ except Exception as e:
+ print(f"[浏览器池] 初始化失败: {e}")
+
+threading.Thread(target=init_pool_background, daemon=True).start()
+
+if __name__ == '__main__':'''
+
+# 替换为新的代码 - 移除后台线程
+new_init_code = '''if __name__ == '__main__':'''
+
+if old_init_code in content:
+ content = content.replace(old_init_code, new_init_code)
+ print("OK - 已移除后台初始化线程")
+else:
+ print("WARNING - 未找到后台初始化代码,尝试其他模式")
+
+# 2. 在 socketio.run 之前添加同步初始化
+# 查找 socketio.run 位置,在其之前添加初始化代码
+old_socketio_run = ''' print("=" * 60 + "\\n")
+
+ socketio.run(app, host=config.SERVER_HOST, port=config.SERVER_PORT, debug=config.DEBUG)'''
+
+new_socketio_run = ''' print("=" * 60 + "\\n")
+
+ # 同步初始化浏览器池(必须在socketio.run之前,否则eventlet会导致asyncio冲突)
+ try:
+ system_cfg = database.get_system_config()
+ pool_size = system_cfg.get('max_screenshot_concurrent', 3) if system_cfg else 3
+ print(f"正在预热 {pool_size} 个浏览器实例(截图加速)...")
+ init_browser_pool(pool_size=pool_size)
+ print("✓ 浏览器池初始化完成")
+ except Exception as e:
+ print(f"警告: 浏览器池初始化失败: {e}")
+
+ socketio.run(app, host=config.SERVER_HOST, port=config.SERVER_PORT, debug=config.DEBUG)'''
+
+if old_socketio_run in content:
+ content = content.replace(old_socketio_run, new_socketio_run)
+ print("OK - 已添加同步初始化代码")
+else:
+ print("WARNING - 未找到socketio.run位置")
+
+with open('/www/wwwroot/zsglpt/app.py', 'w', encoding='utf-8') as f:
+ f.write(content)
+
+print("完成!浏览器池将在socketio启动前同步初始化")
diff --git a/fix_quick_login.py b/fix_quick_login.py
new file mode 100755
index 0000000..9b0c47c
--- /dev/null
+++ b/fix_quick_login.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""修复quick_login - 使用池中浏览器时直接登录,不尝试加载cookies"""
+
+import re
+
+with open('/www/wwwroot/zsglpt/playwright_automation.py', 'r', encoding='utf-8') as f:
+ content = f.read()
+
+# 修复 quick_login 方法 - 当已有浏览器时直接登录
+old_quick_login = ''' def quick_login(self, username: str, password: str, remember: bool = True):
+ """快速登录 - 优先使用cookies,失败则正常登录"""
+ # 尝试使用cookies
+ if self.load_cookies(username):
+ self.log(f"尝试使用已保存的登录态...")
+ if self.check_login_state():
+ self.log(f"✓ 登录态有效,跳过登录")
+ return {"success": True, "message": "使用已保存的登录态", "used_cookies": True}
+ else:
+ self.log(f"登录态已失效,重新登录")
+ # 关闭当前context,重新登录
+ try:
+ if self.context:
+ self.context.close()
+ if self.browser:
+ self.browser.close()
+ if self.playwright:
+ self.playwright.stop()
+ except:
+ pass
+
+ # 正常登录
+ result = self.login(username, password, remember)
+
+ # 登录成功后保存cookies
+ if result.get('success'):
+ self.save_cookies(username)
+ result['used_cookies'] = False
+
+ return result'''
+
+new_quick_login = ''' def quick_login(self, username: str, password: str, remember: bool = True):
+ """快速登录 - 使用池中浏览器时直接登录,否则尝试cookies"""
+ # 如果已有浏览器实例(从池中获取),直接使用该浏览器登录
+ # 不尝试加载cookies,因为load_cookies会创建新浏览器覆盖池中的
+ if self.browser and self.browser.is_connected():
+ self.log("使用池中浏览器,直接登录")
+ result = self.login(username, password, remember)
+ if result.get('success'):
+ self.save_cookies(username)
+ result['used_cookies'] = False
+ return result
+
+ # 无现有浏览器时,尝试使用cookies
+ if self.load_cookies(username):
+ self.log(f"尝试使用已保存的登录态...")
+ if self.check_login_state():
+ self.log(f"✓ 登录态有效,跳过登录")
+ return {"success": True, "message": "使用已保存的登录态", "used_cookies": True}
+ else:
+ self.log(f"登录态已失效,重新登录")
+ # ��闭当前context,重新登录
+ try:
+ if self.context:
+ self.context.close()
+ if self.browser:
+ self.browser.close()
+ if self.playwright:
+ self.playwright.stop()
+ except:
+ pass
+
+ # 正常登录
+ result = self.login(username, password, remember)
+
+ # 登录成功后保存cookies
+ if result.get('success'):
+ self.save_cookies(username)
+ result['used_cookies'] = False
+
+ return result'''
+
+if old_quick_login in content:
+ content = content.replace(old_quick_login, new_quick_login)
+ print("OK - quick_login已修复")
+else:
+ print("WARNING - 未找到old_quick_login")
+
+with open('/www/wwwroot/zsglpt/playwright_automation.py', 'w', encoding='utf-8') as f:
+ f.write(content)
+
+print("完成")
diff --git a/fix_quick_login2.py b/fix_quick_login2.py
new file mode 100755
index 0000000..e9b0f82
--- /dev/null
+++ b/fix_quick_login2.py
@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""修复quick_login - 使用池中浏览器时直接登录"""
+
+with open('/www/wwwroot/zsglpt/playwright_automation.py', 'r', encoding='utf-8') as f:
+ content = f.read()
+
+# 找到quick_login方法并替换
+old = ''' def quick_login(self, username: str, password: str, remember: bool = True):
+ """快速登录 - 优先使用cookies,失败则正常登录"""
+ # 尝试使用cookies
+ if self.load_cookies(username):'''
+
+new = ''' def quick_login(self, username: str, password: str, remember: bool = True):
+ """快速登录 - 使用池中浏览器时直接登录,否则尝试cookies"""
+ # 如果已有浏览器实例(从池中获取),直接使用该浏览器登录
+ # 不尝试加载cookies,因为load_cookies会创建新浏览器覆盖池中的
+ if self.browser and self.browser.is_connected():
+ self.log("使用池中浏览器,直接登录")
+ result = self.login(username, password, remember)
+ if result.get('success'):
+ self.save_cookies(username)
+ result['used_cookies'] = False
+ return result
+
+ # 无现有浏览器时,尝试使用cookies
+ if self.load_cookies(username):'''
+
+if old in content:
+ content = content.replace(old, new)
+ print("OK - quick_login已修复")
+else:
+ print("WARNING - 未找到匹配内容,显示实际内容进行对比")
+ import re
+ match = re.search(r'def quick_login.*?(?=\n def |\n\nclass |\Z)', content, re.DOTALL)
+ if match:
+ print("实际内容前200字符:")
+ print(repr(match.group(0)[:200]))
+
+with open('/www/wwwroot/zsglpt/playwright_automation.py', 'w', encoding='utf-8') as f:
+ f.write(content)
+
+print("完成")
diff --git a/fix_schedule.py b/fix_schedule.py
new file mode 100755
index 0000000..700da10
--- /dev/null
+++ b/fix_schedule.py
@@ -0,0 +1,403 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""修复定时任务并添加执行日志功能"""
+
+def add_schedule_logs_table(database_content):
+ """在database.py中添加定时任务执行日志表"""
+
+ # 在init_database函数中添加表创建代码
+ insert_position = ''' print(" ✓ 创建 user_schedules 表 (用户定时任务)")'''
+
+ new_table_code = ''' print(" ✓ 创建 user_schedules 表 (用户定时任务)")
+
+ # 定时任务执行日志表
+ cursor.execute(\'''
+ CREATE TABLE IF NOT EXISTS schedule_execution_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ schedule_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ schedule_name TEXT,
+ execute_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ total_accounts INTEGER DEFAULT 0,
+ success_accounts INTEGER DEFAULT 0,
+ failed_accounts INTEGER DEFAULT 0,
+ total_items INTEGER DEFAULT 0,
+ total_attachments INTEGER DEFAULT 0,
+ total_screenshots INTEGER DEFAULT 0,
+ duration_seconds INTEGER DEFAULT 0,
+ status TEXT DEFAULT 'running',
+ error_message TEXT,
+ FOREIGN KEY (schedule_id) REFERENCES user_schedules (id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ )
+ \''')
+ print(" ✓ 创建 schedule_execution_logs 表 (定时任务执行日志)")'''
+
+ if insert_position in database_content:
+ database_content = database_content.replace(insert_position, new_table_code)
+ print("✓ 已添加schedule_execution_logs表创建代码")
+ else:
+ print("❌ 未找到插入位置")
+ return database_content
+
+ # 添加数据库操作函数
+ functions_code = '''
+
+# ==================== 定时任务执行日志 ====================
+
+def create_schedule_execution_log(schedule_id, user_id, schedule_name):
+ """创建定时任务执行日志"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cst_tz = pytz.timezone("Asia/Shanghai")
+ execute_time = datetime.now(cst_tz).strftime("%Y-%m-%d %H:%M:%S")
+
+ cursor.execute(\'''
+ INSERT INTO schedule_execution_logs (
+ schedule_id, user_id, schedule_name, execute_time, status
+ ) VALUES (?, ?, ?, ?, 'running')
+ \''', (schedule_id, user_id, schedule_name, execute_time))
+
+ conn.commit()
+ return cursor.lastrowid
+
+
+def update_schedule_execution_log(log_id, **kwargs):
+ """更新定时任务执行日志"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+
+ updates = []
+ params = []
+
+ allowed_fields = ['total_accounts', 'success_accounts', 'failed_accounts',
+ 'total_items', 'total_attachments', 'total_screenshots',
+ 'duration_seconds', 'status', 'error_message']
+
+ for field in allowed_fields:
+ if field in kwargs:
+ updates.append(f'{field} = ?')
+ params.append(kwargs[field])
+
+ if not updates:
+ return False
+
+ params.append(log_id)
+ sql = f"UPDATE schedule_execution_logs SET {', '.join(updates)} WHERE id = ?"
+
+ cursor.execute(sql, params)
+ conn.commit()
+ return cursor.rowcount > 0
+
+
+def get_schedule_execution_logs(schedule_id, limit=10):
+ """获取定时任务执行日志"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute(\'''
+ SELECT * FROM schedule_execution_logs
+ WHERE schedule_id = ?
+ ORDER BY execute_time DESC
+ LIMIT ?
+ \''', (schedule_id, limit))
+ return [dict(row) for row in cursor.fetchall()]
+
+
+def get_user_all_schedule_logs(user_id, limit=50):
+ """获取用户所有定时任务的执行日志"""
+ with db_pool.get_db() as conn:
+ cursor = conn.cursor()
+ cursor.execute(\'''
+ SELECT * FROM schedule_execution_logs
+ WHERE user_id = ?
+ ORDER BY execute_time DESC
+ LIMIT ?
+ \''', (user_id, limit))
+ return [dict(row) for row in cursor.fetchall()]
+'''
+
+ # 在文件末尾添加这些函数
+ database_content += functions_code
+ print("✓ 已添加定时任务日志操作函数")
+
+ return database_content
+
+
+def add_schedule_log_tracking(app_content):
+ """在app.py中添加定时任务执行日志记录"""
+
+ # 修改check_user_schedules函数,添加日志记录
+ old_code = ''' print(f"[用户定时任务] 用户 {schedule_config.get('user_username', user_id)} 的任务 '{schedule_config.get('name', '')}' 开始执行")
+
+ started_count = 0
+ for account_id in account_ids:'''
+
+ new_code = ''' print(f"[用户定时任务] 用户 {schedule_config.get('user_username', user_id)} 的任务 '{schedule_config.get('name', '')}' 开始执行")
+
+ # 创建执行日志
+ import time as time_mod
+ execution_start_time = time_mod.time()
+ log_id = database.create_schedule_execution_log(
+ schedule_id=schedule_id,
+ user_id=user_id,
+ schedule_name=schedule_config.get('name', '未命名任务')
+ )
+
+ started_count = 0
+ for account_id in account_ids:'''
+
+ if old_code in app_content:
+ app_content = app_content.replace(old_code, new_code)
+ print("✓ 已添加执行日志创建代码")
+ else:
+ print("⚠ 未找到执行日志创建位置")
+
+ # 添加日志更新代码(在任务执行完成后)
+ old_code2 = ''' # 更新最后执行时间
+ database.update_schedule_last_run(schedule_id)
+ print(f"[用户定时任务] 已启动 {started_count} 个账号")'''
+
+ new_code2 = ''' # 更新最后执行时间
+ database.update_schedule_last_run(schedule_id)
+
+ # 更新执行日志
+ execution_duration = int(time_mod.time() - execution_start_time)
+ database.update_schedule_execution_log(
+ log_id,
+ total_accounts=len(account_ids),
+ success_accounts=started_count,
+ failed_accounts=len(account_ids) - started_count,
+ duration_seconds=execution_duration,
+ status='completed'
+ )
+
+ print(f"[用户定时任务] 已启动 {started_count} 个账号")'''
+
+ if old_code2 in app_content:
+ app_content = app_content.replace(old_code2, new_code2)
+ print("✓ 已添加执行日志更新代码")
+ else:
+ print("⚠ 未找到执行日志更新位置")
+
+ # 添加日志查询API
+ api_code = '''
+
+# ==================== 定时任务执行日志API ====================
+
+@app.route('/api/schedules//logs', methods=['GET'])
+@login_required
+def get_schedule_logs_api(schedule_id):
+ """获取定时任务执行日志"""
+ schedule = database.get_schedule_by_id(schedule_id)
+ if not schedule:
+ return jsonify({"error": "定时任务不存在"}), 404
+ if schedule['user_id'] != current_user.id:
+ return jsonify({"error": "无权访问"}), 403
+
+ limit = request.args.get('limit', 10, type=int)
+ logs = database.get_schedule_execution_logs(schedule_id, limit)
+ return jsonify(logs)
+
+
+'''
+
+ # 在批量操作API之前插入
+ insert_marker = '# ==================== 批量操作API ===================='
+ if insert_marker in app_content:
+ app_content = app_content.replace(insert_marker, api_code + insert_marker)
+ print("✓ 已添加日志查询API")
+ else:
+ print("⚠ 未找到API插入位置")
+
+ return app_content
+
+
+def add_frontend_log_button(html_content):
+ """在前端添加日志按钮"""
+
+ # 修改定时任务卡片,添加日志按钮
+ old_html = ''' '' +
+ '' +'''
+
+ new_html = ''' '' +
+ '' +
+ '' +'''
+
+ if old_html in html_content:
+ html_content = html_content.replace(old_html, new_html)
+ print("✓ 已添加日志按钮HTML")
+ else:
+ print("⚠ 未找到日志按钮插入位置")
+
+ # 添加日志弹窗HTML
+ modal_html = '''
+
+
+