2026-07-13 21:28:41 +08:00

144 lines
5.9 KiB
Go

// Package luckygiftmq defines the RocketMQ wire contract owned by lucky-gift-service.
package luckygiftmq
import (
"encoding/json"
"errors"
"fmt"
"strings"
"google.golang.org/protobuf/proto"
luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1"
)
const (
MessageTypeLuckyGiftOutboxEvent = "lucky_gift_outbox_event"
EventTypeLuckyGiftDrawn = "LuckyGiftDrawn"
TagLuckyGiftDrawn = EventTypeLuckyGiftDrawn
// LuckyGiftDisplayMinMultiplierPPM keeps room and regional top-banner policy on the same exact 10x boundary.
// The owner still publishes every granted positive reward; consumers apply this presentation threshold independently.
LuckyGiftDisplayMinMultiplierPPM int64 = 10_000_000
)
// LuckyGiftOutboxMessage is the inspectable JSON wrapper around one protobuf owner envelope.
// Metadata is duplicated outside envelope only for RocketMQ diagnostics and routing; the protobuf envelope remains authoritative.
type LuckyGiftOutboxMessage struct {
MessageType string `json:"message_type"`
AppCode string `json:"app_code"`
EventID string `json:"event_id"`
EventType string `json:"event_type"`
DrawID string `json:"draw_id"`
OccurredAtMS int64 `json:"occurred_at_ms"`
Envelope []byte `json:"envelope"`
}
// EventTypeTag validates an owner-controlled event type before it becomes a broker-side typed Tag.
// Only registered fact names are accepted so malformed outbox data cannot publish to an unconsumed routing key.
func EventTypeTag(eventType string) (string, error) {
switch tag := strings.TrimSpace(eventType); tag {
case EventTypeLuckyGiftDrawn:
return tag, nil
case "":
return "", errors.New("lucky gift event_type tag is required")
default:
return "", fmt.Errorf("unsupported lucky gift event_type tag %q", eventType)
}
}
// EncodeLuckyGiftOutboxMessage serializes the protobuf envelope without converting int64 business identifiers through JSON numbers.
func EncodeLuckyGiftOutboxMessage(envelope *luckygifteventsv1.EventEnvelope) ([]byte, error) {
if err := validateLuckyGiftEnvelope(envelope); err != nil {
return nil, err
}
envelopeBytes, err := proto.Marshal(envelope)
if err != nil {
return nil, err
}
return json.Marshal(LuckyGiftOutboxMessage{
MessageType: MessageTypeLuckyGiftOutboxEvent,
AppCode: envelope.GetAppCode(),
EventID: envelope.GetEventId(),
EventType: envelope.GetEventType(),
DrawID: envelope.GetDrawId(),
OccurredAtMS: envelope.GetOccurredAtMs(),
Envelope: envelopeBytes,
})
}
// DecodeLuckyGiftOutboxMessage restores and validates the owner envelope.
// Wrapper/envelope equality is checked explicitly so operators never diagnose one set of visible metadata while consumers process another.
func DecodeLuckyGiftOutboxMessage(body []byte) (*luckygifteventsv1.EventEnvelope, LuckyGiftOutboxMessage, error) {
var message LuckyGiftOutboxMessage
if err := json.Unmarshal(body, &message); err != nil {
return nil, LuckyGiftOutboxMessage{}, err
}
if message.MessageType != MessageTypeLuckyGiftOutboxEvent {
return nil, LuckyGiftOutboxMessage{}, errors.New("unexpected lucky gift outbox message_type")
}
if len(message.Envelope) == 0 {
return nil, LuckyGiftOutboxMessage{}, errors.New("lucky gift outbox envelope is required")
}
var envelope luckygifteventsv1.EventEnvelope
if err := proto.Unmarshal(message.Envelope, &envelope); err != nil {
return nil, LuckyGiftOutboxMessage{}, err
}
if err := validateLuckyGiftEnvelope(&envelope); err != nil {
return nil, LuckyGiftOutboxMessage{}, err
}
if message.AppCode != envelope.GetAppCode() ||
message.EventID != envelope.GetEventId() ||
message.EventType != envelope.GetEventType() ||
message.DrawID != envelope.GetDrawId() ||
message.OccurredAtMS != envelope.GetOccurredAtMs() {
return nil, LuckyGiftOutboxMessage{}, errors.New("lucky gift outbox wrapper does not match envelope")
}
return &envelope, message, nil
}
func validateLuckyGiftEnvelope(envelope *luckygifteventsv1.EventEnvelope) error {
if envelope == nil {
return errors.New("lucky gift outbox envelope is required")
}
if strings.TrimSpace(envelope.GetAppCode()) == "" ||
strings.TrimSpace(envelope.GetEventId()) == "" ||
strings.TrimSpace(envelope.GetDrawId()) == "" ||
envelope.GetOccurredAtMs() <= 0 ||
len(envelope.GetBody()) == 0 {
return errors.New("lucky gift outbox envelope is incomplete")
}
// draw_id 是一次中奖事实的天然幂等键;固定 event_id 规则后,所有消费者都能用同一个键收敛重投,
// 也避免某个 producer 临时生成随机 ID 导致 room/activity 各自重复派生外部飘屏。
if envelope.GetEventId() != "lucky_gift_drawn:"+envelope.GetDrawId() {
return errors.New("lucky gift outbox event_id does not match draw_id")
}
if _, err := EventTypeTag(envelope.GetEventType()); err != nil {
return err
}
// The current topic carries only granted winning facts. Validate the protobuf body here so poison data fails before broker publish
// and is retried from the owner outbox instead of being acknowledged and rejected independently by every consumer.
var drawn luckygifteventsv1.LuckyGiftDrawn
if err := proto.Unmarshal(envelope.GetBody(), &drawn); err != nil {
return fmt.Errorf("decode lucky gift drawn body: %w", err)
}
if strings.TrimSpace(drawn.GetDrawId()) == "" ||
drawn.GetDrawId() != envelope.GetDrawId() ||
strings.TrimSpace(drawn.GetCommandId()) == "" ||
strings.TrimSpace(drawn.GetRoomId()) == "" ||
strings.TrimSpace(drawn.GetGiftId()) == "" ||
drawn.GetGiftCount() <= 0 ||
drawn.GetSenderUserId() <= 0 ||
drawn.GetTargetUserId() <= 0 ||
drawn.GetCoinSpent() <= 0 ||
drawn.GetMultiplierPpm() <= 0 ||
drawn.GetEffectiveRewardCoins() <= 0 ||
strings.ToLower(strings.TrimSpace(drawn.GetRewardStatus())) != "granted" ||
drawn.GetDrawCreatedAtMs() <= 0 ||
drawn.GetDrawCreatedAtMs() != envelope.GetOccurredAtMs() ||
drawn.GetRewardGrantedAtMs() <= 0 {
return errors.New("lucky gift drawn body is incomplete")
}
return nil
}