邀请相关

This commit is contained in:
zhx 2026-07-23 18:58:52 +08:00
parent 1069d95e9b
commit 1bb1372381
45 changed files with 3948 additions and 2199 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1136,6 +1136,23 @@ message InviteActivityRewardInviteeSummary {
int64 invited_at_ms = 3; int64 invited_at_ms = 3;
} }
// ListInviteActivityInviteesRequest UTC
message ListInviteActivityInviteesRequest {
RequestMeta meta = 1;
int64 user_id = 2;
int32 page = 3;
int32 page_size = 4;
}
message ListInviteActivityInviteesResponse {
repeated InviteActivityRewardInviteeSummary invitees = 1;
int64 total = 2;
int32 page = 3;
int32 page_size = 4;
string cycle_key = 5;
int64 server_time_ms = 6;
}
// InviteActivityRewardSummary // InviteActivityRewardSummary
message InviteActivityRewardSummary { message InviteActivityRewardSummary {
string app_code = 1; string app_code = 1;
@ -3165,6 +3182,7 @@ service InviteActivityRewardService {
rpc GetInviteActivityRewardStatus(GetInviteActivityRewardStatusRequest) returns (GetInviteActivityRewardStatusResponse); rpc GetInviteActivityRewardStatus(GetInviteActivityRewardStatusRequest) returns (GetInviteActivityRewardStatusResponse);
rpc ClaimInviteActivityReward(ClaimInviteActivityRewardRequest) returns (ClaimInviteActivityRewardResponse); rpc ClaimInviteActivityReward(ClaimInviteActivityRewardRequest) returns (ClaimInviteActivityRewardResponse);
rpc ListInviteActivityLeaderboard(ListInviteActivityLeaderboardRequest) returns (ListInviteActivityLeaderboardResponse); rpc ListInviteActivityLeaderboard(ListInviteActivityLeaderboardRequest) returns (ListInviteActivityLeaderboardResponse);
rpc ListInviteActivityInvitees(ListInviteActivityInviteesRequest) returns (ListInviteActivityInviteesResponse);
} }
// AdminCumulativeRechargeRewardService 访 // AdminCumulativeRechargeRewardService 访

View File

@ -4342,6 +4342,7 @@ const (
InviteActivityRewardService_GetInviteActivityRewardStatus_FullMethodName = "/hyapp.activity.v1.InviteActivityRewardService/GetInviteActivityRewardStatus" InviteActivityRewardService_GetInviteActivityRewardStatus_FullMethodName = "/hyapp.activity.v1.InviteActivityRewardService/GetInviteActivityRewardStatus"
InviteActivityRewardService_ClaimInviteActivityReward_FullMethodName = "/hyapp.activity.v1.InviteActivityRewardService/ClaimInviteActivityReward" InviteActivityRewardService_ClaimInviteActivityReward_FullMethodName = "/hyapp.activity.v1.InviteActivityRewardService/ClaimInviteActivityReward"
InviteActivityRewardService_ListInviteActivityLeaderboard_FullMethodName = "/hyapp.activity.v1.InviteActivityRewardService/ListInviteActivityLeaderboard" InviteActivityRewardService_ListInviteActivityLeaderboard_FullMethodName = "/hyapp.activity.v1.InviteActivityRewardService/ListInviteActivityLeaderboard"
InviteActivityRewardService_ListInviteActivityInvitees_FullMethodName = "/hyapp.activity.v1.InviteActivityRewardService/ListInviteActivityInvitees"
) )
// InviteActivityRewardServiceClient is the client API for InviteActivityRewardService service. // InviteActivityRewardServiceClient is the client API for InviteActivityRewardService service.
@ -4353,6 +4354,7 @@ type InviteActivityRewardServiceClient interface {
GetInviteActivityRewardStatus(ctx context.Context, in *GetInviteActivityRewardStatusRequest, opts ...grpc.CallOption) (*GetInviteActivityRewardStatusResponse, error) GetInviteActivityRewardStatus(ctx context.Context, in *GetInviteActivityRewardStatusRequest, opts ...grpc.CallOption) (*GetInviteActivityRewardStatusResponse, error)
ClaimInviteActivityReward(ctx context.Context, in *ClaimInviteActivityRewardRequest, opts ...grpc.CallOption) (*ClaimInviteActivityRewardResponse, error) ClaimInviteActivityReward(ctx context.Context, in *ClaimInviteActivityRewardRequest, opts ...grpc.CallOption) (*ClaimInviteActivityRewardResponse, error)
ListInviteActivityLeaderboard(ctx context.Context, in *ListInviteActivityLeaderboardRequest, opts ...grpc.CallOption) (*ListInviteActivityLeaderboardResponse, error) ListInviteActivityLeaderboard(ctx context.Context, in *ListInviteActivityLeaderboardRequest, opts ...grpc.CallOption) (*ListInviteActivityLeaderboardResponse, error)
ListInviteActivityInvitees(ctx context.Context, in *ListInviteActivityInviteesRequest, opts ...grpc.CallOption) (*ListInviteActivityInviteesResponse, error)
} }
type inviteActivityRewardServiceClient struct { type inviteActivityRewardServiceClient struct {
@ -4393,6 +4395,16 @@ func (c *inviteActivityRewardServiceClient) ListInviteActivityLeaderboard(ctx co
return out, nil return out, nil
} }
func (c *inviteActivityRewardServiceClient) ListInviteActivityInvitees(ctx context.Context, in *ListInviteActivityInviteesRequest, opts ...grpc.CallOption) (*ListInviteActivityInviteesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListInviteActivityInviteesResponse)
err := c.cc.Invoke(ctx, InviteActivityRewardService_ListInviteActivityInvitees_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// InviteActivityRewardServiceServer is the server API for InviteActivityRewardService service. // InviteActivityRewardServiceServer is the server API for InviteActivityRewardService service.
// All implementations must embed UnimplementedInviteActivityRewardServiceServer // All implementations must embed UnimplementedInviteActivityRewardServiceServer
// for forward compatibility. // for forward compatibility.
@ -4402,6 +4414,7 @@ type InviteActivityRewardServiceServer interface {
GetInviteActivityRewardStatus(context.Context, *GetInviteActivityRewardStatusRequest) (*GetInviteActivityRewardStatusResponse, error) GetInviteActivityRewardStatus(context.Context, *GetInviteActivityRewardStatusRequest) (*GetInviteActivityRewardStatusResponse, error)
ClaimInviteActivityReward(context.Context, *ClaimInviteActivityRewardRequest) (*ClaimInviteActivityRewardResponse, error) ClaimInviteActivityReward(context.Context, *ClaimInviteActivityRewardRequest) (*ClaimInviteActivityRewardResponse, error)
ListInviteActivityLeaderboard(context.Context, *ListInviteActivityLeaderboardRequest) (*ListInviteActivityLeaderboardResponse, error) ListInviteActivityLeaderboard(context.Context, *ListInviteActivityLeaderboardRequest) (*ListInviteActivityLeaderboardResponse, error)
ListInviteActivityInvitees(context.Context, *ListInviteActivityInviteesRequest) (*ListInviteActivityInviteesResponse, error)
mustEmbedUnimplementedInviteActivityRewardServiceServer() mustEmbedUnimplementedInviteActivityRewardServiceServer()
} }
@ -4421,6 +4434,9 @@ func (UnimplementedInviteActivityRewardServiceServer) ClaimInviteActivityReward(
func (UnimplementedInviteActivityRewardServiceServer) ListInviteActivityLeaderboard(context.Context, *ListInviteActivityLeaderboardRequest) (*ListInviteActivityLeaderboardResponse, error) { func (UnimplementedInviteActivityRewardServiceServer) ListInviteActivityLeaderboard(context.Context, *ListInviteActivityLeaderboardRequest) (*ListInviteActivityLeaderboardResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListInviteActivityLeaderboard not implemented") return nil, status.Error(codes.Unimplemented, "method ListInviteActivityLeaderboard not implemented")
} }
func (UnimplementedInviteActivityRewardServiceServer) ListInviteActivityInvitees(context.Context, *ListInviteActivityInviteesRequest) (*ListInviteActivityInviteesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListInviteActivityInvitees not implemented")
}
func (UnimplementedInviteActivityRewardServiceServer) mustEmbedUnimplementedInviteActivityRewardServiceServer() { func (UnimplementedInviteActivityRewardServiceServer) mustEmbedUnimplementedInviteActivityRewardServiceServer() {
} }
func (UnimplementedInviteActivityRewardServiceServer) testEmbeddedByValue() {} func (UnimplementedInviteActivityRewardServiceServer) testEmbeddedByValue() {}
@ -4497,6 +4513,24 @@ func _InviteActivityRewardService_ListInviteActivityLeaderboard_Handler(srv inte
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _InviteActivityRewardService_ListInviteActivityInvitees_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListInviteActivityInviteesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(InviteActivityRewardServiceServer).ListInviteActivityInvitees(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: InviteActivityRewardService_ListInviteActivityInvitees_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(InviteActivityRewardServiceServer).ListInviteActivityInvitees(ctx, req.(*ListInviteActivityInviteesRequest))
}
return interceptor(ctx, in, info, handler)
}
// InviteActivityRewardService_ServiceDesc is the grpc.ServiceDesc for InviteActivityRewardService service. // InviteActivityRewardService_ServiceDesc is the grpc.ServiceDesc for InviteActivityRewardService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@ -4516,6 +4550,10 @@ var InviteActivityRewardService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListInviteActivityLeaderboard", MethodName: "ListInviteActivityLeaderboard",
Handler: _InviteActivityRewardService_ListInviteActivityLeaderboard_Handler, Handler: _InviteActivityRewardService_ListInviteActivityLeaderboard_Handler,
}, },
{
MethodName: "ListInviteActivityInvitees",
Handler: _InviteActivityRewardService_ListInviteActivityInvitees_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto", Metadata: "proto/activity/v1/activity.proto",

File diff suppressed because it is too large Load Diff

View File

@ -2250,6 +2250,28 @@ message GetMyVipResponse {
VipState state = 2; VipState state = 2;
} }
// UserEffectiveVipLevel wallet VIP
// level=0/source=none App
message UserEffectiveVipLevel {
int64 user_id = 1;
int32 level = 2;
int64 expires_at_ms = 3;
// source: paid/trial/none
string source = 4;
}
// BatchGetUserEffectiveVipLevelsRequest 100
message BatchGetUserEffectiveVipLevelsRequest {
string request_id = 1;
string app_code = 2;
repeated int64 user_ids = 3;
}
message BatchGetUserEffectiveVipLevelsResponse {
repeated UserEffectiveVipLevel users = 1;
int64 evaluated_at_ms = 2;
}
message PurchaseVipRequest { message PurchaseVipRequest {
string command_id = 1; string command_id = 1;
string app_code = 2; string app_code = 2;
@ -3279,6 +3301,7 @@ service WalletService {
rpc ListVipPackages(ListVipPackagesRequest) returns (ListVipPackagesResponse); rpc ListVipPackages(ListVipPackagesRequest) returns (ListVipPackagesResponse);
rpc GetVipProgramConfig(GetVipProgramConfigRequest) returns (GetVipProgramConfigResponse); rpc GetVipProgramConfig(GetVipProgramConfigRequest) returns (GetVipProgramConfigResponse);
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse); rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
rpc BatchGetUserEffectiveVipLevels(BatchGetUserEffectiveVipLevelsRequest) returns (BatchGetUserEffectiveVipLevelsResponse);
rpc CheckVipBenefit(CheckVipBenefitRequest) returns (CheckVipBenefitResponse); rpc CheckVipBenefit(CheckVipBenefitRequest) returns (CheckVipBenefitResponse);
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse); rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
rpc DebitCPBreakupFee(DebitCPBreakupFeeRequest) returns (DebitCPBreakupFeeResponse); rpc DebitCPBreakupFee(DebitCPBreakupFeeRequest) returns (DebitCPBreakupFeeResponse);

View File

@ -337,6 +337,7 @@ const (
WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages" WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages"
WalletService_GetVipProgramConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetVipProgramConfig" WalletService_GetVipProgramConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetVipProgramConfig"
WalletService_GetMyVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyVip" WalletService_GetMyVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyVip"
WalletService_BatchGetUserEffectiveVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/BatchGetUserEffectiveVipLevels"
WalletService_CheckVipBenefit_FullMethodName = "/hyapp.wallet.v1.WalletService/CheckVipBenefit" WalletService_CheckVipBenefit_FullMethodName = "/hyapp.wallet.v1.WalletService/CheckVipBenefit"
WalletService_PurchaseVip_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseVip" WalletService_PurchaseVip_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseVip"
WalletService_DebitCPBreakupFee_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitCPBreakupFee" WalletService_DebitCPBreakupFee_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitCPBreakupFee"
@ -475,6 +476,7 @@ type WalletServiceClient interface {
ListVipPackages(ctx context.Context, in *ListVipPackagesRequest, opts ...grpc.CallOption) (*ListVipPackagesResponse, error) ListVipPackages(ctx context.Context, in *ListVipPackagesRequest, opts ...grpc.CallOption) (*ListVipPackagesResponse, error)
GetVipProgramConfig(ctx context.Context, in *GetVipProgramConfigRequest, opts ...grpc.CallOption) (*GetVipProgramConfigResponse, error) GetVipProgramConfig(ctx context.Context, in *GetVipProgramConfigRequest, opts ...grpc.CallOption) (*GetVipProgramConfigResponse, error)
GetMyVip(ctx context.Context, in *GetMyVipRequest, opts ...grpc.CallOption) (*GetMyVipResponse, error) GetMyVip(ctx context.Context, in *GetMyVipRequest, opts ...grpc.CallOption) (*GetMyVipResponse, error)
BatchGetUserEffectiveVipLevels(ctx context.Context, in *BatchGetUserEffectiveVipLevelsRequest, opts ...grpc.CallOption) (*BatchGetUserEffectiveVipLevelsResponse, error)
CheckVipBenefit(ctx context.Context, in *CheckVipBenefitRequest, opts ...grpc.CallOption) (*CheckVipBenefitResponse, error) CheckVipBenefit(ctx context.Context, in *CheckVipBenefitRequest, opts ...grpc.CallOption) (*CheckVipBenefitResponse, error)
PurchaseVip(ctx context.Context, in *PurchaseVipRequest, opts ...grpc.CallOption) (*PurchaseVipResponse, error) PurchaseVip(ctx context.Context, in *PurchaseVipRequest, opts ...grpc.CallOption) (*PurchaseVipResponse, error)
DebitCPBreakupFee(ctx context.Context, in *DebitCPBreakupFeeRequest, opts ...grpc.CallOption) (*DebitCPBreakupFeeResponse, error) DebitCPBreakupFee(ctx context.Context, in *DebitCPBreakupFeeRequest, opts ...grpc.CallOption) (*DebitCPBreakupFeeResponse, error)
@ -1495,6 +1497,16 @@ func (c *walletServiceClient) GetMyVip(ctx context.Context, in *GetMyVipRequest,
return out, nil return out, nil
} }
func (c *walletServiceClient) BatchGetUserEffectiveVipLevels(ctx context.Context, in *BatchGetUserEffectiveVipLevelsRequest, opts ...grpc.CallOption) (*BatchGetUserEffectiveVipLevelsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchGetUserEffectiveVipLevelsResponse)
err := c.cc.Invoke(ctx, WalletService_BatchGetUserEffectiveVipLevels_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) CheckVipBenefit(ctx context.Context, in *CheckVipBenefitRequest, opts ...grpc.CallOption) (*CheckVipBenefitResponse, error) { func (c *walletServiceClient) CheckVipBenefit(ctx context.Context, in *CheckVipBenefitRequest, opts ...grpc.CallOption) (*CheckVipBenefitResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CheckVipBenefitResponse) out := new(CheckVipBenefitResponse)
@ -1901,6 +1913,7 @@ type WalletServiceServer interface {
ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error) ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error)
GetVipProgramConfig(context.Context, *GetVipProgramConfigRequest) (*GetVipProgramConfigResponse, error) GetVipProgramConfig(context.Context, *GetVipProgramConfigRequest) (*GetVipProgramConfigResponse, error)
GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error) GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error)
BatchGetUserEffectiveVipLevels(context.Context, *BatchGetUserEffectiveVipLevelsRequest) (*BatchGetUserEffectiveVipLevelsResponse, error)
CheckVipBenefit(context.Context, *CheckVipBenefitRequest) (*CheckVipBenefitResponse, error) CheckVipBenefit(context.Context, *CheckVipBenefitRequest) (*CheckVipBenefitResponse, error)
PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error) PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error)
DebitCPBreakupFee(context.Context, *DebitCPBreakupFeeRequest) (*DebitCPBreakupFeeResponse, error) DebitCPBreakupFee(context.Context, *DebitCPBreakupFeeRequest) (*DebitCPBreakupFeeResponse, error)
@ -2235,6 +2248,9 @@ func (UnimplementedWalletServiceServer) GetVipProgramConfig(context.Context, *Ge
func (UnimplementedWalletServiceServer) GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error) { func (UnimplementedWalletServiceServer) GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetMyVip not implemented") return nil, status.Error(codes.Unimplemented, "method GetMyVip not implemented")
} }
func (UnimplementedWalletServiceServer) BatchGetUserEffectiveVipLevels(context.Context, *BatchGetUserEffectiveVipLevelsRequest) (*BatchGetUserEffectiveVipLevelsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method BatchGetUserEffectiveVipLevels not implemented")
}
func (UnimplementedWalletServiceServer) CheckVipBenefit(context.Context, *CheckVipBenefitRequest) (*CheckVipBenefitResponse, error) { func (UnimplementedWalletServiceServer) CheckVipBenefit(context.Context, *CheckVipBenefitRequest) (*CheckVipBenefitResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CheckVipBenefit not implemented") return nil, status.Error(codes.Unimplemented, "method CheckVipBenefit not implemented")
} }
@ -4110,6 +4126,24 @@ func _WalletService_GetMyVip_Handler(srv interface{}, ctx context.Context, dec f
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _WalletService_BatchGetUserEffectiveVipLevels_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchGetUserEffectiveVipLevelsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).BatchGetUserEffectiveVipLevels(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_BatchGetUserEffectiveVipLevels_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).BatchGetUserEffectiveVipLevels(ctx, req.(*BatchGetUserEffectiveVipLevelsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_CheckVipBenefit_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _WalletService_CheckVipBenefit_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CheckVipBenefitRequest) in := new(CheckVipBenefitRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -5049,6 +5083,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetMyVip", MethodName: "GetMyVip",
Handler: _WalletService_GetMyVip_Handler, Handler: _WalletService_GetMyVip_Handler,
}, },
{
MethodName: "BatchGetUserEffectiveVipLevels",
Handler: _WalletService_BatchGetUserEffectiveVipLevels_Handler,
},
{ {
MethodName: "CheckVipBenefit", MethodName: "CheckVipBenefit",
Handler: _WalletService_CheckVipBenefit_Handler, Handler: _WalletService_CheckVipBenefit_Handler,

View File

@ -150,6 +150,8 @@ program 启用时的优先级固定为:`trial_card_enabled=true` 且有效已
初始化时高等级行已展开为完整集合,但这是 seed 结果,不是运行时继承规则。后台可以显式移除任一等级的某项权益。 初始化时高等级行已展开为完整集合,但这是 seed 结果,不是运行时继承规则。后台可以显式移除任一等级的某项权益。
资源发放分为两种语义:座驾、头像框等收藏型装扮在购买高等级 VIP 时可累计目标等级及以下的显式资源;`vip_title`(称号)、`vip_badge_identity`VIP 长徽章)和 `vip_medal`VIP 短徽章是当前身份资源只发放最终有效等级。VIP 升级或更高体验卡生效时wallet 在同一事务中补齐当前等级身份 entitlement并把其它等级、其它 VIP 来源和重复实例标记为 `revoked`,同时清理对应 equipment因此用户资产中实际只存在一套 active 身份素材而不是仅在展示层隐藏。体验卡到期或卸下后wallet 会重新发放仍有效付费等级的三类身份素材,再回收体验等级实例;座驾、头像框等收藏型权益不受此回收规则影响。
## 房间权限和 IM ## 房间权限和 IM
`room-service` 在需要房间权限时读取 wallet 的 `GetMyVip.state` `room-service` 在需要房间权限时读取 wallet 的 `GetMyVip.state`

View File

@ -719,7 +719,7 @@ paths:
- users - users
summary: 批量查询会话展示资料 summary: 批量查询会话展示资料
operationId: batchUserProfiles operationId: batchUserProfiles
description: 用于腾讯云 IM 会话列表补头像、昵称和短号;只返回展示字段,不返回登录、实名或后台身份信息。 description: 用于腾讯云 IM 会话列表补头像、昵称、短号和当前 App 下的 wallet effective VIP 等级;只返回展示字段,不返回登录、实名或后台身份信息。无 VIP 或已过期时 `vip_level=0`
security: security:
- BearerAuth: [] - BearerAuth: []
parameters: parameters:
@ -1321,7 +1321,7 @@ paths:
format: int32 format: int32
responses: responses:
"200": "200":
description: 查询成功;`visited` 由 JoinRoom 成功写入,`friend/following` 同步使用 user-service 好友/关注关系,`followed` 同步使用 room-service 房间关注关系,不依赖 Redis 缓存命中 description: 查询成功;`visited` 由 JoinRoom 成功写入,`friend/following` 同步使用 user-service 好友/关注关系,`followed` 同步使用 room-service 房间关注关系。每个房间都返回 wallet effective VIP 的 `owner_vip_level` 本地投影,列表请求不逐房回查 wallet
schema: schema:
$ref: "#/definitions/RoomListEnvelope" $ref: "#/definitions/RoomListEnvelope"
"400": "400":
@ -4093,6 +4093,7 @@ definitions:
- display_user_id - display_user_id
- username - username
- app_code - app_code
- vip_level
properties: properties:
user_id: user_id:
type: string type: string
@ -4112,6 +4113,11 @@ definitions:
format: int64 format: int64
app_code: app_code:
type: string type: string
vip_level:
type: integer
format: int32
minimum: 0
description: 当前 App 下目标用户的 wallet effective VIP 等级;无 VIP、program 未启用或已过期时为 0不能从徽章、昵称或装扮资源反推。
description: 腾讯云 IM 会话列表展示资料;不包含手机号、登录凭证、实名或后台身份字段。 description: 腾讯云 IM 会话列表展示资料;不包含手机号、登录凭证、实名或后台身份字段。
UserProfilesBatchData: UserProfilesBatchData:
type: object type: object
@ -5810,6 +5816,7 @@ definitions:
required: required:
- room_id - room_id
- im_group_id - im_group_id
- owner_vip_level
- room_border_url - room_border_url
properties: properties:
room_id: room_id:

View File

@ -41,6 +41,7 @@ INCREMENTAL_MIGRATION_FILES=(
"services/room-service/deploy/mysql/migrations/003_room_vip_media_and_decorations.sql" "services/room-service/deploy/mysql/migrations/003_room_vip_media_and_decorations.sql"
"services/room-service/deploy/mysql/migrations/004_room_list_owner_vip_level.sql" "services/room-service/deploy/mysql/migrations/004_room_list_owner_vip_level.sql"
"services/room-service/deploy/mysql/migrations/005_room_list_activity_indicators.sql" "services/room-service/deploy/mysql/migrations/005_room_list_activity_indicators.sql"
"services/room-service/deploy/mysql/migrations/006_room_list_owner_vip_fallback.sql"
"services/activity-service/deploy/mysql/migrations/013_game_win_broadcast_config.sql" "services/activity-service/deploy/mysql/migrations/013_game_win_broadcast_config.sql"
"services/wallet-service/deploy/mysql/migrations/002_resource_shop_price_tiers.sql" "services/wallet-service/deploy/mysql/migrations/002_resource_shop_price_tiers.sql"
"services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.sql" "services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.sql"

View File

@ -0,0 +1,36 @@
-- 与 room-service/deploy/mysql/migrations/006_room_list_owner_vip_fallback.sql 保持同一可重入结构。
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
DROP PROCEDURE IF EXISTS hyapp_migrate_075_room_list_owner_vip_fallback;
DELIMITER $$
CREATE PROCEDURE hyapp_migrate_075_room_list_owner_vip_fallback()
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'room_list_entries'
) THEN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'owner_vip_fallback_level'
) THEN
ALTER TABLE room_list_entries
ADD COLUMN owner_vip_fallback_level INT NOT NULL DEFAULT 0 COMMENT '体验卡到期后 wallet paid VIP 回落等级' AFTER owner_vip_expires_at_ms,
ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'owner_vip_fallback_expires_at_ms'
) THEN
ALTER TABLE room_list_entries
ADD COLUMN owner_vip_fallback_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '回落 paid VIP 到期时间UTC epoch ms' AFTER owner_vip_fallback_level,
ALGORITHM=INSTANT;
END IF;
END IF;
END$$
CALL hyapp_migrate_075_room_list_owner_vip_fallback()$$
DROP PROCEDURE IF EXISTS hyapp_migrate_075_room_list_owner_vip_fallback$$
DELIMITER ;

