This commit is contained in:
Your Name
2026-04-16 13:05:07 +08:00
commit 32efff1670
99 changed files with 9974 additions and 0 deletions

42
.gitignore vendored Normal file
View 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
View 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/`
- 其他运行生成文件

View 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 导出和人工规则脚本化

View 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`:已按你的备注暂缓未审核项,不影响当前主线联调

View 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 导出正确性
---
## 七、外包方回复建议格式
请外包方按以下格式逐项回复:
- `问题编号`
- `是否认可`
- `是否已整改`
- `整改说明`
- `涉及文件`
- `预计完成时间`
---
## 八、当前阶段结论
当前版本不建议直接整体验收通过。
建议以本清单为基础,由外包方完成一类问题整改后,再进入下一轮正式复测。

View 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 双模式并行一段时间
这是当前成本、风险、可维护性三者之间最平衡的方案。

View 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 下日志路径与权限
- 真实代理池在国内服务器上的表现
- 大批量导入与导出任务表现

View 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 方案已经不是概念原型,而是进入了“可交付、可联调、可继续迁移”的阶段。

View 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 部署环境已达到可联调状态
- 可以进入真实服务器联调或灰度上线阶段

View 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 实机联调
- 上线前复测
- 真实服务器灰度验证

View 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` 用于记录最近一次“打包 + 验包”的最终状态。

View 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. 进入灰度上线
## 六、最终判断
当前这套项目,已经达到“工程交付完成,待真实环境上线联调”的状态。

View 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 环境里的诊断包或上述输出给我,我就可以直接进入最后的线上联调阶段,不需要重新铺背景。

View 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 环境:
- 部署
- 联调
- 灰度上线

View 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

View File

@@ -0,0 +1,2 @@
codex-regression-20260416-a.com
codex-regression-20260416-b.net

View 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
1 域名 注册状态 使用状态 检测状态 人工复核状态 过期时间 单位性质 网站首页网址 检测时间 备案历史 备案年份 快照年份 百度历史 百度Site 是否中文标题 360 Site Google Site 友情链接数量
2 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
3 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
View 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
View 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`

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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,
},
}
)

View 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())

View 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,
},
)

View 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)

View 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)

View 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)

View 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")

View 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)

View 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)

View File

@@ -0,0 +1 @@

View 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
View 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()

View 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}"

View 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
View 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)

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str

View 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

View File

@@ -0,0 +1 @@

View 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

View 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,
}

View 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()),
}

View 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

View 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

View 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)

View 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,
}

View 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

View 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,
}

View 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

View 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,
}

View 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

View 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 "检测端已停止"

View 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=...`
把生成的归档包发回即可继续排障。

View 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"

View 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())

View 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

View 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

View 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
View File

@@ -0,0 +1,2 @@
VITE_APP_TITLE=domainCheck 管理后台
VITE_API_BASE_URL=http://127.0.0.1:8100/api/v1

View 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
View 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`

View 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"

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

File diff suppressed because it is too large Load Diff

24
domain-web/package.json Normal file
View 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
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View 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;

View 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`
};

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

View 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
View 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");

View 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;

View 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));
}
}
});

View 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;
}

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

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

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

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

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

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
domain-web/tsconfig.json Normal file
View 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
View 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

Submodule domainCheck added at 83ab7a79e8

192
package_domain_release.ps1 Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
}