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

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

577 lines
15 KiB
Go

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()
}