View File

@ -242,11 +242,12 @@ type AppBanner struct {
AppCode string `gorm:"size:32;index:idx_admin_app_banners_app_sort;index:idx_admin_app_banner_activity_target,priority:1;index:idx_admin_app_banners_region_status_sort,priority:1;not null;default:lalu" json:"appCode"` AppCode string `gorm:"size:32;index:idx_admin_app_banners_app_sort;index:idx_admin_app_banner_activity_target,priority:1;index:idx_admin_app_banners_region_status_sort,priority:1;not null;default:lalu" json:"appCode"`
CoverURL string `gorm:"size:1024;not null" json:"coverUrl"` CoverURL string `gorm:"size:1024;not null" json:"coverUrl"`
RoomSmallImageURL string `gorm:"column:room_small_image_url;size:1024;not null;default:'';comment:房间内小屏图 URL" json:"roomSmallImageUrl"` RoomSmallImageURL string `gorm:"column:room_small_image_url;size:1024;not null;default:'';comment:房间内小屏图 URL" json:"roomSmallImageUrl"`
DiscoverImageURL string `gorm:"column:discover_image_url;size:1024;not null;default:'';comment:发现页横幅图 URL" json:"discoverImageUrl"`
BannerType string `gorm:"size:16;not null" json:"bannerType"` BannerType string `gorm:"size:16;not null" json:"bannerType"`
TargetType string `gorm:"column:target_type;size:32;not null;default:'';index:idx_admin_app_banner_activity_target,priority:2" json:"targetType"` TargetType string `gorm:"column:target_type;size:32;not null;default:'';index:idx_admin_app_banner_activity_target,priority:2" json:"targetType"`
ActivityTemplateID string `gorm:"column:activity_template_id;size:64;not null;default:'';index:idx_admin_app_banner_activity_target,priority:3" json:"activityTemplateId"` ActivityTemplateID string `gorm:"column:activity_template_id;size:64;not null;default:'';index:idx_admin_app_banner_activity_target,priority:3" json:"activityTemplateId"`
ActivityTemplateCode string `gorm:"column:activity_template_code;size:64;not null;default:''" json:"activityTemplateCode"` ActivityTemplateCode string `gorm:"column:activity_template_code;size:64;not null;default:''" json:"activityTemplateCode"`
DisplayScope string `gorm:"size:128;index:idx_admin_app_banners_display,not null;default:home;comment:显示范围,多选逗号分隔:首页、房间内、充值页或我的页" json:"displayScope"` DisplayScope string `gorm:"size:128;index:idx_admin_app_banners_display,not null;default:home;comment:显示范围,多选逗号分隔:首页、房间内、发现页、充值页或我的页" json:"displayScope"`
Param string `gorm:"size:2048" json:"param"` Param string `gorm:"size:2048" json:"param"`
Status string `gorm:"size:24;index:idx_admin_app_banners_app_sort;index:idx_admin_app_banner_activity_target,priority:4;index:idx_admin_app_banners_region_status_sort,priority:3;not null;default:active" json:"status"` Status string `gorm:"size:24;index:idx_admin_app_banners_app_sort;index:idx_admin_app_banner_activity_target,priority:4;index:idx_admin_app_banners_region_status_sort,priority:3;not null;default:active" json:"status"`
Platform string `gorm:"size:24;index:idx_admin_app_banners_scope,not null" json:"platform"` Platform string `gorm:"size:24;index:idx_admin_app_banners_scope,not null" json:"platform"`

View File

@ -29,6 +29,7 @@ type h5LinkPayload struct {
type bannerRequest struct { type bannerRequest struct {
CoverURL string `json:"coverUrl" binding:"required"` CoverURL string `json:"coverUrl" binding:"required"`
RoomSmallImageURL string `json:"roomSmallImageUrl"` RoomSmallImageURL string `json:"roomSmallImageUrl"`
DiscoverImageURL string `json:"discoverImageUrl"`
BannerType string `json:"bannerType" binding:"required"` BannerType string `json:"bannerType" binding:"required"`
TargetType string `json:"targetType"` TargetType string `json:"targetType"`
ActivityTemplateID string `json:"activityTemplateId"` ActivityTemplateID string `json:"activityTemplateId"`

View File

@ -24,6 +24,7 @@ const h5LinkGroup = "h5-links"
const ( const (
bannerDisplayScopeHome = "home" bannerDisplayScopeHome = "home"
bannerDisplayScopeRoom = "room" bannerDisplayScopeRoom = "room"
bannerDisplayScopeDiscover = "discover"
bannerDisplayScopeRecharge = "recharge" bannerDisplayScopeRecharge = "recharge"
bannerDisplayScopeMe = "me" bannerDisplayScopeMe = "me"
@ -34,7 +35,13 @@ const (
splashDefaultDisplayDurationMS = 3000 splashDefaultDisplayDurationMS = 3000
) )
var bannerDisplayScopeOrder = []string{bannerDisplayScopeHome, bannerDisplayScopeRoom, bannerDisplayScopeRecharge, bannerDisplayScopeMe} var bannerDisplayScopeOrder = []string{
bannerDisplayScopeHome,
bannerDisplayScopeRoom,
bannerDisplayScopeDiscover,
bannerDisplayScopeRecharge,
bannerDisplayScopeMe,
}
type AppConfigService struct { type AppConfigService struct {
store *repository.Store store *repository.Store
@ -77,6 +84,7 @@ type AppBanner struct {
AppCode string `json:"appCode"` AppCode string `json:"appCode"`
CoverURL string `json:"coverUrl"` CoverURL string `json:"coverUrl"`
RoomSmallImageURL string `json:"roomSmallImageUrl"` RoomSmallImageURL string `json:"roomSmallImageUrl"`
DiscoverImageURL string `json:"discoverImageUrl"`
BannerType string `json:"bannerType"` BannerType string `json:"bannerType"`
TargetType string `json:"targetType"` TargetType string `json:"targetType"`
ActivityTemplateID string `json:"activityTemplateId"` ActivityTemplateID string `json:"activityTemplateId"`
@ -331,6 +339,7 @@ func (s *AppConfigService) UpdateBanner(ctx context.Context, appCode string, id
} }
item.CoverURL = updated.CoverURL item.CoverURL = updated.CoverURL
item.RoomSmallImageURL = updated.RoomSmallImageURL item.RoomSmallImageURL = updated.RoomSmallImageURL
item.DiscoverImageURL = updated.DiscoverImageURL
item.BannerType = updated.BannerType item.BannerType = updated.BannerType
item.TargetType = updated.TargetType item.TargetType = updated.TargetType
item.ActivityTemplateID = updated.ActivityTemplateID item.ActivityTemplateID = updated.ActivityTemplateID
@ -770,6 +779,7 @@ func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner,
AppCode: appctx.Normalize(appCode), AppCode: appctx.Normalize(appCode),
CoverURL: strings.TrimSpace(req.CoverURL), CoverURL: strings.TrimSpace(req.CoverURL),
RoomSmallImageURL: strings.TrimSpace(req.RoomSmallImageURL), RoomSmallImageURL: strings.TrimSpace(req.RoomSmallImageURL),
DiscoverImageURL: strings.TrimSpace(req.DiscoverImageURL),
BannerType: normalizeBannerType(req.BannerType), BannerType: normalizeBannerType(req.BannerType),
TargetType: strings.ToLower(strings.TrimSpace(req.TargetType)), TargetType: strings.ToLower(strings.TrimSpace(req.TargetType)),
ActivityTemplateID: strings.TrimSpace(req.ActivityTemplateID), ActivityTemplateID: strings.TrimSpace(req.ActivityTemplateID),
@ -805,6 +815,18 @@ func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner,
// 未投放到房间内时不保留小屏图,避免客户端误用房间专属素材。 // 未投放到房间内时不保留小屏图,避免客户端误用房间专属素材。
item.RoomSmallImageURL = "" item.RoomSmallImageURL = ""
} }
if bannerDisplayScopesContain(displayScopes, bannerDisplayScopeDiscover) {
// Discover 使用独立的 355×100 横幅素材;保存时强制完整 URL避免客户端回退到比例不同的首页封面。
if item.DiscoverImageURL == "" || len(item.DiscoverImageURL) > 1024 {
return model.AppBanner{}, errors.New("discover image is required")
}
if containsWhitespace(item.DiscoverImageURL) {
return model.AppBanner{}, errors.New("discover image cannot contain whitespace")
}
} else {
// 取消发现页投放时同步清空专属图,避免后续编辑误以为该素材仍在生效。
item.DiscoverImageURL = ""
}
if item.BannerType != "h5" && item.BannerType != "app" { if item.BannerType != "h5" && item.BannerType != "app" {
return model.AppBanner{}, errors.New("banner type is invalid") return model.AppBanner{}, errors.New("banner type is invalid")
} }
@ -852,6 +874,7 @@ func appBannerFromModel(item model.AppBanner) AppBanner {
AppCode: item.AppCode, AppCode: item.AppCode,
CoverURL: item.CoverURL, CoverURL: item.CoverURL,
RoomSmallImageURL: item.RoomSmallImageURL, RoomSmallImageURL: item.RoomSmallImageURL,
DiscoverImageURL: item.DiscoverImageURL,
BannerType: item.BannerType, BannerType: item.BannerType,
TargetType: item.TargetType, TargetType: item.TargetType,
ActivityTemplateID: item.ActivityTemplateID, ActivityTemplateID: item.ActivityTemplateID,
@ -1161,6 +1184,8 @@ func normalizeBannerDisplayScope(value string) string {
return bannerDisplayScopeHome return bannerDisplayScopeHome
case bannerDisplayScopeRoom, "房间内": case bannerDisplayScopeRoom, "房间内":
return bannerDisplayScopeRoom return bannerDisplayScopeRoom
case bannerDisplayScopeDiscover, "发现页", "发现":
return bannerDisplayScopeDiscover
case bannerDisplayScopeRecharge, "充值页": case bannerDisplayScopeRecharge, "充值页":
return bannerDisplayScopeRecharge return bannerDisplayScopeRecharge
case bannerDisplayScopeMe, "我的页", "我的": case bannerDisplayScopeMe, "我的页", "我的":
@ -1219,7 +1244,11 @@ func validBannerDisplayScopes(scopes []string) bool {
return false return false
} }
for _, scope := range scopes { for _, scope := range scopes {
if scope != bannerDisplayScopeHome && scope != bannerDisplayScopeRoom && scope != bannerDisplayScopeRecharge && scope != bannerDisplayScopeMe { if scope != bannerDisplayScopeHome &&
scope != bannerDisplayScopeRoom &&
scope != bannerDisplayScopeDiscover &&
scope != bannerDisplayScopeRecharge &&
scope != bannerDisplayScopeMe {
return false return false
} }
} }

View File

@ -36,7 +36,7 @@ func (s *Store) ListAppBanners(options AppBannerListOptions) ([]model.AppBanner,
} }
if options.Keyword != "" { if options.Keyword != "" {
like := "%" + strings.TrimSpace(options.Keyword) + "%" like := "%" + strings.TrimSpace(options.Keyword) + "%"
query = query.Where("(description LIKE ? OR param LIKE ? OR country_code LIKE ? OR display_scope LIKE ? OR room_small_image_url LIKE ?)", like, like, like, like, like) query = query.Where("(description LIKE ? OR param LIKE ? OR country_code LIKE ? OR display_scope LIKE ? OR room_small_image_url LIKE ? OR discover_image_url LIKE ?)", like, like, like, like, like, like)
} }
err := query.Order("sort_order DESC, id DESC").Find(&items).Error err := query.Order("sort_order DESC, id DESC").Find(&items).Error
return items, err return items, err

View File

@ -0,0 +1,7 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Discover 卡片使用独立横幅比例,不能复用首页封面或房间小图。
-- ADD COLUMN 可能在旧版 MySQL 上重建表admin_app_banners 是小规模配置表,
-- 上线前仍需确认 TABLE_ROWS/DATA_LENGTH并避开 Banner 集中发布窗口。
ALTER TABLE admin_app_banners
ADD COLUMN discover_image_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '发现页横幅图 URL';

View File

@ -177,6 +177,23 @@ type InviteeSummary struct {
InvitedAtMS int64 InvitedAtMS int64
} }
// InviteeQuery 是 App 当前用户邀请明细的有界分页条件CycleKey 由 service 固定为当前 UTC 月。
type InviteeQuery struct {
UserID int64
CycleKey string
Page int32
PageSize int32
}
type InviteeResult struct {
Invitees []InviteeSummary
Total int64
Page int32
PageSize int32
CycleKey string
ServerTimeMS int64
}
// SummaryQuery 是后台邀请活动聚合列表查询条件UserID 只过滤邀请人CycleKey 为空表示所有周期。 // SummaryQuery 是后台邀请活动聚合列表查询条件UserID 只过滤邀请人CycleKey 为空表示所有周期。
type SummaryQuery struct { type SummaryQuery struct {
UserID int64 UserID int64

View File

@ -43,6 +43,11 @@ type InviteAttributionSource interface {
GetInviteAttribution(ctx context.Context, invitedUserID int64) (*userv1.InviteAttribution, error) GetInviteAttribution(ctx context.Context, invitedUserID int64) (*userv1.InviteAttribution, error)
} }
// InviteeRepository 是 App 邀请明细的窄读接口。与后台 summary 分开,避免 H5 查询触发后台聚合和无界子列表。
type InviteeRepository interface {
ListInviteActivityRewardInvitees(ctx context.Context, query domain.InviteeQuery) ([]domain.InviteeSummary, int64, error)
}
type Service struct { type Service struct {
repository Repository repository Repository
wallet WalletClient wallet WalletClient
@ -108,6 +113,49 @@ func (s *Service) GetConfig(ctx context.Context) (domain.Config, error) {
return config, nil return config, nil
} }
func (s *Service) ListInvitees(ctx context.Context, userID int64, page int32, pageSize int32) (domain.InviteeResult, error) {
if err := s.requireRepository(); err != nil {
return domain.InviteeResult{}, err
}
if userID <= 0 {
return domain.InviteeResult{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
repository, ok := s.repository.(InviteeRepository)
if !ok {
return domain.InviteeResult{}, xerr.New(xerr.Unavailable, "invitee repository is not configured")
}
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 50
}
if pageSize > 100 {
pageSize = 100
}
now := s.now().UTC()
cycle := CycleForTime(now)
query := domain.InviteeQuery{
UserID: userID,
CycleKey: cycle.Key,
Page: page,
PageSize: pageSize,
}
// 查询条件由鉴权 user_id 和服务端 UTC 月份共同固定App 不能借查询参数读取其他邀请人的明细。
invitees, total, err := repository.ListInviteActivityRewardInvitees(ctx, query)
if err != nil {
return domain.InviteeResult{}, err
}
return domain.InviteeResult{
Invitees: invitees,
Total: total,
Page: page,
PageSize: pageSize,
CycleKey: cycle.Key,
ServerTimeMS: now.UnixMilli(),
}, nil
}
func (s *Service) UpdateConfig(ctx context.Context, config domain.Config) (domain.Config, error) { func (s *Service) UpdateConfig(ctx context.Context, config domain.Config) (domain.Config, error) {
if err := s.requireRepository(); err != nil { if err := s.requireRepository(); err != nil {
return domain.Config{}, err return domain.Config{}, err

View File

@ -461,6 +461,84 @@ func (r *Repository) ListInviteActivityRewardSummaries(ctx context.Context, quer
return summaries, total, nil return summaries, total, nil
} }
func (r *Repository) ListInviteActivityRewardInvitees(ctx context.Context, query domain.InviteeQuery) ([]domain.InviteeSummary, int64, error) {
if r == nil || r.db == nil {
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if query.UserID <= 0 || strings.TrimSpace(query.CycleKey) == "" {
return nil, 0, xerr.New(xerr.InvalidArgument, "user_id and cycle_key are required")
}
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = 50
}
if query.PageSize > 100 {
query.PageSize = 100
}
args := []any{
appcode.FromContext(ctx),
query.CycleKey,
query.UserID,
domain.EventTypeUserInvited,
domain.EventTypeWalletRechargeRecorded,
}
// app_code + cycle_key + inviter_user_id 命中 idx_invite_activity_reward_event_inviter
// 查询只聚合当前用户一个 UTC 月的事实,不扫描其他邀请人或历史月份。
var total int64
if err := r.db.QueryRowContext(ctx, `
SELECT COUNT(DISTINCT invited_user_id)
FROM invite_activity_reward_events
WHERE app_code = ?
AND cycle_key = ?
AND inviter_user_id = ?
AND invited_user_id > 0
AND event_type IN (?, ?)`, args...).Scan(&total); err != nil {
return nil, 0, err
}
if total == 0 {
return []domain.InviteeSummary{}, 0, nil
}
rows, err := r.db.QueryContext(ctx, `
SELECT
invited_user_id,
COALESCE(SUM(CASE WHEN event_type = ? AND status = 'consumed' THEN recharge_coin_amount ELSE 0 END), 0) AS recharge_coin_amount,
COALESCE(MIN(CASE WHEN event_type = ? THEN occurred_at_ms ELSE NULL END), 0) AS invited_at_ms
FROM invite_activity_reward_events
WHERE app_code = ?
AND cycle_key = ?
AND inviter_user_id = ?
AND invited_user_id > 0
AND event_type IN (?, ?)
GROUP BY invited_user_id
ORDER BY invited_at_ms DESC, invited_user_id DESC
LIMIT ? OFFSET ?`,
domain.EventTypeWalletRechargeRecorded,
domain.EventTypeUserInvited,
appcode.FromContext(ctx),
query.CycleKey,
query.UserID,
domain.EventTypeUserInvited,
domain.EventTypeWalletRechargeRecorded,
int(query.PageSize),
int((query.Page-1)*query.PageSize),
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
invitees := make([]domain.InviteeSummary, 0, query.PageSize)
for rows.Next() {
var invitee domain.InviteeSummary
if err := rows.Scan(&invitee.InvitedUserID, &invitee.RechargeCoinAmount, &invitee.InvitedAtMS); err != nil {
return nil, 0, err
}
invitees = append(invitees, invitee)
}
return invitees, total, rows.Err()
}
func (r *Repository) ListInviteActivityLeaderboard(ctx context.Context, query domain.LeaderboardQuery) ([]domain.LeaderboardEntry, int64, error) { func (r *Repository) ListInviteActivityLeaderboard(ctx context.Context, query domain.LeaderboardQuery) ([]domain.LeaderboardEntry, int64, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")

View File

@ -60,6 +60,26 @@ func (s *InviteActivityRewardServer) ListInviteActivityLeaderboard(ctx context.C
return resp, nil return resp, nil
} }
func (s *InviteActivityRewardServer) ListInviteActivityInvitees(ctx context.Context, req *activityv1.ListInviteActivityInviteesRequest) (*activityv1.ListInviteActivityInviteesResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
result, err := s.svc.ListInvitees(ctx, req.GetUserId(), req.GetPage(), req.GetPageSize())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &activityv1.ListInviteActivityInviteesResponse{
Invitees: make([]*activityv1.InviteActivityRewardInviteeSummary, 0, len(result.Invitees)),
Total: result.Total,
Page: result.Page,
PageSize: result.PageSize,
CycleKey: result.CycleKey,
ServerTimeMs: result.ServerTimeMS,
}
for _, invitee := range result.Invitees {
resp.Invitees = append(resp.Invitees, inviteActivityRewardInviteeSummaryToProto(invitee))
}
return resp, nil
}
type AdminInviteActivityRewardServer struct { type AdminInviteActivityRewardServer struct {
activityv1.UnimplementedAdminInviteActivityRewardServiceServer activityv1.UnimplementedAdminInviteActivityRewardServiceServer
@ -248,15 +268,19 @@ func inviteActivityRewardSummaryToProto(summary domain.Summary) *activityv1.Invi
Invitees: make([]*activityv1.InviteActivityRewardInviteeSummary, 0, len(summary.Invitees)), Invitees: make([]*activityv1.InviteActivityRewardInviteeSummary, 0, len(summary.Invitees)),
} }
for _, invitee := range summary.Invitees { for _, invitee := range summary.Invitees {
resp.Invitees = append(resp.Invitees, &activityv1.InviteActivityRewardInviteeSummary{ resp.Invitees = append(resp.Invitees, inviteActivityRewardInviteeSummaryToProto(invitee))
InvitedUserId: invitee.InvitedUserID,
RechargeCoinAmount: invitee.RechargeCoinAmount,
InvitedAtMs: invitee.InvitedAtMS,
})
} }
return resp return resp
} }
func inviteActivityRewardInviteeSummaryToProto(invitee domain.InviteeSummary) *activityv1.InviteActivityRewardInviteeSummary {
return &activityv1.InviteActivityRewardInviteeSummary{
InvitedUserId: invitee.InvitedUserID,
RechargeCoinAmount: invitee.RechargeCoinAmount,
InvitedAtMs: invitee.InvitedAtMS,
}
}
func inviteActivityLeaderboardEntryToProto(entry domain.LeaderboardEntry) *activityv1.InviteActivityLeaderboardEntry { func inviteActivityLeaderboardEntryToProto(entry domain.LeaderboardEntry) *activityv1.InviteActivityLeaderboardEntry {
return &activityv1.InviteActivityLeaderboardEntry{ return &activityv1.InviteActivityLeaderboardEntry{
RankNo: entry.RankNo, RankNo: entry.RankNo,

View File

@ -51,7 +51,7 @@ const listExploreTabsSQL = `
WHERE app_code = ? AND enabled = TRUE WHERE app_code = ? AND enabled = TRUE
ORDER BY sort_order DESC, id DESC` ORDER BY sort_order DESC, id DESC`
const listAppBannersSQL = ` const listAppBannersSQL = `
SELECT id, app_code, cover_url, room_small_image_url, banner_type, display_scope, param, platform, sort_order, region_id, country_code, description, starts_at_ms, ends_at_ms, updated_at_ms SELECT id, app_code, cover_url, room_small_image_url, discover_image_url, banner_type, display_scope, param, platform, sort_order, region_id, country_code, description, starts_at_ms, ends_at_ms, updated_at_ms
FROM admin_app_banners FROM admin_app_banners
WHERE app_code = ? AND status = 'active' AND FIND_IN_SET(?, display_scope) > 0 WHERE app_code = ? AND status = 'active' AND FIND_IN_SET(?, display_scope) > 0
AND (? = '' OR platform = ?) AND (? = '' OR platform = ?)
@ -123,6 +123,7 @@ type Banner struct {
ID uint `json:"id"` ID uint `json:"id"`
CoverURL string `json:"cover_url"` CoverURL string `json:"cover_url"`
RoomSmallImageURL string `json:"room_small_image_url,omitempty"` RoomSmallImageURL string `json:"room_small_image_url,omitempty"`
DiscoverImageURL string `json:"discover_image_url,omitempty"`
BannerType string `json:"type"` BannerType string `json:"type"`
DisplayScope string `json:"display_scope"` DisplayScope string `json:"display_scope"`
DisplayScopes []string `json:"display_scopes,omitempty"` DisplayScopes []string `json:"display_scopes,omitempty"`
@ -437,6 +438,7 @@ func (r *MySQLReader) ListBanners(ctx context.Context, query BannerQuery) ([]Ban
&appCode, &appCode,
&item.CoverURL, &item.CoverURL,
&item.RoomSmallImageURL, &item.RoomSmallImageURL,
&item.DiscoverImageURL,
&item.BannerType, &item.BannerType,
&item.DisplayScope, &item.DisplayScope,
&item.Param, &item.Param,
@ -965,6 +967,8 @@ func NormalizeBannerDisplayScope(value string) string {
return "home" return "home"
case "room", "房间内": case "room", "房间内":
return "room" return "room"
case "discover", "发现页", "发现":
return "discover"
case "recharge", "充值页": case "recharge", "充值页":
return "recharge" return "recharge"
case "me", "我的页", "我的": case "me", "我的页", "我的":
@ -975,7 +979,7 @@ func NormalizeBannerDisplayScope(value string) string {
} }
func bannerDisplayScopeList(value string) []string { func bannerDisplayScopeList(value string) []string {
seen := make(map[string]struct{}, 4) seen := make(map[string]struct{}, 5)
for _, part := range strings.Split(value, ",") { for _, part := range strings.Split(value, ",") {
scope := NormalizeBannerDisplayScope(part) scope := NormalizeBannerDisplayScope(part)
if scope != "" { if scope != "" {
@ -983,7 +987,7 @@ func bannerDisplayScopeList(value string) []string {
} }
} }
out := make([]string, 0, len(seen)) out := make([]string, 0, len(seen))
for _, scope := range []string{"home", "room", "recharge", "me"} { for _, scope := range []string{"home", "room", "discover", "recharge", "me"} {
if _, ok := seen[scope]; ok { if _, ok := seen[scope]; ok {
out = append(out, scope) out = append(out, scope)
} }

View File

@ -14,6 +14,11 @@ type InviteActivityRewardClient interface {
ListInviteActivityLeaderboard(ctx context.Context, req *activityv1.ListInviteActivityLeaderboardRequest) (*activityv1.ListInviteActivityLeaderboardResponse, error) ListInviteActivityLeaderboard(ctx context.Context, req *activityv1.ListInviteActivityLeaderboardRequest) (*activityv1.ListInviteActivityLeaderboardResponse, error)
} }
// InviteActivityInviteeClient 单独描述新邀请明细能力,避免滚动升级期间扩大旧 client/fake 的强制实现面。
type InviteActivityInviteeClient interface {
ListInviteActivityInvitees(ctx context.Context, req *activityv1.ListInviteActivityInviteesRequest) (*activityv1.ListInviteActivityInviteesResponse, error)
}
type grpcInviteActivityRewardClient struct { type grpcInviteActivityRewardClient struct {
client activityv1.InviteActivityRewardServiceClient client activityv1.InviteActivityRewardServiceClient
} }
@ -33,3 +38,7 @@ func (c *grpcInviteActivityRewardClient) ClaimInviteActivityReward(ctx context.C
func (c *grpcInviteActivityRewardClient) ListInviteActivityLeaderboard(ctx context.Context, req *activityv1.ListInviteActivityLeaderboardRequest) (*activityv1.ListInviteActivityLeaderboardResponse, error) { func (c *grpcInviteActivityRewardClient) ListInviteActivityLeaderboard(ctx context.Context, req *activityv1.ListInviteActivityLeaderboardRequest) (*activityv1.ListInviteActivityLeaderboardResponse, error) {
return c.client.ListInviteActivityLeaderboard(ctx, req) return c.client.ListInviteActivityLeaderboard(ctx, req)
} }
func (c *grpcInviteActivityRewardClient) ListInviteActivityInvitees(ctx context.Context, req *activityv1.ListInviteActivityInviteesRequest) (*activityv1.ListInviteActivityInviteesResponse, error) {
return c.client.ListInviteActivityInvitees(ctx, req)
}

View File

@ -76,6 +76,12 @@ type WalletClient interface {
GetRedPacket(ctx context.Context, req *walletv1.GetRedPacketRequest) (*walletv1.GetRedPacketResponse, error) GetRedPacket(ctx context.Context, req *walletv1.GetRedPacketRequest) (*walletv1.GetRedPacketResponse, error)
} }
// UserEffectiveVIPClient 是 gateway 展示聚合读取 wallet effective VIP 的最小批量边界。
// 单独声明避免让只依赖余额/资源的测试 fake 被迫实现无关方法。
type UserEffectiveVIPClient interface {
BatchGetUserEffectiveVipLevels(ctx context.Context, req *walletv1.BatchGetUserEffectiveVipLevelsRequest) (*walletv1.BatchGetUserEffectiveVipLevelsResponse, error)
}
type grpcWalletClient struct { type grpcWalletClient struct {
client walletv1.WalletServiceClient client walletv1.WalletServiceClient
} }
@ -197,6 +203,10 @@ func (c *grpcWalletClient) GetMyVip(ctx context.Context, req *walletv1.GetMyVipR
return c.client.GetMyVip(ctx, req) return c.client.GetMyVip(ctx, req)
} }
func (c *grpcWalletClient) BatchGetUserEffectiveVipLevels(ctx context.Context, req *walletv1.BatchGetUserEffectiveVipLevelsRequest) (*walletv1.BatchGetUserEffectiveVipLevelsResponse, error) {
return c.client.BatchGetUserEffectiveVipLevels(ctx, req)
}
func (c *grpcWalletClient) GetVipProgramConfig(ctx context.Context, req *walletv1.GetVipProgramConfigRequest) (*walletv1.GetVipProgramConfigResponse, error) { func (c *grpcWalletClient) GetVipProgramConfig(ctx context.Context, req *walletv1.GetVipProgramConfigRequest) (*walletv1.GetVipProgramConfigResponse, error) {
return c.client.GetVipProgramConfig(ctx, req) return c.client.GetVipProgramConfig(ctx, req)
} }

View File

@ -119,6 +119,7 @@ func (h *Handler) TaskHandlers() httproutes.TaskHandlers {
GetInviteActivityRewardStatus: h.getInviteActivityRewardStatus, GetInviteActivityRewardStatus: h.getInviteActivityRewardStatus,
ClaimInviteActivityReward: h.claimInviteActivityReward, ClaimInviteActivityReward: h.claimInviteActivityReward,
ListInviteActivityLeaderboard: h.listInviteActivityLeaderboard, ListInviteActivityLeaderboard: h.listInviteActivityLeaderboard,
ListInviteActivityInvitees: h.listInviteActivityInvitees,
GetSevenDayCheckInStatus: h.getSevenDayCheckInStatus, GetSevenDayCheckInStatus: h.getSevenDayCheckInStatus,
SignSevenDayCheckIn: h.signSevenDayCheckIn, SignSevenDayCheckIn: h.signSevenDayCheckIn,
ListUserLeaderboards: h.listUserLeaderboards, ListUserLeaderboards: h.listUserLeaderboards,

View File

@ -8,6 +8,7 @@ import (
activityv1 "hyapp.local/api/proto/activity/v1" activityv1 "hyapp.local/api/proto/activity/v1"
userv1 "hyapp.local/api/proto/user/v1" userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/transport/http/httpkit" "hyapp/services/gateway-service/internal/transport/http/httpkit"
) )
@ -84,6 +85,23 @@ type inviteActivityLeaderboardUserData struct {
DisplayUserID string `json:"display_user_id"` DisplayUserID string `json:"display_user_id"`
Username string `json:"username"` Username string `json:"username"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
CreatedAtMS int64 `json:"created_at_ms"`
}
type inviteActivityInviteeData struct {
UserID string `json:"user_id"`
RechargeCoinAmount int64 `json:"recharge_coin_amount"`
InvitedAtMS int64 `json:"invited_at_ms"`
User *inviteActivityLeaderboardUserData `json:"user,omitempty"`
}
type inviteActivityInviteeListData struct {
Invitees []inviteActivityInviteeData `json:"invitees"`
Total int64 `json:"total"`
Page int32 `json:"page"`
PageSize int32 `json:"page_size"`
CycleKey string `json:"cycle_key"`
ServerTimeMS int64 `json:"server_time_ms"`
} }
type inviteActivityLeaderboardEntryData struct { type inviteActivityLeaderboardEntryData struct {
@ -185,6 +203,39 @@ func (h *Handler) listInviteActivityLeaderboard(writer http.ResponseWriter, requ
}) })
} }
// listInviteActivityInvitees 只用鉴权上下文里的 user_id 查询当前月明细URL 不接收目标用户,避免横向读取。
func (h *Handler) listInviteActivityInvitees(writer http.ResponseWriter, request *http.Request) {
inviteeClient, ok := h.inviteActivityReward.(client.InviteActivityInviteeClient)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
page := int32(queryInt(request, "page", 1))
pageSize := int32(queryInt(request, "page_size", 50))
if pageSize > 100 {
pageSize = 100
}
resp, err := inviteeClient.ListInviteActivityInvitees(request.Context(), &activityv1.ListInviteActivityInviteesRequest{
Meta: httpkit.ActivityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
Page: page,
PageSize: pageSize,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
users := h.resolveInviteActivityLeaderboardUsers(request, inviteActivityInviteeUserIDs(resp.GetInvitees()))
httpkit.WriteOK(writer, request, inviteActivityInviteeListData{
Invitees: inviteActivityInviteesFromProto(resp.GetInvitees(), users),
Total: resp.GetTotal(),
Page: resp.GetPage(),
PageSize: resp.GetPageSize(),
CycleKey: resp.GetCycleKey(),
ServerTimeMS: resp.GetServerTimeMs(),
})
}
func inviteActivityRewardStatusFromProto(item *activityv1.InviteActivityRewardStatus) inviteActivityRewardStatusData { func inviteActivityRewardStatusFromProto(item *activityv1.InviteActivityRewardStatus) inviteActivityRewardStatusData {
if item == nil { if item == nil {
return inviteActivityRewardStatusData{ return inviteActivityRewardStatusData{
@ -262,11 +313,42 @@ func (h *Handler) resolveInviteActivityLeaderboardUsers(request *http.Request, i
DisplayUserID: item.GetDisplayUserId(), DisplayUserID: item.GetDisplayUserId(),
Username: item.GetUsername(), Username: item.GetUsername(),
Avatar: item.GetAvatar(), Avatar: item.GetAvatar(),
CreatedAtMS: item.GetCreatedAtMs(),
} }
} }
return users return users
} }
func inviteActivityInviteesFromProto(items []*activityv1.InviteActivityRewardInviteeSummary, users map[int64]inviteActivityLeaderboardUserData) []inviteActivityInviteeData {
invitees := make([]inviteActivityInviteeData, 0, len(items))
for _, item := range items {
if item == nil || item.GetInvitedUserId() <= 0 {
continue
}
invitee := inviteActivityInviteeData{
UserID: strconv.FormatInt(item.GetInvitedUserId(), 10),
RechargeCoinAmount: item.GetRechargeCoinAmount(),
InvitedAtMS: item.GetInvitedAtMs(),
}
if user, ok := users[item.GetInvitedUserId()]; ok {
userCopy := user
invitee.User = &userCopy
}
invitees = append(invitees, invitee)
}
return invitees
}
func inviteActivityInviteeUserIDs(items []*activityv1.InviteActivityRewardInviteeSummary) []int64 {
ids := make([]int64, 0, len(items))
for _, item := range items {
if item != nil && item.GetInvitedUserId() > 0 {
ids = append(ids, item.GetInvitedUserId())
}
}
return ids
}
func inviteActivityLeaderboardEntriesFromProto(items []*activityv1.InviteActivityLeaderboardEntry, users map[int64]inviteActivityLeaderboardUserData) []inviteActivityLeaderboardEntryData { func inviteActivityLeaderboardEntriesFromProto(items []*activityv1.InviteActivityLeaderboardEntry, users map[int64]inviteActivityLeaderboardUserData) []inviteActivityLeaderboardEntryData {
entries := make([]inviteActivityLeaderboardEntryData, 0, len(items)) entries := make([]inviteActivityLeaderboardEntryData, 0, len(items))
for _, item := range items { for _, item := range items {

View File

@ -230,6 +230,7 @@ type TaskHandlers struct {
GetInviteActivityRewardStatus http.HandlerFunc GetInviteActivityRewardStatus http.HandlerFunc
ClaimInviteActivityReward http.HandlerFunc ClaimInviteActivityReward http.HandlerFunc
ListInviteActivityLeaderboard http.HandlerFunc ListInviteActivityLeaderboard http.HandlerFunc
ListInviteActivityInvitees http.HandlerFunc
GetSevenDayCheckInStatus http.HandlerFunc GetSevenDayCheckInStatus http.HandlerFunc
SignSevenDayCheckIn http.HandlerFunc SignSevenDayCheckIn http.HandlerFunc
ListUserLeaderboards http.HandlerFunc ListUserLeaderboards http.HandlerFunc
@ -640,6 +641,7 @@ func (r routes) registerTaskRoutes() {
r.profile("/activities/first-recharge-reward", http.MethodGet, h.GetFirstRechargeRewardStatus) r.profile("/activities/first-recharge-reward", http.MethodGet, h.GetFirstRechargeRewardStatus)
r.profile("/activities/cumulative-recharge-reward", http.MethodGet, h.GetCumulativeRechargeRewardStatus) r.profile("/activities/cumulative-recharge-reward", http.MethodGet, h.GetCumulativeRechargeRewardStatus)
r.profile("/activities/invite-reward", http.MethodGet, h.GetInviteActivityRewardStatus) r.profile("/activities/invite-reward", http.MethodGet, h.GetInviteActivityRewardStatus)
r.profile("/activities/invite-reward/invitees", http.MethodGet, h.ListInviteActivityInvitees)
r.profile("/activities/invite-reward/leaderboard", http.MethodGet, h.ListInviteActivityLeaderboard) r.profile("/activities/invite-reward/leaderboard", http.MethodGet, h.ListInviteActivityLeaderboard)
r.profile("/activities/invite-reward/claim", http.MethodPost, h.ClaimInviteActivityReward) r.profile("/activities/invite-reward/claim", http.MethodPost, h.ClaimInviteActivityReward)
r.profile("/activities/seven-day-checkin", http.MethodGet, h.GetSevenDayCheckInStatus) r.profile("/activities/seven-day-checkin", http.MethodGet, h.GetSevenDayCheckInStatus)

View File

@ -10,22 +10,25 @@ import (
// Handler owns message inbox endpoints and safe profile batches used by Tencent IM conversation UI. // Handler owns message inbox endpoints and safe profile batches used by Tencent IM conversation UI.
// Private chat delivery remains Tencent IM owned; these handlers only expose backend-owned system inbox facts. // Private chat delivery remains Tencent IM owned; these handlers only expose backend-owned system inbox facts.
type Handler struct { type Handler struct {
messageClient client.MessageInboxClient messageClient client.MessageInboxClient
actionClient client.MessageActionConfirmClient actionClient client.MessageActionConfirmClient
userProfileClient client.UserProfileClient userProfileClient client.UserProfileClient
effectiveVIPClient client.UserEffectiveVIPClient
} }
type Config struct { type Config struct {
MessageClient client.MessageInboxClient MessageClient client.MessageInboxClient
ActionClient client.MessageActionConfirmClient ActionClient client.MessageActionConfirmClient
UserProfileClient client.UserProfileClient UserProfileClient client.UserProfileClient
EffectiveVIPClient client.UserEffectiveVIPClient
} }
func New(config Config) *Handler { func New(config Config) *Handler {
return &Handler{ return &Handler{
messageClient: config.MessageClient, messageClient: config.MessageClient,
actionClient: config.ActionClient, actionClient: config.ActionClient,
userProfileClient: config.UserProfileClient, userProfileClient: config.UserProfileClient,
effectiveVIPClient: config.EffectiveVIPClient,
} }
} }

View File

@ -4,9 +4,11 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync"
activityv1 "hyapp.local/api/proto/activity/v1" activityv1 "hyapp.local/api/proto/activity/v1"
userv1 "hyapp.local/api/proto/user/v1" userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit" "hyapp/services/gateway-service/internal/transport/http/httpkit"
) )
@ -55,6 +57,7 @@ type userProfileBatchData struct {
Country string `json:"country,omitempty"` Country string `json:"country,omitempty"`
RegionID int64 `json:"region_id,omitempty"` RegionID int64 `json:"region_id,omitempty"`
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
VIPLevel int32 `json:"vip_level"`
} }
// listMessageTabs returns backend unread summary and declares user conversations as Tencent IM SDK owned. // listMessageTabs returns backend unread summary and declares user conversations as Tencent IM SDK owned.
@ -365,17 +368,54 @@ func (h *Handler) batchUserProfiles(writer http.ResponseWriter, request *http.Re
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return return
} }
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{ var (
Meta: httpkit.UserMeta(request, ""), profileResp *userv1.BatchGetUsersResponse
UserIds: userIDs, vipResp *walletv1.BatchGetUserEffectiveVipLevelsResponse
}) profileErr error
if err != nil { vipErr error
httpkit.WriteRPCError(writer, request, err) waitGroup sync.WaitGroup
)
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
profileResp, profileErr = h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
Meta: httpkit.UserMeta(request, ""),
UserIds: userIDs,
})
}()
if h.effectiveVIPClient != nil {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
meta := httpkit.UserMeta(request, "")
vipResp, vipErr = h.effectiveVIPClient.BatchGetUserEffectiveVipLevels(request.Context(), &walletv1.BatchGetUserEffectiveVipLevelsRequest{
RequestId: meta.GetRequestId(),
AppCode: meta.GetAppCode(),
UserIds: userIDs,
})
}()
}
waitGroup.Wait()
if profileErr != nil {
httpkit.WriteRPCError(writer, request, profileErr)
return return
} }
items := make([]userProfileBatchData, 0, len(resp.GetUsers())) if vipErr != nil {
httpkit.WriteRPCError(writer, request, vipErr)
return
}
// 未装配新 wallet client 只发生在隔离单测或 gateway 滚动升级窗口;字段仍显式返回 0。
// 生产完整装配会使用上面的 wallet 批量 owner 查询,绝不从徽章、资源或昵称反推等级。
vipLevels := make(map[int64]int32, len(userIDs))
for _, item := range vipResp.GetUsers() {
if item != nil && item.GetUserId() > 0 && item.GetLevel() > 0 {
vipLevels[item.GetUserId()] = item.GetLevel()
}
}
items := make([]userProfileBatchData, 0, len(profileResp.GetUsers()))
for _, userID := range userIDs { for _, userID := range userIDs {
if user := resp.GetUsers()[userID]; user != nil { if user := profileResp.GetUsers()[userID]; user != nil {
items = append(items, userProfileBatchData{ items = append(items, userProfileBatchData{
UserID: strconv.FormatInt(user.GetUserId(), 10), UserID: strconv.FormatInt(user.GetUserId(), 10),
DisplayUserID: user.GetDisplayUserId(), DisplayUserID: user.GetDisplayUserId(),
@ -385,6 +425,7 @@ func (h *Handler) batchUserProfiles(writer http.ResponseWriter, request *http.Re
Country: user.GetCountry(), Country: user.GetCountry(),
RegionID: user.GetRegionId(), RegionID: user.GetRegionId(),
AppCode: user.GetAppCode(), AppCode: user.GetAppCode(),
VIPLevel: vipLevels[user.GetUserId()],
}) })
} }
} }

View File

@ -84,10 +84,14 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
AchievementClient: h.achievementClient, AchievementClient: h.achievementClient,
BroadcastClient: h.broadcastClient, BroadcastClient: h.broadcastClient,
}) })
// WalletClient 主接口保持既有调用面effective VIP 用窄接口做显式能力发现,
// 便于 wallet -> gateway 滚动升级期间明确区分旧 client 和新批量契约。
effectiveVIPClient, _ := h.walletClient.(gatewayclient.UserEffectiveVIPClient)
messageAPI := messageapi.New(messageapi.Config{ messageAPI := messageapi.New(messageapi.Config{
MessageClient: h.messageClient, MessageClient: h.messageClient,
ActionClient: h.messageActionClient, ActionClient: h.messageActionClient,
UserProfileClient: h.userProfileClient, UserProfileClient: h.userProfileClient,
EffectiveVIPClient: effectiveVIPClient,
}) })
activityAPI := activityapi.New(activityapi.Config{ activityAPI := activityapi.New(activityapi.Config{
TaskClient: h.taskClient, TaskClient: h.taskClient,

View File

@ -40,6 +40,8 @@ CREATE TABLE IF NOT EXISTS room_list_entries (
owner_user_id BIGINT NOT NULL COMMENT '房主用户 ID', owner_user_id BIGINT NOT NULL COMMENT '房主用户 ID',
owner_vip_level INT NOT NULL DEFAULT 0 COMMENT '房主当前 effective VIP 等级异步投影', owner_vip_level INT NOT NULL DEFAULT 0 COMMENT '房主当前 effective VIP 等级异步投影',
owner_vip_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '房主当前 effective VIP 到期时间UTC epoch ms', owner_vip_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '房主当前 effective VIP 到期时间UTC epoch ms',
owner_vip_fallback_level INT NOT NULL DEFAULT 0 COMMENT '体验卡到期后 wallet paid VIP 回落等级',
owner_vip_fallback_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '回落 paid VIP 到期时间UTC epoch ms',
title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '标题', title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '标题',
cover_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '封面 URL', cover_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '封面 URL',
mode VARCHAR(64) NOT NULL COMMENT '业务模式', mode VARCHAR(64) NOT NULL COMMENT '业务模式',

View File

@ -0,0 +1,39 @@
-- 两列只保存 wallet 已求值的 paid 回落计划,不参与列表过滤、排序或索引。
-- MySQL 8 INSTANT DDL 仅修改数据字典,不扫描或重写高频 room_list_entries。
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_room;
DROP PROCEDURE IF EXISTS hyapp_migrate_006_room_list_owner_vip_fallback;
DELIMITER $$
CREATE PROCEDURE hyapp_migrate_006_room_list_owner_vip_fallback()
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'room_list_entries'
) THEN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'owner_vip_fallback_level'
) THEN
ALTER TABLE room_list_entries
ADD COLUMN owner_vip_fallback_level INT NOT NULL DEFAULT 0 COMMENT '体验卡到期后 wallet paid VIP 回落等级' AFTER owner_vip_expires_at_ms,
ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'owner_vip_fallback_expires_at_ms'
) THEN
ALTER TABLE room_list_entries
ADD COLUMN owner_vip_fallback_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '回落 paid VIP 到期时间UTC epoch ms' AFTER owner_vip_fallback_level,
ALGORITHM=INSTANT;
END IF;
END IF;
END$$
CALL hyapp_migrate_006_room_list_owner_vip_fallback()$$
DROP PROCEDURE IF EXISTS hyapp_migrate_006_room_list_owner_vip_fallback$$
DELIMITER ;

View File

@ -363,7 +363,18 @@ func (s *Service) updateRoomOwnerVIPProjection(ctx context.Context, ownerUserID
if !ok || ownerUserID <= 0 { if !ok || ownerUserID <= 0 {
return nil return nil
} }
entry, exists, err := store.UpdateRoomOwnerVIP(ctx, ownerUserID, vip.level, vip.expiresAtMS) var (
entry RoomListEntry
exists bool
err error
)
if scheduledStore, scheduled := s.repository.(RoomOwnerVIPScheduledProjectionStore); scheduled {
entry, exists, err = scheduledStore.UpdateRoomOwnerVIPSchedule(
ctx, ownerUserID, vip.level, vip.expiresAtMS, vip.fallbackLevel, vip.fallbackExpiresAtMS,
)
} else {
entry, exists, err = store.UpdateRoomOwnerVIP(ctx, ownerUserID, vip.level, vip.expiresAtMS)
}
if err != nil || !exists { if err != nil || !exists {
return err return err
} }
@ -638,8 +649,12 @@ func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
roomBorder := unmarshalRoomListDecoration(entry.RoomBorderJSON, nowMS) roomBorder := unmarshalRoomListDecoration(entry.RoomBorderJSON, nowMS)
ownerVIPLevel := entry.OwnerVIPLevel ownerVIPLevel := entry.OwnerVIPLevel
if ownerVIPLevel <= 0 || entry.OwnerVIPExpiresAtMS <= nowMS { if ownerVIPLevel <= 0 || entry.OwnerVIPExpiresAtMS <= nowMS {
// 到期判断留在服务端,避免自然到期后仍依赖 wallet 事件才能从列表撤下 VIP 展示。 // 体验卡自然到期没有写事务事件;优先使用同一次 wallet 快照固化的 paid 回落,
ownerVIPLevel = 0 // 否则归零。列表仍然只读本地投影,不增加 wallet RPC 或逐房查询。
ownerVIPLevel = entry.OwnerVIPFallbackLevel
if ownerVIPLevel <= 0 || entry.OwnerVIPFallbackExpiresAtMS <= nowMS {
ownerVIPLevel = 0
}
} }
return &roomv1.RoomListItem{ return &roomv1.RoomListItem{
AppCode: entry.AppCode, AppCode: entry.AppCode,

View File

@ -298,6 +298,10 @@ type RoomListEntry struct {
// 过期边界只在服务端判断,客户端只收到仍有效的 owner_vip_level。 // 过期边界只在服务端判断,客户端只收到仍有效的 owner_vip_level。
OwnerVIPLevel int32 OwnerVIPLevel int32
OwnerVIPExpiresAtMS int64 OwnerVIPExpiresAtMS int64
// OwnerVIPFallback* 是 effective source 为 trial 时由 wallet 同快照给出的 paid 回落计划。
// 它只在主投影到期后生效,避免体验卡自然过期窗口错误显示 0。
OwnerVIPFallbackLevel int32
OwnerVIPFallbackExpiresAtMS int64
// OwnerVIPProjectionKnown 区分“投影明确为 0”和“Room Cell 快照没有携带该外部事实”;它不写入 Redis payload。 // OwnerVIPProjectionKnown 区分“投影明确为 0”和“Room Cell 快照没有携带该外部事实”;它不写入 Redis payload。
OwnerVIPProjectionKnown bool `json:"-"` OwnerVIPProjectionKnown bool `json:"-"`
// RocketNearCompletion 是 Room Cell 当前火箭进度的列表投影;普通房间命令会随最新快照覆盖它。 // RocketNearCompletion 是 Room Cell 当前火箭进度的列表投影;普通房间命令会随最新快照覆盖它。
@ -887,6 +891,12 @@ type RoomOwnerVIPProjectionStore interface {
UpdateRoomOwnerVIP(ctx context.Context, ownerUserID int64, level int32, expiresAtMS int64) (RoomListEntry, bool, error) UpdateRoomOwnerVIP(ctx context.Context, ownerUserID int64, level int32, expiresAtMS int64) (RoomListEntry, bool, error)
} }
// RoomOwnerVIPScheduledProjectionStore 在主 effective VIP 是体验卡时额外保存 paid 回落计划。
// 旧测试仓储仍可只实现 RoomOwnerVIPProjectionStore生产 MySQL 使用该扩展能力。
type RoomOwnerVIPScheduledProjectionStore interface {
UpdateRoomOwnerVIPSchedule(ctx context.Context, ownerUserID int64, level int32, expiresAtMS int64, fallbackLevel int32, fallbackExpiresAtMS int64) (RoomListEntry, bool, error)
}
// RoomRedPacketProjectionEvent 是 wallet owner 红包事实进入房间列表读模型的最小契约。 // RoomRedPacketProjectionEvent 是 wallet owner 红包事实进入房间列表读模型的最小契约。
// 它不进入 Room Cell 核心状态packet 级投影只用于正确聚合同房间多个并存红包。 // 它不进入 Room Cell 核心状态packet 级投影只用于正确聚合同房间多个并存红包。
type RoomRedPacketProjectionEvent struct { type RoomRedPacketProjectionEvent struct {

View File

@ -194,6 +194,8 @@ func (s *RedisRoomListCacheStore) UpsertRoomListEntry(ctx context.Context, entry
if !entry.OwnerVIPProjectionKnown { if !entry.OwnerVIPProjectionKnown {
entry.OwnerVIPLevel = previous.OwnerVIPLevel entry.OwnerVIPLevel = previous.OwnerVIPLevel
entry.OwnerVIPExpiresAtMS = previous.OwnerVIPExpiresAtMS entry.OwnerVIPExpiresAtMS = previous.OwnerVIPExpiresAtMS
entry.OwnerVIPFallbackLevel = previous.OwnerVIPFallbackLevel
entry.OwnerVIPFallbackExpiresAtMS = previous.OwnerVIPFallbackExpiresAtMS
} }
if !entry.ActiveRedPacketProjectionKnown { if !entry.ActiveRedPacketProjectionKnown {
entry.HasActiveRedPacket = previous.HasActiveRedPacket entry.HasActiveRedPacket = previous.HasActiveRedPacket

View File

@ -38,6 +38,8 @@ type effectiveVIPAccess struct {
level int32 level int32
name string name string
expiresAtMS int64 expiresAtMS int64
fallbackLevel int32
fallbackExpiresAtMS int64
benefits map[string]struct{} benefits map[string]struct{}
roomEntryNoticeSettingEnabled bool roomEntryNoticeSettingEnabled bool
} }
@ -102,6 +104,14 @@ func (s *Service) loadEffectiveVIPAccess(ctx context.Context, requestID string,
access.name = strings.TrimSpace(effective.GetName()) access.name = strings.TrimSpace(effective.GetName())
access.expiresAtMS = effective.GetExpiresAtMs() access.expiresAtMS = effective.GetExpiresAtMs()
} }
if normalizeVIPCode(state.GetEffectiveSource()) == ledgerVipEffectiveSourceTrial {
if paid := state.GetPaidVip(); paid != nil && paid.GetActive() && paid.GetLevel() > 0 {
// 体验卡自然到期时 wallet 不会产生一次写事务事件;把 wallet 已求值的 paid
// 回落计划一起投影,列表可在毫秒边界切换而无需同步回查或复制 trial 合并规则。
access.fallbackLevel = paid.GetLevel()
access.fallbackExpiresAtMS = paid.GetExpiresAtMs()
}
}
for _, benefit := range state.GetEffectiveBenefits() { for _, benefit := range state.GetEffectiveBenefits() {
if benefit == nil { if benefit == nil {
continue continue
@ -113,6 +123,8 @@ func (s *Service) loadEffectiveVIPAccess(ctx context.Context, requestID string,
return access, nil return access, nil
} }
const ledgerVipEffectiveSourceTrial = "trial"
// requireCustomRoomBackgroundBenefit 只对配置为 P1 权益制的 App 执行自定义背景门禁。 // requireCustomRoomBackgroundBenefit 只对配置为 P1 权益制的 App 执行自定义背景门禁。
// action 由调用点传入精确动作Flutter 才能区分上传、保存和切换失败并保留对应本地状态。 // action 由调用点传入精确动作Flutter 才能区分上传、保存和切换失败并保留对应本地状态。
// legacy_timed 保持 Lalu 原行为;新 App 只需在 wallet 切换 program_type无需再修改 room-service。 // legacy_timed 保持 Lalu 原行为;新 App 只需在 wallet 切换 program_type无需再修改 room-service。

View File

@ -122,7 +122,8 @@ func (r *Repository) ListRoomListEntriesByIDs(ctx context.Context, query roomser
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms, heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms,
COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''), COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''),
owner_vip_level, owner_vip_expires_at_ms, rocket_near_completion, owner_vip_level, owner_vip_expires_at_ms, rocket_near_completion,
has_active_red_packet, active_red_packet_expires_at_ms has_active_red_packet, active_red_packet_expires_at_ms,
owner_vip_fallback_level, owner_vip_fallback_expires_at_ms
FROM room_list_entries FROM room_list_entries
WHERE app_code = ? AND room_id IN (`+placeholders+`)`, args...) WHERE app_code = ? AND room_id IN (`+placeholders+`)`, args...)
if err != nil { if err != nil {
@ -182,6 +183,51 @@ func (r *Repository) UpdateRoomOwnerVIP(ctx context.Context, ownerUserID int64,
return entry, true, nil return entry, true, nil
} }
// UpdateRoomOwnerVIPSchedule 把当前 effective VIP 及体验卡自然到期后的 paid 回落计划一起写入。
// 单行 UPDATE 和随后点查都命中 idx_room_list_owner新增列不参与索引不改变 feeds 查询计划。
func (r *Repository) UpdateRoomOwnerVIPSchedule(ctx context.Context, ownerUserID int64, level int32, expiresAtMS int64, fallbackLevel int32, fallbackExpiresAtMS int64) (roomservice.RoomListEntry, bool, error) {
if ownerUserID <= 0 {
return roomservice.RoomListEntry{}, false, nil
}
nowMS := time.Now().UTC().UnixMilli()
if level <= 0 || expiresAtMS <= nowMS {
level = 0
expiresAtMS = 0
}
if fallbackLevel <= 0 || fallbackExpiresAtMS <= nowMS {
fallbackLevel = 0
fallbackExpiresAtMS = 0
}
appCode := normalizedRecordAppCode(ctx, "")
if _, err := r.db.ExecContext(ctx, `
UPDATE room_list_entries
SET owner_vip_level = ?, owner_vip_expires_at_ms = ?,
owner_vip_fallback_level = ?, owner_vip_fallback_expires_at_ms = ?
WHERE app_code = ? AND owner_user_id = ?`,
level, expiresAtMS, fallbackLevel, fallbackExpiresAtMS, appCode, ownerUserID,
); err != nil {
return roomservice.RoomListEntry{}, false, err
}
rows, err := r.db.QueryContext(ctx, roomListSelectColumns+`
WHERE app_code = ? AND owner_user_id = ?
LIMIT 1`, appCode, ownerUserID)
if err != nil {
return roomservice.RoomListEntry{}, false, err
}
defer rows.Close()
if !rows.Next() {
if err := rows.Err(); err != nil {
return roomservice.RoomListEntry{}, false, err
}
return roomservice.RoomListEntry{}, false, nil
}
entry, err := scanRoomListEntry(rows)
if err != nil {
return roomservice.RoomListEntry{}, false, err
}
return entry, true, nil
}
// ListRoomListCacheEntries 按 app_code/room_id 主键批量扫描 active 列表投影,供 Redis 展示读模型重建。 // ListRoomListCacheEntries 按 app_code/room_id 主键批量扫描 active 列表投影,供 Redis 展示读模型重建。
func (r *Repository) ListRoomListCacheEntries(ctx context.Context, query roomservice.RoomListCacheEntryScanQuery) ([]roomservice.RoomListEntry, error) { func (r *Repository) ListRoomListCacheEntries(ctx context.Context, query roomservice.RoomListCacheEntryScanQuery) ([]roomservice.RoomListEntry, error) {
if query.Limit <= 0 { if query.Limit <= 0 {
@ -292,6 +338,9 @@ func scanRoomListEntry(scanner interface{ Scan(dest ...any) error }) (roomservic
dest = append(dest, &entry.HasActiveRedPacket, &entry.ActiveRedPacketExpiresAtMS) dest = append(dest, &entry.HasActiveRedPacket, &entry.ActiveRedPacketExpiresAtMS)
entry.ActiveRedPacketProjectionKnown = true entry.ActiveRedPacketProjectionKnown = true
} }
if columnCount >= len(dest)+2 {
dest = append(dest, &entry.OwnerVIPFallbackLevel, &entry.OwnerVIPFallbackExpiresAtMS)
}
err := scanner.Scan(dest...) err := scanner.Scan(dest...)
return entry, err return entry, err
} }
@ -320,6 +369,9 @@ func scanRoomListEntryWithPins(scanner interface{ Scan(dest ...any) error }) (ro
dest = append(dest, &entry.HasActiveRedPacket, &entry.ActiveRedPacketExpiresAtMS) dest = append(dest, &entry.HasActiveRedPacket, &entry.ActiveRedPacketExpiresAtMS)
entry.ActiveRedPacketProjectionKnown = true entry.ActiveRedPacketProjectionKnown = true
} }
if columnCount >= len(dest)+2+4 {
dest = append(dest, &entry.OwnerVIPFallbackLevel, &entry.OwnerVIPFallbackExpiresAtMS)
}
dest = append(dest, &pinned, &entry.PinListRank, &entry.PinWeight, &entry.PinnedUntilMS) dest = append(dest, &pinned, &entry.PinListRank, &entry.PinWeight, &entry.PinnedUntilMS)
err := scanner.Scan(dest...) err := scanner.Scan(dest...)
return entry, pinned, err return entry, pinned, err
@ -565,7 +617,8 @@ const roomListSelectColumns = `
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms, heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms,
COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''), COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''),
owner_vip_level, owner_vip_expires_at_ms, rocket_near_completion, owner_vip_level, owner_vip_expires_at_ms, rocket_near_completion,
has_active_red_packet, active_red_packet_expires_at_ms has_active_red_packet, active_red_packet_expires_at_ms,
owner_vip_fallback_level, owner_vip_fallback_expires_at_ms
FROM room_list_entries` FROM room_list_entries`
// owner 定向列表复用公共列表扫描器,但不参与公共发现页置顶排序;这里补零值列保持 scan 契约稳定。 // owner 定向列表复用公共列表扫描器,但不参与公共发现页置顶排序;这里补零值列保持 scan 契约稳定。
@ -575,6 +628,7 @@ const roomListOwnerSelectColumns = `
COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''), COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''),
owner_vip_level, owner_vip_expires_at_ms, rocket_near_completion, owner_vip_level, owner_vip_expires_at_ms, rocket_near_completion,
has_active_red_packet, active_red_packet_expires_at_ms, has_active_red_packet, active_red_packet_expires_at_ms,
owner_vip_fallback_level, owner_vip_fallback_expires_at_ms,
0 AS is_pinned, 0 AS pin_list_rank, 0 AS pin_weight, 0 AS pinned_until_ms 0 AS is_pinned, 0 AS pin_list_rank, 0 AS pin_weight, 0 AS pinned_until_ms
FROM room_list_entries` FROM room_list_entries`
@ -583,7 +637,8 @@ const roomUserFeedSelectColumns = `
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.updated_at_ms, r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.updated_at_ms,
COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''), COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''),
r.owner_vip_level, r.owner_vip_expires_at_ms, r.rocket_near_completion, r.owner_vip_level, r.owner_vip_expires_at_ms, r.rocket_near_completion,
r.has_active_red_packet, r.active_red_packet_expires_at_ms r.has_active_red_packet, r.active_red_packet_expires_at_ms,
r.owner_vip_fallback_level, r.owner_vip_fallback_expires_at_ms
FROM room_user_feed_entries f FROM room_user_feed_entries f
JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id` JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id`
@ -592,7 +647,8 @@ const roomFollowSelectColumns = `
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.followed_at_ms, r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.followed_at_ms,
COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''), COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''),
r.owner_vip_level, r.owner_vip_expires_at_ms, r.rocket_near_completion, r.owner_vip_level, r.owner_vip_expires_at_ms, r.rocket_near_completion,
r.has_active_red_packet, r.active_red_packet_expires_at_ms r.has_active_red_packet, r.active_red_packet_expires_at_ms,
r.owner_vip_fallback_level, r.owner_vip_fallback_expires_at_ms
FROM room_follows f FROM room_follows f
JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id` JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id`
@ -637,6 +693,7 @@ func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) {
COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''), COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''),
r.owner_vip_level, r.owner_vip_expires_at_ms, r.rocket_near_completion, r.owner_vip_level, r.owner_vip_expires_at_ms, r.rocket_near_completion,
r.has_active_red_packet, r.active_red_packet_expires_at_ms, r.has_active_red_packet, r.active_red_packet_expires_at_ms,
r.owner_vip_fallback_level, r.owner_vip_fallback_expires_at_ms,
` + pinnedExpr + ` AS is_pinned, ` + pinRankExpr + ` AS pin_list_rank, ` + pinWeightExpr + ` AS pin_weight, ` + pinExpiresExpr + ` AS pinned_until_ms ` + pinnedExpr + ` AS is_pinned, ` + pinRankExpr + ` AS pin_list_rank, ` + pinWeightExpr + ` AS pin_weight, ` + pinExpiresExpr + ` AS pinned_until_ms
FROM room_list_entries r FROM room_list_entries r
LEFT JOIN room_region_pins gp LEFT JOIN room_region_pins gp

View File

@ -37,6 +37,8 @@ func (r *Repository) Migrate(ctx context.Context) error {
owner_user_id BIGINT NOT NULL, owner_user_id BIGINT NOT NULL,
owner_vip_level INT NOT NULL DEFAULT 0, owner_vip_level INT NOT NULL DEFAULT 0,
owner_vip_expires_at_ms BIGINT NOT NULL DEFAULT 0, owner_vip_expires_at_ms BIGINT NOT NULL DEFAULT 0,
owner_vip_fallback_level INT NOT NULL DEFAULT 0,
owner_vip_fallback_expires_at_ms BIGINT NOT NULL DEFAULT 0,
title VARCHAR(128) NOT NULL DEFAULT '', title VARCHAR(128) NOT NULL DEFAULT '',
cover_url VARCHAR(512) NOT NULL DEFAULT '', cover_url VARCHAR(512) NOT NULL DEFAULT '',
mode VARCHAR(64) NOT NULL, mode VARCHAR(64) NOT NULL,
@ -530,6 +532,8 @@ func (r *Repository) ensureRoomVIPMediaSchema(ctx context.Context) error {
// 两列不参与索引和排序MySQL 8 使用 INSTANT 只改数据字典,不重写高频房间列表表。 // 两列不参与索引和排序MySQL 8 使用 INSTANT 只改数据字典,不重写高频房间列表表。
{"owner_vip_level", "ALTER TABLE room_list_entries ADD COLUMN owner_vip_level INT NOT NULL DEFAULT 0, ALGORITHM=INSTANT"}, {"owner_vip_level", "ALTER TABLE room_list_entries ADD COLUMN owner_vip_level INT NOT NULL DEFAULT 0, ALGORITHM=INSTANT"},
{"owner_vip_expires_at_ms", "ALTER TABLE room_list_entries ADD COLUMN owner_vip_expires_at_ms BIGINT NOT NULL DEFAULT 0, ALGORITHM=INSTANT"}, {"owner_vip_expires_at_ms", "ALTER TABLE room_list_entries ADD COLUMN owner_vip_expires_at_ms BIGINT NOT NULL DEFAULT 0, ALGORITHM=INSTANT"},
{"owner_vip_fallback_level", "ALTER TABLE room_list_entries ADD COLUMN owner_vip_fallback_level INT NOT NULL DEFAULT 0, ALGORITHM=INSTANT"},
{"owner_vip_fallback_expires_at_ms", "ALTER TABLE room_list_entries ADD COLUMN owner_vip_fallback_expires_at_ms BIGINT NOT NULL DEFAULT 0, ALGORITHM=INSTANT"},
} { } {
exists, err := r.columnExists(ctx, "room_list_entries", column.name) exists, err := r.columnExists(ctx, "room_list_entries", column.name)
if err != nil { if err != nil {

View File

@ -239,6 +239,15 @@ type UserVip struct {
ConfigVersion int64 ConfigVersion int64
} }
// UserEffectiveVipLevel 是批量展示查询需要的最小 wallet owner 事实。
// 它已经应用 program 开关、paid/trial 优先级和 UTC 过期边界,调用方只能展示,不能据此授权。
type UserEffectiveVipLevel struct {
UserID int64
Level int32
ExpiresAtMS int64
Source string
}
// VipTrialCard 把通用背包 entitlement 投影为 VIP 体验卡;装备切换只改变 Equipped // VipTrialCard 把通用背包 entitlement 投影为 VIP 体验卡;装备切换只改变 Equipped
// DurationMS、EffectiveAtMS 和 ExpiresAtMS 始终保留发卡时的绝对计时事实。 // DurationMS、EffectiveAtMS 和 ExpiresAtMS 始终保留发卡时的绝对计时事实。
type VipTrialCard struct { type VipTrialCard struct {

View File

@ -152,6 +152,8 @@ type VIPStore interface {
GetVipProgramConfig(ctx context.Context) (ledger.VipProgramConfig, []ledger.VipBenefit, error) GetVipProgramConfig(ctx context.Context) (ledger.VipProgramConfig, []ledger.VipBenefit, error)
// GetVipState 是 paid membership、当前佩戴体验卡和最终权益的唯一合并读模型。 // GetVipState 是 paid membership、当前佩戴体验卡和最终权益的唯一合并读模型。
GetVipState(ctx context.Context, userID int64) (ledger.VipState, error) GetVipState(ctx context.Context, userID int64) (ledger.VipState, error)
// BatchGetUserEffectiveVipLevels 用固定批量查询返回一页用户的最终等级,禁止调用方逐用户 GetVipState。
BatchGetUserEffectiveVipLevels(ctx context.Context, userIDs []int64, nowMS int64) ([]ledger.UserEffectiveVipLevel, error)
CheckVipBenefit(ctx context.Context, userID int64, benefitCode string) (ledger.CheckVipBenefitResult, error) CheckVipBenefit(ctx context.Context, userID int64, benefitCode string) (ledger.CheckVipBenefitResult, error)
// CheckVipResourceBenefit 校验具体资源是否仍绑定在当前 effective VIP 等级或其以下, // CheckVipResourceBenefit 校验具体资源是否仍绑定在当前 effective VIP 等级或其以下,
// 功能型权益继续由 CheckVipBenefit 按当前等级完整矩阵判定。 // 功能型权益继续由 CheckVipBenefit 按当前等级完整矩阵判定。

View File

@ -55,6 +55,29 @@ func (s *Service) GetVipState(ctx context.Context, userID int64) (ledger.VipStat
return s.repository.GetVipState(ctx, userID) return s.repository.GetVipState(ctx, userID)
} }
// BatchGetUserEffectiveVipLevels 为展示聚合提供 wallet effective VIP 的批量 owner 读。
// nowMS 只由 wallet-service 注入,调用方不能通过自定义时间读取未来或历史权益。
func (s *Service) BatchGetUserEffectiveVipLevels(ctx context.Context, userIDs []int64) ([]ledger.UserEffectiveVipLevel, int64, error) {
if len(userIDs) == 0 {
return []ledger.UserEffectiveVipLevel{}, s.now().UTC().UnixMilli(), nil
}
if len(userIDs) > 100 {
return nil, 0, xerr.New(xerr.InvalidArgument, "effective vip batch is too large")
}
for _, userID := range userIDs {
if userID <= 0 {
return nil, 0, xerr.New(xerr.InvalidArgument, "user_ids contains invalid value")
}
}
if s.repository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
evaluatedAtMS := s.now().UTC().UnixMilli()
items, err := s.repository.BatchGetUserEffectiveVipLevels(ctx, userIDs, evaluatedAtMS)
return items, evaluatedAtMS, err
}
// CheckVipBenefit 是房间、用户资料和通知 owner 执行单项权益时的服务端判定入口。 // CheckVipBenefit 是房间、用户资料和通知 owner 执行单项权益时的服务端判定入口。
func (s *Service) CheckVipBenefit(ctx context.Context, userID int64, benefitCode string) (ledger.CheckVipBenefitResult, error) { func (s *Service) CheckVipBenefit(ctx context.Context, userID int64, benefitCode string) (ledger.CheckVipBenefitResult, error) {
benefitCode = strings.ToLower(strings.TrimSpace(benefitCode)) benefitCode = strings.ToLower(strings.TrimSpace(benefitCode))

View File

@ -67,44 +67,26 @@ func (r *Repository) ListUserResources(ctx context.Context, query resourcedomain
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err
} }
// 背包要保留用户仍持有的 VIP 历史权益,但 equipped 必须表达“当前允许生效”,不能只反映装备表残留行。 // reconcile 会实际回收旧等级身份 entitlement这里仍保留读取侧门禁防止历史脏数据、
// 这里复用房间 appearance 的批量 VIP 门禁VIP 失效后客户端仍能看到权益记录,但不能再据此渲染装扮。 // 主从延迟或尚未触发懒同步的老用户短暂暴露多等级称号、长徽章和短徽章。
if err := r.filterCurrentVipEquipmentFlags(ctx, query.UserID, items, nowMs); err != nil { items, err = r.filterCurrentVipResourceList(ctx, query.UserID, items, nowMs)
if err != nil {
return nil, err return nil, err
} }
return items, nil return items, nil
} }
// filterCurrentVipEquipmentFlags 把原始 equipment 指针收敛为当前权限下的有效佩戴状态。 // filterCurrentVipResourceList 在一次固定批查询内同时收敛背包可见性和 equipped 标志。
// 非 VIP 资源原样保留;历史 VIP 绑定资源只有仍匹配当前 paid/trial 等级时才保持 equipped=true。 // 非身份型收藏继续按历级持有;三类身份素材只允许 effective level 作为防御性返回结果。
func (r *Repository) filterCurrentVipEquipmentFlags(ctx context.Context, userID int64, items []resourcedomain.UserResourceEntitlement, nowMS int64) error { func (r *Repository) filterCurrentVipResourceList(ctx context.Context, userID int64, items []resourcedomain.UserResourceEntitlement, nowMS int64) ([]resourcedomain.UserResourceEntitlement, error) {
equipped := make([]resourcedomain.UserResourceEntitlement, 0, len(items)) if len(items) == 0 {
for _, item := range items { return items, nil
if item.Equipped {
equipped = append(equipped, item)
}
} }
if len(equipped) == 0 { grouped := map[int64][]resourcedomain.UserResourceEntitlement{userID: items}
return nil if err := r.filterCurrentVipEquipmentBatch(ctx, []int64{userID}, grouped, nowMS, true); err != nil {
return nil, err
} }
return grouped[userID], nil
grouped := map[int64][]resourcedomain.UserResourceEntitlement{userID: equipped}
if err := r.filterCurrentVipEquipmentBatch(ctx, []int64{userID}, grouped, nowMS); err != nil {
return err
}
allowed := make(map[string]struct{}, len(grouped[userID]))
for _, item := range grouped[userID] {
allowed[item.EntitlementID] = struct{}{}
}
for index := range items {
if !items[index].Equipped {
continue
}
if _, ok := allowed[items[index].EntitlementID]; !ok {
items[index].Equipped = false
}
}
return nil
} }
// ListVipBenefitRequirementsForResource 查询资源在 tiered VIP 中的当前或历史显式绑定。 // ListVipBenefitRequirementsForResource 查询资源在 tiered VIP 中的当前或历史显式绑定。
@ -592,7 +574,7 @@ func (r *Repository) BatchGetUserEquippedResources(ctx context.Context, query re
} }
// appearance 是高频批量读,不能逐用户调用 CheckVipBenefit。这里用固定三次、全部走 // appearance 是高频批量读,不能逐用户调用 CheckVipBenefit。这里用固定三次、全部走
// app/user/resource 覆盖索引的批查询过滤掉改绑、删除、program disabled 后的历史 VIP 装备。 // app/user/resource 覆盖索引的批查询过滤掉改绑、删除、program disabled 后的历史 VIP 装备。
if err := r.filterCurrentVipEquipmentBatch(ctx, userIDs, grouped, nowMs); err != nil { if err := r.filterCurrentVipEquipmentBatch(ctx, userIDs, grouped, nowMs, false); err != nil {
return nil, err return nil, err
} }
allowedBenefits, effectiveVIPLevels, err := r.batchAllowedVipBenefits(ctx, userIDs, benefitCodes, nowMs) allowedBenefits, effectiveVIPLevels, err := r.batchAllowedVipBenefits(ctx, userIDs, benefitCodes, nowMs)

View File

@ -16,9 +16,21 @@ import (
) )
type activeVipBenefitResource struct { type activeVipBenefitResource struct {
BenefitCode string BenefitCode string
AutoEquip bool AutoEquip bool
Resource resourcedomain.Resource CurrentIdentity bool
Resource resourcedomain.Resource
}
// VIP 称号、长徽章和短徽章表达的是同一时刻的当前身份,不能像座驾、头像框等
// 收藏型装扮一样向下累计。三个稳定 code 必须在发放、装备门禁和展示投影中共用此判断。
func isCurrentLevelOnlyVipBenefit(benefitCode string) bool {
switch strings.ToLower(strings.TrimSpace(benefitCode)) {
case ledger.VipBenefitCodeTitle, ledger.VipBenefitCodeBadgeIdentity, ledger.VipBenefitCodeMedal:
return true
default:
return false
}
} }
// listActiveVipBenefitResourcesTx 只返回当前最终等级的自动佩戴候选。用户可以手动选择低等级 // listActiveVipBenefitResourcesTx 只返回当前最终等级的自动佩戴候选。用户可以手动选择低等级
@ -27,8 +39,8 @@ func (r *Repository) listActiveVipBenefitResourcesTx(ctx context.Context, tx *sq
return r.listVipBenefitResourcesTx(ctx, tx, level, effectiveSource, false) return r.listVipBenefitResourcesTx(ctx, tx, level, effectiveSource, false)
} }
// listPaidVipRewardResourcesTx 收集目标等级及其以下每一级显式绑定的物品。这里只改变付费 VIP // listPaidVipRewardResourcesTx 收集目标等级及其以下每一级显式绑定的收藏型物品VIP 称号、
// 的背包发放集合effective_benefits、佩戴门禁和 trial 仍只读当前等级的完整配置 // 长徽章和短徽章只取目标等级,避免直购高等级时把历级身份同时发入用户背包
func (r *Repository) listPaidVipRewardResourcesTx(ctx context.Context, tx *sql.Tx, level int32) ([]activeVipBenefitResource, error) { func (r *Repository) listPaidVipRewardResourcesTx(ctx context.Context, tx *sql.Tx, level int32) ([]activeVipBenefitResource, error) {
return r.listVipBenefitResourcesTx(ctx, tx, level, ledger.VipEffectiveSourcePaid, true) return r.listVipBenefitResourcesTx(ctx, tx, level, ledger.VipEffectiveSourcePaid, true)
} }
@ -41,11 +53,29 @@ func (r *Repository) listVipBenefitResourcesTx(ctx context.Context, tx *sql.Tx,
} }
trialOnly := effectiveSource == ledger.VipEffectiveSourceTrial trialOnly := effectiveSource == ledger.VipEffectiveSourceTrial
levelPredicate := `b.level = ?` levelPredicate := `b.level = ?`
levelArgs := []any{level}
if includeLowerLevels { if includeLowerLevels {
levelPredicate = `b.level <= ?` // 收藏型资源继续向下累计;三类 VIP 当前身份资源只允许命中最终等级。
levelPredicate = `b.level <= ? AND (b.level = ? OR b.benefit_code NOT IN (?, ?, ?))`
levelArgs = []any{
level,
level,
ledger.VipBenefitCodeTitle,
ledger.VipBenefitCodeBadgeIdentity,
ledger.VipBenefitCodeMedal,
}
} }
args := []any{
ledger.VipBenefitCodeTitle,
ledger.VipBenefitCodeBadgeIdentity,
ledger.VipBenefitCodeMedal,
appcode.FromContext(ctx),
}
args = append(args, levelArgs...)
args = append(args, trialOnly, ledger.VipBenefitCodeDailyCoinRebate)
rows, err := tx.QueryContext(ctx, ` rows, err := tx.QueryContext(ctx, `
SELECT MIN(b.benefit_code), MAX(b.auto_equip), b.resource_id SELECT MIN(b.benefit_code), MAX(b.auto_equip),
MAX(b.benefit_code IN (?, ?, ?)), b.resource_id
FROM vip_level_benefits AS b FROM vip_level_benefits AS b
JOIN resources AS resource ON resource.app_code = b.app_code JOIN resources AS resource ON resource.app_code = b.app_code
AND resource.resource_id = b.resource_id AND resource.resource_id = b.resource_id
@ -54,19 +84,20 @@ func (r *Repository) listVipBenefitResourcesTx(ctx context.Context, tx *sql.Tx,
WHERE b.app_code = ? AND `+levelPredicate+` AND b.status = 'active' AND b.resource_id > 0 WHERE b.app_code = ? AND `+levelPredicate+` AND b.status = 'active' AND b.resource_id > 0
AND (? = FALSE OR (b.trial_enabled = TRUE AND b.benefit_code <> ?)) AND (? = FALSE OR (b.trial_enabled = TRUE AND b.benefit_code <> ?))
GROUP BY b.resource_id GROUP BY b.resource_id
ORDER BY b.resource_id ASC`, appcode.FromContext(ctx), level, trialOnly, ledger.VipBenefitCodeDailyCoinRebate) ORDER BY b.resource_id ASC`, args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
type binding struct { type binding struct {
benefitCode string benefitCode string
autoEquip bool autoEquip bool
resourceID int64 currentIdentity bool
resourceID int64
} }
bindings := make([]binding, 0, 4) bindings := make([]binding, 0, 4)
for rows.Next() { for rows.Next() {
var item binding var item binding
if err := rows.Scan(&item.benefitCode, &item.autoEquip, &item.resourceID); err != nil { if err := rows.Scan(&item.benefitCode, &item.autoEquip, &item.currentIdentity, &item.resourceID); err != nil {
_ = rows.Close() _ = rows.Close()
return nil, err return nil, err
} }
@ -92,17 +123,18 @@ func (r *Repository) listVipBenefitResourcesTx(ctx context.Context, tx *sql.Tx,
} }
seen[binding.resourceID] = struct{}{} seen[binding.resourceID] = struct{}{}
items = append(items, activeVipBenefitResource{ items = append(items, activeVipBenefitResource{
BenefitCode: binding.benefitCode, BenefitCode: binding.benefitCode,
AutoEquip: binding.autoEquip, AutoEquip: binding.autoEquip,
Resource: resource, CurrentIdentity: binding.currentIdentity,
Resource: resource,
}) })
} }
return items, nil return items, nil
} }
// grantVipTrialBenefitResourcesTx 按 config_version 为一张体验卡创建来源独立的装扮 entitlement。 // grantVipTrialBenefitResourcesTx 按 config_version 为一张体验卡创建来源独立的装扮 entitlement。
// 体验卡与付费 VIP 使用相同的向下资源继承;改绑会生成新版本 grant未变化资源复用同一卡 // 收藏型资源向下累计,三类当前身份资源只发体验等级;改绑会生成新版本 grant未变化资源
// 已有 entitlement既拿到新资源又不会重复旧资源。 // 复用同一卡已有 entitlement既拿到新资源又不会重复旧资源。
func (r *Repository) grantVipTrialBenefitResourcesTx(ctx context.Context, tx *sql.Tx, card ledger.VipTrialCard, configVersion int64, nowMS int64) error { func (r *Repository) grantVipTrialBenefitResourcesTx(ctx context.Context, tx *sql.Tx, card ledger.VipTrialCard, configVersion int64, nowMS int64) error {
items, err := r.listVipBenefitResourcesTx(ctx, tx, card.Level, ledger.VipEffectiveSourceTrial, true) items, err := r.listVipBenefitResourcesTx(ctx, tx, card.Level, ledger.VipEffectiveSourceTrial, true)
if err != nil || len(items) == 0 { if err != nil || len(items) == 0 {
@ -123,8 +155,8 @@ func (r *Repository) grantVipTrialBenefitResourcesTx(ctx context.Context, tx *sq
if err != nil { if err != nil {
return err return err
} }
// v2 命令空间使已经按旧 target-only 规则补齐过的有效体验卡也会幂等补发低等级素材 // v3 固化“三类身份资源仅当前等级”的新请求语义,避免与 v2 的历级资源快照发生幂等冲突
grantCommandID := "vip_trial_benefit_v2:" + stableHash(fmt.Sprintf("%s|%s|%d", appcode.FromContext(ctx), card.TrialCardID, configVersion)) grantCommandID := "vip_trial_benefit_v3:" + stableHash(fmt.Sprintf("%s|%s|%d", appcode.FromContext(ctx), card.TrialCardID, configVersion))
requestHash := stableHash(fmt.Sprintf("%s|%d|%s", appcode.FromContext(ctx), card.UserID, snapshotJSON)) requestHash := stableHash(fmt.Sprintf("%s|%d|%s", appcode.FromContext(ctx), card.UserID, snapshotJSON))
if _, exists, err := r.lookupResourceGrant(ctx, tx, grantCommandID, requestHash); err != nil || exists { if _, exists, err := r.lookupResourceGrant(ctx, tx, grantCommandID, requestHash); err != nil || exists {
return err return err
@ -175,9 +207,9 @@ func (r *Repository) grantVipTrialBenefitResourcesTx(ctx context.Context, tx *sq
} }
// grantPaidVipBenefitResourcesTx 直接从 active benefit bindings 发付费 entitlement彻底去掉 // grantPaidVipBenefitResourcesTx 直接从 active benefit bindings 发付费 entitlement彻底去掉
// “运营还要同步 reward group”的隐含前置条件。直购高等级时要把 1..level 各级物品都发入背包 // “运营还要同步 reward group”的隐含前置条件。直购高等级时累计 1..level 的收藏型物品
// 但同一 resource_id 只发一次grant subject 仍绑定本次会员有效窗,功能权限仍不做向下合并。 // 但称号、长徽章和短徽章只发当前等级;功能权限仍不做向下合并。
func (r *Repository) grantPaidVipBenefitResourcesTx(ctx context.Context, tx *sql.Tx, vip ledger.UserVip, nowMS int64) ([]ledger.VipRewardItem, string, error) { func (r *Repository) grantPaidVipBenefitResourcesTx(ctx context.Context, tx *sql.Tx, vip ledger.UserVip, includeCurrentIdentity bool, nowMS int64) ([]ledger.VipRewardItem, string, error) {
if !vip.Active || vip.Level <= 0 || vip.ExpiresAtMS <= nowMS { if !vip.Active || vip.Level <= 0 || vip.ExpiresAtMS <= nowMS {
return nil, "", nil return nil, "", nil
} }
@ -185,6 +217,20 @@ func (r *Repository) grantPaidVipBenefitResourcesTx(ctx context.Context, tx *sql
if err != nil || len(items) == 0 { if err != nil || len(items) == 0 {
return nil, "", err return nil, "", err
} }
if !includeCurrentIdentity {
// 更高 trial 是最终身份时paid 仍正常累计座驾、头像框等收藏品,但三类身份素材
// 不得先写 entitlement/outbox 再回收,否则异步徽章投影仍可能短暂收到低等级事实。
filtered := items[:0]
for _, item := range items {
if !item.CurrentIdentity {
filtered = append(filtered, item)
}
}
items = filtered
if len(items) == 0 {
return nil, "", nil
}
}
resourceIDs := make([]int64, 0, len(items)) resourceIDs := make([]int64, 0, len(items))
for _, item := range items { for _, item := range items {
resourceIDs = append(resourceIDs, item.Resource.ResourceID) resourceIDs = append(resourceIDs, item.Resource.ResourceID)
@ -195,13 +241,23 @@ func (r *Repository) grantPaidVipBenefitResourcesTx(ctx context.Context, tx *sql
Level int32 `json:"level"` Level int32 `json:"level"`
ExpiresAtMS int64 `json:"expires_at_ms"` ExpiresAtMS int64 `json:"expires_at_ms"`
ConfigVersion int64 `json:"config_version"` ConfigVersion int64 `json:"config_version"`
WithIdentity bool `json:"with_identity"`
ResourceIDs []int64 `json:"resource_ids"` ResourceIDs []int64 `json:"resource_ids"`
}{vip.UserID, vip.Level, vip.ExpiresAtMS, vip.ConfigVersion, resourceIDs} }{
UserID: vip.UserID, Level: vip.Level, ExpiresAtMS: vip.ExpiresAtMS,
ConfigVersion: vip.ConfigVersion, WithIdentity: includeCurrentIdentity,
ResourceIDs: resourceIDs,
}
snapshotJSON, err := json.Marshal(snapshot) snapshotJSON, err := json.Marshal(snapshot)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
grantCommandID := "vip_paid_benefit:" + stableHash(fmt.Sprintf("%s|%d|%d|%d|%d", appcode.FromContext(ctx), vip.UserID, vip.Level, vip.ExpiresAtMS, vip.ConfigVersion)) // v3 同时隔离旧版历级身份快照与 trial 生效期间的 collectible-only 发放trial
// 回落时 with_identity=true 使用独立幂等命令,不能与先前无身份素材的 grant 冲突。
grantCommandID := "vip_paid_benefit_v3:" + stableHash(fmt.Sprintf(
"%s|%d|%d|%d|%d|%t",
appcode.FromContext(ctx), vip.UserID, vip.Level, vip.ExpiresAtMS, vip.ConfigVersion, includeCurrentIdentity,
))
requestHash := stableHash(fmt.Sprintf("%s|%d|%s", appcode.FromContext(ctx), vip.UserID, snapshotJSON)) requestHash := stableHash(fmt.Sprintf("%s|%d|%s", appcode.FromContext(ctx), vip.UserID, snapshotJSON))
if existingGrantID, exists, err := r.lookupResourceGrant(ctx, tx, grantCommandID, requestHash); err != nil { if existingGrantID, exists, err := r.lookupResourceGrant(ctx, tx, grantCommandID, requestHash); err != nil {
return nil, "", err return nil, "", err
@ -290,9 +346,496 @@ func (r *Repository) findExistingVipBenefitEntitlementTx(ctx context.Context, tx
return entitlementID, true, nil return entitlementID, true, nil
} }
// reconcileTieredVipResourceEquipmentTx 删除高于最终 effective level、目录失效或 entitlement type vipIdentityGrantContext struct {
// 已失效的 VIP 装备,再按当前等级 benefit.auto_equip 恢复空槽位。preserveValidSelection 用于 EffectiveSource string
// 纯读取懒同步:合法低等级素材和普通用户选择都必须保留;首次生效或升级则传 false明确切到新等级默认素材。 GrantSource string
GrantSubjectID string
LegacyCommandID string
ExpiresAtMS int64
}
type activeVipIdentityEntitlement struct {
EntitlementID string
ResourceID int64
SourceGrantID string
EffectiveAtMS int64
ExpiresAtMS int64
Remaining int64
GrantSource string
GrantSubjectID string
GrantCommandID string
GrantStatus string
}
// currentVipIdentityGrantContextTx 把当前 paid membership 或已佩戴 trial card 固化为三类
// 身份 entitlement 的唯一来源和绝对有效期。调用方已按 program -> membership 加锁;
// 这里继续锁定具体来源事实,避免等级切换与回收在两个事务中交错。
func (r *Repository) currentVipIdentityGrantContextTx(ctx context.Context, tx *sql.Tx, userID int64, level int32, effectiveSource string, nowMS int64) (vipIdentityGrantContext, error) {
switch effectiveSource {
case ledger.VipEffectiveSourcePaid:
var expiresAtMS int64
if err := tx.QueryRowContext(ctx, `
SELECT expires_at_ms
FROM user_vip_memberships FORCE INDEX (PRIMARY)
WHERE app_code = ? AND user_id = ? AND level = ? AND status = ?
AND expires_at_ms > ?
FOR UPDATE`,
appcode.FromContext(ctx), userID, level, ledger.VipStatusActive, nowMS,
).Scan(&expiresAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return vipIdentityGrantContext{}, xerr.New(xerr.NotFound, "active paid vip identity source not found")
}
return vipIdentityGrantContext{}, err
}
return vipIdentityGrantContext{
EffectiveSource: effectiveSource,
GrantSource: resourcedomain.GrantSourceVIPPaid,
// 与正常 paid benefit grant 使用同一 membership subjectreconcile 只能
// 保留当前有效窗创建的实例,不能误选旧会员周期的同 resource entitlement。
GrantSubjectID: "vip_paid_membership:" + stableHash(fmt.Sprintf(
"%s|%d|%d|%d",
appcode.FromContext(ctx), userID, level, expiresAtMS,
)),
ExpiresAtMS: expiresAtMS,
}, nil
case ledger.VipEffectiveSourceTrial:
var trialCardID string
var expiresAtMS int64
if err := tx.QueryRowContext(ctx, `
SELECT card.trial_card_id, card.expires_at_ms
FROM user_resource_equipment AS equipment FORCE INDEX (PRIMARY)
JOIN user_vip_trial_cards AS card
ON card.app_code = equipment.app_code
AND card.user_id = equipment.user_id
AND card.entitlement_id = equipment.entitlement_id
JOIN user_resource_entitlements AS entitlement
ON entitlement.app_code = equipment.app_code
AND entitlement.user_id = equipment.user_id
AND entitlement.entitlement_id = equipment.entitlement_id
WHERE equipment.app_code = ? AND equipment.user_id = ?
AND equipment.resource_type = ?
AND card.level = ? AND card.status = ?
AND card.effective_at_ms <= ? AND card.expires_at_ms > ?
AND entitlement.status = 'active'
AND entitlement.effective_at_ms <= ?
AND (entitlement.expires_at_ms = 0 OR entitlement.expires_at_ms > ?)
AND entitlement.remaining_quantity > 0
LIMIT 1 FOR UPDATE`,
appcode.FromContext(ctx), userID, resourcedomain.TypeVIPTrialCard,
level, ledger.VipTrialCardStatusActive, nowMS, nowMS, nowMS, nowMS,
).Scan(&trialCardID, &expiresAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return vipIdentityGrantContext{}, xerr.New(xerr.NotFound, "equipped vip trial identity source not found")
}
return vipIdentityGrantContext{}, err
}
return vipIdentityGrantContext{
EffectiveSource: effectiveSource,
GrantSource: resourcedomain.GrantSourceVIPTrial,
GrantSubjectID: vipTrialBenefitGrantSubjectID(appcode.FromContext(ctx), trialCardID),
LegacyCommandID: "vip_trial_benefit:" + trialCardID,
ExpiresAtMS: expiresAtMS,
}, nil
default:
return vipIdentityGrantContext{EffectiveSource: ledger.VipEffectiveSourceNone}, nil
}
}
// listActiveVipIdentityEntitlementsForUpdateTx 只扫描单用户 active entitlement 索引范围,
// 再通过 resource_id 的 current/history 小表索引识别三类 VIP 身份素材。旧 grant 保持
// 审计不可变;这里只锁将要保留或回收的 entitlement绝不整笔撤销含座驾等收藏品的 grant。
func (r *Repository) listActiveVipIdentityEntitlementsForUpdateTx(ctx context.Context, tx *sql.Tx, userID int64) ([]activeVipIdentityEntitlement, error) {
rows, err := tx.QueryContext(ctx, `
SELECT entitlement.entitlement_id, entitlement.resource_id,
entitlement.source_grant_id,
entitlement.effective_at_ms, entitlement.expires_at_ms,
entitlement.remaining_quantity
FROM user_resource_entitlements AS entitlement FORCE INDEX (idx_user_resource_user_type)
WHERE entitlement.app_code = ? AND entitlement.user_id = ?
AND entitlement.status = 'active'
AND (
EXISTS (
SELECT 1
FROM vip_level_benefits AS current_benefit
JOIN vip_program_configs AS current_program
ON current_program.app_code = current_benefit.app_code
AND current_program.program_type = ?
WHERE current_benefit.app_code = entitlement.app_code
AND current_benefit.resource_id = entitlement.resource_id
AND current_benefit.benefit_code IN (?, ?, ?)
)
OR EXISTS (
SELECT 1
FROM vip_benefit_resource_binding_history AS history
WHERE history.app_code = entitlement.app_code
AND history.resource_id = entitlement.resource_id
AND history.program_type = ?
AND history.benefit_code IN (?, ?, ?)
)
)
ORDER BY entitlement.resource_id ASC, entitlement.expires_at_ms DESC,
entitlement.created_at_ms DESC, entitlement.entitlement_id ASC
FOR UPDATE`,
appcode.FromContext(ctx), userID,
ledger.VipProgramTypeTieredPrivilegeV1,
ledger.VipBenefitCodeTitle,
ledger.VipBenefitCodeBadgeIdentity,
ledger.VipBenefitCodeMedal,
ledger.VipProgramTypeTieredPrivilegeV1,
ledger.VipBenefitCodeTitle,
ledger.VipBenefitCodeBadgeIdentity,
ledger.VipBenefitCodeMedal,
)
if err != nil {
return nil, err
}
items := make([]activeVipIdentityEntitlement, 0, 6)
grantIDSet := make(map[string]struct{}, 6)
for rows.Next() {
var item activeVipIdentityEntitlement
if err := rows.Scan(
&item.EntitlementID,
&item.ResourceID,
&item.SourceGrantID,
&item.EffectiveAtMS,
&item.ExpiresAtMS,
&item.Remaining,
); err != nil {
_ = rows.Close()
return nil, err
}
items = append(items, item)
grantIDSet[item.SourceGrantID] = struct{}{}
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
if len(items) == 0 {
return nil, nil
}
// grant 事实只用于判断来源,不参与写入。先锁 entitlement、再做普通一致性读取
// 可与后台 RevokeResourceGrant 的 grant -> entitlement 锁序共存,避免反向锁死。
grantIDs := make([]string, 0, len(grantIDSet))
for grantID := range grantIDSet {
grantIDs = append(grantIDs, grantID)
}
sort.Strings(grantIDs)
grantArgs := []any{appcode.FromContext(ctx)}
grantArgs = append(grantArgs, stringAnyArgs(grantIDs)...)
grantRows, err := tx.QueryContext(ctx, `
SELECT grant_id, grant_source, grant_subject_id, command_id, status
FROM resource_grants
WHERE app_code = ? AND grant_id IN (`+placeholders(len(grantIDs))+`)`, grantArgs...)
if err != nil {
return nil, err
}
type grantMetadata struct {
source string
subjectID string
commandID string
status string
}
grants := make(map[string]grantMetadata, len(grantIDs))
for grantRows.Next() {
var grantID string
var metadata grantMetadata
if err := grantRows.Scan(
&grantID,
&metadata.source,
&metadata.subjectID,
&metadata.commandID,
&metadata.status,
); err != nil {
_ = grantRows.Close()
return nil, err
}
grants[grantID] = metadata
}
if err := grantRows.Err(); err != nil {
_ = grantRows.Close()
return nil, err
}
if err := grantRows.Close(); err != nil {
return nil, err
}
filtered := items[:0]
for _, item := range items {
metadata, exists := grants[item.SourceGrantID]
if !exists || !isVipIdentityGrantSource(metadata.source) {
continue
}
item.GrantSource = metadata.source
item.GrantSubjectID = metadata.subjectID
item.GrantCommandID = metadata.commandID
item.GrantStatus = metadata.status
filtered = append(filtered, item)
}
return filtered, nil
}
func isVipIdentityGrantSource(source string) bool {
switch strings.ToLower(strings.TrimSpace(source)) {
case resourcedomain.GrantSourceVIPPaid,
resourcedomain.GrantSourceVIPTrial,
ledger.VipGrantSourcePurchase,
ledger.VipGrantSourceActivity,
ledger.VipGrantSourceAdmin,
ledger.VipGrantSourceManagerCenter:
return true
default:
return false
}
}
func vipIdentityEntitlementMatchesCurrentSource(item activeVipIdentityEntitlement, grantContext vipIdentityGrantContext, nowMS int64) bool {
if item.GrantStatus != resourcedomain.GrantStatusDone ||
item.EffectiveAtMS > nowMS ||
(item.ExpiresAtMS != 0 && item.ExpiresAtMS <= nowMS) ||
item.Remaining <= 0 {
return false
}
switch grantContext.EffectiveSource {
case ledger.VipEffectiveSourcePaid:
return item.GrantSource == resourcedomain.GrantSourceVIPPaid &&
item.GrantSubjectID == grantContext.GrantSubjectID
case ledger.VipEffectiveSourceTrial:
return item.GrantSource == resourcedomain.GrantSourceVIPTrial &&
(item.GrantSubjectID == grantContext.GrantSubjectID ||
item.GrantCommandID == grantContext.LegacyCommandID)
default:
return false
}
}
// grantMissingVipIdentityResourcesTx 只在当前来源的某个身份素材确实不存在时创建新 grant。
// 重新生成 entitlement 而不是把已回收行改回 active可保留每次 VIP 来源切换的完整审计,
// 并重新发布 ResourceGroupGranted 让徽章投影在体验卡到期回落时恢复当前 paid 素材。
func (r *Repository) grantMissingVipIdentityResourcesTx(
ctx context.Context,
tx *sql.Tx,
userID int64,
level int32,
grantContext vipIdentityGrantContext,
commandID string,
items []activeVipBenefitResource,
nowMS int64,
) ([]string, walletOutboxEvent, error) {
if len(items) == 0 {
return nil, walletOutboxEvent{}, nil
}
durationMS := grantContext.ExpiresAtMS - nowMS
if durationMS <= 0 {
return nil, walletOutboxEvent{}, xerr.New(xerr.Conflict, "vip identity source already expired")
}
resourceIDs := make([]int64, 0, len(items))
benefitCodes := make([]string, 0, len(items))
for _, item := range items {
resourceIDs = append(resourceIDs, item.Resource.ResourceID)
benefitCodes = append(benefitCodes, item.BenefitCode)
}
snapshot := struct {
UserID int64 `json:"user_id"`
Level int32 `json:"level"`
EffectiveSource string `json:"effective_source"`
ExpiresAtMS int64 `json:"expires_at_ms"`
ResourceIDs []int64 `json:"resource_ids"`
BenefitCodes []string `json:"benefit_codes"`
}{
UserID: userID, Level: level, EffectiveSource: grantContext.EffectiveSource,
ExpiresAtMS: grantContext.ExpiresAtMS, ResourceIDs: resourceIDs, BenefitCodes: benefitCodes,
}
snapshotJSON, err := json.Marshal(snapshot)
if err != nil {
return nil, walletOutboxEvent{}, err
}
// nowMS 只参与内部恢复实例的唯一性membership/card 锁保证同一时刻只有一个事务
// 判断“缺失”,重试提交后会先看到新 active entitlement不会重复发放。
grantCommandID := "vip_identity_restore_v1:" + stableHash(fmt.Sprintf(
"%s|%d|%d|%s|%d|%s|%s|%d",
appcode.FromContext(ctx), userID, level, grantContext.EffectiveSource,
grantContext.ExpiresAtMS, grantContext.GrantSubjectID, commandID, nowMS,
))
grantID := resourceGrantID(appcode.FromContext(ctx), grantCommandID)
requestHash := stableHash(fmt.Sprintf("%s|%s", appcode.FromContext(ctx), snapshotJSON))
if err := r.insertResourceGrant(
ctx,
tx,
grantID,
grantCommandID,
userID,
grantContext.GrantSource,
resourcedomain.GrantSubjectGroup,
grantContext.GrantSubjectID,
requestHash,
string(snapshotJSON),
"restore current vip identity resources",
userID,
nowMS,
); err != nil {
return nil, walletOutboxEvent{}, err
}
entitlementIDs := make([]string, 0, len(items))
for _, item := range items {
inserted, err := r.applyVipGrantItemWithoutAutoEquipTx(
ctx,
tx,
grantID,
grantCommandID,
userID,
item.Resource,
1,
durationMS,
nowMS,
)
if err != nil {
return nil, walletOutboxEvent{}, err
}
if err := r.alignEntitlementExpiresAt(ctx, tx, inserted.EntitlementID, grantContext.ExpiresAtMS, nowMS); err != nil {
return nil, walletOutboxEvent{}, err
}
entitlementIDs = append(entitlementIDs, inserted.EntitlementID)
}
event := resourceOutboxEvent("ResourceGroupGranted", grantCommandID, userID, 0, map[string]any{
"grant_id": grantID, "source": grantContext.GrantSource,
"vip_level": level, "effective_source": grantContext.EffectiveSource,
"expires_at_ms": grantContext.ExpiresAtMS, "resource_ids": resourceIDs,
"identity_only": true,
}, nowMS)
return entitlementIDs, event, nil
}
// reconcileCurrentVipIdentityEntitlementsTx 先补齐最终 effective level 的三类身份素材,
// 再把其它等级、其它来源和重复实例全部标记 revoked。两步与 equipment reconcile 共用
// 一个事务,所以任意时刻数据库中最多只存在当前等级的一套 active 身份 entitlement。
func (r *Repository) reconcileCurrentVipIdentityEntitlementsTx(
ctx context.Context,
tx *sql.Tx,
userID int64,
level int32,
effectiveSource string,
commandID string,
nowMS int64,
items []activeVipBenefitResource,
) ([]walletOutboxEvent, error) {
identityItems := make([]activeVipBenefitResource, 0, 3)
for _, item := range items {
if item.CurrentIdentity {
identityItems = append(identityItems, item)
}
}
grantContext, err := r.currentVipIdentityGrantContextTx(ctx, tx, userID, level, effectiveSource, nowMS)
if err != nil {
return nil, err
}
activeItems, err := r.listActiveVipIdentityEntitlementsForUpdateTx(ctx, tx, userID)
if err != nil {
return nil, err
}
keep := make(map[string]struct{}, len(identityItems))
missing := make([]activeVipBenefitResource, 0, len(identityItems))
for _, desired := range identityItems {
found := false
for _, entitlement := range activeItems {
if entitlement.ResourceID != desired.Resource.ResourceID ||
!vipIdentityEntitlementMatchesCurrentSource(entitlement, grantContext, nowMS) {
continue
}
keep[entitlement.EntitlementID] = struct{}{}
if err := r.alignEntitlementExpiresAt(ctx, tx, entitlement.EntitlementID, grantContext.ExpiresAtMS, nowMS); err != nil {
return nil, err
}
found = true
break
}
if !found {
missing = append(missing, desired)
}
}
events := make([]walletOutboxEvent, 0, 2)
if len(missing) > 0 {
insertedIDs, grantEvent, err := r.grantMissingVipIdentityResourcesTx(
ctx, tx, userID, level, grantContext, commandID, missing, nowMS,
)
if err != nil {
return nil, err
}
for _, entitlementID := range insertedIDs {
keep[entitlementID] = struct{}{}
}
events = append(events, grantEvent)
}
revokeIDs := make([]string, 0, len(activeItems))
reclaimedResourceIDs := make([]int64, 0, len(activeItems))
for _, entitlement := range activeItems {
if _, exists := keep[entitlement.EntitlementID]; exists {
continue
}
revokeIDs = append(revokeIDs, entitlement.EntitlementID)
reclaimedResourceIDs = append(reclaimedResourceIDs, entitlement.ResourceID)
}
if len(revokeIDs) == 0 {
return events, nil
}
args := []any{appcode.FromContext(ctx), userID}
args = append(args, stringAnyArgs(revokeIDs)...)
if _, err := tx.ExecContext(ctx, `
DELETE FROM user_resource_equipment
WHERE app_code = ? AND user_id = ?
AND entitlement_id IN (`+placeholders(len(revokeIDs))+`)`, args...); err != nil {
return nil, err
}
updateArgs := []any{
resourcedomain.GrantStatusRevoked,
nowMS,
appcode.FromContext(ctx),
userID,
resourcedomain.StatusActive,
}
updateArgs = append(updateArgs, stringAnyArgs(revokeIDs)...)
result, err := tx.ExecContext(ctx, `
UPDATE user_resource_entitlements
SET status = ?, remaining_quantity = 0, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND status = ?
AND entitlement_id IN (`+placeholders(len(revokeIDs))+`)`, updateArgs...)
if err != nil {
return nil, err
}
affected, err := result.RowsAffected()
if err != nil {
return nil, err
}
if affected != int64(len(revokeIDs)) {
return nil, xerr.New(xerr.LedgerConflict, "vip identity entitlement reclaim state conflict")
}
reclaimCommandID := "vip_identity_reclaim_v1:" + stableHash(fmt.Sprintf(
"%s|%d|%d|%s|%s|%d",
appcode.FromContext(ctx), userID, level, effectiveSource, strings.Join(revokeIDs, ","), nowMS,
))
events = append(events, resourceOutboxEvent("UserResourceChanged", reclaimCommandID, userID, 0, map[string]any{
"action": "vip_identity_reclaim", "vip_level": level, "effective_source": effectiveSource,
"benefit_codes": []string{
ledger.VipBenefitCodeTitle,
ledger.VipBenefitCodeBadgeIdentity,
ledger.VipBenefitCodeMedal,
},
"reclaimed_count": len(revokeIDs), "resource_ids": reclaimedResourceIDs,
"updated_at_ms": nowMS,
}, nowMS))
return events, nil
}
// reconcileTieredVipResourceEquipmentTx 先把三类当前身份素材实际收敛到最终 effective level
// 再删除目录失效或 entitlement 已失效的装备,并按当前等级 auto_equip 恢复空槽位。
// preserveValidSelection 只适用于座驾、头像框等收藏型装扮;身份素材永远不能保留低等级实例。
func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context, tx *sql.Tx, userID int64, level int32, effectiveSource string, commandID string, nowMS int64, preserveValidSelection bool) error { func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context, tx *sql.Tx, userID int64, level int32, effectiveSource string, commandID string, nowMS int64, preserveValidSelection bool) error {
trialOnly := effectiveSource == ledger.VipEffectiveSourceTrial trialOnly := effectiveSource == ledger.VipEffectiveSourceTrial
if effectiveSource != ledger.VipEffectiveSourcePaid && effectiveSource != ledger.VipEffectiveSourceTrial { if effectiveSource != ledger.VipEffectiveSourcePaid && effectiveSource != ledger.VipEffectiveSourceTrial {
@ -342,13 +885,22 @@ func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context,
AND current_resource.resource_type = current_benefit.resource_type AND current_resource.resource_type = current_benefit.resource_type
WHERE current_benefit.app_code = equipment.app_code WHERE current_benefit.app_code = equipment.app_code
AND current_benefit.level <= ? AND current_benefit.level <= ?
AND (
current_benefit.level = ?
OR current_benefit.benefit_code NOT IN (?, ?, ?)
)
AND current_benefit.status = 'active' AND current_benefit.status = 'active'
AND current_benefit.resource_id = equipment.resource_id AND current_benefit.resource_id = equipment.resource_id
AND (? = FALSE OR (current_benefit.trial_enabled = TRUE AND current_benefit.benefit_code <> ?)) AND (? = FALSE OR (current_benefit.trial_enabled = TRUE AND current_benefit.benefit_code <> ?))
) )
)`, appcode.FromContext(ctx), userID, )`, appcode.FromContext(ctx), userID,
ledger.VipProgramTypeTieredPrivilegeV1, ledger.VipProgramTypeTieredPrivilegeV1, ledger.VipProgramTypeTieredPrivilegeV1, ledger.VipProgramTypeTieredPrivilegeV1,
nowMS, nowMS, level, trialOnly, ledger.VipBenefitCodeDailyCoinRebate) nowMS, nowMS,
level, level,
ledger.VipBenefitCodeTitle,
ledger.VipBenefitCodeBadgeIdentity,
ledger.VipBenefitCodeMedal,
trialOnly, ledger.VipBenefitCodeDailyCoinRebate)
if err != nil { if err != nil {
return err return err
} }
@ -360,6 +912,12 @@ func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context,
if err != nil { if err != nil {
return err return err
} }
identityEvents, err := r.reconcileCurrentVipIdentityEntitlementsTx(
ctx, tx, userID, level, effectiveSource, commandID, nowMS, items,
)
if err != nil {
return err
}
validEquippedTypes := map[string]struct{}{} validEquippedTypes := map[string]struct{}{}
if preserveValidSelection && len(items) > 0 { if preserveValidSelection && len(items) > 0 {
validEquippedTypes, err = r.listValidEquippedResourceTypesTx(ctx, tx, userID, nowMS) validEquippedTypes, err = r.listValidEquippedResourceTypesTx(ctx, tx, userID, nowMS)
@ -367,7 +925,8 @@ func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context,
return err return err
} }
} }
events := make([]walletOutboxEvent, 0, len(items)+1) events := make([]walletOutboxEvent, 0, len(items)+len(identityEvents)+1)
events = append(events, identityEvents...)
if removed > 0 { if removed > 0 {
events = append(events, resourceOutboxEvent("UserResourceChanged", boundedDerivedCommandID("vip_reconcile_remove:", commandID), userID, 0, map[string]any{ events = append(events, resourceOutboxEvent("UserResourceChanged", boundedDerivedCommandID("vip_reconcile_remove:", commandID), userID, 0, map[string]any{
"action": "vip_reconcile", "vip_level": level, "effective_source": effectiveSource, "action": "vip_reconcile", "vip_level": level, "effective_source": effectiveSource,
@ -535,8 +1094,8 @@ func (r *Repository) isTieredVipBenefitResourceTx(ctx context.Context, tx *sql.T
} }
// requireCurrentVipResourceBindingForUserTx 是装备提交事务内的最后门禁。它先共享锁 program // requireCurrentVipResourceBindingForUserTx 是装备提交事务内的最后门禁。它先共享锁 program
// 再通过 idx_vip_benefit_resource 对单 resource_id 做 level <= effective level 的有界检查; // 再通过 idx_vip_benefit_resource 对单 resource_id 做有界检查;收藏型装扮允许命中低等级,
// 因而 admin 的 DELETE+INSERT 改绑不可能夹在校验与 equipment 写入之间。 // 三类当前身份资源必须精确命中 effective level。admin 改绑不能夹在校验与装备写入之间。
func (r *Repository) requireCurrentVipResourceBindingForUserTx(ctx context.Context, tx *sql.Tx, userID int64, resourceID int64, nowMS int64) error { func (r *Repository) requireCurrentVipResourceBindingForUserTx(ctx context.Context, tx *sql.Tx, userID int64, resourceID int64, nowMS int64) error {
bound, err := r.isTieredVipBenefitResourceTx(ctx, tx, resourceID) bound, err := r.isTieredVipBenefitResourceTx(ctx, tx, resourceID)
if err != nil || !bound { if err != nil || !bound {
@ -578,8 +1137,16 @@ func (r *Repository) requireCurrentVipResourceBindingForUserTx(ctx context.Conte
AND resource.resource_type = benefit.resource_type AND resource.resource_type = benefit.resource_type
WHERE benefit.app_code = ? AND benefit.resource_id = ? WHERE benefit.app_code = ? AND benefit.resource_id = ?
AND benefit.status = 'active' AND benefit.level <= ? AND benefit.status = 'active' AND benefit.level <= ?
AND (
benefit.level = ?
OR benefit.benefit_code NOT IN (?, ?, ?)
)
AND (? = FALSE OR (benefit.trial_enabled = TRUE AND benefit.benefit_code <> ?)) AND (? = FALSE OR (benefit.trial_enabled = TRUE AND benefit.benefit_code <> ?))
)`, appcode.FromContext(ctx), resourceID, state.EffectiveVip.Level, )`, appcode.FromContext(ctx), resourceID,
state.EffectiveVip.Level, state.EffectiveVip.Level,
ledger.VipBenefitCodeTitle,
ledger.VipBenefitCodeBadgeIdentity,
ledger.VipBenefitCodeMedal,
trialOnly, ledger.VipBenefitCodeDailyCoinRebate, trialOnly, ledger.VipBenefitCodeDailyCoinRebate,
).Scan(&allowed); err != nil { ).Scan(&allowed); err != nil {
return err return err
@ -683,7 +1250,9 @@ func (r *Repository) ensureTieredVipResourceState(ctx context.Context, userID in
paid := state.PaidVip paid := state.PaidVip
paid.ProgramType = program.ProgramType paid.ProgramType = program.ProgramType
paid.ConfigVersion = program.ConfigVersion paid.ConfigVersion = program.ConfigVersion
if _, _, err := r.grantPaidVipBenefitResourcesTx(ctx, tx, paid, nowMS); err != nil { if _, _, err := r.grantPaidVipBenefitResourcesTx(
ctx, tx, paid, state.EffectiveSource == ledger.VipEffectiveSourcePaid, nowMS,
); err != nil {
return err return err
} }
} }
@ -717,14 +1286,17 @@ type currentVipResourceBinding struct {
} }
type vipBatchEffectiveLevels struct { type vipBatchEffectiveLevels struct {
Paid int32 Paid int32
Trial int32 PaidExpiresAtMS int64
Trial int32
TrialExpiresAtMS int64
} }
// filterCurrentVipEquipmentBatch 对整个 appearance 批次只执行三次配置/用户事实查询: // filterCurrentVipEquipmentBatch 对整个 appearance/背包批次只执行三次配置/用户事实查询:
// 1) 共享锁 program2) 一次 current+history resource mapping3) 一次 paid+trial effective level。 // 1) 共享锁 program2) 一次 current+history resource mapping3) 一次 paid+trial effective level。
// 查询数量不随 user 数增长,且所有条件都以 app_code 开头,避免高频房间列表退化成 N+1 或全表扫描。 // 查询数量不随 user 数增长,且所有条件都以 app_code 开头,避免高频房间列表退化成 N+1 或全表扫描。
func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs []int64, grouped map[int64][]resourcedomain.UserResourceEntitlement, nowMS int64) error { // preserveNonExclusiveInventory=true 时,非身份型 VIP 收藏仍留在背包,只把三类身份资源收敛到最高有效等级。
func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs []int64, grouped map[int64][]resourcedomain.UserResourceEntitlement, nowMS int64, preserveNonExclusiveInventory bool) error {
resourceIDSet := make(map[int64]struct{}, 8) resourceIDSet := make(map[int64]struct{}, 8)
for _, resources := range grouped { for _, resources := range grouped {
for _, entitlement := range resources { for _, entitlement := range resources {
@ -757,8 +1329,8 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs
ledger.VipProgramTypeTieredPrivilegeV1, appcode.FromContext(ctx)) ledger.VipProgramTypeTieredPrivilegeV1, appcode.FromContext(ctx))
mappingArgs = append(mappingArgs, int64AnyArgs(resourceIDs)...) mappingArgs = append(mappingArgs, int64AnyArgs(resourceIDs)...)
rows, err := tx.QueryContext(ctx, ` rows, err := tx.QueryContext(ctx, `
SELECT history.resource_id, 0 AS level, '' AS resource_type, '' AS benefit_code, SELECT history.resource_id, history.level, history.resource_type, history.benefit_code,
FALSE AS trial_enabled, FALSE AS current_allowed history.trial_enabled, FALSE AS current_allowed
FROM vip_benefit_resource_binding_history AS history FROM vip_benefit_resource_binding_history AS history
WHERE history.app_code = ? AND history.resource_id IN (`+placeholders(len(resourceIDs))+`) WHERE history.app_code = ? AND history.resource_id IN (`+placeholders(len(resourceIDs))+`)
AND history.program_type = ? AND history.program_type = ?
@ -778,6 +1350,7 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs
return err return err
} }
historical := make(map[int64]struct{}, len(resourceIDs)) historical := make(map[int64]struct{}, len(resourceIDs))
currentLevelOnly := make(map[int64]struct{}, len(resourceIDs))
bindings := make(map[vipResourceBindingKey]currentVipResourceBinding, len(resourceIDs)) bindings := make(map[vipResourceBindingKey]currentVipResourceBinding, len(resourceIDs))
for rows.Next() { for rows.Next() {
var resourceID int64 var resourceID int64
@ -791,6 +1364,9 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs
return err return err
} }
historical[resourceID] = struct{}{} historical[resourceID] = struct{}{}
if isCurrentLevelOnlyVipBenefit(benefitCode) {
currentLevelOnly[resourceID] = struct{}{}
}
if !currentAllowed || level <= 0 { if !currentAllowed || level <= 0 {
continue continue
} }
@ -814,7 +1390,7 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs
return tx.Commit() return tx.Commit()
} }
if programErr != nil || program.ProgramType != ledger.VipProgramTypeTieredPrivilegeV1 || program.Status != ledger.VipStatusActive { if programErr != nil || program.ProgramType != ledger.VipProgramTypeTieredPrivilegeV1 || program.Status != ledger.VipStatusActive {
filterVipEquipmentByAccess(grouped, historical, bindings, nil, false) filterVipEquipmentByAccess(grouped, historical, currentLevelOnly, bindings, nil, false, preserveNonExclusiveInventory, nowMS)
return tx.Commit() return tx.Commit()
} }
@ -822,11 +1398,11 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs
if err != nil { if err != nil {
return err return err
} }
filterVipEquipmentByAccess(grouped, historical, bindings, levels, program.TrialCardEnabled) filterVipEquipmentByAccess(grouped, historical, currentLevelOnly, bindings, levels, program.TrialCardEnabled, preserveNonExclusiveInventory, nowMS)
return tx.Commit() return tx.Commit()
} }
func filterVipEquipmentByAccess(grouped map[int64][]resourcedomain.UserResourceEntitlement, historical map[int64]struct{}, bindings map[vipResourceBindingKey]currentVipResourceBinding, levels map[int64]vipBatchEffectiveLevels, trialCardEnabled bool) { func filterVipEquipmentByAccess(grouped map[int64][]resourcedomain.UserResourceEntitlement, historical map[int64]struct{}, currentLevelOnly map[int64]struct{}, bindings map[vipResourceBindingKey]currentVipResourceBinding, levels map[int64]vipBatchEffectiveLevels, trialCardEnabled bool, preserveNonExclusiveInventory bool, nowMS int64) {
for userID, resources := range grouped { for userID, resources := range grouped {
filtered := resources[:0] filtered := resources[:0]
access := levels[userID] access := levels[userID]
@ -843,6 +1419,27 @@ func filterVipEquipmentByAccess(grouped map[int64][]resourcedomain.UserResourceE
} }
benefitCodes := currentVipBenefitCodesAtOrBelow(bindings, effectiveLevel, effectiveSource, benefitCodes := currentVipBenefitCodesAtOrBelow(bindings, effectiveLevel, effectiveSource,
entitlement.ResourceID, entitlement.Resource.ResourceType) entitlement.ResourceID, entitlement.Resource.ResourceType)
if preserveNonExclusiveInventory {
if _, exclusive := currentLevelOnly[entitlement.ResourceID]; exclusive {
// 回收记录继续保留审计,但不再属于用户可见资产;即使调用方请求
// active_only=false也不能把 revoked/expired 的当前等级实例重新暴露。
if len(benefitCodes) == 0 ||
entitlement.Status != resourcedomain.StatusActive ||
entitlement.EffectiveAtMS > nowMS ||
(entitlement.ExpiresAtMS != 0 && entitlement.ExpiresAtMS <= nowMS) ||
entitlement.RemainingQuantity <= 0 {
continue
}
}
if entitlement.Equipped && len(benefitCodes) == 0 {
entitlement.Equipped = false
}
if len(benefitCodes) > 0 {
entitlement.VIPBenefitCodes = benefitCodes
}
filtered = append(filtered, entitlement)
continue
}
if len(benefitCodes) == 0 { if len(benefitCodes) == 0 {
continue continue
} }
@ -855,8 +1452,8 @@ func filterVipEquipmentByAccess(grouped map[int64][]resourcedomain.UserResourceE
} }
} }
// currentVipBenefitCodesAtOrBelow 只在后台配置的有限 VIP 等级内倒序查找,因此不会引入 // currentVipBenefitCodesAtOrBelow 只在后台配置的有限 VIP 等级内倒序查找;称号、长徽章和
// 数据库请求或按用户增长的复杂度;同一资源跨等级复用时合并所有当前可用权益编码,供展示层精确分流 // 短徽章只接受 effective level其余收藏型资源仍可从低等级命中
func currentVipBenefitCodesAtOrBelow(bindings map[vipResourceBindingKey]currentVipResourceBinding, effectiveLevel int32, effectiveSource string, resourceID int64, resourceType string) []string { func currentVipBenefitCodesAtOrBelow(bindings map[vipResourceBindingKey]currentVipResourceBinding, effectiveLevel int32, effectiveSource string, resourceID int64, resourceType string) []string {
resourceType = resourcedomain.NormalizeResourceType(resourceType) resourceType = resourcedomain.NormalizeResourceType(resourceType)
result := make([]string, 0, 2) result := make([]string, 0, 2)
@ -870,6 +1467,9 @@ func currentVipBenefitCodesAtOrBelow(bindings map[vipResourceBindingKey]currentV
codes = binding.TrialBenefitCodes codes = binding.TrialBenefitCodes
} }
for _, code := range codes { for _, code := range codes {
if level != effectiveLevel && isCurrentLevelOnlyVipBenefit(code) {
continue
}
result = appendUniqueNormalizedVIPCode(result, code) result = appendUniqueNormalizedVIPCode(result, code)
} }
} }

View File

@ -81,6 +81,62 @@ func (r *Repository) GetVipState(ctx context.Context, userID int64) (ledger.VipS
return r.getVipStateFromSnapshot(ctx, tx, userID, nowMS, config) return r.getVipStateFromSnapshot(ctx, tx, userID, nowMS, config)
} }
// BatchGetUserEffectiveVipLevels 在同一个 repeatable-read 快照内读取 App program 和整批
// paid/trial 事实。查询数固定为两次,且用户条件都命中 (app_code,user_id) 复合索引。
func (r *Repository) BatchGetUserEffectiveVipLevels(ctx context.Context, userIDs []int64, nowMS int64) ([]ledger.UserEffectiveVipLevel, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
userIDs = compactPositiveInt64s(userIDs)
if len(userIDs) == 0 {
return []ledger.UserEffectiveVipLevel{}, nil
}
if len(userIDs) > 100 {
return nil, xerr.New(xerr.InvalidArgument, "effective vip batch is too large")
}
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true, Isolation: sql.LevelRepeatableRead})
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
program, err := r.requireVipProgramConfig(ctx, tx)
if err != nil {
return nil, err
}
levels := make(map[int64]vipBatchEffectiveLevels, len(userIDs))
if program.Status == ledger.VipStatusActive {
levels, err = r.batchVipEffectiveLevelsFromSnapshot(ctx, tx, userIDs, nowMS)
if err != nil {
return nil, err
}
}
result := make([]ledger.UserEffectiveVipLevel, 0, len(userIDs))
for _, userID := range userIDs {
access := levels[userID]
item := ledger.UserEffectiveVipLevel{
UserID: userID,
Source: ledger.VipEffectiveSourceNone,
}
if access.Paid > 0 {
item.Level = access.Paid
item.ExpiresAtMS = access.PaidExpiresAtMS
item.Source = ledger.VipEffectiveSourcePaid
}
if program.TrialCardEnabled && access.Trial > 0 && access.Trial >= access.Paid {
item.Level = access.Trial
item.ExpiresAtMS = access.TrialExpiresAtMS
item.Source = ledger.VipEffectiveSourceTrial
}
result = append(result, item)
}
if err := tx.Commit(); err != nil {
return nil, err
}
return result, nil
}
// batchAllowedVipBenefits 在同一个只读快照内批量计算展示链路需要的最终权益。 // batchAllowedVipBenefits 在同一个只读快照内批量计算展示链路需要的最终权益。
// 查询数量固定为 program、权益矩阵、用户 effective level、用户设置四次且用户事实均以 // 查询数量固定为 program、权益矩阵、用户 effective level、用户设置四次且用户事实均以
// (app_code,user_id) 索引收敛,不能退化成榜单每个用户各调一次 CheckVipBenefit。 // (app_code,user_id) 索引收敛,不能退化成榜单每个用户各调一次 CheckVipBenefit。
@ -230,12 +286,12 @@ func (r *Repository) batchVipEffectiveLevelsFromSnapshot(ctx context.Context, qu
accessArgs = append(accessArgs, resourcedomain.TypeVIPTrialCard, ledger.VipTrialCardStatusActive, accessArgs = append(accessArgs, resourcedomain.TypeVIPTrialCard, ledger.VipTrialCardStatusActive,
nowMS, nowMS, nowMS, nowMS) nowMS, nowMS, nowMS, nowMS)
rows, err := querier.QueryContext(ctx, ` rows, err := querier.QueryContext(ctx, `
SELECT membership.user_id, membership.level, 'paid' AS effective_source SELECT membership.user_id, membership.level, membership.expires_at_ms, 'paid' AS effective_source
FROM user_vip_memberships AS membership FORCE INDEX (PRIMARY) FROM user_vip_memberships AS membership FORCE INDEX (PRIMARY)
WHERE membership.app_code = ? AND membership.user_id IN (`+placeholders(len(userIDs))+`) WHERE membership.app_code = ? AND membership.user_id IN (`+placeholders(len(userIDs))+`)
AND membership.level > 0 AND membership.expires_at_ms > ? AND membership.level > 0 AND membership.expires_at_ms > ?
UNION ALL UNION ALL
SELECT STRAIGHT_JOIN card.user_id, card.level, 'trial' AS effective_source SELECT STRAIGHT_JOIN card.user_id, card.level, card.expires_at_ms, 'trial' AS effective_source
FROM user_resource_equipment AS equipment FORCE INDEX (PRIMARY) FROM user_resource_equipment AS equipment FORCE INDEX (PRIMARY)
JOIN user_vip_trial_cards AS card FORCE INDEX (uk_vip_trial_card_entitlement) ON card.app_code = equipment.app_code JOIN user_vip_trial_cards AS card FORCE INDEX (uk_vip_trial_card_entitlement) ON card.app_code = equipment.app_code
AND card.user_id = equipment.user_id AND card.entitlement_id = equipment.entitlement_id AND card.user_id = equipment.user_id AND card.entitlement_id = equipment.entitlement_id
@ -256,15 +312,18 @@ func (r *Repository) batchVipEffectiveLevelsFromSnapshot(ctx context.Context, qu
for rows.Next() { for rows.Next() {
var userID int64 var userID int64
var level int32 var level int32
var expiresAtMS int64
var source string var source string
if err := rows.Scan(&userID, &level, &source); err != nil { if err := rows.Scan(&userID, &level, &expiresAtMS, &source); err != nil {
return nil, err return nil, err
} }
access := levels[userID] access := levels[userID]
if source == ledger.VipEffectiveSourceTrial { if source == ledger.VipEffectiveSourceTrial {
access.Trial = level access.Trial = level
access.TrialExpiresAtMS = expiresAtMS
} else { } else {
access.Paid = level access.Paid = level
access.PaidExpiresAtMS = expiresAtMS
} }
levels[userID] = access levels[userID] = access
} }

View File

@ -301,9 +301,22 @@ func (r *Repository) activateUserVip(ctx context.Context, tx *sql.Tx, command vi
} }
var rewardItems []ledger.VipRewardItem var rewardItems []ledger.VipRewardItem
var resourceGrantID string var resourceGrantID string
var tieredState ledger.VipState
if command.Program.ProgramType == ledger.VipProgramTypeTieredPrivilegeV1 { if command.Program.ProgramType == ledger.VipProgramTypeTieredPrivilegeV1 {
// 付费身份素材是否发放必须先看 paid+trial 的最终来源。更高 trial 生效时本次
// 购买只累计收藏型资源,绝不创建随后又被回收的低等级身份 entitlement/outbox。
tieredState, err = r.getVipStateFromSnapshot(ctx, tx, command.UserID, command.NowMS, command.Program)
if err != nil {
return vipActivationResult{}, err
}
// tiered 资源直接来自 active benefit bindingsreward group 不再是 entitlement 的人工同步前置条件。 // tiered 资源直接来自 active benefit bindingsreward group 不再是 entitlement 的人工同步前置条件。
rewardItems, resourceGrantID, err = r.grantPaidVipBenefitResourcesTx(ctx, tx, activationVIP, command.NowMS) rewardItems, resourceGrantID, err = r.grantPaidVipBenefitResourcesTx(
ctx,
tx,
activationVIP,
tieredState.EffectiveSource == ledger.VipEffectiveSourcePaid,
command.NowMS,
)
} else { } else {
rewardItems, resourceGrantID, err = r.applyVipRewardGroup(ctx, tx, vipRewardGrantCommand{ rewardItems, resourceGrantID, err = r.applyVipRewardGroup(ctx, tx, vipRewardGrantCommand{
AppCode: command.AppCode, CommandID: command.CommandID, UserID: command.UserID, AppCode: command.AppCode, CommandID: command.CommandID, UserID: command.UserID,
@ -314,10 +327,7 @@ func (r *Repository) activateUserVip(ctx context.Context, tx *sql.Tx, command vi
return vipActivationResult{}, err return vipActivationResult{}, err
} }
if command.Program.ProgramType == ledger.VipProgramTypeTieredPrivilegeV1 { if command.Program.ProgramType == ledger.VipProgramTypeTieredPrivilegeV1 {
state, stateErr := r.getVipStateFromSnapshot(ctx, tx, command.UserID, command.NowMS, command.Program) state := tieredState
if stateErr != nil {
return vipActivationResult{}, stateErr
}
if state.EffectiveSource == ledger.VipEffectiveSourceTrial && state.EquippedTrialCard != nil { if state.EffectiveSource == ledger.VipEffectiveSourceTrial && state.EquippedTrialCard != nil {
if err := r.grantVipTrialBenefitResourcesTx(ctx, tx, *state.EquippedTrialCard, command.Program.ConfigVersion, command.NowMS); err != nil { if err := r.grantVipTrialBenefitResourcesTx(ctx, tx, *state.EquippedTrialCard, command.Program.ConfigVersion, command.NowMS); err != nil {
return vipActivationResult{}, err return vipActivationResult{}, err

View File

@ -650,7 +650,7 @@ func (r *Repository) UnequipVipTrialCard(ctx context.Context, command ledger.Une
effectiveLevel = paid.Level effectiveLevel = paid.Level
paid.ProgramType = program.ProgramType paid.ProgramType = program.ProgramType
paid.ConfigVersion = program.ConfigVersion paid.ConfigVersion = program.ConfigVersion
if _, _, err := r.grantPaidVipBenefitResourcesTx(ctx, tx, paid, nowMS); err != nil { if _, _, err := r.grantPaidVipBenefitResourcesTx(ctx, tx, paid, true, nowMS); err != nil {
return false, ledger.VipState{}, err return false, ledger.VipState{}, err
} }
} }

View File

@ -40,6 +40,29 @@ func (s *Server) GetMyVip(ctx context.Context, req *walletv1.GetMyVipRequest) (*
return &walletv1.GetMyVipResponse{Vip: vipToProto(state.EffectiveVip), State: vipStateToProto(state)}, nil return &walletv1.GetMyVipResponse{Vip: vipToProto(state.EffectiveVip), State: vipStateToProto(state)}, nil
} }
// BatchGetUserEffectiveVipLevels 返回一页用户在当前 App 下的最终有效等级。
// paid/trial 合并和过期判断全部留在 walletgateway 只能原样聚合该投影。
func (s *Server) BatchGetUserEffectiveVipLevels(ctx context.Context, req *walletv1.BatchGetUserEffectiveVipLevelsRequest) (*walletv1.BatchGetUserEffectiveVipLevelsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
items, evaluatedAtMS, err := s.svc.BatchGetUserEffectiveVipLevels(ctx, req.GetUserIds())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
response := &walletv1.BatchGetUserEffectiveVipLevelsResponse{
Users: make([]*walletv1.UserEffectiveVipLevel, 0, len(items)),
EvaluatedAtMs: evaluatedAtMS,
}
for _, item := range items {
response.Users = append(response.Users, &walletv1.UserEffectiveVipLevel{
UserId: item.UserID,
Level: item.Level,
ExpiresAtMs: item.ExpiresAtMS,
Source: item.Source,
})
}
return response, nil
}
// GetVipProgramConfig 返回按 App 隔离的状态机配置和最高等级权益目录。 // GetVipProgramConfig 返回按 App 隔离的状态机配置和最高等级权益目录。
func (s *Server) GetVipProgramConfig(ctx context.Context, req *walletv1.GetVipProgramConfigRequest) (*walletv1.GetVipProgramConfigResponse, error) { func (s *Server) GetVipProgramConfig(ctx context.Context, req *walletv1.GetVipProgramConfigRequest) (*walletv1.GetVipProgramConfigResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode()) ctx = appcode.WithContext(ctx, req.GetAppCode())