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

196 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package broadcast
import (
"context"
"encoding/json"
"fmt"
"strings"
"google.golang.org/protobuf/proto"
luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/luckygiftmq"
"hyapp/pkg/xerr"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
)
// HandleLuckyGiftEvent 把 lucky-gift-service 在钱包返奖成功后发布的 owner fact 投影成区域 IM outbox。
// MQ 事实只负责描述已发生的中奖activity-service 独立拥有区域展示策略和腾讯 IM 投递状态,不能反写 owner outbox。
func (s *Service) HandleLuckyGiftEvent(ctx context.Context, envelope *luckygifteventsv1.EventEnvelope) (broadcastdomain.ConsumeLuckyGiftEventResult, error) {
if envelope == nil {
return broadcastdomain.ConsumeLuckyGiftEventResult{}, xerr.New(xerr.InvalidArgument, "lucky gift event envelope is required")
}
result := broadcastdomain.ConsumeLuckyGiftEventResult{
EventID: envelope.GetEventId(),
Status: broadcastdomain.StatusSkipped,
}
if envelope.GetEventType() != luckygiftmq.EventTypeLuckyGiftDrawn {
// 独立 topic 未来可能增加审计类事实;未知类型必须确认跳过,不能误生成客户端无法识别的播报。
return result, nil
}
var event luckygifteventsv1.LuckyGiftDrawn
if err := proto.Unmarshal(envelope.GetBody(), &event); err != nil {
return broadcastdomain.ConsumeLuckyGiftEventResult{}, fmt.Errorf("decode lucky gift drawn body: %w", err)
}
if err := validateLuckyGiftDrawnEnvelope(envelope, &event); err != nil {
return broadcastdomain.ConsumeLuckyGiftEventResult{}, err
}
// 区域顶部飘屏只接受已经实际到账的 10 倍及以上奖励。pending/failed 事实、0 奖励和无区域事实
// 都属于合法跳过,避免展示先于钱包到账或把区域未知事件扩大成全局消息。
if !shouldPublishLuckyGiftBigWin(&event) {
return result, nil
}
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
sender, err := s.luckyGiftSenderProfile(eventCtx, &event)
if err != nil {
// 资料查询是完整 IM 快照的一部分;暂时失败必须交还 MQ 重试,不能先确认后永久退化成错误昵称。
return broadcastdomain.ConsumeLuckyGiftEventResult{}, fmt.Errorf("resolve lucky gift sender profile: %w", err)
}
broadcastEventID := "lucky_gift_big_win:" + event.GetDrawId()
payloadJSON, err := luckyGiftBigWinPayload(envelope, &event, broadcastEventID, sender, s.now().UTC().UnixMilli())
if err != nil {
return broadcastdomain.ConsumeLuckyGiftEventResult{}, err
}
published, err := s.PublishRegionBroadcast(eventCtx, PublishInput{
EventID: broadcastEventID,
BroadcastType: broadcastdomain.TypeLuckyGiftBigWin,
RegionID: event.GetVisibleRegionId(),
PayloadJSON: payloadJSON,
})
if err != nil {
return broadcastdomain.ConsumeLuckyGiftEventResult{}, err
}
// im_broadcast_outbox 的 (app_code,event_id) 是本投影的幂等事实MQ 重投只会得到 Created=false
// 不会生成第二条区域消息,也不需要读取 lucky-gift-service 的 owner outbox 状态。
return broadcastdomain.ConsumeLuckyGiftEventResult{
EventID: envelope.GetEventId(),
Status: published.Status,
BroadcastEventID: published.EventID,
BroadcastCreated: published.Created,
}, nil
}
func validateLuckyGiftDrawnEnvelope(envelope *luckygifteventsv1.EventEnvelope, event *luckygifteventsv1.LuckyGiftDrawn) error {
if strings.TrimSpace(envelope.GetAppCode()) == "" || strings.TrimSpace(envelope.GetEventId()) == "" {
return xerr.New(xerr.InvalidArgument, "lucky gift event identity is incomplete")
}
drawID := strings.TrimSpace(event.GetDrawId())
if drawID == "" || strings.TrimSpace(event.GetRoomId()) == "" || event.GetSenderUserId() <= 0 {
return xerr.New(xerr.InvalidArgument, "lucky gift drawn fact is incomplete")
}
if envelopeDrawID := strings.TrimSpace(envelope.GetDrawId()); envelopeDrawID != "" && envelopeDrawID != drawID {
return xerr.New(xerr.InvalidArgument, "lucky gift envelope draw_id does not match body")
}
return nil
}
func shouldPublishLuckyGiftBigWin(event *luckygifteventsv1.LuckyGiftDrawn) bool {
if event == nil || !strings.EqualFold(strings.TrimSpace(event.GetRewardStatus()), "granted") {
return false
}
return event.GetEffectiveRewardCoins() > 0 &&
event.GetVisibleRegionId() > 0 &&
event.GetMultiplierPpm() >= luckygiftmq.LuckyGiftDisplayMinMultiplierPPM
}
func (s *Service) luckyGiftSenderProfile(ctx context.Context, event *luckygifteventsv1.LuckyGiftDrawn) (SenderProfile, error) {
profile := SenderProfile{
UserID: event.GetSenderUserId(),
Account: strings.TrimSpace(event.GetSenderDisplayUserId()),
PrettyDisplayUserID: strings.TrimSpace(event.GetSenderPrettyDisplayUserId()),
Nickname: strings.TrimSpace(event.GetSenderName()),
Avatar: strings.TrimSpace(event.GetSenderAvatar()),
}
// 送礼主链路已经把展示快照写进 owner fact字段齐全时禁止回查当前资料避免用户改名后历史事实漂移。
if profile.Nickname != "" && profile.Account != "" {
return profile, nil
}
resolved, err := s.broadcastUserProfile(ctx, profile.UserID)
if err != nil {
return SenderProfile{}, err
}
if profile.Account == "" {
profile.Account = strings.TrimSpace(resolved.Account)
}
if profile.PrettyDisplayUserID == "" {
profile.PrettyDisplayUserID = strings.TrimSpace(resolved.PrettyDisplayUserID)
}
if profile.Nickname == "" {
profile.Nickname = firstLuckyGiftDisplayValue(resolved.Nickname, profile.PrettyDisplayUserID, profile.Account)
}
if profile.Avatar == "" {
profile.Avatar = strings.TrimSpace(resolved.Avatar)
}
return profile, nil
}
func firstLuckyGiftDisplayValue(values ...string) string {
for _, value := range values {
if normalized := strings.TrimSpace(value); normalized != "" {
return normalized
}
}
return ""
}
func luckyGiftBigWinPayload(envelope *luckygifteventsv1.EventEnvelope, event *luckygifteventsv1.LuckyGiftDrawn, eventID string, sender SenderProfile, sentAtMS int64) (string, error) {
createdAtMS := event.GetDrawCreatedAtMs()
if createdAtMS <= 0 {
createdAtMS = envelope.GetOccurredAtMs()
}
// 字段保持拆服前 Flutter 已消费的扁平协议,并补充 granted/pretty ID 快照;不携带 gift_name
// 客户端继续用 gift_id 命中本地多语言礼物配置,避免服务端文案覆盖客户端翻译。
payload := map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": eventID,
"broadcast_type": broadcastdomain.TypeLuckyGiftBigWin,
"scope": broadcastdomain.ScopeRegion,
"app_code": envelope.GetAppCode(),
"region_id": event.GetVisibleRegionId(),
"visible_region_id": event.GetVisibleRegionId(),
"draw_id": event.GetDrawId(),
"command_id": event.GetCommandId(),
"pool_id": event.GetPoolId(),
"room_id": event.GetRoomId(),
"gift_id": event.GetGiftId(),
"gift_count": event.GetGiftCount(),
"user_id": event.GetSenderUserId(),
"sender_user_id": event.GetSenderUserId(),
"target_user_id": event.GetTargetUserId(),
"sender_name": sender.Nickname,
"sender_avatar": sender.Avatar,
"sender_display_user_id": sender.Account,
"sender_pretty_display_user_id": sender.PrettyDisplayUserID,
"coin_spent": event.GetCoinSpent(),
"rule_version": event.GetRuleVersion(),
"experience_pool": event.GetExperiencePool(),
"selected_tier_id": event.GetSelectedTierId(),
"multiplier_ppm": event.GetMultiplierPpm(),
"base_reward_coins": event.GetBaseRewardCoins(),
"effective_reward_coins": event.GetEffectiveRewardCoins(),
"reward_status": "granted",
"wallet_transaction_id": event.GetWalletTransactionId(),
"stage_feedback": event.GetStageFeedback(),
"high_multiplier": event.GetHighMultiplier(),
"created_at_ms": createdAtMS,
"draw_created_at_ms": createdAtMS,
"reward_granted_at_ms": event.GetRewardGrantedAtMs(),
"sent_at_ms": sentAtMS,
"action": map[string]any{
"type": "enter_room",
"room_id": event.GetRoomId(),
},
"sender": map[string]any{
"user_id": sender.UserID,
"display_user_id": sender.Account,
"pretty_display_user_id": sender.PrettyDisplayUserID,
"nickname": sender.Nickname,
"avatar": sender.Avatar,
},
}
encoded, err := json.Marshal(payload)
return string(encoded), err
}