From 7e14bbf42f4dcb89e81663b71375702580eb77e2 Mon Sep 17 00:00:00 2001 From: xuans Date: Wed, 30 Sep 2026 02:00:36 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E4=BB=BB=E5=8A=A1=E5=B9=82?= =?UTF-8?q?=E7=AD=89=E5=B9=B6=E5=8F=91=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增三数据库任务幂等登记迁移,在任务创建事务中防止重复幂等键,并补充并发回归测试与进度清单。 --- backend/internal/database/migrate.go | 5 ++- .../mysql/0015_task_idempotency.sql | 14 ++++++ .../postgres/0015_task_idempotency.sql | 14 ++++++ .../sqlite/0015_task_idempotency.sql | 14 ++++++ backend/internal/httpapi/task_api.go | 11 +++++ .../internal/repository/task_repository.go | 11 +++++ .../repository/task_repository_test.go | 44 +++++++++++++++++++ doc/Scert开发任务清单.md | 15 ++++++- doc/Scert整改任务执行清单.md | 15 ++++++- doc/Scert整改合并优化清单.md | 15 ++++++- doc/基础整改任务规格.md | 15 ++++++- 11 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 backend/internal/database/migrations/mysql/0015_task_idempotency.sql create mode 100644 backend/internal/database/migrations/postgres/0015_task_idempotency.sql create mode 100644 backend/internal/database/migrations/sqlite/0015_task_idempotency.sql diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index e08e230..8ddfc96 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -16,6 +16,7 @@ import ( //go:embed migrations/sqlite/0012_resource_replacement.sql migrations/mysql/0012_resource_replacement.sql migrations/postgres/0012_resource_replacement.sql //go:embed migrations/sqlite/0013_history_scope.sql migrations/mysql/0013_history_scope.sql migrations/postgres/0013_history_scope.sql //go:embed migrations/sqlite/0014_project_members.sql migrations/mysql/0014_project_members.sql migrations/postgres/0014_project_members.sql +//go:embed migrations/sqlite/0015_task_idempotency.sql migrations/mysql/0015_task_idempotency.sql migrations/postgres/0015_task_idempotency.sql var migrationFiles embed.FS func Migrate(ctx context.Context, db *sql.DB, driver string) error { @@ -40,7 +41,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error { return nil } -const latestMigrationVersion = 14 +const latestMigrationVersion = 15 // currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。 func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) { @@ -220,6 +221,8 @@ func migrationName(version int) string { return "history_scope" case 14: return "project_members" + case 15: + return "task_idempotency" default: return "unknown" } diff --git a/backend/internal/database/migrations/mysql/0015_task_idempotency.sql b/backend/internal/database/migrations/mysql/0015_task_idempotency.sql new file mode 100644 index 0000000..1037859 --- /dev/null +++ b/backend/internal/database/migrations/mysql/0015_task_idempotency.sql @@ -0,0 +1,14 @@ +CREATE TABLE task_idempotency ( + domain_id VARCHAR(36) NOT NULL, + idempotency_key VARCHAR(255) NOT NULL, + task_id VARCHAR(36) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (domain_id, idempotency_key), + UNIQUE KEY uq_task_idempotency_task (task_id) +); + +INSERT INTO task_idempotency (domain_id, idempotency_key, task_id, created_at) +SELECT domain_id, idempotency_key, MIN(id), MIN(created_at) +FROM tasks +WHERE idempotency_key <> '' +GROUP BY domain_id, idempotency_key; diff --git a/backend/internal/database/migrations/postgres/0015_task_idempotency.sql b/backend/internal/database/migrations/postgres/0015_task_idempotency.sql new file mode 100644 index 0000000..0ab9543 --- /dev/null +++ b/backend/internal/database/migrations/postgres/0015_task_idempotency.sql @@ -0,0 +1,14 @@ +CREATE TABLE task_idempotency ( + domain_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + task_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (domain_id, idempotency_key), + UNIQUE (task_id) +); + +INSERT INTO task_idempotency (domain_id, idempotency_key, task_id, created_at) +SELECT domain_id, idempotency_key, MIN(id), MIN(created_at) +FROM tasks +WHERE idempotency_key <> '' +GROUP BY domain_id, idempotency_key; diff --git a/backend/internal/database/migrations/sqlite/0015_task_idempotency.sql b/backend/internal/database/migrations/sqlite/0015_task_idempotency.sql new file mode 100644 index 0000000..a6b0cfe --- /dev/null +++ b/backend/internal/database/migrations/sqlite/0015_task_idempotency.sql @@ -0,0 +1,14 @@ +CREATE TABLE task_idempotency ( + domain_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + task_id TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (domain_id, idempotency_key), + UNIQUE (task_id) +); + +INSERT INTO task_idempotency (domain_id, idempotency_key, task_id, created_at) +SELECT domain_id, idempotency_key, MIN(id), MIN(created_at) +FROM tasks +WHERE idempotency_key <> '' +GROUP BY domain_id, idempotency_key; diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index 6ab401f..7e0e8a7 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -102,6 +102,17 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { Message: "等待执行证书申请", IdempotencyKey: idempotencyKey, CreatedAt: now, UpdatedAt: now, } if err := s.tasks.CreateTask(c.Request.Context(), task); err != nil { + if idempotencyKey != "" { + if existing, lookupErr := s.tasks.FindTaskByIdempotencyKey(c.Request.Context(), domainID, idempotencyKey); lookupErr == nil { + status := http.StatusOK + if existing.Status == "queued" || existing.Status == "running" { + status = http.StatusConflict + } + c.JSON(status, gin.H{"task": existing, "replayed": true}) + s.issueTaskMu.Unlock() + return + } + } s.issueTaskMu.Unlock() s.writeError(c, http.StatusInternalServerError, "创建证书任务失败", err) return diff --git a/backend/internal/repository/task_repository.go b/backend/internal/repository/task_repository.go index 2e1c34f..5e65fd2 100644 --- a/backend/internal/repository/task_repository.go +++ b/backend/internal/repository/task_repository.go @@ -127,6 +127,17 @@ func (r *SQLTaskRepository) CreateTask(ctx context.Context, item Task) error { if err := validateTaskInsertResult(result); err != nil { return err } + if item.IdempotencyKey != "" { + idempotencyResult, idempotencyErr := tx.ExecContext(ctx, r.query(`INSERT INTO task_idempotency + (domain_id, idempotency_key, task_id, created_at) VALUES (?, ?, ?, ?)`), + item.DomainID, item.IdempotencyKey, item.ID, now) + if idempotencyErr != nil { + return fmt.Errorf("创建任务幂等记录失败:%w", idempotencyErr) + } + if err := validateTaskInsertResult(idempotencyResult); err != nil { + return err + } + } if err := tx.Commit(); err != nil { return fmt.Errorf("提交任务创建事务失败:%w", err) } diff --git a/backend/internal/repository/task_repository_test.go b/backend/internal/repository/task_repository_test.go index 7c2debf..8b85abe 100644 --- a/backend/internal/repository/task_repository_test.go +++ b/backend/internal/repository/task_repository_test.go @@ -209,3 +209,47 @@ func TestSQLTaskRepositoryRecoversInterruptedTasksAndAttempts(t *testing.T) { t.Fatalf("\u5df2\u5b8c\u6210\u4efb\u52a1\u5c1d\u8bd5\u4e0d\u5e94\u88ab\u6062\u590d\u903b\u8f91\u4fee\u6539\uff1a%+v %v", completedAttempts, err) } } + +func TestSQLTaskRepositoryRejectsConcurrentDuplicateIdempotencyKeys(t *testing.T) { + repository, db := newTaskRepositoryTest(t) + defer db.Close() + + ctx := context.Background() + start := make(chan struct{}) + results := make(chan error, 2) + for _, taskID := range []string{"task-idempotency-a", "task-idempotency-b"} { + go func(taskID string) { + <-start + results <- repository.CreateTask(ctx, Task{ + ID: taskID, DomainID: "domain-1", TaskType: "certificate_issue", + Status: "queued", Stage: "queued", Message: "test", + IdempotencyKey: "duplicate-key-20260929", + }) + }(taskID) + } + close(start) + + var succeeded, failed int + for range 2 { + if err := <-results; err == nil { + succeeded++ + } else { + failed++ + } + } + if succeeded != 1 || failed != 1 { + t.Fatalf("unexpected concurrent idempotency result: success=%d failure=%d", succeeded, failed) + } + + items, err := repository.ListTasks(ctx, "domain-1", 10) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].IdempotencyKey != "duplicate-key-20260929" { + t.Fatalf("duplicate idempotency key created multiple tasks: %+v", items) + } + stored, err := repository.FindTaskByIdempotencyKey(ctx, "domain-1", "duplicate-key-20260929") + if err != nil || stored.ID != items[0].ID { + t.Fatalf("idempotency lookup mismatch: %+v %v", stored, err) + } +} diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 02b13f2..c62cf78 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -3558,4 +3558,17 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无 - [ ] 未开始:P0-12 备份恢复、三数据库生产矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。 - [!] 总体验收不得升级:仍处于 **P0-03 完成,待复审**,当前没有新增 `[✓]` 复审通过项。 -下一步继续审查 P0-10 剩余任务执行链和 P0-12 备份恢复;开发阶段完成后,再统一执行生产构建、部署和实机验收。 \ No newline at end of file +下一步继续审查 P0-10 剩余任务执行链和 P0-12 备份恢复;开发阶段完成后,再统一执行生产构建、部署和实机验收。 +## 本轮开发进度(2026-09-29) + +总体验收位置继续保持 **P0-03:完成,待复审**;本轮只推进 P0-10 任务幂等并发保护,不代表整项复审或生产部署验收通过。 + +- [x] 已完成,等待复审:新增 `task_idempotency` 数据表及 SQLite、PostgreSQL、MySQL 三套 0015 迁移;历史非空幂等键在迁移时建立登记。 +- [x] 已完成,等待复审:任务创建在同一事务登记幂等键,数据库层拒绝同一域名同一幂等键的并发重复任务,避免仅依赖单进程互斥锁。 +- [x] 已完成,等待复审:接口在并发创建冲突后回读已存在任务并返回重放结果,不再把可恢复的幂等冲突直接返回为普通创建失败。 +- [x] 已完成,等待复审:新增并发重复幂等键回归测试;`internal/repository`、`internal/database`、`internal/httpapi` 及后端全量 `go test ./...` 均通过,`git diff --check` 通过。 +- [~] 进行中:P0-10 其余长任务取消、重试、任务尝试记录、崩溃恢复,以及证书校验、导入、部署、恢复入口是否全部纳入任务中心仍需继续审查。 +- [ ] 未开始:P0-12 备份恢复、三数据库实库矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。 +- [!] 总体验收不得升级:当前仍为 **P0-03 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。 + +本轮下一步继续审查任务中心剩余入口和备份恢复;开发阶段收尾后,再统一执行生产构建、实机部署和验收。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index b32fe36..d16ca8b 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -864,4 +864,17 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全 - [ ] 未开始:P0-12 备份恢复、三数据库生产矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。 - [!] 总体验收不得升级:仍处于 **P0-03 完成,待复审**,当前没有新增 `[✓]` 复审通过项。 -下一步继续审查 P0-10 剩余任务执行链和 P0-12 备份恢复;开发阶段完成后,再统一执行生产构建、部署和实机验收。 \ No newline at end of file +下一步继续审查 P0-10 剩余任务执行链和 P0-12 备份恢复;开发阶段完成后,再统一执行生产构建、部署和实机验收。 +## 本轮开发进度(2026-09-29) + +总体验收位置继续保持 **P0-03:完成,待复审**;本轮只推进 P0-10 任务幂等并发保护,不代表整项复审或生产部署验收通过。 + +- [x] 已完成,等待复审:新增 `task_idempotency` 数据表及 SQLite、PostgreSQL、MySQL 三套 0015 迁移;历史非空幂等键在迁移时建立登记。 +- [x] 已完成,等待复审:任务创建在同一事务登记幂等键,数据库层拒绝同一域名同一幂等键的并发重复任务,避免仅依赖单进程互斥锁。 +- [x] 已完成,等待复审:接口在并发创建冲突后回读已存在任务并返回重放结果,不再把可恢复的幂等冲突直接返回为普通创建失败。 +- [x] 已完成,等待复审:新增并发重复幂等键回归测试;`internal/repository`、`internal/database`、`internal/httpapi` 及后端全量 `go test ./...` 均通过,`git diff --check` 通过。 +- [~] 进行中:P0-10 其余长任务取消、重试、任务尝试记录、崩溃恢复,以及证书校验、导入、部署、恢复入口是否全部纳入任务中心仍需继续审查。 +- [ ] 未开始:P0-12 备份恢复、三数据库实库矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。 +- [!] 总体验收不得升级:当前仍为 **P0-03 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。 + +本轮下一步继续审查任务中心剩余入口和备份恢复;开发阶段收尾后,再统一执行生产构建、实机部署和验收。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 0fefb0e..1826901 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1502,4 +1502,17 @@ P0-11 生产验收门禁 - [ ] 未开始:P0-12 备份恢复、三数据库生产矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。 - [!] 总体验收不得升级:仍处于 **P0-03 完成,待复审**,当前没有新增 `[✓]` 复审通过项。 -下一步继续审查 P0-10 剩余任务执行链和 P0-12 备份恢复;开发阶段完成后,再统一执行生产构建、部署和实机验收。 \ No newline at end of file +下一步继续审查 P0-10 剩余任务执行链和 P0-12 备份恢复;开发阶段完成后,再统一执行生产构建、部署和实机验收。 +## 本轮开发进度(2026-09-29) + +总体验收位置继续保持 **P0-03:完成,待复审**;本轮只推进 P0-10 任务幂等并发保护,不代表整项复审或生产部署验收通过。 + +- [x] 已完成,等待复审:新增 `task_idempotency` 数据表及 SQLite、PostgreSQL、MySQL 三套 0015 迁移;历史非空幂等键在迁移时建立登记。 +- [x] 已完成,等待复审:任务创建在同一事务登记幂等键,数据库层拒绝同一域名同一幂等键的并发重复任务,避免仅依赖单进程互斥锁。 +- [x] 已完成,等待复审:接口在并发创建冲突后回读已存在任务并返回重放结果,不再把可恢复的幂等冲突直接返回为普通创建失败。 +- [x] 已完成,等待复审:新增并发重复幂等键回归测试;`internal/repository`、`internal/database`、`internal/httpapi` 及后端全量 `go test ./...` 均通过,`git diff --check` 通过。 +- [~] 进行中:P0-10 其余长任务取消、重试、任务尝试记录、崩溃恢复,以及证书校验、导入、部署、恢复入口是否全部纳入任务中心仍需继续审查。 +- [ ] 未开始:P0-12 备份恢复、三数据库实库矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。 +- [!] 总体验收不得升级:当前仍为 **P0-03 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。 + +本轮下一步继续审查任务中心剩余入口和备份恢复;开发阶段收尾后,再统一执行生产构建、实机部署和验收。 diff --git a/doc/基础整改任务规格.md b/doc/基础整改任务规格.md index cbfb472..4c9b273 100644 --- a/doc/基础整改任务规格.md +++ b/doc/基础整改任务规格.md @@ -443,4 +443,17 @@ P0-06 与 P0-07 均维持 `[~]`,六阶段中的隔离、生产和最终验收 - [ ] 未开始:P0-12 备份恢复、三数据库生产矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。 - [!] 总体验收不得升级:仍处于 **P0-03 完成,待复审**,当前没有新增 `[✓]` 复审通过项。 -下一步继续审查 P0-10 剩余任务执行链和 P0-12 备份恢复;开发阶段完成后,再统一执行生产构建、部署和实机验收。 \ No newline at end of file +下一步继续审查 P0-10 剩余任务执行链和 P0-12 备份恢复;开发阶段完成后,再统一执行生产构建、部署和实机验收。 +## 本轮开发进度(2026-09-29) + +总体验收位置继续保持 **P0-03:完成,待复审**;本轮只推进 P0-10 任务幂等并发保护,不代表整项复审或生产部署验收通过。 + +- [x] 已完成,等待复审:新增 `task_idempotency` 数据表及 SQLite、PostgreSQL、MySQL 三套 0015 迁移;历史非空幂等键在迁移时建立登记。 +- [x] 已完成,等待复审:任务创建在同一事务登记幂等键,数据库层拒绝同一域名同一幂等键的并发重复任务,避免仅依赖单进程互斥锁。 +- [x] 已完成,等待复审:接口在并发创建冲突后回读已存在任务并返回重放结果,不再把可恢复的幂等冲突直接返回为普通创建失败。 +- [x] 已完成,等待复审:新增并发重复幂等键回归测试;`internal/repository`、`internal/database`、`internal/httpapi` 及后端全量 `go test ./...` 均通过,`git diff --check` 通过。 +- [~] 进行中:P0-10 其余长任务取消、重试、任务尝试记录、崩溃恢复,以及证书校验、导入、部署、恢复入口是否全部纳入任务中心仍需继续审查。 +- [ ] 未开始:P0-12 备份恢复、三数据库实库矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。 +- [!] 总体验收不得升级:当前仍为 **P0-03 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。 + +本轮下一步继续审查任务中心剩余入口和备份恢复;开发阶段收尾后,再统一执行生产构建、实机部署和验收。