diff --git a/backend/internal/repository/task_repository.go b/backend/internal/repository/task_repository.go index 5e65fd2..dcc443f 100644 --- a/backend/internal/repository/task_repository.go +++ b/backend/internal/repository/task_repository.go @@ -301,9 +301,24 @@ func (r *SQLTaskRepository) RecoverInterruptedTasks(ctx context.Context, finishe rollback := func() { _ = tx.Rollback() } + recoveryMessage := "\u670d\u52a1\u91cd\u542f\u5bfc\u81f4\u4efb\u52a1\u4e2d\u65ad\uff0c\u8bf7\u91cd\u65b0\u6267\u884c" + auditResult, err := tx.ExecContext(ctx, r.query(`INSERT INTO audit_logs + (action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id) + SELECT 'task_recovery', 'task', id, 'failed', ?, ?, scope_type, project_id, id, operator_id + FROM tasks + WHERE status IN ('queued', 'running', 'retry_wait', 'cancelling')`), recoveryMessage, finishedAt) + if err != nil { + rollback() + return 0, fmt.Errorf("\u5199\u5165\u4e2d\u65ad\u4efb\u52a1\u5ba1\u8ba1\u5931\u8d25\uff1a%w", err) + } + auditCount, err := auditResult.RowsAffected() + if err != nil { + rollback() + return 0, fmt.Errorf("\u8bfb\u53d6\u4e2d\u65ad\u4efb\u52a1\u5ba1\u8ba1\u6570\u91cf\u5931\u8d25\uff1a%w", err) + } result, err := tx.ExecContext(ctx, r.query(`UPDATE tasks SET status = 'failed', stage = 'service_restart', message = ?, finished_at = ?, updated_at = ? - WHERE status IN ('queued', 'running', 'retry_wait', 'cancelling')`), "\u670d\u52a1\u91cd\u542f\u5bfc\u81f4\u4efb\u52a1\u4e2d\u65ad\uff0c\u8bf7\u91cd\u65b0\u6267\u884c", finishedAt, finishedAt) + WHERE status IN ('queued', 'running', 'retry_wait', 'cancelling')`), recoveryMessage, finishedAt, finishedAt) if err != nil { rollback() return 0, fmt.Errorf("\u6062\u590d\u4e2d\u65ad\u4efb\u52a1\u5931\u8d25\uff1a%w", err) @@ -313,9 +328,13 @@ func (r *SQLTaskRepository) RecoverInterruptedTasks(ctx context.Context, finishe rollback() return 0, fmt.Errorf("\u8bfb\u53d6\u4e2d\u65ad\u4efb\u52a1\u6570\u91cf\u5931\u8d25\uff1a%w", err) } + if auditCount != count { + rollback() + return 0, fmt.Errorf("\u4e2d\u65ad\u4efb\u52a1\u5ba1\u8ba1\u6570\u91cf\u4e0e\u4efb\u52a1\u6570\u91cf\u4e0d\u4e00\u81f4\uff1a\u5ba1\u8ba1 %d,\u4efb\u52a1 %d", auditCount, count) + } if _, err := tx.ExecContext(ctx, r.query(`UPDATE task_attempts SET status = 'failed', message = ?, finished_at = ? - WHERE status IN ('queued', 'running', 'retry_wait', 'cancelling')`), "\u670d\u52a1\u91cd\u542f\u5bfc\u81f4\u4efb\u52a1\u4e2d\u65ad\uff0c\u8bf7\u91cd\u65b0\u6267\u884c", finishedAt); err != nil { + WHERE status IN ('queued', 'running', 'retry_wait', 'cancelling')`), recoveryMessage, finishedAt); err != nil { rollback() return 0, fmt.Errorf("\u6062\u590d\u4e2d\u65ad\u4efb\u52a1\u5c1d\u8bd5\u5931\u8d25\uff1a%w", err) } diff --git a/backend/internal/repository/task_repository_test.go b/backend/internal/repository/task_repository_test.go index 8b85abe..b16356f 100644 --- a/backend/internal/repository/task_repository_test.go +++ b/backend/internal/repository/task_repository_test.go @@ -208,6 +208,64 @@ func TestSQLTaskRepositoryRecoversInterruptedTasksAndAttempts(t *testing.T) { if err != nil || len(completedAttempts) != 1 || completedAttempts[0].Status != "success" { t.Fatalf("\u5df2\u5b8c\u6210\u4efb\u52a1\u5c1d\u8bd5\u4e0d\u5e94\u88ab\u6062\u590d\u903b\u8f91\u4fee\u6539\uff1a%+v %v", completedAttempts, err) } + var auditCount int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_logs WHERE action = 'task_recovery'`).Scan(&auditCount); err != nil || auditCount != 2 { + t.Fatalf("\u4e2d\u65ad\u4efb\u52a1\u5ba1\u8ba1\u6570\u91cf\u9519\u8bef\uff1a%d %v", auditCount, err) + } + rows, err := db.QueryContext(ctx, `SELECT resource_id, status, task_id, scope_type, project_id FROM audit_logs WHERE action = 'task_recovery' ORDER BY resource_id`) + if err != nil { + t.Fatalf("\u8bfb\u53d6\u4e2d\u65ad\u4efb\u52a1\u5ba1\u8ba1\u5931\u8d25\uff1a%v", err) + } + defer rows.Close() + for _, expectedID := range []string{"task-queued", "task-running"} { + if !rows.Next() { + t.Fatalf("\u7f3a\u5c11\u4e2d\u65ad\u4efb\u52a1\u5ba1\u8ba1\uff1a%s", expectedID) + } + var resourceID, status, taskID, scopeType, projectID string + if err := rows.Scan(&resourceID, &status, &taskID, &scopeType, &projectID); err != nil { + t.Fatalf("\u8bfb\u53d6\u4e2d\u65ad\u4efb\u52a1\u5ba1\u8ba1\u5931\u8d25\uff1a%v", err) + } + if resourceID != expectedID || status != "failed" || taskID != expectedID || scopeType != "project" { + t.Fatalf("\u4e2d\u65ad\u4efb\u52a1\u5ba1\u8ba1\u5185\u5bb9\u9519\u8bef:id=%s status=%s task=%s scope=%s project=%s", resourceID, status, taskID, scopeType, projectID) + } + } + if err := rows.Err(); err != nil { + t.Fatalf("\u8bfb\u53d6\u4e2d\u65ad\u4efb\u52a1\u5ba1\u8ba1\u6d41\u5931\u8d25\uff1a%v", err) + } + count, err = repository.RecoverInterruptedTasks(ctx, finished.Add(time.Minute)) + if err != nil || count != 0 { + t.Fatalf("\u670d\u52a1\u91cd\u542f\u6062\u590d\u4e0d\u5e94\u91cd\u590d\u6267\u884c\uff1a%d %v", count, err) + } + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_logs WHERE action = 'task_recovery'`).Scan(&auditCount); err != nil || auditCount != 2 { + t.Fatalf("\u91cd\u590d\u6062\u590d\u4e0d\u5e94\u65b0\u589e\u5ba1\u8ba1\uff1a%d %v", auditCount, err) + } +} + +func TestSQLTaskRepositoryRecoveryAuditFailureRollsBack(t *testing.T) { + repository, db := newTaskRepositoryTest(t) + defer db.Close() + ctx := context.Background() + if err := repository.CreateTask(ctx, Task{ + ID: "task-recovery-audit-failure", DomainID: "domain-1", TaskType: "certificate_issue", + Status: "running", Stage: "certificate_issue", Message: "\u6b63\u5728\u6267\u884c", + }); err != nil { + t.Fatalf("\u521b\u5efa\u5ba1\u8ba1\u56de\u6eda\u6d4b\u8bd5\u4efb\u52a1\u5931\u8d25\uff1a%v", err) + } + if _, err := db.ExecContext(ctx, `CREATE TRIGGER reject_task_recovery_audit BEFORE INSERT ON audit_logs BEGIN SELECT RAISE(ABORT, 'audit-failure'); END`); err != nil { + t.Fatalf("\u521b\u5efa\u5ba1\u8ba1\u5931\u8d25\u89e6\u53d1\u5668\u5931\u8d25\uff1a%v", err) + } + defer db.ExecContext(context.Background(), `DROP TRIGGER reject_task_recovery_audit`) + if count, err := repository.RecoverInterruptedTasks(ctx, time.Date(2026, 8, 30, 10, 0, 0, 0, time.UTC)); err == nil || count != 0 { + t.Fatalf("\u5ba1\u8ba1\u5931\u8d25\u65f6\u5e94\u56de\u6eda\u6062\u590d\uff1a%d %v", count, err) + } + item, err := repository.GetTask(ctx, "task-recovery-audit-failure") + if err != nil || item.Status != "running" || item.Stage != "certificate_issue" { + t.Fatalf("\u5ba1\u8ba1\u5931\u8d25\u540e\u4efb\u52a1\u72b6\u6001\u672a\u56de\u6eda\uff1a%+v %v", item, err) + } + var auditCount int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_logs`).Scan(&auditCount); err != nil || auditCount != 0 { + t.Fatalf("\u5ba1\u8ba1\u5931\u8d25\u540e\u4e0d\u5e94\u4fdd\u7559\u65e5\u5fd7\uff1a%d %v", auditCount, err) + } } func TestSQLTaskRepositoryRejectsConcurrentDuplicateIdempotencyKeys(t *testing.T) { diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index ee40411..bace31d 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -3605,3 +3605,14 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无 - [!] 总体验收不得升级:当前仍为 **P0-03 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。 本轮下一步继续审查任务中心剩余入口和备份恢复;开发阶段收尾后,再统一执行生产构建、实机部署和验收。 + +## 本轮开发补充(2026-09-29) + +总体验收位置继续保持 **P0-03:完成,待复审**;本轮完成服务重启恢复审计链开发,不代表 P0-10 整项复审或生产部署验收通过。 + +- [x] 已完成,等待复审:服务重启恢复会为每个 `queued`、`running`、`retry_wait`、`cancelling` 任务写入 `task_recovery` 失败审计,并保留任务、项目和创建人归属。 +- [x] 已完成,等待复审:恢复事务新增审计写入失败回滚保护;审计数量与实际恢复任务数量不一致时,任务、尝试和审计全部回滚。 +- [x] 已完成,等待复审:新增重复恢复与审计失败回滚测试;专项恢复测试、后端全量 `go test ./...` 和 `git diff --check` 均通过。 +- [~] 进行中:P0-10 的 DNS 验证、批量部署结果、其他入口任务事务链、重试幂等及跨数据库实机验证仍需继续审查。 +- [ ] 未开始:P0-12 备份恢复、浏览器清缓存、密码 SSH、远端部署和最终生产门禁仍未执行。 +- [!] 总体验收不得升级:当前仍处于 **P0-03 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。 \ No newline at end of file diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 921a819..7d67145 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -911,3 +911,14 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全 - [!] 总体验收不得升级:当前仍为 **P0-03 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。 本轮下一步继续审查任务中心剩余入口和备份恢复;开发阶段收尾后,再统一执行生产构建、实机部署和验收。 + +## 本轮开发补充(2026-09-29) + +总体验收位置继续保持 **P0-03:完成,待复审**;本轮完成服务重启恢复审计链开发,不代表 P0-10 整项复审或生产部署验收通过。 + +- [x] 已完成,等待复审:服务重启恢复会为每个 `queued`、`running`、`retry_wait`、`cancelling` 任务写入 `task_recovery` 失败审计,并保留任务、项目和创建人归属。 +- [x] 已完成,等待复审:恢复事务新增审计写入失败回滚保护;审计数量与实际恢复任务数量不一致时,任务、尝试和审计全部回滚。 +- [x] 已完成,等待复审:新增重复恢复与审计失败回滚测试;专项恢复测试、后端全量 `go test ./...` 和 `git diff --check` 均通过。 +- [~] 进行中:P0-10 的 DNS 验证、批量部署结果、其他入口任务事务链、重试幂等及跨数据库实机验证仍需继续审查。 +- [ ] 未开始:P0-12 备份恢复、浏览器清缓存、密码 SSH、远端部署和最终生产门禁仍未执行。 +- [!] 总体验收不得升级:当前仍处于 **P0-03 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。 \ No newline at end of file diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 8ebf090..524a7cf 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1548,3 +1548,14 @@ P0-11 生产验收门禁 - [!] 总体验收不得升级:当前仍为 **P0-03 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。 本轮下一步继续审查任务中心剩余入口和备份恢复;开发阶段收尾后,再统一执行生产构建、实机部署和验收。 + +## 本轮开发补充(2026-09-29) + +总体验收位置继续保持 **P0-03:完成,待复审**;本轮完成服务重启恢复审计链开发,不代表 P0-10 整项复审或生产部署验收通过。 + +- [x] 已完成,等待复审:服务重启恢复会为每个 `queued`、`running`、`retry_wait`、`cancelling` 任务写入 `task_recovery` 失败审计,并保留任务、项目和创建人归属。 +- [x] 已完成,等待复审:恢复事务新增审计写入失败回滚保护;审计数量与实际恢复任务数量不一致时,任务、尝试和审计全部回滚。 +- [x] 已完成,等待复审:新增重复恢复与审计失败回滚测试;专项恢复测试、后端全量 `go test ./...` 和 `git diff --check` 均通过。 +- [~] 进行中:P0-10 的 DNS 验证、批量部署结果、其他入口任务事务链、重试幂等及跨数据库实机验证仍需继续审查。 +- [ ] 未开始:P0-12 备份恢复、浏览器清缓存、密码 SSH、远端部署和最终生产门禁仍未执行。 +- [!] 总体验收不得升级:当前仍处于 **P0-03 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。 \ No newline at end of file diff --git a/doc/基础整改任务规格.md b/doc/基础整改任务规格.md index b5119e9..c6a1bc9 100644 --- a/doc/基础整改任务规格.md +++ b/doc/基础整改任务规格.md @@ -489,3 +489,14 @@ P0-06 与 P0-07 均维持 `[~]`,六阶段中的隔离、生产和最终验收 - [!] 总体验收不得升级:当前仍为 **P0-03 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。 本轮下一步继续审查任务中心剩余入口和备份恢复;开发阶段收尾后,再统一执行生产构建、实机部署和验收。 + +## 本轮开发补充(2026-09-29) + +总体验收位置继续保持 **P0-03:完成,待复审**;本轮完成服务重启恢复审计链开发,不代表 P0-10 整项复审或生产部署验收通过。 + +- [x] 已完成,等待复审:服务重启恢复会为每个 `queued`、`running`、`retry_wait`、`cancelling` 任务写入 `task_recovery` 失败审计,并保留任务、项目和创建人归属。 +- [x] 已完成,等待复审:恢复事务新增审计写入失败回滚保护;审计数量与实际恢复任务数量不一致时,任务、尝试和审计全部回滚。 +- [x] 已完成,等待复审:新增重复恢复与审计失败回滚测试;专项恢复测试、后端全量 `go test ./...` 和 `git diff --check` 均通过。 +- [~] 进行中:P0-10 的 DNS 验证、批量部署结果、其他入口任务事务链、重试幂等及跨数据库实机验证仍需继续审查。 +- [ ] 未开始:P0-12 备份恢复、浏览器清缓存、密码 SSH、远端部署和最终生产门禁仍未执行。 +- [!] 总体验收不得升级:当前仍处于 **P0-03 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。 \ No newline at end of file