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