服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
697 lines
21 KiB
Go
697 lines
21 KiB
Go
package player
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/platform/db"
|
|
profileruntime "longheng.io/server/internal/platform/profile"
|
|
"longheng.io/server/internal/platform/readmodel"
|
|
)
|
|
|
|
// Store 是玩家 Profile 的基础持久化接口。
|
|
type Store = db.Store[Profile]
|
|
|
|
// RoleProfileStore 定义按角色 ID 读取玩家 Profile 的扩展接口。
|
|
type RoleProfileStore interface {
|
|
LoadByRoleID(context.Context, string) (Profile, bool, error)
|
|
}
|
|
|
|
// ProfileScanStore 定义按账号游标扫描玩家 Profile 的扩展接口。
|
|
type ProfileScanStore interface {
|
|
ListProfiles(context.Context, string, int) ([]Profile, error)
|
|
}
|
|
|
|
// SummaryRebuildOptions 是重建玩家摘要读模型的游标和批量配置。
|
|
type SummaryRebuildOptions struct {
|
|
AfterAccountID string `json:"after_account_id,omitempty"`
|
|
BatchSize int `json:"batch_size,omitempty"`
|
|
MaxProfiles int `json:"max_profiles,omitempty"`
|
|
}
|
|
|
|
// SummaryRebuildResult 是玩家摘要读模型重建结果。
|
|
type SummaryRebuildResult struct {
|
|
Scanned int `json:"scanned"`
|
|
Rebuilt int `json:"rebuilt"`
|
|
ActiveRebuilt int `json:"active_rebuilt,omitempty"`
|
|
LastAccountID string `json:"last_account_id,omitempty"`
|
|
Complete bool `json:"complete"`
|
|
}
|
|
|
|
// ProfileRepair 描述后台修复玩家 Profile 的可选字段。
|
|
type ProfileRepair struct {
|
|
AccountID string `json:"account_id"`
|
|
State *string `json:"state,omitempty"`
|
|
Name *string `json:"name,omitempty"`
|
|
Level *int `json:"level,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
// StoreStatus 汇总玩家存储、摘要、异步写入和事件日志状态。
|
|
type StoreStatus struct {
|
|
Type string `json:"type"`
|
|
SummaryType string `json:"summary_type,omitempty"`
|
|
AsyncStats *AsyncStoreStats `json:"async_stats,omitempty"`
|
|
DeadLetters []AsyncStoreDeadLetter `json:"dead_letters,omitempty"`
|
|
EventLogConfigured bool `json:"eventlog_configured,omitempty"`
|
|
EventLogStrict bool `json:"eventlog_strict,omitempty"`
|
|
EventLogErrors uint64 `json:"eventlog_errors,omitempty"`
|
|
}
|
|
|
|
// Module 是玩家 Profile 生命周期、摘要读模型和事件日志的参考实现。
|
|
type Module struct {
|
|
logger *slog.Logger
|
|
store Store
|
|
summaries SummaryStore
|
|
profiles *profileruntime.Lifecycle[Profile, ProfileField]
|
|
events ProfileEventAppender
|
|
eventAsync *asyncProfileEvents
|
|
eventsStrict bool
|
|
eventLogErrors uint64
|
|
}
|
|
|
|
// New 创建使用内存存储的玩家模块。
|
|
func New(logger *slog.Logger) *Module {
|
|
return NewWithStore(logger, NewMemoryStore())
|
|
}
|
|
|
|
// NewWithStore 创建使用指定 Profile 存储的玩家模块。
|
|
func NewWithStore(logger *slog.Logger, store Store) *Module {
|
|
return NewWithStores(logger, store, NewMemorySummaryStore())
|
|
}
|
|
|
|
// NewWithStoreChecked 创建玩家模块并返回初始化错误。
|
|
func NewWithStoreChecked(logger *slog.Logger, store Store) (*Module, error) {
|
|
return NewWithStoresChecked(logger, store, NewMemorySummaryStore())
|
|
}
|
|
|
|
// NewWithStores 创建同时指定 Profile 存储和摘要存储的玩家模块。
|
|
func NewWithStores(logger *slog.Logger, store Store, summaries SummaryStore) *Module {
|
|
module, err := NewWithStoresChecked(logger, store, summaries)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return module
|
|
}
|
|
|
|
// NewWithStoresChecked 创建玩家模块并校验 Profile 运行时配置。
|
|
func NewWithStoresChecked(logger *slog.Logger, store Store, summaries SummaryStore) (*Module, error) {
|
|
if store == nil {
|
|
store = NewMemoryStore()
|
|
}
|
|
if summaries == nil {
|
|
summaries = NewMemorySummaryStore()
|
|
}
|
|
summaries = wrapSummaryStore(summaries)
|
|
runtime, err := profileruntime.NewRuntime[Profile, ProfileField](profileruntime.Options[Profile, ProfileField]{
|
|
Store: store,
|
|
Fields: profileFieldRegistry,
|
|
Clone: cloneProfile,
|
|
NewRecord: newProfile,
|
|
PrepareLoaded: prepareLoadedProfile,
|
|
PrepareSaved: prepareSavedProfile,
|
|
Touch: touchProfileRecord,
|
|
SaveFields: saveProfileFields,
|
|
SameActive: sameProfileVersion,
|
|
NormalizeKey: strings.TrimSpace,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
profiles, err := profileruntime.NewLifecycle[Profile, ProfileField](profileruntime.LifecycleOptions[Profile, ProfileField]{
|
|
Store: store,
|
|
Runtime: runtime,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Module{
|
|
logger: logger,
|
|
store: store,
|
|
summaries: summaries,
|
|
profiles: profiles,
|
|
}, nil
|
|
}
|
|
|
|
// Name 返回玩家模块名。
|
|
func (m *Module) Name() string {
|
|
return "player-module"
|
|
}
|
|
|
|
// Start 启动玩家模块,当前参考实现无需额外后台任务。
|
|
func (m *Module) Start(context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
// Preflight 检查玩家 Profile 存储和摘要存储是否可用。
|
|
func (m *Module) Preflight(ctx context.Context) error {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if err := m.profiles.Preflight(ctx); err != nil {
|
|
return err
|
|
}
|
|
return db.Check(ctx, m.summaries)
|
|
}
|
|
|
|
// Stop 保存所有在线玩家 Profile 并关闭或刷新底层存储。
|
|
func (m *Module) Stop(ctx context.Context) error {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
firstErr := m.profiles.SaveActiveAndUnload(ctx, func(ctx context.Context, accountID string, saveProfile, summaryProfile Profile) error {
|
|
m.observeSummary(ctx, summaryProfile)
|
|
if m.logger != nil {
|
|
m.logger.Info("player profile saved", "account_id", accountID, "role_id", saveProfile.RoleID, "reason", "service_stop")
|
|
}
|
|
return m.emitProfileSaved(ctx, saveProfile, "service_stop")
|
|
})
|
|
storeErr := m.profiles.CloseOrFlush(ctx)
|
|
summaryErr := db.CloseOrFlush(ctx, m.summaries)
|
|
eventErr := m.closeProfileEvents(ctx)
|
|
if firstErr != nil {
|
|
return firstErr
|
|
}
|
|
if storeErr != nil {
|
|
return storeErr
|
|
}
|
|
if summaryErr != nil {
|
|
return summaryErr
|
|
}
|
|
if eventErr != nil {
|
|
return eventErr
|
|
}
|
|
return storeErr
|
|
}
|
|
|
|
// LoadOrCreate 加载玩家 Profile,不存在时创建默认档案并置为在线。
|
|
func (m *Module) LoadOrCreate(ctx context.Context, accountID string) (Profile, error) {
|
|
accountID = strings.TrimSpace(accountID)
|
|
if accountID == "" {
|
|
return Profile{}, fmt.Errorf("account id is required")
|
|
}
|
|
profile, activated, err := m.profiles.LoadOrCreate(ctx, accountID)
|
|
if err != nil {
|
|
return Profile{}, err
|
|
}
|
|
if activated {
|
|
m.observeSummary(ctx, profile)
|
|
}
|
|
|
|
if activated && m.logger != nil {
|
|
m.logger.Info("player profile loaded", "account_id", accountID, "role_id", profile.RoleID)
|
|
}
|
|
return cloneProfile(profile), nil
|
|
}
|
|
|
|
// SaveAndUnload 保存并卸载在线玩家 Profile。
|
|
func (m *Module) SaveAndUnload(ctx context.Context, accountID, reason string) (Profile, bool, error) {
|
|
accountID = strings.TrimSpace(accountID)
|
|
if accountID == "" {
|
|
return Profile{}, false, fmt.Errorf("account id is required")
|
|
}
|
|
saveProfile, summaryProfile, ok, err := m.profiles.SaveAndUnload(ctx, accountID)
|
|
if err != nil {
|
|
return Profile{}, false, err
|
|
}
|
|
if !ok {
|
|
return Profile{}, false, nil
|
|
}
|
|
m.observeSummary(ctx, summaryProfile)
|
|
|
|
if m.logger != nil {
|
|
m.logger.Info("player profile saved", "account_id", accountID, "role_id", saveProfile.RoleID, "reason", reason)
|
|
}
|
|
if err := m.emitProfileSaved(ctx, saveProfile, reason); err != nil {
|
|
return cloneProfile(saveProfile), true, err
|
|
}
|
|
return cloneProfile(saveProfile), true, nil
|
|
}
|
|
|
|
// Upsert 使用后台上下文更新玩家在线状态,兼容旧调用方。
|
|
func (m *Module) Upsert(accountID, state string) Record {
|
|
profile, err := m.UpsertWithContext(context.Background(), accountID, state)
|
|
if err != nil && profile.AccountID == "" {
|
|
return Record{}
|
|
}
|
|
return profile
|
|
}
|
|
|
|
// UpsertWithContext 更新玩家在线状态并写入摘要和事件。
|
|
func (m *Module) UpsertWithContext(ctx context.Context, accountID, state string) (Record, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
accountID = strings.TrimSpace(accountID)
|
|
if accountID == "" {
|
|
return Record{}, fmt.Errorf("account id is required")
|
|
}
|
|
if state == "" {
|
|
state = "active"
|
|
}
|
|
|
|
profile, err := m.profiles.Upsert(accountID, func(profile Profile, existed bool, now time.Time) (Profile, []ProfileField, error) {
|
|
fields := dirtyFieldsByModule(ProfileModuleRuntime)
|
|
if !existed {
|
|
profile.LoadedAt = now
|
|
}
|
|
profile.State = state
|
|
profile.UpdatedAt = now
|
|
profile.Version++
|
|
return profile, fields, nil
|
|
})
|
|
if err != nil {
|
|
return Record{}, err
|
|
}
|
|
m.observeSummary(ctx, profile)
|
|
|
|
if m.logger != nil {
|
|
m.logger.Info("player state updated", "account_id", accountID, "state", state)
|
|
}
|
|
if err := m.emitProfileChanged(ctx, profile, state); err != nil {
|
|
return cloneProfile(profile), err
|
|
}
|
|
return cloneProfile(profile), nil
|
|
}
|
|
|
|
// Get 从在线生命周期缓存读取玩家 Profile。
|
|
func (m *Module) Get(accountID string) (Profile, bool) {
|
|
accountID = strings.TrimSpace(accountID)
|
|
profile, ok := m.profiles.Get(accountID)
|
|
return cloneProfile(profile), ok
|
|
}
|
|
|
|
// LoadReadonly 读取玩家 Profile,优先返回在线缓存但不改变生命周期。
|
|
func (m *Module) LoadReadonly(ctx context.Context, accountID string) (Profile, bool, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
accountID = strings.TrimSpace(accountID)
|
|
if accountID == "" {
|
|
return Profile{}, false, fmt.Errorf("account id is required")
|
|
}
|
|
if profile, ok := m.Get(accountID); ok {
|
|
return profile, true, nil
|
|
}
|
|
profile, ok, err := m.store.Load(ctx, accountID)
|
|
if err != nil || !ok {
|
|
return Profile{}, ok, err
|
|
}
|
|
return cloneProfile(profile), true, nil
|
|
}
|
|
|
|
// RepairProfile 按后台修复请求修改玩家 Profile 并刷新摘要。
|
|
func (m *Module) RepairProfile(ctx context.Context, repair ProfileRepair) (Profile, bool, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
repair.AccountID = strings.TrimSpace(repair.AccountID)
|
|
repair.Reason = strings.TrimSpace(repair.Reason)
|
|
if repair.AccountID == "" {
|
|
return Profile{}, false, fmt.Errorf("account id is required")
|
|
}
|
|
if active, ok := m.Get(repair.AccountID); ok {
|
|
before, profile, err := m.profiles.MutateLoaded(ctx, repair.AccountID, func(profile *Profile, now time.Time) ([]ProfileField, error) {
|
|
return applyProfileRepair(profile, repair, now), nil
|
|
})
|
|
if err != nil {
|
|
return Profile{}, false, err
|
|
}
|
|
m.observeSummaryChange(ctx, before, profile)
|
|
if sameProfileVersion(active, profile) {
|
|
return cloneProfile(profile), true, nil
|
|
}
|
|
if err := m.emitProfileSaved(ctx, profile, repairReason(repair)); err != nil {
|
|
return cloneProfile(profile), true, err
|
|
}
|
|
return cloneProfile(profile), true, nil
|
|
}
|
|
profile, ok, err := m.store.Load(ctx, repair.AccountID)
|
|
if err != nil || !ok {
|
|
return Profile{}, ok, err
|
|
}
|
|
before := cloneProfile(profile)
|
|
fields := applyProfileRepair(&profile, repair, time.Now().UTC())
|
|
if len(fields) == 0 {
|
|
return cloneProfile(profile), true, nil
|
|
}
|
|
if err := m.store.Save(ctx, profile); err != nil {
|
|
return Profile{}, true, err
|
|
}
|
|
m.observeSummaryChange(ctx, before, profile)
|
|
if err := m.emitProfileSaved(ctx, profile, repairReason(repair)); err != nil {
|
|
return cloneProfile(profile), true, err
|
|
}
|
|
return cloneProfile(profile), true, nil
|
|
}
|
|
|
|
// Snapshot 返回当前在线玩家 Profile 快照。
|
|
func (m *Module) Snapshot() []Record {
|
|
return m.profiles.Snapshot()
|
|
}
|
|
|
|
// GetSummary 按账号 ID 读取玩家摘要,必要时从 Profile 回填。
|
|
func (m *Module) GetSummary(ctx context.Context, accountID string) (PlayerSummary, bool, error) {
|
|
if m.summaries == nil {
|
|
return PlayerSummary{}, false, nil
|
|
}
|
|
accountID = strings.TrimSpace(accountID)
|
|
if accountID == "" {
|
|
return PlayerSummary{}, false, nil
|
|
}
|
|
summary, summaryOK, err := m.summaries.GetPlayerSummary(ctx, accountID)
|
|
if err != nil {
|
|
m.logSummaryReadFail("account", accountID, err)
|
|
}
|
|
if profile, activeOK := m.activeProfileByAcct(accountID); activeOK {
|
|
activeSummary := NewPlayerSummary(profile)
|
|
if err == nil && (!summaryOK || !samePublicSummary(summary, activeSummary)) {
|
|
m.saveSummaryBest(ctx, activeSummary)
|
|
}
|
|
return activeSummary, true, nil
|
|
}
|
|
if err == nil && summaryOK {
|
|
return summary, true, nil
|
|
}
|
|
profile, ok, err := m.store.Load(ctx, accountID)
|
|
if err != nil || !ok {
|
|
return PlayerSummary{}, ok, err
|
|
}
|
|
summary = NewPlayerSummary(profile)
|
|
m.saveSummaryBest(ctx, summary)
|
|
return summary, true, nil
|
|
}
|
|
|
|
// GetSummaryByRoleID 按角色 ID 读取玩家摘要,必要时从 Profile 回填。
|
|
func (m *Module) GetSummaryByRoleID(ctx context.Context, roleID string) (PlayerSummary, bool, error) {
|
|
if m.summaries == nil {
|
|
return PlayerSummary{}, false, nil
|
|
}
|
|
roleID = strings.TrimSpace(roleID)
|
|
if roleID == "" {
|
|
return PlayerSummary{}, false, nil
|
|
}
|
|
summary, summaryOK, err := m.summaries.GetPlayerSummaryByRoleID(ctx, roleID)
|
|
if err != nil {
|
|
m.logSummaryReadFail("role", roleID, err)
|
|
}
|
|
if profile, activeOK := m.activeRoleProfile(roleID); activeOK {
|
|
activeSummary := NewPlayerSummary(profile)
|
|
if err == nil && (!summaryOK || !samePublicSummary(summary, activeSummary)) {
|
|
m.saveSummaryBest(ctx, activeSummary)
|
|
}
|
|
return activeSummary, true, nil
|
|
}
|
|
if err == nil && summaryOK {
|
|
return summary, true, nil
|
|
}
|
|
roleStore, ok := m.store.(RoleProfileStore)
|
|
if !ok {
|
|
return PlayerSummary{}, false, nil
|
|
}
|
|
profile, ok, err := roleStore.LoadByRoleID(ctx, roleID)
|
|
if err != nil || !ok {
|
|
return PlayerSummary{}, ok, err
|
|
}
|
|
summary = NewPlayerSummary(profile)
|
|
m.saveSummaryBest(ctx, summary)
|
|
return summary, true, nil
|
|
}
|
|
|
|
// ListSummariesByAccountIDs 按账号 ID 批量读取玩家摘要并保持稳定排序。
|
|
func (m *Module) ListSummariesByAccountIDs(ctx context.Context, accountIDs []string) ([]PlayerSummary, error) {
|
|
if m.summaries == nil {
|
|
return nil, nil
|
|
}
|
|
summaries := make([]PlayerSummary, 0, len(accountIDs))
|
|
seen := make(map[string]struct{}, len(accountIDs))
|
|
for _, accountID := range accountIDs {
|
|
accountID = strings.TrimSpace(accountID)
|
|
if accountID == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[accountID]; ok {
|
|
continue
|
|
}
|
|
seen[accountID] = struct{}{}
|
|
summary, ok, err := m.GetSummary(ctx, accountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ok {
|
|
summaries = append(summaries, summary)
|
|
}
|
|
}
|
|
sortPlayerSummaries(summaries)
|
|
return summaries, nil
|
|
}
|
|
|
|
// ListSummariesByRoleIDs 按角色 ID 批量读取玩家摘要并保持稳定排序。
|
|
func (m *Module) ListSummariesByRoleIDs(ctx context.Context, roleIDs []string) ([]PlayerSummary, error) {
|
|
if m.summaries == nil {
|
|
return nil, nil
|
|
}
|
|
summaries := make([]PlayerSummary, 0, len(roleIDs))
|
|
seen := make(map[string]struct{}, len(roleIDs))
|
|
for _, roleID := range roleIDs {
|
|
roleID = strings.TrimSpace(roleID)
|
|
if roleID == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[roleID]; ok {
|
|
continue
|
|
}
|
|
seen[roleID] = struct{}{}
|
|
summary, ok, err := m.GetSummaryByRoleID(ctx, roleID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ok {
|
|
summaries = append(summaries, summary)
|
|
}
|
|
}
|
|
sortPlayerSummaries(summaries)
|
|
return summaries, nil
|
|
}
|
|
|
|
// SearchSummaries 按读模型查询条件搜索玩家摘要。
|
|
func (m *Module) SearchSummaries(ctx context.Context, query PlayerSummaryQuery) ([]PlayerSummary, error) {
|
|
if m.summaries == nil {
|
|
return nil, nil
|
|
}
|
|
if len(query.AccountIDs) > 0 {
|
|
summaries, err := m.ListSummariesByAccountIDs(ctx, query.AccountIDs)
|
|
return filterSummaries(summaries, query), err
|
|
}
|
|
if len(query.RoleIDs) > 0 {
|
|
summaries, err := m.ListSummariesByRoleIDs(ctx, query.RoleIDs)
|
|
return filterSummaries(summaries, query), err
|
|
}
|
|
return m.summaries.SearchPlayerSummaries(ctx, query)
|
|
}
|
|
|
|
// RebuildSummaries 从 Profile 存储重建玩家摘要读模型。
|
|
func (m *Module) RebuildSummaries(ctx context.Context, options SummaryRebuildOptions) (SummaryRebuildResult, error) {
|
|
scanner, ok := m.store.(ProfileScanStore)
|
|
if !ok {
|
|
return SummaryRebuildResult{}, fmt.Errorf("profile store %T does not support profile scans", m.store)
|
|
}
|
|
if m.summaries == nil {
|
|
return SummaryRebuildResult{}, fmt.Errorf("player summary store is nil")
|
|
}
|
|
var flush func(context.Context) error
|
|
if flusher, ok := m.store.(interface {
|
|
Flush(context.Context) error
|
|
}); ok {
|
|
flush = flusher.Flush
|
|
}
|
|
result, err := readmodel.Rebuild(ctx, readmodel.RebuildOptions{
|
|
AfterID: options.AfterAccountID,
|
|
BatchSize: options.BatchSize,
|
|
MaxRecords: options.MaxProfiles,
|
|
}, readmodel.RebuildRunner[Profile, PlayerSummary]{
|
|
Scan: scanner.ListProfiles,
|
|
Flush: flush,
|
|
ActiveSnapshot: m.Snapshot,
|
|
RecordID: func(profile Profile) string {
|
|
return strings.TrimSpace(profile.AccountID)
|
|
},
|
|
Build: NewPlayerSummary,
|
|
Save: m.summaries.SavePlayerSummary,
|
|
})
|
|
if flusher, ok := m.summaries.(interface {
|
|
Flush(context.Context) error
|
|
}); ok {
|
|
if flushErr := flusher.Flush(ctx); flushErr != nil {
|
|
if err != nil {
|
|
err = errors.Join(err, flushErr)
|
|
} else {
|
|
err = flushErr
|
|
}
|
|
}
|
|
}
|
|
return SummaryRebuildResult{
|
|
Scanned: result.Scanned,
|
|
Rebuilt: result.Rebuilt,
|
|
ActiveRebuilt: result.ActiveRebuilt,
|
|
LastAccountID: result.LastID,
|
|
Complete: result.Complete,
|
|
}, err
|
|
}
|
|
|
|
// StoreStatus 返回玩家模块底层存储和事件日志状态。
|
|
func (m *Module) StoreStatus() StoreStatus {
|
|
status := StoreStatus{
|
|
Type: fmt.Sprintf("%T", m.store),
|
|
SummaryType: fmt.Sprintf("%T", m.summaries),
|
|
}
|
|
if asyncStore, ok := m.store.(interface {
|
|
Stats() AsyncStoreStats
|
|
DeadLetters() []AsyncStoreDeadLetter
|
|
}); ok {
|
|
stats := asyncStore.Stats()
|
|
status.AsyncStats = &stats
|
|
status.DeadLetters = asyncStore.DeadLetters()
|
|
}
|
|
if m.events != nil {
|
|
status.EventLogConfigured = true
|
|
status.EventLogStrict = m.eventsStrict
|
|
status.EventLogErrors = m.EventLogErrors()
|
|
}
|
|
return status
|
|
}
|
|
|
|
// DeadLetters 返回玩家异步存储中尚未处理的死信记录。
|
|
func (m *Module) DeadLetters() []AsyncStoreDeadLetter {
|
|
if asyncStore, ok := m.store.(interface {
|
|
DeadLetters() []AsyncStoreDeadLetter
|
|
}); ok {
|
|
return asyncStore.DeadLetters()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RequeueDeadLetter 将指定账号的异步死信重新放回待写队列。
|
|
func (m *Module) RequeueDeadLetter(accountID string) bool {
|
|
accountID = strings.TrimSpace(accountID)
|
|
if asyncStore, ok := m.store.(interface {
|
|
RequeueDeadLetter(string) bool
|
|
}); ok {
|
|
return asyncStore.RequeueDeadLetter(accountID)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func applyProfileRepair(profile *Profile, repair ProfileRepair, now time.Time) []ProfileField {
|
|
if profile == nil {
|
|
return nil
|
|
}
|
|
fields := newProfileFieldSet()
|
|
if repair.State != nil {
|
|
state := strings.TrimSpace(*repair.State)
|
|
if profile.State != state {
|
|
profile.State = state
|
|
fields.Add(ProfileFieldRuntime)
|
|
}
|
|
}
|
|
if repair.Name != nil {
|
|
name := strings.TrimSpace(*repair.Name)
|
|
if profile.Name != name {
|
|
profile.Name = name
|
|
fields.Add(ProfileFieldBase)
|
|
}
|
|
}
|
|
if repair.Level != nil && profile.Level != *repair.Level {
|
|
profile.Level = *repair.Level
|
|
fields.Add(ProfileFieldBase)
|
|
}
|
|
if len(fields.List()) == 0 {
|
|
return nil
|
|
}
|
|
if now.IsZero() {
|
|
now = time.Now().UTC()
|
|
} else {
|
|
now = now.UTC()
|
|
}
|
|
profile.UpdatedAt = now
|
|
profile.Version++
|
|
fields.Add(ProfileFieldRuntime, ProfileFieldMeta)
|
|
return fields.List()
|
|
}
|
|
|
|
func repairReason(repair ProfileRepair) string {
|
|
reason := strings.TrimSpace(repair.Reason)
|
|
if reason == "" {
|
|
return "admin_repair"
|
|
}
|
|
return "admin_repair:" + reason
|
|
}
|
|
|
|
func (m *Module) observeSummary(ctx context.Context, profile Profile) {
|
|
if m.summaries == nil {
|
|
return
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if err := m.summaries.SavePlayerSummary(ctx, NewPlayerSummary(profile)); err != nil && m.logger != nil {
|
|
m.logger.Error("player summary update failed", "account_id", profile.AccountID, "error", err)
|
|
}
|
|
}
|
|
|
|
func (m *Module) activeProfileByAcct(accountID string) (Profile, bool) {
|
|
accountID = strings.TrimSpace(accountID)
|
|
profile, ok := m.profiles.Get(accountID)
|
|
return cloneProfile(profile), ok
|
|
}
|
|
|
|
func (m *Module) activeRoleProfile(roleID string) (Profile, bool) {
|
|
roleID = strings.TrimSpace(roleID)
|
|
profile, ok := m.profiles.ActiveByPredicate(func(profile Profile) bool {
|
|
return profile.RoleID == roleID
|
|
})
|
|
return cloneProfile(profile), ok
|
|
}
|
|
|
|
func (m *Module) logSummaryReadFail(scope, key string, err error) {
|
|
if m.logger == nil || err == nil {
|
|
return
|
|
}
|
|
attrs := []any{"scope", scope, "error", err}
|
|
if key != "" {
|
|
attrs = append(attrs, "key", key)
|
|
}
|
|
m.logger.Warn("player summary read failed, falling back", attrs...)
|
|
}
|
|
|
|
func (m *Module) saveSummaryBest(ctx context.Context, summary PlayerSummary) {
|
|
if m.summaries == nil {
|
|
return
|
|
}
|
|
if err := m.summaries.SavePlayerSummary(ctx, summary); err != nil && m.logger != nil {
|
|
m.logger.Error("player summary backfill failed", "account_id", summary.AccountID, "error", err)
|
|
}
|
|
}
|
|
|
|
func (m *Module) observeSummaryChange(ctx context.Context, before, after Profile) {
|
|
beforeSummary := NewPlayerSummary(before)
|
|
afterSummary := NewPlayerSummary(after)
|
|
if samePublicSummary(beforeSummary, afterSummary) {
|
|
return
|
|
}
|
|
m.observeSummary(ctx, after)
|
|
}
|
|
|
|
func samePublicSummary(left, right PlayerSummary) bool {
|
|
return left.AccountID == right.AccountID &&
|
|
left.RoleID == right.RoleID &&
|
|
left.Name == right.Name &&
|
|
left.Level == right.Level &&
|
|
left.State == right.State &&
|
|
left.Online == right.Online
|
|
}
|