package service import ( "context" "fmt" "strconv" "strings" "time" "google.golang.org/protobuf/proto" activityv1 "hyapp.local/api/proto/activity/v1" roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/api/proto/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/tencentim" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/outbox" "hyapp/services/room-service/internal/room/rank" "hyapp/services/room-service/internal/room/state" ) // SendGift 先同步调用 wallet,再在 Room Cell 内完成热度、榜单和房间事件落地。 func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) // SendGift 的账务不在 room-service 内结算,但房间表现、热度和榜单由 Room Cell 同步更新。 cmd := command.SendGift{ Base: baseFromMeta(req.GetMeta()), TargetType: normalizeGiftTargetType(req.GetTargetType()), TargetUserIDs: normalizeGiftTargetUserIDs(req.GetTargetUserId(), req.GetTargetUserIds()), GiftID: req.GetGiftId(), PoolID: strings.TrimSpace(req.GetPoolId()), GiftCount: req.GetGiftCount(), SenderRegionID: req.GetSenderRegionId(), TargetIsHost: req.GetTargetIsHost(), // 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。 TargetHostRegionID: req.GetTargetHostRegionId(), TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(), } if cmd.TargetType == "" { cmd.TargetType = "user" } if cmd.TargetType == "user" && len(cmd.TargetUserIDs) > 0 { cmd.TargetUserID = cmd.TargetUserIDs[0] } result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, localRank *rank.LocalRank) (mutationResult, []outbox.Record, error) { if _, exists := current.OnlineUsers[cmd.ActorUserID()]; !exists { // 送礼者必须在房间业务 presence 内。 return mutationResult{}, nil, xerr.New(xerr.NotFound, "sender not in room") } if cmd.TargetType != "user" { // v1 HTTP 契约已预留 all_mic/all_room/couple;账务拆单和房间表现策略补齐前先 fail-close。 return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_type is unsupported") } if len(cmd.TargetUserIDs) == 0 || cmd.TargetUserID <= 0 { return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_ids is required") } for _, targetUserID := range cmd.TargetUserIDs { if _, exists := current.OnlineUsers[targetUserID]; !exists { // 每个接收方都必须在房间 presence 内;批量扣费前先全部校验,避免钱包出现无房间表现的入账。 return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not in room") } } if cmd.GiftID == "" || cmd.GiftCount <= 0 { // 礼物 ID 和数量是钱包查服务端价格的最小输入,客户端不再提交礼物单价。 return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "gift_id and gift_count must be valid") } roomMeta, exists, err := s.repository.GetRoomMeta(ctx, cmd.RoomID()) if err != nil { return mutationResult{}, nil, err } if !exists { return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found") } luckyEnabled := false if cmd.PoolID != "" { // 客户端显式传 pool_id 时必须先检查活动规则;规则未启用就拒绝,避免先扣费再发现不能抽奖。 if s.luckyGift == nil { return mutationResult{}, nil, xerr.New(xerr.Unavailable, "lucky gift service is not configured") } checkResp, err := s.luckyGift.CheckLuckyGift(ctx, &activityv1.CheckLuckyGiftRequest{ Meta: activityMetaFromRoom(ctx, req.GetMeta()), UserId: cmd.ActorUserID(), RoomId: cmd.RoomID(), GiftId: cmd.GiftID, PoolId: cmd.PoolID, }) if err != nil { return mutationResult{}, nil, err } if checkResp == nil || !checkResp.GetEnabled() { return mutationResult{}, nil, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active") } // Check 只说明“允许按该 pool 抽奖”,真实价格、区域可用性和扣费仍以 wallet-service 为准。 luckyEnabled = true } rocketConfig, err := s.roomRocketConfig(ctx) if err != nil { return mutationResult{}, nil, err } var billing *walletv1.DebitGiftResponse var targetBillings []giftTargetBilling var walletDebitMS int64 settledCommand := cmd var luckyGift *roomv1.LuckyGiftDrawResult var luckyGifts []*roomv1.LuckyGiftDrawResult if err := s.withLuckyGiftSendLock(ctx, luckyEnabled, cmd.ActorUserID(), func() error { // 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。 walletStartedAt := time.Now() var err error billing, targetBillings, err = s.debitGiftTargets(ctx, cmd, roomMeta) walletDebitMS = elapsedMS(walletStartedAt) if err != nil { return err } if billing == nil { return xerr.New(xerr.Unavailable, "wallet debit response is empty") } settledCommand.BillingReceiptID = billing.GetBillingReceiptId() settledCommand.CoinSpent = billing.GetCoinSpent() settledCommand.HeatValue = billing.GetHeatValue() settledCommand.PriceVersion = billing.GetPriceVersion() settledCommand.GiftTypeCode = billing.GetGiftTypeCode() settledCommand.HostPeriodDiamondAdded = billing.GetHostPeriodDiamondAdded() settledCommand.HostPeriodCycleKey = billing.GetHostPeriodCycleKey() if !luckyEnabled { // 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。 luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode()) } if luckyEnabled { luckyGifts, err = s.executeLuckyGiftDraws(ctx, req.GetMeta(), cmd, roomMeta, targetBillings, now) if err != nil { // 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。 return err } if len(luckyGifts) > 0 { // lucky_gifts 保留每个收礼目标的独立抽奖事实;lucky_gift 返回整次送礼的聚合表现,复用已有批量送礼的倍率累加语义。 luckyGift = aggregateLuckyGiftResults(cmd.ID(), luckyGifts) } } return nil }); err != nil { return mutationResult{}, nil, err } heatValue := billing.GetHeatValue() // 扣费成功后,Room Cell 同步更新热度和本地礼物榜。 current.Heat += heatValue current.GiftRank = localRank.ApplyGift(cmd.ActorUserID(), heatValue, now) current.Version++ rocketApply, err := s.applyRoomRocketGift(now, current, rocketConfig, cmd, &settledCommand, billing, roomMeta) if err != nil { return mutationResult{}, nil, err } commandPayload, err := command.Serialize(settledCommand) if err != nil { return mutationResult{}, nil, err } // 送礼事件按 target 拆分,保证 IM、统计和礼物墙消费者拿到准确的接收方和账务回执。 giftEvents := make([]outbox.Record, 0, len(targetBillings)) for _, targetBilling := range targetBillings { giftEvent, err := outbox.Build(current.RoomID, "RoomGiftSent", current.Version, now, &roomeventsv1.RoomGiftSent{ SenderUserId: cmd.ActorUserID(), TargetUserId: targetBilling.TargetUserID, GiftId: cmd.GiftID, PoolId: cmd.PoolID, GiftCount: cmd.GiftCount, GiftValue: targetBilling.Billing.GetHeatValue(), BillingReceiptId: targetBilling.Billing.GetBillingReceiptId(), VisibleRegionId: roomMeta.VisibleRegionID, CommandId: targetBilling.CommandID, }) if err != nil { return mutationResult{}, nil, err } giftEvents = append(giftEvents, giftEvent) } heatEvent, err := outbox.Build(current.RoomID, "RoomHeatChanged", current.Version, now, &roomeventsv1.RoomHeatChanged{ Delta: heatValue, CurrentHeat: current.Heat, }) if err != nil { return mutationResult{}, nil, err } rankItem := findRankItem(current.GiftRank, cmd.ActorUserID()) rankEvent, err := outbox.Build(current.RoomID, "RoomRankChanged", current.Version, now, &roomeventsv1.RoomRankChanged{ UserId: rankItem.UserID, Score: rankItem.Score, GiftValue: rankItem.GiftValue, }) if err != nil { return mutationResult{}, nil, err } records := make([]outbox.Record, 0, len(giftEvents)+4) records = append(records, giftEvents...) records = append(records, heatEvent, rankEvent) if rocketApply.progressEvent != nil { progressEvent, err := outbox.Build(current.RoomID, "RoomRocketFuelChanged", current.Version, now, rocketApply.progressEvent) if err != nil { return mutationResult{}, nil, err } records = append(records, progressEvent) } if rocketApply.ignited != nil { ignitedEvent, err := outbox.Build(current.RoomID, "RoomRocketIgnited", current.Version, now, rocketApply.ignited) if err != nil { return mutationResult{}, nil, err } records = append(records, ignitedEvent) } return mutationResult{ snapshot: current.ToProto(), billingReceiptID: billing.GetBillingReceiptId(), roomHeat: current.Heat, giftRank: cloneProtoRank(current.GiftRank), luckyGift: luckyGift, luckyGifts: luckyGifts, commandPayload: commandPayload, walletDebitMS: walletDebitMS, roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{ AppCode: appcode.FromContext(ctx), RoomID: current.RoomID, CoinSpent: roomGiftLeaderboardValue(billing), OccurredAtMS: now.UnixMilli(), }, roomUserGiftStats: []RoomUserGiftStatIncrement{ { AppCode: appcode.FromContext(ctx), RoomID: current.RoomID, UserID: cmd.ActorUserID(), GiftValue: heatValue, LastGiftAtMS: now.UnixMilli(), }, }, syncEvent: &tencentim.RoomEvent{ // 同步广播只选 GiftSent 主事件作为客户端房间系统消息,Heat/Rank 通过字段携带。 EventID: giftEvents[0].EventID, RoomID: current.RoomID, EventType: "room_gift_sent", ActorUserID: cmd.ActorUserID(), TargetUserID: cmd.TargetUserID, GiftValue: heatValue, RoomHeat: current.Heat, RoomVersion: current.Version, Attributes: map[string]string{ "gift_id": cmd.GiftID, "pool_id": cmd.PoolID, "gift_count": fmt.Sprintf("%d", cmd.GiftCount), "billing_receipt_id": billing.GetBillingReceiptId(), "coin_spent": fmt.Sprintf("%d", settledCommand.CoinSpent), "target_count": fmt.Sprintf("%d", len(cmd.TargetUserIDs)), "target_user_ids": giftTargetUserIDsAttribute(cmd.TargetUserIDs), "price_version": settledCommand.PriceVersion, "host_period_diamond_added": fmt.Sprintf("%d", settledCommand.HostPeriodDiamondAdded), "host_period_cycle_key": settledCommand.HostPeriodCycleKey, }, }, }, records, nil }) if err != nil { return nil, err } return &roomv1.SendGiftResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), BillingReceiptId: result.billingReceiptID, RoomHeat: result.roomHeat, GiftRank: result.giftRank, Room: result.snapshot, Rocket: result.snapshot.GetRocket(), LuckyGift: result.luckyGift, LuckyGifts: result.luckyGifts, }, nil } type giftTargetBilling struct { TargetUserID int64 CommandID string Billing *walletv1.DebitGiftResponse } func (s *Service) debitGiftTargets(ctx context.Context, cmd command.SendGift, roomMeta RoomMeta) (*walletv1.DebitGiftResponse, []giftTargetBilling, error) { if len(cmd.TargetUserIDs) == 1 { // 单目标保留原 wallet command_id,避免改变已有幂等键、账务回执和排障路径。 billing, err := s.wallet.DebitGift(ctx, &walletv1.DebitGiftRequest{ CommandId: cmd.ID(), RoomId: cmd.RoomID(), SenderUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, GiftId: cmd.GiftID, GiftCount: cmd.GiftCount, AppCode: appcode.FromContext(ctx), RegionId: roomMeta.VisibleRegionID, SenderRegionId: cmd.SenderRegionID, TargetIsHost: cmd.TargetIsHost, TargetHostRegionId: cmd.TargetHostRegionID, TargetAgencyOwnerUserId: cmd.TargetAgencyOwnerUserID, }) if err != nil { return nil, nil, err } return billing, []giftTargetBilling{{TargetUserID: cmd.TargetUserID, CommandID: cmd.ID(), Billing: billing}}, nil } targets := make([]*walletv1.DebitGiftTarget, 0, len(cmd.TargetUserIDs)) for _, targetUserID := range cmd.TargetUserIDs { target := &walletv1.DebitGiftTarget{ CommandId: giftTargetCommandID(cmd.ID(), targetUserID, len(cmd.TargetUserIDs)), TargetUserId: targetUserID, } if targetUserID == cmd.TargetUserID && cmd.TargetIsHost { // 现有 room proto 只有单个 host scope;多目标时只允许把该快照用于对应的第一个目标,不能错误扩散到其他接收方。 target.TargetIsHost = true target.TargetHostRegionId = cmd.TargetHostRegionID target.TargetAgencyOwnerUserId = cmd.TargetAgencyOwnerUserID } targets = append(targets, target) } resp, err := s.wallet.BatchDebitGift(ctx, &walletv1.BatchDebitGiftRequest{ CommandId: cmd.ID(), RoomId: cmd.RoomID(), SenderUserId: cmd.ActorUserID(), GiftId: cmd.GiftID, GiftCount: cmd.GiftCount, AppCode: appcode.FromContext(ctx), RegionId: roomMeta.VisibleRegionID, SenderRegionId: cmd.SenderRegionID, Targets: targets, }) if err != nil { return nil, nil, err } targetBillings, err := giftTargetBillingsFromBatch(resp, cmd.TargetUserIDs) if err != nil { return nil, nil, err } return resp.GetAggregate(), targetBillings, nil } func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling, now time.Time) ([]*roomv1.LuckyGiftDrawResult, error) { results := make([]*roomv1.LuckyGiftDrawResult, 0, len(targetBillings)) for _, targetBilling := range targetBillings { if targetBilling.Billing == nil { return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty") } // 抽奖必须按每个收礼目标独立落事实;command_id、target_user_id 和 coin_spent 都来自该目标的 wallet 子交易。 drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &activityv1.ExecuteLuckyGiftDrawRequest{ LuckyGift: &activityv1.LuckyGiftMeta{ Meta: activityMetaFromRoom(ctx, meta), CommandId: targetBilling.CommandID, UserId: cmd.ActorUserID(), TargetUserId: targetBilling.TargetUserID, // 目前没有独立设备 ID 字段,优先用 session_id;没有时退回目标子 command_id,保证每次抽奖 scope 不为空。 DeviceId: luckyGiftDeviceID(cmd, targetBilling.CommandID), RoomId: cmd.RoomID(), AnchorId: luckyGiftAnchorID(roomMeta), GiftId: cmd.GiftID, GiftCount: cmd.GiftCount, CoinSpent: targetBilling.Billing.GetCoinSpent(), // 房间链路统一使用 UTC epoch ms;不能把本地时区时间传给活动风控窗口。 PaidAtMs: now.UTC().UnixMilli(), PoolId: cmd.PoolID, VisibleRegionId: roomMeta.VisibleRegionID, }, }) if err != nil { return nil, err } if drawResp == nil || drawResp.GetResult() == nil { return nil, xerr.New(xerr.Unavailable, "lucky gift draw response is empty") } // 同步返回只给当前送礼用户做即时表现;金币余额以 activity/wallet 快路径回执为准,房间开奖结果仍由 activity outbox 补偿。 results = append(results, luckyGiftResultFromProto(drawResp.GetResult(), targetBilling.TargetUserID)) } return results, nil } func roomGiftLeaderboardValue(billing *walletv1.DebitGiftResponse) int64 { if billing == nil { return 0 } // 房间贡献榜跟房间热度、房间内贡献人保持同一口径,使用 wallet 已按全局默认比例折算后的 heat_value。 if billing.GetHeatValue() > 0 { return billing.GetHeatValue() } return 0 } func (s *Service) shouldDrawLuckyGift(poolID string, giftTypeCode string) bool { if s.luckyGift == nil { return false } switch strings.TrimSpace(giftTypeCode) { case "lucky", "super_lucky": return true default: return strings.TrimSpace(poolID) != "" } } func (s *Service) withLuckyGiftSendLock(ctx context.Context, enabled bool, userID int64, fn func() error) error { if !enabled || s == nil || s.luckyGiftSendLocker == nil { return fn() } release, err := s.luckyGiftSendLocker.AcquireLuckyGiftSendLock(ctx, appcode.FromContext(ctx), userID, s.luckyGiftSendLockTTL) if err != nil { return err } if release != nil { defer func() { releaseCtx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() release(releaseCtx) }() } return fn() } func activityMetaFromRoom(ctx context.Context, meta *roomv1.RequestMeta) *activityv1.RequestMeta { return &activityv1.RequestMeta{ RequestId: meta.GetRequestId(), Caller: "room-service", GatewayNodeId: meta.GetGatewayNodeId(), SentAtMs: time.Now().UTC().UnixMilli(), AppCode: appcode.FromContext(ctx), } } func luckyGiftDeviceID(cmd command.SendGift, fallbackCommandID string) string { if value := strings.TrimSpace(cmd.SessionID); value != "" { return value } if value := strings.TrimSpace(fallbackCommandID); value != "" { return value } return cmd.ID() } func luckyGiftAnchorID(roomMeta RoomMeta) string { if roomMeta.OwnerUserID <= 0 { return roomMeta.RoomID } return fmt.Sprintf("%d", roomMeta.OwnerUserID) } func luckyGiftResultFromProto(result *activityv1.LuckyGiftDrawResult, targetUserID int64) *roomv1.LuckyGiftDrawResult { if result == nil { return nil } return &roomv1.LuckyGiftDrawResult{ Enabled: true, DrawId: result.GetDrawId(), CommandId: result.GetCommandId(), PoolId: result.GetPoolId(), GiftId: result.GetGiftId(), RuleVersion: result.GetRuleVersion(), ExperiencePool: result.GetExperiencePool(), SelectedTierId: result.GetSelectedTierId(), MultiplierPpm: result.GetMultiplierPpm(), BaseRewardCoins: result.GetBaseRewardCoins(), RoomAtmosphereRewardCoins: result.GetRoomAtmosphereRewardCoins(), ActivitySubsidyCoins: result.GetActivitySubsidyCoins(), EffectiveRewardCoins: result.GetEffectiveRewardCoins(), RewardStatus: result.GetRewardStatus(), StageFeedback: result.GetStageFeedback(), HighMultiplier: result.GetHighMultiplier(), CreatedAtMs: result.GetCreatedAtMs(), WalletTransactionId: result.GetWalletTransactionId(), CoinBalanceAfter: result.GetCoinBalanceAfter(), TargetUserId: targetUserID, } } func aggregateLuckyGiftResults(commandID string, results []*roomv1.LuckyGiftDrawResult) *roomv1.LuckyGiftDrawResult { if len(results) == 0 { return nil } if len(results) == 1 { return results[0] } aggregate := proto.Clone(results[0]).(*roomv1.LuckyGiftDrawResult) aggregate.CommandId = commandID aggregate.SelectedTierId = "batch" aggregate.TargetUserId = 0 aggregate.WalletTransactionId = "" aggregate.MultiplierPpm = 0 aggregate.BaseRewardCoins = 0 aggregate.RoomAtmosphereRewardCoins = 0 aggregate.ActivitySubsidyCoins = 0 aggregate.EffectiveRewardCoins = 0 aggregate.RewardStatus = "granted" aggregate.StageFeedback = false aggregate.HighMultiplier = false aggregate.CoinBalanceAfter = 0 for _, result := range results { if result == nil { continue } aggregate.RuleVersion = result.GetRuleVersion() aggregate.ExperiencePool = result.GetExperiencePool() aggregate.CreatedAtMs = result.GetCreatedAtMs() aggregate.MultiplierPpm += result.GetMultiplierPpm() aggregate.BaseRewardCoins += result.GetBaseRewardCoins() aggregate.RoomAtmosphereRewardCoins += result.GetRoomAtmosphereRewardCoins() aggregate.ActivitySubsidyCoins += result.GetActivitySubsidyCoins() aggregate.EffectiveRewardCoins += result.GetEffectiveRewardCoins() aggregate.StageFeedback = aggregate.StageFeedback || result.GetStageFeedback() aggregate.HighMultiplier = aggregate.HighMultiplier || result.GetHighMultiplier() if result.GetCoinBalanceAfter() > 0 { // 多目标会产生多笔返奖交易;聚合字段不能表达多个交易号,只保留最后一次快路径回执后的余额给客户端刷新资产。 aggregate.CoinBalanceAfter = result.GetCoinBalanceAfter() } switch result.GetRewardStatus() { case "pending": aggregate.RewardStatus = "pending" case "failed": if aggregate.RewardStatus != "pending" { aggregate.RewardStatus = "failed" } case "granted": default: if aggregate.RewardStatus == "" { aggregate.RewardStatus = result.GetRewardStatus() } } } return aggregate } func normalizeGiftTargetType(raw string) string { raw = strings.TrimSpace(raw) switch raw { case "", "user", "all_mic", "all_room", "couple": return raw default: return raw } } func normalizeGiftTargetUserIDs(targetUserID int64, targetUserIDs []int64) []int64 { seen := make(map[int64]bool, len(targetUserIDs)+1) ids := make([]int64, 0, len(targetUserIDs)+1) for _, userID := range targetUserIDs { if userID <= 0 || seen[userID] { continue } seen[userID] = true ids = append(ids, userID) } if len(ids) == 0 && targetUserID > 0 { ids = append(ids, targetUserID) } return ids } func giftTargetCommandID(commandID string, targetUserID int64, targetCount int) string { if targetCount <= 1 { return commandID } // 钱包以 command_id 做幂等主键;多目标必须按接收方派生稳定子命令,才能在同一批内保存独立交易。 return fmt.Sprintf("%s:target:%d", commandID, targetUserID) } func giftTargetBillingsFromBatch(resp *walletv1.BatchDebitGiftResponse, targetUserIDs []int64) ([]giftTargetBilling, error) { if resp == nil || resp.GetAggregate() == nil { return nil, xerr.New(xerr.Unavailable, "wallet batch debit response is empty") } receipts := make(map[int64]*walletv1.BatchDebitGiftReceipt, len(resp.GetReceipts())) for _, receipt := range resp.GetReceipts() { if receipt.GetTargetUserId() <= 0 || receipt.GetBilling() == nil { return nil, xerr.New(xerr.Unavailable, "wallet batch debit receipt is incomplete") } receipts[receipt.GetTargetUserId()] = receipt } targetBillings := make([]giftTargetBilling, 0, len(targetUserIDs)) for _, targetUserID := range targetUserIDs { receipt := receipts[targetUserID] if receipt == nil { // 批量钱包响应必须覆盖每个房间目标;少一条就不能提交房间表现和 outbox。 return nil, xerr.New(xerr.Unavailable, "wallet batch debit receipt is missing") } targetBillings = append(targetBillings, giftTargetBilling{ TargetUserID: targetUserID, CommandID: receipt.GetCommandId(), Billing: receipt.GetBilling(), }) } return targetBillings, nil } func giftTargetUserIDsAttribute(targetUserIDs []int64) string { if len(targetUserIDs) == 0 { return "" } parts := make([]string, 0, len(targetUserIDs)) for _, targetUserID := range targetUserIDs { parts = append(parts, strconv.FormatInt(targetUserID, 10)) } return strings.Join(parts, ",") } func findRankItem(items []state.RankItem, userID int64) state.RankItem { // 送礼后通常能找到发送方榜单项;找不到时返回 user_id 占位避免空事件。 for _, item := range items { if item.UserID == userID { return item } } return state.RankItem{UserID: userID} }