Compare commits

..

188 Commits

Author SHA1 Message Date
e725db79a9 优化后台金山状态:登录后静默预取并在系统配置页复用缓存 2026-02-16 01:42:47 +08:00
1389ec7434 安全修复:加固CSRF与凭证保护并修复越权风险 2026-02-16 01:19:43 +08:00
14b506e8a1 fix(frontend): 退出登录增加原生确认兜底,修复点击无响应 2026-02-16 00:45:10 +08:00
8c0403e0ff 安全增强: 增加日志敏感字段脱敏过滤器 2026-02-16 00:36:47 +08:00
7d42f96e42 安全修复: 收敛认证与日志风险并补充基础测试 2026-02-16 00:34:52 +08:00
7627885b1b fix(passkey): 修复安卓端 Credential Manager 异常并增强兼容
更新说明:\n1. 优化 Passkey 注册参数(residentKey/hints),提升安卓设备兼容性。\n2. 前台与后台统一增强 Passkey 错误提示,针对 NotReadableError/小米浏览器给出明确引导。\n3. 同步更新相关前端页面逻辑与构建产物。
2026-02-16 00:17:11 +08:00
cb35df5f01 fix(front): 修复前台退出登录确认弹窗样式丢失
更新说明:\n1. 在用户端 AppLayout 显式引入 Element Plus 的 Message/MessageBox 样式。\n2. 修复退出登录确认弹窗偶发样式异常(看起来像 JS 未加载)的显示问题。\n3. 同步更新前台构建产物与 manifest。
2026-02-15 23:59:08 +08:00
7007f5f6f5 feat: 完成 Passkey 能力与前后台加载优化
更新说明:\n1. 新增用户端与管理员端 Passkey 登录/注册/设备管理(最多3台,支持设备备注、删除设备)。\n2. 修复 Passkey 注册与登录流程中的浏览器/证书/CSRF相关问题,增强错误提示。\n3. 前台登录页改为独立入口,首屏仅加载必要资源,其他页面按需加载。\n4. 系统配置页改为静默获取金山文档状态,避免首屏阻塞,并优化状态展示为“检测中/已登录/未登录/异常”。\n5. 补充后端接口与页面渲染适配,修复多入口下样式依赖注入问题。\n6. 同步更新前后台构建产物与相关静态资源。
2026-02-15 23:51:46 +08:00
ebfac7266b security: harden proxy IP trust, token flow, health and sessions 2026-02-09 09:14:47 +08:00
f645a0f8ea perf(front): reduce login page preload and split frontend vendor chunks 2026-02-07 21:45:26 +08:00
08864e51ba security: harden admin password change and production session headers 2026-02-07 21:37:55 +08:00
7997a97a9a refactor(admin): remove legacy admin fallback page and routing 2026-02-07 19:50:15 +08:00
122e12728c chore(repo): clean template leftovers and refresh README for current deployment 2026-02-07 19:01:10 +08:00
225abbe7b6 fix(repo): restore runtime key/state files to avoid deploy breakage 2026-02-07 18:49:18 +08:00
855b1e340b chore(repo): remove cleanup report and runtime state files from git 2026-02-07 18:48:20 +08:00
ed0b74eae3 perf(report): avoid duplicate initial stats sync in dashboard loop 2026-02-07 18:39:11 +08:00
4874aa37f6 perf(frontend): add api cache layer and reduce report polling pressure 2026-02-07 18:36:55 +08:00
c285d1e348 fix(frontend): restore stable element-plus bootstrap to resolve admin crash 2026-02-07 18:11:23 +08:00
06fe7f6f68 perf(frontend): on-demand element plus imports and dedupe stats requests 2026-02-07 17:43:18 +08:00
99ecbcf55e perf(logging): reduce allow-strategy log noise via env switch 2026-02-07 17:35:28 +08:00
43f1867033 perf(runtime): switch socketio to eventlet and optimize asset chunk caching 2026-02-07 16:09:21 +08:00
9d1d4d701e feat(report): show live slow-sql threshold in header 2026-02-07 14:55:15 +08:00
b84a5abb8a feat(config): add live slow-sql threshold setting 2026-02-07 14:31:24 +08:00
6a9858cdec feat(report): add 24h slow-sql dashboard and metrics api 2026-02-07 14:07:07 +08:00
52dd7ac9e5 fix(db): persist actual schema version after migrations 2026-02-07 13:47:47 +08:00
dd7f03ef94 perf(db): add slow-query tracing and composite indexes 2026-02-07 13:44:58 +08:00
ff67a9bbab perf(db): tune sqlite pool and add maintenance scheduler 2026-02-07 12:53:43 +08:00
d77e439712 fix(build): stabilize vendor chunking to avoid element-plus init error 2026-02-07 12:30:13 +08:00
e93db6fbf1 feat(report): add drilldown dialog for slow API details 2026-02-07 12:24:44 +08:00
592d48dde0 feat(report): add slow API ranking module for admin 2026-02-07 12:19:53 +08:00
a50294933b perf(stability): add request metrics and resilient API retries 2026-02-07 11:58:21 +08:00
04b94d7fb2 perf: optimize polling, stats cache, and frontend chunk splitting 2026-02-07 11:41:49 +08:00
21c537da10 feat(screenshots): serve thumbnails while keeping original for preview and copy 2026-02-07 11:02:16 +08:00
2d5be0feb2 refactor(report): remove duplicated detail section and keep compact cards 2026-02-07 10:16:35 +08:00
462e12ca0d feat(admin): align desktop report to compact module layout 2026-02-07 10:06:40 +08:00
ce96b17392 fix(admin): include overview metrics in mobile report cards 2026-02-07 09:57:04 +08:00
69e3e4c45c feat(admin): compact mobile cards for report center 2026-02-07 09:54:11 +08:00
12e07962c7 chore(admin): remove manual refresh buttons across pages 2026-02-07 09:47:17 +08:00
dd9cc5a76d fix: open mobile admin drawer from left side 2026-02-07 09:40:53 +08:00
f7832c3c15 fix: use process uptime and host-service stats fallback 2026-02-07 09:13:20 +08:00
d097571f62 fix: prevent report flicker on auto refresh 2026-02-07 09:06:52 +08:00
121251a1f2 feat: smooth report refresh and redesign system settings mobile UI 2026-02-07 08:57:25 +08:00
6eb0651e23 feat: redesign admin layout and stats dashboards 2026-02-07 01:59:29 +08:00
9991834ccd feat: unify login UI and improve kdocs defaults 2026-02-07 01:27:00 +08:00
bf29ac1924 refactor: optimize structure, stability and runtime performance 2026-02-07 00:35:11 +08:00
fae21329d7 优化 KDocs 上传器
- 删除死代码 (二分搜索相关方法,减少 ~186 行)
- 优化 sleep 等待时间,减少约 30% 的等待
- 添加缓存过期机制 (5分钟 TTL)
- 优化日志级别,减少调试日志噪音

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:09:46 +08:00
f46f325518 fix(frontend): 修复登录失败时通知弹两次的问题
- 在登录页面不再由 http.js 拦截器弹出 401 通知
- 让 LoginPage.vue 自己处理登录错误的显示
- 避免同一错误消息重复弹出
2026-01-21 19:45:43 +08:00
156d3a97b2 fix(kdocs): 修复上传线程卡住和超时问题
1. 禁用无效的二分搜索 - _get_cell_value_fast() 使用的 DOM 选择器在金山文档中不存在
2. 移除 _upload_image_to_cell 中重复的导航调用
3. 为 expect_file_chooser 添加 15 秒超时防止无限阻塞
4. 包含看门狗自动恢复机制(之前已实现)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:02:08 +08:00
Yu Yon
f90d840dfe docs: 添加加密密钥配置说明
- 在部署文档中添加加密密钥配置章节
- 说明 .env 文件使用方法
- 添加密钥迁移指南
- 在环境变量表格中添加 ENCRYPTION_KEY_RAW 说明
- 添加密钥丢失警告

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 09:41:54 +08:00
Yu Yon
dfc93bce2e feat(security): 增强密码加密安全机制
- 新增 ENCRYPTION_KEY_RAW 环境变量支持,可直接使用 Fernet 密钥
- 添加密钥丢失保护机制,防止在有加密数据时意外生成新密钥
- 新增 verify_encryption_key() 函数用于启动时验证密钥
- docker-compose.yml 改为从 .env 文件读取敏感配置
- 新增 crypto_utils.py 文件挂载,支持热更新

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 09:31:15 +08:00
10be464265 fix: 修复连接池计数和任务调度器默认值问题
1. db_pool.py - 修复连接计数不一致问题
   - 将 _created_connections 递增移到 put() 成功之后
   - 确保 Full 异常和创建异常时正确关闭连接
   - 避免计数器永久偏高

2. services/tasks.py - 统一 _running_by_user 默认值
   - 将减少计数时的默认值从 1 改为 0
   - 与增加计数时的默认值保持一致
   - 添加注释说明

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 22:46:40 +08:00
e65485cb1e fix: 修复自动重试的竞态条件问题
问题:delayed_retry_submit 闭包捕获的是旧的 account 对象
- 5秒后检查 should_stop 时,可能检查的是旧对象
- 如果账户被删除/重建,会导致状态检查不可靠
- 可能导致重复任务提交

修复:
- 在 delayed_retry_submit 中重新调用 safe_get_account 获取最新账户对象
- 添加账户不存在的检查
- 添加取消时的日志输出,便于调试

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 22:32:37 +08:00
42609651bd fix: 修复截图登录检查逻辑的条件判断错误
问题:attempt > 0 应该是 attempt > 1
- attempt 从 range(1, max_retries + 1) 开始,值为 1, 2, 3
- 原条件 attempt > 0 在 attempt=1 时就为 True
- 导致 elif 分支(首次尝试逻辑)成为死代码

修复:
- 将 attempt > 0 改为 attempt > 1
- 更新注释使其更清晰准确

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 22:16:01 +08:00
zsglpt Optimizer
072fbcbe18 🔧 更新.gitignore,忽略剩余的目录
 添加忽略规则:
- 截图/ (中文命名截图目录)
- ruff_cache/ (代码检查缓存)

🛡️ 目的:
- 确保所有运行时生成的文件都被忽略
- 防止目录意外提交
- 保持仓库干净整洁
2026-01-16 17:55:03 +08:00
zsglpt Optimizer
3702026f9a 🧹 清理测试和工具目录
 删除的文件:
- tests/ 目录及所有11个测试文件
- tools/ 目录及update_agent.py

 更新.gitignore:
- 添加tests/和tools/目录的忽略规则

🎯 原因:
- tests目录包含单元测试,不应在生产仓库
- tools目录包含开发工具脚本,对用户无用
- 保持仓库纯净,只包含生产代码

📊 清理统计:
- 删除文件数:13个
- 涉及目录:2个
- 仓库更加简洁专业
2026-01-16 17:54:23 +08:00
zsglpt Optimizer
00597fb3b7 🧹 删除本地文档文件的最终提交 2026-01-16 17:50:20 +08:00
zsglpt Optimizer
42e88f4924 🧹 最终清理:删除所有非README.md文档
 删除的文档文件:
- BUG_REPORT.md (开发过程中的bug记录)
- CLEANUP_SUMMARY.md (开发者内部文档)
- DATABASE_UPGRADE_COMPATIBILITY.md (临时技术文档)
- GIT_PUSH_SUCCESS.md (开发者内部报告)
- LINUX_DEPLOYMENT_ANALYSIS.md (临时分析文档)
- PERFORMANCE_ANALYSIS_REPORT.md (临时性能报告)
- SCREENSHOT_FIX_SUCCESS.md (过时的问题解决记录)

 保留的内容:
- README.md (项目主要文档,包含完整说明)
- 核心应用代码
- Docker配置文件
- 依赖文件

🎯 理由:
- 项目仓库应该保持简洁专业
- README.md已经包含足够的使用说明
- 其他技术细节可以在项目Wiki中维护
- 避免仓库被开发过程文档污染

📝 .gitignore更新:
- 添加规则只允许根目录存在README.md
- 防止将来推送其他markdown文档
- 保持仓库的长期整洁
2026-01-16 17:49:54 +08:00
zsglpt Optimizer
56b3ca4e59 🔧 修复.gitignore,正确忽略data目录
- 删除旧的data/*规则
- 添加统一的data/规则
- 确保运行时数据文件不会被意外提交
2026-01-16 17:48:28 +08:00
zsglpt Optimizer
92d4e2ba58 🧹 第二轮清理:删除过时文档和开发文件
 删除的文件:
- AUTO_LOGIN_GUIDE.md (关于已删除测试文件的文档)
- README_OPTIMIZATION.md (过时的优化说明)
- TESTING_GUIDE.md (测试指南,已删除相关文件)
- SIMPLE_OPTIMIZATION_VERSION.md (过时的优化文档)
- ENCODING_FIXES.md (编码问题已解决,不再需要)
- INSTALL_WKHTMLTOIMAGE.md (截图问题已解决)
- OPTIMIZATION_FIXES_SUMMARY.md (过时,优化已完成)
- kdocs_optimized_uploader.py (开发测试文件)

 保留的文档:
- BUG_REPORT.md (项目bug分析)
- PERFORMANCE_ANALYSIS_REPORT.md (性能分析报告)
- LINUX_DEPLOYMENT_ANALYSIS.md (Linux部署指南)
- DATABASE_UPGRADE_COMPATIBILITY.md (数据库升级指南)
- GIT_PUSH_SUCCESS.md (推送成功报告)
- CLEANUP_SUMMARY.md (清理总结)

🎯 目标:
- 保持仓库专业化
- 只保留当前项目需要的文档
- 删除过时和重复的信息
2026-01-16 17:48:03 +08:00
zsglpt Optimizer
67340f75be 📚 添加升级兼容性指南和推送成功报告 2026-01-16 17:44:23 +08:00
zsglpt Optimizer
803fe436d3 🧹 清理不必要的文件,保持仓库整洁
 删除的文件:
- 测试文件 (test_*.py, kdocs_*test*.py, simple_test.py)
- 启动脚本 (start_*.bat)
- 临时修复文件 (temp_*.py)
- 图片文件 (qr_code_*.png, screenshots/*)
- 运行时生成文件

 添加的内容:
- .gitignore 文件,防止推送临时文件和开发文件

📋 保留的内容:
- 核心应用代码
- 数据库迁移文件
- Docker配置文件
- 必要的文档 (BUG_REPORT.md, PERFORMANCE_ANALYSIS_REPORT.md, 等)

🎯 目的:
- 保持仓库整洁专业
- 只包含生产环境需要的文件
- 避免推送临时开发文件
- 提高仓库维护性
2026-01-16 17:44:04 +08:00
zsglpt Optimizer
7e9a772104 🎉 项目优化与Bug修复完整版
 主要优化成果:
- 修复Unicode字符编码问题(Windows跨平台兼容性)
- 安装wkhtmltoimage,截图功能完全修复
- 智能延迟优化(api_browser.py)
- 线程池资源泄漏修复(tasks.py)
- HTML解析缓存机制
- 二分搜索算法优化(kdocs_uploader.py)
- 自适应资源配置(browser_pool_worker.py)

🐛 Bug修复:
- 解决截图失败问题
- 修复管理员密码设置
- 解决应用启动编码错误

📚 新增文档:
- BUG_REPORT.md - 完整bug分析报告
- PERFORMANCE_ANALYSIS_REPORT.md - 性能优化分析
- LINUX_DEPLOYMENT_ANALYSIS.md - Linux部署指南
- SCREENSHOT_FIX_SUCCESS.md - 截图功能修复记录
- INSTALL_WKHTMLTOIMAGE.md - 安装指南
- OPTIMIZATION_FIXES_SUMMARY.md - 优化总结

🚀 功能验证:
- Flask应用正常运行(51233端口)
- 数据库、截图线程池、API预热正常
- 管理员登录:admin/admin123
- 健康检查API:http://127.0.0.1:51233/health

💡 技术改进:
- 智能延迟算法(自适应调整)
- LRU缓存策略
- 线程池资源管理优化
- 二分搜索算法(O(log n) vs O(n))
- 自适应资源管理

🎯 项目现在稳定运行,可部署到Linux环境
2026-01-16 17:39:55 +08:00
722dccdc78 fix: 登录路由添加CSRF豁免,解决重启后无法登录的问题
- 添加 /yuyx/api/login, /api/login, /api/auth/login 路由的CSRF豁免
- 登录本身就是建立session的过程,不需要CSRF保护
- 解决服务重启后旧session导致CSRF验证失败的问题

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:29:32 +08:00
606cad43dc fix: 修复无附件文章无法标记已读的问题
- 发现标记已读的正确 API: /tools/submit_ajax.ashx?action=saveread
- 添加 mark_article_read 方法调用 saveread API 标记文章已读
- 修改 get_article_attachments 返回文章的 channel_id 和 article_id
- 对每篇文章都调用 mark_article_read,无论是否有附件
- 解决米米88、黄紫夏99等账号文章无法标记已读的问题

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:24:29 +08:00
6313631b09 fix: 改进分页逻辑,确保遍历所有页面不漏掉内容
- 当前页有新文章时,重新获取第1页(已读文章消失后页面上移)
- 当前页无新文章时,继续检查后续页面
- 遍历完所有页面后才结束循环
- 解决 mark_read 延迟导致后续页面内容被漏掉的问题

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:17:52 +08:00
09188b8765 fix: 防止浏览时无限循环重复处理已处理文章
- 添加 processed_hrefs 集合跟踪已处理的文章 href
- 处理文章前检查是否已处理过,避免重复处理
- 添加 new_articles_in_page 计数器,当前页无新文章时退出循环
- 解决 mark_read 未立即生效导致的无限循环问题

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:14:22 +08:00
b2b0dfd500 fix: 修复分页错位问题,改为循环获取第1页直到清空
问题:标记已读后文章从列表消失,导致后续页面上移,
造成按页码遍历时遗漏部分内容。

解决:每次处理完当前页后重新获取第1页,循环直到没有内容。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:08:34 +08:00
2ff9e18842 fix: 修复附件解析正则,匹配 download2.ashx
正则从 download\.ashx 改为 download2?\.ashx
以同时支持新旧两种附件下载链接格式

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:48:58 +08:00
1bd49f804c docs: 更新浏览逻辑注释,反映网站参数变更
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:45:40 +08:00
f8bbe3da0d fix: 修复应读参数,bz=2 改为 bz=0(适配网站更新)
网站参数变更:
- bz=0: 应读
- bz=1: 已读
- bz=2: 已读(旧参数,已废弃)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:43:25 +08:00
1b85f34a0f fix: 恢复截图顺序,保持完整框架样式
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:38:52 +08:00
f04c5c1c8f fix: 适配网站结构更新
1. 标记已读改用预览通道 (download2.ashx)
2. 截图优先直接访问目标页面,避免 iframe 加载问题

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:31:22 +08:00
Yu Yon
b1484e9c03 fix: 修复多任务上传状态显示问题
1. 后端: 上传完成后恢复为"未开始"状态,不再保持"等待上传"
2. 前端: 调整状态颜色
   - 上传截图(上传中): 红色
   - 等待上传: 黄色
   - 已完成: 绿色
2026-01-09 09:21:30 +08:00
Yu Yon
7f5e9d5244 feat: 多任务上传时显示等待上传状态
- 任务入队时设置状态为"等待上传"
- 实际上传时更新为"上传截图"
- 用户可以更直观地看到多任务上传进度
2026-01-09 09:09:00 +08:00
Yu Yon
0ca6dfe5a7 fix: 修复容器运行时长显示(使用/proc计算实际容器运行时间) 2026-01-08 23:15:18 +08:00
Yu Yon
15fe2093c2 fix: Dockerfile添加curl支持健康检查 2026-01-08 17:59:14 +08:00
30b6e3144b fix: database.py 添加缺失的 kdocs_row_start/row_end 参数
修复保存金山文档配置时报 500 错误的问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 01:03:19 +08:00
da71b0ac5e docs: 修正 README 中截图引擎描述
- 截图使用 wkhtmltoimage(不是 Playwright)
- Playwright 仅用于金山文档表格操作
- 修正技术栈、项目结构、更新日志相关描述

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 01:00:08 +08:00
3c6799ce53 docs: 更新 README 文档至 v2.0
- 更新项目简介,添加新功能描述
- 更新技术栈(Vue 3, Playwright, Element Plus)
- 更新项目结构,添加新模块说明
- 添加更新日志章节,记录 v2.0 主要变更:
  - 金山文档集成
  - Vue 3 SPA 前端
  - 用户自定义定时任务
  - 安全防护系统
  - 邮件通知系统
  - 公告/反馈系统
  - 截图引擎升级等

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 00:58:16 +08:00
a3060e4cd9 feat: Vue SPA 添加 KDocs 在线状态显示 + 清理废弃模板
功能更新:
- AccountsPage.vue: 工具栏显示 KDocs 在线状态(就绪/离线)
- settings.js: 添加 fetchKdocsStatus API 函数
- 每60秒自动刷新状态

代码清理:
- 删除废弃的 legacy 模板文件(约170KB)
  - templates/index.html
  - templates/login.html
  - templates/register.html
  - templates/reset_password.html

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 00:31:42 +08:00
be9ec5e9a2 feat: 用户端显示金山文档在线状态
- 新增 /api/kdocs/status 接口(用户端简化版)
- 工具栏显示"表格上传:  就绪"或"⚠️ 离线"
- 页面加载时获取状态,每60秒自动刷新
- 系统未启用时不显示

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:56:16 +08:00
b0fe325154 feat: KDocs 上传增强 + 离线监控 + Bug修复
KDocs 上传功能增强:
- 搜索优化:只用姓名搜索 + C列验证,避免匹配到错误单元格
- 有效行范围:支持配置起始行/结束行,限制上传区域
- 图片覆盖:支持覆盖单元格已有图片(Escape + Delete)
- 配置持久化:kdocs_row_start/row_end 保存到数据库(v18迁移)

二次登录功能:
- 登录后立即再次登录,让"上次登录时间"显示为刚刚

KDocs 离线监控:
- 每5分钟检测金山文档登录状态
- 离线时发送邮件通知管理员(每次掉线只通知一次)
- 恢复在线后重置通知状态

Bug 修复:
- 任务日志搜索账号关键词报错500:添加异常处理

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:40:46 +08:00
13544867aa Fix clipboard permissions for KDocs 2026-01-07 17:46:28 +08:00
5fd13fa152 Read KDocs cells via clipboard 2026-01-07 17:40:29 +08:00
a36fa3370b Show KDocs upload status 2026-01-07 17:31:50 +08:00
2ec0c7cb58 Speed up KDocs QR retrieval 2026-01-07 17:15:46 +08:00
3841358bc2 Add KDocs action feedback 2026-01-07 17:03:03 +08:00
6bd00021b8 Fix KDocs login detection 2026-01-07 16:53:44 +08:00
f2652af8fb Fix kdocs upload status restore 2026-01-07 15:16:04 +08:00
950af0efda Improve KDocs search matching 2026-01-07 15:03:51 +08:00
45cbdc51b4 Show upload status and log KDocs skips 2026-01-07 14:28:58 +08:00
703a62b6ad Increase KDocs QR timeout 2026-01-07 14:17:01 +08:00
ad847888f8 Avoid live KDocs status on page load 2026-01-07 14:12:54 +08:00
8c150dcb7c Auto poll KDocs login status 2026-01-07 14:04:09 +08:00
ec90404194 Validate and log QR capture 2026-01-07 13:56:16 +08:00
6af8f46129 Log and save KDocs QR screenshot 2026-01-07 13:49:37 +08:00
19f083df7b Auto click KDocs login and confirm 2026-01-07 13:44:15 +08:00
a04cbfa55f Broaden KDocs login click and modal capture 2026-01-07 13:33:26 +08:00
b78bc7935f Trigger KDocs WeChat login flow 2026-01-07 13:26:31 +08:00
d8897f893a Expand KDocs QR detection 2026-01-07 13:21:19 +08:00
95d7cbc825 Improve KDocs QR capture 2026-01-07 13:14:02 +08:00
6b416dc5f1 Force KDocs QR fetch and improve login detection 2026-01-07 13:07:57 +08:00
28e86b1147 Fix kdocs login status detection 2026-01-07 12:57:03 +08:00
1e216ea356 Fix kdocs runtime logger call 2026-01-07 12:49:54 +08:00
3bae759afc Integrate KDocs auto-upload 2026-01-07 12:32:41 +08:00
5137addacc Optimize scheduler status lookups 2026-01-06 15:58:23 +08:00
4c492122dd feat: support announcement image upload
# Conflicts:
#	database.py
#	db/migrations.py
#	routes/admin_api/core.py
#	static/admin/.vite/manifest.json
#	static/admin/assets/AnnouncementsPage-Btl9JP7M.js
#	static/admin/assets/EmailPage-CwqlBGU2.js
#	static/admin/assets/FeedbacksPage-B_qDNL3q.js
#	static/admin/assets/LogsPage-DzdymdrQ.js
#	static/admin/assets/ReportPage-Bp26gOA-.js
#	static/admin/assets/SettingsPage-__r25pN8.js
#	static/admin/assets/SystemPage-C1OfxrU-.js
#	static/admin/assets/UsersPage-DhnABKcY.js
#	static/admin/assets/email-By53DCWv.js
#	static/admin/assets/email-ByiJ74rd.js
#	static/admin/assets/email-DkWacopQ.js
#	static/admin/assets/index-D5wU2pVd.js
#	static/admin/assets/tasks-1acmkoIX.js
#	static/admin/assets/update-DdQLVpC3.js
#	static/admin/assets/users-B1w166uc.js
#	static/admin/assets/users-CPJP5r-B.js
#	static/admin/assets/users-CnIyvFWm.js
#	static/admin/index.html
#	static/app/.vite/manifest.json
#	static/app/assets/AccountsPage-C48gJL8c.js
#	static/app/assets/AccountsPage-D387XNsv.js
#	static/app/assets/AccountsPage-DBJCAsJz.js
#	static/app/assets/LoginPage-BgK_Vl6X.js
#	static/app/assets/RegisterPage-CwADxWfe.js
#	static/app/assets/ResetPasswordPage-CVfZX_5z.js
#	static/app/assets/SchedulesPage-CWuZpJ5h.js
#	static/app/assets/SchedulesPage-Dw-mXbG5.js
#	static/app/assets/SchedulesPage-DwzGOBuc.js
#	static/app/assets/ScreenshotsPage-C6vX2U3V.js
#	static/app/assets/ScreenshotsPage-CreOSjVc.js
#	static/app/assets/ScreenshotsPage-DuTeRzLR.js
#	static/app/assets/VerifyResultPage-BzGlCgtE.js
#	static/app/assets/VerifyResultPage-CN_nr4V6.js
#	static/app/assets/VerifyResultPage-CNbQc83z.js
#	static/app/assets/accounts-BFaVMUve.js
#	static/app/assets/accounts-BYq3lLev.js
#	static/app/assets/accounts-Bc9j2moH.js
#	static/app/assets/auth-Dk_ApO4B.js
#	static/app/assets/index-BIng7uZJ.css
#	static/app/assets/index-CDxVo_1Z.js
#	static/app/index.html
2026-01-06 12:15:16 +08:00
82acc3470f Ensure menu expanded in screenshots 2025-12-31 21:28:28 +08:00
2e44afde30 Capture full-page wkhtmltoimage shots 2025-12-31 20:50:02 +08:00
28f4e807a9 Fix wkhtmltoimage viewport crop 2025-12-31 20:23:31 +08:00
3b04f04a31 feat: 全屏截图改用管理后台框架 2025-12-31 20:12:39 +08:00
ea1c7e8a00 feat: wkhtmltoimage支持自定义高度 2025-12-31 20:05:39 +08:00
d269a99d3c fix: wkhtmltoimage使用安全cookie 2025-12-31 19:41:34 +08:00
7c3d0a0947 fix: wkhtmltoimage兼容UA参数 2025-12-31 19:13:20 +08:00
7cf39f80bc fix: 兼容旧浏览器后台与截图开关 2025-12-31 19:04:42 +08:00
d108f3b51d bust spa asset cache by build id 2025-12-31 18:22:03 +08:00
41ead4bead replace screenshot pipeline and update admin 2025-12-31 16:50:35 +08:00
2d98ab66a3 fix: 修复公告关闭功能 - 当次关闭与永久关闭区分
问题:不管选择"当次关闭"还是"永久关闭",都会永久关闭公告

修复:
- 当次关闭:使用 sessionStorage + pageToken
  - pageToken 基于 performance.timeOrigin 生成
  - 刷新页面后 token 变化,公告重新显示
- 永久关闭:使用 localStorage
  - 持久化存储,刷新/重开后不再显示

修改文件:
- app-frontend/src/layouts/AppLayout.vue
- templates/index.html

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 21:51:28 +08:00
70e09c83a8 fix: 修复浏览器池任务丢失和统计错误 bug
问题:
1. 当浏览器创建失败时,failed_tasks 增加但 total_tasks 不增加
   导致统计显示 "0/5" 这种不合理数据
2. 浏览器创建失败时任务直接丢失,没有重新分配给其他 Worker

修复:
- 添加本地浏览器创建重试(最多2次)
- 失败任务根据 retry_count 决定是否重新入队
- retry_count < 1 时重新入队让其他 Worker 处理
- retry_count >= 1 时才真正失败并计入统计
- 任务字典新增 retry_count 字段初始化为 0
- 添加回归测试用例

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 21:26:56 +08:00
01ffaf96a3 fix: CPU显示修复 + 报表面板添加浏览器池状态
1. CPU 显示修复:
   - routes/admin_api/core.py: 新增 _get_server_cpu_percent()
   - 首次调用使用 interval=0.1 避免返回 0.0
   - 后续调用使用缓存,TTL 1秒

2. 报表面板浏览器池状态:
   - admin-frontend/src/api/browser_pool.js: 新增 API 调用
   - ReportPage.vue: 添加浏览器池状态卡片
   - 显示总/活跃/空闲 Worker 数和队列等待数
   - Worker 表格带状态颜色标签(活跃/空闲/异常)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:15:48 +08:00
1b20478a08 feat: 风险分定时衰减 + 密码提示修复 + 浏览器池API + next回跳
1. 风险分衰减定时任务:
   - services/scheduler.py: 每天 CST 04:00 自动执行 decay_scores()
   - 支持 RISK_SCORE_DECAY_TIME_CST 环境变量覆盖

2. 密码长度提示统一为8位:
   - app-frontend/src/pages/RegisterPage.vue
   - app-frontend/src/layouts/AppLayout.vue
   - admin-frontend/src/pages/SettingsPage.vue
   - templates/register.html

3. 浏览器池统计API:
   - GET /yuyx/api/browser_pool/stats
   - 返回 worker 状态、队列等待数等信息
   - browser_pool_worker.py: 增强 get_stats() 方法

4. 登录后支持 next 参数回跳:
   - app-frontend/src/pages/LoginPage.vue: 检查 ?next= 参数
   - 仅允许站内路径(防止开放重定向)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 18:28:21 +08:00
3d9dba272e refactor: 删除版本更新功能 + 报表页自动刷新
删除版本与更新功能:
- routes/admin_api/update.py: 删除整个文件
- routes/admin_api/__init__.py: 移除 update 模块注册
- admin-frontend/src/pages/SystemPage.vue: 移除版本更新UI区块
- admin-frontend/src/api/update.js: 删除整个文件
- 删除 static/admin/assets/update-*.js

报表页自动刷新:
- admin-frontend/src/pages/ReportPage.vue: 添加 setInterval 每1秒刷新
- 在 onMounted 启动定时器,onUnmounted 清除
- 覆盖统计数据、运行中任务、系统信息等所有动态数据

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 12:41:26 +08:00
89f3fd9759 feat: 安全增强 + 删除密码重置申请功能 + 登录提醒开关
安全增强:
- 新增 SSRF、XXE、模板注入、敏感路径探测检测规则
- security/constants.py: 添加新的威胁类型和检测模式
- security/threat_detector.py: 实现新检测逻辑

删除密码重置申请功能:
- 移除 /api/password_resets 相关API
- 删除 password_reset_requests 数据库表
- 前端移除密码重置申请页面和菜单
- 用户只能通过邮��找回密码,未绑定邮箱需联系管理员

登录提醒全局开关:
- email_service.py: 添加 login_alert_enabled 字段
- routes/api_auth.py: 检查开关状态再发送登录提醒
- EmailPage.vue: 添加新设备登录提醒开关

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 12:08:36 +08:00
4ba933b001 feat: 添加安全仪表板前端页面
- 新增 SecurityPage.vue: 统计卡片、威胁事件表格、封禁管理、风险查询
- 新增 api/security.js: 安全相关API封装
- 路由添加 /security 页面
- 侧边栏添加"安全防护"菜单项

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 01:56:22 +08:00
759d99e8af fix: add security module to Dockerfile
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 01:31:51 +08:00
46253337eb feat: 实现完整安全防护系统
Phase 1 - 威胁检测引擎:
- security/threat_detector.py: JNDI/SQL/XSS/路径遍历/命令注入检测
- security/constants.py: 威胁检测规则和评分常量
- 数据库表: threat_events, ip_risk_scores, user_risk_scores, ip_blacklist

Phase 2 - 风险评分与黑名单:
- security/risk_scorer.py: IP/用户风险评分引擎,支持分数衰减
- security/blacklist.py: 黑名单管理,自动封禁规则

Phase 3 - 响应策略:
- security/honeypot.py: 蜜罐响应生成器
- security/response_handler.py: 渐进式响应策略

Phase 4 - 集成:
- security/middleware.py: Flask安全中间件
- routes/admin_api/security.py: 管理后台安全仪表板API
- 36个测试用例全部通过

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 01:28:38 +08:00
e3b0c35da6 Harden auth risk controls and admin reauth 2025-12-26 21:07:47 +08:00
f90b0a4f11 Harden auth, CSRF, and email log UX 2025-12-26 19:05:42 +08:00
Yu Yon
3214cbbd91 chore: ignore local data and compose backups 2025-12-25 00:39:14 +08:00
Yu Yon
c32f7b797d chore: add API diagnostic request logging toggles 2025-12-24 19:26:50 +08:00
ec84903745 fix: 启动后60秒内所有请求使用15秒超时
问题:之前的 _first_request 只对第一个HTTP请求有效,但login()
需要两次请求(GET登录页+POST登录),导致实际的POST登录
请求仍然只有5秒超时,在冷启动时容易失败。

修复:改为基于模块启动时间的超时策略
- 启动后60秒内:所有请求使用15秒超时
- 60秒后:恢复正常的5秒超时

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 01:42:03 +08:00
151fc3e09f perf: 启动预热优化 - 解决容器重启后首批任务慢/失败
问题:容器重启后前两批任务明显变慢或失败
- 第一批:代理/目标服务器连接冷启动导致超时
- 第二批:浏览器池冷启动需要创建浏览器

解决方案:
- browser_pool_worker.py: 添加 pre_warm 参数,启动时预创建1个浏览器
- api_browser.py: 添加 warmup_api_connection() 预热 TCP/TLS 连接
- api_browser.py: 首次请求使用更长超时(10s),后续恢复正常
- app.py: 启动时后台调用 API 预热

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 01:18:18 +08:00
1d44859857 perf: 优化任务执行速度 (40-70s → ~15s)
问题:容错机制引入了大量叠加的等待时间

优化内容:
- playwright_automation.py:
  - 登录超时 30s → 10s
  - 导航等待 2s → 0.5s
  - navigate_only 等待 1s → 0.3s
  - 首页轮询 8次×3s → networkidle + 2次×0.5s
- services/tasks.py:
  - 删除截图前固定 sleep(2)
- services/screenshots.py:
  - networkidle 超时 30s → 10s
  - selector 超时 20s → 5s

预计性能提升:从 40-70 秒降至约 15 秒

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 00:19:46 +08:00
79a571e58d fix: 容器重启后第一批任务失败
问题:容器重启时账号对象的 is_running 状态未被重置,
导致新任务提交时被拒绝("任务已在运行中")

修复:在启动流程中添加遗留任务状态清理逻辑
- 重置所有账号的 is_running/should_stop/status
- 清理活跃任务句柄

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 23:32:20 +08:00
5f4fb50001 refactor: 统一日志管理 + 数据库索引优化
- db/schema.py: 添加 4 个复合索引优化查询性能
  - idx_user_schedules_user_enabled
  - idx_schedule_execution_logs_schedule_id/user_id/status
- db/users.py: print → logger,密码升级日志改为记录 user_id
- crypto_utils.py: print → logger
- password_utils.py: print → logger

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 22:57:03 +08:00
c5f019be5a chore: restore API request timeout 5s 2025-12-18 09:46:01 +08:00
433a3cb806 fix: avoid blocking browser init 2025-12-18 09:38:02 +08:00
5851120f87 fix: admin auth UX, password policy, deps, db pool 2025-12-17 23:53:11 +08:00
9028f7e272 ui: 移除定时任务停用标签 2025-12-17 22:12:06 +08:00
2ef0a10d6f fix(ui): 开关可见与布局对齐 2025-12-17 22:03:15 +08:00
2f5940d339 fix(ui): 开关可见与卡片不拉伸 2025-12-17 21:14:54 +08:00
db4201a269 fix(ui): 定时任务开关禁用态更清晰 2025-12-17 20:44:49 +08:00
5393648d21 fix(api): 列表页失败不算完成 2025-12-17 19:24:08 +08:00
3f667dd21b fix(api): 超时按单条处理避免中途结束 2025-12-17 15:49:05 +08:00
6827d11f40 chore(update): 忽略备份文件的脏工作区提示 2025-12-16 23:48:58 +08:00
4c9bed0f0b perf: 默认请求超时降为5s 2025-12-16 23:17:21 +08:00
Yu Yon
4571a83492 chore: 使用国内DNS避免解析超时 2025-12-16 22:25:41 +08:00
0e587ca497 ui: 运行中仅显示已浏览内容数 2025-12-16 21:23:03 +08:00
1b707fdace fix: 浏览内容进度实时显示 2025-12-16 21:19:48 +08:00
2abb9ab494 fix: 账号截图开关持久化与状态推送优化 2025-12-16 18:27:45 +08:00
e699f4fb94 更新管理后台布局 2025-12-15 22:18:44 +08:00
d650c6f584 优化报表页面,移除统计页面 2025-12-15 22:12:15 +08:00
9aa28f5b9e 添加报表页面,更新用户管理和注册功能 2025-12-15 21:39:32 +08:00
738eaa5211 更新邮件服务和SMTP配置功能 2025-12-15 20:32:28 +08:00
8846945208 更新邮件页面和管理API 2025-12-15 17:16:56 +08:00
49897081b6 更新日志页面和统计页面
- 更新 LogsPage 和 StatsPage 组件
- 添加 taskSource 工具模块
- 更新 db/tasks.py
- 重新构建前端静态资源
2025-12-15 16:25:37 +08:00
a8b9f225bd 更新系统页面和更新功能
- 更新 admin-frontend 系统页面和更新 API
- 更新 routes 和 services 中的更新逻辑
- 重新构建前端静态资源
2025-12-15 15:58:12 +08:00
de6d269fb4 更新 update_agent.py 2025-12-15 15:09:34 +08:00
0d1397debe 添加自动更新功能 2025-12-15 14:34:08 +08:00
809c735498 添加一键部署脚本 2025-12-15 13:57:25 +08:00
10d5363e29 更新 schedules.py 2025-12-15 13:30:40 +08:00
a619e96e73 同步本地更改 2025-12-15 10:48:58 +08:00
dab29347bd 更新定时任务页面和前端构建 2025-12-14 23:09:27 +08:00
1ec0d80f6c 清理冗余文件,更新 Dockerfile 和配置 2025-12-14 22:49:00 +08:00
dac06d187e 更新 playwright_automation 和 screenshots 服务 2025-12-14 22:04:05 +08:00
a346509a5f 同步更新:重构路由、服务模块,更新前端构建 2025-12-14 21:47:46 +08:00
e01a7b5235 删除 UI_REFACTOR_FRONTEND.md 2025-12-14 18:24:34 +08:00
949ff1e53a 删除 UI_REFACTOR_ADMIN.md 2025-12-14 18:24:29 +08:00
cbddaf810e fix: 直接对iframe内容进行full_page截图
使用iframe.screenshot(full_page=True)来截取iframe内的完整内容。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 16:23:05 +08:00
a3497f2921 fix: 展开所有可滚动容器以截取完整页面内容
在截图前通过JavaScript展开所有带overflow的容器,确保full_page能够捕获全部内容。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 16:17:51 +08:00
94ceb959c7 fix: 使用JavaScript展开iframe高度以截取完整内容
在截图前通过JavaScript动态调整iframe及其父容器的高度,使其能够显示全部内容。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 16:11:58 +08:00
b408e78c74 fix: 改进截图逻辑,直接截取表格元素
尝试截取table.ltable元素来获取完整内容,而不是依赖full_page参数。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 16:08:41 +08:00
4400ded86a fix: 修复截图只截取可见区域的问题
改为对iframe内容进行截图而不是主页面,这样full_page=True才能正确截取iframe内的完整内容。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 16:04:36 +08:00
4510fbba83 fix: 修复截图只能截取区域的问题,改为截取整个网页内容
将 full_page=False 改为 full_page=True,使截图能够捕获完整的网页内容而不仅仅是可见区域。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 15:49:49 +08:00
a9c8aac48f fix: 账号页闪烁/浏览类型/截图复制/时区统一 2025-12-14 11:30:49 +08:00
2ec88eac3b fix(app): refine account progress and screenshots 2025-12-14 01:21:31 +08:00
8931ad5d7f fix(app): screenshot copy button copies image; add copy link 2025-12-14 00:59:02 +08:00
b4c7a3eac9 fix(app): hide account progress when idle; screenshot uses selected browse_type 2025-12-14 00:56:12 +08:00
757de96fd9 fix(app): allow remark-only edit in accounts 2025-12-14 00:38:07 +08:00
69443c2de6 feat(app): add announcements, feedback, settings (stage 5) 2025-12-14 00:27:05 +08:00
54cf6fe538 feat(app): migrate schedules and screenshots (stage 4) 2025-12-14 00:15:19 +08:00
9798ed52c3 feat(app): migrate /app accounts to Vue SPA (stage 3) 2025-12-13 23:56:47 +08:00
324e0d614a feat(app): migrate auth pages to Vue SPA (stage 2) 2025-12-13 23:30:51 +08:00
34f44eed3e feat(app): scaffold Vue3 frontend (stage 1) 2025-12-13 22:42:31 +08:00
39153cc946 docs: confirm frontend refactor decisions 2025-12-13 22:32:27 +08:00
56d2cadd81 docs: frontend UI refactor plan 2025-12-13 22:26:28 +08:00
6bff5e4d97 fix: stats loader and smtp daily reset 2025-12-13 22:01:12 +08:00
85a60009f3 chore(admin): final polish and QA doc 2025-12-13 21:46:34 +08:00
283 changed files with 37644 additions and 20885 deletions

View File

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

168
.gitignore vendored
View File

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

View File

@@ -1,14 +1,18 @@
# 使用国内镜像源加速
FROM mcr.microsoft.com/playwright/python:v1.40.0-jammy
FROM python:3.10-slim-bullseye
# 设置工作目录
WORKDIR /app
# 设置环境变量
ENV PYTHONUNBUFFERED=1
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
ENV TZ=Asia/Shanghai
# 安装 wkhtmltopdf包含 wkhtmltoimage与中文字体
RUN apt-get update && \
apt-get install -y --no-install-recommends wkhtmltopdf curl fonts-noto-cjk && \
rm -rf /var/lib/apt/lists/*
# 配置 pip 使用国内镜像源
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && pip config set install.trusted-host mirrors.aliyun.com
@@ -18,16 +22,15 @@ COPY requirements.txt .
# 安装Python依赖
RUN pip install --no-cache-dir -r requirements.txt
# 安装 Playwright 浏览器依赖与 Chromium
RUN python -m playwright install --with-deps chromium
# 复制应用程序文件
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 crypto_utils.py .
COPY task_checkpoint.py .
@@ -37,8 +40,11 @@ COPY email_service.py .
COPY app_config.py .
COPY app_logger.py .
COPY app_security.py .
COPY app_state.py .
COPY app_utils.py .
COPY routes/ ./routes/
COPY services/ ./services/
COPY realtime/ ./realtime/
COPY db/ ./db/
COPY security/ ./security/
COPY templates/ ./templates/
COPY static/ ./static/

352
README.md
View File

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

View File

@@ -1,430 +0,0 @@
# 后台管理 UI 重构方案Vue3 + Vite保持功能不变
> 目标:仅重构“后台管理界面”(优先 `/yuyx/admin`)的视觉与交互呈现,使其更现代、美观、易用、可维护;后端接口与业务流程保持不变。
>
> 实现方式:后台前端改为 **Vue3 单页应用**Vite 构建产物部署到 `static/admin/`),所有接口继续使用现有 `/yuyx/api/...`,不改后端功能。
---
## 1. 项目概览(基于当前仓库代码)
### 1.1 技术栈与形态
- 后端Python / Flask`app.py`,不变)
- 当前后台前端Jinja2 模板 + 原生 HTML/CSS/JS现状
- 目标后台前端Vue3 + Vite构建+ Element Plus组件库默认方案+ Vue Router + Axios仅后台
- 静态资源:前端构建产物输出到 `static/admin/`(新增;运行时不依赖 CDN
### 1.2 后台入口与页面
- 后台登录页:`GET /yuyx``templates/admin_login.html`(第一阶段可先不改,后续可再 Vue 化/美化)
- 后台管理页:`GET /yuyx/admin` → Vue3 SPA构建后由 Flask 返回 `static/admin/index.html`
- 回滚策略:保留旧版 `templates/admin.html`(计划改名为 `templates/admin_legacy.html` 备份,便于一键回滚)
### 1.3 现状结论(为什么“臃肿”)
- `templates/admin.html` 是一个超大单文件(包含大量内联 `<style>` + 大量内联 JS + 大量内联 `style="..."`),维护成本高、改动风险高。
- “页面结构 / 样式 / 逻辑”混在一起,导致:
- 视觉风格不统一(按钮、卡片、表格、间距、字号混用)。
- 组件复用困难(很多 UI 是拷贝粘贴 + 内联样式)。
- 小改动容易引发回归JS 依赖大量 DOM id/结构)。
---
## 2. 后台功能清单(用于“功能不变”验收)
后台管理页 `templates/admin.html` 目前是一个“单页 + 多 Tab”结构Tab 与能力如下:
### 2.1 顶部统计卡片(页面顶部)
- 展示总用户数、已审核、待审核、总账号数、VIP 用户数
- 数据来源:`GET /yuyx/api/stats`
- 额外:显示管理员用户名(同接口返回 `admin_username` 时更新 UI
### 2.2 Tab待审核pending
1) 用户注册审核
- 列表ID、用户名、邮箱、注册时间、操作
- 操作:通过、拒绝
- 接口:
- `GET /yuyx/api/users/pending`
- `POST /yuyx/api/users/<user_id>/approve`
- `POST /yuyx/api/users/<user_id>/reject`
2) 密码重置审核
- 列表申请ID、用户名、邮箱、申请时间、操作
- 操作:批准、拒绝
- 接口:
- `GET /yuyx/api/password_resets`
- `POST /yuyx/api/password_resets/<request_id>/approve`
- `POST /yuyx/api/password_resets/<request_id>/reject`
### 2.3 Tab所有用户all
- 列表ID、用户名含邮箱/ VIP 标识/到期)、状态、注册/审核时间、操作
- 操作:
- 待审核用户:通过 / 拒绝
- 删除用户
- VIP开通一周/一月/一年/永久)或移除
- 管理员直接重置用户密码(输入新密码)
- 接口:
- `GET /yuyx/api/users`
- `POST /yuyx/api/users/<user_id>/approve`
- `POST /yuyx/api/users/<user_id>/reject`
- `DELETE /yuyx/api/users/<user_id>`
- `POST /yuyx/api/users/<user_id>/vip`body: `{days}`
- `DELETE /yuyx/api/users/<user_id>/vip`
- `POST /yuyx/api/users/<user_id>/reset_password`body: `{new_password}`
### 2.4 Tab反馈管理feedbacks
- 筛选:状态(全部/待处理/已回复/已关闭)
- 统计:总计、待处理、已回复、已关闭(并显示待处理徽章)
- 列表字段ID、用户、标题、描述、联系方式、状态、提交时间、回复、操作
- 操作回复prompt、关闭、删除
- 接口:
- `GET /yuyx/api/feedbacks?status=...`
- `POST /yuyx/api/feedbacks/<feedback_id>/reply`body: `{reply}`
- `POST /yuyx/api/feedbacks/<feedback_id>/close`
- `DELETE /yuyx/api/feedbacks/<feedback_id>`
### 2.5 Tab统计stats
1) 系统资源概览
- CPU / 内存 / 磁盘 / 容器内存 / 运行时长
- 接口:
- `GET /yuyx/api/server/info`
- `GET /yuyx/api/docker_stats`
2) 实时任务监控(每 1 秒刷新)
- 运行中数量、排队中数量、最大并发
- 运行中/排队中任务列表(来源、用户、账号、浏览类型、详细状态、耗时等)
- 接口:
- `GET /yuyx/api/task/running`
3) 任务统计
- 今日/累计:成功任务、失败任务、浏览内容、查看附件
- 接口:
- `GET /yuyx/api/task/stats`
### 2.6 Tab任务日志logs
- 筛选:日期、状态(成功/失败)、来源(手动/定时/即时/恢复)、用户、账号关键字
- 列表字段:时间、来源、用户、账号、浏览类型、状态、内容/附件、用时、失败原因
- 分页limit/offset每页 20、首页/上一页/下一页/末页
- 操作:清理旧日志(输入天数)
- 接口:
- `GET /yuyx/api/task/logs?limit=&offset=&date=&status=&source=&user_id=&account=`
- `POST /yuyx/api/task/logs/clear`body: `{days}`
### 2.7 Tab公告管理announcements
- 创建公告:标题、内容
- 操作:发布并启用 / 保存但不启用 / 清空
- 列表字段ID、标题、状态、创建时间、操作
- 操作:查看、启用、停用、删除
- 接口:
- `GET /yuyx/api/announcements`
- `POST /yuyx/api/announcements`body: `{title, content, is_active}`
- `POST /yuyx/api/announcements/<id>/activate`
- `POST /yuyx/api/announcements/<id>/deactivate`
- `DELETE /yuyx/api/announcements/<id>`
### 2.8 Tab邮件配置email
1) 全局开关与基础 URL
- 启用邮件、故障转移、注册邮箱验证、任务完成通知、网站基础 URL
- 接口:
- `GET /yuyx/api/email/settings`
- `POST /yuyx/api/email/settings`
2) SMTP 配置
- 列表:主/备用/禁用、名称、服务器、今日/限额、成功率、编辑
- 弹窗:新增/编辑 SMTP含密码显隐、测试连接、保存、设为主配置、删除
- 接口:
- `GET /yuyx/api/smtp/configs`
- `POST /yuyx/api/smtp/configs`
- `GET /yuyx/api/smtp/configs/<config_id>`
- `PUT /yuyx/api/smtp/configs/<config_id>`
- `DELETE /yuyx/api/smtp/configs/<config_id>`
- `POST /yuyx/api/smtp/configs/<config_id>/test`
- `POST /yuyx/api/smtp/configs/<config_id>/primary`
3) 邮件统计 & 邮件日志
- 统计:总发送、成功、失败、各类型
- 日志:按类型/状态筛选、分页、清理
- 接口:
- `GET /yuyx/api/email/stats`
- `GET /yuyx/api/email/logs?page=&page_size=&type=&status=`
- `POST /yuyx/api/email/logs/cleanup`body: `{days}`
### 2.9 Tab系统配置system
1) 并发配置
- 全局最大并发、单账号最大并发、截图最大并发
- 接口:`POST /yuyx/api/system/config`
2) 定时任务配置
- 启用定时任务、执行时间、执行日期(周一~周日)、浏览类型、保存、立即执行
- 接口:
- `GET /yuyx/api/system/config`
- `POST /yuyx/api/system/config`
- `POST /yuyx/api/schedule/execute`
3) 代理设置
- 启用代理、代理 API 地址、代理有效期、保存、测试代理
- 接口:
- `GET /yuyx/api/proxy/config`
- `POST /yuyx/api/proxy/config`
- `POST /yuyx/api/proxy/test`
4) 注册自动审核
- 启用自动审核、每小时注册限制、注册赠送 VIP 天数
- 接口:`POST /yuyx/api/system/config`
### 2.10 Tab设置settings
- 修改管理员用户名(成功后提示重新登录并触发退出)
- 修改管理员密码(成功后提示重新登录并触发退出)
- 退出登录
- 接口:
- `PUT /yuyx/api/admin/username`
- `PUT /yuyx/api/admin/password`
- `POST /yuyx/api/logout`
---
## 3. UI 重构目标与范围
### 3.1 明确目标
- 视觉:更现代、克制、统一(字体/字号/间距/颜色/阴影/圆角/表格/表单)
- 交互:信息层级清晰、导航不拥挤、在桌面/手机都可用
- 工程:可维护(减少内联样式、抽离 CSS/JS、结构更清晰
- 兼容:引入 Vue3 + Vite 构建(你已确认部署可拉依赖);运行时仍是 Flask 提供静态文件,不需要 Node 常驻运行
### 3.2 不做的事(避免范围失控)
- 不改后端接口、不改数据库、不改业务流程
- 不依赖运行时外网/CDN所有前端依赖在构建期打包进静态文件
- 不额外新增“业务功能”(最多做纯 UI/UX 级别优化:排版、布局、可读性、响应式)
---
## 4. 推荐的界面改造方向(保持功能不变前提下)
### 4.1 信息架构(解决“标签太多、拥挤”)
建议把当前顶栏 Tabs 升级为“左侧导航 + 右侧内容”的后台经典布局:
- 左侧:导航(待审核/用户/反馈/统计/日志/公告/邮件/系统/设置)
- 右侧:内容区(每个导航项对应一个 Vue 页面/模块;不改变现有数据与按钮)
- 顶部:保留 header系统名 + 管理员用户名 + 退出)
- 移动端左侧导航折叠为抽屉hamburger
这样可以显著减轻横向 Tabs 的拥挤感同时保持“单页切换”的实现方式Vue Router
> 如果你希望“仍然保持 Tabs”也可以我们会把 Tabs 做成可滚动 + 分组/二级菜单的样式;该点需要你确认(见第 8 节)。
### 4.2 视觉体系(统一设计语言)
在使用 Element Plus 的前提下,仍建议补一层轻量的 design tokensCSS 变量用于统一页面背景、间距、圆角、阴影与品牌色Element Plus 负责组件能力,我们负责整体风格统一):
- 颜色:主色、成功/警告/危险、边框色、背景色、文本色
- 间距4/8/12/16/24/32
- 圆角8/12
- 阴影:轻/中
- 字体:系统字体栈(优先苹方/微软雅黑/Segoe UI
并把按钮、卡片、表格、表单、徽章、弹窗、通知统一到同一套组件外观。
### 4.3 表格与表单体验
- 表格:表头 sticky、行 hover、状态徽章统一、操作按钮收纳避免一行按钮过多
- 表单label 与 help text 统一样式;输入框 focus 样式;危险操作(删除/清理)更醒目
### 4.4 反馈与通知
现有通知是简单的 1 秒浮层提示。建议升级为更现代的 toast可保留 1 秒自动消失,但样式更好、支持多行、更清晰的成功/失败状态)。
---
## 5. 技术实施方案Vue3 + Vite 落地,保持功能不变)
### 5.1 总体落地方案(你已确认采用“推荐方式”)
- 新增后台前端工程:`admin-frontend/`Vue3 + Vite
- 构建输出:`admin-frontend/dist/` → 复制/输出到 `static/admin/`
- Flask 对接:`GET /yuyx/admin` 返回 `static/admin/index.html`
- 路由策略Vue Router 使用 **hash 模式**(避免后端再做 history fallback 配置)
- 接口策略:所有请求继续调用现有 `/yuyx/api/...`URL/method/body 完全不变)
### 5.2 组件库选择(我来判断:默认 Element Plus
默认采用:
- `element-plus` + `@element-plus/icons-vue`
原因:
- 后台大量场景是“表格/表单/弹窗/分页/通知”Element Plus 开箱即用且样式现代
- 可通过主题变量与少量 CSS 统一品牌风格,达到“更美观”目标
- 能把大量 `alert/confirm/prompt` 升级为更现代的 Dialog/MessageBox功能不变体验更好
> 如果你明确不想引入组件库,我也能改为“纯自研 UI”但开发周期会明显拉长。
### 5.3 前端目录结构建议(便于可维护)
建议结构(示意):
```
admin-frontend/
package.json
vite.config.(ts|js)
src/
main.(ts|js)
App.vue
router/
index.(ts|js)
api/
client.(ts|js) # axios 实例baseURL=/yuyx/api
users.(ts|js)
feedbacks.(ts|js)
announcements.(ts|js)
system.(ts|js)
email.(ts|js)
taskLogs.(ts|js)
stats.(ts|js)
layouts/
AdminLayout.vue # Header + Sidebar + Content
pages/
Pending.vue
Users.vue
Feedbacks.vue
Stats.vue
Logs.vue
Announcements.vue
Email.vue
System.vue
Settings.vue
components/
... # 可复用的表格/弹窗/筛选条等
```
### 5.4 与后端/静态资源的集成细节(避免踩坑)
- Vite `base`:建议配置为 `/static/admin/`,确保打包后的资源路径正确
- API`axios.create({ baseURL: '/yuyx/api', withCredentials: true })`
- 403/未登录体验API 返回 403 时,前端可提示“需要管理员权限”,并跳转到 `/yuyx`(此行为是否接受见第 8 节问题)
### 5.5 构建与部署(支持 Docker多阶段构建运行时无 Node
你已确认“部署时可以拉依赖”,因此可用以下思路:
- 本地/服务器构建:
- `cd admin-frontend && npm ci && npm run build`
-`admin-frontend/dist` 部署到后端容器的 `static/admin/`
- Docker 推荐:多阶段构建
- Stage 1Node安装依赖并 `npm run build`
- Stage 2现有 Python/Playwright 镜像):复制 `dist/``/app/static/admin/`
> 这样最终运行容器里不需要 Node仍是“Flask + 静态文件”的部署模式。
### 5.6 分阶段交付(按功能模块逐步落地)
阶段 1工程搭建 + 新布局
- 建好 Vue3 工程、Element Plus、路由与基础布局Header/Sidebar
- 先把 9 个模块页面壳子跑通(不要求全部功能完成,但可导航切换)
阶段 2核心模块功能迁移先保证可用
- 待审核 / 所有用户 / 系统配置 / 设置:完成列表+表单+操作全流程
阶段 3复杂模块迁移数据量/交互多)
- 统计(含 1 秒刷新)/ 任务日志(筛选分页)/ 邮件配置SMTP 弹窗 + 日志分页)/ 公告 / 反馈
阶段 4统一细节 + 回归验收
- 全量走一遍第 7 节验收清单
- 补齐移动端适配与细节视觉统一
- 保留旧版后台模板备份,便于回滚
---
## 6. 风险点与注意事项(提前说明)
- Vue SPA 静态资源路径需要正确配置Vite `base`);否则会出现“页面白屏/资源 404”。
- Vue Router 建议使用 hash 模式,避免出现刷新子路由 404 的历史路由问题。
- 管理员鉴权依赖 session cookie前端请求必须同源并携带 cookieaxios `withCredentials`)。
- 统计页每 1 秒刷新一次接口server/docker/task/runningUI 改造不应额外增加请求频率。
- 列表项里存在长文本(反馈描述/失败原因/邮件错误等),需要做更合理的折行/省略/查看方式,避免布局被撑爆。
- `/yuyx/vip` 为历史废弃入口(旧模板缺失),已按确认删除该路由,避免误访问报错。
---
## 7. 验收清单(你确认后我按此对齐开发)
建议你在重构完成后按以下清单点验收(确保“功能不变”):
- 登录/退出:
- `GET /yuyx` 登录成功跳转后台
- 退出后回到后台登录页
- 顶部统计:
- 统计数字正常刷新,管理员用户名正常显示
- 待审核:
- 注册审核列表加载/通过/拒绝正常
- 密码重置列表加载/批准/拒绝正常
- 所有用户:
- 列表加载正常
- 删除用户、VIP 开通/移除、重置密码正常
- 反馈管理:
- 状态筛选正常;徽章数字正确
- 回复/关闭/删除正常
- 公告管理:
- 创建(启用/不启用)正常
- 启用/停用/删除/查看正常
- 系统配置:
- 并发保存正常
- 定时任务保存与“立即执行”正常
- 代理保存与测试正常
- 自动审核保存正常
- 统计:
- CPU/内存/磁盘/容器信息正常
- 运行中/排队中列表正常刷新
- 今日/累计统计正常显示
- 任务日志:
- 筛选、分页、清理旧日志正常
- 邮件配置:
- 全局开关/基础 URL 更新正常
- SMTP 新增/编辑/测试/设为主/删除正常
- 邮件统计/日志筛选/分页/清理正常
- 设置:
- 修改用户名/密码后提示并强制重新登录
---
## 8. 需要你确认的问题(确认后再开始开发)
已确认(来自你的回复):
- 范围:先改后台(`/yuyx/admin`),并同步美化后台登录页(`/yuyx`),仅 UI 不改登录逻辑
- 技术Vue3 + Vite 构建产物部署到 `static/admin/`;部署时允许拉依赖并构建
- 布局:左侧导航 + 右侧内容Admin Layout
- 风格:简洁克制(浅色背景 + 卡片 + 少量主色点缀)
- 暗色模式:不需要
- 适配PC 优先;同时保证手机端不变形(必要时对表格做响应式处理/横向滚动)
- 交互:允许把原生 `alert/confirm/prompt` 替换为 Element Plus 弹窗/对话框
- 403 行为:保持现状(接口 403 时前端提示错误,不强制跳转登录页)
- `/yuyx/vip`为历史废弃入口VIP 已整合在后台,已删除该路由
仍需你确认(只剩 1 点,影响 Vite `base` 与反代配置):
1) 部署路径/前缀:
- A. 你的站点是根路径部署(例如 `https://zsglpt.workyai.cn/` 直接访问就是本系统)
- B. 你的站点是二级目录部署(例如 `https://example.com/zsglpt/`,需要告诉我前缀 `/zsglpt`
> 说明:域名本身没有影响,关键在“是否挂在二级目录”。若是根路径部署,我们默认使用 `/static/admin/` 与 `/yuyx/api/...` 即可。
---
## 9. 下一步
你确认第 8 节后,我会按“分阶段交付”开始开发,并在每个阶段完成后给你可验收的版本点。

View File

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

View File

@@ -1206,23 +1206,24 @@
}
},
"node_modules/element-plus": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.12.0.tgz",
"integrity": "sha512-M9YLSn2np9OnqrSKWsiXvGe3qnF8pd94+TScsHj1aTMCD+nSEvucXermf807qNt6hOP040le0e5Aft7E9ZfHmA==",
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.11.3.tgz",
"integrity": "sha512-769xsjLR4B9Vf9cl5PDXnwTEdmFJvMgAkYtthdJKPhjVjU3hdAwTJ+gXKiO+PUyo2KWFwOYKZd4Ywh6PHfkbJg==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.4.1",
"@element-plus/icons-vue": "^2.3.2",
"@element-plus/icons-vue": "^2.3.1",
"@floating-ui/dom": "^1.0.1",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
"@types/lodash": "^4.17.20",
"@types/lodash-es": "^4.17.12",
"@types/lodash": "^4.14.182",
"@types/lodash-es": "^4.17.6",
"@vueuse/core": "^9.1.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.19",
"dayjs": "^1.11.13",
"escape-html": "^1.0.3",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"lodash-unified": "^1.0.3",
"lodash-unified": "^1.0.2",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0"
},
@@ -1329,6 +1330,12 @@
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",

View File

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

View File

@@ -10,6 +10,13 @@ export async function createAnnouncement(payload) {
return data
}
export async function uploadAnnouncementImage(file) {
const formData = new FormData()
formData.append('file', file)
const { data } = await api.post('/announcements/upload_image', formData)
return data
}
export async function activateAnnouncement(id) {
const { data } = await api.post(`/announcements/${id}/activate`)
return data
@@ -24,4 +31,3 @@ export async function deleteAnnouncement(id) {
const { data } = await api.delete(`/announcements/${id}`)
return data
}

View File

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

View File

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

View File

@@ -1,5 +1,61 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
let lastToastKey = ''
let lastToastAt = 0
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504])
const MAX_RETRY_COUNT = 1
const RETRY_BASE_DELAY_MS = 300
function toastErrorOnce(key, message, minIntervalMs = 1500) {
const now = Date.now()
if (key === lastToastKey && now - lastToastAt < minIntervalMs) return
lastToastKey = key
lastToastAt = now
ElMessage.error(message)
}
function getCookie(name) {
const escaped = String(name || '').replace(/([.*+?^${}()|[\]\\])/g, '\\$1')
const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`))
return match ? decodeURIComponent(match[1]) : ''
}
function isIdempotentMethod(method) {
return ['GET', 'HEAD', 'OPTIONS'].includes(String(method || 'GET').toUpperCase())
}
function shouldRetryRequest(error) {
const config = error?.config
if (!config || config.__no_retry) return false
if (!isIdempotentMethod(config.method)) return false
const retried = Number(config.__retry_count || 0)
if (retried >= MAX_RETRY_COUNT) return false
const code = String(error?.code || '')
if (code === 'ECONNABORTED' || code === 'ERR_NETWORK') return true
const status = Number(error?.response?.status || 0)
return RETRYABLE_STATUS.has(status)
}
function delay(ms) {
return new Promise((resolve) => {
window.setTimeout(resolve, Math.max(0, Number(ms || 0)))
})
}
async function retryRequestOnce(error, client) {
const config = error?.config || {}
const retried = Number(config.__retry_count || 0)
config.__retry_count = retried + 1
const backoffMs = RETRY_BASE_DELAY_MS * (retried + 1)
await delay(backoffMs)
return client.request(config)
}
export const api = axios.create({
baseURL: '/yuyx/api',
@@ -7,24 +63,87 @@ export const api = axios.create({
withCredentials: true,
})
let reauthPromise = null
async function ensureReauth() {
if (reauthPromise) return reauthPromise
reauthPromise = ElMessageBox.prompt('请输入管理员密码进行二次确认', '安全确认', {
inputType: 'password',
inputPlaceholder: '管理员密码',
confirmButtonText: '确认',
cancelButtonText: '取消',
inputValidator: (v) => Boolean(String(v || '').trim()),
inputErrorMessage: '密码不能为空',
})
.then(async (res) => {
const password = String(res.value || '').trim()
await api.post('/admin/reauth', { password })
ElMessage.success('已通过安全确认')
})
.finally(() => {
reauthPromise = null
})
return reauthPromise
}
api.interceptors.request.use((config) => {
const method = String(config?.method || 'GET').toUpperCase()
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
const token = getCookie('csrf_token')
if (token) {
config.headers = config.headers || {}
config.headers['X-CSRF-Token'] = token
}
}
return config
})
api.interceptors.response.use(
(response) => response,
(error) => {
async (error) => {
const status = error?.response?.status
const payload = error?.response?.data
const message = payload?.error || payload?.message || error?.message || '请求失败'
const silent = Boolean(error?.config?.__silent)
if (status === 403) {
ElMessage.error(message || '需要管理员权限')
if (payload?.code === 'reauth_required' && error?.config && !error.config.__reauth_retry) {
try {
error.config.__reauth_retry = true
await ensureReauth()
return api.request(error.config)
} catch {
return Promise.reject(error)
}
}
if (shouldRetryRequest(error)) {
return retryRequestOnce(error, api)
}
if (status === 401) {
if (!silent) {
toastErrorOnce('401', message || '登录已过期,请重新登录', 3000)
}
const pathname = window.location?.pathname || ''
if (!pathname.startsWith('/yuyx')) window.location.href = '/yuyx'
} else if (status === 403) {
if (!silent) {
toastErrorOnce('403', message || '需要管理员权限', 5000)
}
} else if (status) {
ElMessage.error(message)
if (!silent) {
toastErrorOnce(`http:${status}:${message}`, message)
}
} else if (error?.code === 'ECONNABORTED') {
ElMessage.error('请求超时')
if (!silent) {
toastErrorOnce('timeout', '请求超时', 3000)
}
} else {
ElMessage.error(message)
if (!silent) {
toastErrorOnce(`net:${message}`, message, 3000)
}
}
return Promise.reject(error)
},
)

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
import { api } from './client'
export async function fetchKdocsStatus(params = {}, requestConfig = {}) {
const { data } = await api.get('/kdocs/status', { params, ...requestConfig })
return data
}
export async function fetchKdocsQr(payload = {}) {
const body = { force: true, ...payload }
const { data } = await api.post('/kdocs/qr', body)
return data
}
export async function clearKdocsLogin() {
const { data } = await api.post('/kdocs/clear-login', {})
return data
}

View File

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

View File

@@ -0,0 +1,63 @@
import { api } from './client'
export async function getDashboard() {
const { data } = await api.get('/admin/security/dashboard')
return data
}
export async function getThreats(params) {
const { data } = await api.get('/admin/security/threats', { params })
return data
}
export async function getBannedIps() {
const { data } = await api.get('/admin/security/banned-ips')
return data
}
export async function getBannedUsers() {
const { data } = await api.get('/admin/security/banned-users')
return data
}
export async function banIp(payload) {
const { data } = await api.post('/admin/security/ban-ip', payload)
return data
}
export async function unbanIp(ip) {
const { data } = await api.post('/admin/security/unban-ip', { ip })
return data
}
export async function banUser(payload) {
const { data } = await api.post('/admin/security/ban-user', payload)
return data
}
export async function unbanUser(userId) {
const { data } = await api.post('/admin/security/unban-user', { user_id: userId })
return data
}
export async function getIpRisk(ip) {
const safeIp = encodeURIComponent(String(ip || '').trim())
const { data } = await api.get(`/admin/security/ip-risk/${safeIp}`)
return data
}
export async function clearIpRisk(ip) {
const { data } = await api.post('/admin/security/ip-risk/clear', { ip })
return data
}
export async function getUserRisk(userId) {
const safeUserId = encodeURIComponent(String(userId || '').trim())
const { data } = await api.get(`/admin/security/user-risk/${safeUserId}`)
return data
}
export async function cleanup() {
const { data } = await api.post('/admin/security/cleanup', {})
return data
}

View File

@@ -30,3 +30,7 @@ export async function setPrimarySmtpConfig(configId) {
return data
}
export async function clearPrimarySmtpConfig() {
const { data } = await api.post('/smtp/configs/primary/clear')
return data
}

View File

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

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 496 B

View File

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

View File

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

View File

@@ -5,9 +5,9 @@ import { ElMessageBox } from 'element-plus'
import {
Bell,
ChatLineSquare,
DataAnalysis,
Document,
List,
Lock,
Message,
Setting,
Tools,
@@ -16,37 +16,30 @@ import {
import { api } from '../api/client'
import { fetchFeedbackStats } from '../api/feedbacks'
import { fetchPasswordResets } from '../api/passwordResets'
import { fetchSystemStats } from '../api/stats'
import StatsCards from '../components/StatsCards.vue'
import { clearCachedKdocsStatus, preloadKdocsStatus } from '../utils/kdocsStatusCache'
const route = useRoute()
const router = useRouter()
const stats = ref({})
const loadingStats = ref(false)
const adminUsername = computed(() => stats.value?.admin_username || '')
async function refreshStats() {
loadingStats.value = true
try {
stats.value = await fetchSystemStats()
} finally {
loadingStats.value = false
}
async function refreshStats(options = {}) {
stats.value = await fetchSystemStats(options)
}
const loadingBadges = ref(false)
const pendingResetsCount = ref(0)
const pendingFeedbackCount = ref(0)
let badgeTimer
const BADGE_POLL_ACTIVE_MS = 60_000
const BADGE_POLL_HIDDEN_MS = 180_000
let badgeTimer = null
async function refreshNavBadges(partial = null) {
if (partial && typeof partial === 'object') {
if (Object.prototype.hasOwnProperty.call(partial, 'pendingResets')) {
pendingResetsCount.value = Number(partial.pendingResets || 0)
}
if (Object.prototype.hasOwnProperty.call(partial, 'pendingFeedbacks')) {
pendingFeedbackCount.value = Number(partial.pendingFeedbacks || 0)
}
@@ -57,23 +50,41 @@ async function refreshNavBadges(partial = null) {
loadingBadges.value = true
try {
const [resetsResult, feedbackResult] = await Promise.allSettled([
fetchPasswordResets(),
fetchFeedbackStats(),
])
if (resetsResult.status === 'fulfilled') {
pendingResetsCount.value = Array.isArray(resetsResult.value) ? resetsResult.value.length : 0
}
if (feedbackResult.status === 'fulfilled') {
pendingFeedbackCount.value = Number(feedbackResult.value?.pending || 0)
}
const feedbackResult = await fetchFeedbackStats()
pendingFeedbackCount.value = Number(feedbackResult?.pending || 0)
} finally {
loadingBadges.value = false
}
}
function isPageHidden() {
if (typeof document === 'undefined') return false
return document.visibilityState === 'hidden'
}
function currentBadgePollDelay() {
return isPageHidden() ? BADGE_POLL_HIDDEN_MS : BADGE_POLL_ACTIVE_MS
}
function stopBadgePolling() {
if (!badgeTimer) return
window.clearTimeout(badgeTimer)
badgeTimer = null
}
function scheduleBadgePolling() {
stopBadgePolling()
badgeTimer = window.setTimeout(async () => {
badgeTimer = null
await refreshNavBadges().catch(() => {})
scheduleBadgePolling()
}, currentBadgePollDelay())
}
function onVisibilityChange() {
scheduleBadgePolling()
}
provide('refreshStats', refreshStats)
provide('adminStats', stats)
provide('refreshNavBadges', refreshNavBadges)
@@ -92,24 +103,29 @@ onMounted(async () => {
mediaQuery.addEventListener?.('change', syncIsMobile)
syncIsMobile()
// 后台登录后预加载金山文档登录状态,系统配置页可直接复用缓存。
void preloadKdocsStatus({ maxAgeMs: 60_000, silent: true }).catch(() => {})
await refreshStats()
await refreshNavBadges()
badgeTimer = window.setInterval(refreshNavBadges, 60_000)
scheduleBadgePolling()
window.addEventListener('visibilitychange', onVisibilityChange)
})
onBeforeUnmount(() => {
mediaQuery?.removeEventListener?.('change', syncIsMobile)
window.clearInterval(badgeTimer)
stopBadgePolling()
window.removeEventListener('visibilitychange', onVisibilityChange)
})
const menuItems = [
{ path: '/pending', label: '待审核', icon: Document, badgeKey: 'pending' },
{ path: '/reports', label: '报表', icon: Document },
{ path: '/users', label: '用户', icon: User },
{ path: '/feedbacks', label: '反馈', icon: ChatLineSquare, badgeKey: 'feedbacks' },
{ path: '/stats', label: '统计', icon: DataAnalysis },
{ path: '/logs', label: '任务日志', icon: List },
{ path: '/announcements', label: '公告', icon: Bell },
{ path: '/email', label: '邮件', icon: Message },
{ path: '/security', label: '安全防护', icon: Lock },
{ path: '/system', label: '系统配置', icon: Tools },
{ path: '/settings', label: '设置', icon: Setting },
]
@@ -118,9 +134,6 @@ const activeMenu = computed(() => route.path)
function badgeFor(item) {
if (!item?.badgeKey) return 0
if (item.badgeKey === 'pending') {
return Number(stats.value?.pending_users || 0) + Number(pendingResetsCount.value || 0)
}
if (item.badgeKey === 'feedbacks') {
return Number(pendingFeedbackCount.value || 0)
}
@@ -128,19 +141,30 @@ function badgeFor(item) {
}
async function logout() {
let confirmed = false
try {
await ElMessageBox.confirm('确定退出管理员登录吗?', '退出登录', {
confirmButtonText: '退出',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
confirmed = true
} catch (error) {
const reason = String(error || '').toLowerCase()
if (reason === 'cancel' || reason === 'close') return
try {
confirmed = window.confirm('确定退出管理员登录吗?')
} catch {
confirmed = false
}
}
if (!confirmed) return
try {
await api.post('/logout')
} finally {
clearCachedKdocsStatus()
window.location.href = '/yuyx'
}
}
@@ -184,26 +208,27 @@ async function go(path) {
<span class="app-muted">管理员</span>
<strong>{{ adminUsername || '-' }}</strong>
</div>
<el-button type="primary" plain @click="logout">退出</el-button>
<el-button type="primary" plain class="logout-btn" @click="logout">退出</el-button>
</div>
</el-header>
<el-main class="layout-main">
<StatsCards :stats="stats" :loading="loadingStats" />
<Suspense>
<template #default>
<RouterView />
</template>
<template #fallback>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="fallback-card">
<el-skeleton :rows="5" animated />
</el-card>
</template>
</Suspense>
<div class="main-shell">
<Suspense>
<template #default>
<RouterView />
</template>
<template #fallback>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="fallback-card">
<el-skeleton :rows="5" animated />
</el-card>
</template>
</Suspense>
</div>
</el-main>
</el-container>
<el-drawer v-model="drawerOpen" size="240px" :with-header="false">
<el-drawer v-model="drawerOpen" size="min(82vw, 280px)" direction="ltr" :with-header="false">
<div class="drawer-brand">
<div class="brand-title">后台管理</div>
<div class="brand-sub app-muted">知识管理平台</div>
@@ -227,31 +252,58 @@ async function go(path) {
}
.layout-aside {
background: #ffffff;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94));
border-right: 1px solid var(--app-border);
box-shadow: 4px 0 16px rgba(15, 23, 42, 0.04);
}
.brand,
.drawer-brand {
padding: 18px 16px 14px;
}
.brand {
padding: 18px 16px 10px;
}
.drawer-brand {
padding: 18px 16px 10px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
}
.brand-title {
font-size: 15px;
font-size: 16px;
font-weight: 800;
letter-spacing: 0.2px;
}
.brand-sub {
margin-top: 2px;
margin-top: 4px;
font-size: 12px;
}
.aside-menu {
border-right: none;
padding: 8px;
background: transparent;
}
.aside-menu :deep(.el-menu-item) {
height: 42px;
line-height: 42px;
margin: 3px 0;
border-radius: 10px;
color: #334155;
font-weight: 600;
}
.aside-menu :deep(.el-menu-item .el-icon) {
margin-right: 10px;
}
.aside-menu :deep(.el-menu-item:hover) {
background: rgba(59, 130, 246, 0.08);
color: #1d4ed8;
}
.aside-menu :deep(.el-menu-item.is-active) {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.12), rgba(124, 58, 237, 0.1));
color: #1e40af;
}
.menu-label {
@@ -266,16 +318,22 @@ async function go(path) {
}
.fallback-card {
border-radius: var(--app-radius);
min-height: 160px;
border-radius: var(--app-radius-lg);
border: 1px solid var(--app-border);
}
.layout-header {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: rgba(246, 247, 251, 0.6);
height: 58px;
padding: 0 18px;
background: rgba(255, 255, 255, 0.78);
backdrop-filter: saturate(180%) blur(10px);
border-bottom: 1px solid var(--app-border);
}
@@ -288,7 +346,7 @@ async function go(path) {
}
.header-title {
font-size: 14px;
font-size: 15px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
@@ -311,13 +369,48 @@ async function go(path) {
align-items: baseline;
gap: 8px;
font-size: 13px;
color: #334155;
}
.admin-name strong {
color: #0f172a;
font-weight: 800;
}
.logout-btn {
min-width: 74px;
}
.layout-main {
padding: 16px;
padding: 18px;
}
.main-shell {
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
@media (max-width: 768px) {
.layout-header {
flex-wrap: wrap;
height: auto;
padding: 10px 12px;
}
.header-right {
width: 100%;
justify-content: flex-end;
}
.admin-name .app-muted {
display: none;
}
.admin-name strong {
display: none;
}
.layout-main {
padding: 12px;
}

View File

@@ -1,6 +1,7 @@
<script setup>
import { onMounted, ref } from 'vue'
import { h, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import {
activateAnnouncement,
@@ -8,10 +9,14 @@ import {
deactivateAnnouncement,
deleteAnnouncement,
fetchAnnouncements,
uploadAnnouncementImage,
} from '../api/announcements'
const formTitle = ref('')
const formContent = ref('')
const formImageUrl = ref('')
const imageInputRef = ref(null)
const uploading = ref(false)
const loading = ref(false)
const list = ref([])
@@ -30,18 +35,56 @@ async function load() {
function clearForm() {
formTitle.value = ''
formContent.value = ''
formImageUrl.value = ''
if (imageInputRef.value) imageInputRef.value.value = ''
}
function openImagePicker() {
imageInputRef.value?.click()
}
function clearImage() {
formImageUrl.value = ''
if (imageInputRef.value) imageInputRef.value.value = ''
}
async function onImageFileChange(event) {
const file = event.target?.files?.[0]
if (!file) return
if (file.type && !file.type.startsWith('image/')) {
ElMessage.error('请选择图片文件')
event.target.value = ''
return
}
uploading.value = true
try {
const res = await uploadAnnouncementImage(file)
if (!res?.success || !res?.url) {
ElMessage.error(res?.error || '上传失败')
return
}
formImageUrl.value = res.url
ElMessage.success('上传成功')
} catch {
// handled by interceptor
} finally {
uploading.value = false
event.target.value = ''
}
}
async function submit(isActive) {
const title = formTitle.value.trim()
const content = formContent.value.trim()
const image_url = formImageUrl.value.trim()
if (!title || !content) {
ElMessage.error('标题和内容不能为空')
return
}
try {
const res = await createAnnouncement({ title, content, is_active: Boolean(isActive) })
const res = await createAnnouncement({ title, content, image_url, is_active: Boolean(isActive) })
if (!res?.success) {
ElMessage.error(res?.error || '保存失败')
return
@@ -55,7 +98,17 @@ async function submit(isActive) {
}
async function view(row) {
await ElMessageBox.alert(row.content || '', row.title || '公告', {
const body = h('div', { class: 'announcement-view' }, [
row.content ? h('div', { class: 'announcement-view-text' }, row.content) : null,
row.image_url
? h('img', {
class: 'announcement-view-image',
src: row.image_url,
alt: '公告图片',
})
: null,
])
await ElMessageBox.alert(body, row.title || '公告', {
confirmButtonText: '关闭',
dangerouslyUseHTMLString: false,
})
@@ -140,9 +193,6 @@ onMounted(load)
<div class="page-stack">
<div class="app-page-title">
<h2>公告管理</h2>
<div>
<el-button @click="load">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
@@ -162,8 +212,26 @@ onMounted(load)
show-word-limit
/>
</el-form-item>
<el-form-item label="公告图片">
<div class="image-upload-row">
<el-button :icon="Plus" :loading="uploading" @click="openImagePicker">上传图片</el-button>
<el-button v-if="formImageUrl" @click="clearImage">移除</el-button>
<span v-if="formImageUrl" class="image-url">{{ formImageUrl }}</span>
<input
ref="imageInputRef"
class="image-input"
type="file"
accept="image/*"
@change="onImageFileChange"
/>
</div>
</el-form-item>
</el-form>
<div v-if="formImageUrl" class="image-preview">
<img :src="formImageUrl" alt="公告图片预览" />
</div>
<div class="actions">
<el-button type="primary" @click="submit(true)">发布并启用</el-button>
<el-button @click="submit(false)">保存但不启用</el-button>
@@ -193,6 +261,12 @@ onMounted(load)
</el-tag>
</template>
</el-table-column>
<el-table-column label="图片" width="100">
<template #default="{ row }">
<el-tag v-if="row.image_url" type="success" effect="light">有图</el-tag>
<span v-else class="app-muted">-</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
@@ -214,18 +288,22 @@ onMounted(load)
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
gap: 14px;
min-width: 0;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
background: var(--app-card-bg);
box-shadow: var(--app-shadow-soft);
}
.section-title {
margin: 0 0 12px;
font-size: 14px;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.2px;
}
.help {
@@ -234,8 +312,62 @@ onMounted(load)
color: var(--app-muted);
}
.image-preview {
margin: 6px 0 2px;
display: flex;
justify-content: flex-start;
}
.image-preview img {
max-width: 280px;
max-height: 160px;
border-radius: 8px;
border: 1px solid var(--app-border);
object-fit: contain;
}
.image-upload-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.image-input {
display: none;
}
.image-url {
font-size: 12px;
color: var(--app-muted);
word-break: break-all;
}
.announcement-view {
display: flex;
flex-direction: column;
gap: 12px;
}
.announcement-view-text {
white-space: pre-wrap;
line-height: 1.6;
font-size: 14px;
}
.announcement-view-image {
max-width: 100%;
max-height: 320px;
border-radius: 10px;
border: 1px solid var(--app-border);
object-fit: contain;
}
.table-wrap {
overflow-x: auto;
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
.ellipsis {
@@ -252,4 +384,3 @@ onMounted(load)
gap: 8px;
}
</style>

View File

@@ -1,16 +1,18 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { cleanupEmailLogs, fetchEmailLogs, fetchEmailSettings, fetchEmailStats, updateEmailSettings } from '../api/email'
import {
createSmtpConfig,
clearPrimarySmtpConfig,
deleteSmtpConfig,
fetchSmtpConfigs,
setPrimarySmtpConfig,
testSmtpConfig,
updateSmtpConfig,
} from '../api/smtp'
import MetricGrid from '../components/MetricGrid.vue'
// ========== 全局设置 ==========
const emailSettingsLoading = ref(false)
@@ -20,6 +22,7 @@ const settings = reactive({
enabled: false,
failover_enabled: true,
register_verify_enabled: false,
login_alert_enabled: true,
task_notify_enabled: false,
base_url: '',
updated_at: null,
@@ -34,6 +37,7 @@ async function loadEmailSettings() {
settings.enabled = Boolean(data.enabled)
settings.failover_enabled = Boolean(data.failover_enabled)
settings.register_verify_enabled = Boolean(data.register_verify_enabled)
settings.login_alert_enabled = data.login_alert_enabled === undefined ? true : Boolean(data.login_alert_enabled)
settings.task_notify_enabled = Boolean(data.task_notify_enabled)
settings.base_url = data.base_url || ''
settings.updated_at = data.updated_at || null
@@ -52,6 +56,7 @@ async function saveEmailSettings() {
enabled: settings.enabled,
failover_enabled: settings.failover_enabled,
register_verify_enabled: settings.register_verify_enabled,
login_alert_enabled: settings.login_alert_enabled,
task_notify_enabled: settings.task_notify_enabled,
base_url: (settings.base_url || '').trim(),
})
@@ -73,6 +78,11 @@ function scheduleSaveEmailSettings() {
saveTimer = window.setTimeout(saveEmailSettings, 300)
}
onBeforeUnmount(() => {
if (saveTimer) window.clearTimeout(saveTimer)
saveTimer = null
})
// ========== SMTP 配置 ==========
const smtpLoading = ref(false)
const smtpConfigs = ref([])
@@ -80,6 +90,7 @@ const smtpConfigs = ref([])
const smtpDialogOpen = ref(false)
const smtpEditMode = ref(false)
const smtpHasPassword = ref(false)
const smtpIsPrimary = ref(false)
const smtpForm = reactive({
id: null,
@@ -97,10 +108,120 @@ const smtpForm = reactive({
priority: 0,
})
const SMTP_TEMPLATES = [
{
key: 'custom',
label: '自定义(手动填写)',
defaults: null,
note: '适用于其他邮箱/自建SMTP',
links: [],
},
{
key: 'gmail',
label: 'Gmail',
defaults: { host: 'smtp.gmail.com', port: 465, use_ssl: true, use_tls: false },
note: '通常需要开启两步验证并创建应用专用密码App Password',
links: [
{ label: 'SMTP 设置说明', url: 'https://support.google.com/mail/answer/7126229?hl=zh-Hans' },
{ label: 'App Password', url: 'https://myaccount.google.com/apppasswords' },
],
},
{
key: 'qq',
label: 'QQ 邮箱',
defaults: { host: 'smtp.qq.com', port: 465, use_ssl: true, use_tls: false },
note: '需要在邮箱设置中开启 SMTP 并获取授权码不是QQ登录密码',
links: [{ label: 'QQ邮箱 SMTP 帮助', url: 'https://service.mail.qq.com/cgi-bin/help?subtype=1&id=28&no=1001256' }],
},
{
key: '163',
label: '163 邮箱',
defaults: { host: 'smtp.163.com', port: 465, use_ssl: true, use_tls: false },
note: '需要在邮箱设置中开启 SMTP 并使用授权码/客户端授权密码',
links: [{ label: '网易邮箱 SMTP 帮助', url: 'https://help.mail.163.com/faqDetail.do?code=d7a5dc8471a22b76' }],
},
{
key: '126',
label: '126 邮箱',
defaults: { host: 'smtp.126.com', port: 465, use_ssl: true, use_tls: false },
note: '需要在邮箱设置中开启 SMTP 并使用授权码/客户端授权密码',
links: [{ label: '网易邮箱帮助', url: 'https://help.mail.163.com/' }],
},
{
key: 'outlook',
label: 'Outlook/Hotmail',
defaults: { host: 'smtp-mail.outlook.com', port: 587, use_ssl: false, use_tls: true },
note: '建议使用 TLS 587部分账号需开启 SMTP AUTH',
links: [
{
label: '微软 SMTP 设置',
url: 'https://support.microsoft.com/office/pop-imap-and-smtp-settings-for-outlook-com-d088b0b7-0d38-4f9a-bc5d-509f9e4c6d3d',
},
],
},
{
key: 'office365',
label: 'Microsoft 365/Exchange',
defaults: { host: 'smtp.office365.com', port: 587, use_ssl: false, use_tls: true },
note: '企业邮箱常用配置(需启用 SMTP AUTH',
links: [{ label: '微软官方说明', url: 'https://learn.microsoft.com/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission' }],
},
{
key: 'icloud',
label: 'iCloud',
defaults: { host: 'smtp.mail.me.com', port: 587, use_ssl: false, use_tls: true },
note: '需要在 Apple ID 中生成“App 专用密码”',
links: [{ label: 'Apple 邮件服务器设置', url: 'https://support.apple.com/zh-cn/HT202304' }],
},
{
key: 'tencent_exmail',
label: '腾讯企业邮箱',
defaults: { host: 'smtp.exmail.qq.com', port: 465, use_ssl: true, use_tls: false },
note: '企业邮箱常用配置',
links: [{ label: '腾讯企业邮箱帮助', url: 'https://service.exmail.qq.com/cgi-bin/help?subtype=1&id=23&no=1001068' }],
},
{
key: 'aliyun_exmail',
label: '阿里企业邮箱',
defaults: { host: 'smtp.mxhichina.com', port: 465, use_ssl: true, use_tls: false },
note: '企业邮箱常用配置',
links: [{ label: '阿里云文档', url: 'https://help.aliyun.com/document_detail/50652.html' }],
},
]
const smtpTemplateKey = ref('custom')
const currentSmtpTemplate = computed(() => SMTP_TEMPLATES.find((t) => t.key === smtpTemplateKey.value) || SMTP_TEMPLATES[0])
const smtpPasswordPlaceholder = computed(() =>
smtpEditMode.value && smtpHasPassword.value ? '留空保持不变' : 'SMTP密码或授权码',
)
function inferSmtpTemplateKey(row) {
const host = String(row?.host || '').trim().toLowerCase()
if (!host) return 'custom'
const byHost = {
'smtp.gmail.com': 'gmail',
'smtp.qq.com': 'qq',
'smtp.163.com': '163',
'smtp.126.com': '126',
'smtp-mail.outlook.com': 'outlook',
'smtp.office365.com': 'office365',
'smtp.mail.me.com': 'icloud',
'smtp.exmail.qq.com': 'tencent_exmail',
'smtp.mxhichina.com': 'aliyun_exmail',
}
return byHost[host] || 'custom'
}
function applySmtpTemplate(key) {
const tpl = SMTP_TEMPLATES.find((t) => t.key === key)
if (!tpl || !tpl.defaults) return
smtpForm.host = tpl.defaults.host
smtpForm.port = tpl.defaults.port
smtpForm.use_ssl = tpl.defaults.use_ssl
smtpForm.use_tls = tpl.defaults.use_tls
}
function resetSmtpForm() {
smtpForm.id = null
smtpForm.name = '默认配置'
@@ -116,6 +237,8 @@ function resetSmtpForm() {
smtpForm.daily_limit = 0
smtpForm.priority = 0
smtpHasPassword.value = false
smtpIsPrimary.value = false
smtpTemplateKey.value = 'custom'
}
async function loadSmtpConfigs() {
@@ -132,6 +255,7 @@ async function loadSmtpConfigs() {
function openCreateSmtp() {
smtpEditMode.value = false
resetSmtpForm()
smtpTemplateKey.value = 'custom'
smtpDialogOpen.value = true
}
@@ -153,6 +277,8 @@ function openEditSmtp(row) {
smtpForm.daily_limit = row.daily_limit ?? 0
smtpForm.priority = row.priority ?? 0
smtpHasPassword.value = Boolean(row.has_password)
smtpIsPrimary.value = Boolean(row.is_primary)
smtpTemplateKey.value = inferSmtpTemplateKey(row)
smtpDialogOpen.value = true
}
@@ -280,6 +406,32 @@ async function doSetPrimary() {
}
}
async function doClearPrimary() {
if (!smtpEditMode.value) return
try {
await ElMessageBox.confirm('确定取消主配置吗取消后将按优先级选择可用SMTP。', '取消主配置', {
confirmButtonText: '取消主配置',
cancelButtonText: '保留',
type: 'warning',
})
} catch {
return
}
try {
const res = await clearPrimarySmtpConfig()
if (!res?.success) {
ElMessage.error(res?.error || '操作失败')
return
}
ElMessage.success('已取消主配置')
smtpDialogOpen.value = false
await loadSmtpConfigs()
} catch {
// handled by interceptor
}
}
async function doDeleteSmtp() {
if (!smtpEditMode.value || !smtpForm.id) return
try {
@@ -325,10 +477,32 @@ function emailTypeLabel(type) {
reset: '密码重置',
bind: '邮箱绑定',
task_complete: '任务完成',
security_alert: '安全告警',
}
return map[type] || type
}
function emailLogUserLabel(row) {
if (row?.username && row?.user_id) return `${row.username} (#${row.user_id})`
if (row?.user_id) return `用户#${row.user_id}`
return '系统'
}
const emailSummaryCards = computed(() => [
{ key: 'total_sent', label: '总发送', value: emailStats.value?.total_sent || 0, tone: 'blue' },
{ key: 'total_success', label: '成功', value: emailStats.value?.total_success || 0, tone: 'green' },
{ key: 'total_failed', label: '失败', value: emailStats.value?.total_failed || 0, tone: 'red' },
{ key: 'success_rate', label: '成功率', value: `${emailStats.value?.success_rate || 0}%`, tone: 'purple' },
])
const emailTypeCards = computed(() => [
{ key: 'register_sent', label: '注册验证', value: emailStats.value?.register_sent || 0, tone: 'cyan' },
{ key: 'reset_sent', label: '密码重置', value: emailStats.value?.reset_sent || 0, tone: 'orange' },
{ key: 'bind_sent', label: '邮箱绑定', value: emailStats.value?.bind_sent || 0, tone: 'purple' },
{ key: 'task_complete_sent', label: '任务完成', value: emailStats.value?.task_complete_sent || 0, tone: 'green' },
])
async function loadEmailStats() {
emailStatsLoading.value = true
try {
@@ -416,9 +590,6 @@ onMounted(refreshAll)
<div class="page-stack">
<div class="app-page-title">
<h2>邮件配置</h2>
<div class="toolbar">
<el-button @click="refreshAll">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card" v-loading="emailSettingsLoading">
@@ -442,6 +613,8 @@ onMounted(refreshAll)
@change="scheduleSaveEmailSettings"
/>
</el-form-item>
<el-divider content-position="left">通知设置</el-divider>
<el-form-item label="启用任务完成通知">
<el-switch
v-model="settings.task_notify_enabled"
@@ -449,6 +622,14 @@ onMounted(refreshAll)
@change="scheduleSaveEmailSettings"
/>
</el-form-item>
<el-form-item label="新设备登录提醒">
<el-switch
v-model="settings.login_alert_enabled"
:disabled="emailSettingsSaving"
@change="scheduleSaveEmailSettings"
/>
<div class="help">当检测到新设备或新IP登录时发送邮件提醒用户</div>
</el-form-item>
<el-form-item label="网站基础URL">
<el-input
v-model="settings.base_url"
@@ -500,38 +681,10 @@ onMounted(refreshAll)
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card" v-loading="emailStatsLoading">
<h3 class="section-title">邮件发送统计</h3>
<el-row :gutter="12">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value">{{ emailStats.total_sent || 0 }}</div>
<div class="stat-label">总发送</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value ok">{{ emailStats.total_success || 0 }}</div>
<div class="stat-label">成功</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value err">{{ emailStats.total_failed || 0 }}</div>
<div class="stat-label">失败</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value">{{ emailStats.success_rate || 0 }}%</div>
<div class="stat-label">成功率</div>
</el-card>
</el-col>
</el-row>
<MetricGrid :items="emailSummaryCards" :loading="emailStatsLoading" :min-width="160" />
<div class="sub-stats">
<el-tag effect="light">注册验证 {{ emailStats.register_sent || 0 }}</el-tag>
<el-tag effect="light">密码重置 {{ emailStats.reset_sent || 0 }}</el-tag>
<el-tag effect="light">邮箱绑定 {{ emailStats.bind_sent || 0 }}</el-tag>
<el-tag effect="light">任务完成 {{ emailStats.task_complete_sent || 0 }}</el-tag>
<MetricGrid :items="emailTypeCards" :loading="emailStatsLoading" :min-width="150" />
</div>
<div class="help app-muted">最后更新{{ emailStats.last_updated || '-' }}</div>
@@ -547,6 +700,7 @@ onMounted(refreshAll)
<el-option label="密码重置" value="reset" />
<el-option label="邮箱绑定" value="bind" />
<el-option label="任务完成" value="task_complete" />
<el-option label="安全告警" value="security_alert" />
</el-select>
<el-select v-model="emailLogStatusFilter" style="width: 120px" @change="loadEmailLogs(1)">
<el-option label="全部状态" value="" />
@@ -561,6 +715,11 @@ onMounted(refreshAll)
<el-table :data="emailLogs" v-loading="emailLogsLoading" style="width: 100%">
<el-table-column prop="created_at" label="时间" width="180" />
<el-table-column prop="email_to" label="收件人" min-width="180" />
<el-table-column label="来源用户" min-width="160">
<template #default="{ row }">
<span class="ellipsis" :title="emailLogUserLabel(row)">{{ emailLogUserLabel(row) }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="120">
<template #default="{ row }">{{ emailTypeLabel(row.email_type) }}</template>
</el-table-column>
@@ -596,7 +755,11 @@ onMounted(refreshAll)
</div>
</el-card>
<el-dialog v-model="smtpDialogOpen" :title="smtpEditMode ? '编辑SMTP配置' : '添加SMTP配置'" width="560px">
<el-dialog
v-model="smtpDialogOpen"
:title="smtpEditMode ? '编辑SMTP配置' : '添加SMTP配置'"
width="min(560px, 92vw)"
>
<el-form label-width="120px">
<el-form-item label="名称">
<el-input v-model="smtpForm.name" />
@@ -604,6 +767,26 @@ onMounted(refreshAll)
<el-form-item label="启用">
<el-switch v-model="smtpForm.enabled" />
</el-form-item>
<el-form-item label="邮箱模板">
<div style="width: 100%">
<el-select v-model="smtpTemplateKey" placeholder="选择常用邮箱模板" style="width: 100%" @change="applySmtpTemplate">
<el-option v-for="t in SMTP_TEMPLATES" :key="t.key" :label="t.label" :value="t.key" />
</el-select>
<div
v-if="currentSmtpTemplate.note || (currentSmtpTemplate.links && currentSmtpTemplate.links.length)"
class="help"
>
<span v-if="currentSmtpTemplate.note">{{ currentSmtpTemplate.note }}</span>
<template v-if="currentSmtpTemplate.links && currentSmtpTemplate.links.length">
<span v-if="currentSmtpTemplate.note"> · </span>
<span v-for="(l, idx) in currentSmtpTemplate.links" :key="l.url">
<el-link :href="l.url" target="_blank" type="primary" :underline="false">{{ l.label }}</el-link>
<span v-if="idx < currentSmtpTemplate.links.length - 1"> · </span>
</span>
</template>
</div>
</div>
</el-form-item>
<el-form-item label="服务器">
<el-input v-model="smtpForm.host" placeholder="smtp.example.com" />
</el-form-item>
@@ -639,7 +822,8 @@ onMounted(refreshAll)
<template #footer>
<div class="dialog-actions">
<el-button @click="doTestSmtp">测试连接</el-button>
<el-button v-if="smtpEditMode" @click="doSetPrimary">设为主配置</el-button>
<el-button v-if="smtpEditMode && smtpIsPrimary" type="warning" plain @click="doClearPrimary">取消主配置</el-button>
<el-button v-if="smtpEditMode && !smtpIsPrimary" @click="doSetPrimary">设为主配置</el-button>
<el-button v-if="smtpEditMode" type="danger" plain @click="doDeleteSmtp">删除配置</el-button>
<div class="spacer"></div>
<el-button @click="smtpDialogOpen = false">取消</el-button>
@@ -654,7 +838,8 @@ onMounted(refreshAll)
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
gap: 14px;
min-width: 0;
}
.toolbar {
@@ -667,6 +852,8 @@ onMounted(refreshAll)
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
background: var(--app-card-bg);
box-shadow: var(--app-shadow-soft);
}
.section-head {
@@ -680,8 +867,9 @@ onMounted(refreshAll)
.section-title {
margin: 0;
font-size: 14px;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.2px;
}
.help {
@@ -692,37 +880,13 @@ onMounted(refreshAll)
.table-wrap {
overflow-x: auto;
}
.stat-card {
border-radius: var(--app-radius);
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
.stat-value {
font-size: 20px;
font-weight: 900;
line-height: 1.1;
}
.stat-label {
margin-top: 6px;
font-size: 12px;
color: var(--app-muted);
}
.ok {
color: #047857;
}
.err {
color: #b91c1c;
}
.sub-stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}

View File

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

View File

@@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchAllUsers } from '../api/users'
import { clearOldTaskLogs, fetchTaskLogs } from '../api/tasks'
import { getTaskSourceMeta } from '../utils/taskSource'
const pageSize = 20
@@ -32,13 +33,8 @@ function formatDuration(seconds) {
}
function sourceMeta(source) {
const map = {
manual: { label: '手动', type: 'success' },
scheduled: { label: '定时', type: 'primary' },
immediate: { label: '即时', type: 'warning' },
resumed: { label: '恢复', type: 'info' },
}
return map[source] || { label: source || '手动', type: 'info' }
const meta = getTaskSourceMeta(source)
return { key: meta.group, label: meta.label, type: meta.type, tooltip: meta.tooltip }
}
function statusMeta(status) {
@@ -147,9 +143,6 @@ onMounted(async () => {
<div class="page-stack">
<div class="app-page-title">
<h2>任务日志</h2>
<div class="toolbar">
<el-button @click="load">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
@@ -169,9 +162,12 @@ onMounted(async () => {
<el-select v-model="sourceFilter" placeholder="来源" style="width: 120px">
<el-option label="全部" value="" />
<el-option label="手动" value="manual" />
<el-option label="定时" value="scheduled" />
<el-option label="即时" value="immediate" />
<el-option label="恢复" value="resumed" />
<el-option label="定时任务(系统)" value="scheduled" />
<el-option label="定时任务(用户)" value="user_scheduled" />
<el-option label="手动(批量)" value="batch" />
<el-option label="手动(截图)" value="manual_screenshot" />
<el-option label="手动(立即)" value="immediate" />
<el-option label="手动(恢复)" value="resumed" />
</el-select>
<el-select
v-model="userIdFilter"
@@ -195,9 +191,17 @@ onMounted(async () => {
<div class="table-wrap">
<el-table :data="logs" v-loading="loading" style="width: 100%">
<el-table-column prop="created_at" label="时间" width="180" />
<el-table-column label="来源" width="90">
<el-table-column label="来源" width="110">
<template #default="{ row }">
<el-tag :type="sourceMeta(row.source).type" effect="light">{{ sourceMeta(row.source).label }}</el-tag>
<el-tooltip
v-if="sourceMeta(row.source).tooltip"
:content="sourceMeta(row.source).tooltip"
placement="top"
:show-after="300"
>
<el-tag :type="sourceMeta(row.source).type" effect="light">{{ sourceMeta(row.source).label }}</el-tag>
</el-tooltip>
<el-tag v-else :type="sourceMeta(row.source).type" effect="light">{{ sourceMeta(row.source).label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="user_username" label="用户" width="140" />
@@ -242,12 +246,15 @@ onMounted(async () => {
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
gap: 14px;
min-width: 0;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
background: var(--app-card-bg);
box-shadow: var(--app-shadow-soft);
}
.filters {
@@ -259,6 +266,9 @@ onMounted(async () => {
.table-wrap {
overflow-x: auto;
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
.ellipsis {
@@ -282,4 +292,3 @@ onMounted(async () => {
font-size: 12px;
}
</style>

View File

@@ -1,228 +0,0 @@
<script setup>
import { inject, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchPendingUsers, approveUser, rejectUser } from '../api/users'
import { approvePasswordReset, fetchPasswordResets, rejectPasswordReset } from '../api/passwordResets'
import { parseSqliteDateTime } from '../utils/datetime'
const refreshStats = inject('refreshStats', null)
const refreshNavBadges = inject('refreshNavBadges', null)
const pendingUsers = ref([])
const passwordResets = ref([])
const loadingPending = ref(false)
const loadingResets = ref(false)
function isVip(user) {
const expire = user?.vip_expire_time
if (!expire) return false
if (String(expire).startsWith('2099-12-31')) return true
const dt = parseSqliteDateTime(expire)
return dt ? dt.getTime() > Date.now() : false
}
async function loadPending() {
loadingPending.value = true
try {
pendingUsers.value = await fetchPendingUsers()
} catch {
pendingUsers.value = []
} finally {
loadingPending.value = false
}
}
async function loadResets() {
loadingResets.value = true
try {
passwordResets.value = await fetchPasswordResets()
} catch {
passwordResets.value = []
} finally {
loadingResets.value = false
}
}
async function refreshAll() {
await Promise.all([loadPending(), loadResets()])
await refreshNavBadges?.({ pendingResets: passwordResets.value.length })
}
async function onApproveUser(row) {
try {
await ElMessageBox.confirm(`确定通过用户「${row.username}」的注册申请吗?`, '审核通过', {
confirmButtonText: '通过',
cancelButtonText: '取消',
type: 'success',
})
} catch {
return
}
try {
await approveUser(row.id)
ElMessage.success('用户审核通过')
await refreshAll()
await refreshStats?.()
} catch {
// handled by interceptor
}
}
async function onRejectUser(row) {
try {
await ElMessageBox.confirm(`确定拒绝用户「${row.username}」的注册申请吗?`, '拒绝申请', {
confirmButtonText: '拒绝',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await rejectUser(row.id)
ElMessage.success('已拒绝用户')
await refreshAll()
await refreshStats?.()
} catch {
// handled by interceptor
}
}
async function onApproveReset(row) {
try {
await ElMessageBox.confirm(`确定批准「${row.username}」的密码重置申请吗?`, '批准重置', {
confirmButtonText: '批准',
cancelButtonText: '取消',
type: 'success',
})
} catch {
return
}
try {
const res = await approvePasswordReset(row.id)
ElMessage.success(res?.message || '密码重置申请已批准')
await loadResets()
await refreshNavBadges?.({ pendingResets: passwordResets.value.length })
} catch {
// handled by interceptor
}
}
async function onRejectReset(row) {
try {
await ElMessageBox.confirm(`确定拒绝「${row.username}」的密码重置申请吗?`, '拒绝重置', {
confirmButtonText: '拒绝',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await rejectPasswordReset(row.id)
ElMessage.success(res?.message || '密码重置申请已拒绝')
await loadResets()
await refreshNavBadges?.({ pendingResets: passwordResets.value.length })
} catch {
// handled by interceptor
}
}
onMounted(refreshAll)
</script>
<template>
<div class="page-stack">
<div class="app-page-title">
<h2>待审核</h2>
<div>
<el-button @click="refreshAll">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">用户注册审核</h3>
<div class="table-wrap">
<el-table :data="pendingUsers" v-loading="loadingPending" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="用户名" min-width="200">
<template #default="{ row }">
<div class="user-cell">
<strong>{{ row.username }}</strong>
<el-tag v-if="isVip(row)" type="warning" effect="light" size="small">VIP</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="220">
<template #default="{ row }">{{ row.email || '-' }}</template>
</el-table-column>
<el-table-column prop="created_at" label="注册时间" min-width="180" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="success" size="small" @click="onApproveUser(row)">通过</el-button>
<el-button type="danger" size="small" @click="onRejectUser(row)">拒绝</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">密码重置审核</h3>
<div class="table-wrap">
<el-table :data="passwordResets" v-loading="loadingResets" style="width: 100%">
<el-table-column prop="id" label="申请ID" width="90" />
<el-table-column prop="username" label="用户名" min-width="200" />
<el-table-column prop="email" label="邮箱" min-width="220">
<template #default="{ row }">{{ row.email || '-' }}</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" min-width="180" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="success" size="small" @click="onApproveReset(row)">批准</el-button>
<el-button type="danger" size="small" @click="onRejectReset(row)">拒绝</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.section-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 800;
}
.table-wrap {
overflow-x: auto;
}
.user-cell {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,836 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
banIp,
banUser,
cleanup,
clearIpRisk,
getBannedIps,
getBannedUsers,
getDashboard,
getIpRisk,
getThreats,
getUserRisk,
unbanIp,
unbanUser,
} from '../api/security'
import MetricGrid from '../components/MetricGrid.vue'
const pageSize = 20
const activeTab = ref('threats')
const dashboardLoading = ref(false)
const dashboard = ref(null)
const threatsLoading = ref(false)
const threatItems = ref([])
const threatTotal = ref(0)
const threatPage = ref(1)
const threatTypeFilter = ref('')
const threatSeverityFilter = ref('')
const bansLoading = ref(false)
const bannedIps = ref([])
const bannedUsers = ref([])
const banTab = ref('ips')
const banDialogOpen = ref(false)
const banSubmitting = ref(false)
const banForm = ref({
kind: 'ip',
ip: '',
user_id: '',
reason: '',
duration_hours: 24,
permanent: false,
})
const riskTab = ref('ip')
const riskLoading = ref(false)
const riskIpInput = ref('')
const riskUserIdInput = ref('')
const riskResult = ref(null)
const riskResultKind = ref('')
const commonThreatTypes = [
'sql_injection',
'xss',
'path_traversal',
'command_injection',
'ssrf',
'scanner',
'bruteforce',
'csrf',
'xxe',
'file_upload',
]
function normalizeCount(value) {
const n = Number(value)
return Number.isFinite(n) ? n : 0
}
function scoreMeta(score) {
const n = Number(score || 0)
if (n >= 80) return { label: '高', type: 'danger' }
if (n >= 50) return { label: '中', type: 'warning' }
return { label: '低', type: 'success' }
}
function formatExpires(expiresAt) {
const text = String(expiresAt || '').trim()
return text ? text : '永久'
}
function payloadTooltip(row) {
const parts = []
if (row?.field_name) parts.push(`字段: ${row.field_name}`)
if (row?.rule) parts.push(`规则: ${row.rule}`)
if (row?.matched) parts.push(`匹配: ${row.matched}`)
if (row?.value_preview) parts.push(`值: ${row.value_preview}`)
return parts.length ? parts.join(' · ') : '-'
}
function pathText(row) {
const method = String(row?.request_method || '').trim()
const path = String(row?.request_path || '').trim()
const combined = `${method} ${path}`.trim()
return combined || '-'
}
const threatTypeOptions = computed(() => {
const seen = new Set(commonThreatTypes)
const recent = dashboard.value?.recent_threat_events || []
for (const item of recent) {
const t = String(item?.threat_type || '').trim()
if (t) seen.add(t)
}
for (const item of threatItems.value || []) {
const t = String(item?.threat_type || '').trim()
if (t) seen.add(t)
}
return Array.from(seen)
.sort((a, b) => a.localeCompare(b))
.map((t) => ({ label: t, value: t }))
})
const dashboardCards = computed(() => {
const d = dashboard.value || {}
return [
{
key: 'threat_events_24h',
label: '最近24小时威胁事件',
value: normalizeCount(d.threat_events_24h),
tone: 'red',
hint: '用于衡量当前攻击面活跃度',
},
{
key: 'banned_ip_count',
label: '当前封禁 IP 数',
value: normalizeCount(d.banned_ip_count),
tone: 'orange',
hint: '自动与人工封禁总量',
},
{
key: 'banned_user_count',
label: '当前封禁用户数',
value: normalizeCount(d.banned_user_count),
tone: 'purple',
hint: '高风险账户拦截情况',
},
]
})
const threatTotalPages = computed(() => Math.max(1, Math.ceil((threatTotal.value || 0) / pageSize)))
async function loadDashboard() {
dashboardLoading.value = true
try {
dashboard.value = await getDashboard()
} catch {
dashboard.value = null
} finally {
dashboardLoading.value = false
}
}
async function loadThreats() {
threatsLoading.value = true
try {
const params = {
page: threatPage.value,
per_page: pageSize,
}
if (threatTypeFilter.value) params.event_type = threatTypeFilter.value
if (threatSeverityFilter.value) params.severity = threatSeverityFilter.value
const data = await getThreats(params)
threatItems.value = data?.items || []
threatTotal.value = data?.total || 0
} catch {
threatItems.value = []
threatTotal.value = 0
} finally {
threatsLoading.value = false
}
}
async function loadBans() {
if (bansLoading.value) return
bansLoading.value = true
try {
const [ipsRes, usersRes] = await Promise.allSettled([getBannedIps(), getBannedUsers()])
bannedIps.value = ipsRes.status === 'fulfilled' ? ipsRes.value?.items || [] : []
bannedUsers.value = usersRes.status === 'fulfilled' ? usersRes.value?.items || [] : []
} finally {
bansLoading.value = false
}
}
async function refreshAll() {
await Promise.allSettled([loadDashboard(), loadThreats(), loadBans()])
}
function onThreatFilter() {
threatPage.value = 1
loadThreats()
}
function onThreatReset() {
threatTypeFilter.value = ''
threatSeverityFilter.value = ''
threatPage.value = 1
loadThreats()
}
function resetBanForm() {
banForm.value = {
kind: 'ip',
ip: '',
user_id: '',
reason: '',
duration_hours: 24,
permanent: false,
}
}
function openBanDialog(kind = 'ip', preset = {}) {
resetBanForm()
banForm.value.kind = kind === 'user' ? 'user' : 'ip'
if (banForm.value.kind === 'ip') {
banForm.value.ip = String(preset.ip || '').trim()
} else {
banForm.value.user_id = String(preset.user_id || '').trim()
}
if (preset.reason) banForm.value.reason = String(preset.reason || '').trim()
banDialogOpen.value = true
}
async function submitBan() {
const kind = banForm.value.kind
const reason = String(banForm.value.reason || '').trim()
const permanent = Boolean(banForm.value.permanent)
const durationHours = Number(banForm.value.duration_hours || 24)
if (!reason) {
ElMessage.error('原因不能为空')
return
}
if (kind === 'ip') {
const ip = String(banForm.value.ip || '').trim()
if (!ip) {
ElMessage.error('IP不能为空')
return
}
banSubmitting.value = true
try {
await banIp({ ip, reason, duration_hours: durationHours, permanent })
ElMessage.success('IP已封禁')
banDialogOpen.value = false
await Promise.allSettled([loadDashboard(), loadBans()])
} catch {
// handled by interceptor
} finally {
banSubmitting.value = false
}
return
}
const userIdRaw = String(banForm.value.user_id || '').trim()
const userId = Number.parseInt(userIdRaw, 10)
if (!Number.isFinite(userId)) {
ElMessage.error('用户ID无效')
return
}
banSubmitting.value = true
try {
await banUser({ user_id: userId, reason, duration_hours: durationHours, permanent })
ElMessage.success('用户已封禁')
banDialogOpen.value = false
await Promise.allSettled([loadDashboard(), loadBans()])
} catch {
// handled by interceptor
} finally {
banSubmitting.value = false
}
}
async function onUnbanIp(ip) {
const ipText = String(ip || '').trim()
if (!ipText) return
try {
await ElMessageBox.confirm(`确定解除对 IP ${ipText} 的封禁吗?`, '解除封禁', {
confirmButtonText: '解除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await unbanIp(ipText)
ElMessage.success('已解除IP封禁')
await Promise.allSettled([loadDashboard(), loadBans()])
} catch {
// handled by interceptor
}
}
async function onUnbanUser(userId) {
const id = Number.parseInt(String(userId || '').trim(), 10)
if (!Number.isFinite(id)) return
try {
await ElMessageBox.confirm(`确定解除对 用户ID ${id} 的封禁吗?`, '解除封禁', {
confirmButtonText: '解除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await unbanUser(id)
ElMessage.success('已解除用户封禁')
await Promise.allSettled([loadDashboard(), loadBans()])
} catch {
// handled by interceptor
}
}
function jumpToIpRisk(ip) {
const ipText = String(ip || '').trim()
if (!ipText) return
activeTab.value = 'risk'
riskTab.value = 'ip'
riskIpInput.value = ipText
queryIpRisk()
}
function jumpToUserRisk(userId) {
const idText = String(userId || '').trim()
if (!idText) return
activeTab.value = 'risk'
riskTab.value = 'user'
riskUserIdInput.value = idText
queryUserRisk()
}
async function queryIpRisk() {
const ip = String(riskIpInput.value || '').trim()
if (!ip) {
ElMessage.error('请输入IP')
return
}
riskLoading.value = true
try {
riskResult.value = await getIpRisk(ip)
riskResultKind.value = 'ip'
} catch {
riskResult.value = null
riskResultKind.value = ''
} finally {
riskLoading.value = false
}
}
async function queryUserRisk() {
const raw = String(riskUserIdInput.value || '').trim()
const userId = Number.parseInt(raw, 10)
if (!Number.isFinite(userId)) {
ElMessage.error('请输入有效的用户ID')
return
}
riskLoading.value = true
try {
riskResult.value = await getUserRisk(userId)
riskResultKind.value = 'user'
} catch {
riskResult.value = null
riskResultKind.value = ''
} finally {
riskLoading.value = false
}
}
function openBanFromRisk() {
if (!riskResult.value || !riskResultKind.value) return
if (riskResultKind.value === 'ip') {
openBanDialog('ip', { ip: riskResult.value?.ip, reason: '风险查询手动封禁' })
} else {
openBanDialog('user', { user_id: riskResult.value?.user_id, reason: '风险查询手动封禁' })
}
}
async function unbanFromRisk() {
if (!riskResult.value || !riskResultKind.value) return
if (riskResultKind.value === 'ip') {
await onUnbanIp(riskResult.value?.ip)
await queryIpRisk()
} else {
await onUnbanUser(riskResult.value?.user_id)
await queryUserRisk()
}
}
async function clearIpRiskScore() {
if (riskResultKind.value !== 'ip') return
const ipText = String(riskResult.value?.ip || '').trim()
if (!ipText) return
try {
await ElMessageBox.confirm(
`确定清除 IP ${ipText} 的风险分吗?\n\n清除风险分不会删除威胁历史也不会解除封禁。`,
'清除风险分',
{ confirmButtonText: '清除', cancelButtonText: '取消', type: 'warning' },
)
} catch {
return
}
if (riskLoading.value) return
riskLoading.value = true
try {
await clearIpRisk(ipText)
ElMessage.success('IP风险分已清零')
} catch {
// handled by interceptor
} finally {
riskLoading.value = false
}
await queryIpRisk()
}
const cleanupLoading = ref(false)
async function onCleanup() {
try {
await ElMessageBox.confirm(
'确定清理过期封禁记录,并衰减风险分吗?\n\n该操作不会影响仍在有效期内的封禁。',
'清理过期记录',
{ confirmButtonText: '清理', cancelButtonText: '取消', type: 'warning' },
)
} catch {
return
}
cleanupLoading.value = true
try {
await cleanup()
ElMessage.success('清理完成')
await refreshAll()
} catch {
// handled by interceptor
} finally {
cleanupLoading.value = false
}
}
onMounted(async () => {
await refreshAll()
})
</script>
<template>
<div class="page-stack">
<div class="app-page-title">
<h2>安全防护</h2>
<div class="toolbar">
<el-button type="warning" plain :loading="cleanupLoading" @click="onCleanup">清理过期记录</el-button>
<el-button type="primary" @click="openBanDialog()">手动封禁</el-button>
</div>
</div>
<MetricGrid :items="dashboardCards" :loading="dashboardLoading" :min-width="220" />
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<el-tabs v-model="activeTab">
<el-tab-pane label="威胁事件" name="threats">
<div class="filters">
<el-select
v-model="threatTypeFilter"
placeholder="类型"
style="width: 220px"
filterable
clearable
allow-create
default-first-option
>
<el-option label="全部" value="" />
<el-option v-for="t in threatTypeOptions" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
<el-select v-model="threatSeverityFilter" placeholder="严重程度" style="width: 200px" clearable>
<el-option label="全部" value="" />
<el-option label="高风险(>=80)" value="high" />
<el-option label="中风险(50-79)" value="medium" />
<el-option label="低风险(<50)" value="low" />
</el-select>
<el-button type="primary" @click="onThreatFilter">筛选</el-button>
<el-button @click="onThreatReset">重置</el-button>
</div>
<div class="table-wrap">
<el-table :data="threatItems" v-loading="threatsLoading" style="width: 100%">
<el-table-column prop="created_at" label="时间" width="180" />
<el-table-column label="类型" width="170">
<template #default="{ row }">
<el-tag effect="light" type="info">{{ row.threat_type || 'unknown' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="严重程度" width="120">
<template #default="{ row }">
<el-tag :type="scoreMeta(row.score).type" effect="light">
{{ scoreMeta(row.score).label }} ({{ row.score ?? 0 }})
</el-tag>
</template>
</el-table-column>
<el-table-column label="IP" width="150">
<template #default="{ row }">
<el-link v-if="row.ip" type="primary" :underline="false" @click="jumpToIpRisk(row.ip)">
{{ row.ip }}
</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="用户" width="120">
<template #default="{ row }">
<el-link
v-if="row.user_id !== null && row.user_id !== undefined"
type="primary"
:underline="false"
@click="jumpToUserRisk(row.user_id)"
>
{{ row.user_id }}
</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作路径" min-width="220">
<template #default="{ row }">
<el-tooltip :content="pathText(row)" placement="top" :show-after="300">
<span class="mono ellipsis">{{ pathText(row) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="Payload预览" min-width="240">
<template #default="{ row }">
<el-tooltip :content="payloadTooltip(row)" placement="top" :show-after="300">
<span class="ellipsis">{{ row.value_preview || '-' }}</span>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination">
<el-pagination
v-model:current-page="threatPage"
:page-size="pageSize"
:total="threatTotal"
layout="prev, pager, next, jumper, ->, total"
@current-change="loadThreats"
/>
<div class="page-hint app-muted"> {{ threatPage }} / {{ threatTotalPages }} </div>
</div>
</el-tab-pane>
<el-tab-pane label="封禁管理" name="bans">
<div class="toolbar">
<el-button type="primary" @click="openBanDialog()">手动封禁</el-button>
</div>
<el-tabs v-model="banTab" class="inner-tabs">
<el-tab-pane label="IP黑名单" name="ips">
<div class="table-wrap">
<el-table :data="bannedIps" v-loading="bansLoading" style="width: 100%">
<el-table-column label="IP" width="180">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="jumpToIpRisk(row.ip)">
{{ row.ip || '-' }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="reason" label="原因" min-width="260" />
<el-table-column label="过期时间" width="190">
<template #default="{ row }">{{ formatExpires(row.expires_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button size="small" type="danger" plain @click="onUnbanIp(row.ip)">解除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="用户黑名单" name="users">
<div class="table-wrap">
<el-table :data="bannedUsers" v-loading="bansLoading" style="width: 100%">
<el-table-column label="用户ID" width="180">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="jumpToUserRisk(row.user_id)">
{{ row.user_id ?? '-' }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="reason" label="原因" min-width="260" />
<el-table-column label="过期时间" width="190">
<template #default="{ row }">{{ formatExpires(row.expires_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button size="small" type="danger" plain @click="onUnbanUser(row.user_id)">解除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
<el-tab-pane label="风险查询" name="risk">
<el-tabs v-model="riskTab" class="inner-tabs">
<el-tab-pane label="IP查询" name="ip">
<div class="filters">
<el-input v-model="riskIpInput" placeholder="输入IP如 1.2.3.4" style="width: 260px" clearable />
<el-button type="primary" :loading="riskLoading" @click="queryIpRisk">查询</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="用户查询" name="user">
<div class="filters">
<el-input v-model="riskUserIdInput" placeholder="输入用户ID如 123" style="width: 260px" clearable />
<el-button type="primary" :loading="riskLoading" @click="queryUserRisk">查询</el-button>
</div>
</el-tab-pane>
</el-tabs>
<el-card v-if="riskResult" shadow="never" :body-style="{ padding: '16px' }" class="sub-card">
<div class="risk-head">
<div class="risk-title">
<strong v-if="riskResultKind === 'ip'">IP: {{ riskResult.ip }}</strong>
<strong v-else>用户ID: {{ riskResult.user_id }}</strong>
<span class="app-muted">风险分</span>
<el-tag :type="scoreMeta(riskResult.risk_score).type" effect="light">
{{ riskResult.risk_score ?? 0 }}
</el-tag>
<el-tag v-if="riskResult.is_banned" type="danger" effect="light">已封禁</el-tag>
<el-tag v-else type="success" effect="light">未封禁</el-tag>
</div>
<div class="toolbar">
<el-button v-if="!riskResult.is_banned" type="primary" plain @click="openBanFromRisk">封禁</el-button>
<el-button v-else type="danger" plain @click="unbanFromRisk">解除封禁</el-button>
<el-button
v-if="riskResultKind === 'ip'"
type="warning"
plain
:loading="riskLoading"
@click="clearIpRiskScore"
>
清除风险分
</el-button>
</div>
</div>
<div class="table-wrap">
<el-table :data="riskResult.threat_history || []" v-loading="riskLoading" style="width: 100%">
<el-table-column prop="created_at" label="时间" width="180" />
<el-table-column label="类型" width="170">
<template #default="{ row }">
<el-tag effect="light" type="info">{{ row.threat_type || 'unknown' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="严重程度" width="120">
<template #default="{ row }">
<el-tag :type="scoreMeta(row.score).type" effect="light">
{{ scoreMeta(row.score).label }} ({{ row.score ?? 0 }})
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作路径" min-width="220">
<template #default="{ row }">
<el-tooltip :content="pathText(row)" placement="top" :show-after="300">
<span class="mono ellipsis">{{ pathText(row) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="Payload预览" min-width="240">
<template #default="{ row }">
<el-tooltip :content="payloadTooltip(row)" placement="top" :show-after="300">
<span class="ellipsis">{{ row.value_preview || '-' }}</span>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-tab-pane>
</el-tabs>
</el-card>
<el-dialog v-model="banDialogOpen" title="手动封禁" width="min(520px, 92vw)" @closed="resetBanForm">
<el-form label-width="120px">
<el-form-item label="类型">
<el-radio-group v-model="banForm.kind">
<el-radio-button label="ip">IP</el-radio-button>
<el-radio-button label="user">用户</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="banForm.kind === 'ip'" label="IP">
<el-input v-model="banForm.ip" placeholder="例如 1.2.3.4" />
</el-form-item>
<el-form-item v-else label="用户ID">
<el-input v-model="banForm.user_id" placeholder="例如 123" />
</el-form-item>
<el-form-item label="原因">
<el-input v-model="banForm.reason" type="textarea" :rows="3" placeholder="请输入封禁原因" />
</el-form-item>
<el-form-item label="永久封禁">
<el-switch v-model="banForm.permanent" />
</el-form-item>
<el-form-item v-if="!banForm.permanent" label="持续(小时)">
<el-input-number v-model="banForm.duration_hours" :min="1" :max="8760" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-actions">
<div class="spacer"></div>
<el-button @click="banDialogOpen = false">取消</el-button>
<el-button type="primary" :loading="banSubmitting" @click="submitBan">确认封禁</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
background: var(--app-card-bg);
box-shadow: var(--app-shadow-soft);
}
.sub-card {
margin-top: 12px;
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 12px;
}
.table-wrap {
overflow-x: auto;
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
.ellipsis {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 14px;
flex-wrap: wrap;
}
.page-hint {
font-size: 12px;
}
.inner-tabs {
margin-top: 6px;
}
.risk-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.risk-title {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.dialog-actions {
display: flex;
align-items: center;
gap: 10px;
}
.spacer {
flex: 1;
}
</style>

View File

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

View File

@@ -1,467 +0,0 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { fetchDockerStats, fetchRunningTasks, fetchServerInfo, fetchTaskStats } from '../api/tasks'
const loading = ref(false)
const server = ref({
cpu_percent: '-',
memory_used: '-',
memory_total: '-',
disk_used: '-',
disk_total: '-',
uptime: '-',
})
const docker = ref({
status: 'Unknown',
memory_usage: 'N/A',
memory_limit: 'N/A',
memory_percent: 'N/A',
uptime: 'N/A',
})
const taskStats = ref({
today: { success_tasks: 0, failed_tasks: 0, total_items: 0, total_attachments: 0 },
total: { success_tasks: 0, failed_tasks: 0, total_items: 0, total_attachments: 0 },
})
const monitor = ref({
running_count: 0,
queuing_count: 0,
max_concurrent: 0,
running: [],
queuing: [],
})
const sourceMap = {
manual: { label: '手动', type: 'success' },
scheduled: { label: '定时', type: 'primary' },
immediate: { label: '即时', type: 'warning' },
resumed: { label: '恢复', type: 'info' },
}
const statusColorMap = {
初始化: '#6b7280',
正在登录: '#f59e0b',
正在浏览: '#10b981',
浏览完成: '#3b82f6',
正在截图: '#06b6d4',
}
function statusColor(text) {
return statusColorMap[text] || '#6b7280'
}
const serverMemoryDisplay = computed(() => `${server.value.memory_used} / ${server.value.memory_total}`)
const serverDiskDisplay = computed(() => `${server.value.disk_used} / ${server.value.disk_total}`)
let stop = false
let timer = null
async function loadOnce() {
loading.value = true
try {
const [serverInfo, dockerInfo, taskStat, running] = await Promise.all([
fetchServerInfo(),
fetchDockerStats(),
fetchTaskStats(),
fetchRunningTasks(),
])
server.value = serverInfo || server.value
docker.value = dockerInfo || docker.value
taskStats.value = taskStat || taskStats.value
monitor.value = running || monitor.value
} catch {
// handled by interceptor
} finally {
loading.value = false
}
}
async function loop() {
if (stop) return
const start = Date.now()
await loadOnce()
if (stop) return
const elapsed = Date.now() - start
// server/info 正常会阻塞约 1s如果异常很快失败避免疯狂重试
timer = window.setTimeout(loop, elapsed < 900 ? 1000 : 0)
}
onMounted(() => {
stop = false
loop()
})
onBeforeUnmount(() => {
stop = true
if (timer) window.clearTimeout(timer)
})
</script>
<template>
<div class="page-stack" v-loading="loading">
<div class="app-page-title">
<h2>统计</h2>
<span class="app-muted">实时更新</span>
</div>
<el-row :gutter="12">
<el-col :xs="12" :sm="8" :md="6">
<el-card shadow="never" class="metric-card" :body-style="{ padding: '14px' }">
<div class="metric-label">CPU</div>
<div class="metric-value">{{ server.cpu_percent }}%</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="8" :md="6">
<el-card shadow="never" class="metric-card" :body-style="{ padding: '14px' }">
<div class="metric-label">内存</div>
<div class="metric-value">{{ serverMemoryDisplay }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="8" :md="6">
<el-card shadow="never" class="metric-card" :body-style="{ padding: '14px' }">
<div class="metric-label">磁盘</div>
<div class="metric-value">{{ serverDiskDisplay }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="8" :md="6">
<el-card shadow="never" class="metric-card" :body-style="{ padding: '14px' }">
<div class="metric-label">容器内存</div>
<div class="metric-value">{{ docker.memory_limit !== 'N/A' ? `${docker.memory_usage} / ${docker.memory_limit}` : docker.memory_usage }}</div>
<div v-if="docker.memory_percent !== 'N/A'" class="metric-sub app-muted">{{ docker.memory_percent }}</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :xs="24" :md="14">
<el-card shadow="never" class="card" :body-style="{ padding: '16px' }">
<div class="section-head">
<h3 class="section-title">实时监控</h3>
<span class="app-muted">最大并发{{ monitor.max_concurrent }}</span>
</div>
<el-row :gutter="12" class="count-row">
<el-col :span="8">
<el-card shadow="never" class="count-card ok" :body-style="{ padding: '12px' }">
<div class="count-value">{{ monitor.running_count }}</div>
<div class="count-label">运行中</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="count-card warn" :body-style="{ padding: '12px' }">
<div class="count-value">{{ monitor.queuing_count }}</div>
<div class="count-label">排队中</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="count-card" :body-style="{ padding: '12px' }">
<div class="count-value">{{ monitor.max_concurrent }}</div>
<div class="count-label">并发上限</div>
</el-card>
</el-col>
</el-row>
<div class="sub-title">运行中任务</div>
<div v-if="monitor.running.length === 0" class="empty app-muted">暂无运行中的任务</div>
<div v-else class="task-list">
<div v-for="t in monitor.running" :key="`r-${t.account_id}`" class="task-item">
<div class="task-left">
<div class="task-line">
<el-tag :type="(sourceMap[t.source] || sourceMap.manual).type" effect="light" size="small">
{{ (sourceMap[t.source] || sourceMap.manual).label }}
</el-tag>
<span class="task-user">{{ t.user_username }}</span>
<span class="app-muted"></span>
<span class="task-account">{{ t.username }}</span>
<el-tag effect="plain" size="small">{{ t.browse_type }}</el-tag>
</div>
<div class="task-line2">
<span class="dot" :style="{ background: statusColor(t.detail_status) }"></span>
<span class="task-status" :style="{ color: statusColor(t.detail_status) }">{{ t.detail_status }}</span>
<span v-if="t.progress_items || t.progress_attachments" class="app-muted"
>内容/附件{{ t.progress_items }} / {{ t.progress_attachments }}</span
>
</div>
</div>
<div class="task-right">{{ t.elapsed_display }}</div>
</div>
</div>
<div class="sub-title">排队中任务</div>
<div v-if="monitor.queuing.length === 0" class="empty app-muted">暂无排队中的任务</div>
<div v-else class="task-list">
<div v-for="t in monitor.queuing" :key="`q-${t.account_id}`" class="task-item queue">
<div class="task-left">
<div class="task-line">
<el-tag :type="(sourceMap[t.source] || sourceMap.manual).type" effect="light" size="small">
{{ (sourceMap[t.source] || sourceMap.manual).label }}
</el-tag>
<span class="task-user">{{ t.user_username }}</span>
<span class="app-muted"></span>
<span class="task-account">{{ t.username }}</span>
<el-tag effect="plain" size="small">{{ t.browse_type }}</el-tag>
</div>
<div class="task-line2">
<span class="dot" style="background: #f59e0b"></span>
<span class="task-status" style="color: #f59e0b">{{ t.detail_status || '等待资源' }}</span>
</div>
</div>
<div class="task-right warn">{{ t.elapsed_display }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="10">
<el-card shadow="never" class="card" :body-style="{ padding: '16px' }">
<div class="section-head">
<h3 class="section-title">任务统计</h3>
<span class="app-muted">运行{{ server.uptime }}</span>
</div>
<div class="stat-grid">
<div class="stat-box ok">
<div class="stat-name">成功任务</div>
<div class="stat-row">
<span class="stat-big">{{ taskStats.today.success_tasks }}</span>
<span class="app-muted">今日</span>
</div>
<div class="stat-row2 app-muted">累计{{ taskStats.total.success_tasks }}</div>
</div>
<div class="stat-box err">
<div class="stat-name">失败任务</div>
<div class="stat-row">
<span class="stat-big">{{ taskStats.today.failed_tasks }}</span>
<span class="app-muted">今日</span>
</div>
<div class="stat-row2 app-muted">累计{{ taskStats.total.failed_tasks }}</div>
</div>
<div class="stat-box info">
<div class="stat-name">浏览内容</div>
<div class="stat-row">
<span class="stat-big">{{ taskStats.today.total_items }}</span>
<span class="app-muted">今日</span>
</div>
<div class="stat-row2 app-muted">累计{{ taskStats.total.total_items }}</div>
</div>
<div class="stat-box info2">
<div class="stat-name">查看附件</div>
<div class="stat-row">
<span class="stat-big">{{ taskStats.today.total_attachments }}</span>
<span class="app-muted">今日</span>
</div>
<div class="stat-row2 app-muted">累计{{ taskStats.total.total_attachments }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.metric-card,
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.metric-label {
font-size: 12px;
color: var(--app-muted);
}
.metric-value {
margin-top: 6px;
font-size: 18px;
font-weight: 800;
}
.metric-sub {
margin-top: 4px;
font-size: 12px;
}
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.section-title {
margin: 0;
font-size: 14px;
font-weight: 800;
}
.count-row {
margin-bottom: 10px;
}
.count-card {
border-radius: 10px;
border: 1px solid var(--app-border);
}
.count-card.ok {
background: rgba(16, 185, 129, 0.08);
}
.count-card.warn {
background: rgba(245, 158, 11, 0.08);
}
.count-value {
font-size: 22px;
font-weight: 900;
line-height: 1.1;
}
.count-label {
margin-top: 4px;
font-size: 12px;
color: var(--app-muted);
}
.sub-title {
margin-top: 14px;
margin-bottom: 8px;
font-size: 13px;
font-weight: 800;
}
.empty {
padding: 10px 0;
}
.task-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.task-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
.task-item.queue {
background: rgba(245, 158, 11, 0.06);
}
.task-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.task-line2 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
font-size: 12px;
}
.task-user {
font-weight: 600;
}
.task-account {
font-weight: 700;
color: #2563eb;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
display: inline-block;
}
.task-status {
font-weight: 700;
}
.task-right {
font-size: 12px;
font-weight: 700;
color: #10b981;
white-space: nowrap;
}
.task-right.warn {
color: #f59e0b;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.stat-box {
border-radius: 12px;
border: 1px solid var(--app-border);
padding: 12px;
}
.stat-box.ok {
background: rgba(16, 185, 129, 0.08);
}
.stat-box.err {
background: rgba(239, 68, 68, 0.08);
}
.stat-box.info {
background: rgba(59, 130, 246, 0.08);
}
.stat-box.info2 {
background: rgba(6, 182, 212, 0.08);
}
.stat-name {
font-size: 12px;
font-weight: 800;
margin-bottom: 6px;
}
.stat-row {
display: flex;
align-items: baseline;
gap: 8px;
}
.stat-big {
font-size: 20px;
font-weight: 900;
}
.stat-row2 {
margin-top: 6px;
font-size: 12px;
}
</style>

View File

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

View File

@@ -4,9 +4,9 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import {
adminResetUserPassword,
approveUser,
deleteUser,
fetchAllUsers,
approveUser,
rejectUser,
removeUserVip,
setUserVip,
@@ -38,9 +38,8 @@ function vipLabel(user) {
}
function statusMeta(status) {
if (status === 'approved') return { label: '已通过', type: 'success' }
if (status === 'rejected') return { label: '已拒绝', type: 'danger' }
return { label: '待审核', type: 'warning' }
if (status === 'rejected') return { label: '禁用', type: 'danger' }
return { label: '正常', type: 'success' }
}
async function loadUsers() {
@@ -54,10 +53,14 @@ async function loadUsers() {
}
}
async function onApprove(row) {
async function refreshAll() {
await loadUsers()
}
async function onEnableUser(row) {
try {
await ElMessageBox.confirm(`确定通过用户「${row.username}的注册申请吗?`, '审核通过', {
confirmButtonText: '通过',
await ElMessageBox.confirm(`确定启用用户「${row.username}」吗?启用后用户可正常登录。`, '启用用户', {
confirmButtonText: '启用',
cancelButtonText: '取消',
type: 'success',
})
@@ -67,18 +70,18 @@ async function onApprove(row) {
try {
await approveUser(row.id)
ElMessage.success('用户审核通过')
ElMessage.success('用户已启用')
await loadUsers()
await refreshStats?.()
await refreshStats?.({ force: true })
} catch {
// handled by interceptor
}
}
async function onReject(row) {
async function onDisableUser(row) {
try {
await ElMessageBox.confirm(`确定拒绝用户「${row.username}的注册申请吗?`, '拒绝申请', {
confirmButtonText: '拒绝',
await ElMessageBox.confirm(`确定禁用用户「${row.username}」吗?禁用后用户将无法登录。`, '禁用用户', {
confirmButtonText: '禁用',
cancelButtonText: '取消',
type: 'warning',
})
@@ -88,9 +91,9 @@ async function onReject(row) {
try {
await rejectUser(row.id)
ElMessage.success('已拒绝用户')
ElMessage.success('用户已禁用')
await loadUsers()
await refreshStats?.()
await refreshStats?.({ force: true })
} catch {
// handled by interceptor
}
@@ -111,7 +114,7 @@ async function onDelete(row) {
await deleteUser(row.id)
ElMessage.success('用户已删除')
await loadUsers()
await refreshStats?.()
await refreshStats?.({ force: true })
} catch {
// handled by interceptor
}
@@ -133,7 +136,7 @@ async function onSetVip(row, days) {
const res = await setUserVip(row.id, days)
ElMessage.success(res?.message || 'VIP设置成功')
await loadUsers()
await refreshStats?.()
await refreshStats?.({ force: true })
} catch {
// handled by interceptor
}
@@ -154,7 +157,7 @@ async function onRemoveVip(row) {
const res = await removeUserVip(row.id)
ElMessage.success(res?.message || 'VIP已移除')
await loadUsers()
await refreshStats?.()
await refreshStats?.({ force: true })
} catch {
// handled by interceptor
}
@@ -200,16 +203,13 @@ async function onResetPassword(row) {
}
}
onMounted(loadUsers)
onMounted(refreshAll)
</script>
<template>
<div class="page-stack">
<div class="app-page-title">
<h2>用户</h2>
<div>
<el-button @click="loadUsers">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
@@ -239,17 +239,20 @@ onMounted(loadUsers)
<el-table-column label="时间" min-width="220">
<template #default="{ row }">
<div>{{ row.created_at }}</div>
<div v-if="row.approved_at" class="app-muted">审核: {{ row.approved_at }}</div>
<div v-if="row.vip_expire_time" class="app-muted">VIP到期: {{ row.vip_expire_time }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<div class="actions">
<template v-if="row.status === 'pending'">
<el-button type="success" size="small" @click="onApprove(row)">通过</el-button>
<el-button type="warning" size="small" @click="onReject(row)">拒绝</el-button>
</template>
<el-button
v-if="row.status === 'rejected'"
type="success"
size="small"
@click="onEnableUser(row)"
>启用</el-button>
<el-button v-else type="warning" size="small" @click="onDisableUser(row)">禁用</el-button>
<el-dropdown trigger="click">
<el-button size="small">VIP</el-button>
@@ -279,16 +282,34 @@ onMounted(loadUsers)
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
gap: 14px;
min-width: 0;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
background: var(--app-card-bg);
box-shadow: var(--app-shadow-soft);
}
.section-title {
margin: 0 0 12px;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.2px;
}
.help {
margin-top: 10px;
font-size: 12px;
}
.table-wrap {
overflow-x: auto;
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
.user-block {

View File

@@ -2,13 +2,13 @@ import { createRouter, createWebHashHistory } from 'vue-router'
import AdminLayout from '../layouts/AdminLayout.vue'
const PendingPage = () => import('../pages/PendingPage.vue')
const ReportPage = () => import('../pages/ReportPage.vue')
const UsersPage = () => import('../pages/UsersPage.vue')
const FeedbacksPage = () => import('../pages/FeedbacksPage.vue')
const StatsPage = () => import('../pages/StatsPage.vue')
const LogsPage = () => import('../pages/LogsPage.vue')
const AnnouncementsPage = () => import('../pages/AnnouncementsPage.vue')
const EmailPage = () => import('../pages/EmailPage.vue')
const SecurityPage = () => import('../pages/SecurityPage.vue')
const SystemPage = () => import('../pages/SystemPage.vue')
const SettingsPage = () => import('../pages/SettingsPage.vue')
@@ -17,14 +17,16 @@ const routes = [
path: '/',
component: AdminLayout,
children: [
{ path: '', redirect: '/pending' },
{ path: '/pending', name: 'pending', component: PendingPage },
{ path: '', redirect: '/reports' },
{ path: '/pending', redirect: '/reports' },
{ path: '/stats', redirect: '/reports' },
{ path: '/reports', name: 'reports', component: ReportPage },
{ path: '/users', name: 'users', component: UsersPage },
{ path: '/feedbacks', name: 'feedbacks', component: FeedbacksPage },
{ path: '/stats', name: 'stats', component: StatsPage },
{ path: '/logs', name: 'logs', component: LogsPage },
{ path: '/announcements', name: 'announcements', component: AnnouncementsPage },
{ path: '/email', name: 'email', component: EmailPage },
{ path: '/security', name: 'security', component: SecurityPage },
{ path: '/system', name: 'system', component: SystemPage },
{ path: '/settings', name: 'settings', component: SettingsPage },
],

View File

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

View File

@@ -2,9 +2,22 @@ export function parseSqliteDateTime(value) {
if (!value) return null
if (value instanceof Date) return value
const str = String(value)
let str = String(value).trim()
if (!str) return null
// "YYYY-MM-DD" -> "YYYY-MM-DDT00:00:00"
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) str = `${str}T00:00:00`
// "YYYY-MM-DD HH:mm:ss" -> "YYYY-MM-DDTHH:mm:ss"
const iso = str.includes('T') ? str : str.replace(' ', 'T')
let iso = str.includes('T') ? str : str.replace(' ', 'T')
// SQLite 可能带微秒Date 仅可靠支持到毫秒
iso = iso.replace(/\.(\d{3})\d+/, '.$1')
// 统一按北京时间解析(除非字符串本身已带时区)
const hasTimezone = /([zZ]|[+-]\d{2}:\d{2})$/.test(iso)
if (!hasTimezone) iso = `${iso}+08:00`
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return null
return date
@@ -14,4 +27,3 @@ export function formatDateTime(value) {
if (!value) return '-'
return String(value)
}

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
function normalizeRawSource(source) {
return String(source || '').trim()
}
function getBatchIdFromUserScheduledSource(raw) {
if (!raw.startsWith('user_scheduled')) return ''
if (!raw.includes(':')) return ''
return raw.split(':', 2)[1] || ''
}
export function getTaskSourceMeta(source) {
const raw = normalizeRawSource(source)
if (!raw || raw === 'manual') {
return { group: 'manual', label: '手动', type: 'success', tooltip: '' }
}
if (raw === 'scheduled') {
return { group: 'scheduled', label: '定时任务', type: 'primary', tooltip: '系统定时' }
}
if (raw.startsWith('user_scheduled')) {
const batchId = getBatchIdFromUserScheduledSource(raw)
const batchShort = String(batchId || '').replace(/^batch_/, '')
return {
group: 'scheduled',
label: '定时任务',
type: 'primary',
tooltip: batchShort ? `用户定时批次:${batchShort}` : '用户定时',
}
}
const manualTips = {
batch: '手动批量',
manual_screenshot: '手动截图',
immediate: '立即执行',
resumed: '断点恢复',
}
const tip = manualTips[raw] || raw
return { group: 'manual', label: '手动', type: 'success', tooltip: tip }
}

View File

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

View File

@@ -2,20 +2,140 @@
# -*- coding: utf-8 -*-
"""
API 浏览器 - 用纯 HTTP 请求实现浏览功能
Playwright 快 30-60 倍
传统浏览器自动化快 30-60 倍
"""
import requests
from bs4 import BeautifulSoup
import os
import re
import time
import atexit
import weakref
from typing import Optional, Callable
from dataclasses import dataclass
from urllib.parse import urlsplit
import threading
from app_config import get_config
import time as _time_module
_MODULE_START_TIME = _time_module.time()
_WARMUP_PERIOD_SECONDS = 60 # 启动后 60 秒内使用更长超时
_WARMUP_TIMEOUT_SECONDS = 15.0 # 预热期间的超时时间
BASE_URL = "https://postoa.aidunsoft.com"
# HTML解析缓存类
class HTMLParseCache:
"""HTML解析结果缓存"""
def __init__(self, ttl: int = 300, maxsize: int = 1000):
self.cache = {}
self.ttl = ttl
self.maxsize = maxsize
self._access_times = {}
self._lock = threading.RLock()
def _make_key(self, url: str, content_hash: str) -> str:
return f"{url}:{content_hash}"
def get(self, key: str) -> Optional[tuple]:
"""获取缓存,如果存在且未过期"""
with self._lock:
if key in self.cache:
value, timestamp = self.cache[key]
if time.time() - timestamp < self.ttl:
self._access_times[key] = time.time()
return value
else:
# 过期删除
del self.cache[key]
del self._access_times[key]
return None
def set(self, key: str, value: tuple):
"""设置缓存"""
with self._lock:
# 如果缓存已满,删除最久未访问的项
if len(self.cache) >= self.maxsize:
if self._access_times:
# 使用简单的LRU策略删除最久未访问的项
oldest_key = None
oldest_time = float("inf")
for key, access_time in self._access_times.items():
if access_time < oldest_time:
oldest_time = access_time
oldest_key = key
if oldest_key:
del self.cache[oldest_key]
del self._access_times[oldest_key]
self.cache[key] = (value, time.time())
self._access_times[key] = time.time()
def clear(self):
"""清空缓存"""
with self._lock:
self.cache.clear()
self._access_times.clear()
def get_lru_key(self) -> Optional[str]:
"""获取最久未访问的键"""
if not self._access_times:
return None
return min(self._access_times.keys(), key=lambda k: self._access_times[k])
config = get_config()
BASE_URL = getattr(config, "ZSGL_BASE_URL", "https://postoa.aidunsoft.com")
LOGIN_URL = getattr(config, "ZSGL_LOGIN_URL", f"{BASE_URL}/admin/login.aspx")
INDEX_URL_PATTERN = getattr(config, "ZSGL_INDEX_URL_PATTERN", "index.aspx")
COOKIES_DIR = getattr(config, "COOKIES_DIR", "data/cookies")
try:
_API_REQUEST_TIMEOUT_SECONDS = float(
os.environ.get("API_REQUEST_TIMEOUT_SECONDS") or os.environ.get("API_REQUEST_TIMEOUT") or "5"
)
except Exception:
_API_REQUEST_TIMEOUT_SECONDS = 5.0
_API_REQUEST_TIMEOUT_SECONDS = max(3.0, _API_REQUEST_TIMEOUT_SECONDS)
_API_DIAGNOSTIC_LOG = str(os.environ.get("API_DIAGNOSTIC_LOG", "")).strip().lower() in ("1", "true", "yes", "on")
try:
_API_DIAGNOSTIC_SLOW_MS = int(os.environ.get("API_DIAGNOSTIC_SLOW_MS", "0") or "0")
except Exception:
_API_DIAGNOSTIC_SLOW_MS = 0
_API_DIAGNOSTIC_SLOW_MS = max(0, _API_DIAGNOSTIC_SLOW_MS)
_cookie_domain_fallback = urlsplit(BASE_URL).hostname or "postoa.aidunsoft.com"
_COOKIE_JAR_MAX_AGE_SECONDS = 24 * 60 * 60
def get_cookie_jar_path(username: str) -> str:
"""获取截图用的 cookies 文件路径Netscape Cookie 格式)"""
import hashlib
os.makedirs(COOKIES_DIR, mode=0o700, exist_ok=True)
try:
os.chmod(COOKIES_DIR, 0o700)
except Exception:
pass
filename = hashlib.sha256(username.encode()).hexdigest()[:32] + ".cookies.txt"
return os.path.join(COOKIES_DIR, filename)
def is_cookie_jar_fresh(cookie_path: str, max_age_seconds: int = _COOKIE_JAR_MAX_AGE_SECONDS) -> bool:
"""判断 cookies 文件是否存在且未过期"""
if not cookie_path or not os.path.exists(cookie_path):
return False
try:
file_age = time.time() - os.path.getmtime(cookie_path)
return file_age <= max(0, int(max_age_seconds or 0))
except Exception:
return False
_api_browser_instances: "weakref.WeakSet[APIBrowser]" = weakref.WeakSet()
@@ -35,6 +155,7 @@ atexit.register(_cleanup_api_browser_instances)
@dataclass
class APIBrowseResult:
"""API 浏览结果"""
success: bool
total_items: int = 0
total_attachments: int = 0
@@ -46,104 +167,164 @@ class APIBrowser:
def __init__(self, log_callback: Optional[Callable] = None, proxy_config: Optional[dict] = None):
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
})
self.session.headers.update(
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}
)
self.logged_in = False
self.log_callback = log_callback
self.stop_flag = False
self._closed = False # 防止重复关闭
self.last_total_records = 0
# 初始化HTML解析缓存
self._parse_cache = HTMLParseCache(ttl=300, maxsize=500) # 5分钟缓存最多500条记录
# 设置代理
if proxy_config and proxy_config.get("server"):
proxy_server = proxy_config["server"]
self.session.proxies = {
"http": proxy_server,
"https": proxy_server
}
self.session.proxies = {"http": proxy_server, "https": proxy_server}
self.proxy_server = proxy_server
else:
self.proxy_server = None
_api_browser_instances.add(self)
def _calculate_adaptive_delay(self, iteration: int, consecutive_failures: int) -> float:
"""
智能延迟计算:文章处理延迟
根据迭代次数和连续失败次数动态调整延迟
"""
# 基础延迟,显著降低
base_delay = 0.03
# 如果有连续失败,增加延迟但有上限
if consecutive_failures > 0:
delay = base_delay * (1.5 ** min(consecutive_failures, 3))
return min(delay, 0.2) # 最多200ms
# 根据处理进度调整延迟,开始时较慢,后来可以更快
progress_factor = min(iteration / 100.0, 1.0) # 100个文章后达到最大优化
optimized_delay = base_delay * (1.2 - 0.4 * progress_factor) # 从120%逐渐降低到80%
return max(optimized_delay, 0.02) # 最少20ms
def _calculate_page_delay(self, current_page: int, new_articles_in_page: int) -> float:
"""
智能延迟计算:页面处理延迟
根据页面位置和新文章数量调整延迟
"""
base_delay = 0.08 # 基础延迟降低50%
# 如果当前页有大量新文章,可以稍微增加延迟
if new_articles_in_page > 10:
return base_delay * 1.2
# 如果是新页面,降低延迟(内容可能需要加载)
if current_page <= 3:
return base_delay * 1.1
# 后续页面可以更快
return base_delay * 0.8
def log(self, message: str):
"""记录日志"""
if self.log_callback:
self.log_callback(message)
def save_cookies_for_playwright(self, username: str):
"""保存cookies供Playwright使用"""
import os
import json
import hashlib
cookies_dir = '/app/data/cookies'
os.makedirs(cookies_dir, exist_ok=True)
# 安全修复使用SHA256代替MD5作为文件名哈希
filename = hashlib.sha256(username.encode()).hexdigest()[:32] + '.json'
cookies_path = os.path.join(cookies_dir, filename)
def save_cookies_for_screenshot(self, username: str):
"""保存 cookies 供 wkhtmltoimage 使用Netscape Cookie 格式)"""
cookies_path = get_cookie_jar_path(username)
try:
# 获取requests session的cookies
cookies_list = []
lines = [
"# Netscape HTTP Cookie File",
"# This file was generated by zsglpt",
]
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)
domain = cookie.domain or _cookie_domain_fallback
include_subdomains = "TRUE" if domain.startswith(".") else "FALSE"
path = cookie.path or "/"
secure = "TRUE" if getattr(cookie, "secure", False) else "FALSE"
expires = int(getattr(cookie, "expires", 0) or 0)
lines.append(
"\t".join(
[
domain,
include_subdomains,
path,
secure,
str(expires),
cookie.name,
cookie.value,
]
)
)
with open(cookies_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
try:
os.chmod(cookies_path, 0o600)
except Exception:
pass
self.log(f"[API] Cookies已保存供截图使用")
return True
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)
# 启动后 60 秒内使用更长超时15秒之后使用配置的超时
if (_time_module.time() - _MODULE_START_TIME) < _WARMUP_PERIOD_SECONDS:
kwargs.setdefault("timeout", _WARMUP_TIMEOUT_SECONDS)
else:
kwargs.setdefault("timeout", _API_REQUEST_TIMEOUT_SECONDS)
last_error = None
timeout_value = kwargs.get("timeout")
diag_enabled = _API_DIAGNOSTIC_LOG
slow_ms = _API_DIAGNOSTIC_SLOW_MS
for attempt in range(1, max_retries + 1):
start_ts = _time_module.time()
try:
if method.lower() == 'get':
if method.lower() == "get":
resp = self.session.get(url, **kwargs)
else:
resp = self.session.post(url, **kwargs)
if diag_enabled:
elapsed_ms = int((_time_module.time() - start_ts) * 1000)
if slow_ms <= 0 or elapsed_ms >= slow_ms:
self.log(
f"[API][trace] {method.upper()} {url} ok status={resp.status_code} elapsed_ms={elapsed_ms} timeout={timeout_value} attempt={attempt}/{max_retries}"
)
return resp
except Exception as e:
last_error = e
if diag_enabled:
elapsed_ms = int((_time_module.time() - start_ts) * 1000)
self.log(
f"[API][trace] {method.upper()} {url} err={type(e).__name__} elapsed_ms={elapsed_ms} timeout={timeout_value} attempt={attempt}/{max_retries}"
)
if attempt < max_retries:
self.log(f"[API] 请求超时,{retry_delay}秒后重试 ({attempt}/{max_retries})...")
import time
time.sleep(retry_delay)
else:
self.log(f"[API] 请求失败,已重试{max_retries}次: {str(e)}")
raise last_error
def _get_aspnet_fields(self, soup):
"""获取 ASP.NET 隐藏字段"""
fields = {}
for name in ['__VIEWSTATE', '__VIEWSTATEGENERATOR', '__EVENTVALIDATION']:
field = soup.find('input', {'name': name})
for name in ["__VIEWSTATE", "__VIEWSTATEGENERATOR", "__EVENTVALIDATION"]:
field = soup.find("input", {"name": name})
if field:
fields[name] = field.get('value', '')
fields[name] = field.get("value", "")
return fields
def get_real_name(self) -> Optional[str]:
@@ -157,18 +338,18 @@ class APIBrowser:
try:
url = f"{BASE_URL}/admin/center.aspx"
resp = self._request_with_retry('get', url)
soup = BeautifulSoup(resp.text, 'html.parser')
resp = self._request_with_retry("get", url)
soup = BeautifulSoup(resp.text, "html.parser")
# 查找包含"姓名:"的元素
# 页面格式: <li><p>姓名:喻勇祥(19174616018) 人力资源编码: ...</p></li>
nlist = soup.find('div', {'class': 'nlist-5'})
nlist = soup.find("div", {"class": "nlist-5"})
if nlist:
first_li = nlist.find('li')
first_li = nlist.find("li")
if first_li:
text = first_li.get_text()
# 解析姓名:格式为 "姓名XXX(手机号)"
match = re.search(r'姓名[:]\s*([^\(]+)', text)
match = re.search(r"姓名[:]\s*([^\(]+)", text)
if match:
real_name = match.group(1).strip()
if real_name:
@@ -182,37 +363,36 @@ class APIBrowser:
self.log(f"[API] 登录: {username}")
try:
login_url = f"{BASE_URL}/admin/login.aspx"
resp = self._request_with_retry('get', login_url)
resp = self._request_with_retry("get", LOGIN_URL)
soup = BeautifulSoup(resp.text, 'html.parser')
soup = BeautifulSoup(resp.text, "html.parser")
fields = self._get_aspnet_fields(soup)
data = fields.copy()
data['txtUserName'] = username
data['txtPassword'] = password
data['btnSubmit'] = '登 录'
data["txtUserName"] = username
data["txtPassword"] = password
data["btnSubmit"] = "登 录"
resp = self._request_with_retry(
'post',
login_url,
"post",
LOGIN_URL,
data=data,
headers={
'Content-Type': 'application/x-www-form-urlencoded',
'Origin': BASE_URL,
'Referer': login_url,
"Content-Type": "application/x-www-form-urlencoded",
"Origin": BASE_URL,
"Referer": LOGIN_URL,
},
allow_redirects=True
allow_redirects=True,
)
if 'index.aspx' in resp.url:
if INDEX_URL_PATTERN 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 '未知错误'
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
@@ -225,104 +405,145 @@ class APIBrowser:
if not self.logged_in:
return [], 0, None
if base_url and page > 1:
url = re.sub(r"page=\d+", f"page={page}", base_url)
elif page > 1:
# 兼容兜底:若没有 next_url极少数情况下页面不提供“下一页”链接尝试直接拼 page 参数
url = f"{BASE_URL}/admin/center.aspx?bz={bz}&page={page}"
else:
url = f"{BASE_URL}/admin/center.aspx?bz={bz}"
resp = self._request_with_retry("get", url)
soup = BeautifulSoup(resp.text, "html.parser")
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
total_records = 0
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}"
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
self.last_total_records = int(total_records or 0)
except Exception:
self.last_total_records = 0
return articles, total_pages, next_page_url
def get_article_attachments(self, article_href: str):
"""获取文章的附件列表"""
"""获取文章的附件列表和文章信息"""
if not article_href.startswith("http"):
url = f"{BASE_URL}/admin/{article_href}"
else:
url = article_href
# 先检查缓存,避免不必要的请求
# 使用URL作为缓存键简化版本
cache_key = f"attachments_{hash(url)}"
cached_result = self._parse_cache.get(cache_key)
if cached_result:
return cached_result
resp = self._request_with_retry("get", url)
soup = BeautifulSoup(resp.text, "html.parser")
attachments = []
article_info = {"channel_id": None, "article_id": None}
# 从 saveread 按钮获取 channel_id 和 article_id
for elem in soup.find_all(["button", "input"]):
onclick = elem.get("onclick", "")
match = re.search(r"saveread\((\d+),(\d+)\)", onclick)
if match:
article_info["channel_id"] = match.group(1)
article_info["article_id"] = match.group(2)
break
attach_list = soup.find("div", {"class": "attach-list2"})
if attach_list:
items = attach_list.find_all("li")
for item in items:
download_links = item.find_all("a", onclick=re.compile(r"download2?\.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
result = (attachments, article_info)
# 存入缓存
self._parse_cache.set(cache_key, result)
return result
def mark_article_read(self, channel_id: str, article_id: str) -> bool:
"""通过 saveread API 标记文章已读"""
if not channel_id or not article_id:
return False
import random
saveread_url = (
f"{BASE_URL}/tools/submit_ajax.ashx?action=saveread&time={random.random()}&fl={channel_id}&id={article_id}"
)
try:
if not article_href.startswith('http'):
url = f"{BASE_URL}/admin/{article_href}"
else:
url = article_href
resp = self._request_with_retry("post", saveread_url)
# 检查响应是否成功
if resp.status_code == 200:
try:
data = resp.json()
return data.get("status") == 1
except:
return True # 如果不是 JSON 但状态码 200也认为成功
return False
except:
return False
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}"
def mark_read(self, attach_id: str, channel_id: str = "1") -> bool:
"""通过访问预览通道标记附件已读"""
download_url = f"{BASE_URL}/tools/download2.ashx?site=main&id={attach_id}&channel_id={channel_id}"
try:
resp = self._request_with_retry("get", download_url, stream=True)
@@ -331,14 +552,19 @@ class APIBrowser:
except:
return False
def browse_content(self, browse_type: str,
should_stop_callback: Optional[Callable] = None) -> APIBrowseResult:
def browse_content(
self,
browse_type: str,
should_stop_callback: Optional[Callable] = None,
progress_callback: Optional[Callable] = None,
) -> APIBrowseResult:
"""
浏览内容并标记已读
Args:
browse_type: 浏览类型 (应读/注册前未读)
should_stop_callback: 检查是否应该停止的回调函数
progress_callback: 进度回调(可选),用于实时上报已浏览内容数量
Returns:
浏览结果
@@ -350,76 +576,150 @@ class APIBrowser:
return result
# 根据浏览类型确定 bz 参数
# 网页实际选项: 0=注册前未读, 1=已读, 2=应读
# 前端选项: 注册前未读, 应读, 未读, 已读
if '注册前' in browse_type:
bz = 0 # 注册前未读
elif browse_type == '已读':
bz = 1 # 已读
# 网站更新后参数: 0=应读, 1=已读(注册前未读需通过页面交互切换)
# 当前前端选项: 注册前未读、应读(默认应读)
browse_type_text = str(browse_type or "")
if "注册前" in browse_type_text:
bz = 0 # 注册前未读(暂与应读相同,网站通过页面状态区分)
else:
bz = 2 # 应读、未读 都映射到 bz=2
bz = 0 # 应读
self.log(f"[API] 开始浏览 '{browse_type}' (bz={bz})...")
try:
total_items = 0
total_attachments = 0
page = 1
base_url = None
skipped_items = 0
consecutive_failures = 0
max_consecutive_failures = 3
# 获取第一页
articles, total_pages, next_url = self.get_article_list_page(bz, page)
# 获取第一页,了解总记录数
try:
articles, total_pages, _ = self.get_article_list_page(bz, 1)
consecutive_failures = 0
except Exception as e:
result.error_message = str(e)
self.log(f"[API] 获取第1页列表失败: {str(e)}")
return result
if not articles:
self.log(f"[API] '{browse_type}' 没有待处理内容")
result.success = True
return result
self.log(f"[API] 共 {total_pages} 页,开始处理...")
total_records = int(getattr(self, "last_total_records", 0) or 0)
self.log(f"[API] 共 {total_records} 条记录,开始处理...")
if next_url:
base_url = next_url
last_report_ts = 0.0
def report_progress(force: bool = False):
nonlocal last_report_ts
if not progress_callback:
return
now_ts = time.time()
if not force and now_ts - last_report_ts < 1.0:
return
last_report_ts = now_ts
try:
progress_callback({"total_items": total_records, "browsed_items": total_items})
except Exception:
pass
report_progress(force=True)
# 循环处理:遍历所有页面,跟踪已处理文章防止重复
max_iterations = total_records + 20 # 防止无限循环
iteration = 0
processed_hrefs = set() # 跟踪已处理的文章,防止重复处理
current_page = 1
while articles and iteration < max_iterations:
iteration += 1
# 处理所有页面
while True:
if should_stop_callback and should_stop_callback():
self.log("[API] 收到停止信号")
break
new_articles_in_page = 0 # 本次迭代中新处理的文章数
for article in articles:
if should_stop_callback and should_stop_callback():
break
title = article['title'][:30]
article_href = article["href"]
# 跳过已处理的文章
if article_href in processed_hrefs:
continue
processed_hrefs.add(article_href)
new_articles_in_page += 1
title = article["title"][:30]
# 获取附件和文章信息(文章详情页)
try:
attachments, article_info = self.get_article_attachments(article_href)
consecutive_failures = 0
except Exception as e:
skipped_items += 1
consecutive_failures += 1
self.log(
f"[API] 获取文章失败,跳过(连续失败{consecutive_failures}/{max_consecutive_failures}: {title} | {str(e)}"
)
if consecutive_failures >= max_consecutive_failures:
raise
continue
total_items += 1
report_progress()
# 获取附件
attachments = self.get_article_attachments(article['href'])
# 标记文章已读(调用 saveread API
article_marked = False
if article_info.get("channel_id") and article_info.get("article_id"):
article_marked = self.mark_article_read(article_info["channel_id"], article_info["article_id"])
# 处理附件(如果有)
if attachments:
for attach in attachments:
if self.mark_read(attach['id'], attach['channel_id']):
if self.mark_read(attach["id"], attach["channel_id"]):
total_attachments += 1
self.log(f"[API] [{total_items}] {title} - {len(attachments)}个附件")
else:
# 没有附件的文章,只记录标记状态
status = "已标记" if article_marked else "标记失败"
self.log(f"[API] [{total_items}] {title} - 无附件({status})")
time.sleep(0.1)
# 智能延迟策略:根据连续失败次数和文章数量动态调整
time.sleep(self._calculate_adaptive_delay(total_items, consecutive_failures))
# 下一页
page += 1
if page > total_pages:
time.sleep(self._calculate_page_delay(current_page, new_articles_in_page))
# 决定下一步获取哪一页
if new_articles_in_page > 0:
# 有新文章被处理重新获取第1页因为已读文章会从列表消失页面会上移
current_page = 1
else:
# 当前页没有新文章,尝试下一页
current_page += 1
if current_page > total_pages:
self.log(f"[API] 已遍历所有 {total_pages} 页,结束循环")
break
try:
articles, new_total_pages, _ = self.get_article_list_page(bz, current_page)
if new_total_pages > 0:
total_pages = new_total_pages
except Exception as e:
self.log(f"[API] 获取第{current_page}页列表失败: {str(e)}")
break
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} 个附件")
report_progress(force=True)
if skipped_items:
self.log(
f"[API] 浏览完成: {total_items} 条内容,{total_attachments} 个附件(跳过 {skipped_items} 条内容)"
)
else:
self.log(f"[API] 浏览完成: {total_items} 条内容,{total_attachments} 个附件")
result.success = True
result.total_items = total_items
@@ -455,3 +755,28 @@ class APIBrowser:
"""Context manager支持 - 退出"""
self.close()
return False # 不抑制异常
def warmup_api_connection(proxy_config: Optional[dict] = None, log_callback: Optional[Callable] = None):
"""预热 API 连接 - 建立 TCP/TLS 连接池"""
def log(msg: str):
if log_callback:
log_callback(msg)
else:
print(f"[API预热] {msg}")
log("正在预热 API 连接...")
try:
session = requests.Session()
if proxy_config and proxy_config.get("server"):
session.proxies = {"http": proxy_config["server"], "https": proxy_config["server"]}
# 发送一个轻量级请求建立连接
resp = session.get(f"{BASE_URL}/admin/login.aspx", timeout=10, allow_redirects=False)
log(f"[OK] API 连接预热完成 (status={resp.status_code})")
session.close()
return True
except Exception as e:
log(f"API 连接预热失败: {e}")
return False

5
app-frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
dist
node_modules
.DS_Store
.vite

4
app-frontend/README.md Normal file
View File

@@ -0,0 +1,4 @@
# app-frontend
前台用户端Vue3 + Vite 工程,构建产物输出到 `static/app/`

14
app-frontend/index.html Normal file
View File

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

13
app-frontend/login.html Normal file
View File

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

2466
app-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
app-frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "app-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.12.2",
"element-plus": "^2.11.3",
"pinia": "^3.0.3",
"socket.io-client": "^4.8.1",
"vue": "^3.5.24",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0",
"vite": "^7.2.4"
}
}

6
app-frontend/src/App.vue Normal file
View File

@@ -0,0 +1,6 @@
<script setup></script>
<template>
<RouterView />
</template>

View File

@@ -0,0 +1,57 @@
import { publicApi } from './http'
export async function fetchAccounts(params = {}) {
const { data } = await publicApi.get('/accounts', { params })
return data
}
export async function addAccount(payload) {
const { data } = await publicApi.post('/accounts', payload)
return data
}
export async function updateAccount(accountId, payload) {
const { data } = await publicApi.put(`/accounts/${accountId}`, payload)
return data
}
export async function deleteAccount(accountId) {
const { data } = await publicApi.delete(`/accounts/${accountId}`)
return data
}
export async function updateAccountRemark(accountId, payload) {
const { data } = await publicApi.put(`/accounts/${accountId}/remark`, payload)
return data
}
export async function startAccount(accountId, payload) {
const { data } = await publicApi.post(`/accounts/${accountId}/start`, payload)
return data
}
export async function stopAccount(accountId) {
const { data } = await publicApi.post(`/accounts/${accountId}/stop`, {})
return data
}
export async function batchStartAccounts(payload) {
const { data } = await publicApi.post('/accounts/batch/start', payload)
return data
}
export async function batchStopAccounts(payload) {
const { data } = await publicApi.post('/accounts/batch/stop', payload)
return data
}
export async function clearAccounts() {
const { data } = await publicApi.post('/accounts/clear', {})
return data
}
export async function takeScreenshot(accountId, payload = {}) {
const { data } = await publicApi.post(`/accounts/${accountId}/screenshot`, payload)
return data
}

View File

@@ -0,0 +1,12 @@
import { publicApi } from './http'
export async function fetchActiveAnnouncement() {
const { data } = await publicApi.get('/announcements/active')
return data
}
export async function dismissAnnouncement(announcementId) {
const { data } = await publicApi.post(`/announcements/${announcementId}/dismiss`, {})
return data
}

View File

@@ -0,0 +1,46 @@
import { publicApi } from './http'
export async function fetchEmailVerifyStatus() {
const { data } = await publicApi.get('/email/verify-status')
return data
}
export async function generateCaptcha() {
const { data } = await publicApi.post('/generate_captcha', {})
return data
}
export async function login(payload) {
const { data } = await publicApi.post('/login', payload)
return data
}
export async function passkeyLoginOptions(payload) {
const { data } = await publicApi.post('/passkeys/login/options', payload)
return data
}
export async function passkeyLoginVerify(payload) {
const { data } = await publicApi.post('/passkeys/login/verify', payload)
return data
}
export async function register(payload) {
const { data } = await publicApi.post('/register', payload)
return data
}
export async function resendVerifyEmail(payload) {
const { data } = await publicApi.post('/resend-verify-email', payload)
return data
}
export async function forgotPassword(payload) {
const { data } = await publicApi.post('/forgot-password', payload)
return data
}
export async function confirmPasswordReset(payload) {
const { data } = await publicApi.post('/reset-password-confirm', payload)
return data
}

View File

@@ -0,0 +1,12 @@
import { publicApi } from './http'
export async function submitFeedback(payload) {
const { data } = await publicApi.post('/feedback', payload)
return data
}
export async function fetchMyFeedbacks() {
const { data } = await publicApi.get('/feedback')
return data
}

View File

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

View File

@@ -0,0 +1,41 @@
import { publicApi } from './http'
export async function fetchSchedules(params = {}) {
const { data } = await publicApi.get('/schedules', { params })
return data
}
export async function createSchedule(payload) {
const { data } = await publicApi.post('/schedules', payload)
return data
}
export async function updateSchedule(scheduleId, payload) {
const { data } = await publicApi.put(`/schedules/${scheduleId}`, payload)
return data
}
export async function deleteSchedule(scheduleId) {
const { data } = await publicApi.delete(`/schedules/${scheduleId}`)
return data
}
export async function toggleSchedule(scheduleId, payload) {
const { data } = await publicApi.post(`/schedules/${scheduleId}/toggle`, payload)
return data
}
export async function runScheduleNow(scheduleId) {
const { data } = await publicApi.post(`/schedules/${scheduleId}/run`, {})
return data
}
export async function fetchScheduleLogs(scheduleId, params = {}) {
const { data } = await publicApi.get(`/schedules/${scheduleId}/logs`, { params })
return data
}
export async function clearScheduleLogs(scheduleId) {
const { data } = await publicApi.delete(`/schedules/${scheduleId}/logs`)
return data
}

View File

@@ -0,0 +1,16 @@
import { publicApi } from './http'
export async function fetchScreenshots(params = {}) {
const { data } = await publicApi.get('/screenshots', { params })
return data
}
export async function deleteScreenshot(filename) {
const { data } = await publicApi.delete(`/screenshots/${encodeURIComponent(filename)}`)
return data
}
export async function clearScreenshots() {
const { data } = await publicApi.post('/screenshots/clear', {})
return data
}

View File

@@ -0,0 +1,71 @@
import { publicApi } from './http'
export async function fetchUserEmail() {
const { data } = await publicApi.get('/user/email')
return data
}
export async function bindEmail(payload) {
const { data } = await publicApi.post('/user/bind-email', payload)
return data
}
export async function unbindEmail() {
const { data } = await publicApi.post('/user/unbind-email', {})
return data
}
export async function fetchEmailNotify() {
const { data } = await publicApi.get('/user/email-notify')
return data
}
export async function updateEmailNotify(payload) {
const { data } = await publicApi.post('/user/email-notify', payload)
return data
}
export async function changePassword(payload) {
const { data } = await publicApi.post('/user/password', payload)
return data
}
export async function fetchKdocsSettings() {
const { data } = await publicApi.get('/user/kdocs')
return data
}
export async function updateKdocsSettings(payload) {
const { data } = await publicApi.post('/user/kdocs', payload)
return data
}
export async function fetchKdocsStatus() {
const { data } = await publicApi.get('/kdocs/status')
return data
}
export async function fetchUserPasskeys() {
const { data } = await publicApi.get('/user/passkeys')
return data
}
export async function createUserPasskeyOptions(payload) {
const { data } = await publicApi.post('/user/passkeys/register/options', payload)
return data
}
export async function createUserPasskeyVerify(payload) {
const { data } = await publicApi.post('/user/passkeys/register/verify', payload)
return data
}
export async function deleteUserPasskey(passkeyId) {
const { data } = await publicApi.delete(`/user/passkeys/${passkeyId}`)
return data
}
export async function reportUserPasskeyClientError(payload) {
const { data } = await publicApi.post('/user/passkeys/client-error', payload || {})
return data
}

View File

@@ -0,0 +1,7 @@
import { publicApi } from './http'
export async function fetchRunStats() {
const { data } = await publicApi.get('/run_stats')
return data
}

View File

@@ -0,0 +1,12 @@
import { publicApi } from './http'
export async function fetchVipInfo() {
const { data } = await publicApi.get('/user/vip')
return data
}
export async function logout() {
const { data } = await publicApi.post('/logout', {})
return data
}

View File

@@ -0,0 +1,15 @@
import { io } from 'socket.io-client'
let socketInstance = null
export function useSocket() {
if (socketInstance) return socketInstance
socketInstance = io({
transports: ['websocket', 'polling'],
withCredentials: true,
})
return socketInstance
}

File diff suppressed because it is too large Load Diff

View File

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

10
app-frontend/src/main.js Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import './style.css'
createApp(App).use(createPinia()).use(router).mount('#app')

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,945 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
const form = reactive({
username: '',
password: '',
captcha: '',
})
const needCaptcha = ref(false)
const captchaImage = ref('')
const captchaSession = ref('')
const loading = ref(false)
const passkeyLoading = ref(false)
const emailEnabled = ref(false)
const registerVerifyEnabled = ref(false)
const noticeType = ref('')
const noticeText = ref('')
const forgotOpen = ref(false)
const resendOpen = ref(false)
const forgotForm = reactive({
username: '',
captcha: '',
})
const forgotCaptchaImage = ref('')
const forgotCaptchaSession = ref('')
const forgotLoading = ref(false)
const forgotHint = ref('')
const forgotError = ref('')
const resendForm = reactive({
email: '',
captcha: '',
})
const resendCaptchaImage = ref('')
const resendCaptchaSession = ref('')
const resendLoading = ref(false)
const resendError = ref('')
const showResendLink = computed(() => true)
const verifyStatusLoaded = ref(false)
function getCookie(name) {
const escaped = String(name || '').replace(/([.*+?^${}()|[\]\\])/g, '\\$1')
const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`))
return match ? decodeURIComponent(match[1]) : ''
}
class ApiError extends Error {
constructor(message, status, data) {
super(message || '请求失败')
this.name = 'ApiError'
this.response = {
status: Number(status || 0),
data: data || {},
}
}
}
async function apiRequest(path, options = {}) {
const method = String(options.method || 'GET').toUpperCase()
const headers = {
...(options.headers || {}),
}
const hasBody = Object.prototype.hasOwnProperty.call(options, 'body')
if (hasBody && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json'
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
const token = getCookie('csrf_token')
if (token) {
headers['X-CSRF-Token'] = token
}
}
const response = await fetch(`/api${path}`, {
method,
headers,
credentials: 'include',
body: hasBody ? JSON.stringify(options.body ?? {}) : undefined,
})
let data = {}
try {
data = await response.json()
} catch {
data = {}
}
if (!response.ok) {
throw new ApiError(data?.error || data?.message || `请求失败 (${response.status})`, response.status, data)
}
return data
}
const fetchEmailVerifyStatus = () => apiRequest('/email/verify-status')
const generateCaptcha = () => apiRequest('/generate_captcha', { method: 'POST', body: {} })
const loginRequest = (payload) => apiRequest('/login', { method: 'POST', body: payload || {} })
const passkeyLoginOptions = (payload) => apiRequest('/passkeys/login/options', { method: 'POST', body: payload || {} })
const passkeyLoginVerify = (payload) => apiRequest('/passkeys/login/verify', { method: 'POST', body: payload || {} })
const resendVerifyEmail = (payload) => apiRequest('/resend-verify-email', { method: 'POST', body: payload || {} })
const forgotPassword = (payload) => apiRequest('/forgot-password', { method: 'POST', body: payload || {} })
function base64UrlToUint8Array(base64url) {
const value = String(base64url || '')
const padding = '='.repeat((4 - (value.length % 4)) % 4)
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = window.atob(base64)
const bytes = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i += 1) {
bytes[i] = raw.charCodeAt(i)
}
return bytes
}
function uint8ArrayToBase64Url(input) {
const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input || [])
let binary = ''
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i])
}
return window
.btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '')
}
function normalizePublicKeyOptions(options) {
if (!options || typeof options !== 'object') {
throw new Error('Passkey参数无效')
}
return options.publicKey && typeof options.publicKey === 'object' ? options.publicKey : options
}
function toRequestOptions(rawOptions) {
const options = normalizePublicKeyOptions(rawOptions)
const normalized = {
...options,
challenge: base64UrlToUint8Array(options.challenge),
}
if (Array.isArray(options.allowCredentials)) {
normalized.allowCredentials = options.allowCredentials.map((item) => ({
...item,
id: base64UrlToUint8Array(item.id),
}))
}
return normalized
}
function serializeCredential(credential) {
const response = credential?.response || {}
const output = {
id: credential?.id,
rawId: uint8ArrayToBase64Url(credential?.rawId),
type: credential?.type,
authenticatorAttachment: credential?.authenticatorAttachment || undefined,
response: {},
}
if (response.clientDataJSON) output.response.clientDataJSON = uint8ArrayToBase64Url(response.clientDataJSON)
if (response.authenticatorData) output.response.authenticatorData = uint8ArrayToBase64Url(response.authenticatorData)
if (response.signature) output.response.signature = uint8ArrayToBase64Url(response.signature)
if (response.userHandle) {
output.response.userHandle = uint8ArrayToBase64Url(response.userHandle)
} else {
output.response.userHandle = null
}
return output
}
function isPasskeyAvailable() {
return typeof window !== 'undefined' && window.isSecureContext && !!window.PublicKeyCredential && !!navigator.credentials
}
async function authenticateWithPasskey(rawOptions) {
const publicKey = toRequestOptions(rawOptions)
const credential = await navigator.credentials.get({ publicKey })
return serializeCredential(credential)
}
async function loadVerifyStatus() {
if (verifyStatusLoaded.value) return
try {
const status = await fetchEmailVerifyStatus()
emailEnabled.value = Boolean(status?.email_enabled)
registerVerifyEnabled.value = Boolean(status?.register_verify_enabled)
} catch {
emailEnabled.value = false
registerVerifyEnabled.value = false
} finally {
verifyStatusLoaded.value = true
}
}
function setNotice(type, text) {
noticeType.value = String(type || '')
noticeText.value = String(text || '')
}
function clearNotice() {
noticeType.value = ''
noticeText.value = ''
}
async function refreshLoginCaptcha() {
try {
const data = await generateCaptcha()
captchaSession.value = data?.session_id || ''
captchaImage.value = data?.captcha_image || ''
form.captcha = ''
} catch {
captchaSession.value = ''
captchaImage.value = ''
}
}
async function refreshEmailResetCaptcha() {
try {
const data = await generateCaptcha()
forgotCaptchaSession.value = data?.session_id || ''
forgotCaptchaImage.value = data?.captcha_image || ''
forgotForm.captcha = ''
} catch {
forgotCaptchaSession.value = ''
forgotCaptchaImage.value = ''
}
}
async function refreshResendCaptcha() {
try {
const data = await generateCaptcha()
resendCaptchaSession.value = data?.session_id || ''
resendCaptchaImage.value = data?.captcha_image || ''
resendForm.captcha = ''
} catch {
resendCaptchaSession.value = ''
resendCaptchaImage.value = ''
}
}
function redirectAfterLogin() {
const urlParams = new URLSearchParams(window.location.search || '')
const next = String(urlParams.get('next') || '').trim()
const safeNext = next && next.startsWith('/') && !next.startsWith('//') && !next.startsWith('/\\') ? next : ''
window.setTimeout(() => {
window.location.href = safeNext || '/app'
}, 300)
}
async function onSubmit() {
clearNotice()
if (!form.username.trim() || !form.password.trim()) {
setNotice('error', '用户名和密码不能为空')
return
}
if (needCaptcha.value && !form.captcha.trim()) {
setNotice('error', '请输入验证码')
return
}
loading.value = true
try {
const username = form.username.trim()
await loginRequest({
username,
password: form.password,
captcha_session: captchaSession.value,
captcha: form.captcha.trim(),
need_captcha: needCaptcha.value,
})
setNotice('success', '登录成功,正在跳转...')
redirectAfterLogin()
} catch (e) {
const status = e?.response?.status
const data = e?.response?.data
const message = data?.error || data?.message || '登录失败'
setNotice('error', message)
if (data?.need_captcha) {
needCaptcha.value = true
await refreshLoginCaptcha()
} else if (needCaptcha.value && status === 400) {
await refreshLoginCaptcha()
}
} finally {
loading.value = false
}
}
async function onPasskeyLogin() {
clearNotice()
const username = form.username.trim()
if (!isPasskeyAvailable()) {
setNotice('error', '当前浏览器或环境不支持Passkey需 HTTPS')
return
}
passkeyLoading.value = true
try {
const optionsRes = await passkeyLoginOptions(username ? { username } : {})
const credential = await authenticateWithPasskey(optionsRes?.publicKey || {})
await passkeyLoginVerify(username ? { username, credential } : { credential })
setNotice('success', 'Passkey 登录成功,正在跳转...')
redirectAfterLogin()
} catch (e) {
const data = e?.response?.data
const message =
data?.error ||
(e?.name === 'NotAllowedError' ? 'Passkey验证未完成可能取消、超时或设备未响应' : e?.message || 'Passkey登录失败')
setNotice('error', message)
} finally {
passkeyLoading.value = false
}
}
async function openForgot() {
await loadVerifyStatus()
forgotOpen.value = true
forgotHint.value = ''
forgotError.value = ''
forgotForm.username = ''
forgotForm.captcha = ''
await refreshEmailResetCaptcha()
}
async function submitForgot() {
forgotError.value = ''
forgotHint.value = ''
if (!emailEnabled.value) {
forgotError.value = '邮件功能未启用,请联系管理员重置密码。'
return
}
const username = forgotForm.username.trim()
if (!username) {
forgotError.value = '请输入用户名'
return
}
if (!forgotForm.captcha.trim()) {
forgotError.value = '请输入验证码'
return
}
forgotLoading.value = true
try {
const res = await forgotPassword({
username,
captcha_session: forgotCaptchaSession.value,
captcha: forgotForm.captcha.trim(),
})
setNotice('success', res?.message || '已发送重置邮件')
forgotOpen.value = false
} catch (e) {
const data = e?.response?.data
const message = data?.error || '发送失败'
if (data?.code === 'email_not_bound') {
forgotHint.value = message
} else {
forgotError.value = message
}
await refreshEmailResetCaptcha()
} finally {
forgotLoading.value = false
}
}
async function openResend() {
await loadVerifyStatus()
if (!registerVerifyEnabled.value) {
setNotice('error', '当前未启用注册邮箱验证,无需重发验证邮件。')
return
}
resendOpen.value = true
resendForm.email = ''
resendForm.captcha = ''
resendError.value = ''
await refreshResendCaptcha()
}
async function submitResend() {
resendError.value = ''
const email = resendForm.email.trim()
if (!email) {
resendError.value = '请输入邮箱'
return
}
if (!resendForm.captcha.trim()) {
resendError.value = '请输入验证码'
return
}
resendLoading.value = true
try {
const res = await resendVerifyEmail({
email,
captcha_session: resendCaptchaSession.value,
captcha: resendForm.captcha.trim(),
})
setNotice('success', res?.message || '验证邮件已发送,请查收')
resendOpen.value = false
} catch (e) {
const data = e?.response?.data
resendError.value = data?.error || '发送失败'
await refreshResendCaptcha()
} finally {
resendLoading.value = false
}
}
function goRegister() {
window.location.href = '/register'
}
onMounted(async () => {
if (needCaptcha.value) {
await refreshLoginCaptcha()
}
})
</script>
<template>
<div class="login-page">
<div class="login-container">
<div class="login-header">
<span class="login-badge">用户登录</span>
<h1>用户登录系统</h1>
<p>知识管理平台</p>
</div>
<div v-if="noticeText" class="notice" :class="noticeType === 'success' ? 'is-success' : 'is-error'">
{{ noticeText }}
</div>
<div class="form-group">
<label for="username">用户账号</label>
<input
id="username"
v-model="form.username"
class="text-input"
placeholder="请输入用户名"
autocomplete="username"
/>
</div>
<div class="form-group">
<label for="password">密码</label>
<input
id="password"
v-model="form.password"
class="text-input"
type="password"
placeholder="请输入密码"
autocomplete="current-password"
@keyup.enter="onSubmit"
/>
</div>
<div v-if="needCaptcha" class="form-group">
<label for="captcha">验证码</label>
<div class="captcha-row">
<input
id="captcha"
v-model="form.captcha"
class="text-input captcha-input"
placeholder="请输入验证码"
@keyup.enter="onSubmit"
/>
<img
v-if="captchaImage"
class="captcha-img"
:src="captchaImage"
alt="验证码"
title="点击刷新"
@click="refreshLoginCaptcha"
/>
<button type="button" class="captcha-refresh" @click="refreshLoginCaptcha">刷新</button>
</div>
</div>
<button type="button" class="btn-login" :disabled="loading" @click="onSubmit">
{{ loading ? '登录中...' : '登录系统' }}
</button>
<button type="button" class="btn-passkey" :disabled="passkeyLoading" @click="onPasskeyLogin">
{{ passkeyLoading ? 'Passkey验证中...' : '使用 Passkey 登录' }}
</button>
<div class="action-links">
<button type="button" class="link-btn" @click="openForgot">忘记密码</button>
<button v-if="showResendLink" type="button" class="link-btn" @click="openResend">重发验证邮件</button>
</div>
<div class="register-row">
<span>还没有账号</span>
<button type="button" class="link-btn" @click="goRegister">立即注册</button>
</div>
</div>
<div v-if="forgotOpen" class="modal-mask" @click.self="forgotOpen = false">
<section class="modal-card">
<div class="modal-head">
<h3>找回密码</h3>
<button type="button" class="modal-close" @click="forgotOpen = false">关闭</button>
</div>
<p class="modal-tip" :class="{ warn: !emailEnabled }">
{{
emailEnabled
? '输入用户名并完成验证码,我们将向该账号绑定的邮箱发送重置链接。'
: '邮件功能未启用,无法通过邮箱找回密码。'
}}
</p>
<p v-if="forgotHint" class="modal-tip warn">{{ forgotHint }}</p>
<p v-if="forgotError" class="modal-tip error">{{ forgotError }}</p>
<div class="form-group">
<label for="forgot-username">用户名</label>
<input id="forgot-username" v-model="forgotForm.username" class="text-input" placeholder="请输入用户名" />
</div>
<div class="form-group">
<label for="forgot-captcha">验证码</label>
<div class="captcha-row">
<input
id="forgot-captcha"
v-model="forgotForm.captcha"
class="text-input captcha-input"
placeholder="请输入验证码"
/>
<img
v-if="forgotCaptchaImage"
class="captcha-img"
:src="forgotCaptchaImage"
alt="验证码"
title="点击刷新"
@click="refreshEmailResetCaptcha"
/>
<button type="button" class="captcha-refresh" @click="refreshEmailResetCaptcha">刷新</button>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-ghost" @click="forgotOpen = false">取消</button>
<button type="button" class="btn-login" :disabled="forgotLoading || !emailEnabled" @click="submitForgot">
{{ forgotLoading ? '发送中...' : '发送重置邮件' }}
</button>
</div>
</section>
</div>
<div v-if="resendOpen" class="modal-mask" @click.self="resendOpen = false">
<section class="modal-card">
<div class="modal-head">
<h3>重发验证邮件</h3>
<button type="button" class="modal-close" @click="resendOpen = false">关闭</button>
</div>
<p class="modal-tip">用于注册邮箱验证请输入邮箱并完成验证码</p>
<p v-if="resendError" class="modal-tip error">{{ resendError }}</p>
<div class="form-group">
<label for="resend-email">邮箱</label>
<input id="resend-email" v-model="resendForm.email" class="text-input" placeholder="name@example.com" />
</div>
<div class="form-group">
<label for="resend-captcha">验证码</label>
<div class="captcha-row">
<input
id="resend-captcha"
v-model="resendForm.captcha"
class="text-input captcha-input"
placeholder="请输入验证码"
/>
<img
v-if="resendCaptchaImage"
class="captcha-img"
:src="resendCaptchaImage"
alt="验证码"
title="点击刷新"
@click="refreshResendCaptcha"
/>
<button type="button" class="captcha-refresh" @click="refreshResendCaptcha">刷新</button>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-ghost" @click="resendOpen = false">取消</button>
<button type="button" class="btn-login" :disabled="resendLoading" @click="submitResend">
{{ resendLoading ? '发送中...' : '发送' }}
</button>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
position: relative;
background: linear-gradient(135deg, #eef2ff 0%, #f6f7fb 45%, #ecfeff 100%);
}
.login-page::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(800px 500px at 15% 20%, rgba(59, 130, 246, 0.18), transparent 60%),
radial-gradient(700px 420px at 85% 70%, rgba(124, 58, 237, 0.16), transparent 55%);
pointer-events: none;
}
.login-container {
width: 100%;
max-width: 420px;
background: #fff;
border-radius: 16px;
box-shadow: 0 18px 60px rgba(17, 24, 39, 0.15);
border: 1px solid rgba(17, 24, 39, 0.08);
padding: 36px 30px;
position: relative;
z-index: 1;
}
.login-header {
text-align: center;
margin-bottom: 26px;
}
.login-badge {
display: inline-block;
background: rgba(59, 130, 246, 0.1);
color: #1d4ed8;
padding: 6px 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
margin-bottom: 14px;
}
.login-header h1 {
font-size: 24px;
color: #111827;
margin: 0 0 10px;
letter-spacing: 0.2px;
}
.login-header p {
margin: 0;
color: #6b7280;
font-size: 14px;
}
.notice {
margin-bottom: 14px;
padding: 10px 12px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
}
.notice.is-error {
color: #b91c1c;
background: #fee2e2;
border: 1px solid #fecaca;
}
.notice.is-success {
color: #065f46;
background: #d1fae5;
border: 1px solid #a7f3d0;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #111827;
font-weight: 700;
font-size: 13px;
}
.text-input {
width: 100%;
height: 44px;
border-radius: 10px;
border: 1px solid rgba(17, 24, 39, 0.18);
padding: 0 12px;
font-size: 14px;
color: #111827;
background: rgba(255, 255, 255, 0.92);
outline: none;
transition: border-color 0.18s, box-shadow 0.18s;
box-sizing: border-box;
}
.text-input:focus {
border-color: rgba(59, 130, 246, 0.8);
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.14);
}
.btn-login {
width: 100%;
height: 44px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
color: #fff;
font-size: 14px;
font-weight: 800;
cursor: pointer;
transition: transform 0.15s, filter 0.15s;
}
.btn-passkey {
width: 100%;
height: 42px;
margin-top: 10px;
border-radius: 10px;
border: 1px solid rgba(17, 24, 39, 0.14);
background: #f8fafc;
color: #0f172a;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.btn-passkey:hover:not(:disabled) {
background: #f1f5f9;
}
.btn-passkey:disabled,
.btn-login:disabled,
.btn-ghost:disabled,
.captcha-refresh:disabled {
cursor: not-allowed;
opacity: 0.72;
}
.btn-login:hover:not(:disabled) {
transform: translateY(-1px);
filter: brightness(1.02);
}
.action-links {
margin-top: 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.link-btn {
border: none;
background: none;
color: #2563eb;
font-size: 13px;
font-weight: 700;
cursor: pointer;
padding: 0;
}
.link-btn:hover {
text-decoration: underline;
}
.register-row {
margin-top: 16px;
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
color: #6b7280;
font-size: 13px;
}
.captcha-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.captcha-input {
flex: 1;
min-width: 0;
}
.captcha-img {
height: 44px;
border: 1px solid rgba(17, 24, 39, 0.14);
border-radius: 8px;
cursor: pointer;
user-select: none;
}
.captcha-refresh {
height: 42px;
padding: 0 12px;
border: 1px solid rgba(17, 24, 39, 0.14);
border-radius: 10px;
background: #f8fafc;
color: #111827;
font-size: 13px;
cursor: pointer;
}
.captcha-refresh:hover {
background: #f1f5f9;
}
.modal-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 20;
}
.modal-card {
width: min(560px, 96vw);
border-radius: 14px;
background: #fff;
border: 1px solid rgba(17, 24, 39, 0.08);
box-shadow: 0 16px 42px rgba(15, 23, 42, 0.28);
padding: 16px;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.modal-head h3 {
margin: 0;
font-size: 16px;
font-weight: 800;
color: #0f172a;
}
.modal-close {
height: 30px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid rgba(17, 24, 39, 0.16);
background: #fff;
color: #334155;
cursor: pointer;
}
.modal-tip {
margin: 12px 0;
padding: 10px;
border-radius: 10px;
background: #eff6ff;
border: 1px solid #bfdbfe;
color: #1e3a8a;
font-size: 13px;
}
.modal-tip.warn {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
}
.modal-tip.error {
background: #fef2f2;
border-color: #fecaca;
color: #991b1b;
}
.modal-actions {
margin-top: 14px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.btn-ghost {
min-width: 86px;
height: 40px;
border-radius: 10px;
border: 1px solid rgba(17, 24, 39, 0.2);
background: #fff;
color: #334155;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
@media (max-width: 480px) {
.login-page {
align-items: flex-start;
padding: 16px 10px 10px;
}
.login-container {
max-width: 100%;
padding: 26px 18px;
border-radius: 14px;
}
.login-header h1 {
font-size: 22px;
}
.captcha-img {
height: 42px;
}
.captcha-refresh {
height: 40px;
padding: 0 10px;
}
.modal-card {
padding: 14px;
}
}
</style>

View File

@@ -0,0 +1,283 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { fetchEmailVerifyStatus, generateCaptcha, register } from '../api/auth'
import { validateStrongPassword } from '../utils/password'
const router = useRouter()
const form = reactive({
username: '',
password: '',
confirm_password: '',
email: '',
captcha: '',
})
const emailVerifyEnabled = ref(false)
const captchaImage = ref('')
const captchaSession = ref('')
const loading = ref(false)
const errorText = ref('')
const successTitle = ref('')
const successDesc = ref('')
const emailLabel = computed(() => (emailVerifyEnabled.value ? '邮箱 *' : '邮箱(可选)'))
const emailHint = computed(() => (emailVerifyEnabled.value ? '必填,用于账号验证' : '选填,用于找回密码和接收通知'))
async function refreshCaptcha() {
try {
const data = await generateCaptcha()
captchaSession.value = data?.session_id || ''
captchaImage.value = data?.captcha_image || ''
form.captcha = ''
} catch {
captchaSession.value = ''
captchaImage.value = ''
}
}
async function loadEmailVerifyStatus() {
try {
const data = await fetchEmailVerifyStatus()
emailVerifyEnabled.value = Boolean(data?.register_verify_enabled)
} catch {
emailVerifyEnabled.value = false
}
}
function clearAlerts() {
errorText.value = ''
successTitle.value = ''
successDesc.value = ''
}
async function onSubmit() {
clearAlerts()
const username = form.username.trim()
const password = form.password
const confirmPassword = form.confirm_password
const email = form.email.trim()
const captcha = form.captcha.trim()
if (username.length < 3) {
errorText.value = '用户名至少3个字符'
ElMessage.error(errorText.value)
return
}
const passwordCheck = validateStrongPassword(password)
if (!passwordCheck.ok) {
errorText.value = passwordCheck.message || '密码格式不正确'
ElMessage.error(errorText.value)
return
}
if (password !== confirmPassword) {
errorText.value = '两次输入的密码不一致'
ElMessage.error(errorText.value)
return
}
if (emailVerifyEnabled.value && !email) {
errorText.value = '请填写邮箱地址用于账号验证'
ElMessage.error(errorText.value)
return
}
if (email && !email.includes('@')) {
errorText.value = '邮箱格式不正确'
ElMessage.error(errorText.value)
return
}
if (!captcha) {
errorText.value = '请输入验证码'
ElMessage.error(errorText.value)
return
}
loading.value = true
try {
const res = await register({
username,
password,
email,
captcha_session: captchaSession.value,
captcha,
})
successTitle.value = res?.message || '注册成功'
successDesc.value = res?.need_verify ? '请检查您的邮箱(包括垃圾邮件文件夹)' : ''
ElMessage.success('注册成功')
form.username = ''
form.password = ''
form.confirm_password = ''
form.email = ''
form.captcha = ''
setTimeout(() => {
window.location.href = '/login'
}, 3000)
} catch (e) {
const data = e?.response?.data
errorText.value = data?.error || '注册失败'
ElMessage.error(errorText.value)
await refreshCaptcha()
} finally {
loading.value = false
}
}
function goLogin() {
router.push('/login')
}
onMounted(async () => {
await refreshCaptcha()
await loadEmailVerifyStatus()
})
</script>
<template>
<div class="auth-wrap">
<el-card shadow="never" class="auth-card" :body-style="{ padding: '22px' }">
<div class="brand">
<div class="brand-title">知识管理平台</div>
<div class="brand-sub app-muted">用户注册</div>
</div>
<el-alert v-if="errorText" type="error" :closable="false" :title="errorText" show-icon class="alert" />
<el-alert
v-if="successTitle"
type="success"
:closable="false"
:title="successTitle"
:description="successDesc"
show-icon
class="alert"
/>
<el-form label-position="top">
<el-form-item label="用户名 *">
<el-input v-model="form.username" placeholder="至少3个字符" autocomplete="username" />
<div class="hint app-muted">至少3个字符</div>
</el-form-item>
<el-form-item label="密码 *">
<el-input
v-model="form.password"
type="password"
show-password
placeholder="至少8位且包含字母和数字"
autocomplete="new-password"
/>
<div class="hint app-muted">至少8位且包含字母和数字</div>
</el-form-item>
<el-form-item label="确认密码 *">
<el-input
v-model="form.confirm_password"
type="password"
show-password
placeholder="请再次输入密码"
autocomplete="new-password"
@keyup.enter="onSubmit"
/>
</el-form-item>
<el-form-item :label="emailLabel">
<el-input v-model="form.email" placeholder="name@example.com" autocomplete="email" />
<div class="hint app-muted">{{ emailHint }}</div>
</el-form-item>
<el-form-item label="验证码 *">
<div class="captcha-row">
<el-input v-model="form.captcha" placeholder="请输入验证码" @keyup.enter="onSubmit" />
<img
v-if="captchaImage"
class="captcha-img"
:src="captchaImage"
alt="验证码"
title="点击刷新"
@click="refreshCaptcha"
/>
<el-button @click="refreshCaptcha">刷新</el-button>
</div>
</el-form-item>
</el-form>
<el-button type="primary" class="submit-btn" :loading="loading" @click="onSubmit">注册</el-button>
<div class="actions">
<span class="app-muted">已有账号</span>
<el-button link type="primary" @click="goLogin">立即登录</el-button>
</div>
</el-card>
</div>
</template>
<style scoped>
.auth-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-card {
width: 100%;
max-width: 420px;
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
box-shadow: var(--app-shadow);
}
.brand {
margin-bottom: 14px;
}
.brand-title {
font-size: 18px;
font-weight: 900;
}
.brand-sub {
margin-top: 4px;
font-size: 12px;
}
.alert {
margin-bottom: 12px;
}
.hint {
margin-top: 6px;
font-size: 12px;
}
.captcha-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.captcha-img {
height: 40px;
border: 1px solid var(--app-border);
border-radius: 8px;
cursor: pointer;
user-select: none;
}
.submit-btn {
width: 100%;
margin-top: 4px;
}
.actions {
margin-top: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
</style>

View File

@@ -0,0 +1,210 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { confirmPasswordReset } from '../api/auth'
import { validateStrongPassword } from '../utils/password'
const route = useRoute()
const router = useRouter()
const token = ref(String(route.params.token || ''))
const valid = ref(true)
const invalidMessage = ref('')
const form = reactive({
newPassword: '',
confirmPassword: '',
})
const loading = ref(false)
const successText = ref('')
const redirectSeconds = ref(0)
let redirectTimer = null
function loadInitialState() {
if (typeof window === 'undefined') return null
const state = window.__APP_INITIAL_STATE__
if (!state || typeof state !== 'object') return null
window.__APP_INITIAL_STATE__ = null
return state
}
const canSubmit = computed(() => Boolean(valid.value && token.value && !successText.value))
function goLogin() {
router.push('/login')
}
function startRedirect() {
redirectSeconds.value = 3
redirectTimer = window.setInterval(() => {
redirectSeconds.value -= 1
if (redirectSeconds.value <= 0) {
window.clearInterval(redirectTimer)
redirectTimer = null
window.location.href = '/login'
}
}, 1000)
}
async function onSubmit() {
if (!canSubmit.value) return
const newPassword = form.newPassword
const confirmPassword = form.confirmPassword
const check = validateStrongPassword(newPassword)
if (!check.ok) {
ElMessage.error(check.message)
return
}
if (newPassword !== confirmPassword) {
ElMessage.error('两次输入的密码不一致')
return
}
loading.value = true
try {
await confirmPasswordReset({ token: token.value, new_password: newPassword })
successText.value = '密码重置成功3秒后跳转到登录页面...'
ElMessage.success('密码重置成功')
startRedirect()
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '重置失败')
} finally {
loading.value = false
}
}
onMounted(() => {
const init = loadInitialState()
if (init?.page === 'reset_password') {
token.value = String(init?.token || token.value || '')
valid.value = Boolean(init?.valid)
invalidMessage.value =
init?.error_message || (valid.value ? '' : '重置链接无效或已过期,请重新申请密码重置')
} else if (!token.value) {
valid.value = false
invalidMessage.value = '重置链接无效或已过期,请重新申请密码重置'
}
})
onBeforeUnmount(() => {
if (redirectTimer) window.clearInterval(redirectTimer)
})
</script>
<template>
<div class="auth-wrap">
<el-card shadow="never" class="auth-card" :body-style="{ padding: '22px' }">
<div class="brand">
<div class="brand-title">知识管理平台</div>
<div class="brand-sub app-muted">重置密码</div>
</div>
<template v-if="!valid">
<el-alert type="error" :closable="false" title="链接已失效" :description="invalidMessage" show-icon />
<div class="actions">
<el-button type="primary" @click="goLogin">返回登录</el-button>
</div>
</template>
<template v-else>
<el-alert
v-if="successText"
type="success"
:closable="false"
title="重置成功"
:description="successText"
show-icon
class="alert"
/>
<el-form label-position="top">
<el-form-item label="新密码至少8位且包含字母和数字">
<el-input
v-model="form.newPassword"
type="password"
show-password
placeholder="请输入新密码"
autocomplete="new-password"
/>
</el-form-item>
<el-form-item label="确认密码">
<el-input
v-model="form.confirmPassword"
type="password"
show-password
placeholder="请再次输入新密码"
autocomplete="new-password"
@keyup.enter="onSubmit"
/>
</el-form-item>
</el-form>
<el-button type="primary" class="submit-btn" :loading="loading" :disabled="!canSubmit" @click="onSubmit">
确认重置
</el-button>
<div class="actions">
<el-button link type="primary" @click="goLogin">返回登录</el-button>
<span v-if="redirectSeconds > 0" class="app-muted">{{ redirectSeconds }} 秒后自动跳转</span>
</div>
</template>
</el-card>
</div>
</template>
<style scoped>
.auth-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-card {
width: 100%;
max-width: 420px;
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
box-shadow: var(--app-shadow);
}
.brand {
margin-bottom: 14px;
}
.brand-title {
font-size: 18px;
font-weight: 900;
}
.brand-sub {
margin-top: 4px;
font-size: 12px;
}
.alert {
margin-bottom: 12px;
}
.submit-btn {
width: 100%;
margin-top: 4px;
}
.actions {
margin-top: 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
</style>

View File

@@ -0,0 +1,703 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchAccounts } from '../api/accounts'
import {
clearScheduleLogs,
createSchedule,
deleteSchedule,
fetchScheduleLogs,
fetchSchedules,
runScheduleNow,
toggleSchedule,
updateSchedule,
} from '../api/schedules'
import { useUserStore } from '../stores/user'
const userStore = useUserStore()
const loading = ref(false)
const schedules = ref([])
const schedulePage = ref(1)
const scheduleTotal = ref(0)
const schedulePageSize = 12
const accountsLoading = ref(false)
const accountOptions = ref([])
const editorOpen = ref(false)
const editorSaving = ref(false)
const editingId = ref(null)
const logsOpen = ref(false)
const logsLoading = ref(false)
const logs = ref([])
const logsSchedule = ref(null)
const vipModalOpen = ref(false)
const form = reactive({
name: '',
schedule_time: '08:00',
weekdays: ['1', '2', '3', '4', '5'],
browse_type: '应读',
enable_screenshot: true,
random_delay: false,
account_ids: [],
})
const browseTypeOptions = [
{ label: '应读', value: '应读' },
{ label: '注册前未读', value: '注册前未读' },
]
function normalizeBrowseType(value) {
if (String(value) === '注册前未读') return '注册前未读'
return '应读'
}
const weekdayOptions = [
{ label: '周一', value: '1' },
{ label: '周二', value: '2' },
{ label: '周三', value: '3' },
{ label: '周四', value: '4' },
{ label: '周五', value: '5' },
{ label: '周六', value: '6' },
{ label: '周日', value: '7' },
]
const canUseSchedule = computed(() => userStore.isVip)
const scheduleTotalPages = computed(() => Math.max(1, Math.ceil((scheduleTotal.value || 0) / schedulePageSize)))
function normalizeTime(value) {
const match = String(value || '').match(/^(\d{1,2}):(\d{2})$/)
if (!match) return null
const hour = Number(match[1])
const minute = Number(match[2])
if (Number.isNaN(hour) || Number.isNaN(minute)) return null
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}
function weekdaysText(textOrArray) {
const raw = Array.isArray(textOrArray) ? textOrArray : String(textOrArray || '').split(',').filter(Boolean)
const map = Object.fromEntries(weekdayOptions.map((w) => [w.value, w.label]))
return raw.map((d) => map[String(d)] || String(d)).join(' ')
}
async function loadAccounts() {
accountsLoading.value = true
try {
const list = await fetchAccounts({ refresh: false })
accountOptions.value = (list || []).map((acc) => ({ label: acc.username, value: acc.id }))
} catch {
accountOptions.value = []
} finally {
accountsLoading.value = false
}
}
async function reloadSchedulesAfterMutate() {
if (schedulePage.value > 1 && schedules.value.length <= 1) {
schedulePage.value -= 1
}
await loadSchedules()
}
async function onSchedulePageChange(page) {
schedulePage.value = page
await loadSchedules()
}
async function loadSchedules() {
loading.value = true
try {
const params = {
limit: schedulePageSize,
offset: (schedulePage.value - 1) * schedulePageSize,
}
const payload = await fetchSchedules(params)
const rawItems = Array.isArray(payload) ? payload : (Array.isArray(payload?.items) ? payload.items : [])
const rawTotal = Array.isArray(payload) ? rawItems.length : Number(payload?.total ?? rawItems.length)
schedules.value = rawItems.map((s) => ({
...s,
browse_type: normalizeBrowseType(s?.browse_type),
}))
scheduleTotal.value = Number.isFinite(rawTotal) ? Math.max(0, rawTotal) : rawItems.length
} catch (e) {
if (e?.response?.status === 401) window.location.href = '/login'
schedules.value = []
scheduleTotal.value = 0
} finally {
loading.value = false
}
}
function openCreate() {
editingId.value = null
form.name = ''
form.schedule_time = '08:00'
form.weekdays = ['1', '2', '3', '4', '5']
form.browse_type = '应读'
form.enable_screenshot = true
form.random_delay = false
form.account_ids = []
editorOpen.value = true
}
function openEdit(schedule) {
editingId.value = schedule.id
form.name = schedule.name || ''
form.schedule_time = normalizeTime(schedule.schedule_time) || '08:00'
form.weekdays = String(schedule.weekdays || '')
.split(',')
.filter(Boolean)
.map((v) => String(v))
if (form.weekdays.length === 0) form.weekdays = ['1', '2', '3', '4', '5']
form.browse_type = normalizeBrowseType(schedule.browse_type)
form.enable_screenshot = Number(schedule.enable_screenshot ?? 1) !== 0
form.random_delay = Number(schedule.random_delay ?? 0) !== 0
form.account_ids = Array.isArray(schedule.account_ids) ? schedule.account_ids.slice() : []
editorOpen.value = true
}
async function saveSchedule() {
if (!canUseSchedule.value) {
vipModalOpen.value = true
return
}
const normalizedTime = normalizeTime(form.schedule_time)
if (!normalizedTime) {
ElMessage.error('时间格式错误,请使用 HH:MM')
return
}
if (!form.weekdays || form.weekdays.length === 0) {
ElMessage.warning('请选择至少一个执行日期')
return
}
editorSaving.value = true
try {
const payload = {
name: form.name.trim() || '我的定时任务',
schedule_time: normalizedTime,
weekdays: form.weekdays.join(','),
browse_type: form.browse_type,
enable_screenshot: form.enable_screenshot ? 1 : 0,
random_delay: form.random_delay ? 1 : 0,
account_ids: form.account_ids,
}
if (editingId.value) {
await updateSchedule(editingId.value, payload)
ElMessage.success('保存成功')
} else {
await createSchedule(payload)
ElMessage.success('创建成功')
schedulePage.value = 1
}
editorOpen.value = false
await loadSchedules()
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '保存失败')
} finally {
editorSaving.value = false
}
}
async function onDelete(schedule) {
try {
await ElMessageBox.confirm(`确定要删除定时任务「${schedule.name || '未命名任务'}」吗?`, '删除任务', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await deleteSchedule(schedule.id)
if (res?.success) {
ElMessage.success('已删除')
await reloadSchedulesAfterMutate()
} else {
ElMessage.error(res?.error || '删除失败')
}
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '删除失败')
}
}
async function onToggle(schedule, enabled) {
if (!canUseSchedule.value) {
vipModalOpen.value = true
return
}
try {
const res = await toggleSchedule(schedule.id, { enabled })
if (res?.success) {
schedule.enabled = enabled ? 1 : 0
ElMessage.success(enabled ? '已启用' : '已禁用')
}
} catch {
ElMessage.error('操作失败')
}
}
async function onRunNow(schedule) {
if (!canUseSchedule.value) {
vipModalOpen.value = true
return
}
try {
const res = await runScheduleNow(schedule.id)
if (res?.success) ElMessage.success(res?.message || '已开始执行')
else ElMessage.error(res?.error || '执行失败')
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '执行失败')
}
}
async function openLogs(schedule) {
logsSchedule.value = schedule
logsOpen.value = true
logsLoading.value = true
try {
logs.value = await fetchScheduleLogs(schedule.id, { limit: 20 })
} catch {
logs.value = []
} finally {
logsLoading.value = false
}
}
async function clearLogs() {
const schedule = logsSchedule.value
if (!schedule) return
try {
await ElMessageBox.confirm('确定要清空该任务的所有执行日志吗?', '清空日志', {
confirmButtonText: '清空',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await clearScheduleLogs(schedule.id)
if (res?.success) {
ElMessage.success(`已清空 ${res?.deleted || 0} 条日志`)
logs.value = []
} else {
ElMessage.error(res?.error || '操作失败')
}
} catch {
ElMessage.error('操作失败')
}
}
function statusTagType(status) {
const text = String(status || '')
if (text === 'success' || text === 'completed') return 'success'
if (text === 'failed') return 'danger'
return 'info'
}
function formatDuration(seconds) {
const value = Number(seconds || 0)
const mins = Math.floor(value / 60)
const secs = value % 60
if (mins <= 0) return `${secs}`
return `${mins}${secs}`
}
onMounted(async () => {
if (!userStore.vipInfo) {
userStore.refreshVipInfo().catch(() => {
window.location.href = '/login'
})
}
await Promise.all([loadAccounts(), loadSchedules()])
})
</script>
<template>
<div class="page">
<el-alert
v-if="!canUseSchedule"
type="warning"
show-icon
:closable="false"
title="定时任务为 VIP 专属功能,升级后可使用。"
class="vip-alert"
>
<template #default>
<div class="vip-actions">
<el-button type="primary" plain @click="vipModalOpen = true">了解VIP特权</el-button>
</div>
</template>
</el-alert>
<el-card shadow="never" class="panel" :body-style="{ padding: '14px' }">
<div class="panel-head">
<div class="panel-title">定时任务</div>
<div class="panel-actions">
<el-button :loading="loading" @click="loadSchedules">刷新</el-button>
<el-button type="primary" :disabled="!canUseSchedule" @click="openCreate">新建任务</el-button>
</div>
</div>
<el-skeleton v-if="loading" :rows="6" animated />
<template v-else>
<el-empty v-if="schedules.length === 0" description="暂无定时任务" />
<div v-else class="grid">
<el-card v-for="s in schedules" :key="s.id" shadow="never" class="schedule-card" :body-style="{ padding: '14px' }">
<div class="schedule-top">
<div class="schedule-main">
<div class="schedule-title">
<span class="schedule-name">{{ s.name || '未命名任务' }}</span>
</div>
<div class="schedule-meta app-muted">
<span> {{ normalizeTime(s.schedule_time) || s.schedule_time }}</span>
<span>📅 {{ weekdaysText(s.weekdays) }}</span>
</div>
<div class="schedule-meta app-muted">
<span>📋 {{ s.browse_type || '应读' }}</span>
<span>👥 {{ (s.account_ids || []).length }} 个账号</span>
<span>{{ Number(s.enable_screenshot ?? 1) !== 0 ? '📸 截图' : '📷 不截图' }}</span>
<span v-if="Number(s.random_delay ?? 0) !== 0">🎲 随机±15分钟</span>
</div>
</div>
<div class="schedule-switch">
<el-switch
:model-value="Boolean(Number(s.enabled))"
:disabled="!canUseSchedule"
inline-prompt
active-text="启用"
inactive-text="停用"
@change="(val) => onToggle(s, val)"
/>
</div>
</div>
<div class="schedule-actions">
<el-button size="small" type="primary" :disabled="!canUseSchedule" @click="onRunNow(s)">立即执行</el-button>
<el-button size="small" @click="openLogs(s)">日志</el-button>
<el-button size="small" :disabled="!canUseSchedule" @click="openEdit(s)">编辑</el-button>
<el-button size="small" type="danger" text :disabled="!canUseSchedule" @click="onDelete(s)">删除</el-button>
</div>
</el-card>
</div>
<div v-if="scheduleTotal > schedulePageSize" class="pagination">
<el-pagination
v-model:current-page="schedulePage"
:page-size="schedulePageSize"
:total="scheduleTotal"
layout="prev, pager, next, jumper, ->, total"
@current-change="onSchedulePageChange"
/>
<div class="page-hint app-muted"> {{ schedulePage }} / {{ scheduleTotalPages }} </div>
</div>
</template>
</el-card>
<el-dialog v-model="editorOpen" :title="editingId ? '编辑定时任务' : '新建定时任务'" width="min(720px, 92vw)">
<el-form label-position="top">
<el-form-item label="任务名称">
<el-input v-model="form.name" placeholder="我的定时任务" :disabled="!canUseSchedule" />
</el-form-item>
<el-form-item label="执行时间HH:MM">
<el-time-picker
v-model="form.schedule_time"
placeholder="选择时间"
format="HH:mm"
value-format="HH:mm"
style="width: 180px"
:disabled="!canUseSchedule"
/>
</el-form-item>
<el-form-item label="执行日期">
<el-checkbox-group v-model="form.weekdays" :disabled="!canUseSchedule">
<el-checkbox v-for="w in weekdayOptions" :key="w.value" :label="w.value">{{ w.label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="浏览类型">
<el-select v-model="form.browse_type" style="width: 160px" :disabled="!canUseSchedule">
<el-option v-for="opt in browseTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="截图">
<div class="switch-row">
<el-switch
v-model="form.enable_screenshot"
:disabled="!canUseSchedule"
inline-prompt
active-text="截图"
inactive-text="不截图"
/>
<el-switch
v-model="form.random_delay"
:disabled="!canUseSchedule"
inline-prompt
active-text="随机±15分钟"
inactive-text="固定时间"
/>
</div>
</el-form-item>
<el-form-item label="参与账号">
<el-select
v-model="form.account_ids"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="选择账号(可多选)"
style="width: 100%"
:loading="accountsLoading"
:disabled="!canUseSchedule"
>
<el-option v-for="opt in accountOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editorOpen = false">取消</el-button>
<el-button type="primary" :loading="editorSaving" :disabled="!canUseSchedule" @click="saveSchedule">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="logsOpen" :title="logsSchedule ? `【${logsSchedule.name || '未命名任务'}】执行日志` : '执行日志'" width="min(760px, 92vw)">
<el-skeleton v-if="logsLoading" :rows="6" animated />
<template v-else>
<el-empty v-if="logs.length === 0" description="暂无执行日志" />
<div v-else class="logs">
<el-card v-for="log in logs" :key="log.id" shadow="never" class="log-card" :body-style="{ padding: '12px' }">
<div class="log-head">
<el-tag size="small" effect="light" :type="statusTagType(log.status)">
{{ log.status === 'failed' ? '失败' : log.status === 'running' ? '进行中' : '成功' }}
</el-tag>
<span class="app-muted">{{ log.created_at || '' }}</span>
</div>
<div class="log-body">
<div>账号数{{ log.total_accounts || 0 }} </div>
<div>成功{{ log.success_count || 0 }} · 失败{{ log.failed_count || 0 }} </div>
<div>耗时{{ formatDuration(log.duration || 0) }}</div>
<div v-if="log.error_message" class="log-error">错误{{ log.error_message }}</div>
</div>
</el-card>
</div>
</template>
<template #footer>
<el-button @click="logsOpen = false">关闭</el-button>
<el-button type="danger" plain :disabled="logs.length === 0" @click="clearLogs">清空日志</el-button>
</template>
</el-dialog>
<el-dialog v-model="vipModalOpen" title="VIP 特权" width="min(560px, 92vw)">
<el-alert
type="info"
:closable="false"
title="升级 VIP 后可解锁:无限账号、优先排队、定时任务、批量操作。"
show-icon
/>
<div class="vip-body">
<div class="vip-tip app-muted">升级方式请通过反馈联系管理员开通</div>
</div>
<template #footer>
<el-button type="primary" @click="vipModalOpen = false">我知道了</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.switch-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.vip-alert {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.vip-actions {
margin-top: 10px;
}
.panel {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.panel-title {
font-size: 16px;
font-weight: 900;
}
.panel-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
align-items: start;
}
.schedule-card {
border-radius: 14px;
border: 1px solid var(--app-border);
}
.schedule-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.schedule-main {
min-width: 0;
flex: 1;
}
.schedule-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.schedule-name {
font-size: 14px;
font-weight: 900;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.schedule-meta {
margin-top: 6px;
display: flex;
gap: 10px;
flex-wrap: wrap;
font-size: 12px;
}
.schedule-actions {
margin-top: 12px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.pagination {
margin-top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.page-hint {
font-size: 12px;
}
.logs {
display: flex;
flex-direction: column;
gap: 10px;
}
.log-card {
border-radius: 12px;
border: 1px solid var(--app-border);
}
.log-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 12px;
}
.log-body {
margin-top: 8px;
font-size: 13px;
line-height: 1.6;
}
.log-error {
margin-top: 6px;
color: #b91c1c;
}
.vip-body {
padding: 12px 0 0;
}
.vip-tip {
margin-top: 10px;
font-size: 13px;
line-height: 1.6;
}
@media (max-width: 480px) {
.grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.panel-actions {
width: 100%;
justify-content: flex-end;
}
.schedule-switch {
width: 100%;
display: flex;
justify-content: flex-end;
}
}
</style>

View File

@@ -0,0 +1,406 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { clearScreenshots, deleteScreenshot, fetchScreenshots } from '../api/screenshots'
const loading = ref(false)
const screenshots = ref([])
const currentPage = ref(1)
const total = ref(0)
const pageSize = 24
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / pageSize)))
const previewOpen = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
function buildUrl(filename) {
return `/screenshots/${encodeURIComponent(filename)}`
}
function buildThumbUrl(filename) {
return `/screenshots/thumb/${encodeURIComponent(filename)}`
}
async function load() {
loading.value = true
try {
const params = {
limit: pageSize,
offset: (currentPage.value - 1) * pageSize,
}
const payload = await fetchScreenshots(params)
const items = Array.isArray(payload) ? payload : (Array.isArray(payload?.items) ? payload.items : [])
const payloadTotal = Array.isArray(payload) ? items.length : Number(payload?.total ?? items.length)
screenshots.value = items
total.value = Number.isFinite(payloadTotal) ? Math.max(0, payloadTotal) : items.length
} catch (e) {
if (e?.response?.status === 401) window.location.href = '/login'
screenshots.value = []
total.value = 0
} finally {
loading.value = false
}
}
async function onPageChange(page) {
currentPage.value = page
await load()
}
function openPreview(item) {
previewTitle.value = item.display_name || item.filename || '截图预览'
previewUrl.value = buildUrl(item.filename)
previewOpen.value = true
}
function onThumbError(event, item) {
const imageEl = event?.target
if (!imageEl) return
if (imageEl.dataset.fullLoaded === '1') return
imageEl.dataset.fullLoaded = '1'
imageEl.src = buildUrl(item.filename)
}
function canvasToPngBlob(canvas) {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('toBlob_failed'))), 'image/png')
})
}
async function imageElementToPngBlob(imgEl) {
if (!imgEl) throw new Error('no_image')
if (!imgEl.complete || imgEl.naturalWidth <= 0) {
if (typeof imgEl.decode === 'function') await imgEl.decode()
else {
await new Promise((resolve, reject) => {
imgEl.addEventListener('load', resolve, { once: true })
imgEl.addEventListener('error', reject, { once: true })
})
}
}
const canvas = document.createElement('canvas')
canvas.width = imgEl.naturalWidth
canvas.height = imgEl.naturalHeight
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('no_canvas')
ctx.drawImage(imgEl, 0, 0)
return await canvasToPngBlob(canvas)
}
async function blobToPng(blob) {
if (!blob) throw new Error('no_blob')
if (blob.type === 'image/png') return blob
if (typeof createImageBitmap === 'function') {
const bitmap = await createImageBitmap(blob)
const canvas = document.createElement('canvas')
canvas.width = bitmap.width
canvas.height = bitmap.height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('no_canvas')
ctx.drawImage(bitmap, 0, 0)
return await canvasToPngBlob(canvas)
}
const url = URL.createObjectURL(blob)
try {
const img = new Image()
img.src = url
if (typeof img.decode === 'function') await img.decode()
return await imageElementToPngBlob(img)
} finally {
URL.revokeObjectURL(url)
}
}
async function screenshotUrlToPngBlob(url) {
// 复制时始终拉取原图,避免复制到缩略图
const resp = await fetch(url, { credentials: 'include', cache: 'no-store' })
if (!resp.ok) throw new Error('fetch_failed')
const blob = await resp.blob()
const mime = resp.headers.get('Content-Type') || blob.type || ''
if (!mime.startsWith('image/')) throw new Error('not_image')
return await blobToPng(blob)
}
async function onClearAll() {
try {
await ElMessageBox.confirm('确定要清空全部截图吗?', '清空截图', {
confirmButtonText: '清空',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await clearScreenshots()
if (res?.success) {
ElMessage.success(`已清空(删除 ${res?.deleted || 0} 张)`)
screenshots.value = []
total.value = 0
currentPage.value = 1
previewOpen.value = false
return
}
ElMessage.error(res?.error || '操作失败')
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '操作失败')
}
}
async function onDelete(item) {
try {
await ElMessageBox.confirm(`确定要删除截图「${item.display_name || item.filename}」吗?`, '删除截图', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await deleteScreenshot(item.filename)
if (res?.success) {
if (previewUrl.value.includes(encodeURIComponent(item.filename))) previewOpen.value = false
if (currentPage.value > 1 && screenshots.value.length <= 1) currentPage.value -= 1
await load()
ElMessage.success('已删除')
return
}
ElMessage.error(res?.error || '删除失败')
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '删除失败')
}
}
async function copyImage(item) {
const url = buildUrl(item.filename)
if (
!navigator.clipboard ||
typeof navigator.clipboard.write !== 'function' ||
typeof window.ClipboardItem === 'undefined'
) {
ElMessage.warning('当前环境不支持复制图片(建议使用 Chrome/Edge 并通过 HTTPS 访问);可用“下载”。')
return
}
try {
// 关键点:用 Promise 形式的数据源,让 clipboard.write 在用户手势内立即发生(更稳)
try {
await navigator.clipboard.write([
new ClipboardItem({
'image/png': screenshotUrlToPngBlob(url),
}),
])
} catch {
const pngBlob = await screenshotUrlToPngBlob(url)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
}
ElMessage.success('图片已复制到剪贴板')
} catch {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(`${window.location.origin}${url}`)
ElMessage.warning('复制图片失败,已复制图片链接(可直接粘贴到浏览器打开)')
return
}
} catch {
// ignore
}
ElMessage.warning('复制图片失败:请确认允许剪贴板权限;可用“下载”。')
}
}
function download(item) {
const link = document.createElement('a')
link.href = buildUrl(item.filename)
link.download = item.display_name || item.filename
document.body.appendChild(link)
link.click()
link.remove()
}
onMounted(load)
</script>
<template>
<el-card shadow="never" class="panel" :body-style="{ padding: '14px' }">
<div class="panel-head">
<div class="panel-title">截图管理</div>
<div class="panel-actions">
<el-button :loading="loading" @click="load">刷新</el-button>
<el-button type="danger" plain :disabled="total === 0" @click="onClearAll">清空全部</el-button>
</div>
</div>
<el-skeleton v-if="loading" :rows="6" animated />
<template v-else>
<el-empty v-if="total === 0" description="暂无截图" />
<div v-else class="grid">
<el-card v-for="item in screenshots" :key="item.filename" shadow="never" class="shot-card" :body-style="{ padding: '0' }">
<img
class="shot-img"
:src="buildThumbUrl(item.filename)"
:alt="item.display_name || item.filename"
loading="lazy"
@error="onThumbError($event, item)"
@click="openPreview(item)"
/>
<div class="shot-body">
<div class="shot-name" :title="item.display_name || item.filename">{{ item.display_name || item.filename }}</div>
<div class="shot-meta app-muted">{{ item.created || '' }}</div>
<div class="shot-actions">
<el-button size="small" text type="primary" @click="copyImage(item)">复制图片</el-button>
<el-button size="small" text @click="download(item)">下载</el-button>
<el-button size="small" text type="danger" @click="onDelete(item)">删除</el-button>
</div>
</div>
</el-card>
</div>
<div v-if="total > pageSize" class="pagination">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="prev, pager, next, jumper, ->, total"
@current-change="onPageChange"
/>
<div class="page-hint app-muted"> {{ currentPage }} / {{ totalPages }} </div>
</div>
</template>
<el-dialog v-model="previewOpen" :title="previewTitle" width="min(920px, 94vw)">
<div class="preview">
<img :src="previewUrl" :alt="previewTitle" class="preview-img" />
</div>
<template #footer>
<el-button @click="previewOpen = false">关闭</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.panel {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.panel-title {
font-size: 16px;
font-weight: 900;
}
.panel-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
align-items: start;
}
.pagination {
margin-top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.page-hint {
font-size: 12px;
}
.shot-card {
border-radius: 14px;
border: 1px solid var(--app-border);
overflow: hidden;
}
.shot-img {
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
cursor: pointer;
display: block;
}
.shot-body {
padding: 12px;
}
.shot-name {
font-size: 13px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.shot-meta {
margin-top: 4px;
font-size: 12px;
}
.shot-actions {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.preview {
display: flex;
justify-content: center;
}
.preview-img {
max-width: 100%;
max-height: 78vh;
object-fit: contain;
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
@media (max-width: 480px) {
.grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.panel-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>

View File

@@ -0,0 +1,157 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const success = ref(false)
const title = ref('')
const message = ref('')
const primaryLabel = ref('')
const primaryUrl = ref('')
const secondaryLabel = ref('')
const secondaryUrl = ref('')
const redirectUrl = ref('')
const secondsLeft = ref(0)
let countdownTimer = null
function loadInitialState() {
if (typeof window === 'undefined') return null
const state = window.__APP_INITIAL_STATE__
if (!state || typeof state !== 'object') return null
window.__APP_INITIAL_STATE__ = null
return state
}
function normalize(state) {
const ok = Boolean(state?.success)
success.value = ok
title.value = state?.title || (ok ? '验证成功' : '验证失败')
message.value =
state?.message || state?.error_message || (ok ? '操作已完成,现在可以继续使用系统。' : '操作失败,请稍后重试。')
primaryLabel.value = state?.primary_label || (ok ? '立即登录' : '重新注册')
primaryUrl.value = state?.primary_url || (ok ? '/login' : '/register')
secondaryLabel.value = state?.secondary_label || (ok ? '' : '返回登录')
secondaryUrl.value = state?.secondary_url || (ok ? '' : '/login')
redirectUrl.value = state?.redirect_url || (ok ? '/login' : '')
secondsLeft.value = Number(state?.redirect_seconds || (ok ? 5 : 0)) || 0
}
const hasSecondary = computed(() => Boolean(secondaryLabel.value && secondaryUrl.value))
const hasCountdown = computed(() => Boolean(redirectUrl.value && secondsLeft.value > 0))
async function go(url) {
if (!url) return
if (url.startsWith('http://') || url.startsWith('https://')) {
window.location.href = url
return
}
await router.push(url)
}
function startCountdown() {
if (!hasCountdown.value) return
countdownTimer = window.setInterval(() => {
secondsLeft.value -= 1
if (secondsLeft.value <= 0) {
window.clearInterval(countdownTimer)
countdownTimer = null
window.location.href = redirectUrl.value
}
}, 1000)
}
onMounted(() => {
const state = loadInitialState()
normalize(state)
startCountdown()
})
onBeforeUnmount(() => {
if (countdownTimer) window.clearInterval(countdownTimer)
})
</script>
<template>
<div class="auth-wrap">
<el-card shadow="never" class="auth-card" :body-style="{ padding: '22px' }">
<div class="brand">
<div class="brand-title">知识管理平台</div>
<div class="brand-sub app-muted">验证结果</div>
</div>
<el-result
:icon="success ? 'success' : 'error'"
:title="title"
:sub-title="message"
class="result"
>
<template #extra>
<div class="actions">
<el-button type="primary" @click="go(primaryUrl)">{{ primaryLabel }}</el-button>
<el-button v-if="hasSecondary" @click="go(secondaryUrl)">{{ secondaryLabel }}</el-button>
</div>
<div v-if="hasCountdown" class="countdown app-muted">
{{ secondsLeft }} 秒后自动跳转...
</div>
</template>
</el-result>
</el-card>
</div>
</template>
<style scoped>
.auth-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-card {
width: 100%;
max-width: 520px;
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
box-shadow: var(--app-shadow);
}
.brand {
margin-bottom: 14px;
}
.brand-title {
font-size: 18px;
font-weight: 900;
}
.brand-sub {
margin-top: 4px;
font-size: 12px;
}
.result {
padding: 8px 0 2px;
}
.actions {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
.countdown {
margin-top: 10px;
text-align: center;
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,38 @@
import { createRouter, createWebHistory } from 'vue-router'
const LoginPage = () => import('../pages/LoginPage.vue')
const RegisterPage = () => import('../pages/RegisterPage.vue')
const ResetPasswordPage = () => import('../pages/ResetPasswordPage.vue')
const VerifyResultPage = () => import('../pages/VerifyResultPage.vue')
const AppLayout = () => import('../layouts/AppLayout.vue')
const AccountsPage = () => import('../pages/AccountsPage.vue')
const SchedulesPage = () => import('../pages/SchedulesPage.vue')
const ScreenshotsPage = () => import('../pages/ScreenshotsPage.vue')
const routes = [
{ path: '/', redirect: '/login' },
{ path: '/login', name: 'login', component: LoginPage },
{ path: '/register', name: 'register', component: RegisterPage },
{ path: '/reset-password/:token', name: 'reset_password', component: ResetPasswordPage },
{ path: '/api/verify-email/:token', name: 'verify_email', component: VerifyResultPage },
{ path: '/api/verify-bind-email/:token', name: 'verify_bind_email', component: VerifyResultPage },
{
path: '/app',
component: AppLayout,
children: [
{ path: '', redirect: '/app/accounts' },
{ path: 'accounts', name: 'accounts', component: AccountsPage },
{ path: 'schedules', name: 'schedules', component: SchedulesPage },
{ path: 'screenshots', name: 'screenshots', component: ScreenshotsPage },
],
},
{ path: '/:pathMatch(.*)*', redirect: '/login' },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router

View File

@@ -0,0 +1,34 @@
import { defineStore } from 'pinia'
import { fetchVipInfo as apiFetchVipInfo, logout as apiLogout } from '../api/user'
export const useUserStore = defineStore('user', {
state: () => ({
vipInfo: null,
loading: false,
}),
getters: {
username: (state) => state.vipInfo?.username || '',
isVip: (state) => Boolean(state.vipInfo?.is_vip),
vipDaysLeft: (state) => Number(state.vipInfo?.days_left || 0),
vipExpireTime: (state) => state.vipInfo?.expire_time || '',
},
actions: {
async refreshVipInfo() {
this.loading = true
try {
this.vipInfo = await apiFetchVipInfo()
} finally {
this.loading = false
}
},
async logout() {
try {
await apiLogout()
} catch {
// ignore
}
},
},
})

View File

@@ -0,0 +1,75 @@
:root {
--app-bg: #f6f7fb;
--app-text: #111827;
--app-muted: #6b7280;
--app-border: rgba(17, 24, 39, 0.08);
--app-radius: 12px;
--app-shadow: 0 8px 24px rgba(17, 24, 39, 0.06);
/* Element Plus: switch 在白底/禁用态下更易辨识 */
--el-switch-off-color: var(--el-border-color-darker);
--el-switch-border-color: var(--el-border-color-darker);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
background: var(--app-bg);
color: var(--app-text);
}
a {
color: inherit;
text-decoration: none;
}
.app-muted {
color: var(--app-muted);
}
/* Element Plus: 关闭态inline-prompt文字不再是白色避免与浅灰底色“融为一体” */
.el-switch:not(.is-checked) .el-switch__core .el-switch__inner .is-icon,
.el-switch:not(.is-checked) .el-switch__core .el-switch__inner .is-text {
color: var(--el-text-color-regular);
}
/* Element Plus: 禁用态开关默认 0.6 透明度太淡,白底下容易看不见 */
.el-switch.is-disabled {
opacity: 1;
}
@media (max-width: 768px) {
.el-dialog {
max-width: 92vw;
}
.el-form-item {
flex-direction: column;
align-items: stretch;
}
.el-form-item__label {
width: auto !important;
justify-content: flex-start !important;
padding: 0 0 6px !important;
line-height: 1.4;
text-align: left !important;
}
.el-form-item__content {
margin-left: 0 !important;
width: 100%;
}
}

View File

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

View File

@@ -0,0 +1,7 @@
export function validateStrongPassword(value) {
const text = String(value || '')
if (text.length < 8) return { ok: false, message: '密码长度至少8位' }
if (!/[a-zA-Z]/.test(text) || !/\d/.test(text)) return { ok: false, message: '密码必须包含字母和数字' }
return { ok: true, message: '' }
}

View File

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

5660
app.py Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -9,10 +9,14 @@ import os
import re
import time
import hashlib
import hmac
import secrets
import ipaddress
import socket
from pathlib import Path
from typing import Optional
from functools import wraps
from urllib.parse import urlparse
from flask import request, jsonify, session
from collections import defaultdict
import threading
@@ -75,7 +79,13 @@ def sanitize_filename(filename):
class IPRateLimiter:
"""IP访问频率限制器"""
def __init__(self, max_attempts=10, window_seconds=3600, lock_duration=3600):
def __init__(
self,
max_attempts=10,
window_seconds=3600,
lock_duration=3600,
max_tracked_ips=20000,
):
"""
初始化限流器
@@ -87,6 +97,7 @@ class IPRateLimiter:
self.max_attempts = max_attempts
self.window_seconds = window_seconds
self.lock_duration = lock_duration
self.max_tracked_ips = max(1000, int(max_tracked_ips or 0))
# IP访问记录: {ip: [(timestamp, success), ...]}
self._attempts = defaultdict(list)
@@ -94,6 +105,47 @@ class IPRateLimiter:
self._locked = {}
self._lock = threading.Lock()
def _prune_if_oversized(self, now_ts: float) -> None:
"""限制内部映射大小避免在高频随机IP攻击下持续膨胀。"""
tracked = len(self._attempts) + len(self._locked)
if tracked <= self.max_tracked_ips:
return
cutoff_time = now_ts - self.window_seconds
for ip in list(self._attempts.keys()):
self._attempts[ip] = [
(ts, succ) for ts, succ in self._attempts[ip]
if ts > cutoff_time
]
if not self._attempts[ip]:
del self._attempts[ip]
for ip in list(self._locked.keys()):
if now_ts >= self._locked[ip]:
del self._locked[ip]
tracked = len(self._attempts) + len(self._locked)
if tracked <= self.max_tracked_ips:
return
# 优先按“最近访问时间最早”淘汰 attempts 中的 IP 记录。
overflow = tracked - self.max_tracked_ips
oldest = []
for ip, attempt_items in self._attempts.items():
if attempt_items:
oldest.append((attempt_items[-1][0], ip))
else:
oldest.append((0.0, ip))
oldest.sort(key=lambda item: item[0])
removed = 0
for _, ip in oldest:
self._attempts.pop(ip, None)
self._locked.pop(ip, None)
removed += 1
if removed >= overflow:
break
def is_locked(self, ip_address):
"""
检查IP是否被锁定
@@ -126,6 +178,7 @@ class IPRateLimiter:
"""
with self._lock:
now = time.time()
self._prune_if_oversized(now)
# 清理过期记录
cutoff_time = now - self.window_seconds
@@ -194,18 +247,43 @@ class IPRateLimiter:
# 全局IP限流器实例
ip_rate_limiter = IPRateLimiter()
_TRUTHY_VALUES = {"1", "true", "yes", "on"}
_TRUST_PROXY_HEADERS = str(os.environ.get("TRUST_PROXY_HEADERS", "false") or "").strip().lower() in _TRUTHY_VALUES
def require_ip_not_locked(f):
"""装饰器检查IP是否被锁定"""
@wraps(f)
def decorated_function(*args, **kwargs):
ip_address = request.remote_addr
ip_address = get_rate_limit_ip()
if ip_rate_limiter.is_locked(ip_address):
return jsonify({
"error": "由于多次失败尝试您的IP已被临时锁定",
"locked_until": ip_rate_limiter._locked.get(ip_address, 0)
}), 429
# P0 / O-01统一使用 services.state 的线程安全限流状态
try:
from services.state import check_ip_rate_limit, safe_get_ip_lock_until
allowed, error_msg = check_ip_rate_limit(ip_address)
if not allowed:
return (
jsonify(
{
"error": error_msg or "由于多次失败尝试您的IP已被临时锁定",
"locked_until": safe_get_ip_lock_until(ip_address),
}
),
429,
)
except Exception:
# 兜底:沿用旧实现(避免极端情况下阻断业务)
if ip_rate_limiter.is_locked(ip_address):
return (
jsonify(
{
"error": "由于多次失败尝试您的IP已被临时锁定",
"locked_until": ip_rate_limiter._locked.get(ip_address, 0),
}
),
429,
)
return f(*args, **kwargs)
@@ -329,7 +407,19 @@ def generate_csrf_token():
def validate_csrf_token(token):
"""验证CSRF令牌"""
return token == session.get('csrf_token')
expected = session.get("csrf_token")
if (token is None) or (expected is None):
return False
provided_text = str(token or "")
expected_text = str(expected or "")
if (not provided_text) or (not expected_text):
return False
return hmac.compare_digest(
provided_text.encode("utf-8"),
expected_text.encode("utf-8"),
)
# ==================== 内容安全 ====================
@@ -418,7 +508,7 @@ def get_client_ip(trust_proxy=False):
"""
# 安全说明X-Forwarded-For 可被伪造
# 仅在确认请求来自可信代理时才使用代理头
if trust_proxy:
if trust_proxy and _TRUST_PROXY_HEADERS:
if request.headers.get('X-Forwarded-For'):
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
elif request.headers.get('X-Real-IP'):
@@ -428,6 +518,125 @@ def get_client_ip(trust_proxy=False):
return request.remote_addr
def _load_trusted_proxy_networks():
"""加载可信代理 CIDR 列表。"""
default_cidrs = "127.0.0.1/32,::1/128"
raw = str(os.environ.get("TRUSTED_PROXY_CIDRS", default_cidrs) or "").strip()
if not raw:
return []
networks = []
for segment in raw.split(","):
cidr_text = str(segment or "").strip()
if not cidr_text:
continue
try:
networks.append(ipaddress.ip_network(cidr_text, strict=False))
except ValueError:
continue
return networks
_TRUSTED_PROXY_NETWORKS = _load_trusted_proxy_networks()
def _parse_ip_address(candidate: str):
try:
return ipaddress.ip_address(str(candidate or "").strip())
except ValueError:
return None
def _is_trusted_proxy_ip(ip_obj) -> bool:
if ip_obj is None:
return False
for network in _TRUSTED_PROXY_NETWORKS:
try:
if ip_obj.version != network.version:
continue
if ip_obj in network:
return True
except Exception:
continue
return False
def _extract_real_ip_from_forwarded_chain() -> str | None:
"""基于 X-Forwarded-For 链反向提取最靠近应用侧的“非代理”来源 IP。"""
forwarded = str(request.headers.get("X-Forwarded-For", "") or "")
candidates = []
for segment in forwarded.split(","):
ip_text = str(segment or "").strip()
ip_obj = _parse_ip_address(ip_text)
if ip_obj is None:
continue
candidates.append((str(ip_obj), ip_obj))
# 若存在 X-Forwarded-For按“从右到左”剥离可信代理。
if candidates:
for ip_text, ip_obj in reversed(candidates):
if _is_trusted_proxy_ip(ip_obj):
continue
return ip_text
return candidates[0][0]
real_ip_text = str(request.headers.get("X-Real-IP", "") or "").strip()
real_ip_obj = _parse_ip_address(real_ip_text)
if real_ip_obj is None:
return None
return str(real_ip_obj)
def get_rate_limit_ip() -> str:
"""在可信代理场景下取真实IP用于限流/风控。"""
remote_addr = request.remote_addr or ""
if not _TRUST_PROXY_HEADERS:
return remote_addr
remote_ip = _parse_ip_address(remote_addr)
if remote_ip is None:
return remote_addr
# 仅当请求来自可信代理时才信任转发头。
if _is_trusted_proxy_ip(remote_ip):
forwarded_real_ip = _extract_real_ip_from_forwarded_chain()
if forwarded_real_ip:
return forwarded_real_ip
return remote_addr
def is_safe_outbound_url(url: str) -> bool:
"""限制向内网/保留地址发起请求降低SSRF风险。"""
try:
parsed = urlparse(str(url or "").strip())
except Exception:
return False
if parsed.scheme not in ("http", "https"):
return False
host = parsed.hostname
if not host:
return False
ips = []
try:
ips = [ipaddress.ip_address(host)]
except ValueError:
try:
infos = socket.getaddrinfo(host, None)
ips = [ipaddress.ip_address(info[4][0]) for info in infos]
except Exception:
return False
for ip in ips:
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved:
return False
return True
if __name__ == '__main__':
# 测试文件路径安全
print("文件路径安全测试:")

View File

@@ -1,328 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
应用状态管理模块
提供线程安全的全局状态管理
"""
import threading
from typing import Tuple
from typing import Dict, Any, Optional
from datetime import datetime, timedelta
from app_logger import get_logger
logger = get_logger('app_state')
class ThreadSafeDict:
"""线程安全的字典包装类"""
def __init__(self):
self._dict = {}
self._lock = threading.RLock()
def get(self, key, default=None):
"""获取值"""
with self._lock:
return self._dict.get(key, default)
def set(self, key, value):
"""设置值"""
with self._lock:
self._dict[key] = value
def delete(self, key):
"""删除键"""
with self._lock:
if key in self._dict:
del self._dict[key]
def pop(self, key, default=None):
"""弹出键值"""
with self._lock:
return self._dict.pop(key, default)
def keys(self):
"""获取所有键(返回副本)"""
with self._lock:
return list(self._dict.keys())
def items(self):
"""获取所有键值对(返回副本)"""
with self._lock:
return list(self._dict.items())
def __contains__(self, key):
"""检查键是否存在"""
with self._lock:
return key in self._dict
def clear(self):
"""清空字典"""
with self._lock:
self._dict.clear()
def __len__(self):
"""获取长度"""
with self._lock:
return len(self._dict)
class LogCacheManager:
"""日志缓存管理器(线程安全)"""
def __init__(self, max_logs_per_user=100, max_total_logs=1000):
self._cache = {} # {user_id: [logs]}
self._total_count = 0
self._lock = threading.RLock()
self._max_logs_per_user = max_logs_per_user
self._max_total_logs = max_total_logs
def add_log(self, user_id: int, log_entry: Dict[str, Any]) -> bool:
"""添加日志到缓存"""
with self._lock:
# 检查总数限制
if self._total_count >= self._max_total_logs:
logger.warning(f"日志缓存已满 ({self._max_total_logs}),拒绝添加")
return False
# 初始化用户日志列表
if user_id not in self._cache:
self._cache[user_id] = []
user_logs = self._cache[user_id]
# 检查用户日志数限制
if len(user_logs) >= self._max_logs_per_user:
# 移除最旧的日志
user_logs.pop(0)
self._total_count -= 1
# 添加新日志
user_logs.append(log_entry)
self._total_count += 1
return True
def get_logs(self, user_id: int) -> list:
"""获取用户的所有日志(返回副本)"""
with self._lock:
return list(self._cache.get(user_id, []))
def clear_user_logs(self, user_id: int):
"""清空用户的日志"""
with self._lock:
if user_id in self._cache:
count = len(self._cache[user_id])
del self._cache[user_id]
self._total_count -= count
logger.info(f"清空用户 {user_id}{count} 条日志")
def get_total_count(self) -> int:
"""获取总日志数"""
with self._lock:
return self._total_count
def get_stats(self) -> Dict[str, int]:
"""获取统计信息"""
with self._lock:
return {
'total_count': self._total_count,
'user_count': len(self._cache),
'max_per_user': self._max_logs_per_user,
'max_total': self._max_total_logs
}
class CaptchaManager:
"""验证码管理器(线程安全)"""
def __init__(self, expire_seconds=300):
self._storage = {} # {identifier: {'code': str, 'expire': datetime}}
self._lock = threading.RLock()
self._expire_seconds = expire_seconds
def create(self, identifier: str, code: str) -> None:
"""创建验证码"""
with self._lock:
self._storage[identifier] = {
'code': code,
'expire': datetime.now() + timedelta(seconds=self._expire_seconds)
}
def verify(self, identifier: str, code: str) -> Tuple[bool, str]:
"""验证验证码"""
with self._lock:
if identifier not in self._storage:
return False, "验证码不存在或已过期"
captcha_data = self._storage[identifier]
# 检查是否过期
if datetime.now() > captcha_data['expire']:
del self._storage[identifier]
return False, "验证码已过期,请重新获取"
# 验证码码值
if captcha_data['code'] != code:
return False, "验证码错误"
# 验证成功,删除验证码
del self._storage[identifier]
return True, "验证成功"
def cleanup_expired(self) -> int:
"""清理过期的验证码"""
with self._lock:
now = datetime.now()
expired_keys = [
key for key, data in self._storage.items()
if now > data['expire']
]
for key in expired_keys:
del self._storage[key]
if expired_keys:
logger.info(f"清理了 {len(expired_keys)} 个过期验证码")
return len(expired_keys)
def get_count(self) -> int:
"""获取当前验证码数量"""
with self._lock:
return len(self._storage)
class ApplicationState:
"""应用全局状态管理器(单例模式)"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
# 浏览器管理器
self.browser_manager = None
self._browser_lock = threading.Lock()
# 用户账号管理 {user_id: {account_id: Account对象}}
self.user_accounts = ThreadSafeDict()
# 活动任务管理 {account_id: Thread对象}
self.active_tasks = ThreadSafeDict()
# 日志缓存管理
self.log_cache = LogCacheManager()
# 验证码管理
self.captcha = CaptchaManager()
# 用户信号量管理 {account_id: Semaphore}
self.user_semaphores = ThreadSafeDict()
# 全局信号量
self.global_semaphore = None
self.screenshot_semaphore = threading.Semaphore(1)
self._initialized = True
logger.info("应用状态管理器初始化完成")
def set_browser_manager(self, manager):
"""设置浏览器管理器"""
with self._browser_lock:
self.browser_manager = manager
def get_browser_manager(self):
"""获取浏览器管理器"""
with self._browser_lock:
return self.browser_manager
def get_user_semaphore(self, account_id: int, max_concurrent: int = 1):
"""获取或创建用户信号量"""
if account_id not in self.user_semaphores:
self.user_semaphores.set(account_id, threading.Semaphore(max_concurrent))
return self.user_semaphores.get(account_id)
def set_global_semaphore(self, max_concurrent: int):
"""设置全局信号量"""
self.global_semaphore = threading.Semaphore(max_concurrent)
def get_stats(self) -> Dict[str, Any]:
"""获取状态统计信息"""
return {
'user_accounts_count': len(self.user_accounts),
'active_tasks_count': len(self.active_tasks),
'log_cache_stats': self.log_cache.get_stats(),
'captcha_count': self.captcha.get_count(),
'user_semaphores_count': len(self.user_semaphores),
'browser_manager': 'initialized' if self.browser_manager else 'not_initialized'
}
# 全局单例实例
app_state = ApplicationState()
# 向后兼容的辅助函数
def verify_captcha(identifier: str, code: str) -> Tuple[bool, str]:
"""验证验证码(向后兼容接口)"""
return app_state.captcha.verify(identifier, code)
def create_captcha(identifier: str, code: str) -> None:
"""创建验证码(向后兼容接口)"""
app_state.captcha.create(identifier, code)
def cleanup_expired_captchas() -> int:
"""清理过期验证码(向后兼容接口)"""
return app_state.captcha.cleanup_expired()
if __name__ == '__main__':
# 测试代码
print("测试线程安全状态管理器...")
print("=" * 60)
# 测试 ThreadSafeDict
print("\n1. 测试 ThreadSafeDict:")
td = ThreadSafeDict()
td.set('key1', 'value1')
print(f" 设置 key1 = {td.get('key1')}")
print(f" 长度: {len(td)}")
# 测试 LogCacheManager
print("\n2. 测试 LogCacheManager:")
lcm = LogCacheManager(max_logs_per_user=3, max_total_logs=10)
for i in range(5):
lcm.add_log(1, {'message': f'log {i}'})
print(f" 用户1日志数: {len(lcm.get_logs(1))}")
print(f" 总日志数: {lcm.get_total_count()}")
print(f" 统计: {lcm.get_stats()}")
# 测试 CaptchaManager
print("\n3. 测试 CaptchaManager:")
cm = CaptchaManager(expire_seconds=2)
cm.create('test@example.com', '1234')
success, msg = cm.verify('test@example.com', '1234')
print(f" 验证结果: {success}, {msg}")
# 测试 ApplicationState
print("\n4. 测试 ApplicationState (单例):")
state1 = ApplicationState()
state2 = ApplicationState()
print(f" 单例验证: {state1 is state2}")
print(f" 状态统计: {state1.get_stats()}")
print("\n" + "=" * 60)
print("✓ 所有测试通过!")

View File

@@ -1,366 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
应用工具模块
提取重复的业务逻辑
"""
from typing import Dict, Any, Optional, Tuple
from flask import session, jsonify
from app_logger import get_logger, audit_logger
from app_security import get_client_ip
import database
logger = get_logger('app_utils')
class ValidationError(Exception):
"""验证错误异常"""
pass
def verify_user_file_permission(user_id: int, filename: str) -> Tuple[bool, Optional[str]]:
"""
验证用户文件访问权限
Args:
user_id: 用户ID
filename: 文件名
Returns:
(是否有权限, 错误消息)
"""
# 获取用户信息
user = database.get_user_by_id(user_id)
if not user:
return False, "用户不存在"
username = user['username']
# 检查文件名是否以用户名开头
if not filename.startswith(f"{username}_"):
logger.warning(f"用户 {username} (ID:{user_id}) 尝试访问未授权文件: {filename}")
return False, "无权访问此文件"
return True, None
def log_task_event(account_id: int, status: str, message: str,
browse_type: Optional[str] = None,
screenshot_path: Optional[str] = None) -> bool:
"""
记录任务日志(统一接口)
Args:
account_id: 账号ID
status: 状态running/completed/failed/stopped
message: 消息
browse_type: 浏览类型
screenshot_path: 截图路径
Returns:
是否成功
"""
try:
return database.create_task_log(
account_id=account_id,
status=status,
message=message,
browse_type=browse_type,
screenshot_path=screenshot_path
)
except Exception as e:
logger.error(f"记录任务日志失败: {e}", exc_info=True)
return False
def update_account_status(account_id: int, status: str,
error_message: Optional[str] = None) -> bool:
"""
更新账号状态(统一接口)
Args:
account_id: 账号ID
status: 状态idle/running/error/stopped
error_message: 错误消息仅当status=error时
Returns:
是否成功
"""
try:
return database.update_account_status(
account_id=account_id,
status=status,
error_message=error_message
)
except Exception as e:
logger.error(f"更新账号状态失败 (account_id={account_id}): {e}", exc_info=True)
return False
def get_or_create_config_cache() -> Optional[Dict[str, Any]]:
"""
获取或创建系统配置缓存
缓存存储在session中避免重复查询数据库
Returns:
配置字典失败返回None
"""
# 尝试从session获取缓存
if '_system_config' in session:
return session['_system_config']
# 从数据库加载
try:
config = database.get_system_config()
if config:
# 存入session缓存
session['_system_config'] = config
return config
return None
except Exception as e:
logger.error(f"获取系统配置失败: {e}", exc_info=True)
return None
def clear_config_cache():
"""清除配置缓存(配置变更时调用)"""
if '_system_config' in session:
del session['_system_config']
logger.debug("已清除系统配置缓存")
def safe_close_browser(automation_obj, account_id: int):
"""
安全关闭浏览器(统一错误处理)
Args:
automation_obj: PlaywrightAutomation对象
account_id: 账号ID
"""
if automation_obj:
try:
automation_obj.close()
logger.info(f"账号 {account_id} 的浏览器已关闭")
except Exception as e:
logger.error(f"关闭账号 {account_id} 的浏览器失败: {e}", exc_info=True)
def format_error_response(error: str, status_code: int = 400,
need_captcha: bool = False,
extra_data: Optional[Dict] = None) -> Tuple[Any, int]:
"""
格式化错误响应(统一接口)
Args:
error: 错误消息
status_code: HTTP状态码
need_captcha: 是否需要验证码
extra_data: 额外数据
Returns:
(jsonify响应, 状态码)
"""
response_data = {"error": error}
if need_captcha:
response_data["need_captcha"] = True
if extra_data:
response_data.update(extra_data)
return jsonify(response_data), status_code
def format_success_response(message: str = "操作成功",
extra_data: Optional[Dict] = None) -> Any:
"""
格式化成功响应(统一接口)
Args:
message: 成功消息
extra_data: 额外数据
Returns:
jsonify响应
"""
response_data = {"success": True, "message": message}
if extra_data:
response_data.update(extra_data)
return jsonify(response_data)
def log_user_action(action: str, user_id: int, username: str,
success: bool, details: Optional[str] = None):
"""
记录用户操作到审计日志(统一接口)
Args:
action: 操作类型login/register/logout等
user_id: 用户ID
username: 用户名
success: 是否成功
details: 详细信息
"""
ip = get_client_ip()
if action == 'login':
audit_logger.log_user_login(user_id, username, ip, success)
elif action == 'logout':
audit_logger.log_user_logout(user_id, username, ip)
elif action == 'register':
audit_logger.log_user_created(user_id, username, created_by='self')
if details:
logger.info(f"用户操作: {action}, 用户={username}, 成功={success}, 详情={details}")
def validate_pagination(page: Any, page_size: Any,
max_page_size: int = 100) -> Tuple[int, int, Optional[str]]:
"""
验证分页参数
Args:
page: 页码
page_size: 每页大小
max_page_size: 最大每页大小
Returns:
(页码, 每页大小, 错误消息)
"""
try:
page = int(page) if page else 1
page_size = int(page_size) if page_size else 20
except (ValueError, TypeError):
return 1, 20, "无效的分页参数"
if page < 1:
return 1, 20, "页码必须大于0"
if page_size < 1 or page_size > max_page_size:
return page, 20, f"每页大小必须在1-{max_page_size}之间"
return page, page_size, None
def check_user_ownership(user_id: int, resource_type: str,
resource_id: int) -> Tuple[bool, Optional[str]]:
"""
检查用户是否拥有资源
Args:
user_id: 用户ID
resource_type: 资源类型account/task等
resource_id: 资源ID
Returns:
(是否拥有, 错误消息)
"""
try:
if resource_type == 'account':
account = database.get_account_by_id(resource_id)
if not account:
return False, "账号不存在"
if account['user_id'] != user_id:
return False, "无权访问此账号"
return True, None
elif resource_type == 'task':
# 通过account查询所属用户
# 这里需要根据实际数据库结构实现
pass
return False, "不支持的资源类型"
except Exception as e:
logger.error(f"检查资源所有权失败: {e}", exc_info=True)
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
# 安全修复:先取出并删除验证码,无论验证是否成功都不能重用
captcha_data = captcha_storage.pop(session_id, None)
# 检查验证码是否存在
if captcha_data is None:
return False, "验证码已过期或不存在,请重新获取"
try:
# 检查过期时间
if captcha_data["expire_time"] < time.time():
return False, "验证码已过期,请重新获取"
# 检查尝试次数
if captcha_data.get("failed_attempts", 0) >= max_attempts:
return False, f"验证码错误次数过多({max_attempts}次),请重新获取"
# 验证代码(不区分大小写)
if captcha_data["code"].lower() != code.lower():
# 验证失败,增加失败计数后放回(允许继续尝试)
captcha_data["failed_attempts"] = captcha_data.get("failed_attempts", 0) + 1
# 只有未超过最大尝试次数才放回
if captcha_data["failed_attempts"] < max_attempts:
captcha_storage[session_id] = captcha_data
return False, "验证码错误"
# 验证成功,验证码已被删除,不会被重用
return True, "验证成功"
except Exception as e:
# 异常情况下确保验证码不会被重用(已在函数开头删除)
logger.error(f"验证码验证异常: {e}")
return False, "验证码验证失败,请重新获取"
if __name__ == '__main__':
# 测试代码
print("测试应用工具模块...")
print("=" * 60)
# 测试分页验证
print("\n1. 测试分页验证:")
page, page_size, error = validate_pagination("2", "50")
print(f" 页码={page}, 每页={page_size}, 错误={error}")
page, page_size, error = validate_pagination("invalid", "50")
print(f" 无效输入: 页码={page}, 每页={page_size}, 错误={error}")
# 测试响应格式化
print("\n2. 测试响应格式化:")
print(f" 错误响应: {format_error_response('测试错误', need_captcha=True)}")
print(f" 成功响应: {format_success_response('测试成功', {'data': [1, 2, 3]})}")
print("\n" + "=" * 60)
print("✓ 工具模块加载成功!")

View File

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

View File

@@ -1,160 +0,0 @@
#!/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

View File

@@ -1,192 +1,318 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""浏览器池管理 - 工作线程池模式(真正的浏览器复用"""
import os
import threading
import queue
import time
from typing import Callable, Optional, Dict, Any
import nest_asyncio
nest_asyncio.apply()
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""截图线程池管理 - 工作线程池模式(并发执行截图任务"""
import os
import threading
import queue
import time
from typing import Callable, Optional, Dict, Any
# 安全修复: 将魔法数字提取为可配置常量
BROWSER_IDLE_TIMEOUT = int(os.environ.get('BROWSER_IDLE_TIMEOUT', '300')) # 空闲超时(秒)默认5分钟
TASK_QUEUE_TIMEOUT = int(os.environ.get('TASK_QUEUE_TIMEOUT', '10')) # 队列获取超时(秒)
TASK_QUEUE_MAXSIZE = int(os.environ.get('BROWSER_TASK_QUEUE_MAXSIZE', '200')) # 队列最大长度(0表示无限制)
BROWSER_MAX_USE_COUNT = int(os.environ.get('BROWSER_MAX_USE_COUNT', '0')) # 每个浏览器最大复用次数(0表示不限制)
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启动按需模式等待任务时不占用浏览器资源")
last_task_time = 0
while self.running:
try:
# 从队列获取任务(带超时,以便能响应停止信号和空闲检查)
self.idle = True
try:
task = self.task_queue.get(timeout=TASK_QUEUE_TIMEOUT)
except queue.Empty:
# 检查是否需要关闭空闲的浏览器
if self.browser_instance and last_task_time > 0:
idle_time = time.time() - last_task_time
if idle_time > BROWSER_IDLE_TIMEOUT:
self.log(f"空闲{int(idle_time)}秒,关闭浏览器释放资源")
self._close_browser()
continue
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']}次使用浏览器")
BROWSER_IDLE_TIMEOUT = int(os.environ.get("BROWSER_IDLE_TIMEOUT", "300")) # 空闲超时(秒)默认5分钟
TASK_QUEUE_TIMEOUT = int(os.environ.get("TASK_QUEUE_TIMEOUT", "10")) # 队列获取超时(秒)
TASK_QUEUE_MAXSIZE = int(os.environ.get("BROWSER_TASK_QUEUE_MAXSIZE", "200")) # 队列最大长度(0表示无限制)
BROWSER_MAX_USE_COUNT = int(os.environ.get("BROWSER_MAX_USE_COUNT", "0")) # 每个执行环境最大复用次数(0表示不限制)
# 新增:自适应资源配置
ADAPTIVE_CONFIG = os.environ.get("BROWSER_ADAPTIVE_CONFIG", "1").strip().lower() in ("1", "true", "yes", "on")
LOAD_HISTORY_SIZE = 50 # 负载历史记录大小
class AdaptiveResourceManager:
"""自适应资源管理器"""
def __init__(self):
self._load_history = []
self._current_load = 0
self._last_adjustment = 0
self._adjustment_cooldown = 30 # 调整冷却时间30秒
def record_task_interval(self, interval: float):
"""记录任务间隔,更新负载历史"""
if len(self._load_history) >= LOAD_HISTORY_SIZE:
self._load_history.pop(0)
self._load_history.append(interval)
# 计算当前负载
if len(self._load_history) >= 2:
recent_intervals = self._load_history[-10:] # 最近10个任务
avg_interval = sum(recent_intervals) / len(recent_intervals)
# 负载越高,间隔越短
self._current_load = 1.0 / max(avg_interval, 0.1)
def should_adjust_timeout(self) -> bool:
"""判断是否应该调整超时配置"""
if not ADAPTIVE_CONFIG:
return False
current_time = time.time()
if current_time - self._last_adjustment < self._adjustment_cooldown:
return False
return len(self._load_history) >= 10 # 至少需要10个数据点
def calculate_optimal_idle_timeout(self) -> int:
"""基于历史负载计算最优空闲超时"""
if not self._load_history:
return BROWSER_IDLE_TIMEOUT
# 计算最近任务间隔的平均值
recent_intervals = self._load_history[-20:] # 最近20个任务
if len(recent_intervals) < 2:
return BROWSER_IDLE_TIMEOUT
avg_interval = sum(recent_intervals) / len(recent_intervals)
# 根据负载动态调整超时
# 高负载时缩短超时,低负载时延长超时
if self._current_load > 2.0: # 高负载
optimal_timeout = min(avg_interval * 1.5, 600) # 最多10分钟
elif self._current_load < 0.5: # 低负载
optimal_timeout = min(avg_interval * 3.0, 1800) # 最多30分钟
else: # 正常负载
optimal_timeout = min(avg_interval * 2.0, 900) # 最多15分钟
return max(int(optimal_timeout), 60) # 最少1分钟
def get_optimal_queue_timeout(self) -> int:
"""获取最优队列超时"""
if not self._load_history:
return TASK_QUEUE_TIMEOUT
# 根据任务频率调整队列超时
if self._current_load > 2.0: # 高负载时减少等待
return max(TASK_QUEUE_TIMEOUT // 2, 3)
elif self._current_load < 0.5: # 低负载时可以增加等待
return min(TASK_QUEUE_TIMEOUT * 2, 30)
else:
return TASK_QUEUE_TIMEOUT
def record_adjustment(self):
"""记录一次调整操作"""
self._last_adjustment = time.time()
class BrowserWorker(threading.Thread):
"""截图工作线程 - 每个worker维护自己的执行环境"""
def __init__(
self,
worker_id: int,
task_queue: queue.Queue,
log_callback: Optional[Callable] = None,
pre_warm: bool = False,
):
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
self.pre_warm = pre_warm
self.last_activity_ts = 0.0
self.task_start_time = 0.0
# 初始化自适应资源管理器
if ADAPTIVE_CONFIG:
self._adaptive_mgr = AdaptiveResourceManager()
else:
self._adaptive_mgr = None
def log(self, message: str):
"""日志输出"""
if self.log_callback:
self.log_callback(f"[Worker-{self.worker_id}] {message}")
else:
print(f"[截图池][Worker-{self.worker_id}] {message}")
def _create_browser(self):
"""创建截图执行环境(逻辑占位,无需真实浏览器)"""
created_at = time.time()
self.browser_instance = {
"created_at": created_at,
"use_count": 0,
"worker_id": self.worker_id,
}
self.last_activity_ts = created_at
self.log("截图执行环境就绪")
return True
def _close_browser(self):
"""关闭截图执行环境"""
if self.browser_instance:
self.log(f"执行环境已释放(共处理{self.browser_instance.get('use_count', 0)}个任务")
self.browser_instance = None
def _check_browser_health(self) -> bool:
"""检查执行环境是否就绪"""
return bool(self.browser_instance)
def _ensure_browser(self) -> bool:
"""确保执行环境可用"""
if self._check_browser_health():
return True
self.log("执行环境不可用,尝试重新创建...")
self._close_browser()
return self._create_browser()
def run(self):
"""工作线程主循环 - 按需启动执行环境模式"""
if self.pre_warm:
self.log("Worker启动预热模式启动即准备执行环境")
else:
self.log("Worker启动按需模式等待任务时不占用资源")
if self.pre_warm and not self.browser_instance:
self._create_browser()
self.pre_warm = False
while self.running:
try:
# 允许运行中触发预热(例如池在初始化后调用 warmup
if self.pre_warm and not self.browser_instance:
self._create_browser()
self.pre_warm = False
# 从队列获取任务(带超时,以便能响应停止信号和空闲检查)
self.idle = True
# 使用自适应队列超时
queue_timeout = (
self._adaptive_mgr.get_optimal_queue_timeout() if self._adaptive_mgr else TASK_QUEUE_TIMEOUT
)
try:
# 将浏览器实例传递给任务函数
task = self.task_queue.get(timeout=queue_timeout)
except queue.Empty:
# 检查是否需要释放空闲的执行环境
if self.browser_instance and self.last_activity_ts > 0:
idle_time = time.time() - self.last_activity_ts
# 使用自适应空闲超时
optimal_timeout = (
self._adaptive_mgr.calculate_optimal_idle_timeout()
if self._adaptive_mgr
else BROWSER_IDLE_TIMEOUT
)
if idle_time > optimal_timeout:
self.log(f"空闲{int(idle_time)}秒(优化超时:{optimal_timeout}秒),释放执行环境")
self._close_browser()
continue
self.idle = False
if task is None: # None作为停止信号
self.log("收到停止信号")
break
# 按需创建或确保执行环境可用
browser_ready = False
for attempt in range(2):
if self._ensure_browser():
browser_ready = True
break
if attempt < 1:
self.log("执行环境创建失败,重试...")
time.sleep(0.5)
if not browser_ready:
retry_count = int(task.get("retry_count", 0) or 0) if isinstance(task, dict) else 0
if retry_count < 1 and isinstance(task, dict):
task["retry_count"] = retry_count + 1
try:
self.task_queue.put(task, timeout=1)
self.log("执行环境不可用,任务重新入队")
except queue.Full:
self.log("任务队列已满,无法重新入队,任务失败")
callback = task.get("callback")
if callable(callback):
callback(None, "执行环境不可用")
self.total_tasks += 1
self.failed_tasks += 1
continue
self.log("执行环境不可用,任务失败")
callback = task.get("callback") if isinstance(task, dict) else None
if callable(callback):
callback(None, "执行环境不可用")
self.total_tasks += 1
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
# 确保browser_instance存在后再访问
if self.browser_instance is None:
self.log("执行环境不可用,任务失败")
if callable(callback):
callback(None, "执行环境不可用")
self.failed_tasks += 1
continue
self.browser_instance["use_count"] += 1
self.log(f"开始执行任务(第{self.browser_instance['use_count']}次执行)")
# 记录任务开始时间
task_start_time = time.time()
try:
# 将执行环境实例传递给任务函数
result = task_func(self.browser_instance, *task_args, **task_kwargs)
callback(result, None)
self.log(f"任务执行成功")
last_task_time = time.time()
# 记录任务完成并更新负载历史
task_end_time = time.time()
task_interval = task_end_time - task_start_time
if self._adaptive_mgr:
self._adaptive_mgr.record_task_interval(task_interval)
self.last_activity_ts = time.time()
except Exception as e:
self.log(f"任务执行失败: {e}")
callback(None, str(e))
self.failed_tasks += 1
last_task_time = time.time()
self.last_activity_ts = time.time()
# 任务失败后,检查浏览器健康
# 任务失败后,检查执行环境健康
if not self._check_browser_health():
self.log("任务失败导致浏览器异常,将在下次任务前重建")
self.log("任务失败导致执行环境异常,将在下次任务前重建")
self._close_browser()
# 定期重启浏览器释放Chromium可能累积的内存
# 定期重启执行环境,释放可能累积的资源
if self.browser_instance and BROWSER_MAX_USE_COUNT > 0:
if self.browser_instance.get('use_count', 0) >= BROWSER_MAX_USE_COUNT:
self.log(f"浏览器已复用{self.browser_instance['use_count']}次,重启释放资源")
if self.browser_instance.get("use_count", 0) >= BROWSER_MAX_USE_COUNT:
self.log(f"执行环境已复用{self.browser_instance['use_count']}次,重启释放资源")
self._close_browser()
except Exception as e:
self.log(f"Worker出错: {e}")
time.sleep(1)
# 清理资源
self._close_browser()
self.log(f"Worker停止总任务:{self.total_tasks}, 失败:{self.failed_tasks}")
def stop(self):
"""停止worker"""
self.running = False
# 清理资源
self._close_browser()
self.log(f"Worker停止总任务:{self.total_tasks}, 失败:{self.failed_tasks}")
def stop(self):
"""停止worker"""
self.running = False
class BrowserWorkerPool:
"""浏览器工作线程池"""
"""截图工作线程池"""
def __init__(self, pool_size: int = 3, log_callback: Optional[Callable] = None):
self.pool_size = pool_size
@@ -196,172 +322,267 @@ class BrowserWorkerPool:
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)
self.initialized = True
self.log(f"✓ 工作线程池初始化完成({self.pool_size}个worker就绪浏览器将在有任务时按需启动")
def log(self, message: str):
"""日志输出"""
if self.log_callback:
self.log_callback(message)
else:
print(f"[截图池] {message}")
def initialize(self):
"""初始化工作线程池(按需模式,默认预热1个执行环境"""
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,
pre_warm=(i < 1),
)
worker.start()
self.workers.append(worker)
self.initialized = True
self.log(f"[OK] 截图线程池初始化完成({self.pool_size}个worker就绪执行环境将在有任务时按需启动")
# 初始化完成后默认预热1个执行环境降低容器重启后前几批任务的冷启动开销
self.warmup(1)
def warmup(self, count: int = 1) -> int:
"""预热截图线程池 - 预创建指定数量的执行环境"""
if count <= 0:
return 0
if not self.initialized:
self.log("警告:线程池未初始化,无法预热")
return 0
with self.lock:
target_workers = list(self.workers[: min(count, len(self.workers))])
self.log(f"预热截图线程池(预创建{len(target_workers)}个执行环境)...")
for worker in target_workers:
if not worker.browser_instance:
worker.pre_warm = True
# 等待预热完成最多等待20秒避免阻塞过久
deadline = time.time() + 20
while time.time() < deadline:
warmed = sum(1 for w in target_workers if w.browser_instance)
if warmed >= len(target_workers):
break
time.sleep(0.1)
warmed = sum(1 for w in target_workers if w.browser_instance)
self.log(f"[OK] 截图线程池预热完成({warmed}个执行环境就绪)")
return warmed
def submit_task(self, task_func: Callable, callback: Callable, *args, **kwargs) -> bool:
"""
提交任务到队列
Args:
task_func: 任务函数,签名为 func(browser_instance, *args, **kwargs)
callback: 回调函数,签名为 callback(result, error)
*args, **kwargs: 传递给task_func的参数
Returns:
是否成功提交
"""
if not self.initialized:
self.log("警告:线程池未初始化")
return False
task = {
'func': task_func,
'args': args,
'kwargs': kwargs,
'callback': callback
}
"""
提交任务到队列
Args:
task_func: 任务函数,签名为 func(browser_instance, *args, **kwargs)
callback: 回调函数,签名为 callback(result, error)
*args, **kwargs: 传递给task_func的参数
Returns:
是否成功提交
"""
if not self.initialized:
self.log("警告:线程池未初始化")
return False
task = {
"func": task_func,
"args": args,
"kwargs": kwargs,
"callback": callback,
"retry_count": 0,
}
try:
self.task_queue.put(task, timeout=1)
return True
except queue.Full:
self.log(f"警告任务队列已满maxsize={self.task_queue.maxsize}),拒绝提交任务")
return False
def get_stats(self) -> Dict[str, Any]:
"""获取线程池统计信息"""
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测试完成!")
def get_stats(self) -> Dict[str, Any]:
"""获取线程池统计信息"""
workers = list(self.workers or [])
idle_count = sum(1 for w in workers if getattr(w, "idle", False))
total_tasks = sum(int(getattr(w, "total_tasks", 0) or 0) for w in workers)
failed_tasks = sum(int(getattr(w, "failed_tasks", 0) or 0) for w in workers)
worker_details = []
for w in workers:
browser_instance = getattr(w, "browser_instance", None)
browser_use_count = 0
browser_created_at = None
if isinstance(browser_instance, dict):
browser_use_count = int(browser_instance.get("use_count", 0) or 0)
browser_created_at = browser_instance.get("created_at")
worker_details.append(
{
"worker_id": getattr(w, "worker_id", None),
"idle": bool(getattr(w, "idle", False)),
"has_browser": bool(browser_instance),
"total_tasks": int(getattr(w, "total_tasks", 0) or 0),
"failed_tasks": int(getattr(w, "failed_tasks", 0) or 0),
"browser_use_count": browser_use_count,
"browser_created_at": browser_created_at,
"last_active_ts": float(getattr(w, "last_activity_ts", 0) or 0),
"thread_alive": bool(getattr(w, "is_alive", lambda: False)()),
}
)
return {
"pool_size": self.pool_size,
"idle_workers": idle_count,
"busy_workers": max(0, len(workers) - idle_count),
"queue_size": self.task_queue.qsize(),
"total_tasks": total_tasks,
"failed_tasks": failed_tasks,
"success_rate": f"{(total_tasks - failed_tasks) / total_tasks * 100:.1f}%" if total_tasks > 0 else "N/A",
"workers": worker_details,
"timestamp": time.time(),
}
def wait_for_completion(self, timeout: Optional[float] = None):
"""等待所有任务完成"""
start_time = time.time()
while not self.task_queue.empty():
if timeout and (time.time() - start_time) > timeout:
self.log("等待超时")
return False
time.sleep(0.5)
# 再等待一下确保正在执行的任务完成
time.sleep(2)
return True
def shutdown(self):
"""关闭线程池"""
self.log("正在关闭工作线程池...")
# 发送停止信号
for _ in self.workers:
self.task_queue.put(None)
# 等待所有worker停止
for worker in self.workers:
worker.join(timeout=10)
self.workers.clear()
self.initialized = False
self.log("[OK] 工作线程池已关闭")
# 全局实例
_global_pool: Optional[BrowserWorkerPool] = None
_pool_lock = threading.Lock()
def 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_pool_when_idle(pool: BrowserWorkerPool) -> None:
try:
pool.wait_for_completion(timeout=60)
except Exception:
pass
try:
pool.shutdown()
except Exception:
pass
def resize_browser_worker_pool(pool_size: int, log_callback: Optional[Callable] = None) -> bool:
"""调整截图线程池并发(新任务走新池,旧池空闲后自动关闭)"""
global _global_pool
try:
target_size = max(1, int(pool_size))
except Exception:
target_size = 1
with _pool_lock:
old_pool = _global_pool
if old_pool and int(getattr(old_pool, "pool_size", 0) or 0) == target_size:
return False
effective_log_callback = log_callback or (getattr(old_pool, "log_callback", None) if old_pool else None)
_global_pool = BrowserWorkerPool(pool_size=target_size, log_callback=effective_log_callback)
_global_pool.initialize()
if old_pool:
threading.Thread(target=_shutdown_pool_when_idle, args=(old_pool,), daemon=True).start()
return True
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测试完成!")

View File

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

2371
database.py Executable file → Normal file

File diff suppressed because it is too large Load Diff

10
db/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DB 包:按领域拆分的 DAO + schema/migrations。
约束:
- 外部仍通过 `import database` 访问稳定 API
- 本包仅提供内部实现与组织结构P2 / O-07
"""

185
db/accounts.py Normal file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import db_pool
from crypto_utils import decrypt_password, encrypt_password
from db.utils import get_cst_now_str
_ACCOUNT_STATUS_QUERY_SQL = """
SELECT status, login_fail_count, last_login_error
FROM accounts
WHERE id = ?
"""
def _decode_account_password(account_dict: dict) -> dict:
account_dict["password"] = decrypt_password(account_dict.get("password", ""))
return account_dict
def _normalize_account_ids(account_ids) -> list[str]:
normalized = []
seen = set()
for account_id in account_ids or []:
if not account_id:
continue
account_key = str(account_id)
if account_key in seen:
continue
seen.add(account_key)
normalized.append(account_key)
return normalized
def create_account(user_id, account_id, username, password, remember=True, remark=""):
"""创建账号(密码加密存储)"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
encrypted_password = encrypt_password(password)
cursor.execute(
"""
INSERT INTO accounts (id, user_id, username, password, remember, remark, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
account_id,
user_id,
username,
encrypted_password,
1 if remember else 0,
remark,
get_cst_now_str(),
),
)
conn.commit()
return cursor.lastrowid
def get_user_accounts(user_id):
"""获取用户的所有账号(自动解密密码)"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM accounts WHERE user_id = ? ORDER BY created_at DESC", (user_id,))
return [_decode_account_password(dict(row)) for row in cursor.fetchall()]
def get_account(account_id):
"""获取单个账号(自动解密密码)"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
row = cursor.fetchone()
if not row:
return None
return _decode_account_password(dict(row))
def update_account_remark(account_id, remark):
"""更新账号备注"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE accounts SET remark = ? WHERE id = ?", (remark, account_id))
conn.commit()
return cursor.rowcount > 0
def delete_account(account_id):
"""删除账号"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM accounts WHERE id = ?", (account_id,))
conn.commit()
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 = int(row["login_fail_count"] or 0) + 1
is_suspended = fail_count >= 3
cursor.execute(
"""
UPDATE accounts
SET login_fail_count = ?,
last_login_error = ?,
status = CASE WHEN ? = 1 THEN 'suspended' ELSE status END
WHERE id = ?
""",
(fail_count, error_message, 1 if is_suspended else 0, account_id),
)
conn.commit()
return is_suspended
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(_ACCOUNT_STATUS_QUERY_SQL, (account_id,))
return cursor.fetchone()
def get_account_status_batch(account_ids):
"""批量获取账号状态信息"""
normalized_ids = _normalize_account_ids(account_ids)
if not normalized_ids:
return {}
results = {}
chunk_size = 900 # 避免触发 SQLite 绑定参数上限
with db_pool.get_db() as conn:
cursor = conn.cursor()
for idx in range(0, len(normalized_ids), chunk_size):
chunk = normalized_ids[idx : idx + chunk_size]
placeholders = ",".join("?" for _ in chunk)
cursor.execute(
f"""
SELECT id, status, login_fail_count, last_login_error
FROM accounts
WHERE id IN ({placeholders})
""",
chunk,
)
for row in cursor.fetchall():
row_dict = dict(row)
account_id = str(row_dict.pop("id", ""))
if account_id:
results[account_id] = row_dict
return results
def delete_user_accounts(user_id):
"""删除用户的所有账号"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM accounts WHERE user_id = ?", (user_id,))
conn.commit()
return cursor.rowcount

427
db/admin.py Normal file
View File

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

161
db/announcements.py Normal file
View File

@@ -0,0 +1,161 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import db_pool
from db.utils import get_cst_now_str
def _normalize_limit(value, default: int, *, minimum: int = 1, maximum: int = 500) -> int:
try:
parsed = int(value)
except Exception:
parsed = default
parsed = max(minimum, parsed)
parsed = min(maximum, parsed)
return parsed
def _normalize_offset(value, default: int = 0) -> int:
try:
parsed = int(value)
except Exception:
parsed = default
return max(0, parsed)
def _normalize_announcement_payload(title, content, image_url):
normalized_title = str(title or "").strip()
normalized_content = str(content or "").strip()
normalized_image = str(image_url or "").strip() or None
return normalized_title, normalized_content, normalized_image
def _deactivate_all_active_announcements(cursor, cst_time: str) -> None:
cursor.execute("UPDATE announcements SET is_active = 0, updated_at = ? WHERE is_active = 1", (cst_time,))
def create_announcement(title, content, image_url=None, is_active=True):
"""创建公告(默认启用;启用时会自动停用其他公告)"""
title, content, image_url = _normalize_announcement_payload(title, content, image_url)
if not title or not content:
return None
with db_pool.get_db() as conn:
cursor = conn.cursor()
cst_time = get_cst_now_str()
if is_active:
_deactivate_all_active_announcements(cursor, cst_time)
cursor.execute(
"""
INSERT INTO announcements (title, content, image_url, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(title, content, image_url, 1 if is_active else 0, cst_time, cst_time),
)
conn.commit()
return cursor.lastrowid
def get_announcement_by_id(announcement_id):
"""根据ID获取公告"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM announcements WHERE id = ?", (announcement_id,))
row = cursor.fetchone()
return dict(row) if row else None
def get_announcements(limit=50, offset=0):
"""获取公告列表(管理员用)"""
safe_limit = _normalize_limit(limit, 50)
safe_offset = _normalize_offset(offset, 0)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT * FROM announcements
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?
""",
(safe_limit, safe_offset),
)
return [dict(row) for row in cursor.fetchall()]
def set_announcement_active(announcement_id, is_active):
"""启用/停用公告;启用时会自动停用其他公告"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cst_time = get_cst_now_str()
if is_active:
_deactivate_all_active_announcements(cursor, cst_time)
cursor.execute(
"""
UPDATE announcements
SET is_active = 1, updated_at = ?
WHERE id = ?
""",
(cst_time, announcement_id),
)
else:
cursor.execute(
"""
UPDATE announcements
SET is_active = 0, updated_at = ?
WHERE id = ?
""",
(cst_time, announcement_id),
)
conn.commit()
return cursor.rowcount > 0
def delete_announcement(announcement_id):
"""删除公告(同时清理用户关闭记录)"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM announcement_dismissals WHERE announcement_id = ?", (announcement_id,))
cursor.execute("DELETE FROM announcements WHERE id = ?", (announcement_id,))
conn.commit()
return cursor.rowcount > 0
def get_active_announcement_for_user(user_id):
"""获取当前用户应展示的启用公告(已永久关闭的不再返回)"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT a.*
FROM announcements a
LEFT JOIN announcement_dismissals d
ON d.announcement_id = a.id AND d.user_id = ?
WHERE a.is_active = 1 AND d.announcement_id IS NULL
ORDER BY a.created_at DESC, a.id DESC
LIMIT 1
""",
(user_id,),
)
row = cursor.fetchone()
return dict(row) if row else None
def dismiss_announcement_for_user(user_id, announcement_id):
"""用户永久关闭某条公告(幂等)"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
INSERT OR IGNORE INTO announcement_dismissals (user_id, announcement_id, dismissed_at)
VALUES (?, ?, ?)
""",
(user_id, announcement_id, get_cst_now_str()),
)
conn.commit()
return cursor.rowcount >= 0

83
db/email.py Normal file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import db_pool
def _to_bool_with_default(value, default: bool = True) -> bool:
if value is None:
return default
try:
return bool(int(value))
except Exception:
try:
return bool(value)
except Exception:
return default
def _normalize_notify_enabled(enabled) -> int:
if isinstance(enabled, bool):
return 1 if enabled else 0
try:
return 1 if int(enabled) else 0
except Exception:
return 1
def get_user_by_email(email):
"""根据邮箱获取用户"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE email = ?", (email,))
user = cursor.fetchone()
return dict(user) if user else None
def update_user_email(user_id, email, verified=False):
"""更新用户邮箱"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
UPDATE users
SET email = ?, email_verified = ?
WHERE id = ?
""",
(email, 1 if verified else 0, user_id),
)
conn.commit()
return cursor.rowcount > 0
def update_user_email_notify(user_id, enabled):
"""更新用户邮件通知偏好"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
UPDATE users
SET email_notify_enabled = ?
WHERE id = ?
""",
(_normalize_notify_enabled(enabled), user_id),
)
conn.commit()
return cursor.rowcount > 0
def get_user_email_notify(user_id):
"""获取用户邮件通知偏好(默认开启)"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
try:
cursor.execute("SELECT email_notify_enabled FROM users WHERE id = ?", (user_id,))
row = cursor.fetchone()
if row is None:
return True
return _to_bool_with_default(row[0], default=True)
except Exception:
return True

178
db/feedbacks.py Normal file
View File

@@ -0,0 +1,178 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import db_pool
from db.utils import escape_html, get_cst_now_str
def _normalize_limit(value, default: int, *, minimum: int = 1, maximum: int = 500) -> int:
try:
parsed = int(value)
except Exception:
parsed = default
parsed = max(minimum, parsed)
parsed = min(maximum, parsed)
return parsed
def _normalize_offset(value, default: int = 0) -> int:
try:
parsed = int(value)
except Exception:
parsed = default
return max(0, parsed)
def _safe_text(value) -> str:
if value is None:
return ""
text = str(value)
return escape_html(text) if text else ""
def _build_feedback_filter_sql(status_filter=None) -> tuple[str, list]:
where_clauses = ["1=1"]
params = []
if status_filter:
where_clauses.append("status = ?")
params.append(status_filter)
return " AND ".join(where_clauses), params
def _normalize_feedback_stats_row(row) -> dict:
row_dict = dict(row) if row else {}
return {
"total": int(row_dict.get("total") or 0),
"pending": int(row_dict.get("pending") or 0),
"replied": int(row_dict.get("replied") or 0),
"closed": int(row_dict.get("closed") or 0),
}
def create_bug_feedback(user_id, username, title, description, contact=""):
"""创建Bug反馈带XSS防护"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO bug_feedbacks (user_id, username, title, description, contact, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
user_id,
_safe_text(username),
_safe_text(title),
_safe_text(description),
_safe_text(contact),
get_cst_now_str(),
),
)
conn.commit()
return cursor.lastrowid
def get_bug_feedbacks(limit=100, offset=0, status_filter=None):
"""获取Bug反馈列表管理员用"""
safe_limit = _normalize_limit(limit, 100, minimum=1, maximum=1000)
safe_offset = _normalize_offset(offset, 0)
with db_pool.get_db() as conn:
cursor = conn.cursor()
where_sql, params = _build_feedback_filter_sql(status_filter=status_filter)
sql = f"""
SELECT * FROM bug_feedbacks
WHERE {where_sql}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
"""
cursor.execute(sql, params + [safe_limit, safe_offset])
return [dict(row) for row in cursor.fetchall()]
def get_user_feedbacks(user_id, limit=50):
"""获取用户自己的反馈列表"""
safe_limit = _normalize_limit(limit, 50, minimum=1, maximum=1000)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT * FROM bug_feedbacks
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT ?
""",
(user_id, safe_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):
"""管理员回复反馈带XSS防护"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
UPDATE bug_feedbacks
SET admin_reply = ?, status = 'replied', replied_at = ?
WHERE id = ?
""",
(_safe_text(admin_reply), get_cst_now_str(), 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
"""
)
return _normalize_feedback_stats_row(cursor.fetchone())

935
db/migrations.py Normal file
View File

@@ -0,0 +1,935 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import sqlite3
from db.utils import get_cst_now_str
def get_current_version(conn) -> int:
cursor = conn.cursor()
cursor.execute("SELECT version FROM db_version WHERE id = 1")
row = cursor.fetchone()
if not row:
return 0
try:
return int(row["version"])
except Exception:
try:
return int(row[0])
except Exception:
return 0
def set_current_version(conn, version: int) -> None:
cursor = conn.cursor()
cursor.execute("UPDATE db_version SET version = ?, updated_at = ? WHERE id = 1", (int(version), get_cst_now_str()))
conn.commit()
def _table_exists(cursor, table_name: str) -> bool:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (str(table_name),))
return cursor.fetchone() is not None
def _get_table_columns(cursor, table_name: str) -> set[str]:
cursor.execute(f"PRAGMA table_info({table_name})")
return {col[1] for col in cursor.fetchall()}
def _add_column_if_missing(cursor, table_name: str, columns: set[str], column_name: str, column_ddl: str, *, ok_message: str) -> bool:
if column_name in columns:
return False
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_ddl}")
columns.add(column_name)
print(ok_message)
return True
def _read_row_value(row, key: str, index: int):
if isinstance(row, sqlite3.Row):
return row[key]
return row[index]
def _get_migration_steps():
return [
(1, _migrate_to_v1),
(2, _migrate_to_v2),
(3, _migrate_to_v3),
(4, _migrate_to_v4),
(5, _migrate_to_v5),
(6, _migrate_to_v6),
(7, _migrate_to_v7),
(8, _migrate_to_v8),
(9, _migrate_to_v9),
(10, _migrate_to_v10),
(11, _migrate_to_v11),
(12, _migrate_to_v12),
(13, _migrate_to_v13),
(14, _migrate_to_v14),
(15, _migrate_to_v15),
(16, _migrate_to_v16),
(17, _migrate_to_v17),
(18, _migrate_to_v18),
(19, _migrate_to_v19),
(20, _migrate_to_v20),
(21, _migrate_to_v21),
]
def migrate_database(conn, target_version: int) -> None:
"""数据库迁移:按版本增量升级(向前兼容)。"""
cursor = conn.cursor()
cursor.execute("INSERT OR IGNORE INTO db_version (id, version, updated_at) VALUES (1, 0, ?)", (get_cst_now_str(),))
conn.commit()
target_version = int(target_version)
current_version = get_current_version(conn)
for version, migrate_fn in _get_migration_steps():
if version > target_version or current_version >= version:
continue
migrate_fn(conn)
current_version = version
stored_version = get_current_version(conn)
if stored_version != current_version:
set_current_version(conn, current_version)
if current_version != target_version:
print(f" [WARN] 目标版本 {target_version} 未完全可达,当前停留在 {current_version}")
def _migrate_to_v1(conn):
"""迁移到版本1 - 添加缺失字段"""
cursor = conn.cursor()
system_columns = _get_table_columns(cursor, "system_config")
_add_column_if_missing(
cursor,
"system_config",
system_columns,
"schedule_weekdays",
'TEXT DEFAULT "1,2,3,4,5,6,7"',
ok_message=" [OK] 添加 schedule_weekdays 字段",
)
_add_column_if_missing(
cursor,
"system_config",
system_columns,
"max_screenshot_concurrent",
"INTEGER DEFAULT 3",
ok_message=" [OK] 添加 max_screenshot_concurrent 字段",
)
_add_column_if_missing(
cursor,
"system_config",
system_columns,
"max_concurrent_per_account",
"INTEGER DEFAULT 1",
ok_message=" [OK] 添加 max_concurrent_per_account 字段",
)
_add_column_if_missing(
cursor,
"system_config",
system_columns,
"auto_approve_enabled",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 auto_approve_enabled 字段",
)
_add_column_if_missing(
cursor,
"system_config",
system_columns,
"auto_approve_hourly_limit",
"INTEGER DEFAULT 10",
ok_message=" [OK] 添加 auto_approve_hourly_limit 字段",
)
_add_column_if_missing(
cursor,
"system_config",
system_columns,
"auto_approve_vip_days",
"INTEGER DEFAULT 7",
ok_message=" [OK] 添加 auto_approve_vip_days 字段",
)
task_log_columns = _get_table_columns(cursor, "task_logs")
_add_column_if_missing(
cursor,
"task_logs",
task_log_columns,
"duration",
"INTEGER",
ok_message=" [OK] 添加 duration 字段到 task_logs",
)
conn.commit()
def _migrate_to_v2(conn):
"""迁移到版本2 - 添加代理配置字段"""
cursor = conn.cursor()
columns = _get_table_columns(cursor, "system_config")
_add_column_if_missing(
cursor,
"system_config",
columns,
"proxy_enabled",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 proxy_enabled 字段",
)
_add_column_if_missing(
cursor,
"system_config",
columns,
"proxy_api_url",
'TEXT DEFAULT ""',
ok_message=" [OK] 添加 proxy_api_url 字段",
)
_add_column_if_missing(
cursor,
"system_config",
columns,
"proxy_expire_minutes",
"INTEGER DEFAULT 3",
ok_message=" [OK] 添加 proxy_expire_minutes 字段",
)
_add_column_if_missing(
cursor,
"system_config",
columns,
"enable_screenshot",
"INTEGER DEFAULT 1",
ok_message=" [OK] 添加 enable_screenshot 字段",
)
conn.commit()
def _migrate_to_v3(conn):
"""迁移到版本3 - 添加账号状态和登录失败计数字段"""
cursor = conn.cursor()
columns = _get_table_columns(cursor, "accounts")
_add_column_if_missing(
cursor,
"accounts",
columns,
"status",
'TEXT DEFAULT "active"',
ok_message=" [OK] 添加 accounts.status 字段 (账号状态)",
)
_add_column_if_missing(
cursor,
"accounts",
columns,
"login_fail_count",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 accounts.login_fail_count 字段 (登录失败计数)",
)
_add_column_if_missing(
cursor,
"accounts",
columns,
"last_login_error",
"TEXT",
ok_message=" [OK] 添加 accounts.last_login_error 字段 (最后登录错误)",
)
conn.commit()
def _migrate_to_v4(conn):
"""迁移到版本4 - 添加任务来源字段"""
cursor = conn.cursor()
columns = _get_table_columns(cursor, "task_logs")
_add_column_if_missing(
cursor,
"task_logs",
columns,
"source",
'TEXT DEFAULT "manual"',
ok_message=" [OK] 添加 task_logs.source 字段 (任务来源: manual/scheduled/immediate)",
)
conn.commit()
def _migrate_to_v5(conn):
"""迁移到版本5 - 添加用户定时任务表"""
cursor = conn.cursor()
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(" [OK] 创建 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(" [OK] 创建 schedule_execution_logs 表 (定时任务执行日志)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_user_id ON user_schedules(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_enabled ON user_schedules(enabled)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_next_run ON user_schedules(next_run_at)")
print(" [OK] 创建 user_schedules 表索引")
conn.commit()
def _migrate_to_v6(conn):
"""迁移到版本6 - 添加公告功能相关表"""
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='announcements'")
if not cursor.fetchone():
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
is_active INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
print(" [OK] 创建 announcements 表 (公告)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcements_active ON announcements(is_active)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at)")
print(" [OK] 创建 announcements 表索引")
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='announcement_dismissals'")
if not cursor.fetchone():
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS announcement_dismissals (
user_id INTEGER NOT NULL,
announcement_id INTEGER NOT NULL,
dismissed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, announcement_id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (announcement_id) REFERENCES announcements (id) ON DELETE CASCADE
)
"""
)
print(" [OK] 创建 announcement_dismissals 表 (公告永久关闭记录)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcement_dismissals_user ON announcement_dismissals(user_id)")
print(" [OK] 创建 announcement_dismissals 表索引")
conn.commit()
def _migrate_to_v7(conn):
"""迁移到版本7 - 统一存储北京时间将历史UTC时间字段整体+8小时"""
cursor = conn.cursor()
columns_cache: dict[str, set[str]] = {}
def shift_utc_to_cst(table_name: str, column_name: str) -> None:
if not _table_exists(cursor, table_name):
return
if table_name not in columns_cache:
columns_cache[table_name] = _get_table_columns(cursor, table_name)
if column_name not in columns_cache[table_name]:
return
cursor.execute(
f"""
UPDATE {table_name}
SET {column_name} = datetime({column_name}, '+8 hours')
WHERE {column_name} IS NOT NULL AND {column_name} != ''
"""
)
for table, col in [
("users", "created_at"),
("users", "approved_at"),
("admins", "created_at"),
("accounts", "created_at"),
("password_reset_requests", "created_at"),
("password_reset_requests", "processed_at"),
("smtp_configs", "created_at"),
("smtp_configs", "updated_at"),
("smtp_configs", "last_success_at"),
("email_settings", "updated_at"),
("email_tokens", "created_at"),
("email_logs", "created_at"),
("email_stats", "last_updated"),
("task_checkpoints", "created_at"),
("task_checkpoints", "updated_at"),
("task_checkpoints", "completed_at"),
]:
shift_utc_to_cst(table, col)
conn.commit()
print(" [OK] 时区迁移历史UTC时间已转换为北京时间")
def _migrate_to_v8(conn):
"""迁移到版本8 - 用户定时 next_run_at 随机延迟落库O-08"""
cursor = conn.cursor()
# 1) 增量字段random_delay旧库可能不存在
columns = _get_table_columns(cursor, "user_schedules")
_add_column_if_missing(
cursor,
"user_schedules",
columns,
"random_delay",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 user_schedules.random_delay 字段",
)
_add_column_if_missing(
cursor,
"user_schedules",
columns,
"next_run_at",
"TIMESTAMP",
ok_message=" [OK] 添加 user_schedules.next_run_at 字段",
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_next_run ON user_schedules(next_run_at)")
conn.commit()
# 2) 为历史 enabled schedule 补算 next_run_at保证索引驱动可用
try:
from services.schedule_utils import compute_next_run_at, format_cst
from services.time_utils import get_beijing_now
now_dt = get_beijing_now()
now_str = format_cst(now_dt)
cursor.execute(
"""
SELECT id, schedule_time, weekdays, random_delay, last_run_at, next_run_at
FROM user_schedules
WHERE enabled = 1
"""
)
rows = cursor.fetchall() or []
fixed = 0
for row in rows:
try:
schedule_id = _read_row_value(row, "id", 0)
schedule_time = _read_row_value(row, "schedule_time", 1)
weekdays = _read_row_value(row, "weekdays", 2)
random_delay = _read_row_value(row, "random_delay", 3)
last_run_at = _read_row_value(row, "last_run_at", 4)
next_run_at = _read_row_value(row, "next_run_at", 5)
except Exception:
continue
next_run_text = str(next_run_at or "").strip()
# 若 next_run_at 为空/非法/已过期,则重算
if (not next_run_text) or (next_run_text <= now_str):
next_dt = compute_next_run_at(
now=now_dt,
schedule_time=str(schedule_time or "08:00"),
weekdays=str(weekdays or "1,2,3,4,5"),
random_delay=int(random_delay or 0),
last_run_at=str(last_run_at or "") if last_run_at else None,
)
next_run_text = format_cst(next_dt)
cursor.execute(
"UPDATE user_schedules SET next_run_at = ?, updated_at = ? WHERE id = ?",
(next_run_text, get_cst_now_str(), int(schedule_id)),
)
fixed += 1
conn.commit()
if fixed:
print(f" [OK] 已为 {fixed} 条启用定时任务补算 next_run_at")
except Exception as e:
# 迁移过程中不阻断主流程;上线后由 worker 兜底补算
print(f" ⚠ v8 迁移补算 next_run_at 失败: {e}")
def _migrate_to_v9(conn):
"""迁移到版本9 - 邮件设置字段迁移(清理 email_service scattered ALTER TABLE"""
cursor = conn.cursor()
if not _table_exists(cursor, "email_settings"):
# 邮件表由 email_service.init_email_tables 创建;此处仅做增量字段迁移
return
columns = _get_table_columns(cursor, "email_settings")
changed = False
changed = (
_add_column_if_missing(
cursor,
"email_settings",
columns,
"register_verify_enabled",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 email_settings.register_verify_enabled 字段",
)
or changed
)
changed = (
_add_column_if_missing(
cursor,
"email_settings",
columns,
"base_url",
"TEXT DEFAULT ''",
ok_message=" [OK] 添加 email_settings.base_url 字段",
)
or changed
)
changed = (
_add_column_if_missing(
cursor,
"email_settings",
columns,
"task_notify_enabled",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 email_settings.task_notify_enabled 字段",
)
or changed
)
if changed:
conn.commit()
def _migrate_to_v10(conn):
"""迁移到版本10 - users 邮箱字段迁移(避免运行时 ALTER TABLE"""
cursor = conn.cursor()
columns = _get_table_columns(cursor, "users")
changed = False
changed = (
_add_column_if_missing(
cursor,
"users",
columns,
"email_verified",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 users.email_verified 字段",
)
or changed
)
changed = (
_add_column_if_missing(
cursor,
"users",
columns,
"email_notify_enabled",
"INTEGER DEFAULT 1",
ok_message=" [OK] 添加 users.email_notify_enabled 字段",
)
or changed
)
if changed:
conn.commit()
def _migrate_to_v11(conn):
"""迁移到版本11 - 取消注册待审核:历史 pending 用户直接置为 approved"""
cursor = conn.cursor()
now_str = get_cst_now_str()
try:
cursor.execute(
"""
UPDATE users
SET status = 'approved',
approved_at = COALESCE(NULLIF(approved_at, ''), ?)
WHERE status = 'pending'
""",
(now_str,),
)
updated = cursor.rowcount
conn.commit()
if updated:
print(f" [OK] 已将 {updated} 个 pending 用户迁移为 approved")
except sqlite3.OperationalError as e:
print(f" ⚠️ v11 迁移跳过: {e}")
def _migrate_to_v12(conn):
"""迁移到版本12 - 登录设备/IP记录表"""
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS login_fingerprints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
user_agent TEXT NOT NULL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_ip TEXT DEFAULT '',
UNIQUE (user_id, user_agent),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS login_ips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
ip TEXT NOT NULL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, ip),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
"""
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_fingerprints_user ON login_fingerprints(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_ips_user ON login_ips(user_id)")
conn.commit()
def _migrate_to_v13(conn):
"""迁移到版本13 - 安全防护:威胁检测相关表"""
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS threat_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
threat_type TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
rule TEXT,
field_name TEXT,
matched TEXT,
value_preview TEXT,
ip TEXT,
user_id INTEGER,
request_method TEXT,
request_path TEXT,
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
"""
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_created_at ON threat_events(created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_ip ON threat_events(ip)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_user_id ON threat_events(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_type ON threat_events(threat_type)")
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS ip_risk_scores (
ip TEXT PRIMARY KEY,
risk_score INTEGER NOT NULL DEFAULT 0,
last_seen TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_risk_scores_score ON ip_risk_scores(risk_score)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_risk_scores_updated_at ON ip_risk_scores(updated_at)")
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS user_risk_scores (
user_id INTEGER PRIMARY KEY,
risk_score INTEGER NOT NULL DEFAULT 0,
last_seen 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_user_risk_scores_score ON user_risk_scores(risk_score)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_risk_scores_updated_at ON user_risk_scores(updated_at)")
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS ip_blacklist (
ip TEXT PRIMARY KEY,
reason TEXT,
is_active INTEGER DEFAULT 1,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP
)
"""
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_blacklist_active ON ip_blacklist(is_active)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_blacklist_expires ON ip_blacklist(expires_at)")
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS threat_signatures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
threat_type TEXT NOT NULL,
pattern TEXT NOT NULL,
pattern_type TEXT DEFAULT 'regex',
score INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_signatures_type ON threat_signatures(threat_type)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_signatures_active ON threat_signatures(is_active)")
conn.commit()
def _migrate_to_v14(conn):
"""迁移到版本14 - 安全防护:用户黑名单表"""
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS user_blacklist (
user_id INTEGER PRIMARY KEY,
reason TEXT,
is_active INTEGER DEFAULT 1,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP
)
"""
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_blacklist_active ON user_blacklist(is_active)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_blacklist_expires ON user_blacklist(expires_at)")
conn.commit()
def _migrate_to_v15(conn):
"""迁移到版本15 - 邮件设置:新设备登录提醒全局开关"""
cursor = conn.cursor()
if not _table_exists(cursor, "email_settings"):
# 邮件表由 email_service.init_email_tables 创建;此处仅做增量字段迁移
return
columns = _get_table_columns(cursor, "email_settings")
changed = False
changed = (
_add_column_if_missing(
cursor,
"email_settings",
columns,
"login_alert_enabled",
"INTEGER DEFAULT 1",
ok_message=" [OK] 添加 email_settings.login_alert_enabled 字段",
)
or changed
)
try:
cursor.execute("UPDATE email_settings SET login_alert_enabled = 1 WHERE login_alert_enabled IS NULL")
if cursor.rowcount:
changed = True
except sqlite3.OperationalError:
# 列不存在等情况由上方迁移兜底;不阻断主流程
pass
if changed:
conn.commit()
def _migrate_to_v16(conn):
"""迁移到版本16 - 公告支持图片字段"""
cursor = conn.cursor()
columns = _get_table_columns(cursor, "announcements")
if _add_column_if_missing(
cursor,
"announcements",
columns,
"image_url",
"TEXT",
ok_message=" [OK] 添加 announcements.image_url 字段",
):
conn.commit()
def _migrate_to_v17(conn):
"""迁移到版本17 - 金山文档上传配置与用户开关"""
cursor = conn.cursor()
system_columns = _get_table_columns(cursor, "system_config")
system_fields = [
("kdocs_enabled", "INTEGER DEFAULT 0"),
("kdocs_doc_url", "TEXT DEFAULT ''"),
("kdocs_default_unit", "TEXT DEFAULT ''"),
("kdocs_sheet_name", "TEXT DEFAULT ''"),
("kdocs_sheet_index", "INTEGER DEFAULT 0"),
("kdocs_unit_column", "TEXT DEFAULT 'A'"),
("kdocs_image_column", "TEXT DEFAULT 'D'"),
("kdocs_admin_notify_enabled", "INTEGER DEFAULT 0"),
("kdocs_admin_notify_email", "TEXT DEFAULT ''"),
]
for field, ddl in system_fields:
_add_column_if_missing(
cursor,
"system_config",
system_columns,
field,
ddl,
ok_message=f" [OK] 添加 system_config.{field} 字段",
)
user_columns = _get_table_columns(cursor, "users")
user_fields = [
("kdocs_unit", "TEXT DEFAULT ''"),
("kdocs_auto_upload", "INTEGER DEFAULT 0"),
]
for field, ddl in user_fields:
_add_column_if_missing(
cursor,
"users",
user_columns,
field,
ddl,
ok_message=f" [OK] 添加 users.{field} 字段",
)
conn.commit()
def _migrate_to_v18(conn):
"""迁移到版本18 - 金山文档上传:有效行范围配置"""
cursor = conn.cursor()
columns = _get_table_columns(cursor, "system_config")
_add_column_if_missing(
cursor,
"system_config",
columns,
"kdocs_row_start",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 system_config.kdocs_row_start 字段",
)
_add_column_if_missing(
cursor,
"system_config",
columns,
"kdocs_row_end",
"INTEGER DEFAULT 0",
ok_message=" [OK] 添加 system_config.kdocs_row_end 字段",
)
conn.commit()
def _migrate_to_v19(conn):
"""迁移到版本19 - 报表与调度查询复合索引优化"""
cursor = conn.cursor()
index_statements = [
"CREATE INDEX IF NOT EXISTS idx_users_status_created_at ON users(status, created_at)",
"CREATE INDEX IF NOT EXISTS idx_task_logs_status_created_at ON task_logs(status, created_at)",
"CREATE INDEX IF NOT EXISTS idx_user_schedules_enabled_next_run ON user_schedules(enabled, next_run_at)",
"CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_status_created_at ON bug_feedbacks(status, created_at)",
"CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_user_created_at ON bug_feedbacks(user_id, created_at)",
]
for statement in index_statements:
cursor.execute(statement)
conn.commit()
def _migrate_to_v20(conn):
"""迁移到版本20 - 慢SQL阈值系统配置"""
cursor = conn.cursor()
columns = _get_table_columns(cursor, "system_config")
_add_column_if_missing(
cursor,
"system_config",
columns,
"db_slow_query_ms",
"INTEGER DEFAULT 120",
ok_message=" [OK] 添加 system_config.db_slow_query_ms 字段",
)
conn.commit()
def _migrate_to_v21(conn):
"""迁移到版本21 - Passkey 认证设备表"""
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_type TEXT NOT NULL,
owner_id INTEGER NOT NULL,
device_name TEXT NOT NULL,
credential_id TEXT UNIQUE NOT NULL,
public_key TEXT NOT NULL,
sign_count INTEGER DEFAULT 0,
transports TEXT DEFAULT '',
aaguid TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_passkeys_owner ON passkeys(owner_type, owner_id)")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_passkeys_owner_last_used ON passkeys(owner_type, owner_id, last_used_at)"
)
conn.commit()

173
db/passkeys.py Normal file
View File

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

554
db/schedules.py Normal file
View File

@@ -0,0 +1,554 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import json
from datetime import datetime, timedelta
import db_pool
from services.schedule_utils import compute_next_run_at, format_cst
from services.time_utils import get_beijing_now
_SCHEDULE_DEFAULT_TIME = "08:00"
_SCHEDULE_DEFAULT_WEEKDAYS = "1,2,3,4,5"
_ALLOWED_SCHEDULE_UPDATE_FIELDS = (
"name",
"enabled",
"schedule_time",
"weekdays",
"browse_type",
"enable_screenshot",
"random_delay",
"account_ids",
)
_ALLOWED_EXEC_LOG_UPDATE_FIELDS = (
"total_accounts",
"success_accounts",
"failed_accounts",
"total_items",
"total_attachments",
"total_screenshots",
"duration_seconds",
"status",
"error_message",
)
def _normalize_limit(limit, default: int, *, minimum: int = 1) -> int:
try:
parsed = int(limit)
except Exception:
parsed = default
if parsed < minimum:
return minimum
return parsed
def _to_int(value, default: int = 0) -> int:
try:
return int(value)
except Exception:
return default
def _format_optional_datetime(dt: datetime | None) -> str | None:
if dt is None:
return None
return format_cst(dt)
def _serialize_account_ids(account_ids) -> str:
return json.dumps(account_ids) if account_ids else "[]"
def _compute_schedule_next_run_str(
*,
now_dt,
schedule_time,
weekdays,
random_delay,
last_run_at,
) -> str:
next_dt = compute_next_run_at(
now=now_dt,
schedule_time=str(schedule_time or _SCHEDULE_DEFAULT_TIME),
weekdays=str(weekdays or _SCHEDULE_DEFAULT_WEEKDAYS),
random_delay=_to_int(random_delay, 0),
last_run_at=str(last_run_at or "") if last_run_at else None,
)
return format_cst(next_dt)
def _map_schedule_log_row(row) -> dict:
log = dict(row)
log["created_at"] = log.get("execute_time")
log["success_count"] = log.get("success_accounts", 0)
log["failed_count"] = log.get("failed_accounts", 0)
log["duration"] = log.get("duration_seconds", 0)
return log
def get_user_schedules(user_id):
"""获取用户的所有定时任务"""
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,
random_delay=0,
account_ids=None,
):
"""创建用户定时任务"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cst_time = format_cst(get_beijing_now())
cursor.execute(
"""
INSERT INTO user_schedules (
user_id, name, enabled, schedule_time, weekdays,
browse_type, enable_screenshot, random_delay, account_ids, created_at, updated_at
) VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
name,
schedule_time,
weekdays,
browse_type,
enable_screenshot,
_to_int(random_delay, 0),
_serialize_account_ids(account_ids),
cst_time,
cst_time,
),
)
conn.commit()
return cursor.lastrowid
def update_user_schedule(schedule_id, **kwargs):
"""更新用户定时任务"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
now_dt = get_beijing_now()
now_str = format_cst(now_dt)
cursor.execute(
"""
SELECT enabled, schedule_time, weekdays, random_delay, last_run_at
FROM user_schedules
WHERE id = ?
""",
(schedule_id,),
)
current = cursor.fetchone()
if not current:
return False
current_enabled = _to_int(current[0], 0)
current_time = current[1]
current_weekdays = current[2]
current_random_delay = _to_int(current[3], 0)
current_last_run_at = current[4]
will_enabled = current_enabled
next_time = current_time
next_weekdays = current_weekdays
next_random_delay = current_random_delay
updates = []
params = []
for field in _ALLOWED_SCHEDULE_UPDATE_FIELDS:
if field not in kwargs:
continue
value = kwargs[field]
if field == "account_ids" and isinstance(value, list):
value = json.dumps(value)
if field == "enabled":
will_enabled = 1 if value else 0
if field == "schedule_time":
next_time = value
if field == "weekdays":
next_weekdays = value
if field == "random_delay":
next_random_delay = int(value or 0)
updates.append(f"{field} = ?")
params.append(value)
if not updates:
return False
updates.append("updated_at = ?")
params.append(now_str)
config_changed = any(key in kwargs for key in ("schedule_time", "weekdays", "random_delay"))
enabled_toggled = "enabled" in kwargs
should_recompute_next = config_changed or (enabled_toggled and will_enabled == 1)
if should_recompute_next:
next_run_at = _compute_schedule_next_run_str(
now_dt=now_dt,
schedule_time=next_time,
weekdays=next_weekdays,
random_delay=next_random_delay,
last_run_at=None if config_changed else current_last_run_at,
)
updates.append("next_run_at = ?")
params.append(next_run_at)
if enabled_toggled and will_enabled == 0:
updates.append("next_run_at = ?")
params.append(None)
params.append(schedule_id)
sql = f"UPDATE user_schedules SET {', '.join(updates)} WHERE id = ?"
cursor.execute(sql, params)
conn.commit()
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()
now_dt = get_beijing_now()
now_str = format_cst(now_dt)
next_run_at = None
if enabled:
cursor.execute(
"""
SELECT schedule_time, weekdays, random_delay, last_run_at, next_run_at
FROM user_schedules
WHERE id = ?
""",
(schedule_id,),
)
row = cursor.fetchone()
if row:
schedule_time, weekdays, random_delay, last_run_at, existing_next_run_at = row
existing_next_run_at = str(existing_next_run_at or "").strip() or None
if existing_next_run_at and existing_next_run_at > now_str:
next_run_at = existing_next_run_at
else:
next_run_at = _compute_schedule_next_run_str(
now_dt=now_dt,
schedule_time=schedule_time,
weekdays=weekdays,
random_delay=random_delay,
last_run_at=last_run_at,
)
cursor.execute(
"""
UPDATE user_schedules
SET enabled = ?, next_run_at = ?, updated_at = ?
WHERE id = ?
""",
(1 if enabled else 0, next_run_at, now_str, 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):
"""更新定时任务最后运行时间,并推进 next_run_atO-08"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
now_dt = get_beijing_now()
now_str = format_cst(now_dt)
cursor.execute(
"""
SELECT schedule_time, weekdays, random_delay
FROM user_schedules
WHERE id = ?
""",
(schedule_id,),
)
row = cursor.fetchone()
if not row:
return False
schedule_time, weekdays, random_delay = row
next_run_at = _compute_schedule_next_run_str(
now_dt=now_dt,
schedule_time=schedule_time,
weekdays=weekdays,
random_delay=random_delay,
last_run_at=now_str,
)
cursor.execute(
"""
UPDATE user_schedules
SET last_run_at = ?, next_run_at = ?, updated_at = ?
WHERE id = ?
""",
(now_str, next_run_at, now_str, schedule_id),
)
conn.commit()
return cursor.rowcount > 0
def update_schedule_next_run(schedule_id: int, next_run_at: str) -> bool:
"""仅更新 next_run_at不改变 last_run_at用于跳过执行时推进。"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
UPDATE user_schedules
SET next_run_at = ?, updated_at = ?
WHERE id = ?
""",
(
str(next_run_at or "").strip() or None,
format_cst(get_beijing_now()),
int(schedule_id),
),
)
conn.commit()
return cursor.rowcount > 0
def recompute_schedule_next_run(schedule_id: int, *, now_dt=None) -> bool:
"""按当前配置重算 next_run_at不改变 last_run_at"""
now_dt = now_dt or get_beijing_now()
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT schedule_time, weekdays, random_delay, last_run_at
FROM user_schedules
WHERE id = ?
""",
(int(schedule_id),),
)
row = cursor.fetchone()
if not row:
return False
schedule_time, weekdays, random_delay, last_run_at = row
next_run_at = _compute_schedule_next_run_str(
now_dt=now_dt,
schedule_time=schedule_time,
weekdays=weekdays,
random_delay=random_delay,
last_run_at=last_run_at,
)
return update_schedule_next_run(int(schedule_id), next_run_at)
def get_due_user_schedules(now_cst: str, limit: int = 50):
"""获取到期需要执行的用户定时任务(索引驱动)。"""
now_cst = str(now_cst or "").strip()
if not now_cst:
now_cst = format_cst(get_beijing_now())
safe_limit = _normalize_limit(limit, 50, minimum=1)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT us.*, u.username as user_username
FROM user_schedules us
JOIN users u ON us.user_id = u.id
WHERE us.enabled = 1
AND us.next_run_at IS NOT NULL
AND us.next_run_at <= ?
ORDER BY us.next_run_at ASC
LIMIT ?
""",
(now_cst, safe_limit),
)
return [dict(row) for row in cursor.fetchall()]
# ==================== 定时任务执行日志 ====================
def create_schedule_execution_log(schedule_id, user_id, schedule_name):
"""创建定时任务执行日志"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO schedule_execution_logs (
schedule_id, user_id, schedule_name, execute_time, status
) VALUES (?, ?, ?, ?, 'running')
""",
(schedule_id, user_id, schedule_name, format_cst(get_beijing_now())),
)
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 = []
for field in _ALLOWED_EXEC_LOG_UPDATE_FIELDS:
if field not in kwargs:
continue
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):
"""获取定时任务执行日志"""
try:
safe_limit = _normalize_limit(limit, 10, minimum=1)
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, safe_limit),
)
logs = []
for row in cursor.fetchall():
try:
logs.append(_map_schedule_log_row(row))
except Exception as e:
print(f"[数据库] 处理日志行时出错: {e}")
continue
return logs
except Exception as e:
print(f"[数据库] 查询定时任务日志时出错: {e}")
import traceback
traceback.print_exc()
return []
def get_user_all_schedule_logs(user_id, limit=50):
"""获取用户所有定时任务的执行日志"""
safe_limit = _normalize_limit(limit, 50, minimum=1)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT * FROM schedule_execution_logs
WHERE user_id = ?
ORDER BY execute_time DESC
LIMIT ?
""",
(user_id, safe_limit),
)
return [dict(row) for row in cursor.fetchall()]
def delete_schedule_logs(schedule_id, user_id):
"""删除指定定时任务的所有执行日志(需验证用户权限)"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
DELETE FROM schedule_execution_logs
WHERE schedule_id = ? AND user_id = ?
""",
(schedule_id, user_id),
)
conn.commit()
return cursor.rowcount
def clean_old_schedule_logs(days=30):
"""清理指定天数前的定时任务执行日志"""
safe_days = _to_int(days, 30)
if safe_days < 0:
safe_days = 0
cutoff_dt = get_beijing_now() - timedelta(days=safe_days)
cutoff_str = format_cst(cutoff_dt)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
DELETE FROM schedule_execution_logs
WHERE execute_time < ?
""",
(cutoff_str,),
)
conn.commit()
return cursor.rowcount

471
db/schema.py Normal file
View File

@@ -0,0 +1,471 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import sqlite3
from db.utils import get_cst_now_str
def ensure_schema(conn) -> None:
"""创建当前版本所需的所有表与索引(新库可直接得到完整 schema"""
cursor = conn.cursor()
# 管理员表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# 用户表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
email TEXT,
email_verified INTEGER DEFAULT 0,
email_notify_enabled INTEGER DEFAULT 1,
kdocs_unit TEXT DEFAULT '',
kdocs_auto_upload INTEGER DEFAULT 0,
status TEXT DEFAULT 'approved',
vip_expire_time TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
approved_at TIMESTAMP
)
"""
)
# 登录设备指纹表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS login_fingerprints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
user_agent TEXT NOT NULL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_ip TEXT DEFAULT '',
UNIQUE (user_id, user_agent),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
"""
)
# 登录IP记录表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS login_ips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
ip TEXT NOT NULL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, ip),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
"""
)
# Passkey 认证设备表(用户/管理员)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_type TEXT NOT NULL,
owner_id INTEGER NOT NULL,
device_name TEXT NOT NULL,
credential_id TEXT UNIQUE NOT NULL,
public_key TEXT NOT NULL,
sign_count INTEGER DEFAULT 0,
transports TEXT DEFAULT '',
aaguid TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# ==================== 安全防护:威胁检测相关表 ====================
# 威胁事件日志表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS threat_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
threat_type TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
rule TEXT,
field_name TEXT,
matched TEXT,
value_preview TEXT,
ip TEXT,
user_id INTEGER,
request_method TEXT,
request_path TEXT,
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
"""
)
# IP风险评分表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS ip_risk_scores (
ip TEXT PRIMARY KEY,
risk_score INTEGER NOT NULL DEFAULT 0,
last_seen TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# 用户风险评分表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS user_risk_scores (
user_id INTEGER PRIMARY KEY,
risk_score INTEGER NOT NULL DEFAULT 0,
last_seen TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
"""
)
# IP黑名单表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS ip_blacklist (
ip TEXT PRIMARY KEY,
reason TEXT,
is_active INTEGER DEFAULT 1,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP
)
"""
)
# 用户黑名单表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS user_blacklist (
user_id INTEGER PRIMARY KEY,
reason TEXT,
is_active INTEGER DEFAULT 1,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
"""
)
# 威胁特征库表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS threat_signatures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
threat_type TEXT NOT NULL,
pattern TEXT NOT NULL,
pattern_type TEXT DEFAULT 'regex',
score INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# 账号表(关联用户)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
remember INTEGER DEFAULT 1,
remark TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
"""
)
# VIP配置表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS vip_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
default_vip_days INTEGER DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# 系统配置表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS system_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
max_concurrent_global INTEGER DEFAULT 2,
schedule_enabled INTEGER DEFAULT 0,
schedule_time TEXT DEFAULT '02:00',
schedule_browse_type TEXT DEFAULT '应读',
proxy_enabled INTEGER DEFAULT 0,
proxy_api_url TEXT DEFAULT '',
proxy_expire_minutes INTEGER DEFAULT 3,
max_screenshot_concurrent INTEGER DEFAULT 3,
max_concurrent_per_account INTEGER DEFAULT 1,
db_slow_query_ms INTEGER DEFAULT 120,
schedule_weekdays TEXT DEFAULT '1,2,3,4,5,6,7',
enable_screenshot INTEGER DEFAULT 1,
auto_approve_enabled INTEGER DEFAULT 0,
auto_approve_hourly_limit INTEGER DEFAULT 10,
auto_approve_vip_days INTEGER DEFAULT 7,
kdocs_enabled INTEGER DEFAULT 0,
kdocs_doc_url TEXT DEFAULT '',
kdocs_default_unit TEXT DEFAULT '',
kdocs_sheet_name TEXT DEFAULT '',
kdocs_sheet_index INTEGER DEFAULT 0,
kdocs_unit_column TEXT DEFAULT 'A',
kdocs_image_column TEXT DEFAULT 'D',
kdocs_admin_notify_enabled INTEGER DEFAULT 0,
kdocs_admin_notify_email TEXT DEFAULT '',
kdocs_row_start INTEGER DEFAULT 0,
kdocs_row_end INTEGER DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# 任务日志表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS task_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
account_id TEXT NOT NULL,
username TEXT NOT NULL,
browse_type TEXT NOT NULL,
status TEXT NOT NULL,
total_items INTEGER DEFAULT 0,
total_attachments INTEGER DEFAULT 0,
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
)
"""
)
# 数据库版本表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS db_version (
id INTEGER PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# 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 announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
image_url TEXT,
is_active INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# 公告永久关闭记录表(用户维度)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS announcement_dismissals (
user_id INTEGER NOT NULL,
announcement_id INTEGER NOT NULL,
dismissed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, announcement_id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (announcement_id) REFERENCES announcements (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,
random_delay INTEGER DEFAULT 0,
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 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
)
"""
)
# ========== 创建索引 ==========
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_status ON users(status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_vip_expire ON users(vip_expire_time)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_status_created_at ON users(status, created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_fingerprints_user ON login_fingerprints(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_ips_user ON login_ips(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_passkeys_owner ON passkeys(owner_type, owner_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_passkeys_owner_last_used ON passkeys(owner_type, owner_id, last_used_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_created_at ON threat_events(created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_ip ON threat_events(ip)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_user_id ON threat_events(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_type ON threat_events(threat_type)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_risk_scores_score ON ip_risk_scores(risk_score)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_risk_scores_updated_at ON ip_risk_scores(updated_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_risk_scores_score ON user_risk_scores(risk_score)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_risk_scores_updated_at ON user_risk_scores(updated_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_blacklist_active ON ip_blacklist(is_active)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_blacklist_expires ON ip_blacklist(expires_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_blacklist_active ON user_blacklist(is_active)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_blacklist_expires ON user_blacklist(expires_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_signatures_type ON threat_signatures(threat_type)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_signatures_active ON threat_signatures(is_active)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_accounts_user_id ON accounts(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts(username)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_user_id ON task_logs(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_status ON task_logs(status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_status_created_at ON task_logs(status, created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_created_at ON task_logs(created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_source ON task_logs(source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_source_created_at ON task_logs(source, created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_user_date ON task_logs(user_id, created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_user_id ON bug_feedbacks(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_status ON bug_feedbacks(status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_created_at ON bug_feedbacks(created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_status_created_at ON bug_feedbacks(status, created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_user_created_at ON bug_feedbacks(user_id, created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcements_active ON announcements(is_active)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcement_dismissals_user ON announcement_dismissals(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_user_id ON user_schedules(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_enabled ON user_schedules(enabled)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_next_run ON user_schedules(next_run_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_enabled_next_run ON user_schedules(enabled, next_run_at)")
# 复合索引优化
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_user_enabled ON user_schedules(user_id, enabled)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_execution_logs_schedule_id ON schedule_execution_logs(schedule_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_execution_logs_user_id ON schedule_execution_logs(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_execution_logs_status ON schedule_execution_logs(status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_execution_logs_execute_time ON schedule_execution_logs(execute_time)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_execution_logs_schedule_time ON schedule_execution_logs(schedule_id, execute_time)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_execution_logs_user_time ON schedule_execution_logs(user_id, execute_time)")
# 初始化VIP配置幂等
try:
cursor.execute("INSERT INTO vip_config (id, default_vip_days) VALUES (1, 0)")
except sqlite3.IntegrityError:
pass
# 初始化系统配置(幂等)
try:
cursor.execute(
"""
INSERT INTO system_config (
id, max_concurrent_global, max_concurrent_per_account, max_screenshot_concurrent,
schedule_enabled, schedule_time, schedule_browse_type, schedule_weekdays,
proxy_enabled, proxy_api_url, proxy_expire_minutes, enable_screenshot,
auto_approve_enabled, auto_approve_hourly_limit, auto_approve_vip_days
) VALUES (1, 2, 1, 3, 0, '02:00', '应读', '1,2,3,4,5,6,7', 0, '', 3, 1, 0, 10, 7)
"""
)
except sqlite3.IntegrityError:
pass
# 确保 db_version 记录存在(默认 0由迁移统一更新
cursor.execute("INSERT OR IGNORE INTO db_version (id, version, updated_at) VALUES (1, 0, ?)", (get_cst_now_str(),))
conn.commit()

287
db/security.py Normal file
View File

@@ -0,0 +1,287 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from datetime import timedelta
from typing import Any, Dict, Optional
import db_pool
from db.utils import get_cst_now, get_cst_now_str
_THREAT_EVENT_SELECT_COLUMNS = """
id,
threat_type,
score,
rule,
field_name,
matched,
value_preview,
ip,
user_id,
request_method,
request_path,
user_agent,
created_at
"""
def _normalize_page(page: int) -> int:
try:
page_i = int(page)
except Exception:
page_i = 1
return max(1, page_i)
def _normalize_per_page(per_page: int, default: int = 20) -> int:
try:
value = int(per_page)
except Exception:
value = default
return max(1, min(200, value))
def _normalize_limit(limit: int, default: int = 50) -> int:
try:
value = int(limit)
except Exception:
value = default
return max(1, min(200, value))
def _row_value(row, key: str, index: int = 0, default=None):
if row is None:
return default
try:
return row[key]
except Exception:
try:
return row[index]
except Exception:
return default
def _fetch_threat_events_history(where_clause: str, params: tuple[Any, ...], limit_i: int) -> list[dict]:
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
f"""
SELECT
{_THREAT_EVENT_SELECT_COLUMNS}
FROM threat_events
WHERE {where_clause}
ORDER BY created_at DESC, id DESC
LIMIT ?
""",
tuple(params) + (limit_i,),
)
return [dict(r) for r in cursor.fetchall()]
def record_login_context(user_id: int, ip_address: str, user_agent: str) -> Dict[str, bool]:
"""记录登录环境信息,返回是否新设备/新IP。"""
user_id = int(user_id)
ip_text = str(ip_address or "").strip()[:64]
ua_text = str(user_agent or "").strip()[:512]
now_str = get_cst_now_str()
new_device = False
new_ip = False
with db_pool.get_db() as conn:
cursor = conn.cursor()
if ua_text:
cursor.execute(
"SELECT id FROM login_fingerprints WHERE user_id = ? AND user_agent = ?",
(user_id, ua_text),
)
row = cursor.fetchone()
if row:
cursor.execute(
"""
UPDATE login_fingerprints
SET last_seen = ?, last_ip = ?
WHERE id = ?
""",
(now_str, ip_text, _row_value(row, "id", 0)),
)
else:
cursor.execute(
"""
INSERT INTO login_fingerprints (user_id, user_agent, first_seen, last_seen, last_ip)
VALUES (?, ?, ?, ?, ?)
""",
(user_id, ua_text, now_str, now_str, ip_text),
)
new_device = True
if ip_text:
cursor.execute(
"SELECT id FROM login_ips WHERE user_id = ? AND ip = ?",
(user_id, ip_text),
)
row = cursor.fetchone()
if row:
cursor.execute(
"""
UPDATE login_ips
SET last_seen = ?
WHERE id = ?
""",
(now_str, _row_value(row, "id", 0)),
)
else:
cursor.execute(
"""
INSERT INTO login_ips (user_id, ip, first_seen, last_seen)
VALUES (?, ?, ?, ?)
""",
(user_id, ip_text, now_str, now_str),
)
new_ip = True
conn.commit()
return {"new_device": new_device, "new_ip": new_ip}
def get_threat_events_count(hours: int = 24) -> int:
"""获取指定时间内的威胁事件数。"""
try:
hours_int = max(0, int(hours))
except Exception:
hours_int = 24
if hours_int <= 0:
return 0
start_time = (get_cst_now() - timedelta(hours=hours_int)).strftime("%Y-%m-%d %H:%M:%S")
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) AS cnt FROM threat_events WHERE created_at >= ?", (start_time,))
row = cursor.fetchone()
try:
return int(row["cnt"] if row else 0)
except Exception:
return 0
def _build_threat_events_where_clause(filters: Optional[dict]) -> tuple[str, list[Any]]:
clauses: list[str] = []
params: list[Any] = []
if not isinstance(filters, dict):
return "", []
event_type = filters.get("event_type") or filters.get("threat_type")
if event_type:
raw = str(event_type).strip()
types = [t.strip()[:64] for t in raw.split(",") if t.strip()]
if len(types) == 1:
clauses.append("threat_type = ?")
params.append(types[0])
elif types:
placeholders = ", ".join(["?"] * len(types))
clauses.append(f"threat_type IN ({placeholders})")
params.extend(types)
severity = filters.get("severity")
if severity is not None and str(severity).strip():
sev = str(severity).strip().lower()
if "-" in sev:
parts = [p.strip() for p in sev.split("-", 1)]
try:
min_score = int(parts[0])
max_score = int(parts[1])
clauses.append("score >= ? AND score <= ?")
params.extend([min_score, max_score])
except Exception:
pass
elif sev.isdigit():
clauses.append("score >= ?")
params.append(int(sev))
elif sev in {"high", "critical"}:
clauses.append("score >= ?")
params.append(80)
elif sev in {"medium", "med"}:
clauses.append("score >= ? AND score < ?")
params.extend([50, 80])
elif sev in {"low", "info"}:
clauses.append("score < ?")
params.append(50)
ip = filters.get("ip")
if ip is not None and str(ip).strip():
ip_text = str(ip).strip()[:64]
clauses.append("ip = ?")
params.append(ip_text)
user_id = filters.get("user_id")
if user_id is not None and str(user_id).strip():
try:
user_id_int = int(user_id)
except Exception:
user_id_int = None
if user_id_int is not None:
clauses.append("user_id = ?")
params.append(user_id_int)
if not clauses:
return "", []
return " WHERE " + " AND ".join(clauses), params
def get_threat_events_list(page: int, per_page: int, filters: Optional[dict] = None) -> dict:
"""分页获取威胁事件。"""
page_i = _normalize_page(page)
per_page_i = _normalize_per_page(per_page, default=20)
where_sql, params = _build_threat_events_where_clause(filters)
offset = (page_i - 1) * per_page_i
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(f"SELECT COUNT(*) AS cnt FROM threat_events{where_sql}", tuple(params))
row = cursor.fetchone()
total = int(row["cnt"]) if row else 0
cursor.execute(
f"""
SELECT
{_THREAT_EVENT_SELECT_COLUMNS}
FROM threat_events
{where_sql}
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?
""",
tuple(params + [per_page_i, offset]),
)
items = [dict(r) for r in cursor.fetchall()]
return {"page": page_i, "per_page": per_page_i, "total": total, "items": items, "filters": filters or {}}
def get_ip_threat_history(ip: str, limit: int = 50) -> list[dict]:
"""获取IP的威胁历史最近limit条"""
ip_text = str(ip or "").strip()[:64]
if not ip_text:
return []
limit_i = _normalize_limit(limit, default=50)
return _fetch_threat_events_history("ip = ?", (ip_text,), limit_i)
def get_user_threat_history(user_id: int, limit: int = 50) -> list[dict]:
"""获取用户的威胁历史最近limit条"""
if user_id is None:
return []
try:
user_id_int = int(user_id)
except Exception:
return []
limit_i = _normalize_limit(limit, default=50)
return _fetch_threat_events_history("user_id = ?", (user_id_int,), limit_i)

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