feat: 完善多应用 VIP 与用户运营能力

This commit is contained in:
zhx 2026-07-11 02:07:57 +08:00
parent c2bbfa87f5
commit 667828f3c2
230 changed files with 38080 additions and 12407 deletions

1
.gitignore vendored
View File

@ -32,3 +32,4 @@ redis-data/
.tmp/
/tmp/
.claude/settings.local.json
backfill-last7

File diff suppressed because it is too large Load Diff

View File

@ -1450,6 +1450,13 @@ message LevelRewardJob {
string failure_reason = 11;
int64 created_at_ms = 12;
int64 updated_at_ms = 13;
string reward_origin = 14;
string temporary_level_id = 15;
int32 grant_generation = 16;
int32 reward_level = 17;
bool permanent = 18;
int64 temporary_expires_at_ms = 19;
string revoke_status = 20;
}
message ListLevelRewardsRequest {
@ -1511,6 +1518,55 @@ message SetUserLevelResponse {
int64 server_time_ms = 8;
}
// AdminLevelTrackProfile keeps the permanent account and the currently effective display overlay separate.
message AdminLevelTrackProfile {
string track = 1;
int32 real_level = 2;
int64 real_total_value = 3;
int32 display_level = 4;
int64 display_value = 5;
string temporary_level_id = 6;
int32 temporary_target_level = 7;
int64 started_at_ms = 8;
int64 expires_at_ms = 9;
}
message AdminUserLevelProfile {
int64 user_id = 1;
repeated AdminLevelTrackProfile tracks = 2;
}
message BatchGetUserLevelAdminProfilesRequest {
RequestMeta meta = 1;
repeated int64 user_ids = 2;
}
message BatchGetUserLevelAdminProfilesResponse {
repeated AdminUserLevelProfile profiles = 1;
int64 server_time_ms = 2;
}
// TemporaryLevelAdjustment is limited to wealth/charm; duration_days must be positive and level is 1..50.
message TemporaryLevelAdjustment {
string track = 1;
int32 level = 2;
int32 duration_days = 3;
}
message AdjustTemporaryUserLevelsRequest {
RequestMeta meta = 1;
string command_id = 2;
int64 user_id = 3;
repeated TemporaryLevelAdjustment adjustments = 4;
int64 operator_admin_id = 5;
string reason = 6;
}
message AdjustTemporaryUserLevelsResponse {
AdminUserLevelProfile profile = 1;
int64 server_time_ms = 2;
}
message RegistrationLevelBadgeGrant {
string track = 1;
int32 level = 2;
@ -2335,6 +2391,7 @@ service MessageActionConfirmService {
service ActivityCronService {
rpc ProcessMessageFanoutBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessLevelRewardBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessTemporaryLevelBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessAchievementRewardBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessRoomTurnoverRewardSettlementBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessWeeklyStarSettlementBatch(CronBatchRequest) returns (CronBatchResponse);
@ -2529,6 +2586,8 @@ service AdminGrowthLevelService {
rpc UpsertLevelTrack(UpsertLevelTrackRequest) returns (UpsertLevelTrackResponse);
rpc UpsertLevelRule(UpsertLevelRuleRequest) returns (UpsertLevelRuleResponse);
rpc UpsertLevelTier(UpsertLevelTierRequest) returns (UpsertLevelTierResponse);
rpc BatchGetUserLevelAdminProfiles(BatchGetUserLevelAdminProfilesRequest) returns (BatchGetUserLevelAdminProfilesResponse);
rpc AdjustTemporaryUserLevels(AdjustTemporaryUserLevelsRequest) returns (AdjustTemporaryUserLevelsResponse);
}
// AdminAchievementService is the admin entry for achievement definitions.

View File

@ -720,6 +720,7 @@ var MessageActionConfirmService_ServiceDesc = grpc.ServiceDesc{
const (
ActivityCronService_ProcessMessageFanoutBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessMessageFanoutBatch"
ActivityCronService_ProcessLevelRewardBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessLevelRewardBatch"
ActivityCronService_ProcessTemporaryLevelBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessTemporaryLevelBatch"
ActivityCronService_ProcessAchievementRewardBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessAchievementRewardBatch"
ActivityCronService_ProcessRoomTurnoverRewardSettlementBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessRoomTurnoverRewardSettlementBatch"
ActivityCronService_ProcessWeeklyStarSettlementBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessWeeklyStarSettlementBatch"
@ -735,6 +736,7 @@ const (
type ActivityCronServiceClient interface {
ProcessMessageFanoutBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ProcessLevelRewardBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ProcessTemporaryLevelBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ProcessAchievementRewardBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ProcessRoomTurnoverRewardSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ProcessWeeklyStarSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
@ -770,6 +772,16 @@ func (c *activityCronServiceClient) ProcessLevelRewardBatch(ctx context.Context,
return out, nil
}
func (c *activityCronServiceClient) ProcessTemporaryLevelBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CronBatchResponse)
err := c.cc.Invoke(ctx, ActivityCronService_ProcessTemporaryLevelBatch_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *activityCronServiceClient) ProcessAchievementRewardBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CronBatchResponse)
@ -828,6 +840,7 @@ func (c *activityCronServiceClient) ProcessAgencyOpeningSettlementBatch(ctx cont
type ActivityCronServiceServer interface {
ProcessMessageFanoutBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ProcessLevelRewardBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ProcessTemporaryLevelBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ProcessAchievementRewardBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ProcessRoomTurnoverRewardSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ProcessWeeklyStarSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
@ -849,6 +862,9 @@ func (UnimplementedActivityCronServiceServer) ProcessMessageFanoutBatch(context.
func (UnimplementedActivityCronServiceServer) ProcessLevelRewardBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ProcessLevelRewardBatch not implemented")
}
func (UnimplementedActivityCronServiceServer) ProcessTemporaryLevelBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ProcessTemporaryLevelBatch not implemented")
}
func (UnimplementedActivityCronServiceServer) ProcessAchievementRewardBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ProcessAchievementRewardBatch not implemented")
}
@ -921,6 +937,24 @@ func _ActivityCronService_ProcessLevelRewardBatch_Handler(srv interface{}, ctx c
return interceptor(ctx, in, info, handler)
}
func _ActivityCronService_ProcessTemporaryLevelBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CronBatchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ActivityCronServiceServer).ProcessTemporaryLevelBatch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ActivityCronService_ProcessTemporaryLevelBatch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ActivityCronServiceServer).ProcessTemporaryLevelBatch(ctx, req.(*CronBatchRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ActivityCronService_ProcessAchievementRewardBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CronBatchRequest)
if err := dec(in); err != nil {
@ -1026,6 +1060,10 @@ var ActivityCronService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ProcessLevelRewardBatch",
Handler: _ActivityCronService_ProcessLevelRewardBatch_Handler,
},
{
MethodName: "ProcessTemporaryLevelBatch",
Handler: _ActivityCronService_ProcessTemporaryLevelBatch_Handler,
},
{
MethodName: "ProcessAchievementRewardBatch",
Handler: _ActivityCronService_ProcessAchievementRewardBatch_Handler,
@ -5846,10 +5884,12 @@ var AdminSevenDayCheckInService_ServiceDesc = grpc.ServiceDesc{
}
const (
AdminGrowthLevelService_ListLevelConfig_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/ListLevelConfig"
AdminGrowthLevelService_UpsertLevelTrack_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelTrack"
AdminGrowthLevelService_UpsertLevelRule_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelRule"
AdminGrowthLevelService_UpsertLevelTier_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelTier"
AdminGrowthLevelService_ListLevelConfig_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/ListLevelConfig"
AdminGrowthLevelService_UpsertLevelTrack_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelTrack"
AdminGrowthLevelService_UpsertLevelRule_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelRule"
AdminGrowthLevelService_UpsertLevelTier_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelTier"
AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/BatchGetUserLevelAdminProfiles"
AdminGrowthLevelService_AdjustTemporaryUserLevels_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/AdjustTemporaryUserLevels"
)
// AdminGrowthLevelServiceClient is the client API for AdminGrowthLevelService service.
@ -5862,6 +5902,8 @@ type AdminGrowthLevelServiceClient interface {
UpsertLevelTrack(ctx context.Context, in *UpsertLevelTrackRequest, opts ...grpc.CallOption) (*UpsertLevelTrackResponse, error)
UpsertLevelRule(ctx context.Context, in *UpsertLevelRuleRequest, opts ...grpc.CallOption) (*UpsertLevelRuleResponse, error)
UpsertLevelTier(ctx context.Context, in *UpsertLevelTierRequest, opts ...grpc.CallOption) (*UpsertLevelTierResponse, error)
BatchGetUserLevelAdminProfiles(ctx context.Context, in *BatchGetUserLevelAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserLevelAdminProfilesResponse, error)
AdjustTemporaryUserLevels(ctx context.Context, in *AdjustTemporaryUserLevelsRequest, opts ...grpc.CallOption) (*AdjustTemporaryUserLevelsResponse, error)
}
type adminGrowthLevelServiceClient struct {
@ -5912,6 +5954,26 @@ func (c *adminGrowthLevelServiceClient) UpsertLevelTier(ctx context.Context, in
return out, nil
}
func (c *adminGrowthLevelServiceClient) BatchGetUserLevelAdminProfiles(ctx context.Context, in *BatchGetUserLevelAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserLevelAdminProfilesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchGetUserLevelAdminProfilesResponse)
err := c.cc.Invoke(ctx, AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminGrowthLevelServiceClient) AdjustTemporaryUserLevels(ctx context.Context, in *AdjustTemporaryUserLevelsRequest, opts ...grpc.CallOption) (*AdjustTemporaryUserLevelsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdjustTemporaryUserLevelsResponse)
err := c.cc.Invoke(ctx, AdminGrowthLevelService_AdjustTemporaryUserLevels_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminGrowthLevelServiceServer is the server API for AdminGrowthLevelService service.
// All implementations must embed UnimplementedAdminGrowthLevelServiceServer
// for forward compatibility.
@ -5922,6 +5984,8 @@ type AdminGrowthLevelServiceServer interface {
UpsertLevelTrack(context.Context, *UpsertLevelTrackRequest) (*UpsertLevelTrackResponse, error)
UpsertLevelRule(context.Context, *UpsertLevelRuleRequest) (*UpsertLevelRuleResponse, error)
UpsertLevelTier(context.Context, *UpsertLevelTierRequest) (*UpsertLevelTierResponse, error)
BatchGetUserLevelAdminProfiles(context.Context, *BatchGetUserLevelAdminProfilesRequest) (*BatchGetUserLevelAdminProfilesResponse, error)
AdjustTemporaryUserLevels(context.Context, *AdjustTemporaryUserLevelsRequest) (*AdjustTemporaryUserLevelsResponse, error)
mustEmbedUnimplementedAdminGrowthLevelServiceServer()
}
@ -5944,6 +6008,12 @@ func (UnimplementedAdminGrowthLevelServiceServer) UpsertLevelRule(context.Contex
func (UnimplementedAdminGrowthLevelServiceServer) UpsertLevelTier(context.Context, *UpsertLevelTierRequest) (*UpsertLevelTierResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpsertLevelTier not implemented")
}
func (UnimplementedAdminGrowthLevelServiceServer) BatchGetUserLevelAdminProfiles(context.Context, *BatchGetUserLevelAdminProfilesRequest) (*BatchGetUserLevelAdminProfilesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchGetUserLevelAdminProfiles not implemented")
}
func (UnimplementedAdminGrowthLevelServiceServer) AdjustTemporaryUserLevels(context.Context, *AdjustTemporaryUserLevelsRequest) (*AdjustTemporaryUserLevelsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AdjustTemporaryUserLevels not implemented")
}
func (UnimplementedAdminGrowthLevelServiceServer) mustEmbedUnimplementedAdminGrowthLevelServiceServer() {
}
func (UnimplementedAdminGrowthLevelServiceServer) testEmbeddedByValue() {}
@ -6038,6 +6108,42 @@ func _AdminGrowthLevelService_UpsertLevelTier_Handler(srv interface{}, ctx conte
return interceptor(ctx, in, info, handler)
}
func _AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchGetUserLevelAdminProfilesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminGrowthLevelServiceServer).BatchGetUserLevelAdminProfiles(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminGrowthLevelServiceServer).BatchGetUserLevelAdminProfiles(ctx, req.(*BatchGetUserLevelAdminProfilesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminGrowthLevelService_AdjustTemporaryUserLevels_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdjustTemporaryUserLevelsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminGrowthLevelServiceServer).AdjustTemporaryUserLevels(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminGrowthLevelService_AdjustTemporaryUserLevels_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminGrowthLevelServiceServer).AdjustTemporaryUserLevels(ctx, req.(*AdjustTemporaryUserLevelsRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminGrowthLevelService_ServiceDesc is the grpc.ServiceDesc for AdminGrowthLevelService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -6061,6 +6167,14 @@ var AdminGrowthLevelService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UpsertLevelTier",
Handler: _AdminGrowthLevelService_UpsertLevelTier_Handler,
},
{
MethodName: "BatchGetUserLevelAdminProfiles",
Handler: _AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_Handler,
},
{
MethodName: "AdjustTemporaryUserLevels",
Handler: _AdminGrowthLevelService_AdjustTemporaryUserLevels_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",

View File

@ -479,6 +479,11 @@ type RoomUserJoined struct {
Avatar string `protobuf:"bytes,9,opt,name=avatar,proto3" json:"avatar,omitempty"`
DisplayUserId string `protobuf:"bytes,10,opt,name=display_user_id,json=displayUserId,proto3" json:"display_user_id,omitempty"`
PrettyDisplayUserId string `protobuf:"bytes,11,opt,name=pretty_display_user_id,json=prettyDisplayUserId,proto3" json:"pretty_display_user_id,omitempty"`
// VIP 字段来自同一次 GetMyVip effective state首次进房 outbox 与重复进房 direct IM 必须保持同一语义。
VipProgramType string `protobuf:"bytes,12,opt,name=vip_program_type,json=vipProgramType,proto3" json:"vip_program_type,omitempty"`
EffectiveVipLevel int32 `protobuf:"varint,13,opt,name=effective_vip_level,json=effectiveVipLevel,proto3" json:"effective_vip_level,omitempty"`
EffectiveVipName string `protobuf:"bytes,14,opt,name=effective_vip_name,json=effectiveVipName,proto3" json:"effective_vip_name,omitempty"`
RoomEntryNoticeEnabled bool `protobuf:"varint,15,opt,name=room_entry_notice_enabled,json=roomEntryNoticeEnabled,proto3" json:"room_entry_notice_enabled,omitempty"`
}
func (x *RoomUserJoined) Reset() {
@ -588,6 +593,34 @@ func (x *RoomUserJoined) GetPrettyDisplayUserId() string {
return ""
}
func (x *RoomUserJoined) GetVipProgramType() string {
if x != nil {
return x.VipProgramType
}
return ""
}
func (x *RoomUserJoined) GetEffectiveVipLevel() int32 {
if x != nil {
return x.EffectiveVipLevel
}
return 0
}
func (x *RoomUserJoined) GetEffectiveVipName() string {
if x != nil {
return x.EffectiveVipName
}
return ""
}
func (x *RoomUserJoined) GetRoomEntryNoticeEnabled() bool {
if x != nil {
return x.RoomEntryNoticeEnabled
}
return false
}
// RoomUserLeft 表达用户离房成功。
type RoomUserLeft struct {
state protoimpl.MessageState
@ -2996,7 +3029,7 @@ var file_proto_events_room_v1_events_proto_rawDesc = []byte{
0x01, 0x28, 0x09, 0x52, 0x0d, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x49, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x5f, 0x61, 0x74,
0x5f, 0x6d, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x65, 0x78, 0x70, 0x69, 0x72,
0x65, 0x73, 0x41, 0x74, 0x4d, 0x73, 0x22, 0xa6, 0x03, 0x0a, 0x0e, 0x52, 0x6f, 0x6f, 0x6d, 0x55,
0x65, 0x73, 0x41, 0x74, 0x4d, 0x73, 0x22, 0xe9, 0x04, 0x0a, 0x0e, 0x52, 0x6f, 0x6f, 0x6d, 0x55,
0x73, 0x65, 0x72, 0x4a, 0x6f, 0x69, 0x6e, 0x65, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72,
0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
@ -3022,470 +3055,482 @@ var file_proto_events_room_v1_events_proto_rawDesc = []byte{
0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x33, 0x0a, 0x16, 0x70, 0x72,
0x65, 0x74, 0x74, 0x79, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x70, 0x72, 0x65, 0x74,
0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22,
0x27, 0x0a, 0x0c, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x4c, 0x65, 0x66, 0x74, 0x12,
0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03,
0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x52, 0x6f, 0x6f, 0x6d,
0x43, 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61,
0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65,
0x61, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73,
0x6f, 0x6e, 0x22, 0x9d, 0x05, 0x0a, 0x0e, 0x52, 0x6f, 0x6f, 0x6d, 0x4d, 0x69, 0x63, 0x43, 0x68,
0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75,
0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63,
0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72,
0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12,
0x1b, 0x0a, 0x09, 0x66, 0x72, 0x6f, 0x6d, 0x5f, 0x73, 0x65, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01,
0x28, 0x05, 0x52, 0x08, 0x66, 0x72, 0x6f, 0x6d, 0x53, 0x65, 0x61, 0x74, 0x12, 0x17, 0x0a, 0x07,
0x74, 0x6f, 0x5f, 0x73, 0x65, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x74,
0x6f, 0x53, 0x65, 0x61, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18,
0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a,
0x0e, 0x6d, 0x69, 0x63, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18,
0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6d, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f,
0x6e, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x5f, 0x73,
0x74, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x75, 0x62, 0x6c,
0x69, 0x73, 0x68, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73,
0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e,
0x12, 0x2e, 0x0a, 0x13, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x5f, 0x64, 0x65, 0x61, 0x64,
0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x6d, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x70,
0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x44, 0x65, 0x61, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x4d, 0x73,
0x12, 0x31, 0x0a, 0x15, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x5f, 0x65, 0x76, 0x65, 0x6e,
0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x5f, 0x6d, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52,
0x12, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x54, 0x69, 0x6d,
0x65, 0x4d, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x63, 0x5f, 0x6d, 0x75, 0x74, 0x65, 0x64,
0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6d, 0x69, 0x63, 0x4d, 0x75, 0x74, 0x65, 0x64,
0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x61, 0x74, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18,
0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x61, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75,
0x73, 0x12, 0x2a, 0x0a, 0x11, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x67, 0x69, 0x66, 0x74,
0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x74, 0x61,
0x72, 0x67, 0x65, 0x74, 0x47, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x27, 0x0a,
0x0f, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65,
0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x4e, 0x69,
0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74,
0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74,
0x61, 0x72, 0x67, 0x65, 0x74, 0x41, 0x76, 0x61, 0x74, 0x61, 0x72, 0x12, 0x33, 0x0a, 0x16, 0x74,
0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x74, 0x61, 0x72,
0x67, 0x65, 0x74, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64,
0x12, 0x40, 0x0a, 0x1d, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x70, 0x72, 0x65, 0x74, 0x74,
0x79, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
0x64, 0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x19, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x50,
0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72,
0x49, 0x64, 0x22, 0x68, 0x0a, 0x11, 0x52, 0x6f, 0x6f, 0x6d, 0x4d, 0x69, 0x63, 0x53, 0x65, 0x61,
0x74, 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b,
0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x73,
0x65, 0x61, 0x74, 0x5f, 0x6e, 0x6f, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x73, 0x65,
0x61, 0x74, 0x4e, 0x6f, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x03,
0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x22, 0x56, 0x0a, 0x16,
0x52, 0x6f, 0x6f, 0x6d, 0x43, 0x68, 0x61, 0x74, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x43,
0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61,
0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e,
0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61,
0x62, 0x6c, 0x65, 0x64, 0x22, 0x7d, 0x0a, 0x13, 0x52, 0x6f, 0x6f, 0x6d, 0x50, 0x61, 0x73, 0x73,
0x77, 0x6f, 0x72, 0x64, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61,
0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12,
0x16, 0x0a, 0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52,
0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62,
0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f,
0x6e, 0x49, 0x64, 0x22, 0x76, 0x0a, 0x10, 0x52, 0x6f, 0x6f, 0x6d, 0x41, 0x64, 0x6d, 0x69, 0x6e,
0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b,
0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74,
0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49,
0x64, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01,
0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x6f, 0x0a, 0x0d, 0x52,
0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x4d, 0x75, 0x74, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d,
0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64,
0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f,
0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74,
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x75, 0x74, 0x65, 0x64, 0x18,
0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x6d, 0x75, 0x74, 0x65, 0x64, 0x22, 0x5a, 0x0a, 0x0e,
0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x4b, 0x69, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x22,
0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12,
0x28, 0x0a, 0x10, 0x76, 0x69, 0x70, 0x5f, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x61, 0x6d, 0x5f, 0x74,
0x79, 0x70, 0x65, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x76, 0x69, 0x70, 0x50, 0x72,
0x6f, 0x67, 0x72, 0x61, 0x6d, 0x54, 0x79, 0x70, 0x65, 0x12, 0x2e, 0x0a, 0x13, 0x65, 0x66, 0x66,
0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x5f, 0x76, 0x69, 0x70, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c,
0x18, 0x0d, 0x20, 0x01, 0x28, 0x05, 0x52, 0x11, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76,
0x65, 0x56, 0x69, 0x70, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x2c, 0x0a, 0x12, 0x65, 0x66, 0x66,
0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x5f, 0x76, 0x69, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18,
0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65,
0x56, 0x69, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x39, 0x0a, 0x19, 0x72, 0x6f, 0x6f, 0x6d, 0x5f,
0x65, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x6e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x5f, 0x65, 0x6e, 0x61,
0x62, 0x6c, 0x65, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x08, 0x52, 0x16, 0x72, 0x6f, 0x6f, 0x6d,
0x45, 0x6e, 0x74, 0x72, 0x79, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c,
0x65, 0x64, 0x22, 0x27, 0x0a, 0x0c, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x4c, 0x65,
0x66, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20,
0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x52,
0x6f, 0x6f, 0x6d, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74,
0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a,
0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72,
0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x9d, 0x05, 0x0a, 0x0e, 0x52, 0x6f, 0x6f, 0x6d, 0x4d, 0x69,
0x63, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f,
0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52,
0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e,
0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72,
0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x66, 0x72, 0x6f, 0x6d, 0x5f, 0x73, 0x65, 0x61, 0x74, 0x18,
0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x66, 0x72, 0x6f, 0x6d, 0x53, 0x65, 0x61, 0x74, 0x12,
0x17, 0x0a, 0x07, 0x74, 0x6f, 0x5f, 0x73, 0x65, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05,
0x52, 0x06, 0x74, 0x6f, 0x53, 0x65, 0x61, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69,
0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e,
0x12, 0x24, 0x0a, 0x0e, 0x6d, 0x69, 0x63, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f,
0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6d, 0x69, 0x63, 0x53, 0x65, 0x73,
0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73,
0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x70,
0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x72,
0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61,
0x73, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x13, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x5f, 0x64,
0x65, 0x61, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x6d, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03,
0x52, 0x11, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x44, 0x65, 0x61, 0x64, 0x6c, 0x69, 0x6e,
0x65, 0x4d, 0x73, 0x12, 0x31, 0x0a, 0x15, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x5f, 0x65,
0x76, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x5f, 0x6d, 0x73, 0x18, 0x0a, 0x20, 0x01,
0x28, 0x03, 0x52, 0x12, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74,
0x54, 0x69, 0x6d, 0x65, 0x4d, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x63, 0x5f, 0x6d, 0x75,
0x74, 0x65, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6d, 0x69, 0x63, 0x4d, 0x75,
0x74, 0x65, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x61, 0x74, 0x5f, 0x73, 0x74, 0x61, 0x74,
0x75, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x61, 0x74, 0x53, 0x74,
0x61, 0x74, 0x75, 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x67,
0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x52,
0x0f, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x47, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65,
0x12, 0x27, 0x0a, 0x0f, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x6e, 0x69, 0x63, 0x6b, 0x6e,
0x61, 0x6d, 0x65, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65,
0x74, 0x4e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x61, 0x72,
0x67, 0x65, 0x74, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09,
0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x41, 0x76, 0x61, 0x74, 0x61, 0x72, 0x12, 0x33,
0x0a, 0x16, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79,
0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13,
0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x40, 0x0a, 0x1d, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x70, 0x72,
0x65, 0x74, 0x74, 0x79, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x19, 0x74, 0x61, 0x72, 0x67,
0x65, 0x74, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55,
0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x68, 0x0a, 0x11, 0x52, 0x6f, 0x6f, 0x6d, 0x4d, 0x69, 0x63,
0x53, 0x65, 0x61, 0x74, 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63,
0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17,
0x0a, 0x07, 0x73, 0x65, 0x61, 0x74, 0x5f, 0x6e, 0x6f, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52,
0x06, 0x73, 0x65, 0x61, 0x74, 0x4e, 0x6f, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65,
0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x22,
0x56, 0x0a, 0x16, 0x52, 0x6f, 0x6f, 0x6d, 0x43, 0x68, 0x61, 0x74, 0x45, 0x6e, 0x61, 0x62, 0x6c,
0x65, 0x64, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74,
0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a,
0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07,
0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x7d, 0x0a, 0x13, 0x52, 0x6f, 0x6f, 0x6d, 0x50,
0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x22,
0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72,
0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67,
0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x5c, 0x0a, 0x10, 0x52, 0x6f, 0x6f, 0x6d,
0x55, 0x73, 0x65, 0x72, 0x55, 0x6e, 0x62, 0x61, 0x6e, 0x6e, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d,
0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64,
0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01,
0x28, 0x08, 0x52, 0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69,
0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18,
0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65,
0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x76, 0x0a, 0x10, 0x52, 0x6f, 0x6f, 0x6d, 0x41, 0x64,
0x6d, 0x69, 0x6e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63,
0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24,
0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73,
0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18,
0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x6f,
0x0a, 0x0d, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x4d, 0x75, 0x74, 0x65, 0x64, 0x12,
0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72,
0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x75, 0x74,
0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x6d, 0x75, 0x74, 0x65, 0x64, 0x22,
0x5a, 0x0a, 0x0e, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x4b, 0x69, 0x63, 0x6b, 0x65,
0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55,
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74,
0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x5c, 0x0a, 0x10, 0x52,
0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x55, 0x6e, 0x62, 0x61, 0x6e, 0x6e, 0x65, 0x64, 0x12,
0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72,
0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xf1, 0x09, 0x0a, 0x0c, 0x52, 0x6f,
0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x53, 0x65, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65,
0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64,
0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f,
0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74,
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xf1, 0x09, 0x0a, 0x0c, 0x52, 0x6f, 0x6f, 0x6d, 0x47,
0x69, 0x66, 0x74, 0x53, 0x65, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65,
0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52,
0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a,
0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18,
0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03,
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x69, 0x66, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a,
0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05,
0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x67,
0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52,
0x09, 0x67, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x62, 0x69,
0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x5f, 0x69, 0x64,
0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x52,
0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69,
0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69,
0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f,
0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e,
0x64, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x09,
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a,
0x63, 0x6f, 0x69, 0x6e, 0x5f, 0x73, 0x70, 0x65, 0x6e, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03,
0x52, 0x09, 0x63, 0x6f, 0x69, 0x6e, 0x53, 0x70, 0x65, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63,
0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, 0x52,
0x09, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65,
0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x72,
0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x67, 0x69, 0x66, 0x74, 0x5f,
0x74, 0x79, 0x70, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52,
0x0c, 0x67, 0x69, 0x66, 0x74, 0x54, 0x79, 0x70, 0x65, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x28, 0x0a,
0x10, 0x63, 0x70, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x79, 0x70,
0x65, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x63, 0x70, 0x52, 0x65, 0x6c, 0x61, 0x74,
0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x69, 0x66, 0x74, 0x5f,
0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x67, 0x69, 0x66, 0x74,
0x4e, 0x61, 0x6d, 0x65, 0x12, 0x22, 0x0a, 0x0d, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x63, 0x6f,
0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x67, 0x69, 0x66,
0x74, 0x49, 0x63, 0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x12, 0x2c, 0x0a, 0x12, 0x67, 0x69, 0x66, 0x74,
0x5f, 0x61, 0x6e, 0x69, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x11,
0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x67, 0x69, 0x66, 0x74, 0x41, 0x6e, 0x69, 0x6d, 0x61, 0x74,
0x69, 0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x12, 0x2a, 0x0a, 0x11, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74,
0x5f, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x12, 0x20, 0x01, 0x28,
0x03, 0x52, 0x0f, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x47, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c,
0x75, 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x65, 0x66, 0x66, 0x65, 0x63,
0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x18, 0x13, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0f, 0x67,
0x69, 0x66, 0x74, 0x45, 0x66, 0x66, 0x65, 0x63, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x12, 0x22,
0x0a, 0x0d, 0x69, 0x73, 0x5f, 0x72, 0x6f, 0x62, 0x6f, 0x74, 0x5f, 0x67, 0x69, 0x66, 0x74, 0x18,
0x14, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x52, 0x6f, 0x62, 0x6f, 0x74, 0x47, 0x69,
0x66, 0x74, 0x12, 0x30, 0x0a, 0x14, 0x73, 0x79, 0x6e, 0x74, 0x68, 0x65, 0x74, 0x69, 0x63, 0x5f,
0x6c, 0x75, 0x63, 0x6b, 0x79, 0x5f, 0x67, 0x69, 0x66, 0x74, 0x18, 0x15, 0x20, 0x01, 0x28, 0x08,
0x52, 0x12, 0x73, 0x79, 0x6e, 0x74, 0x68, 0x65, 0x74, 0x69, 0x63, 0x4c, 0x75, 0x63, 0x6b, 0x79,
0x47, 0x69, 0x66, 0x74, 0x12, 0x2f, 0x0a, 0x14, 0x72, 0x65, 0x61, 0x6c, 0x5f, 0x72, 0x6f, 0x6f,
0x6d, 0x5f, 0x72, 0x6f, 0x62, 0x6f, 0x74, 0x5f, 0x67, 0x69, 0x66, 0x74, 0x18, 0x16, 0x20, 0x01,
0x28, 0x08, 0x52, 0x11, 0x72, 0x65, 0x61, 0x6c, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x62, 0x6f,
0x74, 0x47, 0x69, 0x66, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f,
0x6e, 0x61, 0x6d, 0x65, 0x18, 0x17, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x6e, 0x64,
0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72,
0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x18, 0x18, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73,
0x65, 0x6e, 0x64, 0x65, 0x72, 0x41, 0x76, 0x61, 0x74, 0x61, 0x72, 0x12, 0x33, 0x0a, 0x16, 0x73,
0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x73, 0x65, 0x6e,
0x64, 0x65, 0x72, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64,
0x12, 0x40, 0x0a, 0x1d, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x72, 0x65, 0x74, 0x74,
0x79, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
0x64, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x19, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x50,
0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72,
0x49, 0x64, 0x12, 0x2b, 0x0a, 0x11, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x5f, 0x6e,
0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x1b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x72,
0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x4e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x12,
0x27, 0x0a, 0x0f, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x5f, 0x61, 0x76, 0x61, 0x74,
0x61, 0x72, 0x18, 0x1c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76,
0x65, 0x72, 0x41, 0x76, 0x61, 0x74, 0x61, 0x72, 0x12, 0x37, 0x0a, 0x18, 0x72, 0x65, 0x63, 0x65,
0x69, 0x76, 0x65, 0x72, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x1d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x72, 0x65, 0x63, 0x65,
0x69, 0x76, 0x65, 0x72, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49,
0x64, 0x12, 0x44, 0x0a, 0x1f, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x5f, 0x70, 0x72,
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69,
0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x69, 0x66, 0x74, 0x49, 0x64, 0x12,
0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20,
0x01, 0x28, 0x05, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d,
0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x05, 0x20, 0x01,
0x28, 0x03, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x2c, 0x0a,
0x12, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74,
0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x62, 0x69, 0x6c, 0x6c, 0x69,
0x6e, 0x67, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76,
0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64,
0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52,
0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61,
0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d,
0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69,
0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12,
0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x69, 0x6e, 0x5f, 0x73, 0x70, 0x65, 0x6e, 0x74, 0x18, 0x0a, 0x20,
0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x6f, 0x69, 0x6e, 0x53, 0x70, 0x65, 0x6e, 0x74, 0x12, 0x1d,
0x0a, 0x0a, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x0b, 0x20, 0x01,
0x28, 0x03, 0x52, 0x09, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x49, 0x64, 0x12, 0x1b, 0x0a,
0x09, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x03,
0x52, 0x08, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x67, 0x69,
0x66, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x0d, 0x20, 0x01,
0x28, 0x09, 0x52, 0x0c, 0x67, 0x69, 0x66, 0x74, 0x54, 0x79, 0x70, 0x65, 0x43, 0x6f, 0x64, 0x65,
0x12, 0x28, 0x0a, 0x10, 0x63, 0x70, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
0x74, 0x79, 0x70, 0x65, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x63, 0x70, 0x52, 0x65,
0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x69,
0x66, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x67,
0x69, 0x66, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x22, 0x0a, 0x0d, 0x67, 0x69, 0x66, 0x74, 0x5f,
0x69, 0x63, 0x6f, 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b,
0x67, 0x69, 0x66, 0x74, 0x49, 0x63, 0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x12, 0x2c, 0x0a, 0x12, 0x67,
0x69, 0x66, 0x74, 0x5f, 0x61, 0x6e, 0x69, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x75, 0x72,
0x6c, 0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x67, 0x69, 0x66, 0x74, 0x41, 0x6e, 0x69,
0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x12, 0x2a, 0x0a, 0x11, 0x74, 0x61, 0x72,
0x67, 0x65, 0x74, 0x5f, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x12,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x47, 0x69, 0x66, 0x74,
0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x65, 0x66,
0x66, 0x65, 0x63, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x18, 0x13, 0x20, 0x03, 0x28, 0x09,
0x52, 0x0f, 0x67, 0x69, 0x66, 0x74, 0x45, 0x66, 0x66, 0x65, 0x63, 0x74, 0x54, 0x79, 0x70, 0x65,
0x73, 0x12, 0x22, 0x0a, 0x0d, 0x69, 0x73, 0x5f, 0x72, 0x6f, 0x62, 0x6f, 0x74, 0x5f, 0x67, 0x69,
0x66, 0x74, 0x18, 0x14, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x52, 0x6f, 0x62, 0x6f,
0x74, 0x47, 0x69, 0x66, 0x74, 0x12, 0x30, 0x0a, 0x14, 0x73, 0x79, 0x6e, 0x74, 0x68, 0x65, 0x74,
0x69, 0x63, 0x5f, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x5f, 0x67, 0x69, 0x66, 0x74, 0x18, 0x15, 0x20,
0x01, 0x28, 0x08, 0x52, 0x12, 0x73, 0x79, 0x6e, 0x74, 0x68, 0x65, 0x74, 0x69, 0x63, 0x4c, 0x75,
0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x12, 0x2f, 0x0a, 0x14, 0x72, 0x65, 0x61, 0x6c, 0x5f,
0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x72, 0x6f, 0x62, 0x6f, 0x74, 0x5f, 0x67, 0x69, 0x66, 0x74, 0x18,
0x16, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x72, 0x65, 0x61, 0x6c, 0x52, 0x6f, 0x6f, 0x6d, 0x52,
0x6f, 0x62, 0x6f, 0x74, 0x47, 0x69, 0x66, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x6e, 0x64,
0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x17, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73,
0x65, 0x6e, 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x6e,
0x64, 0x65, 0x72, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x18, 0x18, 0x20, 0x01, 0x28, 0x09,
0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x41, 0x76, 0x61, 0x74, 0x61, 0x72, 0x12, 0x33,
0x0a, 0x16, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79,
0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13,
0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x40, 0x0a, 0x1d, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x72,
0x65, 0x74, 0x74, 0x79, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x1e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x1b, 0x72, 0x65, 0x63, 0x65,
0x69, 0x76, 0x65, 0x72, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61,
0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c,
0x61, 0x79, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x1f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64,
0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4d, 0x6f, 0x64, 0x65, 0x22, 0xdc, 0x04, 0x0a, 0x18, 0x52,
0x6f, 0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x75, 0x63, 0x6b,
0x79, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c,
0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65,
0x64, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72, 0x61, 0x77, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01,
0x28, 0x09, 0x52, 0x06, 0x64, 0x72, 0x61, 0x77, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f,
0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f,
0x6c, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c,
0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20,
0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x69, 0x66, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x72,
0x75, 0x6c, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28,
0x03, 0x52, 0x0b, 0x72, 0x75, 0x6c, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x27,
0x0a, 0x0f, 0x65, 0x78, 0x70, 0x65, 0x72, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x6f,
0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x65, 0x78, 0x70, 0x65, 0x72, 0x69, 0x65,
0x6e, 0x63, 0x65, 0x50, 0x6f, 0x6f, 0x6c, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63,
0x74, 0x65, 0x64, 0x5f, 0x74, 0x69, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0e, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x54, 0x69, 0x65, 0x72, 0x49,
0x64, 0x12, 0x25, 0x0a, 0x0e, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x5f,
0x70, 0x70, 0x6d, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6d, 0x75, 0x6c, 0x74, 0x69,
0x70, 0x6c, 0x69, 0x65, 0x72, 0x50, 0x70, 0x6d, 0x12, 0x2a, 0x0a, 0x11, 0x62, 0x61, 0x73, 0x65,
0x5f, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x0a, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0f, 0x62, 0x61, 0x73, 0x65, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x43,
0x6f, 0x69, 0x6e, 0x73, 0x12, 0x34, 0x0a, 0x16, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76,
0x65, 0x5f, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x0b,
0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x52,
0x65, 0x77, 0x61, 0x72, 0x64, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65,
0x77, 0x61, 0x72, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0c, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12,
0x25, 0x0a, 0x0e, 0x73, 0x74, 0x61, 0x67, 0x65, 0x5f, 0x66, 0x65, 0x65, 0x64, 0x62, 0x61, 0x63,
0x6b, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x74, 0x61, 0x67, 0x65, 0x46, 0x65,
0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x12, 0x27, 0x0a, 0x0f, 0x68, 0x69, 0x67, 0x68, 0x5f, 0x6d,
0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52,
0x0e, 0x68, 0x69, 0x67, 0x68, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x12,
0x22, 0x0a, 0x0d, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73,
0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41,
0x74, 0x4d, 0x73, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x10, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72,
0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x96, 0x04, 0x0a, 0x13, 0x52, 0x6f,
0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x54, 0x61, 0x72, 0x67, 0x65,
0x74, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72,
0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65,
0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x2b, 0x0a, 0x11, 0x72, 0x65, 0x63, 0x65, 0x69,
0x76, 0x65, 0x72, 0x5f, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01,
0x28, 0x09, 0x52, 0x10, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x4e, 0x69, 0x63, 0x6b,
0x6e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72,
0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x72,
0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x41, 0x76, 0x61, 0x74, 0x61, 0x72, 0x12, 0x37, 0x0a,
0x18, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61,
0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52,
0x15, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79,
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x44, 0x0a, 0x1f, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76,
0x65, 0x72, 0x5f, 0x70, 0x72, 0x65, 0x74, 0x74, 0x79, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61,
0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52,
0x1b, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44,
0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a,
0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03,
0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x74,
0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65,
0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x47, 0x69,
0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x69, 0x6e, 0x5f,
0x73, 0x70, 0x65, 0x6e, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x6f, 0x69,
0x6e, 0x53, 0x70, 0x65, 0x6e, 0x74, 0x12, 0x2c, 0x0a, 0x12, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e,
0x67, 0x5f, 0x72, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x09, 0x20, 0x01,
0x28, 0x09, 0x52, 0x10, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x63, 0x65, 0x69,
0x70, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f,
0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e,
0x64, 0x49, 0x64, 0x12, 0x4d, 0x0a, 0x0a, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x5f, 0x67, 0x69, 0x66,
0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2e, 0x72, 0x6f, 0x6f, 0x6d, 0x2e, 0x76, 0x31, 0x2e, 0x52,
0x6f, 0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x75, 0x63, 0x6b,
0x79, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x09, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69,
0x66, 0x74, 0x22, 0x89, 0x07, 0x0a, 0x11, 0x52, 0x6f, 0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x42,
0x61, 0x74, 0x63, 0x68, 0x53, 0x65, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64,
0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1f,
0x0a, 0x0b, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20,
0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12,
0x23, 0x0a, 0x0d, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72,
0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x41, 0x76,
0x61, 0x74, 0x61, 0x72, 0x12, 0x33, 0x0a, 0x16, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x64,
0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04,
0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x44, 0x69, 0x73, 0x70,
0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x40, 0x0a, 0x1d, 0x73, 0x65, 0x6e,
0x64, 0x65, 0x72, 0x5f, 0x70, 0x72, 0x65, 0x74, 0x74, 0x79, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c,
0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09,
0x52, 0x19, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69,
0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x67,
0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x69,
0x66, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75,
0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43, 0x6f,
0x75, 0x6e, 0x74, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x67, 0x69, 0x66,
0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x74,
0x6f, 0x74, 0x61, 0x6c, 0x47, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x28, 0x0a,
0x10, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x5f, 0x73, 0x70, 0x65, 0x6e,
0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x43, 0x6f,
0x69, 0x6e, 0x53, 0x70, 0x65, 0x6e, 0x74, 0x12, 0x2c, 0x0a, 0x12, 0x62, 0x69, 0x6c, 0x6c, 0x69,
0x6e, 0x67, 0x5f, 0x72, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20,
0x01, 0x28, 0x09, 0x52, 0x10, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x63, 0x65,
0x69, 0x70, 0x74, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x68, 0x65,
0x61, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x72, 0x6f, 0x6f, 0x6d, 0x48, 0x65,
0x61, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20,
0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x67,
0x69, 0x66, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x0d, 0x20,
0x01, 0x28, 0x09, 0x52, 0x0c, 0x67, 0x69, 0x66, 0x74, 0x54, 0x79, 0x70, 0x65, 0x43, 0x6f, 0x64,
0x65, 0x12, 0x28, 0x0a, 0x10, 0x63, 0x70, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e,
0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x63, 0x70, 0x52,
0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x67,
0x69, 0x66, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
0x67, 0x69, 0x66, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x22, 0x0a, 0x0d, 0x67, 0x69, 0x66, 0x74,
0x5f, 0x69, 0x63, 0x6f, 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52,
0x0b, 0x67, 0x69, 0x66, 0x74, 0x49, 0x63, 0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x12, 0x2c, 0x0a, 0x12,
0x67, 0x69, 0x66, 0x74, 0x5f, 0x61, 0x6e, 0x69, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x75,
0x72, 0x6c, 0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x67, 0x69, 0x66, 0x74, 0x41, 0x6e,
0x69, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x12, 0x2a, 0x0a, 0x11, 0x67, 0x69,
0x66, 0x74, 0x5f, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x18,
0x12, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0f, 0x67, 0x69, 0x66, 0x74, 0x45, 0x66, 0x66, 0x65, 0x63,
0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74,
0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x13, 0x20, 0x03, 0x28, 0x03, 0x52,
0x0d, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x12, 0x43,
0x0a, 0x07, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x73, 0x18, 0x14, 0x20, 0x03, 0x28, 0x0b, 0x32,
0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2e, 0x72,
0x6f, 0x6f, 0x6d, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x6f, 0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x42,
0x61, 0x74, 0x63, 0x68, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x52, 0x07, 0x74, 0x61, 0x72, 0x67,
0x65, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69,
0x64, 0x18, 0x15, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64,
0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65,
0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x16, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76,
0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x80,
0x04, 0x0a, 0x17, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x62, 0x6f, 0x74, 0x4c, 0x75, 0x63, 0x6b,
0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x6e, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72,
0x61, 0x77, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x72, 0x61,
0x77, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69,
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64,
0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64,
0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67,
0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17,
0x0a, 0x07, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x19, 0x73, 0x65, 0x6e, 0x64,
0x65, 0x72, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55,
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x2b, 0x0a, 0x11, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65,
0x72, 0x5f, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x1b, 0x20, 0x01, 0x28, 0x09,
0x52, 0x10, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x4e, 0x69, 0x63, 0x6b, 0x6e, 0x61,
0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x5f, 0x61,
0x76, 0x61, 0x74, 0x61, 0x72, 0x18, 0x1c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x72, 0x65, 0x63,
0x65, 0x69, 0x76, 0x65, 0x72, 0x41, 0x76, 0x61, 0x74, 0x61, 0x72, 0x12, 0x37, 0x0a, 0x18, 0x72,
0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x1d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x72,
0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73,
0x65, 0x72, 0x49, 0x64, 0x12, 0x44, 0x0a, 0x1f, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72,
0x5f, 0x70, 0x72, 0x65, 0x74, 0x74, 0x79, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x1e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x1b, 0x72,
0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73,
0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69,
0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x1f, 0x20, 0x01, 0x28, 0x09,
0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4d, 0x6f, 0x64, 0x65, 0x22, 0xdc, 0x04,
0x0a, 0x18, 0x52, 0x6f, 0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x4c,
0x75, 0x63, 0x6b, 0x79, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e,
0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61,
0x62, 0x6c, 0x65, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72, 0x61, 0x77, 0x5f, 0x69, 0x64, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x72, 0x61, 0x77, 0x49, 0x64, 0x12, 0x1d, 0x0a,
0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28,
0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07,
0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70,
0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64,
0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x69, 0x66, 0x74, 0x49, 0x64, 0x12, 0x21,
0x0a, 0x0c, 0x72, 0x75, 0x6c, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x06,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x72, 0x75, 0x6c, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
0x6e, 0x12, 0x27, 0x0a, 0x0f, 0x65, 0x78, 0x70, 0x65, 0x72, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x5f,
0x70, 0x6f, 0x6f, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x65, 0x78, 0x70, 0x65,
0x72, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x50, 0x6f, 0x6f, 0x6c, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x65,
0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x74, 0x69, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x08,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x54, 0x69,
0x65, 0x72, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69,
0x65, 0x72, 0x5f, 0x70, 0x70, 0x6d, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6d, 0x75,
0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x50, 0x70, 0x6d, 0x12, 0x2a, 0x0a, 0x11, 0x62,
0x61, 0x73, 0x65, 0x5f, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73,
0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x62, 0x61, 0x73, 0x65, 0x52, 0x65, 0x77, 0x61,
0x72, 0x64, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x34, 0x0a, 0x16, 0x65, 0x66, 0x66, 0x65, 0x63,
0x74, 0x69, 0x76, 0x65, 0x5f, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e,
0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69,
0x76, 0x65, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x23, 0x0a,
0x0d, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x0c,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x53, 0x74, 0x61, 0x74,
0x75, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x74, 0x61, 0x67, 0x65, 0x5f, 0x66, 0x65, 0x65, 0x64,
0x62, 0x61, 0x63, 0x6b, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x74, 0x61, 0x67,
0x65, 0x46, 0x65, 0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x12, 0x27, 0x0a, 0x0f, 0x68, 0x69, 0x67,
0x68, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x18, 0x0e, 0x20, 0x01,
0x28, 0x08, 0x52, 0x0e, 0x68, 0x69, 0x67, 0x68, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69,
0x65, 0x72, 0x12, 0x22, 0x0a, 0x0d, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74,
0x5f, 0x6d, 0x73, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74,
0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74,
0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x10, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c,
0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x96, 0x04, 0x0a,
0x13, 0x52, 0x6f, 0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x54, 0x61,
0x72, 0x67, 0x65, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75,
0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61,
0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x2b, 0x0a, 0x11, 0x72, 0x65,
0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x5f, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x4e,
0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x72, 0x65, 0x63, 0x65, 0x69,
0x76, 0x65, 0x72, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
0x52, 0x0e, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x41, 0x76, 0x61, 0x74, 0x61, 0x72,
0x12, 0x37, 0x0a, 0x18, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x5f, 0x64, 0x69, 0x73,
0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01,
0x28, 0x09, 0x52, 0x15, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x44, 0x69, 0x73, 0x70,
0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x44, 0x0a, 0x1f, 0x72, 0x65, 0x63,
0x65, 0x69, 0x76, 0x65, 0x72, 0x5f, 0x70, 0x72, 0x65, 0x74, 0x74, 0x79, 0x5f, 0x64, 0x69, 0x73,
0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01,
0x28, 0x09, 0x52, 0x1b, 0x72, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, 0x72, 0x50, 0x72, 0x65, 0x74,
0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12,
0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x06, 0x20,
0x01, 0x28, 0x03, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x2a,
0x0a, 0x11, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61,
0x6c, 0x75, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x74, 0x61, 0x72, 0x67, 0x65,
0x74, 0x47, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f,
0x69, 0x6e, 0x5f, 0x73, 0x70, 0x65, 0x6e, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09,
0x63, 0x6f, 0x69, 0x6e, 0x53, 0x70, 0x65, 0x6e, 0x74, 0x12, 0x2c, 0x0a, 0x12, 0x62, 0x69, 0x6c,
0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x5f, 0x69, 0x64, 0x18,
0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x52, 0x65,
0x63, 0x65, 0x69, 0x70, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61,
0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d,
0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x4d, 0x0a, 0x0a, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x5f,
0x67, 0x69, 0x66, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2e, 0x72, 0x6f, 0x6f, 0x6d, 0x2e, 0x76,
0x31, 0x2e, 0x52, 0x6f, 0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x4c,
0x75, 0x63, 0x6b, 0x79, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x09, 0x6c, 0x75, 0x63, 0x6b,
0x79, 0x47, 0x69, 0x66, 0x74, 0x22, 0x89, 0x07, 0x0a, 0x11, 0x52, 0x6f, 0x6f, 0x6d, 0x47, 0x69,
0x66, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x53, 0x65, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x73,
0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49,
0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65,
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x4e, 0x61,
0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x61, 0x76, 0x61,
0x74, 0x61, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65,
0x72, 0x41, 0x76, 0x61, 0x74, 0x61, 0x72, 0x12, 0x33, 0x0a, 0x16, 0x73, 0x65, 0x6e, 0x64, 0x65,
0x72, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x44,
0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x40, 0x0a, 0x1d,
0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x72, 0x65, 0x74, 0x74, 0x79, 0x5f, 0x64, 0x69,
0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20,
0x01, 0x28, 0x09, 0x52, 0x19, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x50, 0x72, 0x65, 0x74, 0x74,
0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17,
0x0a, 0x07, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52,
0x06, 0x67, 0x69, 0x66, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f,
0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x67, 0x69, 0x66,
0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69,
0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12,
0x25, 0x0a, 0x0e, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x5f, 0x70, 0x70,
0x6d, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c,
0x69, 0x65, 0x72, 0x50, 0x70, 0x6d, 0x12, 0x2a, 0x0a, 0x11, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x72,
0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28,
0x03, 0x52, 0x0f, 0x62, 0x61, 0x73, 0x65, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x43, 0x6f, 0x69,
0x6e, 0x73, 0x12, 0x34, 0x0a, 0x16, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x5f,
0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x0a, 0x20, 0x01,
0x28, 0x03, 0x52, 0x14, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x52, 0x65, 0x77,
0x61, 0x72, 0x64, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x22, 0x0a, 0x0d, 0x63, 0x72, 0x65, 0x61,
0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, 0x52,
0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x2a, 0x0a, 0x11,
0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69,
0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65,
0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x72,
0x6f, 0x62, 0x6f, 0x74, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, 0x52, 0x6f,
0x62, 0x6f, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x79, 0x6e, 0x74, 0x68, 0x65, 0x74, 0x69, 0x63,
0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x79, 0x6e, 0x74, 0x68, 0x65, 0x74, 0x69,
0x63, 0x22, 0x4a, 0x0a, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x74, 0x43, 0x68, 0x61,
0x6e, 0x67, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x01, 0x20,
0x01, 0x28, 0x03, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x75,
0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0b, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x48, 0x65, 0x61, 0x74, 0x22, 0x5f, 0x0a,
0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x61, 0x6e, 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64,
0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f,
0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x12,
0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20,
0x01, 0x28, 0x03, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x94,
0x02, 0x0a, 0x15, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x77,
0x61, 0x72, 0x64, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x77, 0x61,
0x72, 0x64, 0x5f, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x72,
0x65, 0x77, 0x61, 0x72, 0x64, 0x52, 0x6f, 0x6c, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72,
0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x69, 0x74, 0x65,
0x6d, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x77, 0x61,
0x72, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x72, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f,
0x75, 0x70, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f,
0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70,
0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x63, 0x6f, 0x6e, 0x5f,
0x75, 0x72, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x69, 0x63, 0x6f, 0x6e, 0x55,
0x72, 0x6c, 0x12, 0x19, 0x0a, 0x08, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x07,
0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a,
0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73,
0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0xeb, 0x03, 0x0a, 0x15, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f,
0x63, 0x6b, 0x65, 0x74, 0x46, 0x75, 0x65, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12,
0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05,
0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6c, 0x65, 0x76,
0x65, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x64, 0x64, 0x65, 0x64, 0x5f, 0x66, 0x75, 0x65, 0x6c,
0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x61, 0x64, 0x64, 0x65, 0x64, 0x46, 0x75, 0x65,
0x6c, 0x12, 0x30, 0x0a, 0x14, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x5f, 0x61,
0x64, 0x64, 0x65, 0x64, 0x5f, 0x66, 0x75, 0x65, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52,
0x12, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, 0x64, 0x64, 0x65, 0x64, 0x46,
0x75, 0x65, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x76, 0x65, 0x72, 0x66, 0x6c, 0x6f, 0x77, 0x5f,
0x66, 0x75, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6f, 0x76, 0x65, 0x72,
0x66, 0x6c, 0x6f, 0x77, 0x46, 0x75, 0x65, 0x6c, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72, 0x72,
0x65, 0x6e, 0x74, 0x5f, 0x66, 0x75, 0x65, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b,
0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x46, 0x75, 0x65, 0x6c, 0x12, 0x25, 0x0a, 0x0e, 0x66,
0x75, 0x65, 0x6c, 0x5f, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x07, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0d, 0x66, 0x75, 0x65, 0x6c, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f,
0x6c, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x08, 0x20, 0x01,
0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1e, 0x0a, 0x0b, 0x72, 0x65,
0x73, 0x65, 0x74, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52,
0x09, 0x72, 0x65, 0x73, 0x65, 0x74, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65,
0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64,
0x12, 0x17, 0x0a, 0x07, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28,
0x09, 0x52, 0x06, 0x67, 0x69, 0x66, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66,
0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x67,
0x69, 0x66, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d,
0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f,
0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62,
0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x0e, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f,
0x6e, 0x49, 0x64, 0x22, 0x82, 0x04, 0x0a, 0x11, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6b,
0x65, 0x74, 0x49, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x63,
0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f,
0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18,
0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x21, 0x0a, 0x0c,
0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x66, 0x75, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0b, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x46, 0x75, 0x65, 0x6c, 0x12,
0x25, 0x0a, 0x0e, 0x66, 0x75, 0x65, 0x6c, 0x5f, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c,
0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x66, 0x75, 0x65, 0x6c, 0x54, 0x68, 0x72,
0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x69, 0x67, 0x6e, 0x69, 0x74, 0x65,
0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x69,
0x67, 0x6e, 0x69, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x20, 0x0a, 0x0c, 0x6c, 0x61,
0x75, 0x6e, 0x63, 0x68, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0a, 0x6c, 0x61, 0x75, 0x6e, 0x63, 0x68, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x27, 0x0a, 0x0f,
0x62, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x5f, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x18,
0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x62, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74,
0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65,
0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03,
0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x67, 0x69, 0x66,
0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f,
0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0e, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x47, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65,
0x12, 0x28, 0x0a, 0x10, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x5f, 0x73,
0x70, 0x65, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x74, 0x6f, 0x74, 0x61,
0x6c, 0x43, 0x6f, 0x69, 0x6e, 0x53, 0x70, 0x65, 0x6e, 0x74, 0x12, 0x2c, 0x0a, 0x12, 0x62, 0x69,
0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x5f, 0x69, 0x64,
0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x52,
0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6f, 0x6d,
0x5f, 0x68, 0x65, 0x61, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x72, 0x6f, 0x6f,
0x6d, 0x48, 0x65, 0x61, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64,
0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12, 0x24,
0x0a, 0x0e, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65,
0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x67, 0x69, 0x66, 0x74, 0x54, 0x79, 0x70, 0x65,
0x43, 0x6f, 0x64, 0x65, 0x12, 0x28, 0x0a, 0x10, 0x63, 0x70, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74,
0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e,
0x63, 0x70, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1b,
0x0a, 0x09, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0f, 0x20, 0x01, 0x28,
0x09, 0x52, 0x08, 0x67, 0x69, 0x66, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x22, 0x0a, 0x0d, 0x67,
0x69, 0x66, 0x74, 0x5f, 0x69, 0x63, 0x6f, 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x10, 0x20, 0x01,
0x28, 0x09, 0x52, 0x0b, 0x67, 0x69, 0x66, 0x74, 0x49, 0x63, 0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x12,
0x2c, 0x0a, 0x12, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x61, 0x6e, 0x69, 0x6d, 0x61, 0x74, 0x69, 0x6f,
0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x67, 0x69, 0x66,
0x74, 0x41, 0x6e, 0x69, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x12, 0x2a, 0x0a,
0x11, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x5f, 0x74, 0x79, 0x70,
0x65, 0x73, 0x18, 0x12, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0f, 0x67, 0x69, 0x66, 0x74, 0x45, 0x66,
0x66, 0x65, 0x63, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x74, 0x61, 0x72,
0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x13, 0x20, 0x03,
0x28, 0x03, 0x52, 0x0d, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64,
0x73, 0x12, 0x43, 0x0a, 0x07, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x73, 0x18, 0x14, 0x20, 0x03,
0x28, 0x0b, 0x32, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x65, 0x76, 0x65, 0x6e, 0x74,
0x73, 0x2e, 0x72, 0x6f, 0x6f, 0x6d, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x6f, 0x6f, 0x6d, 0x47, 0x69,
0x66, 0x74, 0x42, 0x61, 0x74, 0x63, 0x68, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x52, 0x07, 0x74,
0x61, 0x72, 0x67, 0x65, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e,
0x64, 0x5f, 0x69, 0x64, 0x18, 0x15, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d,
0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65,
0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x16, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49,
0x64, 0x12, 0x20, 0x0a, 0x0c, 0x74, 0x6f, 0x70, 0x31, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x74, 0x6f, 0x70, 0x31, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0f, 0x69, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x72, 0x5f, 0x75,
0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x69, 0x67,
0x6e, 0x69, 0x74, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63,
0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52,
0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x72, 0x6f,
0x6f, 0x6d, 0x5f, 0x73, 0x68, 0x6f, 0x72, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0b, 0x72, 0x6f, 0x6f, 0x6d, 0x53, 0x68, 0x6f, 0x72, 0x74, 0x49, 0x64, 0x12, 0x28,
0x0a, 0x10, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x5f, 0x75,
0x72, 0x6c, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74,
0x43, 0x6f, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12, 0x1e, 0x0a, 0x0b, 0x72, 0x65, 0x73, 0x65,
0x74, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x72,
0x65, 0x73, 0x65, 0x74, 0x41, 0x74, 0x4d, 0x73, 0x22, 0xe6, 0x02, 0x0a, 0x12, 0x52, 0x6f, 0x6f,
0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x4c, 0x61, 0x75, 0x6e, 0x63, 0x68, 0x65, 0x64, 0x12,
0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05,
0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6c, 0x65, 0x76,
0x65, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c,
0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x6e, 0x65, 0x78, 0x74, 0x4c, 0x65, 0x76, 0x65,
0x6c, 0x12, 0x24, 0x0a, 0x0e, 0x6c, 0x61, 0x75, 0x6e, 0x63, 0x68, 0x65, 0x64, 0x5f, 0x61, 0x74,
0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6c, 0x61, 0x75, 0x6e, 0x63,
0x68, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x1e, 0x0a, 0x0b, 0x72, 0x65, 0x73, 0x65, 0x74,
0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x72, 0x65,
0x73, 0x65, 0x74, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x20, 0x0a, 0x0c, 0x74, 0x6f, 0x70, 0x31, 0x5f,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x74,
0x6f, 0x70, 0x31, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0f, 0x69, 0x67, 0x6e,
0x69, 0x74, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0d, 0x69, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49,
0x64, 0x12, 0x27, 0x0a, 0x10, 0x69, 0x6e, 0x5f, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x03, 0x52, 0x0d, 0x69, 0x6e, 0x52,
0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x12, 0x45, 0x0a, 0x07, 0x72, 0x65,
0x77, 0x61, 0x72, 0x64, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2e, 0x72, 0x6f, 0x6f, 0x6d, 0x2e,
0x76, 0x31, 0x2e, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x77,
0x61, 0x72, 0x64, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x07, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64,
0x73, 0x22, 0x93, 0x01, 0x0a, 0x17, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74,
0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x65, 0x64, 0x12, 0x1b, 0x0a,
0x09, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x08, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x65,
0x76, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c,
0x12, 0x45, 0x0a, 0x07, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28,
0x0b, 0x32, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73,
0x2e, 0x72, 0x6f, 0x6f, 0x6d, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63,
0x6b, 0x65, 0x74, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x07,
0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x73, 0x42, 0x33, 0x5a, 0x31, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x72, 0x6f, 0x6f, 0x6d, 0x2f, 0x76, 0x31, 0x3b,
0x72, 0x6f, 0x6f, 0x6d, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x33,
0x64, 0x22, 0x80, 0x04, 0x0a, 0x17, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x62, 0x6f, 0x74, 0x4c,
0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x6e, 0x12, 0x17, 0x0a,
0x07, 0x64, 0x72, 0x61, 0x77, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,
0x64, 0x72, 0x61, 0x77, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e,
0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d,
0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73,
0x65, 0x6e, 0x64, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74,
0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49,
0x64, 0x12, 0x17, 0x0a, 0x07, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01,
0x28, 0x09, 0x52, 0x06, 0x67, 0x69, 0x66, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69,
0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09,
0x67, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f,
0x6c, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c,
0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72,
0x5f, 0x70, 0x70, 0x6d, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6d, 0x75, 0x6c, 0x74,
0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x50, 0x70, 0x6d, 0x12, 0x2a, 0x0a, 0x11, 0x62, 0x61, 0x73,
0x65, 0x5f, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x09,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x62, 0x61, 0x73, 0x65, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64,
0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x34, 0x0a, 0x16, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69,
0x76, 0x65, 0x5f, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18,
0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65,
0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x22, 0x0a, 0x0d, 0x63,
0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x0b, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12,
0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f,
0x6e, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69,
0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x69,
0x73, 0x5f, 0x72, 0x6f, 0x62, 0x6f, 0x74, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69,
0x73, 0x52, 0x6f, 0x62, 0x6f, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x79, 0x6e, 0x74, 0x68, 0x65,
0x74, 0x69, 0x63, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x79, 0x6e, 0x74, 0x68,
0x65, 0x74, 0x69, 0x63, 0x22, 0x4a, 0x0a, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x74,
0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61,
0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x21, 0x0a,
0x0c, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x48, 0x65, 0x61, 0x74,
0x22, 0x5f, 0x0a, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x61, 0x6e, 0x6b, 0x43, 0x68, 0x61, 0x6e,
0x67, 0x65, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05,
0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, 0x63, 0x6f,
0x72, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65,
0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75,
0x65, 0x22, 0x94, 0x02, 0x0a, 0x15, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74,
0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x72,
0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x0a, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x52, 0x6f, 0x6c, 0x65, 0x12, 0x17, 0x0a, 0x07,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75,
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f,
0x69, 0x74, 0x65, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72,
0x65, 0x77, 0x61, 0x72, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x72,
0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64,
0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c,
0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64,
0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x63,
0x6f, 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x69, 0x63,
0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x12, 0x19, 0x0a, 0x08, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x5f, 0x69,
0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x49, 0x64,
0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09,
0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0xeb, 0x03, 0x0a, 0x15, 0x52, 0x6f, 0x6f,
0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x46, 0x75, 0x65, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x67,
0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12,
0x14, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05,
0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x64, 0x64, 0x65, 0x64, 0x5f, 0x66,
0x75, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x61, 0x64, 0x64, 0x65, 0x64,
0x46, 0x75, 0x65, 0x6c, 0x12, 0x30, 0x0a, 0x14, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76,
0x65, 0x5f, 0x61, 0x64, 0x64, 0x65, 0x64, 0x5f, 0x66, 0x75, 0x65, 0x6c, 0x18, 0x04, 0x20, 0x01,
0x28, 0x03, 0x52, 0x12, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, 0x64, 0x64,
0x65, 0x64, 0x46, 0x75, 0x65, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x76, 0x65, 0x72, 0x66, 0x6c,
0x6f, 0x77, 0x5f, 0x66, 0x75, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6f,
0x76, 0x65, 0x72, 0x66, 0x6c, 0x6f, 0x77, 0x46, 0x75, 0x65, 0x6c, 0x12, 0x21, 0x0a, 0x0c, 0x63,
0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x66, 0x75, 0x65, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28,
0x03, 0x52, 0x0b, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x46, 0x75, 0x65, 0x6c, 0x12, 0x25,
0x0a, 0x0e, 0x66, 0x75, 0x65, 0x6c, 0x5f, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64,
0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x66, 0x75, 0x65, 0x6c, 0x54, 0x68, 0x72, 0x65,
0x73, 0x68, 0x6f, 0x6c, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18,
0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1e, 0x0a,
0x0b, 0x72, 0x65, 0x73, 0x65, 0x74, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x09, 0x20, 0x01,
0x28, 0x03, 0x52, 0x09, 0x72, 0x65, 0x73, 0x65, 0x74, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x24, 0x0a,
0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18,
0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x0b,
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x69, 0x66, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a,
0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x05,
0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63,
0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52,
0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69,
0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18,
0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65,
0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x82, 0x04, 0x0a, 0x11, 0x52, 0x6f, 0x6f, 0x6d, 0x52,
0x6f, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09,
0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x08, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x65, 0x76,
0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12,
0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x66, 0x75, 0x65, 0x6c, 0x18,
0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x46, 0x75,
0x65, 0x6c, 0x12, 0x25, 0x0a, 0x0e, 0x66, 0x75, 0x65, 0x6c, 0x5f, 0x74, 0x68, 0x72, 0x65, 0x73,
0x68, 0x6f, 0x6c, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x66, 0x75, 0x65, 0x6c,
0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x69, 0x67, 0x6e,
0x69, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0b, 0x69, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x20, 0x0a,
0x0c, 0x6c, 0x61, 0x75, 0x6e, 0x63, 0x68, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0a, 0x6c, 0x61, 0x75, 0x6e, 0x63, 0x68, 0x41, 0x74, 0x4d, 0x73, 0x12,
0x27, 0x0a, 0x0f, 0x62, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x5f, 0x73, 0x63, 0x6f,
0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x62, 0x72, 0x6f, 0x61, 0x64, 0x63,
0x61, 0x73, 0x74, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69,
0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69,
0x6f, 0x6e, 0x49, 0x64, 0x12, 0x20, 0x0a, 0x0c, 0x74, 0x6f, 0x70, 0x31, 0x5f, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x74, 0x6f, 0x70, 0x31,
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0f, 0x69, 0x67, 0x6e, 0x69, 0x74, 0x65,
0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52,
0x0d, 0x69, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d,
0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x0b, 0x20, 0x01,
0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x22, 0x0a,
0x0d, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x73, 0x68, 0x6f, 0x72, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x0c,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x6f, 0x6f, 0x6d, 0x53, 0x68, 0x6f, 0x72, 0x74, 0x49,
0x64, 0x12, 0x28, 0x0a, 0x10, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x63, 0x6f, 0x76, 0x65,
0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x72, 0x6f, 0x63,
0x6b, 0x65, 0x74, 0x43, 0x6f, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12, 0x1e, 0x0a, 0x0b, 0x72,
0x65, 0x73, 0x65, 0x74, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03,
0x52, 0x09, 0x72, 0x65, 0x73, 0x65, 0x74, 0x41, 0x74, 0x4d, 0x73, 0x22, 0xe6, 0x02, 0x0a, 0x12,
0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x4c, 0x61, 0x75, 0x6e, 0x63, 0x68,
0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12,
0x14, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05,
0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x6c, 0x65,
0x76, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x6e, 0x65, 0x78, 0x74, 0x4c,
0x65, 0x76, 0x65, 0x6c, 0x12, 0x24, 0x0a, 0x0e, 0x6c, 0x61, 0x75, 0x6e, 0x63, 0x68, 0x65, 0x64,
0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6c, 0x61,
0x75, 0x6e, 0x63, 0x68, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x1e, 0x0a, 0x0b, 0x72, 0x65,
0x73, 0x65, 0x74, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52,
0x09, 0x72, 0x65, 0x73, 0x65, 0x74, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x20, 0x0a, 0x0c, 0x74, 0x6f,
0x70, 0x31, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0a, 0x74, 0x6f, 0x70, 0x31, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0f,
0x69, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18,
0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x69, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x72, 0x55, 0x73,
0x65, 0x72, 0x49, 0x64, 0x12, 0x27, 0x0a, 0x10, 0x69, 0x6e, 0x5f, 0x72, 0x6f, 0x6f, 0x6d, 0x5f,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x03, 0x52, 0x0d,
0x69, 0x6e, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x12, 0x45, 0x0a,
0x07, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2e, 0x72, 0x6f,
0x6f, 0x6d, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74,
0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x07, 0x72, 0x65, 0x77,
0x61, 0x72, 0x64, 0x73, 0x22, 0x93, 0x01, 0x0a, 0x17, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63,
0x6b, 0x65, 0x74, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x65, 0x64,
0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20,
0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a,
0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6c, 0x65,
0x76, 0x65, 0x6c, 0x12, 0x45, 0x0a, 0x07, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x73, 0x18, 0x03,
0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x65, 0x76, 0x65,
0x6e, 0x74, 0x73, 0x2e, 0x72, 0x6f, 0x6f, 0x6d, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x6f, 0x6f, 0x6d,
0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x47, 0x72, 0x61, 0x6e,
0x74, 0x52, 0x07, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x73, 0x42, 0x33, 0x5a, 0x31, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x72, 0x6f, 0x6f, 0x6d, 0x2f,
0x76, 0x31, 0x3b, 0x72, 0x6f, 0x6f, 0x6d, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x76, 0x31, 0x62,
0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (

View File

@ -72,6 +72,11 @@ message RoomUserJoined {
string avatar = 9;
string display_user_id = 10;
string pretty_display_user_id = 11;
// VIP GetMyVip effective state outbox direct IM
string vip_program_type = 12;
int32 effective_vip_level = 13;
string effective_vip_name = 14;
bool room_entry_notice_enabled = 15;
}
// RoomUserLeft

File diff suppressed because it is too large Load Diff

View File

@ -682,11 +682,21 @@ message JoinRoomRequest {
string response_mode = 9;
}
// RoomEffectiveVipSnapshot room-service wallet-service VIP
// effective_benefits
message RoomEffectiveVipSnapshot {
string program_type = 1;
int32 level = 2;
string name = 3;
bool room_entry_notice_enabled = 4;
}
// JoinRoomResponse
message JoinRoomResponse {
CommandResult result = 1;
RoomUser user = 2;
RoomSnapshot room = 3;
RoomEffectiveVipSnapshot effective_vip = 4;
}
// RoomHeartbeatRequest presence
@ -1195,8 +1205,9 @@ message VerifyRoomPresenceResponse {
int64 room_version = 3;
}
// ListRoomsRequest
// visible_region_id gateway user-service GetUser
// ListRoomsRequest
// visible_region_id gateway viewer filter_region_id
// gateway filter_region_id
message ListRoomsRequest {
RequestMeta meta = 1;
int64 viewer_user_id = 2;
@ -1205,12 +1216,14 @@ message ListRoomsRequest {
string cursor = 5;
int32 limit = 6;
string query = 7;
// country_code gateway
// country_code gateway active
string country_code = 8;
// viewer_country_code
// viewer_country_code All/
string viewer_country_code = 9;
// all_visible_regions gateway true visible_region_id
// all_visible_regions gateway App true visible_region_id
bool all_visible_regions = 10;
// filter_region_id gateway active all_visible_regions/country_code
int64 filter_region_id = 11;
}
// ListRoomsByOwnersRequest active

File diff suppressed because it is too large Load Diff

View File

@ -124,6 +124,8 @@ message UserRoleSummary {
string coin_seller_status = 11;
int64 pending_role_invitations = 12;
bool is_manager = 13;
// roles App
repeated string roles = 14;
}
// AgencyMembership host Agency

File diff suppressed because it is too large Load Diff

View File

@ -160,6 +160,7 @@ message CronBatchRequest {
int64 lock_ttl_ms = 5;
int64 pending_publish_max_age_ms = 6;
int64 publishing_session_max_age_ms = 7;
int64 open_session_max_age_ms = 8;
}
// CronBatchResponse run
@ -691,6 +692,46 @@ message BatchGetUsersResponse {
map<int64, User> users = 1;
}
// ActiveUserBanSummary
// expires_at_ms=0 permanent=true source adminmanager legacy
message ActiveUserBanSummary {
bool active = 1;
string source = 2;
string ban_id = 3;
bool permanent = 4;
int64 expires_at_ms = 5;
UserStatus user_status = 6;
string operator_type = 7;
int64 operator_user_id = 8;
string reason = 9;
int64 created_at_ms = 10;
int32 active_ban_count = 11;
}
// UserAdminProfile user-service owner
// roles hostagencybdbd_leadercoin_sellermanager
message UserAdminProfile {
User user = 1;
string birth = 2;
string register_device = 3;
int64 last_success_login_at_ms = 4;
repeated string roles = 5;
ActiveUserBanSummary ban = 6;
string last_operator_type = 7;
int64 last_operator_user_id = 8;
string last_operation_reason = 9;
int64 last_operation_at_ms = 10;
}
message BatchGetUserAdminProfilesRequest {
RequestMeta meta = 1;
repeated int64 user_ids = 2;
}
message BatchGetUserAdminProfilesResponse {
map<int64, UserAdminProfile> profiles = 1;
}
// RoomBasicUser
message RoomBasicUser {
int64 user_id = 1;
@ -831,6 +872,51 @@ message SetUserStatusResponse {
string access_token_revoke_error = 11;
}
// AdminUserBan expires_at_ms=0
message AdminUserBan {
string ban_id = 1;
string command_id = 2;
int64 target_user_id = 3;
int64 expires_at_ms = 4;
bool permanent = 5;
string status = 6;
string reason = 7;
int64 operator_admin_id = 8;
int64 created_at_ms = 9;
int64 updated_at_ms = 10;
int64 released_by_admin_id = 11;
string released_reason = 12;
int64 released_at_ms = 13;
}
message AdminBanUserRequest {
RequestMeta meta = 1;
string command_id = 2;
int64 target_user_id = 3;
int64 expires_at_ms = 4;
int64 operator_admin_id = 5;
string reason = 6;
}
message AdminBanUserResponse {
AdminUserBan ban = 1;
SetUserStatusResponse status = 2;
}
// AdminUnbanUserRequest ban_id
message AdminUnbanUserRequest {
RequestMeta meta = 1;
string ban_id = 2;
int64 target_user_id = 3;
int64 operator_admin_id = 4;
string reason = 5;
}
message AdminUnbanUserResponse {
AdminUserBan ban = 1;
SetUserStatusResponse status = 2;
}
message ManagerUserBlock {
string block_id = 1;
int64 manager_user_id = 2;
@ -1343,6 +1429,7 @@ service UserService {
rpc BusinessUserLookup(BusinessUserLookupRequest) returns (BusinessUserLookupResponse);
rpc GetMyProfileStats(GetMyProfileStatsRequest) returns (GetMyProfileStatsResponse);
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
rpc BatchGetUserAdminProfiles(BatchGetUserAdminProfilesRequest) returns (BatchGetUserAdminProfilesResponse);
rpc BatchGetRoomBasicUsers(BatchGetRoomBasicUsersRequest) returns (BatchGetRoomBasicUsersResponse);
rpc ListUserIDs(ListUserIDsRequest) returns (ListUserIDsResponse);
rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse);
@ -1353,6 +1440,8 @@ service UserService {
rpc ChangeUserCountry(ChangeUserCountryRequest) returns (ChangeUserCountryResponse);
rpc AdminChangeUserCountry(AdminChangeUserCountryRequest) returns (ChangeUserCountryResponse);
rpc SetUserStatus(SetUserStatusRequest) returns (SetUserStatusResponse);
rpc AdminBanUser(AdminBanUserRequest) returns (AdminBanUserResponse);
rpc AdminUnbanUser(AdminUnbanUserRequest) returns (AdminUnbanUserResponse);
rpc CreateManagerUserBlock(CreateManagerUserBlockRequest) returns (CreateManagerUserBlockResponse);
rpc ListManagerUserBlocks(ListManagerUserBlocksRequest) returns (ListManagerUserBlocksResponse);
rpc UnblockManagerUser(UnblockManagerUserRequest) returns (UnblockManagerUserResponse);
@ -1399,7 +1488,9 @@ service UserCronService {
rpc ProcessLoginIPRiskBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessRegionRebuildBatch(CronBatchRequest) returns (CronBatchResponse);
rpc CompensateMicOpenSessions(CronBatchRequest) returns (CronBatchResponse);
rpc CompensateRoomOpenSessions(CronBatchRequest) returns (CronBatchResponse);
rpc ExpireManagerUserBlocks(CronBatchRequest) returns (CronBatchResponse);
rpc ExpireAdminUserBans(CronBatchRequest) returns (CronBatchResponse);
rpc RefreshCPIntimacyLeaderboard(CronBatchRequest) returns (CronBatchResponse);
}

View File

@ -24,6 +24,7 @@ const (
UserService_BusinessUserLookup_FullMethodName = "/hyapp.user.v1.UserService/BusinessUserLookup"
UserService_GetMyProfileStats_FullMethodName = "/hyapp.user.v1.UserService/GetMyProfileStats"
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
UserService_BatchGetUserAdminProfiles_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUserAdminProfiles"
UserService_BatchGetRoomBasicUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetRoomBasicUsers"
UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs"
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
@ -34,6 +35,8 @@ const (
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
UserService_AdminChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/AdminChangeUserCountry"
UserService_SetUserStatus_FullMethodName = "/hyapp.user.v1.UserService/SetUserStatus"
UserService_AdminBanUser_FullMethodName = "/hyapp.user.v1.UserService/AdminBanUser"
UserService_AdminUnbanUser_FullMethodName = "/hyapp.user.v1.UserService/AdminUnbanUser"
UserService_CreateManagerUserBlock_FullMethodName = "/hyapp.user.v1.UserService/CreateManagerUserBlock"
UserService_ListManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserService/ListManagerUserBlocks"
UserService_UnblockManagerUser_FullMethodName = "/hyapp.user.v1.UserService/UnblockManagerUser"
@ -53,6 +56,7 @@ type UserServiceClient interface {
BusinessUserLookup(ctx context.Context, in *BusinessUserLookupRequest, opts ...grpc.CallOption) (*BusinessUserLookupResponse, error)
GetMyProfileStats(ctx context.Context, in *GetMyProfileStatsRequest, opts ...grpc.CallOption) (*GetMyProfileStatsResponse, error)
BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error)
BatchGetUserAdminProfiles(ctx context.Context, in *BatchGetUserAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserAdminProfilesResponse, error)
BatchGetRoomBasicUsers(ctx context.Context, in *BatchGetRoomBasicUsersRequest, opts ...grpc.CallOption) (*BatchGetRoomBasicUsersResponse, error)
ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error)
GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, error)
@ -63,6 +67,8 @@ type UserServiceClient interface {
ChangeUserCountry(ctx context.Context, in *ChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error)
AdminChangeUserCountry(ctx context.Context, in *AdminChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error)
SetUserStatus(ctx context.Context, in *SetUserStatusRequest, opts ...grpc.CallOption) (*SetUserStatusResponse, error)
AdminBanUser(ctx context.Context, in *AdminBanUserRequest, opts ...grpc.CallOption) (*AdminBanUserResponse, error)
AdminUnbanUser(ctx context.Context, in *AdminUnbanUserRequest, opts ...grpc.CallOption) (*AdminUnbanUserResponse, error)
CreateManagerUserBlock(ctx context.Context, in *CreateManagerUserBlockRequest, opts ...grpc.CallOption) (*CreateManagerUserBlockResponse, error)
ListManagerUserBlocks(ctx context.Context, in *ListManagerUserBlocksRequest, opts ...grpc.CallOption) (*ListManagerUserBlocksResponse, error)
UnblockManagerUser(ctx context.Context, in *UnblockManagerUserRequest, opts ...grpc.CallOption) (*UnblockManagerUserResponse, error)
@ -129,6 +135,16 @@ func (c *userServiceClient) BatchGetUsers(ctx context.Context, in *BatchGetUsers
return out, nil
}
func (c *userServiceClient) BatchGetUserAdminProfiles(ctx context.Context, in *BatchGetUserAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserAdminProfilesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchGetUserAdminProfilesResponse)
err := c.cc.Invoke(ctx, UserService_BatchGetUserAdminProfiles_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) BatchGetRoomBasicUsers(ctx context.Context, in *BatchGetRoomBasicUsersRequest, opts ...grpc.CallOption) (*BatchGetRoomBasicUsersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchGetRoomBasicUsersResponse)
@ -229,6 +245,26 @@ func (c *userServiceClient) SetUserStatus(ctx context.Context, in *SetUserStatus
return out, nil
}
func (c *userServiceClient) AdminBanUser(ctx context.Context, in *AdminBanUserRequest, opts ...grpc.CallOption) (*AdminBanUserResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminBanUserResponse)
err := c.cc.Invoke(ctx, UserService_AdminBanUser_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) AdminUnbanUser(ctx context.Context, in *AdminUnbanUserRequest, opts ...grpc.CallOption) (*AdminUnbanUserResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminUnbanUserResponse)
err := c.cc.Invoke(ctx, UserService_AdminUnbanUser_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) CreateManagerUserBlock(ctx context.Context, in *CreateManagerUserBlockRequest, opts ...grpc.CallOption) (*CreateManagerUserBlockResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateManagerUserBlockResponse)
@ -300,6 +336,7 @@ type UserServiceServer interface {
BusinessUserLookup(context.Context, *BusinessUserLookupRequest) (*BusinessUserLookupResponse, error)
GetMyProfileStats(context.Context, *GetMyProfileStatsRequest) (*GetMyProfileStatsResponse, error)
BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
BatchGetUserAdminProfiles(context.Context, *BatchGetUserAdminProfilesRequest) (*BatchGetUserAdminProfilesResponse, error)
BatchGetRoomBasicUsers(context.Context, *BatchGetRoomBasicUsersRequest) (*BatchGetRoomBasicUsersResponse, error)
ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error)
GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error)
@ -310,6 +347,8 @@ type UserServiceServer interface {
ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error)
AdminChangeUserCountry(context.Context, *AdminChangeUserCountryRequest) (*ChangeUserCountryResponse, error)
SetUserStatus(context.Context, *SetUserStatusRequest) (*SetUserStatusResponse, error)
AdminBanUser(context.Context, *AdminBanUserRequest) (*AdminBanUserResponse, error)
AdminUnbanUser(context.Context, *AdminUnbanUserRequest) (*AdminUnbanUserResponse, error)
CreateManagerUserBlock(context.Context, *CreateManagerUserBlockRequest) (*CreateManagerUserBlockResponse, error)
ListManagerUserBlocks(context.Context, *ListManagerUserBlocksRequest) (*ListManagerUserBlocksResponse, error)
UnblockManagerUser(context.Context, *UnblockManagerUserRequest) (*UnblockManagerUserResponse, error)
@ -341,6 +380,9 @@ func (UnimplementedUserServiceServer) GetMyProfileStats(context.Context, *GetMyP
func (UnimplementedUserServiceServer) BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchGetUsers not implemented")
}
func (UnimplementedUserServiceServer) BatchGetUserAdminProfiles(context.Context, *BatchGetUserAdminProfilesRequest) (*BatchGetUserAdminProfilesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchGetUserAdminProfiles not implemented")
}
func (UnimplementedUserServiceServer) BatchGetRoomBasicUsers(context.Context, *BatchGetRoomBasicUsersRequest) (*BatchGetRoomBasicUsersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchGetRoomBasicUsers not implemented")
}
@ -371,6 +413,12 @@ func (UnimplementedUserServiceServer) AdminChangeUserCountry(context.Context, *A
func (UnimplementedUserServiceServer) SetUserStatus(context.Context, *SetUserStatusRequest) (*SetUserStatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetUserStatus not implemented")
}
func (UnimplementedUserServiceServer) AdminBanUser(context.Context, *AdminBanUserRequest) (*AdminBanUserResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AdminBanUser not implemented")
}
func (UnimplementedUserServiceServer) AdminUnbanUser(context.Context, *AdminUnbanUserRequest) (*AdminUnbanUserResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AdminUnbanUser not implemented")
}
func (UnimplementedUserServiceServer) CreateManagerUserBlock(context.Context, *CreateManagerUserBlockRequest) (*CreateManagerUserBlockResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateManagerUserBlock not implemented")
}
@ -500,6 +548,24 @@ func _UserService_BatchGetUsers_Handler(srv interface{}, ctx context.Context, de
return interceptor(ctx, in, info, handler)
}
func _UserService_BatchGetUserAdminProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchGetUserAdminProfilesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).BatchGetUserAdminProfiles(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_BatchGetUserAdminProfiles_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).BatchGetUserAdminProfiles(ctx, req.(*BatchGetUserAdminProfilesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_BatchGetRoomBasicUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchGetRoomBasicUsersRequest)
if err := dec(in); err != nil {
@ -680,6 +746,42 @@ func _UserService_SetUserStatus_Handler(srv interface{}, ctx context.Context, de
return interceptor(ctx, in, info, handler)
}
func _UserService_AdminBanUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminBanUserRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).AdminBanUser(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_AdminBanUser_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).AdminBanUser(ctx, req.(*AdminBanUserRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_AdminUnbanUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminUnbanUserRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).AdminUnbanUser(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_AdminUnbanUser_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).AdminUnbanUser(ctx, req.(*AdminUnbanUserRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_CreateManagerUserBlock_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateManagerUserBlockRequest)
if err := dec(in); err != nil {
@ -815,6 +917,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
MethodName: "BatchGetUsers",
Handler: _UserService_BatchGetUsers_Handler,
},
{
MethodName: "BatchGetUserAdminProfiles",
Handler: _UserService_BatchGetUserAdminProfiles_Handler,
},
{
MethodName: "BatchGetRoomBasicUsers",
Handler: _UserService_BatchGetRoomBasicUsers_Handler,
@ -855,6 +961,14 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetUserStatus",
Handler: _UserService_SetUserStatus_Handler,
},
{
MethodName: "AdminBanUser",
Handler: _UserService_AdminBanUser_Handler,
},
{
MethodName: "AdminUnbanUser",
Handler: _UserService_AdminUnbanUser_Handler,
},
{
MethodName: "CreateManagerUserBlock",
Handler: _UserService_CreateManagerUserBlock_Handler,
@ -1890,7 +2004,9 @@ const (
UserCronService_ProcessLoginIPRiskBatch_FullMethodName = "/hyapp.user.v1.UserCronService/ProcessLoginIPRiskBatch"
UserCronService_ProcessRegionRebuildBatch_FullMethodName = "/hyapp.user.v1.UserCronService/ProcessRegionRebuildBatch"
UserCronService_CompensateMicOpenSessions_FullMethodName = "/hyapp.user.v1.UserCronService/CompensateMicOpenSessions"
UserCronService_CompensateRoomOpenSessions_FullMethodName = "/hyapp.user.v1.UserCronService/CompensateRoomOpenSessions"
UserCronService_ExpireManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserCronService/ExpireManagerUserBlocks"
UserCronService_ExpireAdminUserBans_FullMethodName = "/hyapp.user.v1.UserCronService/ExpireAdminUserBans"
UserCronService_RefreshCPIntimacyLeaderboard_FullMethodName = "/hyapp.user.v1.UserCronService/RefreshCPIntimacyLeaderboard"
)
@ -1903,7 +2019,9 @@ type UserCronServiceClient interface {
ProcessLoginIPRiskBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ProcessRegionRebuildBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
CompensateMicOpenSessions(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
CompensateRoomOpenSessions(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ExpireManagerUserBlocks(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ExpireAdminUserBans(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
RefreshCPIntimacyLeaderboard(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
}
@ -1945,6 +2063,16 @@ func (c *userCronServiceClient) CompensateMicOpenSessions(ctx context.Context, i
return out, nil
}
func (c *userCronServiceClient) CompensateRoomOpenSessions(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CronBatchResponse)
err := c.cc.Invoke(ctx, UserCronService_CompensateRoomOpenSessions_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userCronServiceClient) ExpireManagerUserBlocks(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CronBatchResponse)
@ -1955,6 +2083,16 @@ func (c *userCronServiceClient) ExpireManagerUserBlocks(ctx context.Context, in
return out, nil
}
func (c *userCronServiceClient) ExpireAdminUserBans(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CronBatchResponse)
err := c.cc.Invoke(ctx, UserCronService_ExpireAdminUserBans_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userCronServiceClient) RefreshCPIntimacyLeaderboard(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CronBatchResponse)
@ -1974,7 +2112,9 @@ type UserCronServiceServer interface {
ProcessLoginIPRiskBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ProcessRegionRebuildBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
CompensateMicOpenSessions(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
CompensateRoomOpenSessions(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ExpireManagerUserBlocks(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ExpireAdminUserBans(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
RefreshCPIntimacyLeaderboard(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
mustEmbedUnimplementedUserCronServiceServer()
}
@ -1995,9 +2135,15 @@ func (UnimplementedUserCronServiceServer) ProcessRegionRebuildBatch(context.Cont
func (UnimplementedUserCronServiceServer) CompensateMicOpenSessions(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CompensateMicOpenSessions not implemented")
}
func (UnimplementedUserCronServiceServer) CompensateRoomOpenSessions(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CompensateRoomOpenSessions not implemented")
}
func (UnimplementedUserCronServiceServer) ExpireManagerUserBlocks(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ExpireManagerUserBlocks not implemented")
}
func (UnimplementedUserCronServiceServer) ExpireAdminUserBans(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ExpireAdminUserBans not implemented")
}
func (UnimplementedUserCronServiceServer) RefreshCPIntimacyLeaderboard(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RefreshCPIntimacyLeaderboard not implemented")
}
@ -2076,6 +2222,24 @@ func _UserCronService_CompensateMicOpenSessions_Handler(srv interface{}, ctx con
return interceptor(ctx, in, info, handler)
}
func _UserCronService_CompensateRoomOpenSessions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CronBatchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserCronServiceServer).CompensateRoomOpenSessions(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserCronService_CompensateRoomOpenSessions_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserCronServiceServer).CompensateRoomOpenSessions(ctx, req.(*CronBatchRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserCronService_ExpireManagerUserBlocks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CronBatchRequest)
if err := dec(in); err != nil {
@ -2094,6 +2258,24 @@ func _UserCronService_ExpireManagerUserBlocks_Handler(srv interface{}, ctx conte
return interceptor(ctx, in, info, handler)
}
func _UserCronService_ExpireAdminUserBans_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CronBatchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserCronServiceServer).ExpireAdminUserBans(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserCronService_ExpireAdminUserBans_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserCronServiceServer).ExpireAdminUserBans(ctx, req.(*CronBatchRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserCronService_RefreshCPIntimacyLeaderboard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CronBatchRequest)
if err := dec(in); err != nil {
@ -2131,10 +2313,18 @@ var UserCronService_ServiceDesc = grpc.ServiceDesc{
MethodName: "CompensateMicOpenSessions",
Handler: _UserCronService_CompensateMicOpenSessions_Handler,
},
{
MethodName: "CompensateRoomOpenSessions",
Handler: _UserCronService_CompensateRoomOpenSessions_Handler,
},
{
MethodName: "ExpireManagerUserBlocks",
Handler: _UserCronService_ExpireManagerUserBlocks_Handler,
},
{
MethodName: "ExpireAdminUserBans",
Handler: _UserCronService_ExpireAdminUserBans_Handler,
},
{
MethodName: "RefreshCPIntimacyLeaderboard",
Handler: _UserCronService_RefreshCPIntimacyLeaderboard_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -1267,6 +1267,26 @@ message GetRechargeBillSummaryResponse {
RechargeBillSummaryBucket coin_seller = 4;
}
// UserRechargeStats wallet-service
message UserRechargeStats {
int64 user_id = 1;
int64 recharge_count = 2;
int64 total_coin_amount = 3;
int64 total_usd_minor_amount = 4;
int64 first_recharged_at_ms = 5;
int64 updated_at_ms = 6;
}
message BatchGetUserRechargeStatsRequest {
string request_id = 1;
string app_code = 2;
repeated int64 user_ids = 3;
}
message BatchGetUserRechargeStatsResponse {
repeated UserRechargeStats items = 1;
}
message GetRechargeBillOverviewRequest {
string request_id = 1;
string app_code = 2;
@ -1887,6 +1907,54 @@ message VipRewardItem {
string animation_url = 9;
}
// VipProgramConfig App VIP
// app_code
message VipProgramConfig {
string app_code = 1;
// program_type: legacy_timed/tiered_privilege_v1
string program_type = 2;
int32 level_count = 3;
// same_level_expiry_policy: extend_remaining
string same_level_expiry_policy = 4;
// upgrade_expiry_policy: extend_remaining/replace_from_now
string upgrade_expiry_policy = 5;
// downgrade_purchase_policy reject
string downgrade_purchase_policy = 6;
// benefit_inheritance_policy target_only
string benefit_inheritance_policy = 7;
// grant_mode: direct_membership/trial_card VIP
string grant_mode = 8;
bool trial_card_enabled = 9;
string status = 10;
int64 config_version = 11;
int64 updated_by_admin_id = 12;
int64 created_at_ms = 13;
int64 updated_at_ms = 14;
}
// VipBenefit resource_id 0 0
// trial_enabled=false effective_benefits
message VipBenefit {
string benefit_code = 1;
string name = 2;
// benefit_type: decoration/function
string benefit_type = 3;
// unlock_level P1
//
int32 unlock_level = 4;
string status = 5;
bool trial_enabled = 6;
int64 resource_id = 7;
string resource_type = 8;
// execution_scope wallet/room/user/notice
string execution_scope = 9;
bool auto_equip = 10;
int32 sort_order = 11;
string metadata_json = 12;
int64 created_at_ms = 13;
int64 updated_at_ms = 14;
}
message VipLevel {
int32 level = 1;
string name = 2;
@ -1903,6 +1971,9 @@ message VipLevel {
string purchase_locked_reason = 13;
int64 created_at_ms = 14;
int64 updated_at_ms = 15;
// benefits unlock_level
repeated VipBenefit benefits = 16;
int64 config_version = 17;
}
message UserVip {
@ -1913,6 +1984,54 @@ message UserVip {
int64 started_at_ms = 5;
int64 expires_at_ms = 6;
int64 updated_at_ms = 7;
string program_type = 8;
int64 config_version = 9;
}
// VipTrialCard VIP entitlement_id
// expires_at_ms
message VipTrialCard {
string trial_card_id = 1;
string entitlement_id = 2;
int64 resource_id = 3;
int64 user_id = 4;
int32 level = 5;
string name = 6;
string status = 7;
bool equipped = 8;
int64 duration_ms = 9;
int64 effective_at_ms = 10;
int64 expires_at_ms = 11;
int64 remaining_duration_ms = 12;
string grant_source = 13;
string source_grant_id = 14;
int64 created_at_ms = 15;
int64 updated_at_ms = 16;
}
// VipUserSettings App VIP wallet
// updated_at_ms=0 VIP
message VipUserSettings {
string app_code = 1;
int64 user_id = 2;
bool room_entry_notice_enabled = 3;
bool online_global_notice_enabled = 4;
int64 updated_at_ms = 5;
}
// VipState
// effective_benefits paid/trial trial_enabled
// user_settings CheckVipBenefit
message VipState {
UserVip paid_vip = 1;
VipTrialCard equipped_trial_card = 2;
UserVip effective_vip = 3;
// effective_source: paid/trial/none
string effective_source = 4;
repeated VipBenefit effective_benefits = 5;
int64 evaluated_at_ms = 6;
VipProgramConfig program_config = 7;
VipUserSettings user_settings = 8;
}
message ListVipPackagesRequest {
@ -1924,6 +2043,8 @@ message ListVipPackagesRequest {
message ListVipPackagesResponse {
UserVip current_vip = 1;
repeated VipLevel packages = 2;
VipState state = 3;
VipProgramConfig program_config = 4;
}
message GetMyVipRequest {
@ -1934,6 +2055,7 @@ message GetMyVipRequest {
message GetMyVipResponse {
UserVip vip = 1;
VipState state = 2;
}
message PurchaseVipRequest {
@ -1950,6 +2072,7 @@ message PurchaseVipResponse {
int64 coin_spent = 4;
int64 coin_balance_after = 5;
repeated VipRewardItem reward_items = 6;
VipState state = 7;
}
message DebitCPBreakupFeeRequest {
@ -1999,6 +2122,205 @@ message GrantVipResponse {
UserVip vip = 2;
repeated VipRewardItem reward_items = 3;
int64 server_time_ms = 4;
VipState state = 5;
}
message GetVipProgramConfigRequest {
string request_id = 1;
string app_code = 2;
}
message GetVipProgramConfigResponse {
VipProgramConfig config = 1;
repeated VipBenefit benefits = 2;
int64 server_time_ms = 3;
}
// UpdateAdminVipProgramConfigRequest App config_version
//
message UpdateAdminVipProgramConfigRequest {
string request_id = 1;
string app_code = 2;
VipProgramConfig config = 3;
int64 operator_user_id = 4;
}
message UpdateAdminVipProgramConfigResponse {
VipProgramConfig config = 1;
int64 server_time_ms = 2;
}
// UpdateMyVipSettingsRequest 使 optional PATCH false
message UpdateMyVipSettingsRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
optional bool room_entry_notice_enabled = 4;
optional bool online_global_notice_enabled = 5;
}
message UpdateMyVipSettingsResponse {
VipUserSettings settings = 1;
int64 server_time_ms = 2;
}
// VipDailyCoinRebateRun UTC 使
// run config_version/level_amounts_json
message VipDailyCoinRebateRun {
string run_id = 1;
string task_day = 2;
int64 day_start_ms = 3;
int64 day_end_ms = 4;
int64 config_version = 5;
string level_amounts_json = 6;
string status = 7;
int64 last_user_id = 8;
int64 scanned_count = 9;
int64 created_count = 10;
int64 created_at_ms = 11;
int64 updated_at_ms = 12;
int64 completed_at_ms = 13;
}
// VipDailyCoinRebate VIP UTC
// status claimable/claimed/expiredexpired
message VipDailyCoinRebate {
string rebate_id = 1;
int64 user_id = 2;
string task_day = 3;
int32 vip_level = 4;
string vip_name = 5;
int64 coin_amount = 6;
string status = 7;
int64 available_at_ms = 8;
int64 expires_at_ms = 9;
int64 config_version = 10;
int64 claimed_at_ms = 11;
string wallet_transaction_id = 12;
int64 created_at_ms = 13;
int64 updated_at_ms = 14;
}
// ProcessVipDailyCoinRebateBatchRequest WalletCronService task_day
// wallet-service UTC App+task_day run
message ProcessVipDailyCoinRebateBatchRequest {
string request_id = 1;
string app_code = 2;
string task_day = 3;
int32 batch_size = 4;
}
message ProcessVipDailyCoinRebateBatchResponse {
VipDailyCoinRebateRun run = 1;
int32 scanned_count = 2;
int32 created_count = 3;
bool has_more = 4;
int64 server_time_ms = 5;
}
message GetMyCurrentVipDailyCoinRebateRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
}
message GetMyCurrentVipDailyCoinRebateResponse {
bool found = 1;
VipDailyCoinRebate rebate = 2;
int64 server_time_ms = 3;
}
message ListMyVipDailyCoinRebateStatusesRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
int32 page = 4;
int32 page_size = 5;
// rebate_ids 100 user_id
repeated string rebate_ids = 6;
}
message ListMyVipDailyCoinRebateStatusesResponse {
repeated VipDailyCoinRebate rebates = 1;
int64 total = 2;
int64 server_time_ms = 3;
}
message ClaimVipDailyCoinRebateRequest {
string command_id = 1;
string app_code = 2;
int64 user_id = 3;
string rebate_id = 4;
}
message ClaimVipDailyCoinRebateResponse {
VipDailyCoinRebate rebate = 1;
string transaction_id = 2;
AssetBalance coin_balance = 3;
int64 server_time_ms = 4;
}
// GrantVipTrialCardRequest level+duration_ms resource_id
// vip_trial_card_{level} App
message GrantVipTrialCardRequest {
string command_id = 1;
string app_code = 2;
int64 target_user_id = 3;
int32 level = 4;
int64 duration_ms = 5;
optional int64 resource_id = 6;
string grant_source = 7;
int64 operator_user_id = 8;
string reason = 9;
}
message GrantVipTrialCardResponse {
VipTrialCard trial_card = 1;
VipState state = 2;
int64 server_time_ms = 3;
}
message EquipVipTrialCardRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
// entitlement_id
string entitlement_id = 4;
}
message EquipVipTrialCardResponse {
VipTrialCard trial_card = 1;
VipState state = 2;
int64 server_time_ms = 3;
}
message UnequipVipTrialCardRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
}
message UnequipVipTrialCardResponse {
bool unequipped = 1;
VipState state = 2;
int64 server_time_ms = 3;
}
// CheckVipBenefit room/user VIP level
// App
message CheckVipBenefitRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
string benefit_code = 4;
}
message CheckVipBenefitResponse {
bool allowed = 1;
VipBenefit benefit = 2;
VipState state = 3;
string denial_reason = 4;
int64 evaluated_at_ms = 5;
}
message AdminVipLevelInput {
@ -2010,6 +2332,8 @@ message AdminVipLevelInput {
int64 reward_resource_group_id = 6;
int32 sort_order = 7;
int64 required_recharge_coin_amount = 8;
// benefits unlock_level
repeated VipBenefit benefits = 9;
}
message ListAdminVipLevelsRequest {
@ -2020,6 +2344,7 @@ message ListAdminVipLevelsRequest {
message ListAdminVipLevelsResponse {
repeated VipLevel levels = 1;
int64 server_time_ms = 2;
VipProgramConfig program_config = 3;
}
message UpdateAdminVipLevelsRequest {
@ -2032,6 +2357,7 @@ message UpdateAdminVipLevelsRequest {
message UpdateAdminVipLevelsResponse {
repeated VipLevel levels = 1;
int64 server_time_ms = 2;
VipProgramConfig program_config = 3;
}
// CreditTaskRewardRequest activity-service
@ -2440,6 +2766,7 @@ service WalletCronService {
rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessHostSalaryHalfMonthSettlementBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessHostSalaryMonthEndBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessVipDailyCoinRebateBatch(ProcessVipDailyCoinRebateBatchRequest) returns (ProcessVipDailyCoinRebateBatchResponse);
}
// WalletService gRPC HTTP gateway-service
@ -2502,6 +2829,7 @@ service WalletService {
rpc PurchaseResourceShopItem(PurchaseResourceShopItemRequest) returns (PurchaseResourceShopItemResponse);
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
rpc GetRechargeBillSummary(GetRechargeBillSummaryRequest) returns (GetRechargeBillSummaryResponse);
rpc BatchGetUserRechargeStats(BatchGetUserRechargeStatsRequest) returns (BatchGetUserRechargeStatsResponse);
rpc GetRechargeBillOverview(GetRechargeBillOverviewRequest) returns (GetRechargeBillOverviewResponse);
rpc RefreshGooglePaymentPrices(RefreshGooglePaymentPricesRequest) returns (RefreshGooglePaymentPricesResponse);
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);
@ -2530,13 +2858,23 @@ service WalletService {
rpc GetDiamondExchangeConfig(GetDiamondExchangeConfigRequest) returns (GetDiamondExchangeConfigResponse);
rpc ListWalletTransactions(ListWalletTransactionsRequest) returns (ListWalletTransactionsResponse);
rpc ListVipPackages(ListVipPackagesRequest) returns (ListVipPackagesResponse);
rpc GetVipProgramConfig(GetVipProgramConfigRequest) returns (GetVipProgramConfigResponse);
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
rpc CheckVipBenefit(CheckVipBenefitRequest) returns (CheckVipBenefitResponse);
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
rpc DebitCPBreakupFee(DebitCPBreakupFeeRequest) returns (DebitCPBreakupFeeResponse);
rpc DebitWheelDraw(DebitWheelDrawRequest) returns (DebitWheelDrawResponse);
rpc GrantVip(GrantVipRequest) returns (GrantVipResponse);
rpc GrantVipTrialCard(GrantVipTrialCardRequest) returns (GrantVipTrialCardResponse);
rpc EquipVipTrialCard(EquipVipTrialCardRequest) returns (EquipVipTrialCardResponse);
rpc UnequipVipTrialCard(UnequipVipTrialCardRequest) returns (UnequipVipTrialCardResponse);
rpc ListAdminVipLevels(ListAdminVipLevelsRequest) returns (ListAdminVipLevelsResponse);
rpc UpdateAdminVipLevels(UpdateAdminVipLevelsRequest) returns (UpdateAdminVipLevelsResponse);
rpc UpdateAdminVipProgramConfig(UpdateAdminVipProgramConfigRequest) returns (UpdateAdminVipProgramConfigResponse);
rpc UpdateMyVipSettings(UpdateMyVipSettingsRequest) returns (UpdateMyVipSettingsResponse);
rpc GetMyCurrentVipDailyCoinRebate(GetMyCurrentVipDailyCoinRebateRequest) returns (GetMyCurrentVipDailyCoinRebateResponse);
rpc ListMyVipDailyCoinRebateStatuses(ListMyVipDailyCoinRebateStatusesRequest) returns (ListMyVipDailyCoinRebateStatusesResponse);
rpc ClaimVipDailyCoinRebate(ClaimVipDailyCoinRebateRequest) returns (ClaimVipDailyCoinRebateResponse);
rpc CreditTaskReward(CreditTaskRewardRequest) returns (CreditTaskRewardResponse);
rpc CreditLuckyGiftReward(CreditLuckyGiftRewardRequest) returns (CreditLuckyGiftRewardResponse);
rpc CreditWheelReward(CreditWheelRewardRequest) returns (CreditWheelRewardResponse);

View File

@ -22,6 +22,7 @@ const (
WalletCronService_ProcessHostSalaryDailySettlementBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessHostSalaryDailySettlementBatch"
WalletCronService_ProcessHostSalaryHalfMonthSettlementBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessHostSalaryHalfMonthSettlementBatch"
WalletCronService_ProcessHostSalaryMonthEndBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessHostSalaryMonthEndBatch"
WalletCronService_ProcessVipDailyCoinRebateBatch_FullMethodName = "/hyapp.wallet.v1.WalletCronService/ProcessVipDailyCoinRebateBatch"
)
// WalletCronServiceClient is the client API for WalletCronService service.
@ -33,6 +34,7 @@ type WalletCronServiceClient interface {
ProcessHostSalaryDailySettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ProcessHostSalaryMonthEndBatch(ctx context.Context, in *CronBatchRequest, opts ...grpc.CallOption) (*CronBatchResponse, error)
ProcessVipDailyCoinRebateBatch(ctx context.Context, in *ProcessVipDailyCoinRebateBatchRequest, opts ...grpc.CallOption) (*ProcessVipDailyCoinRebateBatchResponse, error)
}
type walletCronServiceClient struct {
@ -73,6 +75,16 @@ func (c *walletCronServiceClient) ProcessHostSalaryMonthEndBatch(ctx context.Con
return out, nil
}
func (c *walletCronServiceClient) ProcessVipDailyCoinRebateBatch(ctx context.Context, in *ProcessVipDailyCoinRebateBatchRequest, opts ...grpc.CallOption) (*ProcessVipDailyCoinRebateBatchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ProcessVipDailyCoinRebateBatchResponse)
err := c.cc.Invoke(ctx, WalletCronService_ProcessVipDailyCoinRebateBatch_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// WalletCronServiceServer is the server API for WalletCronService service.
// All implementations must embed UnimplementedWalletCronServiceServer
// for forward compatibility.
@ -82,6 +94,7 @@ type WalletCronServiceServer interface {
ProcessHostSalaryDailySettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ProcessHostSalaryHalfMonthSettlementBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ProcessHostSalaryMonthEndBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error)
ProcessVipDailyCoinRebateBatch(context.Context, *ProcessVipDailyCoinRebateBatchRequest) (*ProcessVipDailyCoinRebateBatchResponse, error)
mustEmbedUnimplementedWalletCronServiceServer()
}
@ -101,6 +114,9 @@ func (UnimplementedWalletCronServiceServer) ProcessHostSalaryHalfMonthSettlement
func (UnimplementedWalletCronServiceServer) ProcessHostSalaryMonthEndBatch(context.Context, *CronBatchRequest) (*CronBatchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ProcessHostSalaryMonthEndBatch not implemented")
}
func (UnimplementedWalletCronServiceServer) ProcessVipDailyCoinRebateBatch(context.Context, *ProcessVipDailyCoinRebateBatchRequest) (*ProcessVipDailyCoinRebateBatchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ProcessVipDailyCoinRebateBatch not implemented")
}
func (UnimplementedWalletCronServiceServer) mustEmbedUnimplementedWalletCronServiceServer() {}
func (UnimplementedWalletCronServiceServer) testEmbeddedByValue() {}
@ -176,6 +192,24 @@ func _WalletCronService_ProcessHostSalaryMonthEndBatch_Handler(srv interface{},
return interceptor(ctx, in, info, handler)
}
func _WalletCronService_ProcessVipDailyCoinRebateBatch_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ProcessVipDailyCoinRebateBatchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletCronServiceServer).ProcessVipDailyCoinRebateBatch(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletCronService_ProcessVipDailyCoinRebateBatch_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletCronServiceServer).ProcessVipDailyCoinRebateBatch(ctx, req.(*ProcessVipDailyCoinRebateBatchRequest))
}
return interceptor(ctx, in, info, handler)
}
// WalletCronService_ServiceDesc is the grpc.ServiceDesc for WalletCronService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -195,6 +229,10 @@ var WalletCronService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ProcessHostSalaryMonthEndBatch",
Handler: _WalletCronService_ProcessHostSalaryMonthEndBatch_Handler,
},
{
MethodName: "ProcessVipDailyCoinRebateBatch",
Handler: _WalletCronService_ProcessVipDailyCoinRebateBatch_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/wallet/v1/wallet.proto",
@ -258,6 +296,7 @@ const (
WalletService_PurchaseResourceShopItem_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseResourceShopItem"
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
WalletService_GetRechargeBillSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRechargeBillSummary"
WalletService_BatchGetUserRechargeStats_FullMethodName = "/hyapp.wallet.v1.WalletService/BatchGetUserRechargeStats"
WalletService_GetRechargeBillOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRechargeBillOverview"
WalletService_RefreshGooglePaymentPrices_FullMethodName = "/hyapp.wallet.v1.WalletService/RefreshGooglePaymentPrices"
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
@ -286,13 +325,23 @@ const (
WalletService_GetDiamondExchangeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetDiamondExchangeConfig"
WalletService_ListWalletTransactions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListWalletTransactions"
WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages"
WalletService_GetVipProgramConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetVipProgramConfig"
WalletService_GetMyVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyVip"
WalletService_CheckVipBenefit_FullMethodName = "/hyapp.wallet.v1.WalletService/CheckVipBenefit"
WalletService_PurchaseVip_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseVip"
WalletService_DebitCPBreakupFee_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitCPBreakupFee"
WalletService_DebitWheelDraw_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitWheelDraw"
WalletService_GrantVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantVip"
WalletService_GrantVipTrialCard_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantVipTrialCard"
WalletService_EquipVipTrialCard_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipVipTrialCard"
WalletService_UnequipVipTrialCard_FullMethodName = "/hyapp.wallet.v1.WalletService/UnequipVipTrialCard"
WalletService_ListAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminVipLevels"
WalletService_UpdateAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateAdminVipLevels"
WalletService_UpdateAdminVipProgramConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateAdminVipProgramConfig"
WalletService_UpdateMyVipSettings_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateMyVipSettings"
WalletService_GetMyCurrentVipDailyCoinRebate_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyCurrentVipDailyCoinRebate"
WalletService_ListMyVipDailyCoinRebateStatuses_FullMethodName = "/hyapp.wallet.v1.WalletService/ListMyVipDailyCoinRebateStatuses"
WalletService_ClaimVipDailyCoinRebate_FullMethodName = "/hyapp.wallet.v1.WalletService/ClaimVipDailyCoinRebate"
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
WalletService_CreditLuckyGiftReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditLuckyGiftReward"
WalletService_CreditWheelReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditWheelReward"
@ -374,6 +423,7 @@ type WalletServiceClient interface {
PurchaseResourceShopItem(ctx context.Context, in *PurchaseResourceShopItemRequest, opts ...grpc.CallOption) (*PurchaseResourceShopItemResponse, error)
ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error)
GetRechargeBillSummary(ctx context.Context, in *GetRechargeBillSummaryRequest, opts ...grpc.CallOption) (*GetRechargeBillSummaryResponse, error)
BatchGetUserRechargeStats(ctx context.Context, in *BatchGetUserRechargeStatsRequest, opts ...grpc.CallOption) (*BatchGetUserRechargeStatsResponse, error)
GetRechargeBillOverview(ctx context.Context, in *GetRechargeBillOverviewRequest, opts ...grpc.CallOption) (*GetRechargeBillOverviewResponse, error)
RefreshGooglePaymentPrices(ctx context.Context, in *RefreshGooglePaymentPricesRequest, opts ...grpc.CallOption) (*RefreshGooglePaymentPricesResponse, error)
GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error)
@ -402,13 +452,23 @@ type WalletServiceClient interface {
GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(ctx context.Context, in *ListWalletTransactionsRequest, opts ...grpc.CallOption) (*ListWalletTransactionsResponse, error)
ListVipPackages(ctx context.Context, in *ListVipPackagesRequest, opts ...grpc.CallOption) (*ListVipPackagesResponse, error)
GetVipProgramConfig(ctx context.Context, in *GetVipProgramConfigRequest, opts ...grpc.CallOption) (*GetVipProgramConfigResponse, error)
GetMyVip(ctx context.Context, in *GetMyVipRequest, opts ...grpc.CallOption) (*GetMyVipResponse, error)
CheckVipBenefit(ctx context.Context, in *CheckVipBenefitRequest, opts ...grpc.CallOption) (*CheckVipBenefitResponse, error)
PurchaseVip(ctx context.Context, in *PurchaseVipRequest, opts ...grpc.CallOption) (*PurchaseVipResponse, error)
DebitCPBreakupFee(ctx context.Context, in *DebitCPBreakupFeeRequest, opts ...grpc.CallOption) (*DebitCPBreakupFeeResponse, error)
DebitWheelDraw(ctx context.Context, in *DebitWheelDrawRequest, opts ...grpc.CallOption) (*DebitWheelDrawResponse, error)
GrantVip(ctx context.Context, in *GrantVipRequest, opts ...grpc.CallOption) (*GrantVipResponse, error)
GrantVipTrialCard(ctx context.Context, in *GrantVipTrialCardRequest, opts ...grpc.CallOption) (*GrantVipTrialCardResponse, error)
EquipVipTrialCard(ctx context.Context, in *EquipVipTrialCardRequest, opts ...grpc.CallOption) (*EquipVipTrialCardResponse, error)
UnequipVipTrialCard(ctx context.Context, in *UnequipVipTrialCardRequest, opts ...grpc.CallOption) (*UnequipVipTrialCardResponse, error)
ListAdminVipLevels(ctx context.Context, in *ListAdminVipLevelsRequest, opts ...grpc.CallOption) (*ListAdminVipLevelsResponse, error)
UpdateAdminVipLevels(ctx context.Context, in *UpdateAdminVipLevelsRequest, opts ...grpc.CallOption) (*UpdateAdminVipLevelsResponse, error)
UpdateAdminVipProgramConfig(ctx context.Context, in *UpdateAdminVipProgramConfigRequest, opts ...grpc.CallOption) (*UpdateAdminVipProgramConfigResponse, error)
UpdateMyVipSettings(ctx context.Context, in *UpdateMyVipSettingsRequest, opts ...grpc.CallOption) (*UpdateMyVipSettingsResponse, error)
GetMyCurrentVipDailyCoinRebate(ctx context.Context, in *GetMyCurrentVipDailyCoinRebateRequest, opts ...grpc.CallOption) (*GetMyCurrentVipDailyCoinRebateResponse, error)
ListMyVipDailyCoinRebateStatuses(ctx context.Context, in *ListMyVipDailyCoinRebateStatusesRequest, opts ...grpc.CallOption) (*ListMyVipDailyCoinRebateStatusesResponse, error)
ClaimVipDailyCoinRebate(ctx context.Context, in *ClaimVipDailyCoinRebateRequest, opts ...grpc.CallOption) (*ClaimVipDailyCoinRebateResponse, error)
CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error)
CreditLuckyGiftReward(ctx context.Context, in *CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*CreditLuckyGiftRewardResponse, error)
CreditWheelReward(ctx context.Context, in *CreditWheelRewardRequest, opts ...grpc.CallOption) (*CreditWheelRewardResponse, error)
@ -1004,6 +1064,16 @@ func (c *walletServiceClient) GetRechargeBillSummary(ctx context.Context, in *Ge
return out, nil
}
func (c *walletServiceClient) BatchGetUserRechargeStats(ctx context.Context, in *BatchGetUserRechargeStatsRequest, opts ...grpc.CallOption) (*BatchGetUserRechargeStatsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchGetUserRechargeStatsResponse)
err := c.cc.Invoke(ctx, WalletService_BatchGetUserRechargeStats_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetRechargeBillOverview(ctx context.Context, in *GetRechargeBillOverviewRequest, opts ...grpc.CallOption) (*GetRechargeBillOverviewResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetRechargeBillOverviewResponse)
@ -1284,6 +1354,16 @@ func (c *walletServiceClient) ListVipPackages(ctx context.Context, in *ListVipPa
return out, nil
}
func (c *walletServiceClient) GetVipProgramConfig(ctx context.Context, in *GetVipProgramConfigRequest, opts ...grpc.CallOption) (*GetVipProgramConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetVipProgramConfigResponse)
err := c.cc.Invoke(ctx, WalletService_GetVipProgramConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetMyVip(ctx context.Context, in *GetMyVipRequest, opts ...grpc.CallOption) (*GetMyVipResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetMyVipResponse)
@ -1294,6 +1374,16 @@ func (c *walletServiceClient) GetMyVip(ctx context.Context, in *GetMyVipRequest,
return out, nil
}
func (c *walletServiceClient) CheckVipBenefit(ctx context.Context, in *CheckVipBenefitRequest, opts ...grpc.CallOption) (*CheckVipBenefitResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CheckVipBenefitResponse)
err := c.cc.Invoke(ctx, WalletService_CheckVipBenefit_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) PurchaseVip(ctx context.Context, in *PurchaseVipRequest, opts ...grpc.CallOption) (*PurchaseVipResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PurchaseVipResponse)
@ -1334,6 +1424,36 @@ func (c *walletServiceClient) GrantVip(ctx context.Context, in *GrantVipRequest,
return out, nil
}
func (c *walletServiceClient) GrantVipTrialCard(ctx context.Context, in *GrantVipTrialCardRequest, opts ...grpc.CallOption) (*GrantVipTrialCardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GrantVipTrialCardResponse)
err := c.cc.Invoke(ctx, WalletService_GrantVipTrialCard_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) EquipVipTrialCard(ctx context.Context, in *EquipVipTrialCardRequest, opts ...grpc.CallOption) (*EquipVipTrialCardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(EquipVipTrialCardResponse)
err := c.cc.Invoke(ctx, WalletService_EquipVipTrialCard_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) UnequipVipTrialCard(ctx context.Context, in *UnequipVipTrialCardRequest, opts ...grpc.CallOption) (*UnequipVipTrialCardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UnequipVipTrialCardResponse)
err := c.cc.Invoke(ctx, WalletService_UnequipVipTrialCard_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListAdminVipLevels(ctx context.Context, in *ListAdminVipLevelsRequest, opts ...grpc.CallOption) (*ListAdminVipLevelsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListAdminVipLevelsResponse)
@ -1354,6 +1474,56 @@ func (c *walletServiceClient) UpdateAdminVipLevels(ctx context.Context, in *Upda
return out, nil
}
func (c *walletServiceClient) UpdateAdminVipProgramConfig(ctx context.Context, in *UpdateAdminVipProgramConfigRequest, opts ...grpc.CallOption) (*UpdateAdminVipProgramConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateAdminVipProgramConfigResponse)
err := c.cc.Invoke(ctx, WalletService_UpdateAdminVipProgramConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) UpdateMyVipSettings(ctx context.Context, in *UpdateMyVipSettingsRequest, opts ...grpc.CallOption) (*UpdateMyVipSettingsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateMyVipSettingsResponse)
err := c.cc.Invoke(ctx, WalletService_UpdateMyVipSettings_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetMyCurrentVipDailyCoinRebate(ctx context.Context, in *GetMyCurrentVipDailyCoinRebateRequest, opts ...grpc.CallOption) (*GetMyCurrentVipDailyCoinRebateResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetMyCurrentVipDailyCoinRebateResponse)
err := c.cc.Invoke(ctx, WalletService_GetMyCurrentVipDailyCoinRebate_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListMyVipDailyCoinRebateStatuses(ctx context.Context, in *ListMyVipDailyCoinRebateStatusesRequest, opts ...grpc.CallOption) (*ListMyVipDailyCoinRebateStatusesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListMyVipDailyCoinRebateStatusesResponse)
err := c.cc.Invoke(ctx, WalletService_ListMyVipDailyCoinRebateStatuses_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ClaimVipDailyCoinRebate(ctx context.Context, in *ClaimVipDailyCoinRebateRequest, opts ...grpc.CallOption) (*ClaimVipDailyCoinRebateResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ClaimVipDailyCoinRebateResponse)
err := c.cc.Invoke(ctx, WalletService_ClaimVipDailyCoinRebate_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreditTaskRewardResponse)
@ -1568,6 +1738,7 @@ type WalletServiceServer interface {
PurchaseResourceShopItem(context.Context, *PurchaseResourceShopItemRequest) (*PurchaseResourceShopItemResponse, error)
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
GetRechargeBillSummary(context.Context, *GetRechargeBillSummaryRequest) (*GetRechargeBillSummaryResponse, error)
BatchGetUserRechargeStats(context.Context, *BatchGetUserRechargeStatsRequest) (*BatchGetUserRechargeStatsResponse, error)
GetRechargeBillOverview(context.Context, *GetRechargeBillOverviewRequest) (*GetRechargeBillOverviewResponse, error)
RefreshGooglePaymentPrices(context.Context, *RefreshGooglePaymentPricesRequest) (*RefreshGooglePaymentPricesResponse, error)
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
@ -1596,13 +1767,23 @@ type WalletServiceServer interface {
GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(context.Context, *ListWalletTransactionsRequest) (*ListWalletTransactionsResponse, error)
ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error)
GetVipProgramConfig(context.Context, *GetVipProgramConfigRequest) (*GetVipProgramConfigResponse, error)
GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error)
CheckVipBenefit(context.Context, *CheckVipBenefitRequest) (*CheckVipBenefitResponse, error)
PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error)
DebitCPBreakupFee(context.Context, *DebitCPBreakupFeeRequest) (*DebitCPBreakupFeeResponse, error)
DebitWheelDraw(context.Context, *DebitWheelDrawRequest) (*DebitWheelDrawResponse, error)
GrantVip(context.Context, *GrantVipRequest) (*GrantVipResponse, error)
GrantVipTrialCard(context.Context, *GrantVipTrialCardRequest) (*GrantVipTrialCardResponse, error)
EquipVipTrialCard(context.Context, *EquipVipTrialCardRequest) (*EquipVipTrialCardResponse, error)
UnequipVipTrialCard(context.Context, *UnequipVipTrialCardRequest) (*UnequipVipTrialCardResponse, error)
ListAdminVipLevels(context.Context, *ListAdminVipLevelsRequest) (*ListAdminVipLevelsResponse, error)
UpdateAdminVipLevels(context.Context, *UpdateAdminVipLevelsRequest) (*UpdateAdminVipLevelsResponse, error)
UpdateAdminVipProgramConfig(context.Context, *UpdateAdminVipProgramConfigRequest) (*UpdateAdminVipProgramConfigResponse, error)
UpdateMyVipSettings(context.Context, *UpdateMyVipSettingsRequest) (*UpdateMyVipSettingsResponse, error)
GetMyCurrentVipDailyCoinRebate(context.Context, *GetMyCurrentVipDailyCoinRebateRequest) (*GetMyCurrentVipDailyCoinRebateResponse, error)
ListMyVipDailyCoinRebateStatuses(context.Context, *ListMyVipDailyCoinRebateStatusesRequest) (*ListMyVipDailyCoinRebateStatusesResponse, error)
ClaimVipDailyCoinRebate(context.Context, *ClaimVipDailyCoinRebateRequest) (*ClaimVipDailyCoinRebateResponse, error)
CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error)
CreditLuckyGiftReward(context.Context, *CreditLuckyGiftRewardRequest) (*CreditLuckyGiftRewardResponse, error)
CreditWheelReward(context.Context, *CreditWheelRewardRequest) (*CreditWheelRewardResponse, error)
@ -1799,6 +1980,9 @@ func (UnimplementedWalletServiceServer) ListRechargeBills(context.Context, *List
func (UnimplementedWalletServiceServer) GetRechargeBillSummary(context.Context, *GetRechargeBillSummaryRequest) (*GetRechargeBillSummaryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRechargeBillSummary not implemented")
}
func (UnimplementedWalletServiceServer) BatchGetUserRechargeStats(context.Context, *BatchGetUserRechargeStatsRequest) (*BatchGetUserRechargeStatsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchGetUserRechargeStats not implemented")
}
func (UnimplementedWalletServiceServer) GetRechargeBillOverview(context.Context, *GetRechargeBillOverviewRequest) (*GetRechargeBillOverviewResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRechargeBillOverview not implemented")
}
@ -1883,9 +2067,15 @@ func (UnimplementedWalletServiceServer) ListWalletTransactions(context.Context,
func (UnimplementedWalletServiceServer) ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListVipPackages not implemented")
}
func (UnimplementedWalletServiceServer) GetVipProgramConfig(context.Context, *GetVipProgramConfigRequest) (*GetVipProgramConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetVipProgramConfig not implemented")
}
func (UnimplementedWalletServiceServer) GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetMyVip not implemented")
}
func (UnimplementedWalletServiceServer) CheckVipBenefit(context.Context, *CheckVipBenefitRequest) (*CheckVipBenefitResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CheckVipBenefit not implemented")
}
func (UnimplementedWalletServiceServer) PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method PurchaseVip not implemented")
}
@ -1898,12 +2088,36 @@ func (UnimplementedWalletServiceServer) DebitWheelDraw(context.Context, *DebitWh
func (UnimplementedWalletServiceServer) GrantVip(context.Context, *GrantVipRequest) (*GrantVipResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GrantVip not implemented")
}
func (UnimplementedWalletServiceServer) GrantVipTrialCard(context.Context, *GrantVipTrialCardRequest) (*GrantVipTrialCardResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GrantVipTrialCard not implemented")
}
func (UnimplementedWalletServiceServer) EquipVipTrialCard(context.Context, *EquipVipTrialCardRequest) (*EquipVipTrialCardResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method EquipVipTrialCard not implemented")
}
func (UnimplementedWalletServiceServer) UnequipVipTrialCard(context.Context, *UnequipVipTrialCardRequest) (*UnequipVipTrialCardResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UnequipVipTrialCard not implemented")
}
func (UnimplementedWalletServiceServer) ListAdminVipLevels(context.Context, *ListAdminVipLevelsRequest) (*ListAdminVipLevelsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListAdminVipLevels not implemented")
}
func (UnimplementedWalletServiceServer) UpdateAdminVipLevels(context.Context, *UpdateAdminVipLevelsRequest) (*UpdateAdminVipLevelsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateAdminVipLevels not implemented")
}
func (UnimplementedWalletServiceServer) UpdateAdminVipProgramConfig(context.Context, *UpdateAdminVipProgramConfigRequest) (*UpdateAdminVipProgramConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateAdminVipProgramConfig not implemented")
}
func (UnimplementedWalletServiceServer) UpdateMyVipSettings(context.Context, *UpdateMyVipSettingsRequest) (*UpdateMyVipSettingsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateMyVipSettings not implemented")
}
func (UnimplementedWalletServiceServer) GetMyCurrentVipDailyCoinRebate(context.Context, *GetMyCurrentVipDailyCoinRebateRequest) (*GetMyCurrentVipDailyCoinRebateResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetMyCurrentVipDailyCoinRebate not implemented")
}
func (UnimplementedWalletServiceServer) ListMyVipDailyCoinRebateStatuses(context.Context, *ListMyVipDailyCoinRebateStatusesRequest) (*ListMyVipDailyCoinRebateStatusesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListMyVipDailyCoinRebateStatuses not implemented")
}
func (UnimplementedWalletServiceServer) ClaimVipDailyCoinRebate(context.Context, *ClaimVipDailyCoinRebateRequest) (*ClaimVipDailyCoinRebateResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ClaimVipDailyCoinRebate not implemented")
}
func (UnimplementedWalletServiceServer) CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreditTaskReward not implemented")
}
@ -2996,6 +3210,24 @@ func _WalletService_GetRechargeBillSummary_Handler(srv interface{}, ctx context.
return interceptor(ctx, in, info, handler)
}
func _WalletService_BatchGetUserRechargeStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchGetUserRechargeStatsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).BatchGetUserRechargeStats(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_BatchGetUserRechargeStats_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).BatchGetUserRechargeStats(ctx, req.(*BatchGetUserRechargeStatsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetRechargeBillOverview_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRechargeBillOverviewRequest)
if err := dec(in); err != nil {
@ -3500,6 +3732,24 @@ func _WalletService_ListVipPackages_Handler(srv interface{}, ctx context.Context
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetVipProgramConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetVipProgramConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetVipProgramConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetVipProgramConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetVipProgramConfig(ctx, req.(*GetVipProgramConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetMyVip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetMyVipRequest)
if err := dec(in); err != nil {
@ -3518,6 +3768,24 @@ func _WalletService_GetMyVip_Handler(srv interface{}, ctx context.Context, dec f
return interceptor(ctx, in, info, handler)
}
func _WalletService_CheckVipBenefit_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CheckVipBenefitRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).CheckVipBenefit(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_CheckVipBenefit_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).CheckVipBenefit(ctx, req.(*CheckVipBenefitRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_PurchaseVip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PurchaseVipRequest)
if err := dec(in); err != nil {
@ -3590,6 +3858,60 @@ func _WalletService_GrantVip_Handler(srv interface{}, ctx context.Context, dec f
return interceptor(ctx, in, info, handler)
}
func _WalletService_GrantVipTrialCard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GrantVipTrialCardRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GrantVipTrialCard(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GrantVipTrialCard_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GrantVipTrialCard(ctx, req.(*GrantVipTrialCardRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_EquipVipTrialCard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(EquipVipTrialCardRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).EquipVipTrialCard(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_EquipVipTrialCard_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).EquipVipTrialCard(ctx, req.(*EquipVipTrialCardRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_UnequipVipTrialCard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UnequipVipTrialCardRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).UnequipVipTrialCard(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_UnequipVipTrialCard_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).UnequipVipTrialCard(ctx, req.(*UnequipVipTrialCardRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListAdminVipLevels_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListAdminVipLevelsRequest)
if err := dec(in); err != nil {
@ -3626,6 +3948,96 @@ func _WalletService_UpdateAdminVipLevels_Handler(srv interface{}, ctx context.Co
return interceptor(ctx, in, info, handler)
}
func _WalletService_UpdateAdminVipProgramConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateAdminVipProgramConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).UpdateAdminVipProgramConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_UpdateAdminVipProgramConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).UpdateAdminVipProgramConfig(ctx, req.(*UpdateAdminVipProgramConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_UpdateMyVipSettings_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateMyVipSettingsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).UpdateMyVipSettings(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_UpdateMyVipSettings_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).UpdateMyVipSettings(ctx, req.(*UpdateMyVipSettingsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetMyCurrentVipDailyCoinRebate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetMyCurrentVipDailyCoinRebateRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetMyCurrentVipDailyCoinRebate(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetMyCurrentVipDailyCoinRebate_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetMyCurrentVipDailyCoinRebate(ctx, req.(*GetMyCurrentVipDailyCoinRebateRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListMyVipDailyCoinRebateStatuses_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListMyVipDailyCoinRebateStatusesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ListMyVipDailyCoinRebateStatuses(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ListMyVipDailyCoinRebateStatuses_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ListMyVipDailyCoinRebateStatuses(ctx, req.(*ListMyVipDailyCoinRebateStatusesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ClaimVipDailyCoinRebate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ClaimVipDailyCoinRebateRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ClaimVipDailyCoinRebate(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ClaimVipDailyCoinRebate_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ClaimVipDailyCoinRebate(ctx, req.(*ClaimVipDailyCoinRebateRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_CreditTaskReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreditTaskRewardRequest)
if err := dec(in); err != nil {
@ -4131,6 +4543,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetRechargeBillSummary",
Handler: _WalletService_GetRechargeBillSummary_Handler,
},
{
MethodName: "BatchGetUserRechargeStats",
Handler: _WalletService_BatchGetUserRechargeStats_Handler,
},
{
MethodName: "GetRechargeBillOverview",
Handler: _WalletService_GetRechargeBillOverview_Handler,
@ -4243,10 +4659,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListVipPackages",
Handler: _WalletService_ListVipPackages_Handler,
},
{
MethodName: "GetVipProgramConfig",
Handler: _WalletService_GetVipProgramConfig_Handler,
},
{
MethodName: "GetMyVip",
Handler: _WalletService_GetMyVip_Handler,
},
{
MethodName: "CheckVipBenefit",
Handler: _WalletService_CheckVipBenefit_Handler,
},
{
MethodName: "PurchaseVip",
Handler: _WalletService_PurchaseVip_Handler,
@ -4263,6 +4687,18 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GrantVip",
Handler: _WalletService_GrantVip_Handler,
},
{
MethodName: "GrantVipTrialCard",
Handler: _WalletService_GrantVipTrialCard_Handler,
},
{
MethodName: "EquipVipTrialCard",
Handler: _WalletService_EquipVipTrialCard_Handler,
},
{
MethodName: "UnequipVipTrialCard",
Handler: _WalletService_UnequipVipTrialCard_Handler,
},
{
MethodName: "ListAdminVipLevels",
Handler: _WalletService_ListAdminVipLevels_Handler,
@ -4271,6 +4707,26 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UpdateAdminVipLevels",
Handler: _WalletService_UpdateAdminVipLevels_Handler,
},
{
MethodName: "UpdateAdminVipProgramConfig",
Handler: _WalletService_UpdateAdminVipProgramConfig_Handler,
},
{
MethodName: "UpdateMyVipSettings",
Handler: _WalletService_UpdateMyVipSettings_Handler,
},
{
MethodName: "GetMyCurrentVipDailyCoinRebate",
Handler: _WalletService_GetMyCurrentVipDailyCoinRebate_Handler,
},
{
MethodName: "ListMyVipDailyCoinRebateStatuses",
Handler: _WalletService_ListMyVipDailyCoinRebateStatuses_Handler,
},
{
MethodName: "ClaimVipDailyCoinRebate",
Handler: _WalletService_ClaimVipDailyCoinRebate_Handler,
},
{
MethodName: "CreditTaskReward",
Handler: _WalletService_CreditTaskReward_Handler,

View File

@ -516,7 +516,7 @@ tasks:
timeout: 10s
lock_ttl: 30s
batch_size: 500
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
```
如果消息 owner 后续拆成独立 `message-service`这些配置迁移到新服务gateway 只需要更新 message gRPC 地址。

File diff suppressed because it is too large Load Diff

View File

@ -1,462 +1,182 @@
# VIP 系统架构设计
## 目标
VIP 是用户可直接用金币购买的限时会员权益。每个 VIP 等级都可以单独购买,单次购买有效期固定 7 天,不同等级价格不同。购买成功后,系统根据后台配置的资源组发放头像框、座驾、气泡、徽章、礼物等权益。
首版只做当前业务真实需要的能力:
- 用户可以购买任意有效 VIP 等级。
- 用户可以从低等级升级到高等级。
- 用户当前 VIP 未过期时,不能购买比当前等级低的 VIP。
- 同等级重复购买按续期处理。
- VIP 扣金币、会员状态变更、资源组发放必须在 `wallet-service` 内形成一致账务闭环。
- 后台只配置 VIP 等级、金币价格、有效期和赠送资源组;客户端不能传价格或资源明细。
# 可配置 VIP 系统
## 服务边界
## 目标与边界
VIP 属于 `wallet-service` 的账务和权益域,不放在 `user-service`
VIP 是 `wallet-service``app_code` 隔离的会员与权益域。每个 App 通过一份 `vip_program_configs` 选择等级数量、购买有效期策略、后台发放模式和体验卡开关gateway、room、admin 和客户端只能消费这份显式配置,不得通过 `app_code` 写第二套状态机。
原因:
- `wallet-service`:会员、体验卡、用户功能开关、权益矩阵、购买扣费、每日返现、资源发放、历史和 outbox 的 owner。
- `gateway-service`App HTTP 鉴权、协议转换和钱包 gRPC 编排。
- `room-service`:只执行房间权限并通过腾讯云 IM 投递当前房间事件,不能拥有会员状态。
- `server/admin`:配置 program、等级和每级完整权益发放真实 VIP 或体验卡。
- Flutter展示后端状态、操作背包佩戴并处理客户端生命周期效果不参与购买规则计算。
- 购买 VIP 必须先扣金币,扣费成功才允许生成 VIP 状态和权益。
- 购买 VIP 会发放后台资源组,资源组、资源权益和钱包资产已经归 `wallet-service` 管。
- 如果 VIP 状态放在 `user-service`,扣费、会员状态、资源发放会跨服务,需要分布式事务或补偿,复杂度和失败窗口都更大。
服务职责:
全链路业务时间使用 UTC epoch milliseconds。每日结算或刷新按 UTC 0 点切日;用户本地时间只用于展示。
- `gateway-service`:提供 App HTTP API做鉴权、参数校验、生成 `request_id`、透传客户端 `command_id`,调用 `wallet-service`
- `wallet-service`:拥有 VIP 等级配置、用户 VIP 状态、VIP 购买订单、金币扣费、资源组发放、VIP 事件 outbox。
- `user-service`:只在用户资料聚合时读取 VIP 摘要,不拥有 VIP 状态。
- `room-service`:需要展示 VIP 标识时读取用户资料/VIP 投影,不参与 VIP 购买主链路。
- `server/admin`:后台配置 VIP 等级和资源组,具体后台接口单独设计。
## 当前 App 策略
## 核心模型
| 配置 | Lalu | Fami |
| --- | --- | --- |
| `program_type` | `legacy_timed` | `tiered_privilege_v1` |
| `level_count` | 10 | 9 |
| 同级购买 | 从当前截止时间续期 | 从当前截止时间续期 |
| 高级升级 | 保留剩余时间后追加新时长 | 立即替换为高级,从购买时刻重新计时,旧等级剩余时间丢弃 |
| 低级购买 | 拒绝 | 拒绝 |
| 后台发放 | `direct_membership`,直接写真实 VIP | `trial_card`,发到背包后由用户佩戴 |
| 体验卡 | 关闭 | 开启 |
| 权益集合 | 每级显式保存 | 每级显式保存完整集合,不按 `unlock_level` 自动继承 |
### VIP 等级
Fami 的 VIP1VIP9 初始全部为 `disabled`。30 天只是不产生零时长脏配置的占位值;价格和资源组保持 0运营完成配置并启用前不可购买或发卡。
`vip_levels`
## 数据模型
| 字段 | 说明 |
| -------------------------- | -------------------------------------------------- |
| `id` | 内部主键 |
| `level` | VIP 等级,数字越大等级越高 |
| `name` | 展示名称,例如 VIP1、VIP2 |
| `coin_price` | 购买该等级需要消耗的金币数 |
| `duration_ms` | 有效期,首版固定 `7 * 24 * 60 * 60 * 1000` |
| `reward_resource_group_id` | 购买成功后发放的资源组 |
| `status` | `draft` / `active` / `disabled` |
| `sort_order` | App 展示排序 |
| `display_config_json` | 展示配置快照,例如颜色、权益说明、角标,不参与结算 |
| `created_at_ms` | 创建时间 |
| `updated_at_ms` | 更新时间 |
### App 级 program
约束
`vip_program_configs` 是行为规则的唯一来源:
- `level` 唯一。
- `coin_price > 0`
- `duration_ms` 首版必须等于 7 天,不允许后台临时改成其它值。
- `active` 状态必须绑定有效资源组。
- `program_type`
- `level_count`
- `same_level_expiry_policy`
- `upgrade_expiry_policy`
- `downgrade_purchase_policy`
- `benefit_inheritance_policy`,当前固定 `target_only`
- `grant_mode`
- `trial_card_enabled`
- `status`
- `config_version`
### 用户 VIP 当前状态
后台修改 program、等级或权益时递增 `config_version`。会员、订单和体验卡保留创建时的 program/version 快照,当前权限判断使用最新有效 program 和等级显式权益集合。
`user_vip_memberships`
### 等级和权益
| 字段 | 说明 |
| --------------- | ---------------------------- |
| `user_id` | 用户 ID唯一 |
| `level` | 当前等级 |
| `status` | `active` / `expired` |
| `started_at_ms` | 当前等级周期开始时间 |
| `expires_at_ms` | 当前 VIP 到期时间 |
| `last_order_id` | 最近一次购买订单 |
| `version` | 乐观版本,配合行锁防并发覆盖 |
| `created_at_ms` | 创建时间 |
| `updated_at_ms` | 更新时间 |
- `vip_levels`:等级名称、状态、金币价格、时长、可选资源组和排序。
- `vip_level_benefits`:以 `(app_code, level, benefit_code)` 保存该等级最终完整权益集合。
读取时以 `expires_at_ms > now` 判断是否有效,`status` 只作为后台检索和事件处理的辅助状态。这样即使过期任务延迟App 也不会把过期 VIP 当成有效权益
`unlock_level` 只记录 P1 初始化来源,运行时和后台保存都不能据此给高等级自动补权益。纯功能权益的 `resource_id=0`;装扮权益可以绑定同 App 的实际资源。
### VIP 购买订单
### 付费会员
`vip_purchase_orders`
`user_vip_memberships` 保存用户付费会员事实。有效条件是 `level > 0 && expires_at_ms > now_ms`;不依赖异步过期任务才能失效。
| 字段 | 说明 |
| ---------------------------- | --------------------------------- |
| `order_id` | VIP 订单号 |
| `command_id` | 客户端生成的幂等命令 ID |
| `user_id` | 购买用户 |
| `target_level` | 目标 VIP 等级 |
| `coin_price_snapshot` | 下单时价格快照 |
| `duration_ms_snapshot` | 下单时有效期快照 |
| `resource_group_id_snapshot` | 下单时资源组快照 |
| `previous_level` | 购买前有效等级,无有效 VIP 则为 0 |
| `previous_expires_at_ms` | 购买前到期时间 |
| `new_level` | 购买后等级 |
| `new_expires_at_ms` | 购买后到期时间 |
| `wallet_transaction_id` | 金币扣费交易 ID |
| `resource_grant_id` | 资源组发放记录 ID |
| `status` | `succeeded` / `failed` |
| `failure_reason` | 失败原因 |
| `request_hash` | 幂等请求摘要 |
| `created_at_ms` | 创建时间 |
| `updated_at_ms` | 更新时间 |
约束:
- `(user_id, command_id)` 唯一。
- 相同 `command_id` + 相同 `request_hash` 返回原订单结果。
- 相同 `command_id` + 不同 `request_hash` 返回幂等冲突。
### VIP 变更历史
`user_vip_history`
记录每次购买、续期、升级、过期,供客服、后台账单、风控排查使用。历史表不参与 App 主链路强查询。
核心字段:
- `history_id`
- `user_id`
- `event_type`: `purchase` / `renew` / `upgrade` / `expire`
- `from_level`
- `to_level`
- `from_expires_at_ms`
- `to_expires_at_ms`
- `order_id`
- `created_at_ms`
## 购买和升级规则
定义:
- 当前有效 VIP`membership.expires_at_ms > now && membership.level > 0`
- 目标等级:用户本次购买的 `vip_level`
规则:
| 场景 | 处理 |
| ---------------------- | ------------------------------------------------------------------------ |
| 无有效 VIP | 可以购买任意 active 等级,`expires_at_ms = now_ms + 7d_ms` |
| 有效 VIP购买同等级 | 续期,`expires_at_ms = current_expires_at_ms + 7d_ms` |
| 有效 VIP购买更高等级 | 立即升级,`level = target_level``expires_at_ms = current_expires_at_ms + 7d_ms` |
| 有效 VIP购买更低等级 | 拒绝,返回 `VIP_DOWNGRADE_NOT_ALLOWED` |
| VIP 已过期 | 按无有效 VIP 处理,可以购买任意 active 等级 |
首版升级采用“保留剩余时间并升级”的策略:如果用户 VIP1 还剩 3 天,又购买 VIP3则 VIP3 到期时间为当前 VIP1 到期时间再加 7 天。这个规则实现简单、用户感知清晰,也避免升级时损失已付费时长。
如果后续业务希望“补差价升级”或“升级只生效 7 天”,只需要扩展 VIP 等级的升级策略,不改变主链路表结构。
## 资源组发放规则
购买 VIP 成功后,`wallet-service` 根据 `vip_levels.reward_resource_group_id` 发放资源组。
资源组继续复用现有资源组能力:
- 头像框:`avatar_frame`
- 资料卡:`profile_card`
- 座驾:`vehicle`
- 聊天气泡:`chat_bubble`
- 徽章:`badge`
- 飘屏:`floating_screen`
- 礼物:`gift`
- 金币等钱包资产:仅在明确配置时发放,默认不建议 VIP 购买返金币,避免账务绕圈。
VIP 资源组需要增加一个明确约束:
- VIP 专属装扮类资源默认与 VIP 到期时间对齐。
- 资源组 item 如果配置了独立 `duration_ms`,后台必须能看到它和 VIP 7 天周期的差异。
- 首版推荐新增 `duration_policy = inherit_vip_expiry`,购买 VIP 时将资源权益到期时间直接设置为 `new_expires_at_ms`
续期和升级时:
- 同一资源重复发放时,使用资源权益的 `extend_expiry``align_to_expiry` 策略,避免重复插入导致用户出现多个同资源有效权益。
- 升级后只发放目标等级配置的资源组;低等级已获得的资源不立即删除,到期后自然失效。
- App 展示和自动佩戴时按用户当前有效资源、资源等级和佩戴状态决定,不从 VIP 等级反推资源。
## 主链路流程
```mermaid
sequenceDiagram
autonumber
participant App
participant Gateway as gateway-service
participant Wallet as wallet-service
participant DB as Wallet MySQL
participant Outbox as Wallet Outbox
App->>Gateway: POST /api/v1/vip/purchase {level, command_id}
Gateway->>Wallet: PurchaseVip(user_id, level, command_id, request_meta)
Wallet->>DB: BEGIN
Wallet->>DB: 查询并锁定 vip_levels(level)
Wallet->>DB: 查询并锁定 user_vip_memberships(user_id)
Wallet->>DB: 校验不能降级、计算 new_expires_at_ms
Wallet->>DB: 锁定用户 COIN 钱包账户
Wallet->>DB: 扣减金币,写 wallet_transaction / wallet_entries
Wallet->>DB: 发放 VIP 资源组,写 resource_entitlements
Wallet->>DB: upsert user_vip_memberships
Wallet->>DB: 写 vip_purchase_orders / user_vip_history
Wallet->>Outbox: 写 VipPurchased / VipUpgraded / VipRenewed
Wallet->>DB: COMMIT
Wallet-->>Gateway: 返回当前 VIP、扣费、权益摘要
Gateway-->>App: {code,message,request_id,data}
```
关键点:
- 金币扣费、VIP 状态、资源组发放必须在同一个 `wallet-service` 数据库事务中完成。
- 客户端只传 `level``command_id`,价格和资源组完全由服务端配置决定。
- 扣费成功但资源发放失败不能提交事务;否则用户会付费但拿不到权益。
- 资源发放成功但 VIP 状态更新失败也不能提交事务;否则权益和会员状态会不一致。
## App 一级接口
### 查询 VIP 等级
`GET /api/v1/vip/levels`
返回:
```json
{
"current_vip": {
"level": 2,
"name": "VIP2",
"expires_at_ms": 1760000000000,
"remaining_ms": 604800000
},
"levels": [
{
"level": 1,
"name": "VIP1",
"coin_price": 1000,
"duration_ms": 604800000,
"can_purchase": true,
"purchase_block_reason": "",
"benefits": [
{
"resource_type": "avatar_frame",
"resource_id": "vip1_frame",
"name": "VIP1 Avatar Frame",
"duration_policy": "inherit_vip_expiry"
}
]
}
]
}
```
说明:
- `can_purchase=false` 通常表示当前有效 VIP 等级高于该等级。
- App 可以展示所有等级,但购买按钮状态以服务端返回为准。
### 查询我的 VIP
`GET /api/v1/vip/me`
返回:
```json
{
"level": 2,
"name": "VIP2",
"status": "active",
"expires_at_ms": 1760000000000,
"remaining_ms": 604800000,
"renewable": true,
"upgradeable_levels": [3, 4, 5]
}
```
说明:
- 底部“我的”页面只需要拿 VIP 摘要,不要拉完整资源列表。
- 完整装扮资源仍从资源/背包接口查询。
### 购买 VIP
`POST /api/v1/vip/purchase`
请求:
```json
{
"level": 3,
"command_id": "client-generated-id"
}
```
返回:
```json
{
"order_id": "vip_order_123",
"event_type": "upgrade",
"coin_spent": 3000,
"vip": {
"level": 3,
"name": "VIP3",
"expires_at_ms": 1760604800000
},
"granted_resources": [
{
"resource_type": "vehicle",
"resource_id": "vip3_vehicle",
"expires_at_ms": 1760604800000
}
]
}
```
购买事务同时完成:
## gRPC 契约建议
新增 `wallet.v1.VipService`
```protobuf
service VipService {
rpc ListVipLevels(ListVipLevelsRequest) returns (ListVipLevelsResponse);
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
}
```
核心字段:
- `RequestMeta meta`
- `int64 user_id`
- `int32 level`
- `string command_id`
- `int64 now_ms` 不由客户端传,由服务端生成;测试可通过 clock 注入。
1. 锁定 program、目标等级和当前会员
2. 根据 program 计算有效期,拒绝降级;
3. 锁定 COIN 账户并扣费;
4. 发放目标资源组;
5. 更新会员,写订单、历史和 wallet outbox
6. 一次事务提交。
错误码建议纳入 `pkg/xerr` catalog
`command_id` 是购买幂等键,`request_id` 仅用于链路追踪。
- `VIP_LEVEL_NOT_FOUND`
- `VIP_LEVEL_DISABLED`
- `VIP_DOWNGRADE_NOT_ALLOWED`
- `VIP_PRICE_CHANGED`,仅在未来支持客户端价格确认时使用
- `WALLET_INSUFFICIENT_BALANCE`
- `IDEMPOTENCY_CONFLICT`
`vip_command_locks` 在任何会员、账户或资源业务锁之前串行化 `(app_code, command_id)`。购买、直接会员赠送和体验卡发放共享该守卫空间;同命令并发重试会等待原事务提交后读取稳定回执,三种业务也不能交叉复用同一命令号。
gRPC status 仍保持少量通用 code
### 体验卡
- 参数错误:`InvalidArgument`
- 余额不足、不能降级:`FailedPrecondition`
- 等级不存在或禁用:`NotFound` / `FailedPrecondition`
- 幂等冲突:`AlreadyExists`
- 内部事务失败:`Internal`
体验卡使用三层事实:
业务错误放在 `google.rpc.ErrorInfo.reason`
- `resources(resource_type=vip_trial_card)`:卡面与等级元数据;
- `user_resource_entitlements`:背包实例和绝对有效期;
- `user_resource_equipment`:当前单选佩戴指针;
- `user_vip_trial_cards`:等级、来源、时长和 program/version 快照。
## 并发和幂等
发卡时即写入 `effective_at_ms``expires_at_ms`,但不自动佩戴。切换卡片只替换 `user_resource_equipment` 指针,不修改、删除、暂停或延长任一卡片。
同一用户可能快速点击多次购买或同时升级,必须按用户串行化处理
例如 A=14 天、B=20 天、C=30 天:佩戴 A 后立刻切 BB 生效A 仍保留原绝对到期时间;之后只要 A 未过期即可重新佩戴。体验卡等级不受当前付费 VIP 或上一张体验卡等级限制。
事务内锁顺序固定:
### 用户功能开关
1. 锁 `vip_levels(level)`,读取配置快照。
2. 锁 `user_vip_memberships(user_id)`
3. 锁用户 `COIN` 钱包账户。
4. 写订单、流水、权益、会员状态。
`user_vip_settings``(app_code,user_id)` 保存两个用户偏好:
如果 `user_vip_memberships` 不存在,先插入一行占位再锁,或者用用户级 advisory lock。不要只依赖应用内 mutex因为多实例部署时无效。
- `room_entry_notice_enabled`:进房 VIP 高亮通知;
- `online_global_notice_enabled`:上线 VIP 全服飘屏。
幂等规则:
无记录时两项默认为 `true`,首次修改才写行。偏好不授予权益:无 VIP 或对应等级没有权益时,即使开关为开也不会放行。用户可在无 VIP、VIP 过期或切换体验卡时保留偏好Fami 与 Lalu 互不影响。
- `command_id` 是购买命令幂等键,不使用 `request_id`
- 已成功订单重复请求直接返回原结果。
- 已失败订单是否允许重试取决于失败类型:余额不足可以用新 `command_id` 重试;系统错误如果事务未提交,可以继续用原 `command_id` 重试。
- 订单内保存价格、资源组、有效期快照,后台改配置不影响已成功订单解释。
### 每日金币返现
## VIP 过期
`daily_coin_rebate``metadata_json.coin_amount` 是该等级每日可领金币正整数。只有启用的付费 VIP 参与,体验卡即使历史配置误开 `trial_enabled` 也会被 wallet 二次排除。
VIP 是否有效以 `expires_at_ms` 为准。过期处理分两层
每个 App 每个 UTC 日期只有一个 `vip_daily_coin_rebate_runs`
- App 查询时实时判断,过期立即返回 `status=expired`
- 后台定时任务扫描过期会员,写 `user_vip_history(expire)``VipExpired` outbox用于消息通知、缓存清理和搜索投影更新。
1. 首个成功批次固化当时的 `config_version` 和等级金额矩阵;后续分页不重读新配置。
2. wallet 根据会员历史还原 UTC 0 点时的付费 VIP0 点后才购买或升级的状态从次日起参与。
3. 生成 `vip_daily_coin_rebates` 资格,领取窗口固定为 `[UTC 当日0点,UTC 次日0点)`
4. 同事务写 `VipDailyCoinRebateAvailable` outboxactivity-service 幂等投影为“VIP金币返现”系统消息。
5. 用户手动领取时返现行锁、COIN 余额、交易、分录和 `WalletBalanceChanged` outbox 在一个事务内提交。
资源权益也以自己的 `expires_at_ms` 判断有效性。如果使用 `inherit_vip_expiry`VIP 到期后头像框、座驾等权益自然失效,不需要额外删除。
“日切资格”严格按 UTC 0 点;“金额配置”是当日首个成功 run 的快照,不伪造系统尚未保存的 0 点历史配置。正常情况下 cron 每分钟触发;如零点后 wallet/cron 长时间不可用,恢复前的后台金额修改会进入当日快照。运营若要保证修改次日生效,应在当日 run 已创建后修改。
## Outbox 事件
## 最终生效状态
`wallet-service` 写出以下事件:
- `VipPurchased`
- `VipRenewed`
- `VipUpgraded`
- `VipExpired`
所有调用方统一消费 `VipState`
事件内容:
- `paid_vip`:真实购买会员;
- `equipped_trial_card`:当前有效且已佩戴的体验卡;
- `effective_vip`:最终展示和权限使用的 VIP
- `effective_source``paid``trial``none`
- `effective_benefits`:最终身份拥有的权益资格;可关闭的通知类权益还要与 `user_settings` 做 AND
- `program_config`:当前 App 规则;
- `user_settings`:当前 App 下的进房/上线通知偏好。
- `event_id`
- `user_id`
- `order_id`
- `from_level`
- `to_level`
- `expires_at_ms`
- `coin_spent`
- `resource_group_id`
- `occurred_at_ms`
program 启用时的优先级固定为:`trial_card_enabled=true` 且体验卡有效并已佩戴时覆盖展示;否则使用有效付费会员;二者都无效则为 `none`。关闭体验卡开关只停止它参与最终权限合并,背包和佩戴原始事实仍保留;关闭整个 program 时付费与卡片原始事实仍可查询,但最终身份为 `none`、不下发权益。
消费者:
钱包只读取 `effective_vip.level` 对应的显式权益行。来源为 `trial` 时,再移除 `trial_enabled=false` 的权益;当前 `daily_coin_rebate` 不对体验卡开放。owner service 必须按 `benefit_code` 判断,不能改回 `level >= N`;进房和上线通知的执行方必须使用 wallet 已合并用户开关的结果。
- `user-service` 可消费后更新用户资料投影,便于列表页快速展示 VIP 标识。
- `room-service` 如需房间内展示 VIP 角标,可消费投影事件或通过用户资料聚合读取,不接入购买主链路。
- `activity-service` 可用于 VIP 活动任务统计。
## P1 权益编码
## 缓存策略
| 等级 | 本级新增权益 |
| --- | --- |
| VIP1 | `vip_badge_identity``vip_medal``avatar_frame``vip_title` |
| VIP2 | `chat_bubble``vip_gift``entry_effect``visitor_history` |
| VIP3 | `voice_wave``profile_card``custom_room_background``room_image_message` |
| VIP4 | `animated_avatar``animated_room_cover``mic_skin``room_border``vehicle` |
| VIP5 | `colored_room_name``colored_nickname``colored_id``gift_tray_skin` |
| VIP6 | `hide_profile_data``custom_avatar_frame``leaderboard_invisible``daily_coin_rebate` |
| VIP7 | `custom_profile_card``anonymous_profile_visit``custom_gift` |
| VIP8 | `room_entry_notice``custom_pretty_id``custom_vehicle` |
| VIP9 | `anti_kick``anti_mute``online_global_notice` |
VIP 配置可以缓存,用户 VIP 状态不要只放 Redis。
初始化时高等级行已展开为完整集合,但这是 seed 结果,不是运行时继承规则。后台可以显式移除任一等级的某项权益。
- `vip_levels`:本地内存缓存 + 短 TTL后台改配置后通过版本号或事件刷新。
- `user_vip_memberships`MySQL 为准Redis 只做读优化。
- 购买成功后删除或刷新 `vip:me:{user_id}` 缓存。
- 过期时间短且强相关权益展示,缓存 TTL 不能超过 `min(60s, expires_at_ms-now)`
## 房间权限和 IM
## 后台配置约束
`room-service` 在需要房间权限时读取 wallet 的 `GetMyVip.state`
后台配置 VIP 等级时必须校验:
- `custom_room_background`P1 program 的房主保存或切换自定义背景前强校验Lalu 保持原逻辑。
- `anti_kick`:普通房主/管理员踢人时拒绝操作。
- `anti_mute`:普通房主/管理员新增禁言时拒绝操作。
- 平台封禁、风控、超级管理员使用独立系统治理入口,不经过上述普通房管守卫。
- `room_entry_notice`:权益有效且用户开关开启时,随 `JoinRoomResponse``RoomUserJoined` 和腾讯云 IM 当前房间系统消息携带;只在用户进入的当前房间展示,不做全服广播。
- 等级唯一且大于 0。
- 金币价格大于 0。
- 有效期首版固定 7 天。
- 绑定资源组必须存在且可用。
- 资源组里的装扮资源建议使用 `inherit_vip_expiry`
- 禁用等级只影响新购买,不影响已购买用户的剩余有效期。
- 修改价格只影响修改后的新订单,不回改历史订单。
`online_global_notice``POST /vip/online-notice` 触发gateway 先调用 wallet `CheckVipBenefit`,同时校验权益资格和用户开关;然后从 user-service 取服务端用户资料,最后写 activity-service 全局播报 outbox。腾讯云 IM 使用 `TIMCustomElem``Ext=im_broadcast``Desc=vip_online_notice``broadcast_type=vip_online_notice``scope=global`。Flutter 在进程内按 UTC 日控制触发:同一进程每天最多触发一次,不持久化该标记;杀进程重启后允许再次触发。服务端不做用户日级去重,但相同 `command_id` 的 HTTP 重试映射到同一 `event_id`。Flutter 不能直接向全服群发消息。
后台可以修改资源组,但已成功订单使用订单快照解释;用户已经拿到的权益不因为后台换资源组被自动替换。
## App HTTP API
## 数据一致性原则
统一前缀 `/api/v1`,响应使用 `{code,message,request_id,data}`
- VIP 购买不是普通“资源组发放”,它必须先扣金币。
- 不允许 gateway 先扣金币再单独调用资源发放。
- 不允许 user-service 写 VIP 状态。
- 不允许客户端传资源 ID、资源组 ID、价格。
- 不允许用 Redis 作为 VIP 当前状态唯一来源。
- 不允许后台直接改用户 VIP 状态来修单,修单必须走后台补单/补偿命令,并写历史和审计。
- `GET /vip/packages`program、可购买等级、每级权益和完整 state。
- `GET /vip/me`:付费、体验卡、最终 VIP、权益资格和用户功能开关。
- `POST /vip/purchase`:请求 `{command_id,level}`;价格、时长和资源由服务端读取。
- `GET|PATCH /vip/settings`:读取或局部修改进房/上线通知偏好;空 PATCH 拒绝。
- `POST /vip/online-notice`:请求 `{command_id}`,服务端校验权益、开关和资料后写全服播报 outbox。
- `GET /vip/coin-rebates/current`:返回当前 UTC 日返现资格;无资格或 cron 尚未生成时 `found=false`
- `POST /vip/coin-rebates/statuses`:按最多 100 个 `rebate_ids` 恢复系统消息真实状态,或用受限分页查历史。
- `POST /vip/coin-rebates/{rebate_id}/claim`:请求 `{command_id}`,金额、归属、过期和入账全由 wallet 裁决。
- `GET /users/me/resources?resource_type=vip_trial_card&active_only=true`:查询背包体验卡资源实例。
- `POST /vip/trial-cards/{entitlement_id}/equip`:佩戴指定背包实例。
- `DELETE /vip/trial-cards/equipped`:卸下当前体验卡。
- 历史通用背包装备/卸下接口命中 `vip_trial_card` 时由 wallet service 委托上述专用状态机禁止仓储层直接改装备槽Flutter VIP 流程仍使用专用接口以一次取得完整 `VipState`
- `POST /manager-center/vip-grants`gateway 按当前 program 自动选择直接会员或 30 天体验卡发放Fami 发卡不自动佩戴。
## 实现顺序
后台接口:
1. 扩展 protobuf新增 `VipService`、VIP 等级、我的 VIP、购买 VIP 响应结构。
2. 增加 MySQL 表:`vip_levels``user_vip_memberships``vip_purchase_orders``user_vip_history`
3. 在 `wallet-service` 实现 VIP repository先完成等级查询和用户 VIP 查询。
4. 实现 `PurchaseVip` 事务:等级校验、幂等、行锁、扣金币、资源组发放、会员状态更新、历史和 outbox。
5. 在 `gateway-service` 增加 App HTTP API`/api/v1/vip/levels``/api/v1/vip/me``/api/v1/vip/purchase`
6. 接入用户资料聚合:底部“我的”和房间用户信息只展示 VIP 摘要。
7. 增加过期扫描任务和 outbox 消费投影。
8. 再做后台 VIP 配置页面和后台补单/审计能力。
- `GET|PUT /v1/admin/activity/vip-program`
- `GET|PUT /v1/admin/activity/vip-levels`
- `POST /v1/admin/activity/vip-trial-card-grants`
- `POST /v1/admin/activity/vip-grants`,仅用于 `direct_membership` program
## 测试重点
## 权益落地边界
必须覆盖:
当前服务端已强制执行购买有效期、体验卡状态合并、用户通知开关、每日金币返现资格/消息/手动领取入账、自定义房间背景、防踢、防禁言、房内进场通知和上线全服通知。装扮类权益由已绑定的钱包资源与 Flutter 展示链路执行。
- 无 VIP 购买 VIP1 成功。
- VIP1 未过期时续费 VIP1到期时间追加 7 天。
- VIP1 未过期时购买 VIP3等级变 VIP3到期时间追加 7 天。
- VIP3 未过期时购买 VIP1 被拒绝。
- VIP 过期后可以购买任意等级。
- 金币不足时不写 VIP 状态、不发资源。
- 资源组发放失败时金币扣费回滚。
- 同一 `command_id` 重试返回同一订单。
- 同一用户并发购买时只产生符合顺序的一条最终状态。
- 后台禁用等级后不能新购买,但不影响已购买用户有效期。
以下权益仍只有稳定编码和资格下发,不能仅凭本架构宣称业务已完整闭环:访客匿名/隐藏、榜单隐身、房间发图、动态媒体、彩色样式和礼物托盘等。各 owner 实现时必须调用 `CheckVipBenefit` 或消费 `effective_benefits`,并补自己的存储、审计、风控和边界测试。

View File

@ -0,0 +1,82 @@
# Huwaa 首页房间筛选 Flutter 对接
## 地址
筛选目录:
```http
GET /api/v1/rooms/filters
Authorization: Bearer <access_token>
X-App-Code: huwaa
```
房间列表:
```http
GET /api/v1/rooms?tab=hot&region_id=1001
GET /api/v1/rooms?tab=hot&country_code=AE
```
目录接口要求用户已登录并完成资料。租户权威值是 access token claims 中的 `app_code``X-App-Code`/包名只参与请求入口解析,不能覆盖 token 或用来跨租户切换。Huwaa 返回本 App 的全部 active 区域和其中 enabled 国家;其他 App 只返回当前用户所在 active 区域及其国家。
## 参数
目录接口没有 query 参数。每个 option 都返回 `query_key``query_value`Flutter 按下面规则调用房间列表:
- All`query_key``query_value` 都为空,不传 `region_id``country_code`
- 区域:把 option 的 `query_key=region_id``query_value` 原样放进 query。
- 国家:把 option 的 `query_key=country_code``query_value` 原样放进 query。
- `region_id``country_code` 不能同时传;切换筛选项时清空旧 cursor再从第一页请求。
- 旧参数 `country` 继续兼容,新代码统一使用 `country_code`
`parent_region_id` 只用于把国家展示在所属区域下。筛选国家时只传 `country_code`,不要把 `parent_region_id` 同时作为 `region_id` 传入。
## 返回值
```json
{
"code": "OK",
"message": "ok",
"request_id": "req-room-filters",
"data": {
"all": {
"filter_type": "all",
"query_key": "",
"query_value": ""
},
"regions": [
{
"filter_type": "region",
"query_key": "region_id",
"query_value": "1001",
"region_id": 1001,
"region_code": "MIDDLE_EAST",
"region_name": "Middle East",
"sort_order": 10
}
],
"countries": [
{
"filter_type": "country",
"query_key": "country_code",
"query_value": "AE",
"parent_region_id": 1001,
"country_id": 971,
"country_code": "AE",
"country_name": "United Arab Emirates",
"country_display_name": "UAE",
"country_flag": "🇦🇪",
"sort_order": 10
}
]
}
}
```
目录的数组顺序已经使用后台 `sort_order`。国家只在“国家 enabled 且所属区域 active”时下发客户端不要自行拼接国家码或区域 ID。
`GET /api/v1/rooms` 响应中的旧 `data.countries` 字段继续保留供旧版当前区域国家筛选使用Huwaa 新版完整目录以本接口为准。
## 相关 IM/RTC
筛选目录和列表筛选不创建房间、不入腾讯云 IM 群,也不签发 RTC token。用户实际进入房间后继续按现有 JoinRoom 流程处理 IM/RTC。

View File

@ -123,7 +123,7 @@ responses:
schema:
$ref: "#/definitions/ErrorEnvelope"
Forbidden:
description: 已认证但被禁用、封禁或无权限。
description: 已认证但资料未完成、被禁用、封禁或无权限;常见 `code` 为 `PROFILE_REQUIRED`、`USER_DISABLED`、`PERMISSION_DENIED`
schema:
$ref: "#/definitions/ErrorEnvelope"
NotFound:
@ -1014,13 +1014,41 @@ paths:
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/filters:
get:
tags:
- rooms
summary: 首页房间区域与国家筛选目录
operationId: listRoomFilters
description: >-
返回可直接用于房间发现列表的筛选项。每个 option 的 query_key/query_value 可原样作为
GET /api/v1/rooms 的查询参数All 的两个字段为空,表示不附加 region_id/country_code。
Huwaa 返回当前 app scope 的全部 active 区域及其 enabled 国家,其他 App 只返回当前登录用户所在 active 区域,防止下发不可用的跨区选项。
该路由要求 access token 有效且用户资料已完成;资料未完成返回 403/PROFILE_REQUIRED。
security:
- BearerAuth: []
responses:
"200":
description: 查询成功;国家项只包含 enabled 国家与 active 区域映射的交集。
schema:
$ref: "#/definitions/RoomFilterCatalogEnvelope"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms:
get:
tags:
- rooms
summary: 房间发现列表
operationId: listRooms
description: 按当前登录用户的服务端 region_id 查询房间发现列表;响应卡片显式返回 `im_group_id`,当前值等于 `room_id`,但客户端不能硬编码该映射。
description: >-
Huwaa 不传 region_id/country_code 时查询 All传 region_id 时查指定 active 区域,传 country_code 时查指定 enabled 国家,两者互斥。
非 Huwaa App 仍按当前登录用户的服务端 region_id 隔离,不接受客户端 region_id。
置顶之后的普通房依次是本国家有人、其他国家/区域有人、本国家没人、其他国家/区域没人。
响应卡片显式返回 im_group_id当前值等于 room_id但客户端不能硬编码该映射。
security:
- BearerAuth: []
parameters:
@ -1049,6 +1077,23 @@ paths:
in: query
required: false
type: string
- name: region_id
in: query
required: false
type: integer
format: int64
minimum: 1
description: Huwaa 区域筛选;必须原样使用 `/api/v1/rooms/filters` 的 active `region_id`,不得与 `country_code` 同传。
- name: country_code
in: query
required: false
type: string
description: Huwaa 国家筛选;必须原样使用 `/api/v1/rooms/filters` 的 enabled 大写国家码,不得与 `region_id` 同传。
- name: country
in: query
required: false
type: string
description: 旧客户端兼容别名,等价于 `country_code`;新客户端统一使用 `country_code`。
responses:
"200":
description: 查询成功,`data.rooms[*].im_group_id` 是 JoinRoom 成功后客户端加入的腾讯 IM 房间群 ID。
@ -2155,6 +2200,7 @@ paths:
tags:
- resources
summary: 佩戴我的资源
description: vip_trial_card 会在 wallet-service 内部委托 VIP 专用状态机;该通用响应不含完整 VipStateVIP 客户端应使用 /api/v1/vip/trial-cards/{entitlement_id}/equip。
operationId: equipMyResource
security:
- BearerAuth: []
@ -2475,6 +2521,7 @@ definitions:
- DISPLAY_USER_ID_LEASE_EXPIRED
- DISPLAY_USER_ID_PAYMENT_REQUIRED
- USER_DISABLED
- PROFILE_REQUIRED
- PERMISSION_DENIED
- NOT_FOUND
- CONFLICT
@ -2492,6 +2539,7 @@ definitions:
- conflict
- insufficient balance
- permission denied
- profile required
- not found
request_id:
type: string
@ -4829,6 +4877,12 @@ definitions:
type: string
room_short_id:
type: string
country_code:
type: string
description: 房主国家投影的大写国家码,首页国家筛选和卡片展示均以该字段为准。
country_flag:
type: string
description: 根据 `country_code` 派生的国旗 emoji无有效国家投影时省略。
RoomListData:
type: object
properties:
@ -4838,6 +4892,134 @@ definitions:
$ref: "#/definitions/RoomListItemData"
next_cursor:
type: string
countries:
type: array
description: 兼容旧客户端的当前用户区域国家列表;完整跨区域目录请使用 `/api/v1/rooms/filters`。
items:
$ref: "#/definitions/RoomListCountryData"
RoomListCountryData:
type: object
required:
- country_code
properties:
country_id:
type: integer
format: int64
country_code:
type: string
country_name:
type: string
country_display_name:
type: string
country_flag:
type: string
sort_order:
type: integer
format: int32
RoomFilterAllData:
type: object
required:
- filter_type
- query_key
- query_value
properties:
filter_type:
type: string
enum: [all]
query_key:
type: string
description: 固定为空字符串;客户端不附加筛选参数。
query_value:
type: string
description: 固定为空字符串。
RoomFilterRegionData:
type: object
required:
- filter_type
- query_key
- query_value
- region_id
- region_code
- region_name
- sort_order
properties:
filter_type:
type: string
enum: [region]
query_key:
type: string
enum: [region_id]
query_value:
type: string
description: region_id 的十进制字符串,可原样回传。
region_id:
type: integer
format: int64
region_code:
type: string
region_name:
type: string
sort_order:
type: integer
format: int32
RoomFilterCountryData:
type: object
required:
- filter_type
- query_key
- query_value
- parent_region_id
- country_id
- country_code
- country_name
- country_display_name
- country_flag
- sort_order
properties:
filter_type:
type: string
enum: [country]
query_key:
type: string
enum: [country_code]
query_value:
type: string
description: 规范化后的大写 country_code可原样回传。
parent_region_id:
type: integer
format: int64
description: 仅用于客户端把国家分组到区域;筛选国家时不要同时传 region_id。
country_id:
type: integer
format: int64
country_code:
type: string
country_name:
type: string
country_display_name:
type: string
country_flag:
type: string
sort_order:
type: integer
format: int32
RoomFilterCatalogData:
type: object
required:
- all
- regions
- countries
properties:
all:
$ref: "#/definitions/RoomFilterAllData"
regions:
type: array
items:
$ref: "#/definitions/RoomFilterRegionData"
countries:
type: array
items:
$ref: "#/definitions/RoomFilterCountryData"
MyRoomData:
type: object
required:
@ -5581,6 +5763,13 @@ definitions:
properties:
data:
$ref: "#/definitions/RoomListData"
RoomFilterCatalogEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/RoomFilterCatalogData"
MyRoomEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"

View File

@ -79,6 +79,8 @@ graph LR
当前 `user_id` 是内部稳定主键,后台仍必须在所有 host/Agency/BD 查询、幂等和审计 detail 中带上 `app_code`。如果某些现有表还使用全局 `PRIMARY KEY(user_id)``PRIMARY KEY(command_id)``PRIMARY KEY(event_id)`,这只能说明当前实现暂时要求这些 ID 全局唯一;不能据此省略 `app_code`,也不能在多 App 写入前声称同一个 `command_id` 可以跨 App 复用。
经理中心创建 BD Leader 时,`app_code` 必须来自已验证的 App token不接受客户端自报租户。Huwaa 的 `manager_add_bd_leader` 搜索和创建可跨国家/区域;其他 App 仍要求经理和目标用户同国家。两类路径都必须保留 active Manager 和 `manager_add_bd_leader` 能力校验。
```sql
admin_operation_logs(
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
@ -432,8 +434,9 @@ service UserHostAdminService {
}
```
`CreateBDLeaderRequest` 必须携带 `region_id`。user-service 校验该区域 active锁定目标用户行必要时更新 `users.region_id`,再创建 `bd_profiles`;同一个 `command_id` 重试只返回已创建的 BD Leader 事实,不重复迁移。
这属于允许改变用户区域的后台关系管理路径之一;完整来源见 [User Region Change Sources](./用户区域与国家开发.md#user-region-change-sources)。
`CreateBDLeaderRequest` 不接受 `region_id`。user-service 锁定目标用户行,读取其当前 active `users.region_id`,再使用该区域创建或恢复 BD Leader 事实;不会因创建角色而迁移用户国家/区域。同一个 `command_id` 重试只返回已创建的 BD Leader 事实。
Agency 邀请、Host 主动申请、BD 邀请的区域例外也由 `user-service``app_code` 执行:仅 Huwaa 在邀请/申请创建及接受/审核阶段允许跨区,其他 App 保留区域校验。Agency 主动邀请 Host 不在例外内。Coin Seller 列表、子币商申请/审批和转账授权对所有 App 仍为同区域。
当前项目仍在开发阶段proto 可以按当前事实调整;修改 `api/proto` 后必须运行 `make proto` 并提交生成文件。
@ -445,7 +448,7 @@ service UserHostAdminService {
- Cycle calculation 使用 deterministic item key。重跑同一个 `draft` cycle 可以替换 draft items 或返回现有结果,不能重复插入。
- Cycle approve 必须锁 cycle 当前状态为 `draft`
- Posting 使用 `salary_item_id` 作为钱包幂等键。`post_failed` 重试只处理未 posted item。
- Relationship admin action 要锁目标用户的 `users``host_profiles``agencies``bd_profiles`,避免和 App 邀请接受并发造成身份冲突。后台创建 BD Leader 需要在同一事务内更新 `users.region_id``bd_profiles.region_id`
- Relationship admin action 要锁目标用户的 `users``host_profiles``agencies``bd_profiles`,避免和 App 邀请接受并发造成身份冲突。创建 BD Leader 使用目标用户当前 `users.region_id`,不在角色事务内改写用户区域
## Event And Outbox
@ -511,6 +514,9 @@ Outbox consumers can update App message inbox, BI, risk systems, and export jobs
| Posting fails after partial success | Retry posts only unposted items |
| Posted item needs correction | Adjustment item created; original item remains immutable |
| Admin closes Agency | Agency hidden from App search; existing facts remain auditable |
| Huwaa Manager creates a BD Leader in another country/region | Creation succeeds; target keeps its current region |
| Other App Manager creates a BD Leader in another country | Request is rejected before `CreateBDLeader` is called |
| Huwaa Coin Seller creates or approves a cross-region sub-seller relation | Request is rejected with the existing region boundary |
## Critical Rules
@ -520,4 +526,5 @@ Outbox consumers can update App message inbox, BI, risk systems, and export jobs
- BD/BD Leader base is downstream host salary only.
- Leader direct Agency salary source must be de-duplicated.
- Every admin mutation must be audited and idempotent.
- Huwaa 跨区例外只适用于 Agency 邀请、Host 主动申请、BD 邀请和经理创建 BD LeaderCoin Seller 仍为同区域。
- Redis cannot be the source of truth for policies, salary, relationships, or audit.

View File

@ -9,7 +9,7 @@
## Goals
- 用户申请加入 Agency 并通过后成为主播Agency 踢出用户只解除 Agency 归属,不取消主播身份。
- 用户只能搜索并申请加入自己所属区域的 active Agency
- Huwaa 用户可搜索并申请加入本 App 内的跨区域 active Agency其他 App 保留现有国家/区域限制
- Agency 归属于 BDBD 归属于 BD Leader。BD Leader 也是 BD只是具备邀请 BD 的能力。
- BD 可以邀请用户成为 AgencyBD Leader 可以邀请用户成为 BD也可以直接邀请用户成为 Agency。
- 用户被邀请成为 Agency 时自动成为主播;但已是主播的用户不能再成为 Agency。
@ -279,22 +279,20 @@ agency_memberships(
## Region Rules
用户所属区域来自 `user-service.users.region_id`,不能由客户端提交。
用户所属区域来自 `user-service.users.region_id`,不能由客户端提交。`app_code` 来自 gateway 已验证的 token 并写入 `RequestMeta`;客户端不能通过请求体或请求头伪造 Huwaa 例外。
国家/区域后台管理边界在 `hyapp-admin-server`:创建国家、启用/禁用国家、创建区域、启用/禁用区域不属于 App 侧 user-service RPC。App 侧只消费 user-service 已计算好的 `region_id` 和区域投影。
后台创建 BD Leader 是关系管理命令,不是客户端区域提交:`hyapp-admin-server` 负责权限和审计,`user-service` 在同一事务里把目标用户的 `users.region_id` 更新为后台选择的区域,并用同一个区域创建 `bd_profiles`
用户区域允许变化的完整来源以 [User Region Change Sources](./用户区域与国家开发.md#user-region-change-sources) 为准Host/Agency/BD 关系只消费当前 `users.region_id` 和各关系表里的区域快照。
用户区域允许变化的完整来源以 [User Region Change Sources](./用户区域与国家开发.md#user-region-change-sources) 为准Host/Agency/BD 关系创建后保留目标用户当前区域Huwaa 跨区组织关系不会顺带迁移 `users.region_id`
| Action | Region Rule |
| --- | --- |
| 后台创建 BD Leader | 后台必须选择 active 区域;目标用户当前区域可以不同,创建成功后目标用户 `users.region_id` 和 BD Leader `bd_profiles.region_id` 都变为所选区域 |
| 搜索 Agency | 只返回 `agency.region_id == user.region_id` 且 active/join_enabled |
| 申请加入 Agency | 申请人 `region_id` 必须等于 Agency `region_id` |
| BD 邀请 Agency | BD 和被邀请人必须在同一区域,除非后台显式跨区授权 |
| Leader 邀请 BD | Leader 和被邀请人必须在同一区域,除非后台显式跨区授权 |
| Leader 邀请 Agency | Leader 和被邀请人必须在同一区域,除非后台显式跨区授权 |
| 用户改国家导致区域变化 | 不自动迁移 Agency/BD 归属;新申请按新区域限制,历史统计按事件快照归属 |
如果后续业务需要跨区 Agency 或跨区 BD必须增加显式 `region_scope` 和后台审批,不能绕过区域规则直接查全局 Agency。
| 经理创建 BD Leader | Huwaa 经理可搜索并创建其他国家/区域的 BD Leader其他 App 保持经理与目标用户同国家。创建后 BD Leader 使用目标用户已有 `users.region_id`,不迁移用户国家/区域 |
| 搜索 Agency | Huwaa 返回本 App 内所有 active/join_enabled Agency其他 App 保留按当前用户国家过滤 |
| 申请加入 Agency | Huwaa 申请创建和 Agency 审核通过均允许跨区;其他 App 要求申请人与 Agency 同区域 |
| Agency 主动邀请 Host | 所有 App 仍要求目标用户与 Agency 同区域Huwaa 例外只适用于 Host 主动申请 |
| BD/Leader 邀请 Agency | Huwaa 的邀请创建和接受均允许跨区;其他 App 要求邀请人与目标用户同区域 |
| Leader 邀请 BD | Huwaa 的邀请创建和接受均允许跨区;其他 App 要求 Leader 与目标用户同区域 |
| Coin Seller | 所有 App 继续按区域返回 active 币商;子币商申请创建、审批建立关系和转账前授权均要求父子币商同区域 |
| 用户改国家导致区域变化 | 不自动迁移 Agency/BD 归属;新申请或邀请按 `app_code` 对应规则处理,历史统计按事件快照归属 |
## Application And Invitation Flows
@ -309,16 +307,16 @@ sequenceDiagram
C->>G: GET /agencies/search
G->>U: SearchAgenciesForUser(user_id)
U->>H: load user region and active agencies
H-->>U: active agencies in region
U-->>G: active agencies in region
U->>H: load app_code policy, user country/region and active agencies
H-->>U: Huwaa all active agencies; other App same-country agencies
U-->>G: app-scoped agency list
G-->>C: agency list
C->>G: POST /host/applications
G->>U: ApplyToAgency(user_id, agency_id)
U->>H: load user region and agency
H->>H: lock user host profile and agency
H->>H: create pending application
H->>H: enforce app-scoped region policy and create pending application
H-->>U: application
U-->>G: application
```
@ -353,7 +351,7 @@ Kick must not delete `host_profiles` and must not delete relationship history. T
BDInviteAgency(target_user_id)
-> target must not be active host
-> target must not own active agency
-> target region must match BD region
-> Huwaa allows cross-region; other App target region must match BD region
-> create role_invitation(type=agency, inviter_bd_user_id)
-> target accepts
-> create agency owned by target
@ -374,7 +372,7 @@ BD can invite self only when:
LeaderInviteBD(target_user_id)
-> inviter must be active bd_leader
-> target must not be active BD
-> target region must match leader region
-> Huwaa allows cross-region; other App target region must match leader region
-> create role_invitation(type=bd)
-> target accepts
-> create bd_profiles(role=bd, parent_leader_user_id=leader)
@ -610,7 +608,7 @@ Host/Agency/BD 的 App 可见通知统一进入 App `消息` tab 的 `system`
| BD joins another Agency | BD profile remains active; host membership is separate |
| BD wants own Agency after joining another Agency | Must first leave or be removed from active host membership; then self-invite can create Agency |
| Agency owner wants to leave own Agency | Not allowed through normal leave; Agency close or transfer is backend ability |
| User region changes | Existing relationships remain; new search/application uses new region; historical stats use event snapshots |
| User region changes | Existing relationships remain; new search/application/invitation follows the current App policy; historical stats use event snapshots |
| App earning summary and wallet balance differ | Wallet balance is authority for withdrawable amount; earning summary is business explanation |
| Client retries application or invitation action | Same `command_id` returns existing result; different `command_id` must hit uniqueness guard |
@ -622,7 +620,8 @@ Host/Agency/BD 的 App 可见通知统一进入 App `消息` tab 的 `system`
| Scenario | Expected |
| --- | --- |
| User searches Agency | Only same-region active joinable agencies returned |
| Huwaa user searches Agency | All active joinable agencies in Huwaa are returned, including other regions |
| Other App user searches Agency | Existing country/region restriction remains in force |
| User applies and Agency approves | User becomes active host and active Agency member |
| Agency kicks host | Membership ends, host profile remains active |
| Kicked host reapplies to another Agency | New active membership created |
@ -630,6 +629,9 @@ Host/Agency/BD 的 App 可见通知统一进入 App `消息` tab 的 `system`
| BD invites self as Agency while not host | Agency created, BD also becomes host and Agency owner |
| BD joins another Agency | BD remains BD and becomes host under that Agency |
| BD Leader invites BD | Target gets BD profile, not host profile |
| Huwaa cross-region Agency/BD invitation is accepted | Target keeps its own region and the invited relationship is created |
| Huwaa manager creates cross-region BD Leader | Target keeps its own region and receives active BD Leader identity |
| Huwaa user lists or invites Coin Seller | Only same-region sellers or sub-seller targets are allowed |
| Host works across two Agencies in one day | Stats split by relation snapshot |
| App reads earning summary before backend settlement | Shows pending/unsettled state, not withdrawable money |
| Same application command retried | Returns the original pending application |
@ -640,7 +642,7 @@ Host/Agency/BD 的 App 可见通知统一进入 App `消息` tab 的 `system`
- Agency owner is a host, but ordinary active host cannot become Agency.
- BD and host identities can coexist; BD role alone does not imply host.
- BD Leader is a BD with extra invitation rights.
- Region constraints are enforced by server-side `region_id`.
- Region policy is selected by authenticated `app_code`: Huwaa only exempts Agency invitation, Host application, BD invitation and manager-created BD Leader; other App paths and all Coin Seller relations retain server-side region restrictions.
- App cannot configure policies, approve salary, post wallet balance, or mutate withdrawals.
- Wallet is the only owner of salary balance and withdrawal state.
- All relationship changes must be auditable and idempotent.

View File

@ -152,21 +152,21 @@ tasks:
timeout: "3s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
user_region_rebuild:
enabled: true
interval: "5s"
timeout: "5s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
message_fanout:
enabled: true
interval: "1s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
mic_open_session_compensation:
enabled: true
interval: "60s"
@ -175,7 +175,7 @@ tasks:
batch_size: 100
pending_publish_max_age: "2m"
publishing_session_max_age: "12h"
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
```
## Cron Database

View File

@ -57,7 +57,8 @@
| GET | `/api/v1/users/me/friend-requests` | users | `listMyFriendApplications` | 我的好友申请列表 |
| GET | `/api/v1/users/me/friends` | users | `listMyFriends` | 我的好友列表 |
| DELETE | `/api/v1/users/{user_id}/friend` | users | `deleteFriend` | 双向删除好友关系 |
| GET | `/api/v1/rooms` | rooms | `listRooms` | 房间发现列表,支持 `tab=hot/new`,卡片返回 `im_group_id` |
| GET | `/api/v1/rooms/filters` | rooms | `listRoomFilters` | Flutter 首页区域/国家筛选目录,下发可原样回传的 `region_id/country_code` |
| GET | `/api/v1/rooms` | rooms | `listRooms` | 房间发现列表Huwaa 默认 All支持互斥的 `region_id/country_code``tab=hot/new`,卡片返回 `im_group_id` |
| GET | `/api/v1/rooms/me` | rooms | `getMyRoom` | 查询 Mine 顶部我的房间卡片 |
| GET | `/api/v1/rooms/feeds` | rooms | `listRoomFeeds` | Mine 页 Visited/Friend/Following/Followed 房间流 |
| GET | `/api/v1/rooms/current` | rooms | `getCurrentRoom` | 查询当前可恢复房间 |

View File

@ -1,6 +1,6 @@
# Voice Room Region Room List Architecture
本文档定义 App 房间列表的区域化架构。目标是让同一区域内的国家看到同一套房间列表,不同区域看到不同列表,同时不破坏现有 `Room Cell` 单房间状态 owner 模型。
本文档定义 App 房间列表的区域化架构。默认 App 仍按用户区域隔离Huwaa 首页默认读取全部区域,并允许客户端使用服务端目录下发的 active `region_id` 或 enabled `country_code` 做单一筛选。这些列表范围不改变现有 `Room Cell` 单房间状态 owner 模型。
本文的区域隔离始终发生在单个 `app_code` 内。不同 App 即使使用相同国家、区域和 `room_id`,房间列表也必须互相不可见。
@ -13,8 +13,8 @@
| App 国家/区域解析 | `user-service` | App 注册、改国家和用户投影只读 enabled 国家与 active 区域映射 |
| 用户区域归属 | `user-service` | 注册和改国家后写入 `users.region_id` |
| 房间可见区域 | `room-service` | 房间创建时绑定 `visible_region_id` |
| 房间列表读模型 | `room-service` | 按 `visible_region_id` 查询列表卡片 |
| HTTP 列表入口 | `gateway-service` | 鉴权后解析用户区域并调用 room-service |
| 房间列表读模型 | `room-service` | 按默认区域、All、指定区域或指定国家查询列表卡片 |
| HTTP 列表入口 | `gateway-service` | 鉴权后解析 App 策略,并用 active 区域目录校验客户筛选 |
| 实时进房校验 | `room-service` | 列表只负责发现,进入房间仍以 `JoinRoom` 为准 |
本阶段不做:
@ -22,15 +22,15 @@
| Excluded | Reason |
| --- | --- |
| 推荐系统 | 首版先实现区域隔离和稳定排序,不做个性化推荐 |
| 跨区域混排 | 产品语义要求不同区域列表隔离 |
| 客户端提交 `region_id` | 区域必须由服务端按用户国家计算,避免伪造 |
| 非 Huwaa App 客户端提交 `region_id` | 这些 App 仍按用户国家投影的服务端区域隔离 |
| Huwaa 客户端提交任意区域/国家值 | 只接受 `/api/v1/rooms/filters` 中 active 区域和 enabled 国家的字段值 |
| IP 国家决定房间列表 | `country_by_ip` 只用于审计和风控辅助,不代表用户选择国家 |
| 把列表塞进 Room Cell | Room Cell 是单房间高频状态 owner不承担多房间列表查询 |
| 创建者改国家自动迁移房间 | 房间可见区域是创建时确定的业务属性,避免直播中列表突然漂移 |
## Core Rule
房间列表按 `region_id` 隔离,而不是按 `country` 隔离。
非 Huwaa 房间列表默认`region_id` 隔离,而不是按 `country` 隔离。Huwaa All 会合并所有区域;普通房在置顶之后固定排成“本国家有人 → 其他国家/区域有人 → 本国家没人 → 其他国家/区域没人”。
例如:
@ -89,10 +89,10 @@ sequenceDiagram
participant R as room-service
participant S as MySQL/Redis
C->>G: GET /api/v1/rooms?tab=hot&limit=20
C->>G: GET /api/v1/rooms?tab=hot&limit=20[&region_id|country_code]
G->>U: GetUser(user_id)
U-->>G: region_id
G->>R: ListRooms(region_id, tab, cursor, limit)
G->>R: ListRooms(viewer_region_id, all/filter_region_id/country_code, tab, cursor, limit)
R->>S: Read room_list_entries + active room_region_pins
S-->>R: RoomListItem[]
R-->>G: ListRoomsResponse
@ -107,11 +107,12 @@ gateway -> room-service.JoinRoom -> Tencent IM join group callback guard
因此列表允许秒级最终一致;真正能否进入房间由 `JoinRoom` 和 IM 守卫决定。
区域置顶只影响当前 `visible_region_id` 的公共发现列表排序。`room_region_pins` 保存 `app_code + visible_region_id + room_id` 的运营置顶关系;查询时只 join `status=active``expires_at_ms > now_ms` 的记录,排序优先级为:
默认/指定区域列表只应用全区置顶、该 `visible_region_id` 的区域置顶和已选国家置顶。Huwaa All 会合并所有区域,但每个区域/国家置顶仍只能命中房间自身 `visible_region_id` 和房主国家,不会把某区置顶扩散到其他区域。`room_region_pins` 查询只使用 `status=active``expires_at_ms > now_ms` 的记录,排序优先级为:
1. 有效区域置顶在普通房间之前。
2. 置顶房间按 `weight DESC, expires_at_ms DESC, room_id ASC`
3. 普通房间继续按 `hot``sort_score DESC``new``created_at_ms DESC`
1. 同一房间同时命中多种置顶时固定选 `global > region > country`,次级类型不能用更高 weight 反超。
2. 有效全区/区域置顶在国家置顶和普通房间之前。
3. 选定置顶类型后,房间按 `weight DESC, expires_at_ms DESC, room_id ASC`
4. 普通房间继续按本国家/在线桶及 `hot``sort_score DESC``new``created_at_ms DESC`
置顶不进入 Room Cell、command log 或 snapshot它是房间列表读模型的运营控制面。房间改区域后旧区域置顶不会自动跨区域生效后台需要在新区域重新建立置顶。
@ -287,6 +288,8 @@ sort_score = heat * 1000 + online_count * 100 + occupied_seat_count * 10 + fresh
```text
GET /api/v1/rooms?tab=hot&cursor=&limit=20
GET /api/v1/rooms?tab=new&cursor=&limit=20
GET /api/v1/rooms?tab=hot&region_id=2002
GET /api/v1/rooms?tab=hot&country_code=BD
```
响应继续使用 gateway envelope
@ -322,6 +325,8 @@ GET /api/v1/rooms?tab=new&cursor=&limit=20
- `limit` 默认 `20`,最大 `50`
- `cursor` 使用不透明字符串,客户端不能解析或拼装。
- gateway 从 access token 得到 `user_id`,再查 user-service 获取 `region_id`
- Huwaa 不传 `region_id/country_code` 表示 All两者互斥且只能回传 `/api/v1/rooms/filters` 的字段值。
- 显式 `region_id` 写入不透明 cursor不允许把 A 区游标复用到 B 区。
- 如果 user-service 查询失败gateway 应返回错误,不应该用 IP 国家临时兜底。
- `im_group_id` 当前等于 `room_id`,只用于客户端准备腾讯 IM 房间群;真正入群必须等 `JoinRoom` 成功后由 IM 回调守卫校验 presence。
@ -341,12 +346,16 @@ message ListRoomsRequest {
string tab = 4;
string cursor = 5;
int32 limit = 6;
string query = 7;
string country_code = 8;
string viewer_country_code = 9;
bool all_visible_regions = 10;
int64 filter_region_id = 11;
}
message RoomListItem {
string room_id = 1;
int64 owner_user_id = 2;
int64 host_user_id = 3;
string title = 4;
string cover_url = 5;
string mode = 6;
@ -359,6 +368,7 @@ message RoomListItem {
string app_code = 13;
string room_short_id = 14;
bool locked = 15;
string country_code = 16;
}
message ListRoomsResponse {
@ -371,7 +381,7 @@ message ListRoomsResponse {
- 新增 proto 字段只能追加,不能复用字段号。
- `visible_region_id` 使用 `int64``0` 表示 `GLOBAL`
- 不要把 `country` 传给 room-service 列表接口;国家到区域的映射不属于 room-service
- 客户端原始 `country/country_code` 不能直传 room-servicegateway 先校验 enabled 国家与 active 区域映射,再传规范化的 `country_code` 给列表读模型
- `app_code` 只从 `RequestMeta` 来;客户端不能在列表 query/body 里自报另一个 App。
## Write Path Updates
@ -409,8 +419,8 @@ message ListRoomsResponse {
不允许:
- 用户看到其他区域专属列表。
- 客户端通过伪造 `region_id` 越权获取列表
- 非 Huwaa/无 All 权限的用户看到其他区域专属列表。
- Huwaa 客户端传入不在 `/api/v1/rooms/filters` active 目录中的 `region_id`,或同时传 `region_id/country_code`
- 列表查询直接访问 Room Cell 全量内存。
- Redis 成为唯一数据源。

View File

@ -83,6 +83,8 @@ var catalog = map[Code]Spec{
VIPLevelDisabled: spec(codes.FailedPrecondition, httpStatusConflict, VIPLevelDisabled, "vip level is disabled"),
VIPDowngradeNotAllowed: spec(codes.FailedPrecondition, httpStatusConflict, VIPDowngradeNotAllowed, "vip downgrade is not allowed"),
VIPRechargeRequired: spec(codes.FailedPrecondition, httpStatusConflict, VIPRechargeRequired, "vip recharge is required"),
VIPCoinRebateNotFound: spec(codes.NotFound, httpStatusNotFound, VIPCoinRebateNotFound, "vip coin rebate not found"),
VIPCoinRebateExpired: spec(codes.FailedPrecondition, httpStatusConflict, VIPCoinRebateExpired, "vip coin rebate expired"),
RedPacketDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketDisabled, "red packet is disabled"),
RedPacketInvalidAmountTier: spec(codes.InvalidArgument, httpStatusBadRequest, RedPacketInvalidAmountTier, "invalid argument"),

View File

@ -100,6 +100,10 @@ const (
VIPDowngradeNotAllowed Code = "VIP_DOWNGRADE_NOT_ALLOWED"
// VIPRechargeRequired 表示目标 VIP 等级需要用户先达到累计充值门槛。
VIPRechargeRequired Code = "VIP_RECHARGE_REQUIRED"
// VIPCoinRebateNotFound 表示返现不存在,或不属于当前 App/用户。
VIPCoinRebateNotFound Code = "VIP_COIN_REBATE_NOT_FOUND"
// VIPCoinRebateExpired 表示返现已越过 UTC 次日日切的排他结束边界。
VIPCoinRebateExpired Code = "VIP_COIN_REBATE_EXPIRED"
// RedPacketDisabled 表示红包功能未启用。
RedPacketDisabled Code = "RED_PACKET_DISABLED"

View File

@ -0,0 +1,357 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_wallet;
-- 本迁移只建立按 App 可配置的 VIP 规则和体验卡事实;不会为 Fami 猜测价格、有效期或资源组。
CREATE TABLE IF NOT EXISTS vip_program_configs (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
program_type VARCHAR(32) NOT NULL COMMENT '体系类型legacy_timed/tiered_privilege_v1',
level_count INT NOT NULL COMMENT '该 App 可配置等级数量',
same_level_expiry_policy VARCHAR(32) NOT NULL COMMENT '同级续费有效期策略extend_remaining',
upgrade_expiry_policy VARCHAR(32) NOT NULL COMMENT '升级有效期策略extend_remaining/replace_from_now',
downgrade_purchase_policy VARCHAR(32) NOT NULL COMMENT '降级购买策略reject',
benefit_inheritance_policy VARCHAR(32) NOT NULL COMMENT '权益矩阵策略:当前固定 target_only每级保存完整集合',
grant_mode VARCHAR(32) NOT NULL COMMENT '后台赠送语义direct_membership/trial_card',
trial_card_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否允许体验卡背包和佩戴流程',
status VARCHAR(32) NOT NULL COMMENT '业务状态active/disabled',
config_version BIGINT NOT NULL DEFAULT 1 COMMENT '配置版本;订单和会员状态持久化该快照版本',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最后更新管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code),
KEY idx_vip_program_status (status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='按 App 配置的 VIP 体系规则';
CREATE TABLE IF NOT EXISTS vip_level_benefits (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
level INT NOT NULL COMMENT '拥有该权益的具体 VIP 等级',
benefit_code VARCHAR(96) NOT NULL COMMENT '稳定权益编码,跨服务权限判断只使用该值',
name VARCHAR(128) NOT NULL COMMENT '权益展示名称',
benefit_type VARCHAR(32) NOT NULL COMMENT '权益类型decoration/function',
unlock_level INT NOT NULL COMMENT 'P1 初始解锁等级,仅用于展示来源,不参与运行时继承',
status VARCHAR(32) NOT NULL COMMENT '业务状态active/disabled',
trial_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '体验卡是否可获得;金币返现必须为 false',
resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '绑定资源 ID0 表示尚未绑定或纯功能权益',
resource_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '绑定资源类型;空表示纯功能或待运营配置',
execution_scope VARCHAR(32) NOT NULL COMMENT '实际执行方wallet/room/user/notice',
auto_equip BOOLEAN NOT NULL DEFAULT FALSE COMMENT '资源下发后是否自动佩戴最高等级样式',
sort_order INT NOT NULL DEFAULT 0 COMMENT '权益展示顺序',
metadata_json JSON NULL COMMENT '执行方扩展配置;初始 P1 权益不猜测参数',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, level, benefit_code),
KEY idx_vip_benefit_code (app_code, benefit_code, status, level),
KEY idx_vip_benefit_level_sort (app_code, level, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP 每等级完整权益矩阵';
-- 体验卡元数据和通用背包事实按 entitlement_id 一一对应;佩戴切换不更新卡片绝对过期时间。
CREATE TABLE IF NOT EXISTS user_vip_trial_cards (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
trial_card_id VARCHAR(96) NOT NULL COMMENT '体验卡实例 ID',
command_id VARCHAR(128) NOT NULL COMMENT '发卡命令幂等 ID',
request_hash VARCHAR(128) NOT NULL COMMENT '发卡请求语义哈希',
user_id BIGINT NOT NULL COMMENT '持有用户 ID',
entitlement_id VARCHAR(96) NOT NULL COMMENT '通用背包权益实例 ID也是佩戴入口标识',
resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '可选资源 ID0 表示直接按等级和时长发卡',
level INT NOT NULL COMMENT '体验卡 VIP 等级快照',
name VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'VIP 等级名称快照',
status VARCHAR(32) NOT NULL COMMENT '卡片业务状态active/revoked',
duration_ms BIGINT NOT NULL COMMENT '发卡时长快照;佩戴切换不改变该值',
effective_at_ms BIGINT NOT NULL COMMENT '卡片开始计时UTC epoch ms',
expires_at_ms BIGINT NOT NULL COMMENT '卡片绝对过期时间UTC epoch ms',
grant_source VARCHAR(32) NOT NULL COMMENT '发放来源',
source_grant_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '通用资源发放 ID',
operator_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '发放操作人用户 ID',
reason VARCHAR(256) NOT NULL DEFAULT '' COMMENT '发放原因',
program_type VARCHAR(32) NOT NULL COMMENT '发卡时 VIP 体系快照',
config_version BIGINT NOT NULL COMMENT '发卡时 VIP 配置版本',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, trial_card_id),
UNIQUE KEY uk_vip_trial_card_command (app_code, command_id),
UNIQUE KEY uk_vip_trial_card_entitlement (app_code, entitlement_id),
KEY idx_vip_trial_card_user (app_code, user_id, status, expires_at_ms),
KEY idx_vip_trial_card_resource (app_code, resource_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP 体验卡背包元数据';
-- purchase、直接会员赠送与体验卡发放共享同一命令守卫先串行幂等键再获取业务行锁。
CREATE TABLE IF NOT EXISTS vip_command_locks (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
command_id VARCHAR(128) NOT NULL COMMENT 'VIP 写命令幂等 ID',
biz_type VARCHAR(64) NOT NULL COMMENT '命令类型vip_purchase/vip_grant/vip_trial_card_grant',
request_hash VARCHAR(128) NOT NULL 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, command_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP 写命令串行化守卫';
-- 旧库按列逐项补齐;所有 ALTER 都可重复执行,且默认值保持 Lalu 的历史语义。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_memberships' AND COLUMN_NAME = 'program_type') = 0,
'ALTER TABLE user_vip_memberships ADD COLUMN program_type VARCHAR(32) NOT NULL DEFAULT ''legacy_timed'' COMMENT ''激活时使用的 VIP 体系快照'' AFTER status',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 历史成功命令必须在上线前进入同一守卫空间,否则升级后一段时间内仍可能走旧唯一键竞争路径。
INSERT IGNORE INTO vip_command_locks (
app_code, command_id, biz_type, request_hash, created_at_ms, updated_at_ms
)
SELECT app_code, command_id, biz_type, request_hash, created_at_ms, updated_at_ms
FROM wallet_transactions
WHERE biz_type IN ('vip_purchase', 'vip_grant');
INSERT IGNORE INTO vip_command_locks (
app_code, command_id, biz_type, request_hash, created_at_ms, updated_at_ms
)
SELECT app_code, command_id, 'vip_trial_card_grant', request_hash, created_at_ms, updated_at_ms
FROM user_vip_trial_cards;
-- Lalu 显式保持现状Fami 使用替换升级、体验卡赠送和每级完整权益集合。
INSERT IGNORE INTO vip_program_configs (
app_code, program_type, level_count, same_level_expiry_policy, upgrade_expiry_policy,
downgrade_purchase_policy, benefit_inheritance_policy, grant_mode, trial_card_enabled,
status, config_version, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES
('lalu', 'legacy_timed', 10, 'extend_remaining', 'extend_remaining',
'reject', 'target_only', 'direct_membership', FALSE, 'active', 1, 0, 0, 0),
('fami', 'tiered_privilege_v1', 9, 'extend_remaining', 'replace_from_now',
'reject', 'target_only', 'trial_card', TRUE, 'active', 1, 0, 0, 0);
-- Fami 等级初始均不可购买30 天只满足配置校验,价格与资源组保持 0须由运营确认后启用。
INSERT IGNORE INTO vip_levels (
app_code, level, name, status, price_coin, duration_ms, reward_resource_group_id,
required_recharge_coin_amount, sort_order, created_at_ms, updated_at_ms
) VALUES
('fami', 1, 'VIP1', 'disabled', 0, 2592000000, 0, 0, 10, 0, 0),
('fami', 2, 'VIP2', 'disabled', 0, 2592000000, 0, 0, 20, 0, 0),
('fami', 3, 'VIP3', 'disabled', 0, 2592000000, 0, 0, 30, 0, 0),
('fami', 4, 'VIP4', 'disabled', 0, 2592000000, 0, 0, 40, 0, 0),
('fami', 5, 'VIP5', 'disabled', 0, 2592000000, 0, 0, 50, 0, 0),
('fami', 6, 'VIP6', 'disabled', 0, 2592000000, 0, 0, 60, 0, 0),
('fami', 7, 'VIP7', 'disabled', 0, 2592000000, 0, 0, 70, 0, 0),
('fami', 8, 'VIP8', 'disabled', 0, 2592000000, 0, 0, 80, 0, 0),
('fami', 9, 'VIP9', 'disabled', 0, 2592000000, 0, 0, 90, 0, 0);
-- 每级直接持久化最终权益集合,不在运行时根据 unlock_level 自动继承。
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,
created_at_ms, updated_at_ms
)
SELECT
'fami', levels.level, benefits.benefit_code, benefits.name, benefits.benefit_type,
benefits.unlock_level, 'active', benefits.trial_enabled, 0, '', benefits.execution_scope,
benefits.auto_equip, benefits.sort_order,
CASE
WHEN benefits.benefit_code = 'online_global_notice'
THEN JSON_OBJECT('message', '欢迎进入Fami祝你有美好的一天')
ELSE NULL
END,
0, 0
FROM (
SELECT 1 AS level UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9
) AS levels
INNER JOIN (
SELECT 'vip_badge_identity' AS benefit_code, 'VIP标识' AS name, 'function' AS benefit_type, 1 AS unlock_level, TRUE AS trial_enabled, 'user' AS execution_scope, FALSE AS auto_equip, 10 AS sort_order
UNION ALL SELECT 'vip_medal', 'VIP勋章', 'decoration', 1, TRUE, 'user', TRUE, 20
UNION ALL SELECT 'avatar_frame', 'VIP头像框', 'decoration', 1, TRUE, 'user', TRUE, 30
UNION ALL SELECT 'vip_title', 'VIP称号', 'function', 1, TRUE, 'user', FALSE, 40
UNION ALL SELECT 'chat_bubble', 'VIP聊天气泡', 'decoration', 2, TRUE, 'room', TRUE, 50
UNION ALL SELECT 'vip_gift', 'VIP礼物', 'function', 2, TRUE, 'room', FALSE, 60
UNION ALL SELECT 'entry_effect', 'VIP进场通知', 'function', 2, TRUE, 'room', FALSE, 70
UNION ALL SELECT 'visitor_history', '查看访客', 'function', 2, TRUE, 'user', FALSE, 80
UNION ALL SELECT 'voice_wave', 'VIP声波纹', 'decoration', 3, TRUE, 'room', TRUE, 90
UNION ALL SELECT 'profile_card', 'VIP资料卡皮肤', 'decoration', 3, TRUE, 'user', TRUE, 100
UNION ALL SELECT 'custom_room_background', '自定义房间背景', 'function', 3, TRUE, 'room', FALSE, 110
UNION ALL SELECT 'room_image_message', '房间发图', 'function', 3, TRUE, 'room', FALSE, 120
UNION ALL SELECT 'animated_avatar', '动态头像', 'function', 4, TRUE, 'user', FALSE, 130
UNION ALL SELECT 'animated_room_cover', '动态房间封面', 'function', 4, TRUE, 'room', FALSE, 140
UNION ALL SELECT 'mic_skin', '麦克风皮肤', 'decoration', 4, TRUE, 'room', TRUE, 150
UNION ALL SELECT 'room_border', '房间边框', 'decoration', 4, TRUE, 'room', TRUE, 160
UNION ALL SELECT 'vehicle', 'VIP座驾', 'decoration', 4, TRUE, 'room', TRUE, 170
UNION ALL SELECT 'colored_room_name', 'VIP彩色房间名称', 'function', 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 '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
UNION ALL SELECT 'daily_coin_rebate', '金币返现', 'function', 6, FALSE, 'wallet', FALSE, 250
UNION ALL SELECT 'custom_profile_card', '定制资料卡', 'function', 7, TRUE, 'user', FALSE, 260
UNION ALL SELECT 'anonymous_profile_visit', '匿名访问主页', 'function', 7, TRUE, 'user', FALSE, 270
UNION ALL SELECT 'custom_gift', '定制礼物', 'function', 7, TRUE, 'room', FALSE, 280
UNION ALL SELECT 'room_entry_notice', '进房高亮通知', 'function', 8, TRUE, 'room', FALSE, 290
UNION ALL SELECT 'custom_pretty_id', '定制靓号', 'function', 8, TRUE, 'user', FALSE, 300
UNION ALL SELECT 'custom_vehicle', '定制座驾', 'function', 8, TRUE, 'room', FALSE, 310
UNION ALL SELECT 'anti_kick', '防踢', 'function', 9, TRUE, 'room', FALSE, 320
UNION ALL SELECT 'anti_mute', '防禁言', 'function', 9, TRUE, 'room', FALSE, 330
UNION ALL SELECT 'online_global_notice', '上线全服通知', 'function', 9, TRUE, 'notice', FALSE, 340
) AS benefits ON benefits.unlock_level <= levels.level;
-- 早期执行过本迁移的环境已有权益行;只给空元数据补默认文案,不覆盖运营已配置的 App 文案。
UPDATE vip_level_benefits
SET metadata_json = JSON_OBJECT('message', '欢迎进入Fami祝你有美好的一天')
WHERE app_code = 'fami'
AND benefit_code = 'online_global_notice'
AND COALESCE(JSON_LENGTH(metadata_json), 0) = 0;
-- resource_id 使用 resources 自增主键;空素材和仅含 vip_level 的 metadata 明确等待运营补齐视觉资产。
INSERT IGNORE INTO resources (
app_code, resource_code, resource_type, name, status, grantable, manager_grant_enabled,
grant_strategy, wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount,
usage_scope_json, asset_url, preview_url, animation_url, metadata_json, sort_order,
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
) VALUES
('fami', 'vip_trial_card_1', 'vip_trial_card', 'VIP1体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":1}', 10, 0, 0, 0, 0),
('fami', 'vip_trial_card_2', 'vip_trial_card', 'VIP2体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":2}', 20, 0, 0, 0, 0),
('fami', 'vip_trial_card_3', 'vip_trial_card', 'VIP3体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":3}', 30, 0, 0, 0, 0),
('fami', 'vip_trial_card_4', 'vip_trial_card', 'VIP4体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":4}', 40, 0, 0, 0, 0),
('fami', 'vip_trial_card_5', 'vip_trial_card', 'VIP5体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":5}', 50, 0, 0, 0, 0),
('fami', 'vip_trial_card_6', 'vip_trial_card', 'VIP6体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":6}', 60, 0, 0, 0, 0),
('fami', 'vip_trial_card_7', 'vip_trial_card', 'VIP7体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":7}', 70, 0, 0, 0, 0),
('fami', 'vip_trial_card_8', 'vip_trial_card', 'VIP8体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":8}', 80, 0, 0, 0, 0),
('fami', 'vip_trial_card_9', 'vip_trial_card', 'VIP9体验卡', 'active', TRUE, TRUE, 'new_entitlement', '', 0, 'free', 0, 0, '{}', '', '', '', '{"vip_level":9}', 90, 0, 0, 0, 0);
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_memberships' AND COLUMN_NAME = 'config_version') = 0,
'ALTER TABLE user_vip_memberships ADD COLUMN config_version BIGINT NOT NULL DEFAULT 1 COMMENT ''激活时使用的 VIP 配置版本'' AFTER program_type',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vip_purchase_orders' AND COLUMN_NAME = 'previous_expires_at_ms') = 0,
'ALTER TABLE vip_purchase_orders ADD COLUMN previous_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''购买前会员过期时间快照UTC epoch ms'' AFTER previous_level',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vip_purchase_orders' AND COLUMN_NAME = 'new_expires_at_ms') = 0,
'ALTER TABLE vip_purchase_orders ADD COLUMN new_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''购买后会员过期时间快照UTC epoch ms'' AFTER previous_expires_at_ms',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vip_purchase_orders' AND COLUMN_NAME = 'duration_ms') = 0,
'ALTER TABLE vip_purchase_orders ADD COLUMN duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''购买等级的有效期配置快照'' AFTER price_coin',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vip_purchase_orders' AND COLUMN_NAME = 'program_type') = 0,
'ALTER TABLE vip_purchase_orders ADD COLUMN program_type VARCHAR(32) NOT NULL DEFAULT ''legacy_timed'' COMMENT ''订单执行的 VIP 体系快照'' AFTER duration_ms',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vip_purchase_orders' AND COLUMN_NAME = 'config_version') = 0,
'ALTER TABLE vip_purchase_orders ADD COLUMN config_version BIGINT NOT NULL DEFAULT 1 COMMENT ''订单执行的 VIP 配置版本'' AFTER program_type',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vip_purchase_orders' AND COLUMN_NAME = 'expiry_policy') = 0,
'ALTER TABLE vip_purchase_orders ADD COLUMN expiry_policy VARCHAR(32) NOT NULL DEFAULT ''extend_remaining'' COMMENT ''本单实际采用的有效期策略'' AFTER config_version',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_history' AND COLUMN_NAME = 'previous_expires_at_ms') = 0,
'ALTER TABLE user_vip_history ADD COLUMN previous_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''变更前过期时间快照UTC epoch ms'' AFTER new_level',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_history' AND COLUMN_NAME = 'new_expires_at_ms') = 0,
'ALTER TABLE user_vip_history ADD COLUMN new_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''变更后过期时间快照UTC epoch ms'' AFTER previous_expires_at_ms',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_history' AND COLUMN_NAME = 'program_type') = 0,
'ALTER TABLE user_vip_history ADD COLUMN program_type VARCHAR(32) NOT NULL DEFAULT ''legacy_timed'' COMMENT ''变更使用的 VIP 体系快照'' AFTER new_expires_at_ms',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_history' AND COLUMN_NAME = 'config_version') = 0,
'ALTER TABLE user_vip_history ADD COLUMN config_version BIGINT NOT NULL DEFAULT 1 COMMENT ''变更使用的 VIP 配置版本'' AFTER program_type',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 老库 history 在上述有效期列刚增加时只能得到 0不能据此否定存量会员的日切资格。
-- 仅对“完全没有新格式有效期快照”的当前会员写入一条迁移基线previous_* 保持 0
-- 明确表示迁移不猜测基线前状态。稳定 order_id 与 NOT EXISTS 使脚本重复执行不增行,
-- 已有完整新格式 history 的用户也不会被覆盖或改写。
SET @vip_history_baseline_ms := CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO user_vip_history (
app_code, user_id, action, previous_level, new_level, previous_expires_at_ms,
new_expires_at_ms, program_type, config_version, order_id, created_at_ms
)
SELECT
memberships.app_code,
memberships.user_id,
'migration_baseline',
0,
memberships.level,
0,
memberships.expires_at_ms,
memberships.program_type,
memberships.config_version,
'vip_configurable_060_baseline',
@vip_history_baseline_ms
FROM user_vip_memberships AS memberships
WHERE memberships.level > 0
AND memberships.expires_at_ms > 0
AND NOT EXISTS (
SELECT 1
FROM user_vip_history AS complete_history
WHERE complete_history.app_code = memberships.app_code
AND complete_history.user_id = memberships.user_id
AND complete_history.new_level > 0
AND complete_history.new_expires_at_ms > 0
)
AND NOT EXISTS (
SELECT 1
FROM user_vip_history AS existing_baseline
WHERE existing_baseline.app_code = memberships.app_code
AND existing_baseline.user_id = memberships.user_id
AND existing_baseline.order_id = 'vip_configurable_060_baseline'
);

View File

@ -0,0 +1,75 @@
-- VIP 每日金币返现由 wallet-service 独占:日 run 固化 UTC 日切金额矩阵,用户返现行保存资格、窗口和领取交易。
-- 本迁移可重复执行;不猜测任一等级金额,历史空配置会安全关闭,等待运营填 metadata_json.coin_amount 后再启用。
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_wallet;
CREATE TABLE IF NOT EXISTS vip_daily_coin_rebate_runs (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
task_day CHAR(10) NOT NULL COMMENT 'UTC 日期YYYY-MM-DD',
run_id VARCHAR(96) NOT NULL COMMENT '稳定批任务 ID',
day_start_ms BIGINT NOT NULL COMMENT 'UTC 当日开始epoch ms',
day_end_ms BIGINT NOT NULL COMMENT 'UTC 次日开始,排他结束 epoch ms',
config_version BIGINT NOT NULL COMMENT '创建 run 时 VIP 配置版本',
level_amounts_json JSON NOT NULL COMMENT '等级到返现金额的不可变快照',
status VARCHAR(32) NOT NULL COMMENT 'running/completed',
last_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '已扫描会员游标',
scanned_count BIGINT NOT NULL DEFAULT 0 COMMENT '累计扫描会员数',
created_count 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',
completed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '完成时间UTC epoch ms',
PRIMARY KEY (app_code, task_day),
UNIQUE KEY uk_vip_rebate_run_id (app_code, run_id),
KEY idx_vip_rebate_run_status (app_code, status, task_day)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP 每日金币返现运行快照';
CREATE TABLE IF NOT EXISTS vip_daily_coin_rebates (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
rebate_id VARCHAR(96) NOT NULL COMMENT '返现事实 ID',
run_id VARCHAR(96) NOT NULL COMMENT '来源日 run ID',
task_day CHAR(10) NOT NULL COMMENT 'UTC 日期YYYY-MM-DD',
user_id BIGINT NOT NULL COMMENT '领取用户 ID',
vip_level INT NOT NULL COMMENT 'UTC 日切时付费 VIP 等级快照',
vip_name VARCHAR(64) NOT NULL COMMENT 'VIP 名称快照',
coin_amount BIGINT NOT NULL COMMENT '返现金币正整数快照',
status VARCHAR(32) NOT NULL COMMENT 'claimable/claimedexpired 由查询层投影',
available_at_ms BIGINT NOT NULL COMMENT '领取开始UTC epoch ms',
expires_at_ms BIGINT NOT NULL COMMENT '领取排他结束UTC epoch ms',
config_version BIGINT NOT NULL COMMENT '来源 run 的 VIP 配置版本',
claimed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '实际领取时间UTC epoch ms',
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '领取入账交易 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, rebate_id),
UNIQUE KEY uk_vip_rebate_user_day (app_code, task_day, user_id),
KEY idx_vip_rebate_user_day (app_code, user_id, task_day, rebate_id),
KEY idx_vip_rebate_expiry (app_code, status, expires_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP 每日金币返现领取事实';
UPDATE vip_level_benefits
SET status = 'disabled', trial_enabled = FALSE
WHERE benefit_code = 'daily_coin_rebate'
AND (
JSON_EXTRACT(metadata_json, '$.coin_amount') IS NULL
OR JSON_TYPE(JSON_EXTRACT(metadata_json, '$.coin_amount')) <> 'INTEGER'
OR CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_amount')) AS DECIMAL(65, 0)) <= 0
OR CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_amount')) AS DECIMAL(65, 0)) > 9223372036854775807
);
UPDATE vip_level_benefits
SET trial_enabled = FALSE, execution_scope = 'wallet'
WHERE benefit_code = 'daily_coin_rebate';
UPDATE vip_level_benefits
SET execution_scope = 'notice'
WHERE benefit_code = 'online_global_notice';
-- 已经先上线过领取交易的环境补齐统一 VIP command guard同 command 不能再被购买、发卡或另一返现复用。
INSERT IGNORE INTO vip_command_locks (
app_code, command_id, biz_type, request_hash, created_at_ms, updated_at_ms
)
SELECT app_code, command_id, biz_type, request_hash, created_at_ms, updated_at_ms
FROM wallet_transactions
WHERE biz_type = 'vip_daily_coin_rebate';

View File

@ -0,0 +1,15 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_wallet;
-- 用户级通知开关只保存偏好,不改变 VIP 资格或 effective_benefits历史用户不批量补行
-- 查询无记录时由 wallet-service 统一返回默认开启。
CREATE TABLE IF NOT EXISTS user_vip_settings (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL COMMENT '用户 ID',
room_entry_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启进房通知',
online_global_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE 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, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户按 App 隔离的 VIP 功能开关';

View File

@ -276,11 +276,23 @@ func main() {
auth := service.NewAuthService(cfg.JWTSecret, cfg.AccessTokenTTL)
auditHandler := auditmodule.New(store)
countryRegionHandler := countryregionmodule.New(userclient.NewGRPC(userConn), userDB, store, roomClient, cfg, auditHandler)
appUserHandler := appusermodule.New(
userclient.NewGRPC(userConn),
activityclient.NewGRPC(activityConn),
sqlDB,
userDB,
walletDB,
cfg,
auditHandler,
appusermodule.WithWalletClient(walletclient.NewGRPC(walletConn)),
appusermodule.WithExportJobs(store, cfg),
)
var runner *jobrunner.Runner
var jobStatus healthmodule.JobStatusProvider
if cfg.Jobs.Enabled {
runner = jobrunner.NewRunner(store, redisClient, cfg,
jobrunner.WithHandler(countryregionmodule.CountryCodeRenameJobType, countryRegionHandler.Service().HandleCountryCodeRenameJob),
jobrunner.WithArtifactHandler(appusermodule.AppUserExportJobType, appUserHandler.HandleExportJob),
)
runner.Start()
defer runner.Close()
@ -345,7 +357,7 @@ func main() {
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler),
AppRegistry: appregistrymodule.New(userDB),
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
AppUser: appUserHandler,
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
CountryRegion: countryRegionHandler,
CPRelation: cprelationmodule.NewWithActivity(userDB, activityclient.NewGRPC(activityConn), auditHandler),

View File

@ -217,6 +217,8 @@
| 权限码 | kind | 控制范围 |
| --- | --- | --- |
| `app-user:view` | `menu` | App 用户列表、详情 |
| `app-user:level` | `button` | 临时调整 App 用户富豪/魅力等级 |
| `app-user:export` | `button` | 按当前筛选条件异步导出 App 用户 |
| `app-user:update` | `button` | 修改用户资料 |
| `app-user:status` | `button` | 后台封禁、解封 |
| `app-user:password` | `button` | 后台设置 App 用户密码 |

View File

@ -70,6 +70,24 @@ return 0`
}, nil
}
// RefreshLock 只延长仍由当前 owner 持有的锁;任务 worker 续租 DB lease 时同步续租 Redis
// 防止大导出超过初始 TTL 后被另一实例重复执行并覆盖产物。
func (r *Redis) RefreshLock(ctx context.Context, key string, owner string, ttl time.Duration) (bool, error) {
if r == nil || r.client == nil {
return true, nil
}
if strings.TrimSpace(owner) == "" || ttl <= 0 {
return false, errors.New("lock owner and positive ttl are required")
}
const script = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0`
result, err := r.client.Eval(ctx, script, []string{r.key(key)}, owner, ttl.Milliseconds()).Int64()
return result == 1, err
}
func (r *Redis) key(key string) string {
if r.prefix == "" {
return key

View File

@ -324,6 +324,16 @@ func (c *GRPCClient) SetUserLevel(ctx context.Context, req *activityv1.SetUserLe
return c.levelClient.SetUserLevel(ctx, req)
}
// BatchGetUserLevelAdminProfiles 读取真实成长账户与有效 overlay避免 admin-server 复制等级计算规则。
func (c *GRPCClient) BatchGetUserLevelAdminProfiles(ctx context.Context, req *activityv1.BatchGetUserLevelAdminProfilesRequest) (*activityv1.BatchGetUserLevelAdminProfilesResponse, error) {
return c.growthClient.BatchGetUserLevelAdminProfiles(ctx, req)
}
// AdjustTemporaryUserLevels 把财富和魅力两条轨道一次交给 activity-service 原子提交。
func (c *GRPCClient) AdjustTemporaryUserLevels(ctx context.Context, req *activityv1.AdjustTemporaryUserLevelsRequest) (*activityv1.AdjustTemporaryUserLevelsResponse, error) {
return c.growthClient.AdjustTemporaryUserLevels(ctx, req)
}
func (c *GRPCClient) CreateInboxMessage(ctx context.Context, req *activityv1.CreateInboxMessageRequest) (*activityv1.CreateInboxMessageResponse, error) {
return c.messageClient.CreateInboxMessage(ctx, req)
}

View File

@ -379,6 +379,19 @@ func (c *GRPCClient) SetUserStatus(ctx context.Context, req SetUserStatusRequest
}, nil
}
// BatchGetUserAdminProfiles 汇总 user-service owner 的生日、注册设备、成功登录、身份和有效封禁事实。
func (c *GRPCClient) BatchGetUserAdminProfiles(ctx context.Context, req *userv1.BatchGetUserAdminProfilesRequest) (*userv1.BatchGetUserAdminProfilesResponse, error) {
return c.client.BatchGetUserAdminProfiles(ctx, req)
}
func (c *GRPCClient) AdminBanUser(ctx context.Context, req *userv1.AdminBanUserRequest) (*userv1.AdminBanUserResponse, error) {
return c.client.AdminBanUser(ctx, req)
}
func (c *GRPCClient) AdminUnbanUser(ctx context.Context, req *userv1.AdminUnbanUserRequest) (*userv1.AdminUnbanUserResponse, error) {
return c.client.AdminUnbanUser(ctx, req)
}
func userStatusToProto(status string) userv1.UserStatus {
switch status {
case "active":

View File

@ -45,6 +45,7 @@ type Client interface {
ReleaseSalaryWithdrawal(ctx context.Context, req *walletv1.ReleaseSalaryWithdrawalRequest) (*walletv1.ReleaseSalaryWithdrawalResponse, error)
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
GetRechargeBillSummary(ctx context.Context, req *walletv1.GetRechargeBillSummaryRequest) (*walletv1.GetRechargeBillSummaryResponse, error)
BatchGetUserRechargeStats(ctx context.Context, req *walletv1.BatchGetUserRechargeStatsRequest) (*walletv1.BatchGetUserRechargeStatsResponse, error)
GetRechargeBillOverview(ctx context.Context, req *walletv1.GetRechargeBillOverviewRequest) (*walletv1.GetRechargeBillOverviewResponse, error)
RefreshGooglePaymentPrices(ctx context.Context, req *walletv1.RefreshGooglePaymentPricesRequest) (*walletv1.RefreshGooglePaymentPricesResponse, error)
ListThirdPartyPaymentChannels(ctx context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error)
@ -59,9 +60,12 @@ type Client interface {
CreateRechargeProduct(ctx context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
UpdateRechargeProduct(ctx context.Context, req *walletv1.UpdateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
DeleteRechargeProduct(ctx context.Context, req *walletv1.DeleteRechargeProductRequest) (*walletv1.DeleteRechargeProductResponse, error)
GetVipProgramConfig(ctx context.Context, req *walletv1.GetVipProgramConfigRequest) (*walletv1.GetVipProgramConfigResponse, error)
UpdateAdminVipProgramConfig(ctx context.Context, req *walletv1.UpdateAdminVipProgramConfigRequest) (*walletv1.UpdateAdminVipProgramConfigResponse, error)
ListAdminVipLevels(ctx context.Context, req *walletv1.ListAdminVipLevelsRequest) (*walletv1.ListAdminVipLevelsResponse, error)
UpdateAdminVipLevels(ctx context.Context, req *walletv1.UpdateAdminVipLevelsRequest) (*walletv1.UpdateAdminVipLevelsResponse, error)
GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error)
GrantVipTrialCard(ctx context.Context, req *walletv1.GrantVipTrialCardRequest) (*walletv1.GrantVipTrialCardResponse, error)
GetRedPacketConfig(ctx context.Context, req *walletv1.GetRedPacketConfigRequest) (*walletv1.GetRedPacketConfigResponse, error)
UpdateRedPacketConfig(ctx context.Context, req *walletv1.UpdateRedPacketConfigRequest) (*walletv1.UpdateRedPacketConfigResponse, error)
ListRedPackets(ctx context.Context, req *walletv1.ListRedPacketsRequest) (*walletv1.ListRedPacketsResponse, error)
@ -225,6 +229,10 @@ func (c *GRPCClient) GetRechargeBillSummary(ctx context.Context, req *walletv1.G
return c.client.GetRechargeBillSummary(ctx, req)
}
func (c *GRPCClient) BatchGetUserRechargeStats(ctx context.Context, req *walletv1.BatchGetUserRechargeStatsRequest) (*walletv1.BatchGetUserRechargeStatsResponse, error) {
return c.client.BatchGetUserRechargeStats(ctx, req)
}
func (c *GRPCClient) GetRechargeBillOverview(ctx context.Context, req *walletv1.GetRechargeBillOverviewRequest) (*walletv1.GetRechargeBillOverviewResponse, error) {
return c.client.GetRechargeBillOverview(ctx, req)
}
@ -281,6 +289,14 @@ func (c *GRPCClient) DeleteRechargeProduct(ctx context.Context, req *walletv1.De
return c.client.DeleteRechargeProduct(ctx, req)
}
func (c *GRPCClient) GetVipProgramConfig(ctx context.Context, req *walletv1.GetVipProgramConfigRequest) (*walletv1.GetVipProgramConfigResponse, error) {
return c.client.GetVipProgramConfig(ctx, req)
}
func (c *GRPCClient) UpdateAdminVipProgramConfig(ctx context.Context, req *walletv1.UpdateAdminVipProgramConfigRequest) (*walletv1.UpdateAdminVipProgramConfigResponse, error) {
return c.client.UpdateAdminVipProgramConfig(ctx, req)
}
func (c *GRPCClient) ListAdminVipLevels(ctx context.Context, req *walletv1.ListAdminVipLevelsRequest) (*walletv1.ListAdminVipLevelsResponse, error) {
return c.client.ListAdminVipLevels(ctx, req)
}
@ -293,6 +309,10 @@ func (c *GRPCClient) GrantVip(ctx context.Context, req *walletv1.GrantVipRequest
return c.client.GrantVip(ctx, req)
}
func (c *GRPCClient) GrantVipTrialCard(ctx context.Context, req *walletv1.GrantVipTrialCardRequest) (*walletv1.GrantVipTrialCardResponse, error) {
return c.client.GrantVipTrialCard(ctx, req)
}
func (c *GRPCClient) GetRedPacketConfig(ctx context.Context, req *walletv1.GetRedPacketConfigRequest) (*walletv1.GetRedPacketConfigResponse, error) {
return c.client.GetRedPacketConfig(ctx, req)
}

View File

@ -26,29 +26,35 @@ const userExportJobType = "user-export"
type HandlerFunc func(ctx context.Context, job *model.AdminJob) (string, error)
// ArtifactHandlerFunc 供需要生成可下载文件的业务任务使用artifactPath 必须位于 runner 配置的导出目录内。
type ArtifactHandlerFunc func(ctx context.Context, job *model.AdminJob) (resultJSON string, artifactPath string, err error)
type Option func(*Runner)
type Store interface {
LeaseNextJob(workerID string, lease time.Duration, maxAttempts int) (*model.AdminJob, error)
CompleteJobWithArtifact(id uint, resultJSON string, artifactPath string) error
FailJobAttempt(id uint, message string) error
ReleaseJobLease(id uint) error
RenewJobLease(id uint, workerID string, lease time.Duration) error
CompleteJobWithArtifact(id uint, workerID string, resultJSON string, artifactPath string) error
FailJobAttempt(id uint, workerID string, message string) error
ReleaseJobLease(id uint, workerID string) error
ExportUsers(options repository.ListOptions) ([]model.User, error)
}
type Locker interface {
TryLock(ctx context.Context, key string, owner string, ttl time.Duration) (bool, cache.UnlockFunc, error)
RefreshLock(ctx context.Context, key string, owner string, ttl time.Duration) (bool, error)
}
type Runner struct {
artifactDir string
interval time.Duration
leaseTTL time.Duration
locker Locker
handlers map[string]HandlerFunc
maxAttempts int
store Store
workerID string
artifactDir string
interval time.Duration
leaseTTL time.Duration
locker Locker
handlers map[string]HandlerFunc
artifactHandlers map[string]ArtifactHandlerFunc
maxAttempts int
store Store
workerID string
closeOnce sync.Once
done chan struct{}
@ -65,6 +71,17 @@ func WithHandler(jobType string, handler HandlerFunc) Option {
}
}
// WithArtifactHandler 注册带文件产物的任务,避免业务模块绕过统一 job lease、重试和下载校验。
func WithArtifactHandler(jobType string, handler ArtifactHandlerFunc) Option {
return func(r *Runner) {
jobType = strings.TrimSpace(jobType)
if jobType == "" || handler == nil {
return
}
r.artifactHandlers[jobType] = handler
}
}
func NewRunner(store Store, locker Locker, cfg config.Config, opts ...Option) *Runner {
interval := cfg.Jobs.WorkerInterval
if interval <= 0 {
@ -87,16 +104,17 @@ func NewRunner(store Store, locker Locker, cfg config.Config, opts ...Option) *R
workerID = "admin-job-worker"
}
runner := &Runner{
artifactDir: artifactDir,
interval: interval,
leaseTTL: leaseTTL,
locker: locker,
handlers: make(map[string]HandlerFunc),
maxAttempts: maxAttempts,
store: store,
workerID: workerID,
done: make(chan struct{}),
stop: make(chan struct{}),
artifactDir: artifactDir,
interval: interval,
leaseTTL: leaseTTL,
locker: locker,
handlers: make(map[string]HandlerFunc),
artifactHandlers: make(map[string]ArtifactHandlerFunc),
maxAttempts: maxAttempts,
store: store,
workerID: workerID,
done: make(chan struct{}),
stop: make(chan struct{}),
}
for _, opt := range opts {
if opt != nil {
@ -156,19 +174,69 @@ func (r *Runner) runOnce(ctx context.Context) {
locked, unlock, err := r.lock(ctx, job)
if err != nil {
slog.Error("job_lock_failed", "worker_id", r.workerID, "job_id", job.ID, "error", err.Error())
_ = r.store.ReleaseJobLease(job.ID)
_ = r.store.ReleaseJobLease(job.ID, r.workerID)
return
}
if !locked {
_ = r.store.ReleaseJobLease(job.ID)
_ = r.store.ReleaseJobLease(job.ID, r.workerID)
return
}
err = r.handle(ctx, job)
err = r.handleWithHeartbeat(ctx, job)
if unlock != nil {
_ = unlock(ctx)
}
if err != nil {
_ = r.store.FailJobAttempt(job.ID, err.Error())
_ = r.store.FailJobAttempt(job.ID, r.workerID, err.Error())
}
}
}
func (r *Runner) handleWithHeartbeat(ctx context.Context, job *model.AdminJob) error {
jobCtx, cancel := context.WithCancel(ctx)
defer cancel()
heartbeatResult := make(chan error, 1)
go r.heartbeat(jobCtx, cancel, job, heartbeatResult)
handleErr := r.handle(jobCtx, job)
cancel()
heartbeatErr := <-heartbeatResult
if handleErr != nil {
return handleErr
}
return heartbeatErr
}
func (r *Runner) heartbeat(ctx context.Context, cancel context.CancelFunc, job *model.AdminJob, result chan<- error) {
interval := r.leaseTTL / 3
if interval < 100*time.Millisecond {
interval = 100 * time.Millisecond
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
result <- nil
return
case <-ticker.C:
if err := r.store.RenewJobLease(job.ID, r.workerID, r.leaseTTL); err != nil {
cancel()
result <- fmt.Errorf("renew job lease: %w", err)
return
}
if r.locker != nil {
refreshed, err := r.locker.RefreshLock(ctx, fmt.Sprintf("jobs:%d", job.ID), r.workerID, r.leaseTTL)
if err != nil {
cancel()
result <- fmt.Errorf("refresh job lock: %w", err)
return
}
if !refreshed {
cancel()
result <- errors.New("job distributed lock is no longer owned by worker")
return
}
}
}
}
}
@ -178,6 +246,13 @@ func (r *Runner) handle(ctx context.Context, job *model.AdminJob) error {
case userExportJobType:
return r.exportAdminUsers(ctx, job)
default:
if handler := r.artifactHandlers[strings.TrimSpace(job.Type)]; handler != nil {
resultJSON, artifactPath, err := handler(ctx, job)
if err != nil {
return err
}
return r.store.CompleteJobWithArtifact(job.ID, r.workerID, resultJSON, artifactPath)
}
handler := r.handlers[strings.TrimSpace(job.Type)]
if handler == nil {
return fmt.Errorf("unsupported job type: %s", job.Type)
@ -186,7 +261,7 @@ func (r *Runner) handle(ctx context.Context, job *model.AdminJob) error {
if err != nil {
return err
}
return r.store.CompleteJobWithArtifact(job.ID, resultJSON, "")
return r.store.CompleteJobWithArtifact(job.ID, r.workerID, resultJSON, "")
}
}
@ -212,7 +287,7 @@ func (r *Runner) exportAdminUsers(_ context.Context, job *model.AdminJob) error
if err != nil {
return err
}
return r.store.CompleteJobWithArtifact(job.ID, string(result), artifactPath)
return r.store.CompleteJobWithArtifact(job.ID, r.workerID, string(result), artifactPath)
}
func (r *Runner) lock(ctx context.Context, job *model.AdminJob) (bool, cache.UnlockFunc, error) {

View File

@ -0,0 +1,115 @@
package job
import (
"context"
"errors"
"sync"
"testing"
"time"
"hyapp-admin-server/internal/cache"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
)
func TestArtifactJobRenewsDatabaseAndDistributedLeases(t *testing.T) {
store := &heartbeatStore{}
locker := &heartbeatLocker{}
runner := NewRunner(store, locker, config.Config{
NodeID: "worker-heartbeat",
Jobs: config.JobsConfig{LeaseTTL: 150 * time.Millisecond},
}, WithArtifactHandler("slow-export", func(ctx context.Context, _ *model.AdminJob) (string, string, error) {
select {
case <-ctx.Done():
return "", "", ctx.Err()
case <-time.After(350 * time.Millisecond):
return `{"rows":1}`, "slow.csv", nil
}
}))
if err := runner.handleWithHeartbeat(context.Background(), &model.AdminJob{ID: 91, Type: "slow-export"}); err != nil {
t.Fatalf("slow artifact handler failed: %v", err)
}
store.mu.Lock()
renews, completes, completedBy := store.renews, store.completes, store.completedBy
store.mu.Unlock()
locker.mu.Lock()
refreshes := locker.refreshes
locker.mu.Unlock()
if renews < 2 || refreshes < 2 || completes != 1 || completedBy != "worker-heartbeat" {
t.Fatalf("long job lease was not kept alive: renews=%d refreshes=%d completes=%d worker=%q", renews, refreshes, completes, completedBy)
}
}
func TestArtifactJobCancelsWhenDistributedLeaseIsLost(t *testing.T) {
store := &heartbeatStore{}
locker := &heartbeatLocker{loseOnRefresh: true}
runner := NewRunner(store, locker, config.Config{
NodeID: "worker-lost-lock",
Jobs: config.JobsConfig{LeaseTTL: 150 * time.Millisecond},
}, WithArtifactHandler("blocked-export", func(ctx context.Context, _ *model.AdminJob) (string, string, error) {
<-ctx.Done()
return "", "", ctx.Err()
}))
err := runner.handleWithHeartbeat(context.Background(), &model.AdminJob{ID: 92, Type: "blocked-export"})
if !errors.Is(err, context.Canceled) {
t.Fatalf("lost distributed lock must cancel handler, err=%v", err)
}
store.mu.Lock()
completes := store.completes
store.mu.Unlock()
if completes != 0 {
t.Fatalf("worker that lost its lease must not complete the job: completes=%d", completes)
}
}
type heartbeatStore struct {
mu sync.Mutex
renews int
completes int
completedBy string
}
func (s *heartbeatStore) LeaseNextJob(string, time.Duration, int) (*model.AdminJob, error) {
return nil, errors.New("not used")
}
func (s *heartbeatStore) RenewJobLease(_ uint, _ string, _ time.Duration) error {
s.mu.Lock()
s.renews++
s.mu.Unlock()
return nil
}
func (s *heartbeatStore) CompleteJobWithArtifact(_ uint, workerID string, _, _ string) error {
s.mu.Lock()
s.completes++
s.completedBy = workerID
s.mu.Unlock()
return nil
}
func (s *heartbeatStore) FailJobAttempt(uint, string, string) error { return nil }
func (s *heartbeatStore) ReleaseJobLease(uint, string) error { return nil }
func (s *heartbeatStore) ExportUsers(repository.ListOptions) ([]model.User, error) {
return nil, nil
}
type heartbeatLocker struct {
mu sync.Mutex
refreshes int
loseOnRefresh bool
}
func (l *heartbeatLocker) TryLock(context.Context, string, string, time.Duration) (bool, cache.UnlockFunc, error) {
return true, func(context.Context) error { return nil }, nil
}
func (l *heartbeatLocker) RefreshLock(context.Context, string, string, time.Duration) (bool, error) {
l.mu.Lock()
defer l.mu.Unlock()
l.refreshes++
return !l.loseOnRefresh, nil
}

View File

@ -0,0 +1,271 @@
package appuser
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
const AppUserExportJobType = "app-user-export"
type appUserExportJobStore interface {
CreateJob(job *model.AdminJob) error
}
type appUserExportPayload struct {
AppCode string `json:"appCode"`
Query listQuery `json:"query"`
}
type appUserExportJobResponse struct {
JobID uint `json:"jobId"`
Status string `json:"status"`
SnapshotAtMs int64 `json:"snapshotAtMs"`
}
type appUserExportResult struct {
JobID uint `json:"jobId"`
Rows int64 `json:"rows"`
Artifact string `json:"artifact"`
DownloadPath string `json:"downloadPath"`
SnapshotAtMs int64 `json:"snapshotAtMs"`
}
func (s *Service) CreateExportJob(ctx context.Context, actor shared.Actor, query listQuery) (*appUserExportJobResponse, error) {
if s.jobStore == nil || !s.jobsConfig.Enabled {
return nil, errors.New("admin jobs are not enabled")
}
query = normalizeListQuery(query)
query = stableAppUserExportQuery(query)
query.Page = 1
query.PageSize = 100
query.SnapshotAtMs = nowMillis()
payload := appUserExportPayload{
AppCode: appctx.FromContext(ctx),
Query: query,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
job := model.AdminJob{
Type: AppUserExportJobType,
Status: model.JobStatusPending,
PayloadJSON: string(body),
MaxAttempts: s.jobsConfig.MaxAttempts,
CreatedBy: actor.UserID,
CreatedByName: strings.TrimSpace(actor.Username),
}
if job.MaxAttempts <= 0 {
job.MaxAttempts = 3
}
if err := s.jobStore.CreateJob(&job); err != nil {
return nil, err
}
return &appUserExportJobResponse{JobID: job.ID, Status: job.Status, SnapshotAtMs: query.SnapshotAtMs}, nil
}
// HandleExportJob 使用创建任务时固化的 App 与筛选条件逐页读取;任务执行期间不会重新解释前端 query。
func (s *Service) HandleExportJob(ctx context.Context, job *model.AdminJob) (string, string, error) {
if job == nil || job.ID == 0 {
return "", "", errors.New("job is required")
}
var payload appUserExportPayload
if err := json.Unmarshal([]byte(job.PayloadJSON), &payload); err != nil {
return "", "", err
}
payload.AppCode = strings.TrimSpace(payload.AppCode)
payload.Query = normalizeListQuery(payload.Query)
payload.Query = stableAppUserExportQuery(payload.Query)
if payload.AppCode == "" || payload.Query.SnapshotAtMs <= 0 {
return "", "", ErrInvalidArgument
}
artifactDir := strings.TrimSpace(s.jobsConfig.ArtifactDir)
if artifactDir == "" {
artifactDir = "storage/exports"
}
if err := os.MkdirAll(artifactDir, 0o750); err != nil {
return "", "", err
}
file, err := os.CreateTemp(artifactDir, fmt.Sprintf("app-user-export-%d-*.csv", job.ID))
if err != nil {
return "", "", err
}
artifactName := filepath.Base(file.Name())
succeeded := false
defer func() {
_ = file.Close()
if !succeeded {
_ = os.Remove(file.Name())
}
}()
// UTF-8 BOM 让运营直接使用 Excel 打开中文昵称、国家和身份时不出现乱码。
if _, err := file.WriteString("\xEF\xBB\xBF"); err != nil {
return "", "", err
}
writer := csv.NewWriter(file)
if err := writer.Write(appUserExportHeaders()); err != nil {
return "", "", err
}
exportCtx := appctx.WithContext(ctx, payload.AppCode)
query := payload.Query
query.Page = 1
query.PageSize = 100
var exported int64
for {
items, _, err := s.ListUsers(exportCtx, query)
if err != nil {
return "", "", err
}
for i := range items {
if err := writer.Write(appUserExportRow(items[i])); err != nil {
return "", "", err
}
}
exported += int64(len(items))
if len(items) < query.PageSize {
break
}
last := items[len(items)-1]
cursorUserID, err := strconv.ParseInt(last.UserID, 10, 64)
if err != nil || cursorUserID <= 0 || last.CreatedAtMs <= 0 {
return "", "", errors.New("app user export cursor is invalid")
}
// 使用 (created_at_ms,user_id) keyset 而不是 OFFSET任务运行期间新增用户或已读用户变更筛选字段时
// 后续批次仍不会位移、重复或跳过尚未读取的稳定主键区间。
query.ExportCursorCreatedAtMs = last.CreatedAtMs
query.ExportCursorUserID = cursorUserID
}
writer.Flush()
if err := writer.Error(); err != nil {
return "", "", err
}
if err := file.Sync(); err != nil {
return "", "", err
}
if err := file.Close(); err != nil {
return "", "", err
}
succeeded = true
result := appUserExportResult{
JobID: job.ID,
Rows: exported,
Artifact: artifactName,
DownloadPath: fmt.Sprintf("/api/v1/jobs/%d/artifact", job.ID),
SnapshotAtMs: payload.Query.SnapshotAtMs,
}
resultJSON, err := json.Marshal(result)
if err != nil {
return "", "", err
}
return string(resultJSON), artifactName, nil
}
func stableAppUserExportQuery(query listQuery) listQuery {
// 导出顺序不属于筛选语义,统一 created_at/user_id 倒序才能和固定 keyset 游标一致。
// 这也绕开 coin 投影的全量内存排序,避免每一批重复读取形成 O(批次数×用户数)。
query.SortBy = "created_at"
query.SortDirection = "desc"
return query
}
func (h *Handler) CreateExport(c *gin.Context) {
job, err := h.service.CreateExportJob(c.Request.Context(), shared.ActorFromContext(c), parseListQuery(c))
if err != nil {
response.ServerError(c, "创建 App 用户导出任务失败")
return
}
writeAppUserAuditLog(c, h.audit, "export-app-users", "app_user_exports", int64(job.JobID), "success", fmt.Sprintf("snapshot_at_ms=%d", job.SnapshotAtMs))
response.OK(c, job)
}
func (h *Handler) HandleExportJob(ctx context.Context, job *model.AdminJob) (string, string, error) {
return h.service.HandleExportJob(ctx, job)
}
func appUserExportHeaders() []string {
return []string{
"用户ID", "短ID", "靓号", "昵称", "头像", "性别", "生日", "年龄", "国家", "区域",
"VIP等级", "VIP到期时间(ms)", "富豪真实等级", "富豪展示等级", "富豪临时目标", "富豪到期时间(ms)",
"魅力真实等级", "魅力展示等级", "魅力临时目标", "魅力到期时间(ms)", "游戏等级", "身份",
"注册设备", "金币", "钻石", "累计充值(USD分)", "状态", "封禁类型", "封禁到期时间(ms)", "封禁原因",
"注册时间(ms)", "最近活跃时间(ms)", "最近成功登录时间(ms)", "最新操作人", "最新操作", "最新操作时间(ms)",
}
}
func appUserExportRow(item AppUser) []string {
age := ""
if item.Age != nil {
age = strconv.FormatInt(int64(*item.Age), 10)
}
banType := ""
if item.Ban.Active {
if item.Ban.Permanent {
banType = "永久"
} else {
banType = "限时"
}
}
operatorName := ""
operatorAction := ""
if item.LastOperator != nil {
operatorName = item.LastOperator.Name
operatorAction = item.LastOperator.Action
}
row := []string{
item.UserID, item.DefaultDisplayUserID, firstNonEmptyString(item.PrettyDisplayUserID, item.PrettyID), item.Username, item.Avatar,
item.Gender, item.Birth, age, firstNonEmptyString(item.CountryDisplayName, item.CountryName, item.Country), item.RegionName,
strconv.FormatInt(int64(item.VIP.Level), 10), strconv.FormatInt(item.VIP.ExpiresAtMs, 10),
strconv.FormatInt(int64(item.Levels.Wealth.RealLevel), 10), strconv.FormatInt(int64(item.Levels.Wealth.DisplayLevel), 10), strconv.FormatInt(int64(item.Levels.Wealth.TemporaryTargetLevel), 10), strconv.FormatInt(item.Levels.Wealth.ExpiresAtMs, 10),
strconv.FormatInt(int64(item.Levels.Charm.RealLevel), 10), strconv.FormatInt(int64(item.Levels.Charm.DisplayLevel), 10), strconv.FormatInt(int64(item.Levels.Charm.TemporaryTargetLevel), 10), strconv.FormatInt(item.Levels.Charm.ExpiresAtMs, 10),
strconv.FormatInt(int64(item.Levels.Game.DisplayLevel), 10), strings.Join(item.Roles, "|"), item.RegisterDevice,
strconv.FormatInt(item.Coin, 10), strconv.FormatInt(item.Diamond, 10), strconv.FormatInt(item.CumulativeRechargeUSDMinor, 10),
item.Status, banType, strconv.FormatInt(item.Ban.ExpiresAtMs, 10), item.Ban.Reason,
strconv.FormatInt(item.CreatedAtMs, 10), strconv.FormatInt(item.LastActiveAtMs, 10), strconv.FormatInt(item.LastLoginAtMs, 10),
operatorName, operatorAction, strconv.FormatInt(item.LastOperatedAtMs, 10),
}
for index := range row {
row[index] = spreadsheetSafeCSVCell(row[index])
}
return row
}
func spreadsheetSafeCSVCell(value string) string {
trimmed := strings.TrimLeft(value, " \t\r\n")
if trimmed == "" {
return value
}
switch trimmed[0] {
case '=', '+', '-', '@':
// Excel/Sheets 会把这些前缀解释为公式;前置单引号只影响表格解释,不丢失原始文本内容。
return "'" + value
default:
return value
}
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,106 @@
package appuser
import (
"context"
"encoding/json"
"strings"
"testing"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
)
type recordingAppUserExportStore struct {
job *model.AdminJob
}
func (s *recordingAppUserExportStore) CreateJob(job *model.AdminJob) error {
copy := *job
copy.ID = 81
job.ID = copy.ID
s.job = &copy
return nil
}
func TestCreateExportJobPersistsFilterSnapshot(t *testing.T) {
store := &recordingAppUserExportStore{}
service := &Service{
jobStore: store,
jobsConfig: config.JobsConfig{
Enabled: true,
MaxAttempts: 5,
},
}
ctx := appctx.WithContext(context.Background(), "lalu")
response, err := service.CreateExportJob(ctx, shared.Actor{UserID: 7, Username: "operator"}, listQuery{
Country: " ph ",
Status: "active",
Keyword: "1299324",
SortBy: "coin",
SortDirection: "asc",
Page: 9,
PageSize: 10,
})
if err != nil {
t.Fatalf("create export job: %v", err)
}
if response.JobID != 81 || response.SnapshotAtMs <= 0 || store.job == nil {
t.Fatalf("response/job mismatch: response=%+v job=%+v", response, store.job)
}
if store.job.Type != AppUserExportJobType || store.job.MaxAttempts != 5 || store.job.CreatedBy != 7 {
t.Fatalf("job metadata mismatch: %+v", store.job)
}
var payload appUserExportPayload
if err := json.Unmarshal([]byte(store.job.PayloadJSON), &payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload.AppCode != "lalu" || payload.Query.Country != "PH" || payload.Query.Status != "active" || payload.Query.Keyword != "1299324" {
t.Fatalf("filter snapshot mismatch: %+v", payload)
}
if payload.Query.Page != 1 || payload.Query.PageSize != 100 || payload.Query.SnapshotAtMs != response.SnapshotAtMs {
t.Fatalf("pagination snapshot mismatch: %+v", payload.Query)
}
if payload.Query.SortBy != "created_at" || payload.Query.SortDirection != "desc" {
t.Fatalf("export must use stable database pagination instead of coin projection sort: %+v", payload.Query)
}
}
func TestStableAppUserExportQueryForcesDescendingKeysetOrder(t *testing.T) {
for _, query := range []listQuery{
{SortBy: "created_at", SortDirection: "asc"},
{SortBy: "coin", SortDirection: "asc"},
} {
stable := stableAppUserExportQuery(normalizeListQuery(query))
if stable.SortBy != "created_at" || stable.SortDirection != "desc" {
t.Fatalf("export order must match descending keyset: input=%+v stable=%+v", query, stable)
}
}
}
func TestAppUserExportWhereUsesSnapshotAndKeysetCursor(t *testing.T) {
query := normalizeListQuery(listQuery{
SnapshotAtMs: 1_000,
ExportCursorCreatedAtMs: 900,
ExportCursorUserID: 88,
})
whereSQL, args := appUserListWhereSQLAt("lalu", query, query.SnapshotAtMs)
for _, fragment := range []string{
"u.created_at_ms <= ?",
"u.created_at_ms < ? OR (u.created_at_ms = ? AND u.user_id < ?)",
} {
if !strings.Contains(whereSQL, fragment) {
t.Fatalf("export where SQL missing %q: %s", fragment, whereSQL)
}
}
wantTail := []any{int64(1_000), int64(900), int64(900), int64(88)}
if len(args) < len(wantTail) {
t.Fatalf("export args too short: %+v", args)
}
for index, want := range wantTail {
if got := args[len(args)-len(wantTail)+index]; got != want {
t.Fatalf("export cursor arg[%d]=%v want=%v all=%+v", index, got, want, args)
}
}
}

View File

@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
"io"
"strconv"
"strings"
@ -15,6 +16,8 @@ import (
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type Handler struct {
@ -23,9 +26,9 @@ type Handler struct {
audit shared.OperationLogger
}
func New(userClient userclient.Client, activityClient activityclient.Client, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB, cfg config.Config, audit shared.OperationLogger) *Handler {
func New(userClient userclient.Client, activityClient activityclient.Client, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB, cfg config.Config, audit shared.OperationLogger, opts ...ServiceOption) *Handler {
return &Handler{
service: NewService(userClient, activityClient, adminDB, userDB, walletDB),
service: NewService(userClient, activityClient, adminDB, userDB, walletDB, opts...),
cfg: cfg,
audit: audit,
}
@ -113,11 +116,64 @@ func (h *Handler) UpdateUser(c *gin.Context) {
}
func (h *Handler) BanUser(c *gin.Context) {
h.setUserStatus(c, "banned", "ban-app-user", "用户已封禁")
userID, ok := parseInt64ID(c, "id")
if !ok {
return
}
var req banUserRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.BadRequest(c, "封禁参数不正确")
return
}
result, err := h.service.AdminBanUser(c.Request.Context(), userID, int64(middleware.CurrentUserID(c)), middleware.CurrentRequestID(c), req)
if err != nil {
writeMutationError(c, err, "封禁 App 用户失败")
return
}
detail := "permanent"
if req.ExpiresAtMs > 0 {
detail = fmt.Sprintf("expires_at_ms=%d", req.ExpiresAtMs)
}
writeAppUserAuditLog(c, h.audit, "ban-app-user", "app_users", userID, "success", detail)
response.OK(c, result)
}
func (h *Handler) UnbanUser(c *gin.Context) {
h.setUserStatus(c, "active", "unban-app-user", "用户已解封")
userID, ok := parseInt64ID(c, "id")
if !ok {
return
}
var req unbanUserRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.BadRequest(c, "解封参数不正确")
return
}
result, err := h.service.AdminUnbanUser(c.Request.Context(), userID, int64(middleware.CurrentUserID(c)), middleware.CurrentRequestID(c), req)
if err != nil {
writeMutationError(c, err, "解封 App 用户失败")
return
}
writeAppUserAuditLog(c, h.audit, "unban-app-user", "app_users", userID, "success", strings.TrimSpace(req.Reason))
response.OK(c, result)
}
func (h *Handler) AdjustTemporaryLevels(c *gin.Context) {
userID, ok := parseInt64ID(c, "id")
if !ok {
return
}
var req adjustTemporaryLevelsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "等级调整参数不正确")
return
}
user, err := h.service.AdjustTemporaryLevels(c.Request.Context(), userID, int64(middleware.CurrentUserID(c)), middleware.CurrentRequestID(c), req)
if err != nil {
writeMutationError(c, err, "调整用户等级失败")
return
}
writeAppUserAuditLog(c, h.audit, "adjust-app-user-level", "app_users", userID, "success", fmt.Sprintf("tracks=%d", len(req.Adjustments)))
response.OK(c, user)
}
func (h *Handler) SetPassword(c *gin.Context) {
@ -269,6 +325,22 @@ func writeMutationError(c *gin.Context, err error, fallback string) {
response.BadRequest(c, err.Error())
return
}
if grpcStatus, ok := status.FromError(err); ok {
message := strings.TrimSpace(grpcStatus.Message())
if message == "" {
message = fallback
}
switch grpcStatus.Code() {
case codes.InvalidArgument, codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted:
// 等级目标不高于真实等级、规则缺失或仍有其他封禁都属于可修正的业务拒绝,不能伪装成 500。
response.BadRequest(c, message)
case codes.NotFound:
response.NotFound(c, message)
default:
response.ServerError(c, fallback)
}
return
}
response.ServerError(c, fallback)
}

View File

@ -0,0 +1,34 @@
package appuser
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestWriteMutationErrorMapsGRPCBusinessFailures(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
err error
wantStatus int
}{
{name: "invalid level", err: status.Error(codes.FailedPrecondition, "target must exceed real level"), wantStatus: http.StatusBadRequest},
{name: "missing user", err: status.Error(codes.NotFound, "user not found"), wantStatus: http.StatusNotFound},
{name: "owner unavailable", err: status.Error(codes.Unavailable, "activity unavailable"), wantStatus: http.StatusInternalServerError},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
writeMutationError(context, test.err, "mutation failed")
if recorder.Code != test.wantStatus {
t.Fatalf("HTTP status=%d want=%d body=%s", recorder.Code, test.wantStatus, recorder.Body.String())
}
})
}
}

View File

@ -0,0 +1,85 @@
package appuser
import (
"context"
"testing"
"hyapp-admin-server/internal/appctx"
activityv1 "hyapp.local/api/proto/activity/v1"
userv1 "hyapp.local/api/proto/user/v1"
)
type fakeUserAdminProjectionClient struct {
profiles map[int64]*userv1.UserAdminProfile
}
func (f *fakeUserAdminProjectionClient) BatchGetUserAdminProfiles(context.Context, *userv1.BatchGetUserAdminProfilesRequest) (*userv1.BatchGetUserAdminProfilesResponse, error) {
return &userv1.BatchGetUserAdminProfilesResponse{Profiles: f.profiles}, nil
}
func (f *fakeUserAdminProjectionClient) AdminBanUser(context.Context, *userv1.AdminBanUserRequest) (*userv1.AdminBanUserResponse, error) {
return &userv1.AdminBanUserResponse{}, nil
}
func (f *fakeUserAdminProjectionClient) AdminUnbanUser(context.Context, *userv1.AdminUnbanUserRequest) (*userv1.AdminUnbanUserResponse, error) {
return &userv1.AdminUnbanUserResponse{}, nil
}
type fakeLevelAdminProjectionClient struct {
profiles []*activityv1.AdminUserLevelProfile
}
func (f *fakeLevelAdminProjectionClient) BatchGetUserLevelAdminProfiles(context.Context, *activityv1.BatchGetUserLevelAdminProfilesRequest) (*activityv1.BatchGetUserLevelAdminProfilesResponse, error) {
return &activityv1.BatchGetUserLevelAdminProfilesResponse{Profiles: f.profiles}, nil
}
func (f *fakeLevelAdminProjectionClient) AdjustTemporaryUserLevels(context.Context, *activityv1.AdjustTemporaryUserLevelsRequest) (*activityv1.AdjustTemporaryUserLevelsResponse, error) {
return &activityv1.AdjustTemporaryUserLevelsResponse{}, nil
}
func TestAdminProjectionEnrichmentMapsOwnerFacts(t *testing.T) {
service := &Service{
userAdminClient: &fakeUserAdminProjectionClient{profiles: map[int64]*userv1.UserAdminProfile{
10001: {
Birth: "2000-02-03",
RegisterDevice: "Pixel 9",
LastSuccessLoginAtMs: 7000,
Roles: []string{"host", "bd_leader", "manager"},
Ban: &userv1.ActiveUserBanSummary{
Active: true, Source: "admin", BanId: "ban-1", Permanent: false, ExpiresAtMs: 9000, Reason: "risk",
},
},
}},
levelAdminClient: &fakeLevelAdminProjectionClient{profiles: []*activityv1.AdminUserLevelProfile{
{UserId: 10001, Tracks: []*activityv1.AdminLevelTrackProfile{
{Track: "wealth", RealLevel: 8, RealTotalValue: 800, DisplayLevel: 14, DisplayValue: 1400, TemporaryLevelId: "temp-1", TemporaryTargetLevel: 14, StartedAtMs: 1000, ExpiresAtMs: 9000},
{Track: "charm", RealLevel: 9, RealTotalValue: 900, DisplayLevel: 9, DisplayValue: 900},
{Track: "game", RealLevel: 6, RealTotalValue: 600, DisplayLevel: 6, DisplayValue: 600},
}},
}},
}
items := []AppUser{{UserID: "10001"}}
ctx := appctx.WithContext(context.Background(), "lalu")
if err := service.fillUserAdminProfiles(ctx, items, []int64{10001}); err != nil {
t.Fatalf("fill user profiles: %v", err)
}
if err := service.fillLevelAdminProfiles(ctx, items, []int64{10001}); err != nil {
t.Fatalf("fill level profiles: %v", err)
}
item := items[0]
if item.Birth != "2000-02-03" || item.RegisterDevice != "Pixel 9" || item.LastLoginAtMs != 7000 {
t.Fatalf("user owner facts mismatch: %+v", item)
}
if len(item.Roles) != 3 || item.Roles[0] != "主播" || item.Roles[1] != "BD Leader" || item.Roles[2] != "经理" {
t.Fatalf("roles mismatch: %#v", item.Roles)
}
if !item.Ban.Active || item.Ban.ID != "ban-1" || item.Ban.ExpiresAtMs != 9000 {
t.Fatalf("ban mismatch: %+v", item.Ban)
}
if item.Levels.Wealth.RealLevel != 8 || item.Levels.Wealth.DisplayLevel != 14 || item.Levels.Wealth.TemporaryTargetLevel != 14 {
t.Fatalf("wealth overlay mismatch: %+v", item.Levels.Wealth)
}
if item.Levels.Game.DisplayLevel != 6 || item.Levels.Charm.DisplayLevel != 9 {
t.Fatalf("read-only levels mismatch: %+v", item.Levels)
}
}

View File

@ -15,6 +15,11 @@ type listQuery struct {
SortDirection string
StartMs int64
EndMs int64
// SnapshotAtMs 只由异步导出设置,使短号租约和筛选时间基线在所有分页中保持一致。
SnapshotAtMs int64
// ExportCursor* 仅在 job worker 内存中推进 keyset不进入任务 payload也不暴露为 HTTP 查询参数。
ExportCursorCreatedAtMs int64 `json:"-"`
ExportCursorUserID int64 `json:"-"`
}
type loginLogQuery struct {
@ -58,3 +63,23 @@ type updateUserRequest struct {
type setPasswordRequest struct {
Password string `json:"password" binding:"required"`
}
type temporaryLevelAdjustmentRequest struct {
Track string `json:"track" binding:"required"`
Level int32 `json:"level" binding:"required"`
DurationDays int32 `json:"duration_days" binding:"required"`
}
type adjustTemporaryLevelsRequest struct {
Adjustments []temporaryLevelAdjustmentRequest `json:"adjustments" binding:"required,min=1,max=2,dive"`
Reason string `json:"reason"`
}
type banUserRequest struct {
ExpiresAtMs int64 `json:"expires_at_ms"`
Reason string `json:"reason"`
}
type unbanUserRequest struct {
Reason string `json:"reason"`
}

View File

@ -12,11 +12,13 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/app/users", middleware.RequirePermission("app-user:view"), h.ListUsers)
protected.POST("/exports/app-users", middleware.RequirePermission("app-user:export"), h.CreateExport)
protected.GET("/app/users/bans", middleware.RequirePermission("app-user:view"), h.ListBannedUsers)
protected.GET("/app/users/login-logs", middleware.RequirePermission("app-user:view"), h.ListLoginLogs)
protected.GET("/app/users/:id/login-logs", middleware.RequirePermission("app-user:view"), h.ListUserLoginLogs)
protected.GET("/app/users/:id", middleware.RequirePermission("app-user:view"), h.GetUser)
protected.PATCH("/app/users/:id", middleware.RequirePermission("app-user:update"), h.UpdateUser)
protected.PUT("/app/users/:id/levels", middleware.RequirePermission("app-user:level"), h.AdjustTemporaryLevels)
protected.POST("/app/users/:id/ban", middleware.RequirePermission("app-user:status"), h.BanUser)
protected.POST("/app/users/:id/unban", middleware.RequirePermission("app-user:status"), h.UnbanUser)
protected.POST("/app/users/:id/password", middleware.RequirePermission("app-user:password"), h.SetPassword)

View File

@ -12,11 +12,15 @@ import (
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/security"
activityv1 "hyapp.local/api/proto/activity/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
var (
@ -25,37 +29,122 @@ var (
)
type Service struct {
userClient userclient.Client
activityClient activityclient.Client
adminDB *sql.DB
userDB *sql.DB
walletDB *sql.DB
userClient userclient.Client
userAdminClient userAdminProjectionClient
activityClient activityclient.Client
levelAdminClient levelAdminProjectionClient
walletClient rechargeStatsClient
jobStore appUserExportJobStore
jobsConfig config.JobsConfig
adminDB *sql.DB
userDB *sql.DB
walletDB *sql.DB
}
// rechargeStatsClient 只暴露用户后台需要的累计充值聚合;列表不能为展示字段回扫钱包流水。
type rechargeStatsClient interface {
BatchGetUserRechargeStats(context.Context, *walletv1.BatchGetUserRechargeStatsRequest) (*walletv1.BatchGetUserRechargeStatsResponse, error)
}
// userAdminProjectionClient 保证生日、成功登录和封禁语义继续由 user-service 统一解释。
type userAdminProjectionClient interface {
BatchGetUserAdminProfiles(context.Context, *userv1.BatchGetUserAdminProfilesRequest) (*userv1.BatchGetUserAdminProfilesResponse, error)
AdminBanUser(context.Context, *userv1.AdminBanUserRequest) (*userv1.AdminBanUserResponse, error)
AdminUnbanUser(context.Context, *userv1.AdminUnbanUserRequest) (*userv1.AdminUnbanUserResponse, error)
}
// levelAdminProjectionClient 只允许后台读 overlay 和一次原子调整,游戏等级没有写入口。
type levelAdminProjectionClient interface {
BatchGetUserLevelAdminProfiles(context.Context, *activityv1.BatchGetUserLevelAdminProfilesRequest) (*activityv1.BatchGetUserLevelAdminProfilesResponse, error)
AdjustTemporaryUserLevels(context.Context, *activityv1.AdjustTemporaryUserLevelsRequest) (*activityv1.AdjustTemporaryUserLevelsResponse, error)
}
type ServiceOption func(*Service)
// WithWalletClient 通过 wallet-service 读取它拥有的充值聚合,保留 walletDB 仅用于现有余额与 VIP 展示。
func WithWalletClient(client walletclient.Client) ServiceOption {
return func(service *Service) {
service.walletClient = client
}
}
// WithExportJobs 把任务创建留在 admin 数据库,把分批导出交给统一 job runner 执行。
func WithExportJobs(store appUserExportJobStore, cfg config.Config) ServiceOption {
return func(service *Service) {
service.jobStore = store
service.jobsConfig = cfg.Jobs
}
}
type AppUser struct {
Avatar string `json:"avatar"`
Balances []AppUserAssetBalance `json:"balances,omitempty"`
Coin int64 `json:"coin"`
Country string `json:"country"`
CountryDisplayName string `json:"countryDisplayName"`
CountryName string `json:"countryName"`
CreatedAtMs int64 `json:"createdAtMs"`
DefaultDisplayUserID string `json:"defaultDisplayUserId"`
Diamond int64 `json:"diamond"`
DisplayUserID string `json:"displayUserId"`
EquippedResources []AppUserResource `json:"equippedResources,omitempty"`
Gender string `json:"gender"`
LastActiveAtMs int64 `json:"lastActiveAtMs"`
PrettyDisplayUserID string `json:"prettyDisplayUserId"`
PrettyID string `json:"prettyId"`
RegionID int64 `json:"regionId"`
RegionName string `json:"regionName"`
Resources []AppUserResource `json:"resources,omitempty"`
Status string `json:"status"`
UpdatedAtMs int64 `json:"updatedAtMs"`
UserID string `json:"userId"`
Username string `json:"username"`
VIP AppUserVIP `json:"vip"`
Age *int32 `json:"age,omitempty"`
Avatar string `json:"avatar"`
Balances []AppUserAssetBalance `json:"balances,omitempty"`
Ban AppUserBan `json:"ban"`
Birth string `json:"birth"`
Coin int64 `json:"coin"`
Country string `json:"country"`
CountryDisplayName string `json:"countryDisplayName"`
CountryName string `json:"countryName"`
CreatedAtMs int64 `json:"createdAtMs"`
CumulativeRechargeUSDMinor int64 `json:"cumulativeRechargeUsdMinor"`
DefaultDisplayUserID string `json:"defaultDisplayUserId"`
Diamond int64 `json:"diamond"`
DisplayUserID string `json:"displayUserId"`
EquippedResources []AppUserResource `json:"equippedResources,omitempty"`
Gender string `json:"gender"`
LastActiveAtMs int64 `json:"lastActiveAtMs"`
LastLoginAtMs int64 `json:"lastLoginAtMs"`
LastOperatedAtMs int64 `json:"lastOperatedAtMs"`
LastOperator *AppUserOperator `json:"lastOperator,omitempty"`
Levels AppUserLevels `json:"levels"`
PrettyDisplayUserID string `json:"prettyDisplayUserId"`
PrettyID string `json:"prettyId"`
RegisterDevice string `json:"registerDevice"`
RegionID int64 `json:"regionId"`
RegionName string `json:"regionName"`
Resources []AppUserResource `json:"resources,omitempty"`
Roles []string `json:"roles"`
Status string `json:"status"`
UpdatedAtMs int64 `json:"updatedAtMs"`
UserID string `json:"userId"`
Username string `json:"username"`
VIP AppUserVIP `json:"vip"`
}
// AppUserLevels 固定三个成长轨道,前端无需按数组位置猜测富豪、游戏和魅力列。
type AppUserLevels struct {
Wealth AppUserLevel `json:"wealth"`
Game AppUserLevel `json:"game"`
Charm AppUserLevel `json:"charm"`
}
// AppUserLevel 同时暴露真实成长事实和当前临时展示事实,详情页可明确标出到期时间。
type AppUserLevel struct {
Track string `json:"track"`
RealLevel int32 `json:"realLevel"`
RealTotalValue int64 `json:"realTotalValue"`
DisplayLevel int32 `json:"displayLevel"`
DisplayValue int64 `json:"displayValue"`
TemporaryLevelID string `json:"temporaryLevelId,omitempty"`
TemporaryTargetLevel int32 `json:"temporaryTargetLevel,omitempty"`
StartedAtMs int64 `json:"startedAtMs,omitempty"`
ExpiresAtMs int64 `json:"expiresAtMs,omitempty"`
}
type AppUserBan struct {
ID string `json:"id,omitempty"`
Active bool `json:"active"`
Permanent bool `json:"permanent"`
ExpiresAtMs int64 `json:"expiresAtMs"`
Source string `json:"source,omitempty"`
Reason string `json:"reason,omitempty"`
}
type AppUserOperator struct {
AdminID string `json:"adminId"`
Name string `json:"name"`
Action string `json:"action"`
}
type AppUserAssetBalance struct {
@ -110,8 +199,20 @@ type SetUserStatusResult struct {
RoomEvictError string `json:"roomEvictError,omitempty"`
}
func NewService(userClient userclient.Client, activityClient activityclient.Client, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB) *Service {
return &Service{userClient: userClient, activityClient: activityClient, adminDB: adminDB, userDB: userDB, walletDB: walletDB}
func NewService(userClient userclient.Client, activityClient activityclient.Client, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB, opts ...ServiceOption) *Service {
service := &Service{userClient: userClient, activityClient: activityClient, adminDB: adminDB, userDB: userDB, walletDB: walletDB}
if adminClient, ok := any(userClient).(userAdminProjectionClient); ok {
service.userAdminClient = adminClient
}
if adminClient, ok := any(activityClient).(levelAdminProjectionClient); ok {
service.levelAdminClient = adminClient
}
for _, opt := range opts {
if opt != nil {
opt(service)
}
}
return service
}
func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, int64, error) {
@ -120,7 +221,10 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
}
query = normalizeListQuery(query)
appCode := appctx.FromContext(ctx)
nowMs := nowMillis()
nowMs := query.SnapshotAtMs
if nowMs <= 0 {
nowMs = nowMillis()
}
whereSQL, args := appUserListWhereSQLAt(appCode, query, nowMs)
total, err := countRows(ctx, s.userDB, whereSQL, args...)
@ -192,6 +296,18 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
if err := s.fillVIPLevels(ctx, items, userIDs); err != nil {
return nil, 0, err
}
if err := s.fillUserAdminProfiles(ctx, items, userIDs); err != nil {
return nil, 0, err
}
if err := s.fillLevelAdminProfiles(ctx, items, userIDs); err != nil {
return nil, 0, err
}
if err := s.fillRechargeStats(ctx, items, userIDs); err != nil {
return nil, 0, err
}
if err := s.fillLatestOperators(ctx, items, userIDs); err != nil {
return nil, 0, err
}
return items, total, nil
}
@ -256,6 +372,19 @@ func (s *Service) listUsersSortedByCoin(ctx context.Context, query listQuery, wh
if err := s.fillVIPLevels(ctx, pagedItems, appUserNumericIDs(pagedItems)); err != nil {
return nil, err
}
pagedUserIDs := appUserNumericIDs(pagedItems)
if err := s.fillUserAdminProfiles(ctx, pagedItems, pagedUserIDs); err != nil {
return nil, err
}
if err := s.fillLevelAdminProfiles(ctx, pagedItems, pagedUserIDs); err != nil {
return nil, err
}
if err := s.fillRechargeStats(ctx, pagedItems, pagedUserIDs); err != nil {
return nil, err
}
if err := s.fillLatestOperators(ctx, pagedItems, pagedUserIDs); err != nil {
return nil, err
}
return pagedItems, nil
}
@ -380,12 +509,297 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
if err := s.fillVIPLevels(ctx, items, []int64{userID}); err != nil {
return AppUser{}, err
}
if err := s.fillUserAdminProfiles(ctx, items, []int64{userID}); err != nil {
return AppUser{}, err
}
if err := s.fillLevelAdminProfiles(ctx, items, []int64{userID}); err != nil {
return AppUser{}, err
}
if err := s.fillRechargeStats(ctx, items, []int64{userID}); err != nil {
return AppUser{}, err
}
if err := s.fillLatestOperators(ctx, items, []int64{userID}); err != nil {
return AppUser{}, err
}
if err := s.fillUserResources(ctx, items, userID); err != nil {
return AppUser{}, err
}
return items[0], nil
}
func (s *Service) fillUserAdminProfiles(ctx context.Context, items []AppUser, userIDs []int64) error {
initializeAdminProjectionDefaults(items)
if s.userAdminClient == nil || len(items) == 0 || len(userIDs) == 0 {
return nil
}
nowMs := nowMillis()
response, err := s.userAdminClient.BatchGetUserAdminProfiles(ctx, &userv1.BatchGetUserAdminProfilesRequest{
Meta: &userv1.RequestMeta{
RequestId: fmt.Sprintf("admin-app-user-profile-%d", nowMs),
Caller: "hyapp-admin-server",
SentAtMs: nowMs,
AppCode: appctx.FromContext(ctx),
},
UserIds: userIDs,
})
if err != nil {
return err
}
index := appUserIndex(items)
for userID, profile := range response.GetProfiles() {
if profile == nil {
continue
}
i, ok := index[userID]
if !ok {
continue
}
items[i].Birth = strings.TrimSpace(profile.GetBirth())
items[i].Age = ageAtUTC(items[i].Birth, time.UnixMilli(nowMs).UTC())
items[i].RegisterDevice = strings.TrimSpace(profile.GetRegisterDevice())
items[i].LastLoginAtMs = profile.GetLastSuccessLoginAtMs()
items[i].Roles = displayUserRoles(profile.GetRoles())
if ban := profile.GetBan(); ban != nil {
items[i].Ban = AppUserBan{
ID: strings.TrimSpace(ban.GetBanId()),
Active: ban.GetActive(),
Permanent: ban.GetPermanent(),
ExpiresAtMs: ban.GetExpiresAtMs(),
Source: strings.TrimSpace(ban.GetSource()),
Reason: strings.TrimSpace(ban.GetReason()),
}
}
if profile.GetLastOperationAtMs() > items[i].LastOperatedAtMs {
items[i].LastOperatedAtMs = profile.GetLastOperationAtMs()
items[i].LastOperator = &AppUserOperator{
AdminID: strconv.FormatInt(profile.GetLastOperatorUserId(), 10),
Name: displayOperatorType(profile.GetLastOperatorType()),
Action: strings.TrimSpace(profile.GetLastOperationReason()),
}
}
}
return nil
}
func (s *Service) fillLevelAdminProfiles(ctx context.Context, items []AppUser, userIDs []int64) error {
initializeAdminProjectionDefaults(items)
if s.levelAdminClient == nil || len(items) == 0 || len(userIDs) == 0 {
return nil
}
nowMs := nowMillis()
response, err := s.levelAdminClient.BatchGetUserLevelAdminProfiles(ctx, &activityv1.BatchGetUserLevelAdminProfilesRequest{
Meta: &activityv1.RequestMeta{
RequestId: fmt.Sprintf("admin-app-user-level-%d", nowMs),
Caller: "hyapp-admin-server",
SentAtMs: nowMs,
AppCode: appctx.FromContext(ctx),
},
UserIds: userIDs,
})
if err != nil {
return err
}
index := appUserIndex(items)
for _, profile := range response.GetProfiles() {
if profile == nil {
continue
}
i, ok := index[profile.GetUserId()]
if !ok {
continue
}
for _, track := range profile.GetTracks() {
if track == nil {
continue
}
level := AppUserLevel{
Track: strings.ToLower(strings.TrimSpace(track.GetTrack())),
RealLevel: track.GetRealLevel(),
RealTotalValue: track.GetRealTotalValue(),
DisplayLevel: track.GetDisplayLevel(),
DisplayValue: track.GetDisplayValue(),
TemporaryLevelID: strings.TrimSpace(track.GetTemporaryLevelId()),
TemporaryTargetLevel: track.GetTemporaryTargetLevel(),
StartedAtMs: track.GetStartedAtMs(),
ExpiresAtMs: track.GetExpiresAtMs(),
}
switch level.Track {
case "wealth":
items[i].Levels.Wealth = level
case "charm":
items[i].Levels.Charm = level
case "game":
items[i].Levels.Game = level
}
}
}
return nil
}
func initializeAdminProjectionDefaults(items []AppUser) {
for i := range items {
if len(items[i].Roles) == 0 {
items[i].Roles = []string{"普通用户"}
}
if items[i].Levels.Wealth.Track == "" {
items[i].Levels.Wealth.Track = "wealth"
}
if items[i].Levels.Charm.Track == "" {
items[i].Levels.Charm.Track = "charm"
}
if items[i].Levels.Game.Track == "" {
items[i].Levels.Game.Track = "game"
}
}
}
func displayUserRoles(roles []string) []string {
displayByRole := map[string]string{
"host": "主播",
"agency": "公会长",
"bd": "BD",
"bd_leader": "BD Leader",
"coin_seller": "币商",
"manager": "经理",
}
seen := make(map[string]struct{}, len(roles))
for _, role := range roles {
role = strings.ToLower(strings.TrimSpace(role))
if _, ok := displayByRole[role]; ok {
seen[role] = struct{}{}
}
}
order := []string{"host", "agency", "bd", "bd_leader", "coin_seller", "manager"}
result := make([]string, 0, len(seen))
for _, role := range order {
if _, ok := seen[role]; ok {
result = append(result, displayByRole[role])
}
}
if len(result) == 0 {
return []string{"普通用户"}
}
return result
}
func displayOperatorType(operatorType string) string {
switch strings.ToLower(strings.TrimSpace(operatorType)) {
case "admin":
return "管理员"
case "manager":
return "经理"
default:
return strings.TrimSpace(operatorType)
}
}
func ageAtUTC(birth string, now time.Time) *int32 {
birthDate, err := time.Parse("2006-01-02", strings.TrimSpace(birth))
if err != nil || birthDate.After(now) {
return nil
}
age := now.Year() - birthDate.Year()
if now.Month() < birthDate.Month() || (now.Month() == birthDate.Month() && now.Day() < birthDate.Day()) {
age--
}
if age < 0 {
return nil
}
value := int32(age)
return &value
}
func (s *Service) fillRechargeStats(ctx context.Context, items []AppUser, userIDs []int64) error {
if s.walletClient == nil || len(items) == 0 || len(userIDs) == 0 {
return nil
}
response, err := s.walletClient.BatchGetUserRechargeStats(ctx, &walletv1.BatchGetUserRechargeStatsRequest{
RequestId: fmt.Sprintf("admin-app-user-recharge-%d", nowMillis()),
AppCode: appctx.FromContext(ctx),
UserIds: userIDs,
})
if err != nil {
return err
}
index := appUserIndex(items)
for _, stat := range response.GetItems() {
if stat == nil {
continue
}
if i, ok := index[stat.GetUserId()]; ok {
items[i].CumulativeRechargeUSDMinor = stat.GetTotalUsdMinorAmount()
}
}
return nil
}
func (s *Service) fillLatestOperators(ctx context.Context, items []AppUser, userIDs []int64) error {
if s.adminDB == nil || len(items) == 0 || len(userIDs) == 0 {
return nil
}
args := make([]any, 0, len(userIDs)+1)
args = append(args, "app_users")
for _, userID := range userIDs {
args = append(args, strconv.FormatInt(userID, 10))
}
rows, err := s.adminDB.QueryContext(ctx, fmt.Sprintf(`
SELECT resource_id, user_id, username, action, created_at_ms
FROM admin_operation_logs
WHERE resource = ? AND resource_id IN (%s) AND status = 'success'
ORDER BY resource_id ASC, created_at_ms DESC, id DESC
`, placeholders(len(userIDs))), args...)
if err != nil {
return err
}
defer rows.Close()
index := appUserIndex(items)
seen := make(map[int64]struct{}, len(userIDs))
for rows.Next() {
var resourceID string
var adminID uint64
var name string
var action string
var operatedAtMs int64
if err := rows.Scan(&resourceID, &adminID, &name, &action, &operatedAtMs); err != nil {
return err
}
userID, err := strconv.ParseInt(strings.TrimSpace(resourceID), 10, 64)
if err != nil {
continue
}
if _, ok := seen[userID]; ok {
continue
}
i, ok := index[userID]
if !ok {
continue
}
seen[userID] = struct{}{}
if operatedAtMs <= items[i].LastOperatedAtMs {
continue
}
items[i].LastOperatedAtMs = operatedAtMs
items[i].LastOperator = &AppUserOperator{
AdminID: strconv.FormatUint(adminID, 10),
Name: strings.TrimSpace(name),
Action: strings.TrimSpace(action),
}
}
return rows.Err()
}
func appUserIndex(items []AppUser) map[int64]int {
index := make(map[int64]int, len(items))
for i := range items {
userID, err := strconv.ParseInt(strings.TrimSpace(items[i].UserID), 10, 64)
if err == nil && userID > 0 {
index[userID] = i
}
}
return index
}
func (s *Service) UpdateUser(ctx context.Context, userID int64, adminUserID int64, requestID string, req updateUserRequest) (AppUser, error) {
if s.userDB == nil {
return AppUser{}, fmt.Errorf("user mysql is not configured")
@ -527,6 +941,163 @@ func (s *Service) SetUserStatus(ctx context.Context, userID int64, status string
return output, nil
}
func (s *Service) AdjustTemporaryLevels(ctx context.Context, userID int64, operatorAdminID int64, requestID string, req adjustTemporaryLevelsRequest) (AppUser, error) {
if s.levelAdminClient == nil {
return AppUser{}, fmt.Errorf("activity service level admin client is not configured")
}
if userID <= 0 || operatorAdminID <= 0 || len(req.Adjustments) == 0 || len(req.Adjustments) > 2 {
return AppUser{}, fmt.Errorf("%w: 等级调整参数不正确", ErrInvalidArgument)
}
seen := make(map[string]struct{}, len(req.Adjustments))
adjustments := make([]*activityv1.TemporaryLevelAdjustment, 0, len(req.Adjustments))
for _, adjustment := range req.Adjustments {
track := strings.ToLower(strings.TrimSpace(adjustment.Track))
if track != "wealth" && track != "charm" {
return AppUser{}, fmt.Errorf("%w: 只允许调整富豪或魅力等级", ErrInvalidArgument)
}
if _, duplicated := seen[track]; duplicated {
return AppUser{}, fmt.Errorf("%w: 同一等级轨道不能重复提交", ErrInvalidArgument)
}
if adjustment.Level < 1 || adjustment.Level > 50 || adjustment.DurationDays <= 0 {
return AppUser{}, fmt.Errorf("%w: 等级必须为 1-50 且有效期必须大于 0 天", ErrInvalidArgument)
}
seen[track] = struct{}{}
adjustments = append(adjustments, &activityv1.TemporaryLevelAdjustment{
Track: track,
Level: adjustment.Level,
DurationDays: adjustment.DurationDays,
})
}
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return AppUser{}, fmt.Errorf("%w: request_id 不能为空", ErrInvalidArgument)
}
nowMs := nowMillis()
reason := strings.TrimSpace(req.Reason)
if reason == "" {
reason = "admin_temporary_user_level"
}
_, err := s.levelAdminClient.AdjustTemporaryUserLevels(ctx, &activityv1.AdjustTemporaryUserLevelsRequest{
Meta: &activityv1.RequestMeta{
RequestId: requestID,
Caller: "hyapp-admin-server",
SentAtMs: nowMs,
AppCode: appctx.FromContext(ctx),
},
CommandId: fmt.Sprintf("app-user-level:%s:%d", requestID, userID),
UserId: userID,
Adjustments: adjustments,
OperatorAdminId: operatorAdminID,
Reason: reason,
})
if err != nil {
return AppUser{}, err
}
return s.GetUser(ctx, userID)
}
func (s *Service) AdminBanUser(ctx context.Context, userID int64, operatorAdminID int64, requestID string, req banUserRequest) (SetUserStatusResult, error) {
if s.userAdminClient == nil {
return SetUserStatusResult{}, fmt.Errorf("user service admin ban client is not configured")
}
nowMs := nowMillis()
if userID <= 0 || operatorAdminID <= 0 || req.ExpiresAtMs < 0 || (req.ExpiresAtMs > 0 && req.ExpiresAtMs <= nowMs) {
return SetUserStatusResult{}, fmt.Errorf("%w: 封禁截止时间必须为 0 或晚于当前 UTC 时间", ErrInvalidArgument)
}
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return SetUserStatusResult{}, fmt.Errorf("%w: request_id 不能为空", ErrInvalidArgument)
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
reason = "admin_app_user_ban"
}
response, err := s.userAdminClient.AdminBanUser(ctx, &userv1.AdminBanUserRequest{
Meta: &userv1.RequestMeta{
RequestId: requestID,
Caller: "hyapp-admin-server",
SentAtMs: nowMs,
AppCode: appctx.FromContext(ctx),
},
CommandId: fmt.Sprintf("app-user-ban:%s:%d", requestID, userID),
TargetUserId: userID,
ExpiresAtMs: req.ExpiresAtMs,
OperatorAdminId: operatorAdminID,
Reason: reason,
})
if err != nil {
return SetUserStatusResult{}, err
}
return s.userStatusResultFromAdminResponse(ctx, userID, response.GetStatus())
}
func (s *Service) AdminUnbanUser(ctx context.Context, userID int64, operatorAdminID int64, requestID string, req unbanUserRequest) (SetUserStatusResult, error) {
if s.userAdminClient == nil {
return SetUserStatusResult{}, fmt.Errorf("user service admin ban client is not configured")
}
if userID <= 0 || operatorAdminID <= 0 {
return SetUserStatusResult{}, fmt.Errorf("%w: 解封参数不正确", ErrInvalidArgument)
}
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return SetUserStatusResult{}, fmt.Errorf("%w: request_id 不能为空", ErrInvalidArgument)
}
current, err := s.GetUser(ctx, userID)
if err != nil {
return SetUserStatusResult{}, err
}
// 历史 banned/disabled 用户没有独立 ban_id这类永久兼容记录只能沿用旧状态接口手动恢复。
if strings.TrimSpace(current.Ban.ID) == "" {
if current.Ban.Active && current.Ban.Source == "legacy" {
return s.SetUserStatus(ctx, userID, "active", operatorAdminID, requestID)
}
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
reason = "admin_app_user_unban"
}
nowMs := nowMillis()
response, err := s.userAdminClient.AdminUnbanUser(ctx, &userv1.AdminUnbanUserRequest{
Meta: &userv1.RequestMeta{
RequestId: requestID,
Caller: "hyapp-admin-server",
SentAtMs: nowMs,
AppCode: appctx.FromContext(ctx),
},
// 摘要可能选中一条更晚到期的 manager 封禁;留空由 user-service 选择最新 admin 事实。
BanId: "",
TargetUserId: userID,
OperatorAdminId: operatorAdminID,
Reason: reason,
})
if err != nil {
return SetUserStatusResult{}, err
}
return s.userStatusResultFromAdminResponse(ctx, userID, response.GetStatus())
}
func (s *Service) userStatusResultFromAdminResponse(ctx context.Context, userID int64, status *userv1.SetUserStatusResponse) (SetUserStatusResult, error) {
user, err := s.GetUser(ctx, userID)
if err != nil {
return SetUserStatusResult{}, err
}
result := SetUserStatusResult{User: user}
if status == nil {
return result, nil
}
result.RevokedSessionCount = status.GetRevokedSessionCount()
result.AccessTokenRevoked = status.GetAccessTokenRevoked()
result.AccessTokenError = status.GetAccessTokenRevokeError()
result.IMKicked = status.GetImKicked()
result.IMKickError = status.GetImKickError()
result.RoomEvicted = status.GetRoomEvicted()
result.RoomID = status.GetRoomId()
result.RTCKicked = status.GetRtcKicked()
result.RTCKickError = status.GetRtcKickError()
result.RoomEvictError = status.GetRoomEvictError()
return result, nil
}
func (s *Service) SetPassword(ctx context.Context, userID int64, password string) error {
if s.userDB == nil {
return fmt.Errorf("user mysql is not configured")
@ -784,6 +1355,9 @@ func normalizeListQuery(query listQuery) listQuery {
if query.EndMs < 0 {
query.EndMs = 0
}
if query.SnapshotAtMs < 0 {
query.SnapshotAtMs = 0
}
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
query.SortBy = normalizeAppUserSortBy(query.SortBy)
query.SortDirection = normalizeSortDirection(query.SortDirection)
@ -833,6 +1407,16 @@ func appUserListWhereSQLAt(appCode string, query listQuery, nowMs int64) (string
whereSQL += " AND u.created_at_ms < ?"
args = append(args, query.EndMs)
}
if query.SnapshotAtMs > 0 {
// 导出只读取任务创建时已经存在的用户,避免长任务执行期间的新注册插入到头部并改变分页集合。
whereSQL += " AND u.created_at_ms <= ?"
args = append(args, query.SnapshotAtMs)
}
if query.ExportCursorCreatedAtMs > 0 && query.ExportCursorUserID > 0 {
// 导出固定 created_at DESC/user_id DESC组合游标处理同毫秒注册且不依赖 OFFSET。
whereSQL += " AND (u.created_at_ms < ? OR (u.created_at_ms = ? AND u.user_id < ?))"
args = append(args, query.ExportCursorCreatedAtMs, query.ExportCursorCreatedAtMs, query.ExportCursorUserID)
}
return whereSQL, args
}

View File

@ -3,6 +3,7 @@ package appuser
import (
"strings"
"testing"
"time"
"hyapp-admin-server/internal/modules/shared"
)
@ -24,6 +25,68 @@ func TestNormalizeListQuerySort(t *testing.T) {
}
}
func TestAgeAtUTCBirthdayBoundary(t *testing.T) {
beforeBirthday := ageAtUTC("2000-07-11", time.Date(2026, time.July, 10, 23, 59, 59, 0, time.UTC))
if beforeBirthday == nil || *beforeBirthday != 25 {
t.Fatalf("age before UTC birthday = %v, want 25", beforeBirthday)
}
onBirthday := ageAtUTC("2000-07-11", time.Date(2026, time.July, 11, 0, 0, 0, 0, time.UTC))
if onBirthday == nil || *onBirthday != 26 {
t.Fatalf("age on UTC birthday = %v, want 26", onBirthday)
}
if got := ageAtUTC("", time.Now().UTC()); got != nil {
t.Fatalf("empty birth age = %v, want nil", got)
}
}
func TestDisplayUserRolesSupportsMultipleIdentities(t *testing.T) {
roles := displayUserRoles([]string{"manager", "host", "bd_leader", "host", "unknown"})
want := []string{"主播", "BD Leader", "经理"}
if strings.Join(roles, ",") != strings.Join(want, ",") {
t.Fatalf("roles = %#v, want %#v", roles, want)
}
ordinary := displayUserRoles(nil)
if len(ordinary) != 1 || ordinary[0] != "普通用户" {
t.Fatalf("ordinary roles = %#v", ordinary)
}
}
func TestAppUserExportRowKeepsCompositeIdentityFieldsAndAddsColumns(t *testing.T) {
age := int32(26)
item := AppUser{
UserID: "10001",
DefaultDisplayUserID: "1299324",
PrettyDisplayUserID: "11222",
Username: "sumon Ahammad",
Avatar: "https://example.test/avatar.png",
Age: &age,
Roles: []string{"主播", "经理"},
Levels: AppUserLevels{
Wealth: AppUserLevel{RealLevel: 8, DisplayLevel: 14, TemporaryTargetLevel: 14, ExpiresAtMs: 1234},
Charm: AppUserLevel{RealLevel: 9, DisplayLevel: 15, TemporaryTargetLevel: 15, ExpiresAtMs: 5678},
Game: AppUserLevel{DisplayLevel: 6},
},
}
row := appUserExportRow(item)
if len(row) != len(appUserExportHeaders()) {
t.Fatalf("export row columns = %d, headers = %d", len(row), len(appUserExportHeaders()))
}
if row[0] != "10001" || row[1] != "1299324" || row[2] != "11222" || row[3] != "sumon Ahammad" || row[4] != "https://example.test/avatar.png" {
t.Fatalf("identity columns changed: %#v", row[:5])
}
}
func TestSpreadsheetSafeCSVCellEscapesFormulaPrefixes(t *testing.T) {
for _, value := range []string{"=HYPERLINK(\"https://example.test\")", "+cmd", " -1+1", "@SUM(A1:A2)"} {
if got := spreadsheetSafeCSVCell(value); !strings.HasPrefix(got, "'") {
t.Fatalf("dangerous CSV cell %q was not escaped: %q", value, got)
}
}
if got := spreadsheetSafeCSVCell("normal user"); got != "normal user" {
t.Fatalf("normal CSV cell changed: %q", got)
}
}
func TestAppUserListWhereSQLFilters(t *testing.T) {
whereSQL, args := appUserListWhereSQL("lalu", normalizeListQuery(listQuery{
Country: "ph",

View File

@ -57,16 +57,17 @@ type AppTrackingFunnelQuery struct {
}
type SocialRequirementsQuery struct {
AppCode string
StatTZ string
StartMS int64
EndMS int64
RegionID int64
RegionIDs []int64
CountryID int64
Section string
UserRole string
PayerType string
AppCode string
StatTZ string
StartMS int64
EndMS int64
RegionID int64
RegionIDs []int64
CountryID int64
Section string
UserRole string
PayerType string
NewUserType string
}
type PlatformGrantStatisticsQuery struct {
@ -260,6 +261,9 @@ func (s *DashboardService) SocialRequirements(ctx context.Context, query SocialR
if payerType := strings.TrimSpace(query.PayerType); payerType != "" {
values.Set("payer_type", payerType)
}
if newUserType := strings.TrimSpace(query.NewUserType); newUserType != "" {
values.Set("new_user_type", newUserType)
}
// Social BI 数据需求只读 statistics-service 聚合投影admin 不直接拼 owner service 明细或跨库查询。
return s.statisticsGET(ctx, "/internal/v1/statistics/social/requirements", values)
}

View File

@ -78,15 +78,16 @@ func (h *Handler) Requirements(c *gin.Context) {
return
}
result, err := h.service.Requirements(c.Request.Context(), access, RequirementsQuery{
StatTZ: c.Query("stat_tz"),
StartMS: queryInt64(c, "start_ms"),
EndMS: queryInt64(c, "end_ms"),
AppCodes: splitCSV(c.Query("app_codes")),
RegionID: queryInt64(c, "region_id"),
RegionIDs: queryInt64CSV(c.Query("region_ids")),
Section: c.Query("section"),
UserRole: c.Query("user_role"),
PayerType: c.Query("payer_type"),
StatTZ: c.Query("stat_tz"),
StartMS: queryInt64(c, "start_ms"),
EndMS: queryInt64(c, "end_ms"),
AppCodes: splitCSV(c.Query("app_codes")),
RegionID: queryInt64(c, "region_id"),
RegionIDs: queryInt64CSV(c.Query("region_ids")),
Section: c.Query("section"),
UserRole: c.Query("user_role"),
PayerType: c.Query("payer_type"),
NewUserType: c.Query("new_user_type"),
})
if err != nil {
response.ServerError(c, "获取数据需求指标失败")

View File

@ -298,15 +298,16 @@ type FunnelResult struct {
}
type RequirementsQuery struct {
StatTZ string
StartMS int64
EndMS int64
AppCodes []string
RegionID int64
RegionIDs []int64
Section string
UserRole string
PayerType string
StatTZ string
StartMS int64
EndMS int64
AppCodes []string
RegionID int64
RegionIDs []int64
Section string
UserRole string
PayerType string
NewUserType string
}
type AppRequirements struct {
@ -466,15 +467,16 @@ func (s *Service) appRequirements(ctx context.Context, app AppInfo, regions []Re
regionIDs = requestRegionIDs
}
requirements, err := s.dashboards.SocialRequirements(ctx, dashboard.SocialRequirementsQuery{
AppCode: app.AppCode,
StatTZ: query.StatTZ,
StartMS: query.StartMS,
EndMS: query.EndMS,
RegionID: query.RegionID,
RegionIDs: regionIDs,
Section: query.Section,
UserRole: query.UserRole,
PayerType: query.PayerType,
AppCode: app.AppCode,
StatTZ: query.StatTZ,
StartMS: query.StartMS,
EndMS: query.EndMS,
RegionID: query.RegionID,
RegionIDs: regionIDs,
Section: query.Section,
UserRole: query.UserRole,
PayerType: query.PayerType,
NewUserType: query.NewUserType,
})
if err != nil {
out.Error = fmt.Sprintf("获取数据需求指标失败: %v", err)

View File

@ -5,6 +5,8 @@ import (
"path/filepath"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
@ -41,6 +43,10 @@ func (h *Handler) GetJob(c *gin.Context) {
response.BadRequest(c, "任务不存在")
return
}
if !canReadJob(c, job) {
response.Forbidden(c, "没有查看该任务的权限")
return
}
response.OK(c, job)
}
@ -72,6 +78,15 @@ func (h *Handler) DownloadJobArtifact(c *gin.Context) {
if !ok {
return
}
job, err := h.service.GetJob(id)
if err != nil {
response.BadRequest(c, "任务不存在")
return
}
if !canReadJob(c, job) {
response.Forbidden(c, "没有下载该任务的权限")
return
}
_, artifactPath, err := h.service.JobArtifact(id)
if err != nil {
response.BadRequest(c, err.Error())
@ -81,3 +96,16 @@ func (h *Handler) DownloadJobArtifact(c *gin.Context) {
c.Header("Content-Disposition", "attachment; filename="+filepath.Base(artifactPath))
c.File(artifactPath)
}
func canReadJob(c *gin.Context, job *model.AdminJob) bool {
if job == nil {
return false
}
if middleware.HasAnyPermission(c, "job:view", "country:update") {
return true
}
// app-user:export 只允许读取本人创建的用户导出,不能借任务 ID 查看其他后台任务或他人的 CSV。
return middleware.HasPermission(c, "app-user:export") &&
job.Type == "app-user-export" &&
job.CreatedBy == middleware.CurrentUserID(c)
}

View File

@ -0,0 +1,34 @@
package job
import (
"net/http/httptest"
"testing"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/model"
"github.com/gin-gonic/gin"
)
func TestCanReadJobScopesAppUserExportToCreator(t *testing.T) {
gin.SetMode(gin.TestMode)
context, _ := gin.CreateTestContext(httptest.NewRecorder())
context.Set(middleware.ContextUserID, uint(7))
context.Set(middleware.ContextPermissions, []string{"app-user:export"})
owned := &model.AdminJob{ID: 1, Type: "app-user-export", CreatedBy: 7}
if !canReadJob(context, owned) {
t.Fatal("app-user exporter must read own export job")
}
if canReadJob(context, &model.AdminJob{ID: 2, Type: "app-user-export", CreatedBy: 8}) {
t.Fatal("app-user exporter must not read another operator's export")
}
if canReadJob(context, &model.AdminJob{ID: 3, Type: "country-code-rename", CreatedBy: 7}) {
t.Fatal("app-user exporter must not read unrelated job types")
}
context.Set(middleware.ContextPermissions, []string{"job:view"})
if !canReadJob(context, &model.AdminJob{ID: 4, Type: "country-code-rename", CreatedBy: 8}) {
t.Fatal("job viewer must retain broad read access")
}
}

View File

@ -8,8 +8,8 @@ import (
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/jobs", middleware.RequirePermission("job:view"), h.ListJobs)
protected.GET("/jobs/:id", middleware.RequireAnyPermission("job:view", "country:update"), h.GetJob)
protected.GET("/jobs/:id", middleware.RequireAnyPermission("job:view", "country:update", "app-user:export"), h.GetJob)
protected.POST("/jobs/:id/cancel", middleware.RequirePermission("job:cancel"), h.CancelJob)
protected.GET("/jobs/:id/artifact", middleware.RequirePermission("job:view"), h.DownloadJobArtifact)
protected.GET("/jobs/:id/artifact", middleware.RequireAnyPermission("job:view", "app-user:export"), h.DownloadJobArtifact)
protected.POST("/exports/users", middleware.RequirePermission("export:create"), h.CreateUserExportJob)
}

View File

@ -639,6 +639,10 @@ func (h *Handler) GrantResource(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
if err := h.validateGenericGrantResource(c, req.ResourceID); err != nil {
response.BadRequest(c, err.Error())
return
}
resp, err := h.wallet.GrantResource(c.Request.Context(), &walletv1.GrantResourceRequest{
CommandId: strings.TrimSpace(req.CommandID),
AppCode: appctx.FromContext(c.Request.Context()),
@ -670,6 +674,10 @@ func (h *Handler) GrantResourceGroup(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
if err := h.validateGenericGrantGroup(c, req.GroupID); err != nil {
response.BadRequest(c, err.Error())
return
}
resp, err := h.wallet.GrantResourceGroup(c.Request.Context(), &walletv1.GrantResourceGroupRequest{
CommandId: strings.TrimSpace(req.CommandID),
AppCode: appctx.FromContext(c.Request.Context()),
@ -688,6 +696,53 @@ func (h *Handler) GrantResourceGroup(c *gin.Context) {
response.Created(c, grant)
}
func (h *Handler) validateGenericGrantResource(c *gin.Context, resourceID int64) error {
resp, err := h.wallet.GetResource(c.Request.Context(), &walletv1.GetResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: resourceID,
})
if err != nil {
return err
}
// VIP 体验卡除了资源权益,还必须同步写入卡片等级、独立失效时间和 VIP 状态投影;
// 通用资源赠送只会创建 entitlement放行会产生“背包可见但无法生效”的半成品数据。
if resp.GetResource().GetResourceType() == resourceTypeVIPTrialCard {
return fmt.Errorf("VIP 体验卡必须通过 VIP 配置页赠送")
}
return nil
}
func (h *Handler) validateGenericGrantGroup(c *gin.Context, groupID int64) error {
resp, err := h.wallet.GetResourceGroup(c.Request.Context(), &walletv1.GetResourceGroupRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
GroupId: groupID,
})
if err != nil {
return err
}
for _, item := range resp.GetGroup().GetItems() {
resourceType := item.GetResource().GetResourceType()
// 老资源组数据可能只返回 resource_id逐项回查可防止因嵌套 Resource 缺失而绕过体验卡专用发放链路。
if resourceType == "" && item.GetResourceId() > 0 {
resourceResp, err := h.wallet.GetResource(c.Request.Context(), &walletv1.GetResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: item.GetResourceId(),
})
if err != nil {
return err
}
resourceType = resourceResp.GetResource().GetResourceType()
}
if resourceType == resourceTypeVIPTrialCard {
return fmt.Errorf("包含 VIP 体验卡的资源组必须通过 VIP 配置页赠送")
}
}
return nil
}
func (h *Handler) RevokeResourceGrant(c *gin.Context) {
grantID := strings.TrimSpace(c.Param("grant_id"))
if grantID == "" {

View File

@ -220,6 +220,73 @@ func TestIdentityAutoGrantConfigRouteDoesNotHitResourceGroupID(t *testing.T) {
}
}
func TestGenericResourceGrantRejectsVIPTrialCard(t *testing.T) {
wallet := &mockResourceWallet{resources: map[int64]*walletv1.Resource{
99: {AppCode: "lalu", ResourceId: 99, ResourceCode: "vip_trial_5", ResourceType: resourceTypeVIPTrialCard, Name: "VIP5体验卡"},
}}
router := newResourceHandlerTestRouter(New(wallet, nil, nil, time.Second, nil))
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/admin/resource-grants/resource", strings.NewReader(`{"commandId":"grant-1","targetUserId":"318705991371722752","resourceId":99,"quantity":1,"durationMs":86400000,"reason":"manual"}`))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "VIP 体验卡必须通过 VIP 配置页赠送") {
t.Fatalf("generic grant must reject vip trial card: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestGenericResourceGroupGrantRejectsVIPTrialCardItem(t *testing.T) {
wallet := &mockResourceWallet{resourceGroups: map[int64]*walletv1.ResourceGroup{
88: {
AppCode: "lalu",
GroupId: 88,
Items: []*walletv1.ResourceGroupItem{{
ResourceId: 99,
Resource: &walletv1.Resource{ResourceId: 99, ResourceType: resourceTypeVIPTrialCard},
}},
},
}}
router := newResourceHandlerTestRouter(New(wallet, nil, nil, time.Second, nil))
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/admin/resource-grants/group", strings.NewReader(`{"commandId":"grant-group-1","targetUserId":"318705991371722752","groupId":88,"reason":"manual"}`))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "包含 VIP 体验卡的资源组必须通过 VIP 配置页赠送") {
t.Fatalf("generic group grant must reject vip trial card: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestVIPGrantPermissionCanLookupResourceGrantTarget(t *testing.T) {
db, sqlMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sql mock failed: %v", err)
}
defer db.Close()
sqlMock.ExpectQuery(`(?s)SELECT u\.user_id.*FROM users u.*LIMIT 1`).
WillReturnRows(sqlmock.NewRows([]string{"user_id", "current_display_user_id", "username", "avatar"}).AddRow(int64(318705991371722752), "111", "target", "avatar.png"))
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu"))
c.Set(middleware.ContextRequestID, "vip-target-test")
c.Set(middleware.ContextPermissions, []string{"vip-config:grant"})
c.Next()
})
RegisterRoutes(router.Group(""), New(&mockResourceWallet{}, nil, db, time.Second, nil))
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/admin/resource-grants/target?keyword=111", nil)
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"userId":"318705991371722752"`) {
t.Fatalf("vip grant permission should resolve target: status=%d body=%s", recorder.Code, recorder.Body.String())
}
if err := sqlMock.ExpectationsWereMet(); err != nil {
t.Fatalf("lookup sql mismatch: %v", err)
}
}
func TestListResourceGrantsResolvesDisplayIDBeforeWalletFilter(t *testing.T) {
db, sqlMock, err := sqlmock.New()
if err != nil {
@ -304,6 +371,7 @@ func newResourceHandlerTestRouter(handler *Handler) *gin.Engine {
type mockResourceWallet struct {
walletclient.Client
resources map[int64]*walletv1.Resource
resourceGroups map[int64]*walletv1.ResourceGroup
updates []*walletv1.UpdateResourceRequest
deletedResources []*walletv1.DeleteResourceRequest
batchDeletedResources []*walletv1.BatchDeleteResourcesRequest
@ -320,6 +388,14 @@ func (m *mockResourceWallet) GetResource(ctx context.Context, req *walletv1.GetR
return &walletv1.GetResourceResponse{Resource: resource}, nil
}
func (m *mockResourceWallet) GetResourceGroup(_ context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error) {
group := m.resourceGroups[req.GetGroupId()]
if group == nil {
return nil, fmt.Errorf("resource group not found")
}
return &walletv1.GetResourceGroupResponse{Group: group}, nil
}
func (m *mockResourceWallet) UpdateResource(ctx context.Context, req *walletv1.UpdateResourceRequest) (*walletv1.ResourceResponse, error) {
m.updates = append(m.updates, req)
return &walletv1.ResourceResponse{Resource: &walletv1.Resource{

View File

@ -18,11 +18,12 @@ import (
const dayMillis int64 = 24 * 60 * 60 * 1000
const (
resourceTypeCoin = "coin"
resourceTypeAvatarFrame = "avatar_frame"
resourceTypeBadge = "badge"
resourceTypeEmojiPack = "emoji_pack"
resourceTypeProfileCard = "profile_card"
resourceTypeCoin = "coin"
resourceTypeAvatarFrame = "avatar_frame"
resourceTypeBadge = "badge"
resourceTypeEmojiPack = "emoji_pack"
resourceTypeProfileCard = "profile_card"
resourceTypeVIPTrialCard = "vip_trial_card"
)
const (

View File

@ -49,7 +49,8 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.POST("/admin/resource-grants/resource", middleware.RequirePermission("resource-grant:create"), h.GrantResource)
protected.POST("/admin/resource-grants/group", middleware.RequirePermission("resource-grant:create"), h.GrantResourceGroup)
protected.POST("/admin/resource-grants/:grant_id/revoke", middleware.RequirePermission("resource-grant:revoke"), h.RevokeResourceGrant)
protected.GET("/admin/resource-grants/target", middleware.RequirePermission("resource-grant:create"), h.LookupResourceGrantTarget)
// VIP 赠送复用同一目标用户解析入口;仅有 vip-config:grant 的运营也必须能把短号解析成内部 user_id。
protected.GET("/admin/resource-grants/target", middleware.RequireAnyPermission("resource-grant:create", "vip-config:grant"), h.LookupResourceGrantTarget)
protected.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), h.ListResourceGrants)
protected.GET("/admin/resource-shop/purchase-orders", middleware.RequirePermission("resource-shop:view"), h.ListResourceShopPurchaseOrders)

View File

@ -27,6 +27,10 @@ type configRequest struct {
Levels []vipLevelDTO `json:"levels"`
}
type programRequest struct {
Config *vipProgramConfigDTO `json:"config"`
}
type grantVipRequest struct {
CommandID string `json:"commandId"`
TargetUserID any `json:"targetUserId"`
@ -34,9 +38,25 @@ type grantVipRequest struct {
Reason string `json:"reason"`
}
type grantVipTrialCardRequest struct {
CommandID string `json:"commandId"`
TargetUserID any `json:"targetUserId"`
Level int32 `json:"level"`
DurationMS int64 `json:"durationMs"`
ResourceID *int64 `json:"resourceId"`
Reason string `json:"reason"`
}
type configDTO struct {
Levels []vipLevelDTO `json:"levels"`
ServerTimeMS int64 `json:"serverTimeMs"`
Levels []vipLevelDTO `json:"levels"`
ProgramConfig vipProgramConfigDTO `json:"programConfig"`
ServerTimeMS int64 `json:"serverTimeMs"`
}
type programDTO struct {
Config vipProgramConfigDTO `json:"config"`
Benefits []vipBenefitDTO `json:"benefits"`
ServerTimeMS int64 `json:"serverTimeMs"`
}
type grantVipDTO struct {
@ -46,30 +66,103 @@ type grantVipDTO struct {
ServerTimeMS int64 `json:"serverTimeMs"`
}
type grantVipTrialCardDTO struct {
TrialCard vipTrialCardDTO `json:"trialCard"`
State vipStateDTO `json:"state"`
ServerTimeMS int64 `json:"serverTimeMs"`
}
type userVipDTO struct {
UserID string `json:"userId"`
Level int32 `json:"level"`
Name string `json:"name"`
Active bool `json:"active"`
StartedAtMS int64 `json:"startedAtMs"`
ExpiresAtMS int64 `json:"expiresAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
UserID string `json:"userId"`
Level int32 `json:"level"`
Name string `json:"name"`
Active bool `json:"active"`
StartedAtMS int64 `json:"startedAtMs"`
ExpiresAtMS int64 `json:"expiresAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
ProgramType string `json:"programType"`
ConfigVersion int64 `json:"configVersion"`
}
type vipLevelDTO struct {
Level int32 `json:"level"`
Name string `json:"name"`
Status string `json:"status"`
PriceCoin int64 `json:"priceCoin"`
DurationMS int64 `json:"durationMs"`
RewardResourceGroupID int64 `json:"rewardResourceGroupId"`
SortOrder int32 `json:"sortOrder"`
RechargeGateRequired bool `json:"rechargeGateRequired"`
RequiredRechargeCoinAmount int64 `json:"requiredRechargeCoinAmount"`
UserRechargeCoinAmount int64 `json:"userRechargeCoinAmount"`
PurchaseLockedReason string `json:"purchaseLockedReason"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
Level int32 `json:"level"`
Name string `json:"name"`
Status string `json:"status"`
PriceCoin int64 `json:"priceCoin"`
DurationMS int64 `json:"durationMs"`
RewardResourceGroupID int64 `json:"rewardResourceGroupId"`
SortOrder int32 `json:"sortOrder"`
RechargeGateRequired bool `json:"rechargeGateRequired"`
RequiredRechargeCoinAmount int64 `json:"requiredRechargeCoinAmount"`
UserRechargeCoinAmount int64 `json:"userRechargeCoinAmount"`
PurchaseLockedReason string `json:"purchaseLockedReason"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
Benefits []vipBenefitDTO `json:"benefits"`
ConfigVersion int64 `json:"configVersion"`
}
type vipProgramConfigDTO struct {
AppCode string `json:"appCode"`
ProgramType string `json:"programType"`
LevelCount int32 `json:"levelCount"`
SameLevelExpiryPolicy string `json:"sameLevelExpiryPolicy"`
UpgradeExpiryPolicy string `json:"upgradeExpiryPolicy"`
DowngradePurchasePolicy string `json:"downgradePurchasePolicy"`
BenefitInheritancePolicy string `json:"benefitInheritancePolicy"`
GrantMode string `json:"grantMode"`
TrialCardEnabled bool `json:"trialCardEnabled"`
Status string `json:"status"`
ConfigVersion int64 `json:"configVersion"`
UpdatedByAdminID int64 `json:"updatedByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type vipBenefitDTO struct {
BenefitCode string `json:"benefitCode"`
Name string `json:"name"`
BenefitType string `json:"benefitType"`
UnlockLevel int32 `json:"unlockLevel"`
Status string `json:"status"`
TrialEnabled bool `json:"trialEnabled"`
ResourceID int64 `json:"resourceId"`
ResourceType string `json:"resourceType"`
ExecutionScope string `json:"executionScope"`
AutoEquip bool `json:"autoEquip"`
SortOrder int32 `json:"sortOrder"`
MetadataJSON string `json:"metadataJson"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type vipTrialCardDTO struct {
TrialCardID string `json:"trialCardId"`
EntitlementID string `json:"entitlementId"`
ResourceID int64 `json:"resourceId"`
UserID string `json:"userId"`
Level int32 `json:"level"`
Name string `json:"name"`
Status string `json:"status"`
Equipped bool `json:"equipped"`
DurationMS int64 `json:"durationMs"`
EffectiveAtMS int64 `json:"effectiveAtMs"`
ExpiresAtMS int64 `json:"expiresAtMs"`
RemainingDurationMS int64 `json:"remainingDurationMs"`
GrantSource string `json:"grantSource"`
SourceGrantID string `json:"sourceGrantId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type vipStateDTO struct {
PaidVIP userVipDTO `json:"paidVip"`
EquippedTrialCard vipTrialCardDTO `json:"equippedTrialCard"`
EffectiveVIP userVipDTO `json:"effectiveVip"`
EffectiveSource string `json:"effectiveSource"`
EffectiveBenefits []vipBenefitDTO `json:"effectiveBenefits"`
EvaluatedAtMS int64 `json:"evaluatedAtMs"`
ProgramConfig vipProgramConfigDTO `json:"programConfig"`
}
type vipRewardItemDTO struct {
@ -81,6 +174,59 @@ type vipRewardItemDTO struct {
ExpiresAtMS int64 `json:"expiresAtMs"`
}
func (h *Handler) GetProgram(c *gin.Context) {
resp, err := h.wallet.GetVipProgramConfig(c.Request.Context(), &walletv1.GetVipProgramConfigRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
})
if err != nil {
response.ServerError(c, "获取 VIP 体系失败")
return
}
// 体系规则与权益目录同次返回,保证后台切换 App 后不会把上一 App 的权益矩阵误带入当前表单。
response.OK(c, programDTO{
Config: vipProgramConfigFromProto(resp.GetConfig()),
Benefits: vipBenefitsFromProto(resp.GetBenefits()),
ServerTimeMS: resp.GetServerTimeMs(),
})
}
func (h *Handler) UpdateProgram(c *gin.Context) {
var req programRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Config == nil {
response.BadRequest(c, "VIP 体系参数不正确")
return
}
config := req.Config.toProto()
if config.GetLevelCount() <= 0 || strings.TrimSpace(config.GetProgramType()) == "" || strings.TrimSpace(config.GetStatus()) == "" {
response.BadRequest(c, "VIP 体系参数不正确")
return
}
appCode := appctx.FromContext(c.Request.Context())
// AppCode、版本和审计操作者只取服务端上下文即使客户端提交同名字段也不能跨 App 或伪造审计信息。
config.AppCode = appCode
config.ConfigVersion = 0
config.UpdatedByAdminId = 0
config.CreatedAtMs = 0
config.UpdatedAtMs = 0
resp, err := h.wallet.UpdateAdminVipProgramConfig(c.Request.Context(), &walletv1.UpdateAdminVipProgramConfigRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
Config: config,
OperatorUserId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "update-vip-program", "vip_program_configs", appCode, "success", resp.GetConfig().GetProgramType())
response.OK(c, programDTO{
Config: vipProgramConfigFromProto(resp.GetConfig()),
Benefits: []vipBenefitDTO{},
ServerTimeMS: resp.GetServerTimeMs(),
})
}
func (h *Handler) ListLevels(c *gin.Context) {
resp, err := h.wallet.ListAdminVipLevels(c.Request.Context(), &walletv1.ListAdminVipLevelsRequest{
RequestId: middleware.CurrentRequestID(c),
@ -90,7 +236,11 @@ func (h *Handler) ListLevels(c *gin.Context) {
response.ServerError(c, "获取 VIP 配置失败")
return
}
response.OK(c, configDTO{Levels: vipLevelsFromProto(resp.GetLevels()), ServerTimeMS: resp.GetServerTimeMs()})
response.OK(c, configDTO{
Levels: vipLevelsFromProto(resp.GetLevels()),
ProgramConfig: vipProgramConfigFromProto(resp.GetProgramConfig()),
ServerTimeMS: resp.GetServerTimeMs(),
})
}
func (h *Handler) GrantVIP(c *gin.Context) {
@ -130,6 +280,46 @@ func (h *Handler) GrantVIP(c *gin.Context) {
response.Created(c, grantVipFromProto(resp))
}
func (h *Handler) GrantVIPTrialCard(c *gin.Context) {
var req grantVipTrialCardRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "VIP 体验卡赠送参数不正确")
return
}
targetUserID, ok := parseFlexibleUserID(req.TargetUserID)
if !ok || req.Level <= 0 || req.DurationMS <= 0 || (req.ResourceID != nil && *req.ResourceID <= 0) {
response.BadRequest(c, "VIP 体验卡赠送参数不正确")
return
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
response.BadRequest(c, "赠送原因不能为空")
return
}
commandID := strings.TrimSpace(req.CommandID)
if commandID == "" {
commandID = "admin_vip_trial_card_grant:" + middleware.CurrentRequestID(c)
}
resp, err := h.wallet.GrantVipTrialCard(c.Request.Context(), &walletv1.GrantVipTrialCardRequest{
CommandId: commandID,
AppCode: appctx.FromContext(c.Request.Context()),
TargetUserId: targetUserID,
Level: req.Level,
DurationMs: req.DurationMS,
ResourceId: req.ResourceID,
GrantSource: "admin_grant",
OperatorUserId: int64(middleware.CurrentUserID(c)),
Reason: reason,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
// 体验卡是独立背包实例,审计目标使用 trial_card_id后续切换佩戴不会覆盖本次赠送记录。
shared.OperationLogWithResourceID(c, h.audit, "grant-vip-trial-card", "user_vip_trial_cards", resp.GetTrialCard().GetTrialCardId(), "success", "target_user_id="+strconv.FormatInt(targetUserID, 10))
response.Created(c, grantVipTrialCardFromProto(resp))
}
func (h *Handler) UpdateLevels(c *gin.Context) {
var req configRequest
if err := c.ShouldBindJSON(&req); err != nil {
@ -138,6 +328,10 @@ func (h *Handler) UpdateLevels(c *gin.Context) {
}
input := make([]*walletv1.AdminVipLevelInput, 0, len(req.Levels))
for _, level := range req.Levels {
benefits := make([]*walletv1.VipBenefit, 0, len(level.Benefits))
for _, benefit := range level.Benefits {
benefits = append(benefits, benefit.toProto())
}
input = append(input, &walletv1.AdminVipLevelInput{
Level: level.Level,
Name: strings.TrimSpace(level.Name),
@ -148,6 +342,7 @@ func (h *Handler) UpdateLevels(c *gin.Context) {
// VIP 累充门槛已下线;后台旧请求即使携带旧字段,也统一写 0避免旧页面或缓存把门槛重新保存回来。
RequiredRechargeCoinAmount: 0,
SortOrder: level.SortOrder,
Benefits: benefits,
})
}
resp, err := h.wallet.UpdateAdminVipLevels(c.Request.Context(), &walletv1.UpdateAdminVipLevelsRequest{
@ -161,7 +356,11 @@ func (h *Handler) UpdateLevels(c *gin.Context) {
return
}
shared.OperationLogWithResourceID(c, h.audit, "update-vip-config", "vip_levels", appctx.FromContext(c.Request.Context()), "success", strconv.Itoa(len(resp.GetLevels())))
response.OK(c, configDTO{Levels: vipLevelsFromProto(resp.GetLevels()), ServerTimeMS: resp.GetServerTimeMs()})
response.OK(c, configDTO{
Levels: vipLevelsFromProto(resp.GetLevels()),
ProgramConfig: vipProgramConfigFromProto(resp.GetProgramConfig()),
ServerTimeMS: resp.GetServerTimeMs(),
})
}
func vipLevelsFromProto(items []*walletv1.VipLevel) []vipLevelDTO {
@ -185,11 +384,92 @@ func vipLevelsFromProto(items []*walletv1.VipLevel) []vipLevelDTO {
PurchaseLockedReason: item.GetPurchaseLockedReason(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
Benefits: vipBenefitsFromProto(item.GetBenefits()),
ConfigVersion: item.GetConfigVersion(),
})
}
return levels
}
func vipProgramConfigFromProto(item *walletv1.VipProgramConfig) vipProgramConfigDTO {
if item == nil {
return vipProgramConfigDTO{}
}
return vipProgramConfigDTO{
AppCode: item.GetAppCode(),
ProgramType: item.GetProgramType(),
LevelCount: item.GetLevelCount(),
SameLevelExpiryPolicy: item.GetSameLevelExpiryPolicy(),
UpgradeExpiryPolicy: item.GetUpgradeExpiryPolicy(),
DowngradePurchasePolicy: item.GetDowngradePurchasePolicy(),
BenefitInheritancePolicy: item.GetBenefitInheritancePolicy(),
GrantMode: item.GetGrantMode(),
TrialCardEnabled: item.GetTrialCardEnabled(),
Status: item.GetStatus(),
ConfigVersion: item.GetConfigVersion(),
UpdatedByAdminID: item.GetUpdatedByAdminId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func (item vipProgramConfigDTO) toProto() *walletv1.VipProgramConfig {
return &walletv1.VipProgramConfig{
ProgramType: strings.TrimSpace(item.ProgramType),
LevelCount: item.LevelCount,
SameLevelExpiryPolicy: strings.TrimSpace(item.SameLevelExpiryPolicy),
UpgradeExpiryPolicy: strings.TrimSpace(item.UpgradeExpiryPolicy),
DowngradePurchasePolicy: strings.TrimSpace(item.DowngradePurchasePolicy),
BenefitInheritancePolicy: strings.TrimSpace(item.BenefitInheritancePolicy),
GrantMode: strings.TrimSpace(item.GrantMode),
TrialCardEnabled: item.TrialCardEnabled,
Status: strings.TrimSpace(item.Status),
}
}
func vipBenefitsFromProto(items []*walletv1.VipBenefit) []vipBenefitDTO {
benefits := make([]vipBenefitDTO, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
benefits = append(benefits, vipBenefitDTO{
BenefitCode: item.GetBenefitCode(),
Name: item.GetName(),
BenefitType: item.GetBenefitType(),
UnlockLevel: item.GetUnlockLevel(),
Status: item.GetStatus(),
TrialEnabled: item.GetTrialEnabled(),
ResourceID: item.GetResourceId(),
ResourceType: item.GetResourceType(),
ExecutionScope: item.GetExecutionScope(),
AutoEquip: item.GetAutoEquip(),
SortOrder: item.GetSortOrder(),
MetadataJSON: item.GetMetadataJson(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
})
}
return benefits
}
func (item vipBenefitDTO) toProto() *walletv1.VipBenefit {
return &walletv1.VipBenefit{
BenefitCode: strings.TrimSpace(item.BenefitCode),
Name: strings.TrimSpace(item.Name),
BenefitType: strings.TrimSpace(item.BenefitType),
UnlockLevel: item.UnlockLevel,
Status: strings.TrimSpace(item.Status),
TrialEnabled: item.TrialEnabled,
ResourceId: item.ResourceID,
ResourceType: strings.TrimSpace(item.ResourceType),
ExecutionScope: strings.TrimSpace(item.ExecutionScope),
AutoEquip: item.AutoEquip,
SortOrder: item.SortOrder,
MetadataJson: strings.TrimSpace(item.MetadataJSON),
}
}
func grantVipFromProto(resp *walletv1.GrantVipResponse) grantVipDTO {
if resp == nil {
return grantVipDTO{}
@ -207,13 +487,65 @@ func userVipFromProto(item *walletv1.UserVip) userVipDTO {
return userVipDTO{}
}
return userVipDTO{
UserID: strconv.FormatInt(item.GetUserId(), 10),
Level: item.GetLevel(),
Name: item.GetName(),
Active: item.GetActive(),
StartedAtMS: item.GetStartedAtMs(),
ExpiresAtMS: item.GetExpiresAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
UserID: strconv.FormatInt(item.GetUserId(), 10),
Level: item.GetLevel(),
Name: item.GetName(),
Active: item.GetActive(),
StartedAtMS: item.GetStartedAtMs(),
ExpiresAtMS: item.GetExpiresAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
ProgramType: item.GetProgramType(),
ConfigVersion: item.GetConfigVersion(),
}
}
func grantVipTrialCardFromProto(resp *walletv1.GrantVipTrialCardResponse) grantVipTrialCardDTO {
if resp == nil {
return grantVipTrialCardDTO{}
}
return grantVipTrialCardDTO{
TrialCard: vipTrialCardFromProto(resp.GetTrialCard()),
State: vipStateFromProto(resp.GetState()),
ServerTimeMS: resp.GetServerTimeMs(),
}
}
func vipTrialCardFromProto(item *walletv1.VipTrialCard) vipTrialCardDTO {
if item == nil {
return vipTrialCardDTO{}
}
return vipTrialCardDTO{
TrialCardID: item.GetTrialCardId(),
EntitlementID: item.GetEntitlementId(),
ResourceID: item.GetResourceId(),
UserID: strconv.FormatInt(item.GetUserId(), 10),
Level: item.GetLevel(),
Name: item.GetName(),
Status: item.GetStatus(),
Equipped: item.GetEquipped(),
DurationMS: item.GetDurationMs(),
EffectiveAtMS: item.GetEffectiveAtMs(),
ExpiresAtMS: item.GetExpiresAtMs(),
RemainingDurationMS: item.GetRemainingDurationMs(),
GrantSource: item.GetGrantSource(),
SourceGrantID: item.GetSourceGrantId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func vipStateFromProto(item *walletv1.VipState) vipStateDTO {
if item == nil {
return vipStateDTO{EffectiveBenefits: []vipBenefitDTO{}}
}
return vipStateDTO{
PaidVIP: userVipFromProto(item.GetPaidVip()),
EquippedTrialCard: vipTrialCardFromProto(item.GetEquippedTrialCard()),
EffectiveVIP: userVipFromProto(item.GetEffectiveVip()),
EffectiveSource: item.GetEffectiveSource(),
EffectiveBenefits: vipBenefitsFromProto(item.GetEffectiveBenefits()),
EvaluatedAtMS: item.GetEvaluatedAtMs(),
ProgramConfig: vipProgramConfigFromProto(item.GetProgramConfig()),
}
}

View File

@ -0,0 +1,201 @@
package vipconfig
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
func TestGetProgramReturnsCamelCaseCompleteConfigAndBenefits(t *testing.T) {
wallet := &fakeVIPConfigWallet{
programResponse: &walletv1.GetVipProgramConfigResponse{
Config: &walletv1.VipProgramConfig{
AppCode: "fami",
ProgramType: "tiered_privilege_v1",
LevelCount: 9,
SameLevelExpiryPolicy: "extend_remaining",
UpgradeExpiryPolicy: "replace_from_now",
DowngradePurchasePolicy: "reject",
BenefitInheritancePolicy: "target_only",
GrantMode: "trial_card",
TrialCardEnabled: true,
Status: "active",
ConfigVersion: 3,
UpdatedByAdminId: 77,
CreatedAtMs: 100,
UpdatedAtMs: 200,
},
Benefits: []*walletv1.VipBenefit{{
BenefitCode: "anti_kick",
Name: "防踢",
BenefitType: "function",
UnlockLevel: 9,
Status: "active",
TrialEnabled: true,
ExecutionScope: "room",
MetadataJson: "{}",
CreatedAtMs: 101,
UpdatedAtMs: 201,
}},
ServerTimeMs: 300,
},
}
recorder := serveVIPConfigRequest(t, wallet, http.MethodGet, "/admin/activity/vip-program", "")
if recorder.Code != http.StatusOK {
t.Fatalf("get program status = %d, body=%s", recorder.Code, recorder.Body.String())
}
body := recorder.Body.String()
for _, fragment := range []string{
`"programType":"tiered_privilege_v1"`,
`"upgradeExpiryPolicy":"replace_from_now"`,
`"updatedByAdminId":77`,
`"benefitCode":"anti_kick"`,
`"executionScope":"room"`,
`"serverTimeMs":300`,
} {
if !strings.Contains(body, fragment) {
t.Fatalf("get program response missing %s: %s", fragment, body)
}
}
if strings.Contains(body, "program_type") || strings.Contains(body, "benefit_code") {
t.Fatalf("admin contract must stay camelCase: %s", body)
}
if wallet.getProgramRequest.GetAppCode() != "fami" || wallet.getProgramRequest.GetRequestId() != "vip-config-test" {
t.Fatalf("get program request mismatch: %+v", wallet.getProgramRequest)
}
}
func TestUpdateProgramUsesContextAppAndAuthenticatedOperator(t *testing.T) {
wallet := &fakeVIPConfigWallet{updateProgramResponse: &walletv1.UpdateAdminVipProgramConfigResponse{
Config: &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", LevelCount: 9, Status: "active", ConfigVersion: 4},
ServerTimeMs: 400,
}}
body := `{"config":{"appCode":"lalu","programType":"tiered_privilege_v1","levelCount":9,"sameLevelExpiryPolicy":"extend_remaining","upgradeExpiryPolicy":"replace_from_now","downgradePurchasePolicy":"reject","benefitInheritancePolicy":"target_only","grantMode":"trial_card","trialCardEnabled":true,"status":"active","configVersion":999,"updatedByAdminId":999}}`
recorder := serveVIPConfigRequest(t, wallet, http.MethodPut, "/admin/activity/vip-program", body)
if recorder.Code != http.StatusOK {
t.Fatalf("update program status = %d, body=%s", recorder.Code, recorder.Body.String())
}
req := wallet.updateProgramRequest
if req.GetAppCode() != "fami" || req.GetOperatorUserId() != 77 {
t.Fatalf("program scope/operator mismatch: %+v", req)
}
if req.GetConfig().GetAppCode() != "fami" || req.GetConfig().GetConfigVersion() != 0 || req.GetConfig().GetUpdatedByAdminId() != 0 {
t.Fatalf("client-controlled program audit fields leaked through: %+v", req.GetConfig())
}
if !strings.Contains(recorder.Body.String(), `"configVersion":4`) || !strings.Contains(recorder.Body.String(), `"benefits":[]`) {
t.Fatalf("update program response mismatch: %s", recorder.Body.String())
}
}
func TestUpdateLevelsForwardsExplicitBenefitsAndReturnsProgram(t *testing.T) {
wallet := &fakeVIPConfigWallet{updateLevelsResponse: &walletv1.UpdateAdminVipLevelsResponse{
Levels: []*walletv1.VipLevel{{
Level: 2, Name: "VIP2", Status: "active", DurationMs: 2_592_000_000,
Benefits: []*walletv1.VipBenefit{{BenefitCode: "room_entry_notice", Name: "进房通知", Status: "active", ExecutionScope: "room", MetadataJson: "{}"}},
}},
ProgramConfig: &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", LevelCount: 9},
ServerTimeMs: 500,
}}
body := `{"levels":[{"level":2,"name":"VIP2","status":"active","priceCoin":2000,"durationMs":2592000000,"rewardResourceGroupId":22,"sortOrder":20,"requiredRechargeCoinAmount":123,"benefits":[{"benefitCode":"room_entry_notice","name":"进房通知","benefitType":"function","unlockLevel":2,"status":"active","trialEnabled":true,"resourceId":0,"resourceType":"","executionScope":"room","autoEquip":false,"sortOrder":10,"metadataJson":"{}"}]}]}`
recorder := serveVIPConfigRequest(t, wallet, http.MethodPut, "/admin/activity/vip-levels", body)
if recorder.Code != http.StatusOK {
t.Fatalf("update levels status = %d, body=%s", recorder.Code, recorder.Body.String())
}
req := wallet.updateLevelsRequest
if req.GetAppCode() != "fami" || req.GetOperatorUserId() != 77 || len(req.GetLevels()) != 1 {
t.Fatalf("update levels request mismatch: %+v", req)
}
level := req.GetLevels()[0]
if level.GetRequiredRechargeCoinAmount() != 0 || len(level.GetBenefits()) != 1 || level.GetBenefits()[0].GetBenefitCode() != "room_entry_notice" {
t.Fatalf("explicit level benefits mismatch: %+v", level)
}
if !strings.Contains(recorder.Body.String(), `"programConfig":{"appCode":"fami","programType":"tiered_privilege_v1","levelCount":9`) {
t.Fatalf("level response must include program config: %s", recorder.Body.String())
}
}
func TestGrantTrialCardLeavesOptionalResourceUnsetAndUsesAuthenticatedOperator(t *testing.T) {
wallet := &fakeVIPConfigWallet{grantTrialResponse: &walletv1.GrantVipTrialCardResponse{
TrialCard: &walletv1.VipTrialCard{TrialCardId: "trial-1", EntitlementId: "ent-1", UserId: 318705991371722752, Level: 5, DurationMs: 1_728_000_000, ExpiresAtMs: 2_000},
State: &walletv1.VipState{EffectiveSource: "none", EffectiveBenefits: []*walletv1.VipBenefit{}},
ServerTimeMs: 1_000,
}}
body := `{"targetUserId":"318705991371722752","level":5,"durationMs":1728000000,"reason":"manual"}`
recorder := serveVIPConfigRequest(t, wallet, http.MethodPost, "/admin/activity/vip-trial-card-grants", body)
if recorder.Code != http.StatusCreated {
t.Fatalf("grant trial card status = %d, body=%s", recorder.Code, recorder.Body.String())
}
req := wallet.grantTrialRequest
if req.GetCommandId() != "admin_vip_trial_card_grant:vip-config-test" || req.GetAppCode() != "fami" || req.GetOperatorUserId() != 77 {
t.Fatalf("trial grant command context mismatch: %+v", req)
}
if req.ResourceId != nil || req.GetTargetUserId() != 318705991371722752 || req.GetDurationMs() != 1_728_000_000 {
t.Fatalf("trial grant payload mismatch: %+v", req)
}
if !strings.Contains(recorder.Body.String(), `"trialCardId":"trial-1"`) || !strings.Contains(recorder.Body.String(), `"userId":"318705991371722752"`) {
t.Fatalf("trial grant response mismatch: %s", recorder.Body.String())
}
}
func serveVIPConfigRequest(t *testing.T, wallet walletclient.Client, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami"))
c.Set(middleware.ContextRequestID, "vip-config-test")
c.Set(middleware.ContextUserID, uint(77))
c.Set(middleware.ContextUsername, "vip-operator")
c.Set(middleware.ContextPermissions, []string{"vip-config:view", "vip-config:update", "vip-config:grant"})
c.Next()
})
RegisterRoutes(router.Group(""), New(wallet, nil))
recorder := httptest.NewRecorder()
request := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
request.Header.Set("Content-Type", "application/json")
}
router.ServeHTTP(recorder, request)
return recorder
}
type fakeVIPConfigWallet struct {
walletclient.Client
programResponse *walletv1.GetVipProgramConfigResponse
updateProgramResponse *walletv1.UpdateAdminVipProgramConfigResponse
updateLevelsResponse *walletv1.UpdateAdminVipLevelsResponse
grantTrialResponse *walletv1.GrantVipTrialCardResponse
getProgramRequest *walletv1.GetVipProgramConfigRequest
updateProgramRequest *walletv1.UpdateAdminVipProgramConfigRequest
updateLevelsRequest *walletv1.UpdateAdminVipLevelsRequest
grantTrialRequest *walletv1.GrantVipTrialCardRequest
}
func (f *fakeVIPConfigWallet) GetVipProgramConfig(_ context.Context, req *walletv1.GetVipProgramConfigRequest) (*walletv1.GetVipProgramConfigResponse, error) {
f.getProgramRequest = req
return f.programResponse, nil
}
func (f *fakeVIPConfigWallet) UpdateAdminVipProgramConfig(_ context.Context, req *walletv1.UpdateAdminVipProgramConfigRequest) (*walletv1.UpdateAdminVipProgramConfigResponse, error) {
f.updateProgramRequest = req
return f.updateProgramResponse, nil
}
func (f *fakeVIPConfigWallet) UpdateAdminVipLevels(_ context.Context, req *walletv1.UpdateAdminVipLevelsRequest) (*walletv1.UpdateAdminVipLevelsResponse, error) {
f.updateLevelsRequest = req
return f.updateLevelsResponse, nil
}
func (f *fakeVIPConfigWallet) GrantVipTrialCard(_ context.Context, req *walletv1.GrantVipTrialCardRequest) (*walletv1.GrantVipTrialCardResponse, error) {
f.grantTrialRequest = req
return f.grantTrialResponse, nil
}

View File

@ -11,7 +11,11 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
return
}
protected.GET("/admin/activity/vip-program", middleware.RequirePermission("vip-config:view"), h.GetProgram)
protected.PUT("/admin/activity/vip-program", middleware.RequirePermission("vip-config:update"), h.UpdateProgram)
protected.GET("/admin/activity/vip-levels", middleware.RequirePermission("vip-config:view"), h.ListLevels)
protected.PUT("/admin/activity/vip-levels", middleware.RequirePermission("vip-config:update"), h.UpdateLevels)
protected.POST("/admin/activity/vip-trial-card-grants", middleware.RequirePermission("vip-config:grant"), h.GrantVIPTrialCard)
// 旧 App 仍使用直接会员赠送,保留原路由直到其 program.grant_mode 切换为 trial_card。
protected.POST("/admin/activity/vip-grants", middleware.RequirePermission("vip-config:grant"), h.GrantVIP)
}

View File

@ -191,16 +191,51 @@ func (s *Store) LeaseNextJob(workerID string, lease time.Duration, maxAttempts i
return &leased, nil
}
func (s *Store) CompleteJobWithArtifact(id uint, resultJSON string, artifactPath string) error {
func (s *Store) CompleteJobWithArtifact(id uint, workerID string, resultJSON string, artifactPath string) error {
nowMS := time.Now().UTC().UnixMilli()
return s.db.Model(&model.AdminJob{}).Where("id = ?", id).Updates(map[string]any{
"status": model.JobStatusSucceeded,
"result_json": resultJSON,
"artifact_path": artifactPath,
"locked_by": "",
"locked_until_ms": nil,
"finished_at_ms": &nowMS,
}).Error
result := s.db.Model(&model.AdminJob{}).
Where("id = ? AND status = ? AND locked_by = ?", id, model.JobStatusRunning, strings.TrimSpace(workerID)).
Updates(map[string]any{
"status": model.JobStatusSucceeded,
"result_json": resultJSON,
"artifact_path": artifactPath,
"locked_by": "",
"locked_until_ms": nil,
"finished_at_ms": &nowMS,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("job lease is no longer owned by worker")
}
return nil
}
// RenewJobLease 延长仍属于 worker 的 running 任务RowsAffected=0 说明任务已完成或租约已经被其他实例接管。
func (s *Store) RenewJobLease(id uint, workerID string, lease time.Duration) error {
if strings.TrimSpace(workerID) == "" || lease <= 0 {
return errors.New("worker id and positive lease are required")
}
nowMS := time.Now().UTC().UnixMilli()
result := s.db.Model(&model.AdminJob{}).
Where("id = ? AND status = ? AND locked_by = ?", id, model.JobStatusRunning, strings.TrimSpace(workerID)).
Update("locked_until_ms", nowMS+lease.Milliseconds())
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
var job model.AdminJob
if err := s.db.Select("status", "locked_by").First(&job, id).Error; err != nil {
return err
}
// handler 完成提交与 heartbeat tick 可能恰好相邻;终态无需续租,不能把成功任务误报为失败。
if job.Status != model.JobStatusRunning {
return nil
}
return errors.New("job lease is no longer owned by worker")
}
return nil
}
type CountryCodeAdminReferenceCounts struct {
@ -234,9 +269,9 @@ func (s *Store) RenameCountryCodeAdminReferences(appCode string, oldCountryCode
return counts, nil
}
func (s *Store) FailJobAttempt(id uint, message string) error {
func (s *Store) FailJobAttempt(id uint, workerID string, message string) error {
var job model.AdminJob
if err := s.db.First(&job, id).Error; err != nil {
if err := s.db.Where("id = ? AND status = ? AND locked_by = ?", id, model.JobStatusRunning, strings.TrimSpace(workerID)).First(&job).Error; err != nil {
return err
}
nowMS := time.Now().UTC().UnixMilli()
@ -246,23 +281,39 @@ func (s *Store) FailJobAttempt(id uint, message string) error {
status = model.JobStatusFailed
finishedAt = &nowMS
}
return s.db.Model(&model.AdminJob{}).Where("id = ?", id).Updates(map[string]any{
"status": status,
"error": message,
"locked_by": "",
"locked_until_ms": nil,
"finished_at_ms": finishedAt,
}).Error
result := s.db.Model(&model.AdminJob{}).
Where("id = ? AND status = ? AND locked_by = ?", id, model.JobStatusRunning, strings.TrimSpace(workerID)).
Updates(map[string]any{
"status": status,
"error": message,
"locked_by": "",
"locked_until_ms": nil,
"finished_at_ms": finishedAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("job lease is no longer owned by worker")
}
return nil
}
func (s *Store) ReleaseJobLease(id uint) error {
return s.db.Model(&model.AdminJob{}).
Where("id = ? AND status = ?", id, model.JobStatusRunning).
func (s *Store) ReleaseJobLease(id uint, workerID string) error {
result := s.db.Model(&model.AdminJob{}).
Where("id = ? AND status = ? AND locked_by = ?", id, model.JobStatusRunning, strings.TrimSpace(workerID)).
Updates(map[string]any{
"status": model.JobStatusPending,
"locked_by": "",
"locked_until_ms": nil,
}).Error
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("job lease is no longer owned by worker")
}
return nil
}
func (s *Store) CancelJob(id uint) error {

View File

@ -0,0 +1,81 @@
package repository
import (
"testing"
"hyapp-admin-server/internal/model"
"github.com/DATA-DOG/go-sqlmock"
)
func TestMergeScopedAppConfigsDoesNotLeakOverridesOrTombstonesAcrossApps(t *testing.T) {
rows := []model.AppConfig{
{AppCode: "", Group: "h5-links", Key: "admin", Description: "Legacy Admin", Value: "https://legacy.example.com/admin"},
{AppCode: "", Group: "h5-links", Key: "host", Description: "Legacy Host", Value: "https://legacy.example.com/host"},
{AppCode: "yumi", Group: "h5-links", Key: "admin", Description: "Yumi Admin", Value: "https://yumi.example.com/admin"},
{AppCode: "yumi", Group: "h5-links", Key: "host", IsDeleted: true},
{AppCode: "fami", Group: "h5-links", Key: "admin", Description: "Fami Admin", Value: "https://fami.example.com/admin"},
}
yumi := mergeScopedAppConfigs(rows, "yumi")
if len(yumi) != 1 || yumi[0].AppCode != "yumi" || yumi[0].Description != "Yumi Admin" {
t.Fatalf("yumi effective config mismatch: %+v", yumi)
}
fami := mergeScopedAppConfigs(rows, "fami")
if len(fami) != 2 || fami[0].AppCode != "fami" || fami[0].Description != "Fami Admin" || fami[1].Value != "https://legacy.example.com/host" {
t.Fatalf("fami must not inherit yumi override/tombstone: %+v", fami)
}
future := mergeScopedAppConfigs(rows, "future-app")
if len(future) != 2 || future[0].Description != "Legacy Admin" || future[1].Description != "Legacy Host" {
t.Fatalf("future app must inherit only legacy baseline: %+v", future)
}
}
func TestTombstoneScopedAppConfigWritesOnlyCurrentApp(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO `admin_app_configs`").
WithArgs("yumi", "h5-links", "host", "", "", true, sqlmock.AnyArg(), sqlmock.AnyArg(), true, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(9, 1))
mock.ExpectCommit()
if err := store.TombstoneScopedAppConfig(" YUMI ", "h5-links", "host"); err != nil {
t.Fatalf("write scoped tombstone failed: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestListScopedAppConfigsOverlaysBaselineAndHonorsTombstone(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectQuery("SELECT \\* FROM `admin_app_configs` WHERE `group` = \\? AND app_code IN \\(\\?,\\?\\) ORDER BY `key` ASC, app_code ASC").
WithArgs("h5-links", "", "yumi").
WillReturnRows(sqlmock.NewRows([]string{"id", "app_code", "group", "key", "value", "description", "is_deleted", "created_at_ms", "updated_at_ms"}).
AddRow(1, "", "h5-links", "admin", "https://legacy.example.com/admin", "Admin", false, int64(100), int64(100)).
AddRow(2, "yumi", "h5-links", "admin", "https://yumi.example.com/admin", "Yumi Admin", false, int64(200), int64(200)).
AddRow(3, "", "h5-links", "host", "https://legacy.example.com/host", "Host", false, int64(100), int64(100)).
AddRow(4, "yumi", "h5-links", "host", "", "", true, int64(300), int64(300)).
AddRow(5, "", "h5-links", "public", "https://legacy.example.com/public", "Public", false, int64(100), int64(100)))
items, err := store.ListScopedAppConfigs(" YUMI ", "h5-links")
if err != nil {
t.Fatalf("list scoped app configs failed: %v", err)
}
if len(items) != 2 {
t.Fatalf("unexpected effective configs: %+v", items)
}
if items[0].AppCode != "yumi" || items[0].Key != "admin" || items[0].Description != "Yumi Admin" {
t.Fatalf("override mismatch: %+v", items[0])
}
if items[1].AppCode != "yumi" || items[1].Key != "public" || items[1].Value != "https://legacy.example.com/public" {
t.Fatalf("baseline projection mismatch: %+v", items[1])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}

View File

@ -21,6 +21,8 @@ var defaultPermissions = []model.Permission{
{Name: "团队更新", Code: "team:update", Kind: "button"},
{Name: "App 用户查看", Code: "app-user:view", Kind: "menu"},
{Name: "App 用户更新", Code: "app-user:update", Kind: "button"},
{Name: "App 用户等级调整", Code: "app-user:level", Kind: "button"},
{Name: "App 用户导出", Code: "app-user:export", Kind: "button"},
{Name: "App 用户状态", Code: "app-user:status", Kind: "button"},
{Name: "App 用户设置密码", Code: "app-user:password", Kind: "button"},
{Name: "等级配置查看", Code: "level-config:view", Kind: "menu"},
@ -639,7 +641,7 @@ func defaultRolePermissionCodes(code string) []string {
"overview:view",
"user:view", "user:status",
"team:view", "team:create", "team:update",
"app-user:view", "app-user:update", "app-user:status", "app-user:password",
"app-user:view", "app-user:update", "app-user:level", "app-user:export", "app-user:status", "app-user:password",
"level-config:view", "level-config:update",
"pretty-id:view", "pretty-id:update", "pretty-id:generate", "pretty-id:grant",
"risk-config:view", "risk-config:update",
@ -752,7 +754,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
case "ops-admin":
return []string{
"team:view", "team:create", "team:update",
"app-user:update", "app-user:status", "app-user:password",
"app-user:update", "app-user:level", "app-user:export", "app-user:status", "app-user:password",
"room:view", "room:update", "room:delete", "room-pin:view", "room-pin:create", "room-pin:cancel", "room-config:view", "room-config:update", "room-whitelist:view", "room-whitelist:update", "room-robot:view", "room-robot:create", "room-robot:update",
"app-config:view", "app-config:update",
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",

View File

@ -0,0 +1,21 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 等级调整会跨 activity/wallet 执行奖励副作用,用户导出会读取筛选快照和生成文件;
-- 两者都必须与普通资料更新解耦,便于运营角色按职责最小授权。
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('App 用户等级调整', 'app-user:level', 'button', '允许临时调整 App 用户富豪或魅力等级', @now_ms, @now_ms),
('App 用户导出', 'app-user:export', 'button', '允许按当前筛选快照异步导出 App 用户 CSV', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('platform-admin', 'ops-admin')
AND admin_permission.code IN ('app-user:level', 'app-user:export');

View File

@ -78,6 +78,7 @@ rocketmq:
cumulative_recharge_consumer_group: "hyapp-activity-cumulative-recharge-wallet-outbox"
invite_activity_consumer_group: "hyapp-activity-invite-wallet-outbox"
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
vip_rebate_notice_consumer_group: "hyapp-activity-vip-rebate-notice-wallet-outbox"
task_consumer_group: "hyapp-activity-task-wallet-outbox"
user_leaderboard_consumer_group: "hyapp-activity-user-leaderboard-wallet-outbox"
consumer_max_reconsume_times: 16

View File

@ -78,6 +78,7 @@ rocketmq:
cumulative_recharge_consumer_group: "hyapp-activity-cumulative-recharge-wallet-outbox"
invite_activity_consumer_group: "hyapp-activity-invite-wallet-outbox"
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
vip_rebate_notice_consumer_group: "hyapp-activity-vip-rebate-notice-wallet-outbox"
task_consumer_group: "hyapp-activity-task-wallet-outbox"
user_leaderboard_consumer_group: "hyapp-activity-user-leaderboard-wallet-outbox"
consumer_max_reconsume_times: 16

View File

@ -78,6 +78,7 @@ rocketmq:
cumulative_recharge_consumer_group: "hyapp-activity-cumulative-recharge-wallet-outbox"
invite_activity_consumer_group: "hyapp-activity-invite-wallet-outbox"
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
vip_rebate_notice_consumer_group: "hyapp-activity-vip-rebate-notice-wallet-outbox"
task_consumer_group: "hyapp-activity-task-wallet-outbox"
user_leaderboard_consumer_group: "hyapp-activity-user-leaderboard-wallet-outbox"
consumer_max_reconsume_times: 16

View File

@ -695,6 +695,13 @@ CREATE TABLE IF NOT EXISTS growth_level_reward_jobs (
resource_group_id BIGINT NOT NULL COMMENT '资源分组 ID',
wallet_command_id VARCHAR(128) NOT NULL COMMENT '钱包命令 ID',
wallet_grant_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '钱包资源发放 ID',
reward_origin VARCHAR(32) NOT NULL DEFAULT 'organic' COMMENT '奖励来源organic/temporary',
temporary_level_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '临时等级记录 ID',
grant_generation INT NOT NULL DEFAULT 0 COMMENT '同一奖励来源的发放代次',
reward_level INT NOT NULL DEFAULT 0 COMMENT '取得该奖励所需等级',
is_permanent TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否已提升为永久奖励',
temporary_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '临时奖励到期时间UTC epoch ms',
revoke_status VARCHAR(32) NOT NULL DEFAULT 'none' COMMENT 'none/pending/running/revoked/failed/skipped',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '尝试次数',
next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '下一次重试时间UTC epoch ms',
@ -704,11 +711,70 @@ CREATE TABLE IF NOT EXISTS growth_level_reward_jobs (
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, reward_job_id),
UNIQUE KEY uk_growth_level_reward_once (app_code, user_id, track, reward_source_type, reward_source_id),
UNIQUE KEY uk_growth_level_reward_generation (app_code, user_id, track, reward_source_type, reward_source_id, reward_origin, grant_generation),
KEY idx_growth_level_reward_pending (app_code, status, next_retry_at_ms, created_at_ms),
KEY idx_growth_level_reward_user (app_code, user_id, track, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成长等级奖励任务表';
CREATE TABLE IF NOT EXISTS growth_level_reward_wallet_grants (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
reward_job_id VARCHAR(96) NOT NULL COMMENT '奖励任务 ID',
grant_kind VARCHAR(32) NOT NULL COMMENT 'resource_group/avatar_frame/short_badge',
wallet_grant_id VARCHAR(96) NOT NULL COMMENT '钱包发放 ID',
revoke_status VARCHAR(32) NOT NULL DEFAULT 'none' COMMENT 'none/pending/running/revoked/failed/skipped',
revoke_attempt_count INT NOT NULL DEFAULT 0 COMMENT '撤回尝试次数',
revoke_next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '撤回重试时间',
revoke_locked_by VARCHAR(128) NOT NULL DEFAULT '' COMMENT '撤回 worker',
revoke_locked_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '撤回锁截止',
revoke_failure_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '撤回失败原因',
revoked_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '撤回完成时间',
created_at_ms BIGINT NOT NULL COMMENT '创建时间',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间',
PRIMARY KEY (app_code, reward_job_id, wallet_grant_id),
KEY idx_growth_reward_grant_revoke (app_code, revoke_status, revoke_next_retry_at_ms, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='等级奖励钱包发放明细';
CREATE TABLE IF NOT EXISTS growth_temporary_level_commands (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
command_id VARCHAR(128) NOT NULL COMMENT '整批调整幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '规范化请求 SHA256',
user_id BIGINT NOT NULL COMMENT '目标用户',
operator_admin_id BIGINT NOT NULL COMMENT '操作管理员',
created_at_ms BIGINT NOT NULL COMMENT '创建时间',
PRIMARY KEY (app_code, command_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='临时等级批量命令幂等表';
CREATE TABLE IF NOT EXISTS growth_temporary_levels (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
temporary_level_id VARCHAR(96) NOT NULL COMMENT '临时等级 ID',
command_id VARCHAR(128) NOT NULL COMMENT '批量命令 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
track VARCHAR(32) NOT NULL COMMENT 'wealth/charm',
target_level INT NOT NULL COMMENT '临时目标等级',
target_required_value BIGINT NOT NULL COMMENT '目标等级起始阈值快照',
baseline_total_value BIGINT NOT NULL COMMENT '调整时真实累计值快照',
started_at_ms BIGINT NOT NULL COMMENT '有效期开始,包含',
expires_at_ms BIGINT NOT NULL COMMENT '有效期结束,不包含',
status VARCHAR(32) NOT NULL COMMENT 'active/superseded/expired',
generation INT NOT NULL COMMENT '用户轨道临时发放代次',
operator_admin_id BIGINT NOT NULL COMMENT '操作管理员',
reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '操作原因',
final_level INT NOT NULL DEFAULT 0 COMMENT '按基线和有效期经验计算的恢复等级',
activation_notice_status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '下发通知状态',
expiry_notice_status VARCHAR(32) NOT NULL DEFAULT 'skipped' COMMENT '到期通知状态',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '副作用尝试次数',
next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '副作用重试时间',
locked_by VARCHAR(128) NOT NULL DEFAULT '' COMMENT '当前 worker',
locked_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '锁截止时间',
failure_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '副作用失败原因',
created_at_ms BIGINT NOT NULL COMMENT '创建时间',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间',
PRIMARY KEY (app_code, temporary_level_id),
UNIQUE KEY uk_growth_temp_command_track (app_code, command_id, track),
KEY idx_growth_temp_user_track (app_code, user_id, track, status, expires_at_ms),
KEY idx_growth_temp_work (app_code, status, next_retry_at_ms, expires_at_ms, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户临时成长等级 overlay';
CREATE TABLE IF NOT EXISTS user_level_display_profiles (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL COMMENT '用户 ID',

View File

@ -0,0 +1,46 @@
-- 临时财富/魅力等级只建立 overlay 和副作用事实,不修改 user_growth_level_accounts 的真实经验。
ALTER TABLE growth_level_reward_jobs
DROP INDEX uk_growth_level_reward_once,
ADD COLUMN reward_origin VARCHAR(32) NOT NULL DEFAULT 'organic' COMMENT 'organic/temporary' AFTER wallet_grant_id,
ADD COLUMN temporary_level_id VARCHAR(96) NOT NULL DEFAULT '' AFTER reward_origin,
ADD COLUMN grant_generation INT NOT NULL DEFAULT 0 AFTER temporary_level_id,
ADD COLUMN reward_level INT NOT NULL DEFAULT 0 AFTER grant_generation,
ADD COLUMN is_permanent TINYINT(1) NOT NULL DEFAULT 1 AFTER reward_level,
ADD COLUMN temporary_expires_at_ms BIGINT NOT NULL DEFAULT 0 AFTER is_permanent,
ADD COLUMN revoke_status VARCHAR(32) NOT NULL DEFAULT 'none' AFTER temporary_expires_at_ms,
ADD UNIQUE KEY uk_growth_level_reward_generation
(app_code, user_id, track, reward_source_type, reward_source_id, reward_origin, grant_generation);
CREATE TABLE IF NOT EXISTS growth_level_reward_wallet_grants (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', reward_job_id VARCHAR(96) NOT NULL,
grant_kind VARCHAR(32) NOT NULL, wallet_grant_id VARCHAR(96) NOT NULL,
revoke_status VARCHAR(32) NOT NULL DEFAULT 'none', revoke_attempt_count INT NOT NULL DEFAULT 0,
revoke_next_retry_at_ms BIGINT NOT NULL DEFAULT 0, revoke_locked_by VARCHAR(128) NOT NULL DEFAULT '',
revoke_locked_until_ms BIGINT NOT NULL DEFAULT 0, revoke_failure_reason VARCHAR(255) NOT NULL DEFAULT '',
revoked_at_ms BIGINT NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, reward_job_id, wallet_grant_id),
KEY idx_growth_reward_grant_revoke (app_code, revoke_status, revoke_next_retry_at_ms, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS growth_temporary_level_commands (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', command_id VARCHAR(128) NOT NULL,
request_hash VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, operator_admin_id BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, command_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS growth_temporary_levels (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', temporary_level_id VARCHAR(96) NOT NULL,
command_id VARCHAR(128) NOT NULL, user_id BIGINT NOT NULL, track VARCHAR(32) NOT NULL,
target_level INT NOT NULL, target_required_value BIGINT NOT NULL, baseline_total_value BIGINT NOT NULL,
started_at_ms BIGINT NOT NULL, expires_at_ms BIGINT NOT NULL, status VARCHAR(32) NOT NULL,
generation INT NOT NULL, operator_admin_id BIGINT NOT NULL, reason VARCHAR(255) NOT NULL DEFAULT '',
final_level INT NOT NULL DEFAULT 0, activation_notice_status VARCHAR(32) NOT NULL DEFAULT 'pending',
expiry_notice_status VARCHAR(32) NOT NULL DEFAULT 'skipped', attempt_count INT NOT NULL DEFAULT 0,
next_retry_at_ms BIGINT NOT NULL DEFAULT 0, locked_by VARCHAR(128) NOT NULL DEFAULT '',
locked_until_ms BIGINT NOT NULL DEFAULT 0, failure_reason VARCHAR(255) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, temporary_level_id),
UNIQUE KEY uk_growth_temp_command_track (app_code, command_id, track),
KEY idx_growth_temp_user_track (app_code, user_id, track, status, expires_at_ms),
KEY idx_growth_temp_work (app_code, status, next_retry_at_ms, expires_at_ms, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -97,6 +97,14 @@ func buildMQConsumers(cfg config.Config, services *serviceBundle) ([]*rocketmqx.
}
mqConsumers = append(mqConsumers, consumer)
}
if cfg.RocketMQ.WalletOutbox.Enabled {
consumer, err := newVIPRebateNoticeWalletConsumer(cfg, services)
if err != nil {
shutdownConsumers(mqConsumers)
return nil, err
}
mqConsumers = append(mqConsumers, consumer)
}
if cfg.UserLeaderboardWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
consumer, err := newUserLeaderboardWalletConsumer(cfg, services)
if err != nil {
@ -273,6 +281,26 @@ func newRedPacketWalletConsumer(cfg config.Config, services *serviceBundle) (*ro
return consumer, nil
}
func newVIPRebateNoticeWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) {
// VIP 返现资格是 wallet 的逐用户事实,必须使用独立 consumer group 投影为系统通知;
// 不能复用首充/红包位点,也不能用全服 fanout因为每个用户的 rebate_id、金额和到期时间都不同。
consumer, err := rocketmqx.NewConsumer(walletOutboxVIPRebateNoticeConsumerConfig(cfg.RocketMQ))
if err != nil {
return nil, err
}
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
event, ok, err := vipRebateAvailableEventFromWalletMessage(message.Body)
if err != nil || !ok {
return err
}
return createVIPRebateSystemNotice(appcode.WithContext(ctx, event.AppCode), services.message, event)
}); err != nil {
_ = consumer.Shutdown()
return nil, err
}
return consumer, nil
}
func newUserLeaderboardWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) {
if services == nil || services.userLeaderboard == nil {
return nil, errors.New("user leaderboard worker requires redis store")
@ -380,6 +408,14 @@ func walletOutboxUserLeaderboardConsumerConfig(cfg config.RocketMQConfig) rocket
return consumerConfig
}
func walletOutboxVIPRebateNoticeConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
consumerConfig := walletOutboxConsumerConfig(cfg, cfg.WalletOutbox.VIPRebateNoticeConsumerGroup)
// 新 consumer group 上线时必须从 broker 仍保留的最早事实恢复;否则 wallet/cron 先发布的
// 当日资格会永久没有系统通知。非目标钱包事实会被解析器安全跳过,不会写 inbox。
consumerConfig.ConsumeFromFirst = true
return consumerConfig
}
func messageActionProducerConfig(cfg config.RocketMQConfig) rocketmqx.ProducerConfig {
return rocketmqx.ProducerConfig{
EndpointConfig: rocketmqx.EndpointConfig{

View File

@ -60,7 +60,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
taskSvc := taskservice.New(repository, walletClient)
registrationRewardSvc := registrationrewardservice.New(repository, walletClient)
sevenDayCheckInSvc := sevendaycheckinservice.New(repository, walletClient)
growthSvc := growthservice.New(repository, walletClient)
growthSvc := growthservice.New(repository, walletClient, growthservice.WithNoticeService(messageSvc))
achievementSvc := achievementservice.New(repository, walletClient)
agencyOpeningSvc := agencyopeningservice.New(repository, walletClient, activityclient.NewGRPCAgencyOpeningSource(clients.userConn), roomClient)
weeklyStarSvc := weeklystarservice.New(repository, walletClient)

View File

@ -0,0 +1,133 @@
package app
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/walletmq"
"hyapp/pkg/xerr"
messageservice "hyapp/services/activity-service/internal/service/message"
)
const vipDailyCoinRebateAvailableEventType = "VipDailyCoinRebateAvailable"
// vipRebateAvailableEvent 是 wallet outbox 的逐用户返现资格快照。
// activity-service 只把这个已提交事实投影为 inbox资格、金额、过期和最终入账仍由 wallet owner 判定。
type vipRebateAvailableEvent struct {
AppCode string
EventID string
RebateID string
UserID int64
TaskDay string
VIPLevel int32
VIPName string
CoinAmount int64
AvailableAtMS int64
ExpiresAtMS int64
ConfigVersion int64
CreatedAtMS int64
}
func vipRebateAvailableEventFromWalletMessage(body []byte) (vipRebateAvailableEvent, bool, error) {
message, err := walletmq.DecodeWalletOutboxMessage(body)
if err != nil {
return vipRebateAvailableEvent{}, false, err
}
if message.EventType != vipDailyCoinRebateAvailableEventType {
return vipRebateAvailableEvent{}, false, nil
}
var payload struct {
RebateID string `json:"rebate_id"`
UserID int64 `json:"user_id"`
TaskDay string `json:"task_day"`
VIPLevel int32 `json:"vip_level"`
VIPName string `json:"vip_name"`
CoinAmount int64 `json:"coin_amount"`
AvailableAtMS int64 `json:"available_at_ms"`
ExpiresAtMS int64 `json:"expires_at_ms"`
ConfigVersion int64 `json:"config_version"`
CreatedAtMS int64 `json:"created_at_ms"`
}
if err := json.Unmarshal([]byte(message.PayloadJSON), &payload); err != nil {
// 目标事件 payload 损坏必须让 MQ 重投并进入告警,不能确认后永久丢失用户领取入口。
return vipRebateAvailableEvent{}, false, err
}
payload.RebateID = strings.TrimSpace(payload.RebateID)
payload.TaskDay = strings.TrimSpace(payload.TaskDay)
payload.VIPName = strings.TrimSpace(payload.VIPName)
if payload.CreatedAtMS <= 0 {
payload.CreatedAtMS = message.OccurredAtMS
}
if payload.RebateID == "" || payload.UserID <= 0 || payload.UserID != message.UserID || !validUTCTaskDay(payload.TaskDay) || payload.VIPLevel <= 0 || payload.CoinAmount <= 0 || payload.AvailableAtMS <= 0 || payload.ExpiresAtMS <= payload.AvailableAtMS || payload.ConfigVersion <= 0 {
return vipRebateAvailableEvent{}, false, xerr.New(xerr.InvalidArgument, "VIP rebate wallet event is invalid")
}
return vipRebateAvailableEvent{
AppCode: appcode.Normalize(message.AppCode),
EventID: message.EventID,
RebateID: payload.RebateID,
UserID: payload.UserID,
TaskDay: payload.TaskDay,
VIPLevel: payload.VIPLevel,
VIPName: payload.VIPName,
CoinAmount: payload.CoinAmount,
AvailableAtMS: payload.AvailableAtMS,
ExpiresAtMS: payload.ExpiresAtMS,
ConfigVersion: payload.ConfigVersion,
CreatedAtMS: payload.CreatedAtMS,
}, true, nil
}
func createVIPRebateSystemNotice(ctx context.Context, service *messageservice.Service, event vipRebateAvailableEvent) error {
if service == nil {
return xerr.New(xerr.Unavailable, "message service is not configured")
}
actionParam, err := json.Marshal(map[string]any{
"rebate_id": event.RebateID,
"task_day": event.TaskDay,
"coin_amount": event.CoinAmount,
"expires_at_ms": event.ExpiresAtMS,
})
if err != nil {
return err
}
summary := fmt.Sprintf("尊敬的VIP用户平台今日返现%d金币请及时领取。", event.CoinAmount)
_, err = service.CreateSystemNotice(ctx, messageservice.NoticeCommand{
TargetUserID: event.UserID,
Producer: "wallet-service",
ProducerEventID: event.EventID,
ProducerEventType: vipDailyCoinRebateAvailableEventType,
AggregateType: "vip_daily_coin_rebate",
AggregateID: event.RebateID,
TemplateID: "vip_daily_coin_rebate_available",
TemplateVersion: "v1",
Title: "VIP金币返现",
Summary: summary,
Body: summary,
ActionType: "vip_coin_rebate_claim",
ActionParam: string(actionParam),
Priority: 50,
SentAtMS: event.AvailableAtMS,
ExpireAtMS: event.ExpiresAtMS,
Metadata: map[string]any{
"rebate_id": event.RebateID,
"task_day": event.TaskDay,
"vip_level": event.VIPLevel,
"vip_name": event.VIPName,
"coin_amount": event.CoinAmount,
"available_at_ms": event.AvailableAtMS,
"expires_at_ms": event.ExpiresAtMS,
"config_version": event.ConfigVersion,
"claim_status": "claimable",
},
})
return err
}
func validUTCTaskDay(value string) bool {
parsed, err := time.Parse("2006-01-02", value)
return err == nil && parsed.Location() == time.UTC && parsed.Format("2006-01-02") == value
}

View File

@ -0,0 +1,90 @@
package app
import (
"context"
"testing"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/walletmq"
messageservice "hyapp/services/activity-service/internal/service/message"
"hyapp/services/activity-service/internal/testutil/mysqltest"
)
func TestVIPRebateAvailableEventFromWalletMessage(t *testing.T) {
body := vipRebateWalletMessage(t, "VipDailyCoinRebateAvailable", 42, `{
"rebate_id":"viprebate_fami_2027-01-15_42",
"user_id":42,
"task_day":"2027-01-15",
"vip_level":9,
"vip_name":"VIP9",
"coin_amount":100000,
"available_at_ms":1800000000000,
"expires_at_ms":1800086400000,
"config_version":7,
"created_at_ms":1800000000100
}`)
event, ok, err := vipRebateAvailableEventFromWalletMessage(body)
if err != nil || !ok {
t.Fatalf("parse VIP rebate event failed: ok=%v event=%+v err=%v", ok, event, err)
}
if event.AppCode != "fami" || event.EventID != "wev-vip-rebate" || event.RebateID != "viprebate_fami_2027-01-15_42" || event.UserID != 42 || event.TaskDay != "2027-01-15" || event.VIPLevel != 9 || event.CoinAmount != 100000 || event.ExpiresAtMS != 1800086400000 || event.ConfigVersion != 7 {
t.Fatalf("VIP rebate event mismatch: %+v", event)
}
}
func TestVIPRebateWalletEventSkipsOtherFactsAndRejectsCorruptTarget(t *testing.T) {
other := vipRebateWalletMessage(t, "WalletRechargeRecorded", 42, `{}`)
if event, ok, err := vipRebateAvailableEventFromWalletMessage(other); err != nil || ok || event.EventID != "" {
t.Fatalf("non-target event should be acknowledged: ok=%v event=%+v err=%v", ok, event, err)
}
corrupt := vipRebateWalletMessage(t, "VipDailyCoinRebateAvailable", 42, `{"rebate_id":"r1","user_id":99}`)
if _, ok, err := vipRebateAvailableEventFromWalletMessage(corrupt); err == nil || ok {
t.Fatalf("corrupt target event must fail for MQ retry: ok=%v err=%v", ok, err)
}
}
func TestVIPRebateWalletEventCreatesOneIdempotentSystemNotice(t *testing.T) {
ctx := appcode.WithContext(context.Background(), "fami")
repository := mysqltest.NewRepository(t)
service := messageservice.New(messageservice.Config{NodeID: "activity-test"}, repository)
service.SetClock(func() time.Time { return time.UnixMilli(1800000000200) })
event := vipRebateAvailableEvent{
AppCode: "fami", EventID: "wev-vip-rebate", RebateID: "viprebate_fami_2027-01-15_42",
UserID: 42, TaskDay: "2027-01-15", VIPLevel: 9, VIPName: "VIP9", CoinAmount: 100000,
AvailableAtMS: 1800000000000, ExpiresAtMS: 1800086400000, ConfigVersion: 7, CreatedAtMS: 1800000000100,
}
if err := createVIPRebateSystemNotice(ctx, service, event); err != nil {
t.Fatalf("create system notice failed: %v", err)
}
if err := createVIPRebateSystemNotice(ctx, service, event); err != nil {
t.Fatalf("duplicate system notice should be idempotent: %v", err)
}
items, _, err := service.ListInboxMessages(ctx, 42, "system", 20, "")
if err != nil || len(items) != 1 {
t.Fatalf("system inbox mismatch: items=%+v err=%v", items, err)
}
item := items[0]
if item.Title != "VIP金币返现" || item.ActionType != "vip_coin_rebate_claim" || item.AggregateID != event.RebateID || item.ExpireAtMS != event.ExpiresAtMS || item.ActionParam == "" {
t.Fatalf("VIP rebate notice mismatch: %+v", item)
}
}
func vipRebateWalletMessage(t *testing.T, eventType string, userID int64, payload string) []byte {
t.Helper()
body, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{
AppCode: "fami",
EventID: "wev-vip-rebate",
EventType: eventType,
TransactionID: "viprebate_fami_2027-01-15_42",
CommandID: "vip-rebate-generate:fami:2027-01-15:42",
UserID: userID,
AssetType: "COIN",
PayloadJSON: payload,
OccurredAtMS: 1800000000100,
})
if err != nil {
t.Fatalf("encode wallet outbox message: %v", err)
}
return body
}

View File

@ -196,6 +196,7 @@ type WalletOutboxMQConfig struct {
CumulativeRechargeConsumerGroup string `yaml:"cumulative_recharge_consumer_group"`
InviteActivityConsumerGroup string `yaml:"invite_activity_consumer_group"`
RedPacketBroadcastConsumerGroup string `yaml:"red_packet_broadcast_consumer_group"`
VIPRebateNoticeConsumerGroup string `yaml:"vip_rebate_notice_consumer_group"`
TaskConsumerGroup string `yaml:"task_consumer_group"`
UserLeaderboardConsumerGroup string `yaml:"user_leaderboard_consumer_group"`
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
@ -311,6 +312,7 @@ func defaultRocketMQConfig() RocketMQConfig {
CumulativeRechargeConsumerGroup: "hyapp-activity-cumulative-recharge-wallet-outbox",
InviteActivityConsumerGroup: "hyapp-activity-invite-wallet-outbox",
RedPacketBroadcastConsumerGroup: "hyapp-activity-red-packet-wallet-outbox",
VIPRebateNoticeConsumerGroup: "hyapp-activity-vip-rebate-notice-wallet-outbox",
TaskConsumerGroup: "hyapp-activity-task-wallet-outbox",
UserLeaderboardConsumerGroup: "hyapp-activity-user-leaderboard-wallet-outbox",
ConsumerMaxReconsumeTimes: 16,
@ -473,6 +475,9 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
if cfg.WalletOutbox.RedPacketBroadcastConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.RedPacketBroadcastConsumerGroup); cfg.WalletOutbox.RedPacketBroadcastConsumerGroup == "" {
cfg.WalletOutbox.RedPacketBroadcastConsumerGroup = defaults.WalletOutbox.RedPacketBroadcastConsumerGroup
}
if cfg.WalletOutbox.VIPRebateNoticeConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.VIPRebateNoticeConsumerGroup); cfg.WalletOutbox.VIPRebateNoticeConsumerGroup == "" {
cfg.WalletOutbox.VIPRebateNoticeConsumerGroup = defaults.WalletOutbox.VIPRebateNoticeConsumerGroup
}
if cfg.WalletOutbox.TaskConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.TaskConsumerGroup); cfg.WalletOutbox.TaskConsumerGroup == "" {
cfg.WalletOutbox.TaskConsumerGroup = defaults.WalletOutbox.TaskConsumerGroup
}

View File

@ -17,7 +17,7 @@ func TestLoadLocalEnablesOutboxMQConsumers(t *testing.T) {
if cfg.UserLeaderboardWorker.RedisAddr != "127.0.0.1:13379" || cfg.UserLeaderboardWorker.KeyPrefix != "activity:user_leaderboard" {
t.Fatalf("local user leaderboard worker redis config mismatch: %+v", cfg.UserLeaderboardWorker)
}
if cfg.RocketMQ.WalletOutbox.InviteActivityConsumerGroup == "" || cfg.RocketMQ.WalletOutbox.UserLeaderboardConsumerGroup == "" || cfg.RocketMQ.UserOutbox.InviteActivityConsumerGroup == "" {
if cfg.RocketMQ.WalletOutbox.InviteActivityConsumerGroup == "" || cfg.RocketMQ.WalletOutbox.UserLeaderboardConsumerGroup == "" || cfg.RocketMQ.WalletOutbox.VIPRebateNoticeConsumerGroup == "" || cfg.RocketMQ.UserOutbox.InviteActivityConsumerGroup == "" {
t.Fatalf("local config must configure invite activity consumer groups: wallet=%+v user=%+v", cfg.RocketMQ.WalletOutbox, cfg.RocketMQ.UserOutbox)
}
}

View File

@ -22,6 +22,9 @@ const (
TypeRoomPasswordChanged = "room_password_changed"
// TypeLuckyGiftBigWin 是幸运礼物中奖区域飘屏;当前只让 10 倍及以上大奖进入播报 outbox。
TypeLuckyGiftBigWin = "lucky_gift_big_win"
// TypeVIPOnlineNotice 是 VIP 用户进入 App 时的全局飘屏。是否允许发送必须由 gateway
// 先向 wallet-service 校验 effective benefitactivity-service 只负责持久化和 IM 投递。
TypeVIPOnlineNotice = "vip_online_notice"
// StatusPending 表示消息已持久化,尚未被 worker claim。
StatusPending = "pending"

View File

@ -8,10 +8,30 @@ const (
StatusActive = "active"
StatusDisabled = "disabled"
RewardStatusPending = "pending"
RewardStatusRunning = "running"
RewardStatusGranted = "granted"
RewardStatusFailed = "failed"
RewardStatusPending = "pending"
RewardStatusRunning = "running"
RewardStatusGranted = "granted"
RewardStatusFailed = "failed"
RewardStatusCanceled = "canceled"
RewardOriginOrganic = "organic"
RewardOriginTemporary = "temporary"
RevokeStatusNone = "none"
RevokeStatusPending = "pending"
RevokeStatusRunning = "running"
RevokeStatusRevoked = "revoked"
RevokeStatusFailed = "failed"
RevokeStatusSkipped = "skipped"
TemporaryLevelStatusActive = "active"
TemporaryLevelStatusSuperseded = "superseded"
TemporaryLevelStatusExpired = "expired"
NoticeStatusPending = "pending"
NoticeStatusSent = "sent"
NoticeStatusSkipped = "skipped"
NoticeStatusFailed = "failed"
RewardSourceLevel = "level"
RewardSourceTier = "tier"
@ -218,22 +238,121 @@ type SetUserLevelResult struct {
ServerTimeMS int64
}
// RewardJob 是等级奖励资源组的异步发放任务。
type RewardJob struct {
AppCode string
// TemporaryLevel 是真实成长账户之上的限时展示层。BaselineTotalValue 只做审计,
// 有效期经验始终按 value event 的 occurred_at_ms 落在 [StartedAtMS, ExpiresAtMS) 内聚合。
type TemporaryLevel struct {
AppCode string
TemporaryLevelID string
CommandID string
UserID int64
Track string
TargetLevel int32
TargetRequiredValue int64
BaselineTotalValue int64
StartedAtMS int64
ExpiresAtMS int64
Status string
Generation int32
OperatorAdminID int64
Reason string
FinalLevel int32
ActivationNoticeStatus string
ExpiryNoticeStatus string
AttemptCount int32
FailureReason string
CreatedAtMS int64
UpdatedAtMS int64
}
// TemporaryLevelAdjustment 是一次后台临时等级调整。wealth/charm 可在同一命令中各出现一次。
type TemporaryLevelAdjustment struct {
Track string
Level int32
DurationDays int32
}
// AdjustTemporaryLevelsCommand 以 CommandID 为整批幂等键RequestHash 用于拒绝同键不同载荷。
type AdjustTemporaryLevelsCommand struct {
CommandID string
RequestHash string
UserID int64
Adjustments []TemporaryLevelAdjustment
OperatorAdminID int64
Reason string
}
// AdminTrackProfile 同时返回真实账户和当前展示层,后台无需从临时目标反推真实经验。
type AdminTrackProfile struct {
Track string
RealLevel int32
RealTotalValue int64
DisplayLevel int32
DisplayValue int64
TemporaryLevelID string
TemporaryTargetLevel int32
StartedAtMS int64
ExpiresAtMS int64
}
type AdminUserLevelProfile struct {
UserID int64
Tracks []AdminTrackProfile
ServerTimeMS int64
}
type AdminLevelProfiles struct {
Profiles []AdminUserLevelProfile
ServerTimeMS int64
}
// TemporaryLevelWork 是 cron 认领后的副作用快照。过期状态、奖励永久化/撤回准备均已在认领事务内提交。
type TemporaryLevelWork struct {
TemporaryLevel
SendActivationNotice bool
SendExpiryNotice bool
}
// RewardWalletGrant 保存一个奖励任务下的每次钱包发放。资源组、头像框和短徽章必须分别留痕。
type RewardWalletGrant struct {
RewardJobID string
GrantKind string
WalletGrantID string
CreatedAtMS int64
}
// RewardRevocation 是一条可独立幂等重试的钱包 grant 撤回任务。
type RewardRevocation struct {
RewardJobID string
WalletGrantID string
TemporaryLevelID string
UserID int64
Track string
RewardSourceType string
RewardSourceID string
ResourceGroupID int64
WalletCommandID string
WalletGrantID string
Status string
AttemptCount int32
FailureReason string
CreatedAtMS int64
UpdatedAtMS int64
}
// RewardJob 是等级奖励资源组的异步发放任务。
type RewardJob struct {
AppCode string
RewardJobID string
UserID int64
Track string
RewardSourceType string
RewardSourceID string
ResourceGroupID int64
WalletCommandID string
WalletGrantID string
RewardOrigin string
TemporaryLevelID string
GrantGeneration int32
RewardLevel int32
Permanent bool
TemporaryExpiresAtMS int64
RevokeStatus string
Status string
AttemptCount int32
FailureReason string
CreatedAtMS int64
UpdatedAtMS int64
}
// RewardQuery 是用户奖励记录分页筛选。

View File

@ -681,7 +681,9 @@ func normalizedPayloadJSON(input string, forced map[string]any) (string, error)
}
for key, value := range forced {
if key == "region_id" && value.(int64) <= 0 {
// 全局播报不写 region_id避免客户端误把 0 当作一个真实区域。
// 全局播报必须删除调用方可能夹带的 region_id避免客户端把伪造区域或 0
// 当成真实路由维度;只跳过 forced 写入会保留原 payload 中的脏字段。
delete(payload, "region_id")
continue
}
// forced 字段覆盖调用方 payload保证 event_id/scope/app_code/sent_at_ms 不被外部请求伪造。

View File

@ -178,6 +178,54 @@ func TestProcessPendingBroadcastsPublishesCustomMessage(t *testing.T) {
}
}
func TestVIPOnlineNoticeUsesGlobalOutboxAndStableEnvelope(t *testing.T) {
repository := newFakeRepository()
publisher := &fakePublisher{}
service := New(Config{NodeID: "node-a"}, repository, publisher, nil)
service.SetClock(func() time.Time { return time.UnixMilli(1_800_000_000_000) })
ctx := appcode.WithContext(context.Background(), "fami")
input := PublishInput{
EventID: "vip_online_notice:42:digest",
BroadcastType: broadcastdomain.TypeVIPOnlineNotice,
PayloadJSON: `{"event_id":"forged","broadcast_type":"forged","scope":"region","app_code":"lalu","region_id":999,"user_id":42,"vip_level":9}`,
}
result, err := service.PublishGlobalBroadcast(ctx, input)
if err != nil {
t.Fatalf("PublishGlobalBroadcast failed: %v", err)
}
if !result.Created || result.GroupID != "hy_fami_bc_g_v2" || result.Status != broadcastdomain.StatusPending {
t.Fatalf("global publish result mismatch: %+v", result)
}
record := repository.records[input.EventID]
if record.Scope != broadcastdomain.ScopeGlobal || record.GroupID != "hy_fami_bc_g_v2" || record.BroadcastType != broadcastdomain.TypeVIPOnlineNotice {
t.Fatalf("global outbox mismatch: %+v", record)
}
var payload map[string]any
if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
t.Fatalf("decode global payload: %v", err)
}
if payload["event_id"] != input.EventID || payload["broadcast_type"] != broadcastdomain.TypeVIPOnlineNotice || payload["scope"] != broadcastdomain.ScopeGlobal || payload["app_code"] != "fami" || payload["sent_at_ms"] != float64(1_800_000_000_000) {
t.Fatalf("forced global envelope mismatch: %+v", payload)
}
if _, exists := payload["region_id"]; exists {
t.Fatalf("global payload must not carry region_id: %+v", payload)
}
// 同一个 event_id 的 HTTP 重试只返回已有 outbox不能覆盖首个用户资料快照或重复发送。
retry, err := service.PublishGlobalBroadcast(ctx, PublishInput{EventID: input.EventID, BroadcastType: broadcastdomain.TypeVIPOnlineNotice, PayloadJSON: `{"user_id":99}`})
if err != nil || retry.Created || repository.records[input.EventID].PayloadJSON != record.PayloadJSON {
t.Fatalf("global retry must be idempotent: retry=%+v record=%+v err=%v", retry, repository.records[input.EventID], err)
}
processed, err := service.ProcessPendingBroadcasts(ctx, WorkerOptions{WorkerID: "worker-vip", BatchSize: 10})
if err != nil || processed.SuccessCount != 1 || len(publisher.messages) != 2 {
// 全局 v2 成功后会向 v1 旧群补发一份,两个 TIMCustomElem 共享同一业务 payload。
t.Fatalf("VIP online IM publish mismatch: result=%+v messages=%+v err=%v", processed, publisher.messages, err)
}
if publisher.messages[0].Desc != broadcastdomain.TypeVIPOnlineNotice || publisher.messages[0].Ext != "im_broadcast" || publisher.messages[0].EventID != input.EventID {
t.Fatalf("VIP online TIMCustomElem mismatch: %+v", publisher.messages[0])
}
}
func TestConsumeRedPacketCreatedBuildsFullIMPayload(t *testing.T) {
repository := newFakeRepository()
service := New(Config{NodeID: "node-a"}, repository, nil, nil)

View File

@ -2,8 +2,10 @@ package growth
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
@ -15,6 +17,7 @@ import (
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/growth"
messageservice "hyapp/services/activity-service/internal/service/message"
)
const levelRewardReason = "growth_level_reward"
@ -38,29 +41,53 @@ type Repository interface {
ListLevelRewards(ctx context.Context, query domain.RewardQuery) ([]domain.RewardJob, int64, error)
ConsumeLevelEvent(ctx context.Context, event domain.ValueEvent, nowMS int64) (domain.EventResult, error)
SetUserLevel(ctx context.Context, command domain.SetUserLevelCommand, nowMS int64) (domain.SetUserLevelResult, error)
BatchGetUserLevelAdminProfiles(ctx context.Context, userIDs []int64, nowMS int64) (domain.AdminLevelProfiles, error)
AdjustTemporaryUserLevels(ctx context.Context, command domain.AdjustTemporaryLevelsCommand, nowMS int64) (domain.AdminUserLevelProfile, error)
UpsertLevelTrack(ctx context.Context, command domain.TrackCommand, nowMS int64) (domain.Track, bool, error)
UpsertLevelRule(ctx context.Context, command domain.RuleCommand, nowMS int64) (domain.Rule, bool, error)
UpsertLevelTier(ctx context.Context, command domain.TierCommand, nowMS int64) (domain.Tier, bool, error)
ClaimPendingLevelRewardJobs(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.RewardJob, error)
MarkLevelRewardGranted(ctx context.Context, rewardJobID string, walletGrantID string, nowMS int64) error
MarkLevelRewardFailed(ctx context.Context, rewardJobID string, failureReason string, nextRetryAtMS int64, nowMS int64) error
MarkLevelRewardGranted(ctx context.Context, rewardJobID string, grants []domain.RewardWalletGrant, nowMS int64) error
MarkLevelRewardFailed(ctx context.Context, rewardJobID string, grants []domain.RewardWalletGrant, failureReason string, nextRetryAtMS int64, nowMS int64) error
ClaimTemporaryLevelWork(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.TemporaryLevelWork, error)
MarkTemporaryLevelNotices(ctx context.Context, temporaryLevelID string, activationStatus string, expiryStatus string, failureReason string, nextRetryAtMS int64, nowMS int64) error
ClaimLevelRewardRevocations(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.RewardRevocation, error)
MarkLevelRewardRevoked(ctx context.Context, rewardJobID string, walletGrantID string, nowMS int64) error
MarkLevelRewardRevokeFailed(ctx context.Context, rewardJobID string, walletGrantID string, failureReason string, nextRetryAtMS int64, nowMS int64) error
}
// WalletClient 是等级奖励发放的唯一外部副作用。
type WalletClient interface {
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
RevokeResourceGrant(ctx context.Context, req *walletv1.RevokeResourceGrantRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
}
// NoticeService 只调用 message service 的公开 helpergrowth 不直接写 inbox 表。
type NoticeService interface {
CreateSystemNotice(ctx context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error)
}
type Option func(*Service)
func WithNoticeService(notices NoticeService) Option {
return func(service *Service) { service.notices = notices }
}
// Service 承载 App 查询、事件消费、后台配置和等级奖励补偿。
type Service struct {
repository Repository
wallet WalletClient
notices NoticeService
now func() time.Time
}
func New(repository Repository, wallet WalletClient) *Service {
return &Service{repository: repository, wallet: wallet, now: time.Now}
func New(repository Repository, wallet WalletClient, options ...Option) *Service {
service := &Service{repository: repository, wallet: wallet, now: time.Now}
for _, option := range options {
option(service)
}
return service
}
func (s *Service) SetClock(now func() time.Time) {
@ -112,6 +139,65 @@ func (s *Service) BatchGetUserLevelDisplayProfiles(ctx context.Context, userIDs
return s.repository.BatchGetUserLevelDisplayProfiles(ctx, normalized, s.now().UnixMilli())
}
// BatchGetUserLevelAdminProfiles 返回真实账户和当前有效 overlay到期毫秒由 repository 读取条件立即排除,
// 所以后台展示不会依赖 cron 是否已经把记录推进为 expired。
func (s *Service) BatchGetUserLevelAdminProfiles(ctx context.Context, userIDs []int64) (domain.AdminLevelProfiles, error) {
if err := s.requireRepository(); err != nil {
return domain.AdminLevelProfiles{}, err
}
normalized := uniquePositiveUserIDs(userIDs)
if len(normalized) == 0 {
return domain.AdminLevelProfiles{Profiles: []domain.AdminUserLevelProfile{}, ServerTimeMS: s.now().UnixMilli()}, nil
}
if len(normalized) > 500 {
return domain.AdminLevelProfiles{}, xerr.New(xerr.InvalidArgument, "too many user ids; maximum is 500")
}
return s.repository.BatchGetUserLevelAdminProfiles(ctx, normalized, s.now().UnixMilli())
}
// AdjustTemporaryUserLevels 校验整批命令后只调用一次 repository保证 wealth/charm 要么一起生效、要么一起回滚。
func (s *Service) AdjustTemporaryUserLevels(ctx context.Context, command domain.AdjustTemporaryLevelsCommand) (domain.AdminUserLevelProfile, error) {
if err := s.requireRepository(); err != nil {
return domain.AdminUserLevelProfile{}, err
}
command.CommandID = strings.TrimSpace(command.CommandID)
command.Reason = strings.TrimSpace(command.Reason)
if command.CommandID == "" || command.UserID <= 0 || command.OperatorAdminID <= 0 || len(command.Adjustments) == 0 || len(command.Adjustments) > 2 {
return domain.AdminUserLevelProfile{}, xerr.New(xerr.InvalidArgument, "temporary level command is incomplete")
}
seen := make(map[string]struct{}, len(command.Adjustments))
for index := range command.Adjustments {
item := &command.Adjustments[index]
item.Track = normalizeTrack(item.Track)
if item.Track != domain.TrackWealth && item.Track != domain.TrackCharm {
return domain.AdminUserLevelProfile{}, xerr.New(xerr.InvalidArgument, "temporary level track must be wealth or charm")
}
if item.Level < 1 || item.Level > 50 || item.DurationDays <= 0 {
return domain.AdminUserLevelProfile{}, xerr.New(xerr.InvalidArgument, "temporary level must be 1..50 and duration_days must be positive")
}
if _, exists := seen[item.Track]; exists {
return domain.AdminUserLevelProfile{}, xerr.New(xerr.InvalidArgument, "temporary level track is duplicated")
}
seen[item.Track] = struct{}{}
}
// 排序让同一业务载荷生成稳定 hash也让 repository 以固定轨道顺序加锁,降低双轨并发死锁概率。
sort.Slice(command.Adjustments, func(i, j int) bool { return command.Adjustments[i].Track < command.Adjustments[j].Track })
if command.Reason == "" {
command.Reason = "admin_temporary_level"
}
hashPayload, _ := json.Marshal(struct {
UserID int64 `json:"user_id"`
Adjustments []domain.TemporaryLevelAdjustment `json:"adjustments"`
OperatorAdminID int64 `json:"operator_admin_id"`
Reason string `json:"reason"`
}{
UserID: command.UserID, Adjustments: command.Adjustments,
OperatorAdminID: command.OperatorAdminID, Reason: command.Reason,
})
command.RequestHash = fmt.Sprintf("%x", sha256.Sum256(hashPayload))
return s.repository.AdjustTemporaryUserLevels(ctx, command, s.now().UTC().UnixMilli())
}
func (s *Service) IssueRegistrationLevelBadges(ctx context.Context, userID int64, commandID string) (domain.RegistrationLevelBadgeGrants, error) {
if err := s.requireRepository(); err != nil {
return domain.RegistrationLevelBadgeGrants{}, err
@ -386,14 +472,16 @@ func (s *Service) ProcessLevelRewardBatch(ctx context.Context, runID string, wor
}
for _, job := range jobs {
processed++
walletGrantID, grantErr := s.grantLevelRewardJob(ctx, job)
walletGrants, grantErr := s.grantLevelRewardJob(ctx, job)
if grantErr != nil {
failure++
nextRetry := s.now().Add(time.Minute).UnixMilli()
_ = s.repository.MarkLevelRewardFailed(ctx, job.RewardJobID, xerr.MessageOf(grantErr), nextRetry, s.now().UnixMilli())
// One reward job may span several idempotent wallet commands. Persist every successful prefix even when a
// later command fails, otherwise an overlay expiry cannot discover and revoke the already-issued asset.
_ = s.repository.MarkLevelRewardFailed(ctx, job.RewardJobID, walletGrants, xerr.MessageOf(grantErr), nextRetry, s.now().UnixMilli())
continue
}
if markErr := s.repository.MarkLevelRewardGranted(ctx, job.RewardJobID, walletGrantID, s.now().UnixMilli()); markErr != nil {
if markErr := s.repository.MarkLevelRewardGranted(ctx, job.RewardJobID, walletGrants, s.now().UnixMilli()); markErr != nil {
failure++
continue
}
@ -402,8 +490,9 @@ func (s *Service) ProcessLevelRewardBatch(ctx context.Context, runID string, wor
return int32(len(jobs)), processed, success, failure, len(jobs) == batchSize, nil
}
func (s *Service) grantLevelRewardJob(ctx context.Context, job domain.RewardJob) (string, error) {
walletGrantID := ""
func (s *Service) grantLevelRewardJob(ctx context.Context, job domain.RewardJob) ([]domain.RewardWalletGrant, error) {
grants := make([]domain.RewardWalletGrant, 0, 3)
nowMS := s.now().UnixMilli()
if job.ResourceGroupID > 0 {
// 资源组奖励沿用原有配置快照command_id 后缀把组奖励和单资源等级物料奖励拆成多个钱包幂等动作。
resp, err := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
@ -416,16 +505,19 @@ func (s *Service) grantLevelRewardJob(ctx context.Context, job domain.RewardJob)
GrantSource: domain.GrantSourceGrowthLevel,
})
if err != nil {
return "", err
return grants, err
}
if resp.GetGrant() != nil {
walletGrantID = resp.GetGrant().GetGrantId()
grants = append(grants, domain.RewardWalletGrant{RewardJobID: job.RewardJobID, GrantKind: "resource_group", WalletGrantID: resp.GetGrant().GetGrantId(), CreatedAtMS: nowMS})
}
}
levelResources, err := s.levelResourceIDsForRewardJob(ctx, job)
if err != nil {
return "", err
return grants, err
}
durationMS := levelAvatarFrameRewardDurationMS
// 临时直发物料也按自然奖励的长有效期授予,并由记录到子表的 grant 在 overlay 到期时显式撤回。
// 这样用户在有效期内真实达到该等级后只需把任务提升为永久并跳过撤回,钱包权益不会仍按临时截止时间消失。
if levelResources.avatarFrameResourceID > 0 {
// 等级头像框是每级固定物料,不依赖运营额外创建资源组;按产品要求直接发 9999 天权益。
resp, err := s.wallet.GrantResource(ctx, &walletv1.GrantResourceRequest{
@ -434,16 +526,16 @@ func (s *Service) grantLevelRewardJob(ctx context.Context, job domain.RewardJob)
TargetUserId: job.UserID,
ResourceId: levelResources.avatarFrameResourceID,
Quantity: 1,
DurationMs: levelAvatarFrameRewardDurationMS,
DurationMs: durationMS,
Reason: levelRewardReason,
OperatorUserId: job.UserID,
GrantSource: domain.GrantSourceGrowthLevel,
})
if err != nil {
return "", err
return grants, err
}
if walletGrantID == "" && resp.GetGrant() != nil {
walletGrantID = resp.GetGrant().GetGrantId()
if resp.GetGrant() != nil {
grants = append(grants, domain.RewardWalletGrant{RewardJobID: job.RewardJobID, GrantKind: "avatar_frame", WalletGrantID: resp.GetGrant().GetGrantId(), CreatedAtMS: nowMS})
}
}
if levelResources.shortBadgeResourceID > 0 {
@ -454,19 +546,140 @@ func (s *Service) grantLevelRewardJob(ctx context.Context, job domain.RewardJob)
TargetUserId: job.UserID,
ResourceId: levelResources.shortBadgeResourceID,
Quantity: 1,
DurationMs: levelAvatarFrameRewardDurationMS,
DurationMs: durationMS,
Reason: levelRewardReason,
OperatorUserId: job.UserID,
GrantSource: domain.GrantSourceGrowthLevel,
})
if err != nil {
return "", err
return grants, err
}
if walletGrantID == "" && resp.GetGrant() != nil {
walletGrantID = resp.GetGrant().GetGrantId()
if resp.GetGrant() != nil {
grants = append(grants, domain.RewardWalletGrant{RewardJobID: job.RewardJobID, GrantKind: "short_badge", WalletGrantID: resp.GetGrant().GetGrantId(), CreatedAtMS: nowMS})
}
}
return walletGrantID, nil
return grants, nil
}
// ProcessTemporaryLevelBatch 推进过期状态、发送幂等系统消息并撤回不再应得的临时奖励。
// 状态准备由 repository 事务完成,外部副作用失败只回写 retry_at不会重新污染真实成长账户。
func (s *Service) ProcessTemporaryLevelBatch(ctx context.Context, workerID string, batchSize int, lockTTL time.Duration) (claimed int32, processed int32, success int32, failure int32, hasMore bool, err error) {
if err := s.requireRepository(); err != nil {
return 0, 0, 0, 0, false, err
}
if s.wallet == nil || s.notices == nil {
return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "temporary level side effects are not configured")
}
if batchSize <= 0 {
batchSize = 100
}
if lockTTL <= 0 {
lockTTL = 30 * time.Second
}
if strings.TrimSpace(workerID) == "" {
workerID = "activity-temporary-level"
}
nowMS := s.now().UTC().UnixMilli()
works, err := s.repository.ClaimTemporaryLevelWork(ctx, workerID, nowMS, lockTTL, batchSize)
if err != nil {
return 0, 0, 0, 0, false, err
}
for _, work := range works {
processed++
activationStatus, expiryStatus := "", ""
var workErr error
if work.SendActivationNotice {
workErr = s.sendTemporaryLevelNotice(ctx, work, false, nowMS)
if workErr == nil {
activationStatus = domain.NoticeStatusSent
} else {
activationStatus = domain.NoticeStatusFailed
}
}
if workErr == nil && work.SendExpiryNotice {
workErr = s.sendTemporaryLevelNotice(ctx, work, true, nowMS)
if workErr == nil {
expiryStatus = domain.NoticeStatusSent
} else {
expiryStatus = domain.NoticeStatusFailed
}
}
failureReason := ""
nextRetryAtMS := int64(0)
if workErr != nil {
failure++
failureReason = xerr.MessageOf(workErr)
nextRetryAtMS = s.now().Add(time.Minute).UnixMilli()
} else {
success++
}
if markErr := s.repository.MarkTemporaryLevelNotices(ctx, work.TemporaryLevelID, activationStatus, expiryStatus, failureReason, nextRetryAtMS, s.now().UnixMilli()); markErr != nil {
failure++
if workErr == nil && success > 0 {
success--
}
}
}
remaining := batchSize - len(works)
revocations := []domain.RewardRevocation{}
if remaining > 0 {
revocations, err = s.repository.ClaimLevelRewardRevocations(ctx, workerID, s.now().UnixMilli(), lockTTL, remaining)
if err != nil {
return int32(len(works)), processed, success, failure, false, err
}
}
for _, revoke := range revocations {
processed++
_, revokeErr := s.wallet.RevokeResourceGrant(ctx, &walletv1.RevokeResourceGrantRequest{
RequestId: fmt.Sprintf("temporary-level-revoke:%s:%s", revoke.RewardJobID, revoke.WalletGrantID),
AppCode: appcode.FromContext(ctx),
GrantId: revoke.WalletGrantID,
Reason: "temporary_level_expired",
OperatorUserId: revoke.UserID,
})
if revokeErr != nil {
failure++
_ = s.repository.MarkLevelRewardRevokeFailed(ctx, revoke.RewardJobID, revoke.WalletGrantID, xerr.MessageOf(revokeErr), s.now().Add(time.Minute).UnixMilli(), s.now().UnixMilli())
continue
}
if markErr := s.repository.MarkLevelRewardRevoked(ctx, revoke.RewardJobID, revoke.WalletGrantID, s.now().UnixMilli()); markErr != nil {
failure++
continue
}
success++
}
claimed = int32(len(works) + len(revocations))
hasMore = len(works) == batchSize || (remaining > 0 && len(revocations) == remaining)
return claimed, processed, success, failure, hasMore, nil
}
func (s *Service) sendTemporaryLevelNotice(ctx context.Context, work domain.TemporaryLevelWork, expired bool, nowMS int64) error {
trackName := map[string]string{domain.TrackWealth: "富豪", domain.TrackCharm: "魅力"}[work.Track]
body := fmt.Sprintf("尊敬的用户,官方已将您%s等级临时升级为Lv%d有效期%d天请在有效期内继续升级哦", trackName, work.TargetLevel, (work.ExpiresAtMS-work.StartedAtMS)/(24*60*60*1000))
eventSuffix := "activated"
eventType := "temporary_level_activated"
if expired {
body = fmt.Sprintf("您的临时%s等级Lv%d已到期根据您在有效期内的经验值现恢复为Lv%d", trackName, work.TargetLevel, work.FinalLevel)
eventSuffix = "expired"
eventType = "temporary_level_expired"
}
_, err := s.notices.CreateSystemNotice(ctx, messageservice.NoticeCommand{
TargetUserID: work.UserID,
Producer: "growth-level",
ProducerEventID: fmt.Sprintf("temporary-level:%s:%s", work.TemporaryLevelID, eventSuffix),
ProducerEventType: eventType,
AggregateType: "temporary_growth_level",
AggregateID: work.TemporaryLevelID,
Title: "系统通知",
Summary: body,
Body: body,
SentAtMS: nowMS,
Metadata: map[string]any{
"track": work.Track, "target_level": work.TargetLevel, "final_level": work.FinalLevel,
"started_at_ms": work.StartedAtMS, "expires_at_ms": work.ExpiresAtMS,
},
})
return err
}
type levelRewardResourceIDs struct {
@ -642,3 +855,19 @@ func validMetricForTrack(track string, metric string) bool {
return false
}
}
func uniquePositiveUserIDs(values []int64) []int64 {
result := make([]int64, 0, len(values))
seen := make(map[int64]struct{}, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}

View File

@ -13,6 +13,7 @@ import (
"hyapp/pkg/appcode"
domain "hyapp/services/activity-service/internal/domain/growth"
growthservice "hyapp/services/activity-service/internal/service/growth"
messageservice "hyapp/services/activity-service/internal/service/message"
"hyapp/services/activity-service/internal/testutil/mysqltest"
)
@ -88,6 +89,507 @@ func TestGrowthLevelEventRewardFlow(t *testing.T) {
}
}
func TestTemporaryGrowthLevelOverlayExpiresAndRevokesAllWalletGrants(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeGrowthWallet{}
notices := &fakeGrowthNoticeService{}
now := fixedGrowthNow()
svc := growthservice.New(repository, wallet, growthservice.WithNoticeService(notices))
svc.SetClock(func() time.Time { return now })
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth, Level: 2, RequiredValue: 200, Name: "wealth 2", Status: domain.StatusActive,
RewardResourceGroupID: 9002, SortOrder: 2,
DisplayConfigJSON: `{"long_badge_resource_id":702,"avatar_frame_resource_id":802,"short_badge_resource_id":902}`,
OperatorAdminID: 90001,
}); err != nil || created {
t.Fatalf("update temporary reward resources failed: created=%v err=%v", created, err)
}
profile, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-level-command-1", UserID: 51001, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 7}},
})
if err != nil {
t.Fatalf("AdjustTemporaryUserLevels failed: %v", err)
}
wealth := findAdminTrack(profile.Tracks, domain.TrackWealth)
if wealth.RealLevel != 0 || wealth.DisplayLevel != 2 || wealth.DisplayValue != 200 || wealth.TemporaryLevelID == "" {
t.Fatalf("temporary profile mismatch: %+v", wealth)
}
// Same command and payload is a read-only idempotent replay; it must not create a second generation.
replayed, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-level-command-1", UserID: 51001, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 7}},
})
if err != nil || findAdminTrack(replayed.Tracks, domain.TrackWealth).TemporaryLevelID != wealth.TemporaryLevelID {
t.Fatalf("temporary command replay mismatch: profile=%+v err=%v", replayed, err)
}
if _, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-level-command-1", UserID: 51001, OperatorAdminID: 90001, Reason: "changed-payload",
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 7}},
}); err == nil {
t.Fatal("same command id with a changed reason must be rejected as an idempotency conflict")
}
if _, _, success, failure, _, err := svc.ProcessTemporaryLevelBatch(ctx, "worker-notice", 10, time.Second); err != nil || success != 1 || failure != 0 || len(notices.commands) != 1 {
t.Fatalf("activation notice batch mismatch: success=%d failure=%d notices=%d err=%v", success, failure, len(notices.commands), err)
}
if claimed, _, success, failure, _, err := svc.ProcessLevelRewardBatch(ctx, "run-temp-reward", "worker-reward", 10, time.Second); err != nil || claimed != 3 || success != 3 || failure != 0 {
t.Fatalf("temporary reward batch mismatch: claimed=%d success=%d failure=%d err=%v", claimed, success, failure, err)
}
for _, grant := range wallet.resourceGrants {
if grant.GetDurationMs() != 9999*24*time.Hour.Milliseconds() {
t.Fatalf("temporary direct resource must rely on explicit revoke instead of wallet expiry: %+v", grant)
}
}
// The end instant is exclusive: before cron runs, every read must already ignore the overlay.
now = now.Add(7 * 24 * time.Hour)
profiles, err := svc.BatchGetUserLevelAdminProfiles(ctx, []int64{51001})
if err != nil {
t.Fatalf("BatchGetUserLevelAdminProfiles at expiry failed: %v", err)
}
atExpiry := findAdminTrack(profiles.Profiles[0].Tracks, domain.TrackWealth)
if atExpiry.DisplayLevel != 0 || atExpiry.TemporaryLevelID != "" {
t.Fatalf("expired overlay must be ignored immediately: %+v", atExpiry)
}
claimed, processed, success, failure, _, err := svc.ProcessTemporaryLevelBatch(ctx, "worker-expiry", 20, time.Second)
if err != nil || claimed != 6 || processed != 6 || success != 6 || failure != 0 {
t.Fatalf("expiry batch mismatch: claimed=%d processed=%d success=%d failure=%d err=%v", claimed, processed, success, failure, err)
}
if len(wallet.revokes) != 5 || len(notices.commands) != 2 {
t.Fatalf("expiry side effects mismatch: revokes=%d notices=%d", len(wallet.revokes), len(notices.commands))
}
}
func TestTemporaryGrowthLevelExpiredGenerationIsMaterializedBeforeNewAdjustment(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeGrowthWallet{}
notices := &fakeGrowthNoticeService{}
now := fixedGrowthNow()
svc := growthservice.New(repository, wallet, growthservice.WithNoticeService(notices))
svc.SetClock(func() time.Time { return now })
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth, Level: 3, RequiredValue: 300, Name: "wealth 3", Status: domain.StatusActive,
SortOrder: 3, DisplayConfigJSON: `{}`, OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed level 3 failed: created=%v err=%v", created, err)
}
first, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-expired-before-replace-1", UserID: 51007, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
})
if err != nil {
t.Fatalf("create first temporary generation failed: %v", err)
}
firstID := findAdminTrack(first.Tracks, domain.TrackWealth).TemporaryLevelID
now = now.Add(24 * time.Hour)
second, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-expired-before-replace-2", UserID: 51007, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 3, DurationDays: 1}},
})
if err != nil {
t.Fatalf("create second temporary generation failed: %v", err)
}
current := findAdminTrack(second.Tracks, domain.TrackWealth)
if current.TemporaryLevelID == "" || current.TemporaryLevelID == firstID || current.DisplayLevel != 3 {
t.Fatalf("new generation must replace only after old expiry is materialized: first=%s current=%+v", firstID, current)
}
claimed, processed, success, failure, _, err := svc.ProcessTemporaryLevelBatch(ctx, "worker-expired-replace", 10, time.Second)
if err != nil || claimed != 2 || processed != 2 || success != 2 || failure != 0 || len(notices.commands) != 2 {
t.Fatalf("old expiry and new activation must both remain retryable: claimed=%d processed=%d success=%d failure=%d notices=%d err=%v", claimed, processed, success, failure, len(notices.commands), err)
}
}
func TestTemporaryGrowthWorkersReclaimExpiredRunningLocks(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeGrowthWallet{}
now := fixedGrowthNow()
svc := growthservice.New(repository, wallet, growthservice.WithNoticeService(&fakeGrowthNoticeService{}))
svc.SetClock(func() time.Time { return now })
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
if _, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-reward-reclaim", UserID: 51008, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
}); err != nil {
t.Fatalf("seed reward reclaim generation failed: %v", err)
}
firstJobs, err := repository.ClaimPendingLevelRewardJobs(ctx, "crashed-reward-worker", now.UnixMilli(), time.Second, 20)
if err != nil || len(firstJobs) == 0 {
t.Fatalf("first reward claim failed: jobs=%d err=%v", len(firstJobs), err)
}
reclaimedJobs, err := repository.ClaimPendingLevelRewardJobs(ctx, "replacement-reward-worker", now.Add(time.Second).UnixMilli(), time.Second, 20)
if err != nil || len(reclaimedJobs) != len(firstJobs) {
t.Fatalf("expired running reward locks must be reclaimed: first=%d reclaimed=%d err=%v", len(firstJobs), len(reclaimedJobs), err)
}
// A separate generation is fully granted so revocation child rows can exercise the same crash-recovery boundary.
if _, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-revoke-reclaim", UserID: 51009, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
}); err != nil {
t.Fatalf("seed revoke reclaim generation failed: %v", err)
}
if claimed, _, success, failure, _, err := svc.ProcessLevelRewardBatch(ctx, "revoke-reclaim-grant-run", "reward-worker", 20, time.Second); err != nil || claimed == 0 || success != claimed || failure != 0 {
t.Fatalf("grant revocation seed jobs failed: claimed=%d success=%d failure=%d err=%v", claimed, success, failure, err)
}
expiresAt := now.Add(24 * time.Hour)
if _, err := repository.ClaimTemporaryLevelWork(ctx, "expiry-materializer", expiresAt.UnixMilli(), time.Second, 20); err != nil {
t.Fatalf("materialize temporary expiry failed: %v", err)
}
firstRevokes, err := repository.ClaimLevelRewardRevocations(ctx, "crashed-revoke-worker", expiresAt.UnixMilli(), time.Second, 100)
if err != nil || len(firstRevokes) == 0 {
t.Fatalf("first revoke claim failed: revokes=%d err=%v", len(firstRevokes), err)
}
reclaimedRevokes, err := repository.ClaimLevelRewardRevocations(ctx, "replacement-revoke-worker", expiresAt.Add(time.Second).UnixMilli(), time.Second, 100)
if err != nil || len(reclaimedRevokes) != len(firstRevokes) {
t.Fatalf("expired running revoke locks must be reclaimed: first=%d reclaimed=%d err=%v", len(firstRevokes), len(reclaimedRevokes), err)
}
}
func TestTemporaryRewardsBecomePermanentWhenRealLevelCatchesUp(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeGrowthWallet{}
now := fixedGrowthNow()
svc := growthservice.New(repository, wallet, growthservice.WithNoticeService(&fakeGrowthNoticeService{}))
svc.SetClock(func() time.Time { return now })
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth, Level: 2, RequiredValue: 200, Name: "wealth 2", Status: domain.StatusActive,
RewardResourceGroupID: 9002, SortOrder: 2,
DisplayConfigJSON: `{"avatar_frame_resource_id":802,"short_badge_resource_id":902}`,
OperatorAdminID: 90001,
}); err != nil || created {
t.Fatalf("update level 2 direct rewards failed: created=%v err=%v", created, err)
}
if _, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-promote-permanent", UserID: 51010, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
}); err != nil {
t.Fatalf("create temporary promotion generation failed: %v", err)
}
if claimed, _, success, _, _, err := svc.ProcessLevelRewardBatch(ctx, "temporary-promotion-grants", "reward-worker", 20, time.Second); err != nil || claimed == 0 || success != claimed {
t.Fatalf("grant temporary promotion rewards failed: claimed=%d success=%d err=%v", claimed, success, err)
}
now = now.Add(time.Millisecond)
if _, err := svc.ConsumeLevelEvent(ctx, domain.ValueEvent{
EventID: "temporary-promotion-organic", SourceEventID: "temporary-promotion-organic", SourceService: "room-service",
SourceEventType: "RoomGiftSent", UserID: 51010, Track: domain.TrackWealth,
MetricType: domain.MetricGiftSpendCoin, ValueDelta: 200, OccurredAtMS: now.UnixMilli(), DimensionsJSON: `{}`,
}); err != nil {
t.Fatalf("organic catch-up event failed: %v", err)
}
now = fixedGrowthNow().Add(24 * time.Hour)
if _, _, _, _, _, err := svc.ProcessTemporaryLevelBatch(ctx, "promotion-expiry-worker", 20, time.Second); err != nil {
t.Fatalf("process promoted generation expiry failed: %v", err)
}
if len(wallet.revokes) != 0 {
t.Fatalf("rewards earned by real level must remain permanent, revokes=%d", len(wallet.revokes))
}
for _, grant := range wallet.resourceGrants {
if grant.GetDurationMs() != 9999*24*time.Hour.Milliseconds() {
t.Fatalf("promoted direct resource would still expire in wallet: %+v", grant)
}
}
}
func TestTemporaryRewardGenerationReplacementCarriesLiveGrantsWithoutDuplicateIssue(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeGrowthWallet{}
now := fixedGrowthNow()
svc := growthservice.New(repository, wallet, growthservice.WithNoticeService(&fakeGrowthNoticeService{}))
svc.SetClock(func() time.Time { return now })
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth, Level: 2, RequiredValue: 200, Name: "wealth 2", Status: domain.StatusActive,
RewardResourceGroupID: 9002, SortOrder: 2,
DisplayConfigJSON: `{"avatar_frame_resource_id":802,"short_badge_resource_id":902}`,
OperatorAdminID: 90001,
}); err != nil || created {
t.Fatalf("update level 2 resources failed: created=%v err=%v", created, err)
}
first, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-generation-carry-1", UserID: 51011, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
})
if err != nil {
t.Fatalf("create first temporary generation failed: %v", err)
}
firstID := findAdminTrack(first.Tracks, domain.TrackWealth).TemporaryLevelID
if claimed, _, success, failure, _, err := svc.ProcessLevelRewardBatch(ctx, "temporary-generation-carry-grant", "reward-worker", 20, time.Second); err != nil || claimed == 0 || success != claimed || failure != 0 {
t.Fatalf("grant first generation failed: claimed=%d success=%d failure=%d err=%v", claimed, success, failure, err)
}
groupGrants, directGrants := len(wallet.grants), len(wallet.resourceGrants)
// 覆盖调整建立新计时基线,但目标范围内已发放的 grant 必须迁移到新代,不能撤回再发造成重复权益。
now = now.Add(time.Hour)
second, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-generation-carry-2", UserID: 51011, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
})
if err != nil {
t.Fatalf("replace temporary generation failed: %v", err)
}
secondTrack := findAdminTrack(second.Tracks, domain.TrackWealth)
if secondTrack.TemporaryLevelID == "" || secondTrack.TemporaryLevelID == firstID {
t.Fatalf("replacement must create a distinct generation: first=%s second=%+v", firstID, secondTrack)
}
if claimed, _, success, failure, _, err := svc.ProcessLevelRewardBatch(ctx, "temporary-generation-carry-no-duplicate", "reward-worker", 20, time.Second); err != nil || claimed != 0 || success != 0 || failure != 0 {
t.Fatalf("replacement must not enqueue duplicate grants: claimed=%d success=%d failure=%d err=%v", claimed, success, failure, err)
}
if len(wallet.grants) != groupGrants || len(wallet.resourceGrants) != directGrants {
t.Fatalf("replacement duplicated wallet grants: groups=%d/%d direct=%d/%d", len(wallet.grants), groupGrants, len(wallet.resourceGrants), directGrants)
}
// 自然成长只提升当前代中迁移过来的奖励;到期后这些 grant 不得再进入撤回队列。
now = now.Add(time.Millisecond)
if _, err := svc.ConsumeLevelEvent(ctx, domain.ValueEvent{
EventID: "temporary-generation-carry-organic", SourceEventID: "temporary-generation-carry-organic", SourceService: "room-service",
SourceEventType: "RoomGiftSent", UserID: 51011, Track: domain.TrackWealth,
MetricType: domain.MetricGiftSpendCoin, ValueDelta: 200, OccurredAtMS: now.UnixMilli(), DimensionsJSON: `{}`,
}); err != nil {
t.Fatalf("organic catch-up after replacement failed: %v", err)
}
now = time.UnixMilli(secondTrack.ExpiresAtMS).UTC()
if _, _, _, _, _, err := svc.ProcessTemporaryLevelBatch(ctx, "temporary-generation-carry-expiry", 100, time.Second); err != nil {
t.Fatalf("process replacement expiry failed: %v", err)
}
if len(wallet.revokes) != 0 {
t.Fatalf("current-generation grants promoted by real growth must not be revoked: revokes=%d", len(wallet.revokes))
}
}
func TestSupersededRewardWithFailedRevokeIsNotPromotedByNaturalGrowth(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeGrowthWallet{}
now := fixedGrowthNow()
svc := growthservice.New(repository, wallet, growthservice.WithNoticeService(&fakeGrowthNoticeService{}))
svc.SetClock(func() time.Time { return now })
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth, Level: 3, RequiredValue: 300, Name: "wealth 3", Status: domain.StatusActive,
RewardResourceGroupID: 9004, SortOrder: 3,
DisplayConfigJSON: `{"avatar_frame_resource_id":803,"short_badge_resource_id":903}`,
OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed level 3 resources failed: created=%v err=%v", created, err)
}
if _, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-failed-revoke-1", UserID: 51012, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 3, DurationDays: 1}},
}); err != nil {
t.Fatalf("create high temporary generation failed: %v", err)
}
if claimed, _, success, failure, _, err := svc.ProcessLevelRewardBatch(ctx, "temporary-failed-revoke-grant", "reward-worker", 50, time.Second); err != nil || claimed == 0 || success != claimed || failure != 0 {
t.Fatalf("grant high temporary generation failed: claimed=%d success=%d failure=%d err=%v", claimed, success, failure, err)
}
// 降低覆盖目标时仅 Lv3 留在旧代撤回;模拟钱包撤回失败,验证后续自然 Lv3 不会把旧代事实误标永久。
now = now.Add(time.Hour)
if _, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-failed-revoke-2", UserID: 51012, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
}); err != nil {
t.Fatalf("replace with lower temporary target failed: %v", err)
}
revocations, err := repository.ClaimLevelRewardRevocations(ctx, "failed-revoke-worker", now.UnixMilli(), time.Second, 100)
if err != nil || len(revocations) == 0 {
t.Fatalf("claim superseded level 3 revocations failed: revocations=%d err=%v", len(revocations), err)
}
for _, revoke := range revocations {
if err := repository.MarkLevelRewardRevokeFailed(ctx, revoke.RewardJobID, revoke.WalletGrantID, "wallet timeout", now.Add(time.Minute).UnixMilli(), now.UnixMilli()); err != nil {
t.Fatalf("mark superseded revoke failed: %v", err)
}
}
now = now.Add(time.Millisecond)
if _, err := svc.ConsumeLevelEvent(ctx, domain.ValueEvent{
EventID: "temporary-failed-revoke-organic", SourceEventID: "temporary-failed-revoke-organic", SourceService: "room-service",
SourceEventType: "RoomGiftSent", UserID: 51012, Track: domain.TrackWealth,
MetricType: domain.MetricGiftSpendCoin, ValueDelta: 300, OccurredAtMS: now.UnixMilli(), DimensionsJSON: `{}`,
}); err != nil {
t.Fatalf("natural level 3 event failed: %v", err)
}
rewards, _, err := svc.ListLevelRewards(ctx, domain.RewardQuery{UserID: 51012, Track: domain.TrackWealth, PageSize: 100})
if err != nil {
t.Fatalf("list rewards after natural level 3 failed: %v", err)
}
var oldTemporary, freshOrganic int
for _, reward := range rewards {
if reward.RewardSourceType != domain.RewardSourceLevel || reward.RewardSourceID != "level:3" {
continue
}
if reward.RewardOrigin == domain.RewardOriginTemporary && !reward.Permanent && reward.RevokeStatus == domain.RevokeStatusFailed {
oldTemporary++
}
if reward.RewardOrigin == domain.RewardOriginOrganic && reward.Permanent {
freshOrganic++
}
}
if oldTemporary != 1 || freshOrganic != 1 {
t.Fatalf("natural growth must preserve failed old revoke and create one new permanent job: old=%d organic=%d rewards=%+v", oldTemporary, freshOrganic, rewards)
}
groupGrants, directGrants := len(wallet.grants), len(wallet.resourceGrants)
if claimed, _, success, failure, _, err := svc.ProcessLevelRewardBatch(ctx, "temporary-failed-revoke-organic-grant", "reward-worker", 20, time.Second); err != nil || claimed != 1 || success != 1 || failure != 0 {
t.Fatalf("fresh organic level 3 grant failed: claimed=%d success=%d failure=%d err=%v", claimed, success, failure, err)
}
if len(wallet.grants) != groupGrants+1 || len(wallet.resourceGrants) != directGrants+2 {
t.Fatalf("fresh organic job must issue independent replacement grants: groups=%d/%d direct=%d/%d", len(wallet.grants), groupGrants, len(wallet.resourceGrants), directGrants)
}
}
func TestTemporaryRewardPartialWalletSuccessIsDurableAndRevokedAtExpiry(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakePartialGrowthWallet{failNextResource: true}
now := fixedGrowthNow()
svc := growthservice.New(repository, wallet, growthservice.WithNoticeService(&fakeGrowthNoticeService{}))
svc.SetClock(func() time.Time { return now })
ctx := appcode.WithContext(context.Background(), "lalu")
for _, rule := range []domain.RuleCommand{
{Track: domain.TrackWealth, Level: 1, RequiredValue: 100, Name: "wealth 1", Status: domain.StatusActive, SortOrder: 1, DisplayConfigJSON: `{}`, OperatorAdminID: 90001},
{Track: domain.TrackWealth, Level: 2, RequiredValue: 200, Name: "wealth 2", Status: domain.StatusActive, RewardResourceGroupID: 9002, SortOrder: 2, DisplayConfigJSON: `{"avatar_frame_resource_id":802}`, OperatorAdminID: 90001},
} {
if _, created, err := svc.UpsertLevelRule(ctx, rule); err != nil || !created {
t.Fatalf("seed partial-grant rule level %d failed: created=%v err=%v", rule.Level, created, err)
}
}
profile, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-partial-wallet-grant", UserID: 51013, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
})
if err != nil {
t.Fatalf("create partial-grant temporary level failed: %v", err)
}
track := findAdminTrack(profile.Tracks, domain.TrackWealth)
if claimed, _, success, failure, _, err := svc.ProcessLevelRewardBatch(ctx, "temporary-partial-wallet-grant-run", "reward-worker", 20, time.Second); err != nil || claimed != 1 || success != 0 || failure != 1 {
t.Fatalf("partial wallet grant batch mismatch: claimed=%d success=%d failure=%d err=%v", claimed, success, failure, err)
}
if len(wallet.grants) != 1 || len(wallet.resourceGrants) != 0 {
t.Fatalf("test precondition requires group success followed by direct-resource failure: groups=%d resources=%d", len(wallet.grants), len(wallet.resourceGrants))
}
// 到期事务必须能看见失败任务中已成功的资源组 grant并把它交给撤回 worker。
now = time.UnixMilli(track.ExpiresAtMS).UTC()
if _, _, _, failure, _, err := svc.ProcessTemporaryLevelBatch(ctx, "temporary-partial-wallet-expiry", 20, time.Second); err != nil || failure != 0 {
t.Fatalf("process partial-grant expiry failed: failure=%d err=%v", failure, err)
}
if len(wallet.revokes) != 1 || wallet.revokes[0].GetGrantId() != "grant-1" {
t.Fatalf("successful wallet prefix must be explicitly revoked: revokes=%+v", wallet.revokes)
}
rewards, _, err := svc.ListLevelRewards(ctx, domain.RewardQuery{UserID: 51013, Track: domain.TrackWealth, PageSize: 20})
if err != nil || len(rewards) != 1 || rewards[0].Status != domain.RewardStatusCanceled || rewards[0].RevokeStatus != domain.RevokeStatusRevoked {
t.Fatalf("partial reward terminal state mismatch: rewards=%+v err=%v", rewards, err)
}
}
func TestTemporaryGrowthLevelDualTrackValidationRollsBackWholeCommand(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
_, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-level-command-atomic", UserID: 51002, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{
{Track: domain.TrackWealth, Level: 2, DurationDays: 7},
// charm has no active level-2 rule in this isolated schema, so the whole transaction must fail.
{Track: domain.TrackCharm, Level: 2, DurationDays: 7},
},
})
if err == nil {
t.Fatal("dual-track command should fail when one active rule is missing")
}
profiles, queryErr := svc.BatchGetUserLevelAdminProfiles(ctx, []int64{51002})
if queryErr != nil {
t.Fatalf("BatchGetUserLevelAdminProfiles failed: %v", queryErr)
}
if wealth := findAdminTrack(profiles.Profiles[0].Tracks, domain.TrackWealth); wealth.TemporaryLevelID != "" || wealth.DisplayLevel != wealth.RealLevel {
t.Fatalf("wealth adjustment leaked from rolled-back command: %+v", wealth)
}
}
func TestTemporaryGrowthLevelAddsWindowExperienceWithoutMutatingRealTotal(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeGrowthWallet{}
now := fixedGrowthNow()
svc := growthservice.New(repository, wallet)
svc.SetClock(func() time.Time { return now })
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth, Level: 3, RequiredValue: 300, Name: "wealth 3", Status: domain.StatusActive,
SortOrder: 3, DisplayConfigJSON: `{}`, OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed level 3 failed: created=%v err=%v", created, err)
}
if _, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-level-window", UserID: 51003, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
}); err != nil {
t.Fatalf("AdjustTemporaryUserLevels failed: %v", err)
}
if _, err := svc.ConsumeLevelEvent(ctx, domain.ValueEvent{
EventID: "temporary-window-event", SourceEventID: "temporary-window-event", SourceService: "room-service",
SourceEventType: "RoomGiftSent", UserID: 51003, Track: domain.TrackWealth,
MetricType: domain.MetricGiftSpendCoin, ValueDelta: 100, OccurredAtMS: now.UnixMilli(), DimensionsJSON: `{}`,
}); err != nil {
t.Fatalf("ConsumeLevelEvent failed: %v", err)
}
profiles, err := svc.BatchGetUserLevelAdminProfiles(ctx, []int64{51003})
if err != nil {
t.Fatalf("BatchGetUserLevelAdminProfiles failed: %v", err)
}
wealth := findAdminTrack(profiles.Profiles[0].Tracks, domain.TrackWealth)
if wealth.RealTotalValue != 100 || wealth.RealLevel != 1 || wealth.DisplayValue != 300 || wealth.DisplayLevel != 3 {
t.Fatalf("real and overlay progress must remain separate: %+v", wealth)
}
now = now.Add(24 * time.Hour)
profiles, err = svc.BatchGetUserLevelAdminProfiles(ctx, []int64{51003})
if err != nil {
t.Fatalf("expiry profile failed: %v", err)
}
wealth = findAdminTrack(profiles.Profiles[0].Tracks, domain.TrackWealth)
if wealth.RealTotalValue != 100 || wealth.DisplayValue != 100 || wealth.DisplayLevel != 1 || wealth.TemporaryLevelID != "" {
t.Fatalf("expiry must reveal untouched real account: %+v", wealth)
}
}
func TestTemporaryGrowthLevelRejectsInvalidTracksBoundsAndDuration(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
tests := []struct {
name string
adjustments []domain.TemporaryLevelAdjustment
}{
{name: "game is read only", adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackGame, Level: 2, DurationDays: 7}}},
{name: "level zero", adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 0, DurationDays: 7}}},
{name: "above level fifty", adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackCharm, Level: 51, DurationDays: 7}}},
{name: "zero duration", adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 0}}},
{name: "duplicate track", adjustments: []domain.TemporaryLevelAdjustment{
{Track: domain.TrackWealth, Level: 2, DurationDays: 7},
{Track: domain.TrackWealth, Level: 3, DurationDays: 7},
}},
}
for index, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: fmt.Sprintf("temporary-invalid-%d", index), UserID: 51004, OperatorAdminID: 90001,
Adjustments: test.adjustments,
})
if err == nil {
t.Fatalf("invalid adjustment was accepted: %+v", test.adjustments)
}
})
}
}
func TestGrowthLevelEventConcurrentSameUserTrack(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
@ -488,9 +990,48 @@ func findTrackOverview(items []domain.TrackOverview, track string) domain.TrackO
return domain.TrackOverview{}
}
func findAdminTrack(items []domain.AdminTrackProfile, track string) domain.AdminTrackProfile {
for _, item := range items {
if item.Track == track {
return item
}
}
return domain.AdminTrackProfile{}
}
type fakeGrowthWallet struct {
grants []*walletv1.GrantResourceGroupRequest
resourceGrants []*walletv1.GrantResourceRequest
revokes []*walletv1.RevokeResourceGrantRequest
}
// fakePartialGrowthWallet reproduces a multi-action reward where the resource group commits but a later direct
// resource RPC fails. Real wallet command IDs are idempotent; the test focuses on activity's durable grant ledger.
type fakePartialGrowthWallet struct {
fakeGrowthWallet
failNextResource bool
}
func (f *fakePartialGrowthWallet) GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
if f.failNextResource {
f.failNextResource = false
return nil, fmt.Errorf("injected direct resource failure")
}
return f.fakeGrowthWallet.GrantResource(ctx, req, opts...)
}
func (f *fakeGrowthWallet) RevokeResourceGrant(_ context.Context, req *walletv1.RevokeResourceGrantRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
f.revokes = append(f.revokes, req)
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: req.GetGrantId(), Status: "revoked"}}, nil
}
type fakeGrowthNoticeService struct {
commands []messageservice.NoticeCommand
}
func (f *fakeGrowthNoticeService) CreateSystemNotice(_ context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error) {
f.commands = append(f.commands, cmd)
return messageservice.NoticeResult{Created: true}, nil
}
func (f *fakeGrowthWallet) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {

View File

@ -0,0 +1,14 @@
package message
import "testing"
func TestAllowedActionTypeIncludesVIPCoinRebateClaim(t *testing.T) {
// 返现通知由 wallet 事实触发,如果该动作从白名单回归,消息会在进入 MySQL 前被拒绝,
// Flutter 也就永远拿不到 rebate_id。这个纯单测不依赖可选的 MySQL 测试环境。
if !allowedActionType("vip_coin_rebate_claim") {
t.Fatal("vip_coin_rebate_claim must remain an allowed system inbox action")
}
if allowedActionType("vip_coin_rebate_claim_javascript") {
t.Fatal("action whitelist must still reject unregistered variants")
}
}

View File

@ -467,6 +467,10 @@ func allowedActionType(actionType string) bool {
switch actionType {
case "", "wallet_withdraw_detail", "host_application_detail", "activity_detail", "room_detail", "user_profile", "app_h5", "coin_seller_transfer_detail":
return true
case "vip_coin_rebate_claim":
// VIP 返现通知只携带 wallet 生成的 rebate_id JSON客户端仍需调用受鉴权的
// claim 接口才能入账;它不是任意外链或脚本动作,可以进入 system inbox。
return true
default:
return false
}

View File

@ -128,9 +128,58 @@ func (r *Repository) BatchGetUserLevelDisplayProfiles(ctx context.Context, userI
if err := rows.Err(); err != nil {
return domain.LevelDisplayProfiles{}, err
}
adminProfiles, err := r.BatchGetUserLevelAdminProfiles(ctx, userIDs, nowMS)
if err != nil {
return domain.LevelDisplayProfiles{}, err
}
adminTracks := make(map[int64]map[string]domain.AdminTrackProfile, len(adminProfiles.Profiles))
for _, profile := range adminProfiles.Profiles {
byTrack := make(map[string]domain.AdminTrackProfile, len(profile.Tracks))
for _, track := range profile.Tracks {
byTrack[track.Track] = track
}
adminTracks[profile.UserID] = byTrack
}
rulesByTrack := make(map[string][]domain.Rule, 2)
tiersByTrack := make(map[string][]domain.Tier, 2)
for _, track := range []string{domain.TrackWealth, domain.TrackCharm} {
rules, err := r.listActiveLevelRules(ctx, r.db, track)
if err != nil {
return domain.LevelDisplayProfiles{}, err
}
tiers, err := r.listActiveLevelTiers(ctx, r.db, track)
if err != nil {
return domain.LevelDisplayProfiles{}, err
}
rulesByTrack[track] = rules
tiersByTrack[track] = tiers
}
result := make([]domain.UserLevelDisplayProfile, 0, len(userIDs))
for _, userID := range userIDs {
result = append(result, profiles[userID])
item := profiles[userID]
// 物化资料仍是真实账户快照;整批 admin profile 只在读取时覆盖有效 wealth/charm overlay
// 既让到期毫秒立即生效,也避免房间资料批量接口按用户和轨道产生 N+1。
for _, track := range []string{domain.TrackWealth, domain.TrackCharm} {
adminTrack := adminTracks[userID][track]
if adminTrack.TemporaryLevelID == "" {
continue
}
rules := rulesByTrack[track]
tiers := tiersByTrack[track]
tier := tierForLevel(tiers, adminTrack.DisplayLevel)
badgeID, badgeLevel := levelRuleBadgeForLevel(rules, adminTrack.DisplayLevel)
display := domain.LevelDisplayTrackProfile{
Track: track, Level: adminTrack.DisplayLevel, TierID: tier.TierID,
AvatarFrameResourceID: levelRuleAvatarFrameForLevel(rules, adminTrack.DisplayLevel, tier.DisplayAvatarFrameResourceID),
BadgeResourceID: badgeID, BadgeSourceLevel: badgeLevel, UpdatedAtMS: nowMS,
}
if track == domain.TrackWealth {
item.Wealth = display
} else {
item.Charm = display
}
}
result = append(result, item)
}
return domain.LevelDisplayProfiles{Profiles: result, ServerTimeMS: nowMS}, nil
}
@ -287,6 +336,25 @@ func (r *Repository) consumeLevelEventOnce(ctx context.Context, event domain.Val
}
rewardJobCount = count
}
// While an overlay is active, only value events whose business time falls in [start,end) advance its displayed
// value. The real account above was updated independently and remains the sole permanent growth fact.
if temporary, found, err := r.activeTemporaryLevelForUpdate(ctx, tx, event.UserID, event.Track, nowMS); err != nil {
return domain.EventResult{}, err
} else if found && event.OccurredAtMS >= temporary.StartedAtMS && event.OccurredAtMS < temporary.ExpiresAtMS {
windowValue, err := r.temporaryWindowValue(ctx, tx, event.UserID, event.Track, temporary.StartedAtMS, temporary.ExpiresAtMS)
if err != nil {
return domain.EventResult{}, err
}
previousDisplayLevel := levelForValue(rules, temporary.TargetRequiredValue+windowValue-event.ValueDelta)
nextDisplayLevel := levelForValue(rules, temporary.TargetRequiredValue+windowValue)
if nextDisplayLevel > previousDisplayLevel {
count, err := r.createTemporaryRewardJobs(ctx, tx, event.UserID, event.Track, nextDisplayLevel, temporary.TemporaryLevelID, temporary.Generation, temporary.ExpiresAtMS, rules, tiers, nowMS)
if err != nil {
return domain.EventResult{}, err
}
rewardJobCount += count
}
}
badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, nextLevel)
avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, nextLevel, nextTier.DisplayAvatarFrameResourceID)
if err := r.upsertLevelDisplayProfile(ctx, tx, event.UserID, event.Track, nextLevel, nextTier, avatarFrameResourceID, badgeResourceID, badgeSourceLevel, nowMS); err != nil {
@ -539,11 +607,12 @@ func (r *Repository) ClaimPendingLevelRewardJobs(ctx context.Context, workerID s
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx, levelRewardSelectSQL()+`
WHERE app_code = ? AND status IN ('pending', 'failed') AND next_retry_at_ms <= ?
WHERE app_code = ? AND status IN ('pending', 'failed', 'running') AND next_retry_at_ms <= ?
AND (reward_origin = 'organic' OR is_permanent = 1 OR temporary_expires_at_ms > ?)
AND (locked_until_ms = 0 OR locked_until_ms <= ?)
ORDER BY created_at_ms ASC, reward_job_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED`,
appcode.FromContext(ctx), nowMS, nowMS, batchSize,
appcode.FromContext(ctx), nowMS, nowMS, nowMS, batchSize,
)
if err != nil {
return nil, err
@ -570,32 +639,141 @@ func (r *Repository) ClaimPendingLevelRewardJobs(ctx context.Context, workerID s
return jobs, nil
}
func (r *Repository) MarkLevelRewardGranted(ctx context.Context, rewardJobID string, walletGrantID string, nowMS int64) error {
func (r *Repository) MarkLevelRewardGranted(ctx context.Context, rewardJobID string, grants []domain.RewardWalletGrant, nowMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
firstGrantID := ""
for _, grant := range grants {
if strings.TrimSpace(grant.WalletGrantID) == "" {
continue
}
if firstGrantID == "" {
firstGrantID = grant.WalletGrantID
}
// Each external action has its own wallet command. INSERT IGNORE makes a crash after wallet success safe to replay.
_, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO growth_level_reward_wallet_grants
(app_code, reward_job_id, grant_kind, wallet_grant_id, revoke_status, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, 'none', ?, ?)`,
appcode.FromContext(ctx), rewardJobID, grant.GrantKind, grant.WalletGrantID, nowMS, nowMS)
if err != nil {
return err
}
}
_, err = tx.ExecContext(ctx, `
UPDATE growth_level_reward_jobs
SET status = 'granted', wallet_grant_id = ?, locked_by = '', locked_until_ms = 0,
failure_reason = '', updated_at_ms = ?
WHERE app_code = ? AND reward_job_id = ?`,
walletGrantID, nowMS, appcode.FromContext(ctx), rewardJobID,
firstGrantID, nowMS, appcode.FromContext(ctx), rewardJobID,
)
return err
if err != nil {
return err
}
var temporaryID string
var rewardLevel int32
var permanent bool
if err := tx.QueryRowContext(ctx, `SELECT temporary_level_id, reward_level, is_permanent FROM growth_level_reward_jobs WHERE app_code = ? AND reward_job_id = ? FOR UPDATE`, appcode.FromContext(ctx), rewardJobID).Scan(&temporaryID, &rewardLevel, &permanent); err != nil {
return err
}
if temporaryID != "" && !permanent {
var status string
var finalLevel int32
if err := tx.QueryRowContext(ctx, `SELECT status, final_level FROM growth_temporary_levels WHERE app_code = ? AND temporary_level_id = ?`, appcode.FromContext(ctx), temporaryID).Scan(&status, &finalLevel); err != nil {
return err
}
if status != domain.TemporaryLevelStatusActive && rewardLevel > finalLevel {
// A worker can finish after the overlay expires. Persist the grants, then immediately expose them to revoke worker.
revokeStatus := domain.RevokeStatusPending
if firstGrantID == "" {
revokeStatus = domain.RevokeStatusSkipped
}
if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_jobs SET revoke_status = ?, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ?`, revokeStatus, nowMS, appcode.FromContext(ctx), rewardJobID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_wallet_grants SET revoke_status = 'pending', updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ?`, nowMS, appcode.FromContext(ctx), rewardJobID); err != nil {
return err
}
}
}
return tx.Commit()
}
func (r *Repository) MarkLevelRewardFailed(ctx context.Context, rewardJobID string, failureReason string, nextRetryAtMS int64, nowMS int64) error {
func (r *Repository) MarkLevelRewardFailed(ctx context.Context, rewardJobID string, grants []domain.RewardWalletGrant, failureReason string, nextRetryAtMS int64, nowMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for _, grant := range grants {
if strings.TrimSpace(grant.WalletGrantID) == "" {
continue
}
// Partial wallet success is a durable revoke fact even though the parent reward remains retryable.
if _, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO growth_level_reward_wallet_grants
(app_code, reward_job_id, grant_kind, wallet_grant_id, revoke_status, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, 'none', ?, ?)`,
appcode.FromContext(ctx), rewardJobID, grant.GrantKind, grant.WalletGrantID, nowMS, nowMS); err != nil {
return err
}
}
result, err := tx.ExecContext(ctx, `
UPDATE growth_level_reward_jobs
SET status = 'failed', next_retry_at_ms = ?, locked_by = '', locked_until_ms = 0,
failure_reason = ?, updated_at_ms = ?
WHERE app_code = ? AND reward_job_id = ? AND status <> 'granted'`,
WHERE app_code = ? AND reward_job_id = ? AND status = 'running'`,
nextRetryAtMS, truncateGrowthFailure(failureReason), nowMS, appcode.FromContext(ctx), rewardJobID,
)
return err
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err != nil {
return err
} else if affected == 0 {
return xerr.New(xerr.Conflict, "level reward job is no longer running")
}
var temporaryID string
var rewardLevel int32
var permanent bool
if err := tx.QueryRowContext(ctx, `SELECT temporary_level_id, reward_level, is_permanent FROM growth_level_reward_jobs WHERE app_code = ? AND reward_job_id = ? FOR UPDATE`, appcode.FromContext(ctx), rewardJobID).Scan(&temporaryID, &rewardLevel, &permanent); err != nil {
return err
}
if temporaryID != "" && !permanent {
var status string
var finalLevel int32
if err := tx.QueryRowContext(ctx, `SELECT status, final_level FROM growth_temporary_levels WHERE app_code = ? AND temporary_level_id = ?`, appcode.FromContext(ctx), temporaryID).Scan(&status, &finalLevel); err != nil {
return err
}
if status != domain.TemporaryLevelStatusActive && rewardLevel > finalLevel {
// Expiry/supersede can race the wallet calls. If it already won, expose the successful prefix directly to
// the revoke worker instead of leaving a failed parent whose assets are invisible forever.
var persistedGrantCount int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM growth_level_reward_wallet_grants WHERE app_code = ? AND reward_job_id = ?`, appcode.FromContext(ctx), rewardJobID).Scan(&persistedGrantCount); err != nil {
return err
}
revokeStatus := domain.RevokeStatusSkipped
if persistedGrantCount > 0 {
revokeStatus = domain.RevokeStatusPending
}
if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_jobs SET status = 'canceled', revoke_status = ?, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ?`, revokeStatus, nowMS, appcode.FromContext(ctx), rewardJobID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_wallet_grants SET revoke_status = 'pending', revoke_next_retry_at_ms = 0, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ? AND revoke_status IN ('none','failed')`, nowMS, appcode.FromContext(ctx), rewardJobID); err != nil {
return err
}
}
}
return tx.Commit()
}
func (r *Repository) levelTrackOverview(ctx context.Context, userID int64, track domain.Track, nowMS int64) (domain.TrackOverview, error) {
@ -614,10 +792,25 @@ func (r *Repository) levelTrackOverview(ctx context.Context, userID int64, track
if err != nil {
return domain.TrackOverview{}, err
}
tier := tierByID(tiers, account.CurrentTierID)
badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, account.CurrentLevel)
avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, account.CurrentLevel, tier.DisplayAvatarFrameResourceID)
currentRequired, nextLevel, nextRequired := levelProgress(rules, account.CurrentLevel)
displayLevel := account.CurrentLevel
displayValue := account.TotalValue
if temporary, found, err := r.activeTemporaryLevelForRead(ctx, userID, track.Track, nowMS); err != nil {
return domain.TrackOverview{}, err
} else if found {
increment, err := r.temporaryWindowValue(ctx, r.db, userID, track.Track, temporary.StartedAtMS, temporary.ExpiresAtMS)
if err != nil {
return domain.TrackOverview{}, err
}
displayValue = temporary.TargetRequiredValue + increment
displayLevel = levelForValue(rules, displayValue)
if displayLevel < temporary.TargetLevel {
displayLevel = temporary.TargetLevel
}
}
tier := tierForLevel(tiers, displayLevel)
badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, displayLevel)
avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, displayLevel, tier.DisplayAvatarFrameResourceID)
currentRequired, nextLevel, nextRequired := levelProgress(rules, displayLevel)
pendingCount, err := r.pendingLevelRewardCount(ctx, userID, track.Track)
if err != nil {
return domain.TrackOverview{}, err
@ -625,9 +818,9 @@ func (r *Repository) levelTrackOverview(ctx context.Context, userID int64, track
return domain.TrackOverview{
Track: track.Track,
Name: track.Name,
Level: account.CurrentLevel,
TierID: account.CurrentTierID,
TotalValue: account.TotalValue,
Level: displayLevel,
TierID: tier.TierID,
TotalValue: displayValue,
CurrentLevelRequiredValue: currentRequired,
NextLevel: nextLevel,
NextLevelRequiredValue: nextRequired,
@ -757,14 +950,48 @@ func (r *Repository) createLevelRewardJobs(ctx context.Context, tx *sql.Tx, user
}
func (r *Repository) insertLevelRewardJob(ctx context.Context, tx *sql.Tx, userID int64, track string, sourceType string, sourceID string, resourceGroupID int64, nowMS int64) (bool, error) {
// Natural growth permanently earns only the matching reward attached to the currently active overlay. Superseded,
// expired or already-revoking generations must finish revoke and get a fresh organic job; otherwise multiple old
// grants (or an RPC-timeout revoke that actually succeeded) could all be promoted into duplicate permanent assets.
promoted, err := tx.ExecContext(ctx, `
UPDATE growth_level_reward_jobs j
JOIN growth_temporary_levels t
ON t.app_code = j.app_code AND t.temporary_level_id = j.temporary_level_id
SET j.is_permanent = 1, j.revoke_status = 'skipped', j.updated_at_ms = ?
WHERE j.app_code = ? AND j.user_id = ? AND j.track = ?
AND j.reward_source_type = ? AND j.reward_source_id = ?
AND j.reward_origin = 'temporary' AND j.is_permanent = 0
AND j.status IN ('pending','failed','running','granted') AND j.revoke_status = 'none'
AND t.status = 'active' AND t.started_at_ms <= ? AND t.expires_at_ms > ?`,
nowMS, appcode.FromContext(ctx), userID, track, sourceType, sourceID, nowMS, nowMS)
if err != nil {
return false, err
}
if affected, err := promoted.RowsAffected(); err != nil {
return false, err
} else if affected > 0 {
_, err = tx.ExecContext(ctx, `
UPDATE growth_level_reward_wallet_grants g
JOIN growth_level_reward_jobs j ON j.app_code = g.app_code AND j.reward_job_id = g.reward_job_id
JOIN growth_temporary_levels t ON t.app_code = j.app_code AND t.temporary_level_id = j.temporary_level_id
SET g.revoke_status = 'skipped', g.revoke_locked_by = '', g.revoke_locked_until_ms = 0, g.updated_at_ms = ?
WHERE j.app_code = ? AND j.user_id = ? AND j.track = ? AND j.reward_source_type = ? AND j.reward_source_id = ?
AND j.reward_origin = 'temporary' AND j.is_permanent = 1
AND t.status = 'active' AND t.started_at_ms <= ? AND t.expires_at_ms > ?
AND g.revoke_status IN ('none','pending','failed')`,
nowMS, appcode.FromContext(ctx), userID, track, sourceType, sourceID, nowMS, nowMS)
return false, err
}
rewardJobID := idgen.New("greward")
rewardLevel, _ := levelFromGrowthRewardSourceID(sourceID)
result, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO growth_level_reward_jobs (
app_code, reward_job_id, user_id, track, reward_source_type, reward_source_id,
resource_group_id, wallet_command_id, status, next_retry_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`,
resource_group_id, wallet_command_id, reward_origin, grant_generation, reward_level, is_permanent,
status, next_retry_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'organic', 0, ?, 1, 'pending', 0, ?, ?)`,
appcode.FromContext(ctx), rewardJobID, userID, track, sourceType, sourceID, resourceGroupID,
"growth_level_reward:"+rewardJobID, nowMS, nowMS,
"growth_level_reward:"+rewardJobID, rewardLevel, nowMS, nowMS,
)
if err != nil {
return false, err
@ -1289,6 +1516,8 @@ func scanLevelReward(row rowScanner) (domain.RewardJob, error) {
err := row.Scan(
&item.AppCode, &item.RewardJobID, &item.UserID, &item.Track, &item.RewardSourceType,
&item.RewardSourceID, &item.ResourceGroupID, &item.WalletCommandID, &item.WalletGrantID,
&item.RewardOrigin, &item.TemporaryLevelID, &item.GrantGeneration, &item.RewardLevel,
&item.Permanent, &item.TemporaryExpiresAtMS, &item.RevokeStatus,
&item.Status, &item.AttemptCount, &item.FailureReason, &item.CreatedAtMS, &item.UpdatedAtMS,
)
return item, err
@ -1311,7 +1540,8 @@ func levelTierSelectSQL() string {
func levelRewardSelectSQL() string {
return `SELECT app_code, reward_job_id, user_id, track, reward_source_type, reward_source_id,
resource_group_id, wallet_command_id, wallet_grant_id, status, attempt_count,
resource_group_id, wallet_command_id, wallet_grant_id, reward_origin, temporary_level_id,
grant_generation, reward_level, is_permanent, temporary_expires_at_ms, revoke_status, status, attempt_count,
failure_reason, created_at_ms, updated_at_ms
FROM growth_level_reward_jobs `
}

View File

@ -84,6 +84,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureTaskRewardPolicyTables(ctx); err != nil {
return err
}
if err := r.ensureTemporaryGrowthLevelTables(ctx); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,88 @@
package mysql
import "context"
// ensureTemporaryGrowthLevelTables keeps auto-migrated local/test databases compatible with the deploy migration.
// Production rollout still applies the explicit SQL before starting new binaries.
func (r *Repository) ensureTemporaryGrowthLevelTables(ctx context.Context) error {
additions := []struct {
column string
sql string
}{
{"reward_origin", `ALTER TABLE growth_level_reward_jobs ADD COLUMN reward_origin VARCHAR(32) NOT NULL DEFAULT 'organic' AFTER wallet_grant_id`},
{"temporary_level_id", `ALTER TABLE growth_level_reward_jobs ADD COLUMN temporary_level_id VARCHAR(96) NOT NULL DEFAULT '' AFTER reward_origin`},
{"grant_generation", `ALTER TABLE growth_level_reward_jobs ADD COLUMN grant_generation INT NOT NULL DEFAULT 0 AFTER temporary_level_id`},
{"reward_level", `ALTER TABLE growth_level_reward_jobs ADD COLUMN reward_level INT NOT NULL DEFAULT 0 AFTER grant_generation`},
{"is_permanent", `ALTER TABLE growth_level_reward_jobs ADD COLUMN is_permanent TINYINT(1) NOT NULL DEFAULT 1 AFTER reward_level`},
{"temporary_expires_at_ms", `ALTER TABLE growth_level_reward_jobs ADD COLUMN temporary_expires_at_ms BIGINT NOT NULL DEFAULT 0 AFTER is_permanent`},
{"revoke_status", `ALTER TABLE growth_level_reward_jobs ADD COLUMN revoke_status VARCHAR(32) NOT NULL DEFAULT 'none' AFTER temporary_expires_at_ms`},
}
for _, addition := range additions {
exists, err := r.columnExists(ctx, "growth_level_reward_jobs", addition.column)
if err != nil {
return err
}
if !exists {
if _, err := r.db.ExecContext(ctx, addition.sql); err != nil {
return err
}
}
}
oldIndex, err := r.indexExists(ctx, "growth_level_reward_jobs", "uk_growth_level_reward_once")
if err != nil {
return err
}
newIndex, err := r.indexExists(ctx, "growth_level_reward_jobs", "uk_growth_level_reward_generation")
if err != nil {
return err
}
if oldIndex {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE growth_level_reward_jobs DROP INDEX uk_growth_level_reward_once`); err != nil {
return err
}
}
if !newIndex {
if _, err := r.db.ExecContext(ctx, `CREATE UNIQUE INDEX uk_growth_level_reward_generation ON growth_level_reward_jobs (app_code, user_id, track, reward_source_type, reward_source_id, reward_origin, grant_generation)`); err != nil {
return err
}
}
statements := []string{
`CREATE TABLE IF NOT EXISTS growth_level_reward_wallet_grants (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', reward_job_id VARCHAR(96) NOT NULL,
grant_kind VARCHAR(32) NOT NULL, wallet_grant_id VARCHAR(96) NOT NULL,
revoke_status VARCHAR(32) NOT NULL DEFAULT 'none', revoke_attempt_count INT NOT NULL DEFAULT 0,
revoke_next_retry_at_ms BIGINT NOT NULL DEFAULT 0, revoke_locked_by VARCHAR(128) NOT NULL DEFAULT '',
revoke_locked_until_ms BIGINT NOT NULL DEFAULT 0, revoke_failure_reason VARCHAR(255) NOT NULL DEFAULT '',
revoked_at_ms BIGINT NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, reward_job_id, wallet_grant_id),
KEY idx_growth_reward_grant_revoke (app_code, revoke_status, revoke_next_retry_at_ms, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS growth_temporary_level_commands (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', command_id VARCHAR(128) NOT NULL,
request_hash VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, operator_admin_id BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, command_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS growth_temporary_levels (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', temporary_level_id VARCHAR(96) NOT NULL,
command_id VARCHAR(128) NOT NULL, user_id BIGINT NOT NULL, track VARCHAR(32) NOT NULL,
target_level INT NOT NULL, target_required_value BIGINT NOT NULL, baseline_total_value BIGINT NOT NULL,
started_at_ms BIGINT NOT NULL, expires_at_ms BIGINT NOT NULL, status VARCHAR(32) NOT NULL,
generation INT NOT NULL, operator_admin_id BIGINT NOT NULL, reason VARCHAR(255) NOT NULL DEFAULT '',
final_level INT NOT NULL DEFAULT 0, activation_notice_status VARCHAR(32) NOT NULL DEFAULT 'pending',
expiry_notice_status VARCHAR(32) NOT NULL DEFAULT 'skipped', attempt_count INT NOT NULL DEFAULT 0,
next_retry_at_ms BIGINT NOT NULL DEFAULT 0, locked_by VARCHAR(128) NOT NULL DEFAULT '',
locked_until_ms BIGINT NOT NULL DEFAULT 0, failure_reason VARCHAR(255) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, temporary_level_id),
UNIQUE KEY uk_growth_temp_command_track (app_code, command_id, track),
KEY idx_growth_temp_user_track (app_code, user_id, track, status, expires_at_ms),
KEY idx_growth_temp_work (app_code, status, next_retry_at_ms, expires_at_ms, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
}
for _, statement := range statements {
if _, err := r.db.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,761 @@
package mysql
import (
"context"
"database/sql"
"fmt"
"math"
"strconv"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/growth"
)
const temporaryLevelDayMS = int64(24 * time.Hour / time.Millisecond)
func (r *Repository) activeTemporaryLevelForUpdate(ctx context.Context, tx *sql.Tx, userID int64, track string, nowMS int64) (domain.TemporaryLevel, bool, error) {
row := tx.QueryRowContext(ctx, temporaryLevelSelectSQL()+`
WHERE app_code = ? AND user_id = ? AND track = ? AND status = 'active'
AND started_at_ms <= ? AND expires_at_ms > ? ORDER BY generation DESC LIMIT 1 FOR UPDATE`,
appcode.FromContext(ctx), userID, track, nowMS, nowMS)
var item domain.TemporaryLevel
err := row.Scan(&item.AppCode, &item.TemporaryLevelID, &item.CommandID, &item.UserID, &item.Track,
&item.TargetLevel, &item.TargetRequiredValue, &item.BaselineTotalValue, &item.StartedAtMS, &item.ExpiresAtMS,
&item.Status, &item.Generation, &item.OperatorAdminID, &item.Reason, &item.FinalLevel,
&item.ActivationNoticeStatus, &item.ExpiryNoticeStatus, &item.AttemptCount, &item.FailureReason,
&item.CreatedAtMS, &item.UpdatedAtMS)
if err == sql.ErrNoRows {
return domain.TemporaryLevel{}, false, nil
}
return item, err == nil, err
}
func (r *Repository) activeTemporaryLevelForRead(ctx context.Context, userID int64, track string, nowMS int64) (domain.TemporaryLevel, bool, error) {
row := r.db.QueryRowContext(ctx, temporaryLevelSelectSQL()+`
WHERE app_code = ? AND user_id = ? AND track = ? AND status = 'active'
AND started_at_ms <= ? AND expires_at_ms > ? ORDER BY generation DESC LIMIT 1`,
appcode.FromContext(ctx), userID, track, nowMS, nowMS)
var item domain.TemporaryLevel
err := row.Scan(&item.AppCode, &item.TemporaryLevelID, &item.CommandID, &item.UserID, &item.Track,
&item.TargetLevel, &item.TargetRequiredValue, &item.BaselineTotalValue, &item.StartedAtMS, &item.ExpiresAtMS,
&item.Status, &item.Generation, &item.OperatorAdminID, &item.Reason, &item.FinalLevel,
&item.ActivationNoticeStatus, &item.ExpiryNoticeStatus, &item.AttemptCount, &item.FailureReason,
&item.CreatedAtMS, &item.UpdatedAtMS)
if err == sql.ErrNoRows {
return domain.TemporaryLevel{}, false, nil
}
return item, err == nil, err
}
func levelFromGrowthRewardSourceID(sourceID string) (int32, bool) {
value := strings.TrimSpace(sourceID)
if !strings.HasPrefix(value, "level:") {
return 0, false
}
parsed, err := strconv.ParseInt(strings.TrimPrefix(value, "level:"), 10, 32)
if err != nil || parsed < 0 {
return 0, false
}
return int32(parsed), true
}
// BatchGetUserLevelAdminProfiles 一次读取整批真实账户和有效 overlay。列表页不能按用户×轨道产生 N+1 查询;
// expires_at_ms 仍在 SQL 中按 [start,end) 判定,保证 cron 延迟时读路径也不会继续展示临时等级。
func (r *Repository) BatchGetUserLevelAdminProfiles(ctx context.Context, userIDs []int64, nowMS int64) (domain.AdminLevelProfiles, error) {
if r == nil || r.db == nil {
return domain.AdminLevelProfiles{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
userIDs = uniquePositiveInt64s(userIDs)
if len(userIDs) == 0 {
return domain.AdminLevelProfiles{Profiles: []domain.AdminUserLevelProfile{}, ServerTimeMS: nowMS}, nil
}
profiles := make([]domain.AdminUserLevelProfile, len(userIDs))
profileIndex := make(map[int64]int, len(userIDs))
for index, userID := range userIDs {
profiles[index] = domain.AdminUserLevelProfile{UserID: userID, Tracks: []domain.AdminTrackProfile{
{Track: domain.TrackWealth}, {Track: domain.TrackGame}, {Track: domain.TrackCharm},
}, ServerTimeMS: nowMS}
profileIndex[userID] = index
}
trackIndex := map[string]int{domain.TrackWealth: 0, domain.TrackGame: 1, domain.TrackCharm: 2}
args := make([]any, 0, len(userIDs)+1)
args = append(args, appcode.FromContext(ctx))
for _, userID := range userIDs {
args = append(args, userID)
}
rows, err := r.db.QueryContext(ctx, `
SELECT user_id, track, total_value, current_level
FROM user_growth_level_accounts
WHERE app_code = ? AND user_id IN (`+placeholders(len(userIDs))+`)
AND track IN ('wealth','game','charm')`, args...)
if err != nil {
return domain.AdminLevelProfiles{}, err
}
for rows.Next() {
var userID int64
var track string
var totalValue int64
var level int32
if err := rows.Scan(&userID, &track, &totalValue, &level); err != nil {
_ = rows.Close()
return domain.AdminLevelProfiles{}, err
}
profilePosition, profileFound := profileIndex[userID]
trackPosition, trackFound := trackIndex[track]
if !profileFound || !trackFound {
continue
}
item := &profiles[profilePosition].Tracks[trackPosition]
item.RealTotalValue = totalValue
item.RealLevel = level
item.DisplayValue = totalValue
item.DisplayLevel = level
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return domain.AdminLevelProfiles{}, err
}
if err := rows.Close(); err != nil {
return domain.AdminLevelProfiles{}, err
}
rulesByTrack := make(map[string][]domain.Rule, 2)
for _, track := range []string{domain.TrackWealth, domain.TrackCharm} {
rules, err := r.listActiveLevelRules(ctx, r.db, track)
if err != nil {
return domain.AdminLevelProfiles{}, err
}
rulesByTrack[track] = rules
}
overlayArgs := make([]any, 0, len(userIDs)+3)
overlayArgs = append(overlayArgs, appcode.FromContext(ctx), nowMS, nowMS)
for _, userID := range userIDs {
overlayArgs = append(overlayArgs, userID)
}
rows, err = r.db.QueryContext(ctx, `
SELECT t.user_id, t.track, t.temporary_level_id, t.target_level, t.target_required_value,
t.started_at_ms, t.expires_at_ms, t.generation, COALESCE(SUM(e.value_delta), 0)
FROM growth_temporary_levels t
LEFT JOIN growth_level_value_events e
ON e.app_code = t.app_code AND e.user_id = t.user_id AND e.track = t.track
AND e.occurred_at_ms >= t.started_at_ms AND e.occurred_at_ms < t.expires_at_ms
WHERE t.app_code = ? AND t.status = 'active' AND t.started_at_ms <= ? AND t.expires_at_ms > ?
AND t.user_id IN (`+placeholders(len(userIDs))+`)
GROUP BY t.user_id, t.track, t.temporary_level_id, t.target_level, t.target_required_value,
t.started_at_ms, t.expires_at_ms, t.generation
ORDER BY t.user_id ASC, t.track ASC, t.generation DESC`, overlayArgs...)
if err != nil {
return domain.AdminLevelProfiles{}, err
}
seenOverlay := make(map[string]struct{}, len(userIDs)*2)
for rows.Next() {
var userID int64
var track string
var temporaryID string
var targetLevel int32
var targetRequiredValue int64
var startedAtMS, expiresAtMS int64
var generation int32
var windowIncrement int64
if err := rows.Scan(&userID, &track, &temporaryID, &targetLevel, &targetRequiredValue, &startedAtMS, &expiresAtMS, &generation, &windowIncrement); err != nil {
_ = rows.Close()
return domain.AdminLevelProfiles{}, err
}
key := fmt.Sprintf("%d:%s", userID, track)
if _, exists := seenOverlay[key]; exists {
continue
}
seenOverlay[key] = struct{}{}
profilePosition, profileFound := profileIndex[userID]
trackPosition, trackFound := trackIndex[track]
if !profileFound || !trackFound {
continue
}
item := &profiles[profilePosition].Tracks[trackPosition]
item.TemporaryLevelID = temporaryID
item.TemporaryTargetLevel = targetLevel
item.StartedAtMS = startedAtMS
item.ExpiresAtMS = expiresAtMS
item.DisplayValue = targetRequiredValue + windowIncrement
item.DisplayLevel = levelForValue(rulesByTrack[track], item.DisplayValue)
if item.DisplayLevel < targetLevel {
// 目标规则在下发时已校验并快照阈值;有效期内后续禁用配置不能让已生效 overlay 倒退。
item.DisplayLevel = targetLevel
}
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return domain.AdminLevelProfiles{}, err
}
if err := rows.Close(); err != nil {
return domain.AdminLevelProfiles{}, err
}
return domain.AdminLevelProfiles{Profiles: profiles, ServerTimeMS: nowMS}, nil
}
func (r *Repository) adminTrackProfile(ctx context.Context, q queryer, userID int64, track string, nowMS int64) (domain.AdminTrackProfile, error) {
item := domain.AdminTrackProfile{Track: track}
row := q.QueryRowContext(ctx, `
SELECT total_value, current_level
FROM user_growth_level_accounts
WHERE app_code = ? AND user_id = ? AND track = ?`, appcode.FromContext(ctx), userID, track)
if err := row.Scan(&item.RealTotalValue, &item.RealLevel); err != nil && err != sql.ErrNoRows {
return domain.AdminTrackProfile{}, err
}
item.DisplayLevel = item.RealLevel
item.DisplayValue = item.RealTotalValue
var targetRequired int64
err := q.QueryRowContext(ctx, `
SELECT temporary_level_id, target_level, target_required_value, started_at_ms, expires_at_ms
FROM growth_temporary_levels
WHERE app_code = ? AND user_id = ? AND track = ? AND status = 'active'
AND started_at_ms <= ? AND expires_at_ms > ?
ORDER BY generation DESC LIMIT 1`,
appcode.FromContext(ctx), userID, track, nowMS, nowMS,
).Scan(&item.TemporaryLevelID, &item.TemporaryTargetLevel, &targetRequired, &item.StartedAtMS, &item.ExpiresAtMS)
if err == sql.ErrNoRows {
return item, nil
}
if err != nil {
return domain.AdminTrackProfile{}, err
}
increment, err := r.temporaryWindowValue(ctx, q, userID, track, item.StartedAtMS, item.ExpiresAtMS)
if err != nil {
return domain.AdminTrackProfile{}, err
}
rules, err := r.listActiveLevelRules(ctx, q, track)
if err != nil {
return domain.AdminTrackProfile{}, err
}
item.DisplayValue = targetRequired + increment
item.DisplayLevel = levelForValue(rules, item.DisplayValue)
if item.DisplayLevel < item.TemporaryTargetLevel {
item.DisplayLevel = item.TemporaryTargetLevel
}
return item, nil
}
func (r *Repository) temporaryWindowValue(ctx context.Context, q queryer, userID int64, track string, startMS int64, endMS int64) (int64, error) {
var value int64
// occurred_at_ms, not processing time, defines the experience window. Late events outside the window cannot leak into overlay progress.
err := q.QueryRowContext(ctx, `
SELECT COALESCE(SUM(value_delta), 0)
FROM growth_level_value_events
WHERE app_code = ? AND user_id = ? AND track = ? AND occurred_at_ms >= ? AND occurred_at_ms < ?`,
appcode.FromContext(ctx), userID, track, startMS, endMS,
).Scan(&value)
return value, err
}
// AdjustTemporaryUserLevels locks both requested tracks in deterministic order and commits command idempotency,
// supersession, overlay rows and temporary reward jobs as one transaction.
func (r *Repository) AdjustTemporaryUserLevels(ctx context.Context, command domain.AdjustTemporaryLevelsCommand, nowMS int64) (domain.AdminUserLevelProfile, error) {
if r == nil || r.db == nil {
return domain.AdminUserLevelProfile{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.AdminUserLevelProfile{}, err
}
defer func() { _ = tx.Rollback() }()
result, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO growth_temporary_level_commands
(app_code, command_id, request_hash, user_id, operator_admin_id, created_at_ms)
VALUES (?, ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), command.CommandID, command.RequestHash, command.UserID, command.OperatorAdminID, nowMS)
if err != nil {
return domain.AdminUserLevelProfile{}, err
}
inserted, err := result.RowsAffected()
if err != nil {
return domain.AdminUserLevelProfile{}, err
}
if inserted == 0 {
var requestHash string
var userID int64
if err := tx.QueryRowContext(ctx, `SELECT request_hash, user_id FROM growth_temporary_level_commands WHERE app_code = ? AND command_id = ? FOR UPDATE`, appcode.FromContext(ctx), command.CommandID).Scan(&requestHash, &userID); err != nil {
return domain.AdminUserLevelProfile{}, err
}
if requestHash != command.RequestHash || userID != command.UserID {
return domain.AdminUserLevelProfile{}, xerr.New(xerr.IdempotencyConflict, "temporary level command payload conflicts with existing command")
}
if err := tx.Commit(); err != nil {
return domain.AdminUserLevelProfile{}, err
}
return r.singleAdminLevelProfile(ctx, command.UserID, nowMS)
}
for _, adjustment := range command.Adjustments {
account, err := r.ensureLevelAccountForUpdate(ctx, tx, command.UserID, adjustment.Track, nowMS)
if err != nil {
return domain.AdminUserLevelProfile{}, err
}
rules, err := r.listActiveLevelRules(ctx, tx, adjustment.Track)
if err != nil {
return domain.AdminUserLevelProfile{}, err
}
targetRequired, found := requiredValueForLevel(rules, adjustment.Level)
if !found {
return domain.AdminUserLevelProfile{}, xerr.New(xerr.NotFound, "active level rule not found")
}
if adjustment.Level <= account.CurrentLevel {
return domain.AdminUserLevelProfile{}, xerr.New(xerr.Conflict, "temporary target level must be higher than real level")
}
// 新调整只能覆盖仍在有效期内的 overlay先把已越过 end 边界但 cron 尚未处理的旧事实按正常到期语义物化。
if err := r.materializeExpiredTemporaryLevelsForUpdate(ctx, tx, command.UserID, adjustment.Track, rules, nowMS); err != nil {
return domain.AdminUserLevelProfile{}, err
}
generation, err := r.nextTemporaryGeneration(ctx, tx, command.UserID, adjustment.Track)
if err != nil {
return domain.AdminUserLevelProfile{}, err
}
// Superseded records never emit an expiry notice. Common rewards are moved to the new generation below;
// only rewards outside the new target remain associated with the old record and enter revoke processing.
oldIDs, err := r.supersedeTemporaryLevels(ctx, tx, command.UserID, adjustment.Track, nowMS)
if err != nil {
return domain.AdminUserLevelProfile{}, err
}
temporaryID := idgen.New("tmplevel")
durationMS := int64(adjustment.DurationDays) * temporaryLevelDayMS
if durationMS <= 0 || durationMS/temporaryLevelDayMS != int64(adjustment.DurationDays) || nowMS > math.MaxInt64-durationMS {
return domain.AdminUserLevelProfile{}, xerr.New(xerr.InvalidArgument, "temporary level duration exceeds epoch millisecond range")
}
expiresAtMS := nowMS + durationMS
_, err = tx.ExecContext(ctx, `
INSERT INTO growth_temporary_levels (
app_code, temporary_level_id, command_id, user_id, track, target_level, target_required_value,
baseline_total_value, started_at_ms, expires_at_ms, status, generation, operator_admin_id, reason,
activation_notice_status, expiry_notice_status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, 'pending', 'skipped', ?, ?)`,
appcode.FromContext(ctx), temporaryID, command.CommandID, command.UserID, adjustment.Track,
adjustment.Level, targetRequired, account.TotalValue, nowMS, expiresAtMS, generation,
command.OperatorAdminID, command.Reason, nowMS, nowMS,
)
if err != nil {
return domain.AdminUserLevelProfile{}, err
}
for _, oldID := range oldIDs {
if err := r.carryTemporaryRewardsToGeneration(ctx, tx, oldID, temporaryID, generation, adjustment.Level, expiresAtMS, nowMS); err != nil {
return domain.AdminUserLevelProfile{}, err
}
if err := r.prepareTemporaryRewardsForRevocation(ctx, tx, oldID, 0, nowMS); err != nil {
return domain.AdminUserLevelProfile{}, err
}
}
tiers, err := r.listActiveLevelTiers(ctx, tx, adjustment.Track)
if err != nil {
return domain.AdminUserLevelProfile{}, err
}
if _, err := r.createTemporaryRewardJobs(ctx, tx, command.UserID, adjustment.Track, adjustment.Level, temporaryID, generation, expiresAtMS, rules, tiers, nowMS); err != nil {
return domain.AdminUserLevelProfile{}, err
}
}
if err := tx.Commit(); err != nil {
return domain.AdminUserLevelProfile{}, err
}
return r.singleAdminLevelProfile(ctx, command.UserID, nowMS)
}
// carryTemporaryRewardsToGeneration transfers rewards that remain covered by the replacement target instead of
// revoking and regranting them. This makes one source/level have exactly one live temporary generation, so a later
// organic promotion cannot accidentally preserve both the old and the new wallet grants.
func (r *Repository) carryTemporaryRewardsToGeneration(ctx context.Context, tx *sql.Tx, oldTemporaryID string, newTemporaryID string, generation int32, targetLevel int32, expiresAtMS int64, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE growth_level_reward_jobs
SET temporary_level_id = ?, grant_generation = ?, temporary_expires_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND temporary_level_id = ? AND reward_origin = 'temporary'
AND is_permanent = 0 AND reward_level <= ?
AND status IN ('pending','failed','running','granted')
AND revoke_status = 'none'`,
newTemporaryID, generation, expiresAtMS, nowMS,
appcode.FromContext(ctx), oldTemporaryID, targetLevel,
)
return err
}
func (r *Repository) singleAdminLevelProfile(ctx context.Context, userID int64, nowMS int64) (domain.AdminUserLevelProfile, error) {
profiles, err := r.BatchGetUserLevelAdminProfiles(ctx, []int64{userID}, nowMS)
if err != nil {
return domain.AdminUserLevelProfile{}, err
}
if len(profiles.Profiles) == 0 {
return domain.AdminUserLevelProfile{UserID: userID, ServerTimeMS: nowMS}, nil
}
return profiles.Profiles[0], nil
}
func (r *Repository) nextTemporaryGeneration(ctx context.Context, tx *sql.Tx, userID int64, track string) (int32, error) {
var generation int32
err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(generation), 0) + 1 FROM growth_temporary_levels WHERE app_code = ? AND user_id = ? AND track = ?`, appcode.FromContext(ctx), userID, track).Scan(&generation)
return generation, err
}
func (r *Repository) supersedeTemporaryLevels(ctx context.Context, tx *sql.Tx, userID int64, track string, nowMS int64) ([]string, error) {
rows, err := tx.QueryContext(ctx, `SELECT temporary_level_id FROM growth_temporary_levels WHERE app_code = ? AND user_id = ? AND track = ? AND status = 'active' AND expires_at_ms > ? FOR UPDATE`, appcode.FromContext(ctx), userID, track, nowMS)
if err != nil {
return nil, err
}
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
_ = rows.Close()
return nil, err
}
ids = append(ids, id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if len(ids) > 0 {
_, err = tx.ExecContext(ctx, `UPDATE growth_temporary_levels SET status = 'superseded', expiry_notice_status = 'skipped', locked_by = '', locked_until_ms = 0, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND track = ? AND status = 'active' AND expires_at_ms > ?`, nowMS, appcode.FromContext(ctx), userID, track, nowMS)
}
return ids, err
}
func (r *Repository) materializeExpiredTemporaryLevelsForUpdate(ctx context.Context, tx *sql.Tx, userID int64, track string, rules []domain.Rule, nowMS int64) error {
rows, err := tx.QueryContext(ctx, temporaryLevelSelectSQL()+`
WHERE app_code = ? AND user_id = ? AND track = ? AND status = 'active' AND expires_at_ms <= ?
ORDER BY generation ASC FOR UPDATE`, appcode.FromContext(ctx), userID, track, nowMS)
if err != nil {
return err
}
items, err := scanTemporaryLevels(rows)
_ = rows.Close()
if err != nil {
return err
}
for _, item := range items {
increment, err := r.temporaryWindowValue(ctx, tx, item.UserID, item.Track, item.StartedAtMS, item.ExpiresAtMS)
if err != nil {
return err
}
item.FinalLevel = levelForValue(rules, item.BaselineTotalValue+increment)
if item.ActivationNoticeStatus == domain.NoticeStatusPending || item.ActivationNoticeStatus == domain.NoticeStatusFailed {
item.ActivationNoticeStatus = domain.NoticeStatusSkipped
}
if item.FinalLevel < item.TargetLevel {
item.ExpiryNoticeStatus = domain.NoticeStatusPending
} else {
item.ExpiryNoticeStatus = domain.NoticeStatusSkipped
}
if err := r.prepareTemporaryRewardsForRevocation(ctx, tx, item.TemporaryLevelID, item.FinalLevel, nowMS); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
UPDATE growth_temporary_levels
SET status = 'expired', final_level = ?, activation_notice_status = ?, expiry_notice_status = ?,
locked_by = '', locked_until_ms = 0, next_retry_at_ms = 0, updated_at_ms = ?
WHERE app_code = ? AND temporary_level_id = ? AND status = 'active'`,
item.FinalLevel, item.ActivationNoticeStatus, item.ExpiryNoticeStatus, nowMS,
appcode.FromContext(ctx), item.TemporaryLevelID,
); err != nil {
return err
}
}
return nil
}
func (r *Repository) createTemporaryRewardJobs(ctx context.Context, tx *sql.Tx, userID int64, track string, targetLevel int32, temporaryID string, generation int32, expiresAtMS int64, rules []domain.Rule, tiers []domain.Tier, nowMS int64) (int64, error) {
var created int64
for _, rule := range rules {
if rule.Level < 1 || rule.Level > targetLevel || (rule.RewardResourceGroupID <= 0 && avatarFrameResourceIDFromDisplayConfig(rule.DisplayConfigJSON) <= 0 && shortBadgeResourceIDFromDisplayConfig(rule.DisplayConfigJSON) <= 0) {
continue
}
changed, err := r.insertTemporaryRewardJob(ctx, tx, userID, track, domain.RewardSourceLevel, fmt.Sprintf("level:%d", rule.Level), rule.Level, rule.RewardResourceGroupID, temporaryID, generation, expiresAtMS, nowMS)
if err != nil {
return 0, err
}
if changed {
created++
}
}
for _, tier := range tiers {
if tier.MinLevel < 1 || tier.MinLevel > targetLevel || tier.RewardResourceGroupID <= 0 {
continue
}
changed, err := r.insertTemporaryRewardJob(ctx, tx, userID, track, domain.RewardSourceTier, fmt.Sprintf("tier:%d", tier.TierID), tier.MinLevel, tier.RewardResourceGroupID, temporaryID, generation, expiresAtMS, nowMS)
if err != nil {
return 0, err
}
if changed {
created++
}
}
return created, nil
}
func (r *Repository) insertTemporaryRewardJob(ctx context.Context, tx *sql.Tx, userID int64, track string, sourceType string, sourceID string, rewardLevel int32, resourceGroupID int64, temporaryID string, generation int32, expiresAtMS int64, nowMS int64) (bool, error) {
// Any organic/permanent row means this user already owns the reward; temporary generations must not duplicate it.
var permanentCount int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM growth_level_reward_jobs WHERE app_code = ? AND user_id = ? AND track = ? AND reward_source_type = ? AND reward_source_id = ? AND is_permanent = 1 AND status <> 'canceled'`, appcode.FromContext(ctx), userID, track, sourceType, sourceID).Scan(&permanentCount); err != nil {
return false, err
}
if permanentCount > 0 {
return false, nil
}
rewardJobID := idgen.New("greward")
result, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO growth_level_reward_jobs (
app_code, reward_job_id, user_id, track, reward_source_type, reward_source_id, resource_group_id,
wallet_command_id, reward_origin, temporary_level_id, grant_generation, reward_level,
is_permanent, temporary_expires_at_ms, revoke_status, status, next_retry_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'temporary', ?, ?, ?, 0, ?, 'none', 'pending', 0, ?, ?)`,
appcode.FromContext(ctx), rewardJobID, userID, track, sourceType, sourceID, resourceGroupID,
"growth_level_reward:"+rewardJobID, temporaryID, generation, rewardLevel, expiresAtMS, nowMS, nowMS)
if err != nil {
return false, err
}
affected, err := result.RowsAffected()
return affected > 0, err
}
// ClaimTemporaryLevelWork first materializes expiry facts in the same transaction, then leases only message work.
// Reward revocations are leased from their own child rows because one reward job may contain three wallet grants.
func (r *Repository) ClaimTemporaryLevelWork(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.TemporaryLevelWork, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx, temporaryLevelSelectSQL()+`
WHERE app_code = ? AND next_retry_at_ms <= ? AND (locked_until_ms = 0 OR locked_until_ms <= ?)
AND ((status = 'active' AND expires_at_ms <= ?)
OR (status = 'active' AND expires_at_ms > ? AND activation_notice_status IN ('pending','failed'))
OR (status = 'expired' AND expiry_notice_status IN ('pending','failed')))
ORDER BY expires_at_ms ASC, created_at_ms ASC LIMIT ? FOR UPDATE SKIP LOCKED`,
appcode.FromContext(ctx), nowMS, nowMS, nowMS, nowMS, batchSize)
if err != nil {
return nil, err
}
levels, err := scanTemporaryLevels(rows)
_ = rows.Close()
if err != nil {
return nil, err
}
works := make([]domain.TemporaryLevelWork, 0, len(levels))
lockUntil := nowMS + lockTTL.Milliseconds()
for _, item := range levels {
if item.Status == domain.TemporaryLevelStatusActive && item.ExpiresAtMS <= nowMS {
increment, err := r.temporaryWindowValue(ctx, tx, item.UserID, item.Track, item.StartedAtMS, item.ExpiresAtMS)
if err != nil {
return nil, err
}
rules, err := r.listActiveLevelRules(ctx, tx, item.Track)
if err != nil {
return nil, err
}
item.FinalLevel = levelForValue(rules, item.BaselineTotalValue+increment)
item.Status = domain.TemporaryLevelStatusExpired
if item.ActivationNoticeStatus == domain.NoticeStatusPending || item.ActivationNoticeStatus == domain.NoticeStatusFailed {
// A stale activation message after expiry is misleading; expiry truth is still sent when the user fell below target.
item.ActivationNoticeStatus = domain.NoticeStatusSkipped
}
if item.FinalLevel < item.TargetLevel {
item.ExpiryNoticeStatus = domain.NoticeStatusPending
} else {
item.ExpiryNoticeStatus = domain.NoticeStatusSkipped
}
if err := r.prepareTemporaryRewardsForRevocation(ctx, tx, item.TemporaryLevelID, item.FinalLevel, nowMS); err != nil {
return nil, err
}
_, err = tx.ExecContext(ctx, `
UPDATE growth_temporary_levels
SET status = 'expired', final_level = ?, activation_notice_status = ?, expiry_notice_status = ?, updated_at_ms = ?
WHERE app_code = ? AND temporary_level_id = ?`, item.FinalLevel, item.ActivationNoticeStatus, item.ExpiryNoticeStatus, nowMS, appcode.FromContext(ctx), item.TemporaryLevelID)
if err != nil {
return nil, err
}
}
work := domain.TemporaryLevelWork{TemporaryLevel: item}
work.SendActivationNotice = item.Status == domain.TemporaryLevelStatusActive && item.ExpiresAtMS > nowMS && (item.ActivationNoticeStatus == domain.NoticeStatusPending || item.ActivationNoticeStatus == domain.NoticeStatusFailed)
work.SendExpiryNotice = item.Status == domain.TemporaryLevelStatusExpired && (item.ExpiryNoticeStatus == domain.NoticeStatusPending || item.ExpiryNoticeStatus == domain.NoticeStatusFailed)
_, err := tx.ExecContext(ctx, `UPDATE growth_temporary_levels SET locked_by = ?, locked_until_ms = ?, attempt_count = attempt_count + 1, updated_at_ms = ? WHERE app_code = ? AND temporary_level_id = ?`, workerID, lockUntil, nowMS, appcode.FromContext(ctx), item.TemporaryLevelID)
if err != nil {
return nil, err
}
works = append(works, work)
}
if err := tx.Commit(); err != nil {
return nil, err
}
return works, nil
}
// prepareTemporaryRewardsForRevocation promotes rewards earned by the real final level and schedules every wallet
// grant above it for revoke. Ungranted jobs above the final level are canceled, preventing a late worker grant.
func (r *Repository) prepareTemporaryRewardsForRevocation(ctx context.Context, tx *sql.Tx, temporaryID string, finalLevel int32, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE growth_level_reward_jobs
SET is_permanent = 1, revoke_status = 'skipped', updated_at_ms = ?
WHERE app_code = ? AND temporary_level_id = ? AND is_permanent = 0 AND reward_level <= ?`,
nowMS, appcode.FromContext(ctx), temporaryID, finalLevel)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
UPDATE growth_level_reward_jobs
SET status = 'canceled',
revoke_status = CASE
WHEN EXISTS (SELECT 1 FROM growth_level_reward_wallet_grants g WHERE g.app_code = growth_level_reward_jobs.app_code AND g.reward_job_id = growth_level_reward_jobs.reward_job_id)
THEN 'pending' ELSE 'skipped' END,
locked_by = '', locked_until_ms = 0, updated_at_ms = ?
WHERE app_code = ? AND temporary_level_id = ? AND is_permanent = 0 AND reward_level > ? AND status IN ('pending','failed')`,
nowMS, appcode.FromContext(ctx), temporaryID, finalLevel)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
UPDATE growth_level_reward_jobs j
SET revoke_status = CASE
WHEN EXISTS (SELECT 1 FROM growth_level_reward_wallet_grants g WHERE g.app_code = j.app_code AND g.reward_job_id = j.reward_job_id)
THEN 'pending' ELSE 'skipped' END,
updated_at_ms = ?
WHERE j.app_code = ? AND j.temporary_level_id = ? AND j.is_permanent = 0 AND j.reward_level > ? AND j.status = 'granted'`,
nowMS, appcode.FromContext(ctx), temporaryID, finalLevel)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
UPDATE growth_level_reward_wallet_grants g
JOIN growth_level_reward_jobs j ON j.app_code = g.app_code AND j.reward_job_id = g.reward_job_id
SET g.revoke_status = 'pending', g.revoke_next_retry_at_ms = 0, g.updated_at_ms = ?
WHERE j.app_code = ? AND j.temporary_level_id = ? AND j.is_permanent = 0 AND j.reward_level > ?
AND j.status IN ('granted','canceled') AND g.revoke_status IN ('none','failed')`,
nowMS, appcode.FromContext(ctx), temporaryID, finalLevel)
return err
}
func (r *Repository) MarkTemporaryLevelNotices(ctx context.Context, temporaryLevelID string, activationStatus string, expiryStatus string, failureReason string, nextRetryAtMS int64, nowMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
sets := []string{"locked_by = ''", "locked_until_ms = 0", "failure_reason = ?", "next_retry_at_ms = ?", "updated_at_ms = ?"}
args := []any{truncateGrowthFailure(failureReason), nextRetryAtMS, nowMS}
if activationStatus != "" {
sets = append(sets, "activation_notice_status = ?")
args = append(args, activationStatus)
}
if expiryStatus != "" {
sets = append(sets, "expiry_notice_status = ?")
args = append(args, expiryStatus)
}
args = append(args, appcode.FromContext(ctx), temporaryLevelID)
_, err := r.db.ExecContext(ctx, `UPDATE growth_temporary_levels SET `+strings.Join(sets, ", ")+` WHERE app_code = ? AND temporary_level_id = ?`, args...)
return err
}
func (r *Repository) ClaimLevelRewardRevocations(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.RewardRevocation, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx, `
SELECT g.reward_job_id, g.wallet_grant_id, j.temporary_level_id, j.user_id, j.track, g.revoke_attempt_count
FROM growth_level_reward_wallet_grants g
JOIN growth_level_reward_jobs j ON j.app_code = g.app_code AND j.reward_job_id = g.reward_job_id
WHERE g.app_code = ? AND g.revoke_status IN ('pending','failed','running') AND g.revoke_next_retry_at_ms <= ?
AND (g.revoke_locked_until_ms = 0 OR g.revoke_locked_until_ms <= ?)
ORDER BY g.created_at_ms ASC LIMIT ? FOR UPDATE SKIP LOCKED`, appcode.FromContext(ctx), nowMS, nowMS, batchSize)
if err != nil {
return nil, err
}
items := make([]domain.RewardRevocation, 0)
for rows.Next() {
var item domain.RewardRevocation
if err := rows.Scan(&item.RewardJobID, &item.WalletGrantID, &item.TemporaryLevelID, &item.UserID, &item.Track, &item.AttemptCount); err != nil {
_ = rows.Close()
return nil, err
}
items = append(items, item)
}
_ = rows.Close()
lockUntil := nowMS + lockTTL.Milliseconds()
for _, item := range items {
_, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_wallet_grants SET revoke_status = 'running', revoke_attempt_count = revoke_attempt_count + 1, revoke_locked_by = ?, revoke_locked_until_ms = ?, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ? AND wallet_grant_id = ?`, workerID, lockUntil, nowMS, appcode.FromContext(ctx), item.RewardJobID, item.WalletGrantID)
if err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return items, nil
}
func (r *Repository) MarkLevelRewardRevoked(ctx context.Context, rewardJobID string, walletGrantID string, nowMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
_, err = tx.ExecContext(ctx, `UPDATE growth_level_reward_wallet_grants SET revoke_status = 'revoked', revoked_at_ms = ?, revoke_locked_by = '', revoke_locked_until_ms = 0, revoke_failure_reason = '', updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ? AND wallet_grant_id = ?`, nowMS, nowMS, appcode.FromContext(ctx), rewardJobID, walletGrantID)
if err != nil {
return err
}
var remaining int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM growth_level_reward_wallet_grants WHERE app_code = ? AND reward_job_id = ? AND revoke_status NOT IN ('revoked','skipped')`, appcode.FromContext(ctx), rewardJobID).Scan(&remaining); err != nil {
return err
}
if remaining == 0 {
if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_jobs SET revoke_status = 'revoked', updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ?`, nowMS, appcode.FromContext(ctx), rewardJobID); err != nil {
return err
}
}
return tx.Commit()
}
func (r *Repository) MarkLevelRewardRevokeFailed(ctx context.Context, rewardJobID string, walletGrantID string, failureReason string, nextRetryAtMS int64, nowMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_wallet_grants SET revoke_status = 'failed', revoke_next_retry_at_ms = ?, revoke_locked_by = '', revoke_locked_until_ms = 0, revoke_failure_reason = ?, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ? AND wallet_grant_id = ?`, nextRetryAtMS, truncateGrowthFailure(failureReason), nowMS, appcode.FromContext(ctx), rewardJobID, walletGrantID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_jobs SET revoke_status = 'failed', updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ? AND revoke_status <> 'revoked'`, nowMS, appcode.FromContext(ctx), rewardJobID); err != nil {
return err
}
return tx.Commit()
}
func temporaryLevelSelectSQL() string {
return `SELECT app_code, temporary_level_id, command_id, user_id, track, target_level, target_required_value,
baseline_total_value, started_at_ms, expires_at_ms, status, generation, operator_admin_id, reason,
final_level, activation_notice_status, expiry_notice_status, attempt_count, failure_reason,
created_at_ms, updated_at_ms FROM growth_temporary_levels `
}
func scanTemporaryLevels(rows *sql.Rows) ([]domain.TemporaryLevel, error) {
items := make([]domain.TemporaryLevel, 0)
for rows.Next() {
var item domain.TemporaryLevel
if err := rows.Scan(&item.AppCode, &item.TemporaryLevelID, &item.CommandID, &item.UserID, &item.Track,
&item.TargetLevel, &item.TargetRequiredValue, &item.BaselineTotalValue, &item.StartedAtMS, &item.ExpiresAtMS,
&item.Status, &item.Generation, &item.OperatorAdminID, &item.Reason, &item.FinalLevel,
&item.ActivationNoticeStatus, &item.ExpiryNoticeStatus, &item.AttemptCount, &item.FailureReason,
&item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}

View File

@ -85,6 +85,19 @@ func (s *CronServer) ProcessLevelRewardBatch(ctx context.Context, req *activityv
}, nil
}
// ProcessTemporaryLevelBatch advances expired overlays, sends owner-created inbox notices and revokes temporary grants.
func (s *CronServer) ProcessTemporaryLevelBatch(ctx context.Context, req *activityv1.CronBatchRequest) (*activityv1.CronBatchResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
if s.growth == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "growth service is not configured"))
}
claimed, processed, success, failure, hasMore, err := s.growth.ProcessTemporaryLevelBatch(ctx, req.GetWorkerId(), int(req.GetBatchSize()), durationFromMillis(req.GetLockTtlMs()))
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.CronBatchResponse{ClaimedCount: claimed, ProcessedCount: processed, SuccessCount: success, FailureCount: failure, HasMore: hasMore}, nil
}
// ProcessAchievementRewardBatch claims and grants pending achievement badge/resource rewards.
func (s *CronServer) ProcessAchievementRewardBatch(ctx context.Context, req *activityv1.CronBatchRequest) (*activityv1.CronBatchResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())

View File

@ -199,6 +199,35 @@ func (s *AdminGrowthLevelServer) ListLevelConfig(ctx context.Context, req *activ
return resp, nil
}
func (s *AdminGrowthLevelServer) BatchGetUserLevelAdminProfiles(ctx context.Context, req *activityv1.BatchGetUserLevelAdminProfilesRequest) (*activityv1.BatchGetUserLevelAdminProfilesResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
profiles, err := s.svc.BatchGetUserLevelAdminProfiles(ctx, req.GetUserIds())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &activityv1.BatchGetUserLevelAdminProfilesResponse{Profiles: make([]*activityv1.AdminUserLevelProfile, 0, len(profiles.Profiles)), ServerTimeMs: profiles.ServerTimeMS}
for _, profile := range profiles.Profiles {
resp.Profiles = append(resp.Profiles, adminUserLevelProfileToProto(profile))
}
return resp, nil
}
func (s *AdminGrowthLevelServer) AdjustTemporaryUserLevels(ctx context.Context, req *activityv1.AdjustTemporaryUserLevelsRequest) (*activityv1.AdjustTemporaryUserLevelsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
adjustments := make([]domain.TemporaryLevelAdjustment, 0, len(req.GetAdjustments()))
for _, item := range req.GetAdjustments() {
adjustments = append(adjustments, domain.TemporaryLevelAdjustment{Track: item.GetTrack(), Level: item.GetLevel(), DurationDays: item.GetDurationDays()})
}
profile, err := s.svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: req.GetCommandId(), UserID: req.GetUserId(), Adjustments: adjustments,
OperatorAdminID: req.GetOperatorAdminId(), Reason: req.GetReason(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.AdjustTemporaryUserLevelsResponse{Profile: adminUserLevelProfileToProto(profile), ServerTimeMs: profile.ServerTimeMS}, nil
}
func (s *AdminGrowthLevelServer) UpsertLevelTrack(ctx context.Context, req *activityv1.UpsertLevelTrackRequest) (*activityv1.UpsertLevelTrackResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
item, created, err := s.svc.UpsertLevelTrack(ctx, domain.TrackCommand{
@ -343,19 +372,26 @@ func levelDisplayTrackProfileToProto(item domain.LevelDisplayTrackProfile) *acti
func levelRewardToProto(item domain.RewardJob) *activityv1.LevelRewardJob {
return &activityv1.LevelRewardJob{
RewardJobId: item.RewardJobID,
UserId: item.UserID,
Track: item.Track,
RewardSourceType: item.RewardSourceType,
RewardSourceId: item.RewardSourceID,
ResourceGroupId: item.ResourceGroupID,
WalletCommandId: item.WalletCommandID,
WalletGrantId: item.WalletGrantID,
Status: item.Status,
AttemptCount: item.AttemptCount,
FailureReason: item.FailureReason,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
RewardJobId: item.RewardJobID,
UserId: item.UserID,
Track: item.Track,
RewardSourceType: item.RewardSourceType,
RewardSourceId: item.RewardSourceID,
ResourceGroupId: item.ResourceGroupID,
WalletCommandId: item.WalletCommandID,
WalletGrantId: item.WalletGrantID,
Status: item.Status,
AttemptCount: item.AttemptCount,
FailureReason: item.FailureReason,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
RewardOrigin: item.RewardOrigin,
TemporaryLevelId: item.TemporaryLevelID,
GrantGeneration: item.GrantGeneration,
RewardLevel: item.RewardLevel,
Permanent: item.Permanent,
TemporaryExpiresAtMs: item.TemporaryExpiresAtMS,
RevokeStatus: item.RevokeStatus,
}
}
@ -367,3 +403,16 @@ func registrationLevelBadgeGrantToProto(item domain.RegistrationLevelBadgeGrant)
GrantId: item.GrantID,
}
}
func adminUserLevelProfileToProto(item domain.AdminUserLevelProfile) *activityv1.AdminUserLevelProfile {
result := &activityv1.AdminUserLevelProfile{UserId: item.UserID, Tracks: make([]*activityv1.AdminLevelTrackProfile, 0, len(item.Tracks))}
for _, track := range item.Tracks {
result.Tracks = append(result.Tracks, &activityv1.AdminLevelTrackProfile{
Track: track.Track, RealLevel: track.RealLevel, RealTotalValue: track.RealTotalValue,
DisplayLevel: track.DisplayLevel, DisplayValue: track.DisplayValue,
TemporaryLevelId: track.TemporaryLevelID, TemporaryTargetLevel: track.TemporaryTargetLevel,
StartedAtMs: track.StartedAtMS, ExpiresAtMs: track.ExpiresAtMS,
})
}
return result
}

View File

@ -21,63 +21,77 @@ tasks:
timeout: "3s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
user_region_rebuild:
enabled: true
interval: "5s"
timeout: "5s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
message_fanout:
enabled: true
interval: "1s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
vip_daily_coin_rebate:
enabled: true
interval: "1m"
timeout: "30s"
lock_ttl: "1m"
batch_size: 500
app_codes: ["lalu","fami","huwaa"]
growth_level_reward:
enabled: true
interval: "5s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
temporary_growth_level:
enabled: true
interval: "5s"
timeout: "15s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu","fami","huwaa"]
weekly_star_settlement:
enabled: true
interval: "5s"
timeout: "20s"
lock_ttl: "1m"
batch_size: 50
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
agency_opening_settlement:
enabled: true
interval: "5s"
timeout: "20s"
lock_ttl: "1m"
batch_size: 50
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
room_turnover_reward_settlement:
enabled: true
interval: "5m"
timeout: "30s"
lock_ttl: "5m"
batch_size: 100
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
cp_intimacy_leaderboard:
enabled: true
interval: "5m"
timeout: "20s"
lock_ttl: "5m"
batch_size: 10000
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
game_level_event_relay:
enabled: true
interval: "5s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
mic_open_session_compensation:
enabled: true
interval: "60s"
@ -86,4 +100,26 @@ tasks:
batch_size: 100
pending_publish_max_age: "2m"
publishing_session_max_age: "12h"
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
room_open_session_compensation:
enabled: true
interval: "60s"
timeout: "10s"
lock_ttl: "60s"
batch_size: 200
open_session_max_age: "24h"
app_codes: ["lalu","fami","huwaa"]
manager_user_block_expiry:
enabled: true
interval: "30s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu","fami","huwaa"]
admin_user_ban_expiry:
enabled: true
interval: "30s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu","fami","huwaa"]

View File

@ -21,63 +21,77 @@ tasks:
timeout: "3s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
user_region_rebuild:
enabled: true
interval: "5s"
timeout: "5s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
message_fanout:
enabled: true
interval: "1s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
vip_daily_coin_rebate:
enabled: true
interval: "1m"
timeout: "30s"
lock_ttl: "1m"
batch_size: 500
app_codes: ["lalu","fami","huwaa"]
growth_level_reward:
enabled: true
interval: "5s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
temporary_growth_level:
enabled: true
interval: "5s"
timeout: "15s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu","fami","huwaa"]
weekly_star_settlement:
enabled: true
interval: "5s"
timeout: "20s"
lock_ttl: "1m"
batch_size: 50
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
agency_opening_settlement:
enabled: true
interval: "5s"
timeout: "20s"
lock_ttl: "1m"
batch_size: 50
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
room_turnover_reward_settlement:
enabled: true
interval: "5m"
timeout: "30s"
lock_ttl: "5m"
batch_size: 100
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
cp_intimacy_leaderboard:
enabled: true
interval: "5m"
timeout: "20s"
lock_ttl: "5m"
batch_size: 10000
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
game_level_event_relay:
enabled: true
interval: "5s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
mic_open_session_compensation:
enabled: true
interval: "60s"
@ -86,4 +100,26 @@ tasks:
batch_size: 100
pending_publish_max_age: "2m"
publishing_session_max_age: "12h"
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
room_open_session_compensation:
enabled: true
interval: "60s"
timeout: "10s"
lock_ttl: "60s"
batch_size: 200
open_session_max_age: "24h"
app_codes: ["lalu","fami","huwaa"]
manager_user_block_expiry:
enabled: true
interval: "30s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu","fami","huwaa"]
admin_user_ban_expiry:
enabled: true
interval: "30s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu","fami","huwaa"]

View File

@ -21,63 +21,77 @@ tasks:
timeout: "3s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
user_region_rebuild:
enabled: true
interval: "5s"
timeout: "5s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
message_fanout:
enabled: true
interval: "1s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 500
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
vip_daily_coin_rebate:
enabled: true
interval: "1m"
timeout: "30s"
lock_ttl: "1m"
batch_size: 500
app_codes: ["lalu","fami","huwaa"]
growth_level_reward:
enabled: true
interval: "5s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
temporary_growth_level:
enabled: true
interval: "5s"
timeout: "15s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu","fami","huwaa"]
weekly_star_settlement:
enabled: true
interval: "5s"
timeout: "20s"
lock_ttl: "1m"
batch_size: 50
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
agency_opening_settlement:
enabled: true
interval: "5s"
timeout: "20s"
lock_ttl: "1m"
batch_size: 50
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
room_turnover_reward_settlement:
enabled: true
interval: "5m"
timeout: "30s"
lock_ttl: "5m"
batch_size: 100
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
cp_intimacy_leaderboard:
enabled: true
interval: "5m"
timeout: "20s"
lock_ttl: "5m"
batch_size: 10000
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
game_level_event_relay:
enabled: true
interval: "5s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
mic_open_session_compensation:
enabled: true
interval: "60s"
@ -86,4 +100,26 @@ tasks:
batch_size: 100
pending_publish_max_age: "2m"
publishing_session_max_age: "12h"
app_codes: ["lalu"]
app_codes: ["lalu","fami","huwaa"]
room_open_session_compensation:
enabled: true
interval: "60s"
timeout: "10s"
lock_ttl: "60s"
batch_size: 200
open_session_max_age: "24h"
app_codes: ["lalu","fami","huwaa"]
manager_user_block_expiry:
enabled: true
interval: "30s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu","fami","huwaa"]
admin_user_ban_expiry:
enabled: true
interval: "30s"
timeout: "10s"
lock_ttl: "30s"
batch_size: 100
app_codes: ["lalu","fami","huwaa"]

View File

@ -116,12 +116,15 @@ func New(cfg config.Config) (*App, error) {
taskScheduler := scheduler.New(cfg.NodeID, cfg.Tasks, repository, map[string]scheduler.Handler{
"login_ip_risk": userCron.ProcessLoginIPRiskBatch,
"user_region_rebuild": userCron.ProcessRegionRebuildBatch,
"mic_open_session_compensation": userCron.CompensateMicOpenSessions,
"mic_open_session_compensation": userCron.CompensateMicOpenSessions,
"room_open_session_compensation": userCron.CompensateRoomOpenSessions,
"manager_user_block_expiry": userCron.ExpireManagerUserBlocks,
"admin_user_ban_expiry": userCron.ExpireAdminUserBans,
// CP 榜单任务只占 cron 租约并调用 user-service真实 MySQL 读取、wallet 头像框补齐和 Redis 替换都在 user-service 内完成。
"cp_intimacy_leaderboard": userCron.RefreshCPIntimacyLeaderboard,
"message_fanout": activityCron.ProcessMessageFanoutBatch,
"growth_level_reward": activityCron.ProcessLevelRewardBatch,
"temporary_growth_level": activityCron.ProcessTemporaryLevelBatch,
"achievement_reward": activityCron.ProcessAchievementRewardBatch,
"weekly_star_settlement": activityCron.ProcessWeeklyStarSettlementBatch,
"agency_opening_settlement": activityCron.ProcessAgencyOpeningSettlementBatch,
@ -129,6 +132,7 @@ func New(cfg config.Config) (*App, error) {
"room_turnover_reward_settlement": activityCron.ProcessRoomTurnoverRewardSettlementBatch,
"game_level_event_relay": gameCron.ProcessLevelEventOutboxBatch,
"host_salary_daily_settlement": walletCron.ProcessHostSalaryDailySettlementBatch,
"vip_daily_coin_rebate": walletCron.ProcessVIPDailyCoinRebateBatch,
"host_salary_half_month_settlement": walletCron.ProcessHostSalaryHalfMonthSettlementBatch,
"host_salary_month_end": walletCron.ProcessHostSalaryMonthEndBatch,
})

View File

@ -57,6 +57,8 @@ type TaskConfig struct {
PendingPublishMaxAge string `yaml:"pending_publish_max_age"`
// PublishingSessionMaxAge is used by mic_open_session_compensation for publishing sessions.
PublishingSessionMaxAge string `yaml:"publishing_session_max_age"`
// OpenSessionMaxAge is used by room_open_session_compensation for open in-room sessions.
OpenSessionMaxAge string `yaml:"open_session_max_age"`
}
// Default returns local development defaults that follow the 13xxx port rule.
@ -148,6 +150,16 @@ func defaultTasks() map[string]TaskConfig {
BatchSize: 500,
AppCodes: []string{defaultAppCode},
},
"vip_daily_coin_rebate": {
// scheduler 启动即执行,因此不能用 24h 间隔假装 UTC 0 点;每分钟触发一次,
// wallet owner 以 UTC task_day 的 completed run 快速 no-op并在跨日后创建新批次。
Enabled: true,
Interval: "1m",
Timeout: "30s",
LockTTL: "1m",
BatchSize: 500,
AppCodes: []string{"lalu", "fami"},
},
"growth_level_reward": {
Enabled: true,
Interval: "5s",
@ -156,6 +168,14 @@ func defaultTasks() map[string]TaskConfig {
BatchSize: 100,
AppCodes: []string{defaultAppCode},
},
"temporary_growth_level": {
Enabled: true,
Interval: "5s",
Timeout: "15s",
LockTTL: "30s",
BatchSize: 100,
AppCodes: []string{defaultAppCode},
},
"achievement_reward": {
Enabled: true,
Interval: "5s",
@ -246,6 +266,16 @@ func defaultTasks() map[string]TaskConfig {
PendingPublishMaxAge: "2m",
PublishingSessionMaxAge: "12h",
},
"room_open_session_compensation": {
Enabled: true,
Interval: "60s",
Timeout: "10s",
LockTTL: "60s",
BatchSize: 200,
AppCodes: []string{defaultAppCode},
// stale worker 2 分钟兜底断线离房24h 只兜 room-service 节点崩溃或 outbox 丢失的极端场景。
OpenSessionMaxAge: "24h",
},
"manager_user_block_expiry": {
Enabled: true,
Interval: "30s",
@ -254,6 +284,14 @@ func defaultTasks() map[string]TaskConfig {
BatchSize: 100,
AppCodes: []string{defaultAppCode},
},
"admin_user_ban_expiry": {
Enabled: true,
Interval: "30s",
Timeout: "10s",
LockTTL: "30s",
BatchSize: 100,
AppCodes: []string{defaultAppCode},
},
}
}
@ -303,6 +341,10 @@ func mergeTaskConfig(def TaskConfig, current TaskConfig) TaskConfig {
if current.PublishingSessionMaxAge == "" {
current.PublishingSessionMaxAge = def.PublishingSessionMaxAge
}
current.OpenSessionMaxAge = strings.TrimSpace(current.OpenSessionMaxAge)
if current.OpenSessionMaxAge == "" {
current.OpenSessionMaxAge = def.OpenSessionMaxAge
}
if current.BatchSize <= 0 {
current.BatchSize = def.BatchSize
}

View File

@ -32,3 +32,13 @@ func TestDefaultCPIntimacyLeaderboardCronTaskRunsEveryFiveMinutes(t *testing.T)
t.Fatalf("cp intimacy leaderboard cron task has unexpected defaults: %+v", task)
}
}
func TestDefaultVIPDailyCoinRebatePollsBothApps(t *testing.T) {
task, ok := Default().Tasks["vip_daily_coin_rebate"]
if !ok || !task.Enabled || task.Interval != "1m" || task.BatchSize != 500 {
t.Fatalf("VIP daily coin rebate task mismatch: %+v", task)
}
if len(task.AppCodes) != 2 || task.AppCodes[0] != "lalu" || task.AppCodes[1] != "fami" {
t.Fatalf("VIP rebate must be app-scoped for both configured apps: %+v", task.AppCodes)
}
}

View File

@ -36,6 +36,15 @@ func (c *ActivityCronClient) ProcessLevelRewardBatch(ctx context.Context, req sc
return activityCronBatchResult(resp), nil
}
// ProcessTemporaryLevelBatch handles overlay expiry, notice retries and wallet-grant revocations in activity-service.
func (c *ActivityCronClient) ProcessTemporaryLevelBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
resp, err := c.client.ProcessTemporaryLevelBatch(ctx, activityCronBatchRequest(req))
if err != nil {
return scheduler.BatchResult{}, err
}
return activityCronBatchResult(resp), nil
}
// ProcessAchievementRewardBatch handles achievement_reward_jobs.
func (c *ActivityCronClient) ProcessAchievementRewardBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
resp, err := c.client.ProcessAchievementRewardBatch(ctx, activityCronBatchRequest(req))

View File

@ -38,12 +38,24 @@ func (c *UserCronClient) CompensateMicOpenSessions(ctx context.Context, req sche
return userCronResult(resp), err
}
// CompensateRoomOpenSessions handles abnormal user_room_sessions closure.
func (c *UserCronClient) CompensateRoomOpenSessions(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
resp, err := c.client.CompensateRoomOpenSessions(ctx, userCronRequest(req))
return userCronResult(resp), err
}
// ExpireManagerUserBlocks handles timed manager-center bans after their unblock time.
func (c *UserCronClient) ExpireManagerUserBlocks(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
resp, err := c.client.ExpireManagerUserBlocks(ctx, userCronRequest(req))
return userCronResult(resp), err
}
// ExpireAdminUserBans handles timed admin bans; permanent bans are never selected by user-service.
func (c *UserCronClient) ExpireAdminUserBans(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
resp, err := c.client.ExpireAdminUserBans(ctx, userCronRequest(req))
return userCronResult(resp), err
}
// RefreshCPIntimacyLeaderboard 只触发 user-service 重建 CP 亲密值 zset 读模型cron 不读取业务表。
func (c *UserCronClient) RefreshCPIntimacyLeaderboard(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
// userCronRequest 会带 run_id、worker_id、app_code 和 batch_sizeuser-service 用这些值做日志追踪、租户隔离和刷新上限。
@ -66,6 +78,7 @@ func userCronRequest(req scheduler.BatchRequest) *userv1.CronBatchRequest {
LockTtlMs: req.LockTTL.Milliseconds(),
PendingPublishMaxAgeMs: req.PendingPublishMaxAge.Milliseconds(),
PublishingSessionMaxAgeMs: req.PublishingSessionMaxAge.Milliseconds(),
OpenSessionMaxAgeMs: req.OpenSessionMaxAge.Milliseconds(),
}
}

View File

@ -27,6 +27,25 @@ func (c *WalletCronClient) ProcessHostSalaryDailySettlementBatch(ctx context.Con
return walletCronBatchResult(resp), err
}
// ProcessVIPDailyCoinRebateBatch 每分钟触发 wallet owner 的 UTC 日 run。cron 不传 task_day
// 避免调度进程所在时区或延迟重启改变业务日期wallet 会以自己的 UTC 时钟创建/复用当日快照。
func (c *WalletCronClient) ProcessVIPDailyCoinRebateBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
resp, err := c.client.ProcessVipDailyCoinRebateBatch(ctx, &walletv1.ProcessVipDailyCoinRebateBatchRequest{
RequestId: req.RunID,
AppCode: req.AppCode,
BatchSize: int32(req.BatchSize),
})
if err != nil || resp == nil {
return scheduler.BatchResult{}, err
}
return scheduler.BatchResult{
ClaimedCount: int(resp.GetScannedCount()),
ProcessedCount: int(resp.GetScannedCount()),
SuccessCount: int(resp.GetCreatedCount()),
HasMore: resp.GetHasMore(),
}, nil
}
func (c *WalletCronClient) ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
if !isHostSalaryHalfMonthDue(walletCronNowUTC()) {
// 半月结是否适用于某个主播由后台工资政策 settlement_mode 决定cron 这里只负责在月中打开一次全局触发窗口。

View File

@ -38,6 +38,7 @@ type BatchRequest struct {
LockTTL time.Duration
PendingPublishMaxAge time.Duration
PublishingSessionMaxAge time.Duration
OpenSessionMaxAge time.Duration
}
// BatchResult summarizes one owner-service batch response.
@ -167,6 +168,7 @@ func (s *Scheduler) runOnce(ctx context.Context, taskName string, appCode string
LockTTL: lockTTL,
PendingPublishMaxAge: parseDuration(task.PendingPublishMaxAge, 0),
PublishingSessionMaxAge: parseDuration(task.PublishingSessionMaxAge, 0),
OpenSessionMaxAge: parseDuration(task.OpenSessionMaxAge, 0),
})
cancel()

Some files were not shown because too many files have changed in this diff Show More