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,79 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RequestEvent struct {
|
||||
AccountID string `json:"account_id"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Route string `json:"route,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
type RequestResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
}
|
||||
|
||||
type RequestHook interface {
|
||||
BeforeProfileRequest(context.Context, RequestEvent) context.Context
|
||||
AfterProfileRequest(context.Context, RequestEvent, RequestResult)
|
||||
}
|
||||
|
||||
type RequestHooks []RequestHook
|
||||
|
||||
func (hooks RequestHooks) Run(ctx context.Context, event RequestEvent, handler func(context.Context) error) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if handler == nil {
|
||||
return fmt.Errorf("profile request handler is required")
|
||||
}
|
||||
if event.StartedAt.IsZero() {
|
||||
event.StartedAt = time.Now().UTC()
|
||||
} else {
|
||||
event.StartedAt = event.StartedAt.UTC()
|
||||
}
|
||||
event.Metadata = cloneRequestMetadata(event.Metadata)
|
||||
for _, hook := range hooks {
|
||||
if hook == nil {
|
||||
continue
|
||||
}
|
||||
next := hook.BeforeProfileRequest(ctx, event)
|
||||
if next != nil {
|
||||
ctx = next
|
||||
}
|
||||
}
|
||||
err := handler(ctx)
|
||||
result := RequestResult{
|
||||
OK: err == nil,
|
||||
Duration: time.Since(event.StartedAt),
|
||||
}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
for _, hook := range hooks {
|
||||
if hook != nil {
|
||||
hook.AfterProfileRequest(ctx, event, result)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func cloneRequestMetadata(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/db"
|
||||
)
|
||||
|
||||
type SaveActiveHook[T any] func(context.Context, string, T, T) error
|
||||
|
||||
type LifecycleOptions[T any, F comparable] struct {
|
||||
Store db.Store[T]
|
||||
Runtime *Runtime[T, F]
|
||||
}
|
||||
|
||||
type Lifecycle[T any, F comparable] struct {
|
||||
store db.Store[T]
|
||||
runtime *Runtime[T, F]
|
||||
}
|
||||
|
||||
func NewLifecycle[T any, F comparable](options LifecycleOptions[T, F]) (*Lifecycle[T, F], error) {
|
||||
if options.Store == nil {
|
||||
return nil, fmt.Errorf("profile lifecycle store is required")
|
||||
}
|
||||
if options.Runtime == nil {
|
||||
return nil, fmt.Errorf("profile lifecycle runtime is required")
|
||||
}
|
||||
return &Lifecycle[T, F]{
|
||||
store: options.Store,
|
||||
runtime: options.Runtime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *Lifecycle[T, F]) Preflight(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return db.Check(ctx, l.store)
|
||||
}
|
||||
|
||||
func (l *Lifecycle[T, F]) CloseOrFlush(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return db.CloseOrFlush(ctx, l.store)
|
||||
}
|
||||
|
||||
func (l *Lifecycle[T, F]) LoadOrCreate(ctx context.Context, key string) (T, bool, error) {
|
||||
return l.runtime.LoadOrCreate(ctx, key)
|
||||
}
|
||||
|
||||
func (l *Lifecycle[T, F]) SaveAndUnload(ctx context.Context, key string) (T, T, bool, error) {
|
||||
return l.runtime.SaveAndUnload(ctx, key)
|
||||
}
|
||||
|
||||
func (l *Lifecycle[T, F]) SaveActiveAndUnload(ctx context.Context, hook SaveActiveHook[T]) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
var firstErr error
|
||||
for _, key := range l.runtime.ActiveKeys() {
|
||||
saved, summary, ok, err := l.runtime.SaveAndUnload(ctx, key)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ok && hook != nil {
|
||||
if err := hook(ctx, key, saved, summary); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (l *Lifecycle[T, F]) Upsert(key string, mutate func(T, bool, time.Time) (T, []F, error)) (T, error) {
|
||||
return l.runtime.Upsert(key, mutate)
|
||||
}
|
||||
|
||||
func (l *Lifecycle[T, F]) MutateLoaded(ctx context.Context, key string, mutate func(*T, time.Time) ([]F, error)) (T, T, error) {
|
||||
return l.runtime.MutateLoaded(ctx, key, mutate)
|
||||
}
|
||||
|
||||
func (l *Lifecycle[T, F]) MutateActive(key string, mutate func(*T, time.Time) ([]F, error)) (T, T, bool, error) {
|
||||
return l.runtime.MutateActive(key, mutate)
|
||||
}
|
||||
|
||||
func (l *Lifecycle[T, F]) LoadDetached(ctx context.Context, key string) (T, bool, error) {
|
||||
return l.runtime.LoadDetached(ctx, key)
|
||||
}
|
||||
|
||||
func (l *Lifecycle[T, F]) NewDetached(key string, now time.Time) T {
|
||||
return l.runtime.NewDetached(key, now)
|
||||
}
|
||||
|
||||
func (l *Lifecycle[T, F]) AttachIfAbsent(key string, record T, fields ...F) (T, bool) {
|
||||
return l.runtime.AttachIfAbsent(key, record, fields...)
|
||||
}
|
||||
|
||||
func (l *Lifecycle[T, F]) Get(key string) (T, bool) {
|
||||
return l.runtime.Get(key)
|
||||
}
|
||||
|
||||
func (l *Lifecycle[T, F]) Snapshot() []T {
|
||||
return l.runtime.Snapshot()
|
||||
}
|
||||
|
||||
func (l *Lifecycle[T, F]) ActiveKeys() []string {
|
||||
return l.runtime.ActiveKeys()
|
||||
}
|
||||
|
||||
func (l *Lifecycle[T, F]) ActiveByPredicate(match func(T) bool) (T, bool) {
|
||||
return l.runtime.ActiveByPredicate(match)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OfflineDelivery struct {
|
||||
AccountID string `json:"account_id"`
|
||||
Kind string `json:"kind"`
|
||||
Payload []byte `json:"payload,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type OfflinePipeline interface {
|
||||
EnqueueOffline(context.Context, OfflineDelivery) error
|
||||
DrainOffline(context.Context, string, func(context.Context, OfflineDelivery) error) (int, error)
|
||||
SnapshotOffline(string) []OfflineDelivery
|
||||
}
|
||||
|
||||
type MemoryOfflinePipeline struct {
|
||||
mu sync.Mutex
|
||||
maxPerAccount int
|
||||
now func() time.Time
|
||||
deliveries map[string][]OfflineDelivery
|
||||
}
|
||||
|
||||
func NewMemoryOfflinePipeline(maxPerAccount int, now func() time.Time) *MemoryOfflinePipeline {
|
||||
if maxPerAccount <= 0 {
|
||||
maxPerAccount = 256
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &MemoryOfflinePipeline{
|
||||
maxPerAccount: maxPerAccount,
|
||||
now: now,
|
||||
deliveries: make(map[string][]OfflineDelivery),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MemoryOfflinePipeline) EnqueueOffline(ctx context.Context, delivery OfflineDelivery) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
normalized, err := normOfflineDelivery(delivery, p.nowUTC())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.deliveries == nil {
|
||||
p.deliveries = make(map[string][]OfflineDelivery)
|
||||
}
|
||||
queue := slices.Clone(p.deliveries[normalized.AccountID])
|
||||
queue = append(queue, cloneOfflineDelivery(normalized))
|
||||
if len(queue) > p.maxPerAccount {
|
||||
queue = slices.Clone(queue[len(queue)-p.maxPerAccount:])
|
||||
}
|
||||
p.deliveries[normalized.AccountID] = queue
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MemoryOfflinePipeline) DrainOffline(ctx context.Context, accountID string, handler func(context.Context, OfflineDelivery) error) (int, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if handler == nil {
|
||||
return 0, fmt.Errorf("offline delivery handler is required")
|
||||
}
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" || p == nil {
|
||||
return 0, nil
|
||||
}
|
||||
p.mu.Lock()
|
||||
queue := cloneOfflineItems(p.deliveries[accountID])
|
||||
p.mu.Unlock()
|
||||
delivered := 0
|
||||
for idx, delivery := range queue {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
p.keepUndelivered(accountID, queue[idx:])
|
||||
return delivered, err
|
||||
}
|
||||
if err := handler(ctx, cloneOfflineDelivery(delivery)); err != nil {
|
||||
p.keepUndelivered(accountID, queue[idx:])
|
||||
return delivered, err
|
||||
}
|
||||
delivered++
|
||||
}
|
||||
p.keepUndelivered(accountID, nil)
|
||||
return delivered, nil
|
||||
}
|
||||
|
||||
func (p *MemoryOfflinePipeline) SnapshotOffline(accountID string) []OfflineDelivery {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" || p == nil {
|
||||
return nil
|
||||
}
|
||||
p.mu.Lock()
|
||||
out := cloneOfflineItems(p.deliveries[accountID])
|
||||
p.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *MemoryOfflinePipeline) keepUndelivered(accountID string, remaining []OfflineDelivery) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(remaining) == 0 {
|
||||
delete(p.deliveries, accountID)
|
||||
return
|
||||
}
|
||||
p.deliveries[accountID] = cloneOfflineItems(remaining)
|
||||
}
|
||||
|
||||
func (p *MemoryOfflinePipeline) nowUTC() time.Time {
|
||||
if p == nil || p.now == nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return p.now().UTC()
|
||||
}
|
||||
|
||||
func normOfflineDelivery(delivery OfflineDelivery, now time.Time) (OfflineDelivery, error) {
|
||||
delivery.AccountID = strings.TrimSpace(delivery.AccountID)
|
||||
delivery.Kind = strings.TrimSpace(delivery.Kind)
|
||||
delivery.IdempotencyKey = strings.TrimSpace(delivery.IdempotencyKey)
|
||||
if delivery.AccountID == "" || delivery.Kind == "" {
|
||||
return OfflineDelivery{}, fmt.Errorf("offline delivery account_id and kind are required")
|
||||
}
|
||||
if delivery.CreatedAt.IsZero() {
|
||||
delivery.CreatedAt = now
|
||||
} else {
|
||||
delivery.CreatedAt = delivery.CreatedAt.UTC()
|
||||
}
|
||||
delivery.Payload = append([]byte(nil), delivery.Payload...)
|
||||
delivery.Metadata = cloneRequestMetadata(delivery.Metadata)
|
||||
return delivery, nil
|
||||
}
|
||||
|
||||
func cloneOfflineDelivery(delivery OfflineDelivery) OfflineDelivery {
|
||||
delivery.Payload = append([]byte(nil), delivery.Payload...)
|
||||
delivery.Metadata = cloneRequestMetadata(delivery.Metadata)
|
||||
return delivery
|
||||
}
|
||||
|
||||
func cloneOfflineItems(in []OfflineDelivery) []OfflineDelivery {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]OfflineDelivery, len(in))
|
||||
for idx, delivery := range in {
|
||||
out[idx] = cloneOfflineDelivery(delivery)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ctxErr(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ValidState string
|
||||
|
||||
const (
|
||||
ValidStateInvalid ValidState = "invalid"
|
||||
ValidStateLoading ValidState = "loading"
|
||||
ValidStateValid ValidState = "valid"
|
||||
ValidStateClosing ValidState = "closing"
|
||||
)
|
||||
|
||||
type OnlineState string
|
||||
|
||||
const (
|
||||
OnlineStateOffline OnlineState = "offline"
|
||||
OnlineStateConnecting OnlineState = "connecting"
|
||||
OnlineStateOnline OnlineState = "online"
|
||||
OnlineStateDisconnecting OnlineState = "disconnecting"
|
||||
)
|
||||
|
||||
type AccountOnlineState struct {
|
||||
AccountID string `json:"account_id"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Valid ValidState `json:"valid"`
|
||||
Online OnlineState `json:"online"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s AccountOnlineState) CanHandleRequest(sessionID string) bool {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if s.Valid != ValidStateValid || s.Online != OnlineStateOnline {
|
||||
return false
|
||||
}
|
||||
return s.SessionID == "" || sessionID == "" || s.SessionID == sessionID
|
||||
}
|
||||
|
||||
type OnlineStateMachine struct {
|
||||
mu sync.RWMutex
|
||||
states map[string]AccountOnlineState
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewOnlineStateMachine() *OnlineStateMachine {
|
||||
return NewOnlineStateClock(time.Now)
|
||||
}
|
||||
|
||||
func NewOnlineStateClock(now func() time.Time) *OnlineStateMachine {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &OnlineStateMachine{
|
||||
states: make(map[string]AccountOnlineState),
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *OnlineStateMachine) BeginLoad(accountID string) AccountOnlineState {
|
||||
return m.set(accountID, "", ValidStateLoading, OnlineStateOffline)
|
||||
}
|
||||
|
||||
func (m *OnlineStateMachine) MarkValid(accountID string) AccountOnlineState {
|
||||
return m.update(accountID, func(state AccountOnlineState) AccountOnlineState {
|
||||
state.Valid = ValidStateValid
|
||||
if state.Online == "" {
|
||||
state.Online = OnlineStateOffline
|
||||
}
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
func (m *OnlineStateMachine) BindSession(accountID, sessionID string) AccountOnlineState {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
return m.update(accountID, func(state AccountOnlineState) AccountOnlineState {
|
||||
state.Valid = ValidStateValid
|
||||
state.Online = OnlineStateOnline
|
||||
state.SessionID = sessionID
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
func (m *OnlineStateMachine) Disconnect(accountID, sessionID string) AccountOnlineState {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
return m.update(accountID, func(state AccountOnlineState) AccountOnlineState {
|
||||
if sessionID != "" && state.SessionID != "" && state.SessionID != sessionID {
|
||||
return state
|
||||
}
|
||||
state.Online = OnlineStateOffline
|
||||
state.SessionID = ""
|
||||
if state.Valid == "" {
|
||||
state.Valid = ValidStateInvalid
|
||||
}
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
func (m *OnlineStateMachine) MarkClosing(accountID string) AccountOnlineState {
|
||||
return m.update(accountID, func(state AccountOnlineState) AccountOnlineState {
|
||||
state.Valid = ValidStateClosing
|
||||
if state.Online == OnlineStateOnline {
|
||||
state.Online = OnlineStateDisconnecting
|
||||
}
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
func (m *OnlineStateMachine) Invalidate(accountID string) AccountOnlineState {
|
||||
return m.set(accountID, "", ValidStateInvalid, OnlineStateOffline)
|
||||
}
|
||||
|
||||
func (m *OnlineStateMachine) Get(accountID string) (AccountOnlineState, bool) {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" || m == nil {
|
||||
return AccountOnlineState{}, false
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
state, ok := m.states[accountID]
|
||||
return state, ok
|
||||
}
|
||||
|
||||
func (m *OnlineStateMachine) Snapshot() []AccountOnlineState {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]AccountOnlineState, 0, len(m.states))
|
||||
for _, state := range m.states {
|
||||
out = append(out, state)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *OnlineStateMachine) set(accountID, sessionID string, valid ValidState, online OnlineState) AccountOnlineState {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" || m == nil {
|
||||
return AccountOnlineState{}
|
||||
}
|
||||
state := AccountOnlineState{
|
||||
AccountID: accountID,
|
||||
SessionID: strings.TrimSpace(sessionID),
|
||||
Valid: valid,
|
||||
Online: online,
|
||||
UpdatedAt: m.now().UTC(),
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.states[accountID] = state
|
||||
m.mu.Unlock()
|
||||
return state
|
||||
}
|
||||
|
||||
func (m *OnlineStateMachine) update(accountID string, mutate func(AccountOnlineState) AccountOnlineState) AccountOnlineState {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" || m == nil {
|
||||
return AccountOnlineState{}
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
state := m.states[accountID]
|
||||
state.AccountID = accountID
|
||||
state = mutate(state)
|
||||
state.UpdatedAt = m.now().UTC()
|
||||
m.states[accountID] = state
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/db"
|
||||
)
|
||||
|
||||
const rtRecordLockShards = 64
|
||||
|
||||
type SaveFieldsFunc[T any, F comparable] func(context.Context, db.Store[T], T, ...F) error
|
||||
|
||||
type PrepareFunc[T any, F comparable] func(T, string, time.Time, bool) (T, []F, error)
|
||||
|
||||
type SavePrepareFunc[T any, F comparable] func(T, time.Time) (T, []F, error)
|
||||
|
||||
type TouchFunc[T any, F comparable] func(T, time.Time) (T, []F, error)
|
||||
|
||||
type SameFunc[T any] func(current, snapshot T) bool
|
||||
|
||||
var ErrCloneRequired = errors.New("profile runtime clone function is required")
|
||||
|
||||
type Options[T any, F comparable] struct {
|
||||
Store db.Store[T]
|
||||
Fields db.FieldRegistry[F]
|
||||
Clone func(T) T
|
||||
NewRecord func(string, time.Time) T
|
||||
PrepareLoaded PrepareFunc[T, F]
|
||||
PrepareSaved SavePrepareFunc[T, F]
|
||||
Touch TouchFunc[T, F]
|
||||
SaveFields SaveFieldsFunc[T, F]
|
||||
SameActive SameFunc[T]
|
||||
NormalizeKey func(string) string
|
||||
}
|
||||
|
||||
type Runtime[T any, F comparable] struct {
|
||||
store db.Store[T]
|
||||
fields db.FieldRegistry[F]
|
||||
clone func(T) T
|
||||
newRecord func(string, time.Time) T
|
||||
prepareLoaded PrepareFunc[T, F]
|
||||
prepareSaved SavePrepareFunc[T, F]
|
||||
touch TouchFunc[T, F]
|
||||
saveFields SaveFieldsFunc[T, F]
|
||||
sameActive SameFunc[T]
|
||||
normalizeKey func(string) string
|
||||
|
||||
mu sync.RWMutex
|
||||
active map[string]T
|
||||
dirty map[string]map[F]uint64
|
||||
rev uint64
|
||||
|
||||
recordLocks [rtRecordLockShards]sync.Mutex
|
||||
}
|
||||
|
||||
func NewRuntime[T any, F comparable](options Options[T, F]) (*Runtime[T, F], error) {
|
||||
if options.Store == nil {
|
||||
return nil, fmt.Errorf("profile runtime store is required")
|
||||
}
|
||||
if options.NewRecord == nil {
|
||||
return nil, fmt.Errorf("profile runtime new record function is required")
|
||||
}
|
||||
if options.SaveFields == nil {
|
||||
return nil, fmt.Errorf("profile runtime save fields function is required")
|
||||
}
|
||||
if options.Clone == nil && hasRefFields[T]() {
|
||||
return nil, fmt.Errorf("%w: %s", ErrCloneRequired, recordTypeName[T]())
|
||||
}
|
||||
if options.Clone == nil {
|
||||
options.Clone = db.IdentityClone[T]
|
||||
}
|
||||
return &Runtime[T, F]{
|
||||
store: options.Store,
|
||||
fields: options.Fields,
|
||||
clone: options.Clone,
|
||||
newRecord: options.NewRecord,
|
||||
prepareLoaded: options.PrepareLoaded,
|
||||
prepareSaved: options.PrepareSaved,
|
||||
touch: options.Touch,
|
||||
saveFields: options.SaveFields,
|
||||
sameActive: options.SameActive,
|
||||
normalizeKey: options.NormalizeKey,
|
||||
active: make(map[string]T),
|
||||
dirty: make(map[string]map[F]uint64),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func hasRefFields[T any]() bool {
|
||||
var zero T
|
||||
t := reflect.TypeOf(zero)
|
||||
if t == nil {
|
||||
return false
|
||||
}
|
||||
return typeHasRefFields(t)
|
||||
}
|
||||
|
||||
func typeHasRefFields(t reflect.Type) bool {
|
||||
for t.Kind() == reflect.Pointer {
|
||||
return true
|
||||
}
|
||||
switch t.Kind() {
|
||||
case reflect.Map, reflect.Slice, reflect.Chan, reflect.Func, reflect.Interface:
|
||||
return true
|
||||
case reflect.Array:
|
||||
return typeHasRefFields(t.Elem())
|
||||
case reflect.Struct:
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
if directFieldHasRef(t.Field(i).Type) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func directFieldHasRef(t reflect.Type) bool {
|
||||
for t.Kind() == reflect.Pointer {
|
||||
return true
|
||||
}
|
||||
switch t.Kind() {
|
||||
case reflect.Map, reflect.Slice, reflect.Chan, reflect.Func, reflect.Interface:
|
||||
return true
|
||||
case reflect.Array:
|
||||
return directFieldHasRef(t.Elem())
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func recordTypeName[T any]() string {
|
||||
var zero T
|
||||
t := reflect.TypeOf(zero)
|
||||
if t == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return t.String()
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) LoadOrCreate(ctx context.Context, key string) (T, bool, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
var zero T
|
||||
key = r.normalizeRecordKey(key)
|
||||
if key == "" {
|
||||
return zero, false, fmt.Errorf("profile key is required")
|
||||
}
|
||||
|
||||
unlock := r.lockRecord(key)
|
||||
defer unlock()
|
||||
return r.loadOrCreateLocked(ctx, key)
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) loadOrCreateLocked(ctx context.Context, key string) (T, bool, error) {
|
||||
var zero T
|
||||
r.mu.RLock()
|
||||
if record, ok := r.active[key]; ok {
|
||||
r.mu.RUnlock()
|
||||
return r.clone(record), false, nil
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
record, existed, err := r.store.Load(ctx, key)
|
||||
if err != nil {
|
||||
return zero, false, err
|
||||
}
|
||||
dirty := r.fields.NewSet()
|
||||
if !existed {
|
||||
record = r.newRecord(key, now)
|
||||
dirty.AddAll()
|
||||
} else {
|
||||
record = r.clone(record)
|
||||
}
|
||||
if r.prepareLoaded != nil {
|
||||
record, err = r.applyPrepareLoaded(record, key, now, existed, dirty)
|
||||
if err != nil {
|
||||
return zero, false, err
|
||||
}
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
if existing, ok := r.active[key]; ok {
|
||||
r.mu.Unlock()
|
||||
return r.clone(existing), false, nil
|
||||
}
|
||||
record = r.clone(record)
|
||||
r.active[key] = record
|
||||
r.markDirtyLocked(key, dirty.List()...)
|
||||
r.mu.Unlock()
|
||||
return r.clone(record), true, nil
|
||||
}
|
||||
|
||||
// SaveAndUnload 保存当前在线快照,并在记录未被并发修改时卸载。
|
||||
// 保存链路只操作克隆快照,避免 PrepareSaved 或底层存储污染 active 状态。
|
||||
func (r *Runtime[T, F]) SaveAndUnload(ctx context.Context, key string) (T, T, bool, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
var zero T
|
||||
key = r.normalizeRecordKey(key)
|
||||
if key == "" {
|
||||
return zero, zero, false, fmt.Errorf("profile key is required")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
r.mu.Lock()
|
||||
record, ok := r.active[key]
|
||||
if !ok {
|
||||
r.mu.Unlock()
|
||||
return zero, zero, false, nil
|
||||
}
|
||||
recordSnapshot := r.clone(record)
|
||||
saveRecord := r.clone(recordSnapshot)
|
||||
dirtyFields, dirtyRevs := r.dirtySnapshotLocked(key)
|
||||
if r.prepareSaved != nil {
|
||||
var fields []F
|
||||
var err error
|
||||
saveRecord, fields, err = r.prepareSaved(saveRecord, now)
|
||||
if err != nil {
|
||||
r.mu.Unlock()
|
||||
return zero, zero, false, err
|
||||
}
|
||||
dirtyFields = r.fields.Merge(dirtyFields, fields)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
if err := r.saveFields(ctx, r.store, saveRecord, dirtyFields...); err != nil {
|
||||
return zero, zero, false, err
|
||||
}
|
||||
|
||||
summaryRecord := saveRecord
|
||||
r.mu.Lock()
|
||||
if current, exists := r.active[key]; exists {
|
||||
r.clearDirtyLocked(key, dirtyRevs)
|
||||
if r.same(current, recordSnapshot) && len(r.dirty[key]) == 0 {
|
||||
delete(r.active, key)
|
||||
delete(r.dirty, key)
|
||||
} else {
|
||||
summaryRecord = current
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
return r.clone(saveRecord), r.clone(summaryRecord), true, nil
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) Upsert(key string, mutate func(T, bool, time.Time) (T, []F, error)) (T, error) {
|
||||
var zero T
|
||||
key = r.normalizeRecordKey(key)
|
||||
if key == "" {
|
||||
return zero, fmt.Errorf("profile key is required")
|
||||
}
|
||||
if mutate == nil {
|
||||
return zero, fmt.Errorf("profile upsert mutation is required")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
|
||||
unlock := r.lockRecord(key)
|
||||
defer unlock()
|
||||
|
||||
r.mu.RLock()
|
||||
record, existed := r.active[key]
|
||||
if !existed {
|
||||
record = r.newRecord(key, now)
|
||||
} else {
|
||||
record = r.clone(record)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
record, fields, err := mutate(record, existed, now)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
record = r.clone(record)
|
||||
r.mu.Lock()
|
||||
r.active[key] = record
|
||||
if !existed {
|
||||
all := r.fields.All()
|
||||
fields = append(all, fields...)
|
||||
}
|
||||
r.markDirtyLocked(key, fields...)
|
||||
r.mu.Unlock()
|
||||
return r.clone(record), nil
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) MutateLoaded(ctx context.Context, key string, mutate func(*T, time.Time) ([]F, error)) (T, T, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
var zero T
|
||||
key = r.normalizeRecordKey(key)
|
||||
if key == "" {
|
||||
return zero, zero, fmt.Errorf("profile key is required")
|
||||
}
|
||||
if mutate == nil {
|
||||
return zero, zero, fmt.Errorf("profile mutation is required")
|
||||
}
|
||||
const attempts = 2
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
unlock := r.lockRecord(key)
|
||||
if _, _, err := r.loadOrCreateLocked(ctx, key); err != nil {
|
||||
unlock()
|
||||
return zero, zero, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
r.mu.RLock()
|
||||
record, ok := r.active[key]
|
||||
if !ok {
|
||||
r.mu.RUnlock()
|
||||
unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return zero, zero, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
before := r.clone(record)
|
||||
record = r.clone(record)
|
||||
r.mu.RUnlock()
|
||||
fields, err := mutate(&record, now)
|
||||
if err != nil {
|
||||
unlock()
|
||||
return zero, zero, err
|
||||
}
|
||||
if r.touch != nil {
|
||||
var touchFields []F
|
||||
record, touchFields, err = r.touch(record, now)
|
||||
if err != nil {
|
||||
unlock()
|
||||
return zero, zero, err
|
||||
}
|
||||
fields = append(fields, touchFields...)
|
||||
}
|
||||
record = r.clone(record)
|
||||
r.mu.Lock()
|
||||
r.active[key] = record
|
||||
r.markDirtyLocked(key, fields...)
|
||||
r.mu.Unlock()
|
||||
unlock()
|
||||
return before, r.clone(record), nil
|
||||
}
|
||||
return zero, zero, fmt.Errorf("profile %s was unloaded during mutation", key)
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) MutateActive(key string, mutate func(*T, time.Time) ([]F, error)) (T, T, bool, error) {
|
||||
var zero T
|
||||
key = r.normalizeRecordKey(key)
|
||||
if key == "" {
|
||||
return zero, zero, false, fmt.Errorf("profile key is required")
|
||||
}
|
||||
if mutate == nil {
|
||||
return zero, zero, false, fmt.Errorf("profile mutation is required")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
unlock := r.lockRecord(key)
|
||||
defer unlock()
|
||||
|
||||
r.mu.RLock()
|
||||
record, ok := r.active[key]
|
||||
if !ok {
|
||||
r.mu.RUnlock()
|
||||
return zero, zero, false, nil
|
||||
}
|
||||
before := r.clone(record)
|
||||
record = r.clone(record)
|
||||
r.mu.RUnlock()
|
||||
fields, err := mutate(&record, now)
|
||||
if err != nil {
|
||||
return zero, zero, true, err
|
||||
}
|
||||
if r.touch != nil && len(fields) > 0 {
|
||||
var touchFields []F
|
||||
record, touchFields, err = r.touch(record, now)
|
||||
if err != nil {
|
||||
return zero, zero, true, err
|
||||
}
|
||||
fields = append(fields, touchFields...)
|
||||
}
|
||||
record = r.clone(record)
|
||||
r.mu.Lock()
|
||||
r.active[key] = record
|
||||
r.markDirtyLocked(key, fields...)
|
||||
r.mu.Unlock()
|
||||
return before, r.clone(record), true, nil
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) LoadDetached(ctx context.Context, key string) (T, bool, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
key = r.normalizeRecordKey(key)
|
||||
if key == "" {
|
||||
var zero T
|
||||
return zero, false, fmt.Errorf("profile key is required")
|
||||
}
|
||||
record, ok, err := r.store.Load(ctx, key)
|
||||
if err != nil || !ok {
|
||||
return record, ok, err
|
||||
}
|
||||
return r.clone(record), true, nil
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) NewDetached(key string, now time.Time) T {
|
||||
key = r.normalizeRecordKey(key)
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
return r.newRecord(key, now)
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) AttachIfAbsent(key string, record T, fields ...F) (T, bool) {
|
||||
key = r.normalizeRecordKey(key)
|
||||
if key == "" {
|
||||
return r.clone(record), false
|
||||
}
|
||||
unlock := r.lockRecord(key)
|
||||
defer unlock()
|
||||
|
||||
r.mu.Lock()
|
||||
if current, ok := r.active[key]; ok {
|
||||
r.mu.Unlock()
|
||||
return r.clone(current), false
|
||||
}
|
||||
record = r.clone(record)
|
||||
r.active[key] = record
|
||||
r.markDirtyLocked(key, fields...)
|
||||
r.mu.Unlock()
|
||||
return r.clone(record), true
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) Get(key string) (T, bool) {
|
||||
key = r.normalizeRecordKey(key)
|
||||
r.mu.RLock()
|
||||
record, ok := r.active[key]
|
||||
r.mu.RUnlock()
|
||||
return r.clone(record), ok
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) Snapshot() []T {
|
||||
r.mu.RLock()
|
||||
keys := make([]string, 0, len(r.active))
|
||||
for key := range r.active {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
records := make([]T, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
records = append(records, r.active[key])
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
out := make([]T, 0, len(records))
|
||||
for _, record := range records {
|
||||
out = append(out, r.clone(record))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) ActiveKeys() []string {
|
||||
r.mu.RLock()
|
||||
keys := make([]string, 0, len(r.active))
|
||||
for key := range r.active {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) ActiveByPredicate(match func(T) bool) (T, bool) {
|
||||
var zero T
|
||||
if match == nil {
|
||||
return zero, false
|
||||
}
|
||||
r.mu.RLock()
|
||||
records := make([]T, 0, len(r.active))
|
||||
for _, record := range r.active {
|
||||
records = append(records, record)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
for _, record := range records {
|
||||
cloned := r.clone(record)
|
||||
if match(cloned) {
|
||||
return cloned, true
|
||||
}
|
||||
}
|
||||
return zero, false
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) applyPrepareLoaded(record T, key string, now time.Time, existed bool, dirty db.FieldSet[F]) (T, error) {
|
||||
record, fields, err := r.prepareLoaded(record, key, now, existed)
|
||||
if err != nil {
|
||||
return record, err
|
||||
}
|
||||
dirty.Add(fields...)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) markDirtyLocked(key string, fields ...F) {
|
||||
fields = r.fields.Normalize(fields)
|
||||
if len(fields) == 0 {
|
||||
return
|
||||
}
|
||||
revisions := r.dirty[key]
|
||||
if revisions == nil {
|
||||
revisions = make(map[F]uint64, len(fields))
|
||||
}
|
||||
for _, field := range fields {
|
||||
r.rev++
|
||||
revisions[field] = r.rev
|
||||
}
|
||||
r.dirty[key] = revisions
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) dirtySnapshotLocked(key string) ([]F, map[F]uint64) {
|
||||
revisions := r.dirty[key]
|
||||
fields := r.dirtyFieldsLocked(revisions)
|
||||
if len(fields) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
snapshot := make(map[F]uint64, len(fields))
|
||||
for _, field := range fields {
|
||||
snapshot[field] = revisions[field]
|
||||
}
|
||||
return fields, snapshot
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) clearDirtyLocked(key string, saved map[F]uint64) {
|
||||
if len(saved) == 0 {
|
||||
return
|
||||
}
|
||||
revisions := r.dirty[key]
|
||||
for field, savedRev := range saved {
|
||||
if currentRev, ok := revisions[field]; ok && currentRev == savedRev {
|
||||
delete(revisions, field)
|
||||
}
|
||||
}
|
||||
if len(revisions) == 0 {
|
||||
delete(r.dirty, key)
|
||||
return
|
||||
}
|
||||
r.dirty[key] = revisions
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) dirtyFieldsLocked(revisions map[F]uint64) []F {
|
||||
if len(revisions) == 0 {
|
||||
return nil
|
||||
}
|
||||
fields := make([]F, 0, len(revisions))
|
||||
for _, field := range r.fields.All() {
|
||||
if _, ok := revisions[field]; ok {
|
||||
fields = append(fields, field)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) normalizeRecordKey(key string) string {
|
||||
if r.normalizeKey != nil {
|
||||
return r.normalizeKey(key)
|
||||
}
|
||||
return strings.TrimSpace(key)
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) lockRecord(key string) func() {
|
||||
shard := rtRecordShard(key)
|
||||
r.recordLocks[shard].Lock()
|
||||
return func() {
|
||||
r.recordLocks[shard].Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func rtRecordShard(key string) int {
|
||||
var hash uint32 = 2166136261
|
||||
for i := 0; i < len(key); i++ {
|
||||
hash ^= uint32(key[i])
|
||||
hash *= 16777619
|
||||
}
|
||||
// 分片数固定为 64,索引一定落在 int 安全范围内。
|
||||
return int(hash & (rtRecordLockShards - 1))
|
||||
}
|
||||
|
||||
func (r *Runtime[T, F]) same(current, snapshot T) bool {
|
||||
if r.sameActive == nil {
|
||||
return false
|
||||
}
|
||||
return r.sameActive(current, snapshot)
|
||||
}
|
||||
Reference in new issue
Block a user