From 4ab3f93bebeaec32d95161a2b91de09869b5c3f9 Mon Sep 17 00:00:00 2001 From: xuans Date: Fri, 2 Oct 2026 20:46:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=A0=A1=E9=AA=8C=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=B9=82=E7=AD=89=E9=94=AE=E5=B9=B6=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=8F=8C=E7=89=88=E6=9C=AC=E6=95=B0=E6=8D=AE=E5=BA=93=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/docs/ai/async-tasks/api-handoff.md | 21 ++++++++++++-- doc/Linux生产流程测试记录.md | 24 ++++++++++++++++ doc/Scert整改任务执行清单.md | 15 ++++++---- doc/Scert整改合并优化清单.md | 12 +++++++- frontend/src/views/Dashboard/Dashboard.js | 3 +- .../src/views/Dashboard/TaskMonitor.test.js | 28 ++++++++++++++++++- 6 files changed, 92 insertions(+), 11 deletions(-) diff --git a/.claude/docs/ai/async-tasks/api-handoff.md b/.claude/docs/ai/async-tasks/api-handoff.md index 6144696..c3f1c52 100644 --- a/.claude/docs/ai/async-tasks/api-handoff.md +++ b/.claude/docs/ai/async-tasks/api-handoff.md @@ -4,7 +4,7 @@ ## 业务范围 -证书申请、当前证书部署、证书导入及申请/部署手动重试先持久化任务,再交给后台工作线程执行。浏览器断线不取消已接收工作,前端通过任务接口确认最终结果。提交成功、证书保存成功、逐服务器推送成功、整体任务成功是不同层级,不能混用。 +证书申请、当前证书部署、证书导入、本地证书校验及申请/部署手动重试先持久化任务,再交给后台工作线程执行。浏览器断线不取消已接收工作,前端通过任务接口确认最终结果。提交成功、证书保存成功、本地校验成功、逐服务器推送成功、整体任务成功是不同层级,不能混用。 ## 接口与权限 @@ -15,12 +15,14 @@ | POST /api/v1/domains/:id/issue | 提交申请及关联部署,无请求体 | 可见域名、证书管理、部署执行、DNS 凭据使用权限;202 | | POST /api/v1/domains/:id/deploy | 提交已有证书部署,无请求体 | 可见域名、部署执行权限;202 | | POST /api/v1/domains/:id/certificate/import | 上传证书后提交导入及部署任务 | 可见域名、证书管理、部署执行权限;不要求 DNS 凭据权限;202 | +| POST /api/v1/domains/:id/certificate/validate | 提交当前本地证书校验,无请求体 | 可见域名、私钥读取权限;不额外要求证书管理、DNS 或部署执行权限;202 | | POST /api/v1/tasks/:id/retry | 同一任务新增一次尝试,无请求体 | 重试、证书管理、部署执行权限;申请类还检查 DNS 凭据;202 | | DELETE /api/v1/tasks/:id | 请求取消 | 任务可见及取消权限;后台任务返回 202,非异步历史任务可能返回 200 | | GET /api/v1/tasks | 当前身份可见任务列表 | 支持 domain_id 和 limit;返回 200 与 items 数组 | | GET /api/v1/tasks/:id | 单个任务最新状态 | 返回 200 与任务对象本身,不包 task 字段 | | GET /api/v1/tasks/:id/attempts | 任务尝试历史 | 返回 200 与 items 数组 | | GET /api/v1/tasks/:id/deployment-results | 逐尝试的逐服务器结果与完整报告 | 返回 200、items 和 reports 数组;按任务、最近尝试编号及尝试 ID 精确匹配 | +| GET /api/v1/tasks/:id/validation-results | 读取本地校验公开元数据 | 任务读取权限;返回 200 与 items 数组,仓储查询实时复核可见范围 | 列表 limit 默认 50,有效范围为 1~200,越界由仓储回退至 50。不存在和无权访问的任务都返回 403,不能根据接口泄露其他项目资源是否存在。 @@ -56,7 +58,7 @@ ### 幂等与错误 -- 首次申请和部署支持 Idempotency-Key:长度 8~128,只允许英文字母、数字、连字符、下划线及英文句点。前端当前为每次明确操作生成新键,网络重发若需要去重必须复用原键。 +- 首次申请、部署、导入和校验支持 Idempotency-Key:长度 8~128,只允许英文字母、数字、连字符、下划线及英文句点。校验前端使用 certificate-validate 前缀,不将接口路径中的斜线写入请求头。前端当前为每次明确操作生成新键,网络重发若需要去重必须复用原键。 - 同一域名、同一键命中已结束任务返回 200、task 和 replayed: true;命中活动任务返回 409、task 和 replayed: true,不再次执行。跨操作复用同一键返回 409。 - 同类活动任务、仍在收尾、成功任务重试或不支持的任务类型返回 409。部署的 partial_success 允许重试;申请类只允许 failed 或 cancelled。 - 取消 202 只说明接收取消意图,状态为 cancelling;不能提前显示已取消或允许重试。已经完成的外部操作仍可最终成功,并提示取消未撤销该操作。 @@ -71,6 +73,7 @@ | TaskAttempt | id、task_id、attempt_number、stage、status、message、scope_type、project_id、created_at;可选 started_at、finished_at | | TaskDeploymentResult | task_id、attempt_id、attempt_number、result_index、server_id、server、status、message、created_at;可选 backup_id | | TaskDeploymentReport | task_id、attempt_id、attempt_number、report、created_at;report 保留汇总、逐目标结果、verification_status 与 recovery_status | +| TaskValidationResult | task_id、attempt_id、attempt_number、validation、created_at;validation 包含 domains、serial_number、fingerprint_sha256、not_before、not_after、chain_length | 编号和时间为字符串,时间使用带时区的 RFC3339 表示;attempt_number 与 result_index 为整数。Task 与 TaskAttempt 的状态含义如下: @@ -119,6 +122,17 @@ deployment-results 响应保留旧 items,新增 reports;每次尝试独立 ## 执行及前端交互边界 +### 本地证书校验 + +- 提交返回 202,task_type 为 certificate_validate。提交与取得执行槽之后均复核私钥读取权限和域名归属;响应断开不取消已接收工作,不返回证书或私钥路径。 +- 只有成功校验才保存 validation;失败、排队取消、关停、执行异常不生成通过结果。校验结果、任务、尝试和完成审计共用一个事务,不生成空的部署报告。 +- validation 中 domains 为字符串数组,serial_number 和 fingerprint_sha256 为字符串,not_before 和 not_after 为带时区时间,chain_length 为整数。结果读取形式为 items 数组,每条记录关联原 task_id、attempt_id 和 attempt_number。 +- 前端仅为校验任务额外读取 validation-results;任务与最近一次尝试均为 success,且三个关联字段全部一致时展示结果。切换、关闭详情和撤权后清除缓存;历史成功任务没有详情时明确说明未留存,不推断当前证书仍然有效。 +- 结果只证明执行时的本地证书、私钥匹配及信任链校验,不代表远端部署、HTTPS 或生产验收通过。 +- 校验不接入通用手动重试入口,POST /tasks/:id/retry 返回 409。用户需要重新校验时,应从域名校验入口明确创建新任务。 + +### 公共执行约束 + - 当前后台最多接收 32 个在途任务,4 个执行槽,总执行预算 30 分钟;这些是实现限制,不是保证耗时。 - 队列满或关闭时拒绝执行,并尝试保存失败终态;持久化失败保留中断状态,不伪造已完成。 - 服务关闭停止接收、发送取消信号并等待工作线程收尾。启动恢复将中断记录标为失败,不自动重放外部签发、部署。 @@ -153,5 +167,6 @@ deployment-results 响应保留旧 items,新增 reports;每次尝试独立 - [x] HTTP 异步完成审计、逐服务器结果与任务终态同事务,已覆盖三类保存失败整体回滚;其他工作流及三库实机复审仍待完成。 - [x] 后台证书共用执行器新增五处故障回滚、每次重试报告、等待取消及无活动尝试收尾回归;应用、仓储、HTTP 三包原生 Linux 测试、全后端测试及 go vet 均通过,未正式部署。 - [x] 生产导入入口及前端任务跟踪已接入异步执行;专项回归通过,未正式部署。 -- [ ] 版本恢复、校验及手动续期等其他长操作尚未全部异步化;当前历史版本恢复的真实实现仍拒绝恢复,不能用模拟工作流测试替代功能验收。 +- [x] 校验入口、独立结果持久化和详情展示已接入异步任务;前端幂等键契约缺陷已先复现后修复,最新 266 项测试及生产构建通过,尚未正式部署。 +- [ ] 版本恢复及手动续期等其他长操作尚未全部异步化;当前历史版本恢复的真实实现仍拒绝恢复,不能用模拟工作流测试替代功能验收。 - [ ] 正式构建部署后验证超过反代等待窗口的真实操作、断线查询、取消回滚和服务重启;本说明不替代生产验收。 diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index fdf6f0c..d78c518 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,29 @@ # Linux 生产流程测试记录 +## 2026年10月2日20:34 证书校验后台任务及幂等契约回归(未部署) + +### 代码与隔离边界 + +- [x] 接续 fce5a84 校验开发批次,修复前精确提交推送 24a9683;没有暂存测试归档、日志、密钥或凭据。本批只使用原生 Go/前端用例,没有创建辅助验收脚本。 +- [x] 最终后端增量 validation-async-second-20261002.tar 的本机与双机 SHA256 均为 3f6d01008441f2053b7098732bf622aab16b6d9a34ee513967ff1deaa437227b。第一台额外使用 fce5a84 源码基线归档,SHA256 为 a82b68293c7ce1d0a8155e08164a3a6ad2b6ec57df8465731da1a6fad673aca2;均只包含源码,不是正式发布包。 +- [x] 密码 SSH/SCP 沿用严格主机密钥校验,未启用公钥登录。第二台复用 /var/tmp/scert-async-20261002-1556/source/backend;第一台使用 /var/tmp/scert-report-race-20261002-1801/validation-source/backend。测试以 nobody 执行,GOMAXPROCS=1、GOMEMLIMIT=384MiB、-p 1。 + +### 后端与实库证据 + +- [!] validation-second-full.log:第一次全量测试在 SSH 被重置后收到 Hangup;重连确认没有测试进程才重新启动。此记录不记为通过,未覆盖原始日志。 +- [x] 第二台 validation-final-full-vet.log:go test -p 1 ./... -count=1 与 go vet -p 1 ./... 完成,VALIDATION_FINAL_FULL_EXIT=0、VALIDATION_FINAL_VET_EXIT=0。外部实库默认跳过,不把此结果当成三库完整验收。 +- [x] 第二台 validation-final-repeat.log:TestAsyncValidation、TestAsyncExecutionRechecksQueuedAuthorization、TestTaskValidation 重复二十次通过,VALIDATION_FINAL_REPEAT_EXIT=0;HTTP 包 158.647 秒,仓储包 16.395 秒。覆盖独立结果、无伪报告、失败审计原子回滚、撤权与排队取消等路径,不替代竞争检测。 +- [x] 第一台 validation-mysql21.log:既有 /usr/sbin/mysqld 8.0.46 启动独立空实例,仅监听 127.0.0.1:42647,严格 TLS。TestMySQLRealTaskValidationMigrationRecovery 八场景通过,VALIDATION_MYSQL21_EXIT=0,45.369 秒。 +- [x] 第一台 validation-mysql57.log:复用既有 MySQL 5.7.44 程序,独立空实例仅监听 127.0.0.1:44333,严格 TLS;同一八场景通过,VALIDATION_MYSQL57_EXIT=0,21.575 秒。覆盖建表前后恢复、已有详情保持,以及字段容量、主键顺序、空值、引擎和额外字段冲突拒绝。 +- [x] 两个独立 MySQL 测试实例均已退出;正式 MySQL 进程仍为 1092。第一台 Scert、Nginx、MySQL、Xmail 和第二台 Scert、Nginx、MySQL 均 active。第二台磁盘剩余约 774MB,未在该机新增数据库实例或擅自清理产物。 + +### 前端证据与未关闭项 + +- [x] .runtime/validation-async-frontend-second-20261002.log:上一轮组件注册修复后 263 项通过,生产构建通过。但审查随后发现校验幂等键含斜线,旧测试只判断请求头存在,未覆盖后端合法字符契约。 +- [x] .runtime/validation-idempotency-contract-before-20261002.log:补充断言后四个相关测试中两个失败,明确复现 certificate/validate 前缀问题;申请、部署对照用例通过。修复使用 certificate-validate 前缀,没有放宽后端输入限制。 +- [x] .runtime/validation-async-frontend-final-20261002.log:最终 266 项全部通过,零失败、零跳过;.runtime/validation-async-build-final-20261002.log:最终生产构建退出零。新详情组件包含真实 Vue 模板编译和服务端渲染回归,不称为浏览器实机验证。 +- [!] 未替换线上程序、未清空线上数据库、未新增公网监听。本批不是正式包或生产部署验收;PostgreSQL 实库、三库首次 Web、历史恢复、其他长操作、升级、通知重置、备份恢复和完整生产门禁仍未关闭。 + ## 2026年10月2日19:34 证书导入异步自动回归(未部署) - [x] 修改前确认 main 工作区干净,09616e7 基线推送返回已同步。第二台复用已核验主机指纹的密码 SSH 会话;隔离路径 /var/tmp/scert-async-20261002-1556/source/backend,不触碰正式安装或线上数据。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 5d50539..0e21d79 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,14 +1,19 @@ # Scert 整改任务执行清单 -## 2026年10月2日 证书校验后台任务执行计划 +## 2026年10月2日20:34 证书校验后台任务回归完成,等待复审 本批需求沿用已确认的 P0-10、P1-04、P2-03 清单:先开发和自动回归,再统一生产部署;不处理 Windows。修改前已提交并推送 fce5a84。 -- [~] 校验入口接入持久化后台任务;提交和执行前均检查现有私钥读取权限,不误加部署或 DNS 权限。 -- [ ] 校验元数据独立持久化,与任务、尝试和审计终态同事务提交;不伪装部署报告,不保存文件路径或私钥。 -- [ ] 前端跟踪校验任务,在任务详情展示校验域名、指纹、有效期、序列号和链长度;刷新与撤权保持真实状态。 -- [ ] 补充异步生命周期、事务故障回滚、实时权限和前端回归;核对迁移兼容性并更新接口说明及测试记录。 +- [x] 20:25 补充审查发现前端校验幂等键含路径分隔符。提交推送 24a9683 修复前基线后,新增契约断言先复现两个失败,再改用 certificate-validate 前缀;保留后端字符与长度限制。申请、部署、校验三种真实组合式提交均验证每次新操作使用独立合法键。 + +- [x] 校验入口接入持久化后台任务;提交和执行前均检查现有私钥读取权限,不误加部署、证书管理或 DNS 权限。队列、取消、关停和异常回收复用公共工作线程,不重复创建任务。 +- [x] 校验元数据独立持久化,与任务、尝试和审计终态同事务提交;失败或取消不保存通过结果,不伪装部署报告,不保存文件路径或私钥。新增三库第 21 号迁移。 +- [x] 前端跟踪校验任务,在任务详情展示校验域名、指纹、有效期、序列号和链长度;按任务及最近成功尝试精确匹配,切换、关闭及撤权清空缓存,明确本地校验不等于远端 HTTPS 验收。 +- [x] 第二台原生 Linux 全后端测试及 go vet 通过;校验生命周期、排队授权和仓储事务专项重复二十次通过。首次全量测试因 SSH 断开收到 Hangup,确认进程退出后重新执行,未覆盖失败日志。 +- [x] 第一台独立 MySQL 8.0.46 与 5.7.44 的第 21 号迁移各通过八个恢复/冲突场景;只监听回环,严格 TLS,测试后独立实例退出。此结果不替代 PostgreSQL 实库或三库首次 Web 流程。 +- [x] 最新前端 266 项全部通过、零跳过,生产构建通过;真实模板编译与服务端渲染覆盖新结果组件。已同步本清单、整改清单、Linux 测试记录及异步接口说明。 - [!] 本批通过不关闭历史恢复、其他长操作、升级、通知重置、三库、备份恢复及最终生产门禁。 +- [!] 本批未正式部署、未清理线上数据库、未创建辅助验收脚本;竞争检测仍无本批通过证据。第二台磁盘约剩 774MB,后续正式构建前必须重新评估容量,不据此擅自删除文件。 ## 2026年10月2日19:34 证书导入异步化回归完成,等待复审 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 38cae37..27ad75b 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,5 +1,15 @@ # Scert 整改、合并与优化清单 +## 2026年10月2日20:34 当前开发进度:证书校验后台化完成待复审 + +当前开发批次对应 P0-10/P1-04/P2-03;不是仍停留在 P0-03 的代码开发,也不代表这些整项已经复审通过。下方带时间的记录保留为历史证据,最新状态以本入口和执行清单为准。原 P0/P1/P2/P3 范围不缩减,Windows 按本轮要求不处理。 + +- [x] 校验生产入口返回持久化后台任务,复用排队、取消、关停及执行前实时权限;只保留原有私钥读取权限要求,不误加 DNS、证书管理或部署权限。 +- [x] 三库第 21 号迁移及独立公开校验结果存储已实现;成功结果、任务、尝试和审计同事务,不暴露私钥路径,不将校验伪装成部署报告。 +- [x] 前端读取校验详情,按最近成功尝试展示域名、指纹、有效期等信息;补齐前端幂等键非法斜线缺陷,先复现再修复。最新前端 266 项及生产构建通过。 +- [x] 原生 Linux 全后端、go vet 及相关专项二十次重复回归通过;第一台独立 MySQL 8.0.46、5.7.44 均通过新增迁移的八类恢复与冲突场景,未触碰线上数据库。 +- [!] 尚未正式部署或进行浏览器生产验收;历史版本恢复、手动续期等长操作、升级重启 502、通知重置、三库首次 Web、备份恢复、监控及 P3 继续保留。普通重复回归不替代竞争检测。 + ## 2026年10月2日19:34 证书导入异步化开发进度 - [x] P2-03 子项:生产导入入口返回后台任务,复用任务队列、实时权限复核及事务收尾,不重复创建任务。上传文件在请求结束后保留到工作线程完成,并覆盖拒绝、取消、关停及异常回收。 @@ -1757,7 +1767,7 @@ cancelled 已取消 **状态:** `[~] 进行中` -**2026年10月2日19:34 子项进度:** 申请、当前证书部署、申请/部署重试及生产证书导入已接入 HTTP 异步任务;导入增加上传生命周期、执行前授权、幂等找回和保存结果持久化,自动回归通过待复审。校验、历史恢复及其余列出的长操作不因本批通过而关闭,整项继续进行中。 +**2026年10月2日20:34 子项进度:** 申请、当前证书部署、申请/部署重试、生产证书导入及本地证书校验已接入 HTTP 异步任务;导入具有上传生命周期和保存标志,校验具有独立元数据及最近尝试详情,均复核执行前权限。最新自动回归通过待复审,尚未统一生产部署;手动续期、历史恢复及其余列出的长操作不因本批通过而关闭,整项继续进行中。 以下操作不能长期阻塞 HTTP 请求: diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index 389b371..b874e00 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -590,7 +590,8 @@ export function useDashboard() { const options = { method: "POST" }; if (action === "issue" || action === "deploy" || action === "certificate/validate") { const suffix = crypto.randomUUID ? crypto.randomUUID() : createRandomSuffix(); - options.headers = { "Idempotency-Key": `${action}-${item.id}-${suffix}` }; + const prefix = action === "certificate/validate" ? "certificate-validate" : action; + options.headers = { "Idempotency-Key": `${prefix}-${item.id}-${suffix}` }; } const response = await fetchAdmin(`/api/v1/domains/${item.id}/${action}`, options); const result = await response.json().catch(() => ({})); diff --git a/frontend/src/views/Dashboard/TaskMonitor.test.js b/frontend/src/views/Dashboard/TaskMonitor.test.js index d3ae797..e66b2ea 100644 --- a/frontend/src/views/Dashboard/TaskMonitor.test.js +++ b/frontend/src/views/Dashboard/TaskMonitor.test.js @@ -41,7 +41,7 @@ test("校验任务提交后读取独立详情,页面重开可恢复,撤权 const { state } = await mountComposable(t, useDashboard, async (path, options = {}) => { if (path.endsWith("/certificate/validate")) { submitted++; - assert.ok(options.headers["Idempotency-Key"]); + assert.match(options.headers["Idempotency-Key"], /^[A-Za-z0-9._-]{8,128}$/); return Response.json({ task, accepted: true }, { status: 202 }); } if (path === `/api/v1/tasks/${task.id}`) return Response.json(task); @@ -59,6 +59,7 @@ test("校验任务提交后读取独立详情,页面重开可恢复,撤权 }); await state.validateDomain({ id: "domain-1", name: "example.test" }); assert.equal(submitted, 1); + assert.equal(state.error.value, ""); assert.equal(state.showTaskDetails.value, true); assert.match(state.notice.value, /任务已提交/); assert.equal(state.taskValidationResult.value, null); @@ -158,6 +159,31 @@ test("完整报告无逐目标结果仍可读取,执行成功不能覆盖任 assert.equal(latestDeploymentReport(initialTask, attempts, [], reports), null); }); +for (const [method, type, prefix] of [ + ["issueDomain", "certificate_issue", "issue"], + ["deployDomain", "certificate_deploy", "deploy"], + ["validateDomain", "certificate_validate", "certificate-validate"], +]) { + test(`域名操作 ${prefix} 生成符合后端契约且每次独立的幂等键`, async (t) => { + const posts = []; + const item = { id: "2fd1f484-0adc-4a14-a592-fd9342dc5720", name: "example.test" }; + const task = { ...initialTask, task_type: type, domain_id: item.id }; + const { state } = await mountComposable(t, useDashboard, + taskFetcher(() => task, (options) => posts.push(options))); + await state[method](item); + await state[method](item); + assert.equal(posts.length, 2); + const keys = posts.map((options) => options.headers["Idempotency-Key"]); + for (const key of keys) { + assert.match(key, /^[A-Za-z0-9._-]{8,128}$/); + assert.ok(key.startsWith(`${prefix}-${item.id}-`)); + } + assert.notEqual(keys[0], keys[1]); + assert.equal(state.error.value, ""); + assert.match(state.notice.value, /任务已提交/); + }); +} + function taskFetcher(readTask, onPost = () => {}) { return async (path, options = {}) => { const task = readTask();