// Package walletmq defines RocketMQ payloads owned by wallet-service. 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"` AppCode string `json:"app_code"` EventID string `json:"event_id"` EventType string `json:"event_type"` TransactionID string `json:"transaction_id"` CommandID string `json:"command_id"` UserID int64 `json:"user_id"` AssetType string `json:"asset_type"` AvailableDelta int64 `json:"available_delta"` FrozenDelta int64 `json:"frozen_delta"` PayloadJSON string `json:"payload_json"` OccurredAtMS int64 `json:"occurred_at_ms"` } // EncodeWalletOutboxMessage serializes a wallet fact for fanout through RocketMQ. func EncodeWalletOutboxMessage(message WalletOutboxMessage) ([]byte, error) { message.MessageType = MessageTypeWalletOutboxEvent if strings.TrimSpace(message.AppCode) == "" || strings.TrimSpace(message.EventID) == "" || strings.TrimSpace(message.EventType) == "" || strings.TrimSpace(message.TransactionID) == "" || strings.TrimSpace(message.CommandID) == "" || message.OccurredAtMS <= 0 { return nil, errors.New("wallet outbox message is incomplete") } if strings.TrimSpace(message.PayloadJSON) == "" { message.PayloadJSON = "{}" } return json.Marshal(message) } // DecodeWalletOutboxMessage validates the MQ body and restores the wallet fact. func DecodeWalletOutboxMessage(body []byte) (WalletOutboxMessage, error) { var message WalletOutboxMessage if err := json.Unmarshal(body, &message); err != nil { return WalletOutboxMessage{}, err } if message.MessageType != MessageTypeWalletOutboxEvent { return WalletOutboxMessage{}, errors.New("unexpected wallet outbox message_type") } if strings.TrimSpace(message.AppCode) == "" || strings.TrimSpace(message.EventID) == "" || strings.TrimSpace(message.EventType) == "" || strings.TrimSpace(message.TransactionID) == "" || strings.TrimSpace(message.CommandID) == "" || message.OccurredAtMS <= 0 { return WalletOutboxMessage{}, errors.New("wallet outbox message is incomplete") } if strings.TrimSpace(message.PayloadJSON) == "" { message.PayloadJSON = "{}" } return message, nil }