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

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

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

No files matched your search

@@ -0,0 +1,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)
}
}
+5
View File
@@ -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,
}
}
+156
View File
@@ -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
}
+50
View File
@@ -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)
})
}
+636
View File
@@ -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 &copy
}
}
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
}
+479
View File
@@ -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
}
+233
View File
@@ -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()
}
}