增加相关改动

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"` 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"` 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"` UpdatedAtMs int64 `protobuf:"varint,6,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
unknownFields protoimpl.UnknownFields // region_id=0 表示全区域默认配置;正数表示只覆盖对应区域。
sizeCache protoimpl.SizeCache 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() { func (x *RoomTurnoverRewardConfig) Reset() {
@ -10200,6 +10202,13 @@ func (x *RoomTurnoverRewardConfig) GetUpdatedAtMs() int64 {
return 0 return 0
} }
func (x *RoomTurnoverRewardConfig) GetRegionId() int64 {
if x != nil {
return x.RegionId
}
return 0
}
// RoomTurnoverRewardSettlement 是某个房间单个 UTC 周期的结算事实。 // RoomTurnoverRewardSettlement 是某个房间单个 UTC 周期的结算事实。
type RoomTurnoverRewardSettlement struct { type RoomTurnoverRewardSettlement struct {
state protoimpl.MessageState `protogen:"open.v1"` 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"` 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"` 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"` UpdatedAtMs int64 `protobuf:"varint,18,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
unknownFields protoimpl.UnknownFields // region_id 固化贡献事件发生时的房间可见区域0 表示全区域可见房间。
sizeCache protoimpl.SizeCache 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() { func (x *RoomTurnoverRewardSettlement) Reset() {
@ -10381,6 +10392,13 @@ func (x *RoomTurnoverRewardSettlement) GetUpdatedAtMs() int64 {
return 0 return 0
} }
func (x *RoomTurnoverRewardSettlement) GetRegionId() int64 {
if x != nil {
return x.RegionId
}
return 0
}
// RoomTurnoverRewardStatus 给 App H5 返回当前 UTC 周期、房间流水、预计奖励和上一期结算。 // RoomTurnoverRewardStatus 给 App H5 返回当前 UTC 周期、房间流水、预计奖励和上一期结算。
type RoomTurnoverRewardStatus struct { type RoomTurnoverRewardStatus struct {
state protoimpl.MessageState `protogen:"open.v1"` 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"` 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"` 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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -10566,6 +10585,13 @@ func (x *GetRoomTurnoverRewardStatusRequest) GetOwnerUserId() int64 {
return 0 return 0
} }
func (x *GetRoomTurnoverRewardStatusRequest) GetRegionId() int64 {
if x != nil {
return x.RegionId
}
return 0
}
type GetRoomTurnoverRewardStatusResponse struct { type GetRoomTurnoverRewardStatusResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Status *RoomTurnoverRewardStatus `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` Status *RoomTurnoverRewardStatus `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"`
@ -10613,6 +10639,7 @@ func (x *GetRoomTurnoverRewardStatusResponse) GetStatus() *RoomTurnoverRewardSta
type GetRoomTurnoverRewardConfigRequest struct { type GetRoomTurnoverRewardConfigRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -10654,6 +10681,13 @@ func (x *GetRoomTurnoverRewardConfigRequest) GetMeta() *RequestMeta {
return nil return nil
} }
func (x *GetRoomTurnoverRewardConfigRequest) GetRegionId() int64 {
if x != nil {
return x.RegionId
}
return 0
}
type GetRoomTurnoverRewardConfigResponse struct { type GetRoomTurnoverRewardConfigResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Config *RoomTurnoverRewardConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` 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"` Enabled bool `protobuf:"varint,2,opt,name=enabled,proto3" json:"enabled,omitempty"`
Tiers []*RoomTurnoverRewardTier `protobuf:"bytes,3,rep,name=tiers,proto3" json:"tiers,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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -10766,6 +10801,13 @@ func (x *UpdateRoomTurnoverRewardConfigRequest) GetOperatorAdminId() int64 {
return 0 return 0
} }
func (x *UpdateRoomTurnoverRewardConfigRequest) GetRegionId() int64 {
if x != nil {
return x.RegionId
}
return 0
}
type UpdateRoomTurnoverRewardConfigResponse struct { type UpdateRoomTurnoverRewardConfigResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Config *RoomTurnoverRewardConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` 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"` 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"` 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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -10902,6 +10945,13 @@ func (x *ListRoomTurnoverRewardSettlementsRequest) GetPageSize() int32 {
return 0 return 0
} }
func (x *ListRoomTurnoverRewardSettlementsRequest) GetRegionId() int64 {
if x != nil {
return x.RegionId
}
return 0
}
type ListRoomTurnoverRewardSettlementsResponse struct { type ListRoomTurnoverRewardSettlementsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Settlements []*RoomTurnoverRewardSettlement `protobuf:"bytes,1,rep,name=settlements,proto3" json:"settlements,omitempty"` 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"` 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"` 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"` AppCode string `protobuf:"bytes,15,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
unknownFields protoimpl.UnknownFields // region_id=0 表示全区域默认活动;正数表示只覆盖对应区域。
sizeCache protoimpl.SizeCache 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() { func (x *AgencyOpeningCycle) Reset() {
@ -20640,6 +20692,13 @@ func (x *AgencyOpeningCycle) GetAppCode() string {
return "" return ""
} }
func (x *AgencyOpeningCycle) GetRegionId() int64 {
if x != nil {
return x.RegionId
}
return 0
}
// AgencyOpeningApplication 是代理提交开业活动申请后的流水和发奖事实。 // AgencyOpeningApplication 是代理提交开业活动申请后的流水和发奖事实。
type AgencyOpeningApplication struct { type AgencyOpeningApplication struct {
state protoimpl.MessageState `protogen:"open.v1"` 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"` 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"` 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"` ReviewedByAdminId int64 `protobuf:"varint,22,opt,name=reviewed_by_admin_id,json=reviewedByAdminId,proto3" json:"reviewed_by_admin_id,omitempty"`
unknownFields protoimpl.UnknownFields // region_id 固化申请时代理所属区域,后续区域调整不改变已开始的计分事实。
sizeCache protoimpl.SizeCache 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() { func (x *AgencyOpeningApplication) Reset() {
@ -20853,6 +20914,13 @@ func (x *AgencyOpeningApplication) GetReviewedByAdminId() int64 {
return 0 return 0
} }
func (x *AgencyOpeningApplication) GetRegionId() int64 {
if x != nil {
return x.RegionId
}
return 0
}
// AgencyOpeningAgencySnapshot 是申请页展示和资格判断使用的代理快照。 // AgencyOpeningAgencySnapshot 是申请页展示和资格判断使用的代理快照。
type AgencyOpeningAgencySnapshot struct { type AgencyOpeningAgencySnapshot struct {
state protoimpl.MessageState `protogen:"open.v1"` 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"` 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"` 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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -20938,6 +21007,13 @@ func (x *AgencyOpeningAgencySnapshot) GetAgencyCreatedAtMs() int64 {
return 0 return 0
} }
func (x *AgencyOpeningAgencySnapshot) GetRegionId() int64 {
if x != nil {
return x.RegionId
}
return 0
}
type ListAgencyOpeningCyclesRequest struct { type ListAgencyOpeningCyclesRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` 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"` 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"` 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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -21022,6 +21099,13 @@ func (x *ListAgencyOpeningCyclesRequest) GetPageSize() int32 {
return 0 return 0
} }
func (x *ListAgencyOpeningCyclesRequest) GetRegionId() int64 {
if x != nil {
return x.RegionId
}
return 0
}
type ListAgencyOpeningCyclesResponse struct { type ListAgencyOpeningCyclesResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Cycles []*AgencyOpeningCycle `protobuf:"bytes,1,rep,name=cycles,proto3" json:"cycles,omitempty"` 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"` 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"` 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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -21486,6 +21571,13 @@ func (x *ListAgencyOpeningApplicationsRequest) GetPageSize() int32 {
return 0 return 0
} }
func (x *ListAgencyOpeningApplicationsRequest) GetRegionId() int64 {
if x != nil {
return x.RegionId
}
return 0
}
type ListAgencyOpeningApplicationsResponse struct { type ListAgencyOpeningApplicationsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Applications []*AgencyOpeningApplication `protobuf:"bytes,1,rep,name=applications,proto3" json:"applications,omitempty"` 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" + "\n" +
"sort_order\x18\a \x01(\x05R\tsortOrder\x12\"\n" + "sort_order\x18\a \x01(\x05R\tsortOrder\x12\"\n" +
"\rcreated_at_ms\x18\b \x01(\x03R\vcreatedAtMs\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" + "\x18RoomTurnoverRewardConfig\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x18\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x18\n" +
"\aenabled\x18\x02 \x01(\bR\aenabled\x12?\n" + "\aenabled\x18\x02 \x01(\bR\aenabled\x12?\n" +
"\x05tiers\x18\x03 \x03(\v2).hyapp.activity.v1.RoomTurnoverRewardTierR\x05tiers\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" + "\x13updated_by_admin_id\x18\x04 \x01(\x03R\x10updatedByAdminId\x12\"\n" +
"\rcreated_at_ms\x18\x05 \x01(\x03R\vcreatedAtMs\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" + "\x1cRoomTurnoverRewardSettlement\x12#\n" +
"\rsettlement_id\x18\x01 \x01(\tR\fsettlementId\x12\x19\n" + "\rsettlement_id\x18\x01 \x01(\tR\fsettlementId\x12\x19\n" +
"\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x17\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" + "\x0efailure_reason\x18\x0f \x01(\tR\rfailureReason\x12\"\n" +
"\rsettled_at_ms\x18\x10 \x01(\x03R\vsettledAtMs\x12\"\n" + "\rsettled_at_ms\x18\x10 \x01(\x03R\vsettledAtMs\x12\"\n" +
"\rcreated_at_ms\x18\x11 \x01(\x03R\vcreatedAtMs\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" + "\x18RoomTurnoverRewardStatus\x12C\n" +
"\x06config\x18\x01 \x01(\v2+.hyapp.activity.v1.RoomTurnoverRewardConfigR\x06config\x12\x17\n" + "\x06config\x18\x01 \x01(\v2+.hyapp.activity.v1.RoomTurnoverRewardConfigR\x06config\x12\x17\n" +
"\aroom_id\x18\x02 \x01(\tR\x06roomId\x12\"\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" + "\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" + "\x11latest_settlement\x18\t \x01(\v2/.hyapp.activity.v1.RoomTurnoverRewardSettlementR\x10latestSettlement\x12$\n" +
"\x0eserver_time_ms\x18\n" + "\x0eserver_time_ms\x18\n" +
" \x01(\x03R\fserverTimeMs\"\xae\x01\n" + " \x01(\x03R\fserverTimeMs\"\xcb\x01\n" +
"\"GetRoomTurnoverRewardStatusRequest\x122\n" + "\"GetRoomTurnoverRewardStatusRequest\x122\n" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\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" + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" +
"\aroom_id\x18\x03 \x01(\tR\x06roomId\x12\"\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" + "#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" + "\"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" + "#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" + "%UpdateRoomTurnoverRewardConfigRequest\x122\n" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x18\n" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x18\n" +
"\aenabled\x18\x02 \x01(\bR\aenabled\x12?\n" + "\aenabled\x18\x02 \x01(\bR\aenabled\x12?\n" +
"\x05tiers\x18\x03 \x03(\v2).hyapp.activity.v1.RoomTurnoverRewardTierR\x05tiers\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" + "&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" + "(ListRoomTurnoverRewardSettlementsRequest\x122\n" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x16\n" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12\x17\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" + "\rowner_user_id\x18\x04 \x01(\x03R\vownerUserId\x12&\n" +
"\x0fperiod_start_ms\x18\x05 \x01(\x03R\rperiodStartMs\x12\x12\n" + "\x0fperiod_start_ms\x18\x05 \x01(\x03R\rperiodStartMs\x12\x12\n" +
"\x04page\x18\x06 \x01(\x05R\x04page\x12\x1b\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" + ")ListRoomTurnoverRewardSettlementsResponse\x12Q\n" +
"\vsettlements\x18\x01 \x03(\v2/.hyapp.activity.v1.RoomTurnoverRewardSettlementR\vsettlements\x12\x14\n" + "\vsettlements\x18\x01 \x03(\v2/.hyapp.activity.v1.RoomTurnoverRewardSettlementR\vsettlements\x12\x14\n" +
"\x05total\x18\x02 \x01(\x03R\x05total\"\xaf\x01\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" + "\x13AgencyOpeningReward\x12\x17\n" +
"\arank_no\x18\x01 \x01(\x05R\x06rankNo\x12,\n" + "\arank_no\x18\x01 \x01(\x05R\x06rankNo\x12,\n" +
"\x12reward_coin_amount\x18\x02 \x01(\x03R\x10rewardCoinAmount\x120\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" + "\x12AgencyOpeningCycle\x12\x19\n" +
"\bcycle_id\x18\x01 \x01(\tR\acycleId\x12#\n" + "\bcycle_id\x18\x01 \x01(\tR\acycleId\x12#\n" +
"\ractivity_code\x18\x02 \x01(\tR\factivityCode\x12\x14\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" + "\rsettled_at_ms\x18\f \x01(\x03R\vsettledAtMs\x12\"\n" +
"\rcreated_at_ms\x18\r \x01(\x03R\vcreatedAtMs\x12\"\n" + "\rcreated_at_ms\x18\r \x01(\x03R\vcreatedAtMs\x12\"\n" +
"\rupdated_at_ms\x18\x0e \x01(\x03R\vupdatedAtMs\x12\x19\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" + "\x18AgencyOpeningApplication\x12%\n" +
"\x0eapplication_id\x18\x01 \x01(\tR\rapplicationId\x12\x19\n" + "\x0eapplication_id\x18\x01 \x01(\tR\rapplicationId\x12\x19\n" +
"\bcycle_id\x18\x02 \x01(\tR\acycleId\x12\x1b\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" + "\x0escore_start_ms\x18\x14 \x01(\x03R\fscoreStartMs\x12 \n" +
"\fscore_end_ms\x18\x15 \x01(\x03R\n" + "\fscore_end_ms\x18\x15 \x01(\x03R\n" +
"scoreEndMs\x12/\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" + "\x1bAgencyOpeningAgencySnapshot\x12\x1b\n" +
"\tagency_id\x18\x01 \x01(\x03R\bagencyId\x12\"\n" + "\tagency_id\x18\x01 \x01(\x03R\bagencyId\x12\"\n" +
"\rowner_user_id\x18\x02 \x01(\x03R\vownerUserId\x12\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" + "\x06status\x18\x04 \x01(\tR\x06status\x12\x1d\n" +
"\n" + "\n" +
"host_count\x18\x05 \x01(\x05R\thostCount\x12/\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" + "\x1eListAgencyOpeningCyclesRequest\x122\n" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x16\n" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12\x19\n" + "\x06status\x18\x02 \x01(\tR\x06status\x12\x19\n" +
"\bstart_ms\x18\x03 \x01(\x03R\astartMs\x12\x15\n" + "\bstart_ms\x18\x03 \x01(\x03R\astartMs\x12\x15\n" +
"\x06end_ms\x18\x04 \x01(\x03R\x05endMs\x12\x12\n" + "\x06end_ms\x18\x04 \x01(\x03R\x05endMs\x12\x12\n" +
"\x04page\x18\x05 \x01(\x05R\x04page\x12\x1b\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" + "\x1fListAgencyOpeningCyclesResponse\x12=\n" +
"\x06cycles\x18\x01 \x03(\v2%.hyapp.activity.v1.AgencyOpeningCycleR\x06cycles\x12\x14\n" + "\x06cycles\x18\x01 \x03(\v2%.hyapp.activity.v1.AgencyOpeningCycleR\x06cycles\x12\x14\n" +
"\x05total\x18\x02 \x01(\x03R\x05total\"m\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" + "\x06status\x18\x03 \x01(\tR\x06status\x12*\n" +
"\x11operator_admin_id\x18\x04 \x01(\x03R\x0foperatorAdminId\"b\n" + "\x11operator_admin_id\x18\x04 \x01(\x03R\x0foperatorAdminId\"b\n" +
"#SetAgencyOpeningCycleStatusResponse\x12;\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" + "$ListAgencyOpeningApplicationsRequest\x122\n" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x19\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" + "\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" + "\tagency_id\x18\x04 \x01(\x03R\bagencyId\x12/\n" +
"\x14agency_owner_user_id\x18\x05 \x01(\x03R\x11agencyOwnerUserId\x12\x12\n" + "\x14agency_owner_user_id\x18\x05 \x01(\x03R\x11agencyOwnerUserId\x12\x12\n" +
"\x04page\x18\x06 \x01(\x05R\x04page\x12\x1b\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" + "%ListAgencyOpeningApplicationsResponse\x12O\n" +
"\fapplications\x18\x01 \x03(\v2+.hyapp.activity.v1.AgencyOpeningApplicationR\fapplications\x12\x14\n" + "\fapplications\x18\x01 \x03(\v2+.hyapp.activity.v1.AgencyOpeningApplicationR\fapplications\x12\x14\n" +
"\x05total\x18\x02 \x01(\x03R\x05total\"\xc6\x01\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 updated_by_admin_id = 4;
int64 created_at_ms = 5; int64 created_at_ms = 5;
int64 updated_at_ms = 6; int64 updated_at_ms = 6;
// region_id=0
int64 region_id = 7;
} }
// RoomTurnoverRewardSettlement UTC // RoomTurnoverRewardSettlement UTC
@ -1170,6 +1172,8 @@ message RoomTurnoverRewardSettlement {
int64 settled_at_ms = 16; int64 settled_at_ms = 16;
int64 created_at_ms = 17; int64 created_at_ms = 17;
int64 updated_at_ms = 18; int64 updated_at_ms = 18;
// region_id 0
int64 region_id = 19;
} }
// RoomTurnoverRewardStatus App H5 UTC // RoomTurnoverRewardStatus App H5 UTC
@ -1191,6 +1195,7 @@ message GetRoomTurnoverRewardStatusRequest {
int64 user_id = 2; int64 user_id = 2;
string room_id = 3; string room_id = 3;
int64 owner_user_id = 4; int64 owner_user_id = 4;
int64 region_id = 5;
} }
message GetRoomTurnoverRewardStatusResponse { message GetRoomTurnoverRewardStatusResponse {
@ -1199,6 +1204,7 @@ message GetRoomTurnoverRewardStatusResponse {
message GetRoomTurnoverRewardConfigRequest { message GetRoomTurnoverRewardConfigRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
int64 region_id = 2;
} }
message GetRoomTurnoverRewardConfigResponse { message GetRoomTurnoverRewardConfigResponse {
@ -1210,6 +1216,7 @@ message UpdateRoomTurnoverRewardConfigRequest {
bool enabled = 2; bool enabled = 2;
repeated RoomTurnoverRewardTier tiers = 3; repeated RoomTurnoverRewardTier tiers = 3;
int64 operator_admin_id = 4; int64 operator_admin_id = 4;
int64 region_id = 5;
} }
message UpdateRoomTurnoverRewardConfigResponse { message UpdateRoomTurnoverRewardConfigResponse {
@ -1224,6 +1231,7 @@ message ListRoomTurnoverRewardSettlementsRequest {
int64 period_start_ms = 5; int64 period_start_ms = 5;
int32 page = 6; int32 page = 6;
int32 page_size = 7; int32 page_size = 7;
int64 region_id = 8;
} }
message ListRoomTurnoverRewardSettlementsResponse { message ListRoomTurnoverRewardSettlementsResponse {
@ -2280,6 +2288,8 @@ message AgencyOpeningCycle {
int64 created_at_ms = 13; int64 created_at_ms = 13;
int64 updated_at_ms = 14; int64 updated_at_ms = 14;
string app_code = 15; string app_code = 15;
// region_id=0
int64 region_id = 16;
} }
// AgencyOpeningApplication // AgencyOpeningApplication
@ -2306,6 +2316,8 @@ message AgencyOpeningApplication {
int64 score_start_ms = 20; int64 score_start_ms = 20;
int64 score_end_ms = 21; int64 score_end_ms = 21;
int64 reviewed_by_admin_id = 22; int64 reviewed_by_admin_id = 22;
// region_id
int64 region_id = 23;
} }
// AgencyOpeningAgencySnapshot 使 // AgencyOpeningAgencySnapshot 使
@ -2316,6 +2328,7 @@ message AgencyOpeningAgencySnapshot {
string status = 4; string status = 4;
int32 host_count = 5; int32 host_count = 5;
int64 agency_created_at_ms = 6; int64 agency_created_at_ms = 6;
int64 region_id = 7;
} }
message ListAgencyOpeningCyclesRequest { message ListAgencyOpeningCyclesRequest {
@ -2325,6 +2338,7 @@ message ListAgencyOpeningCyclesRequest {
int64 end_ms = 4; int64 end_ms = 4;
int32 page = 5; int32 page = 5;
int32 page_size = 6; int32 page_size = 6;
int64 region_id = 7;
} }
message ListAgencyOpeningCyclesResponse { message ListAgencyOpeningCyclesResponse {
@ -2371,6 +2385,7 @@ message ListAgencyOpeningApplicationsRequest {
int64 agency_owner_user_id = 5; int64 agency_owner_user_id = 5;
int32 page = 6; int32 page = 6;
int32 page_size = 7; int32 page_size = 7;
int64 region_id = 8;
} }
message ListAgencyOpeningApplicationsResponse { 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"` RoomLocked bool `protobuf:"varint,32,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"`
// sender_vip_level 是送礼入口固化的发送者 effective VIP 等级0 表示当前无有效 VIP 或展示查询已降级。 // 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"` SenderVipLevel int32 `protobuf:"varint,33,opt,name=sender_vip_level,json=senderVipLevel,proto3" json:"sender_vip_level,omitempty"`
unknownFields protoimpl.UnknownFields // sender_gift_broadcast_animation_url 是送礼入口从有效 gift_tray_skin 权益固化的动效 URL下游禁止异步回查当前 VIP 覆盖历史事实。
sizeCache protoimpl.SizeCache 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() { func (x *RoomGiftSent) Reset() {
@ -1988,6 +1990,13 @@ func (x *RoomGiftSent) GetSenderVipLevel() int32 {
return 0 return 0
} }
func (x *RoomGiftSent) GetSenderGiftBroadcastAnimationUrl() string {
if x != nil {
return x.SenderGiftBroadcastAnimationUrl
}
return ""
}
// RoomGiftBatchLuckyResult 是批量送礼展示事件内的逐目标幸运礼物结果快照。 // RoomGiftBatchLuckyResult 是批量送礼展示事件内的逐目标幸运礼物结果快照。
// 它只进入房间 IM 展示,不作为 activity 中奖事实,也不携带送礼人余额。 // 它只进入房间 IM 展示,不作为 activity 中奖事实,也不携带送礼人余额。
type RoomGiftBatchLuckyResult struct { 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"` VisibleRegionId int64 `protobuf:"varint,22,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
// sender_vip_level 与同命令的逐目标 RoomGiftSent 共用一份发送者展示快照。 // sender_vip_level 与同命令的逐目标 RoomGiftSent 共用一份发送者展示快照。
SenderVipLevel int32 `protobuf:"varint,23,opt,name=sender_vip_level,json=senderVipLevel,proto3" json:"sender_vip_level,omitempty"` SenderVipLevel int32 `protobuf:"varint,23,opt,name=sender_vip_level,json=senderVipLevel,proto3" json:"sender_vip_level,omitempty"`
unknownFields protoimpl.UnknownFields // sender_gift_broadcast_animation_url 与同命令的逐目标事实共用,批量送礼不能因展示协议合并而丢失 VIP 托盘。
sizeCache protoimpl.SizeCache 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() { func (x *RoomGiftBatchSent) Reset() {
@ -2503,6 +2514,13 @@ func (x *RoomGiftBatchSent) GetSenderVipLevel() int32 {
return 0 return 0
} }
func (x *RoomGiftBatchSent) GetSenderGiftBroadcastAnimationUrl() string {
if x != nil {
return x.SenderGiftBroadcastAnimationUrl
}
return ""
}
// RoomLuckyGiftDrawn 是 room-service 从 lucky-gift-service owner 事实派生的真人中奖展示事件。 // RoomLuckyGiftDrawn 是 room-service 从 lucky-gift-service owner 事实派生的真人中奖展示事件。
// 它只承载腾讯云 IM 房间表现,不是 Room Cell 核心状态,不能推进 room_version、榜单或房间快照。 // 它只承载腾讯云 IM 房间表现,不是 Room Cell 核心状态,不能推进 room_version、榜单或房间快照。
type RoomLuckyGiftDrawn struct { 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" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\\\n" +
"\x10RoomUserUnbanned\x12\"\n" + "\x10RoomUserUnbanned\x12\"\n" +
"\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" + "\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\xbc\n" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\x8a\v\n" +
"\n" +
"\fRoomGiftSent\x12$\n" + "\fRoomGiftSent\x12$\n" +
"\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12$\n" + "\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\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" + "\fdisplay_mode\x18\x1f \x01(\tR\vdisplayMode\x12\x1f\n" +
"\vroom_locked\x18 \x01(\bR\n" + "\vroom_locked\x18 \x01(\bR\n" +
"roomLocked\x12(\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" + "\x18RoomGiftBatchLuckyResult\x12\x18\n" +
"\aenabled\x18\x01 \x01(\bR\aenabled\x12\x17\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x12\x17\n" +
"\adraw_id\x18\x02 \x01(\tR\x06drawId\x12\x1d\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" + "command_id\x18\n" +
" \x01(\tR\tcommandId\x12M\n" + " \x01(\tR\tcommandId\x12M\n" +
"\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" + "\x11RoomGiftBatchSent\x12$\n" +
"\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12\x1f\n" + "\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12\x1f\n" +
"\vsender_name\x18\x02 \x01(\tR\n" + "\vsender_name\x18\x02 \x01(\tR\n" +
@ -3872,7 +3890,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\n" + "\n" +
"command_id\x18\x15 \x01(\tR\tcommandId\x12*\n" + "command_id\x18\x15 \x01(\tR\tcommandId\x12*\n" +
"\x11visible_region_id\x18\x16 \x01(\x03R\x0fvisibleRegionId\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" + "\x12RoomLuckyGiftDrawn\x12\x17\n" +
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" + "\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
"\n" + "\n" +

View File

@ -252,6 +252,8 @@ message RoomGiftSent {
bool room_locked = 32; bool room_locked = 32;
// sender_vip_level effective VIP 0 VIP // sender_vip_level effective VIP 0 VIP
int32 sender_vip_level = 33; int32 sender_vip_level = 33;
// sender_gift_broadcast_animation_url gift_tray_skin URL VIP
string sender_gift_broadcast_animation_url = 34;
} }
// RoomGiftBatchLuckyResult // RoomGiftBatchLuckyResult
@ -318,6 +320,8 @@ message RoomGiftBatchSent {
int64 visible_region_id = 22; int64 visible_region_id = 22;
// sender_vip_level RoomGiftSent // sender_vip_level RoomGiftSent
int32 sender_vip_level = 23; 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 // 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"` 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 或展示查询已降级。 // effective_vip_level 是送礼入口查询到的当前生效 VIP 等级0 表示当前无有效 VIP 或展示查询已降级。
EffectiveVipLevel int32 `protobuf:"varint,6,opt,name=effective_vip_level,json=effectiveVipLevel,proto3" json:"effective_vip_level,omitempty"` EffectiveVipLevel int32 `protobuf:"varint,6,opt,name=effective_vip_level,json=effectiveVipLevel,proto3" json:"effective_vip_level,omitempty"`
unknownFields protoimpl.UnknownFields // gift_broadcast_animation_url 是 wallet 已校验的有效 gift_tray_skin 动效快照;空值表示无可渲染托盘或查询降级。
sizeCache protoimpl.SizeCache 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() { func (x *SendGiftDisplayProfile) Reset() {
@ -10082,6 +10084,13 @@ func (x *SendGiftDisplayProfile) GetEffectiveVipLevel() int32 {
return 0 return 0
} }
func (x *SendGiftDisplayProfile) GetGiftBroadcastAnimationUrl() string {
if x != nil {
return x.GiftBroadcastAnimationUrl
}
return ""
}
// SendGiftBatchTarget 是批量送礼展示响应中单个目标的结算和展示快照。 // SendGiftBatchTarget 是批量送礼展示响应中单个目标的结算和展示快照。
// 它只服务发送方本地即时展示,不替代逐目标 RoomGiftSent 业务事实。 // 它只服务发送方本地即时展示,不替代逐目标 RoomGiftSent 业务事实。
type SendGiftBatchTarget struct { 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"` CommandId string `protobuf:"bytes,22,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
// sender_vip_level 与 RoomGiftBatchSent IM 使用同一份入站快照,避免发送方本地展示和房间广播不一致。 // sender_vip_level 与 RoomGiftBatchSent IM 使用同一份入站快照,避免发送方本地展示和房间广播不一致。
SenderVipLevel int32 `protobuf:"varint,23,opt,name=sender_vip_level,json=senderVipLevel,proto3" json:"sender_vip_level,omitempty"` SenderVipLevel int32 `protobuf:"varint,23,opt,name=sender_vip_level,json=senderVipLevel,proto3" json:"sender_vip_level,omitempty"`
unknownFields protoimpl.UnknownFields // sender_gift_broadcast_animation_url 与逐目标 RoomGiftSent 共用送礼入口快照,普通/幸运/超级幸运礼物保持同一托盘资源。
sizeCache protoimpl.SizeCache 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() { func (x *SendGiftBatchDisplay) Reset() {
@ -10431,6 +10442,13 @@ func (x *SendGiftBatchDisplay) GetSenderVipLevel() int32 {
return 0 return 0
} }
func (x *SendGiftBatchDisplay) GetSenderGiftBroadcastAnimationUrl() string {
if x != nil {
return x.SenderGiftBroadcastAnimationUrl
}
return ""
}
// SendGiftRequest 是首版房间内最重要的变现命令。 // SendGiftRequest 是首版房间内最重要的变现命令。
type SendGiftRequest struct { type SendGiftRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` 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"` 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 回查。 // 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"` RoomBorderUrl string `protobuf:"bytes,20,opt,name=room_border_url,json=roomBorderUrl,proto3" json:"room_border_url,omitempty"`
unknownFields protoimpl.UnknownFields // rocket_near_completion 来自 Room Cell 火箭快照;仅 charging 且剩余燃料不超过当前档阈值 20% 时为 true。
sizeCache protoimpl.SizeCache 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() { func (x *RoomListItem) Reset() {
@ -11800,6 +11824,27 @@ func (x *RoomListItem) GetRoomBorderUrl() string {
return "" 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。 // ListRoomsResponse 返回一页房间卡片和下一页不透明 cursor。
type ListRoomsResponse struct { type ListRoomsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` 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_user_id\x18\x01 \x01(\x03R\ftargetUserId\x12$\n" +
"\x0etarget_is_host\x18\x02 \x01(\bR\ftargetIsHost\x121\n" + "\x0etarget_is_host\x18\x02 \x01(\bR\ftargetIsHost\x121\n" +
"\x15target_host_region_id\x18\x03 \x01(\x03R\x12targetHostRegionId\x12<\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" + "\x16SendGiftDisplayProfile\x12\x17\n" +
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x1a\n" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x1a\n" +
"\busername\x18\x02 \x01(\tR\busername\x12\x16\n" + "\busername\x18\x02 \x01(\tR\busername\x12\x16\n" +
"\x06avatar\x18\x03 \x01(\tR\x06avatar\x12&\n" + "\x06avatar\x18\x03 \x01(\tR\x06avatar\x12&\n" +
"\x0fdisplay_user_id\x18\x04 \x01(\tR\rdisplayUserId\x123\n" + "\x0fdisplay_user_id\x18\x04 \x01(\tR\rdisplayUserId\x123\n" +
"\x16pretty_display_user_id\x18\x05 \x01(\tR\x13prettyDisplayUserId\x12.\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" + "\x13SendGiftBatchTarget\x12$\n" +
"\x0etarget_user_id\x18\x01 \x01(\x03R\ftargetUserId\x12+\n" + "\x0etarget_user_id\x18\x01 \x01(\x03R\ftargetUserId\x12+\n" +
"\x11receiver_nickname\x18\x02 \x01(\tR\x10receiverNickname\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" + "command_id\x18\n" +
" \x01(\tR\tcommandId\x12A\n" + " \x01(\tR\tcommandId\x12A\n" +
"\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" + "\x14SendGiftBatchDisplay\x12\x19\n" +
"\bevent_id\x18\x01 \x01(\tR\aeventId\x12$\n" + "\bevent_id\x18\x01 \x01(\tR\aeventId\x12$\n" +
"\x0esender_user_id\x18\x02 \x01(\x03R\fsenderUserId\x12\x1f\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" + "\atargets\x18\x15 \x03(\v2\".hyapp.room.v1.SendGiftBatchTargetR\atargets\x12\x1d\n" +
"\n" + "\n" +
"command_id\x18\x16 \x01(\tR\tcommandId\x12(\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" + "\x0fSendGiftRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\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" + "\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" + "\tregion_id\x18\x05 \x01(\x03R\bregionId\"c\n" +
"\x13RoomFeedRelatedUser\x12\x17\n" + "\x13RoomFeedRelatedUser\x12\x17\n" +
"\auser_id\x18\x01 \x01(\x03R\x06userId\x123\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" + "\fRoomListItem\x12\x17\n" +
"\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\"\n" + "\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\"\n" +
"\rowner_user_id\x18\x02 \x01(\x03R\vownerUserId\x12\x14\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" + "roomBorder\x12M\n" +
"\x0froom_name_style\x18\x12 \x01(\v2%.hyapp.room.v1.RoomDecorationResourceR\rroomNameStyle\x12&\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" + "\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" + "\x11ListRoomsResponse\x121\n" +
"\x05rooms\x18\x01 \x03(\v2\x1b.hyapp.room.v1.RoomListItemR\x05rooms\x12\x1f\n" + "\x05rooms\x18\x01 \x03(\v2\x1b.hyapp.room.v1.RoomListItemR\x05rooms\x12\x1f\n" +
"\vnext_cursor\x18\x02 \x01(\tR\n" + "\vnext_cursor\x18\x02 \x01(\tR\n" +

View File

@ -1205,6 +1205,8 @@ message SendGiftDisplayProfile {
string pretty_display_user_id = 5; string pretty_display_user_id = 5;
// effective_vip_level VIP 0 VIP // effective_vip_level VIP 0 VIP
int32 effective_vip_level = 6; int32 effective_vip_level = 6;
// gift_broadcast_animation_url wallet gift_tray_skin
string gift_broadcast_animation_url = 7;
} }
// SendGiftBatchTarget // SendGiftBatchTarget
@ -1250,6 +1252,8 @@ message SendGiftBatchDisplay {
string command_id = 22; string command_id = 22;
// sender_vip_level RoomGiftBatchSent IM 使广 // sender_vip_level RoomGiftBatchSent IM 使广
int32 sender_vip_level = 23; int32 sender_vip_level = 23;
// sender_gift_broadcast_animation_url RoomGiftSent //
string sender_gift_broadcast_animation_url = 24;
} }
// SendGiftRequest // SendGiftRequest
@ -1443,6 +1447,12 @@ message RoomListItem {
int32 owner_vip_level = 19; int32 owner_vip_level = 19;
// room_border_url room_border.asset_url wallet // room_border_url room_border.asset_url wallet
string room_border_url = 20; 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 // ListRoomsResponse cursor

View File

@ -40,6 +40,7 @@ INCREMENTAL_MIGRATION_FILES=(
"services/user-service/deploy/mysql/migrations/020_user_avatar_upload_registrations.sql" "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/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/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/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/002_resource_shop_price_tiers.sql"
"services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.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 { type cycleRequest struct {
CycleID string `json:"cycle_id"` CycleID string `json:"cycle_id"`
RegionID int64 `json:"region_id"`
Title string `json:"title"` Title string `json:"title"`
Status string `json:"status"` Status string `json:"status"`
StartMS int64 `json:"start_ms"` StartMS int64 `json:"start_ms"`
@ -42,6 +43,7 @@ type statusRequest struct {
type cycleDTO struct { type cycleDTO struct {
AppCode string `json:"app_code,omitempty"` AppCode string `json:"app_code,omitempty"`
CycleID string `json:"cycle_id"` CycleID string `json:"cycle_id"`
RegionID int64 `json:"region_id"`
ActivityCode string `json:"activity_code"` ActivityCode string `json:"activity_code"`
Title string `json:"title"` Title string `json:"title"`
Status string `json:"status"` Status string `json:"status"`
@ -67,6 +69,7 @@ type rewardDTO struct {
type applicationDTO struct { type applicationDTO struct {
ApplicationID string `json:"application_id"` ApplicationID string `json:"application_id"`
CycleID string `json:"cycle_id"` CycleID string `json:"cycle_id"`
RegionID int64 `json:"region_id"`
AgencyID int64 `json:"agency_id,string"` AgencyID int64 `json:"agency_id,string"`
AgencyOwnerUserID int64 `json:"agency_owner_user_id,string"` AgencyOwnerUserID int64 `json:"agency_owner_user_id,string"`
AgencyName string `json:"agency_name"` AgencyName string `json:"agency_name"`
@ -93,6 +96,7 @@ func (h *Handler) ListCycles(c *gin.Context) {
options := shared.ListOptions(c) options := shared.ListOptions(c)
resp, err := h.activity.ListAgencyOpeningCycles(c.Request.Context(), &activityv1.ListAgencyOpeningCyclesRequest{ resp, err := h.activity.ListAgencyOpeningCycles(c.Request.Context(), &activityv1.ListAgencyOpeningCyclesRequest{
Meta: h.meta(c), Meta: h.meta(c),
RegionId: parseInt64Query(c, "region_id"),
Status: strings.TrimSpace(options.Status), Status: strings.TrimSpace(options.Status),
StartMs: parseInt64Query(c, "start_ms"), StartMs: parseInt64Query(c, "start_ms"),
EndMs: parseInt64Query(c, "end_ms"), EndMs: parseInt64Query(c, "end_ms"),
@ -116,6 +120,10 @@ func (h *Handler) CreateCycle(c *gin.Context) {
response.BadRequest(c, "代理开业周期参数不正确") response.BadRequest(c, "代理开业周期参数不正确")
return return
} }
if req.RegionID < 0 {
response.BadRequest(c, "区域参数不正确")
return
}
resp, err := h.activity.CreateAgencyOpeningCycle(c.Request.Context(), &activityv1.UpsertAgencyOpeningCycleRequest{ resp, err := h.activity.CreateAgencyOpeningCycle(c.Request.Context(), &activityv1.UpsertAgencyOpeningCycleRequest{
Meta: h.meta(c), Meta: h.meta(c),
Cycle: req.toProto(), Cycle: req.toProto(),
@ -146,6 +154,10 @@ func (h *Handler) UpdateCycle(c *gin.Context) {
response.BadRequest(c, "代理开业周期参数不正确") response.BadRequest(c, "代理开业周期参数不正确")
return return
} }
if req.RegionID < 0 {
response.BadRequest(c, "区域参数不正确")
return
}
req.CycleID = strings.TrimSpace(c.Param("cycle_id")) req.CycleID = strings.TrimSpace(c.Param("cycle_id"))
resp, err := h.activity.UpdateAgencyOpeningCycle(c.Request.Context(), &activityv1.UpsertAgencyOpeningCycleRequest{ resp, err := h.activity.UpdateAgencyOpeningCycle(c.Request.Context(), &activityv1.UpsertAgencyOpeningCycleRequest{
Meta: h.meta(c), 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{ resp, err := h.activity.ListAgencyOpeningApplications(c.Request.Context(), &activityv1.ListAgencyOpeningApplicationsRequest{
Meta: h.meta(c), Meta: h.meta(c),
CycleId: strings.TrimSpace(c.Query("cycle_id")), CycleId: strings.TrimSpace(c.Query("cycle_id")),
RegionId: parseInt64Query(c, "region_id"),
Status: strings.TrimSpace(options.Status), Status: strings.TrimSpace(options.Status),
AgencyId: parseInt64Query(c, "agency_id"), AgencyId: parseInt64Query(c, "agency_id"),
AgencyOwnerUserId: parseInt64Query(c, "agency_owner_user_id"), AgencyOwnerUserId: parseInt64Query(c, "agency_owner_user_id"),
@ -246,6 +259,7 @@ func (r cycleRequest) toProto() *activityv1.AgencyOpeningCycle {
} }
return &activityv1.AgencyOpeningCycle{ return &activityv1.AgencyOpeningCycle{
CycleId: strings.TrimSpace(r.CycleID), CycleId: strings.TrimSpace(r.CycleID),
RegionId: r.RegionID,
Title: strings.TrimSpace(r.Title), Title: strings.TrimSpace(r.Title),
Status: strings.TrimSpace(r.Status), Status: strings.TrimSpace(r.Status),
StartMs: r.StartMS, StartMs: r.StartMS,
@ -272,6 +286,7 @@ func cycleFromProto(cycle *activityv1.AgencyOpeningCycle) cycleDTO {
return cycleDTO{ return cycleDTO{
AppCode: cycle.GetAppCode(), AppCode: cycle.GetAppCode(),
CycleID: cycle.GetCycleId(), CycleID: cycle.GetCycleId(),
RegionID: cycle.GetRegionId(),
ActivityCode: cycle.GetActivityCode(), ActivityCode: cycle.GetActivityCode(),
Title: cycle.GetTitle(), Title: cycle.GetTitle(),
Status: cycle.GetStatus(), Status: cycle.GetStatus(),
@ -295,6 +310,7 @@ func applicationFromProto(item *activityv1.AgencyOpeningApplication) application
return applicationDTO{ return applicationDTO{
ApplicationID: item.GetApplicationId(), ApplicationID: item.GetApplicationId(),
CycleID: item.GetCycleId(), CycleID: item.GetCycleId(),
RegionID: item.GetRegionId(),
AgencyID: item.GetAgencyId(), AgencyID: item.GetAgencyId(),
AgencyOwnerUserID: item.GetAgencyOwnerUserId(), AgencyOwnerUserID: item.GetAgencyOwnerUserId(),
AgencyName: item.GetAgencyName(), AgencyName: item.GetAgencyName(),

View File

@ -809,12 +809,15 @@ func validateRoomDecorationResourceMaterial(req resourceRequest) error {
if _, allowed := vipVisualResourceFormats[format]; !allowed { if _, allowed := vipVisualResourceFormats[format]; !allowed {
return fmt.Errorf("视觉资源素材格式不支持") return fmt.Errorf("视觉资源素材格式不支持")
} }
if resourceType == resourceTypeGiftTraySkin && (format != "svga" || strings.TrimSpace(req.AnimationURL) == "") {
return fmt.Errorf("送礼托盘必须上传 SVGA 动效")
}
return nil return nil
} }
func resourceRequiresExplicitVisualFormat(resourceType string) bool { func resourceRequiresExplicitVisualFormat(resourceType string) bool {
switch normalizeResourceType(resourceType) { switch normalizeResourceType(resourceType) {
case resourceTypeMicAnimation, resourceTypeRoomBorder: case resourceTypeGiftTraySkin, resourceTypeMicAnimation, resourceTypeRoomBorder:
return true return true
default: default:
return false 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 { func newGiftRequestTestContext() *gin.Context {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()

View File

@ -28,12 +28,14 @@ func New(activity activityclient.Client, userDB *sql.DB, audit shared.OperationL
} }
type configRequest struct { type configRequest struct {
Enabled bool `json:"enabled"` RegionID int64 `json:"region_id"`
Tiers []tierDTO `json:"tiers"` Enabled bool `json:"enabled"`
Tiers []tierDTO `json:"tiers"`
} }
type configDTO struct { type configDTO struct {
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
RegionID int64 `json:"region_id"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Tiers []tierDTO `json:"tiers"` Tiers []tierDTO `json:"tiers"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"` UpdatedByAdminID int64 `json:"updated_by_admin_id"`
@ -55,6 +57,7 @@ type tierDTO struct {
type settlementDTO struct { type settlementDTO struct {
SettlementID string `json:"settlement_id"` SettlementID string `json:"settlement_id"`
RegionID int64 `json:"region_id"`
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
OwnerUserID int64 `json:"owner_user_id,string"` OwnerUserID int64 `json:"owner_user_id,string"`
OwnerUser *userDTO `json:"owner_user,omitempty"` OwnerUser *userDTO `json:"owner_user,omitempty"`
@ -82,8 +85,9 @@ type userDTO struct {
} }
func (h *Handler) GetConfig(c *gin.Context) { func (h *Handler) GetConfig(c *gin.Context) {
regionID := queryInt64(c, "region_id", "regionId")
// 后台服务只透传到 activity-service 获取当前 App 配置;活动规则 owner 不在 admin-server 内部复制。 // 后台服务只透传到 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 { if err != nil {
response.ServerError(c, "获取房间流水奖励配置失败") response.ServerError(c, "获取房间流水奖励配置失败")
return return
@ -97,6 +101,10 @@ func (h *Handler) UpdateConfig(c *gin.Context) {
response.BadRequest(c, "房间流水奖励配置参数不正确") response.BadRequest(c, "房间流水奖励配置参数不正确")
return return
} }
if req.RegionID < 0 {
response.BadRequest(c, "区域参数不正确")
return
}
tiers := make([]*activityv1.RoomTurnoverRewardTier, 0, len(req.Tiers)) tiers := make([]*activityv1.RoomTurnoverRewardTier, 0, len(req.Tiers))
for _, tier := range req.Tiers { for _, tier := range req.Tiers {
// 后台只做输入清洗和操作者透传;阈值递增、奖励金额和启用状态等业务校验统一由 activity-service 执行。 // 后台只做输入清洗和操作者透传;阈值递增、奖励金额和启用状态等业务校验统一由 activity-service 执行。
@ -112,6 +120,7 @@ func (h *Handler) UpdateConfig(c *gin.Context) {
} }
resp, err := h.activity.UpdateRoomTurnoverRewardConfig(c.Request.Context(), &activityv1.UpdateRoomTurnoverRewardConfigRequest{ resp, err := h.activity.UpdateRoomTurnoverRewardConfig(c.Request.Context(), &activityv1.UpdateRoomTurnoverRewardConfigRequest{
Meta: h.meta(c), Meta: h.meta(c),
RegionId: req.RegionID,
Enabled: req.Enabled, Enabled: req.Enabled,
Tiers: tiers, Tiers: tiers,
OperatorAdminId: int64(middleware.CurrentUserID(c)), OperatorAdminId: int64(middleware.CurrentUserID(c)),
@ -122,7 +131,7 @@ func (h *Handler) UpdateConfig(c *gin.Context) {
} }
item := configFromProto(resp.GetConfig()) item := configFromProto(resp.GetConfig())
// 配置修改必须留下后台审计资源 ID后续排查结算金额变化时可以回到具体 App 配置记录。 // 配置修改必须留下后台审计资源 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) 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{ resp, err := h.activity.ListRoomTurnoverRewardSettlements(c.Request.Context(), &activityv1.ListRoomTurnoverRewardSettlementsRequest{
Meta: h.meta(c), Meta: h.meta(c),
RegionId: queryInt64(c, "region_id", "regionId"),
Status: strings.TrimSpace(options.Status), Status: strings.TrimSpace(options.Status),
RoomId: strings.TrimSpace(options.Keyword), RoomId: strings.TrimSpace(options.Keyword),
OwnerUserId: ownerUserID, OwnerUserId: ownerUserID,
@ -281,6 +291,7 @@ func configFromProto(config *activityv1.RoomTurnoverRewardConfig) configDTO {
} }
return configDTO{ return configDTO{
AppCode: config.GetAppCode(), AppCode: config.GetAppCode(),
RegionID: config.GetRegionId(),
Enabled: config.GetEnabled(), Enabled: config.GetEnabled(),
Tiers: tiers, Tiers: tiers,
UpdatedByAdminID: config.GetUpdatedByAdminId(), UpdatedByAdminID: config.GetUpdatedByAdminId(),
@ -368,6 +379,7 @@ func settlementFromProto(settlement *activityv1.RoomTurnoverRewardSettlement) se
} }
return settlementDTO{ return settlementDTO{
SettlementID: settlement.GetSettlementId(), SettlementID: settlement.GetSettlementId(),
RegionID: settlement.GetRegionId(),
RoomID: settlement.GetRoomId(), RoomID: settlement.GetRoomId(),
OwnerUserID: settlement.GetOwnerUserId(), OwnerUserID: settlement.GetOwnerUserId(),
PeriodStartMS: settlement.GetPeriodStartMs(), 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 ( CREATE TABLE IF NOT EXISTS room_turnover_reward_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', 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 '是否启用', enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用',
updated_by_admin_id BIGINT 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', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_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='房间流水奖励配置表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励配置表';
CREATE TABLE IF NOT EXISTS room_turnover_reward_tiers ( CREATE TABLE IF NOT EXISTS room_turnover_reward_tiers (
tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '档位 ID', tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '档位 ID',
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', 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_code VARCHAR(64) NOT NULL COMMENT '档位编码',
tier_name VARCHAR(128) NOT NULL COMMENT '档位名称', tier_name VARCHAR(128) NOT NULL COMMENT '档位名称',
threshold_coin_spent BIGINT 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', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (tier_id), PRIMARY KEY (tier_id),
UNIQUE KEY uk_room_turnover_reward_tier_code (app_code, tier_code), UNIQUE KEY uk_room_turnover_reward_tier_code (app_code, region_id, tier_code),
KEY idx_room_turnover_reward_tiers_match (app_code, status, threshold_coin_spent) 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='房间流水奖励档位表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励档位表';
CREATE TABLE IF NOT EXISTS room_turnover_reward_event_consumption ( 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', consumed_at_ms BIGINT NOT NULL COMMENT '消费时间UTC epoch ms',
created_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', 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), PRIMARY KEY (app_code, event_id),
KEY idx_room_turnover_reward_event_period (app_code, period_start_ms, room_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='房间流水奖励事件消费幂等表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励事件消费幂等表';
CREATE TABLE IF NOT EXISTS room_turnover_reward_progress ( CREATE TABLE IF NOT EXISTS room_turnover_reward_progress (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', 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', room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
period_start_ms BIGINT NOT NULL COMMENT 'UTC 周期开始', period_start_ms BIGINT NOT NULL COMMENT 'UTC 周期开始',
period_end_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 '最后事件时间', last_event_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最后事件时间',
created_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', 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) KEY idx_room_turnover_reward_progress_period (app_code, period_start_ms, coin_spent)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励周期聚合表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励周期聚合表';
CREATE TABLE IF NOT EXISTS room_turnover_reward_settlements ( CREATE TABLE IF NOT EXISTS room_turnover_reward_settlements (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', 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', settlement_id VARCHAR(96) NOT NULL COMMENT '结算 ID',
room_id VARCHAR(96) NOT NULL COMMENT '房间 ID', room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '结算收款房主', 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', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_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), 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_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) 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='房间流水奖励每周结算表'; ) 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 ( CREATE TABLE IF NOT EXISTS agency_opening_cycles (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', 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', cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
activity_code VARCHAR(64) NOT NULL DEFAULT 'agency_opening' COMMENT '活动编码', activity_code VARCHAR(64) NOT NULL DEFAULT 'agency_opening' COMMENT '活动编码',
title VARCHAR(128) NOT NULL DEFAULT '' 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', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_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), 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) 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='代理开业活动周期'; ) 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 ( CREATE TABLE IF NOT EXISTS agency_opening_applications (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', 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', application_id VARCHAR(96) NOT NULL COMMENT '申请 ID',
cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID', cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
agency_id BIGINT 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_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_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_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='代理开业申请和结算事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业申请和结算事实';
CREATE TABLE IF NOT EXISTS agency_opening_score_events ( 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 '本次流水金币', score_delta BIGINT NOT NULL COMMENT '本次流水金币',
occurred_at_ms BIGINT NOT NULL COMMENT '事件发生时间UTC epoch ms', occurred_at_ms BIGINT NOT NULL COMMENT '事件发生时间UTC epoch ms',
created_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), PRIMARY KEY (app_code, source_event_id),
KEY idx_agency_opening_score_event_application (app_code, application_id, occurred_at_ms) KEY idx_agency_opening_score_event_application (app_code, application_id, occurred_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业流水事件幂等表'; ) 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{ return domain.AgencySnapshot{
AgencyID: agency.GetAgencyId(), AgencyID: agency.GetAgencyId(),
RegionID: agency.GetRegionId(),
OwnerUserID: agency.GetOwnerUserId(), OwnerUserID: agency.GetOwnerUserId(),
Name: agency.GetName(), Name: agency.GetName(),
Status: agency.GetStatus(), Status: agency.GetStatus(),

View File

@ -34,9 +34,10 @@ type Reward struct {
UpdatedAtMS int64 UpdatedAtMS int64
} }
// Cycle is the UTC activity window configured by admin. // Cycle 是后台配置的 UTC 活动窗口RegionID=0 表示全区域,正数周期优先覆盖对应区域。
type Cycle struct { type Cycle struct {
AppCode string AppCode string
RegionID int64
CycleID string CycleID string
ActivityCode string ActivityCode string
Title string Title string
@ -62,6 +63,7 @@ type CycleCommand struct {
// ListQuery filters admin list reads. // ListQuery filters admin list reads.
type ListQuery struct { type ListQuery struct {
RegionID int64
Status string Status string
StartMS int64 StartMS int64
EndMS int64 EndMS int64
@ -69,9 +71,10 @@ type ListQuery struct {
PageSize int32 PageSize int32
} }
// Application is both the submitted application record and the settlement fact. // Application 同时保存申请和结算事实RegionID 固化申请时代理的实际区域,即使周期是全区域也不写 0。
type Application struct { type Application struct {
AppCode string AppCode string
RegionID int64
ApplicationID string ApplicationID string
CycleID string CycleID string
AgencyID int64 AgencyID int64
@ -99,6 +102,7 @@ type Application struct {
// ApplicationQuery filters admin and H5 leaderboard reads. // ApplicationQuery filters admin and H5 leaderboard reads.
type ApplicationQuery struct { type ApplicationQuery struct {
RegionID int64
CycleID string CycleID string
Status string Status string
AgencyID int64 AgencyID int64
@ -110,6 +114,7 @@ type ApplicationQuery struct {
// AgencySnapshot is read from user-service at apply time so the activity record remains auditable. // AgencySnapshot is read from user-service at apply time so the activity record remains auditable.
type AgencySnapshot struct { type AgencySnapshot struct {
AgencyID int64 AgencyID int64
RegionID int64
OwnerUserID int64 OwnerUserID int64
Name string Name string
Status string Status string
@ -133,6 +138,7 @@ type Qualification struct {
// CoinSpent keeps the legacy field name but stores RoomGiftSent.gift_value/HeatValue. // CoinSpent keeps the legacy field name but stores RoomGiftSent.gift_value/HeatValue.
type GiftEvent struct { type GiftEvent struct {
EventID string EventID string
RegionID int64
RoomID string RoomID string
RoomOwnerUserID int64 RoomOwnerUserID int64
TargetUserID int64 TargetUserID int64

View File

@ -26,9 +26,10 @@ type Tier struct {
UpdatedAtMS int64 UpdatedAtMS int64
} }
// Config 是当前 App 的房间流水奖励配置 // Config 是当前 App 的区域配置RegionID=0 表示全区域默认配置,正数配置优先覆盖对应区域
type Config struct { type Config struct {
AppCode string AppCode string
RegionID int64
Enabled bool Enabled bool
Tiers []Tier Tiers []Tier
UpdatedByAdminID int64 UpdatedByAdminID int64
@ -39,6 +40,7 @@ type Config struct {
// Progress 是单房间单贡献周期的 HeatValue 聚合CoinSpent 是历史字段名,运行值为房间贡献值。 // Progress 是单房间单贡献周期的 HeatValue 聚合CoinSpent 是历史字段名,运行值为房间贡献值。
type Progress struct { type Progress struct {
AppCode string AppCode string
RegionID int64
RoomID string RoomID string
PeriodStartMS int64 PeriodStartMS int64
PeriodEndMS int64 PeriodEndMS int64
@ -53,6 +55,7 @@ type RoomGiftEvent struct {
EventID string EventID string
EventType string EventType string
AppCode string AppCode string
RegionID int64
RoomID string RoomID string
CoinSpent int64 CoinSpent int64
OccurredAtMS int64 OccurredAtMS int64
@ -68,6 +71,7 @@ type EventResult struct {
type Settlement struct { type Settlement struct {
SettlementID string SettlementID string
AppCode string AppCode string
RegionID int64
RoomID string RoomID string
OwnerUserID int64 OwnerUserID int64
PeriodStartMS int64 PeriodStartMS int64
@ -102,6 +106,7 @@ type StatusResult struct {
// SettlementQuery 是后台结算记录分页筛选条件。 // SettlementQuery 是后台结算记录分页筛选条件。
type SettlementQuery struct { type SettlementQuery struct {
RegionID int64
Status string Status string
RoomID string RoomID string
OwnerUserID int64 OwnerUserID int64

View File

@ -24,7 +24,7 @@ type Repository interface {
GetAgencyOpeningCycle(ctx context.Context, cycleID string) (domain.Cycle, error) GetAgencyOpeningCycle(ctx context.Context, cycleID string) (domain.Cycle, error)
UpsertAgencyOpeningCycle(ctx context.Context, command domain.CycleCommand, nowMS int64) (domain.Cycle, bool, 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) 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) 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) 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) 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 return domain.Qualification{}, err
} }
nowMS := s.now().UTC().UnixMilli() nowMS := s.now().UTC().UnixMilli()
cycle, exists, err := s.repository.FindCurrentAgencyOpeningCycle(ctx, nowMS) result := domain.Qualification{ServerTimeMS: nowMS}
if err != nil { // 代理区域来自 user-service 的 Agency owner 事实;先解析区域,再按“区域覆盖、全区域默认”顺序选择周期,禁止信任请求参数推断区域。
return domain.Qualification{}, err
}
result := domain.Qualification{Cycle: cycle, Enabled: exists, ServerTimeMS: nowMS}
if !exists {
result.IneligibleReason = "activity_disabled"
return result, nil
}
agency, ok, err := s.resolveOwnerAgency(ctx, userID) agency, ok, err := s.resolveOwnerAgency(ctx, userID)
if err != nil { if err != nil {
return domain.Qualification{}, err return domain.Qualification{}, err
} }
result.Agency = agency result.Agency = agency
if !ok { if !ok || agency.RegionID <= 0 {
result.IneligibleReason = "active_agency_owner_required" result.IneligibleReason = "active_agency_owner_required"
return result, nil 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) application, joined, err := s.repository.GetAgencyOpeningApplicationByOwner(ctx, agency.OwnerUserID)
if err != nil { if err != nil {
return domain.Qualification{}, err return domain.Qualification{}, err
@ -191,7 +194,7 @@ func (s *Service) HandleGiftEvent(ctx context.Context, event domain.GiftEvent) (
if err := s.requireRepository(); err != nil { if err := s.requireRepository(); err != nil {
return 0, err 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 return 0, nil
} }
result, err := s.repository.ConsumeAgencyOpeningGiftEvent(ctx, event, s.now().UTC().UnixMilli()) 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()) roomID := strings.TrimSpace(envelope.GetRoomId())
contributionValue := gift.GetGiftValue() contributionValue := gift.GetGiftValue()
regionID := gift.GetVisibleRegionId()
if gift.GetIsRobotGift() || roomID == "" || contributionValue <= 0 { if gift.GetIsRobotGift() || roomID == "" || contributionValue <= 0 {
return 0, nil return 0, nil
} }
@ -231,6 +235,7 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
} }
return s.HandleGiftEvent(eventCtx, domain.GiftEvent{ return s.HandleGiftEvent(eventCtx, domain.GiftEvent{
EventID: strings.TrimSpace(envelope.GetEventId()), EventID: strings.TrimSpace(envelope.GetEventId()),
RegionID: regionID,
RoomID: roomID, RoomID: roomID,
RoomOwnerUserID: ownerUserID, RoomOwnerUserID: ownerUserID,
TargetUserID: gift.GetTargetUserId(), TargetUserID: gift.GetTargetUserId(),
@ -369,6 +374,10 @@ func (s *Service) requireRepository() error {
} }
func validateCycle(cycle domain.Cycle, operatorAdminID int64) 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) == "" { if strings.TrimSpace(cycle.Title) == "" {
return xerr.New(xerr.InvalidArgument, "title is required") 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) { 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" { if agency.Status != "active" {
return false, "active_agency_required" return false, "active_agency_required"
} }

View File

@ -17,8 +17,8 @@ import (
) )
func TestEligibleRequiresHostCountGreaterThanConfigured(t *testing.T) { func TestEligibleRequiresHostCountGreaterThanConfigured(t *testing.T) {
cycle := domain.Cycle{MinHostCount: 10} cycle := domain.Cycle{RegionID: 12, MinHostCount: 10}
agency := domain.AgencySnapshot{Status: "active", HostCount: 10} agency := domain.AgencySnapshot{RegionID: 12, Status: "active", HostCount: 10}
if ok, reason := eligible(cycle, agency, 1000); ok || reason != "host_count_not_enough" { 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) 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 != "" { 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) 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) { func TestGetStatusRejectsOwnerAlreadyAppliedInAnyCycle(t *testing.T) {
repo := &agencyOpeningFakeRepo{ 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, hasCurrent: true,
application: domain.Application{ application: domain.Application{
ApplicationID: "old-application", ApplicationID: "old-application",
@ -41,7 +46,7 @@ func TestGetStatusRejectsOwnerAlreadyAppliedInAnyCycle(t *testing.T) {
hasApplication: true, hasApplication: true,
} }
svc := New(repo, nil, agencyOpeningFakeAgencySource{ 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, ok: true,
}, nil) }, nil)
svc.now = func() time.Time { return time.UnixMilli(5000).UTC() } svc.now = func() time.Time { return time.UnixMilli(5000).UTC() }
@ -60,7 +65,7 @@ func TestGetStatusRejectsOwnerAlreadyAppliedInAnyCycle(t *testing.T) {
func TestGetStatusRequiresAgencyOwner(t *testing.T) { func TestGetStatusRequiresAgencyOwner(t *testing.T) {
repo := &agencyOpeningFakeRepo{ 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, hasCurrent: true,
} }
svc := New(repo, nil, agencyOpeningFakeAgencySource{ok: false}, nil) svc := New(repo, nil, agencyOpeningFakeAgencySource{ok: false}, nil)
@ -81,10 +86,11 @@ func TestHandleRoomEventScoresApplicantRoomOwner(t *testing.T) {
svc := New(repo, nil, nil, room) svc := New(repo, nil, nil, room)
svc.now = func() time.Time { return time.UnixMilli(8000).UTC() } svc.now = func() time.Time { return time.UnixMilli(8000).UTC() }
body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{ body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{
SenderUserId: 9, SenderUserId: 9,
TargetUserId: 88, TargetUserId: 88,
GiftValue: 900, GiftValue: 900,
CoinSpent: 1200, CoinSpent: 1200,
VisibleRegionId: 12,
}) })
if err != nil { if err != nil {
t.Fatalf("marshal gift: %v", err) t.Fatalf("marshal gift: %v", err)
@ -106,7 +112,7 @@ func TestHandleRoomEventScoresApplicantRoomOwner(t *testing.T) {
if room.roomID != "room-1" { if room.roomID != "room-1" {
t.Fatalf("expected room lookup by event room_id, got %q", room.roomID) 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) t.Fatalf("unexpected consumed event: %+v", repo.consumed)
} }
} }
@ -115,7 +121,7 @@ func TestHandleRoomEventTreatsDeletedRoomAsTerminalSkip(t *testing.T) {
repo := &agencyOpeningFakeRepo{consumeStatus: domain.EventStatusConsumed} repo := &agencyOpeningFakeRepo{consumeStatus: domain.EventStatusConsumed}
room := &agencyOpeningFakeRoomClient{err: status.Error(codes.NotFound, "room not found")} room := &agencyOpeningFakeRoomClient{err: status.Error(codes.NotFound, "room not found")}
svc := New(repo, nil, nil, room) 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 { if err != nil {
t.Fatalf("marshal gift: %v", err) t.Fatalf("marshal gift: %v", err)
} }
@ -135,7 +141,7 @@ func TestHandleRoomEventKeepsTransientRoomFailureRetryable(t *testing.T) {
repo := &agencyOpeningFakeRepo{consumeStatus: domain.EventStatusConsumed} repo := &agencyOpeningFakeRepo{consumeStatus: domain.EventStatusConsumed}
room := &agencyOpeningFakeRoomClient{err: status.Error(codes.Unavailable, "room service unavailable")} room := &agencyOpeningFakeRoomClient{err: status.Error(codes.Unavailable, "room service unavailable")}
svc := New(repo, nil, nil, room) 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 { if err != nil {
t.Fatalf("marshal gift: %v", err) 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) { func (r *agencyOpeningFakeRepo) SetAgencyOpeningCycleStatus(context.Context, string, string, int64, int64) (domain.Cycle, error) {
return domain.Cycle{}, nil 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 return r.currentCycle, r.hasCurrent, nil
} }
func (r *agencyOpeningFakeRepo) GetAgencyOpeningApplicationByOwner(_ context.Context, ownerUserID int64) (domain.Application, bool, error) { 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) { func superGiftPayload(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent, eventID string, sentAtMS int64, sender SenderProfile, receiver SenderProfile) (string, error) {
// payload 同时保留扁平字段和 sender/receiver 对象,旧客户端继续读原字段,新客户端可直接取完整用户资料。 // payload 同时保留扁平字段和 sender/receiver 对象,旧客户端继续读原字段,新客户端可直接取完整用户资料。
payload := map[string]any{ payload := map[string]any{
"event_id": eventID, "event_id": eventID,
"broadcast_type": broadcastdomain.TypeSuperGift, "broadcast_type": broadcastdomain.TypeSuperGift,
"scope": broadcastdomain.ScopeRegion, "scope": broadcastdomain.ScopeRegion,
"app_code": envelope.GetAppCode(), "app_code": envelope.GetAppCode(),
"region_id": gift.GetVisibleRegionId(), "region_id": gift.GetVisibleRegionId(),
"room_id": envelope.GetRoomId(), "room_id": envelope.GetRoomId(),
"room_locked": gift.GetRoomLocked(), "room_locked": gift.GetRoomLocked(),
"sender_user_id": gift.GetSenderUserId(), "sender_user_id": gift.GetSenderUserId(),
"sender_display_user_id": sender.Account, "sender_display_user_id": sender.Account,
"sender_nickname": sender.Nickname, "sender_nickname": sender.Nickname,
"sender_avatar": sender.Avatar, "sender_avatar": sender.Avatar,
"sender_avatar_frame_url": sender.AvatarFrameURL, "sender_avatar_frame_url": sender.AvatarFrameURL,
"target_user_id": gift.GetTargetUserId(), // 托盘 URL 是 gateway 在扣费前从有效 VIP 权益固化、room durable fact 透传的历史快照;
"receiver_user_id": gift.GetTargetUserId(), // activity 不能在异步消费时按当前会员状态回查,否则同一 event_id 重试会改变客户端展示。
"receiver_display_user_id": receiver.Account, "sender_vip_level": gift.GetSenderVipLevel(),
"receiver_nickname": receiver.Nickname, "sender_gift_broadcast_animation_url": gift.GetSenderGiftBroadcastAnimationUrl(),
"receiver_avatar": receiver.Avatar, "target_user_id": gift.GetTargetUserId(),
"receiver_avatar_frame_url": receiver.AvatarFrameURL, "receiver_user_id": gift.GetTargetUserId(),
"gift_id": gift.GetGiftId(), "receiver_display_user_id": receiver.Account,
"gift_name": gift.GetGiftName(), "receiver_nickname": receiver.Nickname,
"gift_icon_url": gift.GetGiftIconUrl(), "receiver_avatar": receiver.Avatar,
"gift_animation_url": gift.GetGiftAnimationUrl(), "receiver_avatar_frame_url": receiver.AvatarFrameURL,
"gift_effect_types": gift.GetGiftEffectTypes(), "gift_id": gift.GetGiftId(),
"gift_count": gift.GetGiftCount(), "gift_name": gift.GetGiftName(),
"gift_value": gift.GetGiftValue(), "gift_icon_url": gift.GetGiftIconUrl(),
"sent_at_ms": sentAtMS, "gift_animation_url": gift.GetGiftAnimationUrl(),
"room_version": envelope.GetRoomVersion(), "gift_effect_types": gift.GetGiftEffectTypes(),
"source_event_id": envelope.GetEventId(), "gift_count": gift.GetGiftCount(),
"sender": broadcastProfilePayload(sender), "gift_value": gift.GetGiftValue(),
"receiver": broadcastProfilePayload(receiver), "sent_at_ms": sentAtMS,
"room_version": envelope.GetRoomVersion(),
"source_event_id": envelope.GetEventId(),
"sender": broadcastProfilePayload(sender),
"receiver": broadcastProfilePayload(receiver),
"action": map[string]any{ "action": map[string]any{
"type": "enter_room", "type": "enter_room",
"room_id": envelope.GetRoomId(), "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"}, 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{ envelope := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{
SenderUserId: 42, SenderUserId: 42,
TargetUserId: 43, TargetUserId: 43,
GiftId: "rocket", GiftId: "rocket",
GiftCount: 2, GiftCount: 2,
GiftValue: 999, GiftValue: 999,
VisibleRegionId: 1001, VisibleRegionId: 1001,
RoomLocked: true, RoomLocked: true,
CommandId: "cmd-send-gift-1", CommandId: "cmd-send-gift-1",
GiftName: "Rocket", GiftName: "Rocket",
GiftIconUrl: "https://cdn.example/rocket.png", GiftIconUrl: "https://cdn.example/rocket.png",
GiftEffectTypes: []string{"animation", "global_broadcast"}, 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) 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 { if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
t.Fatalf("payload is not json: %v", err) 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) t.Fatalf("payload mismatch: %+v", payload)
} }
sender, _ := payload["sender"].(map[string]any) sender, _ := payload["sender"].(map[string]any)

View File

@ -27,11 +27,11 @@ const (
// Repository 是房间流水奖励配置、周期聚合和结算记录的唯一持久化边界。 // Repository 是房间流水奖励配置、周期聚合和结算记录的唯一持久化边界。
type Repository interface { 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) 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) 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) GetRoomTurnoverRewardProgress(ctx context.Context, regionID int64, roomID string, periodStartMS int64) (domain.Progress, bool, error)
GetLatestRoomTurnoverRewardSettlement(ctx context.Context, roomID string) (domain.Settlement, 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) ListRoomTurnoverRewardSettlements(ctx context.Context, query domain.SettlementQuery) ([]domain.Settlement, int64, error)
ListUnsettledRoomTurnoverRewardProgress(ctx context.Context, periodStartMS int64, limit int) ([]domain.Progress, 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) 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 { if err := s.requireRepository(); err != nil {
return domain.Config{}, err 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 { if err != nil {
return domain.Config{}, err return domain.Config{}, err
} }
if !exists { if !exists {
return domain.Config{AppCode: appcode.FromContext(ctx)}, nil return domain.Config{AppCode: appcode.FromContext(ctx), RegionID: regionID}, nil
} }
return config, 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()) 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 { if err := s.requireRepository(); err != nil {
return domain.StatusResult{}, err return domain.StatusResult{}, err
} }
@ -133,22 +136,20 @@ func (s *Service) GetStatus(ctx context.Context, userID int64, roomID string, ow
PeriodEndMS: end.UnixMilli(), PeriodEndMS: end.UnixMilli(),
ServerTimeMS: now.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 { if err != nil {
return domain.StatusResult{}, err return domain.StatusResult{}, err
} }
if exists { if exists {
config.Tiers = sortTiersForDisplay(config.Tiers) config.Tiers = sortTiersForDisplay(config.Tiers)
result.Config = config result.Config = config
} else {
// 未配置不是异常后台首次进入或活动未上线时H5 仍需要贡献周期时间和空档位来渲染默认态。
result.Config = domain.Config{AppCode: appcode.FromContext(ctx)}
} }
if result.RoomID == "" { if result.RoomID == "" {
// 当前用户没有自己的房间时不能查询流水聚合;直接返回配置和周期,让 H5 显示无房间/未参与状态 // 当前用户没有自己的房间时不能查询流水聚合;直接返回当前区域实际命中的配置和周期。
return result, nil 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 { if err != nil {
return domain.StatusResult{}, err return domain.StatusResult{}, err
} }
@ -159,7 +160,7 @@ func (s *Service) GetStatus(ctx context.Context, userID int64, roomID string, ow
result.MatchedTier = tier result.MatchedTier = tier
result.ExpectedRewardCoinAmount = tier.RewardCoinAmount 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 return domain.StatusResult{}, err
} else if found { } else if found {
// 最近结算不限定当前周期;周切换后当前流水会清零,但 H5 仍要展示上一周期是否已经发奖或失败。 // 最近结算不限定当前周期;周切换后当前流水会清零,但 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 return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil
} }
contributionValue := gift.GetGiftValue() contributionValue := gift.GetGiftValue()
regionID := gift.GetVisibleRegionId()
if strings.TrimSpace(envelope.GetRoomId()) == "" || contributionValue <= 0 { if strings.TrimSpace(envelope.GetRoomId()) == "" || contributionValue <= 0 {
// 房间流水奖励只消费 wallet 结算后的 HeatValue也就是 RoomGiftSent.gift_value原始扣费 coin_spent 不再参与达标口径。 // 房间流水奖励只消费 wallet 结算后的 HeatValue也就是 RoomGiftSent.gift_value原始扣费 coin_spent 不再参与达标口径。
return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil 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(), EventID: envelope.GetEventId(),
EventType: envelope.GetEventType(), EventType: envelope.GetEventType(),
AppCode: envelope.GetAppCode(), AppCode: envelope.GetAppCode(),
RegionID: regionID,
RoomID: strings.TrimSpace(envelope.GetRoomId()), RoomID: strings.TrimSpace(envelope.GetRoomId()),
CoinSpent: contributionValue, CoinSpent: contributionValue,
OccurredAtMS: occurred.UnixMilli(), OccurredAtMS: occurred.UnixMilli(),
@ -236,14 +239,6 @@ func (s *Service) ProcessSettlementBatch(ctx context.Context, batchSize int) (do
if batchSize <= 0 { if batchSize <= 0 {
batchSize = 100 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不能提前冻结或清空。 // 只结算上一个完整贡献周期;当前周期仍在持续接收 RoomGiftSent不能提前冻结或清空。
periodStart, periodEnd := PreviousWeekWindow(s.now().UTC()) periodStart, periodEnd := PreviousWeekWindow(s.now().UTC())
result := domain.BatchResult{} result := domain.BatchResult{}
@ -252,7 +247,21 @@ func (s *Service) ProcessSettlementBatch(ctx context.Context, batchSize int) (do
return domain.BatchResult{}, err return domain.BatchResult{}, err
} }
nowMS := s.now().UTC().UnixMilli() nowMS := s.now().UTC().UnixMilli()
configs := make(map[int64]domain.Config)
for _, progress := range progresses { 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 先固化当周流水和命中档位快照,后续运营改配置不会改变已经进入结算队列的周期结果。
settlement := s.settlementFromProgress(ctx, progress, periodEnd.UnixMilli(), activeTiers(config.Tiers), nowMS) settlement := s.settlementFromProgress(ctx, progress, periodEnd.UnixMilli(), activeTiers(config.Tiers), nowMS)
if settlement.TierID <= 0 { 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 { 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) tier, matched := MatchHighestTier(tiers, progress.CoinSpent)
settlement := domain.Settlement{ settlement := domain.Settlement{
AppCode: appcode.FromContext(ctx), AppCode: progress.AppCode,
RegionID: progress.RegionID,
RoomID: progress.RoomID, RoomID: progress.RoomID,
PeriodStartMS: progress.PeriodStartMS, PeriodStartMS: progress.PeriodStartMS,
PeriodEndMS: periodEndMS, PeriodEndMS: periodEndMS,
@ -359,6 +369,15 @@ func (s *Service) settlementFromProgress(ctx context.Context, progress domain.Pr
return settlement 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) { func (s *Service) grantSettlement(ctx context.Context, settlement domain.Settlement) (domain.Settlement, error) {
if settlement.Status == domain.SettlementStatusGranted { if settlement.Status == domain.SettlementStatusGranted {
// 发奖入口保持幂等,避免 cron 和人工 retry 同时进来时重复入账。 // 发奖入口保持幂等,避免 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()) _ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
return domain.Settlement{}, err 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{ resp, err := s.wallet.CreditRoomTurnoverReward(ctx, &walletv1.CreditRoomTurnoverRewardRequest{
CommandId: settlement.WalletCommandID, CommandId: settlement.WalletCommandID,
AppCode: appcode.FromContext(ctx), AppCode: appcode.FromContext(ctx),
@ -512,6 +531,10 @@ func sortTiersForDisplay(tiers []domain.Tier) []domain.Tier {
} }
func validateConfig(config domain.Config) error { 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 { if config.UpdatedByAdminID <= 0 {
return xerr.New(xerr.InvalidArgument, "operator_admin_id is required") 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 := New(repo, nil, nil)
svc.SetClock(func() time.Time { return mustParseTime(t, "2026-06-03T12:00:00Z") }) svc.SetClock(func() time.Time { return mustParseTime(t, "2026-06-03T12:00:00Z") })
body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{ body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{
GiftValue: 900, GiftValue: 900,
CoinSpent: 1200, CoinSpent: 1200,
VisibleRegionId: 12,
}) })
if err != nil { if err != nil {
t.Fatalf("marshal gift: %v", err) t.Fatalf("marshal gift: %v", err)
@ -154,7 +155,7 @@ func TestHandleRoomEventConsumesGiftValueAsHeatValue(t *testing.T) {
if result.Status != domain.EventStatusConsumed { if result.Status != domain.EventStatusConsumed {
t.Fatalf("unexpected result: %+v", result) 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) 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" { 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) { func TestGrantSettlementCreatesSystemNoticeBeforeMarkGranted(t *testing.T) {
repo := &retryBoundaryRepository{ repo := &retryBoundaryRepository{
settlement: roomTurnoverRewardGrantableSettlement(), settlement: roomTurnoverRewardGrantableSettlement(),
@ -230,6 +263,7 @@ func roomTurnoverRewardGrantableSettlement() domain.Settlement {
return domain.Settlement{ return domain.Settlement{
SettlementID: "settlement-room-turnover-1", SettlementID: "settlement-room-turnover-1",
AppCode: "lalu", AppCode: "lalu",
RegionID: 12,
RoomID: "room-1", RoomID: "room-1",
OwnerUserID: 42, OwnerUserID: 42,
PeriodStartMS: mustUnixMilli("2026-06-01T01:00:00Z"), PeriodStartMS: mustUnixMilli("2026-06-01T01:00:00Z"),
@ -272,10 +306,14 @@ type roomGiftEventRepository struct {
event domain.RoomGiftEvent event domain.RoomGiftEvent
periodStartMS int64 periodStartMS int64
periodEndMS int64 periodEndMS int64
configs map[int64]domain.Config
configLookups []int64
} }
func (r *roomGiftEventRepository) GetRoomTurnoverRewardConfig(context.Context) (domain.Config, bool, error) { func (r *roomGiftEventRepository) GetRoomTurnoverRewardConfig(_ context.Context, regionID int64) (domain.Config, bool, error) {
return domain.Config{}, false, nil 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) { 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 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 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 return domain.Settlement{}, false, nil
} }
@ -329,7 +367,7 @@ func (r *roomGiftEventRepository) MarkRoomTurnoverRewardSettlementFailed(context
return nil 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") panic("unexpected GetRoomTurnoverRewardConfig")
} }
@ -341,11 +379,11 @@ func (r *retryBoundaryRepository) ConsumeRoomTurnoverGiftEvent(context.Context,
panic("unexpected ConsumeRoomTurnoverGiftEvent") 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") 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") panic("unexpected GetLatestRoomTurnoverRewardSettlement")
} }

View File

@ -6,6 +6,7 @@ func (r *Repository) ensureAgencyOpeningTables(ctx context.Context) error {
statements := []string{ statements := []string{
`CREATE TABLE IF NOT EXISTS agency_opening_cycles ( `CREATE TABLE IF NOT EXISTS agency_opening_cycles (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', 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', cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
activity_code VARCHAR(64) NOT NULL DEFAULT 'agency_opening' COMMENT '活动编码', activity_code VARCHAR(64) NOT NULL DEFAULT 'agency_opening' COMMENT '活动编码',
title VARCHAR(128) NOT NULL DEFAULT '' 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', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_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), 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) 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='代理开业活动周期'`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业活动周期'`,
`CREATE TABLE IF NOT EXISTS agency_opening_cycle_rewards ( `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='代理开业流水奖励档位'`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业流水奖励档位'`,
`CREATE TABLE IF NOT EXISTS agency_opening_applications ( `CREATE TABLE IF NOT EXISTS agency_opening_applications (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', 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', application_id VARCHAR(96) NOT NULL COMMENT '申请 ID',
cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID', cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
agency_id BIGINT 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_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_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_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='代理开业申请和结算事实'`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业申请和结算事实'`,
`CREATE TABLE IF NOT EXISTS agency_opening_score_events ( `CREATE TABLE IF NOT EXISTS agency_opening_score_events (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', 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', score_delta BIGINT NOT NULL COMMENT '本次房间贡献值 HeatValue',
occurred_at_ms BIGINT NOT NULL COMMENT '事件发生时间UTC epoch ms', occurred_at_ms BIGINT NOT NULL COMMENT '事件发生时间UTC epoch ms',
created_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), PRIMARY KEY (app_code, source_event_id),
KEY idx_agency_opening_score_event_application (app_code, application_id, occurred_at_ms) KEY idx_agency_opening_score_event_application (app_code, application_id, occurred_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业流水事件幂等表'`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业流水事件幂等表'`,
@ -87,12 +91,56 @@ func (r *Repository) ensureAgencyOpeningTables(ctx context.Context) error {
return err return err
} }
} }
if err := r.ensureAgencyOpeningRegionSchema(ctx); err != nil {
return err
}
if err := r.ensureAgencyOpeningRewardTierColumns(ctx); err != nil { if err := r.ensureAgencyOpeningRewardTierColumns(ctx); err != nil {
return err return err
} }
return r.ensureAgencyOpeningApplicationOwnerOnceIndex(ctx) 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 { func (r *Repository) ensureAgencyOpeningRewardTierColumns(ctx context.Context) error {
if exists, err := r.columnExists(ctx, "agency_opening_cycle_rewards", "threshold_coin_spent"); err != nil { if exists, err := r.columnExists(ctx, "agency_opening_cycle_rewards", "threshold_coin_spent"); err != nil {
return err return err

View File

@ -30,7 +30,7 @@ func (r *Repository) ListAgencyOpeningCycles(ctx context.Context, query domain.L
page = 1 page = 1
} }
rows, err := r.db.QueryContext(ctx, ` 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 created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
FROM agency_opening_cycles `+where+` FROM agency_opening_cycles `+where+`
ORDER BY start_ms DESC, cycle_id DESC 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.CycleID = idgen.New("aopen")
cycle.CreatedAtMS = nowMS cycle.CreatedAtMS = nowMS
cycle.CreatedByAdminID = command.OperatorAdminID cycle.CreatedByAdminID = command.OperatorAdminID
} else if _, err := r.getAgencyOpeningCycle(ctx, tx, cycle.CycleID); err != nil { } else {
return domain.Cycle{}, false, err 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 { if !created && command.RequireNewRecord {
return domain.Cycle{}, false, xerr.New(xerr.InvalidArgument, "cycle_id already exists") 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 { if created {
_, err = tx.ExecContext(ctx, ` _, err = tx.ExecContext(ctx, `
INSERT INTO agency_opening_cycles ( 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 created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
cycle.AppCode, cycle.CycleID, cycle.ActivityCode, cycle.Title, cycle.Status, cycle.StartMS, cycle.EndMS, cycle.MinHostCount, cycle.MaxAgencyAgeDays, 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) cycle.CreatedByAdminID, cycle.UpdatedByAdminID, cycle.CreatedAtMS, cycle.UpdatedAtMS)
} else { } else {
_, err = tx.ExecContext(ctx, ` _, err = tx.ExecContext(ctx, `
UPDATE agency_opening_cycles 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 = ? updated_by_admin_id = ?, updated_at_ms = ?
WHERE app_code = ? AND cycle_id = ?`, 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) cycle.UpdatedByAdminID, cycle.UpdatedAtMS, cycle.AppCode, cycle.CycleID)
} }
if err != nil { if err != nil {
@ -170,17 +177,26 @@ func (r *Repository) SetAgencyOpeningCycleStatus(ctx context.Context, cycleID st
return r.GetAgencyOpeningCycle(ctx, cycleID) 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 { if r == nil || r.db == nil {
return domain.Cycle{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") 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, ` 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 created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
FROM agency_opening_cycles 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 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 err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return domain.Cycle{}, false, nil return domain.Cycle{}, false, nil
@ -215,11 +231,11 @@ func (r *Repository) CreateAgencyOpeningApplication(ctx context.Context, cycle d
applicationID := idgen.New("aopenapp") applicationID := idgen.New("aopenapp")
result, err := r.db.ExecContext(ctx, ` result, err := r.db.ExecContext(ctx, `
INSERT IGNORE INTO agency_opening_applications ( 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, 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 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, ?, ?)`, ) 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, appcode.FromContext(ctx), agency.RegionID, applicationID, cycle.CycleID, agency.AgencyID, agency.OwnerUserID, agency.Name, agency.HostCount, agency.AgencyCreatedAtMS,
domain.ApplicationStatusApplied, nowMS, nowMS, nowMS) domain.ApplicationStatusApplied, nowMS, nowMS, nowMS)
if err != nil { if err != nil {
return domain.Application{}, false, err return domain.Application{}, false, err
@ -244,7 +260,7 @@ func (r *Repository) ApproveAgencyOpeningApplication(ctx context.Context, applic
err = tx.QueryRowContext(ctx, agencyOpeningApplicationSelect()+` err = tx.QueryRowContext(ctx, agencyOpeningApplicationSelect()+`
WHERE app_code = ? AND application_id = ? WHERE app_code = ? AND application_id = ?
FOR UPDATE`, appcode.FromContext(ctx), strings.TrimSpace(applicationID)).Scan( 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.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.WalletCommandID, &app.WalletTransactionID, &app.FailureReason, &app.AppliedAtMS, &app.ApprovedAtMS, &app.ScoreStartMS, &app.ScoreEndMS,
&app.ReviewedByAdminID, &app.GrantedAtMS, &app.CreatedAtMS, &app.UpdatedAtMS) &app.ReviewedByAdminID, &app.GrantedAtMS, &app.CreatedAtMS, &app.UpdatedAtMS)
@ -337,17 +353,18 @@ func (r *Repository) ConsumeAgencyOpeningGiftEvent(ctx context.Context, event do
defer tx.Rollback() defer tx.Rollback()
var app domain.Application var app domain.Application
err = tx.QueryRowContext(ctx, ` 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.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 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 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 INNER JOIN agency_opening_cycles cycle
WHERE app.app_code = ? AND app.agency_owner_user_id = ? AND app.status = ? AND app.score_start_ms <= ? AND app.score_end_ms > ? 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 > ? AND cycle.activity_code = ? AND cycle.status IN (?, ?) AND cycle.start_ms <= ? AND cycle.end_ms > ?
ORDER BY app.applied_at_ms DESC 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( 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.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.WalletCommandID, &app.WalletTransactionID, &app.FailureReason, &app.AppliedAtMS, &app.ApprovedAtMS, &app.ScoreStartMS, &app.ScoreEndMS,
&app.ReviewedByAdminID, &app.GrantedAtMS, &app.CreatedAtMS, &app.UpdatedAtMS) &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, ` result, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO agency_opening_score_events ( 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 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.FromContext(ctx), event.EventID, app.CycleID, app.ApplicationID, app.AgencyID, event.TargetUserID, event.CoinSpent, event.OccurredAtMS, nowMS) appcode.FromContext(ctx), event.EventID, app.CycleID, app.ApplicationID, app.AgencyID, event.TargetUserID, event.CoinSpent, event.OccurredAtMS, nowMS, app.RegionID)
if err != nil { if err != nil {
return domain.EventResult{}, err return domain.EventResult{}, err
} }
@ -402,7 +419,7 @@ func (r *Repository) ClaimDueAgencyOpeningCycles(ctx context.Context, workerID s
return nil, err return nil, err
} }
rows, err := r.db.QueryContext(ctx, ` 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 created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
FROM agency_opening_cycles FROM agency_opening_cycles
WHERE app_code = ? AND activity_code = ? AND status = ? AND locked_by = ? AND lock_until_ms > ? 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 domain.Cycle{}, xerr.New(xerr.InvalidArgument, "cycle_id is required")
} }
return scanAgencyOpeningCycle(q.QueryRowContext(ctx, ` 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 created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
FROM agency_opening_cycles FROM agency_opening_cycles
WHERE app_code = ? AND cycle_id = ?`, appcode.FromContext(ctx), cycleID)) 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 var count int64
if err := q.QueryRowContext(ctx, ` if err := q.QueryRowContext(ctx, `
SELECT COUNT(*) FROM agency_opening_cycles SELECT COUNT(*) FROM agency_opening_cycles
WHERE app_code = ? AND activity_code = ? AND status = ? AND cycle_id <> ? AND start_ms < ? AND end_ms > ?`, WHERE app_code = ? AND region_id = ? 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 { appcode.FromContext(ctx), cycle.RegionID, domain.ActivityCode, domain.StatusActive, cycle.CycleID, cycle.EndMS, cycle.StartMS).Scan(&count); err != nil {
return err return err
} }
if count > 0 { 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) { func agencyOpeningCycleWhere(ctx context.Context, query domain.ListQuery) (string, []any) {
where := []string{"app_code = ?", "activity_code = ?"} where := []string{"app_code = ?", "activity_code = ?"}
args := []any{appcode.FromContext(ctx), domain.ActivityCode} 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) != "" { if strings.TrimSpace(query.Status) != "" {
where = append(where, "status = ?") where = append(where, "status = ?")
args = append(args, strings.TrimSpace(query.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) { func agencyOpeningApplicationWhere(ctx context.Context, query domain.ApplicationQuery) (string, []any) {
where := []string{"app_code = ?"} where := []string{"app_code = ?"}
args := []any{appcode.FromContext(ctx)} args := []any{appcode.FromContext(ctx)}
if query.RegionID > 0 {
where = append(where, "region_id = ?")
args = append(args, query.RegionID)
}
if strings.TrimSpace(query.CycleID) != "" { if strings.TrimSpace(query.CycleID) != "" {
where = append(where, "cycle_id = ?") where = append(where, "cycle_id = ?")
args = append(args, strings.TrimSpace(query.CycleID)) args = append(args, strings.TrimSpace(query.CycleID))
@ -650,14 +675,14 @@ func agencyOpeningApplicationWhere(ctx context.Context, query domain.Application
} }
func agencyOpeningApplicationSelect() string { 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, 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` 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) { func scanAgencyOpeningCycle(row interface{ Scan(...any) error }) (domain.Cycle, error) {
var item domain.Cycle 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.MinHostCount, &item.MaxAgencyAgeDays, &item.CreatedByAdminID, &item.UpdatedByAdminID, &item.SettledAtMS,
&item.CreatedAtMS, &item.UpdatedAtMS) &item.CreatedAtMS, &item.UpdatedAtMS)
return item, err 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) { func scanAgencyOpeningApplication(row interface{ Scan(...any) error }) (domain.Application, error) {
var item domain.Application 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.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.WalletCommandID, &item.WalletTransactionID, &item.FailureReason, &item.AppliedAtMS, &item.ApprovedAtMS, &item.ScoreStartMS, &item.ScoreEndMS,
&item.ReviewedByAdminID, &item.GrantedAtMS, &item.CreatedAtMS, &item.UpdatedAtMS) &item.ReviewedByAdminID, &item.GrantedAtMS, &item.CreatedAtMS, &item.UpdatedAtMS)

View File

@ -30,9 +30,9 @@ func TestAgencyOpeningScoresOnlyAfterApprovalWithinWindow(t *testing.T) {
) )
_, err = repository.db.ExecContext(ctx, ` _, err = repository.db.ExecContext(ctx, `
INSERT INTO agency_opening_cycles ( 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 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, cycleID, domain.ActivityCode, domain.StatusActive, nowMS+int64((48*60*60*1000)), nowMS, nowMS,
) )
if err != nil { if err != nil {
@ -40,11 +40,11 @@ func TestAgencyOpeningScoresOnlyAfterApprovalWithinWindow(t *testing.T) {
} }
_, err = repository.db.ExecContext(ctx, ` _, err = repository.db.ExecContext(ctx, `
INSERT INTO agency_opening_applications ( 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, 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, 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 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, applicationID, cycleID, ownerUserID, domain.ApplicationStatusApplied, nowMS-1000, nowMS, nowMS,
) )
if err != nil { if err != nil {
@ -53,6 +53,7 @@ func TestAgencyOpeningScoresOnlyAfterApprovalWithinWindow(t *testing.T) {
beforeApproval, err := repository.ConsumeAgencyOpeningGiftEvent(ctx, domain.GiftEvent{ beforeApproval, err := repository.ConsumeAgencyOpeningGiftEvent(ctx, domain.GiftEvent{
EventID: "gift-before-approval", EventID: "gift-before-approval",
RegionID: 12,
RoomOwnerUserID: ownerUserID, RoomOwnerUserID: ownerUserID,
TargetUserID: 99, TargetUserID: 99,
CoinSpent: 700, CoinSpent: 700,
@ -74,8 +75,24 @@ func TestAgencyOpeningScoresOnlyAfterApprovalWithinWindow(t *testing.T) {
t.Fatalf("unexpected approved application: %+v", approved) 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{ insideWindow, err := repository.ConsumeAgencyOpeningGiftEvent(ctx, domain.GiftEvent{
EventID: "gift-inside-window", EventID: "gift-inside-window",
RegionID: 12,
RoomOwnerUserID: ownerUserID, RoomOwnerUserID: ownerUserID,
TargetUserID: 100, TargetUserID: 100,
CoinSpent: 900, CoinSpent: 900,
@ -90,6 +107,7 @@ func TestAgencyOpeningScoresOnlyAfterApprovalWithinWindow(t *testing.T) {
afterWindow, err := repository.ConsumeAgencyOpeningGiftEvent(ctx, domain.GiftEvent{ afterWindow, err := repository.ConsumeAgencyOpeningGiftEvent(ctx, domain.GiftEvent{
EventID: "gift-after-window", EventID: "gift-after-window",
RegionID: 12,
RoomOwnerUserID: ownerUserID, RoomOwnerUserID: ownerUserID,
TargetUserID: 101, TargetUserID: 101,
CoinSpent: 1200, CoinSpent: 1200,

View File

@ -320,6 +320,42 @@ func (r *Repository) indexExists(ctx context.Context, table string, index string
return count > 0, nil 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) { func (r *Repository) currentDatabase(ctx context.Context) (string, error) {
var database sql.NullString var database sql.NullString
if err := r.db.QueryRowContext(ctx, `SELECT DATABASE()`).Scan(&database); err != nil { 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{ statements := []string{
`CREATE TABLE IF NOT EXISTS room_turnover_reward_configs ( `CREATE TABLE IF NOT EXISTS room_turnover_reward_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', 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 '是否启用', enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用',
updated_by_admin_id BIGINT 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', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_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='房间流水奖励配置表'`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励配置表'`,
`CREATE TABLE IF NOT EXISTS room_turnover_reward_tiers ( `CREATE TABLE IF NOT EXISTS room_turnover_reward_tiers (
tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '档位 ID', tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '档位 ID',
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', 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_code VARCHAR(64) NOT NULL COMMENT '档位编码',
tier_name VARCHAR(128) NOT NULL COMMENT '档位名称', tier_name VARCHAR(128) NOT NULL COMMENT '档位名称',
threshold_coin_spent BIGINT NOT NULL COMMENT '命中该档需要的房间贡献值 HeatValue', 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', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (tier_id), PRIMARY KEY (tier_id),
UNIQUE KEY uk_room_turnover_reward_tier_code (app_code, tier_code), UNIQUE KEY uk_room_turnover_reward_tier_code (app_code, region_id, tier_code),
KEY idx_room_turnover_reward_tiers_match (app_code, status, threshold_coin_spent) 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='房间流水奖励档位表'`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励档位表'`,
`CREATE TABLE IF NOT EXISTS room_turnover_reward_event_consumption ( `CREATE TABLE IF NOT EXISTS room_turnover_reward_event_consumption (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', 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', consumed_at_ms BIGINT NOT NULL COMMENT '消费时间UTC epoch ms',
created_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', 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), PRIMARY KEY (app_code, event_id),
KEY idx_room_turnover_reward_event_period (app_code, period_start_ms, room_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='房间流水奖励事件消费幂等表'`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励事件消费幂等表'`,
`CREATE TABLE IF NOT EXISTS room_turnover_reward_progress ( `CREATE TABLE IF NOT EXISTS room_turnover_reward_progress (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', 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', room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
period_start_ms BIGINT NOT NULL COMMENT 'UTC 周期开始', period_start_ms BIGINT NOT NULL COMMENT 'UTC 周期开始',
period_end_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 '最后事件时间', last_event_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最后事件时间',
created_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', 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) KEY idx_room_turnover_reward_progress_period (app_code, period_start_ms, coin_spent)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励周期聚合表'`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励周期聚合表'`,
`CREATE TABLE IF NOT EXISTS room_turnover_reward_settlements ( `CREATE TABLE IF NOT EXISTS room_turnover_reward_settlements (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', 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', settlement_id VARCHAR(96) NOT NULL COMMENT '结算 ID',
room_id VARCHAR(96) NOT NULL COMMENT '房间 ID', room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '结算收款房主', 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', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_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), 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_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) 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='房间流水奖励每周结算表'`, ) 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 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 return nil
} }

View File

@ -14,13 +14,13 @@ import (
) )
// GetRoomTurnoverRewardConfig 读取房间流水奖励当前配置;未配置是后台首屏正常状态。 // 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 { if r == nil || r.db == nil {
return domain.Config{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") return domain.Config{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
} }
row := r.db.QueryRowContext(ctx, roomTurnoverRewardConfigSelectSQL()+` row := r.db.QueryRowContext(ctx, roomTurnoverRewardConfigSelectSQL()+`
WHERE app_code = ?`, WHERE app_code = ? AND region_id = ?`,
appcode.FromContext(ctx), appcode.FromContext(ctx), regionID,
) )
config, err := scanRoomTurnoverRewardConfig(row) config, err := scanRoomTurnoverRewardConfig(row)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@ -29,7 +29,7 @@ func (r *Repository) GetRoomTurnoverRewardConfig(ctx context.Context) (domain.Co
if err != nil { if err != nil {
return domain.Config{}, false, err return domain.Config{}, false, err
} }
tiers, err := r.listRoomTurnoverRewardTiers(ctx, nil) tiers, err := r.listRoomTurnoverRewardTiers(ctx, nil, regionID)
if err != nil { if err != nil {
return domain.Config{}, false, err return domain.Config{}, false, err
} }
@ -50,20 +50,20 @@ func (r *Repository) UpdateRoomTurnoverRewardConfig(ctx context.Context, config
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO room_turnover_reward_configs ( INSERT INTO room_turnover_reward_configs (
app_code, enabled, updated_by_admin_id, created_at_ms, updated_at_ms app_code, region_id, enabled, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled), enabled = VALUES(enabled),
updated_by_admin_id = VALUES(updated_by_admin_id), updated_by_admin_id = VALUES(updated_by_admin_id),
updated_at_ms = VALUES(updated_at_ms)`, 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 { ); err != nil {
return domain.Config{}, err return domain.Config{}, err
} }
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
DELETE FROM room_turnover_reward_tiers DELETE FROM room_turnover_reward_tiers
WHERE app_code = ?`, WHERE app_code = ? AND region_id = ?`,
appcode.FromContext(ctx), appcode.FromContext(ctx), config.RegionID,
); err != nil { ); err != nil {
return domain.Config{}, err return domain.Config{}, err
} }
@ -75,10 +75,10 @@ func (r *Repository) UpdateRoomTurnoverRewardConfig(ctx context.Context, config
if tier.TierID > 0 { if tier.TierID > 0 {
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO room_turnover_reward_tiers ( 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 reward_coin_amount, status, sort_order, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
tier.TierID, appcode.FromContext(ctx), tier.TierCode, tier.TierName, tier.ThresholdCoinSpent, tier.TierID, appcode.FromContext(ctx), config.RegionID, tier.TierCode, tier.TierName, tier.ThresholdCoinSpent,
tier.RewardCoinAmount, tier.Status, tier.SortOrder, tier.CreatedAtMS, tier.UpdatedAtMS, tier.RewardCoinAmount, tier.Status, tier.SortOrder, tier.CreatedAtMS, tier.UpdatedAtMS,
); err != nil { ); err != nil {
return domain.Config{}, err return domain.Config{}, err
@ -87,10 +87,10 @@ func (r *Repository) UpdateRoomTurnoverRewardConfig(ctx context.Context, config
} }
result, err := tx.ExecContext(ctx, ` result, err := tx.ExecContext(ctx, `
INSERT INTO room_turnover_reward_tiers ( 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 reward_coin_amount, status, sort_order, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.FromContext(ctx), tier.TierCode, tier.TierName, tier.ThresholdCoinSpent, appcode.FromContext(ctx), config.RegionID, tier.TierCode, tier.TierName, tier.ThresholdCoinSpent,
tier.RewardCoinAmount, tier.Status, tier.SortOrder, tier.CreatedAtMS, tier.UpdatedAtMS, tier.RewardCoinAmount, tier.Status, tier.SortOrder, tier.CreatedAtMS, tier.UpdatedAtMS,
) )
if err != nil { if err != nil {
@ -105,7 +105,7 @@ func (r *Repository) UpdateRoomTurnoverRewardConfig(ctx context.Context, config
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return domain.Config{}, err return domain.Config{}, err
} }
updated, exists, err := r.GetRoomTurnoverRewardConfig(ctx) updated, exists, err := r.GetRoomTurnoverRewardConfig(ctx, config.RegionID)
if err != nil { if err != nil {
return domain.Config{}, err return domain.Config{}, err
} }
@ -129,9 +129,9 @@ func (r *Repository) ConsumeRoomTurnoverGiftEvent(ctx context.Context, event dom
result, err := tx.ExecContext(ctx, ` result, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO room_turnover_reward_event_consumption ( INSERT IGNORE INTO room_turnover_reward_event_consumption (
app_code, event_id, event_type, room_id, period_start_ms, coin_spent, app_code, event_id, event_type, room_id, period_start_ms, coin_spent,
status, consumed_at_ms, created_at_ms, updated_at_ms status, consumed_at_ms, created_at_ms, updated_at_ms, region_id
) VALUES (?, ?, ?, ?, ?, ?, 'consumed', ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, 'consumed', ?, ?, ?, ?)`,
appcode.FromContext(ctx), event.EventID, event.EventType, event.RoomID, periodStartMS, event.CoinSpent, nowMS, nowMS, nowMS, appcode.FromContext(ctx), event.EventID, event.EventType, event.RoomID, periodStartMS, event.CoinSpent, nowMS, nowMS, nowMS, event.RegionID,
) )
if err != nil { if err != nil {
return domain.EventResult{}, err return domain.EventResult{}, err
@ -150,15 +150,15 @@ func (r *Repository) ConsumeRoomTurnoverGiftEvent(ctx context.Context, event dom
// progress 是 App H5 和结算任务共用的读模型;同一事务先落消费幂等,再按 UTC 周期原子累加房间流水。 // progress 是 App H5 和结算任务共用的读模型;同一事务先落消费幂等,再按 UTC 周期原子累加房间流水。
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO room_turnover_reward_progress ( 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 last_event_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
coin_spent = coin_spent + VALUES(coin_spent), coin_spent = coin_spent + VALUES(coin_spent),
period_end_ms = VALUES(period_end_ms), period_end_ms = VALUES(period_end_ms),
last_event_at_ms = GREATEST(last_event_at_ms, VALUES(last_event_at_ms)), last_event_at_ms = GREATEST(last_event_at_ms, VALUES(last_event_at_ms)),
updated_at_ms = VALUES(updated_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 { ); err != nil {
return domain.EventResult{}, err return domain.EventResult{}, err
} }
@ -169,13 +169,13 @@ func (r *Repository) ConsumeRoomTurnoverGiftEvent(ctx context.Context, event dom
} }
// GetRoomTurnoverRewardProgress 返回指定房间和 UTC 周期的当前流水。 // 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 { if r == nil || r.db == nil {
return domain.Progress{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") return domain.Progress{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
} }
row := r.db.QueryRowContext(ctx, roomTurnoverRewardProgressSelectSQL()+` row := r.db.QueryRowContext(ctx, roomTurnoverRewardProgressSelectSQL()+`
WHERE app_code = ? AND room_id = ? AND period_start_ms = ?`, WHERE app_code = ? AND region_id = ? AND room_id = ? AND period_start_ms = ?`,
appcode.FromContext(ctx), strings.TrimSpace(roomID), periodStartMS, appcode.FromContext(ctx), regionID, strings.TrimSpace(roomID), periodStartMS,
) )
item, err := scanRoomTurnoverRewardProgress(row) item, err := scanRoomTurnoverRewardProgress(row)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@ -184,16 +184,16 @@ func (r *Repository) GetRoomTurnoverRewardProgress(ctx context.Context, roomID s
return item, err == nil, err 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 { if r == nil || r.db == nil {
return domain.Settlement{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") return domain.Settlement{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
} }
// 最新结算给 H5 展示上一周期结果;按 period_start_ms 倒序而不是 status 排序,保证失败和成功都能被用户看到。 // 最新结算给 H5 展示上一周期结果;按 period_start_ms 倒序而不是 status 排序,保证失败和成功都能被用户看到。
row := r.db.QueryRowContext(ctx, roomTurnoverRewardSettlementSelectSQL()+` 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 ORDER BY period_start_ms DESC, created_at_ms DESC
LIMIT 1`, LIMIT 1`,
appcode.FromContext(ctx), strings.TrimSpace(roomID), appcode.FromContext(ctx), regionID, strings.TrimSpace(roomID),
) )
item, err := scanRoomTurnoverRewardSettlement(row) item, err := scanRoomTurnoverRewardSettlement(row)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@ -210,6 +210,10 @@ func (r *Repository) ListRoomTurnoverRewardSettlements(ctx context.Context, quer
// 后台列表只在 settlement 表上分页筛选;流水明细来自周期聚合快照,避免管理页回扫礼物事件。 // 后台列表只在 settlement 表上分页筛选;流水明细来自周期聚合快照,避免管理页回扫礼物事件。
where := []string{"app_code = ?"} where := []string{"app_code = ?"}
args := []any{appcode.FromContext(ctx)} args := []any{appcode.FromContext(ctx)}
if query.RegionID > 0 {
where = append(where, "region_id = ?")
args = append(args, query.RegionID)
}
if query.Status != "" { if query.Status != "" {
where = append(where, "status = ?") where = append(where, "status = ?")
args = append(args, query.Status) args = append(args, query.Status)
@ -262,14 +266,22 @@ func (r *Repository) ListUnsettledRoomTurnoverRewardProgress(ctx context.Context
if limit <= 0 { if limit <= 0 {
limit = 100 limit = 100
} }
// LEFT JOIN settlement 只认领还没有结算快照的 progress唯一键再兜底一次防止并发 worker 同时扫描到同一房间 // 具体区域配置存在时(即使关闭)优先于全区域配置;仅在区域配置不存在时回退 region_id=0避免 disabled 区域被全局开关重新启用
rows, err := r.db.QueryContext(ctx, roomTurnoverRewardProgressSelectSQL()+` 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 LEFT JOIN room_turnover_reward_settlements settlement
ON settlement.app_code = progress.app_code ON settlement.app_code = progress.app_code
AND settlement.region_id = progress.region_id
AND settlement.room_id = progress.room_id AND settlement.room_id = progress.room_id
AND settlement.period_start_ms = progress.period_start_ms AND settlement.period_start_ms = progress.period_start_ms
WHERE progress.app_code = ? AND 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 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 ORDER BY progress.coin_spent DESC, progress.room_id ASC
LIMIT ?`, LIMIT ?`,
appcode.FromContext(ctx), periodStartMS, limit, appcode.FromContext(ctx), periodStartMS, limit,
@ -290,7 +302,7 @@ func (r *Repository) InsertRoomTurnoverRewardSettlement(ctx context.Context, set
settlement.SettlementID = idgen.New("rtrsettle") settlement.SettlementID = idgen.New("rtrsettle")
} }
if settlement.WalletCommandID == "" { 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.CreatedAtMS = nowMS
settlement.UpdatedAtMS = nowMS settlement.UpdatedAtMS = nowMS
@ -300,12 +312,12 @@ func (r *Repository) InsertRoomTurnoverRewardSettlement(ctx context.Context, set
// room_id + period_start_ms 是业务唯一键cron 重跑、并发 worker 或人工补偿都只能为同一房间周期保留一条 settlement。 // room_id + period_start_ms 是业务唯一键cron 重跑、并发 worker 或人工补偿都只能为同一房间周期保留一条 settlement。
result, err := r.db.ExecContext(ctx, ` result, err := r.db.ExecContext(ctx, `
INSERT IGNORE INTO room_turnover_reward_settlements ( 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, coin_spent, tier_id, tier_code, threshold_coin_spent, reward_coin_amount,
status, wallet_command_id, wallet_transaction_id, failure_reason, status, wallet_command_id, wallet_transaction_id, failure_reason,
settled_at_ms, created_at_ms, updated_at_ms settled_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, 0, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, 0, ?, ?)`,
appcode.FromContext(ctx), settlement.SettlementID, settlement.RoomID, settlement.OwnerUserID, appcode.FromContext(ctx), settlement.RegionID, settlement.SettlementID, settlement.RoomID, settlement.OwnerUserID,
settlement.PeriodStartMS, settlement.PeriodEndMS, settlement.CoinSpent, settlement.TierID, settlement.PeriodStartMS, settlement.PeriodEndMS, settlement.CoinSpent, settlement.TierID,
settlement.TierCode, settlement.ThresholdCoinSpent, settlement.RewardCoinAmount, settlement.TierCode, settlement.ThresholdCoinSpent, settlement.RewardCoinAmount,
settlement.Status, settlement.WalletCommandID, settlement.FailureReason, nowMS, nowMS, settlement.Status, settlement.WalletCommandID, settlement.FailureReason, nowMS, nowMS,
@ -317,7 +329,7 @@ func (r *Repository) InsertRoomTurnoverRewardSettlement(ctx context.Context, set
if err != nil { if err != nil {
return domain.Settlement{}, false, err 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 return item, affected > 0 && exists, err
} }
@ -421,10 +433,10 @@ func (r *Repository) MarkRoomTurnoverRewardSettlementFailed(ctx context.Context,
return err 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()+` row := r.db.QueryRowContext(ctx, roomTurnoverRewardSettlementSelectSQL()+`
WHERE app_code = ? AND room_id = ? AND period_start_ms = ?`, WHERE app_code = ? AND region_id = ? AND room_id = ? AND period_start_ms = ?`,
appcode.FromContext(ctx), strings.TrimSpace(roomID), periodStartMS, appcode.FromContext(ctx), regionID, strings.TrimSpace(roomID), periodStartMS,
) )
item, err := scanRoomTurnoverRewardSettlement(row) item, err := scanRoomTurnoverRewardSettlement(row)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@ -434,24 +446,24 @@ func (r *Repository) getRoomTurnoverRewardSettlementByRoomPeriod(ctx context.Con
} }
func roomTurnoverRewardConfigSelectSQL() string { 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 ` FROM room_turnover_reward_configs `
} }
func roomTurnoverRewardTierSelectSQL() string { 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 reward_coin_amount, status, sort_order, created_at_ms, updated_at_ms
FROM room_turnover_reward_tiers ` FROM room_turnover_reward_tiers `
} }
func roomTurnoverRewardProgressSelectSQL() string { 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 progress.coin_spent, progress.last_event_at_ms, progress.created_at_ms, progress.updated_at_ms
FROM room_turnover_reward_progress progress ` FROM room_turnover_reward_progress progress `
} }
func roomTurnoverRewardSettlementSelectSQL() string { 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, coin_spent, tier_id, tier_code, threshold_coin_spent, reward_coin_amount,
status, wallet_command_id, wallet_transaction_id, failure_reason, status, wallet_command_id, wallet_transaction_id, failure_reason,
settled_at_ms, created_at_ms, updated_at_ms settled_at_ms, created_at_ms, updated_at_ms
@ -464,20 +476,20 @@ type roomTurnoverRewardConfigScanner interface {
func scanRoomTurnoverRewardConfig(scanner roomTurnoverRewardConfigScanner) (domain.Config, error) { func scanRoomTurnoverRewardConfig(scanner roomTurnoverRewardConfigScanner) (domain.Config, error) {
var item domain.Config 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 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() + ` query := roomTurnoverRewardTierSelectSQL() + `
WHERE app_code = ? WHERE app_code = ? AND region_id = ?
ORDER BY sort_order ASC, threshold_coin_spent ASC, tier_id ASC` ORDER BY sort_order ASC, threshold_coin_spent ASC, tier_id ASC`
var rows *sql.Rows var rows *sql.Rows
var err error var err error
if tx != nil { if tx != nil {
rows, err = tx.QueryContext(ctx, query, appcode.FromContext(ctx)) rows, err = tx.QueryContext(ctx, query, appcode.FromContext(ctx), regionID)
} else { } 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 { if err != nil {
return nil, err return nil, err
@ -487,7 +499,8 @@ func (r *Repository) listRoomTurnoverRewardTiers(ctx context.Context, tx *sql.Tx
for rows.Next() { for rows.Next() {
var item domain.Tier var item domain.Tier
var app string 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 return nil, err
} }
items = append(items, item) items = append(items, item)
@ -501,7 +514,7 @@ type roomTurnoverRewardProgressScanner interface {
func scanRoomTurnoverRewardProgress(scanner roomTurnoverRewardProgressScanner) (domain.Progress, error) { func scanRoomTurnoverRewardProgress(scanner roomTurnoverRewardProgressScanner) (domain.Progress, error) {
var item domain.Progress 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 return item, err
} }
@ -524,7 +537,7 @@ type roomTurnoverRewardSettlementScanner interface {
func scanRoomTurnoverRewardSettlement(scanner roomTurnoverRewardSettlementScanner) (domain.Settlement, error) { func scanRoomTurnoverRewardSettlement(scanner roomTurnoverRewardSettlementScanner) (domain.Settlement, error) {
var item domain.Settlement var item domain.Settlement
err := scanner.Scan( 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.PeriodStartMS, &item.PeriodEndMS, &item.CoinSpent,
&item.TierID, &item.TierCode, &item.ThresholdCoinSpent, &item.RewardCoinAmount, &item.TierID, &item.TierCode, &item.ThresholdCoinSpent, &item.RewardCoinAmount,
&item.Status, &item.WalletCommandID, &item.WalletTransactionID, &item.FailureReason, &item.Status, &item.WalletCommandID, &item.WalletTransactionID, &item.FailureReason,
@ -545,8 +558,9 @@ func scanRoomTurnoverRewardSettlements(rows *sql.Rows) ([]domain.Settlement, err
return items, rows.Err() return items, rows.Err()
} }
func walletRoomTurnoverRewardCommandID(app string, periodStartMS int64, roomID string) string { func walletRoomTurnoverRewardCommandID(app string, regionID int64, periodStartMS int64, roomID string) string {
return "room_turnover_reward:" + appcode.Normalize(app) + ":" + int64String(periodStartMS) + ":" + strings.TrimSpace(roomID) // 区域属于结算业务键;同一房间在一周内迁区时,各区域贡献不能共享钱包幂等命令。
return "room_turnover_reward:" + appcode.Normalize(app) + ":" + int64String(regionID) + ":" + int64String(periodStartMS) + ":" + strings.TrimSpace(roomID)
} }
func int64String(value int64) string { func int64String(value int64) string {

View File

@ -26,6 +26,7 @@ func TestAgencyOpeningGRPCInterfaceFlow(t *testing.T) {
agencySvc := agencyservice.New(repository, nil, fakeAgencyOpeningFlowSource{ agencySvc := agencyservice.New(repository, nil, fakeAgencyOpeningFlowSource{
snapshot: agencydomain.AgencySnapshot{ snapshot: agencydomain.AgencySnapshot{
AgencyID: 8001, AgencyID: 8001,
RegionID: 12,
OwnerUserID: ownerUserID, OwnerUserID: ownerUserID,
Name: "Window Agency", Name: "Window Agency",
HostCount: 12, HostCount: 12,
@ -43,6 +44,7 @@ func TestAgencyOpeningGRPCInterfaceFlow(t *testing.T) {
createdCycle, err := adminClient.CreateAgencyOpeningCycle(ctx, &activityv1.UpsertAgencyOpeningCycleRequest{ createdCycle, err := adminClient.CreateAgencyOpeningCycle(ctx, &activityv1.UpsertAgencyOpeningCycleRequest{
Meta: agencyOpeningFlowMeta("agency-opening-cycle-create"), Meta: agencyOpeningFlowMeta("agency-opening-cycle-create"),
Cycle: &activityv1.AgencyOpeningCycle{ Cycle: &activityv1.AgencyOpeningCycle{
RegionId: 12,
Title: "Agency Opening Interface Flow", Title: "Agency Opening Interface Flow",
Status: "active", Status: "active",
StartMs: now.Add(-time.Minute).UnixMilli(), StartMs: now.Add(-time.Minute).UnixMilli(),
@ -167,10 +169,11 @@ func consumeAgencyOpeningFlowGift(t *testing.T, ctx context.Context, client acti
t.Helper() t.Helper()
body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{ body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{
SenderUserId: 10001, SenderUserId: 10001,
TargetUserId: ownerUserID, TargetUserId: ownerUserID,
GiftValue: giftValue, GiftValue: giftValue,
CoinSpent: giftValue, CoinSpent: giftValue,
VisibleRegionId: 12,
}) })
if err != nil { if err != nil {
t.Fatalf("marshal room gift event failed: %v", err) 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) { func (s *AdminAgencyOpeningServer) ListAgencyOpeningCycles(ctx context.Context, req *activityv1.ListAgencyOpeningCyclesRequest) (*activityv1.ListAgencyOpeningCyclesResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) 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 { if err != nil {
return nil, xerr.ToGRPCError(err) return nil, xerr.ToGRPCError(err)
} }
@ -140,6 +140,7 @@ func agencyOpeningCycleFromProto(item *activityv1.AgencyOpeningCycle) domain.Cyc
} }
cycle := domain.Cycle{ cycle := domain.Cycle{
CycleID: item.GetCycleId(), CycleID: item.GetCycleId(),
RegionID: item.GetRegionId(),
ActivityCode: item.GetActivityCode(), ActivityCode: item.GetActivityCode(),
Title: item.GetTitle(), Title: item.GetTitle(),
Status: item.GetStatus(), Status: item.GetStatus(),
@ -166,6 +167,7 @@ func agencyOpeningCycleToProto(item domain.Cycle) *activityv1.AgencyOpeningCycle
} }
resp := &activityv1.AgencyOpeningCycle{ resp := &activityv1.AgencyOpeningCycle{
AppCode: item.AppCode, AppCode: item.AppCode,
RegionId: item.RegionID,
CycleId: item.CycleID, CycleId: item.CycleID,
ActivityCode: item.ActivityCode, ActivityCode: item.ActivityCode,
Title: item.Title, Title: item.Title,
@ -197,6 +199,7 @@ func agencyOpeningApplicationToProto(item domain.Application) *activityv1.Agency
} }
return &activityv1.AgencyOpeningApplication{ return &activityv1.AgencyOpeningApplication{
ApplicationId: item.ApplicationID, ApplicationId: item.ApplicationID,
RegionId: item.RegionID,
CycleId: item.CycleID, CycleId: item.CycleID,
AgencyId: item.AgencyID, AgencyId: item.AgencyID,
AgencyOwnerUserId: item.AgencyOwnerUserID, AgencyOwnerUserId: item.AgencyOwnerUserID,
@ -227,6 +230,7 @@ func agencyOpeningAgencyToProto(item domain.AgencySnapshot) *activityv1.AgencyOp
} }
return &activityv1.AgencyOpeningAgencySnapshot{ return &activityv1.AgencyOpeningAgencySnapshot{
AgencyId: item.AgencyID, AgencyId: item.AgencyID,
RegionId: item.RegionID,
OwnerUserId: item.OwnerUserID, OwnerUserId: item.OwnerUserID,
Name: item.Name, Name: item.Name,
Status: item.Status, Status: item.Status,
@ -246,6 +250,7 @@ func agencyOpeningApplicationsResponse(items []domain.Application, total int64)
func agencyOpeningApplicationQueryFromProto(req *activityv1.ListAgencyOpeningApplicationsRequest) domain.ApplicationQuery { func agencyOpeningApplicationQueryFromProto(req *activityv1.ListAgencyOpeningApplicationsRequest) domain.ApplicationQuery {
return domain.ApplicationQuery{ return domain.ApplicationQuery{
CycleID: req.GetCycleId(), CycleID: req.GetCycleId(),
RegionID: req.GetRegionId(),
Status: req.GetStatus(), Status: req.GetStatus(),
AgencyID: req.GetAgencyId(), AgencyID: req.GetAgencyId(),
AgencyOwnerUserID: req.GetAgencyOwnerUserId(), 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) { func (s *RoomTurnoverRewardServer) GetRoomTurnoverRewardStatus(ctx context.Context, req *activityv1.GetRoomTurnoverRewardStatusRequest) (*activityv1.GetRoomTurnoverRewardStatusResponse, error) {
// AppCode 从 protobuf meta 写入 context下面 service 和 repository 都按这个上下文隔离配置、流水和结算记录。 // AppCode 从 protobuf meta 写入 context下面 service 和 repository 都按这个上下文隔离配置、流水和结算记录。
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) 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 { if err != nil {
return nil, xerr.ToGRPCError(err) 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) { func (s *AdminRoomTurnoverRewardServer) GetRoomTurnoverRewardConfig(ctx context.Context, req *activityv1.GetRoomTurnoverRewardConfigRequest) (*activityv1.GetRoomTurnoverRewardConfigResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
config, err := s.svc.GetConfig(ctx) config, err := s.svc.GetConfig(ctx, req.GetRegionId())
if err != nil { if err != nil {
return nil, xerr.ToGRPCError(err) return nil, xerr.ToGRPCError(err)
} }
@ -67,6 +67,7 @@ func (s *AdminRoomTurnoverRewardServer) UpdateRoomTurnoverRewardConfig(ctx conte
}) })
} }
config, err := s.svc.UpdateConfig(ctx, domain.Config{ config, err := s.svc.UpdateConfig(ctx, domain.Config{
RegionID: req.GetRegionId(),
Enabled: req.GetEnabled(), Enabled: req.GetEnabled(),
Tiers: tiers, Tiers: tiers,
UpdatedByAdminID: req.GetOperatorAdminId(), UpdatedByAdminID: req.GetOperatorAdminId(),
@ -81,6 +82,7 @@ func (s *AdminRoomTurnoverRewardServer) ListRoomTurnoverRewardSettlements(ctx co
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
// 查询条件完整透传到 domain query分页上限由 service 统一收敛,后台和潜在内部工具不会各自实现分页规则。 // 查询条件完整透传到 domain query分页上限由 service 统一收敛,后台和潜在内部工具不会各自实现分页规则。
items, total, err := s.svc.ListSettlements(ctx, domain.SettlementQuery{ items, total, err := s.svc.ListSettlements(ctx, domain.SettlementQuery{
RegionID: req.GetRegionId(),
Status: req.GetStatus(), Status: req.GetStatus(),
RoomID: req.GetRoomId(), RoomID: req.GetRoomId(),
OwnerUserID: req.GetOwnerUserId(), OwnerUserID: req.GetOwnerUserId(),
@ -129,6 +131,7 @@ func roomTurnoverRewardStatusToProto(status domain.StatusResult) *activityv1.Roo
func roomTurnoverRewardConfigToProto(config domain.Config) *activityv1.RoomTurnoverRewardConfig { func roomTurnoverRewardConfigToProto(config domain.Config) *activityv1.RoomTurnoverRewardConfig {
resp := &activityv1.RoomTurnoverRewardConfig{ resp := &activityv1.RoomTurnoverRewardConfig{
AppCode: config.AppCode, AppCode: config.AppCode,
RegionId: config.RegionID,
Enabled: config.Enabled, Enabled: config.Enabled,
Tiers: make([]*activityv1.RoomTurnoverRewardTier, 0, len(config.Tiers)), Tiers: make([]*activityv1.RoomTurnoverRewardTier, 0, len(config.Tiers)),
UpdatedByAdminId: config.UpdatedByAdminID, UpdatedByAdminId: config.UpdatedByAdminID,
@ -165,6 +168,7 @@ func roomTurnoverRewardSettlementToProto(item domain.Settlement) *activityv1.Roo
return &activityv1.RoomTurnoverRewardSettlement{ return &activityv1.RoomTurnoverRewardSettlement{
SettlementId: item.SettlementID, SettlementId: item.SettlementID,
AppCode: item.AppCode, AppCode: item.AppCode,
RegionId: item.RegionID,
RoomId: item.RoomID, RoomId: item.RoomID,
OwnerUserId: item.OwnerUserID, OwnerUserId: item.OwnerUserID,
PeriodStartMs: item.PeriodStartMS, PeriodStartMs: item.PeriodStartMS,

View File

@ -35,16 +35,18 @@ type agencyOpeningStatusData struct {
} }
type agencyOpeningPeriodData struct { type agencyOpeningPeriodData struct {
CycleID string `json:"cycle_id,omitempty"` CycleID string `json:"cycle_id,omitempty"`
Title string `json:"title,omitempty"` RegionID int64 `json:"region_id,omitempty"`
Status string `json:"status,omitempty"` Title string `json:"title,omitempty"`
StartMS int64 `json:"start_ms,omitempty"` Status string `json:"status,omitempty"`
EndMS int64 `json:"end_ms,omitempty"` StartMS int64 `json:"start_ms,omitempty"`
EndMS int64 `json:"end_ms,omitempty"`
} }
type agencyOpeningQualificationData struct { type agencyOpeningQualificationData struct {
Identity string `json:"agency_identity"` Identity string `json:"agency_identity"`
AgencyID int64 `json:"agency_id,omitempty"` AgencyID int64 `json:"agency_id,omitempty"`
RegionID int64 `json:"region_id,omitempty"`
AgencyName string `json:"agency_name,omitempty"` AgencyName string `json:"agency_name,omitempty"`
HostCount int32 `json:"host_count"` HostCount int32 `json:"host_count"`
HostRequirement int32 `json:"host_requirement"` HostRequirement int32 `json:"host_requirement"`
@ -71,6 +73,7 @@ type agencyOpeningRecordData struct {
type agencyOpeningApplicationData struct { type agencyOpeningApplicationData struct {
ApplicationID string `json:"application_id"` ApplicationID string `json:"application_id"`
CycleID string `json:"cycle_id"` CycleID string `json:"cycle_id"`
RegionID int64 `json:"region_id"`
AgencyID int64 `json:"agency_id"` AgencyID int64 `json:"agency_id"`
AgencyOwnerUserID int64 `json:"agency_owner_user_id"` AgencyOwnerUserID int64 `json:"agency_owner_user_id"`
AgencyName string `json:"agency_name"` 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") httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return 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{ resp, err := h.agencyOpening.ListAgencyOpeningLeaderboard(request.Context(), &activityv1.ListAgencyOpeningApplicationsRequest{
Meta: httpkit.ActivityMeta(request), 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")), Status: strings.TrimSpace(request.URL.Query().Get("status")),
Page: int32(queryInt(request, "page", 1)), Page: int32(queryInt(request, "page", 1)),
PageSize: int32(queryInt(request, "page_size", 50)), PageSize: int32(queryInt(request, "page_size", 50)),
@ -195,11 +218,12 @@ func agencyOpeningStatusFromProto(item *activityv1.GetAgencyOpeningStatusRespons
Joined: item.GetJoined(), Joined: item.GetJoined(),
IneligibleReason: item.GetIneligibleReason(), IneligibleReason: item.GetIneligibleReason(),
Period: agencyOpeningPeriodData{ Period: agencyOpeningPeriodData{
CycleID: item.GetCycle().GetCycleId(), CycleID: item.GetCycle().GetCycleId(),
Title: item.GetCycle().GetTitle(), RegionID: item.GetCycle().GetRegionId(),
Status: item.GetCycle().GetStatus(), Title: item.GetCycle().GetTitle(),
StartMS: item.GetCycle().GetStartMs(), Status: item.GetCycle().GetStatus(),
EndMS: item.GetCycle().GetEndMs(), StartMS: item.GetCycle().GetStartMs(),
EndMS: item.GetCycle().GetEndMs(),
}, },
Qualification: agencyOpeningQualificationFromProto(item, application), Qualification: agencyOpeningQualificationFromProto(item, application),
Rewards: rewards, Rewards: rewards,
@ -215,6 +239,7 @@ func agencyOpeningQualificationFromProto(status *activityv1.GetAgencyOpeningStat
hostCount := agency.GetHostCount() hostCount := agency.GetHostCount()
agencyID := agency.GetAgencyId() agencyID := agency.GetAgencyId()
agencyName := agency.GetName() agencyName := agency.GetName()
regionID := agency.GetRegionId()
if application != nil { if application != nil {
if hostCount == 0 { if hostCount == 0 {
hostCount = application.GetHostCount() hostCount = application.GetHostCount()
@ -225,6 +250,9 @@ func agencyOpeningQualificationFromProto(status *activityv1.GetAgencyOpeningStat
if agencyName == "" { if agencyName == "" {
agencyName = application.GetAgencyName() agencyName = application.GetAgencyName()
} }
if regionID == 0 {
regionID = application.GetRegionId()
}
} }
openingStatus := agencyOpeningStatusLabel(application) openingStatus := agencyOpeningStatusLabel(application)
if !status.GetEnabled() && !status.GetJoined() { if !status.GetEnabled() && !status.GetJoined() {
@ -233,6 +261,7 @@ func agencyOpeningQualificationFromProto(status *activityv1.GetAgencyOpeningStat
return agencyOpeningQualificationData{ return agencyOpeningQualificationData{
Identity: "Agency", Identity: "Agency",
AgencyID: agencyID, AgencyID: agencyID,
RegionID: regionID,
AgencyName: agencyName, AgencyName: agencyName,
HostCount: hostCount, HostCount: hostCount,
HostRequirement: status.GetCycle().GetMinHostCount(), HostRequirement: status.GetCycle().GetMinHostCount(),
@ -287,6 +316,7 @@ func agencyOpeningApplicationFromProto(item *activityv1.AgencyOpeningApplication
return agencyOpeningApplicationData{ return agencyOpeningApplicationData{
ApplicationID: item.GetApplicationId(), ApplicationID: item.GetApplicationId(),
CycleID: item.GetCycleId(), CycleID: item.GetCycleId(),
RegionID: item.GetRegionId(),
AgencyID: item.GetAgencyId(), AgencyID: item.GetAgencyId(),
AgencyOwnerUserID: item.GetAgencyOwnerUserId(), AgencyOwnerUserID: item.GetAgencyOwnerUserId(),
AgencyName: item.GetAgencyName(), AgencyName: item.GetAgencyName(),

View File

@ -31,6 +31,7 @@ type roomTurnoverRewardRoomData struct {
HasRoom bool `json:"has_room"` HasRoom bool `json:"has_room"`
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
OwnerUserID int64 `json:"owner_user_id"` OwnerUserID int64 `json:"owner_user_id"`
RegionID int64 `json:"region_id"`
Title string `json:"title"` Title string `json:"title"`
CoverURL string `json:"cover_url"` CoverURL string `json:"cover_url"`
} }
@ -42,6 +43,7 @@ type roomTurnoverRewardPeriodData struct {
type roomTurnoverRewardSettlementData struct { type roomTurnoverRewardSettlementData struct {
SettlementID string `json:"settlement_id"` SettlementID string `json:"settlement_id"`
RegionID int64 `json:"region_id"`
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
OwnerUserID int64 `json:"owner_user_id"` OwnerUserID int64 `json:"owner_user_id"`
PeriodStartMS int64 `json:"period_start_ms"` PeriodStartMS int64 `json:"period_start_ms"`
@ -94,10 +96,11 @@ func (h *Handler) getRoomTurnoverRewardStatus(writer http.ResponseWriter, reques
roomData = roomTurnoverRewardRoomFromProto(roomResp.GetRoom(), userID) roomData = roomTurnoverRewardRoomFromProto(roomResp.GetRoom(), userID)
} }
// activity-service 只需要房间 ID 和 owner_user_id 计算当前周期流水与预计奖励;用户头像、昵称等展示字段由 gateway 统一补齐 // 区域必须使用 room-service 返回的可见区域快照,不能用请求 IP 或当前用户资料推断,否则房间迁区后会把进度查询到错误分桶
resp, err := h.roomTurnoverReward.GetRoomTurnoverRewardStatus(request.Context(), &activityv1.GetRoomTurnoverRewardStatusRequest{ resp, err := h.roomTurnoverReward.GetRoomTurnoverRewardStatus(request.Context(), &activityv1.GetRoomTurnoverRewardStatusRequest{
Meta: httpkit.ActivityMeta(request), Meta: httpkit.ActivityMeta(request),
UserId: userID, UserId: userID,
RegionId: roomData.RegionID,
RoomId: roomData.RoomID, RoomId: roomData.RoomID,
OwnerUserId: roomData.OwnerUserID, OwnerUserId: roomData.OwnerUserID,
}) })
@ -179,6 +182,7 @@ func roomTurnoverRewardRoomFromProto(item *roomv1.RoomListItem, fallbackOwnerUse
HasRoom: true, HasRoom: true,
RoomID: item.GetRoomId(), RoomID: item.GetRoomId(),
OwnerUserID: ownerID, OwnerUserID: ownerID,
RegionID: item.GetVisibleRegionId(),
Title: item.GetTitle(), Title: item.GetTitle(),
CoverURL: item.GetCoverUrl(), CoverURL: item.GetCoverUrl(),
} }
@ -217,6 +221,7 @@ func roomTurnoverRewardSettlementFromProto(item *activityv1.RoomTurnoverRewardSe
} }
return roomTurnoverRewardSettlementData{ return roomTurnoverRewardSettlementData{
SettlementID: item.GetSettlementId(), SettlementID: item.GetSettlementId(),
RegionID: item.GetRegionId(),
RoomID: item.GetRoomId(), RoomID: item.GetRoomId(),
OwnerUserID: item.GetOwnerUserId(), OwnerUserID: item.GetOwnerUserId(),
PeriodStartMS: item.GetPeriodStartMs(), PeriodStartMS: item.GetPeriodStartMs(),

View File

@ -3449,6 +3449,11 @@ func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) {
roomClient := &fakeRoomClient{} roomClient := &fakeRoomClient{}
walletClient := &fakeWalletClient{myVipResp: &walletv1.GetMyVipResponse{State: &walletv1.VipState{ walletClient := &fakeWalletClient{myVipResp: &walletv1.GetMyVipResponse{State: &walletv1.VipState{
EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 7, Active: true}, 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{ profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
42: { 42: {
@ -3484,7 +3489,7 @@ func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
} }
sender := roomClient.lastGift.GetSenderDisplayProfile() 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) t.Fatalf("gateway must inject sender display profile snapshot: %+v", sender)
} }
targets := roomClient.lastGift.GetTargetDisplayProfiles() targets := roomClient.lastGift.GetTargetDisplayProfiles()

View File

@ -2175,6 +2175,11 @@ type giftUserSnapshots struct {
targetDisplayProfiles []*roomv1.SendGiftDisplayProfile targetDisplayProfiles []*roomv1.SendGiftDisplayProfile
} }
type giftSenderVIPPresentation struct {
effectiveLevel int32
giftBroadcastAnimationURL string
}
func (h *Handler) resolveGiftPreflight(request *http.Request, targetUserIDs []int64) (giftUserSnapshots, []*roomv1.SendGiftTargetHostScope, error) { func (h *Handler) resolveGiftPreflight(request *http.Request, targetUserIDs []int64) (giftUserSnapshots, []*roomv1.SendGiftTargetHostScope, error) {
// 用户资料、主播工资归属和发送者 VIP 互不依赖。并发取得三份入站快照,让新增 VIP 不成为 // 用户资料、主播工资归属和发送者 VIP 互不依赖。并发取得三份入站快照,让新增 VIP 不成为
// 原有 profile -> host 串行链路之后的额外等待;任一结算必需查询失败时取消同批请求。 // 原有 profile -> host 串行链路之后的额外等待;任一结算必需查询失败时取消同批请求。
@ -2187,7 +2192,7 @@ func (h *Handler) resolveGiftPreflight(request *http.Request, targetUserIDs []in
var userSnapshotsErr error var userSnapshotsErr error
var targetHostScopes []*roomv1.SendGiftTargetHostScope var targetHostScopes []*roomv1.SendGiftTargetHostScope
var targetHostScopesErr error var targetHostScopesErr error
var senderVIPLevel int32 var senderVIP giftSenderVIPPresentation
waitGroup.Add(3) waitGroup.Add(3)
go func() { go func() {
defer waitGroup.Done() defer waitGroup.Done()
@ -2205,7 +2210,7 @@ func (h *Handler) resolveGiftPreflight(request *http.Request, targetUserIDs []in
}() }()
go func() { go func() {
defer waitGroup.Done() defer waitGroup.Done()
senderVIPLevel = h.resolveGiftSenderVIPLevelBestEffort(preflightRequest) senderVIP = h.resolveGiftSenderVIPPresentationBestEffort(preflightRequest)
}() }()
waitGroup.Wait() waitGroup.Wait()
@ -2216,19 +2221,21 @@ func (h *Handler) resolveGiftPreflight(request *http.Request, targetUserIDs []in
return giftUserSnapshots{}, nil, targetHostScopesErr return giftUserSnapshots{}, nil, targetHostScopesErr
} }
if userSnapshots.senderDisplayProfile != nil { if userSnapshots.senderDisplayProfile != nil {
// VIP 属于发送者展示快照room-service 会把整份快照持久化,重试和 Saga 恢复不再回查当前等级。 // VIP 等级和托盘资源属于同一份发送者展示快照room-service 会把它随首次命令持久化,
userSnapshots.senderDisplayProfile.EffectiveVipLevel = senderVIPLevel // 普通、幸运、超级幸运礼物的重试和 Saga 恢复都不能回查当前配置改写历史展示。
userSnapshots.senderDisplayProfile.EffectiveVipLevel = senderVIP.effectiveLevel
userSnapshots.senderDisplayProfile.GiftBroadcastAnimationUrl = senderVIP.giftBroadcastAnimationURL
} }
return userSnapshots, targetHostScopes, nil return userSnapshots, targetHostScopes, nil
} }
func (h *Handler) resolveGiftSenderVIPLevelBestEffort(request *http.Request) int32 { func (h *Handler) resolveGiftSenderVIPPresentationBestEffort(request *http.Request) giftSenderVIPPresentation {
if h.walletClient == nil { if h.walletClient == nil {
return 0 return giftSenderVIPPresentation{}
} }
senderUserID := auth.UserIDFromContext(request.Context()) senderUserID := auth.UserIDFromContext(request.Context())
if senderUserID <= 0 { if senderUserID <= 0 {
return 0 return giftSenderVIPPresentation{}
} }
vipCtx, cancelVIP := context.WithTimeout(request.Context(), giftSenderVIPLookupTimeout) vipCtx, cancelVIP := context.WithTimeout(request.Context(), giftSenderVIPLookupTimeout)
defer cancelVIP() defer cancelVIP()
@ -2238,17 +2245,44 @@ func (h *Handler) resolveGiftSenderVIPLevelBestEffort(request *http.Request) int
UserId: senderUserID, UserId: senderUserID,
}) })
if err != nil || resp == nil { if err != nil || resp == nil {
return 0 return giftSenderVIPPresentation{}
} }
effectiveVIP := resp.GetState().GetEffectiveVip() state := resp.GetState()
effectiveVIP := state.GetEffectiveVip()
if effectiveVIP == nil { if effectiveVIP == nil {
// GetMyVip.vip 历史上就是 effective VIP保留滚动发布期间旧 wallet-service 的响应兼容。 // GetMyVip.vip 历史上就是 effective VIP保留滚动发布期间旧 wallet-service 的响应兼容。
effectiveVIP = resp.GetVip() effectiveVIP = resp.GetVip()
} }
if !effectiveVIP.GetActive() || effectiveVIP.GetLevel() <= 0 || (effectiveVIP.GetUserId() > 0 && effectiveVIP.GetUserId() != senderUserID) { 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) { func (h *Handler) resolveGiftUserSnapshots(request *http.Request, targetUserIDs []int64) (giftUserSnapshots, error) {

View File

@ -35,27 +35,30 @@ type myRoomData struct {
} }
type roomListItemData struct { type roomListItemData struct {
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
IMGroupID string `json:"im_group_id"` IMGroupID string `json:"im_group_id"`
OwnerUserID string `json:"owner_user_id,omitempty"` OwnerUserID string `json:"owner_user_id,omitempty"`
OwnerVIPLevel int32 `json:"owner_vip_level"` OwnerVIPLevel int32 `json:"owner_vip_level"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
CoverURL string `json:"cover_url,omitempty"` CoverURL string `json:"cover_url,omitempty"`
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Locked bool `json:"locked"` Locked bool `json:"locked"`
Heat int64 `json:"heat"` Heat int64 `json:"heat"`
OnlineCount int32 `json:"online_count"` OnlineCount int32 `json:"online_count"`
SeatCount int32 `json:"seat_count"` SeatCount int32 `json:"seat_count"`
OccupiedSeatCount int32 `json:"occupied_seat_count"` OccupiedSeatCount int32 `json:"occupied_seat_count"`
VisibleRegionID int64 `json:"visible_region_id,omitempty"` VisibleRegionID int64 `json:"visible_region_id,omitempty"`
AppCode string `json:"app_code,omitempty"` AppCode string `json:"app_code,omitempty"`
RoomShortID string `json:"room_short_id,omitempty"` RoomShortID string `json:"room_short_id,omitempty"`
CountryCode string `json:"country_code,omitempty"` CountryCode string `json:"country_code,omitempty"`
CountryFlag string `json:"country_flag,omitempty"` CountryFlag string `json:"country_flag,omitempty"`
RoomBorderURL string `json:"room_border_url"` RoomBorderURL string `json:"room_border_url"`
RoomBorder *roomDecorationData `json:"room_border,omitempty"` RoomBorder *roomDecorationData `json:"room_border,omitempty"`
RoomNameStyle *roomDecorationData `json:"room_name_style,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 { type createRoomData struct {
@ -740,27 +743,30 @@ func roomListItemDataFromProto(room *roomv1.RoomListItem) roomListItemData {
} }
roomID := room.GetRoomId() roomID := room.GetRoomId()
return roomListItemData{ return roomListItemData{
RoomID: roomID, RoomID: roomID,
IMGroupID: roomIMGroupID(roomID), IMGroupID: roomIMGroupID(roomID),
OwnerUserID: formatOptionalUserID(room.GetOwnerUserId()), OwnerUserID: formatOptionalUserID(room.GetOwnerUserId()),
OwnerVIPLevel: room.GetOwnerVipLevel(), OwnerVIPLevel: room.GetOwnerVipLevel(),
Title: room.GetTitle(), Title: room.GetTitle(),
CoverURL: room.GetCoverUrl(), CoverURL: room.GetCoverUrl(),
Mode: room.GetMode(), Mode: room.GetMode(),
Status: room.GetStatus(), Status: room.GetStatus(),
Locked: room.GetLocked(), Locked: room.GetLocked(),
Heat: room.GetHeat(), Heat: room.GetHeat(),
OnlineCount: room.GetOnlineCount(), OnlineCount: room.GetOnlineCount(),
SeatCount: room.GetSeatCount(), SeatCount: room.GetSeatCount(),
OccupiedSeatCount: room.GetOccupiedSeatCount(), OccupiedSeatCount: room.GetOccupiedSeatCount(),
VisibleRegionID: room.GetVisibleRegionId(), VisibleRegionID: room.GetVisibleRegionId(),
AppCode: room.GetAppCode(), AppCode: room.GetAppCode(),
RoomShortID: room.GetRoomShortId(), RoomShortID: room.GetRoomShortId(),
CountryCode: room.GetCountryCode(), CountryCode: room.GetCountryCode(),
CountryFlag: roomProfileCountryFlag(room.GetCountryCode()), CountryFlag: roomProfileCountryFlag(room.GetCountryCode()),
RoomBorderURL: roomBorderURL(room.GetRoomBorderUrl(), room.GetRoomBorder()), RoomBorderURL: roomBorderURL(room.GetRoomBorderUrl(), room.GetRoomBorder()),
RoomBorder: roomDecorationDataFromProto(room.GetRoomBorder()), RoomBorder: roomDecorationDataFromProto(room.GetRoomBorder()),
RoomNameStyle: roomDecorationDataFromProto(room.GetRoomNameStyle()), RoomNameStyle: roomDecorationDataFromProto(room.GetRoomNameStyle()),
RocketNearCompletion: room.GetRocketNearCompletion(),
HasActiveRedPacket: room.GetHasActiveRedPacket(),
ActiveRedPacketExpiresAtMS: room.GetActiveRedPacketExpiresAtMs(),
} }
} }

View File

@ -87,6 +87,12 @@ rocketmq:
topic: "hyapp_wallet_outbox" topic: "hyapp_wallet_outbox"
consumer_group: "hyapp-room-wallet-decoration-sync" consumer_group: "hyapp-room-wallet-decoration-sync"
consumer_max_reconsume_times: 16 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 保证不和其他下游共享消费位点。 # 房间中奖 IM 由 lucky-gift owner 事实可靠派生;独立 group 保证不和其他下游共享消费位点。
lucky_gift_outbox: lucky_gift_outbox:
enabled: true enabled: true

View File

@ -97,6 +97,12 @@ rocketmq:
topic: "hyapp_wallet_outbox" topic: "hyapp_wallet_outbox"
consumer_group: "hyapp-room-wallet-decoration-sync" consumer_group: "hyapp-room-wallet-decoration-sync"
consumer_max_reconsume_times: 16 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 进入房间展示链路。 # 线上必须使用 room-service 自己的消费组owner outbox 只通过 MQ 进入房间展示链路。
lucky_gift_outbox: lucky_gift_outbox:
enabled: true enabled: true

View File

@ -90,6 +90,12 @@ rocketmq:
topic: "hyapp_wallet_outbox" topic: "hyapp_wallet_outbox"
consumer_group: "hyapp-room-wallet-decoration-sync" consumer_group: "hyapp-room-wallet-decoration-sync"
consumer_max_reconsume_times: 16 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 消费位点或直连幸运礼物库。 # 幸运礼物 owner 事实由独立消费组派生为房间展示 outbox不能复用 activity 消费位点或直连幸运礼物库。
lucky_gift_outbox: lucky_gift_outbox:
enabled: true enabled: true

View File

@ -56,6 +56,9 @@ CREATE TABLE IF NOT EXISTS room_list_entries (
seat_count INT NOT NULL DEFAULT 0 COMMENT '麦位数量', seat_count INT NOT NULL DEFAULT 0 COMMENT '麦位数量',
occupied_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 '排序分值', 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_border_json JSON NULL COMMENT '当前未过期房间边框资源快照',
room_name_style_json JSON NULL COMMENT '当前未过期彩色房名资源快照', room_name_style_json JSON NULL COMMENT '当前未过期彩色房名资源快照',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms', 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, consumed_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, event_id), PRIMARY KEY (app_code, event_id),
KEY idx_room_wallet_event_consumed (app_code, consumed_at_ms) 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 ( CREATE TABLE IF NOT EXISTS room_snapshots (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离', 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) 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 { if cfg.RocketMQ.LuckyGiftOutbox.Enabled {
luckyGiftConsumer, err := rocketmqx.NewConsumer(luckyGiftRocketMQConsumerConfig(cfg.RocketMQ)) luckyGiftConsumer, err := rocketmqx.NewConsumer(luckyGiftRocketMQConsumerConfig(cfg.RocketMQ))
if err != nil { if err != nil {
@ -682,6 +720,18 @@ func luckyGiftRocketMQConsumerConfig(cfg config.RocketMQConfig) rocketmqx.Consum
return rocketMQConsumerConfig(cfg, cfg.LuckyGiftOutbox.ConsumerGroup, cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes, 0) 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 { func rocketMQEndpointConfig(cfg config.RocketMQConfig) rocketmqx.EndpointConfig {
return rocketmqx.EndpointConfig{ return rocketmqx.EndpointConfig{
NameServers: cfg.NameServers, NameServers: cfg.NameServers,

View File

@ -152,12 +152,13 @@ type RocketMQConfig struct {
Retry int `yaml:"retry"` Retry int `yaml:"retry"`
// RoomEventTagMode 是 room topic producer 的两阶段发布开关。legacy 保持旧 // RoomEventTagMode 是 room topic producer 的两阶段发布开关。legacy 保持旧
// consumer 可见event_type 只能在全部 consumer 完成兼容订阅滚动后启用。 // consumer 可见event_type 只能在全部 consumer 完成兼容订阅滚动后启用。
RoomEventTagMode roommq.EventTagMode `yaml:"room_event_tag_mode"` RoomEventTagMode roommq.EventTagMode `yaml:"room_event_tag_mode"`
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"` RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
RocketLaunch RocketLaunchMQConfig `yaml:"rocket_launch"` RocketLaunch RocketLaunchMQConfig `yaml:"rocket_launch"`
UserOutbox UserOutboxMQConfig `yaml:"user_outbox"` UserOutbox UserOutboxMQConfig `yaml:"user_outbox"`
WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"` WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"`
LuckyGiftOutbox LuckyGiftOutboxMQConfig `yaml:"lucky_gift_outbox"` WalletRealtimeOutbox WalletOutboxMQConfig `yaml:"wallet_realtime_outbox"`
LuckyGiftOutbox LuckyGiftOutboxMQConfig `yaml:"lucky_gift_outbox"`
} }
// RoomOutboxMQConfig 控制 room_outbox fanout topic。 // RoomOutboxMQConfig 控制 room_outbox fanout topic。
@ -397,6 +398,12 @@ func defaultRocketMQConfig() RocketMQConfig {
ConsumerGroup: "hyapp-room-wallet-decoration-sync", ConsumerGroup: "hyapp-room-wallet-decoration-sync",
ConsumerMaxReconsumeTimes: 16, ConsumerMaxReconsumeTimes: 16,
}, },
WalletRealtimeOutbox: WalletOutboxMQConfig{
Enabled: false,
Topic: "hyapp_wallet_realtime_outbox",
ConsumerGroup: "hyapp-room-wallet-red-packet-projection",
ConsumerMaxReconsumeTimes: 16,
},
LuckyGiftOutbox: LuckyGiftOutboxMQConfig{ LuckyGiftOutbox: LuckyGiftOutboxMQConfig{
Enabled: false, Enabled: false,
Topic: "hyapp_lucky_gift_outbox", Topic: "hyapp_lucky_gift_outbox",
@ -654,6 +661,15 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo
if cfg.WalletOutbox.ConsumerMaxReconsumeTimes <= 0 { if cfg.WalletOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.WalletOutbox.ConsumerMaxReconsumeTimes = defaults.WalletOutbox.ConsumerMaxReconsumeTimes 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 == "" { if cfg.LuckyGiftOutbox.Topic = strings.TrimSpace(cfg.LuckyGiftOutbox.Topic); cfg.LuckyGiftOutbox.Topic == "" {
cfg.LuckyGiftOutbox.Topic = defaults.LuckyGiftOutbox.Topic cfg.LuckyGiftOutbox.Topic = defaults.LuckyGiftOutbox.Topic
} }
@ -663,7 +679,7 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo
if cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes <= 0 { if cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes = defaults.LuckyGiftOutbox.ConsumerMaxReconsumeTimes 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 cfg.Enabled = true
} }
if publishMode == OutboxPublishModeMQ || publishMode == OutboxPublishModeDual { 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) t.Fatalf("unexpected outbox worker config: %+v", cfg.OutboxWorker)
} }
// 本地默认仍走 room_outbox MQ 和火箭延迟唤醒;房间 UI IM 已改为 Room Cell 提交后直发,不再启 MQ IM bridge。 // 本地默认仍走 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) 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" { 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) 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 { if cfg.RocketMQ.RoomEventTagMode != roommq.EventTagModeLegacy {
t.Fatalf("Phase A config must publish legacy room tags: %q", cfg.RocketMQ.RoomEventTagMode) 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 { 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) 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) 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 { 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" { 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) 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 { if cfg.RocketMQ.RoomEventTagMode != roommq.EventTagModeLegacy {
t.Fatalf("docker config must remain in Phase A: %q", cfg.RocketMQ.RoomEventTagMode) 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 return tencentim.RoomEvent{}, false, err
} }
base.Attributes = map[string]string{ base.Attributes = map[string]string{
"gift_id": body.GetGiftId(), "gift_id": body.GetGiftId(),
"room_locked": fmt.Sprintf("%t", body.GetRoomLocked()), "room_locked": fmt.Sprintf("%t", body.GetRoomLocked()),
"gift_count": fmt.Sprintf("%d", body.GetGiftCount()), "gift_count": fmt.Sprintf("%d", body.GetGiftCount()),
"coin_spent": fmt.Sprintf("%d", body.GetCoinSpent()), "coin_spent": fmt.Sprintf("%d", body.GetCoinSpent()),
"billing_receipt_id": body.GetBillingReceiptId(), "billing_receipt_id": body.GetBillingReceiptId(),
"gift_type_code": body.GetGiftTypeCode(), "gift_type_code": body.GetGiftTypeCode(),
"cp_relation_type": body.GetCpRelationType(), "cp_relation_type": body.GetCpRelationType(),
"gift_name": body.GetGiftName(), "gift_name": body.GetGiftName(),
"gift_icon_url": body.GetGiftIconUrl(), "gift_icon_url": body.GetGiftIconUrl(),
"gift_animation_url": body.GetGiftAnimationUrl(), "gift_animation_url": body.GetGiftAnimationUrl(),
"gift_effect_types": string(effectTypesJSON), "gift_effect_types": string(effectTypesJSON),
"sender_name": body.GetSenderName(), "sender_name": body.GetSenderName(),
"sender_avatar": body.GetSenderAvatar(), "sender_avatar": body.GetSenderAvatar(),
"sender_display_user_id": body.GetSenderDisplayUserId(), "sender_display_user_id": body.GetSenderDisplayUserId(),
"sender_pretty_display_user_id": body.GetSenderPrettyDisplayUserId(), "sender_pretty_display_user_id": body.GetSenderPrettyDisplayUserId(),
"sender_vip_level": fmt.Sprintf("%d", body.GetSenderVipLevel()), "sender_vip_level": fmt.Sprintf("%d", body.GetSenderVipLevel()),
"receiver_nickname": body.GetReceiverNickname(), "sender_gift_broadcast_animation_url": body.GetSenderGiftBroadcastAnimationUrl(),
"receiver_avatar": body.GetReceiverAvatar(), "receiver_nickname": body.GetReceiverNickname(),
"receiver_display_user_id": body.GetReceiverDisplayUserId(), "receiver_avatar": body.GetReceiverAvatar(),
"receiver_pretty_display_user_id": body.GetReceiverPrettyDisplayUserId(), "receiver_display_user_id": body.GetReceiverDisplayUserId(),
"receiver_pretty_display_user_id": body.GetReceiverPrettyDisplayUserId(),
} }
if body.GetIsRobotGift() { if body.GetIsRobotGift() {
// 机器人礼物只用于动画和公屏展示。Flutter 会把顶层 gift_value 累加到成员贡献, // 机器人礼物只用于动画和公屏展示。Flutter 会把顶层 gift_value 累加到成员贡献,
@ -438,25 +439,26 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
// batch 必须携带逐目标 wallet 收益快照,客户端不能把根节点 total/aggregate 平均分到不同 Host。 // batch 必须携带逐目标 wallet 收益快照,客户端不能把根节点 total/aggregate 平均分到不同 Host。
// lucky 明细仍由独立协议承载targets 只包含展示和账务快照,避免重复嵌套中奖对象。 // lucky 明细仍由独立协议承载targets 只包含展示和账务快照,避免重复嵌套中奖对象。
base.Attributes = map[string]string{ base.Attributes = map[string]string{
"gift_id": body.GetGiftId(), "gift_id": body.GetGiftId(),
"gift_count": fmt.Sprintf("%d", body.GetGiftCount()), "gift_count": fmt.Sprintf("%d", body.GetGiftCount()),
"total_gift_value": fmt.Sprintf("%d", body.GetTotalGiftValue()), "total_gift_value": fmt.Sprintf("%d", body.GetTotalGiftValue()),
"billing_receipt_id": body.GetBillingReceiptId(), "billing_receipt_id": body.GetBillingReceiptId(),
"pool_id": body.GetPoolId(), "pool_id": body.GetPoolId(),
"gift_type_code": body.GetGiftTypeCode(), "gift_type_code": body.GetGiftTypeCode(),
"cp_relation_type": body.GetCpRelationType(), "cp_relation_type": body.GetCpRelationType(),
"gift_name": body.GetGiftName(), "gift_name": body.GetGiftName(),
"gift_icon_url": body.GetGiftIconUrl(), "gift_icon_url": body.GetGiftIconUrl(),
"target_count": fmt.Sprintf("%d", len(body.GetTargetUserIds())), "target_count": fmt.Sprintf("%d", len(body.GetTargetUserIds())),
"target_user_ids": targetUserIDsJSON, "target_user_ids": targetUserIDsJSON,
"targets": targetsJSON, "targets": targetsJSON,
"command_id": body.GetCommandId(), "command_id": body.GetCommandId(),
"visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()), "visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()),
"sender_name": body.GetSenderName(), "sender_name": body.GetSenderName(),
"sender_avatar": body.GetSenderAvatar(), "sender_avatar": body.GetSenderAvatar(),
"sender_display_user_id": body.GetSenderDisplayUserId(), "sender_display_user_id": body.GetSenderDisplayUserId(),
"sender_pretty_display_user_id": body.GetSenderPrettyDisplayUserId(), "sender_pretty_display_user_id": body.GetSenderPrettyDisplayUserId(),
"sender_vip_level": fmt.Sprintf("%d", body.GetSenderVipLevel()), "sender_vip_level": fmt.Sprintf("%d", body.GetSenderVipLevel()),
"sender_gift_broadcast_animation_url": body.GetSenderGiftBroadcastAnimationUrl(),
} }
return base, true, nil return base, true, nil
case "RoomHeatChanged": case "RoomHeatChanged":

View File

@ -121,19 +121,20 @@ func TestRoomBorderChangedCarriesFlatURLInIMAttributes(t *testing.T) {
func TestRoomGiftSentCarriesTargetGiftValueInIMAttributes(t *testing.T) { func TestRoomGiftSentCarriesTargetGiftValueInIMAttributes(t *testing.T) {
record, err := outbox.Build("room-gift-value", "RoomGiftSent", 8, time.Now(), &roomeventsv1.RoomGiftSent{ record, err := outbox.Build("room-gift-value", "RoomGiftSent", 8, time.Now(), &roomeventsv1.RoomGiftSent{
SenderUserId: 1001, SenderUserId: 1001,
TargetUserId: 1002, TargetUserId: 1002,
GiftId: "gift_rose", GiftId: "gift_rose",
GiftCount: 1, GiftCount: 1,
GiftValue: 117, GiftValue: 117,
TargetGiftValue: 367, TargetGiftValue: 367,
SenderName: "Robot Sender", SenderName: "Robot Sender",
SenderAvatar: "https://cdn.example/sender.png", SenderAvatar: "https://cdn.example/sender.png",
SenderDisplayUserId: "100001", SenderDisplayUserId: "100001",
SenderVipLevel: 7, SenderVipLevel: 7,
ReceiverNickname: "Robot Receiver", SenderGiftBroadcastAnimationUrl: "https://cdn.example/vip-gift-tray.svga",
ReceiverAvatar: "https://cdn.example/receiver.png", ReceiverNickname: "Robot Receiver",
ReceiverDisplayUserId: "100002", ReceiverAvatar: "https://cdn.example/receiver.png",
ReceiverDisplayUserId: "100002",
}) })
if err != nil { if err != nil {
t.Fatalf("Build RoomGiftSent envelope failed: %v", err) 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" { 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) 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) 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" { 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 { if err := json.Unmarshal(payload, &payloadData); err != nil {
t.Fatalf("decode gift IM event failed: %v payload=%s", err, payload) 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) 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) { func TestRoomGiftBatchSentPublishesSingleBatchIMEvent(t *testing.T) {
record, err := outbox.Build("room-gift-batch", "RoomGiftBatchSent", 12, time.Now(), &roomeventsv1.RoomGiftBatchSent{ record, err := outbox.Build("room-gift-batch", "RoomGiftBatchSent", 12, time.Now(), &roomeventsv1.RoomGiftBatchSent{
SenderUserId: 1001, SenderUserId: 1001,
SenderName: "Batch Sender", SenderName: "Batch Sender",
SenderVipLevel: 8, SenderVipLevel: 8,
GiftId: "gift_clover", SenderGiftBroadcastAnimationUrl: "https://cdn.example/vip-gift-tray-batch.svga",
GiftCount: 2, GiftId: "gift_clover",
TotalGiftValue: 600, GiftCount: 2,
TotalCoinSpent: 600, TotalGiftValue: 600,
BillingReceiptId: "receipt-batch", TotalCoinSpent: 600,
RoomHeat: 6600, BillingReceiptId: "receipt-batch",
PoolId: "super_lucky", RoomHeat: 6600,
GiftEffectTypes: []string{"room"}, PoolId: "super_lucky",
TargetUserIds: []int64{1002, 1003}, GiftEffectTypes: []string{"room"},
CommandId: "cmd-batch", TargetUserIds: []int64{1002, 1003},
CommandId: "cmd-batch",
Targets: []*roomeventsv1.RoomGiftBatchTarget{ Targets: []*roomeventsv1.RoomGiftBatchTarget{
{ {
TargetUserId: 1002, 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" { 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) 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) t.Fatalf("batch gift IM sender VIP mismatch: %+v", event.Attributes)
} }
var targets []struct { var targets []struct {

View File

@ -651,6 +651,8 @@ type GiftDisplayProfile struct {
PrettyDisplayUserID string `json:"pretty_display_user_id,omitempty"` PrettyDisplayUserID string `json:"pretty_display_user_id,omitempty"`
// EffectiveVIPLevel 固化送礼入口看到的生效等级,命令重放时不能重新查询导致同一礼物事件展示漂移。 // EffectiveVIPLevel 固化送礼入口看到的生效等级,命令重放时不能重新查询导致同一礼物事件展示漂移。
EffectiveVIPLevel int32 `json:"effective_vip_level,omitempty"` EffectiveVIPLevel int32 `json:"effective_vip_level,omitempty"`
// GiftBroadcastAnimationURL 固化 wallet 已校验的 gift_tray_skin普通、幸运和超级幸运礼物统一复用该快照。
GiftBroadcastAnimationURL string `json:"gift_broadcast_animation_url,omitempty"`
} }
// RocketRewardGrant 记录发射命令中已经结算出的资源组发放结果。 // RocketRewardGrant 记录发射命令中已经结算出的资源组发放结果。

View File

@ -39,28 +39,29 @@ func buildSendGiftBatchDisplay(cmd command.SendGift, billing *walletv1.DebitGift
targetUserIDs = append(targetUserIDs, cmd.TargetUserIDs...) targetUserIDs = append(targetUserIDs, cmd.TargetUserIDs...)
} }
return &roomv1.SendGiftBatchDisplay{ return &roomv1.SendGiftBatchDisplay{
SenderUserId: cmd.ActorUserID(), SenderUserId: cmd.ActorUserID(),
SenderName: giftDisplayName(cmd.SenderDisplayProfile), SenderName: giftDisplayName(cmd.SenderDisplayProfile),
SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar), SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar),
SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID), SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID),
SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID), SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID),
SenderVipLevel: cmd.SenderDisplayProfile.EffectiveVIPLevel, SenderVipLevel: cmd.SenderDisplayProfile.EffectiveVIPLevel,
GiftId: cmd.GiftID, SenderGiftBroadcastAnimationUrl: strings.TrimSpace(cmd.SenderDisplayProfile.GiftBroadcastAnimationURL),
GiftCount: cmd.GiftCount, GiftId: cmd.GiftID,
TotalGiftValue: sumTargetGiftValue(targetBillings), GiftCount: cmd.GiftCount,
TotalCoinSpent: sumTargetCoinSpent(targetBillings, billing), TotalGiftValue: sumTargetGiftValue(targetBillings),
BillingReceiptId: firstNonEmpty(displayBilling.GetBillingReceiptId(), cmd.BillingReceiptID), TotalCoinSpent: sumTargetCoinSpent(targetBillings, billing),
RoomHeat: roomHeat, BillingReceiptId: firstNonEmpty(displayBilling.GetBillingReceiptId(), cmd.BillingReceiptID),
PoolId: cmd.PoolID, RoomHeat: roomHeat,
GiftTypeCode: displayBilling.GetGiftTypeCode(), PoolId: cmd.PoolID,
CpRelationType: displayBilling.GetCpRelationType(), GiftTypeCode: displayBilling.GetGiftTypeCode(),
GiftName: displayBilling.GetGiftName(), CpRelationType: displayBilling.GetCpRelationType(),
GiftIconUrl: displayBilling.GetGiftIconUrl(), GiftName: displayBilling.GetGiftName(),
GiftAnimationUrl: displayBilling.GetGiftAnimationUrl(), GiftIconUrl: displayBilling.GetGiftIconUrl(),
GiftEffectTypes: displayBilling.GetGiftEffectTypes(), GiftAnimationUrl: displayBilling.GetGiftAnimationUrl(),
TargetUserIds: targetUserIDs, GiftEffectTypes: displayBilling.GetGiftEffectTypes(),
Targets: targets, TargetUserIds: targetUserIDs,
CommandId: cmd.ID(), Targets: targets,
CommandId: cmd.ID(),
} }
} }
@ -89,29 +90,30 @@ func roomGiftBatchSentEventFromDisplay(display *roomv1.SendGiftBatchDisplay, vis
targets = append(targets, batchTarget) targets = append(targets, batchTarget)
} }
return &roomeventsv1.RoomGiftBatchSent{ return &roomeventsv1.RoomGiftBatchSent{
SenderUserId: display.GetSenderUserId(), SenderUserId: display.GetSenderUserId(),
SenderName: display.GetSenderName(), SenderName: display.GetSenderName(),
SenderAvatar: display.GetSenderAvatar(), SenderAvatar: display.GetSenderAvatar(),
SenderDisplayUserId: display.GetSenderDisplayUserId(), SenderDisplayUserId: display.GetSenderDisplayUserId(),
SenderPrettyDisplayUserId: display.GetSenderPrettyDisplayUserId(), SenderPrettyDisplayUserId: display.GetSenderPrettyDisplayUserId(),
SenderVipLevel: display.GetSenderVipLevel(), SenderVipLevel: display.GetSenderVipLevel(),
GiftId: display.GetGiftId(), SenderGiftBroadcastAnimationUrl: display.GetSenderGiftBroadcastAnimationUrl(),
GiftCount: display.GetGiftCount(), GiftId: display.GetGiftId(),
TotalGiftValue: display.GetTotalGiftValue(), GiftCount: display.GetGiftCount(),
TotalCoinSpent: display.GetTotalCoinSpent(), TotalGiftValue: display.GetTotalGiftValue(),
BillingReceiptId: display.GetBillingReceiptId(), TotalCoinSpent: display.GetTotalCoinSpent(),
RoomHeat: display.GetRoomHeat(), BillingReceiptId: display.GetBillingReceiptId(),
PoolId: display.GetPoolId(), RoomHeat: display.GetRoomHeat(),
GiftTypeCode: display.GetGiftTypeCode(), PoolId: display.GetPoolId(),
CpRelationType: display.GetCpRelationType(), GiftTypeCode: display.GetGiftTypeCode(),
GiftName: display.GetGiftName(), CpRelationType: display.GetCpRelationType(),
GiftIconUrl: display.GetGiftIconUrl(), GiftName: display.GetGiftName(),
GiftAnimationUrl: display.GetGiftAnimationUrl(), GiftIconUrl: display.GetGiftIconUrl(),
GiftEffectTypes: display.GetGiftEffectTypes(), GiftAnimationUrl: display.GetGiftAnimationUrl(),
TargetUserIds: display.GetTargetUserIds(), GiftEffectTypes: display.GetGiftEffectTypes(),
Targets: targets, TargetUserIds: display.GetTargetUserIds(),
CommandId: display.GetCommandId(), Targets: targets,
VisibleRegionId: visibleRegionID, 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 { for _, targetBilling := range targetBillings {
targetDisplayProfile := giftDisplayProfileForUser(cmd.TargetDisplayProfiles, targetBilling.TargetUserID) targetDisplayProfile := giftDisplayProfileForUser(cmd.TargetDisplayProfiles, targetBilling.TargetUserID)
record, err := outbox.Build(roomID, "RoomGiftSent", roomVersion, now, &roomeventsv1.RoomGiftSent{ record, err := outbox.Build(roomID, "RoomGiftSent", roomVersion, now, &roomeventsv1.RoomGiftSent{
SenderUserId: cmd.ActorUserID(), SenderUserId: cmd.ActorUserID(),
TargetUserId: targetBilling.TargetUserID, TargetUserId: targetBilling.TargetUserID,
GiftId: cmd.GiftID, GiftId: cmd.GiftID,
PoolId: cmd.PoolID, PoolId: cmd.PoolID,
GiftCount: cmd.GiftCount, GiftCount: cmd.GiftCount,
GiftValue: targetBilling.Billing.GetHeatValue(), GiftValue: targetBilling.Billing.GetHeatValue(),
CoinSpent: targetBilling.Billing.GetCoinSpent(), CoinSpent: targetBilling.Billing.GetCoinSpent(),
BillingReceiptId: targetBilling.Billing.GetBillingReceiptId(), BillingReceiptId: targetBilling.Billing.GetBillingReceiptId(),
VisibleRegionId: roomMeta.VisibleRegionID, VisibleRegionId: roomMeta.VisibleRegionID,
CountryId: cmd.SenderCountryID, CountryId: cmd.SenderCountryID,
RegionId: firstNonZeroInt64(cmd.SenderRegionID, roomMeta.VisibleRegionID), RegionId: firstNonZeroInt64(cmd.SenderRegionID, roomMeta.VisibleRegionID),
CommandId: targetBilling.CommandID, CommandId: targetBilling.CommandID,
GiftTypeCode: targetBilling.Billing.GetGiftTypeCode(), GiftTypeCode: targetBilling.Billing.GetGiftTypeCode(),
CpRelationType: targetBilling.Billing.GetCpRelationType(), CpRelationType: targetBilling.Billing.GetCpRelationType(),
GiftName: targetBilling.Billing.GetGiftName(), GiftName: targetBilling.Billing.GetGiftName(),
GiftIconUrl: targetBilling.Billing.GetGiftIconUrl(), GiftIconUrl: targetBilling.Billing.GetGiftIconUrl(),
GiftAnimationUrl: targetBilling.Billing.GetGiftAnimationUrl(), GiftAnimationUrl: targetBilling.Billing.GetGiftAnimationUrl(),
TargetGiftValue: targetCurrentGiftValues[targetBilling.TargetUserID], TargetGiftValue: targetCurrentGiftValues[targetBilling.TargetUserID],
SenderName: giftDisplayName(cmd.SenderDisplayProfile), SenderName: giftDisplayName(cmd.SenderDisplayProfile),
SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar), SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar),
SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID), SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID),
SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID), SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID),
SenderVipLevel: cmd.SenderDisplayProfile.EffectiveVIPLevel, SenderVipLevel: cmd.SenderDisplayProfile.EffectiveVIPLevel,
ReceiverNickname: giftDisplayName(targetDisplayProfile), SenderGiftBroadcastAnimationUrl: strings.TrimSpace(cmd.SenderDisplayProfile.GiftBroadcastAnimationURL),
ReceiverAvatar: strings.TrimSpace(targetDisplayProfile.Avatar), ReceiverNickname: giftDisplayName(targetDisplayProfile),
ReceiverDisplayUserId: strings.TrimSpace(targetDisplayProfile.DisplayUserID), ReceiverAvatar: strings.TrimSpace(targetDisplayProfile.Avatar),
ReceiverPrettyDisplayUserId: strings.TrimSpace(targetDisplayProfile.PrettyDisplayUserID), ReceiverDisplayUserId: strings.TrimSpace(targetDisplayProfile.DisplayUserID),
ReceiverPrettyDisplayUserId: strings.TrimSpace(targetDisplayProfile.PrettyDisplayUserID),
// display_mode 只改变房间展示投递策略;逐目标事实仍然进入 durable outbox保证统计、activity、CP 和礼物墙消费口径不变。 // display_mode 只改变房间展示投递策略;逐目标事实仍然进入 durable outbox保证统计、activity、CP 和礼物墙消费口径不变。
DisplayMode: strings.TrimSpace(cmd.DisplayMode), DisplayMode: strings.TrimSpace(cmd.DisplayMode),
// effect_types 来自 wallet-service 已锁定的礼物价格配置room-service 只透传,避免统计和全服广播各自重新判定礼物效果。 // effect_types 来自 wallet-service 已锁定的礼物价格配置room-service 只透传,避免统计和全服广播各自重新判定礼物效果。

View File

@ -30,12 +30,13 @@ func TestBuildRoomGiftSentRecordsPreservesDurableFactFields(t *testing.T) {
SenderCountryID: 86, SenderCountryID: 86,
SenderRegionID: 9001, SenderRegionID: 9001,
SenderDisplayProfile: command.GiftDisplayProfile{ SenderDisplayProfile: command.GiftDisplayProfile{
UserID: 101, UserID: 101,
Username: " sender ", Username: " sender ",
Avatar: " avatar-sender ", Avatar: " avatar-sender ",
DisplayUserID: "S101", DisplayUserID: "S101",
PrettyDisplayUserID: "PS101", PrettyDisplayUserID: "PS101",
EffectiveVIPLevel: 7, EffectiveVIPLevel: 7,
GiftBroadcastAnimationURL: "https://cdn.example/vip-gift-tray.svga",
}, },
TargetDisplayProfiles: []command.GiftDisplayProfile{{ TargetDisplayProfiles: []command.GiftDisplayProfile{{
UserID: 202, UserID: 202,
@ -100,7 +101,7 @@ func TestBuildRoomGiftSentRecordsPreservesDurableFactFields(t *testing.T) {
if !event.GetIsRobotGift() || !event.GetSyntheticLuckyGift() || !event.GetRealRoomRobotGift() { if !event.GetIsRobotGift() || !event.GetSyntheticLuckyGift() || !event.GetRealRoomRobotGift() {
t.Fatalf("robot/lucky flags mismatch: %+v", &event) 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) t.Fatalf("display profile fields mismatch: %+v", &event)
} }
} }
@ -164,12 +165,13 @@ func TestRoomGiftBatchSentEventFromDisplayPreservesTargetsAndTotals(t *testing.T
GiftCount: 2, GiftCount: 2,
DisplayMode: "batch", DisplayMode: "batch",
SenderDisplayProfile: command.GiftDisplayProfile{ SenderDisplayProfile: command.GiftDisplayProfile{
UserID: 301, UserID: 301,
Username: "batch sender", Username: "batch sender",
Avatar: "sender-avatar", Avatar: "sender-avatar",
DisplayUserID: "S301", DisplayUserID: "S301",
PrettyDisplayUserID: "PS301", PrettyDisplayUserID: "PS301",
EffectiveVIPLevel: 8, EffectiveVIPLevel: 8,
GiftBroadcastAnimationURL: "https://cdn.example/vip-gift-tray-batch.svga",
}, },
TargetDisplayProfiles: []command.GiftDisplayProfile{ TargetDisplayProfiles: []command.GiftDisplayProfile{
{UserID: 401, Username: "target-one", Avatar: "avatar-one", DisplayUserID: "T401", PrettyDisplayUserID: "PT401"}, {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) display := buildSendGiftBatchDisplay(cmd, billing, targetBillings, map[int64]int64{401: 1001, 402: 1002}, luckyGifts, 8888)
event := roomGiftBatchSentEventFromDisplay(display, 6006) 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) t.Fatalf("batch root fields mismatch: %+v", event)
} }
if event.GetTotalGiftValue() != 330 || event.GetTotalCoinSpent() != 220 || event.GetBillingReceiptId() != "receipt-batch-root" || event.GetRoomHeat() != 8888 { 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避免事件字段互相不一致。 // 展示快照只裁剪空白字符,不做业务兜底;兜底顺序统一放在 giftDisplayName避免事件字段互相不一致。
return command.GiftDisplayProfile{ return command.GiftDisplayProfile{
UserID: item.GetUserId(), UserID: item.GetUserId(),
Username: strings.TrimSpace(item.GetUsername()), Username: strings.TrimSpace(item.GetUsername()),
Avatar: strings.TrimSpace(item.GetAvatar()), Avatar: strings.TrimSpace(item.GetAvatar()),
DisplayUserID: strings.TrimSpace(item.GetDisplayUserId()), DisplayUserID: strings.TrimSpace(item.GetDisplayUserId()),
PrettyDisplayUserID: strings.TrimSpace(item.GetPrettyDisplayUserId()), PrettyDisplayUserID: strings.TrimSpace(item.GetPrettyDisplayUserId()),
EffectiveVIPLevel: effectiveVIPLevel, EffectiveVIPLevel: effectiveVIPLevel,
GiftBroadcastAnimationURL: strings.TrimSpace(item.GetGiftBroadcastAnimationUrl()),
} }
} }

View File

@ -431,26 +431,27 @@ func roomListEntryFromSnapshot(snapshot *roomv1.RoomSnapshot, visibleRegionID in
} }
return RoomListEntry{ return RoomListEntry{
AppCode: snapshot.GetAppCode(), AppCode: snapshot.GetAppCode(),
RoomID: snapshot.GetRoomId(), RoomID: snapshot.GetRoomId(),
RoomShortID: roomShortID, RoomShortID: roomShortID,
VisibleRegionID: normalizeVisibleRegionID(visibleRegionID), VisibleRegionID: normalizeVisibleRegionID(visibleRegionID),
OwnerCountryCode: normalizeRoomCountryCode(snapshot.GetRoomExt()[roomExtOwnerCountryKey]), OwnerCountryCode: normalizeRoomCountryCode(snapshot.GetRoomExt()[roomExtOwnerCountryKey]),
OwnerUserID: snapshot.GetOwnerUserId(), OwnerUserID: snapshot.GetOwnerUserId(),
Title: title, Title: title,
CoverURL: coverURL, CoverURL: coverURL,
Mode: snapshot.GetMode(), Mode: snapshot.GetMode(),
Status: snapshot.GetStatus(), Status: snapshot.GetStatus(),
Locked: snapshot.GetLocked(), Locked: snapshot.GetLocked(),
Heat: snapshot.GetHeat(), Heat: snapshot.GetHeat(),
OnlineCount: onlineCount, OnlineCount: onlineCount,
SeatCount: seatCount, SeatCount: seatCount,
OccupiedSeatCount: occupiedSeatCount, OccupiedSeatCount: occupiedSeatCount,
SortScore: roomListSortScore(snapshot.GetHeat(), onlineCount, occupiedSeatCount), SortScore: roomListSortScore(snapshot.GetHeat(), onlineCount, occupiedSeatCount),
RoomBorderJSON: marshalRoomListDecoration(snapshot.GetRoomBorder()), RocketNearCompletion: roomRocketNearCompletion(snapshot.GetRocket()),
RoomNameStyleJSON: marshalRoomListDecoration(snapshot.GetRoomNameStyle()), RoomBorderJSON: marshalRoomListDecoration(snapshot.GetRoomBorder()),
CreatedAtMS: nowMS, RoomNameStyleJSON: marshalRoomListDecoration(snapshot.GetRoomNameStyle()),
UpdatedAtMS: nowMS, CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
} }
} }
@ -641,28 +642,45 @@ func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
ownerVIPLevel = 0 ownerVIPLevel = 0
} }
return &roomv1.RoomListItem{ return &roomv1.RoomListItem{
AppCode: entry.AppCode, AppCode: entry.AppCode,
RoomId: entry.RoomID, RoomId: entry.RoomID,
RoomShortId: entry.RoomShortID, RoomShortId: entry.RoomShortID,
OwnerUserId: entry.OwnerUserID, OwnerUserId: entry.OwnerUserID,
Title: entry.Title, Title: entry.Title,
CoverUrl: entry.CoverURL, CoverUrl: entry.CoverURL,
Mode: entry.Mode, Mode: entry.Mode,
Status: entry.Status, Status: entry.Status,
Locked: entry.Locked, Locked: entry.Locked,
Heat: entry.Heat, Heat: entry.Heat,
OnlineCount: entry.OnlineCount, OnlineCount: entry.OnlineCount,
SeatCount: entry.SeatCount, SeatCount: entry.SeatCount,
OccupiedSeatCount: entry.OccupiedSeatCount, OccupiedSeatCount: entry.OccupiedSeatCount,
VisibleRegionId: entry.VisibleRegionID, VisibleRegionId: entry.VisibleRegionID,
CountryCode: normalizeRoomCountryCode(entry.OwnerCountryCode), CountryCode: normalizeRoomCountryCode(entry.OwnerCountryCode),
RoomBorder: roomBorder, RoomBorder: roomBorder,
RoomBorderUrl: roomBorder.GetAssetUrl(), RoomBorderUrl: roomBorder.GetAssetUrl(),
RoomNameStyle: unmarshalRoomListDecoration(entry.RoomNameStyleJSON, nowMS), RoomNameStyle: unmarshalRoomListDecoration(entry.RoomNameStyleJSON, nowMS),
OwnerVipLevel: ownerVIPLevel, 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 { func marshalRoomListDecoration(resource *roomv1.RoomDecorationResource) string {
if resource == nil || resource.GetResourceId() <= 0 { if resource == nil || resource.GetResourceId() <= 0 {
return "" return ""

View File

@ -7,6 +7,7 @@ import (
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/walletmq"
"hyapp/services/room-service/internal/integration" "hyapp/services/room-service/internal/integration"
roomservice "hyapp/services/room-service/internal/room/service" roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/router" "hyapp/services/room-service/internal/router"
@ -17,18 +18,21 @@ func TestListRoomsUsesCacheWhenEligible(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
cache := &fakeRoomListCache{entries: []roomservice.RoomListEntry{{ cache := &fakeRoomListCache{entries: []roomservice.RoomListEntry{{
AppCode: appcode.Default, AppCode: appcode.Default,
RoomID: "room-cache-hit", RoomID: "room-cache-hit",
RoomShortID: "cache-hit", RoomShortID: "cache-hit",
VisibleRegionID: 7001, VisibleRegionID: 7001,
OwnerUserID: 6001, OwnerUserID: 6001,
OwnerVIPLevel: 5, OwnerVIPLevel: 5,
OwnerVIPExpiresAtMS: time.Now().UTC().Add(time.Hour).UnixMilli(), OwnerVIPExpiresAtMS: time.Now().UTC().Add(time.Hour).UnixMilli(),
Title: "cache", Title: "cache",
Mode: "voice", Mode: "voice",
Status: "active", Status: "active",
SortScore: 99, SortScore: 99,
RoomBorderJSON: `{"resource_id":5201,"resource_type":"room_border","asset_url":"https://cdn.example.com/room-border/gold.webp","expires_at_ms":4102444800000}`, 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{ svc := roomservice.New(roomservice.Config{
NodeID: "node-list-cache-hit-test", 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 { 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]) 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) { func TestListRoomsFallsBackToMySQLWhenCacheMisses(t *testing.T) {

View File

@ -1,6 +1,10 @@
package service package service
import "testing" import (
"testing"
roomv1 "hyapp.local/api/proto/room/v1"
)
func TestRoomListSortScoreUsesCompressedWeightedInputs(t *testing.T) { func TestRoomListSortScoreUsesCompressedWeightedInputs(t *testing.T) {
score := roomListSortScore(7, 3, 2) 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) 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 OwnerVIPExpiresAtMS int64
// OwnerVIPProjectionKnown 区分“投影明确为 0”和“Room Cell 快照没有携带该外部事实”;它不写入 Redis payload。 // OwnerVIPProjectionKnown 区分“投影明确为 0”和“Room Cell 快照没有携带该外部事实”;它不写入 Redis payload。
OwnerVIPProjectionKnown bool `json:"-"` 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 是卡片标题;空值由客户端或后续资料聚合兜底。
Title string Title string
// CoverURL 是卡片封面;空值表示没有单独封面。 // CoverURL 是卡片封面;空值表示没有单独封面。
@ -877,6 +885,28 @@ type RoomOwnerVIPProjectionStore interface {
UpdateRoomOwnerVIP(ctx context.Context, ownerUserID int64, level int32, expiresAtMS int64) (RoomListEntry, bool, error) 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 展示读模型边界。 // RoomListCacheStore 是公共发现页 Redis 展示读模型边界。
// //
// MySQL 仍然是房间列表和置顶事实 owner缓存只服务高频 hot/new 发现页, // MySQL 仍然是房间列表和置顶事实 owner缓存只服务高频 hot/new 发现页,

View File

@ -181,8 +181,9 @@ func (s *RedisRoomListCacheStore) UpsertRoomListEntry(ctx context.Context, entry
} }
entry.AppCode = app entry.AppCode = app
entry.OwnerCountryCode = normalizeRoomCountryCode(entry.OwnerCountryCode) entry.OwnerCountryCode = normalizeRoomCountryCode(entry.OwnerCountryCode)
if !entry.OwnerVIPProjectionKnown { if !entry.OwnerVIPProjectionKnown || !entry.ActiveRedPacketProjectionKnown {
// Room Cell 的普通状态刷新不携带 wallet 外部事实;保留现有卡片 VIP避免送礼/上下麦后把等级误清零。 // Room Cell 的普通状态刷新不携带 wallet 外部事实。只读一次旧卡片并按 known 标记合并,避免
// 送礼/上下麦/火箭进度刷新误清 VIP 或红包,同时不为一次投影写入制造重复 Redis 往返。
raw, err := s.client.HGet(ctx, roomListCacheCardKey(app), roomID).Result() raw, err := s.client.HGet(ctx, roomListCacheCardKey(app), roomID).Result()
if err != nil && err != redis.Nil { if err != nil && err != redis.Nil {
return err return err
@ -190,8 +191,14 @@ func (s *RedisRoomListCacheStore) UpsertRoomListEntry(ctx context.Context, entry
if err == nil { if err == nil {
var previous RoomListEntry var previous RoomListEntry
if json.Unmarshal([]byte(raw), &previous) == nil { if json.Unmarshal([]byte(raw), &previous) == nil {
entry.OwnerVIPLevel = previous.OwnerVIPLevel if !entry.OwnerVIPProjectionKnown {
entry.OwnerVIPExpiresAtMS = previous.OwnerVIPExpiresAtMS 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, _, err := r.db.ExecContext(ctx,
`INSERT INTO room_list_entries ( `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, 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 heat, online_count, seat_count, occupied_seat_count, sort_score, rocket_near_completion,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, ?) room_border_json, room_name_style_json, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
room_short_id = VALUES(room_short_id), room_short_id = VALUES(room_short_id),
visible_region_id = VALUES(visible_region_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), seat_count = VALUES(seat_count),
occupied_seat_count = VALUES(occupied_seat_count), occupied_seat_count = VALUES(occupied_seat_count),
sort_score = VALUES(sort_score), sort_score = VALUES(sort_score),
rocket_near_completion = VALUES(rocket_near_completion),
room_border_json = VALUES(room_border_json), room_border_json = VALUES(room_border_json),
room_name_style_json = VALUES(room_name_style_json), room_name_style_json = VALUES(room_name_style_json),
updated_at_ms = VALUES(updated_at_ms)`, updated_at_ms = VALUES(updated_at_ms)`,
@ -56,6 +58,7 @@ func (r *Repository) UpsertRoomListEntry(ctx context.Context, entry roomservice.
entry.SeatCount, entry.SeatCount,
entry.OccupiedSeatCount, entry.OccupiedSeatCount,
entry.SortScore, entry.SortScore,
entry.RocketNearCompletion,
entry.RoomBorderJSON, entry.RoomBorderJSON,
entry.RoomNameStyleJSON, entry.RoomNameStyleJSON,
entry.CreatedAtMS, 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, 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, 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), ''), 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 FROM room_list_entries
WHERE app_code = ? AND room_id IN (`+placeholders+`)`, args...) WHERE app_code = ? AND room_id IN (`+placeholders+`)`, args...)
if err != nil { if err != nil {
@ -281,6 +285,13 @@ func scanRoomListEntry(scanner interface{ Scan(dest ...any) error }) (roomservic
dest = append(dest, &entry.OwnerVIPLevel, &entry.OwnerVIPExpiresAtMS) dest = append(dest, &entry.OwnerVIPLevel, &entry.OwnerVIPExpiresAtMS)
entry.OwnerVIPProjectionKnown = true 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...) err := scanner.Scan(dest...)
return entry, err return entry, err
} }
@ -302,6 +313,13 @@ func scanRoomListEntryWithPins(scanner interface{ Scan(dest ...any) error }) (ro
dest = append(dest, &entry.OwnerVIPLevel, &entry.OwnerVIPExpiresAtMS) dest = append(dest, &entry.OwnerVIPLevel, &entry.OwnerVIPExpiresAtMS)
entry.OwnerVIPProjectionKnown = true 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) dest = append(dest, &pinned, &entry.PinListRank, &entry.PinWeight, &entry.PinnedUntilMS)
err := scanner.Scan(dest...) err := scanner.Scan(dest...)
return entry, pinned, err 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, 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, 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), ''), 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` FROM room_list_entries`
// owner 定向列表复用公共列表扫描器,但不参与公共发现页置顶排序;这里补零值列保持 scan 契约稳定。 // 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, 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, 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), ''), 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 0 AS is_pinned, 0 AS pin_list_rank, 0 AS pin_weight, 0 AS pinned_until_ms
FROM room_list_entries` 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, 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, 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), ''), 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 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` 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, 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, 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), ''), 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 FROM room_follows f
JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id` 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, 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, 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), ''), 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 ` + pinnedExpr + ` AS is_pinned, ` + pinRankExpr + ` AS pin_list_rank, ` + pinWeightExpr + ` AS pin_weight, ` + pinExpiresExpr + ` AS pinned_until_ms
FROM room_list_entries r FROM room_list_entries r
LEFT JOIN room_region_pins gp 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, seat_count INT NOT NULL DEFAULT 0,
occupied_seat_count INT NOT NULL DEFAULT 0, occupied_seat_count INT NOT NULL DEFAULT 0,
sort_score BIGINT 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_border_json JSON NULL,
room_name_style_json JSON NULL, room_name_style_json JSON NULL,
created_at_ms BIGINT NOT 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), PRIMARY KEY (app_code, event_id),
KEY idx_room_wallet_event_consumed (app_code, consumed_at_ms) KEY idx_room_wallet_event_consumed (app_code, consumed_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, ) 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 ( `CREATE TABLE IF NOT EXISTS room_snapshots (
app_code VARCHAR(32) NOT NULL, app_code VARCHAR(32) NOT NULL,
room_id VARCHAR(64) 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); ('fami', 9, 'VIP9', 'disabled', 0, 2592000000, 0, 0, 90, 0, 0);
-- P1 权益按每个等级展开成完整集合:例如 VIP4 会实际写入 VIP1..VIP4 的全部权益。 -- P1 权益按每个等级展开成完整集合:例如 VIP4 会实际写入 VIP1..VIP4 的全部权益。
-- resource_id/resource_type 保持空,待运营绑定真实资源;体验卡仅排除每日金币返现。 -- resource_id 默认保持空,待运营绑定真实资源;固定房间装扮仍预置精确 resource_type体验卡仅排除每日金币返现。
INSERT IGNORE INTO vip_level_benefits ( INSERT IGNORE INTO vip_level_benefits (
app_code, level, benefit_code, name, benefit_type, unlock_level, status, trial_enabled, 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, 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 'mic_skin' THEN 'mic_seat_icon'
WHEN 'room_border' THEN 'room_border' WHEN 'room_border' THEN 'room_border'
WHEN 'colored_room_name' THEN 'room_name_style' WHEN 'colored_room_name' THEN 'room_name_style'
WHEN 'gift_tray_skin' THEN 'gift_tray_skin'
ELSE '' ELSE ''
END, END,
benefits.execution_scope, 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_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_nickname', 'VIP彩色昵称', 'function', 5, TRUE, 'user', FALSE, 190
UNION ALL SELECT 'colored_id', 'VIP彩色ID', 'function', 5, TRUE, 'user', FALSE, 200 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 'hide_profile_data', '隐藏个人数据', 'function', 6, TRUE, 'user', FALSE, 220
UNION ALL SELECT 'custom_avatar_frame', '定制头框', 'function', 6, TRUE, 'user', FALSE, 230 UNION ALL SELECT 'custom_avatar_frame', '定制头框', 'function', 6, TRUE, 'user', FALSE, 230
UNION ALL SELECT 'leaderboard_invisible', '榜单隐身', 'function', 6, TRUE, 'user', FALSE, 240 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 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 文案必须保留。 -- 老环境可能先执行过不含文案的 seed这里只补空元数据运营已配置的 App 文案必须保留。
UPDATE vip_level_benefits UPDATE vip_level_benefits
SET metadata_json = JSON_OBJECT('message', '欢迎进入Fami祝你有美好的一天') 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) t.Fatalf("VIP gift denial must expose stable level metadata, got %+v", detail)
} }
assertBalance(t, svc, 41003, ledger.AssetCoin, 100) 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() 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{ vipReceipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
CommandID: "cmd-vip-spark-allowed", CommandID: "cmd-vip-spark-allowed",
RoomID: "room-1", RoomID: "room-1",
@ -4877,11 +4882,28 @@ func TestGiftConfigMustSelectGiftResource(t *testing.T) {
GiftCount: 1, GiftCount: 1,
}) })
if err != nil { 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 { if vipReceipt.CoinSpent != 5 {
t.Fatalf("VIP gift settlement mismatch: %+v", vipReceipt) 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) repository.SetBalance(41001, 100)
receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{

View File

@ -10,6 +10,7 @@ import (
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger" "hyapp/services/wallet-service/internal/domain/ledger"
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
) )
type giftPrice struct { 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) { func (r *Repository) resolveGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) {
giftTypeCode = strings.TrimSpace(giftTypeCode) giftTypeCodes := giftRatioTypeCandidates(giftTypeCode)
if giftTypeCode == "" {
giftTypeCode = "normal"
}
regions := []int64{regionID} regions := []int64{regionID}
if regionID != 0 { if regionID != 0 {
regions = append(regions, 0) regions = append(regions, 0)
} }
for _, regionID := range regions { for _, regionID := range regions {
ratio, exists, err := r.getGiftDiamondRatio(ctx, tx, appCode, regionID, giftTypeCode) for _, candidate := range giftTypeCodes {
if err != nil { ratio, exists, err := r.getGiftDiamondRatio(ctx, tx, appCode, regionID, candidate)
return giftDiamondRatioSnapshot{}, 0, err if err != nil {
} return giftDiamondRatioSnapshot{}, 0, err
if exists { }
return ratio, regionID, nil if exists {
return ratio, regionID, nil
}
} }
} }
// 礼物类型倍率属于后台运行政策。区域和全局都未配置时必须在扣币前失败 // 礼物类型倍率属于后台运行政策。VIP 礼物只继承同区域或全局已持久化的 normal 政策
// 不能用 normal/lucky/super_lucky 编译期常量悄悄制造 Host 收益。 // 其余类型仍在区域和全局都未配置时 fail-close不能用编译期百分比制造 Host 收益。
return giftDiamondRatioSnapshot{}, 0, xerr.New(xerr.PermissionDenied, "gift diamond ratio is not configured") 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) { func (r *Repository) resolveGiftReturnCoinRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) {
giftTypeCode = strings.TrimSpace(giftTypeCode) giftTypeCodes := giftRatioTypeCandidates(giftTypeCode)
if giftTypeCode == "" {
giftTypeCode = "normal"
}
regions := []int64{regionID} regions := []int64{regionID}
if regionID != 0 { if regionID != 0 {
regions = append(regions, 0) regions = append(regions, 0)
} }
for _, regionID := range regions { for _, regionID := range regions {
ratio, exists, err := r.getGiftReturnCoinRatio(ctx, tx, appCode, regionID, giftTypeCode) for _, candidate := range giftTypeCodes {
if err != nil { ratio, exists, err := r.getGiftReturnCoinRatio(ctx, tx, appCode, regionID, candidate)
return giftDiamondRatioSnapshot{}, 0, err if err != nil {
} return giftDiamondRatioSnapshot{}, 0, err
if exists { }
return ratio, regionID, nil if exists {
return ratio, regionID, nil
}
} }
} }
// 返金币与主播钻石倍率共用后台配置行,但业务含义独立。区域和全局均无记录时 // 返金币与主播钻石倍率共用后台配置行,但业务含义独立。VIP 礼物继承已持久化的
// 必须在账户锁和扣币前失败,不能再按 gift_type 推导 30/10/1 // normal 返币政策;没有任何可继承政策时仍必须在账户锁和扣币前失败。
return giftDiamondRatioSnapshot{}, 0, xerr.New(xerr.PermissionDenied, "gift return coin ratio is not configured") 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) { func (r *Repository) getGiftReturnCoinRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, bool, error) {
var percent string var percent string
var ppm int64 var ppm int64

View File

@ -649,6 +649,19 @@ func validateVIPTrialCardCatalogResource(command resourcedomain.ResourceCommand)
func validateRoomDecorationResource(command resourcedomain.ResourceCommand) error { func validateRoomDecorationResource(command resourcedomain.ResourceCommand) error {
switch command.ResourceType { 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: case resourcedomain.TypeMicSeatAnimation, resourcedomain.TypeRoomBorder:
// Room Cell/appearance 只保存资源快照,不会替运营生成素材;目录层拒绝空壳视觉资源, // Room Cell/appearance 只保存资源快照,不会替运营生成素材;目录层拒绝空壳视觉资源,
// 同时固化格式Flutter 才不需要从 URL 后缀猜 PAG、SVGA 或 MP4。 // 同时固化格式Flutter 才不需要从 URL 后缀猜 PAG、SVGA 或 MP4。

View File

@ -6,6 +6,22 @@ import (
resourcedomain "hyapp/services/wallet-service/internal/domain/resource" 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) { func TestDecorativeResourceTypesAreEquipable(t *testing.T) {
for _, resourceType := range []string{resourcedomain.TypeProfileCard, resourcedomain.TypeMicSeatIcon, resourcedomain.TypeMicSeatAnimation} { for _, resourceType := range []string{resourcedomain.TypeProfileCard, resourcedomain.TypeMicSeatIcon, resourcedomain.TypeMicSeatAnimation} {
if !isEquipableResourceType(resourceType) { if !isEquipableResourceType(resourceType) {

View File

@ -437,6 +437,9 @@ func (r *Repository) validateAdminVipBenefit(ctx context.Context, tx *sql.Tx, pr
expectedResourceType = resourcedomain.TypeRoomBorder expectedResourceType = resourcedomain.TypeRoomBorder
case ledger.VipBenefitCodeColoredRoomName: case ledger.VipBenefitCodeColoredRoomName:
expectedResourceType = resourcedomain.TypeRoomNameStyle expectedResourceType = resourcedomain.TypeRoomNameStyle
case ledger.VipBenefitCodeGiftTraySkin:
// Fami 客户端的托盘承接器只播放网络 SVGA固定目录类型后执行链路只需透传 wallet 已验证的 animation_url。
expectedResourceType = resourcedomain.TypeGiftTraySkin
} }
} }
if expectedResourceType != "" { 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 { if benefit.ResourceType != "" && resourcedomain.NormalizeResourceType(resource.ResourceType) != benefit.ResourceType {
return xerr.New(xerr.Conflict, "vip benefit resource_type mismatch") 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 _ = level
return nil 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 { func normalizeAdminVipProgramConfig(config ledger.VipProgramConfig) ledger.VipProgramConfig {
config.AppCode = appcode.Normalize(config.AppCode) config.AppCode = appcode.Normalize(config.AppCode)
config.ProgramType = strings.ToLower(strings.TrimSpace(config.ProgramType)) config.ProgramType = strings.ToLower(strings.TrimSpace(config.ProgramType))