完成证书导入后台任务及上传回收,更新回归进度

This commit is contained in:
xuans 2026-10-02 19:42:18 +08:00
parent 09616e732d
commit fce5a84dd4
17 changed files with 473 additions and 15 deletions

View File

@ -4,7 +4,7 @@
## 业务范围 ## 业务范围
证书申请、当前证书部署及手动重试先持久化任务,再交给后台工作线程执行。浏览器断线不取消已接收工作,前端通过任务接口确认最终结果。提交成功、逐服务器推送成功、整体任务成功是不同层级,不能混用。 证书申请、当前证书部署、证书导入及申请/部署手动重试先持久化任务,再交给后台工作线程执行。浏览器断线不取消已接收工作,前端通过任务接口确认最终结果。提交成功、证书保存成功、逐服务器推送成功、整体任务成功是不同层级,不能混用。
## 接口与权限 ## 接口与权限
@ -14,6 +14,7 @@
|---|---|---| |---|---|---|
| POST /api/v1/domains/:id/issue | 提交申请及关联部署,无请求体 | 可见域名、证书管理、部署执行、DNS 凭据使用权限;202 | | POST /api/v1/domains/:id/issue | 提交申请及关联部署,无请求体 | 可见域名、证书管理、部署执行、DNS 凭据使用权限;202 |
| POST /api/v1/domains/:id/deploy | 提交已有证书部署,无请求体 | 可见域名、部署执行权限;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 | | POST /api/v1/tasks/:id/retry | 同一任务新增一次尝试,无请求体 | 重试、证书管理、部署执行权限;申请类还检查 DNS 凭据;202 |
| DELETE /api/v1/tasks/:id | 请求取消 | 任务可见及取消权限;后台任务返回 202,非异步历史任务可能返回 200 | | DELETE /api/v1/tasks/:id | 请求取消 | 任务可见及取消权限;后台任务返回 202,非异步历史任务可能返回 200 |
| GET /api/v1/tasks | 当前身份可见任务列表 | 支持 domain_id 和 limit;返回 200 与 items 数组 | | 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 自动回归持续执行;竞争检测尚无可靠通过结果。 - [~] 当前 Linux 自动回归持续执行;竞争检测尚无可靠通过结果。
- [x] 完整验证及恢复报告持久化与页面读取已实现,关联回归和前端 256 项通过;未部署。 - [x] 完整验证及恢复报告持久化与页面读取已实现,关联回归通过;导入接线后最新前端 260 项及生产构建通过,未部署。
- [x] HTTP 异步完成审计、逐服务器结果与任务终态同事务,已覆盖三类保存失败整体回滚;其他工作流及三库实机复审仍待完成。 - [x] HTTP 异步完成审计、逐服务器结果与任务终态同事务,已覆盖三类保存失败整体回滚;其他工作流及三库实机复审仍待完成。
- [x] 后台证书共用执行器新增五处故障回滚、每次重试报告、等待取消及无活动尝试收尾回归;应用、仓储、HTTP 三包原生 Linux 测试、全后端测试及 go vet 均通过,未正式部署。 - [x] 后台证书共用执行器新增五处故障回滚、每次重试报告、等待取消及无活动尝试收尾回归;应用、仓储、HTTP 三包原生 Linux 测试、全后端测试及 go vet 均通过,未正式部署。
- [ ] 导入、版本恢复、校验及手动续期等其他长操作尚未全部异步化。 - [x] 生产导入入口及前端任务跟踪已接入异步执行;专项回归通过,未正式部署。
- [ ] 版本恢复、校验及手动续期等其他长操作尚未全部异步化;当前历史版本恢复的真实实现仍拒绝恢复,不能用模拟工作流测试替代功能验收。
- [ ] 正式构建部署后验证超过反代等待窗口的真实操作、断线查询、取消回滚和服务重启;本说明不替代生产验收。 - [ ] 正式构建部署后验证超过反代等待窗口的真实操作、断线查询、取消回滚和服务重启;本说明不替代生产验收。

