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