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

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

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 ""
}
}