package service import ( "context" "fmt" "strings" "time" 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" ) type giftFlow struct { s *Service ctx context.Context req *roomv1.SendGiftRequest robot robotGiftOptions cmd command.SendGift roomMeta RoomMeta rocketConfig RoomRocketConfig billing *walletv1.DebitGiftResponse // targetBillings 保存 wallet-service 按目标结算后的账务事实;后续事件、麦位热度和 batch 展示都必须从这里读取,不能重新按客户端输入推导价格。 targetBillings []giftTargetBilling walletDebitMS int64 settledCommand command.SendGift luckyEnabled bool luckyGift *roomv1.LuckyGiftDrawResult luckyGifts []*roomv1.LuckyGiftDrawResult heatValue int64 targetGiftValueDeltas map[int64]int64 targetCurrentGiftValues map[int64]int64 rocketApply rocketGiftApplyResult commandPayload []byte giftEvents []outbox.Record records []outbox.Record robotDisplayRecords []outbox.Record rocketProgress *roomRocketProgressPending batchDisplay *roomv1.SendGiftBatchDisplay batchDisplayEnabled bool suppressDirectIMEventTypes map[string]bool } func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, options giftSendOptions) *giftFlow { displayMode := "" if options.BatchDisplay { displayMode = "batch" } // 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(), EntitlementID: strings.TrimSpace(req.GetEntitlementId()), Source: strings.TrimSpace(req.GetSource()), DisplayMode: displayMode, SenderCountryID: req.GetSenderCountryId(), SenderRegionID: req.GetSenderRegionId(), TargetIsHost: req.GetTargetIsHost(), // 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。 TargetHostRegionID: req.GetTargetHostRegionId(), TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(), TargetHostScopes: giftTargetHostScopesFromProto(req.GetTargetHostScopes()), SenderDisplayProfile: giftDisplayProfileFromProto(req.GetSenderDisplayProfile()), TargetDisplayProfiles: giftDisplayProfilesFromProto(req.GetTargetDisplayProfiles()), RobotGift: options.Robot.Enabled && !options.Robot.RealRoomHeat, RobotWalletGift: options.Robot.Enabled, SyntheticLuckyGift: options.Robot.Enabled && strings.TrimSpace(req.GetPoolId()) != "", SyntheticLuckyRewardCoins: firstPositiveInt64(options.Robot.SyntheticRewardCoins, 0), SyntheticLuckyMultiplierPPM: firstPositiveInt64(options.Robot.SyntheticMultiplierPPM, 1000000), } if cmd.TargetType == "" { cmd.TargetType = "user" } cmd.RequestedTargetUserIDs = append([]int64(nil), cmd.TargetUserIDs...) if cmd.TargetType == "user" && len(cmd.TargetUserIDs) > 0 { cmd.TargetUserID = cmd.TargetUserIDs[0] } return &giftFlow{s: s, ctx: ctx, req: req, robot: options.Robot, cmd: cmd, settledCommand: cmd, batchDisplayEnabled: options.BatchDisplay} } func (f *giftFlow) validateBeforeDebit(current *state.RoomState) error { if _, exists := current.OnlineUsers[f.cmd.ActorUserID()]; !exists { // 送礼者必须在房间业务 presence 内;没有进入 Room Cell 的用户不能触发钱包扣费。 return xerr.New(xerr.NotFound, "sender not in room") } if f.cmd.TargetType != "user" { // v1 HTTP 契约已预留 all_mic/all_room/couple;账务拆单和房间表现策略补齐前先 fail-close。 return xerr.New(xerr.InvalidArgument, "target_type is unsupported") } if len(f.cmd.TargetUserIDs) == 0 || f.cmd.TargetUserID <= 0 { return xerr.New(xerr.InvalidArgument, "target_user_ids is required") } if f.batchDisplayEnabled { if len(f.cmd.RequestedTargetUserIDs) < 2 { // batch-send 只承载“全部人/多人”语义;过滤后可以只剩一个真实送达目标,但原始请求不能退化成单人入口。 return xerr.New(xerr.InvalidArgument, "batch target_user_ids requires at least two users") } if !f.retainOnlineBatchTargets(current) { // 全部目标都已离房时不能做成功空操作,也不能扣费。 return xerr.New(xerr.NotFound, "target not in room") } } else { for _, targetUserID := range f.cmd.TargetUserIDs { if _, exists := current.OnlineUsers[targetUserID]; !exists { // 普通 SendGift 继续 fail-close,避免旧单目标入口把“目标不存在”静默吞掉。 return xerr.New(xerr.NotFound, "target not in room") } } } if f.robot.Enabled { if err := requireRobotGiftParticipants(current, f.cmd.ActorUserID(), f.cmd.TargetUserIDs, f.robot.RobotUserIDs, !f.robot.RealRoomHeat); err != nil { return err } } if f.cmd.GiftID == "" || f.cmd.GiftCount <= 0 { // 礼物 ID 和数量是钱包查服务端价格的最小输入,客户端不再提交礼物单价。 return xerr.New(xerr.InvalidArgument, "gift_id and gift_count must be valid") } if strings.EqualFold(f.cmd.Source, "bag") && strings.TrimSpace(f.cmd.EntitlementID) == "" { // 背包送礼只改变扣费来源,不改变房间事件;因此必须在扣费前明确指定要扣的权益,不能让 wallet 猜测某个库存。 return xerr.New(xerr.InvalidArgument, "entitlement_id is required for bag gift") } return nil } func (f *giftFlow) retainOnlineBatchTargets(current *state.RoomState) bool { if f == nil || current == nil || len(f.cmd.TargetUserIDs) == 0 { return false } kept := make([]int64, 0, len(f.cmd.TargetUserIDs)) for _, targetUserID := range f.cmd.TargetUserIDs { if _, exists := current.OnlineUsers[targetUserID]; exists { kept = append(kept, targetUserID) } } if len(kept) == 0 { return false } if len(kept) == len(f.cmd.TargetUserIDs) { return true } originalPrimaryTargetUserID := f.cmd.TargetUserID primaryHostScope, hasPrimaryHostScope := giftTargetHostScopeForExact(f.cmd.TargetHostScopes, kept[0]) // 后续 wallet、幸运礼物、RoomGiftSent、RoomGiftBatchSent 和 command log 都只能看到实际送达目标。 f.cmd.TargetUserIDs = kept f.cmd.TargetUserID = kept[0] f.cmd.TargetHostScopes = filterGiftTargetHostScopes(f.cmd.TargetHostScopes, kept) f.cmd.TargetDisplayProfiles = filterGiftDisplayProfiles(f.cmd.TargetDisplayProfiles, kept) if hasPrimaryHostScope { f.cmd.TargetIsHost = primaryHostScope.TargetIsHost f.cmd.TargetHostRegionID = primaryHostScope.TargetHostRegionID f.cmd.TargetAgencyOwnerUserID = primaryHostScope.TargetAgencyOwnerUserID } else if originalPrimaryTargetUserID != f.cmd.TargetUserID { // 旧单目标 host 字段只描述原主目标;主目标被跳过时必须清空,避免工资入账串到剩余用户。 f.cmd.TargetIsHost = false f.cmd.TargetHostRegionID = 0 f.cmd.TargetAgencyOwnerUserID = 0 } f.settledCommand.TargetUserIDs = append([]int64(nil), f.cmd.TargetUserIDs...) f.settledCommand.TargetUserID = f.cmd.TargetUserID f.settledCommand.TargetIsHost = f.cmd.TargetIsHost f.settledCommand.TargetHostRegionID = f.cmd.TargetHostRegionID f.settledCommand.TargetAgencyOwnerUserID = f.cmd.TargetAgencyOwnerUserID f.settledCommand.TargetHostScopes = append([]command.GiftTargetHostScope(nil), f.cmd.TargetHostScopes...) f.settledCommand.TargetDisplayProfiles = append([]command.GiftDisplayProfile(nil), f.cmd.TargetDisplayProfiles...) return true } func (f *giftFlow) prepareRoomFacts() error { roomMeta, exists, err := f.s.repository.GetRoomMeta(f.ctx, f.cmd.RoomID()) if err != nil { return err } if !exists { return xerr.New(xerr.NotFound, "room not found") } f.roomMeta = roomMeta if f.cmd.PoolID != "" && !f.cmd.SyntheticLuckyGift { // 客户端显式传 pool_id 时必须先检查活动规则;规则未启用就拒绝,避免先扣费再发现不能抽奖。 if f.s.luckyGift == nil { return xerr.New(xerr.Unavailable, "lucky gift service is not configured") } checkResp, err := f.s.luckyGift.CheckLuckyGift(f.ctx, &activityv1.CheckLuckyGiftRequest{ Meta: activityMetaFromRoom(f.ctx, f.req.GetMeta()), UserId: f.cmd.ActorUserID(), RoomId: f.cmd.RoomID(), GiftId: f.cmd.GiftID, PoolId: f.cmd.PoolID, }) if err != nil { return err } if checkResp == nil || !checkResp.GetEnabled() { return xerr.New(xerr.RuleNotActive, "lucky gift rule is not active") } // Check 只说明“允许按该 pool 抽奖”,真实价格、区域可用性和扣费仍以 wallet-service 为准。 f.luckyEnabled = true } rocketConfig, err := f.s.roomRocketConfig(f.ctx) if err != nil { return err } f.rocketConfig = rocketConfig return nil } func (f *giftFlow) debitAndSettle(now time.Time) error { return f.s.withLuckyGiftSendLock(f.ctx, f.luckyEnabled, f.cmd.ActorUserID(), func() error { // 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。 walletStartedAt := time.Now() var err error f.billing, f.targetBillings, err = f.s.debitGiftTargets(f.ctx, f.cmd, f.roomMeta) f.walletDebitMS = elapsedMS(walletStartedAt) if err != nil { return err } if f.billing == nil { return xerr.New(xerr.Unavailable, "wallet debit response is empty") } f.settledCommand.BillingReceiptID = f.billing.GetBillingReceiptId() f.settledCommand.CoinSpent = f.billing.GetCoinSpent() f.settledCommand.HeatValue = f.billing.GetHeatValue() f.settledCommand.PriceVersion = f.billing.GetPriceVersion() f.settledCommand.GiftTypeCode = f.billing.GetGiftTypeCode() f.settledCommand.HostPeriodDiamondAdded = f.billing.GetHostPeriodDiamondAdded() f.settledCommand.HostPeriodCycleKey = f.billing.GetHostPeriodCycleKey() if f.cmd.SyntheticLuckyGift { f.luckyGift = syntheticLuckyGiftResult(f.cmd, f.targetBillings, now) if f.luckyGift != nil { f.luckyGifts = []*roomv1.LuckyGiftDrawResult{f.luckyGift} } } else if !f.luckyEnabled { // 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。 f.luckyEnabled = f.s.shouldDrawLuckyGift(f.cmd.PoolID, f.billing.GetGiftTypeCode()) } if !f.cmd.SyntheticLuckyGift && f.luckyEnabled { f.luckyGifts, err = f.s.executeLuckyGiftDraws(f.ctx, f.req.GetMeta(), f.cmd, f.roomMeta, f.targetBillings, now) if err != nil { // 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。 return err } if len(f.luckyGifts) > 0 { // lucky_gifts 保留每个收礼目标的独立抽奖事实;lucky_gift 返回整次送礼的聚合表现,复用已有批量送礼的倍率累加语义。 f.luckyGift = aggregateLuckyGiftResults(f.cmd.ID(), f.luckyGifts) } } return nil }) } func (f *giftFlow) applyRoomState(now time.Time, current *state.RoomState, localRank *rank.LocalRank) error { f.heatValue = f.billing.GetHeatValue() // 扣费成功后,Room Cell 同步更新热度和本地礼物榜;这些状态才是 IM 和 snapshot 的权威来源。 current.Heat += f.heatValue current.GiftRank = localRank.ApplyGift(f.cmd.ActorUserID(), f.heatValue, now) f.targetGiftValueDeltas = make(map[int64]int64, len(f.targetBillings)) f.targetCurrentGiftValues = make(map[int64]int64, len(f.targetBillings)) for _, targetBilling := range f.targetBillings { if targetBilling.Billing == nil || targetBilling.TargetUserID <= 0 { continue } targetGiftValue := targetBilling.Billing.GetHeatValue() if userState := current.OnlineUsers[targetBilling.TargetUserID]; userState != nil { // 收礼用户热度完全使用 wallet-service 已结算的 HeatValue,room-service 不重新判断普通/幸运/超级幸运比例。 if targetGiftValue > 0 { userState.GiftValue += targetGiftValue f.targetGiftValueDeltas[targetBilling.TargetUserID] += targetGiftValue } f.targetCurrentGiftValues[targetBilling.TargetUserID] = userState.GiftValue } } if len(f.targetGiftValueDeltas) > 0 { // 命令日志只保存每个目标本次增量;累计值由在线用户态按 replay 顺序重建。 f.settledCommand.TargetGiftValues = f.targetGiftValueDeltas } current.Version++ rocketApply, err := f.s.applyRoomRocketGift(now, current, f.rocketConfig, f.cmd, &f.settledCommand, f.billing, f.roomMeta) if err != nil { return err } f.rocketApply = rocketApply commandPayload, err := command.Serialize(f.settledCommand) if err != nil { return err } f.commandPayload = commandPayload return nil } func (f *giftFlow) buildMutationResult(now time.Time, current *state.RoomState) (mutationResult, []outbox.Record, error) { giftEvents, err := buildRoomGiftSentRecords(current.RoomID, current.Version, now, f.cmd, f.roomMeta, f.targetBillings, f.targetCurrentGiftValues) if err != nil { return mutationResult{}, nil, err } f.giftEvents = giftEvents f.records = make([]outbox.Record, 0, len(f.giftEvents)+4) f.robotDisplayRecords = make([]outbox.Record, 0, len(f.giftEvents)+5) // RobotWalletGift 只表示这笔礼物由机器人钱包结算;真实房机器人仍会同步写房间贡献、火箭和麦位热度,但展示事件改为提交后直发 IM,避免机器人流量抢占真人主 outbox。 robotDisplayGift := f.cmd.RobotWalletGift if robotDisplayGift { f.robotDisplayRecords = append(f.robotDisplayRecords, f.giftEvents...) } else { f.records = append(f.records, f.giftEvents...) } heatEvent, err := buildRoomHeatChangedRecord(current.RoomID, current.Version, now, f.heatValue, current.Heat) if err != nil { return mutationResult{}, nil, err } rankItem := findRankItem(current.GiftRank, f.cmd.ActorUserID()) rankEvent, err := buildRoomRankChangedRecord(current.RoomID, current.Version, now, rankItem) if err != nil { return mutationResult{}, nil, err } if robotDisplayGift { // 房间热度和麦位收礼热度仍要给客户端展示;是否真实计入统计由 RobotGift 语义单独控制,不能再拿投递通道反推业务真实性。 f.robotDisplayRecords = append(f.robotDisplayRecords, heatEvent, rankEvent) } else { f.records = append(f.records, heatEvent, rankEvent) } if !f.cmd.RobotGift { // 真实房间礼物才广播火箭进度;真人房机器人送礼使用机器人钱包结算,但仍是真实房间贡献,所以火箭事件跟随机器人展示直发通道投递。 if f.rocketApply.progressEvent != nil { progressRecord, err := outbox.Build(current.RoomID, "RoomRocketFuelChanged", current.Version, now, f.rocketApply.progressEvent) if err != nil { return mutationResult{}, nil, err } progressRecord.AppCode = appcode.FromContext(f.ctx) if progressRecord.Envelope != nil { progressRecord.Envelope.AppCode = progressRecord.AppCode } // 燃料变化是高频展示事件,不逐笔写 outbox;命令提交成功后由内存合并器按 1 秒窗口只落最大进度。 f.rocketProgress = &roomRocketProgressPending{ record: progressRecord, robotLane: robotDisplayGift, rocketID: f.rocketApply.progressEvent.GetRocketId(), level: f.rocketApply.progressEvent.GetLevel(), currentFuel: f.rocketApply.progressEvent.GetCurrentFuel(), roomVersion: current.Version, } } if f.rocketApply.ignited != nil { ignitedEvent, err := outbox.Build(current.RoomID, "RoomRocketIgnited", current.Version, now, f.rocketApply.ignited) if err != nil { return mutationResult{}, nil, err } if robotDisplayGift { f.robotDisplayRecords = append(f.robotDisplayRecords, ignitedEvent) } else { f.records = append(f.records, ignitedEvent) } } } if f.cmd.SyntheticLuckyGift && f.luckyGift != nil { drawEvent, err := outbox.Build(current.RoomID, "RoomRobotLuckyGiftDrawn", current.Version, now, &roomeventsv1.RoomRobotLuckyGiftDrawn{ DrawId: f.luckyGift.GetDrawId(), CommandId: f.cmd.ID(), SenderUserId: f.cmd.ActorUserID(), TargetUserId: f.cmd.TargetUserID, GiftId: f.cmd.GiftID, GiftCount: f.cmd.GiftCount, PoolId: f.cmd.PoolID, MultiplierPpm: f.luckyGift.GetMultiplierPpm(), BaseRewardCoins: f.luckyGift.GetBaseRewardCoins(), RoomAtmosphereRewardCoins: f.luckyGift.GetRoomAtmosphereRewardCoins(), ActivitySubsidyCoins: f.luckyGift.GetActivitySubsidyCoins(), EffectiveRewardCoins: f.luckyGift.GetEffectiveRewardCoins(), CreatedAtMs: f.luckyGift.GetCreatedAtMs(), VisibleRegionId: f.roomMeta.VisibleRegionID, IsRobot: true, Synthetic: true, }) if err != nil { return mutationResult{}, nil, err } if robotDisplayGift { f.robotDisplayRecords = append(f.robotDisplayRecords, drawEvent) } else { f.records = append(f.records, drawEvent) } } if f.batchDisplayEnabled { // 新 batch-send 只合并房间展示消息:逐目标 RoomGiftSent 已经在 giftEvents 中保留为 durable 业务事实。 f.batchDisplay = buildSendGiftBatchDisplay(f.cmd, f.billing, f.targetBillings, f.targetCurrentGiftValues, f.luckyGifts, current.Heat) batchEvent := roomGiftBatchSentEventFromDisplay(f.batchDisplay, f.roomMeta.VisibleRegionID) batchRecord, err := outbox.Build(current.RoomID, "RoomGiftBatchSent", current.Version, now, batchEvent) if err != nil { return mutationResult{}, nil, err } f.batchDisplay.EventId = batchRecord.EventID f.records = append(f.records, batchRecord) f.suppressDirectIMEventTypes = map[string]bool{"RoomGiftSent": true} } return f.toMutationResult(now, current), f.records, nil } func (f *giftFlow) toMutationResult(now time.Time, current *state.RoomState) mutationResult { finalCoinBalanceAfter := giftFinalCoinBalanceAfter(f.billing) firstTargetDisplayProfile := giftDisplayProfileForUser(f.cmd.TargetDisplayProfiles, f.cmd.TargetUserID) result := mutationResult{ snapshot: current.ToProto(), billingReceiptID: f.billing.GetBillingReceiptId(), coinBalanceAfter: finalCoinBalanceAfter, giftIncomeBalanceAfter: f.billing.GetGiftIncomeBalanceAfter(), roomHeat: current.Heat, giftRank: cloneProtoRank(current.GiftRank), luckyGift: f.luckyGift, luckyGifts: f.luckyGifts, batchDisplay: f.batchDisplay, suppressDirectIMEventTypes: f.suppressDirectIMEventTypes, commandPayload: f.commandPayload, walletDebitMS: f.walletDebitMS, robotDisplayRecords: f.robotDisplayRecords, roomRocketProgress: f.rocketProgress, roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{ AppCode: appcode.FromContext(f.ctx), RoomID: current.RoomID, CoinSpent: roomGiftLeaderboardValue(f.billing), OccurredAtMS: now.UnixMilli(), }, roomUserGiftStats: []RoomUserGiftStatIncrement{ { AppCode: appcode.FromContext(f.ctx), RoomID: current.RoomID, UserID: f.cmd.ActorUserID(), GiftValue: f.heatValue, LastGiftAtMS: now.UnixMilli(), }, }, roomWeeklyContribution: &RoomWeeklyContributionIncrement{ AppCode: appcode.FromContext(f.ctx), RoomID: current.RoomID, GiftValue: f.heatValue, OccurredMS: now.UnixMilli(), }, syncEvent: &tencentim.RoomEvent{ // 同步广播只选 GiftSent 主事件作为客户端房间系统消息,Heat/Rank 通过字段携带。 EventID: f.giftEvents[0].EventID, RoomID: current.RoomID, EventType: "room_gift_sent", ActorUserID: f.cmd.ActorUserID(), TargetUserID: f.cmd.TargetUserID, GiftValue: f.heatValue, RoomHeat: current.Heat, RoomVersion: current.Version, Attributes: map[string]string{ "gift_id": f.cmd.GiftID, "pool_id": f.cmd.PoolID, "gift_count": fmt.Sprintf("%d", f.cmd.GiftCount), "billing_receipt_id": f.billing.GetBillingReceiptId(), "coin_spent": fmt.Sprintf("%d", f.settledCommand.CoinSpent), "target_count": fmt.Sprintf("%d", len(f.cmd.TargetUserIDs)), "target_user_ids": giftTargetUserIDsAttribute(f.cmd.TargetUserIDs), "price_version": f.settledCommand.PriceVersion, "host_period_diamond_added": fmt.Sprintf("%d", f.settledCommand.HostPeriodDiamondAdded), "host_period_cycle_key": f.settledCommand.HostPeriodCycleKey, "gift_type_code": f.billing.GetGiftTypeCode(), "cp_relation_type": f.billing.GetCpRelationType(), "gift_name": f.billing.GetGiftName(), "gift_icon_url": f.billing.GetGiftIconUrl(), "gift_animation_url": f.billing.GetGiftAnimationUrl(), "target_gift_value": fmt.Sprintf("%d", f.targetCurrentGiftValues[f.cmd.TargetUserID]), "is_robot_gift": fmt.Sprintf("%t", f.cmd.RobotWalletGift), "synthetic_lucky_gift": fmt.Sprintf("%t", f.cmd.SyntheticLuckyGift), "sender_name": giftDisplayName(f.cmd.SenderDisplayProfile), "sender_avatar": strings.TrimSpace(f.cmd.SenderDisplayProfile.Avatar), "sender_display_user_id": strings.TrimSpace(f.cmd.SenderDisplayProfile.DisplayUserID), "sender_pretty_display_user_id": strings.TrimSpace(f.cmd.SenderDisplayProfile.PrettyDisplayUserID), "receiver_nickname": giftDisplayName(firstTargetDisplayProfile), "receiver_avatar": strings.TrimSpace(firstTargetDisplayProfile.Avatar), "receiver_display_user_id": strings.TrimSpace(firstTargetDisplayProfile.DisplayUserID), "receiver_pretty_display_user_id": strings.TrimSpace(firstTargetDisplayProfile.PrettyDisplayUserID), }, }, } if f.cmd.RobotGift { // 机器人房间可以增加房间热度和麦位收礼热度,但不进入跨房礼物榜和房间送礼统计。 result.roomGiftLeaderboard = nil result.roomUserGiftStats = nil result.roomWeeklyContribution = nil } return result }