feat(wallet): expand VIP and resource management flows
This commit is contained in:
parent
c556b44cf6
commit
fdcd29126d
File diff suppressed because it is too large
Load Diff
@ -1187,6 +1187,8 @@ message ResourceShopItemInput {
|
|||||||
int64 effective_from_ms = 5;
|
int64 effective_from_ms = 5;
|
||||||
int64 effective_to_ms = 6;
|
int64 effective_to_ms = 6;
|
||||||
int32 sort_order = 7;
|
int32 sort_order = 7;
|
||||||
|
// coin_price 是当前售卖规格自己的金币价;新建传 0 使用资源列表价,更新已有规格传 0 保留当前售价。
|
||||||
|
int64 coin_price = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListResourceShopItemsRequest {
|
message ListResourceShopItemsRequest {
|
||||||
@ -2016,6 +2018,7 @@ message VipBenefit {
|
|||||||
string metadata_json = 12;
|
string metadata_json = 12;
|
||||||
int64 created_at_ms = 13;
|
int64 created_at_ms = 13;
|
||||||
int64 updated_at_ms = 14;
|
int64 updated_at_ms = 14;
|
||||||
|
VipBenefitPresentation presentation = 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
message VipLevel {
|
message VipLevel {
|
||||||
@ -2073,13 +2076,16 @@ message VipTrialCard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// VipUserSettings 是用户按 App 隔离的可关闭 VIP 功能偏好。没有持久记录时 wallet
|
// VipUserSettings 是用户按 App 隔离的可关闭 VIP 功能偏好。没有持久记录时 wallet
|
||||||
// 仍返回两个默认开启值,updated_at_ms=0;设置本身不授予任何 VIP 权益。
|
// 仍返回五个默认开启值,updated_at_ms=0;设置本身不授予任何 VIP 权益。
|
||||||
message VipUserSettings {
|
message VipUserSettings {
|
||||||
string app_code = 1;
|
string app_code = 1;
|
||||||
int64 user_id = 2;
|
int64 user_id = 2;
|
||||||
bool room_entry_notice_enabled = 3;
|
bool room_entry_notice_enabled = 3;
|
||||||
bool online_global_notice_enabled = 4;
|
bool online_global_notice_enabled = 4;
|
||||||
int64 updated_at_ms = 5;
|
int64 updated_at_ms = 5;
|
||||||
|
bool hide_profile_data_enabled = 6;
|
||||||
|
bool anonymous_profile_visit_enabled = 7;
|
||||||
|
bool leaderboard_invisible_enabled = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// VipState 同时返回付费会员、当前佩戴体验卡和最终生效会员,避免调用方自行合并两层状态。
|
// VipState 同时返回付费会员、当前佩戴体验卡和最终生效会员,避免调用方自行合并两层状态。
|
||||||
@ -2136,6 +2142,8 @@ message PurchaseVipResponse {
|
|||||||
int64 coin_balance_after = 5;
|
int64 coin_balance_after = 5;
|
||||||
repeated VipRewardItem reward_items = 6;
|
repeated VipRewardItem reward_items = 6;
|
||||||
VipState state = 7;
|
VipState state = 7;
|
||||||
|
// coin_balance 是购买事务提交后的带版本余额快照;coin_balance_after 仅保留旧客户端兼容。
|
||||||
|
AssetBalance coin_balance = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message DebitCPBreakupFeeRequest {
|
message DebitCPBreakupFeeRequest {
|
||||||
@ -2220,6 +2228,9 @@ message UpdateMyVipSettingsRequest {
|
|||||||
int64 user_id = 3;
|
int64 user_id = 3;
|
||||||
optional bool room_entry_notice_enabled = 4;
|
optional bool room_entry_notice_enabled = 4;
|
||||||
optional bool online_global_notice_enabled = 5;
|
optional bool online_global_notice_enabled = 5;
|
||||||
|
optional bool hide_profile_data_enabled = 6;
|
||||||
|
optional bool anonymous_profile_visit_enabled = 7;
|
||||||
|
optional bool leaderboard_invisible_enabled = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message UpdateMyVipSettingsResponse {
|
message UpdateMyVipSettingsResponse {
|
||||||
@ -2940,6 +2951,52 @@ message GetPointWithdrawalConfigResponse {
|
|||||||
string policy_instance_code = 5;
|
string policy_instance_code = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VipBenefitPreviewItem 是同一权益下的一项预览内容;独立稳定键允许 Flutter 在多图、动态资源间切换,
|
||||||
|
// 且不会把数组下标误当成资源身份。
|
||||||
|
message VipBenefitPreviewItem {
|
||||||
|
string preview_id = 1;
|
||||||
|
string title = 2;
|
||||||
|
// media_type: image/animation;animation 使用 animation_url,静态兜底使用 preview_url/asset_url。
|
||||||
|
string media_type = 3;
|
||||||
|
string asset_url = 4;
|
||||||
|
string preview_url = 5;
|
||||||
|
string animation_url = 6;
|
||||||
|
// composition_type: none/user_avatar_center/user_avatar_waveform,客户端据此把当前用户头像合成到预览中。
|
||||||
|
string composition_type = 7;
|
||||||
|
int32 sort_order = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message VipBenefitNumericReward {
|
||||||
|
string label = 1;
|
||||||
|
int64 value = 2;
|
||||||
|
string unit = 3;
|
||||||
|
// period: once/daily/monthly,由服务端配置返回,客户端只展示不据此结算。
|
||||||
|
string period = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// VipBenefitPresentation 是 Flutter 可直接渲染的结构化特权说明。metadata_json 继续保留后台扩展和
|
||||||
|
// 历史兼容,但客户端不得按 benefit_code 自行解析 metadata_json 生成预览。
|
||||||
|
message VipBenefitPresentation {
|
||||||
|
string description = 1;
|
||||||
|
repeated VipBenefitPreviewItem preview_items = 2;
|
||||||
|
VipBenefitNumericReward numeric_reward = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeUserResourceRequest targets one entitlement instead of its source grant. This keeps
|
||||||
|
// resource-group siblings intact when an operator removes a single badge/frame from a user.
|
||||||
|
message RevokeUserResourceRequest {
|
||||||
|
string request_id = 1;
|
||||||
|
string app_code = 2;
|
||||||
|
int64 user_id = 3;
|
||||||
|
string entitlement_id = 4;
|
||||||
|
string reason = 5;
|
||||||
|
int64 operator_user_id = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RevokeUserResourceResponse {
|
||||||
|
UserResourceEntitlement resource = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// WalletCronService 只给 cron-service 调用;账务状态仍由 wallet-service owner 修改。
|
// WalletCronService 只给 cron-service 调用;账务状态仍由 wallet-service owner 修改。
|
||||||
service WalletCronService {
|
service WalletCronService {
|
||||||
rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse);
|
rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||||
@ -3004,6 +3061,7 @@ service WalletService {
|
|||||||
rpc GrantResourceGroup(GrantResourceGroupRequest) returns (ResourceGrantResponse);
|
rpc GrantResourceGroup(GrantResourceGroupRequest) returns (ResourceGrantResponse);
|
||||||
rpc GrantPinnedResourceGroup(GrantPinnedResourceGroupRequest) returns (ResourceGrantResponse);
|
rpc GrantPinnedResourceGroup(GrantPinnedResourceGroupRequest) returns (ResourceGrantResponse);
|
||||||
rpc RevokeResourceGrant(RevokeResourceGrantRequest) returns (ResourceGrantResponse);
|
rpc RevokeResourceGrant(RevokeResourceGrantRequest) returns (ResourceGrantResponse);
|
||||||
|
rpc RevokeUserResource(RevokeUserResourceRequest) returns (RevokeUserResourceResponse);
|
||||||
rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse);
|
rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse);
|
||||||
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
|
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
|
||||||
rpc UnequipUserResource(UnequipUserResourceRequest) returns (UnequipUserResourceResponse);
|
rpc UnequipUserResource(UnequipUserResourceRequest) returns (UnequipUserResourceResponse);
|
||||||
|
|||||||
@ -292,6 +292,7 @@ const (
|
|||||||
WalletService_GrantResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResourceGroup"
|
WalletService_GrantResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResourceGroup"
|
||||||
WalletService_GrantPinnedResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantPinnedResourceGroup"
|
WalletService_GrantPinnedResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantPinnedResourceGroup"
|
||||||
WalletService_RevokeResourceGrant_FullMethodName = "/hyapp.wallet.v1.WalletService/RevokeResourceGrant"
|
WalletService_RevokeResourceGrant_FullMethodName = "/hyapp.wallet.v1.WalletService/RevokeResourceGrant"
|
||||||
|
WalletService_RevokeUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/RevokeUserResource"
|
||||||
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
|
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
|
||||||
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
|
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
|
||||||
WalletService_UnequipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/UnequipUserResource"
|
WalletService_UnequipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/UnequipUserResource"
|
||||||
@ -427,6 +428,7 @@ type WalletServiceClient interface {
|
|||||||
GrantResourceGroup(ctx context.Context, in *GrantResourceGroupRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error)
|
GrantResourceGroup(ctx context.Context, in *GrantResourceGroupRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error)
|
||||||
GrantPinnedResourceGroup(ctx context.Context, in *GrantPinnedResourceGroupRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error)
|
GrantPinnedResourceGroup(ctx context.Context, in *GrantPinnedResourceGroupRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error)
|
||||||
RevokeResourceGrant(ctx context.Context, in *RevokeResourceGrantRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error)
|
RevokeResourceGrant(ctx context.Context, in *RevokeResourceGrantRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error)
|
||||||
|
RevokeUserResource(ctx context.Context, in *RevokeUserResourceRequest, opts ...grpc.CallOption) (*RevokeUserResourceResponse, error)
|
||||||
ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error)
|
ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error)
|
||||||
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error)
|
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error)
|
||||||
UnequipUserResource(ctx context.Context, in *UnequipUserResourceRequest, opts ...grpc.CallOption) (*UnequipUserResourceResponse, error)
|
UnequipUserResource(ctx context.Context, in *UnequipUserResourceRequest, opts ...grpc.CallOption) (*UnequipUserResourceResponse, error)
|
||||||
@ -1040,6 +1042,16 @@ func (c *walletServiceClient) RevokeResourceGrant(ctx context.Context, in *Revok
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *walletServiceClient) RevokeUserResource(ctx context.Context, in *RevokeUserResourceRequest, opts ...grpc.CallOption) (*RevokeUserResourceResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(RevokeUserResourceResponse)
|
||||||
|
err := c.cc.Invoke(ctx, WalletService_RevokeUserResource_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *walletServiceClient) ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error) {
|
func (c *walletServiceClient) ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(ListUserResourcesResponse)
|
out := new(ListUserResourcesResponse)
|
||||||
@ -1830,6 +1842,7 @@ type WalletServiceServer interface {
|
|||||||
GrantResourceGroup(context.Context, *GrantResourceGroupRequest) (*ResourceGrantResponse, error)
|
GrantResourceGroup(context.Context, *GrantResourceGroupRequest) (*ResourceGrantResponse, error)
|
||||||
GrantPinnedResourceGroup(context.Context, *GrantPinnedResourceGroupRequest) (*ResourceGrantResponse, error)
|
GrantPinnedResourceGroup(context.Context, *GrantPinnedResourceGroupRequest) (*ResourceGrantResponse, error)
|
||||||
RevokeResourceGrant(context.Context, *RevokeResourceGrantRequest) (*ResourceGrantResponse, error)
|
RevokeResourceGrant(context.Context, *RevokeResourceGrantRequest) (*ResourceGrantResponse, error)
|
||||||
|
RevokeUserResource(context.Context, *RevokeUserResourceRequest) (*RevokeUserResourceResponse, error)
|
||||||
ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error)
|
ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error)
|
||||||
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
|
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
|
||||||
UnequipUserResource(context.Context, *UnequipUserResourceRequest) (*UnequipUserResourceResponse, error)
|
UnequipUserResource(context.Context, *UnequipUserResourceRequest) (*UnequipUserResourceResponse, error)
|
||||||
@ -2072,6 +2085,9 @@ func (UnimplementedWalletServiceServer) GrantPinnedResourceGroup(context.Context
|
|||||||
func (UnimplementedWalletServiceServer) RevokeResourceGrant(context.Context, *RevokeResourceGrantRequest) (*ResourceGrantResponse, error) {
|
func (UnimplementedWalletServiceServer) RevokeResourceGrant(context.Context, *RevokeResourceGrantRequest) (*ResourceGrantResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method RevokeResourceGrant not implemented")
|
return nil, status.Error(codes.Unimplemented, "method RevokeResourceGrant not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedWalletServiceServer) RevokeUserResource(context.Context, *RevokeUserResourceRequest) (*RevokeUserResourceResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method RevokeUserResource not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedWalletServiceServer) ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error) {
|
func (UnimplementedWalletServiceServer) ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method ListUserResources not implemented")
|
return nil, status.Error(codes.Unimplemented, "method ListUserResources not implemented")
|
||||||
}
|
}
|
||||||
@ -3266,6 +3282,24 @@ func _WalletService_RevokeResourceGrant_Handler(srv interface{}, ctx context.Con
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _WalletService_RevokeUserResource_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(RevokeUserResourceRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(WalletServiceServer).RevokeUserResource(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: WalletService_RevokeUserResource_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(WalletServiceServer).RevokeUserResource(ctx, req.(*RevokeUserResourceRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
func _WalletService_ListUserResources_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _WalletService_ListUserResources_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(ListUserResourcesRequest)
|
in := new(ListUserResourcesRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -4799,6 +4833,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "RevokeResourceGrant",
|
MethodName: "RevokeResourceGrant",
|
||||||
Handler: _WalletService_RevokeResourceGrant_Handler,
|
Handler: _WalletService_RevokeResourceGrant_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "RevokeUserResource",
|
||||||
|
Handler: _WalletService_RevokeUserResource_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "ListUserResources",
|
MethodName: "ListUserResources",
|
||||||
Handler: _WalletService_ListUserResources_Handler,
|
Handler: _WalletService_ListUserResources_Handler,
|
||||||
|
|||||||
@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
本文定义 Flutter 与当前已落地 VIP 后端的对接契约。客户端不要重复实现 VIP 状态机,也不要按 App、VIP 等级或最低解锁等级推导权限。界面和文案统一使用“VIP”,不再使用“SVIP”。
|
本文定义 Flutter 与当前已落地 VIP 后端的对接契约。客户端不要重复实现 VIP 状态机,也不要按 App、VIP 等级或最低解锁等级推导权限。界面和文案统一使用“VIP”,不再使用“SVIP”。
|
||||||
|
|
||||||
|
## 0. 当前稳定契约
|
||||||
|
|
||||||
|
- `GET /vip/me`、`GET /vip/packages`、购买、佩戴体验卡、卸下体验卡的 `state` 完全同构,固定包含 `paid_vip`、`equipped_trial_card`、`effective_vip`、`effective_source`、`effective_benefits`、`evaluated_at_ms`、完整 `program_config`、完整 `user_settings`。写成功后整体替换 store,禁止局部合并。
|
||||||
|
- `user_settings` 固定包含 5 项开关:进房通知、上线全服通知、隐藏个人数据、匿名访问主页、榜单隐身;均按 App 隔离、无记录默认开启。
|
||||||
|
- 权益富展示读取结构化 `benefits[].presentation`。它覆盖说明文案、多项静态/动态预览、用户头像合成方式和数值奖励;Flutter 不解析 `metadata_json` 生成 UI。
|
||||||
|
- VIP 购买后的余额读取带 `version` 的 `coin_balance`;`coin_balance_after` 仅兼容旧客户端。历史幂等回放若 `version=0`,必须重新请求钱包余额。
|
||||||
|
- 业务分支只判断稳定 `code`,不得解析 `message`。精确 JSON、`presentation` Schema 和错误码矩阵以 [新版 VIP 权限策略 Flutter 客户端对接文档](./新版VIP权限策略_Flutter客户端对接文档.md) 为唯一协议定义;本文后续较短 JSON 只解释场景,不定义裁剪响应。
|
||||||
|
|
||||||
## 1. 通用约定
|
## 1. 通用约定
|
||||||
|
|
||||||
- 地址前缀:`/api/v1`,实际 host 按环境配置。
|
- 地址前缀:`/api/v1`,实际 host 按环境配置。
|
||||||
@ -82,6 +90,20 @@ class VipEntitlementSnapshot {
|
|||||||
return has('online_global_notice') &&
|
return has('online_global_notice') &&
|
||||||
settings.onlineGlobalNoticeEnabled;
|
settings.onlineGlobalNoticeEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool allowsHideProfileData() {
|
||||||
|
return has('hide_profile_data') && settings.hideProfileDataEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool allowsAnonymousProfileVisit() {
|
||||||
|
return has('anonymous_profile_visit') &&
|
||||||
|
settings.anonymousProfileVisitEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool allowsLeaderboardInvisible() {
|
||||||
|
return has('leaderboard_invisible') &&
|
||||||
|
settings.leaderboardInvisibleEnabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -106,7 +128,7 @@ final benefitHandlers = <String, VipBenefitHandler>{
|
|||||||
| 功能入口 | `store.has(code)` 决定正常态/锁态和升级引导 | 点击后的写接口再次鉴权 |
|
| 功能入口 | `store.has(code)` 决定正常态/锁态和升级引导 | 点击后的写接口再次鉴权 |
|
||||||
| 装扮权益 | 同时检查权益和服务端资源字段;素材为空时使用无图兜底 | 保存/佩戴仍调用资源 owner 接口 |
|
| 装扮权益 | 同时检查权益和服务端资源字段;素材为空时使用无图兜底 | 保存/佩戴仍调用资源 owner 接口 |
|
||||||
| 进房通知 | 本人的设置页读 store;房间内展示读 Join/房间 IM 快照 | room-service 在 Join 时校验 |
|
| 进房通知 | 本人的设置页读 store;房间内展示读 Join/房间 IM 快照 | room-service 在 Join 时校验 |
|
||||||
| 防踢、防禁言 | 不根据目标用户本地 VIP 快照禁用管理按钮 | 正常调用房管接口,处理 `PERMISSION_DENIED` |
|
| 防踢、防禁言 | 不根据目标用户本地 VIP 快照禁用管理按钮 | 正常调用房管接口,分别处理 `VIP_ANTI_KICK`、`VIP_ANTI_MUTE` |
|
||||||
| 金币返现 | `daily_coin_rebate` 可用于显示入口,金额/状态不在本地推导 | current/statuses/claim 接口决定资格和入账 |
|
| 金币返现 | `daily_coin_rebate` 可用于显示入口,金额/状态不在本地推导 | current/statuses/claim 接口决定资格和入账 |
|
||||||
| 上线全服通知 | 权益和开关满足时发起 `/vip/online-notice` | activity/notice 链路异步投递 |
|
| 上线全服通知 | 权益和开关满足时发起 `/vip/online-notice` | activity/notice 链路异步投递 |
|
||||||
|
|
||||||
@ -176,7 +198,7 @@ final benefitHandlers = <String, VipBenefitHandler>{
|
|||||||
| 体验卡背包 | `GET /api/v1/users/me/resources?resource_type=vip_trial_card` | 只更新卡列表 |
|
| 体验卡背包 | `GET /api/v1/users/me/resources?resource_type=vip_trial_card` | 只更新卡列表 |
|
||||||
| 佩戴体验卡 | `POST /api/v1/vip/trial-cards/{entitlement_id}/equip` | 成功后使用响应 `state` |
|
| 佩戴体验卡 | `POST /api/v1/vip/trial-cards/{entitlement_id}/equip` | 成功后使用响应 `state` |
|
||||||
| 卸下体验卡 | `DELETE /api/v1/vip/trial-cards/equipped` | 成功后使用响应 `state` |
|
| 卸下体验卡 | `DELETE /api/v1/vip/trial-cards/equipped` | 成功后使用响应 `state` |
|
||||||
| VIP 用户开关 | `GET/PATCH /api/v1/vip/settings` | 更新 `user_settings`,不改变权益集 |
|
| VIP 功能开关 | `GET/PATCH /api/v1/vip/settings` | 更新 `user_settings`,不改变权益集 |
|
||||||
| 金币返现 | `/api/v1/vip/coin-rebates/*` | 使用服务端记录和余额回执 |
|
| 金币返现 | `/api/v1/vip/coin-rebates/*` | 使用服务端记录和余额回执 |
|
||||||
| 上线全服通知 | `POST /api/v1/vip/online-notice` | 只创建异步播报事件 |
|
| 上线全服通知 | `POST /api/v1/vip/online-notice` | 只创建异步播报事件 |
|
||||||
|
|
||||||
@ -250,6 +272,22 @@ final benefitHandlers = <String, VipBenefitHandler>{
|
|||||||
"auto_equip": false,
|
"auto_equip": false,
|
||||||
"sort_order": 110,
|
"sort_order": 110,
|
||||||
"metadata_json": "",
|
"metadata_json": "",
|
||||||
|
"presentation": {
|
||||||
|
"description": "进入房间时展示动态背景和用户头像",
|
||||||
|
"preview_items": [
|
||||||
|
{
|
||||||
|
"preview_id": "room_background_gold",
|
||||||
|
"title": "鎏金房间背景",
|
||||||
|
"media_type": "animation",
|
||||||
|
"asset_url": "https://cdn.example/vip/room-bg.png",
|
||||||
|
"preview_url": "https://cdn.example/vip/room-bg-preview.png",
|
||||||
|
"animation_url": "https://cdn.example/vip/room-bg.svga",
|
||||||
|
"composition_type": "user_avatar_center",
|
||||||
|
"sort_order": 10
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"numeric_reward": null
|
||||||
|
},
|
||||||
"created_at_ms": 0,
|
"created_at_ms": 0,
|
||||||
"updated_at_ms": 0
|
"updated_at_ms": 0
|
||||||
}
|
}
|
||||||
@ -302,6 +340,9 @@ final benefitHandlers = <String, VipBenefitHandler>{
|
|||||||
"user_id": 10001,
|
"user_id": 10001,
|
||||||
"room_entry_notice_enabled": true,
|
"room_entry_notice_enabled": true,
|
||||||
"online_global_notice_enabled": true,
|
"online_global_notice_enabled": true,
|
||||||
|
"hide_profile_data_enabled": true,
|
||||||
|
"anonymous_profile_visit_enabled": true,
|
||||||
|
"leaderboard_invisible_enabled": true,
|
||||||
"updated_at_ms": 0
|
"updated_at_ms": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -412,7 +453,8 @@ final benefitHandlers = <String, VipBenefitHandler>{
|
|||||||
"execution_scope": "room",
|
"execution_scope": "room",
|
||||||
"auto_equip": false,
|
"auto_equip": false,
|
||||||
"sort_order": 290,
|
"sort_order": 290,
|
||||||
"metadata_json": ""
|
"metadata_json": "",
|
||||||
|
"presentation": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"evaluated_at_ms": 1780101000000,
|
"evaluated_at_ms": 1780101000000,
|
||||||
@ -426,6 +468,9 @@ final benefitHandlers = <String, VipBenefitHandler>{
|
|||||||
"user_id": 10001,
|
"user_id": 10001,
|
||||||
"room_entry_notice_enabled": true,
|
"room_entry_notice_enabled": true,
|
||||||
"online_global_notice_enabled": true,
|
"online_global_notice_enabled": true,
|
||||||
|
"hide_profile_data_enabled": true,
|
||||||
|
"anonymous_profile_visit_enabled": true,
|
||||||
|
"leaderboard_invisible_enabled": true,
|
||||||
"updated_at_ms": 0
|
"updated_at_ms": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -447,7 +492,7 @@ final benefitHandlers = <String, VipBenefitHandler>{
|
|||||||
- 当体验卡被佩戴时,不限制卡等级高低,它会覆盖付费 VIP 成为 `effective_vip`;卸下或过期后自然回落到仍有效的 `paid_vip`。
|
- 当体验卡被佩戴时,不限制卡等级高低,它会覆盖付费 VIP 成为 `effective_vip`;卸下或过期后自然回落到仍有效的 `paid_vip`。
|
||||||
- 体验卡状态下,`daily_coin_rebate` 不会进入 `effective_benefits`。Flutter 不要只看卡等级猜测权益。
|
- 体验卡状态下,`daily_coin_rebate` 不会进入 `effective_benefits`。Flutter 不要只看卡等级猜测权益。
|
||||||
- 无 VIP 时,`effective_source="none"`、`effective_vip.active=false`、`effective_benefits=[]`。
|
- 无 VIP 时,`effective_source="none"`、`effective_vip.active=false`、`effective_benefits=[]`。
|
||||||
- `user_settings.updated_at_ms=0` 表示用户尚未修改,两个开关使用默认开启值;设置在无 VIP 时也保留,但不会单独授权。
|
- `user_settings.updated_at_ms=0` 表示用户尚未修改,5 个开关使用默认开启值;设置在无 VIP 时也保留,但不会单独授权。
|
||||||
|
|
||||||
## 8. 购买、续期和升级
|
## 8. 购买、续期和升级
|
||||||
|
|
||||||
@ -490,6 +535,12 @@ final benefitHandlers = <String, VipBenefitHandler>{
|
|||||||
},
|
},
|
||||||
"coin_spent": 4000,
|
"coin_spent": 4000,
|
||||||
"coin_balance_after": 6000,
|
"coin_balance_after": 6000,
|
||||||
|
"coin_balance": {
|
||||||
|
"asset_type": "COIN",
|
||||||
|
"available_amount": 6000,
|
||||||
|
"frozen_amount": 0,
|
||||||
|
"version": 18
|
||||||
|
},
|
||||||
"reward_items": [],
|
"reward_items": [],
|
||||||
"program_config": {
|
"program_config": {
|
||||||
"app_code": "fami",
|
"app_code": "fami",
|
||||||
@ -523,6 +574,16 @@ final benefitHandlers = <String, VipBenefitHandler>{
|
|||||||
"app_code": "fami",
|
"app_code": "fami",
|
||||||
"program_type": "tiered_privilege_v1",
|
"program_type": "tiered_privilege_v1",
|
||||||
"config_version": 7
|
"config_version": 7
|
||||||
|
},
|
||||||
|
"user_settings": {
|
||||||
|
"app_code": "fami",
|
||||||
|
"user_id": 10001,
|
||||||
|
"room_entry_notice_enabled": true,
|
||||||
|
"online_global_notice_enabled": true,
|
||||||
|
"hide_profile_data_enabled": true,
|
||||||
|
"anonymous_profile_visit_enabled": true,
|
||||||
|
"leaderboard_invisible_enabled": true,
|
||||||
|
"updated_at_ms": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -549,8 +610,9 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
|
|||||||
- Fami 同级续期:新截止时间 = 旧 `expires_at_ms` + `duration_ms`,保留剩余时间。
|
- Fami 同级续期:新截止时间 = 旧 `expires_at_ms` + `duration_ms`,保留剩余时间。
|
||||||
- Fami 高级升级:新截止时间 = 购买时间 + `duration_ms`,旧低等级剩余时间丢弃。例如 VIP3 剩 15 天购买 VIP4 30 天,结果是 VIP4 30 天。
|
- Fami 高级升级:新截止时间 = 购买时间 + `duration_ms`,旧低等级剩余时间丢弃。例如 VIP3 剩 15 天购买 VIP4 30 天,结果是 VIP4 30 天。
|
||||||
- 购买更低等级由后端拒绝,错误 `VIP_DOWNGRADE_NOT_ALLOWED`。
|
- 购买更低等级由后端拒绝,错误 `VIP_DOWNGRADE_NOT_ALLOWED`。
|
||||||
- 余额不足返回 `INSUFFICIENT_BALANCE`;等级未启用返回 `VIP_LEVEL_DISABLED`。
|
- 余额不足返回 `VIP_INSUFFICIENT_COIN`;套餐不存在、停用或不可购买返回 `VIP_PACKAGE_NOT_PURCHASABLE`;体系停用返回 `VIP_PROGRAM_INACTIVE`。
|
||||||
- 如果购买时仍佩戴体验卡,购买结果会写入 `paid_vip`,但当前展示仍可能是 `effective_source="trial"`。购买成功页也必须以返回的 `state.effective_vip` 为准。
|
- 如果购买时仍佩戴体验卡,购买结果会写入 `paid_vip`,但当前展示仍可能是 `effective_source="trial"`。购买成功页也必须以返回的 `state.effective_vip` 为准。
|
||||||
|
- 购买成功后用 `coin_balance.version` 防止旧响应覆盖新余额。`version=0` 只可能来自升级前的历史幂等订单,必须刷新钱包余额。
|
||||||
|
|
||||||
## 9. 体验卡背包列表
|
## 9. 体验卡背包列表
|
||||||
|
|
||||||
@ -660,17 +722,73 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
|
|||||||
"program_config": {
|
"program_config": {
|
||||||
"app_code": "fami",
|
"app_code": "fami",
|
||||||
"program_type": "tiered_privilege_v1",
|
"program_type": "tiered_privilege_v1",
|
||||||
"trial_card_enabled": true
|
"level_count": 9,
|
||||||
|
"same_level_expiry_policy": "extend_remaining",
|
||||||
|
"upgrade_expiry_policy": "replace_from_now",
|
||||||
|
"downgrade_purchase_policy": "reject",
|
||||||
|
"benefit_inheritance_policy": "target_only",
|
||||||
|
"grant_mode": "trial_card",
|
||||||
|
"trial_card_enabled": true,
|
||||||
|
"status": "active",
|
||||||
|
"config_version": 7
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"effective_source": "trial",
|
"paid_vip": null,
|
||||||
|
"equipped_trial_card": {
|
||||||
|
"trial_card_id": "vip_trial_card_b",
|
||||||
|
"entitlement_id": "ent_card_b",
|
||||||
|
"resource_id": 102,
|
||||||
|
"user_id": 10001,
|
||||||
|
"level": 4,
|
||||||
|
"name": "VIP4",
|
||||||
|
"status": "active",
|
||||||
|
"equipped": true,
|
||||||
|
"duration_ms": 1728000000,
|
||||||
|
"effective_at_ms": 1780200000000,
|
||||||
|
"expires_at_ms": 1781928000000,
|
||||||
|
"remaining_duration_ms": 1700000000,
|
||||||
|
"grant_source": "admin_grant",
|
||||||
|
"source_grant_id": "grant_b",
|
||||||
|
"created_at_ms": 1780200000000,
|
||||||
|
"updated_at_ms": 1780200000000
|
||||||
|
},
|
||||||
"effective_vip": {
|
"effective_vip": {
|
||||||
|
"user_id": 10001,
|
||||||
"level": 4,
|
"level": 4,
|
||||||
"name": "VIP4",
|
"name": "VIP4",
|
||||||
"active": true,
|
"active": true,
|
||||||
"expires_at_ms": 1781928000000
|
"started_at_ms": 1780200000000,
|
||||||
|
"expires_at_ms": 1781928000000,
|
||||||
|
"updated_at_ms": 1780200000000,
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"config_version": 7
|
||||||
},
|
},
|
||||||
"effective_benefits": []
|
"effective_source": "trial",
|
||||||
|
"effective_benefits": [],
|
||||||
|
"evaluated_at_ms": 1780228000000,
|
||||||
|
"program_config": {
|
||||||
|
"app_code": "fami",
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"level_count": 9,
|
||||||
|
"same_level_expiry_policy": "extend_remaining",
|
||||||
|
"upgrade_expiry_policy": "replace_from_now",
|
||||||
|
"downgrade_purchase_policy": "reject",
|
||||||
|
"benefit_inheritance_policy": "target_only",
|
||||||
|
"grant_mode": "trial_card",
|
||||||
|
"trial_card_enabled": true,
|
||||||
|
"status": "active",
|
||||||
|
"config_version": 7
|
||||||
|
},
|
||||||
|
"user_settings": {
|
||||||
|
"app_code": "fami",
|
||||||
|
"user_id": 10001,
|
||||||
|
"room_entry_notice_enabled": true,
|
||||||
|
"online_global_notice_enabled": true,
|
||||||
|
"hide_profile_data_enabled": true,
|
||||||
|
"anonymous_profile_visit_enabled": true,
|
||||||
|
"leaderboard_invisible_enabled": true,
|
||||||
|
"updated_at_ms": 0
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"server_time_ms": 1780228000000
|
"server_time_ms": 1780228000000
|
||||||
}
|
}
|
||||||
@ -710,22 +828,67 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
|
|||||||
"unequipped": true,
|
"unequipped": true,
|
||||||
"program_config": {
|
"program_config": {
|
||||||
"app_code": "fami",
|
"app_code": "fami",
|
||||||
"program_type": "tiered_privilege_v1"
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"level_count": 9,
|
||||||
|
"same_level_expiry_policy": "extend_remaining",
|
||||||
|
"upgrade_expiry_policy": "replace_from_now",
|
||||||
|
"downgrade_purchase_policy": "reject",
|
||||||
|
"benefit_inheritance_policy": "target_only",
|
||||||
|
"grant_mode": "trial_card",
|
||||||
|
"trial_card_enabled": true,
|
||||||
|
"status": "active",
|
||||||
|
"config_version": 7
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"effective_source": "paid",
|
|
||||||
"paid_vip": {
|
"paid_vip": {
|
||||||
|
"user_id": 10001,
|
||||||
"level": 3,
|
"level": 3,
|
||||||
"name": "VIP3",
|
"name": "VIP3",
|
||||||
"active": true
|
"active": true,
|
||||||
|
"started_at_ms": 1780000000000,
|
||||||
|
"expires_at_ms": 1782592000000,
|
||||||
|
"updated_at_ms": 1780000000000,
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"config_version": 7
|
||||||
},
|
},
|
||||||
"equipped_trial_card": null,
|
"equipped_trial_card": null,
|
||||||
"effective_vip": {
|
"effective_vip": {
|
||||||
|
"user_id": 10001,
|
||||||
"level": 3,
|
"level": 3,
|
||||||
"name": "VIP3",
|
"name": "VIP3",
|
||||||
"active": true
|
"active": true,
|
||||||
|
"started_at_ms": 1780000000000,
|
||||||
|
"expires_at_ms": 1782592000000,
|
||||||
|
"updated_at_ms": 1780000000000,
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"config_version": 7
|
||||||
},
|
},
|
||||||
"effective_benefits": []
|
"effective_source": "paid",
|
||||||
|
"effective_benefits": [],
|
||||||
|
"evaluated_at_ms": 1780229000000,
|
||||||
|
"program_config": {
|
||||||
|
"app_code": "fami",
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"level_count": 9,
|
||||||
|
"same_level_expiry_policy": "extend_remaining",
|
||||||
|
"upgrade_expiry_policy": "replace_from_now",
|
||||||
|
"downgrade_purchase_policy": "reject",
|
||||||
|
"benefit_inheritance_policy": "target_only",
|
||||||
|
"grant_mode": "trial_card",
|
||||||
|
"trial_card_enabled": true,
|
||||||
|
"status": "active",
|
||||||
|
"config_version": 7
|
||||||
|
},
|
||||||
|
"user_settings": {
|
||||||
|
"app_code": "fami",
|
||||||
|
"user_id": 10001,
|
||||||
|
"room_entry_notice_enabled": true,
|
||||||
|
"online_global_notice_enabled": true,
|
||||||
|
"hide_profile_data_enabled": true,
|
||||||
|
"anonymous_profile_visit_enabled": true,
|
||||||
|
"leaderboard_invisible_enabled": true,
|
||||||
|
"updated_at_ms": 0
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"server_time_ms": 1780229000000
|
"server_time_ms": 1780229000000
|
||||||
}
|
}
|
||||||
@ -898,7 +1061,7 @@ Fami(`trial_card`)发一张 30 天体验卡到背包,不自动佩戴:
|
|||||||
- 用 `event_id` 去重。重连/重复 Join 仍可收到进房展示事件,不要以本地 presence 猜测是否展示。
|
- 用 `event_id` 去重。重连/重复 Join 仍可收到进房展示事件,不要以本地 presence 猜测是否展示。
|
||||||
- 进房 VIP 查询失败时后端会降级为普通进房,不阻断进房主链路。
|
- 进房 VIP 查询失败时后端会降级为普通进房,不阻断进房主链路。
|
||||||
|
|
||||||
## 14. VIP 通知开关
|
## 14. VIP 功能开关
|
||||||
|
|
||||||
### 接口地址
|
### 接口地址
|
||||||
|
|
||||||
@ -909,17 +1072,15 @@ Fami(`trial_card`)发一张 30 天体验卡到背包,不自动佩戴:
|
|||||||
|
|
||||||
### 参数
|
### 参数
|
||||||
|
|
||||||
GET 无参数。PATCH 是局部更新,两个字段至少传一个;显式 `false` 不能被当成未传:
|
GET 无参数。PATCH 是局部更新,5 个字段至少传一个;显式 `false` 不能被当成未传:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"room_entry_notice_enabled": false
|
"room_entry_notice_enabled": false,
|
||||||
}
|
"online_global_notice_enabled": true,
|
||||||
```
|
"hide_profile_data_enabled": false,
|
||||||
|
"anonymous_profile_visit_enabled": true,
|
||||||
```json
|
"leaderboard_invisible_enabled": true
|
||||||
{
|
|
||||||
"online_global_notice_enabled": true
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -940,6 +1101,9 @@ GET:
|
|||||||
"user_id": 10001,
|
"user_id": 10001,
|
||||||
"room_entry_notice_enabled": true,
|
"room_entry_notice_enabled": true,
|
||||||
"online_global_notice_enabled": false,
|
"online_global_notice_enabled": false,
|
||||||
|
"hide_profile_data_enabled": true,
|
||||||
|
"anonymous_profile_visit_enabled": true,
|
||||||
|
"leaderboard_invisible_enabled": true,
|
||||||
"updated_at_ms": 1780300000000
|
"updated_at_ms": 1780300000000
|
||||||
},
|
},
|
||||||
"evaluated_at_ms": 1780300001000
|
"evaluated_at_ms": 1780300001000
|
||||||
@ -960,6 +1124,9 @@ PATCH:
|
|||||||
"user_id": 10001,
|
"user_id": 10001,
|
||||||
"room_entry_notice_enabled": false,
|
"room_entry_notice_enabled": false,
|
||||||
"online_global_notice_enabled": false,
|
"online_global_notice_enabled": false,
|
||||||
|
"hide_profile_data_enabled": false,
|
||||||
|
"anonymous_profile_visit_enabled": true,
|
||||||
|
"leaderboard_invisible_enabled": true,
|
||||||
"updated_at_ms": 1780300010000
|
"updated_at_ms": 1780300010000
|
||||||
},
|
},
|
||||||
"server_time_ms": 1780300010000
|
"server_time_ms": 1780300010000
|
||||||
@ -967,7 +1134,7 @@ PATCH:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
用户从未修改过时,两项默认为 `true`,`updated_at_ms=0`。PATCH 只改本次提交的字段,不会覆盖另一项。
|
用户从未修改过时,5 项均默认为 `true`,`updated_at_ms=0`。PATCH 只改本次提交的字段,不会覆盖其他项。
|
||||||
|
|
||||||
### 相关 IM
|
### 相关 IM
|
||||||
|
|
||||||
@ -978,6 +1145,7 @@ PATCH:
|
|||||||
- 开关是用户偏好,不是权益;无 VIP 时也可保存,但不会产生进房或上线播报。
|
- 开关是用户偏好,不是权益;无 VIP 时也可保存,但不会产生进房或上线播报。
|
||||||
- 进房展示必须同时满足 `effective_benefits` 含 `room_entry_notice` 且 `room_entry_notice_enabled=true`。
|
- 进房展示必须同时满足 `effective_benefits` 含 `room_entry_notice` 且 `room_entry_notice_enabled=true`。
|
||||||
- 上线展示必须同时满足 `online_global_notice` 权益和开关;最终以触发接口的服务端判定为准。
|
- 上线展示必须同时满足 `online_global_notice` 权益和开关;最终以触发接口的服务端判定为准。
|
||||||
|
- 隐藏个人数据、匿名访问主页、榜单隐身分别要求对应权益和同名开关同时成立;客户端不得只看设置值授权。
|
||||||
- 设置按 App 隔离,Fami 的修改不影响 Lalu。
|
- 设置按 App 隔离,Fami 的修改不影响 Lalu。
|
||||||
|
|
||||||
## 15. 每日 VIP 金币返现
|
## 15. 每日 VIP 金币返现
|
||||||
@ -1246,7 +1414,7 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
|
|||||||
- 同进程同 UTC 日首次进入 App,且 `/vip/me` 中具有 `online_global_notice` 资格、`online_global_notice_enabled=true` 时触发。成功后当日不再产生新 command。
|
- 同进程同 UTC 日首次进入 App,且 `/vip/me` 中具有 `online_global_notice` 资格、`online_global_notice_enabled=true` 时触发。成功后当日不再产生新 command。
|
||||||
- 超时/5xx 可用同一 `command_id` 重试;不能在同进程同日换 command 规避幂等。
|
- 超时/5xx 可用同一 `command_id` 重试;不能在同进程同日换 command 规避幂等。
|
||||||
- 杀掉进程后内存清空,新进程产生新 `process_boot_id` 和 `command_id`,同日可再播一次,这是已确认产品规则。
|
- 杀掉进程后内存清空,新进程产生新 `process_boot_id` 和 `command_id`,同日可再播一次,这是已确认产品规则。
|
||||||
- 权益过期、后台停用、体验卡不允许或用户关闭开关时,服务端返回 `PERMISSION_DENIED`;Flutter 不做本地飘屏,可重拉 `/vip/me`。
|
- 权益过期、后台停用、体验卡不允许或用户关闭开关时,服务端返回 `VIP_BENEFIT_REQUIRED`;Flutter 不做本地飘屏,可重拉 `/vip/me`。
|
||||||
- 不把“每日一次”放到服务端持久化去重,否则无法满足杀进程后同日可再展示。
|
- 不把“每日一次”放到服务端持久化去重,否则无法满足杀进程后同日可再展示。
|
||||||
|
|
||||||
## 17. 权益场景和完成边界
|
## 17. 权益场景和完成边界
|
||||||
@ -1258,10 +1426,10 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
|
|||||||
| 同级续期 | 不在本地计算,以响应为准 | 已强制,从旧截止时间累加 |
|
| 同级续期 | 不在本地计算,以响应为准 | 已强制,从旧截止时间累加 |
|
||||||
| 体验卡自由切换 | 按 `entitlement_id` 佩戴,每次直接替换全局 `state` | 已强制,不限等级,不作废旧卡,不改绝对截止时间 |
|
| 体验卡自由切换 | 按 `entitlement_id` 佩戴,每次直接替换全局 `state` | 已强制,不限等级,不作废旧卡,不改绝对截止时间 |
|
||||||
| 体验卡权益 | 只渲染 `effective_benefits` 资格 | 已强制过滤 `trial_enabled=false`,金币返现不给体验卡 |
|
| 体验卡权益 | 只渲染 `effective_benefits` 资格 | 已强制过滤 `trial_enabled=false`,金币返现不给体验卡 |
|
||||||
| VIP 通知开关 | 读 `state.user_settings`,用 PATCH 局部修改 | 已按 App 持久化,无记录默认两项开启,开关不单独授权 |
|
| VIP 功能开关 | 读 `state.user_settings`,用 PATCH 局部修改 | 已按 App 持久化,无记录默认 5 项开启,开关不单独授权 |
|
||||||
| 进房高亮通知 | 本人首屏读 Join HTTP `effective_vip`,其他成员消费当前房间 `room_user_joined` | 后端已同时校验权益与用户开关,并透传 Join HTTP 和当前房间 IM;不发全服 |
|
| 进房高亮通知 | 本人首屏读 Join HTTP `effective_vip`,其他成员消费当前房间 `room_user_joined` | 后端已同时校验权益与用户开关,并透传 Join HTTP 和当前房间 IM;不发全服 |
|
||||||
| 防踢 | 房管操作返回 `PERMISSION_DENIED` 时显示不可踢,不做本地预判 | 已在 room-service 强制;只阻止普通房主/管理员的 KickUser |
|
| 防踢 | 房管操作返回 `VIP_ANTI_KICK` 时显示不可踢,不做本地预判 | 已在 room-service 强制;只阻止普通房主/管理员的 KickUser |
|
||||||
| 防禁言 | 房管操作返回 `PERMISSION_DENIED` 时显示不可禁言,解禁始终可发起 | 已在 room-service 强制;只阻止普通房主/管理员新增禁言 |
|
| 防禁言 | 房管操作返回 `VIP_ANTI_MUTE` 时显示不可禁言,解禁始终可发起 | 已在 room-service 强制;只阻止普通房主/管理员新增禁言 |
|
||||||
| 平台封禁/风控/超级管理 | Flutter 不作 VIP 免责提示 | 独立 `SystemEvictUser` 治理链路会绕过防踢,VIP 不能绕过平台治理 |
|
| 平台封禁/风控/超级管理 | Flutter 不作 VIP 免责提示 | 独立 `SystemEvictUser` 治理链路会绕过防踢,VIP 不能绕过平台治理 |
|
||||||
| 自定义房间背景 | 按现有房间背景页面调用 | `tiered_privilege_v1` 已由 room-service 强制校验 `custom_room_background` |
|
| 自定义房间背景 | 按现有房间背景页面调用 | `tiered_privilege_v1` 已由 room-service 强制校验 `custom_room_background` |
|
||||||
| 金币返现 | 消费 system inbox,批量恢复状态,手动领取后使用响应余额 | 已完成 UTC 日资格、异步系统消息、24 小时窗口和 wallet 原子入账;体验卡不参与 |
|
| 金币返现 | 消费 system inbox,批量恢复状态,手动领取后使用响应余额 | 已完成 UTC 日资格、异步系统消息、24 小时窗口和 wallet 原子入账;体验卡不参与 |
|
||||||
@ -1277,4 +1445,4 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
|
|||||||
5. 所有房间、个人页和消息展示都使用 `effective_vip`,不要用 `paid_vip` 覆盖体验卡展示。
|
5. 所有房间、个人页和消息展示都使用 `effective_vip`,不要用 `paid_vip` 覆盖体验卡展示。
|
||||||
6. system inbox 遇到 `action_type=vip_coin_rebate_claim` 时,批量用 statuses 恢复真实状态;领取成功后替换余额与返现状态。
|
6. system inbox 遇到 `action_type=vip_coin_rebate_claim` 时,批量用 statuses 恢复真实状态;领取成功后替换余额与返现状态。
|
||||||
7. IM 登录并加入全局播报群后,按进程内 UTC 日规则触发 `/vip/online-notice`;收到 `vip_online_notice` 时用 `event_id` 去重。
|
7. IM 登录并加入全局播报群后,按进程内 UTC 日规则触发 `/vip/online-notice`;收到 `vip_online_notice` 时用 `event_id` 去重。
|
||||||
8. 修改通知开关成功后,用 PATCH 响应替换本地 `user_settings`,不修改 `effective_benefits`。
|
8. 修改 VIP 功能开关成功后,用 PATCH 响应替换本地 `user_settings`,不修改 `effective_benefits`。
|
||||||
|
|||||||
@ -85,12 +85,19 @@ Fami 的 VIP1–VIP9 初始全部为 `disabled`。30 天只是不产生零时长
|
|||||||
|
|
||||||
### 用户功能开关
|
### 用户功能开关
|
||||||
|
|
||||||
`user_vip_settings` 按 `(app_code,user_id)` 保存两个用户偏好:
|
`user_vip_settings` 按 `(app_code,user_id)` 保存 5 个用户偏好:
|
||||||
|
|
||||||
- `room_entry_notice_enabled`:进房 VIP 高亮通知;
|
- `room_entry_notice_enabled`:进房 VIP 高亮通知;
|
||||||
- `online_global_notice_enabled`:上线 VIP 全服飘屏。
|
- `online_global_notice_enabled`:上线 VIP 全服飘屏;
|
||||||
|
- `hide_profile_data_enabled`:隐藏个人数据;
|
||||||
|
- `anonymous_profile_visit_enabled`:匿名访问主页;
|
||||||
|
- `leaderboard_invisible_enabled`:榜单隐身。
|
||||||
|
|
||||||
无记录时两项默认为 `true`,首次修改才写行。偏好不授予权益:无 VIP 或对应等级没有权益时,即使开关为开也不会放行。用户可在无 VIP、VIP 过期或切换体验卡时保留偏好;Fami 与 Lalu 互不影响。
|
无记录时 5 项默认为 `true`,首次修改才写行。偏好不授予权益:无 VIP 或对应等级没有权益时,即使开关为开也不会放行。用户可在无 VIP、VIP 过期或切换体验卡时保留偏好;Fami 与 Lalu 互不影响。
|
||||||
|
|
||||||
|
### 权益展示投影
|
||||||
|
|
||||||
|
`vip_level_benefits.metadata_json.presentation` 是后台保存的扩展配置,wallet 在读取时解析并校验,跨服务和 HTTP 统一投影为结构化 `presentation`。它包含说明文案、多项预览、`image/animation` 媒体类型、`none/user_avatar_center/user_avatar_waveform` 合成方式,以及 `once/daily/monthly` 数值奖励。Flutter 只消费结构化字段,不按 `benefit_code` 解释原始 JSON;未知扩展因此不会变成客户端隐形配置表。
|
||||||
|
|
||||||
### 每日金币返现
|
### 每日金币返现
|
||||||
|
|
||||||
@ -115,8 +122,11 @@ Fami 的 VIP1–VIP9 初始全部为 `disabled`。30 天只是不产生零时长
|
|||||||
- `effective_vip`:最终展示和权限使用的 VIP;
|
- `effective_vip`:最终展示和权限使用的 VIP;
|
||||||
- `effective_source`:`paid`、`trial` 或 `none`;
|
- `effective_source`:`paid`、`trial` 或 `none`;
|
||||||
- `effective_benefits`:最终身份拥有的权益资格;可关闭的通知类权益还要与 `user_settings` 做 AND;
|
- `effective_benefits`:最终身份拥有的权益资格;可关闭的通知类权益还要与 `user_settings` 做 AND;
|
||||||
|
- `evaluated_at_ms`:本次状态合并的服务端时间;
|
||||||
- `program_config`:当前 App 规则;
|
- `program_config`:当前 App 规则;
|
||||||
- `user_settings`:当前 App 下的进房/上线通知偏好。
|
- `user_settings`:当前 App 下完整的 5 项功能偏好。
|
||||||
|
|
||||||
|
`GET /vip/me`、`GET /vip/packages`、购买、佩戴体验卡和卸下体验卡返回完全同构的 `VipState`。写接口成功响应不是 patch;Flutter 必须原子替换 store。灰度期间如旧节点返回缺字段状态,客户端应丢弃该局部对象并重拉 `/vip/me`。
|
||||||
|
|
||||||
program 启用时的优先级固定为:`trial_card_enabled=true` 且体验卡有效并已佩戴时覆盖展示;否则使用有效付费会员;二者都无效则为 `none`。关闭体验卡开关只停止它参与最终权限合并,背包和佩戴原始事实仍保留;关闭整个 program 时付费与卡片原始事实仍可查询,但最终身份为 `none`、不下发权益。
|
program 启用时的优先级固定为:`trial_card_enabled=true` 且体验卡有效并已佩戴时覆盖展示;否则使用有效付费会员;二者都无效则为 `none`。关闭体验卡开关只停止它参与最终权限合并,背包和佩戴原始事实仍保留;关闭整个 program 时付费与卡片原始事实仍可查询,但最终身份为 `none`、不下发权益。
|
||||||
|
|
||||||
@ -156,8 +166,8 @@ program 启用时的优先级固定为:`trial_card_enabled=true` 且体验卡
|
|||||||
|
|
||||||
- `GET /vip/packages`:program、可购买等级、每级权益和完整 state。
|
- `GET /vip/packages`:program、可购买等级、每级权益和完整 state。
|
||||||
- `GET /vip/me`:付费、体验卡、最终 VIP、权益资格和用户功能开关。
|
- `GET /vip/me`:付费、体验卡、最终 VIP、权益资格和用户功能开关。
|
||||||
- `POST /vip/purchase`:请求 `{command_id,level}`;价格、时长和资源由服务端读取。
|
- `POST /vip/purchase`:请求 `{command_id,level}`;价格、时长和资源由服务端读取;响应 `coin_balance` 是带 `version` 的账后快照,旧 `coin_balance_after` 仅兼容历史客户端。
|
||||||
- `GET|PATCH /vip/settings`:读取或局部修改进房/上线通知偏好;空 PATCH 拒绝。
|
- `GET|PATCH /vip/settings`:读取或局部修改 5 项 VIP 功能偏好;空 PATCH 拒绝。
|
||||||
- `POST /vip/online-notice`:请求 `{command_id}`,服务端校验权益、开关和资料后写全服播报 outbox。
|
- `POST /vip/online-notice`:请求 `{command_id}`,服务端校验权益、开关和资料后写全服播报 outbox。
|
||||||
- `GET /vip/coin-rebates/current`:返回当前 UTC 日返现资格;无资格或 cron 尚未生成时 `found=false`。
|
- `GET /vip/coin-rebates/current`:返回当前 UTC 日返现资格;无资格或 cron 尚未生成时 `found=false`。
|
||||||
- `POST /vip/coin-rebates/statuses`:按最多 100 个 `rebate_ids` 恢复系统消息真实状态,或用受限分页查历史。
|
- `POST /vip/coin-rebates/statuses`:按最多 100 个 `rebate_ids` 恢复系统消息真实状态,或用受限分页查历史。
|
||||||
@ -175,6 +185,8 @@ program 启用时的优先级固定为:`trial_card_enabled=true` 且体验卡
|
|||||||
- `POST /v1/admin/activity/vip-trial-card-grants`
|
- `POST /v1/admin/activity/vip-trial-card-grants`
|
||||||
- `POST /v1/admin/activity/vip-grants`,仅用于 `direct_membership` program
|
- `POST /v1/admin/activity/vip-grants`,仅用于 `direct_membership` program
|
||||||
|
|
||||||
|
App 业务错误通过 xerr 目录稳定映射:购买使用 `VIP_PROGRAM_INACTIVE`、`VIP_PACKAGE_NOT_PURCHASABLE`、`VIP_DOWNGRADE_NOT_ALLOWED`、`VIP_RECHARGE_REQUIRED`、`VIP_INSUFFICIENT_COIN`;体验卡使用 `VIP_TRIAL_CARD_NOT_FOUND`、`VIP_TRIAL_CARD_EXPIRED`;权益拒绝使用 `VIP_BENEFIT_REQUIRED`,普通房管防踢/防禁言分别使用 `VIP_ANTI_KICK`、`VIP_ANTI_MUTE`;返现使用 `VIP_COIN_REBATE_NOT_FOUND`、`VIP_COIN_REBATE_EXPIRED`。客户端不得解析 message。返现重复领取沿用幂等成功回执,不额外定义 already-claimed 错误。
|
||||||
|
|
||||||
## 权益落地边界
|
## 权益落地边界
|
||||||
|
|
||||||
当前服务端已强制执行购买有效期、体验卡状态合并、用户通知开关、每日金币返现资格/消息/手动领取入账、自定义房间背景、防踢、防禁言、房内进场通知和上线全服通知。装扮类权益由已绑定的钱包资源与 Flutter 展示链路执行。
|
当前服务端已强制执行购买有效期、体验卡状态合并、用户通知开关、每日金币返现资格/消息/手动领取入账、自定义房间背景、防踢、防禁言、房内进场通知和上线全服通知。装扮类权益由已绑定的钱包资源与 Flutter 展示链路执行。
|
||||||
|
|||||||
@ -35,6 +35,20 @@ class VipEntitlementState {
|
|||||||
return has('online_global_notice') &&
|
return has('online_global_notice') &&
|
||||||
settings.onlineGlobalNoticeEnabled;
|
settings.onlineGlobalNoticeEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool allowsHideProfileData() {
|
||||||
|
return has('hide_profile_data') && settings.hideProfileDataEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool allowsAnonymousProfileVisit() {
|
||||||
|
return has('anonymous_profile_visit') &&
|
||||||
|
settings.anonymousProfileVisitEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool allowsLeaderboardInvisible() {
|
||||||
|
return has('leaderboard_invisible') &&
|
||||||
|
settings.leaderboardInvisibleEnabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -52,6 +66,8 @@ class VipEntitlementState {
|
|||||||
| `config_version` 不一致 | 用户权限以 `/vip/me.state` 为准,重新请求 `/vip/packages`,不得合并不同版本的权益数组 |
|
| `config_version` 不一致 | 用户权限以 `/vip/me.state` 为准,重新请求 `/vip/packages`,不得合并不同版本的权益数组 |
|
||||||
| VIP 到期倒计时结束 | 可更新页面显示,但执行功能前仍以服务端接口结果为准 |
|
| VIP 到期倒计时结束 | 可更新页面显示,但执行功能前仍以服务端接口结果为准 |
|
||||||
|
|
||||||
|
购买、佩戴体验卡、卸下体验卡成功时,`data.state` 与 `GET /api/v1/vip/me` 的 `data.state` **完全同构**,不是局部补丁。Flutter 必须整体替换 VIP store,禁止按字段合并。若灰度期间收到缺少下文任一顶层字段的旧节点响应,应放弃该响应中的 `state` 并立即重拉 `/vip/me`。
|
||||||
|
|
||||||
### 1.4 Fami 初始等级权益
|
### 1.4 Fami 初始等级权益
|
||||||
|
|
||||||
下表只用于识别初始配置。Flutter 实际必须渲染 `/vip/packages` 返回的数据。
|
下表只用于识别初始配置。Flutter 实际必须渲染 `/vip/packages` 返回的数据。
|
||||||
@ -89,6 +105,52 @@ class VipEntitlementState {
|
|||||||
- `request_id` 只用于链路追踪。
|
- `request_id` 只用于链路追踪。
|
||||||
- 带 `command_id` 的接口,网络重试必须复用原 `command_id`;新的业务动作必须生成新值。
|
- 带 `command_id` 的接口,网络重试必须复用原 `command_id`;新的业务动作必须生成新值。
|
||||||
|
|
||||||
|
### 2.1 `VipState` 唯一结构
|
||||||
|
|
||||||
|
`GET /vip/me`、`GET /vip/packages`、购买、佩戴体验卡和卸下体验卡返回的 `state` 使用同一结构,固定包含以下 8 个顶层字段:
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `paid_vip` | 当前付费 VIP;没有时为 `null` |
|
||||||
|
| `equipped_trial_card` | 当前佩戴的体验卡;没有时为 `null` |
|
||||||
|
| `effective_vip` | 付费 VIP 与体验卡合并后的当前展示身份 |
|
||||||
|
| `effective_source` | `paid`、`trial`、`none` |
|
||||||
|
| `effective_benefits` | 服务端计算后的最终权益集合 |
|
||||||
|
| `evaluated_at_ms` | 本次状态计算时间 |
|
||||||
|
| `program_config` | 本次计算使用的完整 App VIP 配置 |
|
||||||
|
| `user_settings` | 当前 App 下完整的 5 项用户开关 |
|
||||||
|
|
||||||
|
`paid_vip`、`effective_vip` 非空时都返回完整 `UserVip` 字段:`user_id`、`level`、`name`、`active`、`started_at_ms`、`expires_at_ms`、`updated_at_ms`、`program_type`、`config_version`。`program_config` 返回完整字段,不允许写接口只返回 `app_code/program_type` 的裁剪对象。
|
||||||
|
|
||||||
|
### 2.2 特权展示协议
|
||||||
|
|
||||||
|
每项 `VipBenefit` 除执行字段外提供结构化 `presentation`,Flutter 不解析 `metadata_json`,也不按 `benefit_code` 维护隐形展示配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"description": "进入房间时展示动态背景和用户头像",
|
||||||
|
"preview_items": [
|
||||||
|
{
|
||||||
|
"preview_id": "room_background_gold",
|
||||||
|
"title": "鎏金房间背景",
|
||||||
|
"media_type": "animation",
|
||||||
|
"asset_url": "https://cdn.example/vip/room-bg.png",
|
||||||
|
"preview_url": "https://cdn.example/vip/room-bg-preview.png",
|
||||||
|
"animation_url": "https://cdn.example/vip/room-bg.svga",
|
||||||
|
"composition_type": "user_avatar_center",
|
||||||
|
"sort_order": 10
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"numeric_reward": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `media_type`:`image` 或 `animation`;动态资源加载失败时依次使用 `preview_url`、`asset_url`。
|
||||||
|
- `composition_type`:`none`、`user_avatar_center`、`user_avatar_waveform`,分别表示无合成、头像居中、头像与声波纹合成。
|
||||||
|
- `preview_items` 可返回多项,按 `sort_order` 升序、`preview_id` 去重。
|
||||||
|
- `numeric_reward` 用于数值权益,字段为 `label/value/unit/period`,其中 `period` 为 `once/daily/monthly`。该字段只用于展示,结算仍以后端接口为准。
|
||||||
|
- 没有富预览时 `presentation` 为 `null`;Flutter 回退展示权益名称和通用图标。
|
||||||
|
|
||||||
## 3. VIP 套餐
|
## 3. VIP 套餐
|
||||||
|
|
||||||
### 接口地址
|
### 接口地址
|
||||||
@ -144,7 +206,23 @@ class VipEntitlementState {
|
|||||||
"execution_scope": "room",
|
"execution_scope": "room",
|
||||||
"auto_equip": false,
|
"auto_equip": false,
|
||||||
"sort_order": 110,
|
"sort_order": 110,
|
||||||
"metadata_json": ""
|
"metadata_json": "",
|
||||||
|
"presentation": {
|
||||||
|
"description": "进入房间时展示动态背景和用户头像",
|
||||||
|
"preview_items": [
|
||||||
|
{
|
||||||
|
"preview_id": "room_background_gold",
|
||||||
|
"title": "鎏金房间背景",
|
||||||
|
"media_type": "animation",
|
||||||
|
"asset_url": "https://cdn.example/vip/room-bg.png",
|
||||||
|
"preview_url": "https://cdn.example/vip/room-bg-preview.png",
|
||||||
|
"animation_url": "https://cdn.example/vip/room-bg.svga",
|
||||||
|
"composition_type": "user_avatar_center",
|
||||||
|
"sort_order": 10
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"numeric_reward": null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"config_version": 7
|
"config_version": 7
|
||||||
@ -165,17 +243,27 @@ class VipEntitlementState {
|
|||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"paid_vip": {
|
"paid_vip": {
|
||||||
|
"user_id": 10001,
|
||||||
"level": 3,
|
"level": 3,
|
||||||
"name": "VIP3",
|
"name": "VIP3",
|
||||||
"active": true,
|
"active": true,
|
||||||
"expires_at_ms": 1782592000000
|
"started_at_ms": 1780000000000,
|
||||||
|
"expires_at_ms": 1782592000000,
|
||||||
|
"updated_at_ms": 1780000000000,
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"config_version": 7
|
||||||
},
|
},
|
||||||
"equipped_trial_card": null,
|
"equipped_trial_card": null,
|
||||||
"effective_vip": {
|
"effective_vip": {
|
||||||
|
"user_id": 10001,
|
||||||
"level": 3,
|
"level": 3,
|
||||||
"name": "VIP3",
|
"name": "VIP3",
|
||||||
"active": true,
|
"active": true,
|
||||||
"expires_at_ms": 1782592000000
|
"started_at_ms": 1780000000000,
|
||||||
|
"expires_at_ms": 1782592000000,
|
||||||
|
"updated_at_ms": 1780000000000,
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"config_version": 7
|
||||||
},
|
},
|
||||||
"effective_source": "paid",
|
"effective_source": "paid",
|
||||||
"effective_benefits": [
|
"effective_benefits": [
|
||||||
@ -185,9 +273,27 @@ class VipEntitlementState {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"evaluated_at_ms": 1780000000000,
|
"evaluated_at_ms": 1780000000000,
|
||||||
|
"program_config": {
|
||||||
|
"app_code": "fami",
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"level_count": 9,
|
||||||
|
"same_level_expiry_policy": "extend_remaining",
|
||||||
|
"upgrade_expiry_policy": "replace_from_now",
|
||||||
|
"downgrade_purchase_policy": "reject",
|
||||||
|
"benefit_inheritance_policy": "target_only",
|
||||||
|
"grant_mode": "trial_card",
|
||||||
|
"trial_card_enabled": true,
|
||||||
|
"status": "active",
|
||||||
|
"config_version": 7
|
||||||
|
},
|
||||||
"user_settings": {
|
"user_settings": {
|
||||||
|
"app_code": "fami",
|
||||||
|
"user_id": 10001,
|
||||||
"room_entry_notice_enabled": true,
|
"room_entry_notice_enabled": true,
|
||||||
"online_global_notice_enabled": true,
|
"online_global_notice_enabled": true,
|
||||||
|
"hide_profile_data_enabled": true,
|
||||||
|
"anonymous_profile_visit_enabled": true,
|
||||||
|
"leaderboard_invisible_enabled": true,
|
||||||
"updated_at_ms": 0
|
"updated_at_ms": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -235,11 +341,13 @@ class VipEntitlementState {
|
|||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"paid_vip": {
|
"paid_vip": {
|
||||||
|
"user_id": 10001,
|
||||||
"level": 3,
|
"level": 3,
|
||||||
"name": "VIP3",
|
"name": "VIP3",
|
||||||
"active": true,
|
"active": true,
|
||||||
"started_at_ms": 1780000000000,
|
"started_at_ms": 1780000000000,
|
||||||
"expires_at_ms": 1782592000000,
|
"expires_at_ms": 1782592000000,
|
||||||
|
"updated_at_ms": 1780000000000,
|
||||||
"program_type": "tiered_privilege_v1",
|
"program_type": "tiered_privilege_v1",
|
||||||
"config_version": 7
|
"config_version": 7
|
||||||
},
|
},
|
||||||
@ -255,14 +363,20 @@ class VipEntitlementState {
|
|||||||
"duration_ms": 2592000000,
|
"duration_ms": 2592000000,
|
||||||
"effective_at_ms": 1780100000000,
|
"effective_at_ms": 1780100000000,
|
||||||
"expires_at_ms": 1782692000000,
|
"expires_at_ms": 1782692000000,
|
||||||
"remaining_duration_ms": 2591000000
|
"remaining_duration_ms": 2591000000,
|
||||||
|
"grant_source": "admin",
|
||||||
|
"source_grant_id": "grant_xxx",
|
||||||
|
"created_at_ms": 1780100000000,
|
||||||
|
"updated_at_ms": 1780100000000
|
||||||
},
|
},
|
||||||
"effective_vip": {
|
"effective_vip": {
|
||||||
|
"user_id": 10001,
|
||||||
"level": 8,
|
"level": 8,
|
||||||
"name": "VIP8",
|
"name": "VIP8",
|
||||||
"active": true,
|
"active": true,
|
||||||
"started_at_ms": 1780100000000,
|
"started_at_ms": 1780100000000,
|
||||||
"expires_at_ms": 1782692000000,
|
"expires_at_ms": 1782692000000,
|
||||||
|
"updated_at_ms": 1780100000000,
|
||||||
"program_type": "tiered_privilege_v1",
|
"program_type": "tiered_privilege_v1",
|
||||||
"config_version": 7
|
"config_version": 7
|
||||||
},
|
},
|
||||||
@ -279,13 +393,22 @@ class VipEntitlementState {
|
|||||||
"execution_scope": "room",
|
"execution_scope": "room",
|
||||||
"auto_equip": false,
|
"auto_equip": false,
|
||||||
"sort_order": 290,
|
"sort_order": 290,
|
||||||
"metadata_json": ""
|
"metadata_json": "",
|
||||||
|
"presentation": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"evaluated_at_ms": 1780101000000,
|
"evaluated_at_ms": 1780101000000,
|
||||||
"program_config": {
|
"program_config": {
|
||||||
"app_code": "fami",
|
"app_code": "fami",
|
||||||
"program_type": "tiered_privilege_v1",
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"level_count": 9,
|
||||||
|
"same_level_expiry_policy": "extend_remaining",
|
||||||
|
"upgrade_expiry_policy": "replace_from_now",
|
||||||
|
"downgrade_purchase_policy": "reject",
|
||||||
|
"benefit_inheritance_policy": "target_only",
|
||||||
|
"grant_mode": "trial_card",
|
||||||
|
"trial_card_enabled": true,
|
||||||
|
"status": "active",
|
||||||
"config_version": 7
|
"config_version": 7
|
||||||
},
|
},
|
||||||
"user_settings": {
|
"user_settings": {
|
||||||
@ -293,6 +416,9 @@ class VipEntitlementState {
|
|||||||
"user_id": 10001,
|
"user_id": 10001,
|
||||||
"room_entry_notice_enabled": true,
|
"room_entry_notice_enabled": true,
|
||||||
"online_global_notice_enabled": true,
|
"online_global_notice_enabled": true,
|
||||||
|
"hide_profile_data_enabled": true,
|
||||||
|
"anonymous_profile_visit_enabled": true,
|
||||||
|
"leaderboard_invisible_enabled": true,
|
||||||
"updated_at_ms": 0
|
"updated_at_ms": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -346,35 +472,81 @@ class VipEntitlementState {
|
|||||||
},
|
},
|
||||||
"coin_spent": 4000,
|
"coin_spent": 4000,
|
||||||
"coin_balance_after": 6000,
|
"coin_balance_after": 6000,
|
||||||
|
"coin_balance": {
|
||||||
|
"asset_type": "COIN",
|
||||||
|
"available_amount": 6000,
|
||||||
|
"frozen_amount": 0,
|
||||||
|
"version": 18
|
||||||
|
},
|
||||||
"reward_items": [],
|
"reward_items": [],
|
||||||
"program_config": {
|
"program_config": {
|
||||||
"app_code": "fami",
|
"app_code": "fami",
|
||||||
"program_type": "tiered_privilege_v1",
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"level_count": 9,
|
||||||
|
"same_level_expiry_policy": "extend_remaining",
|
||||||
"upgrade_expiry_policy": "replace_from_now",
|
"upgrade_expiry_policy": "replace_from_now",
|
||||||
|
"downgrade_purchase_policy": "reject",
|
||||||
|
"benefit_inheritance_policy": "target_only",
|
||||||
|
"grant_mode": "trial_card",
|
||||||
|
"trial_card_enabled": true,
|
||||||
|
"status": "active",
|
||||||
"config_version": 7
|
"config_version": 7
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"effective_source": "paid",
|
|
||||||
"paid_vip": {
|
"paid_vip": {
|
||||||
|
"user_id": 10001,
|
||||||
"level": 4,
|
"level": 4,
|
||||||
"name": "VIP4",
|
"name": "VIP4",
|
||||||
"active": true,
|
"active": true,
|
||||||
"expires_at_ms": 1782692000000
|
"started_at_ms": 1780100000000,
|
||||||
|
"expires_at_ms": 1782692000000,
|
||||||
|
"updated_at_ms": 1780100000000,
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"config_version": 7
|
||||||
},
|
},
|
||||||
"equipped_trial_card": null,
|
"equipped_trial_card": null,
|
||||||
"effective_vip": {
|
"effective_vip": {
|
||||||
|
"user_id": 10001,
|
||||||
"level": 4,
|
"level": 4,
|
||||||
"name": "VIP4",
|
"name": "VIP4",
|
||||||
"active": true,
|
"active": true,
|
||||||
"expires_at_ms": 1782692000000
|
"started_at_ms": 1780100000000,
|
||||||
|
"expires_at_ms": 1782692000000,
|
||||||
|
"updated_at_ms": 1780100000000,
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"config_version": 7
|
||||||
},
|
},
|
||||||
|
"effective_source": "paid",
|
||||||
"effective_benefits": [
|
"effective_benefits": [
|
||||||
{
|
{
|
||||||
"benefit_code": "custom_room_background",
|
"benefit_code": "custom_room_background",
|
||||||
"status": "active"
|
"status": "active"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"evaluated_at_ms": 1780100000000
|
"evaluated_at_ms": 1780100000000,
|
||||||
|
"program_config": {
|
||||||
|
"app_code": "fami",
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"level_count": 9,
|
||||||
|
"same_level_expiry_policy": "extend_remaining",
|
||||||
|
"upgrade_expiry_policy": "replace_from_now",
|
||||||
|
"downgrade_purchase_policy": "reject",
|
||||||
|
"benefit_inheritance_policy": "target_only",
|
||||||
|
"grant_mode": "trial_card",
|
||||||
|
"trial_card_enabled": true,
|
||||||
|
"status": "active",
|
||||||
|
"config_version": 7
|
||||||
|
},
|
||||||
|
"user_settings": {
|
||||||
|
"app_code": "fami",
|
||||||
|
"user_id": 10001,
|
||||||
|
"room_entry_notice_enabled": true,
|
||||||
|
"online_global_notice_enabled": true,
|
||||||
|
"hide_profile_data_enabled": true,
|
||||||
|
"anonymous_profile_visit_enabled": true,
|
||||||
|
"leaderboard_invisible_enabled": true,
|
||||||
|
"updated_at_ms": 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -384,6 +556,8 @@ class VipEntitlementState {
|
|||||||
- Fami 高等级购买:立即替换低等级,从购买时间重新计时,低等级剩余时间丢弃。
|
- Fami 高等级购买:立即替换低等级,从购买时间重新计时,低等级剩余时间丢弃。
|
||||||
- 低等级购买:服务端拒绝。
|
- 低等级购买:服务端拒绝。
|
||||||
- 购买时佩戴体验卡:付费 VIP 会更新,但 `effective_vip` 仍可能来自体验卡,必须使用响应 `state`。
|
- 购买时佩戴体验卡:付费 VIP 会更新,但 `effective_vip` 仍可能来自体验卡,必须使用响应 `state`。
|
||||||
|
- 钱包缓存只使用 `coin_balance`。应用时比较 `version`,低版本响应不得覆盖本地高版本余额;`coin_balance_after` 仅保留旧客户端兼容。
|
||||||
|
- 历史幂等订单在升级前没有记录版本时可能返回 `coin_balance.version=0`,Flutter 必须调用现有钱包余额接口刷新,不得把版本 0 写入余额 store。
|
||||||
|
|
||||||
## 6. VIP 体验卡
|
## 6. VIP 体验卡
|
||||||
|
|
||||||
@ -473,22 +647,92 @@ class VipEntitlementState {
|
|||||||
"duration_ms": 1728000000,
|
"duration_ms": 1728000000,
|
||||||
"effective_at_ms": 1780200000000,
|
"effective_at_ms": 1780200000000,
|
||||||
"expires_at_ms": 1781928000000,
|
"expires_at_ms": 1781928000000,
|
||||||
"remaining_duration_ms": 1700000000
|
"remaining_duration_ms": 1700000000,
|
||||||
|
"grant_source": "admin",
|
||||||
|
"source_grant_id": "grant_b",
|
||||||
|
"created_at_ms": 1780200000000,
|
||||||
|
"updated_at_ms": 1780200000000
|
||||||
},
|
},
|
||||||
"program_config": {
|
"program_config": {
|
||||||
"app_code": "fami",
|
"app_code": "fami",
|
||||||
"program_type": "tiered_privilege_v1",
|
"program_type": "tiered_privilege_v1",
|
||||||
"trial_card_enabled": true
|
"level_count": 9,
|
||||||
|
"same_level_expiry_policy": "extend_remaining",
|
||||||
|
"upgrade_expiry_policy": "replace_from_now",
|
||||||
|
"downgrade_purchase_policy": "reject",
|
||||||
|
"benefit_inheritance_policy": "target_only",
|
||||||
|
"grant_mode": "trial_card",
|
||||||
|
"trial_card_enabled": true,
|
||||||
|
"status": "active",
|
||||||
|
"config_version": 7
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"effective_source": "trial",
|
"paid_vip": {
|
||||||
|
"user_id": 10001,
|
||||||
|
"level": 3,
|
||||||
|
"name": "VIP3",
|
||||||
|
"active": true,
|
||||||
|
"started_at_ms": 1780000000000,
|
||||||
|
"expires_at_ms": 1782592000000,
|
||||||
|
"updated_at_ms": 1780000000000,
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"config_version": 7
|
||||||
|
},
|
||||||
|
"equipped_trial_card": {
|
||||||
|
"trial_card_id": "vip_trial_card_b",
|
||||||
|
"entitlement_id": "ent_card_b",
|
||||||
|
"resource_id": 102,
|
||||||
|
"user_id": 10001,
|
||||||
|
"level": 4,
|
||||||
|
"name": "VIP4",
|
||||||
|
"status": "active",
|
||||||
|
"equipped": true,
|
||||||
|
"duration_ms": 1728000000,
|
||||||
|
"effective_at_ms": 1780200000000,
|
||||||
|
"expires_at_ms": 1781928000000,
|
||||||
|
"remaining_duration_ms": 1700000000,
|
||||||
|
"grant_source": "admin",
|
||||||
|
"source_grant_id": "grant_b",
|
||||||
|
"created_at_ms": 1780200000000,
|
||||||
|
"updated_at_ms": 1780200000000
|
||||||
|
},
|
||||||
"effective_vip": {
|
"effective_vip": {
|
||||||
|
"user_id": 10001,
|
||||||
"level": 4,
|
"level": 4,
|
||||||
"name": "VIP4",
|
"name": "VIP4",
|
||||||
"active": true,
|
"active": true,
|
||||||
"expires_at_ms": 1781928000000
|
"started_at_ms": 1780200000000,
|
||||||
|
"expires_at_ms": 1781928000000,
|
||||||
|
"updated_at_ms": 1780200000000,
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"config_version": 7
|
||||||
},
|
},
|
||||||
"effective_benefits": []
|
"effective_source": "trial",
|
||||||
|
"effective_benefits": [],
|
||||||
|
"evaluated_at_ms": 1780228000000,
|
||||||
|
"program_config": {
|
||||||
|
"app_code": "fami",
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"level_count": 9,
|
||||||
|
"same_level_expiry_policy": "extend_remaining",
|
||||||
|
"upgrade_expiry_policy": "replace_from_now",
|
||||||
|
"downgrade_purchase_policy": "reject",
|
||||||
|
"benefit_inheritance_policy": "target_only",
|
||||||
|
"grant_mode": "trial_card",
|
||||||
|
"trial_card_enabled": true,
|
||||||
|
"status": "active",
|
||||||
|
"config_version": 7
|
||||||
|
},
|
||||||
|
"user_settings": {
|
||||||
|
"app_code": "fami",
|
||||||
|
"user_id": 10001,
|
||||||
|
"room_entry_notice_enabled": true,
|
||||||
|
"online_global_notice_enabled": true,
|
||||||
|
"hide_profile_data_enabled": true,
|
||||||
|
"anonymous_profile_visit_enabled": true,
|
||||||
|
"leaderboard_invisible_enabled": true,
|
||||||
|
"updated_at_ms": 0
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"server_time_ms": 1780228000000
|
"server_time_ms": 1780228000000
|
||||||
}
|
}
|
||||||
@ -516,22 +760,67 @@ class VipEntitlementState {
|
|||||||
"unequipped": true,
|
"unequipped": true,
|
||||||
"program_config": {
|
"program_config": {
|
||||||
"app_code": "fami",
|
"app_code": "fami",
|
||||||
"program_type": "tiered_privilege_v1"
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"level_count": 9,
|
||||||
|
"same_level_expiry_policy": "extend_remaining",
|
||||||
|
"upgrade_expiry_policy": "replace_from_now",
|
||||||
|
"downgrade_purchase_policy": "reject",
|
||||||
|
"benefit_inheritance_policy": "target_only",
|
||||||
|
"grant_mode": "trial_card",
|
||||||
|
"trial_card_enabled": true,
|
||||||
|
"status": "active",
|
||||||
|
"config_version": 7
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"effective_source": "paid",
|
|
||||||
"paid_vip": {
|
"paid_vip": {
|
||||||
|
"user_id": 10001,
|
||||||
"level": 3,
|
"level": 3,
|
||||||
"name": "VIP3",
|
"name": "VIP3",
|
||||||
"active": true
|
"active": true,
|
||||||
|
"started_at_ms": 1780000000000,
|
||||||
|
"expires_at_ms": 1782592000000,
|
||||||
|
"updated_at_ms": 1780000000000,
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"config_version": 7
|
||||||
},
|
},
|
||||||
"equipped_trial_card": null,
|
"equipped_trial_card": null,
|
||||||
"effective_vip": {
|
"effective_vip": {
|
||||||
|
"user_id": 10001,
|
||||||
"level": 3,
|
"level": 3,
|
||||||
"name": "VIP3",
|
"name": "VIP3",
|
||||||
"active": true
|
"active": true,
|
||||||
|
"started_at_ms": 1780000000000,
|
||||||
|
"expires_at_ms": 1782592000000,
|
||||||
|
"updated_at_ms": 1780000000000,
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"config_version": 7
|
||||||
},
|
},
|
||||||
"effective_benefits": []
|
"effective_source": "paid",
|
||||||
|
"effective_benefits": [],
|
||||||
|
"evaluated_at_ms": 1780229000000,
|
||||||
|
"program_config": {
|
||||||
|
"app_code": "fami",
|
||||||
|
"program_type": "tiered_privilege_v1",
|
||||||
|
"level_count": 9,
|
||||||
|
"same_level_expiry_policy": "extend_remaining",
|
||||||
|
"upgrade_expiry_policy": "replace_from_now",
|
||||||
|
"downgrade_purchase_policy": "reject",
|
||||||
|
"benefit_inheritance_policy": "target_only",
|
||||||
|
"grant_mode": "trial_card",
|
||||||
|
"trial_card_enabled": true,
|
||||||
|
"status": "active",
|
||||||
|
"config_version": 7
|
||||||
|
},
|
||||||
|
"user_settings": {
|
||||||
|
"app_code": "fami",
|
||||||
|
"user_id": 10001,
|
||||||
|
"room_entry_notice_enabled": true,
|
||||||
|
"online_global_notice_enabled": true,
|
||||||
|
"hide_profile_data_enabled": true,
|
||||||
|
"anonymous_profile_visit_enabled": true,
|
||||||
|
"leaderboard_invisible_enabled": true,
|
||||||
|
"updated_at_ms": 0
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"server_time_ms": 1780229000000
|
"server_time_ms": 1780229000000
|
||||||
}
|
}
|
||||||
@ -543,7 +832,7 @@ class VipEntitlementState {
|
|||||||
- 每张卡的剩余时间按绝对 `expires_at_ms` 计算。
|
- 每张卡的剩余时间按绝对 `expires_at_ms` 计算。
|
||||||
- 佩戴和卸下成功后立即使用响应 `state` 替换本地 VIP 状态。
|
- 佩戴和卸下成功后立即使用响应 `state` 替换本地 VIP 状态。
|
||||||
|
|
||||||
## 7. VIP 通知开关
|
## 7. VIP 功能开关
|
||||||
|
|
||||||
### 7.1 查询开关
|
### 7.1 查询开关
|
||||||
|
|
||||||
@ -568,6 +857,9 @@ class VipEntitlementState {
|
|||||||
"user_id": 10001,
|
"user_id": 10001,
|
||||||
"room_entry_notice_enabled": true,
|
"room_entry_notice_enabled": true,
|
||||||
"online_global_notice_enabled": false,
|
"online_global_notice_enabled": false,
|
||||||
|
"hide_profile_data_enabled": true,
|
||||||
|
"anonymous_profile_visit_enabled": true,
|
||||||
|
"leaderboard_invisible_enabled": true,
|
||||||
"updated_at_ms": 1780300000000
|
"updated_at_ms": 1780300000000
|
||||||
},
|
},
|
||||||
"evaluated_at_ms": 1780300001000
|
"evaluated_at_ms": 1780300001000
|
||||||
@ -588,7 +880,10 @@ class VipEntitlementState {
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"room_entry_notice_enabled": false,
|
"room_entry_notice_enabled": false,
|
||||||
"online_global_notice_enabled": true
|
"online_global_notice_enabled": true,
|
||||||
|
"hide_profile_data_enabled": false,
|
||||||
|
"anonymous_profile_visit_enabled": true,
|
||||||
|
"leaderboard_invisible_enabled": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -605,6 +900,9 @@ class VipEntitlementState {
|
|||||||
"user_id": 10001,
|
"user_id": 10001,
|
||||||
"room_entry_notice_enabled": false,
|
"room_entry_notice_enabled": false,
|
||||||
"online_global_notice_enabled": true,
|
"online_global_notice_enabled": true,
|
||||||
|
"hide_profile_data_enabled": false,
|
||||||
|
"anonymous_profile_visit_enabled": true,
|
||||||
|
"leaderboard_invisible_enabled": true,
|
||||||
"updated_at_ms": 1780300010000
|
"updated_at_ms": 1780300010000
|
||||||
},
|
},
|
||||||
"server_time_ms": 1780300010000
|
"server_time_ms": 1780300010000
|
||||||
@ -612,6 +910,8 @@ class VipEntitlementState {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
5 个字段都使用 PATCH 语义:不传表示保持原值,显式 `false` 表示关闭。没有持久记录时均默认 `true`;设置只限制用户主动启用对应效果,不会单独授予 VIP 权益。服务端执行时使用“当前有效权益 AND 用户设置”判定,Flutter 不能只看开关。
|
||||||
|
|
||||||
## 8. 每日 VIP 金币返现
|
## 8. 每日 VIP 金币返现
|
||||||
|
|
||||||
### 8.1 查询当前返现
|
### 8.1 查询当前返现
|
||||||
@ -1050,3 +1350,35 @@ Flutter 将 `action_param` 解析为 JSON,打开页面后调用 `/vip/coin-reb
|
|||||||
接口:`GET /api/v1/im/usersig`。
|
接口:`GET /api/v1/im/usersig`。
|
||||||
|
|
||||||
Flutter 使用返回的 UserSig 登录腾讯云 IM,并加入响应 `join_groups` 中 `type=global_broadcast` 的群组后,再消费 `vip_online_notice`。
|
Flutter 使用返回的 UserSig 登录腾讯云 IM,并加入响应 `join_groups` 中 `type=global_broadcast` 的群组后,再消费 `vip_online_notice`。
|
||||||
|
|
||||||
|
## 13. 稳定业务错误码
|
||||||
|
|
||||||
|
失败仍使用统一 envelope,Flutter 只判断 `code`,不得解析 `message`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "VIP_INSUFFICIENT_COIN",
|
||||||
|
"message": "insufficient coin balance",
|
||||||
|
"request_id": "req_purchase",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| HTTP | `code` | 场景 | Flutter 处理 |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 409 | `VIP_PROGRAM_INACTIVE` | 当前 App 的 VIP 体系未启用 | 关闭购买/体验卡操作并刷新配置 |
|
||||||
|
| 409 | `VIP_PACKAGE_NOT_PURCHASABLE` | 套餐不存在、停用或不在当前体系范围 | 刷新 `/vip/packages` |
|
||||||
|
| 409 | `VIP_DOWNGRADE_NOT_ALLOWED` | 购买等级低于当前付费等级 | 提示不可降级,刷新 `/vip/me` |
|
||||||
|
| 409 | `VIP_RECHARGE_REQUIRED` | 未达到套餐充值门槛 | 展示充值引导 |
|
||||||
|
| 409 | `VIP_INSUFFICIENT_COIN` | 购买金币余额不足 | 展示金币充值入口 |
|
||||||
|
| 404 | `VIP_TRIAL_CARD_NOT_FOUND` | 卡不存在、不属于本人或背包权益已撤销 | 刷新体验卡列表和 `/vip/me` |
|
||||||
|
| 409 | `VIP_TRIAL_CARD_EXPIRED` | 卡或背包权益已越过绝对截止时间 | 刷新体验卡列表和 `/vip/me` |
|
||||||
|
| 403 | `VIP_BENEFIT_REQUIRED` | 操作需要的权益未生效或被用户关闭 | 刷新 `/vip/me`,保持入口锁态 |
|
||||||
|
| 403 | `VIP_ANTI_KICK` | 普通房主/管理员踢人被目标用户防踢拦截 | 提示目标不可被踢出,不改变房间本地状态 |
|
||||||
|
| 403 | `VIP_ANTI_MUTE` | 普通房主/管理员禁言被目标用户防禁言拦截 | 提示目标不可被禁言,不改变房间本地状态 |
|
||||||
|
| 404 | `VIP_COIN_REBATE_NOT_FOUND` | 返现不存在或不属于当前用户/App | 刷新返现列表 |
|
||||||
|
| 409 | `VIP_COIN_REBATE_EXPIRED` | 已越过 UTC 次日 0 点的领取结束边界 | 标记过期并刷新返现列表 |
|
||||||
|
| 409 | `IDEMPOTENCY_CONFLICT` | 同一 `command_id` 被用于不同业务参数 | 停止重试,为新动作生成新 ID |
|
||||||
|
| 409 | `LEDGER_CONFLICT` | 钱包并发版本冲突 | 刷新余额和 `/vip/me` 后由用户重新发起 |
|
||||||
|
|
||||||
|
返现重复领取采用幂等成功语义:相同 `command_id` 重试返回首次成功回执,不返回 `VIP_REBATE_ALREADY_CLAIMED`。因此客户端不应实现该错误分支。
|
||||||
|
|||||||
@ -84,6 +84,14 @@ var catalog = map[Code]Spec{
|
|||||||
VIPLevelNotFound: spec(codes.NotFound, httpStatusNotFound, VIPLevelNotFound, "not found"),
|
VIPLevelNotFound: spec(codes.NotFound, httpStatusNotFound, VIPLevelNotFound, "not found"),
|
||||||
VIPLevelDisabled: spec(codes.FailedPrecondition, httpStatusConflict, VIPLevelDisabled, "vip level is disabled"),
|
VIPLevelDisabled: spec(codes.FailedPrecondition, httpStatusConflict, VIPLevelDisabled, "vip level is disabled"),
|
||||||
VIPDowngradeNotAllowed: spec(codes.FailedPrecondition, httpStatusConflict, VIPDowngradeNotAllowed, "vip downgrade is not allowed"),
|
VIPDowngradeNotAllowed: spec(codes.FailedPrecondition, httpStatusConflict, VIPDowngradeNotAllowed, "vip downgrade is not allowed"),
|
||||||
|
VIPProgramInactive: spec(codes.FailedPrecondition, httpStatusConflict, VIPProgramInactive, "vip program is inactive"),
|
||||||
|
VIPPackageNotPurchasable: spec(codes.FailedPrecondition, httpStatusConflict, VIPPackageNotPurchasable, "vip package is not purchasable"),
|
||||||
|
VIPInsufficientCoin: spec(codes.FailedPrecondition, httpStatusConflict, VIPInsufficientCoin, "insufficient coin balance"),
|
||||||
|
VIPTrialCardNotFound: spec(codes.NotFound, httpStatusNotFound, VIPTrialCardNotFound, "vip trial card not found"),
|
||||||
|
VIPTrialCardExpired: spec(codes.FailedPrecondition, httpStatusConflict, VIPTrialCardExpired, "vip trial card expired"),
|
||||||
|
VIPBenefitRequired: spec(codes.PermissionDenied, httpStatusForbidden, VIPBenefitRequired, "vip benefit is required"),
|
||||||
|
VIPAntiKick: spec(codes.PermissionDenied, httpStatusForbidden, VIPAntiKick, "target user cannot be kicked"),
|
||||||
|
VIPAntiMute: spec(codes.PermissionDenied, httpStatusForbidden, VIPAntiMute, "target user cannot be muted"),
|
||||||
VIPRechargeRequired: spec(codes.FailedPrecondition, httpStatusConflict, VIPRechargeRequired, "vip recharge is required"),
|
VIPRechargeRequired: spec(codes.FailedPrecondition, httpStatusConflict, VIPRechargeRequired, "vip recharge is required"),
|
||||||
VIPCoinRebateNotFound: spec(codes.NotFound, httpStatusNotFound, VIPCoinRebateNotFound, "vip coin rebate not found"),
|
VIPCoinRebateNotFound: spec(codes.NotFound, httpStatusNotFound, VIPCoinRebateNotFound, "vip coin rebate not found"),
|
||||||
VIPCoinRebateExpired: spec(codes.FailedPrecondition, httpStatusConflict, VIPCoinRebateExpired, "vip coin rebate expired"),
|
VIPCoinRebateExpired: spec(codes.FailedPrecondition, httpStatusConflict, VIPCoinRebateExpired, "vip coin rebate expired"),
|
||||||
|
|||||||
@ -102,6 +102,22 @@ const (
|
|||||||
VIPLevelDisabled Code = "VIP_LEVEL_DISABLED"
|
VIPLevelDisabled Code = "VIP_LEVEL_DISABLED"
|
||||||
// VIPDowngradeNotAllowed 表示用户当前有效 VIP 等级高于本次购买目标等级。
|
// VIPDowngradeNotAllowed 表示用户当前有效 VIP 等级高于本次购买目标等级。
|
||||||
VIPDowngradeNotAllowed Code = "VIP_DOWNGRADE_NOT_ALLOWED"
|
VIPDowngradeNotAllowed Code = "VIP_DOWNGRADE_NOT_ALLOWED"
|
||||||
|
// VIPProgramInactive 表示当前 App 未启用可执行的 VIP 体系。
|
||||||
|
VIPProgramInactive Code = "VIP_PROGRAM_INACTIVE"
|
||||||
|
// VIPPackageNotPurchasable 表示目标套餐不存在、停用或不在当前体系等级范围内。
|
||||||
|
VIPPackageNotPurchasable Code = "VIP_PACKAGE_NOT_PURCHASABLE"
|
||||||
|
// VIPInsufficientCoin 表示 VIP 购买事务中的 COIN 余额不足。
|
||||||
|
VIPInsufficientCoin Code = "VIP_INSUFFICIENT_COIN"
|
||||||
|
// VIPTrialCardNotFound 表示体验卡实例不存在、不属于当前用户或其背包权益已撤销。
|
||||||
|
VIPTrialCardNotFound Code = "VIP_TRIAL_CARD_NOT_FOUND"
|
||||||
|
// VIPTrialCardExpired 表示体验卡或对应背包权益已越过绝对过期时间。
|
||||||
|
VIPTrialCardExpired Code = "VIP_TRIAL_CARD_EXPIRED"
|
||||||
|
// VIPBenefitRequired 表示当前操作需要一项未生效或已被用户关闭的 VIP 权益。
|
||||||
|
VIPBenefitRequired Code = "VIP_BENEFIT_REQUIRED"
|
||||||
|
// VIPAntiKick 表示普通房主/管理员踢人动作被目标用户的防踢权益拦截。
|
||||||
|
VIPAntiKick Code = "VIP_ANTI_KICK"
|
||||||
|
// VIPAntiMute 表示普通房主/管理员禁言动作被目标用户的防禁言权益拦截。
|
||||||
|
VIPAntiMute Code = "VIP_ANTI_MUTE"
|
||||||
// VIPRechargeRequired 表示目标 VIP 等级需要用户先达到累计充值门槛。
|
// VIPRechargeRequired 表示目标 VIP 等级需要用户先达到累计充值门槛。
|
||||||
VIPRechargeRequired Code = "VIP_RECHARGE_REQUIRED"
|
VIPRechargeRequired Code = "VIP_RECHARGE_REQUIRED"
|
||||||
// VIPCoinRebateNotFound 表示返现不存在,或不属于当前 App/用户。
|
// VIPCoinRebateNotFound 表示返现不存在,或不属于当前 App/用户。
|
||||||
|
|||||||
@ -135,6 +135,14 @@ func TestCatalogMappings(t *testing.T) {
|
|||||||
publicCode: string(CPAlreadyExistsOther),
|
publicCode: string(CPAlreadyExistsOther),
|
||||||
publicMessage: "The other party already has a CP",
|
publicMessage: "The other party already has a CP",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "vip anti kick keeps actionable forbidden code",
|
||||||
|
code: VIPAntiKick,
|
||||||
|
grpcCode: codes.PermissionDenied,
|
||||||
|
httpStatus: httpStatusForbidden,
|
||||||
|
publicCode: string(VIPAntiKick),
|
||||||
|
publicMessage: "target user cannot be kicked",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "region mismatch keeps forbidden transport but exposes concrete app prompt",
|
name: "region mismatch keeps forbidden transport but exposes concrete app prompt",
|
||||||
code: RegionMismatch,
|
code: RegionMismatch,
|
||||||
|
|||||||
@ -29,12 +29,12 @@ SQL_FILES=(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# initdb only creates missing tables; a reused local MySQL volume therefore
|
# initdb only creates missing tables; a reused local MySQL volume therefore
|
||||||
# needs incremental migrations as well. Keep the owned user-service migrations
|
# needs incremental migrations as well. Keep owner migrations here when reused
|
||||||
# in this local bootstrap path so authentication schema changes (for example
|
# volumes need more than CREATE TABLE IF NOT EXISTS can provide; otherwise new
|
||||||
# login_audit client-version fields) cannot leave a locally logged-in client
|
# binaries may start against a structurally valid-looking but stale table.
|
||||||
# receiving a misleading 401 during token refresh.
|
INCREMENTAL_MIGRATION_FILES=(
|
||||||
USER_MIGRATION_FILES=(
|
|
||||||
"services/user-service/deploy/mysql/migrations/012_login_audit_client_version.sql"
|
"services/user-service/deploy/mysql/migrations/012_login_audit_client_version.sql"
|
||||||
|
"services/wallet-service/deploy/mysql/migrations/002_resource_shop_price_tiers.sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Keep MySQL as the only required dependency for this script. Business services
|
# Keep MySQL as the only required dependency for this script. Business services
|
||||||
@ -71,7 +71,7 @@ for sql_file in "${SQL_FILES[@]}"; do
|
|||||||
docker compose exec -T mysql mysql --default-character-set=utf8mb4 -h 127.0.0.1 -uroot -p"${MYSQL_ROOT_PASSWORD}" < "${sql_file}"
|
docker compose exec -T mysql mysql --default-character-set=utf8mb4 -h 127.0.0.1 -uroot -p"${MYSQL_ROOT_PASSWORD}" < "${sql_file}"
|
||||||
done
|
done
|
||||||
|
|
||||||
for sql_file in "${USER_MIGRATION_FILES[@]}"; do
|
for sql_file in "${INCREMENTAL_MIGRATION_FILES[@]}"; do
|
||||||
if [[ ! -f "${sql_file}" ]]; then
|
if [[ ! -f "${sql_file}" ]]; then
|
||||||
printf 'skipping mysql migration because it is absent or not a regular file: %s\n' "${sql_file}"
|
printf 'skipping mysql migration because it is absent or not a regular file: %s\n' "${sql_file}"
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -9,6 +9,9 @@ CREATE TABLE IF NOT EXISTS user_vip_settings (
|
|||||||
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
room_entry_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启进房通知',
|
room_entry_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启进房通知',
|
||||||
online_global_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启上线全服通知',
|
online_global_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启上线全服通知',
|
||||||
|
hide_profile_data_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启隐藏个人数据',
|
||||||
|
anonymous_profile_visit_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启匿名访问主页',
|
||||||
|
leaderboard_invisible_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启榜单隐身',
|
||||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
PRIMARY KEY (app_code, user_id)
|
PRIMARY KEY (app_code, user_id)
|
||||||
|
|||||||
33
scripts/mysql/067_vip_user_privacy_settings.sql
Normal file
33
scripts/mysql/067_vip_user_privacy_settings.sql
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE hyapp_wallet;
|
||||||
|
|
||||||
|
-- user_vip_settings 以 (app_code,user_id) 为主键且新增列均为常量默认值。MySQL 8.4 可用
|
||||||
|
-- ALGORITHM=INSTANT 仅修改数据字典,不扫描或重写历史行;MySQL 8.4 的 INSTANT 算法不接受
|
||||||
|
-- 显式 LOCK 子句,因此只保留算法硬约束,避免语法失败且禁止静默退化为 COPY/INPLACE。
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_settings' AND COLUMN_NAME = 'hide_profile_data_enabled') = 0,
|
||||||
|
'ALTER TABLE user_vip_settings ADD COLUMN hide_profile_data_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT ''是否开启隐藏个人数据'', ALGORITHM=INSTANT',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_settings' AND COLUMN_NAME = 'anonymous_profile_visit_enabled') = 0,
|
||||||
|
'ALTER TABLE user_vip_settings ADD COLUMN anonymous_profile_visit_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT ''是否开启匿名访问主页'', ALGORITHM=INSTANT',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_settings' AND COLUMN_NAME = 'leaderboard_invisible_enabled') = 0,
|
||||||
|
'ALTER TABLE user_vip_settings ADD COLUMN leaderboard_invisible_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT ''是否开启榜单隐身'', ALGORITHM=INSTANT',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@ -33,6 +33,7 @@ type Client interface {
|
|||||||
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
|
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
|
||||||
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
|
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
|
||||||
RevokeResourceGrant(ctx context.Context, req *walletv1.RevokeResourceGrantRequest) (*walletv1.ResourceGrantResponse, error)
|
RevokeResourceGrant(ctx context.Context, req *walletv1.RevokeResourceGrantRequest) (*walletv1.ResourceGrantResponse, error)
|
||||||
|
RevokeUserResource(ctx context.Context, req *walletv1.RevokeUserResourceRequest) (*walletv1.RevokeUserResourceResponse, error)
|
||||||
EquipUserResource(ctx context.Context, req *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error)
|
EquipUserResource(ctx context.Context, req *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error)
|
||||||
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error)
|
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error)
|
||||||
ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error)
|
ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error)
|
||||||
@ -173,6 +174,10 @@ func (c *GRPCClient) RevokeResourceGrant(ctx context.Context, req *walletv1.Revo
|
|||||||
return c.client.RevokeResourceGrant(ctx, req)
|
return c.client.RevokeResourceGrant(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) RevokeUserResource(ctx context.Context, req *walletv1.RevokeUserResourceRequest) (*walletv1.RevokeUserResourceResponse, error) {
|
||||||
|
return c.client.RevokeUserResource(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *GRPCClient) EquipUserResource(ctx context.Context, req *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error) {
|
func (c *GRPCClient) EquipUserResource(ctx context.Context, req *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error) {
|
||||||
return c.client.EquipUserResource(ctx, req)
|
return c.client.EquipUserResource(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,9 +46,9 @@ const (
|
|||||||
// use an isolated credential lifecycle and can never exchange this row for a main-admin JWT.
|
// use an isolated credential lifecycle and can never exchange this row for a main-admin JWT.
|
||||||
type ExternalAdminAccount struct {
|
type ExternalAdminAccount struct {
|
||||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
AppCode string `gorm:"size:32;uniqueIndex:uk_external_admin_accounts_app_username;uniqueIndex:uk_external_admin_accounts_app_linked_user;index:idx_external_admin_accounts_app_status_updated,priority:1;not null" json:"appCode"`
|
AppCode string `gorm:"size:32;uniqueIndex:uk_external_admin_accounts_app_linked_user;index:idx_external_admin_accounts_app_status_updated,priority:1;not null" json:"appCode"`
|
||||||
LinkedAppUserID int64 `gorm:"column:linked_app_user_id;uniqueIndex:uk_external_admin_accounts_app_linked_user;not null" json:"-"`
|
LinkedAppUserID int64 `gorm:"column:linked_app_user_id;uniqueIndex:uk_external_admin_accounts_app_linked_user;not null" json:"-"`
|
||||||
Username string `gorm:"size:64;uniqueIndex:uk_external_admin_accounts_app_username;not null" json:"username"`
|
Username string `gorm:"size:64;uniqueIndex:uk_external_admin_accounts_username;not null" json:"username"`
|
||||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||||
PermissionsJSON string `gorm:"column:permissions_json;type:json;not null" json:"-"`
|
PermissionsJSON string `gorm:"column:permissions_json;type:json;not null" json:"-"`
|
||||||
PermissionRevision uint64 `gorm:"column:permission_revision;not null;default:1" json:"permissionRevision"`
|
PermissionRevision uint64 `gorm:"column:permission_revision;not null;default:1" json:"permissionRevision"`
|
||||||
|
|||||||
@ -827,6 +827,48 @@ func (h *Handler) RevokeResourceGrant(c *gin.Context) {
|
|||||||
response.OK(c, grant)
|
response.OK(c, grant)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RevokeUserResource(c *gin.Context) {
|
||||||
|
userID, err := strconv.ParseInt(strings.TrimSpace(c.Param("user_id")), 10, 64)
|
||||||
|
if err != nil || userID <= 0 {
|
||||||
|
response.BadRequest(c, "用户 ID 不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entitlementID := strings.TrimSpace(c.Param("entitlement_id"))
|
||||||
|
if entitlementID == "" {
|
||||||
|
response.BadRequest(c, "用户素材权益不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req revokeUserResourceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Reason) == "" {
|
||||||
|
response.BadRequest(c, "请填写素材撤回原因")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.wallet.RevokeUserResource(c.Request.Context(), &walletv1.RevokeUserResourceRequest{
|
||||||
|
RequestId: middleware.CurrentRequestID(c),
|
||||||
|
AppCode: appctx.FromContext(c.Request.Context()),
|
||||||
|
UserId: userID,
|
||||||
|
EntitlementId: entitlementID,
|
||||||
|
Reason: strings.TrimSpace(req.Reason),
|
||||||
|
OperatorUserId: actorID(c),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resource := resp.GetResource()
|
||||||
|
// 审计对象使用 entitlement 而不是 source grant:同一资源组发放可包含多个素材,必须能追溯本次只撤回了哪一个。
|
||||||
|
h.auditLog(c, "revoke-user-resource", "user_resource_entitlements", entitlementID, "success", fmt.Sprintf("target_user_id=%d resource_id=%d", userID, resource.GetResourceId()))
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"entitlementId": resource.GetEntitlementId(),
|
||||||
|
"resourceId": resource.GetResourceId(),
|
||||||
|
"status": resource.GetStatus(),
|
||||||
|
"remainingQuantity": resource.GetRemainingQuantity(),
|
||||||
|
"equipped": resource.GetEquipped(),
|
||||||
|
"updatedAtMs": resource.GetUpdatedAtMs(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) LookupResourceGrantTarget(c *gin.Context) {
|
func (h *Handler) LookupResourceGrantTarget(c *gin.Context) {
|
||||||
keyword := strings.TrimSpace(firstQuery(c, "user_id", "userId", "keyword"))
|
keyword := strings.TrimSpace(firstQuery(c, "user_id", "userId", "keyword"))
|
||||||
if keyword == "" {
|
if keyword == "" {
|
||||||
|
|||||||
@ -409,6 +409,29 @@ func TestListResourceGrantsResolvesDisplayIDBeforeWalletFilter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRevokeUserResourceTargetsOneEntitlement(t *testing.T) {
|
||||||
|
wallet := &mockResourceWallet{}
|
||||||
|
router := newResourceHandlerTestRouter(New(wallet, nil, nil, time.Second, nil))
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/admin/users/318705991371722752/resources/ent-host-badge/revoke", strings.NewReader(`{"reason":"remove host badge"}`))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("revoke user resource status mismatch: %d %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if len(wallet.revokeUserResourceRequests) != 1 {
|
||||||
|
t.Fatalf("expected one precise revoke request, got %d", len(wallet.revokeUserResourceRequests))
|
||||||
|
}
|
||||||
|
req := wallet.revokeUserResourceRequests[0]
|
||||||
|
if req.GetUserId() != 318705991371722752 || req.GetEntitlementId() != "ent-host-badge" || req.GetReason() != "remove host badge" || req.GetOperatorUserId() != 7 {
|
||||||
|
t.Fatalf("precise revoke request mismatch: %+v", req)
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), `"entitlementId":"ent-host-badge"`) || !strings.Contains(recorder.Body.String(), `"status":"revoked"`) {
|
||||||
|
t.Fatalf("precise revoke response mismatch: %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newResourceHandlerTestRouter(handler *Handler) *gin.Engine {
|
func newResourceHandlerTestRouter(handler *Handler) *gin.Engine {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
@ -458,6 +481,7 @@ type mockResourceWallet struct {
|
|||||||
listResourceRequests []*walletv1.ListResourcesRequest
|
listResourceRequests []*walletv1.ListResourcesRequest
|
||||||
listedResources []*walletv1.Resource
|
listedResources []*walletv1.Resource
|
||||||
grantRequests []*walletv1.GrantResourceRequest
|
grantRequests []*walletv1.GrantResourceRequest
|
||||||
|
revokeUserResourceRequests []*walletv1.RevokeUserResourceRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockResourceWallet) ListResources(_ context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error) {
|
func (m *mockResourceWallet) ListResources(_ context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error) {
|
||||||
@ -473,6 +497,18 @@ func (m *mockResourceWallet) GrantResource(_ context.Context, req *walletv1.Gran
|
|||||||
}}, nil
|
}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockResourceWallet) RevokeUserResource(_ context.Context, req *walletv1.RevokeUserResourceRequest) (*walletv1.RevokeUserResourceResponse, error) {
|
||||||
|
m.revokeUserResourceRequests = append(m.revokeUserResourceRequests, req)
|
||||||
|
return &walletv1.RevokeUserResourceResponse{Resource: &walletv1.UserResourceEntitlement{
|
||||||
|
EntitlementId: req.GetEntitlementId(),
|
||||||
|
UserId: req.GetUserId(),
|
||||||
|
ResourceId: 91,
|
||||||
|
Status: "revoked",
|
||||||
|
RemainingQuantity: 0,
|
||||||
|
Equipped: false,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockResourceWallet) GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error) {
|
func (m *mockResourceWallet) GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error) {
|
||||||
resource := m.resources[req.GetResourceId()]
|
resource := m.resources[req.GetResourceId()]
|
||||||
if resource == nil {
|
if resource == nil {
|
||||||
|
|||||||
@ -235,6 +235,10 @@ type grantGroupRequest struct {
|
|||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type revokeUserResourceRequest struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
type resourceShopItemsRequest struct {
|
type resourceShopItemsRequest struct {
|
||||||
Items []resourceShopItemRequest `json:"items"`
|
Items []resourceShopItemRequest `json:"items"`
|
||||||
}
|
}
|
||||||
@ -244,6 +248,7 @@ type resourceShopItemRequest struct {
|
|||||||
ResourceID int64 `json:"resourceId"`
|
ResourceID int64 `json:"resourceId"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
DurationDays int32 `json:"durationDays"`
|
DurationDays int32 `json:"durationDays"`
|
||||||
|
CoinPrice int64 `json:"coinPrice"`
|
||||||
EffectiveFromMS int64 `json:"effectiveFromMs"`
|
EffectiveFromMS int64 `json:"effectiveFromMs"`
|
||||||
EffectiveToMS int64 `json:"effectiveToMs"`
|
EffectiveToMS int64 `json:"effectiveToMs"`
|
||||||
SortOrder int32 `json:"sortOrder"`
|
SortOrder int32 `json:"sortOrder"`
|
||||||
@ -558,6 +563,7 @@ func resourceShopItemInputs(items []resourceShopItemRequest) []*walletv1.Resourc
|
|||||||
ResourceId: item.ResourceID,
|
ResourceId: item.ResourceID,
|
||||||
Status: strings.TrimSpace(item.Status),
|
Status: strings.TrimSpace(item.Status),
|
||||||
DurationDays: item.DurationDays,
|
DurationDays: item.DurationDays,
|
||||||
|
CoinPrice: item.CoinPrice,
|
||||||
EffectiveFromMs: item.EffectiveFromMS,
|
EffectiveFromMs: item.EffectiveFromMS,
|
||||||
EffectiveToMs: item.EffectiveToMS,
|
EffectiveToMs: item.EffectiveToMS,
|
||||||
SortOrder: item.SortOrder,
|
SortOrder: item.SortOrder,
|
||||||
|
|||||||
@ -49,6 +49,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
|||||||
protected.POST("/admin/resource-grants/resource", middleware.RequirePermission("resource-grant:create"), h.GrantResource)
|
protected.POST("/admin/resource-grants/resource", middleware.RequirePermission("resource-grant:create"), h.GrantResource)
|
||||||
protected.POST("/admin/resource-grants/group", middleware.RequirePermission("resource-grant:create"), h.GrantResourceGroup)
|
protected.POST("/admin/resource-grants/group", middleware.RequirePermission("resource-grant:create"), h.GrantResourceGroup)
|
||||||
protected.POST("/admin/resource-grants/:grant_id/revoke", middleware.RequirePermission("resource-grant:revoke"), h.RevokeResourceGrant)
|
protected.POST("/admin/resource-grants/:grant_id/revoke", middleware.RequirePermission("resource-grant:revoke"), h.RevokeResourceGrant)
|
||||||
|
protected.POST("/admin/users/:user_id/resources/:entitlement_id/revoke", middleware.RequirePermission("resource-grant:revoke"), h.RevokeUserResource)
|
||||||
// VIP 赠送复用同一目标用户解析入口;仅有 vip-config:grant 的运营也必须能把短号解析成内部 user_id。
|
// VIP 赠送复用同一目标用户解析入口;仅有 vip-config:grant 的运营也必须能把短号解析成内部 user_id。
|
||||||
protected.GET("/admin/resource-grants/target", middleware.RequireAnyPermission("resource-grant:create", "vip-config:grant"), h.LookupResourceGrantTarget)
|
protected.GET("/admin/resource-grants/target", middleware.RequireAnyPermission("resource-grant:create", "vip-config:grant"), h.LookupResourceGrantTarget)
|
||||||
protected.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), h.ListResourceGrants)
|
protected.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), h.ListResourceGrants)
|
||||||
|
|||||||
@ -134,6 +134,31 @@ type vipBenefitDTO struct {
|
|||||||
MetadataJSON string `json:"metadataJson"`
|
MetadataJSON string `json:"metadataJson"`
|
||||||
CreatedAtMS int64 `json:"createdAtMs"`
|
CreatedAtMS int64 `json:"createdAtMs"`
|
||||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||||
|
Presentation *vipBenefitPresentationDTO `json:"presentation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type vipBenefitPresentationDTO struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
PreviewItems []vipBenefitPreviewItemDTO `json:"previewItems"`
|
||||||
|
NumericReward *vipBenefitNumericRewardDTO `json:"numericReward"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type vipBenefitPreviewItemDTO struct {
|
||||||
|
PreviewID string `json:"previewId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
MediaType string `json:"mediaType"`
|
||||||
|
AssetURL string `json:"assetUrl"`
|
||||||
|
PreviewURL string `json:"previewUrl"`
|
||||||
|
AnimationURL string `json:"animationUrl"`
|
||||||
|
CompositionType string `json:"compositionType"`
|
||||||
|
SortOrder int32 `json:"sortOrder"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type vipBenefitNumericRewardDTO struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Period string `json:"period"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type vipTrialCardDTO struct {
|
type vipTrialCardDTO struct {
|
||||||
@ -163,6 +188,18 @@ type vipStateDTO struct {
|
|||||||
EffectiveBenefits []vipBenefitDTO `json:"effectiveBenefits"`
|
EffectiveBenefits []vipBenefitDTO `json:"effectiveBenefits"`
|
||||||
EvaluatedAtMS int64 `json:"evaluatedAtMs"`
|
EvaluatedAtMS int64 `json:"evaluatedAtMs"`
|
||||||
ProgramConfig vipProgramConfigDTO `json:"programConfig"`
|
ProgramConfig vipProgramConfigDTO `json:"programConfig"`
|
||||||
|
UserSettings vipUserSettingsDTO `json:"userSettings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type vipUserSettingsDTO struct {
|
||||||
|
AppCode string `json:"appCode"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
RoomEntryNoticeEnabled bool `json:"roomEntryNoticeEnabled"`
|
||||||
|
OnlineGlobalNoticeEnabled bool `json:"onlineGlobalNoticeEnabled"`
|
||||||
|
HideProfileDataEnabled bool `json:"hideProfileDataEnabled"`
|
||||||
|
AnonymousProfileVisitEnabled bool `json:"anonymousProfileVisitEnabled"`
|
||||||
|
LeaderboardInvisibleEnabled bool `json:"leaderboardInvisibleEnabled"`
|
||||||
|
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type vipRewardItemDTO struct {
|
type vipRewardItemDTO struct {
|
||||||
@ -470,6 +507,7 @@ func vipBenefitsFromProto(items []*walletv1.VipBenefit) []vipBenefitDTO {
|
|||||||
MetadataJSON: item.GetMetadataJson(),
|
MetadataJSON: item.GetMetadataJson(),
|
||||||
CreatedAtMS: item.GetCreatedAtMs(),
|
CreatedAtMS: item.GetCreatedAtMs(),
|
||||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||||
|
Presentation: vipBenefitPresentationFromProto(item.GetPresentation()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return benefits
|
return benefits
|
||||||
@ -489,9 +527,61 @@ func (item vipBenefitDTO) toProto() *walletv1.VipBenefit {
|
|||||||
AutoEquip: item.AutoEquip,
|
AutoEquip: item.AutoEquip,
|
||||||
SortOrder: item.SortOrder,
|
SortOrder: item.SortOrder,
|
||||||
MetadataJson: strings.TrimSpace(item.MetadataJSON),
|
MetadataJson: strings.TrimSpace(item.MetadataJSON),
|
||||||
|
Presentation: item.Presentation.toProto(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func vipBenefitPresentationFromProto(item *walletv1.VipBenefitPresentation) *vipBenefitPresentationDTO {
|
||||||
|
if item == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := &vipBenefitPresentationDTO{
|
||||||
|
Description: item.GetDescription(),
|
||||||
|
PreviewItems: make([]vipBenefitPreviewItemDTO, 0, len(item.GetPreviewItems())),
|
||||||
|
}
|
||||||
|
for _, preview := range item.GetPreviewItems() {
|
||||||
|
if preview == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.PreviewItems = append(result.PreviewItems, vipBenefitPreviewItemDTO{
|
||||||
|
PreviewID: preview.GetPreviewId(), Title: preview.GetTitle(), MediaType: preview.GetMediaType(),
|
||||||
|
AssetURL: preview.GetAssetUrl(), PreviewURL: preview.GetPreviewUrl(), AnimationURL: preview.GetAnimationUrl(),
|
||||||
|
CompositionType: preview.GetCompositionType(), SortOrder: preview.GetSortOrder(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if reward := item.GetNumericReward(); reward != nil {
|
||||||
|
result.NumericReward = &vipBenefitNumericRewardDTO{
|
||||||
|
Label: reward.GetLabel(), Value: reward.GetValue(), Unit: reward.GetUnit(), Period: reward.GetPeriod(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (item *vipBenefitPresentationDTO) toProto() *walletv1.VipBenefitPresentation {
|
||||||
|
if item == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := &walletv1.VipBenefitPresentation{
|
||||||
|
Description: strings.TrimSpace(item.Description),
|
||||||
|
PreviewItems: make([]*walletv1.VipBenefitPreviewItem, 0, len(item.PreviewItems)),
|
||||||
|
}
|
||||||
|
for _, preview := range item.PreviewItems {
|
||||||
|
result.PreviewItems = append(result.PreviewItems, &walletv1.VipBenefitPreviewItem{
|
||||||
|
PreviewId: strings.TrimSpace(preview.PreviewID), Title: strings.TrimSpace(preview.Title),
|
||||||
|
MediaType: strings.TrimSpace(preview.MediaType), AssetUrl: strings.TrimSpace(preview.AssetURL),
|
||||||
|
PreviewUrl: strings.TrimSpace(preview.PreviewURL), AnimationUrl: strings.TrimSpace(preview.AnimationURL),
|
||||||
|
CompositionType: strings.TrimSpace(preview.CompositionType), SortOrder: preview.SortOrder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if item.NumericReward != nil {
|
||||||
|
result.NumericReward = &walletv1.VipBenefitNumericReward{
|
||||||
|
Label: strings.TrimSpace(item.NumericReward.Label), Value: item.NumericReward.Value,
|
||||||
|
Unit: strings.TrimSpace(item.NumericReward.Unit), Period: strings.TrimSpace(item.NumericReward.Period),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func grantVipFromProto(resp *walletv1.GrantVipResponse) grantVipDTO {
|
func grantVipFromProto(resp *walletv1.GrantVipResponse) grantVipDTO {
|
||||||
if resp == nil {
|
if resp == nil {
|
||||||
return grantVipDTO{}
|
return grantVipDTO{}
|
||||||
@ -568,6 +658,22 @@ func vipStateFromProto(item *walletv1.VipState) vipStateDTO {
|
|||||||
EffectiveBenefits: vipBenefitsFromProto(item.GetEffectiveBenefits()),
|
EffectiveBenefits: vipBenefitsFromProto(item.GetEffectiveBenefits()),
|
||||||
EvaluatedAtMS: item.GetEvaluatedAtMs(),
|
EvaluatedAtMS: item.GetEvaluatedAtMs(),
|
||||||
ProgramConfig: vipProgramConfigFromProto(item.GetProgramConfig()),
|
ProgramConfig: vipProgramConfigFromProto(item.GetProgramConfig()),
|
||||||
|
UserSettings: vipUserSettingsFromProto(item.GetUserSettings()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func vipUserSettingsFromProto(item *walletv1.VipUserSettings) vipUserSettingsDTO {
|
||||||
|
if item == nil {
|
||||||
|
return vipUserSettingsDTO{}
|
||||||
|
}
|
||||||
|
return vipUserSettingsDTO{
|
||||||
|
AppCode: item.GetAppCode(), UserID: strconv.FormatInt(item.GetUserId(), 10),
|
||||||
|
RoomEntryNoticeEnabled: item.GetRoomEntryNoticeEnabled(),
|
||||||
|
OnlineGlobalNoticeEnabled: item.GetOnlineGlobalNoticeEnabled(),
|
||||||
|
HideProfileDataEnabled: item.GetHideProfileDataEnabled(),
|
||||||
|
AnonymousProfileVisitEnabled: item.GetAnonymousProfileVisitEnabled(),
|
||||||
|
LeaderboardInvisibleEnabled: item.GetLeaderboardInvisibleEnabled(),
|
||||||
|
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,12 @@ func TestGetProgramReturnsCamelCaseCompleteConfigAndBenefits(t *testing.T) {
|
|||||||
MetadataJson: "{}",
|
MetadataJson: "{}",
|
||||||
CreatedAtMs: 101,
|
CreatedAtMs: 101,
|
||||||
UpdatedAtMs: 201,
|
UpdatedAtMs: 201,
|
||||||
|
Presentation: &walletv1.VipBenefitPresentation{
|
||||||
|
Description: "普通房管无法踢出",
|
||||||
|
PreviewItems: []*walletv1.VipBenefitPreviewItem{{
|
||||||
|
PreviewId: "anti-kick", MediaType: "image", PreviewUrl: "https://cdn.example/anti-kick.png", CompositionType: "none",
|
||||||
|
}},
|
||||||
|
},
|
||||||
}},
|
}},
|
||||||
ServerTimeMs: 300,
|
ServerTimeMs: 300,
|
||||||
},
|
},
|
||||||
@ -60,6 +66,8 @@ func TestGetProgramReturnsCamelCaseCompleteConfigAndBenefits(t *testing.T) {
|
|||||||
`"updatedByAdminId":77`,
|
`"updatedByAdminId":77`,
|
||||||
`"benefitCode":"anti_kick"`,
|
`"benefitCode":"anti_kick"`,
|
||||||
`"executionScope":"room"`,
|
`"executionScope":"room"`,
|
||||||
|
`"description":"普通房管无法踢出"`,
|
||||||
|
`"compositionType":"none"`,
|
||||||
`"serverTimeMs":300`,
|
`"serverTimeMs":300`,
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(body, fragment) {
|
if !strings.Contains(body, fragment) {
|
||||||
@ -105,7 +113,7 @@ func TestUpdateLevelsForwardsExplicitBenefitsAndReturnsProgram(t *testing.T) {
|
|||||||
ProgramConfig: &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", LevelCount: 9},
|
ProgramConfig: &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", LevelCount: 9},
|
||||||
ServerTimeMs: 500,
|
ServerTimeMs: 500,
|
||||||
}}
|
}}
|
||||||
body := `{"levels":[{"level":2,"name":"VIP2","status":"active","priceCoin":2000,"durationMs":2592000000,"rewardResourceGroupId":22,"sortOrder":20,"requiredRechargeCoinAmount":123,"benefits":[{"benefitCode":"room_entry_notice","name":"进房通知","benefitType":"function","unlockLevel":2,"status":"active","trialEnabled":true,"resourceId":0,"resourceType":"","executionScope":"room","autoEquip":false,"sortOrder":10,"metadataJson":"{}"}]}]}`
|
body := `{"levels":[{"level":2,"name":"VIP2","status":"active","priceCoin":2000,"durationMs":2592000000,"rewardResourceGroupId":22,"sortOrder":20,"requiredRechargeCoinAmount":123,"benefits":[{"benefitCode":"room_entry_notice","name":"进房通知","benefitType":"function","unlockLevel":2,"status":"active","trialEnabled":true,"resourceId":0,"resourceType":"","executionScope":"room","autoEquip":false,"sortOrder":10,"metadataJson":"{}","presentation":{"description":"当前房间进房动效","previewItems":[{"previewId":"entry-main","mediaType":"animation","animationUrl":"https://cdn.example/entry.svga","compositionType":"user_avatar_center","sortOrder":10}]}}]}]}`
|
||||||
recorder := serveVIPConfigRequest(t, wallet, http.MethodPut, "/admin/activity/vip-levels", body)
|
recorder := serveVIPConfigRequest(t, wallet, http.MethodPut, "/admin/activity/vip-levels", body)
|
||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
t.Fatalf("update levels status = %d, body=%s", recorder.Code, recorder.Body.String())
|
t.Fatalf("update levels status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||||
@ -118,6 +126,10 @@ func TestUpdateLevelsForwardsExplicitBenefitsAndReturnsProgram(t *testing.T) {
|
|||||||
if level.GetRequiredRechargeCoinAmount() != 0 || len(level.GetBenefits()) != 1 || level.GetBenefits()[0].GetBenefitCode() != "room_entry_notice" {
|
if level.GetRequiredRechargeCoinAmount() != 0 || len(level.GetBenefits()) != 1 || level.GetBenefits()[0].GetBenefitCode() != "room_entry_notice" {
|
||||||
t.Fatalf("explicit level benefits mismatch: %+v", level)
|
t.Fatalf("explicit level benefits mismatch: %+v", level)
|
||||||
}
|
}
|
||||||
|
presentation := level.GetBenefits()[0].GetPresentation()
|
||||||
|
if presentation.GetDescription() != "当前房间进房动效" || len(presentation.GetPreviewItems()) != 1 || presentation.GetPreviewItems()[0].GetCompositionType() != "user_avatar_center" {
|
||||||
|
t.Fatalf("structured benefit presentation mismatch: %+v", presentation)
|
||||||
|
}
|
||||||
if !strings.Contains(recorder.Body.String(), `"programConfig":{"appCode":"fami","programType":"tiered_privilege_v1","levelCount":9`) {
|
if !strings.Contains(recorder.Body.String(), `"programConfig":{"appCode":"fami","programType":"tiered_privilege_v1","levelCount":9`) {
|
||||||
t.Fatalf("level response must include program config: %s", recorder.Body.String())
|
t.Fatalf("level response must include program config: %s", recorder.Body.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -184,6 +184,9 @@ func TestTriggerVIPOnlineNoticeDenialDoesNotReadProfileOrBroadcast(t *testing.T)
|
|||||||
if recorder.Code != http.StatusForbidden || profileClient.lastGet != nil || broadcastClient.lastGlobal != nil {
|
if recorder.Code != http.StatusForbidden || profileClient.lastGet != nil || broadcastClient.lastGlobal != nil {
|
||||||
t.Fatalf("denied request leaked into downstream calls: status=%d profile=%+v broadcast=%+v body=%s", recorder.Code, profileClient.lastGet, broadcastClient.lastGlobal, recorder.Body.String())
|
t.Fatalf("denied request leaked into downstream calls: status=%d profile=%+v broadcast=%+v body=%s", recorder.Code, profileClient.lastGet, broadcastClient.lastGlobal, recorder.Body.String())
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), `"code":"VIP_BENEFIT_REQUIRED"`) {
|
||||||
|
t.Fatalf("denied notice must expose stable VIP code: %s", recorder.Body.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTriggerVIPOnlineNoticeUserSettingDenialDoesNotReadProfileOrBroadcast(t *testing.T) {
|
func TestTriggerVIPOnlineNoticeUserSettingDenialDoesNotReadProfileOrBroadcast(t *testing.T) {
|
||||||
@ -214,12 +217,16 @@ func TestVIPSettingsHTTPContractPreservesExplicitFalseAndAuthScope(t *testing.T)
|
|||||||
UserSettings: &walletv1.VipUserSettings{
|
UserSettings: &walletv1.VipUserSettings{
|
||||||
AppCode: "fami", UserId: 42, RoomEntryNoticeEnabled: true,
|
AppCode: "fami", UserId: 42, RoomEntryNoticeEnabled: true,
|
||||||
OnlineGlobalNoticeEnabled: false, UpdatedAtMs: 1_799_999_999_000,
|
OnlineGlobalNoticeEnabled: false, UpdatedAtMs: 1_799_999_999_000,
|
||||||
|
HideProfileDataEnabled: true, AnonymousProfileVisitEnabled: false,
|
||||||
|
LeaderboardInvisibleEnabled: true,
|
||||||
},
|
},
|
||||||
}},
|
}},
|
||||||
updateVIPSettingsResp: &walletv1.UpdateMyVipSettingsResponse{
|
updateVIPSettingsResp: &walletv1.UpdateMyVipSettingsResponse{
|
||||||
Settings: &walletv1.VipUserSettings{
|
Settings: &walletv1.VipUserSettings{
|
||||||
AppCode: "fami", UserId: 42, RoomEntryNoticeEnabled: false,
|
AppCode: "fami", UserId: 42, RoomEntryNoticeEnabled: false,
|
||||||
OnlineGlobalNoticeEnabled: false, UpdatedAtMs: 1_800_000_000_100,
|
OnlineGlobalNoticeEnabled: false, UpdatedAtMs: 1_800_000_000_100,
|
||||||
|
HideProfileDataEnabled: false, AnonymousProfileVisitEnabled: false,
|
||||||
|
LeaderboardInvisibleEnabled: true,
|
||||||
},
|
},
|
||||||
ServerTimeMs: 1_800_000_000_200,
|
ServerTimeMs: 1_800_000_000_200,
|
||||||
},
|
},
|
||||||
@ -244,6 +251,9 @@ func TestVIPSettingsHTTPContractPreservesExplicitFalseAndAuthScope(t *testing.T)
|
|||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
RoomEntryNoticeEnabled bool `json:"room_entry_notice_enabled"`
|
RoomEntryNoticeEnabled bool `json:"room_entry_notice_enabled"`
|
||||||
OnlineGlobalNoticeEnabled bool `json:"online_global_notice_enabled"`
|
OnlineGlobalNoticeEnabled bool `json:"online_global_notice_enabled"`
|
||||||
|
HideProfileDataEnabled bool `json:"hide_profile_data_enabled"`
|
||||||
|
AnonymousProfileVisitEnabled bool `json:"anonymous_profile_visit_enabled"`
|
||||||
|
LeaderboardInvisibleEnabled bool `json:"leaderboard_invisible_enabled"`
|
||||||
} `json:"settings"`
|
} `json:"settings"`
|
||||||
EvaluatedAtMS int64 `json:"evaluated_at_ms"`
|
EvaluatedAtMS int64 `json:"evaluated_at_ms"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
@ -251,11 +261,11 @@ func TestVIPSettingsHTTPContractPreservesExplicitFalseAndAuthScope(t *testing.T)
|
|||||||
if err := json.NewDecoder(getRecorder.Body).Decode(&getEnvelope); err != nil {
|
if err := json.NewDecoder(getRecorder.Body).Decode(&getEnvelope); err != nil {
|
||||||
t.Fatalf("decode GET settings response failed: %v", err)
|
t.Fatalf("decode GET settings response failed: %v", err)
|
||||||
}
|
}
|
||||||
if getEnvelope.Data.Settings.AppCode != "fami" || getEnvelope.Data.Settings.UserID != 42 || !getEnvelope.Data.Settings.RoomEntryNoticeEnabled || getEnvelope.Data.Settings.OnlineGlobalNoticeEnabled || getEnvelope.Data.EvaluatedAtMS != 1_800_000_000_000 {
|
if getEnvelope.Data.Settings.AppCode != "fami" || getEnvelope.Data.Settings.UserID != 42 || !getEnvelope.Data.Settings.RoomEntryNoticeEnabled || getEnvelope.Data.Settings.OnlineGlobalNoticeEnabled || !getEnvelope.Data.Settings.HideProfileDataEnabled || getEnvelope.Data.Settings.AnonymousProfileVisitEnabled || !getEnvelope.Data.Settings.LeaderboardInvisibleEnabled || getEnvelope.Data.EvaluatedAtMS != 1_800_000_000_000 {
|
||||||
t.Fatalf("GET settings response mismatch: %+v", getEnvelope.Data)
|
t.Fatalf("GET settings response mismatch: %+v", getEnvelope.Data)
|
||||||
}
|
}
|
||||||
|
|
||||||
patchRequest := httptest.NewRequest(http.MethodPatch, "/api/v1/vip/settings", bytes.NewReader([]byte(`{"room_entry_notice_enabled":false}`)))
|
patchRequest := httptest.NewRequest(http.MethodPatch, "/api/v1/vip/settings", bytes.NewReader([]byte(`{"room_entry_notice_enabled":false,"hide_profile_data_enabled":false}`)))
|
||||||
patchRequest.Header.Set("Authorization", token)
|
patchRequest.Header.Set("Authorization", token)
|
||||||
patchRecorder := httptest.NewRecorder()
|
patchRecorder := httptest.NewRecorder()
|
||||||
router.ServeHTTP(patchRecorder, patchRequest)
|
router.ServeHTTP(patchRecorder, patchRequest)
|
||||||
@ -263,7 +273,7 @@ func TestVIPSettingsHTTPContractPreservesExplicitFalseAndAuthScope(t *testing.T)
|
|||||||
t.Fatalf("PATCH settings status mismatch: got=%d body=%s", patchRecorder.Code, patchRecorder.Body.String())
|
t.Fatalf("PATCH settings status mismatch: got=%d body=%s", patchRecorder.Code, patchRecorder.Body.String())
|
||||||
}
|
}
|
||||||
patch := walletClient.lastUpdateVIPSettings
|
patch := walletClient.lastUpdateVIPSettings
|
||||||
if patch == nil || patch.GetAppCode() != "fami" || patch.GetUserId() != 42 || patch.RoomEntryNoticeEnabled == nil || patch.GetRoomEntryNoticeEnabled() || patch.OnlineGlobalNoticeEnabled != nil || patch.GetRequestId() != patchRecorder.Header().Get("X-Request-ID") {
|
if patch == nil || patch.GetAppCode() != "fami" || patch.GetUserId() != 42 || patch.RoomEntryNoticeEnabled == nil || patch.GetRoomEntryNoticeEnabled() || patch.HideProfileDataEnabled == nil || patch.GetHideProfileDataEnabled() || patch.OnlineGlobalNoticeEnabled != nil || patch.AnonymousProfileVisitEnabled != nil || patch.LeaderboardInvisibleEnabled != nil || patch.GetRequestId() != patchRecorder.Header().Get("X-Request-ID") {
|
||||||
t.Fatalf("PATCH settings lost explicit false or auth scope: %+v", patch)
|
t.Fatalf("PATCH settings lost explicit false or auth scope: %+v", patch)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -505,7 +515,13 @@ func TestListVIPPackagesAddsProgramStateAndLevelBenefits(t *testing.T) {
|
|||||||
CurrentVip: &walletv1.UserVip{UserId: 42, Level: 2, Active: true},
|
CurrentVip: &walletv1.UserVip{UserId: 42, Level: 2, Active: true},
|
||||||
Packages: []*walletv1.VipLevel{{
|
Packages: []*walletv1.VipLevel{{
|
||||||
Level: 3, Name: "VIP3", PriceCoin: 300, DurationMs: 2_592_000_000, CanPurchase: true, ConfigVersion: 7,
|
Level: 3, Name: "VIP3", PriceCoin: 300, DurationMs: 2_592_000_000, CanPurchase: true, ConfigVersion: 7,
|
||||||
Benefits: []*walletv1.VipBenefit{{BenefitCode: "voice_wave", Name: "语音波纹", ExecutionScope: "room", TrialEnabled: true}},
|
Benefits: []*walletv1.VipBenefit{{
|
||||||
|
BenefitCode: "voice_wave", Name: "语音波纹", ExecutionScope: "room", TrialEnabled: true,
|
||||||
|
Presentation: &walletv1.VipBenefitPresentation{
|
||||||
|
Description: "动态声波纹预览",
|
||||||
|
PreviewItems: []*walletv1.VipBenefitPreviewItem{{PreviewId: "wave-main", MediaType: "animation", AnimationUrl: "https://cdn.example/wave.svga", CompositionType: "user_avatar_waveform"}},
|
||||||
|
},
|
||||||
|
}},
|
||||||
}},
|
}},
|
||||||
State: &walletv1.VipState{EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 2, Active: true}, EffectiveSource: "paid", ProgramConfig: config},
|
State: &walletv1.VipState{EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 2, Active: true}, EffectiveSource: "paid", ProgramConfig: config},
|
||||||
ProgramConfig: config,
|
ProgramConfig: config,
|
||||||
@ -538,6 +554,12 @@ func TestListVIPPackagesAddsProgramStateAndLevelBenefits(t *testing.T) {
|
|||||||
ConfigVersion int64 `json:"config_version"`
|
ConfigVersion int64 `json:"config_version"`
|
||||||
Benefits []struct {
|
Benefits []struct {
|
||||||
BenefitCode string `json:"benefit_code"`
|
BenefitCode string `json:"benefit_code"`
|
||||||
|
Presentation struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
PreviewItems []struct {
|
||||||
|
CompositionType string `json:"composition_type"`
|
||||||
|
} `json:"preview_items"`
|
||||||
|
} `json:"presentation"`
|
||||||
} `json:"benefits"`
|
} `json:"benefits"`
|
||||||
} `json:"packages"`
|
} `json:"packages"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
@ -551,13 +573,17 @@ func TestListVIPPackagesAddsProgramStateAndLevelBenefits(t *testing.T) {
|
|||||||
if envelope.Data.Packages[0].ConfigVersion != 7 || len(envelope.Data.Packages[0].Benefits) != 1 || envelope.Data.Packages[0].Benefits[0].BenefitCode != "voice_wave" {
|
if envelope.Data.Packages[0].ConfigVersion != 7 || len(envelope.Data.Packages[0].Benefits) != 1 || envelope.Data.Packages[0].Benefits[0].BenefitCode != "voice_wave" {
|
||||||
t.Fatalf("package benefits mismatch: %+v", envelope.Data.Packages)
|
t.Fatalf("package benefits mismatch: %+v", envelope.Data.Packages)
|
||||||
}
|
}
|
||||||
|
if envelope.Data.Packages[0].Benefits[0].Presentation.Description != "动态声波纹预览" || len(envelope.Data.Packages[0].Benefits[0].Presentation.PreviewItems) != 1 || envelope.Data.Packages[0].Benefits[0].Presentation.PreviewItems[0].CompositionType != "user_avatar_waveform" {
|
||||||
|
t.Fatalf("structured presentation mismatch: %+v", envelope.Data.Packages[0].Benefits[0].Presentation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPurchaseVIPKeepsLegacyResultAndAddsState(t *testing.T) {
|
func TestPurchaseVIPKeepsLegacyResultAndAddsState(t *testing.T) {
|
||||||
config := &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", UpgradeExpiryPolicy: "replace_from_now", ConfigVersion: 8}
|
config := &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", UpgradeExpiryPolicy: "replace_from_now", ConfigVersion: 8}
|
||||||
walletClient := &fakeWalletClient{purchaseVipResp: &walletv1.PurchaseVipResponse{
|
walletClient := &fakeWalletClient{purchaseVipResp: &walletv1.PurchaseVipResponse{
|
||||||
OrderId: "vip-order-1", TransactionId: "vip-tx-1", Vip: &walletv1.UserVip{UserId: 42, Level: 4, Active: true, ExpiresAtMs: 4_000}, CoinSpent: 400, CoinBalanceAfter: 600,
|
OrderId: "vip-order-1", TransactionId: "vip-tx-1", Vip: &walletv1.UserVip{UserId: 42, Level: 4, Active: true, ExpiresAtMs: 4_000}, CoinSpent: 400, CoinBalanceAfter: 600,
|
||||||
State: &walletv1.VipState{PaidVip: &walletv1.UserVip{UserId: 42, Level: 4, Active: true}, EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 4, Active: true}, EffectiveSource: "paid", ProgramConfig: config},
|
CoinBalance: &walletv1.AssetBalance{AssetType: "COIN", AvailableAmount: 600, FrozenAmount: 0, Version: 18},
|
||||||
|
State: &walletv1.VipState{PaidVip: &walletv1.UserVip{UserId: 42, Level: 4, Active: true}, EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 4, Active: true}, EffectiveSource: "paid", ProgramConfig: config, UserSettings: &walletv1.VipUserSettings{AppCode: "fami", UserId: 42, RoomEntryNoticeEnabled: true, OnlineGlobalNoticeEnabled: true, HideProfileDataEnabled: true, AnonymousProfileVisitEnabled: true, LeaderboardInvisibleEnabled: true}},
|
||||||
}}
|
}}
|
||||||
router := newVIPTestRouter(walletClient)
|
router := newVIPTestRouter(walletClient)
|
||||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/purchase", bytes.NewReader([]byte(`{"command_id":"vip-buy-4","level":4}`)))
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/purchase", bytes.NewReader([]byte(`{"command_id":"vip-buy-4","level":4}`)))
|
||||||
@ -569,6 +595,7 @@ func TestPurchaseVIPKeepsLegacyResultAndAddsState(t *testing.T) {
|
|||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||||
}
|
}
|
||||||
|
assertCompleteVIPStateJSON(t, recorder.Body.Bytes())
|
||||||
if req := walletClient.lastPurchaseVip; req == nil || req.GetCommandId() != "vip-buy-4" || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetLevel() != 4 {
|
if req := walletClient.lastPurchaseVip; req == nil || req.GetCommandId() != "vip-buy-4" || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetLevel() != 4 {
|
||||||
t.Fatalf("PurchaseVip request mismatch: %+v", req)
|
t.Fatalf("PurchaseVip request mismatch: %+v", req)
|
||||||
}
|
}
|
||||||
@ -585,12 +612,16 @@ func TestPurchaseVIPKeepsLegacyResultAndAddsState(t *testing.T) {
|
|||||||
State struct {
|
State struct {
|
||||||
EffectiveSource string `json:"effective_source"`
|
EffectiveSource string `json:"effective_source"`
|
||||||
} `json:"state"`
|
} `json:"state"`
|
||||||
|
CoinBalance struct {
|
||||||
|
AvailableAmount int64 `json:"available_amount"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
} `json:"coin_balance"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||||||
t.Fatalf("decode response failed: %v", err)
|
t.Fatalf("decode response failed: %v", err)
|
||||||
}
|
}
|
||||||
if envelope.Data.OrderID != "vip-order-1" || envelope.Data.TransactionID != "vip-tx-1" || envelope.Data.VIP.Level != 4 || envelope.Data.ProgramConfig.UpgradeExpiryPolicy != "replace_from_now" || envelope.Data.State.EffectiveSource != "paid" {
|
if envelope.Data.OrderID != "vip-order-1" || envelope.Data.TransactionID != "vip-tx-1" || envelope.Data.VIP.Level != 4 || envelope.Data.ProgramConfig.UpgradeExpiryPolicy != "replace_from_now" || envelope.Data.State.EffectiveSource != "paid" || envelope.Data.CoinBalance.AvailableAmount != 600 || envelope.Data.CoinBalance.Version != 18 {
|
||||||
t.Fatalf("purchase response mismatch: %+v", envelope.Data)
|
t.Fatalf("purchase response mismatch: %+v", envelope.Data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -612,6 +643,7 @@ func TestEquipVIPTrialCardUsesAuthenticatedEntitlement(t *testing.T) {
|
|||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||||
}
|
}
|
||||||
|
assertCompleteVIPStateJSON(t, recorder.Body.Bytes())
|
||||||
if req := walletClient.lastTrialEquip; req == nil || req.GetRequestId() != recorder.Header().Get("X-Request-ID") || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetEntitlementId() != "ent-card-9" {
|
if req := walletClient.lastTrialEquip; req == nil || req.GetRequestId() != recorder.Header().Get("X-Request-ID") || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetEntitlementId() != "ent-card-9" {
|
||||||
t.Fatalf("EquipVipTrialCard request mismatch: %+v", req)
|
t.Fatalf("EquipVipTrialCard request mismatch: %+v", req)
|
||||||
}
|
}
|
||||||
@ -651,6 +683,7 @@ func TestUnequipVIPTrialCardRestoresPaidState(t *testing.T) {
|
|||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||||
}
|
}
|
||||||
|
assertCompleteVIPStateJSON(t, recorder.Body.Bytes())
|
||||||
if req := walletClient.lastTrialUnequip; req == nil || req.GetRequestId() != recorder.Header().Get("X-Request-ID") || req.GetAppCode() != "fami" || req.GetUserId() != 42 {
|
if req := walletClient.lastTrialUnequip; req == nil || req.GetRequestId() != recorder.Header().Get("X-Request-ID") || req.GetAppCode() != "fami" || req.GetUserId() != 42 {
|
||||||
t.Fatalf("UnequipVipTrialCard request mismatch: %+v", req)
|
t.Fatalf("UnequipVipTrialCard request mismatch: %+v", req)
|
||||||
}
|
}
|
||||||
@ -672,3 +705,22 @@ func TestUnequipVIPTrialCardRestoresPaidState(t *testing.T) {
|
|||||||
t.Fatalf("unequip response mismatch: %+v", envelope.Data)
|
t.Fatalf("unequip response mismatch: %+v", envelope.Data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// assertCompleteVIPStateJSON 锁定所有 VIP 写接口的 state 与 /vip/me.state 同构;即使值为空,
|
||||||
|
// 字段也必须存在,Flutter 才能用响应原子替换本地状态而不是做不安全的局部合并。
|
||||||
|
func assertCompleteVIPStateJSON(t *testing.T, body []byte) {
|
||||||
|
t.Helper()
|
||||||
|
var envelope struct {
|
||||||
|
Data struct {
|
||||||
|
State map[string]json.RawMessage `json:"state"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||||
|
t.Fatalf("decode complete state envelope: %v", err)
|
||||||
|
}
|
||||||
|
for _, field := range []string{"paid_vip", "equipped_trial_card", "effective_vip", "effective_source", "effective_benefits", "evaluated_at_ms", "program_config", "user_settings"} {
|
||||||
|
if _, exists := envelope.Data.State[field]; !exists {
|
||||||
|
t.Fatalf("VIP state missing %s: %s", field, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -229,6 +229,31 @@ type vipBenefitData struct {
|
|||||||
MetadataJSON string `json:"metadata_json"`
|
MetadataJSON string `json:"metadata_json"`
|
||||||
CreatedAtMS int64 `json:"created_at_ms"`
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
|
Presentation *vipBenefitPresentationData `json:"presentation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type vipBenefitPresentationData struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
PreviewItems []vipBenefitPreviewItemData `json:"preview_items"`
|
||||||
|
NumericReward *vipBenefitNumericRewardData `json:"numeric_reward"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type vipBenefitPreviewItemData struct {
|
||||||
|
PreviewID string `json:"preview_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
AssetURL string `json:"asset_url"`
|
||||||
|
PreviewURL string `json:"preview_url"`
|
||||||
|
AnimationURL string `json:"animation_url"`
|
||||||
|
CompositionType string `json:"composition_type"`
|
||||||
|
SortOrder int32 `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type vipBenefitNumericRewardData struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Period string `json:"period"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type vipTrialCardData struct {
|
type vipTrialCardData struct {
|
||||||
@ -266,6 +291,9 @@ type vipUserSettingsData struct {
|
|||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
RoomEntryNoticeEnabled bool `json:"room_entry_notice_enabled"`
|
RoomEntryNoticeEnabled bool `json:"room_entry_notice_enabled"`
|
||||||
OnlineGlobalNoticeEnabled bool `json:"online_global_notice_enabled"`
|
OnlineGlobalNoticeEnabled bool `json:"online_global_notice_enabled"`
|
||||||
|
HideProfileDataEnabled bool `json:"hide_profile_data_enabled"`
|
||||||
|
AnonymousProfileVisitEnabled bool `json:"anonymous_profile_visit_enabled"`
|
||||||
|
LeaderboardInvisibleEnabled bool `json:"leaderboard_invisible_enabled"`
|
||||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
userv1 "hyapp.local/api/proto/user/v1"
|
userv1 "hyapp.local/api/proto/user/v1"
|
||||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
"hyapp/services/gateway-service/internal/auth"
|
"hyapp/services/gateway-service/internal/auth"
|
||||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||||
)
|
)
|
||||||
@ -131,6 +132,7 @@ func (h *Handler) purchaseVIP(writer http.ResponseWriter, request *http.Request)
|
|||||||
"vip": vipFromProto(resp.GetVip()),
|
"vip": vipFromProto(resp.GetVip()),
|
||||||
"coin_spent": resp.GetCoinSpent(),
|
"coin_spent": resp.GetCoinSpent(),
|
||||||
"coin_balance_after": resp.GetCoinBalanceAfter(),
|
"coin_balance_after": resp.GetCoinBalanceAfter(),
|
||||||
|
"coin_balance": balanceFromProto(resp.GetCoinBalance()),
|
||||||
"reward_items": vipRewardItemsFromProto(resp.GetRewardItems()),
|
"reward_items": vipRewardItemsFromProto(resp.GetRewardItems()),
|
||||||
"program_config": vipProgramConfigFromProto(resp.GetState().GetProgramConfig()),
|
"program_config": vipProgramConfigFromProto(resp.GetState().GetProgramConfig()),
|
||||||
"state": vipStateFromProto(resp.GetState()),
|
"state": vipStateFromProto(resp.GetState()),
|
||||||
@ -230,7 +232,8 @@ func (h *Handler) triggerVIPOnlineNotice(writer http.ResponseWriter, request *ht
|
|||||||
if !benefitResp.GetAllowed() {
|
if !benefitResp.GetAllowed() {
|
||||||
// 客户端展示态可能滞后,执行前必须重新查 effective benefit;体验卡排除、过期和后台停用
|
// 客户端展示态可能滞后,执行前必须重新查 effective benefit;体验卡排除、过期和后台停用
|
||||||
// 都以 wallet-service 此刻的判定为准,不能仅比较 VIP9 等级。
|
// 都以 wallet-service 此刻的判定为准,不能仅比较 VIP9 等级。
|
||||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
statusCode, code, message := httpkit.MapReasonToHTTP(xerr.VIPBenefitRequired)
|
||||||
|
httpkit.WriteError(writer, request, statusCode, code, message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
equippedResp, err := h.walletClient.BatchGetUserEquippedResources(ctx, &walletv1.BatchGetUserEquippedResourcesRequest{
|
equippedResp, err := h.walletClient.BatchGetUserEquippedResources(ctx, &walletv1.BatchGetUserEquippedResourcesRequest{
|
||||||
@ -434,11 +437,38 @@ func vipBenefitsFromProto(items []*walletv1.VipBenefit) []vipBenefitData {
|
|||||||
MetadataJSON: item.GetMetadataJson(),
|
MetadataJSON: item.GetMetadataJson(),
|
||||||
CreatedAtMS: item.GetCreatedAtMs(),
|
CreatedAtMS: item.GetCreatedAtMs(),
|
||||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||||
|
Presentation: vipBenefitPresentationFromProto(item.GetPresentation()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func vipBenefitPresentationFromProto(item *walletv1.VipBenefitPresentation) *vipBenefitPresentationData {
|
||||||
|
if item == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := &vipBenefitPresentationData{
|
||||||
|
Description: item.GetDescription(),
|
||||||
|
PreviewItems: make([]vipBenefitPreviewItemData, 0, len(item.GetPreviewItems())),
|
||||||
|
}
|
||||||
|
for _, preview := range item.GetPreviewItems() {
|
||||||
|
if preview == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.PreviewItems = append(result.PreviewItems, vipBenefitPreviewItemData{
|
||||||
|
PreviewID: preview.GetPreviewId(), Title: preview.GetTitle(), MediaType: preview.GetMediaType(),
|
||||||
|
AssetURL: preview.GetAssetUrl(), PreviewURL: preview.GetPreviewUrl(), AnimationURL: preview.GetAnimationUrl(),
|
||||||
|
CompositionType: preview.GetCompositionType(), SortOrder: preview.GetSortOrder(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if reward := item.GetNumericReward(); reward != nil {
|
||||||
|
result.NumericReward = &vipBenefitNumericRewardData{
|
||||||
|
Label: reward.GetLabel(), Value: reward.GetValue(), Unit: reward.GetUnit(), Period: reward.GetPeriod(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func vipTrialCardFromProto(card *walletv1.VipTrialCard) *vipTrialCardData {
|
func vipTrialCardFromProto(card *walletv1.VipTrialCard) *vipTrialCardData {
|
||||||
if card == nil {
|
if card == nil {
|
||||||
return nil
|
return nil
|
||||||
@ -469,6 +499,8 @@ func vipStateFromProto(state *walletv1.VipState) vipStateData {
|
|||||||
EffectiveBenefits: []vipBenefitData{},
|
EffectiveBenefits: []vipBenefitData{},
|
||||||
UserSettings: vipUserSettingsData{
|
UserSettings: vipUserSettingsData{
|
||||||
RoomEntryNoticeEnabled: true, OnlineGlobalNoticeEnabled: true,
|
RoomEntryNoticeEnabled: true, OnlineGlobalNoticeEnabled: true,
|
||||||
|
HideProfileDataEnabled: true, AnonymousProfileVisitEnabled: true,
|
||||||
|
LeaderboardInvisibleEnabled: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -487,12 +519,19 @@ func vipStateFromProto(state *walletv1.VipState) vipStateData {
|
|||||||
func vipUserSettingsFromProto(settings *walletv1.VipUserSettings) vipUserSettingsData {
|
func vipUserSettingsFromProto(settings *walletv1.VipUserSettings) vipUserSettingsData {
|
||||||
if settings == nil {
|
if settings == nil {
|
||||||
// wallet 滚动升级期间旧实例没有 tag 8;与 wallet owner 的缺行语义一致,按默认开启兼容。
|
// wallet 滚动升级期间旧实例没有 tag 8;与 wallet owner 的缺行语义一致,按默认开启兼容。
|
||||||
return vipUserSettingsData{RoomEntryNoticeEnabled: true, OnlineGlobalNoticeEnabled: true}
|
return vipUserSettingsData{
|
||||||
|
RoomEntryNoticeEnabled: true, OnlineGlobalNoticeEnabled: true,
|
||||||
|
HideProfileDataEnabled: true, AnonymousProfileVisitEnabled: true,
|
||||||
|
LeaderboardInvisibleEnabled: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return vipUserSettingsData{
|
return vipUserSettingsData{
|
||||||
AppCode: settings.GetAppCode(), UserID: settings.GetUserId(),
|
AppCode: settings.GetAppCode(), UserID: settings.GetUserId(),
|
||||||
RoomEntryNoticeEnabled: settings.GetRoomEntryNoticeEnabled(),
|
RoomEntryNoticeEnabled: settings.GetRoomEntryNoticeEnabled(),
|
||||||
OnlineGlobalNoticeEnabled: settings.GetOnlineGlobalNoticeEnabled(),
|
OnlineGlobalNoticeEnabled: settings.GetOnlineGlobalNoticeEnabled(),
|
||||||
|
HideProfileDataEnabled: settings.GetHideProfileDataEnabled(),
|
||||||
|
AnonymousProfileVisitEnabled: settings.GetAnonymousProfileVisitEnabled(),
|
||||||
|
LeaderboardInvisibleEnabled: settings.GetLeaderboardInvisibleEnabled(),
|
||||||
UpdatedAtMS: settings.GetUpdatedAtMs(),
|
UpdatedAtMS: settings.GetUpdatedAtMs(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,9 @@ type vipSettingsPatchBody struct {
|
|||||||
// 指针保留 PATCH 的三态:未提交、显式 true、显式 false。
|
// 指针保留 PATCH 的三态:未提交、显式 true、显式 false。
|
||||||
RoomEntryNoticeEnabled *bool `json:"room_entry_notice_enabled"`
|
RoomEntryNoticeEnabled *bool `json:"room_entry_notice_enabled"`
|
||||||
OnlineGlobalNoticeEnabled *bool `json:"online_global_notice_enabled"`
|
OnlineGlobalNoticeEnabled *bool `json:"online_global_notice_enabled"`
|
||||||
|
HideProfileDataEnabled *bool `json:"hide_profile_data_enabled"`
|
||||||
|
AnonymousProfileVisitEnabled *bool `json:"anonymous_profile_visit_enabled"`
|
||||||
|
LeaderboardInvisibleEnabled *bool `json:"leaderboard_invisible_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleVIPSettings 在同一路径分发 GET/PATCH,避免向当前 ServeMux 重复注册相同 pattern。
|
// handleVIPSettings 在同一路径分发 GET/PATCH,避免向当前 ServeMux 重复注册相同 pattern。
|
||||||
@ -70,7 +73,9 @@ func (h *Handler) patchVIPSettings(writer http.ResponseWriter, request *http.Req
|
|||||||
if !httpkit.Decode(writer, request, &body) {
|
if !httpkit.Decode(writer, request, &body) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.RoomEntryNoticeEnabled == nil && body.OnlineGlobalNoticeEnabled == nil {
|
if body.RoomEntryNoticeEnabled == nil && body.OnlineGlobalNoticeEnabled == nil &&
|
||||||
|
body.HideProfileDataEnabled == nil && body.AnonymousProfileVisitEnabled == nil &&
|
||||||
|
body.LeaderboardInvisibleEnabled == nil {
|
||||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -79,6 +84,9 @@ func (h *Handler) patchVIPSettings(writer http.ResponseWriter, request *http.Req
|
|||||||
RequestId: httpkit.RequestIDFromContext(ctx), AppCode: appcode.FromContext(ctx),
|
RequestId: httpkit.RequestIDFromContext(ctx), AppCode: appcode.FromContext(ctx),
|
||||||
UserId: auth.UserIDFromContext(ctx), RoomEntryNoticeEnabled: body.RoomEntryNoticeEnabled,
|
UserId: auth.UserIDFromContext(ctx), RoomEntryNoticeEnabled: body.RoomEntryNoticeEnabled,
|
||||||
OnlineGlobalNoticeEnabled: body.OnlineGlobalNoticeEnabled,
|
OnlineGlobalNoticeEnabled: body.OnlineGlobalNoticeEnabled,
|
||||||
|
HideProfileDataEnabled: body.HideProfileDataEnabled,
|
||||||
|
AnonymousProfileVisitEnabled: body.AnonymousProfileVisitEnabled,
|
||||||
|
LeaderboardInvisibleEnabled: body.LeaderboardInvisibleEnabled,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpkit.WriteRPCError(writer, request, err)
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
|
|||||||
@ -118,7 +118,7 @@ func (s *Service) requireCustomRoomBackgroundBenefit(ctx context.Context, reques
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !access.hasBenefit(vipBenefitCustomRoomBackground) {
|
if !access.hasBenefit(vipBenefitCustomRoomBackground) {
|
||||||
return xerr.New(xerr.PermissionDenied, "custom room background vip benefit is required")
|
return xerr.New(xerr.VIPBenefitRequired, "custom room background vip benefit is required")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -131,7 +131,14 @@ func (s *Service) requireTargetWithoutVIPBenefit(ctx context.Context, requestID
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if access.hasBenefit(benefitCode) {
|
if access.hasBenefit(benefitCode) {
|
||||||
return xerr.New(xerr.PermissionDenied, "target vip benefit prevents this room moderation action")
|
switch normalizeVIPCode(benefitCode) {
|
||||||
|
case vipBenefitAntiKick:
|
||||||
|
return xerr.New(xerr.VIPAntiKick, "target vip benefit prevents kick")
|
||||||
|
case vipBenefitAntiMute:
|
||||||
|
return xerr.New(xerr.VIPAntiMute, "target vip benefit prevents mute")
|
||||||
|
default:
|
||||||
|
return xerr.New(xerr.VIPBenefitRequired, "target vip benefit prevents this room moderation action")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -118,7 +118,7 @@ func TestTieredVIPCustomRoomBackgroundRequiresEffectiveBenefit(t *testing.T) {
|
|||||||
Meta: vipRoomMeta(roomID, ownerID, "save-denied"),
|
Meta: vipRoomMeta(roomID, ownerID, "save-denied"),
|
||||||
RoomId: roomID,
|
RoomId: roomID,
|
||||||
ImageUrl: "https://cdn.example.com/fami-vip-denied.png",
|
ImageUrl: "https://cdn.example.com/fami-vip-denied.png",
|
||||||
}); !xerr.IsCode(err, xerr.PermissionDenied) {
|
}); !xerr.IsCode(err, xerr.VIPBenefitRequired) {
|
||||||
t.Fatalf("tiered app without custom_room_background must be denied: %v", err)
|
t.Fatalf("tiered app without custom_room_background must be denied: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,7 +136,7 @@ func TestTieredVIPCustomRoomBackgroundRequiresEffectiveBenefit(t *testing.T) {
|
|||||||
if _, err := svc.SetRoomBackground(ctx, &roomv1.SetRoomBackgroundRequest{
|
if _, err := svc.SetRoomBackground(ctx, &roomv1.SetRoomBackgroundRequest{
|
||||||
Meta: vipRoomMeta(roomID, ownerID, "set-denied"),
|
Meta: vipRoomMeta(roomID, ownerID, "set-denied"),
|
||||||
BackgroundId: second.GetBackgroundId(),
|
BackgroundId: second.GetBackgroundId(),
|
||||||
}); !xerr.IsCode(err, xerr.PermissionDenied) {
|
}); !xerr.IsCode(err, xerr.VIPBenefitRequired) {
|
||||||
t.Fatalf("expired custom_room_background benefit must block later set: %v", err)
|
t.Fatalf("expired custom_room_background benefit must block later set: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -161,13 +161,13 @@ func TestVIPModerationProtectionBlocksRoomManagersButNotSystemEvict(t *testing.T
|
|||||||
Meta: vipRoomMeta(roomID, ownerID, "mute"),
|
Meta: vipRoomMeta(roomID, ownerID, "mute"),
|
||||||
TargetUserId: targetID,
|
TargetUserId: targetID,
|
||||||
Muted: true,
|
Muted: true,
|
||||||
}); !xerr.IsCode(err, xerr.PermissionDenied) {
|
}); !xerr.IsCode(err, xerr.VIPAntiMute) {
|
||||||
t.Fatalf("anti_mute must block ordinary room owner: %v", err)
|
t.Fatalf("anti_mute must block ordinary room owner: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
|
if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
|
||||||
Meta: vipRoomMeta(roomID, ownerID, "kick"),
|
Meta: vipRoomMeta(roomID, ownerID, "kick"),
|
||||||
TargetUserId: targetID,
|
TargetUserId: targetID,
|
||||||
}); !xerr.IsCode(err, xerr.PermissionDenied) {
|
}); !xerr.IsCode(err, xerr.VIPAntiKick) {
|
||||||
t.Fatalf("anti_kick must block ordinary room owner: %v", err)
|
t.Fatalf("anti_kick must block ordinary room owner: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1153,6 +1153,7 @@ CREATE TABLE IF NOT EXISTS resource_shop_items (
|
|||||||
resource_id BIGINT NOT NULL COMMENT '售卖资源 ID',
|
resource_id BIGINT NOT NULL COMMENT '售卖资源 ID',
|
||||||
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
||||||
duration_days INT NOT NULL COMMENT '售卖天数,只允许 1/3/7',
|
duration_days INT NOT NULL COMMENT '售卖天数,只允许 1/3/7',
|
||||||
|
coin_price BIGINT NOT NULL DEFAULT 0 COMMENT '该售卖天数的独立金币价格;未自定义时复制资源列表价格',
|
||||||
effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间,UTC epoch ms,0 表示立即生效',
|
effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间,UTC epoch ms,0 表示立即生效',
|
||||||
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间,UTC epoch ms,0 表示长期有效',
|
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间,UTC epoch ms,0 表示长期有效',
|
||||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越小越靠前',
|
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越小越靠前',
|
||||||
@ -1160,7 +1161,7 @@ CREATE TABLE IF NOT EXISTS resource_shop_items (
|
|||||||
updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID',
|
updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID',
|
||||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
UNIQUE KEY uk_resource_shop_items_resource (app_code, resource_id),
|
UNIQUE KEY uk_resource_shop_items_resource_duration (app_code, resource_id, duration_days),
|
||||||
KEY idx_resource_shop_items_status_sort (app_code, status, sort_order),
|
KEY idx_resource_shop_items_status_sort (app_code, status, sort_order),
|
||||||
KEY idx_resource_shop_items_effective (app_code, status, effective_from_ms, effective_to_ms)
|
KEY idx_resource_shop_items_effective (app_code, status, effective_from_ms, effective_to_ms)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='道具商店售卖资源表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='道具商店售卖资源表';
|
||||||
@ -1685,6 +1686,9 @@ CREATE TABLE IF NOT EXISTS user_vip_settings (
|
|||||||
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
room_entry_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启进房通知',
|
room_entry_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启进房通知',
|
||||||
online_global_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启上线全服通知',
|
online_global_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启上线全服通知',
|
||||||
|
hide_profile_data_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启隐藏个人数据',
|
||||||
|
anonymous_profile_visit_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启匿名访问主页',
|
||||||
|
leaderboard_invisible_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启榜单隐身',
|
||||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
PRIMARY KEY (app_code, user_id)
|
PRIMARY KEY (app_code, user_id)
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
-- 道具商店从“每个资源一个动态价格”迁移为“每个资源可有 1/3/7 天独立定价规格”。
|
||||||
|
-- 先增加带默认值的列,保证滚动发布期间旧代码仍可写入;随后把历史行回填为上线前实际展示/扣费价,
|
||||||
|
-- 因而迁移不会把既有 3/7 天商品突然降为资源列表原价。配置表数据量很小,但执行前仍应检查长事务,
|
||||||
|
-- 避免 ALTER TABLE 等待 metadata lock。
|
||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE hyapp_wallet;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_shop_items' AND COLUMN_NAME = 'coin_price') = 0,
|
||||||
|
'ALTER TABLE resource_shop_items ADD COLUMN coin_price BIGINT NOT NULL DEFAULT 0 COMMENT ''该售卖天数的独立金币价格;未自定义时复制资源列表价格'' AFTER duration_days',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 旧实现按 resources.coin_price * duration_days 实时计价。乘法上界保护只处理异常脏数据;正常 1/3/7
|
||||||
|
-- 天配置会完整保留迁移前价格,之后资源目录调价不再隐式影响已上架规格。
|
||||||
|
UPDATE resource_shop_items AS si
|
||||||
|
JOIN resources AS r ON r.app_code = si.app_code AND r.resource_id = si.resource_id
|
||||||
|
SET si.coin_price = CASE
|
||||||
|
WHEN si.duration_days > 0 AND r.coin_price <= 9223372036854775807 DIV si.duration_days
|
||||||
|
THEN r.coin_price * si.duration_days
|
||||||
|
ELSE r.coin_price
|
||||||
|
END
|
||||||
|
WHERE si.coin_price = 0;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_shop_items' AND INDEX_NAME = 'uk_resource_shop_items_resource_duration') = 0,
|
||||||
|
'ALTER TABLE resource_shop_items ADD UNIQUE INDEX uk_resource_shop_items_resource_duration (app_code, resource_id, duration_days)',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_shop_items' AND INDEX_NAME = 'uk_resource_shop_items_resource') > 0,
|
||||||
|
'ALTER TABLE resource_shop_items DROP INDEX uk_resource_shop_items_resource',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@ -128,6 +128,36 @@ type VipBenefit struct {
|
|||||||
MetadataJSON string
|
MetadataJSON string
|
||||||
CreatedAtMS int64
|
CreatedAtMS int64
|
||||||
UpdatedAtMS int64
|
UpdatedAtMS int64
|
||||||
|
Presentation *VipBenefitPresentation
|
||||||
|
}
|
||||||
|
|
||||||
|
// VipBenefitPresentation 是 App 可直接渲染的权益表现契约。它从 metadata_json.presentation
|
||||||
|
// 投影而来,避免 Flutter 按 benefit_code 维护一份隐形 JSON 解析规则。
|
||||||
|
type VipBenefitPresentation struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
PreviewItems []VipBenefitPreviewItem `json:"preview_items"`
|
||||||
|
NumericReward *VipBenefitNumericReward `json:"numeric_reward,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VipBenefitPreviewItem 描述一个静态或动态预览。CompositionType 明确头像合成方式,
|
||||||
|
// 头像框与声波纹无需再由客户端根据权益编码猜测布局。
|
||||||
|
type VipBenefitPreviewItem struct {
|
||||||
|
PreviewID string `json:"preview_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
AssetURL string `json:"asset_url"`
|
||||||
|
PreviewURL string `json:"preview_url"`
|
||||||
|
AnimationURL string `json:"animation_url"`
|
||||||
|
CompositionType string `json:"composition_type"`
|
||||||
|
SortOrder int32 `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VipBenefitNumericReward 只负责数值展示;真实发放和结算仍由对应 owner service 执行。
|
||||||
|
type VipBenefitNumericReward struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Period string `json:"period"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// VipRewardItem 是 VIP 资源组权益的轻量展示投影。
|
// VipRewardItem 是 VIP 资源组权益的轻量展示投影。
|
||||||
@ -205,24 +235,35 @@ type VipUserSettings struct {
|
|||||||
UserID int64
|
UserID int64
|
||||||
RoomEntryNoticeEnabled bool
|
RoomEntryNoticeEnabled bool
|
||||||
OnlineGlobalNoticeEnabled bool
|
OnlineGlobalNoticeEnabled bool
|
||||||
|
HideProfileDataEnabled bool
|
||||||
|
AnonymousProfileVisitEnabled bool
|
||||||
|
LeaderboardInvisibleEnabled bool
|
||||||
UpdatedAtMS int64
|
UpdatedAtMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultVipUserSettings 返回尚未物化时的产品默认:两项通知均开启,UpdatedAtMS=0。
|
// DefaultVipUserSettings 返回尚未物化时的产品默认:五项能力均开启,UpdatedAtMS=0。
|
||||||
func DefaultVipUserSettings(appCode string, userID int64) VipUserSettings {
|
func DefaultVipUserSettings(appCode string, userID int64) VipUserSettings {
|
||||||
return VipUserSettings{
|
return VipUserSettings{
|
||||||
AppCode: appCode, UserID: userID,
|
AppCode: appCode, UserID: userID,
|
||||||
RoomEntryNoticeEnabled: true, OnlineGlobalNoticeEnabled: true,
|
RoomEntryNoticeEnabled: true, OnlineGlobalNoticeEnabled: true,
|
||||||
|
HideProfileDataEnabled: true, AnonymousProfileVisitEnabled: true,
|
||||||
|
LeaderboardInvisibleEnabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllowsBenefitSetting 只处理用户可关闭的两项权益;其它权益不受偏好表影响。
|
// AllowsBenefitSetting 只处理用户可关闭的五项权益;其它权益不受偏好表影响。
|
||||||
func (settings VipUserSettings) AllowsBenefitSetting(benefitCode string) bool {
|
func (settings VipUserSettings) AllowsBenefitSetting(benefitCode string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(benefitCode)) {
|
switch strings.ToLower(strings.TrimSpace(benefitCode)) {
|
||||||
case VipBenefitCodeRoomEntryNotice:
|
case VipBenefitCodeRoomEntryNotice:
|
||||||
return settings.RoomEntryNoticeEnabled
|
return settings.RoomEntryNoticeEnabled
|
||||||
case VipBenefitCodeOnlineGlobalNotice:
|
case VipBenefitCodeOnlineGlobalNotice:
|
||||||
return settings.OnlineGlobalNoticeEnabled
|
return settings.OnlineGlobalNoticeEnabled
|
||||||
|
case VipBenefitCodeHideProfileData:
|
||||||
|
return settings.HideProfileDataEnabled
|
||||||
|
case VipBenefitCodeAnonymousProfileVisit:
|
||||||
|
return settings.AnonymousProfileVisitEnabled
|
||||||
|
case VipBenefitCodeLeaderboardInvisible:
|
||||||
|
return settings.LeaderboardInvisibleEnabled
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -256,6 +297,7 @@ type PurchaseVipReceipt struct {
|
|||||||
Vip UserVip
|
Vip UserVip
|
||||||
CoinSpent int64
|
CoinSpent int64
|
||||||
CoinBalanceAfter int64
|
CoinBalanceAfter int64
|
||||||
|
CoinBalance AssetBalance
|
||||||
RewardItems []VipRewardItem
|
RewardItems []VipRewardItem
|
||||||
State VipState
|
State VipState
|
||||||
}
|
}
|
||||||
@ -349,6 +391,9 @@ type UpdateVipUserSettingsCommand struct {
|
|||||||
UserID int64
|
UserID int64
|
||||||
RoomEntryNoticeEnabled *bool
|
RoomEntryNoticeEnabled *bool
|
||||||
OnlineGlobalNoticeEnabled *bool
|
OnlineGlobalNoticeEnabled *bool
|
||||||
|
HideProfileDataEnabled *bool
|
||||||
|
AnonymousProfileVisitEnabled *bool
|
||||||
|
LeaderboardInvisibleEnabled *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// VipDailyCoinRebateRun 是单 App、单 UTC 日唯一的配置快照与分页游标。
|
// VipDailyCoinRebateRun 是单 App、单 UTC 日唯一的配置快照与分页游标。
|
||||||
|
|||||||
@ -0,0 +1,145 @@
|
|||||||
|
package ledger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
vipPresentationDescriptionMaxRunes = 1000
|
||||||
|
vipPresentationPreviewMaxCount = 20
|
||||||
|
vipPresentationTextMaxRunes = 128
|
||||||
|
vipPresentationURLMaxRunes = 2048
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseVipBenefitPresentation 只解析 metadata_json.presentation 这一段稳定展示契约。
|
||||||
|
// metadata_json 的其它业务字段(例如 coin_amount、message)由对应 owner 保留处理。
|
||||||
|
func ParseVipBenefitPresentation(metadataJSON string) (*VipBenefitPresentation, error) {
|
||||||
|
metadataJSON = strings.TrimSpace(metadataJSON)
|
||||||
|
if metadataJSON == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var metadata struct {
|
||||||
|
Presentation *VipBenefitPresentation `json:"presentation"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if metadata.Presentation == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := ValidateVipBenefitPresentation(*metadata.Presentation); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return metadata.Presentation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeVipBenefitPresentation 将后台结构化输入归一到现有 JSON 列,避免为纯展示字段增加双写事实。
|
||||||
|
// presentation 为空对象表示显式清除;nil 表示沿用 metadata_json 中已有值。
|
||||||
|
func MergeVipBenefitPresentation(metadataJSON string, presentation *VipBenefitPresentation) (string, error) {
|
||||||
|
metadataJSON = strings.TrimSpace(metadataJSON)
|
||||||
|
if presentation == nil {
|
||||||
|
if metadataJSON == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if !json.Valid([]byte(metadataJSON)) {
|
||||||
|
return "", errors.New("metadata_json is invalid")
|
||||||
|
}
|
||||||
|
return metadataJSON, nil
|
||||||
|
}
|
||||||
|
if err := ValidateVipBenefitPresentation(*presentation); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := make(map[string]json.RawMessage)
|
||||||
|
if metadataJSON != "" {
|
||||||
|
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil || metadata == nil {
|
||||||
|
return "", errors.New("metadata_json must be an object")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vipBenefitPresentationIsZero(*presentation) {
|
||||||
|
delete(metadata, "presentation")
|
||||||
|
} else {
|
||||||
|
encoded, err := json.Marshal(presentation)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
metadata["presentation"] = encoded
|
||||||
|
}
|
||||||
|
if len(metadata) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(metadata)
|
||||||
|
return string(encoded), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateVipBenefitPresentation 锁定 Flutter 可直接渲染的边界,防止后台把任意 JSON
|
||||||
|
// 重新变成客户端按 benefit_code 猜类型的隐式协议。
|
||||||
|
func ValidateVipBenefitPresentation(presentation VipBenefitPresentation) error {
|
||||||
|
presentation.Description = strings.TrimSpace(presentation.Description)
|
||||||
|
if utf8.RuneCountInString(presentation.Description) > vipPresentationDescriptionMaxRunes {
|
||||||
|
return errors.New("presentation description is too long")
|
||||||
|
}
|
||||||
|
if len(presentation.PreviewItems) > vipPresentationPreviewMaxCount {
|
||||||
|
return errors.New("presentation has too many preview items")
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(presentation.PreviewItems))
|
||||||
|
for index, item := range presentation.PreviewItems {
|
||||||
|
item.PreviewID = strings.TrimSpace(item.PreviewID)
|
||||||
|
item.Title = strings.TrimSpace(item.Title)
|
||||||
|
item.MediaType = strings.ToLower(strings.TrimSpace(item.MediaType))
|
||||||
|
item.CompositionType = strings.ToLower(strings.TrimSpace(item.CompositionType))
|
||||||
|
if item.PreviewID == "" || utf8.RuneCountInString(item.PreviewID) > vipPresentationTextMaxRunes {
|
||||||
|
return fmt.Errorf("preview_items[%d].preview_id is invalid", index)
|
||||||
|
}
|
||||||
|
if _, exists := seen[item.PreviewID]; exists {
|
||||||
|
return fmt.Errorf("preview_items[%d].preview_id is duplicated", index)
|
||||||
|
}
|
||||||
|
seen[item.PreviewID] = struct{}{}
|
||||||
|
if utf8.RuneCountInString(item.Title) > vipPresentationTextMaxRunes {
|
||||||
|
return fmt.Errorf("preview_items[%d].title is too long", index)
|
||||||
|
}
|
||||||
|
if item.MediaType != "image" && item.MediaType != "animation" {
|
||||||
|
return fmt.Errorf("preview_items[%d].media_type is invalid", index)
|
||||||
|
}
|
||||||
|
switch item.CompositionType {
|
||||||
|
case "none", "user_avatar_center", "user_avatar_waveform":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("preview_items[%d].composition_type is invalid", index)
|
||||||
|
}
|
||||||
|
if exceedsRuneLimit(item.AssetURL, vipPresentationURLMaxRunes) || exceedsRuneLimit(item.PreviewURL, vipPresentationURLMaxRunes) || exceedsRuneLimit(item.AnimationURL, vipPresentationURLMaxRunes) {
|
||||||
|
return fmt.Errorf("preview_items[%d] url is too long", index)
|
||||||
|
}
|
||||||
|
if item.MediaType == "animation" && strings.TrimSpace(item.AnimationURL) == "" {
|
||||||
|
return fmt.Errorf("preview_items[%d].animation_url is required", index)
|
||||||
|
}
|
||||||
|
if item.MediaType == "image" && strings.TrimSpace(item.PreviewURL) == "" && strings.TrimSpace(item.AssetURL) == "" {
|
||||||
|
return fmt.Errorf("preview_items[%d] image url is required", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reward := presentation.NumericReward; reward != nil {
|
||||||
|
reward.Label = strings.TrimSpace(reward.Label)
|
||||||
|
reward.Unit = strings.TrimSpace(reward.Unit)
|
||||||
|
reward.Period = strings.ToLower(strings.TrimSpace(reward.Period))
|
||||||
|
if reward.Value <= 0 || reward.Label == "" || reward.Unit == "" || utf8.RuneCountInString(reward.Label) > vipPresentationTextMaxRunes || utf8.RuneCountInString(reward.Unit) > vipPresentationTextMaxRunes {
|
||||||
|
return errors.New("numeric_reward is invalid")
|
||||||
|
}
|
||||||
|
switch reward.Period {
|
||||||
|
case "once", "daily", "monthly":
|
||||||
|
default:
|
||||||
|
return errors.New("numeric_reward.period is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func vipBenefitPresentationIsZero(presentation VipBenefitPresentation) bool {
|
||||||
|
return strings.TrimSpace(presentation.Description) == "" && len(presentation.PreviewItems) == 0 && presentation.NumericReward == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func exceedsRuneLimit(value string, limit int) bool {
|
||||||
|
return utf8.RuneCountInString(strings.TrimSpace(value)) > limit
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package ledger
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestVipBenefitPresentationRoundTripPreservesOtherMetadata(t *testing.T) {
|
||||||
|
presentation := &VipBenefitPresentation{
|
||||||
|
Description: "头像框会自动合成当前用户头像",
|
||||||
|
PreviewItems: []VipBenefitPreviewItem{{
|
||||||
|
PreviewID: "frame-main", MediaType: "animation", AnimationURL: "https://cdn.example/frame.svga",
|
||||||
|
PreviewURL: "https://cdn.example/frame.png", CompositionType: "user_avatar_center", SortOrder: 10,
|
||||||
|
}},
|
||||||
|
NumericReward: &VipBenefitNumericReward{Label: "每日返现", Value: 100, Unit: "金币", Period: "daily"},
|
||||||
|
}
|
||||||
|
metadata, err := MergeVipBenefitPresentation(`{"coin_amount":100}`, presentation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("merge presentation failed: %v", err)
|
||||||
|
}
|
||||||
|
parsed, err := ParseVipBenefitPresentation(metadata)
|
||||||
|
if err != nil || parsed == nil {
|
||||||
|
t.Fatalf("parse presentation failed: parsed=%+v err=%v", parsed, err)
|
||||||
|
}
|
||||||
|
if parsed.Description != presentation.Description || len(parsed.PreviewItems) != 1 || parsed.PreviewItems[0].CompositionType != "user_avatar_center" || parsed.NumericReward == nil || parsed.NumericReward.Value != 100 {
|
||||||
|
t.Fatalf("presentation round trip mismatch: %+v", parsed)
|
||||||
|
}
|
||||||
|
if metadata == "" || !containsJSONFragment(metadata, `"coin_amount":100`) {
|
||||||
|
t.Fatalf("merge must preserve owner metadata: %s", metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVipBenefitPresentationRejectsImplicitClientRules(t *testing.T) {
|
||||||
|
invalid := VipBenefitPresentation{PreviewItems: []VipBenefitPreviewItem{{
|
||||||
|
PreviewID: "wave", MediaType: "animation", CompositionType: "guess_from_benefit_code",
|
||||||
|
}}}
|
||||||
|
if err := ValidateVipBenefitPresentation(invalid); err == nil {
|
||||||
|
t.Fatal("unknown composition type must be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsJSONFragment(value string, fragment string) bool {
|
||||||
|
for index := 0; index+len(fragment) <= len(value); index++ {
|
||||||
|
if value[index:index+len(fragment)] == fragment {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@ -30,6 +30,15 @@ type ListUserResourcesQuery struct {
|
|||||||
ActiveOnly bool
|
ActiveOnly bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RevokeUserResourceCommand struct {
|
||||||
|
RequestID string
|
||||||
|
AppCode string
|
||||||
|
UserID int64
|
||||||
|
EntitlementID string
|
||||||
|
Reason string
|
||||||
|
OperatorUserID int64
|
||||||
|
}
|
||||||
|
|
||||||
type EquipUserResourceCommand struct {
|
type EquipUserResourceCommand struct {
|
||||||
// RequestID 在通用背包命中 VIP 体验卡时作为专用状态机的 outbox 幂等事件键;
|
// RequestID 在通用背包命中 VIP 体验卡时作为专用状态机的 outbox 幂等事件键;
|
||||||
// 普通装扮仍不把 request_id 当业务幂等键。
|
// 普通装扮仍不把 request_id 当业务幂等键。
|
||||||
|
|||||||
@ -47,6 +47,8 @@ type ResourceShopItemInput struct {
|
|||||||
ResourceID int64
|
ResourceID int64
|
||||||
Status string
|
Status string
|
||||||
DurationDays int32
|
DurationDays int32
|
||||||
|
// CoinPrice 新建为 0 时使用资源列表价,更新已有规格为 0 时保留当前售价;落库后各规格价格互相独立。
|
||||||
|
CoinPrice int64
|
||||||
EffectiveFromMS int64
|
EffectiveFromMS int64
|
||||||
EffectiveToMS int64
|
EffectiveToMS int64
|
||||||
SortOrder int32
|
SortOrder int32
|
||||||
|
|||||||
@ -215,6 +215,7 @@ type ResourceGrantStore interface {
|
|||||||
// ResourceEquipmentStore 管理用户资源权益和穿戴状态。
|
// ResourceEquipmentStore 管理用户资源权益和穿戴状态。
|
||||||
type ResourceEquipmentStore interface {
|
type ResourceEquipmentStore interface {
|
||||||
ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error)
|
ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error)
|
||||||
|
RevokeUserResource(ctx context.Context, command resourcedomain.RevokeUserResourceCommand) (resourcedomain.UserResourceEntitlement, error)
|
||||||
EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error)
|
EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error)
|
||||||
UnequipUserResource(ctx context.Context, command resourcedomain.UnequipUserResourceCommand) (resourcedomain.UnequipUserResourceResult, error)
|
UnequipUserResource(ctx context.Context, command resourcedomain.UnequipUserResourceCommand) (resourcedomain.UnequipUserResourceResult, error)
|
||||||
BatchGetUserEquippedResources(ctx context.Context, query resourcedomain.BatchGetUserEquippedResourcesQuery) ([]resourcedomain.UserEquippedResources, error)
|
BatchGetUserEquippedResources(ctx context.Context, query resourcedomain.BatchGetUserEquippedResourcesQuery) ([]resourcedomain.UserEquippedResources, error)
|
||||||
|
|||||||
@ -280,6 +280,15 @@ func (s *Service) ListUserResources(ctx context.Context, query resourcedomain.Li
|
|||||||
return s.repository.ListUserResources(ctx, query)
|
return s.repository.ListUserResources(ctx, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) RevokeUserResource(ctx context.Context, command resourcedomain.RevokeUserResourceCommand) (resourcedomain.UserResourceEntitlement, error) {
|
||||||
|
if s.repository == nil {
|
||||||
|
return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||||
|
}
|
||||||
|
command.AppCode = appcode.Normalize(command.AppCode)
|
||||||
|
ctx = appcode.WithContext(ctx, command.AppCode)
|
||||||
|
return s.repository.RevokeUserResource(ctx, command)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error) {
|
func (s *Service) EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error) {
|
||||||
if s.repository == nil {
|
if s.repository == nil {
|
||||||
return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||||
|
|||||||
@ -5491,6 +5491,132 @@ func TestRevokeResourceGroupGrantReversesWalletCreditAndEntitlement(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRevokeUserResourceKeepsResourceGroupSiblings proves the admin detail action removes only the
|
||||||
|
// selected entitlement. Reusing grant-level revoke here would also remove every sibling in the group.
|
||||||
|
func TestRevokeUserResourceKeepsResourceGroupSiblings(t *testing.T) {
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
svc := walletservice.New(repository)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
hostBadge, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||||||
|
ResourceCode: "host_badge_precise_revoke",
|
||||||
|
ResourceType: resourcedomain.TypeBadge,
|
||||||
|
Name: "Host Badge",
|
||||||
|
Status: resourcedomain.StatusActive,
|
||||||
|
Grantable: true,
|
||||||
|
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
|
||||||
|
UsageScopes: []string{"profile"},
|
||||||
|
OperatorUserID: 90001,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create host badge failed: %v", err)
|
||||||
|
}
|
||||||
|
avatarFrame, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||||||
|
ResourceCode: "host_frame_precise_revoke",
|
||||||
|
ResourceType: resourcedomain.TypeAvatarFrame,
|
||||||
|
Name: "Host Frame",
|
||||||
|
Status: resourcedomain.StatusActive,
|
||||||
|
Grantable: true,
|
||||||
|
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
|
||||||
|
UsageScopes: []string{"profile"},
|
||||||
|
OperatorUserID: 90001,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create host frame failed: %v", err)
|
||||||
|
}
|
||||||
|
group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
|
||||||
|
GroupCode: "host_assets_precise_revoke",
|
||||||
|
Name: "Host Assets",
|
||||||
|
Status: resourcedomain.StatusActive,
|
||||||
|
Items: []resourcedomain.ResourceGroupItemInput{
|
||||||
|
{ResourceID: hostBadge.ResourceID, Quantity: 1, SortOrder: 1},
|
||||||
|
{ResourceID: avatarFrame.ResourceID, Quantity: 1, SortOrder: 2},
|
||||||
|
},
|
||||||
|
OperatorUserID: 90001,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create host resource group failed: %v", err)
|
||||||
|
}
|
||||||
|
grant, err := svc.GrantResourceGroup(ctx, resourcedomain.GrantResourceGroupCommand{
|
||||||
|
CommandID: "cmd-grant-host-assets-precise-revoke",
|
||||||
|
TargetUserID: 42106,
|
||||||
|
GroupID: group.GroupID,
|
||||||
|
Reason: "test precise user resource revoke",
|
||||||
|
OperatorUserID: 90001,
|
||||||
|
GrantSource: resourcedomain.GrantSourceAdmin,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("grant host resource group failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var badgeEntitlementID, frameEntitlementID string
|
||||||
|
for _, item := range grant.Items {
|
||||||
|
switch item.ResourceID {
|
||||||
|
case hostBadge.ResourceID:
|
||||||
|
badgeEntitlementID = item.EntitlementID
|
||||||
|
case avatarFrame.ResourceID:
|
||||||
|
frameEntitlementID = item.EntitlementID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if badgeEntitlementID == "" || frameEntitlementID == "" {
|
||||||
|
t.Fatalf("grant entitlements are incomplete: %+v", grant.Items)
|
||||||
|
}
|
||||||
|
for resourceID, entitlementID := range map[int64]string{
|
||||||
|
hostBadge.ResourceID: badgeEntitlementID,
|
||||||
|
avatarFrame.ResourceID: frameEntitlementID,
|
||||||
|
} {
|
||||||
|
if _, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
|
||||||
|
UserID: 42106, ResourceID: resourceID, EntitlementID: entitlementID,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("equip resource %d failed: %v", resourceID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revoked, err := svc.RevokeUserResource(ctx, resourcedomain.RevokeUserResourceCommand{
|
||||||
|
RequestID: "request-revoke-host-badge",
|
||||||
|
UserID: 42106,
|
||||||
|
EntitlementID: badgeEntitlementID,
|
||||||
|
Reason: "remove host badge",
|
||||||
|
OperatorUserID: 90002,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RevokeUserResource failed: %v", err)
|
||||||
|
}
|
||||||
|
if revoked.Status != resourcedomain.GrantStatusRevoked || revoked.RemainingQuantity != 0 || revoked.Equipped {
|
||||||
|
t.Fatalf("revoked entitlement state mismatch: %+v", revoked)
|
||||||
|
}
|
||||||
|
active, err := svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{UserID: 42106, ActiveOnly: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list active resources failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(active) != 1 || active[0].EntitlementID != frameEntitlementID {
|
||||||
|
t.Fatalf("resource-group sibling must remain active: %+v", active)
|
||||||
|
}
|
||||||
|
if got := repository.CountRows("resource_grants", "grant_id = ? AND status = ?", grant.GrantID, resourcedomain.GrantStatusDone); got != 1 {
|
||||||
|
t.Fatalf("precise revoke must not revoke the source group grant, got %d", got)
|
||||||
|
}
|
||||||
|
if got := repository.CountRows("user_resource_equipment", "user_id = ? AND entitlement_id = ?", int64(42106), badgeEntitlementID); got != 0 {
|
||||||
|
t.Fatalf("revoked badge equipment row must be removed, got %d", got)
|
||||||
|
}
|
||||||
|
if got := repository.CountRows("user_resource_equipment", "user_id = ? AND entitlement_id = ?", int64(42106), frameEntitlementID); got != 1 {
|
||||||
|
t.Fatalf("sibling frame equipment row must remain, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
outboxCount := repository.CountRows("wallet_outbox", "event_type = ? AND user_id = ?", "UserResourceChanged", int64(42106))
|
||||||
|
if _, err := svc.RevokeUserResource(ctx, resourcedomain.RevokeUserResourceCommand{
|
||||||
|
RequestID: "request-revoke-host-badge-retry",
|
||||||
|
UserID: 42106,
|
||||||
|
EntitlementID: badgeEntitlementID,
|
||||||
|
Reason: "remove host badge retry",
|
||||||
|
OperatorUserID: 90003,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("idempotent RevokeUserResource retry failed: %v", err)
|
||||||
|
}
|
||||||
|
if got := repository.CountRows("wallet_outbox", "event_type = ? AND user_id = ?", "UserResourceChanged", int64(42106)); got != outboxCount {
|
||||||
|
t.Fatalf("idempotent retry must not duplicate outbox facts: got %d want %d", got, outboxCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestRevokeResourceGroupGrantClampsWalletDebitWhenBalanceInsufficient 验证撤回金币时余额不足也不能扣成负数。
|
// TestRevokeResourceGroupGrantClampsWalletDebitWhenBalanceInsufficient 验证撤回金币时余额不足也不能扣成负数。
|
||||||
func TestRevokeResourceGroupGrantClampsWalletDebitWhenBalanceInsufficient(t *testing.T) {
|
func TestRevokeResourceGroupGrantClampsWalletDebitWhenBalanceInsufficient(t *testing.T) {
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
@ -5855,27 +5981,58 @@ func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) {
|
|||||||
t.Fatalf("create shop resource failed: %v", err)
|
t.Fatalf("create shop resource failed: %v", err)
|
||||||
}
|
}
|
||||||
shopItems, err := svc.UpsertResourceShopItems(ctx, resourcedomain.ResourceShopItemsCommand{
|
shopItems, err := svc.UpsertResourceShopItems(ctx, resourcedomain.ResourceShopItemsCommand{
|
||||||
Items: []resourcedomain.ResourceShopItemInput{{
|
Items: []resourcedomain.ResourceShopItemInput{
|
||||||
|
{
|
||||||
|
ResourceID: resource.ResourceID,
|
||||||
|
Status: resourcedomain.StatusActive,
|
||||||
|
DurationDays: resourcedomain.ShopDurationOneDay,
|
||||||
|
SortOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
ResourceID: resource.ResourceID,
|
ResourceID: resource.ResourceID,
|
||||||
Status: resourcedomain.StatusActive,
|
Status: resourcedomain.StatusActive,
|
||||||
DurationDays: resourcedomain.ShopDurationThreeDays,
|
DurationDays: resourcedomain.ShopDurationThreeDays,
|
||||||
SortOrder: 1,
|
CoinPrice: 250,
|
||||||
}},
|
SortOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ResourceID: resource.ResourceID,
|
||||||
|
Status: resourcedomain.StatusActive,
|
||||||
|
DurationDays: resourcedomain.ShopDurationSevenDays,
|
||||||
|
CoinPrice: 600,
|
||||||
|
SortOrder: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
OperatorUserID: 90001,
|
OperatorUserID: 90001,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("upsert shop item failed: %v", err)
|
t.Fatalf("upsert shop item failed: %v", err)
|
||||||
}
|
}
|
||||||
|
if len(shopItems) != 3 {
|
||||||
|
t.Fatalf("one resource should keep three sale durations, got %+v", shopItems)
|
||||||
|
}
|
||||||
|
var purchaseItem resourcedomain.ResourceShopItem
|
||||||
|
for _, item := range shopItems {
|
||||||
|
if item.DurationDays == resourcedomain.ShopDurationOneDay && item.CoinPrice != resource.CoinPrice {
|
||||||
|
t.Fatalf("omitted sale price should copy resource price: %+v", item)
|
||||||
|
}
|
||||||
|
if item.DurationDays == resourcedomain.ShopDurationThreeDays {
|
||||||
|
purchaseItem = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if purchaseItem.ShopItemID == 0 || purchaseItem.CoinPrice != 250 {
|
||||||
|
t.Fatalf("custom three-day sale price mismatch: %+v", purchaseItem)
|
||||||
|
}
|
||||||
|
|
||||||
receipt, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
|
receipt, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
|
||||||
CommandID: "cmd-shop-badge",
|
CommandID: "cmd-shop-badge",
|
||||||
UserID: 53001,
|
UserID: 53001,
|
||||||
ShopItemID: shopItems[0].ShopItemID,
|
ShopItemID: purchaseItem.ShopItemID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("PurchaseResourceShopItem failed: %v", err)
|
t.Fatalf("PurchaseResourceShopItem failed: %v", err)
|
||||||
}
|
}
|
||||||
if receipt.OrderID == "" || receipt.TransactionID == "" || receipt.GrantID == "" || receipt.CoinSpent != 300 || receipt.Balance.AvailableAmount != 700 {
|
if receipt.OrderID == "" || receipt.TransactionID == "" || receipt.GrantID == "" || receipt.CoinSpent != 250 || receipt.Balance.AvailableAmount != 750 {
|
||||||
t.Fatalf("resource shop purchase receipt mismatch: %+v", receipt)
|
t.Fatalf("resource shop purchase receipt mismatch: %+v", receipt)
|
||||||
}
|
}
|
||||||
if receipt.Resource.EntitlementID == "" || receipt.Resource.ResourceID != resource.ResourceID || receipt.Resource.ExpiresAtMS <= time.Now().UnixMilli() {
|
if receipt.Resource.EntitlementID == "" || receipt.Resource.ResourceID != resource.ResourceID || receipt.Resource.ExpiresAtMS <= time.Now().UnixMilli() {
|
||||||
@ -5884,16 +6041,72 @@ func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) {
|
|||||||
if !receipt.Resource.Equipped {
|
if !receipt.Resource.Equipped {
|
||||||
t.Fatalf("resource shop badge purchase should auto equip receipt resource: %+v", receipt.Resource)
|
t.Fatalf("resource shop badge purchase should auto equip receipt resource: %+v", receipt.Resource)
|
||||||
}
|
}
|
||||||
|
if _, err := svc.UpsertResourceShopItems(ctx, resourcedomain.ResourceShopItemsCommand{
|
||||||
|
Items: []resourcedomain.ResourceShopItemInput{{
|
||||||
|
ShopItemID: purchaseItem.ShopItemID,
|
||||||
|
ResourceID: resource.ResourceID,
|
||||||
|
Status: resourcedomain.StatusActive,
|
||||||
|
DurationDays: resourcedomain.ShopDurationThreeDays,
|
||||||
|
CoinPrice: 275,
|
||||||
|
SortOrder: 2,
|
||||||
|
}},
|
||||||
|
OperatorUserID: 90001,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update shop item price failed: %v", err)
|
||||||
|
}
|
||||||
|
preservedItems, err := svc.UpsertResourceShopItems(ctx, resourcedomain.ResourceShopItemsCommand{
|
||||||
|
Items: []resourcedomain.ResourceShopItemInput{{
|
||||||
|
ShopItemID: purchaseItem.ShopItemID,
|
||||||
|
ResourceID: resource.ResourceID,
|
||||||
|
Status: resourcedomain.StatusActive,
|
||||||
|
DurationDays: resourcedomain.ShopDurationThreeDays,
|
||||||
|
SortOrder: 4,
|
||||||
|
}},
|
||||||
|
OperatorUserID: 90002,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update shop item without price failed: %v", err)
|
||||||
|
}
|
||||||
|
var preservedPrice int64
|
||||||
|
for _, item := range preservedItems {
|
||||||
|
if item.ShopItemID == purchaseItem.ShopItemID {
|
||||||
|
preservedPrice = item.CoinPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if preservedPrice != 275 {
|
||||||
|
t.Fatalf("omitted price on an existing item must preserve the custom price, got %d", preservedPrice)
|
||||||
|
}
|
||||||
|
naturalKeyItems, err := svc.UpsertResourceShopItems(ctx, resourcedomain.ResourceShopItemsCommand{
|
||||||
|
Items: []resourcedomain.ResourceShopItemInput{{
|
||||||
|
ResourceID: resource.ResourceID,
|
||||||
|
Status: resourcedomain.StatusActive,
|
||||||
|
DurationDays: resourcedomain.ShopDurationThreeDays,
|
||||||
|
SortOrder: 5,
|
||||||
|
}},
|
||||||
|
OperatorUserID: 90003,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update shop item by natural key without price failed: %v", err)
|
||||||
|
}
|
||||||
|
preservedPrice = 0
|
||||||
|
for _, item := range naturalKeyItems {
|
||||||
|
if item.ShopItemID == purchaseItem.ShopItemID {
|
||||||
|
preservedPrice = item.CoinPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if preservedPrice != 275 {
|
||||||
|
t.Fatalf("natural-key update without price must preserve the custom price, got %d", preservedPrice)
|
||||||
|
}
|
||||||
|
|
||||||
again, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
|
again, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
|
||||||
CommandID: "cmd-shop-badge",
|
CommandID: "cmd-shop-badge",
|
||||||
UserID: 53001,
|
UserID: 53001,
|
||||||
ShopItemID: shopItems[0].ShopItemID,
|
ShopItemID: purchaseItem.ShopItemID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("PurchaseResourceShopItem retry failed: %v", err)
|
t.Fatalf("PurchaseResourceShopItem retry failed: %v", err)
|
||||||
}
|
}
|
||||||
if again.OrderID != receipt.OrderID || again.TransactionID != receipt.TransactionID || again.Balance.AvailableAmount != receipt.Balance.AvailableAmount {
|
if again.OrderID != receipt.OrderID || again.TransactionID != receipt.TransactionID || again.Balance.AvailableAmount != receipt.Balance.AvailableAmount || again.ShopItem.CoinPrice != receipt.CoinSpent {
|
||||||
t.Fatalf("resource shop idempotency mismatch: first=%+v again=%+v", receipt, again)
|
t.Fatalf("resource shop idempotency mismatch: first=%+v again=%+v", receipt, again)
|
||||||
}
|
}
|
||||||
if got := repository.CountRows("resource_shop_purchase_orders", "user_id = ?", int64(53001)); got != 1 {
|
if got := repository.CountRows("resource_shop_purchase_orders", "user_id = ?", int64(53001)); got != 1 {
|
||||||
@ -5902,7 +6115,7 @@ func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) {
|
|||||||
if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-shop-badge", "resource_shop_purchase"); got != 1 {
|
if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-shop-badge", "resource_shop_purchase"); got != 1 {
|
||||||
t.Fatalf("resource shop purchase should write one wallet transaction, got %d", got)
|
t.Fatalf("resource shop purchase should write one wallet transaction, got %d", got)
|
||||||
}
|
}
|
||||||
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", receipt.TransactionID, int64(-300)); got != 1 {
|
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", receipt.TransactionID, int64(-250)); got != 1 {
|
||||||
t.Fatalf("resource shop purchase should write one debit entry, got %d", got)
|
t.Fatalf("resource shop purchase should write one debit entry, got %d", got)
|
||||||
}
|
}
|
||||||
if got := repository.CountRows("resource_grants", "grant_id = ?", receipt.GrantID); got != 1 {
|
if got := repository.CountRows("resource_grants", "grant_id = ?", receipt.GrantID); got != 1 {
|
||||||
@ -6600,6 +6813,9 @@ func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) {
|
|||||||
if receipt.OrderID == "" || receipt.TransactionID == "" || receipt.CoinSpent != 1000 || receipt.CoinBalanceAfter != 19000 {
|
if receipt.OrderID == "" || receipt.TransactionID == "" || receipt.CoinSpent != 1000 || receipt.CoinBalanceAfter != 19000 {
|
||||||
t.Fatalf("vip receipt mismatch: %+v", receipt)
|
t.Fatalf("vip receipt mismatch: %+v", receipt)
|
||||||
}
|
}
|
||||||
|
if receipt.CoinBalance.AssetType != ledger.AssetCoin || receipt.CoinBalance.AvailableAmount != 19000 || receipt.CoinBalance.FrozenAmount != 0 || receipt.CoinBalance.Version <= 0 {
|
||||||
|
t.Fatalf("vip purchase must return versioned coin balance: %+v", receipt.CoinBalance)
|
||||||
|
}
|
||||||
if !receipt.Vip.Active || receipt.Vip.Level != 1 || receipt.Vip.ExpiresAtMS <= receipt.Vip.StartedAtMS {
|
if !receipt.Vip.Active || receipt.Vip.Level != 1 || receipt.Vip.ExpiresAtMS <= receipt.Vip.StartedAtMS {
|
||||||
t.Fatalf("vip membership mismatch: %+v", receipt.Vip)
|
t.Fatalf("vip membership mismatch: %+v", receipt.Vip)
|
||||||
}
|
}
|
||||||
@ -6624,7 +6840,7 @@ func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("PurchaseVip retry failed: %v", err)
|
t.Fatalf("PurchaseVip retry failed: %v", err)
|
||||||
}
|
}
|
||||||
if again.OrderID != receipt.OrderID || again.CoinBalanceAfter != receipt.CoinBalanceAfter {
|
if again.OrderID != receipt.OrderID || again.CoinBalanceAfter != receipt.CoinBalanceAfter || again.CoinBalance.Version != receipt.CoinBalance.Version {
|
||||||
t.Fatalf("vip idempotency mismatch: first=%+v again=%+v", receipt, again)
|
t.Fatalf("vip idempotency mismatch: first=%+v again=%+v", receipt, again)
|
||||||
}
|
}
|
||||||
if _, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
|
if _, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
|
||||||
@ -6653,6 +6869,14 @@ func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) {
|
|||||||
if !xerr.IsCode(err, xerr.VIPDowngradeNotAllowed) {
|
if !xerr.IsCode(err, xerr.VIPDowngradeNotAllowed) {
|
||||||
t.Fatalf("expected VIP_DOWNGRADE_NOT_ALLOWED, got %v", err)
|
t.Fatalf("expected VIP_DOWNGRADE_NOT_ALLOWED, got %v", err)
|
||||||
}
|
}
|
||||||
|
_, err = svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
|
||||||
|
CommandID: "cmd-vip-insufficient",
|
||||||
|
UserID: 51002,
|
||||||
|
Level: 1,
|
||||||
|
})
|
||||||
|
if !xerr.IsCode(err, xerr.VIPInsufficientCoin) {
|
||||||
|
t.Fatalf("expected VIP_INSUFFICIENT_COIN, got %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPurchaseVipRenewalAlignsRewardEntitlementExpiry(t *testing.T) {
|
func TestPurchaseVipRenewalAlignsRewardEntitlementExpiry(t *testing.T) {
|
||||||
|
|||||||
@ -230,10 +230,12 @@ func (s *Service) UpdateAdminVipProgramConfig(ctx context.Context, command ledge
|
|||||||
return s.repository.UpdateAdminVipProgramConfig(ctx, command)
|
return s.repository.UpdateAdminVipProgramConfig(ctx, command)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateVipUserSettings 保存用户可关闭的 VIP 通知偏好。用户当前没有 VIP 也允许保存,
|
// UpdateVipUserSettings 保存用户可关闭的 VIP 展示与隐私偏好。用户当前没有 VIP 也允许保存,
|
||||||
// 但偏好不会授予权益;实际执行仍由 CheckVipBenefit 同时校验 effective benefit。
|
// 但偏好不会授予权益;实际执行仍由 CheckVipBenefit 同时校验 effective benefit。
|
||||||
func (s *Service) UpdateVipUserSettings(ctx context.Context, command ledger.UpdateVipUserSettingsCommand) (ledger.VipUserSettings, error) {
|
func (s *Service) UpdateVipUserSettings(ctx context.Context, command ledger.UpdateVipUserSettingsCommand) (ledger.VipUserSettings, error) {
|
||||||
if command.UserID <= 0 || (command.RoomEntryNoticeEnabled == nil && command.OnlineGlobalNoticeEnabled == nil) {
|
if command.UserID <= 0 || (command.RoomEntryNoticeEnabled == nil && command.OnlineGlobalNoticeEnabled == nil &&
|
||||||
|
command.HideProfileDataEnabled == nil && command.AnonymousProfileVisitEnabled == nil &&
|
||||||
|
command.LeaderboardInvisibleEnabled == nil) {
|
||||||
return ledger.VipUserSettings{}, xerr.New(xerr.InvalidArgument, "vip user settings update is incomplete")
|
return ledger.VipUserSettings{}, xerr.New(xerr.InvalidArgument, "vip user settings update is incomplete")
|
||||||
}
|
}
|
||||||
if s.repository == nil {
|
if s.repository == nil {
|
||||||
|
|||||||
@ -144,7 +144,7 @@ func TestFamiVIPReplaceAndTrialCardSwitch(t *testing.T) {
|
|||||||
if _, err := service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
|
if _, err := service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
|
||||||
AppCode: "fami", RequestID: "generic-equip-disabled", UserID: userID,
|
AppCode: "fami", RequestID: "generic-equip-disabled", UserID: userID,
|
||||||
ResourceID: card3.TrialCard.ResourceID, EntitlementID: card3.TrialCard.EntitlementID,
|
ResourceID: card3.TrialCard.ResourceID, EntitlementID: card3.TrialCard.EntitlementID,
|
||||||
}); !xerr.IsCode(err, xerr.Conflict) {
|
}); !xerr.IsCode(err, xerr.VIPProgramInactive) {
|
||||||
t.Fatalf("generic backpack equip must preserve trial_card_enabled validation, got %v", err)
|
t.Fatalf("generic backpack equip must preserve trial_card_enabled validation, got %v", err)
|
||||||
}
|
}
|
||||||
if got := repository.CountRows("user_resource_equipment", "app_code = ? AND user_id = ? AND resource_type = ?", "fami", userID, resourcedomain.TypeVIPTrialCard); got != 0 {
|
if got := repository.CountRows("user_resource_equipment", "app_code = ? AND user_id = ? AND resource_type = ?", "fami", userID, resourcedomain.TypeVIPTrialCard); got != 0 {
|
||||||
@ -270,6 +270,19 @@ func TestFamiVIPReplaceAndTrialCardSwitch(t *testing.T) {
|
|||||||
if err != nil || !paidRebate.Allowed {
|
if err != nil || !paidRebate.Allowed {
|
||||||
t.Fatalf("paid VIP6 must retain configured rebate benefit: allowed=%v err=%v result=%+v", paidRebate.Allowed, err, paidRebate)
|
t.Fatalf("paid VIP6 must retain configured rebate benefit: allowed=%v err=%v result=%+v", paidRebate.Allowed, err, paidRebate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 体验卡错误必须稳定区分“实例不存在”和“绝对时间已过期”,Flutter 不解析 message 猜分支。
|
||||||
|
if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
|
||||||
|
AppCode: "fami", RequestID: "equip-missing-trial", UserID: userID, EntitlementID: "missing-entitlement",
|
||||||
|
}); !xerr.IsCode(err, xerr.VIPTrialCardNotFound) {
|
||||||
|
t.Fatalf("missing trial card must return VIP_TRIAL_CARD_NOT_FOUND, got %v", err)
|
||||||
|
}
|
||||||
|
repository.ExpireEntitlement(card6.TrialCard.EntitlementID, time.Now().Add(-time.Minute).UnixMilli())
|
||||||
|
if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
|
||||||
|
AppCode: "fami", RequestID: "equip-expired-trial", UserID: userID, EntitlementID: card6.TrialCard.EntitlementID,
|
||||||
|
}); !xerr.IsCode(err, xerr.VIPTrialCardExpired) {
|
||||||
|
t.Fatalf("expired trial card must return VIP_TRIAL_CARD_EXPIRED, got %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVipPurchaseConcurrentReplayWithExactBalance 验证幂等守卫发生在账户/会员锁之前:
|
// TestVipPurchaseConcurrentReplayWithExactBalance 验证幂等守卫发生在账户/会员锁之前:
|
||||||
@ -309,6 +322,9 @@ func TestVipPurchaseConcurrentReplayWithExactBalance(t *testing.T) {
|
|||||||
if first.receipt.CoinBalanceAfter != 0 || second.receipt.CoinBalanceAfter != 0 {
|
if first.receipt.CoinBalanceAfter != 0 || second.receipt.CoinBalanceAfter != 0 {
|
||||||
t.Fatalf("idempotent purchase must debit exact balance once: first=%d second=%d", first.receipt.CoinBalanceAfter, second.receipt.CoinBalanceAfter)
|
t.Fatalf("idempotent purchase must debit exact balance once: first=%d second=%d", first.receipt.CoinBalanceAfter, second.receipt.CoinBalanceAfter)
|
||||||
}
|
}
|
||||||
|
if first.receipt.CoinBalance.Version <= 0 || first.receipt.CoinBalance.Version != second.receipt.CoinBalance.Version {
|
||||||
|
t.Fatalf("idempotent purchase must preserve original balance version: first=%+v second=%+v", first.receipt.CoinBalance, second.receipt.CoinBalance)
|
||||||
|
}
|
||||||
if got := repository.CountRows("vip_purchase_orders", "app_code = ? AND command_id = ?", "lalu", commandID); got != 1 {
|
if got := repository.CountRows("vip_purchase_orders", "app_code = ? AND command_id = ?", "lalu", commandID); got != 1 {
|
||||||
t.Fatalf("concurrent replay persisted %d VIP orders, want 1", got)
|
t.Fatalf("concurrent replay persisted %d VIP orders, want 1", got)
|
||||||
}
|
}
|
||||||
@ -399,7 +415,7 @@ func TestVipProgramCanCreateAndResizeAppLevels(t *testing.T) {
|
|||||||
}
|
}
|
||||||
repository.SetBalanceForApp("future_app", 880002, 1_000)
|
repository.SetBalanceForApp("future_app", 880002, 1_000)
|
||||||
_, err = service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "future_app", CommandID: "buy-hidden-level", UserID: 880002, Level: 3})
|
_, err = service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "future_app", CommandID: "buy-hidden-level", UserID: 880002, Level: 3})
|
||||||
if !xerr.IsCode(err, xerr.VIPLevelNotFound) {
|
if !xerr.IsCode(err, xerr.VIPPackageNotPurchasable) {
|
||||||
t.Fatalf("level above current level_count must be unreachable, got %v", err)
|
t.Fatalf("level above current level_count must be unreachable, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,7 +24,9 @@ func TestVipUserSettingsDefaultPartialUpdateAndAppScope(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get default VIP settings failed: %v", err)
|
t.Fatalf("get default VIP settings failed: %v", err)
|
||||||
}
|
}
|
||||||
if !defaultState.UserSettings.RoomEntryNoticeEnabled || !defaultState.UserSettings.OnlineGlobalNoticeEnabled || defaultState.UserSettings.UpdatedAtMS != 0 {
|
if !defaultState.UserSettings.RoomEntryNoticeEnabled || !defaultState.UserSettings.OnlineGlobalNoticeEnabled ||
|
||||||
|
!defaultState.UserSettings.HideProfileDataEnabled || !defaultState.UserSettings.AnonymousProfileVisitEnabled ||
|
||||||
|
!defaultState.UserSettings.LeaderboardInvisibleEnabled || defaultState.UserSettings.UpdatedAtMS != 0 {
|
||||||
t.Fatalf("missing settings row must project default enabled: %+v", defaultState.UserSettings)
|
t.Fatalf("missing settings row must project default enabled: %+v", defaultState.UserSettings)
|
||||||
}
|
}
|
||||||
if got := repository.CountRows("user_vip_settings", "app_code = ? AND user_id = ?", "fami", paidUserID); got != 0 {
|
if got := repository.CountRows("user_vip_settings", "app_code = ? AND user_id = ?", "fami", paidUserID); got != 0 {
|
||||||
@ -38,6 +40,13 @@ func TestVipUserSettingsDefaultPartialUpdateAndAppScope(t *testing.T) {
|
|||||||
if err != nil || settings.RoomEntryNoticeEnabled || !settings.OnlineGlobalNoticeEnabled || settings.UpdatedAtMS <= 0 {
|
if err != nil || settings.RoomEntryNoticeEnabled || !settings.OnlineGlobalNoticeEnabled || settings.UpdatedAtMS <= 0 {
|
||||||
t.Fatalf("first partial settings update mismatch: settings=%+v err=%v", settings, err)
|
t.Fatalf("first partial settings update mismatch: settings=%+v err=%v", settings, err)
|
||||||
}
|
}
|
||||||
|
hideProfileOff := false
|
||||||
|
settings, err = service.UpdateVipUserSettings(famiCtx, ledger.UpdateVipUserSettingsCommand{
|
||||||
|
AppCode: "fami", UserID: paidUserID, HideProfileDataEnabled: &hideProfileOff,
|
||||||
|
})
|
||||||
|
if err != nil || settings.HideProfileDataEnabled || !settings.AnonymousProfileVisitEnabled || !settings.LeaderboardInvisibleEnabled {
|
||||||
|
t.Fatalf("privacy partial update must preserve other switches: settings=%+v err=%v", settings, err)
|
||||||
|
}
|
||||||
onlineOff := false
|
onlineOff := false
|
||||||
settings, err = service.UpdateVipUserSettings(famiCtx, ledger.UpdateVipUserSettingsCommand{
|
settings, err = service.UpdateVipUserSettings(famiCtx, ledger.UpdateVipUserSettingsCommand{
|
||||||
AppCode: "fami", UserID: paidUserID, OnlineGlobalNoticeEnabled: &onlineOff,
|
AppCode: "fami", UserID: paidUserID, OnlineGlobalNoticeEnabled: &onlineOff,
|
||||||
@ -46,7 +55,9 @@ func TestVipUserSettingsDefaultPartialUpdateAndAppScope(t *testing.T) {
|
|||||||
t.Fatalf("second partial update must preserve room switch: settings=%+v err=%v", settings, err)
|
t.Fatalf("second partial update must preserve room switch: settings=%+v err=%v", settings, err)
|
||||||
}
|
}
|
||||||
laluState, err := service.GetVipState(laluCtx, paidUserID)
|
laluState, err := service.GetVipState(laluCtx, paidUserID)
|
||||||
if err != nil || !laluState.UserSettings.RoomEntryNoticeEnabled || !laluState.UserSettings.OnlineGlobalNoticeEnabled || laluState.UserSettings.UpdatedAtMS != 0 {
|
if err != nil || !laluState.UserSettings.RoomEntryNoticeEnabled || !laluState.UserSettings.OnlineGlobalNoticeEnabled ||
|
||||||
|
!laluState.UserSettings.HideProfileDataEnabled || !laluState.UserSettings.AnonymousProfileVisitEnabled ||
|
||||||
|
!laluState.UserSettings.LeaderboardInvisibleEnabled || laluState.UserSettings.UpdatedAtMS != 0 {
|
||||||
t.Fatalf("same user settings must remain App scoped: state=%+v err=%v", laluState.UserSettings, err)
|
t.Fatalf("same user settings must remain App scoped: state=%+v err=%v", laluState.UserSettings, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,6 +109,10 @@ func TestVipUserSettingsDefaultPartialUpdateAndAppScope(t *testing.T) {
|
|||||||
t.Fatalf("paid VIP user setting gate mismatch for %s: result=%+v err=%v", code, result, checkErr)
|
t.Fatalf("paid VIP user setting gate mismatch for %s: result=%+v err=%v", code, result, checkErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
hideProfileCheck, err := service.CheckVipBenefit(famiCtx, paidUserID, ledger.VipBenefitCodeHideProfileData)
|
||||||
|
if err != nil || hideProfileCheck.Allowed || hideProfileCheck.DenialReason != "user_setting_disabled" {
|
||||||
|
t.Fatalf("hide profile setting gate mismatch: result=%+v err=%v", hideProfileCheck, err)
|
||||||
|
}
|
||||||
roomOn := true
|
roomOn := true
|
||||||
settings, err = service.UpdateVipUserSettings(famiCtx, ledger.UpdateVipUserSettingsCommand{
|
settings, err = service.UpdateVipUserSettings(famiCtx, ledger.UpdateVipUserSettingsCommand{
|
||||||
AppCode: "fami", UserID: paidUserID, RoomEntryNoticeEnabled: &roomOn,
|
AppCode: "fami", UserID: paidUserID, RoomEntryNoticeEnabled: &roomOn,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
|
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||||
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
|
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
@ -63,6 +64,109 @@ func (r *Repository) ListUserResources(ctx context.Context, query resourcedomain
|
|||||||
return items, rows.Err()
|
return items, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RevokeUserResource forcibly invalidates exactly one entitlement. It deliberately does not mutate
|
||||||
|
// the source grant because a resource-group grant can contain sibling assets that must stay active.
|
||||||
|
func (r *Repository) RevokeUserResource(ctx context.Context, command resourcedomain.RevokeUserResourceCommand) (resourcedomain.UserResourceEntitlement, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
ctx = contextWithCommandApp(ctx, command.AppCode)
|
||||||
|
command.AppCode = appcode.FromContext(ctx)
|
||||||
|
command.RequestID = strings.TrimSpace(command.RequestID)
|
||||||
|
command.EntitlementID = strings.TrimSpace(command.EntitlementID)
|
||||||
|
command.Reason = strings.TrimSpace(command.Reason)
|
||||||
|
if command.UserID <= 0 || command.EntitlementID == "" || command.Reason == "" || command.OperatorUserID <= 0 {
|
||||||
|
return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.InvalidArgument, "user resource revoke command is incomplete")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return resourcedomain.UserResourceEntitlement{}, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
entitlement, err := r.getUserResourceEntitlementForUpdateTx(ctx, tx, command.EntitlementID)
|
||||||
|
if err != nil {
|
||||||
|
return resourcedomain.UserResourceEntitlement{}, err
|
||||||
|
}
|
||||||
|
if entitlement.UserID != command.UserID {
|
||||||
|
// Do not reveal that another user's entitlement exists when a mismatched user path is supplied.
|
||||||
|
return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.NotFound, "user resource entitlement not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
nowMs := time.Now().UnixMilli()
|
||||||
|
changed := entitlement.Status == resourcedomain.StatusActive || entitlement.RemainingQuantity > 0
|
||||||
|
if changed {
|
||||||
|
result, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE user_resource_entitlements
|
||||||
|
SET status = ?, remaining_quantity = 0, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND entitlement_id = ? AND user_id = ?`,
|
||||||
|
resourcedomain.GrantStatusRevoked, nowMs, command.AppCode, command.EntitlementID, command.UserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return resourcedomain.UserResourceEntitlement{}, err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return resourcedomain.UserResourceEntitlement{}, err
|
||||||
|
}
|
||||||
|
if affected != 1 {
|
||||||
|
return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.LedgerConflict, "user resource revoke state conflict")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing equipment is part of the same transaction as entitlement invalidation, so clients can
|
||||||
|
// never observe a revoked badge/frame still selected as the user's current appearance.
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
DELETE FROM user_resource_equipment
|
||||||
|
WHERE app_code = ? AND user_id = ? AND entitlement_id = ?`,
|
||||||
|
command.AppCode, command.UserID, command.EntitlementID,
|
||||||
|
); err != nil {
|
||||||
|
return resourcedomain.UserResourceEntitlement{}, err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE user_vip_trial_cards
|
||||||
|
SET status = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND user_id = ? AND entitlement_id = ? AND status = ?`,
|
||||||
|
ledger.VipTrialCardStatusRevoked, nowMs, command.AppCode, command.UserID,
|
||||||
|
command.EntitlementID, ledger.VipTrialCardStatusActive,
|
||||||
|
); err != nil {
|
||||||
|
return resourcedomain.UserResourceEntitlement{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
eventCommandID := "revoke-user-resource:" + stableHash(fmt.Sprintf("%s|%d|%s", command.AppCode, command.UserID, command.EntitlementID))
|
||||||
|
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||||
|
resourceOutboxEvent("UserResourceChanged", eventCommandID, command.UserID, entitlement.ResourceID, map[string]any{
|
||||||
|
"action": "revoke",
|
||||||
|
"app_code": command.AppCode,
|
||||||
|
"user_id": command.UserID,
|
||||||
|
"resource_id": entitlement.ResourceID,
|
||||||
|
"resource_type": entitlement.Resource.ResourceType,
|
||||||
|
"entitlement_id": command.EntitlementID,
|
||||||
|
"source_grant_id": entitlement.SourceGrantID,
|
||||||
|
"status": resourcedomain.GrantStatusRevoked,
|
||||||
|
"remaining_quantity": 0,
|
||||||
|
"revoked_by_user_id": command.OperatorUserID,
|
||||||
|
"revoke_reason": command.Reason,
|
||||||
|
"updated_at_ms": nowMs,
|
||||||
|
"event_command": eventCommandID,
|
||||||
|
}, nowMs),
|
||||||
|
}); err != nil {
|
||||||
|
return resourcedomain.UserResourceEntitlement{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revoked, err := r.getUserResourceEntitlementTx(ctx, tx, command.EntitlementID)
|
||||||
|
if err != nil {
|
||||||
|
return resourcedomain.UserResourceEntitlement{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return resourcedomain.UserResourceEntitlement{}, err
|
||||||
|
}
|
||||||
|
return revoked, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error) {
|
func (r *Repository) EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
|||||||
@ -9,11 +9,13 @@ import (
|
|||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||||
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
|
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListResourceShopItems 返回 App 道具商店和后台配置共用的售卖视图;价格始终由资源一日价乘以售卖天数派生。
|
// ListResourceShopItems 返回 App 道具商店和后台配置共用的售卖规格;价格优先取规格自己的持久值。
|
||||||
|
// 滚动发布期间旧实例仍可能写出 0,这类行必须沿用旧版“资源价 × 天数”,不能误套新建规格的默认价。
|
||||||
func (r *Repository) ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error) {
|
func (r *Repository) ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
@ -90,7 +92,8 @@ func (r *Repository) ListResourceShopPurchaseOrders(ctx context.Context, query r
|
|||||||
return items, total, rows.Err()
|
return items, total, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpsertResourceShopItems 批量添加或重配售卖资源;同一个资源在商店只保留一个当前售卖配置。
|
// UpsertResourceShopItems 批量添加或重配售卖规格;同一资源可同时配置 1/3/7 天,
|
||||||
|
// 但相同资源和相同天数只能保留一个规格,购买端继续用独立 shop_item_id 选择具体档位。
|
||||||
func (r *Repository) UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error) {
|
func (r *Repository) UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
@ -108,13 +111,29 @@ func (r *Repository) UpsertResourceShopItems(ctx context.Context, command resour
|
|||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
nowMs := time.Now().UnixMilli()
|
nowMs := time.Now().UnixMilli()
|
||||||
resourceIDs := make([]int64, 0, len(command.Items))
|
items := append([]resourcedomain.ResourceShopItemInput(nil), command.Items...)
|
||||||
seen := make(map[int64]struct{}, len(command.Items))
|
// 所有写事务统一按 resource_id 再按规格身份加锁。购买同样先锁资源再锁商品,避免改价与购买形成反向等待。
|
||||||
for _, item := range command.Items {
|
sort.SliceStable(items, func(left, right int) bool {
|
||||||
if _, exists := seen[item.ResourceID]; exists {
|
if items[left].ResourceID != items[right].ResourceID {
|
||||||
return nil, xerr.New(xerr.InvalidArgument, "resource shop contains duplicate resource")
|
return items[left].ResourceID < items[right].ResourceID
|
||||||
}
|
}
|
||||||
seen[item.ResourceID] = struct{}{}
|
if items[left].ShopItemID != items[right].ShopItemID {
|
||||||
|
return items[left].ShopItemID < items[right].ShopItemID
|
||||||
|
}
|
||||||
|
return items[left].DurationDays < items[right].DurationDays
|
||||||
|
})
|
||||||
|
resourceIDs := make([]int64, 0, len(items))
|
||||||
|
type resourceShopTierKey struct {
|
||||||
|
resourceID int64
|
||||||
|
durationDays int32
|
||||||
|
}
|
||||||
|
seen := make(map[resourceShopTierKey]struct{}, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
tierKey := resourceShopTierKey{resourceID: item.ResourceID, durationDays: item.DurationDays}
|
||||||
|
if _, exists := seen[tierKey]; exists {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "resource shop contains duplicate resource duration")
|
||||||
|
}
|
||||||
|
seen[tierKey] = struct{}{}
|
||||||
resource, err := r.getResourceForUpdate(ctx, tx, item.ResourceID)
|
resource, err := r.getResourceForUpdate(ctx, tx, item.ResourceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -122,22 +141,69 @@ func (r *Repository) UpsertResourceShopItems(ctx context.Context, command resour
|
|||||||
if err := validateResourceShopResource(resource); err != nil {
|
if err := validateResourceShopResource(resource); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if item.ShopItemID > 0 {
|
||||||
|
current, err := r.getResourceShopItemTx(ctx, tx, item.ShopItemID, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// shop_item_id 是既有规格的稳定身份,只允许在同一资源内调整天数和价格,防止误把另一资源的条目搬过来。
|
||||||
|
if current.ResourceID != item.ResourceID {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "resource shop item does not match resource")
|
||||||
|
}
|
||||||
|
// 旧管理端不会发送新增的 coin_price;更新已有规格时 0 必须保留当前售价,不能把自定义价重置为资源价。
|
||||||
|
coinPrice := item.CoinPrice
|
||||||
|
if coinPrice == 0 {
|
||||||
|
coinPrice = current.CoinPrice
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE resource_shop_items
|
||||||
|
SET status = ?, duration_days = ?, coin_price = ?, effective_from_ms = ?, effective_to_ms = ?,
|
||||||
|
sort_order = ?, updated_by_user_id = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND shop_item_id = ?`,
|
||||||
|
item.Status, item.DurationDays, coinPrice, item.EffectiveFromMS, item.EffectiveToMS,
|
||||||
|
item.SortOrder, command.OperatorUserID, nowMs, command.AppCode, item.ShopItemID,
|
||||||
|
); err != nil {
|
||||||
|
if isMySQLDuplicateError(err) {
|
||||||
|
return nil, xerr.New(xerr.Conflict, "resource shop duration already exists")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resourceIDs = append(resourceIDs, item.ResourceID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 新规格未传价格时复制资源列表价一次;落库后规格价格独立,不随资源目录调价变化。
|
||||||
|
coinPrice := item.CoinPrice
|
||||||
|
if coinPrice == 0 {
|
||||||
|
current, exists, err := r.getResourceShopItemByTierTx(ctx, tx, item.ResourceID, item.DurationDays, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
// 兼容未发送 shop_item_id 的旧调用:自然键已存在时 0 表示未提供价格,必须保留当前售价。
|
||||||
|
coinPrice = current.CoinPrice
|
||||||
|
} else {
|
||||||
|
coinPrice = resource.CoinPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO resource_shop_items (
|
INSERT INTO resource_shop_items (
|
||||||
app_code, resource_id, status, duration_days, effective_from_ms, effective_to_ms, sort_order,
|
app_code, resource_id, status, duration_days, coin_price, effective_from_ms, effective_to_ms, sort_order,
|
||||||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
status = VALUES(status),
|
status = VALUES(status),
|
||||||
duration_days = VALUES(duration_days),
|
coin_price = VALUES(coin_price),
|
||||||
effective_from_ms = VALUES(effective_from_ms),
|
effective_from_ms = VALUES(effective_from_ms),
|
||||||
effective_to_ms = VALUES(effective_to_ms),
|
effective_to_ms = VALUES(effective_to_ms),
|
||||||
sort_order = VALUES(sort_order),
|
sort_order = VALUES(sort_order),
|
||||||
updated_by_user_id = VALUES(updated_by_user_id),
|
updated_by_user_id = VALUES(updated_by_user_id),
|
||||||
updated_at_ms = VALUES(updated_at_ms)`,
|
updated_at_ms = VALUES(updated_at_ms)`,
|
||||||
command.AppCode, item.ResourceID, item.Status, item.DurationDays, item.EffectiveFromMS, item.EffectiveToMS, item.SortOrder,
|
command.AppCode, item.ResourceID, item.Status, item.DurationDays, coinPrice, item.EffectiveFromMS, item.EffectiveToMS, item.SortOrder,
|
||||||
command.OperatorUserID, command.OperatorUserID, nowMs, nowMs,
|
command.OperatorUserID, command.OperatorUserID, nowMs, nowMs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
if isMySQLDuplicateError(err) {
|
||||||
|
return nil, xerr.New(xerr.Conflict, "resource shop duration already exists")
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
resourceIDs = append(resourceIDs, item.ResourceID)
|
resourceIDs = append(resourceIDs, item.ResourceID)
|
||||||
@ -167,7 +233,7 @@ func (r *Repository) SetResourceShopItemStatus(ctx context.Context, command reso
|
|||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
item, err := r.getResourceShopItemTx(ctx, tx, command.ShopItemID, true)
|
item, err := r.lockResourceShopItemTx(ctx, tx, command.ShopItemID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resourcedomain.ResourceShopItem{}, err
|
return resourcedomain.ResourceShopItem{}, err
|
||||||
}
|
}
|
||||||
@ -221,7 +287,7 @@ func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resou
|
|||||||
}
|
}
|
||||||
|
|
||||||
nowMs := time.Now().UnixMilli()
|
nowMs := time.Now().UnixMilli()
|
||||||
item, err := r.getResourceShopItemTx(ctx, tx, command.ShopItemID, true)
|
item, err := r.lockResourceShopItemTx(ctx, tx, command.ShopItemID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||||
}
|
}
|
||||||
@ -345,6 +411,39 @@ func (r *Repository) getResourceShopItemTx(ctx context.Context, tx *sql.Tx, shop
|
|||||||
return r.getResourceShopItemWithQuerier(ctx, tx, shopItemID, lock)
|
return r.getResourceShopItemWithQuerier(ctx, tx, shopItemID, lock)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lockResourceShopItemTx 先无锁定位不可变的 resource_id,再统一按“资源 -> 商品”顺序取写锁。
|
||||||
|
// 改价、启停和购买都走这里,避免某条链路先锁商品、另一条链路先锁资源而形成循环等待。
|
||||||
|
func (r *Repository) lockResourceShopItemTx(ctx context.Context, tx *sql.Tx, shopItemID int64) (resourcedomain.ResourceShopItem, error) {
|
||||||
|
candidate, err := r.getResourceShopItemTx(ctx, tx, shopItemID, false)
|
||||||
|
if err != nil {
|
||||||
|
return resourcedomain.ResourceShopItem{}, err
|
||||||
|
}
|
||||||
|
if _, err := r.getResourceForUpdate(ctx, tx, candidate.ResourceID); err != nil {
|
||||||
|
return resourcedomain.ResourceShopItem{}, err
|
||||||
|
}
|
||||||
|
item, err := r.getResourceShopItemTx(ctx, tx, shopItemID, true)
|
||||||
|
if err != nil {
|
||||||
|
return resourcedomain.ResourceShopItem{}, err
|
||||||
|
}
|
||||||
|
// resource_id 按业务约束不可变;若被旁路修改,不能在未锁定的新资源行上继续启停、改价或扣费。
|
||||||
|
if item.ResourceID != candidate.ResourceID {
|
||||||
|
return resourcedomain.ResourceShopItem{}, xerr.New(xerr.Conflict, "resource shop item changed while locking")
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) getResourceShopItemByTierTx(ctx context.Context, tx *sql.Tx, resourceID int64, durationDays int32, lock bool) (resourcedomain.ResourceShopItem, bool, error) {
|
||||||
|
query := resourceShopItemSelectSQL() + ` WHERE si.app_code = ? AND si.resource_id = ? AND si.duration_days = ?`
|
||||||
|
if lock {
|
||||||
|
query += ` FOR UPDATE`
|
||||||
|
}
|
||||||
|
item, err := scanResourceShopItem(tx.QueryRowContext(ctx, query, appcode.FromContext(ctx), resourceID, durationDays))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return resourcedomain.ResourceShopItem{}, false, nil
|
||||||
|
}
|
||||||
|
return item, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) getResourceShopItemWithQuerier(ctx context.Context, querier sqlRowQuerier, shopItemID int64, lock bool) (resourcedomain.ResourceShopItem, error) {
|
func (r *Repository) getResourceShopItemWithQuerier(ctx context.Context, querier sqlRowQuerier, shopItemID int64, lock bool) (resourcedomain.ResourceShopItem, error) {
|
||||||
query := resourceShopItemSelectSQL() + ` WHERE si.app_code = ? AND si.shop_item_id = ?`
|
query := resourceShopItemSelectSQL() + ` WHERE si.app_code = ? AND si.shop_item_id = ?`
|
||||||
if lock {
|
if lock {
|
||||||
@ -361,15 +460,16 @@ func (r *Repository) getResourceShopItemWithQuerier(ctx context.Context, querier
|
|||||||
func (r *Repository) resourceShopPurchaseReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64) (resourcedomain.ResourceShopPurchaseReceipt, error) {
|
func (r *Repository) resourceShopPurchaseReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64) (resourcedomain.ResourceShopPurchaseReceipt, error) {
|
||||||
var orderID string
|
var orderID string
|
||||||
var shopItemID int64
|
var shopItemID int64
|
||||||
|
var durationDays int32
|
||||||
var priceCoin int64
|
var priceCoin int64
|
||||||
var grantID string
|
var grantID string
|
||||||
var entitlementID string
|
var entitlementID string
|
||||||
err := tx.QueryRowContext(ctx, `
|
err := tx.QueryRowContext(ctx, `
|
||||||
SELECT order_id, shop_item_id, price_coin, resource_grant_id, entitlement_id
|
SELECT order_id, shop_item_id, duration_days, price_coin, resource_grant_id, entitlement_id
|
||||||
FROM resource_shop_purchase_orders
|
FROM resource_shop_purchase_orders
|
||||||
WHERE app_code = ? AND wallet_transaction_id = ? AND user_id = ?`,
|
WHERE app_code = ? AND wallet_transaction_id = ? AND user_id = ?`,
|
||||||
appcode.FromContext(ctx), transactionID, userID,
|
appcode.FromContext(ctx), transactionID, userID,
|
||||||
).Scan(&orderID, &shopItemID, &priceCoin, &grantID, &entitlementID)
|
).Scan(&orderID, &shopItemID, &durationDays, &priceCoin, &grantID, &entitlementID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||||
}
|
}
|
||||||
@ -377,6 +477,9 @@ func (r *Repository) resourceShopPurchaseReceiptForTransaction(ctx context.Conte
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||||
}
|
}
|
||||||
|
// 幂等重试必须返回原订单规格;运营在首单后改价或改配置时,不能让回执里的商品价格与 coin_spent 自相矛盾。
|
||||||
|
item.DurationDays = durationDays
|
||||||
|
item.CoinPrice = priceCoin
|
||||||
resource, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID)
|
resource, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||||
@ -534,7 +637,7 @@ func resourceShopItemSelectSQL() string {
|
|||||||
return `
|
return `
|
||||||
SELECT si.app_code, si.shop_item_id, si.resource_id, si.status, si.duration_days,
|
SELECT si.app_code, si.shop_item_id, si.resource_id, si.status, si.duration_days,
|
||||||
r.price_type,
|
r.price_type,
|
||||||
CASE WHEN r.price_type = 'coin' THEN r.coin_price * si.duration_days ELSE 0 END AS coin_price,
|
CASE WHEN si.coin_price > 0 THEN si.coin_price ELSE r.coin_price * si.duration_days END AS coin_price,
|
||||||
si.effective_from_ms, si.effective_to_ms, si.sort_order,
|
si.effective_from_ms, si.effective_to_ms, si.sort_order,
|
||||||
si.created_by_user_id, si.updated_by_user_id, si.created_at_ms, si.updated_at_ms,
|
si.created_by_user_id, si.updated_by_user_id, si.created_at_ms, si.updated_at_ms,
|
||||||
` + resourceColumnsWithAlias("r") + `
|
` + resourceColumnsWithAlias("r") + `
|
||||||
@ -651,6 +754,9 @@ func validateResourceShopItemsCommand(command resourcedomain.ResourceShopItemsCo
|
|||||||
if !resourcedomain.ValidShopDurationDays(item.DurationDays) {
|
if !resourcedomain.ValidShopDurationDays(item.DurationDays) {
|
||||||
return xerr.New(xerr.InvalidArgument, "resource shop duration is invalid")
|
return xerr.New(xerr.InvalidArgument, "resource shop duration is invalid")
|
||||||
}
|
}
|
||||||
|
if item.CoinPrice < 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "resource shop coin price is invalid")
|
||||||
|
}
|
||||||
if item.EffectiveFromMS < 0 || item.EffectiveToMS < 0 || (item.EffectiveFromMS > 0 && item.EffectiveToMS > 0 && item.EffectiveToMS <= item.EffectiveFromMS) {
|
if item.EffectiveFromMS < 0 || item.EffectiveToMS < 0 || (item.EffectiveFromMS > 0 && item.EffectiveToMS > 0 && item.EffectiveToMS <= item.EffectiveFromMS) {
|
||||||
return xerr.New(xerr.InvalidArgument, "resource shop effective time is invalid")
|
return xerr.New(xerr.InvalidArgument, "resource shop effective time is invalid")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -198,6 +198,13 @@ func normalizeAdminVipLevelCommands(levels []ledger.AdminVipLevelCommand, progra
|
|||||||
benefit.ResourceType = resourcedomain.NormalizeResourceType(benefit.ResourceType)
|
benefit.ResourceType = resourcedomain.NormalizeResourceType(benefit.ResourceType)
|
||||||
benefit.ExecutionScope = strings.ToLower(strings.TrimSpace(benefit.ExecutionScope))
|
benefit.ExecutionScope = strings.ToLower(strings.TrimSpace(benefit.ExecutionScope))
|
||||||
benefit.MetadataJSON = strings.TrimSpace(benefit.MetadataJSON)
|
benefit.MetadataJSON = strings.TrimSpace(benefit.MetadataJSON)
|
||||||
|
// 后台可直接提交结构化 presentation;钱包仍把它与 coin_amount/message 等扩展字段
|
||||||
|
// 原子保存在同一 JSON 列,避免展示配置出现双写版本。
|
||||||
|
mergedMetadata, mergeErr := ledger.MergeVipBenefitPresentation(benefit.MetadataJSON, benefit.Presentation)
|
||||||
|
if mergeErr != nil {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "vip benefit presentation is invalid")
|
||||||
|
}
|
||||||
|
benefit.MetadataJSON = mergedMetadata
|
||||||
// 体验卡永远不参与金币返现;保存时强制收敛,运行时仍会按编码再次兜底。
|
// 体验卡永远不参与金币返现;保存时强制收敛,运行时仍会按编码再次兜底。
|
||||||
if benefit.BenefitCode == ledger.VipBenefitCodeDailyCoinRebate {
|
if benefit.BenefitCode == ledger.VipBenefitCodeDailyCoinRebate {
|
||||||
benefit.TrialEnabled = false
|
benefit.TrialEnabled = false
|
||||||
@ -265,6 +272,9 @@ func (r *Repository) validateAdminVipBenefit(ctx context.Context, tx *sql.Tx, le
|
|||||||
if benefit.MetadataJSON != "" && !json.Valid([]byte(benefit.MetadataJSON)) {
|
if benefit.MetadataJSON != "" && !json.Valid([]byte(benefit.MetadataJSON)) {
|
||||||
return xerr.New(xerr.InvalidArgument, "vip benefit metadata_json is invalid")
|
return xerr.New(xerr.InvalidArgument, "vip benefit metadata_json is invalid")
|
||||||
}
|
}
|
||||||
|
if _, err := ledger.ParseVipBenefitPresentation(benefit.MetadataJSON); err != nil {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "vip benefit presentation is invalid")
|
||||||
|
}
|
||||||
if benefit.BenefitCode == ledger.VipBenefitCodeDailyCoinRebate {
|
if benefit.BenefitCode == ledger.VipBenefitCodeDailyCoinRebate {
|
||||||
if benefit.ExecutionScope != ledger.VipExecutionScopeWallet {
|
if benefit.ExecutionScope != ledger.VipExecutionScopeWallet {
|
||||||
return xerr.New(xerr.InvalidArgument, "daily_coin_rebate execution_scope must be wallet")
|
return xerr.New(xerr.InvalidArgument, "daily_coin_rebate execution_scope must be wallet")
|
||||||
|
|||||||
@ -80,7 +80,7 @@ func (r *Repository) GetVipState(ctx context.Context, userID int64) (ledger.VipS
|
|||||||
func (r *Repository) requireVipProgramConfig(ctx context.Context, querier vipSnapshotQuerier) (ledger.VipProgramConfig, error) {
|
func (r *Repository) requireVipProgramConfig(ctx context.Context, querier vipSnapshotQuerier) (ledger.VipProgramConfig, error) {
|
||||||
config, err := r.queryVipProgramConfig(ctx, querier.QueryRowContext(ctx, vipProgramSelectSQL(), appcode.FromContext(ctx)))
|
config, err := r.queryVipProgramConfig(ctx, querier.QueryRowContext(ctx, vipProgramSelectSQL(), appcode.FromContext(ctx)))
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return ledger.VipProgramConfig{}, xerr.New(xerr.NotFound, "vip program config not found")
|
return ledger.VipProgramConfig{}, xerr.New(xerr.VIPProgramInactive, "vip program config not found")
|
||||||
}
|
}
|
||||||
return config, err
|
return config, err
|
||||||
}
|
}
|
||||||
@ -282,6 +282,11 @@ func scanVipBenefit(scanner vipProgramScanner) (ledger.VipBenefit, error) {
|
|||||||
if metadata.Valid {
|
if metadata.Valid {
|
||||||
benefit.MetadataJSON = metadata.String
|
benefit.MetadataJSON = metadata.String
|
||||||
}
|
}
|
||||||
|
if err == nil {
|
||||||
|
// presentation 是 metadata_json 的稳定结构化投影;读取时统一解析,HTTP/gRPC 调用方
|
||||||
|
// 无需按 benefit_code 重复维护 JSON schema。
|
||||||
|
benefit.Presentation, err = ledger.ParseVipBenefitPresentation(benefit.MetadataJSON)
|
||||||
|
}
|
||||||
return benefit, err
|
return benefit, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -43,23 +43,26 @@ func (r *Repository) PurchaseVip(ctx context.Context, command ledger.PurchaseVip
|
|||||||
|
|
||||||
program, err := r.queryVipProgramConfig(ctx, tx.QueryRowContext(ctx, vipProgramSelectSQL()+` LOCK IN SHARE MODE`, command.AppCode))
|
program, err := r.queryVipProgramConfig(ctx, tx.QueryRowContext(ctx, vipProgramSelectSQL()+` LOCK IN SHARE MODE`, command.AppCode))
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return ledger.PurchaseVipReceipt{}, xerr.New(xerr.NotFound, "vip program config not found")
|
return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPProgramInactive, "vip program config not found")
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ledger.PurchaseVipReceipt{}, err
|
return ledger.PurchaseVipReceipt{}, err
|
||||||
}
|
}
|
||||||
if program.Status != ledger.VipStatusActive {
|
if program.Status != ledger.VipStatusActive {
|
||||||
return ledger.PurchaseVipReceipt{}, xerr.New(xerr.Conflict, "vip program is disabled")
|
return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPProgramInactive, "vip program is disabled")
|
||||||
}
|
}
|
||||||
if command.Level > program.LevelCount {
|
if command.Level > program.LevelCount {
|
||||||
return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPLevelNotFound, "vip level exceeds program level_count")
|
return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPPackageNotPurchasable, "vip level exceeds program level_count")
|
||||||
}
|
}
|
||||||
level, err := r.getVipLevelForUpdate(ctx, tx, command.Level)
|
level, err := r.getVipLevelForUpdate(ctx, tx, command.Level)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if xerr.IsCode(err, xerr.VIPLevelNotFound) {
|
||||||
|
return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPPackageNotPurchasable, "vip package not found")
|
||||||
|
}
|
||||||
return ledger.PurchaseVipReceipt{}, err
|
return ledger.PurchaseVipReceipt{}, err
|
||||||
}
|
}
|
||||||
if level.Status != ledger.VipStatusActive {
|
if level.Status != ledger.VipStatusActive {
|
||||||
return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPLevelDisabled, "vip level is disabled")
|
return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPPackageNotPurchasable, "vip package is disabled")
|
||||||
}
|
}
|
||||||
current, err := r.ensureUserVipForUpdate(ctx, tx, command.UserID, nowMs)
|
current, err := r.ensureUserVipForUpdate(ctx, tx, command.UserID, nowMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -85,7 +88,7 @@ func (r *Repository) PurchaseVip(ctx context.Context, command ledger.PurchaseVip
|
|||||||
return ledger.PurchaseVipReceipt{}, err
|
return ledger.PurchaseVipReceipt{}, err
|
||||||
}
|
}
|
||||||
if account.AvailableAmount < level.PriceCoin {
|
if account.AvailableAmount < level.PriceCoin {
|
||||||
return ledger.PurchaseVipReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
|
return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPInsufficientCoin, "insufficient coin balance")
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionID := transactionID(command.AppCode, command.CommandID)
|
transactionID := transactionID(command.AppCode, command.CommandID)
|
||||||
@ -101,6 +104,9 @@ func (r *Repository) PurchaseVip(ctx context.Context, command ledger.PurchaseVip
|
|||||||
"price_coin": level.PriceCoin,
|
"price_coin": level.PriceCoin,
|
||||||
"expires_at_ms": newExpiresAt,
|
"expires_at_ms": newExpiresAt,
|
||||||
"balance_after": coinBalanceAfter,
|
"balance_after": coinBalanceAfter,
|
||||||
|
// 余额版本必须与本次交易后的快照一起固化,幂等重放不能读取已被后续交易推进的当前版本。
|
||||||
|
"balance_version": account.Version + 1,
|
||||||
|
"frozen_after": account.FrozenAmount,
|
||||||
"reward_group_id": level.RewardResourceGroupID,
|
"reward_group_id": level.RewardResourceGroupID,
|
||||||
"duration_ms": level.DurationMS,
|
"duration_ms": level.DurationMS,
|
||||||
"previous_expires_at_ms": current.ExpiresAtMS,
|
"previous_expires_at_ms": current.ExpiresAtMS,
|
||||||
@ -203,6 +209,11 @@ func (r *Repository) PurchaseVip(ctx context.Context, command ledger.PurchaseVip
|
|||||||
Vip: activation.VIP,
|
Vip: activation.VIP,
|
||||||
CoinSpent: level.PriceCoin,
|
CoinSpent: level.PriceCoin,
|
||||||
CoinBalanceAfter: coinBalanceAfter,
|
CoinBalanceAfter: coinBalanceAfter,
|
||||||
|
CoinBalance: ledger.AssetBalance{
|
||||||
|
AppCode: command.AppCode, UserID: command.UserID, AssetType: ledger.AssetCoin,
|
||||||
|
AvailableAmount: coinBalanceAfter, FrozenAmount: account.FrozenAmount,
|
||||||
|
Version: account.Version + 1, UpdatedAtMs: nowMs,
|
||||||
|
},
|
||||||
RewardItems: activation.RewardItems,
|
RewardItems: activation.RewardItems,
|
||||||
State: state,
|
State: state,
|
||||||
}, nil
|
}, nil
|
||||||
@ -521,6 +532,7 @@ func (r *Repository) vipPurchaseReceiptForTransaction(ctx context.Context, tx *s
|
|||||||
VIPName string `json:"vip_name"`
|
VIPName string `json:"vip_name"`
|
||||||
StartedAtMS int64 `json:"started_at_ms"`
|
StartedAtMS int64 `json:"started_at_ms"`
|
||||||
ExpiresAtMS int64 `json:"expires_at_ms"`
|
ExpiresAtMS int64 `json:"expires_at_ms"`
|
||||||
|
BalanceVersion int64 `json:"balance_version"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal([]byte(txRow.MetadataJSON), &metadata)
|
_ = json.Unmarshal([]byte(txRow.MetadataJSON), &metadata)
|
||||||
if expiresAtMS <= 0 {
|
if expiresAtMS <= 0 {
|
||||||
@ -552,6 +564,9 @@ func (r *Repository) vipPurchaseReceiptForTransaction(ctx context.Context, tx *s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ledger.PurchaseVipReceipt{}, err
|
return ledger.PurchaseVipReceipt{}, err
|
||||||
}
|
}
|
||||||
|
// 2026-07-15 之后的新交易始终从交易 metadata 还原原始版本。旧交易没有该字段时保持 0,
|
||||||
|
// Flutter 看到 version=0 必须走余额查询,不能拿当前账户版本伪装成本次历史回执。
|
||||||
|
balance.Version = metadata.BalanceVersion
|
||||||
rewards, err := r.rewardItemsByGrant(ctx, tx, grantID, expiresAtMS)
|
rewards, err := r.rewardItemsByGrant(ctx, tx, grantID, expiresAtMS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ledger.PurchaseVipReceipt{}, err
|
return ledger.PurchaseVipReceipt{}, err
|
||||||
@ -562,6 +577,7 @@ func (r *Repository) vipPurchaseReceiptForTransaction(ctx context.Context, tx *s
|
|||||||
Vip: vip,
|
Vip: vip,
|
||||||
CoinSpent: priceCoin,
|
CoinSpent: priceCoin,
|
||||||
CoinBalanceAfter: balance.AvailableAmount,
|
CoinBalanceAfter: balance.AvailableAmount,
|
||||||
|
CoinBalance: balance,
|
||||||
RewardItems: rewards,
|
RewardItems: rewards,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -238,29 +238,38 @@ func (r *Repository) EquipVipTrialCard(ctx context.Context, command ledger.Equip
|
|||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
nowMS := time.Now().UnixMilli()
|
nowMS := time.Now().UnixMilli()
|
||||||
program, err := r.queryVipProgramConfig(ctx, tx.QueryRowContext(ctx, vipProgramSelectSQL(), command.AppCode))
|
program, err := r.queryVipProgramConfig(ctx, tx.QueryRowContext(ctx, vipProgramSelectSQL(), command.AppCode))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.New(xerr.VIPProgramInactive, "vip program config not found")
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ledger.VipTrialCard{}, ledger.VipState{}, err
|
return ledger.VipTrialCard{}, ledger.VipState{}, err
|
||||||
}
|
}
|
||||||
if program.Status != ledger.VipStatusActive || !program.TrialCardEnabled {
|
if program.Status != ledger.VipStatusActive || !program.TrialCardEnabled {
|
||||||
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.New(xerr.Conflict, "vip trial card is disabled for this app")
|
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.New(xerr.VIPProgramInactive, "vip trial card is disabled for this app")
|
||||||
}
|
}
|
||||||
// 锁序必须与 RevokeResourceGrant 保持 entitlement -> trial_card;反向先锁 card 会和后台撤销形成死锁。
|
// 锁序必须与 RevokeResourceGrant 保持 entitlement -> trial_card;反向先锁 card 会和后台撤销形成死锁。
|
||||||
// entitlement 同时承担并发有效性门禁,避免撤销在校验后抢先提交又被重新写回 equipment。
|
// entitlement 同时承担并发有效性门禁,避免撤销在校验后抢先提交又被重新写回 equipment。
|
||||||
entitlement, err := r.getUserResourceEntitlementForUpdateTx(ctx, tx, command.EntitlementID)
|
entitlement, err := r.getUserResourceEntitlementForUpdateTx(ctx, tx, command.EntitlementID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if xerr.IsCode(err, xerr.NotFound) {
|
||||||
|
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.New(xerr.VIPTrialCardNotFound, "vip trial card entitlement not found")
|
||||||
|
}
|
||||||
return ledger.VipTrialCard{}, ledger.VipState{}, err
|
return ledger.VipTrialCard{}, ledger.VipState{}, err
|
||||||
}
|
}
|
||||||
card, err := r.queryVipTrialCardForUpdate(ctx, tx, command.UserID, command.EntitlementID, nowMS)
|
card, err := r.queryVipTrialCardForUpdate(ctx, tx, command.UserID, command.EntitlementID, nowMS)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.New(xerr.NotFound, "vip trial card not found")
|
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.New(xerr.VIPTrialCardNotFound, "vip trial card not found")
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ledger.VipTrialCard{}, ledger.VipState{}, err
|
return ledger.VipTrialCard{}, ledger.VipState{}, err
|
||||||
}
|
}
|
||||||
|
if card.ExpiresAtMS <= nowMS || (entitlement.ExpiresAtMS > 0 && entitlement.ExpiresAtMS <= nowMS) {
|
||||||
|
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.New(xerr.VIPTrialCardExpired, "vip trial card expired")
|
||||||
|
}
|
||||||
if entitlement.UserID != command.UserID || entitlement.ResourceID != card.ResourceID ||
|
if entitlement.UserID != command.UserID || entitlement.ResourceID != card.ResourceID ||
|
||||||
entitlement.Status != resourcedomain.StatusActive || entitlement.EffectiveAtMS > nowMS ||
|
entitlement.Status != resourcedomain.StatusActive || entitlement.EffectiveAtMS > nowMS ||
|
||||||
(entitlement.ExpiresAtMS > 0 && entitlement.ExpiresAtMS <= nowMS) || entitlement.RemainingQuantity <= 0 {
|
card.Status != ledger.VipTrialCardStatusActive || card.EffectiveAtMS > nowMS || entitlement.RemainingQuantity <= 0 {
|
||||||
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.New(xerr.Conflict, "vip trial card entitlement is inactive")
|
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.New(xerr.VIPTrialCardNotFound, "vip trial card entitlement is inactive")
|
||||||
}
|
}
|
||||||
if resourcedomain.NormalizeResourceType(entitlement.Resource.ResourceType) != resourcedomain.TypeVIPTrialCard {
|
if resourcedomain.NormalizeResourceType(entitlement.Resource.ResourceType) != resourcedomain.TypeVIPTrialCard {
|
||||||
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.New(xerr.Conflict, "vip trial card resource type mismatch")
|
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.New(xerr.Conflict, "vip trial card resource type mismatch")
|
||||||
@ -435,10 +444,9 @@ func (r *Repository) queryVipTrialCardForUpdate(ctx context.Context, tx *sql.Tx,
|
|||||||
duration_ms, effective_at_ms, expires_at_ms, grant_source, source_grant_id,
|
duration_ms, effective_at_ms, expires_at_ms, grant_source, source_grant_id,
|
||||||
created_at_ms, updated_at_ms
|
created_at_ms, updated_at_ms
|
||||||
FROM user_vip_trial_cards
|
FROM user_vip_trial_cards
|
||||||
WHERE app_code = ? AND user_id = ? AND entitlement_id = ? AND status = ?
|
WHERE app_code = ? AND user_id = ? AND entitlement_id = ?
|
||||||
AND effective_at_ms <= ? AND expires_at_ms > ?
|
|
||||||
FOR UPDATE`,
|
FOR UPDATE`,
|
||||||
appcode.FromContext(ctx), userID, entitlementID, ledger.VipTrialCardStatusActive, nowMS, nowMS,
|
appcode.FromContext(ctx), userID, entitlementID,
|
||||||
)
|
)
|
||||||
return scanVipTrialCard(row, nowMS)
|
return scanVipTrialCard(row, nowMS)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,9 @@ func (r *Repository) UpdateVipUserSettings(ctx context.Context, command ledger.U
|
|||||||
}
|
}
|
||||||
ctx = contextWithCommandApp(ctx, command.AppCode)
|
ctx = contextWithCommandApp(ctx, command.AppCode)
|
||||||
command.AppCode = appcode.FromContext(ctx)
|
command.AppCode = appcode.FromContext(ctx)
|
||||||
if command.UserID <= 0 || (command.RoomEntryNoticeEnabled == nil && command.OnlineGlobalNoticeEnabled == nil) {
|
if command.UserID <= 0 || (command.RoomEntryNoticeEnabled == nil && command.OnlineGlobalNoticeEnabled == nil &&
|
||||||
|
command.HideProfileDataEnabled == nil && command.AnonymousProfileVisitEnabled == nil &&
|
||||||
|
command.LeaderboardInvisibleEnabled == nil) {
|
||||||
return ledger.VipUserSettings{}, xerr.New(xerr.InvalidArgument, "vip user settings update is incomplete")
|
return ledger.VipUserSettings{}, xerr.New(xerr.InvalidArgument, "vip user settings update is incomplete")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,17 +41,39 @@ func (r *Repository) UpdateVipUserSettings(ctx context.Context, command ledger.U
|
|||||||
if onlineProvided {
|
if onlineProvided {
|
||||||
onlineValue = *command.OnlineGlobalNoticeEnabled
|
onlineValue = *command.OnlineGlobalNoticeEnabled
|
||||||
}
|
}
|
||||||
|
hideProfileValue := true
|
||||||
|
hideProfileProvided := command.HideProfileDataEnabled != nil
|
||||||
|
if hideProfileProvided {
|
||||||
|
hideProfileValue = *command.HideProfileDataEnabled
|
||||||
|
}
|
||||||
|
anonymousVisitValue := true
|
||||||
|
anonymousVisitProvided := command.AnonymousProfileVisitEnabled != nil
|
||||||
|
if anonymousVisitProvided {
|
||||||
|
anonymousVisitValue = *command.AnonymousProfileVisitEnabled
|
||||||
|
}
|
||||||
|
leaderboardInvisibleValue := true
|
||||||
|
leaderboardInvisibleProvided := command.LeaderboardInvisibleEnabled != nil
|
||||||
|
if leaderboardInvisibleProvided {
|
||||||
|
leaderboardInvisibleValue = *command.LeaderboardInvisibleEnabled
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO user_vip_settings (
|
INSERT INTO user_vip_settings (
|
||||||
app_code, user_id, room_entry_notice_enabled, online_global_notice_enabled,
|
app_code, user_id, room_entry_notice_enabled, online_global_notice_enabled,
|
||||||
|
hide_profile_data_enabled, anonymous_profile_visit_enabled, leaderboard_invisible_enabled,
|
||||||
created_at_ms, updated_at_ms
|
created_at_ms, updated_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
room_entry_notice_enabled = IF(?, ?, room_entry_notice_enabled),
|
room_entry_notice_enabled = IF(?, ?, room_entry_notice_enabled),
|
||||||
online_global_notice_enabled = IF(?, ?, online_global_notice_enabled),
|
online_global_notice_enabled = IF(?, ?, online_global_notice_enabled),
|
||||||
|
hide_profile_data_enabled = IF(?, ?, hide_profile_data_enabled),
|
||||||
|
anonymous_profile_visit_enabled = IF(?, ?, anonymous_profile_visit_enabled),
|
||||||
|
leaderboard_invisible_enabled = IF(?, ?, leaderboard_invisible_enabled),
|
||||||
updated_at_ms = ?`,
|
updated_at_ms = ?`,
|
||||||
command.AppCode, command.UserID, roomValue, onlineValue, nowMS, nowMS,
|
command.AppCode, command.UserID, roomValue, onlineValue, hideProfileValue,
|
||||||
roomProvided, roomValue, onlineProvided, onlineValue, nowMS,
|
anonymousVisitValue, leaderboardInvisibleValue, nowMS, nowMS,
|
||||||
|
roomProvided, roomValue, onlineProvided, onlineValue,
|
||||||
|
hideProfileProvided, hideProfileValue, anonymousVisitProvided, anonymousVisitValue,
|
||||||
|
leaderboardInvisibleProvided, leaderboardInvisibleValue, nowMS,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return ledger.VipUserSettings{}, err
|
return ledger.VipUserSettings{}, err
|
||||||
}
|
}
|
||||||
@ -70,7 +94,8 @@ func (r *Repository) queryVipUserSettings(ctx context.Context, querier interface
|
|||||||
}, userID int64, lock bool) (ledger.VipUserSettings, error) {
|
}, userID int64, lock bool) (ledger.VipUserSettings, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT app_code, user_id, room_entry_notice_enabled,
|
SELECT app_code, user_id, room_entry_notice_enabled,
|
||||||
online_global_notice_enabled, updated_at_ms
|
online_global_notice_enabled, hide_profile_data_enabled,
|
||||||
|
anonymous_profile_visit_enabled, leaderboard_invisible_enabled, updated_at_ms
|
||||||
FROM user_vip_settings
|
FROM user_vip_settings
|
||||||
WHERE app_code = ? AND user_id = ?`
|
WHERE app_code = ? AND user_id = ?`
|
||||||
if lock {
|
if lock {
|
||||||
@ -79,7 +104,9 @@ func (r *Repository) queryVipUserSettings(ctx context.Context, querier interface
|
|||||||
var settings ledger.VipUserSettings
|
var settings ledger.VipUserSettings
|
||||||
if err := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), userID).Scan(
|
if err := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), userID).Scan(
|
||||||
&settings.AppCode, &settings.UserID, &settings.RoomEntryNoticeEnabled,
|
&settings.AppCode, &settings.UserID, &settings.RoomEntryNoticeEnabled,
|
||||||
&settings.OnlineGlobalNoticeEnabled, &settings.UpdatedAtMS,
|
&settings.OnlineGlobalNoticeEnabled, &settings.HideProfileDataEnabled,
|
||||||
|
&settings.AnonymousProfileVisitEnabled, &settings.LeaderboardInvisibleEnabled,
|
||||||
|
&settings.UpdatedAtMS,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return ledger.DefaultVipUserSettings(appcode.FromContext(ctx), userID), nil
|
return ledger.DefaultVipUserSettings(appcode.FromContext(ctx), userID), nil
|
||||||
|
|||||||
@ -349,6 +349,21 @@ func (s *Server) RevokeResourceGrant(ctx context.Context, req *walletv1.RevokeRe
|
|||||||
return &walletv1.ResourceGrantResponse{Grant: resourceGrantToProto(grant)}, nil
|
return &walletv1.ResourceGrantResponse{Grant: resourceGrantToProto(grant)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) RevokeUserResource(ctx context.Context, req *walletv1.RevokeUserResourceRequest) (*walletv1.RevokeUserResourceResponse, error) {
|
||||||
|
resource, err := s.svc.RevokeUserResource(ctx, resourcedomain.RevokeUserResourceCommand{
|
||||||
|
RequestID: req.GetRequestId(),
|
||||||
|
AppCode: req.GetAppCode(),
|
||||||
|
UserID: req.GetUserId(),
|
||||||
|
EntitlementID: req.GetEntitlementId(),
|
||||||
|
Reason: req.GetReason(),
|
||||||
|
OperatorUserID: req.GetOperatorUserId(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &walletv1.RevokeUserResourceResponse{Resource: userResourceToProto(resource)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) ListUserResources(ctx context.Context, req *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
|
func (s *Server) ListUserResources(ctx context.Context, req *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
|
||||||
items, err := s.svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
|
items, err := s.svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
|
||||||
AppCode: req.GetAppCode(),
|
AppCode: req.GetAppCode(),
|
||||||
|
|||||||
@ -164,6 +164,7 @@ func resourceShopItemInputs(items []*walletv1.ResourceShopItemInput) []resourced
|
|||||||
ResourceID: item.GetResourceId(),
|
ResourceID: item.GetResourceId(),
|
||||||
Status: item.GetStatus(),
|
Status: item.GetStatus(),
|
||||||
DurationDays: item.GetDurationDays(),
|
DurationDays: item.GetDurationDays(),
|
||||||
|
CoinPrice: item.GetCoinPrice(),
|
||||||
EffectiveFromMS: item.GetEffectiveFromMs(),
|
EffectiveFromMS: item.GetEffectiveFromMs(),
|
||||||
EffectiveToMS: item.GetEffectiveToMs(),
|
EffectiveToMS: item.GetEffectiveToMs(),
|
||||||
SortOrder: item.GetSortOrder(),
|
SortOrder: item.GetSortOrder(),
|
||||||
|
|||||||
@ -90,6 +90,7 @@ func (s *Server) PurchaseVip(ctx context.Context, req *walletv1.PurchaseVipReque
|
|||||||
CoinBalanceAfter: receipt.CoinBalanceAfter,
|
CoinBalanceAfter: receipt.CoinBalanceAfter,
|
||||||
RewardItems: vipRewardItemsToProto(receipt.RewardItems),
|
RewardItems: vipRewardItemsToProto(receipt.RewardItems),
|
||||||
State: vipStateToProto(receipt.State),
|
State: vipStateToProto(receipt.State),
|
||||||
|
CoinBalance: balanceToProto(receipt.CoinBalance),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,9 +256,26 @@ func (s *Server) UpdateMyVipSettings(ctx context.Context, req *walletv1.UpdateMy
|
|||||||
value := req.GetOnlineGlobalNoticeEnabled()
|
value := req.GetOnlineGlobalNoticeEnabled()
|
||||||
onlineGlobalNoticeEnabled = &value
|
onlineGlobalNoticeEnabled = &value
|
||||||
}
|
}
|
||||||
|
var hideProfileDataEnabled *bool
|
||||||
|
if req.HideProfileDataEnabled != nil {
|
||||||
|
value := req.GetHideProfileDataEnabled()
|
||||||
|
hideProfileDataEnabled = &value
|
||||||
|
}
|
||||||
|
var anonymousProfileVisitEnabled *bool
|
||||||
|
if req.AnonymousProfileVisitEnabled != nil {
|
||||||
|
value := req.GetAnonymousProfileVisitEnabled()
|
||||||
|
anonymousProfileVisitEnabled = &value
|
||||||
|
}
|
||||||
|
var leaderboardInvisibleEnabled *bool
|
||||||
|
if req.LeaderboardInvisibleEnabled != nil {
|
||||||
|
value := req.GetLeaderboardInvisibleEnabled()
|
||||||
|
leaderboardInvisibleEnabled = &value
|
||||||
|
}
|
||||||
settings, err := s.svc.UpdateVipUserSettings(ctx, ledger.UpdateVipUserSettingsCommand{
|
settings, err := s.svc.UpdateVipUserSettings(ctx, ledger.UpdateVipUserSettingsCommand{
|
||||||
AppCode: req.GetAppCode(), UserID: req.GetUserId(),
|
AppCode: req.GetAppCode(), UserID: req.GetUserId(),
|
||||||
RoomEntryNoticeEnabled: roomEntryNoticeEnabled, OnlineGlobalNoticeEnabled: onlineGlobalNoticeEnabled,
|
RoomEntryNoticeEnabled: roomEntryNoticeEnabled, OnlineGlobalNoticeEnabled: onlineGlobalNoticeEnabled,
|
||||||
|
HideProfileDataEnabled: hideProfileDataEnabled, AnonymousProfileVisitEnabled: anonymousProfileVisitEnabled,
|
||||||
|
LeaderboardInvisibleEnabled: leaderboardInvisibleEnabled,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, xerr.ToGRPCError(err)
|
return nil, xerr.ToGRPCError(err)
|
||||||
@ -357,6 +375,9 @@ func vipUserSettingsToProto(settings ledger.VipUserSettings) *walletv1.VipUserSe
|
|||||||
AppCode: settings.AppCode, UserId: settings.UserID,
|
AppCode: settings.AppCode, UserId: settings.UserID,
|
||||||
RoomEntryNoticeEnabled: settings.RoomEntryNoticeEnabled,
|
RoomEntryNoticeEnabled: settings.RoomEntryNoticeEnabled,
|
||||||
OnlineGlobalNoticeEnabled: settings.OnlineGlobalNoticeEnabled,
|
OnlineGlobalNoticeEnabled: settings.OnlineGlobalNoticeEnabled,
|
||||||
|
HideProfileDataEnabled: settings.HideProfileDataEnabled,
|
||||||
|
AnonymousProfileVisitEnabled: settings.AnonymousProfileVisitEnabled,
|
||||||
|
LeaderboardInvisibleEnabled: settings.LeaderboardInvisibleEnabled,
|
||||||
UpdatedAtMs: settings.UpdatedAtMS,
|
UpdatedAtMs: settings.UpdatedAtMS,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -439,6 +460,7 @@ func vipBenefitToProto(benefit ledger.VipBenefit) *walletv1.VipBenefit {
|
|||||||
MetadataJson: benefit.MetadataJSON,
|
MetadataJson: benefit.MetadataJSON,
|
||||||
CreatedAtMs: benefit.CreatedAtMS,
|
CreatedAtMs: benefit.CreatedAtMS,
|
||||||
UpdatedAtMs: benefit.UpdatedAtMS,
|
UpdatedAtMs: benefit.UpdatedAtMS,
|
||||||
|
Presentation: vipBenefitPresentationToProto(benefit.Presentation),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -471,11 +493,62 @@ func vipBenefitsFromProto(benefits []*walletv1.VipBenefit) []ledger.VipBenefit {
|
|||||||
AutoEquip: benefit.GetAutoEquip(),
|
AutoEquip: benefit.GetAutoEquip(),
|
||||||
SortOrder: benefit.GetSortOrder(),
|
SortOrder: benefit.GetSortOrder(),
|
||||||
MetadataJSON: benefit.GetMetadataJson(),
|
MetadataJSON: benefit.GetMetadataJson(),
|
||||||
|
Presentation: vipBenefitPresentationFromProto(benefit.GetPresentation()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func vipBenefitPresentationToProto(presentation *ledger.VipBenefitPresentation) *walletv1.VipBenefitPresentation {
|
||||||
|
if presentation == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := &walletv1.VipBenefitPresentation{
|
||||||
|
Description: presentation.Description,
|
||||||
|
PreviewItems: make([]*walletv1.VipBenefitPreviewItem, 0, len(presentation.PreviewItems)),
|
||||||
|
}
|
||||||
|
for _, item := range presentation.PreviewItems {
|
||||||
|
result.PreviewItems = append(result.PreviewItems, &walletv1.VipBenefitPreviewItem{
|
||||||
|
PreviewId: item.PreviewID, Title: item.Title, MediaType: item.MediaType,
|
||||||
|
AssetUrl: item.AssetURL, PreviewUrl: item.PreviewURL, AnimationUrl: item.AnimationURL,
|
||||||
|
CompositionType: item.CompositionType, SortOrder: item.SortOrder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if presentation.NumericReward != nil {
|
||||||
|
result.NumericReward = &walletv1.VipBenefitNumericReward{
|
||||||
|
Label: presentation.NumericReward.Label, Value: presentation.NumericReward.Value,
|
||||||
|
Unit: presentation.NumericReward.Unit, Period: presentation.NumericReward.Period,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func vipBenefitPresentationFromProto(presentation *walletv1.VipBenefitPresentation) *ledger.VipBenefitPresentation {
|
||||||
|
if presentation == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := &ledger.VipBenefitPresentation{
|
||||||
|
Description: presentation.GetDescription(),
|
||||||
|
PreviewItems: make([]ledger.VipBenefitPreviewItem, 0, len(presentation.GetPreviewItems())),
|
||||||
|
}
|
||||||
|
for _, item := range presentation.GetPreviewItems() {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.PreviewItems = append(result.PreviewItems, ledger.VipBenefitPreviewItem{
|
||||||
|
PreviewID: item.GetPreviewId(), Title: item.GetTitle(), MediaType: item.GetMediaType(),
|
||||||
|
AssetURL: item.GetAssetUrl(), PreviewURL: item.GetPreviewUrl(), AnimationURL: item.GetAnimationUrl(),
|
||||||
|
CompositionType: item.GetCompositionType(), SortOrder: item.GetSortOrder(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if reward := presentation.GetNumericReward(); reward != nil {
|
||||||
|
result.NumericReward = &ledger.VipBenefitNumericReward{
|
||||||
|
Label: reward.GetLabel(), Value: reward.GetValue(), Unit: reward.GetUnit(), Period: reward.GetPeriod(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func vipTrialCardToProto(card ledger.VipTrialCard) *walletv1.VipTrialCard {
|
func vipTrialCardToProto(card ledger.VipTrialCard) *walletv1.VipTrialCard {
|
||||||
if card.TrialCardID == "" {
|
if card.TrialCardID == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user