View File

@ -196,6 +196,10 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
result, err := trackedWorkflow.Import(ctx, request) result, err := trackedWorkflow.Import(ctx, request)
return result.Certificate, result.Deployment, err 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) { DeployCertificate: func(ctx context.Context, domainID string) (certificate.IssueResult, deployment.Report, error) {
result, err := trackedWorkflow.DeployCurrent(ctx, domainID) result, err := trackedWorkflow.DeployCurrent(ctx, domainID)
return result.Certificate, result.Deployment, err return result.Certificate, result.Deployment, err

View File

@ -49,6 +49,8 @@ type Report struct {
// 站点校验与各服务器文件推送分开记录,不表示所有服务器均完成独立 HTTPS 验证。 // 站点校验与各服务器文件推送分开记录,不表示所有服务器均完成独立 HTTPS 验证。
VerificationStatus string `json:"verification_status,omitempty"` VerificationStatus string `json:"verification_status,omitempty"`
RecoveryStatus string `json:"recovery_status,omitempty"` RecoveryStatus string `json:"recovery_status,omitempty"`
// 导入已保存与部署成功分开记录,不保存证书路径或私钥内容。
CertificateImported bool `json:"certificate_imported,omitempty"`
} }
type Service struct { type Service struct {

View File

@ -13,6 +13,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/certificate" "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/domain"
"git.pcux.cn/xuans/Scert/backend/internal/repository" "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) { if !s.authorizeVisibleDomainPermissions(c, domain.PermissionCertificateManage, domain.PermissionDeploymentExecute) {
return return
} }
if s.importCertificate == nil { if s.importCertificate == nil && s.importCertificateTask == nil {
s.writeError(c, http.StatusNotImplemented, "证书导入接口尚未配置", errors.New("证书导入流程未配置")) s.writeError(c, http.StatusNotImplemented, "证书导入接口尚未配置", errors.New("证书导入流程未配置"))
return return
} }
@ -176,6 +177,16 @@ func (s *Server) importCertificateFiles(c *gin.Context) {
s.writeError(c, http.StatusBadRequest, "证书上传参数无效", err) s.writeError(c, http.StatusBadRequest, "证书上传参数无效", err)
return 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() defer cleanup()
result, report, err := s.importCertificate(s.taskWorkflowContext(c), request) result, report, err := s.importCertificate(s.taskWorkflowContext(c), request)
report = sanitizeDeploymentReport(report) report = sanitizeDeploymentReport(report)
@ -206,6 +217,12 @@ func parseCertificateImportRequest(c *gin.Context) (certificate.ImportRequest, f
cleanup := func() {} cleanup := func() {}
contentType := c.GetHeader("Content-Type") contentType := c.GetHeader("Content-Type")
if strings.HasPrefix(strings.ToLower(contentType), "multipart/form-data") { 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 { if err := c.Request.ParseMultipartForm(certificateUploadLimit); err != nil {
return certificate.ImportRequest{}, cleanup, fmt.Errorf("解析上传表单失败:%w", err) return certificate.ImportRequest{}, cleanup, fmt.Errorf("解析上传表单失败:%w", err)
} }

View File

@ -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("缺失任务仓储时必须回收上传文件")
}
}

View File

@ -60,7 +60,9 @@ type Dependencies struct {
IssueSetupCertificate func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error) IssueSetupCertificate func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error)
RollbackSetupCertificate func(context.Context, certificate.SavedResource) error RollbackSetupCertificate func(context.Context, certificate.SavedResource) error
ImportCertificate func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, 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 执行已有任务,不得重复创建任务或尝试记录。
DeployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error) DeployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
RestoreCertificateVersion func(context.Context, string, string) (certificate.IssueResult, 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) issueSetupCertificate func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error)
rollbackSetupCertificate func(context.Context, certificate.SavedResource) error rollbackSetupCertificate func(context.Context, certificate.SavedResource) error
importCertificate func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, 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) deployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
deployCertificateTask 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) restoreVersion func(context.Context, string, string) (certificate.IssueResult, error)
@ -167,6 +170,7 @@ func New(deps Dependencies) *Server {
issueSetupCertificate: deps.IssueSetupCertificate, issueSetupCertificate: deps.IssueSetupCertificate,
rollbackSetupCertificate: deps.RollbackSetupCertificate, rollbackSetupCertificate: deps.RollbackSetupCertificate,
importCertificate: deps.ImportCertificate, importCertificate: deps.ImportCertificate,
importCertificateTask: deps.ImportCertificateTask,
deployCertificate: deps.DeployCertificate, deployCertificate: deps.DeployCertificate,
deployCertificateTask: deps.DeployCertificateTask, deployCertificateTask: deps.DeployCertificateTask,
restoreVersion: deps.RestoreCertificateVersion, restoreVersion: deps.RestoreCertificateVersion,

View File

@ -23,6 +23,21 @@ func (s *Server) issueDomainAsTask(c *gin.Context) {
} }
func (s *Server) submitDomainTask(c *gin.Context, taskType string) { 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) { if (taskType == "certificate_issue" && s.issueCertificate == nil) || (taskType == "certificate_deploy" && s.deployCertificateTask == nil) {
s.writeError(c, http.StatusNotImplemented, "证书申请接口尚未配置", errors.New("证书申请工作流未配置")) s.writeError(c, http.StatusNotImplemented, "证书申请接口尚未配置", errors.New("证书申请工作流未配置"))
return return
@ -50,7 +65,7 @@ func (s *Server) submitDomainTask(c *gin.Context, taskType string) {
s.writeError(c, http.StatusServiceUnavailable, "证书任务权限服务尚未就绪", errors.New("项目授权服务未配置")) s.writeError(c, http.StatusServiceUnavailable, "证书任务权限服务尚未就绪", errors.New("项目授权服务未配置"))
return 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 err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), resource.ResourceScope.Normalize().ProjectID, domain.PermissionCertificateManage); err != nil {
if errors.Is(err, repository.ErrProjectAccessDenied) { if errors.Is(err, repository.ErrProjectAccessDenied) {
s.writeError(c, http.StatusForbidden, "无权管理此项目的证书任务", nil) 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) s.writeError(c, http.StatusInternalServerError, "创建任务尝试失败", err)
return 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" { if taskType == "certificate_deploy" {
s.executeCertificateDeployTask(c, task, attempt, taskType) s.executeCertificateDeployTask(c, task, attempt, taskType)
return 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) { 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) { 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) { 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) { if s.finishCancelledTask(task, attempt, executionCtx) {
return 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) finishCtx, finishCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer finishCancel() defer finishCancel()
deploymentReport = sanitizeDeploymentReport(deploymentReport) deploymentReport = sanitizeDeploymentReport(deploymentReport)
@ -238,6 +269,12 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
} else if partial { } else if partial {
status = "partial_success" status = "partial_success"
} }
if task.TaskType == "certificate_import" && issueErr != nil {
message = "证书导入失败"
if deploymentReport.CertificateImported {
message = "证书已导入,但部署未完成。请检查部署结果后重试部署,无需重新导入。"
}
}
s.taskMu.Lock() s.taskMu.Lock()
defer s.taskMu.Unlock() defer s.taskMu.Unlock()
if persisted, err := s.tasks.GetTask(finishCtx, task.ID); err != nil { if persisted, err := s.tasks.GetTask(finishCtx, task.ID); err != nil {

View File

@ -26,6 +26,8 @@ func (s *Server) authorizeCertificateTaskExecution(ctx context.Context, task rep
case "certificate_issue": case "certificate_issue":
permissions = append(permissions, domain.PermissionCertificateManage, domain.PermissionDNSCredentialUse) permissions = append(permissions, domain.PermissionCertificateManage, domain.PermissionDNSCredentialUse)
case "certificate_deploy": case "certificate_deploy":
case "certificate_import":
permissions = append(permissions, domain.PermissionCertificateManage)
default: default:
return repository.ErrProjectAccessDenied return repository.ErrProjectAccessDenied
} }

View File

@ -17,7 +17,7 @@ import (
) )
func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) { 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{ for _, scenario := range []string{
"unchanged", "disabled_user", "removed_member", "read_only_member", "unchanged", "disabled_user", "removed_member", "read_only_member",
"moved_domain", "deleted_domain", "deployment_denied", "certificate_denied", "moved_domain", "deleted_domain", "deployment_denied", "certificate_denied",
@ -32,6 +32,9 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
server, tasks, audit, cleanup := newTaskAPITestServer(t, operation) server, tasks, audit, cleanup := newTaskAPITestServer(t, operation)
defer cleanup() defer cleanup()
server.deployCertificateTask = operation 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.asyncTasks = make(map[string]bool)
server.taskSlots = make(chan struct{}, 1) server.taskSlots = make(chan struct{}, 1)
server.taskSlots <- struct{}{} server.taskSlots <- struct{}{}
@ -39,8 +42,12 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
if taskType == "certificate_deploy" { if taskType == "certificate_deploy" {
path = "/api/v1/domains/domain-1/deploy" path = "/api/v1/domains/domain-1/deploy"
} }
if taskType == "certificate_import" {
path = "/api/v1/domains/domain-1/certificate/import"
}
response := httptest.NewRecorder() 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) task := submittedTask(t, response)
if calls.Load() != 0 { if calls.Load() != 0 {
t.Fatal("占满执行槽后任务仍执行") t.Fatal("占满执行槽后任务仍执行")
@ -95,7 +102,7 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
<-server.taskSlots <-server.taskSlots
waitTaskWorkersForTest(t, server) waitTaskWorkersForTest(t, server)
want := "failed" 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" want = "success"
} }
stored, err := tasks.GetTask(t.Context(), task.ID) stored, err := tasks.GetTask(t.Context(), task.ID)

View File

@ -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() s.taskMu.Lock()
if s.taskWorkersStopped || len(s.asyncTasks) >= 32 { if s.taskWorkersStopped || len(s.asyncTasks) >= 32 {
s.taskMu.Unlock() s.taskMu.Unlock()
s.finishTaskBeforeExecution(task, attempt, "failed", "服务正在停止或任务队列已满,任务未执行") s.finishTaskBeforeExecution(task, attempt, "failed", "服务正在停止或任务队列已满,任务未执行")
s.writeError(c, http.StatusServiceUnavailable, "服务正在停止或任务队列已满,请稍后重试", nil) s.writeError(c, http.StatusServiceUnavailable, "服务正在停止或任务队列已满,请稍后重试", nil)
return return false
} }
ctx, cancel := context.WithTimeout(context.Background(), certificateTaskTimeout) ctx, cancel := context.WithTimeout(context.Background(), certificateTaskTimeout)
if s.taskCancels == nil { if s.taskCancels == nil {
@ -37,6 +37,9 @@ func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, at
s.taskMu.Unlock() s.taskMu.Unlock()
go func() { go func() {
defer s.taskWorkers.Done() defer s.taskWorkers.Done()
if cleanup != nil {
defer cleanup()
}
defer s.unregisterTaskCancel(task.ID) defer s.unregisterTaskCancel(task.ID)
defer cancel() defer cancel()
defer func() { 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) 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.Header("Location", "/api/v1/tasks/"+task.ID)
c.JSON(http.StatusAccepted, gin.H{"task": task, "accepted": true}) c.JSON(http.StatusAccepted, gin.H{"task": task, "accepted": true})
} }

View File

@ -1,5 +1,18 @@
# Linux 生产流程测试记录 # 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 排队任务实时授权回归(未部署) ## 2026年10月2日19:01 排队任务实时授权回归(未部署)
- [x] 修改前基线 657a9c0 已推送。第二台既有 SSH 会话已实时复核,测试目录为 /var/tmp/scert-async-20261002-1556/source/backend,磁盘剩余约 961MB;未清理或连接线上业务数据库。 - [x] 修改前基线 657a9c0 已推送。第二台既有 SSH 会话已实时复核,测试目录为 /var/tmp/scert-async-20261002-1556/source/backend,磁盘剩余约 961MB;未清理或连接线上业务数据库。

View File

@ -1,5 +1,20 @@
# Scert 整改任务执行清单 # 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 排队任务执行前权限复核 ## 2026年10月2日19:01 排队任务执行前权限复核
需求沿用已确认的 Linux 全清单,先开发及自动回归,再统一部署;不缩减范围、不处理 Windows。本批修改前已推送基线 657a9c0,工作区干净。 需求沿用已确认的 Linux 全清单,先开发及自动回归,再统一部署;不缩减范围、不处理 Windows。本批修改前已推送基线 657a9c0,工作区干净。

View File

@ -1,5 +1,13 @@
# Scert 整改、合并与优化清单 # 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 排队执行前权限开发进度 ## 2026年10月2日19:01 排队执行前权限开发进度
- [x] P0-07/P2-03 子项:确认并修复异步申请、部署及重试只有提交授权、缺少执行前复核的问题。执行槽取得后读取当前账户和域名归属,复核本次操作权限;归属变化即拒绝,不因仍可访问新项目而沿用旧任务。 - [x] P0-07/P2-03 子项:确认并修复异步申请、部署及重试只有提交授权、缺少执行前复核的问题。执行槽取得后读取当前账户和域名归属,复核本次操作权限;归属变化即拒绝,不因仍可访问新项目而沿用旧任务。
@ -1358,6 +1366,8 @@ frontend/src/views/Certificates/
**状态:** `[~] 进行中` **状态:** `[~] 进行中`
**2026年10月2日核对:** 当前文件存储只列出当前版本,真实恢复实现直接拒绝历史恢复,前端也未形成完整历史版本操作链;模拟工作流的恢复测试不代表此功能已经实现。保持本项未完成,不删除原始需求。
证书详情至少包含: 证书详情至少包含:
```text ```text
@ -1747,6 +1757,8 @@ cancelled 已取消
**状态:** `[~] 进行中` **状态:** `[~] 进行中`
**2026年10月2日19:34 子项进度:** 申请、当前证书部署、申请/部署重试及生产证书导入已接入 HTTP 异步任务;导入增加上传生命周期、执行前授权、幂等找回和保存结果持久化,自动回归通过待复审。校验、历史恢复及其余列出的长操作不因本批通过而关闭,整项继续进行中。
以下操作不能长期阻塞 HTTP 请求: 以下操作不能长期阻塞 HTTP 请求:
```text ```text

View File

@ -64,6 +64,7 @@ export function useDashboard() {
const importSaving = ref(false); const importSaving = ref(false);
const importError = ref(""); const importError = ref("");
const importResult = ref(null); const importResult = ref(null);
let importIdempotencyKey = "";
const importFiles = ref({ const importFiles = ref({
certificate: null, certificate: null,
private_key: null, private_key: null,
@ -839,6 +840,8 @@ export function useDashboard() {
function openCertificateImport(item) { function openCertificateImport(item) {
importDomain.value = item; importDomain.value = item;
const suffix = crypto.randomUUID ? crypto.randomUUID() : createRandomSuffix();
importIdempotencyKey = `import-${item.id}-${suffix}`;
importFiles.value = { importFiles.value = {
certificate: null, certificate: null,
private_key: null, private_key: null,
@ -882,9 +885,25 @@ export function useDashboard() {
} }
const response = await fetchAdmin( const response = await fetchAdmin(
`/api/v1/domains/${importDomain.value.id}/certificate/import`, `/api/v1/domains/${importDomain.value.id}/certificate/import`,
{ method: "POST", body }, { method: "POST", body, headers: { "Idempotency-Key": importIdempotencyKey } },
); );
const result = await response.json().catch(() => ({})); 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) { if (!response.ok && result.imported !== true) {
throw new Error(result.error || "导入证书失败"); throw new Error(result.error || "导入证书失败");
} }

View File

@ -192,6 +192,71 @@ test("certificateImport.submitsSelectedFilesAndResetsOnReopen", async (t) => {
assert.ok(state.importError.value); 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) => { test("证书已导入但部署失败时展示服务器结果并刷新状态,不能重复导入", async (t) => {
let imports = 0; let imports = 0;
let refreshes = 0; let refreshes = 0;

View File

@ -110,6 +110,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
taskStatus: snapshot.task.status, taskStatus: snapshot.task.status,
taskMessage: snapshot.task.message || "", taskMessage: snapshot.task.message || "",
report: snapshot.report, report: snapshot.report,
certificateImported: snapshot.report?.certificate_imported === true,
queryError: "", queryError: "",
}; };
} }
@ -130,6 +131,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
deploymentResult.value.queryError = message; deploymentResult.value.queryError = message;
if (denied) { if (denied) {
deploymentResult.value.report = null; deploymentResult.value.report = null;
deploymentResult.value.certificateImported = false;
deploymentResult.value.taskMessage = ""; deploymentResult.value.taskMessage = "";
deploymentResult.value.taskStatus = ""; deploymentResult.value.taskStatus = "";
} }
@ -149,6 +151,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
taskDeploymentReport.value = null; taskDeploymentReport.value = null;
if (deploymentResult.value) { if (deploymentResult.value) {
deploymentResult.value.report = null; deploymentResult.value.report = null;
deploymentResult.value.certificateImported = false;
deploymentResult.value.taskMessage = ""; deploymentResult.value.taskMessage = "";
} }
} }

View File

@ -91,6 +91,30 @@ test("页面刷新查询完整报告并展示恢复状态", async (t) => {
assert.match(deploymentSummary(state.taskDeploymentReport.value), /恢复/); 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) => { test("异步部署接收后不误报成功或缺失结果,最终失败不被逐目标成功覆盖", async (t) => {
let current = { ...initialTask }; let current = { ...initialTask };
const posts = []; const posts = [];
@ -265,12 +289,14 @@ test("项目撤权后清除缓存的任务详情和部署结果", async (t) => {
}); });
await state.openTaskDetails(current); await state.openTaskDetails(current);
assert.equal(state.taskDeploymentReport.value.results.length, 1); assert.equal(state.taskDeploymentReport.value.results.length, 1);
state.deploymentResult.value = { taskID: current.id, pending: true, certificateImported: true };
denied = true; denied = true;
await state.refreshTaskStatus(); await state.refreshTaskStatus();
assert.equal(state.taskDetails.value, null); assert.equal(state.taskDetails.value, null);
assert.equal(state.taskDeploymentReport.value, null); assert.equal(state.taskDeploymentReport.value, null);
assert.deepEqual(state.taskAttempts.value, []); assert.deepEqual(state.taskAttempts.value, []);
assert.match(state.taskDetailsError.value, /无权读取/); assert.match(state.taskDetailsError.value, /无权读取/);
assert.equal(state.deploymentResult.value.certificateImported, false);
}); });
test("任务完成后自动刷新域名证书数据,但不覆盖正在编辑的设置", async (t) => { test("任务完成后自动刷新域名证书数据,但不覆盖正在编辑的设置", async (t) => {