DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,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",
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user