DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,140 @@
package dnf90
import (
"errors"
"io"
"net/http"
dnfcompanion "longheng.io/server/internal/modules/dnf/companion"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
appkit "longheng.io/server/internal/platform/app"
httpx "longheng.io/server/internal/platform/httpx"
"longheng.io/server/internal/services/dnfbridge"
)
const companionAdminPath = "/local/companion"
func registerCompanionAdmin(env *appkit.Env, bridge *dnfbridge.Service) {
if env == nil || env.AdminMux == nil || bridge == nil {
return
}
readHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 同时接受 character_id(内部/Server-90)与 characterId(GM 工具页面)。
value, ok := dnfbridge.DecodeCompanionToolCharacterID(r.URL.RawQuery)
if !ok {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "character_id (or characterId) must be a non-zero uint16",
})
return
}
state, err := bridge.CompanionControlState(r.Context(), value)
if err != nil {
httpx.WriteJSON(w, companionAdminErrorStatus(err), map[string]any{
"error": err.Error(),
"character_id": value,
})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"available": true,
"mode": "next_dungeon_frozen_snapshot",
"state": state,
})
})
if env.AdminAuth != nil {
readHandler = env.AdminAuth("platform", readHandler)
}
writeHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 同时接受内部/Server-90 的 snake_case 与 GM 工具页面的 camelCase 协议
// (含字符串形式的角色 ID)。解析细节见 dnfbridge/companion_tool_protocol.go。
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
request, err := dnfbridge.DecodeCompanionToolRequest(body)
if err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
sources := append([]uint16(nil), request.SourceCharacterIDs...)
if len(sources) == 0 && request.SourceCharacterID != 0 {
sources = append(sources, request.SourceCharacterID)
}
if request.CharacterID == 0 || (request.Enabled && len(sources) == 0) {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "character_id and at least one enabled source_character_id must be non-zero uint16",
})
return
}
for _, source := range sources {
if source == 0 {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "source_character_ids must contain only non-zero uint16 values",
})
return
}
}
state, err := bridge.CompanionControlUpdateManyWithAIProfiles(
r.Context(), request.CharacterID, request.ExpectedRevision,
request.Enabled, sources, request.AI, request.AIProfiles,
request.BalanceEnabled, request.ProgressionEnabled, request.RandomEquipmentEnabled,
)
if err != nil {
httpx.WriteJSON(w, companionAdminErrorStatus(err), map[string]any{
"error": err.Error(),
"character_id": request.CharacterID,
"expected_revision": request.ExpectedRevision,
})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"available": true,
"mode": "next_dungeon_frozen_snapshot",
"state": state,
})
})
if env.AdminDangerous != nil {
writeHandler = env.AdminDangerous(
"platform",
httpx.AdminOperationID(http.MethodPost, companionAdminPath),
writeHandler,
)
} else if env.AdminAuth != nil {
writeHandler = env.AdminAuth("platform", writeHandler)
}
env.AdminMux.HandleFunc(companionAdminPath, func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
readHandler(w, r)
case http.MethodPost:
writeHandler(w, r)
default:
w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost)
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
})
}
func companionAdminErrorStatus(err error) int {
switch {
case errors.Is(err, dnfcompanion.ErrSelection),
errors.Is(err, dnfcompanion.ErrSelf):
return http.StatusBadRequest
case errors.Is(err, dnfcompanion.ErrRevision),
errors.Is(err, dnfcompanion.ErrAccount),
errors.Is(err, dnfcompanion.ErrOnline),
errors.Is(err, dnfcompanion.ErrSource),
errors.Is(err, dnfcompanion.ErrSnapshot),
errors.Is(err, dnfrepo.ErrCompanionAccountMismatch),
errors.Is(err, dnfrepo.ErrCompanionCharacterUnavailable):
return http.StatusConflict
case errors.Is(err, dnfrepo.ErrCompanionReadUnavailable):
return http.StatusServiceUnavailable
default:
return http.StatusInternalServerError
}
}
+225
View File
@@ -0,0 +1,225 @@
// Package dnf90 contains the single composition root for the DNF90 server.
package dnf90
import (
"context"
"encoding/json"
"net/http"
"os"
"strings"
appkit "longheng.io/server/internal/platform/app"
"longheng.io/server/internal/services/dnfbridge"
"longheng.io/server/internal/services/dnfrobot"
logicdnf "longheng.io/server/internal/services/logic/dnf"
)
const (
ServiceName = "dnfbridge"
DefaultConfigPath = "configs/dnfbridge.toml"
DefaultLogicPath = "configs/dnf/logic.toml"
)
// Components wires storage before the protocol service.
func Components(env *appkit.Env) ([]appkit.Component, error) {
return components(env, nil)
}
// ComponentsWithShutdown adds a loopback admin endpoint that cancels the
// application context, allowing the Windows launcher to stop cleanly.
func ComponentsWithShutdown(cancel context.CancelFunc) appkit.Builder {
return func(env *appkit.Env) ([]appkit.Component, error) {
return components(env, cancel)
}
}
func components(env *appkit.Env, cancel context.CancelFunc) ([]appkit.Component, error) {
if cancel != nil && env != nil && env.AdminMux != nil {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte("shutdown requested\n"))
go cancel()
})
if env.AdminAuth != nil {
handler = env.AdminAuth("platform", handler)
}
env.AdminMux.HandleFunc("/local/shutdown", handler)
}
configPath := strings.TrimSpace(os.Getenv(logicdnf.EnvConfigPath))
if configPath == "" {
configPath = DefaultLogicPath
}
cfg, err := logicdnf.LoadConfigForEnv(configPath, env)
if err != nil {
return nil, err
}
runtime, err := logicdnf.NewRuntime(env, cfg)
if err != nil {
return nil, err
}
robotsEnabled := dnfRobotsEnabled()
robotSessionSecret := ""
if robotsEnabled {
robotSessionSecret = env.Core().Config.Admin.Token
}
bridge := dnfbridge.NewWithOptions(env, dnfbridge.ServiceOptions{
RepositoryProvider: runtime.Group,
LocalDebugMode: localDNFDebugModeEnabled(env),
LocalDebugStrikeOnSkillUse: localDNFDebugStrikeOnSkillUseEnabled(env),
RobotSessionSecret: robotSessionSecret,
})
if env != nil && env.AdminMux != nil {
adminListen := strings.TrimSpace(env.Core().Config.Admin.Listen)
if adminListen != "" {
if err := bridge.ConfigurePaymentWeb("http://" + adminListen); err != nil {
return nil, err
}
env.AdminMux.Handle(dnfbridge.PaymentWebOrdinaryPath, bridge.PaymentWebHandler())
}
}
registerDNFDebugRoutes(env, bridge)
registerDNFGMRoutes(env, bridge)
// 30CN GM 工具(GmTool90CN)需要的 /local 桥接能力:
// /local/quest-resync、/local/dungeon-control、/local/character-clone。
registerDNFGMToolCompatRoutes(env, bridge)
// 支援兵(companion):/local/companion 读状态 + 写配置。
// 配置按账号落库,运行态按副本冻结;真正的召唤走私有 opcode 0x7F41 投影。
registerCompanionAdmin(env, bridge)
registerGMAdminRoutes(env, bridge)
var robotManager *dnfrobot.Manager
if robotsEnabled {
robotManager = dnfrobot.NewManager(env.Logger, runtime.Group, bridge, env.Core().Config.Admin.Token)
registerDNFRobotGMRoutes(env, bridge, robotManager)
}
if env != nil && env.AdminMux != nil {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var request struct {
PID uint32 `json:"pid"`
AccountID string `json:"account_id"`
ClientIP string `json:"client_ip,omitempty"`
// LidLease is the launcher's signed statement over
// (server_id, pid, account_id, expiry). It is optional so an
// older launcher keeps working; when the server enforces the
// launcher identity it is the difference between a seat and a
// refusal.
LidLease string `json:"lid_lease,omitempty"`
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
identity := bridge.LauncherIdentityInfo()
lease := strings.TrimSpace(request.LidLease)
var err error
switch {
case lease != "":
// A lease names the launcher, so it takes precedence over the
// legacy shapes: the client_ip form is the SSH-attested remote
// path, and combining the two would need a proof over both
// dimensions.
attested, lidErr := bridge.VerifyLauncherIdentityLease(
request.PID,
request.AccountID,
lease,
)
if lidErr != nil {
if identity.Mode == dnfbridge.LauncherIdentityModeEnforce {
// 401, never 400. A 400 is exactly what an older server
// returns for an unknown JSON field, and the launcher
// answers a 400 by retrying without the proof — which
// would silently register an unattested PID that the game
// gate then refuses, so the operator sees an unexplained
// connect failure instead of "this launcher is not
// authorized".
http.Error(w, "launcher identity lease rejected: "+lidErr.Error(),
http.StatusUnauthorized)
return
}
// Audit: record why, then keep the pre-feature behaviour.
bridge.LogLauncherIdentityVerdict(request.PID, request.AccountID, lidErr)
}
err = bridge.RegisterClientAccountAttested(request.PID, request.AccountID, attested)
case identity.Mode == dnfbridge.LauncherIdentityModeEnforce:
// Enforce mode with no proof at all. Registering the account here
// would only move the refusal to the game socket, where the
// symptom is an unexplained connect failure.
lidErr := bridge.RejectBareLauncherIdentityBind(request.PID, request.AccountID)
http.Error(w, "launcher identity lease required: "+lidErr.Error(),
http.StatusUnauthorized)
return
case strings.TrimSpace(request.ClientIP) != "":
err = bridge.RegisterRemoteClientAccount(request.PID, request.ClientIP, request.AccountID)
default:
err = bridge.RegisterClientAccount(request.PID, request.AccountID)
}
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
})
if env.AdminAuth != nil {
handler = env.AdminAuth("platform", handler)
}
env.AdminMux.HandleFunc("/local/client-account", handler)
// The launcher reads this before it signs anything: the server instance
// ID is what stops a proof from one server run being replayed against
// the next, and the mode tells the launcher whether a proof is required
// or merely observed. It is admin-authenticated like every other
// /local/* route, so only a caller that already holds the admin token
// can read it.
infoHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(bridge.LauncherIdentityInfo())
})
if env.AdminAuth != nil {
infoHandler = env.AdminAuth("platform", infoHandler)
}
env.AdminMux.HandleFunc("/local/lid-info", infoHandler)
}
components := make([]appkit.Component, 0, 3)
if runtime.Repository != nil {
components = append(components, runtime.Repository)
}
components = append(components, bridge)
if robotManager != nil {
components = append(components, robotManager)
}
return components, nil
}
func dnfRobotsEnabled() bool {
value := strings.TrimSpace(os.Getenv("DNFBRIDGE_ROBOT_ENABLED"))
return strings.EqualFold(value, "true")
}
func localDNFDebugModeEnabled(env *appkit.Env) bool {
if env == nil || !strings.EqualFold(env.Core().Config.Service.Environment, "local") {
return false
}
return strings.EqualFold(strings.TrimSpace(os.Getenv("DNFBRIDGE_DEBUG_MODE")), "true")
}
func localDNFDebugStrikeOnSkillUseEnabled(env *appkit.Env) bool {
return localDNFDebugModeEnabled(env) &&
strings.EqualFold(strings.TrimSpace(os.Getenv("DNFBRIDGE_DEBUG_STRIKE_ON_SKILL_USE")), "true")
}
@@ -0,0 +1,22 @@
package dnf90
import "testing"
func TestDNFRobotsEnabledDefaultsOff(t *testing.T) {
t.Setenv("DNFBRIDGE_ROBOT_ENABLED", "")
if dnfRobotsEnabled() {
t.Fatal("robots should remain disabled when the switch is absent")
}
}
func TestDNFRobotsEnabledHonorsExplicitSwitch(t *testing.T) {
t.Setenv("DNFBRIDGE_ROBOT_ENABLED", "false")
if dnfRobotsEnabled() {
t.Fatal("robots should be disabled by an explicit false switch")
}
t.Setenv("DNFBRIDGE_ROBOT_ENABLED", " TRUE ")
if !dnfRobotsEnabled() {
t.Fatal("robots should be enabled by an explicit true switch")
}
}
+141
View File
@@ -0,0 +1,141 @@
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"
)
type debugDungeonRequest struct {
CharacterID uint16 `json:"character_id,omitempty"`
AccountID string `json:"account_id,omitempty"`
ClientPID uint32 `json:"client_pid,omitempty"`
NextX *int `json:"next_x,omitempty"`
NextY *int `json:"next_y,omitempty"`
}
func registerDNFDebugRoutes(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("/local/dnf-debug/sessions", env.AdminAuth("dnf-debug", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", http.MethodGet)
writeDebugJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
writeDebugJSON(w, http.StatusOK, map[string]any{
"environment": "local",
"sessions": bridge.DebugSessionsSnapshot(),
})
}))
env.AdminMux.HandleFunc("/local/dnf-debug/kill-current-room", env.AdminAuth("dnf-debug", debugActionHandler(bridge, "kill")))
env.AdminMux.HandleFunc("/local/dnf-debug/move-room", env.AdminAuth("dnf-debug", debugActionHandler(bridge, "move")))
}
func debugActionHandler(bridge *dnfbridge.Service, action string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeDebugJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
request, err := decodeDebugDungeonRequest(w, r)
if err != nil {
writeDebugJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
characterID, err := bridge.DebugResolveCharacterID(request.CharacterID, request.AccountID, request.ClientPID)
if err != nil {
writeDebugJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
var result dnfbridge.DebugDungeonResult
switch action {
case "kill":
result, err = bridge.DebugKillCurrentRoom(ctx, characterID)
case "move":
if request.NextX == nil || request.NextY == nil || *request.NextX < 0 || *request.NextX > 255 || *request.NextY < 0 || *request.NextY > 255 {
writeDebugJSON(w, http.StatusBadRequest, map[string]string{"error": "next_x and next_y must be bytes"})
return
}
result, err = bridge.DebugMoveCurrentRoom(ctx, characterID, byte(*request.NextX), byte(*request.NextY))
default:
writeDebugJSON(w, http.StatusNotFound, map[string]string{"error": "unknown debug action"})
return
}
if err != nil {
status := http.StatusConflict
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
status = http.StatusGatewayTimeout
}
writeDebugJSON(w, status, map[string]string{"error": err.Error()})
return
}
writeDebugJSON(w, http.StatusOK, result)
}
}
func decodeDebugDungeonRequest(w http.ResponseWriter, r *http.Request) (debugDungeonRequest, error) {
request := debugDungeonRequest{}
if r.Body != nil {
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil && !errors.Is(err, io.EOF) {
return debugDungeonRequest{}, fmt.Errorf("invalid request: %w", err)
}
}
if request.CharacterID == 0 {
request.CharacterID = uint16(queryUint(r, "character_id"))
}
if request.AccountID == "" {
request.AccountID = strings.TrimSpace(r.URL.Query().Get("account_id"))
}
if request.ClientPID == 0 {
request.ClientPID = uint32(queryUint(r, "client_pid"))
}
if request.NextX == nil {
if value, ok := queryInt(r, "next_x"); ok {
request.NextX = &value
}
}
if request.NextY == nil {
if value, ok := queryInt(r, "next_y"); ok {
request.NextY = &value
}
}
return request, nil
}
func queryUint(r *http.Request, key string) uint64 {
value, err := strconv.ParseUint(strings.TrimSpace(r.URL.Query().Get(key)), 10, 32)
if err != nil {
return 0
}
return value
}
func queryInt(r *http.Request, key string) (int, bool) {
value, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get(key)))
return value, err == nil
}
func writeDebugJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+436
View File
@@ -0,0 +1,436 @@
package dnf90
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
appkit "longheng.io/server/internal/platform/app"
"longheng.io/server/internal/services/dnfbridge"
)
const (
gmCharactersPath = "/local/dnf-gm/characters"
gmSystemMailPath = "/local/dnf-gm/mail"
gmMailboxClearPath = "/local/dnf-gm/mail/clear"
gmWalletQueryPath = "/local/dnf-gm/wallet/query"
gmWalletUpdatePath = "/local/dnf-gm/wallet/update"
gmWalletRepairHappyPath = "/local/dnf-gm/wallet/repair-happy-token-cera"
gmCharacterSkillPath = "/local/dnf-gm/character/sptp"
gmCharacterLevelPath = "/local/dnf-gm/character/level"
gmCharacterJobPath = "/local/dnf-gm/character/job"
gmCharacterBlackDiamondPath = "/local/dnf-gm/character/black-diamond"
gmDungeonsUnlockAllPath = "/local/dnf-gm/dungeons/unlock-all"
gmEquipSlotsUnlockAllPath = "/local/dnf-gm/equip-slots/unlock-all"
gmKickSessionsPath = "/local/dnf-gm/kick-sessions"
)
func registerDNFGMRoutes(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(gmCharactersPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", http.MethodGet)
writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
accountID := strings.TrimSpace(r.URL.Query().Get("account_id"))
characterName := strings.TrimSpace(r.URL.Query().Get("character_name"))
username := strings.TrimSpace(r.URL.Query().Get("username"))
var characters []dnfbridge.GMCharacterInfo
var err error
if username != "" {
if accountID != "" || characterName != "" {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "only one character selector is allowed"})
return
}
characters, err = bridge.GMFindCharactersByUsername(ctx, username)
} else if accountID != "" || characterName != "" {
characters, err = bridge.GMFindCharacters(ctx, accountID, characterName)
} else {
characters, err = bridge.GMCharacters(ctx)
}
if err != nil {
writeGMError(w, err)
return
}
message := "character list loaded"
if username != "" || accountID != "" || characterName != "" {
message = "character query completed"
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": message, "data": characters})
}))
env.AdminMux.HandleFunc(gmSystemMailPath, 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
}
request := dnfbridge.GMSystemMailRequest{}
if !decodeGMRequest(w, r, &request, 64<<10) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
result, err := bridge.GMSendSystemMail(ctx, request)
if err != nil {
writeGMError(w, err)
return
}
message := "mail committed"
if result.Refreshed {
message = "mail committed and online mailbox refreshed"
} else if result.Online {
message = "mail committed; online refresh will retry on next mailbox open"
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": message, "data": result})
}))
env.AdminMux.HandleFunc(gmMailboxClearPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
request := dnfbridge.GMMailboxClearRequest{}
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
result, err := bridge.GMClearMailbox(ctx, request)
if err != nil {
writeGMError(w, err)
return
}
message := "mailbox cleared"
if result.Refreshed {
message = "mailbox cleared and online client refreshed"
} else if result.Online {
message = "mailbox cleared; online refresh will retry on next mailbox open"
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": message, "data": result})
}))
registerDNFGMWalletRoutes(env, bridge)
registerDNFGMCharacterRoutes(env, bridge)
registerDNFGKickRoutes(env, bridge)
}
// registerDNFGKickRoutes exposes the ban-driven kick endpoint used by the
// launcher ops tool: after persisting a ban it POSTs the same ban target here
// so every matching online session is disconnected immediately.
func registerDNFGKickRoutes(env *appkit.Env, bridge *dnfbridge.Service) {
env.AdminMux.HandleFunc(gmKickSessionsPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request struct {
BanType string `json:"ban_type"`
BanValue string `json:"ban_value"`
}
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
var banType dnfbridge.KickBanType
switch strings.ToLower(strings.TrimSpace(request.BanType)) {
case string(dnfbridge.KickBanAccount):
banType = dnfbridge.KickBanAccount
case string(dnfbridge.KickBanIP):
banType = dnfbridge.KickBanIP
case string(dnfbridge.KickBanMachine):
banType = dnfbridge.KickBanMachine
default:
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "invalid ban_type"})
return
}
if strings.TrimSpace(request.BanValue) == "" {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "ban_value required"})
return
}
kicked, err := bridge.KickSessions(banType, request.BanValue)
if err != nil {
writeGMError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{
"ok": true,
"message": "kick request completed",
"kicked": kicked,
})
}))
}
func registerDNFGMWalletRoutes(env *appkit.Env, bridge *dnfbridge.Service) {
env.AdminMux.HandleFunc(gmWalletQueryPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request dnfbridge.GMWalletRequest
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
result, err := bridge.GMReadWallet(r.Context(), request)
if err != nil {
writeGMError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "wallet loaded", "data": result})
}))
env.AdminMux.HandleFunc(gmWalletUpdatePath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request dnfbridge.GMWalletRequest
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
result, err := bridge.GMUpdateWallet(r.Context(), request)
if err != nil {
writeGMError(w, err)
return
}
message := "wallet committed"
if result.Refreshed {
message = "wallet committed and online client refreshed"
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": message, "data": result})
}))
env.AdminMux.HandleFunc(gmWalletRepairHappyPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request dnfbridge.GMWalletRequest
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
result, err := bridge.GMRepairHappyTokenCeraVoucher(r.Context(), request)
if err != nil {
writeGMError(w, err)
return
}
message := "happy-token Cera voucher repair completed"
if result.Refreshed {
message = "happy-token Cera voucher repaired and online client refreshed"
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": message, "data": result})
}))
}
func registerDNFGMCharacterRoutes(env *appkit.Env, bridge *dnfbridge.Service) {
env.AdminMux.HandleFunc(gmCharacterSkillPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
result, err := bridge.GMReadCharacterSkill(r.Context(), strings.TrimSpace(r.URL.Query().Get("id")))
if err != nil {
writeGMError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": result})
case http.MethodPost:
var request dnfbridge.GMCharacterSkillUpdate
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
result, err := bridge.GMUpdateCharacterSkill(r.Context(), request)
if err != nil {
writeGMError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": gmNativeStateMessage("skill points committed", result.GMNativeRefreshResult), "data": result})
default:
gmMethodNotAllowed(w, "GET, POST")
}
}))
env.AdminMux.HandleFunc(gmCharacterLevelPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request struct {
CharacterID string `json:"character_id"`
Level int `json:"level"`
}
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
result, err := bridge.GMSetCharacterLevel(r.Context(), request.CharacterID, request.Level)
if err != nil {
writeGMError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": gmNativeStateMessage("character level committed", result.GMNativeRefreshResult), "data": result})
}))
env.AdminMux.HandleFunc(gmCharacterJobPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
result, err := bridge.GMReadCharacterJob(r.Context(), strings.TrimSpace(r.URL.Query().Get("id")))
if err != nil {
writeGMError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": result})
case http.MethodPost:
var request dnfbridge.GMCharacterJobUpdate
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
result, err := bridge.GMSetCharacterJob(r.Context(), request)
if err != nil {
writeGMError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": gmNativeStateMessage("character profession committed", result.GMNativeRefreshResult), "data": result})
default:
gmMethodNotAllowed(w, "GET, POST")
}
}))
env.AdminMux.HandleFunc(gmCharacterBlackDiamondPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// 只读查询:?id=<character_id> 或 ?name=<角色名>,不修改任何数据。
result, err := bridge.GMBlackDiamondStatus(r.Context(), dnfbridge.GMBlackDiamondQuery{
CharacterID: strings.TrimSpace(r.URL.Query().Get("id")),
CharacterName: strings.TrimSpace(r.URL.Query().Get("name")),
})
if err != nil {
writeGMError(w, err)
return
}
message := "Black Diamond expired"
switch {
case result.Active && result.Online:
message = "Black Diamond active (character online)"
case result.Active:
message = "Black Diamond active"
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": message, "data": result})
case http.MethodPost:
var request dnfbridge.GMBlackDiamondRequest
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
result, err := bridge.GMActivateBlackDiamond(r.Context(), request)
if err != nil {
writeGMError(w, err)
return
}
message := "Black Diamond entitlement committed; character is offline and will load it on next login"
switch {
case result.Deferred:
message = "Black Diamond entitlement committed; online refresh is deferred until town"
case result.Refreshed:
message = "Black Diamond entitlement committed and native online client packets sent"
case result.Online:
message = "Black Diamond entitlement committed; native online client packet refresh failed"
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": message, "data": result})
default:
gmMethodNotAllowed(w, "GET, POST")
}
}))
env.AdminMux.HandleFunc(gmDungeonsUnlockAllPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request dnfbridge.GMDungeonUnlockRequest
if !decodeGMRequest(w, r, &request, 128<<10) {
return
}
result, err := bridge.GMUnlockDungeons(r.Context(), request)
if err != nil {
writeGMError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": gmNativeStateMessage("dungeon permissions committed", result.GMNativeRefreshResult), "data": result})
}))
env.AdminMux.HandleFunc(gmEquipSlotsUnlockAllPath, env.AdminAuth("dnf-gm", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request struct {
CharacterID string `json:"character_id"`
}
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
result, err := bridge.GMUnlockEquipSlots(r.Context(), request.CharacterID)
if err != nil {
writeGMError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": gmNativeStateMessage("equipment slots committed", result.GMNativeRefreshResult), "data": result})
}))
}
func gmNativeStateMessage(committed string, result dnfbridge.GMNativeRefreshResult) string {
if result.Refreshed {
return committed + " and native online client packets sent"
}
if result.Online {
return committed + "; native online client packet refresh failed"
}
return committed + "; character is offline and will load it on next login"
}
func decodeGMRequest(w http.ResponseWriter, r *http.Request, target any, maxBytes int64) bool {
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBytes))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "invalid request: " + err.Error()})
return false
}
if err := rejectTrailingGMJSON(decoder); err != nil {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "invalid request: " + err.Error()})
return false
}
return true
}
func gmMethodNotAllowed(w http.ResponseWriter, allow string) {
w.Header().Set("Allow", allow)
writeGMJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method not allowed"})
}
func rejectTrailingGMJSON(decoder *json.Decoder) error {
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("multiple JSON values")
}
return err
}
return nil
}
func writeGMError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
switch {
case errors.Is(err, dnfbridge.ErrGMRequestInvalid):
status = http.StatusBadRequest
case errors.Is(err, dnfbridge.ErrGMCharacterNotFound):
status = http.StatusNotFound
case errors.Is(err, dnfbridge.ErrGMRepositoryUnavailable):
status = http.StatusServiceUnavailable
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
status = http.StatusGatewayTimeout
}
writeGMJSON(w, status, map[string]any{"ok": false, "error": err.Error()})
}
func writeGMJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+449
View File
@@ -0,0 +1,449 @@
package dnf90
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
dnfrobotdomain "longheng.io/server/internal/modules/dnf/robot"
appkit "longheng.io/server/internal/platform/app"
"longheng.io/server/internal/services/dnfbridge"
"longheng.io/server/internal/services/dnfrobot"
)
const (
gmRobotsPath = "/local/dnf-gm/robots"
gmRobotStatusPath = "/local/dnf-gm/robots/status"
gmRobotBatchPath = "/local/dnf-gm/robots/batch"
gmRobotOperationsPath = "/local/dnf-gm/robots/operations"
gmRobotProfilePath = "/local/dnf-gm/robots/profile"
gmRobotProvisionPath = "/local/dnf-gm/robots/provision"
gmRobotProvisionBatchPath = "/local/dnf-gm/robots/provision/batch"
gmRobotStockPath = "/local/dnf-gm/robots/stock"
gmRobotAutomationPath = "/local/dnf-gm/robots/automation"
gmRobotStartPath = "/local/dnf-gm/robots/start"
gmRobotStopPath = "/local/dnf-gm/robots/stop"
gmRobotMovePath = "/local/dnf-gm/robots/move"
gmRobotChatPath = "/local/dnf-gm/robots/chat"
gmRobotStoreOpenPath = "/local/dnf-gm/robots/store/open"
gmRobotStoreClosePath = "/local/dnf-gm/robots/store/close"
)
const gmRobotMaximumProvisionBatch = 64
type gmRobotProvisionBatchRequest struct {
Count int `json:"count"`
StartIndex int `json:"start_index"`
AccountPrefix string `json:"account_prefix"`
NamePrefix string `json:"name_prefix"`
ProfilePrefix string `json:"profile_prefix"`
ChannelID int `json:"channel_id"`
AutoStart bool `json:"auto_start"`
Job int `json:"job"`
GrowType int `json:"grow_type"`
Level int `json:"level"`
Village int `json:"village"`
Area int `json:"area"`
X int `json:"x"`
Y int `json:"y"`
}
type gmRobotProvisionBatchRow struct {
Index int `json:"index"`
ProfileID string `json:"profile_id"`
AccountID string `json:"account_id"`
Name string `json:"name"`
CharacterID uint16 `json:"character_id,omitempty"`
Configured bool `json:"configured"`
Running bool `json:"running"`
Error string `json:"error,omitempty"`
}
type gmRobotProvisionBatchResult struct {
Requested int `json:"requested"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Rows []gmRobotProvisionBatchRow `json:"rows"`
}
func registerDNFRobotGMRoutes(env *appkit.Env, bridge *dnfbridge.Service, manager *dnfrobot.Manager) {
if env == nil || env.AdminMux == nil || env.AdminAuth == nil || bridge == nil || manager == nil {
return
}
auth := func(handler http.HandlerFunc) http.HandlerFunc { return env.AdminAuth("dnf-gm", handler) }
env.AdminMux.HandleFunc(gmRobotsPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
gmMethodNotAllowed(w, http.MethodGet)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": manager.List()})
}))
env.AdminMux.HandleFunc(gmRobotStatusPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
gmMethodNotAllowed(w, http.MethodGet)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": manager.Status()})
}))
env.AdminMux.HandleFunc(gmRobotBatchPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request dnfrobot.BatchRequest
if !decodeGMRequest(w, r, &request, 64<<10) {
return
}
result, err := manager.Batch(r.Context(), request)
if err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot batch action completed", "data": result})
}))
env.AdminMux.HandleFunc(gmRobotOperationsPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
gmMethodNotAllowed(w, http.MethodGet)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": manager.Operations()})
}))
env.AdminMux.HandleFunc(gmRobotProvisionPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request dnfbridge.GMRobotCharacterProvisionRequest
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
result, err := bridge.GMProvisionRobotCharacter(r.Context(), request)
if err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot character provisioned", "data": result})
}))
env.AdminMux.HandleFunc(gmRobotProvisionBatchPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request gmRobotProvisionBatchRequest
if !decodeGMRequest(w, r, &request, 32<<10) {
return
}
request, err := normalizeGMRobotProvisionBatchRequest(request)
if err != nil {
writeGMRobotError(w, err)
return
}
operation := manager.BeginOperation("provision", fmt.Sprintf("%s count=%d start=%d", request.ProfilePrefix, request.Count, request.StartIndex))
result := provisionGMRobotBatch(r.Context(), bridge, manager, request)
manager.CompleteOperation(operation.ID, fmt.Sprintf("requested=%d succeeded=%d failed=%d", result.Requested, result.Succeeded, result.Failed), nil)
writeGMJSON(w, http.StatusOK, map[string]any{
"ok": true,
"message": fmt.Sprintf("robot batch provision completed: %d succeeded, %d failed", result.Succeeded, result.Failed),
"data": result,
})
}))
env.AdminMux.HandleFunc(gmRobotStockPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request struct {
ID string `json:"id"`
Items []dnfbridge.GMRobotStockItem `json:"items"`
}
if !decodeGMRequest(w, r, &request, 32<<10) {
return
}
snapshot, err := manager.Snapshot(request.ID)
if err != nil {
writeGMRobotError(w, err)
return
}
operation := manager.BeginOperation("stock", snapshot.Profile.ID)
result, err := bridge.GMStockRobotCharacter(r.Context(), dnfbridge.GMRobotStockRequest{
AccountID: snapshot.Profile.AccountID, CharacterID: snapshot.Profile.CharacterID, Items: request.Items,
})
if err != nil {
manager.CompleteOperation(operation.ID, "", err)
writeGMRobotError(w, err)
return
}
manager.CompleteOperation(operation.ID, fmt.Sprintf("items=%d slots=%d", len(result.Items), len(result.Slots)), nil)
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot private-store stock committed", "data": result})
}))
env.AdminMux.HandleFunc(gmRobotAutomationPath, auth(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "data": manager.Automation()})
case http.MethodPost:
var policy dnfrobotdomain.AutomationPolicy
if !decodeGMRequest(w, r, &policy, 64<<10) {
return
}
result, err := manager.UpdateAutomation(r.Context(), policy)
if err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot automation saved", "data": result})
default:
gmMethodNotAllowed(w, "GET, POST")
}
}))
env.AdminMux.HandleFunc(gmRobotProfilePath, auth(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
var profile dnfrobotdomain.Profile
if !decodeGMRequest(w, r, &profile, 16<<10) {
return
}
result, err := manager.Upsert(r.Context(), profile)
if err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot profile saved", "data": result})
case http.MethodDelete:
if err := manager.Delete(r.Context(), strings.TrimSpace(r.URL.Query().Get("id"))); err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot profile deleted"})
default:
gmMethodNotAllowed(w, "POST, DELETE")
}
}))
env.AdminMux.HandleFunc(gmRobotStartPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
id, ok := decodeRobotID(w, r)
if !ok {
return
}
result, err := manager.StartProfile(id)
if err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot start requested", "data": result})
}))
env.AdminMux.HandleFunc(gmRobotStopPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
id, ok := decodeRobotID(w, r)
if !ok {
return
}
result, err := manager.StopProfile(id)
if err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot stopped", "data": result})
}))
env.AdminMux.HandleFunc(gmRobotMovePath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request struct {
ID string `json:"id"`
Village uint8 `json:"village"`
Area uint8 `json:"area"`
X uint16 `json:"x"`
Y uint16 `json:"y"`
MoveType uint8 `json:"move_type"`
}
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
if err := manager.Move(request.ID, request.Village, request.Area, request.X, request.Y, request.MoveType); err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot move packet sent"})
}))
env.AdminMux.HandleFunc(gmRobotChatPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request struct {
ID string `json:"id"`
Mode uint8 `json:"mode"`
Message string `json:"message"`
}
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
if err := manager.Chat(request.ID, request.Mode, request.Message); err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot chat packet sent"})
}))
env.AdminMux.HandleFunc(gmRobotStoreOpenPath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
var request struct {
ID string `json:"id"`
Title string `json:"title"`
Maximum int `json:"maximum_rows"`
UnitPrice uint32 `json:"unit_price"`
}
if !decodeGMRequest(w, r, &request, 16<<10) {
return
}
if err := manager.OpenPrivateStore(r.Context(), request.ID, request.Title, request.Maximum, request.UnitPrice); err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot private store opening requested"})
}))
env.AdminMux.HandleFunc(gmRobotStoreClosePath, auth(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
gmMethodNotAllowed(w, http.MethodPost)
return
}
id, ok := decodeRobotID(w, r)
if !ok {
return
}
if err := manager.ClosePrivateStore(id); err != nil {
writeGMRobotError(w, err)
return
}
writeGMJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "robot private store close requested"})
}))
}
func normalizeGMRobotProvisionBatchRequest(request gmRobotProvisionBatchRequest) (gmRobotProvisionBatchRequest, error) {
request.AccountPrefix = strings.TrimSpace(request.AccountPrefix)
request.NamePrefix = strings.TrimSpace(request.NamePrefix)
request.ProfilePrefix = strings.TrimSpace(request.ProfilePrefix)
if request.Count < 1 || request.Count > gmRobotMaximumProvisionBatch || request.StartIndex < 0 || request.StartIndex > 999999 ||
request.AccountPrefix == "" || request.NamePrefix == "" || request.ProfilePrefix == "" || request.ChannelID < 1 || request.ChannelID > 65535 {
return gmRobotProvisionBatchRequest{}, fmt.Errorf("%w: invalid robot provision batch", dnfbridge.ErrGMRequestInvalid)
}
if request.StartIndex == 0 {
request.StartIndex = 1
}
last := request.StartIndex + request.Count - 1
if last > 999999 {
return gmRobotProvisionBatchRequest{}, fmt.Errorf("%w: robot provision batch index exceeds 999999", dnfbridge.ErrGMRequestInvalid)
}
accountID := fmt.Sprintf("%s-%03d", request.AccountPrefix, last)
profileID := fmt.Sprintf("%s-%03d", request.ProfilePrefix, last)
if len(accountID) > 96 || len(profileID) > 64 || strings.IndexByte(accountID, 0) >= 0 || strings.IndexByte(profileID, 0) >= 0 {
return gmRobotProvisionBatchRequest{}, fmt.Errorf("%w: generated robot account/profile id is too long", dnfbridge.ErrGMRequestInvalid)
}
return request, nil
}
func provisionGMRobotBatch(ctx context.Context, bridge *dnfbridge.Service, manager *dnfrobot.Manager, request gmRobotProvisionBatchRequest) gmRobotProvisionBatchResult {
result := gmRobotProvisionBatchResult{Requested: request.Count, Rows: make([]gmRobotProvisionBatchRow, 0, request.Count)}
for offset := 0; offset < request.Count; offset++ {
index := request.StartIndex + offset
row := gmRobotProvisionBatchRow{
Index: index,
ProfileID: fmt.Sprintf("%s-%03d", request.ProfilePrefix, index),
AccountID: fmt.Sprintf("%s-%03d", request.AccountPrefix, index),
Name: fmt.Sprintf("%s%d", request.NamePrefix, index),
}
if ctx != nil && ctx.Err() != nil {
row.Error = ctx.Err().Error()
result.Failed++
result.Rows = append(result.Rows, row)
continue
}
provisioned, err := bridge.GMProvisionRobotCharacter(ctx, dnfbridge.GMRobotCharacterProvisionRequest{
AccountID: row.AccountID,
Name: row.Name,
Job: request.Job,
GrowType: request.GrowType,
Level: request.Level,
Village: request.Village,
Area: request.Area,
X: request.X,
Y: request.Y,
})
if err != nil {
row.Error = err.Error()
result.Failed++
result.Rows = append(result.Rows, row)
continue
}
row.CharacterID = provisioned.CharacterID
runtime, err := manager.Upsert(ctx, dnfrobotdomain.Profile{
ID: row.ProfileID,
AccountID: row.AccountID,
CharacterID: provisioned.CharacterID,
ChannelID: request.ChannelID,
AutoStart: request.AutoStart,
Village: uint8(request.Village),
Area: uint8(request.Area),
X: uint16(request.X),
Y: uint16(request.Y),
})
if err != nil {
row.Error = fmt.Sprintf("character created but profile save failed: %v", err)
result.Failed++
result.Rows = append(result.Rows, row)
continue
}
row.Configured = true
row.Running = runtime.Running
result.Succeeded++
result.Rows = append(result.Rows, row)
}
return result
}
func decodeRobotID(w http.ResponseWriter, r *http.Request) (string, bool) {
var request struct {
ID string `json:"id"`
}
if !decodeGMRequest(w, r, &request, 4096) {
return "", false
}
request.ID = strings.TrimSpace(request.ID)
if request.ID == "" {
writeGMJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "robot profile id is required"})
return "", false
}
return request.ID, true
}
func writeGMRobotError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
switch {
case errors.Is(err, dnfbridge.ErrGMRequestInvalid):
status = http.StatusBadRequest
case errors.Is(err, dnfbridge.ErrGMRepositoryUnavailable):
status = http.StatusServiceUnavailable
case errors.Is(err, dnfrobotdomain.ErrInvalid):
status = http.StatusBadRequest
case errors.Is(err, dnfrobot.ErrProfileNotFound):
status = http.StatusNotFound
case errors.Is(err, dnfrobotdomain.ErrUnavailable), errors.Is(err, dnfrobot.ErrManagerStopped):
status = http.StatusServiceUnavailable
case errors.Is(err, dnfrobot.ErrNotReady):
status = http.StatusConflict
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
status = http.StatusGatewayTimeout
}
writeGMJSON(w, status, map[string]any{"ok": false, "error": err.Error()})
}
@@ -0,0 +1,20 @@
package dnf90
import "testing"
func TestNormalizeGMRobotProvisionBatchRequest(t *testing.T) {
request, err := normalizeGMRobotProvisionBatchRequest(gmRobotProvisionBatchRequest{
Count: 3, AccountPrefix: " robot-account ", NamePrefix: "假人", ProfilePrefix: " town ", ChannelID: 19,
})
if err != nil {
t.Fatalf("normalize provision batch: %v", err)
}
if request.StartIndex != 1 || request.AccountPrefix != "robot-account" || request.ProfilePrefix != "town" {
t.Fatalf("normalized request=%+v", request)
}
if _, err := normalizeGMRobotProvisionBatchRequest(gmRobotProvisionBatchRequest{
Count: gmRobotMaximumProvisionBatch + 1, AccountPrefix: "a", NamePrefix: "n", ProfilePrefix: "p", ChannelID: 19,
}); err == nil {
t.Fatal("oversized provision batch unexpectedly accepted")
}
}
+872
View File
@@ -0,0 +1,872 @@
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()})
}
@@ -0,0 +1,170 @@
package dnf90
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
dnfquest "longheng.io/server/internal/modules/dnf/quest"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
appkit "longheng.io/server/internal/platform/app"
"longheng.io/server/internal/services/dnfbridge"
)
func TestGMAdminRoutesReadAndWrite(t *testing.T) {
t.Parallel()
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "dnf:test", State: "active"}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "7",
AccountID: "dnf:test",
Name: "GM Route",
Job: "0",
Level: 1,
Stats: map[string]int64{"gold": 10, "exp": 0},
}); err != nil {
t.Fatalf("save character: %v", err)
}
service := dnfbridge.NewWithOptions(&appkit.Env{}, dnfbridge.ServiceOptions{
RepositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
})
env := &appkit.Env{AdminMux: http.NewServeMux()}
registerGMAdminRoutes(env, service)
listRecorder := httptest.NewRecorder()
env.AdminMux.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, "/dnf/gm/accounts/dnf:test/characters", nil))
if listRecorder.Code != http.StatusOK {
t.Fatalf("list status = %d body=%s", listRecorder.Code, listRecorder.Body.String())
}
var listed struct {
Success bool `json:"success"`
Data []dnfbridge.GMCharacterSummary `json:"data"`
}
if err := json.Unmarshal(listRecorder.Body.Bytes(), &listed); err != nil || !listed.Success || len(listed.Data) != 1 {
t.Fatalf("list response = %+v, %v", listed, err)
}
writeRecorder := httptest.NewRecorder()
env.AdminMux.ServeHTTP(writeRecorder, httptest.NewRequest(
http.MethodPost,
"/dnf/gm/characters/7/gold",
strings.NewReader(`{"value":888}`),
))
if writeRecorder.Code != http.StatusOK {
t.Fatalf("write status = %d body=%s", writeRecorder.Code, writeRecorder.Body.String())
}
character, found, err := repositories.Character.Load(ctx, "7")
if err != nil || !found || character.Stats["gold"] != 888 {
t.Fatalf("written character = %+v, found=%v err=%v", character, found, err)
}
}
func TestGMAdminMailboxListAndForcedDelete(t *testing.T) {
t.Parallel()
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "7", AccountID: "dnf:test", Name: "Mailbox GM", Job: "0", Level: 1,
}); err != nil {
t.Fatalf("save character: %v", err)
}
if err := repositories.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
CharacterID: "7",
Mails: map[string]dnfrepo.MailRecord{
"12": {
MailID: "12", SenderName: "GM", Title: "未领取附件",
Attachments: []dnfrepo.MailAttachment{{ItemID: 5001, Count: 1}},
},
},
}); err != nil {
t.Fatalf("save mailbox: %v", err)
}
service := dnfbridge.NewWithOptions(&appkit.Env{}, dnfbridge.ServiceOptions{
RepositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
})
env := &appkit.Env{AdminMux: http.NewServeMux()}
registerGMAdminRoutes(env, service)
listRecorder := httptest.NewRecorder()
env.AdminMux.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, "/dnf/gm/characters/7/mailbox", nil))
if listRecorder.Code != http.StatusOK || !strings.Contains(listRecorder.Body.String(), `"mail_id":"12"`) {
t.Fatalf("mailbox list status=%d body=%s", listRecorder.Code, listRecorder.Body.String())
}
rejectRecorder := httptest.NewRecorder()
env.AdminMux.ServeHTTP(rejectRecorder, httptest.NewRequest(
http.MethodPost, "/dnf/gm/characters/7/mailbox/delete", strings.NewReader(`{"mail_ids":["12"],"force":false}`),
))
if rejectRecorder.Code != http.StatusBadRequest {
t.Fatalf("non-forced delete status=%d body=%s", rejectRecorder.Code, rejectRecorder.Body.String())
}
deleteRecorder := httptest.NewRecorder()
env.AdminMux.ServeHTTP(deleteRecorder, httptest.NewRequest(
http.MethodPost, "/dnf/gm/characters/7/mailbox/delete", strings.NewReader(`{"mail_ids":["12"],"force":true}`),
))
if deleteRecorder.Code != http.StatusOK || !strings.Contains(deleteRecorder.Body.String(), `"deleted":1`) {
t.Fatalf("forced delete status=%d body=%s", deleteRecorder.Code, deleteRecorder.Body.String())
}
mailbox, found, err := repositories.Mailbox.Load(ctx, "7")
if err != nil || !found {
t.Fatalf("load mailbox found=%v err=%v", found, err)
}
mail := mailbox.Mails["12"]
if !mail.Deleted || len(mail.Attachments) != 0 || mail.Gold != 0 {
t.Fatalf("deleted mail retained assets: %+v", mail)
}
}
func TestGMAdminUnlocksExtraEquipmentSlots(t *testing.T) {
t.Parallel()
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "9", AccountID: "dnf:test", Name: "Slot GM", Job: "0", Level: 90,
Stats: map[string]int64{"ex_equip_slot_stat": 0},
}); err != nil {
t.Fatalf("save character: %v", err)
}
service := dnfbridge.NewWithOptions(&appkit.Env{}, dnfbridge.ServiceOptions{
RepositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
})
env := &appkit.Env{AdminMux: http.NewServeMux()}
registerGMAdminRoutes(env, service)
request := httptest.NewRequest(
http.MethodPost,
"/dnf/gm/characters/9/equipment-slots/unlock",
strings.NewReader(`{"slots":["support","magic_stone","earring"]}`),
)
recorder := httptest.NewRecorder()
env.AdminMux.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("unlock status=%d body=%s", recorder.Code, recorder.Body.String())
}
var response struct {
Success bool `json:"success"`
Data dnfbridge.GMExtraEquipmentSlotState `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if !response.Success || response.Data.Mask != dnfquest.ExEquipSlotAll ||
!response.Data.SupportUnlocked || !response.Data.MagicStoneUnlocked || !response.Data.EarringUnlocked {
t.Fatalf("unlock response=%+v", response)
}
character, found, err := repositories.Character.Load(ctx, "9")
if err != nil || !found {
t.Fatalf("load character found=%v err=%v", found, err)
}
if got := character.Stats["ex_equip_slot_stat"]; got != int64(dnfquest.ExEquipSlotAll) {
t.Fatalf("ex_equip_slot_stat=%#x want=%#x", got, dnfquest.ExEquipSlotAll)
}
}
+472
View File
@@ -0,0 +1,472 @@
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
}