服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
389 lines
11 KiB
Go
389 lines
11 KiB
Go
package eventlog
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/platform/bus"
|
||
"longheng.io/server/pkg/contracts"
|
||
)
|
||
|
||
var (
|
||
// ErrInvalidPublisherConfig 表示发布器配置缺少必要字段。
|
||
ErrInvalidPublisherConfig = errors.New("eventlog publisher config is invalid")
|
||
|
||
// ErrPermanentPublishFailure 表示发布失败不可重试,应直接进入死信。
|
||
ErrPermanentPublishFailure = errors.New("eventlog publisher failure is permanent")
|
||
)
|
||
|
||
const (
|
||
// HeaderOriginalTopic 表示 outbox 事件需要恢复投递到的原始业务 topic。
|
||
HeaderOriginalTopic = "original_topic"
|
||
)
|
||
|
||
// TopicFunc 根据事件计算下游 topic。
|
||
type TopicFunc func(Event) string
|
||
|
||
// PayloadFunc 根据事件构造 Bus 发布负载。
|
||
type PayloadFunc func(Event) (any, error)
|
||
|
||
// FanoutPublisher 将同一事件投递给多个 Publisher。
|
||
type FanoutPublisher struct {
|
||
publishers []Publisher
|
||
}
|
||
|
||
// NewFanoutPublisher 创建扇出发布器并过滤 nil publisher。
|
||
func NewFanoutPublisher(publishers ...Publisher) (*FanoutPublisher, error) {
|
||
filtered := make([]Publisher, 0, len(publishers))
|
||
for _, publisher := range publishers {
|
||
if publisher != nil {
|
||
filtered = append(filtered, publisher)
|
||
}
|
||
}
|
||
if len(filtered) == 0 {
|
||
return nil, ErrPublisherRequired
|
||
}
|
||
return &FanoutPublisher{publishers: filtered}, nil
|
||
}
|
||
|
||
// Publish 依次调用所有子 publisher,并聚合发布错误。
|
||
func (p *FanoutPublisher) Publish(ctx context.Context, event Event) error {
|
||
if p == nil || len(p.publishers) == 0 {
|
||
return ErrPublisherRequired
|
||
}
|
||
var errs []error
|
||
// Fanout 会尝试所有 publisher 并聚合错误;任一失败都会让 EventLog 保持可重试状态。
|
||
for _, publisher := range p.publishers {
|
||
if err := publisher.Publish(ctx, event); err != nil {
|
||
errs = append(errs, err)
|
||
}
|
||
}
|
||
return errors.Join(errs...)
|
||
}
|
||
|
||
// Message 是发送给通用消息下游的规范化事件消息。
|
||
type Message struct {
|
||
Topic string `json:"topic"`
|
||
Key string `json:"key,omitempty"`
|
||
ContentType string `json:"content_type,omitempty"`
|
||
Payload []byte `json:"payload,omitempty"`
|
||
Headers map[string]string `json:"headers,omitempty"`
|
||
}
|
||
|
||
// MessageSink 是通用消息发布下游的最小接口。
|
||
type MessageSink interface {
|
||
PublishMessage(context.Context, Message) error
|
||
}
|
||
|
||
// MessageSinkFunc 允许用函数快速实现 MessageSink。
|
||
type MessageSinkFunc func(context.Context, Message) error
|
||
|
||
// PublishMessage 调用底层函数并保护 nil 函数场景。
|
||
func (fn MessageSinkFunc) PublishMessage(ctx context.Context, message Message) error {
|
||
if fn == nil {
|
||
return ErrPublisherRequired
|
||
}
|
||
return fn(ctx, message)
|
||
}
|
||
|
||
// KeyFunc 根据事件计算消息 key。
|
||
type KeyFunc func(Event) string
|
||
|
||
// MessagePublisherOptions 描述通用消息发布器的 topic、key、头和正文策略。
|
||
type MessagePublisherOptions struct {
|
||
Sink MessageSink
|
||
Topic TopicFunc
|
||
Key KeyFunc
|
||
Headers map[string]string
|
||
Body HTTPBodyFunc
|
||
}
|
||
|
||
// MessagePublisher 把 eventlog 事件转换成 MessageSink 可消费的消息。
|
||
type MessagePublisher struct {
|
||
sink MessageSink
|
||
topic TopicFunc
|
||
key KeyFunc
|
||
headers map[string]string
|
||
body HTTPBodyFunc
|
||
}
|
||
|
||
// NewMessagePublisher 创建通用消息发布器并补齐默认 topic、key 和正文策略。
|
||
func NewMessagePublisher(options MessagePublisherOptions) (*MessagePublisher, error) {
|
||
if options.Sink == nil {
|
||
return nil, fmt.Errorf("%w: message sink is required", ErrInvalidPublisherConfig)
|
||
}
|
||
topic := options.Topic
|
||
if topic == nil {
|
||
topic = DefaultTopic
|
||
}
|
||
key := options.Key
|
||
if key == nil {
|
||
key = DefaultMessageKey
|
||
}
|
||
body := options.Body
|
||
if body == nil {
|
||
body = EventJSONBody
|
||
}
|
||
return &MessagePublisher{
|
||
sink: options.Sink,
|
||
topic: topic,
|
||
key: key,
|
||
headers: cloneStringMap(options.Headers),
|
||
body: body,
|
||
}, nil
|
||
}
|
||
|
||
// Publish 构造消息并投递到 MessageSink。
|
||
func (p *MessagePublisher) Publish(ctx context.Context, event Event) error {
|
||
if p == nil || p.sink == nil || p.topic == nil || p.key == nil || p.body == nil {
|
||
return ErrPublisherRequired
|
||
}
|
||
topic := strings.TrimSpace(p.topic(event))
|
||
if topic == "" {
|
||
return fmt.Errorf("%w: topic is required", ErrInvalidPublisherConfig)
|
||
}
|
||
body, contentType, err := p.body(event)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
headers := cloneStringMap(p.headers)
|
||
if headers == nil {
|
||
headers = map[string]string{}
|
||
}
|
||
setMessageHeader(headers, "event_id", event.ID)
|
||
setMessageHeader(headers, "event_stream", event.Stream)
|
||
setMessageHeader(headers, "event_type", event.Type)
|
||
setMessageHeader(headers, "event_aggregate_id", event.AggregateID)
|
||
setMessageHeader(headers, "event_idempotency_key", event.IdempotencyKey)
|
||
return p.sink.PublishMessage(ctx, Message{
|
||
Topic: topic,
|
||
Key: strings.TrimSpace(p.key(event)),
|
||
ContentType: contentType,
|
||
Payload: append([]byte(nil), body...),
|
||
Headers: headers,
|
||
})
|
||
}
|
||
|
||
// BusPublisherOptions 描述事件发布到平台 Bus 时的 topic 和 payload 策略。
|
||
type BusPublisherOptions struct {
|
||
Topic TopicFunc
|
||
Payload PayloadFunc
|
||
}
|
||
|
||
// BusPublisher 把 eventlog 事件发布到平台 Bus。
|
||
type BusPublisher struct {
|
||
bus bus.Bus
|
||
topic TopicFunc
|
||
payload PayloadFunc
|
||
}
|
||
|
||
// NewBusPublisher 创建 Bus 发布器并补齐默认 topic 和 payload 策略。
|
||
func NewBusPublisher(eventBus bus.Bus, options BusPublisherOptions) (*BusPublisher, error) {
|
||
if eventBus == nil {
|
||
return nil, fmt.Errorf("%w: bus is required", ErrInvalidPublisherConfig)
|
||
}
|
||
topic := options.Topic
|
||
if topic == nil {
|
||
topic = DefaultTopic
|
||
}
|
||
payload := options.Payload
|
||
if payload == nil {
|
||
payload = RawJSONPayload
|
||
}
|
||
return &BusPublisher{bus: eventBus, topic: topic, payload: payload}, nil
|
||
}
|
||
|
||
// Publish 将事件转换后发布到平台 Bus。
|
||
func (p *BusPublisher) Publish(ctx context.Context, event Event) error {
|
||
if p == nil || p.bus == nil || p.topic == nil || p.payload == nil {
|
||
return ErrPublisherRequired
|
||
}
|
||
topic := strings.TrimSpace(p.topic(event))
|
||
if topic == "" {
|
||
return fmt.Errorf("%w: topic is required", ErrInvalidPublisherConfig)
|
||
}
|
||
payload, err := p.payload(event)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// BusPublisher 的可靠性只等于底层 Bus;Redis/NATS Pub/Sub 成功不代表消费者持久确认。
|
||
return p.bus.Publish(ctx, topic, payload)
|
||
}
|
||
|
||
// HTTPBodyFunc 根据事件生成 HTTP 请求体和 content-type。
|
||
type HTTPBodyFunc func(Event) ([]byte, string, error)
|
||
|
||
// HTTPPublisherOptions 描述 webhook 发布器的地址、方法、客户端和正文策略。
|
||
type HTTPPublisherOptions struct {
|
||
URL string
|
||
Method string
|
||
Client *http.Client
|
||
Headers map[string]string
|
||
Body HTTPBodyFunc
|
||
}
|
||
|
||
// HTTPPublisher 把 eventlog 事件投递到 HTTP webhook。
|
||
type HTTPPublisher struct {
|
||
url string
|
||
method string
|
||
client *http.Client
|
||
headers map[string]string
|
||
body HTTPBodyFunc
|
||
}
|
||
|
||
// NewHTTPPublisher 创建 webhook 发布器并补齐默认 HTTP 客户端和正文策略。
|
||
func NewHTTPPublisher(options HTTPPublisherOptions) (*HTTPPublisher, error) {
|
||
url := strings.TrimSpace(options.URL)
|
||
if url == "" {
|
||
return nil, fmt.Errorf("%w: url is required", ErrInvalidPublisherConfig)
|
||
}
|
||
method := strings.TrimSpace(options.Method)
|
||
if method == "" {
|
||
method = http.MethodPost
|
||
}
|
||
client := options.Client
|
||
if client == nil {
|
||
client = &http.Client{Timeout: 10 * time.Second}
|
||
}
|
||
body := options.Body
|
||
if body == nil {
|
||
body = EventJSONBody
|
||
}
|
||
return &HTTPPublisher{
|
||
url: url,
|
||
method: method,
|
||
client: client,
|
||
headers: cloneStringMap(options.Headers),
|
||
body: body,
|
||
}, nil
|
||
}
|
||
|
||
// Publish 发送 webhook 请求,并把 4xx 响应归类为永久失败。
|
||
func (p *HTTPPublisher) Publish(ctx context.Context, event Event) error {
|
||
if p == nil || p.client == nil || p.body == nil {
|
||
return ErrPublisherRequired
|
||
}
|
||
body, contentType, err := p.body(event)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, p.method, p.url, bytes.NewReader(body))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if contentType != "" {
|
||
req.Header.Set("Content-Type", contentType)
|
||
}
|
||
for key, value := range p.headers {
|
||
req.Header.Set(key, value)
|
||
}
|
||
setEventHeader(req.Header, "X-Event-ID", event.ID)
|
||
setEventHeader(req.Header, "X-Event-Stream", event.Stream)
|
||
setEventHeader(req.Header, "X-Event-Type", event.Type)
|
||
setEventHeader(req.Header, "X-Event-Aggregate-ID", event.AggregateID)
|
||
setEventHeader(req.Header, "X-Event-Idempotency-Key", event.IdempotencyKey)
|
||
|
||
resp, err := p.client.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
// HTTP 4xx 通常是不可重试的契约错误,直接进入死信,避免把坏事件反复打到下游。
|
||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||
_, _ = io.Copy(io.Discard, resp.Body)
|
||
return nil
|
||
}
|
||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||
message := strings.TrimSpace(string(data))
|
||
if message == "" {
|
||
message = resp.Status
|
||
}
|
||
publishErr := fmt.Errorf("eventlog webhook returned %s: %s", resp.Status, message)
|
||
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||
return fmt.Errorf("%w: %w", ErrPermanentPublishFailure, publishErr)
|
||
}
|
||
return publishErr
|
||
}
|
||
|
||
// DefaultTopic 使用 stream 和 type 组合默认 topic。
|
||
func DefaultTopic(event Event) string {
|
||
stream := strings.TrimSpace(event.Stream)
|
||
eventType := strings.TrimSpace(event.Type)
|
||
switch {
|
||
case stream == "":
|
||
return eventType
|
||
case eventType == "":
|
||
return stream
|
||
default:
|
||
return stream + "." + eventType
|
||
}
|
||
}
|
||
|
||
// OriginalTopic 优先使用事件头中记录的原始 topic,缺失时回退到 stream.type。
|
||
func OriginalTopic(event Event) string {
|
||
if event.Headers != nil {
|
||
if topic := strings.TrimSpace(event.Headers[HeaderOriginalTopic]); topic != "" {
|
||
return topic
|
||
}
|
||
}
|
||
return DefaultTopic(event)
|
||
}
|
||
|
||
// DefaultMessageKey 优先使用幂等键,缺失时退回事件 ID。
|
||
func DefaultMessageKey(event Event) string {
|
||
if key := strings.TrimSpace(event.IdempotencyKey); key != "" {
|
||
return key
|
||
}
|
||
return strings.TrimSpace(event.ID)
|
||
}
|
||
|
||
// RawJSONPayload 返回事件原始 payload 副本。
|
||
func RawJSONPayload(event Event) (any, error) {
|
||
return cloneRawMessage(event.Payload), nil
|
||
}
|
||
|
||
// OriginalTopicPayload 只在事件声明 original_topic 时按契约解码 payload,避免恢复事件在 Memory bus 中被 typed 订阅者跳过。
|
||
func OriginalTopicPayload(event Event) (any, error) {
|
||
if event.Headers == nil || strings.TrimSpace(event.Headers[HeaderOriginalTopic]) == "" {
|
||
return RawJSONPayload(event)
|
||
}
|
||
return contracts.UnmarshalTopicPayload(OriginalTopic(event), cloneRawMessage(event.Payload))
|
||
}
|
||
|
||
// EventJSONBody 把完整事件编码成 JSON 请求体。
|
||
func EventJSONBody(event Event) ([]byte, string, error) {
|
||
data, err := json.Marshal(cloneEvent(event))
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
return data, "application/json", nil
|
||
}
|
||
|
||
// PayloadJSONBody 把事件 payload 作为 JSON 请求体。
|
||
func PayloadJSONBody(event Event) ([]byte, string, error) {
|
||
return cloneRawMessage(event.Payload), "application/json", nil
|
||
}
|
||
|
||
func setEventHeader(headers http.Header, key string, value string) {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return
|
||
}
|
||
headers.Set(key, value)
|
||
}
|
||
|
||
func setMessageHeader(headers map[string]string, key string, value string) {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return
|
||
}
|
||
headers[key] = value
|
||
}
|