DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,93 @@
package onlinepush
import (
"errors"
"net/http"
"strconv"
"strings"
"longheng.io/server/internal/platform/httpx"
)
// AdminOptions 控制在线推送管理路由的路径和鉴权包装。
type AdminOptions struct {
Prefix string
Wrap func(http.HandlerFunc) http.HandlerFunc
WrapDangerous func(operation string, next http.HandlerFunc) http.HandlerFunc
}
const maxAdminJSONBytes = 1 << 20
// RegisterAdminRoutes 注册在线推送管理路由。
func RegisterAdminRoutes(mux *http.ServeMux, service *Service, options AdminOptions) error {
if mux == nil {
return errors.New("online push admin mux is required")
}
if service == nil {
return errors.New("online push service is required")
}
prefix := strings.TrimRight(strings.TrimSpace(options.Prefix), "/")
if prefix == "" {
prefix = "/onlinepush"
}
wrap := options.Wrap
if wrap == nil {
wrap = func(next http.HandlerFunc) http.HandlerFunc { return next }
}
wrapDangerous := options.WrapDangerous
if wrapDangerous == nil {
wrapDangerous = func(_ string, next http.HandlerFunc) http.HandlerFunc { return wrap(next) }
}
mux.HandleFunc(prefix, wrap(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
httpx.WriteJSON(w, http.StatusOK, service.Snapshot(r.Context()))
}))
mux.HandleFunc(prefix+"/send", wrapDangerous("onlinepush.send", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
var request Request
if err := httpx.DecodeStrictJSON(w, r, maxAdminJSONBytes, &request); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
receipt, err := service.Send(r.Context(), request)
if err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error(), "receipt": receipt})
return
}
httpx.WriteJSON(w, http.StatusOK, receipt)
}))
mux.HandleFunc(prefix+"/offline", wrap(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := service.Offline(r.Context(), r.URL.Query().Get("account_id"), limit)
if err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": items})
case http.MethodDelete:
if err := service.DeleteOffline(r.Context(), r.URL.Query().Get("id")); err != nil {
status := http.StatusBadRequest
if errors.Is(err, ErrOfflineNotFound) {
status = http.StatusNotFound
}
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"deleted": true})
default:
w.Header().Set("Allow", "GET, DELETE")
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
}))
return nil
}
@@ -0,0 +1,81 @@
package onlinepush
import (
"context"
"crypto/sha256"
"encoding/hex"
"strings"
"time"
)
func ctxErr(ctx context.Context) error {
if ctx == nil {
return nil
}
return ctx.Err()
}
func nowUTC(now func() time.Time) time.Time {
if now == nil {
return time.Now().UTC()
}
return now().UTC()
}
func hashID(prefix string, parts ...string) string {
h := sha256.New()
for _, part := range parts {
h.Write([]byte(strings.TrimSpace(part)))
h.Write([]byte{0})
}
sum := h.Sum(nil)
return prefix + hex.EncodeToString(sum[:8])
}
func cloneStringMap(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 {
key = strings.TrimSpace(key)
if key == "" {
continue
}
out[key] = value
}
if len(out) == 0 {
return nil
}
return out
}
func cloneReceipt(in Receipt) Receipt {
in.Errors = append([]string(nil), in.Errors...)
return in
}
func cloneOffline(in OfflineMessage) OfflineMessage {
in.Body = append([]byte(nil), in.Body...)
in.Metadata = cloneStringMap(in.Metadata)
return in
}
func uniqueStrings(values ...[]string) []string {
seen := make(map[string]struct{})
out := make([]string, 0)
for _, batch := range values {
for _, value := range batch {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
}
return out
}
@@ -0,0 +1,170 @@
package onlinepush
import (
"context"
"sort"
"strings"
"sync"
)
type MemoryStore struct {
initOnce sync.Once
mu sync.RWMutex
receipts map[string]Receipt
idempotent map[string]string
offline map[string]OfflineMessage
}
// NewMemoryStore 创建内存版在线推送状态存储。
func NewMemoryStore() *MemoryStore {
store := &MemoryStore{}
store.ensureReady()
return store
}
func (s *MemoryStore) ensureReady() {
s.initOnce.Do(func() {
if s.receipts == nil {
s.receipts = make(map[string]Receipt)
}
if s.idempotent == nil {
s.idempotent = make(map[string]string)
}
if s.offline == nil {
s.offline = make(map[string]OfflineMessage)
}
})
}
// ReserveReceipt 按幂等键预留 receipt;重复请求返回已存在的副本。
func (s *MemoryStore) ReserveReceipt(ctx context.Context, receipt Receipt) (Receipt, bool, error) {
if err := ctxErr(ctx); err != nil {
return Receipt{}, false, err
}
if s == nil {
return Receipt{}, false, ErrStoreRequired
}
s.ensureReady()
s.mu.Lock()
defer s.mu.Unlock()
if receipt.IdempotencyKey != "" {
if id := s.idempotent[receipt.IdempotencyKey]; id != "" {
existing := cloneReceipt(s.receipts[id])
existing.Duplicate = true
return existing, true, nil
}
}
s.receipts[receipt.ID] = cloneReceipt(receipt)
if receipt.IdempotencyKey != "" {
s.idempotent[receipt.IdempotencyKey] = receipt.ID
}
return cloneReceipt(receipt), false, nil
}
// UpdateReceipt 更新 receipt 最终状态。
func (s *MemoryStore) UpdateReceipt(ctx context.Context, receipt Receipt) error {
if err := ctxErr(ctx); err != nil {
return err
}
if s == nil {
return ErrStoreRequired
}
s.ensureReady()
s.mu.Lock()
defer s.mu.Unlock()
if old, ok := s.receipts[receipt.ID]; ok && receipt.CreatedAt.IsZero() {
receipt.CreatedAt = old.CreatedAt
}
s.receipts[receipt.ID] = cloneReceipt(receipt)
if receipt.IdempotencyKey != "" {
s.idempotent[receipt.IdempotencyKey] = receipt.ID
}
return nil
}
// SaveOffline 保存离线消息。
func (s *MemoryStore) SaveOffline(ctx context.Context, message OfflineMessage) error {
if err := ctxErr(ctx); err != nil {
return err
}
if s == nil {
return ErrStoreRequired
}
s.ensureReady()
s.mu.Lock()
s.offline[message.ID] = cloneOffline(message)
s.mu.Unlock()
return nil
}
// ListOffline 查询指定账号的离线消息。
func (s *MemoryStore) ListOffline(ctx context.Context, accountID string, limit int) ([]OfflineMessage, error) {
if err := ctxErr(ctx); err != nil {
return nil, err
}
if s == nil {
return nil, ErrStoreRequired
}
s.ensureReady()
accountID = strings.TrimSpace(accountID)
if limit <= 0 || limit > 100 {
limit = 100
}
s.mu.RLock()
out := make([]OfflineMessage, 0, len(s.offline))
for _, message := range s.offline {
if accountID != "" && message.AccountID != accountID {
continue
}
out = append(out, cloneOffline(message))
}
s.mu.RUnlock()
sort.Slice(out, func(i, j int) bool {
if out[i].CreatedAt.Equal(out[j].CreatedAt) {
return out[i].ID < out[j].ID
}
return out[i].CreatedAt.Before(out[j].CreatedAt)
})
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// DeleteOffline 删除指定离线消息。
func (s *MemoryStore) DeleteOffline(ctx context.Context, id string) error {
if err := ctxErr(ctx); err != nil {
return err
}
if s == nil {
return ErrStoreRequired
}
s.ensureReady()
id = strings.TrimSpace(id)
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.offline[id]; !ok {
return ErrOfflineNotFound
}
delete(s.offline, id)
return nil
}
// Snapshot 返回当前在线推送状态摘要。
func (s *MemoryStore) Snapshot(ctx context.Context) Snapshot {
if ctxErr(ctx) != nil || s == nil {
return Snapshot{}
}
s.ensureReady()
s.mu.RLock()
defer s.mu.RUnlock()
byStatus := make(map[string]int)
for _, receipt := range s.receipts {
byStatus[receipt.Status]++
}
return Snapshot{
Receipts: len(s.receipts),
Offline: len(s.offline),
ByStatus: byStatus,
}
}
@@ -0,0 +1,231 @@
package onlinepush
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"sync"
"time"
cachekit "longheng.io/server/internal/platform/cache"
)
var (
// ErrCacheRequired 表示缓存状态后端缺失。
ErrCacheRequired = errors.New("online push cache is required")
// ErrSQLRequired 表示 SQL 状态后端缺失。
ErrSQLRequired = errors.New("online push sql db is required")
// ErrInvalidKey 表示状态 key 为空或非法。
ErrInvalidKey = errors.New("online push state key is invalid")
// ErrInvalidTable 表示 SQL 表名为空或非法。
ErrInvalidTable = errors.New("online push table is invalid")
)
// CacheStateStore 使用框架 cache.Store 保存在线推送状态快照。
type CacheStateStore struct {
Cache cachekit.Store
Key string
TTL time.Duration
}
// NewCacheStateStore 创建缓存状态后端。
func NewCacheStateStore(cache cachekit.Store, key string, ttl time.Duration) *CacheStateStore {
return &CacheStateStore{Cache: cache, Key: strings.TrimSpace(key), TTL: ttl}
}
// Load 从缓存读取在线推送状态。
func (s *CacheStateStore) Load(ctx context.Context) (State, bool, error) {
if err := ctxErr(ctx); err != nil {
return State{}, false, err
}
if s == nil || s.Cache == nil {
return State{}, false, ErrCacheRequired
}
key := strings.TrimSpace(s.Key)
if key == "" {
return State{}, false, ErrInvalidKey
}
data, ok, err := s.Cache.Get(ctx, key)
if err != nil || !ok {
return State{}, ok, err
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
return State{}, false, err
}
return state, true, nil
}
// Save 将在线推送状态保存到缓存。
func (s *CacheStateStore) Save(ctx context.Context, state State) error {
if err := ctxErr(ctx); err != nil {
return err
}
if s == nil || s.Cache == nil {
return ErrCacheRequired
}
key := strings.TrimSpace(s.Key)
if key == "" {
return ErrInvalidKey
}
data, err := json.Marshal(state)
if err != nil {
return err
}
return s.Cache.Set(ctx, key, data, s.TTL)
}
// Close 关闭缓存状态后端。
func (s *CacheStateStore) Close() error {
return nil
}
// SQLStateStore 使用 SQL 单行 JSON 保存在线推送状态快照。
type SQLStateStore struct {
DB *sql.DB
Table string
Key string
Now func() time.Time
stmts SQLStatements
initMu sync.Mutex
}
// SQLStatements 保存在线推送 SQL 状态后端的语句模板。
type SQLStatements struct {
Table string
EnsureSchema string
Select string
Upsert string
}
var tableNamePattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
// NewSQLStateStore 创建 SQL 状态后端。
func NewSQLStateStore(db *sql.DB, table, key string, now func() time.Time) (*SQLStateStore, error) {
if db == nil {
return nil, ErrSQLRequired
}
key = strings.TrimSpace(key)
if key == "" {
return nil, ErrInvalidKey
}
stmts, err := NewSQLStatements(table)
if err != nil {
return nil, err
}
if now == nil {
now = func() time.Time { return time.Now().UTC() }
}
return &SQLStateStore{DB: db, Table: stmts.Table, Key: key, Now: now, stmts: stmts}, nil
}
// NewSQLStatements 为指定白名单表名生成 SQL 语句。
func NewSQLStatements(table string) (SQLStatements, error) {
table = strings.TrimSpace(table)
if table == "" {
table = "online_push_state"
}
if !tableNamePattern.MatchString(table) {
return SQLStatements{}, ErrInvalidTable
}
quoted := "`" + table + "`"
return SQLStatements{
Table: table,
EnsureSchema: fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
state_key VARCHAR(191) NOT NULL,
state_json JSON NOT NULL,
updated_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (state_key)
)`, quoted),
Select: fmt.Sprintf(`SELECT state_json FROM %s WHERE state_key=?`, quoted),
Upsert: fmt.Sprintf(`INSERT INTO %s (state_key, state_json, updated_at) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE state_json=VALUES(state_json), updated_at=VALUES(updated_at)`, quoted),
}, nil
}
func (s *SQLStateStore) ensureReady(requireKey bool) error {
if s == nil || s.DB == nil {
return ErrSQLRequired
}
s.initMu.Lock()
defer s.initMu.Unlock()
table := strings.TrimSpace(s.Table)
if table == "" {
table = strings.TrimSpace(s.stmts.Table)
}
stmts, err := NewSQLStatements(table)
if err != nil {
return err
}
if s.stmts.Table != stmts.Table || s.stmts.EnsureSchema == "" || s.stmts.Select == "" || s.stmts.Upsert == "" {
s.stmts = stmts
}
s.Table = s.stmts.Table
s.Key = strings.TrimSpace(s.Key)
if requireKey && s.Key == "" {
return ErrInvalidKey
}
if s.Now == nil {
s.Now = func() time.Time { return time.Now().UTC() }
}
return nil
}
// EnsureSchema 确保 SQL 状态表存在。
func (s *SQLStateStore) EnsureSchema(ctx context.Context) error {
if err := ctxErr(ctx); err != nil {
return err
}
if err := s.ensureReady(false); err != nil {
return err
}
_, err := s.DB.ExecContext(ctx, s.stmts.EnsureSchema)
return err
}
// Load 从 SQL 状态表读取在线推送状态。
func (s *SQLStateStore) Load(ctx context.Context) (State, bool, error) {
if err := ctxErr(ctx); err != nil {
return State{}, false, err
}
if err := s.ensureReady(true); err != nil {
return State{}, false, err
}
var data []byte
err := s.DB.QueryRowContext(ctx, s.stmts.Select, s.Key).Scan(&data)
if errors.Is(err, sql.ErrNoRows) {
return State{}, false, nil
}
if err != nil {
return State{}, false, err
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
return State{}, false, err
}
return state, true, nil
}
// Save 将在线推送状态保存到 SQL 状态表。
func (s *SQLStateStore) Save(ctx context.Context, state State) error {
if err := ctxErr(ctx); err != nil {
return err
}
if err := s.ensureReady(true); err != nil {
return err
}
data, err := json.Marshal(state)
if err != nil {
return err
}
_, err = s.DB.ExecContext(ctx, s.stmts.Upsert, s.Key, data, s.Now().UTC()) // #nosec G701 -- Upsert 由 NewSQLStatements 使用白名单表名生成,业务值通过参数绑定。
return err
}
// Close 关闭 SQL 状态后端。
func (s *SQLStateStore) Close() error {
return nil
}
@@ -0,0 +1,317 @@
package onlinepush
import (
"context"
"errors"
"fmt"
"strings"
"time"
"longheng.io/server/internal/platform/bus"
"longheng.io/server/internal/platform/presence"
"longheng.io/server/pkg/contracts"
"longheng.io/server/pkg/protocol"
)
type Options struct {
Bus bus.Bus
Presence presence.Runtime
Store Store
Now func() time.Time
}
// Service 负责把业务消息投递到在线网关或保存为离线消息。
type Service struct {
bus bus.Bus
presence presence.Runtime
store Store
now func() time.Time
}
// New 创建在线推送服务。
func New(options Options) *Service {
store := options.Store
if store == nil {
store = NewMemoryStore()
}
return &Service{
bus: options.Bus,
presence: options.Presence,
store: store,
now: options.Now,
}
}
// Send 按幂等键投递在线推送请求。
func (s *Service) Send(ctx context.Context, request Request) (Receipt, error) {
return s.send(ctx, request, false)
}
// Retry 只对可重试的失败或部分成功 receipt 重新投递。
func (s *Service) Retry(ctx context.Context, request Request) (Receipt, error) {
return s.send(ctx, request, true)
}
func (s *Service) send(ctx context.Context, request Request, retryPartial bool) (Receipt, error) {
if err := ctxErr(ctx); err != nil {
return Receipt{}, err
}
if s == nil || s.bus == nil {
return Receipt{}, ErrBusRequired
}
if s.store == nil {
return Receipt{}, ErrStoreRequired
}
request = normalizeRequest(request, s.now)
if request.IdempotencyKey == "" {
return Receipt{}, ErrIdempotencyRequired
}
if !request.Broadcast && request.SessionID == "" && len(pushAccounts(request)) == 0 {
return Receipt{}, ErrTargetRequired
}
receipt := newReceipt(request, s.now)
receipt, duplicate, err := s.store.ReserveReceipt(ctx, receipt)
if err != nil {
return receipt, err
}
if duplicate {
if !retryPartial || !retryableReceipt(receipt) {
return receipt, nil
}
receipt = resetReceiptForRetry(receipt, request, s.now)
}
receipt = s.deliver(ctx, request, receipt)
receipt.UpdatedAt = nowUTC(s.now)
if err := s.store.UpdateReceipt(ctx, receipt); err != nil {
return receipt, err
}
if len(receipt.Errors) > 0 {
return receipt, errors.New(strings.Join(receipt.Errors, "; "))
}
return receipt, nil
}
// Offline 查询指定账号的离线消息。
func (s *Service) Offline(ctx context.Context, accountID string, limit int) ([]OfflineMessage, error) {
if s == nil || s.store == nil {
return nil, ErrStoreRequired
}
return s.store.ListOffline(ctx, accountID, limit)
}
// DeleteOffline 删除指定离线消息。
func (s *Service) DeleteOffline(ctx context.Context, id string) error {
if s == nil || s.store == nil {
return ErrStoreRequired
}
return s.store.DeleteOffline(ctx, id)
}
// Snapshot 返回在线推送服务状态摘要。
func (s *Service) Snapshot(ctx context.Context) Snapshot {
if s == nil || s.store == nil {
return Snapshot{}
}
return s.store.Snapshot(ctx)
}
func (s *Service) deliver(ctx context.Context, request Request, receipt Receipt) Receipt {
if request.Broadcast {
if err := s.publish(ctx, "", gatewayPushFor(request, "", "", "")); err != nil {
receipt.Errors = append(receipt.Errors, err.Error())
} else {
receipt.Published++
}
return finalizeReceipt(receipt)
}
if request.SessionID != "" {
if err := s.publish(ctx, request.TargetGatewayNodeID, gatewayPushFor(request, request.TargetGatewayNodeID, request.SessionID, request.AccountID)); err != nil {
receipt.Errors = append(receipt.Errors, err.Error())
} else {
receipt.Published++
}
return finalizeReceipt(receipt)
}
for _, accountID := range pushAccounts(request) {
receipt = s.deliverAccount(ctx, request, receipt, accountID)
}
return finalizeReceipt(receipt)
}
func (s *Service) deliverAccount(ctx context.Context, request Request, receipt Receipt, accountID string) Receipt {
if s.presence == nil {
if err := s.publish(ctx, request.TargetGatewayNodeID, gatewayPushFor(request, request.TargetGatewayNodeID, "", accountID)); err != nil {
receipt.Errors = append(receipt.Errors, err.Error())
} else {
receipt.Published++
}
return receipt
}
items, err := s.presence.Presence(ctx, accountID)
if err != nil {
receipt.Errors = append(receipt.Errors, err.Error())
return receipt
}
if len(items) == 0 {
return s.saveOrDropOffline(ctx, request, receipt, accountID)
}
for _, item := range items {
if err := s.publish(ctx, item.NodeID, gatewayPushFor(request, item.NodeID, item.SessionID, accountID)); err != nil {
receipt.Errors = append(receipt.Errors, err.Error())
continue
}
receipt.Published++
}
return receipt
}
func (s *Service) saveOrDropOffline(ctx context.Context, request Request, receipt Receipt, accountID string) Receipt {
if request.OfflinePolicy == OfflineDrop {
receipt.Dropped++
return receipt
}
message := OfflineMessage{
ID: hashID("offline-", receipt.ID, accountID),
ReceiptID: receipt.ID,
AccountID: accountID,
PacketID: request.PacketID,
MsgID: request.MsgID,
Body: append([]byte(nil), request.Body...),
Metadata: cloneStringMap(request.Metadata),
Note: request.Note,
Status: StatusOffline,
CreatedAt: nowUTC(s.now),
}
if err := s.store.SaveOffline(ctx, message); err != nil {
receipt.Errors = append(receipt.Errors, err.Error())
return receipt
}
receipt.Offline++
return receipt
}
func (s *Service) publish(ctx context.Context, nodeID string, push contracts.GatewayPush) error {
topic := contracts.TopicGatewayPush
if nodeID = strings.TrimSpace(nodeID); nodeID != "" {
topic = contracts.GatewayNodePushTopic(nodeID)
}
return s.bus.Publish(ctx, topic, push)
}
func gatewayPushFor(request Request, nodeID string, sessionID string, accountID string) contracts.GatewayPush {
return contracts.GatewayPush{
TargetGatewayNodeID: strings.TrimSpace(nodeID),
SessionID: strings.TrimSpace(sessionID),
AccountID: strings.TrimSpace(accountID),
Broadcast: request.Broadcast,
PacketID: request.PacketID,
MsgID: request.MsgID,
Sequence: request.Sequence,
WireFormat: request.WireFormat,
Compressed: request.Compressed,
Body: append([]byte(nil), request.Body...),
Metadata: cloneStringMap(request.Metadata),
Note: request.Note,
CreatedAt: request.CreatedAt,
}
}
func normalizeRequest(request Request, now func() time.Time) Request {
request.ID = strings.TrimSpace(request.ID)
request.IdempotencyKey = strings.TrimSpace(request.IdempotencyKey)
request.AccountID = strings.TrimSpace(request.AccountID)
request.AccountIDs = uniqueStrings([]string{request.AccountID}, request.AccountIDs)
request.SessionID = strings.TrimSpace(request.SessionID)
request.TargetGatewayNodeID = strings.TrimSpace(request.TargetGatewayNodeID)
request.WireFormat = protocol.NormalizeWireFormat(strings.TrimSpace(request.WireFormat))
request.Metadata = cloneStringMap(request.Metadata)
request.Note = strings.TrimSpace(request.Note)
request.OfflinePolicy = strings.ToLower(strings.TrimSpace(request.OfflinePolicy))
if request.OfflinePolicy == "" {
request.OfflinePolicy = OfflineStore
}
if request.CreatedAt.IsZero() {
request.CreatedAt = nowUTC(now)
} else {
request.CreatedAt = request.CreatedAt.UTC()
}
request.Body = append([]byte(nil), request.Body...)
return request
}
func newReceipt(request Request, now func() time.Time) Receipt {
id := request.ID
if id == "" {
id = hashID("push-", request.IdempotencyKey, describeTarget(request))
}
at := nowUTC(now)
return Receipt{
ID: id,
RequestID: request.ID,
IdempotencyKey: request.IdempotencyKey,
Target: describeTarget(request),
Status: StatusAccepted,
CreatedAt: at,
UpdatedAt: at,
}
}
func finalizeReceipt(receipt Receipt) Receipt {
switch {
case len(receipt.Errors) > 0 && receipt.Published == 0 && receipt.Offline == 0 && receipt.Dropped == 0:
receipt.Status = StatusFailed
case len(receipt.Errors) > 0 || (receipt.Published > 0 && (receipt.Offline > 0 || receipt.Dropped > 0)):
receipt.Status = StatusPartial
case receipt.Published > 0:
receipt.Status = StatusPublished
case receipt.Offline > 0:
receipt.Status = StatusOffline
case receipt.Dropped > 0:
receipt.Status = StatusDropped
default:
receipt.Status = StatusFailed
receipt.Errors = append(receipt.Errors, "no target was delivered")
}
return receipt
}
func retryableReceipt(receipt Receipt) bool {
switch receipt.Status {
case StatusAccepted, StatusPartial, StatusFailed:
return true
default:
return false
}
}
func resetReceiptForRetry(receipt Receipt, request Request, now func() time.Time) Receipt {
receipt.Duplicate = false
receipt.Target = describeTarget(request)
receipt.Published = 0
receipt.Offline = 0
receipt.Dropped = 0
receipt.Errors = nil
receipt.Status = StatusAccepted
receipt.UpdatedAt = nowUTC(now)
return receipt
}
func pushAccounts(request Request) []string {
return uniqueStrings(request.AccountIDs)
}
func describeTarget(request Request) string {
switch {
case request.Broadcast:
return "broadcast"
case request.SessionID != "":
return "session:" + request.SessionID
case len(request.AccountIDs) == 1:
return "account:" + request.AccountIDs[0]
case len(request.AccountIDs) > 1:
return fmt.Sprintf("accounts:%d", len(request.AccountIDs))
default:
return ""
}
}
@@ -0,0 +1,252 @@
package onlinepush
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"sync"
"longheng.io/server/internal/platform/filex"
)
// ErrStateStoreRequired 表示在线推送持久化状态后端缺失。
var ErrStateStoreRequired = errors.New("online push state store is required")
// StateStore 定义在线推送 receipt/offline 状态的持久化后端。
type StateStore interface {
Load(context.Context) (State, bool, error)
Save(context.Context, State) error
Close() error
}
// State 是在线推送持久化快照。
type State struct {
Receipts []Receipt `json:"receipts,omitempty"`
Idempotency map[string]string `json:"idempotency,omitempty"`
Offline []OfflineMessage `json:"offline,omitempty"`
}
// PersistentStore 在内存 store 外包一层持久化保存和失败回滚。
type PersistentStore struct {
memory *MemoryStore
store StateStore
saveMu sync.Mutex
}
// NewPersistentStore 从后端加载状态并创建持久化 store。
func NewPersistentStore(ctx context.Context, store StateStore) (*PersistentStore, error) {
if err := ctxErr(ctx); err != nil {
return nil, err
}
if store == nil {
return nil, ErrStateStoreRequired
}
memory := NewMemoryStore()
if state, ok, err := store.Load(ctx); err != nil {
return nil, err
} else if ok {
memory.importState(state)
}
return &PersistentStore{memory: memory, store: store}, nil
}
// ReserveReceipt 预留 receipt 并持久化。
func (s *PersistentStore) ReserveReceipt(ctx context.Context, receipt Receipt) (Receipt, bool, error) {
if s == nil || s.memory == nil {
return Receipt{}, false, ErrStateStoreRequired
}
s.saveMu.Lock()
before := s.memory.exportState()
result, duplicate, err := s.memory.ReserveReceipt(ctx, receipt)
if err != nil || duplicate {
s.saveMu.Unlock()
return result, duplicate, err
}
return result, duplicate, s.persistLocked(ctx, before, nil)
}
// UpdateReceipt 更新 receipt 并持久化。
func (s *PersistentStore) UpdateReceipt(ctx context.Context, receipt Receipt) error {
if s == nil || s.memory == nil {
return ErrStateStoreRequired
}
s.saveMu.Lock()
before := s.memory.exportState()
err := s.memory.UpdateReceipt(ctx, receipt)
return s.persistLocked(ctx, before, err)
}
// SaveOffline 保存离线消息并持久化。
func (s *PersistentStore) SaveOffline(ctx context.Context, message OfflineMessage) error {
if s == nil || s.memory == nil {
return ErrStateStoreRequired
}
s.saveMu.Lock()
before := s.memory.exportState()
err := s.memory.SaveOffline(ctx, message)
return s.persistLocked(ctx, before, err)
}
// ListOffline 查询离线消息。
func (s *PersistentStore) ListOffline(ctx context.Context, accountID string, limit int) ([]OfflineMessage, error) {
if s == nil || s.memory == nil {
return nil, ErrStateStoreRequired
}
return s.memory.ListOffline(ctx, accountID, limit)
}
// DeleteOffline 删除离线消息并持久化。
func (s *PersistentStore) DeleteOffline(ctx context.Context, id string) error {
if s == nil || s.memory == nil {
return ErrStateStoreRequired
}
s.saveMu.Lock()
before := s.memory.exportState()
err := s.memory.DeleteOffline(ctx, id)
return s.persistLocked(ctx, before, err)
}
// Snapshot 返回持久化 store 的内存摘要。
func (s *PersistentStore) Snapshot(ctx context.Context) Snapshot {
if s == nil || s.memory == nil {
return Snapshot{}
}
return s.memory.Snapshot(ctx)
}
// Close 关闭底层持久化后端。
func (s *PersistentStore) Close() error {
if s == nil || s.store == nil {
return nil
}
s.saveMu.Lock()
defer s.saveMu.Unlock()
return s.store.Close()
}
// saveMu 覆盖内存变更到失败回滚的完整窗口,否则一次落盘失败会覆盖并发已提交状态。
func (s *PersistentStore) persistLocked(ctx context.Context, before State, err error) error {
if s == nil {
return ErrStateStoreRequired
}
defer s.saveMu.Unlock()
if err != nil {
return err
}
if s.memory == nil || s.store == nil {
return ErrStateStoreRequired
}
if saveErr := s.store.Save(ctx, s.memory.exportState()); saveErr != nil {
s.memory.importState(before)
return saveErr
}
return nil
}
type JSONFileStateStore struct {
Path string
}
// NewJSONFileStateStore 创建 JSON 文件状态后端。
func NewJSONFileStateStore(path string) *JSONFileStateStore {
return &JSONFileStateStore{Path: path}
}
// Load 从 JSON 文件加载在线推送状态。
func (s *JSONFileStateStore) Load(ctx context.Context) (State, bool, error) {
if err := ctxErr(ctx); err != nil {
return State{}, false, err
}
if s == nil || s.Path == "" {
return State{}, false, ErrStateStoreRequired
}
data, err := os.ReadFile(s.Path)
if errors.Is(err, os.ErrNotExist) {
return State{}, false, nil
}
if err != nil {
return State{}, false, err
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
return State{}, false, err
}
return state, true, nil
}
// Save 将在线推送状态原子写入 JSON 文件。
func (s *JSONFileStateStore) Save(ctx context.Context, state State) error {
if err := ctxErr(ctx); err != nil {
return err
}
if s == nil || s.Path == "" {
return ErrStateStoreRequired
}
path := filepath.Clean(s.Path)
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return filex.AtomicWriteFile(path, data, 0o600)
}
// Close 关闭 JSON 文件状态后端。
func (s *JSONFileStateStore) Close() error {
return nil
}
func (s *MemoryStore) exportState() State {
s.mu.RLock()
defer s.mu.RUnlock()
receipts := make([]Receipt, 0, len(s.receipts))
for _, receipt := range s.receipts {
receipts = append(receipts, cloneReceipt(receipt))
}
sort.Slice(receipts, func(i, j int) bool { return receipts[i].ID < receipts[j].ID })
offline := make([]OfflineMessage, 0, len(s.offline))
for _, message := range s.offline {
offline = append(offline, cloneOffline(message))
}
sort.Slice(offline, func(i, j int) bool { return offline[i].ID < offline[j].ID })
idempotency := make(map[string]string, len(s.idempotent))
for key, value := range s.idempotent {
idempotency[key] = value
}
if len(idempotency) == 0 {
idempotency = nil
}
return State{Receipts: receipts, Idempotency: idempotency, Offline: offline}
}
func (s *MemoryStore) importState(state State) {
s.mu.Lock()
defer s.mu.Unlock()
s.receipts = make(map[string]Receipt, len(state.Receipts))
s.idempotent = make(map[string]string, len(state.Idempotency))
s.offline = make(map[string]OfflineMessage, len(state.Offline))
for _, receipt := range state.Receipts {
receipt = cloneReceipt(receipt)
if receipt.ID == "" {
continue
}
s.receipts[receipt.ID] = receipt
if receipt.IdempotencyKey != "" {
s.idempotent[receipt.IdempotencyKey] = receipt.ID
}
}
for key, value := range state.Idempotency {
if key != "" && value != "" {
s.idempotent[key] = value
}
}
for _, message := range state.Offline {
message = cloneOffline(message)
if message.ID == "" {
continue
}
s.offline[message.ID] = message
}
}
@@ -0,0 +1,13 @@
package onlinepush
import "context"
// Store 定义在线推送 receipt 和离线消息状态存储。
type Store interface {
ReserveReceipt(context.Context, Receipt) (Receipt, bool, error)
UpdateReceipt(context.Context, Receipt) error
SaveOffline(context.Context, OfflineMessage) error
ListOffline(context.Context, string, int) ([]OfflineMessage, error)
DeleteOffline(context.Context, string) error
Snapshot(context.Context) Snapshot
}
@@ -0,0 +1,97 @@
package onlinepush
import (
"errors"
"time"
)
var (
// ErrBusRequired 表示在线推送缺少事件总线。
ErrBusRequired = errors.New("online push bus is required")
// ErrStoreRequired 表示在线推送缺少状态存储。
ErrStoreRequired = errors.New("online push store is required")
// ErrTargetRequired 表示请求没有任何可投递目标。
ErrTargetRequired = errors.New("online push target is required")
// ErrIdempotencyRequired 表示请求缺少幂等键。
ErrIdempotencyRequired = errors.New("online push idempotency key is required")
// ErrOfflineNotFound 表示离线消息不存在。
ErrOfflineNotFound = errors.New("online push offline message not found")
)
const (
// OfflineStore 表示账号离线时保存离线消息。
OfflineStore = "store"
// OfflineDrop 表示账号离线时直接丢弃消息。
OfflineDrop = "drop"
// StatusAccepted 表示 receipt 已被接收但尚未完成投递。
StatusAccepted = "accepted"
// StatusPublished 表示消息已发布到网关。
StatusPublished = "published"
// StatusOffline 表示消息已转为离线存储。
StatusOffline = "offline"
// StatusPartial 表示部分目标成功、部分目标失败或离线。
StatusPartial = "partial"
// StatusDropped 表示消息按策略被丢弃。
StatusDropped = "dropped"
// StatusFailed 表示消息投递失败。
StatusFailed = "failed"
)
// Request 是在线推送入口请求。
type Request struct {
ID string `json:"id,omitempty"`
IdempotencyKey string `json:"idempotency_key"`
AccountID string `json:"account_id,omitempty"`
AccountIDs []string `json:"account_ids,omitempty"`
SessionID string `json:"session_id,omitempty"`
TargetGatewayNodeID string `json:"target_gateway_node_id,omitempty"`
Broadcast bool `json:"broadcast,omitempty"`
PacketID int32 `json:"packet_id"`
MsgID uint32 `json:"msg_id"`
Sequence uint64 `json:"sequence,omitempty"`
WireFormat string `json:"wire_format,omitempty"`
Compressed bool `json:"compressed,omitempty"`
Body []byte `json:"body,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Note string `json:"note,omitempty"`
OfflinePolicy string `json:"offline_policy,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
// Receipt 记录一次在线推送请求的投递结果。
type Receipt struct {
ID string `json:"id"`
RequestID string `json:"request_id,omitempty"`
IdempotencyKey string `json:"idempotency_key"`
Target string `json:"target"`
Status string `json:"status"`
Published int `json:"published"`
Offline int `json:"offline"`
Dropped int `json:"dropped"`
Errors []string `json:"errors,omitempty"`
Duplicate bool `json:"duplicate,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// OfflineMessage 表示账号离线时保存的待处理消息。
type OfflineMessage struct {
ID string `json:"id"`
ReceiptID string `json:"receipt_id"`
AccountID string `json:"account_id"`
PacketID int32 `json:"packet_id"`
MsgID uint32 `json:"msg_id"`
Body []byte `json:"body,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Note string `json:"note,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// Snapshot 是在线推送状态摘要。
type Snapshot struct {
Receipts int `json:"receipts"`
Offline int `json:"offline"`
ByStatus map[string]int `json:"by_status,omitempty"`
}