From f17ea6c99e3bd4472813853294347bdfad0b8b17 Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 4 Oct 2026 07:41:18 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=89=A7=E8=A1=8C=E7=A7=9F?= =?UTF-8?q?=E7=BA=A6=E9=85=8D=E7=BD=AE=E5=BF=AB=E7=85=A7=E4=B8=8E=E5=BA=95?= =?UTF-8?q?=E5=B1=82=E5=9F=9F=E5=90=8D=E4=BF=9D=E6=8A=A4=E5=B9=B6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../httpapi/domain_dns_execution_test.go | 54 +++++++++++++++++++ backend/internal/httpapi/server.go | 8 +++ .../repository/dns_execution_repository.go | 18 +++++-- .../dns_execution_repository_test.go | 54 +++++++++++++++++++ .../repository/sql_domain_repository.go | 10 ++++ doc/DNS多账户整改任务清单.md | 22 ++++++-- doc/Linux生产流程测试记录.md | 9 ++++ doc/Scert开发任务清单.md | 7 +++ doc/Scert整改任务执行清单.md | 7 +++ doc/Scert整改合并优化清单.md | 9 ++++ doc/核心能力任务规格.md | 8 +++ 11 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 backend/internal/httpapi/domain_dns_execution_test.go diff --git a/backend/internal/httpapi/domain_dns_execution_test.go b/backend/internal/httpapi/domain_dns_execution_test.go new file mode 100644 index 0000000..78b05aa --- /dev/null +++ b/backend/internal/httpapi/domain_dns_execution_test.go @@ -0,0 +1,54 @@ +package httpapi + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +func TestDomainDNSExecutionLeaseMutationConflict(t *testing.T) { + f, account := newDomainDNSAPIFixture(t) + f.server.projectDomains = f.members + if _, err := f.members.SetDomainDNSAccountBinding(t.Context(), "manager", "domain-a", account.ID, "", account.Revision); err != nil { + t.Fatal(err) + } + item, err := repository.NewSQLDomainRepository(f.db).GetByID(t.Context(), "domain-a") + if err != nil { + t.Fatal(err) + } + tasks := repository.NewSQLTaskRepository(f.db) + identity := domain.DNSExecutionIdentity{TaskID: domain.NewID(), AttemptID: domain.NewID(), AttemptNumber: 1} + if err := tasks.CreateTask(t.Context(), repository.Task{ID: identity.TaskID, DomainID: item.ID, TaskType: "certificate_issue", Status: "running", Stage: "certificate_issue", OperatorID: "manager"}); err != nil { + t.Fatal(err) + } + if err := tasks.CreateAttempt(t.Context(), repository.TaskAttempt{ID: identity.AttemptID, TaskID: identity.TaskID, AttemptNumber: 1, Status: "running", Stage: "certificate_issue"}); err != nil { + t.Fatal(err) + } + lease, err := f.members.AcquireDNSExecutionLease(t.Context(), item, identity) + if err != nil { + t.Fatal(err) + } + // 任务已终态也不能越过尚未清理的租约。 + if _, err := f.db.ExecContext(t.Context(), `UPDATE tasks SET status = 'failed' WHERE id = ?`, identity.TaskID); err != nil { + t.Fatal(err) + } + body := fmt.Sprintf(`{"name":%q,"certificate_type":%q,"dns_provider":%q,"challenge_type":"dns-01"}`, item.Name, item.CertificateType, item.DNSProvider) + for _, method := range []string{http.MethodPut, http.MethodDelete} { + response := serveUserRequest(f.server, f.cookies["manager"], method, "/api/v1/domains/"+item.ID, body) + if response.Code != http.StatusConflict { + t.Fatal("活动租约应返回冲突而非内部错误", method, response.Code, response.Body.String()) + } + for _, secret := range []string{lease.ID, account.CredentialRef, "credential_ref"} { + if secret != "" && strings.Contains(response.Body.String(), secret) { + t.Fatal("冲突响应泄露租约或凭据引用") + } + } + } + if _, err := repository.NewSQLDomainRepository(f.db).GetByID(t.Context(), item.ID); err != nil { + t.Fatal("冲突请求不能删除域名", err) + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index b483d0d..ea9ac95 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -721,6 +721,10 @@ func (s *Server) updateDomain(c *gin.Context) { item.Status = "pending" item.ExpiresAt = nil if err := s.projectDomains.UpdateProjectDomain(c.Request.Context(), c.GetString(authenticatedActorKey), item); err != nil { + if errors.Is(err, repository.ErrDNSAccountBusy) { + s.writeError(c, http.StatusConflict, repository.ErrDNSAccountBusy.Error(), nil) + return + } if errors.Is(err, repository.ErrProjectAccessDenied) { s.writeError(c, http.StatusForbidden, "无权管理此项目的域名", nil) return @@ -773,6 +777,10 @@ func (s *Server) deleteDomain(c *gin.Context) { } domainID := strings.TrimSpace(c.Param("id")) if err := s.projectDomains.DeleteProjectDomain(c.Request.Context(), c.GetString(authenticatedActorKey), domainID); err != nil { + if errors.Is(err, repository.ErrDNSAccountBusy) { + s.writeError(c, http.StatusConflict, repository.ErrDNSAccountBusy.Error(), nil) + return + } if errors.Is(err, repository.ErrProjectAccessDenied) { s.writeError(c, http.StatusForbidden, "无权管理此项目的域名", nil) return diff --git a/backend/internal/repository/dns_execution_repository.go b/backend/internal/repository/dns_execution_repository.go index 87272fb..8167d2a 100644 --- a/backend/internal/repository/dns_execution_repository.go +++ b/backend/internal/repository/dns_execution_repository.go @@ -3,7 +3,9 @@ package repository import ( "context" "database/sql" + "encoding/json" "errors" + "slices" "time" "git.pcux.cn/xuans/Scert/backend/internal/domain" @@ -37,11 +39,21 @@ func (r *SQLProjectMemberRepository) AcquireDNSExecutionLease(ctx context.Contex return domain.DNSExecutionLease{}, err } // 域名行锁与资料更新共用,拒绝授权前后被换掉的签发快照。 - var updated time.Time - if err := tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT updated_at FROM domains WHERE id = ?`), item.ID).Scan(&updated); err != nil { + var stored domain.Domain + var sansJSON string + if err := tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT name, certificate_type, sans_json, auto_renew, + acme_environment, dns_provider, challenge_type, updated_at FROM domains WHERE id = ?`), item.ID). + Scan(&stored.Name, &stored.CertificateType, &sansJSON, &stored.AutoRenew, &stored.ACMEEnvironment, + &stored.DNSProvider, &stored.ChallengeType, &stored.UpdatedAt); err != nil { return domain.DNSExecutionLease{}, err } - if !updated.Equal(item.UpdatedAt) { + // 时间戳可能只有秒精度,必须核对实际配置;SAN 按解码后的数组比较。 + if json.Unmarshal([]byte(sansJSON), &stored.SANs) != nil || + !stored.UpdatedAt.Equal(item.UpdatedAt) || stored.Name != item.Name || + stored.CertificateType != item.CertificateType || !slices.Equal(stored.SANs, item.SANs) || + stored.AutoRenew != item.AutoRenew || stored.ACMEEnvironment != item.ACMEEnvironment || + stored.DNSProvider != item.DNSProvider || stored.ChallengeType != item.ChallengeType || + stored.DNSProvider != account.Provider { return domain.DNSExecutionLease{}, ErrDNSExecutionInvalid } lease.Revision, lease.Provider, lease.CredentialRef = account.Revision, account.Provider, account.CredentialRef diff --git a/backend/internal/repository/dns_execution_repository_test.go b/backend/internal/repository/dns_execution_repository_test.go index 20089ac..16e29fa 100644 --- a/backend/internal/repository/dns_execution_repository_test.go +++ b/backend/internal/repository/dns_execution_repository_test.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "encoding/json" "errors" "path/filepath" "sync" @@ -128,12 +129,20 @@ func TestDNSExecutionLeaseRepository(t *testing.T) { return err }, func() error { return f.repo.UpdateProjectDomain(t.Context(), "lease-root", f.item) }, + func() error { return NewSQLDomainRepository(db.DB, driver).Update(t.Context(), f.item) }, + func() error { return NewSQLDomainRepository(db.DB, driver).Delete(t.Context(), f.item.ID) }, } { if err := mutate(); !errors.Is(err, ErrDNSAccountBusy) { t.Fatal("活动租约未阻止变更", err) } } f.exec(t, `UPDATE tasks SET status = 'failed' WHERE id = ?`, f.identity.TaskID) + if err := NewSQLDomainRepository(db.DB, driver).UpdateCertificateState(t.Context(), f.item.ID, "failed", nil); err != nil { + t.Fatal("活动租约不能阻止证书状态更新", err) + } + if err := NewSQLDomainRepository(db.DB, driver).Delete(t.Context(), f.item.ID); !errors.Is(err, ErrDNSAccountBusy) { + t.Fatal("底层删除不能绕过终态任务的遗留租约", err) + } if err := f.repo.DeleteProjectDomain(t.Context(), "lease-root", f.item.ID); !errors.Is(err, ErrDNSAccountBusy) { t.Fatal("终态任务仍有租约,不能删除域名", err) } @@ -157,6 +166,51 @@ func TestDNSExecutionLeaseRepository(t *testing.T) { if _, err := f.repo.SetDNSAccountEnabled(t.Context(), "lease-root", f.account.ID, f.account.Revision, false); err != nil { t.Fatal("正常释放后应允许禁用", err) } + if err := NewSQLDomainRepository(db.DB, driver).Update(t.Context(), f.item); err != nil { + t.Fatal("正常释放后应允许资料更新", err) + } + if err := NewSQLDomainRepository(db.DB, driver).Delete(t.Context(), f.item.ID); err != nil { + t.Fatal("正常释放后应允许底层删除", err) + } + }) + for _, scenario := range []struct { + name, column string + value any + }{ + {"name", "name", "changed.example.test"}, + {"type", "certificate_type", "san"}, + {"sans", "sans_json", `["other.example.test"]`}, + {"renewal", "auto_renew", false}, + {"environment", "acme_environment", "production"}, + {"provider", "dns_provider", "aliyun"}, + {"challenge", "challenge_type", "http-01"}, + } { + t.Run("same-timestamp-"+scenario.name, func(t *testing.T) { + f := newDNSLeaseFixture(t, db.DB, driver) + f.exec(t, `UPDATE domains SET `+scenario.column+` = ? WHERE id = ?`, scenario.value, f.item.ID) + if _, err := f.repo.AcquireDNSExecutionLease(t.Context(), f.item, f.identity); !errors.Is(err, ErrDNSExecutionInvalid) { + t.Fatal("同时间戳的旧配置快照未拒绝", err) + } + var count int + if err := db.DB.QueryRowContext(t.Context(), Rebind(driver, `SELECT COUNT(*) FROM dns_execution_leases WHERE domain_id = ?`), f.item.ID).Scan(&count); err != nil || count != 0 { + t.Fatal("无效快照遗留执行租约", count, err) + } + }) + } + t.Run("equivalent-sans-encoding", func(t *testing.T) { + f := newDNSLeaseFixture(t, db.DB, driver) + encoded, err := json.MarshalIndent(f.item.SANs, "", " ") + if err != nil { + t.Fatal(err) + } + f.exec(t, `UPDATE domains SET sans_json = ? WHERE id = ?`, string(encoded), f.item.ID) + lease, err := f.repo.AcquireDNSExecutionLease(t.Context(), f.item, f.identity) + if err != nil { + t.Fatal("相同 SAN 不应因 JSON 格式而被拒绝", err) + } + if err := f.repo.ReleaseDNSExecutionLease(t.Context(), lease.ID); err != nil { + t.Fatal(err) + } }) t.Run("atomic-initial-binding", func(t *testing.T) { f := newDNSLeaseFixture(t, db.DB, driver) diff --git a/backend/internal/repository/sql_domain_repository.go b/backend/internal/repository/sql_domain_repository.go index 7b9b974..644f6aa 100644 --- a/backend/internal/repository/sql_domain_repository.go +++ b/backend/internal/repository/sql_domain_repository.go @@ -72,6 +72,9 @@ func (r *SQLDomainRepository) Update(ctx context.Context, item domain.Domain) er if err := validateBoundDomainDNS(ctx, tx, r.driver, item, challengeType); err != nil { return err } + if err := NewSQLProjectMemberRepository(r.db, r.driver).rejectActiveDNSLease(ctx, tx, "domain_id", item.ID); err != nil { + return err + } _, err = tx.ExecContext(ctx, r.query(`UPDATE domains SET name = ?, certificate_type = ?, sans_json = ?, auto_renew = ?, acme_environment = ?, dns_provider = ?, updated_at = ?, updated_by = ?, challenge_type = ? WHERE id = ? AND project_id = ?`), item.Name, item.CertificateType, string(payload), item.AutoRenew, item.ACMEEnvironment, @@ -125,6 +128,13 @@ func (r *SQLDomainRepository) Delete(ctx context.Context, id string) error { _ = tx.Rollback() return cause } + var storedID string + if err := tx.QueryRowContext(ctx, r.query(`SELECT id FROM domains WHERE id = ?`+taskResourceWriteLock(r.driver)), id).Scan(&storedID); err != nil { + return rollback(err) + } + if err := NewSQLProjectMemberRepository(r.db, r.driver).rejectActiveDNSLease(ctx, tx, "domain_id", id); err != nil { + return rollback(err) + } if _, err := tx.ExecContext(ctx, r.query("DELETE FROM domain_servers WHERE domain_id = ?"), id); err != nil { return rollback(fmt.Errorf("删除域名服务器关联失败:%w", err)) } diff --git a/doc/DNS多账户整改任务清单.md b/doc/DNS多账户整改任务清单.md index ccfaa7e..987bf2b 100644 --- a/doc/DNS多账户整改任务清单.md +++ b/doc/DNS多账户整改任务清单.md @@ -1,14 +1,26 @@ # DNS 多账户整改任务清单 +## 2026年10月4日07:30 执行租约边界复核 + +沿用已确认的 Linux 与 Web 整改范围。修改前检查点 `7a3b78d` 已提交推送,未跟踪文件 `=` 保留。本批不操作第二台业务、不干扰浏览器发信配置。 + +- [x] 对比完整签发配置,拒绝时间戳相同但域名、SAN、证书类型、环境等资料已经变化的旧快照;SAN 按解码数组比较。 +- [x] 底层域名更新和删除已补齐同一域名锁及活动租约保护,任务终态也不能绕过;证书状态更新仍正常进行,释放后可更新、删除。 +- [x] 域名更新、删除遇到活动租约返回 409,接口回归验证不泄露租约标识或凭据引用。 +- [✓] 本地五包全量及 vet 通过;仓储租约专项十轮、原子创建和租约冲突接口五轮通过。前端重新执行全量 339 项及构建通过。MySQL、PostgreSQL 本地跳过,不算实库证据。 +- [~] 待提交干净源码并在第一台执行 Linux 全量与三库专项;本轮新迁移及租约尚未获得实库证据。 + +首轮新增 SAN 等价编码测试失败,原因是夹具单域名 SAN 实际包含主域名,不能改为空数组后声称等价;现已使用原 SAN 的缩进 JSON,保留实际语义。未据测试错误放宽产品校验。 + ## 2026年10月4日07:01 执行租约接入任务 沿用已确认的独立账户完整生命周期需求,本批不处理 Windows、不变更正式配置。检查点 `73dba28` 已提交推送。 -- [~] 增加持久化执行租约,固定域名、绑定、账户版本、秘密引用及真实任务尝试;手工任务实时校验权限,自动续期必须核验真实调度身份。 -- [ ] 接通签发、续期和重试;独立账户不读取系统凭据,DNS 操作使用固定账户,取消后仍允许原凭据清理挑战记录。 -- [ ] 在活动租约期间拒绝账户变更和绑定切换;释放失败或挑战清理不确定时保留租约,不按时间到期猜测可以回收秘密。 -- [ ] 检查新建域名与初始账户绑定之间的执行窗口,避免自动任务使用历史系统账户。 -- [ ] 补齐迁移、权限、并发、取消和清理失败测试,再在第一台进行 Linux 与三库验证;更新相关清单和真实测试结果。 +- [x] 增加持久化执行租约,固定域名、绑定、账户版本、秘密引用及真实任务尝试;手工任务实时校验权限,自动续期必须核验真实调度身份。 +- [x] 签发、续期和重试执行器已接通租约;独立账户不读取系统凭据,DNS 操作使用固定账户,取消后仍允许原凭据清理挑战记录。真实 DNS/ACME 全流程待验收。 +- [x] 活动租约期间拒绝账户变更和绑定切换;释放失败或挑战清理不确定时保留租约,不按时间到期猜测可以回收秘密。 +- [x] 新建域名与初始账户绑定、审计已改为同一事务,前端一次提交,不再出现先创建后绑定窗口。 +- [~] 已补迁移、权限、并发、取消和清理失败测试,本地五包与前端通过;第一台 Linux 与三库复审待执行。 租约记录是不可变执行快照,不随任务历史清理级联删除。崩溃遗留租约的外部 TXT 核对及安全回收仍属完整 P1-03 门禁,不能仅凭任务终态或租约年龄释放。 diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index d8e057b..6b13a67 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,14 @@ # Linux 生产流程测试记录 +## 2026年10月4日07:40 执行租约复审准备 + +- 修改前检查点 `7a3b78d` 已提交推送,之后补齐完整配置快照、底层域名租约保护及接口冲突回归。 +- 本地 `go test ./internal/repository ./internal/certificate ./internal/database ./internal/httpapi ./internal/app` 和同范围 vet 通过;仓储 8.790 秒、HTTP 26.759 秒、app 11.943 秒,certificate/database 为缓存结果。 +- 租约专项十轮通过,原子创建及租约冲突接口专项五轮通过;前端重新执行 339 项,零失败零跳过,构建 103 模块通过。 +- 新增 SAN 等价编码用例首轮失败,已纠正夹具保持实际 SAN 后通过;这是测试数据错误,产品未放宽校验。本地 MySQL、PostgreSQL 未启用而跳过,不计三库证据。 +- 第一台 SSH 于 07:37 实时验证可用;前轮 `scert-dns-ui-20261004-0630.service` 为 inactive/dead、success、退出零。本轮尚未运行远端测试,不复用前轮结果冒充新代码通过。 +- 本批未清理正式数据库、未升级正式服务、未改域名或发信配置、未操作第二台。后续用第一台隔离源码及数据库验证,不创建辅助验收脚本。 + ## 2026年10月4日06:51 DNS 页面权限干净源码复审 - 第一台 `8.137.23.95`,源码 `/var/tmp/scert-dns-ui-20261004-0630/source`,真实 Git 干净签出 `412c51f5f0cf466f9d1ff01a09e23dd02e116385`;运行后工作区仍干净。 diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 2028de0..551e68d 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -1,5 +1,12 @@ # Scert 开发任务清单 +## 2026年10月4日07:40 DNS 执行链进展 + +- [x] 独立账户执行租约已接通证书任务与自动续期执行器,重试依真实尝试序号重新授权,不允许系统凭据回退。 +- [x] 新建域名首次绑定原子保存;完整配置快照、底层写入租约保护及 409 响应完成编码。 +- [✓] 本地五包全量与 vet、仓储十轮和接口五轮专项、前端 339 项及构建通过。MySQL/PostgreSQL 本地跳过,不能作为实库结果。 +- [~] 第一台干净源码复审待执行;历史导入、遗留清理核对、安全引用回收、真实签发续期和生产流程仍未完成,P1-03 保持进行中。 + ## 2026年10月4日06:51 开发与复审进展 - [x] 独立 DNS 账户选择已接入域名新建和编辑,显示服务商及归属;显式解除、实时绑定读取、版本冲突、跨项目过滤和部分成功恢复完成编码。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 8461239..f46d650 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,12 @@ # Scert 整改任务执行清单 +## 2026年10月4日07:40 执行租约边界复核 + +- [x] 完成租约迁移二十九、真实任务授权、固定凭据引用、取消清理及活动引用保护;新建域名与首次绑定改为原子事务。 +- [x] 完整签发配置比较避免同秒修改漏检,底层更新/删除加锁并拒绝活动租约;接口忙状态映射 409。 +- [✓] 本地五包全量与 vet、租约十轮及接口五轮专项、前端 339 项和构建通过;首轮 SAN 测试夹具错误已修正并保留失败说明。 +- [~] 第一台 Linux 与三库本轮实测待执行;真实签发、续期、重试、遗留租约核对、安全回收、旧账户导入和最终生产验收未完成。 + ## 2026年10月4日06:51 DNS 域名配置接入 - [✓] 第一台 `412c51f` 干净源码全量、vet 和三库目录及接口单轮竞态通过,`0630` 单元退出零;不是三轮结果。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 9f948eb..9fb59b3 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,5 +1,14 @@ # Scert 整改、合并与优化清单 +## 2026年10月4日07:40 执行租约与原子创建进度 + +- [x] P1-03 已接入真实任务尝试的 DNS 执行租约,固定账户版本及秘密引用;取消后使用原凭据清理,清理或释放不确定则保留引用。未实现的崩溃遗留核对与安全回收仍是门禁。 +- [x] 创建域名、初始 DNS 绑定及审计原子保存;前端一次创建并核对返回绑定,不再先创建再绑定。完整配置快照比较及底层更新、删除租约保护已补齐,忙状态接口返回 409。 +- [✓] 本地五包全量、vet、租约十轮专项、接口五轮专项通过;前端再次全量 339 项与构建通过。本地实库未启用,跳过不计三库结果。 +- [~] 待第一台干净源码 Linux 全量及三库租约、迁移、竞态实测;旧账户导入、安全回收、真实签发续期、浏览器、邮件及生产门禁尚未关闭。 + +第一台主程序测试、第二台仅证书推送的分工不变。本批未升级正式服务、清生产库或改 DNS;不处理 Windows,域名登录页保留给用户配置发信。 + ## 2026年10月4日06:51 最新执行进度 - [✓] 第一台干净 `412c51f` Linux 全量、vet 和单轮三库项目目录及 HTTP 权限竞态全部结束通过;`0630` 单元 success、退出零,PostgreSQL 新增目录用例实际执行通过。 diff --git a/doc/核心能力任务规格.md b/doc/核心能力任务规格.md index 9319866..e3b1e2c 100644 --- a/doc/核心能力任务规格.md +++ b/doc/核心能力任务规格.md @@ -1,5 +1,13 @@ # 核心能力任务规格 +## 2026年10月4日07:40 DNS 执行与原子绑定契约 + +- 新建域名可携带 `dns_account_id`、`dns_account_revision`;绑定标识由服务端生成,域名、绑定、审计共用事务。普通资料更新拒绝这些初始绑定字段,编辑仍使用独立绑定接口及真实旧令牌。 +- 租约绑定真实运行中的任务、尝试、序号、项目、域名、账户版本及秘密引用。自动续期必须同时满足服务端调度标记、真实无操作者续期任务和域名启用续期;手工重试额外要求重试权限。 +- 获取租约在域名行锁内比较完整签发配置,不依赖秒级时间戳;SAN 按实际数组比较。活动租约阻止账户变更、绑定切换及两层域名资料更新/删除,证书状态更新不受影响。 +- DNS 写入前重新授权,清理使用原 Provider 和固定凭据、独立超时;任务取消不得跳过清理。不按 TTL 或任务终态释放未知外部状态的引用,释放失败保持租约。 +- 接口租约忙返回 409,不暴露随机租约能力或秘密引用。遗留租约外部核对与安全回收尚未实现;当前契约的本地测试不能替代真实 DNS/ACME 及三库生产验收。 + ## 2026年10月4日06:51 域名 DNS 账户交互契约 - 新建及编辑域名读取 `/api/v1/dns/accounts`;编辑还读取 `/api/v1/domains/:id/dns-account`,列表缓存不能替代真实绑定令牌。