服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
429 lines
10 KiB
Go
429 lines
10 KiB
Go
package bus
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
|
|
"longheng.io/server/pkg/contracts"
|
|
)
|
|
|
|
type NATS struct {
|
|
logger *slog.Logger
|
|
prefix string
|
|
wireEncoding string
|
|
|
|
mu sync.Mutex
|
|
closed bool
|
|
conn *nats.Conn
|
|
metrics *Metrics
|
|
}
|
|
|
|
type NATSOptions struct {
|
|
Name string
|
|
Timeout time.Duration
|
|
PingInterval time.Duration
|
|
MaxPingsOutstanding int
|
|
MaxReconnects int
|
|
ReconnectWait time.Duration
|
|
RetryOnFailedConnect bool
|
|
WireEncoding string
|
|
Token string
|
|
Username string
|
|
Password string
|
|
Credentials string
|
|
TLSEnabled bool
|
|
TLSServerName string
|
|
CAFile string
|
|
CertFile string
|
|
KeyFile string
|
|
}
|
|
|
|
func DefaultNATSOptions() NATSOptions {
|
|
return NATSOptions{
|
|
Name: "longheng-bus",
|
|
Timeout: 5 * time.Second,
|
|
PingInterval: time.Second,
|
|
MaxPingsOutstanding: 10,
|
|
MaxReconnects: -1,
|
|
ReconnectWait: time.Second,
|
|
RetryOnFailedConnect: true,
|
|
WireEncoding: NATSWireBinary,
|
|
}
|
|
}
|
|
|
|
func normalizeNATSOptions(options NATSOptions) NATSOptions {
|
|
defaults := DefaultNATSOptions()
|
|
if strings.TrimSpace(options.Name) == "" {
|
|
options.Name = defaults.Name
|
|
}
|
|
if options.Timeout <= 0 {
|
|
options.Timeout = defaults.Timeout
|
|
}
|
|
if options.PingInterval <= 0 {
|
|
options.PingInterval = defaults.PingInterval
|
|
}
|
|
if options.MaxPingsOutstanding <= 0 {
|
|
options.MaxPingsOutstanding = defaults.MaxPingsOutstanding
|
|
}
|
|
if options.MaxReconnects == 0 {
|
|
options.MaxReconnects = defaults.MaxReconnects
|
|
}
|
|
if options.ReconnectWait <= 0 {
|
|
options.ReconnectWait = defaults.ReconnectWait
|
|
}
|
|
if !options.RetryOnFailedConnect {
|
|
options.RetryOnFailedConnect = defaults.RetryOnFailedConnect
|
|
}
|
|
if !isNATSWireEncoding(options.WireEncoding) {
|
|
options.WireEncoding = defaults.WireEncoding
|
|
}
|
|
return options
|
|
}
|
|
|
|
// SetMetrics 在构造后挂接 Metrics 发射器,可与 Publish/Subscribe 并发调用。
|
|
func (b *NATS) SetMetrics(m *Metrics) {
|
|
if b == nil {
|
|
return
|
|
}
|
|
b.mu.Lock()
|
|
b.metrics = m
|
|
b.mu.Unlock()
|
|
}
|
|
|
|
func NewNATS(endpoints []string, namespace string, logger *slog.Logger) (*NATS, error) {
|
|
return NewNATSWithOptions(endpoints, namespace, logger, DefaultNATSOptions())
|
|
}
|
|
|
|
func NewNATSWithOptions(endpoints []string, namespace string, logger *slog.Logger, options NATSOptions) (*NATS, error) {
|
|
endpoints = normNATSEndpoints(endpoints)
|
|
if len(endpoints) == 0 {
|
|
return nil, fmt.Errorf("nats bus requires at least one endpoint")
|
|
}
|
|
options = normalizeNATSOptions(options)
|
|
|
|
connectOptions := []nats.Option{
|
|
nats.Name(options.Name),
|
|
nats.Timeout(options.Timeout),
|
|
nats.PingInterval(options.PingInterval),
|
|
nats.MaxPingsOutstanding(options.MaxPingsOutstanding),
|
|
nats.RetryOnFailedConnect(options.RetryOnFailedConnect),
|
|
nats.MaxReconnects(options.MaxReconnects),
|
|
nats.ReconnectWait(options.ReconnectWait),
|
|
}
|
|
connectOptions = appendNATSSecOpts(connectOptions, options)
|
|
conn, err := nats.Connect(
|
|
strings.Join(endpoints, ","),
|
|
connectOptions...,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &NATS{
|
|
logger: logger,
|
|
prefix: strings.Trim(namespace, "."),
|
|
wireEncoding: options.WireEncoding,
|
|
conn: conn,
|
|
}, nil
|
|
}
|
|
|
|
func normNATSEndpoints(endpoints []string) []string {
|
|
normalized := make([]string, 0, len(endpoints))
|
|
for _, endpoint := range endpoints {
|
|
endpoint = strings.TrimSpace(endpoint)
|
|
if endpoint != "" {
|
|
normalized = append(normalized, endpoint)
|
|
}
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func appendNATSSecOpts(connectOptions []nats.Option, options NATSOptions) []nats.Option {
|
|
if options.Token != "" {
|
|
connectOptions = append(connectOptions, nats.Token(options.Token))
|
|
}
|
|
if options.Username != "" || options.Password != "" {
|
|
connectOptions = append(connectOptions, nats.UserInfo(options.Username, options.Password))
|
|
}
|
|
if options.Credentials != "" {
|
|
connectOptions = append(connectOptions, nats.UserCredentials(options.Credentials))
|
|
}
|
|
if options.TLSEnabled {
|
|
connectOptions = append(connectOptions, nats.Secure(&tls.Config{
|
|
MinVersion: tls.VersionTLS12,
|
|
ServerName: options.TLSServerName,
|
|
}))
|
|
}
|
|
if options.CAFile != "" {
|
|
connectOptions = append(connectOptions, nats.RootCAs(options.CAFile))
|
|
}
|
|
if options.CertFile != "" || options.KeyFile != "" {
|
|
connectOptions = append(connectOptions, nats.ClientCert(options.CertFile, options.KeyFile))
|
|
}
|
|
return connectOptions
|
|
}
|
|
|
|
func (b *NATS) Publish(ctx context.Context, topic string, payload any) error {
|
|
return b.publishEnvelope(ctx, Envelope{Topic: topic, Payload: payload, PublishedAt: time.Now().UTC()})
|
|
}
|
|
|
|
func (b *NATS) publishEnvelope(ctx context.Context, env Envelope) error {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
if b != nil {
|
|
b.mu.Lock()
|
|
metricsRef := b.metrics
|
|
b.mu.Unlock()
|
|
metricsRef.recordPublish(classifyHandlerError(err))
|
|
}
|
|
return err
|
|
}
|
|
if b == nil {
|
|
return ErrClosed
|
|
}
|
|
|
|
// NATS core publish 只提供在线投递,不保存消息、不等待消费者确认;关键状态变更必须走 EventLog/Outbox。
|
|
b.mu.Lock()
|
|
if b.closed {
|
|
metricsRef := b.metrics
|
|
b.mu.Unlock()
|
|
metricsRef.recordPublish(ResultClosed)
|
|
return ErrClosed
|
|
}
|
|
conn := b.conn
|
|
metricsRef := b.metrics
|
|
b.mu.Unlock()
|
|
if conn == nil {
|
|
metricsRef.recordPublish(ResultClosed)
|
|
return ErrClosed
|
|
}
|
|
|
|
body, err := contracts.MarshalTopicPayload(env.Payload)
|
|
if err != nil {
|
|
metricsRef.recordPublish(ResultError)
|
|
return err
|
|
}
|
|
if env.PublishedAt.IsZero() {
|
|
env.PublishedAt = time.Now().UTC()
|
|
}
|
|
env = envelopeWithTrace(ctx, env)
|
|
|
|
wire, err := encodeNATSWire(natsWireMessage{
|
|
Topic: env.Topic,
|
|
Payload: body,
|
|
PublishedAt: env.PublishedAt,
|
|
Reply: env.Reply,
|
|
RequestID: env.RequestID,
|
|
Trace: env.Trace,
|
|
}, b.wireEncoding)
|
|
if err != nil {
|
|
metricsRef.recordPublish(ResultError)
|
|
return err
|
|
}
|
|
|
|
if err := conn.Publish(b.subject(env.Topic), wire); err != nil {
|
|
metricsRef.recordPublish(ResultError)
|
|
return err
|
|
}
|
|
metricsRef.recordPublish(ResultOK)
|
|
metricsRef.recordPublishBytes(int64(len(body)))
|
|
return nil
|
|
}
|
|
|
|
func (b *NATS) Request(ctx context.Context, topic string, payload any, timeout time.Duration) ([]byte, error) {
|
|
return requestWithPubSub(ctx, topic, payload, timeout, b.Subscribe, b.publishEnvelope)
|
|
}
|
|
|
|
func (b *NATS) Subscribe(topic string, handler Handler) (Subscription, error) {
|
|
return b.subscribe(topic, "", handler)
|
|
}
|
|
|
|
func (b *NATS) QueueSubscribe(topic string, queue string, handler Handler) (Subscription, error) {
|
|
queue = normalizeQueueName(queue)
|
|
if queue == "" {
|
|
return b.Subscribe(topic, handler)
|
|
}
|
|
return b.subscribe(topic, queue, handler)
|
|
}
|
|
|
|
func (b *NATS) subscribe(topic string, queue string, handler Handler) (Subscription, error) {
|
|
if handler == nil {
|
|
return nil, ErrHandlerRequired
|
|
}
|
|
if b == nil {
|
|
return nil, ErrClosed
|
|
}
|
|
b.mu.Lock()
|
|
if b.closed {
|
|
metricsRef := b.metrics
|
|
b.mu.Unlock()
|
|
metricsRef.recordSubscribe(ResultClosed)
|
|
return nil, ErrClosed
|
|
}
|
|
conn := b.conn
|
|
metricsRef := b.metrics
|
|
b.mu.Unlock()
|
|
if conn == nil {
|
|
metricsRef.recordSubscribe(ResultClosed)
|
|
return nil, ErrClosed
|
|
}
|
|
|
|
// 回调里只做解包和 handler deadline 保护;超时只能记录失败,不能中断已经进入业务 handler 的 goroutine。
|
|
callback := func(msg *nats.Msg) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
if b.logger != nil {
|
|
b.logger.Error("nats handler panic recovered", "topic", topic, "panic", rec)
|
|
}
|
|
metricsRef.recordHandler(ResultPanic)
|
|
}
|
|
}()
|
|
|
|
wire, err := decodeNATSWire(msg.Data)
|
|
if err != nil {
|
|
if b.logger != nil {
|
|
b.logger.Error("nats unmarshal failed", "topic", topic, "error", err)
|
|
}
|
|
metricsRef.recordHandler(ResultError)
|
|
return
|
|
}
|
|
|
|
var payload any = json.RawMessage(wire.Payload)
|
|
if !isRequestReplyTopic(topic) {
|
|
decoded, err := contracts.UnmarshalTopicPayload(topic, wire.Payload)
|
|
if err != nil {
|
|
if b.logger != nil {
|
|
b.logger.Error("nats payload decode failed", "topic", topic, "error", err)
|
|
}
|
|
metricsRef.recordHandler(ResultError)
|
|
return
|
|
}
|
|
payload = decoded
|
|
}
|
|
|
|
publishedAt := wire.PublishedAt
|
|
if publishedAt.IsZero() {
|
|
publishedAt = time.Now().UTC()
|
|
}
|
|
reply := wire.Reply
|
|
if reply == "" {
|
|
reply = msg.Reply
|
|
}
|
|
|
|
env := Envelope{
|
|
Topic: topic,
|
|
Payload: payload,
|
|
PublishedAt: publishedAt,
|
|
Reply: reply,
|
|
RequestID: wire.RequestID,
|
|
Trace: wire.Trace,
|
|
}
|
|
deliverWithDeadline(contextWithTrace(context.Background(), env), b.logger, topic, defHandlerTimeout, env, handler, metricsRef)
|
|
}
|
|
var sub *nats.Subscription
|
|
var err error
|
|
if queue == "" {
|
|
sub, err = conn.Subscribe(b.subject(topic), callback)
|
|
} else {
|
|
sub, err = conn.QueueSubscribe(b.subject(topic), queue, callback)
|
|
}
|
|
if err != nil {
|
|
metricsRef.recordSubscribe(ResultError)
|
|
return nil, err
|
|
}
|
|
|
|
metricsRef.recordSubscribe(ResultOK)
|
|
metricsRef.trackSubscription(1)
|
|
return &natsSubscription{sub: sub, metrics: metricsRef}, nil
|
|
}
|
|
|
|
func (b *NATS) Check(ctx context.Context) error {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if b == nil {
|
|
return ErrClosed
|
|
}
|
|
b.mu.Lock()
|
|
if b.closed {
|
|
b.mu.Unlock()
|
|
return ErrClosed
|
|
}
|
|
conn := b.conn
|
|
b.mu.Unlock()
|
|
|
|
if conn == nil {
|
|
return ErrClosed
|
|
}
|
|
return conn.FlushWithContext(ctx)
|
|
}
|
|
|
|
func (b *NATS) Close() error {
|
|
if b == nil {
|
|
return nil
|
|
}
|
|
b.mu.Lock()
|
|
if b.closed {
|
|
b.mu.Unlock()
|
|
return nil
|
|
}
|
|
b.closed = true
|
|
conn := b.conn
|
|
b.conn = nil
|
|
b.mu.Unlock()
|
|
|
|
// Drain 给正在发送的消息一个收敛窗口,但 NATS core 仍不是持久队列;关闭期间丢失的 live 消息不做补偿。
|
|
if conn == nil {
|
|
return nil
|
|
}
|
|
if err := conn.Drain(); err != nil {
|
|
conn.Close()
|
|
return err
|
|
}
|
|
conn.Close()
|
|
return nil
|
|
}
|
|
|
|
func (b *NATS) subject(topic string) string {
|
|
if b.prefix == "" {
|
|
return topic
|
|
}
|
|
return b.prefix + "." + topic
|
|
}
|
|
|
|
type natsSubscription struct {
|
|
sub *nats.Subscription
|
|
metrics *Metrics
|
|
closed bool
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (s *natsSubscription) Close() error {
|
|
if s == nil || s.sub == nil {
|
|
return nil
|
|
}
|
|
s.mu.Lock()
|
|
if s.closed {
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
s.closed = true
|
|
s.mu.Unlock()
|
|
err := s.sub.Unsubscribe()
|
|
s.metrics.trackSubscription(-1)
|
|
return err
|
|
}
|