服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
516 lines
13 KiB
Go
516 lines
13 KiB
Go
package hotupdate
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"reflect"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/platform/eventlog"
|
||
)
|
||
|
||
const (
|
||
EventLogIntentStream = "hotupdate.intent"
|
||
EventLogProgressStream = "hotupdate.progress"
|
||
)
|
||
|
||
var (
|
||
ErrControlLogRequired = errors.New("hot update eventlog control requires eventlog")
|
||
ErrControlConflict = errors.New("hot update control idempotency key conflicts with existing intent")
|
||
ErrInvalidControlEvent = errors.New("hot update control event payload is invalid")
|
||
ErrControlPollInterval = errors.New("hot update control poll interval is invalid")
|
||
ErrControlInstanceID = errors.New("hot update control instance id generation failed")
|
||
defControlPoll = 2 * time.Second
|
||
)
|
||
|
||
var controlRandRead = rand.Read
|
||
|
||
type EventLogControlOptions struct {
|
||
Log *eventlog.Log
|
||
PollInterval time.Duration
|
||
Now func() time.Time
|
||
}
|
||
|
||
type EventLogControl struct {
|
||
log *eventlog.Log
|
||
pollInterval time.Duration
|
||
now func() time.Time
|
||
|
||
ops sync.RWMutex
|
||
mu sync.Mutex
|
||
intents map[string]Intent
|
||
progress map[string][]Progress
|
||
watchers map[string]map[chan Intent]struct{}
|
||
done chan struct{}
|
||
closed bool
|
||
instanceID string
|
||
progressSequence uint64
|
||
}
|
||
|
||
type intentEnvelope struct {
|
||
Target string `json:"target"`
|
||
Intent Intent `json:"intent"`
|
||
}
|
||
|
||
type progressEnvelope struct {
|
||
Progress Progress `json:"progress"`
|
||
}
|
||
|
||
func NewEventLogControl(options EventLogControlOptions) (*EventLogControl, error) {
|
||
if options.Log == nil {
|
||
return nil, ErrControlLogRequired
|
||
}
|
||
pollInterval := options.PollInterval
|
||
if pollInterval == 0 {
|
||
pollInterval = defControlPoll
|
||
}
|
||
if pollInterval < 0 {
|
||
return nil, ErrControlPollInterval
|
||
}
|
||
now := options.Now
|
||
if now == nil {
|
||
now = func() time.Time { return time.Now().UTC() }
|
||
}
|
||
instanceID, err := newControlInstanceID()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &EventLogControl{
|
||
log: options.Log,
|
||
pollInterval: pollInterval,
|
||
now: now,
|
||
intents: make(map[string]Intent),
|
||
progress: make(map[string][]Progress),
|
||
watchers: make(map[string]map[chan Intent]struct{}),
|
||
done: make(chan struct{}),
|
||
instanceID: instanceID,
|
||
}, nil
|
||
}
|
||
|
||
func (c *EventLogControl) Publish(ctx context.Context, target string, intent Intent) error {
|
||
if err := contextErr(ctx); err != nil {
|
||
return err
|
||
}
|
||
now, err := c.ready()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
target, err = normalizeTarget(target)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
intent, err = normalizeIntent(intent)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
release, err := c.beginWrite()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer release()
|
||
// EventLog 控制面把 intent 持久化为事件,再通知 watcher;watcher 丢通知时可通过轮询补回最新 intent。
|
||
key := intentIdempotencyKey(target, intent)
|
||
existing, ok, err := c.log.GetByIdempotencyKey(ctx, key)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if ok {
|
||
existingIntent, err := decodeIntentEvent(existing)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !reflect.DeepEqual(existingIntent, intent) {
|
||
return fmt.Errorf("%w: target=%s version=%s", ErrControlConflict, target, intent.Version)
|
||
}
|
||
c.storeIntentAndNotify(ctx, target, existingIntent)
|
||
return nil
|
||
}
|
||
payload, err := json.Marshal(intentEnvelope{Target: target, Intent: intent})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if _, err := c.log.Append(ctx, eventlog.Event{
|
||
Stream: EventLogIntentStream,
|
||
Type: string(intent.Action),
|
||
AggregateID: target,
|
||
IdempotencyKey: key,
|
||
Payload: payload,
|
||
Status: eventlog.StatusPublished,
|
||
PublishedAt: now().UTC(),
|
||
Headers: map[string]string{
|
||
"hotupdate_target": target,
|
||
"hotupdate_action": string(intent.Action),
|
||
"hotupdate_version": intent.Version,
|
||
},
|
||
}); err != nil {
|
||
if errors.Is(err, eventlog.ErrIdempotencyConflict) {
|
||
return fmt.Errorf("%w: target=%s version=%s", ErrControlConflict, target, intent.Version)
|
||
}
|
||
return err
|
||
}
|
||
c.storeIntentAndNotify(ctx, target, intent)
|
||
return nil
|
||
}
|
||
|
||
func (c *EventLogControl) Watch(ctx context.Context, target string) (<-chan Intent, error) {
|
||
if err := contextErr(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
if _, err := c.ready(); err != nil {
|
||
return nil, err
|
||
}
|
||
target, err := normalizeTarget(target)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Watch 先注册 channel,再加载最新 intent,避免注册窗口内发布的新 intent 被漏掉。
|
||
ch := make(chan Intent, 1)
|
||
c.mu.Lock()
|
||
if c.closed {
|
||
c.mu.Unlock()
|
||
close(ch)
|
||
return ch, nil
|
||
}
|
||
if c.watchers[target] == nil {
|
||
c.watchers[target] = make(map[chan Intent]struct{})
|
||
}
|
||
c.watchers[target][ch] = struct{}{}
|
||
if intent, ok := c.intents[target]; ok {
|
||
ch <- intent
|
||
}
|
||
c.mu.Unlock()
|
||
go c.watchLoop(ctx, target, ch)
|
||
return ch, nil
|
||
}
|
||
|
||
func (c *EventLogControl) Report(ctx context.Context, progress Progress) error {
|
||
if err := contextErr(ctx); err != nil {
|
||
return err
|
||
}
|
||
now, err := c.ready()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
progress, err = normalizeProgress(progress, now)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
release, err := c.beginWrite()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer release()
|
||
payload, err := json.Marshal(progressEnvelope{Progress: progress})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
instanceID, sequence := c.nextProgressKeyParts()
|
||
if _, err := c.log.Append(ctx, eventlog.Event{
|
||
Stream: EventLogProgressStream,
|
||
Type: string(progress.Stage),
|
||
AggregateID: progress.Target,
|
||
IdempotencyKey: controlProgressKey(progress, instanceID, sequence),
|
||
Payload: payload,
|
||
Status: eventlog.StatusPublished,
|
||
PublishedAt: now().UTC(),
|
||
Headers: map[string]string{
|
||
"hotupdate_target": progress.Target,
|
||
"hotupdate_node": progress.NodeID,
|
||
"hotupdate_version": progress.Version,
|
||
"hotupdate_stage": string(progress.Stage),
|
||
},
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
c.mu.Lock()
|
||
if !c.closed {
|
||
c.progress[progress.Target] = append(c.progress[progress.Target], progress)
|
||
}
|
||
c.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
func (c *EventLogControl) beginWrite() (func(), error) {
|
||
c.ops.RLock()
|
||
c.mu.Lock()
|
||
if c.closed {
|
||
c.mu.Unlock()
|
||
c.ops.RUnlock()
|
||
return nil, ErrControlClosed
|
||
}
|
||
c.mu.Unlock()
|
||
return c.ops.RUnlock, nil
|
||
}
|
||
|
||
func (c *EventLogControl) ready() (func() time.Time, error) {
|
||
if c == nil || c.log == nil {
|
||
return nil, ErrControlLogRequired
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
if c.pollInterval < 0 {
|
||
return nil, ErrControlPollInterval
|
||
}
|
||
if c.pollInterval == 0 {
|
||
c.pollInterval = defControlPoll
|
||
}
|
||
if c.now == nil {
|
||
c.now = func() time.Time { return time.Now().UTC() }
|
||
}
|
||
if c.intents == nil {
|
||
c.intents = make(map[string]Intent)
|
||
}
|
||
if c.progress == nil {
|
||
c.progress = make(map[string][]Progress)
|
||
}
|
||
if c.watchers == nil {
|
||
c.watchers = make(map[string]map[chan Intent]struct{})
|
||
}
|
||
if c.done == nil {
|
||
c.done = make(chan struct{})
|
||
}
|
||
if c.instanceID == "" {
|
||
instanceID, err := newControlInstanceID()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
c.instanceID = instanceID
|
||
}
|
||
return c.now, nil
|
||
}
|
||
|
||
func (c *EventLogControl) Snapshot() ControlSnapshot {
|
||
if c == nil {
|
||
return ControlSnapshot{}
|
||
}
|
||
c.mu.Lock()
|
||
intents := make(map[string]Intent, len(c.intents))
|
||
for target, intent := range c.intents {
|
||
intents[target] = intent
|
||
}
|
||
progress := make(map[string][]Progress, len(c.progress))
|
||
for target, items := range c.progress {
|
||
progress[target] = append([]Progress(nil), items...)
|
||
}
|
||
watchers := make(map[string]int, len(c.watchers))
|
||
for target, list := range c.watchers {
|
||
watchers[target] = len(list)
|
||
}
|
||
snapshot := ControlSnapshot{Intents: intents, Progress: progress, Watchers: watchers, Closed: c.closed}
|
||
c.mu.Unlock()
|
||
if len(snapshot.Intents) == 0 {
|
||
snapshot.Intents = nil
|
||
}
|
||
if len(snapshot.Progress) == 0 {
|
||
snapshot.Progress = nil
|
||
}
|
||
if len(snapshot.Watchers) == 0 {
|
||
snapshot.Watchers = nil
|
||
}
|
||
return snapshot
|
||
}
|
||
|
||
func (c *EventLogControl) Close() error {
|
||
if c == nil {
|
||
return nil
|
||
}
|
||
c.ops.Lock()
|
||
defer c.ops.Unlock()
|
||
c.mu.Lock()
|
||
if c.closed {
|
||
c.mu.Unlock()
|
||
return nil
|
||
}
|
||
c.closed = true
|
||
// Close 关闭所有 watcher channel;EventLog 里的 intent/progress 仍保留用于审计。
|
||
if c.done == nil {
|
||
c.done = make(chan struct{})
|
||
}
|
||
close(c.done)
|
||
for target, watchers := range c.watchers {
|
||
for ch := range watchers {
|
||
close(ch)
|
||
}
|
||
delete(c.watchers, target)
|
||
}
|
||
c.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
func (c *EventLogControl) watchLoop(ctx context.Context, target string, ch chan Intent) {
|
||
defer c.removeWatcher(target, ch)
|
||
// watchLoop 定期从 EventLog 读取最新 intent,作为内存通知丢失后的补偿。
|
||
if c.isDone() {
|
||
return
|
||
}
|
||
_ = c.reloadLatestIntent(ctx, target)
|
||
if c.pollInterval <= 0 {
|
||
select {
|
||
case <-ctx.Done():
|
||
case <-c.done:
|
||
}
|
||
return
|
||
}
|
||
ticker := time.NewTicker(c.pollInterval)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-c.done:
|
||
return
|
||
case <-ticker.C:
|
||
_ = c.reloadLatestIntent(ctx, target)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (c *EventLogControl) isDone() bool {
|
||
select {
|
||
case <-c.done:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (c *EventLogControl) removeWatcher(target string, ch chan Intent) {
|
||
c.mu.Lock()
|
||
if watchers := c.watchers[target]; watchers != nil {
|
||
if _, ok := watchers[ch]; ok {
|
||
delete(watchers, ch)
|
||
close(ch)
|
||
}
|
||
if len(watchers) == 0 {
|
||
delete(c.watchers, target)
|
||
}
|
||
}
|
||
c.mu.Unlock()
|
||
}
|
||
|
||
func (c *EventLogControl) reloadLatestIntent(ctx context.Context, target string) error {
|
||
events, err := c.log.ListByStreamAggregate(ctx, EventLogIntentStream, target, 1)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(events) == 0 {
|
||
return nil
|
||
}
|
||
intent, err := decodeIntentEvent(events[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
c.storeIntentAndNotify(ctx, target, intent)
|
||
return nil
|
||
}
|
||
|
||
func (c *EventLogControl) storeIntentAndNotify(_ context.Context, target string, intent Intent) {
|
||
c.mu.Lock()
|
||
// 内存 latest 是读优化,权威记录仍是 EventLog;通知失败不会删除持久化 intent。
|
||
if c.closed {
|
||
c.mu.Unlock()
|
||
return
|
||
}
|
||
if existing, ok := c.intents[target]; ok && reflect.DeepEqual(existing, intent) {
|
||
c.mu.Unlock()
|
||
return
|
||
}
|
||
c.intents[target] = intent
|
||
for ch := range c.watchers[target] {
|
||
sendLatestIntent(ch, intent)
|
||
}
|
||
c.mu.Unlock()
|
||
}
|
||
|
||
func decodeIntentEvent(event eventlog.Event) (Intent, error) {
|
||
var envelope intentEnvelope
|
||
if len(event.Payload) == 0 {
|
||
return Intent{}, ErrInvalidControlEvent
|
||
}
|
||
if err := json.Unmarshal(event.Payload, &envelope); err != nil {
|
||
return Intent{}, fmt.Errorf("%w: %w", ErrInvalidControlEvent, err)
|
||
}
|
||
intent, err := normalizeIntent(envelope.Intent)
|
||
if err != nil {
|
||
return Intent{}, err
|
||
}
|
||
return intent, nil
|
||
}
|
||
|
||
func intentIdempotencyKey(target string, intent Intent) string {
|
||
if intent.Sequence > 0 {
|
||
return fmt.Sprintf("hotupdate:intent:v1:%s:%d", encodeEventKeyPart(target), intent.Sequence)
|
||
}
|
||
return fmt.Sprintf("hotupdate:intent:v1:%s:%s:%s", encodeEventKeyPart(target), encodeEventKeyPart(string(intent.Action)), encodeEventKeyPart(intent.Version))
|
||
}
|
||
|
||
func progressIdemKey(progress Progress) string {
|
||
parts := []string{
|
||
"hotupdate:progress",
|
||
"v1",
|
||
encodeEventKeyPart(progress.Target),
|
||
encodeEventKeyPart(progress.NodeID),
|
||
encodeEventKeyPart(progress.Version),
|
||
encodeEventKeyPart(string(progress.Stage)),
|
||
progressFingerprint(progress),
|
||
}
|
||
return strings.Join(parts, ":")
|
||
}
|
||
|
||
func controlProgressKey(progress Progress, instanceID string, sequence uint64) string {
|
||
parts := []string{
|
||
progressIdemKey(progress),
|
||
encodeEventKeyPart(instanceID),
|
||
fmt.Sprint(sequence),
|
||
}
|
||
return strings.Join(parts, ":")
|
||
}
|
||
|
||
func (c *EventLogControl) nextProgressKeyParts() (string, uint64) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
c.progressSequence++
|
||
return c.instanceID, c.progressSequence
|
||
}
|
||
|
||
func newControlInstanceID() (string, error) {
|
||
var raw [8]byte
|
||
if _, err := controlRandRead(raw[:]); err != nil {
|
||
return "", fmt.Errorf("%w: %w", ErrControlInstanceID, err)
|
||
}
|
||
return hex.EncodeToString(raw[:]), nil
|
||
}
|
||
|
||
func encodeEventKeyPart(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return "_"
|
||
}
|
||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||
}
|
||
|
||
func progressFingerprint(progress Progress) string {
|
||
payload := strings.Join([]string{
|
||
strings.TrimSpace(progress.Target),
|
||
strings.TrimSpace(progress.NodeID),
|
||
strings.TrimSpace(progress.Version),
|
||
strings.TrimSpace(string(progress.Stage)),
|
||
strings.TrimSpace(progress.Status),
|
||
strings.TrimSpace(progress.Detail),
|
||
progress.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||
}, "\x00")
|
||
sum := sha256.Sum256([]byte(payload))
|
||
return hex.EncodeToString(sum[:8])
|
||
}
|