// 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 }