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