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,153 @@
package player
import (
"context"
"encoding/json"
"errors"
"log/slog"
"sync"
"sync/atomic"
"time"
"longheng.io/server/internal/platform/eventlog"
)
const (
defProfileEventQueue = 1024
profileEventTimeout = 3 * time.Second
)
var (
errEventClosed = errors.New("profile event queue is closed")
errEventFull = errors.New("profile event queue is full")
)
type asyncProfileEvents struct {
next ProfileEventAppender
logger *slog.Logger
errs *uint64
timeout time.Duration
queue chan eventlog.Event
mu sync.Mutex
closed bool
once sync.Once
stopped chan struct{}
}
func newAsyncEvents(next ProfileEventAppender, logger *slog.Logger, errs *uint64) *asyncProfileEvents {
events := &asyncProfileEvents{
next: next,
logger: logger,
errs: errs,
timeout: profileEventTimeout,
queue: make(chan eventlog.Event, defProfileEventQueue),
stopped: make(chan struct{}),
}
go events.run()
return events
}
func (a *asyncProfileEvents) Append(ctx context.Context, event eventlog.Event) (eventlog.Event, error) {
if a == nil || a.next == nil {
return eventlog.Event{}, errEventClosed
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return eventlog.Event{}, err
}
event = cloneProfileEvent(event)
a.mu.Lock()
defer a.mu.Unlock()
if a.closed {
return eventlog.Event{}, errEventClosed
}
select {
case a.queue <- event:
return event, nil
default:
return eventlog.Event{}, errEventFull
}
}
func (a *asyncProfileEvents) Close(ctx context.Context) error {
if a == nil {
return nil
}
closeCtx, cancel := a.closeContext(ctx)
defer cancel()
a.once.Do(func() {
a.mu.Lock()
a.closed = true
close(a.queue)
a.mu.Unlock()
})
select {
case <-a.stopped:
return nil
case <-closeCtx.Done():
return closeCtx.Err()
}
}
func (a *asyncProfileEvents) run() {
defer close(a.stopped)
for event := range a.queue {
ctx, cancel := context.WithTimeout(context.Background(), a.timeoutOrDefault())
_, err := a.next.Append(ctx, event)
cancel()
if err != nil {
a.recordError(event, err)
}
}
}
func (a *asyncProfileEvents) 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, a.timeoutOrDefault())
}
func (a *asyncProfileEvents) timeoutOrDefault() time.Duration {
if a == nil || a.timeout <= 0 {
return profileEventTimeout
}
return a.timeout
}
func (a *asyncProfileEvents) recordError(event eventlog.Event, err error) {
if a.errs != nil {
atomic.AddUint64(a.errs, 1)
}
if a.logger != nil {
a.logger.Error("player profile async eventlog append failed", "account_id", event.AggregateID, "event_type", event.Type, "error", err)
}
}
func (m *Module) closeProfileEvents(ctx context.Context) error {
if m == nil || m.eventAsync == nil {
return nil
}
return m.eventAsync.Close(ctx)
}
func cloneProfileEvent(event eventlog.Event) eventlog.Event {
if event.Payload != nil {
event.Payload = append(json.RawMessage(nil), event.Payload...)
}
if event.Headers != nil {
headers := make(map[string]string, len(event.Headers))
for key, value := range event.Headers {
headers[key] = value
}
event.Headers = headers
}
return event
}
@@ -0,0 +1,53 @@
package player
import (
"errors"
"strings"
"time"
"longheng.io/server/internal/platform/db"
)
// ErrAsyncStoreClosed 表示异步玩家存储已经关闭,不能再接收写入。
var ErrAsyncStoreClosed = errors.New("async player store is closed")
// AsyncStore 是带缓冲、重试和死信能力的玩家 Profile 异步存储。
type AsyncStore = db.AsyncStore[Profile, ProfileField]
// AsyncStoreStats 是玩家异步存储的待写、重试和死信统计。
type AsyncStoreStats = db.AsyncStoreStats
// AsyncStoreOptions 是玩家异步存储的刷新、重试、过期和死信配置。
type AsyncStoreOptions struct {
FlushInterval time.Duration
MaxPending int
RetryBackoff time.Duration
MaxRetries int
AutoExpireTTL time.Duration
DeadLetterLimit int
DeadLetterStore DeadLetterStore
}
// NewAsyncStore 创建玩家异步存储,base 为空时使用内存存储兜底。
func NewAsyncStore(base Store, options AsyncStoreOptions) *AsyncStore {
if base == nil {
base = NewMemoryStore()
}
return db.MustNewAsyncStore[Profile, ProfileField](base, db.AsyncStoreOptions[Profile, ProfileField]{
FlushInterval: options.FlushInterval,
MaxPending: options.MaxPending,
RetryBackoff: options.RetryBackoff,
MaxRetries: options.MaxRetries,
AutoExpire: options.AutoExpireTTL > 0,
AutoExpireTTL: options.AutoExpireTTL,
DeadLetterLimit: options.DeadLetterLimit,
DeadLetterStore: options.DeadLetterStore,
RecordKey: profileKey,
NormalizeKey: strings.TrimSpace,
Clone: cloneProfile,
AllFields: AllProfileFields,
NormalizeFields: normProfileFields,
SaveFields: saveProfileFields,
ClosedError: ErrAsyncStoreClosed,
})
}
@@ -0,0 +1,531 @@
package player
import (
"context"
"errors"
"sort"
"strings"
"sync"
"time"
"longheng.io/server/internal/platform/db"
)
const (
sumQueueMax = 4096
sumFlushTimeout = 3 * time.Second
sumRetryInterval = 200 * time.Millisecond
)
var (
errSumStoreNil = errors.New("player summary store is nil")
errSumClosed = errors.New("player summary queue is closed")
errSumFull = errors.New("player summary queue is full")
)
// AsyncSummaryStore 把玩家摘要写入合并到后台,避免远端读模型拖慢玩家热路径。
type AsyncSummaryStore struct {
base SummaryStore
max int
timeout time.Duration
mu sync.RWMutex
pending map[string]sumPending
roles map[string]string
seq uint64
closed bool
wake chan struct{}
stop chan struct{}
done chan struct{}
once sync.Once
}
// NewAsyncSummaryStore 创建异步玩家摘要读模型存储。
func NewAsyncSummaryStore(base SummaryStore) *AsyncSummaryStore {
store := &AsyncSummaryStore{
base: base,
max: sumQueueMax,
timeout: sumFlushTimeout,
pending: make(map[string]sumPending),
roles: make(map[string]string),
wake: make(chan struct{}, 1),
stop: make(chan struct{}),
done: make(chan struct{}),
}
go store.run()
return store
}
// SavePlayerSummary 合并玩家摘要并唤醒后台刷新。
func (s *AsyncSummaryStore) SavePlayerSummary(ctx context.Context, summary PlayerSummary) error {
if s == nil || s.base == nil {
return errSumStoreNil
}
ctx = summaryCtx(ctx)
if err := ctx.Err(); err != nil {
return err
}
summary = normPlayerSummaryID(summary)
if summary.AccountID == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return errSumClosed
}
if _, ok := s.pending[summary.AccountID]; !ok && len(s.pending) >= s.max {
return errSumFull
}
s.putLocked(summary)
s.wakeLocked()
return nil
}
// GetPlayerSummary 优先读取尚未落远端的最新摘要。
func (s *AsyncSummaryStore) GetPlayerSummary(ctx context.Context, accountID string) (PlayerSummary, bool, error) {
if s == nil || s.base == nil {
return PlayerSummary{}, false, errSumStoreNil
}
ctx = summaryCtx(ctx)
if err := ctx.Err(); err != nil {
return PlayerSummary{}, false, err
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return PlayerSummary{}, false, nil
}
if summary, ok := s.pendingByAcct(accountID); ok {
return summary, true, nil
}
return s.base.GetPlayerSummary(ctx, accountID)
}
// GetPlayerSummaryByRoleID 优先读取尚未落远端的角色索引。
func (s *AsyncSummaryStore) GetPlayerSummaryByRoleID(ctx context.Context, roleID string) (PlayerSummary, bool, error) {
if s == nil || s.base == nil {
return PlayerSummary{}, false, errSumStoreNil
}
ctx = summaryCtx(ctx)
if err := ctx.Err(); err != nil {
return PlayerSummary{}, false, err
}
roleID = strings.TrimSpace(roleID)
if roleID == "" {
return PlayerSummary{}, false, nil
}
if summary, ok := s.pendingByRole(roleID); ok {
return summary, true, nil
}
summary, ok, err := s.base.GetPlayerSummaryByRoleID(ctx, roleID)
if err != nil || !ok {
return summary, ok, err
}
if pending, pendingOK := s.pendingByAcct(summary.AccountID); pendingOK && pending.RoleID != roleID {
return PlayerSummary{}, false, nil
}
return summary, true, nil
}
// ListPlayerSummariesByAccountIDs 批量读取时合并 pending 摘要。
func (s *AsyncSummaryStore) ListPlayerSummariesByAccountIDs(ctx context.Context, accountIDs []string) ([]PlayerSummary, error) {
if s == nil || s.base == nil {
return nil, errSumStoreNil
}
ctx = summaryCtx(ctx)
if err := ctx.Err(); err != nil {
return nil, err
}
base, err := s.base.ListPlayerSummariesByAccountIDs(ctx, accountIDs)
if err != nil {
return nil, err
}
ids := normSummaryIDs(accountIDs)
byAcct := make(map[string]PlayerSummary, len(base)+len(ids))
for _, summary := range base {
summary = normPlayerSummaryID(summary)
if summary.AccountID != "" {
byAcct[summary.AccountID] = clonePlayerSummary(summary)
}
}
for _, summary := range s.pendingByAccts(ids) {
byAcct[summary.AccountID] = summary
}
out := make([]PlayerSummary, 0, len(ids))
for _, accountID := range ids {
if summary, ok := byAcct[accountID]; ok {
out = append(out, clonePlayerSummary(summary))
}
}
sortPlayerSummaries(out)
return out, nil
}
// ListByRoleIDs 批量读取角色索引时合并 pending 摘要并屏蔽旧索引。
func (s *AsyncSummaryStore) ListByRoleIDs(ctx context.Context, roleIDs []string) ([]PlayerSummary, error) {
if s == nil || s.base == nil {
return nil, errSumStoreNil
}
ctx = summaryCtx(ctx)
if err := ctx.Err(); err != nil {
return nil, err
}
base, err := s.base.ListByRoleIDs(ctx, roleIDs)
if err != nil {
return nil, err
}
ids := normSummaryIDs(roleIDs)
byRole := make(map[string]PlayerSummary, len(base)+len(ids))
for _, summary := range base {
summary = normPlayerSummaryID(summary)
if summary.RoleID != "" {
byRole[summary.RoleID] = clonePlayerSummary(summary)
}
}
pending := s.pendingAll()
pendingByAcct := make(map[string]PlayerSummary, len(pending))
for _, summary := range pending {
pendingByAcct[summary.AccountID] = summary
}
for roleID, summary := range byRole {
if pending, ok := pendingByAcct[summary.AccountID]; ok && pending.RoleID != roleID {
delete(byRole, roleID)
}
}
for _, pending := range pending {
if pending.RoleID != "" {
byRole[pending.RoleID] = pending
}
}
out := make([]PlayerSummary, 0, len(ids))
for _, roleID := range ids {
if summary, ok := byRole[roleID]; ok {
out = append(out, clonePlayerSummary(summary))
}
}
sortPlayerSummaries(out)
return out, nil
}
// SearchPlayerSummaries 搜索时合并 pending 摘要,保证读到最新公开状态。
func (s *AsyncSummaryStore) SearchPlayerSummaries(ctx context.Context, query PlayerSummaryQuery) ([]PlayerSummary, error) {
if s == nil || s.base == nil {
return nil, errSumStoreNil
}
if len(query.AccountIDs) > 0 {
summaries, err := s.ListPlayerSummariesByAccountIDs(ctx, query.AccountIDs)
return filterSummaries(summaries, query), err
}
if len(query.RoleIDs) > 0 {
summaries, err := s.ListByRoleIDs(ctx, query.RoleIDs)
return filterSummaries(summaries, query), err
}
ctx = summaryCtx(ctx)
if err := ctx.Err(); err != nil {
return nil, err
}
base, err := s.base.SearchPlayerSummaries(ctx, query)
if err != nil {
return nil, err
}
byAcct := make(map[string]PlayerSummary, len(base))
for _, summary := range base {
summary = normPlayerSummaryID(summary)
if summary.AccountID != "" {
byAcct[summary.AccountID] = clonePlayerSummary(summary)
}
}
for _, pending := range s.pendingAll() {
if summaryMatches(pending, query) {
byAcct[pending.AccountID] = pending
continue
}
delete(byAcct, pending.AccountID)
}
out := make([]PlayerSummary, 0, len(byAcct))
for _, summary := range byAcct {
out = append(out, clonePlayerSummary(summary))
}
sortPlayerSummaries(out)
if query.Limit > 0 && len(out) > query.Limit {
out = out[:query.Limit]
}
return out, nil
}
// Flush 同步刷新所有待写摘要。
func (s *AsyncSummaryStore) Flush(ctx context.Context) error {
if s == nil || s.base == nil {
return errSumStoreNil
}
ctx = summaryCtx(ctx)
for {
batch := s.takePending()
if len(batch) == 0 {
return nil
}
for _, summary := range batch {
if err := ctx.Err(); err != nil {
return err
}
if err := s.base.SavePlayerSummary(ctx, summary.summary); err != nil {
return err
}
s.removeSaved(summary)
}
}
}
// Close 停止后台刷新,排空 pending 后关闭底层存储。
func (s *AsyncSummaryStore) Close(ctx context.Context) error {
if s == nil {
return nil
}
closeCtx, cancel := s.closeContext(ctx)
defer cancel()
s.once.Do(func() {
s.mu.Lock()
s.closed = true
s.mu.Unlock()
close(s.stop)
})
select {
case <-s.done:
case <-closeCtx.Done():
return closeCtx.Err()
}
return errors.Join(s.Flush(closeCtx), db.CloseOrFlush(closeCtx, s.base))
}
func (s *AsyncSummaryStore) closeContext(ctx context.Context) (context.Context, context.CancelFunc) {
ctx = summaryCtx(ctx)
if _, ok := ctx.Deadline(); ok {
return ctx, func() {}
}
timeout := sumFlushTimeout
if s.timeout > 0 {
timeout = s.timeout
}
return context.WithTimeout(ctx, timeout)
}
// Check 透传底层摘要存储预检。
func (s *AsyncSummaryStore) Check(ctx context.Context) error {
if s == nil || s.base == nil {
return errSumStoreNil
}
return db.Check(ctx, s.base)
}
func (s *AsyncSummaryStore) run() {
defer close(s.done)
retry := time.NewTimer(time.Hour)
stopSumTimer(retry)
var retryC <-chan time.Time
defer stopSumTimer(retry)
for {
select {
case <-s.wake:
if err := s.flushBackground(); err != nil {
resetSumTimer(retry, sumRetryInterval)
retryC = retry.C
} else {
retryC = nil
}
case <-retryC:
retryC = nil
if err := s.flushBackground(); err != nil {
resetSumTimer(retry, sumRetryInterval)
retryC = retry.C
}
case <-s.stop:
return
}
}
}
func (s *AsyncSummaryStore) flushBackground() error {
timeout := s.timeout
if timeout <= 0 {
timeout = sumFlushTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
err := s.Flush(ctx)
cancel()
return err
}
func (s *AsyncSummaryStore) putLocked(summary PlayerSummary) {
if old, ok := s.pending[summary.AccountID]; ok && old.summary.RoleID != "" && old.summary.RoleID != summary.RoleID {
delete(s.roles, old.summary.RoleID)
}
s.seq++
s.pending[summary.AccountID] = sumPending{
summary: clonePlayerSummary(summary),
seq: s.seq,
}
if summary.RoleID != "" {
s.roles[summary.RoleID] = summary.AccountID
}
}
func (s *AsyncSummaryStore) wakeLocked() {
select {
case s.wake <- struct{}{}:
default:
}
}
func (s *AsyncSummaryStore) pendingByAcct(accountID string) (PlayerSummary, bool) {
s.mu.RLock()
pending, ok := s.pending[accountID]
s.mu.RUnlock()
return clonePlayerSummary(pending.summary), ok
}
func (s *AsyncSummaryStore) pendingByRole(roleID string) (PlayerSummary, bool) {
s.mu.RLock()
accountID := s.roles[roleID]
pending, ok := s.pending[accountID]
s.mu.RUnlock()
if !ok || pending.summary.RoleID != roleID {
return PlayerSummary{}, false
}
return clonePlayerSummary(pending.summary), true
}
func (s *AsyncSummaryStore) pendingByAccts(accountIDs []string) []PlayerSummary {
if len(accountIDs) == 0 {
return nil
}
out := make([]PlayerSummary, 0, len(accountIDs))
s.mu.RLock()
for _, accountID := range accountIDs {
if pending, ok := s.pending[accountID]; ok {
out = append(out, clonePlayerSummary(pending.summary))
}
}
s.mu.RUnlock()
sortPlayerSummaries(out)
return out
}
func (s *AsyncSummaryStore) pendingAll() []PlayerSummary {
s.mu.RLock()
out := make([]PlayerSummary, 0, len(s.pending))
for _, pending := range s.pending {
out = append(out, clonePlayerSummary(pending.summary))
}
s.mu.RUnlock()
sortPlayerSummaries(out)
return out
}
func (s *AsyncSummaryStore) takePending() []sumWrite {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.pending) == 0 {
return nil
}
out := make([]sumWrite, 0, len(s.pending))
for _, pending := range s.pending {
out = append(out, sumWrite{
summary: clonePlayerSummary(pending.summary),
seq: pending.seq,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].summary.AccountID == out[j].summary.AccountID {
return out[i].summary.RoleID < out[j].summary.RoleID
}
return out[i].summary.AccountID < out[j].summary.AccountID
})
return out
}
func (s *AsyncSummaryStore) removeSaved(saved sumWrite) {
s.mu.Lock()
defer s.mu.Unlock()
pending, ok := s.pending[saved.summary.AccountID]
if !ok || pending.seq != saved.seq {
return
}
delete(s.pending, saved.summary.AccountID)
if saved.summary.RoleID != "" && s.roles[saved.summary.RoleID] == saved.summary.AccountID {
delete(s.roles, saved.summary.RoleID)
}
}
func wrapSummaryStore(store SummaryStore) SummaryStore {
switch store.(type) {
case nil, *MemorySummaryStore, *AsyncSummaryStore:
return store
default:
return NewAsyncSummaryStore(store)
}
}
func normSummaryIDs(ids []string) []string {
if len(ids) == 0 {
return nil
}
seen := make(map[string]struct{}, len(ids))
out := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
sort.Strings(out)
return out
}
func summaryCtx(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return ctx
}
func resetSumTimer(timer *time.Timer, delay time.Duration) {
if timer == nil {
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(delay)
}
func stopSumTimer(timer *time.Timer) {
if timer == nil {
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
type sumPending struct {
summary PlayerSummary
seq uint64
}
type sumWrite struct {
summary PlayerSummary
seq uint64
}
@@ -0,0 +1,10 @@
package player
import "context"
func contextOrBackground(ctx context.Context) context.Context {
if ctx != nil {
return ctx
}
return context.Background()
}
@@ -0,0 +1,20 @@
package player
import "longheng.io/server/internal/platform/db"
// AsyncStoreDeadLetter 是玩家异步写入失败后保留的死信记录。
type AsyncStoreDeadLetter = db.AsyncStoreDeadLetter[Profile, ProfileField]
// DeadLetterStore 定义玩家异步存储死信的持久化接口。
type DeadLetterStore = db.DeadLetterStore[Profile, ProfileField]
// FileDeadLetterStore 是保存玩家异步死信的文件型存储。
type FileDeadLetterStore = db.FileDeadLetterStore[Profile, ProfileField]
// NewFileDeadLetterStore 创建文件型玩家异步死信存储。
func NewFileDeadLetterStore(dir string) *FileDeadLetterStore {
return db.NewFileDeadLetterStore[Profile, ProfileField](db.FileDeadLetterStoreOptions{
Directory: dir,
DefaultDirectory: "data/player_dead_letters",
})
}
+8
View File
@@ -0,0 +1,8 @@
// 玩家模块 player 是蓝图自带的示例账号模块。
//
// 本包演示游戏模块如何把通用 Profile 运行时、生成后的 Profile Schema、
// 生成后的读模型、存储和 handler 串起来。货币、背包、任务、邮箱逻辑只用于本地
// 本地 smoke 测试,真实项目应替换为自己的模块和 Schema。
//
// 共享框架代码应放在 internal/platform 下,不能依赖本包或本包的示例数据字段。
package player
@@ -0,0 +1,222 @@
package player
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"sync/atomic"
"time"
"longheng.io/server/internal/platform/eventlog"
)
const (
// ProfileEventStream 是玩家档案事件写入 eventlog 的流名。
ProfileEventStream = "profile"
// ProfileEventTypeSaved 表示玩家档案保存事件。
ProfileEventTypeSaved = "saved"
// EventStateChanged 表示玩家在线状态变化事件。
EventStateChanged = "state.changed"
// ProfileEventTypeMutation 表示玩家档案关键字段变更事件。
ProfileEventTypeMutation = "mutation.committed"
)
// ProfileEventAppender 是玩家模块写 profile outbox 的最小接口。
type ProfileEventAppender interface {
Append(context.Context, eventlog.Event) (eventlog.Event, error)
}
// ProfileEventPayload 是玩家档案保存或状态变化事件的载荷。
type ProfileEventPayload struct {
AccountID string `json:"account_id"`
RoleID string `json:"role_id,omitempty"`
Name string `json:"name,omitempty"`
Level int `json:"level,omitempty"`
State string `json:"state,omitempty"`
Version int64 `json:"version"`
Reason string `json:"reason,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
SavedAt time.Time `json:"saved_at,omitempty"`
}
// MutationEventPayload 是玩家档案关键字段变更事件的载荷。
type MutationEventPayload struct {
AccountID string `json:"account_id"`
RoleID string `json:"role_id,omitempty"`
Operation string `json:"operation"`
Version int64 `json:"version"`
Fields []ProfileField `json:"fields,omitempty"`
Reason string `json:"reason,omitempty"`
Mutation map[string]any `json:"mutation,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// UseEventLog 为玩家模块接入 eventlog,strict 为 true 时写事件失败会返回错误。
func (m *Module) UseEventLog(log *eventlog.Log, strict bool) *Module {
return m.UseEventAppender(log, strict)
}
// UseEventAppender 为玩家模块接入自定义 profile 事件追加器。
func (m *Module) UseEventAppender(appender ProfileEventAppender, strict bool) *Module {
if m == nil {
return nil
}
m.eventsStrict = strict
m.eventAsync = nil
if appender != nil && !strict {
async := newAsyncEvents(appender, m.logger, &m.eventLogErrors)
m.events = async
m.eventAsync = async
return m
}
m.events = appender
return m
}
// EventLogErrors 返回玩家模块写 eventlog 失败的累计次数。
func (m *Module) EventLogErrors() uint64 {
if m == nil {
return 0
}
return atomic.LoadUint64(&m.eventLogErrors)
}
func (m *Module) emitProfileSaved(ctx context.Context, profile Profile, reason string) error {
return m.emitProfileEvent(ctx, ProfileEventTypeSaved, profile, reason)
}
func (m *Module) emitProfileChanged(ctx context.Context, profile Profile, reason string) error {
return m.emitProfileEvent(ctx, EventStateChanged, profile, reason)
}
func (m *Module) emitProfileMutation(ctx context.Context, profile Profile, operation, reason string, fields []ProfileField, mutation map[string]any) error {
if m == nil || m.events == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
operation = strings.TrimSpace(operation)
if operation == "" {
operation = "profile.mutation"
}
reason = strings.TrimSpace(reason)
payload, err := json.Marshal(MutationEventPayload{
AccountID: profile.AccountID,
RoleID: profile.RoleID,
Operation: operation,
Version: profile.Version,
Fields: normProfileFields(fields),
Reason: reason,
Mutation: cloneMutationMap(mutation),
UpdatedAt: profile.UpdatedAt.UTC(),
})
if err != nil {
return err
}
_, err = m.events.Append(ctx, eventlog.Event{
Stream: ProfileEventStream,
Type: ProfileEventTypeMutation,
AggregateID: profile.AccountID,
IdempotencyKey: profileIdemKey(profile, operation, mutation),
Payload: payload,
Headers: map[string]string{
"account_id": profile.AccountID,
"role_id": profile.RoleID,
"operation": operation,
"reason": reason,
},
})
if err == nil {
return nil
}
atomic.AddUint64(&m.eventLogErrors, 1)
if m.logger != nil {
m.logger.Error("player profile mutation eventlog append failed", "account_id", profile.AccountID, "operation", operation, "error", err)
}
if m.eventsStrict {
return err
}
return nil
}
func (m *Module) emitProfileEvent(ctx context.Context, eventType string, profile Profile, reason string) error {
if m == nil || m.events == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
eventType = strings.TrimSpace(eventType)
if eventType == "" {
eventType = ProfileEventTypeSaved
}
reason = strings.TrimSpace(reason)
payload, err := json.Marshal(ProfileEventPayload{
AccountID: profile.AccountID,
RoleID: profile.RoleID,
Name: profile.Name,
Level: profile.Level,
State: profile.State,
Version: profile.Version,
Reason: reason,
UpdatedAt: profile.UpdatedAt.UTC(),
SavedAt: profile.SavedAt.UTC(),
})
if err != nil {
return err
}
_, err = m.events.Append(ctx, eventlog.Event{
Stream: ProfileEventStream,
Type: eventType,
AggregateID: profile.AccountID,
IdempotencyKey: profileEventIdemKey(profile, eventType, reason),
Payload: payload,
Headers: map[string]string{
"account_id": profile.AccountID,
"role_id": profile.RoleID,
"reason": reason,
},
})
if err == nil {
return nil
}
atomic.AddUint64(&m.eventLogErrors, 1)
if m.logger != nil {
m.logger.Error("player profile eventlog append failed", "account_id", profile.AccountID, "event_type", eventType, "error", err)
}
if m.eventsStrict {
return err
}
return nil
}
func profileEventIdemKey(profile Profile, eventType string, reason string) string {
ts := profile.UpdatedAt.UTC().UnixNano()
if ts == 0 {
ts = profile.SavedAt.UTC().UnixNano()
}
sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%d|%s", profile.AccountID, eventType, profile.Version, ts, reason)))
return "profile:" + hex.EncodeToString(sum[:16])
}
func profileIdemKey(profile Profile, operation string, mutation map[string]any) string {
ts := profile.UpdatedAt.UTC().UnixNano()
mutationJSON, _ := json.Marshal(mutation)
sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%d|%s", profile.AccountID, operation, profile.Version, ts, mutationJSON)))
return "profile-mut:" + hex.EncodeToString(sum[:16])
}
func cloneMutationMap(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
}
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}
@@ -0,0 +1,46 @@
package player
import (
"context"
"strings"
"longheng.io/server/internal/platform/db"
)
// FileStore 用本地 JSON 文件保存玩家 Profile,适合开发和单机演示。
type FileStore struct {
inner *db.JSONFileStore[Profile]
}
// NewFileStore 创建文件型玩家 Profile 存储。
func NewFileStore(dir string) *FileStore {
return &FileStore{
inner: db.NewJSONFileStore(db.JSONFileStoreOptions[Profile]{
Directory: dir,
DefaultDirectory: "data/profiles",
Key: profileKey,
Clone: cloneProfile,
WithLoadedKey: normAccountID,
}),
}
}
// Load 按账号 ID 从文件存储读取玩家 Profile。
func (s *FileStore) Load(ctx context.Context, accountID string) (Profile, bool, error) {
return s.inner.Load(ctx, strings.TrimSpace(accountID))
}
// Check 检查文件存储目录是否可用。
func (s *FileStore) Check(ctx context.Context) error {
return s.inner.Check(ctx)
}
// Save 保存完整玩家 Profile 到文件存储。
func (s *FileStore) Save(ctx context.Context, profile Profile) error {
return s.inner.Save(ctx, normProfileID(profile))
}
// SaveFields 保存玩家 Profile 指定字段,文件存储会退化为全量保存。
func (s *FileStore) SaveFields(ctx context.Context, profile Profile, _ ...ProfileField) error {
return s.Save(ctx, profile)
}
@@ -0,0 +1,94 @@
package player
import (
"context"
"fmt"
"strings"
"longheng.io/server/internal/platform/db"
)
// MemoryStore 是用于测试、开发和 Lite 场景的内存玩家 Profile 存储。
type MemoryStore struct {
inner *db.MemoryStore[Profile]
}
// NewMemoryStore 创建内存玩家 Profile 存储。
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
inner: db.NewMemoryStore(profileKey, cloneProfile),
}
}
// Load 按账号 ID 从内存读取玩家 Profile。
func (s *MemoryStore) Load(ctx context.Context, accountID string) (Profile, bool, error) {
return s.inner.Load(ctx, strings.TrimSpace(accountID))
}
// LoadByRoleID 按角色 ID 从内存扫描玩家 Profile。
func (s *MemoryStore) LoadByRoleID(ctx context.Context, roleID string) (Profile, bool, error) {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return Profile{}, false, err
}
roleID = strings.TrimSpace(roleID)
if roleID == "" {
return Profile{}, false, nil
}
for _, profile := range s.Snapshot() {
if profile.RoleID == roleID {
return profile, true, nil
}
}
return Profile{}, false, nil
}
// ListProfiles 按账号 ID 游标列出内存玩家 Profile。
func (s *MemoryStore) ListProfiles(ctx context.Context, afterAccountID string, limit int) ([]Profile, error) {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return nil, err
}
afterAccountID = strings.TrimSpace(afterAccountID)
if limit <= 0 {
return nil, nil
}
snapshot := s.Snapshot()
out := make([]Profile, 0, min(limit, len(snapshot)))
for _, profile := range snapshot {
if afterAccountID != "" && profile.AccountID <= afterAccountID {
continue
}
out = append(out, profile)
if len(out) >= limit {
break
}
}
return out, nil
}
// Check 检查内存存储状态。
func (s *MemoryStore) Check(ctx context.Context) error {
return s.inner.Check(ctx)
}
// Save 保存完整玩家 Profile 到内存。
func (s *MemoryStore) Save(ctx context.Context, profile Profile) error {
profile = normProfileID(profile)
if profile.AccountID == "" {
return fmt.Errorf("account id is required")
}
return s.inner.Save(ctx, profile)
}
// SaveFields 保存玩家 Profile 指定字段,内存存储会退化为全量保存。
func (s *MemoryStore) SaveFields(ctx context.Context, profile Profile, _ ...ProfileField) error {
return s.Save(ctx, profile)
}
// Snapshot 返回按账号 ID 排序的玩家 Profile 快照。
func (s *MemoryStore) Snapshot() []Profile {
return s.inner.Snapshot(func(left, right Profile) bool {
return left.AccountID < right.AccountID
})
}
+696
View File
@@ -0,0 +1,696 @@
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
}
@@ -0,0 +1,425 @@
package player
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
)
const mysqlProfileTable = "player_profiles"
var errProfileRowsReq = errors.New("mysql profile rows are required")
// MySQLStoreOptions 是 MySQL 玩家 Profile 存储的连接池、表名和建表配置。
type MySQLStoreOptions struct {
DSN string
TableName string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
EnsureSchema bool
}
// MySQLStore 基于 MySQL JSON 列保存玩家 Profile 的字段化数据。
type MySQLStore struct {
db *sql.DB
initErr error
tableName string
ensureSchema bool
}
// NewMySQLStore 创建 MySQL 玩家 Profile 存储。
func NewMySQLStore(options MySQLStoreOptions) *MySQLStore {
options = normMySQLStoreOpts(options)
conn, err := sql.Open("mysql", options.DSN)
if err == nil {
conn.SetMaxOpenConns(options.MaxOpenConns)
conn.SetMaxIdleConns(options.MaxIdleConns)
conn.SetConnMaxLifetime(options.ConnMaxLifetime)
}
return &MySQLStore{
db: conn,
initErr: err,
tableName: options.TableName,
ensureSchema: options.EnsureSchema,
}
}
func normMySQLStoreOpts(options MySQLStoreOptions) MySQLStoreOptions {
options.DSN = strings.TrimSpace(options.DSN)
if options.DSN == "" {
options.DSN = "longheng:longheng@tcp(127.0.0.1:3306)/longheng?parseTime=true&charset=utf8mb4,utf8"
}
options.TableName = strings.TrimSpace(options.TableName)
if options.TableName == "" {
options.TableName = mysqlProfileTable
}
if options.MaxOpenConns <= 0 {
options.MaxOpenConns = 32
}
if options.MaxIdleConns <= 0 {
options.MaxIdleConns = 8
}
if options.ConnMaxLifetime <= 0 {
options.ConnMaxLifetime = 5 * time.Minute
}
return options
}
// Load 按账号 ID 从 MySQL 读取玩家 Profile。
func (s *MySQLStore) Load(ctx context.Context, accountID string) (Profile, bool, error) {
if err := s.ready(ctx); err != nil {
return Profile{}, false, err
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return Profile{}, false, fmt.Errorf("account id is required")
}
query := fmt.Sprintf( // #nosec G201 -- 表名和列名均由框架固定常量或 mysqlQuoteIdentifier 生成,业务值仍使用占位符。
"SELECT %s FROM %s WHERE %s = ?",
mysqlSelectColumns(),
mysqlQuoteIdentifier(s.tableName),
mysqlQuoteIdentifier("account_id"),
)
return s.loadProfile(ctx, query, accountID)
}
// LoadByRoleID 按角色 ID 从 MySQL 读取玩家 Profile。
func (s *MySQLStore) LoadByRoleID(ctx context.Context, roleID string) (Profile, bool, error) {
if err := s.ready(ctx); err != nil {
return Profile{}, false, err
}
roleID = strings.TrimSpace(roleID)
if roleID == "" {
return Profile{}, false, fmt.Errorf("role id is required")
}
query := fmt.Sprintf( // #nosec G201 -- 表名、列名和值占位符均由框架白名单字段生成,业务值通过参数绑定写入。
"SELECT %s FROM %s WHERE %s = ? LIMIT 1",
mysqlSelectColumns(),
mysqlQuoteIdentifier(s.tableName),
mysqlQuoteIdentifier("role_id"),
)
return s.loadProfile(ctx, query, roleID)
}
// ListProfiles 按账号 ID 游标从 MySQL 扫描玩家 Profile。
func (s *MySQLStore) ListProfiles(ctx context.Context, afterAccountID string, limit int) (out []Profile, err error) {
if err := s.ready(ctx); err != nil {
return nil, err
}
if limit <= 0 {
return nil, nil
}
if limit > 1000 {
limit = 1000
}
afterAccountID = strings.TrimSpace(afterAccountID)
query := fmt.Sprintf( //nolint:gosec // G201:表名和列名均由框架固定常量或 mysqlQuoteIdentifier 生成,业务值仍使用占位符。
"SELECT %s FROM %s WHERE %s > ? ORDER BY %s LIMIT ?",
mysqlSelectColumns(),
mysqlQuoteIdentifier(s.tableName),
mysqlQuoteIdentifier("account_id"),
mysqlQuoteIdentifier("account_id"),
)
rows, err := s.db.QueryContext(ctx, query, afterAccountID, limit)
if err != nil {
return nil, err
}
if rows == nil {
return nil, errProfileRowsReq
}
defer closeMySQLRowsErr(rows, &err)
out = make([]Profile, 0, limit)
for rows.Next() {
profile, err := scanMySQLProfile(rows)
if err != nil {
return nil, err
}
out = append(out, profile)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func closeMySQLRowsErr(rows interface{ Close() error }, err *error) {
if rows == nil || err == nil {
return
}
if closeErr := rows.Close(); closeErr != nil && *err == nil {
*err = closeErr
}
}
func (s *MySQLStore) loadProfile(ctx context.Context, query string, args ...any) (Profile, bool, error) {
profile, err := scanMySQLProfile(s.db.QueryRowContext(ctx, query, args...))
if errors.Is(err, sql.ErrNoRows) {
return Profile{}, false, nil
}
if err != nil {
return Profile{}, false, err
}
return profile, true, nil
}
type profileScanner interface {
Scan(...any) error
}
func scanMySQLProfile(scanner profileScanner) (Profile, error) {
var accountID, roleID string
moduleFields := redisModuleFields()
moduleValues := make([]sql.NullString, len(moduleFields))
dest := make([]any, 0, 2+len(moduleFields))
dest = append(dest, &accountID, &roleID)
for idx := range moduleValues {
dest = append(dest, &moduleValues[idx])
}
err := scanner.Scan(dest...)
if err != nil {
return Profile{}, err
}
modules := make(map[string][]byte, len(moduleFields))
for idx, field := range moduleFields {
modules[field] = nullableJSON(moduleValues[idx])
}
baseField, ok := profileDBHashField(ProfileFieldBase)
if !ok {
return Profile{}, fmt.Errorf("profile base field is not configured")
}
if len(modules[baseField]) == 0 {
return Profile{}, fmt.Errorf("profile %s has no base module", accountID)
}
profile, err := profileFromRedis(accountID, modules)
if err != nil {
return Profile{}, err
}
if profile.RoleID == "" {
profile.RoleID = roleID
}
profile, err = normMySQLIdentity(profile)
if err != nil {
return Profile{}, err
}
return cloneProfile(profile), nil
}
// Save 保存完整玩家 Profile 到 MySQL。
func (s *MySQLStore) Save(ctx context.Context, profile Profile) error {
return s.SaveFields(ctx, profile, AllProfileFields()...)
}
// SaveFields 只保存玩家 Profile 的指定字段,必要时补写基础字段。
func (s *MySQLStore) SaveFields(ctx context.Context, profile Profile, fields ...ProfileField) error {
if err := s.ready(ctx); err != nil {
return err
}
profile, err := normMySQLIdentity(profile)
if err != nil {
return err
}
fields = normProfileFields(fields)
if len(fields) == 0 {
return nil
}
encoded, err := encodeProfileFields(profile, fields)
if err != nil {
return err
}
dirty := make(map[ProfileField]struct{}, len(fields))
for _, field := range fields {
dirty[field] = struct{}{}
}
if _, ok := dirty[ProfileFieldBase]; !ok {
baseData, err := encodeProfileField(profile, ProfileFieldBase)
if err != nil {
return err
}
encoded[redisBaseField] = baseData
}
updatedAt := profile.UpdatedAt
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
columns := []string{
mysqlQuoteIdentifier("account_id"),
mysqlQuoteIdentifier("role_id"),
mysqlQuoteIdentifier("updated_at"),
}
args := []any{profile.AccountID, profile.RoleID, updatedAt}
updates := []string{mysqlQuoteIdentifier("updated_at") + " = VALUES(" + mysqlQuoteIdentifier("updated_at") + ")"}
insertFields := fields
if _, ok := dirty[ProfileFieldBase]; !ok {
insertFields = append([]ProfileField{ProfileFieldBase}, fields...)
}
for _, field := range insertFields {
hashField, ok := profileDBHashField(field)
if !ok {
return fmt.Errorf("unsupported profile field %q", field)
}
column, ok := mysqlProfileColumn(hashField)
if !ok {
return fmt.Errorf("unsupported mysql profile field %q", hashField)
}
column = mysqlQuoteIdentifier(column)
columns = append(columns, column)
args = append(args, string(encoded[hashField]))
if _, shouldUpdate := dirty[field]; shouldUpdate {
if field == ProfileFieldBase {
roleIDColumn := mysqlQuoteIdentifier("role_id")
updates = append(updates, roleIDColumn+" = VALUES("+roleIDColumn+")")
}
updates = append(updates, column+" = VALUES("+column+")")
} else if field == ProfileFieldBase {
roleIDColumn := mysqlQuoteIdentifier("role_id")
updates = append(updates,
roleIDColumn+" = IF("+roleIDColumn+" = '', VALUES("+roleIDColumn+"), "+roleIDColumn+")",
column+" = COALESCE("+column+", VALUES("+column+"))",
)
}
}
valueMarkers := make([]string, len(columns))
for i := range valueMarkers {
valueMarkers[i] = "?"
}
query := fmt.Sprintf( //nolint:gosec // G201:表名、列名和值占位符均由框架白名单字段生成,业务值通过参数绑定写入。
"INSERT INTO %s (%s) VALUES (%s) ON DUPLICATE KEY UPDATE %s",
mysqlQuoteIdentifier(s.tableName),
strings.Join(columns, ", "),
strings.Join(valueMarkers, ", "),
strings.Join(updates, ", "),
)
_, err = s.db.ExecContext(ctx, query, args...)
return err
}
func normMySQLIdentity(profile Profile) (Profile, error) {
profile = normProfileID(profile)
if profile.AccountID == "" {
return Profile{}, fmt.Errorf("account id is required")
}
return profile, nil
}
// Check 检查 MySQL 连接,并按配置确保 Profile 表存在。
func (s *MySQLStore) Check(ctx context.Context) error {
if err := s.ready(ctx); err != nil {
return err
}
if err := s.db.PingContext(ctx); err != nil {
return err
}
if s.ensureSchema {
_, err := s.db.ExecContext(ctx, mysqlProfileTableDDL(s.tableName))
return err
}
return nil
}
// Close 关闭 MySQL 玩家 Profile 存储连接。
func (s *MySQLStore) Close(context.Context) error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}
func (s *MySQLStore) ready(ctx context.Context) error {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return err
}
if s == nil || s.db == nil {
return fmt.Errorf("mysql store is nil")
}
return s.initErr
}
// TableName 返回当前 MySQL 玩家 Profile 表名。
func (s *MySQLStore) TableName() string {
if s == nil {
return ""
}
return s.tableName
}
func mysqlProfileColumn(hashField string) (string, bool) {
if _, ok := profileDBDescByHash(hashField); !ok {
return "", false
}
return mysqlJSONColumn(hashField), true
}
func mysqlProfileTableDDL(tableName string) string {
tableName = strings.TrimSpace(tableName)
if tableName == "" {
tableName = mysqlProfileTable
}
columnDefs := []string{
mysqlQuoteIdentifier("account_id") + " VARCHAR(128) NOT NULL",
mysqlQuoteIdentifier("role_id") + " VARCHAR(128) NOT NULL DEFAULT ''",
}
for _, column := range mysqlJSONColumns() {
columnDefs = append(columnDefs, mysqlQuoteIdentifier(column)+" JSON NULL")
}
columnDefs = append(columnDefs,
mysqlQuoteIdentifier("updated_at")+" DATETIME(6) NOT NULL",
"PRIMARY KEY ("+mysqlQuoteIdentifier("account_id")+")",
"UNIQUE KEY "+mysqlQuoteIdentifier("uk_player_profiles_role_id")+" ("+mysqlQuoteIdentifier("role_id")+")",
"KEY "+mysqlQuoteIdentifier("idx_player_profiles_updated_at")+" ("+mysqlQuoteIdentifier("updated_at")+")",
)
return fmt.Sprintf(
"CREATE TABLE IF NOT EXISTS %s (\n %s\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
mysqlQuoteIdentifier(tableName),
strings.Join(columnDefs, ",\n "),
)
}
func mysqlSelectColumns() string {
columns := mysqlProfileCols()
out := make([]string, len(columns))
for idx, column := range columns {
out[idx] = mysqlQuoteIdentifier(column)
}
return strings.Join(out, ", ")
}
func mysqlProfileCols() []string {
columns := []string{"account_id", "role_id"}
columns = append(columns, mysqlJSONColumns()...)
return columns
}
func mysqlJSONColumns() []string {
hashFields := redisModuleFields()
columns := make([]string, 0, len(hashFields))
for _, hashField := range hashFields {
columns = append(columns, mysqlJSONColumn(hashField))
}
return columns
}
func mysqlJSONColumn(hashField string) string {
return hashField + "_json"
}
func mysqlQuoteIdentifier(identifier string) string {
return "`" + strings.ReplaceAll(identifier, "`", "``") + "`"
}
func nullableJSON(value sql.NullString) []byte {
if !value.Valid {
return nil
}
return []byte(value.String)
}
@@ -0,0 +1,319 @@
package player
import (
"context"
"fmt"
"strings"
"time"
)
// MailRecord 是玩家邮箱中的单封邮件记录。
type MailRecord struct {
ID string `json:"id"`
Title string `json:"title"`
State string `json:"state"`
CreatedAt time.Time `json:"created_at"`
}
// AddCurrency 增减玩家指定货币并记录关键字段变更事件。
func (m *Module) AddCurrency(ctx context.Context, accountID, currency string, delta int64) (Profile, error) {
currency = strings.TrimSpace(currency)
if currency == "" {
return Profile{}, fmt.Errorf("currency is required")
}
return m.mutateCritProfile(ctx, accountID, ProfileOperationAddCurrency, "handler.add_currency", func(profile *Profile, _ time.Time) (map[string]any, error) {
if profile.Currencies == nil {
profile.Currencies = defaultCurrencies()
}
before := profile.Currencies[currency]
profile.Currencies[currency] += delta
return map[string]any{
"currency": currency,
"delta": delta,
"balance_before": before,
"balance_after": profile.Currencies[currency],
}, nil
})
}
// SetInventoryItem 设置玩家背包物品数量,数量不大于零时删除该物品。
func (m *Module) SetInventoryItem(ctx context.Context, accountID, itemID string, quantity int64) (Profile, error) {
itemID = strings.TrimSpace(itemID)
if itemID == "" {
return Profile{}, fmt.Errorf("item id is required")
}
return m.mutateCritProfile(ctx, accountID, ProfileOperationSetInventoryItem, "handler.set_inventory_item", func(profile *Profile, _ time.Time) (map[string]any, error) {
if profile.Inventory == nil {
profile.Inventory = defaultInventory()
}
before := profile.Inventory[itemID]
if quantity <= 0 {
delete(profile.Inventory, itemID)
} else {
profile.Inventory[itemID] = quantity
}
return map[string]any{
"item_id": itemID,
"quantity_before": before,
"quantity_after": profile.Inventory[itemID],
"removed": quantity <= 0,
}, nil
})
}
// SetQuestState 设置玩家任务状态并写入任务字段。
func (m *Module) SetQuestState(ctx context.Context, accountID, questID, state string) (Profile, error) {
questID = strings.TrimSpace(questID)
state = strings.TrimSpace(state)
if questID == "" {
return Profile{}, fmt.Errorf("quest id is required")
}
return m.mutateCritProfile(ctx, accountID, ProfileOperationSetQuestState, "handler.set_quest_state", func(profile *Profile, _ time.Time) (map[string]any, error) {
if profile.Quests == nil {
profile.Quests = defaultQuests()
}
before := profile.Quests[questID]
profile.Quests[questID] = state
return map[string]any{
"quest_id": questID,
"state_before": before,
"state_after": state,
}, nil
})
}
// MarkMailRead 标记玩家邮件已读,并返回是否找到和是否发生变更。
func (m *Module) MarkMailRead(ctx context.Context, accountID, mailID string) (Profile, bool, bool, error) {
accountID = strings.TrimSpace(accountID)
mailID = strings.TrimSpace(mailID)
if accountID == "" {
return Profile{}, false, false, fmt.Errorf("account id is required")
}
if mailID == "" {
return Profile{}, false, false, fmt.Errorf("mail id is required")
}
now := time.Now().UTC()
var activeFound, activeChanged bool
if before, profile, ok, err := m.profiles.MutateActive(accountID, func(profile *Profile, now time.Time) ([]ProfileField, error) {
activeFound, activeChanged = markMailRead(profile, mailID)
if activeChanged {
fields := dirtyFieldsByModule(ProfileModuleMailbox)
eventProfile := cloneProfile(*profile)
eventProfile.UpdatedAt = now
eventProfile.Version++
if err := m.emitProfileMutation(ctx, eventProfile, "read_mail", "handler.read_mail", fields, map[string]any{
"mail_id": mailID,
"changed": true,
"unread_count": unreadMailCount(eventProfile),
}); err != nil {
return nil, err
}
return fields, nil
}
return nil, nil
}); err != nil {
return Profile{}, false, false, err
} else if ok {
if activeChanged {
m.observeSummaryChange(ctx, before, profile)
}
return cloneProfile(profile), activeFound, activeChanged, nil
}
profile, ok, err := m.profiles.LoadDetached(ctx, accountID)
if err != nil {
return Profile{}, false, false, err
}
dirtyFields := newProfileFieldSet(dirtyFieldsByModule(ProfileModuleRuntime)...)
if !ok {
profile = m.profiles.NewDetached(accountID, now)
dirtyFields.AddAll()
} else {
profile = normAccountID(profile, accountID)
dirtyFields.Merge(ensureProfileModules(&profile, now))
}
found, changed := markMailRead(&profile, mailID)
if !found {
return cloneProfile(profile), false, false, nil
}
profile.State = "online"
profile.LoadedAt = now
profile.UpdatedAt = now
profile.Version++
if changed {
dirtyFields.Add(dirtyFieldsByModule(ProfileModuleMailbox)...)
}
attachedProfile, attached := m.profiles.AttachIfAbsent(accountID, profile, dirtyFields.List()...)
if !attached {
_, current, ok, err := m.profiles.MutateActive(accountID, func(profile *Profile, now time.Time) ([]ProfileField, error) {
found, changed = markMailRead(profile, mailID)
if changed {
fields := dirtyFieldsByModule(ProfileModuleMailbox)
eventProfile := cloneProfile(*profile)
eventProfile.UpdatedAt = now
eventProfile.Version++
if err := m.emitProfileMutation(ctx, eventProfile, "read_mail", "handler.read_mail", fields, map[string]any{
"mail_id": mailID,
"changed": true,
"unread_count": unreadMailCount(eventProfile),
}); err != nil {
return nil, err
}
return fields, nil
}
return nil, nil
})
if err != nil {
return Profile{}, false, false, err
}
if ok && changed {
m.observeSummary(ctx, current)
}
return cloneProfile(current), found, changed, nil
}
m.observeSummary(ctx, attachedProfile)
if changed {
if err := m.emitProfileMutation(ctx, attachedProfile, "read_mail", "handler.read_mail", dirtyFieldsByModule(ProfileModuleMailbox), map[string]any{
"mail_id": mailID,
"changed": true,
"unread_count": unreadMailCount(attachedProfile),
}); err != nil {
return cloneProfile(attachedProfile), true, changed, err
}
}
return cloneProfile(attachedProfile), true, changed, nil
}
func applyProfileDefaults(profile *Profile, now time.Time) {
if profile == nil {
return
}
if strings.TrimSpace(profile.Name) == "" {
profile.Name = "Player " + profile.AccountID
}
if profile.Level <= 0 {
profile.Level = 1
}
_ = backfillModules(profile, now)
}
func cloneProfileModules(profile Profile) Profile {
profile.Currencies = cloneInt64Map(profile.Currencies)
profile.Inventory = cloneInt64Map(profile.Inventory)
profile.Quests = cloneStringMap(profile.Quests)
if profile.Mailbox != nil {
profile.Mailbox = append([]MailRecord(nil), profile.Mailbox...)
}
return profile
}
func backfillCurrency(profile *Profile, _ time.Time) bool {
if profile == nil || profile.Currencies != nil {
return false
}
profile.Currencies = defaultCurrencies()
return true
}
func backfillInventory(profile *Profile, _ time.Time) bool {
if profile == nil || profile.Inventory != nil {
return false
}
profile.Inventory = defaultInventory()
return true
}
func backfillProfileQuest(profile *Profile, _ time.Time) bool {
if profile == nil || profile.Quests != nil {
return false
}
profile.Quests = defaultQuests()
return true
}
func backfillProfileMail(profile *Profile, now time.Time) bool {
if profile == nil || profile.Mailbox != nil {
return false
}
profile.Mailbox = defaultMailbox(now)
return true
}
func defaultCurrencies() map[string]int64 {
return map[string]int64{
"gold": 0,
"diamond": 0,
}
}
func defaultInventory() map[string]int64 {
return map[string]int64{
"starter_sword": 1,
}
}
func defaultQuests() map[string]string {
return map[string]string{
"main_001": "accepted",
}
}
func defaultMailbox(now time.Time) []MailRecord {
return []MailRecord{
{
ID: "welcome",
Title: "Welcome",
State: "unread",
CreatedAt: now,
},
}
}
func markMailRead(profile *Profile, mailID string) (bool, bool) {
for i := range profile.Mailbox {
if profile.Mailbox[i].ID == mailID {
if profile.Mailbox[i].State == "read" {
return true, false
}
profile.Mailbox[i].State = "read"
return true, true
}
}
return false, false
}
func unreadMailCount(profile Profile) int {
count := 0
for _, mail := range profile.Mailbox {
if mail.State != "read" {
count++
}
}
return count
}
func cloneInt64Map(in map[string]int64) map[string]int64 {
if in == nil {
return nil
}
out := make(map[string]int64, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func cloneStringMap(in map[string]string) map[string]string {
if in == nil {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
@@ -0,0 +1,84 @@
package player
import (
"context"
"strings"
"longheng.io/server/internal/platform/db"
)
// 玩家 Profile、Redis/MySQL 字段记录、dirty-field helper、操作包装和回填分发
// 由 configs/profiledb/player.json 生成。
// 新游戏应替换该 Schema 及其默认值/回填 hook,而不是修改平台运行时。
//go:generate go run ../../../cmd/profilegen -schema ../../../configs/profiledb/player.json -out profile_schema_gen.go
// ProfileField 表示 profiledb schema 中可独立持久化的玩家字段。
type ProfileField string
// ProfileOperation 表示 profiledb schema 中声明的关键玩家操作。
type ProfileOperation string
// FieldStore 定义支持玩家 Profile 字段级保存的存储接口。
type FieldStore = db.FieldStore[Profile, ProfileField]
// ProfileFieldDescriptor 描述玩家 Profile 字段的模块、Hash 字段和列映射。
type ProfileFieldDescriptor struct {
Field ProfileField `json:"field"`
Module string `json:"module,omitempty"`
HashField string `json:"hash_field"`
Record string `json:"record"`
Required bool `json:"required,omitempty"`
Backfill bool `json:"backfill,omitempty"`
DirtyFields []ProfileField `json:"dirty_fields,omitempty"`
Description string `json:"description,omitempty"`
Columns []ProfileRecordFieldDescriptor `json:"columns,omitempty"`
}
// ProfileRecordFieldDescriptor 描述字段记录中的单个列映射。
type ProfileRecordFieldDescriptor struct {
Name string `json:"name"`
Type string `json:"type"`
JSONName string `json:"json"`
Profile string `json:"profile"`
}
// ProfileOperationDescriptor 描述玩家操作和它会写脏的字段集合。
type ProfileOperationDescriptor struct {
Operation ProfileOperation `json:"operation"`
Module string `json:"module,omitempty"`
DirtyFields []ProfileField `json:"dirty_fields,omitempty"`
Description string `json:"description,omitempty"`
}
var profileFieldRegistry = db.NewFieldRegistry(AllProfileFields())
type profileFieldSet = db.FieldSet[ProfileField]
func newProfileFieldSet(fields ...ProfileField) profileFieldSet {
return profileFieldRegistry.NewSet(fields...)
}
func saveProfileFields(ctx context.Context, store Store, profile Profile, fields ...ProfileField) error {
return db.SaveFields(ctx, store, profile, normProfileFields, fields...)
}
func profileKey(profile Profile) string {
return strings.TrimSpace(profile.AccountID)
}
func normProfileID(profile Profile) Profile {
return normAccountID(profile, profile.AccountID)
}
func normAccountID(profile Profile, accountID string) Profile {
accountID = strings.TrimSpace(accountID)
profile.AccountID = strings.TrimSpace(profile.AccountID)
if profile.AccountID == "" {
profile.AccountID = accountID
}
profile.RoleID = strings.TrimSpace(profile.RoleID)
if profile.RoleID == "" && profile.AccountID != "" {
profile.RoleID = "role-" + profile.AccountID
}
return profile
}
@@ -0,0 +1,669 @@
package player
// Code generated by profilegen; DO NOT EDIT.
import (
"context"
"encoding/json"
"fmt"
"time"
)
type Profile struct {
AccountID string `json:"account_id"`
RoleID string `json:"role_id"`
Name string `json:"name"`
Level int `json:"level"`
State string `json:"state"`
LoadedAt time.Time `json:"loaded_at,omitempty"`
SavedAt time.Time `json:"saved_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
Version int64 `json:"version"`
Currencies map[string]int64 `json:"currencies,omitempty"`
Inventory map[string]int64 `json:"inventory,omitempty"`
Quests map[string]string `json:"quests,omitempty"`
Mailbox []MailRecord `json:"mailbox,omitempty"`
}
const (
ProfileFieldBase ProfileField = "base"
ProfileFieldRuntime ProfileField = "runtime"
ProfileFieldMeta ProfileField = "meta"
ProfileFieldCurrency ProfileField = "currency"
ProfileFieldInventory ProfileField = "inventory"
ProfileFieldQuest ProfileField = "quest"
ProfileFieldMail ProfileField = "mail"
)
var orderedFields = []ProfileField{
ProfileFieldBase,
ProfileFieldRuntime,
ProfileFieldMeta,
ProfileFieldCurrency,
ProfileFieldInventory,
ProfileFieldQuest,
ProfileFieldMail,
}
const (
ProfileModuleIdentity = "identity"
ProfileModuleRuntime = "runtime"
ProfileModuleMeta = "meta"
ProfileModuleWallet = "wallet"
ProfileModuleInventory = "inventory"
ProfileModuleQuest = "quest"
ProfileModuleMailbox = "mailbox"
)
type profileBaseRecord struct {
AccountID string `json:"account_id"`
RoleID string `json:"role_id"`
Name string `json:"name"`
Level int `json:"level"`
}
type profileRuntimeRecord struct {
State string `json:"state"`
LoadedAt time.Time `json:"loaded_at,omitempty"`
SavedAt time.Time `json:"saved_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type profileMetaRecord struct {
Version int64 `json:"version"`
}
type currencyRecord struct {
Currencies map[string]int64 `json:"currencies,omitempty"`
}
type inventoryRecord struct {
Inventory map[string]int64 `json:"inventory,omitempty"`
}
type profileQuestRecord struct {
Quests map[string]string `json:"quests,omitempty"`
}
type profileMailRecord struct {
Mailbox []MailRecord `json:"mailbox,omitempty"`
}
const (
redisBaseField = "base"
redisRuntimeField = "runtime"
redisMetaField = "meta"
redisCurrencyField = "currency"
redisInventoryField = "inventory"
redisQuestField = "quest"
redisMailField = "mail"
)
var genFieldLookup = map[ProfileField]struct{}{
ProfileFieldBase: {},
ProfileFieldRuntime: {},
ProfileFieldMeta: {},
ProfileFieldCurrency: {},
ProfileFieldInventory: {},
ProfileFieldQuest: {},
ProfileFieldMail: {},
}
var genModuleFields = map[string]ProfileField{
ProfileModuleIdentity: ProfileFieldBase,
ProfileModuleRuntime: ProfileFieldRuntime,
ProfileModuleMeta: ProfileFieldMeta,
ProfileModuleWallet: ProfileFieldCurrency,
ProfileModuleInventory: ProfileFieldInventory,
ProfileModuleQuest: ProfileFieldQuest,
ProfileModuleMailbox: ProfileFieldMail,
}
var genReqFields = []ProfileField{
ProfileFieldBase,
}
var genBackfillFields = []ProfileField{
ProfileFieldCurrency,
ProfileFieldInventory,
ProfileFieldQuest,
ProfileFieldMail,
}
func AllProfileFields() []ProfileField {
return append([]ProfileField(nil), orderedFields...)
}
func reqProfileFields() []ProfileField {
return append([]ProfileField(nil), genReqFields...)
}
func backfillFields() []ProfileField {
return append([]ProfileField(nil), genBackfillFields...)
}
func isProfileField(field ProfileField) bool {
_, ok := genFieldLookup[field]
return ok
}
func fieldByModule(module string) (ProfileField, bool) {
field, ok := genModuleFields[module]
return field, ok
}
func dirtyFieldsByModule(module string) []ProfileField {
field, ok := fieldByModule(module)
if !ok {
return nil
}
return profileDirtyFields(field)
}
func normProfileFields(fields []ProfileField) []ProfileField {
if len(fields) == 0 {
return nil
}
seen := make(map[ProfileField]struct{}, len(fields))
for _, field := range fields {
if isProfileField(field) {
seen[field] = struct{}{}
}
}
out := make([]ProfileField, 0, len(seen))
for _, field := range orderedFields {
if _, ok := seen[field]; ok {
out = append(out, field)
}
}
return out
}
func mergeProfileFields(left, right []ProfileField) []ProfileField {
if len(left) == 0 {
return normProfileFields(right)
}
if len(right) == 0 {
return normProfileFields(left)
}
merged := make([]ProfileField, 0, len(left)+len(right))
merged = append(merged, left...)
merged = append(merged, right...)
return normProfileFields(merged)
}
func profileDirtyFields(field ProfileField) []ProfileField {
switch field {
case ProfileFieldBase:
return normProfileFields([]ProfileField{
ProfileFieldBase,
})
case ProfileFieldRuntime:
return normProfileFields([]ProfileField{
ProfileFieldRuntime,
ProfileFieldMeta,
})
case ProfileFieldMeta:
return normProfileFields([]ProfileField{
ProfileFieldMeta,
})
case ProfileFieldCurrency:
return normProfileFields([]ProfileField{
ProfileFieldRuntime,
ProfileFieldMeta,
ProfileFieldCurrency,
})
case ProfileFieldInventory:
return normProfileFields([]ProfileField{
ProfileFieldRuntime,
ProfileFieldMeta,
ProfileFieldInventory,
})
case ProfileFieldQuest:
return normProfileFields([]ProfileField{
ProfileFieldRuntime,
ProfileFieldMeta,
ProfileFieldQuest,
})
case ProfileFieldMail:
return normProfileFields([]ProfileField{
ProfileFieldRuntime,
ProfileFieldMeta,
ProfileFieldMail,
})
default:
return nil
}
}
const (
ProfileOperationAddCurrency ProfileOperation = "add_currency"
ProfileOperationSetInventoryItem ProfileOperation = "set_inventory_item"
ProfileOperationSetQuestState ProfileOperation = "set_quest_state"
)
var genProfileDescs = []ProfileFieldDescriptor{
{Field: ProfileFieldBase, Module: ProfileModuleIdentity, HashField: redisBaseField, Record: "profileBaseRecord", Required: true, Backfill: false, DirtyFields: []ProfileField{
ProfileFieldBase,
}, Description: "Account, role, name and level identity data.", Columns: []ProfileRecordFieldDescriptor{
{Name: "AccountID", Type: "string", JSONName: "account_id", Profile: "AccountID"},
{Name: "RoleID", Type: "string", JSONName: "role_id", Profile: "RoleID"},
{Name: "Name", Type: "string", JSONName: "name", Profile: "Name"},
{Name: "Level", Type: "int", JSONName: "level", Profile: "Level"},
}},
{Field: ProfileFieldRuntime, Module: ProfileModuleRuntime, HashField: redisRuntimeField, Record: "profileRuntimeRecord", Required: false, Backfill: false, DirtyFields: []ProfileField{
ProfileFieldRuntime,
ProfileFieldMeta,
}, Description: "Online state and profile timestamps.", Columns: []ProfileRecordFieldDescriptor{
{Name: "State", Type: "string", JSONName: "state", Profile: "State"},
{Name: "LoadedAt", Type: "time.Time", JSONName: "loaded_at", Profile: "LoadedAt"},
{Name: "SavedAt", Type: "time.Time", JSONName: "saved_at", Profile: "SavedAt"},
{Name: "UpdatedAt", Type: "time.Time", JSONName: "updated_at", Profile: "UpdatedAt"},
}},
{Field: ProfileFieldMeta, Module: ProfileModuleMeta, HashField: redisMetaField, Record: "profileMetaRecord", Required: false, Backfill: false, DirtyFields: []ProfileField{
ProfileFieldMeta,
}, Description: "Version and optimistic metadata.", Columns: []ProfileRecordFieldDescriptor{
{Name: "Version", Type: "int64", JSONName: "version", Profile: "Version"},
}},
{Field: ProfileFieldCurrency, Module: ProfileModuleWallet, HashField: redisCurrencyField, Record: "currencyRecord", Required: false, Backfill: true, DirtyFields: []ProfileField{
ProfileFieldRuntime,
ProfileFieldMeta,
ProfileFieldCurrency,
}, Description: "Wallet balances such as gold and diamond.", Columns: []ProfileRecordFieldDescriptor{
{Name: "Currencies", Type: "map[string]int64", JSONName: "currencies", Profile: "Currencies"},
}},
{Field: ProfileFieldInventory, Module: ProfileModuleInventory, HashField: redisInventoryField, Record: "inventoryRecord", Required: false, Backfill: true, DirtyFields: []ProfileField{
ProfileFieldRuntime,
ProfileFieldMeta,
ProfileFieldInventory,
}, Description: "Item quantities keyed by item id.", Columns: []ProfileRecordFieldDescriptor{
{Name: "Inventory", Type: "map[string]int64", JSONName: "inventory", Profile: "Inventory"},
}},
{Field: ProfileFieldQuest, Module: ProfileModuleQuest, HashField: redisQuestField, Record: "profileQuestRecord", Required: false, Backfill: true, DirtyFields: []ProfileField{
ProfileFieldRuntime,
ProfileFieldMeta,
ProfileFieldQuest,
}, Description: "Quest state keyed by quest id.", Columns: []ProfileRecordFieldDescriptor{
{Name: "Quests", Type: "map[string]string", JSONName: "quests", Profile: "Quests"},
}},
{Field: ProfileFieldMail, Module: ProfileModuleMailbox, HashField: redisMailField, Record: "profileMailRecord", Required: false, Backfill: true, DirtyFields: []ProfileField{
ProfileFieldRuntime,
ProfileFieldMeta,
ProfileFieldMail,
}, Description: "Mailbox records and read states.", Columns: []ProfileRecordFieldDescriptor{
{Name: "Mailbox", Type: "[]MailRecord", JSONName: "mailbox", Profile: "Mailbox"},
}},
}
func cloneProfileDesc(descriptor ProfileFieldDescriptor) ProfileFieldDescriptor {
descriptor.DirtyFields = append([]ProfileField(nil), descriptor.DirtyFields...)
descriptor.Columns = append([]ProfileRecordFieldDescriptor(nil), descriptor.Columns...)
return descriptor
}
func ProfileFieldDescriptors() []ProfileFieldDescriptor {
out := make([]ProfileFieldDescriptor, len(genProfileDescs))
for i := range out {
out[i] = cloneProfileDesc(genProfileDescs[i])
}
return out
}
func profileDBDesc(field ProfileField) (ProfileFieldDescriptor, bool) {
switch field {
case ProfileFieldBase:
return cloneProfileDesc(genProfileDescs[0]), true
case ProfileFieldRuntime:
return cloneProfileDesc(genProfileDescs[1]), true
case ProfileFieldMeta:
return cloneProfileDesc(genProfileDescs[2]), true
case ProfileFieldCurrency:
return cloneProfileDesc(genProfileDescs[3]), true
case ProfileFieldInventory:
return cloneProfileDesc(genProfileDescs[4]), true
case ProfileFieldQuest:
return cloneProfileDesc(genProfileDescs[5]), true
case ProfileFieldMail:
return cloneProfileDesc(genProfileDescs[6]), true
default:
return ProfileFieldDescriptor{}, false
}
}
func profileDBDescByHash(hashField string) (ProfileFieldDescriptor, bool) {
switch hashField {
case redisBaseField:
return cloneProfileDesc(genProfileDescs[0]), true
case redisRuntimeField:
return cloneProfileDesc(genProfileDescs[1]), true
case redisMetaField:
return cloneProfileDesc(genProfileDescs[2]), true
case redisCurrencyField:
return cloneProfileDesc(genProfileDescs[3]), true
case redisInventoryField:
return cloneProfileDesc(genProfileDescs[4]), true
case redisQuestField:
return cloneProfileDesc(genProfileDescs[5]), true
case redisMailField:
return cloneProfileDesc(genProfileDescs[6]), true
default:
return ProfileFieldDescriptor{}, false
}
}
func profileDBHashField(field ProfileField) (string, bool) {
switch field {
case ProfileFieldBase:
return redisBaseField, true
case ProfileFieldRuntime:
return redisRuntimeField, true
case ProfileFieldMeta:
return redisMetaField, true
case ProfileFieldCurrency:
return redisCurrencyField, true
case ProfileFieldInventory:
return redisInventoryField, true
case ProfileFieldQuest:
return redisQuestField, true
case ProfileFieldMail:
return redisMailField, true
default:
return "", false
}
}
var genProfileOps = []ProfileOperationDescriptor{
{Operation: ProfileOperationAddCurrency, Module: ProfileModuleWallet, DirtyFields: []ProfileField{
ProfileFieldRuntime,
ProfileFieldMeta,
ProfileFieldCurrency,
}, Description: "Adds currency to wallet balances and marks wallet dirty."},
{Operation: ProfileOperationSetInventoryItem, Module: ProfileModuleInventory, DirtyFields: []ProfileField{
ProfileFieldRuntime,
ProfileFieldMeta,
ProfileFieldInventory,
}, Description: "Sets or removes one inventory item quantity."},
{Operation: ProfileOperationSetQuestState, Module: ProfileModuleQuest, DirtyFields: []ProfileField{
ProfileFieldRuntime,
ProfileFieldMeta,
ProfileFieldQuest,
}, Description: "Updates one quest state entry."},
}
func cloneProfileOp(descriptor ProfileOperationDescriptor) ProfileOperationDescriptor {
descriptor.DirtyFields = append([]ProfileField(nil), descriptor.DirtyFields...)
return descriptor
}
func ProfileOperationDescriptors() []ProfileOperationDescriptor {
out := make([]ProfileOperationDescriptor, len(genProfileOps))
for i := range out {
out[i] = cloneProfileOp(genProfileOps[i])
}
return out
}
func opDesc(operation ProfileOperation) (ProfileOperationDescriptor, bool) {
switch operation {
case ProfileOperationAddCurrency:
return cloneProfileOp(genProfileOps[0]), true
case ProfileOperationSetInventoryItem:
return cloneProfileOp(genProfileOps[1]), true
case ProfileOperationSetQuestState:
return cloneProfileOp(genProfileOps[2]), true
default:
return ProfileOperationDescriptor{}, false
}
}
func dirtyFieldsByOp(operation ProfileOperation) []ProfileField {
descriptor, ok := opDesc(operation)
if !ok {
return nil
}
return descriptor.DirtyFields
}
func mutateProfileOp(ctx context.Context, module *Module, accountID string, operation ProfileOperation, mutate func(*Profile)) (Profile, error) {
if module == nil {
return Profile{}, fmt.Errorf("profile module is nil")
}
if mutate == nil {
return Profile{}, fmt.Errorf("profile operation %s mutation is required", operation)
}
dirtyFields := dirtyFieldsByOp(operation)
if len(dirtyFields) == 0 {
return Profile{}, fmt.Errorf("unsupported profile operation %s", operation)
}
return module.mutateLoadedProfile(ctx, accountID, func(profile *Profile) []ProfileField {
mutate(profile)
return dirtyFields
})
}
func mutateAddCurrency(ctx context.Context, module *Module, accountID string, mutate func(*Profile)) (Profile, error) {
return mutateProfileOp(ctx, module, accountID, ProfileOperationAddCurrency, mutate)
}
func mutateSetItem(ctx context.Context, module *Module, accountID string, mutate func(*Profile)) (Profile, error) {
return mutateProfileOp(ctx, module, accountID, ProfileOperationSetInventoryItem, mutate)
}
func mutateSetQuest(ctx context.Context, module *Module, accountID string, mutate func(*Profile)) (Profile, error) {
return mutateProfileOp(ctx, module, accountID, ProfileOperationSetQuestState, mutate)
}
func encodeProfileField(profile Profile, field ProfileField) ([]byte, error) {
switch field {
case ProfileFieldBase:
return json.Marshal(profileBaseRecord{
AccountID: profile.AccountID,
RoleID: profile.RoleID,
Name: profile.Name,
Level: profile.Level,
})
case ProfileFieldRuntime:
return json.Marshal(profileRuntimeRecord{
State: profile.State,
LoadedAt: profile.LoadedAt,
SavedAt: profile.SavedAt,
UpdatedAt: profile.UpdatedAt,
})
case ProfileFieldMeta:
return json.Marshal(profileMetaRecord{
Version: profile.Version,
})
case ProfileFieldCurrency:
return json.Marshal(currencyRecord{
Currencies: profile.Currencies,
})
case ProfileFieldInventory:
return json.Marshal(inventoryRecord{
Inventory: profile.Inventory,
})
case ProfileFieldQuest:
return json.Marshal(profileQuestRecord{
Quests: profile.Quests,
})
case ProfileFieldMail:
return json.Marshal(profileMailRecord{
Mailbox: profile.Mailbox,
})
default:
return nil, fmt.Errorf("unsupported profile field %q", field)
}
}
func encodeProfileFields(profile Profile, fields []ProfileField) (map[string][]byte, error) {
fields = normProfileFields(fields)
if len(fields) == 0 {
return nil, nil
}
out := make(map[string][]byte, len(fields))
for _, field := range fields {
hashField, ok := profileDBHashField(field)
if !ok {
return nil, fmt.Errorf("unsupported profile field %q", field)
}
data, err := encodeProfileField(profile, field)
if err != nil {
return nil, err
}
out[hashField] = data
}
return out, nil
}
func decodeProfileField(profile *Profile, field ProfileField, data []byte) error {
if profile == nil {
return fmt.Errorf("profile is nil")
}
switch field {
case ProfileFieldBase:
if len(data) == 0 {
return fmt.Errorf("profile field %s is empty", field)
}
var record profileBaseRecord
if err := json.Unmarshal(data, &record); err != nil {
return fmt.Errorf("decode profile field %s: %w", field, err)
}
profile.AccountID = record.AccountID
profile.RoleID = record.RoleID
profile.Name = record.Name
profile.Level = record.Level
return nil
case ProfileFieldRuntime:
if len(data) == 0 {
return nil
}
var record profileRuntimeRecord
if err := json.Unmarshal(data, &record); err != nil {
return fmt.Errorf("decode profile field %s: %w", field, err)
}
profile.State = record.State
profile.LoadedAt = record.LoadedAt
profile.SavedAt = record.SavedAt
profile.UpdatedAt = record.UpdatedAt
return nil
case ProfileFieldMeta:
if len(data) == 0 {
return nil
}
var record profileMetaRecord
if err := json.Unmarshal(data, &record); err != nil {
return fmt.Errorf("decode profile field %s: %w", field, err)
}
profile.Version = record.Version
return nil
case ProfileFieldCurrency:
if len(data) == 0 {
return nil
}
var record currencyRecord
if err := json.Unmarshal(data, &record); err != nil {
return fmt.Errorf("decode profile field %s: %w", field, err)
}
profile.Currencies = record.Currencies
return nil
case ProfileFieldInventory:
if len(data) == 0 {
return nil
}
var record inventoryRecord
if err := json.Unmarshal(data, &record); err != nil {
return fmt.Errorf("decode profile field %s: %w", field, err)
}
profile.Inventory = record.Inventory
return nil
case ProfileFieldQuest:
if len(data) == 0 {
return nil
}
var record profileQuestRecord
if err := json.Unmarshal(data, &record); err != nil {
return fmt.Errorf("decode profile field %s: %w", field, err)
}
profile.Quests = record.Quests
return nil
case ProfileFieldMail:
if len(data) == 0 {
return nil
}
var record profileMailRecord
if err := json.Unmarshal(data, &record); err != nil {
return fmt.Errorf("decode profile field %s: %w", field, err)
}
profile.Mailbox = record.Mailbox
return nil
default:
return fmt.Errorf("unsupported profile field %q", field)
}
}
func backfillProfileField(profile *Profile, field ProfileField, now time.Time) bool {
if profile == nil {
return false
}
switch field {
case ProfileFieldCurrency:
return backfillCurrency(profile, now)
case ProfileFieldInventory:
return backfillInventory(profile, now)
case ProfileFieldQuest:
return backfillProfileQuest(profile, now)
case ProfileFieldMail:
return backfillProfileMail(profile, now)
default:
return false
}
}
func backfillModules(profile *Profile, now time.Time) []ProfileField {
if profile == nil {
return nil
}
dirty := make([]ProfileField, 0, len(genBackfillFields))
for _, field := range genBackfillFields {
if backfillProfileField(profile, field, now) {
dirty = append(dirty, field)
}
}
return normProfileFields(dirty)
}
func redisModuleFields() []string {
return []string{
redisBaseField,
redisRuntimeField,
redisMetaField,
redisCurrencyField,
redisInventoryField,
redisQuestField,
redisMailField,
}
}
func profileToModules(profile Profile) (map[string][]byte, error) {
out := make(map[string][]byte, len(genProfileDescs))
for _, descriptor := range genProfileDescs {
data, err := encodeProfileField(profile, descriptor.Field)
if err != nil {
return nil, err
}
out[descriptor.HashField] = data
}
return out, nil
}
func profileFromRedis(accountID string, modules map[string][]byte) (Profile, error) {
var profile Profile
for _, descriptor := range genProfileDescs {
if err := decodeProfileField(&profile, descriptor.Field, modules[descriptor.HashField]); err != nil {
return Profile{}, fmt.Errorf("decode redis profile %s: %w", accountID, err)
}
}
if profile.AccountID == "" {
profile.AccountID = accountID
}
return profile, nil
}
@@ -0,0 +1,358 @@
package player
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/gomodule/redigo/redis"
"longheng.io/server/internal/platform/db"
)
const (
redisProfileLegacy = "profile"
)
// closeRedigoErr 在 Redis 操作成功时保留连接关闭错误,避免连接池异常被静默吞掉。
func closeRedigoErr(conn redis.Conn, err *error) {
if conn == nil || err == nil {
return
}
if closeErr := conn.Close(); closeErr != nil && *err == nil {
*err = closeErr
}
}
// RedisStoreOptions 是 Redis 玩家 Profile 存储的连接、命名空间和 TTL 配置。
type RedisStoreOptions struct {
Address string
Password string
DB int
KeyPrefix string
PoolSize int
Timeout time.Duration
ConnectTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
TTL time.Duration
}
// RedisStore 基于 Redis Hash 保存玩家 Profile 的字段化数据。
type RedisStore struct {
keyPrefix string
ttl time.Duration
executor redisExecutor
}
type redisExecutor interface {
db.RedisExecutor
Close() error
}
// NewRedisStore 创建 Redis 玩家 Profile 存储。
func NewRedisStore(options RedisStoreOptions) *RedisStore {
options = normRedisStoreOpts(options)
pool := &redis.Pool{
MaxIdle: options.PoolSize,
MaxActive: options.PoolSize * 2,
IdleTimeout: time.Minute,
Wait: true,
Dial: func() (redis.Conn, error) {
dialOptions := []redis.DialOption{
redis.DialConnectTimeout(options.ConnectTimeout),
redis.DialReadTimeout(options.ReadTimeout),
redis.DialWriteTimeout(options.WriteTimeout),
redis.DialDatabase(options.DB),
}
if options.Password != "" {
dialOptions = append(dialOptions, redis.DialPassword(options.Password))
}
return redis.Dial("tcp", options.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 newRedisStoreExec(&redigoExecutor{pool: pool}, options)
}
func newRedisStoreExec(executor redisExecutor, options RedisStoreOptions) *RedisStore {
options = normRedisStoreOpts(options)
return &RedisStore{
keyPrefix: options.KeyPrefix,
ttl: options.TTL,
executor: executor,
}
}
func normRedisStoreOpts(options RedisStoreOptions) RedisStoreOptions {
options.Address = strings.TrimSpace(options.Address)
if options.Address == "" {
options.Address = "127.0.0.1:6379"
}
options.KeyPrefix = strings.TrimSpace(options.KeyPrefix)
if options.KeyPrefix == "" {
options.KeyPrefix = "profile"
}
if options.PoolSize <= 0 {
options.PoolSize = 8
}
if options.Timeout <= 0 {
options.Timeout = 2 * time.Second
}
if options.ConnectTimeout <= 0 {
options.ConnectTimeout = options.Timeout
}
if options.ReadTimeout <= 0 {
options.ReadTimeout = options.Timeout
}
if options.WriteTimeout <= 0 {
options.WriteTimeout = options.Timeout
}
return options
}
// Load 按账号 ID 从 Redis 读取玩家 Profile。
func (s *RedisStore) Load(ctx context.Context, accountID string) (Profile, bool, error) {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return Profile{}, false, err
}
accountID = strings.TrimSpace(accountID)
key, err := s.profileKey(accountID)
if err != nil {
return Profile{}, false, err
}
baseField, ok := profileDBHashField(ProfileFieldBase)
if !ok {
return Profile{}, false, fmt.Errorf("profile base field is not configured")
}
fields := redisModuleFields()
modules, exists, err := s.hmgetBytes(ctx, key, fields...)
if err != nil {
return Profile{}, false, err
}
if !exists[baseField] {
return s.loadLegacyProfile(ctx, key, accountID)
}
profile, err := profileFromRedis(accountID, modules)
if err != nil {
return Profile{}, false, err
}
profile = normAccountID(profile, accountID)
return cloneProfile(profile), true, nil
}
// Check 通过 PING 检查 Redis 玩家 Profile 存储连接。
func (s *RedisStore) Check(ctx context.Context) error {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return err
}
reply, err := s.executor.Do(ctx, "PING")
pong, err := redis.String(reply, err)
if err != nil {
return err
}
if strings.ToUpper(pong) != "PONG" {
return fmt.Errorf("unexpected redis ping response %q", pong)
}
return nil
}
func (s *RedisStore) hgetBytes(ctx context.Context, key, field string) ([]byte, bool, error) {
reply, err := s.executor.Do(ctx, "HGET", key, field)
data, err := redis.Bytes(reply, err)
if errors.Is(err, redis.ErrNil) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return data, true, nil
}
func (s *RedisStore) hmgetBytes(ctx context.Context, key string, fields ...string) (map[string][]byte, map[string]bool, error) {
args := make([]any, 0, len(fields)+1)
args = append(args, key)
for _, field := range fields {
args = append(args, field)
}
reply, err := s.executor.Do(ctx, "HMGET", args...)
values, err := redis.Values(reply, err)
if err != nil {
return nil, nil, err
}
if len(values) != len(fields) {
return nil, nil, fmt.Errorf("redis hmget %s returned %d values for %d fields", key, len(values), len(fields))
}
out := make(map[string][]byte, len(fields))
exists := make(map[string]bool, len(fields))
for idx, field := range fields {
data, err := redis.Bytes(values[idx], nil)
if errors.Is(err, redis.ErrNil) {
out[field] = nil
continue
}
if err != nil {
return nil, nil, fmt.Errorf("redis hmget %s field %s: %w", key, field, err)
}
out[field] = data
exists[field] = true
}
return out, exists, nil
}
func (s *RedisStore) loadLegacyProfile(ctx context.Context, key, accountID string) (Profile, bool, error) {
data, ok, err := s.hgetBytes(ctx, key, redisProfileLegacy)
if err != nil || !ok {
return Profile{}, false, err
}
var profile Profile
if err := json.Unmarshal(data, &profile); err != nil {
return Profile{}, false, fmt.Errorf("decode redis legacy profile %s: %w", accountID, err)
}
if profile.AccountID == "" {
profile.AccountID = accountID
}
profile = normAccountID(profile, accountID)
return cloneProfile(profile), true, nil
}
// Save 保存完整玩家 Profile 到 Redis。
func (s *RedisStore) Save(ctx context.Context, profile Profile) error {
return s.SaveFields(ctx, profile, AllProfileFields()...)
}
// SaveFields 只保存玩家 Profile 的指定 Redis Hash 字段。
func (s *RedisStore) SaveFields(ctx context.Context, profile Profile, fields ...ProfileField) error {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return err
}
profile = normProfileID(profile)
key, err := s.profileKey(profile.AccountID)
if err != nil {
return err
}
fields = normProfileFields(fields)
if len(fields) == 0 {
return nil
}
values, err := encodeProfileFields(profile, fields)
if err != nil {
return err
}
return db.SaveRedisHashFields(ctx, s.executor, key, values, s.ttl)
}
// SaveFieldBatch 批量保存多个玩家 Profile 的字段变更。
func (s *RedisStore) SaveFieldBatch(ctx context.Context, saves []db.FieldSave[Profile, ProfileField]) error {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return err
}
batches := make([]db.RedisHashBatch, 0, len(saves))
for _, save := range saves {
save.Record = normProfileID(save.Record)
key, err := s.profileKey(save.Record.AccountID)
if err != nil {
return err
}
fields := normProfileFields(save.Fields)
if len(fields) == 0 {
continue
}
values, err := encodeProfileFields(save.Record, fields)
if err != nil {
return err
}
batches = append(batches, db.RedisHashBatch{
Key: key,
Fields: values,
TTL: s.ttl,
})
}
return db.SaveRedisHashFieldBatches(ctx, s.executor, batches)
}
// Expire 为指定玩家 Profile 设置 Redis 过期时间。
func (s *RedisStore) Expire(ctx context.Context, accountID string, ttl time.Duration) error {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return err
}
if ttl <= 0 {
return nil
}
key, err := s.profileKey(accountID)
if err != nil {
return err
}
_, err = s.executor.Do(ctx, "EXPIRE", key, int(ttl.Seconds()))
return err
}
// Close 关闭 Redis 玩家 Profile 存储连接池。
func (s *RedisStore) Close(context.Context) error {
if s == nil || s.executor == nil {
return nil
}
return s.executor.Close()
}
func (s *RedisStore) profileKey(accountID string) (string, error) {
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return "", fmt.Errorf("account id is required")
}
return s.keyPrefix + ":" + accountID, nil
}
type redigoExecutor struct {
pool *redis.Pool
}
// Do 在 redigo 连接池上执行单条 Redis 命令。
func (e *redigoExecutor) Do(ctx context.Context, command string, args ...any) (reply any, err error) {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return nil, err
}
conn, err := e.pool.GetContext(ctx)
if err != nil {
return nil, err
}
defer closeRedigoErr(conn, &err)
return conn.Do(command, args...)
}
// DoBatch 在同一条 redigo 连接上批量发送并接收 Redis 命令。
func (e *redigoExecutor) DoBatch(ctx context.Context, commands []db.RedisCommand) (err error) {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return err
}
if len(commands) == 0 {
return nil
}
conn, err := e.pool.GetContext(ctx)
if err != nil {
return err
}
defer closeRedigoErr(conn, &err)
return db.RunRedigoBatch(ctx, conn, commands)
}
// Close 关闭 redigo 连接池。
func (e *redigoExecutor) Close() error {
return e.pool.Close()
}
@@ -0,0 +1,40 @@
package player
import (
"context"
"fmt"
)
// RoleAwareAsyncStore 在异步写入外保留角色 ID 查询和扫描能力。
type RoleAwareAsyncStore struct {
*AsyncStore
roleStore RoleProfileStore
}
// NewRoleAwareAsyncStore 创建带角色查询能力的异步玩家存储包装器。
func NewRoleAwareAsyncStore(async *AsyncStore, roleStore RoleProfileStore) *RoleAwareAsyncStore {
return &RoleAwareAsyncStore{
AsyncStore: async,
roleStore: roleStore,
}
}
// LoadByRoleID 通过底层角色索引存储读取玩家 Profile。
func (s *RoleAwareAsyncStore) LoadByRoleID(ctx context.Context, roleID string) (Profile, bool, error) {
if s == nil || s.roleStore == nil {
return Profile{}, false, nil
}
return s.roleStore.LoadByRoleID(ctx, roleID)
}
// ListProfiles 通过底层扫描存储按账号游标列出玩家 Profile。
func (s *RoleAwareAsyncStore) ListProfiles(ctx context.Context, afterAccountID string, limit int) ([]Profile, error) {
if s == nil || s.roleStore == nil {
return nil, fmt.Errorf("profile store is nil")
}
scanner, ok := s.roleStore.(ProfileScanStore)
if !ok {
return nil, fmt.Errorf("profile store %T does not support profile scans", s.roleStore)
}
return scanner.ListProfiles(ctx, afterAccountID, limit)
}
@@ -0,0 +1,119 @@
package player
import (
"context"
"fmt"
"strings"
"time"
)
// Record 是兼容旧玩家模块调用方的 Profile 别名。
type Record = Profile
func (m *Module) mutateLoadedProfile(ctx context.Context, accountID string, mutate func(*Profile) []ProfileField) (Profile, error) {
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return Profile{}, fmt.Errorf("account id is required")
}
if mutate == nil {
return Profile{}, fmt.Errorf("profile mutation is required")
}
if _, err := m.LoadOrCreate(ctx, accountID); err != nil {
return Profile{}, err
}
before, profile, err := m.profiles.MutateLoaded(ctx, accountID, func(profile *Profile, _ time.Time) ([]ProfileField, error) {
return mutate(profile), nil
})
if err != nil {
return Profile{}, err
}
m.observeSummaryChange(ctx, before, profile)
return cloneProfile(profile), nil
}
func (m *Module) mutateCritProfile(ctx context.Context, accountID string, operation ProfileOperation, reason string, mutate func(*Profile, time.Time) (map[string]any, error)) (Profile, error) {
if m == nil {
return Profile{}, fmt.Errorf("profile module is nil")
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return Profile{}, fmt.Errorf("account id is required")
}
if mutate == nil {
return Profile{}, fmt.Errorf("profile mutation is required")
}
dirtyFields := dirtyFieldsByOp(operation)
if len(dirtyFields) == 0 {
return Profile{}, fmt.Errorf("unsupported profile operation %s", operation)
}
before, profile, err := m.profiles.MutateLoaded(ctx, accountID, func(profile *Profile, now time.Time) ([]ProfileField, error) {
mutation, err := mutate(profile, now)
if err != nil {
return nil, err
}
eventProfile := cloneProfile(*profile)
eventProfile.UpdatedAt = now
eventProfile.Version++
if err := m.emitProfileMutation(ctx, eventProfile, string(operation), reason, dirtyFields, mutation); err != nil {
return nil, err
}
return dirtyFields, nil
})
if err != nil {
return Profile{}, err
}
m.observeSummaryChange(ctx, before, profile)
return cloneProfile(profile), nil
}
func newProfile(accountID string, now time.Time) Profile {
profile := Profile{
AccountID: accountID,
RoleID: "role-" + accountID,
State: "online",
LoadedAt: now,
UpdatedAt: now,
Version: 1,
}
applyProfileDefaults(&profile, now)
return profile
}
func prepareLoadedProfile(profile Profile, accountID string, now time.Time, existed bool) (Profile, []ProfileField, error) {
dirtyFields := newProfileFieldSet(dirtyFieldsByModule(ProfileModuleRuntime)...)
if existed {
profile = normAccountID(profile, accountID)
dirtyFields.Merge(ensureProfileModules(&profile, now))
}
profile.State = "online"
profile.LoadedAt = now
profile.UpdatedAt = now
profile.Version++
return profile, dirtyFields.List(), nil
}
func prepareSavedProfile(profile Profile, now time.Time) (Profile, []ProfileField, error) {
profile.State = "offline"
profile.SavedAt = now
profile.UpdatedAt = now
profile.Version++
return profile, dirtyFieldsByModule(ProfileModuleRuntime), nil
}
func touchProfileRecord(profile Profile, now time.Time) (Profile, []ProfileField, error) {
profile.UpdatedAt = now
profile.Version++
return profile, nil, nil
}
func sameProfileVersion(current, snapshot Profile) bool {
return current.Version == snapshot.Version && current.UpdatedAt.Equal(snapshot.UpdatedAt)
}
func ensureProfileModules(profile *Profile, now time.Time) profileFieldSet {
return newProfileFieldSet(backfillModules(profile, now)...)
}
func cloneProfile(profile Profile) Profile {
return cloneProfileModules(profile)
}
@@ -0,0 +1,387 @@
package player
import (
"context"
"errors"
"fmt"
"hash/crc32"
"math"
"sort"
"strconv"
"strings"
"time"
)
// ErrMySQLShardConfig 表示 MySQL 玩家 Profile 分片配置不合法。
var ErrMySQLShardConfig = errors.New("mysql profile shard config is invalid")
const mysqlShardSlots = 1024
// MySQLShardOptions 描述单个 MySQL 玩家 Profile 分片的连接和槽位配置。
type MySQLShardOptions struct {
ID string
DSN string
TableName string
TablePrefix string
HashSlots string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
EnsureSchema bool
}
// ShardedMySQLStoreOptions 是分片 MySQL 玩家 Profile 存储的全局默认值和分片清单。
type ShardedMySQLStoreOptions struct {
DefaultDSN string
DefaultMaxOpenConns int
DefaultMaxIdleConns int
DefaultConnMaxLifetime time.Duration
EnsureSchema bool
Shards []MySQLShardOptions
}
// ShardedMySQLStore 按账号 ID 路由到多个 MySQL 玩家 Profile 存储。
type ShardedMySQLStore struct {
shards map[string]*MySQLStore
order []string
slotOwners []string
}
// NewShardedMySQLStore 创建分片 MySQL 玩家 Profile 存储。
func NewShardedMySQLStore(options ShardedMySQLStoreOptions) (*ShardedMySQLStore, error) {
if len(options.Shards) == 0 {
return nil, fmt.Errorf("%w: at least one mysql shard is required", ErrMySQLShardConfig)
}
out := &ShardedMySQLStore{
shards: make(map[string]*MySQLStore, len(options.Shards)),
}
normalizedShards := make([]MySQLShardOptions, 0, len(options.Shards))
for idx, shard := range options.Shards {
normalized, err := normMySQLShardOpts(options, shard, idx)
if err != nil {
return nil, err
}
if _, exists := out.shards[normalized.ID]; exists {
return nil, fmt.Errorf("%w: duplicated shard id %q", ErrMySQLShardConfig, normalized.ID)
}
normalizedShards = append(normalizedShards, normalized)
out.shards[normalized.ID] = NewMySQLStore(MySQLStoreOptions{
DSN: normalized.DSN,
TableName: normalized.TableName,
MaxOpenConns: normalized.MaxOpenConns,
MaxIdleConns: normalized.MaxIdleConns,
ConnMaxLifetime: normalized.ConnMaxLifetime,
EnsureSchema: normalized.EnsureSchema,
})
out.order = append(out.order, normalized.ID)
}
sort.Strings(out.order)
slotOwners, err := buildShardOwners(normalizedShards)
if err != nil {
_ = out.Close(context.Background())
return nil, err
}
out.slotOwners = slotOwners
return out, nil
}
func normMySQLShardOpts(defaults ShardedMySQLStoreOptions, shard MySQLShardOptions, idx int) (MySQLShardOptions, error) {
shard.ID = strings.TrimSpace(shard.ID)
if shard.ID == "" {
shard.ID = fmt.Sprintf("shard_%d", idx)
}
if !isSafeProfileShardID(shard.ID) {
return MySQLShardOptions{}, fmt.Errorf("%w: shard id %q can only contain letters, digits, dot, dash, or underscore", ErrMySQLShardConfig, shard.ID)
}
shard.DSN = strings.TrimSpace(shard.DSN)
if shard.DSN == "" {
shard.DSN = strings.TrimSpace(defaults.DefaultDSN)
}
if shard.DSN == "" {
return MySQLShardOptions{}, fmt.Errorf("%w: shard %q dsn is required", ErrMySQLShardConfig, shard.ID)
}
shard.TableName = strings.TrimSpace(shard.TableName)
shard.TablePrefix = strings.TrimSpace(shard.TablePrefix)
shard.HashSlots = strings.TrimSpace(shard.HashSlots)
if shard.TableName == "" && shard.TablePrefix != "" {
shard.TableName = shard.TablePrefix + "_" + mysqlProfileTable
}
if shard.TableName == "" {
shard.TableName = mysqlProfileTable
}
if !isMySQLProfileID(shard.TableName) {
return MySQLShardOptions{}, fmt.Errorf("%w: shard %q table name %q must be a sql identifier", ErrMySQLShardConfig, shard.ID, shard.TableName)
}
if shard.MaxOpenConns <= 0 {
shard.MaxOpenConns = defaults.DefaultMaxOpenConns
}
if shard.MaxIdleConns <= 0 {
shard.MaxIdleConns = defaults.DefaultMaxIdleConns
}
if shard.ConnMaxLifetime <= 0 {
shard.ConnMaxLifetime = defaults.DefaultConnMaxLifetime
}
shard.EnsureSchema = shard.EnsureSchema || defaults.EnsureSchema
return shard, nil
}
func buildShardOwners(shards []MySQLShardOptions) ([]string, error) {
useSlots := false
for _, shard := range shards {
if strings.TrimSpace(shard.HashSlots) != "" {
useSlots = true
break
}
}
if !useSlots {
return nil, nil
}
owners := make([]string, mysqlShardSlots)
for _, shard := range shards {
if strings.TrimSpace(shard.HashSlots) == "" {
return nil, fmt.Errorf("%w: shard %q hash_slots is required when any shard uses slot routing", ErrMySQLShardConfig, shard.ID)
}
ranges, err := parseShardSlots(shard.HashSlots)
if err != nil {
return nil, fmt.Errorf("%w: shard %q %w", ErrMySQLShardConfig, shard.ID, err)
}
for _, item := range ranges {
for slot := item.start; slot <= item.end; slot++ {
if owners[slot] != "" {
return nil, fmt.Errorf("%w: hash slot %d is assigned to both %q and %q", ErrMySQLShardConfig, slot, owners[slot], shard.ID)
}
owners[slot] = shard.ID
}
}
}
for slot, owner := range owners {
if owner == "" {
return nil, fmt.Errorf("%w: hash slot %d is not assigned to any shard", ErrMySQLShardConfig, slot)
}
}
return owners, nil
}
type mysqlShardSlotRange struct {
start int
end int
}
func parseShardSlots(value string) ([]mysqlShardSlotRange, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, fmt.Errorf("hash_slots is required")
}
parts := strings.Split(value, ",")
out := make([]mysqlShardSlotRange, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
return nil, fmt.Errorf("hash_slots contains empty range")
}
bounds := strings.Split(part, "-")
if len(bounds) > 2 {
return nil, fmt.Errorf("hash slot range %q is invalid", part)
}
start, err := strconv.Atoi(strings.TrimSpace(bounds[0]))
if err != nil {
return nil, fmt.Errorf("hash slot range %q has invalid start", part)
}
end := start
if len(bounds) == 2 {
end, err = strconv.Atoi(strings.TrimSpace(bounds[1]))
if err != nil {
return nil, fmt.Errorf("hash slot range %q has invalid end", part)
}
}
if start < 0 || end < start || end >= mysqlShardSlots {
return nil, fmt.Errorf("hash slot range %q must be within 0-%d", part, mysqlShardSlots-1)
}
out = append(out, mysqlShardSlotRange{start: start, end: end})
}
return out, nil
}
func isSafeProfileShardID(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
for _, r := range value {
if r == '.' || r == '-' || r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
return false
}
return !strings.HasPrefix(value, ".") && !strings.HasSuffix(value, ".") && !strings.Contains(value, "..")
}
func isMySQLProfileID(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
for idx, r := range value {
if idx == 0 {
if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
continue
}
return false
}
if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
return false
}
return true
}
// Load 按账号 ID 路由到对应 MySQL 分片读取玩家 Profile。
func (s *ShardedMySQLStore) Load(ctx context.Context, accountID string) (Profile, bool, error) {
store, _, err := s.storeForAccount(accountID)
if err != nil {
return Profile{}, false, err
}
return store.Load(ctx, accountID)
}
// Save 按账号 ID 路由到对应 MySQL 分片保存完整玩家 Profile。
func (s *ShardedMySQLStore) Save(ctx context.Context, profile Profile) error {
return s.SaveFields(ctx, profile, AllProfileFields()...)
}
// SaveFields 按账号 ID 路由到对应 MySQL 分片保存指定玩家字段。
func (s *ShardedMySQLStore) SaveFields(ctx context.Context, profile Profile, fields ...ProfileField) error {
profile, err := normMySQLIdentity(profile)
if err != nil {
return err
}
store, _, err := s.storeForAccount(profile.AccountID)
if err != nil {
return err
}
return store.SaveFields(ctx, profile, fields...)
}
// LoadByRoleID 遍历 MySQL 分片按角色 ID 查找玩家 Profile。
func (s *ShardedMySQLStore) LoadByRoleID(ctx context.Context, roleID string) (Profile, bool, error) {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return Profile{}, false, err
}
roleID = strings.TrimSpace(roleID)
if roleID == "" {
return Profile{}, false, fmt.Errorf("role id is required")
}
for _, shardID := range s.order {
profile, ok, err := s.shards[shardID].LoadByRoleID(ctx, roleID)
if err != nil {
return Profile{}, false, err
}
if ok {
return profile, true, nil
}
}
return Profile{}, false, nil
}
// ListProfiles 从所有 MySQL 分片按账号游标合并扫描玩家 Profile。
func (s *ShardedMySQLStore) ListProfiles(ctx context.Context, afterAccountID string, limit int) ([]Profile, error) {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return nil, err
}
if limit <= 0 {
return nil, nil
}
afterAccountID = strings.TrimSpace(afterAccountID)
out := make([]Profile, 0, limit)
for _, shardID := range s.order {
items, err := s.shards[shardID].ListProfiles(ctx, afterAccountID, limit)
if err != nil {
return nil, err
}
out = append(out, items...)
}
sort.Slice(out, func(i, j int) bool {
return out[i].AccountID < out[j].AccountID
})
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// Check 检查全部 MySQL 玩家 Profile 分片。
func (s *ShardedMySQLStore) Check(ctx context.Context) error {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return err
}
for _, shardID := range s.order {
if err := s.shards[shardID].Check(ctx); err != nil {
return fmt.Errorf("check mysql profile shard %s: %w", shardID, err)
}
}
return nil
}
// Close 关闭全部 MySQL 玩家 Profile 分片连接。
func (s *ShardedMySQLStore) Close(ctx context.Context) error {
if s == nil {
return nil
}
var errs []error
for _, shardID := range s.order {
if err := s.shards[shardID].Close(ctx); err != nil {
errs = append(errs, fmt.Errorf("close mysql profile shard %s: %w", shardID, err))
}
}
return errors.Join(errs...)
}
// ResolveShardID 按账号 ID 解析目标 MySQL 分片 ID。
func (s *ShardedMySQLStore) ResolveShardID(accountID string) (string, error) {
if s == nil || len(s.order) == 0 {
return "", fmt.Errorf("%w: no mysql profile shard is configured", ErrMySQLShardConfig)
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return "", fmt.Errorf("account id is required")
}
if len(s.order) == 1 {
return s.order[0], nil
}
checksum := crc32.ChecksumIEEE([]byte(accountID))
if len(s.slotOwners) == mysqlShardSlots {
slot := int(checksum % mysqlShardSlots)
return s.slotOwners[slot], nil
}
if len(s.order) > math.MaxUint32 {
return "", fmt.Errorf("%w: mysql profile shard count exceeds uint32 range", ErrMySQLShardConfig)
}
shardIndex := int(checksum % uint32(len(s.order))) //nolint:gosec // G115:len 已先校验不超过 uint32,取模结果可安全转回 slice 下标。
return s.order[shardIndex], nil
}
// TableNames 返回每个 MySQL 分片对应的 Profile 表名。
func (s *ShardedMySQLStore) TableNames() map[string]string {
out := make(map[string]string, len(s.shards))
for _, shardID := range s.order {
out[shardID] = s.shards[shardID].TableName()
}
return out
}
func (s *ShardedMySQLStore) storeForAccount(accountID string) (*MySQLStore, string, error) {
shardID, err := s.ResolveShardID(accountID)
if err != nil {
return nil, "", err
}
store := s.shards[shardID]
if store == nil {
return nil, "", fmt.Errorf("%w: shard %q store is missing", ErrMySQLShardConfig, shardID)
}
return store, shardID, nil
}
@@ -0,0 +1,65 @@
package player
import (
"context"
"longheng.io/server/internal/platform/readmodel"
)
// 玩家摘要 PlayerSummary、查询 PlayerSummaryQuery、投影、过滤器、Redis 命名和读模型选项
// 由 configs/readmodel/player_summary.json 生成。
//go:generate go run ../../../cmd/viewgen -schema ../../../configs/readmodel/player_summary.json -out summary_model_gen.go
// SummaryStore 定义玩家摘要读模型的读写和查询接口。
type SummaryStore interface {
SavePlayerSummary(context.Context, PlayerSummary) error
GetPlayerSummary(context.Context, string) (PlayerSummary, bool, error)
GetPlayerSummaryByRoleID(context.Context, string) (PlayerSummary, bool, error)
ListPlayerSummariesByAccountIDs(context.Context, []string) ([]PlayerSummary, error)
ListByRoleIDs(context.Context, []string) ([]PlayerSummary, error)
SearchPlayerSummaries(context.Context, PlayerSummaryQuery) ([]PlayerSummary, error)
}
// MemorySummaryStore 是玩家摘要读模型的内存实现。
type MemorySummaryStore struct {
inner *readmodel.MemoryStore[PlayerSummary, PlayerSummaryQuery]
}
// NewMemorySummaryStore 创建内存玩家摘要读模型存储。
func NewMemorySummaryStore() *MemorySummaryStore {
inner, err := readmodel.NewMemoryStore[PlayerSummary, PlayerSummaryQuery](summaryReadOpts())
if err != nil {
panic(err)
}
return &MemorySummaryStore{inner: inner}
}
// SavePlayerSummary 保存玩家摘要到内存读模型。
func (s *MemorySummaryStore) SavePlayerSummary(ctx context.Context, summary PlayerSummary) error {
return s.inner.Save(ctx, summary)
}
// GetPlayerSummary 按账号 ID 从内存读模型读取玩家摘要。
func (s *MemorySummaryStore) GetPlayerSummary(ctx context.Context, accountID string) (PlayerSummary, bool, error) {
return s.inner.Get(ctx, accountID)
}
// GetPlayerSummaryByRoleID 按角色 ID 从内存读模型读取玩家摘要。
func (s *MemorySummaryStore) GetPlayerSummaryByRoleID(ctx context.Context, roleID string) (PlayerSummary, bool, error) {
return s.inner.GetBySecondaryID(ctx, roleID)
}
// ListPlayerSummariesByAccountIDs 按账号 ID 批量读取内存玩家摘要。
func (s *MemorySummaryStore) ListPlayerSummariesByAccountIDs(ctx context.Context, accountIDs []string) ([]PlayerSummary, error) {
return s.inner.ListByPrimaryIDs(ctx, accountIDs)
}
// ListByRoleIDs 按角色 ID 批量读取内存玩家摘要。
func (s *MemorySummaryStore) ListByRoleIDs(ctx context.Context, roleIDs []string) ([]PlayerSummary, error) {
return s.inner.ListBySecondaryIDs(ctx, roleIDs)
}
// SearchPlayerSummaries 按查询条件搜索内存玩家摘要。
func (s *MemorySummaryStore) SearchPlayerSummaries(ctx context.Context, query PlayerSummaryQuery) ([]PlayerSummary, error) {
return s.inner.Search(ctx, query)
}
@@ -0,0 +1,131 @@
package player
// Code generated by viewgen; DO NOT EDIT.
import (
"longheng.io/server/internal/platform/readmodel"
"sort"
"strings"
"time"
)
type PlayerSummary struct {
AccountID string `json:"account_id"`
RoleID string `json:"role_id"`
Name string `json:"name"`
Level int `json:"level"`
State string `json:"state"`
Online bool `json:"online"`
Version int64 `json:"version"`
UpdatedAt time.Time `json:"updated_at"`
SavedAt time.Time `json:"saved_at,omitempty"`
}
type PlayerSummaryQuery struct {
AccountIDs []string `json:"account_ids,omitempty"`
RoleIDs []string `json:"role_ids,omitempty"`
NameContains string `json:"name_contains,omitempty"`
State string `json:"state,omitempty"`
OnlineOnly bool `json:"online_only,omitempty"`
Limit int `json:"limit,omitempty"`
}
const redisPlayerSumField = "summary"
const redisPlayerSumSet = "accounts"
const redisPlayerSumIndex = "role"
const redisPlayerSumNS = "player_summary"
func NewPlayerSummary(profile Profile) PlayerSummary {
state := strings.TrimSpace(profile.State)
return PlayerSummary{
AccountID: profile.AccountID,
RoleID: profile.RoleID,
Name: profile.Name,
Level: profile.Level,
State: state,
Online: state == "online" || state == "active",
Version: profile.Version,
UpdatedAt: profile.UpdatedAt,
SavedAt: profile.SavedAt,
}
}
func summaryMatches(summary PlayerSummary, query PlayerSummaryQuery) bool {
query.NameContains = strings.ToLower(strings.TrimSpace(query.NameContains))
if query.NameContains != "" && !strings.Contains(strings.ToLower(summary.Name), query.NameContains) {
return false
}
query.State = strings.TrimSpace(query.State)
if query.State != "" && summary.State != query.State {
return false
}
if query.OnlineOnly && !summary.Online {
return false
}
return true
}
func sortPlayerSummaries(summaries []PlayerSummary) {
sort.Slice(summaries, func(i, j int) bool {
if summaries[i].AccountID == summaries[j].AccountID {
return summaries[i].RoleID < summaries[j].RoleID
}
return summaries[i].AccountID < summaries[j].AccountID
})
}
func filterSummaries(summaries []PlayerSummary, query PlayerSummaryQuery) []PlayerSummary {
out := make([]PlayerSummary, 0, len(summaries))
for _, summary := range summaries {
if summaryMatches(summary, query) {
out = append(out, clonePlayerSummary(summary))
}
}
sortPlayerSummaries(out)
if query.Limit > 0 && len(out) > query.Limit {
out = out[:query.Limit]
}
return out
}
func clonePlayerSummary(summary PlayerSummary) PlayerSummary {
return summary
}
func normPlayerSummaryID(summary PlayerSummary) PlayerSummary {
summary.AccountID = strings.TrimSpace(summary.AccountID)
summary.RoleID = strings.TrimSpace(summary.RoleID)
return summary
}
func summaryReadOpts() readmodel.ModelOptions[PlayerSummary, PlayerSummaryQuery] {
return readmodel.ModelOptions[PlayerSummary, PlayerSummaryQuery]{
PrimaryID: func(summary PlayerSummary) string {
return summary.AccountID
},
SecondaryID: func(summary PlayerSummary) string {
return summary.RoleID
},
Normalize: normPlayerSummaryID,
Clone: clonePlayerSummary,
Match: summaryMatches,
Less: func(left, right PlayerSummary) bool {
if left.AccountID == right.AccountID {
return left.RoleID < right.RoleID
}
return left.AccountID < right.AccountID
},
QueryPrimaryIDs: func(query PlayerSummaryQuery) []string {
return query.AccountIDs
},
QuerySecondaryIDs: func(query PlayerSummaryQuery) []string {
return query.RoleIDs
},
QueryLimit: func(query PlayerSummaryQuery) int {
return query.Limit
},
}
}
@@ -0,0 +1,208 @@
package player
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/gomodule/redigo/redis"
"longheng.io/server/internal/platform/readmodel"
)
const (
redisCleanMissing = readmodel.RedisCleanupMissingScript
redisCleanCorrupt = readmodel.RedisCleanupCorruptScript
redisRoleIndexDel = readmodel.RedisDeleteSecondaryIndexIfPrimaryScript
redisSummaryKeyspace = redisPlayerSumNS
redisSummaryRole = redisPlayerSumIndex
)
var errBadPlayerSummary = readmodel.ErrCorruptRecord
// RedisSummaryStoreOptions 是 Redis 玩家摘要读模型的连接、命名空间和 TTL 配置。
type RedisSummaryStoreOptions struct {
Address string
Password string
DB int
KeyPrefix string
PoolSize int
Timeout time.Duration
ConnectTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
TTL time.Duration
}
// RedisSummaryStore 基于 Redis 保存玩家摘要读模型和角色索引。
type RedisSummaryStore struct {
keyPrefix string
ttl time.Duration
executor redisExecutor
inner *readmodel.RedisStore[PlayerSummary, PlayerSummaryQuery]
}
// NewRedisSummaryStore 创建 Redis 玩家摘要读模型存储。
func NewRedisSummaryStore(options RedisSummaryStoreOptions) *RedisSummaryStore {
options = normSummaryOpts(options)
pool := &redis.Pool{
MaxIdle: options.PoolSize,
MaxActive: options.PoolSize * 2,
IdleTimeout: time.Minute,
Wait: true,
Dial: func() (redis.Conn, error) {
dialOptions := []redis.DialOption{
redis.DialConnectTimeout(options.ConnectTimeout),
redis.DialReadTimeout(options.ReadTimeout),
redis.DialWriteTimeout(options.WriteTimeout),
redis.DialDatabase(options.DB),
}
if options.Password != "" {
dialOptions = append(dialOptions, redis.DialPassword(options.Password))
}
return redis.Dial("tcp", options.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 newRedisSummaryStore(&redigoExecutor{pool: pool}, options)
}
func newRedisSummaryStore(executor redisExecutor, options RedisSummaryStoreOptions) *RedisSummaryStore {
options = normSummaryOpts(options)
store := &RedisSummaryStore{
keyPrefix: options.KeyPrefix,
ttl: options.TTL,
executor: executor,
}
store.inner = store.newInner()
return store
}
func normSummaryOpts(options RedisSummaryStoreOptions) RedisSummaryStoreOptions {
options.Address = strings.TrimSpace(options.Address)
if options.Address == "" {
options.Address = "127.0.0.1:6379"
}
options.KeyPrefix = strings.TrimSpace(options.KeyPrefix)
if options.KeyPrefix == "" {
options.KeyPrefix = redisSummaryKeyspace
}
if options.PoolSize <= 0 {
options.PoolSize = 8
}
if options.Timeout <= 0 {
options.Timeout = 2 * time.Second
}
if options.ConnectTimeout <= 0 {
options.ConnectTimeout = options.Timeout
}
if options.ReadTimeout <= 0 {
options.ReadTimeout = options.Timeout
}
if options.WriteTimeout <= 0 {
options.WriteTimeout = options.Timeout
}
return options
}
// SavePlayerSummary 保存玩家摘要到 Redis 读模型。
func (s *RedisSummaryStore) SavePlayerSummary(ctx context.Context, summary PlayerSummary) error {
return s.readModel().Save(ctx, summary)
}
// GetPlayerSummary 按账号 ID 从 Redis 读模型读取玩家摘要。
func (s *RedisSummaryStore) GetPlayerSummary(ctx context.Context, accountID string) (PlayerSummary, bool, error) {
return s.readModel().Get(ctx, accountID)
}
// GetPlayerSummaryByRoleID 按角色 ID 从 Redis 读模型读取玩家摘要。
func (s *RedisSummaryStore) GetPlayerSummaryByRoleID(ctx context.Context, roleID string) (PlayerSummary, bool, error) {
return s.readModel().GetBySecondaryID(ctx, roleID)
}
// ListPlayerSummariesByAccountIDs 按账号 ID 批量读取 Redis 玩家摘要。
func (s *RedisSummaryStore) ListPlayerSummariesByAccountIDs(ctx context.Context, accountIDs []string) ([]PlayerSummary, error) {
return s.readModel().ListByPrimaryIDs(ctx, accountIDs)
}
// ListByRoleIDs 按角色 ID 批量读取 Redis 玩家摘要。
func (s *RedisSummaryStore) ListByRoleIDs(ctx context.Context, roleIDs []string) ([]PlayerSummary, error) {
return s.readModel().ListBySecondaryIDs(ctx, roleIDs)
}
// SearchPlayerSummaries 按查询条件搜索 Redis 玩家摘要。
func (s *RedisSummaryStore) SearchPlayerSummaries(ctx context.Context, query PlayerSummaryQuery) ([]PlayerSummary, error) {
return s.readModel().Search(ctx, query)
}
// Check 检查 Redis 玩家摘要读模型存储。
func (s *RedisSummaryStore) Check(ctx context.Context) error {
return s.readModel().Check(ctx)
}
// Close 关闭 Redis 玩家摘要读模型连接。
func (s *RedisSummaryStore) Close(ctx context.Context) error {
return s.readModel().Close(ctx)
}
func (s *RedisSummaryStore) summaryKey(accountID string) string {
return s.readModel().SummaryKey(accountID)
}
func (s *RedisSummaryStore) roleKey(roleID string) string {
return s.readModel().SecondaryKey(roleID)
}
func (s *RedisSummaryStore) accountsKey() string {
return s.readModel().PrimaryIDsKey()
}
func (s *RedisSummaryStore) readModel() *readmodel.RedisStore[PlayerSummary, PlayerSummaryQuery] {
if s.inner == nil {
s.inner = s.newInner()
}
s.inner.SetExecutor(s.executor)
return s.inner
}
func (s *RedisSummaryStore) newInner() *readmodel.RedisStore[PlayerSummary, PlayerSummaryQuery] {
inner, err := readmodel.NewRedisStore[PlayerSummary, PlayerSummaryQuery](readmodel.RedisStoreOptions[PlayerSummary, PlayerSummaryQuery]{
Model: summaryReadOpts(),
Executor: s.executor,
KeyPrefix: s.keyPrefix,
HashField: redisPlayerSumField,
PrimarySetName: redisPlayerSumSet,
SecondaryIndexName: redisSummaryRole,
TTL: s.ttl,
Encode: func(summary PlayerSummary) ([]byte, error) {
return json.Marshal(summary)
},
Decode: decodePlayerSummary,
})
if err != nil {
panic(err)
}
return inner
}
func decodePlayerSummary(accountID string, data []byte) (PlayerSummary, error) {
var summary PlayerSummary
if err := json.Unmarshal(data, &summary); err != nil {
return PlayerSummary{}, fmt.Errorf("%w %s: %w", errBadPlayerSummary, accountID, err)
}
accountID = strings.TrimSpace(accountID)
if accountID != "" {
summary.AccountID = accountID
} else {
summary.AccountID = strings.TrimSpace(summary.AccountID)
}
summary = normPlayerSummaryID(summary)
return clonePlayerSummary(summary), nil
}