增加相关改动

This commit is contained in:
zhx 2026-07-22 21:43:11 +08:00
parent 6428a26413
commit aa29614b28
67 changed files with 1995 additions and 591 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 '配置区域 ID0 表示全区域默认配置',
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 '配置区域 ID0 表示全区域默认配置',
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 '事件发生时房间区域 ID0 表示全区域可见',
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 '结算对应房间区域 ID0 表示全区域可见',
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 '配置区域 ID0 表示全区域默认活动',
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='代理开业流水事件幂等表';

View File

@ -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 '配置区域 ID0 表示全区域默认配置', ALGORITHM=INSTANT;
ALTER TABLE room_turnover_reward_tiers
ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '配置区域 ID0 表示全区域默认配置', 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 '配置区域 ID0 表示全区域默认活动', 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 '配置区域 ID0 表示全区域默认活动',
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 '配置区域 ID0 表示全区域默认活动', 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

View File

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

View File

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

View File

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

View File

@ -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 '配置区域 ID0 表示全区域默认配置',
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 '配置区域 ID0 表示全区域默认配置',
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 '事件发生时房间区域 ID0 表示全区域可见',
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 '结算对应房间区域 ID0 表示全区域可见',
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 '配置区域 ID0 表示全区域默认配置', ALGORITHM=INSTANT`},
{"room_turnover_reward_tiers", `ALTER TABLE room_turnover_reward_tiers ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 COMMENT '配置区域 ID0 表示全区域默认配置', 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 '应用编码,用于多租户隔离',

View File

@ -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='红包房间列表异步投影,不承载资金事实';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 记录发射命令中已经结算出的资源组发放结果。

View File

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

View File

@ -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 只透传,避免统计和全服广播各自重新判定礼物效果。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 发现页,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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祝你有美好的一天')

View File

@ -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'
)
)
);

View File

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

View File

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

View File

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

View File

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

View File

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