diff --git a/api/proto/activity/v1/activity.pb.go b/api/proto/activity/v1/activity.pb.go index af9e0268..0f238adc 100644 --- a/api/proto/activity/v1/activity.pb.go +++ b/api/proto/activity/v1/activity.pb.go @@ -10124,8 +10124,10 @@ type RoomTurnoverRewardConfig struct { UpdatedByAdminId int64 `protobuf:"varint,4,opt,name=updated_by_admin_id,json=updatedByAdminId,proto3" json:"updated_by_admin_id,omitempty"` CreatedAtMs int64 `protobuf:"varint,5,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` UpdatedAtMs int64 `protobuf:"varint,6,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // region_id=0 表示全区域默认配置;正数表示只覆盖对应区域。 + RegionId int64 `protobuf:"varint,7,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RoomTurnoverRewardConfig) Reset() { @@ -10200,6 +10202,13 @@ func (x *RoomTurnoverRewardConfig) GetUpdatedAtMs() int64 { return 0 } +func (x *RoomTurnoverRewardConfig) GetRegionId() int64 { + if x != nil { + return x.RegionId + } + return 0 +} + // RoomTurnoverRewardSettlement 是某个房间单个 UTC 周期的结算事实。 type RoomTurnoverRewardSettlement struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -10221,8 +10230,10 @@ type RoomTurnoverRewardSettlement struct { SettledAtMs int64 `protobuf:"varint,16,opt,name=settled_at_ms,json=settledAtMs,proto3" json:"settled_at_ms,omitempty"` CreatedAtMs int64 `protobuf:"varint,17,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` UpdatedAtMs int64 `protobuf:"varint,18,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // region_id 固化贡献事件发生时的房间可见区域;0 表示全区域可见房间。 + RegionId int64 `protobuf:"varint,19,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RoomTurnoverRewardSettlement) Reset() { @@ -10381,6 +10392,13 @@ func (x *RoomTurnoverRewardSettlement) GetUpdatedAtMs() int64 { return 0 } +func (x *RoomTurnoverRewardSettlement) GetRegionId() int64 { + if x != nil { + return x.RegionId + } + return 0 +} + // RoomTurnoverRewardStatus 给 App H5 返回当前 UTC 周期、房间流水、预计奖励和上一期结算。 type RoomTurnoverRewardStatus struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -10504,6 +10522,7 @@ type GetRoomTurnoverRewardStatusRequest struct { UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` RoomId string `protobuf:"bytes,3,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` OwnerUserId int64 `protobuf:"varint,4,opt,name=owner_user_id,json=ownerUserId,proto3" json:"owner_user_id,omitempty"` + RegionId int64 `protobuf:"varint,5,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -10566,6 +10585,13 @@ func (x *GetRoomTurnoverRewardStatusRequest) GetOwnerUserId() int64 { return 0 } +func (x *GetRoomTurnoverRewardStatusRequest) GetRegionId() int64 { + if x != nil { + return x.RegionId + } + return 0 +} + type GetRoomTurnoverRewardStatusResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Status *RoomTurnoverRewardStatus `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` @@ -10613,6 +10639,7 @@ func (x *GetRoomTurnoverRewardStatusResponse) GetStatus() *RoomTurnoverRewardSta type GetRoomTurnoverRewardConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + RegionId int64 `protobuf:"varint,2,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -10654,6 +10681,13 @@ func (x *GetRoomTurnoverRewardConfigRequest) GetMeta() *RequestMeta { return nil } +func (x *GetRoomTurnoverRewardConfigRequest) GetRegionId() int64 { + if x != nil { + return x.RegionId + } + return 0 +} + type GetRoomTurnoverRewardConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Config *RoomTurnoverRewardConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` @@ -10704,6 +10738,7 @@ type UpdateRoomTurnoverRewardConfigRequest struct { Enabled bool `protobuf:"varint,2,opt,name=enabled,proto3" json:"enabled,omitempty"` Tiers []*RoomTurnoverRewardTier `protobuf:"bytes,3,rep,name=tiers,proto3" json:"tiers,omitempty"` OperatorAdminId int64 `protobuf:"varint,4,opt,name=operator_admin_id,json=operatorAdminId,proto3" json:"operator_admin_id,omitempty"` + RegionId int64 `protobuf:"varint,5,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -10766,6 +10801,13 @@ func (x *UpdateRoomTurnoverRewardConfigRequest) GetOperatorAdminId() int64 { return 0 } +func (x *UpdateRoomTurnoverRewardConfigRequest) GetRegionId() int64 { + if x != nil { + return x.RegionId + } + return 0 +} + type UpdateRoomTurnoverRewardConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Config *RoomTurnoverRewardConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` @@ -10819,6 +10861,7 @@ type ListRoomTurnoverRewardSettlementsRequest struct { PeriodStartMs int64 `protobuf:"varint,5,opt,name=period_start_ms,json=periodStartMs,proto3" json:"period_start_ms,omitempty"` Page int32 `protobuf:"varint,6,opt,name=page,proto3" json:"page,omitempty"` PageSize int32 `protobuf:"varint,7,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + RegionId int64 `protobuf:"varint,8,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -10902,6 +10945,13 @@ func (x *ListRoomTurnoverRewardSettlementsRequest) GetPageSize() int32 { return 0 } +func (x *ListRoomTurnoverRewardSettlementsRequest) GetRegionId() int64 { + if x != nil { + return x.RegionId + } + return 0 +} + type ListRoomTurnoverRewardSettlementsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Settlements []*RoomTurnoverRewardSettlement `protobuf:"bytes,1,rep,name=settlements,proto3" json:"settlements,omitempty"` @@ -20501,8 +20551,10 @@ type AgencyOpeningCycle struct { CreatedAtMs int64 `protobuf:"varint,13,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` UpdatedAtMs int64 `protobuf:"varint,14,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` AppCode string `protobuf:"bytes,15,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // region_id=0 表示全区域默认活动;正数表示只覆盖对应区域。 + RegionId int64 `protobuf:"varint,16,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AgencyOpeningCycle) Reset() { @@ -20640,6 +20692,13 @@ func (x *AgencyOpeningCycle) GetAppCode() string { return "" } +func (x *AgencyOpeningCycle) GetRegionId() int64 { + if x != nil { + return x.RegionId + } + return 0 +} + // AgencyOpeningApplication 是代理提交开业活动申请后的流水和发奖事实。 type AgencyOpeningApplication struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -20665,8 +20724,10 @@ type AgencyOpeningApplication struct { ScoreStartMs int64 `protobuf:"varint,20,opt,name=score_start_ms,json=scoreStartMs,proto3" json:"score_start_ms,omitempty"` ScoreEndMs int64 `protobuf:"varint,21,opt,name=score_end_ms,json=scoreEndMs,proto3" json:"score_end_ms,omitempty"` ReviewedByAdminId int64 `protobuf:"varint,22,opt,name=reviewed_by_admin_id,json=reviewedByAdminId,proto3" json:"reviewed_by_admin_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // region_id 固化申请时代理所属区域,后续区域调整不改变已开始的计分事实。 + RegionId int64 `protobuf:"varint,23,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AgencyOpeningApplication) Reset() { @@ -20853,6 +20914,13 @@ func (x *AgencyOpeningApplication) GetReviewedByAdminId() int64 { return 0 } +func (x *AgencyOpeningApplication) GetRegionId() int64 { + if x != nil { + return x.RegionId + } + return 0 +} + // AgencyOpeningAgencySnapshot 是申请页展示和资格判断使用的代理快照。 type AgencyOpeningAgencySnapshot struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -20862,6 +20930,7 @@ type AgencyOpeningAgencySnapshot struct { Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"` HostCount int32 `protobuf:"varint,5,opt,name=host_count,json=hostCount,proto3" json:"host_count,omitempty"` AgencyCreatedAtMs int64 `protobuf:"varint,6,opt,name=agency_created_at_ms,json=agencyCreatedAtMs,proto3" json:"agency_created_at_ms,omitempty"` + RegionId int64 `protobuf:"varint,7,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -20938,6 +21007,13 @@ func (x *AgencyOpeningAgencySnapshot) GetAgencyCreatedAtMs() int64 { return 0 } +func (x *AgencyOpeningAgencySnapshot) GetRegionId() int64 { + if x != nil { + return x.RegionId + } + return 0 +} + type ListAgencyOpeningCyclesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` @@ -20946,6 +21022,7 @@ type ListAgencyOpeningCyclesRequest struct { EndMs int64 `protobuf:"varint,4,opt,name=end_ms,json=endMs,proto3" json:"end_ms,omitempty"` Page int32 `protobuf:"varint,5,opt,name=page,proto3" json:"page,omitempty"` PageSize int32 `protobuf:"varint,6,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + RegionId int64 `protobuf:"varint,7,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -21022,6 +21099,13 @@ func (x *ListAgencyOpeningCyclesRequest) GetPageSize() int32 { return 0 } +func (x *ListAgencyOpeningCyclesRequest) GetRegionId() int64 { + if x != nil { + return x.RegionId + } + return 0 +} + type ListAgencyOpeningCyclesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Cycles []*AgencyOpeningCycle `protobuf:"bytes,1,rep,name=cycles,proto3" json:"cycles,omitempty"` @@ -21403,6 +21487,7 @@ type ListAgencyOpeningApplicationsRequest struct { AgencyOwnerUserId int64 `protobuf:"varint,5,opt,name=agency_owner_user_id,json=agencyOwnerUserId,proto3" json:"agency_owner_user_id,omitempty"` Page int32 `protobuf:"varint,6,opt,name=page,proto3" json:"page,omitempty"` PageSize int32 `protobuf:"varint,7,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + RegionId int64 `protobuf:"varint,8,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -21486,6 +21571,13 @@ func (x *ListAgencyOpeningApplicationsRequest) GetPageSize() int32 { return 0 } +func (x *ListAgencyOpeningApplicationsRequest) GetRegionId() int64 { + if x != nil { + return x.RegionId + } + return 0 +} + type ListAgencyOpeningApplicationsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Applications []*AgencyOpeningApplication `protobuf:"bytes,1,rep,name=applications,proto3" json:"applications,omitempty"` @@ -27454,14 +27546,15 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "\n" + "sort_order\x18\a \x01(\x05R\tsortOrder\x12\"\n" + "\rcreated_at_ms\x18\b \x01(\x03R\vcreatedAtMs\x12\"\n" + - "\rupdated_at_ms\x18\t \x01(\x03R\vupdatedAtMs\"\x87\x02\n" + + "\rupdated_at_ms\x18\t \x01(\x03R\vupdatedAtMs\"\xa4\x02\n" + "\x18RoomTurnoverRewardConfig\x12\x19\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x18\n" + "\aenabled\x18\x02 \x01(\bR\aenabled\x12?\n" + "\x05tiers\x18\x03 \x03(\v2).hyapp.activity.v1.RoomTurnoverRewardTierR\x05tiers\x12-\n" + "\x13updated_by_admin_id\x18\x04 \x01(\x03R\x10updatedByAdminId\x12\"\n" + "\rcreated_at_ms\x18\x05 \x01(\x03R\vcreatedAtMs\x12\"\n" + - "\rupdated_at_ms\x18\x06 \x01(\x03R\vupdatedAtMs\"\xa7\x05\n" + + "\rupdated_at_ms\x18\x06 \x01(\x03R\vupdatedAtMs\x12\x1b\n" + + "\tregion_id\x18\a \x01(\x03R\bregionId\"\xc4\x05\n" + "\x1cRoomTurnoverRewardSettlement\x12#\n" + "\rsettlement_id\x18\x01 \x01(\tR\fsettlementId\x12\x19\n" + "\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x17\n" + @@ -27482,7 +27575,8 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "\x0efailure_reason\x18\x0f \x01(\tR\rfailureReason\x12\"\n" + "\rsettled_at_ms\x18\x10 \x01(\x03R\vsettledAtMs\x12\"\n" + "\rcreated_at_ms\x18\x11 \x01(\x03R\vcreatedAtMs\x12\"\n" + - "\rupdated_at_ms\x18\x12 \x01(\x03R\vupdatedAtMs\"\xa7\x04\n" + + "\rupdated_at_ms\x18\x12 \x01(\x03R\vupdatedAtMs\x12\x1b\n" + + "\tregion_id\x18\x13 \x01(\x03R\bregionId\"\xa7\x04\n" + "\x18RoomTurnoverRewardStatus\x12C\n" + "\x06config\x18\x01 \x01(\v2+.hyapp.activity.v1.RoomTurnoverRewardConfigR\x06config\x12\x17\n" + "\aroom_id\x18\x02 \x01(\tR\x06roomId\x12\"\n" + @@ -27494,25 +27588,28 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "\fmatched_tier\x18\b \x01(\v2).hyapp.activity.v1.RoomTurnoverRewardTierR\vmatchedTier\x12\\\n" + "\x11latest_settlement\x18\t \x01(\v2/.hyapp.activity.v1.RoomTurnoverRewardSettlementR\x10latestSettlement\x12$\n" + "\x0eserver_time_ms\x18\n" + - " \x01(\x03R\fserverTimeMs\"\xae\x01\n" + + " \x01(\x03R\fserverTimeMs\"\xcb\x01\n" + "\"GetRoomTurnoverRewardStatusRequest\x122\n" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\n" + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" + "\aroom_id\x18\x03 \x01(\tR\x06roomId\x12\"\n" + - "\rowner_user_id\x18\x04 \x01(\x03R\vownerUserId\"j\n" + + "\rowner_user_id\x18\x04 \x01(\x03R\vownerUserId\x12\x1b\n" + + "\tregion_id\x18\x05 \x01(\x03R\bregionId\"j\n" + "#GetRoomTurnoverRewardStatusResponse\x12C\n" + - "\x06status\x18\x01 \x01(\v2+.hyapp.activity.v1.RoomTurnoverRewardStatusR\x06status\"X\n" + + "\x06status\x18\x01 \x01(\v2+.hyapp.activity.v1.RoomTurnoverRewardStatusR\x06status\"u\n" + "\"GetRoomTurnoverRewardConfigRequest\x122\n" + - "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\"j\n" + + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x1b\n" + + "\tregion_id\x18\x02 \x01(\x03R\bregionId\"j\n" + "#GetRoomTurnoverRewardConfigResponse\x12C\n" + - "\x06config\x18\x01 \x01(\v2+.hyapp.activity.v1.RoomTurnoverRewardConfigR\x06config\"\xe2\x01\n" + + "\x06config\x18\x01 \x01(\v2+.hyapp.activity.v1.RoomTurnoverRewardConfigR\x06config\"\xff\x01\n" + "%UpdateRoomTurnoverRewardConfigRequest\x122\n" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x18\n" + "\aenabled\x18\x02 \x01(\bR\aenabled\x12?\n" + "\x05tiers\x18\x03 \x03(\v2).hyapp.activity.v1.RoomTurnoverRewardTierR\x05tiers\x12*\n" + - "\x11operator_admin_id\x18\x04 \x01(\x03R\x0foperatorAdminId\"m\n" + + "\x11operator_admin_id\x18\x04 \x01(\x03R\x0foperatorAdminId\x12\x1b\n" + + "\tregion_id\x18\x05 \x01(\x03R\bregionId\"m\n" + "&UpdateRoomTurnoverRewardConfigResponse\x12C\n" + - "\x06config\x18\x01 \x01(\v2+.hyapp.activity.v1.RoomTurnoverRewardConfigR\x06config\"\x8c\x02\n" + + "\x06config\x18\x01 \x01(\v2+.hyapp.activity.v1.RoomTurnoverRewardConfigR\x06config\"\xa9\x02\n" + "(ListRoomTurnoverRewardSettlementsRequest\x122\n" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x16\n" + "\x06status\x18\x02 \x01(\tR\x06status\x12\x17\n" + @@ -27520,7 +27617,8 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "\rowner_user_id\x18\x04 \x01(\x03R\vownerUserId\x12&\n" + "\x0fperiod_start_ms\x18\x05 \x01(\x03R\rperiodStartMs\x12\x12\n" + "\x04page\x18\x06 \x01(\x05R\x04page\x12\x1b\n" + - "\tpage_size\x18\a \x01(\x05R\bpageSize\"\x94\x01\n" + + "\tpage_size\x18\a \x01(\x05R\bpageSize\x12\x1b\n" + + "\tregion_id\x18\b \x01(\x03R\bregionId\"\x94\x01\n" + ")ListRoomTurnoverRewardSettlementsResponse\x12Q\n" + "\vsettlements\x18\x01 \x03(\v2/.hyapp.activity.v1.RoomTurnoverRewardSettlementR\vsettlements\x12\x14\n" + "\x05total\x18\x02 \x01(\x03R\x05total\"\xaf\x01\n" + @@ -28355,7 +28453,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "\x13AgencyOpeningReward\x12\x17\n" + "\arank_no\x18\x01 \x01(\x05R\x06rankNo\x12,\n" + "\x12reward_coin_amount\x18\x02 \x01(\x03R\x10rewardCoinAmount\x120\n" + - "\x14threshold_coin_spent\x18\x03 \x01(\x03R\x12thresholdCoinSpent\"\xb0\x04\n" + + "\x14threshold_coin_spent\x18\x03 \x01(\x03R\x12thresholdCoinSpent\"\xcd\x04\n" + "\x12AgencyOpeningCycle\x12\x19\n" + "\bcycle_id\x18\x01 \x01(\tR\acycleId\x12#\n" + "\ractivity_code\x18\x02 \x01(\tR\factivityCode\x12\x14\n" + @@ -28372,7 +28470,8 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "\rsettled_at_ms\x18\f \x01(\x03R\vsettledAtMs\x12\"\n" + "\rcreated_at_ms\x18\r \x01(\x03R\vcreatedAtMs\x12\"\n" + "\rupdated_at_ms\x18\x0e \x01(\x03R\vupdatedAtMs\x12\x19\n" + - "\bapp_code\x18\x0f \x01(\tR\aappCode\"\x84\a\n" + + "\bapp_code\x18\x0f \x01(\tR\aappCode\x12\x1b\n" + + "\tregion_id\x18\x10 \x01(\x03R\bregionId\"\xa1\a\n" + "\x18AgencyOpeningApplication\x12%\n" + "\x0eapplication_id\x18\x01 \x01(\tR\rapplicationId\x12\x19\n" + "\bcycle_id\x18\x02 \x01(\tR\acycleId\x12\x1b\n" + @@ -28399,7 +28498,8 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "\x0escore_start_ms\x18\x14 \x01(\x03R\fscoreStartMs\x12 \n" + "\fscore_end_ms\x18\x15 \x01(\x03R\n" + "scoreEndMs\x12/\n" + - "\x14reviewed_by_admin_id\x18\x16 \x01(\x03R\x11reviewedByAdminId\"\xda\x01\n" + + "\x14reviewed_by_admin_id\x18\x16 \x01(\x03R\x11reviewedByAdminId\x12\x1b\n" + + "\tregion_id\x18\x17 \x01(\x03R\bregionId\"\xf7\x01\n" + "\x1bAgencyOpeningAgencySnapshot\x12\x1b\n" + "\tagency_id\x18\x01 \x01(\x03R\bagencyId\x12\"\n" + "\rowner_user_id\x18\x02 \x01(\x03R\vownerUserId\x12\x12\n" + @@ -28407,14 +28507,16 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "\x06status\x18\x04 \x01(\tR\x06status\x12\x1d\n" + "\n" + "host_count\x18\x05 \x01(\x05R\thostCount\x12/\n" + - "\x14agency_created_at_ms\x18\x06 \x01(\x03R\x11agencyCreatedAtMs\"\xcf\x01\n" + + "\x14agency_created_at_ms\x18\x06 \x01(\x03R\x11agencyCreatedAtMs\x12\x1b\n" + + "\tregion_id\x18\a \x01(\x03R\bregionId\"\xec\x01\n" + "\x1eListAgencyOpeningCyclesRequest\x122\n" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x16\n" + "\x06status\x18\x02 \x01(\tR\x06status\x12\x19\n" + "\bstart_ms\x18\x03 \x01(\x03R\astartMs\x12\x15\n" + "\x06end_ms\x18\x04 \x01(\x03R\x05endMs\x12\x12\n" + "\x04page\x18\x05 \x01(\x05R\x04page\x12\x1b\n" + - "\tpage_size\x18\x06 \x01(\x05R\bpageSize\"v\n" + + "\tpage_size\x18\x06 \x01(\x05R\bpageSize\x12\x1b\n" + + "\tregion_id\x18\a \x01(\x03R\bregionId\"v\n" + "\x1fListAgencyOpeningCyclesResponse\x12=\n" + "\x06cycles\x18\x01 \x03(\v2%.hyapp.activity.v1.AgencyOpeningCycleR\x06cycles\x12\x14\n" + "\x05total\x18\x02 \x01(\x03R\x05total\"m\n" + @@ -28436,7 +28538,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "\x06status\x18\x03 \x01(\tR\x06status\x12*\n" + "\x11operator_admin_id\x18\x04 \x01(\x03R\x0foperatorAdminId\"b\n" + "#SetAgencyOpeningCycleStatusResponse\x12;\n" + - "\x05cycle\x18\x01 \x01(\v2%.hyapp.activity.v1.AgencyOpeningCycleR\x05cycle\"\x8c\x02\n" + + "\x05cycle\x18\x01 \x01(\v2%.hyapp.activity.v1.AgencyOpeningCycleR\x05cycle\"\xa9\x02\n" + "$ListAgencyOpeningApplicationsRequest\x122\n" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x19\n" + "\bcycle_id\x18\x02 \x01(\tR\acycleId\x12\x16\n" + @@ -28444,7 +28546,8 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "\tagency_id\x18\x04 \x01(\x03R\bagencyId\x12/\n" + "\x14agency_owner_user_id\x18\x05 \x01(\x03R\x11agencyOwnerUserId\x12\x12\n" + "\x04page\x18\x06 \x01(\x05R\x04page\x12\x1b\n" + - "\tpage_size\x18\a \x01(\x05R\bpageSize\"\x8e\x01\n" + + "\tpage_size\x18\a \x01(\x05R\bpageSize\x12\x1b\n" + + "\tregion_id\x18\b \x01(\x03R\bregionId\"\x8e\x01\n" + "%ListAgencyOpeningApplicationsResponse\x12O\n" + "\fapplications\x18\x01 \x03(\v2+.hyapp.activity.v1.AgencyOpeningApplicationR\fapplications\x12\x14\n" + "\x05total\x18\x02 \x01(\x03R\x05total\"\xc6\x01\n" + diff --git a/api/proto/activity/v1/activity.proto b/api/proto/activity/v1/activity.proto index 9d2ada38..20df3a4d 100644 --- a/api/proto/activity/v1/activity.proto +++ b/api/proto/activity/v1/activity.proto @@ -1148,6 +1148,8 @@ message RoomTurnoverRewardConfig { int64 updated_by_admin_id = 4; int64 created_at_ms = 5; int64 updated_at_ms = 6; + // region_id=0 表示全区域默认配置;正数表示只覆盖对应区域。 + int64 region_id = 7; } // RoomTurnoverRewardSettlement 是某个房间单个 UTC 周期的结算事实。 @@ -1170,6 +1172,8 @@ message RoomTurnoverRewardSettlement { int64 settled_at_ms = 16; int64 created_at_ms = 17; int64 updated_at_ms = 18; + // region_id 固化贡献事件发生时的房间可见区域;0 表示全区域可见房间。 + int64 region_id = 19; } // RoomTurnoverRewardStatus 给 App H5 返回当前 UTC 周期、房间流水、预计奖励和上一期结算。 @@ -1191,6 +1195,7 @@ message GetRoomTurnoverRewardStatusRequest { int64 user_id = 2; string room_id = 3; int64 owner_user_id = 4; + int64 region_id = 5; } message GetRoomTurnoverRewardStatusResponse { @@ -1199,6 +1204,7 @@ message GetRoomTurnoverRewardStatusResponse { message GetRoomTurnoverRewardConfigRequest { RequestMeta meta = 1; + int64 region_id = 2; } message GetRoomTurnoverRewardConfigResponse { @@ -1210,6 +1216,7 @@ message UpdateRoomTurnoverRewardConfigRequest { bool enabled = 2; repeated RoomTurnoverRewardTier tiers = 3; int64 operator_admin_id = 4; + int64 region_id = 5; } message UpdateRoomTurnoverRewardConfigResponse { @@ -1224,6 +1231,7 @@ message ListRoomTurnoverRewardSettlementsRequest { int64 period_start_ms = 5; int32 page = 6; int32 page_size = 7; + int64 region_id = 8; } message ListRoomTurnoverRewardSettlementsResponse { @@ -2280,6 +2288,8 @@ message AgencyOpeningCycle { int64 created_at_ms = 13; int64 updated_at_ms = 14; string app_code = 15; + // region_id=0 表示全区域默认活动;正数表示只覆盖对应区域。 + int64 region_id = 16; } // AgencyOpeningApplication 是代理提交开业活动申请后的流水和发奖事实。 @@ -2306,6 +2316,8 @@ message AgencyOpeningApplication { int64 score_start_ms = 20; int64 score_end_ms = 21; int64 reviewed_by_admin_id = 22; + // region_id 固化申请时代理所属区域,后续区域调整不改变已开始的计分事实。 + int64 region_id = 23; } // AgencyOpeningAgencySnapshot 是申请页展示和资格判断使用的代理快照。 @@ -2316,6 +2328,7 @@ message AgencyOpeningAgencySnapshot { string status = 4; int32 host_count = 5; int64 agency_created_at_ms = 6; + int64 region_id = 7; } message ListAgencyOpeningCyclesRequest { @@ -2325,6 +2338,7 @@ message ListAgencyOpeningCyclesRequest { int64 end_ms = 4; int32 page = 5; int32 page_size = 6; + int64 region_id = 7; } message ListAgencyOpeningCyclesResponse { @@ -2371,6 +2385,7 @@ message ListAgencyOpeningApplicationsRequest { int64 agency_owner_user_id = 5; int32 page = 6; int32 page_size = 7; + int64 region_id = 8; } message ListAgencyOpeningApplicationsResponse { diff --git a/api/proto/events/room/v1/events.pb.go b/api/proto/events/room/v1/events.pb.go index 88a0a6e3..2c33a499 100644 --- a/api/proto/events/room/v1/events.pb.go +++ b/api/proto/events/room/v1/events.pb.go @@ -1723,8 +1723,10 @@ type RoomGiftSent struct { RoomLocked bool `protobuf:"varint,32,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"` // sender_vip_level 是送礼入口固化的发送者 effective VIP 等级;0 表示当前无有效 VIP 或展示查询已降级。 SenderVipLevel int32 `protobuf:"varint,33,opt,name=sender_vip_level,json=senderVipLevel,proto3" json:"sender_vip_level,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // sender_gift_broadcast_animation_url 是送礼入口从有效 gift_tray_skin 权益固化的动效 URL;下游禁止异步回查当前 VIP 覆盖历史事实。 + SenderGiftBroadcastAnimationUrl string `protobuf:"bytes,34,opt,name=sender_gift_broadcast_animation_url,json=senderGiftBroadcastAnimationUrl,proto3" json:"sender_gift_broadcast_animation_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RoomGiftSent) Reset() { @@ -1988,6 +1990,13 @@ func (x *RoomGiftSent) GetSenderVipLevel() int32 { return 0 } +func (x *RoomGiftSent) GetSenderGiftBroadcastAnimationUrl() string { + if x != nil { + return x.SenderGiftBroadcastAnimationUrl + } + return "" +} + // RoomGiftBatchLuckyResult 是批量送礼展示事件内的逐目标幸运礼物结果快照。 // 它只进入房间 IM 展示,不作为 activity 中奖事实,也不携带送礼人余额。 type RoomGiftBatchLuckyResult struct { @@ -2308,8 +2317,10 @@ type RoomGiftBatchSent struct { VisibleRegionId int64 `protobuf:"varint,22,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"` // sender_vip_level 与同命令的逐目标 RoomGiftSent 共用一份发送者展示快照。 SenderVipLevel int32 `protobuf:"varint,23,opt,name=sender_vip_level,json=senderVipLevel,proto3" json:"sender_vip_level,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // sender_gift_broadcast_animation_url 与同命令的逐目标事实共用,批量送礼不能因展示协议合并而丢失 VIP 托盘。 + SenderGiftBroadcastAnimationUrl string `protobuf:"bytes,24,opt,name=sender_gift_broadcast_animation_url,json=senderGiftBroadcastAnimationUrl,proto3" json:"sender_gift_broadcast_animation_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RoomGiftBatchSent) Reset() { @@ -2503,6 +2514,13 @@ func (x *RoomGiftBatchSent) GetSenderVipLevel() int32 { return 0 } +func (x *RoomGiftBatchSent) GetSenderGiftBroadcastAnimationUrl() string { + if x != nil { + return x.SenderGiftBroadcastAnimationUrl + } + return "" +} + // RoomLuckyGiftDrawn 是 room-service 从 lucky-gift-service owner 事实派生的真人中奖展示事件。 // 它只承载腾讯云 IM 房间表现,不是 Room Cell 核心状态,不能推进 room_version、榜单或房间快照。 type RoomLuckyGiftDrawn struct { @@ -3765,8 +3783,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\"\xbc\n" + - "\n" + + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\x8a\v\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" + @@ -3808,7 +3825,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" + "\fdisplay_mode\x18\x1f \x01(\tR\vdisplayMode\x12\x1f\n" + "\vroom_locked\x18 \x01(\bR\n" + "roomLocked\x12(\n" + - "\x10sender_vip_level\x18! \x01(\x05R\x0esenderVipLevel\"\xdc\x04\n" + + "\x10sender_vip_level\x18! \x01(\x05R\x0esenderVipLevel\x12L\n" + + "#sender_gift_broadcast_animation_url\x18\" \x01(\tR\x1fsenderGiftBroadcastAnimationUrl\"\xdc\x04\n" + "\x18RoomGiftBatchLuckyResult\x12\x18\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x12\x17\n" + "\adraw_id\x18\x02 \x01(\tR\x06drawId\x12\x1d\n" + @@ -3844,7 +3862,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" + "command_id\x18\n" + " \x01(\tR\tcommandId\x12M\n" + "\n" + - "lucky_gift\x18\v \x01(\v2..hyapp.events.room.v1.RoomGiftBatchLuckyResultR\tluckyGift\"\xb3\a\n" + + "lucky_gift\x18\v \x01(\v2..hyapp.events.room.v1.RoomGiftBatchLuckyResultR\tluckyGift\"\x81\b\n" + "\x11RoomGiftBatchSent\x12$\n" + "\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12\x1f\n" + "\vsender_name\x18\x02 \x01(\tR\n" + @@ -3872,7 +3890,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" + "\n" + "command_id\x18\x15 \x01(\tR\tcommandId\x12*\n" + "\x11visible_region_id\x18\x16 \x01(\x03R\x0fvisibleRegionId\x12(\n" + - "\x10sender_vip_level\x18\x17 \x01(\x05R\x0esenderVipLevel\"\xb7\b\n" + + "\x10sender_vip_level\x18\x17 \x01(\x05R\x0esenderVipLevel\x12L\n" + + "#sender_gift_broadcast_animation_url\x18\x18 \x01(\tR\x1fsenderGiftBroadcastAnimationUrl\"\xb7\b\n" + "\x12RoomLuckyGiftDrawn\x12\x17\n" + "\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" + "\n" + diff --git a/api/proto/events/room/v1/events.proto b/api/proto/events/room/v1/events.proto index ceabd352..eeb21fb9 100644 --- a/api/proto/events/room/v1/events.proto +++ b/api/proto/events/room/v1/events.proto @@ -252,6 +252,8 @@ message RoomGiftSent { bool room_locked = 32; // sender_vip_level 是送礼入口固化的发送者 effective VIP 等级;0 表示当前无有效 VIP 或展示查询已降级。 int32 sender_vip_level = 33; + // sender_gift_broadcast_animation_url 是送礼入口从有效 gift_tray_skin 权益固化的动效 URL;下游禁止异步回查当前 VIP 覆盖历史事实。 + string sender_gift_broadcast_animation_url = 34; } // RoomGiftBatchLuckyResult 是批量送礼展示事件内的逐目标幸运礼物结果快照。 @@ -318,6 +320,8 @@ message RoomGiftBatchSent { int64 visible_region_id = 22; // sender_vip_level 与同命令的逐目标 RoomGiftSent 共用一份发送者展示快照。 int32 sender_vip_level = 23; + // sender_gift_broadcast_animation_url 与同命令的逐目标事实共用,批量送礼不能因展示协议合并而丢失 VIP 托盘。 + string sender_gift_broadcast_animation_url = 24; } // RoomLuckyGiftDrawn 是 room-service 从 lucky-gift-service owner 事实派生的真人中奖展示事件。 diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index d4ef6253..e0d7d518 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -10006,8 +10006,10 @@ type SendGiftDisplayProfile struct { PrettyDisplayUserId string `protobuf:"bytes,5,opt,name=pretty_display_user_id,json=prettyDisplayUserId,proto3" json:"pretty_display_user_id,omitempty"` // effective_vip_level 是送礼入口查询到的当前生效 VIP 等级;0 表示当前无有效 VIP 或展示查询已降级。 EffectiveVipLevel int32 `protobuf:"varint,6,opt,name=effective_vip_level,json=effectiveVipLevel,proto3" json:"effective_vip_level,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // gift_broadcast_animation_url 是 wallet 已校验的有效 gift_tray_skin 动效快照;空值表示无可渲染托盘或查询降级。 + GiftBroadcastAnimationUrl string `protobuf:"bytes,7,opt,name=gift_broadcast_animation_url,json=giftBroadcastAnimationUrl,proto3" json:"gift_broadcast_animation_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SendGiftDisplayProfile) Reset() { @@ -10082,6 +10084,13 @@ func (x *SendGiftDisplayProfile) GetEffectiveVipLevel() int32 { return 0 } +func (x *SendGiftDisplayProfile) GetGiftBroadcastAnimationUrl() string { + if x != nil { + return x.GiftBroadcastAnimationUrl + } + return "" +} + // SendGiftBatchTarget 是批量送礼展示响应中单个目标的结算和展示快照。 // 它只服务发送方本地即时展示,不替代逐目标 RoomGiftSent 业务事实。 type SendGiftBatchTarget struct { @@ -10236,8 +10245,10 @@ type SendGiftBatchDisplay struct { CommandId string `protobuf:"bytes,22,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` // sender_vip_level 与 RoomGiftBatchSent IM 使用同一份入站快照,避免发送方本地展示和房间广播不一致。 SenderVipLevel int32 `protobuf:"varint,23,opt,name=sender_vip_level,json=senderVipLevel,proto3" json:"sender_vip_level,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // sender_gift_broadcast_animation_url 与逐目标 RoomGiftSent 共用送礼入口快照,普通/幸运/超级幸运礼物保持同一托盘资源。 + SenderGiftBroadcastAnimationUrl string `protobuf:"bytes,24,opt,name=sender_gift_broadcast_animation_url,json=senderGiftBroadcastAnimationUrl,proto3" json:"sender_gift_broadcast_animation_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SendGiftBatchDisplay) Reset() { @@ -10431,6 +10442,13 @@ func (x *SendGiftBatchDisplay) GetSenderVipLevel() int32 { return 0 } +func (x *SendGiftBatchDisplay) GetSenderGiftBroadcastAnimationUrl() string { + if x != nil { + return x.SenderGiftBroadcastAnimationUrl + } + return "" +} + // SendGiftRequest 是首版房间内最重要的变现命令。 type SendGiftRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -11633,8 +11651,14 @@ type RoomListItem struct { OwnerVipLevel int32 `protobuf:"varint,19,opt,name=owner_vip_level,json=ownerVipLevel,proto3" json:"owner_vip_level,omitempty"` // room_border_url 直接从同一列表投影中的 room_border.asset_url 派生,不增加列表查询或 wallet 回查。 RoomBorderUrl string `protobuf:"bytes,20,opt,name=room_border_url,json=roomBorderUrl,proto3" json:"room_border_url,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // rocket_near_completion 来自 Room Cell 火箭快照;仅 charging 且剩余燃料不超过当前档阈值 20% 时为 true。 + RocketNearCompletion bool `protobuf:"varint,21,opt,name=rocket_near_completion,json=rocketNearCompletion,proto3" json:"rocket_near_completion,omitempty"` + // has_active_red_packet 是 wallet 红包事实经 MQ 异步投影的房间聚合,不触发列表同步 RPC 或逐房查询。 + HasActiveRedPacket bool `protobuf:"varint,22,opt,name=has_active_red_packet,json=hasActiveRedPacket,proto3" json:"has_active_red_packet,omitempty"` + // active_red_packet_expires_at_ms 是当前仍有余量红包的最晚到期时间;客户端缓存可据此在边界后立即隐藏图标。 + ActiveRedPacketExpiresAtMs int64 `protobuf:"varint,23,opt,name=active_red_packet_expires_at_ms,json=activeRedPacketExpiresAtMs,proto3" json:"active_red_packet_expires_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RoomListItem) Reset() { @@ -11800,6 +11824,27 @@ func (x *RoomListItem) GetRoomBorderUrl() string { return "" } +func (x *RoomListItem) GetRocketNearCompletion() bool { + if x != nil { + return x.RocketNearCompletion + } + return false +} + +func (x *RoomListItem) GetHasActiveRedPacket() bool { + if x != nil { + return x.HasActiveRedPacket + } + return false +} + +func (x *RoomListItem) GetActiveRedPacketExpiresAtMs() int64 { + if x != nil { + return x.ActiveRedPacketExpiresAtMs + } + return 0 +} + // ListRoomsResponse 返回一页房间卡片和下一页不透明 cursor。 type ListRoomsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -14980,14 +15025,15 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\x0etarget_user_id\x18\x01 \x01(\x03R\ftargetUserId\x12$\n" + "\x0etarget_is_host\x18\x02 \x01(\bR\ftargetIsHost\x121\n" + "\x15target_host_region_id\x18\x03 \x01(\x03R\x12targetHostRegionId\x12<\n" + - "\x1btarget_agency_owner_user_id\x18\x04 \x01(\x03R\x17targetAgencyOwnerUserId\"\xf2\x01\n" + + "\x1btarget_agency_owner_user_id\x18\x04 \x01(\x03R\x17targetAgencyOwnerUserId\"\xb3\x02\n" + "\x16SendGiftDisplayProfile\x12\x17\n" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x1a\n" + "\busername\x18\x02 \x01(\tR\busername\x12\x16\n" + "\x06avatar\x18\x03 \x01(\tR\x06avatar\x12&\n" + "\x0fdisplay_user_id\x18\x04 \x01(\tR\rdisplayUserId\x123\n" + "\x16pretty_display_user_id\x18\x05 \x01(\tR\x13prettyDisplayUserId\x12.\n" + - "\x13effective_vip_level\x18\x06 \x01(\x05R\x11effectiveVipLevel\"\x8a\x04\n" + + "\x13effective_vip_level\x18\x06 \x01(\x05R\x11effectiveVipLevel\x12?\n" + + "\x1cgift_broadcast_animation_url\x18\a \x01(\tR\x19giftBroadcastAnimationUrl\"\x8a\x04\n" + "\x13SendGiftBatchTarget\x12$\n" + "\x0etarget_user_id\x18\x01 \x01(\x03R\ftargetUserId\x12+\n" + "\x11receiver_nickname\x18\x02 \x01(\tR\x10receiverNickname\x12'\n" + @@ -15004,7 +15050,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "command_id\x18\n" + " \x01(\tR\tcommandId\x12A\n" + "\n" + - "lucky_gift\x18\v \x01(\v2\".hyapp.room.v1.LuckyGiftDrawResultR\tluckyGift\"\x9e\a\n" + + "lucky_gift\x18\v \x01(\v2\".hyapp.room.v1.LuckyGiftDrawResultR\tluckyGift\"\xec\a\n" + "\x14SendGiftBatchDisplay\x12\x19\n" + "\bevent_id\x18\x01 \x01(\tR\aeventId\x12$\n" + "\x0esender_user_id\x18\x02 \x01(\x03R\fsenderUserId\x12\x1f\n" + @@ -15032,7 +15078,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\atargets\x18\x15 \x03(\v2\".hyapp.room.v1.SendGiftBatchTargetR\atargets\x12\x1d\n" + "\n" + "command_id\x18\x16 \x01(\tR\tcommandId\x12(\n" + - "\x10sender_vip_level\x18\x17 \x01(\x05R\x0esenderVipLevel\"\xe0\a\n" + + "\x10sender_vip_level\x18\x17 \x01(\x05R\x0esenderVipLevel\x12L\n" + + "#sender_gift_broadcast_animation_url\x18\x18 \x01(\tR\x1fsenderGiftBroadcastAnimationUrl\"\xe0\a\n" + "\x0fSendGiftRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" + @@ -15138,7 +15185,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\tregion_id\x18\x05 \x01(\x03R\bregionId\"c\n" + "\x13RoomFeedRelatedUser\x12\x17\n" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x123\n" + - "\x16relation_updated_at_ms\x18\x02 \x01(\x03R\x13relationUpdatedAtMs\"\xbd\x05\n" + + "\x16relation_updated_at_ms\x18\x02 \x01(\x03R\x13relationUpdatedAtMs\"\xeb\x06\n" + "\fRoomListItem\x12\x17\n" + "\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\"\n" + "\rowner_user_id\x18\x02 \x01(\x03R\vownerUserId\x12\x14\n" + @@ -15161,7 +15208,10 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "roomBorder\x12M\n" + "\x0froom_name_style\x18\x12 \x01(\v2%.hyapp.room.v1.RoomDecorationResourceR\rroomNameStyle\x12&\n" + "\x0fowner_vip_level\x18\x13 \x01(\x05R\rownerVipLevel\x12&\n" + - "\x0froom_border_url\x18\x14 \x01(\tR\rroomBorderUrl\"g\n" + + "\x0froom_border_url\x18\x14 \x01(\tR\rroomBorderUrl\x124\n" + + "\x16rocket_near_completion\x18\x15 \x01(\bR\x14rocketNearCompletion\x121\n" + + "\x15has_active_red_packet\x18\x16 \x01(\bR\x12hasActiveRedPacket\x12C\n" + + "\x1factive_red_packet_expires_at_ms\x18\x17 \x01(\x03R\x1aactiveRedPacketExpiresAtMs\"g\n" + "\x11ListRoomsResponse\x121\n" + "\x05rooms\x18\x01 \x03(\v2\x1b.hyapp.room.v1.RoomListItemR\x05rooms\x12\x1f\n" + "\vnext_cursor\x18\x02 \x01(\tR\n" + diff --git a/api/proto/room/v1/room.proto b/api/proto/room/v1/room.proto index 640ad4b3..98d1184f 100644 --- a/api/proto/room/v1/room.proto +++ b/api/proto/room/v1/room.proto @@ -1205,6 +1205,8 @@ message SendGiftDisplayProfile { string pretty_display_user_id = 5; // effective_vip_level 是送礼入口查询到的当前生效 VIP 等级;0 表示当前无有效 VIP 或展示查询已降级。 int32 effective_vip_level = 6; + // gift_broadcast_animation_url 是 wallet 已校验的有效 gift_tray_skin 动效快照;空值表示无可渲染托盘或查询降级。 + string gift_broadcast_animation_url = 7; } // SendGiftBatchTarget 是批量送礼展示响应中单个目标的结算和展示快照。 @@ -1250,6 +1252,8 @@ message SendGiftBatchDisplay { string command_id = 22; // sender_vip_level 与 RoomGiftBatchSent IM 使用同一份入站快照,避免发送方本地展示和房间广播不一致。 int32 sender_vip_level = 23; + // sender_gift_broadcast_animation_url 与逐目标 RoomGiftSent 共用送礼入口快照,普通/幸运/超级幸运礼物保持同一托盘资源。 + string sender_gift_broadcast_animation_url = 24; } // SendGiftRequest 是首版房间内最重要的变现命令。 @@ -1443,6 +1447,12 @@ message RoomListItem { int32 owner_vip_level = 19; // room_border_url 直接从同一列表投影中的 room_border.asset_url 派生,不增加列表查询或 wallet 回查。 string room_border_url = 20; + // rocket_near_completion 来自 Room Cell 火箭快照;仅 charging 且剩余燃料不超过当前档阈值 20% 时为 true。 + bool rocket_near_completion = 21; + // has_active_red_packet 是 wallet 红包事实经 MQ 异步投影的房间聚合,不触发列表同步 RPC 或逐房查询。 + bool has_active_red_packet = 22; + // active_red_packet_expires_at_ms 是当前仍有余量红包的最晚到期时间;客户端缓存可据此在边界后立即隐藏图标。 + int64 active_red_packet_expires_at_ms = 23; } // ListRoomsResponse 返回一页房间卡片和下一页不透明 cursor。 diff --git a/scripts/apply-local-mysql-initdb.sh b/scripts/apply-local-mysql-initdb.sh index 989d6f5f..47c80d82 100755 --- a/scripts/apply-local-mysql-initdb.sh +++ b/scripts/apply-local-mysql-initdb.sh @@ -40,6 +40,7 @@ INCREMENTAL_MIGRATION_FILES=( "services/user-service/deploy/mysql/migrations/020_user_avatar_upload_registrations.sql" "services/room-service/deploy/mysql/migrations/003_room_vip_media_and_decorations.sql" "services/room-service/deploy/mysql/migrations/004_room_list_owner_vip_level.sql" + "services/room-service/deploy/mysql/migrations/005_room_list_activity_indicators.sql" "services/activity-service/deploy/mysql/migrations/013_game_win_broadcast_config.sql" "services/wallet-service/deploy/mysql/migrations/002_resource_shop_price_tiers.sql" "services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.sql" diff --git a/server/admin/internal/modules/agencyopening/handler.go b/server/admin/internal/modules/agencyopening/handler.go index dec539b1..3b6b6174 100644 --- a/server/admin/internal/modules/agencyopening/handler.go +++ b/server/admin/internal/modules/agencyopening/handler.go @@ -26,6 +26,7 @@ func New(activity activityclient.Client, audit shared.OperationLogger) *Handler type cycleRequest struct { CycleID string `json:"cycle_id"` + RegionID int64 `json:"region_id"` Title string `json:"title"` Status string `json:"status"` StartMS int64 `json:"start_ms"` @@ -42,6 +43,7 @@ type statusRequest struct { type cycleDTO struct { AppCode string `json:"app_code,omitempty"` CycleID string `json:"cycle_id"` + RegionID int64 `json:"region_id"` ActivityCode string `json:"activity_code"` Title string `json:"title"` Status string `json:"status"` @@ -67,6 +69,7 @@ type rewardDTO struct { type applicationDTO struct { ApplicationID string `json:"application_id"` CycleID string `json:"cycle_id"` + RegionID int64 `json:"region_id"` AgencyID int64 `json:"agency_id,string"` AgencyOwnerUserID int64 `json:"agency_owner_user_id,string"` AgencyName string `json:"agency_name"` @@ -93,6 +96,7 @@ func (h *Handler) ListCycles(c *gin.Context) { options := shared.ListOptions(c) resp, err := h.activity.ListAgencyOpeningCycles(c.Request.Context(), &activityv1.ListAgencyOpeningCyclesRequest{ Meta: h.meta(c), + RegionId: parseInt64Query(c, "region_id"), Status: strings.TrimSpace(options.Status), StartMs: parseInt64Query(c, "start_ms"), EndMs: parseInt64Query(c, "end_ms"), @@ -116,6 +120,10 @@ func (h *Handler) CreateCycle(c *gin.Context) { response.BadRequest(c, "代理开业周期参数不正确") return } + if req.RegionID < 0 { + response.BadRequest(c, "区域参数不正确") + return + } resp, err := h.activity.CreateAgencyOpeningCycle(c.Request.Context(), &activityv1.UpsertAgencyOpeningCycleRequest{ Meta: h.meta(c), Cycle: req.toProto(), @@ -146,6 +154,10 @@ func (h *Handler) UpdateCycle(c *gin.Context) { response.BadRequest(c, "代理开业周期参数不正确") return } + if req.RegionID < 0 { + response.BadRequest(c, "区域参数不正确") + return + } req.CycleID = strings.TrimSpace(c.Param("cycle_id")) resp, err := h.activity.UpdateAgencyOpeningCycle(c.Request.Context(), &activityv1.UpsertAgencyOpeningCycleRequest{ Meta: h.meta(c), @@ -188,6 +200,7 @@ func (h *Handler) ListApplications(c *gin.Context) { resp, err := h.activity.ListAgencyOpeningApplications(c.Request.Context(), &activityv1.ListAgencyOpeningApplicationsRequest{ Meta: h.meta(c), CycleId: strings.TrimSpace(c.Query("cycle_id")), + RegionId: parseInt64Query(c, "region_id"), Status: strings.TrimSpace(options.Status), AgencyId: parseInt64Query(c, "agency_id"), AgencyOwnerUserId: parseInt64Query(c, "agency_owner_user_id"), @@ -246,6 +259,7 @@ func (r cycleRequest) toProto() *activityv1.AgencyOpeningCycle { } return &activityv1.AgencyOpeningCycle{ CycleId: strings.TrimSpace(r.CycleID), + RegionId: r.RegionID, Title: strings.TrimSpace(r.Title), Status: strings.TrimSpace(r.Status), StartMs: r.StartMS, @@ -272,6 +286,7 @@ func cycleFromProto(cycle *activityv1.AgencyOpeningCycle) cycleDTO { return cycleDTO{ AppCode: cycle.GetAppCode(), CycleID: cycle.GetCycleId(), + RegionID: cycle.GetRegionId(), ActivityCode: cycle.GetActivityCode(), Title: cycle.GetTitle(), Status: cycle.GetStatus(), @@ -295,6 +310,7 @@ func applicationFromProto(item *activityv1.AgencyOpeningApplication) application return applicationDTO{ ApplicationID: item.GetApplicationId(), CycleID: item.GetCycleId(), + RegionID: item.GetRegionId(), AgencyID: item.GetAgencyId(), AgencyOwnerUserID: item.GetAgencyOwnerUserId(), AgencyName: item.GetAgencyName(), diff --git a/server/admin/internal/modules/resource/request.go b/server/admin/internal/modules/resource/request.go index 0b350875..e4adba3a 100644 --- a/server/admin/internal/modules/resource/request.go +++ b/server/admin/internal/modules/resource/request.go @@ -809,12 +809,15 @@ func validateRoomDecorationResourceMaterial(req resourceRequest) error { if _, allowed := vipVisualResourceFormats[format]; !allowed { return fmt.Errorf("视觉资源素材格式不支持") } + if resourceType == resourceTypeGiftTraySkin && (format != "svga" || strings.TrimSpace(req.AnimationURL) == "") { + return fmt.Errorf("送礼托盘必须上传 SVGA 动效") + } return nil } func resourceRequiresExplicitVisualFormat(resourceType string) bool { switch normalizeResourceType(resourceType) { - case resourceTypeMicAnimation, resourceTypeRoomBorder: + case resourceTypeGiftTraySkin, resourceTypeMicAnimation, resourceTypeRoomBorder: return true default: return false diff --git a/server/admin/internal/modules/resource/request_test.go b/server/admin/internal/modules/resource/request_test.go index 2082c487..f3a4ec56 100644 --- a/server/admin/internal/modules/resource/request_test.go +++ b/server/admin/internal/modules/resource/request_test.go @@ -84,6 +84,22 @@ func TestResourceRequestKeepsExplicitManagerGrantEnabled(t *testing.T) { } } +func TestGiftTrayResourceRequiresSVGAAnimation(t *testing.T) { + valid := resourceRequest{ + ResourceType: resourceTypeGiftTraySkin, + PreviewURL: "https://cdn.example/vip-gift-tray.png", + AnimationURL: "https://cdn.example/vip-gift-tray.svga", + MetadataJSON: `{"format":"svga"}`, + } + if err := validateRoomDecorationResourceMaterial(valid); err != nil { + t.Fatalf("valid gift tray material should be accepted: %v", err) + } + valid.MetadataJSON = `{"format":"mp4"}` + if err := validateRoomDecorationResourceMaterial(valid); err == nil { + t.Fatal("gift tray must reject non-SVGA material") + } +} + func newGiftRequestTestContext() *gin.Context { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() diff --git a/server/admin/internal/modules/roomturnoverreward/handler.go b/server/admin/internal/modules/roomturnoverreward/handler.go index 91d3e0a2..020ad40b 100644 --- a/server/admin/internal/modules/roomturnoverreward/handler.go +++ b/server/admin/internal/modules/roomturnoverreward/handler.go @@ -28,12 +28,14 @@ func New(activity activityclient.Client, userDB *sql.DB, audit shared.OperationL } type configRequest struct { - Enabled bool `json:"enabled"` - Tiers []tierDTO `json:"tiers"` + RegionID int64 `json:"region_id"` + Enabled bool `json:"enabled"` + Tiers []tierDTO `json:"tiers"` } type configDTO struct { AppCode string `json:"app_code"` + RegionID int64 `json:"region_id"` Enabled bool `json:"enabled"` Tiers []tierDTO `json:"tiers"` UpdatedByAdminID int64 `json:"updated_by_admin_id"` @@ -55,6 +57,7 @@ type tierDTO struct { type settlementDTO struct { SettlementID string `json:"settlement_id"` + RegionID int64 `json:"region_id"` RoomID string `json:"room_id"` OwnerUserID int64 `json:"owner_user_id,string"` OwnerUser *userDTO `json:"owner_user,omitempty"` @@ -82,8 +85,9 @@ type userDTO struct { } func (h *Handler) GetConfig(c *gin.Context) { + regionID := queryInt64(c, "region_id", "regionId") // 后台服务只透传到 activity-service 获取当前 App 配置;活动规则 owner 不在 admin-server 内部复制。 - resp, err := h.activity.GetRoomTurnoverRewardConfig(c.Request.Context(), &activityv1.GetRoomTurnoverRewardConfigRequest{Meta: h.meta(c)}) + resp, err := h.activity.GetRoomTurnoverRewardConfig(c.Request.Context(), &activityv1.GetRoomTurnoverRewardConfigRequest{Meta: h.meta(c), RegionId: regionID}) if err != nil { response.ServerError(c, "获取房间流水奖励配置失败") return @@ -97,6 +101,10 @@ func (h *Handler) UpdateConfig(c *gin.Context) { response.BadRequest(c, "房间流水奖励配置参数不正确") return } + if req.RegionID < 0 { + response.BadRequest(c, "区域参数不正确") + return + } tiers := make([]*activityv1.RoomTurnoverRewardTier, 0, len(req.Tiers)) for _, tier := range req.Tiers { // 后台只做输入清洗和操作者透传;阈值递增、奖励金额和启用状态等业务校验统一由 activity-service 执行。 @@ -112,6 +120,7 @@ func (h *Handler) UpdateConfig(c *gin.Context) { } resp, err := h.activity.UpdateRoomTurnoverRewardConfig(c.Request.Context(), &activityv1.UpdateRoomTurnoverRewardConfigRequest{ Meta: h.meta(c), + RegionId: req.RegionID, Enabled: req.Enabled, Tiers: tiers, OperatorAdminId: int64(middleware.CurrentUserID(c)), @@ -122,7 +131,7 @@ func (h *Handler) UpdateConfig(c *gin.Context) { } item := configFromProto(resp.GetConfig()) // 配置修改必须留下后台审计资源 ID,后续排查结算金额变化时可以回到具体 App 配置记录。 - shared.OperationLogWithResourceID(c, h.audit, "update-room-turnover-reward", "room_turnover_reward_configs", item.AppCode, "success", "") + shared.OperationLogWithResourceID(c, h.audit, "update-room-turnover-reward", "room_turnover_reward_configs", item.AppCode+":"+strconv.FormatInt(item.RegionID, 10), "success", "") response.OK(c, item) } @@ -145,6 +154,7 @@ func (h *Handler) ListSettlements(c *gin.Context) { } resp, err := h.activity.ListRoomTurnoverRewardSettlements(c.Request.Context(), &activityv1.ListRoomTurnoverRewardSettlementsRequest{ Meta: h.meta(c), + RegionId: queryInt64(c, "region_id", "regionId"), Status: strings.TrimSpace(options.Status), RoomId: strings.TrimSpace(options.Keyword), OwnerUserId: ownerUserID, @@ -281,6 +291,7 @@ func configFromProto(config *activityv1.RoomTurnoverRewardConfig) configDTO { } return configDTO{ AppCode: config.GetAppCode(), + RegionID: config.GetRegionId(), Enabled: config.GetEnabled(), Tiers: tiers, UpdatedByAdminID: config.GetUpdatedByAdminId(), @@ -368,6 +379,7 @@ func settlementFromProto(settlement *activityv1.RoomTurnoverRewardSettlement) se } return settlementDTO{ SettlementID: settlement.GetSettlementId(), + RegionID: settlement.GetRegionId(), RoomID: settlement.GetRoomId(), OwnerUserID: settlement.GetOwnerUserId(), PeriodStartMS: settlement.GetPeriodStartMs(), diff --git a/services/activity-service/deploy/mysql/initdb/001_activity_service.sql b/services/activity-service/deploy/mysql/initdb/001_activity_service.sql index d6e0c35c..ba90dfe0 100644 --- a/services/activity-service/deploy/mysql/initdb/001_activity_service.sql +++ b/services/activity-service/deploy/mysql/initdb/001_activity_service.sql @@ -1094,16 +1094,18 @@ CREATE TABLE IF NOT EXISTS message_action_outbox ( CREATE TABLE IF NOT EXISTS room_turnover_reward_configs ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '配置区域 ID;0 表示全区域默认配置', enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用', updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最后更新管理员', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', - PRIMARY KEY (app_code) + PRIMARY KEY (app_code, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励配置表'; CREATE TABLE IF NOT EXISTS room_turnover_reward_tiers ( tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '档位 ID', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '配置区域 ID;0 表示全区域默认配置', tier_code VARCHAR(64) NOT NULL COMMENT '档位编码', tier_name VARCHAR(128) NOT NULL COMMENT '档位名称', threshold_coin_spent BIGINT NOT NULL COMMENT '命中该档需要的房间送礼金币流水', @@ -1113,8 +1115,8 @@ CREATE TABLE IF NOT EXISTS room_turnover_reward_tiers ( created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (tier_id), - UNIQUE KEY uk_room_turnover_reward_tier_code (app_code, tier_code), - KEY idx_room_turnover_reward_tiers_match (app_code, status, threshold_coin_spent) + UNIQUE KEY uk_room_turnover_reward_tier_code (app_code, region_id, tier_code), + KEY idx_room_turnover_reward_tiers_match (app_code, region_id, status, threshold_coin_spent) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励档位表'; CREATE TABLE IF NOT EXISTS room_turnover_reward_event_consumption ( @@ -1128,12 +1130,14 @@ CREATE TABLE IF NOT EXISTS room_turnover_reward_event_consumption ( consumed_at_ms BIGINT NOT NULL COMMENT '消费时间,UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时房间区域 ID', PRIMARY KEY (app_code, event_id), KEY idx_room_turnover_reward_event_period (app_code, period_start_ms, room_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励事件消费幂等表'; CREATE TABLE IF NOT EXISTS room_turnover_reward_progress ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时房间区域 ID;0 表示全区域可见', room_id VARCHAR(96) NOT NULL COMMENT '房间 ID', period_start_ms BIGINT NOT NULL COMMENT 'UTC 周期开始', period_end_ms BIGINT NOT NULL COMMENT 'UTC 周期结束', @@ -1141,12 +1145,13 @@ CREATE TABLE IF NOT EXISTS room_turnover_reward_progress ( last_event_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最后事件时间', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', - PRIMARY KEY (app_code, room_id, period_start_ms), + PRIMARY KEY (app_code, region_id, room_id, period_start_ms), KEY idx_room_turnover_reward_progress_period (app_code, period_start_ms, coin_spent) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励周期聚合表'; CREATE TABLE IF NOT EXISTS room_turnover_reward_settlements ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '结算对应房间区域 ID;0 表示全区域可见', settlement_id VARCHAR(96) NOT NULL COMMENT '结算 ID', room_id VARCHAR(96) NOT NULL COMMENT '房间 ID', owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '结算收款房主', @@ -1165,7 +1170,8 @@ CREATE TABLE IF NOT EXISTS room_turnover_reward_settlements ( created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, settlement_id), - UNIQUE KEY uk_room_turnover_reward_settlement_room_period (app_code, room_id, period_start_ms), + UNIQUE KEY uk_room_turnover_reward_settlement_room_period (app_code, region_id, room_id, period_start_ms), + KEY idx_room_turnover_reward_settlement_region (app_code, region_id, period_start_ms, created_at_ms), KEY idx_room_turnover_reward_settlement_status (app_code, status, period_start_ms, created_at_ms), KEY idx_room_turnover_reward_settlement_owner (app_code, owner_user_id, period_start_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励每周结算表'; @@ -1304,6 +1310,7 @@ CREATE TABLE IF NOT EXISTS cp_weekly_rank_settlements ( CREATE TABLE IF NOT EXISTS agency_opening_cycles ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '配置区域 ID;0 表示全区域默认活动', cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID', activity_code VARCHAR(64) NOT NULL DEFAULT 'agency_opening' COMMENT '活动编码', title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '周期标题', @@ -1320,7 +1327,7 @@ CREATE TABLE IF NOT EXISTS agency_opening_cycles ( created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, cycle_id), - KEY idx_agency_opening_cycle_current (app_code, activity_code, status, start_ms, end_ms), + KEY idx_agency_opening_cycle_current (app_code, region_id, activity_code, status, start_ms, end_ms), KEY idx_agency_opening_cycle_due (app_code, activity_code, status, end_ms, lock_until_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业活动周期'; @@ -1337,6 +1344,7 @@ CREATE TABLE IF NOT EXISTS agency_opening_cycle_rewards ( CREATE TABLE IF NOT EXISTS agency_opening_applications ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '申请时代理实际区域 ID', application_id VARCHAR(96) NOT NULL COMMENT '申请 ID', cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID', agency_id BIGINT NOT NULL COMMENT '代理 ID', @@ -1366,7 +1374,8 @@ CREATE TABLE IF NOT EXISTS agency_opening_applications ( KEY idx_agency_opening_application_owner (app_code, agency_owner_user_id, applied_at_ms), KEY idx_agency_opening_agency_cycle (app_code, cycle_id, agency_id), KEY idx_agency_opening_application_rank (app_code, cycle_id, score_coin_amount, applied_at_ms, agency_id), - KEY idx_agency_opening_application_status (app_code, cycle_id, status, updated_at_ms) + KEY idx_agency_opening_application_status (app_code, cycle_id, status, updated_at_ms), + KEY idx_agency_opening_application_region (app_code, region_id, updated_at_ms, application_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业申请和结算事实'; CREATE TABLE IF NOT EXISTS agency_opening_score_events ( @@ -1379,6 +1388,7 @@ CREATE TABLE IF NOT EXISTS agency_opening_score_events ( score_delta BIGINT NOT NULL COMMENT '本次流水金币', occurred_at_ms BIGINT NOT NULL COMMENT '事件发生时间,UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时房间区域 ID', PRIMARY KEY (app_code, source_event_id), KEY idx_agency_opening_score_event_application (app_code, application_id, occurred_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业流水事件幂等表'; diff --git a/services/activity-service/deploy/mysql/migrations/014_activity_region_scoping.sql b/services/activity-service/deploy/mysql/migrations/014_activity_region_scoping.sql new file mode 100644 index 00000000..13aaf02c --- /dev/null +++ b/services/activity-service/deploy/mysql/migrations/014_activity_region_scoping.sql @@ -0,0 +1,51 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_activity; + +-- 所有新增列都使用 INSTANT 元数据变更,不扫描或重写高频事件/进度表。历史全局配置回填 0 后继续作为 +-- 全区域默认配置,具体区域配置存在时由业务查询优先命中具体区域。 +ALTER TABLE room_turnover_reward_configs + ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '配置区域 ID;0 表示全区域默认配置', ALGORITHM=INSTANT; +ALTER TABLE room_turnover_reward_tiers + ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '配置区域 ID;0 表示全区域默认配置', ALGORITHM=INSTANT; +ALTER TABLE room_turnover_reward_event_consumption + ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时房间区域 ID', ALGORITHM=INSTANT; +ALTER TABLE room_turnover_reward_progress + ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时房间区域 ID', ALGORITHM=INSTANT; +ALTER TABLE room_turnover_reward_settlements + ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '结算对应房间区域 ID', ALGORITHM=INSTANT; +ALTER TABLE agency_opening_cycles + ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '配置区域 ID;0 表示全区域默认活动', ALGORITHM=INSTANT; +ALTER TABLE agency_opening_applications + ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '申请时代理区域 ID', ALGORITHM=INSTANT; +ALTER TABLE agency_opening_score_events + ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时房间区域 ID', ALGORITHM=INSTANT; + +-- 业务唯一键必须携带 region_id,保证同一 App 的不同区域可独立配置和结算。 +-- 这些索引变更需要扫描索引记录,但使用 INPLACE + LOCK=NONE,部署前仍应按线上表体量确认 DDL 时长和磁盘余量。 +ALTER TABLE room_turnover_reward_configs + DROP PRIMARY KEY, + ADD PRIMARY KEY (app_code, region_id), + ALGORITHM=INPLACE, LOCK=NONE; +ALTER TABLE room_turnover_reward_tiers + DROP INDEX uk_room_turnover_reward_tier_code, + DROP INDEX idx_room_turnover_reward_tiers_match, + ADD UNIQUE KEY uk_room_turnover_reward_tier_code (app_code, region_id, tier_code), + ADD KEY idx_room_turnover_reward_tiers_match (app_code, region_id, status, threshold_coin_spent), + ALGORITHM=INPLACE, LOCK=NONE; +ALTER TABLE room_turnover_reward_progress + DROP PRIMARY KEY, + ADD PRIMARY KEY (app_code, region_id, room_id, period_start_ms), + ALGORITHM=INPLACE, LOCK=NONE; +ALTER TABLE room_turnover_reward_settlements + DROP INDEX uk_room_turnover_reward_settlement_room_period, + ADD UNIQUE KEY uk_room_turnover_reward_settlement_room_period (app_code, region_id, room_id, period_start_ms), + ADD KEY idx_room_turnover_reward_settlement_region (app_code, region_id, period_start_ms, created_at_ms), + ALGORITHM=INPLACE, LOCK=NONE; +ALTER TABLE agency_opening_cycles + DROP INDEX idx_agency_opening_cycle_current, + ADD KEY idx_agency_opening_cycle_current (app_code, region_id, activity_code, status, start_ms, end_ms), + ALGORITHM=INPLACE, LOCK=NONE; +ALTER TABLE agency_opening_applications + ADD KEY idx_agency_opening_application_region (app_code, region_id, updated_at_ms, application_id), + ALGORITHM=INPLACE, LOCK=NONE; diff --git a/services/activity-service/internal/client/agency_opening_source.go b/services/activity-service/internal/client/agency_opening_source.go index 1abbef67..a2835bc2 100644 --- a/services/activity-service/internal/client/agency_opening_source.go +++ b/services/activity-service/internal/client/agency_opening_source.go @@ -61,6 +61,7 @@ func agencySnapshotFromProto(agency *userv1.Agency) domain.AgencySnapshot { } return domain.AgencySnapshot{ AgencyID: agency.GetAgencyId(), + RegionID: agency.GetRegionId(), OwnerUserID: agency.GetOwnerUserId(), Name: agency.GetName(), Status: agency.GetStatus(), diff --git a/services/activity-service/internal/domain/agencyopening/agency_opening.go b/services/activity-service/internal/domain/agencyopening/agency_opening.go index 5b2c1ba6..be53e8a1 100644 --- a/services/activity-service/internal/domain/agencyopening/agency_opening.go +++ b/services/activity-service/internal/domain/agencyopening/agency_opening.go @@ -34,9 +34,10 @@ type Reward struct { UpdatedAtMS int64 } -// Cycle is the UTC activity window configured by admin. +// Cycle 是后台配置的 UTC 活动窗口;RegionID=0 表示全区域,正数周期优先覆盖对应区域。 type Cycle struct { AppCode string + RegionID int64 CycleID string ActivityCode string Title string @@ -62,6 +63,7 @@ type CycleCommand struct { // ListQuery filters admin list reads. type ListQuery struct { + RegionID int64 Status string StartMS int64 EndMS int64 @@ -69,9 +71,10 @@ type ListQuery struct { PageSize int32 } -// Application is both the submitted application record and the settlement fact. +// Application 同时保存申请和结算事实;RegionID 固化申请时代理的实际区域,即使周期是全区域也不写 0。 type Application struct { AppCode string + RegionID int64 ApplicationID string CycleID string AgencyID int64 @@ -99,6 +102,7 @@ type Application struct { // ApplicationQuery filters admin and H5 leaderboard reads. type ApplicationQuery struct { + RegionID int64 CycleID string Status string AgencyID int64 @@ -110,6 +114,7 @@ type ApplicationQuery struct { // AgencySnapshot is read from user-service at apply time so the activity record remains auditable. type AgencySnapshot struct { AgencyID int64 + RegionID int64 OwnerUserID int64 Name string Status string @@ -133,6 +138,7 @@ type Qualification struct { // CoinSpent keeps the legacy field name but stores RoomGiftSent.gift_value/HeatValue. type GiftEvent struct { EventID string + RegionID int64 RoomID string RoomOwnerUserID int64 TargetUserID int64 diff --git a/services/activity-service/internal/domain/roomturnoverreward/room_turnover_reward.go b/services/activity-service/internal/domain/roomturnoverreward/room_turnover_reward.go index 7e0e5e52..33a7af98 100644 --- a/services/activity-service/internal/domain/roomturnoverreward/room_turnover_reward.go +++ b/services/activity-service/internal/domain/roomturnoverreward/room_turnover_reward.go @@ -26,9 +26,10 @@ type Tier struct { UpdatedAtMS int64 } -// Config 是当前 App 的房间流水奖励配置。 +// Config 是当前 App 的区域配置;RegionID=0 表示全区域默认配置,正数配置优先覆盖对应区域。 type Config struct { AppCode string + RegionID int64 Enabled bool Tiers []Tier UpdatedByAdminID int64 @@ -39,6 +40,7 @@ type Config struct { // Progress 是单房间单贡献周期的 HeatValue 聚合;CoinSpent 是历史字段名,运行值为房间贡献值。 type Progress struct { AppCode string + RegionID int64 RoomID string PeriodStartMS int64 PeriodEndMS int64 @@ -53,6 +55,7 @@ type RoomGiftEvent struct { EventID string EventType string AppCode string + RegionID int64 RoomID string CoinSpent int64 OccurredAtMS int64 @@ -68,6 +71,7 @@ type EventResult struct { type Settlement struct { SettlementID string AppCode string + RegionID int64 RoomID string OwnerUserID int64 PeriodStartMS int64 @@ -102,6 +106,7 @@ type StatusResult struct { // SettlementQuery 是后台结算记录分页筛选条件。 type SettlementQuery struct { + RegionID int64 Status string RoomID string OwnerUserID int64 diff --git a/services/activity-service/internal/service/agencyopening/service.go b/services/activity-service/internal/service/agencyopening/service.go index 34ce249e..09b0165d 100644 --- a/services/activity-service/internal/service/agencyopening/service.go +++ b/services/activity-service/internal/service/agencyopening/service.go @@ -24,7 +24,7 @@ type Repository interface { GetAgencyOpeningCycle(ctx context.Context, cycleID string) (domain.Cycle, error) UpsertAgencyOpeningCycle(ctx context.Context, command domain.CycleCommand, nowMS int64) (domain.Cycle, bool, error) SetAgencyOpeningCycleStatus(ctx context.Context, cycleID string, status string, operatorAdminID int64, nowMS int64) (domain.Cycle, error) - FindCurrentAgencyOpeningCycle(ctx context.Context, nowMS int64) (domain.Cycle, bool, error) + FindCurrentAgencyOpeningCycle(ctx context.Context, regionID int64, nowMS int64) (domain.Cycle, bool, error) GetAgencyOpeningApplicationByOwner(ctx context.Context, ownerUserID int64) (domain.Application, bool, error) CreateAgencyOpeningApplication(ctx context.Context, cycle domain.Cycle, agency domain.AgencySnapshot, nowMS int64) (domain.Application, bool, error) ApproveAgencyOpeningApplication(ctx context.Context, applicationID string, operatorAdminID int64, nowMS int64) (domain.Application, error) @@ -109,24 +109,27 @@ func (s *Service) GetStatus(ctx context.Context, userID int64) (domain.Qualifica return domain.Qualification{}, err } nowMS := s.now().UTC().UnixMilli() - cycle, exists, err := s.repository.FindCurrentAgencyOpeningCycle(ctx, nowMS) - if err != nil { - return domain.Qualification{}, err - } - result := domain.Qualification{Cycle: cycle, Enabled: exists, ServerTimeMS: nowMS} - if !exists { - result.IneligibleReason = "activity_disabled" - return result, nil - } + result := domain.Qualification{ServerTimeMS: nowMS} + // 代理区域来自 user-service 的 Agency owner 事实;先解析区域,再按“区域覆盖、全区域默认”顺序选择周期,禁止信任请求参数推断区域。 agency, ok, err := s.resolveOwnerAgency(ctx, userID) if err != nil { return domain.Qualification{}, err } result.Agency = agency - if !ok { + if !ok || agency.RegionID <= 0 { result.IneligibleReason = "active_agency_owner_required" return result, nil } + cycle, exists, err := s.repository.FindCurrentAgencyOpeningCycle(ctx, agency.RegionID, nowMS) + if err != nil { + return domain.Qualification{}, err + } + result.Cycle = cycle + result.Enabled = exists + if !exists { + result.IneligibleReason = "activity_disabled" + return result, nil + } application, joined, err := s.repository.GetAgencyOpeningApplicationByOwner(ctx, agency.OwnerUserID) if err != nil { return domain.Qualification{}, err @@ -191,7 +194,7 @@ func (s *Service) HandleGiftEvent(ctx context.Context, event domain.GiftEvent) ( if err := s.requireRepository(); err != nil { return 0, err } - if event.EventID == "" || event.RoomOwnerUserID <= 0 || event.CoinSpent <= 0 { + if event.EventID == "" || event.RegionID < 0 || event.RoomOwnerUserID <= 0 || event.CoinSpent <= 0 { return 0, nil } result, err := s.repository.ConsumeAgencyOpeningGiftEvent(ctx, event, s.now().UTC().UnixMilli()) @@ -216,6 +219,7 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev } roomID := strings.TrimSpace(envelope.GetRoomId()) contributionValue := gift.GetGiftValue() + regionID := gift.GetVisibleRegionId() if gift.GetIsRobotGift() || roomID == "" || contributionValue <= 0 { return 0, nil } @@ -231,6 +235,7 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev } return s.HandleGiftEvent(eventCtx, domain.GiftEvent{ EventID: strings.TrimSpace(envelope.GetEventId()), + RegionID: regionID, RoomID: roomID, RoomOwnerUserID: ownerUserID, TargetUserID: gift.GetTargetUserId(), @@ -369,6 +374,10 @@ func (s *Service) requireRepository() error { } func validateCycle(cycle domain.Cycle, operatorAdminID int64) error { + if cycle.RegionID < 0 { + // region_id=0 是全区域周期,正数周期覆盖对应区域。 + return xerr.New(xerr.InvalidArgument, "region_id must not be negative") + } if strings.TrimSpace(cycle.Title) == "" { return xerr.New(xerr.InvalidArgument, "title is required") } @@ -409,6 +418,9 @@ func validateCycle(cycle domain.Cycle, operatorAdminID int64) error { } func eligible(cycle domain.Cycle, agency domain.AgencySnapshot, nowMS int64) (bool, string) { + if cycle.RegionID != 0 && agency.RegionID != cycle.RegionID { + return false, "agency_region_not_eligible" + } if agency.Status != "active" { return false, "active_agency_required" } diff --git a/services/activity-service/internal/service/agencyopening/service_test.go b/services/activity-service/internal/service/agencyopening/service_test.go index 92f46f50..c7a62da1 100644 --- a/services/activity-service/internal/service/agencyopening/service_test.go +++ b/services/activity-service/internal/service/agencyopening/service_test.go @@ -17,8 +17,8 @@ import ( ) func TestEligibleRequiresHostCountGreaterThanConfigured(t *testing.T) { - cycle := domain.Cycle{MinHostCount: 10} - agency := domain.AgencySnapshot{Status: "active", HostCount: 10} + cycle := domain.Cycle{RegionID: 12, MinHostCount: 10} + agency := domain.AgencySnapshot{RegionID: 12, Status: "active", HostCount: 10} if ok, reason := eligible(cycle, agency, 1000); ok || reason != "host_count_not_enough" { t.Fatalf("host_count equal to threshold must be rejected, got ok=%v reason=%q", ok, reason) } @@ -26,11 +26,16 @@ func TestEligibleRequiresHostCountGreaterThanConfigured(t *testing.T) { if ok, reason := eligible(cycle, agency, 1000); !ok || reason != "" { t.Fatalf("host_count above threshold must be eligible, got ok=%v reason=%q", ok, reason) } + cycle.RegionID = 0 + agency.RegionID = 99 + if ok, reason := eligible(cycle, agency, 1000); !ok || reason != "" { + t.Fatalf("global cycle must be eligible across regions, got ok=%v reason=%q", ok, reason) + } } func TestGetStatusRejectsOwnerAlreadyAppliedInAnyCycle(t *testing.T) { repo := &agencyOpeningFakeRepo{ - currentCycle: domain.Cycle{CycleID: "current", Status: domain.StatusActive, StartMS: 1, EndMS: 10000, MinHostCount: 10}, + currentCycle: domain.Cycle{CycleID: "current", RegionID: 12, Status: domain.StatusActive, StartMS: 1, EndMS: 10000, MinHostCount: 10}, hasCurrent: true, application: domain.Application{ ApplicationID: "old-application", @@ -41,7 +46,7 @@ func TestGetStatusRejectsOwnerAlreadyAppliedInAnyCycle(t *testing.T) { hasApplication: true, } svc := New(repo, nil, agencyOpeningFakeAgencySource{ - snapshot: domain.AgencySnapshot{AgencyID: 7, OwnerUserID: 42, Status: "active", HostCount: 11}, + snapshot: domain.AgencySnapshot{AgencyID: 7, RegionID: 12, OwnerUserID: 42, Status: "active", HostCount: 11}, ok: true, }, nil) svc.now = func() time.Time { return time.UnixMilli(5000).UTC() } @@ -60,7 +65,7 @@ func TestGetStatusRejectsOwnerAlreadyAppliedInAnyCycle(t *testing.T) { func TestGetStatusRequiresAgencyOwner(t *testing.T) { repo := &agencyOpeningFakeRepo{ - currentCycle: domain.Cycle{CycleID: "current", Status: domain.StatusActive, StartMS: 1, EndMS: 10000, MinHostCount: 10}, + currentCycle: domain.Cycle{CycleID: "current", RegionID: 12, Status: domain.StatusActive, StartMS: 1, EndMS: 10000, MinHostCount: 10}, hasCurrent: true, } svc := New(repo, nil, agencyOpeningFakeAgencySource{ok: false}, nil) @@ -81,10 +86,11 @@ func TestHandleRoomEventScoresApplicantRoomOwner(t *testing.T) { svc := New(repo, nil, nil, room) svc.now = func() time.Time { return time.UnixMilli(8000).UTC() } body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{ - SenderUserId: 9, - TargetUserId: 88, - GiftValue: 900, - CoinSpent: 1200, + SenderUserId: 9, + TargetUserId: 88, + GiftValue: 900, + CoinSpent: 1200, + VisibleRegionId: 12, }) if err != nil { t.Fatalf("marshal gift: %v", err) @@ -106,7 +112,7 @@ func TestHandleRoomEventScoresApplicantRoomOwner(t *testing.T) { if room.roomID != "room-1" { t.Fatalf("expected room lookup by event room_id, got %q", room.roomID) } - if repo.consumed.RoomOwnerUserID != 42 || repo.consumed.TargetUserID != 88 || repo.consumed.CoinSpent != 900 { + if repo.consumed.RegionID != 12 || repo.consumed.RoomOwnerUserID != 42 || repo.consumed.TargetUserID != 88 || repo.consumed.CoinSpent != 900 { t.Fatalf("unexpected consumed event: %+v", repo.consumed) } } @@ -115,7 +121,7 @@ func TestHandleRoomEventTreatsDeletedRoomAsTerminalSkip(t *testing.T) { repo := &agencyOpeningFakeRepo{consumeStatus: domain.EventStatusConsumed} room := &agencyOpeningFakeRoomClient{err: status.Error(codes.NotFound, "room not found")} svc := New(repo, nil, nil, room) - body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{SenderUserId: 9, TargetUserId: 88, GiftValue: 900}) + body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{SenderUserId: 9, TargetUserId: 88, GiftValue: 900, VisibleRegionId: 12}) if err != nil { t.Fatalf("marshal gift: %v", err) } @@ -135,7 +141,7 @@ func TestHandleRoomEventKeepsTransientRoomFailureRetryable(t *testing.T) { repo := &agencyOpeningFakeRepo{consumeStatus: domain.EventStatusConsumed} room := &agencyOpeningFakeRoomClient{err: status.Error(codes.Unavailable, "room service unavailable")} svc := New(repo, nil, nil, room) - body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{SenderUserId: 9, TargetUserId: 88, GiftValue: 900}) + body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{SenderUserId: 9, TargetUserId: 88, GiftValue: 900, VisibleRegionId: 12}) if err != nil { t.Fatalf("marshal gift: %v", err) } @@ -173,7 +179,7 @@ func (r *agencyOpeningFakeRepo) UpsertAgencyOpeningCycle(context.Context, domain func (r *agencyOpeningFakeRepo) SetAgencyOpeningCycleStatus(context.Context, string, string, int64, int64) (domain.Cycle, error) { return domain.Cycle{}, nil } -func (r *agencyOpeningFakeRepo) FindCurrentAgencyOpeningCycle(context.Context, int64) (domain.Cycle, bool, error) { +func (r *agencyOpeningFakeRepo) FindCurrentAgencyOpeningCycle(context.Context, int64, int64) (domain.Cycle, bool, error) { return r.currentCycle, r.hasCurrent, nil } func (r *agencyOpeningFakeRepo) GetAgencyOpeningApplicationByOwner(_ context.Context, ownerUserID int64) (domain.Application, bool, error) { diff --git a/services/activity-service/internal/service/broadcast/service.go b/services/activity-service/internal/service/broadcast/service.go index 263daa91..d1d61dfe 100644 --- a/services/activity-service/internal/service/broadcast/service.go +++ b/services/activity-service/internal/service/broadcast/service.go @@ -905,36 +905,40 @@ func superGiftBroadcastEventID(envelope *roomeventsv1.EventEnvelope, gift *roome func superGiftPayload(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent, eventID string, sentAtMS int64, sender SenderProfile, receiver SenderProfile) (string, error) { // payload 同时保留扁平字段和 sender/receiver 对象,旧客户端继续读原字段,新客户端可直接取完整用户资料。 payload := map[string]any{ - "event_id": eventID, - "broadcast_type": broadcastdomain.TypeSuperGift, - "scope": broadcastdomain.ScopeRegion, - "app_code": envelope.GetAppCode(), - "region_id": gift.GetVisibleRegionId(), - "room_id": envelope.GetRoomId(), - "room_locked": gift.GetRoomLocked(), - "sender_user_id": gift.GetSenderUserId(), - "sender_display_user_id": sender.Account, - "sender_nickname": sender.Nickname, - "sender_avatar": sender.Avatar, - "sender_avatar_frame_url": sender.AvatarFrameURL, - "target_user_id": gift.GetTargetUserId(), - "receiver_user_id": gift.GetTargetUserId(), - "receiver_display_user_id": receiver.Account, - "receiver_nickname": receiver.Nickname, - "receiver_avatar": receiver.Avatar, - "receiver_avatar_frame_url": receiver.AvatarFrameURL, - "gift_id": gift.GetGiftId(), - "gift_name": gift.GetGiftName(), - "gift_icon_url": gift.GetGiftIconUrl(), - "gift_animation_url": gift.GetGiftAnimationUrl(), - "gift_effect_types": gift.GetGiftEffectTypes(), - "gift_count": gift.GetGiftCount(), - "gift_value": gift.GetGiftValue(), - "sent_at_ms": sentAtMS, - "room_version": envelope.GetRoomVersion(), - "source_event_id": envelope.GetEventId(), - "sender": broadcastProfilePayload(sender), - "receiver": broadcastProfilePayload(receiver), + "event_id": eventID, + "broadcast_type": broadcastdomain.TypeSuperGift, + "scope": broadcastdomain.ScopeRegion, + "app_code": envelope.GetAppCode(), + "region_id": gift.GetVisibleRegionId(), + "room_id": envelope.GetRoomId(), + "room_locked": gift.GetRoomLocked(), + "sender_user_id": gift.GetSenderUserId(), + "sender_display_user_id": sender.Account, + "sender_nickname": sender.Nickname, + "sender_avatar": sender.Avatar, + "sender_avatar_frame_url": sender.AvatarFrameURL, + // 托盘 URL 是 gateway 在扣费前从有效 VIP 权益固化、room durable fact 透传的历史快照; + // activity 不能在异步消费时按当前会员状态回查,否则同一 event_id 重试会改变客户端展示。 + "sender_vip_level": gift.GetSenderVipLevel(), + "sender_gift_broadcast_animation_url": gift.GetSenderGiftBroadcastAnimationUrl(), + "target_user_id": gift.GetTargetUserId(), + "receiver_user_id": gift.GetTargetUserId(), + "receiver_display_user_id": receiver.Account, + "receiver_nickname": receiver.Nickname, + "receiver_avatar": receiver.Avatar, + "receiver_avatar_frame_url": receiver.AvatarFrameURL, + "gift_id": gift.GetGiftId(), + "gift_name": gift.GetGiftName(), + "gift_icon_url": gift.GetGiftIconUrl(), + "gift_animation_url": gift.GetGiftAnimationUrl(), + "gift_effect_types": gift.GetGiftEffectTypes(), + "gift_count": gift.GetGiftCount(), + "gift_value": gift.GetGiftValue(), + "sent_at_ms": sentAtMS, + "room_version": envelope.GetRoomVersion(), + "source_event_id": envelope.GetEventId(), + "sender": broadcastProfilePayload(sender), + "receiver": broadcastProfilePayload(receiver), "action": map[string]any{ "type": "enter_room", "room_id": envelope.GetRoomId(), diff --git a/services/activity-service/internal/service/broadcast/service_test.go b/services/activity-service/internal/service/broadcast/service_test.go index 1b74d51f..14801c7c 100644 --- a/services/activity-service/internal/service/broadcast/service_test.go +++ b/services/activity-service/internal/service/broadcast/service_test.go @@ -89,17 +89,19 @@ func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) { 43: {UserID: 43, Account: "160043", Nickname: "Receiver", Avatar: "https://cdn.example/receiver.png", AvatarFrameURL: "https://cdn.example/receiver-frame.png"}, }}) envelope := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{ - SenderUserId: 42, - TargetUserId: 43, - GiftId: "rocket", - GiftCount: 2, - GiftValue: 999, - VisibleRegionId: 1001, - RoomLocked: true, - CommandId: "cmd-send-gift-1", - GiftName: "Rocket", - GiftIconUrl: "https://cdn.example/rocket.png", - GiftEffectTypes: []string{"animation", "global_broadcast"}, + SenderUserId: 42, + TargetUserId: 43, + GiftId: "rocket", + GiftCount: 2, + GiftValue: 999, + VisibleRegionId: 1001, + RoomLocked: true, + CommandId: "cmd-send-gift-1", + GiftName: "Rocket", + GiftIconUrl: "https://cdn.example/rocket.png", + GiftEffectTypes: []string{"animation", "global_broadcast"}, + SenderVipLevel: 7, + SenderGiftBroadcastAnimationUrl: "https://cdn.example/vip-gift-tray.svga", }) result, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope) @@ -117,7 +119,7 @@ func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) { if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil { t.Fatalf("payload is not json: %v", err) } - if payload["room_id"] != "room-1001" || payload["room_locked"] != true || payload["gift_id"] != "rocket" || payload["scope"] != "region" || payload["gift_icon_url"] != "https://cdn.example/rocket.png" { + if payload["room_id"] != "room-1001" || payload["room_locked"] != true || payload["gift_id"] != "rocket" || payload["scope"] != "region" || payload["gift_icon_url"] != "https://cdn.example/rocket.png" || payload["sender_vip_level"] != float64(7) || payload["sender_gift_broadcast_animation_url"] != "https://cdn.example/vip-gift-tray.svga" { t.Fatalf("payload mismatch: %+v", payload) } sender, _ := payload["sender"].(map[string]any) diff --git a/services/activity-service/internal/service/roomturnoverreward/service.go b/services/activity-service/internal/service/roomturnoverreward/service.go index 28abbae8..1828a60d 100644 --- a/services/activity-service/internal/service/roomturnoverreward/service.go +++ b/services/activity-service/internal/service/roomturnoverreward/service.go @@ -27,11 +27,11 @@ const ( // Repository 是房间流水奖励配置、周期聚合和结算记录的唯一持久化边界。 type Repository interface { - GetRoomTurnoverRewardConfig(ctx context.Context) (domain.Config, bool, error) + GetRoomTurnoverRewardConfig(ctx context.Context, regionID int64) (domain.Config, bool, error) UpdateRoomTurnoverRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error) ConsumeRoomTurnoverGiftEvent(ctx context.Context, event domain.RoomGiftEvent, periodStartMS int64, periodEndMS int64, nowMS int64) (domain.EventResult, error) - GetRoomTurnoverRewardProgress(ctx context.Context, roomID string, periodStartMS int64) (domain.Progress, bool, error) - GetLatestRoomTurnoverRewardSettlement(ctx context.Context, roomID string) (domain.Settlement, bool, error) + GetRoomTurnoverRewardProgress(ctx context.Context, regionID int64, roomID string, periodStartMS int64) (domain.Progress, bool, error) + GetLatestRoomTurnoverRewardSettlement(ctx context.Context, regionID int64, roomID string) (domain.Settlement, bool, error) ListRoomTurnoverRewardSettlements(ctx context.Context, query domain.SettlementQuery) ([]domain.Settlement, int64, error) ListUnsettledRoomTurnoverRewardProgress(ctx context.Context, periodStartMS int64, limit int) ([]domain.Progress, error) InsertRoomTurnoverRewardSettlement(ctx context.Context, settlement domain.Settlement, nowMS int64) (domain.Settlement, bool, error) @@ -90,16 +90,19 @@ func (s *Service) SetClock(now func() time.Time) { } } -func (s *Service) GetConfig(ctx context.Context) (domain.Config, error) { +func (s *Service) GetConfig(ctx context.Context, regionID int64) (domain.Config, error) { if err := s.requireRepository(); err != nil { return domain.Config{}, err } - config, exists, err := s.repository.GetRoomTurnoverRewardConfig(ctx) + if regionID < 0 { + return domain.Config{}, xerr.New(xerr.InvalidArgument, "region_id must not be negative") + } + config, exists, err := s.repository.GetRoomTurnoverRewardConfig(ctx, regionID) if err != nil { return domain.Config{}, err } if !exists { - return domain.Config{AppCode: appcode.FromContext(ctx)}, nil + return domain.Config{AppCode: appcode.FromContext(ctx), RegionID: regionID}, nil } return config, nil } @@ -116,7 +119,7 @@ func (s *Service) UpdateConfig(ctx context.Context, config domain.Config) (domai return s.repository.UpdateRoomTurnoverRewardConfig(ctx, config, s.now().UTC().UnixMilli()) } -func (s *Service) GetStatus(ctx context.Context, userID int64, roomID string, ownerUserID int64) (domain.StatusResult, error) { +func (s *Service) GetStatus(ctx context.Context, userID int64, regionID int64, roomID string, ownerUserID int64) (domain.StatusResult, error) { if err := s.requireRepository(); err != nil { return domain.StatusResult{}, err } @@ -133,22 +136,20 @@ func (s *Service) GetStatus(ctx context.Context, userID int64, roomID string, ow PeriodEndMS: end.UnixMilli(), ServerTimeMS: now.UnixMilli(), } - config, exists, err := s.repository.GetRoomTurnoverRewardConfig(ctx) + result.Config = domain.Config{AppCode: appcode.FromContext(ctx), RegionID: regionID} + config, exists, err := s.effectiveConfig(ctx, regionID) if err != nil { return domain.StatusResult{}, err } if exists { config.Tiers = sortTiersForDisplay(config.Tiers) result.Config = config - } else { - // 未配置不是异常:后台首次进入或活动未上线时,H5 仍需要贡献周期时间和空档位来渲染默认态。 - result.Config = domain.Config{AppCode: appcode.FromContext(ctx)} } if result.RoomID == "" { - // 当前用户没有自己的房间时不能查询流水聚合;直接返回配置和周期,让 H5 显示无房间/未参与状态。 + // 当前用户没有自己的房间时不能查询流水聚合;直接返回当前区域实际命中的配置和周期。 return result, nil } - progress, found, err := s.repository.GetRoomTurnoverRewardProgress(ctx, result.RoomID, result.PeriodStartMS) + progress, found, err := s.repository.GetRoomTurnoverRewardProgress(ctx, regionID, result.RoomID, result.PeriodStartMS) if err != nil { return domain.StatusResult{}, err } @@ -159,7 +160,7 @@ func (s *Service) GetStatus(ctx context.Context, userID int64, roomID string, ow result.MatchedTier = tier result.ExpectedRewardCoinAmount = tier.RewardCoinAmount } - if settlement, found, err := s.repository.GetLatestRoomTurnoverRewardSettlement(ctx, result.RoomID); err != nil { + if settlement, found, err := s.repository.GetLatestRoomTurnoverRewardSettlement(ctx, regionID, result.RoomID); err != nil { return domain.StatusResult{}, err } else if found { // 最近结算不限定当前周期;周切换后当前流水会清零,但 H5 仍要展示上一周期是否已经发奖或失败。 @@ -188,6 +189,7 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil } contributionValue := gift.GetGiftValue() + regionID := gift.GetVisibleRegionId() if strings.TrimSpace(envelope.GetRoomId()) == "" || contributionValue <= 0 { // 房间流水奖励只消费 wallet 结算后的 HeatValue,也就是 RoomGiftSent.gift_value;原始扣费 coin_spent 不再参与达标口径。 return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil @@ -204,6 +206,7 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev EventID: envelope.GetEventId(), EventType: envelope.GetEventType(), AppCode: envelope.GetAppCode(), + RegionID: regionID, RoomID: strings.TrimSpace(envelope.GetRoomId()), CoinSpent: contributionValue, OccurredAtMS: occurred.UnixMilli(), @@ -236,14 +239,6 @@ func (s *Service) ProcessSettlementBatch(ctx context.Context, batchSize int) (do if batchSize <= 0 { batchSize = 100 } - config, exists, err := s.repository.GetRoomTurnoverRewardConfig(ctx) - if err != nil { - return domain.BatchResult{}, err - } - if !exists || !config.Enabled { - // 活动未配置或关闭时不创建 settlement,也不触碰历史 pending;运营重新开启后再由 cron 正常补偿。 - return domain.BatchResult{}, nil - } // 只结算上一个完整贡献周期;当前周期仍在持续接收 RoomGiftSent,不能提前冻结或清空。 periodStart, periodEnd := PreviousWeekWindow(s.now().UTC()) result := domain.BatchResult{} @@ -252,7 +247,21 @@ func (s *Service) ProcessSettlementBatch(ctx context.Context, batchSize int) (do return domain.BatchResult{}, err } nowMS := s.now().UTC().UnixMilli() + configs := make(map[int64]domain.Config) for _, progress := range progresses { + config, cached := configs[progress.RegionID] + if !cached { + var exists bool + config, exists, err = s.effectiveConfig(ctx, progress.RegionID) + if err != nil { + return result, err + } + if !exists || !config.Enabled { + // 扫描查询已只关联启用配置;这里再次校验用于收敛配置在扫描后被停用的并发窗口。 + continue + } + configs[progress.RegionID] = config + } // settlement 先固化当周流水和命中档位快照,后续运营改配置不会改变已经进入结算队列的周期结果。 settlement := s.settlementFromProgress(ctx, progress, periodEnd.UnixMilli(), activeTiers(config.Tiers), nowMS) if settlement.TierID <= 0 { @@ -340,7 +349,8 @@ func (s *Service) RetrySettlement(ctx context.Context, settlementID string) (dom func (s *Service) settlementFromProgress(ctx context.Context, progress domain.Progress, periodEndMS int64, tiers []domain.Tier, nowMS int64) domain.Settlement { tier, matched := MatchHighestTier(tiers, progress.CoinSpent) settlement := domain.Settlement{ - AppCode: appcode.FromContext(ctx), + AppCode: progress.AppCode, + RegionID: progress.RegionID, RoomID: progress.RoomID, PeriodStartMS: progress.PeriodStartMS, PeriodEndMS: periodEndMS, @@ -359,6 +369,15 @@ func (s *Service) settlementFromProgress(ctx context.Context, progress domain.Pr return settlement } +func (s *Service) effectiveConfig(ctx context.Context, regionID int64) (domain.Config, bool, error) { + // 具体区域配置优先;只要该区域已经存在配置(包括显式关闭),就不再回退全区域,避免全区域开关覆盖区域运营决策。 + config, exists, err := s.repository.GetRoomTurnoverRewardConfig(ctx, regionID) + if err != nil || exists || regionID == 0 { + return config, exists, err + } + return s.repository.GetRoomTurnoverRewardConfig(ctx, 0) +} + func (s *Service) grantSettlement(ctx context.Context, settlement domain.Settlement) (domain.Settlement, error) { if settlement.Status == domain.SettlementStatusGranted { // 发奖入口保持幂等,避免 cron 和人工 retry 同时进来时重复入账。 @@ -384,7 +403,7 @@ func (s *Service) grantSettlement(ctx context.Context, settlement domain.Settlem _ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli()) return domain.Settlement{}, err } - // 钱包 command_id 使用 app + 周期 + room_id,保证同一房间同一周期无论 cron 重跑还是 retry,都只产生一笔账务交易。 + // 钱包 command_id 使用 app + 区域 + 周期 + room_id;房间同周迁区时两份区域流水独立结算,cron 重跑和 retry 仍保持幂等。 resp, err := s.wallet.CreditRoomTurnoverReward(ctx, &walletv1.CreditRoomTurnoverRewardRequest{ CommandId: settlement.WalletCommandID, AppCode: appcode.FromContext(ctx), @@ -512,6 +531,10 @@ func sortTiersForDisplay(tiers []domain.Tier) []domain.Tier { } func validateConfig(config domain.Config) error { + if config.RegionID < 0 { + // region_id=0 是全区域默认配置;正数是覆盖该区域的配置。 + return xerr.New(xerr.InvalidArgument, "region_id must not be negative") + } if config.UpdatedByAdminID <= 0 { return xerr.New(xerr.InvalidArgument, "operator_admin_id is required") } diff --git a/services/activity-service/internal/service/roomturnoverreward/service_test.go b/services/activity-service/internal/service/roomturnoverreward/service_test.go index 2a4636e7..c80d2c44 100644 --- a/services/activity-service/internal/service/roomturnoverreward/service_test.go +++ b/services/activity-service/internal/service/roomturnoverreward/service_test.go @@ -134,8 +134,9 @@ func TestHandleRoomEventConsumesGiftValueAsHeatValue(t *testing.T) { svc := New(repo, nil, nil) svc.SetClock(func() time.Time { return mustParseTime(t, "2026-06-03T12:00:00Z") }) body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{ - GiftValue: 900, - CoinSpent: 1200, + GiftValue: 900, + CoinSpent: 1200, + VisibleRegionId: 12, }) if err != nil { t.Fatalf("marshal gift: %v", err) @@ -154,7 +155,7 @@ func TestHandleRoomEventConsumesGiftValueAsHeatValue(t *testing.T) { if result.Status != domain.EventStatusConsumed { t.Fatalf("unexpected result: %+v", result) } - if repo.event.CoinSpent != 900 { + if repo.event.CoinSpent != 900 || repo.event.RegionID != 12 { t.Fatalf("room turnover reward must consume gift_value/HeatValue, got %+v", repo.event) } if got := time.UnixMilli(repo.periodStartMS).UTC().Format(time.RFC3339); got != "2026-06-01T01:00:00Z" { @@ -162,6 +163,38 @@ func TestHandleRoomEventConsumesGiftValueAsHeatValue(t *testing.T) { } } +func TestGetStatusFallsBackToGlobalConfigUnlessRegionOverrides(t *testing.T) { + repo := &roomGiftEventRepository{configs: map[int64]domain.Config{ + 0: {RegionID: 0, Enabled: true, Tiers: []domain.Tier{{TierID: 1, Status: domain.TierStatusActive, ThresholdCoinSpent: 100, RewardCoinAmount: 10}}}, + }} + svc := New(repo, nil, nil) + svc.SetClock(func() time.Time { return mustParseTime(t, "2026-06-03T12:00:00Z") }) + + status, err := svc.GetStatus(appcode.WithContext(context.Background(), "lalu"), 42, 12, "", 42) + if err != nil { + t.Fatalf("GetStatus returned error: %v", err) + } + if status.Config.RegionID != 0 || !status.Config.Enabled { + t.Fatalf("region without override must use global config, got %+v", status.Config) + } + if len(repo.configLookups) != 2 || repo.configLookups[0] != 12 || repo.configLookups[1] != 0 { + t.Fatalf("expected exact-region then global lookup, got %v", repo.configLookups) + } + + repo.configs[12] = domain.Config{RegionID: 12, Enabled: false} + repo.configLookups = nil + status, err = svc.GetStatus(appcode.WithContext(context.Background(), "lalu"), 42, 12, "", 42) + if err != nil { + t.Fatalf("GetStatus with override returned error: %v", err) + } + if status.Config.RegionID != 12 || status.Config.Enabled { + t.Fatalf("explicit disabled regional config must block global fallback, got %+v", status.Config) + } + if len(repo.configLookups) != 1 || repo.configLookups[0] != 12 { + t.Fatalf("regional override should stop fallback lookup, got %v", repo.configLookups) + } +} + func TestGrantSettlementCreatesSystemNoticeBeforeMarkGranted(t *testing.T) { repo := &retryBoundaryRepository{ settlement: roomTurnoverRewardGrantableSettlement(), @@ -230,6 +263,7 @@ func roomTurnoverRewardGrantableSettlement() domain.Settlement { return domain.Settlement{ SettlementID: "settlement-room-turnover-1", AppCode: "lalu", + RegionID: 12, RoomID: "room-1", OwnerUserID: 42, PeriodStartMS: mustUnixMilli("2026-06-01T01:00:00Z"), @@ -272,10 +306,14 @@ type roomGiftEventRepository struct { event domain.RoomGiftEvent periodStartMS int64 periodEndMS int64 + configs map[int64]domain.Config + configLookups []int64 } -func (r *roomGiftEventRepository) GetRoomTurnoverRewardConfig(context.Context) (domain.Config, bool, error) { - return domain.Config{}, false, nil +func (r *roomGiftEventRepository) GetRoomTurnoverRewardConfig(_ context.Context, regionID int64) (domain.Config, bool, error) { + r.configLookups = append(r.configLookups, regionID) + config, exists := r.configs[regionID] + return config, exists, nil } func (r *roomGiftEventRepository) UpdateRoomTurnoverRewardConfig(context.Context, domain.Config, int64) (domain.Config, error) { @@ -289,11 +327,11 @@ func (r *roomGiftEventRepository) ConsumeRoomTurnoverGiftEvent(_ context.Context return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusConsumed}, nil } -func (r *roomGiftEventRepository) GetRoomTurnoverRewardProgress(context.Context, string, int64) (domain.Progress, bool, error) { +func (r *roomGiftEventRepository) GetRoomTurnoverRewardProgress(context.Context, int64, string, int64) (domain.Progress, bool, error) { return domain.Progress{}, false, nil } -func (r *roomGiftEventRepository) GetLatestRoomTurnoverRewardSettlement(context.Context, string) (domain.Settlement, bool, error) { +func (r *roomGiftEventRepository) GetLatestRoomTurnoverRewardSettlement(context.Context, int64, string) (domain.Settlement, bool, error) { return domain.Settlement{}, false, nil } @@ -329,7 +367,7 @@ func (r *roomGiftEventRepository) MarkRoomTurnoverRewardSettlementFailed(context return nil } -func (r *retryBoundaryRepository) GetRoomTurnoverRewardConfig(context.Context) (domain.Config, bool, error) { +func (r *retryBoundaryRepository) GetRoomTurnoverRewardConfig(context.Context, int64) (domain.Config, bool, error) { panic("unexpected GetRoomTurnoverRewardConfig") } @@ -341,11 +379,11 @@ func (r *retryBoundaryRepository) ConsumeRoomTurnoverGiftEvent(context.Context, panic("unexpected ConsumeRoomTurnoverGiftEvent") } -func (r *retryBoundaryRepository) GetRoomTurnoverRewardProgress(context.Context, string, int64) (domain.Progress, bool, error) { +func (r *retryBoundaryRepository) GetRoomTurnoverRewardProgress(context.Context, int64, string, int64) (domain.Progress, bool, error) { panic("unexpected GetRoomTurnoverRewardProgress") } -func (r *retryBoundaryRepository) GetLatestRoomTurnoverRewardSettlement(context.Context, string) (domain.Settlement, bool, error) { +func (r *retryBoundaryRepository) GetLatestRoomTurnoverRewardSettlement(context.Context, int64, string) (domain.Settlement, bool, error) { panic("unexpected GetLatestRoomTurnoverRewardSettlement") } diff --git a/services/activity-service/internal/storage/mysql/agency_opening_migration.go b/services/activity-service/internal/storage/mysql/agency_opening_migration.go index 8f61ae36..880428b6 100644 --- a/services/activity-service/internal/storage/mysql/agency_opening_migration.go +++ b/services/activity-service/internal/storage/mysql/agency_opening_migration.go @@ -6,6 +6,7 @@ func (r *Repository) ensureAgencyOpeningTables(ctx context.Context) error { statements := []string{ `CREATE TABLE IF NOT EXISTS agency_opening_cycles ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '配置区域 ID;0 表示全区域默认活动', cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID', activity_code VARCHAR(64) NOT NULL DEFAULT 'agency_opening' COMMENT '活动编码', title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '周期标题', @@ -22,7 +23,7 @@ func (r *Repository) ensureAgencyOpeningTables(ctx context.Context) error { created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, cycle_id), - KEY idx_agency_opening_cycle_current (app_code, activity_code, status, start_ms, end_ms), + KEY idx_agency_opening_cycle_current (app_code, region_id, activity_code, status, start_ms, end_ms), KEY idx_agency_opening_cycle_due (app_code, activity_code, status, end_ms, lock_until_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业活动周期'`, `CREATE TABLE IF NOT EXISTS agency_opening_cycle_rewards ( @@ -37,6 +38,7 @@ func (r *Repository) ensureAgencyOpeningTables(ctx context.Context) error { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业流水奖励档位'`, `CREATE TABLE IF NOT EXISTS agency_opening_applications ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '申请时代理实际区域 ID', application_id VARCHAR(96) NOT NULL COMMENT '申请 ID', cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID', agency_id BIGINT NOT NULL COMMENT '代理 ID', @@ -66,7 +68,8 @@ func (r *Repository) ensureAgencyOpeningTables(ctx context.Context) error { KEY idx_agency_opening_application_owner (app_code, agency_owner_user_id, applied_at_ms), KEY idx_agency_opening_agency_cycle (app_code, cycle_id, agency_id), KEY idx_agency_opening_application_rank (app_code, cycle_id, score_coin_amount, applied_at_ms, agency_id), - KEY idx_agency_opening_application_status (app_code, cycle_id, status, updated_at_ms) + KEY idx_agency_opening_application_status (app_code, cycle_id, status, updated_at_ms), + KEY idx_agency_opening_application_region (app_code, region_id, updated_at_ms, application_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业申请和结算事实'`, `CREATE TABLE IF NOT EXISTS agency_opening_score_events ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', @@ -78,6 +81,7 @@ func (r *Repository) ensureAgencyOpeningTables(ctx context.Context) error { score_delta BIGINT NOT NULL COMMENT '本次房间贡献值 HeatValue', occurred_at_ms BIGINT NOT NULL COMMENT '事件发生时间,UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时房间区域 ID', PRIMARY KEY (app_code, source_event_id), KEY idx_agency_opening_score_event_application (app_code, application_id, occurred_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业流水事件幂等表'`, @@ -87,12 +91,56 @@ func (r *Repository) ensureAgencyOpeningTables(ctx context.Context) error { return err } } + if err := r.ensureAgencyOpeningRegionSchema(ctx); err != nil { + return err + } if err := r.ensureAgencyOpeningRewardTierColumns(ctx); err != nil { return err } return r.ensureAgencyOpeningApplicationOwnerOnceIndex(ctx) } +func (r *Repository) ensureAgencyOpeningRegionSchema(ctx context.Context) error { + // 历史活动回填为 0 后继续作为全区域默认活动;申请和积分事件仍固化代理的实际区域。 + additions := []struct { + table string + statement string + }{ + {"agency_opening_cycles", `ALTER TABLE agency_opening_cycles ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '配置区域 ID;0 表示全区域默认活动', ALGORITHM=INSTANT`}, + {"agency_opening_applications", `ALTER TABLE agency_opening_applications ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '申请时代理实际区域 ID', ALGORITHM=INSTANT`}, + {"agency_opening_score_events", `ALTER TABLE agency_opening_score_events ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时房间区域 ID', ALGORITHM=INSTANT`}, + } + for _, addition := range additions { + exists, err := r.columnExists(ctx, addition.table, "region_id") + if err != nil { + return err + } + if !exists { + if _, err := r.db.ExecContext(ctx, addition.statement); err != nil { + return err + } + } + } + currentIndex := []string{"app_code", "region_id", "activity_code", "status", "start_ms", "end_ms"} + if matches, err := r.indexColumnsMatch(ctx, "agency_opening_cycles", "idx_agency_opening_cycle_current", currentIndex); err != nil { + return err + } else if !matches { + if _, err := r.db.ExecContext(ctx, `ALTER TABLE agency_opening_cycles DROP INDEX idx_agency_opening_cycle_current, ADD KEY idx_agency_opening_cycle_current (app_code, region_id, activity_code, status, start_ms, end_ms), ALGORITHM=INPLACE, LOCK=NONE`); err != nil { + return err + } + } + regionIndexExists, err := r.indexExists(ctx, "agency_opening_applications", "idx_agency_opening_application_region") + if err != nil { + return err + } + if !regionIndexExists { + if _, err := r.db.ExecContext(ctx, `ALTER TABLE agency_opening_applications ADD KEY idx_agency_opening_application_region (app_code, region_id, updated_at_ms, application_id), ALGORITHM=INPLACE, LOCK=NONE`); err != nil { + return err + } + } + return nil +} + func (r *Repository) ensureAgencyOpeningRewardTierColumns(ctx context.Context) error { if exists, err := r.columnExists(ctx, "agency_opening_cycle_rewards", "threshold_coin_spent"); err != nil { return err diff --git a/services/activity-service/internal/storage/mysql/agency_opening_repository.go b/services/activity-service/internal/storage/mysql/agency_opening_repository.go index 6442cb52..8b03c79b 100644 --- a/services/activity-service/internal/storage/mysql/agency_opening_repository.go +++ b/services/activity-service/internal/storage/mysql/agency_opening_repository.go @@ -30,7 +30,7 @@ func (r *Repository) ListAgencyOpeningCycles(ctx context.Context, query domain.L page = 1 } rows, err := r.db.QueryContext(ctx, ` - SELECT app_code, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days, + SELECT app_code, region_id, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days, created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms FROM agency_opening_cycles `+where+` ORDER BY start_ms DESC, cycle_id DESC @@ -91,8 +91,15 @@ func (r *Repository) UpsertAgencyOpeningCycle(ctx context.Context, command domai cycle.CycleID = idgen.New("aopen") cycle.CreatedAtMS = nowMS cycle.CreatedByAdminID = command.OperatorAdminID - } else if _, err := r.getAgencyOpeningCycle(ctx, tx, cycle.CycleID); err != nil { - return domain.Cycle{}, false, err + } else { + stored, getErr := r.getAgencyOpeningCycle(ctx, tx, cycle.CycleID) + if getErr != nil { + return domain.Cycle{}, false, getErr + } + if stored.RegionID != cycle.RegionID { + // 申请和计分事件都持有周期区域快照;周期创建后改区域会让既有申请与周期失联,因此区域只能通过新建周期调整。 + return domain.Cycle{}, false, xerr.New(xerr.Conflict, "agency opening cycle region cannot be changed") + } } if !created && command.RequireNewRecord { return domain.Cycle{}, false, xerr.New(xerr.InvalidArgument, "cycle_id already exists") @@ -105,18 +112,18 @@ func (r *Repository) UpsertAgencyOpeningCycle(ctx context.Context, command domai if created { _, err = tx.ExecContext(ctx, ` INSERT INTO agency_opening_cycles ( - app_code, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days, + app_code, region_id, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days, created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, - cycle.AppCode, cycle.CycleID, cycle.ActivityCode, cycle.Title, cycle.Status, cycle.StartMS, cycle.EndMS, cycle.MinHostCount, cycle.MaxAgencyAgeDays, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, + cycle.AppCode, cycle.RegionID, cycle.CycleID, cycle.ActivityCode, cycle.Title, cycle.Status, cycle.StartMS, cycle.EndMS, cycle.MinHostCount, cycle.MaxAgencyAgeDays, cycle.CreatedByAdminID, cycle.UpdatedByAdminID, cycle.CreatedAtMS, cycle.UpdatedAtMS) } else { _, err = tx.ExecContext(ctx, ` UPDATE agency_opening_cycles - SET title = ?, status = ?, start_ms = ?, end_ms = ?, min_host_count = ?, max_agency_age_days = ?, + SET region_id = ?, title = ?, status = ?, start_ms = ?, end_ms = ?, min_host_count = ?, max_agency_age_days = ?, updated_by_admin_id = ?, updated_at_ms = ? WHERE app_code = ? AND cycle_id = ?`, - cycle.Title, cycle.Status, cycle.StartMS, cycle.EndMS, cycle.MinHostCount, cycle.MaxAgencyAgeDays, + cycle.RegionID, cycle.Title, cycle.Status, cycle.StartMS, cycle.EndMS, cycle.MinHostCount, cycle.MaxAgencyAgeDays, cycle.UpdatedByAdminID, cycle.UpdatedAtMS, cycle.AppCode, cycle.CycleID) } if err != nil { @@ -170,17 +177,26 @@ func (r *Repository) SetAgencyOpeningCycleStatus(ctx context.Context, cycleID st return r.GetAgencyOpeningCycle(ctx, cycleID) } -func (r *Repository) FindCurrentAgencyOpeningCycle(ctx context.Context, nowMS int64) (domain.Cycle, bool, error) { +func (r *Repository) FindCurrentAgencyOpeningCycle(ctx context.Context, regionID int64, nowMS int64) (domain.Cycle, bool, error) { if r == nil || r.db == nil { return domain.Cycle{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") } + cycle, found, err := r.findAgencyOpeningCycleForRegion(ctx, regionID, nowMS) + if err != nil || found { + return cycle, found, err + } + // 没有具体区域周期时回退全区域周期;拆成两次索引点查,避免 OR 条件破坏 current 索引前缀。 + return r.findAgencyOpeningCycleForRegion(ctx, 0, nowMS) +} + +func (r *Repository) findAgencyOpeningCycleForRegion(ctx context.Context, regionID int64, nowMS int64) (domain.Cycle, bool, error) { cycle, err := scanAgencyOpeningCycle(r.db.QueryRowContext(ctx, ` - SELECT app_code, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days, + SELECT app_code, region_id, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days, created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms FROM agency_opening_cycles - WHERE app_code = ? AND activity_code = ? AND status = ? AND start_ms <= ? AND end_ms > ? + WHERE app_code = ? AND region_id = ? AND activity_code = ? AND status = ? AND start_ms <= ? AND end_ms > ? ORDER BY start_ms DESC, updated_at_ms DESC - LIMIT 1`, appcode.FromContext(ctx), domain.ActivityCode, domain.StatusActive, nowMS, nowMS)) + LIMIT 1`, appcode.FromContext(ctx), regionID, domain.ActivityCode, domain.StatusActive, nowMS, nowMS)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return domain.Cycle{}, false, nil @@ -215,11 +231,11 @@ func (r *Repository) CreateAgencyOpeningApplication(ctx context.Context, cycle d applicationID := idgen.New("aopenapp") result, err := r.db.ExecContext(ctx, ` INSERT IGNORE INTO agency_opening_applications ( - app_code, application_id, cycle_id, agency_id, agency_owner_user_id, agency_name, host_count, agency_created_at_ms, + app_code, region_id, application_id, cycle_id, agency_id, agency_owner_user_id, agency_name, host_count, agency_created_at_ms, status, score_coin_amount, reward_rank_no, reward_threshold_coin_spent, reward_coin_amount, wallet_command_id, wallet_transaction_id, failure_reason, applied_at_ms, approved_at_ms, score_start_ms, score_end_ms, reviewed_by_admin_id, granted_at_ms, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, 0, '', '', '', ?, 0, 0, 0, 0, 0, ?, ?)`, - appcode.FromContext(ctx), applicationID, cycle.CycleID, agency.AgencyID, agency.OwnerUserID, agency.Name, agency.HostCount, agency.AgencyCreatedAtMS, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, 0, '', '', '', ?, 0, 0, 0, 0, 0, ?, ?)`, + appcode.FromContext(ctx), agency.RegionID, applicationID, cycle.CycleID, agency.AgencyID, agency.OwnerUserID, agency.Name, agency.HostCount, agency.AgencyCreatedAtMS, domain.ApplicationStatusApplied, nowMS, nowMS, nowMS) if err != nil { return domain.Application{}, false, err @@ -244,7 +260,7 @@ func (r *Repository) ApproveAgencyOpeningApplication(ctx context.Context, applic err = tx.QueryRowContext(ctx, agencyOpeningApplicationSelect()+` WHERE app_code = ? AND application_id = ? FOR UPDATE`, appcode.FromContext(ctx), strings.TrimSpace(applicationID)).Scan( - &app.AppCode, &app.ApplicationID, &app.CycleID, &app.AgencyID, &app.AgencyOwnerUserID, &app.AgencyName, + &app.AppCode, &app.RegionID, &app.ApplicationID, &app.CycleID, &app.AgencyID, &app.AgencyOwnerUserID, &app.AgencyName, &app.HostCount, &app.AgencyCreatedAtMS, &app.Status, &app.ScoreCoinAmount, &app.RewardRankNo, &app.RewardThresholdCoin, &app.RewardCoinAmount, &app.WalletCommandID, &app.WalletTransactionID, &app.FailureReason, &app.AppliedAtMS, &app.ApprovedAtMS, &app.ScoreStartMS, &app.ScoreEndMS, &app.ReviewedByAdminID, &app.GrantedAtMS, &app.CreatedAtMS, &app.UpdatedAtMS) @@ -337,17 +353,18 @@ func (r *Repository) ConsumeAgencyOpeningGiftEvent(ctx context.Context, event do defer tx.Rollback() var app domain.Application err = tx.QueryRowContext(ctx, ` - SELECT app.app_code, app.application_id, app.cycle_id, app.agency_id, app.agency_owner_user_id, app.agency_name, app.host_count, app.agency_created_at_ms, + SELECT app.app_code, app.region_id, app.application_id, app.cycle_id, app.agency_id, app.agency_owner_user_id, app.agency_name, app.host_count, app.agency_created_at_ms, app.status, app.score_coin_amount, app.reward_rank_no, app.reward_threshold_coin_spent, app.reward_coin_amount, app.wallet_command_id, app.wallet_transaction_id, app.failure_reason, app.applied_at_ms, app.approved_at_ms, app.score_start_ms, app.score_end_ms, app.reviewed_by_admin_id, app.granted_at_ms, app.created_at_ms, app.updated_at_ms FROM agency_opening_applications app - INNER JOIN agency_opening_cycles cycle ON cycle.app_code = app.app_code AND cycle.cycle_id = app.cycle_id - WHERE app.app_code = ? AND app.agency_owner_user_id = ? AND app.status = ? AND app.score_start_ms <= ? AND app.score_end_ms > ? + INNER JOIN agency_opening_cycles cycle + ON cycle.app_code = app.app_code AND cycle.cycle_id = app.cycle_id AND cycle.region_id IN (0, app.region_id) + WHERE app.app_code = ? AND (? = 0 OR app.region_id = ?) AND app.agency_owner_user_id = ? AND app.status = ? AND app.score_start_ms <= ? AND app.score_end_ms > ? AND cycle.activity_code = ? AND cycle.status IN (?, ?) AND cycle.start_ms <= ? AND cycle.end_ms > ? ORDER BY app.applied_at_ms DESC - LIMIT 1`, appcode.FromContext(ctx), event.RoomOwnerUserID, domain.ApplicationStatusApproved, event.OccurredAtMS, event.OccurredAtMS, + LIMIT 1`, appcode.FromContext(ctx), event.RegionID, event.RegionID, event.RoomOwnerUserID, domain.ApplicationStatusApproved, event.OccurredAtMS, event.OccurredAtMS, domain.ActivityCode, domain.StatusActive, domain.StatusSettling, event.OccurredAtMS, event.OccurredAtMS).Scan( - &app.AppCode, &app.ApplicationID, &app.CycleID, &app.AgencyID, &app.AgencyOwnerUserID, &app.AgencyName, + &app.AppCode, &app.RegionID, &app.ApplicationID, &app.CycleID, &app.AgencyID, &app.AgencyOwnerUserID, &app.AgencyName, &app.HostCount, &app.AgencyCreatedAtMS, &app.Status, &app.ScoreCoinAmount, &app.RewardRankNo, &app.RewardThresholdCoin, &app.RewardCoinAmount, &app.WalletCommandID, &app.WalletTransactionID, &app.FailureReason, &app.AppliedAtMS, &app.ApprovedAtMS, &app.ScoreStartMS, &app.ScoreEndMS, &app.ReviewedByAdminID, &app.GrantedAtMS, &app.CreatedAtMS, &app.UpdatedAtMS) @@ -359,9 +376,9 @@ func (r *Repository) ConsumeAgencyOpeningGiftEvent(ctx context.Context, event do } result, err := tx.ExecContext(ctx, ` INSERT IGNORE INTO agency_opening_score_events ( - app_code, source_event_id, cycle_id, application_id, agency_id, target_user_id, score_delta, occurred_at_ms, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - appcode.FromContext(ctx), event.EventID, app.CycleID, app.ApplicationID, app.AgencyID, event.TargetUserID, event.CoinSpent, event.OccurredAtMS, nowMS) + app_code, source_event_id, cycle_id, application_id, agency_id, target_user_id, score_delta, occurred_at_ms, created_at_ms, region_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), event.EventID, app.CycleID, app.ApplicationID, app.AgencyID, event.TargetUserID, event.CoinSpent, event.OccurredAtMS, nowMS, app.RegionID) if err != nil { return domain.EventResult{}, err } @@ -402,7 +419,7 @@ func (r *Repository) ClaimDueAgencyOpeningCycles(ctx context.Context, workerID s return nil, err } rows, err := r.db.QueryContext(ctx, ` - SELECT app_code, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days, + SELECT app_code, region_id, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days, created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms FROM agency_opening_cycles WHERE app_code = ? AND activity_code = ? AND status = ? AND locked_by = ? AND lock_until_ms > ? @@ -549,7 +566,7 @@ func (r *Repository) getAgencyOpeningCycle(ctx context.Context, q interface { return domain.Cycle{}, xerr.New(xerr.InvalidArgument, "cycle_id is required") } return scanAgencyOpeningCycle(q.QueryRowContext(ctx, ` - SELECT app_code, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days, + SELECT app_code, region_id, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days, created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms FROM agency_opening_cycles WHERE app_code = ? AND cycle_id = ?`, appcode.FromContext(ctx), cycleID)) @@ -599,8 +616,8 @@ func (r *Repository) checkAgencyOpeningCycleOverlap(ctx context.Context, q inter var count int64 if err := q.QueryRowContext(ctx, ` SELECT COUNT(*) FROM agency_opening_cycles - WHERE app_code = ? AND activity_code = ? AND status = ? AND cycle_id <> ? AND start_ms < ? AND end_ms > ?`, - appcode.FromContext(ctx), domain.ActivityCode, domain.StatusActive, cycle.CycleID, cycle.EndMS, cycle.StartMS).Scan(&count); err != nil { + WHERE app_code = ? AND region_id = ? AND activity_code = ? AND status = ? AND cycle_id <> ? AND start_ms < ? AND end_ms > ?`, + appcode.FromContext(ctx), cycle.RegionID, domain.ActivityCode, domain.StatusActive, cycle.CycleID, cycle.EndMS, cycle.StartMS).Scan(&count); err != nil { return err } if count > 0 { @@ -612,6 +629,10 @@ func (r *Repository) checkAgencyOpeningCycleOverlap(ctx context.Context, q inter func agencyOpeningCycleWhere(ctx context.Context, query domain.ListQuery) (string, []any) { where := []string{"app_code = ?", "activity_code = ?"} args := []any{appcode.FromContext(ctx), domain.ActivityCode} + if query.RegionID > 0 { + where = append(where, "region_id = ?") + args = append(args, query.RegionID) + } if strings.TrimSpace(query.Status) != "" { where = append(where, "status = ?") args = append(args, strings.TrimSpace(query.Status)) @@ -630,6 +651,10 @@ func agencyOpeningCycleWhere(ctx context.Context, query domain.ListQuery) (strin func agencyOpeningApplicationWhere(ctx context.Context, query domain.ApplicationQuery) (string, []any) { where := []string{"app_code = ?"} args := []any{appcode.FromContext(ctx)} + if query.RegionID > 0 { + where = append(where, "region_id = ?") + args = append(args, query.RegionID) + } if strings.TrimSpace(query.CycleID) != "" { where = append(where, "cycle_id = ?") args = append(args, strings.TrimSpace(query.CycleID)) @@ -650,14 +675,14 @@ func agencyOpeningApplicationWhere(ctx context.Context, query domain.Application } func agencyOpeningApplicationSelect() string { - return `SELECT app_code, application_id, cycle_id, agency_id, agency_owner_user_id, agency_name, host_count, agency_created_at_ms, + return `SELECT app_code, region_id, application_id, cycle_id, agency_id, agency_owner_user_id, agency_name, host_count, agency_created_at_ms, status, score_coin_amount, reward_rank_no, reward_threshold_coin_spent, reward_coin_amount, wallet_command_id, wallet_transaction_id, failure_reason, applied_at_ms, approved_at_ms, score_start_ms, score_end_ms, reviewed_by_admin_id, granted_at_ms, created_at_ms, updated_at_ms FROM agency_opening_applications` } func scanAgencyOpeningCycle(row interface{ Scan(...any) error }) (domain.Cycle, error) { var item domain.Cycle - err := row.Scan(&item.AppCode, &item.CycleID, &item.ActivityCode, &item.Title, &item.Status, &item.StartMS, &item.EndMS, + err := row.Scan(&item.AppCode, &item.RegionID, &item.CycleID, &item.ActivityCode, &item.Title, &item.Status, &item.StartMS, &item.EndMS, &item.MinHostCount, &item.MaxAgencyAgeDays, &item.CreatedByAdminID, &item.UpdatedByAdminID, &item.SettledAtMS, &item.CreatedAtMS, &item.UpdatedAtMS) return item, err @@ -665,7 +690,7 @@ func scanAgencyOpeningCycle(row interface{ Scan(...any) error }) (domain.Cycle, func scanAgencyOpeningApplication(row interface{ Scan(...any) error }) (domain.Application, error) { var item domain.Application - err := row.Scan(&item.AppCode, &item.ApplicationID, &item.CycleID, &item.AgencyID, &item.AgencyOwnerUserID, &item.AgencyName, + err := row.Scan(&item.AppCode, &item.RegionID, &item.ApplicationID, &item.CycleID, &item.AgencyID, &item.AgencyOwnerUserID, &item.AgencyName, &item.HostCount, &item.AgencyCreatedAtMS, &item.Status, &item.ScoreCoinAmount, &item.RewardRankNo, &item.RewardThresholdCoin, &item.RewardCoinAmount, &item.WalletCommandID, &item.WalletTransactionID, &item.FailureReason, &item.AppliedAtMS, &item.ApprovedAtMS, &item.ScoreStartMS, &item.ScoreEndMS, &item.ReviewedByAdminID, &item.GrantedAtMS, &item.CreatedAtMS, &item.UpdatedAtMS) diff --git a/services/activity-service/internal/storage/mysql/agency_opening_repository_test.go b/services/activity-service/internal/storage/mysql/agency_opening_repository_test.go index e7f527e7..b4255d5b 100644 --- a/services/activity-service/internal/storage/mysql/agency_opening_repository_test.go +++ b/services/activity-service/internal/storage/mysql/agency_opening_repository_test.go @@ -30,9 +30,9 @@ func TestAgencyOpeningScoresOnlyAfterApprovalWithinWindow(t *testing.T) { ) _, err = repository.db.ExecContext(ctx, ` INSERT INTO agency_opening_cycles ( - app_code, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days, + app_code, region_id, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days, created_by_admin_id, updated_by_admin_id, settled_at_ms, locked_by, lock_until_ms, created_at_ms, updated_at_ms - ) VALUES ('lalu', ?, ?, 'window test', ?, 1, ?, 10, 30, 1, 1, 0, '', 0, ?, ?)`, + ) VALUES ('lalu', 12, ?, ?, 'window test', ?, 1, ?, 10, 30, 1, 1, 0, '', 0, ?, ?)`, cycleID, domain.ActivityCode, domain.StatusActive, nowMS+int64((48*60*60*1000)), nowMS, nowMS, ) if err != nil { @@ -40,11 +40,11 @@ func TestAgencyOpeningScoresOnlyAfterApprovalWithinWindow(t *testing.T) { } _, err = repository.db.ExecContext(ctx, ` INSERT INTO agency_opening_applications ( - app_code, application_id, cycle_id, agency_id, agency_owner_user_id, agency_name, host_count, agency_created_at_ms, + app_code, region_id, application_id, cycle_id, agency_id, agency_owner_user_id, agency_name, host_count, agency_created_at_ms, status, score_coin_amount, reward_rank_no, reward_threshold_coin_spent, reward_coin_amount, wallet_command_id, wallet_transaction_id, failure_reason, applied_at_ms, approved_at_ms, score_start_ms, score_end_ms, reviewed_by_admin_id, granted_at_ms, created_at_ms, updated_at_ms - ) VALUES ('lalu', ?, ?, 8001, ?, 'Window Agency', 12, 1, ?, 0, 0, 0, 0, '', '', '', ?, 0, 0, 0, 0, 0, ?, ?)`, + ) VALUES ('lalu', 12, ?, ?, 8001, ?, 'Window Agency', 12, 1, ?, 0, 0, 0, 0, '', '', '', ?, 0, 0, 0, 0, 0, ?, ?)`, applicationID, cycleID, ownerUserID, domain.ApplicationStatusApplied, nowMS-1000, nowMS, nowMS, ) if err != nil { @@ -53,6 +53,7 @@ func TestAgencyOpeningScoresOnlyAfterApprovalWithinWindow(t *testing.T) { beforeApproval, err := repository.ConsumeAgencyOpeningGiftEvent(ctx, domain.GiftEvent{ EventID: "gift-before-approval", + RegionID: 12, RoomOwnerUserID: ownerUserID, TargetUserID: 99, CoinSpent: 700, @@ -74,8 +75,24 @@ func TestAgencyOpeningScoresOnlyAfterApprovalWithinWindow(t *testing.T) { t.Fatalf("unexpected approved application: %+v", approved) } + otherRegion, err := repository.ConsumeAgencyOpeningGiftEvent(ctx, domain.GiftEvent{ + EventID: "gift-other-region", + RegionID: 13, + RoomOwnerUserID: ownerUserID, + TargetUserID: 100, + CoinSpent: 800, + OccurredAtMS: nowMS + 250, + }, nowMS+250) + if err != nil { + t.Fatalf("consume other-region gift: %v", err) + } + if otherRegion.Status != domain.EventStatusSkipped { + t.Fatalf("gift from another region must not score, got %s", otherRegion.Status) + } + insideWindow, err := repository.ConsumeAgencyOpeningGiftEvent(ctx, domain.GiftEvent{ EventID: "gift-inside-window", + RegionID: 12, RoomOwnerUserID: ownerUserID, TargetUserID: 100, CoinSpent: 900, @@ -90,6 +107,7 @@ func TestAgencyOpeningScoresOnlyAfterApprovalWithinWindow(t *testing.T) { afterWindow, err := repository.ConsumeAgencyOpeningGiftEvent(ctx, domain.GiftEvent{ EventID: "gift-after-window", + RegionID: 12, RoomOwnerUserID: ownerUserID, TargetUserID: 101, CoinSpent: 1200, diff --git a/services/activity-service/internal/storage/mysql/repository.go b/services/activity-service/internal/storage/mysql/repository.go index c1b8b5b4..d9224896 100644 --- a/services/activity-service/internal/storage/mysql/repository.go +++ b/services/activity-service/internal/storage/mysql/repository.go @@ -320,6 +320,42 @@ func (r *Repository) indexExists(ctx context.Context, table string, index string return count > 0, nil } +func (r *Repository) indexColumnsMatch(ctx context.Context, table string, index string, expected []string) (bool, error) { + database, err := r.currentDatabase(ctx) + if err != nil { + return false, err + } + rows, err := r.db.QueryContext(ctx, ` + SELECT COLUMN_NAME + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = ? + ORDER BY SEQ_IN_INDEX`, database, table, index) + if err != nil { + return false, err + } + defer rows.Close() + actual := make([]string, 0, len(expected)) + for rows.Next() { + var column string + if err := rows.Scan(&column); err != nil { + return false, err + } + actual = append(actual, column) + } + if err := rows.Err(); err != nil { + return false, err + } + if len(actual) != len(expected) { + return false, nil + } + for index := range expected { + if actual[index] != expected[index] { + return false, nil + } + } + return true, nil +} + func (r *Repository) currentDatabase(ctx context.Context) (string, error) { var database sql.NullString if err := r.db.QueryRowContext(ctx, `SELECT DATABASE()`).Scan(&database); err != nil { diff --git a/services/activity-service/internal/storage/mysql/room_turnover_reward_migration.go b/services/activity-service/internal/storage/mysql/room_turnover_reward_migration.go index 8bbe9bf1..fbffe9b4 100644 --- a/services/activity-service/internal/storage/mysql/room_turnover_reward_migration.go +++ b/services/activity-service/internal/storage/mysql/room_turnover_reward_migration.go @@ -6,15 +6,17 @@ func (r *Repository) ensureRoomTurnoverRewardTables(ctx context.Context) error { statements := []string{ `CREATE TABLE IF NOT EXISTS room_turnover_reward_configs ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '配置区域 ID;0 表示全区域默认配置', enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用', updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最后更新管理员', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', - PRIMARY KEY (app_code) + PRIMARY KEY (app_code, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励配置表'`, `CREATE TABLE IF NOT EXISTS room_turnover_reward_tiers ( tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '档位 ID', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '配置区域 ID;0 表示全区域默认配置', tier_code VARCHAR(64) NOT NULL COMMENT '档位编码', tier_name VARCHAR(128) NOT NULL COMMENT '档位名称', threshold_coin_spent BIGINT NOT NULL COMMENT '命中该档需要的房间贡献值 HeatValue', @@ -24,8 +26,8 @@ func (r *Repository) ensureRoomTurnoverRewardTables(ctx context.Context) error { created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (tier_id), - UNIQUE KEY uk_room_turnover_reward_tier_code (app_code, tier_code), - KEY idx_room_turnover_reward_tiers_match (app_code, status, threshold_coin_spent) + UNIQUE KEY uk_room_turnover_reward_tier_code (app_code, region_id, tier_code), + KEY idx_room_turnover_reward_tiers_match (app_code, region_id, status, threshold_coin_spent) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励档位表'`, `CREATE TABLE IF NOT EXISTS room_turnover_reward_event_consumption ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', @@ -38,11 +40,13 @@ func (r *Repository) ensureRoomTurnoverRewardTables(ctx context.Context) error { consumed_at_ms BIGINT NOT NULL COMMENT '消费时间,UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时房间区域 ID', PRIMARY KEY (app_code, event_id), KEY idx_room_turnover_reward_event_period (app_code, period_start_ms, room_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励事件消费幂等表'`, `CREATE TABLE IF NOT EXISTS room_turnover_reward_progress ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时房间区域 ID;0 表示全区域可见', room_id VARCHAR(96) NOT NULL COMMENT '房间 ID', period_start_ms BIGINT NOT NULL COMMENT 'UTC 周期开始', period_end_ms BIGINT NOT NULL COMMENT 'UTC 周期结束', @@ -50,11 +54,12 @@ func (r *Repository) ensureRoomTurnoverRewardTables(ctx context.Context) error { last_event_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最后事件时间', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', - PRIMARY KEY (app_code, room_id, period_start_ms), + PRIMARY KEY (app_code, region_id, room_id, period_start_ms), KEY idx_room_turnover_reward_progress_period (app_code, period_start_ms, coin_spent) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励周期聚合表'`, `CREATE TABLE IF NOT EXISTS room_turnover_reward_settlements ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '结算对应房间区域 ID;0 表示全区域可见', settlement_id VARCHAR(96) NOT NULL COMMENT '结算 ID', room_id VARCHAR(96) NOT NULL COMMENT '房间 ID', owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '结算收款房主', @@ -73,7 +78,8 @@ func (r *Repository) ensureRoomTurnoverRewardTables(ctx context.Context) error { created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, settlement_id), - UNIQUE KEY uk_room_turnover_reward_settlement_room_period (app_code, room_id, period_start_ms), + UNIQUE KEY uk_room_turnover_reward_settlement_room_period (app_code, region_id, room_id, period_start_ms), + KEY idx_room_turnover_reward_settlement_region (app_code, region_id, period_start_ms, created_at_ms), KEY idx_room_turnover_reward_settlement_status (app_code, status, period_start_ms, created_at_ms), KEY idx_room_turnover_reward_settlement_owner (app_code, owner_user_id, period_start_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励每周结算表'`, @@ -83,5 +89,66 @@ func (r *Repository) ensureRoomTurnoverRewardTables(ctx context.Context) error { return err } } + return r.ensureRoomTurnoverRewardRegionSchema(ctx) +} + +func (r *Repository) ensureRoomTurnoverRewardRegionSchema(ctx context.Context) error { + // 本地旧库的全 App 配置回填为 0 后继续作为全区域默认配置,避免区域能力上线时中断原有奖励。 + additions := []struct { + table string + statement string + }{ + {"room_turnover_reward_configs", `ALTER TABLE room_turnover_reward_configs ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '配置区域 ID;0 表示全区域默认配置', ALGORITHM=INSTANT`}, + {"room_turnover_reward_tiers", `ALTER TABLE room_turnover_reward_tiers ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '配置区域 ID;0 表示全区域默认配置', ALGORITHM=INSTANT`}, + {"room_turnover_reward_event_consumption", `ALTER TABLE room_turnover_reward_event_consumption ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时房间区域 ID', ALGORITHM=INSTANT`}, + {"room_turnover_reward_progress", `ALTER TABLE room_turnover_reward_progress ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时房间区域 ID', ALGORITHM=INSTANT`}, + {"room_turnover_reward_settlements", `ALTER TABLE room_turnover_reward_settlements ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '结算对应房间区域 ID', ALGORITHM=INSTANT`}, + } + for _, addition := range additions { + exists, err := r.columnExists(ctx, addition.table, "region_id") + if err != nil { + return err + } + if !exists { + if _, err := r.db.ExecContext(ctx, addition.statement); err != nil { + return err + } + } + } + + // 配置和流水业务键都必须包含 region_id。单条 ALTER 合并索引重建,避免本地旧库为同一张表重复扫描。 + indexMigrations := []struct { + table string + index string + columns []string + statement string + }{ + {"room_turnover_reward_configs", "PRIMARY", []string{"app_code", "region_id"}, `ALTER TABLE room_turnover_reward_configs DROP PRIMARY KEY, ADD PRIMARY KEY (app_code, region_id), ALGORITHM=INPLACE, LOCK=NONE`}, + {"room_turnover_reward_tiers", "uk_room_turnover_reward_tier_code", []string{"app_code", "region_id", "tier_code"}, `ALTER TABLE room_turnover_reward_tiers DROP INDEX uk_room_turnover_reward_tier_code, ADD UNIQUE KEY uk_room_turnover_reward_tier_code (app_code, region_id, tier_code), ALGORITHM=INPLACE, LOCK=NONE`}, + {"room_turnover_reward_tiers", "idx_room_turnover_reward_tiers_match", []string{"app_code", "region_id", "status", "threshold_coin_spent"}, `ALTER TABLE room_turnover_reward_tiers DROP INDEX idx_room_turnover_reward_tiers_match, ADD KEY idx_room_turnover_reward_tiers_match (app_code, region_id, status, threshold_coin_spent), ALGORITHM=INPLACE, LOCK=NONE`}, + {"room_turnover_reward_progress", "PRIMARY", []string{"app_code", "region_id", "room_id", "period_start_ms"}, `ALTER TABLE room_turnover_reward_progress DROP PRIMARY KEY, ADD PRIMARY KEY (app_code, region_id, room_id, period_start_ms), ALGORITHM=INPLACE, LOCK=NONE`}, + {"room_turnover_reward_settlements", "uk_room_turnover_reward_settlement_room_period", []string{"app_code", "region_id", "room_id", "period_start_ms"}, `ALTER TABLE room_turnover_reward_settlements DROP INDEX uk_room_turnover_reward_settlement_room_period, ADD UNIQUE KEY uk_room_turnover_reward_settlement_room_period (app_code, region_id, room_id, period_start_ms), ALGORITHM=INPLACE, LOCK=NONE`}, + } + for _, migration := range indexMigrations { + matches, err := r.indexColumnsMatch(ctx, migration.table, migration.index, migration.columns) + if err != nil { + return err + } + if !matches { + if _, err := r.db.ExecContext(ctx, migration.statement); err != nil { + return err + } + } + } + regionIndexExists, err := r.indexExists(ctx, "room_turnover_reward_settlements", "idx_room_turnover_reward_settlement_region") + if err != nil { + return err + } + if !regionIndexExists { + // 后台按区域分页时直接走 app_code + region_id 的有序索引,避免区域配置上线后退化成结算全表扫描。 + if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_turnover_reward_settlements ADD KEY idx_room_turnover_reward_settlement_region (app_code, region_id, period_start_ms, created_at_ms), ALGORITHM=INPLACE, LOCK=NONE`); err != nil { + return err + } + } return nil } diff --git a/services/activity-service/internal/storage/mysql/room_turnover_reward_repository.go b/services/activity-service/internal/storage/mysql/room_turnover_reward_repository.go index ced3f976..58e81c0e 100644 --- a/services/activity-service/internal/storage/mysql/room_turnover_reward_repository.go +++ b/services/activity-service/internal/storage/mysql/room_turnover_reward_repository.go @@ -14,13 +14,13 @@ import ( ) // GetRoomTurnoverRewardConfig 读取房间流水奖励当前配置;未配置是后台首屏正常状态。 -func (r *Repository) GetRoomTurnoverRewardConfig(ctx context.Context) (domain.Config, bool, error) { +func (r *Repository) GetRoomTurnoverRewardConfig(ctx context.Context, regionID int64) (domain.Config, bool, error) { if r == nil || r.db == nil { return domain.Config{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") } row := r.db.QueryRowContext(ctx, roomTurnoverRewardConfigSelectSQL()+` - WHERE app_code = ?`, - appcode.FromContext(ctx), + WHERE app_code = ? AND region_id = ?`, + appcode.FromContext(ctx), regionID, ) config, err := scanRoomTurnoverRewardConfig(row) if errors.Is(err, sql.ErrNoRows) { @@ -29,7 +29,7 @@ func (r *Repository) GetRoomTurnoverRewardConfig(ctx context.Context) (domain.Co if err != nil { return domain.Config{}, false, err } - tiers, err := r.listRoomTurnoverRewardTiers(ctx, nil) + tiers, err := r.listRoomTurnoverRewardTiers(ctx, nil, regionID) if err != nil { return domain.Config{}, false, err } @@ -50,20 +50,20 @@ func (r *Repository) UpdateRoomTurnoverRewardConfig(ctx context.Context, config if _, err := tx.ExecContext(ctx, ` INSERT INTO room_turnover_reward_configs ( - app_code, enabled, updated_by_admin_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?) + app_code, region_id, enabled, updated_by_admin_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_by_admin_id = VALUES(updated_by_admin_id), updated_at_ms = VALUES(updated_at_ms)`, - appcode.FromContext(ctx), config.Enabled, config.UpdatedByAdminID, nowMS, nowMS, + appcode.FromContext(ctx), config.RegionID, config.Enabled, config.UpdatedByAdminID, nowMS, nowMS, ); err != nil { return domain.Config{}, err } if _, err := tx.ExecContext(ctx, ` DELETE FROM room_turnover_reward_tiers - WHERE app_code = ?`, - appcode.FromContext(ctx), + WHERE app_code = ? AND region_id = ?`, + appcode.FromContext(ctx), config.RegionID, ); err != nil { return domain.Config{}, err } @@ -75,10 +75,10 @@ func (r *Repository) UpdateRoomTurnoverRewardConfig(ctx context.Context, config if tier.TierID > 0 { if _, err := tx.ExecContext(ctx, ` INSERT INTO room_turnover_reward_tiers ( - tier_id, app_code, tier_code, tier_name, threshold_coin_spent, + tier_id, app_code, region_id, tier_code, tier_name, threshold_coin_spent, reward_coin_amount, status, sort_order, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - tier.TierID, appcode.FromContext(ctx), tier.TierCode, tier.TierName, tier.ThresholdCoinSpent, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + tier.TierID, appcode.FromContext(ctx), config.RegionID, tier.TierCode, tier.TierName, tier.ThresholdCoinSpent, tier.RewardCoinAmount, tier.Status, tier.SortOrder, tier.CreatedAtMS, tier.UpdatedAtMS, ); err != nil { return domain.Config{}, err @@ -87,10 +87,10 @@ func (r *Repository) UpdateRoomTurnoverRewardConfig(ctx context.Context, config } result, err := tx.ExecContext(ctx, ` INSERT INTO room_turnover_reward_tiers ( - app_code, tier_code, tier_name, threshold_coin_spent, + app_code, region_id, tier_code, tier_name, threshold_coin_spent, reward_coin_amount, status, sort_order, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - appcode.FromContext(ctx), tier.TierCode, tier.TierName, tier.ThresholdCoinSpent, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), config.RegionID, tier.TierCode, tier.TierName, tier.ThresholdCoinSpent, tier.RewardCoinAmount, tier.Status, tier.SortOrder, tier.CreatedAtMS, tier.UpdatedAtMS, ) if err != nil { @@ -105,7 +105,7 @@ func (r *Repository) UpdateRoomTurnoverRewardConfig(ctx context.Context, config if err := tx.Commit(); err != nil { return domain.Config{}, err } - updated, exists, err := r.GetRoomTurnoverRewardConfig(ctx) + updated, exists, err := r.GetRoomTurnoverRewardConfig(ctx, config.RegionID) if err != nil { return domain.Config{}, err } @@ -129,9 +129,9 @@ func (r *Repository) ConsumeRoomTurnoverGiftEvent(ctx context.Context, event dom result, err := tx.ExecContext(ctx, ` INSERT IGNORE INTO room_turnover_reward_event_consumption ( app_code, event_id, event_type, room_id, period_start_ms, coin_spent, - status, consumed_at_ms, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, 'consumed', ?, ?, ?)`, - appcode.FromContext(ctx), event.EventID, event.EventType, event.RoomID, periodStartMS, event.CoinSpent, nowMS, nowMS, nowMS, + status, consumed_at_ms, created_at_ms, updated_at_ms, region_id + ) VALUES (?, ?, ?, ?, ?, ?, 'consumed', ?, ?, ?, ?)`, + appcode.FromContext(ctx), event.EventID, event.EventType, event.RoomID, periodStartMS, event.CoinSpent, nowMS, nowMS, nowMS, event.RegionID, ) if err != nil { return domain.EventResult{}, err @@ -150,15 +150,15 @@ func (r *Repository) ConsumeRoomTurnoverGiftEvent(ctx context.Context, event dom // progress 是 App H5 和结算任务共用的读模型;同一事务先落消费幂等,再按 UTC 周期原子累加房间流水。 if _, err := tx.ExecContext(ctx, ` INSERT INTO room_turnover_reward_progress ( - app_code, room_id, period_start_ms, period_end_ms, coin_spent, + app_code, region_id, room_id, period_start_ms, period_end_ms, coin_spent, last_event_at_ms, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE coin_spent = coin_spent + VALUES(coin_spent), period_end_ms = VALUES(period_end_ms), last_event_at_ms = GREATEST(last_event_at_ms, VALUES(last_event_at_ms)), updated_at_ms = VALUES(updated_at_ms)`, - appcode.FromContext(ctx), event.RoomID, periodStartMS, periodEndMS, event.CoinSpent, event.OccurredAtMS, nowMS, nowMS, + appcode.FromContext(ctx), event.RegionID, event.RoomID, periodStartMS, periodEndMS, event.CoinSpent, event.OccurredAtMS, nowMS, nowMS, ); err != nil { return domain.EventResult{}, err } @@ -169,13 +169,13 @@ func (r *Repository) ConsumeRoomTurnoverGiftEvent(ctx context.Context, event dom } // GetRoomTurnoverRewardProgress 返回指定房间和 UTC 周期的当前流水。 -func (r *Repository) GetRoomTurnoverRewardProgress(ctx context.Context, roomID string, periodStartMS int64) (domain.Progress, bool, error) { +func (r *Repository) GetRoomTurnoverRewardProgress(ctx context.Context, regionID int64, roomID string, periodStartMS int64) (domain.Progress, bool, error) { if r == nil || r.db == nil { return domain.Progress{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") } row := r.db.QueryRowContext(ctx, roomTurnoverRewardProgressSelectSQL()+` - WHERE app_code = ? AND room_id = ? AND period_start_ms = ?`, - appcode.FromContext(ctx), strings.TrimSpace(roomID), periodStartMS, + WHERE app_code = ? AND region_id = ? AND room_id = ? AND period_start_ms = ?`, + appcode.FromContext(ctx), regionID, strings.TrimSpace(roomID), periodStartMS, ) item, err := scanRoomTurnoverRewardProgress(row) if errors.Is(err, sql.ErrNoRows) { @@ -184,16 +184,16 @@ func (r *Repository) GetRoomTurnoverRewardProgress(ctx context.Context, roomID s return item, err == nil, err } -func (r *Repository) GetLatestRoomTurnoverRewardSettlement(ctx context.Context, roomID string) (domain.Settlement, bool, error) { +func (r *Repository) GetLatestRoomTurnoverRewardSettlement(ctx context.Context, regionID int64, roomID string) (domain.Settlement, bool, error) { if r == nil || r.db == nil { return domain.Settlement{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") } // 最新结算给 H5 展示上一周期结果;按 period_start_ms 倒序而不是 status 排序,保证失败和成功都能被用户看到。 row := r.db.QueryRowContext(ctx, roomTurnoverRewardSettlementSelectSQL()+` - WHERE app_code = ? AND room_id = ? + WHERE app_code = ? AND region_id = ? AND room_id = ? ORDER BY period_start_ms DESC, created_at_ms DESC LIMIT 1`, - appcode.FromContext(ctx), strings.TrimSpace(roomID), + appcode.FromContext(ctx), regionID, strings.TrimSpace(roomID), ) item, err := scanRoomTurnoverRewardSettlement(row) if errors.Is(err, sql.ErrNoRows) { @@ -210,6 +210,10 @@ func (r *Repository) ListRoomTurnoverRewardSettlements(ctx context.Context, quer // 后台列表只在 settlement 表上分页筛选;流水明细来自周期聚合快照,避免管理页回扫礼物事件。 where := []string{"app_code = ?"} args := []any{appcode.FromContext(ctx)} + if query.RegionID > 0 { + where = append(where, "region_id = ?") + args = append(args, query.RegionID) + } if query.Status != "" { where = append(where, "status = ?") args = append(args, query.Status) @@ -262,14 +266,22 @@ func (r *Repository) ListUnsettledRoomTurnoverRewardProgress(ctx context.Context if limit <= 0 { limit = 100 } - // LEFT JOIN settlement 只认领还没有结算快照的 progress;唯一键再兜底一次,防止并发 worker 同时扫描到同一房间。 + // 具体区域配置存在时(即使关闭)优先于全区域配置;仅在区域配置不存在时回退 region_id=0,避免 disabled 区域被全局开关重新启用。 rows, err := r.db.QueryContext(ctx, roomTurnoverRewardProgressSelectSQL()+` + LEFT JOIN room_turnover_reward_configs regional_config + ON regional_config.app_code = progress.app_code + AND regional_config.region_id = progress.region_id + LEFT JOIN room_turnover_reward_configs global_config + ON global_config.app_code = progress.app_code + AND global_config.region_id = 0 LEFT JOIN room_turnover_reward_settlements settlement ON settlement.app_code = progress.app_code + AND settlement.region_id = progress.region_id AND settlement.room_id = progress.room_id AND settlement.period_start_ms = progress.period_start_ms WHERE progress.app_code = ? AND progress.period_start_ms = ? AND progress.coin_spent > 0 AND settlement.settlement_id IS NULL + AND COALESCE(regional_config.enabled, global_config.enabled, 0) = 1 ORDER BY progress.coin_spent DESC, progress.room_id ASC LIMIT ?`, appcode.FromContext(ctx), periodStartMS, limit, @@ -290,7 +302,7 @@ func (r *Repository) InsertRoomTurnoverRewardSettlement(ctx context.Context, set settlement.SettlementID = idgen.New("rtrsettle") } if settlement.WalletCommandID == "" { - settlement.WalletCommandID = walletRoomTurnoverRewardCommandID(settlement.AppCode, settlement.PeriodStartMS, settlement.RoomID) + settlement.WalletCommandID = walletRoomTurnoverRewardCommandID(settlement.AppCode, settlement.RegionID, settlement.PeriodStartMS, settlement.RoomID) } settlement.CreatedAtMS = nowMS settlement.UpdatedAtMS = nowMS @@ -300,12 +312,12 @@ func (r *Repository) InsertRoomTurnoverRewardSettlement(ctx context.Context, set // room_id + period_start_ms 是业务唯一键;cron 重跑、并发 worker 或人工补偿都只能为同一房间周期保留一条 settlement。 result, err := r.db.ExecContext(ctx, ` INSERT IGNORE INTO room_turnover_reward_settlements ( - app_code, settlement_id, room_id, owner_user_id, period_start_ms, period_end_ms, + app_code, region_id, settlement_id, room_id, owner_user_id, period_start_ms, period_end_ms, coin_spent, tier_id, tier_code, threshold_coin_spent, reward_coin_amount, status, wallet_command_id, wallet_transaction_id, failure_reason, settled_at_ms, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, 0, ?, ?)`, - appcode.FromContext(ctx), settlement.SettlementID, settlement.RoomID, settlement.OwnerUserID, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, 0, ?, ?)`, + appcode.FromContext(ctx), settlement.RegionID, settlement.SettlementID, settlement.RoomID, settlement.OwnerUserID, settlement.PeriodStartMS, settlement.PeriodEndMS, settlement.CoinSpent, settlement.TierID, settlement.TierCode, settlement.ThresholdCoinSpent, settlement.RewardCoinAmount, settlement.Status, settlement.WalletCommandID, settlement.FailureReason, nowMS, nowMS, @@ -317,7 +329,7 @@ func (r *Repository) InsertRoomTurnoverRewardSettlement(ctx context.Context, set if err != nil { return domain.Settlement{}, false, err } - item, exists, err := r.getRoomTurnoverRewardSettlementByRoomPeriod(ctx, settlement.RoomID, settlement.PeriodStartMS) + item, exists, err := r.getRoomTurnoverRewardSettlementByRoomPeriod(ctx, settlement.RegionID, settlement.RoomID, settlement.PeriodStartMS) return item, affected > 0 && exists, err } @@ -421,10 +433,10 @@ func (r *Repository) MarkRoomTurnoverRewardSettlementFailed(ctx context.Context, return err } -func (r *Repository) getRoomTurnoverRewardSettlementByRoomPeriod(ctx context.Context, roomID string, periodStartMS int64) (domain.Settlement, bool, error) { +func (r *Repository) getRoomTurnoverRewardSettlementByRoomPeriod(ctx context.Context, regionID int64, roomID string, periodStartMS int64) (domain.Settlement, bool, error) { row := r.db.QueryRowContext(ctx, roomTurnoverRewardSettlementSelectSQL()+` - WHERE app_code = ? AND room_id = ? AND period_start_ms = ?`, - appcode.FromContext(ctx), strings.TrimSpace(roomID), periodStartMS, + WHERE app_code = ? AND region_id = ? AND room_id = ? AND period_start_ms = ?`, + appcode.FromContext(ctx), regionID, strings.TrimSpace(roomID), periodStartMS, ) item, err := scanRoomTurnoverRewardSettlement(row) if errors.Is(err, sql.ErrNoRows) { @@ -434,24 +446,24 @@ func (r *Repository) getRoomTurnoverRewardSettlementByRoomPeriod(ctx context.Con } func roomTurnoverRewardConfigSelectSQL() string { - return `SELECT app_code, enabled, updated_by_admin_id, created_at_ms, updated_at_ms + return `SELECT app_code, region_id, enabled, updated_by_admin_id, created_at_ms, updated_at_ms FROM room_turnover_reward_configs ` } func roomTurnoverRewardTierSelectSQL() string { - return `SELECT tier_id, app_code, tier_code, tier_name, threshold_coin_spent, + return `SELECT tier_id, app_code, region_id, tier_code, tier_name, threshold_coin_spent, reward_coin_amount, status, sort_order, created_at_ms, updated_at_ms FROM room_turnover_reward_tiers ` } func roomTurnoverRewardProgressSelectSQL() string { - return `SELECT progress.app_code, progress.room_id, progress.period_start_ms, progress.period_end_ms, + return `SELECT progress.app_code, progress.region_id, progress.room_id, progress.period_start_ms, progress.period_end_ms, progress.coin_spent, progress.last_event_at_ms, progress.created_at_ms, progress.updated_at_ms FROM room_turnover_reward_progress progress ` } func roomTurnoverRewardSettlementSelectSQL() string { - return `SELECT app_code, settlement_id, room_id, owner_user_id, period_start_ms, period_end_ms, + return `SELECT app_code, region_id, settlement_id, room_id, owner_user_id, period_start_ms, period_end_ms, coin_spent, tier_id, tier_code, threshold_coin_spent, reward_coin_amount, status, wallet_command_id, wallet_transaction_id, failure_reason, settled_at_ms, created_at_ms, updated_at_ms @@ -464,20 +476,20 @@ type roomTurnoverRewardConfigScanner interface { func scanRoomTurnoverRewardConfig(scanner roomTurnoverRewardConfigScanner) (domain.Config, error) { var item domain.Config - err := scanner.Scan(&item.AppCode, &item.Enabled, &item.UpdatedByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS) + err := scanner.Scan(&item.AppCode, &item.RegionID, &item.Enabled, &item.UpdatedByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS) return item, err } -func (r *Repository) listRoomTurnoverRewardTiers(ctx context.Context, tx *sql.Tx) ([]domain.Tier, error) { +func (r *Repository) listRoomTurnoverRewardTiers(ctx context.Context, tx *sql.Tx, regionID int64) ([]domain.Tier, error) { query := roomTurnoverRewardTierSelectSQL() + ` - WHERE app_code = ? + WHERE app_code = ? AND region_id = ? ORDER BY sort_order ASC, threshold_coin_spent ASC, tier_id ASC` var rows *sql.Rows var err error if tx != nil { - rows, err = tx.QueryContext(ctx, query, appcode.FromContext(ctx)) + rows, err = tx.QueryContext(ctx, query, appcode.FromContext(ctx), regionID) } else { - rows, err = r.db.QueryContext(ctx, query, appcode.FromContext(ctx)) + rows, err = r.db.QueryContext(ctx, query, appcode.FromContext(ctx), regionID) } if err != nil { return nil, err @@ -487,7 +499,8 @@ func (r *Repository) listRoomTurnoverRewardTiers(ctx context.Context, tx *sql.Tx for rows.Next() { var item domain.Tier var app string - if err := rows.Scan(&item.TierID, &app, &item.TierCode, &item.TierName, &item.ThresholdCoinSpent, &item.RewardCoinAmount, &item.Status, &item.SortOrder, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { + var storedRegionID int64 + if err := rows.Scan(&item.TierID, &app, &storedRegionID, &item.TierCode, &item.TierName, &item.ThresholdCoinSpent, &item.RewardCoinAmount, &item.Status, &item.SortOrder, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { return nil, err } items = append(items, item) @@ -501,7 +514,7 @@ type roomTurnoverRewardProgressScanner interface { func scanRoomTurnoverRewardProgress(scanner roomTurnoverRewardProgressScanner) (domain.Progress, error) { var item domain.Progress - err := scanner.Scan(&item.AppCode, &item.RoomID, &item.PeriodStartMS, &item.PeriodEndMS, &item.CoinSpent, &item.LastEventAtMS, &item.CreatedAtMS, &item.UpdatedAtMS) + err := scanner.Scan(&item.AppCode, &item.RegionID, &item.RoomID, &item.PeriodStartMS, &item.PeriodEndMS, &item.CoinSpent, &item.LastEventAtMS, &item.CreatedAtMS, &item.UpdatedAtMS) return item, err } @@ -524,7 +537,7 @@ type roomTurnoverRewardSettlementScanner interface { func scanRoomTurnoverRewardSettlement(scanner roomTurnoverRewardSettlementScanner) (domain.Settlement, error) { var item domain.Settlement err := scanner.Scan( - &item.AppCode, &item.SettlementID, &item.RoomID, &item.OwnerUserID, + &item.AppCode, &item.RegionID, &item.SettlementID, &item.RoomID, &item.OwnerUserID, &item.PeriodStartMS, &item.PeriodEndMS, &item.CoinSpent, &item.TierID, &item.TierCode, &item.ThresholdCoinSpent, &item.RewardCoinAmount, &item.Status, &item.WalletCommandID, &item.WalletTransactionID, &item.FailureReason, @@ -545,8 +558,9 @@ func scanRoomTurnoverRewardSettlements(rows *sql.Rows) ([]domain.Settlement, err return items, rows.Err() } -func walletRoomTurnoverRewardCommandID(app string, periodStartMS int64, roomID string) string { - return "room_turnover_reward:" + appcode.Normalize(app) + ":" + int64String(periodStartMS) + ":" + strings.TrimSpace(roomID) +func walletRoomTurnoverRewardCommandID(app string, regionID int64, periodStartMS int64, roomID string) string { + // 区域属于结算业务键;同一房间在一周内迁区时,各区域贡献不能共享钱包幂等命令。 + return "room_turnover_reward:" + appcode.Normalize(app) + ":" + int64String(regionID) + ":" + int64String(periodStartMS) + ":" + strings.TrimSpace(roomID) } func int64String(value int64) string { diff --git a/services/activity-service/internal/transport/grpc/agency_opening_interface_flow_test.go b/services/activity-service/internal/transport/grpc/agency_opening_interface_flow_test.go index 59206c1f..7750ac6f 100644 --- a/services/activity-service/internal/transport/grpc/agency_opening_interface_flow_test.go +++ b/services/activity-service/internal/transport/grpc/agency_opening_interface_flow_test.go @@ -26,6 +26,7 @@ func TestAgencyOpeningGRPCInterfaceFlow(t *testing.T) { agencySvc := agencyservice.New(repository, nil, fakeAgencyOpeningFlowSource{ snapshot: agencydomain.AgencySnapshot{ AgencyID: 8001, + RegionID: 12, OwnerUserID: ownerUserID, Name: "Window Agency", HostCount: 12, @@ -43,6 +44,7 @@ func TestAgencyOpeningGRPCInterfaceFlow(t *testing.T) { createdCycle, err := adminClient.CreateAgencyOpeningCycle(ctx, &activityv1.UpsertAgencyOpeningCycleRequest{ Meta: agencyOpeningFlowMeta("agency-opening-cycle-create"), Cycle: &activityv1.AgencyOpeningCycle{ + RegionId: 12, Title: "Agency Opening Interface Flow", Status: "active", StartMs: now.Add(-time.Minute).UnixMilli(), @@ -167,10 +169,11 @@ func consumeAgencyOpeningFlowGift(t *testing.T, ctx context.Context, client acti t.Helper() body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{ - SenderUserId: 10001, - TargetUserId: ownerUserID, - GiftValue: giftValue, - CoinSpent: giftValue, + SenderUserId: 10001, + TargetUserId: ownerUserID, + GiftValue: giftValue, + CoinSpent: giftValue, + VisibleRegionId: 12, }) if err != nil { t.Fatalf("marshal room gift event failed: %v", err) diff --git a/services/activity-service/internal/transport/grpc/agency_opening_server.go b/services/activity-service/internal/transport/grpc/agency_opening_server.go index 15a85b3a..bdb1e34b 100644 --- a/services/activity-service/internal/transport/grpc/agency_opening_server.go +++ b/services/activity-service/internal/transport/grpc/agency_opening_server.go @@ -66,7 +66,7 @@ func NewAdminAgencyOpeningServer(svc *service.Service) *AdminAgencyOpeningServer func (s *AdminAgencyOpeningServer) ListAgencyOpeningCycles(ctx context.Context, req *activityv1.ListAgencyOpeningCyclesRequest) (*activityv1.ListAgencyOpeningCyclesResponse, error) { ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) - cycles, total, err := s.svc.ListCycles(ctx, domain.ListQuery{Status: req.GetStatus(), StartMS: req.GetStartMs(), EndMS: req.GetEndMs(), Page: req.GetPage(), PageSize: req.GetPageSize()}) + cycles, total, err := s.svc.ListCycles(ctx, domain.ListQuery{RegionID: req.GetRegionId(), Status: req.GetStatus(), StartMS: req.GetStartMs(), EndMS: req.GetEndMs(), Page: req.GetPage(), PageSize: req.GetPageSize()}) if err != nil { return nil, xerr.ToGRPCError(err) } @@ -140,6 +140,7 @@ func agencyOpeningCycleFromProto(item *activityv1.AgencyOpeningCycle) domain.Cyc } cycle := domain.Cycle{ CycleID: item.GetCycleId(), + RegionID: item.GetRegionId(), ActivityCode: item.GetActivityCode(), Title: item.GetTitle(), Status: item.GetStatus(), @@ -166,6 +167,7 @@ func agencyOpeningCycleToProto(item domain.Cycle) *activityv1.AgencyOpeningCycle } resp := &activityv1.AgencyOpeningCycle{ AppCode: item.AppCode, + RegionId: item.RegionID, CycleId: item.CycleID, ActivityCode: item.ActivityCode, Title: item.Title, @@ -197,6 +199,7 @@ func agencyOpeningApplicationToProto(item domain.Application) *activityv1.Agency } return &activityv1.AgencyOpeningApplication{ ApplicationId: item.ApplicationID, + RegionId: item.RegionID, CycleId: item.CycleID, AgencyId: item.AgencyID, AgencyOwnerUserId: item.AgencyOwnerUserID, @@ -227,6 +230,7 @@ func agencyOpeningAgencyToProto(item domain.AgencySnapshot) *activityv1.AgencyOp } return &activityv1.AgencyOpeningAgencySnapshot{ AgencyId: item.AgencyID, + RegionId: item.RegionID, OwnerUserId: item.OwnerUserID, Name: item.Name, Status: item.Status, @@ -246,6 +250,7 @@ func agencyOpeningApplicationsResponse(items []domain.Application, total int64) func agencyOpeningApplicationQueryFromProto(req *activityv1.ListAgencyOpeningApplicationsRequest) domain.ApplicationQuery { return domain.ApplicationQuery{ CycleID: req.GetCycleId(), + RegionID: req.GetRegionId(), Status: req.GetStatus(), AgencyID: req.GetAgencyId(), AgencyOwnerUserID: req.GetAgencyOwnerUserId(), diff --git a/services/activity-service/internal/transport/grpc/room_turnover_reward_server.go b/services/activity-service/internal/transport/grpc/room_turnover_reward_server.go index 663b0b16..a3b96c89 100644 --- a/services/activity-service/internal/transport/grpc/room_turnover_reward_server.go +++ b/services/activity-service/internal/transport/grpc/room_turnover_reward_server.go @@ -24,7 +24,7 @@ func NewRoomTurnoverRewardServer(svc *service.Service) *RoomTurnoverRewardServer func (s *RoomTurnoverRewardServer) GetRoomTurnoverRewardStatus(ctx context.Context, req *activityv1.GetRoomTurnoverRewardStatusRequest) (*activityv1.GetRoomTurnoverRewardStatusResponse, error) { // AppCode 从 protobuf meta 写入 context,下面 service 和 repository 都按这个上下文隔离配置、流水和结算记录。 ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) - status, err := s.svc.GetStatus(ctx, req.GetUserId(), req.GetRoomId(), req.GetOwnerUserId()) + status, err := s.svc.GetStatus(ctx, req.GetUserId(), req.GetRegionId(), req.GetRoomId(), req.GetOwnerUserId()) if err != nil { return nil, xerr.ToGRPCError(err) } @@ -44,7 +44,7 @@ func NewAdminRoomTurnoverRewardServer(svc *service.Service) *AdminRoomTurnoverRe func (s *AdminRoomTurnoverRewardServer) GetRoomTurnoverRewardConfig(ctx context.Context, req *activityv1.GetRoomTurnoverRewardConfigRequest) (*activityv1.GetRoomTurnoverRewardConfigResponse, error) { ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) - config, err := s.svc.GetConfig(ctx) + config, err := s.svc.GetConfig(ctx, req.GetRegionId()) if err != nil { return nil, xerr.ToGRPCError(err) } @@ -67,6 +67,7 @@ func (s *AdminRoomTurnoverRewardServer) UpdateRoomTurnoverRewardConfig(ctx conte }) } config, err := s.svc.UpdateConfig(ctx, domain.Config{ + RegionID: req.GetRegionId(), Enabled: req.GetEnabled(), Tiers: tiers, UpdatedByAdminID: req.GetOperatorAdminId(), @@ -81,6 +82,7 @@ func (s *AdminRoomTurnoverRewardServer) ListRoomTurnoverRewardSettlements(ctx co ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) // 查询条件完整透传到 domain query,分页上限由 service 统一收敛,后台和潜在内部工具不会各自实现分页规则。 items, total, err := s.svc.ListSettlements(ctx, domain.SettlementQuery{ + RegionID: req.GetRegionId(), Status: req.GetStatus(), RoomID: req.GetRoomId(), OwnerUserID: req.GetOwnerUserId(), @@ -129,6 +131,7 @@ func roomTurnoverRewardStatusToProto(status domain.StatusResult) *activityv1.Roo func roomTurnoverRewardConfigToProto(config domain.Config) *activityv1.RoomTurnoverRewardConfig { resp := &activityv1.RoomTurnoverRewardConfig{ AppCode: config.AppCode, + RegionId: config.RegionID, Enabled: config.Enabled, Tiers: make([]*activityv1.RoomTurnoverRewardTier, 0, len(config.Tiers)), UpdatedByAdminId: config.UpdatedByAdminID, @@ -165,6 +168,7 @@ func roomTurnoverRewardSettlementToProto(item domain.Settlement) *activityv1.Roo return &activityv1.RoomTurnoverRewardSettlement{ SettlementId: item.SettlementID, AppCode: item.AppCode, + RegionId: item.RegionID, RoomId: item.RoomID, OwnerUserId: item.OwnerUserID, PeriodStartMs: item.PeriodStartMS, diff --git a/services/gateway-service/internal/transport/http/activityapi/agency_opening_handler.go b/services/gateway-service/internal/transport/http/activityapi/agency_opening_handler.go index 07260bf9..ccacde24 100644 --- a/services/gateway-service/internal/transport/http/activityapi/agency_opening_handler.go +++ b/services/gateway-service/internal/transport/http/activityapi/agency_opening_handler.go @@ -35,16 +35,18 @@ type agencyOpeningStatusData struct { } type agencyOpeningPeriodData struct { - CycleID string `json:"cycle_id,omitempty"` - Title string `json:"title,omitempty"` - Status string `json:"status,omitempty"` - StartMS int64 `json:"start_ms,omitempty"` - EndMS int64 `json:"end_ms,omitempty"` + CycleID string `json:"cycle_id,omitempty"` + RegionID int64 `json:"region_id,omitempty"` + Title string `json:"title,omitempty"` + Status string `json:"status,omitempty"` + StartMS int64 `json:"start_ms,omitempty"` + EndMS int64 `json:"end_ms,omitempty"` } type agencyOpeningQualificationData struct { Identity string `json:"agency_identity"` AgencyID int64 `json:"agency_id,omitempty"` + RegionID int64 `json:"region_id,omitempty"` AgencyName string `json:"agency_name,omitempty"` HostCount int32 `json:"host_count"` HostRequirement int32 `json:"host_requirement"` @@ -71,6 +73,7 @@ type agencyOpeningRecordData struct { type agencyOpeningApplicationData struct { ApplicationID string `json:"application_id"` CycleID string `json:"cycle_id"` + RegionID int64 `json:"region_id"` AgencyID int64 `json:"agency_id"` AgencyOwnerUserID int64 `json:"agency_owner_user_id"` AgencyName string `json:"agency_name"` @@ -160,9 +163,29 @@ func (h *Handler) listAgencyOpeningLeaderboard(writer http.ResponseWriter, reque httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } + userID := auth.UserIDFromContext(request.Context()) + statusResp, err := h.agencyOpening.GetAgencyOpeningStatus(request.Context(), &activityv1.GetAgencyOpeningStatusRequest{ + Meta: httpkit.ActivityMeta(request), + UserId: userID, + }) + if err != nil { + httpkit.WriteRPCError(writer, request, err) + return + } + regionID := statusResp.GetAgency().GetRegionId() + if regionID <= 0 { + // 榜单不能在用户没有明确代理区域时退化成全区域读取,空结果比跨区域泄露更符合活动边界。 + httpkit.WriteOK(writer, request, map[string]any{"items": []agencyOpeningApplicationData{}, "total": int64(0)}) + return + } + cycleID := strings.TrimSpace(request.URL.Query().Get("cycle_id")) + if cycleID == "" { + cycleID = statusResp.GetCycle().GetCycleId() + } resp, err := h.agencyOpening.ListAgencyOpeningLeaderboard(request.Context(), &activityv1.ListAgencyOpeningApplicationsRequest{ Meta: httpkit.ActivityMeta(request), - CycleId: strings.TrimSpace(request.URL.Query().Get("cycle_id")), + CycleId: cycleID, + RegionId: regionID, Status: strings.TrimSpace(request.URL.Query().Get("status")), Page: int32(queryInt(request, "page", 1)), PageSize: int32(queryInt(request, "page_size", 50)), @@ -195,11 +218,12 @@ func agencyOpeningStatusFromProto(item *activityv1.GetAgencyOpeningStatusRespons Joined: item.GetJoined(), IneligibleReason: item.GetIneligibleReason(), Period: agencyOpeningPeriodData{ - CycleID: item.GetCycle().GetCycleId(), - Title: item.GetCycle().GetTitle(), - Status: item.GetCycle().GetStatus(), - StartMS: item.GetCycle().GetStartMs(), - EndMS: item.GetCycle().GetEndMs(), + CycleID: item.GetCycle().GetCycleId(), + RegionID: item.GetCycle().GetRegionId(), + Title: item.GetCycle().GetTitle(), + Status: item.GetCycle().GetStatus(), + StartMS: item.GetCycle().GetStartMs(), + EndMS: item.GetCycle().GetEndMs(), }, Qualification: agencyOpeningQualificationFromProto(item, application), Rewards: rewards, @@ -215,6 +239,7 @@ func agencyOpeningQualificationFromProto(status *activityv1.GetAgencyOpeningStat hostCount := agency.GetHostCount() agencyID := agency.GetAgencyId() agencyName := agency.GetName() + regionID := agency.GetRegionId() if application != nil { if hostCount == 0 { hostCount = application.GetHostCount() @@ -225,6 +250,9 @@ func agencyOpeningQualificationFromProto(status *activityv1.GetAgencyOpeningStat if agencyName == "" { agencyName = application.GetAgencyName() } + if regionID == 0 { + regionID = application.GetRegionId() + } } openingStatus := agencyOpeningStatusLabel(application) if !status.GetEnabled() && !status.GetJoined() { @@ -233,6 +261,7 @@ func agencyOpeningQualificationFromProto(status *activityv1.GetAgencyOpeningStat return agencyOpeningQualificationData{ Identity: "Agency", AgencyID: agencyID, + RegionID: regionID, AgencyName: agencyName, HostCount: hostCount, HostRequirement: status.GetCycle().GetMinHostCount(), @@ -287,6 +316,7 @@ func agencyOpeningApplicationFromProto(item *activityv1.AgencyOpeningApplication return agencyOpeningApplicationData{ ApplicationID: item.GetApplicationId(), CycleID: item.GetCycleId(), + RegionID: item.GetRegionId(), AgencyID: item.GetAgencyId(), AgencyOwnerUserID: item.GetAgencyOwnerUserId(), AgencyName: item.GetAgencyName(), diff --git a/services/gateway-service/internal/transport/http/activityapi/room_turnover_reward_handler.go b/services/gateway-service/internal/transport/http/activityapi/room_turnover_reward_handler.go index dca15893..a6025a2d 100644 --- a/services/gateway-service/internal/transport/http/activityapi/room_turnover_reward_handler.go +++ b/services/gateway-service/internal/transport/http/activityapi/room_turnover_reward_handler.go @@ -31,6 +31,7 @@ type roomTurnoverRewardRoomData struct { HasRoom bool `json:"has_room"` RoomID string `json:"room_id"` OwnerUserID int64 `json:"owner_user_id"` + RegionID int64 `json:"region_id"` Title string `json:"title"` CoverURL string `json:"cover_url"` } @@ -42,6 +43,7 @@ type roomTurnoverRewardPeriodData struct { type roomTurnoverRewardSettlementData struct { SettlementID string `json:"settlement_id"` + RegionID int64 `json:"region_id"` RoomID string `json:"room_id"` OwnerUserID int64 `json:"owner_user_id"` PeriodStartMS int64 `json:"period_start_ms"` @@ -94,10 +96,11 @@ func (h *Handler) getRoomTurnoverRewardStatus(writer http.ResponseWriter, reques roomData = roomTurnoverRewardRoomFromProto(roomResp.GetRoom(), userID) } - // activity-service 只需要房间 ID 和 owner_user_id 计算当前周期流水与预计奖励;用户头像、昵称等展示字段由 gateway 统一补齐。 + // 区域必须使用 room-service 返回的可见区域快照,不能用请求 IP 或当前用户资料推断,否则房间迁区后会把进度查询到错误分桶。 resp, err := h.roomTurnoverReward.GetRoomTurnoverRewardStatus(request.Context(), &activityv1.GetRoomTurnoverRewardStatusRequest{ Meta: httpkit.ActivityMeta(request), UserId: userID, + RegionId: roomData.RegionID, RoomId: roomData.RoomID, OwnerUserId: roomData.OwnerUserID, }) @@ -179,6 +182,7 @@ func roomTurnoverRewardRoomFromProto(item *roomv1.RoomListItem, fallbackOwnerUse HasRoom: true, RoomID: item.GetRoomId(), OwnerUserID: ownerID, + RegionID: item.GetVisibleRegionId(), Title: item.GetTitle(), CoverURL: item.GetCoverUrl(), } @@ -217,6 +221,7 @@ func roomTurnoverRewardSettlementFromProto(item *activityv1.RoomTurnoverRewardSe } return roomTurnoverRewardSettlementData{ SettlementID: item.GetSettlementId(), + RegionID: item.GetRegionId(), RoomID: item.GetRoomId(), OwnerUserID: item.GetOwnerUserId(), PeriodStartMS: item.GetPeriodStartMs(), diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 593d650f..961145a3 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -3449,6 +3449,11 @@ func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) { roomClient := &fakeRoomClient{} walletClient := &fakeWalletClient{myVipResp: &walletv1.GetMyVipResponse{State: &walletv1.VipState{ EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 7, Active: true}, + EffectiveBenefits: []*walletv1.VipBenefit{{ + BenefitCode: "gift_tray_skin", BenefitType: "decoration", Status: "active", + ResourceId: 7007, ResourceType: "gift_tray_skin", + Resource: &walletv1.Resource{ResourceId: 7007, ResourceType: "gift_tray_skin", Status: "active", AnimationUrl: "https://cdn.example/vip-gift-tray.svga"}, + }}, }}} profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 42: { @@ -3484,7 +3489,7 @@ func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } sender := roomClient.lastGift.GetSenderDisplayProfile() - if sender.GetUserId() != 42 || sender.GetUsername() != "Robot Sender" || sender.GetAvatar() == "" || sender.GetDisplayUserId() != "100042" || sender.GetPrettyDisplayUserId() != "888042" || sender.GetEffectiveVipLevel() != 7 { + if sender.GetUserId() != 42 || sender.GetUsername() != "Robot Sender" || sender.GetAvatar() == "" || sender.GetDisplayUserId() != "100042" || sender.GetPrettyDisplayUserId() != "888042" || sender.GetEffectiveVipLevel() != 7 || sender.GetGiftBroadcastAnimationUrl() != "https://cdn.example/vip-gift-tray.svga" { t.Fatalf("gateway must inject sender display profile snapshot: %+v", sender) } targets := roomClient.lastGift.GetTargetDisplayProfiles() diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index df1fcb8d..0fe1625c 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -2175,6 +2175,11 @@ type giftUserSnapshots struct { targetDisplayProfiles []*roomv1.SendGiftDisplayProfile } +type giftSenderVIPPresentation struct { + effectiveLevel int32 + giftBroadcastAnimationURL string +} + func (h *Handler) resolveGiftPreflight(request *http.Request, targetUserIDs []int64) (giftUserSnapshots, []*roomv1.SendGiftTargetHostScope, error) { // 用户资料、主播工资归属和发送者 VIP 互不依赖。并发取得三份入站快照,让新增 VIP 不成为 // 原有 profile -> host 串行链路之后的额外等待;任一结算必需查询失败时取消同批请求。 @@ -2187,7 +2192,7 @@ func (h *Handler) resolveGiftPreflight(request *http.Request, targetUserIDs []in var userSnapshotsErr error var targetHostScopes []*roomv1.SendGiftTargetHostScope var targetHostScopesErr error - var senderVIPLevel int32 + var senderVIP giftSenderVIPPresentation waitGroup.Add(3) go func() { defer waitGroup.Done() @@ -2205,7 +2210,7 @@ func (h *Handler) resolveGiftPreflight(request *http.Request, targetUserIDs []in }() go func() { defer waitGroup.Done() - senderVIPLevel = h.resolveGiftSenderVIPLevelBestEffort(preflightRequest) + senderVIP = h.resolveGiftSenderVIPPresentationBestEffort(preflightRequest) }() waitGroup.Wait() @@ -2216,19 +2221,21 @@ func (h *Handler) resolveGiftPreflight(request *http.Request, targetUserIDs []in return giftUserSnapshots{}, nil, targetHostScopesErr } if userSnapshots.senderDisplayProfile != nil { - // VIP 属于发送者展示快照;room-service 会把整份快照持久化,重试和 Saga 恢复不再回查当前等级。 - userSnapshots.senderDisplayProfile.EffectiveVipLevel = senderVIPLevel + // VIP 等级和托盘资源属于同一份发送者展示快照;room-service 会把它随首次命令持久化, + // 普通、幸运、超级幸运礼物的重试和 Saga 恢复都不能回查当前配置改写历史展示。 + userSnapshots.senderDisplayProfile.EffectiveVipLevel = senderVIP.effectiveLevel + userSnapshots.senderDisplayProfile.GiftBroadcastAnimationUrl = senderVIP.giftBroadcastAnimationURL } return userSnapshots, targetHostScopes, nil } -func (h *Handler) resolveGiftSenderVIPLevelBestEffort(request *http.Request) int32 { +func (h *Handler) resolveGiftSenderVIPPresentationBestEffort(request *http.Request) giftSenderVIPPresentation { if h.walletClient == nil { - return 0 + return giftSenderVIPPresentation{} } senderUserID := auth.UserIDFromContext(request.Context()) if senderUserID <= 0 { - return 0 + return giftSenderVIPPresentation{} } vipCtx, cancelVIP := context.WithTimeout(request.Context(), giftSenderVIPLookupTimeout) defer cancelVIP() @@ -2238,17 +2245,44 @@ func (h *Handler) resolveGiftSenderVIPLevelBestEffort(request *http.Request) int UserId: senderUserID, }) if err != nil || resp == nil { - return 0 + return giftSenderVIPPresentation{} } - effectiveVIP := resp.GetState().GetEffectiveVip() + state := resp.GetState() + effectiveVIP := state.GetEffectiveVip() if effectiveVIP == nil { // GetMyVip.vip 历史上就是 effective VIP;保留滚动发布期间旧 wallet-service 的响应兼容。 effectiveVIP = resp.GetVip() } if !effectiveVIP.GetActive() || effectiveVIP.GetLevel() <= 0 || (effectiveVIP.GetUserId() > 0 && effectiveVIP.GetUserId() != senderUserID) { - return 0 + return giftSenderVIPPresentation{} } - return effectiveVIP.GetLevel() + return giftSenderVIPPresentation{ + effectiveLevel: effectiveVIP.GetLevel(), + giftBroadcastAnimationURL: giftTrayAnimationURLFromVIPState(state), + } +} + +func giftTrayAnimationURLFromVIPState(state *walletv1.VipState) string { + if state == nil { + return "" + } + for _, benefit := range state.GetEffectiveBenefits() { + if !strings.EqualFold(strings.TrimSpace(benefit.GetBenefitCode()), "gift_tray_skin") || + !strings.EqualFold(strings.TrimSpace(benefit.GetStatus()), "active") || + !strings.EqualFold(strings.TrimSpace(benefit.GetResourceType()), "gift_tray_skin") || + benefit.GetResourceId() <= 0 { + continue + } + resource := benefit.GetResource() + // wallet 负责 App、VIP 和目录有效性;gateway 仍核对返回资源坐标,避免滚动发布或脏配置把别的装扮 URL 塞进礼物托盘。 + if resource == nil || resource.GetResourceId() != benefit.GetResourceId() || + !strings.EqualFold(strings.TrimSpace(resource.GetResourceType()), "gift_tray_skin") || + !strings.EqualFold(strings.TrimSpace(resource.GetStatus()), "active") { + continue + } + return strings.TrimSpace(resource.GetAnimationUrl()) + } + return "" } func (h *Handler) resolveGiftUserSnapshots(request *http.Request, targetUserIDs []int64) (giftUserSnapshots, error) { 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 01c154ed..ca1107fb 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_view.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_view.go @@ -35,27 +35,30 @@ type myRoomData struct { } type roomListItemData struct { - RoomID string `json:"room_id"` - IMGroupID string `json:"im_group_id"` - OwnerUserID string `json:"owner_user_id,omitempty"` - OwnerVIPLevel int32 `json:"owner_vip_level"` - Title string `json:"title,omitempty"` - CoverURL string `json:"cover_url,omitempty"` - Mode string `json:"mode,omitempty"` - Status string `json:"status,omitempty"` - Locked bool `json:"locked"` - Heat int64 `json:"heat"` - OnlineCount int32 `json:"online_count"` - SeatCount int32 `json:"seat_count"` - OccupiedSeatCount int32 `json:"occupied_seat_count"` - VisibleRegionID int64 `json:"visible_region_id,omitempty"` - AppCode string `json:"app_code,omitempty"` - RoomShortID string `json:"room_short_id,omitempty"` - CountryCode string `json:"country_code,omitempty"` - CountryFlag string `json:"country_flag,omitempty"` - RoomBorderURL string `json:"room_border_url"` - RoomBorder *roomDecorationData `json:"room_border,omitempty"` - RoomNameStyle *roomDecorationData `json:"room_name_style,omitempty"` + RoomID string `json:"room_id"` + IMGroupID string `json:"im_group_id"` + OwnerUserID string `json:"owner_user_id,omitempty"` + OwnerVIPLevel int32 `json:"owner_vip_level"` + Title string `json:"title,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + Mode string `json:"mode,omitempty"` + Status string `json:"status,omitempty"` + Locked bool `json:"locked"` + Heat int64 `json:"heat"` + OnlineCount int32 `json:"online_count"` + SeatCount int32 `json:"seat_count"` + OccupiedSeatCount int32 `json:"occupied_seat_count"` + VisibleRegionID int64 `json:"visible_region_id,omitempty"` + AppCode string `json:"app_code,omitempty"` + RoomShortID string `json:"room_short_id,omitempty"` + CountryCode string `json:"country_code,omitempty"` + CountryFlag string `json:"country_flag,omitempty"` + RoomBorderURL string `json:"room_border_url"` + RoomBorder *roomDecorationData `json:"room_border,omitempty"` + RoomNameStyle *roomDecorationData `json:"room_name_style,omitempty"` + RocketNearCompletion bool `json:"rocket_near_completion"` + HasActiveRedPacket bool `json:"has_active_red_packet"` + ActiveRedPacketExpiresAtMS int64 `json:"active_red_packet_expires_at_ms,omitempty"` } type createRoomData struct { @@ -740,27 +743,30 @@ func roomListItemDataFromProto(room *roomv1.RoomListItem) roomListItemData { } roomID := room.GetRoomId() return roomListItemData{ - RoomID: roomID, - IMGroupID: roomIMGroupID(roomID), - OwnerUserID: formatOptionalUserID(room.GetOwnerUserId()), - OwnerVIPLevel: room.GetOwnerVipLevel(), - Title: room.GetTitle(), - CoverURL: room.GetCoverUrl(), - Mode: room.GetMode(), - Status: room.GetStatus(), - Locked: room.GetLocked(), - Heat: room.GetHeat(), - OnlineCount: room.GetOnlineCount(), - SeatCount: room.GetSeatCount(), - OccupiedSeatCount: room.GetOccupiedSeatCount(), - VisibleRegionID: room.GetVisibleRegionId(), - AppCode: room.GetAppCode(), - RoomShortID: room.GetRoomShortId(), - CountryCode: room.GetCountryCode(), - CountryFlag: roomProfileCountryFlag(room.GetCountryCode()), - RoomBorderURL: roomBorderURL(room.GetRoomBorderUrl(), room.GetRoomBorder()), - RoomBorder: roomDecorationDataFromProto(room.GetRoomBorder()), - RoomNameStyle: roomDecorationDataFromProto(room.GetRoomNameStyle()), + RoomID: roomID, + IMGroupID: roomIMGroupID(roomID), + OwnerUserID: formatOptionalUserID(room.GetOwnerUserId()), + OwnerVIPLevel: room.GetOwnerVipLevel(), + Title: room.GetTitle(), + CoverURL: room.GetCoverUrl(), + Mode: room.GetMode(), + Status: room.GetStatus(), + Locked: room.GetLocked(), + Heat: room.GetHeat(), + OnlineCount: room.GetOnlineCount(), + SeatCount: room.GetSeatCount(), + OccupiedSeatCount: room.GetOccupiedSeatCount(), + VisibleRegionID: room.GetVisibleRegionId(), + AppCode: room.GetAppCode(), + RoomShortID: room.GetRoomShortId(), + CountryCode: room.GetCountryCode(), + CountryFlag: roomProfileCountryFlag(room.GetCountryCode()), + RoomBorderURL: roomBorderURL(room.GetRoomBorderUrl(), room.GetRoomBorder()), + RoomBorder: roomDecorationDataFromProto(room.GetRoomBorder()), + RoomNameStyle: roomDecorationDataFromProto(room.GetRoomNameStyle()), + RocketNearCompletion: room.GetRocketNearCompletion(), + HasActiveRedPacket: room.GetHasActiveRedPacket(), + ActiveRedPacketExpiresAtMS: room.GetActiveRedPacketExpiresAtMs(), } } diff --git a/services/room-service/configs/config.docker.yaml b/services/room-service/configs/config.docker.yaml index 590c9c77..f77fae42 100644 --- a/services/room-service/configs/config.docker.yaml +++ b/services/room-service/configs/config.docker.yaml @@ -87,6 +87,12 @@ rocketmq: topic: "hyapp_wallet_outbox" consumer_group: "hyapp-room-wallet-decoration-sync" consumer_max_reconsume_times: 16 + # 红包卡片状态由 wallet realtime outbox 异步重建,列表请求不跨服务回查。 + wallet_realtime_outbox: + enabled: true + topic: "hyapp_wallet_realtime_outbox" + consumer_group: "hyapp-room-wallet-red-packet-projection" + consumer_max_reconsume_times: 16 # 房间中奖 IM 由 lucky-gift owner 事实可靠派生;独立 group 保证不和其他下游共享消费位点。 lucky_gift_outbox: enabled: true diff --git a/services/room-service/configs/config.tencent.example.yaml b/services/room-service/configs/config.tencent.example.yaml index 68793ce2..5968eb13 100644 --- a/services/room-service/configs/config.tencent.example.yaml +++ b/services/room-service/configs/config.tencent.example.yaml @@ -97,6 +97,12 @@ rocketmq: topic: "hyapp_wallet_outbox" consumer_group: "hyapp-room-wallet-decoration-sync" consumer_max_reconsume_times: 16 + # 线上使用独立可重建消费位点维护红包列表投影,禁止列表接口同步调用 wallet。 + wallet_realtime_outbox: + enabled: true + topic: "hyapp_wallet_realtime_outbox" + consumer_group: "hyapp-room-wallet-red-packet-projection" + consumer_max_reconsume_times: 16 # 线上必须使用 room-service 自己的消费组;owner outbox 只通过 MQ 进入房间展示链路。 lucky_gift_outbox: enabled: true diff --git a/services/room-service/configs/config.yaml b/services/room-service/configs/config.yaml index 422c6e1b..ded24597 100644 --- a/services/room-service/configs/config.yaml +++ b/services/room-service/configs/config.yaml @@ -90,6 +90,12 @@ rocketmq: topic: "hyapp_wallet_outbox" consumer_group: "hyapp-room-wallet-decoration-sync" consumer_max_reconsume_times: 16 + # 红包实时事实用独立 topic/group 派生列表图标;新组从最早位点重建,不进入 ListRooms 同步链路。 + wallet_realtime_outbox: + enabled: true + topic: "hyapp_wallet_realtime_outbox" + consumer_group: "hyapp-room-wallet-red-packet-projection" + consumer_max_reconsume_times: 16 # 幸运礼物 owner 事实由独立消费组派生为房间展示 outbox;不能复用 activity 消费位点或直连幸运礼物库。 lucky_gift_outbox: enabled: true diff --git a/services/room-service/deploy/mysql/initdb/001_room_service.sql b/services/room-service/deploy/mysql/initdb/001_room_service.sql index d802f0c4..66d4dd4d 100644 --- a/services/room-service/deploy/mysql/initdb/001_room_service.sql +++ b/services/room-service/deploy/mysql/initdb/001_room_service.sql @@ -56,6 +56,9 @@ CREATE TABLE IF NOT EXISTS room_list_entries ( seat_count INT NOT NULL DEFAULT 0 COMMENT '麦位数量', occupied_seat_count INT NOT NULL DEFAULT 0 COMMENT '占用麦位数量', sort_score BIGINT NOT NULL DEFAULT 0 COMMENT '排序分值', + rocket_near_completion BOOLEAN NOT NULL DEFAULT FALSE COMMENT '当前档火箭剩余燃料不超过 20%', + has_active_red_packet BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'wallet MQ 派生的房间有效红包聚合', + active_red_packet_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '仍有余量红包的最晚到期时间,UTC epoch ms', room_border_json JSON NULL COMMENT '当前未过期房间边框资源快照', room_name_style_json JSON NULL COMMENT '当前未过期彩色房名资源快照', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', @@ -194,7 +197,22 @@ CREATE TABLE IF NOT EXISTS room_wallet_event_consumption ( consumed_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, event_id), KEY idx_room_wallet_event_consumed (app_code, consumed_at_ms) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='room-service 消费 wallet 装扮事实的本地幂等表'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='room-service 消费 wallet 投影事实的本地幂等表'; + +CREATE TABLE IF NOT EXISTS room_red_packet_projections ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码,多租户隔离', + packet_id VARCHAR(128) NOT NULL COMMENT 'wallet 红包 ID', + room_id VARCHAR(64) NOT NULL COMMENT '红包所属房间', + status VARCHAR(32) NOT NULL COMMENT 'wallet 红包状态快照', + remaining_amount BIGINT NOT NULL DEFAULT 0 COMMENT '事件携带的剩余金额', + remaining_count INT NOT NULL DEFAULT 0 COMMENT '事件携带的剩余份数', + expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '红包到期时间,UTC epoch ms', + event_rank INT NOT NULL DEFAULT 0 COMMENT 'created/claimed/refunded 单调状态级别', + event_occurred_at_ms BIGINT NOT NULL COMMENT 'wallet 事实发生时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '投影更新时间,UTC epoch ms', + PRIMARY KEY (app_code, packet_id), + KEY idx_room_red_packet_active (app_code, room_id, status, expires_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='红包房间列表异步投影,不承载资金事实'; CREATE TABLE IF NOT EXISTS room_snapshots ( app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离', diff --git a/services/room-service/deploy/mysql/migrations/005_room_list_activity_indicators.sql b/services/room-service/deploy/mysql/migrations/005_room_list_activity_indicators.sql new file mode 100644 index 00000000..334fcbd4 --- /dev/null +++ b/services/room-service/deploy/mysql/migrations/005_room_list_activity_indicators.sql @@ -0,0 +1,62 @@ +-- 房间列表活动图标只读取 room_list_entries 同一行;新增列不参与排序索引,避免放大列表查询成本。 +-- MySQL 8 INSTANT 仅修改数据字典;红包 packet 投影是新表,不扫描 room_list_entries 或 wallet 资金表。 +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_room; + +DROP PROCEDURE IF EXISTS hyapp_migrate_005_room_list_activity_indicators; + +DELIMITER $$ + +CREATE PROCEDURE hyapp_migrate_005_room_list_activity_indicators() +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' + ) THEN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'rocket_near_completion' + ) THEN + ALTER TABLE room_list_entries + ADD COLUMN rocket_near_completion BOOLEAN NOT NULL DEFAULT FALSE COMMENT '当前档火箭剩余燃料不超过 20%' AFTER sort_score, + ALGORITHM=INSTANT; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'has_active_red_packet' + ) THEN + ALTER TABLE room_list_entries + ADD COLUMN has_active_red_packet BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'wallet MQ 派生的房间有效红包聚合' AFTER rocket_near_completion, + ALGORITHM=INSTANT; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'active_red_packet_expires_at_ms' + ) THEN + ALTER TABLE room_list_entries + ADD COLUMN active_red_packet_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '仍有余量红包的最晚到期时间,UTC epoch ms' AFTER has_active_red_packet, + ALGORITHM=INSTANT; + END IF; + END IF; +END$$ + +CALL hyapp_migrate_005_room_list_activity_indicators()$$ +DROP PROCEDURE IF EXISTS hyapp_migrate_005_room_list_activity_indicators$$ + +DELIMITER ; + +CREATE TABLE IF NOT EXISTS room_red_packet_projections ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码,多租户隔离', + packet_id VARCHAR(128) NOT NULL COMMENT 'wallet 红包 ID', + room_id VARCHAR(64) NOT NULL COMMENT '红包所属房间', + status VARCHAR(32) NOT NULL COMMENT 'wallet 红包状态快照', + remaining_amount BIGINT NOT NULL DEFAULT 0 COMMENT '事件携带的剩余金额', + remaining_count INT NOT NULL DEFAULT 0 COMMENT '事件携带的剩余份数', + expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '红包到期时间,UTC epoch ms', + event_rank INT NOT NULL DEFAULT 0 COMMENT 'created/claimed/refunded 单调状态级别', + event_occurred_at_ms BIGINT NOT NULL COMMENT 'wallet 事实发生时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '投影更新时间,UTC epoch ms', + PRIMARY KEY (app_code, packet_id), + KEY idx_room_red_packet_active (app_code, room_id, status, expires_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='红包房间列表异步投影,不承载资金事实'; diff --git a/services/room-service/internal/app/app.go b/services/room-service/internal/app/app.go index 1f8ccf73..164e5df0 100644 --- a/services/room-service/internal/app/app.go +++ b/services/room-service/internal/app/app.go @@ -352,6 +352,44 @@ func New(cfg config.Config) (*App, error) { } mqConsumers = append(mqConsumers, walletConsumer) } + if cfg.RocketMQ.WalletRealtimeOutbox.Enabled { + // 红包 projection 是可重建读模型;独立 group 从最早保留位点回放,覆盖发布前已经存在且仍未过期的红包。 + redPacketTags, err := walletmq.LegacyCompatibleTagExpression( + walletmq.EventTypeWalletRedPacketCreated, + walletmq.EventTypeWalletRedPacketClaimed, + walletmq.EventTypeWalletRedPacketRefunded, + ) + if err != nil { + _ = repository.Close() + closeClientConn(activityConn) + _ = walletConn.Close() + _ = redisClient.Close() + return nil, err + } + redPacketConsumer, err := rocketmqx.NewConsumer(walletRedPacketProjectionConsumerConfig(cfg.RocketMQ)) + if err != nil { + _ = repository.Close() + closeClientConn(activityConn) + _ = walletConn.Close() + _ = redisClient.Close() + return nil, err + } + if err := redPacketConsumer.Subscribe(cfg.RocketMQ.WalletRealtimeOutbox.Topic, redPacketTags, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { + walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body) + if err != nil { + return err + } + return svc.HandleWalletRedPacketProjectionEvent(ctx, walletMessage) + }); err != nil { + _ = redPacketConsumer.Shutdown() + _ = repository.Close() + closeClientConn(activityConn) + _ = walletConn.Close() + _ = redisClient.Close() + return nil, err + } + mqConsumers = append(mqConsumers, redPacketConsumer) + } if cfg.RocketMQ.LuckyGiftOutbox.Enabled { luckyGiftConsumer, err := rocketmqx.NewConsumer(luckyGiftRocketMQConsumerConfig(cfg.RocketMQ)) if err != nil { @@ -682,6 +720,18 @@ func luckyGiftRocketMQConsumerConfig(cfg config.RocketMQConfig) rocketmqx.Consum return rocketMQConsumerConfig(cfg, cfg.LuckyGiftOutbox.ConsumerGroup, cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes, 0) } +func walletRedPacketProjectionConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig { + consumerCfg := rocketMQConsumerConfig( + cfg, + cfg.WalletRealtimeOutbox.ConsumerGroup, + cfg.WalletRealtimeOutbox.ConsumerMaxReconsumeTimes, + 0, + ) + // 这是无外部副作用的 MySQL/Redis 读模型,新 consumer group 必须回放 retained events 才不会漏掉部署前的活跃红包。 + consumerCfg.ConsumeFromFirst = true + return consumerCfg +} + func rocketMQEndpointConfig(cfg config.RocketMQConfig) rocketmqx.EndpointConfig { return rocketmqx.EndpointConfig{ NameServers: cfg.NameServers, diff --git a/services/room-service/internal/config/config.go b/services/room-service/internal/config/config.go index 83fba42f..941a7027 100644 --- a/services/room-service/internal/config/config.go +++ b/services/room-service/internal/config/config.go @@ -152,12 +152,13 @@ type RocketMQConfig struct { Retry int `yaml:"retry"` // RoomEventTagMode 是 room topic producer 的两阶段发布开关。legacy 保持旧 // consumer 可见;event_type 只能在全部 consumer 完成兼容订阅滚动后启用。 - RoomEventTagMode roommq.EventTagMode `yaml:"room_event_tag_mode"` - RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"` - RocketLaunch RocketLaunchMQConfig `yaml:"rocket_launch"` - UserOutbox UserOutboxMQConfig `yaml:"user_outbox"` - WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"` - LuckyGiftOutbox LuckyGiftOutboxMQConfig `yaml:"lucky_gift_outbox"` + RoomEventTagMode roommq.EventTagMode `yaml:"room_event_tag_mode"` + RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"` + RocketLaunch RocketLaunchMQConfig `yaml:"rocket_launch"` + UserOutbox UserOutboxMQConfig `yaml:"user_outbox"` + WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"` + WalletRealtimeOutbox WalletOutboxMQConfig `yaml:"wallet_realtime_outbox"` + LuckyGiftOutbox LuckyGiftOutboxMQConfig `yaml:"lucky_gift_outbox"` } // RoomOutboxMQConfig 控制 room_outbox fanout topic。 @@ -397,6 +398,12 @@ func defaultRocketMQConfig() RocketMQConfig { ConsumerGroup: "hyapp-room-wallet-decoration-sync", ConsumerMaxReconsumeTimes: 16, }, + WalletRealtimeOutbox: WalletOutboxMQConfig{ + Enabled: false, + Topic: "hyapp_wallet_realtime_outbox", + ConsumerGroup: "hyapp-room-wallet-red-packet-projection", + ConsumerMaxReconsumeTimes: 16, + }, LuckyGiftOutbox: LuckyGiftOutboxMQConfig{ Enabled: false, Topic: "hyapp_lucky_gift_outbox", @@ -654,6 +661,15 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo if cfg.WalletOutbox.ConsumerMaxReconsumeTimes <= 0 { cfg.WalletOutbox.ConsumerMaxReconsumeTimes = defaults.WalletOutbox.ConsumerMaxReconsumeTimes } + if cfg.WalletRealtimeOutbox.Topic = strings.TrimSpace(cfg.WalletRealtimeOutbox.Topic); cfg.WalletRealtimeOutbox.Topic == "" { + cfg.WalletRealtimeOutbox.Topic = defaults.WalletRealtimeOutbox.Topic + } + if cfg.WalletRealtimeOutbox.ConsumerGroup = strings.TrimSpace(cfg.WalletRealtimeOutbox.ConsumerGroup); cfg.WalletRealtimeOutbox.ConsumerGroup == "" { + cfg.WalletRealtimeOutbox.ConsumerGroup = defaults.WalletRealtimeOutbox.ConsumerGroup + } + if cfg.WalletRealtimeOutbox.ConsumerMaxReconsumeTimes <= 0 { + cfg.WalletRealtimeOutbox.ConsumerMaxReconsumeTimes = defaults.WalletRealtimeOutbox.ConsumerMaxReconsumeTimes + } if cfg.LuckyGiftOutbox.Topic = strings.TrimSpace(cfg.LuckyGiftOutbox.Topic); cfg.LuckyGiftOutbox.Topic == "" { cfg.LuckyGiftOutbox.Topic = defaults.LuckyGiftOutbox.Topic } @@ -663,7 +679,7 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo if cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes <= 0 { cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes = defaults.LuckyGiftOutbox.ConsumerMaxReconsumeTimes } - if cfg.RoomOutbox.Enabled || cfg.RocketLaunch.Enabled || cfg.UserOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.LuckyGiftOutbox.Enabled { + if cfg.RoomOutbox.Enabled || cfg.RocketLaunch.Enabled || cfg.UserOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.WalletRealtimeOutbox.Enabled || cfg.LuckyGiftOutbox.Enabled { cfg.Enabled = true } if publishMode == OutboxPublishModeMQ || publishMode == OutboxPublishModeDual { diff --git a/services/room-service/internal/config/config_test.go b/services/room-service/internal/config/config_test.go index 10f99eed..bf1fd360 100644 --- a/services/room-service/internal/config/config_test.go +++ b/services/room-service/internal/config/config_test.go @@ -49,12 +49,15 @@ func TestLoad(t *testing.T) { t.Fatalf("unexpected outbox worker config: %+v", cfg.OutboxWorker) } // 本地默认仍走 room_outbox MQ 和火箭延迟唤醒;房间 UI IM 已改为 Room Cell 提交后直发,不再启 MQ IM bridge。 - if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled || !cfg.RocketMQ.RocketLaunch.Enabled || !cfg.RocketMQ.UserOutbox.Enabled || !cfg.RocketMQ.LuckyGiftOutbox.Enabled { + if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled || !cfg.RocketMQ.RocketLaunch.Enabled || !cfg.RocketMQ.UserOutbox.Enabled || !cfg.RocketMQ.WalletRealtimeOutbox.Enabled || !cfg.RocketMQ.LuckyGiftOutbox.Enabled { t.Fatalf("local config must enable room MQ fanout and disable MQ IM bridge: %+v", cfg.RocketMQ) } if cfg.RocketMQ.LuckyGiftOutbox.Topic != "hyapp_lucky_gift_outbox" || cfg.RocketMQ.LuckyGiftOutbox.ConsumerGroup != "hyapp-room-lucky-gift-outbox" { t.Fatalf("local config must isolate lucky gift display consumption: %+v", cfg.RocketMQ.LuckyGiftOutbox) } + if cfg.RocketMQ.WalletRealtimeOutbox.Topic != "hyapp_wallet_realtime_outbox" || cfg.RocketMQ.WalletRealtimeOutbox.ConsumerGroup != "hyapp-room-wallet-red-packet-projection" { + t.Fatalf("local config must isolate red packet room-list projection: %+v", cfg.RocketMQ.WalletRealtimeOutbox) + } if cfg.RocketMQ.RoomEventTagMode != roommq.EventTagModeLegacy { t.Fatalf("Phase A config must publish legacy room tags: %q", cfg.RocketMQ.RoomEventTagMode) } @@ -126,7 +129,7 @@ func TestLoadTencentExample(t *testing.T) { if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PollInterval != 200*time.Millisecond || cfg.OutboxWorker.BatchSize != 50 || cfg.OutboxWorker.Concurrency != 16 || cfg.OutboxWorker.PublishTimeout != 15*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 { t.Fatalf("tencent example must configure outbox worker: %+v", cfg.OutboxWorker) } - if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.RocketLaunch.Enabled || !cfg.RocketMQ.LuckyGiftOutbox.Enabled { + if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.RocketLaunch.Enabled || !cfg.RocketMQ.WalletRealtimeOutbox.Enabled || !cfg.RocketMQ.LuckyGiftOutbox.Enabled { t.Fatalf("tencent example must route room outbox and rocket launch through RocketMQ: mode=%s mq=%+v", cfg.OutboxWorker.PublishMode, cfg.RocketMQ) } if cfg.RocketMQ.RoomEventTagMode != roommq.EventTagModeLegacy { @@ -152,6 +155,9 @@ func TestLoadDockerConfigDoesNotStartRoomIMBridgeWhenOutboxUsesMQ(t *testing.T) if !cfg.RocketMQ.LuckyGiftOutbox.Enabled || cfg.RocketMQ.LuckyGiftOutbox.ConsumerGroup != "hyapp-room-lucky-gift-outbox" { t.Fatalf("docker config must enable an isolated lucky gift consumer: %+v", cfg.RocketMQ.LuckyGiftOutbox) } + if !cfg.RocketMQ.WalletRealtimeOutbox.Enabled || cfg.RocketMQ.WalletRealtimeOutbox.ConsumerGroup != "hyapp-room-wallet-red-packet-projection" { + t.Fatalf("docker config must enable isolated red packet projection: %+v", cfg.RocketMQ.WalletRealtimeOutbox) + } if cfg.RocketMQ.RoomEventTagMode != roommq.EventTagModeLegacy { t.Fatalf("docker config must remain in Phase A: %q", cfg.RocketMQ.RoomEventTagMode) } diff --git a/services/room-service/internal/integration/tencent_im.go b/services/room-service/internal/integration/tencent_im.go index e1f772e2..9554492b 100644 --- a/services/room-service/internal/integration/tencent_im.go +++ b/services/room-service/internal/integration/tencent_im.go @@ -389,26 +389,27 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room return tencentim.RoomEvent{}, false, err } base.Attributes = map[string]string{ - "gift_id": body.GetGiftId(), - "room_locked": fmt.Sprintf("%t", body.GetRoomLocked()), - "gift_count": fmt.Sprintf("%d", body.GetGiftCount()), - "coin_spent": fmt.Sprintf("%d", body.GetCoinSpent()), - "billing_receipt_id": body.GetBillingReceiptId(), - "gift_type_code": body.GetGiftTypeCode(), - "cp_relation_type": body.GetCpRelationType(), - "gift_name": body.GetGiftName(), - "gift_icon_url": body.GetGiftIconUrl(), - "gift_animation_url": body.GetGiftAnimationUrl(), - "gift_effect_types": string(effectTypesJSON), - "sender_name": body.GetSenderName(), - "sender_avatar": body.GetSenderAvatar(), - "sender_display_user_id": body.GetSenderDisplayUserId(), - "sender_pretty_display_user_id": body.GetSenderPrettyDisplayUserId(), - "sender_vip_level": fmt.Sprintf("%d", body.GetSenderVipLevel()), - "receiver_nickname": body.GetReceiverNickname(), - "receiver_avatar": body.GetReceiverAvatar(), - "receiver_display_user_id": body.GetReceiverDisplayUserId(), - "receiver_pretty_display_user_id": body.GetReceiverPrettyDisplayUserId(), + "gift_id": body.GetGiftId(), + "room_locked": fmt.Sprintf("%t", body.GetRoomLocked()), + "gift_count": fmt.Sprintf("%d", body.GetGiftCount()), + "coin_spent": fmt.Sprintf("%d", body.GetCoinSpent()), + "billing_receipt_id": body.GetBillingReceiptId(), + "gift_type_code": body.GetGiftTypeCode(), + "cp_relation_type": body.GetCpRelationType(), + "gift_name": body.GetGiftName(), + "gift_icon_url": body.GetGiftIconUrl(), + "gift_animation_url": body.GetGiftAnimationUrl(), + "gift_effect_types": string(effectTypesJSON), + "sender_name": body.GetSenderName(), + "sender_avatar": body.GetSenderAvatar(), + "sender_display_user_id": body.GetSenderDisplayUserId(), + "sender_pretty_display_user_id": body.GetSenderPrettyDisplayUserId(), + "sender_vip_level": fmt.Sprintf("%d", body.GetSenderVipLevel()), + "sender_gift_broadcast_animation_url": body.GetSenderGiftBroadcastAnimationUrl(), + "receiver_nickname": body.GetReceiverNickname(), + "receiver_avatar": body.GetReceiverAvatar(), + "receiver_display_user_id": body.GetReceiverDisplayUserId(), + "receiver_pretty_display_user_id": body.GetReceiverPrettyDisplayUserId(), } if body.GetIsRobotGift() { // 机器人礼物只用于动画和公屏展示。Flutter 会把顶层 gift_value 累加到成员贡献, @@ -438,25 +439,26 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room // batch 必须携带逐目标 wallet 收益快照,客户端不能把根节点 total/aggregate 平均分到不同 Host。 // lucky 明细仍由独立协议承载,targets 只包含展示和账务快照,避免重复嵌套中奖对象。 base.Attributes = map[string]string{ - "gift_id": body.GetGiftId(), - "gift_count": fmt.Sprintf("%d", body.GetGiftCount()), - "total_gift_value": fmt.Sprintf("%d", body.GetTotalGiftValue()), - "billing_receipt_id": body.GetBillingReceiptId(), - "pool_id": body.GetPoolId(), - "gift_type_code": body.GetGiftTypeCode(), - "cp_relation_type": body.GetCpRelationType(), - "gift_name": body.GetGiftName(), - "gift_icon_url": body.GetGiftIconUrl(), - "target_count": fmt.Sprintf("%d", len(body.GetTargetUserIds())), - "target_user_ids": targetUserIDsJSON, - "targets": targetsJSON, - "command_id": body.GetCommandId(), - "visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()), - "sender_name": body.GetSenderName(), - "sender_avatar": body.GetSenderAvatar(), - "sender_display_user_id": body.GetSenderDisplayUserId(), - "sender_pretty_display_user_id": body.GetSenderPrettyDisplayUserId(), - "sender_vip_level": fmt.Sprintf("%d", body.GetSenderVipLevel()), + "gift_id": body.GetGiftId(), + "gift_count": fmt.Sprintf("%d", body.GetGiftCount()), + "total_gift_value": fmt.Sprintf("%d", body.GetTotalGiftValue()), + "billing_receipt_id": body.GetBillingReceiptId(), + "pool_id": body.GetPoolId(), + "gift_type_code": body.GetGiftTypeCode(), + "cp_relation_type": body.GetCpRelationType(), + "gift_name": body.GetGiftName(), + "gift_icon_url": body.GetGiftIconUrl(), + "target_count": fmt.Sprintf("%d", len(body.GetTargetUserIds())), + "target_user_ids": targetUserIDsJSON, + "targets": targetsJSON, + "command_id": body.GetCommandId(), + "visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()), + "sender_name": body.GetSenderName(), + "sender_avatar": body.GetSenderAvatar(), + "sender_display_user_id": body.GetSenderDisplayUserId(), + "sender_pretty_display_user_id": body.GetSenderPrettyDisplayUserId(), + "sender_vip_level": fmt.Sprintf("%d", body.GetSenderVipLevel()), + "sender_gift_broadcast_animation_url": body.GetSenderGiftBroadcastAnimationUrl(), } return base, true, nil case "RoomHeatChanged": diff --git a/services/room-service/internal/integration/tencent_im_test.go b/services/room-service/internal/integration/tencent_im_test.go index 994c78ec..4bc24296 100644 --- a/services/room-service/internal/integration/tencent_im_test.go +++ b/services/room-service/internal/integration/tencent_im_test.go @@ -121,19 +121,20 @@ func TestRoomBorderChangedCarriesFlatURLInIMAttributes(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, - SenderName: "Robot Sender", - SenderAvatar: "https://cdn.example/sender.png", - SenderDisplayUserId: "100001", - SenderVipLevel: 7, - ReceiverNickname: "Robot Receiver", - ReceiverAvatar: "https://cdn.example/receiver.png", - ReceiverDisplayUserId: "100002", + SenderUserId: 1001, + TargetUserId: 1002, + GiftId: "gift_rose", + GiftCount: 1, + GiftValue: 117, + TargetGiftValue: 367, + SenderName: "Robot Sender", + SenderAvatar: "https://cdn.example/sender.png", + SenderDisplayUserId: "100001", + SenderVipLevel: 7, + SenderGiftBroadcastAnimationUrl: "https://cdn.example/vip-gift-tray.svga", + ReceiverNickname: "Robot Receiver", + ReceiverAvatar: "https://cdn.example/receiver.png", + ReceiverDisplayUserId: "100002", }) if err != nil { t.Fatalf("Build RoomGiftSent envelope failed: %v", err) @@ -149,7 +150,7 @@ func TestRoomGiftSentCarriesTargetGiftValueInIMAttributes(t *testing.T) { 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) } - if event.Attributes["sender_name"] != "Robot Sender" || event.Attributes["sender_avatar"] != "https://cdn.example/sender.png" || event.Attributes["sender_display_user_id"] != "100001" || event.Attributes["sender_vip_level"] != "7" { + if event.Attributes["sender_name"] != "Robot Sender" || event.Attributes["sender_avatar"] != "https://cdn.example/sender.png" || event.Attributes["sender_display_user_id"] != "100001" || event.Attributes["sender_vip_level"] != "7" || event.Attributes["sender_gift_broadcast_animation_url"] != "https://cdn.example/vip-gift-tray.svga" { t.Fatalf("gift IM event sender display fields mismatch: %+v", event.Attributes) } if event.Attributes["receiver_nickname"] != "Robot Receiver" || event.Attributes["receiver_avatar"] != "https://cdn.example/receiver.png" || event.Attributes["receiver_display_user_id"] != "100002" { @@ -163,7 +164,7 @@ func TestRoomGiftSentCarriesTargetGiftValueInIMAttributes(t *testing.T) { if err := json.Unmarshal(payload, &payloadData); err != nil { t.Fatalf("decode gift IM event failed: %v payload=%s", err, payload) } - if payloadData["sender_vip_level"] != "7" { + if payloadData["sender_vip_level"] != "7" || payloadData["sender_gift_broadcast_animation_url"] != "https://cdn.example/vip-gift-tray.svga" { t.Fatalf("gift IM JSON must flatten sender VIP for Flutter: %+v", payloadData) } @@ -217,19 +218,20 @@ func TestRoomGiftSentBatchDisplayFactSkipsSingleTargetIM(t *testing.T) { func TestRoomGiftBatchSentPublishesSingleBatchIMEvent(t *testing.T) { record, err := outbox.Build("room-gift-batch", "RoomGiftBatchSent", 12, time.Now(), &roomeventsv1.RoomGiftBatchSent{ - SenderUserId: 1001, - SenderName: "Batch Sender", - SenderVipLevel: 8, - GiftId: "gift_clover", - GiftCount: 2, - TotalGiftValue: 600, - TotalCoinSpent: 600, - BillingReceiptId: "receipt-batch", - RoomHeat: 6600, - PoolId: "super_lucky", - GiftEffectTypes: []string{"room"}, - TargetUserIds: []int64{1002, 1003}, - CommandId: "cmd-batch", + SenderUserId: 1001, + SenderName: "Batch Sender", + SenderVipLevel: 8, + SenderGiftBroadcastAnimationUrl: "https://cdn.example/vip-gift-tray-batch.svga", + GiftId: "gift_clover", + GiftCount: 2, + TotalGiftValue: 600, + TotalCoinSpent: 600, + BillingReceiptId: "receipt-batch", + RoomHeat: 6600, + PoolId: "super_lucky", + GiftEffectTypes: []string{"room"}, + TargetUserIds: []int64{1002, 1003}, + CommandId: "cmd-batch", Targets: []*roomeventsv1.RoomGiftBatchTarget{ { TargetUserId: 1002, @@ -269,7 +271,7 @@ func TestRoomGiftBatchSentPublishesSingleBatchIMEvent(t *testing.T) { if event.Attributes["gift_id"] != "gift_clover" || event.Attributes["gift_count"] != "2" || event.Attributes["command_id"] != "cmd-batch" { t.Fatalf("batch gift IM gift attributes mismatch: %+v", event.Attributes) } - if event.Attributes["sender_vip_level"] != "8" { + if event.Attributes["sender_vip_level"] != "8" || event.Attributes["sender_gift_broadcast_animation_url"] != "https://cdn.example/vip-gift-tray-batch.svga" { t.Fatalf("batch gift IM sender VIP mismatch: %+v", event.Attributes) } var targets []struct { diff --git a/services/room-service/internal/room/command/command.go b/services/room-service/internal/room/command/command.go index e4855481..8f852100 100644 --- a/services/room-service/internal/room/command/command.go +++ b/services/room-service/internal/room/command/command.go @@ -651,6 +651,8 @@ type GiftDisplayProfile struct { PrettyDisplayUserID string `json:"pretty_display_user_id,omitempty"` // EffectiveVIPLevel 固化送礼入口看到的生效等级,命令重放时不能重新查询导致同一礼物事件展示漂移。 EffectiveVIPLevel int32 `json:"effective_vip_level,omitempty"` + // GiftBroadcastAnimationURL 固化 wallet 已校验的 gift_tray_skin;普通、幸运和超级幸运礼物统一复用该快照。 + GiftBroadcastAnimationURL string `json:"gift_broadcast_animation_url,omitempty"` } // RocketRewardGrant 记录发射命令中已经结算出的资源组发放结果。 diff --git a/services/room-service/internal/room/service/gift_batch.go b/services/room-service/internal/room/service/gift_batch.go index ffb8ee73..d913b43b 100644 --- a/services/room-service/internal/room/service/gift_batch.go +++ b/services/room-service/internal/room/service/gift_batch.go @@ -39,28 +39,29 @@ func buildSendGiftBatchDisplay(cmd command.SendGift, billing *walletv1.DebitGift targetUserIDs = append(targetUserIDs, cmd.TargetUserIDs...) } return &roomv1.SendGiftBatchDisplay{ - SenderUserId: cmd.ActorUserID(), - SenderName: giftDisplayName(cmd.SenderDisplayProfile), - SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar), - SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID), - SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID), - SenderVipLevel: cmd.SenderDisplayProfile.EffectiveVIPLevel, - GiftId: cmd.GiftID, - GiftCount: cmd.GiftCount, - TotalGiftValue: sumTargetGiftValue(targetBillings), - TotalCoinSpent: sumTargetCoinSpent(targetBillings, billing), - BillingReceiptId: firstNonEmpty(displayBilling.GetBillingReceiptId(), cmd.BillingReceiptID), - RoomHeat: roomHeat, - PoolId: cmd.PoolID, - GiftTypeCode: displayBilling.GetGiftTypeCode(), - CpRelationType: displayBilling.GetCpRelationType(), - GiftName: displayBilling.GetGiftName(), - GiftIconUrl: displayBilling.GetGiftIconUrl(), - GiftAnimationUrl: displayBilling.GetGiftAnimationUrl(), - GiftEffectTypes: displayBilling.GetGiftEffectTypes(), - TargetUserIds: targetUserIDs, - Targets: targets, - CommandId: cmd.ID(), + SenderUserId: cmd.ActorUserID(), + SenderName: giftDisplayName(cmd.SenderDisplayProfile), + SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar), + SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID), + SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID), + SenderVipLevel: cmd.SenderDisplayProfile.EffectiveVIPLevel, + SenderGiftBroadcastAnimationUrl: strings.TrimSpace(cmd.SenderDisplayProfile.GiftBroadcastAnimationURL), + GiftId: cmd.GiftID, + GiftCount: cmd.GiftCount, + TotalGiftValue: sumTargetGiftValue(targetBillings), + TotalCoinSpent: sumTargetCoinSpent(targetBillings, billing), + BillingReceiptId: firstNonEmpty(displayBilling.GetBillingReceiptId(), cmd.BillingReceiptID), + RoomHeat: roomHeat, + PoolId: cmd.PoolID, + GiftTypeCode: displayBilling.GetGiftTypeCode(), + CpRelationType: displayBilling.GetCpRelationType(), + GiftName: displayBilling.GetGiftName(), + GiftIconUrl: displayBilling.GetGiftIconUrl(), + GiftAnimationUrl: displayBilling.GetGiftAnimationUrl(), + GiftEffectTypes: displayBilling.GetGiftEffectTypes(), + TargetUserIds: targetUserIDs, + Targets: targets, + CommandId: cmd.ID(), } } @@ -89,29 +90,30 @@ func roomGiftBatchSentEventFromDisplay(display *roomv1.SendGiftBatchDisplay, vis targets = append(targets, batchTarget) } return &roomeventsv1.RoomGiftBatchSent{ - SenderUserId: display.GetSenderUserId(), - SenderName: display.GetSenderName(), - SenderAvatar: display.GetSenderAvatar(), - SenderDisplayUserId: display.GetSenderDisplayUserId(), - SenderPrettyDisplayUserId: display.GetSenderPrettyDisplayUserId(), - SenderVipLevel: display.GetSenderVipLevel(), - GiftId: display.GetGiftId(), - GiftCount: display.GetGiftCount(), - TotalGiftValue: display.GetTotalGiftValue(), - TotalCoinSpent: display.GetTotalCoinSpent(), - BillingReceiptId: display.GetBillingReceiptId(), - RoomHeat: display.GetRoomHeat(), - PoolId: display.GetPoolId(), - GiftTypeCode: display.GetGiftTypeCode(), - CpRelationType: display.GetCpRelationType(), - GiftName: display.GetGiftName(), - GiftIconUrl: display.GetGiftIconUrl(), - GiftAnimationUrl: display.GetGiftAnimationUrl(), - GiftEffectTypes: display.GetGiftEffectTypes(), - TargetUserIds: display.GetTargetUserIds(), - Targets: targets, - CommandId: display.GetCommandId(), - VisibleRegionId: visibleRegionID, + SenderUserId: display.GetSenderUserId(), + SenderName: display.GetSenderName(), + SenderAvatar: display.GetSenderAvatar(), + SenderDisplayUserId: display.GetSenderDisplayUserId(), + SenderPrettyDisplayUserId: display.GetSenderPrettyDisplayUserId(), + SenderVipLevel: display.GetSenderVipLevel(), + SenderGiftBroadcastAnimationUrl: display.GetSenderGiftBroadcastAnimationUrl(), + GiftId: display.GetGiftId(), + GiftCount: display.GetGiftCount(), + TotalGiftValue: display.GetTotalGiftValue(), + TotalCoinSpent: display.GetTotalCoinSpent(), + BillingReceiptId: display.GetBillingReceiptId(), + RoomHeat: display.GetRoomHeat(), + PoolId: display.GetPoolId(), + GiftTypeCode: display.GetGiftTypeCode(), + CpRelationType: display.GetCpRelationType(), + GiftName: display.GetGiftName(), + GiftIconUrl: display.GetGiftIconUrl(), + GiftAnimationUrl: display.GetGiftAnimationUrl(), + GiftEffectTypes: display.GetGiftEffectTypes(), + TargetUserIds: display.GetTargetUserIds(), + Targets: targets, + CommandId: display.GetCommandId(), + VisibleRegionId: visibleRegionID, } } diff --git a/services/room-service/internal/room/service/gift_events.go b/services/room-service/internal/room/service/gift_events.go index b1cdbc05..9381b7e6 100644 --- a/services/room-service/internal/room/service/gift_events.go +++ b/services/room-service/internal/room/service/gift_events.go @@ -16,33 +16,34 @@ func buildRoomGiftSentRecords(roomID string, roomVersion int64, now time.Time, c for _, targetBilling := range targetBillings { targetDisplayProfile := giftDisplayProfileForUser(cmd.TargetDisplayProfiles, targetBilling.TargetUserID) record, err := outbox.Build(roomID, "RoomGiftSent", roomVersion, now, &roomeventsv1.RoomGiftSent{ - SenderUserId: cmd.ActorUserID(), - TargetUserId: targetBilling.TargetUserID, - GiftId: cmd.GiftID, - PoolId: cmd.PoolID, - GiftCount: cmd.GiftCount, - GiftValue: targetBilling.Billing.GetHeatValue(), - CoinSpent: targetBilling.Billing.GetCoinSpent(), - BillingReceiptId: targetBilling.Billing.GetBillingReceiptId(), - VisibleRegionId: roomMeta.VisibleRegionID, - CountryId: cmd.SenderCountryID, - RegionId: firstNonZeroInt64(cmd.SenderRegionID, roomMeta.VisibleRegionID), - CommandId: targetBilling.CommandID, - GiftTypeCode: targetBilling.Billing.GetGiftTypeCode(), - CpRelationType: targetBilling.Billing.GetCpRelationType(), - GiftName: targetBilling.Billing.GetGiftName(), - GiftIconUrl: targetBilling.Billing.GetGiftIconUrl(), - GiftAnimationUrl: targetBilling.Billing.GetGiftAnimationUrl(), - TargetGiftValue: targetCurrentGiftValues[targetBilling.TargetUserID], - SenderName: giftDisplayName(cmd.SenderDisplayProfile), - SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar), - SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID), - SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID), - SenderVipLevel: cmd.SenderDisplayProfile.EffectiveVIPLevel, - ReceiverNickname: giftDisplayName(targetDisplayProfile), - ReceiverAvatar: strings.TrimSpace(targetDisplayProfile.Avatar), - ReceiverDisplayUserId: strings.TrimSpace(targetDisplayProfile.DisplayUserID), - ReceiverPrettyDisplayUserId: strings.TrimSpace(targetDisplayProfile.PrettyDisplayUserID), + SenderUserId: cmd.ActorUserID(), + TargetUserId: targetBilling.TargetUserID, + GiftId: cmd.GiftID, + PoolId: cmd.PoolID, + GiftCount: cmd.GiftCount, + GiftValue: targetBilling.Billing.GetHeatValue(), + CoinSpent: targetBilling.Billing.GetCoinSpent(), + BillingReceiptId: targetBilling.Billing.GetBillingReceiptId(), + VisibleRegionId: roomMeta.VisibleRegionID, + CountryId: cmd.SenderCountryID, + RegionId: firstNonZeroInt64(cmd.SenderRegionID, roomMeta.VisibleRegionID), + CommandId: targetBilling.CommandID, + GiftTypeCode: targetBilling.Billing.GetGiftTypeCode(), + CpRelationType: targetBilling.Billing.GetCpRelationType(), + GiftName: targetBilling.Billing.GetGiftName(), + GiftIconUrl: targetBilling.Billing.GetGiftIconUrl(), + GiftAnimationUrl: targetBilling.Billing.GetGiftAnimationUrl(), + TargetGiftValue: targetCurrentGiftValues[targetBilling.TargetUserID], + SenderName: giftDisplayName(cmd.SenderDisplayProfile), + SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar), + SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID), + SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID), + SenderVipLevel: cmd.SenderDisplayProfile.EffectiveVIPLevel, + SenderGiftBroadcastAnimationUrl: strings.TrimSpace(cmd.SenderDisplayProfile.GiftBroadcastAnimationURL), + ReceiverNickname: giftDisplayName(targetDisplayProfile), + ReceiverAvatar: strings.TrimSpace(targetDisplayProfile.Avatar), + ReceiverDisplayUserId: strings.TrimSpace(targetDisplayProfile.DisplayUserID), + ReceiverPrettyDisplayUserId: strings.TrimSpace(targetDisplayProfile.PrettyDisplayUserID), // display_mode 只改变房间展示投递策略;逐目标事实仍然进入 durable outbox,保证统计、activity、CP 和礼物墙消费口径不变。 DisplayMode: strings.TrimSpace(cmd.DisplayMode), // effect_types 来自 wallet-service 已锁定的礼物价格配置;room-service 只透传,避免统计和全服广播各自重新判定礼物效果。 diff --git a/services/room-service/internal/room/service/gift_events_test.go b/services/room-service/internal/room/service/gift_events_test.go index 984e2dcf..c65b88ec 100644 --- a/services/room-service/internal/room/service/gift_events_test.go +++ b/services/room-service/internal/room/service/gift_events_test.go @@ -30,12 +30,13 @@ func TestBuildRoomGiftSentRecordsPreservesDurableFactFields(t *testing.T) { SenderCountryID: 86, SenderRegionID: 9001, SenderDisplayProfile: command.GiftDisplayProfile{ - UserID: 101, - Username: " sender ", - Avatar: " avatar-sender ", - DisplayUserID: "S101", - PrettyDisplayUserID: "PS101", - EffectiveVIPLevel: 7, + UserID: 101, + Username: " sender ", + Avatar: " avatar-sender ", + DisplayUserID: "S101", + PrettyDisplayUserID: "PS101", + EffectiveVIPLevel: 7, + GiftBroadcastAnimationURL: "https://cdn.example/vip-gift-tray.svga", }, TargetDisplayProfiles: []command.GiftDisplayProfile{{ UserID: 202, @@ -100,7 +101,7 @@ func TestBuildRoomGiftSentRecordsPreservesDurableFactFields(t *testing.T) { if !event.GetIsRobotGift() || !event.GetSyntheticLuckyGift() || !event.GetRealRoomRobotGift() { t.Fatalf("robot/lucky flags mismatch: %+v", &event) } - if event.GetSenderName() != "sender" || event.GetSenderVipLevel() != 7 || event.GetReceiverNickname() != "receiver" || event.GetReceiverAvatar() != "avatar-receiver" { + if event.GetSenderName() != "sender" || event.GetSenderVipLevel() != 7 || event.GetSenderGiftBroadcastAnimationUrl() != "https://cdn.example/vip-gift-tray.svga" || event.GetReceiverNickname() != "receiver" || event.GetReceiverAvatar() != "avatar-receiver" { t.Fatalf("display profile fields mismatch: %+v", &event) } } @@ -164,12 +165,13 @@ func TestRoomGiftBatchSentEventFromDisplayPreservesTargetsAndTotals(t *testing.T GiftCount: 2, DisplayMode: "batch", SenderDisplayProfile: command.GiftDisplayProfile{ - UserID: 301, - Username: "batch sender", - Avatar: "sender-avatar", - DisplayUserID: "S301", - PrettyDisplayUserID: "PS301", - EffectiveVIPLevel: 8, + UserID: 301, + Username: "batch sender", + Avatar: "sender-avatar", + DisplayUserID: "S301", + PrettyDisplayUserID: "PS301", + EffectiveVIPLevel: 8, + GiftBroadcastAnimationURL: "https://cdn.example/vip-gift-tray-batch.svga", }, TargetDisplayProfiles: []command.GiftDisplayProfile{ {UserID: 401, Username: "target-one", Avatar: "avatar-one", DisplayUserID: "T401", PrettyDisplayUserID: "PT401"}, @@ -204,7 +206,7 @@ func TestRoomGiftBatchSentEventFromDisplayPreservesTargetsAndTotals(t *testing.T display := buildSendGiftBatchDisplay(cmd, billing, targetBillings, map[int64]int64{401: 1001, 402: 1002}, luckyGifts, 8888) event := roomGiftBatchSentEventFromDisplay(display, 6006) - if event.GetSenderUserId() != 301 || event.GetSenderVipLevel() != 8 || display.GetSenderVipLevel() != 8 || event.GetGiftId() != "gift-star" || event.GetGiftCount() != 2 || event.GetCommandId() != "cmd-batch-fact" { + if event.GetSenderUserId() != 301 || event.GetSenderVipLevel() != 8 || display.GetSenderVipLevel() != 8 || event.GetSenderGiftBroadcastAnimationUrl() != "https://cdn.example/vip-gift-tray-batch.svga" || display.GetSenderGiftBroadcastAnimationUrl() != "https://cdn.example/vip-gift-tray-batch.svga" || event.GetGiftId() != "gift-star" || event.GetGiftCount() != 2 || event.GetCommandId() != "cmd-batch-fact" { t.Fatalf("batch root fields mismatch: %+v", event) } if event.GetTotalGiftValue() != 330 || event.GetTotalCoinSpent() != 220 || event.GetBillingReceiptId() != "receipt-batch-root" || event.GetRoomHeat() != 8888 { diff --git a/services/room-service/internal/room/service/gift_profile.go b/services/room-service/internal/room/service/gift_profile.go index 4d470550..450c36b6 100644 --- a/services/room-service/internal/room/service/gift_profile.go +++ b/services/room-service/internal/room/service/gift_profile.go @@ -55,12 +55,13 @@ func giftDisplayProfileFromProto(item *roomv1.SendGiftDisplayProfile) command.Gi } // 展示快照只裁剪空白字符,不做业务兜底;兜底顺序统一放在 giftDisplayName,避免事件字段互相不一致。 return command.GiftDisplayProfile{ - UserID: item.GetUserId(), - Username: strings.TrimSpace(item.GetUsername()), - Avatar: strings.TrimSpace(item.GetAvatar()), - DisplayUserID: strings.TrimSpace(item.GetDisplayUserId()), - PrettyDisplayUserID: strings.TrimSpace(item.GetPrettyDisplayUserId()), - EffectiveVIPLevel: effectiveVIPLevel, + UserID: item.GetUserId(), + Username: strings.TrimSpace(item.GetUsername()), + Avatar: strings.TrimSpace(item.GetAvatar()), + DisplayUserID: strings.TrimSpace(item.GetDisplayUserId()), + PrettyDisplayUserID: strings.TrimSpace(item.GetPrettyDisplayUserId()), + EffectiveVIPLevel: effectiveVIPLevel, + GiftBroadcastAnimationURL: strings.TrimSpace(item.GetGiftBroadcastAnimationUrl()), } } diff --git a/services/room-service/internal/room/service/list.go b/services/room-service/internal/room/service/list.go index 29672dc9..79b8024c 100644 --- a/services/room-service/internal/room/service/list.go +++ b/services/room-service/internal/room/service/list.go @@ -431,26 +431,27 @@ func roomListEntryFromSnapshot(snapshot *roomv1.RoomSnapshot, visibleRegionID in } return RoomListEntry{ - AppCode: snapshot.GetAppCode(), - RoomID: snapshot.GetRoomId(), - RoomShortID: roomShortID, - VisibleRegionID: normalizeVisibleRegionID(visibleRegionID), - OwnerCountryCode: normalizeRoomCountryCode(snapshot.GetRoomExt()[roomExtOwnerCountryKey]), - OwnerUserID: snapshot.GetOwnerUserId(), - Title: title, - CoverURL: coverURL, - Mode: snapshot.GetMode(), - Status: snapshot.GetStatus(), - Locked: snapshot.GetLocked(), - Heat: snapshot.GetHeat(), - OnlineCount: onlineCount, - SeatCount: seatCount, - OccupiedSeatCount: occupiedSeatCount, - SortScore: roomListSortScore(snapshot.GetHeat(), onlineCount, occupiedSeatCount), - RoomBorderJSON: marshalRoomListDecoration(snapshot.GetRoomBorder()), - RoomNameStyleJSON: marshalRoomListDecoration(snapshot.GetRoomNameStyle()), - CreatedAtMS: nowMS, - UpdatedAtMS: nowMS, + AppCode: snapshot.GetAppCode(), + RoomID: snapshot.GetRoomId(), + RoomShortID: roomShortID, + VisibleRegionID: normalizeVisibleRegionID(visibleRegionID), + OwnerCountryCode: normalizeRoomCountryCode(snapshot.GetRoomExt()[roomExtOwnerCountryKey]), + OwnerUserID: snapshot.GetOwnerUserId(), + Title: title, + CoverURL: coverURL, + Mode: snapshot.GetMode(), + Status: snapshot.GetStatus(), + Locked: snapshot.GetLocked(), + Heat: snapshot.GetHeat(), + OnlineCount: onlineCount, + SeatCount: seatCount, + OccupiedSeatCount: occupiedSeatCount, + SortScore: roomListSortScore(snapshot.GetHeat(), onlineCount, occupiedSeatCount), + RocketNearCompletion: roomRocketNearCompletion(snapshot.GetRocket()), + RoomBorderJSON: marshalRoomListDecoration(snapshot.GetRoomBorder()), + RoomNameStyleJSON: marshalRoomListDecoration(snapshot.GetRoomNameStyle()), + CreatedAtMS: nowMS, + UpdatedAtMS: nowMS, } } @@ -641,28 +642,45 @@ func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem { ownerVIPLevel = 0 } return &roomv1.RoomListItem{ - AppCode: entry.AppCode, - RoomId: entry.RoomID, - RoomShortId: entry.RoomShortID, - OwnerUserId: entry.OwnerUserID, - Title: entry.Title, - CoverUrl: entry.CoverURL, - Mode: entry.Mode, - Status: entry.Status, - Locked: entry.Locked, - Heat: entry.Heat, - OnlineCount: entry.OnlineCount, - SeatCount: entry.SeatCount, - OccupiedSeatCount: entry.OccupiedSeatCount, - VisibleRegionId: entry.VisibleRegionID, - CountryCode: normalizeRoomCountryCode(entry.OwnerCountryCode), - RoomBorder: roomBorder, - RoomBorderUrl: roomBorder.GetAssetUrl(), - RoomNameStyle: unmarshalRoomListDecoration(entry.RoomNameStyleJSON, nowMS), - OwnerVipLevel: ownerVIPLevel, + AppCode: entry.AppCode, + RoomId: entry.RoomID, + RoomShortId: entry.RoomShortID, + OwnerUserId: entry.OwnerUserID, + Title: entry.Title, + CoverUrl: entry.CoverURL, + Mode: entry.Mode, + Status: entry.Status, + Locked: entry.Locked, + Heat: entry.Heat, + OnlineCount: entry.OnlineCount, + SeatCount: entry.SeatCount, + OccupiedSeatCount: entry.OccupiedSeatCount, + VisibleRegionId: entry.VisibleRegionID, + CountryCode: normalizeRoomCountryCode(entry.OwnerCountryCode), + RoomBorder: roomBorder, + RoomBorderUrl: roomBorder.GetAssetUrl(), + RoomNameStyle: unmarshalRoomListDecoration(entry.RoomNameStyleJSON, nowMS), + OwnerVipLevel: ownerVIPLevel, + RocketNearCompletion: entry.RocketNearCompletion, + HasActiveRedPacket: entry.HasActiveRedPacket && entry.ActiveRedPacketExpiresAtMS > nowMS, + ActiveRedPacketExpiresAtMs: entry.ActiveRedPacketExpiresAtMS, } } +// roomRocketNearCompletion 只展示仍可积攒燃料的当前档;点火/完成态即使数值满格也不再显示冲刺图标。 +// 用整数剩余量和 floor(threshold/5) 比较,等价于 remaining/threshold <= 20%,且避免乘法溢出。 +func roomRocketNearCompletion(rocket *roomv1.RoomRocketState) bool { + if rocket == nil || rocket.GetStatus() != "charging" || rocket.GetFuelThreshold() <= 0 { + return false + } + currentFuel := rocket.GetCurrentFuel() + threshold := rocket.GetFuelThreshold() + if currentFuel < 0 || currentFuel > threshold { + return false + } + return threshold-currentFuel <= threshold/5 +} + func marshalRoomListDecoration(resource *roomv1.RoomDecorationResource) string { if resource == nil || resource.GetResourceId() <= 0 { return "" diff --git a/services/room-service/internal/room/service/list_cache_service_test.go b/services/room-service/internal/room/service/list_cache_service_test.go index 1e2abab3..2b681002 100644 --- a/services/room-service/internal/room/service/list_cache_service_test.go +++ b/services/room-service/internal/room/service/list_cache_service_test.go @@ -7,6 +7,7 @@ import ( roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" + "hyapp/pkg/walletmq" "hyapp/services/room-service/internal/integration" roomservice "hyapp/services/room-service/internal/room/service" "hyapp/services/room-service/internal/router" @@ -17,18 +18,21 @@ func TestListRoomsUsesCacheWhenEligible(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) cache := &fakeRoomListCache{entries: []roomservice.RoomListEntry{{ - AppCode: appcode.Default, - RoomID: "room-cache-hit", - RoomShortID: "cache-hit", - VisibleRegionID: 7001, - OwnerUserID: 6001, - OwnerVIPLevel: 5, - OwnerVIPExpiresAtMS: time.Now().UTC().Add(time.Hour).UnixMilli(), - Title: "cache", - Mode: "voice", - Status: "active", - SortScore: 99, - RoomBorderJSON: `{"resource_id":5201,"resource_type":"room_border","asset_url":"https://cdn.example.com/room-border/gold.webp","expires_at_ms":4102444800000}`, + AppCode: appcode.Default, + RoomID: "room-cache-hit", + RoomShortID: "cache-hit", + VisibleRegionID: 7001, + OwnerUserID: 6001, + OwnerVIPLevel: 5, + OwnerVIPExpiresAtMS: time.Now().UTC().Add(time.Hour).UnixMilli(), + Title: "cache", + Mode: "voice", + Status: "active", + SortScore: 99, + RoomBorderJSON: `{"resource_id":5201,"resource_type":"room_border","asset_url":"https://cdn.example.com/room-border/gold.webp","expires_at_ms":4102444800000}`, + RocketNearCompletion: true, + HasActiveRedPacket: true, + ActiveRedPacketExpiresAtMS: time.Now().UTC().Add(time.Hour).UnixMilli(), }}} svc := roomservice.New(roomservice.Config{ NodeID: "node-list-cache-hit-test", @@ -60,6 +64,71 @@ func TestListRoomsUsesCacheWhenEligible(t *testing.T) { if got := resp.GetRooms()[0].GetRoomBorderUrl(); got != "https://cdn.example.com/room-border/gold.webp" || resp.GetRooms()[0].GetRoomBorder().GetAssetUrl() != got { t.Fatalf("list response must derive flat room border URL from the existing projection: %+v", resp.GetRooms()[0]) } + if !resp.GetRooms()[0].GetRocketNearCompletion() || !resp.GetRooms()[0].GetHasActiveRedPacket() { + t.Fatalf("list response must expose cached activity indicators without an upstream lookup: %+v", resp.GetRooms()[0]) + } +} + +func TestWalletRedPacketEventsProjectAggregateIntoRoomList(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + svc := roomservice.New(roomservice.Config{ + NodeID: "node-red-packet-list-projection-test", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1, + }, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) + createPinnedListRoom(t, ctx, svc, "room-red-packet-list", "red-packet-list", 6301, 7101, "US") + nowMS := time.Now().UTC().UnixMilli() + created := walletmq.WalletOutboxMessage{ + AppCode: appcode.Default, EventID: "evt-red-packet-created", EventType: walletmq.EventTypeWalletRedPacketCreated, + TransactionID: "tx-created", CommandID: "cmd-created", UserID: 6301, AssetType: "COIN", OccurredAtMS: nowMS, + PayloadJSON: `{"packet_id":"rp-list","room_id":"room-red-packet-list","status":"active","remaining_amount":100,"remaining_count":2,"expires_at_ms":4102444800000}`, + } + if err := svc.HandleWalletRedPacketProjectionEvent(ctx, created); err != nil { + t.Fatalf("project created red packet: %v", err) + } + resp, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{ + Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, ViewerUserId: 4001, VisibleRegionId: 7101, Tab: "new", Limit: 10, + }) + if err != nil || len(resp.GetRooms()) != 1 || !resp.GetRooms()[0].GetHasActiveRedPacket() { + t.Fatalf("created packet must enable room-list indicator without wallet lookup: err=%v resp=%+v", err, resp) + } + secondCreated := walletmq.WalletOutboxMessage{ + AppCode: appcode.Default, EventID: "evt-red-packet-second-created", EventType: walletmq.EventTypeWalletRedPacketCreated, + TransactionID: "tx-second-created", CommandID: "cmd-second-created", UserID: 6301, AssetType: "COIN", OccurredAtMS: nowMS + 1, + PayloadJSON: `{"packet_id":"rp-list-second","room_id":"room-red-packet-list","status":"active","remaining_amount":50,"remaining_count":1,"expires_at_ms":4102444700000}`, + } + if err := svc.HandleWalletRedPacketProjectionEvent(ctx, secondCreated); err != nil { + t.Fatalf("project second active red packet: %v", err) + } + + claimed := walletmq.WalletOutboxMessage{ + AppCode: appcode.Default, EventID: "evt-red-packet-finished", EventType: walletmq.EventTypeWalletRedPacketClaimed, + TransactionID: "tx-finished", CommandID: "cmd-finished", UserID: 6401, AssetType: "COIN", OccurredAtMS: nowMS + 2, + PayloadJSON: `{"packet_id":"rp-list","room_id":"room-red-packet-list","packet_status":"finished","remaining_amount":0,"remaining_count":0}`, + } + if err := svc.HandleWalletRedPacketProjectionEvent(ctx, claimed); err != nil { + t.Fatalf("project finished red packet: %v", err) + } + resp, err = svc.ListRooms(ctx, &roomv1.ListRoomsRequest{ + Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, ViewerUserId: 4001, VisibleRegionId: 7101, Tab: "new", Limit: 10, + }) + if err != nil || len(resp.GetRooms()) != 1 || !resp.GetRooms()[0].GetHasActiveRedPacket() { + t.Fatalf("finishing one packet must retain indicator while another is active: err=%v resp=%+v", err, resp) + } + + secondClaimed := walletmq.WalletOutboxMessage{ + AppCode: appcode.Default, EventID: "evt-red-packet-second-finished", EventType: walletmq.EventTypeWalletRedPacketClaimed, + TransactionID: "tx-second-finished", CommandID: "cmd-second-finished", UserID: 6402, AssetType: "COIN", OccurredAtMS: nowMS + 3, + PayloadJSON: `{"packet_id":"rp-list-second","room_id":"room-red-packet-list","packet_status":"finished","remaining_amount":0,"remaining_count":0}`, + } + if err := svc.HandleWalletRedPacketProjectionEvent(ctx, secondClaimed); err != nil { + t.Fatalf("project second finished red packet: %v", err) + } + resp, err = svc.ListRooms(ctx, &roomv1.ListRoomsRequest{ + Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, ViewerUserId: 4001, VisibleRegionId: 7101, Tab: "new", Limit: 10, + }) + if err != nil || len(resp.GetRooms()) != 1 || resp.GetRooms()[0].GetHasActiveRedPacket() { + t.Fatalf("finishing the last packet must clear room-list indicator: err=%v resp=%+v", err, resp) + } } func TestListRoomsFallsBackToMySQLWhenCacheMisses(t *testing.T) { diff --git a/services/room-service/internal/room/service/list_score_test.go b/services/room-service/internal/room/service/list_score_test.go index b10b0614..e7a27626 100644 --- a/services/room-service/internal/room/service/list_score_test.go +++ b/services/room-service/internal/room/service/list_score_test.go @@ -1,6 +1,10 @@ package service -import "testing" +import ( + "testing" + + roomv1 "hyapp.local/api/proto/room/v1" +) func TestRoomListSortScoreUsesCompressedWeightedInputs(t *testing.T) { score := roomListSortScore(7, 3, 2) @@ -16,3 +20,24 @@ func TestRoomListSortScoreCapsPresenceInputs(t *testing.T) { t.Fatalf("hot score should cap online and occupied seats, capped=%d over_cap=%d", capped, overCap) } } + +func TestRoomRocketNearCompletionUsesInclusiveTwentyPercentBoundary(t *testing.T) { + tests := []struct { + name string + rocket *roomv1.RoomRocketState + expects bool + }{ + {name: "above boundary", rocket: &roomv1.RoomRocketState{Status: "charging", CurrentFuel: 79, FuelThreshold: 100}}, + {name: "exact boundary", rocket: &roomv1.RoomRocketState{Status: "charging", CurrentFuel: 80, FuelThreshold: 100}, expects: true}, + {name: "small threshold remains above twenty percent", rocket: &roomv1.RoomRocketState{Status: "charging", CurrentFuel: 3, FuelThreshold: 4}}, + {name: "already ignited", rocket: &roomv1.RoomRocketState{Status: "ignited", CurrentFuel: 100, FuelThreshold: 100}}, + {name: "invalid overflow", rocket: &roomv1.RoomRocketState{Status: "charging", CurrentFuel: 101, FuelThreshold: 100}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := roomRocketNearCompletion(test.rocket); got != test.expects { + t.Fatalf("near-completion mismatch: got=%t want=%t rocket=%+v", got, test.expects, test.rocket) + } + }) + } +} diff --git a/services/room-service/internal/room/service/repository.go b/services/room-service/internal/room/service/repository.go index 169661ff..2d0f68e1 100644 --- a/services/room-service/internal/room/service/repository.go +++ b/services/room-service/internal/room/service/repository.go @@ -300,6 +300,14 @@ type RoomListEntry struct { OwnerVIPExpiresAtMS int64 // OwnerVIPProjectionKnown 区分“投影明确为 0”和“Room Cell 快照没有携带该外部事实”;它不写入 Redis payload。 OwnerVIPProjectionKnown bool `json:"-"` + // RocketNearCompletion 是 Room Cell 当前火箭进度的列表投影;普通房间命令会随最新快照覆盖它。 + RocketNearCompletion bool + // HasActiveRedPacket/ActiveRedPacketExpiresAtMS 是 wallet 红包事实经 MQ 派生的列表投影。 + // 到期时间让 Redis/MySQL 卡片无需同步回查 wallet,也能在过期边界立即停止展示。 + HasActiveRedPacket bool + ActiveRedPacketExpiresAtMS int64 + // ActiveRedPacketProjectionKnown 区分 wallet 事件投影与普通 Room Cell 快照,避免后者覆盖 Redis 外部事实。 + ActiveRedPacketProjectionKnown bool `json:"-"` // Title 是卡片标题;空值由客户端或后续资料聚合兜底。 Title string // CoverURL 是卡片封面;空值表示没有单独封面。 @@ -877,6 +885,28 @@ type RoomOwnerVIPProjectionStore interface { UpdateRoomOwnerVIP(ctx context.Context, ownerUserID int64, level int32, expiresAtMS int64) (RoomListEntry, bool, error) } +// RoomRedPacketProjectionEvent 是 wallet owner 红包事实进入房间列表读模型的最小契约。 +// 它不进入 Room Cell 核心状态;packet 级投影只用于正确聚合同房间多个并存红包。 +type RoomRedPacketProjectionEvent struct { + EventID string + EventType string + PacketID string + RoomID string + Status string + RemainingAmount int64 + RemainingCount int32 + ExpiresAtMS int64 + OccurredAtMS int64 + ProjectedAtMS int64 + EventRank int32 +} + +// RoomRedPacketProjectionStore 以独立 wallet consumer 位点更新红包卡片投影。 +// 生产 MySQL 实现必须在同一事务内完成事件幂等、packet 投影和 room_list_entries 聚合更新。 +type RoomRedPacketProjectionStore interface { + ApplyRoomRedPacketProjectionEvent(ctx context.Context, event RoomRedPacketProjectionEvent) (RoomListEntry, bool, error) +} + // RoomListCacheStore 是公共发现页 Redis 展示读模型边界。 // // MySQL 仍然是房间列表和置顶事实 owner;缓存只服务高频 hot/new 发现页, diff --git a/services/room-service/internal/room/service/room_list_cache.go b/services/room-service/internal/room/service/room_list_cache.go index 35835ee8..a1cf1bcb 100644 --- a/services/room-service/internal/room/service/room_list_cache.go +++ b/services/room-service/internal/room/service/room_list_cache.go @@ -181,8 +181,9 @@ func (s *RedisRoomListCacheStore) UpsertRoomListEntry(ctx context.Context, entry } entry.AppCode = app entry.OwnerCountryCode = normalizeRoomCountryCode(entry.OwnerCountryCode) - if !entry.OwnerVIPProjectionKnown { - // Room Cell 的普通状态刷新不携带 wallet 外部事实;保留现有卡片 VIP,避免送礼/上下麦后把等级误清零。 + if !entry.OwnerVIPProjectionKnown || !entry.ActiveRedPacketProjectionKnown { + // Room Cell 的普通状态刷新不携带 wallet 外部事实。只读一次旧卡片并按 known 标记合并,避免 + // 送礼/上下麦/火箭进度刷新误清 VIP 或红包,同时不为一次投影写入制造重复 Redis 往返。 raw, err := s.client.HGet(ctx, roomListCacheCardKey(app), roomID).Result() if err != nil && err != redis.Nil { return err @@ -190,8 +191,14 @@ func (s *RedisRoomListCacheStore) UpsertRoomListEntry(ctx context.Context, entry if err == nil { var previous RoomListEntry if json.Unmarshal([]byte(raw), &previous) == nil { - entry.OwnerVIPLevel = previous.OwnerVIPLevel - entry.OwnerVIPExpiresAtMS = previous.OwnerVIPExpiresAtMS + if !entry.OwnerVIPProjectionKnown { + entry.OwnerVIPLevel = previous.OwnerVIPLevel + entry.OwnerVIPExpiresAtMS = previous.OwnerVIPExpiresAtMS + } + if !entry.ActiveRedPacketProjectionKnown { + entry.HasActiveRedPacket = previous.HasActiveRedPacket + entry.ActiveRedPacketExpiresAtMS = previous.ActiveRedPacketExpiresAtMS + } } } } diff --git a/services/room-service/internal/room/service/wallet_red_packet_projection.go b/services/room-service/internal/room/service/wallet_red_packet_projection.go new file mode 100644 index 00000000..5b9168e8 --- /dev/null +++ b/services/room-service/internal/room/service/wallet_red_packet_projection.go @@ -0,0 +1,90 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "hyapp/pkg/appcode" + "hyapp/pkg/walletmq" + "hyapp/pkg/xerr" +) + +// HandleWalletRedPacketProjectionEvent 消费 wallet realtime outbox,异步刷新房间列表红包聚合。 +// 该链路不进入 ListRooms 请求,也不读取 wallet 数据库;列表延迟只增加同一行两个标量字段的反序列化成本。 +func (s *Service) HandleWalletRedPacketProjectionEvent(ctx context.Context, message walletmq.WalletOutboxMessage) error { + event, handled, err := roomRedPacketProjectionEvent(message, s.clock.Now().UnixMilli()) + if err != nil || !handled { + return err + } + store, ok := s.repository.(RoomRedPacketProjectionStore) + if !ok { + return xerr.New(xerr.Unavailable, "room red packet projection store is unavailable") + } + ctx = appcode.WithContext(ctx, message.AppCode) + entry, exists, err := store.ApplyRoomRedPacketProjectionEvent(ctx, event) + if err != nil || !exists { + return err + } + // MySQL 事务是权威投影;Redis 失败只影响短期命中率,MQ 重投和定时重建都会继续收敛。 + s.projectRoomListCacheBestEffort(ctx, entry) + return nil +} + +func roomRedPacketProjectionEvent(message walletmq.WalletOutboxMessage, projectedAtMS int64) (RoomRedPacketProjectionEvent, bool, error) { + eventType := strings.TrimSpace(message.EventType) + eventRank := int32(0) + switch eventType { + case walletmq.EventTypeWalletRedPacketCreated: + eventRank = 1 + case walletmq.EventTypeWalletRedPacketClaimed: + eventRank = 2 + case walletmq.EventTypeWalletRedPacketRefunded: + eventRank = 3 + default: + return RoomRedPacketProjectionEvent{}, false, nil + } + + var payload struct { + PacketID string `json:"packet_id"` + RoomID string `json:"room_id"` + Status string `json:"status"` + PacketStatus string `json:"packet_status"` + RemainingAmount int64 `json:"remaining_amount"` + RemainingCount int32 `json:"remaining_count"` + ExpiresAtMS int64 `json:"expires_at_ms"` + } + if err := json.Unmarshal([]byte(message.PayloadJSON), &payload); err != nil { + return RoomRedPacketProjectionEvent{}, true, err + } + status := strings.ToLower(strings.TrimSpace(payload.Status)) + if payload.PacketStatus != "" { + status = strings.ToLower(strings.TrimSpace(payload.PacketStatus)) + } + if eventType == walletmq.EventTypeWalletRedPacketRefunded { + status = "refunded" + payload.RemainingAmount = 0 + payload.RemainingCount = 0 + } + if strings.TrimSpace(message.EventID) == "" || strings.TrimSpace(payload.PacketID) == "" || + strings.TrimSpace(payload.RoomID) == "" || status == "" || message.OccurredAtMS <= 0 { + return RoomRedPacketProjectionEvent{}, true, fmt.Errorf("%s projection payload is incomplete", eventType) + } + if projectedAtMS <= 0 { + projectedAtMS = message.OccurredAtMS + } + return RoomRedPacketProjectionEvent{ + EventID: strings.TrimSpace(message.EventID), + EventType: eventType, + PacketID: strings.TrimSpace(payload.PacketID), + RoomID: strings.TrimSpace(payload.RoomID), + Status: status, + RemainingAmount: payload.RemainingAmount, + RemainingCount: payload.RemainingCount, + ExpiresAtMS: payload.ExpiresAtMS, + OccurredAtMS: message.OccurredAtMS, + ProjectedAtMS: projectedAtMS, + EventRank: eventRank, + }, true, nil +} diff --git a/services/room-service/internal/storage/mysql/room_list.go b/services/room-service/internal/storage/mysql/room_list.go index 98e243c5..b4a1791f 100644 --- a/services/room-service/internal/storage/mysql/room_list.go +++ b/services/room-service/internal/storage/mysql/room_list.go @@ -20,8 +20,9 @@ func (r *Repository) UpsertRoomListEntry(ctx context.Context, entry roomservice. _, err := r.db.ExecContext(ctx, `INSERT INTO room_list_entries ( app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked, - heat, online_count, seat_count, occupied_seat_count, sort_score, room_border_json, room_name_style_json, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, ?) + heat, online_count, seat_count, occupied_seat_count, sort_score, rocket_near_completion, + room_border_json, room_name_style_json, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, ?) ON DUPLICATE KEY UPDATE room_short_id = VALUES(room_short_id), visible_region_id = VALUES(visible_region_id), @@ -37,6 +38,7 @@ func (r *Repository) UpsertRoomListEntry(ctx context.Context, entry roomservice. seat_count = VALUES(seat_count), occupied_seat_count = VALUES(occupied_seat_count), sort_score = VALUES(sort_score), + rocket_near_completion = VALUES(rocket_near_completion), room_border_json = VALUES(room_border_json), room_name_style_json = VALUES(room_name_style_json), updated_at_ms = VALUES(updated_at_ms)`, @@ -56,6 +58,7 @@ func (r *Repository) UpsertRoomListEntry(ctx context.Context, entry roomservice. entry.SeatCount, entry.OccupiedSeatCount, entry.SortScore, + entry.RocketNearCompletion, entry.RoomBorderJSON, entry.RoomNameStyleJSON, entry.CreatedAtMS, @@ -118,7 +121,8 @@ func (r *Repository) ListRoomListEntriesByIDs(ctx context.Context, query roomser SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked, heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms, COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''), - owner_vip_level, owner_vip_expires_at_ms + owner_vip_level, owner_vip_expires_at_ms, rocket_near_completion, + has_active_red_packet, active_red_packet_expires_at_ms FROM room_list_entries WHERE app_code = ? AND room_id IN (`+placeholders+`)`, args...) if err != nil { @@ -281,6 +285,13 @@ func scanRoomListEntry(scanner interface{ Scan(dest ...any) error }) (roomservic dest = append(dest, &entry.OwnerVIPLevel, &entry.OwnerVIPExpiresAtMS) entry.OwnerVIPProjectionKnown = true } + if columnCount >= len(dest)+1 { + dest = append(dest, &entry.RocketNearCompletion) + } + if columnCount >= len(dest)+2 { + dest = append(dest, &entry.HasActiveRedPacket, &entry.ActiveRedPacketExpiresAtMS) + entry.ActiveRedPacketProjectionKnown = true + } err := scanner.Scan(dest...) return entry, err } @@ -302,6 +313,13 @@ func scanRoomListEntryWithPins(scanner interface{ Scan(dest ...any) error }) (ro dest = append(dest, &entry.OwnerVIPLevel, &entry.OwnerVIPExpiresAtMS) entry.OwnerVIPProjectionKnown = true } + if columnCount >= len(dest)+1+4 { + dest = append(dest, &entry.RocketNearCompletion) + } + if columnCount >= len(dest)+2+4 { + dest = append(dest, &entry.HasActiveRedPacket, &entry.ActiveRedPacketExpiresAtMS) + entry.ActiveRedPacketProjectionKnown = true + } dest = append(dest, &pinned, &entry.PinListRank, &entry.PinWeight, &entry.PinnedUntilMS) err := scanner.Scan(dest...) return entry, pinned, err @@ -546,7 +564,8 @@ const roomListSelectColumns = ` SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked, heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms, COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''), - owner_vip_level, owner_vip_expires_at_ms + owner_vip_level, owner_vip_expires_at_ms, rocket_near_completion, + has_active_red_packet, active_red_packet_expires_at_ms FROM room_list_entries` // owner 定向列表复用公共列表扫描器,但不参与公共发现页置顶排序;这里补零值列保持 scan 契约稳定。 @@ -554,7 +573,8 @@ const roomListOwnerSelectColumns = ` SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked, heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms, COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''), - owner_vip_level, owner_vip_expires_at_ms, + owner_vip_level, owner_vip_expires_at_ms, rocket_near_completion, + has_active_red_packet, active_red_packet_expires_at_ms, 0 AS is_pinned, 0 AS pin_list_rank, 0 AS pin_weight, 0 AS pinned_until_ms FROM room_list_entries` @@ -562,7 +582,8 @@ const roomUserFeedSelectColumns = ` SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked, r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.updated_at_ms, COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''), - r.owner_vip_level, r.owner_vip_expires_at_ms + r.owner_vip_level, r.owner_vip_expires_at_ms, r.rocket_near_completion, + r.has_active_red_packet, r.active_red_packet_expires_at_ms FROM room_user_feed_entries f JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id` @@ -570,7 +591,8 @@ const roomFollowSelectColumns = ` SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked, r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.followed_at_ms, COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''), - r.owner_vip_level, r.owner_vip_expires_at_ms + r.owner_vip_level, r.owner_vip_expires_at_ms, r.rocket_near_completion, + r.has_active_red_packet, r.active_red_packet_expires_at_ms FROM room_follows f JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id` @@ -613,7 +635,8 @@ func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) { SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked, r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, r.updated_at_ms, COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''), - r.owner_vip_level, r.owner_vip_expires_at_ms, + r.owner_vip_level, r.owner_vip_expires_at_ms, r.rocket_near_completion, + r.has_active_red_packet, r.active_red_packet_expires_at_ms, ` + pinnedExpr + ` AS is_pinned, ` + pinRankExpr + ` AS pin_list_rank, ` + pinWeightExpr + ` AS pin_weight, ` + pinExpiresExpr + ` AS pinned_until_ms FROM room_list_entries r LEFT JOIN room_region_pins gp diff --git a/services/room-service/internal/storage/mysql/room_red_packet_projection.go b/services/room-service/internal/storage/mysql/room_red_packet_projection.go new file mode 100644 index 00000000..b14079ee --- /dev/null +++ b/services/room-service/internal/storage/mysql/room_red_packet_projection.go @@ -0,0 +1,100 @@ +package mysql + +import ( + "context" + "strings" + + "hyapp/pkg/appcode" + roomservice "hyapp/services/room-service/internal/room/service" +) + +// ApplyRoomRedPacketProjectionEvent 在 wallet 事件消费事务内维护 packet 级状态,再聚合同房间是否仍有有效红包。 +// 高频列表只读 room_list_entries 同一行;这里的 MAX 查询只在红包状态变化时执行,并命中 +// idx_room_red_packet_active(app_code, room_id, status, expires_at_ms)。 +func (r *Repository) ApplyRoomRedPacketProjectionEvent(ctx context.Context, event roomservice.RoomRedPacketProjectionEvent) (roomservice.RoomListEntry, bool, error) { + app := normalizedRecordAppCode(ctx, "") + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return roomservice.RoomListEntry{}, false, err + } + defer func() { _ = tx.Rollback() }() + + // consumption 主键先占位;后续任一步失败会随事务回滚,at-least-once 重投仍可完整重做。 + result, err := tx.ExecContext(ctx, ` + INSERT IGNORE INTO room_wallet_event_consumption (app_code, event_id, event_type, consumed_at_ms) + VALUES (?, ?, ?, ?)`, app, event.EventID, event.EventType, event.ProjectedAtMS) + if err != nil { + return roomservice.RoomListEntry{}, false, err + } + inserted, err := result.RowsAffected() + if err != nil { + return roomservice.RoomListEntry{}, false, err + } + if inserted > 0 { + // 乱序保护按事实时间、终态等级、剩余份数依次收敛;同毫秒的晚到 created 不能覆盖 claimed/refunded。 + const newer = `(VALUES(event_occurred_at_ms) > event_occurred_at_ms OR + (VALUES(event_occurred_at_ms) = event_occurred_at_ms AND VALUES(event_rank) > event_rank) OR + (VALUES(event_occurred_at_ms) = event_occurred_at_ms AND VALUES(event_rank) = event_rank AND VALUES(remaining_count) <= remaining_count))` + _, err = tx.ExecContext(ctx, ` + INSERT INTO room_red_packet_projections ( + app_code, packet_id, room_id, status, remaining_amount, remaining_count, + expires_at_ms, event_rank, event_occurred_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + room_id = IF(`+newer+`, VALUES(room_id), room_id), + status = IF(`+newer+`, VALUES(status), status), + remaining_amount = IF(`+newer+`, VALUES(remaining_amount), remaining_amount), + remaining_count = IF(`+newer+`, VALUES(remaining_count), remaining_count), + expires_at_ms = IF(`+newer+` AND VALUES(expires_at_ms) > 0, VALUES(expires_at_ms), expires_at_ms), + event_rank = IF(`+newer+`, VALUES(event_rank), event_rank), + event_occurred_at_ms = IF(`+newer+`, VALUES(event_occurred_at_ms), event_occurred_at_ms), + updated_at_ms = IF(`+newer+`, VALUES(updated_at_ms), updated_at_ms)`, + app, event.PacketID, event.RoomID, event.Status, event.RemainingAmount, event.RemainingCount, + event.ExpiresAtMS, event.EventRank, event.OccurredAtMS, event.ProjectedAtMS, + ) + if err != nil { + return roomservice.RoomListEntry{}, false, err + } + + var activeExpiresAtMS int64 + if err := tx.QueryRowContext(ctx, ` + SELECT COALESCE(MAX(expires_at_ms), 0) + FROM room_red_packet_projections + WHERE app_code = ? AND room_id = ? + AND status IN ('waiting_open', 'active') + AND remaining_count > 0 AND remaining_amount > 0 + AND expires_at_ms > ?`, app, event.RoomID, event.ProjectedAtMS).Scan(&activeExpiresAtMS); err != nil { + return roomservice.RoomListEntry{}, false, err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE room_list_entries + SET has_active_red_packet = ?, active_red_packet_expires_at_ms = ? + WHERE app_code = ? AND room_id = ?`, activeExpiresAtMS > 0, activeExpiresAtMS, app, event.RoomID); err != nil { + return roomservice.RoomListEntry{}, false, err + } + } + if err := tx.Commit(); err != nil { + return roomservice.RoomListEntry{}, false, err + } + + // 幂等重投也重新读取卡片:若首次事务已提交但 Redis 刷新失败,重投仍能把缓存收敛到 MySQL。 + return r.roomListEntryByID(ctx, app, event.RoomID) +} + +func (r *Repository) roomListEntryByID(ctx context.Context, app string, roomID string) (roomservice.RoomListEntry, bool, error) { + rows, err := r.db.QueryContext(ctx, roomListSelectColumns+` + WHERE app_code = ? AND room_id = ? + LIMIT 1`, appcode.Normalize(app), strings.TrimSpace(roomID)) + if err != nil { + return roomservice.RoomListEntry{}, false, err + } + defer rows.Close() + if !rows.Next() { + return roomservice.RoomListEntry{}, false, rows.Err() + } + entry, err := scanRoomListEntry(rows) + if err != nil { + return roomservice.RoomListEntry{}, false, err + } + return entry, true, rows.Err() +} diff --git a/services/room-service/internal/storage/mysql/schema.go b/services/room-service/internal/storage/mysql/schema.go index 8bd3c831..569eeeb4 100644 --- a/services/room-service/internal/storage/mysql/schema.go +++ b/services/room-service/internal/storage/mysql/schema.go @@ -53,6 +53,9 @@ func (r *Repository) Migrate(ctx context.Context) error { seat_count INT NOT NULL DEFAULT 0, occupied_seat_count INT NOT NULL DEFAULT 0, sort_score BIGINT NOT NULL DEFAULT 0, + rocket_near_completion BOOLEAN NOT NULL DEFAULT FALSE, + has_active_red_packet BOOLEAN NOT NULL DEFAULT FALSE, + active_red_packet_expires_at_ms BIGINT NOT NULL DEFAULT 0, room_border_json JSON NULL, room_name_style_json JSON NULL, created_at_ms BIGINT NOT NULL, @@ -183,6 +186,20 @@ func (r *Repository) Migrate(ctx context.Context) error { PRIMARY KEY (app_code, event_id), KEY idx_room_wallet_event_consumed (app_code, consumed_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS room_red_packet_projections ( + app_code VARCHAR(32) NOT NULL, + packet_id VARCHAR(128) NOT NULL, + room_id VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + remaining_amount BIGINT NOT NULL DEFAULT 0, + remaining_count INT NOT NULL DEFAULT 0, + expires_at_ms BIGINT NOT NULL DEFAULT 0, + event_rank INT NOT NULL DEFAULT 0, + event_occurred_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, packet_id), + KEY idx_room_red_packet_active (app_code, room_id, status, expires_at_ms) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS room_snapshots ( app_code VARCHAR(32) NOT NULL, room_id VARCHAR(64) NOT NULL, diff --git a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql index 1aaeb855..51f7bab5 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -2351,7 +2351,7 @@ INSERT IGNORE INTO vip_levels ( ('fami', 9, 'VIP9', 'disabled', 0, 2592000000, 0, 0, 90, 0, 0); -- P1 权益按每个等级展开成完整集合:例如 VIP4 会实际写入 VIP1..VIP4 的全部权益。 --- resource_id/resource_type 保持空,待运营绑定真实资源;体验卡仅排除每日金币返现。 +-- resource_id 默认保持空,待运营绑定真实资源;固定房间装扮仍预置精确 resource_type,体验卡仅排除每日金币返现。 INSERT IGNORE INTO vip_level_benefits ( app_code, level, benefit_code, name, benefit_type, unlock_level, status, trial_enabled, resource_id, resource_type, execution_scope, auto_equip, sort_order, metadata_json, @@ -2366,6 +2366,7 @@ SELECT WHEN 'mic_skin' THEN 'mic_seat_icon' WHEN 'room_border' THEN 'room_border' WHEN 'colored_room_name' THEN 'room_name_style' + WHEN 'gift_tray_skin' THEN 'gift_tray_skin' ELSE '' END, benefits.execution_scope, @@ -2401,7 +2402,7 @@ INNER JOIN ( UNION ALL SELECT 'colored_room_name', 'VIP彩色房间名称', 'decoration', 5, TRUE, 'room', FALSE, 180 UNION ALL SELECT 'colored_nickname', 'VIP彩色昵称', 'function', 5, TRUE, 'user', FALSE, 190 UNION ALL SELECT 'colored_id', 'VIP彩色ID', 'function', 5, TRUE, 'user', FALSE, 200 - UNION ALL SELECT 'gift_tray_skin', '送礼托盘皮肤', 'function', 5, TRUE, 'room', FALSE, 210 + UNION ALL SELECT 'gift_tray_skin', '送礼托盘皮肤', 'decoration', 5, TRUE, 'room', FALSE, 210 UNION ALL SELECT 'hide_profile_data', '隐藏个人数据', 'function', 6, TRUE, 'user', FALSE, 220 UNION ALL SELECT 'custom_avatar_frame', '定制头框', 'function', 6, TRUE, 'user', FALSE, 230 UNION ALL SELECT 'leaderboard_invisible', '榜单隐身', 'function', 6, TRUE, 'user', FALSE, 240 @@ -2431,6 +2432,27 @@ WHERE app_code = 'fami' OR CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_amount')) AS DECIMAL(65, 0)) > 9223372036854775807 ); +-- 礼物托盘是需要真实 SVGA 素材的装扮权益,不再把 active function 当作“客户端按等级猜素材”。 +-- fresh init 没有运营资源时先保持 disabled;后台绑定同 App active gift_tray_skin 后才能启用。 +UPDATE vip_level_benefits AS benefit +LEFT JOIN resources AS resource + ON resource.app_code = benefit.app_code + AND resource.resource_id = benefit.resource_id + AND resource.status = 'active' + AND resource.resource_type = 'gift_tray_skin' +SET benefit.benefit_type = 'decoration', + benefit.resource_type = 'gift_tray_skin', + benefit.resource_id = CASE WHEN resource.resource_id IS NULL THEN 0 ELSE benefit.resource_id END, + benefit.status = CASE + WHEN resource.resource_id IS NULL + OR TRIM(resource.animation_url) = '' + OR LOWER(TRIM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(resource.metadata_json, '$.format')), ''))) <> 'svga' + THEN 'disabled' + ELSE benefit.status + END +WHERE benefit.app_code = 'fami' + AND benefit.benefit_code = 'gift_tray_skin'; + -- 老环境可能先执行过不含文案的 seed;这里只补空元数据,运营已配置的 App 文案必须保留。 UPDATE vip_level_benefits SET metadata_json = JSON_OBJECT('message', '欢迎进入Fami,祝你有美好的一天') diff --git a/services/wallet-service/deploy/mysql/migrations/021_fami_vip_gift_tray_skin_resource_contract.sql b/services/wallet-service/deploy/mysql/migrations/021_fami_vip_gift_tray_skin_resource_contract.sql new file mode 100644 index 00000000..cd85cafc --- /dev/null +++ b/services/wallet-service/deploy/mysql/migrations/021_fami_vip_gift_tray_skin_resource_contract.sql @@ -0,0 +1,37 @@ +-- Fami gift_tray_skin 必须绑定真实 SVGA 目录资源;旧 function/空资源行不能继续伪装成可执行视觉权益。 +-- 过滤使用 idx_vip_benefit_code(app_code, benefit_code, status, level),关联资源使用 resources 主键, +-- 只会触碰 Fami VIP5..VIP9 的小型权益矩阵,不扫描用户会员、背包或账务流水。 +SET @gift_tray_contract_now_ms := CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +UPDATE vip_level_benefits AS benefit +LEFT JOIN resources AS resource + ON resource.app_code = benefit.app_code + AND resource.resource_id = benefit.resource_id + AND resource.status = 'active' + AND resource.resource_type = 'gift_tray_skin' +SET benefit.benefit_type = 'decoration', + benefit.resource_type = 'gift_tray_skin', + benefit.resource_id = CASE WHEN resource.resource_id IS NULL THEN 0 ELSE benefit.resource_id END, + benefit.status = CASE + WHEN resource.resource_id IS NULL + OR TRIM(resource.animation_url) = '' + OR LOWER(TRIM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(resource.metadata_json, '$.format')), ''))) <> 'svga' + THEN 'disabled' + ELSE benefit.status + END, + benefit.updated_at_ms = @gift_tray_contract_now_ms +WHERE benefit.app_code = 'fami' + AND benefit.benefit_code = 'gift_tray_skin' + AND ( + benefit.benefit_type <> 'decoration' + OR benefit.resource_type <> 'gift_tray_skin' + OR (benefit.resource_id > 0 AND resource.resource_id IS NULL) + OR ( + benefit.status = 'active' + AND ( + resource.resource_id IS NULL + OR TRIM(resource.animation_url) = '' + OR LOWER(TRIM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(resource.metadata_json, '$.format')), ''))) <> 'svga' + ) + ) + ); diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index 92203e33..d045d162 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -4866,8 +4866,13 @@ func TestGiftConfigMustSelectGiftResource(t *testing.T) { t.Fatalf("VIP gift denial must expose stable level metadata, got %+v", detail) } assertBalance(t, svc, 41003, ledger.AssetCoin, 100) + // VIP 礼物只声明最低等级;VIP9 必须同时覆盖 VIP3 的单目标和批量送礼入口。 + // 测试库只发布 normal 倍率,确保 noble 分类能继承真实运营政策而非依赖专属配置行。 + if got := repository.CountRows("gift_diamond_ratio_configs", "app_code = ? AND gift_type_code = ?", appcode.Default, resourcedomain.GiftTypeNoble); got != 0 { + t.Fatalf("VIP gift regression requires no dedicated noble ratio, got %d", got) + } nowMS := time.Now().UnixMilli() - repository.SeedPaidVIPAtWindow(appcode.Default, 41003, 3, nowMS-1000, nowMS+60_000) + repository.SeedPaidVIPAtWindow(appcode.Default, 41003, 9, nowMS-1000, nowMS+60_000) vipReceipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ CommandID: "cmd-vip-spark-allowed", RoomID: "room-1", @@ -4877,11 +4882,28 @@ func TestGiftConfigMustSelectGiftResource(t *testing.T) { GiftCount: 1, }) if err != nil { - t.Fatalf("DebitGift must allow sender at configured VIP level: %v", err) + t.Fatalf("DebitGift must allow a higher VIP level to send a lower-level gift: %v", err) } if vipReceipt.CoinSpent != 5 { t.Fatalf("VIP gift settlement mismatch: %+v", vipReceipt) } + batchReceipt, err := svc.BatchDebitGift(ctx, ledger.BatchDebitGiftCommand{ + CommandID: "cmd-vip-spark-batch-allowed", + RoomID: "room-1", + SenderUserID: 41003, + GiftID: "vip-spark", + GiftCount: 1, + Targets: []ledger.DebitGiftTargetCommand{ + {CommandID: "cmd-vip-spark-batch-allowed:41002", TargetUserID: 41002}, + {CommandID: "cmd-vip-spark-batch-allowed:41004", TargetUserID: 41004}, + }, + }) + if err != nil { + t.Fatalf("BatchDebitGift must allow a higher VIP level to send a lower-level gift: %v", err) + } + if batchReceipt.Aggregate.CoinSpent != 10 || len(batchReceipt.Targets) != 2 { + t.Fatalf("VIP batch gift settlement mismatch: %+v", batchReceipt) + } repository.SetBalance(41001, 100) receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ diff --git a/services/wallet-service/internal/storage/mysql/gift_pricing.go b/services/wallet-service/internal/storage/mysql/gift_pricing.go index 1c26c4ca..ad642bbc 100644 --- a/services/wallet-service/internal/storage/mysql/gift_pricing.go +++ b/services/wallet-service/internal/storage/mysql/gift_pricing.go @@ -10,6 +10,7 @@ import ( "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" ) type giftPrice struct { @@ -72,25 +73,24 @@ func (r *Repository) resolveGiftPrice(ctx context.Context, tx *sql.Tx, giftID st } func (r *Repository) resolveGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) { - giftTypeCode = strings.TrimSpace(giftTypeCode) - if giftTypeCode == "" { - giftTypeCode = "normal" - } + giftTypeCodes := giftRatioTypeCandidates(giftTypeCode) regions := []int64{regionID} if regionID != 0 { regions = append(regions, 0) } for _, regionID := range regions { - ratio, exists, err := r.getGiftDiamondRatio(ctx, tx, appCode, regionID, giftTypeCode) - if err != nil { - return giftDiamondRatioSnapshot{}, 0, err - } - if exists { - return ratio, regionID, nil + for _, candidate := range giftTypeCodes { + ratio, exists, err := r.getGiftDiamondRatio(ctx, tx, appCode, regionID, candidate) + if err != nil { + return giftDiamondRatioSnapshot{}, 0, err + } + if exists { + return ratio, regionID, nil + } } } - // 礼物类型倍率属于后台运行政策。区域和全局都未配置时必须在扣币前失败, - // 不能用 normal/lucky/super_lucky 编译期常量悄悄制造 Host 收益。 + // 礼物类型倍率属于后台运行政策。VIP 礼物只继承同区域或全局已持久化的 normal 政策, + // 其余类型仍在区域和全局都未配置时 fail-close,不能用编译期百分比制造 Host 收益。 return giftDiamondRatioSnapshot{}, 0, xerr.New(xerr.PermissionDenied, "gift diamond ratio is not configured") } @@ -126,28 +126,38 @@ func (r *Repository) getGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCod } func (r *Repository) resolveGiftReturnCoinRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) { - giftTypeCode = strings.TrimSpace(giftTypeCode) - if giftTypeCode == "" { - giftTypeCode = "normal" - } + giftTypeCodes := giftRatioTypeCandidates(giftTypeCode) regions := []int64{regionID} if regionID != 0 { regions = append(regions, 0) } for _, regionID := range regions { - ratio, exists, err := r.getGiftReturnCoinRatio(ctx, tx, appCode, regionID, giftTypeCode) - if err != nil { - return giftDiamondRatioSnapshot{}, 0, err - } - if exists { - return ratio, regionID, nil + for _, candidate := range giftTypeCodes { + ratio, exists, err := r.getGiftReturnCoinRatio(ctx, tx, appCode, regionID, candidate) + if err != nil { + return giftDiamondRatioSnapshot{}, 0, err + } + if exists { + return ratio, regionID, nil + } } } - // 返金币与主播钻石倍率共用后台配置行,但业务含义独立。区域和全局均无记录时 - // 必须在账户锁和扣币前失败,不能再按 gift_type 推导 30/10/1。 + // 返金币与主播钻石倍率共用后台配置行,但业务含义独立。VIP 礼物继承已持久化的 + // normal 返币政策;没有任何可继承政策时仍必须在账户锁和扣币前失败。 return giftDiamondRatioSnapshot{}, 0, xerr.New(xerr.PermissionDenied, "gift return coin ratio is not configured") } +// giftRatioTypeCandidates 将 VIP 身份门禁与礼物经济分类解耦:noble/vip 先读取运营可选的 +// 专属倍率,缺失时继承 normal 的已发布倍率。这样新增 VIP 展示分类不会要求运营复制整套 +// 区域政策,也不会绕过 fail-close,因为 normal 行缺失时仍然拒绝扣费。 +func giftRatioTypeCandidates(giftTypeCode string) []string { + normalized := resourcedomain.NormalizeGiftTypeCode(giftTypeCode) + if resourcedomain.IsVIPGiftType(normalized) { + return []string{normalized, resourcedomain.GiftTypeNormal} + } + return []string{normalized} +} + func (r *Repository) getGiftReturnCoinRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, bool, error) { var percent string var ppm int64 diff --git a/services/wallet-service/internal/storage/mysql/resource_catalog_repository.go b/services/wallet-service/internal/storage/mysql/resource_catalog_repository.go index 0375ea8b..6d6609fd 100644 --- a/services/wallet-service/internal/storage/mysql/resource_catalog_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_catalog_repository.go @@ -649,6 +649,19 @@ func validateVIPTrialCardCatalogResource(command resourcedomain.ResourceCommand) func validateRoomDecorationResource(command resourcedomain.ResourceCommand) error { switch command.ResourceType { + case resourcedomain.TypeGiftTraySkin: + // Flutter 的 Fami 托盘背景使用网络 SVGA 播放器;目录 owner 在写入时拒绝空 URL 或其它格式, + // 避免权益判定成功后客户端仍只能因解码失败回落普通渐变。 + if command.AnimationURL == "" { + return xerr.New(xerr.InvalidArgument, "vip gift tray animation is required") + } + var metadata struct { + Format string `json:"format"` + } + if err := json.Unmarshal([]byte(command.MetadataJSON), &metadata); err != nil || !strings.EqualFold(strings.TrimSpace(metadata.Format), "svga") { + return xerr.New(xerr.InvalidArgument, "vip gift tray animation format must be svga") + } + return nil case resourcedomain.TypeMicSeatAnimation, resourcedomain.TypeRoomBorder: // Room Cell/appearance 只保存资源快照,不会替运营生成素材;目录层拒绝空壳视觉资源, // 同时固化格式,Flutter 才不需要从 URL 后缀猜 PAG、SVGA 或 MP4。 diff --git a/services/wallet-service/internal/storage/mysql/resource_repository_test.go b/services/wallet-service/internal/storage/mysql/resource_repository_test.go index 1373eebc..ad1a2516 100644 --- a/services/wallet-service/internal/storage/mysql/resource_repository_test.go +++ b/services/wallet-service/internal/storage/mysql/resource_repository_test.go @@ -6,6 +6,22 @@ import ( resourcedomain "hyapp/services/wallet-service/internal/domain/resource" ) +func TestGiftTraySkinRequiresSVGAAnimation(t *testing.T) { + base := resourcedomain.ResourceCommand{ + ResourceCode: "vip_gift_tray_5", ResourceType: resourcedomain.TypeGiftTraySkin, + Name: "VIP5 Gift Tray", Status: resourcedomain.StatusActive, + GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, + AnimationURL: "https://cdn.example/vip-gift-tray.svga", MetadataJSON: `{"format":"svga"}`, + } + if err := validateResourceCommand(base); err != nil { + t.Fatalf("valid SVGA gift tray should be accepted: %v", err) + } + base.MetadataJSON = `{"format":"mp4"}` + if err := validateResourceCommand(base); err == nil { + t.Fatal("gift tray must reject formats unsupported by the Fami SVGA player") + } +} + func TestDecorativeResourceTypesAreEquipable(t *testing.T) { for _, resourceType := range []string{resourcedomain.TypeProfileCard, resourcedomain.TypeMicSeatIcon, resourcedomain.TypeMicSeatAnimation} { if !isEquipableResourceType(resourceType) { diff --git a/services/wallet-service/internal/storage/mysql/vip_admin_repository.go b/services/wallet-service/internal/storage/mysql/vip_admin_repository.go index 361d46de..54565912 100644 --- a/services/wallet-service/internal/storage/mysql/vip_admin_repository.go +++ b/services/wallet-service/internal/storage/mysql/vip_admin_repository.go @@ -437,6 +437,9 @@ func (r *Repository) validateAdminVipBenefit(ctx context.Context, tx *sql.Tx, pr expectedResourceType = resourcedomain.TypeRoomBorder case ledger.VipBenefitCodeColoredRoomName: expectedResourceType = resourcedomain.TypeRoomNameStyle + case ledger.VipBenefitCodeGiftTraySkin: + // Fami 客户端的托盘承接器只播放网络 SVGA;固定目录类型后,执行链路只需透传 wallet 已验证的 animation_url。 + expectedResourceType = resourcedomain.TypeGiftTraySkin } } if expectedResourceType != "" { @@ -463,10 +466,23 @@ func (r *Repository) validateAdminVipBenefit(ctx context.Context, tx *sql.Tx, pr if benefit.ResourceType != "" && resourcedomain.NormalizeResourceType(resource.ResourceType) != benefit.ResourceType { return xerr.New(xerr.Conflict, "vip benefit resource_type mismatch") } + if benefit.BenefitCode == ledger.VipBenefitCodeGiftTraySkin && !validGiftTraySkinResource(resource) { + return xerr.New(xerr.Conflict, "vip gift tray resource must provide svga animation") + } _ = level return nil } +func validGiftTraySkinResource(resource resourcedomain.Resource) bool { + if resourcedomain.NormalizeResourceType(resource.ResourceType) != resourcedomain.TypeGiftTraySkin || strings.TrimSpace(resource.AnimationURL) == "" { + return false + } + var metadata struct { + Format string `json:"format"` + } + return json.Unmarshal([]byte(resource.MetadataJSON), &metadata) == nil && strings.EqualFold(strings.TrimSpace(metadata.Format), "svga") +} + func normalizeAdminVipProgramConfig(config ledger.VipProgramConfig) ledger.VipProgramConfig { config.AppCode = appcode.Normalize(config.AppCode) config.ProgramType = strings.ToLower(strings.TrimSpace(config.ProgramType))