DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
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
|
||||
}
|
||||
Reference in new issue
Block a user