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,481 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/sessioncontract"
|
||||
"longheng.io/server/internal/reference/player"
|
||||
"longheng.io/server/pkg/contracts"
|
||||
)
|
||||
|
||||
const (
|
||||
accountStateLoading = string(sessioncontract.PlayerStateLoading)
|
||||
accountStateOnline = string(sessioncontract.PlayerStateOnline)
|
||||
accountStateValid = string(sessioncontract.PlayerStateValid)
|
||||
accountStateReconn = string(sessioncontract.PlayerStateReconnecting)
|
||||
accountStateClosing = string(sessioncontract.PlayerStateClosing)
|
||||
accountStateKicked = string(sessioncontract.PlayerStateKicked)
|
||||
accountStateInvalid = string(sessioncontract.PlayerStateInvalid)
|
||||
accountStateOffline = "offline"
|
||||
acctProfileActive = "active"
|
||||
)
|
||||
|
||||
// AccountManagerOptions 是账号管理器的空闲回收和停机回收配置。
|
||||
type AccountManagerOptions struct {
|
||||
IdleTTL time.Duration
|
||||
SweepInterval time.Duration
|
||||
ReclaimTimeout time.Duration
|
||||
}
|
||||
|
||||
// AccountSnapshot 是账号运行时生命周期状态快照。
|
||||
type AccountSnapshot struct {
|
||||
AccountID string `json:"account_id"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
State string `json:"state"`
|
||||
Generation int64 `json:"generation"`
|
||||
ConnectedAt time.Time `json:"connected_at,omitempty"`
|
||||
DisconnectedAt time.Time `json:"disconnected_at,omitempty"`
|
||||
LastActiveAt time.Time `json:"last_active_at"`
|
||||
}
|
||||
|
||||
type playerRuntime struct {
|
||||
accountID string
|
||||
sessionID string
|
||||
state string
|
||||
generation int64
|
||||
connectedAt time.Time
|
||||
disconnectedAt time.Time
|
||||
lastActiveAt time.Time
|
||||
}
|
||||
|
||||
// AccountManager 管理账号在线、断线、重连和玩家资料回收。
|
||||
type AccountManager struct {
|
||||
players *player.Module
|
||||
logger *slog.Logger
|
||||
|
||||
idleTTL time.Duration
|
||||
sweepInterval time.Duration
|
||||
reclaimTimeout time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
accounts map[string]*playerRuntime
|
||||
started bool
|
||||
stopping bool
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newAccountManager(players *player.Module, logger *slog.Logger, options AccountManagerOptions) *AccountManager {
|
||||
if options.IdleTTL <= 0 {
|
||||
options.IdleTTL = 10 * time.Minute
|
||||
}
|
||||
if options.SweepInterval <= 0 {
|
||||
options.SweepInterval = options.IdleTTL / 2
|
||||
if options.SweepInterval <= 0 {
|
||||
options.SweepInterval = time.Minute
|
||||
}
|
||||
}
|
||||
if options.ReclaimTimeout <= 0 {
|
||||
options.ReclaimTimeout = 5 * time.Second
|
||||
}
|
||||
return &AccountManager{
|
||||
players: players,
|
||||
logger: logger,
|
||||
idleTTL: options.IdleTTL,
|
||||
sweepInterval: options.SweepInterval,
|
||||
reclaimTimeout: options.ReclaimTimeout,
|
||||
accounts: make(map[string]*playerRuntime),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动账号空闲回收循环。
|
||||
func (m *AccountManager) Start(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.started || m.stopping {
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
m.cancel = cancel
|
||||
m.done = make(chan struct{})
|
||||
m.started = true
|
||||
done := m.done
|
||||
m.mu.Unlock()
|
||||
|
||||
go m.sweep(runCtx, done)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop 停止账号管理器并保存卸载已加载账号。
|
||||
func (m *AccountManager) Stop(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
m.mu.Lock()
|
||||
if !m.started && !m.stopping {
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
cancel := m.cancel
|
||||
done := m.done
|
||||
shouldSignal := m.started
|
||||
if shouldSignal {
|
||||
m.cancel = nil
|
||||
m.started = false
|
||||
m.stopping = true
|
||||
}
|
||||
accountIDs := make([]string, 0, len(m.accounts))
|
||||
for accountID, runtime := range m.accounts {
|
||||
if shouldSignal {
|
||||
runtime.state = accountStateClosing
|
||||
runtime.generation++
|
||||
}
|
||||
accountIDs = append(accountIDs, accountID)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if shouldSignal && cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done != nil {
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
for _, accountID := range accountIDs {
|
||||
if _, _, err := m.players.SaveAndUnload(ctx, accountID, "account_manager_stop"); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.accounts = make(map[string]*playerRuntime)
|
||||
m.cancel = nil
|
||||
m.done = nil
|
||||
m.stopping = false
|
||||
m.mu.Unlock()
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// Connect 处理 gateway 会话接入并加载或创建玩家资料。
|
||||
func (m *AccountManager) Connect(ctx context.Context, evt contracts.SessionConnected) (player.Profile, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
evt.AccountID = strings.TrimSpace(evt.AccountID)
|
||||
evt.SessionID = strings.TrimSpace(evt.SessionID)
|
||||
now := evt.ConnectedAt
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
|
||||
loadingGeneration, err := m.beginLogin(evt.AccountID, evt.SessionID, now)
|
||||
if err != nil {
|
||||
return player.Profile{}, err
|
||||
}
|
||||
if _, err := m.players.LoadOrCreate(ctx, evt.AccountID); err != nil {
|
||||
m.clearRuntimeGen(evt.AccountID, evt.SessionID, loadingGeneration)
|
||||
return player.Profile{}, err
|
||||
}
|
||||
profile := m.players.Upsert(evt.AccountID, accountStateOnline)
|
||||
m.finishLogin(evt.AccountID, evt.SessionID, loadingGeneration, now)
|
||||
if m.logger != nil {
|
||||
m.logger.Info("account connected", "account_id", evt.AccountID, "session_id", evt.SessionID)
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (m *AccountManager) beginLogin(accountID, sessionID string, now time.Time) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
runtime := m.accounts[accountID]
|
||||
if runtime == nil {
|
||||
runtime = &playerRuntime{accountID: accountID}
|
||||
m.accounts[accountID] = runtime
|
||||
}
|
||||
next, err := sessioncontract.NextPlayerLifecycleState(sessioncontract.PlayerLifecycleState(runtime.state), sessioncontract.PlayerEventLoginStart)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
runtime.sessionID = sessionID
|
||||
runtime.state = string(next)
|
||||
runtime.connectedAt = now
|
||||
runtime.disconnectedAt = time.Time{}
|
||||
runtime.lastActiveAt = now
|
||||
runtime.generation++
|
||||
return runtime.generation, nil
|
||||
}
|
||||
|
||||
func (m *AccountManager) finishLogin(accountID, sessionID string, generation int64, now time.Time) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
runtime := m.accounts[accountID]
|
||||
if runtime == nil || runtime.generation != generation {
|
||||
return
|
||||
}
|
||||
if runtime.sessionID != "" && sessionID != "" && runtime.sessionID != sessionID {
|
||||
return
|
||||
}
|
||||
next, err := sessioncontract.NextPlayerLifecycleState(sessioncontract.PlayerLifecycleState(runtime.state), sessioncontract.PlayerEventLoginBound)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
runtime.state = string(next)
|
||||
runtime.connectedAt = now
|
||||
runtime.disconnectedAt = time.Time{}
|
||||
runtime.lastActiveAt = now
|
||||
runtime.generation++
|
||||
}
|
||||
|
||||
func (m *AccountManager) clearRuntimeGen(accountID, sessionID string, generation int64) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
runtime := m.accounts[accountID]
|
||||
if runtime == nil || runtime.generation != generation {
|
||||
return
|
||||
}
|
||||
if runtime.sessionID != "" && sessionID != "" && runtime.sessionID != sessionID {
|
||||
return
|
||||
}
|
||||
delete(m.accounts, accountID)
|
||||
}
|
||||
|
||||
// Disconnect 处理 gateway 会话断开并标记账号离线。
|
||||
func (m *AccountManager) Disconnect(ctx context.Context, evt contracts.SessionDisconnected) (player.Profile, bool, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
evt.AccountID = strings.TrimSpace(evt.AccountID)
|
||||
evt.SessionID = strings.TrimSpace(evt.SessionID)
|
||||
now := evt.DisconnectedAt
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
runtime := m.accounts[evt.AccountID]
|
||||
if runtime == nil {
|
||||
m.mu.Unlock()
|
||||
return player.Profile{}, false, nil
|
||||
}
|
||||
if runtime.sessionID != "" && evt.SessionID != "" && runtime.sessionID != evt.SessionID {
|
||||
m.mu.Unlock()
|
||||
return player.Profile{}, false, nil
|
||||
}
|
||||
next, err := sessioncontract.NextPlayerLifecycleState(sessioncontract.PlayerLifecycleState(runtime.state), sessioncontract.PlayerEventDisconnect)
|
||||
if err != nil {
|
||||
m.mu.Unlock()
|
||||
return player.Profile{}, false, err
|
||||
}
|
||||
runtime.state = string(next)
|
||||
runtime.disconnectedAt = now
|
||||
runtime.lastActiveAt = now
|
||||
runtime.generation++
|
||||
m.mu.Unlock()
|
||||
|
||||
profile := m.players.Upsert(evt.AccountID, accountStateOffline)
|
||||
if m.logger != nil {
|
||||
m.logger.Info("account disconnected", "account_id", evt.AccountID, "session_id", evt.SessionID, "reason", evt.Reason)
|
||||
}
|
||||
return profile, true, ctx.Err()
|
||||
}
|
||||
|
||||
// Activate 标记账号已完成逻辑侧激活。
|
||||
func (m *AccountManager) Activate(ctx context.Context, accountID, sessionID string) (player.Profile, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
now := time.Now().UTC()
|
||||
if profile, ok := m.players.Get(accountID); ok && m.touchActiveRuntime(accountID, sessionID, now) {
|
||||
return profile, nil
|
||||
}
|
||||
if _, err := m.players.LoadOrCreate(ctx, accountID); err != nil {
|
||||
return player.Profile{}, err
|
||||
}
|
||||
profile := m.players.Upsert(accountID, acctProfileActive)
|
||||
if err := m.activateRuntime(accountID, sessionID, now); err != nil {
|
||||
return player.Profile{}, err
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (m *AccountManager) touchActiveRuntime(accountID, sessionID string, now time.Time) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
runtime := m.accounts[accountID]
|
||||
if runtime == nil || runtime.state != accountStateValid {
|
||||
return false
|
||||
}
|
||||
if runtime.sessionID != "" && sessionID != "" && runtime.sessionID != sessionID {
|
||||
return false
|
||||
}
|
||||
if sessionID != "" {
|
||||
runtime.sessionID = sessionID
|
||||
}
|
||||
runtime.lastActiveAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *AccountManager) activateRuntime(accountID, sessionID string, now time.Time) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
runtime := m.accounts[accountID]
|
||||
if runtime == nil {
|
||||
runtime = &playerRuntime{accountID: accountID}
|
||||
m.accounts[accountID] = runtime
|
||||
}
|
||||
if sessionID != "" {
|
||||
runtime.sessionID = sessionID
|
||||
}
|
||||
if runtime.state == "" || runtime.state == accountStateInvalid || runtime.state == accountStateClosing {
|
||||
runtime.state = accountStateOnline
|
||||
}
|
||||
next, err := sessioncontract.NextPlayerLifecycleState(sessioncontract.PlayerLifecycleState(runtime.state), sessioncontract.PlayerEventActivate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.state = string(next)
|
||||
runtime.lastActiveAt = now
|
||||
runtime.generation++
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot 返回所有账号运行时状态快照。
|
||||
func (m *AccountManager) Snapshot() []AccountSnapshot {
|
||||
m.mu.RLock()
|
||||
out := make([]AccountSnapshot, 0, len(m.accounts))
|
||||
for _, runtime := range m.accounts {
|
||||
out = append(out, runtime.snapshot())
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].AccountID < out[j].AccountID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *AccountManager) sweep(ctx context.Context, done chan<- struct{}) {
|
||||
defer close(done)
|
||||
ticker := time.NewTicker(m.sweepInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
m.reclaimIdle(ctx, time.Now().UTC())
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AccountManager) reclaimIdle(ctx context.Context, now time.Time) {
|
||||
if m.idleTTL <= 0 {
|
||||
return
|
||||
}
|
||||
type candidate struct {
|
||||
accountID string
|
||||
generation int64
|
||||
}
|
||||
candidates := make([]candidate, 0)
|
||||
|
||||
m.mu.Lock()
|
||||
for accountID, runtime := range m.accounts {
|
||||
if !isReclaimableAcct(runtime.state) || runtime.lastActiveAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
if now.Sub(runtime.lastActiveAt) < m.idleTTL {
|
||||
continue
|
||||
}
|
||||
if runtime.state == accountStateOffline {
|
||||
runtime.state = accountStateReconn
|
||||
}
|
||||
next, err := sessioncontract.NextPlayerLifecycleState(sessioncontract.PlayerLifecycleState(runtime.state), sessioncontract.PlayerEventReclaim)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
runtime.state = string(next)
|
||||
runtime.generation++
|
||||
candidates = append(candidates, candidate{accountID: accountID, generation: runtime.generation})
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, c := range candidates {
|
||||
saveCtx, cancel := context.WithTimeout(ctx, m.reclaimTimeout)
|
||||
profile, saved, err := m.players.SaveAndUnload(saveCtx, c.accountID, "idle_timeout")
|
||||
cancel()
|
||||
if err != nil {
|
||||
m.restoreReclaimFail(c.accountID, c.generation)
|
||||
if m.logger != nil {
|
||||
m.logger.Error("account idle save failed", "account_id", c.accountID, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
m.mu.Lock()
|
||||
runtime := m.accounts[c.accountID]
|
||||
if runtime != nil && runtime.state == accountStateClosing && runtime.generation == c.generation {
|
||||
delete(m.accounts, c.accountID)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if saved && m.logger != nil {
|
||||
m.logger.Info("account reclaimed after idle", "account_id", profile.AccountID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AccountManager) restoreReclaimFail(accountID string, generation int64) {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
runtime := m.accounts[accountID]
|
||||
if runtime == nil || runtime.generation != generation || runtime.state != accountStateClosing {
|
||||
return
|
||||
}
|
||||
next, err := sessioncontract.NextPlayerLifecycleState(sessioncontract.PlayerLifecycleState(runtime.state), sessioncontract.EventRestoreFail)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
runtime.state = string(next)
|
||||
runtime.generation++
|
||||
runtime.lastActiveAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
func isReclaimableAcct(state string) bool {
|
||||
switch state {
|
||||
case accountStateReconn:
|
||||
return true
|
||||
case accountStateOffline:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (r *playerRuntime) snapshot() AccountSnapshot {
|
||||
return AccountSnapshot{
|
||||
AccountID: r.accountID,
|
||||
SessionID: r.sessionID,
|
||||
State: r.state,
|
||||
Generation: r.generation,
|
||||
ConnectedAt: r.connectedAt,
|
||||
DisconnectedAt: r.disconnectedAt,
|
||||
LastActiveAt: r.lastActiveAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
appkit "longheng.io/server/internal/platform/app"
|
||||
"longheng.io/server/internal/platform/dispatch"
|
||||
"longheng.io/server/internal/platform/httpx"
|
||||
"longheng.io/server/internal/platform/registry"
|
||||
)
|
||||
|
||||
func logicAdminWrapper(env *appkit.Env, scope string) func(http.HandlerFunc) http.HandlerFunc {
|
||||
if env != nil && env.AdminAuth != nil {
|
||||
return func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return env.AdminAuth(scope, next)
|
||||
}
|
||||
}
|
||||
var logger *slog.Logger
|
||||
if env != nil {
|
||||
logger = env.Logger
|
||||
}
|
||||
return func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return httpx.WrapAdminPolicy(func() httpx.AdminPolicy {
|
||||
return logicAdminPolicy(env)
|
||||
}, logger, scope, next)
|
||||
}
|
||||
}
|
||||
|
||||
func logicAdminGuard(env *appkit.Env, scope string) func(string, http.HandlerFunc) http.HandlerFunc {
|
||||
if env != nil && env.AdminDangerous != nil {
|
||||
return func(operation string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return env.AdminDangerous(scope, operation, next)
|
||||
}
|
||||
}
|
||||
var logger *slog.Logger
|
||||
if env != nil {
|
||||
logger = env.Logger
|
||||
}
|
||||
return func(operation string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return httpx.WrapDangerousAdminPolicy(func() httpx.AdminPolicy {
|
||||
return logicAdminPolicy(env)
|
||||
}, logger, scope, operation, next)
|
||||
}
|
||||
}
|
||||
|
||||
func logicAdminPolicy(env *appkit.Env) httpx.AdminPolicy {
|
||||
if env == nil {
|
||||
return httpx.AdminPolicy{}
|
||||
}
|
||||
tokens := make([]httpx.AdminToken, 0, len(env.Config.Admin.Tokens))
|
||||
for _, token := range env.Config.Admin.Tokens {
|
||||
tokens = append(tokens, httpx.AdminToken{
|
||||
Name: token.Name,
|
||||
Token: token.Token,
|
||||
Role: token.Role,
|
||||
Scopes: append([]string(nil), token.Scopes...),
|
||||
})
|
||||
}
|
||||
return httpx.AdminPolicy{
|
||||
Token: env.Config.Admin.Token,
|
||||
RBACEnabled: env.Config.Admin.RBACEnabled,
|
||||
RequireConfirmation: env.Config.Admin.DangerousConfirm,
|
||||
Tokens: tokens,
|
||||
}
|
||||
}
|
||||
|
||||
func routesFromHandlers(handlers []dispatch.HandlerMeta) []registry.Route {
|
||||
routes := make([]registry.Route, 0, len(handlers))
|
||||
for _, meta := range handlers {
|
||||
if meta.MsgID == 0 {
|
||||
continue
|
||||
}
|
||||
routes = append(routes, registry.Route{
|
||||
ID: fmt.Sprint(meta.MsgID),
|
||||
Group: strings.TrimSpace(meta.RouteGroup),
|
||||
Internal: meta.Internal,
|
||||
Stateful: meta.Stateful || meta.PlayerScoped,
|
||||
Authorized: meta.Authorized || meta.AuthRequired,
|
||||
Idempotent: meta.Idempotent,
|
||||
})
|
||||
}
|
||||
return routes
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/platform/httpx"
|
||||
"longheng.io/server/internal/reference/player"
|
||||
"longheng.io/server/pkg/contracts"
|
||||
)
|
||||
|
||||
func (s *Service) registerRoutes(mux *http.ServeMux) {
|
||||
prefix := s.adminPrefix
|
||||
mux.HandleFunc(prefix+"/players", s.admin(s.handlePlayers))
|
||||
mux.HandleFunc(prefix+"/player_summaries", s.admin(s.handleSummaries))
|
||||
mux.HandleFunc(prefix+"/player_summaries/rebuild", s.admin(s.handleSummaryRebuild))
|
||||
mux.HandleFunc(prefix+"/readmodels/player_summaries", s.admin(s.handleViewSearch("player_summaries")))
|
||||
mux.HandleFunc(prefix+"/readmodels/player_summaries/rebuild", s.admin(s.handleViewRebuild("player_summaries")))
|
||||
mux.HandleFunc(prefix+"/accounts", s.admin(s.handleAccounts))
|
||||
mux.HandleFunc(prefix+"/playerstore", s.admin(s.handlePlayerStore))
|
||||
mux.HandleFunc(prefix+"/playerstore/deadletters", s.admin(s.handleDeadLetters))
|
||||
mux.HandleFunc(prefix+"/playerstore/deadletters/requeue", s.adminDangerous(httpx.AdminOperationID(http.MethodPost, prefix+"/playerstore/deadletters/requeue"), s.handleDLRequeue))
|
||||
mux.HandleFunc(prefix+"/world", s.admin(s.handleWorld))
|
||||
mux.HandleFunc(prefix+"/playerloops", s.admin(s.handlePlayerLoops))
|
||||
mux.HandleFunc(prefix+"/idempotency", s.admin(s.handleIdempotency))
|
||||
mux.HandleFunc(prefix+"/handlers", s.admin(s.handleHandlers))
|
||||
mux.HandleFunc(prefix+"/profiledb", s.admin(s.handleProfileDB))
|
||||
mux.HandleFunc(prefix+"/mock/player", s.adminDangerous(httpx.AdminOperationID(http.MethodPost, prefix+"/mock/player"), s.handlePlayerMutation))
|
||||
s.registerAdminRoutes(mux)
|
||||
s.registerOpsRoutes(mux, prefix)
|
||||
}
|
||||
|
||||
func (s *Service) handlePlayers(w http.ResponseWriter, _ *http.Request) {
|
||||
if s.players == nil {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "player module not configured"})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, s.players.Snapshot())
|
||||
}
|
||||
|
||||
func (s *Service) handleAccounts(w http.ResponseWriter, _ *http.Request) {
|
||||
if s.accounts == nil {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "account runtime not configured"})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, s.accounts.Snapshot())
|
||||
}
|
||||
|
||||
func (s *Service) handlePlayerStore(w http.ResponseWriter, _ *http.Request) {
|
||||
if s.players == nil {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "player module not configured"})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, s.players.StoreStatus())
|
||||
}
|
||||
|
||||
func (s *Service) handleDeadLetters(w http.ResponseWriter, r *http.Request) {
|
||||
if s.players == nil {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "player module not configured"})
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"dead_letters": s.players.DeadLetters(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) handleDLRequeue(w http.ResponseWriter, r *http.Request) {
|
||||
if s.players == nil {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "player module not configured"})
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
accountID := strings.TrimSpace(r.URL.Query().Get("account_id"))
|
||||
if accountID == "" {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "account_id is required"})
|
||||
return
|
||||
}
|
||||
receipt, duplicate, submitted, err := s.submitOpsCmd(r, "logic.playerstore.deadletter_requeue", accountID, "deadletter requeue", map[string]any{
|
||||
"account_id": accountID,
|
||||
})
|
||||
if writeOpsCmdErr(w, err) {
|
||||
return
|
||||
}
|
||||
if submitted && duplicate {
|
||||
writeOpsDup(w, receipt)
|
||||
return
|
||||
}
|
||||
if !s.players.RequeueDeadLetter(accountID) {
|
||||
if submitted {
|
||||
s.markOpsCmdFail(r.Context(), receipt, errors.New("dead letter not found"))
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "dead letter not found"})
|
||||
return
|
||||
}
|
||||
response := map[string]any{
|
||||
"status": "requeued",
|
||||
"account_id": accountID,
|
||||
}
|
||||
if submitted {
|
||||
receipt = s.markOpsAdminOK(r.Context(), receipt)
|
||||
writeOpsAdminReceipt(w, http.StatusOK, response, receipt)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (s *Service) handleWorld(w http.ResponseWriter, _ *http.Request) {
|
||||
if s.world == nil {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "world module not configured"})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, s.world.Snapshot())
|
||||
}
|
||||
|
||||
func (s *Service) handlePlayerLoops(w http.ResponseWriter, _ *http.Request) {
|
||||
httpx.WriteJSON(w, http.StatusOK, s.playerLoops.Snapshot())
|
||||
}
|
||||
|
||||
func (s *Service) handleIdempotency(w http.ResponseWriter, _ *http.Request) {
|
||||
responseCache := map[string]any{}
|
||||
if s.responses != nil {
|
||||
responseCache = s.responses.Snapshot()
|
||||
}
|
||||
if s.idempotency == nil {
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"response_cache": responseCache})
|
||||
return
|
||||
}
|
||||
snapshot := s.idempotency.Snapshot()
|
||||
snapshot["response_cache"] = responseCache
|
||||
httpx.WriteJSON(w, http.StatusOK, snapshot)
|
||||
}
|
||||
|
||||
func (s *Service) handleHandlers(w http.ResponseWriter, _ *http.Request) {
|
||||
httpx.WriteJSON(w, http.StatusOK, s.dispatcher.Snapshot())
|
||||
}
|
||||
|
||||
func (s *Service) handleProfileDB(w http.ResponseWriter, _ *http.Request) {
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"fields": player.ProfileFieldDescriptors(),
|
||||
"operations": player.ProfileOperationDescriptors(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) handlePlayerMutation(w http.ResponseWriter, r *http.Request) {
|
||||
if s.players == nil {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "player module not configured"})
|
||||
return
|
||||
}
|
||||
accountID := strings.TrimSpace(r.URL.Query().Get("account_id"))
|
||||
state := strings.TrimSpace(r.URL.Query().Get("state"))
|
||||
if accountID == "" {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "account_id is required"})
|
||||
return
|
||||
}
|
||||
if state == "" {
|
||||
state = "online"
|
||||
}
|
||||
|
||||
receipt, duplicate, submitted, err := s.submitOpsCmd(r, "logic.mock.player_mutation", accountID, "mock player mutation", map[string]any{
|
||||
"account_id": accountID,
|
||||
"state": state,
|
||||
})
|
||||
if writeOpsCmdErr(w, err) {
|
||||
return
|
||||
}
|
||||
if submitted && duplicate {
|
||||
writeOpsDup(w, receipt)
|
||||
return
|
||||
}
|
||||
|
||||
record := s.players.Upsert(accountID, state)
|
||||
event := contracts.PlayerStateChanged{
|
||||
AccountID: record.AccountID,
|
||||
State: record.State,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
}
|
||||
s.publishAsync(contracts.TopicPlayerStateChange, event)
|
||||
if submitted {
|
||||
receipt = s.markOpsAdminOK(r.Context(), receipt)
|
||||
writeOpsAdminReceipt(w, http.StatusOK, map[string]any{"player": record}, receipt)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, record)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/dispatch"
|
||||
"longheng.io/server/internal/platform/idempotency"
|
||||
"longheng.io/server/internal/platform/playerloop"
|
||||
"longheng.io/server/pkg/contracts"
|
||||
"longheng.io/server/pkg/protocol"
|
||||
)
|
||||
|
||||
const idemFinalizeTimeout = 5 * time.Second
|
||||
|
||||
func (s *Service) handlePlayerEvent(ctx context.Context, event playerloop.Event) error {
|
||||
switch payload := event.Payload.(type) {
|
||||
case contracts.SessionConnected:
|
||||
return s.handleSessConnected(ctx, payload)
|
||||
case contracts.SessionDisconnected:
|
||||
return s.handleSessionDisc(ctx, payload)
|
||||
case contracts.GatewayClientPacket:
|
||||
return s.handleGwPacket(ctx, payload)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) handleGwPacket(ctx context.Context, packet contracts.GatewayClientPacket) error {
|
||||
packet.AccountID = strings.TrimSpace(packet.AccountID)
|
||||
packet.SessionID = strings.TrimSpace(packet.SessionID)
|
||||
if s.isStaleSession(packet.AccountID, packet.SessionID) {
|
||||
return nil
|
||||
}
|
||||
meta, metaOK := s.dispatcher.Meta(packet.MsgID)
|
||||
if metaOK {
|
||||
// route policy 先于幂等和 handler 执行,非法路由不应占用防重 key。
|
||||
if err := dispatch.EnforceRoutePolicy(meta, dispatch.Request{
|
||||
GatewayNodeID: packet.GatewayNodeID,
|
||||
SessionID: packet.SessionID,
|
||||
AccountID: packet.AccountID,
|
||||
PacketID: packet.PacketID,
|
||||
MsgID: packet.MsgID,
|
||||
Compressed: packet.Compressed,
|
||||
Body: append([]byte(nil), packet.Body...),
|
||||
Metadata: protocol.CloneFrameMetadata(packet.Metadata),
|
||||
ReceivedAt: packet.ReceivedAt,
|
||||
}); err != nil {
|
||||
s.incMetric("logic_packet_errors_total", map[string]string{"stage": "route_policy"})
|
||||
return s.publishProtocolError(ctx, packet, err)
|
||||
}
|
||||
}
|
||||
var reservation idempotency.Reservation
|
||||
var decision idempotency.Decision
|
||||
protected := metaOK && s.shouldProtectPacket(meta)
|
||||
if protected {
|
||||
var err error
|
||||
// Begin 只占位,不执行业务;handler 成功后必须 Commit,失败必须 Abort。
|
||||
reservation, decision, err = s.reservePktIdem(ctx, packet)
|
||||
if err != nil {
|
||||
if errors.Is(err, idempotency.ErrRequestConflict) {
|
||||
s.incMetric("logic_idempotency_checks_total", map[string]string{"status": "conflict", "msg_id": fmt.Sprint(packet.MsgID)})
|
||||
s.incMetric("logic_packet_errors_total", map[string]string{"stage": "idempotency_conflict"})
|
||||
return s.publishProtocolError(ctx, packet, protocol.WrapError(protocol.CodeConflict, "idempotency request conflicts with original payload", err))
|
||||
}
|
||||
if errors.Is(err, idempotency.ErrInvalidRequest) {
|
||||
s.incMetric("logic_idempotency_checks_total", map[string]string{"status": "invalid_request", "msg_id": fmt.Sprint(packet.MsgID)})
|
||||
s.incMetric("logic_packet_errors_total", map[string]string{"stage": "idempotency_invalid"})
|
||||
return s.publishProtocolError(ctx, packet, protocol.WrapError(protocol.CodeBadRequest, "invalid idempotency request", err))
|
||||
}
|
||||
s.incMetric("logic_idempotency_checks_total", map[string]string{"status": "backend_error", "msg_id": fmt.Sprint(packet.MsgID)})
|
||||
s.incMetric("logic_packet_errors_total", map[string]string{"stage": "idempotency"})
|
||||
return s.publishProtocolError(ctx, packet, protocol.WrapError(protocol.CodeUnavailable, "idempotency backend unavailable", err))
|
||||
}
|
||||
s.incMetric("logic_idempotency_checks_total", map[string]string{"status": string(decision.Status), "msg_id": fmt.Sprint(packet.MsgID)})
|
||||
if decision.Status == idempotency.StatusDuplicate || decision.Status == idempotency.StatusReplay {
|
||||
// 重复请求优先重放缓存响应,不再进入 handler,避免加钱、领奖、读邮件等动作重复执行。
|
||||
cached, ok, err := s.cachedIdemResp(ctx, packet, decision)
|
||||
if err != nil {
|
||||
s.incMetric("logic_idempotency_response_cache_errors_total", map[string]string{"operation": "get", "msg_id": fmt.Sprint(packet.MsgID)})
|
||||
s.incMetric("logic_packet_errors_total", map[string]string{"stage": "idempotency_response_cache"})
|
||||
return s.publishProtocolError(ctx, packet, protocol.WrapError(protocol.CodeUnavailable, "load idempotent response", err))
|
||||
}
|
||||
if ok {
|
||||
s.incMetric("logic_idempotency_replays_total", map[string]string{"status": string(decision.Status), "msg_id": fmt.Sprint(packet.MsgID)})
|
||||
return s.publishPlayerResp(ctx, cached)
|
||||
}
|
||||
s.incMetric("logic_idempotency_response_cache_misses_total", map[string]string{"status": string(decision.Status), "msg_id": fmt.Sprint(packet.MsgID)})
|
||||
s.incMetric("logic_packet_errors_total", map[string]string{"stage": "idempotency_response_cache_miss"})
|
||||
return s.publishProtocolError(ctx, packet, protocol.Errorf(protocol.CodeConflict, "idempotent response is no longer available"))
|
||||
}
|
||||
if decision.Status == idempotency.StatusInFlight {
|
||||
// 同一个幂等请求仍在处理时返回冲突,客户端应稍后重试而不是并发打 handler。
|
||||
s.incMetric("logic_idempotency_inflight_responses_total", map[string]string{"msg_id": fmt.Sprint(packet.MsgID)})
|
||||
s.incMetric("logic_packet_errors_total", map[string]string{"stage": "idempotency_in_flight"})
|
||||
return s.publishProtocolError(ctx, packet, protocol.Errorf(protocol.CodeConflict, "idempotent request is still in flight"))
|
||||
}
|
||||
}
|
||||
s.incMetricLow(MetricLogicPacketsTotal, map[string]string{"msg_id": fmt.Sprint(packet.MsgID)})
|
||||
dispatchStarted := time.Now()
|
||||
resp, err := s.dispatcher.Dispatch(ctx, dispatch.Request{
|
||||
GatewayNodeID: packet.GatewayNodeID,
|
||||
SessionID: packet.SessionID,
|
||||
AccountID: packet.AccountID,
|
||||
PacketID: packet.PacketID,
|
||||
MsgID: packet.MsgID,
|
||||
Compressed: packet.Compressed,
|
||||
Body: append([]byte(nil), packet.Body...),
|
||||
Metadata: protocol.CloneFrameMetadata(packet.Metadata),
|
||||
ReceivedAt: packet.ReceivedAt,
|
||||
})
|
||||
s.observeLoopHandler(meta, packet.MsgID, time.Since(dispatchStarted), err)
|
||||
if err != nil {
|
||||
// handler 失败必须释放 pending 占位,否则客户端重试会被误判为仍在处理。
|
||||
_ = abortIdemReservation(ctx, reservation)
|
||||
return s.publishProtocolError(ctx, packet, err)
|
||||
}
|
||||
response := responseFromDispatch(packet, resp)
|
||||
if protected {
|
||||
// handler 成功后先提交幂等结果,不让后置激活或热缓存耗尽一致性提交的超时预算。
|
||||
commitCtx, commitCancel := idemFinalizeContext(ctx)
|
||||
payload, err := encodeIdemResp(response)
|
||||
if err == nil {
|
||||
err = reservation.CommitResult(commitCtx, payload)
|
||||
}
|
||||
commitCancel()
|
||||
// marker 与响应先在幂等权威后端原子提交;独立 response cache 只做可失败的热缓存。
|
||||
if err != nil {
|
||||
s.incMetric("logic_idempotency_checks_total", map[string]string{"status": "commit_error", "msg_id": fmt.Sprint(packet.MsgID)})
|
||||
s.incMetric("logic_packet_errors_total", map[string]string{"stage": "idempotency_commit"})
|
||||
publishCtx, publishCancel := idemFinalizeContext(ctx)
|
||||
defer publishCancel()
|
||||
return s.publishProtocolError(publishCtx, packet, idemProtocolErr("commit idempotency request", err))
|
||||
}
|
||||
}
|
||||
if metaOK && meta.PlayerScoped && s.accounts != nil {
|
||||
activateCtx, activateCancel := idemFinalizeContext(ctx)
|
||||
record, err := s.accounts.Activate(activateCtx, packet.AccountID, packet.SessionID)
|
||||
activateCancel()
|
||||
if err != nil {
|
||||
// handler 和幂等结果均已成功,Activate 只是后置生命周期记录,不能影响原业务重放。
|
||||
s.incMetric("logic_packet_errors_total", map[string]string{"stage": "activate_profile"})
|
||||
if s.logger != nil {
|
||||
s.logger.Error("logic player profile activation failed after handler success", "account_id", packet.AccountID, "session_id", packet.SessionID, "msg_id", packet.MsgID, "error", err)
|
||||
}
|
||||
} else if s.world != nil {
|
||||
s.world.ObservePlayer(record.AccountID, record.State)
|
||||
}
|
||||
}
|
||||
if protected {
|
||||
cacheCtx, cacheCancel := idemFinalizeContext(ctx)
|
||||
err := s.storeIdemResp(cacheCtx, decision, response)
|
||||
cacheCancel()
|
||||
if err != nil {
|
||||
s.incMetric("logic_idempotency_response_cache_errors_total", map[string]string{"operation": "store", "msg_id": fmt.Sprint(packet.MsgID)})
|
||||
if s.logger != nil {
|
||||
s.logger.Error("logic idempotent response hot cache store failed", "account_id", packet.AccountID, "msg_id", packet.MsgID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.incMetricLow(MetricLogicResponsesTotal, map[string]string{"msg_id": fmt.Sprint(response.MsgID)})
|
||||
publishCtx := ctx
|
||||
var publishCancel context.CancelFunc
|
||||
if protected {
|
||||
publishCtx, publishCancel = idemFinalizeContext(ctx)
|
||||
defer publishCancel()
|
||||
}
|
||||
return s.publishPlayerResp(publishCtx, response)
|
||||
}
|
||||
|
||||
func idemProtocolErr(operation string, err error) error {
|
||||
switch {
|
||||
case errors.Is(err, idempotency.ErrInvalidRequest):
|
||||
return protocol.WrapError(protocol.CodeBadRequest, operation, err)
|
||||
case errors.Is(err, idempotency.ErrRequestConflict):
|
||||
return protocol.WrapError(protocol.CodeConflict, operation, err)
|
||||
default:
|
||||
return protocol.WrapError(protocol.CodeUnavailable, operation, err)
|
||||
}
|
||||
}
|
||||
|
||||
func abortIdemReservation(ctx context.Context, reservation idempotency.Reservation) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
abortCtx, cancel := idemFinalizeContext(ctx)
|
||||
defer cancel()
|
||||
return reservation.Abort(abortCtx)
|
||||
}
|
||||
|
||||
func idemFinalizeContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), idemFinalizeTimeout)
|
||||
}
|
||||
|
||||
func responseFromDispatch(packet contracts.GatewayClientPacket, resp dispatch.Response) contracts.LogicPlayerResponse {
|
||||
metadata := protocol.CloneFrameMetadata(resp.Metadata)
|
||||
if len(metadata) == 0 {
|
||||
metadata = protocol.CloneFrameMetadata(packet.Metadata)
|
||||
}
|
||||
return contracts.LogicPlayerResponse{
|
||||
TargetGatewayNodeID: packet.GatewayNodeID,
|
||||
SessionID: packet.SessionID,
|
||||
AccountID: packet.AccountID,
|
||||
PacketID: resp.PacketID,
|
||||
MsgID: resp.MsgID,
|
||||
Sequence: packet.Sequence,
|
||||
WireFormat: packet.WireFormat,
|
||||
Compressed: resp.Compressed,
|
||||
Body: append([]byte(nil), resp.Body...),
|
||||
Metadata: metadata,
|
||||
Note: resp.Note,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publishPlayerResp(ctx context.Context, response contracts.LogicPlayerResponse) error {
|
||||
topic := contracts.GatewayNodeRespTopic(response.TargetGatewayNodeID)
|
||||
if topic == "" {
|
||||
topic = contracts.TopicLogicPlayerResponse
|
||||
}
|
||||
if s.responsesOut != nil {
|
||||
return s.responsesOut.Publish(ctx, topic, response)
|
||||
}
|
||||
if s.bus == nil {
|
||||
return errRespMissing
|
||||
}
|
||||
return s.bus.Publish(ctx, topic, response)
|
||||
}
|
||||
|
||||
func playerLoopErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, playerloop.ErrMissingAccount):
|
||||
return protocol.WrapError(protocol.CodeBadRequest, "account_id is required", err)
|
||||
case errors.Is(err, playerloop.ErrStopped):
|
||||
return protocol.WrapError(protocol.CodeUnavailable, "player loop unavailable", err)
|
||||
case errors.Is(err, playerloop.ErrQueueFull):
|
||||
return protocol.WrapError(protocol.CodeUnavailable, "player loop queue full", err)
|
||||
default:
|
||||
return protocol.WrapError(protocol.CodeInternal, "submit player packet", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publishProtocolError(ctx context.Context, packet contracts.GatewayClientPacket, err error) error {
|
||||
appErr := protocol.ErrorFrom(err)
|
||||
s.incMetric("logic_protocol_errors_total", map[string]string{"code": appErr.Code.String(), "msg_id": fmt.Sprint(packet.MsgID)})
|
||||
if s.logger != nil {
|
||||
s.logger.Error("logic dispatch failed", "account_id", packet.AccountID, "msg_id", packet.MsgID, "code", appErr.Code.String(), "error", err)
|
||||
}
|
||||
now := time.Now()
|
||||
errorResp := protocol.PublicErrorResponse(appErr, now.Unix())
|
||||
return s.publishPlayerResp(ctx, contracts.LogicPlayerResponse{
|
||||
TargetGatewayNodeID: packet.GatewayNodeID,
|
||||
SessionID: packet.SessionID,
|
||||
AccountID: packet.AccountID,
|
||||
PacketID: protocol.PacketIDReqResp,
|
||||
MsgID: packet.MsgID,
|
||||
Sequence: packet.Sequence,
|
||||
WireFormat: packet.WireFormat,
|
||||
Body: protocol.EncodeErrorResponse(errorResp),
|
||||
Metadata: protocol.CloneFrameMetadata(packet.Metadata),
|
||||
Note: fmt.Sprintf("ProtocolError:%s", appErr.Code.String()),
|
||||
CreatedAt: now.UTC(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Package dnf 提供 DNF 项目接入 logic 服务的装配入口。
|
||||
package dnf
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
appkit "longheng.io/server/internal/platform/app"
|
||||
)
|
||||
|
||||
const (
|
||||
// EnvConfigPath 是 DNF logic manifest 使用的外部配置路径环境变量。
|
||||
EnvConfigPath = "LONGHENG_DNF_CONFIG"
|
||||
// DefaultConfigPath 是本地 DNF logic manifest 配置路径。
|
||||
DefaultConfigPath = "configs/dnf/logic.toml"
|
||||
)
|
||||
|
||||
var ErrConfigInvalid = errors.New("dnf logic config is invalid")
|
||||
|
||||
type Config struct {
|
||||
Repository RepositoryConfig `toml:"repository"`
|
||||
}
|
||||
|
||||
type RepositoryConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Driver string `toml:"driver"`
|
||||
SQLitePath string `toml:"sqlite_path"`
|
||||
MySQLDSN string `toml:"mysql_dsn"`
|
||||
MySQLMaxOpenConns int `toml:"mysql_max_open_conns"`
|
||||
MySQLMaxIdleConns int `toml:"mysql_max_idle_conns"`
|
||||
MySQLConnMaxLifetime int `toml:"mysql_conn_max_lifetime_seconds"`
|
||||
RedisEnabled bool `toml:"redis_enabled"`
|
||||
RedisAddress string `toml:"redis_address"`
|
||||
RedisPassword string `toml:"redis_password"`
|
||||
RedisDB int `toml:"redis_db"`
|
||||
RedisPoolSize int `toml:"redis_pool_size"`
|
||||
RedisTimeoutSeconds int `toml:"redis_timeout_seconds"`
|
||||
RedisKeyPrefix string `toml:"redis_key_prefix"`
|
||||
RedisTTLSeconds int `toml:"redis_ttl_seconds"`
|
||||
ShardID string `toml:"shard_id"`
|
||||
TablePrefix string `toml:"table_prefix"`
|
||||
ServerGroupPlanFile string `toml:"server_group_plan_file"`
|
||||
AutoCreateSchema bool `toml:"auto_create_schema"`
|
||||
CSharpLegacySchema bool `toml:"csharp_legacy_schema"`
|
||||
CreateDatabases bool `toml:"create_databases"`
|
||||
}
|
||||
|
||||
// LoadConfig 读取 DNF logic manifest 外部配置。
|
||||
// 该配置只属于 DNF 项目装配层,不进入平台通用 ServiceConfig;环境相关默认值由 LoadConfigForEnv 补齐。
|
||||
func LoadConfig(path string) (Config, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
path = DefaultConfigPath
|
||||
}
|
||||
var cfg Config
|
||||
meta, err := toml.DecodeFile(path, &cfg)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if keys := meta.Undecoded(); len(keys) > 0 {
|
||||
names := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
names = append(names, key.String())
|
||||
}
|
||||
return Config{}, fmt.Errorf("%w: unknown keys: %s", ErrConfigInvalid, strings.Join(names, ", "))
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadConfigForEnv 读取并校验 DNF logic manifest 配置。
|
||||
// 它会从框架 Env 补齐当前区服 shard_id,再执行配置校验。
|
||||
func LoadConfigForEnv(path string, env *appkit.Env) (Config, error) {
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Normalize(env)
|
||||
return cfg, cfg.Validate()
|
||||
}
|
||||
|
||||
// Normalize 补齐 DNF 仓储装配默认值。
|
||||
// ShardID 默认读取框架区服 ID,读写库名仍由 servergroup route meta 派生。
|
||||
func (c *Config) Normalize(env *appkit.Env) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
repo := &c.Repository
|
||||
repo.Driver = strings.ToLower(strings.TrimSpace(repo.Driver))
|
||||
repo.SQLitePath = strings.TrimSpace(repo.SQLitePath)
|
||||
repo.MySQLDSN = strings.TrimSpace(repo.MySQLDSN)
|
||||
repo.RedisAddress = strings.TrimSpace(repo.RedisAddress)
|
||||
repo.RedisPassword = strings.TrimSpace(repo.RedisPassword)
|
||||
repo.RedisKeyPrefix = strings.TrimSpace(repo.RedisKeyPrefix)
|
||||
repo.ShardID = strings.TrimSpace(repo.ShardID)
|
||||
repo.TablePrefix = strings.TrimSpace(repo.TablePrefix)
|
||||
repo.ServerGroupPlanFile = strings.TrimSpace(repo.ServerGroupPlanFile)
|
||||
if repo.ShardID == "" && env != nil && env.Config.Cluster.ShardID != 0 {
|
||||
repo.ShardID = fmt.Sprintf("%d", env.Config.Cluster.ShardID)
|
||||
}
|
||||
if repo.ServerGroupPlanFile == "" && env != nil {
|
||||
repo.ServerGroupPlanFile = strings.TrimSpace(env.Config.ServerGroup.PlanFile)
|
||||
}
|
||||
if repo.TablePrefix == "" {
|
||||
repo.TablePrefix = "dnf"
|
||||
}
|
||||
// An omitted driver preserves every existing installation's MySQL
|
||||
// behavior. New one-click templates opt in to SQLite explicitly.
|
||||
if repo.Driver == "" {
|
||||
repo.Driver = "mysql"
|
||||
}
|
||||
if repo.Driver == "sqlite" && repo.SQLitePath == "" {
|
||||
repo.SQLitePath = "data/dnf90.db"
|
||||
}
|
||||
if repo.MySQLMaxOpenConns <= 0 {
|
||||
repo.MySQLMaxOpenConns = 32
|
||||
}
|
||||
if repo.MySQLMaxIdleConns <= 0 {
|
||||
repo.MySQLMaxIdleConns = 8
|
||||
}
|
||||
if repo.MySQLConnMaxLifetime <= 0 {
|
||||
repo.MySQLConnMaxLifetime = 300
|
||||
}
|
||||
if repo.RedisEnabled {
|
||||
if repo.RedisAddress == "" {
|
||||
repo.RedisAddress = "127.0.0.1:6379"
|
||||
}
|
||||
if repo.RedisPoolSize <= 0 {
|
||||
repo.RedisPoolSize = 8
|
||||
}
|
||||
if repo.RedisTimeoutSeconds <= 0 {
|
||||
repo.RedisTimeoutSeconds = 2
|
||||
}
|
||||
if repo.RedisKeyPrefix == "" {
|
||||
repo.RedisKeyPrefix = "dnf:repository"
|
||||
if repo.ShardID != "" {
|
||||
repo.RedisKeyPrefix += ":" + repo.ShardID
|
||||
}
|
||||
}
|
||||
if repo.RedisTTLSeconds < 0 {
|
||||
repo.RedisTTLSeconds = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate 校验 DNF 仓储装配配置。
|
||||
// 开启仓储时必须显式配置 MySQL DSN,并通过 servergroup 提供 shard 路由。
|
||||
func (c Config) Validate() error {
|
||||
repo := c.Repository
|
||||
if !repo.Enabled {
|
||||
return nil
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(repo.Driver)) {
|
||||
case "mysql":
|
||||
if strings.TrimSpace(repo.MySQLDSN) == "" {
|
||||
return fmt.Errorf("%w: repository mysql_dsn is required", ErrConfigInvalid)
|
||||
}
|
||||
if strings.TrimSpace(repo.ShardID) == "" {
|
||||
return fmt.Errorf("%w: repository shard_id is required", ErrConfigInvalid)
|
||||
}
|
||||
if repo.MySQLMaxOpenConns <= 0 || repo.MySQLMaxIdleConns <= 0 || repo.MySQLConnMaxLifetime <= 0 {
|
||||
return fmt.Errorf("%w: repository mysql pool values must be positive", ErrConfigInvalid)
|
||||
}
|
||||
case "sqlite":
|
||||
if strings.TrimSpace(repo.SQLitePath) == "" {
|
||||
return fmt.Errorf("%w: repository sqlite_path is required", ErrConfigInvalid)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: repository driver must be mysql or sqlite", ErrConfigInvalid)
|
||||
}
|
||||
if repo.RedisEnabled {
|
||||
if strings.TrimSpace(repo.RedisAddress) == "" {
|
||||
return fmt.Errorf("%w: repository redis_address is required", ErrConfigInvalid)
|
||||
}
|
||||
if repo.RedisDB < 0 {
|
||||
return fmt.Errorf("%w: repository redis_db must be non-negative", ErrConfigInvalid)
|
||||
}
|
||||
if repo.RedisPoolSize <= 0 || repo.RedisTimeoutSeconds <= 0 {
|
||||
return fmt.Errorf("%w: repository redis pool values must be positive", ErrConfigInvalid)
|
||||
}
|
||||
if strings.TrimSpace(repo.RedisKeyPrefix) == "" {
|
||||
return fmt.Errorf("%w: repository redis_key_prefix is required", ErrConfigInvalid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c RepositoryConfig) connMaxLife() time.Duration {
|
||||
if c.MySQLConnMaxLifetime <= 0 {
|
||||
return 5 * time.Minute
|
||||
}
|
||||
return time.Duration(c.MySQLConnMaxLifetime) * time.Second
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Package game 装配演示游戏框架的 DNF logic 组件。
|
||||
// 该子包隔离 internal/services/logic 演示框架依赖,
|
||||
// 生产二进制(cmd/server/dnf90 经 internal/app/dnf90)只装配仓储底座,
|
||||
// 不引用本包,演示框架因此留在生产依赖闭包之外。
|
||||
package game
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
appkit "longheng.io/server/internal/platform/app"
|
||||
logicsvc "longheng.io/server/internal/services/logic"
|
||||
logicdnf "longheng.io/server/internal/services/logic/dnf"
|
||||
)
|
||||
|
||||
// Components 按外部配置构建 DNF logic 服务组件列表。
|
||||
// 仓储组件排在 logic service 前启动,后续 DNF owner 可从 Runtime 读取 repository.Group。
|
||||
func Components(env *appkit.Env) ([]appkit.Component, error) {
|
||||
path := strings.TrimSpace(os.Getenv(logicdnf.EnvConfigPath))
|
||||
if path == "" {
|
||||
path = logicdnf.DefaultConfigPath
|
||||
}
|
||||
cfg, err := logicdnf.LoadConfigForEnv(path, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ComponentsWithConfig(env, cfg)
|
||||
}
|
||||
|
||||
// ComponentsWithConfig 使用已解析配置构建 DNF logic 服务组件列表。
|
||||
// 该入口用于测试和项目侧自定义配置装配。
|
||||
func ComponentsWithConfig(env *appkit.Env, cfg logicdnf.Config) ([]appkit.Component, error) {
|
||||
runtime, err := logicdnf.NewRuntime(env, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
components := make([]appkit.Component, 0, 2)
|
||||
if runtime.Repository != nil {
|
||||
components = append(components, runtime.Repository)
|
||||
}
|
||||
components = append(components, logicsvc.NewWithOptions(env, logicsvc.Options{
|
||||
Game: func(e *appkit.Env) (logicsvc.GameManifest, error) {
|
||||
return GameManifest(runtime, e)
|
||||
},
|
||||
}))
|
||||
return components, nil
|
||||
}
|
||||
|
||||
// GameManifest 返回当前 DNF logic 的业务 manifest。
|
||||
// 本阶段只接入仓储底座,协议 handler 和 owner 后续在这里继续挂接。
|
||||
func GameManifest(_ *logicdnf.Runtime, env *appkit.Env) (logicsvc.GameManifest, error) {
|
||||
return logicsvc.BlankManifest(env)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package dnf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/repository/mysql"
|
||||
sqliterepo "longheng.io/server/internal/modules/dnf/repository/sqlite"
|
||||
appkit "longheng.io/server/internal/platform/app"
|
||||
platformdb "longheng.io/server/internal/platform/db"
|
||||
"longheng.io/server/internal/platform/servergroup"
|
||||
)
|
||||
|
||||
var openSQL = sql.Open
|
||||
|
||||
type Runtime struct {
|
||||
Repository *RepoComponent
|
||||
}
|
||||
|
||||
type RepoComponent struct {
|
||||
component repositoryLifecycle
|
||||
driver string
|
||||
db *sql.DB
|
||||
redis *platformdb.RedigoExecutor
|
||||
cacheOpts dnfrepo.RedisCacheOptions
|
||||
}
|
||||
|
||||
type repositoryLifecycle interface {
|
||||
Name() string
|
||||
Preflight(context.Context) error
|
||||
Start(context.Context) error
|
||||
Stop(context.Context) error
|
||||
Group() (dnfrepo.Group, bool)
|
||||
}
|
||||
|
||||
type RepoSnapshot struct {
|
||||
Driver string `json:"driver"`
|
||||
MySQL *mysql.ComponentSnapshot `json:"mysql,omitempty"`
|
||||
SQLite *sqliterepo.Snapshot `json:"sqlite,omitempty"`
|
||||
}
|
||||
|
||||
// NewRuntime 创建 DNF logic 运行时装配对象。
|
||||
// 它只负责项目依赖装配,不注册协议 handler,也不直接修改玩家状态。
|
||||
func NewRuntime(env *appkit.Env, cfg Config) (*Runtime, error) {
|
||||
cfg.Normalize(env)
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtime := &Runtime{}
|
||||
if cfg.Repository.Enabled {
|
||||
component, err := newRepoComponent(env, cfg.Repository)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtime.Repository = component
|
||||
}
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
// Group 返回启动后的 DNF 玩家仓储聚合。
|
||||
// owner/service 只能通过该聚合访问账号、角色、背包、技能、设置和 packet 模板。
|
||||
func (r *Runtime) Group() (dnfrepo.Group, bool) {
|
||||
if r == nil || r.Repository == nil {
|
||||
return dnfrepo.Group{}, false
|
||||
}
|
||||
return r.Repository.Group()
|
||||
}
|
||||
|
||||
// NewRepoComponent 按 DNF 配置打开 MySQL 连接池并创建仓储组件。
|
||||
// 连接池由返回的组件在 Stop 阶段关闭。
|
||||
func NewRepoComponent(env *appkit.Env, cfg RepositoryConfig) (*RepoComponent, error) {
|
||||
envCfg := Config{Repository: cfg}
|
||||
envCfg.Normalize(env)
|
||||
if err := envCfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg = envCfg.Repository
|
||||
return newRepoComponent(env, cfg)
|
||||
}
|
||||
|
||||
func newRepoComponent(env *appkit.Env, cfg RepositoryConfig) (*RepoComponent, error) {
|
||||
if openSQL == nil {
|
||||
return nil, fmt.Errorf("%w: sql opener is required", ErrConfigInvalid)
|
||||
}
|
||||
driver := strings.ToLower(strings.TrimSpace(cfg.Driver))
|
||||
dataSource := cfg.MySQLDSN
|
||||
if driver == "sqlite" {
|
||||
dataSource = filepath.Clean(cfg.SQLitePath)
|
||||
parent := filepath.Dir(dataSource)
|
||||
if parent != "." && parent != "" {
|
||||
if err := os.MkdirAll(parent, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create sqlite repository directory: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
db, err := openSQL(driver, dataSource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("%w: sql db is nil", ErrConfigInvalid)
|
||||
}
|
||||
if driver == "sqlite" {
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
} else {
|
||||
db.SetMaxOpenConns(cfg.MySQLMaxOpenConns)
|
||||
db.SetMaxIdleConns(cfg.MySQLMaxIdleConns)
|
||||
db.SetConnMaxLifetime(cfg.connMaxLife())
|
||||
}
|
||||
|
||||
var redisExec *platformdb.RedigoExecutor
|
||||
if cfg.RedisEnabled {
|
||||
redisExec = platformdb.NewRedigoExecutor(platformdb.RedigoOptions{
|
||||
Address: cfg.RedisAddress,
|
||||
Password: cfg.RedisPassword,
|
||||
DB: cfg.RedisDB,
|
||||
PoolSize: cfg.RedisPoolSize,
|
||||
Timeout: cfg.redisTimeout(),
|
||||
})
|
||||
}
|
||||
var component repositoryLifecycle
|
||||
if driver == "sqlite" {
|
||||
component = sqliterepo.NewComponent(db, dataSource)
|
||||
} else {
|
||||
var manager *servergroup.Manager
|
||||
if env != nil {
|
||||
manager = env.ServerGroup
|
||||
}
|
||||
if manager == nil && cfg.ServerGroupPlanFile != "" {
|
||||
var loadErr error
|
||||
manager, loadErr = loadServerGroupPlan(cfg.ServerGroupPlanFile)
|
||||
if loadErr != nil {
|
||||
_ = db.Close()
|
||||
return nil, loadErr
|
||||
}
|
||||
}
|
||||
component = mysql.NewComponentFromServerGroup(mysql.SQLDBAdapter{DB: db}, manager, mysql.ComponentOptions{
|
||||
ShardID: cfg.ShardID,
|
||||
TablePrefix: cfg.TablePrefix,
|
||||
AutoCreateSchema: cfg.AutoCreateSchema,
|
||||
IncludeCSharpLegacySchema: cfg.CSharpLegacySchema,
|
||||
CreateDatabases: cfg.CreateDatabases,
|
||||
Now: time.Now,
|
||||
})
|
||||
}
|
||||
return &RepoComponent{
|
||||
component: component,
|
||||
driver: driver,
|
||||
db: db,
|
||||
redis: redisExec,
|
||||
cacheOpts: dnfrepo.RedisCacheOptions{
|
||||
KeyPrefix: cfg.RedisKeyPrefix,
|
||||
TTL: cfg.redisTTL(),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loadServerGroupPlan 从外部区服计划文件构造只读 manager。
|
||||
// DNF manifest 可能早于框架 servergroup 注入运行,仓储仍必须按同一份 route meta 派生库名。
|
||||
func loadServerGroupPlan(path string) (*servergroup.Manager, error) {
|
||||
store, err := servergroup.NewFileStore(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan, err := store.Load(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return servergroup.New(plan)
|
||||
}
|
||||
|
||||
// Name 返回框架生命周期组件名。
|
||||
func (c *RepoComponent) Name() string {
|
||||
if c == nil || c.component == nil {
|
||||
return "dnf-repository"
|
||||
}
|
||||
return c.component.Name()
|
||||
}
|
||||
|
||||
// Preflight 检查 DNF 仓储启动依赖,不创建 schema。
|
||||
func (c *RepoComponent) Preflight(ctx context.Context) error {
|
||||
if c == nil || c.component == nil {
|
||||
return dnfrepo.ErrRepoMissing
|
||||
}
|
||||
if err := c.component.Preflight(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.pingRedis(ctx)
|
||||
}
|
||||
|
||||
// Start 启动 DNF 仓储组件。
|
||||
func (c *RepoComponent) Start(ctx context.Context) error {
|
||||
if c == nil || c.component == nil {
|
||||
return dnfrepo.ErrRepoMissing
|
||||
}
|
||||
if err := c.component.Start(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.pingRedis(ctx)
|
||||
}
|
||||
|
||||
// Stop 停止 DNF 仓储组件并关闭项目侧 SQL 连接池。
|
||||
func (c *RepoComponent) Stop(ctx context.Context) error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
var errs []error
|
||||
if c.component != nil {
|
||||
errs = append(errs, c.component.Stop(ctx))
|
||||
}
|
||||
if c.db != nil {
|
||||
errs = append(errs, c.db.Close())
|
||||
}
|
||||
if c.redis != nil {
|
||||
errs = append(errs, c.redis.Close())
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Group 返回启动后的 DNF 玩家仓储聚合。
|
||||
func (c *RepoComponent) Group() (dnfrepo.Group, bool) {
|
||||
if c == nil || c.component == nil {
|
||||
return dnfrepo.Group{}, false
|
||||
}
|
||||
group, ok := c.component.Group()
|
||||
if !ok {
|
||||
return dnfrepo.Group{}, false
|
||||
}
|
||||
if c.redis != nil {
|
||||
group = dnfrepo.NewCachedGroup(group, c.redis, c.cacheOpts)
|
||||
}
|
||||
return group, true
|
||||
}
|
||||
|
||||
// Snapshot 返回 DNF 仓储装配状态。
|
||||
func (c *RepoComponent) Snapshot() RepoSnapshot {
|
||||
if c == nil || c.component == nil {
|
||||
return RepoSnapshot{}
|
||||
}
|
||||
snapshot := RepoSnapshot{Driver: c.driver}
|
||||
switch component := c.component.(type) {
|
||||
case *mysql.Component:
|
||||
value := component.Snapshot()
|
||||
snapshot.MySQL = &value
|
||||
case *sqliterepo.Component:
|
||||
value := component.Snapshot()
|
||||
snapshot.SQLite = &value
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func (c *RepoComponent) pingRedis(ctx context.Context) error {
|
||||
if c == nil || c.redis == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := c.redis.Do(ctx, "PING")
|
||||
return err
|
||||
}
|
||||
|
||||
func (c RepositoryConfig) redisTimeout() time.Duration {
|
||||
if c.RedisTimeoutSeconds <= 0 {
|
||||
return 2 * time.Second
|
||||
}
|
||||
return time.Duration(c.RedisTimeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
func (c RepositoryConfig) redisTTL() time.Duration {
|
||||
if c.RedisTTLSeconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(c.RedisTTLSeconds) * time.Second
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dnf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
func TestSQLiteRepoComponentLifecycle(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
component, err := NewRepoComponent(nil, RepositoryConfig{
|
||||
Enabled: true,
|
||||
Driver: "sqlite",
|
||||
SQLitePath: path,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := component.Preflight(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := component.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
group, ok := component.Group()
|
||||
if !ok {
|
||||
t.Fatal("SQLite repository group is unavailable")
|
||||
}
|
||||
if group.CharacterSettlement == nil {
|
||||
t.Fatal("SQLite repository group does not expose the atomic quest/inventory settlement")
|
||||
}
|
||||
if err := group.Account.Save(ctx, repository.AccountRecord{AccountID: "account-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot := component.Snapshot(); snapshot.Driver != "sqlite" || snapshot.SQLite == nil || snapshot.MySQL != nil {
|
||||
t.Fatalf("SQLite component snapshot = %+v", snapshot)
|
||||
}
|
||||
if err := component.Stop(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryConfigKeepsLegacyMySQLDefault(t *testing.T) {
|
||||
cfg := Config{Repository: RepositoryConfig{
|
||||
Enabled: true,
|
||||
MySQLDSN: "root:password@tcp(127.0.0.1:3306)/dnf",
|
||||
ShardID: "9999",
|
||||
TablePrefix: "dnf",
|
||||
}}
|
||||
cfg.Normalize(nil)
|
||||
if cfg.Repository.Driver != "mysql" {
|
||||
t.Fatalf("default repository driver = %q, want mysql", cfg.Repository.Driver)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package logic 负责已认证玩家流量的后端命令分发。
|
||||
//
|
||||
// Logic handler 负责加载和修改 Profile 状态、执行幂等保护、追加 EventLog 并更新投影。
|
||||
// 传输细节留在 gateway,可复用玩法系统放在 internal/runtime。
|
||||
package logic
|
||||
@@ -0,0 +1,289 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/bus"
|
||||
"longheng.io/server/internal/services/asyncpayload"
|
||||
)
|
||||
|
||||
const (
|
||||
evtQMax = 4096
|
||||
evtRetryInterval = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
var (
|
||||
errEvtMissing = errors.New("logic event publisher is missing")
|
||||
errEvtClosed = errors.New("logic event queue is closed")
|
||||
errEvtFull = errors.New("logic event queue is full")
|
||||
)
|
||||
|
||||
type evtItem struct {
|
||||
topic string
|
||||
payload any
|
||||
}
|
||||
|
||||
// eventPub 把 logic 的非关键通知 publish 移出调用方热路径。
|
||||
type eventPub struct {
|
||||
bus bus.Bus
|
||||
logger *slog.Logger
|
||||
timeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
pending []evtItem
|
||||
closed bool
|
||||
flushing bool
|
||||
flushDone chan struct{}
|
||||
|
||||
wake chan struct{}
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newEventPub(eventBus bus.Bus, logger *slog.Logger) *eventPub {
|
||||
if eventBus == nil {
|
||||
return nil
|
||||
}
|
||||
pub := &eventPub{
|
||||
bus: eventBus,
|
||||
logger: logger,
|
||||
timeout: asyncPublishTimeout,
|
||||
wake: make(chan struct{}, 1),
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go pub.run()
|
||||
return pub
|
||||
}
|
||||
|
||||
func (p *eventPub) Publish(topic string, payload any) error {
|
||||
if p == nil || p.bus == nil {
|
||||
return errEvtMissing
|
||||
}
|
||||
item := evtItem{topic: topic, payload: asyncpayload.Clone(payload)}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.closed {
|
||||
return errEvtClosed
|
||||
}
|
||||
if len(p.pending) >= evtQMax {
|
||||
return errEvtFull
|
||||
}
|
||||
p.pending = append(p.pending, item)
|
||||
p.wakeLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *eventPub) Flush(ctx context.Context) error {
|
||||
if p == nil || p.bus == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
for {
|
||||
batch, wait := p.beginFlush()
|
||||
if wait != nil {
|
||||
if err := waitEvtFlush(ctx, wait); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := p.writeBatch(ctx, batch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *eventPub) Close(ctx context.Context) error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := p.closeContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
p.mu.Lock()
|
||||
p.closed = true
|
||||
stop := p.stop
|
||||
done := p.done
|
||||
p.mu.Unlock()
|
||||
if stop != nil {
|
||||
p.once.Do(func() {
|
||||
close(stop)
|
||||
})
|
||||
}
|
||||
if stop != nil && done != nil {
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return p.Flush(ctx)
|
||||
}
|
||||
|
||||
func (p *eventPub) run() {
|
||||
defer close(p.done)
|
||||
retry := time.NewTimer(time.Hour)
|
||||
stopEvtTimer(retry)
|
||||
var retryC <-chan time.Time
|
||||
defer stopEvtTimer(retry)
|
||||
for {
|
||||
select {
|
||||
case <-p.wake:
|
||||
if err := p.flushBackground(); err != nil {
|
||||
resetEvtTimer(retry, evtRetryInterval)
|
||||
retryC = retry.C
|
||||
} else {
|
||||
retryC = nil
|
||||
}
|
||||
case <-retryC:
|
||||
retryC = nil
|
||||
if err := p.flushBackground(); err != nil {
|
||||
resetEvtTimer(retry, evtRetryInterval)
|
||||
retryC = retry.C
|
||||
}
|
||||
case <-p.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *eventPub) flushBackground() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), p.timeoutOrDefault())
|
||||
err := p.Flush(ctx)
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *eventPub) beginFlush() ([]evtItem, <-chan struct{}) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.flushing {
|
||||
return nil, p.flushDone
|
||||
}
|
||||
if len(p.pending) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
batch := cloneEvtItems(p.pending)
|
||||
p.pending = nil
|
||||
p.flushing = true
|
||||
p.flushDone = make(chan struct{})
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
func (p *eventPub) writeBatch(ctx context.Context, batch []evtItem) error {
|
||||
for idx, item := range batch {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
p.finishFlush(batch[idx:])
|
||||
return err
|
||||
}
|
||||
if err := p.bus.Publish(ctx, item.topic, asyncpayload.Clone(item.payload)); err != nil {
|
||||
if p.logger != nil {
|
||||
p.logger.Error("logic async publish failed", "topic", item.topic, "error", err)
|
||||
}
|
||||
p.finishFlush(batch[idx:])
|
||||
return err
|
||||
}
|
||||
}
|
||||
p.finishFlush(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *eventPub) finishFlush(failed []evtItem) {
|
||||
p.mu.Lock()
|
||||
if len(failed) > 0 {
|
||||
restored := cloneEvtItems(failed)
|
||||
restored = append(restored, p.pending...)
|
||||
p.pending = restored
|
||||
}
|
||||
done := p.flushDone
|
||||
p.flushing = false
|
||||
p.flushDone = nil
|
||||
p.mu.Unlock()
|
||||
if done != nil {
|
||||
close(done)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *eventPub) wakeLocked() {
|
||||
select {
|
||||
case p.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func cloneEvtItems(in []evtItem) []evtItem {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]evtItem, len(in))
|
||||
for idx, item := range in {
|
||||
out[idx] = evtItem{
|
||||
topic: item.topic,
|
||||
payload: asyncpayload.Clone(item.payload),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *eventPub) timeoutOrDefault() time.Duration {
|
||||
if p == nil || p.timeout <= 0 {
|
||||
return asyncPublishTimeout
|
||||
}
|
||||
return p.timeout
|
||||
}
|
||||
|
||||
func (p *eventPub) closeContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
return ctx, func() {}
|
||||
}
|
||||
return context.WithTimeout(ctx, p.timeoutOrDefault())
|
||||
}
|
||||
|
||||
func stopEvtTimer(timer *time.Timer) {
|
||||
if timer == nil {
|
||||
return
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resetEvtTimer(timer *time.Timer, d time.Duration) {
|
||||
if d <= 0 {
|
||||
d = evtRetryInterval
|
||||
}
|
||||
stopEvtTimer(timer)
|
||||
timer.Reset(d)
|
||||
}
|
||||
|
||||
func ctxErr(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func waitEvtFlush(ctx context.Context, done <-chan struct{}) error {
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/dispatch"
|
||||
)
|
||||
|
||||
func (s *Service) observeLoopHandler(meta dispatch.HandlerMeta, msgID uint32, duration time.Duration, err error) {
|
||||
if s == nil || s.metrics == nil {
|
||||
return
|
||||
}
|
||||
result := "ok"
|
||||
if err != nil {
|
||||
result = "error"
|
||||
}
|
||||
labels := map[string]string{
|
||||
"handler": handlerMetricName(meta, msgID),
|
||||
"msg_id": fmt.Sprint(msgID),
|
||||
"result": result,
|
||||
}
|
||||
s.incMetric(MetricLogicPlayerLoopHandlerCallsTotal, labels)
|
||||
s.setMetricGaugeMax(MetricLogicPlayerLoopHandlerLatencyMaxMillis, labels, durationMillis(duration))
|
||||
}
|
||||
|
||||
func handlerMetricName(meta dispatch.HandlerMeta, msgID uint32) string {
|
||||
if name := strings.TrimSpace(meta.Name); name != "" {
|
||||
return name
|
||||
}
|
||||
if msgID != 0 {
|
||||
return fmt.Sprintf("msg_%d", msgID)
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func durationMillis(duration time.Duration) int64 {
|
||||
if duration <= 0 {
|
||||
return 0
|
||||
}
|
||||
millis := duration.Milliseconds()
|
||||
if millis == 0 {
|
||||
return 1
|
||||
}
|
||||
return millis
|
||||
}
|
||||
|
||||
func (s *Service) setMetricGaugeMax(name string, labels map[string]string, value int64) {
|
||||
if s == nil || s.metrics == nil || name == "" {
|
||||
return
|
||||
}
|
||||
out := s.metricLabels(labels)
|
||||
key := metricSampleKey(name, out)
|
||||
s.handlerMetricMu.Lock()
|
||||
if s.handlerLatencyMax == nil {
|
||||
s.handlerLatencyMax = make(map[string]int64)
|
||||
}
|
||||
current, ok := s.handlerLatencyMax[key]
|
||||
if ok && value <= current {
|
||||
s.handlerMetricMu.Unlock()
|
||||
return
|
||||
}
|
||||
s.handlerLatencyMax[key] = value
|
||||
s.handlerMetricMu.Unlock()
|
||||
s.metrics.SetGauge(name, out, value)
|
||||
}
|
||||
|
||||
func metricSampleKey(name string, labels map[string]string) string {
|
||||
if len(labels) == 0 {
|
||||
return name
|
||||
}
|
||||
keys := make([]string, 0, len(labels))
|
||||
for key := range labels {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(name)
|
||||
for _, key := range keys {
|
||||
b.WriteByte('\x00')
|
||||
b.WriteString(key)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(labels[key])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package handlers
|
||||
|
||||
// Code generated by routegen; DO NOT EDIT.
|
||||
|
||||
import (
|
||||
"longheng.io/server/internal/platform/dispatch"
|
||||
"longheng.io/server/internal/reference/player"
|
||||
"longheng.io/server/pkg/protocol"
|
||||
)
|
||||
|
||||
var genHandlerMetas = []dispatch.HandlerMeta{
|
||||
{MsgID: protocol.MessageEnterWorld, Name: "EnterWorld", Description: "Loads the active profile and returns base role state.", AuthRequired: true, PlayerScoped: true, Stateful: true, Authorized: true, Idempotent: true, RouteGroup: "player"},
|
||||
{MsgID: protocol.MessageAddCurrency, Name: "AddCurrency", Description: "Adds currency to the active profile and returns the new balance.", AuthRequired: true, PlayerScoped: true, Stateful: true, Authorized: true, Idempotent: true, RouteGroup: "player", ProfileOperation: string(player.ProfileOperationAddCurrency), CriticalEvent: true, Durability: "eventlog", DurabilityOwner: "profile"},
|
||||
{MsgID: protocol.MessageReadMail, Name: "ReadMail", Description: "Marks one mailbox item read and returns mailbox unread count.", AuthRequired: true, PlayerScoped: true, Stateful: true, Authorized: true, Idempotent: true, RouteGroup: "player", CriticalEvent: true, Durability: "eventlog", DurabilityOwner: "profile"},
|
||||
}
|
||||
|
||||
func GeneratedHandlerMetas() []dispatch.HandlerMeta {
|
||||
return append([]dispatch.HandlerMeta(nil), genHandlerMetas...)
|
||||
}
|
||||
|
||||
func Default(ctx Context) []Handler {
|
||||
return []Handler{
|
||||
EnterWorld{Players: ctx.Players},
|
||||
AddCurrency{Players: ctx.Players},
|
||||
ReadMail{Players: ctx.Players},
|
||||
}
|
||||
}
|
||||
|
||||
func (h EnterWorld) Meta() dispatch.HandlerMeta {
|
||||
return genHandlerMetas[0]
|
||||
}
|
||||
|
||||
func (h AddCurrency) Meta() dispatch.HandlerMeta {
|
||||
return genHandlerMetas[1]
|
||||
}
|
||||
|
||||
func (h ReadMail) Meta() dispatch.HandlerMeta {
|
||||
return genHandlerMetas[2]
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/dispatch"
|
||||
"longheng.io/server/internal/reference/player"
|
||||
"longheng.io/server/pkg/protocol"
|
||||
)
|
||||
|
||||
type EnterWorld struct {
|
||||
Players *player.Module
|
||||
}
|
||||
|
||||
func (h EnterWorld) Handle(ctx context.Context, req dispatch.Request) (dispatch.Response, error) {
|
||||
if h.Players == nil {
|
||||
return dispatch.Response{}, protocol.NewError(protocol.CodeUnavailable, "player module unavailable")
|
||||
}
|
||||
profile, err := h.Players.LoadOrCreate(ctx, req.AccountID)
|
||||
if err != nil {
|
||||
return dispatch.Response{}, protocol.WrapError(protocol.CodeInternal, "load player profile", err)
|
||||
}
|
||||
return dispatch.Response{
|
||||
PacketID: protocol.PacketIDReqResp,
|
||||
MsgID: req.MsgID,
|
||||
Body: protocol.EncodeEnterWorldResponse(protocol.EnterWorldResponse{
|
||||
AccountID: profile.AccountID,
|
||||
RoleID: profile.RoleID,
|
||||
Name: profile.Name,
|
||||
Level: profile.Level,
|
||||
Gold: profile.Currencies["gold"],
|
||||
Diamond: profile.Currencies["diamond"],
|
||||
InventoryCount: len(profile.Inventory),
|
||||
QuestCount: len(profile.Quests),
|
||||
UnreadMail: countUnreadMail(profile.Mailbox),
|
||||
ServerTime: time.Now().Unix(),
|
||||
}),
|
||||
Note: "EnterWorld",
|
||||
}, nil
|
||||
}
|
||||
|
||||
type AddCurrency struct {
|
||||
Players *player.Module
|
||||
}
|
||||
|
||||
func (h AddCurrency) Handle(ctx context.Context, req dispatch.Request) (dispatch.Response, error) {
|
||||
if h.Players == nil {
|
||||
return dispatch.Response{}, protocol.NewError(protocol.CodeUnavailable, "player module unavailable")
|
||||
}
|
||||
body := protocol.DecodeAddCurrencyRequest(req.Body)
|
||||
body.Currency = strings.TrimSpace(body.Currency)
|
||||
if body.Currency == "" {
|
||||
return dispatch.Response{}, protocol.NewError(protocol.CodeBadRequest, "currency is required")
|
||||
}
|
||||
profile, err := h.Players.AddCurrency(ctx, req.AccountID, body.Currency, body.Delta)
|
||||
if err != nil {
|
||||
return dispatch.Response{}, protocol.WrapError(protocol.CodeInternal, "add currency", err)
|
||||
}
|
||||
return dispatch.Response{
|
||||
PacketID: protocol.PacketIDReqResp,
|
||||
MsgID: req.MsgID,
|
||||
Body: protocol.EncodeAddCurrencyResponse(protocol.AddCurrencyResponse{
|
||||
AccountID: profile.AccountID,
|
||||
Currency: body.Currency,
|
||||
Balance: profile.Currencies[body.Currency],
|
||||
ServerTime: time.Now().Unix(),
|
||||
}),
|
||||
Note: "AddCurrency",
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ReadMail struct {
|
||||
Players *player.Module
|
||||
}
|
||||
|
||||
func (h ReadMail) Handle(ctx context.Context, req dispatch.Request) (dispatch.Response, error) {
|
||||
if h.Players == nil {
|
||||
return dispatch.Response{}, protocol.NewError(protocol.CodeUnavailable, "player module unavailable")
|
||||
}
|
||||
body := protocol.DecodeReadMailRequest(req.Body)
|
||||
body.MailID = strings.TrimSpace(body.MailID)
|
||||
if body.MailID == "" {
|
||||
return dispatch.Response{}, protocol.NewError(protocol.CodeBadRequest, "mail_id is required")
|
||||
}
|
||||
profile, found, changed, err := h.Players.MarkMailRead(ctx, req.AccountID, body.MailID)
|
||||
if err != nil {
|
||||
return dispatch.Response{}, protocol.WrapError(protocol.CodeInternal, "read mail", err)
|
||||
}
|
||||
if !found {
|
||||
return dispatch.Response{}, protocol.Errorf(protocol.CodeNotFound, "mail_id %s not found", body.MailID)
|
||||
}
|
||||
return dispatch.Response{
|
||||
PacketID: protocol.PacketIDReqResp,
|
||||
MsgID: req.MsgID,
|
||||
Body: protocol.EncodeReadMailResponse(protocol.ReadMailResponse{
|
||||
AccountID: profile.AccountID,
|
||||
MailID: body.MailID,
|
||||
Changed: changed,
|
||||
UnreadMail: countUnreadMail(profile.Mailbox),
|
||||
ServerTime: time.Now().Unix(),
|
||||
}),
|
||||
Note: "ReadMail",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func countUnreadMail(mailbox []player.MailRecord) int {
|
||||
total := 0
|
||||
for _, mail := range mailbox {
|
||||
if mail.State == "unread" {
|
||||
total++
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/platform/dispatch"
|
||||
"longheng.io/server/internal/reference/player"
|
||||
"longheng.io/server/pkg/protocol"
|
||||
)
|
||||
|
||||
//go:generate go run ../../../../cmd/routegen -schema ../../../../configs/handlers/logic.json -out handler_schema_gen.go -module longheng.io/server
|
||||
|
||||
type Context struct {
|
||||
Players *player.Module
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
Meta() dispatch.HandlerMeta
|
||||
Handle(context.Context, dispatch.Request) (dispatch.Response, error)
|
||||
}
|
||||
|
||||
func RegisterAll(mux *dispatch.Mux, handlers ...Handler) error {
|
||||
if mux == nil {
|
||||
return fmt.Errorf("dispatch mux is required")
|
||||
}
|
||||
seen := make(map[uint32]string, len(handlers))
|
||||
for _, existing := range mux.Snapshot() {
|
||||
seen[existing.MsgID] = handlerName(existing)
|
||||
}
|
||||
for _, handler := range handlers {
|
||||
if handler == nil {
|
||||
return fmt.Errorf("handler is nil")
|
||||
}
|
||||
meta := handler.Meta()
|
||||
if meta.MsgID == 0 {
|
||||
return fmt.Errorf("handler %s has empty msg_id", meta.Name)
|
||||
}
|
||||
name := handlerName(meta)
|
||||
if existing, ok := seen[meta.MsgID]; ok {
|
||||
return fmt.Errorf("duplicate msg_id %d for handlers %s and %s", meta.MsgID, existing, name)
|
||||
}
|
||||
seen[meta.MsgID] = name
|
||||
}
|
||||
for _, handler := range handlers {
|
||||
meta := handler.Meta()
|
||||
current := handler
|
||||
if err := mux.HandleMetaChecked(meta, func(ctx context.Context, req dispatch.Request) (dispatch.Response, error) {
|
||||
req.AccountID = strings.TrimSpace(req.AccountID)
|
||||
if meta.PlayerScoped && req.AccountID == "" {
|
||||
return dispatch.Response{}, protocol.NewError(protocol.CodeBadRequest, "account_id is required")
|
||||
}
|
||||
return current.Handle(ctx, req)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handlerName(meta dispatch.HandlerMeta) string {
|
||||
if meta.Name != "" {
|
||||
return meta.Name
|
||||
}
|
||||
return fmt.Sprintf("msg_%d", meta.MsgID)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/platform/dispatch"
|
||||
"longheng.io/server/internal/platform/idempotency"
|
||||
"longheng.io/server/pkg/contracts"
|
||||
"longheng.io/server/pkg/protocol"
|
||||
)
|
||||
|
||||
func (s *Service) cachedIdemResp(ctx context.Context, packet contracts.GatewayClientPacket, decision idempotency.Decision) (contracts.LogicPlayerResponse, bool, error) {
|
||||
if s == nil || strings.TrimSpace(decision.Key) == "" {
|
||||
return contracts.LogicPlayerResponse{}, false, nil
|
||||
}
|
||||
var cacheErr error
|
||||
if s.responses != nil {
|
||||
response, ok, err := s.responses.Get(ctx, decision.Key)
|
||||
if ok {
|
||||
return replayIdemResp(packet, response), true, nil
|
||||
}
|
||||
cacheErr = err
|
||||
}
|
||||
// response cache 只是热缓存;miss 或故障时从幂等权威后端读取同事务提交的结果。
|
||||
if s.idempotency == nil {
|
||||
return contracts.LogicPlayerResponse{}, false, cacheErr
|
||||
}
|
||||
payload, ok, resultErr := s.idempotency.LookupResult(ctx, decision)
|
||||
if resultErr != nil || !ok {
|
||||
return contracts.LogicPlayerResponse{}, false, errors.Join(cacheErr, resultErr)
|
||||
}
|
||||
var response contracts.LogicPlayerResponse
|
||||
if err := json.Unmarshal(payload, &response); err != nil {
|
||||
return contracts.LogicPlayerResponse{}, false, fmt.Errorf("decode idempotency result: %w", err)
|
||||
}
|
||||
if s.responses != nil {
|
||||
// 权威结果读取成功后尽力回填热缓存;回填失败不能让已提交结果再次变成不可用。
|
||||
_ = s.responses.Store(ctx, decision.Key, response)
|
||||
}
|
||||
return replayIdemResp(packet, response), true, nil
|
||||
}
|
||||
|
||||
func replayIdemResp(packet contracts.GatewayClientPacket, response contracts.LogicPlayerResponse) contracts.LogicPlayerResponse {
|
||||
// 重放响应只复用业务结果,目标 gateway/session/sequence 必须按本次包重写,避免旧连接收到新响应。
|
||||
response.TargetGatewayNodeID = packet.GatewayNodeID
|
||||
response.SessionID = packet.SessionID
|
||||
response.AccountID = packet.AccountID
|
||||
response.Sequence = packet.Sequence
|
||||
response.WireFormat = packet.WireFormat
|
||||
response.Metadata = replayIdemMetadata(response.Metadata, packet.Metadata)
|
||||
return response
|
||||
}
|
||||
|
||||
func replayIdemMetadata(stored, current map[string]string) map[string]string {
|
||||
// handler 产生的响应 metadata 属于可重放结果;只有链路跟踪字段应跟随本次请求。
|
||||
metadata := protocol.CloneFrameMetadata(stored)
|
||||
current = protocol.CloneFrameMetadata(current)
|
||||
for _, key := range []string{
|
||||
protocol.FrameMetadataTraceParent,
|
||||
protocol.FrameMetadataTraceState,
|
||||
protocol.FrameMetadataBaggage,
|
||||
} {
|
||||
delete(metadata, key)
|
||||
if value := current[key]; value != "" {
|
||||
if metadata == nil {
|
||||
metadata = make(map[string]string, 3)
|
||||
}
|
||||
metadata[key] = value
|
||||
}
|
||||
}
|
||||
if len(metadata) == 0 {
|
||||
return nil
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func encodeIdemResp(response contracts.LogicPlayerResponse) ([]byte, error) {
|
||||
payload, err := json.Marshal(clonePlayerResp(response))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode idempotency result: %w", err)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (s *Service) storeIdemResp(ctx context.Context, decision idempotency.Decision, response contracts.LogicPlayerResponse) error {
|
||||
if s == nil || s.responses == nil || strings.TrimSpace(decision.Key) == "" {
|
||||
return nil
|
||||
}
|
||||
return s.responses.Store(ctx, decision.Key, response)
|
||||
}
|
||||
|
||||
func (s *Service) shouldProtectPacket(meta dispatch.HandlerMeta) bool {
|
||||
return meta.Idempotent || meta.PlayerScoped || meta.Stateful || strings.TrimSpace(meta.ProfileOperation) != ""
|
||||
}
|
||||
|
||||
func (s *Service) reservePktIdem(ctx context.Context, packet contracts.GatewayClientPacket) (idempotency.Reservation, idempotency.Decision, error) {
|
||||
if s.idempotency == nil {
|
||||
return idempotency.Reservation{}, idempotency.Decision{Status: idempotency.StatusAccepted}, nil
|
||||
}
|
||||
return s.idempotency.Begin(ctx, packetIdemRequest(packet))
|
||||
}
|
||||
|
||||
func packetIdemRequest(packet contracts.GatewayClientPacket) idempotency.Request {
|
||||
scope := "logic:msg:" + strconv.FormatUint(uint64(packet.MsgID), 10)
|
||||
accountID := strings.ToLower(strings.TrimSpace(packet.AccountID))
|
||||
sessionID := strings.ToLower(strings.TrimSpace(packet.SessionID))
|
||||
rawKey := strings.TrimSpace(packet.IdempotencyKey)
|
||||
clientRequestID := strings.TrimSpace(packet.ClientRequestID)
|
||||
key := ""
|
||||
if clientRequestID != "" {
|
||||
// 稳定的客户端请求 ID 是跨重连业务身份;session 和 sequence 只是当前传输属性。
|
||||
key = idempotency.CanonicalKey("logic-packet-client-request-v1", scope, accountID, clientRequestID)
|
||||
} else if rawKey != "" {
|
||||
// 旧 gateway 的显式 key 未包含 session;logic 必须再做会话隔离,避免重连后 sequence 重置命中旧结果。
|
||||
key = idempotency.CanonicalKey("logic-packet-explicit-v1", scope, accountID, sessionID, rawKey)
|
||||
} else if packet.Sequence > 0 {
|
||||
key = idempotency.CanonicalKey(
|
||||
"logic-packet-fallback-v1",
|
||||
scope,
|
||||
accountID,
|
||||
sessionID,
|
||||
strconv.FormatInt(int64(packet.PacketID), 10),
|
||||
strconv.FormatUint(uint64(packet.MsgID), 10),
|
||||
strconv.FormatUint(packet.Sequence, 10),
|
||||
)
|
||||
}
|
||||
return idempotency.Request{
|
||||
Scope: scope,
|
||||
Subject: accountID,
|
||||
Session: sessionID,
|
||||
Key: key,
|
||||
Fingerprint: idempotency.CanonicalKey(
|
||||
"logic-packet-fingerprint-v1",
|
||||
strconv.FormatInt(int64(packet.PacketID), 10),
|
||||
strconv.FormatUint(uint64(packet.MsgID), 10),
|
||||
strconv.FormatBool(packet.Compressed),
|
||||
string(packet.Body),
|
||||
),
|
||||
Sequence: packet.Sequence,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"longheng.io/server/internal/modules/samples/world"
|
||||
appkit "longheng.io/server/internal/platform/app"
|
||||
"longheng.io/server/internal/platform/dispatch"
|
||||
"longheng.io/server/internal/platform/integrity"
|
||||
modulemgr "longheng.io/server/internal/platform/module"
|
||||
"longheng.io/server/internal/platform/notification"
|
||||
"longheng.io/server/internal/platform/readmodel"
|
||||
"longheng.io/server/internal/platform/runtimeguard"
|
||||
"longheng.io/server/internal/platform/scripthost"
|
||||
"longheng.io/server/internal/platform/secrets"
|
||||
"longheng.io/server/internal/reference/player"
|
||||
"longheng.io/server/internal/runtime/leaderboard"
|
||||
"longheng.io/server/internal/runtime/moderation"
|
||||
"longheng.io/server/internal/runtime/notice"
|
||||
"longheng.io/server/internal/runtime/redeem"
|
||||
logichandlers "longheng.io/server/internal/services/logic/handlers"
|
||||
)
|
||||
|
||||
// ErrManifestMemStore 表示默认 manifest 在分布式环境仍使用单节点内存后端。
|
||||
var ErrManifestMemStore = errors.New("logic default manifest uses single-node runtime backend")
|
||||
|
||||
// GameManifestProvider 根据框架环境构建游戏 manifest。
|
||||
type GameManifestProvider func(*appkit.Env) (GameManifest, error)
|
||||
|
||||
// ExtensionPoints 是项目侧接入平台扩展包的 wiring 表面。
|
||||
// 它刻意放在 GameManifest 中,让项目能挂接具体 provider,
|
||||
// 同时避免 platform 包反向 import services 或游戏专属 runtime 代码。
|
||||
type ExtensionPoints struct {
|
||||
ScriptHost scripthost.Host
|
||||
ScriptRuntime scripthost.Runtime
|
||||
Secrets secrets.Provider
|
||||
Push notification.PushProvider
|
||||
Mail notification.MailProvider
|
||||
Integrity integrity.ActionValidator
|
||||
ReplayDigest integrity.ReplayDigestProvider
|
||||
RiskScoreProvider integrity.RiskScoreProvider
|
||||
}
|
||||
|
||||
// GameManifest 描述 logic/playerd 要装配的模块、运行时和 handler。
|
||||
type GameManifest struct {
|
||||
Modules []modulemgr.Module
|
||||
Players *player.Module
|
||||
World *world.Module
|
||||
Accounts *AccountManager
|
||||
Leaderboards leaderboard.Runtime
|
||||
Sanctions moderation.SanctionStore
|
||||
Notices notice.Service
|
||||
Redeems redeem.Service
|
||||
AdminRoutes []AdminRouteRegistrar
|
||||
RegisterHandlers func(*dispatch.Mux) error
|
||||
RegisterReadModels func(*readmodel.AdminRegistry)
|
||||
Extensions ExtensionPoints
|
||||
}
|
||||
|
||||
// BlankManifest 返回不装配业务模块的 GameManifest。
|
||||
// 新项目应以它为起点,通过 Modules / Players / RegisterHandlers 注册自己的
|
||||
// player、world 和 runtime 类型。
|
||||
func BlankManifest(_ *appkit.Env) (GameManifest, error) {
|
||||
return GameManifest{}, nil
|
||||
}
|
||||
|
||||
// DemoGameManifest 装配内置示例模块用于本地演示。
|
||||
// 即使本地配置为了 route metadata smoke 保持 topology 开启,示例 runtime 也按单节点处理。
|
||||
func DemoGameManifest(env *appkit.Env) (GameManifest, error) {
|
||||
if env == nil {
|
||||
env = &appkit.Env{}
|
||||
}
|
||||
demoEnv := *env
|
||||
demoEnv.Config.Topology.Enabled = false
|
||||
return DefaultGameManifest(&demoEnv)
|
||||
}
|
||||
|
||||
// DefaultPlayerDManifest 是 playerd 的参考 manifest。
|
||||
// 它只装配示例 Player/Profile 模块,不继承仅属于 logic 的 world、leaderboard、notice 或 redeem 示例 runtime。
|
||||
func DefaultPlayerDManifest(env *appkit.Env) (GameManifest, error) {
|
||||
profileStore, summaryStore := newPlayerStores(env.Config.ProfileStore, env)
|
||||
playerModule, err := player.NewWithStoresChecked(env.Logger, profileStore, summaryStore)
|
||||
if err != nil {
|
||||
return GameManifest{}, err
|
||||
}
|
||||
if env.EventLog != nil {
|
||||
playerModule.UseEventLog(env.EventLog, env.Config.EventLog.Strict)
|
||||
}
|
||||
return GameManifest{
|
||||
Modules: []modulemgr.Module{
|
||||
playerModule,
|
||||
},
|
||||
Players: playerModule,
|
||||
RegisterHandlers: func(mux *dispatch.Mux) error {
|
||||
return logichandlers.RegisterAll(mux, logichandlers.Default(logichandlers.Context{Players: playerModule})...)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DefaultGameManifest 创建内置演示用 logic manifest。
|
||||
func DefaultGameManifest(env *appkit.Env) (GameManifest, error) {
|
||||
profileStore, summaryStore := newPlayerStores(env.Config.ProfileStore, env)
|
||||
playerModule, err := player.NewWithStoresChecked(env.Logger, profileStore, summaryStore)
|
||||
if err != nil {
|
||||
return GameManifest{}, err
|
||||
}
|
||||
if env.EventLog != nil {
|
||||
playerModule.UseEventLog(env.EventLog, env.Config.EventLog.Strict)
|
||||
}
|
||||
worldModule := world.New(env.Logger)
|
||||
leaderboards := leaderboard.New(leaderboard.Options{Name: env.Config.Service.Name + "-leaderboards"})
|
||||
sanctions := moderation.NewMemorySanctionStore()
|
||||
profile := runtimeguard.ProfileFromEnvironment(env.Config.Service.Environment, env.Config.Topology.Enabled)
|
||||
notices := notice.Service{
|
||||
Store: notice.NewMemoryStore(),
|
||||
EventLog: env.EventLog,
|
||||
}
|
||||
if env.Bus != nil {
|
||||
publishers := []notice.LivePublisher{notice.NewBusPublisher(env.Bus)}
|
||||
if env.OnlinePush != nil {
|
||||
publishers = append(publishers, notice.NewOnlinePushPublisher(env.OnlinePush))
|
||||
} else {
|
||||
publishers = append(publishers, notice.NewGatewayPushPublisher(env.Bus))
|
||||
}
|
||||
notices.Publisher = notice.NewAsyncLivePublisher(notice.NewFanoutPublisher(publishers...))
|
||||
}
|
||||
redeems := redeem.Service{
|
||||
Store: redeem.NewMemoryStore(),
|
||||
EventLog: env.EventLog,
|
||||
}
|
||||
manifest := GameManifest{
|
||||
Modules: []modulemgr.Module{
|
||||
playerModule,
|
||||
worldModule,
|
||||
},
|
||||
Players: playerModule,
|
||||
World: worldModule,
|
||||
Leaderboards: leaderboards,
|
||||
Sanctions: sanctions,
|
||||
Notices: notices,
|
||||
Redeems: redeems,
|
||||
AdminRoutes: []AdminRouteRegistrar{
|
||||
NoticeAdminRouteRegistrar(notices),
|
||||
RedeemAdminRouteRegistrar(redeems),
|
||||
},
|
||||
RegisterHandlers: func(mux *dispatch.Mux) error {
|
||||
return logichandlers.RegisterAll(mux, logichandlers.Default(logichandlers.Context{Players: playerModule})...)
|
||||
},
|
||||
}
|
||||
if profile.Distributed {
|
||||
return manifest, fmt.Errorf("%w: notice and redeem default stores are memory-only; provide a project manifest with durable stores",
|
||||
ErrManifestMemStore)
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"longheng.io/server/internal/platform/eventloop"
|
||||
"longheng.io/server/internal/platform/metrics"
|
||||
)
|
||||
|
||||
const (
|
||||
// MetricLogicPacketsTotal 记录 logic 收到的业务包总数。
|
||||
MetricLogicPacketsTotal = "logic_packets_total"
|
||||
// MetricLogicResponsesTotal 记录 logic 返回响应总数。
|
||||
MetricLogicResponsesTotal = "logic_responses_total"
|
||||
// MetricLogicPlayerLoopHandlerCallsTotal 记录玩家队列 handler 调用总数。
|
||||
MetricLogicPlayerLoopHandlerCallsTotal = "logic_playerloop_handler_calls_total"
|
||||
// MetricLogicPlayerLoopHandlerLatencyMaxMillis 记录玩家队列 handler 最大延迟毫秒值。
|
||||
MetricLogicPlayerLoopHandlerLatencyMaxMillis = "logic_playerloop_handler_latency_max_milliseconds"
|
||||
)
|
||||
|
||||
func (s *Service) regRuntimeMetrics() {
|
||||
if s == nil || s.metrics == nil {
|
||||
return
|
||||
}
|
||||
labels := map[string]string{
|
||||
"service": s.metricServiceLabel,
|
||||
"node_id": s.nodeID,
|
||||
}
|
||||
if s.dispatcher != nil {
|
||||
s.metrics.RegisterObserver(s.metricServiceLabel+"-dispatch", func(reg *metrics.Registry) {
|
||||
s.dispatcher.ObserveMetrics(reg, labels)
|
||||
})
|
||||
}
|
||||
if s.playerLoops != nil {
|
||||
s.metrics.RegisterObserver(s.metricServiceLabel+"-playerloops", func(reg *metrics.Registry) {
|
||||
s.playerLoops.ObserveMetrics(reg, labels)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) regEventLoopMetrics(loop *eventloop.Loop) {
|
||||
if s == nil || s.metrics == nil || loop == nil {
|
||||
return
|
||||
}
|
||||
labels := map[string]string{
|
||||
"service": s.metricServiceLabel,
|
||||
"node_id": s.nodeID,
|
||||
}
|
||||
s.metrics.RegisterObserver(s.metricServiceLabel+"-metric-eventloop", func(reg *metrics.Registry) {
|
||||
loop.ObserveMetrics(reg, labels)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/eventlog"
|
||||
"longheng.io/server/internal/platform/httpx"
|
||||
"longheng.io/server/internal/reference/player"
|
||||
"longheng.io/server/internal/runtime/leaderboard"
|
||||
"longheng.io/server/internal/runtime/moderation"
|
||||
)
|
||||
|
||||
type lbRepairReq struct {
|
||||
Action string `json:"action"`
|
||||
LeaderboardID string `json:"leaderboard_id"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
SortOrder string `json:"sort_order,omitempty"`
|
||||
Operator string `json:"operator,omitempty"`
|
||||
MaxSize int `json:"max_size,omitempty"`
|
||||
Score int64 `json:"score,omitempty"`
|
||||
Subscore int64 `json:"subscore,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type sanctionRequest struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Subject string `json:"subject"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Until time.Time `json:"until,omitempty"`
|
||||
TTLSecond int64 `json:"ttl_seconds,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
const maxOpsAdminJSONBytes = 1 << 20
|
||||
|
||||
func (s *Service) registerOpsRoutes(mux *http.ServeMux, prefix string) {
|
||||
if mux == nil {
|
||||
return
|
||||
}
|
||||
opsPrefix := prefix + "/ops"
|
||||
dangerous := func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.adminDangerous("", next)
|
||||
}
|
||||
mux.HandleFunc(opsPrefix, s.admin(s.handleOpsIndex))
|
||||
mux.HandleFunc(opsPrefix+"/players", s.admin(s.handleOpsPlayers))
|
||||
mux.HandleFunc(opsPrefix+"/profile", s.admin(s.handleOpsProfile))
|
||||
mux.HandleFunc(opsPrefix+"/profile/repair", dangerous(s.handleOpsRepair))
|
||||
mux.HandleFunc(opsPrefix+"/events/replay", dangerous(s.handleOpsEventReplay))
|
||||
mux.HandleFunc(opsPrefix+"/leaderboard", s.admin(s.handleOpsLeaderboard))
|
||||
mux.HandleFunc(opsPrefix+"/leaderboard/repair", dangerous(s.handleOpsRankRepair))
|
||||
mux.HandleFunc(opsPrefix+"/bans", dangerous(s.handleOpsBans))
|
||||
}
|
||||
|
||||
func (s *Service) handleOpsIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"players": s.adminPrefix + "/ops/players?account_id=",
|
||||
"profile": s.adminPrefix + "/ops/profile?account_id=",
|
||||
"profile_fix": s.adminPrefix + "/ops/profile/repair",
|
||||
"event_replay": s.adminPrefix + "/ops/events/replay?id=",
|
||||
"leaderboard": s.adminPrefix + "/ops/leaderboard",
|
||||
"bans": s.adminPrefix + "/ops/bans",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) handleOpsPlayers(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
accountID := strings.TrimSpace(r.URL.Query().Get("account_id"))
|
||||
if accountID == "" {
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"accounts": s.accountSnapshots(),
|
||||
"players": s.playerSnapshots(),
|
||||
})
|
||||
return
|
||||
}
|
||||
profile, active, found, err := s.lookupProfile(r, accountID)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "player not found"})
|
||||
return
|
||||
}
|
||||
response := map[string]any{
|
||||
"account_id": accountID,
|
||||
"profile": profile,
|
||||
"active": active,
|
||||
"account": s.accountSnapshot(accountID),
|
||||
}
|
||||
if s.players != nil {
|
||||
if summary, ok, err := s.players.GetSummary(r.Context(), accountID); err == nil && ok {
|
||||
response["summary"] = summary
|
||||
}
|
||||
}
|
||||
if s.sanctions != nil {
|
||||
if sanctions, err := s.sanctions.Active(r.Context(), moderation.SanctionQuery{Subject: accountID, Scope: "global"}); err == nil {
|
||||
response["active_sanctions"] = sanctions
|
||||
}
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (s *Service) handleOpsProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
accountID := strings.TrimSpace(r.URL.Query().Get("account_id"))
|
||||
if accountID == "" {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "account_id is required"})
|
||||
return
|
||||
}
|
||||
profile, active, found, err := s.lookupProfile(r, accountID)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "profile not found"})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"profile": profile,
|
||||
"active": active,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) handleOpsRepair(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
if s.players == nil {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "player module not configured"})
|
||||
return
|
||||
}
|
||||
var repair player.ProfileRepair
|
||||
if err := decodeJSONOrQuery(w, r, &repair); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if repair.AccountID == "" {
|
||||
repair.AccountID = strings.TrimSpace(r.URL.Query().Get("account_id"))
|
||||
}
|
||||
applyRepairQuery(r, &repair)
|
||||
receipt, duplicate, submitted, err := s.submitOpsCmd(r, "logic.profile.repair", repair.AccountID, repair.Reason, repair)
|
||||
if writeOpsCmdErr(w, err) {
|
||||
return
|
||||
}
|
||||
if duplicate {
|
||||
writeOpsDup(w, receipt)
|
||||
return
|
||||
}
|
||||
profile, ok, err := s.players.RepairProfile(r.Context(), repair)
|
||||
if err != nil {
|
||||
if submitted {
|
||||
s.markOpsCmdFail(r.Context(), receipt, err)
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
if submitted {
|
||||
s.markOpsCmdFail(r.Context(), receipt, errors.New("profile not found"))
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "profile not found"})
|
||||
return
|
||||
}
|
||||
if submitted {
|
||||
receipt = s.markOpsAdminOK(r.Context(), receipt)
|
||||
}
|
||||
writeOpsAdminReceipt(w, http.StatusOK, map[string]any{
|
||||
"status": "repaired",
|
||||
"profile": profile,
|
||||
}, receipt)
|
||||
}
|
||||
|
||||
func (s *Service) handleOpsEventReplay(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
if s.eventLog == nil {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "eventlog is not configured"})
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := decodeJSONOrQuery(w, r, &body); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
id = strings.TrimSpace(body.ID)
|
||||
}
|
||||
if id == "" {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "id is required"})
|
||||
return
|
||||
}
|
||||
receipt, duplicate, submitted, submitErr := s.submitOpsCmd(r, "logic.event.replay", id, "event replay", map[string]any{"id": id})
|
||||
if writeOpsCmdErr(w, submitErr) {
|
||||
return
|
||||
}
|
||||
if duplicate {
|
||||
writeOpsDup(w, receipt)
|
||||
return
|
||||
}
|
||||
event, err := s.eventLog.RequeueDeadLetter(r.Context(), id, eventlog.RequeueOptions{})
|
||||
if err != nil {
|
||||
if submitted {
|
||||
s.markOpsCmdFail(r.Context(), receipt, err)
|
||||
}
|
||||
status := http.StatusServiceUnavailable
|
||||
if errors.Is(err, eventlog.ErrEventNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if submitted {
|
||||
receipt = s.markOpsAdminOK(r.Context(), receipt)
|
||||
}
|
||||
writeOpsAdminReceipt(w, http.StatusOK, map[string]any{
|
||||
"status": "requeued",
|
||||
"event": event,
|
||||
}, receipt)
|
||||
}
|
||||
|
||||
func (s *Service) handleOpsLeaderboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
if s.leaderboards == nil {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "leaderboard runtime not configured"})
|
||||
return
|
||||
}
|
||||
boardID := strings.TrimSpace(r.URL.Query().Get("leaderboard_id"))
|
||||
ownerID := strings.TrimSpace(r.URL.Query().Get("owner_id"))
|
||||
if boardID == "" {
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"snapshot": s.leaderboards.Snapshot(),
|
||||
"definitions": s.leaderboards.Definitions(),
|
||||
})
|
||||
return
|
||||
}
|
||||
response := map[string]any{"leaderboard_id": boardID}
|
||||
if definition, ok := s.leaderboards.Definition(boardID); ok {
|
||||
response["definition"] = definition
|
||||
} else {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "leaderboard not found"})
|
||||
return
|
||||
}
|
||||
if ownerID != "" {
|
||||
if record, ok := s.leaderboards.Record(boardID, ownerID); ok {
|
||||
response["record"] = record
|
||||
} else {
|
||||
response["record_missing"] = true
|
||||
}
|
||||
}
|
||||
limit := queryInt(r, "limit", 50)
|
||||
records, err := s.leaderboards.List(r.Context(), boardID, leaderboard.ListOptions{Limit: limit})
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
response["records"] = records
|
||||
httpx.WriteJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (s *Service) handleOpsRankRepair(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
if s.leaderboards == nil {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "leaderboard runtime not configured"})
|
||||
return
|
||||
}
|
||||
var req lbRepairReq
|
||||
if err := decodeJSONOrQuery(w, r, &req); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
fillRankRepairQuery(r, &req)
|
||||
reason := "leaderboard repair"
|
||||
if action := strings.TrimSpace(req.Action); action != "" {
|
||||
reason = "leaderboard " + action
|
||||
}
|
||||
target := strings.TrimSpace(req.LeaderboardID)
|
||||
if req.OwnerID != "" {
|
||||
target += ":" + strings.TrimSpace(req.OwnerID)
|
||||
}
|
||||
receipt, duplicate, submitted, submitErr := s.submitOpsCmd(r, "logic.leaderboard.repair", target, reason, req)
|
||||
if writeOpsCmdErr(w, submitErr) {
|
||||
return
|
||||
}
|
||||
if duplicate {
|
||||
writeOpsDup(w, receipt)
|
||||
return
|
||||
}
|
||||
result, err := s.applyLbRepair(r, req)
|
||||
if err != nil {
|
||||
if submitted {
|
||||
s.markOpsCmdFail(r.Context(), receipt, err)
|
||||
}
|
||||
status := http.StatusServiceUnavailable
|
||||
if errors.Is(err, leaderboard.ErrLeaderboardNotFound) || errors.Is(err, leaderboard.ErrRecordNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
if errors.Is(err, leaderboard.ErrInvalidDefinition) || errors.Is(err, leaderboard.ErrInvalidSubmission) || errors.Is(err, leaderboard.ErrLeaderboardExists) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if submitted {
|
||||
receipt = s.markOpsAdminOK(r.Context(), receipt)
|
||||
}
|
||||
writeOpsAdminReceipt(w, http.StatusOK, result, receipt)
|
||||
}
|
||||
|
||||
func (s *Service) handleOpsBans(w http.ResponseWriter, r *http.Request) {
|
||||
if s.sanctions == nil {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "moderation sanction store not configured"})
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead:
|
||||
snapshot, err := s.sanctions.Snapshot(r.Context())
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, snapshot)
|
||||
case http.MethodPost:
|
||||
var req sanctionRequest
|
||||
if err := decodeJSONOrQuery(w, r, &req); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
fillSanctionQuery(r, &req)
|
||||
receipt, duplicate, submitted, submitErr := s.submitOpsCmd(r, "logic.sanction.upsert", req.Subject, req.Reason, req)
|
||||
if writeOpsCmdErr(w, submitErr) {
|
||||
return
|
||||
}
|
||||
if duplicate {
|
||||
writeOpsDup(w, receipt)
|
||||
return
|
||||
}
|
||||
item, err := s.sanctions.Upsert(r.Context(), req.toSanction())
|
||||
if err != nil {
|
||||
if submitted {
|
||||
s.markOpsCmdFail(r.Context(), receipt, err)
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if submitted {
|
||||
receipt = s.markOpsAdminOK(r.Context(), receipt)
|
||||
}
|
||||
writeOpsAdminReceipt(w, http.StatusOK, map[string]any{"status": "upserted", "sanction": item}, receipt)
|
||||
case http.MethodDelete:
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "id is required"})
|
||||
return
|
||||
}
|
||||
reason := strings.TrimSpace(r.URL.Query().Get("reason"))
|
||||
if reason == "" {
|
||||
reason = "remove sanction"
|
||||
}
|
||||
receipt, duplicate, submitted, submitErr := s.submitOpsCmd(r, "logic.sanction.remove", id, reason, map[string]any{"id": id, "reason": reason})
|
||||
if writeOpsCmdErr(w, submitErr) {
|
||||
return
|
||||
}
|
||||
if duplicate {
|
||||
writeOpsDup(w, receipt)
|
||||
return
|
||||
}
|
||||
if err := s.sanctions.Remove(r.Context(), id); err != nil {
|
||||
if submitted {
|
||||
s.markOpsCmdFail(r.Context(), receipt, err)
|
||||
}
|
||||
status := http.StatusServiceUnavailable
|
||||
if errors.Is(err, moderation.ErrSanctionMissing) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if submitted {
|
||||
receipt = s.markOpsAdminOK(r.Context(), receipt)
|
||||
}
|
||||
writeOpsAdminReceipt(w, http.StatusOK, map[string]any{"status": "removed", "id": id}, receipt)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST, DELETE")
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) lookupProfile(r *http.Request, accountID string) (player.Profile, bool, bool, error) {
|
||||
if s.players == nil {
|
||||
return player.Profile{}, false, false, nil
|
||||
}
|
||||
if profile, ok := s.players.Get(accountID); ok {
|
||||
return profile, true, true, nil
|
||||
}
|
||||
profile, ok, err := s.players.LoadReadonly(r.Context(), accountID)
|
||||
return profile, false, ok, err
|
||||
}
|
||||
|
||||
func (s *Service) accountSnapshots() []AccountSnapshot {
|
||||
if s.accounts == nil {
|
||||
return nil
|
||||
}
|
||||
return s.accounts.Snapshot()
|
||||
}
|
||||
|
||||
func (s *Service) playerSnapshots() []player.Record {
|
||||
if s.players == nil {
|
||||
return nil
|
||||
}
|
||||
return s.players.Snapshot()
|
||||
}
|
||||
|
||||
func (s *Service) accountSnapshot(accountID string) *AccountSnapshot {
|
||||
if s.accounts == nil {
|
||||
return nil
|
||||
}
|
||||
for _, item := range s.accounts.Snapshot() {
|
||||
if item.AccountID == accountID {
|
||||
copy := item
|
||||
return ©
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeJSONOrQuery(w http.ResponseWriter, r *http.Request, dst any) error {
|
||||
if r.Body == nil || r.ContentLength == 0 {
|
||||
return nil
|
||||
}
|
||||
return httpx.DecodeStrictJSON(w, r, maxOpsAdminJSONBytes, dst)
|
||||
}
|
||||
|
||||
func applyRepairQuery(r *http.Request, repair *player.ProfileRepair) {
|
||||
query := r.URL.Query()
|
||||
if repair.Reason == "" {
|
||||
repair.Reason = strings.TrimSpace(query.Get("reason"))
|
||||
}
|
||||
if repair.State == nil {
|
||||
if raw := strings.TrimSpace(query.Get("state")); raw != "" {
|
||||
repair.State = &raw
|
||||
}
|
||||
}
|
||||
if repair.Name == nil {
|
||||
if raw := strings.TrimSpace(query.Get("name")); raw != "" {
|
||||
repair.Name = &raw
|
||||
}
|
||||
}
|
||||
if repair.Level == nil {
|
||||
if raw := strings.TrimSpace(query.Get("level")); raw != "" {
|
||||
if value, err := strconv.Atoi(raw); err == nil {
|
||||
repair.Level = &value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fillRankRepairQuery(r *http.Request, req *lbRepairReq) {
|
||||
query := r.URL.Query()
|
||||
if req.Action == "" {
|
||||
req.Action = strings.TrimSpace(query.Get("action"))
|
||||
}
|
||||
if req.LeaderboardID == "" {
|
||||
req.LeaderboardID = strings.TrimSpace(query.Get("leaderboard_id"))
|
||||
}
|
||||
if req.OwnerID == "" {
|
||||
req.OwnerID = strings.TrimSpace(query.Get("owner_id"))
|
||||
}
|
||||
if req.Title == "" {
|
||||
req.Title = strings.TrimSpace(query.Get("title"))
|
||||
}
|
||||
if req.SortOrder == "" {
|
||||
req.SortOrder = strings.TrimSpace(query.Get("sort_order"))
|
||||
}
|
||||
if req.Operator == "" {
|
||||
req.Operator = strings.TrimSpace(query.Get("operator"))
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("score")); raw != "" && req.Score == 0 {
|
||||
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
req.Score = value
|
||||
}
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("subscore")); raw != "" && req.Subscore == 0 {
|
||||
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
req.Subscore = value
|
||||
}
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("max_size")); raw != "" && req.MaxSize == 0 {
|
||||
if value, err := strconv.Atoi(raw); err == nil {
|
||||
req.MaxSize = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fillSanctionQuery(r *http.Request, req *sanctionRequest) {
|
||||
query := r.URL.Query()
|
||||
if req.ID == "" {
|
||||
req.ID = strings.TrimSpace(query.Get("id"))
|
||||
}
|
||||
if req.Subject == "" {
|
||||
req.Subject = strings.TrimSpace(query.Get("subject"))
|
||||
}
|
||||
if req.Scope == "" {
|
||||
req.Scope = strings.TrimSpace(query.Get("scope"))
|
||||
}
|
||||
if req.Kind == "" {
|
||||
req.Kind = strings.TrimSpace(query.Get("kind"))
|
||||
}
|
||||
if req.Reason == "" {
|
||||
req.Reason = strings.TrimSpace(query.Get("reason"))
|
||||
}
|
||||
if req.Source == "" {
|
||||
req.Source = strings.TrimSpace(query.Get("source"))
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("ttl_seconds")); raw != "" && req.TTLSecond == 0 {
|
||||
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
req.TTLSecond = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (req sanctionRequest) toSanction() moderation.Sanction {
|
||||
kind := moderation.SanctionKind(strings.TrimSpace(req.Kind))
|
||||
if kind == "" {
|
||||
kind = moderation.SanctionBan
|
||||
}
|
||||
scope := strings.TrimSpace(req.Scope)
|
||||
if scope == "" {
|
||||
scope = "global"
|
||||
}
|
||||
until := req.Until
|
||||
if until.IsZero() && req.TTLSecond > 0 {
|
||||
until = time.Now().UTC().Add(time.Duration(req.TTLSecond) * time.Second)
|
||||
}
|
||||
source := strings.TrimSpace(req.Source)
|
||||
if source == "" {
|
||||
source = "admin"
|
||||
}
|
||||
return moderation.Sanction{
|
||||
ID: req.ID,
|
||||
Subject: req.Subject,
|
||||
Scope: scope,
|
||||
Kind: kind,
|
||||
Reason: req.Reason,
|
||||
Source: source,
|
||||
Until: until,
|
||||
Meta: req.Meta,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) applyLbRepair(r *http.Request, req lbRepairReq) (map[string]any, error) {
|
||||
action := strings.TrimSpace(req.Action)
|
||||
if action == "" {
|
||||
action = "submit"
|
||||
}
|
||||
switch action {
|
||||
case "create":
|
||||
definition, err := s.leaderboards.Create(r.Context(), leaderboard.Definition{
|
||||
ID: req.LeaderboardID,
|
||||
Title: req.Title,
|
||||
SortOrder: req.SortOrder,
|
||||
Operator: req.Operator,
|
||||
MaxSize: req.MaxSize,
|
||||
Metadata: req.Metadata,
|
||||
})
|
||||
return map[string]any{"status": "created", "definition": definition}, err
|
||||
case "submit", "set_score":
|
||||
record, err := s.leaderboards.Submit(r.Context(), req.LeaderboardID, leaderboard.Submission{
|
||||
OwnerID: req.OwnerID,
|
||||
Score: req.Score,
|
||||
Subscore: req.Subscore,
|
||||
Metadata: req.Metadata,
|
||||
})
|
||||
return map[string]any{"status": "updated", "record": record}, err
|
||||
case "delete_record":
|
||||
record, err := s.leaderboards.DeleteRecord(r.Context(), req.LeaderboardID, req.OwnerID)
|
||||
return map[string]any{"status": "record_deleted", "record": record}, err
|
||||
case "reset":
|
||||
definition, err := s.leaderboards.Reset(r.Context(), req.LeaderboardID)
|
||||
return map[string]any{"status": "reset", "definition": definition}, err
|
||||
case "delete_leaderboard":
|
||||
definition, err := s.leaderboards.Delete(r.Context(), req.LeaderboardID)
|
||||
return map[string]any{"status": "deleted", "definition": definition}, err
|
||||
default:
|
||||
return nil, leaderboard.ErrInvalidDefinition
|
||||
}
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, key string, fallback int) int {
|
||||
raw := strings.TrimSpace(r.URL.Query().Get(key))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/platform/admincmd"
|
||||
"longheng.io/server/internal/platform/admincmdqueue"
|
||||
"longheng.io/server/internal/platform/httpx"
|
||||
"longheng.io/server/internal/services/adminops"
|
||||
)
|
||||
|
||||
func (s *Service) submitOpsCmd(r *http.Request, operation, target, reason string, params any) (admincmd.Receipt, bool, bool, error) {
|
||||
if s == nil || s.adminCommands == nil {
|
||||
return admincmd.Receipt{}, false, false, nil
|
||||
}
|
||||
ctx := context.Background()
|
||||
command := admincmd.Command{
|
||||
Operation: strings.TrimSpace(operation),
|
||||
Scope: "logic.ops",
|
||||
Environment: s.environment,
|
||||
ShardID: s.nodeID,
|
||||
Target: strings.TrimSpace(target),
|
||||
Actor: adminActor(r),
|
||||
Reason: strings.TrimSpace(reason),
|
||||
Params: adminCommandParams(params),
|
||||
}
|
||||
if r != nil {
|
||||
ctx = r.Context()
|
||||
command.IdempotencyKey = strings.TrimSpace(r.Header.Get(httpx.AdminIdempotencyHeader))
|
||||
command.Confirmation = strings.TrimSpace(r.Header.Get(httpx.AdminConfirmHeader))
|
||||
}
|
||||
result, err := s.adminCommands.Submit(ctx, command, admincmd.DangerousPolicy())
|
||||
if err != nil {
|
||||
return admincmd.Receipt{}, false, true, err
|
||||
}
|
||||
return result.Receipt, result.Duplicate, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) markOpsAdminOK(ctx context.Context, receipt admincmd.Receipt) admincmd.Receipt {
|
||||
if s == nil || s.adminCommands == nil || receipt.ID == "" {
|
||||
return receipt
|
||||
}
|
||||
updated, err := adminops.MarkSucceededWithError(ctx, s.adminCommands, receipt)
|
||||
if err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Error("mark admin command succeeded failed", "receipt_id", receipt.ID, "error", err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func (s *Service) markOpsCmdFail(ctx context.Context, receipt admincmd.Receipt, err error) {
|
||||
if s == nil || s.adminCommands == nil || receipt.ID == "" || err == nil {
|
||||
return
|
||||
}
|
||||
if markErr := adminops.MarkFailedWithError(ctx, s.adminCommands, receipt, err); markErr != nil && s.logger != nil {
|
||||
s.logger.Error("mark admin command failed failed", "receipt_id", receipt.ID, "error", markErr)
|
||||
}
|
||||
}
|
||||
|
||||
func writeOpsCmdErr(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) {
|
||||
status = http.StatusPreconditionRequired
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return true
|
||||
}
|
||||
|
||||
func writeOpsDup(w http.ResponseWriter, receipt admincmd.Receipt) {
|
||||
if receipt.ID != "" {
|
||||
w.Header().Set(httpx.AdminReceiptHeader, receipt.ID)
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "duplicate",
|
||||
"admin_receipt": receipt,
|
||||
})
|
||||
}
|
||||
|
||||
func withAdminReceipt(response map[string]any, receipt admincmd.Receipt) map[string]any {
|
||||
if response == nil {
|
||||
response = make(map[string]any)
|
||||
}
|
||||
if receipt.ID != "" {
|
||||
response["admin_receipt"] = receipt
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func writeOpsAdminReceipt(w http.ResponseWriter, status int, response map[string]any, receipt admincmd.Receipt) {
|
||||
if receipt.ID != "" {
|
||||
w.Header().Set(httpx.AdminReceiptHeader, receipt.ID)
|
||||
}
|
||||
httpx.WriteJSON(w, status, withAdminReceipt(response, receipt))
|
||||
}
|
||||
|
||||
func adminActor(r *http.Request) string {
|
||||
actor := httpx.AuthenticatedAdminActor(r)
|
||||
if actor == "" {
|
||||
actor = "admin"
|
||||
}
|
||||
return actor
|
||||
}
|
||||
|
||||
func adminCommandParams(value any) map[string]any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if params, ok := value.(map[string]any); ok {
|
||||
return params
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return map[string]any{"marshal_error": err.Error()}
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return map[string]any{"marshal_error": err.Error()}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/platform/httpx"
|
||||
"longheng.io/server/internal/platform/readmodel"
|
||||
"longheng.io/server/internal/reference/player"
|
||||
)
|
||||
|
||||
func (s *Service) regReadModelAdmins() {
|
||||
if s.readModels == nil {
|
||||
s.readModels = readmodel.NewAdminRegistry()
|
||||
}
|
||||
s.readModels.Register(readmodel.AdminModel{
|
||||
Name: "player_summaries",
|
||||
Search: func(ctx context.Context, values url.Values) (any, error) {
|
||||
query, err := parsePlayerSumQuery(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summaries, err := s.players.SearchSummaries(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"summaries": summaries}, nil
|
||||
},
|
||||
Rebuild: func(ctx context.Context, values url.Values) (any, error) {
|
||||
options, err := parseSummaryRebuild(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.players.RebuildSummaries(ctx, options)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) handleSummaries(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleViewSearch("player_summaries")(w, r)
|
||||
}
|
||||
|
||||
func (s *Service) handleSummaryRebuild(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleViewRebuild("player_summaries")(w, r)
|
||||
}
|
||||
|
||||
func (s *Service) handleViewSearch(name string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
result, found, err := s.readModels.Search(r.Context(), name, r.URL.Query())
|
||||
if !found {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "read model not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if readmodel.IsBadRequest(err) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) handleViewRebuild(name string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
result, found, err := s.readModels.Rebuild(r.Context(), name, r.URL.Query())
|
||||
if !found {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "read model not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.incMetric("logic_profile_summary_rebuild_total", map[string]string{"result": "error"})
|
||||
status := http.StatusInternalServerError
|
||||
if readmodel.IsBadRequest(err) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.incMetric("logic_profile_summary_rebuild_total", map[string]string{"result": "ok"})
|
||||
httpx.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func parsePlayerSumQuery(values url.Values) (player.PlayerSummaryQuery, error) {
|
||||
query := player.PlayerSummaryQuery{
|
||||
AccountIDs: splitCSV(values.Get("account_id")),
|
||||
RoleIDs: splitCSV(values.Get("role_id")),
|
||||
State: strings.TrimSpace(values.Get("state")),
|
||||
NameContains: strings.TrimSpace(values.Get("name")),
|
||||
}
|
||||
if online := strings.TrimSpace(values.Get("online")); online != "" {
|
||||
value, err := strconv.ParseBool(online)
|
||||
if err != nil {
|
||||
return player.PlayerSummaryQuery{}, readmodel.BadRequest("online must be true or false")
|
||||
}
|
||||
query.OnlineOnly = value
|
||||
}
|
||||
if limit := strings.TrimSpace(values.Get("limit")); limit != "" {
|
||||
value, err := strconv.Atoi(limit)
|
||||
if err != nil || value < 0 {
|
||||
return player.PlayerSummaryQuery{}, readmodel.BadRequest("limit must be a non-negative integer")
|
||||
}
|
||||
query.Limit = value
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func parseSummaryRebuild(values url.Values) (player.SummaryRebuildOptions, error) {
|
||||
options := player.SummaryRebuildOptions{
|
||||
AfterAccountID: strings.TrimSpace(values.Get("after_account_id")),
|
||||
}
|
||||
if batchSize := strings.TrimSpace(values.Get("batch_size")); batchSize != "" {
|
||||
value, err := strconv.Atoi(batchSize)
|
||||
if err != nil || value <= 0 {
|
||||
return player.SummaryRebuildOptions{}, readmodel.BadRequest("batch_size must be a positive integer")
|
||||
}
|
||||
options.BatchSize = value
|
||||
}
|
||||
if maxProfiles := strings.TrimSpace(values.Get("max_profiles")); maxProfiles != "" {
|
||||
value, err := strconv.Atoi(maxProfiles)
|
||||
if err != nil || value <= 0 {
|
||||
return player.SummaryRebuildOptions{}, readmodel.BadRequest("max_profiles must be a positive integer")
|
||||
}
|
||||
options.MaxProfiles = value
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"longheng.io/server/pkg/contracts"
|
||||
)
|
||||
|
||||
type hybridRespCache struct {
|
||||
redis logicResponseCache
|
||||
mysql logicResponseCache
|
||||
}
|
||||
|
||||
// Store 先写权威 MySQL 响应缓存,再尝试写 Redis 热缓存。
|
||||
func (c *hybridRespCache) Store(ctx context.Context, key string, response contracts.LogicPlayerResponse) error {
|
||||
var durableErr error
|
||||
// MySQL 是权威响应缓存,必须先写;Redis 只是热缓存,失败不能覆盖 MySQL 写入结果。
|
||||
if c != nil && c.mysql != nil {
|
||||
durableErr = c.mysql.Store(ctx, key, response)
|
||||
}
|
||||
var redisErr error
|
||||
if c != nil && c.redis != nil {
|
||||
redisErr = c.redis.Store(ctx, key, response)
|
||||
}
|
||||
if durableErr != nil {
|
||||
return durableErr
|
||||
}
|
||||
if c == nil || c.mysql == nil {
|
||||
return redisErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 先读 Redis 热缓存,未命中时读取 MySQL 并回填 Redis。
|
||||
func (c *hybridRespCache) Get(ctx context.Context, key string) (contracts.LogicPlayerResponse, bool, error) {
|
||||
var redisErr error
|
||||
// 读取先走 Redis,未命中再读 MySQL 并回填。Redis 错误只在没有 MySQL 兜底时才向上暴露。
|
||||
if c != nil && c.redis != nil {
|
||||
response, ok, err := c.redis.Get(ctx, key)
|
||||
if ok {
|
||||
return response, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
redisErr = err
|
||||
}
|
||||
}
|
||||
if c != nil && c.mysql != nil {
|
||||
response, ok, err := c.mysql.Get(ctx, key)
|
||||
if err != nil || !ok {
|
||||
if err != nil {
|
||||
return contracts.LogicPlayerResponse{}, false, err
|
||||
}
|
||||
return contracts.LogicPlayerResponse{}, false, redisErr
|
||||
}
|
||||
if c.redis != nil {
|
||||
_ = c.redis.Store(ctx, key, response)
|
||||
}
|
||||
return response, true, nil
|
||||
}
|
||||
return contracts.LogicPlayerResponse{}, false, redisErr
|
||||
}
|
||||
|
||||
// Delete 同时删除 Redis 和 MySQL 中的响应缓存。
|
||||
func (c *hybridRespCache) Delete(ctx context.Context, key string) error {
|
||||
if c != nil && c.redis != nil {
|
||||
if err := c.redis.Delete(ctx, key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c != nil && c.mysql != nil {
|
||||
return c.mysql.Delete(ctx, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot 返回混合响应缓存的后端状态快照。
|
||||
func (c *hybridRespCache) Snapshot() map[string]any {
|
||||
out := map[string]any{
|
||||
"backend": "mysql_redis",
|
||||
}
|
||||
if c == nil {
|
||||
return out
|
||||
}
|
||||
if c.redis != nil {
|
||||
out["redis"] = c.redis.Snapshot()
|
||||
}
|
||||
if c.mysql != nil {
|
||||
out["mysql"] = c.mysql.Snapshot()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Close 关闭混合响应缓存持有的后端连接。
|
||||
func (c *hybridRespCache) Close(ctx context.Context) error {
|
||||
var first error
|
||||
if c != nil && c.redis != nil {
|
||||
first = c.redis.Close(ctx)
|
||||
}
|
||||
if c != nil && c.mysql != nil {
|
||||
if err := c.mysql.Close(ctx); first == nil {
|
||||
first = err
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/pkg/contracts"
|
||||
)
|
||||
|
||||
type memRespCache struct {
|
||||
mu sync.Mutex
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
entries map[string]logicRespCacheEntry
|
||||
}
|
||||
|
||||
type logicRespCacheEntry struct {
|
||||
response contracts.LogicPlayerResponse
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func newMemRespCache(ttl time.Duration) *memRespCache {
|
||||
if ttl <= 0 {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
return &memRespCache{
|
||||
ttl: ttl,
|
||||
now: time.Now,
|
||||
entries: make(map[string]logicRespCacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// Store 将响应写入内存缓存并按 TTL 记录过期时间。
|
||||
func (c *memRespCache) Store(_ context.Context, key string, response contracts.LogicPlayerResponse) error {
|
||||
key = strings.TrimSpace(key)
|
||||
if c == nil || key == "" {
|
||||
return nil
|
||||
}
|
||||
now := c.nowUTC()
|
||||
ttl := c.ttl
|
||||
if ttl <= 0 {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.pruneLocked(now)
|
||||
if c.entries == nil {
|
||||
c.entries = make(map[string]logicRespCacheEntry)
|
||||
}
|
||||
c.entries[key] = logicRespCacheEntry{
|
||||
response: clonePlayerResp(response),
|
||||
expiresAt: now.Add(ttl),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 从内存缓存读取未过期的响应。
|
||||
func (c *memRespCache) Get(_ context.Context, key string) (contracts.LogicPlayerResponse, bool, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if c == nil || key == "" {
|
||||
return contracts.LogicPlayerResponse{}, false, nil
|
||||
}
|
||||
now := c.nowUTC()
|
||||
c.mu.Lock()
|
||||
c.pruneLocked(now)
|
||||
entry, ok := c.entries[key]
|
||||
c.mu.Unlock()
|
||||
if !ok {
|
||||
return contracts.LogicPlayerResponse{}, false, nil
|
||||
}
|
||||
return clonePlayerResp(entry.response), true, nil
|
||||
}
|
||||
|
||||
// Delete 从内存缓存删除指定响应。
|
||||
func (c *memRespCache) Delete(_ context.Context, key string) error {
|
||||
key = strings.TrimSpace(key)
|
||||
if c == nil || key == "" {
|
||||
return nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
delete(c.entries, key)
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot 返回内存响应缓存的条目数和 TTL 信息。
|
||||
func (c *memRespCache) Snapshot() map[string]any {
|
||||
out := map[string]any{
|
||||
"backend": "memory",
|
||||
}
|
||||
if c == nil {
|
||||
return out
|
||||
}
|
||||
c.mu.Lock()
|
||||
now := c.nowUTC()
|
||||
c.pruneLocked(now)
|
||||
out["ttl_seconds"] = int64(c.ttl / time.Second)
|
||||
out["entries"] = len(c.entries)
|
||||
c.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// Close 关闭内存响应缓存。
|
||||
func (c *memRespCache) Close(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *memRespCache) nowUTC() time.Time {
|
||||
if c == nil || c.now == nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return c.now().UTC()
|
||||
}
|
||||
|
||||
func (c *memRespCache) pruneLocked(now time.Time) {
|
||||
if c.entries == nil {
|
||||
return
|
||||
}
|
||||
for key, entry := range c.entries {
|
||||
if !entry.expiresAt.After(now) {
|
||||
delete(c.entries, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/config"
|
||||
"longheng.io/server/pkg/contracts"
|
||||
"longheng.io/server/pkg/protocol"
|
||||
)
|
||||
|
||||
const logicRespCacheTable = "logic_idempotency_responses"
|
||||
|
||||
type logicResponseCache interface {
|
||||
Store(context.Context, string, contracts.LogicPlayerResponse) error
|
||||
Get(context.Context, string) (contracts.LogicPlayerResponse, bool, error)
|
||||
Delete(context.Context, string) error
|
||||
Snapshot() map[string]any
|
||||
Close(context.Context) error
|
||||
}
|
||||
|
||||
func newLogicRespCache(cfg config.IdempotencySection) logicResponseCache {
|
||||
ttl := time.Duration(cfg.TTLSeconds) * time.Second
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.Kind)) {
|
||||
case "redis":
|
||||
return newRedisRespCache(cfg, ttl)
|
||||
case "mysql":
|
||||
return newMySQLRespCache(cfg, ttl)
|
||||
case "mysql_redis":
|
||||
return &hybridRespCache{
|
||||
redis: newRedisRespCache(cfg, ttl),
|
||||
mysql: newMySQLRespCache(cfg, ttl),
|
||||
}
|
||||
default:
|
||||
return newMemRespCache(ttl)
|
||||
}
|
||||
}
|
||||
|
||||
func clonePlayerResp(response contracts.LogicPlayerResponse) contracts.LogicPlayerResponse {
|
||||
response.Body = append([]byte(nil), response.Body...)
|
||||
response.Metadata = protocol.CloneFrameMetadata(response.Metadata)
|
||||
return response
|
||||
}
|
||||
|
||||
func respCachePrefix(prefix string) string {
|
||||
prefix = strings.Trim(strings.TrimSpace(prefix), ":")
|
||||
if prefix == "" {
|
||||
prefix = "idempotency"
|
||||
}
|
||||
return prefix + ":responses"
|
||||
}
|
||||
|
||||
func digestRespCacheKey(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"longheng.io/server/internal/platform/config"
|
||||
"longheng.io/server/pkg/contracts"
|
||||
)
|
||||
|
||||
type mysqlRespCache struct {
|
||||
db *sql.DB
|
||||
initErr error
|
||||
ensureSchema bool
|
||||
schemaMu sync.Mutex
|
||||
schemaReady bool
|
||||
schemaErr error
|
||||
keyPrefix string
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func newMySQLRespCache(cfg config.IdempotencySection, ttl time.Duration) *mysqlRespCache {
|
||||
if ttl <= 0 {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
dsn := strings.TrimSpace(cfg.MySQLDSN)
|
||||
if dsn == "" {
|
||||
dsn = "longheng:longheng@tcp(127.0.0.1:3306)/longheng?parseTime=true&charset=utf8mb4,utf8"
|
||||
}
|
||||
conn, err := sql.Open("mysql", dsn)
|
||||
if err == nil {
|
||||
maxOpen := cfg.MySQLMaxOpenConns
|
||||
if maxOpen <= 0 {
|
||||
maxOpen = 32
|
||||
}
|
||||
maxIdle := cfg.MySQLMaxIdleConns
|
||||
if maxIdle <= 0 {
|
||||
maxIdle = 8
|
||||
}
|
||||
lifetime := time.Duration(cfg.MySQLConnMaxLifetimeSec) * time.Second
|
||||
if lifetime <= 0 {
|
||||
lifetime = 5 * time.Minute
|
||||
}
|
||||
conn.SetMaxOpenConns(maxOpen)
|
||||
conn.SetMaxIdleConns(maxIdle)
|
||||
conn.SetConnMaxLifetime(lifetime)
|
||||
}
|
||||
return &mysqlRespCache{
|
||||
db: conn,
|
||||
initErr: err,
|
||||
ensureSchema: true,
|
||||
keyPrefix: respCachePrefix(cfg.KeyPrefix),
|
||||
ttl: ttl,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Store 将响应缓存写入 MySQL 权威存储。
|
||||
func (c *mysqlRespCache) Store(ctx context.Context, key string, response contracts.LogicPlayerResponse) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
if err := c.ready(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
now := c.nowUTC()
|
||||
data, err := json.Marshal(clonePlayerResp(response))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.db.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,缓存内容通过参数绑定。
|
||||
"INSERT INTO "+quoteMySQLIdent(logicRespCacheTable)+" ("+ //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,缓存内容通过参数绑定。
|
||||
strings.Join([]string{
|
||||
quoteMySQLIdent("cache_key"),
|
||||
quoteMySQLIdent("original_key"),
|
||||
quoteMySQLIdent("response_json"),
|
||||
quoteMySQLIdent("expires_at"),
|
||||
quoteMySQLIdent("updated_at"),
|
||||
}, ", ")+") VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE "+
|
||||
quoteMySQLIdent("original_key")+" = VALUES("+quoteMySQLIdent("original_key")+"), "+
|
||||
quoteMySQLIdent("response_json")+" = VALUES("+quoteMySQLIdent("response_json")+"), "+
|
||||
quoteMySQLIdent("expires_at")+" = VALUES("+quoteMySQLIdent("expires_at")+"), "+
|
||||
quoteMySQLIdent("updated_at")+" = VALUES("+quoteMySQLIdent("updated_at")+")",
|
||||
c.cacheKey(key),
|
||||
limitRespCacheText(key, 512),
|
||||
string(data),
|
||||
now.Add(c.ttl),
|
||||
now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Get 从 MySQL 读取未过期的响应缓存。
|
||||
func (c *mysqlRespCache) Get(ctx context.Context, key string) (contracts.LogicPlayerResponse, bool, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return contracts.LogicPlayerResponse{}, false, nil
|
||||
}
|
||||
if err := c.ready(ctx); err != nil {
|
||||
return contracts.LogicPlayerResponse{}, false, err
|
||||
}
|
||||
now := c.nowUTC()
|
||||
var data []byte
|
||||
err := c.db.QueryRowContext(ctx,
|
||||
"SELECT "+quoteMySQLIdent("response_json")+" FROM "+quoteMySQLIdent(logicRespCacheTable)+" WHERE "+quoteMySQLIdent("cache_key")+" = ? AND "+quoteMySQLIdent("expires_at")+" > ? LIMIT 1",
|
||||
c.cacheKey(key),
|
||||
now,
|
||||
).Scan(&data)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
_, _ = c.db.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,清理条件通过参数绑定。
|
||||
"DELETE FROM "+quoteMySQLIdent(logicRespCacheTable)+" WHERE "+quoteMySQLIdent("cache_key")+" = ? AND "+quoteMySQLIdent("expires_at")+" <= ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,清理条件通过参数绑定。
|
||||
c.cacheKey(key),
|
||||
now,
|
||||
)
|
||||
return contracts.LogicPlayerResponse{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return contracts.LogicPlayerResponse{}, false, err
|
||||
}
|
||||
var response contracts.LogicPlayerResponse
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return contracts.LogicPlayerResponse{}, false, err
|
||||
}
|
||||
return clonePlayerResp(response), true, nil
|
||||
}
|
||||
|
||||
// Delete 从 MySQL 删除指定响应缓存。
|
||||
func (c *mysqlRespCache) Delete(ctx context.Context, key string) error {
|
||||
if c == nil || c.db == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := c.db.ExecContext(ctx, //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,缓存键通过参数绑定。
|
||||
"DELETE FROM "+quoteMySQLIdent(logicRespCacheTable)+" WHERE "+quoteMySQLIdent("cache_key")+" = ?", //nolint:gosec // G202:SQL 只拼接框架固定表名/列名,缓存键通过参数绑定。
|
||||
c.cacheKey(key),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Snapshot 返回 MySQL 响应缓存的配置状态。
|
||||
func (c *mysqlRespCache) Snapshot() map[string]any {
|
||||
out := map[string]any{
|
||||
"backend": "mysql",
|
||||
}
|
||||
if c == nil {
|
||||
return out
|
||||
}
|
||||
out["ttl_seconds"] = int64(c.ttl / time.Second)
|
||||
out["key_prefix"] = c.keyPrefix
|
||||
return out
|
||||
}
|
||||
|
||||
// Close 关闭 MySQL 响应缓存连接。
|
||||
func (c *mysqlRespCache) Close(context.Context) error {
|
||||
if c == nil || c.db == nil {
|
||||
return nil
|
||||
}
|
||||
return c.db.Close()
|
||||
}
|
||||
|
||||
func (c *mysqlRespCache) ready(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if c == nil || c.db == nil {
|
||||
return errors.New("logic mysql response cache is nil")
|
||||
}
|
||||
if c.initErr != nil {
|
||||
return c.initErr
|
||||
}
|
||||
if c.ensureSchema {
|
||||
c.schemaMu.Lock()
|
||||
defer c.schemaMu.Unlock()
|
||||
if c.schemaReady {
|
||||
return nil
|
||||
}
|
||||
_, c.schemaErr = c.db.ExecContext(ctx, responseCacheDDL())
|
||||
if c.schemaErr == nil {
|
||||
c.schemaReady = true
|
||||
}
|
||||
}
|
||||
return c.schemaErr
|
||||
}
|
||||
|
||||
func (c *mysqlRespCache) nowUTC() time.Time {
|
||||
if c == nil || c.now == nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return c.now().UTC()
|
||||
}
|
||||
|
||||
func (c *mysqlRespCache) cacheKey(key string) string {
|
||||
return digestRespCacheKey(c.keyPrefix + "\x00" + key)
|
||||
}
|
||||
|
||||
func responseCacheDDL() string {
|
||||
columns := []string{
|
||||
quoteMySQLIdent("cache_key") + " VARCHAR(128) NOT NULL",
|
||||
quoteMySQLIdent("original_key") + " VARCHAR(512) NOT NULL",
|
||||
quoteMySQLIdent("response_json") + " JSON NOT NULL",
|
||||
quoteMySQLIdent("expires_at") + " DATETIME(6) NOT NULL",
|
||||
quoteMySQLIdent("updated_at") + " DATETIME(6) NOT NULL",
|
||||
"PRIMARY KEY (" + quoteMySQLIdent("cache_key") + ")",
|
||||
"KEY " + quoteMySQLIdent("idx_logic_idempotency_responses_expires_at") + " (" + quoteMySQLIdent("expires_at") + ")",
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"CREATE TABLE IF NOT EXISTS %s (\n %s\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
quoteMySQLIdent(logicRespCacheTable),
|
||||
strings.Join(columns, ",\n "),
|
||||
)
|
||||
}
|
||||
|
||||
func quoteMySQLIdent(identifier string) string {
|
||||
return "`" + strings.ReplaceAll(identifier, "`", "``") + "`"
|
||||
}
|
||||
|
||||
func limitRespCacheText(value string, limit int) string {
|
||||
original := value
|
||||
value = strings.ToValidUTF8(value, "")
|
||||
if limit <= 0 || len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
sum := sha256.Sum256([]byte(original))
|
||||
suffix := ":" + hex.EncodeToString(sum[:8])
|
||||
if limit <= len(suffix) {
|
||||
return suffix[:limit]
|
||||
}
|
||||
return truncRespCacheText(value, limit-len(suffix)) + suffix
|
||||
}
|
||||
|
||||
func truncRespCacheText(value string, limit int) string {
|
||||
if limit <= 0 || len(value) <= limit {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
last := 0
|
||||
for idx := range value {
|
||||
if idx > limit {
|
||||
return value[:last]
|
||||
}
|
||||
last = idx
|
||||
}
|
||||
return value[:last]
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gomodule/redigo/redis"
|
||||
"longheng.io/server/internal/platform/config"
|
||||
"longheng.io/server/pkg/contracts"
|
||||
)
|
||||
|
||||
type redisRespCache struct {
|
||||
keyPrefix string
|
||||
ttl time.Duration
|
||||
pool *redis.Pool
|
||||
}
|
||||
|
||||
// closeRedisConnErr 在响应缓存操作成功时保留连接关闭错误。
|
||||
func closeRedisConnErr(conn redis.Conn, err *error) {
|
||||
if conn == nil || err == nil {
|
||||
return
|
||||
}
|
||||
if closeErr := conn.Close(); closeErr != nil && *err == nil {
|
||||
*err = closeErr
|
||||
}
|
||||
}
|
||||
|
||||
func newRedisRespCache(cfg config.IdempotencySection, ttl time.Duration) *redisRespCache {
|
||||
if ttl <= 0 {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
address := strings.TrimSpace(cfg.RedisAddress)
|
||||
if address == "" {
|
||||
address = "127.0.0.1:6379"
|
||||
}
|
||||
poolSize := cfg.RedisPoolSize
|
||||
if poolSize <= 0 {
|
||||
poolSize = 8
|
||||
}
|
||||
timeout := time.Duration(cfg.RedisTimeoutSeconds) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 2 * time.Second
|
||||
}
|
||||
pool := &redis.Pool{
|
||||
MaxIdle: poolSize,
|
||||
MaxActive: poolSize * 2,
|
||||
IdleTimeout: time.Minute,
|
||||
Wait: true,
|
||||
Dial: func() (redis.Conn, error) {
|
||||
dialOptions := []redis.DialOption{
|
||||
redis.DialConnectTimeout(timeout),
|
||||
redis.DialReadTimeout(timeout),
|
||||
redis.DialWriteTimeout(timeout),
|
||||
redis.DialDatabase(cfg.RedisDB),
|
||||
}
|
||||
if cfg.RedisPassword != "" {
|
||||
dialOptions = append(dialOptions, redis.DialPassword(cfg.RedisPassword))
|
||||
}
|
||||
return redis.Dial("tcp", address, dialOptions...)
|
||||
},
|
||||
TestOnBorrow: func(conn redis.Conn, lastUsed time.Time) error {
|
||||
if time.Since(lastUsed) < time.Minute {
|
||||
return nil
|
||||
}
|
||||
_, err := conn.Do("PING")
|
||||
return err
|
||||
},
|
||||
}
|
||||
return &redisRespCache{
|
||||
keyPrefix: respCachePrefix(cfg.KeyPrefix),
|
||||
ttl: ttl,
|
||||
pool: pool,
|
||||
}
|
||||
}
|
||||
|
||||
// Store 将响应缓存写入 Redis 并设置毫秒级 TTL。
|
||||
func (c *redisRespCache) Store(ctx context.Context, key string, response contracts.LogicPlayerResponse) error {
|
||||
if c == nil || c.pool == nil {
|
||||
return errors.New("logic redis response cache is nil")
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(clonePlayerResp(response))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ttlMillis := int64(c.ttl / time.Millisecond)
|
||||
if ttlMillis <= 0 {
|
||||
ttlMillis = int64((10 * time.Minute) / time.Millisecond)
|
||||
}
|
||||
_, err = c.do(ctx, "PSETEX", c.responseKey(key), ttlMillis, data)
|
||||
return err
|
||||
}
|
||||
|
||||
// Get 从 Redis 读取响应缓存。
|
||||
func (c *redisRespCache) Get(ctx context.Context, key string) (contracts.LogicPlayerResponse, bool, error) {
|
||||
if c == nil || c.pool == nil {
|
||||
return contracts.LogicPlayerResponse{}, false, errors.New("logic redis response cache is nil")
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return contracts.LogicPlayerResponse{}, false, nil
|
||||
}
|
||||
value, err := redis.Bytes(c.do(ctx, "GET", c.responseKey(key)))
|
||||
if errors.Is(err, redis.ErrNil) {
|
||||
return contracts.LogicPlayerResponse{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return contracts.LogicPlayerResponse{}, false, err
|
||||
}
|
||||
var response contracts.LogicPlayerResponse
|
||||
if err := json.Unmarshal(value, &response); err != nil {
|
||||
return contracts.LogicPlayerResponse{}, false, err
|
||||
}
|
||||
return clonePlayerResp(response), true, nil
|
||||
}
|
||||
|
||||
// Delete 从 Redis 删除指定响应缓存。
|
||||
func (c *redisRespCache) Delete(ctx context.Context, key string) error {
|
||||
if c == nil || c.pool == nil {
|
||||
return nil
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := c.do(ctx, "DEL", c.responseKey(key))
|
||||
return err
|
||||
}
|
||||
|
||||
// Snapshot 返回 Redis 响应缓存的配置状态。
|
||||
func (c *redisRespCache) Snapshot() map[string]any {
|
||||
out := map[string]any{
|
||||
"backend": "redis",
|
||||
}
|
||||
if c == nil {
|
||||
return out
|
||||
}
|
||||
out["ttl_seconds"] = int64(c.ttl / time.Second)
|
||||
out["key_prefix"] = c.keyPrefix
|
||||
return out
|
||||
}
|
||||
|
||||
// Close 关闭 Redis 响应缓存连接池。
|
||||
func (c *redisRespCache) Close(context.Context) error {
|
||||
if c == nil || c.pool == nil {
|
||||
return nil
|
||||
}
|
||||
return c.pool.Close()
|
||||
}
|
||||
|
||||
func (c *redisRespCache) do(ctx context.Context, command string, args ...any) (reply any, err error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn, err := c.pool.GetContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeRedisConnErr(conn, &err)
|
||||
return conn.Do(command, args...)
|
||||
}
|
||||
|
||||
func (c *redisRespCache) responseKey(key string) string {
|
||||
return c.keyPrefix + ":response:" + digestRespCacheKey(key)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/bus"
|
||||
"longheng.io/server/pkg/contracts"
|
||||
)
|
||||
|
||||
const (
|
||||
respQueueMax = 4096
|
||||
respPubTimeout = 3 * time.Second
|
||||
respRetryInterval = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
var (
|
||||
errRespMissing = errors.New("logic response publisher is missing")
|
||||
errRespClosed = errors.New("logic response queue is closed")
|
||||
errRespFull = errors.New("logic response queue is full")
|
||||
)
|
||||
|
||||
type respPubItem struct {
|
||||
topic string
|
||||
response contracts.LogicPlayerResponse
|
||||
}
|
||||
|
||||
// respPublisher 把 logic 到 gateway 的回包 publish 移出玩家队列热路径。
|
||||
type respPublisher struct {
|
||||
bus bus.Bus
|
||||
logger *slog.Logger
|
||||
max int
|
||||
timeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
pending []respPubItem
|
||||
closed bool
|
||||
flushing bool
|
||||
flushDone chan struct{}
|
||||
errs uint64
|
||||
|
||||
wake chan struct{}
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newRespPublisher(eventBus bus.Bus, logger *slog.Logger) *respPublisher {
|
||||
if eventBus == nil {
|
||||
return nil
|
||||
}
|
||||
publisher := &respPublisher{
|
||||
bus: eventBus,
|
||||
logger: logger,
|
||||
max: respQueueMax,
|
||||
timeout: respPubTimeout,
|
||||
wake: make(chan struct{}, 1),
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go publisher.run()
|
||||
return publisher
|
||||
}
|
||||
|
||||
func (p *respPublisher) Publish(ctx context.Context, topic string, response contracts.LogicPlayerResponse) error {
|
||||
if p == nil || p.bus == nil {
|
||||
return errRespMissing
|
||||
}
|
||||
if err := respCtxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
item := respPubItem{
|
||||
topic: topic,
|
||||
response: clonePlayerResp(response),
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.closed {
|
||||
return errRespClosed
|
||||
}
|
||||
if len(p.pending) >= p.max {
|
||||
atomic.AddUint64(&p.errs, 1)
|
||||
return errRespFull
|
||||
}
|
||||
p.pending = append(p.pending, item)
|
||||
p.wakeLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *respPublisher) Flush(ctx context.Context) error {
|
||||
if p == nil || p.bus == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
for {
|
||||
batch, wait := p.beginFlush()
|
||||
if wait != nil {
|
||||
if err := waitRespFlush(ctx, wait); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := p.writeBatch(ctx, batch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *respPublisher) Close(ctx context.Context) error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := p.closeContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
p.mu.Lock()
|
||||
p.closed = true
|
||||
stop := p.stop
|
||||
done := p.done
|
||||
p.mu.Unlock()
|
||||
if stop != nil {
|
||||
p.once.Do(func() {
|
||||
close(stop)
|
||||
})
|
||||
}
|
||||
if stop != nil && done != nil {
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return p.Flush(ctx)
|
||||
}
|
||||
|
||||
func (p *respPublisher) Errors() uint64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return atomic.LoadUint64(&p.errs)
|
||||
}
|
||||
|
||||
func (p *respPublisher) run() {
|
||||
defer close(p.done)
|
||||
retry := time.NewTimer(time.Hour)
|
||||
stopRespTimer(retry)
|
||||
var retryC <-chan time.Time
|
||||
defer stopRespTimer(retry)
|
||||
for {
|
||||
select {
|
||||
case <-p.wake:
|
||||
if err := p.flushBackground(); err != nil {
|
||||
resetRespTimer(retry, respRetryInterval)
|
||||
retryC = retry.C
|
||||
} else {
|
||||
retryC = nil
|
||||
}
|
||||
case <-retryC:
|
||||
retryC = nil
|
||||
if err := p.flushBackground(); err != nil {
|
||||
resetRespTimer(retry, respRetryInterval)
|
||||
retryC = retry.C
|
||||
}
|
||||
case <-p.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *respPublisher) flushBackground() error {
|
||||
timeout := p.timeout
|
||||
if timeout <= 0 {
|
||||
timeout = respPubTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
err := p.Flush(ctx)
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *respPublisher) beginFlush() ([]respPubItem, <-chan struct{}) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.flushing {
|
||||
return nil, p.flushDone
|
||||
}
|
||||
if len(p.pending) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
batch := cloneRespPubItems(p.pending)
|
||||
p.pending = nil
|
||||
p.flushing = true
|
||||
p.flushDone = make(chan struct{})
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
func (p *respPublisher) writeBatch(ctx context.Context, batch []respPubItem) error {
|
||||
for idx, item := range batch {
|
||||
if err := respCtxErr(ctx); err != nil {
|
||||
p.finishFlush(batch[idx:])
|
||||
return err
|
||||
}
|
||||
if err := p.bus.Publish(ctx, item.topic, item.response); err != nil {
|
||||
atomic.AddUint64(&p.errs, 1)
|
||||
if p.logger != nil {
|
||||
p.logger.Error("logic response publish failed", "topic", item.topic, "account_id", item.response.AccountID, "msg_id", item.response.MsgID, "error", err)
|
||||
}
|
||||
p.finishFlush(batch[idx:])
|
||||
return err
|
||||
}
|
||||
}
|
||||
p.finishFlush(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *respPublisher) finishFlush(failed []respPubItem) {
|
||||
p.mu.Lock()
|
||||
if len(failed) > 0 {
|
||||
restored := cloneRespPubItems(failed)
|
||||
restored = append(restored, p.pending...)
|
||||
p.pending = restored
|
||||
}
|
||||
done := p.flushDone
|
||||
p.flushing = false
|
||||
p.flushDone = nil
|
||||
p.mu.Unlock()
|
||||
if done != nil {
|
||||
close(done)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *respPublisher) wakeLocked() {
|
||||
select {
|
||||
case p.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func cloneRespPubItems(in []respPubItem) []respPubItem {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]respPubItem, len(in))
|
||||
for idx, item := range in {
|
||||
out[idx] = respPubItem{
|
||||
topic: item.topic,
|
||||
response: clonePlayerResp(item.response),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func waitRespFlush(ctx context.Context, done <-chan struct{}) error {
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *respPublisher) closeContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
return ctx, func() {}
|
||||
}
|
||||
timeout := respPubTimeout
|
||||
if p != nil && p.timeout > 0 {
|
||||
timeout = p.timeout
|
||||
}
|
||||
return context.WithTimeout(ctx, timeout)
|
||||
}
|
||||
|
||||
func respCtxErr(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func stopRespTimer(timer *time.Timer) {
|
||||
if timer == nil {
|
||||
return
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resetRespTimer(timer *time.Timer, d time.Duration) {
|
||||
if d <= 0 {
|
||||
d = respRetryInterval
|
||||
}
|
||||
stopRespTimer(timer)
|
||||
timer.Reset(d)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"longheng.io/server/internal/platform/admincmd"
|
||||
"longheng.io/server/internal/runtime/notice"
|
||||
"longheng.io/server/internal/runtime/redeem"
|
||||
)
|
||||
|
||||
// AdminRouteRegistrar 定义 runtime 向 logic admin mux 注册路由的函数。
|
||||
type AdminRouteRegistrar func(*Service, *http.ServeMux) error
|
||||
|
||||
// NoticeAdminRouteRegistrar 返回公告 runtime 的 admin 路由注册器。
|
||||
func NoticeAdminRouteRegistrar(notices notice.Service) AdminRouteRegistrar {
|
||||
return func(s *Service, mux *http.ServeMux) error {
|
||||
if notices.Store == nil {
|
||||
return nil
|
||||
}
|
||||
prefix := s.adminPrefix + "/notice"
|
||||
return notice.RegisterAdminRoutes(mux, notices, notice.AdminOptions{
|
||||
Prefix: prefix,
|
||||
Wrap: s.admin,
|
||||
MutateWrap: func(operation string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.adminDangerous(operation, next)
|
||||
},
|
||||
CommandHooks: notice.AdminCommandHooks{
|
||||
Submit: func(r *http.Request, operation, target, reason string, params any) (admincmd.Receipt, bool, bool, error) {
|
||||
return s.submitOpsCmd(r, operation, target, reason, params)
|
||||
},
|
||||
MarkSucceeded: func(ctx context.Context, receipt admincmd.Receipt) admincmd.Receipt {
|
||||
return s.markOpsAdminOK(ctx, receipt)
|
||||
},
|
||||
MarkFailed: func(ctx context.Context, receipt admincmd.Receipt, err error) {
|
||||
s.markOpsCmdFail(ctx, receipt, err)
|
||||
},
|
||||
WriteError: writeOpsCmdErr,
|
||||
WriteDuplicate: writeOpsDup,
|
||||
WriteJSONWithReceipt: writeOpsAdminReceipt,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RedeemAdminRouteRegistrar 返回兑换码 runtime 的 admin 路由注册器。
|
||||
func RedeemAdminRouteRegistrar(redeems redeem.Service) AdminRouteRegistrar {
|
||||
return func(s *Service, mux *http.ServeMux) error {
|
||||
if redeems.Store == nil {
|
||||
return nil
|
||||
}
|
||||
prefix := s.adminPrefix + "/redeem"
|
||||
return redeem.RegisterAdminRoutes(mux, redeems, redeem.AdminOptions{
|
||||
Prefix: prefix,
|
||||
Wrap: s.admin,
|
||||
MutateWrap: func(operation string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.adminDangerous(operation, next)
|
||||
},
|
||||
CommandHooks: redeem.AdminCommandHooks{
|
||||
Submit: func(r *http.Request, operation, target, reason string, params any) (admincmd.Receipt, bool, bool, error) {
|
||||
return s.submitOpsCmd(r, operation, target, reason, params)
|
||||
},
|
||||
MarkSucceeded: func(ctx context.Context, receipt admincmd.Receipt) admincmd.Receipt {
|
||||
return s.markOpsAdminOK(ctx, receipt)
|
||||
},
|
||||
MarkFailed: func(ctx context.Context, receipt admincmd.Receipt, err error) {
|
||||
s.markOpsCmdFail(ctx, receipt, err)
|
||||
},
|
||||
WriteError: writeOpsCmdErr,
|
||||
WriteDuplicate: writeOpsDup,
|
||||
WriteJSONWithReceipt: writeOpsAdminReceipt,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) registerAdminRoutes(mux *http.ServeMux) {
|
||||
for _, register := range s.adminRoutes {
|
||||
if register == nil {
|
||||
continue
|
||||
}
|
||||
if err := register(s, mux); err != nil {
|
||||
s.initErr = errors.Join(s.initErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/dispatch"
|
||||
"longheng.io/server/internal/platform/eventloop"
|
||||
"longheng.io/server/pkg/contracts"
|
||||
"longheng.io/server/pkg/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
asyncPublishTimeout = 3 * time.Second
|
||||
logicHandlerTimeout = 5 * time.Second
|
||||
logicMetricEventType = "logic.metric.inc"
|
||||
logicDrainWakeType = "logic.metric.drain_wake"
|
||||
)
|
||||
|
||||
type logicMetricEvent struct {
|
||||
Name string
|
||||
Labels map[string]string
|
||||
}
|
||||
|
||||
func (s *Service) handleSessConnected(ctx context.Context, evt contracts.SessionConnected) error {
|
||||
evt.AccountID = strings.TrimSpace(evt.AccountID)
|
||||
evt.SessionID = strings.TrimSpace(evt.SessionID)
|
||||
if s.accounts == nil {
|
||||
return nil
|
||||
}
|
||||
profile, err := s.accounts.Connect(ctx, evt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.world != nil {
|
||||
s.world.ObservePlayer(profile.AccountID, profile.State)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) handleSessionDisc(ctx context.Context, evt contracts.SessionDisconnected) error {
|
||||
evt.AccountID = strings.TrimSpace(evt.AccountID)
|
||||
evt.SessionID = strings.TrimSpace(evt.SessionID)
|
||||
if s.accounts == nil {
|
||||
return nil
|
||||
}
|
||||
profile, saved, err := s.accounts.Disconnect(ctx, evt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if saved && s.world != nil {
|
||||
s.world.ObservePlayer(profile.AccountID, profile.State)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) newLogicDispatcher() (*dispatch.Mux, error) {
|
||||
mux := dispatch.NewWithOptions(dispatch.MuxOptions{HandlerTimeout: logicHandlerTimeout})
|
||||
if s.registerHandlers != nil {
|
||||
if err := s.registerHandlers(mux); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
mux.HandleDefault(func(_ context.Context, req dispatch.Request) (dispatch.Response, error) {
|
||||
return dispatch.Response{}, protocol.Errorf(protocol.CodeNotImplemented, "unsupported msg_id %d", req.MsgID)
|
||||
})
|
||||
return mux, nil
|
||||
}
|
||||
|
||||
func (s *Service) incMetric(name string, labels map[string]string) {
|
||||
if s.metrics == nil {
|
||||
return
|
||||
}
|
||||
s.metrics.Inc(name, s.metricLabels(labels))
|
||||
}
|
||||
|
||||
func (s *Service) metricLabels(labels map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(labels)+2)
|
||||
out["service"] = s.metricServiceLabel
|
||||
if s.nodeID != "" {
|
||||
out["node_id"] = s.nodeID
|
||||
}
|
||||
for key, value := range labels {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) incMetricLow(name string, labels map[string]string) {
|
||||
if s == nil || s.metricEventLoop == nil {
|
||||
s.incMetric(name, labels)
|
||||
return
|
||||
}
|
||||
event := eventloop.Event{
|
||||
Type: logicMetricEventType,
|
||||
Priority: eventloop.PriorityLow,
|
||||
Data: logicMetricEvent{
|
||||
Name: name,
|
||||
Labels: cloneMetricLabels(labels),
|
||||
},
|
||||
}
|
||||
if err := s.metricEventLoop.Submit(event); err != nil {
|
||||
s.incMetric(name, labels)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) startMetricEventLoop(ctx context.Context) error {
|
||||
if s == nil || s.metrics == nil {
|
||||
return nil
|
||||
}
|
||||
if s.metricEventLoop != nil {
|
||||
return nil
|
||||
}
|
||||
loop := eventloop.New("logic-metrics", eventloop.ProcessorFunc(func(_ context.Context, event eventloop.Event) eventloop.Result {
|
||||
metric, ok := event.Data.(logicMetricEvent)
|
||||
if !ok || event.Type != logicMetricEventType {
|
||||
return eventloop.Result{}
|
||||
}
|
||||
s.incMetric(metric.Name, metric.Labels)
|
||||
return eventloop.Result{}
|
||||
}), eventloop.Options{QueueSize: 1024, Logger: s.logger})
|
||||
if err := loop.Start(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
s.metricEventLoop = loop
|
||||
s.regEventLoopMetrics(loop)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) drainMetricEventLoop(ctx context.Context) error {
|
||||
if s == nil || s.metricEventLoop == nil {
|
||||
return nil
|
||||
}
|
||||
_ = s.metricEventLoop.Submit(eventloop.Event{Type: logicDrainWakeType, Priority: eventloop.PriorityLow})
|
||||
err := s.metricEventLoop.Drain(ctx)
|
||||
if errors.Is(err, eventloop.ErrNotStarted) || errors.Is(err, eventloop.ErrClosed) {
|
||||
err = nil
|
||||
}
|
||||
if err == nil {
|
||||
s.metricEventLoop = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func cloneMetricLabels(labels map[string]string) map[string]string {
|
||||
if len(labels) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(labels))
|
||||
for key, value := range labels {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) publishAsync(topic string, payload any) {
|
||||
pub := s.ensureEventPub()
|
||||
if pub == nil {
|
||||
return
|
||||
}
|
||||
if err := pub.Publish(topic, payload); err != nil && s.logger != nil {
|
||||
s.logger.Error("logic async publish enqueue failed", "topic", topic, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ensureEventPub() *eventPub {
|
||||
if s == nil || s.bus == nil {
|
||||
return nil
|
||||
}
|
||||
s.eventOutMu.Lock()
|
||||
defer s.eventOutMu.Unlock()
|
||||
if s.eventOut == nil {
|
||||
s.eventOut = newEventPub(s.bus, s.logger)
|
||||
}
|
||||
return s.eventOut
|
||||
}
|
||||
|
||||
func (s *Service) eventPublisher() *eventPub {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.eventOutMu.Lock()
|
||||
defer s.eventOutMu.Unlock()
|
||||
return s.eventOut
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/samples/world"
|
||||
"longheng.io/server/internal/platform/admincmdqueue"
|
||||
appkit "longheng.io/server/internal/platform/app"
|
||||
"longheng.io/server/internal/platform/bus"
|
||||
"longheng.io/server/internal/platform/db"
|
||||
"longheng.io/server/internal/platform/dispatch"
|
||||
"longheng.io/server/internal/platform/eventlog"
|
||||
"longheng.io/server/internal/platform/eventloop"
|
||||
"longheng.io/server/internal/platform/health"
|
||||
"longheng.io/server/internal/platform/idempotency"
|
||||
"longheng.io/server/internal/platform/metrics"
|
||||
modulemgr "longheng.io/server/internal/platform/module"
|
||||
"longheng.io/server/internal/platform/playerloop"
|
||||
"longheng.io/server/internal/platform/readmodel"
|
||||
"longheng.io/server/internal/platform/runtimeguard"
|
||||
"longheng.io/server/internal/platform/workerpool"
|
||||
"longheng.io/server/internal/reference/player"
|
||||
"longheng.io/server/internal/runtime/leaderboard"
|
||||
"longheng.io/server/internal/runtime/moderation"
|
||||
"longheng.io/server/internal/runtime/notice"
|
||||
"longheng.io/server/internal/runtime/redeem"
|
||||
"longheng.io/server/pkg/contracts"
|
||||
)
|
||||
|
||||
// Service 是 logic 服务入口,负责调度、玩家队列、响应缓存和运行时模块装配。
|
||||
type Service struct {
|
||||
nodeID string
|
||||
componentName string
|
||||
adminPrefix string
|
||||
metricServiceLabel string
|
||||
startedService string
|
||||
environment string
|
||||
logger *slog.Logger
|
||||
bus bus.Bus
|
||||
workers *workerpool.Pool
|
||||
health *health.Service
|
||||
metrics *metrics.Registry
|
||||
|
||||
modules *modulemgr.Manager
|
||||
players *player.Module
|
||||
readModels *readmodel.AdminRegistry
|
||||
accounts *AccountManager
|
||||
world *world.Module
|
||||
leaderboards leaderboard.Runtime
|
||||
sanctions moderation.SanctionStore
|
||||
notices notice.Service
|
||||
redeems redeem.Service
|
||||
extensions ExtensionPoints
|
||||
runtimeProfile runtimeguard.Profile
|
||||
eventLog *eventlog.Log
|
||||
adminCommands *admincmdqueue.Executor
|
||||
dispatcher *dispatch.Mux
|
||||
metricEventLoop *eventloop.Loop
|
||||
adminRoutes []AdminRouteRegistrar
|
||||
registerHandlers func(*dispatch.Mux) error
|
||||
idempotency *idempotency.Guard
|
||||
responses logicResponseCache
|
||||
responsesOut *respPublisher
|
||||
eventOutMu sync.Mutex
|
||||
eventOut *eventPub
|
||||
playerLoops *playerloop.Manager
|
||||
subsMu sync.Mutex
|
||||
subs []bus.Subscription
|
||||
admin func(http.HandlerFunc) http.HandlerFunc
|
||||
adminDangerous func(string, http.HandlerFunc) http.HandlerFunc
|
||||
initErr error
|
||||
|
||||
sessionMu sync.Mutex
|
||||
activeSessions map[string]string
|
||||
closedSessions map[string]struct{}
|
||||
handlerMetricMu sync.Mutex
|
||||
handlerLatencyMax map[string]int64
|
||||
}
|
||||
|
||||
// Options 是 logic 服务创建时的可选覆盖项。
|
||||
type Options struct {
|
||||
ComponentName string
|
||||
AdminPrefix string
|
||||
MetricServiceLabel string
|
||||
StartedService string
|
||||
Game GameManifestProvider
|
||||
}
|
||||
|
||||
type runtimeStoreCloser interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// New 使用默认配置创建 logic 服务。
|
||||
func New(env *appkit.Env) *Service {
|
||||
return NewWithOptions(env, Options{})
|
||||
}
|
||||
|
||||
// NewPlayerD 创建 playerd 服务实例。
|
||||
func NewPlayerD(env *appkit.Env) *Service {
|
||||
return NewPlayerDWithManifest(env, DefaultPlayerDManifest)
|
||||
}
|
||||
|
||||
// NewPlayerDWithManifest 使用指定 manifest 创建 playerd 服务实例。
|
||||
func NewPlayerDWithManifest(env *appkit.Env, game GameManifestProvider) *Service {
|
||||
if game == nil {
|
||||
game = DefaultPlayerDManifest
|
||||
}
|
||||
return NewWithOptions(env, Options{
|
||||
ComponentName: "playerd-service",
|
||||
AdminPrefix: "/playerd",
|
||||
MetricServiceLabel: "playerd",
|
||||
StartedService: "playerd",
|
||||
Game: game,
|
||||
})
|
||||
}
|
||||
|
||||
// NewWithOptions 使用指定 manifest 和选项创建 logic 服务。
|
||||
func NewWithOptions(env *appkit.Env, options Options) *Service {
|
||||
if env == nil {
|
||||
env = &appkit.Env{}
|
||||
}
|
||||
options = normalizeOptions(options)
|
||||
// NewWithOptions 是 logic/playerd 的统一装配入口:平台依赖来自 Env,玩法侧能力来自 GameManifest。
|
||||
// 默认 logic 使用空玩法,真实项目应显式传入自己的 manifest,避免把示例模块误认为平台核心。
|
||||
wiring := newLogicWiring(env, options)
|
||||
game := wiring.game
|
||||
core := env.Core()
|
||||
realtime := env.Realtime()
|
||||
persistence := env.Persistence()
|
||||
admin := env.AdminRuntime()
|
||||
|
||||
s := &Service{
|
||||
nodeID: core.Config.Service.NodeID,
|
||||
componentName: options.ComponentName,
|
||||
adminPrefix: options.AdminPrefix,
|
||||
metricServiceLabel: options.MetricServiceLabel,
|
||||
startedService: options.StartedService,
|
||||
environment: core.Config.Service.Environment,
|
||||
logger: core.Logger,
|
||||
bus: realtime.Bus,
|
||||
workers: realtime.Workers,
|
||||
health: core.Health,
|
||||
metrics: wiring.metrics,
|
||||
modules: wiring.modules,
|
||||
players: game.Players,
|
||||
readModels: newLogicReadModels(),
|
||||
accounts: wiring.accounts,
|
||||
world: game.World,
|
||||
leaderboards: game.Leaderboards,
|
||||
sanctions: game.Sanctions,
|
||||
notices: game.Notices,
|
||||
redeems: game.Redeems,
|
||||
extensions: game.Extensions,
|
||||
runtimeProfile: wiring.runtimeProfile,
|
||||
eventLog: persistence.EventLog,
|
||||
adminCommands: admin.Commands,
|
||||
adminRoutes: append([]AdminRouteRegistrar(nil), game.AdminRoutes...),
|
||||
registerHandlers: game.RegisterHandlers,
|
||||
idempotency: newIdempotencyGuard(core.Config.Idempotency),
|
||||
responses: newLogicRespCache(core.Config.Idempotency),
|
||||
responsesOut: newRespPublisher(realtime.Bus, core.Logger),
|
||||
eventOut: newEventPub(realtime.Bus, core.Logger),
|
||||
activeSessions: make(map[string]string),
|
||||
closedSessions: make(map[string]struct{}),
|
||||
admin: logicAdminWrapper(env, options.MetricServiceLabel),
|
||||
adminDangerous: logicAdminGuard(env, options.MetricServiceLabel),
|
||||
initErr: wiring.gameErr,
|
||||
}
|
||||
s.wireLogicRuntime(env, game)
|
||||
s.registerRoutes(env.AdminMux)
|
||||
return s
|
||||
}
|
||||
|
||||
// ExtensionPoints 返回项目侧可挂接的平台扩展点。
|
||||
func (s *Service) ExtensionPoints() ExtensionPoints {
|
||||
if s == nil {
|
||||
return ExtensionPoints{}
|
||||
}
|
||||
return s.extensions
|
||||
}
|
||||
|
||||
func normalizeOptions(options Options) Options {
|
||||
if options.ComponentName == "" {
|
||||
options.ComponentName = "logic-service"
|
||||
}
|
||||
if options.AdminPrefix == "" {
|
||||
options.AdminPrefix = "/logic"
|
||||
}
|
||||
options.AdminPrefix = "/" + strings.Trim(strings.TrimSpace(options.AdminPrefix), "/")
|
||||
if options.MetricServiceLabel == "" {
|
||||
options.MetricServiceLabel = "logic"
|
||||
}
|
||||
if options.StartedService == "" {
|
||||
options.StartedService = "logic"
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// Name 返回 logic 服务生命周期组件名。
|
||||
func (s *Service) Name() string {
|
||||
return s.componentName
|
||||
}
|
||||
|
||||
// Preflight 校验 logic 服务启动前所需依赖。
|
||||
func (s *Service) Preflight(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if s.initErr != nil {
|
||||
return s.initErr
|
||||
}
|
||||
if err := runtimeguard.Verify(s.runtimeProfile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.checkStoreBackend("notice", s.notices.Store); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.checkStoreBackend("redeem", s.redeems.Store); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.modules.Preflight(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) checkStoreBackend(name string, store any) error {
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
describer, ok := store.(runtimeguard.BackendDescriber)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return runtimeguard.CheckDescriber(name, describer, s.runtimeProfile)
|
||||
}
|
||||
|
||||
func (s *Service) gatewayPacketTopic() string {
|
||||
if topic := contracts.LogicNodePacketTopic(s.nodeID); topic != "" {
|
||||
return topic
|
||||
}
|
||||
return contracts.TopicGatewayClientPacket
|
||||
}
|
||||
|
||||
func (s *Service) sessionOnTopic() string {
|
||||
if topic := contracts.LogicSessConnTopic(s.nodeID); topic != "" {
|
||||
return topic
|
||||
}
|
||||
return contracts.TopicSessionConnected
|
||||
}
|
||||
|
||||
func (s *Service) sessionOffTopic() string {
|
||||
if topic := contracts.LogicSessDiscTopic(s.nodeID); topic != "" {
|
||||
return topic
|
||||
}
|
||||
return contracts.TopicSessionDisconnected
|
||||
}
|
||||
|
||||
func (s *Service) acceptsLogicTarget(target string) bool {
|
||||
target = strings.TrimSpace(target)
|
||||
return target == "" || s.nodeID == "" || target == s.nodeID
|
||||
}
|
||||
|
||||
// Start 启动 logic 订阅、玩家队列、运行时模块和后台组件。
|
||||
func (s *Service) Start(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := s.Preflight(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
s.resetSessionTracking()
|
||||
if err := s.modules.Start(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.playerLoops.Start(ctx); err != nil {
|
||||
_ = s.modules.Stop(ctx)
|
||||
return err
|
||||
}
|
||||
if s.accounts != nil {
|
||||
if err := s.accounts.Start(ctx); err != nil {
|
||||
_ = s.playerLoops.Stop(ctx)
|
||||
_ = s.modules.Stop(ctx)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Start 的订阅按依赖顺序建立,任一订阅失败都会回滚已启动的模块和玩家队列,避免半启动服务接收流量。
|
||||
stateSub, err := s.bus.Subscribe(contracts.TopicPlayerStateChange, func(_ context.Context, env bus.Envelope) error {
|
||||
if evt, ok := env.Payload.(contracts.PlayerStateChanged); ok {
|
||||
if s.world != nil {
|
||||
s.world.ObservePlayer(evt.AccountID, evt.State)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if s.accounts != nil {
|
||||
_ = s.accounts.Stop(ctx)
|
||||
}
|
||||
_ = s.playerLoops.Stop(ctx)
|
||||
_ = s.modules.Stop(ctx)
|
||||
return err
|
||||
}
|
||||
s.addSubscription(stateSub)
|
||||
|
||||
connectSub, err := s.bus.Subscribe(s.sessionOnTopic(), func(ctx context.Context, env bus.Envelope) error {
|
||||
evt, ok := env.Payload.(contracts.SessionConnected)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !s.acceptsLogicTarget(evt.TargetLogicNodeID) {
|
||||
return nil
|
||||
}
|
||||
evt.AccountID = strings.TrimSpace(evt.AccountID)
|
||||
evt.SessionID = strings.TrimSpace(evt.SessionID)
|
||||
// 先更新 session 投影再投递玩家队列;如果投递失败必须回滚投影,避免后续包被错误放行。
|
||||
s.markSessionConnected(evt.AccountID, evt.SessionID)
|
||||
if err := s.playerLoops.Submit(ctx, evt.AccountID, evt); err != nil {
|
||||
s.clearCurSession(evt.AccountID, evt.SessionID)
|
||||
return playerLoopErr(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
s.closeSubscriptions()
|
||||
if s.accounts != nil {
|
||||
_ = s.accounts.Stop(ctx)
|
||||
}
|
||||
_ = s.playerLoops.Stop(ctx)
|
||||
_ = s.modules.Stop(ctx)
|
||||
return err
|
||||
}
|
||||
s.addSubscription(connectSub)
|
||||
|
||||
disconnectSub, err := s.bus.Subscribe(s.sessionOffTopic(), func(ctx context.Context, env bus.Envelope) error {
|
||||
evt, ok := env.Payload.(contracts.SessionDisconnected)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !s.acceptsLogicTarget(evt.TargetLogicNodeID) {
|
||||
return nil
|
||||
}
|
||||
evt.AccountID = strings.TrimSpace(evt.AccountID)
|
||||
evt.SessionID = strings.TrimSpace(evt.SessionID)
|
||||
// 断开事件允许乱序到达,markSessGone 会拒绝覆盖更晚的新 session。
|
||||
accepted, hadCurrent := s.markSessGone(evt.AccountID, evt.SessionID)
|
||||
if !accepted {
|
||||
return nil
|
||||
}
|
||||
if err := s.playerLoops.Submit(ctx, evt.AccountID, evt); err != nil {
|
||||
s.restoreSessGone(evt.AccountID, evt.SessionID, hadCurrent)
|
||||
return playerLoopErr(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
s.closeSubscriptions()
|
||||
if s.accounts != nil {
|
||||
_ = s.accounts.Stop(ctx)
|
||||
}
|
||||
_ = s.playerLoops.Stop(ctx)
|
||||
_ = s.modules.Stop(ctx)
|
||||
return err
|
||||
}
|
||||
s.addSubscription(disconnectSub)
|
||||
|
||||
packetSub, err := s.bus.Subscribe(s.gatewayPacketTopic(), func(ctx context.Context, env bus.Envelope) error {
|
||||
evt, ok := env.Payload.(contracts.GatewayClientPacket)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !s.acceptsLogicTarget(evt.TargetLogicNodeID) {
|
||||
return nil
|
||||
}
|
||||
evt.AccountID = strings.TrimSpace(evt.AccountID)
|
||||
evt.SessionID = strings.TrimSpace(evt.SessionID)
|
||||
// 所有客户端包先进入玩家队列串行化,真正的 route policy、幂等和 handler 执行在 dispatch flow 里完成。
|
||||
if err := s.playerLoops.Submit(ctx, evt.AccountID, evt); err != nil {
|
||||
s.incMetric("logic_packet_errors_total", map[string]string{"stage": "submit_playerloop"})
|
||||
return s.publishProtocolError(ctx, evt, playerLoopErr(err))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
s.closeSubscriptions()
|
||||
if s.accounts != nil {
|
||||
_ = s.accounts.Stop(ctx)
|
||||
}
|
||||
_ = s.playerLoops.Stop(ctx)
|
||||
_ = s.modules.Stop(ctx)
|
||||
return err
|
||||
}
|
||||
s.addSubscription(packetSub)
|
||||
|
||||
if err := s.startMetricEventLoop(ctx); err != nil {
|
||||
_ = s.Stop(context.Background())
|
||||
return err
|
||||
}
|
||||
|
||||
if s.health != nil {
|
||||
s.health.SetComponent(s.Name(), health.StateReady, "logic modules running")
|
||||
}
|
||||
if err := s.bus.Publish(ctx, contracts.TopicServiceStarted, contracts.ServiceStarted{
|
||||
Service: s.startedService,
|
||||
NodeID: s.nodeID,
|
||||
StartedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
_ = s.Stop(context.Background())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) addSubscription(sub bus.Subscription) {
|
||||
if s == nil || sub == nil {
|
||||
return
|
||||
}
|
||||
s.subsMu.Lock()
|
||||
s.subs = append(s.subs, sub)
|
||||
s.subsMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Service) closeSubscriptions() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.subsMu.Lock()
|
||||
subs := append([]bus.Subscription(nil), s.subs...)
|
||||
s.subs = nil
|
||||
s.subsMu.Unlock()
|
||||
for _, sub := range subs {
|
||||
if sub != nil {
|
||||
_ = sub.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 停止 logic 服务并释放订阅、玩家队列和缓存资源。
|
||||
func (s *Service) Stop(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
s.closeSubscriptions()
|
||||
var stopErr error
|
||||
stopErr = errors.Join(stopErr, s.drainMetricEventLoop(ctx))
|
||||
if s.playerLoops != nil {
|
||||
stopErr = errors.Join(stopErr, s.playerLoops.Stop(ctx))
|
||||
}
|
||||
if pub := s.eventPublisher(); pub != nil {
|
||||
stopErr = errors.Join(stopErr, pub.Close(ctx))
|
||||
}
|
||||
if s.responsesOut != nil {
|
||||
stopErr = errors.Join(stopErr, s.responsesOut.Close(ctx))
|
||||
}
|
||||
if s.accounts != nil {
|
||||
stopErr = errors.Join(stopErr, s.accounts.Stop(ctx))
|
||||
}
|
||||
if s.idempotency != nil {
|
||||
stopErr = errors.Join(stopErr, s.idempotency.Close(ctx))
|
||||
}
|
||||
if s.responses != nil {
|
||||
stopErr = errors.Join(stopErr, s.responses.Close(ctx))
|
||||
}
|
||||
if closer, ok := s.notices.Store.(runtimeStoreCloser); ok {
|
||||
stopErr = errors.Join(stopErr, closer.Close())
|
||||
}
|
||||
stopErr = errors.Join(stopErr, db.CloseOrFlush(ctx, s.notices.Publisher))
|
||||
if closer, ok := s.redeems.Store.(runtimeStoreCloser); ok {
|
||||
stopErr = errors.Join(stopErr, closer.Close())
|
||||
}
|
||||
stopErr = errors.Join(stopErr, db.CloseOrFlush(ctx, s.leaderboards))
|
||||
stopErr = errors.Join(stopErr, s.modules.Stop(ctx))
|
||||
s.resetSessionTracking()
|
||||
return stopErr
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
appkit "longheng.io/server/internal/platform/app"
|
||||
"longheng.io/server/internal/platform/dispatch"
|
||||
"longheng.io/server/internal/platform/metrics"
|
||||
modulemgr "longheng.io/server/internal/platform/module"
|
||||
"longheng.io/server/internal/platform/playerloop"
|
||||
"longheng.io/server/internal/platform/readmodel"
|
||||
"longheng.io/server/internal/platform/runtimeguard"
|
||||
)
|
||||
|
||||
type logicServiceWiring struct {
|
||||
game GameManifest
|
||||
gameErr error
|
||||
metrics *metrics.Registry
|
||||
modules *modulemgr.Manager
|
||||
accounts *AccountManager
|
||||
runtimeProfile runtimeguard.Profile
|
||||
}
|
||||
|
||||
func newLogicWiring(env *appkit.Env, options Options) logicServiceWiring {
|
||||
if env == nil {
|
||||
env = &appkit.Env{}
|
||||
}
|
||||
core := env.Core()
|
||||
gameProvider := options.Game
|
||||
if gameProvider == nil {
|
||||
gameProvider = DefaultGameManifest
|
||||
}
|
||||
game, gameErr := gameProvider(env)
|
||||
metricsRegistry := core.Metrics
|
||||
if metricsRegistry == nil {
|
||||
metricsRegistry = metrics.New()
|
||||
}
|
||||
profile := runtimeguard.ProfileFromEnvironment(core.Config.Service.Environment, core.Config.Topology.Enabled)
|
||||
manager := modulemgr.New("logic-modules", core.Logger, core.Health)
|
||||
manager.SetRuntimeGuardProfile(profile)
|
||||
for _, mod := range game.Modules {
|
||||
if mod != nil {
|
||||
manager.Add(mod)
|
||||
}
|
||||
}
|
||||
accounts := game.Accounts
|
||||
if accounts == nil && game.Players != nil {
|
||||
accounts = newAccountManager(game.Players, core.Logger, AccountManagerOptions{})
|
||||
}
|
||||
return logicServiceWiring{
|
||||
game: game,
|
||||
gameErr: gameErr,
|
||||
metrics: metricsRegistry,
|
||||
modules: manager,
|
||||
accounts: accounts,
|
||||
runtimeProfile: profile,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) wireLogicRuntime(env *appkit.Env, game GameManifest) {
|
||||
if env == nil {
|
||||
env = &appkit.Env{}
|
||||
}
|
||||
core := env.Core()
|
||||
if game.RegisterReadModels != nil {
|
||||
game.RegisterReadModels(s.readModels)
|
||||
} else if s.players != nil {
|
||||
s.regReadModelAdmins()
|
||||
}
|
||||
dispatcher, err := s.newLogicDispatcher()
|
||||
if err != nil {
|
||||
s.initErr = errors.Join(s.initErr, err)
|
||||
dispatcher = dispatch.New()
|
||||
}
|
||||
s.dispatcher = dispatcher
|
||||
if env.Cluster != nil {
|
||||
env.Cluster.SetRoutes(routesFromHandlers(dispatcher.Snapshot()))
|
||||
}
|
||||
s.playerLoops = playerloop.NewWithOptions("lg-loops", 128, s.handlePlayerEvent, core.Logger, playerloop.Options{
|
||||
IdleTTL: 10 * time.Minute,
|
||||
SweepInterval: time.Minute,
|
||||
HandlerTimeout: logicHandlerTimeout,
|
||||
})
|
||||
s.regRuntimeMetrics()
|
||||
}
|
||||
|
||||
func newLogicReadModels() *readmodel.AdminRegistry {
|
||||
return readmodel.NewAdminRegistry()
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package logic
|
||||
|
||||
import "strings"
|
||||
|
||||
func (s *Service) markSessionConnected(accountID, sessionID string) {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if accountID == "" || sessionID == "" {
|
||||
return
|
||||
}
|
||||
// logic 只认每个账号的最新 session;新连接到达后要清掉同 session 的关闭标记,避免后续包被误判 stale。
|
||||
s.sessionMu.Lock()
|
||||
s.activeSessions[accountID] = sessionID
|
||||
delete(s.closedSessions, sessionKey(accountID, sessionID))
|
||||
s.sessionMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Service) markSessGone(accountID, sessionID string) (bool, bool) {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if accountID == "" || sessionID == "" {
|
||||
return true, false
|
||||
}
|
||||
s.sessionMu.Lock()
|
||||
defer s.sessionMu.Unlock()
|
||||
// 旧 session 的断开事件可能晚于新 session 连接事件到达;这种情况只记录关闭,不允许它清掉当前在线投影。
|
||||
current, ok := s.activeSessions[accountID]
|
||||
if ok && current != sessionID {
|
||||
s.closedSessions[sessionKey(accountID, sessionID)] = struct{}{}
|
||||
return false, false
|
||||
}
|
||||
if ok {
|
||||
delete(s.activeSessions, accountID)
|
||||
}
|
||||
s.closedSessions[sessionKey(accountID, sessionID)] = struct{}{}
|
||||
return true, ok
|
||||
}
|
||||
|
||||
func (s *Service) clearCurSession(accountID, sessionID string) {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if accountID == "" || sessionID == "" {
|
||||
return
|
||||
}
|
||||
s.sessionMu.Lock()
|
||||
if s.activeSessions[accountID] == sessionID {
|
||||
delete(s.activeSessions, accountID)
|
||||
}
|
||||
s.sessionMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Service) restoreSessGone(accountID, sessionID string, hadCurrent bool) {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if accountID == "" || sessionID == "" {
|
||||
return
|
||||
}
|
||||
s.sessionMu.Lock()
|
||||
delete(s.closedSessions, sessionKey(accountID, sessionID))
|
||||
if hadCurrent {
|
||||
s.activeSessions[accountID] = sessionID
|
||||
}
|
||||
s.sessionMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Service) isStaleSession(accountID, sessionID string) bool {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if accountID == "" || sessionID == "" {
|
||||
return false
|
||||
}
|
||||
s.sessionMu.Lock()
|
||||
defer s.sessionMu.Unlock()
|
||||
// 包处理前做 stale session 检查,防止被踢下线或重连前的旧连接继续修改 Profile。
|
||||
current, ok := s.activeSessions[accountID]
|
||||
if ok {
|
||||
return current != sessionID
|
||||
}
|
||||
_, closed := s.closedSessions[sessionKey(accountID, sessionID)]
|
||||
return closed
|
||||
}
|
||||
|
||||
func (s *Service) resetSessionTracking() {
|
||||
s.sessionMu.Lock()
|
||||
s.activeSessions = make(map[string]string)
|
||||
s.closedSessions = make(map[string]struct{})
|
||||
s.sessionMu.Unlock()
|
||||
}
|
||||
|
||||
func sessionKey(accountID, sessionID string) string {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
return accountID + "\x00" + sessionID
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appkit "longheng.io/server/internal/platform/app"
|
||||
"longheng.io/server/internal/platform/config"
|
||||
"longheng.io/server/internal/platform/idempotency"
|
||||
"longheng.io/server/internal/reference/player"
|
||||
)
|
||||
|
||||
func newPlayerStore(cfg config.ProfileStoreSection, env *appkit.Env) player.Store {
|
||||
store, _ := newPlayerStores(cfg, env)
|
||||
return store
|
||||
}
|
||||
|
||||
func newIdempotencyGuard(cfg config.IdempotencySection) *idempotency.Guard {
|
||||
ttl := time.Duration(cfg.TTLSeconds) * time.Second
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.Kind)) {
|
||||
case "redis":
|
||||
return idempotency.NewRedis(idempotency.RedisOptions{
|
||||
Address: cfg.RedisAddress,
|
||||
Password: cfg.RedisPassword,
|
||||
DB: cfg.RedisDB,
|
||||
KeyPrefix: cfg.KeyPrefix,
|
||||
PoolSize: cfg.RedisPoolSize,
|
||||
Timeout: time.Duration(cfg.RedisTimeoutSeconds) * time.Second,
|
||||
ConnectTimeout: time.Duration(cfg.RedisConnectTimeoutSecs) * time.Second,
|
||||
ReadTimeout: time.Duration(cfg.RedisReadTimeoutSecs) * time.Second,
|
||||
WriteTimeout: time.Duration(cfg.RedisWriteTimeoutSecs) * time.Second,
|
||||
TTL: ttl,
|
||||
})
|
||||
case "mysql":
|
||||
return idempotency.NewMySQL(idempotency.MySQLOptions{
|
||||
DSN: cfg.MySQLDSN,
|
||||
MaxOpenConns: cfg.MySQLMaxOpenConns,
|
||||
MaxIdleConns: cfg.MySQLMaxIdleConns,
|
||||
ConnMaxLifetime: time.Duration(cfg.MySQLConnMaxLifetimeSec) * time.Second,
|
||||
KeyPrefix: cfg.KeyPrefix,
|
||||
TTL: ttl,
|
||||
EnsureSchema: true,
|
||||
})
|
||||
case "mysql_redis":
|
||||
return idempotency.NewMySQLRedis(idempotency.MySQLRedisOptions{
|
||||
TTL: ttl,
|
||||
Redis: idempotency.RedisOptions{
|
||||
Address: cfg.RedisAddress,
|
||||
Password: cfg.RedisPassword,
|
||||
DB: cfg.RedisDB,
|
||||
KeyPrefix: cfg.KeyPrefix,
|
||||
PoolSize: cfg.RedisPoolSize,
|
||||
Timeout: time.Duration(cfg.RedisTimeoutSeconds) * time.Second,
|
||||
ConnectTimeout: time.Duration(cfg.RedisConnectTimeoutSecs) * time.Second,
|
||||
ReadTimeout: time.Duration(cfg.RedisReadTimeoutSecs) * time.Second,
|
||||
WriteTimeout: time.Duration(cfg.RedisWriteTimeoutSecs) * time.Second,
|
||||
},
|
||||
MySQL: idempotency.MySQLOptions{
|
||||
DSN: cfg.MySQLDSN,
|
||||
MaxOpenConns: cfg.MySQLMaxOpenConns,
|
||||
MaxIdleConns: cfg.MySQLMaxIdleConns,
|
||||
ConnMaxLifetime: time.Duration(cfg.MySQLConnMaxLifetimeSec) * time.Second,
|
||||
KeyPrefix: cfg.KeyPrefix,
|
||||
EnsureSchema: true,
|
||||
},
|
||||
})
|
||||
default:
|
||||
return idempotency.New(idempotency.Options{TTL: ttl})
|
||||
}
|
||||
}
|
||||
|
||||
func newPlayerStores(cfg config.ProfileStoreSection, env *appkit.Env) (player.Store, player.SummaryStore) {
|
||||
return newProfileStore(cfg, env), newSummaryStore(cfg, env)
|
||||
}
|
||||
|
||||
func newProfileStore(cfg config.ProfileStoreSection, env *appkit.Env) player.Store {
|
||||
var store player.Store
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.StoreKind)) {
|
||||
case "", "memory":
|
||||
store = player.NewMemoryStore()
|
||||
case "file", "json":
|
||||
store = player.NewFileStore(cfg.StoreDirectory)
|
||||
case "redis", "pika", "profiledb", "profile_db":
|
||||
store = player.NewRedisStore(player.RedisStoreOptions{
|
||||
Address: cfg.StoreAddress,
|
||||
Password: cfg.StorePassword,
|
||||
DB: cfg.StoreDB,
|
||||
KeyPrefix: cfg.StoreKeyPrefix,
|
||||
PoolSize: cfg.StorePoolSize,
|
||||
Timeout: time.Duration(cfg.StoreTimeoutSeconds) * time.Second,
|
||||
ConnectTimeout: time.Duration(cfg.StoreConnectTimeout) * time.Second,
|
||||
ReadTimeout: time.Duration(cfg.StoreReadTimeout) * time.Second,
|
||||
WriteTimeout: time.Duration(cfg.StoreWriteTimeout) * time.Second,
|
||||
TTL: time.Duration(cfg.StoreTTLSeconds) * time.Second,
|
||||
})
|
||||
case "mysql", "mysql_redis":
|
||||
if cfg.MySQLShardingEnabled || len(cfg.MySQLShards) > 0 {
|
||||
sharded, err := newShardedStore(cfg)
|
||||
if err != nil {
|
||||
if env != nil && env.Logger != nil {
|
||||
env.Logger.Error("invalid sharded mysql player store config", "error", err)
|
||||
}
|
||||
return failingPlayerStore{err: fmt.Errorf("invalid sharded mysql player store config: %w", err)}
|
||||
} else {
|
||||
store = sharded
|
||||
}
|
||||
} else {
|
||||
store = player.NewMySQLStore(player.MySQLStoreOptions{
|
||||
DSN: cfg.MySQLDSN,
|
||||
MaxOpenConns: cfg.MySQLMaxOpenConns,
|
||||
MaxIdleConns: cfg.MySQLMaxIdleConns,
|
||||
ConnMaxLifetime: time.Duration(cfg.MySQLConnMaxLifetime) * time.Second,
|
||||
EnsureSchema: true,
|
||||
})
|
||||
}
|
||||
default:
|
||||
if env != nil && env.Logger != nil {
|
||||
env.Logger.Warn("unsupported player store kind, falling back to memory", "kind", cfg.StoreKind)
|
||||
}
|
||||
store = player.NewMemoryStore()
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.SaveMode)) {
|
||||
case "", "sync":
|
||||
return store
|
||||
case "async", "writebehind", "write_behind":
|
||||
asyncStore := player.NewAsyncStore(store, player.AsyncStoreOptions{
|
||||
FlushInterval: time.Duration(cfg.AsyncFlushIntervalSeconds) * time.Second,
|
||||
MaxPending: cfg.AsyncMaxPending,
|
||||
RetryBackoff: time.Duration(cfg.AsyncRetryBackoffSeconds) * time.Second,
|
||||
MaxRetries: cfg.AsyncMaxRetries,
|
||||
AutoExpireTTL: time.Duration(cfg.AsyncAutoExpireSeconds) * time.Second,
|
||||
DeadLetterLimit: cfg.AsyncDeadLetterLimit,
|
||||
DeadLetterStore: player.NewFileDeadLetterStore(cfg.AsyncDeadLetterDirectory),
|
||||
})
|
||||
if roleStore, ok := store.(player.RoleProfileStore); ok {
|
||||
return player.NewRoleAwareAsyncStore(asyncStore, roleStore)
|
||||
}
|
||||
return asyncStore
|
||||
default:
|
||||
if env != nil && env.Logger != nil {
|
||||
env.Logger.Warn("unsupported player save mode, using sync store", "mode", cfg.SaveMode)
|
||||
}
|
||||
return store
|
||||
}
|
||||
}
|
||||
|
||||
type failingPlayerStore struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// Load 返回配置错误,阻止损坏的玩家存储继续启动。
|
||||
func (s failingPlayerStore) Load(context.Context, string) (player.Profile, bool, error) {
|
||||
return player.Profile{}, false, s.err
|
||||
}
|
||||
|
||||
// Save 返回配置错误,避免玩家资料写入未知后端。
|
||||
func (s failingPlayerStore) Save(context.Context, player.Profile) error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
// Check 返回配置错误,供启动前检查暴露存储配置问题。
|
||||
func (s failingPlayerStore) Check(context.Context) error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
func newShardedStore(cfg config.ProfileStoreSection) (*player.ShardedMySQLStore, error) {
|
||||
shards := make([]player.MySQLShardOptions, 0, len(cfg.MySQLShards))
|
||||
for _, shard := range cfg.MySQLShards {
|
||||
maxOpen := shard.MaxOpenConns
|
||||
if maxOpen == 0 {
|
||||
maxOpen = cfg.MySQLMaxOpenConns
|
||||
}
|
||||
maxIdle := shard.MaxIdleConns
|
||||
if maxIdle == 0 {
|
||||
maxIdle = cfg.MySQLMaxIdleConns
|
||||
}
|
||||
lifetime := shard.ConnMaxLifetime
|
||||
if lifetime == 0 {
|
||||
lifetime = cfg.MySQLConnMaxLifetime
|
||||
}
|
||||
shards = append(shards, player.MySQLShardOptions{
|
||||
ID: shard.ID,
|
||||
DSN: shard.DSN,
|
||||
TableName: shard.TableName,
|
||||
TablePrefix: shard.TablePrefix,
|
||||
HashSlots: shard.HashSlots,
|
||||
MaxOpenConns: maxOpen,
|
||||
MaxIdleConns: maxIdle,
|
||||
ConnMaxLifetime: time.Duration(lifetime) * time.Second,
|
||||
EnsureSchema: true,
|
||||
})
|
||||
}
|
||||
return player.NewShardedMySQLStore(player.ShardedMySQLStoreOptions{
|
||||
DefaultDSN: cfg.MySQLDSN,
|
||||
DefaultMaxOpenConns: cfg.MySQLMaxOpenConns,
|
||||
DefaultMaxIdleConns: cfg.MySQLMaxIdleConns,
|
||||
DefaultConnMaxLifetime: time.Duration(cfg.MySQLConnMaxLifetime) * time.Second,
|
||||
EnsureSchema: true,
|
||||
Shards: shards,
|
||||
})
|
||||
}
|
||||
|
||||
func newSummaryStore(cfg config.ProfileStoreSection, env *appkit.Env) player.SummaryStore {
|
||||
kind := strings.ToLower(strings.TrimSpace(cfg.SummaryStoreKind))
|
||||
if kind == "" && strings.ToLower(strings.TrimSpace(cfg.StoreKind)) == "mysql_redis" {
|
||||
kind = "redis"
|
||||
}
|
||||
switch kind {
|
||||
case "", "memory":
|
||||
return player.NewMemorySummaryStore()
|
||||
case "redis", "pika":
|
||||
return player.NewRedisSummaryStore(player.RedisSummaryStoreOptions{
|
||||
Address: cfg.SummaryStoreAddress,
|
||||
Password: cfg.SummaryStorePassword,
|
||||
DB: cfg.SummaryStoreDB,
|
||||
KeyPrefix: cfg.SummaryStoreKeyPrefix,
|
||||
PoolSize: cfg.SummaryStorePoolSize,
|
||||
Timeout: time.Duration(cfg.SummaryStoreTimeout) * time.Second,
|
||||
ConnectTimeout: time.Duration(cfg.SummaryStoreConnectTimeout) * time.Second,
|
||||
ReadTimeout: time.Duration(cfg.SummaryStoreReadTimeout) * time.Second,
|
||||
WriteTimeout: time.Duration(cfg.SummaryStoreWriteTimeout) * time.Second,
|
||||
TTL: time.Duration(cfg.SummaryStoreTTL) * time.Second,
|
||||
})
|
||||
default:
|
||||
if env != nil && env.Logger != nil {
|
||||
env.Logger.Warn("unsupported player summary store kind, falling back to memory", "kind", cfg.SummaryStoreKind)
|
||||
}
|
||||
return player.NewMemorySummaryStore()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user