邀请相关
This commit is contained in:
parent
1069d95e9b
commit
1bb1372381
File diff suppressed because it is too large
Load Diff
@ -1136,6 +1136,23 @@ message InviteActivityRewardInviteeSummary {
|
||||
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 是后台邀请活动列表的一行,按邀请人和周期聚合。
|
||||
message InviteActivityRewardSummary {
|
||||
string app_code = 1;
|
||||
@ -3165,6 +3182,7 @@ service InviteActivityRewardService {
|
||||
rpc GetInviteActivityRewardStatus(GetInviteActivityRewardStatusRequest) returns (GetInviteActivityRewardStatusResponse);
|
||||
rpc ClaimInviteActivityReward(ClaimInviteActivityRewardRequest) returns (ClaimInviteActivityRewardResponse);
|
||||
rpc ListInviteActivityLeaderboard(ListInviteActivityLeaderboardRequest) returns (ListInviteActivityLeaderboardResponse);
|
||||
rpc ListInviteActivityInvitees(ListInviteActivityInviteesRequest) returns (ListInviteActivityInviteesResponse);
|
||||
}
|
||||
|
||||
// AdminCumulativeRechargeRewardService 是后台活动管理访问累充奖励配置和发放记录的唯一入口。
|
||||
|
||||
@ -4342,6 +4342,7 @@ const (
|
||||
InviteActivityRewardService_GetInviteActivityRewardStatus_FullMethodName = "/hyapp.activity.v1.InviteActivityRewardService/GetInviteActivityRewardStatus"
|
||||
InviteActivityRewardService_ClaimInviteActivityReward_FullMethodName = "/hyapp.activity.v1.InviteActivityRewardService/ClaimInviteActivityReward"
|
||||
InviteActivityRewardService_ListInviteActivityLeaderboard_FullMethodName = "/hyapp.activity.v1.InviteActivityRewardService/ListInviteActivityLeaderboard"
|
||||
InviteActivityRewardService_ListInviteActivityInvitees_FullMethodName = "/hyapp.activity.v1.InviteActivityRewardService/ListInviteActivityInvitees"
|
||||
)
|
||||
|
||||
// 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)
|
||||
ClaimInviteActivityReward(ctx context.Context, in *ClaimInviteActivityRewardRequest, opts ...grpc.CallOption) (*ClaimInviteActivityRewardResponse, 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 {
|
||||
@ -4393,6 +4395,16 @@ func (c *inviteActivityRewardServiceClient) ListInviteActivityLeaderboard(ctx co
|
||||
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.
|
||||
// All implementations must embed UnimplementedInviteActivityRewardServiceServer
|
||||
// for forward compatibility.
|
||||
@ -4402,6 +4414,7 @@ type InviteActivityRewardServiceServer interface {
|
||||
GetInviteActivityRewardStatus(context.Context, *GetInviteActivityRewardStatusRequest) (*GetInviteActivityRewardStatusResponse, error)
|
||||
ClaimInviteActivityReward(context.Context, *ClaimInviteActivityRewardRequest) (*ClaimInviteActivityRewardResponse, error)
|
||||
ListInviteActivityLeaderboard(context.Context, *ListInviteActivityLeaderboardRequest) (*ListInviteActivityLeaderboardResponse, error)
|
||||
ListInviteActivityInvitees(context.Context, *ListInviteActivityInviteesRequest) (*ListInviteActivityInviteesResponse, error)
|
||||
mustEmbedUnimplementedInviteActivityRewardServiceServer()
|
||||
}
|
||||
|
||||
@ -4421,6 +4434,9 @@ func (UnimplementedInviteActivityRewardServiceServer) ClaimInviteActivityReward(
|
||||
func (UnimplementedInviteActivityRewardServiceServer) ListInviteActivityLeaderboard(context.Context, *ListInviteActivityLeaderboardRequest) (*ListInviteActivityLeaderboardResponse, error) {
|
||||
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) testEmbeddedByValue() {}
|
||||
@ -4497,6 +4513,24 @@ func _InviteActivityRewardService_ListInviteActivityLeaderboard_Handler(srv inte
|
||||
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.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -4516,6 +4550,10 @@ var InviteActivityRewardService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ListInviteActivityLeaderboard",
|
||||
Handler: _InviteActivityRewardService_ListInviteActivityLeaderboard_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListInviteActivityInvitees",
|
||||
Handler: _InviteActivityRewardService_ListInviteActivityInvitees_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/activity/v1/activity.proto",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2250,6 +2250,28 @@ message GetMyVipResponse {
|
||||
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 {
|
||||
string command_id = 1;
|
||||
string app_code = 2;
|
||||
@ -3279,6 +3301,7 @@ service WalletService {
|
||||
rpc ListVipPackages(ListVipPackagesRequest) returns (ListVipPackagesResponse);
|
||||
rpc GetVipProgramConfig(GetVipProgramConfigRequest) returns (GetVipProgramConfigResponse);
|
||||
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
|
||||
rpc BatchGetUserEffectiveVipLevels(BatchGetUserEffectiveVipLevelsRequest) returns (BatchGetUserEffectiveVipLevelsResponse);
|
||||
rpc CheckVipBenefit(CheckVipBenefitRequest) returns (CheckVipBenefitResponse);
|
||||
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
|
||||
rpc DebitCPBreakupFee(DebitCPBreakupFeeRequest) returns (DebitCPBreakupFeeResponse);
|
||||
|
||||
@ -337,6 +337,7 @@ const (
|
||||
WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages"
|
||||
WalletService_GetVipProgramConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetVipProgramConfig"
|
||||
WalletService_GetMyVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyVip"
|
||||
WalletService_BatchGetUserEffectiveVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/BatchGetUserEffectiveVipLevels"
|
||||
WalletService_CheckVipBenefit_FullMethodName = "/hyapp.wallet.v1.WalletService/CheckVipBenefit"
|
||||
WalletService_PurchaseVip_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseVip"
|
||||
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)
|
||||
GetVipProgramConfig(ctx context.Context, in *GetVipProgramConfigRequest, opts ...grpc.CallOption) (*GetVipProgramConfigResponse, 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)
|
||||
PurchaseVip(ctx context.Context, in *PurchaseVipRequest, opts ...grpc.CallOption) (*PurchaseVipResponse, 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
|
||||
}
|
||||
|
||||
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) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CheckVipBenefitResponse)
|
||||
@ -1901,6 +1913,7 @@ type WalletServiceServer interface {
|
||||
ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error)
|
||||
GetVipProgramConfig(context.Context, *GetVipProgramConfigRequest) (*GetVipProgramConfigResponse, error)
|
||||
GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error)
|
||||
BatchGetUserEffectiveVipLevels(context.Context, *BatchGetUserEffectiveVipLevelsRequest) (*BatchGetUserEffectiveVipLevelsResponse, error)
|
||||
CheckVipBenefit(context.Context, *CheckVipBenefitRequest) (*CheckVipBenefitResponse, error)
|
||||
PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, 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) {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
in := new(CheckVipBenefitRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -5049,6 +5083,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GetMyVip",
|
||||
Handler: _WalletService_GetMyVip_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BatchGetUserEffectiveVipLevels",
|
||||
Handler: _WalletService_BatchGetUserEffectiveVipLevels_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CheckVipBenefit",
|
||||
Handler: _WalletService_CheckVipBenefit_Handler,
|
||||
|
||||
@ -150,6 +150,8 @@ program 启用时的优先级固定为:`trial_card_enabled=true` 且有效已
|
||||
|
||||
初始化时高等级行已展开为完整集合,但这是 seed 结果,不是运行时继承规则。后台可以显式移除任一等级的某项权益。
|
||||
|
||||
资源发放分为两种语义:座驾、头像框等收藏型装扮在购买高等级 VIP 时可累计目标等级及以下的显式资源;`vip_title`(称号)、`vip_badge_identity`(VIP 长徽章)和 `vip_medal`(VIP 短徽章)是当前身份资源,只发放最终有效等级。VIP 升级或更高体验卡生效时,wallet 在同一事务中补齐当前等级身份 entitlement,并把其它等级、其它 VIP 来源和重复实例标记为 `revoked`,同时清理对应 equipment,因此用户资产中实际只存在一套 active 身份素材,而不是仅在展示层隐藏。体验卡到期或卸下后,wallet 会重新发放仍有效付费等级的三类身份素材,再回收体验等级实例;座驾、头像框等收藏型权益不受此回收规则影响。
|
||||
|
||||
## 房间权限和 IM
|
||||
|
||||
`room-service` 在需要房间权限时读取 wallet 的 `GetMyVip.state`:
|
||||
|
||||
@ -719,7 +719,7 @@ paths:
|
||||
- users
|
||||
summary: 批量查询会话展示资料
|
||||
operationId: batchUserProfiles
|
||||
description: 用于腾讯云 IM 会话列表补头像、昵称和短号;只返回展示字段,不返回登录、实名或后台身份信息。
|
||||
description: 用于腾讯云 IM 会话列表补头像、昵称、短号和当前 App 下的 wallet effective VIP 等级;只返回展示字段,不返回登录、实名或后台身份信息。无 VIP 或已过期时 `vip_level=0`。
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
@ -1321,7 +1321,7 @@ paths:
|
||||
format: int32
|
||||
responses:
|
||||
"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:
|
||||
$ref: "#/definitions/RoomListEnvelope"
|
||||
"400":
|
||||
@ -4093,6 +4093,7 @@ definitions:
|
||||
- display_user_id
|
||||
- username
|
||||
- app_code
|
||||
- vip_level
|
||||
properties:
|
||||
user_id:
|
||||
type: string
|
||||
@ -4112,6 +4113,11 @@ definitions:
|
||||
format: int64
|
||||
app_code:
|
||||
type: string
|
||||
vip_level:
|
||||
type: integer
|
||||
format: int32
|
||||
minimum: 0
|
||||
description: 当前 App 下目标用户的 wallet effective VIP 等级;无 VIP、program 未启用或已过期时为 0,不能从徽章、昵称或装扮资源反推。
|
||||
description: 腾讯云 IM 会话列表展示资料;不包含手机号、登录凭证、实名或后台身份字段。
|
||||
UserProfilesBatchData:
|
||||
type: object
|
||||
@ -5810,6 +5816,7 @@ definitions:
|
||||
required:
|
||||
- room_id
|
||||
- im_group_id
|
||||
- owner_vip_level
|
||||
- room_border_url
|
||||
properties:
|
||||
room_id:
|
||||
|
||||
@ -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/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/006_room_list_owner_vip_fallback.sql"
|
||||
"services/activity-service/deploy/mysql/migrations/013_game_win_broadcast_config.sql"
|
||||
"services/wallet-service/deploy/mysql/migrations/002_resource_shop_price_tiers.sql"
|
||||
"services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.sql"
|
||||
|
||||
36
scripts/mysql/075_room_list_owner_vip_fallback.sql
Normal file
36
scripts/mysql/075_room_list_owner_vip_fallback.sql
Normal 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 ;
|
||||
@ -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"`
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
|
||||
@ -29,6 +29,7 @@ type h5LinkPayload struct {
|
||||
type bannerRequest struct {
|
||||
CoverURL string `json:"coverUrl" binding:"required"`
|
||||
RoomSmallImageURL string `json:"roomSmallImageUrl"`
|
||||
DiscoverImageURL string `json:"discoverImageUrl"`
|
||||
BannerType string `json:"bannerType" binding:"required"`
|
||||
TargetType string `json:"targetType"`
|
||||
ActivityTemplateID string `json:"activityTemplateId"`
|
||||
|
||||
@ -24,6 +24,7 @@ const h5LinkGroup = "h5-links"
|
||||
const (
|
||||
bannerDisplayScopeHome = "home"
|
||||
bannerDisplayScopeRoom = "room"
|
||||
bannerDisplayScopeDiscover = "discover"
|
||||
bannerDisplayScopeRecharge = "recharge"
|
||||
bannerDisplayScopeMe = "me"
|
||||
|
||||
@ -34,7 +35,13 @@ const (
|
||||
splashDefaultDisplayDurationMS = 3000
|
||||
)
|
||||
|
||||
var bannerDisplayScopeOrder = []string{bannerDisplayScopeHome, bannerDisplayScopeRoom, bannerDisplayScopeRecharge, bannerDisplayScopeMe}
|
||||
var bannerDisplayScopeOrder = []string{
|
||||
bannerDisplayScopeHome,
|
||||
bannerDisplayScopeRoom,
|
||||
bannerDisplayScopeDiscover,
|
||||
bannerDisplayScopeRecharge,
|
||||
bannerDisplayScopeMe,
|
||||
}
|
||||
|
||||
type AppConfigService struct {
|
||||
store *repository.Store
|
||||
@ -77,6 +84,7 @@ type AppBanner struct {
|
||||
AppCode string `json:"appCode"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
RoomSmallImageURL string `json:"roomSmallImageUrl"`
|
||||
DiscoverImageURL string `json:"discoverImageUrl"`
|
||||
BannerType string `json:"bannerType"`
|
||||
TargetType string `json:"targetType"`
|
||||
ActivityTemplateID string `json:"activityTemplateId"`
|
||||
@ -331,6 +339,7 @@ func (s *AppConfigService) UpdateBanner(ctx context.Context, appCode string, id
|
||||
}
|
||||
item.CoverURL = updated.CoverURL
|
||||
item.RoomSmallImageURL = updated.RoomSmallImageURL
|
||||
item.DiscoverImageURL = updated.DiscoverImageURL
|
||||
item.BannerType = updated.BannerType
|
||||
item.TargetType = updated.TargetType
|
||||
item.ActivityTemplateID = updated.ActivityTemplateID
|
||||
@ -770,6 +779,7 @@ func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner,
|
||||
AppCode: appctx.Normalize(appCode),
|
||||
CoverURL: strings.TrimSpace(req.CoverURL),
|
||||
RoomSmallImageURL: strings.TrimSpace(req.RoomSmallImageURL),
|
||||
DiscoverImageURL: strings.TrimSpace(req.DiscoverImageURL),
|
||||
BannerType: normalizeBannerType(req.BannerType),
|
||||
TargetType: strings.ToLower(strings.TrimSpace(req.TargetType)),
|
||||
ActivityTemplateID: strings.TrimSpace(req.ActivityTemplateID),
|
||||
@ -805,6 +815,18 @@ func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner,
|
||||
// 未投放到房间内时不保留小屏图,避免客户端误用房间专属素材。
|
||||
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" {
|
||||
return model.AppBanner{}, errors.New("banner type is invalid")
|
||||
}
|
||||
@ -852,6 +874,7 @@ func appBannerFromModel(item model.AppBanner) AppBanner {
|
||||
AppCode: item.AppCode,
|
||||
CoverURL: item.CoverURL,
|
||||
RoomSmallImageURL: item.RoomSmallImageURL,
|
||||
DiscoverImageURL: item.DiscoverImageURL,
|
||||
BannerType: item.BannerType,
|
||||
TargetType: item.TargetType,
|
||||
ActivityTemplateID: item.ActivityTemplateID,
|
||||
@ -1161,6 +1184,8 @@ func normalizeBannerDisplayScope(value string) string {
|
||||
return bannerDisplayScopeHome
|
||||
case bannerDisplayScopeRoom, "房间内":
|
||||
return bannerDisplayScopeRoom
|
||||
case bannerDisplayScopeDiscover, "发现页", "发现":
|
||||
return bannerDisplayScopeDiscover
|
||||
case bannerDisplayScopeRecharge, "充值页":
|
||||
return bannerDisplayScopeRecharge
|
||||
case bannerDisplayScopeMe, "我的页", "我的":
|
||||
@ -1219,7 +1244,11 @@ func validBannerDisplayScopes(scopes []string) bool {
|
||||
return false
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@ func (s *Store) ListAppBanners(options AppBannerListOptions) ([]model.AppBanner,
|
||||
}
|
||||
if 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
|
||||
return items, err
|
||||
|
||||
@ -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';
|
||||
@ -177,6 +177,23 @@ type InviteeSummary struct {
|
||||
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 为空表示所有周期。
|
||||
type SummaryQuery struct {
|
||||
UserID int64
|
||||
|
||||
@ -43,6 +43,11 @@ type InviteAttributionSource interface {
|
||||
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 {
|
||||
repository Repository
|
||||
wallet WalletClient
|
||||
@ -108,6 +113,49 @@ func (s *Service) GetConfig(ctx context.Context) (domain.Config, error) {
|
||||
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) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return domain.Config{}, err
|
||||
|
||||
@ -461,6 +461,84 @@ func (r *Repository) ListInviteActivityRewardSummaries(ctx context.Context, quer
|
||||
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) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
|
||||
@ -60,6 +60,26 @@ func (s *InviteActivityRewardServer) ListInviteActivityLeaderboard(ctx context.C
|
||||
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 {
|
||||
activityv1.UnimplementedAdminInviteActivityRewardServiceServer
|
||||
|
||||
@ -248,15 +268,19 @@ func inviteActivityRewardSummaryToProto(summary domain.Summary) *activityv1.Invi
|
||||
Invitees: make([]*activityv1.InviteActivityRewardInviteeSummary, 0, len(summary.Invitees)),
|
||||
}
|
||||
for _, invitee := range summary.Invitees {
|
||||
resp.Invitees = append(resp.Invitees, &activityv1.InviteActivityRewardInviteeSummary{
|
||||
InvitedUserId: invitee.InvitedUserID,
|
||||
RechargeCoinAmount: invitee.RechargeCoinAmount,
|
||||
InvitedAtMs: invitee.InvitedAtMS,
|
||||
})
|
||||
resp.Invitees = append(resp.Invitees, inviteActivityRewardInviteeSummaryToProto(invitee))
|
||||
}
|
||||
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 {
|
||||
return &activityv1.InviteActivityLeaderboardEntry{
|
||||
RankNo: entry.RankNo,
|
||||
|
||||
@ -51,7 +51,7 @@ const listExploreTabsSQL = `
|
||||
WHERE app_code = ? AND enabled = TRUE
|
||||
ORDER BY sort_order DESC, id DESC`
|
||||
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
|
||||
WHERE app_code = ? AND status = 'active' AND FIND_IN_SET(?, display_scope) > 0
|
||||
AND (? = '' OR platform = ?)
|
||||
@ -123,6 +123,7 @@ type Banner struct {
|
||||
ID uint `json:"id"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
RoomSmallImageURL string `json:"room_small_image_url,omitempty"`
|
||||
DiscoverImageURL string `json:"discover_image_url,omitempty"`
|
||||
BannerType string `json:"type"`
|
||||
DisplayScope string `json:"display_scope"`
|
||||
DisplayScopes []string `json:"display_scopes,omitempty"`
|
||||
@ -437,6 +438,7 @@ func (r *MySQLReader) ListBanners(ctx context.Context, query BannerQuery) ([]Ban
|
||||
&appCode,
|
||||
&item.CoverURL,
|
||||
&item.RoomSmallImageURL,
|
||||
&item.DiscoverImageURL,
|
||||
&item.BannerType,
|
||||
&item.DisplayScope,
|
||||
&item.Param,
|
||||
@ -965,6 +967,8 @@ func NormalizeBannerDisplayScope(value string) string {
|
||||
return "home"
|
||||
case "room", "房间内":
|
||||
return "room"
|
||||
case "discover", "发现页", "发现":
|
||||
return "discover"
|
||||
case "recharge", "充值页":
|
||||
return "recharge"
|
||||
case "me", "我的页", "我的":
|
||||
@ -975,7 +979,7 @@ func NormalizeBannerDisplayScope(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, ",") {
|
||||
scope := NormalizeBannerDisplayScope(part)
|
||||
if scope != "" {
|
||||
@ -983,7 +987,7 @@ func bannerDisplayScopeList(value string) []string {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
out = append(out, scope)
|
||||
}
|
||||
|
||||
@ -14,6 +14,11 @@ type InviteActivityRewardClient interface {
|
||||
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 {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -76,6 +76,12 @@ type WalletClient interface {
|
||||
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 {
|
||||
client walletv1.WalletServiceClient
|
||||
}
|
||||
@ -197,6 +203,10 @@ func (c *grpcWalletClient) GetMyVip(ctx context.Context, req *walletv1.GetMyVipR
|
||||
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) {
|
||||
return c.client.GetVipProgramConfig(ctx, req)
|
||||
}
|
||||
|
||||
@ -119,6 +119,7 @@ func (h *Handler) TaskHandlers() httproutes.TaskHandlers {
|
||||
GetInviteActivityRewardStatus: h.getInviteActivityRewardStatus,
|
||||
ClaimInviteActivityReward: h.claimInviteActivityReward,
|
||||
ListInviteActivityLeaderboard: h.listInviteActivityLeaderboard,
|
||||
ListInviteActivityInvitees: h.listInviteActivityInvitees,
|
||||
GetSevenDayCheckInStatus: h.getSevenDayCheckInStatus,
|
||||
SignSevenDayCheckIn: h.signSevenDayCheckIn,
|
||||
ListUserLeaderboards: h.listUserLeaderboards,
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/client"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
@ -84,6 +85,23 @@ type inviteActivityLeaderboardUserData struct {
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
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 {
|
||||
@ -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 {
|
||||
if item == nil {
|
||||
return inviteActivityRewardStatusData{
|
||||
@ -262,11 +313,42 @@ func (h *Handler) resolveInviteActivityLeaderboardUsers(request *http.Request, i
|
||||
DisplayUserID: item.GetDisplayUserId(),
|
||||
Username: item.GetUsername(),
|
||||
Avatar: item.GetAvatar(),
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
}
|
||||
}
|
||||
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 {
|
||||
entries := make([]inviteActivityLeaderboardEntryData, 0, len(items))
|
||||
for _, item := range items {
|
||||
|
||||
@ -230,6 +230,7 @@ type TaskHandlers struct {
|
||||
GetInviteActivityRewardStatus http.HandlerFunc
|
||||
ClaimInviteActivityReward http.HandlerFunc
|
||||
ListInviteActivityLeaderboard http.HandlerFunc
|
||||
ListInviteActivityInvitees http.HandlerFunc
|
||||
GetSevenDayCheckInStatus http.HandlerFunc
|
||||
SignSevenDayCheckIn 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/cumulative-recharge-reward", http.MethodGet, h.GetCumulativeRechargeRewardStatus)
|
||||
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/claim", http.MethodPost, h.ClaimInviteActivityReward)
|
||||
r.profile("/activities/seven-day-checkin", http.MethodGet, h.GetSevenDayCheckInStatus)
|
||||
|
||||
@ -10,22 +10,25 @@ import (
|
||||
// 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.
|
||||
type Handler struct {
|
||||
messageClient client.MessageInboxClient
|
||||
actionClient client.MessageActionConfirmClient
|
||||
userProfileClient client.UserProfileClient
|
||||
messageClient client.MessageInboxClient
|
||||
actionClient client.MessageActionConfirmClient
|
||||
userProfileClient client.UserProfileClient
|
||||
effectiveVIPClient client.UserEffectiveVIPClient
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
MessageClient client.MessageInboxClient
|
||||
ActionClient client.MessageActionConfirmClient
|
||||
UserProfileClient client.UserProfileClient
|
||||
MessageClient client.MessageInboxClient
|
||||
ActionClient client.MessageActionConfirmClient
|
||||
UserProfileClient client.UserProfileClient
|
||||
EffectiveVIPClient client.UserEffectiveVIPClient
|
||||
}
|
||||
|
||||
func New(config Config) *Handler {
|
||||
return &Handler{
|
||||
messageClient: config.MessageClient,
|
||||
actionClient: config.ActionClient,
|
||||
userProfileClient: config.UserProfileClient,
|
||||
messageClient: config.MessageClient,
|
||||
actionClient: config.ActionClient,
|
||||
userProfileClient: config.UserProfileClient,
|
||||
effectiveVIPClient: config.EffectiveVIPClient,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,9 +4,11 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
activityv1 "hyapp.local/api/proto/activity/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/transport/http/httpkit"
|
||||
)
|
||||
@ -55,6 +57,7 @@ type userProfileBatchData struct {
|
||||
Country string `json:"country,omitempty"`
|
||||
RegionID int64 `json:"region_id,omitempty"`
|
||||
AppCode string `json:"app_code"`
|
||||
VIPLevel int32 `json:"vip_level"`
|
||||
}
|
||||
|
||||
// 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")
|
||||
return
|
||||
}
|
||||
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserIds: userIDs,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
var (
|
||||
profileResp *userv1.BatchGetUsersResponse
|
||||
vipResp *walletv1.BatchGetUserEffectiveVipLevelsResponse
|
||||
profileErr error
|
||||
vipErr error
|
||||
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
|
||||
}
|
||||
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 {
|
||||
if user := resp.GetUsers()[userID]; user != nil {
|
||||
if user := profileResp.GetUsers()[userID]; user != nil {
|
||||
items = append(items, userProfileBatchData{
|
||||
UserID: strconv.FormatInt(user.GetUserId(), 10),
|
||||
DisplayUserID: user.GetDisplayUserId(),
|
||||
@ -385,6 +425,7 @@ func (h *Handler) batchUserProfiles(writer http.ResponseWriter, request *http.Re
|
||||
Country: user.GetCountry(),
|
||||
RegionID: user.GetRegionId(),
|
||||
AppCode: user.GetAppCode(),
|
||||
VIPLevel: vipLevels[user.GetUserId()],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,10 +84,14 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
AchievementClient: h.achievementClient,
|
||||
BroadcastClient: h.broadcastClient,
|
||||
})
|
||||
// WalletClient 主接口保持既有调用面;effective VIP 用窄接口做显式能力发现,
|
||||
// 便于 wallet -> gateway 滚动升级期间明确区分旧 client 和新批量契约。
|
||||
effectiveVIPClient, _ := h.walletClient.(gatewayclient.UserEffectiveVIPClient)
|
||||
messageAPI := messageapi.New(messageapi.Config{
|
||||
MessageClient: h.messageClient,
|
||||
ActionClient: h.messageActionClient,
|
||||
UserProfileClient: h.userProfileClient,
|
||||
MessageClient: h.messageClient,
|
||||
ActionClient: h.messageActionClient,
|
||||
UserProfileClient: h.userProfileClient,
|
||||
EffectiveVIPClient: effectiveVIPClient,
|
||||
})
|
||||
activityAPI := activityapi.New(activityapi.Config{
|
||||
TaskClient: h.taskClient,
|
||||
|
||||
@ -40,6 +40,8 @@ CREATE TABLE IF NOT EXISTS room_list_entries (
|
||||
owner_user_id BIGINT NOT NULL COMMENT '房主用户 ID',
|
||||
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_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 '标题',
|
||||
cover_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '封面 URL',
|
||||
mode VARCHAR(64) NOT NULL COMMENT '业务模式',
|
||||
|
||||
@ -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 ;
|
||||
@ -363,7 +363,18 @@ func (s *Service) updateRoomOwnerVIPProjection(ctx context.Context, ownerUserID
|
||||
if !ok || ownerUserID <= 0 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@ -638,8 +649,12 @@ func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
|
||||
roomBorder := unmarshalRoomListDecoration(entry.RoomBorderJSON, nowMS)
|
||||
ownerVIPLevel := entry.OwnerVIPLevel
|
||||
if ownerVIPLevel <= 0 || entry.OwnerVIPExpiresAtMS <= nowMS {
|
||||
// 到期判断留在服务端,避免自然到期后仍依赖 wallet 事件才能从列表撤下 VIP 展示。
|
||||
ownerVIPLevel = 0
|
||||
// 体验卡自然到期没有写事务事件;优先使用同一次 wallet 快照固化的 paid 回落,
|
||||
// 否则归零。列表仍然只读本地投影,不增加 wallet RPC 或逐房查询。
|
||||
ownerVIPLevel = entry.OwnerVIPFallbackLevel
|
||||
if ownerVIPLevel <= 0 || entry.OwnerVIPFallbackExpiresAtMS <= nowMS {
|
||||
ownerVIPLevel = 0
|
||||
}
|
||||
}
|
||||
return &roomv1.RoomListItem{
|
||||
AppCode: entry.AppCode,
|
||||
|
||||
@ -298,6 +298,10 @@ type RoomListEntry struct {
|
||||
// 过期边界只在服务端判断,客户端只收到仍有效的 owner_vip_level。
|
||||
OwnerVIPLevel int32
|
||||
OwnerVIPExpiresAtMS int64
|
||||
// OwnerVIPFallback* 是 effective source 为 trial 时由 wallet 同快照给出的 paid 回落计划。
|
||||
// 它只在主投影到期后生效,避免体验卡自然过期窗口错误显示 0。
|
||||
OwnerVIPFallbackLevel int32
|
||||
OwnerVIPFallbackExpiresAtMS int64
|
||||
// OwnerVIPProjectionKnown 区分“投影明确为 0”和“Room Cell 快照没有携带该外部事实”;它不写入 Redis payload。
|
||||
OwnerVIPProjectionKnown bool `json:"-"`
|
||||
// RocketNearCompletion 是 Room Cell 当前火箭进度的列表投影;普通房间命令会随最新快照覆盖它。
|
||||
@ -887,6 +891,12 @@ type RoomOwnerVIPProjectionStore interface {
|
||||
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 红包事实进入房间列表读模型的最小契约。
|
||||
// 它不进入 Room Cell 核心状态;packet 级投影只用于正确聚合同房间多个并存红包。
|
||||
type RoomRedPacketProjectionEvent struct {
|
||||
|
||||
@ -194,6 +194,8 @@ func (s *RedisRoomListCacheStore) UpsertRoomListEntry(ctx context.Context, entry
|
||||
if !entry.OwnerVIPProjectionKnown {
|
||||
entry.OwnerVIPLevel = previous.OwnerVIPLevel
|
||||
entry.OwnerVIPExpiresAtMS = previous.OwnerVIPExpiresAtMS
|
||||
entry.OwnerVIPFallbackLevel = previous.OwnerVIPFallbackLevel
|
||||
entry.OwnerVIPFallbackExpiresAtMS = previous.OwnerVIPFallbackExpiresAtMS
|
||||
}
|
||||
if !entry.ActiveRedPacketProjectionKnown {
|
||||
entry.HasActiveRedPacket = previous.HasActiveRedPacket
|
||||
|
||||
@ -38,6 +38,8 @@ type effectiveVIPAccess struct {
|
||||
level int32
|
||||
name string
|
||||
expiresAtMS int64
|
||||
fallbackLevel int32
|
||||
fallbackExpiresAtMS int64
|
||||
benefits map[string]struct{}
|
||||
roomEntryNoticeSettingEnabled bool
|
||||
}
|
||||
@ -102,6 +104,14 @@ func (s *Service) loadEffectiveVIPAccess(ctx context.Context, requestID string,
|
||||
access.name = strings.TrimSpace(effective.GetName())
|
||||
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() {
|
||||
if benefit == nil {
|
||||
continue
|
||||
@ -113,6 +123,8 @@ func (s *Service) loadEffectiveVIPAccess(ctx context.Context, requestID string,
|
||||
return access, nil
|
||||
}
|
||||
|
||||
const ledgerVipEffectiveSourceTrial = "trial"
|
||||
|
||||
// requireCustomRoomBackgroundBenefit 只对配置为 P1 权益制的 App 执行自定义背景门禁。
|
||||
// action 由调用点传入精确动作,Flutter 才能区分上传、保存和切换失败并保留对应本地状态。
|
||||
// legacy_timed 保持 Lalu 原行为;新 App 只需在 wallet 切换 program_type,无需再修改 room-service。
|
||||
|
||||
@ -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,
|
||||
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,
|
||||
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
|
||||
WHERE app_code = ? AND room_id IN (`+placeholders+`)`, args...)
|
||||
if err != nil {
|
||||
@ -182,6 +183,51 @@ func (r *Repository) UpdateRoomOwnerVIP(ctx context.Context, ownerUserID int64,
|
||||
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 展示读模型重建。
|
||||
func (r *Repository) ListRoomListCacheEntries(ctx context.Context, query roomservice.RoomListCacheEntryScanQuery) ([]roomservice.RoomListEntry, error) {
|
||||
if query.Limit <= 0 {
|
||||
@ -292,6 +338,9 @@ func scanRoomListEntry(scanner interface{ Scan(dest ...any) error }) (roomservic
|
||||
dest = append(dest, &entry.HasActiveRedPacket, &entry.ActiveRedPacketExpiresAtMS)
|
||||
entry.ActiveRedPacketProjectionKnown = true
|
||||
}
|
||||
if columnCount >= len(dest)+2 {
|
||||
dest = append(dest, &entry.OwnerVIPFallbackLevel, &entry.OwnerVIPFallbackExpiresAtMS)
|
||||
}
|
||||
err := scanner.Scan(dest...)
|
||||
return entry, err
|
||||
}
|
||||
@ -320,6 +369,9 @@ func scanRoomListEntryWithPins(scanner interface{ Scan(dest ...any) error }) (ro
|
||||
dest = append(dest, &entry.HasActiveRedPacket, &entry.ActiveRedPacketExpiresAtMS)
|
||||
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)
|
||||
err := scanner.Scan(dest...)
|
||||
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,
|
||||
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,
|
||||
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`
|
||||
|
||||
// owner 定向列表复用公共列表扫描器,但不参与公共发现页置顶排序;这里补零值列保持 scan 契约稳定。
|
||||
@ -575,6 +628,7 @@ const roomListOwnerSelectColumns = `
|
||||
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,
|
||||
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
|
||||
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,
|
||||
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.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
|
||||
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,
|
||||
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.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
|
||||
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), ''),
|
||||
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.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
|
||||
FROM room_list_entries r
|
||||
LEFT JOIN room_region_pins gp
|
||||
|
||||
@ -37,6 +37,8 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
owner_user_id BIGINT NOT NULL,
|
||||
owner_vip_level INT 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 '',
|
||||
cover_url VARCHAR(512) NOT NULL DEFAULT '',
|
||||
mode VARCHAR(64) NOT NULL,
|
||||
@ -530,6 +532,8 @@ func (r *Repository) ensureRoomVIPMediaSchema(ctx context.Context) error {
|
||||
// 两列不参与索引和排序,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_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)
|
||||
if err != nil {
|
||||
|
||||
@ -239,6 +239,15 @@ type UserVip struct {
|
||||
ConfigVersion int64
|
||||
}
|
||||
|
||||
// UserEffectiveVipLevel 是批量展示查询需要的最小 wallet owner 事实。
|
||||
// 它已经应用 program 开关、paid/trial 优先级和 UTC 过期边界,调用方只能展示,不能据此授权。
|
||||
type UserEffectiveVipLevel struct {
|
||||
UserID int64
|
||||
Level int32
|
||||
ExpiresAtMS int64
|
||||
Source string
|
||||
}
|
||||
|
||||
// VipTrialCard 把通用背包 entitlement 投影为 VIP 体验卡;装备切换只改变 Equipped,
|
||||
// DurationMS、EffectiveAtMS 和 ExpiresAtMS 始终保留发卡时的绝对计时事实。
|
||||
type VipTrialCard struct {
|
||||
|
||||
@ -152,6 +152,8 @@ type VIPStore interface {
|
||||
GetVipProgramConfig(ctx context.Context) (ledger.VipProgramConfig, []ledger.VipBenefit, error)
|
||||
// GetVipState 是 paid membership、当前佩戴体验卡和最终权益的唯一合并读模型。
|
||||
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)
|
||||
// CheckVipResourceBenefit 校验具体资源是否仍绑定在当前 effective VIP 等级或其以下,
|
||||
// 功能型权益继续由 CheckVipBenefit 按当前等级完整矩阵判定。
|
||||
|
||||
@ -55,6 +55,29 @@ func (s *Service) GetVipState(ctx context.Context, userID int64) (ledger.VipStat
|
||||
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 执行单项权益时的服务端判定入口。
|
||||
func (s *Service) CheckVipBenefit(ctx context.Context, userID int64, benefitCode string) (ledger.CheckVipBenefitResult, error) {
|
||||
benefitCode = strings.ToLower(strings.TrimSpace(benefitCode))
|
||||
|
||||
@ -67,44 +67,26 @@ func (r *Repository) ListUserResources(ctx context.Context, query resourcedomain
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 背包要保留用户仍持有的 VIP 历史权益,但 equipped 必须表达“当前允许生效”,不能只反映装备表残留行。
|
||||
// 这里复用房间 appearance 的批量 VIP 门禁;VIP 失效后客户端仍能看到权益记录,但不能再据此渲染装扮。
|
||||
if err := r.filterCurrentVipEquipmentFlags(ctx, query.UserID, items, nowMs); err != nil {
|
||||
// reconcile 会实际回收旧等级身份 entitlement;这里仍保留读取侧门禁,防止历史脏数据、
|
||||
// 主从延迟或尚未触发懒同步的老用户短暂暴露多等级称号、长徽章和短徽章。
|
||||
items, err = r.filterCurrentVipResourceList(ctx, query.UserID, items, nowMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// filterCurrentVipEquipmentFlags 把原始 equipment 指针收敛为当前权限下的有效佩戴状态。
|
||||
// 非 VIP 资源原样保留;历史 VIP 绑定资源只有仍匹配当前 paid/trial 等级时才保持 equipped=true。
|
||||
func (r *Repository) filterCurrentVipEquipmentFlags(ctx context.Context, userID int64, items []resourcedomain.UserResourceEntitlement, nowMS int64) error {
|
||||
equipped := make([]resourcedomain.UserResourceEntitlement, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Equipped {
|
||||
equipped = append(equipped, item)
|
||||
}
|
||||
// filterCurrentVipResourceList 在一次固定批查询内同时收敛背包可见性和 equipped 标志。
|
||||
// 非身份型收藏继续按历级持有;三类身份素材只允许 effective level 作为防御性返回结果。
|
||||
func (r *Repository) filterCurrentVipResourceList(ctx context.Context, userID int64, items []resourcedomain.UserResourceEntitlement, nowMS int64) ([]resourcedomain.UserResourceEntitlement, error) {
|
||||
if len(items) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
if len(equipped) == 0 {
|
||||
return nil
|
||||
grouped := map[int64][]resourcedomain.UserResourceEntitlement{userID: items}
|
||||
if err := r.filterCurrentVipEquipmentBatch(ctx, []int64{userID}, grouped, nowMS, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
return grouped[userID], nil
|
||||
}
|
||||
|
||||
// ListVipBenefitRequirementsForResource 查询资源在 tiered VIP 中的当前或历史显式绑定。
|
||||
@ -592,7 +574,7 @@ func (r *Repository) BatchGetUserEquippedResources(ctx context.Context, query re
|
||||
}
|
||||
// appearance 是高频批量读,不能逐用户调用 CheckVipBenefit。这里用固定三次、全部走
|
||||
// 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
|
||||
}
|
||||
allowedBenefits, effectiveVIPLevels, err := r.batchAllowedVipBenefits(ctx, userIDs, benefitCodes, nowMs)
|
||||
|
||||
@ -16,9 +16,21 @@ import (
|
||||
)
|
||||
|
||||
type activeVipBenefitResource struct {
|
||||
BenefitCode string
|
||||
AutoEquip bool
|
||||
Resource resourcedomain.Resource
|
||||
BenefitCode string
|
||||
AutoEquip bool
|
||||
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 只返回当前最终等级的自动佩戴候选。用户可以手动选择低等级
|
||||
@ -27,8 +39,8 @@ func (r *Repository) listActiveVipBenefitResourcesTx(ctx context.Context, tx *sq
|
||||
return r.listVipBenefitResourcesTx(ctx, tx, level, effectiveSource, false)
|
||||
}
|
||||
|
||||
// listPaidVipRewardResourcesTx 收集目标等级及其以下每一级显式绑定的物品。这里只改变付费 VIP
|
||||
// 的背包发放集合;effective_benefits、佩戴门禁和 trial 仍只读当前等级的完整配置。
|
||||
// listPaidVipRewardResourcesTx 收集目标等级及其以下每一级显式绑定的收藏型物品;VIP 称号、
|
||||
// 长徽章和短徽章只取目标等级,避免直购高等级时把历级身份同时发入用户背包。
|
||||
func (r *Repository) listPaidVipRewardResourcesTx(ctx context.Context, tx *sql.Tx, level int32) ([]activeVipBenefitResource, error) {
|
||||
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
|
||||
levelPredicate := `b.level = ?`
|
||||
levelArgs := []any{level}
|
||||
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, `
|
||||
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
|
||||
JOIN resources AS resource ON resource.app_code = b.app_code
|
||||
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
|
||||
AND (? = FALSE OR (b.trial_enabled = TRUE AND b.benefit_code <> ?))
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
type binding struct {
|
||||
benefitCode string
|
||||
autoEquip bool
|
||||
resourceID int64
|
||||
benefitCode string
|
||||
autoEquip bool
|
||||
currentIdentity bool
|
||||
resourceID int64
|
||||
}
|
||||
bindings := make([]binding, 0, 4)
|
||||
for rows.Next() {
|
||||
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()
|
||||
return nil, err
|
||||
}
|
||||
@ -92,17 +123,18 @@ func (r *Repository) listVipBenefitResourcesTx(ctx context.Context, tx *sql.Tx,
|
||||
}
|
||||
seen[binding.resourceID] = struct{}{}
|
||||
items = append(items, activeVipBenefitResource{
|
||||
BenefitCode: binding.benefitCode,
|
||||
AutoEquip: binding.autoEquip,
|
||||
Resource: resource,
|
||||
BenefitCode: binding.benefitCode,
|
||||
AutoEquip: binding.autoEquip,
|
||||
CurrentIdentity: binding.currentIdentity,
|
||||
Resource: resource,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// grantVipTrialBenefitResourcesTx 按 config_version 为一张体验卡创建来源独立的装扮 entitlement。
|
||||
// 体验卡与付费 VIP 使用相同的向下资源继承;改绑会生成新版本 grant,未变化资源复用同一卡
|
||||
// 已有 entitlement,既拿到新资源又不会重复旧资源。
|
||||
// 收藏型资源向下累计,三类当前身份资源只发体验等级;改绑会生成新版本 grant,未变化资源
|
||||
// 复用同一卡已有 entitlement,既拿到新资源又不会重复旧资源。
|
||||
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)
|
||||
if err != nil || len(items) == 0 {
|
||||
@ -123,8 +155,8 @@ func (r *Repository) grantVipTrialBenefitResourcesTx(ctx context.Context, tx *sq
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// v2 命令空间使已经按旧 target-only 规则补齐过的有效体验卡也会幂等补发低等级素材。
|
||||
grantCommandID := "vip_trial_benefit_v2:" + stableHash(fmt.Sprintf("%s|%s|%d", appcode.FromContext(ctx), card.TrialCardID, configVersion))
|
||||
// v3 固化“三类身份资源仅当前等级”的新请求语义,避免与 v2 的历级资源快照发生幂等冲突。
|
||||
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))
|
||||
if _, exists, err := r.lookupResourceGrant(ctx, tx, grantCommandID, requestHash); err != nil || exists {
|
||||
return err
|
||||
@ -175,9 +207,9 @@ func (r *Repository) grantVipTrialBenefitResourcesTx(ctx context.Context, tx *sq
|
||||
}
|
||||
|
||||
// grantPaidVipBenefitResourcesTx 直接从 active benefit bindings 发付费 entitlement,彻底去掉
|
||||
// “运营还要同步 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) {
|
||||
// “运营还要同步 reward group”的隐含前置条件。直购高等级时累计 1..level 的收藏型物品,
|
||||
// 但称号、长徽章和短徽章只发当前等级;功能权限仍不做向下合并。
|
||||
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 {
|
||||
return nil, "", nil
|
||||
}
|
||||
@ -185,6 +217,20 @@ func (r *Repository) grantPaidVipBenefitResourcesTx(ctx context.Context, tx *sql
|
||||
if err != nil || len(items) == 0 {
|
||||
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))
|
||||
for _, item := range items {
|
||||
resourceIDs = append(resourceIDs, item.Resource.ResourceID)
|
||||
@ -195,13 +241,23 @@ func (r *Repository) grantPaidVipBenefitResourcesTx(ctx context.Context, tx *sql
|
||||
Level int32 `json:"level"`
|
||||
ExpiresAtMS int64 `json:"expires_at_ms"`
|
||||
ConfigVersion int64 `json:"config_version"`
|
||||
WithIdentity bool `json:"with_identity"`
|
||||
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)
|
||||
if err != nil {
|
||||
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))
|
||||
if existingGrantID, exists, err := r.lookupResourceGrant(ctx, tx, grantCommandID, requestHash); err != nil {
|
||||
return nil, "", err
|
||||
@ -290,9 +346,496 @@ func (r *Repository) findExistingVipBenefitEntitlementTx(ctx context.Context, tx
|
||||
return entitlementID, true, nil
|
||||
}
|
||||
|
||||
// reconcileTieredVipResourceEquipmentTx 删除高于最终 effective level、目录失效或 entitlement
|
||||
// 已失效的 VIP 装备,再按当前等级 benefit.auto_equip 恢复空槽位。preserveValidSelection 用于
|
||||
// 纯读取懒同步:合法低等级素材和普通用户选择都必须保留;首次生效或升级则传 false,明确切到新等级默认素材。
|
||||
type vipIdentityGrantContext struct {
|
||||
EffectiveSource string
|
||||
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 subject;reconcile 只能
|
||||
// 保留当前有效窗创建的实例,不能误选旧会员周期的同 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 {
|
||||
trialOnly := 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
|
||||
WHERE current_benefit.app_code = equipment.app_code
|
||||
AND current_benefit.level <= ?
|
||||
AND (
|
||||
current_benefit.level = ?
|
||||
OR current_benefit.benefit_code NOT IN (?, ?, ?)
|
||||
)
|
||||
AND current_benefit.status = 'active'
|
||||
AND current_benefit.resource_id = equipment.resource_id
|
||||
AND (? = FALSE OR (current_benefit.trial_enabled = TRUE AND current_benefit.benefit_code <> ?))
|
||||
)
|
||||
)`, appcode.FromContext(ctx), userID,
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@ -360,6 +912,12 @@ func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
identityEvents, err := r.reconcileCurrentVipIdentityEntitlementsTx(
|
||||
ctx, tx, userID, level, effectiveSource, commandID, nowMS, items,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
validEquippedTypes := map[string]struct{}{}
|
||||
if preserveValidSelection && len(items) > 0 {
|
||||
validEquippedTypes, err = r.listValidEquippedResourceTypesTx(ctx, tx, userID, nowMS)
|
||||
@ -367,7 +925,8 @@ func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context,
|
||||
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 {
|
||||
events = append(events, resourceOutboxEvent("UserResourceChanged", boundedDerivedCommandID("vip_reconcile_remove:", commandID), userID, 0, map[string]any{
|
||||
"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,
|
||||
// 再通过 idx_vip_benefit_resource 对单 resource_id 做 level <= effective level 的有界检查;
|
||||
// 因而 admin 的 DELETE+INSERT 改绑不可能夹在校验与 equipment 写入之间。
|
||||
// 再通过 idx_vip_benefit_resource 对单 resource_id 做有界检查;收藏型装扮允许命中低等级,
|
||||
// 三类当前身份资源必须精确命中 effective level。admin 改绑不能夹在校验与装备写入之间。
|
||||
func (r *Repository) requireCurrentVipResourceBindingForUserTx(ctx context.Context, tx *sql.Tx, userID int64, resourceID int64, nowMS int64) error {
|
||||
bound, err := r.isTieredVipBenefitResourceTx(ctx, tx, resourceID)
|
||||
if err != nil || !bound {
|
||||
@ -578,8 +1137,16 @@ func (r *Repository) requireCurrentVipResourceBindingForUserTx(ctx context.Conte
|
||||
AND resource.resource_type = benefit.resource_type
|
||||
WHERE benefit.app_code = ? AND benefit.resource_id = ?
|
||||
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 <> ?))
|
||||
)`, 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,
|
||||
).Scan(&allowed); err != nil {
|
||||
return err
|
||||
@ -683,7 +1250,9 @@ func (r *Repository) ensureTieredVipResourceState(ctx context.Context, userID in
|
||||
paid := state.PaidVip
|
||||
paid.ProgramType = program.ProgramType
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -717,14 +1286,17 @@ type currentVipResourceBinding struct {
|
||||
}
|
||||
|
||||
type vipBatchEffectiveLevels struct {
|
||||
Paid int32
|
||||
Trial int32
|
||||
Paid int32
|
||||
PaidExpiresAtMS int64
|
||||
Trial int32
|
||||
TrialExpiresAtMS int64
|
||||
}
|
||||
|
||||
// filterCurrentVipEquipmentBatch 对整个 appearance 批次只执行三次配置/用户事实查询:
|
||||
// filterCurrentVipEquipmentBatch 对整个 appearance/背包批次只执行三次配置/用户事实查询:
|
||||
// 1) 共享锁 program;2) 一次 current+history resource mapping;3) 一次 paid+trial effective level。
|
||||
// 查询数量不随 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)
|
||||
for _, resources := range grouped {
|
||||
for _, entitlement := range resources {
|
||||
@ -757,8 +1329,8 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs
|
||||
ledger.VipProgramTypeTieredPrivilegeV1, appcode.FromContext(ctx))
|
||||
mappingArgs = append(mappingArgs, int64AnyArgs(resourceIDs)...)
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT history.resource_id, 0 AS level, '' AS resource_type, '' AS benefit_code,
|
||||
FALSE AS trial_enabled, FALSE AS current_allowed
|
||||
SELECT history.resource_id, history.level, history.resource_type, history.benefit_code,
|
||||
history.trial_enabled, FALSE AS current_allowed
|
||||
FROM vip_benefit_resource_binding_history AS history
|
||||
WHERE history.app_code = ? AND history.resource_id IN (`+placeholders(len(resourceIDs))+`)
|
||||
AND history.program_type = ?
|
||||
@ -778,6 +1350,7 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs
|
||||
return err
|
||||
}
|
||||
historical := make(map[int64]struct{}, len(resourceIDs))
|
||||
currentLevelOnly := make(map[int64]struct{}, len(resourceIDs))
|
||||
bindings := make(map[vipResourceBindingKey]currentVipResourceBinding, len(resourceIDs))
|
||||
for rows.Next() {
|
||||
var resourceID int64
|
||||
@ -791,6 +1364,9 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs
|
||||
return err
|
||||
}
|
||||
historical[resourceID] = struct{}{}
|
||||
if isCurrentLevelOnlyVipBenefit(benefitCode) {
|
||||
currentLevelOnly[resourceID] = struct{}{}
|
||||
}
|
||||
if !currentAllowed || level <= 0 {
|
||||
continue
|
||||
}
|
||||
@ -814,7 +1390,7 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs
|
||||
return tx.Commit()
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
@ -822,11 +1398,11 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filterVipEquipmentByAccess(grouped, historical, bindings, levels, program.TrialCardEnabled)
|
||||
filterVipEquipmentByAccess(grouped, historical, currentLevelOnly, bindings, levels, program.TrialCardEnabled, preserveNonExclusiveInventory, nowMS)
|
||||
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 {
|
||||
filtered := resources[:0]
|
||||
access := levels[userID]
|
||||
@ -843,6 +1419,27 @@ func filterVipEquipmentByAccess(grouped map[int64][]resourcedomain.UserResourceE
|
||||
}
|
||||
benefitCodes := currentVipBenefitCodesAtOrBelow(bindings, effectiveLevel, effectiveSource,
|
||||
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 {
|
||||
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 {
|
||||
resourceType = resourcedomain.NormalizeResourceType(resourceType)
|
||||
result := make([]string, 0, 2)
|
||||
@ -870,6 +1467,9 @@ func currentVipBenefitCodesAtOrBelow(bindings map[vipResourceBindingKey]currentV
|
||||
codes = binding.TrialBenefitCodes
|
||||
}
|
||||
for _, code := range codes {
|
||||
if level != effectiveLevel && isCurrentLevelOnlyVipBenefit(code) {
|
||||
continue
|
||||
}
|
||||
result = appendUniqueNormalizedVIPCode(result, code)
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,6 +81,62 @@ func (r *Repository) GetVipState(ctx context.Context, userID int64) (ledger.VipS
|
||||
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 在同一个只读快照内批量计算展示链路需要的最终权益。
|
||||
// 查询数量固定为 program、权益矩阵、用户 effective level、用户设置四次,且用户事实均以
|
||||
// (app_code,user_id) 索引收敛,不能退化成榜单每个用户各调一次 CheckVipBenefit。
|
||||
@ -230,12 +286,12 @@ func (r *Repository) batchVipEffectiveLevelsFromSnapshot(ctx context.Context, qu
|
||||
accessArgs = append(accessArgs, resourcedomain.TypeVIPTrialCard, ledger.VipTrialCardStatusActive,
|
||||
nowMS, nowMS, nowMS, nowMS)
|
||||
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)
|
||||
WHERE membership.app_code = ? AND membership.user_id IN (`+placeholders(len(userIDs))+`)
|
||||
AND membership.level > 0 AND membership.expires_at_ms > ?
|
||||
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)
|
||||
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
|
||||
@ -256,15 +312,18 @@ func (r *Repository) batchVipEffectiveLevelsFromSnapshot(ctx context.Context, qu
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
var level int32
|
||||
var expiresAtMS int64
|
||||
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
|
||||
}
|
||||
access := levels[userID]
|
||||
if source == ledger.VipEffectiveSourceTrial {
|
||||
access.Trial = level
|
||||
access.TrialExpiresAtMS = expiresAtMS
|
||||
} else {
|
||||
access.Paid = level
|
||||
access.PaidExpiresAtMS = expiresAtMS
|
||||
}
|
||||
levels[userID] = access
|
||||
}
|
||||
|
||||
@ -301,9 +301,22 @@ func (r *Repository) activateUserVip(ctx context.Context, tx *sql.Tx, command vi
|
||||
}
|
||||
var rewardItems []ledger.VipRewardItem
|
||||
var resourceGrantID string
|
||||
var tieredState ledger.VipState
|
||||
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 bindings;reward 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 {
|
||||
rewardItems, resourceGrantID, err = r.applyVipRewardGroup(ctx, tx, vipRewardGrantCommand{
|
||||
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
|
||||
}
|
||||
if command.Program.ProgramType == ledger.VipProgramTypeTieredPrivilegeV1 {
|
||||
state, stateErr := r.getVipStateFromSnapshot(ctx, tx, command.UserID, command.NowMS, command.Program)
|
||||
if stateErr != nil {
|
||||
return vipActivationResult{}, stateErr
|
||||
}
|
||||
state := tieredState
|
||||
if state.EffectiveSource == ledger.VipEffectiveSourceTrial && state.EquippedTrialCard != nil {
|
||||
if err := r.grantVipTrialBenefitResourcesTx(ctx, tx, *state.EquippedTrialCard, command.Program.ConfigVersion, command.NowMS); err != nil {
|
||||
return vipActivationResult{}, err
|
||||
|
||||
@ -650,7 +650,7 @@ func (r *Repository) UnequipVipTrialCard(ctx context.Context, command ledger.Une
|
||||
effectiveLevel = paid.Level
|
||||
paid.ProgramType = program.ProgramType
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
// BatchGetUserEffectiveVipLevels 返回一页用户在当前 App 下的最终有效等级。
|
||||
// paid/trial 合并和过期判断全部留在 wallet,gateway 只能原样聚合该投影。
|
||||
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 隔离的状态机配置和最高等级权益目录。
|
||||
func (s *Server) GetVipProgramConfig(ctx context.Context, req *walletv1.GetVipProgramConfigRequest) (*walletv1.GetVipProgramConfigResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user