From 32efff167096ca21de3ae2329dda12eb1b4c1d7e Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 13:05:07 +0800 Subject: [PATCH] dev --- .gitignore | 42 + README.md | 164 ++ docs/01_domainCheck_验收清单.md | 267 ++ docs/02_domainCheck_优化清单_待审核.md | 426 ++++ docs/03_domainCheck_整改清单_外包版.md | 427 ++++ docs/04_domainCheck_WebLinux改造总体方案.md | 604 +++++ docs/05_domainCheck_Linux部署清单.md | 142 ++ docs/06_domainCheck_Web版当前完成度.md | 75 + docs/07_domainCheck_WebLinux发布验收清单.md | 90 + docs/08_domainCheck_交付说明.md | 122 + docs/09_domainCheck_交付打包说明.md | 111 + docs/10_domainCheck_最终交付结论.md | 67 + docs/11_domainCheck_Linux联调输入清单.md | 83 + docs/12_domainCheck_导航索引.md | 84 + docs/_tmp_regression/import_regression.txt | 5 + docs/_tmp_regression/regression_domains.txt | 2 + docs/_tmp_regression/regression_page1.csv | 3 + domain-api/.env.example | 28 + domain-api/README.md | 80 + domain-api/app/__init__.py | 1 + domain-api/app/api/__init__.py | 1 + domain-api/app/api/routes/__init__.py | 1 + domain-api/app/api/routes/auth.py | 23 + domain-api/app/api/routes/dashboard.py | 11 + domain-api/app/api/routes/detect.py | 40 + domain-api/app/api/routes/domains.py | 50 + domain-api/app/api/routes/exports.py | 29 + domain-api/app/api/routes/imports.py | 30 + domain-api/app/api/routes/logs.py | 18 + domain-api/app/api/routes/runtime.py | 23 + domain-api/app/api/routes/settings.py | 69 + domain-api/app/core/__init__.py | 1 + domain-api/app/core/config.py | 51 + domain-api/app/core/db.py | 20 + domain-api/app/core/files.py | 107 + domain-api/app/core/redis_client.py | 17 + domain-api/app/main.py | 43 + domain-api/app/schemas/__init__.py | 1 + domain-api/app/schemas/auth.py | 6 + domain-api/app/schemas/common.py | 8 + domain-api/app/services/__init__.py | 1 + domain-api/app/services/dashboard.py | 27 + domain-api/app/services/detect_service.py | 65 + domain-api/app/services/domains_service.py | 277 +++ domain-api/app/services/export_service.py | 172 ++ .../app/services/import_task_service.py | 114 + .../app/services/import_worker_service.py | 103 + domain-api/app/services/imports_service.py | 25 + domain-api/app/services/logs_service.py | 86 + .../app/services/runtime_control_service.py | 65 + .../app/services/runtime_settings_service.py | 28 + .../app/services/runtime_status_service.py | 113 + domain-api/app/services/settings_service.py | 172 ++ .../app/services/worker_control_service.py | 188 ++ domain-api/deploy/linux/README.md | 179 ++ .../deploy/linux/collect_diagnostics.sh | 60 + domain-api/deploy/linux/smoke_test.py | 103 + domain-api/deploy/systemd/domain-api.service | 18 + .../deploy/systemd/domain-worker.service | 16 + domain-api/requirements.txt | 8 + domain-web/.env.example | 2 + domain-web/.env.production.example | 2 + domain-web/README.md | 60 + domain-web/deploy/linux/publish.sh | 24 + domain-web/deploy/nginx/domain-web.conf | 20 + domain-web/index.html | 12 + domain-web/package-lock.json | 2194 +++++++++++++++++ domain-web/package.json | 24 + domain-web/src/App.vue | 3 + domain-web/src/api/http.ts | 25 + domain-web/src/api/modules.ts | 61 + domain-web/src/components/PageCard.vue | 49 + domain-web/src/layouts/MainLayout.vue | 259 ++ domain-web/src/main.ts | 13 + domain-web/src/router/index.ts | 47 + domain-web/src/stores/auth.ts | 44 + domain-web/src/styles/main.scss | 29 + domain-web/src/views/auth/LoginView.vue | 109 + .../src/views/dashboard/DashboardView.vue | 160 ++ domain-web/src/views/detect/DetectView.vue | 183 ++ domain-web/src/views/domains/DomainsView.vue | 309 +++ domain-web/src/views/imports/ImportsView.vue | 189 ++ .../src/views/settings/SettingsView.vue | 399 +++ domain-web/src/vite-env.d.ts | 1 + domain-web/tsconfig.json | 21 + domain-web/vite.config.ts | 41 + domainCheck | 1 + package_domain_release.ps1 | 192 ++ prepare_final_release.ps1 | 35 + show_latest_release.ps1 | 24 + smoke_test_stack.ps1 | 16 + start_domain_api.ps1 | 36 + start_domain_stack.ps1 | 7 + start_domain_web.ps1 | 13 + start_domain_web_background.ps1 | 19 + stop_domain_api.ps1 | 22 + stop_domain_stack.ps1 | 7 + stop_domain_web.ps1 | 25 + verify_domain_release.ps1 | 105 + 99 files changed, 9974 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/01_domainCheck_验收清单.md create mode 100644 docs/02_domainCheck_优化清单_待审核.md create mode 100644 docs/03_domainCheck_整改清单_外包版.md create mode 100644 docs/04_domainCheck_WebLinux改造总体方案.md create mode 100644 docs/05_domainCheck_Linux部署清单.md create mode 100644 docs/06_domainCheck_Web版当前完成度.md create mode 100644 docs/07_domainCheck_WebLinux发布验收清单.md create mode 100644 docs/08_domainCheck_交付说明.md create mode 100644 docs/09_domainCheck_交付打包说明.md create mode 100644 docs/10_domainCheck_最终交付结论.md create mode 100644 docs/11_domainCheck_Linux联调输入清单.md create mode 100644 docs/12_domainCheck_导航索引.md create mode 100644 docs/_tmp_regression/import_regression.txt create mode 100644 docs/_tmp_regression/regression_domains.txt create mode 100644 docs/_tmp_regression/regression_page1.csv create mode 100644 domain-api/.env.example create mode 100644 domain-api/README.md create mode 100644 domain-api/app/__init__.py create mode 100644 domain-api/app/api/__init__.py create mode 100644 domain-api/app/api/routes/__init__.py create mode 100644 domain-api/app/api/routes/auth.py create mode 100644 domain-api/app/api/routes/dashboard.py create mode 100644 domain-api/app/api/routes/detect.py create mode 100644 domain-api/app/api/routes/domains.py create mode 100644 domain-api/app/api/routes/exports.py create mode 100644 domain-api/app/api/routes/imports.py create mode 100644 domain-api/app/api/routes/logs.py create mode 100644 domain-api/app/api/routes/runtime.py create mode 100644 domain-api/app/api/routes/settings.py create mode 100644 domain-api/app/core/__init__.py create mode 100644 domain-api/app/core/config.py create mode 100644 domain-api/app/core/db.py create mode 100644 domain-api/app/core/files.py create mode 100644 domain-api/app/core/redis_client.py create mode 100644 domain-api/app/main.py create mode 100644 domain-api/app/schemas/__init__.py create mode 100644 domain-api/app/schemas/auth.py create mode 100644 domain-api/app/schemas/common.py create mode 100644 domain-api/app/services/__init__.py create mode 100644 domain-api/app/services/dashboard.py create mode 100644 domain-api/app/services/detect_service.py create mode 100644 domain-api/app/services/domains_service.py create mode 100644 domain-api/app/services/export_service.py create mode 100644 domain-api/app/services/import_task_service.py create mode 100644 domain-api/app/services/import_worker_service.py create mode 100644 domain-api/app/services/imports_service.py create mode 100644 domain-api/app/services/logs_service.py create mode 100644 domain-api/app/services/runtime_control_service.py create mode 100644 domain-api/app/services/runtime_settings_service.py create mode 100644 domain-api/app/services/runtime_status_service.py create mode 100644 domain-api/app/services/settings_service.py create mode 100644 domain-api/app/services/worker_control_service.py create mode 100644 domain-api/deploy/linux/README.md create mode 100644 domain-api/deploy/linux/collect_diagnostics.sh create mode 100644 domain-api/deploy/linux/smoke_test.py create mode 100644 domain-api/deploy/systemd/domain-api.service create mode 100644 domain-api/deploy/systemd/domain-worker.service create mode 100644 domain-api/requirements.txt create mode 100644 domain-web/.env.example create mode 100644 domain-web/.env.production.example create mode 100644 domain-web/README.md create mode 100644 domain-web/deploy/linux/publish.sh create mode 100644 domain-web/deploy/nginx/domain-web.conf create mode 100644 domain-web/index.html create mode 100644 domain-web/package-lock.json create mode 100644 domain-web/package.json create mode 100644 domain-web/src/App.vue create mode 100644 domain-web/src/api/http.ts create mode 100644 domain-web/src/api/modules.ts create mode 100644 domain-web/src/components/PageCard.vue create mode 100644 domain-web/src/layouts/MainLayout.vue create mode 100644 domain-web/src/main.ts create mode 100644 domain-web/src/router/index.ts create mode 100644 domain-web/src/stores/auth.ts create mode 100644 domain-web/src/styles/main.scss create mode 100644 domain-web/src/views/auth/LoginView.vue create mode 100644 domain-web/src/views/dashboard/DashboardView.vue create mode 100644 domain-web/src/views/detect/DetectView.vue create mode 100644 domain-web/src/views/domains/DomainsView.vue create mode 100644 domain-web/src/views/imports/ImportsView.vue create mode 100644 domain-web/src/views/settings/SettingsView.vue create mode 100644 domain-web/src/vite-env.d.ts create mode 100644 domain-web/tsconfig.json create mode 100644 domain-web/vite.config.ts create mode 160000 domainCheck create mode 100644 package_domain_release.ps1 create mode 100644 prepare_final_release.ps1 create mode 100644 show_latest_release.ps1 create mode 100644 smoke_test_stack.ps1 create mode 100644 start_domain_api.ps1 create mode 100644 start_domain_stack.ps1 create mode 100644 start_domain_web.ps1 create mode 100644 start_domain_web_background.ps1 create mode 100644 stop_domain_api.ps1 create mode 100644 stop_domain_stack.ps1 create mode 100644 stop_domain_web.ps1 create mode 100644 verify_domain_release.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..842a5e3 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b182c1 --- /dev/null +++ b/README.md @@ -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/` +- 其他运行生成文件 diff --git a/docs/01_domainCheck_验收清单.md b/docs/01_domainCheck_验收清单.md new file mode 100644 index 0000000..05d6fe7 --- /dev/null +++ b/docs/01_domainCheck_验收清单.md @@ -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 导出和人工规则脚本化 + diff --git a/docs/02_domainCheck_优化清单_待审核.md b/docs/02_domainCheck_优化清单_待审核.md new file mode 100644 index 0000000..15dfc5b --- /dev/null +++ b/docs/02_domainCheck_优化清单_待审核.md @@ -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`:已按你的备注暂缓未审核项,不影响当前主线联调 diff --git a/docs/03_domainCheck_整改清单_外包版.md b/docs/03_domainCheck_整改清单_外包版.md new file mode 100644 index 0000000..aca24b1 --- /dev/null +++ b/docs/03_domainCheck_整改清单_外包版.md @@ -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 导出正确性 + +--- + +## 七、外包方回复建议格式 + +请外包方按以下格式逐项回复: + +- `问题编号` +- `是否认可` +- `是否已整改` +- `整改说明` +- `涉及文件` +- `预计完成时间` + +--- + +## 八、当前阶段结论 + +当前版本不建议直接整体验收通过。 +建议以本清单为基础,由外包方完成一类问题整改后,再进入下一轮正式复测。 diff --git a/docs/04_domainCheck_WebLinux改造总体方案.md b/docs/04_domainCheck_WebLinux改造总体方案.md new file mode 100644 index 0000000..af9600d --- /dev/null +++ b/docs/04_domainCheck_WebLinux改造总体方案.md @@ -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 双模式并行一段时间 + +这是当前成本、风险、可维护性三者之间最平衡的方案。 diff --git a/docs/05_domainCheck_Linux部署清单.md b/docs/05_domainCheck_Linux部署清单.md new file mode 100644 index 0000000..0389acc --- /dev/null +++ b/docs/05_domainCheck_Linux部署清单.md @@ -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 下日志路径与权限 +- 真实代理池在国内服务器上的表现 +- 大批量导入与导出任务表现 diff --git a/docs/06_domainCheck_Web版当前完成度.md b/docs/06_domainCheck_Web版当前完成度.md new file mode 100644 index 0000000..cbee79f --- /dev/null +++ b/docs/06_domainCheck_Web版当前完成度.md @@ -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 方案已经不是概念原型,而是进入了“可交付、可联调、可继续迁移”的阶段。 diff --git a/docs/07_domainCheck_WebLinux发布验收清单.md b/docs/07_domainCheck_WebLinux发布验收清单.md new file mode 100644 index 0000000..5730fda --- /dev/null +++ b/docs/07_domainCheck_WebLinux发布验收清单.md @@ -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 部署环境已达到可联调状态 +- 可以进入真实服务器联调或灰度上线阶段 diff --git a/docs/08_domainCheck_交付说明.md b/docs/08_domainCheck_交付说明.md new file mode 100644 index 0000000..da18c6f --- /dev/null +++ b/docs/08_domainCheck_交付说明.md @@ -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 实机联调 +- 上线前复测 +- 真实服务器灰度验证 diff --git a/docs/09_domainCheck_交付打包说明.md b/docs/09_domainCheck_交付打包说明.md new file mode 100644 index 0000000..579f611 --- /dev/null +++ b/docs/09_domainCheck_交付打包说明.md @@ -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` 用于记录最近一次“打包 + 验包”的最终状态。 diff --git a/docs/10_domainCheck_最终交付结论.md b/docs/10_domainCheck_最终交付结论.md new file mode 100644 index 0000000..b5731aa --- /dev/null +++ b/docs/10_domainCheck_最终交付结论.md @@ -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. 进入灰度上线 + +## 六、最终判断 + +当前这套项目,已经达到“工程交付完成,待真实环境上线联调”的状态。 diff --git a/docs/11_domainCheck_Linux联调输入清单.md b/docs/11_domainCheck_Linux联调输入清单.md new file mode 100644 index 0000000..6130b7d --- /dev/null +++ b/docs/11_domainCheck_Linux联调输入清单.md @@ -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 环境里的诊断包或上述输出给我,我就可以直接进入最后的线上联调阶段,不需要重新铺背景。 diff --git a/docs/12_domainCheck_导航索引.md b/docs/12_domainCheck_导航索引.md new file mode 100644 index 0000000..44d1301 --- /dev/null +++ b/docs/12_domainCheck_导航索引.md @@ -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 环境: + +- 部署 +- 联调 +- 灰度上线 diff --git a/docs/_tmp_regression/import_regression.txt b/docs/_tmp_regression/import_regression.txt new file mode 100644 index 0000000..c9e646a --- /dev/null +++ b/docs/_tmp_regression/import_regression.txt @@ -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 \ No newline at end of file diff --git a/docs/_tmp_regression/regression_domains.txt b/docs/_tmp_regression/regression_domains.txt new file mode 100644 index 0000000..b80d504 --- /dev/null +++ b/docs/_tmp_regression/regression_domains.txt @@ -0,0 +1,2 @@ +codex-regression-20260416-a.com +codex-regression-20260416-b.net diff --git a/docs/_tmp_regression/regression_page1.csv b/docs/_tmp_regression/regression_page1.csv new file mode 100644 index 0000000..ce1d44a --- /dev/null +++ b/docs/_tmp_regression/regression_page1.csv @@ -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 diff --git a/domain-api/.env.example b/domain-api/.env.example new file mode 100644 index 0000000..ce30220 --- /dev/null +++ b/domain-api/.env.example @@ -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 diff --git a/domain-api/README.md b/domain-api/README.md new file mode 100644 index 0000000..c1f556f --- /dev/null +++ b/domain-api/README.md @@ -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` diff --git a/domain-api/app/__init__.py b/domain-api/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/domain-api/app/__init__.py @@ -0,0 +1 @@ + diff --git a/domain-api/app/api/__init__.py b/domain-api/app/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/domain-api/app/api/__init__.py @@ -0,0 +1 @@ + diff --git a/domain-api/app/api/routes/__init__.py b/domain-api/app/api/routes/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/domain-api/app/api/routes/__init__.py @@ -0,0 +1 @@ + diff --git a/domain-api/app/api/routes/auth.py b/domain-api/app/api/routes/auth.py new file mode 100644 index 0000000..c4dda6c --- /dev/null +++ b/domain-api/app/api/routes/auth.py @@ -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, + }, + } + ) diff --git a/domain-api/app/api/routes/dashboard.py b/domain-api/app/api/routes/dashboard.py new file mode 100644 index 0000000..cc1198d --- /dev/null +++ b/domain-api/app/api/routes/dashboard.py @@ -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()) diff --git a/domain-api/app/api/routes/detect.py b/domain-api/app/api/routes/detect.py new file mode 100644 index 0000000..24f46a6 --- /dev/null +++ b/domain-api/app/api/routes/detect.py @@ -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, + }, + ) diff --git a/domain-api/app/api/routes/domains.py b/domain-api/app/api/routes/domains.py new file mode 100644 index 0000000..bf0511a --- /dev/null +++ b/domain-api/app/api/routes/domains.py @@ -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) diff --git a/domain-api/app/api/routes/exports.py b/domain-api/app/api/routes/exports.py new file mode 100644 index 0000000..0b1e21a --- /dev/null +++ b/domain-api/app/api/routes/exports.py @@ -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) diff --git a/domain-api/app/api/routes/imports.py b/domain-api/app/api/routes/imports.py new file mode 100644 index 0000000..f3f1e66 --- /dev/null +++ b/domain-api/app/api/routes/imports.py @@ -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) diff --git a/domain-api/app/api/routes/logs.py b/domain-api/app/api/routes/logs.py new file mode 100644 index 0000000..087437a --- /dev/null +++ b/domain-api/app/api/routes/logs.py @@ -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") diff --git a/domain-api/app/api/routes/runtime.py b/domain-api/app/api/routes/runtime.py new file mode 100644 index 0000000..236f640 --- /dev/null +++ b/domain-api/app/api/routes/runtime.py @@ -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) diff --git a/domain-api/app/api/routes/settings.py b/domain-api/app/api/routes/settings.py new file mode 100644 index 0000000..1397980 --- /dev/null +++ b/domain-api/app/api/routes/settings.py @@ -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) diff --git a/domain-api/app/core/__init__.py b/domain-api/app/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/domain-api/app/core/__init__.py @@ -0,0 +1 @@ + diff --git a/domain-api/app/core/config.py b/domain-api/app/core/config.py new file mode 100644 index 0000000..1236b93 --- /dev/null +++ b/domain-api/app/core/config.py @@ -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() diff --git a/domain-api/app/core/db.py b/domain-api/app/core/db.py new file mode 100644 index 0000000..bce16b5 --- /dev/null +++ b/domain-api/app/core/db.py @@ -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() diff --git a/domain-api/app/core/files.py b/domain-api/app/core/files.py new file mode 100644 index 0000000..f83e2db --- /dev/null +++ b/domain-api/app/core/files.py @@ -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}" diff --git a/domain-api/app/core/redis_client.py b/domain-api/app/core/redis_client.py new file mode 100644 index 0000000..e0ebc08 --- /dev/null +++ b/domain-api/app/core/redis_client.py @@ -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, + ) diff --git a/domain-api/app/main.py b/domain-api/app/main.py new file mode 100644 index 0000000..89b5b87 --- /dev/null +++ b/domain-api/app/main.py @@ -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) diff --git a/domain-api/app/schemas/__init__.py b/domain-api/app/schemas/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/domain-api/app/schemas/__init__.py @@ -0,0 +1 @@ + diff --git a/domain-api/app/schemas/auth.py b/domain-api/app/schemas/auth.py new file mode 100644 index 0000000..188a8bd --- /dev/null +++ b/domain-api/app/schemas/auth.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class LoginRequest(BaseModel): + username: str + password: str diff --git a/domain-api/app/schemas/common.py b/domain-api/app/schemas/common.py new file mode 100644 index 0000000..ab3f490 --- /dev/null +++ b/domain-api/app/schemas/common.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel +from typing import Any + + +class ApiResponse(BaseModel): + code: int = 0 + message: str = "ok" + data: Any = None diff --git a/domain-api/app/services/__init__.py b/domain-api/app/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/domain-api/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/domain-api/app/services/dashboard.py b/domain-api/app/services/dashboard.py new file mode 100644 index 0000000..e3ee8fa --- /dev/null +++ b/domain-api/app/services/dashboard.py @@ -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 diff --git a/domain-api/app/services/detect_service.py b/domain-api/app/services/detect_service.py new file mode 100644 index 0000000..867dcfc --- /dev/null +++ b/domain-api/app/services/detect_service.py @@ -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, + } diff --git a/domain-api/app/services/domains_service.py b/domain-api/app/services/domains_service.py new file mode 100644 index 0000000..1affcdb --- /dev/null +++ b/domain-api/app/services/domains_service.py @@ -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()), + } diff --git a/domain-api/app/services/export_service.py b/domain-api/app/services/export_service.py new file mode 100644 index 0000000..435ba43 --- /dev/null +++ b/domain-api/app/services/export_service.py @@ -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 diff --git a/domain-api/app/services/import_task_service.py b/domain-api/app/services/import_task_service.py new file mode 100644 index 0000000..bf4ce35 --- /dev/null +++ b/domain-api/app/services/import_task_service.py @@ -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 diff --git a/domain-api/app/services/import_worker_service.py b/domain-api/app/services/import_worker_service.py new file mode 100644 index 0000000..0bce79f --- /dev/null +++ b/domain-api/app/services/import_worker_service.py @@ -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) diff --git a/domain-api/app/services/imports_service.py b/domain-api/app/services/imports_service.py new file mode 100644 index 0000000..8a117b4 --- /dev/null +++ b/domain-api/app/services/imports_service.py @@ -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, + } diff --git a/domain-api/app/services/logs_service.py b/domain-api/app/services/logs_service.py new file mode 100644 index 0000000..edf1d91 --- /dev/null +++ b/domain-api/app/services/logs_service.py @@ -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 diff --git a/domain-api/app/services/runtime_control_service.py b/domain-api/app/services/runtime_control_service.py new file mode 100644 index 0000000..c385d1a --- /dev/null +++ b/domain-api/app/services/runtime_control_service.py @@ -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, + } diff --git a/domain-api/app/services/runtime_settings_service.py b/domain-api/app/services/runtime_settings_service.py new file mode 100644 index 0000000..7005e43 --- /dev/null +++ b/domain-api/app/services/runtime_settings_service.py @@ -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 diff --git a/domain-api/app/services/runtime_status_service.py b/domain-api/app/services/runtime_status_service.py new file mode 100644 index 0000000..6dfefa3 --- /dev/null +++ b/domain-api/app/services/runtime_status_service.py @@ -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, + } diff --git a/domain-api/app/services/settings_service.py b/domain-api/app/services/settings_service.py new file mode 100644 index 0000000..a3588f3 --- /dev/null +++ b/domain-api/app/services/settings_service.py @@ -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 diff --git a/domain-api/app/services/worker_control_service.py b/domain-api/app/services/worker_control_service.py new file mode 100644 index 0000000..e31278c --- /dev/null +++ b/domain-api/app/services/worker_control_service.py @@ -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 "检测端已停止" diff --git a/domain-api/deploy/linux/README.md b/domain-api/deploy/linux/README.md new file mode 100644 index 0000000..1bdb6c6 --- /dev/null +++ b/domain-api/deploy/linux/README.md @@ -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=...` + +把生成的归档包发回即可继续排障。 diff --git a/domain-api/deploy/linux/collect_diagnostics.sh b/domain-api/deploy/linux/collect_diagnostics.sh new file mode 100644 index 0000000..a789077 --- /dev/null +++ b/domain-api/deploy/linux/collect_diagnostics.sh @@ -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" diff --git a/domain-api/deploy/linux/smoke_test.py b/domain-api/deploy/linux/smoke_test.py new file mode 100644 index 0000000..a010497 --- /dev/null +++ b/domain-api/deploy/linux/smoke_test.py @@ -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()) diff --git a/domain-api/deploy/systemd/domain-api.service b/domain-api/deploy/systemd/domain-api.service new file mode 100644 index 0000000..7e7e8a2 --- /dev/null +++ b/domain-api/deploy/systemd/domain-api.service @@ -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 diff --git a/domain-api/deploy/systemd/domain-worker.service b/domain-api/deploy/systemd/domain-worker.service new file mode 100644 index 0000000..8441558 --- /dev/null +++ b/domain-api/deploy/systemd/domain-worker.service @@ -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 diff --git a/domain-api/requirements.txt b/domain-api/requirements.txt new file mode 100644 index 0000000..335c08a --- /dev/null +++ b/domain-api/requirements.txt @@ -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 diff --git a/domain-web/.env.example b/domain-web/.env.example new file mode 100644 index 0000000..74d451c --- /dev/null +++ b/domain-web/.env.example @@ -0,0 +1,2 @@ +VITE_APP_TITLE=domainCheck 管理后台 +VITE_API_BASE_URL=http://127.0.0.1:8100/api/v1 diff --git a/domain-web/.env.production.example b/domain-web/.env.production.example new file mode 100644 index 0000000..9b459bf --- /dev/null +++ b/domain-web/.env.production.example @@ -0,0 +1,2 @@ +VITE_APP_TITLE=domainCheck 管理后台 +VITE_API_BASE_URL=https://your-domain.example.com/api/v1 diff --git a/domain-web/README.md b/domain-web/README.md new file mode 100644 index 0000000..428536e --- /dev/null +++ b/domain-web/README.md @@ -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` diff --git a/domain-web/deploy/linux/publish.sh b/domain-web/deploy/linux/publish.sh new file mode 100644 index 0000000..842946b --- /dev/null +++ b/domain-web/deploy/linux/publish.sh @@ -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" diff --git a/domain-web/deploy/nginx/domain-web.conf b/domain-web/deploy/nginx/domain-web.conf new file mode 100644 index 0000000..4a12f06 --- /dev/null +++ b/domain-web/deploy/nginx/domain-web.conf @@ -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; + } +} diff --git a/domain-web/index.html b/domain-web/index.html new file mode 100644 index 0000000..77dfa88 --- /dev/null +++ b/domain-web/index.html @@ -0,0 +1,12 @@ + + + + + + domainCheck 管理后台 + + +
+ + + diff --git a/domain-web/package-lock.json b/domain-web/package-lock.json new file mode 100644 index 0000000..2397212 --- /dev/null +++ b/domain-web/package-lock.json @@ -0,0 +1,2194 @@ +{ + "name": "domain-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "domain-web", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.13.7", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.7.tgz", + "integrity": "sha512-XdHATFZOyzVFL1DaHQ90IOJQSg9UnSAV+bhDW+YB5UoZ0Hxs50mwqjqfwXkuwpSag+VXXizVcErBR6Movo5daw==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/sass": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", + "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", + "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/domain-web/package.json b/domain-web/package.json new file mode 100644 index 0000000..a785e62 --- /dev/null +++ b/domain-web/package.json @@ -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" + } +} diff --git a/domain-web/src/App.vue b/domain-web/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/domain-web/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/domain-web/src/api/http.ts b/domain-web/src/api/http.ts new file mode 100644 index 0000000..ffc7c74 --- /dev/null +++ b/domain-web/src/api/http.ts @@ -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; diff --git a/domain-web/src/api/modules.ts b/domain-web/src/api/modules.ts new file mode 100644 index 0000000..fac5a3c --- /dev/null +++ b/domain-web/src/api/modules.ts @@ -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) => http.put("/settings", payload), + exportSettings: () => http.get("/settings/export"), + importSettings: (payload: Record) => http.post("/settings/import", payload), + validateImportSettings: (payload: Record) => 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) => http.get("/domains", { params }), + batchUpdate: (payload: Record) => http.post("/domains/batch-update", payload) +}; + +export const exportsApi = { + list: () => http.get("/exports"), + run: (payload: Record) => 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` +}; diff --git a/domain-web/src/components/PageCard.vue b/domain-web/src/components/PageCard.vue new file mode 100644 index 0000000..cddabde --- /dev/null +++ b/domain-web/src/components/PageCard.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/domain-web/src/layouts/MainLayout.vue b/domain-web/src/layouts/MainLayout.vue new file mode 100644 index 0000000..a901ebe --- /dev/null +++ b/domain-web/src/layouts/MainLayout.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/domain-web/src/main.ts b/domain-web/src/main.ts new file mode 100644 index 0000000..896b962 --- /dev/null +++ b/domain-web/src/main.ts @@ -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"); diff --git a/domain-web/src/router/index.ts b/domain-web/src/router/index.ts new file mode 100644 index 0000000..77fd42b --- /dev/null +++ b/domain-web/src/router/index.ts @@ -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; diff --git a/domain-web/src/stores/auth.ts b/domain-web/src/stores/auth.ts new file mode 100644 index 0000000..881145e --- /dev/null +++ b/domain-web/src/stores/auth.ts @@ -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)); + } + } +}); diff --git a/domain-web/src/styles/main.scss b/domain-web/src/styles/main.scss new file mode 100644 index 0000000..0cfa0d4 --- /dev/null +++ b/domain-web/src/styles/main.scss @@ -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; +} diff --git a/domain-web/src/views/auth/LoginView.vue b/domain-web/src/views/auth/LoginView.vue new file mode 100644 index 0000000..07096d8 --- /dev/null +++ b/domain-web/src/views/auth/LoginView.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/domain-web/src/views/dashboard/DashboardView.vue b/domain-web/src/views/dashboard/DashboardView.vue new file mode 100644 index 0000000..4de90a6 --- /dev/null +++ b/domain-web/src/views/dashboard/DashboardView.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/domain-web/src/views/detect/DetectView.vue b/domain-web/src/views/detect/DetectView.vue new file mode 100644 index 0000000..d7e1b0f --- /dev/null +++ b/domain-web/src/views/detect/DetectView.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/domain-web/src/views/domains/DomainsView.vue b/domain-web/src/views/domains/DomainsView.vue new file mode 100644 index 0000000..fdb956a --- /dev/null +++ b/domain-web/src/views/domains/DomainsView.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/domain-web/src/views/imports/ImportsView.vue b/domain-web/src/views/imports/ImportsView.vue new file mode 100644 index 0000000..dd3e9ed --- /dev/null +++ b/domain-web/src/views/imports/ImportsView.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/domain-web/src/views/settings/SettingsView.vue b/domain-web/src/views/settings/SettingsView.vue new file mode 100644 index 0000000..29a8822 --- /dev/null +++ b/domain-web/src/views/settings/SettingsView.vue @@ -0,0 +1,399 @@ + + + + + diff --git a/domain-web/src/vite-env.d.ts b/domain-web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/domain-web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/domain-web/tsconfig.json b/domain-web/tsconfig.json new file mode 100644 index 0000000..22d7967 --- /dev/null +++ b/domain-web/tsconfig.json @@ -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"] +} diff --git a/domain-web/vite.config.ts b/domain-web/vite.config.ts new file mode 100644 index 0000000..e763785 --- /dev/null +++ b/domain-web/vite.config.ts @@ -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" + } +}); diff --git a/domainCheck b/domainCheck new file mode 160000 index 0000000..83ab7a7 --- /dev/null +++ b/domainCheck @@ -0,0 +1 @@ +Subproject commit 83ab7a79e817762ec2c4d6f08a3d3e3baafb3376 diff --git a/package_domain_release.ps1 b/package_domain_release.ps1 new file mode 100644 index 0000000..02aac8f --- /dev/null +++ b/package_domain_release.ps1 @@ -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" diff --git a/prepare_final_release.ps1 b/prepare_final_release.ps1 new file mode 100644 index 0000000..ec7af8a --- /dev/null +++ b/prepare_final_release.ps1 @@ -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)" diff --git a/show_latest_release.ps1 b/show_latest_release.ps1 new file mode 100644 index 0000000..b82f339 --- /dev/null +++ b/show_latest_release.ps1 @@ -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 diff --git a/smoke_test_stack.ps1 b/smoke_test_stack.ps1 new file mode 100644 index 0000000..8b14784 --- /dev/null +++ b/smoke_test_stack.ps1 @@ -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 diff --git a/start_domain_api.ps1 b/start_domain_api.ps1 new file mode 100644 index 0000000..51d99ee --- /dev/null +++ b/start_domain_api.ps1 @@ -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" diff --git a/start_domain_stack.ps1 b/start_domain_stack.ps1 new file mode 100644 index 0000000..2d49d3a --- /dev/null +++ b/start_domain_stack.ps1 @@ -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 栈已启动' diff --git a/start_domain_web.ps1 b/start_domain_web.ps1 new file mode 100644 index 0000000..8601a99 --- /dev/null +++ b/start_domain_web.ps1 @@ -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 diff --git a/start_domain_web_background.ps1 b/start_domain_web_background.ps1 new file mode 100644 index 0000000..4ea29f7 --- /dev/null +++ b/start_domain_web_background.ps1 @@ -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" diff --git a/stop_domain_api.ps1 b/stop_domain_api.ps1 new file mode 100644 index 0000000..95a2ec3 --- /dev/null +++ b/stop_domain_api.ps1 @@ -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)) diff --git a/stop_domain_stack.ps1 b/stop_domain_stack.ps1 new file mode 100644 index 0000000..d1eaae0 --- /dev/null +++ b/stop_domain_stack.ps1 @@ -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 栈已停止' diff --git a/stop_domain_web.ps1 b/stop_domain_web.ps1 new file mode 100644 index 0000000..22b0369 --- /dev/null +++ b/stop_domain_web.ps1 @@ -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)) diff --git a/verify_domain_release.ps1 b/verify_domain_release.ps1 new file mode 100644 index 0000000..872b021 --- /dev/null +++ b/verify_domain_release.ps1 @@ -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 +}