feat: add lossless typed wallet MQ tags

This commit is contained in:
zhx 2026-07-11 04:34:26 +08:00
parent 3666a9ff02
commit 1fe23326c6
26 changed files with 706 additions and 61 deletions

View File

@ -4,15 +4,92 @@ package walletmq
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"sort"
"strings" "strings"
) )
const ( const (
MessageTypeWalletOutboxEvent = "wallet_outbox_event" MessageTypeWalletOutboxEvent = "wallet_outbox_event"
// TagWalletOutboxEvent 是 typed tag 上线前所有钱包事实共用的 legacy Tag。
// 兼容窗口内消费者必须同时订阅它和自己需要的 EventType Tag确保 producer
// 切换前已经进入 Broker 的旧消息仍会被同一 consumer group 完整消费。
TagWalletOutboxEvent = "wallet_outbox_event" TagWalletOutboxEvent = "wallet_outbox_event"
// 以下常量只描述当前 wallet topic 消费者实际处理的事件类型。producer 使用
// outbox.event_type 作为 Tag因此消费者和消息体分派必须复用同一拼写避免
// Tag 过滤成功但 handler 因事件名漂移而静默跳过。
EventTypeWalletRechargeRecorded = "WalletRechargeRecorded"
EventTypeWalletCoinSellerStockPurchased = "WalletCoinSellerStockPurchased"
EventTypeWalletLuckyGiftRewardCredited = "WalletLuckyGiftRewardCredited"
EventTypeWalletTaskRewardCredited = "WalletTaskRewardCredited"
EventTypeWalletWheelRewardCredited = "WalletWheelRewardCredited"
EventTypeWalletRoomTurnoverRewardCredited = "WalletRoomTurnoverRewardCredited"
EventTypeWalletInviteActivityRewardCredited = "WalletInviteActivityRewardCredited"
EventTypeWalletAgencyOpeningRewardCredited = "WalletAgencyOpeningRewardCredited"
EventTypeWalletGiftIncomeCredited = "WalletGiftIncomeCredited"
EventTypeWalletBalanceChanged = "WalletBalanceChanged"
EventTypeWalletGiftDebited = "WalletGiftDebited"
EventTypeWalletRedPacketCreated = "WalletRedPacketCreated"
EventTypeWalletRedPacketClaimed = "WalletRedPacketClaimed"
EventTypeWalletRedPacketRefunded = "WalletRedPacketRefunded"
EventTypeVIPActivated = "VipActivated"
EventTypeVIPDailyCoinRebateAvailable = "VipDailyCoinRebateAvailable"
EventTypeResourceGranted = "ResourceGranted"
EventTypeResourceGroupGranted = "ResourceGroupGranted"
) )
// EventTypeTag converts one committed wallet event type into its RocketMQ Tag.
// Event types are controlled identifiers rather than user input; rejecting expression
// operators and punctuation here prevents a malformed outbox row from widening a
// subscription or being published under a Tag no consumer can express safely.
func EventTypeTag(eventType string) (string, error) {
tag := strings.TrimSpace(eventType)
if tag == "" {
return "", errors.New("wallet event_type tag is required")
}
if tag == TagWalletOutboxEvent {
return "", errors.New("wallet event_type cannot reuse the legacy wallet tag")
}
for _, char := range tag {
if (char >= 'a' && char <= 'z') ||
(char >= 'A' && char <= 'Z') ||
(char >= '0' && char <= '9') ||
char == '_' || char == '-' {
continue
}
return "", fmt.Errorf("wallet event_type %q cannot be used as a RocketMQ tag", eventType)
}
return tag, nil
}
// LegacyCompatibleTagExpression builds the compatibility selector used during
// the typed-tag migration: legacy messages remain consumable, while newly published
// messages are filtered by the exact event types handled by this consumer.
// Sorting and deduplication make the expression deterministic so every process in
// the same consumer group registers byte-for-byte identical subscription metadata.
func LegacyCompatibleTagExpression(eventTypes ...string) (string, error) {
typedTags := make([]string, 0, len(eventTypes))
seen := make(map[string]struct{}, len(eventTypes))
for _, eventType := range eventTypes {
tag, err := EventTypeTag(eventType)
if err != nil {
return "", err
}
if _, exists := seen[tag]; exists {
continue
}
seen[tag] = struct{}{}
typedTags = append(typedTags, tag)
}
if len(typedTags) == 0 {
return "", errors.New("at least one typed wallet event tag is required")
}
sort.Strings(typedTags)
return TagWalletOutboxEvent + " || " + strings.Join(typedTags, " || "), nil
}
// WalletOutboxMessage is the MQ representation of one committed wallet_outbox fact. // WalletOutboxMessage is the MQ representation of one committed wallet_outbox fact.
type WalletOutboxMessage struct { type WalletOutboxMessage struct {
MessageType string `json:"message_type"` MessageType string `json:"message_type"`

View File

@ -1,6 +1,9 @@
package walletmq package walletmq
import "testing" import (
"strings"
"testing"
)
func TestWalletOutboxMessageRoundTrip(t *testing.T) { func TestWalletOutboxMessageRoundTrip(t *testing.T) {
encoded, err := EncodeWalletOutboxMessage(WalletOutboxMessage{ encoded, err := EncodeWalletOutboxMessage(WalletOutboxMessage{
@ -27,3 +30,59 @@ func TestWalletOutboxMessageRoundTrip(t *testing.T) {
t.Fatalf("decoded message mismatch: %+v", decoded) t.Fatalf("decoded message mismatch: %+v", decoded)
} }
} }
func TestEventTypeTagUsesExactTrimmedEventType(t *testing.T) {
tag, err := EventTypeTag(" WalletRechargeRecorded ")
if err != nil {
t.Fatalf("EventTypeTag failed: %v", err)
}
if tag != EventTypeWalletRechargeRecorded {
t.Fatalf("event tag = %q, want %q", tag, EventTypeWalletRechargeRecorded)
}
for _, invalid := range []string{"", TagWalletOutboxEvent, "Wallet RechargeRecorded", "WalletRechargeRecorded || *", "Wallet.RechargeRecorded"} {
if _, err := EventTypeTag(invalid); err == nil {
t.Fatalf("EventTypeTag(%q) unexpectedly succeeded", invalid)
}
}
}
func TestLegacyCompatibleTagExpressionIsDeterministicAndDeduplicated(t *testing.T) {
first, err := LegacyCompatibleTagExpression(
EventTypeWalletGiftDebited,
EventTypeWalletRechargeRecorded,
EventTypeWalletGiftDebited,
)
if err != nil {
t.Fatalf("LegacyCompatibleTagExpression failed: %v", err)
}
second, err := LegacyCompatibleTagExpression(
EventTypeWalletRechargeRecorded,
EventTypeWalletGiftDebited,
)
if err != nil {
t.Fatalf("LegacyCompatibleTagExpression failed: %v", err)
}
if first != second {
t.Fatalf("expressions differ by input order: first=%q second=%q", first, second)
}
want := TagWalletOutboxEvent + " || " + EventTypeWalletGiftDebited + " || " + EventTypeWalletRechargeRecorded
if first != want {
t.Fatalf("expression = %q, want %q", first, want)
}
if strings.Count(first, EventTypeWalletGiftDebited) != 1 {
t.Fatalf("typed tag was not deduplicated: %q", first)
}
}
func TestLegacyCompatibleTagExpressionRejectsUnsafeOrMissingTypedTags(t *testing.T) {
for _, eventTypes := range [][]string{
nil,
{TagWalletOutboxEvent},
{"WalletRechargeRecorded || *"},
} {
if _, err := LegacyCompatibleTagExpression(eventTypes...); err == nil {
t.Fatalf("LegacyCompatibleTagExpression(%v) unexpectedly succeeded", eventTypes)
}
}
}

View File

@ -186,7 +186,7 @@ func newFirstRechargeWalletConsumer(cfg config.Config, services *serviceBundle)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { if err := subscribeWalletOutboxEvents(consumer, cfg.RocketMQ.WalletOutbox.Topic, []string{walletmq.EventTypeWalletRechargeRecorded}, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
event, ok, err := firstRechargeEventFromWalletMessage(message.Body) event, ok, err := firstRechargeEventFromWalletMessage(message.Body)
if err != nil || !ok { if err != nil || !ok {
return err return err
@ -205,7 +205,7 @@ func newCumulativeRechargeWalletConsumer(cfg config.Config, services *serviceBun
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { if err := subscribeWalletOutboxEvents(consumer, cfg.RocketMQ.WalletOutbox.Topic, []string{walletmq.EventTypeWalletRechargeRecorded}, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
event, ok, err := cumulativeRechargeEventFromWalletMessage(message.Body) event, ok, err := cumulativeRechargeEventFromWalletMessage(message.Body)
if err != nil || !ok { if err != nil || !ok {
return err return err
@ -224,7 +224,7 @@ func newInviteActivityWalletConsumer(cfg config.Config, services *serviceBundle)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { if err := subscribeWalletOutboxEvents(consumer, cfg.RocketMQ.WalletOutbox.Topic, []string{walletmq.EventTypeWalletRechargeRecorded}, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
event, ok, err := inviteActivityRechargeEventFromWalletMessage(message.Body) event, ok, err := inviteActivityRechargeEventFromWalletMessage(message.Body)
if err != nil || !ok { if err != nil || !ok {
return err return err
@ -268,7 +268,11 @@ func newRedPacketWalletConsumer(cfg config.Config, services *serviceBundle) (*ro
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := consumer.Subscribe(redPacketWalletOutboxTopic(cfg.RocketMQ.WalletOutbox), walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { if err := subscribeWalletOutboxEvents(consumer, redPacketWalletOutboxTopic(cfg.RocketMQ.WalletOutbox), []string{
walletmq.EventTypeWalletRedPacketCreated,
walletmq.EventTypeWalletRedPacketClaimed,
walletmq.EventTypeWalletRedPacketRefunded,
}, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
event, ok, err := redPacketEventFromWalletMessage(message.Body) event, ok, err := redPacketEventFromWalletMessage(message.Body)
if err != nil || !ok { if err != nil || !ok {
return err return err
@ -288,7 +292,7 @@ func newVIPRebateNoticeWalletConsumer(cfg config.Config, services *serviceBundle
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { if err := subscribeWalletOutboxEvents(consumer, cfg.RocketMQ.WalletOutbox.Topic, []string{walletmq.EventTypeVIPDailyCoinRebateAvailable}, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
event, ok, err := vipRebateAvailableEventFromWalletMessage(message.Body) event, ok, err := vipRebateAvailableEventFromWalletMessage(message.Body)
if err != nil || !ok { if err != nil || !ok {
return err return err
@ -310,7 +314,7 @@ func newUserLeaderboardWalletConsumer(cfg config.Config, services *serviceBundle
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { if err := subscribeWalletOutboxEvents(consumer, cfg.RocketMQ.WalletOutbox.Topic, []string{walletmq.EventTypeWalletGiftDebited}, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
event, ok, err := userLeaderboardGiftEventFromWalletMessage(message.Body) event, ok, err := userLeaderboardGiftEventFromWalletMessage(message.Body)
if err != nil || !ok { if err != nil || !ok {
return err return err
@ -330,7 +334,7 @@ func newTaskWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketm
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { if err := subscribeWalletOutboxEvents(consumer, cfg.RocketMQ.WalletOutbox.Topic, []string{walletmq.EventTypeWalletRechargeRecorded}, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
// 钱包任务消费者使用独立 consumer group不能复用红包或其他钱包消费者进度否则充值任务会被其他业务确认掉。 // 钱包任务消费者使用独立 consumer group不能复用红包或其他钱包消费者进度否则充值任务会被其他业务确认掉。
event, appCode, ok, err := taskEventFromWalletMessage(message.Body) event, appCode, ok, err := taskEventFromWalletMessage(message.Body)
if err != nil || !ok { if err != nil || !ok {
@ -346,6 +350,18 @@ func newTaskWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketm
return consumer, nil return consumer, nil
} }
// subscribeWalletOutboxEvents is the only activity-service entry point for wallet
// subscriptions during the typed-tag compatibility phase. Keeping selector assembly
// centralized guarantees every replica in a consumer group registers the same sorted
// expression and that the legacy Tag remains present until the old backlog is drained.
func subscribeWalletOutboxEvents(consumer *rocketmqx.Consumer, topic string, eventTypes []string, handler rocketmqx.Handler) error {
tagExpression, err := walletmq.LegacyCompatibleTagExpression(eventTypes...)
if err != nil {
return err
}
return consumer.Subscribe(topic, tagExpression, handler)
}
func newTaskUserOutboxConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) { func newTaskUserOutboxConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) {
consumer, err := rocketmqx.NewConsumer(userOutboxTaskConsumerConfig(cfg.RocketMQ)) consumer, err := rocketmqx.NewConsumer(userOutboxTaskConsumerConfig(cfg.RocketMQ))
if err != nil { if err != nil {

View File

@ -13,7 +13,7 @@ import (
) )
const ( const (
taskWalletRechargeRecorded = "WalletRechargeRecorded" taskWalletRechargeRecorded = walletmq.EventTypeWalletRechargeRecorded
taskCPRelationshipCreated = "UserCPRelationshipCreated" taskCPRelationshipCreated = "UserCPRelationshipCreated"
taskMicDailyStatsUpdated = "UserMicDailyStatsUpdated" taskMicDailyStatsUpdated = "UserMicDailyStatsUpdated"
taskMoodPublished = "UserMoodPublished" taskMoodPublished = "UserMoodPublished"

View File

@ -13,7 +13,7 @@ import (
messageservice "hyapp/services/activity-service/internal/service/message" messageservice "hyapp/services/activity-service/internal/service/message"
) )
const vipDailyCoinRebateAvailableEventType = "VipDailyCoinRebateAvailable" const vipDailyCoinRebateAvailableEventType = walletmq.EventTypeVIPDailyCoinRebateAvailable
// vipRebateAvailableEvent 是 wallet outbox 的逐用户返现资格快照。 // vipRebateAvailableEvent 是 wallet outbox 的逐用户返现资格快照。
// activity-service 只把这个已提交事实投影为 inbox资格、金额、过期和最终入账仍由 wallet owner 判定。 // activity-service 只把这个已提交事实投影为 inbox资格、金额、过期和最终入账仍由 wallet owner 判定。

View File

@ -18,7 +18,7 @@ func firstRechargeEventFromWalletMessage(body []byte) (firstrechargedomain.Recha
if err != nil { if err != nil {
return firstrechargedomain.RechargeEvent{}, false, err return firstrechargedomain.RechargeEvent{}, false, err
} }
if message.EventType != firstrechargedomain.RechargeEventWalletRecorded { if message.EventType != walletmq.EventTypeWalletRechargeRecorded {
return firstrechargedomain.RechargeEvent{}, false, nil return firstrechargedomain.RechargeEvent{}, false, nil
} }
fields := firstRechargeFactFromWalletPayload(message.PayloadJSON) fields := firstRechargeFactFromWalletPayload(message.PayloadJSON)
@ -49,7 +49,7 @@ func cumulativeRechargeEventFromWalletMessage(body []byte) (cumulativerechargedo
if err != nil { if err != nil {
return cumulativerechargedomain.RechargeEvent{}, false, err return cumulativerechargedomain.RechargeEvent{}, false, err
} }
if message.EventType != cumulativerechargedomain.RechargeEventWalletRecorded { if message.EventType != walletmq.EventTypeWalletRechargeRecorded {
return cumulativerechargedomain.RechargeEvent{}, false, nil return cumulativerechargedomain.RechargeEvent{}, false, nil
} }
// WalletRechargeRecorded 是累充唯一事实源payload 里没有可折算 USD 金额时直接跳过,避免把普通钱包流水计入活动。 // WalletRechargeRecorded 是累充唯一事实源payload 里没有可折算 USD 金额时直接跳过,避免把普通钱包流水计入活动。
@ -78,7 +78,7 @@ func inviteActivityRechargeEventFromWalletMessage(body []byte) (inviteactivitydo
if err != nil { if err != nil {
return inviteactivitydomain.RechargeEvent{}, false, err return inviteactivitydomain.RechargeEvent{}, false, err
} }
if message.EventType != inviteactivitydomain.EventTypeWalletRechargeRecorded || message.AssetType != "COIN" || message.AvailableDelta <= 0 { if message.EventType != walletmq.EventTypeWalletRechargeRecorded || message.AssetType != "COIN" || message.AvailableDelta <= 0 {
return inviteactivitydomain.RechargeEvent{}, false, nil return inviteactivitydomain.RechargeEvent{}, false, nil
} }
fields := cumulativeRechargeFactFromWalletPayload(message.PayloadJSON, message.AvailableDelta) fields := cumulativeRechargeFactFromWalletPayload(message.PayloadJSON, message.AvailableDelta)
@ -101,7 +101,9 @@ func redPacketEventFromWalletMessage(body []byte) (broadcastdomain.RedPacketWall
if err != nil { if err != nil {
return broadcastdomain.RedPacketWalletEvent{}, false, err return broadcastdomain.RedPacketWalletEvent{}, false, err
} }
if message.EventType != "WalletRedPacketCreated" && message.EventType != "WalletRedPacketClaimed" && message.EventType != "WalletRedPacketRefunded" { if message.EventType != walletmq.EventTypeWalletRedPacketCreated &&
message.EventType != walletmq.EventTypeWalletRedPacketClaimed &&
message.EventType != walletmq.EventTypeWalletRedPacketRefunded {
return broadcastdomain.RedPacketWalletEvent{}, false, nil return broadcastdomain.RedPacketWalletEvent{}, false, nil
} }
fields := redPacketFieldsFromWalletPayload(message.PayloadJSON, message.EventType) fields := redPacketFieldsFromWalletPayload(message.PayloadJSON, message.EventType)
@ -138,7 +140,7 @@ func userLeaderboardGiftEventFromWalletMessage(body []byte) (userleaderboard.Gif
if err != nil { if err != nil {
return userleaderboard.GiftEvent{}, false, err return userleaderboard.GiftEvent{}, false, err
} }
if message.EventType != "WalletGiftDebited" { if message.EventType != walletmq.EventTypeWalletGiftDebited {
return userleaderboard.GiftEvent{}, false, nil return userleaderboard.GiftEvent{}, false, nil
} }
var decoded map[string]any var decoded map[string]any
@ -310,12 +312,12 @@ func redPacketFieldsFromWalletPayload(payload string, eventType string) redPacke
status := stringFromDecoded(decoded, "status") status := stringFromDecoded(decoded, "status")
openAtMS := int64FromDecoded(decoded, "open_at_ms") openAtMS := int64FromDecoded(decoded, "open_at_ms")
expiresAtMS := int64FromDecoded(decoded, "expires_at_ms") expiresAtMS := int64FromDecoded(decoded, "expires_at_ms")
if eventType == "WalletRedPacketClaimed" || eventType == "WalletRedPacketRefunded" { if eventType == walletmq.EventTypeWalletRedPacketClaimed || eventType == walletmq.EventTypeWalletRedPacketRefunded {
status = stringFromDecoded(decoded, "packet_status") status = stringFromDecoded(decoded, "packet_status")
} }
totalCount := int32FromDecoded(decoded, "packet_count") totalCount := int32FromDecoded(decoded, "packet_count")
remainingCount := int32FromDecoded(decoded, "remaining_count") remainingCount := int32FromDecoded(decoded, "remaining_count")
if remainingCount == 0 && eventType == "WalletRedPacketCreated" { if remainingCount == 0 && eventType == walletmq.EventTypeWalletRedPacketCreated {
remainingCount = totalCount remainingCount = totalCount
} }
return redPacketPayloadFields{ return redPacketPayloadFields{

View File

@ -148,13 +148,24 @@ func New(cfg config.Config) (*App, error) {
confirmOptions := confirmNoticeWorkerOptions(cfg.ConfirmNoticeWorker) confirmOptions := confirmNoticeWorkerOptions(cfg.ConfirmNoticeWorker)
mqConsumers := make([]*rocketmqx.Consumer, 0, 4) mqConsumers := make([]*rocketmqx.Consumer, 0, 4)
if cfg.RocketMQ.WalletOutbox.Enabled { if cfg.RocketMQ.WalletOutbox.Enabled {
// notice-service 只投递余额变化和 VIP 激活私信。保留 legacy Tag 是迁移期
// 的零丢失边界,新 producer 的其他钱包事实由 Broker 直接过滤。
walletTagExpression, err := walletmq.LegacyCompatibleTagExpression(
walletmq.EventTypeWalletBalanceChanged,
walletmq.EventTypeVIPActivated,
)
if err != nil {
_ = listener.Close()
_ = store.Close()
return nil, err
}
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.ConsumerGroup, cfg.RocketMQ.WalletOutbox.ConsumerMaxReconsumeTimes)) consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.ConsumerGroup, cfg.RocketMQ.WalletOutbox.ConsumerMaxReconsumeTimes))
if err != nil { if err != nil {
_ = listener.Close() _ = listener.Close()
_ = store.Close() _ = store.Close()
return nil, err return nil, err
} }
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletTagExpression, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body) walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body)
if err != nil { if err != nil {
return err return err

View File

@ -18,8 +18,8 @@ import (
const ( const (
sourceWalletOutbox = "wallet_outbox" sourceWalletOutbox = "wallet_outbox"
channelTencentIMC2C = "tencent_im_c2c" channelTencentIMC2C = "tencent_im_c2c"
eventWalletBalance = "WalletBalanceChanged" eventWalletBalance = walletmq.EventTypeWalletBalanceChanged
eventVIPActivated = "VipActivated" eventVIPActivated = walletmq.EventTypeVIPActivated
robotCoinAssetType = "ROBOT_COIN" robotCoinAssetType = "ROBOT_COIN"
deliveryStatusDelivering = "delivering" deliveryStatusDelivering = "delivering"
deliveryStatusDelivered = "delivered" deliveryStatusDelivered = "delivered"

View File

@ -145,7 +145,15 @@ func newConsumers(cfg config.Config, repo *mysqlstorage.Repository) ([]*rocketmq
}); err != nil { }); err != nil {
return nil, err return nil, err
} }
if err := add(cfg.RocketMQ.WalletOutbox, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { // 统计服务需要多个钱包事实,但不应为未建模的 gift debit、红包等事件支付
// 消费 TPS。兼容表达式继续接住存量 legacy 消息,新消息由 Broker 按下列
// 精确事件集合过滤。
walletTagExpression, err := walletmq.LegacyCompatibleTagExpression(statisticsWalletEventTypes()...)
if err != nil {
shutdownConsumers(consumers)
return nil, err
}
if err := add(cfg.RocketMQ.WalletOutbox, walletTagExpression, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
return ConsumeWalletOutboxBody(ctx, repo, message.Body) return ConsumeWalletOutboxBody(ctx, repo, message.Body)
}); err != nil { }); err != nil {
shutdownConsumers(consumers) shutdownConsumers(consumers)
@ -166,6 +174,21 @@ func newConsumers(cfg config.Config, repo *mysqlstorage.Repository) ([]*rocketmq
return consumers, nil return consumers, nil
} }
func statisticsWalletEventTypes() []string {
return []string{
walletmq.EventTypeWalletRechargeRecorded,
walletmq.EventTypeWalletCoinSellerStockPurchased,
walletmq.EventTypeWalletLuckyGiftRewardCredited,
walletmq.EventTypeWalletTaskRewardCredited,
walletmq.EventTypeWalletWheelRewardCredited,
walletmq.EventTypeWalletRoomTurnoverRewardCredited,
walletmq.EventTypeWalletInviteActivityRewardCredited,
walletmq.EventTypeWalletAgencyOpeningRewardCredited,
walletmq.EventTypeWalletGiftIncomeCredited,
walletmq.EventTypeWalletBalanceChanged,
}
}
// ConsumeUserOutboxBody 是 user outbox 的唯一统计投影入口;实时 MQ 和离线回灌共用它,避免同一 payload 出现两套解析口径。 // ConsumeUserOutboxBody 是 user outbox 的唯一统计投影入口;实时 MQ 和离线回灌共用它,避免同一 payload 出现两套解析口径。
func ConsumeUserOutboxBody(ctx context.Context, repo *mysqlstorage.Repository, body []byte) error { func ConsumeUserOutboxBody(ctx context.Context, repo *mysqlstorage.Repository, body []byte) error {
if event, ok, err := userRegisteredEvent(body); err != nil || ok { if event, ok, err := userRegisteredEvent(body); err != nil || ok {
@ -359,7 +382,7 @@ func rechargeEvent(body []byte) (mysqlstorage.RechargeEvent, bool, error) {
if err != nil { if err != nil {
return mysqlstorage.RechargeEvent{}, false, err return mysqlstorage.RechargeEvent{}, false, err
} }
if message.EventType != "WalletRechargeRecorded" { if message.EventType != walletmq.EventTypeWalletRechargeRecorded {
return mysqlstorage.RechargeEvent{}, false, nil return mysqlstorage.RechargeEvent{}, false, nil
} }
payload := mysqlstorage.DecodeJSON(message.PayloadJSON) payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
@ -391,7 +414,7 @@ func coinSellerStockEvent(body []byte) (mysqlstorage.CoinSellerStockEvent, bool,
if err != nil { if err != nil {
return mysqlstorage.CoinSellerStockEvent{}, false, err return mysqlstorage.CoinSellerStockEvent{}, false, err
} }
if message.EventType != "WalletCoinSellerStockPurchased" { if message.EventType != walletmq.EventTypeWalletCoinSellerStockPurchased {
return mysqlstorage.CoinSellerStockEvent{}, false, nil return mysqlstorage.CoinSellerStockEvent{}, false, nil
} }
payload := mysqlstorage.DecodeJSON(message.PayloadJSON) payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
@ -459,7 +482,7 @@ func luckyGiftRewardEvent(body []byte) (mysqlstorage.LuckyGiftRewardEvent, bool,
if err != nil { if err != nil {
return mysqlstorage.LuckyGiftRewardEvent{}, false, err return mysqlstorage.LuckyGiftRewardEvent{}, false, err
} }
if message.EventType != "WalletLuckyGiftRewardCredited" { if message.EventType != walletmq.EventTypeWalletLuckyGiftRewardCredited {
return mysqlstorage.LuckyGiftRewardEvent{}, false, nil return mysqlstorage.LuckyGiftRewardEvent{}, false, nil
} }
payload := mysqlstorage.DecodeJSON(message.PayloadJSON) payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
@ -481,7 +504,7 @@ func platformGrantCoinEvent(body []byte) (mysqlstorage.PlatformGrantCoinEvent, b
if err != nil { if err != nil {
return mysqlstorage.PlatformGrantCoinEvent{}, false, err return mysqlstorage.PlatformGrantCoinEvent{}, false, err
} }
if !isPlatformGrantCoinEventType(message.EventType) || message.EventType == "WalletLuckyGiftRewardCredited" { if !isPlatformGrantCoinEventType(message.EventType) || message.EventType == walletmq.EventTypeWalletLuckyGiftRewardCredited {
return mysqlstorage.PlatformGrantCoinEvent{}, false, nil return mysqlstorage.PlatformGrantCoinEvent{}, false, nil
} }
payload := mysqlstorage.DecodeJSON(message.PayloadJSON) payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
@ -502,7 +525,7 @@ func giftIncomeOutputEvent(body []byte) (mysqlstorage.CoinOutputEvent, bool, err
if err != nil { if err != nil {
return mysqlstorage.CoinOutputEvent{}, false, err return mysqlstorage.CoinOutputEvent{}, false, err
} }
if message.EventType != "WalletGiftIncomeCredited" { if message.EventType != walletmq.EventTypeWalletGiftIncomeCredited {
return mysqlstorage.CoinOutputEvent{}, false, nil return mysqlstorage.CoinOutputEvent{}, false, nil
} }
payload := mysqlstorage.DecodeJSON(message.PayloadJSON) payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
@ -523,7 +546,7 @@ func walletBalanceChangedEvent(body []byte) (mysqlstorage.WalletBalanceChangedEv
if err != nil { if err != nil {
return mysqlstorage.WalletBalanceChangedEvent{}, false, err return mysqlstorage.WalletBalanceChangedEvent{}, false, err
} }
if message.EventType != "WalletBalanceChanged" { if message.EventType != walletmq.EventTypeWalletBalanceChanged {
return mysqlstorage.WalletBalanceChangedEvent{}, false, nil return mysqlstorage.WalletBalanceChangedEvent{}, false, nil
} }
payload := mysqlstorage.DecodeJSON(message.PayloadJSON) payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
@ -553,11 +576,11 @@ func walletBalanceChangedEvent(body []byte) (mysqlstorage.WalletBalanceChangedEv
func isPlatformGrantCoinEventType(value string) bool { func isPlatformGrantCoinEventType(value string) bool {
switch strings.TrimSpace(value) { switch strings.TrimSpace(value) {
case "WalletTaskRewardCredited", case walletmq.EventTypeWalletTaskRewardCredited,
"WalletWheelRewardCredited", walletmq.EventTypeWalletWheelRewardCredited,
"WalletRoomTurnoverRewardCredited", walletmq.EventTypeWalletRoomTurnoverRewardCredited,
"WalletInviteActivityRewardCredited", walletmq.EventTypeWalletInviteActivityRewardCredited,
"WalletAgencyOpeningRewardCredited": walletmq.EventTypeWalletAgencyOpeningRewardCredited:
return true return true
default: default:
return false return false

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"runtime" "runtime"
"strings"
"testing" "testing"
"time" "time"
@ -17,6 +18,44 @@ import (
mysqlstorage "hyapp/services/statistics-service/internal/storage/mysql" mysqlstorage "hyapp/services/statistics-service/internal/storage/mysql"
) )
func TestStatisticsWalletTagExpressionMatchesModeledEvents(t *testing.T) {
eventTypes := statisticsWalletEventTypes()
want := map[string]struct{}{
walletmq.EventTypeWalletRechargeRecorded: {},
walletmq.EventTypeWalletCoinSellerStockPurchased: {},
walletmq.EventTypeWalletLuckyGiftRewardCredited: {},
walletmq.EventTypeWalletTaskRewardCredited: {},
walletmq.EventTypeWalletWheelRewardCredited: {},
walletmq.EventTypeWalletRoomTurnoverRewardCredited: {},
walletmq.EventTypeWalletInviteActivityRewardCredited: {},
walletmq.EventTypeWalletAgencyOpeningRewardCredited: {},
walletmq.EventTypeWalletGiftIncomeCredited: {},
walletmq.EventTypeWalletBalanceChanged: {},
}
if len(eventTypes) != len(want) {
t.Fatalf("statistics wallet event count = %d, want %d: %v", len(eventTypes), len(want), eventTypes)
}
for _, eventType := range eventTypes {
if _, ok := want[eventType]; !ok {
t.Fatalf("statistics selector contains unmodeled event %q", eventType)
}
delete(want, eventType)
}
if len(want) != 0 {
t.Fatalf("statistics selector misses modeled events: %v", want)
}
expression, err := walletmq.LegacyCompatibleTagExpression(eventTypes...)
if err != nil {
t.Fatalf("LegacyCompatibleTagExpression failed: %v", err)
}
if !strings.Contains(expression, walletmq.TagWalletOutboxEvent) {
t.Fatalf("compatibility expression misses legacy tag: %q", expression)
}
if strings.Contains(expression, walletmq.EventTypeWalletGiftDebited) {
t.Fatalf("statistics selector must not receive high-volume gift debit facts: %q", expression)
}
}
// 这个用例只覆盖 statistics-service 对 owner outbox payload 的解析和聚合边界; // 这个用例只覆盖 statistics-service 对 owner outbox payload 的解析和聚合边界;
// 本地真实业务链路用 scripts/databi-real-flow-check.sh 从各 owner service 的 outbox 重放验证。 // 本地真实业务链路用 scripts/databi-real-flow-check.sh 从各 owner service 的 outbox 重放验证。
func TestConsumeOutboxMessagesProjectsDataScreenMetrics(t *testing.T) { func TestConsumeOutboxMessagesProjectsDataScreenMetrics(t *testing.T) {

View File

@ -641,11 +641,17 @@ func (i tencentIMAccountImporter) ImportAccount(ctx context.Context, userID int6
func newMQConsumers(cfg config.Config, inviteSvc *inviteservice.Service, micTimeSvc *mictimeservice.Service, roomTimeSvc *roomtimeservice.Service, cpSvc *cpservice.Service) ([]*rocketmqx.Consumer, error) { func newMQConsumers(cfg config.Config, inviteSvc *inviteservice.Service, micTimeSvc *mictimeservice.Service, roomTimeSvc *roomtimeservice.Service, cpSvc *cpservice.Service) ([]*rocketmqx.Consumer, error) {
consumers := make([]*rocketmqx.Consumer, 0, 4) consumers := make([]*rocketmqx.Consumer, 0, 4)
if cfg.InviteRechargeWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled { if cfg.InviteRechargeWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
// 邀请关系只消费充值成功事实。兼容期仍订阅 legacy Tag 以排空 producer
// 切换前的存量消息typed 消息则在 Broker 侧只投递充值事件。
walletTagExpression, err := walletmq.LegacyCompatibleTagExpression(walletmq.EventTypeWalletRechargeRecorded)
if err != nil {
return nil, err
}
consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ)) consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ))
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletTagExpression, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
event, ok, err := inviteRechargeEventFromWalletMessage(message.Body) event, ok, err := inviteRechargeEventFromWalletMessage(message.Body)
if err != nil || !ok { if err != nil || !ok {
return err return err

View File

@ -34,6 +34,8 @@ rocketmq:
namespace: "" namespace: ""
send_timeout: "3s" send_timeout: "3s"
retry: 2 retry: 2
# Phase A 保持 legacy所有消费者完成兼容滚动后才允许切换为 event_type。
wallet_event_tag_mode: "legacy"
wallet_outbox: wallet_outbox:
enabled: true enabled: true
topic: "hyapp_wallet_outbox" topic: "hyapp_wallet_outbox"

View File

@ -34,6 +34,8 @@ rocketmq:
namespace: "hyapp-prod" namespace: "hyapp-prod"
send_timeout: "3s" send_timeout: "3s"
retry: 2 retry: 2
# Phase A 必须保持 legacyPhase B 经消费者订阅与积压验证后,仅将该值切为 event_type。
wallet_event_tag_mode: "legacy"
wallet_outbox: wallet_outbox:
enabled: true enabled: true
topic: "hyapp_wallet_outbox" topic: "hyapp_wallet_outbox"

View File

@ -34,6 +34,8 @@ rocketmq:
namespace: "" namespace: ""
send_timeout: "3s" send_timeout: "3s"
retry: 2 retry: 2
# Phase A 保持 legacy确认所有 wallet consumer 已双订阅且稳定后Phase B 只把此值改为 event_type。
wallet_event_tag_mode: "legacy"
wallet_outbox: wallet_outbox:
enabled: true enabled: true
topic: "hyapp_wallet_outbox" topic: "hyapp_wallet_outbox"

View File

@ -0,0 +1,110 @@
-- wallet resource outbox transaction_id repair and controlled retry.
--
-- This file is deliberately outside initdb/migrations: Phase A must not replay any
-- historical message. Run it only after Phase B (`wallet_event_tag_mode=event_type`)
-- is live, all wallet consumer subscriptions are healthy, and the target rows below
-- have been reviewed. One execution handles at most 100 unlocked rows; repeat while
-- observing MQ lag and consumer errors.
--
-- Preview-only (default, never mutates rows):
-- mysql ... < 20260711_retry_incomplete_resource_outbox.sql
--
-- Explicit Phase-B execution in the same mysql session:
-- SET @execute_wallet_resource_outbox_retry = 1;
-- SOURCE /absolute/path/20260711_retry_incomplete_resource_outbox.sql;
SET @execute_wallet_resource_outbox_retry :=
IF(COALESCE(@execute_wallet_resource_outbox_retry, 0) = 1, 1, 0);
DROP TEMPORARY TABLE IF EXISTS tmp_wallet_resource_outbox_retry;
CREATE TEMPORARY TABLE tmp_wallet_resource_outbox_retry (
app_code VARCHAR(32) NOT NULL,
event_id VARCHAR(128) NOT NULL,
PRIMARY KEY (app_code, event_id)
) ENGINE=MEMORY;
-- The event list is the complete set currently created through resourceOutboxEvent.
-- Empty transaction_id plus the exact encoder error prevents this maintenance action
-- from touching financial facts or resource failures caused by any other condition.
INSERT INTO tmp_wallet_resource_outbox_retry (app_code, event_id)
SELECT app_code, event_id
FROM wallet_outbox
WHERE asset_type = 'RESOURCE'
AND event_type IN (
'GiftConfigChanged',
'ResourceChanged',
'ResourceGrantRevoked',
'ResourceGranted',
'ResourceGroupChanged',
'ResourceGroupGranted',
'UserResourceChanged',
'VipEffectiveChanged',
'VipTrialCardGranted'
)
AND transaction_id = ''
AND BINARY last_error = BINARY 'wallet outbox message is incomplete'
AND status IN ('failed', 'retryable')
AND worker_id = ''
AND lock_until_ms IS NULL
ORDER BY created_at_ms, event_id
LIMIT 100;
SELECT
wo.app_code,
wo.event_id,
wo.event_type,
wo.status,
wo.retry_count,
wo.created_at_ms
FROM wallet_outbox wo
JOIN tmp_wallet_resource_outbox_retry target
ON target.app_code = wo.app_code AND target.event_id = wo.event_id
ORDER BY wo.created_at_ms, wo.event_id;
START TRANSACTION;
-- @execute_wallet_resource_outbox_retry defaults to 0. When explicitly set to 1,
-- transaction_id=event_id restores a stable correlation ID and status=pending asks
-- the already-running Phase-B worker to publish exactly the original outbox fact.
UPDATE wallet_outbox wo
JOIN tmp_wallet_resource_outbox_retry target
ON target.app_code = wo.app_code AND target.event_id = wo.event_id
SET wo.transaction_id = wo.event_id,
wo.status = 'pending',
wo.worker_id = '',
wo.lock_until_ms = NULL,
wo.retry_count = 0,
wo.next_retry_at_ms = NULL,
wo.last_error = NULL,
wo.updated_at_ms = CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED)
WHERE @execute_wallet_resource_outbox_retry = 1
AND wo.asset_type = 'RESOURCE'
AND wo.event_type IN (
'GiftConfigChanged',
'ResourceChanged',
'ResourceGrantRevoked',
'ResourceGranted',
'ResourceGroupChanged',
'ResourceGroupGranted',
'UserResourceChanged',
'VipEffectiveChanged',
'VipTrialCardGranted'
)
AND wo.transaction_id = ''
AND BINARY wo.last_error = BINARY 'wallet outbox message is incomplete'
AND wo.status IN ('failed', 'retryable')
AND wo.worker_id = ''
AND wo.lock_until_ms IS NULL;
SET @wallet_resource_outbox_requeued_rows := ROW_COUNT();
COMMIT;
SELECT
@execute_wallet_resource_outbox_retry AS execution_enabled,
@wallet_resource_outbox_requeued_rows AS requeued_rows,
CASE
WHEN @execute_wallet_resource_outbox_retry = 1 THEN 'phase_b_batch_requeued'
ELSE 'preview_only_no_rows_changed'
END AS result;
DROP TEMPORARY TABLE IF EXISTS tmp_wallet_resource_outbox_retry;

View File

@ -48,6 +48,7 @@ type App struct {
externalRechargeReconcileWorkerCfg config.ExternalRechargeReconcileWorkerConfig externalRechargeReconcileWorkerCfg config.ExternalRechargeReconcileWorkerConfig
walletOutboxTopic string walletOutboxTopic string
realtimeWalletOutboxTopic string realtimeWalletOutboxTopic string
walletEventTagMode config.WalletEventTagMode
nodeID string nodeID string
redPacketExpiryWorkerCfg config.RedPacketExpiryWorkerConfig redPacketExpiryWorkerCfg config.RedPacketExpiryWorkerConfig
stopWorker context.CancelFunc stopWorker context.CancelFunc
@ -208,6 +209,7 @@ func New(cfg config.Config) (*App, error) {
externalRechargeReconcileWorkerCfg: cfg.ExternalRechargeReconcileWorker, externalRechargeReconcileWorkerCfg: cfg.ExternalRechargeReconcileWorker,
walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic, walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic,
realtimeWalletOutboxTopic: cfg.RocketMQ.RealtimeOutbox.Topic, realtimeWalletOutboxTopic: cfg.RocketMQ.RealtimeOutbox.Topic,
walletEventTagMode: cfg.RocketMQ.WalletEventTagMode,
nodeID: cfg.NodeID, nodeID: cfg.NodeID,
redPacketExpiryWorkerCfg: cfg.RedPacketExpiryWorker, redPacketExpiryWorkerCfg: cfg.RedPacketExpiryWorker,
}, nil }, nil

View File

@ -31,12 +31,22 @@ func newWalletProjectionConsumer(cfg config.Config, svc *walletservice.Service)
if svc == nil || !cfg.ProjectionWorker.Enabled { if svc == nil || !cfg.ProjectionWorker.Enabled {
return nil, nil return nil, nil
} }
// 钱包自有投影只关心礼物墙和徽章发放事实;兼容 selector 同时保留
// legacy Tag待旧消息排空后可在后续阶段只留下这三个 typed Tag。
tagExpression, err := walletmq.LegacyCompatibleTagExpression(
walletmq.EventTypeWalletGiftDebited,
walletmq.EventTypeResourceGranted,
walletmq.EventTypeResourceGroupGranted,
)
if err != nil {
return nil, err
}
consumer, err := rocketmqx.NewConsumer(rocketMQProjectionConsumerConfig(cfg.RocketMQ, cfg.ProjectionWorker)) consumer, err := rocketmqx.NewConsumer(rocketMQProjectionConsumerConfig(cfg.RocketMQ, cfg.ProjectionWorker))
if err != nil { if err != nil {
return nil, err return nil, err
} }
workerID := "wallet-projection-" + cfg.NodeID workerID := "wallet-projection-" + cfg.NodeID
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, tagExpression, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body) walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body)
if err != nil { if err != nil {
return err return err

View File

@ -94,10 +94,34 @@ func (a *App) processWalletOutboxBatch(ctx context.Context, workerID string, opt
} }
func (a *App) publishWalletOutboxRecord(ctx context.Context, producer *rocketmqx.Producer, topic string, record mysqlstorage.WalletOutboxRecord) error { func (a *App) publishWalletOutboxRecord(ctx context.Context, producer *rocketmqx.Producer, topic string, record mysqlstorage.WalletOutboxRecord) error {
message, err := walletOutboxRocketMessage(topic, a.walletEventTagMode, record)
if err != nil {
return err
}
return producer.SendSync(ctx, message)
}
// walletOutboxRocketMessage keeps the envelope event_type canonical in both phases.
// Phase A deliberately publishes the legacy Tag; Phase B uses that same event_type as
// the Broker routing Tag, avoiding any derived category that could drift from handlers.
func walletOutboxRocketMessage(topic string, tagMode config.WalletEventTagMode, record mysqlstorage.WalletOutboxRecord) (rocketmqx.Message, error) {
eventTag, err := walletmq.EventTypeTag(record.EventType)
if err != nil {
return rocketmqx.Message{}, err
}
publishTag := walletmq.TagWalletOutboxEvent
switch tagMode {
case "", config.WalletEventTagModeLegacy:
// 空值属于旧配置或测试构造,必须保持 legacy只有显式 event_type 才能进入 Phase B。
case config.WalletEventTagModeEventType:
publishTag = eventTag
default:
return rocketmqx.Message{}, fmt.Errorf("unsupported wallet event tag mode %q", tagMode)
}
body, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{ body, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{
AppCode: record.AppCode, AppCode: record.AppCode,
EventID: record.EventID, EventID: record.EventID,
EventType: record.EventType, EventType: eventTag,
TransactionID: record.TransactionID, TransactionID: record.TransactionID,
CommandID: record.CommandID, CommandID: record.CommandID,
UserID: record.UserID, UserID: record.UserID,
@ -108,14 +132,14 @@ func (a *App) publishWalletOutboxRecord(ctx context.Context, producer *rocketmqx
OccurredAtMS: record.CreatedAtMS, OccurredAtMS: record.CreatedAtMS,
}) })
if err != nil { if err != nil {
return err return rocketmqx.Message{}, err
} }
return producer.SendSync(ctx, rocketmqx.Message{ return rocketmqx.Message{
Topic: topic, Topic: topic,
Tag: walletmq.TagWalletOutboxEvent, Tag: publishTag,
Keys: []string{record.EventID, record.TransactionID, record.CommandID}, Keys: []string{record.EventID, record.TransactionID, record.CommandID},
Body: body, Body: body,
}) }, nil
} }
func deadWalletOutboxReason(record mysqlstorage.WalletOutboxRecord) string { func deadWalletOutboxReason(record mysqlstorage.WalletOutboxRecord) string {

View File

@ -0,0 +1,85 @@
package app
import (
"testing"
"hyapp/pkg/walletmq"
"hyapp/services/wallet-service/internal/config"
mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
)
func TestWalletOutboxRocketMessageUsesConfiguredTagMode(t *testing.T) {
tests := []struct {
name string
mode config.WalletEventTagMode
wantTag string
}{
{
name: "missing config remains legacy",
mode: "",
wantTag: walletmq.TagWalletOutboxEvent,
},
{
name: "phase a publishes legacy",
mode: config.WalletEventTagModeLegacy,
wantTag: walletmq.TagWalletOutboxEvent,
},
{
name: "phase b publishes event type",
mode: config.WalletEventTagModeEventType,
wantTag: walletmq.EventTypeWalletGiftDebited,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
message, err := walletOutboxRocketMessage("hyapp_wallet_outbox", test.mode, validWalletOutboxRecord())
if err != nil {
t.Fatalf("walletOutboxRocketMessage failed: %v", err)
}
if message.Tag != test.wantTag {
t.Fatalf("message tag = %q, want %q", message.Tag, test.wantTag)
}
decoded, err := walletmq.DecodeWalletOutboxMessage(message.Body)
if err != nil {
t.Fatalf("DecodeWalletOutboxMessage failed: %v", err)
}
if decoded.EventType != walletmq.EventTypeWalletGiftDebited {
t.Fatalf("body event_type = %q", decoded.EventType)
}
if message.Topic != "hyapp_wallet_outbox" || len(message.Keys) != 3 {
t.Fatalf("message routing metadata mismatch: %+v", message)
}
})
}
}
func TestWalletOutboxRocketMessageRejectsUnsupportedTagMode(t *testing.T) {
_, err := walletOutboxRocketMessage("hyapp_wallet_outbox", config.WalletEventTagMode("all"), validWalletOutboxRecord())
if err == nil {
t.Fatal("walletOutboxRocketMessage unexpectedly accepted an unsupported tag mode")
}
}
func TestWalletOutboxRocketMessageRejectsUnsafeEventTypeTag(t *testing.T) {
record := validWalletOutboxRecord()
record.EventType = "WalletGiftDebited || *"
_, err := walletOutboxRocketMessage("hyapp_wallet_outbox", config.WalletEventTagModeLegacy, record)
if err == nil {
t.Fatal("walletOutboxRocketMessage unexpectedly accepted an unsafe event_type")
}
}
func validWalletOutboxRecord() mysqlstorage.WalletOutboxRecord {
return mysqlstorage.WalletOutboxRecord{
AppCode: "lalu",
EventID: "event-1",
EventType: " " + walletmq.EventTypeWalletGiftDebited + " ",
TransactionID: "transaction-1",
CommandID: "command-1",
UserID: 1001,
AssetType: "COIN",
AvailableDelta: -100,
PayloadJSON: `{"gift_id":"gift-1"}`,
CreatedAtMS: 1_700_000_000_000,
}
}

View File

@ -66,20 +66,33 @@ type RedPacketExpiryWorkerConfig struct {
// RocketMQConfig 描述 wallet-service 发布账务事实的 MQ 连接。 // RocketMQConfig 描述 wallet-service 发布账务事实的 MQ 连接。
type RocketMQConfig struct { type RocketMQConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
NameServers []string `yaml:"name_servers"` NameServers []string `yaml:"name_servers"`
NameServerDomain string `yaml:"name_server_domain"` NameServerDomain string `yaml:"name_server_domain"`
AccessKey string `yaml:"access_key"` AccessKey string `yaml:"access_key"`
SecretKey string `yaml:"secret_key"` SecretKey string `yaml:"secret_key"`
SecurityToken string `yaml:"security_token"` SecurityToken string `yaml:"security_token"`
Namespace string `yaml:"namespace"` Namespace string `yaml:"namespace"`
VIPChannel bool `yaml:"vip_channel"` VIPChannel bool `yaml:"vip_channel"`
SendTimeout time.Duration `yaml:"send_timeout"` SendTimeout time.Duration `yaml:"send_timeout"`
Retry int `yaml:"retry"` Retry int `yaml:"retry"`
WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"` // WalletEventTagMode 是 wallet topic producer 的两阶段发布开关:
RealtimeOutbox WalletOutboxMQConfig `yaml:"realtime_outbox"` // legacy 用统一 Tag 保证旧消费者可见event_type 仅在所有消费者完成双订阅后启用。
WalletEventTagMode WalletEventTagMode `yaml:"wallet_event_tag_mode"`
WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"`
RealtimeOutbox WalletOutboxMQConfig `yaml:"realtime_outbox"`
} }
// WalletEventTagMode 控制钱包事实的 Broker 路由 Tag不改变消息体协议。
type WalletEventTagMode string
const (
// WalletEventTagModeLegacy 是安全默认值;旧配置缺字段时不能因普通滚动发布提前切 typed Tag。
WalletEventTagModeLegacy WalletEventTagMode = "legacy"
// WalletEventTagModeEventType 在 Phase B 使用消息 event_type 作为 Tag让 Broker 精确过滤。
WalletEventTagModeEventType WalletEventTagMode = "event_type"
)
// WalletOutboxMQConfig 保存 wallet_outbox topic 的生产者配置。 // WalletOutboxMQConfig 保存 wallet_outbox topic 的生产者配置。
type WalletOutboxMQConfig struct { type WalletOutboxMQConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
@ -367,9 +380,10 @@ func Default() Config {
func defaultRocketMQConfig() RocketMQConfig { func defaultRocketMQConfig() RocketMQConfig {
return RocketMQConfig{ return RocketMQConfig{
Enabled: false, Enabled: false,
SendTimeout: 3 * time.Second, SendTimeout: 3 * time.Second,
Retry: 2, Retry: 2,
WalletEventTagMode: WalletEventTagModeLegacy,
WalletOutbox: WalletOutboxMQConfig{ WalletOutbox: WalletOutboxMQConfig{
Enabled: false, Enabled: false,
Topic: "hyapp_wallet_outbox", Topic: "hyapp_wallet_outbox",
@ -769,6 +783,16 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
if cfg.Retry <= 0 { if cfg.Retry <= 0 {
cfg.Retry = defaults.Retry cfg.Retry = defaults.Retry
} }
cfg.WalletEventTagMode = WalletEventTagMode(strings.ToLower(strings.TrimSpace(string(cfg.WalletEventTagMode))))
if cfg.WalletEventTagMode == "" {
// 配置升级必须显式进入 Phase B历史线上 YAML 缺少新字段时始终继续发布 legacy Tag。
cfg.WalletEventTagMode = defaults.WalletEventTagMode
}
switch cfg.WalletEventTagMode {
case WalletEventTagModeLegacy, WalletEventTagModeEventType:
default:
return RocketMQConfig{}, errors.New("rocketmq.wallet_event_tag_mode must be legacy or event_type")
}
if cfg.WalletOutbox.Topic = strings.TrimSpace(cfg.WalletOutbox.Topic); cfg.WalletOutbox.Topic == "" { if cfg.WalletOutbox.Topic = strings.TrimSpace(cfg.WalletOutbox.Topic); cfg.WalletOutbox.Topic == "" {
cfg.WalletOutbox.Topic = defaults.WalletOutbox.Topic cfg.WalletOutbox.Topic = defaults.WalletOutbox.Topic
} }

View File

@ -4,6 +4,8 @@ import (
"crypto/rsa" "crypto/rsa"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
) )
@ -17,6 +19,9 @@ func TestLoadLocalEnablesOutboxMQ(t *testing.T) {
if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.WalletOutbox.Enabled || !cfg.RocketMQ.RealtimeOutbox.Enabled { if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.WalletOutbox.Enabled || !cfg.RocketMQ.RealtimeOutbox.Enabled {
t.Fatalf("local config must enable wallet outbox MQ producers: %+v", cfg.RocketMQ) t.Fatalf("local config must enable wallet outbox MQ producers: %+v", cfg.RocketMQ)
} }
if cfg.RocketMQ.WalletEventTagMode != WalletEventTagModeLegacy {
t.Fatalf("Phase A config must publish legacy wallet tags: %q", cfg.RocketMQ.WalletEventTagMode)
}
if !cfg.OutboxWorker.Enabled || !cfg.RealtimeOutboxWorker.Enabled || !cfg.ProjectionWorker.Enabled { if !cfg.OutboxWorker.Enabled || !cfg.RealtimeOutboxWorker.Enabled || !cfg.ProjectionWorker.Enabled {
t.Fatalf("local config must enable wallet outbox workers: outbox=%+v realtime=%+v projection=%+v", cfg.OutboxWorker, cfg.RealtimeOutboxWorker, cfg.ProjectionWorker) t.Fatalf("local config must enable wallet outbox workers: outbox=%+v realtime=%+v projection=%+v", cfg.OutboxWorker, cfg.RealtimeOutboxWorker, cfg.ProjectionWorker)
} }
@ -31,6 +36,50 @@ func TestLoadLocalEnablesOutboxMQ(t *testing.T) {
} }
} }
func TestDefaultWalletEventTagModeIsLegacy(t *testing.T) {
if got := Default().RocketMQ.WalletEventTagMode; got != WalletEventTagModeLegacy {
t.Fatalf("default wallet event tag mode = %q, want %q", got, WalletEventTagModeLegacy)
}
}
func TestWalletEventTagModeMissingConfigDefaultsToLegacy(t *testing.T) {
path := writeWalletConfig(t, "rocketmq:\n enabled: false\n")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load missing tag mode config failed: %v", err)
}
if cfg.RocketMQ.WalletEventTagMode != WalletEventTagModeLegacy {
t.Fatalf("missing wallet_event_tag_mode = %q, want %q", cfg.RocketMQ.WalletEventTagMode, WalletEventTagModeLegacy)
}
}
func TestWalletEventTagModeParsesExplicitPhaseB(t *testing.T) {
path := writeWalletConfig(t, "rocketmq:\n enabled: false\n wallet_event_tag_mode: ' EVENT_TYPE '\n")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load Phase B tag mode config failed: %v", err)
}
if cfg.RocketMQ.WalletEventTagMode != WalletEventTagModeEventType {
t.Fatalf("wallet_event_tag_mode = %q, want %q", cfg.RocketMQ.WalletEventTagMode, WalletEventTagModeEventType)
}
}
func TestWalletEventTagModeRejectsUnknownValue(t *testing.T) {
path := writeWalletConfig(t, "rocketmq:\n enabled: false\n wallet_event_tag_mode: wildcard\n")
if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "wallet_event_tag_mode") {
t.Fatalf("Load unknown wallet_event_tag_mode error = %v", err)
}
}
func writeWalletConfig(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return path
}
func TestLoadTencentExampleSplitsRealtimeOutboxAndRaisesThroughput(t *testing.T) { func TestLoadTencentExampleSplitsRealtimeOutboxAndRaisesThroughput(t *testing.T) {
cfg, err := Load("../../configs/config.tencent.example.yaml") cfg, err := Load("../../configs/config.tencent.example.yaml")
if err != nil { if err != nil {

View File

@ -129,7 +129,7 @@ func isBadgeGrantWalletProjectionMessage(message walletmq.WalletOutboxMessage) b
return false return false
} }
switch message.EventType { switch message.EventType {
case "ResourceGranted", "ResourceGroupGranted": case walletmq.EventTypeResourceGranted, walletmq.EventTypeResourceGroupGranted:
return true return true
default: default:
return false return false

View File

@ -17,8 +17,8 @@ import (
const ( const (
badgeDisplayProjectionName = "activity_badge_display" badgeDisplayProjectionName = "activity_badge_display"
walletResourceGrantedEvent = "ResourceGranted" walletResourceGrantedEvent = walletmq.EventTypeResourceGranted
walletResourceGroupGrantedEvent = "ResourceGroupGranted" walletResourceGroupGrantedEvent = walletmq.EventTypeResourceGroupGranted
grantSourceVIPPurchase = "vip_purchase" grantSourceVIPPurchase = "vip_purchase"
) )

View File

@ -16,7 +16,7 @@ import (
const ( const (
giftWallProjectionName = "user_gift_wall" giftWallProjectionName = "user_gift_wall"
walletGiftDebitedEvent = "WalletGiftDebited" walletGiftDebitedEvent = walletmq.EventTypeWalletGiftDebited
// projection 状态机只描述“某个 outbox 事件是否已经投影进礼物墙”。 // projection 状态机只描述“某个 outbox 事件是否已经投影进礼物墙”。
// 这里不复用 wallet_outbox.status避免展示读模型反向污染账务事件分发语义。 // 这里不复用 wallet_outbox.status避免展示读模型反向污染账务事件分发语义。

View File

@ -8,10 +8,14 @@ import (
func resourceOutboxEvent(eventType string, commandID string, userID int64, resourceID int64, payload any, nowMs int64) walletOutboxEvent { func resourceOutboxEvent(eventType string, commandID string, userID int64, resourceID int64, payload any, nowMs int64) walletOutboxEvent {
eventKey := fmt.Sprintf("%s|%s|%d|%d", eventType, commandID, userID, resourceID) eventKey := fmt.Sprintf("%s|%s|%d|%d", eventType, commandID, userID, resourceID)
eventID := "wev_" + stableHash(eventKey)
return walletOutboxEvent{ return walletOutboxEvent{
EventID: "wev_" + stableHash(eventKey), EventID: eventID,
EventType: eventType, EventType: eventType,
TransactionID: "", // 资源目录、赠送和装备变更没有金融 ledger transaction使用稳定 event_id
// 作为 correlation transaction_id既满足统一 MQ envelope又能从消息直接
// 回查唯一 outbox 行。相同业务命令重试会得到同一 ID不会制造新事实。
TransactionID: eventID,
CommandID: commandID, CommandID: commandID,
UserID: userID, UserID: userID,
AssetType: resourceOutboxAsset, AssetType: resourceOutboxAsset,

View File

@ -0,0 +1,98 @@
package mysql
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"hyapp/pkg/walletmq"
)
func TestResourceOutboxEventsUseStableEventIDAsTransactionID(t *testing.T) {
// 这些是当前所有通过 resourceOutboxEvent 创建的资源事实helper 对事件类型
// 本身无分支,因此该约束也自动覆盖以后接入同一入口的新资源事实。
eventTypes := []string{
"GiftConfigChanged",
"ResourceChanged",
"ResourceGrantRevoked",
"ResourceGranted",
"ResourceGroupChanged",
"ResourceGroupGranted",
"UserResourceChanged",
"VipEffectiveChanged",
"VipTrialCardGranted",
}
for _, eventType := range eventTypes {
t.Run(eventType, func(t *testing.T) {
first := resourceOutboxEvent(eventType, "command-1", 1001, 2001, map[string]any{"source": "test"}, 1_700_000_000_000)
second := resourceOutboxEvent(eventType, "command-1", 1001, 2001, map[string]any{"source": "retry"}, 1_700_000_000_999)
if first.TransactionID == "" || first.TransactionID != first.EventID {
t.Fatalf("resource correlation ids mismatch: event_id=%q transaction_id=%q", first.EventID, first.TransactionID)
}
if second.EventID != first.EventID || second.TransactionID != first.TransactionID {
t.Fatalf("resource correlation id changed on retry: first=%+v second=%+v", first, second)
}
if len(first.TransactionID) > 96 {
t.Fatalf("resource transaction_id exceeds wallet_outbox column: %d", len(first.TransactionID))
}
if _, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{
AppCode: "lalu",
EventID: first.EventID,
EventType: first.EventType,
TransactionID: first.TransactionID,
CommandID: first.CommandID,
UserID: first.UserID,
AssetType: first.AssetType,
PayloadJSON: `{"source":"test"}`,
OccurredAtMS: first.CreatedAtMS,
}); err != nil {
t.Fatalf("resource event is still rejected by wallet MQ encoder: %v", err)
}
})
}
}
func TestResourceOutboxHistoricalRetryIsOptInAndNarrowlyScoped(t *testing.T) {
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve test source path")
}
path := filepath.Join(filepath.Dir(file), "../../../deploy/mysql/maintenance/20260711_retry_incomplete_resource_outbox.sql")
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read resource outbox maintenance SQL: %v", err)
}
sql := string(body)
for _, required := range []string{
"COALESCE(@execute_wallet_resource_outbox_retry, 0)",
"WHERE @execute_wallet_resource_outbox_retry = 1",
"AND wo.asset_type = 'RESOURCE'",
"AND wo.transaction_id = ''",
"AND BINARY wo.last_error = BINARY 'wallet outbox message is incomplete'",
"AND wo.status IN ('failed', 'retryable')",
"LIMIT 100",
"SET wo.transaction_id = wo.event_id",
"wo.status = 'pending'",
} {
if !strings.Contains(sql, required) {
t.Fatalf("maintenance SQL misses safety contract %q", required)
}
}
for _, eventType := range []string{
"GiftConfigChanged",
"ResourceChanged",
"ResourceGrantRevoked",
"ResourceGranted",
"ResourceGroupChanged",
"ResourceGroupGranted",
"UserResourceChanged",
"VipEffectiveChanged",
"VipTrialCardGranted",
} {
if !strings.Contains(sql, "'"+eventType+"'") {
t.Fatalf("maintenance SQL misses resource event type %q", eventType)
}
}
}