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,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"`
|
||||
}
|
||||
Reference in new issue
Block a user