feat(工作流):支持本地图编排策略包导入导出 (#195)

* docs: define local workflow package mvp

* docs: fix workflow package api contract

* feat: add local workflow package mvp

* feat(workflow): add package API client

* feat(workflow): add package import interface

* feat(workflow): connect package import flow

* fix(workflow): map package validation errors

* fix(workflow): handle import key generation errors

* feat(workflow): localize package import states

* fix(workflow): reset package import modal on open

---------

Co-authored-by: ruanmingchen <“ruanm@chenchen”>
This commit is contained in:
chenchen
2026-07-14 11:28:37 +08:00
committed by GitHub
parent 2e5c1ff286
commit acfacfe1b3
24 changed files with 3154 additions and 3 deletions
+319
View File
@@ -0,0 +1,319 @@
package handler
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"strings"
"time"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
workflowrunner "cyberstrike-ai/internal/workflow"
workflowpkg "cyberstrike-ai/internal/workflow/package"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type workflowPackageResolution struct {
Action string `json:"action"`
NewWorkflowID string `json:"new_workflow_id"`
}
type workflowPackageImportRequest struct {
InspectionID string `json:"inspection_id"`
Resolution workflowPackageResolution `json:"resolution"`
ConfirmOverwrite bool `json:"confirm_overwrite"`
}
func (h *WorkflowHandler) ExportPackage(c *gin.Context) {
wf, err := h.db.GetWorkflowDefinition(c.Param("id"))
if err != nil {
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_EXPORT_FAILED", "导出工作流包失败", nil)
return
}
if wf == nil {
writeWorkflowPackageError(c, http.StatusNotFound, "WFPKG_WORKFLOW_NOT_FOUND", "工作流不存在", nil)
return
}
pkg, meta, err := workflowpkg.Export(workflowPackageDocument(wf))
if err != nil {
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_EXPORT_FAILED", "导出工作流包失败", nil)
return
}
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": meta.FileName}))
c.Header("ETag", `"`+meta.PackageHash+`"`)
c.Header("X-Workflow-Package-SHA256", meta.PackageHash)
if h.audit != nil {
h.audit.RecordOK(c, "workflow_package", "export", "导出工作流包", "workflow", wf.ID, map[string]interface{}{"package_hash": meta.PackageHash})
}
c.Data(http.StatusOK, "application/zip", pkg)
}
func (h *WorkflowHandler) CreatePackageInspection(c *gin.Context) {
session, ok := security.CurrentSession(c)
if !ok || strings.TrimSpace(session.UserID) == "" {
writeWorkflowPackageError(c, http.StatusUnauthorized, "WFPKG_INSPECTION_NOT_FOUND", "未授权访问", nil)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, workflowpkg.MaxArchiveBytes+1)
file, _, err := c.Request.FormFile("file")
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_FILE_TOO_LARGE", "工作流包文件超过大小限制", nil)
return
}
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_FILE_REQUIRED", "必须上传工作流包文件", nil)
return
}
defer file.Close()
archive, err := io.ReadAll(io.LimitReader(file, workflowpkg.MaxArchiveBytes+1))
if err != nil || len(archive) > workflowpkg.MaxArchiveBytes {
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_FILE_TOO_LARGE", "工作流包文件超过大小限制", nil)
return
}
inspected, err := workflowpkg.InspectArchive(c.Request.Context(), archive, workflowrunner.ValidateGraphJSON)
if err != nil {
code := workflowpkg.ErrorCode(err)
if code == "" {
code = "WFPKG_INVALID_ARCHIVE"
}
if h.audit != nil {
h.audit.RecordFail(c, "workflow_package", "inspect", "工作流包预检失败", map[string]interface{}{"code": code, "package_hash": workflowPackageHash(archive)})
}
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, code, "工作流包预检失败", nil)
return
}
state, local, err := h.workflowPackageConflict(inspected.Document.ID, inspected.ContentHash)
if err != nil {
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "读取本地工作流失败", nil)
return
}
summary := workflowPackageInspectionSummary{ID: "wpi_" + strings.ReplaceAll(uuid.NewString(), "-", ""), Status: "ready", ExpiresAt: time.Now().UTC().Add(30 * time.Minute), Package: workflowPackagePackageSummary{PackageFormat: inspected.Manifest.PackageFormat, FormatVersion: inspected.Manifest.FormatVersion, PackageID: inspected.Manifest.PackageID, PackageHash: inspected.PackageHash}, Workflow: workflowPackageWorkflowSummary{SourceID: inspected.Document.ID, Name: inspected.Document.Name, Description: inspected.Document.Description, SourceRevision: inspected.Document.Version, Enabled: inspected.Document.Enabled, ContentHash: inspected.ContentHash, GraphHash: inspected.GraphHash, NodeCount: inspected.NodeCount, EdgeCount: inspected.EdgeCount}, Conflict: workflowPackageConflictSummary{State: state}, Warnings: []string{}}
if local != nil {
content, graph, _, _ := workflowpkg.DocumentHashes(workflowPackageDocument(local))
summary.Conflict.LocalWorkflow = &workflowPackageLocalWorkflow{ID: local.ID, Version: local.Version, ContentHash: content, GraphHash: graph}
}
manifestJSON, _ := json.Marshal(inspected.Manifest)
payloadJSON, err := json.Marshal(inspected.Document)
if err != nil {
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "保存预检失败", nil)
return
}
inspectionJSON, _ := json.Marshal(summary)
record := &database.WorkflowPackageInspection{ID: summary.ID, PackageHash: inspected.PackageHash, ManifestJSON: string(manifestJSON), WorkflowPayloadJSON: string(payloadJSON), InspectionJSON: string(inspectionJSON), SourceWorkflowID: inspected.Document.ID, SourceRevision: inspected.Document.Version, SourceContentHash: inspected.ContentHash, SourceGraphHash: inspected.GraphHash, LocalConflictState: state, CreatedBy: session.UserID, CreatedAt: time.Now().UTC(), ExpiresAt: summary.ExpiresAt}
if local != nil {
content, graph, _, _ := workflowpkg.DocumentHashes(workflowPackageDocument(local))
record.LocalWorkflowID = local.ID
record.LocalContentHash = content
record.LocalGraphHash = graph
}
if err := h.db.CreateWorkflowPackageInspection(record); err != nil {
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "保存预检失败", nil)
return
}
if h.audit != nil {
h.audit.RecordOK(c, "workflow_package", "inspect", "工作流包预检成功", "inspection", record.ID, map[string]interface{}{"package_hash": record.PackageHash, "workflow_id": record.SourceWorkflowID})
}
c.JSON(http.StatusCreated, gin.H{"inspection": summary})
}
func (h *WorkflowHandler) GetPackageInspection(c *gin.Context) {
session, ok := security.CurrentSession(c)
if !ok {
writeWorkflowPackageError(c, http.StatusUnauthorized, "WFPKG_INSPECTION_NOT_FOUND", "未授权访问", nil)
return
}
v, err := h.db.GetWorkflowPackageInspection(c.Param("inspectionId"), session.UserID)
if err != nil {
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "读取预检失败", nil)
return
}
if v == nil {
writeWorkflowPackageError(c, http.StatusNotFound, "WFPKG_INSPECTION_NOT_FOUND", "预检不存在", nil)
return
}
if v.Status == "expired" {
writeWorkflowPackageError(c, http.StatusConflict, "WFPKG_INSPECTION_EXPIRED", "预检已过期", nil)
return
}
var summary any
_ = json.Unmarshal([]byte(v.InspectionJSON), &summary)
c.JSON(http.StatusOK, gin.H{"inspection": summary})
}
func (h *WorkflowHandler) ApplyPackageImport(c *gin.Context) {
session, ok := security.CurrentSession(c)
if !ok {
writeWorkflowPackageError(c, http.StatusUnauthorized, "WFPKG_INSPECTION_NOT_FOUND", "未授权访问", nil)
return
}
key := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
if _, err := uuid.Parse(key); err != nil {
writeWorkflowPackageError(c, http.StatusBadRequest, "WFPKG_IDEMPOTENCY_KEY_REQUIRED", "必须提供 UUID 幂等键", nil)
return
}
var req workflowPackageImportRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_INVALID_ACTION", "导入请求无效", nil)
return
}
req.InspectionID = strings.TrimSpace(req.InspectionID)
req.Resolution.Action = strings.TrimSpace(req.Resolution.Action)
req.Resolution.NewWorkflowID = strings.TrimSpace(req.Resolution.NewWorkflowID)
if req.Resolution.Action != "rename" && req.Resolution.NewWorkflowID != "" {
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_INVALID_ACTION", "当前导入动作不接受新工作流 ID", nil)
return
}
requestHash := workflowPackageRequestHash(req)
imp, replayed, err := h.db.ApplyWorkflowPackageImport(c.Request.Context(), database.WorkflowPackageApplyRequest{InspectionID: req.InspectionID, RequestHash: requestHash, IdempotencyKey: key, ActorUserID: session.UserID, Action: req.Resolution.Action, NewWorkflowID: req.Resolution.NewWorkflowID, ConfirmOverwrite: req.ConfirmOverwrite})
if err != nil {
h.writeWorkflowPackageImportError(c, req.InspectionID, err)
return
}
wf, _ := h.db.GetWorkflowDefinition(imp.ResultingWorkflowID)
if !replayed && (imp.Result == "created" || imp.Result == "overwritten" || imp.Result == "renamed") {
workflowrunner.InvalidateCompiledCache(imp.ResultingWorkflowID)
}
response := h.workflowPackageImportResponse(imp, wf)
if !replayed && h.audit != nil {
h.audit.RecordOK(c, "workflow_package", "import", "工作流包导入成功", "workflow", imp.ResultingWorkflowID, map[string]interface{}{"inspection_id": imp.InspectionID, "action": imp.Action, "result": imp.Result})
}
status := http.StatusCreated
if replayed {
status = http.StatusOK
}
c.JSON(status, gin.H{"import": response})
}
func (h *WorkflowHandler) GetPackageImport(c *gin.Context) {
session, ok := security.CurrentSession(c)
if !ok {
writeWorkflowPackageError(c, http.StatusUnauthorized, "WFPKG_INSPECTION_NOT_FOUND", "未授权访问", nil)
return
}
imp, err := h.db.GetWorkflowPackageImport(c.Param("importId"), session.UserID)
if err != nil {
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "读取导入结果失败", nil)
return
}
if imp == nil {
writeWorkflowPackageError(c, http.StatusNotFound, "WFPKG_INSPECTION_NOT_FOUND", "导入结果不存在", nil)
return
}
wf, _ := h.db.GetWorkflowDefinition(imp.ResultingWorkflowID)
c.JSON(http.StatusOK, gin.H{"import": h.workflowPackageImportResponse(imp, wf)})
}
func (h *WorkflowHandler) workflowPackageConflict(id, sourceHash string) (string, *database.WorkflowDefinition, error) {
local, err := h.db.GetWorkflowDefinition(id)
if err != nil {
return "", nil, err
}
if local == nil {
return "none", nil, nil
}
localHash, _, _, err := workflowpkg.DocumentHashes(workflowPackageDocument(local))
if err != nil {
return "", nil, err
}
if localHash == sourceHash {
return "identical", local, nil
}
return "id_conflict", local, nil
}
func workflowPackageDocument(w *database.WorkflowDefinition) workflowpkg.Document {
return workflowpkg.Document{ID: w.ID, Name: w.Name, Description: w.Description, Version: w.Version, GraphJSON: w.GraphJSON, Enabled: w.Enabled, UpdatedAt: w.UpdatedAt}
}
func workflowPackageRequestHash(req workflowPackageImportRequest) string {
value := struct {
ConfirmOverwrite bool `json:"confirm_overwrite"`
InspectionID string `json:"inspection_id"`
Resolution workflowPackageResolution `json:"resolution"`
}{req.ConfirmOverwrite, req.InspectionID, req.Resolution}
b, _ := json.Marshal(value)
sum := sha256.Sum256(b)
return "sha256:" + hex.EncodeToString(sum[:])
}
func workflowPackageHash(b []byte) string {
sum := sha256.Sum256(b)
return "sha256:" + hex.EncodeToString(sum[:])
}
func (h *WorkflowHandler) writeWorkflowPackageImportError(c *gin.Context, inspectionID string, err error) {
var e *database.WorkflowPackageStoreError
if errors.As(err, &e) {
status := http.StatusConflict
if e.Code == "WFPKG_INVALID_ACTION" || e.Code == "WFPKG_INVALID_RENAME_ID" {
status = http.StatusUnprocessableEntity
}
if h.audit != nil {
h.audit.RecordFail(c, "workflow_package", "import", "工作流包导入失败", map[string]interface{}{"code": e.Code, "inspection_id": inspectionID})
}
writeWorkflowPackageError(c, status, e.Code, e.Message, nil)
return
}
if h.audit != nil {
h.audit.RecordFail(c, "workflow_package", "import", "工作流包导入失败", map[string]interface{}{"code": "WFPKG_IMPORT_FAILED", "inspection_id": inspectionID})
}
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "导入工作流包失败", nil)
}
func writeWorkflowPackageError(c *gin.Context, status int, code, message string, details map[string]any) {
body := gin.H{"code": code, "message": message}
if len(details) > 0 {
body["details"] = details
}
c.JSON(status, gin.H{"error": body})
}
type workflowPackageInspectionSummary struct {
ID string `json:"id"`
Status string `json:"status"`
ExpiresAt time.Time `json:"expires_at"`
Package workflowPackagePackageSummary `json:"package"`
Workflow workflowPackageWorkflowSummary `json:"workflow"`
Conflict workflowPackageConflictSummary `json:"conflict"`
Warnings []string `json:"warnings"`
}
type workflowPackagePackageSummary struct {
PackageFormat string `json:"package_format"`
FormatVersion string `json:"format_version"`
PackageID string `json:"package_id"`
PackageHash string `json:"package_hash"`
}
type workflowPackageWorkflowSummary struct {
SourceID string `json:"source_id"`
Name string `json:"name"`
Description string `json:"description"`
SourceRevision int `json:"source_revision"`
Enabled bool `json:"enabled"`
ContentHash string `json:"content_hash"`
GraphHash string `json:"graph_hash"`
NodeCount int `json:"node_count"`
EdgeCount int `json:"edge_count"`
}
type workflowPackageConflictSummary struct {
State string `json:"state"`
LocalWorkflow *workflowPackageLocalWorkflow `json:"local_workflow,omitempty"`
}
type workflowPackageLocalWorkflow struct {
ID string `json:"id"`
Version int `json:"version"`
ContentHash string `json:"content_hash"`
GraphHash string `json:"graph_hash"`
}
func (h *WorkflowHandler) workflowPackageImportResponse(imp *database.WorkflowPackageImport, wf *database.WorkflowDefinition) gin.H {
out := gin.H{"id": imp.ID, "inspection_id": imp.InspectionID, "status": "succeeded", "result": imp.Result, "action": imp.Action, "source_workflow_id": imp.SourceWorkflowID, "target_workflow_id": imp.TargetWorkflowID, "applied_at": imp.AppliedAt}
if wf != nil {
content, graph, _, _ := workflowpkg.DocumentHashes(workflowPackageDocument(wf))
out["workflow"] = gin.H{"id": wf.ID, "version": wf.Version, "content_hash": content, "graph_hash": graph}
}
return out
}