diff --git a/backend/internal/httpapi/certificate_api.go b/backend/internal/httpapi/certificate_api.go index 62b28ab..def42ad 100644 --- a/backend/internal/httpapi/certificate_api.go +++ b/backend/internal/httpapi/certificate_api.go @@ -139,12 +139,19 @@ func (s *Server) importCertificateFiles(c *gin.Context) { defer cleanup() result, report, err := s.importCertificate(c.Request.Context(), request) report = sanitizeDeploymentReport(report) - response := gin.H{"certificate": result, "deployment": report} + // 导入保存与远端部署是不同阶段;部署失败不能让用户误以为证书尚未保存。 + imported := err == nil || (result.DomainID == request.DomainID && + result.Saved.CertificatePath != "" && result.Saved.FullChainPath != "" && result.Saved.PrivateKeyPath != "") + response := gin.H{"certificate": result, "deployment": report, "imported": imported} if err != nil { - if s.logger != nil { - s.logger.Error("证书导入失败", "error", err) + message := "证书导入失败" + if imported { + message = "证书已导入,但部署未完成。请修正服务器配置后重试部署,无需重新导入。" } - response["error"] = "证书导入失败" + if s.logger != nil { + s.logger.Error(message, "error", err) + } + response["error"] = message c.JSON(http.StatusBadGateway, response) return } diff --git a/backend/internal/httpapi/certificate_api_test.go b/backend/internal/httpapi/certificate_api_test.go index 1c57639..747909c 100644 --- a/backend/internal/httpapi/certificate_api_test.go +++ b/backend/internal/httpapi/certificate_api_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "mime/multipart" "net/http" "net/http/httptest" @@ -145,6 +146,51 @@ func TestCertificateImportAPIHidesWorkflowErrors(t *testing.T) { t.Fatalf("certificate import did not include the safe error message: %s", responseBody) } } +func TestCertificateImportAPIReportsSavedCertificateOnDeploymentFailure(t *testing.T) { + for _, saved := range []bool{false, true} { + t.Run(fmt.Sprintf("saved=%t", saved), func(t *testing.T) { + server := New(Dependencies{ + Config: readyTestConfig(t.TempDir()), Repository: &stubDomainRepository{}, + ImportCertificate: func(_ context.Context, request certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) { + result := certificate.IssueResult{} + if saved { + result = certificate.IssueResult{DomainID: request.DomainID, Saved: certificate.SavedResource{ + CertificatePath: "/ssl/cert.pem", FullChainPath: "/ssl/fullchain.pem", PrivateKeyPath: "/ssl/key.pem", + }} + } + return result, deployment.Report{Total: 1, Failed: 1, Status: "failed", Results: []deployment.Result{ + {ServerID: "server-1", Server: "验收服务器", Status: "failed", Message: "secret-diagnostic"}, + }}, errors.New("secret-diagnostic") + }, + }) + request := httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/certificate/import", strings.NewReader(`{"certificate_path":"/ssl/cert.pem","private_key_path":"/ssl/key.pem"}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + serveAdminHTTP(server, response, request) + var result struct { + Imported bool `json:"imported"` + Error string `json:"error"` + Deployment deployment.Report `json:"deployment"` + } + if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if response.Code != http.StatusBadGateway || result.Imported != saved || len(result.Deployment.Results) != 1 { + t.Fatalf("导入和部署阶段未正确区分:%d %s", response.Code, response.Body.String()) + } + if saved && (!strings.Contains(result.Error, "证书已导入") || !strings.Contains(result.Error, "无需重新导入")) { + t.Fatalf("缺少部署重试提示:%s", result.Error) + } + if !saved && result.Error != "证书导入失败" { + t.Fatalf("未保存证书却报告导入成功:%s", result.Error) + } + if strings.Contains(response.Body.String(), "secret-diagnostic") { + t.Fatal("部署诊断泄露到响应") + } + }) + } +} + func TestCertificateImportAPIAcceptsMultipartFiles(t *testing.T) { var received certificate.ImportRequest importer := func(_ context.Context, request certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) { diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index f56ed52..89b1327 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -1013,6 +1013,7 @@ export function useDashboard() { } async function importCertificate() { + if (importSaving.value || importResult.value) return; importError.value = ""; importResult.value = null; if (!importFiles.value.certificate || !importFiles.value.private_key) { @@ -1038,11 +1039,21 @@ export function useDashboard() { { method: "POST", body }, ); const result = await response.json().catch(() => ({})); - if (!response.ok) { + if (!response.ok && result.imported !== true) { throw new Error(result.error || "导入证书失败"); } importResult.value = result; notice.value = `域名 ${importDomain.value.name} 的证书已导入`; + if (!response.ok || result.deployment?.total > 0) { + showCertificateImport.value = false; + deploymentResult.value = { + domain: importDomain.value.name, + running: false, + certificateImported: true, + report: result.deployment || null, + error: response.ok ? "" : result.error || "证书已导入,但部署未完成。请重试部署,无需重新导入。", + }; + } await loadData(); } catch (err) { importError.value = err.message || "导入证书失败"; diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index 4a1ba68..08b3c7d 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -163,6 +163,83 @@ test("certificateImport.submitsSelectedFilesAndResetsOnReopen", async (t) => { assert.ok(state.importError.value); }); +test("证书已导入但部署失败时展示服务器结果并刷新状态,不能重复导入", async (t) => { + let imports = 0; + let refreshes = 0; + const report = { + total: 1, success: 0, failed: 1, status: "failed", + results: [{ server_id: "server-1", server: "验收服务器", status: "failed", message: "连接服务器失败" }], + }; + const message = "证书已导入,但部署未完成。请修正服务器配置后重试部署,无需重新导入。"; + const { state } = await mountComposable(t, useDashboard, async (path) => { + if (path.endsWith("/certificate/import")) { + imports++; + return Response.json({ imported: true, deployment: report, error: message }, { status: 502 }); + } + if (path === "/api/v1/domains") refreshes++; + return Response.json({ items: [] }); + }); + const before = refreshes; + 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.importResult.value?.imported, true); + assert.equal(state.importError.value, ""); + assert.equal(state.showCertificateImport.value, false); + assert.equal(state.deploymentResult.value.certificateImported, true); + assert.equal(state.deploymentResult.value.error, message); + assert.deepEqual(state.deploymentResult.value.report, report); + assert.ok(refreshes > before); + assert.match(state.notice.value, /证书已导入/); + await state.importCertificate(); + assert.equal(imports, 1); +}); + +test("证书未保存时保留导入表单并显示真正的导入失败", async (t) => { + const { state } = await mountComposable(t, useDashboard, async (path) => { + if (path.endsWith("/certificate/import")) { + return Response.json({ imported: false, error: "证书导入失败" }, { status: 502 }); + } + 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.importResult.value, null); + assert.equal(state.deploymentResult.value, null); + assert.equal(state.showCertificateImport.value, true); + assert.equal(state.importError.value, "证书导入失败"); +}); + +test("导入执行中阻止重复提交,成功后展示实际部署结果", async (t) => { + const pending = deferred(); + let imports = 0; + const report = { total: 1, success: 1, failed: 0, status: "success", results: [{ server_id: "server-1", status: "success" }] }; + const { state } = await mountComposable(t, useDashboard, async (path) => { + if (path.endsWith("/certificate/import")) { + imports++; + return pending.promise; + } + 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`)] } }); + } + const action = state.importCertificate(); + const duplicate = state.importCertificate(); + assert.equal(imports, 1); + pending.resolve(Response.json({ imported: true, deployment: report })); + await Promise.all([action, duplicate]); + assert.equal(state.showCertificateImport.value, false); + assert.equal(state.deploymentResult.value.error, ""); + assert.deepEqual(state.deploymentResult.value.report, report); +}); + async function setup(t, request) { const mounted = await mountComposable(t, useDashboard, async (path) => { if (path === "/api/v1/dns/test") return request(); diff --git a/frontend/src/views/Dashboard/Dashboard.vue b/frontend/src/views/Dashboard/Dashboard.vue index 995717b..26f7f33 100644 --- a/frontend/src/views/Dashboard/Dashboard.vue +++ b/frontend/src/views/Dashboard/Dashboard.vue @@ -544,7 +544,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi diff --git a/frontend/src/views/Dashboard/DeploymentResult.vue b/frontend/src/views/Dashboard/DeploymentResult.vue index 911a8c0..0ee118f 100644 --- a/frontend/src/views/Dashboard/DeploymentResult.vue +++ b/frontend/src/views/Dashboard/DeploymentResult.vue @@ -16,7 +16,7 @@ defineEmits(["close"]); >