diff --git a/.claude/docs/ai/async-tasks/api-handoff.md b/.claude/docs/ai/async-tasks/api-handoff.md index af12293..6144696 100644 --- a/.claude/docs/ai/async-tasks/api-handoff.md +++ b/.claude/docs/ai/async-tasks/api-handoff.md @@ -4,7 +4,7 @@ ## 业务范围 -证书申请、当前证书部署及手动重试先持久化任务,再交给后台工作线程执行。浏览器断线不取消已接收工作,前端通过任务接口确认最终结果。提交成功、逐服务器推送成功、整体任务成功是不同层级,不能混用。 +证书申请、当前证书部署、证书导入及申请/部署手动重试先持久化任务,再交给后台工作线程执行。浏览器断线不取消已接收工作,前端通过任务接口确认最终结果。提交成功、证书保存成功、逐服务器推送成功、整体任务成功是不同层级,不能混用。 ## 接口与权限 @@ -14,6 +14,7 @@ |---|---|---| | POST /api/v1/domains/:id/issue | 提交申请及关联部署,无请求体 | 可见域名、证书管理、部署执行、DNS 凭据使用权限;202 | | POST /api/v1/domains/:id/deploy | 提交已有证书部署,无请求体 | 可见域名、部署执行权限;202 | +| POST /api/v1/domains/:id/certificate/import | 上传证书后提交导入及部署任务 | 可见域名、证书管理、部署执行权限;不要求 DNS 凭据权限;202 | | POST /api/v1/tasks/:id/retry | 同一任务新增一次尝试,无请求体 | 重试、证书管理、部署执行权限;申请类还检查 DNS 凭据;202 | | DELETE /api/v1/tasks/:id | 请求取消 | 任务可见及取消权限;后台任务返回 202,非异步历史任务可能返回 200 | | GET /api/v1/tasks | 当前身份可见任务列表 | 支持 domain_id 和 limit;返回 200 与 items 数组 | @@ -131,11 +132,26 @@ deployment-results 响应保留旧 items,新增 reports;每次尝试独立 ## 回归与未完成项 +### 证书导入接线补充 + +- 上传使用 multipart/form-data:certificate、private_key 必填,issuer_bundle 可选;整个请求最多 20 MiB,包含表单开销,不是每个文件分别获得 20 MiB 配额。错误上传参数返回 400。 +- 兼容 JSON 路径字段 certificate_path、private_key_path、issuer_bundle_path;前两项必填,第三项可省略。路径在实际导入时仍受原有读取校验约束,异步接收不代表路径有效,不放宽任意本地文件读取权限。 +- 返回与申请相同的 202、accepted、task 和 Location;task_type 为 certificate_import。不返回同步证书路径,不允许收到 202 就显示“证书已导入”。缺少 task.id 应提示结果未知,不能自动重新提交。 +- 前端每次打开导入表单生成一个 Idempotency-Key,响应断开后继续使用原键。409 且 replayed=true 表示原任务仍活动,应跟踪原 task.id;已完成任务重放返回 200。相同键不比较上传内容,不用于更换文件或启动新的导入。 +- 在途同域名任务冲突、幂等重放、无任务仓储、队列满或服务正在关闭时,本次上传副本由提交路径回收;成功入队后所有权交给工作线程,正常结束、授权拒绝、取消、关停和可恢复异常均回收副本。客户端断开不影响已接收任务。JSON 指定的源文件不在回收范围内。 +- 进程强制终止后的上传残留清理尚未完成实机验证,不将正常关停回收扩大为崩溃清理验收通过。 +- 已执行导入的最终完整报告新增 certificate_imported: true,表示证书已经保存,不表示部署成功;false 时字段可省略。该标志与任务终态及报告同事务持久化,不含源文件路径和私钥材料。 +- 部署失败或部分成功且证书已保存时,任务消息明确提示检查结果后重试部署,无需重新导入。导入任务不支持 POST /tasks/:id/retry,返回 409,不能重放已回收的上传文件;修复目标后使用已有证书部署入口。 +- 前端刷新时读取最近尝试的完整报告恢复保存标志;任务查询失败只显示无法确认,不将上传失败、部署失败、取消和保存状态混为一谈。 + +导入验证场景:正常上传及请求结束后继续执行、排队取消、服务关停、队列拒绝、异常、无效幂等键、重复上传回收、保存后部署失败、部分成功、禁止重试丢失的上传文件,以及排队后的账户/权限/资源变化。 + 已加入自动用例:断线后继续执行、排队取消、取消收尾禁止重试、队列满、异常脱敏、读取和保存失败、跨任务尝试拒绝、终态事务回滚、已完成与部分成功不被取消意图覆盖、前端迟到响应和网络恢复。 - [~] 当前 Linux 自动回归持续执行;竞争检测尚无可靠通过结果。 -- [x] 完整验证及恢复报告持久化与页面读取已实现,关联回归和前端 256 项通过;未部署。 +- [x] 完整验证及恢复报告持久化与页面读取已实现,关联回归通过;导入接线后最新前端 260 项及生产构建通过,未部署。 - [x] HTTP 异步完成审计、逐服务器结果与任务终态同事务,已覆盖三类保存失败整体回滚;其他工作流及三库实机复审仍待完成。 - [x] 后台证书共用执行器新增五处故障回滚、每次重试报告、等待取消及无活动尝试收尾回归;应用、仓储、HTTP 三包原生 Linux 测试、全后端测试及 go vet 均通过,未正式部署。 -- [ ] 导入、版本恢复、校验及手动续期等其他长操作尚未全部异步化。 +- [x] 生产导入入口及前端任务跟踪已接入异步执行;专项回归通过,未正式部署。 +- [ ] 版本恢复、校验及手动续期等其他长操作尚未全部异步化;当前历史版本恢复的真实实现仍拒绝恢复,不能用模拟工作流测试替代功能验收。 - [ ] 正式构建部署后验证超过反代等待窗口的真实操作、断线查询、取消回滚和服务重启;本说明不替代生产验收。 diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 3e3316c..8a03579 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -196,6 +196,10 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string result, err := trackedWorkflow.Import(ctx, request) return result.Certificate, result.Deployment, err }, + ImportCertificateTask: func(ctx context.Context, request certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) { + result, err := workflow.Import(ctx, request) + return result.Certificate, result.Deployment, err + }, DeployCertificate: func(ctx context.Context, domainID string) (certificate.IssueResult, deployment.Report, error) { result, err := trackedWorkflow.DeployCurrent(ctx, domainID) return result.Certificate, result.Deployment, err diff --git a/backend/internal/deployment/service.go b/backend/internal/deployment/service.go index b3c5614..a60c7ee 100644 --- a/backend/internal/deployment/service.go +++ b/backend/internal/deployment/service.go @@ -49,6 +49,8 @@ type Report struct { // 站点校验与各服务器文件推送分开记录,不表示所有服务器均完成独立 HTTPS 验证。 VerificationStatus string `json:"verification_status,omitempty"` RecoveryStatus string `json:"recovery_status,omitempty"` + // 导入已保存与部署成功分开记录,不保存证书路径或私钥内容。 + CertificateImported bool `json:"certificate_imported,omitempty"` } type Service struct { diff --git a/backend/internal/httpapi/certificate_api.go b/backend/internal/httpapi/certificate_api.go index b461f5b..7b09e4a 100644 --- a/backend/internal/httpapi/certificate_api.go +++ b/backend/internal/httpapi/certificate_api.go @@ -13,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "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" ) @@ -166,7 +167,7 @@ func (s *Server) importCertificateFiles(c *gin.Context) { if !s.authorizeVisibleDomainPermissions(c, domain.PermissionCertificateManage, domain.PermissionDeploymentExecute) { return } - if s.importCertificate == nil { + if s.importCertificate == nil && s.importCertificateTask == nil { s.writeError(c, http.StatusNotImplemented, "证书导入接口尚未配置", errors.New("证书导入流程未配置")) return } @@ -176,6 +177,16 @@ func (s *Server) importCertificateFiles(c *gin.Context) { s.writeError(c, http.StatusBadRequest, "证书上传参数无效", err) return } + if s.importCertificateTask != nil { + // 仅传递独立参数和文件所有权,后台不得引用请求上下文。 + s.submitPreparedDomainTask(c, "certificate_import", &preparedCertificateTask{ + operation: func(ctx context.Context, _ string) (certificate.IssueResult, deployment.Report, error) { + return s.importCertificateTask(ctx, request) + }, + cleanup: cleanup, + }) + return + } defer cleanup() result, report, err := s.importCertificate(s.taskWorkflowContext(c), request) report = sanitizeDeploymentReport(report) @@ -206,6 +217,12 @@ func parseCertificateImportRequest(c *gin.Context) (certificate.ImportRequest, f cleanup := func() {} contentType := c.GetHeader("Content-Type") if strings.HasPrefix(strings.ToLower(contentType), "multipart/form-data") { + // 表单解析产生的暂存文件不属于异步工作线程;独立复制后立即回收。 + defer func() { + if c.Request.MultipartForm != nil { + _ = c.Request.MultipartForm.RemoveAll() + } + }() if err := c.Request.ParseMultipartForm(certificateUploadLimit); err != nil { return certificate.ImportRequest{}, cleanup, fmt.Errorf("解析上传表单失败:%w", err) } diff --git a/backend/internal/httpapi/certificate_import_async_test.go b/backend/internal/httpapi/certificate_import_async_test.go new file mode 100644 index 0000000..8f11ba5 --- /dev/null +++ b/backend/internal/httpapi/certificate_import_async_test.go @@ -0,0 +1,208 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/gin-gonic/gin" + + "git.pcux.cn/xuans/Scert/backend/internal/certificate" + "git.pcux.cn/xuans/Scert/backend/internal/deployment" +) + +func importUploadRequest(t *testing.T, key string) *http.Request { + t.Helper() + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + for _, name := range []string{"certificate", "private_key"} { + part, err := writer.CreateFormFile(name, name+".pem") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write([]byte(name)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/certificate/import", body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + request.Header.Set("Idempotency-Key", key) + return request +} + +func TestAsyncImportUploadLifecycle(t *testing.T) { + for _, scenario := range []string{"success", "failure", "saved_failure", "partial", "panic", "cancel", "shutdown", "stopped", "full", "invalid_key", "replay"} { + t.Run(scenario, func(t *testing.T) { + temporary := t.TempDir() + for _, name := range []string{"TMPDIR", "TMP", "TEMP"} { + t.Setenv(name, temporary) + } + var calls atomic.Int32 + server, tasks, _, cleanup := newTaskAPITestServer(t, nil) + defer cleanup() + server.importCertificateTask = func(_ context.Context, request certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) { + calls.Add(1) + for path, expected := range map[string]string{request.Certificate: "certificate", request.PrivateKey: "private_key"} { + data, err := os.ReadFile(path) + if err != nil || string(data) != expected { + t.Errorf("上传文件被提前回收或内容变化:%v", err) + } + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0600 { + t.Errorf("上传文件权限错误:%v", err) + } + } + if scenario == "panic" { + panic("private-import-error") + } + if scenario == "failure" { + return certificate.IssueResult{}, deployment.Report{}, errors.New("private-import-error") + } + result := certificate.IssueResult{DomainID: request.DomainID, Saved: certificate.SavedResource{ + CertificatePath: "/private/cert.pem", FullChainPath: "/private/fullchain.pem", PrivateKeyPath: "/private/key.pem", + }} + if scenario == "saved_failure" || scenario == "partial" { + status := "failed" + if scenario == "partial" { + status = "partial" + } + return result, deployment.Report{Status: status}, errors.New("private-deploy-error") + } + return result, deployment.Report{Status: "success"}, nil + } + server.asyncTasks = make(map[string]bool) + server.taskSlots = make(chan struct{}, 1) + server.taskSlots <- struct{}{} + key := "import-lifecycle" + switch scenario { + case "stopped": + server.stopTaskWorkers() + case "full": + for index := range 32 { + server.asyncTasks[string(rune('a'+index))] = true + } + case "invalid_key": + key = strings.Repeat("x", 300) + } + response := httptest.NewRecorder() + request := importUploadRequest(t, key) + requestCtx, cancelRequest := context.WithCancel(request.Context()) + serveAdminHTTP(server, response, request.WithContext(requestCtx)) + cancelRequest() + files, err := filepath.Glob(filepath.Join(temporary, "scert-upload-*")) + if err != nil { + t.Fatal(err) + } + if scenario == "stopped" || scenario == "full" || scenario == "invalid_key" { + want := http.StatusServiceUnavailable + if scenario == "invalid_key" { + want = http.StatusBadRequest + } + if response.Code != want || len(files) != 0 || calls.Load() != 0 { + t.Fatalf("拒绝提交仍保留上传文件或执行导入:%d %v %d", response.Code, files, calls.Load()) + } + return + } + task := submittedTask(t, response) + if task.TaskType != "certificate_import" || len(files) != 2 || calls.Load() != 0 { + t.Fatalf("排队状态或上传文件所有权错误:%+v %v %d", task, files, calls.Load()) + } + if scenario == "replay" { + replay := httptest.NewRecorder() + serveAdminHTTP(server, replay, importUploadRequest(t, key)) + remaining, _ := filepath.Glob(filepath.Join(temporary, "scert-upload-*")) + if replay.Code != http.StatusConflict || len(remaining) != 2 { + t.Fatalf("重复提交未回收本次上传文件:%d %v", replay.Code, remaining) + } + } + switch scenario { + case "cancel": + cancelled := httptest.NewRecorder() + serveAdminHTTP(server, cancelled, httptest.NewRequest(http.MethodDelete, "/api/v1/tasks/"+task.ID, nil)) + if cancelled.Code != http.StatusAccepted { + t.Fatalf("取消排队导入失败:%d %s", cancelled.Code, cancelled.Body.String()) + } + case "shutdown": + server.stopTaskWorkers() + default: + <-server.taskSlots + } + waitTaskWorkersForTest(t, server) + wantStatus := "success" + wantCalls := int32(1) + switch scenario { + case "failure", "saved_failure", "panic": + wantStatus = "failed" + case "partial": + wantStatus = "partial_success" + case "cancel", "shutdown": + wantStatus, wantCalls = "cancelled", 0 + } + stored, err := tasks.GetTask(t.Context(), task.ID) + if err != nil || stored.Status != wantStatus || calls.Load() != wantCalls { + t.Fatalf("导入终态错误:%+v %v,执行次数 %d", stored, err, calls.Load()) + } + for _, path := range files { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("上传文件未回收:%v", err) + } + } + if scenario == "saved_failure" || scenario == "partial" || scenario == "success" || scenario == "failure" { + reportResponse := httptest.NewRecorder() + serveAdminHTTP(server, reportResponse, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+task.ID+"/deployment-results", nil)) + var payload struct { + Reports []struct { + Report deployment.Report `json:"report"` + } `json:"reports"` + } + if err := json.Unmarshal(reportResponse.Body.Bytes(), &payload); err != nil || len(payload.Reports) != 1 { + t.Fatalf("完整导入结果未持久化:%s %v", reportResponse.Body.String(), err) + } + if payload.Reports[0].Report.CertificateImported != (scenario != "failure") { + t.Fatalf("导入是否保存的状态丢失:%+v", payload) + } + if strings.Contains(reportResponse.Body.String(), "private-") || strings.Contains(reportResponse.Body.String(), "/private/") || strings.Contains(stored.Message, "private-") { + t.Fatal("公开结果泄露内部错误或证书路径") + } + if scenario == "saved_failure" || scenario == "partial" { + if !strings.Contains(stored.Message, "无需重新导入") { + t.Fatalf("已保存证书的失败结果不明确:%s", stored.Message) + } + } + } + if scenario == "failure" { + retry := httptest.NewRecorder() + serveAdminHTTP(server, retry, httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+task.ID+"/retry", nil)) + if retry.Code != http.StatusConflict || calls.Load() != 1 { + t.Fatal("不得重放已回收上传文件的导入任务") + } + } + }) + } +} + +func TestAsyncImportRejectedSubmissionCleansFiles(t *testing.T) { + server, _, _, cleanup := newTaskAPITestServer(t, nil) + defer cleanup() + var cleaned atomic.Int32 + server.tasks = nil + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Request = httptest.NewRequest(http.MethodPost, "/", nil) + server.submitPreparedDomainTask(ctx, "certificate_import", &preparedCertificateTask{cleanup: func() { cleaned.Add(1) }}) + if response.Code != http.StatusNotImplemented || cleaned.Load() != 1 { + t.Fatal("缺失任务仓储时必须回收上传文件") + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index cf60e69..cd9df8d 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -60,7 +60,9 @@ type Dependencies struct { IssueSetupCertificate func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error) RollbackSetupCertificate func(context.Context, certificate.SavedResource) error ImportCertificate func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) - DeployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error) + // ImportCertificateTask 只执行导入流程,复用 HTTP 层已经创建的任务。 + ImportCertificateTask func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) + DeployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error) // DeployCertificateTask 执行已有任务,不得重复创建任务或尝试记录。 DeployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error) RestoreCertificateVersion func(context.Context, string, string) (certificate.IssueResult, error) @@ -99,6 +101,7 @@ type Server struct { issueSetupCertificate func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error) rollbackSetupCertificate func(context.Context, certificate.SavedResource) error importCertificate func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) + importCertificateTask func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) deployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error) deployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error) restoreVersion func(context.Context, string, string) (certificate.IssueResult, error) @@ -167,6 +170,7 @@ func New(deps Dependencies) *Server { issueSetupCertificate: deps.IssueSetupCertificate, rollbackSetupCertificate: deps.RollbackSetupCertificate, importCertificate: deps.ImportCertificate, + importCertificateTask: deps.ImportCertificateTask, deployCertificate: deps.DeployCertificate, deployCertificateTask: deps.DeployCertificateTask, restoreVersion: deps.RestoreCertificateVersion, diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index d8feb48..583fc28 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -23,6 +23,21 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { } func (s *Server) submitDomainTask(c *gin.Context, taskType string) { + s.submitPreparedDomainTask(c, taskType, nil) +} + +type preparedCertificateTask struct { + operation func(context.Context, string) (certificate.IssueResult, deployment.Report, error) + cleanup func() +} + +func (s *Server) submitPreparedDomainTask(c *gin.Context, taskType string, prepared *preparedCertificateTask) { + transferred := false + defer func() { + if !transferred && prepared != nil && prepared.cleanup != nil { + prepared.cleanup() + } + }() if (taskType == "certificate_issue" && s.issueCertificate == nil) || (taskType == "certificate_deploy" && s.deployCertificateTask == nil) { s.writeError(c, http.StatusNotImplemented, "证书申请接口尚未配置", errors.New("证书申请工作流未配置")) return @@ -50,7 +65,7 @@ func (s *Server) submitDomainTask(c *gin.Context, taskType string) { s.writeError(c, http.StatusServiceUnavailable, "证书任务权限服务尚未就绪", errors.New("项目授权服务未配置")) return } - if taskType == "certificate_issue" { + if taskType == "certificate_issue" || taskType == "certificate_import" { if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), resource.ResourceScope.Normalize().ProjectID, domain.PermissionCertificateManage); err != nil { if errors.Is(err, repository.ErrProjectAccessDenied) { s.writeError(c, http.StatusForbidden, "无权管理此项目的证书任务", nil) @@ -149,6 +164,14 @@ func (s *Server) submitDomainTask(c *gin.Context, taskType string) { s.writeError(c, http.StatusInternalServerError, "创建任务尝试失败", err) return } + if prepared != nil { + transferred = s.enqueueCertificateTask(c, task, attempt, taskType, prepared.operation, + taskType, "正在导入证书并部署", "证书导入和部署完成", prepared.cleanup) + if transferred { + writeAcceptedCertificateTask(c, task) + } + return + } if taskType == "certificate_deploy" { s.executeCertificateDeployTask(c, task, attempt, taskType) return @@ -157,11 +180,15 @@ func (s *Server) submitDomainTask(c *gin.Context, taskType string) { } func (s *Server) executeCertificateIssueTask(c *gin.Context, task repository.Task, attempt repository.TaskAttempt, auditAction string) { - s.enqueueCertificateTask(c, task, attempt, auditAction, s.issueCertificate, "certificate_issue", "正在申请证书并部署", "证书申请和部署完成") + if s.enqueueCertificateTask(c, task, attempt, auditAction, s.issueCertificate, "certificate_issue", "正在申请证书并部署", "证书申请和部署完成", nil) { + writeAcceptedCertificateTask(c, task) + } } func (s *Server) executeCertificateDeployTask(c *gin.Context, task repository.Task, attempt repository.TaskAttempt, auditAction string) { - s.enqueueCertificateTask(c, task, attempt, auditAction, s.deployCertificateTask, "certificate_deploy", "正在部署证书", "证书部署完成") + if s.enqueueCertificateTask(c, task, attempt, auditAction, s.deployCertificateTask, "certificate_deploy", "正在部署证书", "证书部署完成", nil) { + writeAcceptedCertificateTask(c, task) + } } func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, attempt repository.TaskAttempt, auditAction string, operation func(context.Context, string) (certificate.IssueResult, deployment.Report, error), stage, runningMessage, successMessage string) { @@ -214,7 +241,11 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a if s.finishCancelledTask(task, attempt, executionCtx) { return } - _, deploymentReport, issueErr := operation(executionCtx, task.DomainID) + certificateResult, deploymentReport, issueErr := operation(executionCtx, task.DomainID) + if task.TaskType == "certificate_import" { + deploymentReport.CertificateImported = issueErr == nil || (certificateResult.DomainID == task.DomainID && + certificateResult.Saved.CertificatePath != "" && certificateResult.Saved.FullChainPath != "" && certificateResult.Saved.PrivateKeyPath != "") + } finishCtx, finishCancel := context.WithTimeout(context.Background(), 10*time.Second) defer finishCancel() deploymentReport = sanitizeDeploymentReport(deploymentReport) @@ -238,6 +269,12 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a } else if partial { status = "partial_success" } + if task.TaskType == "certificate_import" && issueErr != nil { + message = "证书导入失败" + if deploymentReport.CertificateImported { + message = "证书已导入,但部署未完成。请检查部署结果后重试部署,无需重新导入。" + } + } s.taskMu.Lock() defer s.taskMu.Unlock() if persisted, err := s.tasks.GetTask(finishCtx, task.ID); err != nil { diff --git a/backend/internal/httpapi/task_execution_authorization.go b/backend/internal/httpapi/task_execution_authorization.go index d4027ac..98894ce 100644 --- a/backend/internal/httpapi/task_execution_authorization.go +++ b/backend/internal/httpapi/task_execution_authorization.go @@ -26,6 +26,8 @@ func (s *Server) authorizeCertificateTaskExecution(ctx context.Context, task rep case "certificate_issue": permissions = append(permissions, domain.PermissionCertificateManage, domain.PermissionDNSCredentialUse) case "certificate_deploy": + case "certificate_import": + permissions = append(permissions, domain.PermissionCertificateManage) default: return repository.ErrProjectAccessDenied } diff --git a/backend/internal/httpapi/task_execution_authorization_test.go b/backend/internal/httpapi/task_execution_authorization_test.go index 98319f7..9adfc7f 100644 --- a/backend/internal/httpapi/task_execution_authorization_test.go +++ b/backend/internal/httpapi/task_execution_authorization_test.go @@ -17,7 +17,7 @@ import ( ) func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) { - for _, taskType := range []string{"certificate_issue", "certificate_deploy"} { + for _, taskType := range []string{"certificate_issue", "certificate_deploy", "certificate_import"} { for _, scenario := range []string{ "unchanged", "disabled_user", "removed_member", "read_only_member", "moved_domain", "deleted_domain", "deployment_denied", "certificate_denied", @@ -32,6 +32,9 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) { server, tasks, audit, cleanup := newTaskAPITestServer(t, operation) defer cleanup() server.deployCertificateTask = operation + server.importCertificateTask = func(ctx context.Context, request certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) { + return operation(ctx, request.DomainID) + } server.asyncTasks = make(map[string]bool) server.taskSlots = make(chan struct{}, 1) server.taskSlots <- struct{}{} @@ -39,8 +42,12 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) { if taskType == "certificate_deploy" { path = "/api/v1/domains/domain-1/deploy" } + if taskType == "certificate_import" { + path = "/api/v1/domains/domain-1/certificate/import" + } response := httptest.NewRecorder() - serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, path, nil)) + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, path, + strings.NewReader(`{"certificate_path":"/managed/cert.pem","private_key_path":"/managed/key.pem"}`))) task := submittedTask(t, response) if calls.Load() != 0 { t.Fatal("占满执行槽后任务仍执行") @@ -95,7 +102,7 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) { <-server.taskSlots waitTaskWorkersForTest(t, server) want := "failed" - if scenario == "unchanged" || taskType == "certificate_deploy" && (scenario == "dns_denied" || scenario == "certificate_denied") { + if scenario == "unchanged" || taskType == "certificate_deploy" && (scenario == "dns_denied" || scenario == "certificate_denied") || taskType == "certificate_import" && scenario == "dns_denied" { want = "success" } stored, err := tasks.GetTask(t.Context(), task.ID) diff --git a/backend/internal/httpapi/task_executor.go b/backend/internal/httpapi/task_executor.go index 3b0f25b..2a212d5 100644 --- a/backend/internal/httpapi/task_executor.go +++ b/backend/internal/httpapi/task_executor.go @@ -15,13 +15,13 @@ import ( // 持久化任务与尝试后才调用此入口;后台执行不得持有请求或响应对象。 // 进程中断由启动恢复标记失败,不自动重放可能已经签发或部署的外部操作。 -func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, attempt repository.TaskAttempt, action string, operation func(context.Context, string) (certificate.IssueResult, deployment.Report, error), stage, runningMessage, successMessage string) { +func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, attempt repository.TaskAttempt, action string, operation func(context.Context, string) (certificate.IssueResult, deployment.Report, error), stage, runningMessage, successMessage string, cleanup func()) bool { s.taskMu.Lock() if s.taskWorkersStopped || len(s.asyncTasks) >= 32 { s.taskMu.Unlock() s.finishTaskBeforeExecution(task, attempt, "failed", "服务正在停止或任务队列已满,任务未执行") s.writeError(c, http.StatusServiceUnavailable, "服务正在停止或任务队列已满,请稍后重试", nil) - return + return false } ctx, cancel := context.WithTimeout(context.Background(), certificateTaskTimeout) if s.taskCancels == nil { @@ -37,6 +37,9 @@ func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, at s.taskMu.Unlock() go func() { defer s.taskWorkers.Done() + if cleanup != nil { + defer cleanup() + } defer s.unregisterTaskCancel(task.ID) defer cancel() defer func() { @@ -53,6 +56,11 @@ func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, at } s.runCertificateTask(ctx, task, attempt, action, operation, stage, runningMessage, successMessage) }() + return true +} + +// 上传文件所有权完成转交后再写响应;客户端断开或响应异常不得回收工作线程的文件。 +func writeAcceptedCertificateTask(c *gin.Context, task repository.Task) { c.Header("Location", "/api/v1/tasks/"+task.ID) c.JSON(http.StatusAccepted, gin.H{"task": task, "accepted": true}) } diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 307eeaf..fdf6f0c 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,18 @@ # Linux 生产流程测试记录 +## 2026年10月2日19:34 证书导入异步自动回归(未部署) + +- [x] 修改前确认 main 工作区干净,09616e7 基线推送返回已同步。第二台复用已核验主机指纹的密码 SSH 会话;隔离路径 /var/tmp/scert-async-20261002-1556/source/backend,不触碰正式安装或线上数据。 +- [x] 最终后端输入 import-async-second-20261002.tar 两端 SHA256 一致:748f229134f568e7269d15d9cdd8412c5a62cec1f97ca65d28cda57e35f118c4。仅上传本批源码与原生 Go 测试,不是正式发布包。 +- [!] import-after.log:首轮专项退出一,仅排队取消子场景因测试调用不存在的 POST /cancel 返回 404;修正为真实 DELETE /tasks/:id、202 响应,不放宽最终 cancelled 和零外部调用断言。 +- [x] import-second.log:专项通过,IMPORT_SECOND_EXIT=0,6.967 秒。包含十一种上传生命周期场景、缺少任务仓储回收、导入新增十二个实时授权场景,以及原有申请/部署/重试授权回归。 +- [x] import-full.log:全后端 go test -p 1 ./... -count=1 通过,IMPORT_FULL_EXIT=0;import-vet.log:go vet -p 1 ./... 通过,IMPORT_VET_EXIT=0。使用 nobody、GOMAXPROCS=1、GOMEMLIMIT=384MiB,测试自行创建独立空 SQLite 数据库,不将此记录当作三库完整实库验收。 +- [x] import-repeat.log:新增 TestAsyncImport 专项重复二十次通过,IMPORT_REPEAT_EXIT=0,29.448 秒;普通重复测试不替代竞争检测。 +- [x] 本机 .runtime/import-async-frontend-final-20261002.log:260 项通过,零失败、零跳过;.runtime/import-async-build-final-20261002.log:生产构建通过,退出码均为零。 +- [x] 测试后第二台 Scert、Nginx、MySQL 均 active;磁盘余量约 863MB。未新增公网监听、未重启生产服务、未清理线上数据库。 +- [!] 本批无正式部署或浏览器生产上传验收,无强制终止后的上传残留清理证明,无竞争检测通过结论。历史恢复、其他长操作及原有整体门禁继续未关闭。 + + ## 2026年10月2日19:01 排队任务实时授权回归(未部署) - [x] 修改前基线 657a9c0 已推送。第二台既有 SSH 会话已实时复核,测试目录为 /var/tmp/scert-async-20261002-1556/source/backend,磁盘剩余约 961MB;未清理或连接线上业务数据库。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 04836d9..81742b5 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,20 @@ # Scert 整改任务执行清单 +## 2026年10月2日19:34 证书导入异步化回归完成,等待复审 + +需求沿用已确认的 Linux 完整清单,先开发及自动回归,再统一部署,不处理 Windows。本批修改前已确认工作区干净并推送 09616e7 基线,无额外待提交文件。 + +- [x] 生产证书导入接入已有异步任务执行器,应用直接执行已有任务,不嵌套创建任务;返回 202 后继续导入和部署。 +- [x] 上传副本转交工作线程,拒绝提交、排队取消、正常关停、执行异常及正常结束均回收文件;响应写出前完成所有权转交。JSON 源文件不在清理范围内。 +- [x] 导入提交与执行前复核证书管理及部署权限;不要求 DNS 权限,导入任务的通用重试返回 409,不重放已回收上传文件。 +- [x] 完整报告增加 certificate_imported 标志,与任务终态同事务持久化;保留已导入但部署失败或部分成功的真实状态,不存储源文件路径及私钥内容。 +- [x] 前端展示任务编号并跟踪结果,不把 202 当作已导入;响应断开后沿用原幂等键找回任务,刷新可恢复保存标志,撤权清除缓存结果。 +- [x] 原生 Linux 专项、全后端测试及 go vet 通过;新增导入专项重复二十次通过,耗时 29.448 秒。第一轮测试误用取消路由的失败记录保留,按真实 DELETE 和 202 契约修正后通过。 +- [x] 最新前端 260 项全部通过、零跳过,生产构建通过;清单、Linux 测试记录和接口说明同步更新。本批不创建辅助验收脚本,不清线上数据库,不部署。 +- [!] 进程强制终止后的上传残留清理、真实浏览器上传及生产流程尚未验收;普通重复回归不替代竞争检测。第二台磁盘剩余约 863MB,Scert、Nginx、MySQL 保持 active。 +- [!] 历史版本恢复的真实实现目前明确拒绝恢复,与 P1-02 清单存在差异;该项继续保留,不能以模拟测试宣称完成。其余长操作、升级、通知重置、三库、备份恢复及完整生产门禁仍未关闭。 + + ## 2026年10月2日19:01 排队任务执行前权限复核 需求沿用已确认的 Linux 全清单,先开发及自动回归,再统一部署;不缩减范围、不处理 Windows。本批修改前已推送基线 657a9c0,工作区干净。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 5a64553..38cae37 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,5 +1,13 @@ # Scert 整改、合并与优化清单 +## 2026年10月2日19:34 证书导入异步化开发进度 + +- [x] P2-03 子项:生产导入入口返回后台任务,复用任务队列、实时权限复核及事务收尾,不重复创建任务。上传文件在请求结束后保留到工作线程完成,并覆盖拒绝、取消、关停及异常回收。 +- [x] P0-10/P1-04 配套:完整报告持久化证书已保存标志,区分导入失败与保存后部署失败;前端跟踪任务并支持响应断开后幂等找回,不提前显示成功,不重放已删除的上传材料。 +- [x] 原生 Linux 专项、全后端测试、go vet 及导入专项二十次重复回归通过;前端 260 项和生产构建通过。代码尚未正式部署,子项状态为完成等待复审。 +- [!] 历史恢复真实实现仍返回不支持恢复,与 P1-02 清单存在功能差异;该差异及校验等其他长操作继续保留。上传崩溃残留、升级、通知重置、三库、备份恢复与最终门禁均未据此关闭。 + + ## 2026年10月2日19:01 排队执行前权限开发进度 - [x] P0-07/P2-03 子项:确认并修复异步申请、部署及重试只有提交授权、缺少执行前复核的问题。执行槽取得后读取当前账户和域名归属,复核本次操作权限;归属变化即拒绝,不因仍可访问新项目而沿用旧任务。 @@ -1358,6 +1366,8 @@ frontend/src/views/Certificates/ **状态:** `[~] 进行中` +**2026年10月2日核对:** 当前文件存储只列出当前版本,真实恢复实现直接拒绝历史恢复,前端也未形成完整历史版本操作链;模拟工作流的恢复测试不代表此功能已经实现。保持本项未完成,不删除原始需求。 + 证书详情至少包含: ```text @@ -1747,6 +1757,8 @@ cancelled 已取消 **状态:** `[~] 进行中` +**2026年10月2日19:34 子项进度:** 申请、当前证书部署、申请/部署重试及生产证书导入已接入 HTTP 异步任务;导入增加上传生命周期、执行前授权、幂等找回和保存结果持久化,自动回归通过待复审。校验、历史恢复及其余列出的长操作不因本批通过而关闭,整项继续进行中。 + 以下操作不能长期阻塞 HTTP 请求: ```text diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index 5c3e4da..f930fbc 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -64,6 +64,7 @@ export function useDashboard() { const importSaving = ref(false); const importError = ref(""); const importResult = ref(null); + let importIdempotencyKey = ""; const importFiles = ref({ certificate: null, private_key: null, @@ -839,6 +840,8 @@ export function useDashboard() { function openCertificateImport(item) { importDomain.value = item; + const suffix = crypto.randomUUID ? crypto.randomUUID() : createRandomSuffix(); + importIdempotencyKey = `import-${item.id}-${suffix}`; importFiles.value = { certificate: null, private_key: null, @@ -882,9 +885,25 @@ export function useDashboard() { } const response = await fetchAdmin( `/api/v1/domains/${importDomain.value.id}/certificate/import`, - { method: "POST", body }, + { method: "POST", body, headers: { "Idempotency-Key": importIdempotencyKey } }, ); const result = await response.json().catch(() => ({})); + if (response.status === 202 && !result.task?.id) { + throw new Error("服务未返回任务编号,请在运行概览确认是否已接收,勿重复提交"); + } + if ((response.ok || response.status === 409 && result.replayed) && result.task?.id && + (response.status === 202 || result.accepted || result.replayed)) { + importResult.value = result; + showCertificateImport.value = false; + notice.value = `导入任务已提交:${result.task.id},可在运行概览查看执行结果`; + deploymentResult.value = { + domain: importDomain.value.name, running: false, pending: true, + taskID: result.task.id, taskStatus: result.task.status, + taskMessage: result.task.message || "", report: null, error: "", queryError: "", + }; + await loadData(); + return; + } if (!response.ok && result.imported !== true) { throw new Error(result.error || "导入证书失败"); } diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index 9e656a8..e18231a 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -192,6 +192,71 @@ test("certificateImport.submitsSelectedFilesAndResetsOnReopen", async (t) => { assert.ok(state.importError.value); }); +test("导入异步接收只展示任务并等待结果,不提前宣布保存成功", async (t) => { + let imports = 0; + const { state } = await mountComposable(t, useDashboard, async (path) => { + if (path.endsWith("/certificate/import")) { + imports++; + return Response.json({ accepted: true, task: { id: "import-task", status: "queued" } }, { status: 202 }); + } + return Response.json({ items: [] }); + }); + state.openCertificateImport({ id: "domain-import", name: "example.test" }); + for (const kind of ["certificate", "private_key"]) { + state.selectCertificateImportFile(kind, { target: { files: [new File([kind], `${kind}.pem`)] } }); + } + await state.importCertificate(); + assert.equal(state.deploymentResult.value.taskID, "import-task"); + assert.equal(state.deploymentResult.value.pending, true); + assert.equal(state.deploymentResult.value.report, null); + assert.notEqual(state.deploymentResult.value.certificateImported, true); + assert.equal(state.showCertificateImport.value, false); + assert.match(state.notice.value, /导入任务已提交/); + assert.doesNotMatch(state.notice.value, /证书已导入/); + await state.importCertificate(); + assert.equal(imports, 1); +}); + +test("导入响应断开后使用原幂等键找回任务,不重复执行导入", async (t) => { + const keys = []; + const { state } = await mountComposable(t, useDashboard, async (path, options) => { + if (path.endsWith("/certificate/import")) { + keys.push(options.headers["Idempotency-Key"]); + if (keys.length === 1) throw new Error("连接断开"); + return Response.json({ replayed: true, task: { id: "existing-import", status: "running" } }, { status: 409 }); + } + return Response.json({ items: [] }); + }); + state.openCertificateImport({ id: "domain-import", name: "example.test" }); + for (const kind of ["certificate", "private_key"]) { + state.selectCertificateImportFile(kind, { target: { files: [new File([kind], `${kind}.pem`)] } }); + } + await state.importCertificate(); + assert.match(state.importError.value, /连接断开/); + await state.importCertificate(); + assert.equal(keys.length, 2); + assert.match(keys[0], /^import-domain-import-/); + assert.equal(keys[0], keys[1]); + assert.equal(state.importError.value, ""); + assert.equal(state.deploymentResult.value.taskID, "existing-import"); + assert.equal(state.deploymentResult.value.pending, true); +}); + +test("异步导入缺少任务编号时不能显示导入成功", async (t) => { + const { state } = await mountComposable(t, useDashboard, async (path) => { + if (path.endsWith("/certificate/import")) return Response.json({ accepted: true }, { status: 202 }); + return Response.json({ items: [] }); + }); + state.openCertificateImport({ id: "domain-import", name: "example.test" }); + for (const kind of ["certificate", "private_key"]) { + state.selectCertificateImportFile(kind, { target: { files: [new File([kind], `${kind}.pem`)] } }); + } + await state.importCertificate(); + assert.match(state.importError.value, /未返回任务编号/); + assert.equal(state.importResult.value, null); + assert.doesNotMatch(state.notice.value, /证书已导入/); +}); + test("证书已导入但部署失败时展示服务器结果并刷新状态,不能重复导入", async (t) => { let imports = 0; let refreshes = 0; diff --git a/frontend/src/views/Dashboard/TaskMonitor.js b/frontend/src/views/Dashboard/TaskMonitor.js index 267297a..03d4dde 100644 --- a/frontend/src/views/Dashboard/TaskMonitor.js +++ b/frontend/src/views/Dashboard/TaskMonitor.js @@ -110,6 +110,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe taskStatus: snapshot.task.status, taskMessage: snapshot.task.message || "", report: snapshot.report, + certificateImported: snapshot.report?.certificate_imported === true, queryError: "", }; } @@ -130,6 +131,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe deploymentResult.value.queryError = message; if (denied) { deploymentResult.value.report = null; + deploymentResult.value.certificateImported = false; deploymentResult.value.taskMessage = ""; deploymentResult.value.taskStatus = ""; } @@ -149,6 +151,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe taskDeploymentReport.value = null; if (deploymentResult.value) { deploymentResult.value.report = null; + deploymentResult.value.certificateImported = false; deploymentResult.value.taskMessage = ""; } } diff --git a/frontend/src/views/Dashboard/TaskMonitor.test.js b/frontend/src/views/Dashboard/TaskMonitor.test.js index 062badb..c419653 100644 --- a/frontend/src/views/Dashboard/TaskMonitor.test.js +++ b/frontend/src/views/Dashboard/TaskMonitor.test.js @@ -91,6 +91,30 @@ test("页面刷新查询完整报告并展示恢复状态", async (t) => { assert.match(deploymentSummary(state.taskDeploymentReport.value), /恢复/); }); +test("异步导入从持久化报告恢复保存状态,不因部署失败诱导重复导入", async (t) => { + const task = { ...initialTask, task_type: "certificate_import", status: "failed", message: "证书已导入,但部署未完成" }; + const fetchTask = taskFetcher(() => task); + const { state } = await mountComposable(t, useDashboard, (path, options) => { + if (path.endsWith("/attempts")) { + return Response.json({ items: [{ id: "import-attempt", attempt_number: 1, status: "failed" }] }); + } + if (path.endsWith("/deployment-results")) { + return Response.json({ items: [], reports: [{ + task_id: task.id, attempt_id: "import-attempt", attempt_number: 1, + report: { status: "failed", results: [], certificate_imported: true }, + }] }); + } + return fetchTask(path, options); + }); + state.deploymentResult.value = { taskID: task.id, pending: true }; + await state.refreshTaskStatus(); + assert.equal(state.deploymentResult.value.pending, false); + assert.equal(state.deploymentResult.value.certificateImported, true); + assert.equal(state.deploymentResult.value.taskStatus, "failed"); + await state.openTaskDetails(task); + assert.equal(state.taskDeploymentReport.value.certificate_imported, true); +}); + test("异步部署接收后不误报成功或缺失结果,最终失败不被逐目标成功覆盖", async (t) => { let current = { ...initialTask }; const posts = []; @@ -265,12 +289,14 @@ test("项目撤权后清除缓存的任务详情和部署结果", async (t) => { }); await state.openTaskDetails(current); assert.equal(state.taskDeploymentReport.value.results.length, 1); + state.deploymentResult.value = { taskID: current.id, pending: true, certificateImported: true }; denied = true; await state.refreshTaskStatus(); assert.equal(state.taskDetails.value, null); assert.equal(state.taskDeploymentReport.value, null); assert.deepEqual(state.taskAttempts.value, []); assert.match(state.taskDetailsError.value, /无权读取/); + assert.equal(state.deploymentResult.value.certificateImported, false); }); test("任务完成后自动刷新域名证书数据,但不覆盖正在编辑的设置", async (t) => {