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