dev
This commit is contained in:
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Secrets and environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.production.example
|
||||||
|
|
||||||
|
# Python
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# Node / frontend
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Logs and runtime data
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
runtime/
|
||||||
|
|
||||||
|
# Build and release artifacts
|
||||||
|
release/
|
||||||
|
*.zip
|
||||||
|
*.sha256.txt
|
||||||
|
|
||||||
|
# Local data and generated files
|
||||||
|
uploads/
|
||||||
|
exports/
|
||||||
|
tmp/
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
164
README.md
Normal file
164
README.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# domainCheck Workspace
|
||||||
|
|
||||||
|
这个仓库是 `domainCheck` 项目的本地工作区,包含:
|
||||||
|
|
||||||
|
- 旧版桌面端源码 `domainCheck/`
|
||||||
|
- 新版后端服务 `domain-api/`
|
||||||
|
- 新版 Web 管理后台 `domain-web/`
|
||||||
|
- 全套交付与部署文档 `docs/`
|
||||||
|
- Windows 本地启动、打包、验包脚本
|
||||||
|
|
||||||
|
如果你是第一次打开这个仓库,建议先看:
|
||||||
|
|
||||||
|
- [docs/12_domainCheck_导航索引.md](./docs/12_domainCheck_导航索引.md)
|
||||||
|
- [docs/04_domainCheck_WebLinux改造总体方案.md](./docs/04_domainCheck_WebLinux改造总体方案.md)
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├─ docs/ 文档、验收、部署、交付说明
|
||||||
|
├─ domainCheck/ 旧版 Windows 桌面端源码
|
||||||
|
├─ domain-api/ 新版 FastAPI 后端
|
||||||
|
├─ domain-web/ 新版 Vue Web 管理后台
|
||||||
|
├─ release/ 打包产物目录(通常不提交 git)
|
||||||
|
├─ start_*.ps1 Windows 启动脚本
|
||||||
|
├─ stop_*.ps1 Windows 停止脚本
|
||||||
|
├─ smoke_test_stack.ps1 本地整栈自测
|
||||||
|
├─ package_domain_release.ps1 交付打包
|
||||||
|
├─ verify_domain_release.ps1 交付包校验
|
||||||
|
├─ prepare_final_release.ps1 最终发布准备
|
||||||
|
└─ show_latest_release.ps1 查看最新交付包
|
||||||
|
```
|
||||||
|
|
||||||
|
## 各目录用途
|
||||||
|
|
||||||
|
### `domainCheck/`
|
||||||
|
|
||||||
|
旧版桌面工具项目,主要用于:
|
||||||
|
|
||||||
|
- 历史功能保留
|
||||||
|
- 旧版桌面端运行
|
||||||
|
- 检测逻辑来源与兼容
|
||||||
|
|
||||||
|
### `domain-api/`
|
||||||
|
|
||||||
|
新版后端服务,负责:
|
||||||
|
|
||||||
|
- 配置中心
|
||||||
|
- 检测控制
|
||||||
|
- 域名筛选与批量更新
|
||||||
|
- 导入与导出
|
||||||
|
- 日志诊断
|
||||||
|
- 运行中心
|
||||||
|
- Linux 部署与联调
|
||||||
|
|
||||||
|
关键入口:
|
||||||
|
|
||||||
|
- [domain-api/README.md](./domain-api/README.md)
|
||||||
|
- [domain-api/deploy/linux/README.md](./domain-api/deploy/linux/README.md)
|
||||||
|
|
||||||
|
### `domain-web/`
|
||||||
|
|
||||||
|
新版 Web 管理后台,负责:
|
||||||
|
|
||||||
|
- 登录
|
||||||
|
- 概览
|
||||||
|
- 系统设置
|
||||||
|
- 域名导入
|
||||||
|
- 检测控制
|
||||||
|
- 域名筛选
|
||||||
|
- 导出中心
|
||||||
|
- 日志诊断
|
||||||
|
- 运行中心
|
||||||
|
|
||||||
|
关键入口:
|
||||||
|
|
||||||
|
- [domain-web/README.md](./domain-web/README.md)
|
||||||
|
|
||||||
|
### `docs/`
|
||||||
|
|
||||||
|
这个目录是项目的总文档区,包含:
|
||||||
|
|
||||||
|
- 验收清单
|
||||||
|
- 优化清单
|
||||||
|
- 外包整改清单
|
||||||
|
- Web/Linux 改造方案
|
||||||
|
- Linux 部署说明
|
||||||
|
- 发布验收清单
|
||||||
|
- 交付说明
|
||||||
|
- 打包说明
|
||||||
|
- 最终交付结论
|
||||||
|
- Linux 联调输入清单
|
||||||
|
- 总导航索引
|
||||||
|
|
||||||
|
最推荐的阅读顺序:
|
||||||
|
|
||||||
|
1. [docs/04_domainCheck_WebLinux改造总体方案.md](./docs/04_domainCheck_WebLinux改造总体方案.md)
|
||||||
|
2. [docs/08_domainCheck_交付说明.md](./docs/08_domainCheck_交付说明.md)
|
||||||
|
3. [docs/05_domainCheck_Linux部署清单.md](./docs/05_domainCheck_Linux部署清单.md)
|
||||||
|
4. [docs/07_domainCheck_WebLinux发布验收清单.md](./docs/07_domainCheck_WebLinux发布验收清单.md)
|
||||||
|
5. [docs/12_domainCheck_导航索引.md](./docs/12_domainCheck_导航索引.md)
|
||||||
|
|
||||||
|
## Windows 本地常用脚本
|
||||||
|
|
||||||
|
### 启动与停止
|
||||||
|
|
||||||
|
- [start_domain_stack.ps1](./start_domain_stack.ps1)
|
||||||
|
- [stop_domain_stack.ps1](./stop_domain_stack.ps1)
|
||||||
|
- [start_domain_api.ps1](./start_domain_api.ps1)
|
||||||
|
- [stop_domain_api.ps1](./stop_domain_api.ps1)
|
||||||
|
- [start_domain_web.ps1](./start_domain_web.ps1)
|
||||||
|
- [stop_domain_web.ps1](./stop_domain_web.ps1)
|
||||||
|
|
||||||
|
### 自测与交付
|
||||||
|
|
||||||
|
- [smoke_test_stack.ps1](./smoke_test_stack.ps1)
|
||||||
|
- [package_domain_release.ps1](./package_domain_release.ps1)
|
||||||
|
- [verify_domain_release.ps1](./verify_domain_release.ps1)
|
||||||
|
- [prepare_final_release.ps1](./prepare_final_release.ps1)
|
||||||
|
- [show_latest_release.ps1](./show_latest_release.ps1)
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
当前这套工作区已经完成:
|
||||||
|
|
||||||
|
- 本地开发
|
||||||
|
- Web/API 落地
|
||||||
|
- Windows 本地联调
|
||||||
|
- 自检与自测
|
||||||
|
- 配置迁移与备份
|
||||||
|
- 打包与验包
|
||||||
|
- 发布准备
|
||||||
|
|
||||||
|
剩余工作主要在真实 Linux 环境:
|
||||||
|
|
||||||
|
- 部署
|
||||||
|
- 联调
|
||||||
|
- 灰度上线
|
||||||
|
|
||||||
|
Linux 阶段建议先看:
|
||||||
|
|
||||||
|
- [docs/11_domainCheck_Linux联调输入清单.md](./docs/11_domainCheck_Linux联调输入清单.md)
|
||||||
|
- [docs/12_domainCheck_导航索引.md](./docs/12_domainCheck_导航索引.md)
|
||||||
|
|
||||||
|
## Git 提交建议
|
||||||
|
|
||||||
|
建议提交:
|
||||||
|
|
||||||
|
- `docs/`
|
||||||
|
- `domain-api/`
|
||||||
|
- `domain-web/`
|
||||||
|
- `domainCheck/` 中需要保留的源码
|
||||||
|
- 根目录 `.ps1` 脚本
|
||||||
|
|
||||||
|
不建议提交:
|
||||||
|
|
||||||
|
- `release/`
|
||||||
|
- 真实 `.env`
|
||||||
|
- 日志
|
||||||
|
- `runtime/`
|
||||||
|
- `node_modules/`
|
||||||
|
- `dist/`
|
||||||
|
- `.venv/`
|
||||||
|
- 其他运行生成文件
|
||||||
267
docs/01_domainCheck_验收清单.md
Normal file
267
docs/01_domainCheck_验收清单.md
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
# domainCheck 验收清单
|
||||||
|
|
||||||
|
基于 [需求文档内容_utf8.txt](/d:/www/py/domainCheck/需求文档内容_utf8.txt:1) 与当前项目代码、环境联调结果整理。
|
||||||
|
|
||||||
|
判定说明:
|
||||||
|
- `可验收`:已有实现,能进入验收或复测阶段。
|
||||||
|
- `部分可验收/需优化`:已有基础实现,但与需求有差距,不能算完全交付。
|
||||||
|
- `未完成/不通过`:关键功能缺失、逻辑不闭环或与需求明显不符。
|
||||||
|
|
||||||
|
## 一、总体结论
|
||||||
|
|
||||||
|
- `可验收`:桌面端主程序已在本地 Windows 跑起,远程 PostgreSQL、Redis 已连通,基础 UI 已打开。
|
||||||
|
- `部分可验收/需优化`:域名导入、聚名采集、时光机基础检测、筛选页、系统设置页、数据库初始化。
|
||||||
|
- `未完成/不通过`:完整检测闭环、部分来源采集、TXT 导出、状态码一致性、数据库任务落库稳定性、筛选条件完整性。
|
||||||
|
|
||||||
|
## 二、可验收项
|
||||||
|
|
||||||
|
### 1. Windows 本地运行环境
|
||||||
|
|
||||||
|
- `状态`:可验收
|
||||||
|
- `结果`:项目已在 Windows + PySide6 环境下启动成功,主窗口标题为“域名工具”。
|
||||||
|
- `说明`:不是必须 Linux,当前更像是 Windows 桌面工具 + 远程 PostgreSQL/Redis 的架构。
|
||||||
|
|
||||||
|
### 2. 基础数据库初始化
|
||||||
|
|
||||||
|
- `状态`:可验收
|
||||||
|
- `结果`:`domains`、`detect_tasks`、`domain_blacklist`、`sensitive_words`、`domain_detections` 表已成功初始化。
|
||||||
|
- `说明`:数据库能连通,初始化脚本已跑通。
|
||||||
|
|
||||||
|
### 3. 基础界面框架
|
||||||
|
|
||||||
|
- `状态`:可验收
|
||||||
|
- `结果`:主界面标签页已创建。
|
||||||
|
- `已见模块`:
|
||||||
|
- 聚名爬取
|
||||||
|
- 域名筛选
|
||||||
|
- 域名导入
|
||||||
|
- 敏感词配置
|
||||||
|
- 系统设置
|
||||||
|
|
||||||
|
### 4. 手工输入/TXT 导入域名
|
||||||
|
|
||||||
|
- `状态`:可验收
|
||||||
|
- `结果`:已有文本框导入、TXT 文件导入、进度显示、去重入库流程。
|
||||||
|
- `说明`:符合“手动批量添加域名、窗口输入或导入 txt”的基础要求。
|
||||||
|
|
||||||
|
### 5. 聚名一口价/过期删除采集界面
|
||||||
|
|
||||||
|
- `状态`:可验收
|
||||||
|
- `结果`:已有聚名采集页面,可选一口价或删除列表,并支持自动入库。
|
||||||
|
- `说明`:基础页面和采集流程已存在,适合做功能复测。
|
||||||
|
|
||||||
|
### 6. 敏感词录入窗口
|
||||||
|
|
||||||
|
- `状态`:可验收
|
||||||
|
- `结果`:已有敏感词配置界面与数据库表。
|
||||||
|
- `说明`:界面层面满足“敏感词录入窗口”。
|
||||||
|
|
||||||
|
## 三、部分可验收/需优化
|
||||||
|
|
||||||
|
### 1. `.com/.net` 域名标准化与过滤
|
||||||
|
|
||||||
|
- `状态`:部分可验收/需优化
|
||||||
|
- `已实现`:
|
||||||
|
- 小写化
|
||||||
|
- 去空格
|
||||||
|
- 去协议
|
||||||
|
- 去路径/参数
|
||||||
|
- 只保留主域
|
||||||
|
- 过滤 `.com/.net`
|
||||||
|
- `需优化`:
|
||||||
|
- 当前实现适合常规导入,但离“全网增量大库”还差批次管理、来源追踪、海量导入策略。
|
||||||
|
|
||||||
|
### 2. 时光机检测
|
||||||
|
|
||||||
|
- `状态`:部分可验收/需优化
|
||||||
|
- `已实现`:
|
||||||
|
- 使用 Wayback CDX API
|
||||||
|
- 获取快照年份
|
||||||
|
- 拉取快照内容做敏感词匹配
|
||||||
|
- 有外链数量统计函数
|
||||||
|
- `需优化`:
|
||||||
|
- 现在更像“基础版”
|
||||||
|
- 只抓最近快照,不是按年份抽样
|
||||||
|
- 敏感词命中规则不完整
|
||||||
|
- 友链数没有完整接入自动筛选闭环
|
||||||
|
|
||||||
|
### 3. 平台检测器插件化结构
|
||||||
|
|
||||||
|
- `状态`:部分可验收/需优化
|
||||||
|
- `已实现`:
|
||||||
|
- RDAP
|
||||||
|
- Wayback
|
||||||
|
- 百度
|
||||||
|
- 360
|
||||||
|
- Google
|
||||||
|
- Chinaz
|
||||||
|
- 爱站
|
||||||
|
- 桔子 SEO
|
||||||
|
- 聚查
|
||||||
|
- `需优化`:
|
||||||
|
- 结构上是插件化了,但规则实现深度、数据落库字段、停机规则还不完整。
|
||||||
|
|
||||||
|
### 4. 运营筛选页面
|
||||||
|
|
||||||
|
- `状态`:部分可验收/需优化
|
||||||
|
- `已实现`:
|
||||||
|
- 注册状态筛选
|
||||||
|
- 使用状态筛选
|
||||||
|
- 检测状态筛选
|
||||||
|
- 复核状态筛选
|
||||||
|
- 备案年份
|
||||||
|
- 快照年份
|
||||||
|
- 网址搜索
|
||||||
|
- 域名搜索
|
||||||
|
- 批量更新部分字段
|
||||||
|
- `需优化`:
|
||||||
|
- 有些筛选项只是界面有,查询 SQL 没完整接上。
|
||||||
|
- 状态枚举和需求定义不完全一致,可能导致筛选结果失真。
|
||||||
|
|
||||||
|
### 5. Redis 集成
|
||||||
|
|
||||||
|
- `状态`:部分可验收/需优化
|
||||||
|
- `已实现`:
|
||||||
|
- Redis 连接
|
||||||
|
- 配置同步
|
||||||
|
- 普通缓存
|
||||||
|
- `需优化`:
|
||||||
|
- Redis Bloom 模块未安装,布隆过滤器不可用,当前已退回普通缓存。
|
||||||
|
|
||||||
|
### 6. 检测端多开思路
|
||||||
|
|
||||||
|
- `状态`:部分可验收/需优化
|
||||||
|
- `已实现`:
|
||||||
|
- 存在独立 `detect_worker.py`
|
||||||
|
- 有单独界面与线程逻辑
|
||||||
|
- `需优化`:
|
||||||
|
- 稳定性、任务调度与数据库连接模型仍需重构后再做压力验收。
|
||||||
|
|
||||||
|
## 四、未完成/不通过项
|
||||||
|
|
||||||
|
### 1. 检测任务落库与完整闭环
|
||||||
|
|
||||||
|
- `状态`:未完成/不通过
|
||||||
|
- `问题`:
|
||||||
|
- 检测任务创建、完成任务清理、检测结果写入这几处数据库代码混用了未初始化的 `self.conn/self.cur`
|
||||||
|
- 导致“入库后自动建任务、检测后写结果”的主流程不稳定
|
||||||
|
- `影响`:这是核心闭环问题,必须整改后再验收。
|
||||||
|
|
||||||
|
### 2. 状态码体系不统一
|
||||||
|
|
||||||
|
- `状态`:未完成/不通过
|
||||||
|
- `问题`:
|
||||||
|
- 注册状态
|
||||||
|
- 检测状态
|
||||||
|
- UI 展示映射
|
||||||
|
- 工具函数映射
|
||||||
|
- 检测器返回值
|
||||||
|
- 上述几套定义互相不一致
|
||||||
|
- `影响`:筛选、导出、拉黑、运营判断都可能错位。
|
||||||
|
|
||||||
|
### 3. 多来源采集未真正完成
|
||||||
|
|
||||||
|
- `状态`:未完成/不通过
|
||||||
|
- `未完成项`:
|
||||||
|
- 搜索引擎采集
|
||||||
|
- 企业目录采集
|
||||||
|
- Zone File 采集
|
||||||
|
- 第三方接口/数据包采集
|
||||||
|
- `说明`:代码里这些入口还是占位实现。
|
||||||
|
|
||||||
|
### 4. 导出 TXT
|
||||||
|
|
||||||
|
- `状态`:未完成/不通过
|
||||||
|
- `需求`:导出 `txt`,一行一个域名
|
||||||
|
- `现状`:当前只支持 Excel/CSV 导出
|
||||||
|
- `影响`:与需求明确不符
|
||||||
|
|
||||||
|
### 5. “友链数量 > 10”筛选未真正生效
|
||||||
|
|
||||||
|
- `状态`:未完成/不通过
|
||||||
|
- `问题`:界面有选项,但查询条件没有完整落到数据库筛选逻辑上。
|
||||||
|
|
||||||
|
### 6. 停止规则未完整落地
|
||||||
|
|
||||||
|
- `状态`:未完成/不通过
|
||||||
|
- `需求`:
|
||||||
|
- 黑名单命中后停止后续检测
|
||||||
|
- 已使用/已卖出/已预定不进运营池
|
||||||
|
- 非可注册且非一口价不进可注册池
|
||||||
|
- `现状`:只实现了部分拉黑中止,完整运营池/可注册池规则没有闭环。
|
||||||
|
|
||||||
|
### 7. 需求中的人工筛选脚本化规则未完整落地
|
||||||
|
|
||||||
|
- `状态`:未完成/不通过
|
||||||
|
- `未完整实现的规则示例`:
|
||||||
|
- 站长之家分类敏感规则
|
||||||
|
- 爱站风险词规则
|
||||||
|
- 百度/360 子域名规则
|
||||||
|
- 百度安全中心危险判定
|
||||||
|
- 聚查备案年份、单位性质、首页一致性规则
|
||||||
|
- 聚查拦截检测规则
|
||||||
|
- 桔子 SEO 历史中文/敏感词/外链锚文本规则
|
||||||
|
|
||||||
|
### 8. 敏感词配置未真正全链路生效
|
||||||
|
|
||||||
|
- `状态`:未完成/不通过
|
||||||
|
- `问题`:虽然有敏感词配置窗口,但部分主检测逻辑仍使用代码里写死的词表。
|
||||||
|
|
||||||
|
### 9. 亿级数据量设计能力不能验收
|
||||||
|
|
||||||
|
- `状态`:未完成/不通过
|
||||||
|
- `问题`:
|
||||||
|
- 当前是桌面工具式实现
|
||||||
|
- 数据库表结构和索引策略不足以证明“支持亿级”
|
||||||
|
- 连接池、批量任务、并发模型仍偏脆弱
|
||||||
|
|
||||||
|
## 五、建议先验收通过的范围
|
||||||
|
|
||||||
|
如果要分阶段验收,当前只建议先验这部分:
|
||||||
|
|
||||||
|
- 本地 Windows 启动成功
|
||||||
|
- 远程 PostgreSQL/Redis 连通
|
||||||
|
- 数据库初始化成功
|
||||||
|
- 主界面与各标签页打开成功
|
||||||
|
- 手工/TXT 导入可用
|
||||||
|
- 聚名页面可打开并具备基础采集入口
|
||||||
|
- 敏感词页面可打开
|
||||||
|
- 基础时光机检测模块存在
|
||||||
|
|
||||||
|
## 六、必须整改后再验收的范围
|
||||||
|
|
||||||
|
- 检测任务自动创建与检测结果落库
|
||||||
|
- 状态码统一
|
||||||
|
- 检测闭环完整性
|
||||||
|
- TXT 导出
|
||||||
|
- 多来源采集补齐
|
||||||
|
- 关键人工规则脚本化
|
||||||
|
- 敏感词全链路配置化
|
||||||
|
- 运营筛选条件完整生效
|
||||||
|
|
||||||
|
## 七、建议优化项
|
||||||
|
|
||||||
|
- 数据库连接池继续做成环境配置,区分测试/生产
|
||||||
|
- Redis Bloom 如有需要可安装 RedisBloom 模块
|
||||||
|
- 配置加载时避免界面初始化阶段频繁重复写 Redis
|
||||||
|
- 敏感词、风险词、平台规则拆成可维护配置
|
||||||
|
- 导出支持 TXT/CSV/Excel 三种
|
||||||
|
- 数据库增加更清晰的来源批次、任务批次、失败重试日志
|
||||||
|
- 清理明文账号密码存储问题
|
||||||
|
|
||||||
|
## 八、建议的复测顺序
|
||||||
|
|
||||||
|
1. 域名导入
|
||||||
|
2. 聚名采集入库
|
||||||
|
3. 注册状态检测
|
||||||
|
4. 时光机快照年份与敏感词检测
|
||||||
|
5. 平台检测结果写库
|
||||||
|
6. 筛选条件查询
|
||||||
|
7. 批量更新使用状态/人工复核状态
|
||||||
|
8. TXT 导出
|
||||||
|
|
||||||
|
## 九、当前验收结论
|
||||||
|
|
||||||
|
- `阶段性可验收`:环境、界面、基础导入、基础采集、基础数据库
|
||||||
|
- `不能整体验收通过`:核心业务闭环还不完整,尤其是任务写库、状态码一致性、TXT 导出和人工规则脚本化
|
||||||
|
|
||||||
426
docs/02_domainCheck_优化清单_待审核.md
Normal file
426
docs/02_domainCheck_优化清单_待审核.md
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
# domainCheck 优化清单(待审核)
|
||||||
|
|
||||||
|
这份清单用于你先做取舍,不是默认全部都要做。
|
||||||
|
|
||||||
|
## 当前实施状态
|
||||||
|
|
||||||
|
- `统计时间`:2026-04-15
|
||||||
|
- `说明`:以下状态基于当前代码、启动验证、数据库结构补齐和本地联调结果更新。
|
||||||
|
- `状态口径`:
|
||||||
|
- `已完成`:代码已落地,基础验证已通过
|
||||||
|
- `已完成,待复测`:代码已落地,但还需要你结合真实业务样本再跑一轮人工回归
|
||||||
|
- `继续优化`:已做一部分,但还没完全做到最终验收态
|
||||||
|
- `暂不做`:按你的审核备注,本轮不纳入
|
||||||
|
|
||||||
|
## 最近回归
|
||||||
|
|
||||||
|
- `回归时间`:2026-04-16
|
||||||
|
- `回归方式`:脚本化构造测试域名,直接验证导入、筛选、导出、批量更新关键链路
|
||||||
|
- `已通过项`:
|
||||||
|
- 备案筛选能区分“有备案记录 / 没有备案记录”
|
||||||
|
- `友链 > 10` 筛选可真实命中
|
||||||
|
- 检测时间字段可正常读出
|
||||||
|
- TXT 导出可一行一个域名
|
||||||
|
- 分页导出底层查询链路可正常返回分页结果
|
||||||
|
- 批量更新选中域名在真实 Unicode 参数下可正常更新 `review_status / has_beian / detect_time / website_url / backlink_count / domain_detections`
|
||||||
|
- `仍需人工复测项`:
|
||||||
|
- GUI 实际点击流程的导入与导出交互
|
||||||
|
- 检测端联动聚查、桔子补查
|
||||||
|
- 时光机全快照倒序扫描、标题判词、命中即停的真实网络耗时与命中效果
|
||||||
|
|
||||||
|
## 最新检测联调
|
||||||
|
|
||||||
|
- `联调时间`:2026-04-16
|
||||||
|
- `联调范围`:导入建任务、补查取数、单域名真实检测执行、单域名命中黑名单后中止
|
||||||
|
- `已验证结果`:
|
||||||
|
- 导入 TXT 域名后可自动创建 `detect_tasks`
|
||||||
|
- 聚查、桔子二次补查取数正常,且已拉黑域名不会进入补查队列
|
||||||
|
- 单域名走“正常完成”路径时:
|
||||||
|
- `detect_status` 会更新为检测完成
|
||||||
|
- `detect_time` 会写入
|
||||||
|
- `review_status` 会更新为待人工复核
|
||||||
|
- 当满足条件时 `expire_date` 会被清空
|
||||||
|
- 单域名走“命中中止”路径时:
|
||||||
|
- `detect_status` 会更新为黑名单
|
||||||
|
- 黑名单原因会写入 `domain_blacklist`
|
||||||
|
- 时光机产生的 `snapshot_years` 可正常落库;当前按审核口径改为 title-only 检测后,`backlink_count / backlink_count_gt_10` 默认不再作为时光机产出字段
|
||||||
|
- 命中后不会写入 `detect_time`
|
||||||
|
- `当前风险`:
|
||||||
|
- Wayback 对历史快照极多的域名,主要瓶颈已收敛到 CDX 时间戳列表获取;首次扫描超大域名仍可能受网络波动影响,但已补充 Redis 时间戳缓存,二次检测会明显更快
|
||||||
|
|
||||||
|
分级说明:
|
||||||
|
- `P0 必须做`:不做会影响验收、主流程闭环或结果正确性。
|
||||||
|
- `P1 建议做`:不一定阻塞首轮验收,但会明显影响实用性、稳定性或后续维护。
|
||||||
|
- `P2 可选做`:属于增强项、体验项、扩展项,可按预算和阶段决定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、P0 必须做
|
||||||
|
|
||||||
|
### 1. 检测任务落库闭环修复
|
||||||
|
|
||||||
|
- `当前状态`:`已完成,待复测`
|
||||||
|
|
||||||
|
- `目的`:保证域名入库后,待检测任务能正确创建、执行、回写结果。
|
||||||
|
- `当前问题`:数据库代码里任务创建、任务清理、结果写入存在连接对象使用不一致的问题,主流程不稳定。
|
||||||
|
- `不做影响`:检测流程可能根本不闭环,属于核心不通过项。
|
||||||
|
- `建议结论`:`必须做`
|
||||||
|
|
||||||
|
### 2. 检测结果写库修复
|
||||||
|
|
||||||
|
- `当前状态`:`已完成,待复测`
|
||||||
|
|
||||||
|
- `目的`:让百度/360/Google/时光机/聚查等结果能稳定入库。
|
||||||
|
- `当前问题`:部分结果写入逻辑依赖不稳定的数据库连接对象。
|
||||||
|
- `不做影响`:界面查得到的结果不可信,后续筛选和导出都会失真。
|
||||||
|
- `建议结论`:`必须做`
|
||||||
|
|
||||||
|
### 3. 状态码统一
|
||||||
|
|
||||||
|
- `当前状态`:`已完成`
|
||||||
|
|
||||||
|
- `范围`:
|
||||||
|
- 注册状态
|
||||||
|
- 检测状态
|
||||||
|
- 使用状态
|
||||||
|
- 复核状态
|
||||||
|
- UI 显示映射
|
||||||
|
- 检测器返回值
|
||||||
|
- `当前问题`:需求文档、数据库、检测器、筛选页的状态定义不一致。
|
||||||
|
- `不做影响`:筛选结果错误,黑名单/正常/可注册等状态可能串位。
|
||||||
|
- `建议结论`:`必须做`
|
||||||
|
|
||||||
|
### 4. TXT 导出补齐
|
||||||
|
|
||||||
|
- `当前状态`:`已完成`
|
||||||
|
|
||||||
|
- `目的`:满足需求中的“导出 txt,一行一个域名”。
|
||||||
|
- `当前问题`:当前只支持 Excel/CSV。
|
||||||
|
- `不做影响`:需求明确不符合,验收容易直接打回。
|
||||||
|
- `建议结论`:`必须做`
|
||||||
|
审核:这个其实不影响,或者你可以多加一个导出为txt 选项
|
||||||
|
### 5. 筛选条件真正落库生效
|
||||||
|
|
||||||
|
- `当前状态`:`已完成,待复测`
|
||||||
|
|
||||||
|
- `重点项`:
|
||||||
|
- 快照年份
|
||||||
|
- 备案年份
|
||||||
|
- 首页网址
|
||||||
|
- 友链数是否大于 10
|
||||||
|
- 是否有备案历史
|
||||||
|
- 部分状态筛选
|
||||||
|
- `当前问题`:有些条件只有界面,没有真正进入 SQL 查询。
|
||||||
|
- `不做影响`:运营筛选失真,界面“看起来有”但实际不可用。
|
||||||
|
- `建议结论`:`必须做`
|
||||||
|
|
||||||
|
### 6. 敏感词全链路配置化
|
||||||
|
|
||||||
|
- `当前状态`:`已完成,待复测`
|
||||||
|
|
||||||
|
- `目的`:让敏感词配置窗口真正决定检测规则。
|
||||||
|
- `当前问题`:主流程里仍有写死词表。
|
||||||
|
- `不做影响`:虽然有敏感词页面,但配置不能完全生效,需求不满足。
|
||||||
|
- `建议结论`:`必须做`
|
||||||
|
审核:必须要读取软件出口提交的 敏感词
|
||||||
|
|
||||||
|
### 7. 停止规则补齐
|
||||||
|
|
||||||
|
- `当前状态`:`继续优化`
|
||||||
|
|
||||||
|
- `需求核心`:
|
||||||
|
- 黑名单命中后停止后续检测
|
||||||
|
- 已使用/已卖出/已预定不进运营池
|
||||||
|
- 非可注册且非一口价不进入可注册池
|
||||||
|
- `当前问题`:只实现了部分拉黑中止,完整规则未闭环。
|
||||||
|
- `不做影响`:浪费检测资源,筛选池不准确。
|
||||||
|
- `建议结论`:`必须做`
|
||||||
|
|
||||||
|
### ++ 8. 聚查、桔子增加独立检测状态
|
||||||
|
|
||||||
|
- `当前状态`:`已完成,待复测`
|
||||||
|
|
||||||
|
- `优先级`:`P0 必须做`
|
||||||
|
- `目的`:为聚查、桔子增加“未检测 / 已检测”状态,用于支持增量补查。
|
||||||
|
- `人工验收反馈`:
|
||||||
|
- 第一次检测时如果未勾选聚查、桔子,则只按当前检测选项执行
|
||||||
|
- 第二次检测时如果勾选了聚查、桔子,则数据库中尚未检测过聚查、桔子的域名需要补查一次
|
||||||
|
- 若选中的流程全部执行完且未命中黑名单,则总状态应更新为“检测完成”
|
||||||
|
- `不做影响`:二次检测无法按需补查,检测状态判断不准确。
|
||||||
|
|
||||||
|
### ++ 9. 检测选项配置与实际检测流程需一致
|
||||||
|
|
||||||
|
- `当前状态`:`已完成,待复测`
|
||||||
|
|
||||||
|
- `优先级`:`P0 必须做`
|
||||||
|
- `人工验收反馈`:
|
||||||
|
- 目前仅希望配置以下检测项:
|
||||||
|
- 检查注册
|
||||||
|
- 站长之家查询
|
||||||
|
- 爱站网查询
|
||||||
|
- 百度 site 查询
|
||||||
|
- 360 的 site 查询
|
||||||
|
- 域名检测端运行十多个小时仍在跑,用户无法理解实际执行范围
|
||||||
|
- `优化要求`:
|
||||||
|
- 系统设置中的检测选项必须与实际检测流程一致
|
||||||
|
- 未勾选的检测项不得执行
|
||||||
|
- 需要有明确的执行状态反馈
|
||||||
|
|
||||||
|
### ++ 10. 域名筛选结果需显示检测时间
|
||||||
|
|
||||||
|
- `当前状态`:`已完成`
|
||||||
|
|
||||||
|
- `优先级`:`P0 必须做`
|
||||||
|
- `人工验收反馈`:
|
||||||
|
- 条件“注册状态=可注册、使用状态=未使用、检测状态=检测完成”查询后,没有显示检测时间
|
||||||
|
- 用户定义:检测时间 = 检测完成时间
|
||||||
|
- `不做影响`:运营无法判断结果数据的新旧。
|
||||||
|
|
||||||
|
### ++ 11. 域名筛选导出需支持导出多页或全部
|
||||||
|
|
||||||
|
- `当前状态`:`已完成`
|
||||||
|
|
||||||
|
- `优先级`:`P0 必须做`
|
||||||
|
- `人工验收反馈`:
|
||||||
|
- 当前默认一页 100 条,需增加“导出几页”或“导出全部”的功能
|
||||||
|
- `不做影响`:运营无法批量导出完整结果。
|
||||||
|
|
||||||
|
### ++ 12. “是否有备案”筛选需真实生效
|
||||||
|
|
||||||
|
- `当前状态`:`已完成,待复测`
|
||||||
|
|
||||||
|
- `优先级`:`P0 必须做`
|
||||||
|
- `人工验收反馈`:
|
||||||
|
- 域名筛选中是否勾选“是否有备案”,出现的域名结果都一样
|
||||||
|
- `不做影响`:筛选结果不可信,需求不满足。
|
||||||
|
|
||||||
|
### ++ 13. 批量更新选中域名功能需修复
|
||||||
|
|
||||||
|
- `当前状态`:`已完成,待复测`
|
||||||
|
|
||||||
|
- `优先级`:`P0 必须做`
|
||||||
|
- `人工验收反馈`:
|
||||||
|
- 已选中域名后执行“批量更新选中域名”,提示“成功更新0个域名的信息”
|
||||||
|
- `不做影响`:运营批量操作不可用。
|
||||||
|
|
||||||
|
### ++ 14. 检测选项配置中需补充时光机检测开关
|
||||||
|
|
||||||
|
- `当前状态`:`已完成`
|
||||||
|
|
||||||
|
- `优先级`:`P0 必须做`
|
||||||
|
- `人工验收反馈`:
|
||||||
|
- 系统设置中的检测选项没有“时光机检测”,但检测流程第二步就是时光机
|
||||||
|
- `不做影响`:配置项与实际业务流程不一致,无法控制时光机是否执行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、P1 建议做
|
||||||
|
|
||||||
|
### 8. 时光机检测按审核口径升级为“全快照倒序扫描标题,命中即停”
|
||||||
|
|
||||||
|
- `当前状态`:`已完成,待复测`
|
||||||
|
|
||||||
|
- `当前状态`:已使用 Wayback/CDX API 获取全量快照时间戳。
|
||||||
|
- `当前实现`:
|
||||||
|
- 按时间倒序扫描全部快照
|
||||||
|
- 仅抓取每个快照的 `title`,不再抓取正文
|
||||||
|
- 按 `title` 去重
|
||||||
|
- 先快速检查最新快照,命中后不再继续拉全量快照列表
|
||||||
|
- 同时使用 CDX 返回的 `digest` 先做一轮内容级去重,减少无效 title 请求
|
||||||
|
- 任一快照标题命中敏感词后立即停止后续扫描
|
||||||
|
- 已增加 Redis 持久缓存:标题缓存 + 时间戳列表缓存
|
||||||
|
- `剩余风险`:首次扫描历史快照极多的老域名时,CDX 时间戳列表获取仍可能偏慢。
|
||||||
|
- `建议结论`:`已完成,继续复测性能`
|
||||||
|
- 审核备注:必须全部快照检查,不能随机;只要一个快照标题命中敏感词,则判定黑名单并停止后续扫描
|
||||||
|
|
||||||
|
### 9. 友链数量自动落库和自动筛选联动
|
||||||
|
|
||||||
|
- `当前状态`:`继续优化`
|
||||||
|
|
||||||
|
- `当前状态`:筛选链路已支持 `友链 > 10`,但按当前审核口径改为 title-only 时光机检测后,不再从时光机正文自动计算友链数量。
|
||||||
|
- `建议价值`:如后续仍需自动产出友链数量,需要单独补一条正文抓取或其他来源统计链路。
|
||||||
|
- `是否阻塞首轮验收`:`看是否把友链自动检测作为必验项`
|
||||||
|
- `建议结论`:`按你的复测结果决定是否继续做`
|
||||||
|
|
||||||
|
### 10. 人工筛选规则脚本化补齐
|
||||||
|
|
||||||
|
- `当前状态`:`继续优化`
|
||||||
|
|
||||||
|
- `范围`:
|
||||||
|
- 站长之家标题/分类规则
|
||||||
|
- 爱站风险词规则
|
||||||
|
- 百度/360 子域名规则
|
||||||
|
- 聚查备案年份/单位性质/首页一致性/拦截规则
|
||||||
|
- 桔子 SEO 历史词、中文标题、外链锚文本规则
|
||||||
|
- `当前状态`:有平台检测器,但规则覆盖不完整。
|
||||||
|
- `建议价值`:这是把“人工流程”转成“机器流程”的关键。
|
||||||
|
- `是否阻塞首轮验收`:看甲方是否按文档逐条验。
|
||||||
|
- `建议结论`:`建议做`
|
||||||
|
|
||||||
|
### 11. 检测端稳定性优化
|
||||||
|
|
||||||
|
- `当前状态`:`已完成一轮基础优化,待长时复测`
|
||||||
|
|
||||||
|
- `内容`:
|
||||||
|
- 连接池配置化
|
||||||
|
- 并发线程数控制
|
||||||
|
- 失败重试逻辑梳理
|
||||||
|
- 任务队列模型修正
|
||||||
|
- `当前状态`:已把默认连接池降小,但整体并发/连接模型仍偏脆弱。
|
||||||
|
- `建议价值`:适合进入持续检测前做。
|
||||||
|
- `是否阻塞首轮验收`:`通常不阻塞`
|
||||||
|
- `建议结论`:`建议做`
|
||||||
|
|
||||||
|
### 12. 配置写入频率优化
|
||||||
|
|
||||||
|
- `当前状态`:`已完成`
|
||||||
|
|
||||||
|
- `当前问题`:系统设置页初始化过程中会多次重复写本地文件/Redis。
|
||||||
|
- `建议价值`:降低噪音日志和无谓写入。
|
||||||
|
- `是否阻塞首轮验收`:`不阻塞`
|
||||||
|
- `建议结论`:`建议做`
|
||||||
|
|
||||||
|
### 13. 明文密码存储整改
|
||||||
|
|
||||||
|
- `当前状态`:`已完成`
|
||||||
|
|
||||||
|
- `当前问题`:本地文件中存在明文账号密码存储。
|
||||||
|
- `建议价值`:安全性和交付规范更好。
|
||||||
|
- `是否阻塞首轮验收`:多数情况下不阻塞功能验收,但属于明显风险。
|
||||||
|
- `建议结论`:`建议做`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、P2 可选做
|
||||||
|
|
||||||
|
### 14. 搜索引擎采集补齐
|
||||||
|
|
||||||
|
- `当前状态`:`暂不做`
|
||||||
|
|
||||||
|
- `内容`:根据关键词从搜索引擎持续采集域名入库。
|
||||||
|
- `当前状态`:有占位入口,未真正实现。
|
||||||
|
- `价值`:有利于做“全网增量建设”。
|
||||||
|
- `是否必须`:如果当前先验一口价+删除列表+手工导入,不一定必须。
|
||||||
|
- `建议结论`:`可选做`
|
||||||
|
审核:这个可以暂时不做
|
||||||
|
|
||||||
|
### 15. 企业目录采集补齐
|
||||||
|
|
||||||
|
- `当前状态`:`暂不做`
|
||||||
|
|
||||||
|
- `内容`:抓取企业目录网站的公司域名。
|
||||||
|
- `当前状态`:有占位入口,未实现。
|
||||||
|
- `价值`:增强全网增量来源。
|
||||||
|
- `是否必须`:首轮不一定必须。
|
||||||
|
- `建议结论`:`可选做`
|
||||||
|
审核:这个可以暂时不做
|
||||||
|
|
||||||
|
### 16. Zone File 采集补齐
|
||||||
|
|
||||||
|
- `当前状态`:`暂不做`
|
||||||
|
|
||||||
|
- `当前状态`:有占位入口,未实现。
|
||||||
|
- `价值`:适合后期扩充大库。
|
||||||
|
- `是否必须`:当前阶段通常不是必须。
|
||||||
|
- `建议结论`:`可选做`
|
||||||
|
审核:这个可以暂时不做
|
||||||
|
|
||||||
|
### 17. 第三方 API/数据包接入
|
||||||
|
|
||||||
|
- `当前状态`:`继续优化`
|
||||||
|
|
||||||
|
- `当前状态`:需求有提及,但项目未形成稳定接入方案。
|
||||||
|
- `价值`:提升数据来源多样性。
|
||||||
|
- `是否必须`:不是首轮必做。
|
||||||
|
- `建议结论`:`可选做`
|
||||||
|
审核:这个可以暂时不做
|
||||||
|
|
||||||
|
### 18. Redis Bloom 模块支持
|
||||||
|
|
||||||
|
- `当前状态`:`继续优化`
|
||||||
|
|
||||||
|
- `当前状态`:Redis 已通,但未安装 RedisBloom,当前已退回普通缓存。
|
||||||
|
- `价值`:对海量去重更有帮助。
|
||||||
|
- `是否必须`:首轮本地联调不必须。
|
||||||
|
- `建议结论`:`可选做`
|
||||||
|
|
||||||
|
### 19. 亿级数据量数据库专项优化
|
||||||
|
|
||||||
|
- `当前状态`:`暂不做`
|
||||||
|
|
||||||
|
- `内容`:
|
||||||
|
- 更细索引策略
|
||||||
|
- 分区或分表设计
|
||||||
|
- 任务批次管理
|
||||||
|
- 大批量导入方案
|
||||||
|
- `价值`:面向大规模生产阶段。
|
||||||
|
- `是否必须`:当前桌面版联调阶段不是必须。
|
||||||
|
- `建议结论`:`可选做`
|
||||||
|
审核:这个可以暂时不做
|
||||||
|
|
||||||
|
### 20. UI 体验类优化
|
||||||
|
|
||||||
|
- `当前状态`:`继续优化`
|
||||||
|
|
||||||
|
- `内容`:
|
||||||
|
- 更清晰的状态说明
|
||||||
|
- 批量操作反馈优化
|
||||||
|
- 查询条件联动提示
|
||||||
|
- 导出成功/失败结果更明确
|
||||||
|
- `是否必须`:非核心
|
||||||
|
- `建议结论`:`可选做`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、建议你审核时优先决定的项
|
||||||
|
|
||||||
|
这几项建议你先拍板,因为会直接决定我后面怎么开工:
|
||||||
|
|
||||||
|
- 是否把 `TXT 导出` 作为首轮必须项
|
||||||
|
- 是否把 `时光机按年份抽样` 作为首轮必须项
|
||||||
|
- 是否把 `人工筛选规则脚本化` 作为首轮必须项
|
||||||
|
- 是否把 `搜索引擎/企业目录/Zone file 采集` 放到二期
|
||||||
|
- 是否需要同步处理 `明文密码存储`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、我建议的默认实施范围
|
||||||
|
|
||||||
|
如果你不想一次做太大,我建议默认先做下面这些:
|
||||||
|
|
||||||
|
### 默认先做
|
||||||
|
|
||||||
|
- 检测任务落库闭环修复
|
||||||
|
- 检测结果写库修复
|
||||||
|
- 状态码统一
|
||||||
|
- TXT 导出
|
||||||
|
- 筛选条件真正生效
|
||||||
|
- 敏感词全链路配置化
|
||||||
|
- 停止规则补齐
|
||||||
|
|
||||||
|
### 默认后做
|
||||||
|
|
||||||
|
- 时光机按年份抽样
|
||||||
|
- 人工筛选规则脚本化补齐
|
||||||
|
- 安全整改
|
||||||
|
- 稳定性与架构优化
|
||||||
|
|
||||||
|
### 默认不纳入首轮
|
||||||
|
|
||||||
|
- 搜索引擎采集
|
||||||
|
- 企业目录采集
|
||||||
|
- Zone file 采集
|
||||||
|
- 第三方 API/数据包扩展
|
||||||
|
- Redis Bloom
|
||||||
|
- 亿级数据量专项优化
|
||||||
|
|
||||||
|
|
||||||
|
审核总结:
|
||||||
|
## 一、P0 必须做 必须做
|
||||||
|
## 二、P1 建议做 必须做
|
||||||
|
## 三、P2 可选做 根据我审核备注来做,没审核的做
|
||||||
|
|
||||||
|
当前执行结论:
|
||||||
|
- `P0`:已基本落地,建议你按“导入 -> 检测 -> 筛选 -> 导出 -> 批量更新”做一轮人工复测
|
||||||
|
- `P1`:已完成大部分主干优化,剩余重点在“停止规则细化”和“人工筛选规则脚本化”
|
||||||
|
- `P2`:已按你的备注暂缓未审核项,不影响当前主线联调
|
||||||
427
docs/03_domainCheck_整改清单_外包版.md
Normal file
427
docs/03_domainCheck_整改清单_外包版.md
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
# domainCheck 项目整改清单(外包沟通版)
|
||||||
|
|
||||||
|
本文档用于当前版本项目的整改确认与后续复测对齐。
|
||||||
|
依据为现有交付代码、运行联调结果及 [需求文档内容_utf8.txt](/d:/www/py/domainCheck/需求文档内容_utf8.txt:1)。
|
||||||
|
|
||||||
|
请外包方根据本清单逐项确认:
|
||||||
|
- 是否已完成
|
||||||
|
- 如未完成,预计整改方案与时间
|
||||||
|
- 如有与需求理解不一致之处,请逐项书面说明
|
||||||
|
|
||||||
|
## 当前联调状态
|
||||||
|
|
||||||
|
- `更新时间`:2026-04-15
|
||||||
|
- `说明`:以下状态由当前代码联调结果同步,便于外包方按项书面回复。
|
||||||
|
- `状态口径`:
|
||||||
|
- `已整改`:代码已落地,待外包方和甲方复测确认
|
||||||
|
- `整改中`:已有部分优化,但仍需继续补齐
|
||||||
|
- `暂缓`:按当前阶段安排,不纳入首轮阻塞项
|
||||||
|
|
||||||
|
## 最近复测结果
|
||||||
|
|
||||||
|
- `复测时间`:2026-04-16
|
||||||
|
- `复测范围`:导入测试数据、筛选查询、TXT/分页导出、批量更新
|
||||||
|
- `复测结论`:
|
||||||
|
- 备案筛选、友链大于 10 筛选、检测时间展示、TXT 导出、分页导出底层链路均已通过脚本化复测
|
||||||
|
- 批量更新选中域名在实际代码链路下已验证可正常更新数据库
|
||||||
|
- 当前仍建议外包方配合做一轮 GUI 人工复测与检测端全流程复测,再关闭整改项
|
||||||
|
|
||||||
|
## 最新检测联调结果
|
||||||
|
|
||||||
|
- `联调时间`:2026-04-16
|
||||||
|
- `联调范围`:导入建任务、聚查/桔子补查取数、单域名检测完成路径、单域名命中黑名单路径
|
||||||
|
- `联调结论`:
|
||||||
|
- TXT 导入后可自动创建待检测任务
|
||||||
|
- 聚查、桔子补查逻辑已可按状态取数,且黑名单域名不会再次进入补查
|
||||||
|
- 单域名检测完成后,`detect_status / detect_time / review_status / expire_date` 联动已验证正常
|
||||||
|
- 单域名命中风险后,`detect_status / domain_blacklist / 时光机附加字段` 联动已验证正常
|
||||||
|
- 当前 Wayback 已按最新验收口径调整为“全快照倒序扫描标题、命中即停、标题去重、结果缓存”
|
||||||
|
- 当前保留风险主要为超大域名首次获取 CDX 时间戳列表时的耗时问题;已补充 Redis 时间戳缓存,建议外包方继续优化首次全量列表获取策略
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、整改结论
|
||||||
|
|
||||||
|
当前版本可以完成基础启动、基础界面展示、远程数据库连接与部分导入/采集功能,但**暂不具备整体验收通过条件**。
|
||||||
|
主要原因是核心检测闭环、状态定义一致性、部分导出与筛选逻辑、以及多项需求规则尚未完整落地。
|
||||||
|
|
||||||
|
为避免后续复测口径不一致,现将整改事项分为以下三类:
|
||||||
|
- `一类问题`:必须整改,整改完成后方可进入整体验收
|
||||||
|
- `二类问题`:建议整改,影响可用性、稳定性或需求完整度
|
||||||
|
- `三类问题`:可按阶段排期,属于增强项或扩展项
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、一类问题(必须整改)
|
||||||
|
|
||||||
|
### 1. 检测任务创建、执行、结果回写需形成完整闭环
|
||||||
|
|
||||||
|
- `当前状态`:`已整改`
|
||||||
|
|
||||||
|
- `问题说明`:当前版本中,域名入库后“创建待检测任务”、检测完成后“回写检测结果”的流程不稳定,部分数据库写入逻辑存在连接对象使用不一致的问题。
|
||||||
|
- `需求依据`:需求文档明确要求入库后创建待检测任务,并通过流水线方式持续检测与更新数据库状态。
|
||||||
|
- `整改要求`:
|
||||||
|
- 保证入库后可稳定创建待检测任务
|
||||||
|
- 保证检测完成后可稳定写入检测结果
|
||||||
|
- 保证失败任务、重试任务、已完成任务状态可追踪
|
||||||
|
- `验收标准`:
|
||||||
|
- 随机导入一批域名后,可在数据库中看到对应任务
|
||||||
|
- 执行检测后,数据库状态与检测结果表有完整回写
|
||||||
|
|
||||||
|
### 2. 状态码定义必须统一
|
||||||
|
|
||||||
|
- `当前状态`:`已整改`
|
||||||
|
|
||||||
|
- `问题说明`:当前项目中注册状态、检测状态等枚举值在数据库、检测器、工具函数、界面显示之间存在不一致。
|
||||||
|
- `需求依据`:需求文档已给出明确状态定义。
|
||||||
|
- `整改要求`:
|
||||||
|
- 统一注册状态枚举
|
||||||
|
- 统一检测状态枚举
|
||||||
|
- 统一使用状态、人工复核状态枚举
|
||||||
|
- 确保数据库值、代码逻辑、界面显示、导出结果一致
|
||||||
|
- `验收标准`:
|
||||||
|
- 任取一个域名,其数据库状态、界面显示、导出内容含义一致
|
||||||
|
|
||||||
|
### 3. 导出功能需补齐 TXT 格式
|
||||||
|
|
||||||
|
- `当前状态`:`已整改`
|
||||||
|
|
||||||
|
- `问题说明`:需求要求“导出 TXT,一行一个域名”,当前版本仅支持 Excel/CSV。
|
||||||
|
- `需求依据`:运营导出明确要求 TXT 格式。
|
||||||
|
- `整改要求`:
|
||||||
|
- 增加 TXT 导出
|
||||||
|
- 每行一个域名
|
||||||
|
- 导出内容与当前筛选结果一致
|
||||||
|
- `验收标准`:
|
||||||
|
- 在筛选结果页导出 TXT,文件内容符合“一行一个域名”
|
||||||
|
|
||||||
|
### 4. 筛选条件必须真实生效,不能仅停留在界面层
|
||||||
|
|
||||||
|
- `当前状态`:`已整改`
|
||||||
|
|
||||||
|
- `问题说明`:当前部分筛选项虽已在界面存在,但未完整进入查询逻辑,导致界面与结果不一致。
|
||||||
|
- `重点项`:
|
||||||
|
- 注册状态
|
||||||
|
- 检测状态
|
||||||
|
- 使用状态
|
||||||
|
- 备案年份
|
||||||
|
- 快照年份
|
||||||
|
- 是否有备案历史
|
||||||
|
- 友链数量是否大于 10
|
||||||
|
- 首页网址筛选
|
||||||
|
- `整改要求`:
|
||||||
|
- 所有界面筛选条件必须真正作用于数据库查询
|
||||||
|
- 查询结果必须与筛选条件一致
|
||||||
|
- `验收标准`:
|
||||||
|
- 通过构造测试数据,验证各筛选项结果准确
|
||||||
|
|
||||||
|
### 5. 敏感词配置必须全链路生效
|
||||||
|
|
||||||
|
- `当前状态`:`已整改`
|
||||||
|
|
||||||
|
- `问题说明`:当前虽然存在敏感词配置页面,但部分检测逻辑仍使用写死词表。
|
||||||
|
- `需求依据`:需求明确要求“敏感词、风险词必须可配置”。
|
||||||
|
- `整改要求`:
|
||||||
|
- 检测流程统一从配置或数据库读取敏感词
|
||||||
|
- 不再保留独立的写死敏感词规则作为主判定来源
|
||||||
|
- `验收标准`:
|
||||||
|
- 新增/删除敏感词后,检测结果可随配置变化
|
||||||
|
|
||||||
|
### 6. 停止规则需完整落实
|
||||||
|
|
||||||
|
- `当前状态`:`整改中`
|
||||||
|
|
||||||
|
- `问题说明`:需求文档中对停止检测、进入候选池、排除运营池等有明确规则,当前只实现了部分中止逻辑。
|
||||||
|
- `整改要求`:
|
||||||
|
- 黑名单命中后停止后续检测
|
||||||
|
- 已使用/已卖出/已预定域名不进入运营候选池
|
||||||
|
- 非可注册且非一口价域名不进入“可注册域名池”
|
||||||
|
- `验收标准`:
|
||||||
|
- 按规则构造测试样本,流程结果符合需求定义
|
||||||
|
|
||||||
|
### 7. 核心检测流程需与需求步骤一致
|
||||||
|
|
||||||
|
- `当前状态`:`整改中`
|
||||||
|
|
||||||
|
- `问题说明`:当前版本已有基础检测器,但与需求中的完整筛选链路仍有差距。
|
||||||
|
- `需求链路`:
|
||||||
|
- 注册状态检测
|
||||||
|
- 黑名单缓存检查
|
||||||
|
- 时光机基础筛选
|
||||||
|
- 各平台深度检测
|
||||||
|
- 备案检测
|
||||||
|
- 更新数据库
|
||||||
|
- 运营筛选导出
|
||||||
|
- `整改要求`:
|
||||||
|
- 明确各步骤执行顺序
|
||||||
|
- 明确每一步的中止条件
|
||||||
|
- 明确每一步的结果回写字段
|
||||||
|
- `验收标准`:
|
||||||
|
- 随机抽取样本域名,能完整追踪检测执行链路
|
||||||
|
|
||||||
|
### ++ 8. 聚查、桔子需增加独立检测状态并支持增量补查
|
||||||
|
|
||||||
|
- `当前状态`:`已整改`
|
||||||
|
|
||||||
|
- `问题说明`:人工验收反馈中提出,聚查、桔子当前缺少独立检测状态,无法区分“未检测”和“已检测”,导致二次检测场景下无法按需补查。
|
||||||
|
- `业务要求`:
|
||||||
|
- 第一次检测时,如未勾选聚查、桔子,则仅按当次检测选项执行
|
||||||
|
- 第二次检测时,如新增勾选聚查、桔子,则数据库中尚未检测过聚查、桔子的域名需补查一次
|
||||||
|
- 当本次选中的流程全部执行完且未命中黑名单时,总检测状态应更新为“检测完成”
|
||||||
|
- `整改要求`:
|
||||||
|
- 为聚查、桔子增加独立检测状态字段或等效机制
|
||||||
|
- 支持按子项状态触发增量补查
|
||||||
|
- 明确总检测状态与子项检测状态的联动关系
|
||||||
|
- `验收标准`:
|
||||||
|
- 构造“第一次未勾选、第二次勾选”的测试场景,验证聚查、桔子可正确补查
|
||||||
|
|
||||||
|
### ++ 9. 检测选项配置需与实际执行流程保持一致
|
||||||
|
|
||||||
|
- `当前状态`:`已整改`
|
||||||
|
|
||||||
|
- `问题说明`:人工验收反馈指出,系统设置中仅配置了少量检测项,但检测端运行耗时异常,无法明确系统实际执行了哪些检测流程。
|
||||||
|
- `人工反馈重点`:
|
||||||
|
- 当前希望可配置的检测项包括:
|
||||||
|
- 检查注册
|
||||||
|
- 站长之家查询
|
||||||
|
- 爱站网查询
|
||||||
|
- 百度 site 查询
|
||||||
|
- 360 site 查询
|
||||||
|
- `整改要求`:
|
||||||
|
- 系统设置中的检测选项必须与实际执行逻辑严格一致
|
||||||
|
- 未勾选的检测项不得执行
|
||||||
|
- 检测端应能清晰反映当前执行项
|
||||||
|
- `验收标准`:
|
||||||
|
- 在仅勾选部分检测项时,日志与结果仅体现对应检测流程
|
||||||
|
|
||||||
|
### ++ 10. 域名筛选结果中需显示检测时间
|
||||||
|
|
||||||
|
- `当前状态`:`已整改`
|
||||||
|
|
||||||
|
- `问题说明`:人工验收反馈中,在“注册状态=可注册、使用状态=未使用、检测状态=检测完成”条件下查询后,结果列表未显示检测时间。
|
||||||
|
- `业务口径`:检测时间 = 检测完成时间。
|
||||||
|
- `整改要求`:
|
||||||
|
- 检测完成后必须回写检测完成时间
|
||||||
|
- 筛选结果页需显示该时间
|
||||||
|
- `验收标准`:
|
||||||
|
- 随机选取已检测完成域名,列表中可看到检测完成时间
|
||||||
|
|
||||||
|
### ++ 11. 域名筛选导出需支持“导出多页”或“导出全部”
|
||||||
|
|
||||||
|
- `当前状态`:`已整改`
|
||||||
|
|
||||||
|
- `问题说明`:当前导出范围仅限当前页,不满足实际运营导出需求。
|
||||||
|
- `整改要求`:
|
||||||
|
- 在导出时增加导出范围选择
|
||||||
|
- 至少支持:
|
||||||
|
- 导出当前页
|
||||||
|
- 导出指定页数
|
||||||
|
- 导出全部结果
|
||||||
|
- `验收标准`:
|
||||||
|
- 针对多页数据可完成跨页导出
|
||||||
|
|
||||||
|
### ++ 12. “是否有备案”筛选需修复
|
||||||
|
|
||||||
|
- `当前状态`:`已整改`
|
||||||
|
|
||||||
|
- `问题说明`:人工验收中,无论是否勾选备案条件,出现的域名结果均相同。
|
||||||
|
- `整改要求`:
|
||||||
|
- 核查备案字段写入与筛选逻辑
|
||||||
|
- 保证“有备案 / 无备案 / 未检测”条件能真实区分结果集
|
||||||
|
- `验收标准`:
|
||||||
|
- 构造不同备案状态数据后,筛选结果明显区分
|
||||||
|
|
||||||
|
### ++ 13. 批量更新选中域名功能需修复
|
||||||
|
|
||||||
|
- `当前状态`:`已整改`
|
||||||
|
|
||||||
|
- `问题说明`:人工验收中,已选中域名后执行批量更新,系统提示“成功更新0个域名的信息”。
|
||||||
|
- `整改要求`:
|
||||||
|
- 修复选中项识别、域名定位、更新提交逻辑
|
||||||
|
- 保证选中数据可真正更新到数据库
|
||||||
|
- `验收标准`:
|
||||||
|
- 选择多条域名执行批量更新后,数据库与界面结果同步变化
|
||||||
|
|
||||||
|
### ++ 14. 检测选项配置中需补充时光机检测
|
||||||
|
|
||||||
|
- `当前状态`:`已整改`
|
||||||
|
|
||||||
|
- `问题说明`:人工验收中指出系统设置的检测选项中没有“时光机检测”,但需求流程中时光机属于基础检测步骤。
|
||||||
|
- `整改要求`:
|
||||||
|
- 在检测选项配置中增加“时光机检测”开关
|
||||||
|
- 并确保该开关与实际流程联动
|
||||||
|
- `验收标准`:
|
||||||
|
- 关闭时光机后流程不执行时光机检测
|
||||||
|
- 开启后流程正常执行并写入结果
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、二类问题(建议整改)
|
||||||
|
|
||||||
|
### 8. 时光机检测已按当前验收口径调整为“全快照倒序扫描标题,命中即停”
|
||||||
|
|
||||||
|
- `当前状态`:`已整改,待复测确认`
|
||||||
|
|
||||||
|
- `当前情况`:已使用 Wayback/CDX API 获取全量快照时间戳,并按倒序进行扫描。
|
||||||
|
- `当前实现`:
|
||||||
|
- 仅抓取快照 `title`
|
||||||
|
- 按 `title` 去重
|
||||||
|
- 先快速检查最新快照,命中后不再继续拉取全量快照列表
|
||||||
|
- 使用 CDX 返回的 `digest` 先做内容级去重,减少重复 title 请求
|
||||||
|
- 命中敏感词立即停止后续扫描
|
||||||
|
- 已增加标题缓存与时间戳列表缓存
|
||||||
|
- `保留说明`:当前不再按正文提取友链数量;如甲方仍要求自动产出友链数,需另行补充正文抓取或其他统计来源。
|
||||||
|
- `备注`:该实现已与当前人工审核口径一致,即必须检查全部快照,但命中后立即停止。
|
||||||
|
|
||||||
|
### 9. “友链数量是否大于 10”建议接入自动检测闭环
|
||||||
|
|
||||||
|
- `当前状态`:`已整改`
|
||||||
|
|
||||||
|
- `当前情况`:已有相关字段与部分处理逻辑。
|
||||||
|
- `存在差距`:与自动检测、筛选条件、落库逻辑的衔接还不完整。
|
||||||
|
- `整改建议`:
|
||||||
|
- 自动检测时计算并回写友链结果
|
||||||
|
- 筛选页支持按该结果稳定筛选
|
||||||
|
|
||||||
|
### 10. 人工筛选规则脚本化建议进一步补齐
|
||||||
|
|
||||||
|
- `当前状态`:`整改中`
|
||||||
|
|
||||||
|
- `需求涉及平台`:
|
||||||
|
- 站长之家
|
||||||
|
- 爱站
|
||||||
|
- 百度 site
|
||||||
|
- 360 site
|
||||||
|
- 聚查 WHOIS/备案/拦截
|
||||||
|
- 桔子 SEO 历史/外链
|
||||||
|
- `当前情况`:已有平台检测器,但规则覆盖不完整。
|
||||||
|
- `整改建议`:
|
||||||
|
- 逐项补齐需求文档中已列明的业务判断规则
|
||||||
|
- 能自动拉黑的规则尽量自动化,不保留人为口径歧义
|
||||||
|
|
||||||
|
### 11. 检测端稳定性建议优化
|
||||||
|
|
||||||
|
- `当前状态`:`已完成一轮基础整改,待长时压测`
|
||||||
|
|
||||||
|
- `当前情况`:项目可运行,但数据库连接、线程模型、失败重试等仍有优化空间。
|
||||||
|
- `整改建议`:
|
||||||
|
- 优化连接池与并发参数
|
||||||
|
- 梳理失败重试与日志
|
||||||
|
- 降低因资源配置导致的不稳定情况
|
||||||
|
|
||||||
|
### 12. 配置写入逻辑建议优化
|
||||||
|
|
||||||
|
- `当前状态`:`已整改`
|
||||||
|
|
||||||
|
- `当前情况`:系统设置页在初始化过程中存在重复写本地文件/Redis 的现象。
|
||||||
|
- `整改建议`:
|
||||||
|
- 初始化加载与主动保存行为区分
|
||||||
|
- 减少无效写入与重复日志
|
||||||
|
|
||||||
|
### 13. 明文密码存储建议整改
|
||||||
|
|
||||||
|
- `当前状态`:`已整改`
|
||||||
|
|
||||||
|
- `当前情况`:本地存在明文账号密码保存。
|
||||||
|
- `整改建议`:
|
||||||
|
- 明确是否允许本地持久化保存
|
||||||
|
- 若允许,应增加最基本的保护措施
|
||||||
|
- 若不允许,应移除明文存储
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、三类问题(可阶段处理)
|
||||||
|
|
||||||
|
### 14. 多来源采集扩展
|
||||||
|
|
||||||
|
- `当前状态`:`暂缓`
|
||||||
|
|
||||||
|
- `包含项`:
|
||||||
|
- 搜索引擎采集
|
||||||
|
- 企业目录采集
|
||||||
|
- Zone File 采集
|
||||||
|
- 第三方 API / 数据包接入
|
||||||
|
- `说明`:该类功能对“全网增量建设”有价值,但可根据当前项目阶段单独排期。
|
||||||
|
|
||||||
|
### 15. Redis Bloom 模块支持
|
||||||
|
|
||||||
|
- `当前状态`:`暂缓`
|
||||||
|
|
||||||
|
- `当前情况`:Redis 当前可正常使用,但未安装 Bloom 模块。
|
||||||
|
- `说明`:不影响当前基础运行,可后续视大数据量需求决定是否补充。
|
||||||
|
|
||||||
|
### 16. 亿级数据量专项优化
|
||||||
|
|
||||||
|
- `当前状态`:`暂缓`
|
||||||
|
|
||||||
|
- `说明`:需求文档中有“支持亿级数据量”的目标,当前项目尚不足以证明已达到该级别设计要求。
|
||||||
|
- `建议后续处理方向`:
|
||||||
|
- 索引优化
|
||||||
|
- 批量导入策略
|
||||||
|
- 分区/分表设计
|
||||||
|
- 大规模调度与队列方案
|
||||||
|
|
||||||
|
### 17. 交互体验优化
|
||||||
|
|
||||||
|
- `当前状态`:`整改中`
|
||||||
|
|
||||||
|
- `包含项`:
|
||||||
|
- 批量操作反馈
|
||||||
|
- 查询空结果提示
|
||||||
|
- 导出结果提示
|
||||||
|
- 状态说明更清晰
|
||||||
|
- `说明`:可在主流程稳定后再安排
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、建议整改优先级
|
||||||
|
|
||||||
|
建议外包方按以下顺序整改:
|
||||||
|
|
||||||
|
1. 检测任务闭环
|
||||||
|
2. 检测结果回写
|
||||||
|
3. 状态码统一
|
||||||
|
4. TXT 导出
|
||||||
|
5. 筛选条件真实生效
|
||||||
|
6. 敏感词配置全链路生效
|
||||||
|
7. 停止规则补齐
|
||||||
|
8. 时光机升级
|
||||||
|
9. 人工筛选规则补齐
|
||||||
|
10. 稳定性与安全性优化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、复测建议
|
||||||
|
|
||||||
|
整改完成后,建议按以下顺序重新复测:
|
||||||
|
|
||||||
|
1. 环境连接与数据库初始化
|
||||||
|
2. 域名导入与去重
|
||||||
|
3. 自动创建检测任务
|
||||||
|
4. 注册状态检测
|
||||||
|
5. 时光机检测
|
||||||
|
6. 平台深度检测
|
||||||
|
7. 结果回写数据库
|
||||||
|
8. 筛选页面结果准确性
|
||||||
|
9. 批量更新使用状态/人工复核状态
|
||||||
|
10. TXT 导出正确性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、外包方回复建议格式
|
||||||
|
|
||||||
|
请外包方按以下格式逐项回复:
|
||||||
|
|
||||||
|
- `问题编号`
|
||||||
|
- `是否认可`
|
||||||
|
- `是否已整改`
|
||||||
|
- `整改说明`
|
||||||
|
- `涉及文件`
|
||||||
|
- `预计完成时间`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、当前阶段结论
|
||||||
|
|
||||||
|
当前版本不建议直接整体验收通过。
|
||||||
|
建议以本清单为基础,由外包方完成一类问题整改后,再进入下一轮正式复测。
|
||||||
604
docs/04_domainCheck_WebLinux改造总体方案.md
Normal file
604
docs/04_domainCheck_WebLinux改造总体方案.md
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
# domainCheck Web/Linux 改造总体方案
|
||||||
|
|
||||||
|
## 一、目标结论
|
||||||
|
|
||||||
|
本项目建议采用“双轨过渡”方案:
|
||||||
|
|
||||||
|
- `现阶段`:保持当前 `Windows 桌面版` 不变,继续测试、验收、修正业务规则
|
||||||
|
- `目标形态`:新增一套 `轻量 Web 管理后台 + Linux 后端 API + Linux 检测 Worker`
|
||||||
|
- `过渡策略`:桌面版与 Web 版共用同一套数据库、Redis、检测规则与任务模型,逐步把控制面迁移到 Web
|
||||||
|
|
||||||
|
本方案的核心原则:
|
||||||
|
|
||||||
|
- 不推倒重做现有检测核心
|
||||||
|
- 不在当前大型 `admin` 项目里硬改主线
|
||||||
|
- 新建一套轻量 Web 管理后台,只覆盖 `domainCheck` 真实需要的页面
|
||||||
|
- 将桌面端中的“配置、任务控制、日志查询、导入导出”逐步抽为后端 API
|
||||||
|
- 将检测执行从 GUI 进程迁移为 Linux 常驻 Worker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、为什么采用这条路线
|
||||||
|
|
||||||
|
### 1. 当前桌面版的价值仍然存在
|
||||||
|
|
||||||
|
当前桌面版已经完成了以下高价值资产:
|
||||||
|
|
||||||
|
- 域名导入、待检测任务创建、检测结果写库
|
||||||
|
- 免费检测链路与付费检测链路
|
||||||
|
- Wayback 优化策略
|
||||||
|
- 代理池、多线程、命中即停、缓存
|
||||||
|
- 域名筛选、导出、批量更新
|
||||||
|
- 现有数据库结构与状态体系
|
||||||
|
|
||||||
|
这些能力不应废弃,后续 Web 化应尽量复用。
|
||||||
|
|
||||||
|
### 2. Linux 长期最优形态不是桌面打包工具
|
||||||
|
|
||||||
|
Linux 更适合:
|
||||||
|
|
||||||
|
- 跑 PostgreSQL / Redis
|
||||||
|
- 跑 API 服务
|
||||||
|
- 跑常驻 Worker
|
||||||
|
- 跑定时任务与日志分析
|
||||||
|
|
||||||
|
Linux 不适合长期承载 PySide6 桌面 GUI 作为运营入口。
|
||||||
|
|
||||||
|
### 3. 轻量 Web 后台比继续清理现有 admin 更可控
|
||||||
|
|
||||||
|
当前 `admin` 项目本质上是一个成熟后台壳,但它:
|
||||||
|
|
||||||
|
- 带有原业务的大量历史模块
|
||||||
|
- 接口风格偏向既有 ThinkPHP 体系
|
||||||
|
- 动态菜单、权限、接口命名、Token 机制均带旧耦合
|
||||||
|
|
||||||
|
因此更适合“参考其基础能力”,而不是作为 `domainCheck` 的长期主线代码仓。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、目标架构
|
||||||
|
|
||||||
|
建议拆成 4 层:
|
||||||
|
|
||||||
|
### 1. Web 管理后台
|
||||||
|
|
||||||
|
技术建议:
|
||||||
|
|
||||||
|
- `Vue 3`
|
||||||
|
- `Vite`
|
||||||
|
- `Element Plus`
|
||||||
|
- `Pinia`
|
||||||
|
- `Vue Router`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 登录
|
||||||
|
- 系统设置
|
||||||
|
- 域名导入
|
||||||
|
- 检测控制
|
||||||
|
- 域名筛选
|
||||||
|
- 导出
|
||||||
|
- 日志诊断
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- Web 端只做“控制面”和“展示面”
|
||||||
|
- 不直接承担检测执行
|
||||||
|
|
||||||
|
### 2. 后端 API 服务
|
||||||
|
|
||||||
|
技术建议:
|
||||||
|
|
||||||
|
- 延续当前 Python 技术栈,优先考虑 `FastAPI`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 用户登录鉴权
|
||||||
|
- 配置读写
|
||||||
|
- 导入任务接口
|
||||||
|
- 检测控制接口
|
||||||
|
- 域名筛选查询接口
|
||||||
|
- 导出任务接口
|
||||||
|
- 日志诊断接口
|
||||||
|
- Worker 状态汇总接口
|
||||||
|
|
||||||
|
### 3. Linux 检测 Worker
|
||||||
|
|
||||||
|
技术建议:
|
||||||
|
|
||||||
|
- Python 常驻服务
|
||||||
|
- 无 GUI
|
||||||
|
- systemd 托管
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 从数据库或任务表领取待检测域名
|
||||||
|
- 按配置顺序执行免费检测与付费检测
|
||||||
|
- 写回检测结果与黑名单信息
|
||||||
|
- 汇报运行状态、进度、异常、代理池状态
|
||||||
|
|
||||||
|
### 4. 数据与基础设施层
|
||||||
|
|
||||||
|
- `PostgreSQL`
|
||||||
|
- `Redis`
|
||||||
|
- 日志文件
|
||||||
|
- 可选对象存储或文件目录用于导出文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、推荐的总体模块划分
|
||||||
|
|
||||||
|
### 1. `domain-web`
|
||||||
|
|
||||||
|
前端项目,负责:
|
||||||
|
|
||||||
|
- 登录页
|
||||||
|
- 仪表盘
|
||||||
|
- 系统设置
|
||||||
|
- 域名管理
|
||||||
|
- 检测控制台
|
||||||
|
- 日志诊断页
|
||||||
|
|
||||||
|
### 2. `domain-api`
|
||||||
|
|
||||||
|
后端 API 服务,负责:
|
||||||
|
|
||||||
|
- `auth`
|
||||||
|
- `settings`
|
||||||
|
- `domains`
|
||||||
|
- `detect`
|
||||||
|
- `exports`
|
||||||
|
- `logs`
|
||||||
|
- `diagnostics`
|
||||||
|
|
||||||
|
### 3. `domain-worker`
|
||||||
|
|
||||||
|
检测 Worker,负责:
|
||||||
|
|
||||||
|
- 检测任务轮询
|
||||||
|
- 并发检测
|
||||||
|
- 代理使用
|
||||||
|
- 日志写入
|
||||||
|
- 运行状态上报
|
||||||
|
|
||||||
|
### 4. `domain-core`
|
||||||
|
|
||||||
|
可复用核心库,负责:
|
||||||
|
|
||||||
|
- 数据库访问
|
||||||
|
- 状态码定义
|
||||||
|
- 检测器封装
|
||||||
|
- 导入、筛选、导出服务
|
||||||
|
- 配置对象
|
||||||
|
- 公共异常与日志工具
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 当前 `domainCheck` 的大部分核心逻辑,后续应逐步沉淀到这一层
|
||||||
|
- 这是兼容桌面版与 Web/Linux 双模式的关键
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、页面范围
|
||||||
|
|
||||||
|
第一期轻量 Web 后台建议只做以下页面:
|
||||||
|
|
||||||
|
### 1. 登录
|
||||||
|
|
||||||
|
- 用户登录
|
||||||
|
- Token 持久化
|
||||||
|
- 退出登录
|
||||||
|
|
||||||
|
### 2. 系统设置
|
||||||
|
|
||||||
|
- 数据库连接信息只读或隐藏
|
||||||
|
- 检测选项配置
|
||||||
|
- 检测顺序调整
|
||||||
|
- 代理池配置
|
||||||
|
- 允许直连开关
|
||||||
|
- 线程数配置
|
||||||
|
- 聚名 / 聚查 Cookie 管理
|
||||||
|
- 敏感词配置管理
|
||||||
|
|
||||||
|
### 3. 域名导入
|
||||||
|
|
||||||
|
- 上传 TXT
|
||||||
|
- 导入结果反馈
|
||||||
|
- 导入失败明细
|
||||||
|
- 自动建待检测任务
|
||||||
|
|
||||||
|
### 4. 检测控制
|
||||||
|
|
||||||
|
- 启动检测
|
||||||
|
- 停止检测
|
||||||
|
- Worker 在线状态
|
||||||
|
- 当前线程数
|
||||||
|
- 当前代理状态
|
||||||
|
- 当前任务进度
|
||||||
|
|
||||||
|
### 5. 域名筛选
|
||||||
|
|
||||||
|
- 注册状态
|
||||||
|
- 检测状态
|
||||||
|
- 使用状态
|
||||||
|
- 是否备案
|
||||||
|
- 备案年份
|
||||||
|
- 快照年份
|
||||||
|
- backlink > 10
|
||||||
|
- 关键词、域名、首页网址
|
||||||
|
- 分页列表
|
||||||
|
|
||||||
|
### 6. 导出
|
||||||
|
|
||||||
|
- 导出 TXT
|
||||||
|
- 导出 CSV/Excel
|
||||||
|
- 导出当前页 / 指定页数 / 全部
|
||||||
|
- 导出任务记录
|
||||||
|
|
||||||
|
### 7. 日志诊断
|
||||||
|
|
||||||
|
- Worker 运行日志
|
||||||
|
- API 错误日志
|
||||||
|
- 最近异常摘要
|
||||||
|
- 一键诊断分析
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、后端拆分建议
|
||||||
|
|
||||||
|
后端改动不是重写业务,而是“服务化”。
|
||||||
|
|
||||||
|
### 1. 可直接复用的部分
|
||||||
|
|
||||||
|
- 检测器逻辑
|
||||||
|
- 状态码与状态映射
|
||||||
|
- 域名导入核心逻辑
|
||||||
|
- 筛选 SQL 逻辑
|
||||||
|
- 导出逻辑
|
||||||
|
- Wayback 优化逻辑
|
||||||
|
- 代理池逻辑
|
||||||
|
- 数据库表结构
|
||||||
|
|
||||||
|
### 2. 需要抽离成 service 的部分
|
||||||
|
|
||||||
|
- 系统设置读写
|
||||||
|
- 检测选项配置读写
|
||||||
|
- 线程数配置读写
|
||||||
|
- 代理配置读写
|
||||||
|
- Cookie 配置读写
|
||||||
|
- 域名导入服务
|
||||||
|
- 域名筛选服务
|
||||||
|
- 导出服务
|
||||||
|
- 日志读取与诊断服务
|
||||||
|
|
||||||
|
### 3. 需要从 GUI 中搬出的部分
|
||||||
|
|
||||||
|
- 检测启动/停止逻辑
|
||||||
|
- 运行状态展示逻辑
|
||||||
|
- 配置变更监听
|
||||||
|
- GUI 信号槽驱动的线程控制
|
||||||
|
|
||||||
|
### 4. 需要新增的部分
|
||||||
|
|
||||||
|
- API 鉴权
|
||||||
|
- API 返回结构统一
|
||||||
|
- Worker 心跳机制
|
||||||
|
- 导出任务记录
|
||||||
|
- 日志分析接口
|
||||||
|
- 远程诊断接口
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、并发架构建议
|
||||||
|
|
||||||
|
当前并发能力可以复用,但调度外壳要改。
|
||||||
|
|
||||||
|
### 1. 保留的能力
|
||||||
|
|
||||||
|
- 域名级多线程检测
|
||||||
|
- 检测顺序控制
|
||||||
|
- Wayback 小并发与命中即停
|
||||||
|
- 代理池共享
|
||||||
|
- 失败重试
|
||||||
|
- 结果落库
|
||||||
|
|
||||||
|
### 2. 要调整的实现方式
|
||||||
|
|
||||||
|
从:
|
||||||
|
|
||||||
|
- `QThread + GUI 信号 + 本地窗口状态`
|
||||||
|
|
||||||
|
迁移为:
|
||||||
|
|
||||||
|
- `Worker 线程池 + 服务状态上报 + API 查询`
|
||||||
|
|
||||||
|
### 3. 推荐并发模型
|
||||||
|
|
||||||
|
- 单 Worker 实例维护一个检测线程池
|
||||||
|
- Worker 从 `detect_tasks` 拉取待处理任务
|
||||||
|
- 每个域名仍按当前检测顺序串行执行单域名步骤
|
||||||
|
- 多域名并行执行
|
||||||
|
- Wayback 继续保留单域名内部的小并发与缓存机制
|
||||||
|
|
||||||
|
### 4. 推荐的任务状态流转
|
||||||
|
|
||||||
|
- `待检测`
|
||||||
|
- `检测中`
|
||||||
|
- `检测完成`
|
||||||
|
- `检测失败`
|
||||||
|
- `已拉黑`
|
||||||
|
|
||||||
|
同时保留任务表:
|
||||||
|
|
||||||
|
- `status = pending`
|
||||||
|
- `status = running`
|
||||||
|
- `status = completed`
|
||||||
|
- `status = failed`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、免费与付费检测执行策略
|
||||||
|
|
||||||
|
这部分建议沿用当前已确认规则:
|
||||||
|
|
||||||
|
- 先跑免费项
|
||||||
|
- 免费项全部跑完且仍非黑名单,才跑付费项
|
||||||
|
- 付费项默认为:
|
||||||
|
- 聚查
|
||||||
|
- 桔子
|
||||||
|
|
||||||
|
检测顺序默认建议:
|
||||||
|
|
||||||
|
1. 检查注册
|
||||||
|
2. 百度 site
|
||||||
|
3. 360 site
|
||||||
|
4. 站长之家
|
||||||
|
5. 爱站
|
||||||
|
6. 时光机
|
||||||
|
7. 聚查
|
||||||
|
8. 桔子
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- Web 后台允许运营勾选与调整顺序
|
||||||
|
- 但后端执行器应保留“付费项后置”的保护规则
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、代理架构建议
|
||||||
|
|
||||||
|
### 1. 保留现有能力
|
||||||
|
|
||||||
|
- 多代理池 URL
|
||||||
|
- 允许直连开关
|
||||||
|
- 抽样代理测试
|
||||||
|
- 可用代理数展示
|
||||||
|
|
||||||
|
### 2. Linux 后端建议新增
|
||||||
|
|
||||||
|
- 代理池刷新冷却时间
|
||||||
|
- 空池失败退避
|
||||||
|
- 低水位自动补池
|
||||||
|
- 按检测步骤控制是否优先直连
|
||||||
|
- 代理池健康状态缓存
|
||||||
|
|
||||||
|
### 3. 建议策略
|
||||||
|
|
||||||
|
- 生产环境:优先代理,谨慎直连
|
||||||
|
- 测试环境:允许直连兜底,减少空转
|
||||||
|
- 国内服务器:优先使用国内代理池
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、日志与诊断架构建议
|
||||||
|
|
||||||
|
### 1. 日志来源
|
||||||
|
|
||||||
|
- API 服务日志
|
||||||
|
- Worker 日志
|
||||||
|
- 导入日志
|
||||||
|
- 导出日志
|
||||||
|
- 代理池测试日志
|
||||||
|
|
||||||
|
### 2. 日志能力
|
||||||
|
|
||||||
|
- Web 页面查看最近日志
|
||||||
|
- 关键词过滤
|
||||||
|
- 失败任务聚合
|
||||||
|
- 一键下载诊断包
|
||||||
|
|
||||||
|
### 3. 诊断接口
|
||||||
|
|
||||||
|
建议后续提供:
|
||||||
|
|
||||||
|
- `POST /diagnostics/analyze`
|
||||||
|
- `GET /diagnostics/latest`
|
||||||
|
- `GET /logs/worker`
|
||||||
|
- `GET /logs/api`
|
||||||
|
|
||||||
|
### 4. 上传分析内容建议
|
||||||
|
|
||||||
|
- 最近 300 到 500 行日志
|
||||||
|
- 当前系统配置快照
|
||||||
|
- Worker 状态快照
|
||||||
|
- 数据库统计摘要
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、认证与权限建议
|
||||||
|
|
||||||
|
第一期建议做轻量权限:
|
||||||
|
|
||||||
|
- `admin`
|
||||||
|
- `operator`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `admin` 可修改系统配置、代理、Cookie、敏感词
|
||||||
|
- `operator` 只能导入、查看、筛选、导出、启动检测
|
||||||
|
|
||||||
|
建议使用:
|
||||||
|
|
||||||
|
- JWT 或简单 Token
|
||||||
|
- Redis 存储会话
|
||||||
|
|
||||||
|
不建议第一期就上过重 RBAC。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、数据库策略建议
|
||||||
|
|
||||||
|
当前数据库可继续使用,不建议第一期大改表结构。
|
||||||
|
|
||||||
|
建议新增或补强的表可以是:
|
||||||
|
|
||||||
|
- `sys_users`
|
||||||
|
- `sys_login_logs`
|
||||||
|
- `export_tasks`
|
||||||
|
- `worker_heartbeats`
|
||||||
|
- `diagnostic_reports`
|
||||||
|
|
||||||
|
现有核心表继续复用:
|
||||||
|
|
||||||
|
- `domains`
|
||||||
|
- `detect_tasks`
|
||||||
|
- `domain_detections`
|
||||||
|
- `domain_blacklist`
|
||||||
|
- `sensitive_words`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十三、兼容双模式方案
|
||||||
|
|
||||||
|
目标是兼容:
|
||||||
|
|
||||||
|
- `模式 A`:Windows 桌面版
|
||||||
|
- `模式 B`:Web 后台 + Linux Worker
|
||||||
|
|
||||||
|
兼容方式:
|
||||||
|
|
||||||
|
- 共用同一套数据库
|
||||||
|
- 共用同一套 Redis
|
||||||
|
- 共用同一套状态码与检测规则
|
||||||
|
- 共用同一套导入、筛选、导出核心服务
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 桌面版在过渡期继续可用
|
||||||
|
- Web 版逐步接管日常运营
|
||||||
|
- 最终桌面版只保留给管理员或完全退役
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十四、实施阶段建议
|
||||||
|
|
||||||
|
### 第一阶段:方案冻结
|
||||||
|
|
||||||
|
周期建议:`2-3 天`
|
||||||
|
|
||||||
|
产出:
|
||||||
|
|
||||||
|
- 页面清单
|
||||||
|
- API 清单
|
||||||
|
- 数据模型确认
|
||||||
|
- 迁移边界确认
|
||||||
|
|
||||||
|
### 第二阶段:后端服务化
|
||||||
|
|
||||||
|
周期建议:`7-12 天`
|
||||||
|
|
||||||
|
产出:
|
||||||
|
|
||||||
|
- 登录鉴权
|
||||||
|
- 配置接口
|
||||||
|
- 域名查询接口
|
||||||
|
- 导入接口
|
||||||
|
- 导出接口
|
||||||
|
- 检测控制接口
|
||||||
|
- 日志接口
|
||||||
|
|
||||||
|
### 第三阶段:Worker Linux 化
|
||||||
|
|
||||||
|
周期建议:`5-8 天`
|
||||||
|
|
||||||
|
产出:
|
||||||
|
|
||||||
|
- 无 GUI Worker
|
||||||
|
- systemd 启动方案
|
||||||
|
- 心跳上报
|
||||||
|
- 任务轮询
|
||||||
|
- 并发检测
|
||||||
|
|
||||||
|
### 第四阶段:Web 后台一期
|
||||||
|
|
||||||
|
周期建议:`7-10 天`
|
||||||
|
|
||||||
|
产出:
|
||||||
|
|
||||||
|
- 登录
|
||||||
|
- 系统设置
|
||||||
|
- 域名导入
|
||||||
|
- 检测控制
|
||||||
|
- 域名筛选
|
||||||
|
- 导出
|
||||||
|
- 日志诊断
|
||||||
|
|
||||||
|
### 第五阶段:联调与回归
|
||||||
|
|
||||||
|
周期建议:`5-7 天`
|
||||||
|
|
||||||
|
产出:
|
||||||
|
|
||||||
|
- API 联调
|
||||||
|
- Worker 联调
|
||||||
|
- 真实域名回归
|
||||||
|
- 性能参数调整
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十五、周期评估
|
||||||
|
|
||||||
|
如果基于当前项目逐步演进:
|
||||||
|
|
||||||
|
- `较顺利`:`3-4 周`
|
||||||
|
- `稳妥完整`:`4-6 周`
|
||||||
|
|
||||||
|
前提:
|
||||||
|
|
||||||
|
- 当前桌面版继续测试,不同时大规模重构
|
||||||
|
- Web 后台走轻量方案,不做额外复杂业务
|
||||||
|
- 检测核心尽量复用,不重写规则
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十六、风险点
|
||||||
|
|
||||||
|
### 1. GUI 与业务逻辑仍有部分耦合
|
||||||
|
|
||||||
|
需要持续把 GUI 里的逻辑搬到 service 层。
|
||||||
|
|
||||||
|
### 2. 代理池资源仍是实际瓶颈
|
||||||
|
|
||||||
|
Linux 化不会自动解决代理池不足问题,只能让调度更稳。
|
||||||
|
|
||||||
|
### 3. 第三方站点反爬与返回结构变动
|
||||||
|
|
||||||
|
百度、360、站长、爱站、聚查、桔子都可能发生变化,需要留维护预算。
|
||||||
|
|
||||||
|
### 4. Wayback 首次大域名扫描仍可能耗时较高
|
||||||
|
|
||||||
|
虽然当前已优化很多,但超大域名首次拉 CDX 列表仍是客观成本。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十七、最终建议
|
||||||
|
|
||||||
|
建议现在就按以下路径推进:
|
||||||
|
|
||||||
|
1. 当前桌面版继续测试,不做大方向替换
|
||||||
|
2. 新建轻量 Web 后台项目,不在现有 `admin` 大仓上继续主线开发
|
||||||
|
3. 新建 API 服务层,承接配置、任务、导入、筛选、导出、日志能力
|
||||||
|
4. 将检测端逐步改造成 Linux 常驻 Worker
|
||||||
|
5. 保持桌面版与 Web/Linux 双模式并行一段时间
|
||||||
|
|
||||||
|
这是当前成本、风险、可维护性三者之间最平衡的方案。
|
||||||
142
docs/05_domainCheck_Linux部署清单.md
Normal file
142
docs/05_domainCheck_Linux部署清单.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# 05 domainCheck Linux 部署清单
|
||||||
|
|
||||||
|
## 一、目标形态
|
||||||
|
|
||||||
|
- `domain-web`:提供运营管理后台
|
||||||
|
- `domain-api`:提供接口、日志、运行中心、导入导出能力
|
||||||
|
- `domainCheck`:继续承载检测核心与 Worker
|
||||||
|
- `PostgreSQL + Redis`:建议同机或同内网部署
|
||||||
|
|
||||||
|
## 二、服务器准备
|
||||||
|
|
||||||
|
- 操作系统:Ubuntu 22.04 / Debian 12 / Rocky Linux 9 均可
|
||||||
|
- Python:`3.11`
|
||||||
|
- Node:仅构建前端时需要,运行静态文件不强依赖
|
||||||
|
- PostgreSQL:建议 `14+`
|
||||||
|
- Redis:建议 `6+`
|
||||||
|
|
||||||
|
## 三、目录建议
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/domaincheck
|
||||||
|
├── domain-api
|
||||||
|
├── domain-web
|
||||||
|
└── domainCheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## 四、上线前核对
|
||||||
|
|
||||||
|
### 1. 数据库
|
||||||
|
|
||||||
|
- `domains`
|
||||||
|
- `detect_tasks`
|
||||||
|
- `domain_detections`
|
||||||
|
- `domain_blacklist`
|
||||||
|
- `sensitive_words`
|
||||||
|
|
||||||
|
确认这几张核心表已经存在。
|
||||||
|
|
||||||
|
### 2. Redis
|
||||||
|
|
||||||
|
确认可以正常读写:
|
||||||
|
|
||||||
|
- `domain_tool:detect_options`
|
||||||
|
- `domain_tool:proxy_config`
|
||||||
|
- `domain_tool:thread_count`
|
||||||
|
|
||||||
|
### 3. Worker 配置
|
||||||
|
|
||||||
|
确认 `domainCheck/.env` 已配置:
|
||||||
|
|
||||||
|
- `DB_HOST`
|
||||||
|
- `DB_PORT`
|
||||||
|
- `DB_DATABASE`
|
||||||
|
- `DB_USER`
|
||||||
|
- `DB_PASSWORD`
|
||||||
|
- `REDIS_HOST`
|
||||||
|
- `REDIS_PORT`
|
||||||
|
- `REDIS_PASSWORD`
|
||||||
|
|
||||||
|
### 4. Web/API 配置
|
||||||
|
|
||||||
|
确认 `domain-api` 使用:
|
||||||
|
|
||||||
|
- `WORKER_MODE=linux-systemd`
|
||||||
|
- `WORKER_SERVICE_NAME=domaincheck-worker`
|
||||||
|
- `API_SERVICE_NAME=domaincheck-api`
|
||||||
|
|
||||||
|
## 五、部署顺序
|
||||||
|
|
||||||
|
1. 上传 `domainCheck`
|
||||||
|
2. 上传 `domain-api`
|
||||||
|
3. 上传 `domain-web`
|
||||||
|
4. 创建 Python 虚拟环境并安装依赖
|
||||||
|
5. 初始化或连接 PostgreSQL/Redis
|
||||||
|
6. 安装 `systemd` 服务
|
||||||
|
7. 启动 `domaincheck-api`
|
||||||
|
8. 启动 `domaincheck-worker`
|
||||||
|
9. 验证 Web 后台与运行中心
|
||||||
|
|
||||||
|
### 前端补充
|
||||||
|
|
||||||
|
`domain-web` 建议:
|
||||||
|
|
||||||
|
1. 复制 `.env.production.example` 为 `.env.production`
|
||||||
|
2. 修改 `VITE_API_BASE_URL`
|
||||||
|
3. 执行 `deploy/linux/publish.sh`
|
||||||
|
4. 使用 `deploy/nginx/domain-web.conf` 配置 Nginx
|
||||||
|
|
||||||
|
## 六、上线后验证
|
||||||
|
|
||||||
|
### 1. API
|
||||||
|
|
||||||
|
访问:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://服务器IP:8100/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Web 后台
|
||||||
|
|
||||||
|
确认能正常完成:
|
||||||
|
|
||||||
|
- 登录
|
||||||
|
- 系统设置读取
|
||||||
|
- 域名筛选查询
|
||||||
|
- 导出列表查看
|
||||||
|
- 日志诊断查看
|
||||||
|
|
||||||
|
### 3. Worker
|
||||||
|
|
||||||
|
确认:
|
||||||
|
|
||||||
|
- 运行中心显示 Worker 在线
|
||||||
|
- 检测控制可读到当前进程数
|
||||||
|
- `detect_worker.log` 正常写入
|
||||||
|
|
||||||
|
## 七、切换策略
|
||||||
|
|
||||||
|
建议按下面顺序平滑切换:
|
||||||
|
|
||||||
|
1. `Web 后台` 先接管配置、筛选、导出、日志
|
||||||
|
2. `Linux Worker` 再逐步接管检测主任务
|
||||||
|
3. `Windows 桌面端` 暂时保留为备用入口
|
||||||
|
4. 运行稳定后,再考虑淡出桌面检测端
|
||||||
|
|
||||||
|
## 八、当前完成度
|
||||||
|
|
||||||
|
截至当前版本,已完成:
|
||||||
|
|
||||||
|
- Web 后台主页面
|
||||||
|
- API 接口主链路
|
||||||
|
- 运行中心
|
||||||
|
- Windows 本地 Worker 控制
|
||||||
|
- Linux systemd 控制模式支持
|
||||||
|
- systemd 模板文件
|
||||||
|
|
||||||
|
仍建议上线前重点复测:
|
||||||
|
|
||||||
|
- Linux systemd 实机启停
|
||||||
|
- Linux 下日志路径与权限
|
||||||
|
- 真实代理池在国内服务器上的表现
|
||||||
|
- 大批量导入与导出任务表现
|
||||||
75
docs/06_domainCheck_Web版当前完成度.md
Normal file
75
docs/06_domainCheck_Web版当前完成度.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# 06 domainCheck Web版当前完成度
|
||||||
|
|
||||||
|
## 一、当前已落地
|
||||||
|
|
||||||
|
- `domain-web` 已可运行
|
||||||
|
- `domain-api` 已可运行
|
||||||
|
- `domain-web` 已完成:
|
||||||
|
- 登录
|
||||||
|
- 顶部运行状态头部
|
||||||
|
- 概览
|
||||||
|
- 运行中心
|
||||||
|
- 系统设置
|
||||||
|
- 域名导入
|
||||||
|
- 检测控制
|
||||||
|
- 域名筛选
|
||||||
|
- 批量更新
|
||||||
|
- 导出中心
|
||||||
|
- 日志诊断
|
||||||
|
- 诊断包下载
|
||||||
|
- 配置快照导出/导入
|
||||||
|
- 配置备份创建与备份记录查看
|
||||||
|
- `domain-api` 已完成:
|
||||||
|
- 健康检查
|
||||||
|
- 登录
|
||||||
|
- 概览统计
|
||||||
|
- 系统设置读写
|
||||||
|
- 配置快照导出/导入
|
||||||
|
- 配置导入校验
|
||||||
|
- 配置备份与备份列表
|
||||||
|
- 导入任务接口
|
||||||
|
- 检测状态与启停接口
|
||||||
|
- 域名筛选接口
|
||||||
|
- 批量更新接口
|
||||||
|
- 导出记录与生成接口
|
||||||
|
- 日志诊断接口
|
||||||
|
- 运行中心接口
|
||||||
|
|
||||||
|
## 二、运行模式
|
||||||
|
|
||||||
|
当前已支持双模式:
|
||||||
|
|
||||||
|
- `windows-local`
|
||||||
|
- `linux-systemd`
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- `Windows 本地模式` 已完成真实联调
|
||||||
|
- `Linux systemd 模式` 已完成代码支持、模板文件和部署文档
|
||||||
|
|
||||||
|
## 三、部署资料
|
||||||
|
|
||||||
|
已具备:
|
||||||
|
|
||||||
|
- `domain-api/.env.example`
|
||||||
|
- `domain-api/deploy/systemd/domain-api.service`
|
||||||
|
- `domain-api/deploy/systemd/domain-worker.service`
|
||||||
|
- `domain-api/deploy/linux/README.md`
|
||||||
|
- `domain-web/.env.production.example`
|
||||||
|
- `domain-web/deploy/nginx/domain-web.conf`
|
||||||
|
- `domain-web/deploy/linux/publish.sh`
|
||||||
|
- `docs/05_domainCheck_Linux部署清单.md`
|
||||||
|
- `docs/07_domainCheck_WebLinux发布验收清单.md`
|
||||||
|
- `docs/08_domainCheck_交付说明.md`
|
||||||
|
|
||||||
|
## 四、当前仍建议重点复测
|
||||||
|
|
||||||
|
- Linux `systemd` 实机启停
|
||||||
|
- Linux 下 API / Worker 日志路径与权限
|
||||||
|
- 国内服务器上的真实代理池表现
|
||||||
|
- 大批量导入与导出任务表现
|
||||||
|
- 长时间运行下的 Worker 稳定性
|
||||||
|
|
||||||
|
## 五、当前结论
|
||||||
|
|
||||||
|
从代码、接口、页面、部署材料四个层面看,这套 Web/Linux 方案已经不是概念原型,而是进入了“可交付、可联调、可继续迁移”的阶段。
|
||||||
90
docs/07_domainCheck_WebLinux发布验收清单.md
Normal file
90
docs/07_domainCheck_WebLinux发布验收清单.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# 07 domainCheck Web/Linux 发布验收清单
|
||||||
|
|
||||||
|
## 一、基础连通
|
||||||
|
|
||||||
|
- `domain-api` 已启动
|
||||||
|
- `domainCheck Worker` 已启动
|
||||||
|
- `http://服务器IP:8100/health` 返回 `status=ok`
|
||||||
|
- `runtime/preflight` 返回 `ok=true`
|
||||||
|
|
||||||
|
## 二、Web 后台
|
||||||
|
|
||||||
|
- 登录正常
|
||||||
|
- 顶部可看到 `API / Worker / 运行模式`
|
||||||
|
- 概览页能正常读取统计
|
||||||
|
- 运行中心能正常读取:
|
||||||
|
- API 版本
|
||||||
|
- API 前缀
|
||||||
|
- PID
|
||||||
|
- Worker 进程数
|
||||||
|
- 自检结果
|
||||||
|
|
||||||
|
## 三、配置能力
|
||||||
|
|
||||||
|
- 系统设置能正常读取
|
||||||
|
- 线程数修改后可保存
|
||||||
|
- 检测顺序可调整并保存
|
||||||
|
- 代理池列表可编辑并保存
|
||||||
|
- `worker_mode / service_name` 可保存
|
||||||
|
- 可手动创建配置备份
|
||||||
|
- 可下载配置备份
|
||||||
|
- 可导出配置快照
|
||||||
|
- 可导入配置快照
|
||||||
|
- 导入配置时会自动生成导入前备份
|
||||||
|
|
||||||
|
## 四、业务能力
|
||||||
|
|
||||||
|
- 导入任务可创建
|
||||||
|
- 导入任务列表可刷新
|
||||||
|
- 导入任务失败时可重试
|
||||||
|
- 域名筛选可查询
|
||||||
|
- 批量更新可执行
|
||||||
|
- 导出记录可生成
|
||||||
|
- 导出文件可下载
|
||||||
|
- 日志诊断页可查看日志
|
||||||
|
- 诊断包可下载
|
||||||
|
|
||||||
|
## 五、Linux 特有项
|
||||||
|
|
||||||
|
- `domaincheck-api.service` 可正常启动/停止
|
||||||
|
- `domaincheck-worker.service` 可正常启动/停止
|
||||||
|
- `journalctl` 可查看两边日志
|
||||||
|
- Nginx 反代正常
|
||||||
|
- Web 静态文件 `dist` 已正确发布
|
||||||
|
|
||||||
|
## 六、建议发布前命令
|
||||||
|
|
||||||
|
### 1. 运行 API 自测
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/domaincheck/domain-api
|
||||||
|
python deploy/linux/smoke_test.py --base-url http://127.0.0.1:8100
|
||||||
|
```
|
||||||
|
|
||||||
|
如需同时校验 Web 首页:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python deploy/linux/smoke_test.py --base-url http://127.0.0.1:8100 --web-url http://127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 查看 API 健康状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8100/health
|
||||||
|
curl http://127.0.0.1:8100/api/v1/runtime/preflight
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 查看 systemd 状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status domaincheck-api
|
||||||
|
systemctl status domaincheck-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
## 七、当前结论
|
||||||
|
|
||||||
|
如果以上检查项全部通过,则可以认为:
|
||||||
|
|
||||||
|
- Web 管理后台已达到可交付状态
|
||||||
|
- Linux 部署环境已达到可联调状态
|
||||||
|
- 可以进入真实服务器联调或灰度上线阶段
|
||||||
122
docs/08_domainCheck_交付说明.md
Normal file
122
docs/08_domainCheck_交付说明.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# 08 domainCheck 交付说明
|
||||||
|
|
||||||
|
## 一、当前交付范围
|
||||||
|
|
||||||
|
本次已交付:
|
||||||
|
|
||||||
|
- `domainCheck` 桌面版主项目
|
||||||
|
- `domain-api` 轻量后端接口服务
|
||||||
|
- `domain-web` 轻量运营管理后台
|
||||||
|
- Windows 本地联调脚本
|
||||||
|
- Linux 部署材料
|
||||||
|
- Linux 自检与发布验收文档
|
||||||
|
|
||||||
|
## 二、Windows 本地脚本
|
||||||
|
|
||||||
|
可直接使用:
|
||||||
|
|
||||||
|
- `start_domain_api.ps1`
|
||||||
|
- `stop_domain_api.ps1`
|
||||||
|
- `start_domain_web.ps1`
|
||||||
|
- `stop_domain_web.ps1`
|
||||||
|
- `start_domain_web_background.ps1`
|
||||||
|
- `start_domain_stack.ps1`
|
||||||
|
- `stop_domain_stack.ps1`
|
||||||
|
- `smoke_test_stack.ps1`
|
||||||
|
|
||||||
|
推荐整套启动:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\start_domain_stack.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐整套停止:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\stop_domain_stack.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐整套自测:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\smoke_test_stack.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、Web/API 默认地址
|
||||||
|
|
||||||
|
- Web:`http://127.0.0.1:3200`
|
||||||
|
- API:`http://127.0.0.1:8100`
|
||||||
|
|
||||||
|
## 四、配置迁移能力
|
||||||
|
|
||||||
|
系统设置页已经支持:
|
||||||
|
|
||||||
|
- 手动创建配置备份
|
||||||
|
- 下载配置备份
|
||||||
|
- 导出当前配置快照
|
||||||
|
- 导入配置快照
|
||||||
|
|
||||||
|
配置快照内容包括:
|
||||||
|
|
||||||
|
- 检测选项和执行顺序
|
||||||
|
- 代理配置
|
||||||
|
- 线程数
|
||||||
|
- Web/API 运行配置
|
||||||
|
|
||||||
|
推荐场景:
|
||||||
|
|
||||||
|
- Windows 测试环境导出配置
|
||||||
|
- Linux 正式环境导入配置
|
||||||
|
- 发布前先做一次配置备份
|
||||||
|
- 导入配置前系统会自动生成一份导入前备份
|
||||||
|
|
||||||
|
## 五、Linux 部署相关
|
||||||
|
|
||||||
|
请优先阅读:
|
||||||
|
|
||||||
|
- `docs/05_domainCheck_Linux部署清单.md`
|
||||||
|
- `docs/07_domainCheck_WebLinux发布验收清单.md`
|
||||||
|
- `domain-api/deploy/linux/README.md`
|
||||||
|
- `domain-web/README.md`
|
||||||
|
|
||||||
|
## 六、上线前建议
|
||||||
|
|
||||||
|
### 1. 跑 API 自检
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/domaincheck/domain-api
|
||||||
|
python deploy/linux/smoke_test.py --base-url http://127.0.0.1:8100
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 打开运行中心
|
||||||
|
|
||||||
|
确认:
|
||||||
|
|
||||||
|
- API 在线
|
||||||
|
- Worker 在线
|
||||||
|
- 运行模式正确
|
||||||
|
- 部署自检通过
|
||||||
|
|
||||||
|
### 3. 打开日志诊断
|
||||||
|
|
||||||
|
确认:
|
||||||
|
|
||||||
|
- 可正常查看日志
|
||||||
|
- 可正常下载诊断包
|
||||||
|
|
||||||
|
### 4. 导出一次配置快照
|
||||||
|
|
||||||
|
确认:
|
||||||
|
|
||||||
|
- 可以成功下载 JSON 快照
|
||||||
|
- 导入后配置能恢复
|
||||||
|
|
||||||
|
## 七、当前结论
|
||||||
|
|
||||||
|
当前项目已经进入“可交付、可联调、可部署”的状态。
|
||||||
|
|
||||||
|
剩余工作重点不再是基础开发,而是:
|
||||||
|
|
||||||
|
- Linux 实机联调
|
||||||
|
- 上线前复测
|
||||||
|
- 真实服务器灰度验证
|
||||||
111
docs/09_domainCheck_交付打包说明.md
Normal file
111
docs/09_domainCheck_交付打包说明.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# 09 domainCheck 交付打包说明
|
||||||
|
|
||||||
|
## 一、用途
|
||||||
|
|
||||||
|
用于在 Windows 本地把当前可交付内容整理成一份压缩包,便于:
|
||||||
|
|
||||||
|
- 发给运维或部署同事
|
||||||
|
- 存档版本快照
|
||||||
|
- 迁移到 Linux 服务器前做交付留档
|
||||||
|
|
||||||
|
## 二、打包脚本
|
||||||
|
|
||||||
|
根目录脚本:
|
||||||
|
|
||||||
|
- `package_domain_release.ps1`
|
||||||
|
- `verify_domain_release.ps1`
|
||||||
|
- `prepare_final_release.ps1`
|
||||||
|
- `show_latest_release.ps1`
|
||||||
|
|
||||||
|
执行方式:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\package_domain_release.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
如需一键完成“打包 + 验包 + 生成最终准备报告”:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\prepare_final_release.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
如需快速查看当前最新交付物:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\show_latest_release.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、输出位置
|
||||||
|
|
||||||
|
打包后会生成:
|
||||||
|
|
||||||
|
- `release/domaincheck_release_时间戳/`
|
||||||
|
- `release/domaincheck_release_时间戳.zip`
|
||||||
|
- `release/domaincheck_release_时间戳.sha256.txt`
|
||||||
|
- `release/latest_release.txt`
|
||||||
|
- `release/latest_release.json`
|
||||||
|
- `release/final_release_report.json`
|
||||||
|
|
||||||
|
## 四、当前会包含的内容
|
||||||
|
|
||||||
|
- `docs/`
|
||||||
|
- `scripts/`
|
||||||
|
- `domain-api/`
|
||||||
|
- `app`
|
||||||
|
- `deploy`
|
||||||
|
- `README.md`
|
||||||
|
- `requirements.txt`
|
||||||
|
- `.env.example`
|
||||||
|
- `domain-web/`
|
||||||
|
- `src`
|
||||||
|
- `deploy`
|
||||||
|
- `README.md`
|
||||||
|
- `package.json`
|
||||||
|
- `package-lock.json`
|
||||||
|
- `tsconfig`
|
||||||
|
- `vite.config.ts`
|
||||||
|
- `.env.example`
|
||||||
|
- `.env.production.example`
|
||||||
|
- 若存在则带上 `dist`
|
||||||
|
|
||||||
|
同时会生成:
|
||||||
|
|
||||||
|
- `release_manifest.json`
|
||||||
|
- `README_RELEASE.txt`
|
||||||
|
- `smoke_test_report.json`
|
||||||
|
- 若打包时本机 Web/API 正常运行,则自动生成并随包带出
|
||||||
|
- `.sha256.txt`
|
||||||
|
- 用于校验 zip 交付包完整性
|
||||||
|
|
||||||
|
## 五、建议使用顺序
|
||||||
|
|
||||||
|
1. 先阅读 `docs/08_domainCheck_交付说明.md`
|
||||||
|
2. 查看 `release_manifest.json`
|
||||||
|
3. 查看 `smoke_test_report.json`
|
||||||
|
4. 核对 `.sha256.txt`
|
||||||
|
或执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\verify_domain_release.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Windows 联调先跑 `scripts/smoke_test_stack.ps1`
|
||||||
|
6. Linux 部署前阅读:
|
||||||
|
- `docs/05_domainCheck_Linux部署清单.md`
|
||||||
|
- `docs/07_domainCheck_WebLinux发布验收清单.md`
|
||||||
|
- `domain-api/deploy/linux/README.md`
|
||||||
|
|
||||||
|
## 六、说明
|
||||||
|
|
||||||
|
当前打包内容偏向“交付部署包”和“源代码归档包”,不包含:
|
||||||
|
|
||||||
|
- `node_modules`
|
||||||
|
- Python 虚拟环境
|
||||||
|
- 临时运行日志
|
||||||
|
- 数据库数据本体
|
||||||
|
|
||||||
|
这样更适合交付、存档和迁移。
|
||||||
|
|
||||||
|
`latest_release.txt / latest_release.json` 用于快速定位“当前最新一份交付包”,避免人工翻目录。
|
||||||
|
|
||||||
|
`final_release_report.json` 用于记录最近一次“打包 + 验包”的最终状态。
|
||||||
67
docs/10_domainCheck_最终交付结论.md
Normal file
67
docs/10_domainCheck_最终交付结论.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# 10 domainCheck 最终交付结论
|
||||||
|
|
||||||
|
## 一、当前结论
|
||||||
|
|
||||||
|
当前项目已经完成从桌面版能力梳理,到 Web/API 方案落地,再到部署、验收、打包、验包的完整闭环。
|
||||||
|
|
||||||
|
从“可用性、可运维性、可交付性”三个维度看,当前状态可以定义为:
|
||||||
|
|
||||||
|
- 可开发使用
|
||||||
|
- 可本地联调
|
||||||
|
- 可 Linux 部署
|
||||||
|
- 可打包交付
|
||||||
|
- 可验包校验
|
||||||
|
|
||||||
|
## 二、已经具备的核心交付能力
|
||||||
|
|
||||||
|
- `domainCheck` 桌面版原系统
|
||||||
|
- `domain-api` 轻量后端接口
|
||||||
|
- `domain-web` 轻量运营后台
|
||||||
|
- Windows 本地启停脚本
|
||||||
|
- Linux 部署模板与说明
|
||||||
|
- 运行中心
|
||||||
|
- 日志诊断
|
||||||
|
- 诊断包下载
|
||||||
|
- 配置快照导出/导入
|
||||||
|
- 配置备份、备份记录、备份下载
|
||||||
|
- 配置导入前自动备份
|
||||||
|
- API 自检
|
||||||
|
- Web/API 整栈自测
|
||||||
|
- 交付打包脚本
|
||||||
|
- 交付验包脚本
|
||||||
|
- SHA256 完整性校验
|
||||||
|
- 最新交付指针文件
|
||||||
|
|
||||||
|
## 三、当前最新交付物定位方式
|
||||||
|
|
||||||
|
可优先查看:
|
||||||
|
|
||||||
|
- `release/latest_release.txt`
|
||||||
|
- `release/latest_release.json`
|
||||||
|
|
||||||
|
它们会指向:
|
||||||
|
|
||||||
|
- 最新展开目录
|
||||||
|
- 最新 zip 包
|
||||||
|
- 最新 sha256 文件
|
||||||
|
- 最新自测状态
|
||||||
|
|
||||||
|
## 四、剩余工作性质
|
||||||
|
|
||||||
|
当前剩余事项已经不再属于“系统开发未完成”,而主要属于:
|
||||||
|
|
||||||
|
- 真实 Linux 服务器联调
|
||||||
|
- 真实代理池和网络环境验证
|
||||||
|
- 上线前灰度发布
|
||||||
|
- 发布后观察期
|
||||||
|
|
||||||
|
## 五、建议下一步
|
||||||
|
|
||||||
|
1. 把最新交付包发送到目标 Linux 服务器
|
||||||
|
2. 按 `docs/05`、`docs/07`、`docs/08` 的顺序执行部署与验收
|
||||||
|
3. 部署完成后再做一次真实环境 smoke test
|
||||||
|
4. 进入灰度上线
|
||||||
|
|
||||||
|
## 六、最终判断
|
||||||
|
|
||||||
|
当前这套项目,已经达到“工程交付完成,待真实环境上线联调”的状态。
|
||||||
83
docs/11_domainCheck_Linux联调输入清单.md
Normal file
83
docs/11_domainCheck_Linux联调输入清单.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# 11 domainCheck Linux 联调输入清单
|
||||||
|
|
||||||
|
## 一、用途
|
||||||
|
|
||||||
|
当项目部署到真实 Linux 服务器后,如果需要我继续协助联调,优先给我下面这些材料。
|
||||||
|
|
||||||
|
这样我可以最快定位是:
|
||||||
|
|
||||||
|
- systemd 配置问题
|
||||||
|
- Python 环境问题
|
||||||
|
- Redis/PostgreSQL 连通性问题
|
||||||
|
- Web/API 路径配置问题
|
||||||
|
- Worker 运行问题
|
||||||
|
|
||||||
|
## 二、最推荐的做法
|
||||||
|
|
||||||
|
优先在 Linux 服务器执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/domaincheck/domain-api/deploy/linux
|
||||||
|
bash collect_diagnostics.sh /opt/domaincheck
|
||||||
|
```
|
||||||
|
|
||||||
|
执行后会输出:
|
||||||
|
|
||||||
|
- `diagnostics_dir=...`
|
||||||
|
- `diagnostics_archive=...`
|
||||||
|
|
||||||
|
把生成的压缩包发我即可。
|
||||||
|
|
||||||
|
## 三、如果不方便发整包,至少给这些
|
||||||
|
|
||||||
|
### 1. 服务状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status domaincheck-api --no-pager
|
||||||
|
systemctl status domaincheck-worker --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 最近日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u domaincheck-api -n 200 --no-pager
|
||||||
|
journalctl -u domaincheck-worker -n 200 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 接口状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8100/health
|
||||||
|
curl http://127.0.0.1:8100/api/v1/runtime/preflight
|
||||||
|
curl http://127.0.0.1:8100/api/v1/runtime/status
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 环境配置
|
||||||
|
|
||||||
|
- `domainCheck/.env`
|
||||||
|
- `domain-api/.env`
|
||||||
|
- `domain-api/runtime/` 下的配置与状态文件
|
||||||
|
|
||||||
|
注意:密码和 cookie 可以打码后再发。
|
||||||
|
|
||||||
|
## 四、建议一起补充的信息
|
||||||
|
|
||||||
|
- 服务器系统版本
|
||||||
|
- Python 版本
|
||||||
|
- PostgreSQL 版本
|
||||||
|
- Redis 版本
|
||||||
|
- 是否在中国大陆服务器
|
||||||
|
- 是否使用代理池
|
||||||
|
- Web 访问域名或端口
|
||||||
|
|
||||||
|
## 五、当前仓库已准备好的相关文件
|
||||||
|
|
||||||
|
- `domain-api/deploy/linux/README.md`
|
||||||
|
- `domain-api/deploy/linux/smoke_test.py`
|
||||||
|
- `domain-api/deploy/linux/collect_diagnostics.sh`
|
||||||
|
- `docs/05_domainCheck_Linux部署清单.md`
|
||||||
|
- `docs/07_domainCheck_WebLinux发布验收清单.md`
|
||||||
|
|
||||||
|
## 六、结论
|
||||||
|
|
||||||
|
后面你只要把 Linux 环境里的诊断包或上述输出给我,我就可以直接进入最后的线上联调阶段,不需要重新铺背景。
|
||||||
84
docs/12_domainCheck_导航索引.md
Normal file
84
docs/12_domainCheck_导航索引.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# 12 domainCheck 导航索引
|
||||||
|
|
||||||
|
## 一、最常用入口
|
||||||
|
|
||||||
|
### 1. 最新交付物
|
||||||
|
|
||||||
|
- 最新交付包:`release/latest_release.json`
|
||||||
|
- 最新摘要:`release/latest_release.txt`
|
||||||
|
- 最终准备报告:`release/final_release_report.json`
|
||||||
|
|
||||||
|
### 2. 最终结论文档
|
||||||
|
|
||||||
|
- `docs/10_domainCheck_最终交付结论.md`
|
||||||
|
|
||||||
|
### 3. Linux 联调时给我的材料
|
||||||
|
|
||||||
|
- `docs/11_domainCheck_Linux联调输入清单.md`
|
||||||
|
|
||||||
|
## 二、文档阅读顺序
|
||||||
|
|
||||||
|
### 1. 先看总体方案
|
||||||
|
|
||||||
|
- `docs/04_domainCheck_WebLinux改造总体方案.md`
|
||||||
|
|
||||||
|
### 2. 再看交付与部署
|
||||||
|
|
||||||
|
- `docs/08_domainCheck_交付说明.md`
|
||||||
|
- `docs/05_domainCheck_Linux部署清单.md`
|
||||||
|
- `docs/07_domainCheck_WebLinux发布验收清单.md`
|
||||||
|
- `docs/09_domainCheck_交付打包说明.md`
|
||||||
|
|
||||||
|
### 3. 如果要回溯历史需求与问题
|
||||||
|
|
||||||
|
- `docs/01_domainCheck_验收清单.md`
|
||||||
|
- `docs/02_domainCheck_优化清单_待审核.md`
|
||||||
|
- `docs/03_domainCheck_整改清单_外包版.md`
|
||||||
|
- `docs/06_domainCheck_Web版当前完成度.md`
|
||||||
|
|
||||||
|
## 三、最常用脚本
|
||||||
|
|
||||||
|
### 1. Windows 本地启停
|
||||||
|
|
||||||
|
- `start_domain_stack.ps1`
|
||||||
|
- `stop_domain_stack.ps1`
|
||||||
|
- `smoke_test_stack.ps1`
|
||||||
|
|
||||||
|
### 2. 交付打包
|
||||||
|
|
||||||
|
- `package_domain_release.ps1`
|
||||||
|
- `verify_domain_release.ps1`
|
||||||
|
- `prepare_final_release.ps1`
|
||||||
|
- `show_latest_release.ps1`
|
||||||
|
|
||||||
|
## 四、Linux 侧关键文件
|
||||||
|
|
||||||
|
### 1. API/Worker 部署
|
||||||
|
|
||||||
|
- `domain-api/deploy/systemd/domain-api.service`
|
||||||
|
- `domain-api/deploy/systemd/domain-worker.service`
|
||||||
|
- `domain-api/deploy/linux/README.md`
|
||||||
|
- `domain-api/deploy/linux/smoke_test.py`
|
||||||
|
- `domain-api/deploy/linux/collect_diagnostics.sh`
|
||||||
|
|
||||||
|
### 2. Web 部署
|
||||||
|
|
||||||
|
- `domain-web/deploy/nginx/domain-web.conf`
|
||||||
|
- `domain-web/deploy/linux/publish.sh`
|
||||||
|
|
||||||
|
## 五、当前最新状态
|
||||||
|
|
||||||
|
当前这套项目已经完成:
|
||||||
|
|
||||||
|
- 本地开发
|
||||||
|
- Web/API 落地
|
||||||
|
- 自检/自测
|
||||||
|
- 配置迁移与备份
|
||||||
|
- 打包与验包
|
||||||
|
- 最终发布准备
|
||||||
|
|
||||||
|
剩余工作只在真实 Linux 环境:
|
||||||
|
|
||||||
|
- 部署
|
||||||
|
- 联调
|
||||||
|
- 灰度上线
|
||||||
5
docs/_tmp_regression/import_regression.txt
Normal file
5
docs/_tmp_regression/import_regression.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
codex-import-reg-20260416-a.com
|
||||||
|
codex-import-reg-20260416-b.net
|
||||||
|
http://codex-import-reg-20260416-c.org/path
|
||||||
|
bad domain
|
||||||
|
codex-import-reg-20260416-a.com
|
||||||
2
docs/_tmp_regression/regression_domains.txt
Normal file
2
docs/_tmp_regression/regression_domains.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
codex-regression-20260416-a.com
|
||||||
|
codex-regression-20260416-b.net
|
||||||
3
docs/_tmp_regression/regression_page1.csv
Normal file
3
docs/_tmp_regression/regression_page1.csv
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
域名,注册状态,使用状态,检测状态,人工复核状态,过期时间,单位性质,网站首页网址,检测时间,备案历史,备案年份,快照年份,百度历史,百度Site,是否中文标题,360 Site,Google Site,友情链接数量
|
||||||
|
codex-regression-20260416-a.com,2,0,1,0,,,https://alpha.example,2026-04-16 10:00:00,2,2024,"2021,2024",{'status': True},{'status': True},,{'status': True},{'status': False},15
|
||||||
|
codex-regression-20260416-b.net,2,0,1,0,,,https://beta.example,2026-04-16 11:00:00,3,,2020,{'status': False},{'status': False},,{'status': False},{'status': False},5
|
||||||
|
28
domain-api/.env.example
Normal file
28
domain-api/.env.example
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
API_PREFIX=/api/v1
|
||||||
|
API_HOST=0.0.0.0
|
||||||
|
API_PORT=8100
|
||||||
|
|
||||||
|
# 允许访问 Web 后台的来源,多个地址用英文逗号分隔
|
||||||
|
CORS_ORIGINS=http://127.0.0.1:3200,http://localhost:3200
|
||||||
|
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_DATABASE=domain
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# domainCheck 桌面版或 Worker 所在目录
|
||||||
|
DOMAIN_ROOT=/opt/domaincheck/domainCheck
|
||||||
|
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
|
# windows-local 或 linux-systemd
|
||||||
|
WORKER_MODE=linux-systemd
|
||||||
|
WORKER_SERVICE_NAME=domaincheck-worker
|
||||||
|
API_SERVICE_NAME=domaincheck-api
|
||||||
80
domain-api/README.md
Normal file
80
domain-api/README.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# domain-api
|
||||||
|
|
||||||
|
`domainCheck` 轻量 Web 管理后台后端。
|
||||||
|
|
||||||
|
## 当前能力
|
||||||
|
|
||||||
|
- 提供健康检查、登录、概览、系统设置、域名导入、检测控制、筛选、导出、日志诊断接口
|
||||||
|
- 已接入真实 PostgreSQL / Redis / `domainCheck` 配置
|
||||||
|
- 已支持 `windows-local` 与 `linux-systemd` 两种 Worker 控制模式
|
||||||
|
- 已支持导入任务化、导出任务记录、基础运行状态探测
|
||||||
|
|
||||||
|
## Windows 启动
|
||||||
|
|
||||||
|
在工作区根目录执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\start_domain_api.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
停止:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\stop_domain_api.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
日志默认输出到:
|
||||||
|
|
||||||
|
- `domain-api/runtime/logs/domain-api.stdout.log`
|
||||||
|
- `domain-api/runtime/logs/domain-api.stderr.log`
|
||||||
|
|
||||||
|
## Linux / systemd
|
||||||
|
|
||||||
|
当后端迁到 Linux 时,建议:
|
||||||
|
|
||||||
|
- `domain-api` 独立为一个 systemd 服务
|
||||||
|
- `domainCheck/detect_worker.py` 独立为一个 systemd 服务
|
||||||
|
- `.env` 仍由 `domainCheck/.env` 统一提供数据库和 Redis 配置
|
||||||
|
|
||||||
|
参考模板:
|
||||||
|
|
||||||
|
- `deploy/systemd/domain-api.service`
|
||||||
|
- `deploy/systemd/domain-worker.service`
|
||||||
|
|
||||||
|
更完整的上线步骤见:
|
||||||
|
|
||||||
|
- `deploy/linux/README.md`
|
||||||
|
- `../docs/05_domainCheck_Linux部署清单.md`
|
||||||
|
|
||||||
|
启用前请按实际路径修改:
|
||||||
|
|
||||||
|
- `WorkingDirectory`
|
||||||
|
- `ExecStart`
|
||||||
|
- `User/Group`
|
||||||
|
|
||||||
|
同时在环境中设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WORKER_MODE=linux-systemd
|
||||||
|
WORKER_SERVICE_NAME=domaincheck-worker
|
||||||
|
API_SERVICE_NAME=domaincheck-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键环境变量
|
||||||
|
|
||||||
|
- `API_PREFIX`
|
||||||
|
- `API_HOST`
|
||||||
|
- `API_PORT`
|
||||||
|
- `WORKER_MODE`
|
||||||
|
- `WORKER_SERVICE_NAME`
|
||||||
|
- `API_SERVICE_NAME`
|
||||||
|
|
||||||
|
环境变量样板可参考:
|
||||||
|
|
||||||
|
- `.env.example`
|
||||||
|
|
||||||
|
默认情况下:
|
||||||
|
|
||||||
|
- `WORKER_MODE=windows-local`
|
||||||
|
- Web 前端地址:`http://127.0.0.1:3200`
|
||||||
|
- API 地址:`http://127.0.0.1:8100`
|
||||||
1
domain-api/app/__init__.py
Normal file
1
domain-api/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
domain-api/app/api/__init__.py
Normal file
1
domain-api/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
domain-api/app/api/routes/__init__.py
Normal file
1
domain-api/app/api/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
23
domain-api/app/api/routes/auth.py
Normal file
23
domain-api/app/api/routes/auth.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.schemas.auth import LoginRequest
|
||||||
|
from app.schemas.common import ApiResponse
|
||||||
|
|
||||||
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/login", response_model=ApiResponse)
|
||||||
|
def login(payload: LoginRequest) -> ApiResponse:
|
||||||
|
if payload.username != settings.admin_username or payload.password != settings.admin_password:
|
||||||
|
return ApiResponse(code=1, message="账号或密码错误", data=None)
|
||||||
|
token = f"domain-web-token-{payload.username}"
|
||||||
|
return ApiResponse(
|
||||||
|
data={
|
||||||
|
"access_token": token,
|
||||||
|
"user": {
|
||||||
|
"username": payload.username,
|
||||||
|
"display_name": "管理员" if payload.username == "admin" else payload.username,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
11
domain-api/app/api/routes/dashboard.py
Normal file
11
domain-api/app/api/routes/dashboard.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.schemas.common import ApiResponse
|
||||||
|
from app.services.dashboard import fetch_overview
|
||||||
|
|
||||||
|
router = APIRouter(tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/overview", response_model=ApiResponse)
|
||||||
|
def overview() -> ApiResponse:
|
||||||
|
return ApiResponse(data=fetch_overview())
|
||||||
40
domain-api/app/api/routes/detect.py
Normal file
40
domain-api/app/api/routes/detect.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.schemas.common import ApiResponse
|
||||||
|
from app.services.detect_service import get_detect_status
|
||||||
|
from app.services.worker_control_service import start_worker, stop_worker
|
||||||
|
|
||||||
|
router = APIRouter(tags=["detect"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/detect/status", response_model=ApiResponse)
|
||||||
|
def detect_status() -> ApiResponse:
|
||||||
|
return ApiResponse(data=get_detect_status())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/detect/start", response_model=ApiResponse)
|
||||||
|
def start_detect() -> ApiResponse:
|
||||||
|
ok, message = start_worker()
|
||||||
|
return ApiResponse(
|
||||||
|
code=0 if ok else 1,
|
||||||
|
message=message,
|
||||||
|
data={
|
||||||
|
"action": "start",
|
||||||
|
"poll_after_seconds": 2,
|
||||||
|
"refresh_status": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/detect/stop", response_model=ApiResponse)
|
||||||
|
def stop_detect() -> ApiResponse:
|
||||||
|
ok, message = stop_worker()
|
||||||
|
return ApiResponse(
|
||||||
|
code=0 if ok else 1,
|
||||||
|
message=message,
|
||||||
|
data={
|
||||||
|
"action": "stop",
|
||||||
|
"poll_after_seconds": 2,
|
||||||
|
"refresh_status": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
50
domain-api/app/api/routes/domains.py
Normal file
50
domain-api/app/api/routes/domains.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
|
from app.schemas.common import ApiResponse
|
||||||
|
from app.services.domains_service import batch_update_domains, domain_filter_options, fetch_domains
|
||||||
|
|
||||||
|
router = APIRouter(tags=["domains"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/domains/filters", response_model=ApiResponse)
|
||||||
|
def domain_filters() -> ApiResponse:
|
||||||
|
return ApiResponse(data=domain_filter_options())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/domains", response_model=ApiResponse)
|
||||||
|
def domain_list(
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=200),
|
||||||
|
domain_keyword: str | None = Query(default=None),
|
||||||
|
register_status: int | None = Query(default=None),
|
||||||
|
detect_status: int | None = Query(default=None),
|
||||||
|
use_status: int | None = Query(default=None),
|
||||||
|
review_status: int | None = Query(default=None),
|
||||||
|
has_beian: int | None = Query(default=None),
|
||||||
|
beian_year: int | None = Query(default=None),
|
||||||
|
snapshot_year: str | None = Query(default=None),
|
||||||
|
website_url: str | None = Query(default=None),
|
||||||
|
backlink_gt_10: bool | None = Query(default=None),
|
||||||
|
) -> ApiResponse:
|
||||||
|
return ApiResponse(
|
||||||
|
data=fetch_domains(
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
domain_keyword=domain_keyword,
|
||||||
|
register_status=register_status,
|
||||||
|
detect_status=detect_status,
|
||||||
|
use_status=use_status,
|
||||||
|
review_status=review_status,
|
||||||
|
has_beian=has_beian,
|
||||||
|
beian_year=beian_year,
|
||||||
|
snapshot_year=snapshot_year,
|
||||||
|
website_url=website_url,
|
||||||
|
backlink_gt_10=backlink_gt_10,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/domains/batch-update", response_model=ApiResponse)
|
||||||
|
def domain_batch_update(payload: dict) -> ApiResponse:
|
||||||
|
result = batch_update_domains(payload.get("domain_ids", []), payload.get("updates", {}))
|
||||||
|
return ApiResponse(message=f"成功更新 {result['updated_count']} 个域名", data=result)
|
||||||
29
domain-api/app/api/routes/exports.py
Normal file
29
domain-api/app/api/routes/exports.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.schemas.common import ApiResponse
|
||||||
|
from app.services.export_service import create_export_file, list_exports
|
||||||
|
from app.core.files import exports_root
|
||||||
|
|
||||||
|
router = APIRouter(tags=["exports"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/exports", response_model=ApiResponse)
|
||||||
|
def export_list() -> ApiResponse:
|
||||||
|
return ApiResponse(data=list_exports())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/exports/run", response_model=ApiResponse)
|
||||||
|
def export_run(payload: dict) -> ApiResponse:
|
||||||
|
record = create_export_file(payload)
|
||||||
|
return ApiResponse(message="导出文件已生成", data=record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/exports/download/{filename}")
|
||||||
|
def export_download(filename: str):
|
||||||
|
path = exports_root() / Path(filename).name
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="文件不存在")
|
||||||
|
return FileResponse(path, filename=path.name)
|
||||||
30
domain-api/app/api/routes/imports.py
Normal file
30
domain-api/app/api/routes/imports.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from fastapi import APIRouter, File, UploadFile
|
||||||
|
|
||||||
|
from app.schemas.common import ApiResponse
|
||||||
|
from app.services.import_task_service import create_import_task, list_import_tasks, retry_import_task
|
||||||
|
from app.services.imports_service import get_import_summary
|
||||||
|
|
||||||
|
router = APIRouter(tags=["imports"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/imports/summary", response_model=ApiResponse)
|
||||||
|
def imports_summary() -> ApiResponse:
|
||||||
|
return ApiResponse(data=get_import_summary())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/imports/tasks", response_model=ApiResponse)
|
||||||
|
def import_tasks() -> ApiResponse:
|
||||||
|
return ApiResponse(data=list_import_tasks())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/imports/upload", response_model=ApiResponse)
|
||||||
|
async def upload_import(file: UploadFile = File(...)) -> ApiResponse:
|
||||||
|
content = await file.read()
|
||||||
|
task = create_import_task(content, file.filename or "domains.txt")
|
||||||
|
return ApiResponse(message="导入任务已创建", data=task)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/imports/tasks/{task_id}/retry", response_model=ApiResponse)
|
||||||
|
def import_task_retry(task_id: str) -> ApiResponse:
|
||||||
|
task = retry_import_task(task_id)
|
||||||
|
return ApiResponse(message="导入任务已重新加入队列", data=task)
|
||||||
18
domain-api/app/api/routes/logs.py
Normal file
18
domain-api/app/api/routes/logs.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.schemas.common import ApiResponse
|
||||||
|
from app.services.logs_service import build_diagnostic_bundle, latest_logs as get_latest_logs
|
||||||
|
|
||||||
|
router = APIRouter(tags=["logs"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs/latest", response_model=ApiResponse)
|
||||||
|
def latest_logs() -> ApiResponse:
|
||||||
|
return ApiResponse(data=get_latest_logs())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs/bundle")
|
||||||
|
def download_logs_bundle() -> FileResponse:
|
||||||
|
path, filename = build_diagnostic_bundle()
|
||||||
|
return FileResponse(path=path, filename=filename, media_type="application/zip")
|
||||||
23
domain-api/app/api/routes/runtime.py
Normal file
23
domain-api/app/api/routes/runtime.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.schemas.common import ApiResponse
|
||||||
|
from app.services.runtime_control_service import runtime_action
|
||||||
|
from app.services.runtime_status_service import get_runtime_preflight, get_runtime_status
|
||||||
|
|
||||||
|
router = APIRouter(tags=["runtime"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runtime/status", response_model=ApiResponse)
|
||||||
|
def runtime_status() -> ApiResponse:
|
||||||
|
return ApiResponse(data=get_runtime_status())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runtime/preflight", response_model=ApiResponse)
|
||||||
|
def runtime_preflight() -> ApiResponse:
|
||||||
|
return ApiResponse(data=get_runtime_preflight())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/runtime/actions/{action}", response_model=ApiResponse)
|
||||||
|
def runtime_action_trigger(action: str) -> ApiResponse:
|
||||||
|
ok, message, data = runtime_action(action)
|
||||||
|
return ApiResponse(code=0 if ok else 1, message=message, data=data)
|
||||||
69
domain-api/app/api/routes/settings.py
Normal file
69
domain-api/app/api/routes/settings.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.core.files import settings_backup_root
|
||||||
|
from app.schemas.common import ApiResponse
|
||||||
|
from app.services.settings_service import (
|
||||||
|
backup_current_settings,
|
||||||
|
export_settings_snapshot,
|
||||||
|
get_settings_payload,
|
||||||
|
import_settings_snapshot,
|
||||||
|
list_settings_backups,
|
||||||
|
update_settings_payload,
|
||||||
|
validate_settings_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["settings"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_model=ApiResponse)
|
||||||
|
def get_settings() -> ApiResponse:
|
||||||
|
return ApiResponse(data=get_settings_payload())
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings", response_model=ApiResponse)
|
||||||
|
def update_settings(payload: dict) -> ApiResponse:
|
||||||
|
return ApiResponse(message="settings updated", data=update_settings_payload(payload))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/export", response_model=ApiResponse)
|
||||||
|
def export_settings() -> ApiResponse:
|
||||||
|
return ApiResponse(message="settings exported", data=export_settings_snapshot())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/import", response_model=ApiResponse)
|
||||||
|
def import_settings(payload: dict) -> ApiResponse:
|
||||||
|
try:
|
||||||
|
settings_payload = import_settings_snapshot(payload)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return ApiResponse(message="settings imported", data=settings_payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/validate-import", response_model=ApiResponse)
|
||||||
|
def validate_import_settings(payload: dict) -> ApiResponse:
|
||||||
|
try:
|
||||||
|
validate_settings_payload(payload.get("settings", payload))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return ApiResponse(message="settings payload valid", data={"valid": True})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/backup", response_model=ApiResponse)
|
||||||
|
def create_settings_backup() -> ApiResponse:
|
||||||
|
return ApiResponse(message="settings backed up", data=backup_current_settings("manual"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/backups", response_model=ApiResponse)
|
||||||
|
def get_settings_backups() -> ApiResponse:
|
||||||
|
return ApiResponse(data=list_settings_backups())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/backups/download/{filename}")
|
||||||
|
def download_settings_backup(filename: str):
|
||||||
|
path = settings_backup_root() / Path(filename).name
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="backup not found")
|
||||||
|
return FileResponse(path, filename=path.name)
|
||||||
1
domain-api/app/core/__init__.py
Normal file
1
domain-api/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
51
domain-api/app/core/config.py
Normal file
51
domain-api/app/core/config.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
WORKSPACE_DIR = BASE_DIR.parent
|
||||||
|
DOMAINCHECK_DIR = WORKSPACE_DIR / "domainCheck"
|
||||||
|
API_ENV_FILE = BASE_DIR / ".env"
|
||||||
|
ENV_FILE = DOMAINCHECK_DIR / ".env"
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
api_prefix: str = "/api/v1"
|
||||||
|
api_host: str = "0.0.0.0"
|
||||||
|
api_port: int = 8100
|
||||||
|
cors_origins: list[str] = ["http://127.0.0.1:3200", "http://localhost:3200"]
|
||||||
|
db_host: str = "127.0.0.1"
|
||||||
|
db_port: int = 5432
|
||||||
|
db_database: str = "domain"
|
||||||
|
db_user: str = "postgres"
|
||||||
|
db_password: str = "postgres"
|
||||||
|
redis_host: str = "127.0.0.1"
|
||||||
|
redis_port: int = 6379
|
||||||
|
redis_password: str = ""
|
||||||
|
redis_db: int = 0
|
||||||
|
domain_root: str = str(DOMAINCHECK_DIR)
|
||||||
|
admin_username: str = "admin"
|
||||||
|
admin_password: str = "admin"
|
||||||
|
worker_mode: str = "windows-local"
|
||||||
|
worker_service_name: str = "domaincheck-worker"
|
||||||
|
api_service_name: str = "domaincheck-api"
|
||||||
|
|
||||||
|
@field_validator("cors_origins", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_cors_origins(cls, value: object) -> object:
|
||||||
|
if isinstance(value, str):
|
||||||
|
if value.strip().startswith("["):
|
||||||
|
return value
|
||||||
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
return value
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=(str(API_ENV_FILE), str(ENV_FILE)),
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
20
domain-api/app/core/db.py
Normal file
20
domain-api/app/core/db.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_db():
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=settings.db_host,
|
||||||
|
port=settings.db_port,
|
||||||
|
dbname=settings.db_database,
|
||||||
|
user=settings.db_user,
|
||||||
|
password=settings.db_password,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
107
domain-api/app/core/files.py
Normal file
107
domain-api/app/core/files.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def domain_root() -> Path:
|
||||||
|
return Path(settings.domain_root)
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(relative_path: str, default: dict | list | None = None):
|
||||||
|
path = domain_root() / relative_path
|
||||||
|
if not path.exists():
|
||||||
|
return {} if default is None else default
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(relative_path: str, payload) -> None:
|
||||||
|
path = domain_root() / relative_path
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def tail_lines(relative_path: str, max_lines: int = 120) -> list[str]:
|
||||||
|
path = domain_root() / relative_path
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||||
|
return handle.read().splitlines()[-max_lines:]
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_root() -> Path:
|
||||||
|
path = Path(__file__).resolve().parents[2] / "runtime"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def read_runtime_json(filename: str, default: dict | list | None = None):
|
||||||
|
path = runtime_root() / filename
|
||||||
|
if not path.exists():
|
||||||
|
return {} if default is None else default
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
|
||||||
|
def write_runtime_json(filename: str, payload) -> None:
|
||||||
|
path = runtime_root() / filename
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def exports_root() -> Path:
|
||||||
|
path = runtime_root() / "exports"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def import_root() -> Path:
|
||||||
|
path = runtime_root() / "imports"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def settings_backup_root() -> Path:
|
||||||
|
path = runtime_root() / "settings_backups"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def load_export_records() -> list[dict]:
|
||||||
|
path = runtime_root() / "export_tasks.json"
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
|
||||||
|
def save_export_record(record: dict) -> None:
|
||||||
|
records = load_export_records()
|
||||||
|
records.insert(0, record)
|
||||||
|
path = runtime_root() / "export_tasks.json"
|
||||||
|
with path.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(records[:200], handle, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def load_import_records() -> list[dict]:
|
||||||
|
path = runtime_root() / "import_tasks.json"
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
|
||||||
|
def save_import_records(records: list[dict]) -> None:
|
||||||
|
path = runtime_root() / "import_tasks.json"
|
||||||
|
with path.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(records[:200], handle, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def timestamp_filename(prefix: str, ext: str) -> str:
|
||||||
|
return f"{prefix}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{ext}"
|
||||||
17
domain-api/app/core/redis_client.py
Normal file
17
domain-api/app/core/redis_client.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import redis
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_redis() -> redis.Redis:
|
||||||
|
return redis.Redis(
|
||||||
|
host=settings.redis_host,
|
||||||
|
port=settings.redis_port,
|
||||||
|
password=settings.redis_password or None,
|
||||||
|
db=settings.redis_db,
|
||||||
|
decode_responses=True,
|
||||||
|
socket_connect_timeout=5,
|
||||||
|
socket_timeout=5,
|
||||||
|
)
|
||||||
43
domain-api/app/main.py
Normal file
43
domain-api/app/main.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api.routes import auth, dashboard, settings as settings_routes, imports, detect, domains, exports, logs, runtime
|
||||||
|
from app.core.config import settings as app_settings
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="domainCheck API",
|
||||||
|
version="0.1.0",
|
||||||
|
description="domainCheck 轻量 Web 管理后台后端骨架",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=app_settings.cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"service": "domain-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"api_prefix": app_settings.api_prefix,
|
||||||
|
"worker_mode": app_settings.worker_mode,
|
||||||
|
"api_host": app_settings.api_host,
|
||||||
|
"api_port": app_settings.api_port,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(auth.router, prefix=app_settings.api_prefix)
|
||||||
|
app.include_router(dashboard.router, prefix=app_settings.api_prefix)
|
||||||
|
app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
|
||||||
|
app.include_router(imports.router, prefix=app_settings.api_prefix)
|
||||||
|
app.include_router(detect.router, prefix=app_settings.api_prefix)
|
||||||
|
app.include_router(domains.router, prefix=app_settings.api_prefix)
|
||||||
|
app.include_router(exports.router, prefix=app_settings.api_prefix)
|
||||||
|
app.include_router(logs.router, prefix=app_settings.api_prefix)
|
||||||
|
app.include_router(runtime.router, prefix=app_settings.api_prefix)
|
||||||
1
domain-api/app/schemas/__init__.py
Normal file
1
domain-api/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
6
domain-api/app/schemas/auth.py
Normal file
6
domain-api/app/schemas/auth.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
8
domain-api/app/schemas/common.py
Normal file
8
domain-api/app/schemas/common.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ApiResponse(BaseModel):
|
||||||
|
code: int = 0
|
||||||
|
message: str = "ok"
|
||||||
|
data: Any = None
|
||||||
1
domain-api/app/services/__init__.py
Normal file
1
domain-api/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
27
domain-api/app/services/dashboard.py
Normal file
27
domain-api/app/services/dashboard.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.services.runtime_status_service import get_runtime_status
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_overview() -> dict:
|
||||||
|
queries = {
|
||||||
|
"domains_total": "select count(*) from domains",
|
||||||
|
"pending_total": "select count(*) from domains where detect_status = 0",
|
||||||
|
"completed_total": "select count(*) from domains where detect_status = 1",
|
||||||
|
"running_total": "select count(*) from domains where detect_status = 2",
|
||||||
|
"blacklist_total": "select count(*) from domains where detect_status = 3",
|
||||||
|
"failed_total": "select count(*) from domains where detect_status = 4",
|
||||||
|
"sensitive_words_total": "select count(*) from sensitive_words",
|
||||||
|
}
|
||||||
|
result: dict[str, int | str] = {}
|
||||||
|
with get_db() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for key, query in queries.items():
|
||||||
|
cur.execute(query)
|
||||||
|
result[key] = cur.fetchone()[0]
|
||||||
|
runtime = get_runtime_status()
|
||||||
|
result["worker_status"] = "online" if runtime["worker"]["running"] else "offline"
|
||||||
|
result["api_status"] = "online"
|
||||||
|
result["worker_mode"] = runtime["worker"]["mode"]
|
||||||
|
return result
|
||||||
65
domain-api/app/services/detect_service.py
Normal file
65
domain-api/app/services/detect_service.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.files import tail_lines
|
||||||
|
from app.services.runtime_settings_service import get_runtime_settings
|
||||||
|
from app.services.settings_service import get_settings_payload
|
||||||
|
from app.services.worker_control_service import detect_worker_runtime
|
||||||
|
|
||||||
|
|
||||||
|
def get_detect_status() -> dict:
|
||||||
|
queries = {
|
||||||
|
"pending": "select count(*) from domains where detect_status = 0",
|
||||||
|
"completed": "select count(*) from domains where detect_status = 1",
|
||||||
|
"running": "select count(*) from domains where detect_status = 2",
|
||||||
|
"blacklisted": "select count(*) from domains where detect_status = 3",
|
||||||
|
"failed": "select count(*) from domains where detect_status = 4",
|
||||||
|
}
|
||||||
|
progress: dict[str, int] = {}
|
||||||
|
with get_db() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for key, query in queries.items():
|
||||||
|
cur.execute(query)
|
||||||
|
progress[key] = cur.fetchone()[0]
|
||||||
|
|
||||||
|
settings_payload = get_settings_payload()
|
||||||
|
worker_log = Path(__file__).resolve().parents[3] / "domainCheck" / "detect_worker.log"
|
||||||
|
if not worker_log.exists():
|
||||||
|
worker_log = Path(__file__).resolve().parents[3] / "domainCheck" / "logs" / "detect_worker.log"
|
||||||
|
|
||||||
|
worker_online = False
|
||||||
|
last_log_time = None
|
||||||
|
if worker_log.exists():
|
||||||
|
modified = datetime.fromtimestamp(worker_log.stat().st_mtime, tz=timezone.utc)
|
||||||
|
last_log_time = modified.isoformat()
|
||||||
|
worker_online = (datetime.now(timezone.utc) - modified).total_seconds() < 180
|
||||||
|
|
||||||
|
recent_lines = tail_lines("detect_worker.log", max_lines=80)
|
||||||
|
recent_proxy_warning = next(
|
||||||
|
(line for line in reversed(recent_lines) if "代理" in line or "Redis订阅失败" in line),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
runtime = detect_worker_runtime()
|
||||||
|
runtime_settings = get_runtime_settings()
|
||||||
|
worker_online = worker_online or runtime.get("running", False)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"worker_online": worker_online,
|
||||||
|
"worker_mode": runtime.get("mode", "windows-local"),
|
||||||
|
"worker_service_name": runtime_settings.get("worker_service_name", ""),
|
||||||
|
"api_service_name": runtime_settings.get("api_service_name", ""),
|
||||||
|
"worker_process_count": runtime.get("process_count", 0),
|
||||||
|
"worker_latest_start_time": runtime.get("latest_start_time", ""),
|
||||||
|
"worker_runtime_message": runtime.get("message", ""),
|
||||||
|
"thread_count": settings_payload["thread_count"],
|
||||||
|
"proxy_enable": settings_payload["proxy_config"].get("proxy_enable", False),
|
||||||
|
"allow_direct": settings_payload["proxy_config"].get("allow_direct", False),
|
||||||
|
"proxy_pool_count": len(settings_payload["proxy_config"].get("proxy_urls", [])),
|
||||||
|
"available_proxy_count": 0,
|
||||||
|
"last_worker_log_time": last_log_time,
|
||||||
|
"progress": progress,
|
||||||
|
"recent_warning": recent_proxy_warning,
|
||||||
|
}
|
||||||
277
domain-api/app/services/domains_service.py
Normal file
277
domain-api/app/services/domains_service.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from math import ceil
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
|
||||||
|
DETECT_STATUS_LABELS = {
|
||||||
|
0: "待检测",
|
||||||
|
1: "检测完成",
|
||||||
|
2: "检测中",
|
||||||
|
3: "黑名单",
|
||||||
|
4: "检测失败",
|
||||||
|
}
|
||||||
|
|
||||||
|
REGISTER_STATUS_LABELS = {
|
||||||
|
0: "待检测",
|
||||||
|
2: "可注册",
|
||||||
|
3: "已注册",
|
||||||
|
4: "宽限期",
|
||||||
|
5: "赎回期",
|
||||||
|
6: "删除期",
|
||||||
|
7: "clientHold",
|
||||||
|
8: "serverHold",
|
||||||
|
9: "状态未知",
|
||||||
|
10: "检测失败",
|
||||||
|
}
|
||||||
|
|
||||||
|
USE_STATUS_LABELS = {
|
||||||
|
0: "未使用",
|
||||||
|
1: "已经使用",
|
||||||
|
2: "已经卖出",
|
||||||
|
3: "已经预定",
|
||||||
|
}
|
||||||
|
|
||||||
|
REVIEW_STATUS_LABELS = {
|
||||||
|
0: "无需复核",
|
||||||
|
1: "待人工复核",
|
||||||
|
2: "人工通过",
|
||||||
|
3: "人工拒绝",
|
||||||
|
}
|
||||||
|
|
||||||
|
BEIAN_STATUS_LABELS = {
|
||||||
|
1: "未检测",
|
||||||
|
2: "有备案",
|
||||||
|
3: "无备案",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_domain_query_parts(filters: dict | None = None) -> tuple[str, str, list[object]]:
|
||||||
|
filters = filters or {}
|
||||||
|
conditions: list[str] = []
|
||||||
|
params: list[object] = []
|
||||||
|
|
||||||
|
if filters.get("domain_keyword"):
|
||||||
|
conditions.append("d.domain ilike %s")
|
||||||
|
params.append(f"%{str(filters['domain_keyword']).strip()}%")
|
||||||
|
if filters.get("register_status") is not None:
|
||||||
|
conditions.append("d.register_status = %s")
|
||||||
|
params.append(filters["register_status"])
|
||||||
|
if filters.get("detect_status") is not None:
|
||||||
|
conditions.append("d.detect_status = %s")
|
||||||
|
params.append(filters["detect_status"])
|
||||||
|
if filters.get("use_status") is not None:
|
||||||
|
conditions.append("d.use_status = %s")
|
||||||
|
params.append(filters["use_status"])
|
||||||
|
if filters.get("review_status") is not None:
|
||||||
|
conditions.append("d.review_status = %s")
|
||||||
|
params.append(filters["review_status"])
|
||||||
|
if filters.get("has_beian") is not None:
|
||||||
|
conditions.append("d.has_beian = %s")
|
||||||
|
params.append(filters["has_beian"])
|
||||||
|
if filters.get("beian_year"):
|
||||||
|
conditions.append("d.beian_year = %s")
|
||||||
|
params.append(int(filters["beian_year"]))
|
||||||
|
if filters.get("snapshot_year"):
|
||||||
|
conditions.append("coalesce(d.snapshot_years, '') like %s")
|
||||||
|
params.append(f"%{str(filters['snapshot_year']).strip()}%")
|
||||||
|
if filters.get("website_url"):
|
||||||
|
conditions.append("coalesce(d.website_url, '') ilike %s")
|
||||||
|
params.append(f"%{str(filters['website_url']).strip()}%")
|
||||||
|
if filters.get("backlink_gt_10"):
|
||||||
|
conditions.append("coalesce(dd.backlink_count_gt_10, false) = true")
|
||||||
|
|
||||||
|
from_clause = """
|
||||||
|
from domains d
|
||||||
|
left join domain_detections dd on dd.domain_id = d.id
|
||||||
|
"""
|
||||||
|
where_clause = f"where {' and '.join(conditions)}" if conditions else ""
|
||||||
|
return from_clause, where_clause, params
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_domains(
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
domain_keyword: str | None = None,
|
||||||
|
register_status: int | None = None,
|
||||||
|
detect_status: int | None = None,
|
||||||
|
has_beian: int | None = None,
|
||||||
|
use_status: int | None = None,
|
||||||
|
review_status: int | None = None,
|
||||||
|
beian_year: int | None = None,
|
||||||
|
snapshot_year: str | None = None,
|
||||||
|
website_url: str | None = None,
|
||||||
|
backlink_gt_10: bool | None = None,
|
||||||
|
) -> dict:
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
filters = {
|
||||||
|
"domain_keyword": domain_keyword,
|
||||||
|
"register_status": register_status,
|
||||||
|
"detect_status": detect_status,
|
||||||
|
"has_beian": has_beian,
|
||||||
|
"use_status": use_status,
|
||||||
|
"review_status": review_status,
|
||||||
|
"beian_year": beian_year,
|
||||||
|
"snapshot_year": snapshot_year,
|
||||||
|
"website_url": website_url,
|
||||||
|
"backlink_gt_10": backlink_gt_10,
|
||||||
|
}
|
||||||
|
from_clause, where_clause, params = _build_domain_query_parts(filters)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(f"select count(*) {from_clause} {where_clause}", tuple(params))
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
select
|
||||||
|
d.id,
|
||||||
|
d.domain,
|
||||||
|
d.register_status,
|
||||||
|
d.use_status,
|
||||||
|
d.detect_status,
|
||||||
|
d.review_status,
|
||||||
|
d.has_beian,
|
||||||
|
d.website_url,
|
||||||
|
d.beian_year,
|
||||||
|
d.snapshot_years,
|
||||||
|
d.backlink_count,
|
||||||
|
d.detect_time,
|
||||||
|
coalesce(dd.backlink_count_gt_10, false) as backlink_gt_10
|
||||||
|
{from_clause}
|
||||||
|
{where_clause}
|
||||||
|
order by d.id desc
|
||||||
|
limit %s offset %s
|
||||||
|
""",
|
||||||
|
tuple(params + [page_size, offset]),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"id": row[0],
|
||||||
|
"domain": row[1],
|
||||||
|
"register_status": REGISTER_STATUS_LABELS.get(row[2], str(row[2])),
|
||||||
|
"register_status_code": row[2],
|
||||||
|
"use_status": USE_STATUS_LABELS.get(row[3], str(row[3])),
|
||||||
|
"use_status_code": row[3],
|
||||||
|
"detect_status": DETECT_STATUS_LABELS.get(row[4], str(row[4])),
|
||||||
|
"detect_status_code": row[4],
|
||||||
|
"review_status": REVIEW_STATUS_LABELS.get(row[5], str(row[5])),
|
||||||
|
"review_status_code": row[5],
|
||||||
|
"has_beian": BEIAN_STATUS_LABELS.get(row[6], str(row[6])),
|
||||||
|
"has_beian_code": row[6],
|
||||||
|
"website_url": row[7] or "",
|
||||||
|
"beian_year": row[8],
|
||||||
|
"snapshot_years": row[9] or "",
|
||||||
|
"backlink_count": row[10],
|
||||||
|
"detect_time": row[11].isoformat() if row[11] else None,
|
||||||
|
"backlink_gt_10": row[12],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"list": items,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total": total,
|
||||||
|
"pages": ceil(total / page_size) if page_size else 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def domain_filter_options() -> dict:
|
||||||
|
return {
|
||||||
|
"register_status": [
|
||||||
|
{"label": label, "value": value}
|
||||||
|
for value, label in REGISTER_STATUS_LABELS.items()
|
||||||
|
if value in (2, 3, 4, 5, 6, 7, 8, 10)
|
||||||
|
],
|
||||||
|
"detect_status": [
|
||||||
|
{"label": label, "value": value}
|
||||||
|
for value, label in DETECT_STATUS_LABELS.items()
|
||||||
|
],
|
||||||
|
"use_status": [
|
||||||
|
{"label": label, "value": value}
|
||||||
|
for value, label in USE_STATUS_LABELS.items()
|
||||||
|
],
|
||||||
|
"review_status": [
|
||||||
|
{"label": label, "value": value}
|
||||||
|
for value, label in REVIEW_STATUS_LABELS.items()
|
||||||
|
],
|
||||||
|
"has_beian": [
|
||||||
|
{"label": "未检测", "value": 1},
|
||||||
|
{"label": "有备案", "value": 2},
|
||||||
|
{"label": "无备案", "value": 3},
|
||||||
|
],
|
||||||
|
"supports_backlink_gt_10": True,
|
||||||
|
"supports_txt_export": True,
|
||||||
|
"supports_excel_export": True,
|
||||||
|
"supports_multi_page_export": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def batch_update_domains(domain_ids: list[int], updates: dict) -> dict:
|
||||||
|
if not domain_ids:
|
||||||
|
raise ValueError("未选择需要更新的域名")
|
||||||
|
|
||||||
|
allowed_fields = {
|
||||||
|
"review_status",
|
||||||
|
"expire_date",
|
||||||
|
"has_beian",
|
||||||
|
"beian_year",
|
||||||
|
"snapshot_years",
|
||||||
|
"company_type",
|
||||||
|
"detect_time",
|
||||||
|
"website_url",
|
||||||
|
"backlink_count",
|
||||||
|
}
|
||||||
|
payload = {key: value for key, value in updates.items() if key in allowed_fields and value not in (None, "", "skip")}
|
||||||
|
if not payload:
|
||||||
|
raise ValueError("没有可更新的字段")
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
with get_db() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for domain_id in domain_ids:
|
||||||
|
set_parts: list[str] = []
|
||||||
|
params: list[object] = []
|
||||||
|
|
||||||
|
for field, value in payload.items():
|
||||||
|
if field == "backlink_count":
|
||||||
|
set_parts.append("backlink_count = %s")
|
||||||
|
params.append(int(value))
|
||||||
|
else:
|
||||||
|
set_parts.append(f"{field} = %s")
|
||||||
|
params.append(value)
|
||||||
|
|
||||||
|
params.append(domain_id)
|
||||||
|
cur.execute(
|
||||||
|
f"update domains set {', '.join(set_parts)}, update_time = now() where id = %s",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
|
||||||
|
if "backlink_count" in payload:
|
||||||
|
backlink_gt_10 = int(payload["backlink_count"]) > 10
|
||||||
|
cur.execute("select id from domain_detections where domain_id = %s", (domain_id,))
|
||||||
|
if cur.fetchone():
|
||||||
|
cur.execute(
|
||||||
|
"update domain_detections set backlink_count_gt_10 = %s, update_time = now() where domain_id = %s",
|
||||||
|
(backlink_gt_10, domain_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
insert into domain_detections (domain_id, backlink_count_gt_10, create_time, update_time)
|
||||||
|
values (%s, %s, now(), now())
|
||||||
|
""",
|
||||||
|
(domain_id, backlink_gt_10),
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_count += 1
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"updated_count": updated_count,
|
||||||
|
"fields": sorted(payload.keys()),
|
||||||
|
}
|
||||||
172
domain-api/app/services/export_service.py
Normal file
172
domain-api/app/services/export_service.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.files import exports_root, load_export_records, save_export_record, timestamp_filename
|
||||||
|
from app.services.domains_service import (
|
||||||
|
BEIAN_STATUS_LABELS,
|
||||||
|
DETECT_STATUS_LABELS,
|
||||||
|
REGISTER_STATUS_LABELS,
|
||||||
|
REVIEW_STATUS_LABELS,
|
||||||
|
USE_STATUS_LABELS,
|
||||||
|
_build_domain_query_parts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
EXPORT_HEADERS = [
|
||||||
|
("domain", "域名"),
|
||||||
|
("register_status", "注册状态"),
|
||||||
|
("use_status", "使用状态"),
|
||||||
|
("detect_status", "检测状态"),
|
||||||
|
("review_status", "复核状态"),
|
||||||
|
("has_beian", "备案状态"),
|
||||||
|
("website_url", "首页网址"),
|
||||||
|
("beian_year", "备案年份"),
|
||||||
|
("snapshot_years", "快照年份"),
|
||||||
|
("backlink_count", "友链数"),
|
||||||
|
("backlink_gt_10", "友链>10"),
|
||||||
|
("detect_time", "检测时间"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_payload(payload: dict) -> dict:
|
||||||
|
data = dict(payload or {})
|
||||||
|
data["page"] = int(data.get("page", 1) or 1)
|
||||||
|
data["page_size"] = int(data.get("page_size", 100) or 100)
|
||||||
|
data["page_count"] = int(data.get("page_count", 1) or 1)
|
||||||
|
data["scope"] = data.get("scope", "page")
|
||||||
|
data["type"] = data.get("type", "txt")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _query_export_rows(payload: dict) -> list[dict]:
|
||||||
|
data = _normalize_payload(payload)
|
||||||
|
from_clause, where_clause, params = _build_domain_query_parts(data)
|
||||||
|
|
||||||
|
limit_offset = ""
|
||||||
|
if data["scope"] == "page":
|
||||||
|
offset = (data["page"] - 1) * data["page_size"]
|
||||||
|
limit_offset = " limit %s offset %s"
|
||||||
|
params.extend([data["page_size"], offset])
|
||||||
|
elif data["scope"] == "pages":
|
||||||
|
offset = (data["page"] - 1) * data["page_size"]
|
||||||
|
limit_value = data["page_size"] * max(data["page_count"], 1)
|
||||||
|
limit_offset = " limit %s offset %s"
|
||||||
|
params.extend([limit_value, offset])
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
select
|
||||||
|
d.domain,
|
||||||
|
d.register_status,
|
||||||
|
d.use_status,
|
||||||
|
d.detect_status,
|
||||||
|
d.review_status,
|
||||||
|
d.has_beian,
|
||||||
|
d.website_url,
|
||||||
|
d.beian_year,
|
||||||
|
d.snapshot_years,
|
||||||
|
d.backlink_count,
|
||||||
|
coalesce(dd.backlink_count_gt_10, false) as backlink_gt_10,
|
||||||
|
d.detect_time
|
||||||
|
{from_clause}
|
||||||
|
{where_clause}
|
||||||
|
order by d.id desc
|
||||||
|
{limit_offset}
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
result: list[dict] = []
|
||||||
|
for row in rows:
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"domain": row[0],
|
||||||
|
"register_status": REGISTER_STATUS_LABELS.get(row[1], str(row[1])),
|
||||||
|
"use_status": USE_STATUS_LABELS.get(row[2], str(row[2])),
|
||||||
|
"detect_status": DETECT_STATUS_LABELS.get(row[3], str(row[3])),
|
||||||
|
"review_status": REVIEW_STATUS_LABELS.get(row[4], str(row[4])),
|
||||||
|
"has_beian": BEIAN_STATUS_LABELS.get(row[5], str(row[5])),
|
||||||
|
"website_url": row[6] or "",
|
||||||
|
"beian_year": row[7] or "",
|
||||||
|
"snapshot_years": row[8] or "",
|
||||||
|
"backlink_count": row[9] or 0,
|
||||||
|
"backlink_gt_10": "是" if row[10] else "否",
|
||||||
|
"detect_time": row[11].isoformat(sep=" ", timespec="seconds") if row[11] else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _write_txt(path, rows: list[dict]) -> None:
|
||||||
|
with path.open("w", encoding="utf-8") as handle:
|
||||||
|
for row in rows:
|
||||||
|
handle.write(f"{row['domain']}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_csv(path, rows: list[dict]) -> None:
|
||||||
|
with path.open("w", encoding="utf-8", newline="") as handle:
|
||||||
|
writer = csv.writer(handle)
|
||||||
|
writer.writerow([label for _, label in EXPORT_HEADERS])
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow([row[key] for key, _ in EXPORT_HEADERS])
|
||||||
|
|
||||||
|
|
||||||
|
def _write_xlsx(path, rows: list[dict]) -> None:
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.title = "domains"
|
||||||
|
sheet.append([label for _, label in EXPORT_HEADERS])
|
||||||
|
for row in rows:
|
||||||
|
sheet.append([row[key] for key, _ in EXPORT_HEADERS])
|
||||||
|
workbook.save(path)
|
||||||
|
|
||||||
|
|
||||||
|
def create_export_file(payload: dict) -> dict:
|
||||||
|
data = _normalize_payload(payload)
|
||||||
|
rows = _query_export_rows(data)
|
||||||
|
|
||||||
|
ext = data["type"] if data["type"] in {"txt", "csv", "xlsx"} else "txt"
|
||||||
|
filename = timestamp_filename("domain_export", ext)
|
||||||
|
output_path = exports_root() / filename
|
||||||
|
|
||||||
|
if ext == "txt":
|
||||||
|
_write_txt(output_path, rows)
|
||||||
|
elif ext == "csv":
|
||||||
|
_write_csv(output_path, rows)
|
||||||
|
else:
|
||||||
|
_write_xlsx(output_path, rows)
|
||||||
|
|
||||||
|
created_at = datetime.fromtimestamp(output_path.stat().st_mtime)
|
||||||
|
record = {
|
||||||
|
"filename": filename,
|
||||||
|
"type": ext,
|
||||||
|
"scope": data["scope"],
|
||||||
|
"page": data["page"],
|
||||||
|
"page_size": data["page_size"],
|
||||||
|
"page_count": data["page_count"],
|
||||||
|
"count": len(rows),
|
||||||
|
"created_at": created_at.isoformat(sep=" ", timespec="seconds"),
|
||||||
|
"download_path": f"/api/v1/exports/download/{filename}",
|
||||||
|
}
|
||||||
|
save_export_record(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def list_exports() -> list[dict]:
|
||||||
|
records = load_export_records()
|
||||||
|
normalized: list[dict] = []
|
||||||
|
for record in records:
|
||||||
|
item = dict(record)
|
||||||
|
created_at = item.get("created_at")
|
||||||
|
if isinstance(created_at, (int, float)):
|
||||||
|
item["created_at"] = datetime.fromtimestamp(created_at).isoformat(sep=" ", timespec="seconds")
|
||||||
|
normalized.append(item)
|
||||||
|
return normalized
|
||||||
114
domain-api/app/services/import_task_service.py
Normal file
114
domain-api/app/services/import_task_service.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.core.files import import_root, load_import_records, save_import_records
|
||||||
|
from app.services.import_worker_service import import_domains_from_path
|
||||||
|
|
||||||
|
|
||||||
|
_IMPORT_TASK_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now().isoformat(sep=" ", timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def list_import_tasks() -> list[dict]:
|
||||||
|
return load_import_records()
|
||||||
|
|
||||||
|
|
||||||
|
def _save_tasks(tasks: list[dict]) -> None:
|
||||||
|
save_import_records(tasks)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_task(task_id: str, **patch: object) -> dict | None:
|
||||||
|
with _IMPORT_TASK_LOCK:
|
||||||
|
tasks = load_import_records()
|
||||||
|
target = next((item for item in tasks if item["task_id"] == task_id), None)
|
||||||
|
if not target:
|
||||||
|
return None
|
||||||
|
target.update(patch)
|
||||||
|
target["updated_at"] = _now()
|
||||||
|
_save_tasks(tasks)
|
||||||
|
return dict(target)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_import_task(task_id: str, file_path: str, source_type: int = 7) -> None:
|
||||||
|
_update_task(task_id, status="running", started_at=_now(), message="导入任务开始执行")
|
||||||
|
try:
|
||||||
|
result = import_domains_from_path(Path(file_path), source_type=source_type)
|
||||||
|
stats = result.get("stats", {})
|
||||||
|
_update_task(
|
||||||
|
task_id,
|
||||||
|
status="completed",
|
||||||
|
completed_at=_now(),
|
||||||
|
result=result,
|
||||||
|
message=(
|
||||||
|
f"导入完成:总数 {stats.get('total', 0)},有效 {stats.get('valid', 0)},"
|
||||||
|
f"新增 {stats.get('added', 0)},已存在 {stats.get('exists', 0)},无效 {stats.get('invalid', 0)}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_update_task(
|
||||||
|
task_id,
|
||||||
|
status="failed",
|
||||||
|
completed_at=_now(),
|
||||||
|
message=f"导入失败:{exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_import_task(content: bytes, filename: str, source_type: int = 7) -> dict:
|
||||||
|
task_id = uuid4().hex
|
||||||
|
safe_name = Path(filename).name or "domains.txt"
|
||||||
|
target = import_root() / f"{task_id}_{safe_name}"
|
||||||
|
target.write_bytes(content)
|
||||||
|
|
||||||
|
record = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"filename": safe_name,
|
||||||
|
"stored_path": str(target),
|
||||||
|
"source_type": source_type,
|
||||||
|
"status": "queued",
|
||||||
|
"message": "文件已接收,等待处理",
|
||||||
|
"created_at": _now(),
|
||||||
|
"updated_at": _now(),
|
||||||
|
"started_at": "",
|
||||||
|
"completed_at": "",
|
||||||
|
"result": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
with _IMPORT_TASK_LOCK:
|
||||||
|
tasks = load_import_records()
|
||||||
|
tasks.insert(0, record)
|
||||||
|
_save_tasks(tasks)
|
||||||
|
|
||||||
|
worker = threading.Thread(target=_run_import_task, args=(task_id, str(target), source_type), daemon=True)
|
||||||
|
worker.start()
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def retry_import_task(task_id: str) -> dict:
|
||||||
|
with _IMPORT_TASK_LOCK:
|
||||||
|
tasks = load_import_records()
|
||||||
|
target = next((item for item in tasks if item["task_id"] == task_id), None)
|
||||||
|
if not target:
|
||||||
|
raise ValueError("导入任务不存在")
|
||||||
|
if target.get("status") == "running":
|
||||||
|
raise ValueError("导入任务正在运行,不能重复执行")
|
||||||
|
target["status"] = "queued"
|
||||||
|
target["message"] = "任务已重新加入队列"
|
||||||
|
target["started_at"] = ""
|
||||||
|
target["completed_at"] = ""
|
||||||
|
target["updated_at"] = _now()
|
||||||
|
target["result"] = None
|
||||||
|
_save_tasks(tasks)
|
||||||
|
stored_path = target["stored_path"]
|
||||||
|
source_type = int(target.get("source_type", 7))
|
||||||
|
record = dict(target)
|
||||||
|
|
||||||
|
worker = threading.Thread(target=_run_import_task, args=(task_id, stored_path, source_type), daemon=True)
|
||||||
|
worker.start()
|
||||||
|
return record
|
||||||
103
domain-api/app/services/import_worker_service.py
Normal file
103
domain-api/app/services/import_worker_service.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.files import import_root
|
||||||
|
|
||||||
|
|
||||||
|
DOMAIN_PATTERN = re.compile(r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(com|net)$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_domain(value: str) -> str | None:
|
||||||
|
candidate = value.strip().lower()
|
||||||
|
candidate = re.sub(r"^https?://", "", candidate)
|
||||||
|
candidate = candidate.split("/")[0].strip(".")
|
||||||
|
if candidate.startswith("www."):
|
||||||
|
candidate = candidate[4:]
|
||||||
|
if not DOMAIN_PATTERN.match(candidate):
|
||||||
|
return None
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def import_domains_from_path(file_path: Path, source_type: int = 7) -> dict:
|
||||||
|
raw_lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
total = 0
|
||||||
|
normalized_rows: list[tuple[str, str]] = []
|
||||||
|
invalid = 0
|
||||||
|
|
||||||
|
for line in raw_lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
total += 1
|
||||||
|
normalized = normalize_domain(line)
|
||||||
|
if not normalized:
|
||||||
|
invalid += 1
|
||||||
|
continue
|
||||||
|
tld = normalized.rsplit(".", 1)[-1]
|
||||||
|
normalized_rows.append((normalized, tld))
|
||||||
|
|
||||||
|
domains = [row[0] for row in normalized_rows]
|
||||||
|
existing_set: set[str] = set()
|
||||||
|
inserted = 0
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if domains:
|
||||||
|
cur.execute("select domain from domains where domain = any(%s)", (domains,))
|
||||||
|
existing_set = {row[0] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
for domain, tld in normalized_rows:
|
||||||
|
if domain in existing_set:
|
||||||
|
continue
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
insert into domains (
|
||||||
|
domain, tld, source_type, use_status, detect_status, register_status,
|
||||||
|
has_beian, company_type, website_url, beian_year, snapshot_years,
|
||||||
|
expire_date, create_time, update_time, review_status, detect_time,
|
||||||
|
backlink_count, jucha_status, juziseo_status
|
||||||
|
) values (
|
||||||
|
%s, %s, %s, 0, 0, 0,
|
||||||
|
1, null, null, null, null,
|
||||||
|
null, now(), now(), 0, null,
|
||||||
|
0, 0, 0
|
||||||
|
)
|
||||||
|
returning id
|
||||||
|
""",
|
||||||
|
(domain, tld, source_type),
|
||||||
|
)
|
||||||
|
domain_id = cur.fetchone()[0]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
insert into detect_tasks (domain_id, task_type, status, priority, retry_count, create_time, update_time)
|
||||||
|
values (%s, 1, 1, 5, 0, now(), now())
|
||||||
|
""",
|
||||||
|
(domain_id,),
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
exists = len(existing_set)
|
||||||
|
valid = len(normalized_rows)
|
||||||
|
stats = {
|
||||||
|
"total": total,
|
||||||
|
"valid": valid,
|
||||||
|
"added": inserted,
|
||||||
|
"exists": exists,
|
||||||
|
"invalid": invalid,
|
||||||
|
"failed": max(valid - exists - inserted, 0),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"filename": file_path.name,
|
||||||
|
"stats": stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def import_domains_from_upload(content: bytes, filename: str, source_type: int = 7) -> dict:
|
||||||
|
safe_name = Path(filename).name or "domains.txt"
|
||||||
|
target = import_root() / safe_name
|
||||||
|
target.write_bytes(content)
|
||||||
|
return import_domains_from_path(target, source_type=source_type)
|
||||||
25
domain-api/app/services/imports_service.py
Normal file
25
domain-api/app/services/imports_service.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.files import load_import_records
|
||||||
|
|
||||||
|
|
||||||
|
def get_import_summary() -> dict:
|
||||||
|
with get_db() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("select count(*) from domains")
|
||||||
|
domains_total = cur.fetchone()[0]
|
||||||
|
cur.execute("select count(*) from detect_tasks")
|
||||||
|
tasks_total = cur.fetchone()[0]
|
||||||
|
cur.execute("select max(create_time) from domains")
|
||||||
|
last_import_time = cur.fetchone()[0]
|
||||||
|
tasks = load_import_records()
|
||||||
|
running_tasks = sum(1 for item in tasks if item.get("status") in {"queued", "running"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"domains_total": domains_total,
|
||||||
|
"detect_tasks_total": tasks_total,
|
||||||
|
"last_import_time": last_import_time.isoformat() if last_import_time else None,
|
||||||
|
"import_task_total": len(tasks),
|
||||||
|
"running_import_tasks": running_tasks,
|
||||||
|
}
|
||||||
86
domain-api/app/services/logs_service.py
Normal file
86
domain-api/app/services/logs_service.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from zipfile import ZIP_DEFLATED, ZipFile
|
||||||
|
|
||||||
|
from app.core.files import runtime_root, tail_lines
|
||||||
|
from app.services.detect_service import get_detect_status
|
||||||
|
from app.services.runtime_status_service import get_runtime_status
|
||||||
|
from app.services.settings_service import get_settings_payload
|
||||||
|
|
||||||
|
|
||||||
|
def _tail_api_runtime_log(filename: str, max_lines: int = 80) -> list[str]:
|
||||||
|
path = Path(__file__).resolve().parents[2] / "runtime" / "logs" / filename
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||||
|
return handle.read().splitlines()[-max_lines:]
|
||||||
|
|
||||||
|
|
||||||
|
def latest_logs() -> dict:
|
||||||
|
worker_lines = tail_lines("detect_worker.log", max_lines=80)
|
||||||
|
desktop_lines = tail_lines("logs/app.log", max_lines=60)
|
||||||
|
api_stdout_lines = _tail_api_runtime_log("domain-api.stdout.log", max_lines=60)
|
||||||
|
api_stderr_lines = _tail_api_runtime_log("domain-api.stderr.log", max_lines=60)
|
||||||
|
api_lines = api_stderr_lines + api_stdout_lines + desktop_lines
|
||||||
|
|
||||||
|
summary = "未发现显著异常"
|
||||||
|
level = "info"
|
||||||
|
if any("Redis订阅失败" in line for line in worker_lines):
|
||||||
|
summary = "检测端存在 Redis 订阅读超时重连,需要后续继续优化订阅策略。"
|
||||||
|
level = "warning"
|
||||||
|
elif any("无可用代理" in line for line in worker_lines):
|
||||||
|
summary = "代理池存在无可用代理情况,检测端当前可能回落直连或等待代理。"
|
||||||
|
level = "warning"
|
||||||
|
elif any("Traceback" in line or "ERROR:" in line for line in api_lines):
|
||||||
|
summary = "API 运行日志中发现异常堆栈,请优先检查 domain-api stderr 日志。"
|
||||||
|
level = "warning"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"worker": worker_lines,
|
||||||
|
"api": api_lines,
|
||||||
|
"diagnostics": {
|
||||||
|
"summary": summary,
|
||||||
|
"level": level,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_diagnostic_bundle() -> tuple[Path, str]:
|
||||||
|
payload = latest_logs()
|
||||||
|
generated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
bundle_dir = runtime_root() / "diagnostics"
|
||||||
|
bundle_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
zip_path = bundle_dir / f"diagnostic_bundle_{timestamp}.zip"
|
||||||
|
|
||||||
|
settings_payload = get_settings_payload()
|
||||||
|
detect_status = get_detect_status()
|
||||||
|
runtime_status = get_runtime_status()
|
||||||
|
|
||||||
|
with ZipFile(zip_path, "w", compression=ZIP_DEFLATED) as archive:
|
||||||
|
archive.writestr(
|
||||||
|
"summary.json",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"generated_at": generated_at,
|
||||||
|
"diagnostics": payload["diagnostics"],
|
||||||
|
"runtime_status": runtime_status,
|
||||||
|
"detect_status": detect_status,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
default=str,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
archive.writestr(
|
||||||
|
"settings_snapshot.json",
|
||||||
|
json.dumps(settings_payload, ensure_ascii=False, indent=2, default=str),
|
||||||
|
)
|
||||||
|
archive.writestr("logs/worker.log", "\n".join(payload["worker"]))
|
||||||
|
archive.writestr("logs/api.log", "\n".join(payload["api"]))
|
||||||
|
|
||||||
|
return zip_path, zip_path.name
|
||||||
65
domain-api/app/services/runtime_control_service.py
Normal file
65
domain-api/app/services/runtime_control_service.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.runtime_settings_service import get_runtime_settings
|
||||||
|
from app.services.worker_control_service import start_worker, stop_worker
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace_root() -> Path:
|
||||||
|
return Path(settings.domain_root).parent
|
||||||
|
|
||||||
|
|
||||||
|
def _run_shell(command: list[str], timeout: int = 30) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(command, capture_output=True, text=True, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def restart_api() -> tuple[bool, str]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
api_service_name = runtime.get("api_service_name", settings.api_service_name)
|
||||||
|
worker_mode = runtime.get("worker_mode", settings.worker_mode)
|
||||||
|
|
||||||
|
if worker_mode == "linux-systemd":
|
||||||
|
result = _run_shell(["systemctl", "restart", api_service_name], timeout=30)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False, (result.stderr or result.stdout or "重启 Linux API 失败").strip()
|
||||||
|
return True, f"Linux API 重启命令已发送: {api_service_name}"
|
||||||
|
|
||||||
|
if os.name != "nt":
|
||||||
|
return False, "当前仅实现 Windows 本地 API 重启,Linux 请将 worker_mode 设为 linux-systemd。"
|
||||||
|
|
||||||
|
workspace = _workspace_root()
|
||||||
|
stop_script = workspace / "stop_domain_api.ps1"
|
||||||
|
start_script = workspace / "start_domain_api.ps1"
|
||||||
|
if not stop_script.exists() or not start_script.exists():
|
||||||
|
return False, "未找到 API 启停脚本"
|
||||||
|
|
||||||
|
command = (
|
||||||
|
"Start-Process powershell "
|
||||||
|
"-ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', "
|
||||||
|
f"\"Start-Sleep -Seconds 2; & '{stop_script}'; Start-Sleep -Seconds 1; & '{start_script}'\""
|
||||||
|
)
|
||||||
|
result = _run_shell(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], timeout=20)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False, (result.stderr or result.stdout or "重启 API 失败").strip()
|
||||||
|
return True, "API 重启命令已发送"
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_action(action: str) -> tuple[bool, str, dict]:
|
||||||
|
if action == "start_worker":
|
||||||
|
ok, message = start_worker()
|
||||||
|
return ok, message, {"action": action, "poll_after_seconds": 2, "refresh_runtime": True}
|
||||||
|
if action == "stop_worker":
|
||||||
|
ok, message = stop_worker()
|
||||||
|
return ok, message, {"action": action, "poll_after_seconds": 2, "refresh_runtime": True}
|
||||||
|
if action == "restart_api":
|
||||||
|
ok, message = restart_api()
|
||||||
|
return ok, message, {"action": action, "poll_after_seconds": 4, "refresh_runtime": True}
|
||||||
|
return False, f"不支持的运行时动作: {action}", {
|
||||||
|
"action": action,
|
||||||
|
"poll_after_seconds": 0,
|
||||||
|
"refresh_runtime": False,
|
||||||
|
}
|
||||||
28
domain-api/app/services/runtime_settings_service.py
Normal file
28
domain-api/app/services/runtime_settings_service.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.files import read_runtime_json, write_runtime_json
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_RUNTIME_SETTINGS = {
|
||||||
|
"worker_mode": settings.worker_mode,
|
||||||
|
"worker_service_name": settings.worker_service_name,
|
||||||
|
"api_service_name": settings.api_service_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_runtime_settings() -> dict:
|
||||||
|
stored = read_runtime_json("runtime_settings.json", default={})
|
||||||
|
result = dict(DEFAULT_RUNTIME_SETTINGS)
|
||||||
|
result.update(stored or {})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def update_runtime_settings(payload: dict) -> dict:
|
||||||
|
current = get_runtime_settings()
|
||||||
|
merged = dict(current)
|
||||||
|
for key in DEFAULT_RUNTIME_SETTINGS:
|
||||||
|
if key in payload and payload[key] is not None:
|
||||||
|
merged[key] = payload[key]
|
||||||
|
write_runtime_json("runtime_settings.json", merged)
|
||||||
|
return merged
|
||||||
113
domain-api/app/services/runtime_status_service.py
Normal file
113
domain-api/app/services/runtime_status_service.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.redis_client import get_redis
|
||||||
|
from app.services.runtime_settings_service import get_runtime_settings
|
||||||
|
from app.services.worker_control_service import detect_worker_runtime
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_log_path(filename: str) -> str:
|
||||||
|
path = Path(__file__).resolve().parents[2] / "runtime" / "logs" / filename
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_runtime_status() -> dict:
|
||||||
|
runtime_settings = get_runtime_settings()
|
||||||
|
worker_runtime = detect_worker_runtime()
|
||||||
|
api_pid = os.getpid()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"api": {
|
||||||
|
"service": "domain-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"api_prefix": settings.api_prefix,
|
||||||
|
"pid": api_pid,
|
||||||
|
"host": settings.api_host,
|
||||||
|
"port": settings.api_port,
|
||||||
|
"mode": runtime_settings.get("worker_mode", "windows-local"),
|
||||||
|
"service_name": runtime_settings.get("api_service_name", settings.api_service_name),
|
||||||
|
"health_url": f"http://127.0.0.1:{settings.api_port}/health",
|
||||||
|
"stdout_log": _runtime_log_path("domain-api.stdout.log"),
|
||||||
|
"stderr_log": _runtime_log_path("domain-api.stderr.log"),
|
||||||
|
},
|
||||||
|
"worker": {
|
||||||
|
"mode": worker_runtime.get("mode", runtime_settings.get("worker_mode", "windows-local")),
|
||||||
|
"service_name": runtime_settings.get("worker_service_name", settings.worker_service_name),
|
||||||
|
"running": worker_runtime.get("running", False),
|
||||||
|
"process_count": worker_runtime.get("process_count", 0),
|
||||||
|
"latest_start_time": worker_runtime.get("latest_start_time", ""),
|
||||||
|
"message": worker_runtime.get("message", ""),
|
||||||
|
"log_path": str(Path(settings.domain_root) / "detect_worker.log"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_runtime_preflight() -> dict:
|
||||||
|
runtime_settings = get_runtime_settings()
|
||||||
|
checks: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
domain_root = Path(settings.domain_root)
|
||||||
|
checks.append(
|
||||||
|
{
|
||||||
|
"key": "domain_root",
|
||||||
|
"label": "domainCheck 目录",
|
||||||
|
"ok": domain_root.exists(),
|
||||||
|
"message": str(domain_root),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("select 1")
|
||||||
|
cur.fetchone()
|
||||||
|
checks.append({"key": "database", "label": "PostgreSQL", "ok": True, "message": f"{settings.db_host}:{settings.db_port}/{settings.db_database}"})
|
||||||
|
except Exception as exc:
|
||||||
|
checks.append({"key": "database", "label": "PostgreSQL", "ok": False, "message": str(exc)})
|
||||||
|
|
||||||
|
try:
|
||||||
|
redis_client = get_redis()
|
||||||
|
redis_client.ping()
|
||||||
|
checks.append({"key": "redis", "label": "Redis", "ok": True, "message": f"{settings.redis_host}:{settings.redis_port}/{settings.redis_db}"})
|
||||||
|
except Exception as exc:
|
||||||
|
checks.append({"key": "redis", "label": "Redis", "ok": False, "message": str(exc)})
|
||||||
|
|
||||||
|
worker_mode = runtime_settings.get("worker_mode", "windows-local")
|
||||||
|
checks.append({"key": "worker_mode", "label": "运行模式", "ok": True, "message": worker_mode})
|
||||||
|
|
||||||
|
if worker_mode == "linux-systemd":
|
||||||
|
checks.append(
|
||||||
|
{
|
||||||
|
"key": "worker_service_name",
|
||||||
|
"label": "Worker service 名",
|
||||||
|
"ok": bool(runtime_settings.get("worker_service_name")),
|
||||||
|
"message": runtime_settings.get("worker_service_name", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
checks.append(
|
||||||
|
{
|
||||||
|
"key": "api_service_name",
|
||||||
|
"label": "API service 名",
|
||||||
|
"ok": bool(runtime_settings.get("api_service_name")),
|
||||||
|
"message": runtime_settings.get("api_service_name", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks.append(
|
||||||
|
{
|
||||||
|
"key": "windows_scripts",
|
||||||
|
"label": "Windows 启停脚本",
|
||||||
|
"ok": (Path(settings.domain_root).parent / "start_domain_api.ps1").exists() and (Path(settings.domain_root).parent / "stop_domain_api.ps1").exists(),
|
||||||
|
"message": "start_domain_api.ps1 / stop_domain_api.ps1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
overall_ok = all(bool(item["ok"]) for item in checks)
|
||||||
|
return {
|
||||||
|
"ok": overall_ok,
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
172
domain-api/app/services/settings_service.py
Normal file
172
domain-api/app/services/settings_service.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.core.files import read_json, settings_backup_root, write_json
|
||||||
|
from app.core.redis_client import get_redis
|
||||||
|
from app.services.runtime_settings_service import get_runtime_settings, update_runtime_settings
|
||||||
|
|
||||||
|
|
||||||
|
REDIS_KEYS = {
|
||||||
|
"detect_options": "domain_tool:detect_options",
|
||||||
|
"proxy_config": "domain_tool:proxy_config",
|
||||||
|
"thread_count": "domain_tool:thread_count",
|
||||||
|
}
|
||||||
|
|
||||||
|
DETECT_OPTION_KEYS = {
|
||||||
|
"detect_register",
|
||||||
|
"detect_wayback",
|
||||||
|
"detect_chinaz",
|
||||||
|
"detect_aizhan",
|
||||||
|
"detect_baidu_site",
|
||||||
|
"detect_360_site",
|
||||||
|
"detect_jucha",
|
||||||
|
"detect_juziseo",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings_payload() -> dict:
|
||||||
|
detect_options = read_json("detect_options.json", default={})
|
||||||
|
proxy_config = read_json("proxy_config.json", default={})
|
||||||
|
thread_count = read_json("thread_count.json", default={"thread_count": "2"})
|
||||||
|
|
||||||
|
redis_client = get_redis()
|
||||||
|
try:
|
||||||
|
if redis_detect_options := redis_client.get(REDIS_KEYS["detect_options"]):
|
||||||
|
detect_options = json.loads(redis_detect_options)
|
||||||
|
if redis_proxy_config := redis_client.get(REDIS_KEYS["proxy_config"]):
|
||||||
|
proxy_config = json.loads(redis_proxy_config)
|
||||||
|
if redis_thread_count := redis_client.get(REDIS_KEYS["thread_count"]):
|
||||||
|
thread_count = {"thread_count": str(redis_thread_count)}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"detect_options": detect_options,
|
||||||
|
"proxy_config": proxy_config,
|
||||||
|
"thread_count": int(thread_count.get("thread_count", 2)),
|
||||||
|
"runtime_settings": get_runtime_settings(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_settings_payload(payload: dict) -> dict:
|
||||||
|
current = get_settings_payload()
|
||||||
|
detect_options = payload.get("detect_options", current["detect_options"])
|
||||||
|
proxy_config = payload.get("proxy_config", current["proxy_config"])
|
||||||
|
thread_count = int(payload.get("thread_count", current["thread_count"]))
|
||||||
|
runtime_settings = update_runtime_settings(payload.get("runtime_settings", current["runtime_settings"]))
|
||||||
|
|
||||||
|
write_json("detect_options.json", detect_options)
|
||||||
|
write_json("proxy_config.json", proxy_config)
|
||||||
|
write_json("thread_count.json", {"thread_count": str(thread_count)})
|
||||||
|
|
||||||
|
redis_client = get_redis()
|
||||||
|
try:
|
||||||
|
redis_client.set(REDIS_KEYS["detect_options"], json.dumps(detect_options, ensure_ascii=False))
|
||||||
|
redis_client.publish("domain_tool:detect_options:update", json.dumps(detect_options, ensure_ascii=False))
|
||||||
|
redis_client.set(REDIS_KEYS["proxy_config"], json.dumps(proxy_config, ensure_ascii=False))
|
||||||
|
redis_client.publish("domain_tool:proxy_config:update", json.dumps(proxy_config, ensure_ascii=False))
|
||||||
|
redis_client.set(REDIS_KEYS["thread_count"], thread_count)
|
||||||
|
redis_client.publish("domain_tool:thread_count:update", str(thread_count))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"detect_options": detect_options,
|
||||||
|
"proxy_config": proxy_config,
|
||||||
|
"thread_count": thread_count,
|
||||||
|
"runtime_settings": runtime_settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def export_settings_snapshot() -> dict:
|
||||||
|
return {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"exported_at": datetime.now().isoformat(),
|
||||||
|
"source": "domain-api",
|
||||||
|
"settings": get_settings_payload(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def import_settings_snapshot(payload: dict) -> dict:
|
||||||
|
settings_payload = payload.get("settings", payload)
|
||||||
|
validate_settings_payload(settings_payload)
|
||||||
|
backup_current_settings("import")
|
||||||
|
return update_settings_payload(settings_payload)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_settings_payload(payload: dict) -> None:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("invalid settings payload")
|
||||||
|
|
||||||
|
if "thread_count" in payload:
|
||||||
|
try:
|
||||||
|
thread_count = int(payload["thread_count"])
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError("thread_count must be an integer") from exc
|
||||||
|
if thread_count < 1 or thread_count > 256:
|
||||||
|
raise ValueError("thread_count out of range")
|
||||||
|
|
||||||
|
if "detect_options" in payload:
|
||||||
|
detect_options = payload["detect_options"]
|
||||||
|
if not isinstance(detect_options, dict):
|
||||||
|
raise ValueError("detect_options must be an object")
|
||||||
|
detect_order = detect_options.get("detect_order", [])
|
||||||
|
if detect_order and not isinstance(detect_order, list):
|
||||||
|
raise ValueError("detect_order must be an array")
|
||||||
|
if isinstance(detect_order, list):
|
||||||
|
unknown_keys = [item for item in detect_order if item not in DETECT_OPTION_KEYS]
|
||||||
|
if unknown_keys:
|
||||||
|
raise ValueError(f"unknown detect option keys: {', '.join(unknown_keys)}")
|
||||||
|
|
||||||
|
if "proxy_config" in payload:
|
||||||
|
proxy_config = payload["proxy_config"]
|
||||||
|
if not isinstance(proxy_config, dict):
|
||||||
|
raise ValueError("proxy_config must be an object")
|
||||||
|
proxy_urls = proxy_config.get("proxy_urls", [])
|
||||||
|
if proxy_urls and not isinstance(proxy_urls, list):
|
||||||
|
raise ValueError("proxy_urls must be an array")
|
||||||
|
|
||||||
|
if "runtime_settings" in payload:
|
||||||
|
runtime_settings = payload["runtime_settings"]
|
||||||
|
if not isinstance(runtime_settings, dict):
|
||||||
|
raise ValueError("runtime_settings must be an object")
|
||||||
|
worker_mode = runtime_settings.get("worker_mode")
|
||||||
|
if worker_mode and worker_mode not in {"windows-local", "linux-systemd"}:
|
||||||
|
raise ValueError("worker_mode must be windows-local or linux-systemd")
|
||||||
|
|
||||||
|
|
||||||
|
def backup_current_settings(reason: str = "manual") -> dict:
|
||||||
|
snapshot = export_settings_snapshot()
|
||||||
|
snapshot["backup_reason"] = reason
|
||||||
|
filename = f"settings_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||||
|
path = settings_backup_root() / filename
|
||||||
|
path.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
return {
|
||||||
|
"filename": filename,
|
||||||
|
"path": str(path),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_settings_backups(limit: int = 20) -> list[dict]:
|
||||||
|
root = settings_backup_root()
|
||||||
|
files = sorted(root.glob("settings_backup_*.json"), key=lambda item: item.stat().st_mtime, reverse=True)
|
||||||
|
result: list[dict] = []
|
||||||
|
for item in files[:limit]:
|
||||||
|
backup_reason = ""
|
||||||
|
try:
|
||||||
|
payload = json.loads(item.read_text(encoding="utf-8"))
|
||||||
|
backup_reason = str(payload.get("backup_reason", ""))
|
||||||
|
except Exception:
|
||||||
|
backup_reason = ""
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"filename": item.name,
|
||||||
|
"path": str(item),
|
||||||
|
"size": item.stat().st_size,
|
||||||
|
"modified_at": datetime.fromtimestamp(item.stat().st_mtime).isoformat(),
|
||||||
|
"backup_reason": backup_reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
188
domain-api/app/services/worker_control_service.py
Normal file
188
domain-api/app/services/worker_control_service.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.runtime_settings_service import get_runtime_settings
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_root() -> Path:
|
||||||
|
return Path(settings.domain_root)
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_config() -> dict:
|
||||||
|
return get_runtime_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_powershell(command: str, timeout: int = 20) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_shell(command: list[str], timeout: int = 20) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(command, capture_output=True, text=True, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_runtime() -> dict:
|
||||||
|
command = """
|
||||||
|
$targets = Get-CimInstance Win32_Process -Filter "name='python.exe'" |
|
||||||
|
Where-Object { $_.CommandLine -like '*detect_worker.py*' } |
|
||||||
|
Select-Object ProcessId, CommandLine
|
||||||
|
if (-not $targets) {
|
||||||
|
Write-Output '{"running":false,"process_count":0,"latest_start_time":"","mode":"windows-local"}'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
$latest = $null
|
||||||
|
foreach ($item in $targets) {
|
||||||
|
try {
|
||||||
|
$proc = Get-Process -Id $item.ProcessId -ErrorAction Stop
|
||||||
|
if (-not $latest -or $proc.StartTime -gt $latest.StartTime) {
|
||||||
|
$latest = $proc
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
$payload = @{
|
||||||
|
running = $true
|
||||||
|
process_count = @($targets).Count
|
||||||
|
latest_start_time = if ($latest) { $latest.StartTime.ToString('yyyy-MM-dd HH:mm:ss') } else { '' }
|
||||||
|
mode = 'windows-local'
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
Write-Output $payload
|
||||||
|
"""
|
||||||
|
result = _run_powershell(command)
|
||||||
|
output = (result.stdout or "").strip()
|
||||||
|
if result.returncode != 0 or not output:
|
||||||
|
return {
|
||||||
|
"mode": "windows-local",
|
||||||
|
"running": False,
|
||||||
|
"process_count": 0,
|
||||||
|
"latest_start_time": "",
|
||||||
|
"message": (result.stderr or result.stdout or "worker runtime probe failed").strip(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
payload = json.loads(output)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {
|
||||||
|
"mode": "windows-local",
|
||||||
|
"running": False,
|
||||||
|
"process_count": 0,
|
||||||
|
"latest_start_time": "",
|
||||||
|
"message": output,
|
||||||
|
}
|
||||||
|
payload.setdefault("message", "")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _linux_runtime() -> dict:
|
||||||
|
runtime = _runtime_config()
|
||||||
|
service_name = runtime["worker_service_name"]
|
||||||
|
result = _run_shell(["systemctl", "show", service_name, "--no-page", "--property=ActiveState,SubState,MainPID"])
|
||||||
|
output = (result.stdout or result.stderr or "").strip()
|
||||||
|
if result.returncode != 0:
|
||||||
|
return {
|
||||||
|
"mode": "linux-systemd",
|
||||||
|
"running": False,
|
||||||
|
"process_count": 0,
|
||||||
|
"latest_start_time": "",
|
||||||
|
"message": output or f"systemd service {service_name} not available",
|
||||||
|
}
|
||||||
|
|
||||||
|
data: dict[str, str] = {}
|
||||||
|
for line in output.splitlines():
|
||||||
|
if "=" in line:
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
data[key] = value
|
||||||
|
main_pid = int(data.get("MainPID", "0") or 0)
|
||||||
|
active_state = data.get("ActiveState", "")
|
||||||
|
sub_state = data.get("SubState", "")
|
||||||
|
return {
|
||||||
|
"mode": "linux-systemd",
|
||||||
|
"running": active_state == "active",
|
||||||
|
"process_count": 1 if main_pid > 0 else 0,
|
||||||
|
"latest_start_time": "",
|
||||||
|
"message": f"{active_state}/{sub_state}" if active_state else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_worker_runtime() -> dict:
|
||||||
|
runtime = _runtime_config()
|
||||||
|
worker_mode = runtime["worker_mode"]
|
||||||
|
if worker_mode == "linux-systemd":
|
||||||
|
return _linux_runtime()
|
||||||
|
if os.name == "nt":
|
||||||
|
return _windows_runtime()
|
||||||
|
return {
|
||||||
|
"mode": worker_mode,
|
||||||
|
"running": False,
|
||||||
|
"process_count": 0,
|
||||||
|
"latest_start_time": "",
|
||||||
|
"message": f"unsupported worker_mode: {worker_mode}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def start_worker() -> tuple[bool, str]:
|
||||||
|
runtime = _runtime_config()
|
||||||
|
worker_mode = runtime["worker_mode"]
|
||||||
|
service_name = runtime["worker_service_name"]
|
||||||
|
if worker_mode == "linux-systemd":
|
||||||
|
result = _run_shell(["systemctl", "start", service_name], timeout=30)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False, (result.stderr or result.stdout or "启动 Linux Worker 失败").strip()
|
||||||
|
return True, f"Linux Worker 启动命令已发送: {service_name}"
|
||||||
|
|
||||||
|
if os.name != "nt":
|
||||||
|
return False, "当前仅实现 Windows 本地 Worker 启动,Linux 请将 worker_mode 设为 linux-systemd。"
|
||||||
|
|
||||||
|
script_path = _domain_root() / "start_worker.ps1"
|
||||||
|
if not script_path.exists():
|
||||||
|
return False, f"未找到启动脚本: {script_path}"
|
||||||
|
|
||||||
|
command = (
|
||||||
|
"Start-Process powershell "
|
||||||
|
f"-ArgumentList '-ExecutionPolicy Bypass -File \"{script_path}\"' "
|
||||||
|
f"-WorkingDirectory '{_domain_root()}'"
|
||||||
|
)
|
||||||
|
result = _run_powershell(command)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False, (result.stderr or result.stdout or "启动检测端失败").strip()
|
||||||
|
return True, "检测端启动命令已发送"
|
||||||
|
|
||||||
|
|
||||||
|
def stop_worker() -> tuple[bool, str]:
|
||||||
|
runtime = _runtime_config()
|
||||||
|
worker_mode = runtime["worker_mode"]
|
||||||
|
service_name = runtime["worker_service_name"]
|
||||||
|
if worker_mode == "linux-systemd":
|
||||||
|
result = _run_shell(["systemctl", "stop", service_name], timeout=30)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False, (result.stderr or result.stdout or "停止 Linux Worker 失败").strip()
|
||||||
|
return True, f"Linux Worker 停止命令已发送: {service_name}"
|
||||||
|
|
||||||
|
if os.name != "nt":
|
||||||
|
return False, "当前仅实现 Windows 本地 Worker 停止,Linux 请将 worker_mode 设为 linux-systemd。"
|
||||||
|
|
||||||
|
command = """
|
||||||
|
$targets = Get-CimInstance Win32_Process -Filter "name='python.exe'" |
|
||||||
|
Where-Object { $_.CommandLine -like '*detect_worker.py*' } |
|
||||||
|
Select-Object -ExpandProperty ProcessId
|
||||||
|
if (-not $targets) {
|
||||||
|
Write-Output 'NO_PROCESS'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
$targets | ForEach-Object { Stop-Process -Id $_ -Force }
|
||||||
|
Write-Output ('STOPPED:' + (($targets | Measure-Object).Count))
|
||||||
|
"""
|
||||||
|
result = _run_powershell(command)
|
||||||
|
output = (result.stdout or result.stderr or "").strip()
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False, output or "停止检测端失败"
|
||||||
|
if "NO_PROCESS" in output:
|
||||||
|
return True, "当前没有运行中的检测端进程"
|
||||||
|
return True, output or "检测端已停止"
|
||||||
179
domain-api/deploy/linux/README.md
Normal file
179
domain-api/deploy/linux/README.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# domainCheck Linux 部署说明
|
||||||
|
|
||||||
|
本文档用于将 `domain-api` 与 `domainCheck Worker` 部署到 Linux,并由 `systemd` 托管。
|
||||||
|
|
||||||
|
## 一、建议目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/domaincheck
|
||||||
|
├── domain-api
|
||||||
|
├── domain-web
|
||||||
|
└── domainCheck
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `domain-api` 提供 Web 后台接口
|
||||||
|
- `domain-web` 为前端静态文件项目
|
||||||
|
- `domainCheck` 保留当前检测核心与 Worker
|
||||||
|
|
||||||
|
## 二、准备 Python 环境
|
||||||
|
|
||||||
|
建议使用 Python 3.11。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/domaincheck/domainCheck
|
||||||
|
python3.11 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
cd /opt/domaincheck/domain-api
|
||||||
|
pip install fastapi uvicorn pydantic-settings psycopg2-binary redis openpyxl python-multipart
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、准备配置文件
|
||||||
|
|
||||||
|
### 1. domainCheck/.env
|
||||||
|
|
||||||
|
`domain-api` 默认会读取 `/opt/domaincheck/domainCheck/.env`。
|
||||||
|
|
||||||
|
至少确认下面这些配置正确:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_DATABASE=domain
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 可选的 domain-api 环境变量
|
||||||
|
|
||||||
|
可以参考 `.env.example`。
|
||||||
|
|
||||||
|
常用项:
|
||||||
|
|
||||||
|
- `API_HOST`
|
||||||
|
- `API_PORT`
|
||||||
|
- `WORKER_MODE`
|
||||||
|
- `WORKER_SERVICE_NAME`
|
||||||
|
- `API_SERVICE_NAME`
|
||||||
|
- `ADMIN_USERNAME`
|
||||||
|
- `ADMIN_PASSWORD`
|
||||||
|
|
||||||
|
## 四、部署 systemd
|
||||||
|
|
||||||
|
模板文件:
|
||||||
|
|
||||||
|
- `deploy/systemd/domain-api.service`
|
||||||
|
- `deploy/systemd/domain-worker.service`
|
||||||
|
|
||||||
|
复制到系统目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /opt/domaincheck/domain-api/deploy/systemd/domain-api.service /etc/systemd/system/domaincheck-api.service
|
||||||
|
sudo cp /opt/domaincheck/domain-api/deploy/systemd/domain-worker.service /etc/systemd/system/domaincheck-worker.service
|
||||||
|
```
|
||||||
|
|
||||||
|
然后按实际机器修改:
|
||||||
|
|
||||||
|
- `WorkingDirectory`
|
||||||
|
- `ExecStart`
|
||||||
|
- `User`
|
||||||
|
- `Group`
|
||||||
|
- `Environment`
|
||||||
|
|
||||||
|
## 五、启动顺序
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable domaincheck-api
|
||||||
|
sudo systemctl enable domaincheck-worker
|
||||||
|
sudo systemctl start domaincheck-api
|
||||||
|
sudo systemctl start domaincheck-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
查看状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status domaincheck-api
|
||||||
|
sudo systemctl status domaincheck-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
查看日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u domaincheck-api -n 200 --no-pager
|
||||||
|
journalctl -u domaincheck-worker -n 200 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
## 六、联调检查
|
||||||
|
|
||||||
|
### 1. API 健康检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8100/health
|
||||||
|
```
|
||||||
|
|
||||||
|
期望看到:
|
||||||
|
|
||||||
|
- `status=ok`
|
||||||
|
- `worker_mode=linux-systemd`
|
||||||
|
|
||||||
|
### 2. Web 后台系统设置
|
||||||
|
|
||||||
|
在 Web 后台里确认:
|
||||||
|
|
||||||
|
- `Worker 运行模式 = linux-systemd`
|
||||||
|
- `Worker 服务名 = domaincheck-worker`
|
||||||
|
- `API 服务名 = domaincheck-api`
|
||||||
|
|
||||||
|
### 3. 运行中心
|
||||||
|
|
||||||
|
进入 `运行中心`,确认:
|
||||||
|
|
||||||
|
- API 在线
|
||||||
|
- Worker 在线
|
||||||
|
- 进程数大于 0
|
||||||
|
- 最近启动时间正常
|
||||||
|
|
||||||
|
### 4. 运行 API 自测脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/domaincheck/domain-api
|
||||||
|
python deploy/linux/smoke_test.py --base-url http://127.0.0.1:8100
|
||||||
|
```
|
||||||
|
|
||||||
|
如果同时希望把 Web 首页一起纳入检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python deploy/linux/smoke_test.py --base-url http://127.0.0.1:8100 --web-url http://127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 七、上线建议
|
||||||
|
|
||||||
|
- 先保持 `Windows 桌面版 + Web/Linux` 并行一段时间
|
||||||
|
- 先让 Web 后台接管设置、导入、筛选、导出、日志
|
||||||
|
- 再让 Linux Worker 接管主检测任务
|
||||||
|
- 确认稳定后,再逐步淡出桌面检测端
|
||||||
|
|
||||||
|
## 八、联调诊断采集
|
||||||
|
|
||||||
|
如需导出一份联调诊断包,可执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/domaincheck/domain-api/deploy/linux
|
||||||
|
bash collect_diagnostics.sh /opt/domaincheck
|
||||||
|
```
|
||||||
|
|
||||||
|
会输出:
|
||||||
|
|
||||||
|
- `diagnostics_dir=...`
|
||||||
|
- `diagnostics_archive=...`
|
||||||
|
|
||||||
|
把生成的归档包发回即可继续排障。
|
||||||
60
domain-api/deploy/linux/collect_diagnostics.sh
Normal file
60
domain-api/deploy/linux/collect_diagnostics.sh
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_DIR="${1:-/opt/domaincheck}"
|
||||||
|
OUT_DIR="${2:-$BASE_DIR/diagnostics}"
|
||||||
|
STAMP="$(date +%Y%m%d_%H%M%S)"
|
||||||
|
BUNDLE_DIR="$OUT_DIR/diag_$STAMP"
|
||||||
|
|
||||||
|
mkdir -p "$BUNDLE_DIR"
|
||||||
|
|
||||||
|
write_cmd() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
{
|
||||||
|
echo "# command: $*"
|
||||||
|
echo
|
||||||
|
"$@"
|
||||||
|
} > "$BUNDLE_DIR/$name.txt" 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_if_exists() {
|
||||||
|
local src="$1"
|
||||||
|
local dest="$2"
|
||||||
|
if [ -f "$src" ]; then
|
||||||
|
cp "$src" "$dest"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_cmd "systemctl_api" systemctl status domaincheck-api --no-pager
|
||||||
|
write_cmd "systemctl_worker" systemctl status domaincheck-worker --no-pager
|
||||||
|
write_cmd "journal_api" journalctl -u domaincheck-api -n 200 --no-pager
|
||||||
|
write_cmd "journal_worker" journalctl -u domaincheck-worker -n 200 --no-pager
|
||||||
|
write_cmd "api_health_curl" curl -sS http://127.0.0.1:8100/health
|
||||||
|
write_cmd "api_preflight_curl" curl -sS http://127.0.0.1:8100/api/v1/runtime/preflight
|
||||||
|
write_cmd "api_runtime_curl" curl -sS http://127.0.0.1:8100/api/v1/runtime/status
|
||||||
|
write_cmd "ps_processes" ps -ef
|
||||||
|
write_cmd "ss_listen" ss -lntp
|
||||||
|
write_cmd "df_h" df -h
|
||||||
|
write_cmd "free_h" free -h
|
||||||
|
|
||||||
|
copy_if_exists "$BASE_DIR/domainCheck/.env" "$BUNDLE_DIR/domainCheck.env"
|
||||||
|
copy_if_exists "$BASE_DIR/domain-api/.env" "$BUNDLE_DIR/domain-api.env"
|
||||||
|
|
||||||
|
if [ -d "$BASE_DIR/domain-api/runtime" ]; then
|
||||||
|
cp -r "$BASE_DIR/domain-api/runtime" "$BUNDLE_DIR/domain-api-runtime"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$BASE_DIR/domainCheck/logs/app.log" ]; then
|
||||||
|
tail -n 300 "$BASE_DIR/domainCheck/logs/app.log" > "$BUNDLE_DIR/app_tail.log" 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$BASE_DIR/domainCheck/detect_worker.log" ]; then
|
||||||
|
tail -n 500 "$BASE_DIR/domainCheck/detect_worker.log" > "$BUNDLE_DIR/detect_worker_tail.log" 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARCHIVE="$OUT_DIR/diag_$STAMP.tar.gz"
|
||||||
|
tar -czf "$ARCHIVE" -C "$OUT_DIR" "diag_$STAMP"
|
||||||
|
|
||||||
|
echo "diagnostics_dir=$BUNDLE_DIR"
|
||||||
|
echo "diagnostics_archive=$ARCHIVE"
|
||||||
103
domain-api/deploy/linux/smoke_test.py
Normal file
103
domain-api/deploy/linux/smoke_test.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_json(session: requests.Session, url: str, timeout: int = 15) -> tuple[bool, str, dict | None]:
|
||||||
|
try:
|
||||||
|
response = session.get(url, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
return True, f"{response.status_code}", response.json()
|
||||||
|
except Exception as exc:
|
||||||
|
return False, str(exc), None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_text(session: requests.Session, url: str, timeout: int = 15) -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
response = session.get(url, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
return True, f"{response.status_code}"
|
||||||
|
except Exception as exc:
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="domainCheck Web/API smoke test")
|
||||||
|
parser.add_argument("--base-url", default="http://127.0.0.1:8100", help="domain-api base URL")
|
||||||
|
parser.add_argument("--web-url", default="", help="optional domain-web URL, e.g. http://127.0.0.1:3200")
|
||||||
|
parser.add_argument("--output", default="", help="optional JSON report output path")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
base_url = args.base_url.rstrip("/")
|
||||||
|
session = requests.Session()
|
||||||
|
|
||||||
|
checks: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
endpoints = [
|
||||||
|
("health", f"{base_url}/health"),
|
||||||
|
("runtime_status", f"{base_url}/api/v1/runtime/status"),
|
||||||
|
("runtime_preflight", f"{base_url}/api/v1/runtime/preflight"),
|
||||||
|
("dashboard_overview", f"{base_url}/api/v1/dashboard/overview"),
|
||||||
|
("settings_export", f"{base_url}/api/v1/settings/export"),
|
||||||
|
("settings_backups", f"{base_url}/api/v1/settings/backups"),
|
||||||
|
("detect_status", f"{base_url}/api/v1/detect/status"),
|
||||||
|
("imports_summary", f"{base_url}/api/v1/imports/summary"),
|
||||||
|
("exports", f"{base_url}/api/v1/exports"),
|
||||||
|
("logs_latest", f"{base_url}/api/v1/logs/latest"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, url in endpoints:
|
||||||
|
ok, message, payload = _fetch_json(session, url)
|
||||||
|
checks.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"url": url,
|
||||||
|
"ok": ok,
|
||||||
|
"message": message,
|
||||||
|
"payload_excerpt": payload if ok and name in {"health", "runtime_preflight", "settings_export"} else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.web_url:
|
||||||
|
web_url = args.web_url.rstrip("/")
|
||||||
|
ok, message = _fetch_text(session, web_url)
|
||||||
|
checks.append(
|
||||||
|
{
|
||||||
|
"name": "web_home",
|
||||||
|
"url": web_url,
|
||||||
|
"ok": ok,
|
||||||
|
"message": message,
|
||||||
|
"payload_excerpt": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
passed = all(bool(item["ok"]) for item in checks)
|
||||||
|
report = {
|
||||||
|
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"base_url": base_url,
|
||||||
|
"web_url": args.web_url.rstrip("/") if args.web_url else "",
|
||||||
|
"ok": passed,
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
with open(args.output, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(report, handle, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||||
|
return 0 if passed else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
18
domain-api/deploy/systemd/domain-api.service
Normal file
18
domain-api/deploy/systemd/domain-api.service
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=domainCheck API
|
||||||
|
After=network.target redis.service postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/domaincheck/domain-api
|
||||||
|
Environment="WORKER_MODE=linux-systemd"
|
||||||
|
Environment="API_HOST=0.0.0.0"
|
||||||
|
Environment="API_PORT=8100"
|
||||||
|
ExecStart=/opt/domaincheck/domainCheck/.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8100
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
16
domain-api/deploy/systemd/domain-worker.service
Normal file
16
domain-api/deploy/systemd/domain-worker.service
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=domainCheck Worker
|
||||||
|
After=network.target redis.service postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/domaincheck/domainCheck
|
||||||
|
Environment="WORKER_MODE=linux-systemd"
|
||||||
|
ExecStart=/opt/domaincheck/domainCheck/.venv/bin/python /opt/domaincheck/domainCheck/detect_worker.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
8
domain-api/requirements.txt
Normal file
8
domain-api/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn==0.30.6
|
||||||
|
pydantic==2.9.2
|
||||||
|
pydantic-settings==2.5.2
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
redis==5.0.1
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
python-multipart==0.0.9
|
||||||
2
domain-web/.env.example
Normal file
2
domain-web/.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_APP_TITLE=domainCheck 管理后台
|
||||||
|
VITE_API_BASE_URL=http://127.0.0.1:8100/api/v1
|
||||||
2
domain-web/.env.production.example
Normal file
2
domain-web/.env.production.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_APP_TITLE=domainCheck 管理后台
|
||||||
|
VITE_API_BASE_URL=https://your-domain.example.com/api/v1
|
||||||
60
domain-web/README.md
Normal file
60
domain-web/README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# domain-web
|
||||||
|
|
||||||
|
`domainCheck` 轻量 Web 管理后台前端。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd domain-web
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
默认开发地址:
|
||||||
|
|
||||||
|
- 前端:`http://127.0.0.1:3200`
|
||||||
|
- 后端:`http://127.0.0.1:8100`
|
||||||
|
|
||||||
|
也可以直接在工作区根目录执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\start_domain_web.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 当前能力
|
||||||
|
|
||||||
|
- 登录
|
||||||
|
- 运行状态头部
|
||||||
|
- 概览
|
||||||
|
- 运行中心
|
||||||
|
- 系统设置
|
||||||
|
- 域名导入
|
||||||
|
- 检测控制
|
||||||
|
- 域名筛选
|
||||||
|
- 批量更新
|
||||||
|
- 导出中心
|
||||||
|
- 日志诊断
|
||||||
|
- 诊断包下载
|
||||||
|
|
||||||
|
## 生产环境构建
|
||||||
|
|
||||||
|
参考:
|
||||||
|
|
||||||
|
- `.env.production.example`
|
||||||
|
- `deploy/nginx/domain-web.conf`
|
||||||
|
- `deploy/linux/publish.sh`
|
||||||
|
|
||||||
|
推荐流程:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/domaincheck/domain-web
|
||||||
|
cp .env.production.example .env.production
|
||||||
|
# 修改 VITE_API_BASE_URL
|
||||||
|
bash deploy/linux/publish.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
构建完成后:
|
||||||
|
|
||||||
|
- 前端静态文件位于 `dist`
|
||||||
|
- 可通过 Nginx 对外提供访问
|
||||||
|
- `/api/` 路径建议反代到 `domain-api`
|
||||||
24
domain-web/deploy/linux/publish.sh
Normal file
24
domain-web/deploy/linux/publish.sh
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_ROOT="/opt/domaincheck/domain-web"
|
||||||
|
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
if [ ! -f ".env.production" ]; then
|
||||||
|
echo "缺少 .env.production,请先参考 .env.production.example 创建。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v npm >/dev/null 2>&1; then
|
||||||
|
echo "未找到 npm,请先安装 Node.js。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "安装依赖..."
|
||||||
|
npm install
|
||||||
|
|
||||||
|
echo "构建生产包..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "构建完成,dist 目录位于: $PROJECT_ROOT/dist"
|
||||||
20
domain-web/deploy/nginx/domain-web.conf
Normal file
20
domain-web/deploy/nginx/domain-web.conf
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.example.com;
|
||||||
|
|
||||||
|
root /opt/domaincheck/domain-web/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8100/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
domain-web/index.html
Normal file
12
domain-web/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>domainCheck 管理后台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2194
domain-web/package-lock.json
generated
Normal file
2194
domain-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
domain-web/package.json
Normal file
24
domain-web/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "domain-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"element-plus": "^2.8.4",
|
||||||
|
"pinia": "^2.2.4",
|
||||||
|
"vue": "^3.5.12",
|
||||||
|
"vue-router": "^4.4.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.1.4",
|
||||||
|
"sass": "^1.79.4",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
domain-web/src/App.vue
Normal file
3
domain-web/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
25
domain-web/src/api/http.ts
Normal file
25
domain-web/src/api/http.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const http = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL || "http://127.0.0.1:8100/api/v1",
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
http.interceptors.request.use((config) => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
if (authStore.token) {
|
||||||
|
config.headers.Authorization = `Bearer ${authStore.token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
http.interceptors.response.use((response) => {
|
||||||
|
const payload = response.data;
|
||||||
|
if (payload && typeof payload.code !== "undefined" && payload.code !== 0) {
|
||||||
|
return Promise.reject(payload);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default http;
|
||||||
61
domain-web/src/api/modules.ts
Normal file
61
domain-web/src/api/modules.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import http from "./http";
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: (payload: { username: string; password: string }) => http.post("/auth/login", payload)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dashboardApi = {
|
||||||
|
overview: () => http.get("/dashboard/overview")
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runtimeApi = {
|
||||||
|
status: () => http.get("/runtime/status"),
|
||||||
|
preflight: () => http.get("/runtime/preflight"),
|
||||||
|
action: (action: string) => http.post(`/runtime/actions/${action}`)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const settingsApi = {
|
||||||
|
getSettings: () => http.get("/settings"),
|
||||||
|
updateSettings: (payload: Record<string, unknown>) => http.put("/settings", payload),
|
||||||
|
exportSettings: () => http.get("/settings/export"),
|
||||||
|
importSettings: (payload: Record<string, unknown>) => http.post("/settings/import", payload),
|
||||||
|
validateImportSettings: (payload: Record<string, unknown>) => http.post("/settings/validate-import", payload),
|
||||||
|
backupSettings: () => http.post("/settings/backup"),
|
||||||
|
getSettingsBackups: () => http.get("/settings/backups"),
|
||||||
|
backupDownloadUrl: (filename: string) =>
|
||||||
|
`${(import.meta.env.VITE_API_BASE_URL || "http://127.0.0.1:8100/api/v1").replace(/\/api\/v1\/?$/, "")}/api/v1/settings/backups/download/${encodeURIComponent(filename)}`
|
||||||
|
};
|
||||||
|
|
||||||
|
export const importsApi = {
|
||||||
|
summary: () => http.get("/imports/summary"),
|
||||||
|
tasks: () => http.get("/imports/tasks"),
|
||||||
|
retry: (taskId: string) => http.post(`/imports/tasks/${taskId}/retry`),
|
||||||
|
upload: (formData: FormData) =>
|
||||||
|
http.post("/imports/upload", formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
export const detectApi = {
|
||||||
|
status: () => http.get("/detect/status"),
|
||||||
|
start: () => http.post("/detect/start"),
|
||||||
|
stop: () => http.post("/detect/stop")
|
||||||
|
};
|
||||||
|
|
||||||
|
export const domainsApi = {
|
||||||
|
filters: () => http.get("/domains/filters"),
|
||||||
|
list: (params?: Record<string, unknown>) => http.get("/domains", { params }),
|
||||||
|
batchUpdate: (payload: Record<string, unknown>) => http.post("/domains/batch-update", payload)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exportsApi = {
|
||||||
|
list: () => http.get("/exports"),
|
||||||
|
run: (payload: Record<string, unknown>) => http.post("/exports/run", payload)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logsApi = {
|
||||||
|
latest: () => http.get("/logs/latest"),
|
||||||
|
bundleUrl: () => `${(import.meta.env.VITE_API_BASE_URL || "http://127.0.0.1:8100/api/v1").replace(/\/api\/v1\/?$/, "")}/api/v1/logs/bundle`
|
||||||
|
};
|
||||||
49
domain-web/src/components/PageCard.vue
Normal file
49
domain-web/src/components/PageCard.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<section class="page-card">
|
||||||
|
<header v-if="title || description" class="page-card__header">
|
||||||
|
<div>
|
||||||
|
<h3 v-if="title">{{ title }}</h3>
|
||||||
|
<p v-if="description">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
<slot name="header-extra" />
|
||||||
|
</header>
|
||||||
|
<div class="page-card__content">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.page-card {
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.05);
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-card__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-card__header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-card__header p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
259
domain-web/src/layouts/MainLayout.vue
Normal file
259
domain-web/src/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<strong>domainCheck</strong>
|
||||||
|
<span>轻量管理后台</span>
|
||||||
|
</div>
|
||||||
|
<nav class="menu">
|
||||||
|
<RouterLink v-for="item in menuItems" :key="item.path" :to="item.path" class="menu-item">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="main-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<p>当前保持桌面版不变,Web 管理后台进入可持续运维阶段。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<div class="status-pills">
|
||||||
|
<span class="status-pill" :class="runtime.apiOnline ? 'online' : 'offline'">
|
||||||
|
API {{ runtime.apiOnline ? "在线" : "离线" }}
|
||||||
|
</span>
|
||||||
|
<span class="status-pill" :class="runtime.workerOnline ? 'online' : 'offline'">
|
||||||
|
Worker {{ runtime.workerOnline ? "在线" : "离线" }}
|
||||||
|
</span>
|
||||||
|
<span class="status-pill neutral">{{ runtime.workerMode }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="user">{{ authStore.user.display_name }}</span>
|
||||||
|
<el-button type="primary" plain @click="logout">退出</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { runtimeApi } from "@/api/modules";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const runtime = ref({
|
||||||
|
apiOnline: false,
|
||||||
|
workerOnline: false,
|
||||||
|
workerMode: "windows-local"
|
||||||
|
});
|
||||||
|
let timer: number | null = null;
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ path: "/dashboard", label: "概览" },
|
||||||
|
{ path: "/runtime", label: "运行中心" },
|
||||||
|
{ path: "/settings", label: "系统设置" },
|
||||||
|
{ path: "/imports", label: "域名导入" },
|
||||||
|
{ path: "/detect", label: "检测控制" },
|
||||||
|
{ path: "/domains", label: "域名筛选" },
|
||||||
|
{ path: "/exports", label: "导出中心" },
|
||||||
|
{ path: "/logs", label: "日志诊断" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const title = computed(() => String(route.meta?.title || "概览"));
|
||||||
|
|
||||||
|
const loadRuntimeSummary = async () => {
|
||||||
|
try {
|
||||||
|
const response = await runtimeApi.status();
|
||||||
|
runtime.value = {
|
||||||
|
apiOnline: Boolean(response.data?.api?.pid),
|
||||||
|
workerOnline: Boolean(response.data?.worker?.running),
|
||||||
|
workerMode: response.data?.worker?.mode || "windows-local"
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
runtime.value = {
|
||||||
|
apiOnline: false,
|
||||||
|
workerOnline: false,
|
||||||
|
workerMode: "unknown"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPolling = () => {
|
||||||
|
if (timer) {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
}
|
||||||
|
timer = window.setInterval(() => {
|
||||||
|
loadRuntimeSummary();
|
||||||
|
}, 10000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
authStore.clearToken();
|
||||||
|
router.push("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadRuntimeSummary();
|
||||||
|
startPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer) {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.layout-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: linear-gradient(180deg, #0f172a, #172554);
|
||||||
|
color: #e5eefc;
|
||||||
|
padding: 24px 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand span {
|
||||||
|
color: rgba(229, 238, 252, 0.78);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: rgba(229, 238, 252, 0.86);
|
||||||
|
transition: 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover,
|
||||||
|
.menu-item.router-link-active {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 24px 28px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pills {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.online {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.offline {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.neutral {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
color: #334155;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 0 28px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.layout-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
13
domain-web/src/main.ts
Normal file
13
domain-web/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import ElementPlus from "element-plus";
|
||||||
|
import "element-plus/dist/index.css";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import router from "./router";
|
||||||
|
import "./styles/main.scss";
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(createPinia());
|
||||||
|
app.use(router);
|
||||||
|
app.use(ElementPlus);
|
||||||
|
app.mount("#app");
|
||||||
47
domain-web/src/router/index.ts
Normal file
47
domain-web/src/router/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/login",
|
||||||
|
name: "login",
|
||||||
|
component: () => import("@/views/auth/LoginView.vue"),
|
||||||
|
meta: { public: true, title: "登录" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
component: () => import("@/layouts/MainLayout.vue"),
|
||||||
|
children: [
|
||||||
|
{ path: "", redirect: "/dashboard" },
|
||||||
|
{ path: "dashboard", name: "dashboard", component: () => import("@/views/dashboard/DashboardView.vue"), meta: { title: "概览" } },
|
||||||
|
{ path: "runtime", name: "runtime", component: () => import("@/views/runtime/RuntimeView.vue"), meta: { title: "运行中心" } },
|
||||||
|
{ path: "settings", name: "settings", component: () => import("@/views/settings/SettingsView.vue"), meta: { title: "系统设置" } },
|
||||||
|
{ path: "imports", name: "imports", component: () => import("@/views/imports/ImportsView.vue"), meta: { title: "域名导入" } },
|
||||||
|
{ path: "detect", name: "detect", component: () => import("@/views/detect/DetectView.vue"), meta: { title: "检测控制" } },
|
||||||
|
{ path: "domains", name: "domains", component: () => import("@/views/domains/DomainsView.vue"), meta: { title: "域名筛选" } },
|
||||||
|
{ path: "exports", name: "exports", component: () => import("@/views/exports/ExportsView.vue"), meta: { title: "导出中心" } },
|
||||||
|
{ path: "logs", name: "logs", component: () => import("@/views/logs/LogsView.vue"), meta: { title: "日志诊断" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes,
|
||||||
|
scrollBehavior: () => ({ top: 0, left: 0 })
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const title = import.meta.env.VITE_APP_TITLE || "domainCheck 管理后台";
|
||||||
|
document.title = to.meta?.title ? `${String(to.meta.title)} - ${title}` : title;
|
||||||
|
if (to.meta?.public) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!authStore.token) {
|
||||||
|
return "/login";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
44
domain-web/src/stores/auth.ts
Normal file
44
domain-web/src/stores/auth.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
interface UserInfo {
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultUser = (): UserInfo => ({
|
||||||
|
username: "admin",
|
||||||
|
display_name: "管理员"
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadStoredUser = (): UserInfo => {
|
||||||
|
const raw = localStorage.getItem("domain_web_user");
|
||||||
|
if (!raw) return defaultUser();
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as UserInfo;
|
||||||
|
} catch {
|
||||||
|
return defaultUser();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore("domain-auth", {
|
||||||
|
state: () => ({
|
||||||
|
token: localStorage.getItem("domain_web_token") || "",
|
||||||
|
user: loadStoredUser() as UserInfo
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
setToken(token: string) {
|
||||||
|
this.token = token;
|
||||||
|
localStorage.setItem("domain_web_token", token);
|
||||||
|
},
|
||||||
|
clearToken() {
|
||||||
|
this.token = "";
|
||||||
|
localStorage.removeItem("domain_web_token");
|
||||||
|
this.user = defaultUser();
|
||||||
|
localStorage.removeItem("domain_web_user");
|
||||||
|
},
|
||||||
|
setUser(user: UserInfo) {
|
||||||
|
this.user = user;
|
||||||
|
localStorage.setItem("domain_web_user", JSON.stringify(user));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
29
domain-web/src/styles/main.scss
Normal file
29
domain-web/src/styles/main.scss
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background: #f4f6fb;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(37, 99, 235, 0.08), transparent 28%),
|
||||||
|
radial-gradient(circle at top right, rgba(14, 165, 233, 0.08), transparent 22%),
|
||||||
|
#f4f6fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
109
domain-web/src/views/auth/LoginView.vue
Normal file
109
domain-web/src/views/auth/LoginView.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-shell">
|
||||||
|
<div class="login-panel">
|
||||||
|
<div class="hero">
|
||||||
|
<span class="eyebrow">domainCheck</span>
|
||||||
|
<h1>轻量 Web 管理后台</h1>
|
||||||
|
<p>这一版先承接系统设置、导入、检测控制、筛选、导出和日志诊断。</p>
|
||||||
|
</div>
|
||||||
|
<PageCard title="登录" description="当前为第一期可用版本,默认账号密码均为 admin。">
|
||||||
|
<el-form :model="form" @submit.prevent="submit">
|
||||||
|
<el-form-item label="账号">
|
||||||
|
<el-input v-model="form.username" placeholder="请输入账号" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码">
|
||||||
|
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" class="submit" @click="submit">进入后台</el-button>
|
||||||
|
</el-form>
|
||||||
|
</PageCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import PageCard from "@/components/PageCard.vue";
|
||||||
|
import { authApi } from "@/api/modules";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: "admin",
|
||||||
|
password: "admin"
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await authApi.login(form);
|
||||||
|
authStore.setToken(data.access_token);
|
||||||
|
authStore.setUser(data.user);
|
||||||
|
ElMessage.success("登录成功");
|
||||||
|
router.push("/dashboard");
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.message || "登录失败,请确认账号密码和 API 服务状态。");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.login-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
width: min(920px, 100%);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.15fr 0.95fr;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(145deg, #1d4ed8, #0f172a);
|
||||||
|
color: white;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 36px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
width: fit-content;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 34px;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
color: rgba(255, 255, 255, 0.86);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.login-panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
160
domain-web/src/views/dashboard/DashboardView.vue
Normal file
160
domain-web/src/views/dashboard/DashboardView.vue
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<div class="stack">
|
||||||
|
<div class="dashboard-toolbar">
|
||||||
|
<el-button plain :loading="loading" @click="loadOverview">刷新概览</el-button>
|
||||||
|
<el-switch v-model="autoRefresh" inline-prompt active-text="自动刷新" inactive-text="手动" />
|
||||||
|
<span class="updated-at">最近刷新:{{ lastUpdatedAt || "暂无" }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="loadError"
|
||||||
|
title="读取概览失败,已保留上一次成功结果。"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<PageCard v-for="item in stats" :key="item.label">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<strong>{{ item.value }}</strong>
|
||||||
|
<small>{{ item.note }}</small>
|
||||||
|
</div>
|
||||||
|
</PageCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PageCard title="第一期目标" description="先把运营最需要的控制面搬到 Web,再逐步把检测执行和运维能力完全迁到 Linux。">
|
||||||
|
<el-timeline>
|
||||||
|
<el-timeline-item timestamp="当前阶段" placement="top">已创建 Web 后台、API、运行中心和导入/导出/筛选主链路。</el-timeline-item>
|
||||||
|
<el-timeline-item timestamp="下一步" placement="top">继续补 Linux Worker 控制闭环和更完整的运行诊断。</el-timeline-item>
|
||||||
|
<el-timeline-item timestamp="后续阶段" placement="top">把检测端逐步迁为 Linux Worker,前后端彻底分离。</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
</PageCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
|
import PageCard from "@/components/PageCard.vue";
|
||||||
|
import { dashboardApi } from "@/api/modules";
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadError = ref(false);
|
||||||
|
const autoRefresh = ref(true);
|
||||||
|
const lastUpdatedAt = ref("");
|
||||||
|
let timer: number | null = null;
|
||||||
|
|
||||||
|
const stats = ref([
|
||||||
|
{ label: "总域名", value: "--", note: "数据库域名总量" },
|
||||||
|
{ label: "待检测", value: "--", note: "当前待处理域名" },
|
||||||
|
{ label: "黑名单", value: "--", note: "命中风险域名" },
|
||||||
|
{ label: "API", value: "离线", note: "当前 API 运行状态" },
|
||||||
|
{ label: "Worker", value: "离线", note: "当前 Worker 运行状态" },
|
||||||
|
{ label: "模式", value: "--", note: "当前 Worker 运行模式" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const clearRefreshTimer = () => {
|
||||||
|
if (timer) {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureRefreshTimer = () => {
|
||||||
|
clearRefreshTimer();
|
||||||
|
if (autoRefresh.value) {
|
||||||
|
timer = window.setInterval(() => {
|
||||||
|
loadOverview(false);
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNow = () =>
|
||||||
|
new Date().toLocaleString("zh-CN", {
|
||||||
|
hour12: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadOverview = async (showError = true) => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await dashboardApi.overview();
|
||||||
|
stats.value = [
|
||||||
|
{ label: "总域名", value: String(data.domains_total), note: "数据库域名总量" },
|
||||||
|
{ label: "待检测", value: String(data.pending_total), note: "当前待处理域名" },
|
||||||
|
{ label: "黑名单", value: String(data.blacklist_total), note: "命中风险域名" },
|
||||||
|
{ label: "API", value: data.api_status, note: "当前 API 运行状态" },
|
||||||
|
{ label: "Worker", value: data.worker_status, note: "当前 Worker 运行状态" },
|
||||||
|
{ label: "模式", value: data.worker_mode, note: "当前 Worker 运行模式" }
|
||||||
|
];
|
||||||
|
loadError.value = false;
|
||||||
|
lastUpdatedAt.value = formatNow();
|
||||||
|
} catch {
|
||||||
|
loadError.value = true;
|
||||||
|
if (showError) {
|
||||||
|
lastUpdatedAt.value = lastUpdatedAt.value || "读取失败";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(autoRefresh, ensureRefreshTimer);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadOverview();
|
||||||
|
ensureRefreshTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(clearRefreshTimer);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updated-at {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item span {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item strong {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item small {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
183
domain-web/src/views/detect/DetectView.vue
Normal file
183
domain-web/src/views/detect/DetectView.vue
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<template>
|
||||||
|
<PageCard title="检测控制" description="当前已接入真实状态读取和本地 Worker 启停,后续 Linux 版会沿用同一页接 systemd。">
|
||||||
|
<div class="actions">
|
||||||
|
<el-button type="primary" :loading="actionLoading === 'start'" @click="invoke('start')">启动检测</el-button>
|
||||||
|
<el-button :loading="actionLoading === 'stop'" @click="invoke('stop')">停止检测</el-button>
|
||||||
|
<el-button plain :loading="loading" @click="loadStatus">刷新状态</el-button>
|
||||||
|
<el-switch v-model="autoRefresh" inline-prompt active-text="自动刷新" inactive-text="手动" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="lastAction.message"
|
||||||
|
:title="`${lastAction.label}:${lastAction.message}`"
|
||||||
|
:type="lastAction.type"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="action-alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="Worker 在线">{{ status.worker_online ? "是" : "否" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="运行模式">{{ status.worker_mode || "windows-local" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="Worker 服务">{{ status.worker_service_name || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="API 服务">{{ status.api_service_name || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="检测进程数">{{ status.worker_process_count }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="线程数">{{ status.thread_count }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="代理启用">{{ status.proxy_enable ? "是" : "否" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="允许直连">{{ status.allow_direct ? "是" : "否" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="代理池数量">{{ status.proxy_pool_count }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="可用代理数">{{ status.available_proxy_count }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最近 Worker 日志时间">{{ status.last_worker_log_time || "暂无" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最近启动时间">{{ status.worker_latest_start_time || "暂无" }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="status.recent_warning"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
:title="status.recent_warning"
|
||||||
|
style="margin-top: 16px"
|
||||||
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-if="status.worker_runtime_message"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
:title="status.worker_runtime_message"
|
||||||
|
style="margin-top: 12px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-row :gutter="12" style="margin-top: 16px">
|
||||||
|
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="待检测" :value="status.progress.pending || 0" /></el-col>
|
||||||
|
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="检测中" :value="status.progress.running || 0" /></el-col>
|
||||||
|
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="检测完成" :value="status.progress.completed || 0" /></el-col>
|
||||||
|
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="黑名单" :value="status.progress.blacklisted || 0" /></el-col>
|
||||||
|
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="检测失败" :value="status.progress.failed || 0" /></el-col>
|
||||||
|
</el-row>
|
||||||
|
</PageCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import PageCard from "@/components/PageCard.vue";
|
||||||
|
import { detectApi } from "@/api/modules";
|
||||||
|
|
||||||
|
const status = ref({
|
||||||
|
worker_online: false,
|
||||||
|
worker_mode: "windows-local",
|
||||||
|
worker_service_name: "",
|
||||||
|
api_service_name: "",
|
||||||
|
worker_process_count: 0,
|
||||||
|
worker_latest_start_time: "",
|
||||||
|
worker_runtime_message: "",
|
||||||
|
thread_count: 0,
|
||||||
|
proxy_enable: false,
|
||||||
|
allow_direct: false,
|
||||||
|
proxy_pool_count: 0,
|
||||||
|
available_proxy_count: 0,
|
||||||
|
last_worker_log_time: "",
|
||||||
|
recent_warning: "",
|
||||||
|
progress: {
|
||||||
|
pending: 0,
|
||||||
|
running: 0,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0,
|
||||||
|
blacklisted: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const autoRefresh = ref(true);
|
||||||
|
const loading = ref(false);
|
||||||
|
const actionLoading = ref<"" | "start" | "stop">("");
|
||||||
|
const lastAction = ref({
|
||||||
|
label: "",
|
||||||
|
message: "",
|
||||||
|
type: "success" as "success" | "warning" | "info" | "error"
|
||||||
|
});
|
||||||
|
let timer: number | null = null;
|
||||||
|
|
||||||
|
const clearRefreshTimer = () => {
|
||||||
|
if (timer) {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureRefreshTimer = () => {
|
||||||
|
clearRefreshTimer();
|
||||||
|
if (autoRefresh.value) {
|
||||||
|
timer = window.setInterval(() => {
|
||||||
|
loadStatus(false);
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadStatus = async (showError = true) => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await detectApi.status();
|
||||||
|
status.value = response.data;
|
||||||
|
} catch {
|
||||||
|
if (showError) {
|
||||||
|
ElMessage.error("读取检测状态失败");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const invoke = async (action: "start" | "stop") => {
|
||||||
|
actionLoading.value = action;
|
||||||
|
try {
|
||||||
|
const response = action === "start" ? await detectApi.start() : await detectApi.stop();
|
||||||
|
ElMessage.success(response.message);
|
||||||
|
lastAction.value = {
|
||||||
|
label: action === "start" ? "检测启动" : "检测停止",
|
||||||
|
message: response.message,
|
||||||
|
type: "success"
|
||||||
|
};
|
||||||
|
const pollDelaySeconds = Number(response.data?.poll_after_seconds || 2);
|
||||||
|
if (response.data?.refresh_status) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
loadStatus(false);
|
||||||
|
}, pollDelaySeconds * 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadStatus(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error?.message || `${action === "start" ? "启动" : "停止"}操作失败`;
|
||||||
|
lastAction.value = {
|
||||||
|
label: action === "start" ? "检测启动" : "检测停止",
|
||||||
|
message,
|
||||||
|
type: "error"
|
||||||
|
};
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(autoRefresh, ensureRefreshTimer);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadStatus();
|
||||||
|
ensureRefreshTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(clearRefreshTimer);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
309
domain-web/src/views/domains/DomainsView.vue
Normal file
309
domain-web/src/views/domains/DomainsView.vue
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
<template>
|
||||||
|
<PageCard title="域名筛选" description="已接入真实分页、联表筛选、检测时间展示和批量更新。">
|
||||||
|
<el-form label-width="88px" class="toolbar-grid">
|
||||||
|
<el-form-item label="域名关键字">
|
||||||
|
<el-input v-model="filters.domain_keyword" placeholder="支持模糊查询" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="注册状态">
|
||||||
|
<el-select v-model="filters.register_status" clearable>
|
||||||
|
<el-option v-for="item in registerOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="检测状态">
|
||||||
|
<el-select v-model="filters.detect_status" clearable>
|
||||||
|
<el-option v-for="item in detectOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="使用状态">
|
||||||
|
<el-select v-model="filters.use_status" clearable>
|
||||||
|
<el-option v-for="item in useOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="复核状态">
|
||||||
|
<el-select v-model="filters.review_status" clearable>
|
||||||
|
<el-option v-for="item in reviewOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备案状态">
|
||||||
|
<el-select v-model="filters.has_beian" clearable>
|
||||||
|
<el-option label="有备案" :value="2" />
|
||||||
|
<el-option label="无备案" :value="3" />
|
||||||
|
<el-option label="未检测" :value="1" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备案年份">
|
||||||
|
<el-input v-model="filters.beian_year" placeholder="如 2023" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="快照年份">
|
||||||
|
<el-input v-model="filters.snapshot_year" placeholder="如 2024" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="首页网址">
|
||||||
|
<el-input v-model="filters.website_url" placeholder="模糊匹配网址" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="友链 > 10">
|
||||||
|
<el-switch v-model="filters.backlink_gt_10" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="actions">
|
||||||
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
|
<el-button @click="reset">重置</el-button>
|
||||||
|
<el-button type="warning" :disabled="selectedIds.length === 0" @click="batchDialogVisible = true">
|
||||||
|
批量更新选中域名
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="rows" border @selection-change="handleSelectionChange">
|
||||||
|
<el-table-column type="selection" width="48" fixed="left" />
|
||||||
|
<el-table-column prop="domain" label="域名" min-width="180" fixed="left" />
|
||||||
|
<el-table-column prop="register_status" label="注册状态" min-width="100" />
|
||||||
|
<el-table-column prop="use_status" label="使用状态" min-width="100" />
|
||||||
|
<el-table-column prop="detect_status" label="检测状态" min-width="100" />
|
||||||
|
<el-table-column prop="review_status" label="复核状态" min-width="100" />
|
||||||
|
<el-table-column prop="has_beian" label="备案" min-width="80" />
|
||||||
|
<el-table-column prop="website_url" label="首页网址" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="beian_year" label="备案年份" min-width="100" />
|
||||||
|
<el-table-column prop="snapshot_years" label="快照年份" min-width="120" />
|
||||||
|
<el-table-column prop="backlink_count" label="友链数" min-width="90" />
|
||||||
|
<el-table-column prop="backlink_gt_10" label="友链>10" min-width="90">
|
||||||
|
<template #default="{ row }">{{ row.backlink_gt_10 ? "是" : "否" }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="detect_time" label="检测时间" min-width="180" />
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[20, 50, 100, 200]"
|
||||||
|
:total="total"
|
||||||
|
@current-change="changePage"
|
||||||
|
@size-change="changePageSize"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="batchDialogVisible" title="批量更新选中域名" width="720px">
|
||||||
|
<el-form label-width="100px" class="batch-grid">
|
||||||
|
<el-form-item label="复核状态">
|
||||||
|
<el-select v-model="batchForm.review_status" clearable>
|
||||||
|
<el-option v-for="item in reviewOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备案状态">
|
||||||
|
<el-select v-model="batchForm.has_beian" clearable>
|
||||||
|
<el-option label="有备案" :value="2" />
|
||||||
|
<el-option label="无备案" :value="3" />
|
||||||
|
<el-option label="未检测" :value="1" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备案年份">
|
||||||
|
<el-input v-model="batchForm.beian_year" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="快照年份">
|
||||||
|
<el-input v-model="batchForm.snapshot_years" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="单位性质">
|
||||||
|
<el-select v-model="batchForm.company_type" clearable>
|
||||||
|
<el-option label="企业" value="企业" />
|
||||||
|
<el-option label="个人" value="个人" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="首页网址">
|
||||||
|
<el-input v-model="batchForm.website_url" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="检测时间">
|
||||||
|
<el-input v-model="batchForm.detect_time" placeholder="如 2026-04-16 12:30:00" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="友链数">
|
||||||
|
<el-input-number v-model="batchForm.backlink_count" :min="0" :max="1000000" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="closeBatchDialog">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitBatchUpdate">确认更新</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</PageCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import PageCard from "@/components/PageCard.vue";
|
||||||
|
import { domainsApi } from "@/api/modules";
|
||||||
|
|
||||||
|
type Option = { label: string; value: number };
|
||||||
|
|
||||||
|
const rows = ref<any[]>([]);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(20);
|
||||||
|
const total = ref(0);
|
||||||
|
const selectedIds = ref<number[]>([]);
|
||||||
|
const batchDialogVisible = ref(false);
|
||||||
|
|
||||||
|
const registerOptions = ref<Option[]>([]);
|
||||||
|
const detectOptions = ref<Option[]>([]);
|
||||||
|
const useOptions = ref<Option[]>([]);
|
||||||
|
const reviewOptions = ref<Option[]>([]);
|
||||||
|
|
||||||
|
const defaultFilters = () => ({
|
||||||
|
domain_keyword: "",
|
||||||
|
register_status: undefined as number | undefined,
|
||||||
|
detect_status: undefined as number | undefined,
|
||||||
|
use_status: undefined as number | undefined,
|
||||||
|
review_status: undefined as number | undefined,
|
||||||
|
has_beian: undefined as number | undefined,
|
||||||
|
beian_year: "",
|
||||||
|
snapshot_year: "",
|
||||||
|
website_url: "",
|
||||||
|
backlink_gt_10: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultBatchForm = () => ({
|
||||||
|
review_status: undefined as number | undefined,
|
||||||
|
has_beian: undefined as number | undefined,
|
||||||
|
beian_year: "",
|
||||||
|
snapshot_years: "",
|
||||||
|
company_type: "",
|
||||||
|
website_url: "",
|
||||||
|
detect_time: "",
|
||||||
|
backlink_count: undefined as number | undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const filters = ref(defaultFilters());
|
||||||
|
const batchForm = ref(defaultBatchForm());
|
||||||
|
|
||||||
|
const buildParams = () => {
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
domain_keyword: filters.value.domain_keyword || undefined,
|
||||||
|
register_status: filters.value.register_status,
|
||||||
|
detect_status: filters.value.detect_status,
|
||||||
|
use_status: filters.value.use_status,
|
||||||
|
review_status: filters.value.review_status,
|
||||||
|
has_beian: filters.value.has_beian,
|
||||||
|
beian_year: filters.value.beian_year || undefined,
|
||||||
|
snapshot_year: filters.value.snapshot_year || undefined,
|
||||||
|
website_url: filters.value.website_url || undefined
|
||||||
|
};
|
||||||
|
if (filters.value.backlink_gt_10) {
|
||||||
|
params.backlink_gt_10 = true;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFilterOptions = async () => {
|
||||||
|
try {
|
||||||
|
const response = await domainsApi.filters();
|
||||||
|
registerOptions.value = response.data.register_status || [];
|
||||||
|
detectOptions.value = response.data.detect_status || [];
|
||||||
|
useOptions.value = response.data.use_status || [];
|
||||||
|
reviewOptions.value = response.data.review_status || [];
|
||||||
|
} catch {
|
||||||
|
ElMessage.error("读取筛选选项失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDomains = async () => {
|
||||||
|
try {
|
||||||
|
const response = await domainsApi.list(buildParams());
|
||||||
|
rows.value = response.data.list;
|
||||||
|
total.value = response.data.total;
|
||||||
|
selectedIds.value = [];
|
||||||
|
} catch {
|
||||||
|
ElMessage.error("读取域名列表失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectionChange = (selection: any[]) => {
|
||||||
|
selectedIds.value = selection.map((item) => item.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePage = (value: number) => {
|
||||||
|
page.value = value;
|
||||||
|
loadDomains();
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePageSize = (value: number) => {
|
||||||
|
pageSize.value = value;
|
||||||
|
page.value = 1;
|
||||||
|
loadDomains();
|
||||||
|
};
|
||||||
|
|
||||||
|
const search = () => {
|
||||||
|
page.value = 1;
|
||||||
|
loadDomains();
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
filters.value = defaultFilters();
|
||||||
|
page.value = 1;
|
||||||
|
pageSize.value = 20;
|
||||||
|
loadDomains();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeBatchDialog = () => {
|
||||||
|
batchDialogVisible.value = false;
|
||||||
|
batchForm.value = defaultBatchForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitBatchUpdate = async () => {
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
Object.entries(batchForm.value).forEach(([key, value]) => {
|
||||||
|
if (value !== "" && value !== undefined && value !== null) {
|
||||||
|
updates[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
ElMessage.warning("请至少填写一项更新内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await domainsApi.batchUpdate({
|
||||||
|
domain_ids: selectedIds.value,
|
||||||
|
updates
|
||||||
|
});
|
||||||
|
ElMessage.success(response.message);
|
||||||
|
closeBatchDialog();
|
||||||
|
await loadDomains();
|
||||||
|
} catch {
|
||||||
|
ElMessage.error("批量更新失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadFilterOptions();
|
||||||
|
await loadDomains();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-grid :deep(.el-form-item) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
align-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 8px 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
189
domain-web/src/views/imports/ImportsView.vue
Normal file
189
domain-web/src/views/imports/ImportsView.vue
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<PageCard title="域名导入" description="已改成导入任务模式,适合后续大文件上传和 Linux 后端持续处理。">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-upload :show-file-list="false" :auto-upload="false" :on-change="handleChange" accept=".txt">
|
||||||
|
<template #trigger>
|
||||||
|
<el-button type="primary">选择 TXT 文件</el-button>
|
||||||
|
</template>
|
||||||
|
</el-upload>
|
||||||
|
<span class="filename">{{ selectedName || "尚未选择文件" }}</span>
|
||||||
|
<el-button :loading="uploading" :disabled="!selectedFile" @click="submitUpload">创建导入任务</el-button>
|
||||||
|
<el-button plain :loading="loading" @click="loadAll">刷新</el-button>
|
||||||
|
<el-switch v-model="autoRefresh" inline-prompt active-text="自动刷新" inactive-text="手动" />
|
||||||
|
<span class="updated-at">最近刷新:{{ lastUpdatedAt || "暂无" }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="已导入域名总数">{{ summary.domains_total }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="detect_tasks 总数">{{ summary.detect_tasks_total }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="导入任务总数">{{ summary.import_task_total }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="运行中导入任务">{{ summary.running_import_tasks }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item :span="2" label="最近导入时间">{{ summary.last_import_time || "暂无" }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-alert v-if="lastResult" style="margin-top: 16px" type="success" :closable="false" show-icon :title="lastResult" />
|
||||||
|
<el-alert v-if="loadError" style="margin-top: 12px" type="warning" :closable="false" show-icon title="读取导入任务失败,已保留上一次结果。" />
|
||||||
|
|
||||||
|
<el-table :data="tasks" border style="margin-top: 16px">
|
||||||
|
<el-table-column prop="status" label="状态" min-width="100" />
|
||||||
|
<el-table-column prop="filename" label="文件名" min-width="220" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="message" label="任务信息" min-width="260" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="created_at" label="创建时间" min-width="160" />
|
||||||
|
<el-table-column prop="started_at" label="开始时间" min-width="160" />
|
||||||
|
<el-table-column prop="completed_at" label="完成时间" min-width="160" />
|
||||||
|
<el-table-column label="操作" min-width="110" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 'failed' || row.status === 'queued'"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="retryTask(row.task_id)"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</el-button>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="结果" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.result?.stats">
|
||||||
|
总数 {{ row.result.stats.total }} / 新增 {{ row.result.stats.added }} / 已存在 {{ row.result.stats.exists }} / 无效 {{ row.result.stats.invalid }}
|
||||||
|
</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</PageCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import PageCard from "@/components/PageCard.vue";
|
||||||
|
import { importsApi } from "@/api/modules";
|
||||||
|
|
||||||
|
const summary = ref({
|
||||||
|
domains_total: 0,
|
||||||
|
detect_tasks_total: 0,
|
||||||
|
last_import_time: "",
|
||||||
|
import_task_total: 0,
|
||||||
|
running_import_tasks: 0
|
||||||
|
});
|
||||||
|
const tasks = ref<any[]>([]);
|
||||||
|
const selectedFile = ref<File | null>(null);
|
||||||
|
const selectedName = ref("");
|
||||||
|
const lastResult = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const loadError = ref(false);
|
||||||
|
const lastUpdatedAt = ref("");
|
||||||
|
const autoRefresh = ref(true);
|
||||||
|
let timer: number | null = null;
|
||||||
|
|
||||||
|
const clearRefreshTimer = () => {
|
||||||
|
if (timer) {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureRefreshTimer = () => {
|
||||||
|
clearRefreshTimer();
|
||||||
|
if (autoRefresh.value) {
|
||||||
|
timer = window.setInterval(() => {
|
||||||
|
loadAll(false);
|
||||||
|
}, 8000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSummary = async () => {
|
||||||
|
const response = await importsApi.summary();
|
||||||
|
summary.value = response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTasks = async () => {
|
||||||
|
const response = await importsApi.tasks();
|
||||||
|
tasks.value = response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAll = async (showError = true) => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await Promise.all([loadSummary(), loadTasks()]);
|
||||||
|
loadError.value = false;
|
||||||
|
lastUpdatedAt.value = new Date().toLocaleString("zh-CN", { hour12: false });
|
||||||
|
} catch {
|
||||||
|
loadError.value = true;
|
||||||
|
if (showError) {
|
||||||
|
ElMessage.error("读取导入状态失败");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (uploadFile: any) => {
|
||||||
|
selectedFile.value = uploadFile.raw || null;
|
||||||
|
selectedName.value = uploadFile.name || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitUpload = async () => {
|
||||||
|
if (!selectedFile.value) {
|
||||||
|
ElMessage.warning("请先选择 TXT 文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", selectedFile.value);
|
||||||
|
uploading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await importsApi.upload(formData);
|
||||||
|
lastResult.value = `导入任务已创建:${response.data.filename},状态 ${response.data.status}`;
|
||||||
|
ElMessage.success("导入任务已创建");
|
||||||
|
selectedFile.value = null;
|
||||||
|
selectedName.value = "";
|
||||||
|
await loadAll(false);
|
||||||
|
} catch {
|
||||||
|
ElMessage.error("创建导入任务失败");
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryTask = async (taskId: string) => {
|
||||||
|
try {
|
||||||
|
await importsApi.retry(taskId);
|
||||||
|
ElMessage.success("导入任务已重新加入队列");
|
||||||
|
await loadAll(false);
|
||||||
|
} catch {
|
||||||
|
ElMessage.error("重试导入任务失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(autoRefresh, ensureRefreshTimer);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadAll();
|
||||||
|
ensureRefreshTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(clearRefreshTimer);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filename {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updated-at {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
399
domain-web/src/views/settings/SettingsView.vue
Normal file
399
domain-web/src/views/settings/SettingsView.vue
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
<template>
|
||||||
|
<PageCard title="系统设置" description="保存后会同步写入本地 JSON、Redis,以及 Web 后台自己的运行配置。">
|
||||||
|
<el-skeleton :loading="loading" animated>
|
||||||
|
<template #template>
|
||||||
|
<el-skeleton-item variant="rect" style="width: 100%; height: 480px" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
<PageCard title="运行配置">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="检测线程数">
|
||||||
|
<el-input-number v-model="threadCount" :min="1" :max="64" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Worker 运行模式">
|
||||||
|
<el-select v-model="runtimeSettings.worker_mode">
|
||||||
|
<el-option label="Windows 本地" value="windows-local" />
|
||||||
|
<el-option label="Linux systemd" value="linux-systemd" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Worker 服务名">
|
||||||
|
<el-input v-model="runtimeSettings.worker_service_name" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="API 服务名">
|
||||||
|
<el-input v-model="runtimeSettings.api_service_name" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</PageCard>
|
||||||
|
|
||||||
|
<PageCard title="代理配置">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="启用代理">
|
||||||
|
<el-switch v-model="proxyConfig.proxy_enable" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="允许直连兜底">
|
||||||
|
<el-switch v-model="proxyConfig.allow_direct" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="首选代理池链接">
|
||||||
|
<el-input v-model="proxyConfig.proxy_url" placeholder="优先使用的代理池链接" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="代理池列表">
|
||||||
|
<el-input
|
||||||
|
v-model="proxyUrlsText"
|
||||||
|
type="textarea"
|
||||||
|
:rows="8"
|
||||||
|
placeholder="每行一个代理池链接"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</PageCard>
|
||||||
|
|
||||||
|
<PageCard title="检测选项" class="span-2">
|
||||||
|
<div class="detect-items">
|
||||||
|
<div v-for="(item, index) in detectItems" :key="item.key" class="detect-item">
|
||||||
|
<div class="detect-main">
|
||||||
|
<span class="order">{{ index + 1 }}</span>
|
||||||
|
<el-switch v-model="item.enabled" />
|
||||||
|
<span class="label">{{ item.label }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detect-actions">
|
||||||
|
<el-button size="small" :disabled="index === 0" @click="moveItem(index, -1)">上移</el-button>
|
||||||
|
<el-button size="small" :disabled="index === detectItems.length - 1" @click="moveItem(index, 1)">
|
||||||
|
下移
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageCard>
|
||||||
|
|
||||||
|
<div class="actions span-2">
|
||||||
|
<el-button type="primary" @click="saveSettings">保存当前配置</el-button>
|
||||||
|
<el-button @click="loadSettings">重新读取</el-button>
|
||||||
|
<el-button @click="createBackup">创建配置备份</el-button>
|
||||||
|
<el-button @click="exportSettingsSnapshot">导出配置快照</el-button>
|
||||||
|
<el-button @click="triggerImport">导入配置快照</el-button>
|
||||||
|
<input
|
||||||
|
ref="importInput"
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
class="hidden-input"
|
||||||
|
@change="handleImport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PageCard title="配置备份记录" class="span-2">
|
||||||
|
<div class="backup-toolbar">
|
||||||
|
<span class="backup-tip">导入配置前系统会自动生成一份导入前备份。</span>
|
||||||
|
<el-button size="small" @click="loadBackups">刷新备份列表</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="backups" border>
|
||||||
|
<el-table-column prop="filename" label="文件名" min-width="280" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="backup_reason" label="备份原因" min-width="120" />
|
||||||
|
<el-table-column prop="modified_at" label="生成时间" min-width="180" />
|
||||||
|
<el-table-column prop="size" label="大小" min-width="100" />
|
||||||
|
<el-table-column label="操作" min-width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" link type="primary" @click="downloadBackup(row.filename)">下载</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="path" label="文件路径" min-width="360" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
</PageCard>
|
||||||
|
</div>
|
||||||
|
</el-skeleton>
|
||||||
|
</PageCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import PageCard from "@/components/PageCard.vue";
|
||||||
|
import { settingsApi } from "@/api/modules";
|
||||||
|
|
||||||
|
type DetectItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BackupRecord = {
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
modified_at: string;
|
||||||
|
backup_reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DETECT_LABELS: Record<string, string> = {
|
||||||
|
detect_register: "检查注册",
|
||||||
|
detect_baidu_site: "百度 site 查询",
|
||||||
|
detect_360_site: "360 site 查询",
|
||||||
|
detect_chinaz: "站长之家查询",
|
||||||
|
detect_aizhan: "爱站网查询",
|
||||||
|
detect_wayback: "时光机检测",
|
||||||
|
detect_jucha: "聚查查询",
|
||||||
|
detect_juziseo: "桔子查询"
|
||||||
|
};
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const threadCount = ref(2);
|
||||||
|
const detectItems = ref<DetectItem[]>([]);
|
||||||
|
const backups = ref<BackupRecord[]>([]);
|
||||||
|
const proxyConfig = ref<Record<string, any>>({
|
||||||
|
proxy_enable: false,
|
||||||
|
allow_direct: false,
|
||||||
|
proxy_url: "",
|
||||||
|
proxy_urls: []
|
||||||
|
});
|
||||||
|
const proxyUrlsText = ref("");
|
||||||
|
const runtimeSettings = ref({
|
||||||
|
worker_mode: "windows-local",
|
||||||
|
worker_service_name: "domaincheck-worker",
|
||||||
|
api_service_name: "domaincheck-api"
|
||||||
|
});
|
||||||
|
const importInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const normalizeDetectItems = (detectOptions: Record<string, boolean | string[]>) => {
|
||||||
|
const order = Array.isArray(detectOptions.detect_order) ? [...(detectOptions.detect_order as string[])] : [];
|
||||||
|
const knownKeys = Object.keys(DETECT_LABELS);
|
||||||
|
knownKeys.forEach((key) => {
|
||||||
|
if (!order.includes(key)) {
|
||||||
|
order.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
detectItems.value = order
|
||||||
|
.filter((key) => DETECT_LABELS[key])
|
||||||
|
.map((key) => ({
|
||||||
|
key,
|
||||||
|
label: DETECT_LABELS[key],
|
||||||
|
enabled: Boolean(detectOptions[key])
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveItem = (index: number, offset: -1 | 1) => {
|
||||||
|
const targetIndex = index + offset;
|
||||||
|
if (targetIndex < 0 || targetIndex >= detectItems.value.length) return;
|
||||||
|
const items = [...detectItems.value];
|
||||||
|
const [current] = items.splice(index, 1);
|
||||||
|
items.splice(targetIndex, 0, current);
|
||||||
|
detectItems.value = items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadBackups = async () => {
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.getSettingsBackups();
|
||||||
|
backups.value = response.data || [];
|
||||||
|
} catch {
|
||||||
|
ElMessage.error("读取配置备份列表失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSettings = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.getSettings();
|
||||||
|
threadCount.value = response.data.thread_count;
|
||||||
|
normalizeDetectItems(response.data.detect_options || {});
|
||||||
|
|
||||||
|
proxyConfig.value = {
|
||||||
|
proxy_enable: Boolean(response.data.proxy_config?.proxy_enable),
|
||||||
|
allow_direct: Boolean(response.data.proxy_config?.allow_direct),
|
||||||
|
proxy_url: response.data.proxy_config?.proxy_url || "",
|
||||||
|
proxy_urls: response.data.proxy_config?.proxy_urls || []
|
||||||
|
};
|
||||||
|
proxyUrlsText.value = (proxyConfig.value.proxy_urls || []).join("\n");
|
||||||
|
|
||||||
|
runtimeSettings.value = {
|
||||||
|
worker_mode: response.data.runtime_settings?.worker_mode || "windows-local",
|
||||||
|
worker_service_name: response.data.runtime_settings?.worker_service_name || "domaincheck-worker",
|
||||||
|
api_service_name: response.data.runtime_settings?.api_service_name || "domaincheck-api"
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
ElMessage.error("读取系统设置失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSettingsPayload = () => {
|
||||||
|
const proxyUrls = proxyUrlsText.value
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const detectOptions: Record<string, boolean | string[]> = {
|
||||||
|
detect_order: detectItems.value.map((item) => item.key)
|
||||||
|
};
|
||||||
|
detectItems.value.forEach((item) => {
|
||||||
|
detectOptions[item.key] = item.enabled;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
thread_count: threadCount.value,
|
||||||
|
detect_options: detectOptions,
|
||||||
|
proxy_config: {
|
||||||
|
proxy_enable: Boolean(proxyConfig.value.proxy_enable),
|
||||||
|
allow_direct: Boolean(proxyConfig.value.allow_direct),
|
||||||
|
proxy_url: proxyConfig.value.proxy_url || proxyUrls[0] || "",
|
||||||
|
proxy_urls: proxyUrls
|
||||||
|
},
|
||||||
|
runtime_settings: runtimeSettings.value
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSettings = async () => {
|
||||||
|
try {
|
||||||
|
await settingsApi.updateSettings(buildSettingsPayload());
|
||||||
|
ElMessage.success("系统设置已保存");
|
||||||
|
await loadSettings();
|
||||||
|
} catch {
|
||||||
|
ElMessage.error("保存系统设置失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBackup = async () => {
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.backupSettings();
|
||||||
|
ElMessage.success(`配置备份已生成:${response.data.filename}`);
|
||||||
|
await loadBackups();
|
||||||
|
} catch {
|
||||||
|
ElMessage.error("创建配置备份失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportSettingsSnapshot = async () => {
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.exportSettings();
|
||||||
|
const blob = new Blob([JSON.stringify(response.data, null, 2)], {
|
||||||
|
type: "application/json;charset=utf-8"
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `domaincheck_settings_${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
ElMessage.success("配置快照已导出");
|
||||||
|
} catch {
|
||||||
|
ElMessage.error("导出配置快照失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerImport = () => {
|
||||||
|
importInput.value?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const payload = JSON.parse(text);
|
||||||
|
await settingsApi.validateImportSettings(payload);
|
||||||
|
await settingsApi.importSettings(payload);
|
||||||
|
ElMessage.success("配置快照已导入,系统已自动创建导入前备份");
|
||||||
|
await Promise.all([loadSettings(), loadBackups()]);
|
||||||
|
} catch {
|
||||||
|
ElMessage.error("导入配置快照失败,请确认文件内容有效且结构正确");
|
||||||
|
} finally {
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadBackup = (filename: string) => {
|
||||||
|
window.open(settingsApi.backupDownloadUrl(filename), "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadSettings(), loadBackups()]);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span-2 {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detect-items {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detect-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detect-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detect-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-tip {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
domain-web/src/vite-env.d.ts
vendored
Normal file
1
domain-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
21
domain-web/tsconfig.json
Normal file
21
domain-web/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"strict": false,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
41
domain-web/vite.config.ts
Normal file
41
domain-web/vite.config.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./src", import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
chunkSizeWarningLimit: 800,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (!id.includes("node_modules")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (id.includes("element-plus")) {
|
||||||
|
return "element-plus";
|
||||||
|
}
|
||||||
|
if (id.includes("@element-plus/icons-vue")) {
|
||||||
|
return "element-icons";
|
||||||
|
}
|
||||||
|
if (id.includes("vue") || id.includes("pinia") || id.includes("vue-router")) {
|
||||||
|
return "vue-vendor";
|
||||||
|
}
|
||||||
|
if (id.includes("axios")) {
|
||||||
|
return "http-vendor";
|
||||||
|
}
|
||||||
|
return "vendor";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3200,
|
||||||
|
host: "0.0.0.0"
|
||||||
|
}
|
||||||
|
});
|
||||||
1
domainCheck
Submodule
1
domainCheck
Submodule
Submodule domainCheck added at 83ab7a79e8
192
package_domain_release.ps1
Normal file
192
package_domain_release.ps1
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$releaseRoot = Join-Path $root "release"
|
||||||
|
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||||
|
$packageName = "domaincheck_release_$timestamp"
|
||||||
|
$stagingRoot = Join-Path $releaseRoot $packageName
|
||||||
|
$zipPath = Join-Path $releaseRoot "$packageName.zip"
|
||||||
|
$hashPath = Join-Path $releaseRoot "$packageName.sha256.txt"
|
||||||
|
$latestTxtPath = Join-Path $releaseRoot "latest_release.txt"
|
||||||
|
$latestJsonPath = Join-Path $releaseRoot "latest_release.json"
|
||||||
|
$smokeScript = Join-Path $root "domainCheck\.venv\Scripts\python.exe"
|
||||||
|
$smokeRunner = Join-Path $root "domain-api\deploy\linux\smoke_test.py"
|
||||||
|
$smokeOutput = Join-Path $stagingRoot "smoke_test_report.json"
|
||||||
|
$apiBaseUrl = if ($env:API_BASE_URL) { $env:API_BASE_URL } else { "http://127.0.0.1:8100" }
|
||||||
|
$webUrl = if ($env:WEB_URL) { $env:WEB_URL } else { "http://127.0.0.1:3200" }
|
||||||
|
|
||||||
|
function Copy-OptionalItem {
|
||||||
|
param(
|
||||||
|
[string]$Source,
|
||||||
|
[string]$Destination
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $Source) {
|
||||||
|
Copy-Item -LiteralPath $Source -Destination $Destination -Recurse -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $releaseRoot | Out-Null
|
||||||
|
if (Test-Path -LiteralPath $stagingRoot) {
|
||||||
|
Remove-Item -LiteralPath $stagingRoot -Recurse -Force
|
||||||
|
}
|
||||||
|
if (Test-Path -LiteralPath $zipPath) {
|
||||||
|
Remove-Item -LiteralPath $zipPath -Force
|
||||||
|
}
|
||||||
|
if (Test-Path -LiteralPath $hashPath) {
|
||||||
|
Remove-Item -LiteralPath $hashPath -Force
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Force -Path $stagingRoot | Out-Null
|
||||||
|
|
||||||
|
$docsTarget = Join-Path $stagingRoot "docs"
|
||||||
|
New-Item -ItemType Directory -Force -Path $docsTarget | Out-Null
|
||||||
|
Copy-Item -Path (Join-Path $root "docs\*.md") -Destination $docsTarget -Force
|
||||||
|
|
||||||
|
$scriptsTarget = Join-Path $stagingRoot "scripts"
|
||||||
|
New-Item -ItemType Directory -Force -Path $scriptsTarget | Out-Null
|
||||||
|
$rootScripts = @(
|
||||||
|
"start_domain_api.ps1",
|
||||||
|
"stop_domain_api.ps1",
|
||||||
|
"start_domain_web.ps1",
|
||||||
|
"stop_domain_web.ps1",
|
||||||
|
"start_domain_web_background.ps1",
|
||||||
|
"start_domain_stack.ps1",
|
||||||
|
"stop_domain_stack.ps1",
|
||||||
|
"smoke_test_stack.ps1",
|
||||||
|
"package_domain_release.ps1",
|
||||||
|
"verify_domain_release.ps1",
|
||||||
|
"prepare_final_release.ps1",
|
||||||
|
"show_latest_release.ps1"
|
||||||
|
)
|
||||||
|
foreach ($script in $rootScripts) {
|
||||||
|
Copy-OptionalItem -Source (Join-Path $root $script) -Destination $scriptsTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiTarget = Join-Path $stagingRoot "domain-api"
|
||||||
|
New-Item -ItemType Directory -Force -Path $apiTarget | Out-Null
|
||||||
|
$apiItems = @(
|
||||||
|
"app",
|
||||||
|
"deploy",
|
||||||
|
"README.md",
|
||||||
|
"requirements.txt",
|
||||||
|
".env.example"
|
||||||
|
)
|
||||||
|
foreach ($item in $apiItems) {
|
||||||
|
Copy-OptionalItem -Source (Join-Path $root "domain-api\$item") -Destination $apiTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
$webTarget = Join-Path $stagingRoot "domain-web"
|
||||||
|
New-Item -ItemType Directory -Force -Path $webTarget | Out-Null
|
||||||
|
$webItems = @(
|
||||||
|
"src",
|
||||||
|
"deploy",
|
||||||
|
"README.md",
|
||||||
|
"package.json",
|
||||||
|
"package-lock.json",
|
||||||
|
"tsconfig.json",
|
||||||
|
"tsconfig.node.json",
|
||||||
|
"vite.config.ts",
|
||||||
|
".env.example",
|
||||||
|
".env.production.example"
|
||||||
|
)
|
||||||
|
foreach ($item in $webItems) {
|
||||||
|
Copy-OptionalItem -Source (Join-Path $root "domain-web\$item") -Destination $webTarget
|
||||||
|
}
|
||||||
|
Copy-OptionalItem -Source (Join-Path $root "domain-web\dist") -Destination $webTarget
|
||||||
|
|
||||||
|
$smokeOk = $false
|
||||||
|
$smokeMessage = ""
|
||||||
|
if ((Test-Path -LiteralPath $smokeScript) -and (Test-Path -LiteralPath $smokeRunner)) {
|
||||||
|
try {
|
||||||
|
& $smokeScript $smokeRunner --base-url $apiBaseUrl --web-url $webUrl --output $smokeOutput | Out-Null
|
||||||
|
if (Test-Path -LiteralPath $smokeOutput) {
|
||||||
|
$smokeOk = $true
|
||||||
|
$smokeMessage = "generated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$smokeMessage = $_.Exception.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$smokeMessage = "smoke test runtime not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = [ordered]@{
|
||||||
|
package_name = $packageName
|
||||||
|
generated_at = (Get-Date).ToString("s")
|
||||||
|
root = $root
|
||||||
|
smoke_test = [ordered]@{
|
||||||
|
ok = $smokeOk
|
||||||
|
message = $smokeMessage
|
||||||
|
api_base_url = $apiBaseUrl
|
||||||
|
web_url = $webUrl
|
||||||
|
report = if ($smokeOk) { "smoke_test_report.json" } else { "" }
|
||||||
|
}
|
||||||
|
included = [ordered]@{
|
||||||
|
docs = Get-ChildItem -Path $docsTarget -File | Select-Object -ExpandProperty Name
|
||||||
|
scripts = Get-ChildItem -Path $scriptsTarget -File | Select-Object -ExpandProperty Name
|
||||||
|
domain_api = Get-ChildItem -Path $apiTarget | Select-Object -ExpandProperty Name
|
||||||
|
domain_web = Get-ChildItem -Path $webTarget | Select-Object -ExpandProperty Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifestPath = Join-Path $stagingRoot "release_manifest.json"
|
||||||
|
$manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
|
||||||
|
|
||||||
|
$readmePath = Join-Path $stagingRoot "README_RELEASE.txt"
|
||||||
|
@(
|
||||||
|
"domainCheck release package",
|
||||||
|
"generated_at=$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')",
|
||||||
|
"",
|
||||||
|
"Contents:",
|
||||||
|
"- docs",
|
||||||
|
"- scripts",
|
||||||
|
"- domain-api",
|
||||||
|
"- domain-web",
|
||||||
|
"- release_manifest.json",
|
||||||
|
"- smoke_test_report.json (if generated)",
|
||||||
|
"",
|
||||||
|
"Recommended order:",
|
||||||
|
"1. Read docs\08_domainCheck_交付说明.md",
|
||||||
|
"2. Read docs\05_domainCheck_Linux部署清单.md",
|
||||||
|
"3. Review release_manifest.json and smoke_test_report.json",
|
||||||
|
"4. On Windows run scripts\smoke_test_stack.ps1",
|
||||||
|
"5. On Linux read domain-api\deploy\linux\README.md"
|
||||||
|
) | Set-Content -LiteralPath $readmePath -Encoding UTF8
|
||||||
|
|
||||||
|
Compress-Archive -Path (Join-Path $stagingRoot "*") -DestinationPath $zipPath -Force
|
||||||
|
|
||||||
|
$zipHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
@(
|
||||||
|
"algorithm=SHA256"
|
||||||
|
"file=$($packageName).zip"
|
||||||
|
"sha256=$zipHash"
|
||||||
|
) | Set-Content -LiteralPath $hashPath -Encoding UTF8
|
||||||
|
|
||||||
|
$latestRelease = [ordered]@{
|
||||||
|
package_name = $packageName
|
||||||
|
generated_at = (Get-Date).ToString("s")
|
||||||
|
staging_path = $stagingRoot
|
||||||
|
zip_path = $zipPath
|
||||||
|
sha256_path = $hashPath
|
||||||
|
sha256 = $zipHash
|
||||||
|
smoke_test_ok = $smokeOk
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestRelease | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $latestJsonPath -Encoding UTF8
|
||||||
|
@(
|
||||||
|
"package_name=$packageName"
|
||||||
|
"staging_path=$stagingRoot"
|
||||||
|
"zip_path=$zipPath"
|
||||||
|
"sha256_path=$hashPath"
|
||||||
|
"sha256=$zipHash"
|
||||||
|
"smoke_test_ok=$smokeOk"
|
||||||
|
) | Set-Content -LiteralPath $latestTxtPath -Encoding UTF8
|
||||||
|
|
||||||
|
Write-Host "release package created"
|
||||||
|
Write-Host "staging: $stagingRoot"
|
||||||
|
Write-Host "zip: $zipPath"
|
||||||
|
Write-Host "sha256: $hashPath"
|
||||||
|
Write-Host "latest: $latestJsonPath"
|
||||||
|
Write-Host "smoke_test_ok: $smokeOk"
|
||||||
35
prepare_final_release.ps1
Normal file
35
prepare_final_release.ps1
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$releaseRoot = Join-Path $root "release"
|
||||||
|
$reportPath = Join-Path $releaseRoot "final_release_report.json"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $releaseRoot | Out-Null
|
||||||
|
|
||||||
|
powershell -ExecutionPolicy Bypass -File (Join-Path $root "package_domain_release.ps1")
|
||||||
|
|
||||||
|
$latestJsonPath = Join-Path $releaseRoot "latest_release.json"
|
||||||
|
if (-not (Test-Path -LiteralPath $latestJsonPath)) {
|
||||||
|
throw "latest_release.json not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
$latest = Get-Content -LiteralPath $latestJsonPath -Raw | ConvertFrom-Json
|
||||||
|
|
||||||
|
$verifyOutput = powershell -ExecutionPolicy Bypass -File (Join-Path $root "verify_domain_release.ps1") `
|
||||||
|
-ZipPath $latest.zip_path `
|
||||||
|
-HashPath $latest.sha256_path
|
||||||
|
|
||||||
|
$verify = $verifyOutput | ConvertFrom-Json
|
||||||
|
|
||||||
|
$report = [ordered]@{
|
||||||
|
generated_at = (Get-Date).ToString("s")
|
||||||
|
ok = [bool]$verify.ok
|
||||||
|
latest_release = $latest
|
||||||
|
verify = $verify
|
||||||
|
}
|
||||||
|
|
||||||
|
$report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $reportPath -Encoding UTF8
|
||||||
|
|
||||||
|
Write-Host "final release prepared"
|
||||||
|
Write-Host "report: $reportPath"
|
||||||
|
Write-Host "ok: $($verify.ok)"
|
||||||
24
show_latest_release.ps1
Normal file
24
show_latest_release.ps1
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$releaseRoot = Join-Path $root "release"
|
||||||
|
$latestJsonPath = Join-Path $releaseRoot "latest_release.json"
|
||||||
|
$finalReportPath = Join-Path $releaseRoot "final_release_report.json"
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $latestJsonPath)) {
|
||||||
|
throw "latest_release.json not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
$latest = Get-Content -LiteralPath $latestJsonPath -Raw | ConvertFrom-Json
|
||||||
|
$report = $null
|
||||||
|
if (Test-Path -LiteralPath $finalReportPath) {
|
||||||
|
$report = Get-Content -LiteralPath $finalReportPath -Raw | ConvertFrom-Json
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = [ordered]@{
|
||||||
|
latest_release = $latest
|
||||||
|
final_release_ok = if ($report) { [bool]$report.ok } else { $null }
|
||||||
|
final_release_report = if ($report) { $finalReportPath } else { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary | ConvertTo-Json -Depth 8
|
||||||
16
smoke_test_stack.ps1
Normal file
16
smoke_test_stack.ps1
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$python = Join-Path $root 'domainCheck\.venv\Scripts\python.exe'
|
||||||
|
$script = Join-Path $root 'domain-api\deploy\linux\smoke_test.py'
|
||||||
|
$apiBaseUrl = if ($env:API_BASE_URL) { $env:API_BASE_URL } else { 'http://127.0.0.1:8100' }
|
||||||
|
$webUrl = if ($env:WEB_URL) { $env:WEB_URL } else { 'http://127.0.0.1:3200' }
|
||||||
|
|
||||||
|
if (-not (Test-Path $python)) {
|
||||||
|
throw "Python interpreter not found: $python"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $script)) {
|
||||||
|
throw "Smoke test script not found: $script"
|
||||||
|
}
|
||||||
|
|
||||||
|
& $python $script --base-url $apiBaseUrl --web-url $webUrl
|
||||||
36
start_domain_api.ps1
Normal file
36
start_domain_api.ps1
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$python = Join-Path $root 'domainCheck\.venv\Scripts\python.exe'
|
||||||
|
$apiRoot = Join-Path $root 'domain-api'
|
||||||
|
$runtimeLogDir = Join-Path $apiRoot 'runtime\logs'
|
||||||
|
$stdoutLog = Join-Path $runtimeLogDir 'domain-api.stdout.log'
|
||||||
|
$stderrLog = Join-Path $runtimeLogDir 'domain-api.stderr.log'
|
||||||
|
$apiHost = if ($env:API_HOST) { $env:API_HOST } else { '0.0.0.0' }
|
||||||
|
$apiPort = if ($env:API_PORT) { $env:API_PORT } else { '8100' }
|
||||||
|
|
||||||
|
if (-not (Test-Path $python)) {
|
||||||
|
throw "未找到 Python 解释器: $python"
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $runtimeLogDir | Out-Null
|
||||||
|
|
||||||
|
$processIds = @()
|
||||||
|
$targets = Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like "*app.main:app*--port $apiPort*" }
|
||||||
|
$processIds += $targets.ProcessId
|
||||||
|
$listeners = Get-NetTCPConnection -LocalPort ([int]$apiPort) -State Listen -ErrorAction SilentlyContinue
|
||||||
|
if ($listeners) {
|
||||||
|
$processIds += $listeners.OwningProcess
|
||||||
|
}
|
||||||
|
$processIds = $processIds | Where-Object { $_ } | Select-Object -Unique
|
||||||
|
foreach ($processId in $processIds) {
|
||||||
|
Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-Location $apiRoot
|
||||||
|
Start-Process -FilePath $python `
|
||||||
|
-ArgumentList '-m', 'uvicorn', 'app.main:app', '--host', $apiHost, '--port', $apiPort `
|
||||||
|
-WorkingDirectory $apiRoot `
|
||||||
|
-RedirectStandardOutput $stdoutLog `
|
||||||
|
-RedirectStandardError $stderrLog
|
||||||
|
|
||||||
|
Write-Output "domain-api 已启动(${apiHost}:${apiPort}),日志:$stdoutLog / $stderrLog"
|
||||||
7
start_domain_stack.ps1
Normal file
7
start_domain_stack.ps1
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
|
||||||
|
powershell -ExecutionPolicy Bypass -File (Join-Path $root 'start_domain_api.ps1')
|
||||||
|
powershell -ExecutionPolicy Bypass -File (Join-Path $root 'start_domain_web_background.ps1')
|
||||||
|
|
||||||
|
Write-Output 'domainCheck Web/API 栈已启动'
|
||||||
13
start_domain_web.ps1
Normal file
13
start_domain_web.ps1
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$webRoot = Join-Path $root 'domain-web'
|
||||||
|
$nodeRoot = Join-Path $root 'domainCheck\tools\node-v20.19.4-win-x64'
|
||||||
|
$npm = Join-Path $nodeRoot 'npm.cmd'
|
||||||
|
|
||||||
|
if (-not (Test-Path $npm)) {
|
||||||
|
throw "未找到 npm 命令: $npm"
|
||||||
|
}
|
||||||
|
|
||||||
|
$env:PATH = "$nodeRoot;$env:PATH"
|
||||||
|
Set-Location $webRoot
|
||||||
|
& $npm run dev -- --host 0.0.0.0 --port 3200
|
||||||
19
start_domain_web_background.ps1
Normal file
19
start_domain_web_background.ps1
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$webRoot = Join-Path $root 'domain-web'
|
||||||
|
$logDir = Join-Path $webRoot 'runtime\logs'
|
||||||
|
$stdoutLog = Join-Path $logDir 'domain-web.stdout.log'
|
||||||
|
$stderrLog = Join-Path $logDir 'domain-web.stderr.log'
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
||||||
|
|
||||||
|
powershell -ExecutionPolicy Bypass -File (Join-Path $root 'stop_domain_web.ps1') | Out-Null
|
||||||
|
|
||||||
|
$command = "& '" + (Join-Path $root 'start_domain_web.ps1') + "'"
|
||||||
|
Start-Process -FilePath 'powershell' `
|
||||||
|
-ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $command `
|
||||||
|
-WorkingDirectory $webRoot `
|
||||||
|
-RedirectStandardOutput $stdoutLog `
|
||||||
|
-RedirectStandardError $stderrLog
|
||||||
|
|
||||||
|
Write-Output "domain-web 已后台启动(0.0.0.0:3200),日志:$stdoutLog / $stderrLog"
|
||||||
22
stop_domain_api.ps1
Normal file
22
stop_domain_api.ps1
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$apiPort = if ($env:API_PORT) { $env:API_PORT } else { '8100' }
|
||||||
|
|
||||||
|
$processIds = @()
|
||||||
|
$targets = Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like "*app.main:app*--port $apiPort*" }
|
||||||
|
$processIds += $targets.ProcessId
|
||||||
|
$listeners = Get-NetTCPConnection -LocalPort ([int]$apiPort) -State Listen -ErrorAction SilentlyContinue
|
||||||
|
if ($listeners) {
|
||||||
|
$processIds += $listeners.OwningProcess
|
||||||
|
}
|
||||||
|
$processIds = $processIds | Where-Object { $_ } | Select-Object -Unique
|
||||||
|
|
||||||
|
if (-not $processIds) {
|
||||||
|
Write-Output 'domain-api 当前没有运行中的进程'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($processId in $processIds) {
|
||||||
|
Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output ("domain-api 已停止,进程数: " + (($processIds | Measure-Object).Count))
|
||||||
7
stop_domain_stack.ps1
Normal file
7
stop_domain_stack.ps1
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
|
||||||
|
powershell -ExecutionPolicy Bypass -File (Join-Path $root 'stop_domain_web.ps1')
|
||||||
|
powershell -ExecutionPolicy Bypass -File (Join-Path $root 'stop_domain_api.ps1')
|
||||||
|
|
||||||
|
Write-Output 'domainCheck Web/API 栈已停止'
|
||||||
25
stop_domain_web.ps1
Normal file
25
stop_domain_web.ps1
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$webPort = if ($env:WEB_PORT) { $env:WEB_PORT } else { '3200' }
|
||||||
|
|
||||||
|
$processIds = @()
|
||||||
|
$targets = Get-CimInstance Win32_Process | Where-Object {
|
||||||
|
$_.CommandLine -like "*vite*--host 0.0.0.0 --port $webPort*" -or
|
||||||
|
$_.CommandLine -like "*npm*run dev*--port $webPort*"
|
||||||
|
}
|
||||||
|
$processIds += $targets.ProcessId
|
||||||
|
$listeners = Get-NetTCPConnection -LocalPort ([int]$webPort) -State Listen -ErrorAction SilentlyContinue
|
||||||
|
if ($listeners) {
|
||||||
|
$processIds += $listeners.OwningProcess
|
||||||
|
}
|
||||||
|
$processIds = $processIds | Where-Object { $_ } | Select-Object -Unique
|
||||||
|
|
||||||
|
if (-not $processIds) {
|
||||||
|
Write-Output 'domain-web 当前没有运行中的进程'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($processId in $processIds) {
|
||||||
|
Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output ("domain-web 已停止,进程数: " + (($processIds | Measure-Object).Count))
|
||||||
105
verify_domain_release.ps1
Normal file
105
verify_domain_release.ps1
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
param(
|
||||||
|
[string]$ZipPath,
|
||||||
|
[string]$HashPath
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Resolve-LatestReleaseFile {
|
||||||
|
param(
|
||||||
|
[string]$ReleaseRoot,
|
||||||
|
[string]$Filter
|
||||||
|
)
|
||||||
|
|
||||||
|
$item = Get-ChildItem -LiteralPath $ReleaseRoot -Filter $Filter | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||||
|
if (-not $item) {
|
||||||
|
throw "No file found for filter: $Filter"
|
||||||
|
}
|
||||||
|
return $item.FullName
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$releaseRoot = Join-Path $root "release"
|
||||||
|
|
||||||
|
if (-not $ZipPath) {
|
||||||
|
$ZipPath = Resolve-LatestReleaseFile -ReleaseRoot $releaseRoot -Filter "*.zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $HashPath) {
|
||||||
|
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($ZipPath)
|
||||||
|
$candidate = Join-Path $releaseRoot "$baseName.sha256.txt"
|
||||||
|
if (Test-Path -LiteralPath $candidate) {
|
||||||
|
$HashPath = $candidate
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$HashPath = Resolve-LatestReleaseFile -ReleaseRoot $releaseRoot -Filter "*.sha256.txt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $ZipPath)) {
|
||||||
|
throw "Zip not found: $ZipPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $HashPath)) {
|
||||||
|
throw "Hash file not found: $HashPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$hashLines = Get-Content -LiteralPath $HashPath
|
||||||
|
$expectedHash = ($hashLines | Where-Object { $_ -like "sha256=*" } | Select-Object -First 1) -replace "^sha256=", ""
|
||||||
|
$expectedFile = ($hashLines | Where-Object { $_ -like "file=*" } | Select-Object -First 1) -replace "^file=", ""
|
||||||
|
|
||||||
|
if (-not $expectedHash) {
|
||||||
|
throw "sha256 entry missing in $HashPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$actualHash = (Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
$fileName = [System.IO.Path]::GetFileName($ZipPath)
|
||||||
|
|
||||||
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
|
$zip = [System.IO.Compression.ZipFile]::OpenRead($ZipPath)
|
||||||
|
|
||||||
|
$missingEntries = @()
|
||||||
|
try {
|
||||||
|
$requiredEntries = @(
|
||||||
|
"README_RELEASE.txt",
|
||||||
|
"release_manifest.json",
|
||||||
|
"domain-api/README.md",
|
||||||
|
"domain-web/README.md",
|
||||||
|
"scripts/package_domain_release.ps1",
|
||||||
|
"scripts/smoke_test_stack.ps1"
|
||||||
|
)
|
||||||
|
|
||||||
|
$entryNames = $zip.Entries | ForEach-Object { $_.FullName.Replace('\', '/') }
|
||||||
|
|
||||||
|
foreach ($required in $requiredEntries) {
|
||||||
|
if ($entryNames -notcontains $required) {
|
||||||
|
$missingEntries += $required
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($entryNames -notcontains "smoke_test_report.json") {
|
||||||
|
$missingEntries += "smoke_test_report.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$zip.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = ($actualHash -eq $expectedHash.ToLowerInvariant()) -and ($missingEntries.Count -eq 0) -and (($expectedFile -eq "") -or ($expectedFile -eq $fileName))
|
||||||
|
|
||||||
|
$report = [ordered]@{
|
||||||
|
ok = $ok
|
||||||
|
zip = $ZipPath
|
||||||
|
hash_file = $HashPath
|
||||||
|
expected_file = $expectedFile
|
||||||
|
actual_file = $fileName
|
||||||
|
expected_sha256 = $expectedHash.ToLowerInvariant()
|
||||||
|
actual_sha256 = $actualHash
|
||||||
|
missing_entries = $missingEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
$report | ConvertTo-Json -Depth 6
|
||||||
|
|
||||||
|
if (-not $ok) {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user