diff --git a/api/proto/events/room/v1/events.pb.go b/api/proto/events/room/v1/events.pb.go index e3f8a2ef..b901a3c3 100644 --- a/api/proto/events/room/v1/events.pb.go +++ b/api/proto/events/room/v1/events.pb.go @@ -657,6 +657,7 @@ type RoomMicChanged struct { PublishEventTimeMs int64 `protobuf:"varint,10,opt,name=publish_event_time_ms,json=publishEventTimeMs,proto3" json:"publish_event_time_ms,omitempty"` MicMuted bool `protobuf:"varint,11,opt,name=mic_muted,json=micMuted,proto3" json:"mic_muted,omitempty"` SeatStatus string `protobuf:"bytes,12,opt,name=seat_status,json=seatStatus,proto3" json:"seat_status,omitempty"` + TargetGiftValue int64 `protobuf:"varint,13,opt,name=target_gift_value,json=targetGiftValue,proto3" json:"target_gift_value,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -775,6 +776,13 @@ func (x *RoomMicChanged) GetSeatStatus() string { return "" } +func (x *RoomMicChanged) GetTargetGiftValue() int64 { + if x != nil { + return x.TargetGiftValue + } + return 0 +} + // RoomMicSeatLocked 表达单个麦位锁定状态变更。 type RoomMicSeatLocked struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1199,6 +1207,7 @@ type RoomGiftSent struct { GiftName string `protobuf:"bytes,15,opt,name=gift_name,json=giftName,proto3" json:"gift_name,omitempty"` GiftIconUrl string `protobuf:"bytes,16,opt,name=gift_icon_url,json=giftIconUrl,proto3" json:"gift_icon_url,omitempty"` GiftAnimationUrl string `protobuf:"bytes,17,opt,name=gift_animation_url,json=giftAnimationUrl,proto3" json:"gift_animation_url,omitempty"` + TargetGiftValue int64 `protobuf:"varint,18,opt,name=target_gift_value,json=targetGiftValue,proto3" json:"target_gift_value,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1352,6 +1361,13 @@ func (x *RoomGiftSent) GetGiftAnimationUrl() string { return "" } +func (x *RoomGiftSent) GetTargetGiftValue() int64 { + if x != nil { + return x.TargetGiftValue + } + return 0 +} + // RoomHeatChanged 表达热度变化结果。 type RoomHeatChanged struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2095,7 +2111,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" + "\n" + "RoomClosed\x12\"\n" + "\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12\x16\n" + - "\x06reason\x18\x02 \x01(\tR\x06reason\"\xac\x03\n" + + "\x06reason\x18\x02 \x01(\tR\x06reason\"\xd8\x03\n" + "\x0eRoomMicChanged\x12\"\n" + "\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x1b\n" + @@ -2110,7 +2126,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" + " \x01(\x03R\x12publishEventTimeMs\x12\x1b\n" + "\tmic_muted\x18\v \x01(\bR\bmicMuted\x12\x1f\n" + "\vseat_status\x18\f \x01(\tR\n" + - "seatStatus\"h\n" + + "seatStatus\x12*\n" + + "\x11target_gift_value\x18\r \x01(\x03R\x0ftargetGiftValue\"h\n" + "\x11RoomMicSeatLocked\x12\"\n" + "\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12\x17\n" + "\aseat_no\x18\x02 \x01(\x05R\x06seatNo\x12\x16\n" + @@ -2135,7 +2152,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\\\n" + "\x10RoomUserUnbanned\x12\"\n" + "\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" + - "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\xdd\x04\n" + + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\x89\x05\n" + "\fRoomGiftSent\x12$\n" + "\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12$\n" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" + @@ -2159,7 +2176,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" + "\x10cp_relation_type\x18\x0e \x01(\tR\x0ecpRelationType\x12\x1b\n" + "\tgift_name\x18\x0f \x01(\tR\bgiftName\x12\"\n" + "\rgift_icon_url\x18\x10 \x01(\tR\vgiftIconUrl\x12,\n" + - "\x12gift_animation_url\x18\x11 \x01(\tR\x10giftAnimationUrl\"J\n" + + "\x12gift_animation_url\x18\x11 \x01(\tR\x10giftAnimationUrl\x12*\n" + + "\x11target_gift_value\x18\x12 \x01(\x03R\x0ftargetGiftValue\"J\n" + "\x0fRoomHeatChanged\x12\x14\n" + "\x05delta\x18\x01 \x01(\x03R\x05delta\x12!\n" + "\fcurrent_heat\x18\x02 \x01(\x03R\vcurrentHeat\"_\n" + diff --git a/api/proto/events/room/v1/events.proto b/api/proto/events/room/v1/events.proto index f3a85dfa..ae64da5f 100644 --- a/api/proto/events/room/v1/events.proto +++ b/api/proto/events/room/v1/events.proto @@ -95,6 +95,7 @@ message RoomMicChanged { int64 publish_event_time_ms = 10; bool mic_muted = 11; string seat_status = 12; + int64 target_gift_value = 13; } // RoomMicSeatLocked 表达单个麦位锁定状态变更。 @@ -163,6 +164,7 @@ message RoomGiftSent { string gift_name = 15; string gift_icon_url = 16; string gift_animation_url = 17; + int64 target_gift_value = 18; } // RoomHeatChanged 表达热度变化结果。 diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index 82d0f494..57a067b2 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -193,6 +193,7 @@ type RoomUser struct { Role string `protobuf:"bytes,2,opt,name=role,proto3" json:"role,omitempty"` JoinedAtMs int64 `protobuf:"varint,3,opt,name=joined_at_ms,json=joinedAtMs,proto3" json:"joined_at_ms,omitempty"` LastSeenAtMs int64 `protobuf:"varint,4,opt,name=last_seen_at_ms,json=lastSeenAtMs,proto3" json:"last_seen_at_ms,omitempty"` + GiftValue int64 `protobuf:"varint,5,opt,name=gift_value,json=giftValue,proto3" json:"gift_value,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -255,6 +256,13 @@ func (x *RoomUser) GetLastSeenAtMs() int64 { return 0 } +func (x *RoomUser) GetGiftValue() int64 { + if x != nil { + return x.GiftValue + } + return 0 +} + // RoomOnlineUser 是在线用户列表的展示读模型。 // role 保留进房 presence 语义;room_role 只表达房间管理员身份;is_owner 单独表达房主身份。 type RoomOnlineUser struct { @@ -371,8 +379,10 @@ type SeatState struct { SeatStatus string `protobuf:"bytes,10,opt,name=seat_status,json=seatStatus,proto3" json:"seat_status,omitempty"` // mic_heartbeat_at_ms 是当前 mic_session 最近一次服务端接受麦上心跳的 UTC epoch ms。 MicHeartbeatAtMs int64 `protobuf:"varint,11,opt,name=mic_heartbeat_at_ms,json=micHeartbeatAtMs,proto3" json:"mic_heartbeat_at_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // gift_value 是当前房间内该麦位用户收到的礼物热度累计,用户离房后清零。 + GiftValue int64 `protobuf:"varint,12,opt,name=gift_value,json=giftValue,proto3" json:"gift_value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SeatState) Reset() { @@ -482,6 +492,13 @@ func (x *SeatState) GetMicHeartbeatAtMs() int64 { return 0 } +func (x *SeatState) GetGiftValue() int64 { + if x != nil { + return x.GiftValue + } + return 0 +} + // RankItem 表达房间内本地礼物榜项目。 type RankItem struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -9371,13 +9388,15 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\rCommandResult\x12\x18\n" + "\aapplied\x18\x01 \x01(\bR\aapplied\x12!\n" + "\froom_version\x18\x02 \x01(\x03R\vroomVersion\x12$\n" + - "\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs\"\x80\x01\n" + + "\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs\"\x9f\x01\n" + "\bRoomUser\x12\x17\n" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x12\n" + "\x04role\x18\x02 \x01(\tR\x04role\x12 \n" + "\fjoined_at_ms\x18\x03 \x01(\x03R\n" + "joinedAtMs\x12%\n" + - "\x0flast_seen_at_ms\x18\x04 \x01(\x03R\flastSeenAtMs\"\xdd\x01\n" + + "\x0flast_seen_at_ms\x18\x04 \x01(\x03R\flastSeenAtMs\x12\x1d\n" + + "\n" + + "gift_value\x18\x05 \x01(\x03R\tgiftValue\"\xdd\x01\n" + "\x0eRoomOnlineUser\x12\x17\n" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x12\n" + "\x04role\x18\x02 \x01(\tR\x04role\x12\x1b\n" + @@ -9387,7 +9406,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\fjoined_at_ms\x18\x05 \x01(\x03R\n" + "joinedAtMs\x12%\n" + "\x0flast_seen_at_ms\x18\x06 \x01(\x03R\flastSeenAtMs\x12\x19\n" + - "\bis_owner\x18\a \x01(\bR\aisOwner\"\xb2\x03\n" + + "\bis_owner\x18\a \x01(\bR\aisOwner\"\xd1\x03\n" + "\tSeatState\x12\x17\n" + "\aseat_no\x18\x01 \x01(\x05R\x06seatNo\x12\x17\n" + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x16\n" + @@ -9401,7 +9420,9 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\vseat_status\x18\n" + " \x01(\tR\n" + "seatStatus\x12-\n" + - "\x13mic_heartbeat_at_ms\x18\v \x01(\x03R\x10micHeartbeatAtMs\"|\n" + + "\x13mic_heartbeat_at_ms\x18\v \x01(\x03R\x10micHeartbeatAtMs\x12\x1d\n" + + "\n" + + "gift_value\x18\f \x01(\x03R\tgiftValue\"|\n" + "\bRankItem\x12\x17\n" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x14\n" + "\x05score\x18\x02 \x01(\x03R\x05score\x12\x1d\n" + diff --git a/api/proto/room/v1/room.proto b/api/proto/room/v1/room.proto index c2656c8d..5ec6fb2b 100644 --- a/api/proto/room/v1/room.proto +++ b/api/proto/room/v1/room.proto @@ -32,6 +32,7 @@ message RoomUser { string role = 2; int64 joined_at_ms = 3; int64 last_seen_at_ms = 4; + int64 gift_value = 5; } // RoomOnlineUser 是在线用户列表的展示读模型。 @@ -67,6 +68,8 @@ message SeatState { string seat_status = 10; // mic_heartbeat_at_ms 是当前 mic_session 最近一次服务端接受麦上心跳的 UTC epoch ms。 int64 mic_heartbeat_at_ms = 11; + // gift_value 是当前房间内该麦位用户收到的礼物热度累计,用户离房后清零。 + int64 gift_value = 12; } // RankItem 表达房间内本地礼物榜项目。 diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go b/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go index c64667c4..7555e6a1 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go @@ -83,6 +83,21 @@ func TestCreateRoomDataIncludesExplicitIMGroupID(t *testing.T) { } } +func TestRoomSeatDataIncludesGiftValue(t *testing.T) { + seats := roomSeatDataFromSnapshot(&roomv1.RoomSnapshot{ + RoomId: "room-seat-gift-value", + MicSeats: []*roomv1.SeatState{{ + SeatNo: 3, + UserId: 1003, + GiftValue: 367, + }}, + }) + + if len(seats) != 1 || seats[0].SeatNo != 3 || seats[0].UserID != "1003" || seats[0].GiftValue != 367 { + t.Fatalf("seat data must expose gift_value: %+v", seats) + } +} + func TestRoomProfileAgeFromBirth(t *testing.T) { now := time.Date(2026, time.May, 27, 12, 0, 0, 0, time.UTC) tests := []struct { diff --git a/services/gateway-service/internal/transport/http/roomapi/room_view.go b/services/gateway-service/internal/transport/http/roomapi/room_view.go index 96affcdd..118ecd96 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_view.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_view.go @@ -407,6 +407,7 @@ type roomSeatData struct { MicMuted bool `json:"mic_muted"` SeatStatus string `json:"seat_status,omitempty"` MicHeartbeatAtMS int64 `json:"mic_heartbeat_at_ms,omitempty"` + GiftValue int64 `json:"gift_value"` } type roomRankItemData struct { @@ -842,6 +843,7 @@ func roomSeatDataFromSnapshot(snapshot *roomv1.RoomSnapshot) []roomSeatData { MicMuted: seat.GetMicMuted(), SeatStatus: seat.GetSeatStatus(), MicHeartbeatAtMS: seat.GetMicHeartbeatAtMs(), + GiftValue: seat.GetGiftValue(), }) } return items diff --git a/services/room-service/internal/integration/tencent_im.go b/services/room-service/internal/integration/tencent_im.go index f9a8f6b2..1417fb19 100644 --- a/services/room-service/internal/integration/tencent_im.go +++ b/services/room-service/internal/integration/tencent_im.go @@ -177,6 +177,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room "publish_event_time_ms": fmt.Sprintf("%d", body.GetPublishEventTimeMs()), "mic_muted": fmt.Sprintf("%t", body.GetMicMuted()), "seat_status": body.GetSeatStatus(), + "target_gift_value": fmt.Sprintf("%d", body.GetTargetGiftValue()), } return base, true, nil case "RoomMicSeatLocked": @@ -284,6 +285,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room "gift_count": fmt.Sprintf("%d", body.GetGiftCount()), "coin_spent": fmt.Sprintf("%d", body.GetCoinSpent()), "billing_receipt_id": body.GetBillingReceiptId(), + "target_gift_value": fmt.Sprintf("%d", body.GetTargetGiftValue()), } return base, true, nil case "RoomRocketFuelChanged": diff --git a/services/room-service/internal/integration/tencent_im_test.go b/services/room-service/internal/integration/tencent_im_test.go index 01b7ae94..42c6161b 100644 --- a/services/room-service/internal/integration/tencent_im_test.go +++ b/services/room-service/internal/integration/tencent_im_test.go @@ -88,6 +88,56 @@ func TestRoomBackgroundChangedPublishesDedicatedIMEvent(t *testing.T) { } } +func TestRoomGiftSentCarriesTargetGiftValueInIMAttributes(t *testing.T) { + record, err := outbox.Build("room-gift-value", "RoomGiftSent", 8, time.Now(), &roomeventsv1.RoomGiftSent{ + SenderUserId: 1001, + TargetUserId: 1002, + GiftId: "gift_rose", + GiftCount: 1, + GiftValue: 117, + TargetGiftValue: 367, + }) + if err != nil { + t.Fatalf("Build RoomGiftSent envelope failed: %v", err) + } + + event, publish, err := roomEventFromEnvelope(record.Envelope) + if err != nil { + t.Fatalf("RoomGiftSent should decode: %v", err) + } + if !publish { + t.Fatal("RoomGiftSent must publish Tencent IM message") + } + if event.EventType != "room_gift_sent" || event.GiftValue != 117 || event.Attributes["target_gift_value"] != "367" { + t.Fatalf("gift IM event target gift value mismatch: %+v", event) + } +} + +func TestRoomMicChangedCarriesTargetGiftValueInIMAttributes(t *testing.T) { + record, err := outbox.Build("room-mic-value", "RoomMicChanged", 9, time.Now(), &roomeventsv1.RoomMicChanged{ + ActorUserId: 1001, + TargetUserId: 1002, + FromSeat: 2, + ToSeat: 5, + Action: "change", + TargetGiftValue: 367, + }) + if err != nil { + t.Fatalf("Build RoomMicChanged envelope failed: %v", err) + } + + event, publish, err := roomEventFromEnvelope(record.Envelope) + if err != nil { + t.Fatalf("RoomMicChanged should decode: %v", err) + } + if !publish { + t.Fatal("RoomMicChanged must publish Tencent IM message") + } + if event.EventType != "room_mic_changed" || event.SeatNo != 5 || event.Attributes["target_gift_value"] != "367" { + t.Fatalf("mic IM event target gift value mismatch: %+v", event) + } +} + func TestRoomPasswordChangedSkipsRoomGroupIM(t *testing.T) { record, err := outbox.Build("room-lock", "RoomPasswordChanged", 8, time.Now(), &roomeventsv1.RoomPasswordChanged{ ActorUserId: 42, diff --git a/services/room-service/internal/room/command/command.go b/services/room-service/internal/room/command/command.go index bb20e434..18161a57 100644 --- a/services/room-service/internal/room/command/command.go +++ b/services/room-service/internal/room/command/command.go @@ -419,6 +419,8 @@ type SendGift struct { GiftPointAdded int64 `json:"gift_point_added,omitempty"` // HeatValue 是 Room Cell 恢复时唯一可信的热度增量,不能重新用客户端价格推导。 HeatValue int64 `json:"heat_value,omitempty"` + // TargetGiftValues 记录每个收礼用户本次从 wallet receipt 得到的热度增量;恢复时只按目标增量重建收礼用户在线态。 + TargetGiftValues map[int64]int64 `json:"target_gift_values,omitempty"` // PriceVersion 是本次钱包结算使用的服务端礼物价格版本。 PriceVersion string `json:"price_version,omitempty"` // GiftTypeCode 是 wallet-service 结算时锁定的礼物类型,用于火箭礼物类型燃料规则。 diff --git a/services/room-service/internal/room/service/gift.go b/services/room-service/internal/room/service/gift.go index 177765e0..df9491e1 100644 --- a/services/room-service/internal/room/service/gift.go +++ b/services/room-service/internal/room/service/gift.go @@ -153,6 +153,26 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r // 扣费成功后,Room Cell 同步更新热度和本地礼物榜。 current.Heat += heatValue current.GiftRank = localRank.ApplyGift(cmd.ActorUserID(), heatValue, now) + targetGiftValueDeltas := make(map[int64]int64, len(targetBillings)) + targetCurrentGiftValues := make(map[int64]int64, len(targetBillings)) + for _, targetBilling := range 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 + targetGiftValueDeltas[targetBilling.TargetUserID] += targetGiftValue + } + targetCurrentGiftValues[targetBilling.TargetUserID] = userState.GiftValue + } + } + if len(targetGiftValueDeltas) > 0 { + // 命令日志只保存每个目标本次增量;累计值由在线用户态按 replay 顺序重建。 + settledCommand.TargetGiftValues = targetGiftValueDeltas + } current.Version++ rocketApply, err := s.applyRoomRocketGift(now, current, rocketConfig, cmd, &settledCommand, billing, roomMeta) if err != nil { @@ -184,6 +204,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r GiftName: targetBilling.Billing.GetGiftName(), GiftIconUrl: targetBilling.Billing.GetGiftIconUrl(), GiftAnimationUrl: targetBilling.Billing.GetGiftAnimationUrl(), + TargetGiftValue: targetCurrentGiftValues[targetBilling.TargetUserID], }) if err != nil { return mutationResult{}, nil, err @@ -277,6 +298,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r "gift_name": billing.GetGiftName(), "gift_icon_url": billing.GetGiftIconUrl(), "gift_animation_url": billing.GetGiftAnimationUrl(), + "target_gift_value": fmt.Sprintf("%d", targetCurrentGiftValues[cmd.TargetUserID]), }, }, }, records, nil diff --git a/services/room-service/internal/room/service/mic.go b/services/room-service/internal/room/service/mic.go index 19e6a2a5..810bc58f 100644 --- a/services/room-service/internal/room/service/mic.go +++ b/services/room-service/internal/room/service/mic.go @@ -71,6 +71,7 @@ func (s *Service) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1. current.MicSeats[index].MicHeartbeatAtMS = 0 current.MicSeats[index].MicMuted = false seatStatus := current.MicSeats[index].SeatStatus() + targetGiftValue := roomUserGiftValue(current, cmd.ActorUserID()) // RoomMicChanged 是房间外消费者和腾讯云 IM 共同使用的麦位事件源。 micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{ @@ -83,6 +84,7 @@ func (s *Service) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1. PublishState: state.MicPublishPending, PublishDeadlineMs: cmd.PublishDeadlineMS, SeatStatus: seatStatus, + TargetGiftValue: targetGiftValue, }) if err != nil { return mutationResult{}, nil, err @@ -107,6 +109,7 @@ func (s *Service) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1. "mic_session_id": cmd.MicSessionID, "publish_deadline_ms": int64String(cmd.PublishDeadlineMS), "seat_status": seatStatus, + "target_gift_value": int64String(targetGiftValue), }, }, }, []outbox.Record{micEvent}, nil @@ -296,6 +299,7 @@ func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRe current.MicSeats[toIndex].MicMuted = movedSeat.MicMuted current.Version++ seatStatus := current.MicSeats[toIndex].SeatStatus() + targetGiftValue := roomUserGiftValue(current, cmd.TargetUserID) micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{ ActorUserId: cmd.ActorUserID(), @@ -307,6 +311,7 @@ func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRe PublishState: movedSeat.PublishState, PublishDeadlineMs: movedSeat.PublishDeadlineMS, SeatStatus: seatStatus, + TargetGiftValue: targetGiftValue, }) if err != nil { return mutationResult{}, nil, err @@ -328,6 +333,7 @@ func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRe "mic_session_id": movedSeat.MicSessionID, "publish_deadline_ms": int64String(movedSeat.PublishDeadlineMS), "seat_status": seatStatus, + "target_gift_value": int64String(targetGiftValue), }, }, }, []outbox.Record{micEvent}, nil @@ -401,6 +407,7 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM } current.Version++ seatStatus := current.MicSeats[index].SeatStatus() + targetGiftValue := roomUserGiftValue(current, cmd.TargetUserID) micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{ ActorUserId: cmd.ActorUserID(), @@ -414,6 +421,7 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM PublishEventTimeMs: cmd.EventTimeMS, MicMuted: current.MicSeats[index].MicMuted, SeatStatus: seatStatus, + TargetGiftValue: targetGiftValue, }) if err != nil { return mutationResult{}, nil, err @@ -441,6 +449,7 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM "source": cmd.Source, "mic_muted": boolString(current.MicSeats[index].MicMuted), "seat_status": seatStatus, + "target_gift_value": int64String(targetGiftValue), }, }, }, []outbox.Record{micEvent}, nil @@ -565,6 +574,7 @@ func (s *Service) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) current.MicSeats[index].MicMuted = cmd.Muted current.Version++ seatStatus := current.MicSeats[index].SeatStatus() + targetGiftValue := roomUserGiftValue(current, cmd.TargetUserID) action := "mic_unmute" if cmd.Muted { @@ -581,6 +591,7 @@ func (s *Service) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) PublishDeadlineMs: seat.PublishDeadlineMS, MicMuted: cmd.Muted, SeatStatus: seatStatus, + TargetGiftValue: targetGiftValue, }) if err != nil { return mutationResult{}, nil, err @@ -598,11 +609,12 @@ func (s *Service) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) SeatNo: seat.SeatNo, RoomVersion: current.Version, Attributes: map[string]string{ - "action": action, - "mic_session_id": seat.MicSessionID, - "publish_state": seat.PublishState, - "mic_muted": boolString(cmd.Muted), - "seat_status": seatStatus, + "action": action, + "mic_session_id": seat.MicSessionID, + "publish_state": seat.PublishState, + "mic_muted": boolString(cmd.Muted), + "seat_status": seatStatus, + "target_gift_value": int64String(targetGiftValue), }, }, }, []outbox.Record{micEvent}, nil @@ -654,3 +666,14 @@ func protoSeatByUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.SeatSt return nil } + +func roomUserGiftValue(current *state.RoomState, userID int64) int64 { + if current == nil || userID <= 0 { + return 0 + } + if userState := current.OnlineUsers[userID]; userState != nil { + // 麦位事件只透出在线用户态中的收礼热度,避免把麦位号当成热度归属。 + return userState.GiftValue + } + return 0 +} diff --git a/services/room-service/internal/room/service/recovery.go b/services/room-service/internal/room/service/recovery.go index b129065f..6b233456 100644 --- a/services/room-service/internal/room/service/recovery.go +++ b/services/room-service/internal/room/service/recovery.go @@ -379,6 +379,15 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64) // 送礼回放不能再次调用 wallet,只使用命令中记录的钱包结算热度重建展示态。 total := typed.HeatValue current.Heat += total + for targetUserID, giftValue := range sendGiftTargetGiftValues(typed) { + if giftValue <= 0 { + continue + } + if userState := current.OnlineUsers[targetUserID]; userState != nil { + // 收礼用户热度只恢复当时仍在房间内的在线态;后续 Leave/Kick/Stale 命令会删除在线态并自然清零。 + userState.GiftValue += giftValue + } + } applied := false for index := range current.GiftRank { if current.GiftRank[index].UserID == typed.ActorUserID() { @@ -417,6 +426,21 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64) return nil } +func sendGiftTargetGiftValues(cmd *command.SendGift) map[int64]int64 { + if cmd == nil { + return nil + } + if len(cmd.TargetGiftValues) > 0 { + return cmd.TargetGiftValues + } + if cmd.TargetUserID > 0 && len(cmd.TargetUserIDs) <= 1 && cmd.HeatValue > 0 { + // 旧单目标命令没有 TargetGiftValues 时,HeatValue 同时就是该唯一收礼人的本次热度增量。 + return map[int64]int64{cmd.TargetUserID: cmd.HeatValue} + } + // 旧多目标命令无法从聚合 HeatValue 反推出每个收礼人的值,宁可不恢复也不能把总值错误扩散给每个目标。 + return nil +} + func rocketStateFromSettledGift(cmd *command.SendGift) *state.RocketState { return &state.RocketState{ CurrentLevel: cmd.RocketLevel, diff --git a/services/room-service/internal/room/service/room_rocket_test.go b/services/room-service/internal/room/service/room_rocket_test.go index 661f8e2f..a2212938 100644 --- a/services/room-service/internal/room/service/room_rocket_test.go +++ b/services/room-service/internal/room/service/room_rocket_test.go @@ -129,6 +129,12 @@ func TestSendGiftBatchDebitsMultipleTargets(t *testing.T) { if resp.GetRoomHeat() != 30 || resp.GetBillingReceiptId() != "receipt-202,receipt-303" { t.Fatalf("multi-target gift response must aggregate wallet results: %+v", resp) } + if firstUser := onlineUserByID(resp.GetRoom(), firstTargetID); firstUser == nil || firstUser.GetGiftValue() != 11 { + t.Fatalf("first target must receive only its own heat value: %+v", firstUser) + } + if secondUser := onlineUserByID(resp.GetRoom(), secondTargetID); secondUser == nil || secondUser.GetGiftValue() != 19 { + t.Fatalf("second target must receive only its own heat value: %+v", secondUser) + } if len(wallet.debitRequests) != 0 { t.Fatalf("multi-target gift must use wallet BatchDebitGift, single debits=%+v", wallet.debitRequests) } @@ -150,9 +156,15 @@ func TestSendGiftBatchDebitsMultipleTargets(t *testing.T) { if got[firstTargetID].GetBillingReceiptId() != "receipt-202" || got[firstTargetID].GetGiftValue() != 11 || got[firstTargetID].GetCommandId() != "cmd-gift-multi:target:202" { t.Fatalf("first target GiftSent mismatch: %+v", got[firstTargetID]) } + if got[firstTargetID].GetTargetGiftValue() != 11 { + t.Fatalf("first target GiftSent must carry its cumulative value: %+v", got[firstTargetID]) + } if got[secondTargetID].GetBillingReceiptId() != "receipt-303" || got[secondTargetID].GetGiftValue() != 19 || got[secondTargetID].GetCommandId() != "cmd-gift-multi:target:303" { t.Fatalf("second target GiftSent mismatch: %+v", got[secondTargetID]) } + if got[secondTargetID].GetTargetGiftValue() != 19 { + t.Fatalf("second target GiftSent must carry its cumulative value: %+v", got[secondTargetID]) + } } func (w *rocketTestWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) { diff --git a/services/room-service/internal/room/service/user_gift_value_test.go b/services/room-service/internal/room/service/user_gift_value_test.go new file mode 100644 index 00000000..191efb07 --- /dev/null +++ b/services/room-service/internal/room/service/user_gift_value_test.go @@ -0,0 +1,192 @@ +package service_test + +import ( + "context" + "testing" + "time" + + "google.golang.org/protobuf/proto" + roomeventsv1 "hyapp.local/api/proto/events/room/v1" + roomv1 "hyapp.local/api/proto/room/v1" + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/services/room-service/internal/integration" + roomservice "hyapp/services/room-service/internal/room/service" + "hyapp/services/room-service/internal/router" + "hyapp/services/room-service/internal/testutil/mysqltest" +) + +func TestRoomUserGiftValueFollowsMicChangeAndResetsAfterLeave(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 10, 0, 0, 0, time.UTC)} + wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{ + BillingReceiptId: "receipt-target-heat", + CoinSpent: 1000, + ChargeAmount: 1000, + HeatValue: 117, + GiftTypeCode: "normal", + }}} + svc := newUserGiftValueTestService(repository, wallet, now, "node-user-gift-value") + + roomID := "room-user-gift-value" + ownerID := int64(61001) + targetID := int64(61002) + createRocketRoom(t, ctx, svc, roomID, ownerID, 9001) + joinRocketRoom(t, ctx, svc, roomID, targetID) + if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{ + Meta: rocketMeta(roomID, targetID, "target-heat-up"), + SeatNo: 2, + }); err != nil { + t.Fatalf("target mic up failed: %v", err) + } + + giftResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ + Meta: rocketMeta(roomID, ownerID, "target-heat-gift"), + TargetType: "user", + TargetUserId: targetID, + GiftId: "gift-target-heat", + GiftCount: 1, + }) + if err != nil { + t.Fatalf("send gift failed: %v", err) + } + if user := onlineUserByID(giftResp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 117 { + t.Fatalf("target online user gift_value mismatch: %+v", user) + } + if seat := seatByNo(giftResp.GetRoom(), 2); seat == nil || seat.GetUserId() != targetID || seat.GetGiftValue() != 117 { + t.Fatalf("target seat gift_value mismatch after gift: %+v", seat) + } + giftEvents := roomGiftSentEvents(t, ctx, repository) + if len(giftEvents) != 1 || giftEvents[0].GetTargetGiftValue() != 117 { + t.Fatalf("RoomGiftSent must carry target cumulative gift value: %+v", giftEvents) + } + + changeResp, err := svc.ChangeMicSeat(ctx, &roomv1.ChangeMicSeatRequest{ + Meta: rocketMeta(roomID, targetID, "target-heat-change"), + TargetUserId: targetID, + SeatNo: 5, + }) + if err != nil { + t.Fatalf("change mic failed: %v", err) + } + if oldSeat := seatByNo(changeResp.GetRoom(), 2); oldSeat == nil || oldSeat.GetUserId() != 0 || oldSeat.GetGiftValue() != 0 { + t.Fatalf("old seat must be empty after change: %+v", oldSeat) + } + if newSeat := seatByNo(changeResp.GetRoom(), 5); newSeat == nil || newSeat.GetUserId() != targetID || newSeat.GetGiftValue() != 117 { + t.Fatalf("gift value must follow user to new seat: %+v", newSeat) + } + if changed := firstMicChangedEventByAction(t, ctx, repository, "change"); changed == nil || changed.GetTargetGiftValue() != 117 { + t.Fatalf("RoomMicChanged/change must carry target gift value: %+v", changed) + } + + if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, targetID, "target-heat-leave")}); err != nil { + t.Fatalf("leave room failed: %v", err) + } + joinRocketRoom(t, ctx, svc, roomID, targetID) + reupResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{ + Meta: rocketMeta(roomID, targetID, "target-heat-reup"), + SeatNo: 1, + }) + if err != nil { + t.Fatalf("target mic re-up failed: %v", err) + } + if user := onlineUserByID(reupResp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 0 { + t.Fatalf("gift value must reset after leave and rejoin: %+v", user) + } + if seat := seatByNo(reupResp.GetRoom(), 1); seat == nil || seat.GetUserId() != targetID || seat.GetGiftValue() != 0 { + t.Fatalf("rejoined user seat gift_value must be zero: %+v", seat) + } +} + +func TestRoomUserGiftValueRecoversFromCommandLog(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 10, 30, 0, 0, time.UTC)} + wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{ + BillingReceiptId: "receipt-replay-target-heat", + CoinSpent: 1500, + ChargeAmount: 1500, + HeatValue: 367, + GiftTypeCode: "lucky", + }}} + svc := newUserGiftValueTestService(repository, wallet, now, "node-user-gift-replay-writer") + + roomID := "room-user-gift-replay" + ownerID := int64(62001) + targetID := int64(62002) + createRocketRoom(t, ctx, svc, roomID, ownerID, 9001) + joinRocketRoom(t, ctx, svc, roomID, targetID) + if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{ + Meta: rocketMeta(roomID, targetID, "replay-up"), + SeatNo: 2, + }); err != nil { + t.Fatalf("target mic up failed: %v", err) + } + if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ + Meta: rocketMeta(roomID, ownerID, "replay-gift"), + TargetType: "user", + TargetUserId: targetID, + GiftId: "gift-replay-target-heat", + GiftCount: 1, + }); err != nil { + t.Fatalf("send gift failed: %v", err) + } + if _, err := svc.ChangeMicSeat(ctx, &roomv1.ChangeMicSeatRequest{ + Meta: rocketMeta(roomID, targetID, "replay-change"), + TargetUserId: targetID, + SeatNo: 4, + }); err != nil { + t.Fatalf("change mic failed: %v", err) + } + + recoveredSvc := newUserGiftValueTestService(repository, &rocketTestWallet{}, now, "node-user-gift-replay-reader") + resp, err := recoveredSvc.GetRoomSnapshot(ctx, &roomv1.GetRoomSnapshotRequest{ + Meta: rocketMeta(roomID, ownerID, "replay-snapshot"), + RoomId: roomID, + ViewerUserId: ownerID, + }) + if err != nil { + t.Fatalf("recover room snapshot failed: %v", err) + } + if user := onlineUserByID(resp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 367 { + t.Fatalf("recovered online user gift_value mismatch: %+v", user) + } + if oldSeat := seatByNo(resp.GetRoom(), 2); oldSeat == nil || oldSeat.GetUserId() != 0 || oldSeat.GetGiftValue() != 0 { + t.Fatalf("recovered old seat must stay empty: %+v", oldSeat) + } + if newSeat := seatByNo(resp.GetRoom(), 4); newSeat == nil || newSeat.GetUserId() != targetID || newSeat.GetGiftValue() != 367 { + t.Fatalf("recovered seat must keep target gift value after change: %+v", newSeat) + } +} + +func newUserGiftValueTestService(repository *mysqltest.Repository, wallet *rocketTestWallet, clock *fixedRoomRocketClock, nodeID string) *roomservice.Service { + return roomservice.New(roomservice.Config{ + NodeID: nodeID, + LeaseTTL: 10 * time.Second, + RankLimit: 20, + SnapshotEveryN: 100, + Clock: clock, + }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) +} + +func firstMicChangedEventByAction(t *testing.T, ctx context.Context, repository *mysqltest.Repository, action string) *roomeventsv1.RoomMicChanged { + t.Helper() + + records, err := repository.ListPendingOutbox(ctx, 100) + if err != nil { + t.Fatalf("list pending outbox failed: %v", err) + } + for _, record := range records { + if record.EventType != "RoomMicChanged" { + continue + } + var event roomeventsv1.RoomMicChanged + if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil { + t.Fatalf("decode mic changed event failed: %v", err) + } + if event.GetAction() == action { + return &event + } + } + return nil +} diff --git a/services/room-service/internal/room/state/state.go b/services/room-service/internal/room/state/state.go index 526aa5a7..0c9eec7b 100644 --- a/services/room-service/internal/room/state/state.go +++ b/services/room-service/internal/room/state/state.go @@ -100,6 +100,8 @@ type RoomUserState struct { JoinedAtMS int64 // LastSeenAtMS 是最后一次房间业务操作时间,不等同于腾讯云 IM 心跳。 LastSeenAtMS int64 + // GiftValue 是该用户本次在线停留期间在当前房间收到的礼物热度,离房删除 OnlineUsers 后自然清零。 + GiftValue int64 } // UserModerationState 保存房间内 ban/mute 这类治理状态的时间边界。 @@ -387,12 +389,20 @@ func (s *RoomState) ToProto() *roomv1.RoomSnapshot { Role: userState.Role, JoinedAtMs: userState.JoinedAtMS, LastSeenAtMs: userState.LastSeenAtMS, + GiftValue: userState.GiftValue, }) } seats := make([]*roomv1.SeatState, 0, len(s.MicSeats)) for _, seat := range s.MicSeats { + seatGiftValue := int64(0) + if seat.UserID > 0 { + if userState, exists := s.OnlineUsers[seat.UserID]; exists && userState != nil { + // 麦位展示值从在线用户态派生,切麦只移动 UserID,不会重置已收到的礼物热度。 + seatGiftValue = userState.GiftValue + } + } // 麦位顺序沿用内部切片顺序,即初始化的 SeatNo 升序和后续原位修改。 seats = append(seats, &roomv1.SeatState{ SeatNo: seat.SeatNo, @@ -406,6 +416,7 @@ func (s *RoomState) ToProto() *roomv1.RoomSnapshot { MicMuted: seat.MicMuted, SeatStatus: seat.SeatStatus(), MicHeartbeatAtMs: seat.MicHeartbeatAtMS, + GiftValue: seatGiftValue, }) } @@ -494,6 +505,7 @@ func FromProto(snapshot *roomv1.RoomSnapshot) *RoomState { Role: user.GetRole(), JoinedAtMS: user.GetJoinedAtMs(), LastSeenAtMS: user.GetLastSeenAtMs(), + GiftValue: user.GetGiftValue(), } }