DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,316 @@
package adminworkflow
import (
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"longheng.io/server/internal/platform/httpx"
)
const (
defRecordLimit = 100
maxFlowRecLimit = 500
)
// AdminOptions 描述运营工作流只读后台接口的挂载方式。
type AdminOptions struct {
Prefix string
Wrap func(string, http.HandlerFunc) http.HandlerFunc
}
// WorkflowRecordSummary 是工作流列表页使用的轻量记录摘要。
type WorkflowRecordSummary struct {
ID string `json:"id"`
Workflow string `json:"workflow,omitempty"`
Operation string `json:"operation,omitempty"`
Scope string `json:"scope,omitempty"`
Target string `json:"target,omitempty"`
Actor string `json:"actor,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
Phase Phase `json:"phase"`
ApprovalID string `json:"approval_id,omitempty"`
ReceiptID string `json:"receipt_id,omitempty"`
ReceiptStatus string `json:"receipt_status,omitempty"`
HasRollbackNote bool `json:"has_rollback_note"`
RollbackEvidenceRef string `json:"rollback_evidence_ref,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type flowRecordFilter struct {
workflow string
phase Phase
operation string
actor string
idempotencyKey string
approvalID string
receiptID string
hasRollbackNote *bool
limit int
}
// RegisterAdminRoutes 注册工作流记录和 rollback note 的只读后台接口。
func RegisterAdminRoutes(mux *http.ServeMux, service *Service, options AdminOptions) {
if mux == nil {
return
}
prefix := strings.TrimRight(strings.TrimSpace(options.Prefix), "/")
if prefix == "" {
prefix = "/debug/admin/workflows"
}
wrap := options.Wrap
if wrap == nil {
wrap = func(_ string, next http.HandlerFunc) http.HandlerFunc { return next }
}
mux.HandleFunc(prefix, wrap("adminworkflow", func(w http.ResponseWriter, r *http.Request) {
handleWorkflowList(w, r, service)
}))
mux.HandleFunc(prefix+"/", wrap("adminworkflow", func(w http.ResponseWriter, r *http.Request) {
suffix := strings.TrimPrefix(r.URL.Path, prefix+"/")
handleWorkflowItem(w, r, service, suffix)
}))
}
func handleWorkflowList(w http.ResponseWriter, r *http.Request, service *Service) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeMethodNA(w, http.MethodGet)
return
}
filter, err := workflowFilterReq(r)
if err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
records, err := service.List(r.Context())
if err != nil {
writeWorkflowErr(w, err)
return
}
sort.SliceStable(records, func(i, j int) bool {
left := records[i].UpdatedAt.UTC()
right := records[j].UpdatedAt.UTC()
if !left.Equal(right) {
return left.After(right)
}
return records[i].ID < records[j].ID
})
summaries := make([]WorkflowRecordSummary, 0, minWorkflowInt(len(records), filter.limit))
total := 0
for _, record := range records {
if !filter.match(record) {
continue
}
total++
if len(summaries) >= filter.limit {
continue
}
summaries = append(summaries, WorkflowRecordSummaryFromRecord(record))
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"records": summaries,
"count": len(summaries),
"total": total,
"limit": filter.limit,
})
}
func handleWorkflowItem(w http.ResponseWriter, r *http.Request, service *Service, suffix string) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeMethodNA(w, http.MethodGet)
return
}
id, rollbackNote, ok := parseRecordItemPath(suffix)
if !ok {
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": ErrWorkflowNotFound.Error()})
return
}
record, found, err := service.Get(r.Context(), id)
if err != nil {
writeWorkflowErr(w, err)
return
}
if !found {
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": ErrWorkflowNotFound.Error()})
return
}
if rollbackNote {
if record.RollbackNote == nil || !hasRollbackNote(*record.RollbackNote) {
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "rollback note not found"})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"workflow_id": record.ID,
"rollback_note": record.RollbackNote,
})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"record": record,
"summary": WorkflowRecordSummaryFromRecord(record),
})
}
// WorkflowRecordSummaryFromRecord 从完整工作流记录生成列表摘要。
func WorkflowRecordSummaryFromRecord(record Record) WorkflowRecordSummary {
record = cloneRecord(record)
summary := WorkflowRecordSummary{
ID: strings.TrimSpace(record.ID),
Workflow: strings.TrimSpace(record.Workflow),
Operation: record.Command.Operation,
Scope: record.Command.Scope,
Target: record.Command.Target,
Actor: record.Command.Actor,
IdempotencyKey: record.Command.IdempotencyKey,
Phase: record.Phase,
Error: strings.TrimSpace(record.Error),
CreatedAt: record.CreatedAt.UTC(),
UpdatedAt: record.UpdatedAt.UTC(),
}
if record.Approval != nil {
summary.ApprovalID = strings.TrimSpace(record.Approval.ID)
}
if record.Receipt != nil {
summary.ReceiptID = strings.TrimSpace(record.Receipt.ID)
summary.ReceiptStatus = strings.TrimSpace(record.Receipt.Status)
}
if record.RollbackNote != nil && hasRollbackNote(*record.RollbackNote) {
summary.HasRollbackNote = true
summary.RollbackEvidenceRef = strings.TrimSpace(record.RollbackNote.EvidenceRef)
}
return summary
}
func workflowFilterReq(r *http.Request) (flowRecordFilter, error) {
query := r.URL.Query()
limit, err := parseRecordListLimit(query.Get("limit"))
if err != nil {
return flowRecordFilter{}, err
}
hasRollbackNote, err := parseWorkflowBool(query.Get("has_rollback_note"), "has_rollback_note")
if err != nil {
return flowRecordFilter{}, err
}
return flowRecordFilter{
workflow: strings.TrimSpace(query.Get("workflow")),
phase: Phase(strings.TrimSpace(query.Get("phase"))),
operation: strings.TrimSpace(query.Get("operation")),
actor: strings.TrimSpace(query.Get("actor")),
idempotencyKey: strings.TrimSpace(query.Get("idempotency_key")),
approvalID: strings.TrimSpace(query.Get("approval_id")),
receiptID: strings.TrimSpace(query.Get("receipt_id")),
hasRollbackNote: hasRollbackNote,
limit: limit,
}, nil
}
func (f flowRecordFilter) match(record Record) bool {
record = cloneRecord(record)
if f.workflow != "" && record.Workflow != f.workflow {
return false
}
if f.phase != "" && record.Phase != f.phase {
return false
}
if f.operation != "" && record.Command.Operation != f.operation {
return false
}
if f.actor != "" && record.Command.Actor != f.actor {
return false
}
if f.idempotencyKey != "" && record.Command.IdempotencyKey != f.idempotencyKey {
return false
}
if f.approvalID != "" {
if record.Approval == nil || record.Approval.ID != f.approvalID {
return false
}
}
if f.receiptID != "" {
if record.Receipt == nil || record.Receipt.ID != f.receiptID {
return false
}
}
if f.hasRollbackNote != nil {
hasNote := record.RollbackNote != nil && hasRollbackNote(*record.RollbackNote)
if hasNote != *f.hasRollbackNote {
return false
}
}
return true
}
func parseRecordItemPath(suffix string) (string, bool, bool) {
suffix = strings.Trim(suffix, "/")
if suffix == "" {
return "", false, false
}
rollbackNote := false
if strings.HasSuffix(suffix, "/rollback-note") {
rollbackNote = true
suffix = strings.TrimSuffix(suffix, "/rollback-note")
}
if suffix == "" || strings.Contains(suffix, "/") {
return "", false, false
}
id := strings.TrimSpace(suffix)
if id == "" {
return "", false, false
}
return id, rollbackNote, true
}
func parseRecordListLimit(raw string) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return defRecordLimit, nil
}
limit, err := strconv.Atoi(raw)
if err != nil || limit < 0 {
return 0, fmt.Errorf("%w: limit must be a non-negative integer", ErrWorkflowInvalid)
}
if limit > maxFlowRecLimit {
return maxFlowRecLimit, nil
}
return limit, nil
}
func parseWorkflowBool(raw, name string) (*bool, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
value, err := strconv.ParseBool(raw)
if err != nil {
return nil, fmt.Errorf("%w: %s must be boolean", ErrWorkflowInvalid, name)
}
return &value, nil
}
func writeMethodNA(w http.ResponseWriter, allowed string) {
if allowed == http.MethodGet {
allowed = http.MethodGet + ", " + http.MethodHead
}
w.Header().Set("Allow", allowed)
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
func writeWorkflowErr(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
if errors.Is(err, ErrStoreRequired) {
status = http.StatusServiceUnavailable
}
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
}
func minWorkflowInt(left, right int) int {
if left < right {
return left
}
return right
}
@@ -0,0 +1,190 @@
package adminworkflow
import (
"encoding/json"
"errors"
"fmt"
"os"
"sort"
"strings"
)
const CatalogKind = "admin_operator_workflows"
var ErrCatalogInvalid = errors.New("admin workflow catalog is invalid")
type Catalog struct {
SchemaVersion int `json:"schema_version"`
Kind string `json:"kind"`
Workflows []WorkflowTemplate `json:"workflows"`
}
type WorkflowTemplate struct {
Name string `json:"name"`
RequiredControls []string `json:"required_controls"`
Evidence []string `json:"evidence"`
}
type CatalogValidateOptions struct {
RequiredNames []string
}
func WorkflowNames() []string {
return []string{
"player_query",
"session_drain_and_kick",
"profile_repair",
"deadletter_requeue",
"event_replay",
"leaderboard_correction",
"ban_or_unban",
"rollback_or_gray_recovery",
}
}
func LoadCatalogFile(path string) (Catalog, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
if err != nil {
return Catalog{}, err
}
return ParseCatalog(data)
}
func ParseCatalog(data []byte) (Catalog, error) {
var catalog Catalog
if err := json.Unmarshal(data, &catalog); err != nil {
return Catalog{}, err
}
catalog = NormalizeCatalog(catalog)
if err := ValidateCatalog(catalog, CatalogValidateOptions{RequiredNames: WorkflowNames()}); err != nil {
return Catalog{}, err
}
return catalog, nil
}
func NormalizeCatalog(catalog Catalog) Catalog {
catalog.Kind = strings.TrimSpace(catalog.Kind)
for idx := range catalog.Workflows {
catalog.Workflows[idx] = NormalizeWorkflowTemplate(catalog.Workflows[idx])
}
sort.SliceStable(catalog.Workflows, func(i, j int) bool { return catalog.Workflows[i].Name < catalog.Workflows[j].Name })
return catalog
}
func NormalizeWorkflowTemplate(template WorkflowTemplate) WorkflowTemplate {
template.Name = normalizeWorkflowID(template.Name)
template.RequiredControls = normWorkflowList(template.RequiredControls)
template.Evidence = normWorkflowList(template.Evidence)
return template
}
func ValidateCatalog(catalog Catalog, options CatalogValidateOptions) error {
if catalog.SchemaVersion <= 0 {
return fmt.Errorf("%w: schema_version is required", ErrCatalogInvalid)
}
if catalog.Kind != CatalogKind {
return fmt.Errorf("%w: kind must be %s", ErrCatalogInvalid, CatalogKind)
}
if len(catalog.Workflows) == 0 {
return fmt.Errorf("%w: workflows is empty", ErrCatalogInvalid)
}
seen := make(map[string]WorkflowTemplate, len(catalog.Workflows))
for _, template := range catalog.Workflows {
if err := ValidateWorkflowTemplate(template); err != nil {
return err
}
if _, ok := seen[template.Name]; ok {
return fmt.Errorf("%w: duplicate workflow %s", ErrCatalogInvalid, template.Name)
}
seen[template.Name] = template
}
for _, name := range normWorkflowList(options.RequiredNames) {
name = normalizeWorkflowID(name)
if _, ok := seen[name]; !ok {
return fmt.Errorf("%w: required workflow %s is missing", ErrCatalogInvalid, name)
}
}
return nil
}
func ValidateWorkflowTemplate(template WorkflowTemplate) error {
template = NormalizeWorkflowTemplate(template)
if template.Name == "" {
return fmt.Errorf("%w: workflow name is required", ErrCatalogInvalid)
}
if len(template.RequiredControls) == 0 {
return fmt.Errorf("%w: %s controls are empty", ErrCatalogInvalid, template.Name)
}
if len(template.Evidence) == 0 {
return fmt.Errorf("%w: %s evidence is empty", ErrCatalogInvalid, template.Name)
}
controls := workflowSet(template.RequiredControls)
if !hasWorkflowItem(controls, "RBAC") {
return fmt.Errorf("%w: %s requires RBAC control", ErrCatalogInvalid, template.Name)
}
if !hasWorkflowItem(controls, "audit") {
return fmt.Errorf("%w: %s requires audit control", ErrCatalogInvalid, template.Name)
}
if hasWorkflowItem(controls, "dangerous_confirmation") {
for _, required := range []string{"idempotency_key", "command_receipt"} {
if !hasWorkflowItem(controls, required) {
return fmt.Errorf("%w: %s dangerous workflow requires %s", ErrCatalogInvalid, template.Name, required)
}
}
}
return nil
}
func (c Catalog) FindWorkflow(name string) (WorkflowTemplate, bool) {
name = normalizeWorkflowID(name)
for _, template := range c.Workflows {
if template.Name == name {
return template, true
}
}
return WorkflowTemplate{}, false
}
func workflowSet(values []string) map[string]struct{} {
out := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value != "" {
out[value] = struct{}{}
}
}
return out
}
func hasWorkflowItem(values map[string]struct{}, item string) bool {
_, ok := values[strings.ToLower(strings.TrimSpace(item))]
return ok
}
func normalizeWorkflowID(value string) string {
value = strings.TrimSpace(value)
value = strings.ReplaceAll(value, "-", "_")
value = strings.ReplaceAll(value, " ", "_")
return strings.ToLower(value)
}
func normWorkflowList(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
key := strings.ToLower(value)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, value)
}
return out
}
@@ -0,0 +1,379 @@
package adminworkflow
import (
"errors"
"fmt"
"sort"
"longheng.io/server/internal/platform/admincmd"
)
var ErrOperationRecipeInvalid = errors.New("admin operation recipe is invalid")
// OperationRecipe 描述一个可复用运营命令从参数、预览、执行到回滚证据的接入配方。
type OperationRecipe struct {
// Name 是运营配方名,和 admincmd 模板、adminworkflow 工作流名对齐。
Name string `json:"name"`
// Description 是配方用途说明。
Description string `json:"description,omitempty"`
// Source 是抽取来源类别,通常标记为成熟项目GM 或龙恒平台能力。
Source string `json:"source,omitempty"`
// Command 是对应的 admincmd 模板。
Command admincmd.Template `json:"command"`
// Workflow 是对应的 Admin 工作流控制要求。
Workflow WorkflowTemplate `json:"workflow"`
// DryRunSteps 是执行前必须产出的预览或检查步骤。
DryRunSteps []string `json:"dry_run_steps,omitempty"`
// ExecuteSteps 是审批后真正执行的步骤。
ExecuteSteps []string `json:"execute_steps,omitempty"`
// RollbackSteps 是失败或误操作后的回滚、补偿或人工修复步骤。
RollbackSteps []string `json:"rollback_steps,omitempty"`
// Notes 是项目接入时容易遗漏的工程注意事项。
Notes []string `json:"notes,omitempty"`
}
// DefaultOperationRecipes 返回从成熟项目GM 操作经验抽出的默认运营命令配方。
func DefaultOperationRecipes() []OperationRecipe {
return cloneOpRecipes(defOpRecipes)
}
// FindDefaultOperationRecipe 按名称查找默认运营命令配方。
func FindDefaultOperationRecipe(name string) (OperationRecipe, bool) {
name = normalizeWorkflowID(name)
for _, recipe := range defOpRecipes {
if recipe.Name == name {
return cloneOperationRecipe(recipe), true
}
}
return OperationRecipe{}, false
}
// ValidateOperationRecipe 校验运营命令配方是否具备命令模板、工作流控制、步骤和证据。
func ValidateOperationRecipe(recipe OperationRecipe) error {
_, err := normOpRecipe(recipe)
return err
}
// DefaultOperationCommandTemplates 返回默认运营命令配方中的 admincmd 模板。
func DefaultOperationCommandTemplates() ([]admincmd.Template, error) {
recipes := DefaultOperationRecipes()
out := make([]admincmd.Template, 0, len(recipes))
for _, recipe := range recipes {
normalized, err := normOpRecipe(recipe)
if err != nil {
return nil, err
}
out = append(out, normalized.Command)
}
return out, nil
}
// DefaultOperationWorkflowTemplates 返回默认运营命令配方中的 Admin 工作流模板。
func DefaultOperationWorkflowTemplates() ([]WorkflowTemplate, error) {
recipes := DefaultOperationRecipes()
out := make([]WorkflowTemplate, 0, len(recipes))
for _, recipe := range recipes {
normalized, err := normOpRecipe(recipe)
if err != nil {
return nil, err
}
out = append(out, normalized.Workflow)
}
return out, nil
}
var defOpRecipes = []OperationRecipe{
{
Name: "player_query",
Description: "玩家基础信息、会话和 Profile 版本查询",
Source: "成熟项目GM player/profile 查询经验",
Command: admincmd.Template{
Name: "player_query",
Operation: "player.query",
Scope: "logic",
Target: "player",
Params: []admincmd.ParamSpec{
{Name: "account_id", Type: admincmd.ParamString, Required: true},
{Name: "shard_id", Type: admincmd.ParamString},
},
},
Workflow: WorkflowTemplate{
Name: "player_query",
RequiredControls: []string{"RBAC", "read_only", "audit", "trace_id"},
Evidence: []string{"player overview snapshot", "session snapshot", "profile version"},
},
DryRunSteps: []string{"读取玩家在线状态", "读取 Profile 版本", "读取最近会话摘要"},
ExecuteSteps: []string{"返回只读查询结果", "记录审计查询"},
RollbackSteps: []string{"只读操作不需要回滚"},
Notes: []string{"查询也要审计,避免后台成为隐形数据出口"},
},
{
Name: "ban_or_unban",
Description: "封禁、解封、禁言和解除禁言",
Source: "成熟项目GM ban 操作经验",
Command: admincmd.Template{
Name: "ban_or_unban",
Operation: "moderation.sanction",
Scope: "moderation",
Target: "account",
Dangerous: true,
Params: []admincmd.ParamSpec{
{Name: "account_id", Type: admincmd.ParamString, Required: true},
{Name: "action", Type: admincmd.ParamString, Required: true, Allowed: []string{"ban", "unban", "mute", "unmute"}},
{Name: "duration_seconds", Type: admincmd.ParamInt},
{Name: "reason_code", Type: admincmd.ParamString, Required: true},
},
},
Workflow: WorkflowTemplate{
Name: "ban_or_unban",
RequiredControls: []string{"RBAC", "reason", "duration_or_policy", "dangerous_confirmation", "idempotency_key", "audit", "command_receipt"},
Evidence: []string{"moderation record", "gateway refresh", "audit query"},
},
DryRunSteps: []string{"查询当前处罚状态", "校验处罚策略", "生成影响范围预览"},
ExecuteSteps: []string{"写入处罚记录", "刷新网关会话状态", "返回 command receipt"},
RollbackSteps: []string{"按 receipt 撤销本次处罚变更", "刷新网关会话状态"},
Notes: []string{"封禁类操作必须有原因码和审计记录"},
},
{
Name: "mail_publish",
Description: "单人邮件、全服邮件和运营公告发布",
Source: "成熟项目GM mail/public_notice 操作经验",
Command: admincmd.Template{
Name: "mail_publish",
Operation: "notice.publish",
Scope: "logic",
Target: "notice",
Dangerous: true,
Params: []admincmd.ParamSpec{
{Name: "notice_id", Type: admincmd.ParamString, Required: true},
{Name: "audience", Type: admincmd.ParamString, Required: true, Allowed: []string{"account", "shard", "all"}},
{Name: "account_id", Type: admincmd.ParamString},
{Name: "shard_id", Type: admincmd.ParamString},
},
},
Workflow: WorkflowTemplate{
Name: "mail_publish",
RequiredControls: []string{"dry_run", "RBAC", "dangerous_confirmation", "idempotency_key", "audit", "command_receipt", "rollback_note"},
Evidence: []string{"audience preview", "notice publish receipt", "delivery sample"},
},
DryRunSteps: []string{"校验收件范围", "抽样展示邮件内容", "检查附件幂等键"},
ExecuteSteps: []string{"写入公告或邮件批次", "触发在线推送", "记录发放证据"},
RollbackSteps: []string{"冻结未领取附件", "撤销未生效公告", "生成客服补偿清单"},
Notes: []string{"邮件附件必须接入可靠发放 owner,不能只写后台记录"},
},
{
Name: "profile_repair",
Description: "玩家 Profile 检查、字段修复和批量修复",
Source: "成熟项目GM profile 修复经验",
Command: admincmd.Template{
Name: "profile_repair",
Operation: "profile.repair",
Scope: "logic",
Target: "player",
Dangerous: true,
Params: []admincmd.ParamSpec{
{Name: "account_id", Type: admincmd.ParamString, Required: true},
{Name: "field", Type: admincmd.ParamString, Required: true},
{Name: "mode", Type: admincmd.ParamString, Required: true, Allowed: []string{"check", "repair"}},
{Name: "expected_version", Type: admincmd.ParamString},
},
},
Workflow: WorkflowTemplate{
Name: "profile_repair",
RequiredControls: []string{"read_only_preview", "RBAC", "dangerous_confirmation", "idempotency_key", "audit", "command_receipt", "rollback_plan"},
Evidence: []string{"before profile snapshot", "admin command receipt", "EventLog entry", "after profile snapshot"},
},
DryRunSteps: []string{"读取修复前 Profile 快照", "校验字段 owner", "生成字段差异预览"},
ExecuteSteps: []string{"通过 owner 写路径修复字段", "写入 EventLog", "返回修复 receipt"},
RollbackSteps: []string{"按修复前快照生成回滚命令", "校验 Profile 版本后恢复"},
Notes: []string{"Profile 修复不能绕过 owner、幂等和 EventLog"},
},
{
Name: "hotdata_publish",
Description: "热数据发布、检查和回滚",
Source: "成熟项目GM hotdata 操作经验",
Command: admincmd.Template{
Name: "hotdata_publish",
Operation: "hotdata.publish",
Scope: "config",
Target: "hotdata",
Dangerous: true,
Params: []admincmd.ParamSpec{
{Name: "bundle_id", Type: admincmd.ParamString, Required: true},
{Name: "version", Type: admincmd.ParamString, Required: true},
{Name: "mode", Type: admincmd.ParamString, Required: true, Allowed: []string{"dry_run", "apply", "rollback"}},
},
},
Workflow: WorkflowTemplate{
Name: "hotdata_publish",
RequiredControls: []string{"dry_run", "RBAC", "dangerous_confirmation", "idempotency_key", "audit", "command_receipt", "rollback_note"},
Evidence: []string{"hotdata diff", "checksum report", "reload receipt"},
},
DryRunSteps: []string{"校验 bundle checksum", "生成配置差异", "检查目标服务 reload 能力"},
ExecuteSteps: []string{"发布热数据版本", "触发受控 reload", "采集服务确认回执"},
RollbackSteps: []string{"切回上一版本", "再次触发 reload", "验证配置 checksum"},
Notes: []string{"热数据发布必须有差异报告和回滚 intent"},
},
{
Name: "hotpatch_rollout",
Description: "代码热补丁发布、灰度和回滚",
Source: "成熟项目GM hotpatch 操作经验",
Command: admincmd.Template{
Name: "hotpatch_rollout",
Operation: "hotpatch.rollout",
Scope: "runtime",
Target: "hotpatch",
Dangerous: true,
Params: []admincmd.ParamSpec{
{Name: "patch_id", Type: admincmd.ParamString, Required: true},
{Name: "action", Type: admincmd.ParamString, Required: true, Allowed: []string{"dry_run", "rollout", "rollback"}},
{Name: "target_nodes", Type: admincmd.ParamString},
},
},
Workflow: WorkflowTemplate{
Name: "hotpatch_rollout",
RequiredControls: []string{"dry_run", "RBAC", "dangerous_confirmation", "idempotency_key", "audit", "command_receipt", "rollback_note", "health verification"},
Evidence: []string{"patch manifest", "target node list", "rollout receipt", "health after rollout"},
},
DryRunSteps: []string{"校验补丁签名和目标节点", "生成影响范围", "确认回滚补丁存在"},
ExecuteSteps: []string{"按节点灰度发布补丁", "采集健康检查", "记录 rollout receipt"},
RollbackSteps: []string{"按补丁 ID 回滚", "恢复节点健康", "输出回滚证据"},
Notes: []string{"热补丁必须先小流量灰度,不能直接全量覆盖"},
},
{
Name: "payment_replay",
Description: "支付订单查询、补单、重发和发货回放",
Source: "成熟项目GM pay_opt 操作经验",
Command: admincmd.Template{
Name: "payment_replay",
Operation: "commerce.fulfillment.replay",
Scope: "commerce",
Target: "order",
Dangerous: true,
Params: []admincmd.ParamSpec{
{Name: "order_id", Type: admincmd.ParamString, Required: true},
{Name: "channel", Type: admincmd.ParamString, Required: true},
{Name: "mode", Type: admincmd.ParamString, Required: true, Allowed: []string{"query", "replay", "compensate"}},
},
},
Workflow: WorkflowTemplate{
Name: "payment_replay",
RequiredControls: []string{"order inspect", "dry_run", "RBAC", "dangerous_confirmation", "idempotency_key", "audit", "command_receipt", "rollback_note"},
Evidence: []string{"payment order snapshot", "fulfillment idempotency record", "delivery receipt"},
},
DryRunSteps: []string{"查询订单状态", "查询发货幂等记录", "判断是否允许补偿"},
ExecuteSteps: []string{"通过发货 owner 重放", "写入补单复核记录", "返回发货 receipt"},
RollbackSteps: []string{"冻结异常发货", "生成客服人工处理单", "保留支付复核证据"},
Notes: []string{"支付补单不能绕过订单唯一键和发货幂等记录"},
},
{
Name: "servergroup_merge",
Description: "区服合服 dry-run、执行、回滚和证据归档",
Source: "成熟项目GM merge 操作经验",
Command: admincmd.Template{
Name: "servergroup_merge",
Operation: "servergroup.merge.workflow",
Scope: "servergroup",
Target: "merge",
Dangerous: true,
Params: []admincmd.ParamSpec{
{Name: "merge_id", Type: admincmd.ParamString, Required: true},
{Name: "main_shard_id", Type: admincmd.ParamString, Required: true},
{Name: "shards", Type: admincmd.ParamString, Required: true},
{Name: "mode", Type: admincmd.ParamString, Required: true, Allowed: []string{"dry_run", "apply", "rollback_dry_run", "rollback"}},
{Name: "check_features", Type: admincmd.ParamString},
{Name: "block_features", Type: admincmd.ParamString},
{Name: "rollback_archive_id", Type: admincmd.ParamString},
},
},
Workflow: WorkflowTemplate{
Name: "servergroup_merge",
RequiredControls: []string{"merge dry_run", "module report", "RBAC", "dangerous_confirmation", "idempotency_key", "audit", "command_receipt", "adminworkflow_adapter", "merge_workflow_runner", "merge_workflow_archive", "archive_store", "archive_query_api", "workflow_record_query_api", "rollback_note"},
Evidence: []string{"adminworkflow.DryRun", "adminworkflow.Record", "adminworkflow.RollbackNote", "MigrationPlan", "MergeArchive", "MergeModuleRunReport", "RollbackPoint"},
},
DryRunSteps: []string{"生成平台 MigrationPlan", "执行模块 GenDB 报告", "保存 dry-run 归档"},
ExecuteSteps: []string{"校验审批和幂等键", "执行模块 Merge 报告", "应用平台路由并保存归档"},
RollbackSteps: []string{"读取 apply 归档", "执行模块 Rollback 报告", "恢复平台 RollbackPoint"},
Notes: []string{"合服必须保留模块报告、平台回滚点和后台可查询归档"},
},
}
func normOpRecipe(recipe OperationRecipe) (OperationRecipe, error) {
recipe.Name = normalizeWorkflowID(recipe.Name)
recipe.Command = normCmdTemplate(recipe.Command)
recipe.Workflow = NormalizeWorkflowTemplate(recipe.Workflow)
recipe.DryRunSteps = normWorkflowList(recipe.DryRunSteps)
recipe.ExecuteSteps = normWorkflowList(recipe.ExecuteSteps)
recipe.RollbackSteps = normWorkflowList(recipe.RollbackSteps)
recipe.Notes = normWorkflowList(recipe.Notes)
if recipe.Name == "" {
return OperationRecipe{}, fmt.Errorf("%w: name is required", ErrOperationRecipeInvalid)
}
if recipe.Command.Name != recipe.Name {
return OperationRecipe{}, fmt.Errorf("%w: command template name must match %s", ErrOperationRecipeInvalid, recipe.Name)
}
if recipe.Workflow.Name != recipe.Name {
return OperationRecipe{}, fmt.Errorf("%w: workflow template name must match %s", ErrOperationRecipeInvalid, recipe.Name)
}
if len(recipe.DryRunSteps) == 0 || len(recipe.ExecuteSteps) == 0 || len(recipe.RollbackSteps) == 0 {
return OperationRecipe{}, fmt.Errorf("%w: %s steps are required", ErrOperationRecipeInvalid, recipe.Name)
}
if err := ValidateWorkflowTemplate(recipe.Workflow); err != nil {
return OperationRecipe{}, fmt.Errorf("%w: %w", ErrOperationRecipeInvalid, err)
}
if recipe.Command.Operation == "" || recipe.Command.Target == "" {
return OperationRecipe{}, fmt.Errorf("%w: %s command operation and target are required", ErrOperationRecipeInvalid, recipe.Name)
}
if recipe.Command.Dangerous {
controls := workflowSet(recipe.Workflow.RequiredControls)
for _, required := range []string{"dangerous_confirmation", "idempotency_key", "command_receipt"} {
if !hasWorkflowItem(controls, required) {
return OperationRecipe{}, fmt.Errorf("%w: dangerous recipe %s missing %s", ErrOperationRecipeInvalid, recipe.Name, required)
}
}
}
return recipe, nil
}
func normCmdTemplate(template admincmd.Template) admincmd.Template {
registry, err := admincmd.NewTemplateRegistry(template)
if err != nil {
return template
}
normalized, ok := registry.Get(template.Name)
if !ok {
return template
}
return normalized
}
func cloneOpRecipes(recipes []OperationRecipe) []OperationRecipe {
if len(recipes) == 0 {
return nil
}
out := make([]OperationRecipe, len(recipes))
for index, recipe := range recipes {
out[index] = cloneOperationRecipe(recipe)
}
return out
}
func cloneOperationRecipe(recipe OperationRecipe) OperationRecipe {
recipe.Command.Params = append([]admincmd.ParamSpec(nil), recipe.Command.Params...)
recipe.Workflow.RequiredControls = append([]string(nil), recipe.Workflow.RequiredControls...)
recipe.Workflow.Evidence = append([]string(nil), recipe.Workflow.Evidence...)
recipe.DryRunSteps = append([]string(nil), recipe.DryRunSteps...)
recipe.ExecuteSteps = append([]string(nil), recipe.ExecuteSteps...)
recipe.RollbackSteps = append([]string(nil), recipe.RollbackSteps...)
recipe.Notes = append([]string(nil), recipe.Notes...)
return recipe
}
func sortedOpNames(recipes []OperationRecipe) []string {
names := make([]string, 0, len(recipes))
for _, recipe := range recipes {
names = append(names, recipe.Name)
}
sort.Strings(names)
return names
}
@@ -0,0 +1,346 @@
package adminworkflow
import (
"context"
"errors"
"fmt"
"strings"
"time"
"longheng.io/server/internal/platform/admincmd"
"longheng.io/server/internal/platform/audit"
)
var (
ErrServiceRequired = errors.New("admin workflow service is required")
ErrCatalogRequired = errors.New("admin workflow catalog is required")
ErrRBACRequired = errors.New("admin workflow RBAC checker is required")
ErrAuditRequired = errors.New("admin workflow audit logger is required")
ErrRBACDenied = errors.New("admin workflow RBAC denied")
)
const (
ActionDryRun = "dry_run"
ActionSubmitApproval = "submit_approval"
ActionApprove = "approve"
ActionReject = "reject"
ActionExecute = "execute"
)
type RBACChecker interface {
CheckAdminWorkflow(context.Context, WorkflowTemplate, admincmd.Command, string, string) error
}
type RBACFunc func(context.Context, WorkflowTemplate, admincmd.Command, string, string) error
func (fn RBACFunc) CheckAdminWorkflow(ctx context.Context, template WorkflowTemplate, command admincmd.Command, action, actor string) error {
if fn == nil {
return ErrRBACRequired
}
return fn(ctx, template, command, action, actor)
}
type OrchestratorOptions struct {
Service *Service
Catalog Catalog
RBAC RBACChecker
Audit *audit.Logger
Now func() time.Time
}
type Orchestrator struct {
service *Service
catalog Catalog
rbac RBACChecker
audit *audit.Logger
now func() time.Time
}
type BeginRequest struct {
Workflow string
Command admincmd.Command
Previewer Previewer
SubmitApproval bool
}
type DecisionRequest struct {
ID string
Decision admincmd.ApprovalDecision
}
type ExecuteRequest struct {
ID string
Actor string
Executor Executor
}
func NewOrchestrator(options OrchestratorOptions) (*Orchestrator, error) {
if options.Service == nil {
return nil, ErrServiceRequired
}
if len(options.Catalog.Workflows) == 0 {
return nil, ErrCatalogRequired
}
if options.RBAC == nil {
return nil, ErrRBACRequired
}
if options.Audit == nil {
return nil, ErrAuditRequired
}
if err := ValidateCatalog(options.Catalog, CatalogValidateOptions{RequiredNames: WorkflowNames()}); err != nil {
return nil, err
}
now := options.Now
if now == nil {
now = time.Now
}
return &Orchestrator{
service: options.Service,
catalog: NormalizeCatalog(options.Catalog),
rbac: options.RBAC,
audit: options.Audit,
now: now,
}, nil
}
func (o *Orchestrator) Begin(ctx context.Context, request BeginRequest) (StartResult, error) {
if err := ctxErr(ctx); err != nil {
return StartResult{}, err
}
template, command, err := o.prepare(ctx, request.Workflow, request.Command, ActionDryRun, request.Command.Actor)
if err != nil {
return StartResult{}, err
}
if request.Previewer == nil {
return StartResult{}, ErrPreviewerRequired
}
if err := o.recordAudit(ctx, ActionDryRun+".requested", template, command, command.Actor, nil, ""); err != nil {
return StartResult{}, err
}
result, err := o.service.StartDryRun(ctx, command, request.Previewer)
if err != nil {
_ = o.recordAudit(ctx, ActionDryRun+".failed", template, command, command.Actor, nil, err.Error())
return StartResult{}, err
}
record := result.Record
record.Workflow = template.Name
record.UpdatedAt = o.now().UTC()
record, err = o.service.store.SaveWorkflow(ctx, record)
if err != nil {
return StartResult{}, err
}
result.Record = record
if err := o.recordAudit(ctx, ActionDryRun+".succeeded", template, command, command.Actor, &record, ""); err != nil {
return StartResult{}, err
}
if !request.SubmitApproval {
return result, nil
}
record, err = o.SubmitApproval(ctx, record.ID)
if err != nil {
return StartResult{}, err
}
result.Record = record
return result, nil
}
func (o *Orchestrator) SubmitApproval(ctx context.Context, id string) (Record, error) {
record, template, err := o.recordForAction(ctx, id, ActionSubmitApproval, "")
if err != nil {
return Record{}, err
}
if err := o.recordAudit(ctx, ActionSubmitApproval+".requested", template, record.Command, record.Command.Actor, &record, ""); err != nil {
return Record{}, err
}
record, err = o.service.SubmitApproval(ctx, id)
if err != nil {
_ = o.recordAudit(ctx, ActionSubmitApproval+".failed", template, record.Command, record.Command.Actor, &record, err.Error())
return Record{}, err
}
record.Workflow = template.Name
record, err = o.service.store.SaveWorkflow(ctx, record)
if err != nil {
return Record{}, err
}
if err := o.recordAudit(ctx, ActionSubmitApproval+".succeeded", template, record.Command, record.Command.Actor, &record, ""); err != nil {
return Record{}, err
}
return record, nil
}
func (o *Orchestrator) Approve(ctx context.Context, request DecisionRequest) (Record, error) {
record, template, err := o.recordForAction(ctx, request.ID, ActionApprove, request.Decision.Approver)
if err != nil {
return Record{}, err
}
if err := o.recordAudit(ctx, ActionApprove+".requested", template, record.Command, request.Decision.Approver, &record, ""); err != nil {
return Record{}, err
}
record, err = o.service.Approve(ctx, request.ID, request.Decision)
if err != nil {
_ = o.recordAudit(ctx, ActionApprove+".failed", template, record.Command, request.Decision.Approver, &record, err.Error())
return Record{}, err
}
record.Workflow = template.Name
record, err = o.service.store.SaveWorkflow(ctx, record)
if err != nil {
return Record{}, err
}
if err := o.recordAudit(ctx, ActionApprove+".succeeded", template, record.Command, request.Decision.Approver, &record, ""); err != nil {
return Record{}, err
}
return record, nil
}
func (o *Orchestrator) Reject(ctx context.Context, request DecisionRequest) (Record, error) {
record, template, err := o.recordForAction(ctx, request.ID, ActionReject, request.Decision.Approver)
if err != nil {
return Record{}, err
}
if err := o.recordAudit(ctx, ActionReject+".requested", template, record.Command, request.Decision.Approver, &record, ""); err != nil {
return Record{}, err
}
record, err = o.service.Reject(ctx, request.ID, request.Decision)
if err != nil {
_ = o.recordAudit(ctx, ActionReject+".failed", template, record.Command, request.Decision.Approver, &record, err.Error())
return Record{}, err
}
record.Workflow = template.Name
record, err = o.service.store.SaveWorkflow(ctx, record)
if err != nil {
return Record{}, err
}
if err := o.recordAudit(ctx, ActionReject+".succeeded", template, record.Command, request.Decision.Approver, &record, ""); err != nil {
return Record{}, err
}
return record, nil
}
func (o *Orchestrator) Execute(ctx context.Context, request ExecuteRequest) (Record, error) {
record, template, err := o.recordForAction(ctx, request.ID, ActionExecute, request.Actor)
if err != nil {
return Record{}, err
}
if request.Executor == nil {
return Record{}, ErrExecutorRequired
}
if err := o.recordAudit(ctx, ActionExecute+".requested", template, record.Command, request.Actor, &record, ""); err != nil {
return Record{}, err
}
record, err = o.service.Execute(ctx, request.ID, request.Executor)
if err != nil {
if latest, ok, getErr := o.service.Get(ctx, request.ID); getErr == nil && ok {
record = latest
}
_ = o.recordAudit(ctx, ActionExecute+".failed", template, record.Command, request.Actor, &record, err.Error())
return Record{}, err
}
record.Workflow = template.Name
record, err = o.service.store.SaveWorkflow(ctx, record)
if err != nil {
return Record{}, err
}
if err := o.recordAudit(ctx, ActionExecute+".succeeded", template, record.Command, request.Actor, &record, ""); err != nil {
return Record{}, err
}
return record, nil
}
func (o *Orchestrator) prepare(ctx context.Context, workflow string, command admincmd.Command, action, actor string) (WorkflowTemplate, admincmd.Command, error) {
if o == nil || o.service == nil {
return WorkflowTemplate{}, admincmd.Command{}, ErrServiceRequired
}
command = admincmd.Normalize(command)
template, err := o.templateFor(workflow, command)
if err != nil {
return WorkflowTemplate{}, admincmd.Command{}, err
}
if err := o.rbac.CheckAdminWorkflow(ctx, template, command, action, strings.TrimSpace(actor)); err != nil {
return WorkflowTemplate{}, admincmd.Command{}, fmt.Errorf("%w: %w", ErrRBACDenied, err)
}
return template, command, nil
}
func (o *Orchestrator) recordForAction(ctx context.Context, id, action, actor string) (Record, WorkflowTemplate, error) {
if o == nil || o.service == nil {
return Record{}, WorkflowTemplate{}, ErrServiceRequired
}
record, err := o.service.mustGet(ctx, id)
if err != nil {
return Record{}, WorkflowTemplate{}, err
}
if actor == "" {
actor = record.Command.Actor
}
template, command, err := o.prepare(ctx, record.Workflow, record.Command, action, actor)
if err != nil {
return Record{}, WorkflowTemplate{}, err
}
record.Command = command
if record.Workflow == "" {
record.Workflow = template.Name
}
return record, template, nil
}
func (o *Orchestrator) templateFor(workflow string, command admincmd.Command) (WorkflowTemplate, error) {
if o == nil || len(o.catalog.Workflows) == 0 {
return WorkflowTemplate{}, ErrCatalogRequired
}
names := []string{workflow}
if command.Operation != "" {
names = append(names, strings.ReplaceAll(command.Operation, ".", "_"))
}
if command.Scope != "" {
names = append(names, command.Scope)
}
for _, name := range names {
if template, ok := o.catalog.FindWorkflow(name); ok {
return template, nil
}
}
return WorkflowTemplate{}, ErrWorkflowNotFound
}
func (o *Orchestrator) recordAudit(ctx context.Context, action string, template WorkflowTemplate, command admincmd.Command, actor string, record *Record, errText string) error {
if o == nil || o.audit == nil {
return ErrAuditRequired
}
fields := map[string]string{
"workflow": template.Name,
"operation": command.Operation,
"scope": command.Scope,
"environment": command.Environment,
"shard_id": command.ShardID,
"idempotency_key": command.IdempotencyKey,
}
if record != nil {
fields["workflow_id"] = record.ID
fields["phase"] = string(record.Phase)
if record.Receipt != nil {
fields["receipt_id"] = record.Receipt.ID
fields["receipt_status"] = record.Receipt.Status
}
}
if errText != "" {
fields["error"] = errText
}
hash, err := admincmd.ParamsHash(command.Params)
if err != nil {
return err
}
if hash != "" {
fields["params_hash"] = hash
}
actor = strings.TrimSpace(actor)
if actor == "" {
actor = command.Actor
}
return o.audit.Record(ctx, audit.Event{
Time: o.now().UTC(),
Actor: actor,
Action: action,
Target: command.Target,
Fields: fields,
})
}
@@ -0,0 +1,576 @@
package adminworkflow
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"time"
"longheng.io/server/internal/platform/admincmd"
)
var (
ErrWorkflowNotFound = errors.New("admin workflow not found")
ErrWorkflowInvalid = errors.New("admin workflow is invalid")
ErrWorkflowPhase = errors.New("admin workflow phase is invalid")
ErrPreviewerRequired = errors.New("admin workflow previewer is required")
ErrExecutorRequired = errors.New("admin workflow executor is required")
ErrApprovalStoreRequired = errors.New("admin workflow approval store is required")
ErrDryRunWarnings = errors.New("admin workflow dry-run has blocking warnings")
ErrStoreRequired = errors.New("admin workflow store is required")
)
type Phase string
const (
PhaseDryRun Phase = "dry_run"
PhasePendingApproval Phase = "pending_approval"
PhaseApproved Phase = "approved"
PhaseRejected Phase = "rejected"
PhaseExecuted Phase = "executed"
PhaseFailed Phase = "failed"
PhaseRollbackNoted Phase = "rollback_noted"
)
type Diff struct {
Path string `json:"path"`
Before any `json:"before,omitempty"`
After any `json:"after,omitempty"`
Risk string `json:"risk,omitempty"`
}
type DryRun struct {
Summary string `json:"summary,omitempty"`
Diffs []Diff `json:"diffs,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
}
type RollbackNote struct {
Summary string `json:"summary,omitempty"`
Steps []string `json:"steps,omitempty"`
EvidenceRef string `json:"evidence_ref,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
}
type Record struct {
ID string `json:"id"`
Workflow string `json:"workflow,omitempty"`
Command admincmd.Command `json:"command"`
Phase Phase `json:"phase"`
DryRun DryRun `json:"dry_run,omitempty"`
Approval *admincmd.ApprovalRequest `json:"approval,omitempty"`
Receipt *admincmd.Receipt `json:"receipt,omitempty"`
RollbackNote *RollbackNote `json:"rollback_note,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type StartResult struct {
Record Record `json:"record"`
Duplicate bool `json:"duplicate,omitempty"`
}
type Previewer interface {
DryRunAdminWorkflow(context.Context, admincmd.Command) (DryRun, error)
}
type PreviewerFunc func(context.Context, admincmd.Command) (DryRun, error)
func (fn PreviewerFunc) DryRunAdminWorkflow(ctx context.Context, command admincmd.Command) (DryRun, error) {
if fn == nil {
return DryRun{}, ErrPreviewerRequired
}
return fn(ctx, command)
}
type Executor interface {
ExecuteAdminWorkflow(context.Context, Record) (admincmd.Receipt, RollbackNote, error)
}
type ExecutorFunc func(context.Context, Record) (admincmd.Receipt, RollbackNote, error)
func (fn ExecutorFunc) ExecuteAdminWorkflow(ctx context.Context, record Record) (admincmd.Receipt, RollbackNote, error) {
if fn == nil {
return admincmd.Receipt{}, RollbackNote{}, ErrExecutorRequired
}
return fn(ctx, record)
}
type Store interface {
SaveWorkflow(context.Context, Record) (Record, error)
GetWorkflow(context.Context, string) (Record, bool, error)
ListWorkflows(context.Context) ([]Record, error)
}
type Policy struct {
CommandPolicy admincmd.Policy
RequireApproval bool
BlockWarnings bool
}
func DangerousPolicy() Policy {
return Policy{
CommandPolicy: admincmd.DangerousApprovalPolicy().CommandPolicy,
RequireApproval: true,
BlockWarnings: false,
}
}
type ServiceOptions struct {
Store Store
Approval *admincmd.ApprovalStore
Policy Policy
Now func() time.Time
}
type Service struct {
store Store
approval *admincmd.ApprovalStore
policy Policy
now func() time.Time
}
func NewService(options ServiceOptions) *Service {
policy := options.Policy
if policy.CommandPolicy == (admincmd.Policy{}) {
policy = DangerousPolicy()
}
now := options.Now
if now == nil {
now = time.Now
}
return &Service{
store: options.Store,
approval: options.Approval,
policy: policy,
now: now,
}
}
func (s *Service) StartDryRun(ctx context.Context, command admincmd.Command, previewer Previewer) (StartResult, error) {
if err := ctxErr(ctx); err != nil {
return StartResult{}, err
}
if s == nil || s.store == nil {
return StartResult{}, ErrStoreRequired
}
if previewer == nil {
return StartResult{}, ErrPreviewerRequired
}
command = admincmd.Normalize(command)
if err := admincmd.Validate(command, s.policy.CommandPolicy); err != nil {
return StartResult{}, err
}
id, err := WorkflowID(command)
if err != nil {
return StartResult{}, err
}
if current, ok, err := s.store.GetWorkflow(ctx, id); err != nil || ok {
return StartResult{Record: current, Duplicate: ok}, err
}
dryRun, err := previewer.DryRunAdminWorkflow(ctx, command)
if err != nil {
return StartResult{}, err
}
dryRun = normalizeDryRun(dryRun)
if !hasDryRunPreview(dryRun) {
return StartResult{}, fmt.Errorf("%w: dry-run must include summary or diffs", ErrWorkflowInvalid)
}
if s.policy.BlockWarnings && len(dryRun.Warnings) > 0 {
return StartResult{}, fmt.Errorf("%w: %s", ErrDryRunWarnings, strings.Join(dryRun.Warnings, "; "))
}
now := s.now().UTC()
record := Record{
ID: id,
Command: cloneCommand(command),
Phase: PhaseDryRun,
DryRun: dryRun,
CreatedAt: now,
UpdatedAt: now,
}
record, err = s.store.SaveWorkflow(ctx, record)
if err != nil {
return StartResult{}, err
}
return StartResult{Record: record}, nil
}
func (s *Service) SubmitApproval(ctx context.Context, id string) (Record, error) {
if err := ctxErr(ctx); err != nil {
return Record{}, err
}
if s == nil || s.store == nil {
return Record{}, ErrStoreRequired
}
if s.approval == nil {
return Record{}, ErrApprovalStoreRequired
}
record, err := s.mustGet(ctx, id)
if err != nil {
return Record{}, err
}
if record.Phase != PhaseDryRun {
if record.Approval != nil {
return cloneRecord(record), nil
}
return Record{}, fmt.Errorf("%w: expected %s got %s", ErrWorkflowPhase, PhaseDryRun, record.Phase)
}
request, err := s.approval.Submit(ctx, record.Command)
if err != nil {
return Record{}, err
}
record.Approval = &request
record.Phase = PhasePendingApproval
record.UpdatedAt = s.now().UTC()
return s.store.SaveWorkflow(ctx, record)
}
func (s *Service) Approve(ctx context.Context, id string, decision admincmd.ApprovalDecision) (Record, error) {
return s.decide(ctx, id, decision, true)
}
func (s *Service) Reject(ctx context.Context, id string, decision admincmd.ApprovalDecision) (Record, error) {
return s.decide(ctx, id, decision, false)
}
func (s *Service) Execute(ctx context.Context, id string, executor Executor) (Record, error) {
if err := ctxErr(ctx); err != nil {
return Record{}, err
}
if s == nil || s.store == nil {
return Record{}, ErrStoreRequired
}
if executor == nil {
return Record{}, ErrExecutorRequired
}
record, err := s.mustGet(ctx, id)
if err != nil {
return Record{}, err
}
if record.Phase == PhaseExecuted || record.Phase == PhaseRollbackNoted {
return record, nil
}
if s.policy.RequireApproval {
if record.Phase != PhaseApproved && record.Phase != PhaseFailed {
return Record{}, fmt.Errorf("%w: expected %s got %s", ErrWorkflowPhase, PhaseApproved, record.Phase)
}
if s.approval == nil || record.Approval == nil {
return Record{}, ErrApprovalStoreRequired
}
approvalReceipt, err := s.approval.ExecuteApproved(ctx, record.Approval.ID)
if err != nil {
return Record{}, err
}
request, ok := s.approval.Get(record.Approval.ID)
if ok {
record.Approval = &request
record.Command = cloneCommand(request.Command)
}
if record.Receipt == nil {
record.Receipt = &approvalReceipt
}
} else if record.Phase != PhaseDryRun && record.Phase != PhaseFailed {
return Record{}, fmt.Errorf("%w: expected %s got %s", ErrWorkflowPhase, PhaseDryRun, record.Phase)
}
receipt, rollback, err := executor.ExecuteAdminWorkflow(ctx, cloneRecord(record))
if err != nil {
record.Phase = PhaseFailed
record.Error = strings.TrimSpace(err.Error())
record.UpdatedAt = s.now().UTC()
_, _ = s.store.SaveWorkflow(ctx, record)
return Record{}, err
}
if receipt.ID == "" {
receipt = fallbackReceipt(record.Command, s.now)
}
record.Receipt = &receipt
rollback = normRollbackNote(rollback)
if hasRollbackNote(rollback) {
record.RollbackNote = &rollback
record.Phase = PhaseRollbackNoted
} else {
record.Phase = PhaseExecuted
}
record.Error = ""
record.UpdatedAt = s.now().UTC()
return s.store.SaveWorkflow(ctx, record)
}
func (s *Service) Get(ctx context.Context, id string) (Record, bool, error) {
if err := ctxErr(ctx); err != nil {
return Record{}, false, err
}
if s == nil || s.store == nil {
return Record{}, false, ErrStoreRequired
}
return s.store.GetWorkflow(ctx, strings.TrimSpace(id))
}
func (s *Service) List(ctx context.Context) ([]Record, error) {
if err := ctxErr(ctx); err != nil {
return nil, err
}
if s == nil || s.store == nil {
return nil, ErrStoreRequired
}
return s.store.ListWorkflows(ctx)
}
func (s *Service) decide(ctx context.Context, id string, decision admincmd.ApprovalDecision, approve bool) (Record, error) {
if err := ctxErr(ctx); err != nil {
return Record{}, err
}
if s == nil || s.store == nil {
return Record{}, ErrStoreRequired
}
if s.approval == nil {
return Record{}, ErrApprovalStoreRequired
}
record, err := s.mustGet(ctx, id)
if err != nil {
return Record{}, err
}
if record.Phase != PhasePendingApproval || record.Approval == nil {
return Record{}, fmt.Errorf("%w: expected %s got %s", ErrWorkflowPhase, PhasePendingApproval, record.Phase)
}
var request admincmd.ApprovalRequest
if approve {
request, err = s.approval.Approve(ctx, record.Approval.ID, decision)
record.Phase = PhaseApproved
} else {
request, err = s.approval.Reject(ctx, record.Approval.ID, decision)
record.Phase = PhaseRejected
}
if err != nil {
return Record{}, err
}
record.Approval = &request
record.Command = cloneCommand(request.Command)
record.UpdatedAt = s.now().UTC()
return s.store.SaveWorkflow(ctx, record)
}
func (s *Service) mustGet(ctx context.Context, id string) (Record, error) {
id = strings.TrimSpace(id)
if id == "" {
return Record{}, ErrWorkflowNotFound
}
record, ok, err := s.store.GetWorkflow(ctx, id)
if err != nil {
return Record{}, err
}
if !ok {
return Record{}, ErrWorkflowNotFound
}
return record, nil
}
func WorkflowID(command admincmd.Command) (string, error) {
command = admincmd.Normalize(command)
paramsHash, err := admincmd.ParamsHash(command.Params)
if err != nil {
return "", err
}
if command.Operation == "" || command.IdempotencyKey == "" {
return "", ErrWorkflowInvalid
}
seed := strings.Join([]string{
command.Operation,
command.Scope,
command.Environment,
command.ShardID,
command.Target,
command.Actor,
command.IdempotencyKey,
paramsHash,
}, "|")
sum := sha256.Sum256([]byte(seed))
return "workflow-" + hex.EncodeToString(sum[:12]), nil
}
type MemoryStore struct {
mu sync.Mutex
records map[string]Record
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{records: make(map[string]Record)}
}
func (s *MemoryStore) SaveWorkflow(ctx context.Context, record Record) (Record, error) {
if err := ctxErr(ctx); err != nil {
return Record{}, err
}
if s == nil {
return Record{}, ErrStoreRequired
}
record.ID = strings.TrimSpace(record.ID)
if record.ID == "" || record.Phase == "" {
return Record{}, ErrWorkflowInvalid
}
record = cloneRecord(record)
s.mu.Lock()
if s.records == nil {
s.records = make(map[string]Record)
}
s.records[record.ID] = record
s.mu.Unlock()
return cloneRecord(record), nil
}
func (s *MemoryStore) GetWorkflow(ctx context.Context, id string) (Record, bool, error) {
if err := ctxErr(ctx); err != nil {
return Record{}, false, err
}
if s == nil {
return Record{}, false, ErrStoreRequired
}
id = strings.TrimSpace(id)
s.mu.Lock()
record, ok := s.records[id]
s.mu.Unlock()
return cloneRecord(record), ok, nil
}
func (s *MemoryStore) ListWorkflows(ctx context.Context) ([]Record, error) {
if err := ctxErr(ctx); err != nil {
return nil, err
}
if s == nil {
return nil, ErrStoreRequired
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Record, 0, len(s.records))
for _, record := range s.records {
out = append(out, cloneRecord(record))
}
return out, nil
}
func fallbackReceipt(command admincmd.Command, now func() time.Time) admincmd.Receipt {
if now == nil {
now = time.Now
}
receipt, err := admincmd.NewReceipt(command, "executed", now().UTC())
if err != nil {
return admincmd.Receipt{Status: "executed", CreatedAt: now().UTC()}
}
return receipt
}
func normalizeDryRun(dryRun DryRun) DryRun {
dryRun.Summary = strings.TrimSpace(dryRun.Summary)
dryRun.Diffs = cloneDiffs(dryRun.Diffs)
dryRun.Warnings = normalizeStrings(dryRun.Warnings)
dryRun.Meta = cloneStringMap(dryRun.Meta)
return dryRun
}
func normRollbackNote(note RollbackNote) RollbackNote {
note.Summary = strings.TrimSpace(note.Summary)
note.Steps = normalizeStrings(note.Steps)
note.EvidenceRef = strings.TrimSpace(note.EvidenceRef)
note.Meta = cloneStringMap(note.Meta)
return note
}
func hasRollbackNote(note RollbackNote) bool {
return note.Summary != "" || len(note.Steps) > 0 || note.EvidenceRef != "" || len(note.Meta) > 0
}
func hasDryRunPreview(dryRun DryRun) bool {
return dryRun.Summary != "" || len(dryRun.Diffs) > 0
}
func cloneRecord(record Record) Record {
record.Command = cloneCommand(record.Command)
record.DryRun = normalizeDryRun(record.DryRun)
if record.Approval != nil {
approval := *record.Approval
approval.Command = cloneCommand(approval.Command)
if approval.Receipt != nil {
receipt := *approval.Receipt
approval.Receipt = &receipt
}
record.Approval = &approval
}
if record.Receipt != nil {
receipt := *record.Receipt
record.Receipt = &receipt
}
if record.RollbackNote != nil {
note := normRollbackNote(*record.RollbackNote)
record.RollbackNote = &note
}
return record
}
func cloneCommand(command admincmd.Command) admincmd.Command {
command = admincmd.Normalize(command)
command.Params = cloneParams(command.Params)
return command
}
func cloneParams(params map[string]any) map[string]any {
if len(params) == 0 {
return nil
}
out := make(map[string]any, len(params))
for key, value := range params {
out[key] = value
}
return out
}
func cloneDiffs(diffs []Diff) []Diff {
if len(diffs) == 0 {
return nil
}
out := make([]Diff, 0, len(diffs))
for _, diff := range diffs {
diff.Path = strings.TrimSpace(diff.Path)
diff.Risk = strings.TrimSpace(diff.Risk)
out = append(out, diff)
}
return out
}
func normalizeStrings(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
out = append(out, value)
}
}
return out
}
func cloneStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func ctxErr(ctx context.Context) error {
if ctx == nil {
return nil
}
return ctx.Err()
}