diff --git a/backend/internal/database/domain_dns_account_migration_test.go b/backend/internal/database/domain_dns_account_migration_test.go index 8e2ce76..b52268d 100644 --- a/backend/internal/database/domain_dns_account_migration_test.go +++ b/backend/internal/database/domain_dns_account_migration_test.go @@ -145,13 +145,24 @@ func checkDomainDNSConstraints(t *testing.T, db *sql.DB, driver string) { func TestMySQLDomainDNSAccountInterruptedMigration(t *testing.T) { f := startRealMySQL(t) - for _, boundary := range []string{"before_create", "after_create", "after_index", "after_domain_fk", "after_account_fk", "with_binding", "bad_column", "bad_index", "bad_foreign_key", "extra_foreign_key"} { + for _, boundary := range []string{"before_create", "after_create", "after_index", "after_domain_fk", "after_account_fk", "with_binding", "changed_schema_default", "bad_column", "bad_column_collation", "bad_index", "bad_foreign_key", "extra_foreign_key"} { t.Run(boundary, func(t *testing.T) { db := f.database(t) mysqlPrepareVersion(t, db.DB, 27) statements := mysqlDeploymentStatements(t, 28) + if boundary == "changed_schema_default" { + var name string + if err := db.DB.QueryRowContext(t.Context(), `SELECT DATABASE()`).Scan(&name); err != nil || !mysqlCollationIdentifier.MatchString(name) { + t.Fatal("测试数据库标识无效", err) + } + mysqlDeploymentExec(t, db.DB, "ALTER DATABASE `"+name+"` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin") + } + create, _, err := mysqlDomainDNSCreateStatement(t.Context(), db.DB, statements[0]) + if err != nil { + t.Fatal(err) + } if boundary != "before_create" { - mysqlDeploymentExec(t, db.DB, statements[0]) + mysqlDeploymentExec(t, db.DB, create) } if boundary == "after_index" || boundary == "after_domain_fk" || boundary == "after_account_fk" || boundary == "with_binding" { mysqlDeploymentExec(t, db.DB, statements[1]) @@ -164,6 +175,8 @@ func TestMySQLDomainDNSAccountInterruptedMigration(t *testing.T) { } conflict := true switch boundary { + case "bad_column_collation": + mysqlDeploymentExec(t, db.DB, `ALTER TABLE domain_dns_accounts MODIFY COLUMN domain_id VARCHAR(36) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL`) case "bad_column": mysqlDeploymentExec(t, db.DB, `ALTER TABLE domain_dns_accounts MODIFY COLUMN id VARCHAR(36) NULL`) case "bad_index": diff --git a/backend/internal/database/mysql_deployment_migration.go b/backend/internal/database/mysql_deployment_migration.go index cabce41..d1cf8b2 100644 --- a/backend/internal/database/mysql_deployment_migration.go +++ b/backend/internal/database/mysql_deployment_migration.go @@ -50,10 +50,11 @@ type mysqlDeploymentColumn struct { } type mysqlDeploymentShape struct { - Table string - Collation string - Columns []mysqlDeploymentColumn - Indexes []string + Table string + Collation string + Columns []mysqlDeploymentColumn + Indexes []string + ColumnCollations map[string]string } var ( @@ -151,7 +152,11 @@ func validateMySQLDeploymentShape(ctx context.Context, db *sql.DB, expected mysq } column.Default = strings.TrimSuffix(strings.ToLower(defaultValue.String), "()") column.HasDefault = defaultValue.Valid - if columnCollation.Valid && columnCollation.String != tableCollation { + expectedColumnCollation := tableCollation + if override := expected.ColumnCollations[column.Name]; override != "" { + expectedColumnCollation = override + } + if columnCollation.Valid && columnCollation.String != expectedColumnCollation { rows.Close() return fmt.Errorf("部署迁移表字段排序规则不匹配:%s.%s", expected.Table, column.Name) } diff --git a/backend/internal/database/mysql_domain_dns_migration.go b/backend/internal/database/mysql_domain_dns_migration.go index fee9b6d..abbab66 100644 --- a/backend/internal/database/mysql_domain_dns_migration.go +++ b/backend/internal/database/mysql_domain_dns_migration.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "regexp" "strings" ) @@ -16,7 +17,12 @@ func applyMySQLDomainDNSMigration(ctx context.Context, db *sql.DB, version int, if err != nil { return err } - if _, err := db.ExecContext(ctx, strings.Replace(statements[0], "CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ", 1)); err != nil { + create, collation, err := mysqlDomainDNSCreateStatement(ctx, db, statements[0]) + if err != nil { + return err + } + shape.ColumnCollations = map[string]string{"domain_id": collation} + if _, err := db.ExecContext(ctx, strings.Replace(create, "CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ", 1)); err != nil { return err } if err := validateMySQLDeploymentShape(ctx, db, shape); err != nil { @@ -65,6 +71,27 @@ func applyMySQLDomainDNSMigration(ctx context.Context, db *sql.DB, version int, return err } +var mysqlCollationIdentifier = regexp.MustCompile(`^[a-zA-Z0-9_]+$`) + +// 历史域名主键继承建库时的排序规则,不能假定它与后来的项目标识相同。 +// 仅让新外键列匹配父列;不修改父表、数据库默认值或已有冲突表。 +func mysqlDomainDNSCreateStatement(ctx context.Context, db *sql.DB, statement string) (string, string, error) { + var columnType, charset, collation string + if err := db.QueryRowContext(ctx, `SELECT column_type, character_set_name, collation_name + FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'domains' AND column_name = 'id'`).Scan(&columnType, &charset, &collation); err != nil { + return "", "", err + } + if !strings.EqualFold(columnType, "varchar(36)") || !mysqlCollationIdentifier.MatchString(charset) || !mysqlCollationIdentifier.MatchString(collation) { + return "", "", fmt.Errorf("历史域名主键字符定义不受支持,拒绝修改现有结构") + } + const original = "domain_id VARCHAR(36) NOT NULL" + if strings.Count(statement, original) != 1 { + return "", "", fmt.Errorf("域名 DNS 绑定迁移主键定义不完整") + } + definition := "domain_id VARCHAR(36) CHARACTER SET " + charset + " COLLATE " + collation + " NOT NULL" + return strings.Replace(statement, original, definition, 1), collation, nil +} + func validateMySQLDomainDNSForeignKeys(ctx context.Context, db *sql.DB) (map[string]bool, error) { rows, err := db.QueryContext(ctx, `SELECT r.constraint_name, r.delete_rule, r.update_rule, k.column_name, k.referenced_table_schema, k.referenced_table_name, k.referenced_column_name, diff --git a/backend/internal/httpapi/domain_dns_account_api.go b/backend/internal/httpapi/domain_dns_account_api.go new file mode 100644 index 0000000..7442092 --- /dev/null +++ b/backend/internal/httpapi/domain_dns_account_api.go @@ -0,0 +1,83 @@ +package httpapi + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +func (s *Server) domainDNSRepository(c *gin.Context) (repository.DomainDNSAccountRepository, bool) { + repo, ok := s.projectMembers.(repository.DomainDNSAccountRepository) + if !ok { + s.writeError(c, http.StatusServiceUnavailable, "域名 DNS 账户绑定服务尚未就绪", nil) + return nil, false + } + if !domain.ValidProjectID(c.Param("id")) || c.Request.URL.RawQuery != "" { + s.writeError(c, http.StatusBadRequest, "域名 DNS 账户绑定参数无效", nil) + return nil, false + } + return repo, true +} + +func (s *Server) writeDomainDNSAccountError(c *gin.Context, err error) { + switch { + case errors.Is(err, repository.ErrProjectAccessDenied): + s.writeError(c, http.StatusForbidden, "无权操作此域名的 DNS 账户绑定", nil) + case errors.Is(err, repository.ErrDNSBindingConflict), errors.Is(err, repository.ErrDNSAccountConflict): + s.writeError(c, http.StatusConflict, "域名绑定或 DNS 账户已变更,请刷新后重试", nil) + case errors.Is(err, repository.ErrDNSBindingBusy): + s.writeError(c, http.StatusConflict, "域名存在未结束的任务,暂不能变更 DNS 账户", nil) + case errors.Is(err, repository.ErrDNSBindingInvalid): + s.writeError(c, http.StatusUnprocessableEntity, "域名验证方式或 DNS 账户状态不允许绑定", nil) + default: + // 驱动错误可能含内部标识;只返回固定提示,不记录原始错误。 + s.writeError(c, http.StatusServiceUnavailable, "保存或读取 DNS 账户绑定失败,请刷新后核对", nil) + } +} + +func (s *Server) getDomainDNSAccount(c *gin.Context) { + repo, ok := s.domainDNSRepository(c) + if !ok { + return + } + item, err := repo.GetDomainDNSAccountBinding(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id")) + if err != nil { + s.writeDomainDNSAccountError(c, err) + return + } + c.JSON(http.StatusOK, item) +} + +func (s *Server) setDomainDNSAccount(c *gin.Context) { + repo, ok := s.domainDNSRepository(c) + if !ok { + return + } + var input struct { + AccountID *string `json:"dns_account_id"` + ExpectedBindingID *string `json:"expected_binding_id"` + AccountRevision *int64 `json:"account_revision"` + } + // 三个字段必须显式提供,禁止遗漏字段或 null 被解释成解除已有绑定。 + if bindJSON(c, &input, jsonRequestBodyLimit) != nil || input.AccountID == nil || input.ExpectedBindingID == nil || input.AccountRevision == nil { + s.writeError(c, http.StatusBadRequest, "请明确提供 DNS 账户、当前绑定标识和账户版本", nil) + return + } + accountID, expectedID, revision := *input.AccountID, *input.ExpectedBindingID, *input.AccountRevision + if (expectedID != "" && !domain.ValidProjectID(expectedID)) || + (accountID == "" && revision != 0) || + (accountID != "" && (!domain.ValidProjectID(accountID) || revision < 1 || revision > 2147483647)) { + s.writeError(c, http.StatusBadRequest, "DNS 账户绑定标识或版本无效", nil) + return + } + item, err := repo.SetDomainDNSAccountBinding(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"), accountID, expectedID, revision) + if err != nil { + s.writeDomainDNSAccountError(c, err) + return + } + c.JSON(http.StatusOK, item) +} diff --git a/backend/internal/httpapi/domain_dns_account_api_test.go b/backend/internal/httpapi/domain_dns_account_api_test.go new file mode 100644 index 0000000..ca76e53 --- /dev/null +++ b/backend/internal/httpapi/domain_dns_account_api_test.go @@ -0,0 +1,188 @@ +package httpapi + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +func newDomainDNSAPIFixture(t *testing.T) (projectMemberAPIFixture, domain.DNSAccount) { + t.Helper() + f, _ := newProjectResourceAPIFixture(t) + item, err := f.members.CreateDNSAccount(t.Context(), "manager", domain.DNSAccount{ + ID: "account-a", ProjectID: "project-a", Name: "项目账户", Provider: "cloudflare", CredentialRef: "secret://dns/fixture-sensitive-token", + }) + if err != nil { + t.Fatal(err) + } + item, err = f.members.RecordDNSAccountVerification(t.Context(), "manager", item.ID, item.Revision, true) + if err != nil { + t.Fatal(err) + } + return f, item +} + +func domainDNSBody(accountID, expectedID string, revision int64) string { + return fmt.Sprintf(`{"dns_account_id":%q,"expected_binding_id":%q,"account_revision":%d}`, accountID, expectedID, revision) +} + +func domainDNSRequest(t *testing.T, f projectMemberAPIFixture, actor, method, path, body string, status int) domain.DNSAccountBinding { + t.Helper() + response := serveUserRequest(f.server, f.cookies[actor], method, "/api/v1/domains/"+path+"/dns-account", body) + if response.Code != status { + t.Fatalf("域名 DNS 绑定响应错误:%s %s %s,实际 %d,期望 %d,%s", actor, method, path, response.Code, status, response.Body.String()) + } + if response.Header().Get("Cache-Control") != "private, no-store" || response.Header().Get("Vary") != "Cookie" { + t.Fatal("绑定响应缺少身份缓存隔离") + } + for _, secret := range []string{"secret://", "credential_ref", "fixture-sensitive-token", "internal-sensitive-error"} { + if strings.Contains(response.Body.String(), secret) { + t.Fatal("绑定响应泄露秘密或内部错误") + } + } + var binding domain.DNSAccountBinding + if status == http.StatusOK { + if err := json.Unmarshal(response.Body.Bytes(), &binding); err != nil { + t.Fatal(err) + } + } + return binding +} + +func TestDomainDNSAccountAPILifecycle(t *testing.T) { + f, account := newDomainDNSAPIFixture(t) + read := domainDNSRequest(t, f, "viewer", http.MethodGet, "domain-a", "", http.StatusOK) + if read.ID != "" || read.DomainID != "domain-a" || read.ProjectID != "project-a" { + t.Fatal("未绑定时未返回域名归属") + } + body := domainDNSBody(account.ID, "", account.Revision) + bound := domainDNSRequest(t, f, "manager", http.MethodPut, "domain-a", body, http.StatusOK) + if bound.ID == "" || bound.DNSAccountID != account.ID || bound.UpdatedAt.IsZero() { + t.Fatal("绑定未返回版本令牌") + } + read = domainDNSRequest(t, f, "viewer", http.MethodGet, "domain-a", "", http.StatusOK) + if read.ID != bound.ID || read.DNSAccountID != account.ID { + t.Fatal("读取与实际绑定不符") + } + for _, stale := range []string{body, domainDNSBody(account.ID, bound.ID, 1), domainDNSBody("", "", 0)} { + domainDNSRequest(t, f, "manager", http.MethodPut, "domain-a", stale, http.StatusConflict) + } + cleared := domainDNSRequest(t, f, "manager", http.MethodPut, "domain-a", domainDNSBody("", bound.ID, 0), http.StatusOK) + if cleared.ID != "" || cleared.DNSAccountID != "" { + t.Fatal("解除后仍返回绑定") + } + rebound := domainDNSRequest(t, f, "manager", http.MethodPut, "domain-a", body, http.StatusOK) + if rebound.ID == bound.ID { + t.Fatal("重绑重复使用旧令牌") + } + domainDNSRequest(t, f, "manager", http.MethodPut, "domain-a", domainDNSBody("", bound.ID, 0), http.StatusConflict) + var count int + if err := f.db.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action='domain.dns_account' AND status='success' AND operator_id='manager'`).Scan(&count); err != nil || count != 3 { + t.Fatal("绑定成功审计数不符", count, err) + } +} + +func TestDomainDNSAccountAPIAuthorization(t *testing.T) { + f, account := newDomainDNSAPIFixture(t) + body := domainDNSBody(account.ID, "", account.Revision) + for _, actor := range []string{"anonymous", "viewer", "global-admin", "target"} { + status := http.StatusForbidden + if actor == "anonymous" { + status = http.StatusUnauthorized + } + domainDNSRequest(t, f, actor, http.MethodPut, "domain-a", body, status) + if actor != "viewer" { + domainDNSRequest(t, f, actor, http.MethodGet, "domain-a", "", status) + } + } + for _, id := range []string{"domain-b", "missing-domain"} { + domainDNSRequest(t, f, "manager", http.MethodGet, id, "", http.StatusForbidden) + domainDNSRequest(t, f, "manager", http.MethodPut, id, body, http.StatusForbidden) + } + // 超级管理员也不能将同一账户绑定到另一个项目。 + domainDNSRequest(t, f, "primary-super-admin", http.MethodPut, "domain-b", body, http.StatusForbidden) + if err := f.members.RevokeProjectRole(t.Context(), "primary-super-admin", "project-a", "manager", domain.ProjectRoleAdmin); err != nil { + t.Fatal(err) + } + domainDNSRequest(t, f, "manager", http.MethodGet, "domain-a", "", http.StatusForbidden) + domainDNSRequest(t, f, "manager", http.MethodPut, "domain-a", body, http.StatusForbidden) +} + +func TestDomainDNSAccountAPIInputAndOrigin(t *testing.T) { + f, account := newDomainDNSAPIFixture(t) + for _, body := range []string{ + `{}`, `null`, `{"dns_account_id":null,"expected_binding_id":"","account_revision":0}`, + `{"dns_account_id":"","expected_binding_id":null,"account_revision":0}`, + `{"dns_account_id":"","expected_binding_id":"","account_revision":null}`, + domainDNSBody("", "", 1), domainDNSBody(account.ID, "", 0), domainDNSBody(account.ID, "", 2147483648), + domainDNSBody(account.ID, "invalid/id", account.Revision), + `{"dns_account_id":"","expected_binding_id":"","account_revision":0,"actor_id":"primary-super-admin"}`, + `{"dns_account_id":"","expected_binding_id":"","account_revision":0} {}`, + } { + domainDNSRequest(t, f, "manager", http.MethodPut, "domain-a", body, http.StatusBadRequest) + } + for _, method := range []string{http.MethodGet, http.MethodPut} { + response := serveUserRequest(f.server, f.cookies["manager"], method, "/api/v1/domains/domain-a/dns-account?project_id=project-b", domainDNSBody(account.ID, "", account.Revision)) + if response.Code != http.StatusBadRequest { + t.Fatal("查询参数未拒绝", response.Code) + } + } + request := httptest.NewRequest(http.MethodPut, "/api/v1/domains/domain-a/dns-account", strings.NewReader(domainDNSBody(account.ID, "", account.Revision))) + request.AddCookie(f.cookies["manager"]) + request.Header.Set("Origin", "https://untrusted.example.test") + response := httptest.NewRecorder() + f.server.Handler().ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatal("跨源写入未拒绝", response.Code) + } + binding := domainDNSRequest(t, f, "manager", http.MethodGet, "domain-a", "", http.StatusOK) + if binding.ID != "" { + t.Fatal("非法请求改变绑定") + } +} + +func TestDomainDNSAccountAPIDependencyAndState(t *testing.T) { + for _, failure := range []string{"unverified", "disabled", "http_challenge", "active_task", "audit", "repository"} { + t.Run(failure, func(t *testing.T) { + f, account := newDomainDNSAPIFixture(t) + status := http.StatusUnprocessableEntity + switch failure { + case "unverified": + if _, err := f.db.Exec(`UPDATE dns_accounts SET verification_state='unverified'`); err != nil { + t.Fatal(err) + } + case "disabled": + if _, err := f.db.Exec(`UPDATE dns_accounts SET status='disabled'`); err != nil { + t.Fatal(err) + } + case "http_challenge": + if _, err := f.db.Exec(`UPDATE domains SET challenge_type='http-01' WHERE id='domain-a'`); err != nil { + t.Fatal(err) + } + case "active_task": + if _, err := f.db.Exec(`INSERT INTO tasks (id,domain_id,task_type,status,message) VALUES ('active-task','domain-a','certificate_issue','running','')`); err != nil { + t.Fatal(err) + } + status = http.StatusConflict + case "audit": + if _, err := f.db.Exec(`CREATE TRIGGER reject_binding_audit BEFORE INSERT ON audit_logs WHEN NEW.action='domain.dns_account' BEGIN SELECT RAISE(ABORT,'internal-sensitive-error'); END`); err != nil { + t.Fatal(err) + } + status = http.StatusServiceUnavailable + case "repository": + f.server.projectMembers = nil + status = http.StatusServiceUnavailable + } + domainDNSRequest(t, f, "manager", http.MethodPut, "domain-a", domainDNSBody(account.ID, "", account.Revision), status) + binding, err := f.members.GetDomainDNSAccountBinding(t.Context(), "manager", "domain-a") + if err != nil || binding.ID != "" { + t.Fatal("失败请求改变绑定", err) + } + }) + } +} diff --git a/backend/internal/httpapi/project_resources_api.go b/backend/internal/httpapi/project_resources_api.go index 52936ab..9588bd4 100644 --- a/backend/internal/httpapi/project_resources_api.go +++ b/backend/internal/httpapi/project_resources_api.go @@ -13,6 +13,8 @@ import ( func (s *Server) registerProjectResourceRoutes() { api := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated) api.GET("/domains", s.listDomains) + api.GET("/domains/:id/dns-account", s.getDomainDNSAccount) + api.PUT("/domains/:id/dns-account", s.requireSameOrigin, s.setDomainDNSAccount) api.GET("/dns/accounts", s.listDNSAccounts) api.GET("/dns/accounts/:id", s.getManagedDNSAccount) api.POST("/dns/accounts", s.requireSameOrigin, s.createManagedDNSAccount) diff --git a/doc/DNS多账户整改任务清单.md b/doc/DNS多账户整改任务清单.md index dd2c581..8632878 100644 --- a/doc/DNS多账户整改任务清单.md +++ b/doc/DNS多账户整改任务清单.md @@ -1,5 +1,14 @@ # DNS 多账户整改任务清单 +## 2026年10月4日05:54 绑定接口与 MySQL 外键兼容修复 + +- [✓] 修改前检查点 `45798c5` 已推送;第一台原任务已查询至终态,没有因观察超时重复启动。 +- [x] 新增域名账户绑定读取和写入接口,显式提供账户、绑定令牌和版本,支持解除;拒绝遗漏、空值、伪造身份字段、跨源、越权、旧版本及在途任务。依赖失败不泄露内部错误,成功审计与绑定同事务。 +- [!] 干净 `7a6da6b` 的 Linux 全量及 vet 通过,但三库专项在 MySQL 报外键字段排序规则不兼容,整体单元退出一;不能计为三库通过。旧主键继承历史建库排序规则,新绑定列不能固定为项目标识的排序规则。 +- [x] 修复为从历史域名主键读取字符集和排序规则,仅应用于新外键列,并严格校验重入结构;不修改历史父表或数据库默认值,不覆盖冲突表。补数据库默认值改变及错误列排序规则用例。 +- [✓] 本地 HTTP API 全量 31.842 秒、数据库全量 27.797 秒及两包 vet 通过;本地外部实库跳过不计实库证据。首次新增测试编译时误用数据库包装对象的方法,已改为底层连接后复跑。 +- [~] 等待修复提交的第一台干净源码实库复审。执行租约、旧账户导入、实际签发续期、删除回收和前端仍未接通;不发布过渡版本,不关闭 P1-03。 + ## 需求与边界(2026年10月4日) 依据《Scert整改合并优化清单》和《核心能力任务规格》P1-03,以及用户已确认的 DNS 账户归属要求执行;不是新增需求。范围仅 Linux 与 Web,第一台部署业务,第二台只验收证书推送。公共域名切换和邮件授权尚待确认,不阻断本项开发。 @@ -20,6 +29,7 @@ - [✓] 三库迁移和仓储专项三轮竞态通过;MySQL 审计故障夹具权限已修正,失败记录保留。完整执行链仍待实现验证。 - [ ] 旧系统账户导入及幂等恢复,保护配置与历史凭据引用。 - [~] 域名绑定及数据库引用保护已实现,等待第一台实库复审;安全删除、执行租约和凭据清理尚未完成。 +- [x] 域名绑定 HTTP 接口与严格参数、实时权限、版本竞争、跨源和审计回滚用例已实现;第一台回归待复审。 - [ ] 正式签发、续期、重试的账户解析与执行前权限复核。 - [x] 账户验证、增改、启停、轮换接口及无缓存和敏感字段隔离;本地全包与专项通过,第一台 Linux 回归待终态确认。 - [ ] DNS 账户页、域名配置页及前端回归。 diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 3fd6268..3585a15 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,13 @@ # Linux 生产流程测试记录 +## 2026年10月4日05:54 绑定接口及 MySQL 实库失败修复 + +- 第一台 `scert-domain-dns-20261004-0538.service` 使用真实 Git 干净提交 `7a6da6b`;实查 `ActiveState=failed`、`Result=exit-code`、`ExecMainStatus=1`。原目录 `/var/tmp/scert-domain-dns-20261004-0538` 的 `full.log` 记录全量及 vet 通过,`race.log` 记录三库专项失败,不重写日志。 +- 三轮数据库、仓储 MySQL 用例均报 3780:新 `domain_id` 与历史 `domains.id` 字符排序规则不兼容;数据库 214.109 秒和仓储 96.024 秒失败,证书 1.036 秒、HTTP 32.665 秒通过。整体不能标为成功。 +- 修复读取历史主键字符集和排序规则,应用于新表外键列;严格重入校验支持该列专用规则,不修改父表,不取消约束。新增默认值变更及错误列排序规则两种实库用例,等待复跑。 +- 域名绑定 API 已加入读取、修改和解除,覆盖身份、同源、缓存、严格参数、过期版本、账户状态、任务占用、依赖异常和审计失败回滚。本地 HTTP 全量 31.842 秒、数据库全量 27.797 秒及 vet 通过;首次新增夹具编译错误已修正,本地外部库跳过不计实库证据。 +- 本批没有升级服务、改 DNS、清业务数据、创建辅助验收脚本或操作第二台。执行租约和签发链未接通,P1-03 及整体生产验收仍未完成。 + ## 2026年10月4日05:34 域名账户绑定与干净源码回归 - `scert-dns-clean-20261004-0514.service` 使用 `1dc72f2` 的真实 Git 干净签出,源码 `/var/tmp/scert-dns-clean-20261004-0514/source`,日志 `full.log`;已 failed、退出一。仓储并发用例发现 SQLite 锁错误被更新入口包装成凭据重复错误;后续 vet、竞态因前置失败未执行,不计为通过。 diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 5c74d46..afad23a 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -1,5 +1,11 @@ # Scert 开发任务清单 +## 2026年10月4日05:54 DNS 绑定开发进度 + +- [x] 独立账户域名绑定接口已完成编码及本地 API 回归;绑定和解除都必须提供当前令牌,拒绝隐式清空和旧版本覆盖。 +- [x] 第一台三库暴露的 MySQL 历史字段排序规则冲突已修复,并增加中断重入兼容和冲突拒绝测试;本地数据库全量通过,实库复审待完成。 +- [~] P1-03 尚未完成:执行租约、旧账户导入、安全删除回收、真实签发续期和前端继续开发;正式服务未升级。 + ## 2026年10月4日05:34 DNS 域名绑定开发 - [x] 独立绑定数据结构与迁移、实时项目双权限、同项目及状态约束、乐观绑定令牌和审计事务已实现;域名查询包含账户和绑定标识。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 287a55e..01dd788 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,12 @@ # Scert 整改任务执行清单 +## 2026年10月4日05:54 域名 DNS 绑定接口 + +- [x] 绑定读取、写入、解除路由及严格参数、权限、缓存、同源、版本、状态和审计回滚测试已实现;本地两包全量和 vet 通过。 +- [!] 第一台原 0538 任务已失败退出一,MySQL 外键排序规则冲突是实际缺陷;Linux 无外部库全量及 vet 通过不能替代三库验证。 +- [x] 新外键列按父表实际字符定义生成,重入继续严格校验;补数据库默认值改变和错误排序规则用例,待第一台实测。 +- [ ] 执行租约、旧账户导入、签发续期、删除回收及前端继续按完整 P1-03 推进;未部署过渡版。 + ## 2026年10月4日05:34 域名 DNS 账户绑定 - [x] 三库迁移二十八、同项目绑定、账户版本与状态检查、旧绑定令牌拒绝、在途任务保护及原子审计已实现;删除域名释放绑定但保留账户。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 5b4e45a..9933f33 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,5 +1,11 @@ # Scert 整改、合并与优化清单 +## 2026年10月4日05:54 DNS 绑定接口及实库阻塞修复 + +- [x] 域名 DNS 绑定读取、修改和解除接口已接入,包含显式版本令牌、实时权限、在途任务拒绝、同源及缓存隔离;本地接口与数据库全量、vet 通过。 +- [!] 第一台 `scert-domain-dns-20261004-0538.service` 已失败退出一:干净 `7a6da6b` 全量及 vet 通过,但三库竞态发现 MySQL 历史主键与新绑定外键列排序规则不兼容。已修复为继承实际父列定义,不删数据、不放宽外键;待修复提交实库复审。 +- [~] P1-03 仍缺执行租约、旧账户导入、实际签发续期、安全删除回收和前端。当前线上未升级,第二台未操作,整体生产验收未完成。 + ## 2026年10月4日05:34 DNS 域名绑定与引用保护 - [x] 迁移二十八、同项目账户绑定仓储、变更令牌、实时双权限、在途任务保护及事务审计已实现;域名读取返回绑定标识,普通编辑不能绕过绑定切换服务商或验证方式。 diff --git a/doc/核心能力任务规格.md b/doc/核心能力任务规格.md index 12dc2c9..c759648 100644 --- a/doc/核心能力任务规格.md +++ b/doc/核心能力任务规格.md @@ -1,5 +1,12 @@ # 核心能力任务规格 +## 2026年10月4日05:54 DNS 绑定接口契约 + +- [x] `GET /api/v1/domains/:id/dns-account` 返回已授权域名的账户绑定摘要;未绑定时标识为空,不读取秘密。 +- [x] `PUT /api/v1/domains/:id/dns-account` 必须显式提供 `dns_account_id`、`expected_binding_id`、`account_revision`;首次绑定使用空旧令牌,解除使用空账户和版本零。拒绝未知字段、查询参数及空值;账户或绑定版本冲突返回 409,无权或不存在返回 403,账户状态不符返回 422,依赖异常返回固定 503,不输出内部错误。 +- [x] MySQL 绑定外键列继承历史域名主键的实际字符集和排序规则,而不是假定新旧表使用相同默认值;结构冲突拒绝覆盖。 +- [ ] 执行租约与实际签发续期尚未完成,绑定不等于可签发。Linux 三库修复版本和完整前端验收仍待通过。 + ## 2026年10月4日05:34 DNS 独立绑定阶段 迁移二十八通过同项目复合外键与 SQLite 补充触发器保存域名账户绑定。仓储必须同时核验证书管理、DNS 凭据使用权限,锁定域名并拒绝在途任务,核对账户版本、启用和验证状态;变更绑定令牌、域名服务商及审计同事务。普通域名编辑不能绕过绑定,删除域名只解除引用。域名读取显式返回账户和绑定标识。