feat(wallet): expand VIP and resource management flows

This commit is contained in:
zhx 2026-07-15 19:34:11 +08:00
parent c556b44cf6
commit fdcd29126d
49 changed files with 3047 additions and 601 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1187,6 +1187,8 @@ message ResourceShopItemInput {
int64 effective_from_ms = 5;
int64 effective_to_ms = 6;
int32 sort_order = 7;
// coin_price 0 使 0
int64 coin_price = 8;
}
message ListResourceShopItemsRequest {
@ -2016,6 +2018,7 @@ message VipBenefit {
string metadata_json = 12;
int64 created_at_ms = 13;
int64 updated_at_ms = 14;
VipBenefitPresentation presentation = 15;
}
message VipLevel {
@ -2073,13 +2076,16 @@ message VipTrialCard {
}
// VipUserSettings App VIP wallet
// updated_at_ms=0 VIP
// updated_at_ms=0 VIP
message VipUserSettings {
string app_code = 1;
int64 user_id = 2;
bool room_entry_notice_enabled = 3;
bool online_global_notice_enabled = 4;
int64 updated_at_ms = 5;
bool hide_profile_data_enabled = 6;
bool anonymous_profile_visit_enabled = 7;
bool leaderboard_invisible_enabled = 8;
}
// VipState
@ -2136,6 +2142,8 @@ message PurchaseVipResponse {
int64 coin_balance_after = 5;
repeated VipRewardItem reward_items = 6;
VipState state = 7;
// coin_balance coin_balance_after
AssetBalance coin_balance = 8;
}
message DebitCPBreakupFeeRequest {
@ -2220,6 +2228,9 @@ message UpdateMyVipSettingsRequest {
int64 user_id = 3;
optional bool room_entry_notice_enabled = 4;
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 {
@ -2940,6 +2951,52 @@ message GetPointWithdrawalConfigResponse {
string policy_instance_code = 5;
}
// VipBenefitPreviewItem Flutter
//
message VipBenefitPreviewItem {
string preview_id = 1;
string title = 2;
// media_type: image/animationanimation 使 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
service WalletCronService {
rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse);
@ -3004,6 +3061,7 @@ service WalletService {
rpc GrantResourceGroup(GrantResourceGroupRequest) returns (ResourceGrantResponse);
rpc GrantPinnedResourceGroup(GrantPinnedResourceGroupRequest) returns (ResourceGrantResponse);
rpc RevokeResourceGrant(RevokeResourceGrantRequest) returns (ResourceGrantResponse);
rpc RevokeUserResource(RevokeUserResourceRequest) returns (RevokeUserResourceResponse);
rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse);
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
rpc UnequipUserResource(UnequipUserResourceRequest) returns (UnequipUserResourceResponse);

View File

@ -292,6 +292,7 @@ const (
WalletService_GrantResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResourceGroup"
WalletService_GrantPinnedResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantPinnedResourceGroup"
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_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
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)
GrantPinnedResourceGroup(ctx context.Context, in *GrantPinnedResourceGroupRequest, 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)
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, 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
}
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) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListUserResourcesResponse)
@ -1830,6 +1842,7 @@ type WalletServiceServer interface {
GrantResourceGroup(context.Context, *GrantResourceGroupRequest) (*ResourceGrantResponse, error)
GrantPinnedResourceGroup(context.Context, *GrantPinnedResourceGroupRequest) (*ResourceGrantResponse, error)
RevokeResourceGrant(context.Context, *RevokeResourceGrantRequest) (*ResourceGrantResponse, error)
RevokeUserResource(context.Context, *RevokeUserResourceRequest) (*RevokeUserResourceResponse, error)
ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error)
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
UnequipUserResource(context.Context, *UnequipUserResourceRequest) (*UnequipUserResourceResponse, error)
@ -2072,6 +2085,9 @@ func (UnimplementedWalletServiceServer) GrantPinnedResourceGroup(context.Context
func (UnimplementedWalletServiceServer) RevokeResourceGrant(context.Context, *RevokeResourceGrantRequest) (*ResourceGrantResponse, error) {
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) {
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)
}
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) {
in := new(ListUserResourcesRequest)
if err := dec(in); err != nil {
@ -4799,6 +4833,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "RevokeResourceGrant",
Handler: _WalletService_RevokeResourceGrant_Handler,
},
{
MethodName: "RevokeUserResource",
Handler: _WalletService_RevokeUserResource_Handler,
},
{
MethodName: "ListUserResources",
Handler: _WalletService_ListUserResources_Handler,

View File

@ -2,6 +2,14 @@
本文定义 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. 通用约定
- 地址前缀:`/api/v1`,实际 host 按环境配置。
@ -82,6 +90,20 @@ class VipEntitlementSnapshot {
return has('online_global_notice') &&
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)` 决定正常态/锁态和升级引导 | 点击后的写接口再次鉴权 |
| 装扮权益 | 同时检查权益和服务端资源字段;素材为空时使用无图兜底 | 保存/佩戴仍调用资源 owner 接口 |
| 进房通知 | 本人的设置页读 store房间内展示读 Join/房间 IM 快照 | room-service 在 Join 时校验 |
| 防踢、防禁言 | 不根据目标用户本地 VIP 快照禁用管理按钮 | 正常调用房管接口,处理 `PERMISSION_DENIED` |
| 防踢、防禁言 | 不根据目标用户本地 VIP 快照禁用管理按钮 | 正常调用房管接口,分别处理 `VIP_ANTI_KICK``VIP_ANTI_MUTE` |
| 金币返现 | `daily_coin_rebate` 可用于显示入口,金额/状态不在本地推导 | current/statuses/claim 接口决定资格和入账 |
| 上线全服通知 | 权益和开关满足时发起 `/vip/online-notice` | activity/notice 链路异步投递 |
@ -176,7 +198,7 @@ final benefitHandlers = <String, VipBenefitHandler>{
| 体验卡背包 | `GET /api/v1/users/me/resources?resource_type=vip_trial_card` | 只更新卡列表 |
| 佩戴体验卡 | `POST /api/v1/vip/trial-cards/{entitlement_id}/equip` | 成功后使用响应 `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/*` | 使用服务端记录和余额回执 |
| 上线全服通知 | `POST /api/v1/vip/online-notice` | 只创建异步播报事件 |
@ -250,6 +272,22 @@ final benefitHandlers = <String, VipBenefitHandler>{
"auto_equip": false,
"sort_order": 110,
"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,
"updated_at_ms": 0
}
@ -302,6 +340,9 @@ final benefitHandlers = <String, VipBenefitHandler>{
"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
}
}
@ -412,7 +453,8 @@ final benefitHandlers = <String, VipBenefitHandler>{
"execution_scope": "room",
"auto_equip": false,
"sort_order": 290,
"metadata_json": ""
"metadata_json": "",
"presentation": null
}
],
"evaluated_at_ms": 1780101000000,
@ -426,6 +468,9 @@ final benefitHandlers = <String, VipBenefitHandler>{
"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
}
}
@ -447,7 +492,7 @@ final benefitHandlers = <String, VipBenefitHandler>{
- 当体验卡被佩戴时,不限制卡等级高低,它会覆盖付费 VIP 成为 `effective_vip`;卸下或过期后自然回落到仍有效的 `paid_vip`
- 体验卡状态下,`daily_coin_rebate` 不会进入 `effective_benefits`。Flutter 不要只看卡等级猜测权益。
- 无 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. 购买、续期和升级
@ -490,6 +535,12 @@ final benefitHandlers = <String, VipBenefitHandler>{
},
"coin_spent": 4000,
"coin_balance_after": 6000,
"coin_balance": {
"asset_type": "COIN",
"available_amount": 6000,
"frozen_amount": 0,
"version": 18
},
"reward_items": [],
"program_config": {
"app_code": "fami",
@ -523,6 +574,16 @@ final benefitHandlers = <String, VipBenefitHandler>{
"app_code": "fami",
"program_type": "tiered_privilege_v1",
"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 高级升级:新截止时间 = 购买时间 + `duration_ms`,旧低等级剩余时间丢弃。例如 VIP3 剩 15 天购买 VIP4 30 天,结果是 VIP4 30 天。
- 购买更低等级由后端拒绝,错误 `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` 为准。
- 购买成功后用 `coin_balance.version` 防止旧响应覆盖新余额。`version=0` 只可能来自升级前的历史幂等订单,必须刷新钱包余额。
## 9. 体验卡背包列表
@ -660,17 +722,73 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
"program_config": {
"app_code": "fami",
"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": {
"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": {
"user_id": 10001,
"level": 4,
"name": "VIP4",
"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
}
@ -710,22 +828,67 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
"unequipped": true,
"program_config": {
"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": {
"effective_source": "paid",
"paid_vip": {
"user_id": 10001,
"level": 3,
"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,
"effective_vip": {
"user_id": 10001,
"level": 3,
"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
}
@ -898,7 +1061,7 @@ Fami`trial_card`)发一张 30 天体验卡到背包,不自动佩戴:
- 用 `event_id` 去重。重连/重复 Join 仍可收到进房展示事件,不要以本地 presence 猜测是否展示。
- 进房 VIP 查询失败时后端会降级为普通进房,不阻断进房主链路。
## 14. VIP 通知开关
## 14. VIP 功能开关
### 接口地址
@ -909,17 +1072,15 @@ Fami`trial_card`)发一张 30 天体验卡到背包,不自动佩戴:
### 参数
GET 无参数。PATCH 是局部更新,个字段至少传一个;显式 `false` 不能被当成未传:
GET 无参数。PATCH 是局部更新,5 个字段至少传一个;显式 `false` 不能被当成未传:
```json
{
"room_entry_notice_enabled": false
}
```
```json
{
"online_global_notice_enabled": true
"room_entry_notice_enabled": false,
"online_global_notice_enabled": true,
"hide_profile_data_enabled": false,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true
}
```
@ -940,6 +1101,9 @@ GET
"user_id": 10001,
"room_entry_notice_enabled": true,
"online_global_notice_enabled": false,
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 1780300000000
},
"evaluated_at_ms": 1780300001000
@ -960,6 +1124,9 @@ PATCH
"user_id": 10001,
"room_entry_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
},
"server_time_ms": 1780300010000
@ -967,7 +1134,7 @@ PATCH
}
```
用户从未修改过时,两项默认为 `true``updated_at_ms=0`。PATCH 只改本次提交的字段,不会覆盖另一项。
用户从未修改过时,5 项均默认为 `true``updated_at_ms=0`。PATCH 只改本次提交的字段,不会覆盖其他项。
### 相关 IM
@ -978,6 +1145,7 @@ PATCH
- 开关是用户偏好,不是权益;无 VIP 时也可保存,但不会产生进房或上线播报。
- 进房展示必须同时满足 `effective_benefits``room_entry_notice``room_entry_notice_enabled=true`
- 上线展示必须同时满足 `online_global_notice` 权益和开关;最终以触发接口的服务端判定为准。
- 隐藏个人数据、匿名访问主页、榜单隐身分别要求对应权益和同名开关同时成立;客户端不得只看设置值授权。
- 设置按 App 隔离Fami 的修改不影响 Lalu。
## 15. 每日 VIP 金币返现
@ -1246,7 +1414,7 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
- 同进程同 UTC 日首次进入 App`/vip/me` 中具有 `online_global_notice` 资格、`online_global_notice_enabled=true` 时触发。成功后当日不再产生新 command。
- 超时/5xx 可用同一 `command_id` 重试;不能在同进程同日换 command 规避幂等。
- 杀掉进程后内存清空,新进程产生新 `process_boot_id``command_id`,同日可再播一次,这是已确认产品规则。
- 权益过期、后台停用、体验卡不允许或用户关闭开关时,服务端返回 `PERMISSION_DENIED`Flutter 不做本地飘屏,可重拉 `/vip/me`
- 权益过期、后台停用、体验卡不允许或用户关闭开关时,服务端返回 `VIP_BENEFIT_REQUIRED`Flutter 不做本地飘屏,可重拉 `/vip/me`
- 不把“每日一次”放到服务端持久化去重,否则无法满足杀进程后同日可再展示。
## 17. 权益场景和完成边界
@ -1258,10 +1426,10 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
| 同级续期 | 不在本地计算,以响应为准 | 已强制,从旧截止时间累加 |
| 体验卡自由切换 | 按 `entitlement_id` 佩戴,每次直接替换全局 `state` | 已强制,不限等级,不作废旧卡,不改绝对截止时间 |
| 体验卡权益 | 只渲染 `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不发全服 |
| 防踢 | 房管操作返回 `PERMISSION_DENIED` 时显示不可踢,不做本地预判 | 已在 room-service 强制;只阻止普通房主/管理员的 KickUser |
| 防禁言 | 房管操作返回 `PERMISSION_DENIED` 时显示不可禁言,解禁始终可发起 | 已在 room-service 强制;只阻止普通房主/管理员新增禁言 |
| 防踢 | 房管操作返回 `VIP_ANTI_KICK` 时显示不可踢,不做本地预判 | 已在 room-service 强制;只阻止普通房主/管理员的 KickUser |
| 防禁言 | 房管操作返回 `VIP_ANTI_MUTE` 时显示不可禁言,解禁始终可发起 | 已在 room-service 强制;只阻止普通房主/管理员新增禁言 |
| 平台封禁/风控/超级管理 | Flutter 不作 VIP 免责提示 | 独立 `SystemEvictUser` 治理链路会绕过防踢VIP 不能绕过平台治理 |
| 自定义房间背景 | 按现有房间背景页面调用 | `tiered_privilege_v1` 已由 room-service 强制校验 `custom_room_background` |
| 金币返现 | 消费 system inbox批量恢复状态手动领取后使用响应余额 | 已完成 UTC 日资格、异步系统消息、24 小时窗口和 wallet 原子入账;体验卡不参与 |
@ -1277,4 +1445,4 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
5. 所有房间、个人页和消息展示都使用 `effective_vip`,不要用 `paid_vip` 覆盖体验卡展示。
6. system inbox 遇到 `action_type=vip_coin_rebate_claim` 时,批量用 statuses 恢复真实状态;领取成功后替换余额与返现状态。
7. IM 登录并加入全局播报群后,按进程内 UTC 日规则触发 `/vip/online-notice`;收到 `vip_online_notice` 时用 `event_id` 去重。
8. 修改通知开关成功后,用 PATCH 响应替换本地 `user_settings`,不修改 `effective_benefits`
8. 修改 VIP 功能开关成功后,用 PATCH 响应替换本地 `user_settings`,不修改 `effective_benefits`

View File

@ -85,12 +85,19 @@ Fami 的 VIP1VIP9 初始全部为 `disabled`。30 天只是不产生零时长
### 用户功能开关
`user_vip_settings``(app_code,user_id)` 保存个用户偏好:
`user_vip_settings``(app_code,user_id)` 保存 5 个用户偏好:
- `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 的 VIP1VIP9 初始全部为 `disabled`。30 天只是不产生零时长
- `effective_vip`:最终展示和权限使用的 VIP
- `effective_source``paid``trial``none`
- `effective_benefits`:最终身份拥有的权益资格;可关闭的通知类权益还要与 `user_settings` 做 AND
- `evaluated_at_ms`:本次状态合并的服务端时间;
- `program_config`:当前 App 规则;
- `user_settings`:当前 App 下的进房/上线通知偏好。
- `user_settings`:当前 App 下完整的 5 项功能偏好。
`GET /vip/me``GET /vip/packages`、购买、佩戴体验卡和卸下体验卡返回完全同构的 `VipState`。写接口成功响应不是 patchFlutter 必须原子替换 store。灰度期间如旧节点返回缺字段状态客户端应丢弃该局部对象并重拉 `/vip/me`
program 启用时的优先级固定为:`trial_card_enabled=true` 且体验卡有效并已佩戴时覆盖展示;否则使用有效付费会员;二者都无效则为 `none`。关闭体验卡开关只停止它参与最终权限合并,背包和佩戴原始事实仍保留;关闭整个 program 时付费与卡片原始事实仍可查询,但最终身份为 `none`、不下发权益。
@ -156,8 +166,8 @@ program 启用时的优先级固定为:`trial_card_enabled=true` 且体验卡
- `GET /vip/packages`program、可购买等级、每级权益和完整 state。
- `GET /vip/me`:付费、体验卡、最终 VIP、权益资格和用户功能开关。
- `POST /vip/purchase`:请求 `{command_id,level}`;价格、时长和资源由服务端读取。
- `GET|PATCH /vip/settings`:读取或局部修改进房/上线通知偏好;空 PATCH 拒绝。
- `POST /vip/purchase`:请求 `{command_id,level}`;价格、时长和资源由服务端读取;响应 `coin_balance` 是带 `version` 的账后快照,旧 `coin_balance_after` 仅兼容历史客户端
- `GET|PATCH /vip/settings`:读取或局部修改 5 项 VIP 功能偏好;空 PATCH 拒绝。
- `POST /vip/online-notice`:请求 `{command_id}`,服务端校验权益、开关和资料后写全服播报 outbox。
- `GET /vip/coin-rebates/current`:返回当前 UTC 日返现资格;无资格或 cron 尚未生成时 `found=false`
- `POST /vip/coin-rebates/statuses`:按最多 100 个 `rebate_ids` 恢复系统消息真实状态,或用受限分页查历史。
@ -175,6 +185,8 @@ program 启用时的优先级固定为:`trial_card_enabled=true` 且体验卡
- `POST /v1/admin/activity/vip-trial-card-grants`
- `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 展示链路执行。

View File

@ -35,6 +35,20 @@ class VipEntitlementState {
return has('online_global_notice') &&
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`,不得合并不同版本的权益数组 |
| VIP 到期倒计时结束 | 可更新页面显示,但执行功能前仍以服务端接口结果为准 |
购买、佩戴体验卡、卸下体验卡成功时,`data.state``GET /api/v1/vip/me``data.state` **完全同构**不是局部补丁。Flutter 必须整体替换 VIP store禁止按字段合并。若灰度期间收到缺少下文任一顶层字段的旧节点响应应放弃该响应中的 `state` 并立即重拉 `/vip/me`
### 1.4 Fami 初始等级权益
下表只用于识别初始配置。Flutter 实际必须渲染 `/vip/packages` 返回的数据。
@ -89,6 +105,52 @@ class VipEntitlementState {
- `request_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 套餐
### 接口地址
@ -144,7 +206,23 @@ class VipEntitlementState {
"execution_scope": "room",
"auto_equip": false,
"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
@ -165,17 +243,27 @@ class VipEntitlementState {
},
"state": {
"paid_vip": {
"user_id": 10001,
"level": 3,
"name": "VIP3",
"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,
"effective_vip": {
"user_id": 10001,
"level": 3,
"name": "VIP3",
"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_benefits": [
@ -185,9 +273,27 @@ class VipEntitlementState {
}
],
"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": {
"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
}
}
@ -235,11 +341,13 @@ class VipEntitlementState {
},
"state": {
"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
},
@ -255,14 +363,20 @@ class VipEntitlementState {
"duration_ms": 2592000000,
"effective_at_ms": 1780100000000,
"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": {
"user_id": 10001,
"level": 8,
"name": "VIP8",
"active": true,
"started_at_ms": 1780100000000,
"expires_at_ms": 1782692000000,
"updated_at_ms": 1780100000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
},
@ -279,13 +393,22 @@ class VipEntitlementState {
"execution_scope": "room",
"auto_equip": false,
"sort_order": 290,
"metadata_json": ""
"metadata_json": "",
"presentation": null
}
],
"evaluated_at_ms": 1780101000000,
"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": {
@ -293,6 +416,9 @@ class VipEntitlementState {
"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
}
}
@ -346,35 +472,81 @@ class VipEntitlementState {
},
"coin_spent": 4000,
"coin_balance_after": 6000,
"coin_balance": {
"asset_type": "COIN",
"available_amount": 6000,
"frozen_amount": 0,
"version": 18
},
"reward_items": [],
"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
},
"state": {
"effective_source": "paid",
"paid_vip": {
"user_id": 10001,
"level": 4,
"name": "VIP4",
"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,
"effective_vip": {
"user_id": 10001,
"level": 4,
"name": "VIP4",
"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": [
{
"benefit_code": "custom_room_background",
"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 高等级购买:立即替换低等级,从购买时间重新计时,低等级剩余时间丢弃。
- 低等级购买:服务端拒绝。
- 购买时佩戴体验卡:付费 VIP 会更新,但 `effective_vip` 仍可能来自体验卡,必须使用响应 `state`
- 钱包缓存只使用 `coin_balance`。应用时比较 `version`,低版本响应不得覆盖本地高版本余额;`coin_balance_after` 仅保留旧客户端兼容。
- 历史幂等订单在升级前没有记录版本时可能返回 `coin_balance.version=0`Flutter 必须调用现有钱包余额接口刷新,不得把版本 0 写入余额 store。
## 6. VIP 体验卡
@ -473,22 +647,92 @@ class VipEntitlementState {
"duration_ms": 1728000000,
"effective_at_ms": 1780200000000,
"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": {
"app_code": "fami",
"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": {
"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": {
"user_id": 10001,
"level": 4,
"name": "VIP4",
"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
}
@ -516,22 +760,67 @@ class VipEntitlementState {
"unequipped": true,
"program_config": {
"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": {
"effective_source": "paid",
"paid_vip": {
"user_id": 10001,
"level": 3,
"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,
"effective_vip": {
"user_id": 10001,
"level": 3,
"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
}
@ -543,7 +832,7 @@ class VipEntitlementState {
- 每张卡的剩余时间按绝对 `expires_at_ms` 计算。
- 佩戴和卸下成功后立即使用响应 `state` 替换本地 VIP 状态。
## 7. VIP 通知开关
## 7. VIP 功能开关
### 7.1 查询开关
@ -568,6 +857,9 @@ class VipEntitlementState {
"user_id": 10001,
"room_entry_notice_enabled": true,
"online_global_notice_enabled": false,
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 1780300000000
},
"evaluated_at_ms": 1780300001000
@ -588,7 +880,10 @@ class VipEntitlementState {
```json
{
"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,
"room_entry_notice_enabled": false,
"online_global_notice_enabled": true,
"hide_profile_data_enabled": false,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"updated_at_ms": 1780300010000
},
"server_time_ms": 1780300010000
@ -612,6 +910,8 @@ class VipEntitlementState {
}
```
5 个字段都使用 PATCH 语义:不传表示保持原值,显式 `false` 表示关闭。没有持久记录时均默认 `true`;设置只限制用户主动启用对应效果,不会单独授予 VIP 权益。服务端执行时使用“当前有效权益 AND 用户设置”判定Flutter 不能只看开关。
## 8. 每日 VIP 金币返现
### 8.1 查询当前返现
@ -1050,3 +1350,35 @@ Flutter 将 `action_param` 解析为 JSON打开页面后调用 `/vip/coin-reb
接口:`GET /api/v1/im/usersig`
Flutter 使用返回的 UserSig 登录腾讯云 IM并加入响应 `join_groups``type=global_broadcast` 的群组后,再消费 `vip_online_notice`
## 13. 稳定业务错误码
失败仍使用统一 envelopeFlutter 只判断 `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`。因此客户端不应实现该错误分支。

View File

@ -84,6 +84,14 @@ var catalog = map[Code]Spec{
VIPLevelNotFound: spec(codes.NotFound, httpStatusNotFound, VIPLevelNotFound, "not found"),
VIPLevelDisabled: spec(codes.FailedPrecondition, httpStatusConflict, VIPLevelDisabled, "vip level is disabled"),
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"),
VIPCoinRebateNotFound: spec(codes.NotFound, httpStatusNotFound, VIPCoinRebateNotFound, "vip coin rebate not found"),
VIPCoinRebateExpired: spec(codes.FailedPrecondition, httpStatusConflict, VIPCoinRebateExpired, "vip coin rebate expired"),

View File

@ -102,6 +102,22 @@ const (
VIPLevelDisabled Code = "VIP_LEVEL_DISABLED"
// VIPDowngradeNotAllowed 表示用户当前有效 VIP 等级高于本次购买目标等级。
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 Code = "VIP_RECHARGE_REQUIRED"
// VIPCoinRebateNotFound 表示返现不存在,或不属于当前 App/用户。

View File

@ -135,6 +135,14 @@ func TestCatalogMappings(t *testing.T) {
publicCode: string(CPAlreadyExistsOther),
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",
code: RegionMismatch,

View File

@ -29,12 +29,12 @@ SQL_FILES=(
)
# initdb only creates missing tables; a reused local MySQL volume therefore
# needs incremental migrations as well. Keep the owned user-service migrations
# in this local bootstrap path so authentication schema changes (for example
# login_audit client-version fields) cannot leave a locally logged-in client
# receiving a misleading 401 during token refresh.
USER_MIGRATION_FILES=(
# needs incremental migrations as well. Keep owner migrations here when reused
# volumes need more than CREATE TABLE IF NOT EXISTS can provide; otherwise new
# binaries may start against a structurally valid-looking but stale table.
INCREMENTAL_MIGRATION_FILES=(
"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
@ -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}"
done
for sql_file in "${USER_MIGRATION_FILES[@]}"; do
for sql_file in "${INCREMENTAL_MIGRATION_FILES[@]}"; do
if [[ ! -f "${sql_file}" ]]; then
printf 'skipping mysql migration because it is absent or not a regular file: %s\n' "${sql_file}"
continue

View File

@ -9,6 +9,9 @@ CREATE TABLE IF NOT EXISTS user_vip_settings (
user_id BIGINT NOT NULL COMMENT '用户 ID',
room_entry_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启进房通知',
online_global_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启上线全服通知',
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',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id)

View 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;

View File

@ -33,6 +33,7 @@ type Client interface {
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*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)
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, 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)
}
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) {
return c.client.EquipUserResource(ctx, req)
}

View File

@ -46,9 +46,9 @@ const (
// use an isolated credential lifecycle and can never exchange this row for a main-admin JWT.
type ExternalAdminAccount struct {
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:"-"`
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:"-"`
PermissionsJSON string `gorm:"column:permissions_json;type:json;not null" json:"-"`
PermissionRevision uint64 `gorm:"column:permission_revision;not null;default:1" json:"permissionRevision"`

View File

@ -827,6 +827,48 @@ func (h *Handler) RevokeResourceGrant(c *gin.Context) {
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) {
keyword := strings.TrimSpace(firstQuery(c, "user_id", "userId", "keyword"))
if keyword == "" {

View File

@ -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 {
gin.SetMode(gin.TestMode)
router := gin.New()
@ -458,6 +481,7 @@ type mockResourceWallet struct {
listResourceRequests []*walletv1.ListResourcesRequest
listedResources []*walletv1.Resource
grantRequests []*walletv1.GrantResourceRequest
revokeUserResourceRequests []*walletv1.RevokeUserResourceRequest
}
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
}
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) {
resource := m.resources[req.GetResourceId()]
if resource == nil {

View File

@ -235,6 +235,10 @@ type grantGroupRequest struct {
Reason string `json:"reason"`
}
type revokeUserResourceRequest struct {
Reason string `json:"reason"`
}
type resourceShopItemsRequest struct {
Items []resourceShopItemRequest `json:"items"`
}
@ -244,6 +248,7 @@ type resourceShopItemRequest struct {
ResourceID int64 `json:"resourceId"`
Status string `json:"status"`
DurationDays int32 `json:"durationDays"`
CoinPrice int64 `json:"coinPrice"`
EffectiveFromMS int64 `json:"effectiveFromMs"`
EffectiveToMS int64 `json:"effectiveToMs"`
SortOrder int32 `json:"sortOrder"`
@ -558,6 +563,7 @@ func resourceShopItemInputs(items []resourceShopItemRequest) []*walletv1.Resourc
ResourceId: item.ResourceID,
Status: strings.TrimSpace(item.Status),
DurationDays: item.DurationDays,
CoinPrice: item.CoinPrice,
EffectiveFromMs: item.EffectiveFromMS,
EffectiveToMs: item.EffectiveToMS,
SortOrder: item.SortOrder,

View File

@ -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/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/users/:user_id/resources/:entitlement_id/revoke", middleware.RequirePermission("resource-grant:revoke"), h.RevokeUserResource)
// VIP 赠送复用同一目标用户解析入口;仅有 vip-config:grant 的运营也必须能把短号解析成内部 user_id。
protected.GET("/admin/resource-grants/target", middleware.RequireAnyPermission("resource-grant:create", "vip-config:grant"), h.LookupResourceGrantTarget)
protected.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), h.ListResourceGrants)

View File

@ -134,6 +134,31 @@ type vipBenefitDTO struct {
MetadataJSON string `json:"metadataJson"`
CreatedAtMS int64 `json:"createdAtMs"`
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 {
@ -163,6 +188,18 @@ type vipStateDTO struct {
EffectiveBenefits []vipBenefitDTO `json:"effectiveBenefits"`
EvaluatedAtMS int64 `json:"evaluatedAtMs"`
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 {
@ -470,6 +507,7 @@ func vipBenefitsFromProto(items []*walletv1.VipBenefit) []vipBenefitDTO {
MetadataJSON: item.GetMetadataJson(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
Presentation: vipBenefitPresentationFromProto(item.GetPresentation()),
})
}
return benefits
@ -489,9 +527,61 @@ func (item vipBenefitDTO) toProto() *walletv1.VipBenefit {
AutoEquip: item.AutoEquip,
SortOrder: item.SortOrder,
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 {
if resp == nil {
return grantVipDTO{}
@ -568,6 +658,22 @@ func vipStateFromProto(item *walletv1.VipState) vipStateDTO {
EffectiveBenefits: vipBenefitsFromProto(item.GetEffectiveBenefits()),
EvaluatedAtMS: item.GetEvaluatedAtMs(),
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(),
}
}

View File

@ -45,6 +45,12 @@ func TestGetProgramReturnsCamelCaseCompleteConfigAndBenefits(t *testing.T) {
MetadataJson: "{}",
CreatedAtMs: 101,
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,
},
@ -60,6 +66,8 @@ func TestGetProgramReturnsCamelCaseCompleteConfigAndBenefits(t *testing.T) {
`"updatedByAdminId":77`,
`"benefitCode":"anti_kick"`,
`"executionScope":"room"`,
`"description":"普通房管无法踢出"`,
`"compositionType":"none"`,
`"serverTimeMs":300`,
} {
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},
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)
if recorder.Code != http.StatusOK {
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" {
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`) {
t.Fatalf("level response must include program config: %s", recorder.Body.String())
}

View File

@ -184,6 +184,9 @@ func TestTriggerVIPOnlineNoticeDenialDoesNotReadProfileOrBroadcast(t *testing.T)
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())
}
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) {
@ -214,12 +217,16 @@ func TestVIPSettingsHTTPContractPreservesExplicitFalseAndAuthScope(t *testing.T)
UserSettings: &walletv1.VipUserSettings{
AppCode: "fami", UserId: 42, RoomEntryNoticeEnabled: true,
OnlineGlobalNoticeEnabled: false, UpdatedAtMs: 1_799_999_999_000,
HideProfileDataEnabled: true, AnonymousProfileVisitEnabled: false,
LeaderboardInvisibleEnabled: true,
},
}},
updateVIPSettingsResp: &walletv1.UpdateMyVipSettingsResponse{
Settings: &walletv1.VipUserSettings{
AppCode: "fami", UserId: 42, RoomEntryNoticeEnabled: false,
OnlineGlobalNoticeEnabled: false, UpdatedAtMs: 1_800_000_000_100,
HideProfileDataEnabled: false, AnonymousProfileVisitEnabled: false,
LeaderboardInvisibleEnabled: true,
},
ServerTimeMs: 1_800_000_000_200,
},
@ -244,6 +251,9 @@ func TestVIPSettingsHTTPContractPreservesExplicitFalseAndAuthScope(t *testing.T)
UserID int64 `json:"user_id"`
RoomEntryNoticeEnabled bool `json:"room_entry_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"`
EvaluatedAtMS int64 `json:"evaluated_at_ms"`
} `json:"data"`
@ -251,11 +261,11 @@ func TestVIPSettingsHTTPContractPreservesExplicitFalseAndAuthScope(t *testing.T)
if err := json.NewDecoder(getRecorder.Body).Decode(&getEnvelope); err != nil {
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)
}
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)
patchRecorder := httptest.NewRecorder()
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())
}
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)
}
@ -505,7 +515,13 @@ func TestListVIPPackagesAddsProgramStateAndLevelBenefits(t *testing.T) {
CurrentVip: &walletv1.UserVip{UserId: 42, Level: 2, Active: true},
Packages: []*walletv1.VipLevel{{
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},
ProgramConfig: config,
@ -538,6 +554,12 @@ func TestListVIPPackagesAddsProgramStateAndLevelBenefits(t *testing.T) {
ConfigVersion int64 `json:"config_version"`
Benefits []struct {
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:"packages"`
} `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" {
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) {
config := &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", UpgradeExpiryPolicy: "replace_from_now", ConfigVersion: 8}
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,
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)
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 {
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 {
t.Fatalf("PurchaseVip request mismatch: %+v", req)
}
@ -585,12 +612,16 @@ func TestPurchaseVIPKeepsLegacyResultAndAddsState(t *testing.T) {
State struct {
EffectiveSource string `json:"effective_source"`
} `json:"state"`
CoinBalance struct {
AvailableAmount int64 `json:"available_amount"`
Version int64 `json:"version"`
} `json:"coin_balance"`
} `json:"data"`
}
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
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)
}
}
@ -612,6 +643,7 @@ func TestEquipVIPTrialCardUsesAuthenticatedEntitlement(t *testing.T) {
if recorder.Code != http.StatusOK {
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" {
t.Fatalf("EquipVipTrialCard request mismatch: %+v", req)
}
@ -651,6 +683,7 @@ func TestUnequipVIPTrialCardRestoresPaidState(t *testing.T) {
if recorder.Code != http.StatusOK {
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 {
t.Fatalf("UnequipVipTrialCard request mismatch: %+v", req)
}
@ -672,3 +705,22 @@ func TestUnequipVIPTrialCardRestoresPaidState(t *testing.T) {
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)
}
}
}

View File

@ -229,6 +229,31 @@ type vipBenefitData struct {
MetadataJSON string `json:"metadata_json"`
CreatedAtMS int64 `json:"created_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 {
@ -266,6 +291,9 @@ type vipUserSettingsData struct {
UserID int64 `json:"user_id"`
RoomEntryNoticeEnabled bool `json:"room_entry_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"`
}

View File

@ -11,6 +11,7 @@ import (
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
"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()),
"coin_spent": resp.GetCoinSpent(),
"coin_balance_after": resp.GetCoinBalanceAfter(),
"coin_balance": balanceFromProto(resp.GetCoinBalance()),
"reward_items": vipRewardItemsFromProto(resp.GetRewardItems()),
"program_config": vipProgramConfigFromProto(resp.GetState().GetProgramConfig()),
"state": vipStateFromProto(resp.GetState()),
@ -230,7 +232,8 @@ func (h *Handler) triggerVIPOnlineNotice(writer http.ResponseWriter, request *ht
if !benefitResp.GetAllowed() {
// 客户端展示态可能滞后,执行前必须重新查 effective benefit体验卡排除、过期和后台停用
// 都以 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
}
equippedResp, err := h.walletClient.BatchGetUserEquippedResources(ctx, &walletv1.BatchGetUserEquippedResourcesRequest{
@ -434,11 +437,38 @@ func vipBenefitsFromProto(items []*walletv1.VipBenefit) []vipBenefitData {
MetadataJSON: item.GetMetadataJson(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
Presentation: vipBenefitPresentationFromProto(item.GetPresentation()),
})
}
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 {
if card == nil {
return nil
@ -469,6 +499,8 @@ func vipStateFromProto(state *walletv1.VipState) vipStateData {
EffectiveBenefits: []vipBenefitData{},
UserSettings: vipUserSettingsData{
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 {
if settings == nil {
// 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{
AppCode: settings.GetAppCode(), UserID: settings.GetUserId(),
RoomEntryNoticeEnabled: settings.GetRoomEntryNoticeEnabled(),
OnlineGlobalNoticeEnabled: settings.GetOnlineGlobalNoticeEnabled(),
HideProfileDataEnabled: settings.GetHideProfileDataEnabled(),
AnonymousProfileVisitEnabled: settings.GetAnonymousProfileVisitEnabled(),
LeaderboardInvisibleEnabled: settings.GetLeaderboardInvisibleEnabled(),
UpdatedAtMS: settings.GetUpdatedAtMs(),
}
}

View File

@ -13,6 +13,9 @@ type vipSettingsPatchBody struct {
// 指针保留 PATCH 的三态:未提交、显式 true、显式 false。
RoomEntryNoticeEnabled *bool `json:"room_entry_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。
@ -70,7 +73,9 @@ func (h *Handler) patchVIPSettings(writer http.ResponseWriter, request *http.Req
if !httpkit.Decode(writer, request, &body) {
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")
return
}
@ -79,6 +84,9 @@ func (h *Handler) patchVIPSettings(writer http.ResponseWriter, request *http.Req
RequestId: httpkit.RequestIDFromContext(ctx), AppCode: appcode.FromContext(ctx),
UserId: auth.UserIDFromContext(ctx), RoomEntryNoticeEnabled: body.RoomEntryNoticeEnabled,
OnlineGlobalNoticeEnabled: body.OnlineGlobalNoticeEnabled,
HideProfileDataEnabled: body.HideProfileDataEnabled,
AnonymousProfileVisitEnabled: body.AnonymousProfileVisitEnabled,
LeaderboardInvisibleEnabled: body.LeaderboardInvisibleEnabled,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)

View File

@ -118,7 +118,7 @@ func (s *Service) requireCustomRoomBackgroundBenefit(ctx context.Context, reques
return nil
}
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
}
@ -131,7 +131,14 @@ func (s *Service) requireTargetWithoutVIPBenefit(ctx context.Context, requestID
return err
}
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
}

View File

@ -118,7 +118,7 @@ func TestTieredVIPCustomRoomBackgroundRequiresEffectiveBenefit(t *testing.T) {
Meta: vipRoomMeta(roomID, ownerID, "save-denied"),
RoomId: roomID,
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)
}
@ -136,7 +136,7 @@ func TestTieredVIPCustomRoomBackgroundRequiresEffectiveBenefit(t *testing.T) {
if _, err := svc.SetRoomBackground(ctx, &roomv1.SetRoomBackgroundRequest{
Meta: vipRoomMeta(roomID, ownerID, "set-denied"),
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)
}
}
@ -161,13 +161,13 @@ func TestVIPModerationProtectionBlocksRoomManagersButNotSystemEvict(t *testing.T
Meta: vipRoomMeta(roomID, ownerID, "mute"),
TargetUserId: targetID,
Muted: true,
}); !xerr.IsCode(err, xerr.PermissionDenied) {
}); !xerr.IsCode(err, xerr.VIPAntiMute) {
t.Fatalf("anti_mute must block ordinary room owner: %v", err)
}
if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
Meta: vipRoomMeta(roomID, ownerID, "kick"),
TargetUserId: targetID,
}); !xerr.IsCode(err, xerr.PermissionDenied) {
}); !xerr.IsCode(err, xerr.VIPAntiKick) {
t.Fatalf("anti_kick must block ordinary room owner: %v", err)
}

View File

@ -1153,6 +1153,7 @@ CREATE TABLE IF NOT EXISTS resource_shop_items (
resource_id BIGINT NOT NULL COMMENT '售卖资源 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
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 ms0 表示立即生效',
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间UTC epoch ms0 表示长期有效',
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',
created_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_effective (app_code, status, effective_from_ms, effective_to_ms)
) 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',
room_entry_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',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id)

View File

@ -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;

View File

@ -128,6 +128,36 @@ type VipBenefit struct {
MetadataJSON string
CreatedAtMS 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 资源组权益的轻量展示投影。
@ -205,24 +235,35 @@ type VipUserSettings struct {
UserID int64
RoomEntryNoticeEnabled bool
OnlineGlobalNoticeEnabled bool
HideProfileDataEnabled bool
AnonymousProfileVisitEnabled bool
LeaderboardInvisibleEnabled bool
UpdatedAtMS int64
}
// DefaultVipUserSettings 返回尚未物化时的产品默认:两项通知均开启UpdatedAtMS=0。
// DefaultVipUserSettings 返回尚未物化时的产品默认:五项能力均开启UpdatedAtMS=0。
func DefaultVipUserSettings(appCode string, userID int64) VipUserSettings {
return VipUserSettings{
AppCode: appCode, UserID: userID,
RoomEntryNoticeEnabled: true, OnlineGlobalNoticeEnabled: true,
HideProfileDataEnabled: true, AnonymousProfileVisitEnabled: true,
LeaderboardInvisibleEnabled: true,
}
}
// AllowsBenefitSetting 只处理用户可关闭的项权益;其它权益不受偏好表影响。
// AllowsBenefitSetting 只处理用户可关闭的项权益;其它权益不受偏好表影响。
func (settings VipUserSettings) AllowsBenefitSetting(benefitCode string) bool {
switch strings.ToLower(strings.TrimSpace(benefitCode)) {
case VipBenefitCodeRoomEntryNotice:
return settings.RoomEntryNoticeEnabled
case VipBenefitCodeOnlineGlobalNotice:
return settings.OnlineGlobalNoticeEnabled
case VipBenefitCodeHideProfileData:
return settings.HideProfileDataEnabled
case VipBenefitCodeAnonymousProfileVisit:
return settings.AnonymousProfileVisitEnabled
case VipBenefitCodeLeaderboardInvisible:
return settings.LeaderboardInvisibleEnabled
default:
return true
}
@ -256,6 +297,7 @@ type PurchaseVipReceipt struct {
Vip UserVip
CoinSpent int64
CoinBalanceAfter int64
CoinBalance AssetBalance
RewardItems []VipRewardItem
State VipState
}
@ -349,6 +391,9 @@ type UpdateVipUserSettingsCommand struct {
UserID int64
RoomEntryNoticeEnabled *bool
OnlineGlobalNoticeEnabled *bool
HideProfileDataEnabled *bool
AnonymousProfileVisitEnabled *bool
LeaderboardInvisibleEnabled *bool
}
// VipDailyCoinRebateRun 是单 App、单 UTC 日唯一的配置快照与分页游标。

View File

@ -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
}

View File

@ -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
}

View File

@ -30,6 +30,15 @@ type ListUserResourcesQuery struct {
ActiveOnly bool
}
type RevokeUserResourceCommand struct {
RequestID string
AppCode string
UserID int64
EntitlementID string
Reason string
OperatorUserID int64
}
type EquipUserResourceCommand struct {
// RequestID 在通用背包命中 VIP 体验卡时作为专用状态机的 outbox 幂等事件键;
// 普通装扮仍不把 request_id 当业务幂等键。

View File

@ -47,6 +47,8 @@ type ResourceShopItemInput struct {
ResourceID int64
Status string
DurationDays int32
// CoinPrice 新建为 0 时使用资源列表价,更新已有规格为 0 时保留当前售价;落库后各规格价格互相独立。
CoinPrice int64
EffectiveFromMS int64
EffectiveToMS int64
SortOrder int32

View File

@ -215,6 +215,7 @@ type ResourceGrantStore interface {
// ResourceEquipmentStore 管理用户资源权益和穿戴状态。
type ResourceEquipmentStore interface {
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)
UnequipUserResource(ctx context.Context, command resourcedomain.UnequipUserResourceCommand) (resourcedomain.UnequipUserResourceResult, error)
BatchGetUserEquippedResources(ctx context.Context, query resourcedomain.BatchGetUserEquippedResourcesQuery) ([]resourcedomain.UserEquippedResources, error)

View File

@ -280,6 +280,15 @@ func (s *Service) ListUserResources(ctx context.Context, query resourcedomain.Li
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) {
if s.repository == nil {
return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")

View File

@ -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 验证撤回金币时余额不足也不能扣成负数。
func TestRevokeResourceGroupGrantClampsWalletDebitWhenBalanceInsufficient(t *testing.T) {
repository := mysqltest.NewRepository(t)
@ -5855,27 +5981,58 @@ func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) {
t.Fatalf("create shop resource failed: %v", err)
}
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,
Status: resourcedomain.StatusActive,
DurationDays: resourcedomain.ShopDurationThreeDays,
SortOrder: 1,
}},
CoinPrice: 250,
SortOrder: 2,
},
{
ResourceID: resource.ResourceID,
Status: resourcedomain.StatusActive,
DurationDays: resourcedomain.ShopDurationSevenDays,
CoinPrice: 600,
SortOrder: 3,
},
},
OperatorUserID: 90001,
})
if err != nil {
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{
CommandID: "cmd-shop-badge",
UserID: 53001,
ShopItemID: shopItems[0].ShopItemID,
ShopItemID: purchaseItem.ShopItemID,
})
if err != nil {
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)
}
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 {
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{
CommandID: "cmd-shop-badge",
UserID: 53001,
ShopItemID: shopItems[0].ShopItemID,
ShopItemID: purchaseItem.ShopItemID,
})
if err != nil {
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)
}
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 {
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)
}
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 {
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 {
t.Fatalf("vip membership mismatch: %+v", receipt.Vip)
}
@ -6624,7 +6840,7 @@ func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) {
if err != nil {
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)
}
if _, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
@ -6653,6 +6869,14 @@ func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) {
if !xerr.IsCode(err, xerr.VIPDowngradeNotAllowed) {
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) {

View File

@ -230,10 +230,12 @@ func (s *Service) UpdateAdminVipProgramConfig(ctx context.Context, command ledge
return s.repository.UpdateAdminVipProgramConfig(ctx, command)
}
// UpdateVipUserSettings 保存用户可关闭的 VIP 通知偏好。用户当前没有 VIP 也允许保存,
// UpdateVipUserSettings 保存用户可关闭的 VIP 展示与隐私偏好。用户当前没有 VIP 也允许保存,
// 但偏好不会授予权益;实际执行仍由 CheckVipBenefit 同时校验 effective benefit。
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")
}
if s.repository == nil {

View File

@ -144,7 +144,7 @@ func TestFamiVIPReplaceAndTrialCardSwitch(t *testing.T) {
if _, err := service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
AppCode: "fami", RequestID: "generic-equip-disabled", UserID: userID,
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)
}
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 {
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 验证幂等守卫发生在账户/会员锁之前:
@ -309,6 +322,9 @@ func TestVipPurchaseConcurrentReplayWithExactBalance(t *testing.T) {
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)
}
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 {
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)
_, 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)
}
}

View File

@ -24,7 +24,9 @@ func TestVipUserSettingsDefaultPartialUpdateAndAppScope(t *testing.T) {
if err != nil {
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)
}
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 {
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
settings, err = service.UpdateVipUserSettings(famiCtx, ledger.UpdateVipUserSettingsCommand{
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)
}
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)
}
@ -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)
}
}
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
settings, err = service.UpdateVipUserSettings(famiCtx, ledger.UpdateVipUserSettingsCommand{
AppCode: "fami", UserID: paidUserID, RoomEntryNoticeEnabled: &roomOn,

View File

@ -8,6 +8,7 @@ import (
"fmt"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
"math"
"strings"
@ -63,6 +64,109 @@ func (r *Repository) ListUserResources(ctx context.Context, query resourcedomain
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) {
if r == nil || r.db == nil {
return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")

View File

@ -9,11 +9,13 @@ import (
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
"sort"
"strings"
"time"
)
// ListResourceShopItems 返回 App 道具商店和后台配置共用的售卖视图;价格始终由资源一日价乘以售卖天数派生。
// ListResourceShopItems 返回 App 道具商店和后台配置共用的售卖规格;价格优先取规格自己的持久值。
// 滚动发布期间旧实例仍可能写出 0这类行必须沿用旧版“资源价 × 天数”,不能误套新建规格的默认价。
func (r *Repository) ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error) {
if r == nil || r.db == nil {
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()
}
// UpsertResourceShopItems 批量添加或重配售卖资源;同一个资源在商店只保留一个当前售卖配置。
// UpsertResourceShopItems 批量添加或重配售卖规格;同一资源可同时配置 1/3/7 天,
// 但相同资源和相同天数只能保留一个规格,购买端继续用独立 shop_item_id 选择具体档位。
func (r *Repository) UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error) {
if r == nil || r.db == nil {
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() }()
nowMs := time.Now().UnixMilli()
resourceIDs := make([]int64, 0, len(command.Items))
seen := make(map[int64]struct{}, len(command.Items))
for _, item := range command.Items {
if _, exists := seen[item.ResourceID]; exists {
return nil, xerr.New(xerr.InvalidArgument, "resource shop contains duplicate resource")
items := append([]resourcedomain.ResourceShopItemInput(nil), command.Items...)
// 所有写事务统一按 resource_id 再按规格身份加锁。购买同样先锁资源再锁商品,避免改价与购买形成反向等待。
sort.SliceStable(items, func(left, right int) bool {
if items[left].ResourceID != items[right].ResourceID {
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)
if err != nil {
return nil, err
@ -122,22 +141,69 @@ func (r *Repository) UpsertResourceShopItems(ctx context.Context, command resour
if err := validateResourceShopResource(resource); err != nil {
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, `
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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
duration_days = VALUES(duration_days),
coin_price = VALUES(coin_price),
effective_from_ms = VALUES(effective_from_ms),
effective_to_ms = VALUES(effective_to_ms),
sort_order = VALUES(sort_order),
updated_by_user_id = VALUES(updated_by_user_id),
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,
); err != nil {
if isMySQLDuplicateError(err) {
return nil, xerr.New(xerr.Conflict, "resource shop duration already exists")
}
return nil, err
}
resourceIDs = append(resourceIDs, item.ResourceID)
@ -167,7 +233,7 @@ func (r *Repository) SetResourceShopItemStatus(ctx context.Context, command reso
}
defer func() { _ = tx.Rollback() }()
item, err := r.getResourceShopItemTx(ctx, tx, command.ShopItemID, true)
item, err := r.lockResourceShopItemTx(ctx, tx, command.ShopItemID)
if err != nil {
return resourcedomain.ResourceShopItem{}, err
}
@ -221,7 +287,7 @@ func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resou
}
nowMs := time.Now().UnixMilli()
item, err := r.getResourceShopItemTx(ctx, tx, command.ShopItemID, true)
item, err := r.lockResourceShopItemTx(ctx, tx, command.ShopItemID)
if err != nil {
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)
}
// 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) {
query := resourceShopItemSelectSQL() + ` WHERE si.app_code = ? AND si.shop_item_id = ?`
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) {
var orderID string
var shopItemID int64
var durationDays int32
var priceCoin int64
var grantID string
var entitlementID string
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
WHERE app_code = ? AND wallet_transaction_id = ? AND user_id = ?`,
appcode.FromContext(ctx), transactionID, userID,
).Scan(&orderID, &shopItemID, &priceCoin, &grantID, &entitlementID)
).Scan(&orderID, &shopItemID, &durationDays, &priceCoin, &grantID, &entitlementID)
if err != nil {
return resourcedomain.ResourceShopPurchaseReceipt{}, err
}
@ -377,6 +477,9 @@ func (r *Repository) resourceShopPurchaseReceiptForTransaction(ctx context.Conte
if err != nil {
return resourcedomain.ResourceShopPurchaseReceipt{}, err
}
// 幂等重试必须返回原订单规格;运营在首单后改价或改配置时,不能让回执里的商品价格与 coin_spent 自相矛盾。
item.DurationDays = durationDays
item.CoinPrice = priceCoin
resource, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID)
if err != nil {
return resourcedomain.ResourceShopPurchaseReceipt{}, err
@ -534,7 +637,7 @@ func resourceShopItemSelectSQL() string {
return `
SELECT si.app_code, si.shop_item_id, si.resource_id, si.status, si.duration_days,
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.created_by_user_id, si.updated_by_user_id, si.created_at_ms, si.updated_at_ms,
` + resourceColumnsWithAlias("r") + `
@ -651,6 +754,9 @@ func validateResourceShopItemsCommand(command resourcedomain.ResourceShopItemsCo
if !resourcedomain.ValidShopDurationDays(item.DurationDays) {
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) {
return xerr.New(xerr.InvalidArgument, "resource shop effective time is invalid")
}

View File

@ -198,6 +198,13 @@ func normalizeAdminVipLevelCommands(levels []ledger.AdminVipLevelCommand, progra
benefit.ResourceType = resourcedomain.NormalizeResourceType(benefit.ResourceType)
benefit.ExecutionScope = strings.ToLower(strings.TrimSpace(benefit.ExecutionScope))
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 {
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)) {
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.ExecutionScope != ledger.VipExecutionScopeWallet {
return xerr.New(xerr.InvalidArgument, "daily_coin_rebate execution_scope must be wallet")

View File

@ -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) {
config, err := r.queryVipProgramConfig(ctx, querier.QueryRowContext(ctx, vipProgramSelectSQL(), appcode.FromContext(ctx)))
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
}
@ -282,6 +282,11 @@ func scanVipBenefit(scanner vipProgramScanner) (ledger.VipBenefit, error) {
if metadata.Valid {
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
}

View File

@ -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))
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 {
return ledger.PurchaseVipReceipt{}, err
}
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 {
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)
if err != nil {
if xerr.IsCode(err, xerr.VIPLevelNotFound) {
return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPPackageNotPurchasable, "vip package not found")
}
return ledger.PurchaseVipReceipt{}, err
}
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)
if err != nil {
@ -85,7 +88,7 @@ func (r *Repository) PurchaseVip(ctx context.Context, command ledger.PurchaseVip
return ledger.PurchaseVipReceipt{}, err
}
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)
@ -101,6 +104,9 @@ func (r *Repository) PurchaseVip(ctx context.Context, command ledger.PurchaseVip
"price_coin": level.PriceCoin,
"expires_at_ms": newExpiresAt,
"balance_after": coinBalanceAfter,
// 余额版本必须与本次交易后的快照一起固化,幂等重放不能读取已被后续交易推进的当前版本。
"balance_version": account.Version + 1,
"frozen_after": account.FrozenAmount,
"reward_group_id": level.RewardResourceGroupID,
"duration_ms": level.DurationMS,
"previous_expires_at_ms": current.ExpiresAtMS,
@ -203,6 +209,11 @@ func (r *Repository) PurchaseVip(ctx context.Context, command ledger.PurchaseVip
Vip: activation.VIP,
CoinSpent: level.PriceCoin,
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,
State: state,
}, nil
@ -521,6 +532,7 @@ func (r *Repository) vipPurchaseReceiptForTransaction(ctx context.Context, tx *s
VIPName string `json:"vip_name"`
StartedAtMS int64 `json:"started_at_ms"`
ExpiresAtMS int64 `json:"expires_at_ms"`
BalanceVersion int64 `json:"balance_version"`
}
_ = json.Unmarshal([]byte(txRow.MetadataJSON), &metadata)
if expiresAtMS <= 0 {
@ -552,6 +564,9 @@ func (r *Repository) vipPurchaseReceiptForTransaction(ctx context.Context, tx *s
if err != nil {
return ledger.PurchaseVipReceipt{}, err
}
// 2026-07-15 之后的新交易始终从交易 metadata 还原原始版本。旧交易没有该字段时保持 0
// Flutter 看到 version=0 必须走余额查询,不能拿当前账户版本伪装成本次历史回执。
balance.Version = metadata.BalanceVersion
rewards, err := r.rewardItemsByGrant(ctx, tx, grantID, expiresAtMS)
if err != nil {
return ledger.PurchaseVipReceipt{}, err
@ -562,6 +577,7 @@ func (r *Repository) vipPurchaseReceiptForTransaction(ctx context.Context, tx *s
Vip: vip,
CoinSpent: priceCoin,
CoinBalanceAfter: balance.AvailableAmount,
CoinBalance: balance,
RewardItems: rewards,
}, nil
}

View File

@ -238,29 +238,38 @@ func (r *Repository) EquipVipTrialCard(ctx context.Context, command ledger.Equip
defer func() { _ = tx.Rollback() }()
nowMS := time.Now().UnixMilli()
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 {
return ledger.VipTrialCard{}, ledger.VipState{}, err
}
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 会和后台撤销形成死锁。
// entitlement 同时承担并发有效性门禁,避免撤销在校验后抢先提交又被重新写回 equipment。
entitlement, err := r.getUserResourceEntitlementForUpdateTx(ctx, tx, command.EntitlementID)
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
}
card, err := r.queryVipTrialCardForUpdate(ctx, tx, command.UserID, command.EntitlementID, nowMS)
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 {
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 ||
entitlement.Status != resourcedomain.StatusActive || entitlement.EffectiveAtMS > nowMS ||
(entitlement.ExpiresAtMS > 0 && entitlement.ExpiresAtMS <= nowMS) || entitlement.RemainingQuantity <= 0 {
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.New(xerr.Conflict, "vip trial card entitlement is inactive")
card.Status != ledger.VipTrialCardStatusActive || card.EffectiveAtMS > nowMS || entitlement.RemainingQuantity <= 0 {
return ledger.VipTrialCard{}, ledger.VipState{}, xerr.New(xerr.VIPTrialCardNotFound, "vip trial card entitlement is inactive")
}
if resourcedomain.NormalizeResourceType(entitlement.Resource.ResourceType) != resourcedomain.TypeVIPTrialCard {
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,
created_at_ms, updated_at_ms
FROM user_vip_trial_cards
WHERE app_code = ? AND user_id = ? AND entitlement_id = ? AND status = ?
AND effective_at_ms <= ? AND expires_at_ms > ?
WHERE app_code = ? AND user_id = ? AND entitlement_id = ?
FOR UPDATE`,
appcode.FromContext(ctx), userID, entitlementID, ledger.VipTrialCardStatusActive, nowMS, nowMS,
appcode.FromContext(ctx), userID, entitlementID,
)
return scanVipTrialCard(row, nowMS)
}

View File

@ -19,7 +19,9 @@ func (r *Repository) UpdateVipUserSettings(ctx context.Context, command ledger.U
}
ctx = contextWithCommandApp(ctx, command.AppCode)
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")
}
@ -39,17 +41,39 @@ func (r *Repository) UpdateVipUserSettings(ctx context.Context, command ledger.U
if onlineProvided {
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, `
INSERT INTO user_vip_settings (
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
) VALUES (?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
room_entry_notice_enabled = IF(?, ?, room_entry_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 = ?`,
command.AppCode, command.UserID, roomValue, onlineValue, nowMS, nowMS,
roomProvided, roomValue, onlineProvided, onlineValue, nowMS,
command.AppCode, command.UserID, roomValue, onlineValue, hideProfileValue,
anonymousVisitValue, leaderboardInvisibleValue, nowMS, nowMS,
roomProvided, roomValue, onlineProvided, onlineValue,
hideProfileProvided, hideProfileValue, anonymousVisitProvided, anonymousVisitValue,
leaderboardInvisibleProvided, leaderboardInvisibleValue, nowMS,
); err != nil {
return ledger.VipUserSettings{}, err
}
@ -70,7 +94,8 @@ func (r *Repository) queryVipUserSettings(ctx context.Context, querier interface
}, userID int64, lock bool) (ledger.VipUserSettings, error) {
query := `
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
WHERE app_code = ? AND user_id = ?`
if lock {
@ -79,7 +104,9 @@ func (r *Repository) queryVipUserSettings(ctx context.Context, querier interface
var settings ledger.VipUserSettings
if err := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), userID).Scan(
&settings.AppCode, &settings.UserID, &settings.RoomEntryNoticeEnabled,
&settings.OnlineGlobalNoticeEnabled, &settings.UpdatedAtMS,
&settings.OnlineGlobalNoticeEnabled, &settings.HideProfileDataEnabled,
&settings.AnonymousProfileVisitEnabled, &settings.LeaderboardInvisibleEnabled,
&settings.UpdatedAtMS,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ledger.DefaultVipUserSettings(appcode.FromContext(ctx), userID), nil

View File

@ -349,6 +349,21 @@ func (s *Server) RevokeResourceGrant(ctx context.Context, req *walletv1.RevokeRe
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) {
items, err := s.svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
AppCode: req.GetAppCode(),

View File

@ -164,6 +164,7 @@ func resourceShopItemInputs(items []*walletv1.ResourceShopItemInput) []resourced
ResourceID: item.GetResourceId(),
Status: item.GetStatus(),
DurationDays: item.GetDurationDays(),
CoinPrice: item.GetCoinPrice(),
EffectiveFromMS: item.GetEffectiveFromMs(),
EffectiveToMS: item.GetEffectiveToMs(),
SortOrder: item.GetSortOrder(),

View File

@ -90,6 +90,7 @@ func (s *Server) PurchaseVip(ctx context.Context, req *walletv1.PurchaseVipReque
CoinBalanceAfter: receipt.CoinBalanceAfter,
RewardItems: vipRewardItemsToProto(receipt.RewardItems),
State: vipStateToProto(receipt.State),
CoinBalance: balanceToProto(receipt.CoinBalance),
}, nil
}
@ -255,9 +256,26 @@ func (s *Server) UpdateMyVipSettings(ctx context.Context, req *walletv1.UpdateMy
value := req.GetOnlineGlobalNoticeEnabled()
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{
AppCode: req.GetAppCode(), UserID: req.GetUserId(),
RoomEntryNoticeEnabled: roomEntryNoticeEnabled, OnlineGlobalNoticeEnabled: onlineGlobalNoticeEnabled,
HideProfileDataEnabled: hideProfileDataEnabled, AnonymousProfileVisitEnabled: anonymousProfileVisitEnabled,
LeaderboardInvisibleEnabled: leaderboardInvisibleEnabled,
})
if err != nil {
return nil, xerr.ToGRPCError(err)
@ -357,6 +375,9 @@ func vipUserSettingsToProto(settings ledger.VipUserSettings) *walletv1.VipUserSe
AppCode: settings.AppCode, UserId: settings.UserID,
RoomEntryNoticeEnabled: settings.RoomEntryNoticeEnabled,
OnlineGlobalNoticeEnabled: settings.OnlineGlobalNoticeEnabled,
HideProfileDataEnabled: settings.HideProfileDataEnabled,
AnonymousProfileVisitEnabled: settings.AnonymousProfileVisitEnabled,
LeaderboardInvisibleEnabled: settings.LeaderboardInvisibleEnabled,
UpdatedAtMs: settings.UpdatedAtMS,
}
}
@ -439,6 +460,7 @@ func vipBenefitToProto(benefit ledger.VipBenefit) *walletv1.VipBenefit {
MetadataJson: benefit.MetadataJSON,
CreatedAtMs: benefit.CreatedAtMS,
UpdatedAtMs: benefit.UpdatedAtMS,
Presentation: vipBenefitPresentationToProto(benefit.Presentation),
}
}
@ -471,11 +493,62 @@ func vipBenefitsFromProto(benefits []*walletv1.VipBenefit) []ledger.VipBenefit {
AutoEquip: benefit.GetAutoEquip(),
SortOrder: benefit.GetSortOrder(),
MetadataJSON: benefit.GetMetadataJson(),
Presentation: vipBenefitPresentationFromProto(benefit.GetPresentation()),
})
}
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 {
if card.TrialCardID == "" {
return nil