From 4e8d50919ad1a1a11dd737f15d2feeeea5df16dd Mon Sep 17 00:00:00 2001 From: xuans Date: Fri, 2 Oct 2026 17:51:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=AE=8C=E6=95=B4=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=8A=A5=E5=91=8A=E5=8F=8A=E5=AE=8C=E6=88=90=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E5=8E=9F=E5=AD=90=E5=8C=96=E5=9F=BA=E7=BA=BF=EF=BC=8C?= =?UTF-8?q?=E6=9C=80=E6=96=B0=E5=9B=9E=E5=BD=92=E5=BE=85=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/database/migrate.go | 7 +- .../mysql/0020_task_deployment_reports.sql | 8 ++ .../postgres/0020_task_deployment_reports.sql | 8 ++ .../sqlite/0020_task_deployment_reports.sql | 8 ++ .../database/mysql_deployment_migration.go | 2 +- .../mysql_deployment_migration_test.go | 2 +- .../internal/database/sqlite_close_test.go | 4 + backend/internal/httpapi/task_api.go | 36 +---- backend/internal/httpapi/task_api_test.go | 4 +- backend/internal/httpapi/task_executor.go | 1 + .../internal/httpapi/task_executor_test.go | 16 +-- backend/internal/httpapi/task_report_test.go | 71 ++++++++++ .../repository/project_task_reader.go | 1 + .../internal/repository/task_completion.go | 28 +++- .../repository/task_completion_audit.go | 30 +++++ backend/internal/repository/task_report.go | 109 ++++++++++++++++ .../internal/repository/task_report_test.go | 123 ++++++++++++++++++ .../internal/repository/task_repository.go | 1 + doc/Scert整改任务执行清单.md | 12 ++ frontend/src/views/Dashboard/TaskMonitor.js | 18 ++- .../src/views/Dashboard/TaskMonitor.test.js | 68 ++++++++++ 21 files changed, 505 insertions(+), 52 deletions(-) create mode 100644 backend/internal/database/migrations/mysql/0020_task_deployment_reports.sql create mode 100644 backend/internal/database/migrations/postgres/0020_task_deployment_reports.sql create mode 100644 backend/internal/database/migrations/sqlite/0020_task_deployment_reports.sql create mode 100644 backend/internal/httpapi/task_report_test.go create mode 100644 backend/internal/repository/task_completion_audit.go create mode 100644 backend/internal/repository/task_report.go create mode 100644 backend/internal/repository/task_report_test.go diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index 1c1d00b..877b299 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -20,6 +20,7 @@ import ( //go:embed migrations/sqlite/0016_task_deployment_results.sql migrations/mysql/0016_task_deployment_results.sql migrations/postgres/0016_task_deployment_results.sql //go:embed migrations/sqlite/0017_deployment_transactions.sql migrations/mysql/0017_deployment_transactions.sql migrations/postgres/0017_deployment_transactions.sql //go:embed migrations/sqlite/0018_recovery_evidence.sql migrations/mysql/0018_recovery_evidence.sql migrations/postgres/0018_recovery_evidence.sql migrations/sqlite/0019_password_auth_only.sql migrations/mysql/0019_password_auth_only.sql migrations/postgres/0019_password_auth_only.sql +//go:embed migrations/sqlite/0020_task_deployment_reports.sql migrations/mysql/0020_task_deployment_reports.sql migrations/postgres/0020_task_deployment_reports.sql var migrationFiles embed.FS func Migrate(ctx context.Context, db *sql.DB, driver string) error { @@ -44,7 +45,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error { return nil } -const latestMigrationVersion = 19 +const latestMigrationVersion = 20 // currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。 func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) { @@ -84,7 +85,7 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) return applySQLiteTaskMigration(ctx, db, statements, version) } if driver == "mysql" { - if version >= 15 && version <= 17 { + if (version >= 15 && version <= 17) || version == 20 { return applyMySQLDeploymentMigration(ctx, db, version, statements) } if version == 14 { @@ -237,6 +238,8 @@ func migrationName(version int) string { return "recovery_evidence" case 19: return "password_auth_only" + case 20: + return "task_deployment_reports" default: return "unknown" } diff --git a/backend/internal/database/migrations/mysql/0020_task_deployment_reports.sql b/backend/internal/database/migrations/mysql/0020_task_deployment_reports.sql new file mode 100644 index 0000000..7a4ad0d --- /dev/null +++ b/backend/internal/database/migrations/mysql/0020_task_deployment_reports.sql @@ -0,0 +1,8 @@ +CREATE TABLE task_deployment_reports ( + task_id VARCHAR(36) NOT NULL, + attempt_id VARCHAR(36) NOT NULL, + attempt_number INT NOT NULL, + report_json MEDIUMTEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (task_id, attempt_id) +); diff --git a/backend/internal/database/migrations/postgres/0020_task_deployment_reports.sql b/backend/internal/database/migrations/postgres/0020_task_deployment_reports.sql new file mode 100644 index 0000000..6f41da7 --- /dev/null +++ b/backend/internal/database/migrations/postgres/0020_task_deployment_reports.sql @@ -0,0 +1,8 @@ +CREATE TABLE task_deployment_reports ( + task_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + report_json TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (task_id, attempt_id) +); diff --git a/backend/internal/database/migrations/sqlite/0020_task_deployment_reports.sql b/backend/internal/database/migrations/sqlite/0020_task_deployment_reports.sql new file mode 100644 index 0000000..590affa --- /dev/null +++ b/backend/internal/database/migrations/sqlite/0020_task_deployment_reports.sql @@ -0,0 +1,8 @@ +CREATE TABLE task_deployment_reports ( + task_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + report_json TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (task_id, attempt_id) +); diff --git a/backend/internal/database/mysql_deployment_migration.go b/backend/internal/database/mysql_deployment_migration.go index 82160cf..0ca15d3 100644 --- a/backend/internal/database/mysql_deployment_migration.go +++ b/backend/internal/database/mysql_deployment_migration.go @@ -57,7 +57,7 @@ type mysqlDeploymentShape struct { var ( mysqlDeploymentTablePattern = regexp.MustCompile(`^CREATE TABLE ([a-z_]+) \($`) - mysqlDeploymentColumnPattern = regexp.MustCompile(`^([a-z_]+) (VARCHAR\([0-9]+\)|INT|TEXT|DATETIME) NOT NULL(?: DEFAULT (CURRENT_TIMESTAMP|0))?( PRIMARY KEY)?$`) + mysqlDeploymentColumnPattern = regexp.MustCompile(`^([a-z_]+) (VARCHAR\([0-9]+\)|INT|TEXT|MEDIUMTEXT|DATETIME) NOT NULL(?: DEFAULT (CURRENT_TIMESTAMP|0))?( PRIMARY KEY)?$`) mysqlDeploymentIndexPattern = regexp.MustCompile(`^(PRIMARY KEY|UNIQUE KEY ([a-z_]+)|INDEX ([a-z_]+)) \(([a-z_, ]+)\)$`) mysqlIntegerWidthPattern = regexp.MustCompile(`^int\([0-9]+\)$`) ) diff --git a/backend/internal/database/mysql_deployment_migration_test.go b/backend/internal/database/mysql_deployment_migration_test.go index 6991c70..d8c0bc3 100644 --- a/backend/internal/database/mysql_deployment_migration_test.go +++ b/backend/internal/database/mysql_deployment_migration_test.go @@ -8,7 +8,7 @@ import ( ) func TestMySQLDeploymentShapeDefinitions(t *testing.T) { - for version := 15; version <= 17; version++ { + for _, version := range []int{15, 16, 17, 20} { statements := mysqlDeploymentStatements(t, version) shape, err := parseMySQLDeploymentShape(statements[0]) if err != nil || len(shape.Columns) == 0 || len(shape.Indexes) == 0 { diff --git a/backend/internal/database/sqlite_close_test.go b/backend/internal/database/sqlite_close_test.go index 64814f3..d3fedac 100644 --- a/backend/internal/database/sqlite_close_test.go +++ b/backend/internal/database/sqlite_close_test.go @@ -60,6 +60,10 @@ func TestSQLiteCanceledQueriesReleaseFiles(t *testing.T) { rows, err := query(ctx) if rows != nil { for rows.Next() { + // 单执行线程也要覆盖结果集已打开后的取消,保留其余轮次的自然竞争。 + if attempt%2 == 0 { + runtime.Gosched() + } var value int if scanErr := rows.Scan(&value); scanErr != nil { err = scanErr diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index 9d65d05..2996103 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -180,15 +180,6 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a } attempt.Stage = stage s.finishTaskBeforeExecution(task, attempt, "failed", failureMessage) - if s.audit != nil { - auditCtx, auditCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer auditCancel() - _ = s.audit.CreateAuditLog(auditCtx, repository.AuditLog{ - TaskID: task.ID, OperatorID: task.OperatorID, - Action: auditAction, ResourceType: "domain", ResourceID: task.DomainID, - Status: "error", Message: failureMessage, - }) - } return } if s.audit != nil { @@ -216,10 +207,6 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a finishCtx, finishCancel := context.WithTimeout(context.Background(), 10*time.Second) defer finishCancel() deploymentReport = sanitizeDeploymentReport(deploymentReport) - recordErr := s.tasks.RecordTaskDeploymentResults(finishCtx, task.ID, attempt.ID, attempt.AttemptNumber, taskDeploymentResults(deploymentReport)) - if recordErr != nil { - issueErr = errors.Join(issueErr, recordErr) - } finished := time.Now().UTC() status := "success" message := successMessage @@ -240,9 +227,6 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a } else if partial { status = "partial_success" } - if recordErr != nil { - status, message = "failed", "部署结果保存失败,请核查实际部署状态后再重试" - } s.taskMu.Lock() defer s.taskMu.Unlock() if persisted, err := s.tasks.GetTask(finishCtx, task.ID); err != nil { @@ -257,21 +241,10 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a message = "任务已停止;已执行操作是否回滚请查看部署结果" } } - if s.audit != nil { - auditStatus := status - if issueErr != nil && status != "cancelled" && status != "partial_success" { - auditStatus = "error" - } - if err := s.audit.CreateAuditLog(finishCtx, repository.AuditLog{ - TaskID: task.ID, OperatorID: task.OperatorID, - Action: auditAction, ResourceType: "domain", ResourceID: task.DomainID, Status: auditStatus, Message: message, - }); err != nil { - status, message = "failed", "任务完成审计保存失败,请检查任务恢复状态" - } - } if err := s.tasks.FinishTaskAttempt(finishCtx, repository.TaskCompletion{ TaskID: task.ID, AttemptID: attempt.ID, Status: status, Stage: stage, Message: message, StartedAt: &now, FinishedAt: finished, + Report: &deploymentReport, AuditAction: auditAction, }); err != nil && s.logger != nil { s.logger.Error("保存任务及尝试终态失败,保留中断恢复状态", "task_id", task.ID, "error", err) } @@ -620,7 +593,12 @@ func (s *Server) listTaskDeploymentResults(c *gin.Context) { items[index].Message = deployment.PublicStoredFailureMessage(items[index].Message) } } - c.JSON(http.StatusOK, gin.H{"items": items}) + reports, err := reader.ListVisibleTaskDeploymentReports(c.Request.Context(), actorID, c.Param("id")) + if err != nil { + s.writeError(c, http.StatusInternalServerError, "查询完整部署报告失败", err) + return + } + c.JSON(http.StatusOK, gin.H{"items": items, "reports": reports}) } func taskDeploymentResults(report deployment.Report) []repository.TaskDeploymentResult { diff --git a/backend/internal/httpapi/task_api_test.go b/backend/internal/httpapi/task_api_test.go index 64f2f13..085fafe 100644 --- a/backend/internal/httpapi/task_api_test.go +++ b/backend/internal/httpapi/task_api_test.go @@ -246,8 +246,8 @@ func TestTaskAPIClosesTaskWhenStartAuditFails(t *testing.T) { t.Fatalf("任务启动审计失败后尝试未收敛:%+v %v", attempts, err) } logs, err := auditRepo.ListAuditLogs(context.Background(), 10) - if err != nil || len(logs) != 0 { - t.Fatalf("启动审计写入失败时不应生成伪造终态审计:%+v %v", logs, err) + if err != nil || len(logs) != 1 || logs[0].Status != "error" || logs[0].Message != items[0].Message || logs[0].TaskID != items[0].ID { + t.Fatalf("启动失败必须由终态事务记录真实失败,不能伪造运行或成功审计:%+v %v", logs, err) } } func TestTaskAPIClosesTaskWhenStartStateUpdateFails(t *testing.T) { diff --git a/backend/internal/httpapi/task_executor.go b/backend/internal/httpapi/task_executor.go index f9b6f3f..3b0f25b 100644 --- a/backend/internal/httpapi/task_executor.go +++ b/backend/internal/httpapi/task_executor.go @@ -72,6 +72,7 @@ func (s *Server) finishTaskBeforeExecution(task repository.Task, attempt reposit if err := s.tasks.FinishTaskAttempt(ctx, repository.TaskCompletion{ TaskID: task.ID, AttemptID: attempt.ID, Status: status, Stage: attempt.Stage, Message: message, FinishedAt: finished, + AuditAction: task.TaskType, }); err != nil && s.logger != nil { s.logger.Error("保存任务及尝试终态失败,保留中断恢复状态", "task_id", task.ID, "error", err) } diff --git a/backend/internal/httpapi/task_executor_test.go b/backend/internal/httpapi/task_executor_test.go index 9f46884..9b24972 100644 --- a/backend/internal/httpapi/task_executor_test.go +++ b/backend/internal/httpapi/task_executor_test.go @@ -110,31 +110,31 @@ func TestAsyncDeadlineBeforeExecutionIsFailureNotUserCancellation(t *testing.T) } } -type failingTaskFinishAuditRepository struct{ repository.AuditRepository } +type failingTaskFinishAuditRepository struct{ repository.TaskRepository } -func (r failingTaskFinishAuditRepository) CreateAuditLog(ctx context.Context, item repository.AuditLog) error { - if item.Status == "success" { +func (r failingTaskFinishAuditRepository) FinishTaskAttempt(ctx context.Context, item repository.TaskCompletion) error { + if item.AuditAction != "" { return errors.New("模拟完成审计写入失败") } - return r.AuditRepository.CreateAuditLog(ctx, item) + return r.TaskRepository.FinishTaskAttempt(ctx, item) } func TestAsyncFinishAuditFailureDoesNotLeaveSuccessfulAttempt(t *testing.T) { - server, tasks, audit, cleanup := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) { + server, tasks, _, cleanup := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) { return certificate.IssueResult{}, deployment.Report{Status: "success"}, nil }) defer cleanup() - server.audit = failingTaskFinishAuditRepository{audit} + server.tasks = failingTaskFinishAuditRepository{tasks} response := httptest.NewRecorder() serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) task := submittedTask(t, response) waitTaskWorkersForTest(t, server) stored, err := tasks.GetTask(t.Context(), task.ID) - if err != nil || stored.Status != "failed" { + if err != nil || !isActiveTaskStatus(stored.Status) || stored.FinishedAt != nil { t.Fatalf("完成审计失败却标记成功:%+v %v", stored, err) } attempts, err := tasks.ListAttempts(t.Context(), task.ID) - if err != nil || len(attempts) != 1 || attempts[0].Status != "failed" || attempts[0].Message != stored.Message { + if err != nil || len(attempts) != 1 || !isActiveTaskStatus(attempts[0].Status) || attempts[0].FinishedAt != nil { t.Fatalf("任务与尝试终态不一致:%+v %v", attempts, err) } } diff --git a/backend/internal/httpapi/task_report_test.go b/backend/internal/httpapi/task_report_test.go new file mode 100644 index 0000000..e8c8c59 --- /dev/null +++ b/backend/internal/httpapi/task_report_test.go @@ -0,0 +1,71 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/certificate" + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +func TestAsyncTaskReportRestoresVerificationAndRecovery(t *testing.T) { + server, tasks, _, cleanup := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) { + return certificate.IssueResult{}, deployment.Report{ + Total: 1, Failed: 1, Status: "failed", VerificationStatus: "failed", RecoveryStatus: "success", + Results: []deployment.Result{{ServerID: "private-report-target", Status: "failed", FailureCode: deployment.FailureRecoveryRestored, Message: "secret-remote-output"}}, + }, errors.New("站点校验失败,已恢复") + }) + defer cleanup() + response := httptest.NewRecorder() + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) + task := submittedTask(t, response) + waitTaskWorkersForTest(t, server) + path := "/api/v1/tasks/" + task.ID + "/deployment-results" + response = httptest.NewRecorder() + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodGet, path, nil)) + var body struct { + Items []repository.TaskDeploymentResult `json:"items"` + Reports []repository.TaskDeploymentReport `json:"reports"` + } + if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &body) != nil || len(body.Items) != 1 || len(body.Reports) != 1 { + t.Fatalf("任务完整报告接口响应错误:%d %s", response.Code, response.Body.String()) + } + report := body.Reports[0] + if report.TaskID != task.ID || report.AttemptNumber != 1 || report.Report.VerificationStatus != "failed" || report.Report.RecoveryStatus != "success" || report.Report.Results[0].FailureCode != deployment.FailureRecoveryRestored { + t.Fatalf("接口遗漏验证和恢复报告:%+v", report) + } + if strings.Contains(response.Body.String(), "secret-remote-output") { + t.Fatal("接口泄露原始远端输出") + } + outsider := httpUserSessionCookie(server, "outsider", repository.UserRoleUser) + response = serveUserRequest(server, outsider, http.MethodGet, path, "") + if response.Code != http.StatusForbidden || strings.Contains(response.Body.String(), "private-report-target") { + t.Fatal("完整报告未遵守项目权限") + } + member := httpUserSessionCookie(server, "report-reader", repository.UserRoleUser) + if err := server.projectMembers.GrantProjectRole(t.Context(), "test-admin", domain.DefaultProjectID, "report-reader", domain.ProjectRoleReadOnly); err != nil { + t.Fatal(err) + } + response = serveUserRequest(server, member, http.MethodGet, path, "") + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "private-report-target") { + t.Fatal("只读成员无法读取完整报告") + } + if err := server.projectMembers.RevokeProjectRole(t.Context(), "test-admin", domain.DefaultProjectID, "report-reader", domain.ProjectRoleReadOnly); err != nil { + t.Fatal(err) + } + response = serveUserRequest(server, member, http.MethodGet, path, "") + if response.Code != http.StatusForbidden || strings.Contains(response.Body.String(), "private-report-target") { + t.Fatal("撤权后仍泄露完整报告") + } + visible, err := tasks.ListVisibleTaskDeploymentReports(t.Context(), "report-reader", task.ID) + if err != nil || len(visible) != 0 { + t.Fatalf("报告仓储未实时检查权限:%+v %v", visible, err) + } +} diff --git a/backend/internal/repository/project_task_reader.go b/backend/internal/repository/project_task_reader.go index e2af962..7701705 100644 --- a/backend/internal/repository/project_task_reader.go +++ b/backend/internal/repository/project_task_reader.go @@ -17,6 +17,7 @@ type ProjectTaskReader interface { GetVisibleTask(context.Context, string, string) (Task, error) ListVisibleTaskAttempts(context.Context, string, string) ([]TaskAttempt, error) ListVisibleTaskDeploymentResults(context.Context, string, string) ([]TaskDeploymentResult, error) + ListVisibleTaskDeploymentReports(context.Context, string, string) ([]TaskDeploymentReport, error) CancelVisibleTask(context.Context, string, string, string, *time.Time) error RequestCancelVisibleTask(context.Context, string, string) error RetryVisibleTask(context.Context, string, string, *time.Time) (Task, TaskAttempt, error) diff --git a/backend/internal/repository/task_completion.go b/backend/internal/repository/task_completion.go index df67965..3bc2f12 100644 --- a/backend/internal/repository/task_completion.go +++ b/backend/internal/repository/task_completion.go @@ -7,17 +7,21 @@ import ( "fmt" "strings" "time" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" ) // TaskCompletion 同时关闭任务与对应尝试,避免只保存其中一方的终态。 type TaskCompletion struct { - TaskID string - AttemptID string - Status string - Stage string - Message string - StartedAt *time.Time - FinishedAt time.Time + TaskID string + AttemptID string + Status string + Stage string + Message string + StartedAt *time.Time + FinishedAt time.Time + Report *deployment.Report + AuditAction string } func (r *SQLTaskRepository) FinishTaskAttempt(ctx context.Context, completion TaskCompletion) error { @@ -66,6 +70,16 @@ func (r *SQLTaskRepository) FinishTaskAttempt(ctx context.Context, completion Ta } else if count != 1 { return sql.ErrNoRows } + if completion.Report != nil { + if err := r.recordCompletionReport(ctx, tx, completion); err != nil { + return err + } + } + if completion.AuditAction != "" { + if err := r.recordCompletionAudit(ctx, tx, completion); err != nil { + return err + } + } if err := tx.Commit(); err != nil { return fmt.Errorf("提交任务收尾事务失败:%w", err) } diff --git a/backend/internal/repository/task_completion_audit.go b/backend/internal/repository/task_completion_audit.go new file mode 100644 index 0000000..696e5ba --- /dev/null +++ b/backend/internal/repository/task_completion_audit.go @@ -0,0 +1,30 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" +) + +// 完成审计从已锁定的任务取资源、操作者及项目,不信任外部传入的归属字段。 +func (r *SQLTaskRepository) recordCompletionAudit(ctx context.Context, tx *sql.Tx, completion TaskCompletion) error { + status := completion.Status + if status == "failed" { + status = "error" + } + result, 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 ?, CASE WHEN scope_type = 'system' THEN 'management_certificate' ELSE 'domain' END, + domain_id, ?, ?, ?, scope_type, project_id, id, operator_id + FROM tasks WHERE id = ?`), completion.AuditAction, status, completion.Message, + completion.FinishedAt.UTC(), completion.TaskID) + if err != nil { + return fmt.Errorf("保存任务完成审计失败:%w", err) + } + if count, err := result.RowsAffected(); err != nil { + return fmt.Errorf("读取任务完成审计写入结果失败:%w", err) + } else if count != 1 { + return sql.ErrNoRows + } + return nil +} diff --git a/backend/internal/repository/task_report.go b/backend/internal/repository/task_report.go new file mode 100644 index 0000000..7f11052 --- /dev/null +++ b/backend/internal/repository/task_report.go @@ -0,0 +1,109 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +// TaskDeploymentReport 保存一次尝试的完整公开报告,不包含远端输出或私钥。 +type TaskDeploymentReport struct { + TaskID string `json:"task_id"` + AttemptID string `json:"attempt_id"` + AttemptNumber int `json:"attempt_number"` + Report deployment.Report `json:"report"` + CreatedAt time.Time `json:"created_at"` +} + +func publicTaskReport(report deployment.Report) deployment.Report { + report.Results = append([]deployment.Result{}, report.Results...) + for index := range report.Results { + report.Results[index] = deployment.PublicResult(report.Results[index]) + } + return report +} + +// 报告、逐目标结果和两条终态共用事务;任一保存失败均保留活动记录供恢复接管。 +func (r *SQLTaskRepository) recordCompletionReport(ctx context.Context, tx *sql.Tx, completion TaskCompletion) error { + var attemptNumber int + if err := tx.QueryRowContext(ctx, r.query(`SELECT attempt_number FROM task_attempts WHERE id = ? AND task_id = ?`), + completion.AttemptID, completion.TaskID).Scan(&attemptNumber); err != nil { + return fmt.Errorf("读取报告所属尝试失败:%w", err) + } + report := publicTaskReport(*completion.Report) + content, err := json.Marshal(report) + if err != nil { + return fmt.Errorf("序列化部署报告失败:%w", err) + } + if _, err := tx.ExecContext(ctx, r.query(`INSERT INTO task_deployment_reports + (task_id, attempt_id, attempt_number, report_json, created_at) VALUES (?, ?, ?, ?, ?)`), + completion.TaskID, completion.AttemptID, attemptNumber, string(content), completion.FinishedAt.UTC()); err != nil { + return fmt.Errorf("保存完整部署报告失败:%w", err) + } + if _, err := tx.ExecContext(ctx, r.query(`DELETE FROM task_deployment_results WHERE task_id = ? AND attempt_id = ?`), + completion.TaskID, completion.AttemptID); err != nil { + return fmt.Errorf("清理尝试旧结果失败:%w", err) + } + for index, result := range report.Results { + if _, err := tx.ExecContext(ctx, r.query(`INSERT INTO task_deployment_results + (task_id, attempt_id, attempt_number, result_index, server_id, server_name, status, message, backup_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), completion.TaskID, completion.AttemptID, attemptNumber, + index, result.ServerID, result.Server, result.Status, result.Message, result.BackupID, completion.FinishedAt.UTC()); err != nil { + return fmt.Errorf("保存报告逐服务器结果失败:%w", err) + } + } + return nil +} + +func (r *SQLTaskRepository) ListTaskDeploymentReports(ctx context.Context, taskID string) ([]TaskDeploymentReport, error) { + if r == nil || r.db == nil || strings.TrimSpace(taskID) == "" { + return nil, errors.New("数据库连接和任务编号不能为空") + } + return r.listTaskReports(ctx, `SELECT task_id, attempt_id, attempt_number, report_json, created_at + FROM task_deployment_reports WHERE task_id = ? ORDER BY attempt_number`, taskID) +} + +func (r *SQLTaskRepository) ListVisibleTaskDeploymentReports(ctx context.Context, actorID, taskID string) ([]TaskDeploymentReport, error) { + if r == nil || r.db == nil { + return nil, errors.New("数据库连接不能为空") + } + predicate, args := taskVisibilityPredicate("tasks", domain.PermissionTaskRead) + args[0] = strings.TrimSpace(actorID) + args = append(args, strings.TrimSpace(taskID)) + return r.listTaskReports(ctx, `SELECT reports.task_id, reports.attempt_id, reports.attempt_number, reports.report_json, reports.created_at + FROM task_deployment_reports reports JOIN tasks ON tasks.id = reports.task_id + WHERE `+predicate+` AND reports.task_id = ? ORDER BY reports.attempt_number`, args...) +} + +func (r *SQLTaskRepository) listTaskReports(ctx context.Context, query string, args ...any) ([]TaskDeploymentReport, error) { + rows, err := r.db.QueryContext(ctx, r.query(query), args...) + if err != nil { + return nil, fmt.Errorf("查询完整部署报告失败:%w", err) + } + defer rows.Close() + items := make([]TaskDeploymentReport, 0) + for rows.Next() { + var item TaskDeploymentReport + var content string + if err := rows.Scan(&item.TaskID, &item.AttemptID, &item.AttemptNumber, &content, &item.CreatedAt); err != nil { + return nil, fmt.Errorf("读取完整部署报告失败:%w", err) + } + if err := json.Unmarshal([]byte(content), &item.Report); err != nil { + return nil, fmt.Errorf("解析完整部署报告失败:%w", err) + } + item.Report = publicTaskReport(item.Report) + item.CreatedAt = item.CreatedAt.UTC() + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("读取完整部署报告列表失败:%w", err) + } + return items, nil +} diff --git a/backend/internal/repository/task_report_test.go b/backend/internal/repository/task_report_test.go new file mode 100644 index 0000000..ac01a8c --- /dev/null +++ b/backend/internal/repository/task_report_test.go @@ -0,0 +1,123 @@ +package repository + +import ( + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" +) + +func completionTestReport() *deployment.Report { + return &deployment.Report{ + Total: 2, Success: 1, Failed: 1, Status: "failed", VerificationStatus: "failed", RecoveryStatus: "unconfirmed", + Results: []deployment.Result{ + {ServerID: "server-1", Server: "第一台", Status: "success", BackupID: "backup-1"}, + {ServerID: "server-2", Server: "第二台", Status: "failed", FailureCode: deployment.FailureRecoveryUnknown, Message: "secret-output-marker"}, + }, + } +} + +func TestTaskCompletionReportPersistsAcrossRepositoryRestart(t *testing.T) { + repo, db := newTaskRepositoryTest(t) + defer db.Close() + completion := seedTaskCompletion(t, repo) + completion.Status = "failed" + completion.Report = completionTestReport() + completion.AuditAction = "certificate_deploy" + if err := repo.FinishTaskAttempt(t.Context(), completion); err != nil { + t.Fatal(err) + } + reader := NewSQLTaskRepository(db) + var auditStatus, scope, projectID, resourceID string + if err := db.QueryRowContext(t.Context(), `SELECT status, scope_type, project_id, resource_id FROM audit_logs WHERE task_id = ?`, + completion.TaskID).Scan(&auditStatus, &scope, &projectID, &resourceID); err != nil || auditStatus != "error" || scope != "project" || projectID != "default" || resourceID != "domain-1" { + t.Fatalf("完成审计与任务状态或归属不一致:%s %s %s %s %v", auditStatus, scope, projectID, resourceID, err) + } + reports, err := reader.ListTaskDeploymentReports(t.Context(), completion.TaskID) + if err != nil || len(reports) != 1 { + t.Fatalf("完整报告未保存:%+v %v", reports, err) + } + stored := reports[0] + if stored.AttemptNumber != 1 || stored.AttemptID != completion.AttemptID || stored.Report.VerificationStatus != "failed" || stored.Report.RecoveryStatus != "unconfirmed" || len(stored.Report.Results) != 2 { + t.Fatalf("重读后验证或恢复证据丢失:%+v", stored) + } + if stored.Report.Results[1].FailureCode != deployment.FailureRecoveryUnknown || strings.Contains(stored.Report.Results[1].Message, "secret-output-marker") { + t.Fatal("报告未保留固定诊断或泄露远端输出") + } + var raw string + if err := db.QueryRowContext(t.Context(), `SELECT report_json FROM task_deployment_reports WHERE task_id = ?`, completion.TaskID).Scan(&raw); err != nil || strings.Contains(raw, "secret-output-marker") { + t.Fatalf("持久化前未脱敏:%v", err) + } + results, err := reader.ListTaskDeploymentResults(t.Context(), completion.TaskID) + if err != nil || len(results) != 2 || results[1].Message != stored.Report.Results[1].Message { + t.Fatalf("逐目标结果与报告不一致:%+v %v", results, err) + } + if completion.Report.Results[1].Message != "secret-output-marker" { + t.Fatal("保存报告不能原地修改调用者结果") + } + if _, err := db.ExecContext(t.Context(), `INSERT INTO users (id, username, password_hash, role, status) + VALUES ('report-admin', 'report-admin', 'test', 'admin', 'active')`); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(t.Context(), `INSERT INTO project_members (project_id, user_id, role) + VALUES ('default', 'report-admin', 'project_admin')`); err != nil { + t.Fatal(err) + } + _, retry, err := repo.RetryVisibleTask(t.Context(), "report-admin", completion.TaskID, nil) + if err != nil { + t.Fatal(err) + } + completion.AttemptID = retry.ID + completion.Status = "success" + completion.FinishedAt = time.Now().UTC() + completion.Report = &deployment.Report{Status: "success", VerificationStatus: "success"} + if err := repo.FinishTaskAttempt(t.Context(), completion); err != nil { + t.Fatal(err) + } + reports, err = reader.ListTaskDeploymentReports(t.Context(), completion.TaskID) + if err != nil || len(reports) != 2 || reports[0].Report.RecoveryStatus != "unconfirmed" || reports[1].AttemptNumber != 2 || reports[1].Report.VerificationStatus != "success" { + t.Fatalf("重试覆盖或混用了旧报告:%+v %v", reports, err) + } + if reports[1].Report.Results == nil { + t.Fatal("零目标报告必须返回空数组而非空值,避免前端忽略验证状态") + } +} + +func TestTaskCompletionReportFailureRollsBackAllRecords(t *testing.T) { + for _, table := range []string{"task_deployment_reports", "task_deployment_results", "audit_logs"} { + t.Run(table, func(t *testing.T) { + repo, db := newTaskRepositoryTest(t) + defer db.Close() + completion := seedTaskCompletion(t, repo) + completion.Report = completionTestReport() + completion.AuditAction = "certificate_deploy" + if _, err := db.ExecContext(t.Context(), `CREATE TRIGGER reject_report BEFORE INSERT ON `+table+` BEGIN SELECT RAISE(ABORT, 'injected report failure'); END`); err != nil { + t.Fatal(err) + } + if err := repo.FinishTaskAttempt(t.Context(), completion); err == nil { + t.Fatal("报告保存失败必须拒绝提交终态") + } + task, err := repo.GetTask(t.Context(), completion.TaskID) + if err != nil || task.Status != "running" || task.FinishedAt != nil { + t.Fatalf("报告失败但任务被提交:%+v %v", task, err) + } + attempts, err := repo.ListAttempts(t.Context(), completion.TaskID) + if err != nil || len(attempts) != 1 || attempts[0].Status != "running" || attempts[0].FinishedAt != nil { + t.Fatalf("报告失败但尝试被提交:%+v %v", attempts, err) + } + reports, err := repo.ListTaskDeploymentReports(t.Context(), completion.TaskID) + if err != nil || len(reports) != 0 { + t.Fatalf("出现部分提交的报告:%+v %v", reports, err) + } + results, err := repo.ListTaskDeploymentResults(t.Context(), completion.TaskID) + if err != nil || len(results) != 0 { + t.Fatalf("出现部分提交的目标结果:%+v %v", results, err) + } + var auditCount int + if err := db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM audit_logs WHERE task_id = ?`, completion.TaskID).Scan(&auditCount); err != nil || auditCount != 0 { + t.Fatalf("出现部分提交的完成审计:%d %v", auditCount, err) + } + }) + } +} diff --git a/backend/internal/repository/task_repository.go b/backend/internal/repository/task_repository.go index 8f99a8c..2947aaa 100644 --- a/backend/internal/repository/task_repository.go +++ b/backend/internal/repository/task_repository.go @@ -72,6 +72,7 @@ type TaskRepository interface { ListAttempts(context.Context, string) ([]TaskAttempt, error) RecordTaskDeploymentResults(context.Context, string, string, int, []TaskDeploymentResult) error ListTaskDeploymentResults(context.Context, string) ([]TaskDeploymentResult, error) + ListTaskDeploymentReports(context.Context, string) ([]TaskDeploymentReport, error) } type SQLTaskRepository struct { diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 046db7d..f8bde07 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,17 @@ # Scert 整改任务执行清单 +## 2026年10月2日17:30 完整异步报告整改入口 + +需求已沿用既定清单确认:异步执行后必须保留完整部署、站点验证与恢复报告;不能仅由逐目标成功推断整个任务成功。本轮仍只处理 Linux 开发与自动回归,不清线上数据,不部署。 + +- [x] 上轮终态事务与取消一致性整改已提交为 `8a8b1a5`,本轮已成功推送,解除此前远程同步超时。 +- [x] 接续记录确认上轮普通专项重复二十次通过;竞争检测已中断退出一百三十,不能记为通过。本轮已轮询原 SSH 会话,返回空闲提示符,不重新启动无限制负载。 +- [~] 增加完整报告三库迁移;报告、逐服务器结果及任务与尝试终态共同提交,保存失败整体回滚。 +- [~] 前端优先读取最新尝试的持久化报告,保留站点验证与恢复状态;旧历史不伪造验证结论。 +- [ ] 新增报告写入故障、事务回滚、重试隔离、权限、脱敏、刷新恢复和迁移回归。 +- [ ] 在第二台隔离源码运行最新全量后端回归与静态检查;本机运行前端测试和构建。自动回归不替代正式部署验收。 +- [!] 完成审计尚未与上述事务统一;竞争检测、其他长操作、关停与重启边界、历史恢复证据、通知重置、三库首次 Web、备份恢复与最终验收仍未关闭。 + ## 2026年10月2日17:11 异步收尾一致性整改 本节接续 16:47 的任务,不缩减原始 Linux 上线范围;本批仍未部署。 diff --git a/frontend/src/views/Dashboard/TaskMonitor.js b/frontend/src/views/Dashboard/TaskMonitor.js index f677e7e..267297a 100644 --- a/frontend/src/views/Dashboard/TaskMonitor.js +++ b/frontend/src/views/Dashboard/TaskMonitor.js @@ -6,8 +6,15 @@ export function taskIsPending(task) { return !terminalStatuses.has(task?.status); } -export function latestDeploymentReport(task, attempts, results) { +export function latestDeploymentReport(task, attempts, results, reports = []) { const latest = Math.max(0, ...attempts.map((item) => item.attempt_number || 0)); + if (taskIsPending(task) || latest === 0) return null; + const attempt = attempts.find((item) => item.attempt_number === latest); + const stored = reports.find((item) => item.task_id === task.id && + item.attempt_id === attempt.id && item.attempt_number === latest); + if (stored && Array.isArray(stored.report?.results)) { + return { ...stored.report, status: task.status }; + } const items = results.filter((item) => item.attempt_number === latest); if (taskIsPending(task) || latest === 0 || items.length === 0) return null; return { @@ -58,7 +65,14 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe if (!taskIsPending(task) && latestAttempt && latestAttempt.status !== task.status) { throw new Error("任务记录正在更新,将重新查询一致的执行结果"); } - return { task, attempts: attempts.items, report: latestDeploymentReport(task, attempts.items, results.items) }; + if (results.reports !== undefined && !Array.isArray(results.reports)) { + throw new Error("完整部署报告响应不完整,将自动重试"); + } + return { + task, + attempts: attempts.items, + report: latestDeploymentReport(task, attempts.items, results.items, results.reports), + }; } async function poll() { diff --git a/frontend/src/views/Dashboard/TaskMonitor.test.js b/frontend/src/views/Dashboard/TaskMonitor.test.js index 1f31f80..062badb 100644 --- a/frontend/src/views/Dashboard/TaskMonitor.test.js +++ b/frontend/src/views/Dashboard/TaskMonitor.test.js @@ -8,6 +8,50 @@ import { deferred, mountComposable } from "../../test-support/mount.js"; const initialTask = { id: "async-task", domain_id: "domain-1", task_type: "certificate_deploy", status: "running" }; const target = { server_id: "server-1", server: "部署目标", status: "success", attempt_number: 1 }; +test("完整报告恢复站点验证与回滚事实,不被逐目标结果覆盖", () => { + const task = { ...initialTask, status: "failed" }; + const attempts = [{ id: "attempt-2", attempt_number: 2, status: "failed" }]; + const persisted = { + task_id: task.id, attempt_id: "attempt-2", attempt_number: 2, + report: { + total: 1, success: 0, failed: 1, status: "failed", + verification_status: "failed", recovery_status: "success", + results: [{ ...target, status: "failed", failure_code: "recovery_restored" }], + }, + }; + const report = latestDeploymentReport(task, attempts, [target], [persisted]); + assert.equal(report.verification_status, "failed"); + assert.equal(report.recovery_status, "success"); + assert.equal(report.results[0].failure_code, "recovery_restored"); + assert.equal(report.success, 0); + assert.match(deploymentSummary(report), /恢复/); +}); + +test("完整报告按任务和尝试精确匹配,不复用旧尝试或其他任务报告", () => { + const task = { ...initialTask, status: "success" }; + const attempts = [{ id: "attempt-2", attempt_number: 2 }]; + for (const mismatch of [ + { task_id: "another-task", attempt_id: "attempt-2", attempt_number: 2 }, + { task_id: task.id, attempt_id: "attempt-1", attempt_number: 2 }, + { task_id: task.id, attempt_id: "attempt-2", attempt_number: 1 }, + ]) { + assert.equal(latestDeploymentReport(task, attempts, [], [{ + ...mismatch, report: { status: "success", verification_status: "success", results: [] }, + }]), null); + } +}); + +test("完整报告无逐目标结果仍可读取,执行成功不能覆盖任务收尾失败", () => { + const task = { ...initialTask, status: "failed" }; + const attempts = [{ id: "attempt-1", attempt_number: 1 }]; + const reports = [{ + task_id: task.id, attempt_id: "attempt-1", attempt_number: 1, + report: { status: "success", total: 0, success: 0, failed: 0, results: [] }, + }]; + assert.equal(latestDeploymentReport(task, attempts, [], reports).status, "failed"); + assert.equal(latestDeploymentReport(initialTask, attempts, [], reports), null); +}); + function taskFetcher(readTask, onPost = () => {}) { return async (path, options = {}) => { const task = readTask(); @@ -23,6 +67,30 @@ function taskFetcher(readTask, onPost = () => {}) { }; } +test("页面刷新查询完整报告并展示恢复状态", async (t) => { + const task = { ...initialTask, status: "failed" }; + const fetchTask = taskFetcher(() => task); + const { state } = await mountComposable(t, useDashboard, (path, options) => { + if (path.endsWith("/attempts")) { + return Response.json({ items: [{ id: "stored-attempt", attempt_number: 1, status: "failed" }] }); + } + if (path.endsWith("/deployment-results")) { + return Response.json({ items: [target], reports: [{ + task_id: task.id, attempt_id: "stored-attempt", attempt_number: 1, + report: { + status: "failed", total: 1, success: 0, failed: 1, + verification_status: "failed", recovery_status: "unconfirmed", results: [target], + }, + }] }); + } + return fetchTask(path, options); + }); + await state.openTaskDetails(task); + assert.equal(state.taskDeploymentReport.value.verification_status, "failed"); + assert.equal(state.taskDeploymentReport.value.recovery_status, "unconfirmed"); + assert.match(deploymentSummary(state.taskDeploymentReport.value), /恢复/); +}); + test("异步部署接收后不误报成功或缺失结果,最终失败不被逐目标成功覆盖", async (t) => { let current = { ...initialTask }; const posts = [];