Files
90CNServer/server/internal/app/dnf90/gm_tool_admin.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

873 lines
33 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"
)
const gmAdminScope = "dnf-gm"
const (
gmGoldOperation = "POST /dnf/gm/characters/{character_id}/gold"
gmLevelOperation = "POST /dnf/gm/characters/{character_id}/level"
gmMailOperation = "POST /dnf/gm/characters/{character_id}/mail"
gmMailboxDeleteOperation = "POST /dnf/gm/characters/{character_id}/mailbox/delete"
gmEquipmentUnlockOperation = "POST /dnf/gm/characters/{character_id}/equipment-slots/unlock"
gmCareerOperation = "POST /dnf/gm/characters/{character_id}/career"
gmQuestAcceptOperation = "POST /dnf/gm/characters/{character_id}/quests/{quest_id}/accept"
gmQuestReadyOperation = "POST /dnf/gm/characters/{character_id}/quests/{quest_id}/ready"
gmQuestGiveUpOperation = "POST /dnf/gm/characters/{character_id}/quests/{quest_id}/give-up"
gmQuestCompleteOperation = "POST /dnf/gm/characters/{character_id}/quests/{quest_id}/complete"
gmQuestUncompleteOperation = "POST /dnf/gm/characters/{character_id}/quests/{quest_id}/uncomplete"
gmAccountValueOperation = "POST /dnf/gm/accounts/{account_id}/value"
gmSkillBonusOperation = "POST /dnf/gm/characters/{character_id}/skill-points"
gmInventoryDeleteOperation = "POST /dnf/gm/characters/{character_id}/inventory/delete"
gmInventoryClearOperation = "POST /dnf/gm/characters/{character_id}/inventory/clear"
gmEquipmentUnequipOperation = "POST /dnf/gm/characters/{character_id}/equipment/unequip"
gmEquipmentDeleteOperation = "POST /dnf/gm/characters/{character_id}/equipment/delete"
gmAccountCubeOperation = "POST /dnf/gm/characters/{character_id}/account-cubes"
gmQuestBatchOperation = "POST /dnf/gm/characters/{character_id}/quests/complete-batch"
gmDungeonDifficultyUnlock = "POST /dnf/gm/characters/{character_id}/dungeons/unlock-difficulties"
gmHellChallengeUnlock = "POST /dnf/gm/characters/{character_id}/dungeons/unlock-hell"
gmHellDropRateOperation = "POST /dnf/gm/gameplay/hell-drop-rate"
// gmEpicPotionTestMultiplierOperation 是"城镇增益药剂临时测试倍率"(0 = 关闭,
// 按 PVF 表走)。测试用,勿当生产配置。
gmEpicPotionTestMultiplierOperation = "POST /dnf/gm/gameplay/epic-potion-test-multiplier"
// gmFatigueModeOperation 疲劳模式(none/free/lock/refill)。
gmFatigueModeOperation = "POST /dnf/gm/gameplay/fatigue-mode"
// gmBurningOperation 燃烧疲劳开关(双倍消耗 + 经验翻倍)。
gmBurningOperation = "POST /dnf/gm/gameplay/burning"
// gmFatigueRefillOperation 一键回满角色疲劳。
gmFatigueRefillOperation = "POST /dnf/gm/characters/{character_id}/fatigue/refill"
// gmChannelReloadOperation 频道热重载:重读 channel_info.etc 并热替换频道表 +
// 对账游戏端口监听,不需要重启进程。
gmChannelReloadOperation = "POST /dnf/gm/channels/reload"
// gmRecordQueryOperation 记录查询。只读操作,但沿用统一的操作串便于审计日志对齐。
gmRecordQueryOperation = "POST /dnf/gm/records/query"
)
type gmAPIResponse struct {
Success bool `json:"success"`
Data any `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// gmRecordMaxOffset 记录查询允许的最大偏移。分页深度需要上限:偏移越大,
// 服务端就越要把前面的命中全部扫一遍再丢掉。10 万条足够翻遍实际保留期内的记录。
const gmRecordMaxOffset = 100000
func registerGMAdminRoutes(env *appkit.Env, service *dnfbridge.Service) {
if env == nil || env.AdminMux == nil || service == nil {
return
}
read := func(handler http.HandlerFunc) http.HandlerFunc {
if env.AdminAuth != nil {
return env.AdminAuth(gmAdminScope, handler)
}
return handler
}
write := func(operation string, handler http.HandlerFunc) http.HandlerFunc {
if env.AdminDangerous != nil {
return env.AdminDangerous(gmAdminScope, operation, handler)
}
if env.AdminAuth != nil {
return env.AdminAuth(gmAdminScope, handler)
}
return handler
}
env.AdminMux.HandleFunc("GET /dnf/gm/status", read(func(w http.ResponseWriter, r *http.Request) {
writeGMData(w, http.StatusOK, service.GMStatus(r.Context()))
}))
env.AdminMux.HandleFunc("GET /dnf/gm/channels", read(func(w http.ResponseWriter, r *http.Request) {
channels, err := service.GMChannels()
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, channels)
}))
// 神秘异次元遗迹(周常五图 4906~4910)的双组计数快照:账号组(5 + 冒险团分档)
// 与角色组(2/1/1/1/1)。只读 Preflight,不写库、不推进账本,所以走 read()。
// 有了它,两组计数是否独立生效不必依赖客户端界面即可核对。
env.AdminMux.HandleFunc("GET /dnf/gm/abis-weekly-dungeons", read(func(w http.ResponseWriter, r *http.Request) {
characterID, parseErr := strconv.ParseUint(strings.TrimSpace(r.URL.Query().Get("character_id")), 10, 16)
if parseErr != nil || characterID == 0 {
writeGMJSON(w, http.StatusBadRequest, map[string]any{
"ok": false, "error": "character_id query parameter is required (u16)",
})
return
}
overview, err := service.GMAbisWeeklyDungeons(r.Context(), uint16(characterID), time.Time{})
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, overview)
}))
// 频道热重载:运维侧改完 channel_info.etc 后调这里生效,不必重启 DNF90Server。
// 属于改运行态的操作,因此走 write()(危险操作包装:强制幂等键,可选确认头)。
env.AdminMux.HandleFunc("POST /dnf/gm/channels/reload", write(gmChannelReloadOperation, func(w http.ResponseWriter, r *http.Request) {
result, err := service.GMReloadChannels(r.Context())
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, result)
}))
// 记录查询(点券消费/对话/副本)。三类记录都落在服务端的 JSONL 文件里,
// 这里只做检索,不改任何运行态,所以走 read()。
env.AdminMux.HandleFunc("GET /dnf/gm/records/meta", read(func(w http.ResponseWriter, r *http.Request) {
writeGMData(w, http.StatusOK, service.GMRecordMeta())
}))
env.AdminMux.HandleFunc("POST /dnf/gm/records/query", read(func(w http.ResponseWriter, r *http.Request) {
var request struct {
Kind string `json:"kind"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Keyword string `json:"keyword"`
CharacterID string `json:"character_id"`
CharacterName string `json:"character_name"`
AccountID string `json:"account_id"`
Since string `json:"since"`
Until string `json:"until"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
if request.Offset < 0 || request.Offset > gmRecordMaxOffset {
writeGMToolError(w, fmt.Errorf("offset must be between 0 and %d", gmRecordMaxOffset))
return
}
since, err := gmParseRecordTime(request.Since)
if err != nil {
writeGMToolError(w, err)
return
}
until, err := gmParseRecordTime(request.Until)
if err != nil {
writeGMToolError(w, err)
return
}
page, err := service.GMQueryRecords(dnfbridge.GMRecordQuery{
Kind: request.Kind,
Limit: request.Limit,
Offset: request.Offset,
Keyword: request.Keyword,
CharacterID: request.CharacterID,
CharacterName: request.CharacterName,
AccountID: request.AccountID,
Since: since,
Until: until,
})
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, page)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/gameplay", read(func(w http.ResponseWriter, r *http.Request) {
settings, err := service.GMGameplaySettings(r.Context())
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, settings)
}))
env.AdminMux.HandleFunc("POST /dnf/gm/gameplay/hell-drop-rate", write(gmHellDropRateOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
Percent int `json:"percent"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
settings, err := service.GMSetHellEpicDropRate(r.Context(), request.Percent)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, settings)
}))
// 城镇增益药剂(史诗Buff药剂一族)的**临时测试倍率**:0 = 关闭(按 PVF 表走)。
// 服主要肉眼确认"用了药剂史诗真的掉得更多",真实倍率只有 2 倍、看不出来,
// 所以给一个能随手拧的旋钮。它只替换倍数,不绕过"药剂窗口必须仍在生效"这个前置条件。
env.AdminMux.HandleFunc("POST /dnf/gm/gameplay/epic-potion-test-multiplier", write(gmEpicPotionTestMultiplierOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
Percent int `json:"percent"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
settings, err := service.GMSetEpicDropRatePotionTestMultiplier(r.Context(), request.Percent)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, settings)
}))
// 疲劳模式(none/free/lock/refill):无限疲劳/锁高值/一键回满的开关。
env.AdminMux.HandleFunc("POST /dnf/gm/gameplay/fatigue-mode", write(gmFatigueModeOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
Mode string `json:"mode"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
settings, err := service.GMSetFatigueMode(r.Context(), request.Mode)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, settings)
}))
// 燃烧疲劳开关(双倍消耗 + 经验翻倍)。
env.AdminMux.HandleFunc("POST /dnf/gm/gameplay/burning", write(gmBurningOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
Enabled bool `json:"enabled"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
settings, err := service.GMSetBurning(r.Context(), request.Enabled)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, settings)
}))
// 一键回满角色疲劳。
env.AdminMux.HandleFunc("POST /dnf/gm/characters/{character_id}/fatigue/refill", write(gmFatigueRefillOperation, func(w http.ResponseWriter, r *http.Request) {
summary, err := service.GMRefillFatigue(r.Context(), r.PathValue("character_id"))
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, summary)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/accounts", read(func(w http.ResponseWriter, r *http.Request) {
limit, err := gmLimit(r, 50)
if err != nil {
writeGMToolError(w, err)
return
}
accounts, err := service.GMListAccounts(r.Context(), r.URL.Query().Get("q"), limit)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, accounts)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/accounts/{account_id}/characters", read(func(w http.ResponseWriter, r *http.Request) {
characters, err := service.GMListCharacters(r.Context(), r.PathValue("account_id"))
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, characters)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/accounts/{account_id}", read(func(w http.ResponseWriter, r *http.Request) {
detail, err := service.GMAccount(r.Context(), r.PathValue("account_id"))
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, detail)
}))
env.AdminMux.HandleFunc("POST /dnf/gm/accounts/{account_id}/value", write(gmAccountValueOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
Field string `json:"field"`
Value uint64 `json:"value"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
detail, err := service.GMSetAccountValue(r.Context(), r.PathValue("account_id"), request.Field, request.Value)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, detail)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/characters/{character_id}", read(func(w http.ResponseWriter, r *http.Request) {
character, err := service.GMCharacter(r.Context(), r.PathValue("character_id"))
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, character)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/characters/{character_id}/dungeons", read(func(w http.ResponseWriter, r *http.Request) {
state, err := service.GMDungeonAccess(r.Context(), r.PathValue("character_id"))
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, state)
}))
env.AdminMux.HandleFunc("POST /dnf/gm/characters/{character_id}/dungeons/unlock-difficulties", write(gmDungeonDifficultyUnlock, func(w http.ResponseWriter, r *http.Request) {
state, err := service.GMUnlockAllDungeonDifficulties(r.Context(), r.PathValue("character_id"))
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, state)
}))
env.AdminMux.HandleFunc("POST /dnf/gm/characters/{character_id}/dungeons/unlock-hell", write(gmHellChallengeUnlock, func(w http.ResponseWriter, r *http.Request) {
state, err := service.GMUnlockAllHellChallenges(r.Context(), r.PathValue("character_id"))
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, state)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/characters/{character_id}/detail", read(func(w http.ResponseWriter, r *http.Request) {
detail, err := service.GMCharacterDetail(r.Context(), r.PathValue("character_id"))
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, detail)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/characters/{character_id}/mailbox", read(func(w http.ResponseWriter, r *http.Request) {
mailbox, err := service.GMMailbox(r.Context(), r.PathValue("character_id"))
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, mailbox)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/characters/{character_id}/equipment-slots", read(func(w http.ResponseWriter, r *http.Request) {
state, err := service.GMExtraEquipmentSlots(r.Context(), r.PathValue("character_id"))
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, state)
}))
env.AdminMux.HandleFunc("POST /dnf/gm/characters/{character_id}/equipment-slots/unlock", write(gmEquipmentUnlockOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
Slots []string `json:"slots"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
state, err := service.GMUnlockExtraEquipmentSlots(r.Context(), r.PathValue("character_id"), request.Slots)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, state)
}))
env.AdminMux.HandleFunc("POST /dnf/gm/characters/{character_id}/mailbox/delete", write(gmMailboxDeleteOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
MailIDs []string `json:"mail_ids"`
Force bool `json:"force"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
result, err := service.GMDeleteMailboxMails(r.Context(), r.PathValue("character_id"), request.MailIDs, request.Force)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, result)
}))
env.AdminMux.HandleFunc("POST /dnf/gm/characters/{character_id}/skill-points", write(gmSkillBonusOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
BonusSP int64 `json:"bonus_sp"`
BonusTP int64 `json:"bonus_tp"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
detail, err := service.GMSetCharacterSkillBonus(r.Context(), r.PathValue("character_id"), request.BonusSP, request.BonusTP)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, detail)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/characters/{character_id}/inventory", read(func(w http.ResponseWriter, r *http.Request) {
snapshot, err := service.GMInventory(r.Context(), r.PathValue("character_id"))
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, snapshot)
}))
env.AdminMux.HandleFunc("POST /dnf/gm/characters/{character_id}/inventory/delete", write(gmInventoryDeleteOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
Container string `json:"container"`
Key string `json:"key"`
Count int64 `json:"count"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
snapshot, err := service.GMDeleteInventoryItem(r.Context(), r.PathValue("character_id"), request.Container, request.Key, request.Count)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, snapshot)
}))
env.AdminMux.HandleFunc("POST /dnf/gm/characters/{character_id}/inventory/clear", write(gmInventoryClearOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
Container string `json:"container"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
snapshot, err := service.GMClearInventory(r.Context(), r.PathValue("character_id"), request.Container)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, snapshot)
}))
env.AdminMux.HandleFunc("POST /dnf/gm/characters/{character_id}/equipment/unequip", write(gmEquipmentUnequipOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
Key string `json:"key"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
snapshot, err := service.GMUnequipEquipment(r.Context(), r.PathValue("character_id"), request.Key)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, snapshot)
}))
env.AdminMux.HandleFunc("POST /dnf/gm/characters/{character_id}/equipment/delete", write(gmEquipmentDeleteOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
Key string `json:"key"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
snapshot, err := service.GMDeleteEquipment(r.Context(), r.PathValue("character_id"), request.Key)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, snapshot)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/characters/{character_id}/account-cubes", read(func(w http.ResponseWriter, r *http.Request) {
snapshot, err := service.GMAccountCubes(r.Context(), r.PathValue("character_id"))
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, snapshot)
}))
env.AdminMux.HandleFunc("POST /dnf/gm/characters/{character_id}/account-cubes", write(gmAccountCubeOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
Selection int `json:"selection"`
Count int64 `json:"count"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
snapshot, err := service.GMSetAccountCube(r.Context(), r.PathValue("character_id"), request.Selection, request.Count)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, snapshot)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/characters/{character_id}/career", read(func(w http.ResponseWriter, r *http.Request) {
career, err := service.GMCareer(r.Context(), r.PathValue("character_id"))
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, career)
}))
env.AdminMux.HandleFunc("POST /dnf/gm/characters/{character_id}/career", write(gmCareerOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
Job int `json:"job"`
First int `json:"first"`
Awakening int `json:"awakening"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
career, err := service.GMSetCareer(r.Context(), r.PathValue("character_id"), request.Job, request.First, request.Awakening)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, career)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/characters/{character_id}/quests", read(func(w http.ResponseWriter, r *http.Request) {
overview, err := service.GMQuestOverview(r.Context(), r.PathValue("character_id"))
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, overview)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/characters/{character_id}/quests/search", read(func(w http.ResponseWriter, r *http.Request) {
limit, err := gmLimit(r, 40)
if err != nil {
writeGMToolError(w, err)
return
}
quests, err := service.GMSearchQuests(r.Context(), r.PathValue("character_id"), r.URL.Query().Get("q"), limit)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, quests)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/characters/{character_id}/quests/{quest_id}/detail", read(func(w http.ResponseWriter, r *http.Request) {
questID, err := strconv.ParseInt(strings.TrimSpace(r.PathValue("quest_id")), 10, 64)
if err != nil || questID <= 0 {
writeGMToolError(w, dnfbridge.ErrGMInvalidRequest)
return
}
detail, err := service.GMQuestDetail(r.Context(), r.PathValue("character_id"), questID)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, detail)
}))
registerGMQuestMutation := func(pattern string, operation string, mutate func(context.Context, string, int64) (dnfbridge.GMQuestMutationResult, error)) {
env.AdminMux.HandleFunc(pattern, write(operation, func(w http.ResponseWriter, r *http.Request) {
questID, err := strconv.ParseInt(strings.TrimSpace(r.PathValue("quest_id")), 10, 64)
if err != nil {
writeGMToolError(w, dnfbridge.ErrGMInvalidRequest)
return
}
result, err := mutate(r.Context(), r.PathValue("character_id"), questID)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, result)
}))
}
registerGMQuestMutation("POST /dnf/gm/characters/{character_id}/quests/{quest_id}/accept", gmQuestAcceptOperation, service.GMAcceptQuest)
registerGMQuestMutation("POST /dnf/gm/characters/{character_id}/quests/{quest_id}/ready", gmQuestReadyOperation, service.GMMarkQuestReady)
registerGMQuestMutation("POST /dnf/gm/characters/{character_id}/quests/{quest_id}/give-up", gmQuestGiveUpOperation, service.GMGiveUpQuest)
registerGMQuestMutation("POST /dnf/gm/characters/{character_id}/quests/{quest_id}/complete", gmQuestCompleteOperation, service.GMForceCompleteQuest)
registerGMQuestMutation("POST /dnf/gm/characters/{character_id}/quests/{quest_id}/uncomplete", gmQuestUncompleteOperation, service.GMUncompleteQuest)
env.AdminMux.HandleFunc("POST /dnf/gm/characters/{character_id}/quests/complete-batch", write(gmQuestBatchOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
QuestIDs []int64 `json:"quest_ids"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
overview, err := service.GMCompleteQuestBatch(r.Context(), r.PathValue("character_id"), request.QuestIDs)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, overview)
}))
env.AdminMux.HandleFunc("POST /dnf/gm/characters/{character_id}/gold", write(gmGoldOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
Value int64 `json:"value"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
character, err := service.GMSetCharacterGold(r.Context(), r.PathValue("character_id"), request.Value)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, character)
}))
env.AdminMux.HandleFunc("POST /dnf/gm/characters/{character_id}/level", write(gmLevelOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
Level int `json:"level"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
character, err := service.GMSetCharacterLevel(r.Context(), r.PathValue("character_id"), request.Level)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, character)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/items", read(func(w http.ResponseWriter, r *http.Request) {
limit, err := gmLimit(r, 30)
if err != nil {
writeGMToolError(w, err)
return
}
items, err := service.GMSearchItems(r.Context(), r.URL.Query().Get("q"), limit)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, items)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/items/page", read(func(w http.ResponseWriter, r *http.Request) {
limit, err := gmLimit(r, 100)
if err != nil {
writeGMToolError(w, err)
return
}
parseInt := func(name string, fallback int64) (int64, error) {
raw := strings.TrimSpace(r.URL.Query().Get(name))
if raw == "" {
return fallback, nil
}
return strconv.ParseInt(raw, 10, 64)
}
minLevel, err := parseInt("min_level", 0)
if err != nil {
writeGMToolError(w, dnfbridge.ErrGMInvalidRequest)
return
}
maxLevel, err := parseInt("max_level", 0)
if err != nil {
writeGMToolError(w, dnfbridge.ErrGMInvalidRequest)
return
}
rarity, err := parseInt("rarity", -1)
if err != nil {
writeGMToolError(w, dnfbridge.ErrGMInvalidRequest)
return
}
offset64, err := parseInt("offset", 0)
if err != nil || offset64 < 0 || offset64 > int64(^uint(0)>>1) {
writeGMToolError(w, dnfbridge.ErrGMInvalidRequest)
return
}
page, err := service.GMSearchItemPage(r.Context(), dnfbridge.GMItemQuery{
Query: r.URL.Query().Get("q"), Kind: r.URL.Query().Get("kind"), Tag: r.URL.Query().Get("tag"), Segment: r.URL.Query().Get("segment"), Group: r.URL.Query().Get("group"), Job: r.URL.Query().Get("job"), MinLevel: minLevel,
MaxLevel: maxLevel, Rarity: rarity, Expiration: r.URL.Query().Get("expiration"), Limit: limit, Offset: int(offset64),
})
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, page)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/avatar-sets", read(func(w http.ResponseWriter, r *http.Request) {
limit, err := gmLimit(r, 24)
if err != nil {
writeGMToolError(w, err)
return
}
offset := 0
if raw := strings.TrimSpace(r.URL.Query().Get("offset")); raw != "" {
value, parseErr := strconv.ParseInt(raw, 10, 32)
if parseErr != nil || value < 0 {
writeGMToolError(w, dnfbridge.ErrGMInvalidRequest)
return
}
offset = int(value)
}
page, err := service.GMSearchAvatarSets(r.Context(), dnfbridge.GMAvatarSetQuery{
Query: r.URL.Query().Get("q"), Job: r.URL.Query().Get("job"), Limit: limit, Offset: offset,
})
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, page)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/items/{item_id}/detail", read(func(w http.ResponseWriter, r *http.Request) {
itemID, err := strconv.ParseUint(strings.TrimSpace(r.PathValue("item_id")), 10, 32)
if err != nil || itemID == 0 {
writeGMToolError(w, dnfbridge.ErrGMInvalidRequest)
return
}
detail, err := service.GMItemDetail(r.Context(), uint32(itemID))
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, detail)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/items/{item_id}/mail-options", read(func(w http.ResponseWriter, r *http.Request) {
itemID, err := strconv.ParseUint(strings.TrimSpace(r.PathValue("item_id")), 10, 32)
if err != nil || itemID == 0 {
writeGMToolError(w, dnfbridge.ErrGMInvalidRequest)
return
}
limit, err := gmLimit(r, 40)
if err != nil {
writeGMToolError(w, err)
return
}
offset := 0
if raw := strings.TrimSpace(r.URL.Query().Get("offset")); raw != "" {
offset, err = strconv.Atoi(raw)
}
if err != nil || offset < 0 {
writeGMToolError(w, dnfbridge.ErrGMInvalidRequest)
return
}
rarity := int64(-1)
if raw := strings.TrimSpace(r.URL.Query().Get("rarity")); raw != "" {
rarity, err = strconv.ParseInt(raw, 10, 64)
}
if err != nil || rarity < -1 || rarity > 6 {
writeGMToolError(w, dnfbridge.ErrGMInvalidRequest)
return
}
page, err := service.GMItemMailOptions(r.Context(), uint32(itemID), r.URL.Query().Get("mode"), r.URL.Query().Get("q"), rarity, limit, offset)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, page)
}))
env.AdminMux.HandleFunc("GET /dnf/gm/items/categories", read(func(w http.ResponseWriter, r *http.Request) {
categories, err := service.GMItemCategories(r.Context())
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, categories)
}))
env.AdminMux.HandleFunc("POST /dnf/gm/characters/{character_id}/mail", write(gmMailOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
ItemID uint32 `json:"item_id"`
Count int64 `json:"count"`
Title string `json:"title"`
Body string `json:"body"`
ExpireDays int `json:"expire_days"`
Reinforce int `json:"reinforce"`
AmplifyType int `json:"amplify_type"`
EquipmentQuality string `json:"equipment_quality"`
Forging int `json:"forging"`
EmblemItemIDs []uint32 `json:"emblem_item_ids"`
EnchantBeadItemID uint32 `json:"enchant_bead_item_id"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
result, err := service.GMSendItemMailWithOptions(
r.Context(),
r.PathValue("character_id"),
request.ItemID,
request.Count,
request.Title,
request.Body,
dnfbridge.GMMailOptions{
ExpireDays: request.ExpireDays,
Reinforce: request.Reinforce,
AmplifyType: request.AmplifyType,
EquipmentQuality: request.EquipmentQuality,
Forging: request.Forging,
EmblemItemIDs: request.EmblemItemIDs,
EnchantBeadItemID: request.EnchantBeadItemID,
},
)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusCreated, result)
}))
}
func gmLimit(r *http.Request, fallback int) (int, error) {
raw := strings.TrimSpace(r.URL.Query().Get("limit"))
if raw == "" {
return fallback, nil
}
limit, err := strconv.Atoi(raw)
if err != nil || limit < 1 || limit > 200 {
return 0, errors.New("limit must be between 1 and 200")
}
return limit, nil
}
// gmParseRecordTime 解析记录查询的时间边界。
//
// 接受两种写法:RFC3339(管理端界面直接给带时区的完整时间),以及 "2006-01-02 15:04:05"
// / "2006-01-02"(手写查询时更省事,按本地时区解释)。空串表示不限。
func gmParseRecordTime(raw string) (time.Time, error) {
value := strings.TrimSpace(raw)
if value == "" {
return time.Time{}, nil
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
if stamp, err := time.ParseInLocation(layout, value, time.Local); err == nil {
return stamp, nil
}
}
return time.Time{}, fmt.Errorf("invalid JSON request")
}
func decodeGMJSON(w http.ResponseWriter, r *http.Request, target any) error {
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16*1024))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return errors.New("invalid JSON request")
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("request must contain exactly one JSON object")
}
return nil
}
func writeGMData(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(gmAPIResponse{Success: true, Data: data})
}
func writeGMToolError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
switch {
case errors.Is(err, dnfbridge.ErrGMCharacterNotFound), errors.Is(err, dnfbridge.ErrGMAccountNotFound):
status = http.StatusNotFound
case errors.Is(err, dnfbridge.ErrGMCharacterOnline):
status = http.StatusConflict
case errors.Is(err, dnfbridge.ErrGMInvalidRequest):
status = http.StatusBadRequest
case errors.Is(err, dnfbridge.ErrGMUnavailable):
status = http.StatusServiceUnavailable
default:
if strings.HasPrefix(err.Error(), "limit must") || strings.HasPrefix(err.Error(), "invalid JSON") || strings.HasPrefix(err.Error(), "request must") {
status = http.StatusBadRequest
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(gmAPIResponse{Success: false, Error: err.Error()})
}