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

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

466 lines
14 KiB
Go

package app
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"time"
"longheng.io/server/internal/platform/admincmd"
"longheng.io/server/internal/platform/admincmdqueue"
"longheng.io/server/internal/platform/config"
"longheng.io/server/internal/platform/datatable"
"longheng.io/server/internal/platform/eventlog"
"longheng.io/server/internal/platform/hotupdate"
"longheng.io/server/internal/platform/httpx"
"longheng.io/server/internal/platform/nativepatch"
"longheng.io/server/internal/platform/statesync"
)
const maxUpdateJSONBytes = 1 << 20
func newStateSyncStore(cfg config.ServiceConfig) (statesync.Store, error) {
if !cfg.StateSync.Enabled {
return nil, nil
}
return statesync.NewMemory(statesync.Options{
MaxPayloadBytes: cfg.StateSync.MaxPayloadBytes,
}), nil
}
func newHotUpdateManager(cfg config.ServiceConfig, dataTables *datatable.Registry, dataTableViews *datatable.ViewManager, eventLog *eventlog.Log, logger *slog.Logger) (*hotupdate.Manager, error) {
if !cfg.HotUpdate.Enabled {
return nil, nil
}
var control hotupdate.Control
switch cfg.HotUpdate.ControlKind {
case "", "memory":
control = hotupdate.NewMemoryControl()
case "eventlog":
if eventLog == nil {
return nil, hotupdate.ErrControlLogRequired
}
var err error
control, err = hotupdate.NewEventLogControl(hotupdate.EventLogControlOptions{Log: eventLog})
if err != nil {
return nil, err
}
default:
return nil, errors.New("unsupported hot update control kind")
}
applier := hotupdate.Applier(hotupdate.DataTableApplier{Registry: dataTables, Views: dataTableViews})
if cfg.HotUpdate.ApplierKind == "native_patch" {
applier = hotupdate.NativePatchApplier{Policy: nativepatch.Policy{
AllowedTargets: cfg.HotUpdate.NativePatchAllowedTargets,
AllowedOldSymbols: cfg.HotUpdate.PatchOldSymbols,
RequireBuildID: cfg.HotUpdate.NativePatchRequireBuildID,
RequireRequestedBy: cfg.HotUpdate.PatchRequireActor,
RequireReason: cfg.HotUpdate.NativePatchRequireReason,
MinReasonLength: cfg.HotUpdate.NativePatchMinReasonLength,
MaxSymbols: cfg.HotUpdate.NativePatchMaxSymbols,
MaxLiveDuration: time.Duration(cfg.HotUpdate.NativePatchMaxLiveSeconds) * time.Second,
}}
}
return hotupdate.NewManager(hotupdate.ManagerOptions{
Target: cfg.HotUpdate.Target,
NodeID: cfg.Service.NodeID,
Workspace: cfg.HotUpdate.Workspace,
ApplyTimeout: time.Duration(cfg.HotUpdate.ApplyTimeoutSeconds) * time.Second,
Control: control,
Source: hotupdate.LocalSource{VerifyOptions: hotupdate.BundleVerifyOptions{
SigningKey: hotUpdateSigningKey(cfg),
RequireSignature: cfg.HotUpdate.RequireSignature,
}},
Applier: applier,
Logger: logger,
})
}
func hotUpdateSigningKey(cfg config.ServiceConfig) []byte {
keyEnv := strings.TrimSpace(cfg.HotUpdate.SigningKeyEnv)
if keyEnv == "" {
return nil
}
key := strings.TrimSpace(os.Getenv(keyEnv))
if key == "" {
return nil
}
return []byte(key)
}
func closeHotCtl(manager *hotupdate.Manager) {
if manager == nil || manager.Control() == nil {
return
}
_ = manager.Control().Close()
}
func newStateSyncSweeper(store statesync.Store, interval time.Duration, logger *slog.Logger) Component {
if store == nil || interval <= 0 {
return nil
}
return &stateSyncSweeper{
store: store,
interval: interval,
logger: logger,
}
}
type stateSyncSweeper struct {
store statesync.Store
interval time.Duration
logger *slog.Logger
mu sync.Mutex
cancel context.CancelFunc
done chan struct{}
stopping bool
}
// Name 返回状态同步清理组件名。
func (s *stateSyncSweeper) Name() string {
return "state-sync-sweeper"
}
// Start 启动状态同步过期数据清理循环。
func (s *stateSyncSweeper) Start(ctx context.Context) error {
if s == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
if s.done != nil || s.stopping {
s.mu.Unlock()
return nil
}
runCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
s.cancel = cancel
s.done = done
s.mu.Unlock()
go func() {
defer func() {
s.clearStopped(done)
close(done)
}()
sweepStateSync(runCtx, s.store, s.interval, s.logger)
}()
return nil
}
// Stop 停止状态同步过期数据清理循环。
func (s *stateSyncSweeper) Stop(ctx context.Context) error {
if s == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
cancel := s.cancel
done := s.done
shouldSignal := cancel != nil
if shouldSignal {
s.cancel = nil
s.stopping = true
}
s.mu.Unlock()
if shouldSignal {
cancel()
}
if done == nil {
return nil
}
select {
case <-done:
s.clearStopped(done)
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *stateSyncSweeper) clearStopped(done chan struct{}) {
s.mu.Lock()
defer s.mu.Unlock()
if s.done != done {
return
}
s.cancel = nil
s.done = nil
s.stopping = false
}
func regUpdateStateRoutes(mux *http.ServeMux, wrapDangerous func(string, string, http.HandlerFunc) http.HandlerFunc, adminCommands *admincmdqueue.Executor, hotUpdateManager *hotupdate.Manager, stateSyncStore statesync.Store, stateSyncDefaultTTL time.Duration) {
if stateSyncDefaultTTL <= 0 {
stateSyncDefaultTTL = time.Minute
}
hotUpdateScope := hotUpdateAdminScope(hotUpdateManager)
mux.HandleFunc("/debug/hotupdate", wrapDangerous(hotUpdateScope, httpx.AdminOperationID(http.MethodPost, "/debug/hotupdate"), func(w http.ResponseWriter, r *http.Request) {
if hotUpdateManager == nil {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"enabled": false})
return
}
switch r.Method {
case http.MethodGet:
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"enabled": true,
"snapshot": hotUpdateManager.Snapshot(),
"control": hotUpdateManager.Control().Snapshot(),
})
case http.MethodPost:
var intent hotupdate.Intent
if err := httpx.DecodeStrictJSON(w, r, maxUpdateJSONBytes, &intent); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
target := hotUpdateManager.Snapshot().Target
receipt, duplicate, submitted, err := submitHotUpdateCmd(r, adminCommands, hotUpdateScope, target, intent)
if writeUpdateCmdError(w, err) {
return
}
if duplicate {
writeAdminDuplicate(w, receipt)
return
}
if err := hotUpdateManager.Control().Publish(r.Context(), target, intent); err != nil {
if submitted {
markUpdateCmdFailed(r.Context(), adminCommands, receipt, err)
}
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if submitted {
receipt = markUpdateOK(r.Context(), adminCommands, receipt)
}
setAdminReceipt(w, receipt)
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{
"accepted": true,
"target": target,
"intent": intent,
"admin_receipt": receipt,
})
default:
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
}))
mux.HandleFunc("/debug/statesync", wrapDangerous("platform", httpx.AdminOperationID(http.MethodPost, "/debug/statesync"), func(w http.ResponseWriter, r *http.Request) {
if stateSyncStore == nil {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"enabled": false})
return
}
switch r.Method {
case http.MethodGet:
scope := r.URL.Query().Get("scope")
if scope == "" {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"enabled": true, "snapshot": stateSyncStore.Snapshot()})
return
}
records, err := stateSyncStore.List(r.Context(), scope)
if err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"enabled": true, "scope": scope, "records": records})
case http.MethodPost:
var request stateSyncAdminReq
if err := httpx.DecodeStrictJSON(w, r, maxUpdateJSONBytes, &request); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
record := request.Record
expiresAtProvided := !record.ExpiresAt.IsZero()
if record.ExpiresAt.IsZero() {
record.ExpiresAt = time.Now().UTC().Add(stateSyncDefaultTTL)
}
receipt, duplicate, submitted, err := submitStateSyncCmd(r, adminCommands, record, request.Reason, expiresAtProvided)
if writeUpdateCmdError(w, err) {
return
}
if duplicate {
writeAdminDuplicate(w, receipt)
return
}
stored, err := stateSyncStore.Upsert(r.Context(), record)
if err != nil {
if submitted {
markUpdateCmdFailed(r.Context(), adminCommands, receipt, err)
}
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if submitted {
receipt = markUpdateOK(r.Context(), adminCommands, receipt)
}
setAdminReceipt(w, receipt)
httpx.WriteJSON(w, http.StatusOK, map[string]any{"record": stored, "admin_receipt": receipt})
default:
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
}))
}
func hotUpdateAdminScope(manager *hotupdate.Manager) string {
if manager != nil && manager.NativePatchEnabled() {
return "platform.nativepatch"
}
return "platform.hotupdate"
}
type stateSyncAdminReq struct {
statesync.Record
Reason string `json:"reason,omitempty"`
}
func submitHotUpdateCmd(r *http.Request, commands *admincmdqueue.Executor, scope, target string, intent hotupdate.Intent) (admincmd.Receipt, bool, bool, error) {
if commands == nil {
return admincmd.Receipt{}, false, false, nil
}
action := strings.TrimSpace(string(intent.Action))
if action == "" {
action = string(hotupdate.ActionApply)
}
operation := "platform.hotupdate." + action
result, err := commands.Submit(r.Context(), admincmd.Command{
Operation: operation,
Scope: scope,
Target: strings.TrimSpace(target),
Actor: hotUpdateAdminActor(r),
Reason: strings.TrimSpace(intent.Reason),
IdempotencyKey: strings.TrimSpace(r.Header.Get(httpx.AdminIdempotencyHeader)),
Confirmation: strings.TrimSpace(r.Header.Get(httpx.AdminConfirmHeader)),
Params: map[string]any{
"action": action,
"version": intent.Version,
"source_uri": intent.SourceURI,
"checksum": intent.Checksum,
"available_at": intent.AvailableAt,
"requested_by": intent.RequestedBy,
"sequence": intent.Sequence,
},
}, admincmd.DangerousPolicy())
if err != nil {
return admincmd.Receipt{}, false, true, err
}
return result.Receipt, result.Duplicate, true, nil
}
func hotUpdateAdminActor(r *http.Request) string {
actor := httpx.AuthenticatedAdminActor(r)
if actor == "" {
actor = "admin"
}
return actor
}
func submitStateSyncCmd(r *http.Request, commands *admincmdqueue.Executor, record statesync.Record, reason string, expiresAtProvided bool) (admincmd.Receipt, bool, bool, error) {
if commands == nil {
return admincmd.Receipt{}, false, false, nil
}
target := strings.TrimSpace(record.Scope)
if key := strings.TrimSpace(record.Key); key != "" {
target += "/" + key
}
params := map[string]any{
"scope": record.Scope,
"key": record.Key,
"version": record.Version,
"owner_node": record.OwnerNode,
"payload": record.Payload,
}
if expiresAtProvided {
params["expires_at"] = record.ExpiresAt
}
result, err := commands.Submit(r.Context(), admincmd.Command{
Operation: "platform.statesync.upsert",
Scope: "platform",
Target: target,
Actor: hotUpdateAdminActor(r),
Reason: strings.TrimSpace(reason),
IdempotencyKey: strings.TrimSpace(r.Header.Get(httpx.AdminIdempotencyHeader)),
Confirmation: strings.TrimSpace(r.Header.Get(httpx.AdminConfirmHeader)),
Params: params,
}, admincmd.DangerousPolicy())
if err != nil {
return admincmd.Receipt{}, false, true, err
}
return result.Receipt, result.Duplicate, true, nil
}
func markUpdateOK(ctx context.Context, commands *admincmdqueue.Executor, receipt admincmd.Receipt) admincmd.Receipt {
if commands == nil || receipt.ID == "" {
return receipt
}
updated, err := commands.MarkSucceeded(ctx, receipt.ID)
if err != nil {
slog.Default().Error("mark hotupdate admin command succeeded failed", "receipt_id", receipt.ID, "error", err)
return receipt
}
return updated
}
func markUpdateCmdFailed(ctx context.Context, commands *admincmdqueue.Executor, receipt admincmd.Receipt, err error) {
if commands == nil || receipt.ID == "" || err == nil {
return
}
if _, markErr := commands.MarkFailed(ctx, receipt.ID, err.Error(), time.Now().UTC().Add(time.Minute)); markErr != nil {
slog.Default().Error("mark hotupdate admin command failed failed", "receipt_id", receipt.ID, "error", markErr)
}
}
func writeUpdateCmdError(w http.ResponseWriter, err error) bool {
if err == nil {
return false
}
status := http.StatusBadRequest
if errors.Is(err, admincmdqueue.ErrIdempotencyConflict) {
status = http.StatusConflict
}
if errors.Is(err, admincmd.ErrMissingIdempotencyKey) ||
errors.Is(err, admincmd.ErrMissingConfirmation) ||
errors.Is(err, admincmd.ErrMissingReason) {
status = http.StatusPreconditionRequired
}
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
return true
}
func writeAdminDuplicate(w http.ResponseWriter, receipt admincmd.Receipt) {
setAdminReceipt(w, receipt)
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"status": "duplicate",
"admin_receipt": receipt,
})
}
func setAdminReceipt(w http.ResponseWriter, receipt admincmd.Receipt) {
if w != nil && receipt.ID != "" {
w.Header().Set(httpx.AdminReceiptHeader, receipt.ID)
}
}
func sweepStateSync(ctx context.Context, store statesync.Store, interval time.Duration, logger *slog.Logger) {
if store == nil || interval <= 0 {
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if _, err := store.SweepExpired(ctx); err != nil && logger != nil {
logger.Warn("state sync cleanup failed", "error", err)
}
}
}
}