房间个人热度

This commit is contained in:
zhx 2026-06-08 21:27:08 +08:00
parent 1e038aa074
commit f4a76d7b3f
15 changed files with 415 additions and 15 deletions

View File

@ -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" +

View File

@ -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

View File

@ -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" +

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -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":

View File

@ -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,

View File

@ -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 结算时锁定的礼物类型,用于火箭礼物类型燃料规则。

View File

@ -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 已结算的 HeatValueroom-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

View File

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

View File

@ -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,

View File

@ -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) {

View File

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

View File

@ -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(),
}
}