package dnf90 import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "strings" "time" appkit "longheng.io/server/internal/platform/app" "longheng.io/server/internal/services/dnfbridge" ) // 本文件注册 30CN GM 工具(GmTool90CN)需要的三个 /local admin 能力。 // // 契约由工具端三个客户端类固定: // // QuestRuntimeResyncClient POST /local/quest-resync // DungeonAdminClient GET|POST /local/dungeon-control // CharacterCloneAdminClient GET|POST /local/character-clone // // 工具端会对所有 mutating 方法无条件带 X-Admin-Confirm: "METHOD path", // 所以这里只校验 token,不额外要求 confirm 头。 const ( gmQuestResyncPath = "/local/quest-resync" gmDungeonControlPath = "/local/dungeon-control" gmCharacterClonePath = "/local/character-clone" gmItemGrantPath = "/local/dnf-gm/item/grant" gmItemGrantOptsPath = "/local/dnf-gm/item/grant-options" gmItemEmblemsPath = "/local/dnf-gm/item/grant-with-emblems" gmItemDeletePath = "/local/dnf-gm/item/delete" gmInventoryClearPath = "/local/dnf-gm/inventory/clear" gmPetUpdatePath = "/local/dnf-gm/pet/update" gmReviveCoinPath = "/local/dnf-gm/character/revive-coin" gmAccountCeraPath = "/local/dnf-gm/account/cera" gmEquipmentMovePath = "/local/equipment-move" gmLoadoutEquipmentPath = "/local/dnf-gm/loadout/equipment" gmLoadoutApplyPath = "/local/dnf-gm/loadout/apply" gmCompanionExpPath = "/local/dnf-gm/companion-render-experiment" gmAuctionListingsPath = "/local/dnf-gm/auction/listings" gmAuctionListPath = "/local/dnf-gm/auction/list" gmAuctionCancelPath = "/local/dnf-gm/auction/cancel" gmInventoryListPath = "/local/dnf-gm/inventory/list" gmToolAdminTimeout = 12 * time.Second gmToolAdminBodyLimit = 64 << 10 gmDungeonReturnAction = "return_to_town" ) func registerDNFGMToolCompatRoutes(env *appkit.Env, bridge *dnfbridge.Service) { if env == nil || bridge == nil || env.AdminMux == nil || env.AdminAuth == nil || !strings.EqualFold(env.Core().Config.Service.Environment, "local") { return } env.AdminMux.HandleFunc(gmQuestResyncPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) return } var body struct { CharacterID uint16 `json:"character_id"` } if err := decodeGMNullableJSON(r, &body); err != nil { writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout) defer cancel() result, err := bridge.GMQuestResync(ctx, body.CharacterID) if err != nil { writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } writeGMJSON(w, http.StatusOK, map[string]any{ "character_id": result.CharacterID, "online": result.Online, "refreshed": result.Refreshed, "reason": result.Reason, }) })) env.AdminMux.HandleFunc(gmDungeonControlPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet, http.MethodHead: characterID, err := parseGMDungeonCharacterID(r.URL.Query().Get("character_id")) if err != nil { writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout) defer cancel() snapshot := bridge.GMDungeonControlStateOf(ctx, characterID) writeGMJSON(w, http.StatusOK, map[string]any{ "available": snapshot.Available, "mode": snapshot.Mode, "safe_actions": snapshot.SafeActions, "blocked_actions": snapshot.BlockedActions, "state": snapshot.State, }) case http.MethodPost: var body struct { CharacterID uint16 `json:"character_id"` Action string `json:"action"` Confirmation string `json:"confirmation"` } if err := decodeGMNullableJSON(r, &body); err != nil { writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } if strings.TrimSpace(body.Action) != gmDungeonReturnAction { writeGMJSON(w, http.StatusBadRequest, map[string]any{ "ok": false, "error": "unsupported action: " + body.Action, }) return } ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout) defer cancel() result, err := bridge.GMDungeonReturnToTown(ctx, body.CharacterID) if err != nil { writeGMJSON(w, http.StatusConflict, map[string]any{"ok": false, "error": err.Error()}) return } writeGMJSON(w, http.StatusOK, map[string]any{ "character_id": result.CharacterID, "initiated": result.Initiated, "already_pending": result.AlreadyPending, "before": result.Before, "after": result.After, }) default: w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost) writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) } })) env.AdminMux.HandleFunc(gmCharacterClonePath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet, http.MethodHead: capability := bridge.GMCharacterCloneCapabilityOf() writeGMJSON(w, http.StatusOK, map[string]any{ "available": capability.Available, "mode": capability.Mode, }) case http.MethodPost: var body struct { SourceCharacterID uint16 `json:"source_character_id"` Name string `json:"name"` Confirmation string `json:"confirmation"` } if err := decodeGMNullableJSON(r, &body); err != nil { writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout) defer cancel() result, err := bridge.GMCloneCharacter(ctx, dnfbridge.GMCharacterCloneRequest{ SourceCharacterID: strconv.FormatUint(uint64(body.SourceCharacterID), 10), Name: body.Name, Confirmation: body.Confirmation, }) if err != nil { writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } writeGMJSON(w, http.StatusOK, map[string]any{ "source_character_id": result.SourceCharacterID, "character_id": result.CharacterID, "account_id": result.AccountID, "slot": result.Slot, "name": result.Name, "settings_count": result.SettingsCount, "permission_count": result.PermissionCount, "inventory_copied": result.InventoryCopied, "equipment_copied": result.EquipmentCopied, "skill_copied": result.SkillCopied, "quest_copied": result.QuestCopied, "pet_copied": result.PetCopied, }) default: w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost) writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) } })) // ── 道具发放 / 删除 ──────────────────────────────────────────────────── gmToolPost(env, bridge, gmItemGrantPath, func(ctx context.Context, body dnfbridge.GMItemGrantRequest) (any, error) { return bridge.GMGrantItem(ctx, body) }) gmToolPost(env, bridge, gmItemDeletePath, func(ctx context.Context, body dnfbridge.GMItemDeleteRequestV2) (any, error) { return bridge.GMDeleteItemV2(ctx, body) }) gmToolPost(env, bridge, gmItemGrantOptsPath, func(ctx context.Context, body dnfbridge.GMItemGrantOptionsRequest) (any, error) { return bridge.GMGrantItemWithOptions(ctx, body) }) gmToolPost(env, bridge, gmInventoryClearPath, func(ctx context.Context, body dnfbridge.GMInventoryClearRequest) (any, error) { return bridge.GMClearInventoryForTool(ctx, body) }) gmToolPost(env, bridge, gmPetUpdatePath, func(ctx context.Context, body dnfbridge.GMPetUpdateRequest) (any, error) { return bridge.GMUpdatePet(ctx, body) }) gmToolPost(env, bridge, gmItemEmblemsPath, func(ctx context.Context, body dnfbridge.GMItemWithExtrasRequest) (any, error) { return bridge.GMGrantItemWithEmblems(ctx, body) }) // ── 复活币钱包 ──────────────────────────────────────────────────────── gmToolPost(env, bridge, gmReviveCoinPath, func(ctx context.Context, body dnfbridge.GMReviveCoinRequest) (any, error) { return bridge.GMAdjustReviveCoin(ctx, body) }) // ── 账号点券 / 代币券 ───────────────────────────────────────────────── gmToolPost(env, bridge, gmAccountCeraPath, func(ctx context.Context, body dnfbridge.GMAccountCeraRequest) (any, error) { return bridge.GMAdjustAccountCera(ctx, body) }) // ── 任务状态(走服务端权威操作)──────────────────────────────────────── // 为什么不直接改 SQLite 仓储:accept 需要接取资格校验、complete 需要正确 // 应用转职转换(职业/成长类型/技能),这两件事只有服务端自己的任务模块 // 能做对。角色在线时服务端会明确拒绝。 gmToolPostPath(env, "POST /local/dnf-gm/character/quests/{quest_id}/accept", func(ctx context.Context, pathValue func(string) string, body gmQuestAdminBody) (any, error) { questID, err := parseGMQuestID(pathValue("quest_id")) if err != nil { return nil, err } return bridge.GMAcceptQuest(ctx, body.CharacterID, questID) }) gmToolPostPath(env, "POST /local/dnf-gm/character/quests/{quest_id}/complete", func(ctx context.Context, pathValue func(string) string, body gmQuestAdminBody) (any, error) { questID, err := parseGMQuestID(pathValue("quest_id")) if err != nil { return nil, err } return bridge.GMForceCompleteQuest(ctx, body.CharacterID, questID) }) gmToolPostPath(env, "POST /local/dnf-gm/character/quests/{quest_id}/give-up", func(ctx context.Context, pathValue func(string) string, body gmQuestAdminBody) (any, error) { questID, err := parseGMQuestID(pathValue("quest_id")) if err != nil { return nil, err } return bridge.GMGiveUpQuest(ctx, body.CharacterID, questID) }) gmToolPostPath(env, "POST /local/dnf-gm/character/quests/{quest_id}/uncomplete", func(ctx context.Context, pathValue func(string) string, body gmQuestAdminBody) (any, error) { questID, err := parseGMQuestID(pathValue("quest_id")) if err != nil { return nil, err } return bridge.GMUncompleteQuest(ctx, body.CharacterID, questID) }) // ── 装备穿脱(GM 工具的装备管理页)──────────────────────────────────── // 走后端 equip 模块的 Move,与游戏内 op19 同一条路径,所以部位匹配、 // 职业/成长类型门槛、装扮外观投影、双武器副手限制都自动生效。 gmToolPost(env, bridge, gmEquipmentMovePath, func(ctx context.Context, body dnfbridge.GMEquipmentMoveRequest) (any, error) { return bridge.GMEquipmentMove(ctx, body) }) // Loadout snapshot/apply for the external hotkey tool. env.AdminMux.HandleFunc(gmLoadoutEquipmentPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { w.Header().Set("Allow", "GET, HEAD") writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) return } ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout) defer cancel() snapshot, err := bridge.GMLoadoutSnapshot(ctx, r.URL.Query().Get("character_id")) if err != nil { writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": snapshot}) })) gmToolPost(env, bridge, gmLoadoutApplyPath, func(ctx context.Context, body struct { CharacterID string `json:"character_id"` Slots []dnfbridge.GMLoadoutSlot `json:"slots"` }) (any, error) { return bridge.GMLoadoutApply(ctx, body.CharacterID, body.Slots) }) // ── 支援兵渲染可行性实验 ─────────────────────────────────────────────── // 把另一个角色当成远端 actor 下发到目标角色当前场景,观察客户端是否渲染。 // 这是决定「支援兵能否纯服务端实现」的判据,不是最终功能。 gmToolPostPath(env, "POST /local/dnf-gm/character/{character_id}/companion-render-experiment", func(ctx context.Context, pathValue func(string) string, body struct { CompanionCharacterID uint16 `json:"companion_character_id"` WithBinding *bool `json:"with_binding,omitempty"` OwnerChannel *int `json:"owner_channel,omitempty"` }) (any, error) { withBinding := true if body.WithBinding != nil { withBinding = *body.WithBinding } // owner_channel 省略时用频道 ID;现场实测该值与本地 actor 相同, // 客户端会走 "重建本地角色" 分支,所以要能手工换一个值再试。 ownerChannel := -1 if body.OwnerChannel != nil { ownerChannel = *body.OwnerChannel } err := bridge.GMCompanionRenderExperimentForCharacter( ctx, pathValue("character_id"), body.CompanionCharacterID, withBinding, ownerChannel) if err != nil { return nil, err } return map[string]any{ "sent": true, "companion_character": body.CompanionCharacterID, "with_binding": withBinding, "owner_channel": ownerChannel, "note": "包已发出;是否渲染请看客户端画面", }, nil }) // ── 拍卖行(GM 工具的上/下架)──────────────────────────────────────── // 服务端拍卖行没有面向工具的 GM 能力,这里直接调用 dnfbridge 的 // GMAuction* 方法(内部走 dnfauction.Owner 的纯数据层 Register/Cancel/ // 遍历 escrow,不依赖玩家 session),供服务端在线时上架/下架。 env.AdminMux.HandleFunc(gmAuctionListingsPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { w.Header().Set("Allow", "GET, HEAD") writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) return } ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout) defer cancel() result, err := bridge.GMAuctionListings(ctx) if err != nil { writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": result}) })) gmToolPost(env, bridge, gmAuctionListPath, func(ctx context.Context, body dnfbridge.GMAuctionListRequest) (any, error) { return bridge.GMAuctionList(ctx, body) }) gmToolPost(env, bridge, gmAuctionCancelPath, func(ctx context.Context, body dnfbridge.GMAuctionCancelRequest) (any, error) { return bridge.GMAuctionCancel(ctx, body) }) // ── 背包管理(列全部道具 + 删除,GM 工具背包页)───────────────────── env.AdminMux.HandleFunc(gmInventoryListPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { w.Header().Set("Allow", "GET, HEAD") writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) return } ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout) defer cancel() result, err := bridge.GMInventoryList(ctx, r.URL.Query().Get("character_id")) if err != nil { writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": result}) })) } // parseGMQuestID 解析任务路由里的 quest_id(正 int64)。 func parseGMQuestID(raw string) (int64, error) { value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil || value <= 0 { return 0, fmt.Errorf("%w: quest_id must be a positive integer", dnfbridge.ErrGMRequestInvalid) } return value, nil } // gmToolPost 是这几条新增写接口的公共外壳:只接受 POST、解析 body、 // 统一把成功/失败包成 {"ok":bool,...}。 func gmToolPost[T any]( env *appkit.Env, bridge *dnfbridge.Service, path string, handle func(ctx context.Context, body T) (any, error), ) { env.AdminMux.HandleFunc(path, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) return } var body T if err := decodeGMNullableJSON(r, &body); err != nil { writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout) defer cancel() data, err := handle(ctx, body) if err != nil { writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": data}) })) } // gmToolPostPath 与 gmToolPost 相同,但接受完整路由模板(含 {quest_id} 这类通配), // 并把路径参数交给 handle。GM 工具的任务操作需要 quest_id 在 URL 里。 func gmToolPostPath[T any]( env *appkit.Env, pattern string, handle func(ctx context.Context, pathValue func(string) string, body T) (any, error), ) { env.AdminMux.HandleFunc(pattern, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"}) return } var body T if err := decodeGMNullableJSON(r, &body); err != nil { writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } ctx, cancel := context.WithTimeout(r.Context(), gmToolAdminTimeout) defer cancel() data, err := handle(ctx, r.PathValue, body) if err != nil { writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": data}) })) } // gmQuestAdminBody 是任务操作请求体:只带 character_id,quest_id 在路径里。 type gmQuestAdminBody struct { CharacterID string `json:"character_id"` } // decodeGMNullableJSON 解析一个可选请求体;空体不算错误(工具端 probe 会发空 POST)。 func decodeGMNullableJSON(r *http.Request, target any) error { if r == nil || r.Body == nil { return nil } decoder := json.NewDecoder(io.LimitReader(r.Body, gmToolAdminBodyLimit)) if err := decoder.Decode(target); err != nil { if err == io.EOF { return nil } return err } return nil } func parseGMDungeonCharacterID(raw string) (uint16, error) { value, err := strconv.ParseUint(strings.TrimSpace(raw), 10, 16) if err != nil || value == 0 { return 0, errors.New("character_id must be a uint16") } return uint16(value), nil }