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