feat: add lossless typed wallet MQ tags
This commit is contained in:
parent
3666a9ff02
commit
1fe23326c6
@ -4,15 +4,92 @@ package walletmq
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageTypeWalletOutboxEvent = "wallet_outbox_event"
|
||||
|
||||
// TagWalletOutboxEvent 是 typed tag 上线前所有钱包事实共用的 legacy Tag。
|
||||
// 兼容窗口内消费者必须同时订阅它和自己需要的 EventType Tag,确保 producer
|
||||
// 切换前已经进入 Broker 的旧消息仍会被同一 consumer group 完整消费。
|
||||
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.
|
||||
type WalletOutboxMessage struct {
|
||||
MessageType string `json:"message_type"`
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package walletmq
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWalletOutboxMessageRoundTrip(t *testing.T) {
|
||||
encoded, err := EncodeWalletOutboxMessage(WalletOutboxMessage{
|
||||
@ -27,3 +30,59 @@ func TestWalletOutboxMessageRoundTrip(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -186,7 +186,7 @@ func newFirstRechargeWalletConsumer(cfg config.Config, services *serviceBundle)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
@ -205,7 +205,7 @@ func newCumulativeRechargeWalletConsumer(cfg config.Config, services *serviceBun
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
@ -224,7 +224,7 @@ func newInviteActivityWalletConsumer(cfg config.Config, services *serviceBundle)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
@ -268,7 +268,11 @@ func newRedPacketWalletConsumer(cfg config.Config, services *serviceBundle) (*ro
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
@ -288,7 +292,7 @@ func newVIPRebateNoticeWalletConsumer(cfg config.Config, services *serviceBundle
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
@ -310,7 +314,7 @@ func newUserLeaderboardWalletConsumer(cfg config.Config, services *serviceBundle
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
@ -330,7 +334,7 @@ func newTaskWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketm
|
||||
if err != nil {
|
||||
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,不能复用红包或其他钱包消费者进度;否则充值任务会被其他业务确认掉。
|
||||
event, appCode, ok, err := taskEventFromWalletMessage(message.Body)
|
||||
if err != nil || !ok {
|
||||
@ -346,6 +350,18 @@ func newTaskWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketm
|
||||
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) {
|
||||
consumer, err := rocketmqx.NewConsumer(userOutboxTaskConsumerConfig(cfg.RocketMQ))
|
||||
if err != nil {
|
||||
|
||||
@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
taskWalletRechargeRecorded = "WalletRechargeRecorded"
|
||||
taskWalletRechargeRecorded = walletmq.EventTypeWalletRechargeRecorded
|
||||
taskCPRelationshipCreated = "UserCPRelationshipCreated"
|
||||
taskMicDailyStatsUpdated = "UserMicDailyStatsUpdated"
|
||||
taskMoodPublished = "UserMoodPublished"
|
||||
|
||||
@ -13,7 +13,7 @@ import (
|
||||
messageservice "hyapp/services/activity-service/internal/service/message"
|
||||
)
|
||||
|
||||
const vipDailyCoinRebateAvailableEventType = "VipDailyCoinRebateAvailable"
|
||||
const vipDailyCoinRebateAvailableEventType = walletmq.EventTypeVIPDailyCoinRebateAvailable
|
||||
|
||||
// vipRebateAvailableEvent 是 wallet outbox 的逐用户返现资格快照。
|
||||
// activity-service 只把这个已提交事实投影为 inbox;资格、金额、过期和最终入账仍由 wallet owner 判定。
|
||||
|
||||
@ -18,7 +18,7 @@ func firstRechargeEventFromWalletMessage(body []byte) (firstrechargedomain.Recha
|
||||
if err != nil {
|
||||
return firstrechargedomain.RechargeEvent{}, false, err
|
||||
}
|
||||
if message.EventType != firstrechargedomain.RechargeEventWalletRecorded {
|
||||
if message.EventType != walletmq.EventTypeWalletRechargeRecorded {
|
||||
return firstrechargedomain.RechargeEvent{}, false, nil
|
||||
}
|
||||
fields := firstRechargeFactFromWalletPayload(message.PayloadJSON)
|
||||
@ -49,7 +49,7 @@ func cumulativeRechargeEventFromWalletMessage(body []byte) (cumulativerechargedo
|
||||
if err != nil {
|
||||
return cumulativerechargedomain.RechargeEvent{}, false, err
|
||||
}
|
||||
if message.EventType != cumulativerechargedomain.RechargeEventWalletRecorded {
|
||||
if message.EventType != walletmq.EventTypeWalletRechargeRecorded {
|
||||
return cumulativerechargedomain.RechargeEvent{}, false, nil
|
||||
}
|
||||
// WalletRechargeRecorded 是累充唯一事实源;payload 里没有可折算 USD 金额时直接跳过,避免把普通钱包流水计入活动。
|
||||
@ -78,7 +78,7 @@ func inviteActivityRechargeEventFromWalletMessage(body []byte) (inviteactivitydo
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
fields := cumulativeRechargeFactFromWalletPayload(message.PayloadJSON, message.AvailableDelta)
|
||||
@ -101,7 +101,9 @@ func redPacketEventFromWalletMessage(body []byte) (broadcastdomain.RedPacketWall
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
fields := redPacketFieldsFromWalletPayload(message.PayloadJSON, message.EventType)
|
||||
@ -138,7 +140,7 @@ func userLeaderboardGiftEventFromWalletMessage(body []byte) (userleaderboard.Gif
|
||||
if err != nil {
|
||||
return userleaderboard.GiftEvent{}, false, err
|
||||
}
|
||||
if message.EventType != "WalletGiftDebited" {
|
||||
if message.EventType != walletmq.EventTypeWalletGiftDebited {
|
||||
return userleaderboard.GiftEvent{}, false, nil
|
||||
}
|
||||
var decoded map[string]any
|
||||
@ -310,12 +312,12 @@ func redPacketFieldsFromWalletPayload(payload string, eventType string) redPacke
|
||||
status := stringFromDecoded(decoded, "status")
|
||||
openAtMS := int64FromDecoded(decoded, "open_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")
|
||||
}
|
||||
totalCount := int32FromDecoded(decoded, "packet_count")
|
||||
remainingCount := int32FromDecoded(decoded, "remaining_count")
|
||||
if remainingCount == 0 && eventType == "WalletRedPacketCreated" {
|
||||
if remainingCount == 0 && eventType == walletmq.EventTypeWalletRedPacketCreated {
|
||||
remainingCount = totalCount
|
||||
}
|
||||
return redPacketPayloadFields{
|
||||
|
||||
@ -148,13 +148,24 @@ func New(cfg config.Config) (*App, error) {
|
||||
confirmOptions := confirmNoticeWorkerOptions(cfg.ConfirmNoticeWorker)
|
||||
mqConsumers := make([]*rocketmqx.Consumer, 0, 4)
|
||||
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))
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
_ = store.Close()
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@ -18,8 +18,8 @@ import (
|
||||
const (
|
||||
sourceWalletOutbox = "wallet_outbox"
|
||||
channelTencentIMC2C = "tencent_im_c2c"
|
||||
eventWalletBalance = "WalletBalanceChanged"
|
||||
eventVIPActivated = "VipActivated"
|
||||
eventWalletBalance = walletmq.EventTypeWalletBalanceChanged
|
||||
eventVIPActivated = walletmq.EventTypeVIPActivated
|
||||
robotCoinAssetType = "ROBOT_COIN"
|
||||
deliveryStatusDelivering = "delivering"
|
||||
deliveryStatusDelivered = "delivered"
|
||||
|
||||
@ -145,7 +145,15 @@ func newConsumers(cfg config.Config, repo *mysqlstorage.Repository) ([]*rocketmq
|
||||
}); err != nil {
|
||||
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)
|
||||
}); err != nil {
|
||||
shutdownConsumers(consumers)
|
||||
@ -166,6 +174,21 @@ func newConsumers(cfg config.Config, repo *mysqlstorage.Repository) ([]*rocketmq
|
||||
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 出现两套解析口径。
|
||||
func ConsumeUserOutboxBody(ctx context.Context, repo *mysqlstorage.Repository, body []byte) error {
|
||||
if event, ok, err := userRegisteredEvent(body); err != nil || ok {
|
||||
@ -359,7 +382,7 @@ func rechargeEvent(body []byte) (mysqlstorage.RechargeEvent, bool, error) {
|
||||
if err != nil {
|
||||
return mysqlstorage.RechargeEvent{}, false, err
|
||||
}
|
||||
if message.EventType != "WalletRechargeRecorded" {
|
||||
if message.EventType != walletmq.EventTypeWalletRechargeRecorded {
|
||||
return mysqlstorage.RechargeEvent{}, false, nil
|
||||
}
|
||||
payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
|
||||
@ -391,7 +414,7 @@ func coinSellerStockEvent(body []byte) (mysqlstorage.CoinSellerStockEvent, bool,
|
||||
if err != nil {
|
||||
return mysqlstorage.CoinSellerStockEvent{}, false, err
|
||||
}
|
||||
if message.EventType != "WalletCoinSellerStockPurchased" {
|
||||
if message.EventType != walletmq.EventTypeWalletCoinSellerStockPurchased {
|
||||
return mysqlstorage.CoinSellerStockEvent{}, false, nil
|
||||
}
|
||||
payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
|
||||
@ -459,7 +482,7 @@ func luckyGiftRewardEvent(body []byte) (mysqlstorage.LuckyGiftRewardEvent, bool,
|
||||
if err != nil {
|
||||
return mysqlstorage.LuckyGiftRewardEvent{}, false, err
|
||||
}
|
||||
if message.EventType != "WalletLuckyGiftRewardCredited" {
|
||||
if message.EventType != walletmq.EventTypeWalletLuckyGiftRewardCredited {
|
||||
return mysqlstorage.LuckyGiftRewardEvent{}, false, nil
|
||||
}
|
||||
payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
|
||||
@ -481,7 +504,7 @@ func platformGrantCoinEvent(body []byte) (mysqlstorage.PlatformGrantCoinEvent, b
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
|
||||
@ -502,7 +525,7 @@ func giftIncomeOutputEvent(body []byte) (mysqlstorage.CoinOutputEvent, bool, err
|
||||
if err != nil {
|
||||
return mysqlstorage.CoinOutputEvent{}, false, err
|
||||
}
|
||||
if message.EventType != "WalletGiftIncomeCredited" {
|
||||
if message.EventType != walletmq.EventTypeWalletGiftIncomeCredited {
|
||||
return mysqlstorage.CoinOutputEvent{}, false, nil
|
||||
}
|
||||
payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
|
||||
@ -523,7 +546,7 @@ func walletBalanceChangedEvent(body []byte) (mysqlstorage.WalletBalanceChangedEv
|
||||
if err != nil {
|
||||
return mysqlstorage.WalletBalanceChangedEvent{}, false, err
|
||||
}
|
||||
if message.EventType != "WalletBalanceChanged" {
|
||||
if message.EventType != walletmq.EventTypeWalletBalanceChanged {
|
||||
return mysqlstorage.WalletBalanceChangedEvent{}, false, nil
|
||||
}
|
||||
payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
|
||||
@ -553,11 +576,11 @@ func walletBalanceChangedEvent(body []byte) (mysqlstorage.WalletBalanceChangedEv
|
||||
|
||||
func isPlatformGrantCoinEventType(value string) bool {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "WalletTaskRewardCredited",
|
||||
"WalletWheelRewardCredited",
|
||||
"WalletRoomTurnoverRewardCredited",
|
||||
"WalletInviteActivityRewardCredited",
|
||||
"WalletAgencyOpeningRewardCredited":
|
||||
case walletmq.EventTypeWalletTaskRewardCredited,
|
||||
walletmq.EventTypeWalletWheelRewardCredited,
|
||||
walletmq.EventTypeWalletRoomTurnoverRewardCredited,
|
||||
walletmq.EventTypeWalletInviteActivityRewardCredited,
|
||||
walletmq.EventTypeWalletAgencyOpeningRewardCredited:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -17,6 +18,44 @@ import (
|
||||
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 的解析和聚合边界;
|
||||
// 本地真实业务链路用 scripts/databi-real-flow-check.sh 从各 owner service 的 outbox 重放验证。
|
||||
func TestConsumeOutboxMessagesProjectsDataScreenMetrics(t *testing.T) {
|
||||
|
||||
@ -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) {
|
||||
consumers := make([]*rocketmqx.Consumer, 0, 4)
|
||||
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))
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
|
||||
@ -34,6 +34,8 @@ rocketmq:
|
||||
namespace: ""
|
||||
send_timeout: "3s"
|
||||
retry: 2
|
||||
# Phase A 保持 legacy;所有消费者完成兼容滚动后才允许切换为 event_type。
|
||||
wallet_event_tag_mode: "legacy"
|
||||
wallet_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_wallet_outbox"
|
||||
|
||||
@ -34,6 +34,8 @@ rocketmq:
|
||||
namespace: "hyapp-prod"
|
||||
send_timeout: "3s"
|
||||
retry: 2
|
||||
# Phase A 必须保持 legacy;Phase B 经消费者订阅与积压验证后,仅将该值切为 event_type。
|
||||
wallet_event_tag_mode: "legacy"
|
||||
wallet_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_wallet_outbox"
|
||||
|
||||
@ -34,6 +34,8 @@ rocketmq:
|
||||
namespace: ""
|
||||
send_timeout: "3s"
|
||||
retry: 2
|
||||
# Phase A 保持 legacy;确认所有 wallet consumer 已双订阅且稳定后,Phase B 只把此值改为 event_type。
|
||||
wallet_event_tag_mode: "legacy"
|
||||
wallet_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_wallet_outbox"
|
||||
|
||||
@ -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;
|
||||
@ -48,6 +48,7 @@ type App struct {
|
||||
externalRechargeReconcileWorkerCfg config.ExternalRechargeReconcileWorkerConfig
|
||||
walletOutboxTopic string
|
||||
realtimeWalletOutboxTopic string
|
||||
walletEventTagMode config.WalletEventTagMode
|
||||
nodeID string
|
||||
redPacketExpiryWorkerCfg config.RedPacketExpiryWorkerConfig
|
||||
stopWorker context.CancelFunc
|
||||
@ -208,6 +209,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
externalRechargeReconcileWorkerCfg: cfg.ExternalRechargeReconcileWorker,
|
||||
walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic,
|
||||
realtimeWalletOutboxTopic: cfg.RocketMQ.RealtimeOutbox.Topic,
|
||||
walletEventTagMode: cfg.RocketMQ.WalletEventTagMode,
|
||||
nodeID: cfg.NodeID,
|
||||
redPacketExpiryWorkerCfg: cfg.RedPacketExpiryWorker,
|
||||
}, nil
|
||||
|
||||
@ -31,12 +31,22 @@ func newWalletProjectionConsumer(cfg config.Config, svc *walletservice.Service)
|
||||
if svc == nil || !cfg.ProjectionWorker.Enabled {
|
||||
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))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@ -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 {
|
||||
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{
|
||||
AppCode: record.AppCode,
|
||||
EventID: record.EventID,
|
||||
EventType: record.EventType,
|
||||
EventType: eventTag,
|
||||
TransactionID: record.TransactionID,
|
||||
CommandID: record.CommandID,
|
||||
UserID: record.UserID,
|
||||
@ -108,14 +132,14 @@ func (a *App) publishWalletOutboxRecord(ctx context.Context, producer *rocketmqx
|
||||
OccurredAtMS: record.CreatedAtMS,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return rocketmqx.Message{}, err
|
||||
}
|
||||
return producer.SendSync(ctx, rocketmqx.Message{
|
||||
return rocketmqx.Message{
|
||||
Topic: topic,
|
||||
Tag: walletmq.TagWalletOutboxEvent,
|
||||
Tag: publishTag,
|
||||
Keys: []string{record.EventID, record.TransactionID, record.CommandID},
|
||||
Body: body,
|
||||
})
|
||||
}, nil
|
||||
}
|
||||
|
||||
func deadWalletOutboxReason(record mysqlstorage.WalletOutboxRecord) string {
|
||||
|
||||
85
services/wallet-service/internal/app/outbox_worker_test.go
Normal file
85
services/wallet-service/internal/app/outbox_worker_test.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -66,20 +66,33 @@ type RedPacketExpiryWorkerConfig struct {
|
||||
|
||||
// RocketMQConfig 描述 wallet-service 发布账务事实的 MQ 连接。
|
||||
type RocketMQConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
NameServers []string `yaml:"name_servers"`
|
||||
NameServerDomain string `yaml:"name_server_domain"`
|
||||
AccessKey string `yaml:"access_key"`
|
||||
SecretKey string `yaml:"secret_key"`
|
||||
SecurityToken string `yaml:"security_token"`
|
||||
Namespace string `yaml:"namespace"`
|
||||
VIPChannel bool `yaml:"vip_channel"`
|
||||
SendTimeout time.Duration `yaml:"send_timeout"`
|
||||
Retry int `yaml:"retry"`
|
||||
WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"`
|
||||
RealtimeOutbox WalletOutboxMQConfig `yaml:"realtime_outbox"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
NameServers []string `yaml:"name_servers"`
|
||||
NameServerDomain string `yaml:"name_server_domain"`
|
||||
AccessKey string `yaml:"access_key"`
|
||||
SecretKey string `yaml:"secret_key"`
|
||||
SecurityToken string `yaml:"security_token"`
|
||||
Namespace string `yaml:"namespace"`
|
||||
VIPChannel bool `yaml:"vip_channel"`
|
||||
SendTimeout time.Duration `yaml:"send_timeout"`
|
||||
Retry int `yaml:"retry"`
|
||||
// WalletEventTagMode 是 wallet topic producer 的两阶段发布开关:
|
||||
// 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 的生产者配置。
|
||||
type WalletOutboxMQConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
@ -367,9 +380,10 @@ func Default() Config {
|
||||
|
||||
func defaultRocketMQConfig() RocketMQConfig {
|
||||
return RocketMQConfig{
|
||||
Enabled: false,
|
||||
SendTimeout: 3 * time.Second,
|
||||
Retry: 2,
|
||||
Enabled: false,
|
||||
SendTimeout: 3 * time.Second,
|
||||
Retry: 2,
|
||||
WalletEventTagMode: WalletEventTagModeLegacy,
|
||||
WalletOutbox: WalletOutboxMQConfig{
|
||||
Enabled: false,
|
||||
Topic: "hyapp_wallet_outbox",
|
||||
@ -769,6 +783,16 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
|
||||
if cfg.Retry <= 0 {
|
||||
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 == "" {
|
||||
cfg.WalletOutbox.Topic = defaults.WalletOutbox.Topic
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@ import (
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@ -17,6 +19,9 @@ func TestLoadLocalEnablesOutboxMQ(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
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) {
|
||||
cfg, err := Load("../../configs/config.tencent.example.yaml")
|
||||
if err != nil {
|
||||
|
||||
@ -129,7 +129,7 @@ func isBadgeGrantWalletProjectionMessage(message walletmq.WalletOutboxMessage) b
|
||||
return false
|
||||
}
|
||||
switch message.EventType {
|
||||
case "ResourceGranted", "ResourceGroupGranted":
|
||||
case walletmq.EventTypeResourceGranted, walletmq.EventTypeResourceGroupGranted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@ -17,8 +17,8 @@ import (
|
||||
|
||||
const (
|
||||
badgeDisplayProjectionName = "activity_badge_display"
|
||||
walletResourceGrantedEvent = "ResourceGranted"
|
||||
walletResourceGroupGrantedEvent = "ResourceGroupGranted"
|
||||
walletResourceGrantedEvent = walletmq.EventTypeResourceGranted
|
||||
walletResourceGroupGrantedEvent = walletmq.EventTypeResourceGroupGranted
|
||||
grantSourceVIPPurchase = "vip_purchase"
|
||||
)
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ import (
|
||||
|
||||
const (
|
||||
giftWallProjectionName = "user_gift_wall"
|
||||
walletGiftDebitedEvent = "WalletGiftDebited"
|
||||
walletGiftDebitedEvent = walletmq.EventTypeWalletGiftDebited
|
||||
|
||||
// projection 状态机只描述“某个 outbox 事件是否已经投影进礼物墙”。
|
||||
// 这里不复用 wallet_outbox.status,避免展示读模型反向污染账务事件分发语义。
|
||||
|
||||
@ -8,10 +8,14 @@ import (
|
||||
|
||||
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)
|
||||
eventID := "wev_" + stableHash(eventKey)
|
||||
return walletOutboxEvent{
|
||||
EventID: "wev_" + stableHash(eventKey),
|
||||
EventType: eventType,
|
||||
TransactionID: "",
|
||||
EventID: eventID,
|
||||
EventType: eventType,
|
||||
// 资源目录、赠送和装备变更没有金融 ledger transaction;使用稳定 event_id
|
||||
// 作为 correlation transaction_id,既满足统一 MQ envelope,又能从消息直接
|
||||
// 回查唯一 outbox 行。相同业务命令重试会得到同一 ID,不会制造新事实。
|
||||
TransactionID: eventID,
|
||||
CommandID: commandID,
|
||||
UserID: userID,
|
||||
AssetType: resourceOutboxAsset,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user